Skip to main content

rust_tts_wrapper/
timeline.rs

1//! Playback timeline: map playback wall-clock time to a text position.
2//!
3//! Reader-style consumers (audiobook readers, sentence highlighters) need the
4//! inverse of boundary events: "given that playback started N seconds ago,
5//! which word / character is being spoken right now?" — queried repeatedly
6//! against a static timeline while audio plays, with pause and seek support
7//! and no re-synthesis. This module turns the boundary data the engines
8//! already produce (`Vec<WordBoundary>`, or the
9//! `(word, start, end, byte_offset, len, estimated)` callback tuples)
10//! into that timeline.
11//!
12//! Semantics follow the pattern popularized by terminal reader projects
13//! (e.g. readaloud): events are keyed in *playback* time — audio seconds
14//! divided by the playback speed factor, compensating a tempo filter such as
15//! ffmpeg's `atempo` — and lookups hold the last event at or before the
16//! elapsed time rather than failing on gaps. Timings are kept as `f32`:
17//! at playback lengths of a day the resolution is still ~5 ms, ample for
18//! word-level highlighting.
19//!
20//! ```
21//! use rust_tts_wrapper::timeline::{PlaybackClock, PlaybackTimeline};
22//!
23//! // Boundary-callback events (word, start_s, end_s, byte_offset, len, est),
24//! // played at 1.25x through a tempo filter:
25//! let events = vec![
26//!     ("Hello".to_string(), 0.0, 0.4, 0, 5, false),
27//!     ("world".to_string(), 0.5, 0.9, 6, 5, false),
28//! ];
29//! let timeline = PlaybackTimeline::from_callback_events(&events, 1.25);
30//! assert_eq!(timeline.byte_offset_at(0.0), 0);
31//! // 0.5s of audio takes 0.4s of playback at 1.25x:
32//! assert_eq!(timeline.byte_offset_at(0.45), 6);
33//!
34//! let mut clock = PlaybackClock::start(1.25);
35//! // ... after ~0.45s of playing (minus pauses):
36//! // timeline.byte_offset_at(clock.elapsed_playback_secs())
37//! ```
38
39use std::time::Instant;
40
41use crate::types::WordBoundary;
42
43/// One word event on the playback timeline.
44///
45/// Units: `byte_offset` is a byte index into the source text (matching the
46/// boundary-callback convention), `byte_len` a byte length. Both are -1 when
47/// unknown. Slice with `&text[byte_offset..byte_offset + byte_len]` only
48/// when both are non-negative.
49#[derive(Debug, Clone, PartialEq)]
50pub struct TimelineEntry {
51    /// When the word starts, in playback seconds (audio time ÷ speed).
52    pub playback_start: f32,
53    /// When the word ends, in playback seconds.
54    pub playback_end: f32,
55    /// The spoken word text.
56    pub word: String,
57    /// Byte offset of the word in the source text, or -1 when unknown.
58    pub byte_offset: i32,
59    /// Byte length of the word, or -1 when unknown. When built via
60    /// [`PlaybackTimeline::from_callback_events`] this is the length the
61    /// caller supplied (the engine callbacks currently report a character
62    /// count — a known inconsistency tracked in the repo TODO; when built
63    /// via [`PlaybackTimeline::from_word_boundaries`] it is the matched
64    /// slice's byte length).
65    pub byte_len: i32,
66    /// Whether the timings are estimates rather than measurements.
67    pub estimated: bool,
68}
69
70/// A sorted `(playback_time → position)` timeline over word events.
71#[derive(Debug, Clone, Default)]
72pub struct PlaybackTimeline {
73    entries: Vec<TimelineEntry>,
74}
75
76impl PlaybackTimeline {
77    /// Build from boundary-callback tuples as delivered by the engines:
78    /// `(word, start_secs, end_secs, byte_offset, len, estimated)`.
79    ///
80    /// `speed` is the playback speed factor (1.0 = normal, 1.25 = 25%
81    /// faster playback via a tempo filter): audio timestamps are divided
82    /// by it so the timeline is keyed in playback wall-clock time.
83    /// Non-positive speeds fall back to 1.0.
84    #[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        // Ties on start resolve to the entry that ends last.
99        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    /// Build from `Vec<WordBoundary>` (as returned by
113    /// `synth_with_boundaries`), recovering each word's byte offset in
114    /// `text` with a [`crate::word_search::WordSearch`]: exact match,
115    /// then case-insensitive with combining diacritics ignored, then
116    /// hold-last on a miss (punctuation artifacts, normalization
117    /// differences).
118    ///
119    /// # Panics (debug builds only)
120    /// Panics via `debug_assert!` when the boundaries are not in
121    /// non-decreasing audio-offset order — the forward scan assumes
122    /// spoken order. Engine output always is.
123    #[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)] // ms timestamps; f32 is ample
136            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    /// The byte offset into the source text being spoken at
154    /// `playback_secs`, holding the last event at or before that time —
155    /// including holding the last *known* offset across entries whose
156    /// offset is unknown (-1). Returns 0 before the first event and on an
157    /// empty timeline.
158    #[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    /// The word being spoken at `playback_secs`, if any has been
171    /// scheduled by then.
172    #[must_use]
173    pub fn word_at(&self, playback_secs: f32) -> Option<&TimelineEntry> {
174        self.entry_at(playback_secs)
175    }
176
177    /// All entries, in playback order.
178    #[must_use]
179    pub fn entries(&self) -> &[TimelineEntry] {
180        &self.entries
181    }
182
183    /// Number of word events on the timeline.
184    #[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    /// Last event at or before `playback_secs` (binary search).
195    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/// Wall-clock playback clock with pause accounting, for driving lookups
208/// into a [`PlaybackTimeline`] while audio plays through an external
209/// player (the caller owns the audio device; this is just the stopwatch).
210#[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    /// Start counting playback time now. `speed` is informational here —
221    /// the clock counts playback wall time (post-tempo); pair it with a
222    /// timeline built with the same speed.
223    #[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    /// The playback speed factor this clock was started with.
235    #[must_use]
236    pub fn speed(&self) -> f32 {
237        self.speed
238    }
239
240    /// Pause the clock. Pausing while already paused is a no-op.
241    pub fn pause(&mut self) {
242        if self.pause_started.is_none() {
243            self.pause_started = Some(Instant::now());
244        }
245    }
246
247    /// Resume the clock. Resuming while playing is a no-op.
248    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    /// Elapsed playback seconds since start (or the last
260    /// [`seek`](Self::seek)), excluding paused time.
261    #[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            // Currently paused: count only up to the pause moment.
266            elapsed = elapsed.saturating_sub(at.elapsed());
267        }
268        self.base_secs + elapsed.as_secs_f32() - self.paused_total.as_secs_f32()
269    }
270
271    /// Move the clock to `playback_secs` (e.g. after seeking the audio
272    /// player with `-ss`): subsequent elapsed readings continue from
273    /// there. Negative values clamp to 0. Does not touch the audio
274    /// player itself; a paused clock stays paused at the new position.
275    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); // between words
303        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); // after the end
306        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        // At 2x playback, 1.0s of audio has played by wall second 0.5:
319        // word starts 0.0 / 0.5 / 1.1 (audio) become 0.0 / 0.25 / 0.55.
320        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        // Same start as "playback" (0.5) but shorter end: must sort first.
336        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"); // earlier end wins the tie
347        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        // Both start at 1.0; at 1.6 only b is "current" by our
358        // last-at-or-before rule, which returns the final entry at that
359        // time — b, the longer one, matching the sort tiebreak.
360        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); // "Hello"
385        assert_eq!(t.entries()[1].byte_offset, 11); // "world"
386        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); // case-insensitive match
395                                                   // Miss holds the last KNOWN offset; the length is unknown.
396        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        // "İstanbul" is 9 bytes (İ is 2); exact match is byte-true.
407        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        // çağırdı = 11 bytes (ç, ğ and the dotless ı are 2 bytes each).
412        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        // The motivating case: engine normalizes "İstanbul" to a plain
419        // lowercase word. The combining dot that İ lowercases to is
420        // ignored, so the whole word matches at byte 0 spanning all 9
421        // bytes.
422        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        // Round-2 repro: needle "i" consumes only one of İ's two produced
433        // chars; the scan must still advance past the whole İ so the next
434        // word resolves at its true offset, not back at byte 0.
435        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); // the whole İ
440        assert_eq!(t.entries()[1].byte_offset, 4); // second "İa" (bytes 4..7)
441        assert_eq!(&text[4..7], "İa");
442    }
443
444    #[test]
445    fn composed_accent_needle_vs_nfd_text_misses_safely() {
446        // Documented limitation: a composed 'é' needle against NFD text
447        // (e + combining acute) is not normalized to a match — it degrades
448        // to hold-last rather than panicking or mis-offsetting.
449        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); // no match known yet
453        assert_eq!(t.entries()[1].byte_offset, 7); // "tonight" (c,a,f,e,·,sp = 7)
454    }
455
456    #[test]
457    fn turkish_dotted_i_repro_no_panic_and_correct_offsets() {
458        // The review-round repro: engine-normalized lowercase words against
459        // text starting with İ (U+0130, 2 bytes; lowercases to 3 bytes incl.
460        // a combining dot). Must not panic, and offsets must come from the
461        // original text's byte space.
462        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        // "i" matches the lowercase expansion of İ: source byte 0, and the
466        // matched span covers the whole source char (2 bytes).
467        assert_eq!(t.entries()[0].byte_offset, 0);
468        assert_eq!(t.entries()[0].byte_len, 2);
469        // "abc" resolves in the original text's byte space: byte 5.
470        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        // Kelvin sign K (3 bytes) lowercases to k (1 byte) — divergence in
477        // the other direction.
478        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        // Paused time is excluded — only tiny scheduling drift allowed.
498        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        // Paused at 3.0: elapsed excludes ongoing pause.
525        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}