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