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