Skip to main content

kcode_speaker_v3_schema/
lib.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::{collections::BTreeSet, error::Error, fmt, str::FromStr};
3
4pub const MAX_AUDIO_DURATION_MS: u64 = 150_000;
5pub const OGG_MEDIA_TYPE: &str = "audio/ogg";
6pub const FEATURE_SCHEMA_REVISION: &str = "speaker-v3-features-24-r1";
7
8pub const FEATURE_NAMES: [&str; 24] = [
9    "median_f0_hz",
10    "high_front_vowel_f1_hz",
11    "high_back_vowel_f2_hz",
12    "spectral_tilt_db_per_octave",
13    "cepstral_peak_prominence_db",
14    "foreign_accentedness_1_to_9",
15    "dominant_rhotic_realization",
16    "unstressed_vowel_reduction_percent",
17    "high_front_vowel_f2_hz",
18    "low_vowel_f1_hz",
19    "h1_minus_h2_db",
20    "rhotic_f3_minus_f2_hz",
21    "word_initial_t_vot_ms",
22    "dominant_lateral_realization",
23    "monophthongization_percent",
24    "vocal_gender_presentation",
25    "low_vowel_f2_hz",
26    "high_back_vowel_f1_hz",
27    "mean_formant_dispersion_hz",
28    "creaky_phonation_percent",
29    "hypernasality_0_to_4",
30    "sibilant_center_of_gravity_hz",
31    "consonant_cluster_reduction_percent",
32    "perceived_vocal_age_years",
33];
34
35const NUMERIC_FEATURE_NAMES: [&str; 22] = [
36    "median_f0_hz",
37    "high_front_vowel_f1_hz",
38    "high_back_vowel_f2_hz",
39    "spectral_tilt_db_per_octave",
40    "cepstral_peak_prominence_db",
41    "foreign_accentedness_1_to_9",
42    "unstressed_vowel_reduction_percent",
43    "high_front_vowel_f2_hz",
44    "low_vowel_f1_hz",
45    "h1_minus_h2_db",
46    "rhotic_f3_minus_f2_hz",
47    "word_initial_t_vot_ms",
48    "monophthongization_percent",
49    "vocal_gender_presentation",
50    "low_vowel_f2_hz",
51    "high_back_vowel_f1_hz",
52    "mean_formant_dispersion_hz",
53    "creaky_phonation_percent",
54    "hypernasality_0_to_4",
55    "sibilant_center_of_gravity_hz",
56    "consonant_cluster_reduction_percent",
57    "perceived_vocal_age_years",
58];
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum ValidationError {
62    InvalidOgg,
63    InvalidDuration(u64),
64    ByteLengthOverflow,
65    Blank(&'static str),
66    InvalidSpeakerLabel(String),
67    DuplicateSpeakerLabel(LocalSpeakerLabel),
68    NonFiniteFeature(&'static str),
69}
70
71impl fmt::Display for ValidationError {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::InvalidOgg => formatter.write_str("audio is not a complete Ogg first page"),
75            Self::InvalidDuration(value) => write!(formatter, "invalid audio duration: {value} ms"),
76            Self::ByteLengthOverflow => formatter.write_str("audio byte length exceeds u64"),
77            Self::Blank(field) => write!(formatter, "{field} is blank"),
78            Self::InvalidSpeakerLabel(value) => write!(formatter, "invalid speaker label: {value}"),
79            Self::DuplicateSpeakerLabel(value) => {
80                write!(formatter, "duplicate speaker label: {value}")
81            }
82            Self::NonFiniteFeature(field) => write!(formatter, "{field} is not finite"),
83        }
84    }
85}
86
87impl Error for ValidationError {}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct OggAudioMetadata {
91    duration_ms: u64,
92    byte_length: u64,
93    filename: Option<String>,
94}
95
96impl OggAudioMetadata {
97    pub fn from_bytes(
98        bytes: &[u8],
99        duration_ms: u64,
100        filename: Option<String>,
101    ) -> Result<Self, ValidationError> {
102        validate_duration(duration_ms)?;
103        validate_optional_text(filename.as_deref(), "filename")?;
104        if bytes.len() < 27 || &bytes[..4] != b"OggS" || bytes[4] != 0 {
105            return Err(ValidationError::InvalidOgg);
106        }
107        let body_start = 27 + bytes[26] as usize;
108        if bytes.len() < body_start {
109            return Err(ValidationError::InvalidOgg);
110        }
111        let body_length: usize = bytes[27..body_start]
112            .iter()
113            .map(|value| *value as usize)
114            .sum();
115        if bytes.len() < body_start + body_length {
116            return Err(ValidationError::InvalidOgg);
117        }
118        Ok(Self {
119            duration_ms,
120            byte_length: u64::try_from(bytes.len())
121                .map_err(|_| ValidationError::ByteLengthOverflow)?,
122            filename,
123        })
124    }
125
126    pub fn validate(&self) -> Result<(), ValidationError> {
127        validate_duration(self.duration_ms)?;
128        validate_optional_text(self.filename.as_deref(), "filename")?;
129        (self.byte_length >= 27)
130            .then_some(())
131            .ok_or(ValidationError::InvalidOgg)
132    }
133
134    pub fn duration_ms(&self) -> u64 {
135        self.duration_ms
136    }
137
138    pub fn byte_length(&self) -> u64 {
139        self.byte_length
140    }
141
142    pub fn filename(&self) -> Option<&str> {
143        self.filename.as_deref()
144    }
145
146    pub fn media_type(&self) -> &'static str {
147        OGG_MEDIA_TYPE
148    }
149}
150
151fn validate_duration(duration_ms: u64) -> Result<(), ValidationError> {
152    (1..=MAX_AUDIO_DURATION_MS)
153        .contains(&duration_ms)
154        .then_some(())
155        .ok_or(ValidationError::InvalidDuration(duration_ms))
156}
157
158fn validate_optional_text(value: Option<&str>, field: &'static str) -> Result<(), ValidationError> {
159    if value.is_some_and(|text| text.trim().is_empty()) {
160        return Err(ValidationError::Blank(field));
161    }
162    Ok(())
163}
164
165fn validate_text(value: &str, field: &'static str) -> Result<(), ValidationError> {
166    (!value.trim().is_empty())
167        .then_some(())
168        .ok_or(ValidationError::Blank(field))
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
172pub struct LocalSpeakerLabel(u32);
173
174impl LocalSpeakerLabel {
175    pub fn new(number: u32) -> Result<Self, ValidationError> {
176        (number > 0)
177            .then_some(Self(number))
178            .ok_or_else(|| ValidationError::InvalidSpeakerLabel("Speaker 0".into()))
179    }
180
181    pub fn number(self) -> u32 {
182        self.0
183    }
184}
185
186impl fmt::Display for LocalSpeakerLabel {
187    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
188        write!(formatter, "Speaker {}", self.0)
189    }
190}
191
192impl FromStr for LocalSpeakerLabel {
193    type Err = ValidationError;
194
195    fn from_str(value: &str) -> Result<Self, Self::Err> {
196        let number = value
197            .strip_prefix("Speaker ")
198            .and_then(|value| value.parse::<u32>().ok())
199            .filter(|value| *value > 0)
200            .ok_or_else(|| ValidationError::InvalidSpeakerLabel(value.into()))?;
201        Ok(Self(number))
202    }
203}
204
205impl Serialize for LocalSpeakerLabel {
206    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
207        serializer.serialize_str(&self.to_string())
208    }
209}
210
211impl<'de> Deserialize<'de> for LocalSpeakerLabel {
212    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
213        String::deserialize(deserializer)?
214            .parse()
215            .map_err(serde::de::Error::custom)
216    }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum VocalGenderPresentation {
222    StronglyFeminine,
223    Feminine,
224    Androgynous,
225    Masculine,
226    StronglyMasculine,
227}
228
229impl VocalGenderPresentation {
230    pub fn numeric_value(self) -> f64 {
231        match self {
232            Self::StronglyFeminine => -2.0,
233            Self::Feminine => -1.0,
234            Self::Androgynous => 0.0,
235            Self::Masculine => 1.0,
236            Self::StronglyMasculine => 2.0,
237        }
238    }
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
242pub struct FeatureVector24 {
243    pub median_f0_hz: Option<f64>,
244    pub high_front_vowel_f1_hz: Option<f64>,
245    pub high_back_vowel_f2_hz: Option<f64>,
246    pub spectral_tilt_db_per_octave: Option<f64>,
247    pub cepstral_peak_prominence_db: Option<f64>,
248    pub foreign_accentedness_1_to_9: Option<f64>,
249    pub dominant_rhotic_realization: Option<String>,
250    pub unstressed_vowel_reduction_percent: Option<f64>,
251    pub high_front_vowel_f2_hz: Option<f64>,
252    pub low_vowel_f1_hz: Option<f64>,
253    pub h1_minus_h2_db: Option<f64>,
254    pub rhotic_f3_minus_f2_hz: Option<f64>,
255    pub word_initial_t_vot_ms: Option<f64>,
256    pub dominant_lateral_realization: Option<String>,
257    pub monophthongization_percent: Option<f64>,
258    pub vocal_gender_presentation: Option<VocalGenderPresentation>,
259    pub low_vowel_f2_hz: Option<f64>,
260    pub high_back_vowel_f1_hz: Option<f64>,
261    pub mean_formant_dispersion_hz: Option<f64>,
262    pub creaky_phonation_percent: Option<f64>,
263    pub hypernasality_0_to_4: Option<f64>,
264    pub sibilant_center_of_gravity_hz: Option<f64>,
265    pub consonant_cluster_reduction_percent: Option<f64>,
266    pub perceived_vocal_age_years: Option<f64>,
267}
268
269impl FeatureVector24 {
270    pub fn validate(&self) -> Result<(), ValidationError> {
271        for (name, value) in NUMERIC_FEATURE_NAMES.into_iter().zip(self.numeric_values()) {
272            if value.is_some_and(|number| !number.is_finite()) {
273                return Err(ValidationError::NonFiniteFeature(name));
274            }
275        }
276        validate_optional_text(
277            self.dominant_rhotic_realization.as_deref(),
278            "dominant_rhotic_realization",
279        )?;
280        validate_optional_text(
281            self.dominant_lateral_realization.as_deref(),
282            "dominant_lateral_realization",
283        )
284    }
285
286    pub fn numeric_values(&self) -> [Option<f64>; 22] {
287        [
288            self.median_f0_hz,
289            self.high_front_vowel_f1_hz,
290            self.high_back_vowel_f2_hz,
291            self.spectral_tilt_db_per_octave,
292            self.cepstral_peak_prominence_db,
293            self.foreign_accentedness_1_to_9,
294            self.unstressed_vowel_reduction_percent,
295            self.high_front_vowel_f2_hz,
296            self.low_vowel_f1_hz,
297            self.h1_minus_h2_db,
298            self.rhotic_f3_minus_f2_hz,
299            self.word_initial_t_vot_ms,
300            self.monophthongization_percent,
301            self.vocal_gender_presentation
302                .map(VocalGenderPresentation::numeric_value),
303            self.low_vowel_f2_hz,
304            self.high_back_vowel_f1_hz,
305            self.mean_formant_dispersion_hz,
306            self.creaky_phonation_percent,
307            self.hypernasality_0_to_4,
308            self.sibilant_center_of_gravity_hz,
309            self.consonant_cluster_reduction_percent,
310            self.perceived_vocal_age_years,
311        ]
312    }
313
314    pub fn nominal_values(&self) -> [Option<&str>; 2] {
315        [
316            self.dominant_rhotic_realization.as_deref(),
317            self.dominant_lateral_realization.as_deref(),
318        ]
319    }
320
321    pub fn present_feature_count(&self) -> u8 {
322        let numeric = self
323            .numeric_values()
324            .into_iter()
325            .filter(Option::is_some)
326            .count();
327        let nominal = self
328            .nominal_values()
329            .into_iter()
330            .filter(Option::is_some)
331            .count();
332        (numeric + nominal) as u8
333    }
334}
335
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337pub struct StructuredSpeaker {
338    pub speaker: LocalSpeakerLabel,
339    pub language: String,
340    pub features: FeatureVector24,
341    pub features_usable_for_training: bool,
342}
343
344impl StructuredSpeaker {
345    pub fn validate(&self) -> Result<(), ValidationError> {
346        validate_text(&self.language, "language")?;
347        self.features.validate()
348    }
349}
350
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352pub struct StructuredAnalysis {
353    pub transcript: String,
354    pub speakers: Vec<StructuredSpeaker>,
355}
356
357impl StructuredAnalysis {
358    pub fn validate(&self) -> Result<(), ValidationError> {
359        validate_text(&self.transcript, "transcript")?;
360        let mut labels = BTreeSet::new();
361        for speaker in &self.speakers {
362            speaker.validate()?;
363            if !labels.insert(speaker.speaker) {
364                return Err(ValidationError::DuplicateSpeakerLabel(speaker.speaker));
365            }
366        }
367        Ok(())
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    fn ogg(body_length: u8) -> Vec<u8> {
376        let mut bytes = vec![0; 28 + body_length as usize];
377        bytes[..4].copy_from_slice(b"OggS");
378        bytes[4] = 0;
379        bytes[26] = 1;
380        bytes[27] = body_length;
381        bytes
382    }
383
384    fn speaker(number: u32) -> StructuredSpeaker {
385        StructuredSpeaker {
386            speaker: LocalSpeakerLabel::new(number).unwrap(),
387            language: "English".into(),
388            features: FeatureVector24::default(),
389            features_usable_for_training: true,
390        }
391    }
392
393    #[test]
394    fn ogg_metadata_checks_page_and_duration() {
395        let bytes = ogg(3);
396        let metadata =
397            OggAudioMetadata::from_bytes(&bytes, MAX_AUDIO_DURATION_MS, Some("voice.ogg".into()))
398                .unwrap();
399        assert_eq!(metadata.media_type(), OGG_MEDIA_TYPE);
400        assert_eq!(metadata.byte_length(), bytes.len() as u64);
401        assert_eq!(metadata.filename(), Some("voice.ogg"));
402        assert_eq!(
403            OggAudioMetadata::from_bytes(&bytes, 0, None),
404            Err(ValidationError::InvalidDuration(0))
405        );
406        assert_eq!(
407            OggAudioMetadata::from_bytes(&bytes[..bytes.len() - 1], 1, None),
408            Err(ValidationError::InvalidOgg)
409        );
410        let mut wrong = bytes;
411        wrong[4] = 1;
412        assert_eq!(
413            OggAudioMetadata::from_bytes(&wrong, 1, None),
414            Err(ValidationError::InvalidOgg)
415        );
416    }
417
418    #[test]
419    fn speaker_labels_have_one_exact_form() {
420        let label = LocalSpeakerLabel::new(12).unwrap();
421        assert_eq!(label.to_string(), "Speaker 12");
422        assert_eq!("Speaker 12".parse(), Ok(label));
423        assert!("speaker 12".parse::<LocalSpeakerLabel>().is_err());
424        assert_eq!(serde_json::to_string(&label).unwrap(), "\"Speaker 12\"");
425        assert_eq!(
426            serde_json::from_str::<LocalSpeakerLabel>("\"Speaker 12\"").unwrap(),
427            label
428        );
429    }
430
431    #[test]
432    fn features_validate_and_project_in_frozen_order() {
433        let features = FeatureVector24 {
434            median_f0_hz: Some(100.0),
435            dominant_rhotic_realization: Some("tap".into()),
436            vocal_gender_presentation: Some(VocalGenderPresentation::Masculine),
437            perceived_vocal_age_years: Some(30.0),
438            ..FeatureVector24::default()
439        };
440        assert_eq!(features.numeric_values()[0], Some(100.0));
441        assert_eq!(features.numeric_values()[13], Some(1.0));
442        assert_eq!(features.numeric_values()[21], Some(30.0));
443        assert_eq!(features.nominal_values(), [Some("tap"), None]);
444        assert_eq!(features.present_feature_count(), 4);
445        assert!(features.validate().is_ok());
446
447        let invalid = FeatureVector24 {
448            hypernasality_0_to_4: Some(f64::NAN),
449            ..FeatureVector24::default()
450        };
451        assert_eq!(
452            invalid.validate(),
453            Err(ValidationError::NonFiniteFeature("hypernasality_0_to_4"))
454        );
455
456        let blank = FeatureVector24 {
457            dominant_lateral_realization: Some(" ".into()),
458            ..FeatureVector24::default()
459        };
460        assert_eq!(
461            blank.validate(),
462            Err(ValidationError::Blank("dominant_lateral_realization"))
463        );
464    }
465
466    #[test]
467    fn structured_analysis_validates_and_round_trips() {
468        let analysis = StructuredAnalysis {
469            transcript: "[high] Speaker 1: hello world".into(),
470            speakers: vec![speaker(1)],
471        };
472        analysis.validate().unwrap();
473        let encoded = serde_json::to_vec(&analysis).unwrap();
474        let decoded: StructuredAnalysis = serde_json::from_slice(&encoded).unwrap();
475        assert_eq!(decoded, analysis);
476        assert!(decoded.speakers[0].features_usable_for_training);
477
478        let duplicate = StructuredAnalysis {
479            transcript: "speech".into(),
480            speakers: vec![speaker(1), speaker(1)],
481        };
482        assert!(matches!(
483            duplicate.validate(),
484            Err(ValidationError::DuplicateSpeakerLabel(_))
485        ));
486    }
487}