Files
SoulStream/crates/player/src/lib.rs
T

1212 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<f64>,
},
/// 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<Arc<StreamProgress>>,
total_bytes: u64,
duration_hint: Option<f64>,
/// 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<f64>,
},
/// Decode and append the next local track before the active one ends.
Preload {
path: PathBuf,
duration_hint: Option<f64>,
},
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<PauseAck>,
},
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<String>,
reply: mpsc::Sender<Result<(), String>>,
},
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<Arc<StreamProgress>>,
total_bytes: u64,
duration_hint: Option<f64>,
}
struct ReadyDecoder {
generation: u64,
decoder: Decoder<Box<dyn source::MediaRead>>,
track: TrackSource,
preload: bool,
resume_at: Option<Duration>,
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<f64> {
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<f64>, decoded: Option<f64>) -> Option<f64> {
match (hint, decoded) {
(Some(h), Some(d)) => Some(h.max(d)),
(h, d) => h.or(d),
}
}
struct PendingTrack {
duration: Option<f64>,
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<S> {
inner: S,
queued: Arc<AtomicUsize>,
event_tx: mpsc::Sender<PlayerEvent>,
sent: bool,
}
impl<S> Iterator for EndNotifier<S>
where
S: Source,
{
type Item = S::Item;
fn next(&mut self) -> Option<Self::Item> {
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<S> Source for EndNotifier<S>
where
S: Source,
{
fn current_span_len(&self) -> Option<usize> {
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<Duration> {
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<Cmd>,
/// f32 bits; the audio thread mirrors its live fade-gain here for
/// observability (the debug overlay / crossfade diagnostics). 1.0 = full.
fade_report: Arc<std::sync::atomic::AtomicU32>,
/// 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<std::sync::atomic::AtomicU32>,
norm_target_report: Arc<std::sync::atomic::AtomicU32>,
}
impl Player {
/// Spawn the audio thread. Events stream out on the receiver.
pub fn spawn() -> (Player, mpsc::Receiver<PlayerEvent>) {
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<String>,
) -> (Player, mpsc::Receiver<PlayerEvent>) {
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
let (event_tx, event_rx) = mpsc::channel::<PlayerEvent>();
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<PathBuf>, duration_hint: Option<f64>) {
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<PathBuf>,
duration_hint: Option<f64>,
start_at_secs: Option<f64>,
) {
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<PathBuf>, duration_hint: Option<f64>) {
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<PathBuf>,
progress: Arc<StreamProgress>,
total_bytes: u64,
duration_hint: Option<f64>,
) {
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<PauseAck> {
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<String>) -> 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<Vec<String>, String> {
let host = rodio::cpal::default_host();
let mut names: Vec<String> = 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<String> {
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<Duration>,
start_paused: bool,
tx: mpsc::Sender<Result<ReadyDecoder, (u64, String)>>,
) {
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<dyn source::MediaRead>, 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<Cmd>,
event_tx: mpsc::Sender<PlayerEvent>,
initial_device: Option<String>,
fade_report: Arc<std::sync::atomic::AtomicU32>,
norm_now_report: Arc<std::sync::atomic::AtomicU32>,
norm_target_report: Arc<std::sync::atomic::AtomicU32>,
) {
// 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::<Result<ReadyDecoder, (u64, String)>>();
let mut sink: Option<Sink> = None;
let mut generation: u64 = 0;
let mut duration: Option<f64> = 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<f32> = 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<Arc<StreamProgress>> = 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<ReadyDecoder> = 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<PendingTrack> = VecDeque::new();
let mut last_queued: usize = 0;
// Scratch playback is possible only for a stable, complete local file.
let mut local_path: Option<PathBuf> = None;
let mut active_track: Option<TrackSource> = 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<String>, Option<mpsc::Sender<Result<(), String>>>)> =
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<Decoder<Box<dyn source::MediaRead>>, 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<dyn source::MediaRead>)
.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<f32> = 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);
}
}