Skip to main content

suno_core/
vocab.rs

1//! Shared vocabulary: the small, dependency-free types spoken across the crate.
2//!
3//! These enums and settings are named by many modules (`config`, `reconcile`,
4//! `ffmpeg`, `executor`, `desired`, ...). Housing them in one leaf module keeps
5//! them out of any heavy engine module, so naming a format or a source mode
6//! never drags a dependency on the planner or the transcoder. The module depends
7//! only on [`crate::error`] (for the `FromStr` impls) and so sits at the bottom
8//! of the dependency graph.
9
10use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Error, Result};
16
17/// How a selected source treats its clips: mirror with deletion, or additive copy.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(rename_all = "lowercase")]
21pub enum SourceMode {
22    /// Mirror the source, deleting local files that leave it (rclone `sync`).
23    Mirror,
24    /// Copy additively; never delete (rclone `copy`).
25    Copy,
26}
27
28/// The class of an external sidecar artifact a clip (or album/library) owns.
29///
30/// The reconcile engine keeps a single pair of artifact actions
31/// (`Action::WriteArtifact` / `Action::DeleteArtifact`) rather than one variant
32/// per class; the `kind` distinguishes them so the executor and the manifest can
33/// route each to the right slot. Per-clip classes
34/// ([`CoverJpg`](ArtifactKind::CoverJpg), [`CoverWebp`](ArtifactKind::CoverWebp),
35/// [`DetailsTxt`](ArtifactKind::DetailsTxt), [`LyricsTxt`](ArtifactKind::LyricsTxt),
36/// [`Lrc`](ArtifactKind::Lrc), and [`VideoMp4`](ArtifactKind::VideoMp4)) map to
37/// a manifest entry field; the album/library classes are reconciled by later
38/// phases and have no per-clip manifest slot yet.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub enum ArtifactKind {
41    /// The per-song external cover, sourced from `image_large_url`.
42    CoverJpg,
43    /// Retired: the per-song animated cover is now embedded in the audio, not
44    /// written as a `<track>.webp` sidecar. The kind is kept only so a `.webp`
45    /// from an older version stays tracked and is cleaned up (delete-eligible;
46    /// see `removed_kind_delete_eligible` in `reconcile`); it is never emitted
47    /// into a new desired set.
48    CoverWebp,
49    /// The per-song plain-text details dump (generated, inline content).
50    DetailsTxt,
51    /// The per-song plain-text lyrics file (generated, inline content).
52    LyricsTxt,
53    /// The per-song untimed `.lrc` lyrics file (generated, inline content).
54    Lrc,
55    /// The per-song standalone music video, fetched from `video_url` (off by
56    /// default). A large binary, removed only alongside its own audio.
57    VideoMp4,
58    /// The album folder's static cover (album-scoped, later phase).
59    FolderJpg,
60    /// The album folder's animated cover (album-scoped, later phase).
61    FolderWebp,
62    /// The album folder's raw animated cover: the same `video_cover_url` as
63    /// [`FolderWebp`](ArtifactKind::FolderWebp), kept verbatim with no transcode
64    /// (album-scoped, later phase).
65    FolderMp4,
66    /// A library-root `.m3u8` playlist (library-scoped, later phase).
67    Playlist,
68}
69
70impl ArtifactKind {
71    /// The fixed file-name suffix a per-clip sidecar of this kind appends to the
72    /// song's extensionless base (`{base}{suffix}`). `None` for the album/library
73    /// classes, whose paths are not song-base derived. The extension is fixed and
74    /// config-independent (only the base name is sanitised, never the extension),
75    /// so this is the single home for it, consumed by both `desired`'s path
76    /// construction and reconcile's stranded-sidecar relocation (#355).
77    pub(crate) fn sidecar_suffix(self) -> Option<&'static str> {
78        Some(match self {
79            Self::CoverJpg => ".jpg",
80            Self::CoverWebp => ".webp",
81            Self::DetailsTxt => ".details.txt",
82            Self::LyricsTxt => ".lyrics.txt",
83            Self::Lrc => ".lrc",
84            Self::VideoMp4 => ".mp4",
85            Self::FolderJpg | Self::FolderWebp | Self::FolderMp4 | Self::Playlist => {
86                return None;
87            }
88        })
89    }
90
91    /// Whether this kind is a per-clip sidecar (recorded on a
92    /// [`ManifestEntry`](crate::manifest::ManifestEntry) and reconciled per
93    /// clip) rather than an album/library class owned by a later phase.
94    ///
95    /// A per-clip kind is exactly one whose path is the song base plus a fixed
96    /// suffix, so membership is derived from [`sidecar_suffix`](Self::sidecar_suffix)
97    /// and never drifts from it: adding a sidecar kind's suffix opts it in here
98    /// automatically.
99    pub(crate) fn is_per_clip(self) -> bool {
100        self.sidecar_suffix().is_some()
101    }
102}
103
104/// Audio format for downloaded clips.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107#[serde(rename_all = "lowercase")]
108pub enum AudioFormat {
109    Mp3,
110    #[default]
111    Flac,
112    Wav,
113    Alac,
114}
115
116impl AudioFormat {
117    /// The on-disk file extension for a clip in this format. Kept separate from
118    /// the [`Display`](fmt::Display) token so a codec's container extension need
119    /// not match its config name.
120    pub fn ext(self) -> &'static str {
121        match self {
122            Self::Mp3 => "mp3",
123            Self::Flac => "flac",
124            Self::Wav => "wav",
125            Self::Alac => "m4a",
126        }
127    }
128
129    /// Whether an animated WebP can be embedded as this format's front cover.
130    ///
131    /// FLAC, MP3, and WAV embed an `image/webp` picture; ALAC (`mp4ameta` `covr`)
132    /// supports only JPEG/PNG/BMP artwork, so it always embeds the static JPEG.
133    pub fn embeds_animated_cover(self) -> bool {
134        !matches!(self, Self::Alac)
135    }
136}
137
138impl FromStr for AudioFormat {
139    type Err = Error;
140
141    // Case-sensitive to match serde (TOML) and the published JSON schema, which
142    // accept lowercase only. The env tiers parse through here, so `SUNO_FORMAT`
143    // rejects `FLAC` exactly as `format = "FLAC"` does in the file.
144    fn from_str(s: &str) -> Result<Self> {
145        match s {
146            "mp3" => Ok(Self::Mp3),
147            "flac" => Ok(Self::Flac),
148            "wav" => Ok(Self::Wav),
149            "alac" => Ok(Self::Alac),
150            other => Err(Error::Config(format!("unknown format '{other}'"))),
151        }
152    }
153}
154
155impl fmt::Display for AudioFormat {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self {
158            Self::Mp3 => f.write_str("mp3"),
159            Self::Flac => f.write_str("flac"),
160            Self::Wav => f.write_str("wav"),
161            Self::Alac => f.write_str("alac"),
162        }
163    }
164}
165
166/// Container format for a downloaded stem.
167///
168/// Stems are stored RAW in their native container and are never transcoded, so
169/// unlike [`AudioFormat`] there is no lossless-from-lossy render: WAV comes
170/// straight from Suno's free `convert_wav` endpoint and MP3 straight from the
171/// public CDN. FLAC is deliberately unrepresentable — a stem is never
172/// re-encoded to FLAC, even when the song's own format is FLAC.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175#[serde(rename_all = "lowercase")]
176pub enum StemFormat {
177    /// Lossless WAV via the free `convert_wav` render, stored as delivered.
178    #[default]
179    Wav,
180    /// The public CDN MP3, stored as delivered.
181    Mp3,
182}
183
184impl StemFormat {
185    /// The file extension for a stem stored in this format.
186    pub fn ext(self) -> &'static str {
187        match self {
188            Self::Wav => "wav",
189            Self::Mp3 => "mp3",
190        }
191    }
192}
193
194impl FromStr for StemFormat {
195    type Err = Error;
196
197    // Case-sensitive to match serde (TOML) and the JSON schema; see
198    // [`AudioFormat::from_str`].
199    fn from_str(s: &str) -> Result<Self> {
200        match s {
201            "wav" => Ok(Self::Wav),
202            "mp3" => Ok(Self::Mp3),
203            "flac" => Err(Error::Config(
204                "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
205            )),
206            other => Err(Error::Config(format!("unknown stem format '{other}'"))),
207        }
208    }
209}
210
211impl fmt::Display for StemFormat {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        f.write_str(self.ext())
214    }
215}
216
217/// Which video-cover artifacts to retain.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
220#[serde(rename_all = "lowercase")]
221pub enum VideoCoverRetention {
222    #[default]
223    Neither,
224    Webp,
225    Mp4,
226    Both,
227}
228
229impl VideoCoverRetention {
230    pub fn keeps_webp(self) -> bool {
231        matches!(self, Self::Webp | Self::Both)
232    }
233
234    pub fn keeps_mp4(self) -> bool {
235        matches!(self, Self::Mp4 | Self::Both)
236    }
237}
238
239impl FromStr for VideoCoverRetention {
240    type Err = Error;
241
242    // Case-sensitive to match serde (TOML) and the JSON schema; see
243    // [`AudioFormat::from_str`].
244    fn from_str(s: &str) -> Result<Self> {
245        match s {
246            "neither" => Ok(Self::Neither),
247            "webp" => Ok(Self::Webp),
248            "mp4" => Ok(Self::Mp4),
249            "both" => Ok(Self::Both),
250            other => Err(Error::Config(format!(
251                "unknown video_cover_retention '{other}'"
252            ))),
253        }
254    }
255}
256
257impl fmt::Display for VideoCoverRetention {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            Self::Neither => f.write_str("neither"),
261            Self::Webp => f.write_str("webp"),
262            Self::Mp4 => f.write_str("mp4"),
263            Self::Both => f.write_str("both"),
264        }
265    }
266}
267
268/// Encoder settings for the animated WebP cover derived from a clip's MP4
269/// preview.
270///
271/// The animated WebP is embedded as the audio file's front-cover picture, and a
272/// FLAC PICTURE block cannot exceed ~16 MiB (its length is a 24-bit field). The
273/// [`Default`] is therefore a bounded lossy profile that reliably fits: quality
274/// 90 at effort (`compression_level`) 4, scaled to at most 640 px wide. Effort
275/// is capped at 4 because 6 only matches its size for many times the encode
276/// time, and lossless is opt-in and far larger, so it fits only the roomier
277/// MP3/ALAC containers, never FLAC.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub struct WebpEncodeSettings {
280    /// Lossy encoder quality, 0-100 (higher is better and larger). Ignored when
281    /// `lossless` is set.
282    pub quality: u8,
283    /// Cap on the output frame rate; a faster source is downsampled to this.
284    pub max_fps: u32,
285    /// Optional cap on the output width in pixels: `Some(w)` scales a wider
286    /// source down keeping its aspect ratio (never upscaling), while `None`
287    /// keeps the source resolution.
288    pub max_width: Option<u32>,
289    /// Encode losslessly. Off by default: lossless animated WebP of real video
290    /// is intrinsically huge (roughly 30x the lossy source) with no visible
291    /// gain over quality 95 for a cover.
292    pub lossless: bool,
293    /// Encoder effort, 0-4 (higher is smaller and slower). Capped at 4 because
294    /// effort 6 yields the same size for many times the encode time.
295    pub compression_level: u8,
296}
297
298impl Default for WebpEncodeSettings {
299    fn default() -> Self {
300        Self {
301            quality: 90,
302            max_fps: 24,
303            max_width: Some(640),
304            lossless: false,
305            compression_level: 4,
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn audio_format_parses_lowercase_only() {
316        assert_eq!("flac".parse::<AudioFormat>().unwrap(), AudioFormat::Flac);
317        assert_eq!("mp3".parse::<AudioFormat>().unwrap(), AudioFormat::Mp3);
318        assert_eq!("wav".parse::<AudioFormat>().unwrap(), AudioFormat::Wav);
319        assert_eq!("alac".parse::<AudioFormat>().unwrap(), AudioFormat::Alac);
320        // Case-sensitive to match serde (TOML) and the JSON schema.
321        assert!("FLAC".parse::<AudioFormat>().is_err());
322        assert!("Mp3".parse::<AudioFormat>().is_err());
323    }
324
325    #[test]
326    fn audio_format_rejects_unknown_without_panicking() {
327        assert!(matches!(
328            "ogg".parse::<AudioFormat>().unwrap_err(),
329            Error::Config(_)
330        ));
331    }
332
333    #[test]
334    fn audio_format_default_is_flac() {
335        assert_eq!(AudioFormat::default(), AudioFormat::Flac);
336    }
337
338    #[test]
339    fn sidecar_suffix_maps_each_per_clip_kind() {
340        assert_eq!(ArtifactKind::CoverJpg.sidecar_suffix(), Some(".jpg"));
341        assert_eq!(ArtifactKind::CoverWebp.sidecar_suffix(), Some(".webp"));
342        assert_eq!(
343            ArtifactKind::DetailsTxt.sidecar_suffix(),
344            Some(".details.txt")
345        );
346        assert_eq!(
347            ArtifactKind::LyricsTxt.sidecar_suffix(),
348            Some(".lyrics.txt")
349        );
350        assert_eq!(ArtifactKind::Lrc.sidecar_suffix(), Some(".lrc"));
351        assert_eq!(ArtifactKind::VideoMp4.sidecar_suffix(), Some(".mp4"));
352    }
353
354    #[test]
355    fn sidecar_suffix_is_none_for_album_and_library_kinds() {
356        assert_eq!(ArtifactKind::FolderJpg.sidecar_suffix(), None);
357        assert_eq!(ArtifactKind::FolderWebp.sidecar_suffix(), None);
358        assert_eq!(ArtifactKind::FolderMp4.sidecar_suffix(), None);
359        assert_eq!(ArtifactKind::Playlist.sidecar_suffix(), None);
360    }
361
362    #[test]
363    fn is_per_clip_matches_sidecar_suffix_set() {
364        for kind in [
365            ArtifactKind::CoverJpg,
366            ArtifactKind::CoverWebp,
367            ArtifactKind::DetailsTxt,
368            ArtifactKind::LyricsTxt,
369            ArtifactKind::Lrc,
370            ArtifactKind::VideoMp4,
371        ] {
372            assert!(kind.is_per_clip(), "{kind:?} is a per-clip sidecar");
373            assert_eq!(kind.is_per_clip(), kind.sidecar_suffix().is_some());
374        }
375        for kind in [
376            ArtifactKind::FolderJpg,
377            ArtifactKind::FolderWebp,
378            ArtifactKind::FolderMp4,
379            ArtifactKind::Playlist,
380        ] {
381            assert!(!kind.is_per_clip(), "{kind:?} is album/library-scoped");
382            assert_eq!(kind.is_per_clip(), kind.sidecar_suffix().is_some());
383        }
384    }
385
386    #[test]
387    fn audio_format_ext_differs_from_display_for_alac() {
388        assert_eq!(AudioFormat::Alac.ext(), "m4a");
389        assert_eq!(AudioFormat::Alac.to_string(), "alac");
390    }
391
392    #[test]
393    fn audio_format_display_round_trips_through_from_str() {
394        for f in [
395            AudioFormat::Mp3,
396            AudioFormat::Flac,
397            AudioFormat::Wav,
398            AudioFormat::Alac,
399        ] {
400            assert_eq!(f.to_string().parse::<AudioFormat>().unwrap(), f);
401        }
402    }
403
404    #[test]
405    fn audio_format_embeds_animated_cover_except_alac() {
406        assert!(AudioFormat::Flac.embeds_animated_cover());
407        assert!(AudioFormat::Mp3.embeds_animated_cover());
408        assert!(AudioFormat::Wav.embeds_animated_cover());
409        assert!(!AudioFormat::Alac.embeds_animated_cover());
410    }
411
412    #[test]
413    fn stem_format_parses_wav_and_mp3() {
414        assert_eq!("wav".parse::<StemFormat>().unwrap(), StemFormat::Wav);
415        assert_eq!("mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
416        // Case-sensitive to match serde (TOML) and the JSON schema.
417        assert!("WAV".parse::<StemFormat>().is_err());
418    }
419
420    #[test]
421    fn stem_format_rejects_flac_with_guidance() {
422        match "flac".parse::<StemFormat>().unwrap_err() {
423            Error::Config(msg) => assert!(msg.contains("FLAC")),
424            other => panic!("expected Config error, got {other:?}"),
425        }
426    }
427
428    #[test]
429    fn stem_format_rejects_unknown_without_panicking() {
430        assert!(matches!(
431            "ogg".parse::<StemFormat>().unwrap_err(),
432            Error::Config(_)
433        ));
434    }
435
436    #[test]
437    fn stem_format_default_is_wav_and_display_matches_ext() {
438        assert_eq!(StemFormat::default(), StemFormat::Wav);
439        assert_eq!(StemFormat::Mp3.to_string(), StemFormat::Mp3.ext());
440    }
441
442    #[test]
443    fn video_cover_retention_parses_all_variants() {
444        assert_eq!(
445            "neither".parse::<VideoCoverRetention>().unwrap(),
446            VideoCoverRetention::Neither
447        );
448        assert_eq!(
449            "webp".parse::<VideoCoverRetention>().unwrap(),
450            VideoCoverRetention::Webp
451        );
452        assert_eq!(
453            "mp4".parse::<VideoCoverRetention>().unwrap(),
454            VideoCoverRetention::Mp4
455        );
456        assert_eq!(
457            "both".parse::<VideoCoverRetention>().unwrap(),
458            VideoCoverRetention::Both
459        );
460        // Case-sensitive to match serde (TOML) and the JSON schema.
461        assert!("WEBP".parse::<VideoCoverRetention>().is_err());
462    }
463
464    #[test]
465    fn video_cover_retention_rejects_unknown_without_panicking() {
466        assert!(matches!(
467            "all".parse::<VideoCoverRetention>().unwrap_err(),
468            Error::Config(_)
469        ));
470    }
471
472    #[test]
473    fn video_cover_retention_keeps_matrix() {
474        assert!(!VideoCoverRetention::Neither.keeps_webp());
475        assert!(!VideoCoverRetention::Neither.keeps_mp4());
476        assert!(VideoCoverRetention::Webp.keeps_webp());
477        assert!(!VideoCoverRetention::Webp.keeps_mp4());
478        assert!(!VideoCoverRetention::Mp4.keeps_webp());
479        assert!(VideoCoverRetention::Mp4.keeps_mp4());
480        assert!(VideoCoverRetention::Both.keeps_webp());
481        assert!(VideoCoverRetention::Both.keeps_mp4());
482    }
483
484    #[test]
485    fn video_cover_retention_default_is_neither() {
486        assert_eq!(VideoCoverRetention::default(), VideoCoverRetention::Neither);
487    }
488
489    #[test]
490    fn webp_defaults_fit_the_flac_picture_ceiling() {
491        let d = WebpEncodeSettings::default();
492        assert_eq!(d.quality, 90);
493        assert_eq!(d.max_fps, 24);
494        assert_eq!(d.max_width, Some(640));
495        assert!(!d.lossless);
496        assert_eq!(d.compression_level, 4);
497    }
498}