polyvoice/embedder/mod.rs
1//! v1.0 `Embedder` trait + concrete extractors (CAM++, ResNet34, ERes2NetV2) +
2//! overlap-mask helper.
3//!
4//! `Embedder` is the supported bring-your-own embedder contract for offline
5//! [`crate::pipeline::LegacyPipeline`] and online
6//! [`crate::streaming::StreamingPipeline`]. The pure-Rust trait and overlap
7//! mask are always available (no `onnx` required). ONNX-backed adapters still
8//! need `features = ["infer", "embedder"]`. The generic `EmbedderPool` is a
9//! test-only helper, not public API.
10//!
11//! Shared fbank+ONNX engine: `crate::fbank_onnx::FbankOnnxExtractor` (feature
12//! `onnx`; implements [`Embedder`] directly). The architecture adapters share
13//! one generic wrapper with per-model named constructors.
14
15/// Speaker embedding extractor — turns a slice of 16 kHz mono audio into a
16/// fixed-dimension embedding vector. Implementations are expected to L2-normalize
17/// their output so cosine similarity is a meaningful metric downstream.
18///
19/// This is the **supported library injection API** for
20/// [`crate::pipeline::LegacyPipeline`] and
21/// [`crate::streaming::StreamingPipeline`]. Implement it on an external
22/// encoder (Candle, tract, custom) without enabling `onnx`:
23///
24/// ```rust
25/// use polyvoice::{Embedder, EmbedderError};
26///
27/// struct ConstantEmbedder { dim: usize }
28///
29/// impl Embedder for ConstantEmbedder {
30/// fn dim(&self) -> usize { self.dim }
31/// fn embed(&self, _audio: &[f32]) -> Result<Vec<f32>, EmbedderError> {
32/// let mut v = vec![0.0f32; self.dim];
33/// if let Some(first) = v.first_mut() { *first = 1.0; }
34/// Ok(v)
35/// }
36/// }
37/// ```
38pub trait Embedder: Send + Sync {
39 /// Output dimension of this embedder. Constant per instance.
40 fn dim(&self) -> usize;
41
42 /// Compute an embedding for one audio segment.
43 ///
44 /// **Requires:** `audio` is 16 kHz mono PCM.
45 /// **Guarantees on Ok:** `result.len() == self.dim()` and the vector is L2-normalized
46 /// (`|sum(x²)¹ᐟ² − 1.0| < 1e-3`).
47 fn embed(&self, audio: &[f32]) -> Result<Vec<f32>, EmbedderError>;
48
49 /// Compute embeddings for a batch of audio segments. Default implementation
50 /// is sequential; impls may override with a true batched ONNX call.
51 fn embed_batch(&self, audios: &[&[f32]]) -> Result<Vec<Vec<f32>>, EmbedderError> {
52 audios.iter().map(|a| self.embed(a)).collect()
53 }
54}
55
56/// Errors from `Embedder` implementations.
57///
58/// Marked `#[non_exhaustive]` so new variants (e.g. back-pressure) can land in
59/// minor releases without forcing every consumer match to update.
60#[non_exhaustive]
61#[derive(Debug, Clone, thiserror::Error)]
62pub enum EmbedderError {
63 #[error("audio too short for this embedder: {actual_secs:.3}s < {min_secs:.3}s")]
64 AudioTooShort { actual_secs: f32, min_secs: f32 },
65
66 #[error("ONNX inference failed: {detail}")]
67 InferenceFailed { detail: String },
68
69 /// Encoder concurrency / session pool exhausted (or equivalent back-pressure).
70 ///
71 /// Prefer this variant over stuffing the marker into
72 /// [`EmbedderError::InferenceFailed`] so serving layers can classify metrics
73 /// with `downcast` / [`EmbedderError::is_resource_exhausted`] instead of
74 /// substring-matching English messages.
75 #[error("resource exhausted: {detail}")]
76 ResourceExhausted { detail: String },
77
78 #[error("expected embedding dim {expected}, got {actual}")]
79 DimMismatch { expected: usize, actual: usize },
80
81 #[error("model file io error on {path}: {detail}")]
82 ModelIo {
83 path: std::path::PathBuf,
84 detail: String,
85 },
86
87 /// An ONNX-backed extractor failed to construct: invalid pool size, an
88 /// unloadable model file, or a backend session-build failure. The typed
89 /// cause is preserved as the [`std::error::Error::source`].
90 #[cfg(feature = "infer")]
91 #[error("failed to build embedder for {path}: {source}")]
92 SessionBuild {
93 path: std::path::PathBuf,
94 #[source]
95 source: crate::fbank_onnx::FbankExtractorError,
96 },
97
98 #[error("legacy adapter error: {0}")]
99 Legacy(String),
100}
101
102impl EmbedderError {
103 /// True when this error reports encoder resource exhaustion.
104 ///
105 /// Matches the typed [`EmbedderError::ResourceExhausted`] variant and, for
106 /// transitional consumers, legacy strings that still embed
107 /// `"pool exhausted"` in [`EmbedderError::InferenceFailed`] or
108 /// [`EmbedderError::Legacy`].
109 pub fn is_resource_exhausted(&self) -> bool {
110 match self {
111 Self::ResourceExhausted { .. } => true,
112 Self::InferenceFailed { detail } | Self::Legacy(detail) => {
113 detail_looks_exhausted(detail)
114 }
115 _ => false,
116 }
117 }
118}
119
120/// Substring still used by historical extractors / metrics classifiers.
121fn detail_looks_exhausted(detail: &str) -> bool {
122 detail.contains("pool exhausted")
123}
124
125/// Deterministic pseudo-random unit-vector embedder for tests and benchmarks.
126///
127/// Implements [`Embedder`] directly — pass it to
128/// [`crate::pipeline::LegacyPipeline`] / [`crate::streaming::StreamingPipeline`].
129///
130/// ```rust
131/// use polyvoice::{DummyExtractor, Embedder};
132/// let extractor = DummyExtractor::new(256);
133/// assert_eq!(extractor.dim(), 256);
134/// ```
135pub struct DummyExtractor {
136 dim: usize,
137 seed: std::sync::atomic::AtomicU64,
138}
139
140impl DummyExtractor {
141 /// { true }
142 /// pub fn new(dim: usize) -> Self
143 /// { true }
144 /// Create a dummy extractor that returns deterministic pseudo-random embeddings.
145 ///
146 /// Useful for tests and benchmarks where a real ONNX model is not available.
147 ///
148 /// ```rust
149 /// use polyvoice::{DummyExtractor, Embedder};
150 /// let extractor = DummyExtractor::new(256);
151 /// assert_eq!(extractor.dim(), 256);
152 /// ```
153 pub fn new(dim: usize) -> Self {
154 Self {
155 dim,
156 seed: std::sync::atomic::AtomicU64::new(1),
157 }
158 }
159
160 fn next_unit_vector(&self) -> Vec<f32> {
161 let mut seed = self.seed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
162 let mut vec = vec![0.0f32; self.dim];
163 for v in &mut vec {
164 // Simple LCG for deterministic "randomness".
165 seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
166 *v = ((seed % 1000) as f32 / 1000.0) - 0.5;
167 }
168 crate::utils::l2_normalize(&mut vec);
169 vec
170 }
171}
172
173impl Embedder for DummyExtractor {
174 fn dim(&self) -> usize {
175 self.dim
176 }
177
178 fn embed(&self, _audio: &[f32]) -> Result<Vec<f32>, EmbedderError> {
179 Ok(self.next_unit_vector())
180 }
181}
182
183/// { true }
184/// `pub fn apply_overlap_mask( audio: &[f32], overlap_regions: &[(f32, f32)], sample_rate: u32, ) -> Vec<f32>`
185/// { ret.len() == audio.len() }
186/// Zero-fill audio samples in regions where the segmenter flagged a 2-speaker
187/// overlap. The returned `Vec<f32>` is a copy of `audio` with zeros in the
188/// `(start_secs, end_secs)` ranges listed in `overlap_regions`.
189///
190/// Out-of-bounds and inverted (end < start) regions are silently clamped or
191/// skipped — never panics.
192///
193/// **Pure Rust, no allocations beyond the output Vec, wasm32-clean.**
194pub fn apply_overlap_mask(
195 audio: &[f32],
196 overlap_regions: &[(f32, f32)],
197 sample_rate: u32,
198) -> Vec<f32> {
199 let mut out = audio.to_vec();
200 if out.is_empty() {
201 return out;
202 }
203 let sr = sample_rate as f32;
204 for &(start_s, end_s) in overlap_regions {
205 if !end_s.is_finite() || !start_s.is_finite() || end_s <= start_s {
206 continue;
207 }
208 let start = (start_s * sr).max(0.0).floor() as usize;
209 let end = (end_s * sr).max(0.0).ceil() as usize;
210 let end = end.min(out.len());
211 if start >= end || start >= out.len() {
212 continue;
213 }
214 for v in &mut out[start..end] {
215 *v = 0.0;
216 }
217 }
218 out
219}
220
221/// Pool of `Embedder` instances for concurrent extraction.
222///
223/// Test-only helper: production pipelines hold a `Box<dyn Embedder>` (and
224/// `FbankOnnxExtractor` pools ONNX sessions internally), so this type is
225/// compiled only for unit tests and is not part of the public API.
226///
227/// Generic over `E: Embedder` so the same pool implementation works for
228/// `CamPlusPlusExtractor`, `ResNet34Adapter`, or any user-provided embedder.
229/// All embedders in a pool must share the same output dimension.
230///
231/// Backed by a blocking object pool (`Mutex<Vec<E>>`): checkout waits until an
232/// embedder is free; Drop returns it.
233#[cfg(test)]
234pub(crate) struct EmbedderPool<E: Embedder> {
235 pool: crate::utils::ObjectPool<E>,
236 dim: usize,
237 capacity: usize,
238}
239
240#[cfg(test)]
241impl<E: Embedder> EmbedderPool<E> {
242 /// { true }
243 /// `pub fn new(embedders: Vec<E>) -> Result<Self, EmbedderError>`
244 /// { ret.is_ok() => ret.as_ref().unwrap().dim() == embedders.first().map_or(0, |e| e.dim()) }
245 /// Build a pool from a list of embedders. All must share the same `dim()`.
246 /// An empty list is allowed and constructs an empty pool: [`Self::is_empty`]
247 /// is true and every [`Self::embed`] call fails with
248 /// `EmbedderError::ResourceExhausted`.
249 pub fn new(embedders: Vec<E>) -> Result<Self, EmbedderError> {
250 let dim = embedders.first().map(|e| e.dim()).unwrap_or(0);
251 for e in embedders.iter().skip(1) {
252 let actual = e.dim();
253 if actual != dim {
254 return Err(EmbedderError::DimMismatch {
255 expected: dim,
256 actual,
257 });
258 }
259 }
260 let capacity = embedders.len();
261 Ok(Self {
262 pool: crate::utils::ObjectPool::new(embedders),
263 dim,
264 capacity,
265 })
266 }
267
268 /// { true }
269 /// pub fn dim(&self) -> usize
270 /// { ret == self.dim }
271 pub fn dim(&self) -> usize {
272 self.dim
273 }
274
275 /// { true }
276 /// pub fn is_empty(&self) -> bool
277 /// { ret == (self.capacity == 0) }
278 /// True when the pool holds no embedders; every `embed` call then fails
279 /// instead of blocking forever on an empty pool.
280 pub fn is_empty(&self) -> bool {
281 self.capacity == 0
282 }
283
284 /// { true }
285 /// `pub fn embed(&self, audio: &[f32]) -> Result<Vec<f32>, EmbedderError>`
286 /// { ret.as_ref().map_or(true, |v| v.len() == self.dim) }
287 /// Extract a single embedding using the next-available pooled embedder.
288 /// Blocks until one is free.
289 pub fn embed(&self, audio: &[f32]) -> Result<Vec<f32>, EmbedderError> {
290 if self.is_empty() {
291 return Err(EmbedderError::ResourceExhausted {
292 detail: "empty embedder pool".to_owned(),
293 });
294 }
295 let embedder = self.pool.checkout();
296 embedder.embed(audio)
297 }
298}
299
300/// Parallel batch embedding using `std::thread::scope`.
301/// Spawns up to `max_threads` threads (capped by `available_parallelism`),
302/// each processing a chunk of the input via `embedder.embed()`. Callers pass
303/// their session-pool size as `max_threads`: extra threads would just spin in
304/// the pool's blocking checkout and compete with the workers for cores.
305///
306/// Only referenced by the shared ONNX adapter backing the per-model wrappers
307/// (`ResNet34`, CAM++, ERes2NetV2).
308#[cfg(all(feature = "infer", feature = "embedder"))]
309fn parallel_embed_batch<E: Embedder>(
310 embedder: &E,
311 audios: &[&[f32]],
312 max_threads: usize,
313) -> Result<Vec<Vec<f32>>, EmbedderError> {
314 let n = audios.len();
315 if n == 0 {
316 return Ok(Vec::new());
317 }
318 let num_threads = std::thread::available_parallelism()
319 .map(|n| n.get())
320 .unwrap_or(4)
321 .min(max_threads.max(1))
322 .min(n);
323
324 let chunk_size = n.div_ceil(num_threads);
325 let chunks: Vec<&[&[f32]]> = audios.chunks(chunk_size).collect();
326
327 std::thread::scope(|s| {
328 let handles: Vec<_> = chunks
329 .into_iter()
330 .map(|chunk| {
331 s.spawn(move || {
332 chunk
333 .iter()
334 .map(|audio| embedder.embed(audio))
335 .collect::<Vec<_>>()
336 })
337 })
338 .collect();
339
340 let mut all_results = Vec::with_capacity(n);
341 for h in handles {
342 let chunk_results = h
343 .join()
344 .map_err(|_| EmbedderError::Legacy("embed_batch thread panicked".to_string()))?;
345 all_results.extend(chunk_results);
346 }
347 all_results.into_iter().collect::<Result<Vec<_>, _>>()
348 })
349}
350
351#[cfg(all(feature = "infer", feature = "embedder"))]
352mod onnx_adapters {
353 use super::*;
354 use crate::fbank_onnx::FbankOnnxExtractor;
355 use std::path::Path;
356
357 /// Generic fbank+ONNX embedder adapter: owns the shared engine and the
358 /// output dim, maps construction failures to [`EmbedderError::SessionBuild`]
359 /// with the model path attached, and forwards the [`Embedder`] contract
360 /// (batches fan out across threads via `parallel_embed_batch`). The public
361 /// per-model adapters below are thin named wrappers over this one
362 /// implementation.
363 struct FbankAdapter {
364 inner: FbankOnnxExtractor,
365 dim: usize,
366 }
367
368 impl FbankAdapter {
369 /// Load an fbank+ONNX model with the given output dim, session pool
370 /// size, and execution provider.
371 fn new(
372 path: impl AsRef<Path>,
373 dim: usize,
374 pool_size: usize,
375 ep: crate::onnx::ExecutionProvider,
376 ) -> Result<Self, EmbedderError> {
377 let inner =
378 FbankOnnxExtractor::new(path.as_ref(), dim, pool_size, ep).map_err(|e| {
379 EmbedderError::SessionBuild {
380 path: path.as_ref().to_path_buf(),
381 source: e,
382 }
383 })?;
384 Ok(Self { inner, dim })
385 }
386 }
387
388 impl Embedder for FbankAdapter {
389 fn dim(&self) -> usize {
390 self.dim
391 }
392
393 fn embed(&self, audio: &[f32]) -> Result<Vec<f32>, EmbedderError> {
394 self.inner.embed(audio)
395 }
396
397 fn embed_batch(&self, audios: &[&[f32]]) -> Result<Vec<Vec<f32>>, EmbedderError> {
398 parallel_embed_batch(self, audios, self.inner.pool_size())
399 }
400 }
401
402 /// Declare a public per-model adapter as a named wrapper over
403 /// [`FbankAdapter`]: the tuple struct plus the delegating [`Embedder`]
404 /// impl. Constructor conventions differ per model and are written
405 /// explicitly next to each invocation.
406 macro_rules! named_fbank_adapter {
407 ($(#[$meta:meta])* $name:ident) => {
408 $(#[$meta])*
409 pub struct $name(FbankAdapter);
410
411 impl Embedder for $name {
412 fn dim(&self) -> usize {
413 self.0.dim()
414 }
415
416 fn embed(&self, audio: &[f32]) -> Result<Vec<f32>, EmbedderError> {
417 self.0.embed(audio)
418 }
419
420 fn embed_batch(
421 &self,
422 audios: &[&[f32]],
423 ) -> Result<Vec<Vec<f32>>, EmbedderError> {
424 self.0.embed_batch(audios)
425 }
426 }
427 };
428 }
429
430 named_fbank_adapter! {
431 /// WeSpeaker ResNet34 embedder (256-d) via the shared fbank+ONNX engine.
432 ResNet34Adapter
433 }
434
435 impl ResNet34Adapter {
436 /// { true }
437 /// `pub fn new(path: impl AsRef<Path>, pool_size: usize, ep: ExecutionProvider) -> Result<Self, EmbedderError>`
438 /// { ret.as_ref().map_or(true, |e| e.dim() == 256) }
439 /// Load the WeSpeaker ResNet34 ONNX model with the given execution provider.
440 pub fn new(
441 path: impl AsRef<Path>,
442 pool_size: usize,
443 ep: crate::onnx::ExecutionProvider,
444 ) -> Result<Self, EmbedderError> {
445 FbankAdapter::new(path, 256, pool_size, ep).map(Self)
446 }
447 }
448
449 named_fbank_adapter! {
450 /// CAM++ embedder (Channel-Attentive Multi-scale Pooling). Dim is supplied
451 /// explicitly because WeSpeaker ships several CAM++ variants:
452 /// `voxceleb_CAM++.onnx` is 512-d; smaller variants exist at 192-d.
453 /// Uses the same 80-bin log-mel fbank pipeline as ResNet34.
454 CamPlusPlusExtractor
455 }
456
457 impl CamPlusPlusExtractor {
458 /// { true }
459 /// `pub fn new( path: impl AsRef<Path>, dim: usize, pool_size: usize, ep: ExecutionProvider, ) -> Result<Self, EmbedderError>`
460 /// { ret.as_ref().map_or(true, |e| e.dim() == dim) }
461 /// Load a CAM++ ONNX model. `dim` must match the model's output
462 /// dimension (e.g. 192 or 512 depending on the variant). Pool size
463 /// controls the number of concurrent ONNX sessions held internally
464 /// (canonical: `num_cpus().min(4)`).
465 pub fn new(
466 path: impl AsRef<Path>,
467 dim: usize,
468 pool_size: usize,
469 ep: crate::onnx::ExecutionProvider,
470 ) -> Result<Self, EmbedderError> {
471 FbankAdapter::new(path, dim, pool_size, ep).map(Self)
472 }
473 }
474
475 named_fbank_adapter! {
476 /// ERes2NetV2 speaker embedder (Interspeech 2024): 192-d output, same
477 /// 80-bin log-mel fbank path as CAM++. Tuned for short (1–3 s) utterances.
478 /// Weights are optional downloads (Apache-2.0); never bundled or default.
479 ERes2NetV2Extractor
480 }
481
482 impl ERes2NetV2Extractor {
483 /// Output embedding dimension for the common zh-cn 16 kHz ONNX export.
484 pub const DIM: usize = 192;
485
486 /// Load an ERes2NetV2 ONNX model. Default dim is [`Self::DIM`] (192).
487 pub fn new(
488 path: impl AsRef<Path>,
489 pool_size: usize,
490 ep: crate::onnx::ExecutionProvider,
491 ) -> Result<Self, EmbedderError> {
492 Self::with_dim(path, Self::DIM, pool_size, ep)
493 }
494
495 /// Load with an explicit output dimension (for non-standard exports).
496 pub fn with_dim(
497 path: impl AsRef<Path>,
498 dim: usize,
499 pool_size: usize,
500 ep: crate::onnx::ExecutionProvider,
501 ) -> Result<Self, EmbedderError> {
502 FbankAdapter::new(path, dim, pool_size, ep).map(Self)
503 }
504 }
505}
506
507#[cfg(all(feature = "infer", feature = "embedder"))]
508pub use onnx_adapters::{CamPlusPlusExtractor, ERes2NetV2Extractor, ResNet34Adapter};
509
510#[cfg(feature = "embedder-native")]
511mod native;
512#[cfg(feature = "embedder-native")]
513pub use native::ResNet34Native;
514#[allow(clippy::unwrap_used)]
515#[cfg(test)]
516#[path = "overlap_mask_tests.rs"]
517mod overlap_mask_tests;
518
519#[allow(clippy::unwrap_used)]
520#[cfg(test)]
521#[path = "trait_tests.rs"]
522mod trait_tests;
523
524#[allow(clippy::unwrap_used)]
525#[cfg(test)]
526#[path = "pool_tests.rs"]
527mod pool_tests;
528
529#[allow(clippy::unwrap_used)]
530#[cfg(test)]
531#[path = "error_display_tests.rs"]
532mod error_display_tests;
533
534#[allow(clippy::unwrap_used)]
535#[cfg(test)]
536#[path = "dummy_extractor_tests.rs"]
537mod dummy_extractor_tests;
538
539#[allow(clippy::unwrap_used)]
540#[cfg(all(test, feature = "infer", feature = "embedder"))]
541#[path = "onnx_adapter_tests.rs"]
542mod onnx_adapter_tests;