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#[serde(rename_all = "lowercase")]
20pub enum SourceMode {
21    /// Mirror the source, deleting local files that leave it (rclone `sync`).
22    Mirror,
23    /// Copy additively; never delete (rclone `copy`).
24    Copy,
25}
26
27/// The class of an external sidecar artifact a clip (or album/library) owns.
28///
29/// The reconcile engine keeps a single pair of artifact actions
30/// (`Action::WriteArtifact` / `Action::DeleteArtifact`) rather than one variant
31/// per class; the `kind` distinguishes them so the executor and the manifest can
32/// route each to the right slot. Per-clip classes
33/// ([`CoverJpg`](ArtifactKind::CoverJpg), [`CoverWebp`](ArtifactKind::CoverWebp),
34/// [`DetailsTxt`](ArtifactKind::DetailsTxt), [`LyricsTxt`](ArtifactKind::LyricsTxt),
35/// [`Lrc`](ArtifactKind::Lrc), and [`VideoMp4`](ArtifactKind::VideoMp4)) map to
36/// a manifest entry field; the album/library classes are reconciled by later
37/// phases and have no per-clip manifest slot yet.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub enum ArtifactKind {
40    /// The per-song external cover, sourced from `image_large_url`.
41    CoverJpg,
42    /// Retired: the per-song animated cover is now embedded in the audio, not
43    /// written as a `<track>.webp` sidecar. The kind is kept only so a `.webp`
44    /// from an older version stays tracked and is cleaned up (delete-eligible;
45    /// see `removed_kind_delete_eligible` in `reconcile`); it is never emitted
46    /// into a new desired set.
47    CoverWebp,
48    /// The per-song plain-text details dump (generated, inline content).
49    DetailsTxt,
50    /// The per-song plain-text lyrics file (generated, inline content).
51    LyricsTxt,
52    /// The per-song untimed `.lrc` lyrics file (generated, inline content).
53    Lrc,
54    /// The per-song standalone music video, fetched from `video_url` (off by
55    /// default). A large binary, removed only alongside its own audio.
56    VideoMp4,
57    /// The album folder's static cover (album-scoped, later phase).
58    FolderJpg,
59    /// The album folder's animated cover (album-scoped, later phase).
60    FolderWebp,
61    /// The album folder's raw animated cover: the same `video_cover_url` as
62    /// [`FolderWebp`](ArtifactKind::FolderWebp), kept verbatim with no transcode
63    /// (album-scoped, later phase).
64    FolderMp4,
65    /// A library-root `.m3u8` playlist (library-scoped, later phase).
66    Playlist,
67}
68
69/// Audio format for downloaded clips.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
71#[serde(rename_all = "lowercase")]
72pub enum AudioFormat {
73    Mp3,
74    #[default]
75    Flac,
76    Wav,
77    Alac,
78}
79
80impl AudioFormat {
81    /// The on-disk file extension for a clip in this format. Kept separate from
82    /// the [`Display`](fmt::Display) token so a codec's container extension need
83    /// not match its config name.
84    pub fn ext(self) -> &'static str {
85        match self {
86            Self::Mp3 => "mp3",
87            Self::Flac => "flac",
88            Self::Wav => "wav",
89            Self::Alac => "m4a",
90        }
91    }
92
93    /// Whether an animated WebP can be embedded as this format's front cover.
94    ///
95    /// FLAC, MP3, and WAV embed an `image/webp` picture; ALAC (`mp4ameta` `covr`)
96    /// supports only JPEG/PNG/BMP artwork, so it always embeds the static JPEG.
97    pub fn embeds_animated_cover(self) -> bool {
98        !matches!(self, Self::Alac)
99    }
100}
101
102impl FromStr for AudioFormat {
103    type Err = Error;
104
105    fn from_str(s: &str) -> Result<Self> {
106        match s.to_ascii_lowercase().as_str() {
107            "mp3" => Ok(Self::Mp3),
108            "flac" => Ok(Self::Flac),
109            "wav" => Ok(Self::Wav),
110            "alac" => Ok(Self::Alac),
111            other => Err(Error::Config(format!("unknown format '{other}'"))),
112        }
113    }
114}
115
116impl fmt::Display for AudioFormat {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::Mp3 => f.write_str("mp3"),
120            Self::Flac => f.write_str("flac"),
121            Self::Wav => f.write_str("wav"),
122            Self::Alac => f.write_str("alac"),
123        }
124    }
125}
126
127/// Container format for a downloaded stem.
128///
129/// Stems are stored RAW in their native container and are never transcoded, so
130/// unlike [`AudioFormat`] there is no lossless-from-lossy render: WAV comes
131/// straight from Suno's free `convert_wav` endpoint and MP3 straight from the
132/// public CDN. FLAC is deliberately unrepresentable — a stem is never
133/// re-encoded to FLAC, even when the song's own format is FLAC.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
135#[serde(rename_all = "lowercase")]
136pub enum StemFormat {
137    /// Lossless WAV via the free `convert_wav` render, stored as delivered.
138    #[default]
139    Wav,
140    /// The public CDN MP3, stored as delivered.
141    Mp3,
142}
143
144impl StemFormat {
145    /// The file extension for a stem stored in this format.
146    pub fn ext(self) -> &'static str {
147        match self {
148            Self::Wav => "wav",
149            Self::Mp3 => "mp3",
150        }
151    }
152}
153
154impl FromStr for StemFormat {
155    type Err = Error;
156
157    fn from_str(s: &str) -> Result<Self> {
158        match s.to_ascii_lowercase().as_str() {
159            "wav" => Ok(Self::Wav),
160            "mp3" => Ok(Self::Mp3),
161            "flac" => Err(Error::Config(
162                "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
163            )),
164            other => Err(Error::Config(format!("unknown stem format '{other}'"))),
165        }
166    }
167}
168
169impl fmt::Display for StemFormat {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.write_str(self.ext())
172    }
173}
174
175/// Which video-cover artifacts to retain.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
177#[serde(rename_all = "lowercase")]
178pub enum VideoCoverRetention {
179    #[default]
180    Neither,
181    Webp,
182    Mp4,
183    Both,
184}
185
186impl VideoCoverRetention {
187    pub fn keeps_webp(self) -> bool {
188        matches!(self, Self::Webp | Self::Both)
189    }
190
191    pub fn keeps_mp4(self) -> bool {
192        matches!(self, Self::Mp4 | Self::Both)
193    }
194}
195
196impl FromStr for VideoCoverRetention {
197    type Err = Error;
198
199    fn from_str(s: &str) -> Result<Self> {
200        match s.to_ascii_lowercase().as_str() {
201            "neither" => Ok(Self::Neither),
202            "webp" => Ok(Self::Webp),
203            "mp4" => Ok(Self::Mp4),
204            "both" => Ok(Self::Both),
205            other => Err(Error::Config(format!(
206                "unknown video_cover_retention '{other}'"
207            ))),
208        }
209    }
210}
211
212impl fmt::Display for VideoCoverRetention {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        match self {
215            Self::Neither => f.write_str("neither"),
216            Self::Webp => f.write_str("webp"),
217            Self::Mp4 => f.write_str("mp4"),
218            Self::Both => f.write_str("both"),
219        }
220    }
221}
222
223/// Encoder settings for the animated WebP cover derived from a clip's MP4
224/// preview.
225///
226/// The animated WebP is embedded as the audio file's front-cover picture, and a
227/// FLAC PICTURE block cannot exceed ~16 MiB (its length is a 24-bit field). The
228/// [`Default`] is therefore a bounded lossy profile that reliably fits: quality
229/// 90 at effort (`compression_level`) 4, scaled to at most 640 px wide. Effort
230/// is capped at 4 because 6 only matches its size for many times the encode
231/// time, and lossless is opt-in and far larger, so it fits only the roomier
232/// MP3/ALAC containers, never FLAC.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub struct WebpEncodeSettings {
235    /// Lossy encoder quality, 0-100 (higher is better and larger). Ignored when
236    /// `lossless` is set.
237    pub quality: u8,
238    /// Cap on the output frame rate; a faster source is downsampled to this.
239    pub max_fps: u32,
240    /// Optional cap on the output width in pixels: `Some(w)` scales a wider
241    /// source down keeping its aspect ratio (never upscaling), while `None`
242    /// keeps the source resolution.
243    pub max_width: Option<u32>,
244    /// Encode losslessly. Off by default: lossless animated WebP of real video
245    /// is intrinsically huge (roughly 30x the lossy source) with no visible
246    /// gain over quality 95 for a cover.
247    pub lossless: bool,
248    /// Encoder effort, 0-4 (higher is smaller and slower). Capped at 4 because
249    /// effort 6 yields the same size for many times the encode time.
250    pub compression_level: u8,
251}
252
253impl Default for WebpEncodeSettings {
254    fn default() -> Self {
255        Self {
256            quality: 90,
257            max_fps: 24,
258            max_width: Some(640),
259            lossless: false,
260            compression_level: 4,
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn audio_format_parses_case_insensitively() {
271        assert_eq!("FLAC".parse::<AudioFormat>().unwrap(), AudioFormat::Flac);
272        assert_eq!("Mp3".parse::<AudioFormat>().unwrap(), AudioFormat::Mp3);
273        assert_eq!("wav".parse::<AudioFormat>().unwrap(), AudioFormat::Wav);
274        assert_eq!("alac".parse::<AudioFormat>().unwrap(), AudioFormat::Alac);
275    }
276
277    #[test]
278    fn audio_format_rejects_unknown_without_panicking() {
279        assert!(matches!(
280            "ogg".parse::<AudioFormat>().unwrap_err(),
281            Error::Config(_)
282        ));
283    }
284
285    #[test]
286    fn audio_format_default_is_flac() {
287        assert_eq!(AudioFormat::default(), AudioFormat::Flac);
288    }
289
290    #[test]
291    fn audio_format_ext_differs_from_display_for_alac() {
292        assert_eq!(AudioFormat::Alac.ext(), "m4a");
293        assert_eq!(AudioFormat::Alac.to_string(), "alac");
294    }
295
296    #[test]
297    fn audio_format_display_round_trips_through_from_str() {
298        for f in [
299            AudioFormat::Mp3,
300            AudioFormat::Flac,
301            AudioFormat::Wav,
302            AudioFormat::Alac,
303        ] {
304            assert_eq!(f.to_string().parse::<AudioFormat>().unwrap(), f);
305        }
306    }
307
308    #[test]
309    fn audio_format_embeds_animated_cover_except_alac() {
310        assert!(AudioFormat::Flac.embeds_animated_cover());
311        assert!(AudioFormat::Mp3.embeds_animated_cover());
312        assert!(AudioFormat::Wav.embeds_animated_cover());
313        assert!(!AudioFormat::Alac.embeds_animated_cover());
314    }
315
316    #[test]
317    fn stem_format_parses_wav_and_mp3() {
318        assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
319        assert_eq!("mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
320    }
321
322    #[test]
323    fn stem_format_rejects_flac_with_guidance() {
324        match "flac".parse::<StemFormat>().unwrap_err() {
325            Error::Config(msg) => assert!(msg.contains("FLAC")),
326            other => panic!("expected Config error, got {other:?}"),
327        }
328    }
329
330    #[test]
331    fn stem_format_rejects_unknown_without_panicking() {
332        assert!(matches!(
333            "ogg".parse::<StemFormat>().unwrap_err(),
334            Error::Config(_)
335        ));
336    }
337
338    #[test]
339    fn stem_format_default_is_wav_and_display_matches_ext() {
340        assert_eq!(StemFormat::default(), StemFormat::Wav);
341        assert_eq!(StemFormat::Mp3.to_string(), StemFormat::Mp3.ext());
342    }
343
344    #[test]
345    fn video_cover_retention_parses_all_variants() {
346        assert_eq!(
347            "neither".parse::<VideoCoverRetention>().unwrap(),
348            VideoCoverRetention::Neither
349        );
350        assert_eq!(
351            "WEBP".parse::<VideoCoverRetention>().unwrap(),
352            VideoCoverRetention::Webp
353        );
354        assert_eq!(
355            "mp4".parse::<VideoCoverRetention>().unwrap(),
356            VideoCoverRetention::Mp4
357        );
358        assert_eq!(
359            "both".parse::<VideoCoverRetention>().unwrap(),
360            VideoCoverRetention::Both
361        );
362    }
363
364    #[test]
365    fn video_cover_retention_rejects_unknown_without_panicking() {
366        assert!(matches!(
367            "all".parse::<VideoCoverRetention>().unwrap_err(),
368            Error::Config(_)
369        ));
370    }
371
372    #[test]
373    fn video_cover_retention_keeps_matrix() {
374        assert!(!VideoCoverRetention::Neither.keeps_webp());
375        assert!(!VideoCoverRetention::Neither.keeps_mp4());
376        assert!(VideoCoverRetention::Webp.keeps_webp());
377        assert!(!VideoCoverRetention::Webp.keeps_mp4());
378        assert!(!VideoCoverRetention::Mp4.keeps_webp());
379        assert!(VideoCoverRetention::Mp4.keeps_mp4());
380        assert!(VideoCoverRetention::Both.keeps_webp());
381        assert!(VideoCoverRetention::Both.keeps_mp4());
382    }
383
384    #[test]
385    fn video_cover_retention_default_is_neither() {
386        assert_eq!(VideoCoverRetention::default(), VideoCoverRetention::Neither);
387    }
388
389    #[test]
390    fn webp_defaults_fit_the_flac_picture_ceiling() {
391        let d = WebpEncodeSettings::default();
392        assert_eq!(d.quality, 90);
393        assert_eq!(d.max_fps, 24);
394        assert_eq!(d.max_width, Some(640));
395        assert!(!d.lossless);
396        assert_eq!(d.compression_level, 4);
397    }
398}