Skip to main content

aria_kit/speaker/
store.rs

1//! The speakers directory: enrolled voices read back, stored, and forgotten.
2//!
3//! One document per person, named for them. The engine that measured a
4//! profile is recorded with it, because two engines put a voice in different
5//! places and comparing across them is meaningless.
6
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use serde::{Deserialize, Serialize};
11
12use crate::primitives::private_document::{DocumentError, PrivateDocument};
13
14use super::{Profile, Role, SpeakerName, SpeakerNameError};
15
16/// A profile is a name, a role, and a few hundred floats. Anything larger is
17/// not one.
18const MAX_PROFILE_BYTES: u64 = 64 * 1024;
19
20/// The directory of enrolled voices.
21pub struct ProfileStore {
22    directory: PathBuf,
23}
24
25impl ProfileStore {
26    pub fn open(directory: impl Into<PathBuf>) -> Self {
27        Self {
28            directory: directory.into(),
29        }
30    }
31
32    /// Every readable profile, sorted by name.
33    ///
34    /// A profile that will not load is skipped with a warning: one unreadable
35    /// file must not cost a session every other enrolled voice. A directory
36    /// that does not exist means nobody has enrolled, which is ordinary.
37    pub fn read_all(&self) -> Vec<Profile> {
38        let Ok(entries) = std::fs::read_dir(&self.directory) else {
39            return Vec::new();
40        };
41        let mut profiles = Vec::new();
42        for entry in entries.flatten() {
43            let path = entry.path();
44            if path.extension().is_none_or(|extension| extension != "json") {
45                // Loud, because a profile this build cannot read is a person
46                // it will not recognize, and silence looks like nobody
47                // enrolled. The path stays out: the stem is their name.
48                tracing::warn!(
49                    "a file in the speakers directory is not a profile this build reads"
50                );
51                continue;
52            }
53            match self.read(&path) {
54                Ok(Some(profile)) => profiles.push(profile),
55                Ok(None) => {}
56                Err(error) => tracing::warn!(%error, "skipping an unreadable speaker profile"),
57            }
58        }
59        profiles.sort_by(|a, b| a.name.cmp(&b.name));
60        profiles
61    }
62
63    /// The profiles `engine` measured, at the width it produces. Everything
64    /// else was made by a different model and cannot be compared.
65    pub fn read_for(&self, engine: &str, dimension: usize) -> Vec<Profile> {
66        self.read_all()
67            .into_iter()
68            .filter(|profile| profile.engine == engine && profile.vector.len() == dimension)
69            .collect()
70    }
71
72    /// Stores a profile. `Replace::Never` refuses when a profile is already
73    /// there, judged by the filesystem rather than by comparing names: a name
74    /// that folds onto an existing one would otherwise overwrite it.
75    ///
76    /// [`ProfileError::DurabilityUnconfirmed`] means the profile is stored
77    /// and live; only whether it survives a power cut is unknown.
78    pub fn save(&self, profile: &Profile, replace: Replace) -> Result<(), ProfileError> {
79        let document = self.document(&profile.name);
80        if replace == Replace::Never && !document.claim().map_err(ProfileError::Write)? {
81            return Err(ProfileError::AlreadyStored);
82        }
83        document
84            .prepare(&StoredProfile::from(profile))
85            .map_err(ProfileError::Write)?
86            .commit()
87            .map_err(ProfileError::Write)?
88            .confirm_durability()
89            .map_err(ProfileError::DurabilityUnconfirmed)
90    }
91
92    /// `false` when nobody by that name was enrolled.
93    pub fn remove(&self, name: &SpeakerName) -> Result<bool, ProfileError> {
94        self.document(name).remove().map_err(ProfileError::Write)
95    }
96
97    fn document(&self, name: &SpeakerName) -> PrivateDocument {
98        PrivateDocument::new(
99            self.directory.join(format!("{name}.json")),
100            MAX_PROFILE_BYTES,
101        )
102    }
103
104    fn read(&self, path: &Path) -> Result<Option<Profile>, ProfileError> {
105        let name = path
106            .file_stem()
107            .and_then(|stem| stem.to_str())
108            .ok_or(ProfileError::UnnamedFile)?;
109        let name = SpeakerName::new(name).map_err(ProfileError::Name)?;
110
111        let document = PrivateDocument::new(path, MAX_PROFILE_BYTES);
112        let Some(stored) = document
113            .read::<StoredProfile>()
114            .map_err(ProfileError::Read)?
115        else {
116            return Ok(None);
117        };
118
119        let vector = super::normalized(stored.vector).ok_or(ProfileError::NoDirection)?;
120        Ok(Some(Profile {
121            name,
122            role: stored.role,
123            engine: stored.engine,
124            // A stored moment far enough in the future to overflow is a
125            // tampered file, not a profile: it joins the skipped ones.
126            enrolled_at: UNIX_EPOCH
127                .checked_add(std::time::Duration::from_secs(stored.enrolled_at))
128                .ok_or(ProfileError::UnreadableMoment)?,
129            vector,
130        }))
131    }
132}
133
134/// The on-disk shape. Unknown fields are refused: a profile written by a
135/// newer Aria is not one this build can judge a voice with.
136#[derive(Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138struct StoredProfile {
139    role: Role,
140    engine: String,
141    /// Seconds since the Unix epoch.
142    enrolled_at: u64,
143    vector: Vec<f32>,
144}
145
146impl From<&Profile> for StoredProfile {
147    fn from(profile: &Profile) -> Self {
148        Self {
149            role: profile.role,
150            engine: profile.engine.clone(),
151            enrolled_at: profile
152                .enrolled_at
153                .duration_since(UNIX_EPOCH)
154                .unwrap_or_default()
155                .as_secs(),
156            vector: profile.vector.clone(),
157        }
158    }
159}
160
161/// Whether storing a profile may overwrite one already there.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum Replace {
164    Never,
165    Allowed,
166}
167
168#[derive(Debug, thiserror::Error)]
169pub enum ProfileError {
170    #[error("the profile could not be read")]
171    Read(#[source] DocumentError),
172
173    #[error("the profile could not be written")]
174    Write(#[source] DocumentError),
175
176    #[error("the profile is stored, but its durability could not be confirmed")]
177    DurabilityUnconfirmed(#[source] DocumentError),
178
179    #[error("a profile of that name is already stored")]
180    AlreadyStored,
181
182    #[error("the profile records a moment outside any calendar")]
183    UnreadableMoment,
184
185    #[error("the profile filename is not a usable speaker name")]
186    Name(#[source] SpeakerNameError),
187
188    #[error("the profile filename has no name in it")]
189    UnnamedFile,
190
191    #[error("the profile embedding has no direction")]
192    NoDirection,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::os::unix::fs::PermissionsExt;
199
200    /// A store in a directory that lives as long as the test holds the pair.
201    fn store() -> (tempfile::TempDir, ProfileStore) {
202        let home = tempfile::tempdir().expect("a temporary directory");
203        let store = ProfileStore::open(home.path());
204        (home, store)
205    }
206
207    fn profile(name: &str, role: Role, engine: &str, vector: Vec<f32>) -> Profile {
208        Profile {
209            name: SpeakerName::new(name).expect("the name is usable"),
210            role,
211            engine: engine.to_owned(),
212            enrolled_at: UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
213            vector,
214        }
215    }
216
217    #[test]
218    fn a_saved_profile_reads_back() {
219        let (_home, store) = store();
220        let alex = profile("alex", Role::Owner, "wespeaker", vec![1.0, 0.0]);
221        store
222            .save(&alex, Replace::Never)
223            .expect("the profile is written");
224
225        assert_eq!(store.read_all(), vec![alex]);
226    }
227
228    #[test]
229    fn profiles_come_back_sorted_by_name() {
230        let (_home, store) = store();
231        for name in ["zoe", "ada"] {
232            store
233                .save(
234                    &profile(name, Role::Guest, "wespeaker", vec![0.0, 1.0]),
235                    Replace::Never,
236                )
237                .expect("the profile is written");
238        }
239
240        let names: Vec<String> = store
241            .read_all()
242            .iter()
243            .map(|profile| profile.name.to_string())
244            .collect();
245        assert_eq!(names, ["ada", "zoe"]);
246    }
247
248    #[test]
249    fn a_profile_from_another_engine_is_not_offered_to_this_one() {
250        let (_home, store) = store();
251        store
252            .save(
253                &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
254                Replace::Never,
255            )
256            .expect("the profile is written");
257        store
258            .save(
259                &profile("zoe", Role::Owner, "other", vec![1.0, 0.0]),
260                Replace::Never,
261            )
262            .expect("the profile is written");
263
264        let usable = store.read_for("wespeaker", 2);
265        assert_eq!(usable.len(), 1);
266        assert_eq!(usable[0].name.as_str(), "ada");
267    }
268
269    #[test]
270    fn a_profile_of_another_width_is_not_offered_either() {
271        let (_home, store) = store();
272        store
273            .save(
274                &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0, 0.0]),
275                Replace::Never,
276            )
277            .expect("the profile is written");
278
279        assert!(store.read_for("wespeaker", 2).is_empty());
280    }
281
282    #[test]
283    fn one_unreadable_profile_does_not_cost_the_others() {
284        let (home, store) = store();
285        store
286            .save(
287                &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
288                Replace::Never,
289            )
290            .expect("the profile is written");
291        std::fs::write(home.path().join("broken.json"), b"{").expect("the file is written");
292
293        let names: Vec<String> = store
294            .read_all()
295            .iter()
296            .map(|profile| profile.name.to_string())
297            .collect();
298        assert_eq!(names, ["ada"]);
299    }
300
301    #[test]
302    fn an_empty_speakers_directory_means_nobody_enrolled() {
303        let home = tempfile::tempdir().expect("a temporary directory");
304        assert!(
305            ProfileStore::open(home.path().join("absent"))
306                .read_all()
307                .is_empty()
308        );
309    }
310
311    #[test]
312    fn removing_says_whether_anyone_was_enrolled() {
313        let (_home, store) = store();
314        let ada = SpeakerName::new("ada").expect("the name is usable");
315        store
316            .save(
317                &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
318                Replace::Never,
319            )
320            .expect("the profile is written");
321
322        assert!(store.remove(&ada).expect("the delete succeeds"));
323        assert!(!store.remove(&ada).expect("the delete succeeds"));
324        assert!(store.read_all().is_empty());
325    }
326
327    #[test]
328    fn a_second_profile_of_the_same_name_is_refused_unless_asked() {
329        let (_home, store) = store();
330        let first = profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]);
331        store
332            .save(&first, Replace::Never)
333            .expect("the profile is written");
334
335        let second = profile("ada", Role::Guest, "wespeaker", vec![0.0, 1.0]);
336        assert!(matches!(
337            store.save(&second, Replace::Never),
338            Err(ProfileError::AlreadyStored)
339        ));
340        assert_eq!(store.read_all(), vec![first]);
341
342        store
343            .save(&second, Replace::Allowed)
344            .expect("the profile is replaced");
345        assert_eq!(store.read_all(), vec![second]);
346    }
347
348    #[test]
349    fn a_profile_recording_an_impossible_moment_is_skipped() {
350        let (home, store) = store();
351        store
352            .save(
353                &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
354                Replace::Never,
355            )
356            .expect("the profile is written");
357        let tampered = home.path().join("zoe.json");
358        std::fs::write(
359            &tampered,
360            br#"{"role":"guest","engine":"wespeaker","enrolled_at":18446744073709551615,"vector":[1.0,0.0]}"#,
361        )
362        .expect("the file is written");
363        std::fs::set_permissions(&tampered, std::fs::Permissions::from_mode(0o600))
364            .expect("the mode is set");
365
366        let names: Vec<String> = store
367            .read_all()
368            .iter()
369            .map(|profile| profile.name.to_string())
370            .collect();
371        assert_eq!(names, ["ada"]);
372    }
373
374    #[test]
375    fn a_name_that_could_escape_the_directory_is_refused() {
376        for name in ["", " ada", "ada ", "../ada", "ada/zoe", ".hidden", "a\nb"] {
377            assert!(
378                SpeakerName::new(name).is_err(),
379                "{name:?} must not become a filename"
380            );
381        }
382    }
383
384    #[test]
385    fn an_ordinary_name_is_accepted() {
386        assert_eq!(
387            SpeakerName::new("Alex Morgan")
388                .expect("the name is usable")
389                .as_str(),
390            "Alex Morgan"
391        );
392    }
393}