//! TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020 use serde::{Deserialize, Serialize}; /// Volume normalization levels matching Spotify's presets #[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum VolumeLevel { /// Louder output (-11 LUFS) Loud, /// Default level (-14 LUFS) #[default] Normal, /// Quieter output (-23 LUFS) Quiet, } impl VolumeLevel { /// Get the target LUFS value for this volume level pub fn target_lufs(&self) -> f32 { match self { VolumeLevel::Loud => -11.0, VolumeLevel::Normal => -14.0, VolumeLevel::Quiet => -23.0, } } } /// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count /// and layout are a property of the audio engine, not the UI — presets and the /// MPV filter are defined against these bands. See docs/specs/audio-equalizer.md. /// /// TRACES: UR-027 | DR-030, IR-020 pub const EQ_BANDS: [f32; 10] = [ 31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0, ]; /// Minimum per-band gain in dB. pub const EQ_GAIN_MIN: f32 = -12.0; /// Maximum per-band gain in dB. pub const EQ_GAIN_MAX: f32 = 12.0; /// Built-in equalizer presets. A preset *is* a gain curve defined by the band /// layout above (a domain concept), not a mere label — the curve numbers live /// in Rust so the frontend never encodes the taxonomy. /// /// TRACES: UR-027 | DR-030 #[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal, } impl EqPreset { /// All presets, for enumerating the curve table across the IPC boundary. pub const ALL: [EqPreset; 8] = [ EqPreset::Flat, EqPreset::Rock, EqPreset::Pop, EqPreset::Jazz, EqPreset::Classical, EqPreset::BassBoost, EqPreset::TrebleBoost, EqPreset::Vocal, ]; /// The 10-band gain curve (dB) for this preset, one entry per [`EQ_BANDS`]. /// Curves are conservative (within ±8 dB) so presets stack safely with the /// player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k. pub fn gains(&self) -> [f32; 10] { match self { EqPreset::Flat => [0.0; 10], EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0], EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0], EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0], EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0], EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0], EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0], } } } /// Audio playback settings #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AudioSettings { /// Crossfade duration in seconds (0 = disabled, max 12) pub crossfade_duration: f32, /// Enable gapless playback between tracks pub gapless_playback: bool, /// Enable volume normalization pub normalize_volume: bool, /// Target volume level for normalization pub volume_level: VolumeLevel, /// Enable the graphic equalizer. When false, no EQ filter is applied. #[serde(default)] pub equalizer_enabled: bool, /// Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and /// clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`]. #[serde(default = "default_eq_bands")] pub equalizer_bands: Vec, } /// Flat 10-band curve — the default equalizer state. fn default_eq_bands() -> Vec { vec![0.0; EQ_BANDS.len()] } impl Default for AudioSettings { fn default() -> Self { Self { crossfade_duration: 0.0, gapless_playback: true, normalize_volume: false, volume_level: VolumeLevel::Normal, equalizer_enabled: false, equalizer_bands: default_eq_bands(), } } } impl AudioSettings { /// Clamp crossfade duration to valid range (0-12 seconds) pub fn with_crossfade_clamped(mut self) -> Self { self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0); self } /// Normalise the equalizer band vector to exactly [`EQ_BANDS`]`.len()` /// entries (pad with 0 dB / truncate) and clamp each gain to the valid /// range. Guards against malformed persisted or IPC input. /// /// TRACES: UR-027 | DR-030 pub fn with_equalizer_normalised(mut self) -> Self { let n = EQ_BANDS.len(); self.equalizer_bands.resize(n, 0.0); for g in &mut self.equalizer_bands { *g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX); } self } } /// Video playback settings #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoSettings { /// Enable auto-play of next episode (with countdown) pub auto_play_next_episode: bool, /// Countdown duration in seconds before auto-play (5-30 seconds) pub auto_play_countdown_seconds: u32, /// Maximum number of episodes to auto-play consecutively (0 = unlimited) #[serde(default)] pub auto_play_max_episodes: u32, } impl Default for VideoSettings { fn default() -> Self { Self { auto_play_next_episode: true, auto_play_countdown_seconds: 10, auto_play_max_episodes: 0, } } } impl VideoSettings { /// Clamp countdown duration to valid range (5-30 seconds) pub fn with_countdown_clamped(mut self) -> Self { self.auto_play_countdown_seconds = self.auto_play_countdown_seconds.clamp(5, 30); self } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_settings() { let settings = AudioSettings::default(); assert_eq!(settings.crossfade_duration, 0.0); assert!(settings.gapless_playback); assert!(!settings.normalize_volume); assert_eq!(settings.volume_level, VolumeLevel::Normal); // Equalizer defaults: disabled and flat. assert!(!settings.equalizer_enabled); assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]); } /// EQ presets each return one gain per band; Flat is all zeros. /// /// TRACES: UR-027 | DR-030 | UT-079 #[test] fn test_eq_preset_curves() { for preset in EqPreset::ALL { assert_eq!( preset.gains().len(), EQ_BANDS.len(), "preset {:?} must have one gain per band", preset ); // Every preset stays within the advertised gain range. for g in preset.gains() { assert!( (EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g), "preset {:?} gain {} out of range", preset, g ); } } assert_eq!(EqPreset::Flat.gains(), [0.0; 10]); // Bass boost lifts the low bands and leaves the top flat. let bass = EqPreset::BassBoost.gains(); assert!(bass[0] > 0.0 && bass[9] == 0.0); } /// `with_equalizer_normalised` clamps out-of-range gains and forces the /// band vector to exactly EQ_BANDS.len() (pad short, truncate long). /// /// TRACES: UR-027 | DR-030 | UT-080 #[test] fn test_eq_normalisation() { // Out-of-range gains are clamped. let s = AudioSettings { equalizer_bands: vec![100.0, -100.0, 3.0], ..Default::default() } .with_equalizer_normalised(); assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len()); assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX); assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN); assert_eq!(s.equalizer_bands[2], 3.0); // Short vector padded with 0 dB. assert_eq!(s.equalizer_bands[9], 0.0); // Over-long vector truncated. let long = AudioSettings { equalizer_bands: vec![1.0; 20], ..Default::default() } .with_equalizer_normalised(); assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len()); } /// Old persisted JSON without the EQ fields loads as disabled + flat. /// /// TRACES: UR-027 | DR-030 | UT-081 #[test] fn test_audio_settings_eq_backward_compat() { let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#; let parsed: AudioSettings = serde_json::from_str(json).unwrap(); assert!(!parsed.equalizer_enabled); assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]); } /// EQ fields serialize as camelCase and round-trip. /// /// TRACES: UR-027 | DR-030 | UT-082 #[test] fn test_audio_settings_eq_serialization() { let settings = AudioSettings { equalizer_enabled: true, equalizer_bands: EqPreset::Rock.gains().to_vec(), ..Default::default() }; let json = serde_json::to_string(&settings).unwrap(); assert!(json.contains("\"equalizerEnabled\":true")); assert!(json.contains("\"equalizerBands\":")); let parsed: AudioSettings = serde_json::from_str(&json).unwrap(); assert!(parsed.equalizer_enabled); assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec()); } #[test] fn test_volume_level_lufs() { assert_eq!(VolumeLevel::Loud.target_lufs(), -11.0); assert_eq!(VolumeLevel::Normal.target_lufs(), -14.0); assert_eq!(VolumeLevel::Quiet.target_lufs(), -23.0); } #[test] fn test_crossfade_clamping() { let settings = AudioSettings { crossfade_duration: 20.0, ..Default::default() } .with_crossfade_clamped(); assert_eq!(settings.crossfade_duration, 12.0); let settings = AudioSettings { crossfade_duration: -5.0, ..Default::default() } .with_crossfade_clamped(); assert_eq!(settings.crossfade_duration, 0.0); } #[test] fn test_settings_serialization() { let settings = AudioSettings { crossfade_duration: 5.0, gapless_playback: true, normalize_volume: true, volume_level: VolumeLevel::Loud, ..Default::default() }; let json = serde_json::to_string(&settings).unwrap(); assert!(json.contains("\"crossfadeDuration\":5.0")); assert!(json.contains("\"gaplessPlayback\":true")); assert!(json.contains("\"normalizeVolume\":true")); assert!(json.contains("\"volumeLevel\":\"loud\"")); let parsed: AudioSettings = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.crossfade_duration, 5.0); assert_eq!(parsed.volume_level, VolumeLevel::Loud); } #[test] fn test_video_default_settings() { let settings = VideoSettings::default(); assert!(settings.auto_play_next_episode); assert_eq!(settings.auto_play_countdown_seconds, 10); assert_eq!(settings.auto_play_max_episodes, 0); } #[test] fn test_video_countdown_clamping() { let settings = VideoSettings { auto_play_countdown_seconds: 60, ..Default::default() } .with_countdown_clamped(); assert_eq!(settings.auto_play_countdown_seconds, 30); let settings = VideoSettings { auto_play_countdown_seconds: 2, ..Default::default() } .with_countdown_clamped(); assert_eq!(settings.auto_play_countdown_seconds, 5); } #[test] fn test_video_settings_serialization() { let settings = VideoSettings { auto_play_next_episode: false, auto_play_countdown_seconds: 15, auto_play_max_episodes: 5, }; let json = serde_json::to_string(&settings).unwrap(); assert!(json.contains("\"autoPlayNextEpisode\":false")); assert!(json.contains("\"autoPlayCountdownSeconds\":15")); assert!(json.contains("\"autoPlayMaxEpisodes\":5")); let parsed: VideoSettings = serde_json::from_str(&json).unwrap(); assert!(!parsed.auto_play_next_episode); assert_eq!(parsed.auto_play_countdown_seconds, 15); assert_eq!(parsed.auto_play_max_episodes, 5); } #[test] fn test_video_settings_backward_compat() { // Old JSON without auto_play_max_episodes field let json = r#"{"autoPlayNextEpisode":true,"autoPlayCountdownSeconds":10}"#; let parsed: VideoSettings = serde_json::from_str(json).unwrap(); assert!(parsed.auto_play_next_episode); assert_eq!(parsed.auto_play_countdown_seconds, 10); assert_eq!(parsed.auto_play_max_episodes, 0); } }