use std::sync::Arc;
use numr::dtype::DType;
use numr::runtime::Runtime;
use numr::tensor::Tensor;
use boostr::model::audio::voxcpm::model::VoxCpm2Model;
use boostr::model::audio::voxcpm::model::config::AUDIO_START_ID;
use crate::error::{Error, Result};
use crate::voxcpm::engine::batch::{Job, JobVoice, Sampler};
use crate::voxcpm::engine::reply::Reply;
use crate::voxcpm::engine::request::{Badcase, PromptSource, SynthRequest, VoiceSource};
use crate::voxcpm::engine::types::{
MAX_LEN_CAP, VoxCpm2Engine, VoxCpm2RenderClient, ZERO_SHOT_VOICE_ID,
};
use crate::voxcpm::tokenizer::{is_blank, request_ids};
struct ResolvedPrompt<'a, R: Runtime> {
feat: Arc<Tensor<R>>,
patches: usize,
text: &'a str,
}
impl<R> VoxCpm2Engine<R>
where
R: Runtime<DType = DType> + 'static,
R::Client: VoxCpm2RenderClient<R>,
VoxCpm2Model<R>: Send + Sync,
Tensor<R>: Send + Sync,
{
pub(super) fn plan(&self, request: &SynthRequest<'_, R>, reply: Reply) -> Result<Job<R>> {
let (voice, ref_len) = self.resolve_voice(request.voice)?;
let prompt = request
.prompt
.map(|prompt| self.resolve_prompt(prompt))
.transpose()?;
if is_blank(request.text) {
return Err(Error::InvalidArgument {
arg: "text",
reason: "input text must not be empty".into(),
});
}
let (mut text_token_ids, target_len) = request_ids(
&self.inner.tokenizer,
request.text,
prompt.as_ref().map(|prompt| prompt.text),
);
text_token_ids.push(AUDIO_START_ID);
check_badcase(request.badcase)?;
let budget = (target_len as f64 * request.badcase.ratio + 10.0) as usize;
let max_len = budget.min(MAX_LEN_CAP);
let prompt_len = prompt.as_ref().map_or(0, |prompt| prompt.patches);
let seq_len = seq_len_for(ref_len, text_token_ids.len(), prompt_len);
Ok(Job {
voice,
prompt: prompt.map(|prompt| prompt.feat),
seed: request.seed.unwrap_or(self.inner.options.seed),
sampler: self.sampler(request)?,
text_token_ids,
max_len,
seq_len,
target_len,
badcase: request.badcase,
attempt: 1,
reply,
})
}
fn sampler(&self, request: &SynthRequest<'_, R>) -> Result<Sampler> {
let options = &self.inner.options;
let sampler = Sampler {
n_timesteps: request.n_timesteps.unwrap_or(options.n_timesteps),
cfg_value: request.cfg_value.unwrap_or(options.cfg_value),
};
check_sampler(sampler)?;
Ok(sampler)
}
fn resolve_voice(&self, voice: VoiceSource<'_, R>) -> Result<(JobVoice<R>, Option<usize>)> {
match voice {
VoiceSource::ZeroShot => Ok((JobVoice::ZeroShot, None)),
VoiceSource::Named(id) if id == ZERO_SHOT_VOICE_ID => Ok((JobVoice::ZeroShot, None)),
VoiceSource::Named(id) => {
let voice = self
.inner
.voices
.get(id)
.ok_or_else(|| Error::InvalidArgument {
arg: "voice",
reason: format!(
"unknown voice {id:?}; available voices: [{}], or {:?} for \
zero-shot rendering",
self.inner
.voices
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", "),
ZERO_SHOT_VOICE_ID,
),
})?;
let patches = voice.ref_feat.shape().first().copied().unwrap_or(0);
Ok((JobVoice::Named(id.to_string()), Some(patches)))
}
VoiceSource::Reference(reference) => {
if reference.engine != self.inner.id {
return Err(Error::InvalidArgument {
arg: "voice",
reason: "this reference was encoded by a different VoxCPM2 engine; \
encode it again with this engine's encode_reference"
.into(),
});
}
Ok((
JobVoice::Reference(Arc::clone(&reference.feat)),
Some(reference.patches()),
))
}
}
}
fn resolve_prompt<'a>(&self, prompt: PromptSource<'a, R>) -> Result<ResolvedPrompt<'a, R>> {
if prompt.prompt.engine != self.inner.id {
return Err(Error::InvalidArgument {
arg: "prompt",
reason: "this prompt was encoded by a different VoxCPM2 engine; encode it \
again with this engine's encode_prompt"
.into(),
});
}
if is_blank(prompt.text) {
return Err(Error::InvalidArgument {
arg: "prompt_text",
reason: format!(
"prompt transcript {:?} holds no words; pass the words spoken in the \
prompt clip",
prompt.text
),
});
}
Ok(ResolvedPrompt {
feat: Arc::clone(&prompt.prompt.feat),
patches: prompt.prompt.patches(),
text: prompt.text,
})
}
}
fn check_badcase(badcase: Badcase) -> Result<()> {
if badcase.max_attempts == 0 {
return Err(Error::InvalidArgument {
arg: "badcase.max_attempts",
reason: "0 takes; pass at least 1, or Badcase::no_retry to retry none".into(),
});
}
if !(badcase.ratio.is_finite() && badcase.ratio > 0.0) {
return Err(Error::InvalidArgument {
arg: "badcase.ratio",
reason: format!(
"ratio {} is not finite and positive; pass a ratio such as 6.0",
badcase.ratio
),
});
}
Ok(())
}
fn check_sampler(sampler: Sampler) -> Result<()> {
if sampler.n_timesteps == 0 {
return Err(Error::InvalidArgument {
arg: "n_timesteps",
reason: "0 solver steps per patch; pass at least 1".into(),
});
}
if !sampler.cfg_value.is_finite() {
return Err(Error::InvalidArgument {
arg: "cfg_value",
reason: format!(
"guidance scale {} is not finite; pass a finite scale such as 2.0",
sampler.cfg_value
),
});
}
Ok(())
}
fn seq_len_for(ref_len: Option<usize>, text_len: usize, prompt_len: usize) -> usize {
let prefix = match ref_len {
Some(t_ref) => t_ref + 2,
None => 0,
};
prefix + text_len + prompt_len
}
#[cfg(test)]
mod tests {
use super::*;
use boostr::model::audio::voxcpm::model::SequenceLayout;
#[test]
fn zero_attempts_and_a_bad_ratio_are_refused() {
assert!(check_badcase(Badcase::default()).is_ok());
assert!(check_badcase(Badcase::no_retry(6.0)).is_ok());
let none = Badcase {
max_attempts: 0,
..Badcase::default()
};
assert!(check_badcase(none).is_err_and(|e| e.to_string().contains("max_attempts")));
for ratio in [0.0, -1.0, f64::NAN] {
assert!(check_badcase(Badcase::no_retry(ratio)).is_err(), "{ratio}");
}
}
#[test]
fn zero_steps_and_a_scale_that_is_not_finite_are_refused() {
let ok = Sampler {
n_timesteps: 16,
cfg_value: 1.6,
};
assert!(check_sampler(ok).is_ok());
let no_steps = check_sampler(Sampler {
n_timesteps: 0,
..ok
});
assert!(no_steps.is_err_and(|e| e.to_string().contains("n_timesteps")));
let nan = check_sampler(Sampler {
cfg_value: f32::NAN,
..ok
});
assert!(nan.is_err_and(|e| e.to_string().contains("cfg_value")));
}
#[test]
fn seq_len_for_with_reference_adds_t_ref_plus_two() {
assert_eq!(seq_len_for(Some(40), 12, 0), 40 + 2 + 12);
}
#[test]
fn seq_len_for_zero_shot_is_text_len_only() {
assert_eq!(seq_len_for(None, 12, 0), 12);
}
#[test]
fn seq_len_for_adds_the_prompt_tail() {
assert_eq!(seq_len_for(Some(40), 12, 9), 40 + 2 + 12 + 9);
assert_eq!(seq_len_for(None, 12, 9), 12 + 9);
}
#[test]
fn seq_len_for_matches_the_prefill_layout() {
let text = [11u32, 22, AUDIO_START_ID];
for (ref_len, prompt_len) in [(None, 0), (Some(5), 0), (None, 3), (Some(5), 3)] {
let layout =
SequenceLayout::build(ref_len.unwrap_or(0), &text, prompt_len).expect("layout");
assert_eq!(
seq_len_for(ref_len, text.len(), prompt_len),
layout.seq_len(),
"ref {ref_len:?}, prompt {prompt_len}"
);
}
}
}