Skip to main content

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