Files
piperust/src/assets.rs
T
2026-10-16 11:36:02 +01:00

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)
}