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            stems: None,
262        }
263    }
264
265    fn one_line_alignment() -> AlignedLyrics {
266        AlignedLyrics::from_json(&serde_json::json!({
267            "aligned_words": [],
268            "aligned_lyrics": [
269                {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
270                 "words": [
271                     {"text": "hi", "start_s": 0.5, "end_s": 0.8},
272                     {"text": "there", "start_s": 0.9, "end_s": 1.2}
273                 ]}
274            ]
275        }))
276    }
277
278    fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
279        ManifestEntry {
280            path: "song.flac".to_string(),
281            format: AudioFormat::Flac,
282            lrc,
283            synced_lyrics: check,
284            ..Default::default()
285        }
286    }
287
288    #[test]
289    fn targets_empty_when_feature_off() {
290        let d = vec![desired("a", "")];
291        let manifest = Manifest::new();
292        assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
293    }
294
295    #[test]
296    fn targets_new_clip_but_not_a_recently_resolved_one() {
297        let d = vec![desired("new", ""), desired("done", "")];
298        let mut manifest = Manifest::new();
299        // `done` was resolved (written) at the current version; `new` is unseen.
300        manifest.insert(
301            "done",
302            entry(
303                Some(ArtifactState {
304                    path: "done.lrc".to_string(),
305                    hash: "h".to_string(),
306                }),
307                Some(SyncedLyricsCheck {
308                    version: SYNCED_LRC_VERSION,
309                    checked_unix: 1_000,
310                    empty: false,
311                }),
312            ),
313        );
314        let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
315        assert!(targets.contains("new"));
316        assert!(!targets.contains("done"));
317    }
318
319    #[test]
320    fn instrumental_is_rechecked_only_after_the_window() {
321        let d = vec![desired("instr", "")];
322        let mut manifest = Manifest::new();
323        manifest.insert(
324            "instr",
325            entry(
326                None,
327                Some(SyncedLyricsCheck {
328                    version: SYNCED_LRC_VERSION,
329                    checked_unix: 1_000,
330                    empty: true,
331                }),
332            ),
333        );
334        // Within the window: not re-fetched (this is the fix for forever-refetch).
335        let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
336        assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
337        // Past the window: re-checked, to pick up late alignment.
338        let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
339        assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
340    }
341
342    #[test]
343    fn version_bump_refetches_everything() {
344        let d = vec![desired("done", "")];
345        let mut manifest = Manifest::new();
346        manifest.insert(
347            "done",
348            entry(
349                Some(ArtifactState {
350                    path: "done.lrc".to_string(),
351                    hash: "h".to_string(),
352                }),
353                Some(SyncedLyricsCheck {
354                    version: SYNCED_LRC_VERSION + 1, // resolved at a different version
355                    checked_unix: 1_000,
356                    empty: false,
357                }),
358            ),
359        );
360        assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
361    }
362
363    #[test]
364    fn rename_refetches_a_written_clip() {
365        let mut d = vec![desired("a", "")];
366        // The audio (and so the `.lrc`) moved to a new path.
367        d[0].artifacts[0].path = "new/a.lrc".to_string();
368        let mut manifest = Manifest::new();
369        manifest.insert(
370            "a",
371            entry(
372                Some(ArtifactState {
373                    path: "old/a.lrc".to_string(),
374                    hash: "h".to_string(),
375                }),
376                Some(SyncedLyricsCheck {
377                    version: SYNCED_LRC_VERSION,
378                    checked_unix: 1_000,
379                    empty: false,
380                }),
381            ),
382        );
383        assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
384    }
385
386    #[test]
387    fn apply_sets_timed_body_and_content_hash() {
388        let mut d = vec![desired("a", "")];
389        let mut successes = HashMap::new();
390        successes.insert("a".to_string(), one_line_alignment());
391        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
392
393        let art = &d[0].artifacts[0];
394        let body = art.content.as_deref().unwrap();
395        assert!(body.contains("[00:00.50]hi there"));
396        assert_eq!(art.hash, content_hash(body));
397        assert_eq!(
398            pending,
399            vec![PendingCheck {
400                clip_id: "a".to_string(),
401                empty: false,
402                body_hash: Some(content_hash(body)),
403            }]
404        );
405    }
406
407    #[test]
408    fn apply_drops_instrumental_and_marks_empty() {
409        let mut d = vec![desired("instr", "")];
410        let mut successes = HashMap::new();
411        successes.insert("instr".to_string(), AlignedLyrics::default());
412        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
413
414        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
415        assert_eq!(
416            pending,
417            vec![PendingCheck {
418                clip_id: "instr".to_string(),
419                empty: true,
420                body_hash: None,
421            }]
422        );
423    }
424
425    #[test]
426    fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
427        // The clip has an existing timed `.lrc` (slot present) but its fetch
428        // failed this run (absent from successes). The artifact is reset to the
429        // stored slot hash with no content, so reconcile skips it — the good
430        // timed file is neither rewritten nor downgraded — and no check is
431        // recorded, so it is retried next run.
432        let mut d = vec![desired("a", "")];
433        let mut manifest = Manifest::new();
434        manifest.insert(
435            "a",
436            entry(
437                Some(ArtifactState {
438                    path: "a.lrc".to_string(),
439                    hash: "timed-hash".to_string(),
440                }),
441                Some(SyncedLyricsCheck {
442                    version: SYNCED_LRC_VERSION,
443                    checked_unix: 1_000,
444                    empty: false,
445                }),
446            ),
447        );
448        let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
449
450        let art = &d[0].artifacts[0];
451        assert_eq!(art.hash, "timed-hash");
452        assert_eq!(art.content, None);
453        assert!(
454            pending.is_empty(),
455            "no check recorded on failure -> retried"
456        );
457    }
458
459    #[test]
460    fn apply_drops_write_on_failure_when_nothing_on_disk() {
461        // A brand-new clip whose fetch failed: no slot to keep, so the write is
462        // dropped (retried next run) rather than written empty.
463        let mut d = vec![desired("a", "")];
464        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
465        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
466        assert!(pending.is_empty());
467    }
468
469    #[test]
470    fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
471        // The clip previously wrote an untimed body (stored slot hash); a later
472        // fetch returns alignment, so the timed body's content hash differs and
473        // reconcile will rewrite (the artifact carries the new content).
474        let mut d = vec![desired("a", "")];
475        let untimed_hash = "untimed".to_string();
476        let mut manifest = Manifest::new();
477        manifest.insert(
478            "a",
479            entry(
480                Some(ArtifactState {
481                    path: "a.lrc".to_string(),
482                    hash: untimed_hash.clone(),
483                }),
484                Some(SyncedLyricsCheck {
485                    version: SYNCED_LRC_VERSION,
486                    checked_unix: 1_000,
487                    empty: true,
488                }),
489            ),
490        );
491        let mut successes = HashMap::new();
492        successes.insert("a".to_string(), one_line_alignment());
493        apply_synced_lrc(&mut d, &manifest, &successes);
494        let art = &d[0].artifacts[0];
495        assert!(
496            art.content
497                .as_deref()
498                .unwrap()
499                .contains("[00:00.50]hi there")
500        );
501        assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
502    }
503
504    #[test]
505    fn preview_shows_write_for_targets_and_skips_resolved() {
506        let mut d = vec![desired("new", ""), desired("done", "")];
507        let mut manifest = Manifest::new();
508        manifest.insert(
509            "done",
510            entry(
511                Some(ArtifactState {
512                    path: "done.lrc".to_string(),
513                    hash: "slot-hash".to_string(),
514                }),
515                Some(SyncedLyricsCheck {
516                    version: SYNCED_LRC_VERSION,
517                    checked_unix: 1_000,
518                    empty: false,
519                }),
520            ),
521        );
522        preview_synced_lrc(&mut d, &manifest, 2_000, true);
523        // `new` keeps a pending hash (would write); `done` reuses its slot hash.
524        assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
525        assert_eq!(d[1].artifacts[0].hash, "slot-hash");
526    }
527}