suno_core/lyrics/aligned.rs
1//! Word- and line-level timed (synced) lyrics from Suno's aligned-lyrics API.
2//!
3//! [`AlignedLyrics`] is the domain shape of `GET /api/gen/{id}/aligned_lyrics/v2/`
4//! ([`SunoClient::aligned_lyrics`](crate::SunoClient::aligned_lyrics)): a flat
5//! word-level list plus a line-level list carrying section labels and nested
6//! per-word timing. Decoding the API body into it lives in
7//! `wire::parse_aligned_lyrics`; everything here is pure and free of direct IO,
8//! rendering the synced artefacts (the line-synced `.lrc` body, a word-level ID3
9//! `SYLT` table, and a plain-text fallback), so the formatting is unit tested
10//! without a network.
11//!
12//! Instrumentals (and any clip Suno could not force-align) return `200` with
13//! empty arrays, so [`AlignedLyrics::is_empty`] is the signal to write no synced
14//! artefact for that clip, exactly as an empty cover URL writes no cover.
15
16use std::fmt::Write as _;
17
18/// One force-aligned word from the flat `aligned_words` list.
19///
20/// `success` is Suno's per-word alignment flag (it can be `false` where forced
21/// alignment failed) and `p_align` its confidence; both are carried so callers
22/// can gate on them, though the line-level [`AlignedLine::words`] is preferred
23/// for rendering because it already reflects Suno's own line grouping.
24#[derive(Debug, Clone, PartialEq)]
25pub struct AlignedWord {
26 pub word: String,
27 pub success: bool,
28 pub start_s: f64,
29 pub end_s: f64,
30 pub p_align: f64,
31}
32
33/// One word within a line, from the nested `aligned_lyrics[].words` list.
34///
35/// The API keys the word text as `text` here (the flat list keys it as `word`).
36/// These carry no `success`/`p_align`; they are Suno's authoritative grouping of
37/// words into lines and are what the `.lrc` and `SYLT` renderers use.
38#[derive(Debug, Clone, PartialEq)]
39pub struct AlignedLineWord {
40 pub text: String,
41 pub start_s: f64,
42 pub end_s: f64,
43}
44
45/// One aligned line: its text, span, section label, and nested words.
46#[derive(Debug, Clone, PartialEq)]
47pub struct AlignedLine {
48 pub text: String,
49 pub start_s: f64,
50 pub end_s: f64,
51 /// Structural section label (e.g. `Verse 1`, `Chorus`), empty when absent.
52 pub section: String,
53 pub words: Vec<AlignedLineWord>,
54}
55
56/// A clip's aligned lyrics: the flat word list and the line list.
57///
58/// Both are empty for an instrumental or an un-alignable clip; see
59/// [`is_empty`](Self::is_empty).
60#[derive(Debug, Clone, Default, PartialEq)]
61pub struct AlignedLyrics {
62 pub words: Vec<AlignedWord>,
63 pub lines: Vec<AlignedLine>,
64 /// `waveform_data`: the amplitude/peak envelope Suno returns for waveform
65 /// display, empty when absent. Additive metadata, not lyric content, so it
66 /// does not affect [`is_empty`](Self::is_empty).
67 pub waveform_data: Vec<f64>,
68 /// `hoot_cer`: Suno's alignment/transcription error metric (higher is
69 /// worse), `None` when absent.
70 pub hoot_cer: Option<f64>,
71 /// `is_streamed`: Suno's streaming flag, `None` when absent.
72 pub is_streamed: Option<bool>,
73}
74
75impl AlignedLyrics {
76 /// True when the clip carries no aligned lyrics (an instrumental, or a clip
77 /// Suno could not align). No synced artefact is written for such a clip.
78 pub fn is_empty(&self) -> bool {
79 self.lines.is_empty() && self.words.is_empty()
80 }
81
82 /// The plain lyric text, one line per aligned line (falling back to the flat
83 /// word list when there are no lines), for the unsynced `LYRICS`/`USLT` tag.
84 ///
85 /// Returns an empty string when there is nothing to embed.
86 pub fn plain_text(&self) -> String {
87 if !self.lines.is_empty() {
88 return self
89 .lines
90 .iter()
91 .map(|line| line.text.trim_end())
92 .collect::<Vec<_>>()
93 .join("\n");
94 }
95 self.words
96 .iter()
97 .map(|word| word.word.as_str())
98 .collect::<Vec<_>>()
99 .join(" ")
100 }
101
102 /// The body of a standard (line-level) `.lrc`: one `[mm:ss.xx]` stamp per
103 /// aligned line, followed by the line text.
104 ///
105 /// Line-level is the universally supported LRC form, so every player syncs
106 /// and displays it cleanly; the enhanced "A2" per-word `<mm:ss.xx>` tags are
107 /// parsed by only a few karaoke players and are shown as literal text by the
108 /// rest, so they are not emitted here. Word-level timing is carried instead
109 /// in the MP3 `SYLT` frame (see [`sylt_entries`](Self::sylt_entries)). A line
110 /// with empty text falls back to its nested words joined by spaces. The body
111 /// is empty when there are no lines; callers treat that as "no `.lrc`".
112 pub fn lrc_body(&self) -> String {
113 let mut out = String::new();
114 for line in &self.lines {
115 let text = if line.text.trim().is_empty() {
116 line.words
117 .iter()
118 .map(|w| w.text.trim())
119 .filter(|t| !t.is_empty())
120 .collect::<Vec<_>>()
121 .join(" ")
122 } else {
123 line.text.trim().to_owned()
124 };
125 let _ = writeln!(out, "[{}]{text}", lrc_stamp(line.start_s));
126 }
127 out
128 }
129
130 /// Word-level `SYLT` content: `(offset_ms, text)` pairs in time order.
131 ///
132 /// Each new line's first word carries a leading newline so a player renders
133 /// line breaks (the ID3v2 `SYLT` convention). Uses Suno's own line grouping;
134 /// a line with no nested words contributes its whole text as one segment.
135 pub fn sylt_entries(&self) -> Vec<(u32, String)> {
136 let mut entries = Vec::new();
137 for (line_index, line) in self.lines.iter().enumerate() {
138 let words: Vec<&AlignedLineWord> = line
139 .words
140 .iter()
141 .filter(|w| !w.text.trim().is_empty())
142 .collect();
143 let prefix = if line_index == 0 { "" } else { "\n" };
144 if words.is_empty() {
145 let text = line.text.trim();
146 if !text.is_empty() {
147 entries.push((to_ms(line.start_s), format!("{prefix}{text}")));
148 }
149 continue;
150 }
151 for (word_index, word) in words.iter().enumerate() {
152 let text = word.text.trim();
153 let segment = if word_index == 0 {
154 format!("{prefix}{text}")
155 } else {
156 format!(" {text}")
157 };
158 entries.push((to_ms(word.start_s), segment));
159 }
160 }
161 entries
162 }
163}
164
165/// Total whole milliseconds for `secs`, clamped at zero (never negative).
166fn to_ms(secs: f64) -> u32 {
167 if !secs.is_finite() || secs <= 0.0 {
168 return 0;
169 }
170 (secs * 1000.0).round() as u32
171}
172
173/// Format `secs` as an LRC line stamp `mm:ss.xx` (centiseconds), with minutes
174/// allowed to exceed 59 so a long track is not wrapped.
175fn lrc_stamp(secs: f64) -> String {
176 let cs = centiseconds(secs);
177 format!("{:02}:{:02}.{:02}", cs / 6000, (cs / 100) % 60, cs % 100)
178}
179
180fn centiseconds(secs: f64) -> u64 {
181 if !secs.is_finite() || secs <= 0.0 {
182 return 0;
183 }
184 (secs * 100.0).round() as u64
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 /// The same two-line sample as the decode tests, built directly so the
192 /// render tests depend on the domain types, not on JSON decoding.
193 fn sample_aligned() -> AlignedLyrics {
194 AlignedLyrics {
195 words: vec![
196 AlignedWord {
197 word: "Hello".to_owned(),
198 success: true,
199 start_s: 0.5,
200 end_s: 0.9,
201 p_align: 0.99,
202 },
203 AlignedWord {
204 word: "world".to_owned(),
205 success: true,
206 start_s: 1.0,
207 end_s: 1.4,
208 p_align: 0.98,
209 },
210 AlignedWord {
211 word: "again".to_owned(),
212 success: true,
213 start_s: 61.2,
214 end_s: 61.8,
215 p_align: 0.97,
216 },
217 ],
218 lines: vec![
219 AlignedLine {
220 text: "Hello world".to_owned(),
221 start_s: 0.5,
222 end_s: 1.4,
223 section: "Verse 1".to_owned(),
224 words: vec![
225 AlignedLineWord {
226 text: "Hello".to_owned(),
227 start_s: 0.5,
228 end_s: 0.9,
229 },
230 AlignedLineWord {
231 text: "world".to_owned(),
232 start_s: 1.0,
233 end_s: 1.4,
234 },
235 ],
236 },
237 AlignedLine {
238 text: "[Chorus]".to_owned(),
239 start_s: 60.0,
240 end_s: 60.0,
241 section: "Chorus".to_owned(),
242 words: vec![],
243 },
244 AlignedLine {
245 text: "again".to_owned(),
246 start_s: 61.2,
247 end_s: 61.8,
248 section: "Chorus".to_owned(),
249 words: vec![AlignedLineWord {
250 text: "again".to_owned(),
251 start_s: 61.2,
252 end_s: 61.8,
253 }],
254 },
255 ],
256 ..Default::default()
257 }
258 }
259
260 #[test]
261 fn lrc_body_has_line_level_stamps() {
262 let aligned = sample_aligned();
263 let body = aligned.lrc_body();
264 let expected = "[00:00.50]Hello world\n\
265 [01:00.00][Chorus]\n\
266 [01:01.20]again\n";
267 assert_eq!(body, expected);
268 }
269
270 #[test]
271 fn plain_text_joins_line_text() {
272 let aligned = sample_aligned();
273 assert_eq!(aligned.plain_text(), "Hello world\n[Chorus]\nagain");
274 }
275
276 #[test]
277 fn sylt_entries_are_word_level_with_line_breaks() {
278 let aligned = sample_aligned();
279 let entries = aligned.sylt_entries();
280 assert_eq!(
281 entries,
282 vec![
283 (500, "Hello".to_owned()),
284 (1000, " world".to_owned()),
285 (60000, "\n[Chorus]".to_owned()),
286 (61200, "\nagain".to_owned()),
287 ]
288 );
289 }
290
291 #[test]
292 fn stamps_round_and_do_not_wrap_minutes() {
293 // 61.2s -> 01:01.20; a value over an hour stays in minutes (not hours).
294 assert_eq!(lrc_stamp(61.2), "01:01.20");
295 assert_eq!(lrc_stamp(3661.0), "61:01.00");
296 assert_eq!(to_ms(1.2346), 1235);
297 assert_eq!(to_ms(-1.0), 0);
298 }
299}