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]>
200 lines
6.1 KiB
Rust
200 lines
6.1 KiB
Rust
//! 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
|
|
}
|
|
}
|