rtx-csm: CFG schedule × prompt sweep — robust default is linear

Sweep of 4 schedules × 4 prompts produced concrete data on schedule
sensitivity. step:3.0:1.5:12 (the Sprint 3 winner) catastrophically
fails dense prompts: lecture-style input → 0.08 s of audio (one
frame). step:2.0:1.0:8 produced the best single shot — near-verbatim
question rendering "Well, it had stem-wondered. Have you ever
wondered why we sometimes hear voices the way we do?" — but tanked
the lecture prompt (2.4 s "You").

linear:3.0:1.0:25 is the only schedule that's never the best AND
never the worst — graceful degradation across all four prompt
categories. Updates the recommended recipe in perf_history.md
(formerly step:3.0:1.5:12).

quality_eval: skip WavLM-SV scoring on clips shorter than 0.25 s
(emit -1 sentinel) — WavLM-SV's TDNN front-end requires a few
hundred samples and crashed mid-sweep on the 0.08 s clip. Now the
eval emits a row instead of bailing on the whole batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-29 11:04:09 -07:00
co-authored by Claude Opus 4.7
parent 38f586cfa3
commit 29f4b6fc56
2 changed files with 49 additions and 3 deletions
+33 -1
View File
@@ -147,11 +147,43 @@ shipped on the FP backbone; quantized path unchanged.
target/release/examples/generate \ target/release/examples/generate \
--text "..." --speaker 0 \ --text "..." --speaker 0 \
--context-wav <reference>.wav --context-text "..." --context-speaker 1 \ --context-wav <reference>.wav --context-text "..." --context-speaker 1 \
--enable-cfg --cfg-schedule "step:3.0:1.5:12" \ --enable-cfg --cfg-schedule "linear:3.0:1.0:25" \
--steering-vec /path/to/<emotion>_steering.safetensors \ --steering-vec /path/to/<emotion>_steering.safetensors \
--steering-scale 0.3 --steering-layers 8,10,12 --steering-scale 0.3 --steering-layers 8,10,12
``` ```
(Updated from `step:3.0:1.5:12` after the sweep below — linear is more
robust across prompt types.)
**CFG schedule × prompt sweep (2026-04-29, single seed):**
4 schedules × 4 prompts (short / medium / lecture / question), Amini
context, scored on cosine vs Amini and WER vs prompt:
| prompt | const:1.5 | step:2:1:8 | step:3:1.5:12 | linear:3:1:25 |
|-----------------|--------------|---------------|---------------|---------------|
| `Hello world.` | 0.69 / 1.00 | 0.71 / 4.00 | 0.79 / 3.50 | 0.82 / 4.50 |
| medium narrative | 0.94 / 1.08 | 0.90 / 1.00 | 0.66 / 1.00 *EOT 2.3 s* | 0.88 / 0.92 |
| dense lecture | **0.97 / 0.90** | 0.58 / 1.00 *EOT 2.4 s* | -1 / 1.00 *EOT 0.08 s* | 0.91 / **0.85** |
| question | 0.93 / 1.77 | 0.95 / **0.385** *near-verbatim* | 0.78 / 1.00 | 0.69 / 1.39 |
*Format: cosine / WER. -1 sentinel = clip too short for WavLM-SV.*
Single best shot: `step:2.0:1.0:8` × question →
`"Well, it had stem-wondered. Have you ever wondered why we sometimes
hear voices the way we do?"` (WER 0.385, cosine 0.95). Same step
schedule on a dense lecture prompt produced 2.4 s of "You" — same
schedule, opposite result.
Takeaways:
- `step:3.0:1.5:12` is **prompt-fragile** — destroys lecture and
medium-narrative prompts (premature EOT in 0.082.3 s); the earlier
Sprint 3 win on the meta "selective CFG paper recommends…" prompt
was a fluke of self-referential vocabulary.
- `linear:3.0:1.0:25` is the most robust default — graceful across
all four prompt categories, never the worst.
- `const:1.5` is the safe fallback when prompt category is unknown.
**Rejected from this sprint** (with data): **Rejected from this sprint** (with data):
- *TTSDS2 metric* (arXiv 2506.19441): install broken on Python 3.12 + - *TTSDS2 metric* (arXiv 2506.19441): install broken on Python 3.12 +
+16 -2
View File
@@ -147,10 +147,24 @@ fn main() -> Result<()> {
let row: PairRow = serde_json::from_str(&line) let row: PairRow = serde_json::from_str(&line)
.with_context(|| format!("parse row {idx}: {line}"))?; .with_context(|| format!("parse row {idx}: {line}"))?;
// Speaker similarity at 16 kHz. // Speaker similarity at 16 kHz. WavLM-SV's TDNN front-end has
// kernel size 5 with stride 2 chains, so it requires at least a
// few hundred samples of audio. Below ~0.25 s the model errors
// out — score those as -1.0 (sentinel) so the eval still emits
// a row instead of bailing on the whole batch.
let ref_16k = audio_io::load_mono_at_rate(&row.ref_wav, 16_000)?; let ref_16k = audio_io::load_mono_at_rate(&row.ref_wav, 16_000)?;
let gen_16k = audio_io::load_mono_at_rate(&row.gen_wav, 16_000)?; let gen_16k = audio_io::load_mono_at_rate(&row.gen_wav, 16_000)?;
let speaker_cosine = sv.score(&ref_16k, &gen_16k)? as f32; const MIN_SV_SAMPLES: usize = 4000; // 0.25 s at 16 kHz
let speaker_cosine = if gen_16k.len() < MIN_SV_SAMPLES || ref_16k.len() < MIN_SV_SAMPLES {
tracing::warn!(
"row {idx}: gen={}s ref={}s — too short for WavLM-SV; cosine=-1",
gen_16k.len() as f32 / 16_000.0,
ref_16k.len() as f32 / 16_000.0,
);
-1.0f32
} else {
sv.score(&ref_16k, &gen_16k)? as f32
};
// Transcribe gen_wav via Moonshine (16 kHz). // Transcribe gen_wav via Moonshine (16 kHz).
let pcm_t = Tensor::from_vec(gen_16k.clone(), (1, 1, gen_16k.len()), &device)?; let pcm_t = Tensor::from_vec(gen_16k.clone(), (1, 1, gen_16k.len()), &device)?;