use std::path::Path;
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use chrono::{DateTime, Utc};
use rustfft::{Fft, FftPlanner};
use serde::{Deserialize, Serialize};
use tract_onnx::prelude::*;
use crate::voice::features::{
build_mel_filterbank, compute_fbank, FFT_SIZE, NUM_MEL_BINS, SAMPLE_RATE,
};
const L2_EPSILON: f32 = 1e-12;
pub const MIN_EMBED_SAMPLES: usize = SAMPLE_RATE as usize / 2;
type OnnxPlan = SimplePlan<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>;
pub struct WespeakerEmbedder {
plan: OnnxPlan,
mel_filters: Vec<Vec<f32>>,
#[allow(dead_code)]
fft: Arc<dyn Fft<f32>>,
}
impl WespeakerEmbedder {
pub fn new(model_path: &Path) -> Result<Self> {
if !model_path.is_file() {
return Err(anyhow!(
"wespeaker ONNX not found at {}; run `omni-dev voice install-model \
--variant speaker-wespeaker-en` or pass --speaker-model <path>",
model_path.display()
));
}
let plan = tract_onnx::onnx()
.model_for_path(model_path)
.with_context(|| format!("load wespeaker ONNX at {}", model_path.display()))?
.into_optimized()
.context("optimize wespeaker ONNX")?
.into_runnable()
.context("make wespeaker ONNX runnable")?;
let mel_filters = build_mel_filterbank(NUM_MEL_BINS, FFT_SIZE, SAMPLE_RATE)
.context("build wespeaker mel filterbank")?;
let fft = FftPlanner::<f32>::new().plan_fft_forward(FFT_SIZE);
Ok(Self {
plan,
mel_filters,
fft,
})
}
pub fn embed(&self, pcm: &[i16]) -> Result<Vec<f32>> {
if pcm.len() < MIN_EMBED_SAMPLES {
bail!(
"PCM window has {} samples; need at least {} (~0.5 s at 16 kHz) \
for a stable speaker embedding",
pcm.len(),
MIN_EMBED_SAMPLES
);
}
let pcm_f32: Vec<f32> = pcm.iter().map(|&s| f32::from(s) / 32768.0).collect();
let features = compute_fbank(&pcm_f32, &self.mel_filters)?;
let num_frames = features.len();
let mut flat = Vec::with_capacity(num_frames * NUM_MEL_BINS);
for frame in &features {
flat.extend_from_slice(frame);
}
let tensor: Tensor =
tract_ndarray::Array3::from_shape_vec((1, num_frames, NUM_MEL_BINS), flat)
.context("build wespeaker feature tensor")?
.into();
let outputs = self
.plan
.run(tvec!(tensor.into()))
.context("run wespeaker inference")?;
let emb: Vec<f32> = outputs[0]
.to_array_view::<f32>()
.context("wespeaker output to f32 view")?
.iter()
.copied()
.collect();
Ok(l2_normalise(emb))
}
}
pub fn l2_normalise(v: Vec<f32>) -> Vec<f32> {
let norm = (v.iter().map(|x| x * x).sum::<f32>())
.sqrt()
.max(L2_EPSILON);
v.into_iter().map(|x| x / norm).collect()
}
#[allow(clippy::missing_panics_doc)]
pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "cosine: length mismatch");
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnrolledSpeaker {
pub name: String,
pub model: String,
pub dim: usize,
pub vector: Vec<f32>,
pub samples_used: u32,
pub enrolled_at: DateTime<Utc>,
}
impl EnrolledSpeaker {
pub fn load(path: &Path) -> Result<Self> {
let body = std::fs::read_to_string(path)
.with_context(|| format!("read enrolled-speaker JSON at {}", path.display()))?;
let speaker: Self = serde_json::from_str(&body)
.with_context(|| format!("parse enrolled-speaker JSON at {}", path.display()))?;
if speaker.dim != speaker.vector.len() {
bail!(
"enrolled-speaker {} declares dim={} but vector has {} elements",
path.display(),
speaker.dim,
speaker.vector.len()
);
}
Ok(speaker)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(self).context("serialise enrolled-speaker JSON")?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &json)
.with_context(|| format!("write enrolled-speaker JSON to {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn stub_speaker(name: &str) -> EnrolledSpeaker {
EnrolledSpeaker {
name: name.to_string(),
model: "speaker-wespeaker-en".to_string(),
dim: 4,
vector: vec![0.5, 0.5, 0.5, 0.5],
samples_used: 1,
enrolled_at: Utc::now(),
}
}
#[test]
fn enrolled_speaker_save_load_round_trip() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("alice.json");
let original = stub_speaker("alice");
original.save(&path).unwrap();
let loaded = EnrolledSpeaker::load(&path).unwrap();
assert_eq!(loaded, original);
}
#[test]
fn enrolled_speaker_save_creates_parent_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("nested/under/here/alice.json");
stub_speaker("alice").save(&path).unwrap();
assert!(path.is_file());
}
#[test]
fn enrolled_speaker_save_is_atomic_no_tmp_leftover() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("alice.json");
stub_speaker("alice").save(&path).unwrap();
let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "save left .tmp files behind");
}
#[test]
fn enrolled_speaker_load_rejects_dim_vector_mismatch() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("bad.json");
let bad = serde_json::json!({
"name": "bob",
"model": "speaker-wespeaker-en",
"dim": 256,
"vector": [0.1, 0.2],
"samples_used": 1,
"enrolled_at": Utc::now().to_rfc3339()
});
std::fs::write(&path, bad.to_string()).unwrap();
let err = EnrolledSpeaker::load(&path).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("declares dim=256"), "got: {msg}");
assert!(msg.contains("vector has 2 elements"), "got: {msg}");
}
#[test]
fn enrolled_speaker_load_errors_on_missing_file() {
let err = EnrolledSpeaker::load(Path::new("/nonexistent/x.json")).unwrap_err();
assert!(err.to_string().contains("read enrolled-speaker JSON"));
}
#[test]
fn enrolled_speaker_load_errors_on_malformed_json() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("bad.json");
std::fs::write(&path, b"{not json").unwrap();
let err = EnrolledSpeaker::load(&path).unwrap_err();
assert!(err.to_string().contains("parse enrolled-speaker JSON"));
}
#[test]
fn cosine_orthogonal_is_zero() {
let a = [1.0, 0.0, 0.0, 0.0];
let b = [0.0, 1.0, 0.0, 0.0];
assert!((cosine(&a, &b) - 0.0).abs() < 1e-6);
}
#[test]
fn cosine_identical_l2_normed_is_one() {
let a = l2_normalise(vec![1.0, 2.0, 3.0, 4.0]);
let s = cosine(&a, &a);
assert!((s - 1.0).abs() < 1e-6, "got: {s}");
}
#[test]
fn l2_normalise_unit_length() {
let v = l2_normalise(vec![3.0, 4.0]);
let norm = v[0].hypot(v[1]);
assert!((norm - 1.0).abs() < 1e-6);
}
#[test]
fn l2_normalise_handles_zero_vector() {
let v = l2_normalise(vec![0.0, 0.0, 0.0]);
assert!(v.iter().all(|x| x.is_finite()));
}
#[test]
fn wespeaker_embedder_new_errors_on_missing_file() {
let Err(err) = WespeakerEmbedder::new(Path::new("/nope/wespeaker.onnx")) else {
panic!("missing model file should error");
};
let msg = format!("{err:#}");
assert!(msg.contains("wespeaker ONNX not found"), "got: {msg}");
assert!(msg.contains("--variant speaker-wespeaker-en"), "got: {msg}");
}
}