//! Audio playback: rodio/symphonia on a dedicated audio thread, with a //! growing-file source for play-while-downloading. //! //! The audio thread owns the output device and stays responsive: decoder //! construction (which blocks until enough of the file exists) happens on //! short-lived helper threads, tagged with a generation counter so stale //! decodes are discarded when the user has already moved on. mod loudness; mod source; pub mod vocals; mod waveform; pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter}; pub use source::{GrowingFile, StreamProgress, Terminal}; pub use waveform::{compute_waveform, compute_waveform_range}; use std::collections::VecDeque; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::time::{Duration, Instant}; use rodio::cpal::traits::{DeviceTrait, HostTrait}; use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source}; /// Acknowledgement for [`Player::pause_acked`], sent from the audio thread /// AFTER the pause has been applied. Semantics, stated honestly: /// /// - `sink_was_active`: a live sink existed and `Sink::pause()` returned — /// the control state is paused, so the mixer stops pulling samples; at /// most one already-submitted device buffer can still drain (~ms). /// - `latched`: no sink existed yet (decode in flight or idle). The /// desired-paused latch is set, so a decoder completing for this same /// generation starts PAUSED instead of auto-playing. `Resume` or a new /// `Play` clears the latch. /// - `generation`: the playback generation the acknowledgement is bound to. /// A coordinator holding a snapshot from an older generation must treat /// this ack as stale and re-negotiate. #[derive(Debug, Clone, Copy)] pub struct PauseAck { pub generation: u64, pub sink_was_active: bool, pub latched: bool, } #[derive(Debug, Clone)] pub enum PlayerEvent { /// Decoder is being set up (streaming: waiting for first bytes). Buffering, Playing, Paused, Stopped, Position { secs: f64, duration: Option, }, /// A track ended. `gapless` means the next decoder was already queued in /// the audio sink and has begun without an output-device interruption. TrackEnded { gapless: bool, }, Error(String), } enum Cmd { Play { path: PathBuf, stream: Option>, total_bytes: u64, duration_hint: Option, /// Start playback at this offset (seconds): the decoder comes up /// already positioned (same mechanism as the device-change resume), /// so there is no play-then-seek race and no audible blip at 0. start_at_secs: Option, }, /// Decode and append the next local track before the active one ends. Preload { path: PathBuf, duration_hint: Option, }, Pause, /// Pause with an acknowledgement bound to the playback generation — the /// audio-focus primitive (Phase 0.4). Unlike `Pause`, this also LATCHES: /// a decoder still in flight for the current generation will come up /// paused instead of auto-starting, which plain `Pause` silently allows /// today (with no sink yet, `Pause` is a no-op and the track then plays). PauseAcked { reply: mpsc::Sender, }, Resume, Seek(f64), Scratch { secs: f64, rate: f32, }, EndScratch { secs: f64, }, Volume(f32), /// Loudness-normalization gain in dB, layered UNDER the user volume /// (effective sink volume = user volume × 10^(dB/20)). `immediate` /// jumps (pre-playback, from the loudness cache); otherwise the audio /// thread slews toward it so a mid-track refinement is a gentle drift, /// never a step. SetNormGainDb { db: f32, immediate: bool, }, /// Normalization gain for the NEXT gapless-queued track, applied /// sample-accurately when the boundary passes — a track change is /// exactly where loudness may step. Cleared by Play/Stop; an unknown /// next track falls back to unity at the boundary. SetNextNormGainDb(f32), /// Transient fade multiplier (0..1) for the preview open/close crossfade, /// SEPARATE from the user volume so a fade can never strand the volume /// setting. Reset to 1 by every Play/Stop. SetFadeGain(f32), SetOutputDevice { device: Option, reply: mpsc::Sender>, }, Stop, } /// How far the normalization gain may move per 60 ms tick — 0.25 dB/s. /// /// The rate IS the feature: a mid-track correction is only acceptable if the /// correcting is inaudible. See the slew in the tick arm for why this replaced /// the original 0.15 (≈2.5 dB/s). const NORM_SLEW_PER_TICK_DB: f32 = 0.015; #[derive(Clone)] struct TrackSource { path: PathBuf, stream: Option>, total_bytes: u64, duration_hint: Option, } struct ReadyDecoder { generation: u64, decoder: Decoder>, track: TrackSource, preload: bool, resume_at: Option, start_paused: bool, } impl ReadyDecoder { /// True duration for the timeline/seek range. The peer's search-metadata /// hint can be wildly wrong (a bogus 30s on a 41-minute live track), and /// on its own it would truncate the seek bar. The decoder reads the real /// length from the container (FLAC STREAMINFO, an MP3's byte length), so /// we take whichever is LONGER: a short hint can't truncate a genuinely /// long file, and a still-partial decoder can't under-report against a /// good hint. fn track_duration(&self) -> Option { reconcile_duration( self.track.duration_hint, self.decoder.total_duration().map(|d| d.as_secs_f64()), ) } } /// Pick the timeline duration from the peer's metadata hint and the decoder's /// own reading: take whichever is longer. See [`ReadyDecoder::track_duration`]. fn reconcile_duration(hint: Option, decoded: Option) -> Option { match (hint, decoded) { (Some(h), Some(d)) => Some(h.max(d)), (h, d) => h.or(d), } } struct PendingTrack { duration: Option, track: TrackSource, } /// Forwards a boundary event from rodio's real-time source iterator exactly /// when a decoder is exhausted. This lets the UI change its metadata after /// the next queued source has already started producing samples. struct EndNotifier { inner: S, queued: Arc, event_tx: mpsc::Sender, sent: bool, } impl Iterator for EndNotifier where S: Source, { type Item = S::Item; fn next(&mut self) -> Option { match self.inner.next() { Some(sample) => Some(sample), None => { if !self.sent { self.sent = true; let remaining = self.queued.fetch_sub(1, Ordering::SeqCst); let _ = self.event_tx.send(PlayerEvent::TrackEnded { gapless: remaining > 1, }); } None } } } } impl Source for EndNotifier where S: Source, { fn current_span_len(&self) -> Option { self.inner.current_span_len() } fn channels(&self) -> rodio::ChannelCount { self.inner.channels() } fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() } fn total_duration(&self) -> Option { self.inner.total_duration() } // The gapless boundary wrapper must remain transparent to transport // controls. Without this delegation, Source's default implementation // reports `NotSupported` and makes every seek a no-op. fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { self.inner.try_seek(pos) } } #[derive(Clone)] pub struct Player { cmd_tx: mpsc::Sender, /// f32 bits; the audio thread mirrors its live fade-gain here for /// observability (the debug overlay / crossfade diagnostics). 1.0 = full. fade_report: Arc, /// f32 bits, dB: the gain the sink is being given RIGHT NOW, and the one /// the slew is heading for. Mirrored separately because the interesting /// state during a mid-track correction is the gap between them — a single /// "current gain" readout cannot show that a correction is in flight, only /// that it has finished. norm_now_report: Arc, norm_target_report: Arc, } impl Player { /// Spawn the audio thread. Events stream out on the receiver. pub fn spawn() -> (Player, mpsc::Receiver) { Self::spawn_with_device(None) } /// Spawn with a persisted output-device preference. `None` follows the /// operating system's default output device. pub fn spawn_with_device( output_device: Option, ) -> (Player, mpsc::Receiver) { let (cmd_tx, cmd_rx) = mpsc::channel::(); let (event_tx, event_rx) = mpsc::channel::(); let fade_report = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits())); let norm_now_report = Arc::new(std::sync::atomic::AtomicU32::new(0.0f32.to_bits())); let norm_target_report = Arc::new(std::sync::atomic::AtomicU32::new(0.0f32.to_bits())); let thread_report = fade_report.clone(); let thread_norm_now = norm_now_report.clone(); let thread_norm_target = norm_target_report.clone(); std::thread::Builder::new() .name("audio".into()) .spawn(move || { audio_thread( cmd_rx, event_tx, output_device, thread_report, thread_norm_now, thread_norm_target, ) }) .expect("spawn audio thread"); ( Player { cmd_tx, fade_report, norm_now_report, norm_target_report, }, event_rx, ) } /// The audio thread's current fade-gain (0..1). Observability only. pub fn fade_gain(&self) -> f32 { f32::from_bits(self.fade_report.load(Ordering::Relaxed)) } /// The normalization gain being applied right now, and the one the slew is /// heading for, both in dB. Equal means settled; different means a /// correction is in flight. Observability only. pub fn norm_gain_now_target_db(&self) -> (f32, f32) { ( f32::from_bits(self.norm_now_report.load(Ordering::Relaxed)), f32::from_bits(self.norm_target_report.load(Ordering::Relaxed)), ) } pub fn play_local(&self, path: impl Into, duration_hint: Option) { self.play_local_from(path, duration_hint, None); } /// `play_local`, but the decoder comes up at `start_at_secs` — used by /// the preview session's resume memory. `None`/`0` is a plain start. pub fn play_local_from( &self, path: impl Into, duration_hint: Option, start_at_secs: Option, ) { let _ = self.cmd_tx.send(Cmd::Play { path: path.into(), stream: None, total_bytes: 0, duration_hint, start_at_secs: start_at_secs.filter(|s| *s > 0.0), }); } /// Queue a local file behind the current source. The decoder is prepared /// on the helper thread, so disk I/O never interrupts audible playback. pub fn preload_local(&self, path: impl Into, duration_hint: Option) { let _ = self.cmd_tx.send(Cmd::Preload { path: path.into(), duration_hint, }); } /// Play a file that is still being downloaded. `progress` must be kept /// updated by the download; `total_bytes` is the expected final size. pub fn play_stream( &self, path: impl Into, progress: Arc, total_bytes: u64, duration_hint: Option, ) { let _ = self.cmd_tx.send(Cmd::Play { path: path.into(), stream: Some(progress), total_bytes, duration_hint, start_at_secs: None, }); } pub fn pause(&self) { let _ = self.cmd_tx.send(Cmd::Pause); } /// Pause and receive a [`PauseAck`] once the audio thread has applied it. /// The returned receiver yields exactly one ack (or disconnects if the /// player is gone). Callers should `recv_timeout` and treat a timeout as /// "not acknowledged" — never as silence. pub fn pause_acked(&self) -> mpsc::Receiver { let (reply, rx) = mpsc::channel(); let _ = self.cmd_tx.send(Cmd::PauseAcked { reply }); rx } pub fn resume(&self) { let _ = self.cmd_tx.send(Cmd::Resume); } pub fn seek(&self, secs: f64) { let _ = self.cmd_tx.send(Cmd::Seek(secs)); } pub fn scratch(&self, secs: f64, rate: f32) { let _ = self.cmd_tx.send(Cmd::Scratch { secs, rate }); } pub fn end_scratch(&self, secs: f64) { let _ = self.cmd_tx.send(Cmd::EndScratch { secs }); } pub fn set_volume(&self, volume: f32) { let _ = self.cmd_tx.send(Cmd::Volume(volume.clamp(0.0, 2.0))); } /// Loudness-normalization gain (dB), independent of the user volume. /// `immediate` applies it as a jump — correct before audio starts (a /// cached measurement); pass false while sounding and the audio thread /// glides there instead. Reset to 0 dB by every new `Play`. pub fn set_norm_gain_db(&self, db: f32, immediate: bool) { let _ = self.cmd_tx.send(Cmd::SetNormGainDb { db: db.clamp(-18.0, 9.0), immediate, }); } /// Normalization gain for the next GAPLESS-queued track; the audio /// thread applies it exactly at the boundary. See [`Cmd::SetNextNormGainDb`]. pub fn set_next_norm_gain_db(&self, db: f32) { let _ = self .cmd_tx .send(Cmd::SetNextNormGainDb(db.clamp(-18.0, 9.0))); } /// Transient fade multiplier (0..1) for the preview open/close crossfade. /// A separate channel from the user volume — reset to 1 by every Play/Stop /// — so a fade can never leave a later track silent. See [`Cmd::SetFadeGain`]. pub fn set_fade_gain(&self, gain: f32) { let _ = self.cmd_tx.send(Cmd::SetFadeGain(gain.clamp(0.0, 1.0))); } /// Change devices immediately. The audio thread acknowledges only after /// the endpoint has opened successfully, so callers can safely persist it. pub fn set_output_device(&self, device: Option) -> Result<(), String> { let (reply, result) = mpsc::channel(); self.cmd_tx .send(Cmd::SetOutputDevice { device, reply }) .map_err(|_| "audio thread is unavailable".to_string())?; result .recv_timeout(Duration::from_secs(5)) .map_err(|_| "timed out while opening the audio device".to_string())? } pub fn stop(&self) { let _ = self.cmd_tx.send(Cmd::Stop); } } /// Names reported by the platform audio host, suitable for a device picker. pub fn output_device_names() -> Result, String> { let host = rodio::cpal::default_host(); let mut names: Vec = host .output_devices() .map_err(|e| format!("could not list audio devices: {e}"))? .filter_map(|device| device.name().ok()) .collect(); names.sort_by_key(|name| name.to_lowercase()); names.dedup(); Ok(names) } fn default_output_device_name() -> Option { rodio::cpal::default_host() .default_output_device()? .name() .ok() } fn open_output_stream(device_name: Option<&str>) -> Result<(rodio::OutputStream, String), String> { let host = rodio::cpal::default_host(); let device = match device_name { Some(wanted) => host .output_devices() .map_err(|e| format!("could not list audio devices: {e}"))? .find(|device| device.name().ok().as_deref() == Some(wanted)) .ok_or_else(|| format!("audio device not found: {wanted}"))?, None => host .default_output_device() .ok_or_else(|| "Windows has no default audio output device".to_string())?, }; let actual_name = device.name().unwrap_or_else(|_| "Unknown device".into()); let builder = rodio::OutputStreamBuilder::from_device(device) .map_err(|e| format!("could not configure {actual_name}: {e}"))?; let stream = builder .open_stream_or_fallback() .map_err(|e| format!("could not open {actual_name}: {e}"))?; Ok((stream, actual_name)) } fn start_decoder( track: TrackSource, generation: u64, preload: bool, resume_at: Option, start_paused: bool, tx: mpsc::Sender>, ) { std::thread::Builder::new() .name( if preload { "decode-preload" } else { "decode-setup" } .into(), ) .spawn(move || { // Refuse formats there is no decoder for, BEFORE opening them. // // Most simply fail to build and report themselves. WavPack does // not: a `.wv` carrying the original RIFF header in its wrapper is // probed as raw PCM, so the decoder reports success and streams the // compressed body as samples — measured at 136,815 NaN/Inf values // in a single track, straight into the mixer and the normaliser. // An extension check is the only thing that can stop that, because // by the time there is a decoder there is no error to notice. if let Some(reason) = audio_formats::unplayable_path_reason(&track.path) { let _ = tx.send(Err((generation, reason.to_string()))); return; } let (reader, byte_len): (Box, u64) = match track.stream.clone() { Some(progress) => { match GrowingFile::open(&track.path, track.total_bytes, progress) { Ok(g) => (Box::new(g), track.total_bytes), Err(e) => { let _ = tx.send(Err((generation, e.to_string()))); return; } } } None => match std::fs::File::open(&track.path) { Ok(f) => { let len = f.metadata().map(|m| m.len()).unwrap_or(0); (Box::new(std::io::BufReader::new(f)), len) } Err(e) => { let _ = tx.send(Err((generation, e.to_string()))); return; } }, }; let mut builder = Decoder::builder().with_data(reader).with_seekable(true); if byte_len > 0 { builder = builder.with_byte_len(byte_len); } match builder.build() { Ok(decoder) => { let _ = tx.send(Ok(ReadyDecoder { generation, decoder, track, preload, resume_at, start_paused, })); } Err(e) => { let _ = tx.send(Err((generation, format!("decode failed: {e}")))); } } }) .ok(); } fn audio_thread( cmd_rx: mpsc::Receiver, event_tx: mpsc::Sender, initial_device: Option, fade_report: Arc, norm_now_report: Arc, norm_target_report: Arc, ) { // Open the output device lazily and keep it across tracks. If the device // is momentarily busy (e.g. another instance still releasing it), we // retry on the next Play instead of killing the thread for the session. let (mut output_stream, mut current_device_name) = match open_output_stream(initial_device.as_deref()) { Ok((stream, name)) => (Some(stream), Some(name)), Err(e) => { log::warn!("could not open configured audio output: {e}"); (None, None) } }; let mut selected_device = initial_device; let mut last_default_check = Instant::now(); let (decoder_tx, decoder_rx) = mpsc::channel::>(); let mut sink: Option = None; let mut generation: u64 = 0; let mut duration: Option = None; let mut volume: f32 = 0.7; // Loudness-normalization gain, layered under the user volume. `norm_db` // is what the sink currently gets; `norm_target_db` is where the slew in // the tick arm is heading (~2.5 dB/s — a refinement mid-track drifts, // it never steps). Every new Play resets both to unity. let mut norm_db: f32 = 0.0; let mut norm_target_db: f32 = 0.0; // Gain waiting for the next gapless boundary (None → unity there). let mut pending_norm_db: Option = None; // Transient fade multiplier (0..1) for the preview session's open/close // crossfade — a SEPARATE channel from the user volume, so a fade can // never corrupt the volume setting (leaving a track silent after the // fade or across the next Play). Reset to 1 by every Play/Stop. let mut fade_gain: f32 = 1.0; let eff = |user: f32, ndb: f32, fg: f32| user * 10f32.powf(ndb / 20.0) * fg; let mut playing = false; // The active stream source, so we can abort its blocking reads. let mut active_stream: Option> = None; // Number of sources handed to the sink but not yet exhausted. Kept in an // atomic because the rodio mixer consumes sources on its own audio thread. let queued_sources = Arc::new(AtomicUsize::new(0)); let mut waiting_preload: Option = None; // Durations of sources queued behind the active one. When a gapless // boundary passes, the front entry becomes the new track's duration — // otherwise Position events keep reporting the finished track's length. let mut pending_tracks: VecDeque = VecDeque::new(); let mut last_queued: usize = 0; // Scratch playback is possible only for a stable, complete local file. let mut local_path: Option = None; let mut active_track: Option = None; let mut scratching = false; let mut scratch_was_playing = false; // Desired-paused latch (audio-focus primitive): set by PauseAcked, and it // means "nothing for the CURRENT generation may become audible on its // own". Its one job is closing the window where a pause lands while the // decoder is still in flight — plain Pause is a no-op then, and the track // auto-starts moments after the caller believed playback was silenced. // Cleared by Resume and by Play (a new track is a new intent). let mut desired_paused = false; let emit = |ev: PlayerEvent| { let _ = event_tx.send(ev); }; loop { let mut output_change: Option<(Option, Option>>)> = None; // Commands, with a 60 ms tick for a visibly smooth playhead without // flooding the UI with frame-rate IPC events. match cmd_rx.recv_timeout(Duration::from_millis(60)) { Ok(Cmd::Play { path, stream, total_bytes, duration_hint, start_at_secs, }) => { // Abort whatever was playing or buffering. generation += 1; if let Some(s) = active_stream.take() { s.abort(); } if let Some(s) = sink.take() { s.stop(); } queued_sources.store(0, Ordering::SeqCst); waiting_preload = None; pending_tracks.clear(); playing = false; scratching = false; desired_paused = false; // a new Play is explicit listen intent // A new track's loudness is unknown until measured — start at // unity; a cached measurement arrives as an immediate // SetNormGainDb right behind this command, before any sink. norm_db = 0.0; norm_target_db = 0.0; pending_norm_db = None; // A clicked/queued track always plays at full: never inherit a // preview crossfade's residual fade level (that was the // "audio broken after a track click" bug). fade_gain = 1.0; fade_report.store(fade_gain.to_bits(), Ordering::Relaxed); let track = TrackSource { path, stream, total_bytes, duration_hint, }; local_path = track.stream.is_none().then(|| track.path.clone()); duration = duration_hint; emit(PlayerEvent::Buffering); active_stream = track.stream.clone(); active_track = Some(track.clone()); start_decoder( track, generation, false, start_at_secs.map(Duration::from_secs_f64), false, decoder_tx.clone(), ); } Ok(Cmd::Preload { path, duration_hint, }) => { // Streams are intentionally excluded: a growing file can // block while waiting for bytes, which would defeat a clean // album transition. Local files are fully available. start_decoder( TrackSource { path, stream: None, total_bytes: 0, duration_hint, }, generation, true, None, false, decoder_tx.clone(), ); } Ok(Cmd::Pause) => { if let Some(s) = &sink { s.pause(); playing = false; emit(PlayerEvent::Paused); } } Ok(Cmd::PauseAcked { reply }) => { let sink_was_active = if let Some(s) = &sink { s.pause(); playing = false; emit(PlayerEvent::Paused); true } else { false }; // Latch regardless: if a decoder for this generation is still // in flight, it must come up paused, not audible. desired_paused = true; let _ = reply.send(PauseAck { generation, sink_was_active, latched: !sink_was_active, }); } Ok(Cmd::Resume) => { desired_paused = false; if let Some(s) = &sink { s.play(); playing = true; emit(PlayerEvent::Playing); } } Ok(Cmd::Seek(secs)) => { if let Some(s) = &sink { if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) { log::debug!("seek failed: {e}"); } // Land the pending correction rather than carrying it // across the jump. The slew is slow ONLY because a gain // change is audible against continuous music — and a seek // is already a discontinuity, so the step hides inside it. // Drifting afterwards would be the audible case: the same // few seconds of new material rising in level for no // reason the listener can attribute to anything. norm_db = norm_target_db; s.set_volume(eff(volume, norm_db, fade_gain)); } } Ok(Cmd::Scratch { secs, rate }) => { let (Some(path), Some(out)) = (local_path.as_ref(), output_stream.as_ref()) else { continue; }; if !scratching { // Discard any asynchronous decoder/preload that was // prepared for the normal gapless path. Scratch owns the // sink until release and must never mix in the next song. generation += 1; scratch_was_playing = playing; scratching = true; queued_sources.store(0, Ordering::SeqCst); waiting_preload = None; pending_tracks.clear(); last_queued = 0; } if let Some(old) = sink.take() { old.stop(); } match decode_scratch_window(path, secs, rate) { Ok((samples, speed)) => { let s = Sink::connect_new(out.mixer()); s.set_volume(eff(volume, norm_db, fade_gain)); s.set_speed(speed); s.append(samples); s.play(); sink = Some(s); // The UI owns position while the platter is dragged. playing = true; } Err(e) => log::debug!("scratch decode failed: {e}"), } } Ok(Cmd::EndScratch { secs }) => { if !scratching { continue; } scratching = false; if let Some(old) = sink.take() { old.stop(); } queued_sources.store(0, Ordering::SeqCst); pending_tracks.clear(); last_queued = 0; let (Some(path), Some(out)) = (local_path.as_ref(), output_stream.as_ref()) else { continue; }; match local_decoder(path) { Ok(decoder) => { let s = Sink::connect_new(out.mixer()); s.set_volume(eff(volume, norm_db, fade_gain)); queued_sources.store(1, Ordering::SeqCst); s.append(EndNotifier { inner: decoder, queued: queued_sources.clone(), event_tx: event_tx.clone(), sent: false, }); // Seek through the sink rather than before append so // Sink::get_pos continues to report the track's real // absolute position after a scratch release. if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) { log::debug!("scratch release seek failed: {e}"); } if scratch_was_playing { s.play(); playing = true; emit(PlayerEvent::Playing); } else { s.pause(); playing = false; emit(PlayerEvent::Paused); } sink = Some(s); } Err(e) => emit(PlayerEvent::Error(e)), } } Ok(Cmd::Volume(v)) => { volume = v; if let Some(s) = &sink { s.set_volume(eff(volume, norm_db, fade_gain)); } } Ok(Cmd::SetNormGainDb { db, immediate }) => { norm_target_db = db; if immediate { norm_db = db; if let Some(s) = &sink { s.set_volume(eff(volume, norm_db, fade_gain)); } } // Non-immediate: the tick arm slews norm_db there. } Ok(Cmd::SetNextNormGainDb(db)) => { pending_norm_db = Some(db); } Ok(Cmd::SetFadeGain(fg)) => { fade_gain = fg.clamp(0.0, 1.0); fade_report.store(fade_gain.to_bits(), Ordering::Relaxed); if let Some(s) = &sink { s.set_volume(eff(volume, norm_db, fade_gain)); } } Ok(Cmd::SetOutputDevice { device, reply }) => { output_change = Some((device, Some(reply))); } Ok(Cmd::Stop) => { generation += 1; if let Some(s) = active_stream.take() { s.abort(); } if let Some(s) = sink.take() { s.stop(); } queued_sources.store(0, Ordering::SeqCst); waiting_preload = None; pending_tracks.clear(); pending_norm_db = None; fade_gain = 1.0; fade_report.store(fade_gain.to_bits(), Ordering::Relaxed); playing = false; scratching = false; local_path = None; active_track = None; emit(PlayerEvent::Stopped); } Err(mpsc::RecvTimeoutError::Timeout) => { // Normalization slew: glide toward the analyzer's latest // target, slowly enough that the move is not itself audible. // // This ran at ~2.5 dB/s (0.15 dB per 60 ms tick), and at that // rate a mid-track refinement was heard as a drift — which is // why mid-track correction was switched off entirely on // 2026-07-23 rather than slowed down. It is back on because // the alternative is worse: a streamed first play is gained // from a 10 s prefix, which reads a median 3.4 dB too quiet // across the cached corpus, and sometimes from nothing at all. // Being 3 dB wrong for a whole track is a bigger error than a // correction nobody can hear. // // 0.25 dB/s sits under the threshold at which a gradual level // change reads as a change rather than as the music: the // median 3.4 dB correction takes ~14 s and the worst observed // (~6 dB) about 24 s, both spread thinly enough to be masked // by the track's own dynamics. let diff = norm_target_db - norm_db; if diff.abs() > 0.001 { norm_db += diff.clamp(-NORM_SLEW_PER_TICK_DB, NORM_SLEW_PER_TICK_DB); if let Some(s) = &sink { s.set_volume(eff(volume, norm_db, fade_gain)); } } } Err(mpsc::RecvTimeoutError::Disconnected) => break, } // Mirror the normalization pair after every command and every tick, so // the debug overlay sees the slew moving rather than only its endpoint. norm_now_report.store(norm_db.to_bits(), Ordering::Relaxed); norm_target_report.store(norm_target_db.to_bits(), Ordering::Relaxed); // In system-default mode, periodically compare endpoint names. CPAL's // existing stream remains attached to the old endpoint when Windows' // default changes, so reopening is required. if output_change.is_none() && selected_device.is_none() && !scratching && last_default_check.elapsed() >= Duration::from_secs(1) { last_default_check = Instant::now(); if let Some(default_name) = default_output_device_name() { if current_device_name.as_deref() != Some(default_name.as_str()) { output_change = Some((None, None)); } } } if let Some((requested, reply)) = output_change { if scratching { if let Some(reply) = reply { let _ = reply.send(Err("finish scratching before changing audio devices".into())); } } else { match open_output_stream(requested.as_deref()) { Err(e) => { if let Some(reply) = reply { let _ = reply.send(Err(e)); } else { log::warn!("could not follow Windows audio output change: {e}"); } } Ok((new_stream, name)) => { let resume_at = sink.as_ref().map(|s| s.get_pos()); let start_paused = sink.is_some() && !playing; if active_track.is_some() { generation += 1; } if let Some(old) = sink.take() { old.stop(); } queued_sources.store(0, Ordering::SeqCst); waiting_preload = None; pending_tracks.clear(); last_queued = 0; output_stream = Some(new_stream); current_device_name = Some(name); selected_device = requested; if let Some(track) = active_track.clone() { playing = false; emit(PlayerEvent::Buffering); start_decoder( track, generation, false, resume_at, start_paused, decoder_tx.clone(), ); } if let Some(reply) = reply { let _ = reply.send(Ok(())); } } } } } // Finished decoder setups. while let Ok(result) = decoder_rx.try_recv() { match result { Ok(ready) if ready.generation == generation => { // (Re)acquire the output device if we don't have it yet. if output_stream.is_none() { match open_output_stream(selected_device.as_deref()) { Ok((new_stream, name)) => { output_stream = Some(new_stream); current_device_name = Some(name); } Err(e) => log::warn!("could not acquire audio output: {e}"), } } let Some(out) = output_stream.as_ref() else { emit(PlayerEvent::Error( "audio device unavailable — is another app using it?".into(), )); continue; }; if ready.preload && sink.is_none() { // Decoder setup can beat the first decoder on a fast // disk. Hold it until the active sink exists. waiting_preload = Some(ready); continue; } let is_preload = ready.preload; let track_duration = ready.track_duration(); if !is_preload { let s = Sink::connect_new(out.mixer()); s.set_volume(eff(volume, norm_db, fade_gain)); sink = Some(s); active_track = Some(ready.track.clone()); active_stream = ready.track.stream.clone(); local_path = ready .track .stream .is_none() .then(|| ready.track.path.clone()); } let target = sink .as_ref() .expect("sink exists for active or preloaded decoder"); queued_sources.fetch_add(1, Ordering::SeqCst); target.append(EndNotifier { inner: ready.decoder, queued: queued_sources.clone(), event_tx: event_tx.clone(), sent: false, }); if let Some(pos) = ready.resume_at { if let Err(e) = target.try_seek(pos) { log::warn!("could not resume after audio-device change: {e}"); } } if is_preload { // Nothing changes visually yet; samples will switch at // the exact decoder boundary. pending_tracks.push_back(PendingTrack { duration: track_duration, track: ready.track, }); } else { duration = track_duration; // desired_paused: an acknowledged pause landed while // this decoder was in flight. Honouring it here is // the whole point of the latch — the caller was told // "silent", so the track must not become audible. if ready.start_paused || desired_paused { target.pause(); playing = false; emit(PlayerEvent::Paused); } else { target.play(); playing = true; emit(PlayerEvent::Playing); } if let Some(next) = waiting_preload.take() { let next_duration = next.track_duration(); pending_tracks.push_back(PendingTrack { duration: next_duration, track: next.track.clone(), }); queued_sources.fetch_add(1, Ordering::SeqCst); target.append(EndNotifier { inner: next.decoder, queued: queued_sources.clone(), event_tx: event_tx.clone(), sent: false, }); } } } Ok(_) => {} // stale generation: user moved on Err((gen, msg)) if gen == generation => emit(PlayerEvent::Error(msg)), Err(_) => {} } } // A queued source finished while the sink kept playing (gapless // boundary): adopt the next track's duration for Position events. let q = queued_sources.load(Ordering::SeqCst); if q < last_queued && q > 0 { for _ in 0..(last_queued - q) { if let Some(next) = pending_tracks.pop_front() { duration = next.duration; local_path = next.track.stream.is_none().then(|| next.track.path.clone()); active_stream = next.track.stream.clone(); active_track = Some(next.track); } } // Gapless boundary = a track change: apply the next track's // normalization gain (unity when unmeasured) as a step HERE — // never mid-track. norm_db = pending_norm_db.take().unwrap_or(0.0); norm_target_db = norm_db; if let Some(s) = &sink { s.set_volume(eff(volume, norm_db, fade_gain)); } } last_queued = q; // Position tick / end detection. if let Some(s) = &sink { if playing && !scratching { if s.empty() { playing = false; sink = None; if let Some(st) = active_stream.take() { st.abort(); } active_track = None; local_path = None; // EndNotifier emits TrackEnded from the audio callback; // do not emit a second, late event from this polling tick. } else { emit(PlayerEvent::Position { secs: s.get_pos().as_secs_f64(), duration, }); } } } } } /// Build a seekable decoder for a complete local file. Growing network /// downloads stay on ordinary seek behavior because reverse windows need /// stable bytes on disk. fn local_decoder(path: &Path) -> Result>, String> { let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let len = file.metadata().map(|m| m.len()).unwrap_or(0); let mut builder = Decoder::builder() .with_data(Box::new(std::io::BufReader::new(file)) as Box) .with_seekable(true); if len > 0 { builder = builder.with_byte_len(len); } builder.build().map_err(|e| format!("decode failed: {e}")) } /// Decode ~180 ms around the jog point. Reversing complete interleaved frames /// (not individual samples) preserves stereo while producing true reverse /// audio; the sink rate carries the hand-speed/pitch relationship. fn decode_scratch_window( path: &Path, secs: f64, rate: f32, ) -> Result<(SamplesBuffer, f32), String> { let mut decoder = local_decoder(path)?; let channels = decoder.channels(); let sample_rate = decoder.sample_rate(); let speed = rate.abs().clamp(0.25, 4.0); let window_secs = 0.18 * f64::from(speed); let start = if rate < 0.0 { (secs - window_secs).max(0.0) } else { secs.max(0.0) }; decoder .try_seek(Duration::from_secs_f64(start)) .map_err(|e| format!("scratch seek failed: {e}"))?; let count = (window_secs * f64::from(sample_rate) * f64::from(channels)) as usize; let mut data: Vec = decoder .by_ref() .take(count.max(channels as usize)) .collect(); if data.is_empty() { return Err("no audio at scratch position".into()); } if rate < 0.0 { let frame = channels as usize; data = data .chunks(frame) .rev() .flat_map(|f| f.iter().copied()) .collect(); } Ok((SamplesBuffer::new(channels, sample_rate, data), speed)) } #[cfg(test)] mod tests { use super::reconcile_duration; #[test] fn bogus_short_hint_cannot_truncate_a_long_file() { // The reported bug: peer metadata said 30s for a 41-minute live FLAC // whose decoder reads the true length. The real duration must win. assert_eq!(reconcile_duration(Some(30.0), Some(2491.0)), Some(2491.0)); } #[test] fn partial_decoder_cannot_underreport_against_a_good_hint() { // A still-downloading source whose decoder only sees part of the file // must not shrink a correct hint. assert_eq!(reconcile_duration(Some(240.0), Some(45.0)), Some(240.0)); } #[test] fn falls_back_to_whichever_exists() { assert_eq!(reconcile_duration(None, Some(180.0)), Some(180.0)); assert_eq!(reconcile_duration(Some(180.0), None), Some(180.0)); assert_eq!(reconcile_duration(None, None), None); } }