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}