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;