#![forbid(unsafe_code)]
use crate::{AudioError, AudioResult};
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct AudioDescriptionMetadata {
pub track_id: String,
pub label: String,
pub language: LanguageTag,
pub speaker: SpeakerInfo,
pub classification: ContentClassification,
pub wcag_level: WcagLevel,
pub is_extended: bool,
pub timing_mode: TimingMode,
pub metadata: HashMap<String, String>,
}
impl Default for AudioDescriptionMetadata {
fn default() -> Self {
Self {
track_id: String::new(),
label: String::from("Audio Description"),
language: LanguageTag::default(),
speaker: SpeakerInfo::default(),
classification: ContentClassification::default(),
wcag_level: WcagLevel::default(),
is_extended: false,
timing_mode: TimingMode::default(),
metadata: HashMap::new(),
}
}
}
impl AudioDescriptionMetadata {
#[must_use]
pub fn new(track_id: impl Into<String>, language: LanguageTag) -> Self {
Self {
track_id: track_id.into(),
language,
..Default::default()
}
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
#[must_use]
pub fn with_speaker(mut self, speaker: SpeakerInfo) -> Self {
self.speaker = speaker;
self
}
#[must_use]
pub fn with_classification(mut self, classification: ContentClassification) -> Self {
self.classification = classification;
self
}
#[must_use]
pub fn with_wcag_level(mut self, level: WcagLevel) -> Self {
self.wcag_level = level;
self
}
#[must_use]
pub fn with_extended(mut self, extended: bool) -> Self {
self.is_extended = extended;
self
}
#[must_use]
pub fn with_timing_mode(mut self, mode: TimingMode) -> Self {
self.timing_mode = mode;
self
}
#[must_use]
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn validate(&self) -> AudioResult<()> {
if self.track_id.is_empty() {
return Err(AudioError::InvalidParameter(
"Track ID cannot be empty".to_string(),
));
}
if self.label.is_empty() {
return Err(AudioError::InvalidParameter(
"Track label cannot be empty".to_string(),
));
}
self.language.validate()?;
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LanguageTag {
pub language: String,
pub script: Option<String>,
pub region: Option<String>,
pub variants: Vec<String>,
}
impl Default for LanguageTag {
fn default() -> Self {
Self {
language: String::from("en"),
script: None,
region: None,
variants: Vec::new(),
}
}
}
impl LanguageTag {
#[must_use]
pub fn new(language: impl Into<String>) -> Self {
Self {
language: language.into(),
..Default::default()
}
}
#[must_use]
pub fn with_script(mut self, script: impl Into<String>) -> Self {
self.script = Some(script.into());
self
}
#[must_use]
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
#[must_use]
pub fn with_variant(mut self, variant: impl Into<String>) -> Self {
self.variants.push(variant.into());
self
}
pub fn parse(tag: &str) -> AudioResult<Self> {
let parts: Vec<&str> = tag.split('-').collect();
if parts.is_empty() {
return Err(AudioError::InvalidParameter(
"Empty language tag".to_string(),
));
}
let language = parts[0].to_lowercase();
let mut script = None;
let mut region = None;
let mut variants = Vec::new();
let mut i = 1;
while i < parts.len() {
let part = parts[i];
if part.len() == 4 && part.chars().all(|c| c.is_ascii_alphabetic()) {
script = Some(part.to_string());
} else if part.len() == 2 && part.chars().all(|c| c.is_ascii_alphabetic()) {
region = Some(part.to_uppercase());
} else if part.len() == 3 && part.chars().all(|c| c.is_ascii_digit()) {
region = Some(part.to_string());
} else {
variants.push(part.to_string());
}
i += 1;
}
Ok(Self {
language,
script,
region,
variants,
})
}
#[must_use]
pub fn to_string(&self) -> String {
let mut parts = vec![self.language.clone()];
if let Some(ref script) = self.script {
parts.push(script.clone());
}
if let Some(ref region) = self.region {
parts.push(region.clone());
}
parts.extend(self.variants.clone());
parts.join("-")
}
pub fn validate(&self) -> AudioResult<()> {
if self.language.is_empty() {
return Err(AudioError::InvalidParameter(
"Language subtag cannot be empty".to_string(),
));
}
if self.language.len() < 2 || self.language.len() > 3 {
return Err(AudioError::InvalidParameter(format!(
"Invalid language subtag length: {}",
self.language
)));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SpeakerInfo {
pub name: String,
pub gender: SpeakerGender,
pub age: SpeakerAge,
pub voice_characteristics: VoiceCharacteristics,
}
impl Default for SpeakerInfo {
fn default() -> Self {
Self {
name: String::from("Narrator"),
gender: SpeakerGender::Neutral,
age: SpeakerAge::Adult,
voice_characteristics: VoiceCharacteristics::default(),
}
}
}
impl SpeakerInfo {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Default::default()
}
}
#[must_use]
pub fn with_gender(mut self, gender: SpeakerGender) -> Self {
self.gender = gender;
self
}
#[must_use]
pub fn with_age(mut self, age: SpeakerAge) -> Self {
self.age = age;
self
}
#[must_use]
pub fn with_voice_characteristics(mut self, characteristics: VoiceCharacteristics) -> Self {
self.voice_characteristics = characteristics;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SpeakerGender {
Male,
Female,
#[default]
Neutral,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SpeakerAge {
Child,
Teen,
#[default]
Adult,
Senior,
}
#[derive(Clone, Debug, PartialEq)]
pub struct VoiceCharacteristics {
pub pitch_hz: f64,
pub rate_wpm: f64,
pub volume: f64,
}
impl Default for VoiceCharacteristics {
fn default() -> Self {
Self {
pitch_hz: 120.0,
rate_wpm: 150.0,
volume: 1.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ContentClassification {
#[default]
General,
Educational,
News,
Entertainment,
Sports,
Documentary,
Children,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum WcagLevel {
A,
#[default]
AA,
AAA,
}
impl WcagLevel {
#[must_use]
pub fn meets(&self, other: WcagLevel) -> bool {
match (self, other) {
(WcagLevel::AAA, _) => true,
(WcagLevel::AA, WcagLevel::AAA) => false,
(WcagLevel::AA, _) => true,
(WcagLevel::A, WcagLevel::A) => true,
(WcagLevel::A, _) => false,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TimingMode {
#[default]
Standard,
Extended,
Continuous,
}
#[derive(Clone, Debug)]
pub struct DescriptionCue {
pub id: String,
pub start_time: f64,
pub end_time: f64,
pub text: Option<String>,
pub audio_path: Option<String>,
pub mixing_strategy: CueMixingStrategy,
pub priority: u8,
}
impl DescriptionCue {
#[must_use]
pub fn new(id: impl Into<String>, start_time: f64, end_time: f64) -> Self {
Self {
id: id.into(),
start_time,
end_time,
text: None,
audio_path: None,
mixing_strategy: CueMixingStrategy::default(),
priority: 128,
}
}
#[must_use]
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = Some(text.into());
self
}
#[must_use]
pub fn with_audio_path(mut self, path: impl Into<String>) -> Self {
self.audio_path = Some(path.into());
self
}
#[must_use]
pub fn with_mixing_strategy(mut self, strategy: CueMixingStrategy) -> Self {
self.mixing_strategy = strategy;
self
}
#[must_use]
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = priority;
self
}
#[must_use]
pub fn duration(&self) -> f64 {
self.end_time - self.start_time
}
pub fn validate(&self) -> AudioResult<()> {
if self.id.is_empty() {
return Err(AudioError::InvalidParameter(
"Cue ID cannot be empty".to_string(),
));
}
if self.start_time < 0.0 {
return Err(AudioError::InvalidParameter(format!(
"Invalid start time: {}",
self.start_time
)));
}
if self.end_time <= self.start_time {
return Err(AudioError::InvalidParameter(format!(
"End time must be after start time: {} <= {}",
self.end_time, self.start_time
)));
}
if self.text.is_none() && self.audio_path.is_none() {
return Err(AudioError::InvalidParameter(
"Cue must have either text or audio path".to_string(),
));
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum CueMixingStrategy {
#[default]
Duck,
Mix,
Replace,
Pause,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_language_tag_parse() {
let tag = LanguageTag::parse("en-US").unwrap();
assert_eq!(tag.language, "en");
assert_eq!(tag.region, Some("US".to_string()));
assert_eq!(tag.to_string(), "en-US");
}
#[test]
fn test_language_tag_complex() {
let tag = LanguageTag::parse("zh-Hans-CN").unwrap();
assert_eq!(tag.language, "zh");
assert_eq!(tag.script, Some("Hans".to_string()));
assert_eq!(tag.region, Some("CN".to_string()));
}
#[test]
fn test_wcag_level_meets() {
assert!(WcagLevel::AAA.meets(WcagLevel::A));
assert!(WcagLevel::AAA.meets(WcagLevel::AA));
assert!(WcagLevel::AAA.meets(WcagLevel::AAA));
assert!(WcagLevel::AA.meets(WcagLevel::A));
assert!(!WcagLevel::A.meets(WcagLevel::AA));
}
#[test]
fn test_description_cue_duration() {
let cue = DescriptionCue::new("cue1", 1.0, 3.5);
assert!((cue.duration() - 2.5).abs() < f64::EPSILON);
}
#[test]
fn test_metadata_validation() {
let mut metadata = AudioDescriptionMetadata::default();
assert!(metadata.validate().is_err());
metadata.track_id = "track1".to_string();
assert!(metadata.validate().is_ok());
}
#[test]
fn test_cue_validation() {
let cue = DescriptionCue::new("cue1", 1.0, 2.0).with_text("Test description");
assert!(cue.validate().is_ok());
let invalid_cue = DescriptionCue::new("", 1.0, 2.0);
assert!(invalid_cue.validate().is_err());
}
}