1use 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::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 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 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 let empty = crate::lyrics::AlignedLyrics::default();
255 assert_eq!(
256 render_synced_lrc(&full_clip(), &full_lineage(), &empty),
257 None
258 );
259 }
260}