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::hash::{SYNCED_LRC_VERSION, content_hash, synced_lrc_source_hash};
21use crate::lyrics::{AlignedLyrics, render_clip_lrc, render_synced_lrc};
22use crate::manifest::{Manifest, ManifestEntry};
23use crate::reconcile::Desired;
24use crate::vocab::ArtifactKind;
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::lineage::LineageContext;
233    use crate::lyrics::{AlignedLine, AlignedLineWord};
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 {
278            lines: vec![AlignedLine {
279                text: "hi there".to_owned(),
280                start_s: 0.5,
281                end_s: 1.2,
282                section: "Verse 1".to_owned(),
283                words: vec![
284                    AlignedLineWord {
285                        text: "hi".to_owned(),
286                        start_s: 0.5,
287                        end_s: 0.8,
288                    },
289                    AlignedLineWord {
290                        text: "there".to_owned(),
291                        start_s: 0.9,
292                        end_s: 1.2,
293                    },
294                ],
295            }],
296            ..Default::default()
297        }
298    }
299
300    fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
301        ManifestEntry {
302            path: "song.flac".to_string(),
303            format: AudioFormat::Flac,
304            lrc,
305            synced_lyrics: check,
306            ..Default::default()
307        }
308    }
309
310    #[test]
311    fn targets_empty_when_feature_off() {
312        let d = vec![desired("a", "")];
313        let manifest = Manifest::new();
314        assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
315    }
316
317    #[test]
318    fn targets_new_clip_but_not_a_recently_resolved_one() {
319        let d = vec![desired("new", ""), desired("done", "")];
320        let mut manifest = Manifest::new();
321        // `done` was timed-resolved at the current version; `new` is unseen.
322        manifest.insert(
323            "done",
324            entry(
325                Some(ArtifactState {
326                    path: "done.lrc".to_string(),
327                    hash: "h".to_string(),
328                }),
329                Some(SyncedLyricsCheck {
330                    version: SYNCED_LRC_VERSION,
331                    checked_unix: 1_000,
332                    empty: false,
333                    timed: true,
334                }),
335            ),
336        );
337        let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
338        assert!(targets.contains("new"));
339        assert!(!targets.contains("done"));
340    }
341
342    #[test]
343    fn instrumental_is_rechecked_only_after_the_window() {
344        let d = vec![desired("instr", "")];
345        let mut manifest = Manifest::new();
346        manifest.insert(
347            "instr",
348            entry(
349                None,
350                Some(SyncedLyricsCheck {
351                    version: SYNCED_LRC_VERSION,
352                    checked_unix: 1_000,
353                    empty: true,
354                    timed: false,
355                }),
356            ),
357        );
358        // Within the window: not re-fetched (this is the fix for forever-refetch).
359        let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
360        assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
361        // Past the window: re-checked, to pick up late alignment.
362        let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
363        assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
364    }
365
366    #[test]
367    fn untimed_fallback_is_rechecked_after_the_window() {
368        // A clip that previously resolved to an untimed fallback (empty alignment
369        // but non-empty lyrics) must be re-checked after the window so a later
370        // Suno alignment upgrades it to a timed `.lrc`.
371        let d = vec![desired("a", "some lyrics")];
372        let mut manifest = Manifest::new();
373        manifest.insert(
374            "a",
375            entry(
376                Some(ArtifactState {
377                    path: "a.lrc".to_string(),
378                    hash: "untimed-hash".to_string(),
379                }),
380                Some(SyncedLyricsCheck {
381                    version: SYNCED_LRC_VERSION,
382                    checked_unix: 1_000,
383                    empty: false,
384                    timed: false,
385                }),
386            ),
387        );
388        // Within the window: no re-fetch (avoids churn on every run).
389        let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
390        assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
391        // Past the window: re-checked, to upgrade to timed if alignment arrived.
392        let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
393        assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("a"));
394    }
395
396    #[test]
397    fn timed_clip_is_not_rechecked_without_rename() {
398        // A timed clip must not be re-fetched just because the window elapsed;
399        // only a rename (path drift) or missing slot should trigger a re-fetch.
400        let d = vec![desired("a", "")];
401        let mut manifest = Manifest::new();
402        manifest.insert(
403            "a",
404            entry(
405                Some(ArtifactState {
406                    path: "a.lrc".to_string(),
407                    hash: "h".to_string(),
408                }),
409                Some(SyncedLyricsCheck {
410                    version: SYNCED_LRC_VERSION,
411                    checked_unix: 0, // maximally stale
412                    empty: false,
413                    timed: true,
414                }),
415            ),
416        );
417        // Even long after the window: still not re-fetched.
418        let very_late = 2 * SYNCED_LRC_RECHECK_SECS;
419        assert!(synced_lyrics_targets(&d, &manifest, very_late, true).is_empty());
420    }
421
422    #[test]
423    fn version_bump_refetches_everything() {
424        let d = vec![desired("done", "")];
425        let mut manifest = Manifest::new();
426        manifest.insert(
427            "done",
428            entry(
429                Some(ArtifactState {
430                    path: "done.lrc".to_string(),
431                    hash: "h".to_string(),
432                }),
433                Some(SyncedLyricsCheck {
434                    version: SYNCED_LRC_VERSION + 1, // resolved at a different version
435                    checked_unix: 1_000,
436                    empty: false,
437                    timed: true,
438                }),
439            ),
440        );
441        assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
442    }
443
444    #[test]
445    fn rename_refetches_a_written_clip() {
446        let mut d = vec![desired("a", "")];
447        // The audio (and so the `.lrc`) moved to a new path.
448        d[0].artifacts[0].path = "new/a.lrc".to_string();
449        let mut manifest = Manifest::new();
450        manifest.insert(
451            "a",
452            entry(
453                Some(ArtifactState {
454                    path: "old/a.lrc".to_string(),
455                    hash: "h".to_string(),
456                }),
457                Some(SyncedLyricsCheck {
458                    version: SYNCED_LRC_VERSION,
459                    checked_unix: 1_000,
460                    empty: false,
461                    timed: true,
462                }),
463            ),
464        );
465        assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
466    }
467
468    #[test]
469    fn apply_sets_timed_body_and_content_hash() {
470        let mut d = vec![desired("a", "")];
471        let mut successes = HashMap::new();
472        successes.insert("a".to_string(), one_line_alignment());
473        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
474
475        let art = &d[0].artifacts[0];
476        let body = art.content.as_deref().unwrap();
477        assert!(body.contains("[00:00.50]hi there"));
478        assert_eq!(art.hash, content_hash(body));
479        assert_eq!(
480            pending,
481            vec![PendingCheck {
482                clip_id: "a".to_string(),
483                empty: false,
484                timed: true,
485                body_hash: Some(content_hash(body)),
486            }]
487        );
488    }
489
490    #[test]
491    fn apply_untimed_fallback_marks_not_timed() {
492        // When Suno returns empty alignment but the clip has lyrics, the untimed
493        // plain-text fallback is written but `timed` is false so the check is
494        // subject to the periodic re-check window.
495        let mut d = vec![desired("a", "some lyrics")];
496        let mut successes = HashMap::new();
497        successes.insert("a".to_string(), AlignedLyrics::default());
498        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
499
500        let art = &d[0].artifacts[0];
501        assert!(art.content.is_some(), "untimed body written");
502        let check = &pending[0];
503        assert!(!check.empty, "clip has lyrics, not an instrumental");
504        assert!(!check.timed, "alignment was empty -> untimed fallback");
505    }
506
507    #[test]
508    fn apply_drops_instrumental_and_marks_empty() {
509        let mut d = vec![desired("instr", "")];
510        let mut successes = HashMap::new();
511        successes.insert("instr".to_string(), AlignedLyrics::default());
512        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
513
514        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
515        assert_eq!(
516            pending,
517            vec![PendingCheck {
518                clip_id: "instr".to_string(),
519                empty: true,
520                timed: false,
521                body_hash: None,
522            }]
523        );
524    }
525
526    #[test]
527    fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
528        // The clip has an existing timed `.lrc` (slot present) but its fetch
529        // failed this run (absent from successes). The artifact is reset to the
530        // stored slot hash with no content, so reconcile skips it — the good
531        // timed file is neither rewritten nor downgraded — and no check is
532        // recorded, so it is retried next run.
533        let mut d = vec![desired("a", "")];
534        let mut manifest = Manifest::new();
535        manifest.insert(
536            "a",
537            entry(
538                Some(ArtifactState {
539                    path: "a.lrc".to_string(),
540                    hash: "timed-hash".to_string(),
541                }),
542                Some(SyncedLyricsCheck {
543                    version: SYNCED_LRC_VERSION,
544                    checked_unix: 1_000,
545                    empty: false,
546                    timed: true,
547                }),
548            ),
549        );
550        let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
551
552        let art = &d[0].artifacts[0];
553        assert_eq!(art.hash, "timed-hash");
554        assert_eq!(art.content, None);
555        assert!(
556            pending.is_empty(),
557            "no check recorded on failure -> retried"
558        );
559    }
560
561    #[test]
562    fn apply_drops_write_on_failure_when_nothing_on_disk() {
563        // A brand-new clip whose fetch failed: no slot to keep, so the write is
564        // dropped (retried next run) rather than written empty.
565        let mut d = vec![desired("a", "")];
566        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
567        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
568        assert!(pending.is_empty());
569    }
570
571    #[test]
572    fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
573        // The clip previously resolved to an untimed fallback (empty alignment,
574        // body written, timed: false). A re-check now returns alignment, so the
575        // timed body's content hash differs and reconcile will rewrite.
576        let mut d = vec![desired("a", "some lyrics")];
577        let untimed_hash = "untimed".to_string();
578        let mut manifest = Manifest::new();
579        manifest.insert(
580            "a",
581            entry(
582                Some(ArtifactState {
583                    path: "a.lrc".to_string(),
584                    hash: untimed_hash.clone(),
585                }),
586                Some(SyncedLyricsCheck {
587                    version: SYNCED_LRC_VERSION,
588                    checked_unix: 1_000,
589                    empty: false,
590                    timed: false,
591                }),
592            ),
593        );
594        let mut successes = HashMap::new();
595        successes.insert("a".to_string(), one_line_alignment());
596        let pending = apply_synced_lrc(&mut d, &manifest, &successes);
597        let art = &d[0].artifacts[0];
598        assert!(
599            art.content
600                .as_deref()
601                .unwrap()
602                .contains("[00:00.50]hi there")
603        );
604        assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
605        assert!(pending[0].timed, "upgraded to timed");
606    }
607
608    #[test]
609    fn preview_shows_write_for_targets_and_skips_resolved() {
610        let mut d = vec![desired("new", ""), desired("done", "")];
611        let mut manifest = Manifest::new();
612        manifest.insert(
613            "done",
614            entry(
615                Some(ArtifactState {
616                    path: "done.lrc".to_string(),
617                    hash: "slot-hash".to_string(),
618                }),
619                Some(SyncedLyricsCheck {
620                    version: SYNCED_LRC_VERSION,
621                    checked_unix: 1_000,
622                    empty: false,
623                    timed: true,
624                }),
625            ),
626        );
627        preview_synced_lrc(&mut d, &manifest, 2_000, true);
628        // `new` keeps a pending hash (would write); `done` reuses its slot hash.
629        assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
630        assert_eq!(d[1].artifacts[0].hash, "slot-hash");
631    }
632}