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 profile in the directory. One unreadable file does not hide the
33    /// others; it comes back in [`StoredProfiles::unreadable`]. A directory
34    /// that does not exist means nobody has enrolled, which is ordinary.
35    pub fn read_all(&self) -> Result<StoredProfiles, ProfileError> {
36        let entries = match std::fs::read_dir(&self.directory) {
37            Ok(entries) => entries,
38            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
39                return Ok(StoredProfiles::default());
40            }
41            Err(error) => return Err(ProfileError::List(error)),
42        };
43        let mut stored = StoredProfiles::default();
44        for entry in entries {
45            let path = entry.map_err(ProfileError::List)?.path();
46            // Hidden files such as `.DS_Store` are the system's.
47            if path
48                .file_name()
49                .is_some_and(|name| name.as_encoded_bytes().starts_with(b"."))
50            {
51                continue;
52            }
53            if path.extension().is_none_or(|extension| extension != "json") {
54                // The path stays out of the log: its stem is a person's name.
55                tracing::warn!(
56                    "a file in the speakers directory is not a profile this build reads"
57                );
58                continue;
59            }
60            match self.read(&path) {
61                Ok(Some(profile)) => stored.profiles.push(profile),
62                Ok(None) => {}
63                Err(error) => stored.unreadable.push(error),
64            }
65        }
66        stored.profiles.sort_by(|a, b| a.name.cmp(&b.name));
67        Ok(stored)
68    }
69
70    /// The profiles `engine` measured, at the width it produces. Everything
71    /// else was made by a different model and cannot be compared.
72    pub fn read_for(&self, engine: &str, dimension: usize) -> Result<StoredProfiles, ProfileError> {
73        let mut stored = self.read_all()?;
74        stored
75            .profiles
76            .retain(|profile| profile.engine == engine && profile.vector.len() == dimension);
77        Ok(stored)
78    }
79
80    /// Stores a profile. `Replace::Never` refuses when a profile is already
81    /// there, judged by the filesystem rather than by comparing names: a name
82    /// that folds onto an existing one would otherwise overwrite it.
83    ///
84    /// [`ProfileError::DurabilityUnconfirmed`] means the profile is stored
85    /// and live; only whether it survives a power cut is unknown.
86    pub fn save(&self, profile: &Profile, replace: Replace) -> Result<(), ProfileError> {
87        let document = self.document(&profile.name);
88        if replace == Replace::Never && !document.claim().map_err(ProfileError::Write)? {
89            return Err(ProfileError::AlreadyStored);
90        }
91        document
92            .prepare(&StoredProfile::from(profile))
93            .map_err(ProfileError::Write)?
94            .commit()
95            .map_err(ProfileError::Write)?
96            .confirm_durability()
97            .map_err(ProfileError::DurabilityUnconfirmed)
98    }
99
100    /// `false` when nobody by that name was enrolled.
101    pub fn remove(&self, name: &SpeakerName) -> Result<bool, ProfileError> {
102        self.document(name).remove().map_err(ProfileError::Write)
103    }
104
105    /// Whether `name` addresses a stored profile. A filesystem that ignores
106    /// case finds `ada.json` for "Ada".
107    pub fn contains(&self, name: &SpeakerName) -> bool {
108        self.path(name).exists()
109    }
110
111    fn document(&self, name: &SpeakerName) -> PrivateDocument {
112        PrivateDocument::new(self.path(name), MAX_PROFILE_BYTES)
113    }
114
115    fn path(&self, name: &SpeakerName) -> PathBuf {
116        self.directory.join(format!("{name}.json"))
117    }
118
119    fn read(&self, path: &Path) -> Result<Option<Profile>, ProfileError> {
120        let name = path
121            .file_stem()
122            .and_then(|stem| stem.to_str())
123            .ok_or(ProfileError::UnnamedFile)?;
124        let name = SpeakerName::new(name).map_err(ProfileError::Name)?;
125
126        let document = PrivateDocument::new(path, MAX_PROFILE_BYTES);
127        let Some(stored) = document
128            .read::<StoredProfile>()
129            .map_err(ProfileError::Read)?
130        else {
131            return Ok(None);
132        };
133
134        let vector = super::normalized(stored.vector).ok_or(ProfileError::NoDirection)?;
135        Ok(Some(Profile {
136            name,
137            role: stored.role,
138            engine: stored.engine,
139            // A stored moment far enough in the future to overflow is a
140            // tampered file, not a profile: it joins the skipped ones.
141            enrolled_at: UNIX_EPOCH
142                .checked_add(std::time::Duration::from_secs(stored.enrolled_at))
143                .ok_or(ProfileError::UnreadableMoment)?,
144            vector,
145        }))
146    }
147}
148
149#[derive(Debug, Default)]
150pub struct StoredProfiles {
151    /// Sorted by name.
152    pub profiles: Vec<Profile>,
153    /// Each one is a person no session recognizes until the file is fixed or
154    /// they enroll again. The path is withheld: its stem is their name.
155    pub unreadable: Vec<ProfileError>,
156}
157
158/// The on-disk shape. Unknown fields are refused: a profile written by a
159/// newer Aria is not one this build can judge a voice with.
160#[derive(Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162struct StoredProfile {
163    role: Role,
164    engine: String,
165    /// Seconds since the Unix epoch.
166    enrolled_at: u64,
167    vector: Vec<f32>,
168}
169
170impl From<&Profile> for StoredProfile {
171    fn from(profile: &Profile) -> Self {
172        Self {
173            role: profile.role,
174            engine: profile.engine.clone(),
175            enrolled_at: profile
176                .enrolled_at
177                .duration_since(UNIX_EPOCH)
178                .unwrap_or_default()
179                .as_secs(),
180            vector: profile.vector.clone(),
181        }
182    }
183}
184
185/// Whether storing a profile may overwrite one already there.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Replace {
188    Never,
189    Allowed,
190}
191
192#[derive(Debug, thiserror::Error)]
193pub enum ProfileError {
194    #[error("the speakers directory could not be listed")]
195    List(#[source] std::io::Error),
196
197    #[error("the profile could not be read")]
198    Read(#[source] DocumentError),
199
200    #[error("the profile could not be written")]
201    Write(#[source] DocumentError),
202
203    #[error("the profile is stored, but its durability could not be confirmed")]
204    DurabilityUnconfirmed(#[source] DocumentError),
205
206    #[error("a profile of that name is already stored")]
207    AlreadyStored,
208
209    #[error("the profile records a moment outside any calendar")]
210    UnreadableMoment,
211
212    #[error("the profile filename is not a usable speaker name")]
213    Name(#[source] SpeakerNameError),
214
215    #[error("the profile filename has no name in it")]
216    UnnamedFile,
217
218    #[error("the profile embedding has no direction")]
219    NoDirection,
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::os::unix::fs::PermissionsExt;
226
227    fn profiles(store: &ProfileStore) -> Vec<Profile> {
228        store.read_all().expect("the directory lists").profiles
229    }
230
231    fn names(store: &ProfileStore) -> Vec<String> {
232        profiles(store)
233            .iter()
234            .map(|profile| profile.name.to_string())
235            .collect()
236    }
237
238    /// A store in a directory that lives as long as the test holds the pair.
239    fn store() -> (tempfile::TempDir, ProfileStore) {
240        let home = tempfile::tempdir().expect("a temporary directory");
241        let store = ProfileStore::open(home.path());
242        (home, store)
243    }
244
245    fn profile(name: &str, role: Role, engine: &str, vector: Vec<f32>) -> Profile {
246        Profile {
247            name: SpeakerName::new(name).expect("the name is usable"),
248            role,
249            engine: engine.to_owned(),
250            enrolled_at: UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
251            vector,
252        }
253    }
254
255    #[test]
256    fn a_saved_profile_reads_back() {
257        let (_home, store) = store();
258        let alex = profile("alex", Role::Owner, "wespeaker", vec![1.0, 0.0]);
259        store
260            .save(&alex, Replace::Never)
261            .expect("the profile is written");
262
263        assert_eq!(profiles(&store), vec![alex]);
264    }
265
266    #[test]
267    fn profiles_come_back_sorted_by_name() {
268        let (_home, store) = store();
269        for name in ["zoe", "ada"] {
270            store
271                .save(
272                    &profile(name, Role::Guest, "wespeaker", vec![0.0, 1.0]),
273                    Replace::Never,
274                )
275                .expect("the profile is written");
276        }
277
278        let names = names(&store);
279        assert_eq!(names, ["ada", "zoe"]);
280    }
281
282    #[test]
283    fn a_profile_from_another_engine_is_not_offered_to_this_one() {
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        store
292            .save(
293                &profile("zoe", Role::Owner, "other", vec![1.0, 0.0]),
294                Replace::Never,
295            )
296            .expect("the profile is written");
297
298        let usable = store
299            .read_for("wespeaker", 2)
300            .expect("the directory lists")
301            .profiles;
302        assert_eq!(usable.len(), 1);
303        assert_eq!(usable[0].name.as_str(), "ada");
304    }
305
306    #[test]
307    fn a_profile_of_another_width_is_not_offered_either() {
308        let (_home, store) = store();
309        store
310            .save(
311                &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0, 0.0]),
312                Replace::Never,
313            )
314            .expect("the profile is written");
315
316        let stored = store.read_for("wespeaker", 2).expect("the directory lists");
317        assert!(stored.profiles.is_empty());
318    }
319
320    #[test]
321    fn one_unreadable_profile_does_not_hide_the_others() {
322        let (home, store) = store();
323        store
324            .save(
325                &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
326                Replace::Never,
327            )
328            .expect("the profile is written");
329        std::fs::write(home.path().join("broken.json"), b"{").expect("the file is written");
330
331        let stored = store.read_all().expect("the directory lists");
332        let names: Vec<&str> = stored
333            .profiles
334            .iter()
335            .map(|profile| profile.name.as_str())
336            .collect();
337        assert_eq!(names, ["ada"]);
338        assert_eq!(stored.unreadable.len(), 1);
339    }
340
341    #[test]
342    fn an_empty_profiles_directory_means_nobody_enrolled() {
343        let home = tempfile::tempdir().expect("a temporary directory");
344        assert!(profiles(&ProfileStore::open(home.path().join("absent"))).is_empty());
345    }
346
347    #[test]
348    fn removing_says_whether_anyone_was_enrolled() {
349        let (_home, store) = store();
350        let ada = SpeakerName::new("ada").expect("the name is usable");
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
358        assert!(store.remove(&ada).expect("the delete succeeds"));
359        assert!(!store.remove(&ada).expect("the delete succeeds"));
360        assert!(profiles(&store).is_empty());
361    }
362
363    #[test]
364    fn a_second_profile_of_the_same_name_is_refused_unless_asked() {
365        let (_home, store) = store();
366        let first = profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]);
367        store
368            .save(&first, Replace::Never)
369            .expect("the profile is written");
370
371        let second = profile("ada", Role::Guest, "wespeaker", vec![0.0, 1.0]);
372        assert!(matches!(
373            store.save(&second, Replace::Never),
374            Err(ProfileError::AlreadyStored)
375        ));
376        assert_eq!(profiles(&store), vec![first]);
377
378        store
379            .save(&second, Replace::Allowed)
380            .expect("the profile is replaced");
381        assert_eq!(profiles(&store), vec![second]);
382    }
383
384    #[test]
385    fn a_profile_recording_an_impossible_moment_is_skipped() {
386        let (home, store) = store();
387        store
388            .save(
389                &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
390                Replace::Never,
391            )
392            .expect("the profile is written");
393        let tampered = home.path().join("zoe.json");
394        std::fs::write(
395            &tampered,
396            br#"{"role":"guest","engine":"wespeaker","enrolled_at":18446744073709551615,"vector":[1.0,0.0]}"#,
397        )
398        .expect("the file is written");
399        std::fs::set_permissions(&tampered, std::fs::Permissions::from_mode(0o600))
400            .expect("the mode is set");
401
402        let names = names(&store);
403        assert_eq!(names, ["ada"]);
404    }
405
406    #[test]
407    fn a_name_that_could_escape_the_directory_is_refused() {
408        for name in ["", " ada", "ada ", "../ada", "ada/zoe", ".hidden", "a\nb"] {
409            assert!(
410                SpeakerName::new(name).is_err(),
411                "{name:?} must not become a filename"
412            );
413        }
414    }
415
416    #[test]
417    fn a_name_that_reads_like_an_anonymous_speaker_is_refused() {
418        assert!(SpeakerName::new("Speaker 2").is_err());
419        assert!(SpeakerName::new("Speaker Two").is_ok());
420    }
421
422    #[test]
423    fn an_ordinary_name_is_accepted() {
424        assert_eq!(
425            SpeakerName::new("Alex Morgan")
426                .expect("the name is usable")
427                .as_str(),
428            "Alex Morgan"
429        );
430    }
431}