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        const MODEL: &'static [crate::backend::ModelDownload] = &[];
158
159        type Config = ();
160
161        fn check_from(_config: &Self::Config) -> Result<PathBuf, SpeakerError> {
162            Ok(PathBuf::new())
163        }
164
165        fn load_from(_config: Self::Config) -> Result<Self, SpeakerError> {
166            Ok(Self {
167                embeddings: Vec::new(),
168            })
169        }
170    }
171
172    fn enrolling(embeddings: Vec<Option<Vec<f32>>>) -> Enrollment<Replay> {
173        Enrollment::new(Replay { embeddings })
174    }
175
176    fn ada() -> SpeakerName {
177        SpeakerName::new("ada").expect("the name is usable")
178    }
179
180    #[test]
181    fn one_voice_averages_to_itself_and_scores_perfectly() {
182        let voice = vec![0.6, 0.8];
183        let mut enrollment = enrolling(vec![Some(voice.clone()), Some(voice.clone())]);
184        enrollment.add(&[]).expect("the sample carries a voice");
185        enrollment.add(&[]).expect("the sample carries a voice");
186
187        let enrolled = enrollment
188            .finish(ada(), Role::Owner)
189            .expect("the samples agree");
190        assert!((enrolled.consistency - 1.0).abs() < 1e-6);
191        assert!((enrolled.profile.vector[0] - voice[0]).abs() < 1e-6);
192        assert_eq!(enrolled.profile.engine, "replay");
193    }
194
195    #[test]
196    fn the_profile_is_unit_norm() {
197        let mut enrollment = enrolling(vec![Some(vec![1.0, 0.0]), Some(vec![0.8, 0.6])]);
198        enrollment.add(&[]).expect("the sample carries a voice");
199        enrollment.add(&[]).expect("the sample carries a voice");
200
201        let enrolled = enrollment
202            .finish(ada(), Role::Guest)
203            .expect("the samples agree");
204        let norm: f32 = enrolled.profile.vector.iter().map(|v| v * v).sum();
205        assert!((norm - 1.0).abs() < 1e-6);
206    }
207
208    #[test]
209    fn a_silent_sample_is_reported_as_one_sample() {
210        let mut enrollment = enrolling(vec![None]);
211        assert!(matches!(enrollment.add(&[]), Err(EnrollmentError::NoVoice)));
212        assert!(enrollment.samples.is_empty());
213    }
214
215    #[test]
216    fn two_different_voices_do_not_enroll() {
217        let mut enrollment = enrolling(vec![Some(vec![1.0, 0.0]), Some(vec![0.0, 1.0])]);
218        enrollment.add(&[]).expect("the sample carries a voice");
219        enrollment.add(&[]).expect("the sample carries a voice");
220
221        assert!(matches!(
222            enrollment.finish(ada(), Role::Owner),
223            Err(EnrollmentError::Inconsistent { .. })
224        ));
225    }
226
227    #[test]
228    fn one_sample_is_not_an_enrollment() {
229        let mut enrollment = enrolling(vec![Some(vec![1.0, 0.0])]);
230        enrollment.add(&[]).expect("the sample carries a voice");
231
232        assert!(matches!(
233            enrollment.finish(ada(), Role::Owner),
234            Err(EnrollmentError::TooFewSamples { samples: 1, .. })
235        ));
236    }
237}