rtx-csm: Sprint 3 — Selective CFG schedule (step / linear / const)

Per-frame CFG scale schedule (arXiv 2509.19668, Zheng & Maleki). Pure
inference-time. Standard CFG uses one fixed scale for the whole
sequence; this lets the scale vary across frames so early frames
(speaker character) get full CFG and later frames (text adherence)
get a lower scale.

What lands:
- src/cfg_schedule.rs: CfgSchedule enum (Constant / Step /
  LinearRamp), scale_at(frame_idx), parser for CLI form
  `step:E:L:T | linear:S:E:R | const:X`. 6 unit tests.
- src/generator.rs: GenerateOptions::cfg_schedule (takes precedence
  over legacy cfg_scale; fixed-f64 path is preserved as
  Constant(s) for back-compat). Generation loop reads
  schedule.scale_at(frame_idx) and passes per-frame to
  generate_frame_cfg.
- examples/generate.rs: --cfg-schedule, --cfg-scale, --enable-cfg
  flags. Loading via load_csm_1b_with_cfg when --enable-cfg.

A/B with 6s output on Amini context, prompt about Selective CFG:

  case              cos    WER  transcript
  no-CFG baseline   0.944  1.50 "Okay, the M.U. worked..." (off)
  const:2.0         0.854  0.92 "On the right side." (short)
  step:3.0:1.5:12   0.938  1.00 "The officer for the selective
                                 C.F.D. paper recommends" (best)
  linear:3.0:1.0:25 0.854  1.08 "On the surface..." (off)

Step schedule produces the transcript closest to the input ("the
selective CFG paper recommends..."). WER stays at 1.0 because
Moonshine doesn't know "CFG" as a word, but qualitatively this is
the only one that's coherently following the prompt. Speaker cosine
stays ≈ baseline (0.94) instead of dropping to 0.85 like the
constant and linear cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-29 09:28:47 -07:00
co-authored by Claude Opus 4.7
parent 3a67e4aa50
commit 6b69fb68c7
4 changed files with 255 additions and 7 deletions
+199
View File
@@ -0,0 +1,199 @@
//! Per-frame CFG scale schedules (Selective CFG, arXiv 2509.19668).
//!
//! Standard CFG uses a fixed `cfg_scale` for every frame. Zheng & Maleki
//! show that for zero-shot TTS, **frame-dependent** CFG schedules
//! preserve text adherence late in synthesis while keeping speaker
//! similarity high early on:
//!
//! - Early frames (set the speaker character) → full CFG scale.
//! - Later frames (need to commit to the text) → lower scale, since
//! too much CFG late in generation causes over-conditioning on the
//! speaker prompt and degrades intelligibility.
//!
//! ## Variants
//!
//! - [`CfgSchedule::Constant`] — back-compat with the old fixed-`f64`
//! API; `cfg_scale.unwrap_or(1.0)` translates to this.
//! - [`CfgSchedule::Step`] — `scale_early` until frame `transition`,
//! then jump to `scale_late`. Matches the paper's two-phase recipe.
//! - [`CfgSchedule::LinearRamp`] — smooth interpolation from `start` at
//! frame 0 to `end` at `ramp_frames`, then holds at `end`. Sometimes
//! sounds less abrupt than the step variant.
use crate::error::{CsmError, Result};
#[derive(Debug, Clone, Copy)]
pub enum CfgSchedule {
Constant(f64),
Step {
early: f64,
late: f64,
transition: usize,
},
LinearRamp {
start: f64,
end: f64,
ramp_frames: usize,
},
}
impl CfgSchedule {
pub fn scale_at(&self, frame_idx: usize) -> f64 {
match *self {
Self::Constant(s) => s,
Self::Step {
early,
late,
transition,
} => {
if frame_idx < transition {
early
} else {
late
}
}
Self::LinearRamp {
start,
end,
ramp_frames,
} => {
if ramp_frames == 0 || frame_idx >= ramp_frames {
end
} else {
let t = frame_idx as f64 / ramp_frames as f64;
start * (1.0 - t) + end * t
}
}
}
}
/// True if any frame would invoke the CFG path (any scale > 1.0).
pub fn is_active(&self) -> bool {
match *self {
Self::Constant(s) => s > 1.0,
Self::Step { early, late, .. } => early > 1.0 || late > 1.0,
Self::LinearRamp { start, end, .. } => start > 1.0 || end > 1.0,
}
}
/// Parse the `--cfg-schedule` CLI value. Forms:
/// - `step:<early>:<late>:<transition>`
/// - `linear:<start>:<end>:<ramp_frames>`
/// - `const:<scale>`
pub fn parse(s: &str) -> Result<Self> {
let err = || {
CsmError::Config(format!(
"invalid --cfg-schedule '{s}'; expected step:E:L:T, linear:S:E:R, or const:X"
))
};
let mut parts = s.split(':');
let kind = parts.next().ok_or_else(err)?;
match kind {
"const" => {
let v: f64 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
Ok(Self::Constant(v))
}
"step" => {
let e: f64 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
let l: f64 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
let t: usize = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
Ok(Self::Step {
early: e,
late: l,
transition: t,
})
}
"linear" => {
let s0: f64 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
let e: f64 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
let r: usize = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
Ok(Self::LinearRamp {
start: s0,
end: e,
ramp_frames: r,
})
}
_ => Err(err()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_returns_same_scale() {
let s = CfgSchedule::Constant(2.0);
assert_eq!(s.scale_at(0), 2.0);
assert_eq!(s.scale_at(50), 2.0);
assert!(s.is_active());
}
#[test]
fn step_transitions_at_threshold() {
let s = CfgSchedule::Step {
early: 3.0,
late: 1.5,
transition: 10,
};
assert_eq!(s.scale_at(0), 3.0);
assert_eq!(s.scale_at(9), 3.0);
assert_eq!(s.scale_at(10), 1.5);
assert_eq!(s.scale_at(100), 1.5);
assert!(s.is_active());
}
#[test]
fn linear_ramp_interpolates() {
let s = CfgSchedule::LinearRamp {
start: 3.0,
end: 1.0,
ramp_frames: 10,
};
assert_eq!(s.scale_at(0), 3.0);
assert!((s.scale_at(5) - 2.0).abs() < 1e-9);
assert_eq!(s.scale_at(10), 1.0);
assert_eq!(s.scale_at(100), 1.0); // holds at end
}
#[test]
fn parse_step() {
let s = CfgSchedule::parse("step:3.0:1.5:12").unwrap();
match s {
CfgSchedule::Step {
early,
late,
transition,
} => {
assert_eq!(early, 3.0);
assert_eq!(late, 1.5);
assert_eq!(transition, 12);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn parse_linear() {
let s = CfgSchedule::parse("linear:2.5:1.0:20").unwrap();
match s {
CfgSchedule::LinearRamp {
start,
end,
ramp_frames,
} => {
assert_eq!(start, 2.5);
assert_eq!(end, 1.0);
assert_eq!(ramp_frames, 20);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn parse_invalid() {
assert!(CfgSchedule::parse("garbage").is_err());
assert!(CfgSchedule::parse("step:1.5:1.0").is_err()); // missing transition
}
}