feat: crossfade preview suite, loudness normalization, debug overlay

Preview modal audio, five rounds:
- True track-change crossfade: scheduleSettle no longer silences the
  outgoing clip at t=0; preview_load's equal-power fade owns both ramps,
  gated on the incoming voice's first Playing event (audible_marks), with
  failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's
  card-slide so audio and visuals land together.
- Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache
  (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits
  never re-download.
- Adaptive settle (isolated steps fire immediately, bursts coalesce) and
  anticipatory auto-advance (step at clip end minus 400 ms) so auto-play
  crossfades over the tail instead of gapping.
- Session resume memory: revisited tracks continue where they left off
  (decoder-level start offset via play_local_from/resume_at); finished
  tracks restart from the top.
- Open/close crossfades with the MAIN player: fade-pause with the
  desired-paused latch landing at ramp end, fade-resume from the paused
  position; MAIN_FADE_GEN guards; focus handshake relaxation documented
  in focusBridge.

Volume normalization (Settings > Playback, default on):
- Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting,
  400 ms gated blocks, incremental) + analyzer that reads the same growing
  file playback streams from — first plays are measured at download speed.
- Gain layered under user volume; audible start GATED on a preliminary
  measurement (10 s of audio or 800 ms budget) so the level is right from
  the first sample and never changes mid-track; prelim boosts capped 3 dB,
  peak-aware clipping guard, persistent loudness-cache.json.
- Gapless boundaries get their own sample-accurate gain via
  SetNextNormGainDb, fed by preload analysis.

Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set +
private-commit RAM / network rates / FPS / current-track download progress
read from the backend's own StreamProgress / live normalization state /
preview session phase. Polls only while visible.

131 JS + player + app tests green; every path verified live over CDP.
This commit is contained in:
KeelrLeek committed 2026-07-23 16:05:14 -05:00
1 parent 5e877c0b0a
commit 793f78370e
27 files changed
+2834 -178

No files matched your search

