first
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
//! 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)
|
||||
}
|
||||
Reference in New Issue
Block a user