Adds a 10-band graphic equalizer to AudioSettings (enabled flag + per-band dB gains, normalised to 10 entries and clamped to range). Presets return gain curves; the settings page gains EQ UI. libmpv applies the filter on Linux (Android parity pending). Old persisted settings without EQ fields load as disabled + flat. Also includes the requirements/traceability/ux-flows doc updates for this feature and the home long-press routing (UR-058/DR-087). TRACES: UR-027 | IR-020, DR-030 | UT-079, UT-080, UT-081, UT-082
383 lines
13 KiB
Rust
383 lines
13 KiB
Rust
//! 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<f32>,
|
|
}
|
|
|
|
/// Flat 10-band curve — the default equalizer state.
|
|
fn default_eq_bands() -> Vec<f32> {
|
|
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);
|
|
}
|
|
}
|