Skip to main content

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}