Skip to main content

suno_core/lyrics/
render.rs

1//! Clip-level lyric sidecar renderers: the `.lyrics.txt` text and the
2//! timed/untimed `.lrc`, built from a clip, its lineage, and Suno's aligned
3//! lyrics. The pure timed-lyrics primitives live in the sibling `aligned` leaf.
4
5use std::fmt::Write as _;
6
7use super::AlignedLyrics;
8use crate::lineage::LineageContext;
9use crate::model::Clip;
10use crate::tag::TrackMetadata;
11use crate::textfmt::{format_duration, to_single_line};
12
13/// Render the plain-text lyrics sidecar for `clip`, or `None` when it has none.
14///
15/// The clip's own `lyrics`, verbatim, normalised to one trailing newline. Empty
16/// or whitespace-only lyrics return `None` so no empty `.lyrics.txt` is written.
17/// The generation prompt is not used here (it lives in the details sidecar).
18pub fn render_clip_lyrics(clip: &Clip) -> Option<String> {
19    if clip.lyrics.trim().is_empty() {
20        return None;
21    }
22    Some(format!("{}\n", clip.lyrics.trim_end()))
23}
24
25/// Render an untimed `.lrc` sidecar for `clip`, or `None` when it has no lyrics.
26///
27/// Plain lyric lines with no timestamps, under the shared `.lrc` header. Empty
28/// or whitespace-only lyrics return `None` so no empty `.lrc` is written. This is
29/// the fallback when Suno has no alignment; the synced [`render_synced_lrc`]
30/// supersedes it at the same path when available.
31pub fn render_clip_lrc(clip: &Clip, lineage: &LineageContext) -> Option<String> {
32    if clip.lyrics.trim().is_empty() {
33        return None;
34    }
35    let mut out = lrc_headers(clip, lineage);
36    for line in clip.lyrics.trim_end().lines() {
37        let _ = writeln!(out, "{line}");
38    }
39    Some(out)
40}
41
42/// Render a synced (timed) `.lrc` sidecar for `clip` from Suno's `aligned`
43/// lyrics, or `None` when there is nothing to time (an instrumental).
44///
45/// Same header as [`render_clip_lrc`]; the body is the line-level form from
46/// [`AlignedLyrics::lrc_body`], one `[mm:ss.xx]` stamp per line. Word-level
47/// timing rides the MP3 `SYLT` frame, not the `.lrc`. Returns `None` when there
48/// are no timed lines.
49pub fn render_synced_lrc(
50    clip: &Clip,
51    lineage: &LineageContext,
52    aligned: &AlignedLyrics,
53) -> Option<String> {
54    let body = aligned.lrc_body();
55    if body.is_empty() {
56        return None;
57    }
58    let mut out = lrc_headers(clip, lineage);
59    out.push_str(&body);
60    Some(out)
61}
62
63/// The shared `.lrc` header block: `[ti:]`, `[ar:]`, `[al:]`, `[length:]` (each
64/// omitted when empty or unknown), plus the constant `[re:rs-suno]` tool tag.
65fn lrc_headers(clip: &Clip, lineage: &LineageContext) -> String {
66    let meta = TrackMetadata::from_clip(clip, lineage);
67    let length = format_duration(clip.duration);
68    let headers: [(&str, &str); 5] = [
69        ("ti", &meta.title),
70        ("ar", &meta.artist),
71        ("al", &meta.album),
72        ("length", &length),
73        ("re", "rs-suno"),
74    ];
75    let mut out = String::new();
76    for (tag, value) in headers {
77        if value.is_empty() {
78            continue;
79        }
80        let _ = writeln!(out, "[{tag}:{}]", to_single_line(value));
81    }
82    out
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::lineage::{EdgeType, ResolveStatus};
89
90    fn full_clip() -> Clip {
91        Clip {
92            id: "clip-1234abcd".to_owned(),
93            title: "Electric Storm".to_owned(),
94            tags: "ambient, cinematic".to_owned(),
95            duration: 211.6,
96            created_at: "2024-03-10T14:22:01Z".to_owned(),
97            display_name: "alice".to_owned(),
98            handle: "alice".to_owned(),
99            prompt: "an orchestral storm".to_owned(),
100            gpt_description_prompt: "a moody cinematic build".to_owned(),
101            lyrics: "thunder rolls\nover the plains".to_owned(),
102            model_name: "chirp-v4".to_owned(),
103            major_model_version: "v4".to_owned(),
104            image_large_url: "https://cdn1.suno.ai/signed?token=secret".to_owned(),
105            audio_url: "https://cdn1.suno.ai/clip-1234abcd.mp3".to_owned(),
106            ..Clip::default()
107        }
108    }
109
110    fn full_lineage() -> LineageContext {
111        LineageContext {
112            root_id: "rootid567890".to_owned(),
113            root_title: "Weather Series".to_owned(),
114            root_date: String::new(),
115            parent_id: "parentid1234".to_owned(),
116            edge_type: Some(EdgeType::Extend),
117            status: ResolveStatus::Resolved,
118            track: 0,
119            track_total: 0,
120        }
121    }
122
123    #[test]
124    fn lyrics_render_verbatim_with_one_trailing_newline() {
125        let clip = Clip {
126            lyrics: "line one\nline two".to_owned(),
127            ..Clip::default()
128        };
129        assert_eq!(
130            render_clip_lyrics(&clip),
131            Some("line one\nline two\n".to_owned())
132        );
133    }
134
135    #[test]
136    fn lyrics_normalise_trailing_whitespace_to_one_newline() {
137        let clip = Clip {
138            lyrics: "verse\n\n\n".to_owned(),
139            ..Clip::default()
140        };
141        assert_eq!(render_clip_lyrics(&clip), Some("verse\n".to_owned()));
142    }
143
144    #[test]
145    fn lyrics_none_when_empty_or_whitespace_only() {
146        assert_eq!(render_clip_lyrics(&Clip::default()), None);
147        let clip = Clip {
148            lyrics: "  \n\t \n".to_owned(),
149            ..Clip::default()
150        };
151        assert_eq!(render_clip_lyrics(&clip), None);
152    }
153
154    #[test]
155    fn lyrics_use_clip_lyrics_not_prompt() {
156        let clip = Clip {
157            prompt: "the generation prompt".to_owned(),
158            lyrics: "the actual sung words".to_owned(),
159            ..Clip::default()
160        };
161        let rendered = render_clip_lyrics(&clip).unwrap();
162        assert!(rendered.contains("the actual sung words"));
163        assert!(!rendered.contains("the generation prompt"));
164    }
165
166    #[test]
167    fn lrc_none_when_lyrics_blank() {
168        let empty = Clip::default();
169        assert_eq!(
170            render_clip_lrc(&empty, &LineageContext::own_root(&empty)),
171            None
172        );
173        let clip = Clip {
174            lyrics: "  \n\t \n".to_owned(),
175            ..Clip::default()
176        };
177        assert_eq!(
178            render_clip_lrc(&clip, &LineageContext::own_root(&clip)),
179            None
180        );
181    }
182
183    #[test]
184    fn lrc_renders_untimed_body_with_headers() {
185        let rendered = render_clip_lrc(&full_clip(), &full_lineage()).unwrap();
186        let expected = "[ti:Electric Storm]\n\
187        [ar:alice]\n\
188        [al:Weather Series]\n\
189        [length:3:32]\n\
190        [re:rs-suno]\n\
191        thunder rolls\n\
192        over the plains\n";
193        assert_eq!(rendered, expected);
194        // Untimed: no per-line `[mm:ss.xx]` timestamps.
195        assert!(!rendered.contains("[00:"));
196    }
197
198    #[test]
199    fn lrc_omits_unknown_headers() {
200        let clip = Clip {
201            title: "Bare".to_owned(),
202            lyrics: "one line".to_owned(),
203            ..Clip::default()
204        };
205        let rendered = render_clip_lrc(&clip, &LineageContext::own_root(&clip)).unwrap();
206        // No duration, so `[length:]` is omitted; artist falls back to Suno and
207        // album to the title. The constant tool tag is always present.
208        assert!(!rendered.contains("[length:"));
209        assert!(rendered.contains("[ti:Bare]\n"));
210        assert!(rendered.contains("[re:rs-suno]\n"));
211        assert!(rendered.ends_with("one line\n"));
212    }
213
214    fn sample_aligned() -> crate::lyrics::AlignedLyrics {
215        crate::lyrics::AlignedLyrics {
216            lines: vec![crate::lyrics::AlignedLine {
217                text: "thunder rolls".to_owned(),
218                start_s: 1.5,
219                end_s: 2.4,
220                section: "Verse 1".to_owned(),
221                words: vec![
222                    crate::lyrics::AlignedLineWord {
223                        text: "thunder".to_owned(),
224                        start_s: 1.5,
225                        end_s: 2.0,
226                    },
227                    crate::lyrics::AlignedLineWord {
228                        text: "rolls".to_owned(),
229                        start_s: 2.1,
230                        end_s: 2.4,
231                    },
232                ],
233            }],
234            ..Default::default()
235        }
236    }
237
238    #[test]
239    fn synced_lrc_has_headers_then_line_stamps() {
240        let rendered = render_synced_lrc(&full_clip(), &full_lineage(), &sample_aligned()).unwrap();
241        let expected = "[ti:Electric Storm]\n\
242        [ar:alice]\n\
243        [al:Weather Series]\n\
244        [length:3:32]\n\
245        [re:rs-suno]\n\
246        [00:01.50]thunder rolls\n";
247        assert_eq!(rendered, expected);
248    }
249
250    #[test]
251    fn synced_lrc_is_none_for_empty_alignment() {
252        // An instrumental (empty arrays) writes no synced `.lrc`, exactly as an
253        // empty cover URL writes no cover.
254        let empty = crate::lyrics::AlignedLyrics::default();
255        assert_eq!(
256            render_synced_lrc(&full_clip(), &full_lineage(), &empty),
257            None
258        );
259    }
260}