use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::thread;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use chrono::Utc;
use clap::Parser;
use crate::voice::capture::{
install_ctrl_c_handler, run_capture, CaptureOpts, CaptureSummary, TerminationReason,
};
use crate::voice::models::SPEAKER_WESPEAKER_EN;
use crate::voice::{
captures_dir, speaker_file, CpalAudioSource, EnrolledSpeaker, WespeakerEmbedder,
};
pub const DEFAULT_IDLE_AFTER_SECS: u32 = 2;
pub const DEFAULT_MAX_SECS: u32 = 30;
#[derive(Parser)]
pub struct EnrollCommand {
#[arg(long, default_value = "default")]
pub name: String,
#[arg(long, default_value_t = DEFAULT_IDLE_AFTER_SECS)]
pub idle_after: u32,
#[arg(long, default_value_t = DEFAULT_MAX_SECS)]
pub max_secs: u32,
#[arg(long)]
pub device: Option<String>,
#[arg(long)]
pub speaker_model: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
impl EnrollCommand {
pub fn execute(self) -> Result<()> {
let speaker_model = resolve_speaker_model_path(self.speaker_model.as_deref())?;
let dest = speaker_file(&self.name)?;
if dest.is_file() && !self.force {
bail!(
"speaker {} already enrolled at {}; pass --force to overwrite",
self.name,
dest.display()
);
}
let captures = captures_dir()?;
std::fs::create_dir_all(&captures)
.with_context(|| format!("create captures dir {}", captures.display()))?;
let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let tmp_wav = captures.join(format!(".enroll-{timestamp}.wav"));
let stop = install_ctrl_c_handler()?;
if self.max_secs > 0 {
let stop = stop.clone();
let deadline_secs = u64::from(self.max_secs);
thread::spawn(move || {
thread::sleep(Duration::from_secs(deadline_secs));
stop.store(true, Ordering::Relaxed);
});
}
let source = CpalAudioSource::new(self.device.as_deref())?;
eprintln!(
"Recording enrolment for {} to {} (idle-after: {}s, max: {}s, Ctrl-C to stop)…",
self.name,
tmp_wav.display(),
self.idle_after,
self.max_secs,
);
let opts = CaptureOpts::new(&tmp_wav, self.idle_after);
let summary = run_capture(source, opts, stop)?;
print_capture_summary(&summary);
let result = embed_and_save(&self.name, &speaker_model, &tmp_wav, &dest);
let _ = std::fs::remove_file(&tmp_wav);
result?;
eprintln!(
"Enrolled speaker {} ({} dim) -> {}",
self.name,
embed_dim_hint(),
dest.display()
);
Ok(())
}
}
fn embed_and_save(
name: &str,
speaker_model: &std::path::Path,
tmp_wav: &std::path::Path,
dest: &std::path::Path,
) -> Result<()> {
let pcm = read_wav_16k_mono_i16(tmp_wav)?;
let embedder = WespeakerEmbedder::new(speaker_model)?;
let vector = embedder.embed(&pcm)?;
let enrolled = EnrolledSpeaker {
name: name.to_string(),
model: SPEAKER_WESPEAKER_EN.variant.to_string(),
dim: vector.len(),
vector,
samples_used: 1,
enrolled_at: Utc::now(),
};
enrolled.save(dest)
}
fn read_wav_16k_mono_i16(path: &std::path::Path) -> Result<Vec<i16>> {
let mut reader = hound::WavReader::open(path)
.with_context(|| format!("open enrolment WAV at {}", path.display()))?;
let spec = reader.spec();
if spec.sample_rate != 16_000 || spec.channels != 1 {
bail!(
"enrolment WAV at {} must be 16 kHz mono (got {} Hz, {} channels)",
path.display(),
spec.sample_rate,
spec.channels
);
}
let samples: Vec<i16> = reader
.samples::<i16>()
.collect::<Result<Vec<_>, _>>()
.context("decode enrolment WAV samples")?;
Ok(samples)
}
fn resolve_speaker_model_path(override_path: Option<&std::path::Path>) -> Result<PathBuf> {
let dir = SPEAKER_WESPEAKER_EN.resolve_dir(override_path)?;
SPEAKER_WESPEAKER_EN.ensure_present(&dir)?;
Ok(dir.join(SPEAKER_WESPEAKER_EN.required_files[0]))
}
fn print_capture_summary(summary: &CaptureSummary) {
eprintln!("{}", format_capture_summary(summary));
}
fn format_capture_summary(summary: &CaptureSummary) -> String {
let reason = match summary.terminated_by {
TerminationReason::Idle => "silence threshold reached",
TerminationReason::SourceExhausted => "audio source ended",
TerminationReason::Signal => "Ctrl-C or max-secs deadline",
};
let seconds = samples_to_seconds(summary.samples_written);
format!(
"Captured {seconds:.2}s ({} samples; {} trimmed; stopped: {reason})",
summary.samples_written, summary.trimmed_samples,
)
}
fn samples_to_seconds(samples: u64) -> f64 {
samples as f64 / f64::from(crate::voice::wav::TARGET_SAMPLE_RATE)
}
const fn embed_dim_hint() -> usize {
256
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
enroll: EnrollCommand,
}
#[test]
fn parses_defaults() {
let cli = TestCli::try_parse_from(["test"]).unwrap();
assert_eq!(cli.enroll.name, "default");
assert_eq!(cli.enroll.idle_after, DEFAULT_IDLE_AFTER_SECS);
assert_eq!(cli.enroll.max_secs, DEFAULT_MAX_SECS);
assert!(cli.enroll.device.is_none());
assert!(cli.enroll.speaker_model.is_none());
assert!(!cli.enroll.force);
}
#[test]
fn parses_all_flags() {
let cli = TestCli::try_parse_from([
"test",
"--name",
"jky",
"--idle-after",
"3",
"--max-secs",
"20",
"--device",
"Built-in Mic",
"--speaker-model",
"/opt/wespeaker.onnx",
"--force",
])
.unwrap();
assert_eq!(cli.enroll.name, "jky");
assert_eq!(cli.enroll.idle_after, 3);
assert_eq!(cli.enroll.max_secs, 20);
assert_eq!(cli.enroll.device.as_deref(), Some("Built-in Mic"));
assert_eq!(
cli.enroll.speaker_model.as_deref().and_then(|p| p.to_str()),
Some("/opt/wespeaker.onnx")
);
assert!(cli.enroll.force);
}
#[test]
fn parses_max_secs_zero_disables_cap() {
let cli = TestCli::try_parse_from(["test", "--max-secs", "0"]).unwrap();
assert_eq!(cli.enroll.max_secs, 0);
}
#[test]
fn rejects_negative_idle_after() {
let r = TestCli::try_parse_from(["test", "--idle-after", "-1"]);
assert!(r.is_err());
}
#[test]
fn rejects_negative_max_secs() {
let r = TestCli::try_parse_from(["test", "--max-secs", "-1"]);
assert!(r.is_err());
}
#[test]
fn resolve_speaker_model_path_errors_with_install_hint_when_dir_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let Err(err) = resolve_speaker_model_path(Some(tmp.path())) else {
panic!("empty model dir should fail the ensure_present check");
};
let msg = format!("{err:#}");
assert!(msg.contains("no Speaker model found"), "got: {msg}");
assert!(msg.contains("--variant speaker-wespeaker-en"), "got: {msg}");
}
#[test]
fn resolve_speaker_model_path_returns_onnx_file_when_present() {
let tmp = tempfile::TempDir::new().unwrap();
let onnx = tmp.path().join(SPEAKER_WESPEAKER_EN.required_files[0]);
std::fs::write(&onnx, b"placeholder").unwrap();
let resolved = resolve_speaker_model_path(Some(tmp.path())).unwrap();
assert_eq!(resolved, onnx);
}
#[test]
fn read_wav_16k_mono_i16_rejects_wrong_format() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("bad.wav");
let spec = hound::WavSpec {
channels: 2,
sample_rate: 44_100,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(&path, spec).unwrap();
writer.write_sample(0_i16).unwrap();
writer.write_sample(0_i16).unwrap();
writer.finalize().unwrap();
let Err(err) = read_wav_16k_mono_i16(&path) else {
panic!("stereo @ 44.1k should fail");
};
let msg = format!("{err:#}");
assert!(msg.contains("must be 16 kHz mono"), "got: {msg}");
}
fn summary(reason: TerminationReason, written: u64, trimmed: u64) -> CaptureSummary {
CaptureSummary {
output: std::path::PathBuf::from("/tmp/out.wav"),
samples_written: written,
trimmed_samples: trimmed,
terminated_by: reason,
}
}
#[test]
fn format_capture_summary_idle_termination_mentions_silence() {
let s = format_capture_summary(&summary(TerminationReason::Idle, 16_000, 3_200));
assert!(s.contains("silence threshold reached"));
assert!(
s.contains("1.00s"),
"16000 samples @ 16kHz = 1.00s; got: {s}"
);
assert!(s.contains("16000 samples"));
assert!(s.contains("3200 trimmed"));
}
#[test]
fn format_capture_summary_signal_termination_mentions_ctrl_c_or_deadline() {
let s = format_capture_summary(&summary(TerminationReason::Signal, 48_000, 0));
assert!(s.contains("Ctrl-C or max-secs deadline"));
assert!(
s.contains("3.00s"),
"48000 samples @ 16kHz = 3.00s; got: {s}"
);
}
#[test]
fn format_capture_summary_source_exhausted_mentions_source() {
let s = format_capture_summary(&summary(TerminationReason::SourceExhausted, 8_000, 0));
assert!(s.contains("audio source ended"));
assert!(s.contains("0.50s"));
}
#[test]
fn samples_to_seconds_round_trips_at_16k() {
assert!((samples_to_seconds(0) - 0.0).abs() < f64::EPSILON);
assert!((samples_to_seconds(16_000) - 1.0).abs() < f64::EPSILON);
assert!((samples_to_seconds(8_000) - 0.5).abs() < f64::EPSILON);
}
#[test]
fn embed_dim_hint_is_256() {
assert_eq!(embed_dim_hint(), 256);
}
#[test]
fn read_wav_16k_mono_i16_decodes_ok_when_format_matches() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("ok.wav");
let spec = hound::WavSpec {
channels: 1,
sample_rate: 16_000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(&path, spec).unwrap();
for s in [100_i16, 200, 300, 400] {
writer.write_sample(s).unwrap();
}
writer.finalize().unwrap();
let samples = read_wav_16k_mono_i16(&path).unwrap();
assert_eq!(samples, vec![100, 200, 300, 400]);
}
}