#![forbid(unsafe_code)]
#![allow(clippy::cast_lossless)]
#![allow(clippy::cast_precision_loss)]
use super::metadata::DescriptionCue;
use crate::AudioResult;
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TimingPrecision {
#[default]
Frame,
Sample,
Millisecond,
}
#[derive(Clone, Debug)]
pub struct TimingConfig {
pub precision: TimingPrecision,
pub min_gap_seconds: f64,
pub max_overlap_seconds: f64,
pub auto_fill_gaps: bool,
pub detect_overlaps: bool,
pub snap_to_frames: bool,
pub frame_rate: f64,
}
impl Default for TimingConfig {
fn default() -> Self {
Self {
precision: TimingPrecision::Frame,
min_gap_seconds: 0.1,
max_overlap_seconds: 0.0,
auto_fill_gaps: false,
detect_overlaps: true,
snap_to_frames: true,
frame_rate: 30.0,
}
}
}
impl TimingConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_precision(mut self, precision: TimingPrecision) -> Self {
self.precision = precision;
self
}
#[must_use]
pub fn with_min_gap(mut self, seconds: f64) -> Self {
self.min_gap_seconds = seconds.max(0.0);
self
}
#[must_use]
pub fn with_max_overlap(mut self, seconds: f64) -> Self {
self.max_overlap_seconds = seconds.max(0.0);
self
}
#[must_use]
pub fn with_auto_fill_gaps(mut self, enabled: bool) -> Self {
self.auto_fill_gaps = enabled;
self
}
#[must_use]
pub fn with_overlap_detection(mut self, enabled: bool) -> Self {
self.detect_overlaps = enabled;
self
}
#[must_use]
pub fn with_frame_rate(mut self, frame_rate: f64) -> Self {
self.frame_rate = frame_rate.max(1.0);
self.snap_to_frames = true;
self
}
#[must_use]
pub fn broadcast() -> Self {
Self {
precision: TimingPrecision::Frame,
min_gap_seconds: 0.0,
max_overlap_seconds: 0.0,
auto_fill_gaps: false,
detect_overlaps: true,
snap_to_frames: true,
frame_rate: 30.0,
}
}
#[must_use]
pub fn flexible() -> Self {
Self {
precision: TimingPrecision::Millisecond,
min_gap_seconds: 0.0,
max_overlap_seconds: 0.5,
auto_fill_gaps: true,
detect_overlaps: false,
snap_to_frames: false,
frame_rate: 30.0,
}
}
}
#[derive(Clone, Debug)]
pub struct TimingValidation {
pub is_valid: bool,
pub issues: Vec<TimingIssue>,
}
impl TimingValidation {
#[must_use]
pub fn valid() -> Self {
Self {
is_valid: true,
issues: Vec::new(),
}
}
#[must_use]
pub fn invalid(issues: Vec<TimingIssue>) -> Self {
Self {
is_valid: false,
issues,
}
}
pub fn add_issue(&mut self, issue: TimingIssue) {
self.is_valid = false;
self.issues.push(issue);
}
}
#[derive(Clone, Debug)]
pub enum TimingIssue {
GapTooSmall {
prev_cue_id: String,
next_cue_id: String,
gap: f64,
min_gap: f64,
},
Overlap {
cue_id_1: String,
cue_id_2: String,
overlap: f64,
},
InvalidDuration {
cue_id: String,
duration: f64,
},
OutOfBounds {
cue_id: String,
start_time: f64,
end_time: f64,
},
}
pub struct Timeline {
config: TimingConfig,
sample_rate: f64,
cues: BTreeMap<u64, DescriptionCue>,
total_duration: f64,
}
impl Timeline {
#[must_use]
pub fn new(config: TimingConfig, sample_rate: f64) -> Self {
Self {
config,
sample_rate,
cues: BTreeMap::new(),
total_duration: 0.0,
}
}
pub fn set_duration(&mut self, duration: f64) {
self.total_duration = duration;
}
pub fn add_cue(&mut self, mut cue: DescriptionCue) -> AudioResult<()> {
cue.validate()?;
if self.config.snap_to_frames {
cue.start_time = self.snap_to_frame(cue.start_time);
cue.end_time = self.snap_to_frame(cue.end_time);
}
let key = self.time_to_key(cue.start_time);
self.cues.insert(key, cue);
Ok(())
}
pub fn remove_cue(&mut self, cue_id: &str) -> bool {
let key_to_remove = self
.cues
.iter()
.find(|(_, cue)| cue.id == cue_id)
.map(|(k, _)| *k);
if let Some(key) = key_to_remove {
self.cues.remove(&key);
true
} else {
false
}
}
#[must_use]
pub fn get_cue_at(&self, time: f64) -> Option<&DescriptionCue> {
self.cues
.values()
.find(|cue| time >= cue.start_time && time < cue.end_time)
}
#[must_use]
pub fn get_cues_in_range(&self, start: f64, end: f64) -> Vec<&DescriptionCue> {
self.cues
.values()
.filter(|cue| {
(cue.start_time >= start && cue.start_time < end)
|| (cue.end_time > start && cue.end_time <= end)
|| (cue.start_time < start && cue.end_time > end)
})
.collect()
}
#[must_use]
pub fn get_all_cues(&self) -> Vec<&DescriptionCue> {
self.cues.values().collect()
}
pub fn validate(&self) -> TimingValidation {
let mut validation = TimingValidation::valid();
let cues: Vec<_> = self.cues.values().collect();
for cue in &cues {
if cue.duration() <= 0.0 {
validation.add_issue(TimingIssue::InvalidDuration {
cue_id: cue.id.clone(),
duration: cue.duration(),
});
}
if self.total_duration > 0.0 && cue.end_time > self.total_duration {
validation.add_issue(TimingIssue::OutOfBounds {
cue_id: cue.id.clone(),
start_time: cue.start_time,
end_time: cue.end_time,
});
}
}
if self.config.detect_overlaps || self.config.min_gap_seconds > 0.0 {
for i in 0..cues.len().saturating_sub(1) {
let current = cues[i];
let next = cues[i + 1];
if current.end_time > next.start_time {
let overlap = current.end_time - next.start_time;
if self.config.detect_overlaps && overlap > self.config.max_overlap_seconds {
validation.add_issue(TimingIssue::Overlap {
cue_id_1: current.id.clone(),
cue_id_2: next.id.clone(),
overlap,
});
}
} else {
let gap = next.start_time - current.end_time;
if gap < self.config.min_gap_seconds {
validation.add_issue(TimingIssue::GapTooSmall {
prev_cue_id: current.id.clone(),
next_cue_id: next.id.clone(),
gap,
min_gap: self.config.min_gap_seconds,
});
}
}
}
}
validation
}
#[must_use]
pub fn detect_gaps(&self) -> Vec<TimeGap> {
let mut gaps = Vec::new();
let cues: Vec<_> = self.cues.values().collect();
if cues.is_empty() {
if self.total_duration > 0.0 {
gaps.push(TimeGap {
start: 0.0,
end: self.total_duration,
duration: self.total_duration,
});
}
return gaps;
}
if cues[0].start_time > 0.0 {
gaps.push(TimeGap {
start: 0.0,
end: cues[0].start_time,
duration: cues[0].start_time,
});
}
for i in 0..cues.len().saturating_sub(1) {
let current = cues[i];
let next = cues[i + 1];
if next.start_time > current.end_time {
gaps.push(TimeGap {
start: current.end_time,
end: next.start_time,
duration: next.start_time - current.end_time,
});
}
}
if self.total_duration > 0.0 {
if let Some(last) = cues.last() {
if last.end_time < self.total_duration {
gaps.push(TimeGap {
start: last.end_time,
end: self.total_duration,
duration: self.total_duration - last.end_time,
});
}
}
}
gaps
}
#[must_use]
pub fn time_to_sample(&self, time: f64) -> usize {
(time * self.sample_rate) as usize
}
#[must_use]
pub fn sample_to_time(&self, sample: usize) -> f64 {
sample as f64 / self.sample_rate
}
fn snap_to_frame(&self, time: f64) -> f64 {
if !self.config.snap_to_frames {
return time;
}
let frame_duration = 1.0 / self.config.frame_rate;
let frame_number = (time / frame_duration).round();
frame_number * frame_duration
}
fn time_to_key(&self, time: f64) -> u64 {
match self.config.precision {
TimingPrecision::Sample => (time * self.sample_rate) as u64,
TimingPrecision::Frame => ((time * self.config.frame_rate).round() * 1000.0) as u64,
TimingPrecision::Millisecond => (time * 1000.0) as u64,
}
}
pub fn clear(&mut self) {
self.cues.clear();
}
#[must_use]
pub fn cue_count(&self) -> usize {
self.cues.len()
}
}
#[derive(Clone, Debug)]
pub struct TimeGap {
pub start: f64,
pub end: f64,
pub duration: f64,
}
pub struct SubtitleSync {
timeline: Timeline,
subtitle_offset: f64,
}
impl SubtitleSync {
#[must_use]
pub fn new(config: TimingConfig, sample_rate: f64) -> Self {
Self {
timeline: Timeline::new(config, sample_rate),
subtitle_offset: 0.0,
}
}
pub fn set_offset(&mut self, offset: f64) {
self.subtitle_offset = offset;
}
#[must_use]
pub fn offset(&self) -> f64 {
self.subtitle_offset
}
pub fn sync_cue(&mut self, mut cue: DescriptionCue) -> AudioResult<()> {
cue.start_time += self.subtitle_offset;
cue.end_time += self.subtitle_offset;
self.timeline.add_cue(cue)
}
#[must_use]
pub fn get_cue_at(&self, time: f64) -> Option<&DescriptionCue> {
self.timeline.get_cue_at(time + self.subtitle_offset)
}
#[must_use]
pub fn timeline(&self) -> &Timeline {
&self.timeline
}
pub fn timeline_mut(&mut self) -> &mut Timeline {
&mut self.timeline
}
}
pub struct CueScheduler {
timeline: Timeline,
position: f64,
active_cue: Option<DescriptionCue>,
lookahead: f64,
}
impl CueScheduler {
#[must_use]
pub fn new(timeline: Timeline, lookahead: f64) -> Self {
Self {
timeline,
position: 0.0,
active_cue: None,
lookahead,
}
}
pub fn update_position(&mut self, position: f64) {
self.position = position;
if let Some(ref cue) = self.active_cue {
if position >= cue.end_time {
self.active_cue = None;
}
}
if self.active_cue.is_none() {
self.active_cue = self.timeline.get_cue_at(position).cloned();
}
}
#[must_use]
pub fn active_cue(&self) -> Option<&DescriptionCue> {
self.active_cue.as_ref()
}
#[must_use]
pub fn upcoming_cues(&self) -> Vec<&DescriptionCue> {
self.timeline
.get_cues_in_range(self.position, self.position + self.lookahead)
}
#[must_use]
pub fn is_cue_active(&self) -> bool {
self.active_cue.is_some()
}
pub fn seek(&mut self, position: f64) {
self.position = position;
self.active_cue = self.timeline.get_cue_at(position).cloned();
}
pub fn reset(&mut self) {
self.position = 0.0;
self.active_cue = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timing_config() {
let config = TimingConfig::new().with_min_gap(0.5);
assert!((config.min_gap_seconds - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_timeline_add_cue() {
let config = TimingConfig::default();
let mut timeline = Timeline::new(config, 48000.0);
let cue = DescriptionCue::new("cue1", 1.0, 2.0).with_text("Test");
timeline.add_cue(cue).unwrap();
assert_eq!(timeline.cue_count(), 1);
}
#[test]
fn test_timeline_get_cue_at() {
let config = TimingConfig::default();
let mut timeline = Timeline::new(config, 48000.0);
let cue = DescriptionCue::new("cue1", 1.0, 2.0).with_text("Test");
timeline.add_cue(cue).unwrap();
assert!(timeline.get_cue_at(1.5).is_some());
assert!(timeline.get_cue_at(0.5).is_none());
assert!(timeline.get_cue_at(2.5).is_none());
}
#[test]
fn test_timeline_detect_gaps() {
let config = TimingConfig::default();
let mut timeline = Timeline::new(config, 48000.0);
timeline.set_duration(10.0);
let cue1 = DescriptionCue::new("cue1", 1.0, 2.0).with_text("Test 1");
let cue2 = DescriptionCue::new("cue2", 3.0, 4.0).with_text("Test 2");
timeline.add_cue(cue1).unwrap();
timeline.add_cue(cue2).unwrap();
let gaps = timeline.detect_gaps();
assert_eq!(gaps.len(), 3);
}
#[test]
fn test_timeline_validation() {
let config = TimingConfig::default();
let mut timeline = Timeline::new(config, 48000.0);
let cue1 = DescriptionCue::new("cue1", 1.0, 2.0).with_text("Test 1");
let cue2 = DescriptionCue::new("cue2", 1.5, 2.5).with_text("Test 2");
timeline.add_cue(cue1).unwrap();
timeline.add_cue(cue2).unwrap();
let validation = timeline.validate();
assert!(!validation.is_valid);
assert!(!validation.issues.is_empty());
}
#[test]
fn test_cue_scheduler() {
let config = TimingConfig::default();
let mut timeline = Timeline::new(config, 48000.0);
let cue = DescriptionCue::new("cue1", 1.0, 2.0).with_text("Test");
timeline.add_cue(cue).unwrap();
let mut scheduler = CueScheduler::new(timeline, 1.0);
scheduler.update_position(1.5);
assert!(scheduler.is_cue_active());
scheduler.update_position(2.5);
assert!(!scheduler.is_cue_active());
}
}