Krolyxon
Commit a3a311f

rmptuisrclyricslrc.rs

165 lines · 5.3 KB · Rust

1//! Parser for LRC (synced lyrics) files.
2
3use std::time::Duration;
4
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct LyricLine {
7 pub time: Duration,
8 pub text: String,
9}
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum Lyrics {
13 /// Lines with timestamps, sorted by time.
14 Synced(Vec<LyricLine>),
15 Plain(Vec<String>),
16}
17
18impl Lyrics {
19 /// Parses LRC text. Text without any timestamps is treated as plain
20 /// lyrics.
21 pub fn parse(text: &str) -> Self {
22 let mut offset_ms: i64 = 0;
23 let mut synced = Vec::new();
24 for raw in text.lines() {
25 let mut rest = raw.trim();
26 let mut times = Vec::new();
27 while let Some(tag) = rest.strip_prefix('[').and_then(|r| r.split_once(']')) {
28 let (inner, tail) = tag;
29 if let Some(time) = parse_timestamp(inner) {
30 times.push(time);
31 } else if let Some(value) = inner.strip_prefix("offset:") {
32 offset_ms = value.trim().parse().unwrap_or(0);
33 } else if !is_id_tag(inner) {
34 break;
35 }
36 rest = tail;
37 }
38 let text = rest.trim().to_owned();
39 synced.extend(times.into_iter().map(|time| (time, text.clone())));
40 }
41 if synced.is_empty() {
42 return Self::Plain(text.lines().map(|l| l.trim_end().to_owned()).collect());
43 }
44 // A positive offset makes lyrics appear sooner.
45 let mut lines: Vec<LyricLine> = synced
46 .into_iter()
47 .map(|(time, text)| LyricLine {
48 time: shift(time, -offset_ms),
49 text,
50 })
51 .collect();
52 lines.sort_by_key(|line| line.time);
53 Self::Synced(lines)
54 }
55
56 pub fn is_empty(&self) -> bool {
57 match self {
58 Self::Synced(lines) => lines.is_empty(),
59 Self::Plain(lines) => lines.iter().all(|l| l.trim().is_empty()),
60 }
61 }
62
63 pub fn len(&self) -> usize {
64 match self {
65 Self::Synced(lines) => lines.len(),
66 Self::Plain(lines) => lines.len(),
67 }
68 }
69
70 /// The index of the line being sung at `elapsed`.
71 pub fn current_line(&self, elapsed: Duration) -> Option<usize> {
72 match self {
73 Self::Synced(lines) => lines
74 .partition_point(|line| line.time <= elapsed)
75 .checked_sub(1),
76 Self::Plain(_) => None,
77 }
78 }
79
80 pub fn line(&self, index: usize) -> &str {
81 match self {
82 Self::Synced(lines) => lines.get(index).map_or("", |l| l.text.as_str()),
83 Self::Plain(lines) => lines.get(index).map_or("", String::as_str),
84 }
85 }
86}
87
88/// `mm:ss`, `mm:ss.xx` or `mm:ss.xxx`.
89fn parse_timestamp(tag: &str) -> Option<Duration> {
90 let (minutes, seconds) = tag.split_once(':')?;
91 let minutes: u64 = minutes.trim().parse().ok()?;
92 let (secs, fraction) = seconds.split_once(['.', ':']).unwrap_or((seconds, "0"));
93 let secs: u64 = secs.trim().parse().ok()?;
94 if secs >= 60 || fraction.is_empty() || !fraction.chars().all(|c| c.is_ascii_digit()) {
95 return None;
96 }
97 let millis = match fraction.len() {
98 1 => fraction.parse::<u64>().ok()? * 100,
99 2 => fraction.parse::<u64>().ok()? * 10,
100 _ => fraction[..3].parse::<u64>().ok()?,
101 };
102 Some(Duration::from_millis((minutes * 60 + secs) * 1000 + millis))
103}
104
105fn is_id_tag(tag: &str) -> bool {
106 tag.split_once(':')
107 .is_some_and(|(key, _)| !key.is_empty() && key.chars().all(|c| c.is_ascii_alphabetic()))
108}
109
110fn shift(time: Duration, ms: i64) -> Duration {
111 let shifted = time.as_millis() as i64 + ms;
112 Duration::from_millis(shifted.max(0) as u64)
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn ms(ms: u64) -> Duration {
120 Duration::from_millis(ms)
121 }
122
123 #[test]
124 fn parses_synced_lyrics() {
125 let lyrics = Lyrics::parse(
126 "[ar:Someone]\n[ti:Song]\n[00:12.50]First\n[00:05.1][01:00.000]Chorus\n\n[00:20]Last",
127 );
128 let Lyrics::Synced(lines) = &lyrics else {
129 panic!("expected synced lyrics");
130 };
131 let times: Vec<_> = lines.iter().map(|l| l.time).collect();
132 assert_eq!(times, [ms(5_100), ms(12_500), ms(20_000), ms(60_000)]);
133 assert_eq!(lines[0].text, "Chorus");
134 assert_eq!(lyrics.current_line(ms(0)), None);
135 assert_eq!(lyrics.current_line(ms(12_500)), Some(1));
136 assert_eq!(lyrics.current_line(ms(90_000)), Some(3));
137 }
138
139 #[test]
140 fn applies_offset() {
141 let lyrics = Lyrics::parse("[offset:+500]\n[00:01.00]a\n[00:00.20]b");
142 let Lyrics::Synced(lines) = lyrics else {
143 panic!("expected synced lyrics");
144 };
145 assert_eq!(lines[0].time, ms(0));
146 assert_eq!(lines[1].time, ms(500));
147 }
148
149 #[test]
150 fn falls_back_to_plain() {
151 let lyrics = Lyrics::parse("Just some\nwords [not a tag]\n");
152 assert_eq!(
153 lyrics,
154 Lyrics::Plain(vec!["Just some".into(), "words [not a tag]".into()])
155 );
156 assert_eq!(lyrics.current_line(ms(1000)), None);
157 }
158
159 #[test]
160 fn rejects_bad_timestamps() {
161 assert_eq!(parse_timestamp("1:75.00"), None);
162 assert_eq!(parse_timestamp("ab:10"), None);
163 assert_eq!(parse_timestamp("02:03.4567"), Some(ms(123_456)));
164 }
165}