aede_core/
lyrics_timeline.rs

1//! Shared lookup for clients following lyrics from their own playback clock.
2
3use super::Lyrics;
4
5/// All lyric lines beginning at one moment in a track.
6///
7/// A cue remains active until the next cue, or through the end of the track.
8/// A cue containing only empty text deliberately clears the previous words.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Cue {
11    /// Milliseconds from the beginning of the decoded track.
12    ///
13    /// This is the same time base as [`super::Line::at_ms`]. Seeking retains
14    /// that origin; changing the output sample rate does not restart it. The
15    /// parser has already applied any LRC offset, so clients must not apply it
16    /// a second time.
17    pub at_ms: u64,
18    /// Zero-based indices into the original [`Lyrics::lines`], in source order.
19    ///
20    /// Equal timestamps share a cue, including repeated timestamps and blank
21    /// lines. The referenced lyrics must remain unchanged while these indices
22    /// are used; rebuild the timeline after editing or replacing them.
23    pub line_indices: Vec<usize>,
24}
25
26/// A chronological index of a track's synchronized lyrics.
27///
28/// Preparation takes O(n log n) time and O(n) auxiliary storage for n lyric
29/// lines, without cloning their text. Each position lookup takes O(log c)
30/// time for c distinct cues. There is no clock or moving cursor: a caller can
31/// pause, seek backward or repeat by supplying its current track position.
32///
33/// Untimed lines remain in [`Lyrics`] for plain-text display, but are excluded
34/// here: the index never invents a timing for metadata or unmarked verses.
35/// Timed blank lines are retained because they stop the previous text.
36#[derive(Debug, Clone, PartialEq, Eq, Default)]
37pub struct Timeline {
38    cues: Vec<Cue>,
39}
40
41impl Timeline {
42    /// Indexes all timed lines without changing their source order or content.
43    ///
44    /// Input timestamps may be unsorted. Chronological order is deterministic,
45    /// with original line order breaking ties. The index owns only timestamps
46    /// and indices, so the caller keeps the unmodified [`Lyrics`] alongside it.
47    /// Memory grows linearly with the supplied lines; this constructor does
48    /// not impose another size limit on publicly constructed lyrics.
49    pub fn new(lyrics: &Lyrics) -> Self {
50        let mut timed: Vec<_> = lyrics
51            .lines
52            .iter()
53            .enumerate()
54            .filter_map(|(index, line)| line.at_ms.map(|at_ms| (at_ms, index)))
55            .collect();
56        timed.sort_unstable();
57
58        let mut cues: Vec<Cue> = Vec::new();
59        for (at_ms, index) in timed {
60            match cues.last_mut() {
61                Some(cue) if cue.at_ms == at_ms => cue.line_indices.push(index),
62                _ => cues.push(Cue {
63                    at_ms,
64                    line_indices: vec![index],
65                }),
66            }
67        }
68        Self { cues }
69    }
70
71    /// The distinct cues in increasing timestamp order.
72    pub fn cues(&self) -> &[Cue] {
73        &self.cues
74    }
75
76    /// Whether the lyrics have no timed cue.
77    pub fn is_empty(&self) -> bool {
78        self.cues.is_empty()
79    }
80
81    /// Finds the most recent cue at or before a decoded-track position.
82    ///
83    /// The timestamp is inclusive. Before the first cue, or with plain lyrics,
84    /// this returns `None`. The last cue remains active until the caller ends
85    /// the track; no lyric duration is guessed from its text. An active blank
86    /// cue is returned rather than skipped, allowing the client to clear text.
87    pub fn active_at(&self, position_ms: u64) -> Option<&Cue> {
88        let end = self.cues.partition_point(|cue| cue.at_ms <= position_ms);
89        end.checked_sub(1).and_then(|index| self.cues.get(index))
90    }
91}
92
93#[cfg(test)]
94#[path = "lyrics_timeline_tests.rs"]
95mod tests;