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