use splintr::{AnyTokenizer, Tokenize};
use crate::corpus::options::{CorpusOptions, TextTokenizer, check_max_speech_duration};
use crate::corpus::utterance::{Utterance, pack_utterances_with_layout};
use crate::error::{Error, Result};
use crate::vad::{SpeechSegment, speech_timestamps};
use crate::whisper::{TranscribeOptions, WhisperBundle};
use boostr::model::audio::neucodec::NeuCodecEncoder;
use boostr::model::audio::neucodec::client::NeuCodecClient;
use boostr::model::audio::vad::SileroVad;
use boostr::model::speech_lm::codec::CodecVocab;
use boostr::model::speech_lm::layout::SpeechLayout;
use boostr::model::speech_lm::layout::expressive_tts::{CODEBOOK_SIZE, ExpressiveTtsLayout};
use boostr::model::speech_lm::vocab::SpeechVocab;
use numr::dtype::DType;
use numr::ops::MatmulOps;
use numr::runtime::Runtime;
pub struct SpeechCorpusBuilder<R: Runtime> {
vad: Option<SileroVad<R>>,
whisper: Option<WhisperBundle<R>>,
codec: NeuCodecEncoder<R>,
tokenizer: AnyTokenizer,
layout: SpeechLayout,
}
fn codec_vocab<R: Runtime<DType = DType>>(codec: &NeuCodecEncoder<R>) -> Result<CodecVocab> {
let quantizer = codec.quantizer().config();
let codebook_size =
usize::try_from(quantizer.layer_config()?.codebook_size()).map_err(|_| {
Error::ModelError {
reason: "NeuCodec codebook size does not fit in usize".to_string(),
}
})?;
Ok(CodecVocab::new(quantizer.num_quantizers, codebook_size)?)
}
impl<R: Runtime<DType = DType>> SpeechCorpusBuilder<R> {
pub fn new(
vad: SileroVad<R>,
whisper: WhisperBundle<R>,
codec: NeuCodecEncoder<R>,
tokenizer: TextTokenizer<'_>,
) -> Result<Self> {
let tokenizer = tokenizer.resolve()?;
let text_vocab_size = Tokenize::vocab_size(&tokenizer);
let codec_vocab = codec_vocab(&codec)?;
let vocab = SpeechVocab::with_default_specials(text_vocab_size, codec_vocab)?;
Ok(Self {
vad: Some(vad),
whisper: Some(whisper),
codec,
tokenizer,
layout: SpeechLayout::Native(vocab),
})
}
pub fn new_expressive_tts(
vad: SileroVad<R>,
whisper: WhisperBundle<R>,
codec: NeuCodecEncoder<R>,
tokenizer: TextTokenizer<'_>,
) -> Result<Self> {
let codec_vocab = codec_vocab(&codec)?;
if codec_vocab.num_codebooks() != 1 || codec_vocab.codebook_size() != CODEBOOK_SIZE {
return Err(Error::InvalidArgument {
arg: "codec",
reason: format!(
"this encoder has {} codebooks of {} codes, but the ExpressiveTTS layout \
holds exactly 1 codebook of {CODEBOOK_SIZE} codes (<|s_0|> .. <|s_65535|>); \
a differently-shaped codec cannot be expressed in it",
codec_vocab.num_codebooks(),
codec_vocab.codebook_size()
),
});
}
let tokenizer = tokenizer.resolve()?;
Ok(Self {
vad: Some(vad),
whisper: Some(whisper),
codec,
tokenizer,
layout: SpeechLayout::ExpressiveTts(ExpressiveTtsLayout::new()),
})
}
fn maybe_enhance<'a>(
&self,
samples: &'a [f32],
opts: &CorpusOptions<'_>,
) -> Result<std::borrow::Cow<'a, [f32]>> {
let Some(enhance_opts) = opts.enhance else {
return Ok(std::borrow::Cow::Borrowed(samples));
};
let rate = u32::try_from(opts.sample_rate).map_err(|_| Error::InvalidArgument {
arg: "opts.sample_rate",
reason: format!("{} does not fit a u32 sample rate", opts.sample_rate),
})?;
let (out, report) = crate::enhance::enhance(samples, rate, enhance_opts)?;
tracing::info!(
input_lufs = report.input_lufs,
output_lufs = report.output_lufs,
input_floor_dbfs = report.input_noise_floor_dbfs,
output_floor_dbfs = report.output_noise_floor_dbfs,
limiting_db = report.limiter_reduction_db,
reached_target = report.reached_target,
"enhanced recording"
);
Ok(std::borrow::Cow::Owned(out))
}
pub fn new_scripted_expressive_tts(
codec: NeuCodecEncoder<R>,
tokenizer: TextTokenizer<'_>,
) -> Result<Self> {
Ok(Self {
vad: None,
whisper: None,
codec,
tokenizer: tokenizer.resolve()?,
layout: SpeechLayout::ExpressiveTts(ExpressiveTtsLayout::new()),
})
}
pub fn scripted_utterance<C>(
&self,
client: &C,
samples: &[f32],
text: &str,
opts: &CorpusOptions<'_>,
) -> Result<Utterance>
where
C: NeuCodecClient<R> + MatmulOps<R>,
R::Client: NeuCodecClient<R>,
{
if opts.sample_rate == 0 {
return Err(Error::InvalidArgument {
arg: "opts.sample_rate",
reason: "sample rate must be non-zero".to_string(),
});
}
let text = text.trim();
if text.is_empty() {
return Err(Error::InvalidArgument {
arg: "text",
reason: "a scripted utterance needs its transcript".to_string(),
});
}
if samples.is_empty() {
return Err(Error::InvalidArgument {
arg: "samples",
reason: "no samples".to_string(),
});
}
let samples = self.maybe_enhance(samples, opts)?;
let samples = samples.as_ref();
let text = match opts.speaker {
Some(speaker) => format!("{speaker}: {text}"),
None => text.to_string(),
};
let text_tokens = self.tokenizer.encode(&text);
let frames = self.codec.encode_frames(client, samples, client.device())?;
Ok(Utterance {
segment: SpeechSegment {
start: 0,
end: samples.len(),
},
text,
text_tokens,
frames,
})
}
pub fn layout(&self) -> &SpeechLayout {
&self.layout
}
pub fn vocab(&self) -> Option<&SpeechVocab> {
self.layout.vocab()
}
pub fn tokenizer(&self) -> &AnyTokenizer {
&self.tokenizer
}
pub fn utterances<C>(
&self,
client: &C,
samples: &[f32],
opts: &CorpusOptions<'_>,
) -> Result<Vec<Utterance>>
where
C: NeuCodecClient<R> + MatmulOps<R>,
R::Client: NeuCodecClient<R>,
{
if opts.sample_rate == 0 {
return Err(Error::InvalidArgument {
arg: "opts.sample_rate",
reason: "sample rate must be non-zero".to_string(),
});
}
check_max_speech_duration(opts.vad.max_speech_duration_s)?;
let samples = self.maybe_enhance(samples, opts)?;
let samples = samples.as_ref();
let vad = self.vad.as_ref().ok_or_else(|| Error::InvalidArgument {
arg: "builder",
reason: "this builder was constructed for a SCRIPTED corpus and has no VAD; \
use `scripted_utterance` with the known transcript"
.to_string(),
})?;
let segments = speech_timestamps(vad, client, samples, &opts.vad)?;
let min_samples = if opts.min_utterance_secs > 0.0 {
(f64::from(opts.min_utterance_secs) * opts.sample_rate as f64).ceil() as usize
} else {
0
};
let transcribe_opts = TranscribeOptions {
language: opts.language,
translate: false,
max_new_tokens: opts.max_new_tokens,
};
let mut out = Vec::with_capacity(segments.len());
let mut empty_transcripts = 0usize;
for (i, segment) in segments.iter().enumerate() {
if segment.start > segment.end || segment.end > samples.len() {
return Err(Error::InvalidArgument {
arg: "samples",
reason: format!(
"segment {i} covers samples {}..{}, outside the {} samples given",
segment.start,
segment.end,
samples.len()
),
});
}
if segment.len() < min_samples {
continue;
}
let slice = &samples[segment.start..segment.end];
let whisper = self
.whisper
.as_ref()
.ok_or_else(|| Error::InvalidArgument {
arg: "builder",
reason: "this builder was constructed for a SCRIPTED corpus and has no \
recognizer; use `scripted_utterance` with the known transcript"
.to_string(),
})?;
let transcription =
whisper.transcribe(client, slice, opts.sample_rate, &transcribe_opts)?;
let text = transcription.text.trim();
if text.is_empty() {
empty_transcripts += 1;
continue;
}
let text = match opts.speaker {
Some(speaker) => format!("{speaker}: {text}"),
None => text.to_string(),
};
let text_tokens = self.tokenizer.encode(&text);
let frames = self.codec.encode_frames(client, slice, client.device())?;
out.push(Utterance {
segment: *segment,
text,
text_tokens,
frames,
});
}
if empty_transcripts > 0 {
tracing::debug!(
empty_transcripts,
segments = segments.len(),
kept = out.len(),
"dropped segments that transcribed to no text"
);
}
Ok(out)
}
pub fn pack(&self, utterances: &[Utterance], opts: &CorpusOptions<'_>) -> Result<Vec<u32>> {
pack_utterances_with_layout(&self.layout, utterances, opts)
}
}