Skip to main content

aria_kit/speaker/
enrollment.rs

1//! Building one enrolled profile out of several spoken samples.
2//!
3//! Averages embeddings from several utterances to reduce variation between samples.
4//! Rejects enrollment when the samples disagree too much.
5
6use super::{Engine, Profile, Role, SpeakerError, SpeakerName, normalized};
7
8/// Below two samples there is nothing to average and no disagreement to
9/// measure.
10pub const MIN_SAMPLES: usize = 2;
11/// Minimum mean pairwise cosine. Leaves room for noisier recordings than the
12/// LibriSpeech calibration corpus. Calibration results are recorded in
13/// `crates/aria-bench-speaker/baselines/speaker.json`; rerun `aria-bench-speaker`
14/// before changing the threshold.
15const MIN_CONSISTENCY: f32 = 0.40;
16
17/// Samples gathered so far, and the engine that measures them.
18pub struct Enrollment<E> {
19    embedder: E,
20    samples: Vec<Vec<f32>>,
21}
22
23impl<E: Engine> Enrollment<E> {
24    pub fn new(embedder: E) -> Self {
25        Self {
26            embedder,
27            samples: Vec::new(),
28        }
29    }
30
31    /// Measures one utterance.
32    ///
33    /// [`EnrollmentError::NoVoice`] means the window held nothing to embed —
34    /// too short, or too quiet. Whether that ends the enrollment or asks for
35    /// the sample again is the caller's to decide.
36    pub fn add(&mut self, window: &[f32]) -> Result<(), EnrollmentError> {
37        match self.embedder.embed(window)? {
38            Some(embedding) => {
39                self.samples.push(embedding);
40                Ok(())
41            }
42            None => Err(EnrollmentError::NoVoice),
43        }
44    }
45
46    /// The profile the samples agree on, and how strongly they agree.
47    pub fn finish(self, name: SpeakerName, role: Role) -> Result<Enrolled, EnrollmentError> {
48        if self.samples.len() < MIN_SAMPLES {
49            return Err(EnrollmentError::TooFewSamples {
50                samples: self.samples.len(),
51                minimum: MIN_SAMPLES,
52            });
53        }
54
55        let consistency = consistency(&self.samples);
56        if consistency < MIN_CONSISTENCY {
57            return Err(EnrollmentError::Inconsistent {
58                consistency,
59                minimum: MIN_CONSISTENCY,
60            });
61        }
62
63        let vector = centroid(&self.samples).ok_or(EnrollmentError::NoDirection)?;
64        Ok(Enrolled {
65            profile: Profile {
66                name,
67                role,
68                engine: E::NAME.to_owned(),
69                enrolled_at: std::time::SystemTime::now(),
70                vector,
71            },
72            consistency,
73        })
74    }
75}
76
77/// A finished enrollment: the profile to store, and the score worth showing
78/// the person who just recorded it.
79pub struct Enrolled {
80    pub profile: Profile,
81    /// Mean pairwise cosine of the samples, 0.0 to 1.0.
82    pub consistency: f32,
83}
84
85/// Normalizes the elementwise mean of unit-length embeddings.
86/// Returns `None` when the mean has no direction.
87pub fn centroid(samples: &[Vec<f32>]) -> Option<Vec<f32>> {
88    let width = samples.first()?.len();
89    let mut sum = vec![0.0; width];
90    for sample in samples {
91        for (total, value) in sum.iter_mut().zip(sample) {
92            *total += value;
93        }
94    }
95    for total in &mut sum {
96        *total /= samples.len() as f32;
97    }
98    normalized(sum)
99}
100
101/// Mean pairwise cosine similarity of unit-length embeddings.
102pub fn consistency(samples: &[Vec<f32>]) -> f32 {
103    let mut total = 0.0;
104    let mut pairs = 0;
105    for (at, sample) in samples.iter().enumerate() {
106        for other in &samples[at + 1..] {
107            total += sample.iter().zip(other).map(|(a, b)| a * b).sum::<f32>();
108            pairs += 1;
109        }
110    }
111    total / pairs as f32
112}
113
114#[derive(Debug, thiserror::Error)]
115pub enum EnrollmentError {
116    #[error("that sample carried no voice")]
117    NoVoice,
118
119    #[error("enrolling needs {minimum} samples; {samples} were recorded")]
120    TooFewSamples { samples: usize, minimum: usize },
121
122    #[error(
123        "the samples sound like different voices (consistency {consistency:.2}, needs {minimum:.2})"
124    )]
125    Inconsistent { consistency: f32, minimum: f32 },
126
127    #[error("the samples average to nothing")]
128    NoDirection,
129
130    #[error(transparent)]
131    Engine(#[from] SpeakerError),
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::speaker::Embedder;
138    use std::path::PathBuf;
139
140    /// Answers with whatever it was told to, in order.
141    struct Replay {
142        embeddings: Vec<Option<Vec<f32>>>,
143    }
144
145    impl Embedder for Replay {
146        fn embed(&mut self, _window: &[f32]) -> Result<Option<Vec<f32>>, SpeakerError> {
147            Ok(self.embeddings.remove(0))
148        }
149
150        fn dimension(&self) -> usize {
151            2
152        }
153    }
154
155    impl Engine for Replay {
156        const NAME: &'static str = "replay";
157
158        type Config = ();
159
160        fn check_from(_config: &Self::Config) -> Result<PathBuf, SpeakerError> {
161            Ok(PathBuf::new())
162        }
163
164        fn load_from(_config: Self::Config) -> Result<Self, SpeakerError> {
165            Ok(Self {
166                embeddings: Vec::new(),
167            })
168        }
169    }
170
171    fn enrolling(embeddings: Vec<Option<Vec<f32>>>) -> Enrollment<Replay> {
172        Enrollment::new(Replay { embeddings })
173    }
174
175    fn ada() -> SpeakerName {
176        SpeakerName::new("ada").expect("the name is usable")
177    }
178
179    #[test]
180    fn one_voice_averages_to_itself_and_scores_perfectly() {
181        let voice = vec![0.6, 0.8];
182        let mut enrollment = enrolling(vec![Some(voice.clone()), Some(voice.clone())]);
183        enrollment.add(&[]).expect("the sample carries a voice");
184        enrollment.add(&[]).expect("the sample carries a voice");
185
186        let enrolled = enrollment
187            .finish(ada(), Role::Owner)
188            .expect("the samples agree");
189        assert!((enrolled.consistency - 1.0).abs() < 1e-6);
190        assert!((enrolled.profile.vector[0] - voice[0]).abs() < 1e-6);
191        assert_eq!(enrolled.profile.engine, "replay");
192    }
193
194    #[test]
195    fn the_profile_is_unit_norm() {
196        let mut enrollment = enrolling(vec![Some(vec![1.0, 0.0]), Some(vec![0.8, 0.6])]);
197        enrollment.add(&[]).expect("the sample carries a voice");
198        enrollment.add(&[]).expect("the sample carries a voice");
199
200        let enrolled = enrollment
201            .finish(ada(), Role::Guest)
202            .expect("the samples agree");
203        let norm: f32 = enrolled.profile.vector.iter().map(|v| v * v).sum();
204        assert!((norm - 1.0).abs() < 1e-6);
205    }
206
207    #[test]
208    fn a_silent_sample_is_reported_as_one_sample() {
209        let mut enrollment = enrolling(vec![None]);
210        assert!(matches!(enrollment.add(&[]), Err(EnrollmentError::NoVoice)));
211        assert!(enrollment.samples.is_empty());
212    }
213
214    #[test]
215    fn two_different_voices_do_not_enroll() {
216        let mut enrollment = enrolling(vec![Some(vec![1.0, 0.0]), Some(vec![0.0, 1.0])]);
217        enrollment.add(&[]).expect("the sample carries a voice");
218        enrollment.add(&[]).expect("the sample carries a voice");
219
220        assert!(matches!(
221            enrollment.finish(ada(), Role::Owner),
222            Err(EnrollmentError::Inconsistent { .. })
223        ));
224    }
225
226    #[test]
227    fn one_sample_is_not_an_enrollment() {
228        let mut enrollment = enrolling(vec![Some(vec![1.0, 0.0])]);
229        enrollment.add(&[]).expect("the sample carries a voice");
230
231        assert!(matches!(
232            enrollment.finish(ada(), Role::Owner),
233            Err(EnrollmentError::TooFewSamples { samples: 1, .. })
234        ));
235    }
236}