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. 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}