Skip to main content

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;