Skip to main content

aria_kit/speaker/
profile.rs

1//! What an enrolled voice is: a name, a role, and the vector a
2//! [`Diarizer`](super::Diarizer) claims that name from.
3//!
4//! Reading and writing them is [`store`](super::store)'s business; nothing
5//! here touches a filesystem, so it compiles for the browser too.
6
7use std::time::SystemTime;
8
9use serde::{Deserialize, Serialize};
10
11/// Long enough for a name, short enough to stay a filename.
12const MAX_NAME_BYTES: usize = 64;
13
14/// What an enrolled voice is allowed to be. Only an owner's voice answers
15/// "was that you?", so a guest can be recognized by name without turning on
16/// dictation meant for the person who set the machine up.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Role {
20    Owner,
21    Guest,
22}
23
24impl std::fmt::Display for Role {
25    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        formatter.write_str(match self {
27            Self::Owner => "owner",
28            Self::Guest => "guest",
29        })
30    }
31}
32
33/// An enrolled person's name, which is also their filename. Holding one is
34/// proof it cannot escape the speakers directory.
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
36pub struct SpeakerName(String);
37
38impl SpeakerName {
39    pub fn new(name: impl Into<String>) -> Result<Self, SpeakerNameError> {
40        let name = name.into();
41        if name.is_empty() {
42            return Err(SpeakerNameError::Empty);
43        }
44        if name.len() > MAX_NAME_BYTES {
45            return Err(SpeakerNameError::TooLong {
46                actual_bytes: name.len(),
47                maximum_bytes: MAX_NAME_BYTES,
48            });
49        }
50        if name.trim() != name {
51            return Err(SpeakerNameError::Padded);
52        }
53        // A separator would make the name a path, and a leading dot would
54        // hide the profile from anyone looking at the directory.
55        if name.starts_with('.')
56            || name
57                .chars()
58                .any(|character| character.is_control() || matches!(character, '/' | '\\' | ':'))
59        {
60            return Err(SpeakerNameError::Unusable);
61        }
62        Ok(Self(name))
63    }
64
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70impl std::fmt::Display for SpeakerName {
71    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        formatter.write_str(&self.0)
73    }
74}
75
76/// One enrolled voice.
77#[derive(Debug, Clone, PartialEq)]
78pub struct Profile {
79    pub name: SpeakerName,
80    pub role: Role,
81    /// The engine that measured `vector`, by its `Engine::NAME`.
82    pub engine: String,
83    pub enrolled_at: SystemTime,
84    /// Unit norm, so a cosine against it is a plain dot product.
85    pub vector: Vec<f32>,
86}
87
88/// A profile carrying one voice. The engine and the moment it was recorded
89/// do not change what a diarizer decides, so they take fixed values.
90#[cfg(any(test, feature = "test-support"))]
91pub fn enrolled_voice(name: &str, role: Role, vector: Vec<f32>) -> Profile {
92    Profile {
93        name: SpeakerName::new(name).expect("a test name is usable"),
94        role,
95        engine: "test".to_owned(),
96        enrolled_at: SystemTime::UNIX_EPOCH,
97        vector,
98    }
99}
100
101#[derive(Debug, thiserror::Error)]
102pub enum SpeakerNameError {
103    #[error("a speaker name cannot be empty")]
104    Empty,
105
106    #[error("a speaker name holds {actual_bytes} bytes; maximum is {maximum_bytes}")]
107    TooLong {
108        actual_bytes: usize,
109        maximum_bytes: usize,
110    },
111
112    #[error("a speaker name cannot start or end with a space")]
113    Padded,
114
115    #[error("a speaker name cannot start with a dot or hold a path separator")]
116    Unusable,
117}