diff --git a/Cargo.lock b/Cargo.lock index a7522dd..35bd3f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,7 +161,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "base64 0.22.1", "image", @@ -174,6 +174,7 @@ dependencies = [ "serde_json", "soulseek-client", "soulseek-proto", + "sysinfo", "tagger", "tauri", "tauri-build", @@ -699,6 +700,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -1015,6 +1035,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "embed-resource" version = "3.0.11" @@ -2270,7 +2296,7 @@ dependencies = [ [[package]] name = "library" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "log", "rusqlite", @@ -2401,7 +2427,7 @@ dependencies = [ [[package]] name = "metadata" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "futures", "httpdate", @@ -2525,6 +2551,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3042,7 +3077,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "player" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "env_logger", "log", @@ -3301,6 +3336,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4020,7 +4075,7 @@ dependencies = [ [[package]] name = "soulseek-client" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "bytes", "env_logger", @@ -4034,7 +4089,7 @@ dependencies = [ [[package]] name = "soulseek-proto" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "bytes", "flate2", @@ -4358,6 +4413,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.54.0", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -4394,7 +4463,7 @@ dependencies = [ [[package]] name = "tagger" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "lofty", "log", diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 05b340f..b91b5fe 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -6360,19 +6360,15 @@ pub async fn fetch_preview_media(url: &str) -> Result>, 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 { @@ -94,6 +99,20 @@ enum Cmd { 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), SetOutputDevice { device: Option, reply: mpsc::Sender>, @@ -233,11 +252,23 @@ impl Player { } 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), }); } @@ -264,6 +295,7 @@ impl Player { stream: Some(progress), total_bytes, duration_hint, + start_at_secs: None, }); } @@ -301,6 +333,25 @@ impl Player { 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))); + } + /// 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> { @@ -446,6 +497,15 @@ fn audio_thread( 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; + let eff = |user: f32, ndb: f32| user * 10f32.powf(ndb / 20.0); let mut playing = false; // The active stream source, so we can abort its blocking reads. let mut active_stream: Option> = None; @@ -486,6 +546,7 @@ fn audio_thread( stream, total_bytes, duration_hint, + start_at_secs, }) => { // Abort whatever was playing or buffering. generation += 1; @@ -501,6 +562,12 @@ fn audio_thread( 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; let track = TrackSource { path, stream, @@ -513,7 +580,14 @@ fn audio_thread( active_stream = track.stream.clone(); active_track = Some(track.clone()); - start_decoder(track, generation, false, None, false, decoder_tx.clone()); + start_decoder( + track, + generation, + false, + start_at_secs.map(Duration::from_secs_f64), + false, + decoder_tx.clone(), + ); } Ok(Cmd::Preload { path, @@ -598,7 +672,7 @@ fn audio_thread( match decode_scratch_window(path, secs, rate) { Ok((samples, speed)) => { let s = Sink::connect_new(out.mixer()); - s.set_volume(volume); + s.set_volume(eff(volume, norm_db)); s.set_speed(speed); s.append(samples); s.play(); @@ -626,7 +700,7 @@ fn audio_thread( match local_decoder(path) { Ok(decoder) => { let s = Sink::connect_new(out.mixer()); - s.set_volume(volume); + s.set_volume(eff(volume, norm_db)); queued_sources.store(1, Ordering::SeqCst); s.append(EndNotifier { inner: decoder, @@ -657,9 +731,22 @@ fn audio_thread( Ok(Cmd::Volume(v)) => { volume = v; if let Some(s) = &sink { - s.set_volume(v); + s.set_volume(eff(volume, norm_db)); } } + 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)); + } + } + // Non-immediate: the tick arm slews norm_db there. + } + Ok(Cmd::SetNextNormGainDb(db)) => { + pending_norm_db = Some(db); + } Ok(Cmd::SetOutputDevice { device, reply }) => { output_change = Some((device, Some(reply))); } @@ -674,13 +761,25 @@ fn audio_thread( queued_sources.store(0, Ordering::SeqCst); waiting_preload = None; pending_tracks.clear(); + pending_norm_db = None; playing = false; scratching = false; local_path = None; active_track = None; emit(PlayerEvent::Stopped); } - Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Timeout) => { + // Normalization slew: glide toward the analyzer's latest + // target at ~2.5 dB/s (0.15 dB per 60 ms tick) — audible as + // a slow drift at worst, never a jump. + let diff = norm_target_db - norm_db; + if diff.abs() > 0.001 { + norm_db += diff.clamp(-0.15, 0.15); + if let Some(s) = &sink { + s.set_volume(eff(volume, norm_db)); + } + } + } Err(mpsc::RecvTimeoutError::Disconnected) => break, } @@ -783,7 +882,7 @@ fn audio_thread( let track_duration = ready.track_duration(); if !is_preload { let s = Sink::connect_new(out.mixer()); - s.set_volume(volume); + s.set_volume(eff(volume, norm_db)); sink = Some(s); active_track = Some(ready.track.clone()); active_stream = ready.track.stream.clone(); @@ -864,6 +963,14 @@ fn audio_thread( 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)); + } } last_queued = q; diff --git a/crates/player/src/loudness.rs b/crates/player/src/loudness.rs new file mode 100644 index 0000000..2a6957a --- /dev/null +++ b/crates/player/src/loudness.rs @@ -0,0 +1,418 @@ +//! Integrated-loudness measurement (ITU-R BS.1770-4 / EBU R128), the basis +//! of the player's volume normalization: every track is measured in LUFS +//! and played back with a gain that brings it to one target loudness, so a +//! quiet 90s master and a brickwalled modern one come out level. +//! +//! Dependency-free implementation of the standard: per-channel K-weighting +//! (a high-shelf that models head diffraction, then a high-pass), energy +//! collected in 400 ms blocks at 75 % overlap, and two-stage gating +//! (absolute −70 LUFS, then relative −10 LU below the ungated mean). The +//! meter is INCREMENTAL — `push` accepts interleaved frames as they are +//! decoded, and `integrated_lufs()` can be read at any point for the +//! loudness of everything seen so far — which is what lets a Soulseek +//! stream be normalized during its very first play instead of after it. + +/// A biquad in direct form 1, processing one channel. +#[derive(Clone, Copy, Default)] +struct Biquad { + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, + x1: f64, + x2: f64, + y1: f64, + y2: f64, +} + +impl Biquad { + #[inline] + fn process(&mut self, x: f64) -> f64 { + let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2 + - self.a1 * self.y1 + - self.a2 * self.y2; + self.x2 = self.x1; + self.x1 = x; + self.y2 = self.y1; + self.y1 = y; + y + } +} + +/// Stage-1 K-weighting shelf (BS.1770 spherical-head model), designed for +/// an arbitrary sample rate with the reference parameters the standard's +/// 48 kHz table was derived from (the libebur128 derivation). +fn shelf(fs: f64) -> Biquad { + let f0 = 1681.974450955533; + let gain_db = 3.999843853973347; + let q = 0.7071752369554196; + + let k = (std::f64::consts::PI * f0 / fs).tan(); + let vh = 10f64.powf(gain_db / 20.0); + let vb = vh.powf(0.4996667741545416); + let a0 = 1.0 + k / q + k * k; + Biquad { + b0: (vh + vb * k / q + k * k) / a0, + b1: 2.0 * (k * k - vh) / a0, + b2: (vh - vb * k / q + k * k) / a0, + a1: 2.0 * (k * k - 1.0) / a0, + a2: (1.0 - k / q + k * k) / a0, + ..Default::default() + } +} + +/// Stage-2 K-weighting high-pass (rumble removal below ~38 Hz). +fn highpass(fs: f64) -> Biquad { + let f0 = 38.13547087602444; + let q = 0.5003270373238773; + + let k = (std::f64::consts::PI * f0 / fs).tan(); + let a0 = 1.0 + k / q + k * k; + Biquad { + b0: 1.0, + b1: -2.0, + b2: 1.0, + a1: 2.0 * (k * k - 1.0) / a0, + a2: (1.0 - k / q + k * k) / a0, + ..Default::default() + } +} + +const MAX_CHANNELS: usize = 8; +/// Gating block = 400 ms as 4 overlapping 100 ms sub-blocks (75 % overlap). +const SUBBLOCKS_PER_BLOCK: usize = 4; +const ABSOLUTE_GATE_LUFS: f64 = -70.0; + +pub struct LoudnessMeter { + sample_rate: u32, + channels: usize, + shelves: [Biquad; MAX_CHANNELS], + highpasses: [Biquad; MAX_CHANNELS], + /// Samples per 100 ms sub-block (per channel). + subblock_len: usize, + /// Running energy sum of the current sub-block (all channels, weighted). + cur_energy: f64, + cur_count: usize, + /// Completed 100 ms sub-block mean energies. + subblocks: Vec, + /// Pre-filter sample peak, for clipping headroom when boosting. + peak: f32, +} + +impl LoudnessMeter { + pub fn new(sample_rate: u32, channels: usize) -> Self { + let fs = sample_rate.max(1) as f64; + let ch = channels.clamp(1, MAX_CHANNELS); + LoudnessMeter { + sample_rate: sample_rate.max(1), + channels: ch, + shelves: [shelf(fs); MAX_CHANNELS], + highpasses: [highpass(fs); MAX_CHANNELS], + subblock_len: (fs / 10.0).round().max(1.0) as usize, + cur_energy: 0.0, + cur_count: 0, + subblocks: Vec::new(), + peak: 0.0, + } + } + + /// Re-arm the filters for a new spec mid-stream (rare); accumulated + /// blocks are kept — they are already energies, spec-independent. + pub fn rearm(&mut self, sample_rate: u32, channels: usize) { + if sample_rate == self.sample_rate && channels.clamp(1, MAX_CHANNELS) == self.channels { + return; + } + let keep = std::mem::take(&mut self.subblocks); + *self = LoudnessMeter::new(sample_rate, channels); + self.subblocks = keep; + } + + /// Feed interleaved f32 frames. + pub fn push(&mut self, interleaved: &[f32]) { + let ch = self.channels; + for frame in interleaved.chunks_exact(ch) { + let mut frame_energy = 0.0f64; + for (c, &s) in frame.iter().enumerate() { + self.peak = self.peak.max(s.abs()); + let w = self.shelves[c].process(s as f64); + let k = self.highpasses[c].process(w); + // Channel weights: 1.0 (stereo/mono; surround rears would be + // 1.41 — practically all content here is stereo). + frame_energy += k * k; + } + self.cur_energy += frame_energy; + self.cur_count += 1; + if self.cur_count >= self.subblock_len { + self.subblocks.push(self.cur_energy / self.cur_count as f64); + self.cur_energy = 0.0; + self.cur_count = 0; + } + } + } + + /// Seconds of audio consumed so far. + pub fn seconds_seen(&self) -> f64 { + (self.subblocks.len() as f64) * 0.1 + self.cur_count as f64 / self.sample_rate as f64 + } + + pub fn sample_peak(&self) -> f32 { + self.peak + } + + /// Gated integrated loudness of everything pushed so far, or None while + /// there is not yet one full 400 ms block above the absolute gate. + pub fn integrated_lufs(&self) -> Option { + if self.subblocks.len() < SUBBLOCKS_PER_BLOCK { + return None; + } + let block_energy = |i: usize| -> f64 { + self.subblocks[i..i + SUBBLOCKS_PER_BLOCK] + .iter() + .sum::() + / SUBBLOCKS_PER_BLOCK as f64 + }; + let lufs = |e: f64| -0.691 + 10.0 * e.max(f64::MIN_POSITIVE).log10(); + let n_blocks = self.subblocks.len() - SUBBLOCKS_PER_BLOCK + 1; + + // Absolute gate. + let mut sum = 0.0; + let mut count = 0usize; + for i in 0..n_blocks { + let e = block_energy(i); + if lufs(e) > ABSOLUTE_GATE_LUFS { + sum += e; + count += 1; + } + } + if count == 0 { + return None; + } + // Relative gate: −10 LU below the absolute-gated mean. + let relative_gate = lufs(sum / count as f64) - 10.0; + let mut sum2 = 0.0; + let mut count2 = 0usize; + for i in 0..n_blocks { + let e = block_energy(i); + let l = lufs(e); + if l > ABSOLUTE_GATE_LUFS && l > relative_gate { + sum2 += e; + count2 += 1; + } + } + if count2 == 0 { + return None; + } + Some(lufs(sum2 / count2 as f64)) + } +} + +// ------------------------------------------------------------ analyzer ---- + +use std::path::PathBuf; +use std::sync::Arc; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::{MediaSourceStream, ReadOnlySource}; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +use crate::source::{GrowingFile, StreamProgress}; + +/// What the loudness analyzer reads. `Growing` is the streaming case: the +/// same blocking growing-file reader playback uses, so analysis proceeds at +/// download speed — normally far ahead of the play cursor — and the FIRST +/// play of a stream converges on its true loudness within seconds. +pub enum AnalysisSource { + Local(PathBuf), + Growing { + path: PathBuf, + total: u64, + progress: Arc, + }, +} + +/// How often (in analyzed audio time) partial measurements are published. +/// 1 s keeps the play-gate responsive — the caller decides when it has +/// heard enough to commit a pre-playback gain. +const PUBLISH_EVERY_SECS: f64 = 1.0; + +/// Decode `source` from the top and measure it. `on_update` receives +/// (partial LUFS, peak, seconds analyzed) roughly every 5 s of audio; +/// returns the final (integrated LUFS, peak) at EOF, or None on decode +/// failure / cancellation / too little audio. Blocking — run on a worker +/// thread. An aborted stream unblocks the reader and lands here as EOF. +pub fn analyze_loudness( + source: AnalysisSource, + cancelled: &dyn Fn() -> bool, + on_update: &mut dyn FnMut(f64, f32, f64), +) -> Option<(f64, f32)> { + let (mss, path) = match source { + AnalysisSource::Local(path) => { + let file = std::fs::File::open(&path).ok()?; + ( + MediaSourceStream::new(Box::new(file), Default::default()), + path, + ) + } + AnalysisSource::Growing { + path, + total, + progress, + } => { + let growing = GrowingFile::open(&path, total, progress).ok()?; + ( + MediaSourceStream::new(Box::new(ReadOnlySource::new(growing)), Default::default()), + path, + ) + } + }; + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + // Stream partials are "..partial" — hint the codec ext. + let ext = if ext.eq_ignore_ascii_case("partial") { + path.file_stem() + .map(std::path::Path::new) + .and_then(|p| p.extension()) + .and_then(|e| e.to_str()) + .unwrap_or(ext) + } else { + ext + }; + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .ok()?; + let mut format = probed.format; + let track = format.default_track()?; + let track_id = track.id; + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .ok()?; + + let mut sample_buf: Option> = None; + let mut meter: Option = None; + let mut last_published = 0.0f64; + + loop { + if cancelled() { + return None; + } + let packet = match format.next_packet() { + Ok(p) => p, + Err(_) => break, // EOF (or aborted stream) → finish with what we have + }; + if packet.track_id() != track_id { + continue; + } + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(_) => break, + }; + let spec = *decoded.spec(); + let channels = spec.channels.count().max(1); + match &mut meter { + None => meter = Some(LoudnessMeter::new(spec.rate, channels)), + Some(m) => m.rearm(spec.rate, channels), + } + if sample_buf.is_none() { + sample_buf = Some(SampleBuffer::new(decoded.capacity() as u64, spec)); + } + let sb = sample_buf.as_mut().unwrap(); + sb.copy_interleaved_ref(decoded); + let m = meter.as_mut().unwrap(); + m.push(sb.samples()); + + let secs = m.seconds_seen(); + if secs - last_published >= PUBLISH_EVERY_SECS { + if let Some(lufs) = m.integrated_lufs() { + on_update(lufs, m.sample_peak(), secs); + } + last_published = secs; + } + } + + let m = meter?; + m.integrated_lufs().map(|l| (l, m.sample_peak())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sine(freq: f64, fs: u32, secs: f64, amplitude: f64, channels: usize) -> Vec { + let frames = (fs as f64 * secs) as usize; + let mut out = Vec::with_capacity(frames * channels); + for n in 0..frames { + let s = (amplitude + * (2.0 * std::f64::consts::PI * freq * n as f64 / fs as f64).sin()) + as f32; + for _ in 0..channels { + out.push(s); + } + } + out + } + + /// BS.1770's own reference point: a full-scale 997 Hz sine reads + /// −3.01 LUFS (per channel of a stereo pair it sums +3 dB, so use mono). + #[test] + fn full_scale_sine_reads_reference_level() { + let mut m = LoudnessMeter::new(48_000, 1); + m.push(&sine(997.0, 48_000, 5.0, 1.0, 1)); + let l = m.integrated_lufs().expect("enough audio"); + assert!((l - (-3.01)).abs() < 0.1, "got {l}"); + } + + /// −20 dB quieter input must read 20 LU lower — the meter is linear in + /// level, which is what makes gain = target − measured valid. + #[test] + fn level_linearity() { + let mut loud = LoudnessMeter::new(44_100, 2); + loud.push(&sine(997.0, 44_100, 4.0, 0.5, 2)); + let mut quiet = LoudnessMeter::new(44_100, 2); + quiet.push(&sine(997.0, 44_100, 4.0, 0.05, 2)); + let dl = loud.integrated_lufs().unwrap(); + let dq = quiet.integrated_lufs().unwrap(); + assert!(((dl - dq) - 20.0).abs() < 0.2, "loud {dl} quiet {dq}"); + } + + /// Silence padding must not drag the integrated value down — that is + /// the absolute gate doing its job. + #[test] + fn silence_is_gated_out() { + let mut padded = LoudnessMeter::new(48_000, 1); + padded.push(&sine(997.0, 48_000, 3.0, 0.25, 1)); + padded.push(&vec![0.0f32; 48_000 * 3]); + let mut bare = LoudnessMeter::new(48_000, 1); + bare.push(&sine(997.0, 48_000, 3.0, 0.25, 1)); + let lp = padded.integrated_lufs().unwrap(); + let lb = bare.integrated_lufs().unwrap(); + assert!((lp - lb).abs() < 0.3, "padded {lp} vs bare {lb}"); + } + + #[test] + fn peak_tracks_prefilter_samples() { + let mut m = LoudnessMeter::new(48_000, 2); + m.push(&sine(440.0, 48_000, 1.0, 0.8, 2)); + assert!((m.sample_peak() - 0.8).abs() < 0.01); + } + + #[test] + fn too_little_audio_reads_none() { + let mut m = LoudnessMeter::new(48_000, 2); + m.push(&sine(997.0, 48_000, 0.2, 0.5, 2)); + assert!(m.integrated_lufs().is_none()); + } +} diff --git a/crates/player/src/source.rs b/crates/player/src/source.rs index f0c0947..78cce46 100644 --- a/crates/player/src/source.rs +++ b/crates/player/src/source.rs @@ -42,6 +42,12 @@ impl StreamProgress { self.cv.notify_all(); } + /// Current (received bytes, done) — diagnostics/observability readout. + pub fn received_done(&self) -> (u64, bool) { + let st = self.state.lock().unwrap(); + (st.received, st.done) + } + /// Unblock any waiting reads permanently (track switched / stopped). pub fn abort(&self) { let mut st = self.state.lock().unwrap(); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3655fe..cb75114 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ tokio.workspace = true base64 = "0.22" keyring = { version = "3", features = ["windows-native"] } +sysinfo = "0.33" soulseek-proto = { path = "../crates/soulseek-proto" } soulseek-client = { path = "../crates/soulseek-client" } player = { path = "../crates/player" } @@ -41,5 +42,9 @@ windows = { version = "0.61.3", features = [ "Media_FaceAnalysis", "Media_Ocr", "Storage_Streams", + "Win32_Foundation", + "Win32_System_Performance", + "Win32_System_ProcessStatus", + "Win32_System_Threading", "Win32_System_WinRT", ] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6a1ed0c..dc225c3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -75,6 +75,7 @@ pub struct Settings { pub stream_cache_max_mb: u64, pub stream_source_prefs: config::SourcePrefs, pub download_source_prefs: config::SourcePrefs, + pub volume_normalization: bool, } #[tauri::command] @@ -112,6 +113,7 @@ pub async fn get_settings( stream_cache_max_mb: config::stream_cache_max_mb(&cfg), stream_source_prefs: cfg.stream_source_prefs, download_source_prefs: cfg.download_source_prefs, + volume_normalization: config::volume_normalization(&cfg), username: cfg.username, }) } @@ -138,6 +140,7 @@ pub async fn set_settings( stream_cache_max_mb: Option, stream_source_prefs: Option, download_source_prefs: Option, + volume_normalization: Option, ) -> Result<(), String> { let mut cfg = config::load(&app); if let Some(d) = download_dir { @@ -197,6 +200,12 @@ pub async fn set_settings( if stream_cache_max_mb.is_some() { cfg.stream_cache_max_mb = stream_cache_max_mb; } + if let Some(enabled) = volume_normalization { + cfg.volume_normalization = Some(enabled); + // Applies live: off glides the current track back to unity; on + // takes effect from the next track. + crate::loudness::set_enabled(&app, enabled); + } config::save(&app, &cfg); // Tighter limits take effect right away, not at the next stream/startup. @@ -538,6 +547,12 @@ pub async fn connect_and_login( }), ); if matches!(&update.state, DS::Complete { .. }) { + // Gapless normalization: measure the next + // track now so its gain is ready at the + // boundary. + if let Some(c) = &cache { + crate::loudness::on_preload_ready(&event_app, c); + } // Only explicit saves reach the library — // unless streamed tracks are configured to // be kept forever. @@ -3272,7 +3287,20 @@ pub async fn play_stream( if let Ok(md) = std::fs::metadata(&cache) { if size > 0 && md.len() == size { playback::touch(&cache); - players.player.play_local(&cache, duration); + crate::diagnostics::clear_stream(&app); + let app2 = app.clone(); + let cache2 = cache.clone(); + crate::loudness::on_track_started( + &app, + &cache, + size, + None, + Box::new(move || { + app2.state::() + .player + .play_local(&cache2, duration); + }), + ); return Ok(()); } let _ = std::fs::remove_file(&cache); @@ -3300,9 +3328,21 @@ pub async fn play_stream( .lock() .unwrap() .insert(id, cache.clone()); - players - .player - .play_stream(&partial, progress, size, duration); + crate::diagnostics::note_stream(&app, size, progress.clone()); + let app2 = app.clone(); + let partial2 = partial.clone(); + let progress2 = progress.clone(); + crate::loudness::on_track_started( + &app, + &cache, + size, + Some((partial, progress)), + Box::new(move || { + app2.state::() + .player + .play_stream(&partial2, progress2, size, duration); + }), + ); return Ok(()); } @@ -3321,9 +3361,21 @@ pub async fn play_stream( .unwrap() .insert(id, cache.clone()); - players - .player - .play_stream(&partial, progress, size, duration); + crate::diagnostics::note_stream(&app, size, progress.clone()); + let app2 = app.clone(); + let partial2 = partial.clone(); + let progress2 = progress.clone(); + crate::loudness::on_track_started( + &app, + &cache, + size, + Some((partial, progress)), + Box::new(move || { + app2.state::() + .player + .play_stream(&partial2, progress2, size, duration); + }), + ); Ok(()) } @@ -3342,6 +3394,8 @@ pub async fn preload_stream( let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; if let Ok(md) = std::fs::metadata(&cache) { if size > 0 && md.len() == size { + // Gapless normalization: boundary gain ready before the switch. + crate::loudness::on_preload_ready(&app, &cache); return Ok(()); // already fully cached } let _ = std::fs::remove_file(&cache); @@ -3383,6 +3437,8 @@ pub fn preload_cached_stream( Ok(md) if size == 0 || md.len() == size => { playback::touch(&cache); players.player.preload_local(&cache, duration); + // Gapless normalization: boundary gain ready before the switch. + crate::loudness::on_preload_ready(&app, &cache); Ok(true) } _ => Ok(false), @@ -3485,14 +3541,25 @@ pub async fn stream_waveform( /// Play an already-downloaded local file. #[tauri::command] pub fn play_local( - players: State<'_, PlayerState>, + app: tauri::AppHandle, path: String, duration: Option, ) -> Result<(), String> { if std::fs::metadata(&path).is_err() { return Err("file not found".into()); } - players.player.play_local(&path, duration); + crate::diagnostics::clear_stream(&app); + let app2 = app.clone(); + let path2 = path.clone(); + crate::loudness::on_track_started( + &app, + std::path::Path::new(&path), + 0, + None, + Box::new(move || { + app2.state::().player.play_local(&path2, duration); + }), + ); Ok(()) } @@ -3500,6 +3567,7 @@ pub fn play_local( /// used for gapless album playback; it has no effect on streaming downloads. #[tauri::command] pub fn preload_local( + app: tauri::AppHandle, players: State<'_, PlayerState>, path: String, duration: Option, @@ -3508,6 +3576,8 @@ pub fn preload_local( return Err("file not found".into()); } players.player.preload_local(&path, duration); + // Gapless normalization: have the boundary gain ready before the switch. + crate::loudness::on_preload_ready(&app, std::path::Path::new(&path)); Ok(()) } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 141928c..b72be36 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -129,6 +129,10 @@ pub struct AppConfig { /// Some(0) → unlimited. #[serde(default)] pub stream_cache_max_mb: Option, + /// Loudness normalization (−14 LUFS target, BS.1770 measurement): + /// every track plays at roughly the same perceived level. None → true. + #[serde(default)] + pub volume_normalization: Option, } /// Default longest edge for embedded covers when the user hasn't chosen. @@ -166,6 +170,10 @@ pub fn stream_cache_max_mb(cfg: &AppConfig) -> u64 { .unwrap_or(DEFAULT_STREAM_CACHE_MAX_MB) } +pub fn volume_normalization(cfg: &AppConfig) -> bool { + cfg.volume_normalization.unwrap_or(true) +} + /// One-time migration after the identifier rename: the app-data dir moved /// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename /// the old dir into place so the library DB, config and stream cache diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs new file mode 100644 index 0000000..a5ba7f3 --- /dev/null +++ b/src-tauri/src/diagnostics.rs @@ -0,0 +1,281 @@ +//! Dev diagnostics for the debug overlay (Ctrl+Shift+D): system + process +//! CPU, memory, GPU, network rates, and the loudness-normalization state of +//! the active track. Polled by the frontend only while the overlay is +//! visible — nothing here runs otherwise. +//! +//! Process figures aggregate the WHOLE process tree: the WebView2 children +//! do the actual rendering, so app.exe alone would tell a comforting lie. + +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use serde::Serialize; +use sysinfo::{Networks, Pid, ProcessRefreshKind, ProcessesToUpdate, System}; +use tauri::{Manager, State}; + +use player::StreamProgress; + +pub struct DiagState(Mutex); + +struct Inner { + sys: System, + networks: Networks, + last_net: Instant, + gpu: Option, + started: Instant, + /// The ACTIVE track's transfer, straight from the player's own + /// StreamProgress — the overlay must read download truth at the source, + /// not the UI store's window-activity-gated mirror. + current_stream: Option<(u64, Arc)>, +} + +/// Called by the play paths: this track streams from a peer. +pub fn note_stream(app: &tauri::AppHandle, total: u64, progress: Arc) { + if let Some(state) = app.try_state::() { + state.0.lock().expect("diag lock").current_stream = Some((total, progress)); + } +} + +/// Called by the play paths: this track plays from a complete local file. +pub fn clear_stream(app: &tauri::AppHandle) { + if let Some(state) = app.try_state::() { + state.0.lock().expect("diag lock").current_stream = None; + } +} + +pub fn init(app: &tauri::AppHandle) { + app.manage(DiagState(Mutex::new(Inner { + sys: System::new(), + networks: Networks::new_with_refreshed_list(), + last_net: Instant::now(), + gpu: GpuCounter::open(), + started: Instant::now(), + current_stream: None, + }))); +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugStats { + /// Whole-process-tree CPU, percent of the whole machine. + pub cpu_process: f32, + pub cpu_system: f32, + /// GPU 3D-engine utilization (Task-Manager-style), if counters opened. + pub gpu_percent: Option, + /// Process tree working set / private commit, bytes. + pub mem_working_set: u64, + pub mem_private: u64, + pub mem_system_used: u64, + pub mem_system_total: u64, + /// System-wide network rates since the previous poll, bytes/sec. + pub net_rx_bps: f64, + pub net_tx_bps: f64, + pub process_count: usize, + pub uptime_secs: u64, + /// Active track's transfer, from the player's own progress handle. + pub dl_received: Option, + pub dl_total: Option, + pub dl_done: Option, + pub normalization_enabled: bool, + pub target_lufs: f64, + pub loudness: Option, +} + +#[tauri::command] +pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugStats { + let mut inner = state.0.lock().expect("diag lock"); + let inner = &mut *inner; + + inner.sys.refresh_cpu_usage(); + inner.sys.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing().with_cpu().with_memory(), + ); + + // Our process tree: app.exe + every descendant (the WebView2 family). + let me = Pid::from_u32(std::process::id()); + let mut tree = vec![me]; + let mut grew = true; + while grew { + grew = false; + for (pid, proc_) in inner.sys.processes() { + if let Some(parent) = proc_.parent() { + if tree.contains(&parent) && !tree.contains(pid) { + tree.push(*pid); + grew = true; + } + } + } + } + let cores = inner.sys.cpus().len().max(1) as f32; + let mut cpu_process = 0.0f32; + let mut mem_working_set = 0u64; + let mut mem_private = 0u64; + for pid in &tree { + if let Some(p) = inner.sys.process(*pid) { + cpu_process += p.cpu_usage(); // % of one core + mem_working_set += p.memory(); + // sysinfo's virtual_memory() is the raw address space on Windows + // (WebView reserves terabytes of it) — useless. The meaningful + // "reserved" figure is the private commit, straight from Win32. + mem_private += private_commit(pid.as_u32()); + } + } + cpu_process /= cores; + + inner.sys.refresh_memory(); + + // Network: sysinfo reports bytes since the LAST refresh per interface. + let elapsed = inner.last_net.elapsed().as_secs_f64().max(0.05); + inner.last_net = Instant::now(); + inner.networks.refresh(true); + let (mut rx, mut tx) = (0u64, 0u64); + for (_, data) in inner.networks.iter() { + rx += data.received(); + tx += data.transmitted(); + } + + let (normalization_enabled, target_lufs, loudness) = crate::loudness::snapshot(&app); + let (dl_received, dl_total, dl_done) = match &inner.current_stream { + Some((total, progress)) => { + let (received, done) = progress.received_done(); + (Some(received), Some(*total), Some(done)) + } + None => (None, None, None), + }; + + DebugStats { + cpu_process, + cpu_system: inner.sys.global_cpu_usage(), + gpu_percent: inner.gpu.as_mut().and_then(|g| g.sample()), + mem_working_set, + mem_private, + mem_system_used: inner.sys.used_memory(), + mem_system_total: inner.sys.total_memory(), + net_rx_bps: rx as f64 / elapsed, + net_tx_bps: tx as f64 / elapsed, + process_count: tree.len(), + uptime_secs: inner.started.elapsed().as_secs(), + dl_received, + dl_total, + dl_done, + normalization_enabled, + target_lufs, + loudness, + } +} + +/// Private commit ("reserved") of one process, bytes. 0 if unreadable. +fn private_commit(pid: u32) -> u64 { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::ProcessStatus::{ + K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX, + }; + use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; + unsafe { + let Ok(handle) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else { + return 0; + }; + let mut counters = PROCESS_MEMORY_COUNTERS_EX::default(); + let ok = K32GetProcessMemoryInfo( + handle, + (&mut counters as *mut PROCESS_MEMORY_COUNTERS_EX).cast::(), + std::mem::size_of::() as u32, + ) + .as_bool(); + let _ = CloseHandle(handle); + if ok { + counters.PrivateUsage as u64 + } else { + 0 + } + } +} + +// ------------------------------------------------------------ GPU (PDH) --- + +/// Total 3D-engine utilization via the PDH "GPU Engine" counter set — the +/// number Task Manager labels "GPU". Instances are per-process-per-engine; +/// summing the 3D ones gives machine-wide utilization. First sample after +/// opening reads as None (PDH rate counters need two collections). +struct GpuCounter { + query: windows::Win32::System::Performance::PDH_HQUERY, + counter: windows::Win32::System::Performance::PDH_HCOUNTER, +} + +// PDH handles are plain registry-ish handles; the query is only touched +// under the DiagState mutex. +unsafe impl Send for GpuCounter {} + +impl GpuCounter { + fn open() -> Option { + use windows::core::w; + use windows::Win32::System::Performance::{ + PdhAddEnglishCounterW, PdhOpenQueryW, PDH_HCOUNTER, PDH_HQUERY, + }; + unsafe { + let mut query = PDH_HQUERY::default(); + if PdhOpenQueryW(None, 0, &mut query) != 0 { + return None; + } + let mut counter = PDH_HCOUNTER::default(); + let path = w!("\\GPU Engine(*engtype_3D)\\Utilization Percentage"); + if PdhAddEnglishCounterW(query, path, 0, &mut counter) != 0 { + return None; + } + Some(GpuCounter { query, counter }) + } + } + + fn sample(&mut self) -> Option { + use windows::Win32::System::Performance::{ + PdhCollectQueryData, PdhGetFormattedCounterArrayW, PDH_FMT_COUNTERVALUE_ITEM_W, + PDH_FMT_DOUBLE, + }; + unsafe { + if PdhCollectQueryData(self.query) != 0 { + return None; + } + let mut buf_len: u32 = 0; + let mut count: u32 = 0; + // Size probe returns PDH_MORE_DATA; then fetch. + let _ = PdhGetFormattedCounterArrayW( + self.counter, + PDH_FMT_DOUBLE, + &mut buf_len, + &mut count, + None, + ); + if buf_len == 0 { + return None; + } + let mut buf = vec![0u8; buf_len as usize]; + let items = buf.as_mut_ptr() as *mut PDH_FMT_COUNTERVALUE_ITEM_W; + if PdhGetFormattedCounterArrayW( + self.counter, + PDH_FMT_DOUBLE, + &mut buf_len, + &mut count, + Some(items), + ) != 0 + { + return None; + } + let mut total = 0.0f64; + for i in 0..count as usize { + total += (*items.add(i)).FmtValue.Anonymous.doubleValue; + } + Some(total.min(100.0) as f32) + } + } +} + +impl Drop for GpuCounter { + fn drop(&mut self) { + use windows::Win32::System::Performance::PdhCloseQuery; + unsafe { + let _ = PdhCloseQuery(self.query); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 618d547..901f26d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ mod commands; mod config; mod cover_logo; +mod diagnostics; mod hero_crop; mod hero_detector; +mod loudness; mod pipeline; mod playback; mod preview; @@ -31,6 +33,8 @@ pub fn run() { playback::init(app.handle()); playback::prune_cache(app.handle()); preview::init(app.handle()); + loudness::init(app.handle()); + diagnostics::init(app.handle()); if let Err(e) = pipeline::init(app.handle()) { log::error!("library init failed: {e}"); } @@ -108,6 +112,9 @@ pub fn run() { preview::preview_control, preview::preview_dispose, preview::player_pause_acked, + preview::player_fade_pause_acked, + preview::player_fade_resume, + diagnostics::debug_stats, ]) .run({ #[allow(unused_mut)] diff --git a/src-tauri/src/loudness.rs b/src-tauri/src/loudness.rs new file mode 100644 index 0000000..79ac57a --- /dev/null +++ b/src-tauri/src/loudness.rs @@ -0,0 +1,421 @@ +//! Volume normalization — the app-side orchestration over the player +//! crate's BS.1770 meter. Every main-player track is measured and played +//! with a gain that brings it to one target loudness (−14 LUFS, Spotify's +//! reference), so track-to-track volume stops being all over the place. +//! +//! Streaming-first: an unmeasured track is analyzed DURING its first play, +//! reading the same growing partial file the player streams from — analysis +//! runs at download speed, so the true integrated loudness usually lands +//! within seconds and the gain glides there (the player slew-limits it). +//! Measured tracks live in `loudness-cache.json`; their exact gain is +//! applied before the first sample on every later play. +//! +//! Toggleable via config `volume_normalization` (default on). Preview clips +//! are deliberately NOT normalized — Deezer masters them consistently. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde::{Deserialize, Serialize}; +use tauri::Manager; + +use player::{analyze_loudness, AnalysisSource, StreamProgress}; + +use crate::playback::PlayerState; + +/// Playback target (Spotify's default reference level). +pub const TARGET_LUFS: f64 = -14.0; +/// Gain clamp: quiet-track boosts are the risky direction (headroom), loud +/// tracks can always be cut. +const MIN_GAIN_DB: f64 = -15.0; +const MAX_GAIN_DB: f64 = 9.0; +/// Boosts additionally respect the track's own sample peak up to this +/// ceiling — normalization must never introduce clipping. +const PEAK_CEILING: f64 = 0.98; + +const CACHE_FILE: &str = "loudness-cache.json"; +/// Entries beyond this are dropped oldest-first at save time. +const CACHE_CAP: usize = 4000; + +#[derive(Serialize, Deserialize, Clone, Copy)] +pub struct Measured { + pub lufs: f64, + pub peak: f32, + /// File size when measured — a different size is a different file. + pub size: u64, + pub at_epoch_ms: i64, +} + +/// Live view of the ACTIVE track's normalization, for the debug overlay. +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub struct CurrentTrack { + pub lufs: Option, + pub peak: f32, + pub gain_db: f64, + /// The gain came from the cache (exact, applied pre-sample). + pub cached: bool, + /// The analyzer is still measuring this play. + pub analyzing: bool, +} + +pub struct LoudnessState { + cache: Mutex>, + /// Bumped on every track start; orphans older analyzer threads. + generation: Arc, + enabled: AtomicBool, + /// Debug-overlay mirror of the active track's measurement state. + current: Mutex>, +} + +/// (normalization enabled, target LUFS, active-track state) for diagnostics. +pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option) { + let state = app.state::(); + let current = *state.current.lock().expect("loudness current lock"); + ( + state.enabled.load(Ordering::Relaxed), + TARGET_LUFS, + current, + ) +} + +fn cache_file(app: &tauri::AppHandle) -> Option { + app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE)) +} + +pub fn init(app: &tauri::AppHandle) { + let cache: HashMap = cache_file(app) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let enabled = crate::config::volume_normalization(&crate::config::load(app)); + app.manage(LoudnessState { + cache: Mutex::new(cache), + generation: Arc::new(AtomicU64::new(0)), + enabled: AtomicBool::new(enabled), + current: Mutex::new(None), + }); +} + +fn save_cache(app: &tauri::AppHandle, state: &LoudnessState) { + let Some(path) = cache_file(app) else { return }; + let mut entries: Vec<(String, Measured)> = state + .cache + .lock() + .expect("loudness cache lock") + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + if entries.len() > CACHE_CAP { + entries.sort_by_key(|(_, m)| std::cmp::Reverse(m.at_epoch_ms)); + entries.truncate(CACHE_CAP); + } + let map: HashMap<&String, &Measured> = entries.iter().map(|(k, v)| (k, v)).collect(); + if let Ok(json) = serde_json::to_string(&map) { + let _ = std::fs::write(path, json); + } +} + +/// gain = target − measured, clamped, and boosts capped so the track's own +/// peak never exceeds the ceiling. +fn gain_db(m: &Measured) -> f64 { + let mut g = (TARGET_LUFS - m.lufs).clamp(MIN_GAIN_DB, MAX_GAIN_DB); + if g > 0.0 && m.peak > 0.0 { + let headroom = 20.0 * (PEAK_CEILING / m.peak as f64).log10(); + g = g.min(headroom.max(0.0)); + } + g +} + +/// Toggle from settings: applies to the CURRENT track immediately (gain to +/// unity when turning off; the cached gain back on when turning on is left +/// to the next track — the analyzer generation for the current one is gone). +pub fn set_enabled(app: &tauri::AppHandle, enabled: bool) { + let state = app.state::(); + state.enabled.store(enabled, Ordering::Relaxed); + if !enabled { + state.generation.fetch_add(1, Ordering::Relaxed); // orphan analyzers + *state.current.lock().expect("loudness current lock") = None; + app.state::().player.set_norm_gain_db(0.0, false); + } +} + +/// Pre-playback measurement window: the audible start is HELD until this +/// much audio is analyzed (at download/disk speed — normally well under a +/// second of wall time)… +const PRELIM_MIN_SECS: f64 = 10.0; +/// …or until this much wall time passes after the FIRST measurement lands +/// (slow peer): then we commit to what we have. Either way the gain is set +/// BEFORE the first audible sample and NEVER changes mid-track (user +/// decision 2026-07-23 — the earlier mid-play glide was audible and bad). +const PRELIM_WALL_BUDGET: std::time::Duration = std::time::Duration::from_millis(800); +/// A head estimate can under-read a track that opens quietly, so +/// preliminary BOOSTS are capped harder than cached ones; cuts are trusted +/// (a loud head is reliably loud). +const PRELIM_MAX_BOOST_DB: f64 = 3.0; + +fn prelim_gain_db(m: &Measured) -> f64 { + gain_db(m).min(PRELIM_MAX_BOOST_DB) +} + +fn store_measurement(app: &tauri::AppHandle, key: &str, lufs: f64, peak: f32, size_hint: u64) { + let final_size = std::fs::metadata(key).map(|m| m.len()).unwrap_or(size_hint); + let measured = Measured { + lufs, + peak, + size: final_size, + at_epoch_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0), + }; + let state = app.state::(); + state + .cache + .lock() + .expect("loudness cache lock") + .insert(key.to_owned(), measured); + save_cache(app, &state); +} + +/// The hook every main-player track start funnels through. `play` is the +/// DEFERRED start of playback: a cache hit or disabled normalization fires +/// it immediately; an unmeasured track holds it for the preliminary +/// measurement window so the gain is right from the very first sample. +/// The analyzer then keeps running silently to EOF — only to fill the +/// cache, never to touch the volume of the play in progress. +pub fn on_track_started( + app: &tauri::AppHandle, + cache_path: &Path, + size: u64, + stream: Option<(PathBuf, Arc)>, + play: Box, +) { + let state = app.state::(); + let generation = state.generation.fetch_add(1, Ordering::Relaxed) + 1; + if !state.enabled.load(Ordering::Relaxed) { + *state.current.lock().expect("loudness current lock") = None; + play(); + return; // player resets to unity on Play + } + let key = cache_path.to_string_lossy().into_owned(); + + // Known file → play at once with the exact gain (the set lands behind + // Play on the ordered command channel, before any sink exists). + let known_size = if size > 0 { + size + } else { + std::fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0) + }; + if let Some(m) = state.cache.lock().expect("loudness cache lock").get(&key) { + if m.size == known_size || known_size == 0 { + let g = gain_db(m); + play(); + app.state::() + .player + .set_norm_gain_db(g as f32, true); + *state.current.lock().expect("loudness current lock") = Some(CurrentTrack { + lufs: Some(m.lufs), + peak: m.peak, + gain_db: g, + cached: true, + analyzing: false, + }); + return; + } + } + *state.current.lock().expect("loudness current lock") = Some(CurrentTrack { + lufs: None, + peak: 0.0, + gain_db: 0.0, + cached: false, + analyzing: true, + }); + + // Unmeasured → the analyzer races ahead of playback (download/disk + // speed) and RELEASES the held play once the preliminary window is met. + let app = app.clone(); + let gen_ctr = state.generation.clone(); + std::thread::Builder::new() + .name("loudness-analysis".into()) + .spawn(move || { + let source = match stream { + Some((partial, progress)) => AnalysisSource::Growing { + path: partial, + total: size, + progress, + }, + None => AnalysisSource::Local(PathBuf::from(&key)), + }; + let cancelled = || gen_ctr.load(Ordering::Relaxed) != generation; + let mut play_slot = Some(play); + let mut applied_db: Option = None; + let mut first_measure: Option = None; + { + let app = &app; + let cancelled = &cancelled; + let mut on_update = |lufs: f64, peak: f32, secs: f64| { + if cancelled() || play_slot.is_none() { + return; // started already: NO further volume touches + } + let first = *first_measure.get_or_insert_with(std::time::Instant::now); + if secs < PRELIM_MIN_SECS && first.elapsed() < PRELIM_WALL_BUDGET { + return; // keep listening a moment longer + } + let m = Measured { + lufs, + peak, + size: 0, + at_epoch_ms: 0, + }; + let g = prelim_gain_db(&m); + (play_slot.take().expect("gate fires once"))(); + app.state::() + .player + .set_norm_gain_db(g as f32, true); + applied_db = Some(g); + *app.state::() + .current + .lock() + .expect("loudness current lock") = Some(CurrentTrack { + lufs: Some(lufs), + peak, + gain_db: g, + cached: false, + analyzing: true, + }); + }; + let done = analyze_loudness(source, cancelled, &mut on_update); + + // Gate never fired (clip shorter than a gating block, decode + // failure, dead transfer): start at unity — unless a newer + // track superseded this one, then never start it at all. + if let Some(play) = play_slot.take() { + if !cancelled() { + play(); + } + } + let Some((lufs, peak)) = done else { return }; + // The full measurement is valid for every future play of + // this file, whatever happened to this one. + store_measurement(app, &key, lufs, peak, size); + if !cancelled() { + *app.state::() + .current + .lock() + .expect("loudness current lock") = Some(CurrentTrack { + lufs: Some(lufs), + peak, + gain_db: applied_db.unwrap_or(0.0), + cached: false, + analyzing: false, + }); + } + } + }) + .expect("spawn loudness analysis"); +} + +/// Gapless normalization: called when the NEXT track's preload is complete +/// (or was already cached on disk). Hands the boundary gain to the audio +/// thread, which applies it sample-accurately when the gapless switch +/// happens — a track change is the one place loudness may step. Guarded by +/// the loudness generation: a user-initiated play supersedes any pending +/// preload analysis (the player also clears its pending gain on Play/Stop). +pub fn on_preload_ready(app: &tauri::AppHandle, cache_path: &Path) { + let state = app.state::(); + if !state.enabled.load(Ordering::Relaxed) { + return; + } + let generation = state.generation.load(Ordering::Relaxed); + let key = cache_path.to_string_lossy().into_owned(); + let known_size = std::fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0); + if let Some(m) = state.cache.lock().expect("loudness cache lock").get(&key) { + if m.size == known_size || known_size == 0 { + app.state::() + .player + .set_next_norm_gain_db(gain_db(m) as f32); + return; + } + } + let app = app.clone(); + let gen_ctr = state.generation.clone(); + std::thread::Builder::new() + .name("loudness-preload".into()) + .spawn(move || { + let done = analyze_loudness( + AnalysisSource::Local(PathBuf::from(&key)), + &|| false, + &mut |_, _, _| {}, + ); + let Some((lufs, peak)) = done else { return }; + store_measurement(&app, &key, lufs, peak, 0); + // Only hand the boundary gain over if no new track started + // since — a stale preload must not color a different queue. + if gen_ctr.load(Ordering::Relaxed) == generation { + let m = Measured { + lufs, + peak, + size: 0, + at_epoch_ms: 0, + }; + app.state::() + .player + .set_next_norm_gain_db(gain_db(&m) as f32); + } + }) + .expect("spawn preload loudness analysis"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn m(lufs: f64, peak: f32) -> Measured { + Measured { + lufs, + peak, + size: 0, + at_epoch_ms: 0, + } + } + + #[test] + fn quiet_tracks_boost_toward_target() { + assert!((gain_db(&m(-20.0, 0.3)) - 6.0).abs() < 1e-9); + } + + #[test] + fn loud_tracks_cut_toward_target() { + assert!((gain_db(&m(-8.0, 1.0)) - (-6.0)).abs() < 1e-9); + } + + #[test] + fn boost_is_capped_by_peak_headroom() { + // Target wants +9 (clamped), but a 0.9 peak only has ~0.73 dB of + // headroom to the 0.98 ceiling. + let g = gain_db(&m(-30.0, 0.9)); + assert!(g < 0.8 && g > 0.6, "got {g}"); + } + + #[test] + fn cuts_ignore_peak() { + assert!((gain_db(&m(-8.0, 0.2)) - (-6.0)).abs() < 1e-9); + } + + #[test] + fn extreme_measurements_stay_clamped() { + assert_eq!(gain_db(&m(-60.0, 0.0001)), MAX_GAIN_DB); + assert_eq!(gain_db(&m(20.0, 1.0)), MIN_GAIN_DB); + } + + #[test] + fn preliminary_boosts_are_capped_but_cuts_pass_through() { + // A quiet head would ask +6 dB; the head estimate only gets +3. + assert!((prelim_gain_db(&m(-20.0, 0.3)) - PRELIM_MAX_BOOST_DB).abs() < 1e-9); + // Cuts are trusted at full strength — a loud head is reliably loud. + assert!((prelim_gain_db(&m(-8.0, 1.0)) - (-6.0)).abs() < 1e-9); + } +} diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs index 9ca0e60..fe0de12 100644 --- a/src-tauri/src/preview.rs +++ b/src-tauri/src/preview.rs @@ -14,8 +14,8 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; +use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde::Serialize; @@ -30,6 +30,15 @@ use crate::playback::PlayerState; /// motion than the main bar's 200 ms cadence, and one small window is cheap. const PREVIEW_POSITION_INTERVAL: Duration = Duration::from_millis(100); +/// Fade-engine tick. ~15 ms keeps a 250 ms ramp at 16+ steps — audibly +/// smooth — while each step is one cheap volume command to the sink. +const FADE_TICK: Duration = Duration::from_millis(15); + +/// The preview voices' resting volume. The player crate's own default; the +/// renderer never sets a preview volume today, so fades ramp to this unless +/// a `volume` control changes the target. +const DEFAULT_TARGET_VOL: f32 = 0.7; + /// Lease-lifetime constants, mirroring `src/lib/preview/lease.js` — the two /// sides MUST agree or the renderer would trust a lease the backend already /// considers dead. Values are ADRs backed by the Phase 0 soak. @@ -41,15 +50,138 @@ const MAX_SAFE_MS: i64 = 30 * 60_000; /// preview session never legitimately needs an hour-old clip. const CLIP_MAX_AGE: Duration = Duration::from_secs(60 * 60); +/// Re-resolving a track whose clip bytes are already on disk skips the +/// network entirely within this window (the bytes are immutable audio — only +/// the signed URL goes stale, and a local file needs no URL). This is what +/// makes stepping BACK to a just-played track as fast as a prefetched +/// forward step. Kept well under CLIP_MAX_AGE so the sweep can never delete +/// a file this cache would still hand out. +const CLIP_REUSE_TTL: Duration = Duration::from_secs(15 * 60); + +/// Two voices, not one: a 250 ms equal-power crossfade between track +/// previews (user decision 2026-07-23) needs the outgoing and incoming clips +/// SOUNDING at once, which a single `Player` cannot do. `active` names the +/// voice whose events reach the UI; the other is either idle or a dying +/// fade-out tail. Shadow volumes exist because `Player` has no volume +/// getter and an interrupted fade must hand its partial level to the next +/// ramp — restarting from the target would audibly bump. pub struct PreviewState { - pub player: Player, + voices: [Arc; 2], + active: Arc, + /// f32 bits; what fades ramp to and steady playback sits at. + target_vol: AtomicU32, + /// f32 bits per voice; the last volume actually commanded. + shadow_vols: Arc<[AtomicU32; 2]>, + /// Bumping this orphans every in-flight fade thread (generation guard — + /// same discipline as the renderer's session reducer). + fade_gen: Arc, + /// Per-voice counter bumped by the event forwarders on that voice's + /// Playing events — i.e. a clip actually STARTED SOUNDING there. The + /// crossfade waits on this before starting its ramps: ramping on the + /// load command would fade the outgoing into the incoming clip's decode + /// latency (the mock-derived "ramp on audible, not on play()" trap). + audible_marks: Arc<[AtomicU64; 2]>, /// opaque lease id → clip file. Entries die with the session (dispose) /// or the sweep; the map is small by construction (active + prefetch). leases: Mutex>, + /// track id → (clip file, fetched at): the CLIP_REUSE_TTL byte cache. + /// Several leases may point at one file; dispose clears both maps. + recent_clips: Mutex>, clip_dir: PathBuf, lease_counter: AtomicU64, } +impl PreviewState { + fn active_idx(&self) -> usize { + self.active.load(Ordering::Relaxed) + } + fn target(&self) -> f32 { + f32::from_bits(self.target_vol.load(Ordering::Relaxed)) + } + fn set_vol(&self, idx: usize, v: f32) { + self.voices[idx].set_volume(v); + self.shadow_vols[idx].store(v.to_bits(), Ordering::Relaxed); + } + fn shadow(&self, idx: usize) -> f32 { + f32::from_bits(self.shadow_vols[idx].load(Ordering::Relaxed)) + } + /// Cancel any running fade and return the new generation. + fn bump_fades(&self) -> u64 { + self.fade_gen.fetch_add(1, Ordering::Relaxed) + 1 + } +} + +/// How long a crossfade waits for the incoming voice to become audible +/// before ramping anyway. Local-file decode start is tens of ms; hitting +/// this means the clip is broken (its error event fails the item) and the +/// outgoing should still fade out rather than play forever. +const AUDIBLE_WAIT_MAX: Duration = Duration::from_millis(2_000); + +/// One fade thread: equal-power ramp (incoming rides sin, outgoing cos, so +/// summed acoustic power stays level — a linear fade dips in the middle). +/// `audible_gate` (marks array, voice, snapshot) holds BOTH ramps until that +/// voice's mark moves past the snapshot — the incoming clip is genuinely +/// sounding — so the outgoing keeps playing at level through decode startup +/// and the cross is a true overlap. Generation-guarded at every tick; at +/// completion the outgoing voice is STOPPED, not just silent, so a decayed +/// clip can never linger. +#[allow(clippy::too_many_arguments)] +fn spawn_fade( + gen: u64, + gen_ctr: Arc, + voices: [Arc; 2], + shadows: Arc<[AtomicU32; 2]>, + fade_in: Option<(usize, f32)>, + fade_out: Option<(usize, f32)>, + duration_ms: u64, + audible_gate: Option<(Arc<[AtomicU64; 2]>, usize, u64)>, +) { + let set = move |shadows: &[AtomicU32; 2], voices: &[Arc; 2], idx: usize, v: f32| { + voices[idx].set_volume(v); + shadows[idx].store(v.to_bits(), Ordering::Relaxed); + }; + std::thread::Builder::new() + .name("preview-fade".into()) + .spawn(move || { + if let Some((marks, idx, snapshot)) = &audible_gate { + let wait0 = Instant::now(); + while marks[*idx].load(Ordering::Relaxed) == *snapshot + && wait0.elapsed() < AUDIBLE_WAIT_MAX + { + if gen_ctr.load(Ordering::Relaxed) != gen { + return; // a newer command owns the voices now + } + std::thread::sleep(FADE_TICK); + } + } + let t0 = Instant::now(); + loop { + if gen_ctr.load(Ordering::Relaxed) != gen { + return; // a newer command owns the voices now + } + let t = (t0.elapsed().as_millis() as f32 / duration_ms.max(1) as f32).min(1.0); + let phase = t * std::f32::consts::FRAC_PI_2; + if let Some((idx, target)) = fade_in { + set(&shadows, &voices, idx, target * phase.sin()); + } + if let Some((idx, from)) = fade_out { + set(&shadows, &voices, idx, from * phase.cos()); + } + if t >= 1.0 { + break; + } + std::thread::sleep(FADE_TICK); + } + if gen_ctr.load(Ordering::Relaxed) != gen { + return; + } + if let Some((idx, _)) = fade_out { + voices[idx].stop(); + } + }) + .expect("spawn preview fade"); +} + // ------------------------------------------------------------------ init -- pub fn init(app: &tauri::AppHandle) { @@ -57,7 +189,8 @@ pub fn init(app: &tauri::AppHandle) { // the user's routing. (Device CHANGES while a preview session is open // are part of the Phase 2 focus matrix, handled at the coordinator.) let output_device = crate::config::load(app).audio_output_device; - let (player, events) = Player::spawn_with_device(output_device); + let (p0, events0) = Player::spawn_with_device(output_device.clone()); + let (p1, events1) = Player::spawn_with_device(output_device); let clip_dir = app .path() @@ -67,39 +200,71 @@ pub fn init(app: &tauri::AppHandle) { let _ = std::fs::create_dir_all(&clip_dir); sweep_old_clips(&clip_dir); + let active = Arc::new(AtomicUsize::new(0)); + let audible_marks: Arc<[AtomicU64; 2]> = Arc::new([AtomicU64::new(0), AtomicU64::new(0)]); + app.manage(PreviewState { - player, + voices: [Arc::new(p0), Arc::new(p1)], + active: active.clone(), + target_vol: AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()), + shadow_vols: Arc::new([ + AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()), + AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()), + ]), + fade_gen: Arc::new(AtomicU64::new(0)), + audible_marks: audible_marks.clone(), leases: Mutex::new(HashMap::new()), + recent_clips: Mutex::new(HashMap::new()), clip_dir, lease_counter: AtomicU64::new(1), }); - let handle = app.clone(); - std::thread::Builder::new() - .name("preview-events".into()) - .spawn(move || { - let mut last_position = Instant::now() - PREVIEW_POSITION_INTERVAL; - for ev in events { - let ui = match ev { - PlayerEvent::Buffering => PreviewUiEvent::Buffering, - PlayerEvent::Playing => PreviewUiEvent::Playing, - PlayerEvent::Paused => PreviewUiEvent::Paused, - PlayerEvent::Stopped => PreviewUiEvent::Stopped, - PlayerEvent::TrackEnded { .. } => PreviewUiEvent::Ended, - PlayerEvent::Position { secs, duration } => { - let now = Instant::now(); - if now.duration_since(last_position) < PREVIEW_POSITION_INTERVAL { - continue; - } - last_position = now; - PreviewUiEvent::Position { secs, duration } + // One forwarder per voice. Only the ACTIVE voice's events reach the UI: + // after a crossfade handover the dying voice keeps decoding for its fade + // tail, and its Stopped/Ended must not masquerade as the new clip's. + for (idx, events) in [(0usize, events0), (1usize, events1)] { + let handle = app.clone(); + let active = active.clone(); + let audible_marks = audible_marks.clone(); + std::thread::Builder::new() + .name(format!("preview-events-{idx}")) + .spawn(move || { + let mut last_position = Instant::now() - PREVIEW_POSITION_INTERVAL; + for ev in events { + // Audible-mark bump happens BEFORE the active filter: it + // is a per-voice fact the crossfade gate must see even if + // a later command flipped active away. Playing ONLY — a + // rapid step can land while this voice still runs an + // orphaned fade tail, whose Position stream would open + // the gate before the NEW clip sounds; Playing is emitted + // exactly once per play start, so it names the new clip. + if matches!(&ev, PlayerEvent::Playing) { + audible_marks[idx].fetch_add(1, Ordering::Relaxed); } - PlayerEvent::Error(message) => PreviewUiEvent::Error { message }, - }; - let _ = handle.emit("soulstream:preview", ui); - } - }) - .expect("spawn preview event forwarder"); + if active.load(Ordering::Relaxed) != idx { + continue; + } + let ui = match ev { + PlayerEvent::Buffering => PreviewUiEvent::Buffering, + PlayerEvent::Playing => PreviewUiEvent::Playing, + PlayerEvent::Paused => PreviewUiEvent::Paused, + PlayerEvent::Stopped => PreviewUiEvent::Stopped, + PlayerEvent::TrackEnded { .. } => PreviewUiEvent::Ended, + PlayerEvent::Position { secs, duration } => { + let now = Instant::now(); + if now.duration_since(last_position) < PREVIEW_POSITION_INTERVAL { + continue; + } + last_position = now; + PreviewUiEvent::Position { secs, duration } + } + PlayerEvent::Error(message) => PreviewUiEvent::Error { message }, + }; + let _ = handle.emit("soulstream:preview", ui); + } + }) + .expect("spawn preview event forwarder"); + } } #[derive(Serialize, Clone)] @@ -167,6 +332,42 @@ fn fail(failure: &'static str, retryable: bool) -> PreviewResolveResult { /// fresh — /track responses carry per-request signed URLs (Phase 0), so a /// cached body is a stale lease by construction. The single-retry cap on /// failures lives with the caller (renderer), matching the fixture resolver. +/// Mint a lease over an already-downloaded clip file, if one is fresh enough. +/// The bytes are immutable; only the signed URL a lease normally rides on can +/// go stale, and a local file has no URL — so the reuse window is ours to +/// pick (CLIP_REUSE_TTL). safe_for_ms gets the short fallback: a re-resolve +/// after it lapses lands right back here and is equally cheap. +fn mint_from_recent_clip(state: &PreviewState, track_id: u64) -> Option { + let (path, byte_length) = { + let clips = state.recent_clips.lock().expect("preview clip lock"); + let (path, fetched_at) = clips.get(&track_id)?; + if fetched_at.elapsed() > CLIP_REUSE_TTL { + return None; + } + let len = std::fs::metadata(path).ok()?.len() as usize; + (path.clone(), len) + }; + let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed); + let lease_id = format!("pv-{track_id}-{nonce}"); + state + .leases + .lock() + .expect("preview lease lock") + .insert(lease_id.clone(), path); + Some(PreviewResolveResult { + ok: true, + lease: Some(PreviewLeaseDto { + opaque_lease_id: lease_id, + safe_for_ms: FALLBACK_SAFE_MS, + byte_length, + mime_type: "audio/mpeg".into(), + clip_duration_ms: 30_000, + }), + failure: None, + retryable: false, + }) +} + #[tauri::command] pub async fn preview_resolve( library: State<'_, LibraryState>, @@ -175,6 +376,10 @@ pub async fn preview_resolve( ) -> Result { sweep_old_clips(&state.clip_dir); + if let Some(cached) = mint_from_recent_clip(&state, track_id) { + return Ok(cached); + } + let Some(body) = library.metadata.deezer_track_uncached(track_id).await else { return Ok(fail("network", true)); }; @@ -231,13 +436,15 @@ pub async fn preview_resolve( .leases .lock() .expect("preview lease lock") - .insert(lease_id.clone(), path); + .insert(lease_id.clone(), path.clone()); + state + .recent_clips + .lock() + .expect("preview clip lock") + .insert(track_id, (path, Instant::now())); - let safe_for_ms = compute_safe_for_ms( - parse_exp_ms(&preview_url), - media.date_ms, - now_epoch_ms(), - ); + let safe_for_ms = + compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()); Ok(PreviewResolveResult { ok: true, @@ -258,11 +465,21 @@ pub async fn preview_resolve( /// Load a resolved clip into the preview player. `start_paused` uses the /// desired-paused latch (Phase 0.4): the decoder comes up parked, so opening /// against a still-negotiating focus transfer can never leak audio. +/// +/// `crossfade_ms > 0` (and not paused): the clip lands on the IDLE voice, +/// which becomes active and ramps 0→target while the old voice ramps down +/// from wherever it is and stops — the equal-power crossfade. `0`/paused is +/// the original hard swap on the active voice. +/// +/// `start_at_secs`: the session's resume memory — the decoder comes up +/// already positioned there (no play-then-seek race). #[tauri::command] pub fn preview_load( state: State<'_, PreviewState>, lease_id: String, start_paused: bool, + crossfade_ms: Option, + start_at_secs: Option, ) -> Result<(), String> { let path = state .leases @@ -271,15 +488,47 @@ pub fn preview_load( .get(&lease_id) .cloned() .ok_or("unknown or expired lease")?; - state.player.play_local(&path, Some(30.0)); - if start_paused { - // Ack receipt matters less than the latch being set before any - // decoder can finish; a timeout here still leaves the latch armed. - let _ = state - .player - .pause_acked() - .recv_timeout(Duration::from_millis(750)); + let gen = state.bump_fades(); + let xfade = crossfade_ms.unwrap_or(0); + let cur = state.active_idx(); + let target = state.target(); + + if xfade == 0 || start_paused { + // Hard swap (open, replay, muted, parked): idle voice must be silent + // — it may be a fade tail a bumped generation just orphaned. + state.voices[1 - cur].stop(); + state.set_vol(cur, target); + state.voices[cur].play_local_from(&path, Some(30.0), start_at_secs); + if start_paused { + // Ack receipt matters less than the latch being set before any + // decoder can finish; a timeout here still leaves the latch armed. + let _ = state.voices[cur] + .pause_acked() + .recv_timeout(Duration::from_millis(750)); + } + return Ok(()); } + + let next = 1 - cur; + let out_from = state.shadow(cur); + state.set_vol(next, 0.0); + // Snapshot the incoming voice's audible mark BEFORE play so the fade + // thread can tell "sounding now" from any earlier life of this voice. + let mark = state.audible_marks[next].load(Ordering::Relaxed); + // Active flips BEFORE play so the new voice's Buffering/Playing events + // are the ones the UI sees; the old voice goes dark mid-fade by design. + state.active.store(next, Ordering::Relaxed); + state.voices[next].play_local_from(&path, Some(30.0), start_at_secs); + spawn_fade( + gen, + state.fade_gen.clone(), + state.voices.clone(), + state.shadow_vols.clone(), + Some((next, target)), + Some((cur, out_from)), + xfade, + Some((state.audible_marks.clone(), next, mark)), + ); Ok(()) } @@ -289,12 +538,47 @@ pub fn preview_control( action: String, value: Option, ) -> Result<(), String> { + let cur = state.active_idx(); match action.as_str() { - "pause" => state.player.pause(), - "resume" => state.player.resume(), - "stop" => state.player.stop(), - "seek" => state.player.seek(value.unwrap_or(0.0)), - "volume" => state.player.set_volume(value.unwrap_or(0.7) as f32), + "pause" => { + // Pause silences EVERYTHING: orphan fades, kill any dying tail, + // and park the active voice back at target so resume is full. + state.bump_fades(); + state.voices[1 - cur].stop(); + state.voices[cur].pause(); + state.set_vol(cur, state.target()); + } + "resume" => state.voices[cur].resume(), + "stop" => { + state.bump_fades(); + state.voices[0].stop(); + state.voices[1].stop(); + state.set_vol(cur, state.target()); + } + "seek" => state.voices[cur].seek(value.unwrap_or(0.0)), + "volume" => { + let v = value.unwrap_or(DEFAULT_TARGET_VOL as f64) as f32; + state.target_vol.store(v.to_bits(), Ordering::Relaxed); + state.set_vol(cur, v); + } + // The crossfade's out-half when the incoming clip is not ready yet: + // ramp the active voice to silence and stop it. The voice STAYS + // active — its tail events carry a stale generation and die at the + // renderer's reducer; a later crossfade load flips voices itself. + "fade_out" => { + let gen = state.bump_fades(); + state.voices[1 - cur].stop(); + spawn_fade( + gen, + state.fade_gen.clone(), + state.voices.clone(), + state.shadow_vols.clone(), + None, + Some((cur, state.shadow(cur))), + (value.unwrap_or(250.0) as u64).max(1), + None, + ); + } other => return Err(format!("unknown preview action {other}")), } Ok(()) @@ -304,7 +588,16 @@ pub fn preview_control( /// call repeatedly (close, navigation, hot reload all funnel here). #[tauri::command] pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { - state.player.stop(); + state.bump_fades(); + state.voices[0].stop(); + state.voices[1].stop(); + state.set_vol(0, state.target()); + state.set_vol(1, state.target()); + state + .recent_clips + .lock() + .expect("preview clip lock") + .clear(); let mut leases = state.leases.lock().expect("preview lease lock"); for (_, path) in leases.drain() { let _ = std::fs::remove_file(path); @@ -314,6 +607,93 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { // ----------------------------------------------------- main-player ack ---- +/// Generation guard for MAIN-player fade threads (the session open/close +/// crossfades). A newer fade command orphans any running one, exactly like +/// the preview voices' `fade_gen`. +static MAIN_FADE_GEN: AtomicU64 = AtomicU64::new(0); + +/// Session-open crossfade, main side: ramp the main player to silence over +/// `fade_ms` (equal-power cos, so the preview's sin ramp-in sums level), +/// then pause it WITH the desired-paused latch, then restore `volume` on the +/// now-silent sink so the later resume has its level back. Returns as soon +/// as the fade thread is RUNNING — the caller starts the preview's ramp-in +/// over it; the overlap is the point (deliberate relaxation of the Phase +/// 0.4 "acknowledged silence before any preview audio" rule, user decision +/// 2026-07-23). If the ack never lands, the volume stays at 0 — silent is +/// the safe failure — and the close-side fade-resume self-heals it. +#[tauri::command] +pub fn player_fade_pause_acked( + app: tauri::AppHandle, + fade_ms: u64, + volume: f64, +) -> Result<(), String> { + let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1; + std::thread::Builder::new() + .name("main-fade-out".into()) + .spawn(move || { + let players = app.state::(); + let v = volume as f32; + let dur = fade_ms.max(1); + let t0 = Instant::now(); + loop { + if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen { + return; + } + let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0); + players + .player + .set_volume(v * (t * std::f32::consts::FRAC_PI_2).cos()); + if t >= 1.0 { + break; + } + std::thread::sleep(FADE_TICK); + } + if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen { + return; + } + let rx = players.player.pause_acked(); + match rx.recv_timeout(Duration::from_millis(1_500)) { + Ok(_) => players.player.set_volume(v), + Err(_) => eprintln!("preview: main fade-pause ack timed out; volume left silent"), + } + }) + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Session-close crossfade, main side: come back from the paused snapshot at +/// volume 0 and ramp to `volume` while the preview's fade-out rings down. +/// Resume also clears the desired-paused latch (player-crate contract). +#[tauri::command] +pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64, volume: f64) -> Result<(), String> { + let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1; + std::thread::Builder::new() + .name("main-fade-in".into()) + .spawn(move || { + let players = app.state::(); + let v = volume as f32; + players.player.set_volume(0.0); + players.player.resume(); + let dur = fade_ms.max(1); + let t0 = Instant::now(); + loop { + if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen { + return; + } + let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0); + players + .player + .set_volume(v * (t * std::f32::consts::FRAC_PI_2).sin()); + if t >= 1.0 { + break; + } + std::thread::sleep(FADE_TICK); + } + }) + .map_err(|e| e.to_string())?; + Ok(()) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PauseAckDto { diff --git a/src/App.svelte b/src/App.svelte index e597773..f44466a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -6,6 +6,7 @@ import ArtworkViewer from "./components/ArtworkViewer.svelte"; import LoginModal from "./components/LoginModal.svelte"; import PreviewOverlay from "./components/PreviewOverlay.svelte"; + import DebugOverlay from "./components/DebugOverlay.svelte"; import { previewUi } from "./lib/preview/previewSession.svelte.js"; import Home from "./views/Home.svelte"; import Search from "./views/Search.svelte"; @@ -52,21 +53,23 @@ class:preview-open={previewUi.open} > - +
- {#if !previewUi.open} - - {/if} + + diff --git a/src/components/PreviewOverlay.svelte b/src/components/PreviewOverlay.svelte index 541e33f..739075f 100644 --- a/src/components/PreviewOverlay.svelte +++ b/src/components/PreviewOverlay.svelte @@ -11,6 +11,8 @@ // R1b capsule, T3 directional metadata swaps, folded control row with // the P2 ghost pill, F3 footer. import { tick, untrack } from "svelte"; + import { fade, scale } from "svelte/transition"; + import { cubicOut } from "svelte/easing"; import { previewUi, previewCommands, @@ -31,6 +33,16 @@ const playing = $derived(st?.phase === PHASE.PLAYING); const ended = $derived(st?.phase === PHASE.ENDED); const failed = $derived(st?.phase === PHASE.ITEM_FAILED); + // The play button shows INTENT, not the transport's instantaneous phase: + // during a step the session passes through resolving/buffering for a few + // hundred ms, and flipping the glyph there reads as a stutter (user + // report 2026-07-23). togglePlay() parks from those phases, so the pause + // glyph is honest — clicking it stops the audio either way. + const wantsPlay = $derived( + playing || + (st?.desiredPlaying && + (st?.phase === PHASE.RESOLVING_MEDIA || st?.phase === PHASE.BUFFERING)) + ); const playable = $derived(st ? playableManifestIndices(st) : []); const cursor = $derived(st ? playableCursor(st) : null); const unavailableCount = $derived(items.length - playable.length); @@ -399,6 +411,16 @@ const posLabel = $derived(fmtMs(st?.positionMs ?? 0) || "0:00"); const clipLabel = $derived(fmtMs(st?.clipDurationMs ?? 30_000) || "0:30"); + // Shared by the transport buttons and the reel-side arrows. + const prevDisabled = $derived( + cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0 + ); + const nextDisabled = $derived( + cursor == null + ? activeIndex != null && !playable.some((i) => i > activeIndex) + : cursor >= playable.length - 1 + ); + async function onPlayFull() { if (!previewUi.playFull || !activeItem) return; const starter = previewUi.playFull; @@ -410,7 +432,15 @@ {#if previewUi.open && st} -
closePreview()} aria-hidden="true">
+ +
closePreview()} + aria-hidden="true" + in:fade={{ duration: 240 }} + out:fade={{ duration: 190 }} + >
+
+ + +
@@ -589,7 +641,13 @@ {/each} - {cursor != null ? `${cursor + 1} of ${playable.length}` : `unavailable · ${(activeIndex ?? 0) + 1} of ${items.length}`} + {#if cursor != null} + + {cursor + 1} of {playable.length} + {:else} + unavailable · {(activeIndex ?? 0) + 1} of {items.length} + {/if} {#if unavailableCount > 0} · {unavailableCount} unavailable{/if} @@ -604,14 +662,31 @@ position: fixed; inset: 0; z-index: 60; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(2px); - -webkit-backdrop-filter: blur(2px); + /* Spotify-style: the page behind darkens clearly and blurs a touch, + so the panel reads as a floating modal rather than a full takeover. */ + background: rgba(0, 0, 0, 0.62); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); } .modal { + /* A centred, window-relative panel — not an inset-26 near-fullscreen + sheet. Capped so it never dominates a large monitor while the two + columns still keep their room on smaller windows. */ position: fixed; - inset: 26px; + /* Safe frame: the modal centers (margin auto) inside a fixed inset + box whose 48px top ALWAYS clears the 30px app titlebar — the modal + can never cover it, whatever the window size — with matching + gutters on the sides and bottom. The width/height caps keep it a + real modal on a 4K monitor (1660×1000) while the calc() terms + guarantee it never outgrows the frame on small windows. */ + inset: 48px; + margin: auto; + width: min(1660px, 90vw, calc(100vw - 96px)); + height: min(1000px, calc(100vh - 96px)); + /* vw-based headline: small windows sit at the 38px floor and only + wide screens grow it. */ + --pv-title: clamp(38px, 2.4vw, 52px); z-index: 61; border-radius: 14px; overflow: hidden; @@ -727,7 +802,7 @@ /* -------------------------------------------------------------- body */ .m-body { display: grid; - grid-template-columns: minmax(300px, 500px) auto; + grid-template-columns: minmax(320px, 680px) auto; justify-content: center; gap: 36px; padding: 0 20px; @@ -743,13 +818,14 @@ /* Fixed two-line title slot, BOTTOM-aligned: a one-line title sits against the artist row instead of floating a line above it. */ .title-slot { - height: 82px; + /* Two lines of the (now responsive) title at 1.08 line-height. */ + height: calc(var(--pv-title, 38px) * 1.08 * 2); display: flex; align-items: flex-end; margin-bottom: 8px; } .m-title { - font-size: 38px; + font-size: var(--pv-title, 38px); font-weight: 800; line-height: 1.08; letter-spacing: -0.8px; @@ -797,7 +873,7 @@ gap: 11px; margin-top: 28px; /* Spans the same width as the control row beneath it. */ - max-width: 472px; + max-width: 560px; } .m-wave { flex: 1; @@ -830,7 +906,10 @@ cursor: pointer; font-family: inherit; } - .m-play { + /* Scoped under .m-transport so it outranks the `.m-transport button` + reset (0,1,1) that was blanking the accent fill and the black glyph — + the mockup only had a plain `button{}` reset, which lost to `.m-play`. */ + .m-transport .m-play { width: 46px; height: 46px; border-radius: 50%; @@ -841,7 +920,7 @@ transition: background 0.35s ease; flex: none; } - .m-play:hover { + .m-transport .m-play:hover { transform: scale(1.05); } .m-step { @@ -917,7 +996,11 @@ } .reel { /* Single source of geometry: everything below is calc() off these. */ - --cover: clamp(190px, 34vh, 340px); + /* max(34vh, 22vw) so wide screens (4K) grow the cover via the vw term + while smaller windows stay on 34vh exactly as before — 22vw only + overtakes 34vh once the viewport is very wide, so the 4:3 window is + untouched and the 4K cover jumps from 340 up to the 540 cap. */ + --cover: clamp(190px, max(34vh, 22vw), 540px); --gap: 30px; --stride: calc(var(--cover) + var(--gap)); --bleed-room: 70px; @@ -928,6 +1011,43 @@ -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%); mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%); } + /* Manual step chevrons beside the art (user request 2026-07-23): bare + glyphs like the reference, no chrome until hover. They sit in the + reel's 70px bleed gutter — negative margin tucks them against the + cover's edge without widening the column. */ + .reel-arrows { + display: flex; + flex-direction: column; + gap: 26px; + margin-left: -30px; + position: relative; + z-index: 2; + } + .reel-arrow { + width: 40px; + height: 40px; + border-radius: 50%; + display: grid; + place-items: center; + background: none; + border: 0; + color: var(--text-secondary); + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease, transform 0.15s ease; + } + .reel-arrow:hover:not(:disabled) { + color: #fff; + background: rgba(255, 255, 255, 0.08); + transform: scale(1.08); + } + .reel-arrow:active:not(:disabled) { + transform: scale(0.96); + } + .reel-arrow:disabled { + opacity: 0.25; + cursor: default; + } + .reel-track { /* A fixed anchor at the reel centre — it never moves. Cards position themselves relative to it at (index − activeIndex) × stride and @@ -1134,6 +1254,11 @@ gap: 3px; flex: 1; min-width: 0; + /* Fixed box height: the active segment animates 3↔5px, so without a + pinned height the rail's box shrank mid-step and the centred rail + bounced ~0.5px vertically on every track change. Now the segments + grow inside a constant-height box and nothing reflows. */ + height: 5px; } .rail i { flex: 1; @@ -1149,6 +1274,10 @@ font-variant-numeric: tabular-nums; flex: none; } + .pos-n { + display: inline-block; + text-align: right; + } .sr-announce { position: absolute; diff --git a/src/lib/api.js b/src/lib/api.js index 02a0c90..a8ef09a 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -136,8 +136,13 @@ export const streamWaveform = (username, path, size, buckets) => // previewResolve returns { ok, lease | failure, retryable } — the signed URL // never crosses this boundary, only an opaque lease id does. export const previewResolve = (trackId) => invoke("preview_resolve", { trackId }); -export const previewLoad = (leaseId, startPaused) => - invoke("preview_load", { leaseId, startPaused }); +export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) => + invoke("preview_load", { leaseId, startPaused, crossfadeMs, startAtSecs }); +export const playerFadePauseAcked = (fadeMs, volume) => + invoke("player_fade_pause_acked", { fadeMs, volume }); +export const playerFadeResume = (fadeMs, volume) => + invoke("player_fade_resume", { fadeMs, volume }); +export const debugStats = () => invoke("debug_stats"); export const previewControl = (action, value = null) => invoke("preview_control", { action, value }); export const previewDispose = () => invoke("preview_dispose"); diff --git a/src/lib/preview/focusBridge.js b/src/lib/preview/focusBridge.js index 30aa1d2..447f66e 100644 --- a/src/lib/preview/focusBridge.js +++ b/src/lib/preview/focusBridge.js @@ -1,17 +1,26 @@ // Production wiring for the Phase 1 focus coordinator: the real main player // on one side, the preview session on the other. // -// One honest deviation from the pure model, documented here because it is a -// real design decision: the renderer cannot know the NATIVE playback -// generation ahead of time (it only travels in the PauseAck itself). The -// production binding therefore replaces "ack.generation === snapshot's -// native generation" with an equivalent renderer-side invariant: +// Two honest deviations from the pure model, documented here because they +// are real design decisions: // -// granted ⇔ ack received (never assumed on timeout) +// 1. The renderer cannot know the NATIVE playback generation ahead of time +// (it only travels in the PauseAck itself), so the production binding +// replaces "ack.generation === snapshot's native generation" with an +// equivalent renderer-side invariant: +// +// granted ⇔ the main fade-to-silence is RUNNING (see requestSilence — +// since the 2026-07-23 open-crossfade, silence is a ramp the +// preview deliberately overlaps, not an acknowledged fact) // ∧ no renderer PLAYBACK generation change during negotiation // (a new Buffering = a new track start = a new generation) // ∧ no revision-gateway change during negotiation // +// 2. The open/close crossfades trade the hard "silence before preview +// audio" gate for a bounded overlap window (CROSSFADE_MS); the backend +// fade thread still ends in pause + desired-paused latch, and its +// failure mode is silence, never double audio. +// // Every native source change reaches the renderer as an event that bumps the // gateway (wired in state.svelte.js), so a native-side surprise invalidates // the grant exactly as a user command does. The native ack generation is @@ -19,6 +28,7 @@ import * as api from "../api.js"; import { createFocusCoordinator, createRevisionGateway } from "./focus.js"; +import { CROSSFADE_MS } from "./service.js"; /** * @param {object} deps @@ -48,7 +58,17 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu }; }, requestSilence: async (generationAtSnapshot) => { - const ack = await api.playerPauseAcked(); // throws on timeout + // Open crossfade (user decision 2026-07-23): the main player + // FADES to silence over the session crossfade instead of hard- + // pausing, and this resolves as soon as the backend fade thread + // is running — the first preview clip ramps in OVER the fading + // main track. This deliberately relaxes the Phase 0.4 rule + // ("acknowledged silence before any preview audio"): the + // overlap is the feature. The pause + desired-paused latch land + // at the fade's end inside the backend thread; its failure mode + // is silence (volume left at 0), never double audio. + const p = readPlayer(); + await api.playerFadePauseAcked(CROSSFADE_MS, p.muted ? 0 : (p.volume ?? 0.7)); const generationNow = readPlaybackGeneration(); return { // Applied only if no new main track started mid-negotiation; @@ -57,8 +77,8 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu applied: true, generation: generationNow === generationAtSnapshot ? generationAtSnapshot : generationNow, - nativeGeneration: ack.generation, - latched: ack.latched, + nativeGeneration: null, + latched: true, }; }, resumeMain: (snapshot, opts) => { diff --git a/src/lib/preview/manifest.js b/src/lib/preview/manifest.js index 29feb69..4bddc73 100644 --- a/src/lib/preview/manifest.js +++ b/src/lib/preview/manifest.js @@ -14,6 +14,17 @@ function availabilityFromCatalogTrack(t) { return AVAILABILITY.NOT_PROVIDED; } +/** Deezer CDN cover URLs are size-addressed (`…/cover//250x250-….jpg`). + * Track payloads carry the 250px variant — right for list thumbnails, blurry + * on the preview reel's large card — so rewrite to the 1000px variant of the + * SAME asset the artist page's album grid (`cover_xl`) already uses. Only + * the preview modal pays for the bigger image; non-Deezer URLs and anything + * shaped unexpectedly pass through untouched. */ +function fullSizeArtwork(url) { + if (typeof url !== "string" || !url.includes("dzcdn.net")) return url; + return url.replace(/\/\d{2,4}x\d{2,4}(?=-)/, "/1000x1000"); +} + function itemFromCatalogTrack(t, ordinal, inherit = {}) { return makeItem({ sessionKey: `s${ordinal}-dz${t.id}`, @@ -25,7 +36,7 @@ function itemFromCatalogTrack(t, ordinal, inherit = {}) { artists: [t.artist || inherit.artist || ""].filter(Boolean), album: t.album || inherit.album, albumId: t.album_id ?? inherit.albumId, - artwork: t.cover ?? inherit.artwork, + artwork: fullSizeArtwork(t.cover ?? inherit.artwork), fullDurationMs: Number.isFinite(t.duration_secs) ? t.duration_secs * 1000 : undefined, sources: [makeSourceRef({ provider: "deezer", providerTrackId: t.id, matchConfidence: "native" })], initialAvailability: availabilityFromCatalogTrack(t), diff --git a/src/lib/preview/nativeTransport.js b/src/lib/preview/nativeTransport.js index c7b38e1..4fc64db 100644 --- a/src/lib/preview/nativeTransport.js +++ b/src/lib/preview/nativeTransport.js @@ -57,7 +57,7 @@ export function createNativeTransport(dispatch) { /** Accept a native lease and hand its clip to the preview player. * `startPaused` maps to the backend's desired-paused latch, so a * parked open can never leak audio (Phase 0.4). */ - async load(lease, { sessionId, generation, sessionKey }, { startPaused = false } = {}) { + async load(lease, { sessionId, generation, sessionKey }, { startPaused = false, crossfadeMs = 0, startAtMs = 0 } = {}) { if (disposed) throw new Error("transport disposed"); await ensureSubscribed(); if (!isValidLease(lease) || lease.transport !== "native") { @@ -67,7 +67,14 @@ export function createNativeTransport(dispatch) { return; } ctx = { sessionId, generation, sessionKey }; - await api.previewLoad(lease.opaqueLeaseId, startPaused); + // startAtMs rides into the DECODER (session resume): the clip + // comes up already positioned, no post-load seek race. + await api.previewLoad( + lease.opaqueLeaseId, + startPaused, + crossfadeMs, + startAtMs > 0 ? startAtMs / 1000 : null + ); }, async play() { if (!ctx) return; @@ -84,6 +91,15 @@ export function createNativeTransport(dispatch) { async setVolume(v) { await api.previewControl("volume", v); }, + /** Crossfade out-half: ramp the current clip to silence instead of + * cutting it. ctx is deliberately KEPT — the tail's events carry the + * superseded generation and die at the reducer, and the next load + * will restamp; clearing ctx here would also mute a failure event + * racing the fade. */ + async fadeOut(ms) { + if (!ctx) return; + await api.previewControl("fade_out", ms); + }, async stop() { ctx = null; // events from the dying clip stamp nothing await api.previewControl("stop"); diff --git a/src/lib/preview/previewSession.svelte.js b/src/lib/preview/previewSession.svelte.js index eb4c3b6..3ca6ba2 100644 --- a/src/lib/preview/previewSession.svelte.js +++ b/src/lib/preview/previewSession.svelte.js @@ -145,19 +145,25 @@ export async function closePreview() { demoTicker = null; } if (!service) return { restored: false }; - const out = await service.close(); - unsubscribe?.(); - unsubscribe = null; + // Detach first (re-entrancy: a second Escape lands on the early return), + // and hide the modal BEFORE awaiting the close — service.close() now + // holds the line for the close-crossfade tail (~CROSSFADE_MS), and the + // visual out-transition must not wait on audio. The snapshot stays until + // the close resolves so the dialog has content through its exit. + const svc = service; service = null; previewUi.open = false; - previewUi.snapshot = null; - previewUi.focusWaiting = false; - previewUi.playFull = null; const back = previewUi.returnFocusTo; previewUi.returnFocusTo = null; if (back && typeof back.focus === "function" && back.isConnected) { back.focus(); } + const out = await svc.close(); + unsubscribe?.(); + unsubscribe = null; + previewUi.snapshot = null; + previewUi.focusWaiting = false; + previewUi.playFull = null; return out; } diff --git a/src/lib/preview/service.js b/src/lib/preview/service.js index a3caf42..0c5b519 100644 --- a/src/lib/preview/service.js +++ b/src/lib/preview/service.js @@ -2,7 +2,7 @@ // // The reducer decides WHAT happens; this service decides WHEN: the settle // window after navigation, the resolve→load→play pipeline, the single -// cache-bypass retry, one direction-aware lease prefetch, and the focus +// cache-bypass retry, the two-neighbour lease prefetch, and the focus // handshake at the session edges. Everything time-like is injected // (scheduler, monotonic clock) so the whole loop runs under node:test with // manual ticks — no real timers anywhere in the tests. @@ -20,6 +20,33 @@ const defaultScheduler = { clear: (id) => clearTimeout(id), }; +/** Equal-power crossfade between track previews. 340 ms — the EXACT length + * of the reel's card-slide transition (`top 340ms` in PreviewOverlay), so + * the audio cross and the visual slide start and land together (user + * decision 2026-07-23, superseding the earlier 250 ms pick). The outgoing + * clip keeps SOUNDING until the incoming one is loaded — the backend's + * crossfade owns both ramps — so the two genuinely overlap. */ +export const CROSSFADE_MS = 340; + +/** How long the outgoing clip may keep playing under the NEW card before we + * give up waiting for the incoming load and fade it out anyway. Only reached + * on a slow un-prefetched resolve; with the lease prefetch the load lands in + * well under one crossfade. */ +const OUTGOING_OVERSTAY_MS = 1000; + +/** Steps closer together than this are a burst (held arrow key, ~33 ms + * repeats) and get the coalescing settle window; an ISOLATED step fires the + * pipeline immediately — the user asked for audio "the instant I scroll", + * and the wheel is already rate-gated upstream. */ +const BURST_WINDOW_MS = 250; + +/** Start the auto-advance this far before the playing clip's end, so the + * next clip crossfades in while the tail is still sounding — continuous + * auto-play instead of end→silence→late fade-in. Sized for load latency + * (~50 ms) + the full crossfade; MEDIA_ENDED remains the backstop for clips + * that run shorter than their 30 s nominal duration. */ +const EARLY_ADVANCE_MS = CROSSFADE_MS + 60; + export function createPreviewService({ resolver, // The service CONSTRUCTS the transport, binding it to its own dispatch — @@ -37,7 +64,12 @@ export function createPreviewService({ let settleTimer = null; let focusToken = null; let focusWaiting = false; - // sessionKey -> { lease, receiptAt } — the one direction-aware prefetch. + // Burst detection for the adaptive settle window. + let lastNavAt = -Infinity; + // One early auto-advance per generation, however many position events + // cross the threshold. + let lastEarlyAdvanceGen = -1; + // sessionKey -> { lease, receiptAt } — the two-neighbour prefetch (≤2). let prefetched = new Map(); const emit = () => { @@ -62,20 +94,56 @@ export function createPreviewService({ if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) { scheduleSettle(); } + maybeScheduleEarlyAdvance(); + } + + /** Continuous auto-play: once the playing clip is within EARLY_ADVANCE_MS + * of its end and a next playable exists, step forward NOW — the step's + * crossfade then overlaps the tail instead of following the silence. + * Latched per generation; the dispatch is deferred a tick (via the + * scheduler) so a reducer transition never re-enters the reducer. */ + function maybeScheduleEarlyAdvance() { + if (!state || state.phase !== PHASE.PLAYING || !state.desiredPlaying || state.muted) return; + const gen = state.requestGeneration; + if (gen === lastEarlyAdvanceGen) return; + const clip = state.clipDurationMs; + if (!clip || state.positionMs < clip - EARLY_ADVANCE_MS) return; + const playable = playableManifestIndices(state); + const cur = playableCursor(state); + if (cur == null || cur + 1 >= playable.length) return; // last clip ends naturally + lastEarlyAdvanceGen = gen; + const sessionId = state.sessionId; + scheduler.set(() => { + if (!state || state.sessionId !== sessionId || state.requestGeneration !== gen) return; + if (state.phase !== PHASE.PLAYING || !state.desiredPlaying || state.muted) return; + dispatch({ type: "STEP", sessionId, dir: 1 }); + }, 0); } function scheduleSettle() { if (settleTimer != null) scheduler.clear(settleTimer); const sessionId = state.sessionId; const generation = state.requestGeneration; - // Stop the outgoing clip immediately — silence between cards is - // correct; audio under the wrong artwork never is. - void transport.stop(); + // Fade-capable transports (native) keep the outgoing clip SOUNDING + // through settle + resolve: the crossfade inside load() ramps it out + // against the incoming clip, which is the only way the two overlap. + // Fading here at t=0 was the 2026-07-23 gap bug — the outgoing was + // silent before the incoming even loaded. Transports without fades + // (fixture/tests) keep the original hard stop; either way superseded + // events carry a stale generation and land inert at the reducer. + if (typeof transport.fadeOut !== "function") void transport.stop(); + // Adaptive settle: an isolated navigation fires the pipeline at once + // (audio the instant you scroll); only a BURST — steps landing within + // BURST_WINDOW_MS of each other, i.e. a held key — pays the + // coalescing window so intermediates never load. + const now = monotonicNow(); + const delay = now - lastNavAt > BURST_WINDOW_MS ? 0 : settleMs; + lastNavAt = now; settleTimer = scheduler.set(() => { settleTimer = null; if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return; void runPipeline(sessionId, generation); - }, settleMs); + }, delay); } async function runPipeline(sessionId, generation) { @@ -83,8 +151,35 @@ export function createPreviewService({ state && state.sessionId === sessionId && state.requestGeneration === generation; if (!fresh() || state.activeItemKey == null) return; const item = state.manifest.items[state.activeManifestIndex]; + + // On fade transports the OUTGOING clip is still sounding right now — + // scheduleSettle deliberately left it alone so load()'s crossfade can + // ramp it out against the incoming clip. These guards are the two + // cases where that handover never comes: the incoming load fails, or + // it overstays (slow un-prefetched resolve) — then the outgoing gets + // the standalone fade to silence instead of playing on under the + // wrong card. Both are no-ops for stop-at-settle transports. + let overstayTimer = null; + if (typeof transport.fadeOut === "function") { + overstayTimer = scheduler.set(() => { + overstayTimer = null; + if (fresh()) void transport.fadeOut(CROSSFADE_MS); + }, OUTGOING_OVERSTAY_MS); + } + const clearOverstay = () => { + if (overstayTimer != null) { + scheduler.clear(overstayTimer); + overstayTimer = null; + } + }; + const retireOutgoing = () => { + clearOverstay(); + if (typeof transport.fadeOut === "function") void transport.fadeOut(CROSSFADE_MS); + }; + const source = item.sources[0]; if (!source) { + retireOutgoing(); dispatch({ type: "ITEM_FAILED", sessionId, generation, sessionKey: item.sessionKey, errorClass: "no_source" }); return; } @@ -100,14 +195,15 @@ export function createPreviewService({ if (!lease) { let result = await resolver.resolve(source, { generation }); - if (!fresh()) return; + if (!fresh()) return clearOverstay(); if (!result.ok && result.retryable) { // The capped retry: exactly one cache-bypass re-mint per // user intent, per the resolver contract. Never a loop. result = await resolver.resolve(source, { generation, bypassCache: true }); - if (!fresh()) return; + if (!fresh()) return clearOverstay(); } if (!result.ok) { + retireOutgoing(); dispatch({ type: "ITEM_FAILED", sessionId, @@ -128,42 +224,66 @@ export function createPreviewService({ sessionKey: item.sessionKey, clipDurationMs: lease.clipDurationMs, }); - if (!fresh()) return; + if (!fresh()) return clearOverstay(); + // The load owns the handover from here (crossfade or hard swap). + clearOverstay(); const audible = state.desiredPlaying && focusToken != null && !state.muted; - await transport.load(lease, { sessionId, generation, sessionKey: item.sessionKey }, { startPaused: !audible }); + // Session resume memory: a revisited clip picks up where it left + // off (recorded by the reducer on departure; 0 after it finished). + const resumeMs = state.itemRuntimeByKey[item.sessionKey]?.resumeMs ?? 0; + await transport.load( + lease, + { sessionId, generation, sessionKey: item.sessionKey }, + { startPaused: !audible, crossfadeMs: audible ? CROSSFADE_MS : 0, startAtMs: resumeMs } + ); if (!fresh()) { void transport.stop(); return; } + if (resumeMs > 0) { + // Paint the scrubber at the resume point immediately; the + // transport's own position events take over within a tick. + dispatch({ type: "MEDIA_POSITION", sessionId, generation, positionMs: resumeMs }); + } if (audible) await transport.play(); - prefetchNeighbor(sessionId, generation); + prefetchNeighbors(sessionId, generation); } - /** Resolve the NEXT playable item's lease in the background so stepping - * forward usually skips the network entirely. One item, forward only — - * the bounded mechanism the plan allows without byte prefetch. */ - function prefetchNeighbor(sessionId, generation) { + /** Resolve BOTH playable neighbours' leases in the background so a step + * in either direction usually skips the network entirely. Forward-only + * prefetch was the 2026-07-23 asymmetry bug: down-steps crossfaded in + * ~180 ms while up-steps sat through a full resolve. Bounded at the two + * neighbours of the current position — still leases only, no byte + * prefetch beyond what resolve itself stores. */ + function prefetchNeighbors(sessionId, generation) { if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return; const playable = playableManifestIndices(state); const cur = playableCursor(state); - if (cur == null || cur + 1 >= playable.length) return; - const next = state.manifest.items[playable[cur + 1]]; - if (prefetched.has(next.sessionKey) || !next.sources[0]) return; - void resolver - .resolve(next.sources[0], { generation }) - .then((r) => { - if (r.ok && state && state.sessionId === sessionId) { - prefetched.set(next.sessionKey, { lease: r.lease, receiptAt: monotonicNow() }); - // bounded: keep at most one prefetched lease around - if (prefetched.size > 1) { - const oldest = prefetched.keys().next().value; - if (oldest !== next.sessionKey) prefetched.delete(oldest); + if (cur == null) return; + const wanted = []; + if (cur + 1 < playable.length) wanted.push(state.manifest.items[playable[cur + 1]]); + if (cur - 1 >= 0) wanted.push(state.manifest.items[playable[cur - 1]]); + // Prune anything that is no longer a neighbour — the map stays at ≤2. + const wantedKeys = new Set(wanted.map((i) => i.sessionKey)); + for (const key of [...prefetched.keys()]) { + if (!wantedKeys.has(key)) prefetched.delete(key); + } + for (const item of wanted) { + if (prefetched.has(item.sessionKey) || !item.sources[0]) continue; + // A settled failure stays settled: background prefetch must not + // grow the pipeline's capped retry into a loop. + if (state.itemRuntimeByKey[item.sessionKey]?.errorClass) continue; + void resolver + .resolve(item.sources[0], { generation }) + .then((r) => { + if (r.ok && state && state.sessionId === sessionId) { + prefetched.set(item.sessionKey, { lease: r.lease, receiptAt: monotonicNow() }); } - } - }) - .catch(() => {}); + }) + .catch(() => {}); + } } // -------------------------------------------------------------- focus -- @@ -236,6 +356,18 @@ export function createPreviewService({ await transport.pause(); return; } + // Mid-transition (step still resolving/buffering) with listen + // intent: the button shows a pause glyph, so a click must PARK — + // drop the intent (the pipeline will load startPaused) and + // silence whatever is still sounding (the crossfade's outgoing). + if ( + state.desiredPlaying && + (state.phase === PHASE.RESOLVING_MEDIA || state.phase === PHASE.BUFFERING) + ) { + dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: false }); + await transport.pause(); + return; + } if (state.phase === PHASE.ENDED) { dispatch({ type: "REPLAY", sessionId: sid }); return; @@ -262,7 +394,10 @@ export function createPreviewService({ }, /** Close: terminal for the session; transport torn down; main - * restored only if we paused it and nothing changed since. */ + * restored only if we paused it and nothing changed since. On fade + * transports the close is a CROSSFADE: the preview rings down while + * the release's fade-resume brings the main track back up, and the + * teardown waits for the tail so dispose cannot cut it off. */ async close() { if (!state) return { restored: false }; if (settleTimer != null) { @@ -271,11 +406,16 @@ export function createPreviewService({ } dispatch({ type: "CLOSE", sessionId: state.sessionId }); prefetched.clear(); - await transport.stop(); - await transport.dispose(); + const fades = typeof transport.fadeOut === "function"; + if (fades) void transport.fadeOut(CROSSFADE_MS); + else await transport.stop(); const token = focusToken; focusToken = null; const restored = token ? focus.coordinator.release(token) : { restored: false }; + if (fades) { + await new Promise((resolve) => scheduler.set(resolve, CROSSFADE_MS + 80)); + } + await transport.dispose(); return restored; }, }; diff --git a/src/lib/preview/session.js b/src/lib/preview/session.js index 154b31f..b54cac1 100644 --- a/src/lib/preview/session.js +++ b/src/lib/preview/session.js @@ -95,13 +95,29 @@ function withRuntime(state, sessionKey, patch) { }); } +/** Leaving a clip with less than this remaining counts as FINISHING it — a + * revisit starts from the top. Sized just past the service's anticipatory + * auto-advance point (clip end − ~400 ms), so an auto-advanced track always + * restarts while a mid-clip departure always resumes. */ +const RESUME_END_GUARD_MS = 500; + /** Activate the item at `manifestIndex`, bumping the generation — the * common core of first-pick, step, click and auto-advance. */ function activate(state, manifestIndex, { keepDesired }) { const item = itemAt(state, manifestIndex); if (!item) return state; + // Remember where the OUTGOING clip stopped so a revisit can pick it back + // up. positionMs === 0 records nothing — a burst step that only passed + // through must not wipe a position remembered from a real earlier visit. + let runtime = state.itemRuntimeByKey; + if (state.activeItemKey && state.activeItemKey !== item.sessionKey && state.positionMs > 0) { + const clip = state.clipDurationMs ?? 30_000; + const resumeMs = clip - state.positionMs > RESUME_END_GUARD_MS ? state.positionMs : 0; + runtime = withRuntime(state, state.activeItemKey, { resumeMs }); + } return Object.freeze({ ...state, + itemRuntimeByKey: runtime, phase: PHASE.RESOLVING_MEDIA, activeItemKey: item.sessionKey, activeManifestIndex: manifestIndex, @@ -178,8 +194,15 @@ export function reduce(state, action) { case "REPLAY": { if (!guard(state, action, { needGeneration: false })) return state; if (state.activeManifestIndex == null) return state; + // Replay means "from the top" — drop any remembered position. + const cleared = state.activeItemKey + ? Object.freeze({ + ...state, + itemRuntimeByKey: withRuntime(state, state.activeItemKey, { resumeMs: 0 }), + }) + : state; return Object.freeze({ - ...activate(state, state.activeManifestIndex, { keepDesired: true }), + ...activate(cleared, cleared.activeManifestIndex, { keepDesired: true }), desiredPlaying: true, }); } @@ -237,7 +260,15 @@ export function reduce(state, action) { if (cur != null && cur + 1 < playable.length) { return activate(state, playable[cur + 1], { keepDesired: true }); } - return Object.freeze({ ...state, phase: PHASE.ENDED, desiredPlaying: false }); + // The last clip finished: any revisit or replay starts fresh. + return Object.freeze({ + ...state, + phase: PHASE.ENDED, + desiredPlaying: false, + itemRuntimeByKey: state.activeItemKey + ? withRuntime(state, state.activeItemKey, { resumeMs: 0 }) + : state.itemRuntimeByKey, + }); } case "ITEM_FAILED": { if (!guard(state, action, { needGeneration: true })) return state; diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index b59d574..e969e40 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -6,6 +6,7 @@ import { bumpMainRevision, notePlaybackGeneration, } from "./preview/previewSession.svelte.js"; +import { CROSSFADE_MS as PREVIEW_CROSSFADE_MS } from "./preview/service.js"; export const app = $state({ connection: "offline", // offline | connecting | online @@ -3567,7 +3568,9 @@ export function initEvents() { initPreviewBridge({ readPlayer: () => player, resumeMain: () => { - api.playerControl("resume"); + // Close crossfade: come back from the preview at volume 0 and + // ramp to the user's level while the preview rings down. + api.playerFadeResume(PREVIEW_CROSSFADE_MS, player.muted ? 0 : player.volume); }, }); diff --git a/src/views/Settings.svelte b/src/views/Settings.svelte index ffc9178..6c91b7c 100644 --- a/src/views/Settings.svelte +++ b/src/views/Settings.svelte @@ -38,6 +38,12 @@ } } + async function setVolumeNormalization(next) { + if (next !== settings.volume_normalization) { + await commit({ volumeNormalization: next }); + } + } + async function chooseDownloadDir() { const dir = await api.pickFolder(); if (dir) await commit({ downloadDir: dir }); @@ -226,6 +232,21 @@ {/each} +
+
+
Normalize volume
+
Plays every track at roughly the same loudness (−14 LUFS, like Spotify), so quiet and loud masters stop needing the volume knob. Streams are measured during their first play; boosts never clip.
+
+ +

Changes apply immediately and keep the current track near the same position.

diff --git a/tests/preview/service.test.mjs b/tests/preview/service.test.mjs index c873cac..b441d09 100644 --- a/tests/preview/service.test.mjs +++ b/tests/preview/service.test.mjs @@ -25,10 +25,13 @@ function manifest(n = 3) { function makeScheduler() { let queue = new Map(); let nextId = 1; + const delays = []; return { + delays, // every ms passed to set(), in order — for adaptive-settle asserts set(fn, ms) { const id = nextId++; queue.set(id, fn); + delays.push(ms); return id; }, clear(id) { @@ -56,7 +59,7 @@ function makeTransport() { log, async load(lease, c, opts) { ctx = c; - log.push(["load", lease.opaqueLeaseId, c.generation, !!opts?.startPaused]); + log.push(["load", lease.opaqueLeaseId, c.generation, !!opts?.startPaused, opts?.startAtMs ?? 0]); }, async play() { log.push(["play"]); @@ -79,6 +82,9 @@ function makeTransport() { end() { if (ctx) dispatch({ type: "MEDIA_ENDED", ...ctx }); }, + position(ms) { + if (ctx) dispatch({ type: "MEDIA_POSITION", ...ctx, positionMs: ms }); + }, }; return { transport: t, @@ -148,7 +154,7 @@ test("open: focus, settle, resolve, load audible, play", async () => { const st = service.getState(); assert.equal(st.phase, PHASE.PLAYING); // lease landed, played, confirmed const load = transport.log.find((e) => e[0] === "load"); - assert.deepEqual(load, ["load", "op-1", st.requestGeneration, false]); // audible → not parked + assert.deepEqual(load, ["load", "op-1", st.requestGeneration, false, 0]); // audible → not parked, from the top assert.ok(transport.log.some((e) => e[0] === "play")); }); @@ -207,6 +213,119 @@ test("failed resolve retries exactly once, then fails the item in place", async assert.equal(resolver.callCount("2"), 3, "failure is settled — no more calls"); }); +// -------------------------------------------- adaptive settle / auto-play -- + +test("isolated steps fire the pipeline immediately; bursts pay the settle window", async () => { + const { service, scheduler, advance, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); // isolated → 0 + service.step(1); // same instant as the open's activation → burst → 150 + advance(300); + service.step(1); // beyond the burst window → isolated → 0 + assert.deepEqual(scheduler.delays, [0, 150, 0]); +}); + +test("auto-play: nearing the clip's end advances early so the next track crossfades in", async () => { + const { service, scheduler, transport, advance, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + await drainMicrotasks(); // playing item 1, neighbour prefetched + const genBefore = service.getState().requestGeneration; + advance(1000); // clear the burst window: the early step must fire at once + transport.position(29_700); // inside EARLY_ADVANCE_MS of the 30 s clip + assert.ok(scheduler.pending() >= 1, "early advance armed"); + await scheduler.flush(); // STEP dispatched, settle armed at delay 0 + await scheduler.flush(); // pipeline: load + play item 2 + const st = service.getState(); + assert.equal(st.activeManifestIndex, 1, "advanced before the clip ended"); + assert.equal(st.phase, PHASE.PLAYING); + assert.ok(st.requestGeneration > genBefore); + assert.equal(st.desiredPlaying, true, "listen intent survives the auto-advance"); + assert.equal(scheduler.delays.at(-1), 0, "the early step skipped the settle window"); +}); + +test("auto-play: the LAST playable track never advances early", async () => { + const { service, scheduler, transport, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + service.step(1); + await scheduler.flush(); + service.step(1); + await scheduler.flush(); // riding on item 3 of 3 + await drainMicrotasks(); + assert.equal(service.getState().activeManifestIndex, 2); + transport.position(29_800); + assert.equal(scheduler.pending(), 0, "no early advance armed on the last track"); + transport.end(); // natural end still lands in the replay state + assert.equal(service.getState().phase, PHASE.ENDED); +}); + +// ------------------------------------------------------- resume memory ---- + +test("resume: stepping back to a track picks up where it left off", async () => { + const { service, scheduler, transport, advance, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + await drainMicrotasks(); // playing item 1 + transport.position(12_000); // listened 12 s in + advance(300); + service.step(1); + await scheduler.flush(); + await drainMicrotasks(); // playing item 2 from the top + assert.equal(transport.log.filter((e) => e[0] === "load").at(-1)[4], 0); + advance(300); + service.step(-1); + await scheduler.flush(); + const back = transport.log.filter((e) => e[0] === "load").at(-1); + assert.equal(back[1], "op-1"); + assert.equal(back[4], 12_000, "revisit resumes at the departure position"); + assert.equal(service.getState().positionMs, 12_000, "scrubber painted at the resume point"); + assert.equal(service.getState().phase, PHASE.PLAYING); +}); + +test("resume: a track that finished (auto-advance) restarts from the top", async () => { + const { service, scheduler, transport, advance, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + await drainMicrotasks(); // playing item 1 + advance(1000); + transport.position(29_700); // the anticipatory auto-advance point + await scheduler.flush(); // early STEP + await scheduler.flush(); // playing item 2 + assert.equal(service.getState().activeManifestIndex, 1); + advance(300); + service.step(-1); + await scheduler.flush(); + const back = transport.log.filter((e) => e[0] === "load").at(-1); + assert.equal(back[1], "op-1"); + assert.equal(back[4], 0, "a finished clip restarts from the beginning"); +}); + +test("resume: flicking past a track does not erase its remembered position", async () => { + const { service, scheduler, transport, advance, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + await drainMicrotasks(); // playing item 1 + transport.position(9_000); + advance(300); + service.step(1); // land on 2 + await scheduler.flush(); + await drainMicrotasks(); + advance(300); + service.step(1); // land on 3 — item 2 leaves at position 0 + await scheduler.flush(); + await drainMicrotasks(); + advance(300); + service.step(-1); // back to 2: no remembered position → from the top + await scheduler.flush(); + assert.equal(transport.log.filter((e) => e[0] === "load").at(-1)[4], 0); + advance(300); + service.step(-1); // back to 1: the 9 s position survived the pass-through + await scheduler.flush(); + const back = transport.log.filter((e) => e[0] === "load").at(-1); + assert.equal(back[1], "op-1"); + assert.equal(back[4], 9_000); +}); + // ------------------------------------------------------------- prefetch --- test("neighbor lease prefetch: stepping forward skips the resolve", async () => { @@ -221,6 +340,45 @@ test("neighbor lease prefetch: stepping forward skips the resolve", async () => assert.equal(service.getState().phase, PHASE.PLAYING); }); +test("backward prefetch: stepping back to the previous track skips the resolve", async () => { + const { service, scheduler, resolver, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + await drainMicrotasks(); // landed on 1; forward neighbour 2 prefetched + service.step(1); + await scheduler.flush(); + await drainMicrotasks(); // landed on 2; BOTH neighbours (1, 3) prefetched + const before = resolver.callCount("1"); + assert.ok(before >= 2, "backward neighbour was prefetched after the step"); + service.step(-1); + await scheduler.flush(); + assert.equal(service.getState().phase, PHASE.PLAYING); + assert.equal(resolver.callCount("1"), before, "backward step used the prefetched lease"); +}); + +test("pause clicked mid-step transition parks the pipeline instead of playing on", async () => { + const { service, scheduler, transport, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + assert.equal(service.getState().phase, PHASE.PLAYING); + + service.step(1); // phase leaves PLAYING while the step resolves + assert.equal(service.getState().phase, PHASE.RESOLVING_MEDIA); + await service.togglePlay(); // user hits pause DURING the transition + assert.equal(service.getState().desiredPlaying, false); + assert.ok(transport.log.some((e) => e[0] === "pause"), "outgoing audio silenced"); + + const playsBefore = transport.log.filter((e) => e[0] === "play").length; + await scheduler.flush(); + const load = transport.log.filter((e) => e[0] === "load").at(-1); + assert.equal(load[3], true, "the new track loads parked"); + assert.equal( + transport.log.filter((e) => e[0] === "play").length, + playsBefore, + "nothing auto-plays after parking" + ); +}); + test("a prefetched lease past 80% of its safe window is not trusted", async () => { const { service, scheduler, resolver, advance, manifest } = makeService({ plan: { @@ -238,6 +396,143 @@ test("a prefetched lease past 80% of its safe window is not trusted", async () = assert.equal(resolver.callCount("2"), 2, "stale prefetch re-resolved"); }); +// ------------------------------------------- crossfade (fade transports) -- + +/** Fade-capable transport double (the native transport's shape): fadeOut + * exists, and load records the crossfadeMs it was handed. */ +function makeFadeTransport() { + const log = []; + let dispatch = null; + let ctx = null; + const t = { + log, + async load(lease, c, opts) { + ctx = c; + log.push(["load", lease.opaqueLeaseId, c.generation, !!opts?.startPaused, opts?.crossfadeMs ?? 0]); + }, + async play() { + log.push(["play"]); + if (ctx) dispatch({ type: "MEDIA_PLAYING", ...ctx }); + }, + async pause() { + log.push(["pause"]); + }, + async seek() {}, + async fadeOut(ms) { + log.push(["fadeOut", ms]); + }, + async stop() { + ctx = null; + log.push(["stop"]); + }, + async dispose() { + log.push(["dispose"]); + }, + }; + return { transport: t, factory: (d) => ((dispatch = d), t) }; +} + +function makeFadeService({ plan, n = 3 } = {}) { + const scheduler = makeScheduler(); + const { transport, factory } = makeFadeTransport(); + const focus = makeFocus(); + const resolver = createFixtureResolver( + plan ?? { 1: { opaqueLeaseId: "op-1" }, 2: { opaqueLeaseId: "op-2" }, 3: { opaqueLeaseId: "op-3" } } + ); + const service = createPreviewService({ + resolver, + transportFactory: factory, + focus, + scheduler, + monotonicNow: () => 0, + }); + return { service, scheduler, transport, focus, manifest: manifest(n) }; +} + +test("fade transport: close crossfades — main returns at once, teardown waits for the tail", async () => { + const { service, scheduler, transport, focus, manifest } = makeFadeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + await drainMicrotasks(); // playing item 1 + const closing = service.close(); + await drainMicrotasks(); + assert.ok(transport.log.some((e) => e[0] === "fadeOut"), "the preview rings down"); + assert.equal(focus.calls.release, 1, "main resumes while the fade is still audible"); + assert.ok(!transport.log.some((e) => e[0] === "dispose"), "teardown holds for the fade tail"); + await scheduler.flush(); // the tail timer + await closing; + assert.ok(transport.log.some((e) => e[0] === "dispose"), "then the transport is torn down"); +}); + +test("fade transport: outgoing keeps sounding through a step — load's crossfade owns the handover", async () => { + const { service, scheduler, transport, manifest } = makeFadeService(); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); // playing op-1, op-2 prefetched + await drainMicrotasks(); + transport.log.length = 0; + + service.step(1); + assert.ok( + !transport.log.some((e) => e[0] === "fadeOut" || e[0] === "stop"), + "nothing retires the outgoing at step time — it must still be sounding" + ); + await scheduler.flush(); + const load = transport.log.find((e) => e[0] === "load"); + assert.equal(load[1], "op-2"); + assert.equal(load[4], 340, "audible step loads with the reel-slide-length crossfade"); + assert.ok( + !transport.log.some((e) => e[0] === "fadeOut"), + "no standalone fade — the crossfade inside load ramps the outgoing" + ); + assert.equal(scheduler.pending(), 0, "overstay guard cleared once the load landed"); +}); + +test("fade transport: failed resolve fades the outgoing out instead of leaving it playing", async () => { + const { service, scheduler, transport, manifest } = makeFadeService({ + plan: { + 1: { opaqueLeaseId: "op-1" }, + 2: { failure: "network", retryable: false }, + 3: { opaqueLeaseId: "op-3" }, + }, + }); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + await drainMicrotasks(); + service.step(1); + await scheduler.flush(); + assert.equal(service.getState().phase, PHASE.ITEM_FAILED); + assert.deepEqual( + transport.log.filter((e) => e[0] === "fadeOut"), + [["fadeOut", 340]], + "failure retires the outgoing with the standalone fade" + ); +}); + +test("fade transport: an overstaying resolve trips the guard and fades the outgoing", async () => { + const { service, scheduler, transport, manifest } = makeFadeService({ + plan: { + 1: { opaqueLeaseId: "op-1" }, + 2: () => new Promise(() => {}), // resolve never returns + 3: { opaqueLeaseId: "op-3" }, + }, + }); + await service.open({ sessionId: SID, manifest }); + await scheduler.flush(); + await drainMicrotasks(); + transport.log.length = 0; + + service.step(1); + await scheduler.flush(); // settle fires, pipeline blocks on the resolve, guard armed + assert.equal(scheduler.pending(), 1, "overstay guard is armed while the resolve hangs"); + await scheduler.flush(); // guard fires + assert.deepEqual( + transport.log.filter((e) => e[0] === "fadeOut"), + [["fadeOut", 340]], + "the outgoing is faded out rather than playing on under the new card" + ); + assert.ok(!transport.log.some((e) => e[0] === "load"), "nothing ever loaded"); +}); + // ------------------------------------------------------ close/handoff ----- test("close during resolve: terminal, transport torn down, focus released once", async () => {