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. Structural
111 /// section labels (`[Chorus]`, `[Verse 1]`) are omitted (see
112 /// [`is_section_label`]); they are never sung, so a timed stamp would
113 /// highlight a non-lyric line. They are kept in the untimed
114 /// [`plain_text`](Self::plain_text). The body is empty when there are no
115 /// lines; callers treat that as "no `.lrc`".
116 pub fn lrc_body(&self) -> String {
117 let mut out = String::new();
118 for line in &self.lines {
119 if is_section_label(line) {
120 continue;
121 }
122 let text = if line.text.trim().is_empty() {
123 line.words
124 .iter()
125 .map(|w| w.text.trim())
126 .filter(|t| !t.is_empty())
127 .collect::<Vec<_>>()
128 .join(" ")
129 } else {
130 line.text.trim().to_owned()
131 };
132 let _ = writeln!(out, "[{}]{text}", lrc_stamp(line.start_s));
133 }
134 out
135 }
136
137 /// Word-level `SYLT` content: `(offset_ms, text)` pairs in time order.
138 ///
139 /// Each new line's first word carries a leading newline so a player renders
140 /// line breaks (the ID3v2 `SYLT` convention). Uses Suno's own line grouping;
141 /// a line with no nested words contributes its whole text as one segment.
142 /// Structural section labels (`[Chorus]`, `[Verse 1]`) are skipped (see
143 /// [`is_section_label`]), matching the `.lrc` body. The leading newline tracks
144 /// the first *emitted* segment, not the line index, so skipping a label at the
145 /// start (or between lines) never leaves a stray or missing break.
146 pub fn sylt_entries(&self) -> Vec<(u32, String)> {
147 let mut entries = Vec::new();
148 let mut first_emitted = true;
149 for line in &self.lines {
150 if is_section_label(line) {
151 continue;
152 }
153 let words: Vec<&AlignedLineWord> = line
154 .words
155 .iter()
156 .filter(|w| !w.text.trim().is_empty())
157 .collect();
158 let prefix = if first_emitted { "" } else { "\n" };
159 if words.is_empty() {
160 let text = line.text.trim();
161 if !text.is_empty() {
162 entries.push((to_ms(line.start_s), format!("{prefix}{text}")));
163 first_emitted = false;
164 }
165 continue;
166 }
167 for (word_index, word) in words.iter().enumerate() {
168 let text = word.text.trim();
169 let segment = if word_index == 0 {
170 format!("{prefix}{text}")
171 } else {
172 format!(" {text}")
173 };
174 entries.push((to_ms(word.start_s), segment));
175 }
176 first_emitted = false;
177 }
178 entries
179 }
180}
181
182/// True when `line` is a structural section marker (e.g. `[Chorus]`, `[Verse 1]`)
183/// rather than sung words, so it is omitted from the timed outputs (the `.lrc`
184/// body and the `SYLT` frame) where a player would otherwise highlight a line
185/// that is never sung.
186///
187/// Both signals are required: the trimmed text is wholly wrapped in brackets AND
188/// the line carries no word-level timing. Suno emits its section labels with an
189/// empty `words` list, whereas a genuinely-sung bracketed ad-lib (such as
190/// `[laughs]`) carries word timing and is kept. The `section` field is not used
191/// as a signal because every line carries one. The label is retained in the
192/// untimed [`AlignedLyrics::plain_text`], which mirrors `clip.lyrics`.
193fn is_section_label(line: &AlignedLine) -> bool {
194 let text = line.text.trim();
195 text.len() >= 2 && text.starts_with('[') && text.ends_with(']') && line.words.is_empty()
196}
197
198/// Total whole milliseconds for `secs`, clamped at zero (never negative).
199fn to_ms(secs: f64) -> u32 {
200 if !secs.is_finite() || secs <= 0.0 {
201 return 0;
202 }
203 (secs * 1000.0).round() as u32
204}
205
206/// Format `secs` as an LRC line stamp `mm:ss.xx` (centiseconds), with minutes
207/// allowed to exceed 59 so a long track is not wrapped.
208fn lrc_stamp(secs: f64) -> String {
209 let cs = centiseconds(secs);
210 format!("{:02}:{:02}.{:02}", cs / 6000, (cs / 100) % 60, cs % 100)
211}
212
213fn centiseconds(secs: f64) -> u64 {
214 if !secs.is_finite() || secs <= 0.0 {
215 return 0;
216 }
217 (secs * 100.0).round() as u64
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 /// The same two-line sample as the decode tests, built directly so the
225 /// render tests depend on the domain types, not on JSON decoding.
226 fn sample_aligned() -> AlignedLyrics {
227 AlignedLyrics {
228 words: vec![
229 AlignedWord {
230 word: "Hello".to_owned(),
231 success: true,
232 start_s: 0.5,
233 end_s: 0.9,
234 p_align: 0.99,
235 },
236 AlignedWord {
237 word: "world".to_owned(),
238 success: true,
239 start_s: 1.0,
240 end_s: 1.4,
241 p_align: 0.98,
242 },
243 AlignedWord {
244 word: "again".to_owned(),
245 success: true,
246 start_s: 61.2,
247 end_s: 61.8,
248 p_align: 0.97,
249 },
250 ],
251 lines: vec![
252 AlignedLine {
253 text: "Hello world".to_owned(),
254 start_s: 0.5,
255 end_s: 1.4,
256 section: "Verse 1".to_owned(),
257 words: vec![
258 AlignedLineWord {
259 text: "Hello".to_owned(),
260 start_s: 0.5,
261 end_s: 0.9,
262 },
263 AlignedLineWord {
264 text: "world".to_owned(),
265 start_s: 1.0,
266 end_s: 1.4,
267 },
268 ],
269 },
270 AlignedLine {
271 text: "[Chorus]".to_owned(),
272 start_s: 60.0,
273 end_s: 60.0,
274 section: "Chorus".to_owned(),
275 words: vec![],
276 },
277 AlignedLine {
278 text: "again".to_owned(),
279 start_s: 61.2,
280 end_s: 61.8,
281 section: "Chorus".to_owned(),
282 words: vec![AlignedLineWord {
283 text: "again".to_owned(),
284 start_s: 61.2,
285 end_s: 61.8,
286 }],
287 },
288 ],
289 ..Default::default()
290 }
291 }
292
293 #[test]
294 fn lrc_body_omits_section_labels() {
295 // The `[Chorus]` label line (bracketed text, empty words) is suppressed
296 // from the timed body; the two sung lines keep their own stamps.
297 let aligned = sample_aligned();
298 let body = aligned.lrc_body();
299 let expected = "[00:00.50]Hello world\n\
300 [01:01.20]again\n";
301 assert_eq!(body, expected);
302 assert!(!body.contains("[Chorus]"), "no timed section label");
303 }
304
305 #[test]
306 fn plain_text_retains_section_labels() {
307 // The untimed text keeps the section label verbatim (it mirrors
308 // `clip.lyrics`), so `.lyrics.txt`/`USLT`/FLAC `LYRICS` are unchanged.
309 let aligned = sample_aligned();
310 assert_eq!(aligned.plain_text(), "Hello world\n[Chorus]\nagain");
311 }
312
313 #[test]
314 fn sylt_entries_are_word_level_with_line_breaks() {
315 // The `[Chorus]` label is skipped; `again` still opens a new line, so it
316 // carries the leading newline as the second *emitted* line.
317 let aligned = sample_aligned();
318 let entries = aligned.sylt_entries();
319 assert_eq!(
320 entries,
321 vec![
322 (500, "Hello".to_owned()),
323 (1000, " world".to_owned()),
324 (61200, "\nagain".to_owned()),
325 ]
326 );
327 }
328
329 #[test]
330 fn stamps_round_and_do_not_wrap_minutes() {
331 // 61.2s -> 01:01.20; a value over an hour stays in minutes (not hours).
332 assert_eq!(lrc_stamp(61.2), "01:01.20");
333 assert_eq!(lrc_stamp(3661.0), "61:01.00");
334 assert_eq!(to_ms(1.2346), 1235);
335 assert_eq!(to_ms(-1.0), 0);
336 }
337
338 fn word(text: &str, start_s: f64, end_s: f64) -> AlignedLineWord {
339 AlignedLineWord {
340 text: text.to_owned(),
341 start_s,
342 end_s,
343 }
344 }
345
346 /// A line with `section` left empty (the predicate never reads it), so these
347 /// fixtures prove the label test keys on bracket shape plus empty words alone.
348 fn line(text: &str, start_s: f64, words: Vec<AlignedLineWord>) -> AlignedLine {
349 let end_s = words.last().map(|w| w.end_s).unwrap_or(start_s);
350 AlignedLine {
351 text: text.to_owned(),
352 start_s,
353 end_s,
354 section: String::new(),
355 words,
356 }
357 }
358
359 fn lyrics(lines: Vec<AlignedLine>) -> AlignedLyrics {
360 AlignedLyrics {
361 lines,
362 ..Default::default()
363 }
364 }
365
366 #[test]
367 fn is_section_label_needs_both_brackets_and_no_words() {
368 // Bracketed AND no word timing -> a structural label.
369 assert!(is_section_label(&line("[Chorus]", 1.0, vec![])));
370 assert!(is_section_label(&line("[Verse 1]", 1.0, vec![])));
371 assert!(is_section_label(&line(" [Bridge] ", 1.0, vec![])));
372 // Bracketed but carries sung words -> a real ad-lib, kept.
373 assert!(!is_section_label(&line(
374 "[laughs]",
375 1.0,
376 vec![word("[laughs]", 1.0, 1.5)]
377 )));
378 // Not wholly bracketed, or degenerate -> not a label.
379 assert!(!is_section_label(&line("I said [stop]", 1.0, vec![])));
380 assert!(!is_section_label(&line("[Chorus] Oh baby", 1.0, vec![])));
381 assert!(!is_section_label(&line("Hello", 1.0, vec![])));
382 assert!(!is_section_label(&line("[", 1.0, vec![])));
383 assert!(!is_section_label(&line("", 1.0, vec![])));
384 }
385
386 #[test]
387 fn sylt_label_at_index_zero_has_no_leading_newline() {
388 // A label as the very first line must not push the leading newline onto
389 // the first *sung* line: the flag tracks emission, not the line index.
390 let aligned = lyrics(vec![
391 line("[Intro]", 0.0, vec![]),
392 line(
393 "first line",
394 1.0,
395 vec![word("first", 1.0, 1.4), word("line", 1.5, 1.9)],
396 ),
397 ]);
398 assert_eq!(
399 aligned.sylt_entries(),
400 vec![(1000, "first".to_owned()), (1500, " line".to_owned())]
401 );
402 }
403
404 #[test]
405 fn label_as_last_line_adds_no_trailing_entry() {
406 // A trailing label yields no stamp/segment in either timed surface.
407 let aligned = lyrics(vec![
408 line(
409 "only line",
410 1.0,
411 vec![word("only", 1.0, 1.4), word("line", 1.5, 1.9)],
412 ),
413 line("[Outro]", 5.0, vec![]),
414 ]);
415 assert_eq!(aligned.lrc_body(), "[00:01.00]only line\n");
416 assert_eq!(
417 aligned.sylt_entries(),
418 vec![(1000, "only".to_owned()), (1500, " line".to_owned())]
419 );
420 }
421
422 #[test]
423 fn consecutive_labels_yield_exactly_one_newline() {
424 // Two skipped labels between sung lines add neither a stray nor a missing
425 // break: the next sung line gets exactly one leading newline.
426 let aligned = lyrics(vec![
427 line(
428 "line one",
429 1.0,
430 vec![word("line", 1.0, 1.3), word("one", 1.4, 1.7)],
431 ),
432 line("[Chorus]", 2.0, vec![]),
433 line("[Verse 2]", 2.5, vec![]),
434 line(
435 "line two",
436 3.0,
437 vec![word("line", 3.0, 3.3), word("two", 3.4, 3.7)],
438 ),
439 ]);
440 assert_eq!(
441 aligned.sylt_entries(),
442 vec![
443 (1000, "line".to_owned()),
444 (1400, " one".to_owned()),
445 (3000, "\nline".to_owned()),
446 (3400, " two".to_owned()),
447 ]
448 );
449 assert_eq!(
450 aligned.lrc_body(),
451 "[00:01.00]line one\n[00:03.00]line two\n"
452 );
453 }
454
455 #[test]
456 fn bracketed_ad_lib_with_words_is_kept() {
457 // A wholly-bracketed line that carries word timing is a sung ad-lib, not a
458 // structural label, so it survives in both timed surfaces (predicate-B).
459 let aligned = lyrics(vec![line(
460 "[laughs]",
461 2.0,
462 vec![word("[laughs]", 2.0, 2.6)],
463 )]);
464 assert_eq!(aligned.lrc_body(), "[00:02.00][laughs]\n");
465 assert_eq!(aligned.sylt_entries(), vec![(2000, "[laughs]".to_owned())]);
466 }
467}