use std::time::Instant;
use crate::types::WordBoundary;
#[derive(Debug, Clone, PartialEq)]
pub struct TimelineEntry {
pub playback_start: f32,
pub playback_end: f32,
pub word: String,
pub byte_offset: i32,
pub byte_len: i32,
pub estimated: bool,
}
#[derive(Debug, Clone, Default)]
pub struct PlaybackTimeline {
entries: Vec<TimelineEntry>,
}
impl PlaybackTimeline {
#[must_use]
pub fn from_callback_events(events: &[(String, f32, f32, i32, i32, bool)], speed: f32) -> Self {
let speed = if speed > 0.0 { speed } else { 1.0 };
let mut entries: Vec<TimelineEntry> = events
.iter()
.map(|(word, start, end, off, len, est)| TimelineEntry {
playback_start: start / speed,
playback_end: end / speed,
word: word.clone(),
byte_offset: *off,
byte_len: *len,
estimated: *est,
})
.collect();
entries.sort_by(|a, b| {
a.playback_start
.partial_cmp(&b.playback_start)
.unwrap_or(std::cmp::Ordering::Equal)
.then(
a.playback_end
.partial_cmp(&b.playback_end)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
Self { entries }
}
#[must_use]
pub fn from_word_boundaries(boundaries: &[WordBoundary], text: &str, speed: f32) -> Self {
let speed = if speed > 0.0 { speed } else { 1.0 };
let mut search = crate::word_search::WordSearch::new(text);
let mut entries = Vec::with_capacity(boundaries.len());
let mut prev_offset = 0u64;
for b in boundaries {
debug_assert!(
b.offset >= prev_offset,
"boundaries must be in non-decreasing audio order"
);
prev_offset = b.offset;
#[allow(clippy::cast_precision_loss)] let start_s = b.offset as f32 / 1000.0;
#[allow(clippy::cast_precision_loss)]
let end_s = (b.offset + b.duration) as f32 / 1000.0;
let (byte_offset, byte_len) = search.find_next(&b.text);
entries.push(TimelineEntry {
playback_start: start_s / speed,
playback_end: end_s / speed,
word: b.text.clone(),
byte_offset,
byte_len,
estimated: b.estimated,
});
}
Self { entries }
}
#[must_use]
pub fn byte_offset_at(&self, playback_secs: f32) -> i32 {
let idx = self
.entries
.partition_point(|e| e.playback_start <= playback_secs);
self.entries[..idx]
.iter()
.rev()
.find(|e| e.byte_offset >= 0)
.map_or(0, |e| e.byte_offset)
}
#[must_use]
pub fn word_at(&self, playback_secs: f32) -> Option<&TimelineEntry> {
self.entry_at(playback_secs)
}
#[must_use]
pub fn entries(&self) -> &[TimelineEntry] {
&self.entries
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn entry_at(&self, playback_secs: f32) -> Option<&TimelineEntry> {
let idx = self
.entries
.partition_point(|e| e.playback_start <= playback_secs);
if idx == 0 {
None
} else {
self.entries.get(idx - 1)
}
}
}
#[derive(Debug)]
pub struct PlaybackClock {
speed: f32,
epoch: Instant,
base_secs: f32,
paused_total: std::time::Duration,
pause_started: Option<Instant>,
}
impl PlaybackClock {
#[must_use]
pub fn start(speed: f32) -> Self {
Self {
speed: if speed > 0.0 { speed } else { 1.0 },
epoch: Instant::now(),
base_secs: 0.0,
paused_total: std::time::Duration::ZERO,
pause_started: None,
}
}
#[must_use]
pub fn speed(&self) -> f32 {
self.speed
}
pub fn pause(&mut self) {
if self.pause_started.is_none() {
self.pause_started = Some(Instant::now());
}
}
pub fn resume(&mut self) {
if let Some(at) = self.pause_started.take() {
self.paused_total += at.elapsed();
}
}
#[must_use]
pub fn is_paused(&self) -> bool {
self.pause_started.is_some()
}
#[must_use]
pub fn elapsed_playback_secs(&self) -> f32 {
let mut elapsed = self.epoch.elapsed();
if let Some(at) = self.pause_started {
elapsed = elapsed.saturating_sub(at.elapsed());
}
self.base_secs + elapsed.as_secs_f32() - self.paused_total.as_secs_f32()
}
pub fn seek(&mut self, playback_secs: f32) {
self.epoch = Instant::now();
self.base_secs = playback_secs.max(0.0);
self.paused_total = std::time::Duration::ZERO;
if self.pause_started.is_some() {
self.pause_started = Some(Instant::now());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn events() -> Vec<(String, f32, f32, i32, i32, bool)> {
vec![
("Hello".into(), 0.0, 0.40, 0, 5, false),
("playback".into(), 0.50, 1.00, 6, 8, false),
("world".into(), 1.10, 1.50, 15, 5, false),
]
}
#[test]
fn lookup_holds_last_event() {
let t = PlaybackTimeline::from_callback_events(&events(), 1.0);
assert_eq!(t.len(), 3);
assert_eq!(t.byte_offset_at(0.0), 0);
assert_eq!(t.byte_offset_at(0.45), 0); assert_eq!(t.byte_offset_at(0.6), 6);
assert_eq!(t.byte_offset_at(1.49), 15);
assert_eq!(t.byte_offset_at(99.0), 15); assert_eq!(t.word_at(0.6).unwrap().word, "playback");
}
#[test]
fn before_first_event_returns_zero() {
let t = PlaybackTimeline::from_callback_events(&events(), 1.0);
assert_eq!(t.byte_offset_at(-1.0), 0);
assert!(t.word_at(-1.0).is_none());
}
#[test]
fn speed_divides_audio_time() {
let t = PlaybackTimeline::from_callback_events(&events(), 2.0);
assert_eq!(t.byte_offset_at(0.24), 0);
assert_eq!(t.byte_offset_at(0.25), 6);
assert_eq!(t.byte_offset_at(0.55), 15);
}
#[test]
fn nonpositive_speed_falls_back_to_one() {
let t = PlaybackTimeline::from_callback_events(&events(), 0.0);
assert_eq!(t.byte_offset_at(0.6), 6);
}
#[test]
fn unsorted_input_is_sorted_with_end_time_tiebreak() {
let mut ev = events();
ev.push(("hi".into(), 0.50, 0.60, 24, 2, false));
ev.reverse();
let t = PlaybackTimeline::from_callback_events(&ev, 1.0);
assert_eq!(t.entries()[0].word, "Hello");
let tie = t
.entries()
.iter()
.filter(|e| (e.playback_start - 0.5).abs() < f32::EPSILON)
.collect::<Vec<_>>();
assert_eq!(tie.len(), 2);
assert_eq!(tie[0].word, "hi"); assert_eq!(tie[1].word, "playback");
}
#[test]
fn duplicate_starts_hold_longest_end() {
let ev = vec![
("a".to_string(), 1.0, 1.5, 10, 1, false),
("b".to_string(), 1.0, 1.9, 20, 1, false),
];
let t = PlaybackTimeline::from_callback_events(&ev, 1.0);
assert_eq!(t.word_at(1.6).unwrap().word, "b");
}
#[test]
fn empty_timeline_is_inert() {
let t = PlaybackTimeline::from_callback_events(&[], 1.0);
assert!(t.is_empty());
assert_eq!(t.byte_offset_at(10.0), 0);
assert!(t.word_at(10.0).is_none());
}
fn wb(text: &str, offset: u64, duration: u64) -> WordBoundary {
WordBoundary {
text: text.into(),
offset,
duration,
estimated: false,
}
}
#[test]
fn from_word_boundaries_recovers_offsets_by_scanning() {
let words = vec![wb("Hello", 0, 400), wb("world", 500, 400)];
let t = PlaybackTimeline::from_word_boundaries(&words, "Say Hello, world!", 1.0);
assert_eq!(t.entries()[0].byte_offset, 4); assert_eq!(t.entries()[1].byte_offset, 11); assert_eq!(t.entries()[0].byte_len, 5);
assert_eq!(t.byte_offset_at(0.55), 11);
}
#[test]
fn from_word_boundaries_case_insensitive_and_hold_last() {
let words = vec![wb("HELLO", 0, 300), wb("xyzzy", 400, 300)];
let t = PlaybackTimeline::from_word_boundaries(&words, "hello there", 1.0);
assert_eq!(t.entries()[0].byte_offset, 0); assert_eq!(t.entries()[1].byte_offset, 0);
assert!(t.entries()[1].byte_len < 0);
assert_eq!(t.byte_offset_at(0.45), 0);
}
#[test]
fn multibyte_text_never_panics_and_offsets_are_byte_true() {
let text = "İstanbul çağırdı";
let words = vec![wb("İstanbul", 0, 600), wb("çağırdı", 700, 600)];
let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
assert_eq!(t.entries()[0].byte_offset, 0);
assert_eq!(t.entries()[0].byte_len, 9);
assert_eq!(&text[0..9], "İstanbul");
assert_eq!(t.entries()[1].byte_offset, 10);
assert_eq!(t.entries()[1].byte_len, 11);
assert_eq!(&text[10..21], "çağırdı");
}
#[test]
fn turkish_dotted_i_whole_word_matches_after_mark_stripping() {
let text = "İstanbul çağırdı";
let words = vec![wb("istanbul", 0, 600)];
let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
assert_eq!(t.entries()[0].byte_offset, 0);
assert_eq!(t.entries()[0].byte_len, 9);
assert_eq!(&text[0..9], "İstanbul");
}
#[test]
fn cursor_advances_past_partial_expansion_match() {
let text = "İa İa";
let words = vec![wb("i", 0, 200), wb("İa", 300, 400)];
let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
assert_eq!(t.entries()[0].byte_offset, 0);
assert_eq!(t.entries()[0].byte_len, 2); assert_eq!(t.entries()[1].byte_offset, 4); assert_eq!(&text[4..7], "İa");
}
#[test]
fn composed_accent_needle_vs_nfd_text_misses_safely() {
let nfd = "cafe\u{0301} tonight";
let words = vec![wb("café", 0, 500), wb("tonight", 600, 500)];
let t = PlaybackTimeline::from_word_boundaries(&words, nfd, 1.0);
assert_eq!(t.entries()[0].byte_offset, -1); assert_eq!(t.entries()[1].byte_offset, 7); }
#[test]
fn turkish_dotted_i_repro_no_panic_and_correct_offsets() {
let text = "İü abc";
let words = vec![wb("i", 0, 200), wb("abc", 300, 400)];
let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
assert_eq!(t.entries()[0].byte_offset, 0);
assert_eq!(t.entries()[0].byte_len, 2);
assert_eq!(t.entries()[1].byte_offset, 5);
assert_eq!(&text[5..8], "abc");
}
#[test]
fn kelvin_sign_case_folding_shrinks() {
let text = "\u{212A}epler";
let words = vec![wb("kepler", 0, 500)];
let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
assert_eq!(t.entries()[0].byte_offset, 0);
assert_eq!(t.entries()[0].byte_len, 3 + "epler".len() as i32);
}
#[test]
fn clock_counts_playback_and_pauses() {
let mut clock = PlaybackClock::start(1.0);
std::thread::sleep(std::time::Duration::from_millis(50));
let before = clock.elapsed_playback_secs();
assert!(before >= 0.04, "elapsed {before}");
assert!(!clock.is_paused());
clock.pause();
assert!(clock.is_paused());
std::thread::sleep(std::time::Duration::from_millis(120));
let paused = clock.elapsed_playback_secs();
assert!(paused - before < 0.05, "paused drift {}", paused - before);
clock.resume();
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(clock.elapsed_playback_secs() >= paused, "resumes counting");
}
#[test]
fn clock_seek_rebases_and_clamps_negative() {
let mut clock = PlaybackClock::start(2.0);
std::thread::sleep(std::time::Duration::from_millis(30));
clock.seek(10.0);
let e = clock.elapsed_playback_secs();
assert!((10.0..10.5).contains(&e), "after seek {e}");
assert!((clock.speed() - 2.0).abs() < f32::EPSILON);
clock.seek(-5.0);
assert!(clock.elapsed_playback_secs() < 0.5, "negative seek clamps");
}
#[test]
fn clock_seek_while_paused_holds_position() {
let mut clock = PlaybackClock::start(1.0);
clock.pause();
clock.seek(3.0);
assert!(clock.is_paused());
let e = clock.elapsed_playback_secs();
assert!((2.95..3.05).contains(&e), "paused at seek target: {e}");
std::thread::sleep(std::time::Duration::from_millis(80));
let e2 = clock.elapsed_playback_secs();
assert!(e2 - e < 0.05, "still frozen: drift {}", e2 - e);
}
}