Skip to main content

suno_core/
synced.rs

1//! Pure synced-lyrics resolution: which clips to fetch alignment for, and how
2//! each fetched result maps onto a clip's desired `.lrc` artifact.
3//!
4//! The alignment fetch itself is IO and lives in the CLI (through the `Http`
5//! port); everything here is pure so the fetch-gating, the timed/untimed body
6//! choice, the "keep existing on failure" rule, and the instrumental "checked"
7//! marker are unit-tested without a network.
8//!
9//! Suno's forced alignment for a clip is immutable in practice (the audio and
10//! its lyrics are fixed once generated), so a clip is fetched at most once per
11//! render [`SYNCED_LRC_VERSION`] — recorded by [`SyncedLyricsCheck`] on the
12//! manifest — except that a clip that resolved to no lyrics (an instrumental) is
13//! re-checked after [`SYNCED_LRC_RECHECK_SECS`] to pick up alignment Suno may
14//! compute after generation, and a clip whose audio is renamed is re-fetched so
15//! its `.lrc` moves with it. A version bump re-resolves everything.
16
17use std::collections::{BTreeSet, HashMap};
18
19use crate::extras::{render_clip_lrc, render_synced_lrc};
20use crate::hash::{SYNCED_LRC_VERSION, content_hash, synced_lrc_source_hash};
21use crate::lyrics::AlignedLyrics;
22use crate::manifest::{Manifest, ManifestEntry};
23use crate::reconcile::{ArtifactKind, Desired};
24
25/// How long a clip that resolved to no lyrics is trusted before its alignment is
26/// re-checked (14 days). Bounds the re-fetch of instrumentals to catch alignment
27/// Suno may compute shortly after a clip is generated.
28pub const SYNCED_LRC_RECHECK_SECS: u64 = 14 * 24 * 60 * 60;
29
30/// One clip's synced-lyrics outcome this run, for the caller to record as a
31/// manifest [`SyncedLyricsCheck`](crate::SyncedLyricsCheck) once the `.lrc` write
32/// (if any) has safely landed.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PendingCheck {
35    /// The clip this outcome concerns.
36    pub clip_id: String,
37    /// Whether the clip resolved to no lyrics (an instrumental).
38    pub empty: bool,
39    /// The content hash of the rendered `.lrc` body, when one was produced. The
40    /// caller records the marker only once the manifest slot reflects this hash,
41    /// so an interrupted or failed write is re-resolved next run.
42    pub body_hash: Option<String>,
43}
44
45/// The relative `.lrc` path a clip's desired artifact targets, if it has one.
46fn desired_lrc(desired: &Desired) -> Option<&str> {
47    desired
48        .artifacts
49        .iter()
50        .find(|a| a.kind == ArtifactKind::Lrc)
51        .map(|a| a.path.as_str())
52}
53
54/// Whether a clip's alignment must be (re)fetched this run.
55fn needs_fetch(entry: Option<&ManifestEntry>, desired_lrc_path: &str, now_unix: u64) -> bool {
56    let Some(entry) = entry else {
57        return true; // never downloaded -> resolve on first sight
58    };
59    match &entry.synced_lyrics {
60        // Never resolved (e.g. a clip downloaded before the feature existed).
61        None => true,
62        Some(check) => {
63            if check.version != SYNCED_LRC_VERSION {
64                return true; // the render changed -> re-resolve and re-render
65            }
66            if check.empty {
67                // An instrumental: re-check only once the window elapses.
68                now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS
69            } else {
70                // Written: re-fetch only to move the `.lrc` when the audio is
71                // renamed (its `.lrc` path drifts), or if the slot is somehow
72                // missing (an interrupted prior write).
73                entry
74                    .lrc
75                    .as_ref()
76                    .map(|slot| slot.path != desired_lrc_path)
77                    .unwrap_or(true)
78            }
79        }
80    }
81}
82
83/// The clip ids whose alignment must be fetched this run, in a stable order.
84///
85/// Empty when `enabled` is false, so the synced-lyrics feature being off means
86/// zero alignment fetches. Only clips carrying a desired `.lrc` artifact (a
87/// lyric signal) are considered; each is fetched at most once per render version
88/// (see [`needs_fetch`]).
89pub fn synced_lyrics_targets(
90    desired: &[Desired],
91    manifest: &Manifest,
92    now_unix: u64,
93    enabled: bool,
94) -> BTreeSet<String> {
95    if !enabled {
96        return BTreeSet::new();
97    }
98    let mut out = BTreeSet::new();
99    for d in desired {
100        let Some(path) = desired_lrc(d) else {
101            continue;
102        };
103        if needs_fetch(manifest.get(&d.clip.id), path, now_unix) {
104            out.insert(d.clip.id.clone());
105        }
106    }
107    out
108}
109
110/// Resolve each clip's desired `.lrc` artifact from the fetched alignment,
111/// returning the checks to persist for the clips that were successfully fetched.
112///
113/// `successes` holds the alignment for clips whose fetch returned `200` (an empty
114/// value for an instrumental); a clip absent from it either was not fetched
115/// (resolved recently) or its fetch FAILED. In both of those cases the existing
116/// `.lrc` is KEPT untouched — the artifact's hash is reset to the stored slot so
117/// reconcile skips it (no rewrite, no downgrade of a timed file to untimed), or
118/// the artifact is dropped when there is nothing on disk yet — and no check is
119/// returned, so a failed fetch is simply retried next run.
120///
121/// For a successful fetch the body is the timed render when Suno has alignment,
122/// else the untimed lyrics as a fallback; an instrumental (no body) drops the
123/// artifact and records an empty check. A produced body sets the artifact's
124/// content and its content hash, so reconcile rewrites only when the body
125/// actually changes (including an untimed→timed upgrade after a re-check).
126pub fn apply_synced_lrc(
127    desired: &mut [Desired],
128    manifest: &Manifest,
129    successes: &HashMap<String, AlignedLyrics>,
130) -> Vec<PendingCheck> {
131    let mut pending = Vec::new();
132    for d in desired.iter_mut() {
133        let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
134            continue;
135        };
136        let clip_id = d.clip.id.clone();
137        let slot_hash = manifest
138            .get(&clip_id)
139            .and_then(|e| e.lrc.as_ref())
140            .map(|slot| slot.hash.clone());
141
142        if let Some(aligned) = successes.get(&clip_id) {
143            let body = if aligned.is_empty() {
144                render_clip_lrc(&d.clip, &d.lineage)
145            } else {
146                render_synced_lrc(&d.clip, &d.lineage, aligned)
147            };
148            match body {
149                Some(text) => {
150                    let hash = content_hash(&text);
151                    let artifact = &mut d.artifacts[idx];
152                    artifact.hash = hash.clone();
153                    artifact.content = Some(text);
154                    pending.push(PendingCheck {
155                        clip_id,
156                        empty: false,
157                        body_hash: Some(hash),
158                    });
159                }
160                None => {
161                    d.artifacts.remove(idx);
162                    pending.push(PendingCheck {
163                        clip_id,
164                        empty: true,
165                        body_hash: None,
166                    });
167                }
168            }
169        } else {
170            // Not fetched this run (resolved recently) or the fetch failed: keep
171            // whatever is already on disk. Reuse the stored slot hash so reconcile
172            // skips the write; drop the artifact when nothing was ever written.
173            match slot_hash {
174                Some(hash) => {
175                    let artifact = &mut d.artifacts[idx];
176                    artifact.hash = hash;
177                    artifact.content = None;
178                }
179                None => {
180                    d.artifacts.remove(idx);
181                }
182            }
183        }
184    }
185    pending
186}
187
188/// Adjust each clip's desired `.lrc` artifact for a dry run, without any fetch.
189///
190/// A clip that WOULD be fetched (a target) keeps a distinct pending hash so the
191/// previewed plan reports its `.lrc` write; a clip already resolved reuses its
192/// stored slot hash (so it shows as skipped) or drops the artifact when it is a
193/// known instrumental. The preview is therefore an upper bound on synced `.lrc`
194/// writes (it cannot know which targets will turn out to be instrumentals).
195pub fn preview_synced_lrc(
196    desired: &mut [Desired],
197    manifest: &Manifest,
198    now_unix: u64,
199    enabled: bool,
200) {
201    let targets = synced_lyrics_targets(desired, manifest, now_unix, enabled);
202    for d in desired.iter_mut() {
203        let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
204            continue;
205        };
206        if targets.contains(&d.clip.id) {
207            d.artifacts[idx].hash = synced_lrc_source_hash(&d.clip.id);
208            continue;
209        }
210        match manifest.get(&d.clip.id).and_then(|e| e.lrc.as_ref()) {
211            Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
212            None => {
213                d.artifacts.remove(idx);
214            }
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::config::AudioFormat;
223    use crate::lineage::LineageContext;
224    use crate::manifest::{ArtifactState, SyncedLyricsCheck};
225    use crate::model::Clip;
226    use crate::reconcile::DesiredArtifact;
227
228    fn clip(id: &str, lyrics: &str) -> Clip {
229        Clip {
230            id: id.to_string(),
231            title: "Song".to_string(),
232            lyrics: lyrics.to_string(),
233            prompt: "a prompt".to_string(),
234            ..Default::default()
235        }
236    }
237
238    fn lrc_artifact(clip_id: &str) -> DesiredArtifact {
239        DesiredArtifact {
240            kind: ArtifactKind::Lrc,
241            path: format!("{clip_id}.lrc"),
242            source_url: String::new(),
243            hash: synced_lrc_source_hash(clip_id),
244            content: None,
245        }
246    }
247
248    fn desired(id: &str, lyrics: &str) -> Desired {
249        let c = clip(id, lyrics);
250        Desired {
251            lineage: LineageContext::own_root(&c),
252            path: format!("{id}.flac"),
253            format: AudioFormat::Flac,
254            meta_hash: "m".to_string(),
255            art_hash: "a".to_string(),
256            modes: vec![crate::reconcile::SourceMode::Mirror],
257            trashed: false,
258            private: false,
259            artifacts: vec![lrc_artifact(id)],
260            clip: c,
261        }
262    }
263
264    fn one_line_alignment() -> AlignedLyrics {
265        AlignedLyrics::from_json(&serde_json::json!({
266            "aligned_words": [],
267            "aligned_lyrics": [
268                {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
269                 "words": [
270                     {"text": "hi", "start_s": 0.5, "end_s": 0.8},
271                     {"text": "there", "start_s": 0.9, "end_s": 1.2}
272                 ]}
273            ]
274        }))
275    }
276
277    fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
278        ManifestEntry {
279            path: "song.flac".to_string(),
280            format: AudioFormat::Flac,
281            lrc,
282            synced_lyrics: check,
283            ..Default::default()
284        }
285    }
286
287    #[test]
288    fn targets_empty_when_feature_off() {
289        let d = vec![desired("a", "")];
290        let manifest = Manifest::new();
291        assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
292    }
293
294    #[test]
295    fn targets_new_clip_but_not_a_recently_resolved_one() {
296        let d = vec![desired("new", ""), desired("done", "")];
297        let mut manifest = Manifest::new();
298        // `done` was resolved (written) at the current version; `new` is unseen.
299        manifest.insert(
300            "done",
301            entry(
302                Some(ArtifactState {
303                    path: "done.lrc".to_string(),
304                    hash: "h".to_string(),
305                }),
306                Some(SyncedLyricsCheck {
307                    version: SYNCED_LRC_VERSION,
308                    checked_unix: 1_000,
309                    empty: false,
310                }),
311            ),
312        );
313        let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
314        assert!(targets.contains("new"));
315        assert!(!targets.contains("done"));
316    }
317
318    #[test]
319    fn instrumental_is_rechecked_only_after_the_window() {
320        let d = vec![desired("instr", "")];
321        let mut manifest = Manifest::new();
322        manifest.insert(
323            "instr",
324            entry(
325                None,
326                Some(SyncedLyricsCheck {
327                    version: SYNCED_LRC_VERSION,
328                    checked_unix: 1_000,
329                    empty: true,
330                }),
331            ),
332        );
333        // Within the window: not re-fetched (this is the fix for forever-refetch).
334        let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
335        assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
336        // Past the window: re-checked, to pick up late alignment.
337        let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
338        assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
339    }
340
341    #[test]
342    fn version_bump_refetches_everything() {
343        let d = vec![desired("done", "")];
344        let mut manifest = Manifest::new();
345        manifest.insert(
346            "done",
347            entry(
348                Some(ArtifactState {
349                    path: "done.lrc".to_string(),
350                    hash: "h".to_string(),
351                }),
352                Some(SyncedLyricsCheck {
353                    version: SYNCED_LRC_VERSION + 1, // resolved at a different version
354                    checked_unix: 1_000,
355                    empty: false,
356                }),
357            ),
358        );
359        assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
360    }
361
362    #[test]
363    fn rename_refetches_a_written_clip() {
364        let mut d = vec![desired("a", "")];
365        // The audio (and so the `.lrc`) moved to a new path.
366        d[0].artifacts[0].path = "new/a.lrc".to_string();
367        let mut manifest = Manifest::new();
368        manifest.insert(
369            "a",
370            entry(
371                Some(ArtifactState {
372                    path: "old/a.lrc".to_string(),
373                    hash: "h".to_string(),
374                }),
375                Some(SyncedLyricsCheck {
376                    version: SYNCED_LRC_VERSION,
377                    checked_unix: 1_000,
378                    empty: false,
379                }),
380            ),
381        );
382        assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
383    }
384
385    #[test]
386    fn apply_sets_timed_body_and_content_hash() {
387        let mut d = vec![desired("a", "")];
388        let mut successes = HashMap::new();
389        successes.insert("a".to_string(), one_line_alignment());
390        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
391
392        let art = &d[0].artifacts[0];
393        let body = art.content.as_deref().unwrap();
394        assert!(body.contains("[00:00.50]hi there"));
395        assert_eq!(art.hash, content_hash(body));
396        assert_eq!(
397            pending,
398            vec![PendingCheck {
399                clip_id: "a".to_string(),
400                empty: false,
401                body_hash: Some(content_hash(body)),
402            }]
403        );
404    }
405
406    #[test]
407    fn apply_drops_instrumental_and_marks_empty() {
408        let mut d = vec![desired("instr", "")];
409        let mut successes = HashMap::new();
410        successes.insert("instr".to_string(), AlignedLyrics::default());
411        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
412
413        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
414        assert_eq!(
415            pending,
416            vec![PendingCheck {
417                clip_id: "instr".to_string(),
418                empty: true,
419                body_hash: None,
420            }]
421        );
422    }
423
424    #[test]
425    fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
426        // The clip has an existing timed `.lrc` (slot present) but its fetch
427        // failed this run (absent from successes). The artifact is reset to the
428        // stored slot hash with no content, so reconcile skips it — the good
429        // timed file is neither rewritten nor downgraded — and no check is
430        // recorded, so it is retried next run.
431        let mut d = vec![desired("a", "")];
432        let mut manifest = Manifest::new();
433        manifest.insert(
434            "a",
435            entry(
436                Some(ArtifactState {
437                    path: "a.lrc".to_string(),
438                    hash: "timed-hash".to_string(),
439                }),
440                Some(SyncedLyricsCheck {
441                    version: SYNCED_LRC_VERSION,
442                    checked_unix: 1_000,
443                    empty: false,
444                }),
445            ),
446        );
447        let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
448
449        let art = &d[0].artifacts[0];
450        assert_eq!(art.hash, "timed-hash");
451        assert_eq!(art.content, None);
452        assert!(
453            pending.is_empty(),
454            "no check recorded on failure -> retried"
455        );
456    }
457
458    #[test]
459    fn apply_drops_write_on_failure_when_nothing_on_disk() {
460        // A brand-new clip whose fetch failed: no slot to keep, so the write is
461        // dropped (retried next run) rather than written empty.
462        let mut d = vec![desired("a", "")];
463        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
464        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
465        assert!(pending.is_empty());
466    }
467
468    #[test]
469    fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
470        // The clip previously wrote an untimed body (stored slot hash); a later
471        // fetch returns alignment, so the timed body's content hash differs and
472        // reconcile will rewrite (the artifact carries the new content).
473        let mut d = vec![desired("a", "")];
474        let untimed_hash = "untimed".to_string();
475        let mut manifest = Manifest::new();
476        manifest.insert(
477            "a",
478            entry(
479                Some(ArtifactState {
480                    path: "a.lrc".to_string(),
481                    hash: untimed_hash.clone(),
482                }),
483                Some(SyncedLyricsCheck {
484                    version: SYNCED_LRC_VERSION,
485                    checked_unix: 1_000,
486                    empty: true,
487                }),
488            ),
489        );
490        let mut successes = HashMap::new();
491        successes.insert("a".to_string(), one_line_alignment());
492        apply_synced_lrc(&mut d, &manifest, &successes);
493        let art = &d[0].artifacts[0];
494        assert!(
495            art.content
496                .as_deref()
497                .unwrap()
498                .contains("[00:00.50]hi there")
499        );
500        assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
501    }
502
503    #[test]
504    fn preview_shows_write_for_targets_and_skips_resolved() {
505        let mut d = vec![desired("new", ""), desired("done", "")];
506        let mut manifest = Manifest::new();
507        manifest.insert(
508            "done",
509            entry(
510                Some(ArtifactState {
511                    path: "done.lrc".to_string(),
512                    hash: "slot-hash".to_string(),
513                }),
514                Some(SyncedLyricsCheck {
515                    version: SYNCED_LRC_VERSION,
516                    checked_unix: 1_000,
517                    empty: false,
518                }),
519            ),
520        );
521        preview_synced_lrc(&mut d, &manifest, 2_000, true);
522        // `new` keeps a pending hash (would write); `done` reuses its slot hash.
523        assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
524        assert_eq!(d[1].artifacts[0].hash, "slot-hash");
525    }
526}