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
92/// Audio format for downloaded clips.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[serde(rename_all = "lowercase")]
96pub enum AudioFormat {
97    Mp3,
98    #[default]
99    Flac,
100    Wav,
101    Alac,
102}
103
104impl AudioFormat {
105    /// The on-disk file extension for a clip in this format. Kept separate from
106    /// the [`Display`](fmt::Display) token so a codec's container extension need
107    /// not match its config name.
108    pub fn ext(self) -> &'static str {
109        match self {
110            Self::Mp3 => "mp3",
111            Self::Flac => "flac",
112            Self::Wav => "wav",
113            Self::Alac => "m4a",
114        }
115    }
116
117    /// Whether an animated WebP can be embedded as this format's front cover.
118    ///
119    /// FLAC, MP3, and WAV embed an `image/webp` picture; ALAC (`mp4ameta` `covr`)
120    /// supports only JPEG/PNG/BMP artwork, so it always embeds the static JPEG.
121    pub fn embeds_animated_cover(self) -> bool {
122        !matches!(self, Self::Alac)
123    }
124}
125
126impl FromStr for AudioFormat {
127    type Err = Error;
128
129    // Case-sensitive to match serde (TOML) and the published JSON schema, which
130    // accept lowercase only. The env tiers parse through here, so `SUNO_FORMAT`
131    // rejects `FLAC` exactly as `format = "FLAC"` does in the file.
132    fn from_str(s: &str) -> Result<Self> {
133        match s {
134            "mp3" => Ok(Self::Mp3),
135            "flac" => Ok(Self::Flac),
136            "wav" => Ok(Self::Wav),
137            "alac" => Ok(Self::Alac),
138            other => Err(Error::Config(format!("unknown format '{other}'"))),
139        }
140    }
141}
142
143impl fmt::Display for AudioFormat {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Self::Mp3 => f.write_str("mp3"),
147            Self::Flac => f.write_str("flac"),
148            Self::Wav => f.write_str("wav"),
149            Self::Alac => f.write_str("alac"),
150        }
151    }
152}
153
154/// Container format for a downloaded stem.
155///
156/// Stems are stored RAW in their native container and are never transcoded, so
157/// unlike [`AudioFormat`] there is no lossless-from-lossy render: WAV comes
158/// straight from Suno's free `convert_wav` endpoint and MP3 straight from the
159/// public CDN. FLAC is deliberately unrepresentable — a stem is never
160/// re-encoded to FLAC, even when the song's own format is FLAC.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163#[serde(rename_all = "lowercase")]
164pub enum StemFormat {
165    /// Lossless WAV via the free `convert_wav` render, stored as delivered.
166    #[default]
167    Wav,
168    /// The public CDN MP3, stored as delivered.
169    Mp3,
170}
171
172impl StemFormat {
173    /// The file extension for a stem stored in this format.
174    pub fn ext(self) -> &'static str {
175        match self {
176            Self::Wav => "wav",
177            Self::Mp3 => "mp3",
178        }
179    }
180}
181
182impl FromStr for StemFormat {
183    type Err = Error;
184
185    // Case-sensitive to match serde (TOML) and the JSON schema; see
186    // [`AudioFormat::from_str`].
187    fn from_str(s: &str) -> Result<Self> {
188        match s {
189            "wav" => Ok(Self::Wav),
190            "mp3" => Ok(Self::Mp3),
191            "flac" => Err(Error::Config(
192                "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
193            )),
194            other => Err(Error::Config(format!("unknown stem format '{other}'"))),
195        }
196    }
197}
198
199impl fmt::Display for StemFormat {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        f.write_str(self.ext())
202    }
203}
204
205/// Which video-cover artifacts to retain.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208#[serde(rename_all = "lowercase")]
209pub enum VideoCoverRetention {
210    #[default]
211    Neither,
212    Webp,
213    Mp4,
214    Both,
215}
216
217impl VideoCoverRetention {
218    pub fn keeps_webp(self) -> bool {
219        matches!(self, Self::Webp | Self::Both)
220    }
221
222    pub fn keeps_mp4(self) -> bool {
223        matches!(self, Self::Mp4 | Self::Both)
224    }
225}
226
227impl FromStr for VideoCoverRetention {
228    type Err = Error;
229
230    // Case-sensitive to match serde (TOML) and the JSON schema; see
231    // [`AudioFormat::from_str`].
232    fn from_str(s: &str) -> Result<Self> {
233        match s {
234            "neither" => Ok(Self::Neither),
235            "webp" => Ok(Self::Webp),
236            "mp4" => Ok(Self::Mp4),
237            "both" => Ok(Self::Both),
238            other => Err(Error::Config(format!(
239                "unknown video_cover_retention '{other}'"
240            ))),
241        }
242    }
243}
244
245impl fmt::Display for VideoCoverRetention {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        match self {
248            Self::Neither => f.write_str("neither"),
249            Self::Webp => f.write_str("webp"),
250            Self::Mp4 => f.write_str("mp4"),
251            Self::Both => f.write_str("both"),
252        }
253    }
254}
255
256/// Encoder settings for the animated WebP cover derived from a clip's MP4
257/// preview.
258///
259/// The animated WebP is embedded as the audio file's front-cover picture, and a
260/// FLAC PICTURE block cannot exceed ~16 MiB (its length is a 24-bit field). The
261/// [`Default`] is therefore a bounded lossy profile that reliably fits: quality
262/// 90 at effort (`compression_level`) 4, scaled to at most 640 px wide. Effort
263/// is capped at 4 because 6 only matches its size for many times the encode
264/// time, and lossless is opt-in and far larger, so it fits only the roomier
265/// MP3/ALAC containers, never FLAC.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub struct WebpEncodeSettings {
268    /// Lossy encoder quality, 0-100 (higher is better and larger). Ignored when
269    /// `lossless` is set.
270    pub quality: u8,
271    /// Cap on the output frame rate; a faster source is downsampled to this.
272    pub max_fps: u32,
273    /// Optional cap on the output width in pixels: `Some(w)` scales a wider
274    /// source down keeping its aspect ratio (never upscaling), while `None`
275    /// keeps the source resolution.
276    pub max_width: Option<u32>,
277    /// Encode losslessly. Off by default: lossless animated WebP of real video
278    /// is intrinsically huge (roughly 30x the lossy source) with no visible
279    /// gain over quality 95 for a cover.
280    pub lossless: bool,
281    /// Encoder effort, 0-4 (higher is smaller and slower). Capped at 4 because
282    /// effort 6 yields the same size for many times the encode time.
283    pub compression_level: u8,
284}
285
286impl Default for WebpEncodeSettings {
287    fn default() -> Self {
288        Self {
289            quality: 90,
290            max_fps: 24,
291            max_width: Some(640),
292            lossless: false,
293            compression_level: 4,
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn audio_format_parses_lowercase_only() {
304        assert_eq!("flac".parse::<AudioFormat>().unwrap(), AudioFormat::Flac);
305        assert_eq!("mp3".parse::<AudioFormat>().unwrap(), AudioFormat::Mp3);
306        assert_eq!("wav".parse::<AudioFormat>().unwrap(), AudioFormat::Wav);
307        assert_eq!("alac".parse::<AudioFormat>().unwrap(), AudioFormat::Alac);
308        // Case-sensitive to match serde (TOML) and the JSON schema.
309        assert!("FLAC".parse::<AudioFormat>().is_err());
310        assert!("Mp3".parse::<AudioFormat>().is_err());
311    }
312
313    #[test]
314    fn audio_format_rejects_unknown_without_panicking() {
315        assert!(matches!(
316            "ogg".parse::<AudioFormat>().unwrap_err(),
317            Error::Config(_)
318        ));
319    }
320
321    #[test]
322    fn audio_format_default_is_flac() {
323        assert_eq!(AudioFormat::default(), AudioFormat::Flac);
324    }
325
326    #[test]
327    fn sidecar_suffix_maps_each_per_clip_kind() {
328        assert_eq!(ArtifactKind::CoverJpg.sidecar_suffix(), Some(".jpg"));
329        assert_eq!(ArtifactKind::CoverWebp.sidecar_suffix(), Some(".webp"));
330        assert_eq!(
331            ArtifactKind::DetailsTxt.sidecar_suffix(),
332            Some(".details.txt")
333        );
334        assert_eq!(
335            ArtifactKind::LyricsTxt.sidecar_suffix(),
336            Some(".lyrics.txt")
337        );
338        assert_eq!(ArtifactKind::Lrc.sidecar_suffix(), Some(".lrc"));
339        assert_eq!(ArtifactKind::VideoMp4.sidecar_suffix(), Some(".mp4"));
340    }
341
342    #[test]
343    fn sidecar_suffix_is_none_for_album_and_library_kinds() {
344        assert_eq!(ArtifactKind::FolderJpg.sidecar_suffix(), None);
345        assert_eq!(ArtifactKind::FolderWebp.sidecar_suffix(), None);
346        assert_eq!(ArtifactKind::FolderMp4.sidecar_suffix(), None);
347        assert_eq!(ArtifactKind::Playlist.sidecar_suffix(), None);
348    }
349
350    #[test]
351    fn audio_format_ext_differs_from_display_for_alac() {
352        assert_eq!(AudioFormat::Alac.ext(), "m4a");
353        assert_eq!(AudioFormat::Alac.to_string(), "alac");
354    }
355
356    #[test]
357    fn audio_format_display_round_trips_through_from_str() {
358        for f in [
359            AudioFormat::Mp3,
360            AudioFormat::Flac,
361            AudioFormat::Wav,
362            AudioFormat::Alac,
363        ] {
364            assert_eq!(f.to_string().parse::<AudioFormat>().unwrap(), f);
365        }
366    }
367
368    #[test]
369    fn audio_format_embeds_animated_cover_except_alac() {
370        assert!(AudioFormat::Flac.embeds_animated_cover());
371        assert!(AudioFormat::Mp3.embeds_animated_cover());
372        assert!(AudioFormat::Wav.embeds_animated_cover());
373        assert!(!AudioFormat::Alac.embeds_animated_cover());
374    }
375
376    #[test]
377    fn stem_format_parses_wav_and_mp3() {
378        assert_eq!("wav".parse::<StemFormat>().unwrap(), StemFormat::Wav);
379        assert_eq!("mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
380        // Case-sensitive to match serde (TOML) and the JSON schema.
381        assert!("WAV".parse::<StemFormat>().is_err());
382    }
383
384    #[test]
385    fn stem_format_rejects_flac_with_guidance() {
386        match "flac".parse::<StemFormat>().unwrap_err() {
387            Error::Config(msg) => assert!(msg.contains("FLAC")),
388            other => panic!("expected Config error, got {other:?}"),
389        }
390    }
391
392    #[test]
393    fn stem_format_rejects_unknown_without_panicking() {
394        assert!(matches!(
395            "ogg".parse::<StemFormat>().unwrap_err(),
396            Error::Config(_)
397        ));
398    }
399
400    #[test]
401    fn stem_format_default_is_wav_and_display_matches_ext() {
402        assert_eq!(StemFormat::default(), StemFormat::Wav);
403        assert_eq!(StemFormat::Mp3.to_string(), StemFormat::Mp3.ext());
404    }
405
406    #[test]
407    fn video_cover_retention_parses_all_variants() {
408        assert_eq!(
409            "neither".parse::<VideoCoverRetention>().unwrap(),
410            VideoCoverRetention::Neither
411        );
412        assert_eq!(
413            "webp".parse::<VideoCoverRetention>().unwrap(),
414            VideoCoverRetention::Webp
415        );
416        assert_eq!(
417            "mp4".parse::<VideoCoverRetention>().unwrap(),
418            VideoCoverRetention::Mp4
419        );
420        assert_eq!(
421            "both".parse::<VideoCoverRetention>().unwrap(),
422            VideoCoverRetention::Both
423        );
424        // Case-sensitive to match serde (TOML) and the JSON schema.
425        assert!("WEBP".parse::<VideoCoverRetention>().is_err());
426    }
427
428    #[test]
429    fn video_cover_retention_rejects_unknown_without_panicking() {
430        assert!(matches!(
431            "all".parse::<VideoCoverRetention>().unwrap_err(),
432            Error::Config(_)
433        ));
434    }
435
436    #[test]
437    fn video_cover_retention_keeps_matrix() {
438        assert!(!VideoCoverRetention::Neither.keeps_webp());
439        assert!(!VideoCoverRetention::Neither.keeps_mp4());
440        assert!(VideoCoverRetention::Webp.keeps_webp());
441        assert!(!VideoCoverRetention::Webp.keeps_mp4());
442        assert!(!VideoCoverRetention::Mp4.keeps_webp());
443        assert!(VideoCoverRetention::Mp4.keeps_mp4());
444        assert!(VideoCoverRetention::Both.keeps_webp());
445        assert!(VideoCoverRetention::Both.keeps_mp4());
446    }
447
448    #[test]
449    fn video_cover_retention_default_is_neither() {
450        assert_eq!(VideoCoverRetention::default(), VideoCoverRetention::Neither);
451    }
452
453    #[test]
454    fn webp_defaults_fit_the_flac_picture_ceiling() {
455        let d = WebpEncodeSettings::default();
456        assert_eq!(d.quality, 90);
457        assert_eq!(d.max_fps, 24);
458        assert_eq!(d.max_width, Some(640));
459        assert!(!d.lossless);
460        assert_eq!(d.compression_level, 4);
461    }
462}