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