use super::backend::{PlayerBackend, PlayerError}; use super::events::{PlayerEventEmitter, PlayerStatusEvent}; use super::media::{MediaItem, MediaSource}; use super::state::PlayerState; use super::stream_end::ObservedTime; use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter}; 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; use log::{debug, error, info, warn}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::Mutex as TokioMutex; /// MPV-based player backend for Linux /// /// Uses libmpv for audio playback with full control over playback state, /// position tracking, and event handling. pub struct MpvBackend { mpv: Arc, state: Arc>, event_emitter: Option>, audio_settings: AudioSettings, playback_reporter: Arc>>, position_throttler: Arc, last_seek_time: Arc, /// Last position/duration seen while a file was loaded. /// /// `time-pos` and `duration` are live properties of the *loaded* file: at /// EOF MPV unloads it and both stop resolving, so reading them straight /// through reported 0.0 / unknown exactly when end-of-file handling needed to /// know where playback reached. See [`ObservedTime`]. observed: Arc>, } struct InternalState { current_media: Option, volume: f32, } /// Detect which audio system is available on the system fn detect_audio_system() -> String { info!("[MpvBackend] Detecting audio system..."); // Try PulseAudio/PipeWire first (most common on modern Linux) if let Ok(output) = Command::new("pactl").arg("info").output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); if stdout.contains("PipeWire") { info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)"); return "pulse".to_string(); } else if stdout.contains("PulseAudio") { info!("[MpvBackend] Detected PulseAudio"); return "pulse".to_string(); } } } // Try detecting PipeWire directly if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() { if output.status.success() { info!("[MpvBackend] Detected PipeWire"); return "pulse".to_string(); // PipeWire works with pulse driver } } // Check if ALSA is available if std::path::Path::new("/proc/asound/cards").exists() { info!("[MpvBackend] Falling back to ALSA"); return "alsa".to_string(); } // Default fallback warn!("[MpvBackend] Could not detect audio system, using 'auto'"); "auto".to_string() } /// Helper to get stream URL from MediaItem fn get_stream_url(media: &MediaItem) -> String { match &media.source { MediaSource::Remote { stream_url, .. } => stream_url.clone(), MediaSource::Local { file_path, .. } => { format!("file://{}", file_path.to_string_lossy()) } MediaSource::DirectUrl { url } => url.clone(), } } impl MpvBackend { /// Create a new MPV backend pub fn new( event_emitter: Option>, playback_reporter: Arc>>, position_throttler: Arc, ) -> Result { info!("[MpvBackend] Initializing MPV backend..."); // MPV requires LC_NUMERIC to be set to "C" locale // Set it before initializing MPV, then restore it after use std::ffi::CString; unsafe { let c_locale = CString::new("C").unwrap(); libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr()); } let mpv = Mpv::new().map_err(|e| PlayerError { message: format!("Failed to initialize MPV: {:?}", e), })?; // Detect and configure audio output let audio_driver = detect_audio_system(); info!( "[MpvBackend] Configuring audio output driver: {}", audio_driver ); mpv.set_property("ao", audio_driver.as_str()) .map_err(|e| PlayerError { message: format!( "Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e ), })?; // Enable verbose logging for audio initialization mpv.set_property("msg-level", "all=warn,ao=debug") .unwrap_or_else(|e| { warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e); }); // Configure MPV for audio playback mpv.set_property("audio-display", "no") .map_err(|e| PlayerError { message: format!("Failed to configure MPV audio-display: {:?}", e), })?; mpv.set_property("video", "no").map_err(|e| PlayerError { message: format!("Failed to configure MPV video: {:?}", e), })?; // Set volume to 100% (we'll control via MPV's volume property) mpv.set_property("volume", 100i64) .map_err(|e| PlayerError { message: format!("Failed to set initial volume: {:?}", e), })?; // Survive a flaky connection instead of dying on it. Without these, // ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises // EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting // in the demuxer handles the common case entirely below our level, so // most outages never reach the recovery in `player_recover_stream`. // // Non-fatal: these are ffmpeg-side options whose availability varies with // the libmpv/ffmpeg build, and losing resilience is not a reason to // refuse to play anything (graceful backend init, CLAUDE.md). mpv.set_property( "stream-lavf-o", "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5", ) .unwrap_or_else(|e| { warn!( "[MpvBackend] Could not enable stream reconnection: {:?} — \ playback will not survive network interruptions", e ); }); mpv.set_property("network-timeout", 15i64) .unwrap_or_else(|e| { warn!("[MpvBackend] Could not set network timeout: {:?}", e); }); let state = Arc::new(Mutex::new(InternalState { current_media: None, volume: 1.0, })); let backend = MpvBackend { mpv: Arc::new(mpv), state, event_emitter, audio_settings: AudioSettings::default(), playback_reporter, position_throttler, last_seek_time: Arc::new(AtomicU64::new(0)), observed: Arc::new(Mutex::new(ObservedTime::default())), }; // Start event loop in background thread backend.start_event_loop(); info!("[MpvBackend] Initialized successfully"); Ok(backend) } /// Start the MPV event loop in a background thread fn start_event_loop(&self) { let mpv = self.mpv.clone(); let event_emitter = self.event_emitter.clone(); let state = self.state.clone(); let reporter = self.playback_reporter.clone(); let throttler = self.position_throttler.clone(); std::thread::spawn(move || { info!("[MpvBackend] Event loop started"); let mut ev_ctx = mpv.create_event_context(); ev_ctx.disable_deprecated_events().unwrap_or_else(|e| { error!("[MpvBackend] Failed to disable deprecated events: {:?}", e); }); loop { match ev_ctx.wait_event(1.0) { Some(Ok(event)) => match event { libmpv::events::Event::StartFile => { debug!("[MpvBackend] Starting file"); } libmpv::events::Event::FileLoaded => { info!("[MpvBackend] File loaded"); // Get duration if let Ok(duration) = mpv.get_property::("duration") { if let Some(emitter) = &event_emitter { emitter.emit(PlayerStatusEvent::MediaLoaded { duration }); } } } libmpv::events::Event::PlaybackRestart => { debug!("[MpvBackend] Playback started/resumed"); let media_id = state .lock_safe() .current_media .as_ref() .map(|m| m.id.clone()); if let Some(emitter) = &event_emitter { emitter.emit(PlayerStatusEvent::StateChanged { state: "playing".to_string(), media_id, }); } } libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => { // Handle pause state changes if let Ok(is_paused) = mpv.get_property::("pause") { let media_id = state .lock_safe() .current_media .as_ref() .map(|m| m.id.clone()); if let Some(emitter) = &event_emitter { emitter.emit(PlayerStatusEvent::StateChanged { state: if is_paused { "paused" } else { "playing" } .to_string(), media_id, }); } } } libmpv::events::Event::EndFile(reason) => { debug!("[MpvBackend] End file with reason: {}", reason); // Only emit PlaybackEnded for natural track completion (EOF = 0) // Don't emit for Stop (2), Quit (3), Error (4), or other reasons // Constants from MPV_END_FILE_REASON enum: EOF=0, STOP=2, QUIT=3, ERROR=4 const MPV_END_FILE_REASON_EOF: u32 = 0; const MPV_END_FILE_REASON_STOP: u32 = 2; const MPV_END_FILE_REASON_QUIT: u32 = 3; const MPV_END_FILE_REASON_ERROR: u32 = 4; if reason == MPV_END_FILE_REASON_EOF { debug!("[MpvBackend] Track finished naturally (EOF), emitting PlaybackEnded"); if let Some(emitter) = &event_emitter { emitter.emit(PlayerStatusEvent::PlaybackEnded); } } else if reason == MPV_END_FILE_REASON_STOP { debug!("[MpvBackend] Track stopped (loading new track), NOT emitting PlaybackEnded"); // Don't emit - user is loading a new track } else if reason == MPV_END_FILE_REASON_QUIT { debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded"); // Don't emit - player is shutting down } else if reason == MPV_END_FILE_REASON_ERROR { // NOT PlaybackEnded — the track did not finish, so // autoplay must not advance. It is an error, and it // has to be *said*: emitting nothing here left // playback halted with the UI still showing // "playing" and no way back. Marked recoverable so // the frontend echoes it into player_recover_stream, // which re-opens the stream where it stopped — // MPV's own reconnect handles shorter blips before // they ever get this far. warn!("[MpvBackend] Track ended with an error — reporting as recoverable"); if let Some(emitter) = &event_emitter { emitter.emit(PlayerStatusEvent::Error { message: "Playback stream failed".to_string(), recoverable: true, }); } } else { debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason); } } libmpv::events::Event::Shutdown => { info!("[MpvBackend] Shutdown event received"); break; } _ => {} }, Some(Err(e)) => { error!("[MpvBackend] Event error: {:?}", e); } None => { // Timeout, continue } } std::thread::sleep(Duration::from_millis(10)); } info!("[MpvBackend] Event loop ended"); }); // Start position update thread let mpv_for_position = self.mpv.clone(); let emitter_for_position = self.event_emitter.clone(); let state_for_position = self.state.clone(); let reporter_for_position = reporter.clone(); let throttler_for_position = throttler.clone(); let last_seek_time_for_position = self.last_seek_time.clone(); let observed_for_position = self.observed.clone(); std::thread::spawn(move || { loop { std::thread::sleep(Duration::from_millis(250)); // Get current position and duration // Note: We emit position updates even when paused so scrubbing works if let (Ok(pos), Ok(dur)) = ( mpv_for_position.get_property::("time-pos"), mpv_for_position.get_property::("duration"), ) { // Remember it: both properties belong to the *loaded* file and // stop resolving the instant MPV unloads it at EOF, which is // exactly when end-of-file handling asks where playback got to. // Recorded before the post-seek skip below so a track that ends // right after a seek still reports the seek target, not zero. observed_for_position.lock_safe().record(pos, dur); // Check if we recently seeked - skip position updates briefly after seeks // to avoid "jumping to zero" visual glitches while MPV is seeking let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_millis() as u64; let last_seek = last_seek_time_for_position.load(Ordering::Relaxed); let time_since_seek = now.saturating_sub(last_seek); // Skip position updates for 150ms after a seek to let MPV stabilize if time_since_seek < 150 { continue; } // Emit position update event (even when paused, for scrubbing) if let Some(emitter) = &emitter_for_position { emitter.emit(PlayerStatusEvent::PositionUpdate { position: pos, duration: dur, }); } // Check if we're playing for progress reporting let is_paused = mpv_for_position .get_property::("pause") .unwrap_or(true); // Only report progress to server when playing (not paused) if !is_paused { // Throttled progress reporting (every 30s) let jellyfin_id = { let state = state_for_position.lock_safe(); state .current_media .as_ref() .and_then(|m| m.jellyfin_id().map(|s| s.to_string())) }; if let Some(item_id) = jellyfin_id { if throttler_for_position.should_report(&item_id) { let position_ticks = seconds_to_ticks(pos); let reporter_clone = reporter_for_position.clone(); let item_id_clone = item_id.clone(); // Spawn async task to report progress // Check if we're in a Tokio runtime, otherwise spawn a new thread with its own runtime if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { let reporter_guard = reporter_clone.lock().await; if let Some(reporter_instance) = reporter_guard.as_ref() { let operation = PlaybackOperation::Progress { item_id: item_id_clone.clone(), position_ticks, is_paused: false, }; match reporter_instance.report(operation, true).await { Ok(_) => debug!( "[MpvBackend] Reported progress for {}", item_id_clone ), Err(e) => warn!( "[MpvBackend] Failed to report progress: {}", e ), } } }); } else { // Fallback: spawn in a new thread with its own runtime std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { let reporter_guard = reporter_clone.lock().await; if let Some(reporter_instance) = reporter_guard.as_ref() { let operation = PlaybackOperation::Progress { item_id: item_id_clone.clone(), position_ticks, is_paused: false, }; match reporter_instance.report(operation, true).await { Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone), Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e), } } }); }); } throttler_for_position.mark_reported(&item_id); } } } } } }); } } impl PlayerBackend for MpvBackend { fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> { let stream_url = get_stream_url(media); info!("[MpvBackend] Loading: {} - {}", media.title, stream_url); // Update state { let mut state = self.state.lock_safe(); state.current_media = Some(media.clone()); } // A different file: the previous one's timestamp must not survive as this // one's "last observed" position. self.observed.lock_safe().reset(); // Load the media file self.mpv .command("loadfile", &[&stream_url]) .map_err(|e| PlayerError { message: format!("Failed to load file: {:?}", e), })?; debug!("[MpvBackend] Load command sent successfully"); Ok(()) } fn play(&mut self) -> Result<(), PlayerError> { debug!("[MpvBackend] Play command"); self.mpv .set_property("pause", false) .map_err(|e| PlayerError { message: format!("Failed to play: {:?}", e), })?; Ok(()) } fn pause(&mut self) -> Result<(), PlayerError> { debug!("[MpvBackend] Pause command"); self.mpv .set_property("pause", true) .map_err(|e| PlayerError { message: format!("Failed to pause: {:?}", e), })?; Ok(()) } fn stop(&mut self) -> Result<(), PlayerError> { debug!("[MpvBackend] Stop command"); self.mpv.command("stop", &[]).map_err(|e| PlayerError { message: format!("Failed to stop: {:?}", e), })?; let mut state = self.state.lock_safe(); state.current_media = None; Ok(()) } fn seek(&mut self, position: f64) -> Result<(), PlayerError> { debug!("[MpvBackend] Seek to {} seconds", position); // Record the seek time to suppress position updates briefly let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_millis() as u64; self.last_seek_time.store(now, Ordering::Relaxed); self.mpv .set_property("time-pos", position) .map_err(|e| PlayerError { message: format!("Failed to seek: {:?}", e), })?; // The poll thread suppresses updates for 150ms after a seek, so without // this a file ending inside that window would report the pre-seek time. self.observed.lock_safe().record_position(position); Ok(()) } fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> { let clamped = volume.clamp(0.0, 1.0); debug!("[MpvBackend] Set volume to {}", clamped); // MPV expects volume as percentage (0-100) let mpv_volume = volume_to_percent(clamped as f64) as i64; self.mpv .set_property("volume", mpv_volume) .map_err(|e| PlayerError { message: format!("Failed to set volume: {:?}", e), })?; let mut state = self.state.lock_safe(); state.volume = clamped; Ok(()) } /// Current position — the live `time-pos`, or the last one observed while a /// file was loaded. /// /// The fallback is the point: `time-pos` is a property of the *loaded* file, /// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at /// exactly the moment end-of-file handling asks where playback reached. /// /// TRACES: UR-005 | DR-130 | UT-121 fn position(&self) -> f64 { let live = self.mpv.get_property::("time-pos").ok(); self.observed.lock_safe().position_or_last(live) } /// Total duration — live, or the last one observed. Unloaded at EOF for the /// same reason as `position`. /// /// TRACES: UR-005 | DR-130 | UT-121 fn duration(&self) -> Option { let live = self.mpv.get_property::("duration").ok(); self.observed.lock_safe().duration_or_last(live) } fn state(&self) -> PlayerState { let state = self.state.lock_safe(); if let Some(ref media) = state.current_media { let is_paused = self.mpv.get_property::("pause").unwrap_or(true); let position = self.position(); let duration = self.duration().unwrap_or(0.0); if is_paused { PlayerState::Paused { media: media.clone(), position, duration, } } else { PlayerState::Playing { media: media.clone(), position, duration, } } } else { PlayerState::Idle } } fn volume(&self) -> f32 { let state = self.state.lock_safe(); state.volume } fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> { info!("[MpvBackend] Applying audio settings"); self.audio_settings = settings.clone(); // Apply gapless playback if settings.gapless_playback { self.mpv .set_property("gapless-audio", "yes") .map_err(|e| PlayerError { message: format!("Failed to enable gapless: {:?}", e), })?; } else { self.mpv .set_property("gapless-audio", "no") .map_err(|e| PlayerError { message: format!("Failed to disable gapless: {:?}", e), })?; } // 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 Ok(()) } fn audio_settings(&self) -> AudioSettings { self.audio_settings.clone() } } /// 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 { 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 0–1), 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 { 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}"); } }