suno_core/lyrics/
render.rs1use 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
13pub 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
25pub 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
42pub 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
63fn 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 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 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 let empty = crate::lyrics::AlignedLyrics::default();
222 assert_eq!(
223 render_synced_lrc(&full_clip(), &full_lineage(), &empty),
224 None
225 );
226 }
227}