use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::{collections::BTreeSet, error::Error, fmt, str::FromStr};
pub const MAX_AUDIO_DURATION_MS: u64 = 150_000;
pub const OGG_MEDIA_TYPE: &str = "audio/ogg";
pub const FEATURE_SCHEMA_REVISION: &str = "speaker-v3-features-24-r1";
pub const FEATURE_NAMES: [&str; 24] = [
"median_f0_hz",
"high_front_vowel_f1_hz",
"high_back_vowel_f2_hz",
"spectral_tilt_db_per_octave",
"cepstral_peak_prominence_db",
"foreign_accentedness_1_to_9",
"dominant_rhotic_realization",
"unstressed_vowel_reduction_percent",
"high_front_vowel_f2_hz",
"low_vowel_f1_hz",
"h1_minus_h2_db",
"rhotic_f3_minus_f2_hz",
"word_initial_t_vot_ms",
"dominant_lateral_realization",
"monophthongization_percent",
"vocal_gender_presentation",
"low_vowel_f2_hz",
"high_back_vowel_f1_hz",
"mean_formant_dispersion_hz",
"creaky_phonation_percent",
"hypernasality_0_to_4",
"sibilant_center_of_gravity_hz",
"consonant_cluster_reduction_percent",
"perceived_vocal_age_years",
];
const NUMERIC_FEATURE_NAMES: [&str; 22] = [
"median_f0_hz",
"high_front_vowel_f1_hz",
"high_back_vowel_f2_hz",
"spectral_tilt_db_per_octave",
"cepstral_peak_prominence_db",
"foreign_accentedness_1_to_9",
"unstressed_vowel_reduction_percent",
"high_front_vowel_f2_hz",
"low_vowel_f1_hz",
"h1_minus_h2_db",
"rhotic_f3_minus_f2_hz",
"word_initial_t_vot_ms",
"monophthongization_percent",
"vocal_gender_presentation",
"low_vowel_f2_hz",
"high_back_vowel_f1_hz",
"mean_formant_dispersion_hz",
"creaky_phonation_percent",
"hypernasality_0_to_4",
"sibilant_center_of_gravity_hz",
"consonant_cluster_reduction_percent",
"perceived_vocal_age_years",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
InvalidOgg,
InvalidDuration(u64),
ByteLengthOverflow,
Blank(&'static str),
InvalidSpeakerLabel(String),
DuplicateSpeakerLabel(LocalSpeakerLabel),
NonFiniteFeature(&'static str),
}
impl fmt::Display for ValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidOgg => {
formatter.write_str("audio is not a complete valid Ogg Opus stream")
}
Self::InvalidDuration(value) => {
write!(formatter, "invalid audio duration: {value} ms")
}
Self::ByteLengthOverflow => formatter.write_str("audio byte length exceeds u64"),
Self::Blank(field) => write!(formatter, "{field} is blank"),
Self::InvalidSpeakerLabel(value) => {
write!(formatter, "invalid speaker label: {value}")
}
Self::DuplicateSpeakerLabel(value) => {
write!(formatter, "duplicate speaker label: {value}")
}
Self::NonFiniteFeature(field) => write!(formatter, "{field} is not finite"),
}
}
}
impl Error for ValidationError {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OggAudioMetadata {
duration_ms: u64,
byte_length: u64,
filename: Option<String>,
}
impl OggAudioMetadata {
pub fn from_bytes(
bytes: &[u8],
duration_ms: u64,
filename: Option<String>,
) -> Result<Self, ValidationError> {
validate_duration(duration_ms)?;
validate_optional_text(filename.as_deref(), "filename")?;
if bytes.len() < 27 || &bytes[..4] != b"OggS" || bytes[4] != 0 {
return Err(ValidationError::InvalidOgg);
}
let body_start = 27 + bytes[26] as usize;
if bytes.len() < body_start {
return Err(ValidationError::InvalidOgg);
}
let body_length: usize = bytes[27..body_start]
.iter()
.map(|value| *value as usize)
.sum();
if bytes.len() < body_start + body_length {
return Err(ValidationError::InvalidOgg);
}
Ok(Self {
duration_ms,
byte_length: u64::try_from(bytes.len())
.map_err(|_| ValidationError::ByteLengthOverflow)?,
filename,
})
}
pub fn from_ogg_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
let stream = StrictOgg::parse(bytes)?;
let samples = stream
.final_granule
.checked_sub(u64::from(stream.pre_skip))
.ok_or(ValidationError::InvalidOgg)?;
let duration_ms = samples / 48 + u64::from(samples % 48 != 0);
validate_duration(duration_ms)?;
Ok(Self {
duration_ms,
byte_length: u64::try_from(bytes.len())
.map_err(|_| ValidationError::ByteLengthOverflow)?,
filename: None,
})
}
pub fn validate(&self) -> Result<(), ValidationError> {
validate_duration(self.duration_ms)?;
validate_optional_text(self.filename.as_deref(), "filename")?;
(self.byte_length >= 27)
.then_some(())
.ok_or(ValidationError::InvalidOgg)
}
pub fn duration_ms(&self) -> u64 {
self.duration_ms
}
pub fn byte_length(&self) -> u64 {
self.byte_length
}
pub fn filename(&self) -> Option<&str> {
self.filename.as_deref()
}
pub fn media_type(&self) -> &'static str {
OGG_MEDIA_TYPE
}
}
struct StrictOgg {
pre_skip: u16,
final_granule: u64,
}
impl StrictOgg {
fn parse(bytes: &[u8]) -> Result<Self, ValidationError> {
let mut offset = 0usize;
let mut serial = None;
let mut expected_sequence = 0u32;
let mut saw_bos = false;
let mut saw_eos = false;
let mut packet = Vec::new();
let mut packet_open = false;
let mut first_packet = None;
let mut last_granule = None;
let mut final_granule = None;
while offset < bytes.len() {
let page = Page::read(bytes, offset)?;
offset = page.end;
if saw_eos || page.header_type & !0x07 != 0 {
return Err(ValidationError::InvalidOgg);
}
if serial
.replace(page.serial)
.is_some_and(|known| known != page.serial)
{
return Err(ValidationError::InvalidOgg);
}
if page.sequence != expected_sequence {
return Err(ValidationError::InvalidOgg);
}
expected_sequence = expected_sequence
.checked_add(1)
.ok_or(ValidationError::InvalidOgg)?;
let continuation = page.header_type & 0x01 != 0;
let bos = page.header_type & 0x02 != 0;
let eos = page.header_type & 0x04 != 0;
if bos != !saw_bos || continuation != packet_open {
return Err(ValidationError::InvalidOgg);
}
saw_bos = true;
if let Some(granule) = page.granule {
if last_granule.is_some_and(|last| granule < last) {
return Err(ValidationError::InvalidOgg);
}
last_granule = Some(granule);
}
let mut body_offset = page.body_start;
for lace in page.laces {
let end = body_offset
.checked_add(usize::from(*lace))
.ok_or(ValidationError::InvalidOgg)?;
packet.extend_from_slice(&bytes[body_offset..end]);
body_offset = end;
packet_open = *lace == 255;
if !packet_open {
if first_packet.is_none() {
first_packet = Some(std::mem::take(&mut packet));
} else {
packet.clear();
}
}
}
if eos {
if packet_open || first_packet.is_none() || page.granule.is_none() {
return Err(ValidationError::InvalidOgg);
}
saw_eos = true;
final_granule = page.granule;
}
}
if !saw_bos || !saw_eos {
return Err(ValidationError::InvalidOgg);
}
let head = first_packet.ok_or(ValidationError::InvalidOgg)?;
let pre_skip = parse_opus_head(&head)?;
Ok(Self {
pre_skip,
final_granule: final_granule.ok_or(ValidationError::InvalidOgg)?,
})
}
}
struct Page<'a> {
header_type: u8,
granule: Option<u64>,
serial: u32,
sequence: u32,
laces: &'a [u8],
body_start: usize,
end: usize,
}
impl<'a> Page<'a> {
fn read(bytes: &'a [u8], offset: usize) -> Result<Self, ValidationError> {
let header = bytes
.get(offset..offset + 27)
.ok_or(ValidationError::InvalidOgg)?;
if &header[..4] != b"OggS" || header[4] != 0 {
return Err(ValidationError::InvalidOgg);
}
let segments = usize::from(header[26]);
let laces = bytes
.get(offset + 27..offset + 27 + segments)
.ok_or(ValidationError::InvalidOgg)?;
let body_length: usize = laces.iter().map(|&lace| usize::from(lace)).sum();
let body_start = offset + 27 + segments;
let end = body_start
.checked_add(body_length)
.ok_or(ValidationError::InvalidOgg)?;
if end > bytes.len() {
return Err(ValidationError::InvalidOgg);
}
let expected_crc = u32::from_le_bytes(
header[22..26]
.try_into()
.map_err(|_| ValidationError::InvalidOgg)?,
);
if ogg_crc(&bytes[offset..end]) != expected_crc {
return Err(ValidationError::InvalidOgg);
}
let raw_granule = u64::from_le_bytes(
header[6..14]
.try_into()
.map_err(|_| ValidationError::InvalidOgg)?,
);
Ok(Self {
header_type: header[5],
granule: (raw_granule != u64::MAX).then_some(raw_granule),
serial: u32::from_le_bytes(
header[14..18]
.try_into()
.map_err(|_| ValidationError::InvalidOgg)?,
),
sequence: u32::from_le_bytes(
header[18..22]
.try_into()
.map_err(|_| ValidationError::InvalidOgg)?,
),
laces,
body_start,
end,
})
}
}
fn parse_opus_head(packet: &[u8]) -> Result<u16, ValidationError> {
if packet.len() != 19
|| &packet[..8] != b"OpusHead"
|| packet[8] != 1
|| !(1..=2).contains(&packet[9])
|| packet[18] != 0
{
return Err(ValidationError::InvalidOgg);
}
Ok(u16::from_le_bytes([packet[10], packet[11]]))
}
fn ogg_crc(page: &[u8]) -> u32 {
let mut crc = 0u32;
for (index, &byte) in page.iter().enumerate() {
let byte = if (22..26).contains(&index) { 0 } else { byte };
crc ^= u32::from(byte) << 24;
for _ in 0..8 {
crc = if crc & 0x8000_0000 != 0 {
(crc << 1) ^ 0x04c1_1db7
} else {
crc << 1
};
}
}
crc
}
fn validate_duration(duration_ms: u64) -> Result<(), ValidationError> {
(1..=MAX_AUDIO_DURATION_MS)
.contains(&duration_ms)
.then_some(())
.ok_or(ValidationError::InvalidDuration(duration_ms))
}
fn validate_optional_text(value: Option<&str>, field: &'static str) -> Result<(), ValidationError> {
if value.is_some_and(|text| text.trim().is_empty()) {
return Err(ValidationError::Blank(field));
}
Ok(())
}
fn validate_text(value: &str, field: &'static str) -> Result<(), ValidationError> {
(!value.trim().is_empty())
.then_some(())
.ok_or(ValidationError::Blank(field))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LocalSpeakerLabel(u32);
impl LocalSpeakerLabel {
pub fn new(number: u32) -> Result<Self, ValidationError> {
(number > 0)
.then_some(Self(number))
.ok_or_else(|| ValidationError::InvalidSpeakerLabel("Speaker 0".into()))
}
pub fn number(self) -> u32 {
self.0
}
}
impl fmt::Display for LocalSpeakerLabel {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "Speaker {}", self.0)
}
}
impl FromStr for LocalSpeakerLabel {
type Err = ValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
value
.strip_prefix("Speaker ")
.and_then(|number| number.parse::<u32>().ok())
.filter(|number| *number > 0)
.map(Self)
.ok_or_else(|| ValidationError::InvalidSpeakerLabel(value.into()))
}
}
impl Serialize for LocalSpeakerLabel {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for LocalSpeakerLabel {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VocalGenderPresentation {
StronglyFeminine,
Feminine,
Androgynous,
Masculine,
StronglyMasculine,
}
impl VocalGenderPresentation {
pub fn numeric_value(self) -> f64 {
match self {
Self::StronglyFeminine => -2.0,
Self::Feminine => -1.0,
Self::Androgynous => 0.0,
Self::Masculine => 1.0,
Self::StronglyMasculine => 2.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct FeatureVector24 {
pub median_f0_hz: Option<f64>,
pub high_front_vowel_f1_hz: Option<f64>,
pub high_back_vowel_f2_hz: Option<f64>,
pub spectral_tilt_db_per_octave: Option<f64>,
pub cepstral_peak_prominence_db: Option<f64>,
pub foreign_accentedness_1_to_9: Option<f64>,
pub dominant_rhotic_realization: Option<String>,
pub unstressed_vowel_reduction_percent: Option<f64>,
pub high_front_vowel_f2_hz: Option<f64>,
pub low_vowel_f1_hz: Option<f64>,
pub h1_minus_h2_db: Option<f64>,
pub rhotic_f3_minus_f2_hz: Option<f64>,
pub word_initial_t_vot_ms: Option<f64>,
pub dominant_lateral_realization: Option<String>,
pub monophthongization_percent: Option<f64>,
pub vocal_gender_presentation: Option<VocalGenderPresentation>,
pub low_vowel_f2_hz: Option<f64>,
pub high_back_vowel_f1_hz: Option<f64>,
pub mean_formant_dispersion_hz: Option<f64>,
pub creaky_phonation_percent: Option<f64>,
pub hypernasality_0_to_4: Option<f64>,
pub sibilant_center_of_gravity_hz: Option<f64>,
pub consonant_cluster_reduction_percent: Option<f64>,
pub perceived_vocal_age_years: Option<f64>,
}
impl FeatureVector24 {
pub fn validate(&self) -> Result<(), ValidationError> {
for (name, value) in NUMERIC_FEATURE_NAMES.into_iter().zip(self.numeric_values()) {
if value.is_some_and(|number| !number.is_finite()) {
return Err(ValidationError::NonFiniteFeature(name));
}
}
validate_optional_text(
self.dominant_rhotic_realization.as_deref(),
"dominant_rhotic_realization",
)?;
validate_optional_text(
self.dominant_lateral_realization.as_deref(),
"dominant_lateral_realization",
)
}
pub fn numeric_values(&self) -> [Option<f64>; 22] {
[
self.median_f0_hz,
self.high_front_vowel_f1_hz,
self.high_back_vowel_f2_hz,
self.spectral_tilt_db_per_octave,
self.cepstral_peak_prominence_db,
self.foreign_accentedness_1_to_9,
self.unstressed_vowel_reduction_percent,
self.high_front_vowel_f2_hz,
self.low_vowel_f1_hz,
self.h1_minus_h2_db,
self.rhotic_f3_minus_f2_hz,
self.word_initial_t_vot_ms,
self.monophthongization_percent,
self.vocal_gender_presentation
.map(VocalGenderPresentation::numeric_value),
self.low_vowel_f2_hz,
self.high_back_vowel_f1_hz,
self.mean_formant_dispersion_hz,
self.creaky_phonation_percent,
self.hypernasality_0_to_4,
self.sibilant_center_of_gravity_hz,
self.consonant_cluster_reduction_percent,
self.perceived_vocal_age_years,
]
}
pub fn nominal_values(&self) -> [Option<&str>; 2] {
[
self.dominant_rhotic_realization.as_deref(),
self.dominant_lateral_realization.as_deref(),
]
}
pub fn present_feature_count(&self) -> u8 {
(self
.numeric_values()
.into_iter()
.filter(Option::is_some)
.count()
+ self
.nominal_values()
.into_iter()
.filter(Option::is_some)
.count()) as u8
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StructuredSpeaker {
pub speaker: LocalSpeakerLabel,
pub language: String,
pub features: FeatureVector24,
pub features_usable_for_training: bool,
}
impl StructuredSpeaker {
pub fn validate(&self) -> Result<(), ValidationError> {
validate_text(&self.language, "language")?;
self.features.validate()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StructuredAnalysis {
pub transcript: String,
pub speakers: Vec<StructuredSpeaker>,
}
impl StructuredAnalysis {
pub fn validate(&self) -> Result<(), ValidationError> {
validate_text(&self.transcript, "transcript")?;
let mut labels = BTreeSet::new();
for speaker in &self.speakers {
speaker.validate()?;
if !labels.insert(speaker.speaker) {
return Err(ValidationError::DuplicateSpeakerLabel(speaker.speaker));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn page(
serial: u32,
sequence: u32,
flags: u8,
granule: u64,
laces: &[u8],
body: &[u8],
) -> Vec<u8> {
assert_eq!(
laces.iter().map(|&value| usize::from(value)).sum::<usize>(),
body.len()
);
let mut bytes = Vec::with_capacity(27 + laces.len() + body.len());
bytes.extend_from_slice(b"OggS");
bytes.push(0);
bytes.push(flags);
bytes.extend_from_slice(&granule.to_le_bytes());
bytes.extend_from_slice(&serial.to_le_bytes());
bytes.extend_from_slice(&sequence.to_le_bytes());
bytes.extend_from_slice(&[0; 4]);
bytes.push(laces.len() as u8);
bytes.extend_from_slice(laces);
bytes.extend_from_slice(body);
set_crc(&mut bytes);
bytes
}
fn set_crc(page: &mut [u8]) {
page[22..26].fill(0);
let crc = ogg_crc(page);
page[22..26].copy_from_slice(&crc.to_le_bytes());
}
fn head(pre_skip: u16, channels: u8, mapping: u8) -> Vec<u8> {
let mut value = b"OpusHead".to_vec();
value.push(1);
value.push(channels);
value.extend_from_slice(&pre_skip.to_le_bytes());
value.extend_from_slice(&48_000u32.to_le_bytes());
value.extend_from_slice(&0u16.to_le_bytes());
value.push(mapping);
value
}
fn one_page_stream(pre_skip: u16, granule: u64) -> Vec<u8> {
let mut body = head(pre_skip, 1, 0);
body.push(0);
page(7, 0, 0x06, granule, &[19, 1], &body)
}
fn two_page_stream(pre_skip: u16, granule: u64) -> Vec<u8> {
let head = head(pre_skip, 1, 0);
let mut bytes = page(7, 0, 0x02, 0, &[19], &head);
bytes.extend(page(7, 1, 0x04, granule, &[1], &[0]));
bytes
}
fn assert_invalid(bytes: &[u8]) {
assert_eq!(
OggAudioMetadata::from_ogg_bytes(bytes),
Err(ValidationError::InvalidOgg)
);
}
#[test]
fn strict_ogg_accepts_single_and_multiple_pages() {
let one = one_page_stream(0, 48);
assert_eq!(
OggAudioMetadata::from_ogg_bytes(&one)
.unwrap()
.duration_ms(),
1
);
let head = head(48, 2, 0);
let mut multi = page(9, 0, 0x02, 0, &[19], &head);
multi.extend(page(9, 1, 0, 96, &[1], &[3]));
multi.extend(page(9, 2, 0x04, 144, &[1], &[4]));
assert_eq!(
OggAudioMetadata::from_ogg_bytes(&multi)
.unwrap()
.duration_ms(),
2
);
}
#[test]
fn strict_ogg_uses_pre_skip_and_ceiling_duration() {
assert_eq!(
OggAudioMetadata::from_ogg_bytes(&two_page_stream(47, 48))
.unwrap()
.duration_ms(),
1
);
let exact = two_page_stream(0, MAX_AUDIO_DURATION_MS * 48);
assert_eq!(
OggAudioMetadata::from_ogg_bytes(&exact)
.unwrap()
.duration_ms(),
MAX_AUDIO_DURATION_MS
);
assert_eq!(
OggAudioMetadata::from_ogg_bytes(&two_page_stream(0, MAX_AUDIO_DURATION_MS * 48 + 1,)),
Err(ValidationError::InvalidDuration(MAX_AUDIO_DURATION_MS + 1))
);
assert_eq!(
OggAudioMetadata::from_ogg_bytes(&two_page_stream(48, 48)),
Err(ValidationError::InvalidDuration(0))
);
}
#[test]
fn strict_ogg_rejects_truncation_crc_and_opus_head_errors() {
let valid = two_page_stream(0, 48);
assert_invalid(&valid[..valid.len() - 1]);
let mut corrupt = valid.clone();
corrupt[30] ^= 1;
assert_invalid(&corrupt);
let mut missing = b"NotHead".to_vec();
missing.resize(19, 0);
let mut bytes = page(1, 0, 0x02, 0, &[19], &missing);
bytes.extend(page(1, 1, 0x04, 48, &[1], &[0]));
assert_invalid(&bytes);
for (version, channels, mapping) in [(2, 1, 0), (1, 0, 0), (1, 3, 0), (1, 1, 1)] {
let mut opus_head = head(0, channels, mapping);
opus_head[8] = version;
let mut bytes = page(1, 0, 0x02, 0, &[19], &opus_head);
bytes.extend(page(1, 1, 0x04, 48, &[1], &[0]));
assert_invalid(&bytes);
}
}
#[test]
fn strict_ogg_rejects_stream_structure_errors() {
let valid = two_page_stream(0, 48);
let mut no_bos = valid.clone();
no_bos[5] = 0;
set_crc(&mut no_bos[..47]);
assert_invalid(&no_bos);
let mut no_eos = valid.clone();
no_eos[52] = 0;
set_crc(&mut no_eos[47..]);
assert_invalid(&no_eos);
let mut wrong_serial = page(1, 0, 0x02, 0, &[19], &head(0, 1, 0));
wrong_serial.extend(page(2, 1, 0x04, 48, &[1], &[0]));
assert_invalid(&wrong_serial);
let mut wrong_sequence = page(1, 0, 0x02, 0, &[19], &head(0, 1, 0));
wrong_sequence.extend(page(1, 2, 0x04, 48, &[1], &[0]));
assert_invalid(&wrong_sequence);
let mut decreasing = page(1, 0, 0x02, 10, &[19], &head(0, 1, 0));
decreasing.extend(page(1, 1, 0x04, 9, &[1], &[0]));
assert_invalid(&decreasing);
let mut chained = valid;
chained.extend(page(3, 0, 0x06, 48, &[1], &[0]));
assert_invalid(&chained);
}
#[test]
fn strict_ogg_rejects_bad_packet_continuation() {
let mut unexpected_continuation = page(1, 0, 0x03, 0, &[19], &head(0, 1, 0));
unexpected_continuation.extend(page(1, 1, 0x04, 48, &[1], &[0]));
assert_invalid(&unexpected_continuation);
let mut incomplete_head = head(0, 1, 0);
incomplete_head.resize(255, 0);
let mut missing_continuation = page(1, 0, 0x02, 0, &[255], &incomplete_head);
missing_continuation.extend(page(1, 1, 0x04, 48, &[1], &[0]));
assert_invalid(&missing_continuation);
}
#[test]
fn compatibility_constructor_is_unchanged() {
let mut bytes = vec![0; 28];
bytes[..4].copy_from_slice(b"OggS");
bytes[26] = 1;
bytes[27] = 0;
let metadata = OggAudioMetadata::from_bytes(&bytes, 1, Some("voice.ogg".into())).unwrap();
assert_eq!(metadata.filename(), Some("voice.ogg"));
assert_eq!(
OggAudioMetadata::from_bytes(&bytes, 0, None),
Err(ValidationError::InvalidDuration(0))
);
}
#[test]
fn speaker_labels_and_features_remain_compatible() {
let label = LocalSpeakerLabel::new(12).unwrap();
assert_eq!(label.to_string(), "Speaker 12");
assert_eq!("Speaker 12".parse(), Ok(label));
assert_eq!(serde_json::to_string(&label).unwrap(), "\"Speaker 12\"");
let features = FeatureVector24 {
median_f0_hz: Some(100.0),
dominant_rhotic_realization: Some("tap".into()),
vocal_gender_presentation: Some(VocalGenderPresentation::Masculine),
..Default::default()
};
assert_eq!(features.numeric_values()[13], Some(1.0));
assert_eq!(features.present_feature_count(), 3);
assert!(features.validate().is_ok());
}
#[test]
fn structured_analysis_remains_compatible() {
let speaker = StructuredSpeaker {
speaker: LocalSpeakerLabel::new(1).unwrap(),
language: "English".into(),
features: FeatureVector24::default(),
features_usable_for_training: true,
};
let analysis = StructuredAnalysis {
transcript: "speech".into(),
speakers: vec![speaker.clone()],
};
analysis.validate().unwrap();
let duplicate = StructuredAnalysis {
transcript: "speech".into(),
speakers: vec![speaker.clone(), speaker],
};
assert!(matches!(
duplicate.validate(),
Err(ValidationError::DuplicateSpeakerLabel(_))
));
}
}