feat(audio): graphic equalizer with presets and custom bands

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
This commit is contained in:
2026-07-24 23:49:13 +02:00
parent 589f08b873
commit c543f90ad3
9 changed files with 1454 additions and 369 deletions
+22 -3
View File
@@ -1,12 +1,12 @@
//! Audio and video playback settings commands.
//!
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
use tauri::State;
use super::{PlayerStateWrapper, VideoSettingsWrapper};
use crate::player::AutoplaySettings;
use crate::settings::{AudioSettings, VideoSettings};
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
#[tauri::command]
#[specta::specta]
@@ -14,13 +14,32 @@ pub async fn player_set_audio_settings(
player: State<'_, PlayerStateWrapper>,
settings: AudioSettings,
) -> Result<AudioSettings, String> {
// Validate/normalise domain values before applying: clamp crossfade to its
// range and normalise the equalizer band vector (length + gain clamps).
let validated = settings
.with_crossfade_clamped()
.with_equalizer_normalised();
let mut controller = player.0.lock().await;
controller
.set_audio_settings(&settings)
.set_audio_settings(&validated)
.map_err(|e| e.to_string())?;
Ok(controller.audio_settings())
}
/// The built-in equalizer presets and their per-band gain curves (dB), for the
/// settings UI. The curve numbers are domain data defined by the band layout,
/// so the frontend reads them here rather than encoding them.
///
/// TRACES: UR-027 | DR-030
#[tauri::command]
#[specta::specta]
pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String> {
Ok(EqPreset::ALL
.iter()
.map(|p| (*p, p.gains().to_vec()))
.collect())
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_audio_settings(
+1
View File
@@ -332,6 +332,7 @@ mod tests {
gapless_playback: false,
normalize_volume: true,
volume_level: VolumeLevel::Loud,
..Default::default()
};
backend.set_audio_settings(&settings).unwrap();
+204 -2
View File
@@ -3,7 +3,7 @@ use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::AudioSettings;
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use crate::utils::lock::MutexSafe;
use libmpv::Mpv;
@@ -552,8 +552,19 @@ impl PlayerBackend for MpvBackend {
})?;
}
// Audio filter chain: build a single lavfi graph combining the EQ
// peaking bands and (optionally) a dynamic loudness normalizer, and
// set the `af` property. An empty string clears all filters. Both
// features share one `af` graph because MPV exposes a single filter
// property. See docs/specs/audio-equalizer.md and IR-020.
let af = build_af_filter(settings);
self.mpv
.set_property("af", af.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set audio filters: {:?}", e),
})?;
// TODO: Implement crossfade via MPV audio filters if needed
// TODO: Implement volume normalization if needed
Ok(())
}
@@ -563,9 +574,200 @@ impl PlayerBackend for MpvBackend {
}
}
/// Build the full MPV `af` (audio filter) value from the audio settings.
///
/// Combines the equalizer peaking bands and the loudness-normalization filter
/// into a single `lavfi` graph, because MPV exposes one `af` property. The
/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
/// empty string when neither feature contributes a filter, which clears `af`.
///
/// TRACES: UR-027, UR-033 | IR-020, DR-036
fn build_af_filter(settings: &AudioSettings) -> String {
let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
entries.push(norm);
}
if entries.is_empty() {
return String::new();
}
format!("lavfi=[{}]", entries.join(","))
}
/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
/// peaking) per band with a non-zero gain, e.g.
/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
/// is disabled or every gain is ~0. Gains are assumed already normalised by
/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
/// ignored.
///
/// TRACES: UR-027 | IR-020
fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
if !enabled {
return Vec::new();
}
bands
.iter()
.zip(EQ_BANDS.iter())
.filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
.map(|(gain, freq)| {
// width_type=o → octave bandwidth; width=1 → one octave per band.
format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
})
.collect()
}
/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
/// [`VolumeLevel::Normal`] (14 LUFS) target, leaving 1.2 dB of headroom.
const NORMALIZE_REF_PEAK: f32 = 0.87;
/// Reference loudness the peak table is anchored at (Normal preset, 14 LUFS).
const NORMALIZE_REF_LUFS: f32 = -14.0;
/// The loudness-normalization filter entry (unwrapped), or `None` when
/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
/// mode can produce on very dynamic material.
///
/// `dynaudnorm` targets a peak amplitude (`p`, linear 01), not a LUFS value,
/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
/// offset from the Normal reference is applied as a dB offset to the reference
/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
/// so loud presets never request full-scale.
///
/// TRACES: UR-033 | DR-036
fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
if !enabled {
return None;
}
// LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
// 3 decimals is plenty for a peak target and keeps the filter string stable.
Some(format!("dynaudnorm=p={:.3}:g=15", peak))
}
impl Drop for MpvBackend {
fn drop(&mut self) {
info!("[MpvBackend] Shutting down");
// MPV will be automatically cleaned up
}
}
#[cfg(test)]
mod af_filter_tests {
use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
use crate::settings::{AudioSettings, VolumeLevel};
fn settings() -> AudioSettings {
AudioSettings {
equalizer_enabled: false,
equalizer_bands: vec![0.0; 10],
normalize_volume: false,
..AudioSettings::default()
}
}
/// Disabled EQ, or an all-zero curve, produces no EQ entries.
///
/// TRACES: UR-027 | IR-020 | UT-083
#[test]
fn test_eq_entries_empty_when_disabled_or_flat() {
assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
// Sub-threshold gains count as flat.
assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
}
/// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
/// centre frequency and gain, chained inside a single `lavfi` filter.
///
/// TRACES: UR-027 | IR-020 | UT-084
#[test]
fn test_eq_filter_builds_lavfi_chain() {
// First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
let mut s = settings();
s.equalizer_enabled = true;
s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let af = build_af_filter(&s);
assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
assert!(af.ends_with("]"));
assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
// Only two bands are non-zero → exactly two peaking filters.
assert_eq!(af.matches("equalizer=").count(), 2);
}
/// Disabled normalization yields no filter entry; the combined `af` for a
/// fully default (all-off) settings is empty, which clears `af`.
///
/// TRACES: UR-033 | DR-036 | UT-085
#[test]
fn test_normalize_disabled_produces_no_filter() {
assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
assert_eq!(build_af_filter(&settings()), "");
}
/// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
/// the peak preserves the Loud > Normal > Quiet ordering.
///
/// TRACES: UR-033 | DR-036 | UT-086
#[test]
fn test_normalize_peak_preserves_preset_ordering() {
fn peak_of(entry: &str) -> f32 {
// "dynaudnorm=p=0.870:g=15" → 0.870
entry
.split("p=")
.nth(1)
.and_then(|s| s.split(':').next())
.and_then(|s| s.parse().ok())
.expect("parseable peak")
}
let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
for entry in [&loud, &normal, &quiet] {
assert!(
entry.starts_with("dynaudnorm="),
"dynaudnorm filter: {entry}"
);
}
assert!(
peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
"Loud {} > Normal {} > Quiet {}",
peak_of(&loud),
peak_of(&normal),
peak_of(&quiet),
);
// Every preset stays within the safe (0, 0.99] clamp.
for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
}
let mut s = settings();
s.normalize_volume = true;
s.volume_level = VolumeLevel::Quiet;
let af = build_af_filter(&s);
assert!(af.starts_with("lavfi=["));
assert!(af.contains("dynaudnorm=p="));
}
/// EQ and normalization coexist in one `lavfi` graph, with the normalizer
/// placed after the EQ bands so it levels the post-EQ signal.
///
/// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
#[test]
fn test_eq_and_normalize_combine_in_order() {
let mut s = settings();
s.equalizer_enabled = true;
s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
s.normalize_volume = true;
s.volume_level = VolumeLevel::Normal;
let af = build_af_filter(&s);
let eq_pos = af.find("equalizer=").expect("has EQ");
let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
}
}
+180 -1
View File
@@ -1,4 +1,4 @@
//! TRACES: UR-023, UR-031, UR-032, UR-033 | DR-034, DR-035, DR-036, DR-048
//! 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};
@@ -26,6 +26,67 @@ impl VolumeLevel {
}
}
/// 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")]
@@ -38,6 +99,18 @@ pub struct AudioSettings {
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 {
@@ -47,6 +120,8 @@ impl Default for AudioSettings {
gapless_playback: true,
normalize_volume: false,
volume_level: VolumeLevel::Normal,
equalizer_enabled: false,
equalizer_bands: default_eq_bands(),
}
}
}
@@ -57,6 +132,20 @@ impl AudioSettings {
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
@@ -101,6 +190,95 @@ mod tests {
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]
@@ -134,6 +312,7 @@ mod tests {
gapless_playback: true,
normalize_volume: true,
volume_level: VolumeLevel::Loud,
..Default::default()
};
let json = serde_json::to_string(&settings).unwrap();