117 lines
3.5 KiB
Rust
117 lines
3.5 KiB
Rust
//! 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 `<dir>/<name>.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<usize> {
|
|
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)
|
|
}
|