//! Startup asset bootstrap. //! //! The filler (end-of-utterance) and backchannel (during-utterance) clip banks //! are plain `*.raw` PCM files. If a clip is missing at startup it is //! synthesized with the configured TTS engine and written to disk, so a fresh //! checkout works without a manual generation step. Existing clips are never //! overwritten. use crate::audio::SampleRingBuffer; use crate::tts::TtsEngine; use anyhow::{Context, Result}; use std::path::Path; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; /// Default end-of-utterance filler clips: `(name, text)`. pub const FILLER_CLIPS: &[(&str, &str)] = &[ ("uhm", "Uhm..."), ("eh", "Eh..."), ("mmm", "Mmm..."), ("aver", "A ver..."), ("bueno", "Bueno..."), ("pues", "Pues..."), ("vaya", "Vaya..."), ]; /// Default during-utterance backchannel clips: `(name, text)`. pub const BACKCHANNEL_CLIPS: &[(&str, &str)] = &[ ("mmhmm", "Mm-hmm."), ("aja", "Ajá."), ("claro", "Claro."), ("yaveo", "Ya veo."), ]; /// Capacity (in samples) of the scratch buffer used to capture one synthesized /// clip. 30 s at 44.1 kHz is far beyond any filler / backchannel length. const SYNTH_BUFFER_SAMPLES: usize = 30 * 44_100; /// Ensure every clip in `clips` exists as `/.raw`, synthesizing the /// missing ones with `tts`. Creates `dir` if needed. Returns the number of /// clips generated. Never fails the caller: individual errors are logged. pub async fn ensure_clips( dir: &Path, clips: &[(&str, &str)], tts: &dyn TtsEngine, label: &str, ) -> usize { if let Err(e) = std::fs::create_dir_all(dir) { warn!( label, dir = %dir.display(), error = %e, "cannot create clip directory — skipping auto-generation" ); return 0; } let missing: Vec<&(&str, &str)> = clips .iter() .filter(|(name, _)| !dir.join(format!("{name}.raw")).exists()) .collect(); if missing.is_empty() { return 0; } info!( label, dir = %dir.display(), missing = missing.len(), engine = tts.name(), "generating missing clips at startup" ); let mut generated = 0usize; for (name, text) in missing { let path = dir.join(format!("{name}.raw")); match synthesize_to_file(tts, text, &path).await { Ok(samples) => { info!( label, name, samples, path = %path.display(), "generated clip" ); generated += 1; } Err(e) => warn!(label, name, error = %e, "failed to generate clip"), } } generated } /// Synthesize `text` and write the raw 16-bit LE mono PCM to `path`. async fn synthesize_to_file(tts: &dyn TtsEngine, text: &str, path: &Path) -> Result { let buf = SampleRingBuffer::new(SYNTH_BUFFER_SAMPLES); let cancel = CancellationToken::new(); tts.synthesize(text, &buf, &cancel) .await .context("synthesis failed")?; let n = buf.len(); if n == 0 { anyhow::bail!("synthesis produced no samples"); } let mut samples = vec![0i16; n]; buf.pop_into(&mut samples); let mut bytes = Vec::with_capacity(n * 2); for s in &samples { bytes.extend_from_slice(&s.to_le_bytes()); } std::fs::write(path, &bytes) .with_context(|| format!("failed to write {}", path.display()))?; Ok(n) }