1use std::time::Instant;
40
41use crate::types::WordBoundary;
42
43#[derive(Debug, Clone, PartialEq)]
50pub struct TimelineEntry {
51 pub playback_start: f32,
53 pub playback_end: f32,
55 pub word: String,
57 pub byte_offset: i32,
59 pub byte_len: i32,
66 pub estimated: bool,
68}
69
70#[derive(Debug, Clone, Default)]
72pub struct PlaybackTimeline {
73 entries: Vec<TimelineEntry>,
74}
75
76impl PlaybackTimeline {
77 #[must_use]
85 pub fn from_callback_events(events: &[(String, f32, f32, i32, i32, bool)], speed: f32) -> Self {
86 let speed = if speed > 0.0 { speed } else { 1.0 };
87 let mut entries: Vec<TimelineEntry> = events
88 .iter()
89 .map(|(word, start, end, off, len, est)| TimelineEntry {
90 playback_start: start / speed,
91 playback_end: end / speed,
92 word: word.clone(),
93 byte_offset: *off,
94 byte_len: *len,
95 estimated: *est,
96 })
97 .collect();
98 entries.sort_by(|a, b| {
100 a.playback_start
101 .partial_cmp(&b.playback_start)
102 .unwrap_or(std::cmp::Ordering::Equal)
103 .then(
104 a.playback_end
105 .partial_cmp(&b.playback_end)
106 .unwrap_or(std::cmp::Ordering::Equal),
107 )
108 });
109 Self { entries }
110 }
111
112 #[must_use]
124 pub fn from_word_boundaries(boundaries: &[WordBoundary], text: &str, speed: f32) -> Self {
125 let speed = if speed > 0.0 { speed } else { 1.0 };
126 let mut search = crate::word_search::WordSearch::new(text);
127 let mut entries = Vec::with_capacity(boundaries.len());
128 let mut prev_offset = 0u64;
129 for b in boundaries {
130 debug_assert!(
131 b.offset >= prev_offset,
132 "boundaries must be in non-decreasing audio order"
133 );
134 prev_offset = b.offset;
135 #[allow(clippy::cast_precision_loss)] let start_s = b.offset as f32 / 1000.0;
137 #[allow(clippy::cast_precision_loss)]
138 let end_s = (b.offset + b.duration) as f32 / 1000.0;
139
140 let (byte_offset, byte_len) = search.find_next(&b.text);
141 entries.push(TimelineEntry {
142 playback_start: start_s / speed,
143 playback_end: end_s / speed,
144 word: b.text.clone(),
145 byte_offset,
146 byte_len,
147 estimated: b.estimated,
148 });
149 }
150 Self { entries }
151 }
152
153 #[must_use]
159 pub fn byte_offset_at(&self, playback_secs: f32) -> i32 {
160 let idx = self
161 .entries
162 .partition_point(|e| e.playback_start <= playback_secs);
163 self.entries[..idx]
164 .iter()
165 .rev()
166 .find(|e| e.byte_offset >= 0)
167 .map_or(0, |e| e.byte_offset)
168 }
169
170 #[must_use]
173 pub fn word_at(&self, playback_secs: f32) -> Option<&TimelineEntry> {
174 self.entry_at(playback_secs)
175 }
176
177 #[must_use]
179 pub fn entries(&self) -> &[TimelineEntry] {
180 &self.entries
181 }
182
183 #[must_use]
185 pub fn len(&self) -> usize {
186 self.entries.len()
187 }
188
189 #[must_use]
190 pub fn is_empty(&self) -> bool {
191 self.entries.is_empty()
192 }
193
194 fn entry_at(&self, playback_secs: f32) -> Option<&TimelineEntry> {
196 let idx = self
197 .entries
198 .partition_point(|e| e.playback_start <= playback_secs);
199 if idx == 0 {
200 None
201 } else {
202 self.entries.get(idx - 1)
203 }
204 }
205}
206
207#[derive(Debug)]
211pub struct PlaybackClock {
212 speed: f32,
213 epoch: Instant,
214 base_secs: f32,
215 paused_total: std::time::Duration,
216 pause_started: Option<Instant>,
217}
218
219impl PlaybackClock {
220 #[must_use]
224 pub fn start(speed: f32) -> Self {
225 Self {
226 speed: if speed > 0.0 { speed } else { 1.0 },
227 epoch: Instant::now(),
228 base_secs: 0.0,
229 paused_total: std::time::Duration::ZERO,
230 pause_started: None,
231 }
232 }
233
234 #[must_use]
236 pub fn speed(&self) -> f32 {
237 self.speed
238 }
239
240 pub fn pause(&mut self) {
242 if self.pause_started.is_none() {
243 self.pause_started = Some(Instant::now());
244 }
245 }
246
247 pub fn resume(&mut self) {
249 if let Some(at) = self.pause_started.take() {
250 self.paused_total += at.elapsed();
251 }
252 }
253
254 #[must_use]
255 pub fn is_paused(&self) -> bool {
256 self.pause_started.is_some()
257 }
258
259 #[must_use]
262 pub fn elapsed_playback_secs(&self) -> f32 {
263 let mut elapsed = self.epoch.elapsed();
264 if let Some(at) = self.pause_started {
265 elapsed = elapsed.saturating_sub(at.elapsed());
267 }
268 self.base_secs + elapsed.as_secs_f32() - self.paused_total.as_secs_f32()
269 }
270
271 pub fn seek(&mut self, playback_secs: f32) {
276 self.epoch = Instant::now();
277 self.base_secs = playback_secs.max(0.0);
278 self.paused_total = std::time::Duration::ZERO;
279 if self.pause_started.is_some() {
280 self.pause_started = Some(Instant::now());
281 }
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn events() -> Vec<(String, f32, f32, i32, i32, bool)> {
290 vec![
291 ("Hello".into(), 0.0, 0.40, 0, 5, false),
292 ("playback".into(), 0.50, 1.00, 6, 8, false),
293 ("world".into(), 1.10, 1.50, 15, 5, false),
294 ]
295 }
296
297 #[test]
298 fn lookup_holds_last_event() {
299 let t = PlaybackTimeline::from_callback_events(&events(), 1.0);
300 assert_eq!(t.len(), 3);
301 assert_eq!(t.byte_offset_at(0.0), 0);
302 assert_eq!(t.byte_offset_at(0.45), 0); assert_eq!(t.byte_offset_at(0.6), 6);
304 assert_eq!(t.byte_offset_at(1.49), 15);
305 assert_eq!(t.byte_offset_at(99.0), 15); assert_eq!(t.word_at(0.6).unwrap().word, "playback");
307 }
308
309 #[test]
310 fn before_first_event_returns_zero() {
311 let t = PlaybackTimeline::from_callback_events(&events(), 1.0);
312 assert_eq!(t.byte_offset_at(-1.0), 0);
313 assert!(t.word_at(-1.0).is_none());
314 }
315
316 #[test]
317 fn speed_divides_audio_time() {
318 let t = PlaybackTimeline::from_callback_events(&events(), 2.0);
321 assert_eq!(t.byte_offset_at(0.24), 0);
322 assert_eq!(t.byte_offset_at(0.25), 6);
323 assert_eq!(t.byte_offset_at(0.55), 15);
324 }
325
326 #[test]
327 fn nonpositive_speed_falls_back_to_one() {
328 let t = PlaybackTimeline::from_callback_events(&events(), 0.0);
329 assert_eq!(t.byte_offset_at(0.6), 6);
330 }
331
332 #[test]
333 fn unsorted_input_is_sorted_with_end_time_tiebreak() {
334 let mut ev = events();
335 ev.push(("hi".into(), 0.50, 0.60, 24, 2, false));
337 ev.reverse();
338 let t = PlaybackTimeline::from_callback_events(&ev, 1.0);
339 assert_eq!(t.entries()[0].word, "Hello");
340 let tie = t
341 .entries()
342 .iter()
343 .filter(|e| (e.playback_start - 0.5).abs() < f32::EPSILON)
344 .collect::<Vec<_>>();
345 assert_eq!(tie.len(), 2);
346 assert_eq!(tie[0].word, "hi"); assert_eq!(tie[1].word, "playback");
348 }
349
350 #[test]
351 fn duplicate_starts_hold_longest_end() {
352 let ev = vec![
353 ("a".to_string(), 1.0, 1.5, 10, 1, false),
354 ("b".to_string(), 1.0, 1.9, 20, 1, false),
355 ];
356 let t = PlaybackTimeline::from_callback_events(&ev, 1.0);
357 assert_eq!(t.word_at(1.6).unwrap().word, "b");
361 }
362
363 #[test]
364 fn empty_timeline_is_inert() {
365 let t = PlaybackTimeline::from_callback_events(&[], 1.0);
366 assert!(t.is_empty());
367 assert_eq!(t.byte_offset_at(10.0), 0);
368 assert!(t.word_at(10.0).is_none());
369 }
370
371 fn wb(text: &str, offset: u64, duration: u64) -> WordBoundary {
372 WordBoundary {
373 text: text.into(),
374 offset,
375 duration,
376 estimated: false,
377 }
378 }
379
380 #[test]
381 fn from_word_boundaries_recovers_offsets_by_scanning() {
382 let words = vec![wb("Hello", 0, 400), wb("world", 500, 400)];
383 let t = PlaybackTimeline::from_word_boundaries(&words, "Say Hello, world!", 1.0);
384 assert_eq!(t.entries()[0].byte_offset, 4); assert_eq!(t.entries()[1].byte_offset, 11); assert_eq!(t.entries()[0].byte_len, 5);
387 assert_eq!(t.byte_offset_at(0.55), 11);
388 }
389
390 #[test]
391 fn from_word_boundaries_case_insensitive_and_hold_last() {
392 let words = vec![wb("HELLO", 0, 300), wb("xyzzy", 400, 300)];
393 let t = PlaybackTimeline::from_word_boundaries(&words, "hello there", 1.0);
394 assert_eq!(t.entries()[0].byte_offset, 0); assert_eq!(t.entries()[1].byte_offset, 0);
397 assert!(t.entries()[1].byte_len < 0);
398 assert_eq!(t.byte_offset_at(0.45), 0);
399 }
400
401 #[test]
402 fn multibyte_text_never_panics_and_offsets_are_byte_true() {
403 let text = "İstanbul çağırdı";
404 let words = vec![wb("İstanbul", 0, 600), wb("çağırdı", 700, 600)];
405 let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
406 assert_eq!(t.entries()[0].byte_offset, 0);
408 assert_eq!(t.entries()[0].byte_len, 9);
409 assert_eq!(&text[0..9], "İstanbul");
410 assert_eq!(t.entries()[1].byte_offset, 10);
411 assert_eq!(t.entries()[1].byte_len, 11);
413 assert_eq!(&text[10..21], "çağırdı");
414 }
415
416 #[test]
417 fn turkish_dotted_i_whole_word_matches_after_mark_stripping() {
418 let text = "İstanbul çağırdı";
423 let words = vec![wb("istanbul", 0, 600)];
424 let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
425 assert_eq!(t.entries()[0].byte_offset, 0);
426 assert_eq!(t.entries()[0].byte_len, 9);
427 assert_eq!(&text[0..9], "İstanbul");
428 }
429
430 #[test]
431 fn cursor_advances_past_partial_expansion_match() {
432 let text = "İa İa";
436 let words = vec![wb("i", 0, 200), wb("İa", 300, 400)];
437 let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
438 assert_eq!(t.entries()[0].byte_offset, 0);
439 assert_eq!(t.entries()[0].byte_len, 2); assert_eq!(t.entries()[1].byte_offset, 4); assert_eq!(&text[4..7], "İa");
442 }
443
444 #[test]
445 fn composed_accent_needle_vs_nfd_text_misses_safely() {
446 let nfd = "cafe\u{0301} tonight";
450 let words = vec![wb("café", 0, 500), wb("tonight", 600, 500)];
451 let t = PlaybackTimeline::from_word_boundaries(&words, nfd, 1.0);
452 assert_eq!(t.entries()[0].byte_offset, -1); assert_eq!(t.entries()[1].byte_offset, 7); }
455
456 #[test]
457 fn turkish_dotted_i_repro_no_panic_and_correct_offsets() {
458 let text = "İü abc";
463 let words = vec![wb("i", 0, 200), wb("abc", 300, 400)];
464 let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
465 assert_eq!(t.entries()[0].byte_offset, 0);
468 assert_eq!(t.entries()[0].byte_len, 2);
469 assert_eq!(t.entries()[1].byte_offset, 5);
471 assert_eq!(&text[5..8], "abc");
472 }
473
474 #[test]
475 fn kelvin_sign_case_folding_shrinks() {
476 let text = "\u{212A}epler";
479 let words = vec![wb("kepler", 0, 500)];
480 let t = PlaybackTimeline::from_word_boundaries(&words, text, 1.0);
481 assert_eq!(t.entries()[0].byte_offset, 0);
482 assert_eq!(t.entries()[0].byte_len, 3 + "epler".len() as i32);
483 }
484
485 #[test]
486 fn clock_counts_playback_and_pauses() {
487 let mut clock = PlaybackClock::start(1.0);
488 std::thread::sleep(std::time::Duration::from_millis(50));
489 let before = clock.elapsed_playback_secs();
490 assert!(before >= 0.04, "elapsed {before}");
491 assert!(!clock.is_paused());
492
493 clock.pause();
494 assert!(clock.is_paused());
495 std::thread::sleep(std::time::Duration::from_millis(120));
496 let paused = clock.elapsed_playback_secs();
497 assert!(paused - before < 0.05, "paused drift {}", paused - before);
499
500 clock.resume();
501 std::thread::sleep(std::time::Duration::from_millis(50));
502 assert!(clock.elapsed_playback_secs() >= paused, "resumes counting");
503 }
504
505 #[test]
506 fn clock_seek_rebases_and_clamps_negative() {
507 let mut clock = PlaybackClock::start(2.0);
508 std::thread::sleep(std::time::Duration::from_millis(30));
509 clock.seek(10.0);
510 let e = clock.elapsed_playback_secs();
511 assert!((10.0..10.5).contains(&e), "after seek {e}");
512 assert!((clock.speed() - 2.0).abs() < f32::EPSILON);
513
514 clock.seek(-5.0);
515 assert!(clock.elapsed_playback_secs() < 0.5, "negative seek clamps");
516 }
517
518 #[test]
519 fn clock_seek_while_paused_holds_position() {
520 let mut clock = PlaybackClock::start(1.0);
521 clock.pause();
522 clock.seek(3.0);
523 assert!(clock.is_paused());
524 let e = clock.elapsed_playback_secs();
526 assert!((2.95..3.05).contains(&e), "paused at seek target: {e}");
527 std::thread::sleep(std::time::Duration::from_millis(80));
528 let e2 = clock.elapsed_playback_secs();
529 assert!(e2 - e < 0.05, "still frozen: drift {}", e2 - e);
530 }
531}