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::testutil::{full_clip, full_lineage};
89
90    #[test]
91    fn lyrics_render_verbatim_with_one_trailing_newline() {
92        let clip = Clip {
93            lyrics: "line one\nline two".to_owned(),
94            ..Clip::default()
95        };
96        assert_eq!(
97            render_clip_lyrics(&clip),
98            Some("line one\nline two\n".to_owned())
99        );
100    }
101
102    #[test]
103    fn lyrics_normalise_trailing_whitespace_to_one_newline() {
104        let clip = Clip {
105            lyrics: "verse\n\n\n".to_owned(),
106            ..Clip::default()
107        };
108        assert_eq!(render_clip_lyrics(&clip), Some("verse\n".to_owned()));
109    }
110
111    #[test]
112    fn lyrics_none_when_empty_or_whitespace_only() {
113        assert_eq!(render_clip_lyrics(&Clip::default()), None);
114        let clip = Clip {
115            lyrics: "  \n\t \n".to_owned(),
116            ..Clip::default()
117        };
118        assert_eq!(render_clip_lyrics(&clip), None);
119    }
120
121    #[test]
122    fn lyrics_use_clip_lyrics_not_prompt() {
123        let clip = Clip {
124            prompt: "the generation prompt".to_owned(),
125            lyrics: "the actual sung words".to_owned(),
126            ..Clip::default()
127        };
128        let rendered = render_clip_lyrics(&clip).unwrap();
129        assert!(rendered.contains("the actual sung words"));
130        assert!(!rendered.contains("the generation prompt"));
131    }
132
133    #[test]
134    fn lrc_none_when_lyrics_blank() {
135        let empty = Clip::default();
136        assert_eq!(
137            render_clip_lrc(&empty, &LineageContext::own_root(&empty)),
138            None
139        );
140        let clip = Clip {
141            lyrics: "  \n\t \n".to_owned(),
142            ..Clip::default()
143        };
144        assert_eq!(
145            render_clip_lrc(&clip, &LineageContext::own_root(&clip)),
146            None
147        );
148    }
149
150    #[test]
151    fn lrc_renders_untimed_body_with_headers() {
152        let rendered = render_clip_lrc(&full_clip(), &full_lineage()).unwrap();
153        let expected = "[ti:Electric Storm]\n\
154        [ar:alice]\n\
155        [al:Weather Series]\n\
156        [length:3:32]\n\
157        [re:rs-suno]\n\
158        thunder rolls\n\
159        over the plains\n";
160        assert_eq!(rendered, expected);
161        // Untimed: no per-line `[mm:ss.xx]` timestamps.
162        assert!(!rendered.contains("[00:"));
163    }
164
165    #[test]
166    fn lrc_omits_unknown_headers() {
167        let clip = Clip {
168            title: "Bare".to_owned(),
169            lyrics: "one line".to_owned(),
170            ..Clip::default()
171        };
172        let rendered = render_clip_lrc(&clip, &LineageContext::own_root(&clip)).unwrap();
173        // No duration, so `[length:]` is omitted; artist falls back to Suno and
174        // album to the title. The constant tool tag is always present.
175        assert!(!rendered.contains("[length:"));
176        assert!(rendered.contains("[ti:Bare]\n"));
177        assert!(rendered.contains("[re:rs-suno]\n"));
178        assert!(rendered.ends_with("one line\n"));
179    }
180
181    fn sample_aligned() -> crate::lyrics::AlignedLyrics {
182        crate::lyrics::AlignedLyrics {
183            lines: vec![crate::lyrics::AlignedLine {
184                text: "thunder rolls".to_owned(),
185                start_s: 1.5,
186                end_s: 2.4,
187                section: "Verse 1".to_owned(),
188                words: vec![
189                    crate::lyrics::AlignedLineWord {
190                        text: "thunder".to_owned(),
191                        start_s: 1.5,
192                        end_s: 2.0,
193                    },
194                    crate::lyrics::AlignedLineWord {
195                        text: "rolls".to_owned(),
196                        start_s: 2.1,
197                        end_s: 2.4,
198                    },
199                ],
200            }],
201            ..Default::default()
202        }
203    }
204
205    #[test]
206    fn synced_lrc_has_headers_then_line_stamps() {
207        let rendered = render_synced_lrc(&full_clip(), &full_lineage(), &sample_aligned()).unwrap();
208        let expected = "[ti:Electric Storm]\n\
209        [ar:alice]\n\
210        [al:Weather Series]\n\
211        [length:3:32]\n\
212        [re:rs-suno]\n\
213        [00:01.50]thunder rolls\n";
214        assert_eq!(rendered, expected);
215    }
216
217    #[test]
218    fn synced_lrc_is_none_for_empty_alignment() {
219        // An instrumental (empty arrays) writes no synced `.lrc`, exactly as an
220        // empty cover URL writes no cover.
221        let empty = crate::lyrics::AlignedLyrics::default();
222        assert_eq!(
223            render_synced_lrc(&full_clip(), &full_lineage(), &empty),
224            None
225        );
226    }
227}