Generated
+76 -7
View File
@@ -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",
+9 -13
View File
@@ -6360,19 +6360,15 @@ pub async fn fetch_preview_media(url: &str) -> Result<PreviewMediaResponse, Prev
if !preview_url_allowed(url) {
return Err(PreviewFetchError::DisallowedUrl);
}
let resp = preview_media_client()
.get(url)
.send()
.await
.map_err(|e| {
// A redirect rejected by the policy surfaces as a generic
// reqwest error; classify it as the policy violation it is.
if e.is_redirect() {
PreviewFetchError::DisallowedUrl
} else {
PreviewFetchError::Network
}
})?;
let resp = preview_media_client().get(url).send().await.map_err(|e| {
// A redirect rejected by the policy surfaces as a generic
// reqwest error; classify it as the policy violation it is.
if e.is_redirect() {
PreviewFetchError::DisallowedUrl
} else {
PreviewFetchError::Network
}
})?;
let status = resp.status();
if !status.is_success() {
return Err(PreviewFetchError::Status(status.as_u16()));
+12 -3
View File
@@ -55,7 +55,10 @@ fn main() {
p.play_local(&file, Some(30.0));
let evs = collect(&rx, 2500);
assert!(saw_playing(&evs), "A: track should start");
let ack = p.pause_acked().recv_timeout(Duration::from_millis(1000)).expect("A: ack");
let ack = p
.pause_acked()
.recv_timeout(Duration::from_millis(1000))
.expect("A: ack");
let pos_at_ack = last_pos(&collect(&rx, 200)).unwrap_or(0.0);
let after = collect(&rx, 800);
let pos_later = last_pos(&after).unwrap_or(pos_at_ack);
@@ -79,7 +82,10 @@ fn main() {
let (p2, rx2) = Player::spawn();
p2.play_local(&file, Some(30.0));
// no waiting: the decoder thread is still setting up
let ack2 = p2.pause_acked().recv_timeout(Duration::from_millis(1000)).expect("B: ack");
let ack2 = p2
.pause_acked()
.recv_timeout(Duration::from_millis(1000))
.expect("B: ack");
let evs = collect(&rx2, 2500);
let became_audible = saw_playing(&evs);
let came_up_paused = evs.iter().any(|e| matches!(e, PlayerEvent::Paused));
@@ -87,7 +93,10 @@ fn main() {
"PROBE B ack-while-buffering: latched={} sink_was_active={} came_up_paused={} became_audible={}",
ack2.latched, ack2.sink_was_active, came_up_paused, became_audible
);
assert!(ack2.latched && !became_audible, "B failed: latch leaked audio");
assert!(
ack2.latched && !became_audible,
"B failed: latch leaked audio"
);
p2.stop();
drop(p2);
let _ = collect(&rx2, 400);
+7 -2
View File
@@ -65,7 +65,10 @@ fn main() {
drain("preview", &prev_rx, 2500, &mut log);
let started = log.iter().any(|l| l.contains("Playing"));
println!("PROBE preview-starts: {started} ({} ms to first events)", t0.elapsed().as_millis());
println!(
"PROBE preview-starts: {started} ({} ms to first events)",
t0.elapsed().as_millis()
);
prev.pause();
drain("preview", &prev_rx, 600, &mut log);
@@ -78,7 +81,9 @@ fn main() {
if let Some((_main, main_rx, ref path)) = main_pair {
let mut main_log = Vec::new();
drain("main", &main_rx, 1200, &mut main_log);
let main_alive = main_log.iter().any(|l| l.contains("Playing") || l.contains("Position"));
let main_alive = main_log
.iter()
.any(|l| l.contains("Playing") || l.contains("Position"));
println!("PROBE main-coexists: {main_alive} ({path})");
// cross-talk check: no preview-channel event may appear on main's
// channel or vice versa — separate receivers make this structural,
+115 -8
View File
@@ -6,10 +6,12 @@
//! short-lived helper threads, tagged with a generation counter so stale
//! decodes are discarded when the user has already moved on.
mod loudness;
mod source;
mod waveform;
pub use source::StreamProgress;
pub use loudness::{analyze_loudness, AnalysisSource, LoudnessMeter};
pub use source::{GrowingFile, StreamProgress};
pub use waveform::compute_waveform;
use std::collections::VecDeque;
@@ -22,7 +24,6 @@ use std::time::{Duration, Instant};
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source};
use source::GrowingFile;
/// Acknowledgement for [`Player::pause_acked`], sent from the audio thread
/// AFTER the pause has been applied. Semantics, stated honestly:
@@ -69,6 +70,10 @@ enum Cmd {
stream: Option<Arc<StreamProgress>>,
total_bytes: u64,
duration_hint: Option<f64>,
/// Start playback at this offset (seconds): the decoder comes up
/// already positioned (same mechanism as the device-change resume),
/// so there is no play-then-seek race and no audible blip at 0.
start_at_secs: Option<f64>,
},
/// Decode and append the next local track before the active one ends.
Preload {
@@ -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<String>,
reply: mpsc::Sender<Result<(), String>>,
@@ -233,11 +252,23 @@ impl Player {
}
pub fn play_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
self.play_local_from(path, duration_hint, None);
}
/// `play_local`, but the decoder comes up at `start_at_secs` — used by
/// the preview session's resume memory. `None`/`0` is a plain start.
pub fn play_local_from(
&self,
path: impl Into<PathBuf>,
duration_hint: Option<f64>,
start_at_secs: Option<f64>,
) {
let _ = self.cmd_tx.send(Cmd::Play {
path: path.into(),
stream: None,
total_bytes: 0,
duration_hint,
start_at_secs: start_at_secs.filter(|s| *s > 0.0),
});
}
@@ -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<String>) -> Result<(), String> {
@@ -446,6 +497,15 @@ fn audio_thread(
let mut generation: u64 = 0;
let mut duration: Option<f64> = None;
let mut volume: f32 = 0.7;
// Loudness-normalization gain, layered under the user volume. `norm_db`
// is what the sink currently gets; `norm_target_db` is where the slew in
// the tick arm is heading (~2.5 dB/s — a refinement mid-track drifts,
// it never steps). Every new Play resets both to unity.
let mut norm_db: f32 = 0.0;
let mut norm_target_db: f32 = 0.0;
// Gain waiting for the next gapless boundary (None → unity there).
let mut pending_norm_db: Option<f32> = None;
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<Arc<StreamProgress>> = 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;
+418
View File
@@ -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<f64>,
/// 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<f64> {
if self.subblocks.len() < SUBBLOCKS_PER_BLOCK {
return None;
}
let block_energy = |i: usize| -> f64 {
self.subblocks[i..i + SUBBLOCKS_PER_BLOCK]
.iter()
.sum::<f64>()
/ 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<StreamProgress>,
},
}
/// 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 "<name>.<ext>.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<SampleBuffer<f32>> = None;
let mut meter: Option<LoudnessMeter> = 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<f32> {
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());
}
}
+6
View File
@@ -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();
+5
View File
@@ -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",
] }
+79 -9
View File
@@ -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<u64>,
stream_source_prefs: Option<config::SourcePrefs>,
download_source_prefs: Option<config::SourcePrefs>,
volume_normalization: Option<bool>,
) -> 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::<PlayerState>()
.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::<PlayerState>()
.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::<PlayerState>()
.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<f64>,
) -> 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::<PlayerState>().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<f64>,
@@ -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(())
}
+8
View File
@@ -129,6 +129,10 @@ pub struct AppConfig {
/// Some(0) → unlimited.
#[serde(default)]
pub stream_cache_max_mb: Option<u64>,
/// 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<bool>,
}
/// 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
+281
View File
@@ -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<Inner>);
struct Inner {
sys: System,
networks: Networks,
last_net: Instant,
gpu: Option<GpuCounter>,
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<StreamProgress>)>,
}
/// Called by the play paths: this track streams from a peer.
pub fn note_stream(app: &tauri::AppHandle, total: u64, progress: Arc<StreamProgress>) {
if let Some(state) = app.try_state::<DiagState>() {
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::<DiagState>() {
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<f32>,
/// 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<u64>,
pub dl_total: Option<u64>,
pub dl_done: Option<bool>,
pub normalization_enabled: bool,
pub target_lufs: f64,
pub loudness: Option<crate::loudness::CurrentTrack>,
}
#[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::<PROCESS_MEMORY_COUNTERS>(),
std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() 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<GpuCounter> {
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<f32> {
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);
}
}
}
+7
View File
@@ -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)]
+421
View File
@@ -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<f64>,
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<HashMap<String, Measured>>,
/// Bumped on every track start; orphans older analyzer threads.
generation: Arc<AtomicU64>,
enabled: AtomicBool,
/// Debug-overlay mirror of the active track's measurement state.
current: Mutex<Option<CurrentTrack>>,
}
/// (normalization enabled, target LUFS, active-track state) for diagnostics.
pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option<CurrentTrack>) {
let state = app.state::<LoudnessState>();
let current = *state.current.lock().expect("loudness current lock");
(
state.enabled.load(Ordering::Relaxed),
TARGET_LUFS,
current,
)
}
fn cache_file(app: &tauri::AppHandle) -> Option<PathBuf> {
app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE))
}
pub fn init(app: &tauri::AppHandle) {
let cache: HashMap<String, Measured> = 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::<LoudnessState>();
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::<PlayerState>().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::<LoudnessState>();
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<StreamProgress>)>,
play: Box<dyn FnOnce() + Send>,
) {
let state = app.state::<LoudnessState>();
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::<PlayerState>()
.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<f64> = None;
let mut first_measure: Option<std::time::Instant> = 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::<PlayerState>()
.player
.set_norm_gain_db(g as f32, true);
applied_db = Some(g);
*app.state::<LoudnessState>()
.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::<LoudnessState>()
.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::<LoudnessState>();
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::<PlayerState>()
.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::<PlayerState>()
.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);
}
}
+430 -50
View File
@@ -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<Player>; 2],
active: Arc<AtomicUsize>,
/// 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<AtomicU64>,
/// 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<HashMap<String, PathBuf>>,
/// 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<HashMap<u64, (PathBuf, Instant)>>,
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<AtomicU64>,
voices: [Arc<Player>; 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<Player>; 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<PreviewResolveResult> {
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<PreviewResolveResult, String> {
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<u64>,
start_at_secs: Option<f64>,
) -> 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<f64>,
) -> 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::<PlayerState>();
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::<PlayerState>();
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 {
+14 -10
View File
@@ -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}
>
<TitleBar />
<!-- Previews take over the whole window (decision 2026-07-22): while the
modal is open the page and the player bar are inert/not rendered —
there is deliberately NO UI path to command main audio behind the
focus coordinator's back. The titlebar stays live for window chrome. -->
<!-- While the preview modal is open the page is inert and the flanking
panels + player bar are pointer-inert under the scrim (user decision
2026-07-23: the bar stays VISIBLE, dimmed, like Spotify — un-rendering
it left a black hole in the grid's bottom row). There is still no UI
path to command main audio behind the focus coordinator's back: the
scrim intercepts pointer input and the dialog traps the keyboard.
The titlebar stays live for window chrome. -->
<Sidebar />
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
<CurrentView />
</main>
<NowPlaying />
{#if !previewUi.open}
<PlayerBar />
{/if}
<PlayerBar />
<PreviewOverlay />
<ArtworkViewer />
<LoginModal />
<DebugOverlay />
</div>
<style>
@@ -74,10 +77,11 @@
no separate chrome color, the panels float beneath it. */
/* While the preview modal is open: keyboard is contained by the dialog's
focus trap and aria-modal, the content pane is `inert`, and the flanking
panels stop taking pointer input here. (The player bar is simply not
rendered — see the markup note above.) */
panels + player bar stop taking pointer input here — the bar stays
visible (dimmed under the scrim) but cannot command main audio. */
.app.preview-open :global(.sidebar),
.app.preview-open :global(.nowplaying) {
.app.preview-open :global(.nowplaying),
.app.preview-open :global(.player) {
pointer-events: none;
}
+193
View File
@@ -0,0 +1,193 @@
<script>
// Dev debug overlay — Ctrl+Shift+D. Read-only glass pane over the app:
// system + process-tree CPU/GPU/RAM, network rates, the active track's
// download progress/speed and loudness-normalization state, renderer
// FPS, and the preview session when one is open. Polls the backend only
// while visible; costs nothing when hidden.
import * as api from "../lib/api.js";
import { player } from "../lib/state.svelte.js";
import { previewUi } from "../lib/preview/previewSession.svelte.js";
let visible = $state(false);
let stats = $state(null);
let fps = $state(0);
let dlSpeed = $state(0); // bytes/sec of the CURRENT track's stream
function onKeydown(e) {
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
e.preventDefault();
visible = !visible;
}
}
// Poll + FPS only while visible.
$effect(() => {
if (!visible) return;
let lastReceived = 0;
let lastAt = performance.now();
const tick = async () => {
try {
stats = await api.debugStats();
} catch {
stats = null;
}
// Download speed from the BACKEND's own progress handle — the
// player store's mirror is window-activity-gated and can lag.
const now = performance.now();
const cur = stats?.dlReceived ?? 0;
const dt = (now - lastAt) / 1000;
dlSpeed = cur >= lastReceived && dt > 0 ? (cur - lastReceived) / dt : 0;
lastReceived = cur;
lastAt = now;
};
void tick();
const id = setInterval(tick, 1000);
let frames = 0;
let fpsWindow = performance.now();
let raf = requestAnimationFrame(function loop() {
frames += 1;
const now = performance.now();
if (now - fpsWindow >= 500) {
fps = Math.round((frames * 1000) / (now - fpsWindow));
frames = 0;
fpsWindow = now;
}
raf = requestAnimationFrame(loop);
});
return () => {
clearInterval(id);
cancelAnimationFrame(raf);
};
});
const mb = (bytes) => (bytes / 1048576).toFixed(bytes >= 10485760 ? 0 : 1);
const gb = (bytes) => (bytes / 1073741824).toFixed(1);
function rate(bps) {
if (bps >= 1048576) return `${(bps / 1048576).toFixed(1)} MB/s`;
return `${(bps / 1024).toFixed(0)} KB/s`;
}
function fmtUptime(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m${String(secs % 60).padStart(2, "0")}s`;
}
const dlTotal = $derived(stats?.dlTotal ?? 0);
const dlDone = $derived(stats?.dlReceived ?? 0);
const loud = $derived(stats?.loudness ?? null);
</script>
<svelte:window onkeydown={onKeydown} />
{#if visible}
<aside class="debug-overlay" aria-hidden="true">
<div class="hdr">DEBUG <span class="dim">Ctrl+Shift+D</span></div>
{#if stats}
<div class="sec">
<div><b>CPU</b> app {stats.cpuProcess.toFixed(1)}% · sys {stats.cpuSystem.toFixed(1)}%</div>
<div><b>GPU</b> {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}% (3D)` : "n/a"}</div>
<div><b>RAM</b> ws {mb(stats.memWorkingSet)} MB · reserved {mb(stats.memPrivate)} MB</div>
<div class="dim">system {gb(stats.memSystemUsed)} / {gb(stats.memSystemTotal)} GB · {stats.processCount} procs</div>
<div><b>NET</b> ↓ {rate(stats.netRxBps)} · ↑ {rate(stats.netTxBps)}</div>
</div>
<div class="sec">
<div><b>FPS</b> {fps}</div>
<div><b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}</div>
{#if dlTotal > 0}
<div>
<b>DL</b>
{mb(dlDone)} / {mb(dlTotal)} MB ({((dlDone / dlTotal) * 100).toFixed(0)}%)
{#if dlDone < dlTotal}· {rate(dlSpeed)}{:else}· done{/if}
</div>
<div class="bar"><i style="width:{Math.min(100, (dlDone / dlTotal) * 100)}%"></i></div>
{:else}
<div class="dim">DL — no active stream</div>
{/if}
</div>
<div class="sec">
<div>
<b>NORM</b>
{#if !stats.normalizationEnabled}off{:else if loud}
{loud.lufs != null ? `${loud.lufs.toFixed(1)} LUFS` : "measuring…"}
→ {loud.gainDb >= 0 ? "+" : ""}{loud.gainDb.toFixed(1)} dB
<span class="dim">(target {stats.targetLufs})</span>
{:else}on · idle{/if}
</div>
{#if stats.normalizationEnabled && loud}
<div class="dim">
peak {loud.peak.toFixed(2)}
· {loud.cached ? "cache hit" : loud.analyzing ? "analyzing" : "measured this play"}
</div>
{/if}
{#if previewUi.open}
<div>
<b>PREVIEW</b>
{previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} ·
idx {previewUi.snapshot?.activeManifestIndex ?? "—"}
</div>
{/if}
</div>
<div class="dim ft">up {fmtUptime(stats.uptimeSecs)}</div>
{:else}
<div class="dim">waiting for backend…</div>
{/if}
</aside>
{/if}
<style>
.debug-overlay {
position: fixed;
top: 40px;
right: 12px;
z-index: 2147483000;
pointer-events: none;
min-width: 300px;
max-width: 380px;
padding: 10px 12px;
background: rgba(8, 10, 14, 0.82);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
backdrop-filter: blur(4px);
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 11px;
line-height: 1.55;
color: #d7e0ea;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.hdr {
font-weight: 700;
letter-spacing: 0.08em;
color: #7fd0ff;
margin-bottom: 4px;
}
.sec {
padding: 4px 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
b {
color: #9fc1e0;
font-weight: 600;
display: inline-block;
min-width: 46px;
}
.dim {
color: rgba(215, 224, 234, 0.55);
}
.ft {
margin-top: 2px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 4px;
}
.bar {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
margin: 2px 0 1px;
}
.bar i {
display: block;
height: 100%;
background: #7fd0ff;
}
</style>
+147 -18
View File
@@ -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 @@
<svelte:window onkeydown={onKeydown} />
{#if previewUi.open && st}
<div class="scrim" onclick={() => closePreview()} aria-hidden="true"></div>
<!-- Scale & fade (user pick 2026-07-23, variant A of the motion mockup):
240 ms in from 96%, 190 ms out to 97%, scrim fading alongside. -->
<div
class="scrim"
onclick={() => closePreview()}
aria-hidden="true"
in:fade={{ duration: 240 }}
out:fade={{ duration: 190 }}
></div>
<div
class="modal"
class:is-playing={playing}
@@ -419,6 +449,8 @@
aria-label={manifest ? `Track previews: ${manifest.context.title}` : "Track previews"}
style="--pv-accent: {pvAccent}; --pv-accent-hover: {pvHover};"
bind:this={dialogEl}
in:scale={{ duration: 240, start: 0.96, easing: cubicOut }}
out:scale={{ duration: 190, start: 0.97 }}
>
<div class="bg" aria-hidden="true">
<img class="bleed" class:show={bleedSlot === 1 && bleedA} src={bleedA || undefined} alt="" />
@@ -490,18 +522,18 @@
class="m-step"
title="Previous preview (↑)"
aria-label="Previous preview"
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0}
disabled={prevDisabled}
onclick={() => step(-1)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2.2v12H6zm3.8 6l8.2 6V6z" /></svg>
</button>
<button
class="m-play"
title={playing ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
aria-label={playing ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
title={wantsPlay ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
aria-label={wantsPlay ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
onclick={() => previewCommands.togglePlay()}
>
{#if playing}
{#if wantsPlay}
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
{:else if ended}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /></svg>
@@ -513,7 +545,7 @@
class="m-step"
title="Next preview (↓)"
aria-label="Next preview"
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i > activeIndex) : cursor >= playable.length - 1}
disabled={nextDisabled}
onclick={() => step(1)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.8 6H18v12h-2.2zM6 6l8.2 6L6 18z" /></svg>
@@ -574,6 +606,26 @@
{/each}
</div>
</div>
<div class="reel-arrows">
<button
class="reel-arrow"
title="Previous preview (↑)"
aria-label="Previous preview"
disabled={prevDisabled}
onclick={() => step(-1)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14.5l6-6 6 6" /></svg>
</button>
<button
class="reel-arrow"
title="Next preview (↓)"
aria-label="Next preview"
disabled={nextDisabled}
onclick={() => step(1)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9.5l6 6 6-6" /></svg>
</button>
</div>
</div>
</div>
@@ -589,7 +641,13 @@
{/each}
</span>
<span class="m-pos">
{cursor != null ? `${cursor + 1} of ${playable.length}` : `unavailable · ${(activeIndex ?? 0) + 1} of ${items.length}`}
{#if cursor != null}
<!-- Reserve the widest digit count so stepping 9→10
never widens the label and reflows the flex rail. -->
<span class="pos-n" style="min-width: {String(playable.length).length}ch">{cursor + 1}</span> of {playable.length}
{:else}
unavailable · {(activeIndex ?? 0) + 1} of {items.length}
{/if}
{#if unavailableCount > 0}&nbsp;· {unavailableCount} unavailable{/if}
</span>
</div>
@@ -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;
+7 -2
View File
@@ -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");
+29 -9
View File
@@ -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) => {
+12 -1
View File
@@ -14,6 +14,17 @@ function availabilityFromCatalogTrack(t) {
return AVAILABILITY.NOT_PROVIDED;
}
/** Deezer CDN cover URLs are size-addressed (`…/cover/<md5>/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),
+18 -2
View File
@@ -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");
+12 -6
View File
@@ -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;
}
+173 -33
View File
@@ -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;
},
};
+33 -2
View File
@@ -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;
+4 -1
View File
@@ -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);
},
});
+21
View File
@@ -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}
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Normalize volume</div>
<div class="hint">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.</div>
</div>
<select
class="device-select"
value={settings.volume_normalization ? "on" : "off"}
disabled={saving}
onchange={(e) => setVolumeNormalization(e.currentTarget.value === "on")}
>
<option value="on">On (recommended)</option>
<option value="off">Off</option>
</select>
</div>
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
</section>
+297 -2
View File
@@ -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 () => {