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