forked from Keelrleek/SoulStream
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:
1 parent
5e877c0b0a
commit
793f78370e
27 files changed
+2789
-133
No files matched your search
Generated
+76
-7
@@ -161,7 +161,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"image",
|
"image",
|
||||||
@@ -174,6 +174,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"soulseek-client",
|
"soulseek-client",
|
||||||
"soulseek-proto",
|
"soulseek-proto",
|
||||||
|
"sysinfo",
|
||||||
"tagger",
|
"tagger",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -699,6 +700,25 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -1015,6 +1035,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.11"
|
version = "3.0.11"
|
||||||
@@ -2270,7 +2296,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "library"
|
name = "library"
|
||||||
version = "0.1.0"
|
version = "0.1.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -2401,7 +2427,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "metadata"
|
name = "metadata"
|
||||||
version = "0.1.0"
|
version = "0.1.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
@@ -2525,6 +2551,15 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ntapi"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.8"
|
version = "0.4.8"
|
||||||
@@ -3042,7 +3077,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "player"
|
name = "player"
|
||||||
version = "0.1.0"
|
version = "0.1.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
@@ -3301,6 +3336,26 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -4020,7 +4075,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "soulseek-client"
|
name = "soulseek-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -4034,7 +4089,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "soulseek-proto"
|
name = "soulseek-proto"
|
||||||
version = "0.1.0"
|
version = "0.1.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -4358,6 +4413,20 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -4394,7 +4463,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tagger"
|
name = "tagger"
|
||||||
version = "0.1.0"
|
version = "0.1.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lofty",
|
"lofty",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -6360,11 +6360,7 @@ pub async fn fetch_preview_media(url: &str) -> Result<PreviewMediaResponse, Prev
|
|||||||
if !preview_url_allowed(url) {
|
if !preview_url_allowed(url) {
|
||||||
return Err(PreviewFetchError::DisallowedUrl);
|
return Err(PreviewFetchError::DisallowedUrl);
|
||||||
}
|
}
|
||||||
let resp = preview_media_client()
|
let resp = preview_media_client().get(url).send().await.map_err(|e| {
|
||||||
.get(url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
// A redirect rejected by the policy surfaces as a generic
|
// A redirect rejected by the policy surfaces as a generic
|
||||||
// reqwest error; classify it as the policy violation it is.
|
// reqwest error; classify it as the policy violation it is.
|
||||||
if e.is_redirect() {
|
if e.is_redirect() {
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ fn main() {
|
|||||||
p.play_local(&file, Some(30.0));
|
p.play_local(&file, Some(30.0));
|
||||||
let evs = collect(&rx, 2500);
|
let evs = collect(&rx, 2500);
|
||||||
assert!(saw_playing(&evs), "A: track should start");
|
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 pos_at_ack = last_pos(&collect(&rx, 200)).unwrap_or(0.0);
|
||||||
let after = collect(&rx, 800);
|
let after = collect(&rx, 800);
|
||||||
let pos_later = last_pos(&after).unwrap_or(pos_at_ack);
|
let pos_later = last_pos(&after).unwrap_or(pos_at_ack);
|
||||||
@@ -79,7 +82,10 @@ fn main() {
|
|||||||
let (p2, rx2) = Player::spawn();
|
let (p2, rx2) = Player::spawn();
|
||||||
p2.play_local(&file, Some(30.0));
|
p2.play_local(&file, Some(30.0));
|
||||||
// no waiting: the decoder thread is still setting up
|
// 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 evs = collect(&rx2, 2500);
|
||||||
let became_audible = saw_playing(&evs);
|
let became_audible = saw_playing(&evs);
|
||||||
let came_up_paused = evs.iter().any(|e| matches!(e, PlayerEvent::Paused));
|
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={}",
|
"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
|
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();
|
p2.stop();
|
||||||
drop(p2);
|
drop(p2);
|
||||||
let _ = collect(&rx2, 400);
|
let _ = collect(&rx2, 400);
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ fn main() {
|
|||||||
|
|
||||||
drain("preview", &prev_rx, 2500, &mut log);
|
drain("preview", &prev_rx, 2500, &mut log);
|
||||||
let started = log.iter().any(|l| l.contains("Playing"));
|
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();
|
prev.pause();
|
||||||
drain("preview", &prev_rx, 600, &mut log);
|
drain("preview", &prev_rx, 600, &mut log);
|
||||||
@@ -78,7 +81,9 @@ fn main() {
|
|||||||
if let Some((_main, main_rx, ref path)) = main_pair {
|
if let Some((_main, main_rx, ref path)) = main_pair {
|
||||||
let mut main_log = Vec::new();
|
let mut main_log = Vec::new();
|
||||||
drain("main", &main_rx, 1200, &mut main_log);
|
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})");
|
println!("PROBE main-coexists: {main_alive} ({path})");
|
||||||
// cross-talk check: no preview-channel event may appear on main's
|
// cross-talk check: no preview-channel event may appear on main's
|
||||||
// channel or vice versa — separate receivers make this structural,
|
// channel or vice versa — separate receivers make this structural,
|
||||||
|
|||||||
+115
-8
@@ -6,10 +6,12 @@
|
|||||||
//! short-lived helper threads, tagged with a generation counter so stale
|
//! short-lived helper threads, tagged with a generation counter so stale
|
||||||
//! decodes are discarded when the user has already moved on.
|
//! decodes are discarded when the user has already moved on.
|
||||||
|
|
||||||
|
mod loudness;
|
||||||
mod source;
|
mod source;
|
||||||
mod waveform;
|
mod waveform;
|
||||||
|
|
||||||
pub use source::StreamProgress;
|
pub use loudness::{analyze_loudness, AnalysisSource, LoudnessMeter};
|
||||||
|
pub use source::{GrowingFile, StreamProgress};
|
||||||
pub use waveform::compute_waveform;
|
pub use waveform::compute_waveform;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
@@ -22,7 +24,6 @@ use std::time::{Duration, Instant};
|
|||||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source};
|
use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source};
|
||||||
|
|
||||||
use source::GrowingFile;
|
|
||||||
|
|
||||||
/// Acknowledgement for [`Player::pause_acked`], sent from the audio thread
|
/// Acknowledgement for [`Player::pause_acked`], sent from the audio thread
|
||||||
/// AFTER the pause has been applied. Semantics, stated honestly:
|
/// AFTER the pause has been applied. Semantics, stated honestly:
|
||||||
@@ -69,6 +70,10 @@ enum Cmd {
|
|||||||
stream: Option<Arc<StreamProgress>>,
|
stream: Option<Arc<StreamProgress>>,
|
||||||
total_bytes: u64,
|
total_bytes: u64,
|
||||||
duration_hint: Option<f64>,
|
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.
|
/// Decode and append the next local track before the active one ends.
|
||||||
Preload {
|
Preload {
|
||||||
@@ -94,6 +99,20 @@ enum Cmd {
|
|||||||
secs: f64,
|
secs: f64,
|
||||||
},
|
},
|
||||||
Volume(f32),
|
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 {
|
SetOutputDevice {
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
reply: mpsc::Sender<Result<(), 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>) {
|
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 {
|
let _ = self.cmd_tx.send(Cmd::Play {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
stream: None,
|
stream: None,
|
||||||
total_bytes: 0,
|
total_bytes: 0,
|
||||||
duration_hint,
|
duration_hint,
|
||||||
|
start_at_secs: start_at_secs.filter(|s| *s > 0.0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +295,7 @@ impl Player {
|
|||||||
stream: Some(progress),
|
stream: Some(progress),
|
||||||
total_bytes,
|
total_bytes,
|
||||||
duration_hint,
|
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)));
|
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
|
/// Change devices immediately. The audio thread acknowledges only after
|
||||||
/// the endpoint has opened successfully, so callers can safely persist it.
|
/// the endpoint has opened successfully, so callers can safely persist it.
|
||||||
pub fn set_output_device(&self, device: Option<String>) -> Result<(), String> {
|
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 generation: u64 = 0;
|
||||||
let mut duration: Option<f64> = None;
|
let mut duration: Option<f64> = None;
|
||||||
let mut volume: f32 = 0.7;
|
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;
|
let mut playing = false;
|
||||||
// The active stream source, so we can abort its blocking reads.
|
// The active stream source, so we can abort its blocking reads.
|
||||||
let mut active_stream: Option<Arc<StreamProgress>> = None;
|
let mut active_stream: Option<Arc<StreamProgress>> = None;
|
||||||
@@ -486,6 +546,7 @@ fn audio_thread(
|
|||||||
stream,
|
stream,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
duration_hint,
|
duration_hint,
|
||||||
|
start_at_secs,
|
||||||
}) => {
|
}) => {
|
||||||
// Abort whatever was playing or buffering.
|
// Abort whatever was playing or buffering.
|
||||||
generation += 1;
|
generation += 1;
|
||||||
@@ -501,6 +562,12 @@ fn audio_thread(
|
|||||||
playing = false;
|
playing = false;
|
||||||
scratching = false;
|
scratching = false;
|
||||||
desired_paused = false; // a new Play is explicit listen intent
|
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 {
|
let track = TrackSource {
|
||||||
path,
|
path,
|
||||||
stream,
|
stream,
|
||||||
@@ -513,7 +580,14 @@ fn audio_thread(
|
|||||||
|
|
||||||
active_stream = track.stream.clone();
|
active_stream = track.stream.clone();
|
||||||
active_track = Some(track.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 {
|
Ok(Cmd::Preload {
|
||||||
path,
|
path,
|
||||||
@@ -598,7 +672,7 @@ fn audio_thread(
|
|||||||
match decode_scratch_window(path, secs, rate) {
|
match decode_scratch_window(path, secs, rate) {
|
||||||
Ok((samples, speed)) => {
|
Ok((samples, speed)) => {
|
||||||
let s = Sink::connect_new(out.mixer());
|
let s = Sink::connect_new(out.mixer());
|
||||||
s.set_volume(volume);
|
s.set_volume(eff(volume, norm_db));
|
||||||
s.set_speed(speed);
|
s.set_speed(speed);
|
||||||
s.append(samples);
|
s.append(samples);
|
||||||
s.play();
|
s.play();
|
||||||
@@ -626,7 +700,7 @@ fn audio_thread(
|
|||||||
match local_decoder(path) {
|
match local_decoder(path) {
|
||||||
Ok(decoder) => {
|
Ok(decoder) => {
|
||||||
let s = Sink::connect_new(out.mixer());
|
let s = Sink::connect_new(out.mixer());
|
||||||
s.set_volume(volume);
|
s.set_volume(eff(volume, norm_db));
|
||||||
queued_sources.store(1, Ordering::SeqCst);
|
queued_sources.store(1, Ordering::SeqCst);
|
||||||
s.append(EndNotifier {
|
s.append(EndNotifier {
|
||||||
inner: decoder,
|
inner: decoder,
|
||||||
@@ -657,9 +731,22 @@ fn audio_thread(
|
|||||||
Ok(Cmd::Volume(v)) => {
|
Ok(Cmd::Volume(v)) => {
|
||||||
volume = v;
|
volume = v;
|
||||||
if let Some(s) = &sink {
|
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 }) => {
|
Ok(Cmd::SetOutputDevice { device, reply }) => {
|
||||||
output_change = Some((device, Some(reply)));
|
output_change = Some((device, Some(reply)));
|
||||||
}
|
}
|
||||||
@@ -674,13 +761,25 @@ fn audio_thread(
|
|||||||
queued_sources.store(0, Ordering::SeqCst);
|
queued_sources.store(0, Ordering::SeqCst);
|
||||||
waiting_preload = None;
|
waiting_preload = None;
|
||||||
pending_tracks.clear();
|
pending_tracks.clear();
|
||||||
|
pending_norm_db = None;
|
||||||
playing = false;
|
playing = false;
|
||||||
scratching = false;
|
scratching = false;
|
||||||
local_path = None;
|
local_path = None;
|
||||||
active_track = None;
|
active_track = None;
|
||||||
emit(PlayerEvent::Stopped);
|
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,
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +882,7 @@ fn audio_thread(
|
|||||||
let track_duration = ready.track_duration();
|
let track_duration = ready.track_duration();
|
||||||
if !is_preload {
|
if !is_preload {
|
||||||
let s = Sink::connect_new(out.mixer());
|
let s = Sink::connect_new(out.mixer());
|
||||||
s.set_volume(volume);
|
s.set_volume(eff(volume, norm_db));
|
||||||
sink = Some(s);
|
sink = Some(s);
|
||||||
active_track = Some(ready.track.clone());
|
active_track = Some(ready.track.clone());
|
||||||
active_stream = ready.track.stream.clone();
|
active_stream = ready.track.stream.clone();
|
||||||
@@ -864,6 +963,14 @@ fn audio_thread(
|
|||||||
active_track = Some(next.track);
|
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;
|
last_queued = q;
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,12 @@ impl StreamProgress {
|
|||||||
self.cv.notify_all();
|
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).
|
/// Unblock any waiting reads permanently (track switched / stopped).
|
||||||
pub fn abort(&self) {
|
pub fn abort(&self) {
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock().unwrap();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ tokio.workspace = true
|
|||||||
|
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
keyring = { version = "3", features = ["windows-native"] }
|
keyring = { version = "3", features = ["windows-native"] }
|
||||||
|
sysinfo = "0.33"
|
||||||
soulseek-proto = { path = "../crates/soulseek-proto" }
|
soulseek-proto = { path = "../crates/soulseek-proto" }
|
||||||
soulseek-client = { path = "../crates/soulseek-client" }
|
soulseek-client = { path = "../crates/soulseek-client" }
|
||||||
player = { path = "../crates/player" }
|
player = { path = "../crates/player" }
|
||||||
@@ -41,5 +42,9 @@ windows = { version = "0.61.3", features = [
|
|||||||
"Media_FaceAnalysis",
|
"Media_FaceAnalysis",
|
||||||
"Media_Ocr",
|
"Media_Ocr",
|
||||||
"Storage_Streams",
|
"Storage_Streams",
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Performance",
|
||||||
|
"Win32_System_ProcessStatus",
|
||||||
|
"Win32_System_Threading",
|
||||||
"Win32_System_WinRT",
|
"Win32_System_WinRT",
|
||||||
] }
|
] }
|
||||||
@@ -75,6 +75,7 @@ pub struct Settings {
|
|||||||
pub stream_cache_max_mb: u64,
|
pub stream_cache_max_mb: u64,
|
||||||
pub stream_source_prefs: config::SourcePrefs,
|
pub stream_source_prefs: config::SourcePrefs,
|
||||||
pub download_source_prefs: config::SourcePrefs,
|
pub download_source_prefs: config::SourcePrefs,
|
||||||
|
pub volume_normalization: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -112,6 +113,7 @@ pub async fn get_settings(
|
|||||||
stream_cache_max_mb: config::stream_cache_max_mb(&cfg),
|
stream_cache_max_mb: config::stream_cache_max_mb(&cfg),
|
||||||
stream_source_prefs: cfg.stream_source_prefs,
|
stream_source_prefs: cfg.stream_source_prefs,
|
||||||
download_source_prefs: cfg.download_source_prefs,
|
download_source_prefs: cfg.download_source_prefs,
|
||||||
|
volume_normalization: config::volume_normalization(&cfg),
|
||||||
username: cfg.username,
|
username: cfg.username,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,7 @@ pub async fn set_settings(
|
|||||||
stream_cache_max_mb: Option<u64>,
|
stream_cache_max_mb: Option<u64>,
|
||||||
stream_source_prefs: Option<config::SourcePrefs>,
|
stream_source_prefs: Option<config::SourcePrefs>,
|
||||||
download_source_prefs: Option<config::SourcePrefs>,
|
download_source_prefs: Option<config::SourcePrefs>,
|
||||||
|
volume_normalization: Option<bool>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut cfg = config::load(&app);
|
let mut cfg = config::load(&app);
|
||||||
if let Some(d) = download_dir {
|
if let Some(d) = download_dir {
|
||||||
@@ -197,6 +200,12 @@ pub async fn set_settings(
|
|||||||
if stream_cache_max_mb.is_some() {
|
if stream_cache_max_mb.is_some() {
|
||||||
cfg.stream_cache_max_mb = stream_cache_max_mb;
|
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);
|
config::save(&app, &cfg);
|
||||||
|
|
||||||
// Tighter limits take effect right away, not at the next stream/startup.
|
// 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 { .. }) {
|
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 —
|
// Only explicit saves reach the library —
|
||||||
// unless streamed tracks are configured to
|
// unless streamed tracks are configured to
|
||||||
// be kept forever.
|
// be kept forever.
|
||||||
@@ -3272,7 +3287,20 @@ pub async fn play_stream(
|
|||||||
if let Ok(md) = std::fs::metadata(&cache) {
|
if let Ok(md) = std::fs::metadata(&cache) {
|
||||||
if size > 0 && md.len() == size {
|
if size > 0 && md.len() == size {
|
||||||
playback::touch(&cache);
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&cache);
|
let _ = std::fs::remove_file(&cache);
|
||||||
@@ -3300,9 +3328,21 @@ pub async fn play_stream(
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(id, cache.clone());
|
.insert(id, cache.clone());
|
||||||
players
|
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
|
.player
|
||||||
.play_stream(&partial, progress, size, duration);
|
.play_stream(&partial2, progress2, size, duration);
|
||||||
|
}),
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3321,9 +3361,21 @@ pub async fn play_stream(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(id, cache.clone());
|
.insert(id, cache.clone());
|
||||||
|
|
||||||
players
|
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
|
.player
|
||||||
.play_stream(&partial, progress, size, duration);
|
.play_stream(&partial2, progress2, size, duration);
|
||||||
|
}),
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3342,6 +3394,8 @@ pub async fn preload_stream(
|
|||||||
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
|
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
|
||||||
if let Ok(md) = std::fs::metadata(&cache) {
|
if let Ok(md) = std::fs::metadata(&cache) {
|
||||||
if size > 0 && md.len() == size {
|
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
|
return Ok(()); // already fully cached
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&cache);
|
let _ = std::fs::remove_file(&cache);
|
||||||
@@ -3383,6 +3437,8 @@ pub fn preload_cached_stream(
|
|||||||
Ok(md) if size == 0 || md.len() == size => {
|
Ok(md) if size == 0 || md.len() == size => {
|
||||||
playback::touch(&cache);
|
playback::touch(&cache);
|
||||||
players.player.preload_local(&cache, duration);
|
players.player.preload_local(&cache, duration);
|
||||||
|
// Gapless normalization: boundary gain ready before the switch.
|
||||||
|
crate::loudness::on_preload_ready(&app, &cache);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
_ => Ok(false),
|
_ => Ok(false),
|
||||||
@@ -3485,14 +3541,25 @@ pub async fn stream_waveform(
|
|||||||
/// Play an already-downloaded local file.
|
/// Play an already-downloaded local file.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn play_local(
|
pub fn play_local(
|
||||||
players: State<'_, PlayerState>,
|
app: tauri::AppHandle,
|
||||||
path: String,
|
path: String,
|
||||||
duration: Option<f64>,
|
duration: Option<f64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if std::fs::metadata(&path).is_err() {
|
if std::fs::metadata(&path).is_err() {
|
||||||
return Err("file not found".into());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3500,6 +3567,7 @@ pub fn play_local(
|
|||||||
/// used for gapless album playback; it has no effect on streaming downloads.
|
/// used for gapless album playback; it has no effect on streaming downloads.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn preload_local(
|
pub fn preload_local(
|
||||||
|
app: tauri::AppHandle,
|
||||||
players: State<'_, PlayerState>,
|
players: State<'_, PlayerState>,
|
||||||
path: String,
|
path: String,
|
||||||
duration: Option<f64>,
|
duration: Option<f64>,
|
||||||
@@ -3508,6 +3576,8 @@ pub fn preload_local(
|
|||||||
return Err("file not found".into());
|
return Err("file not found".into());
|
||||||
}
|
}
|
||||||
players.player.preload_local(&path, duration);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ pub struct AppConfig {
|
|||||||
/// Some(0) → unlimited.
|
/// Some(0) → unlimited.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stream_cache_max_mb: Option<u64>,
|
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.
|
/// 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)
|
.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
|
/// One-time migration after the identifier rename: the app-data dir moved
|
||||||
/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename
|
/// 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
|
/// the old dir into place so the library DB, config and stream cache
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod cover_logo;
|
mod cover_logo;
|
||||||
|
mod diagnostics;
|
||||||
mod hero_crop;
|
mod hero_crop;
|
||||||
mod hero_detector;
|
mod hero_detector;
|
||||||
|
mod loudness;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod playback;
|
mod playback;
|
||||||
mod preview;
|
mod preview;
|
||||||
@@ -31,6 +33,8 @@ pub fn run() {
|
|||||||
playback::init(app.handle());
|
playback::init(app.handle());
|
||||||
playback::prune_cache(app.handle());
|
playback::prune_cache(app.handle());
|
||||||
preview::init(app.handle());
|
preview::init(app.handle());
|
||||||
|
loudness::init(app.handle());
|
||||||
|
diagnostics::init(app.handle());
|
||||||
if let Err(e) = pipeline::init(app.handle()) {
|
if let Err(e) = pipeline::init(app.handle()) {
|
||||||
log::error!("library init failed: {e}");
|
log::error!("library init failed: {e}");
|
||||||
}
|
}
|
||||||
@@ -108,6 +112,9 @@ pub fn run() {
|
|||||||
preview::preview_control,
|
preview::preview_control,
|
||||||
preview::preview_dispose,
|
preview::preview_dispose,
|
||||||
preview::player_pause_acked,
|
preview::player_pause_acked,
|
||||||
|
preview::player_fade_pause_acked,
|
||||||
|
preview::player_fade_resume,
|
||||||
|
diagnostics::debug_stats,
|
||||||
])
|
])
|
||||||
.run({
|
.run({
|
||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+401
-21
@@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use serde::Serialize;
|
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.
|
/// motion than the main bar's 200 ms cadence, and one small window is cheap.
|
||||||
const PREVIEW_POSITION_INTERVAL: Duration = Duration::from_millis(100);
|
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
|
/// Lease-lifetime constants, mirroring `src/lib/preview/lease.js` — the two
|
||||||
/// sides MUST agree or the renderer would trust a lease the backend already
|
/// sides MUST agree or the renderer would trust a lease the backend already
|
||||||
/// considers dead. Values are ADRs backed by the Phase 0 soak.
|
/// 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.
|
/// preview session never legitimately needs an hour-old clip.
|
||||||
const CLIP_MAX_AGE: Duration = Duration::from_secs(60 * 60);
|
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 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)
|
/// opaque lease id → clip file. Entries die with the session (dispose)
|
||||||
/// or the sweep; the map is small by construction (active + prefetch).
|
/// or the sweep; the map is small by construction (active + prefetch).
|
||||||
leases: Mutex<HashMap<String, PathBuf>>,
|
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,
|
clip_dir: PathBuf,
|
||||||
lease_counter: AtomicU64,
|
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 --
|
// ------------------------------------------------------------------ init --
|
||||||
|
|
||||||
pub fn init(app: &tauri::AppHandle) {
|
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
|
// the user's routing. (Device CHANGES while a preview session is open
|
||||||
// are part of the Phase 2 focus matrix, handled at the coordinator.)
|
// are part of the Phase 2 focus matrix, handled at the coordinator.)
|
||||||
let output_device = crate::config::load(app).audio_output_device;
|
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
|
let clip_dir = app
|
||||||
.path()
|
.path()
|
||||||
@@ -67,19 +200,50 @@ pub fn init(app: &tauri::AppHandle) {
|
|||||||
let _ = std::fs::create_dir_all(&clip_dir);
|
let _ = std::fs::create_dir_all(&clip_dir);
|
||||||
sweep_old_clips(&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 {
|
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()),
|
leases: Mutex::new(HashMap::new()),
|
||||||
|
recent_clips: Mutex::new(HashMap::new()),
|
||||||
clip_dir,
|
clip_dir,
|
||||||
lease_counter: AtomicU64::new(1),
|
lease_counter: AtomicU64::new(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 handle = app.clone();
|
||||||
|
let active = active.clone();
|
||||||
|
let audible_marks = audible_marks.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("preview-events".into())
|
.name(format!("preview-events-{idx}"))
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let mut last_position = Instant::now() - PREVIEW_POSITION_INTERVAL;
|
let mut last_position = Instant::now() - PREVIEW_POSITION_INTERVAL;
|
||||||
for ev in events {
|
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);
|
||||||
|
}
|
||||||
|
if active.load(Ordering::Relaxed) != idx {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let ui = match ev {
|
let ui = match ev {
|
||||||
PlayerEvent::Buffering => PreviewUiEvent::Buffering,
|
PlayerEvent::Buffering => PreviewUiEvent::Buffering,
|
||||||
PlayerEvent::Playing => PreviewUiEvent::Playing,
|
PlayerEvent::Playing => PreviewUiEvent::Playing,
|
||||||
@@ -101,6 +265,7 @@ pub fn init(app: &tauri::AppHandle) {
|
|||||||
})
|
})
|
||||||
.expect("spawn preview event forwarder");
|
.expect("spawn preview event forwarder");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||||
@@ -167,6 +332,42 @@ fn fail(failure: &'static str, retryable: bool) -> PreviewResolveResult {
|
|||||||
/// fresh — /track responses carry per-request signed URLs (Phase 0), so a
|
/// 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
|
/// cached body is a stale lease by construction. The single-retry cap on
|
||||||
/// failures lives with the caller (renderer), matching the fixture resolver.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn preview_resolve(
|
pub async fn preview_resolve(
|
||||||
library: State<'_, LibraryState>,
|
library: State<'_, LibraryState>,
|
||||||
@@ -175,6 +376,10 @@ pub async fn preview_resolve(
|
|||||||
) -> Result<PreviewResolveResult, String> {
|
) -> Result<PreviewResolveResult, String> {
|
||||||
sweep_old_clips(&state.clip_dir);
|
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 {
|
let Some(body) = library.metadata.deezer_track_uncached(track_id).await else {
|
||||||
return Ok(fail("network", true));
|
return Ok(fail("network", true));
|
||||||
};
|
};
|
||||||
@@ -231,13 +436,15 @@ pub async fn preview_resolve(
|
|||||||
.leases
|
.leases
|
||||||
.lock()
|
.lock()
|
||||||
.expect("preview lease 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(
|
let safe_for_ms =
|
||||||
parse_exp_ms(&preview_url),
|
compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms());
|
||||||
media.date_ms,
|
|
||||||
now_epoch_ms(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(PreviewResolveResult {
|
Ok(PreviewResolveResult {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -258,11 +465,21 @@ pub async fn preview_resolve(
|
|||||||
/// Load a resolved clip into the preview player. `start_paused` uses the
|
/// 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
|
/// desired-paused latch (Phase 0.4): the decoder comes up parked, so opening
|
||||||
/// against a still-negotiating focus transfer can never leak audio.
|
/// 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]
|
#[tauri::command]
|
||||||
pub fn preview_load(
|
pub fn preview_load(
|
||||||
state: State<'_, PreviewState>,
|
state: State<'_, PreviewState>,
|
||||||
lease_id: String,
|
lease_id: String,
|
||||||
start_paused: bool,
|
start_paused: bool,
|
||||||
|
crossfade_ms: Option<u64>,
|
||||||
|
start_at_secs: Option<f64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let path = state
|
let path = state
|
||||||
.leases
|
.leases
|
||||||
@@ -271,15 +488,47 @@ pub fn preview_load(
|
|||||||
.get(&lease_id)
|
.get(&lease_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or("unknown or expired lease")?;
|
.ok_or("unknown or expired lease")?;
|
||||||
state.player.play_local(&path, Some(30.0));
|
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 {
|
if start_paused {
|
||||||
// Ack receipt matters less than the latch being set before any
|
// Ack receipt matters less than the latch being set before any
|
||||||
// decoder can finish; a timeout here still leaves the latch armed.
|
// decoder can finish; a timeout here still leaves the latch armed.
|
||||||
let _ = state
|
let _ = state.voices[cur]
|
||||||
.player
|
|
||||||
.pause_acked()
|
.pause_acked()
|
||||||
.recv_timeout(Duration::from_millis(750));
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,12 +538,47 @@ pub fn preview_control(
|
|||||||
action: String,
|
action: String,
|
||||||
value: Option<f64>,
|
value: Option<f64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let cur = state.active_idx();
|
||||||
match action.as_str() {
|
match action.as_str() {
|
||||||
"pause" => state.player.pause(),
|
"pause" => {
|
||||||
"resume" => state.player.resume(),
|
// Pause silences EVERYTHING: orphan fades, kill any dying tail,
|
||||||
"stop" => state.player.stop(),
|
// and park the active voice back at target so resume is full.
|
||||||
"seek" => state.player.seek(value.unwrap_or(0.0)),
|
state.bump_fades();
|
||||||
"volume" => state.player.set_volume(value.unwrap_or(0.7) as f32),
|
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}")),
|
other => return Err(format!("unknown preview action {other}")),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -304,7 +588,16 @@ pub fn preview_control(
|
|||||||
/// call repeatedly (close, navigation, hot reload all funnel here).
|
/// call repeatedly (close, navigation, hot reload all funnel here).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> {
|
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");
|
let mut leases = state.leases.lock().expect("preview lease lock");
|
||||||
for (_, path) in leases.drain() {
|
for (_, path) in leases.drain() {
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
@@ -314,6 +607,93 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> {
|
|||||||
|
|
||||||
// ----------------------------------------------------- main-player ack ----
|
// ----------------------------------------------------- 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)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PauseAckDto {
|
pub struct PauseAckDto {
|
||||||
|
|||||||
+13
-9
@@ -6,6 +6,7 @@
|
|||||||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||||
import LoginModal from "./components/LoginModal.svelte";
|
import LoginModal from "./components/LoginModal.svelte";
|
||||||
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
||||||
|
import DebugOverlay from "./components/DebugOverlay.svelte";
|
||||||
import { previewUi } from "./lib/preview/previewSession.svelte.js";
|
import { previewUi } from "./lib/preview/previewSession.svelte.js";
|
||||||
import Home from "./views/Home.svelte";
|
import Home from "./views/Home.svelte";
|
||||||
import Search from "./views/Search.svelte";
|
import Search from "./views/Search.svelte";
|
||||||
@@ -52,21 +53,23 @@
|
|||||||
class:preview-open={previewUi.open}
|
class:preview-open={previewUi.open}
|
||||||
>
|
>
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<!-- Previews take over the whole window (decision 2026-07-22): while the
|
<!-- While the preview modal is open the page is inert and the flanking
|
||||||
modal is open the page and the player bar are inert/not rendered —
|
panels + player bar are pointer-inert under the scrim (user decision
|
||||||
there is deliberately NO UI path to command main audio behind the
|
2026-07-23: the bar stays VISIBLE, dimmed, like Spotify — un-rendering
|
||||||
focus coordinator's back. The titlebar stays live for window chrome. -->
|
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 />
|
<Sidebar />
|
||||||
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
|
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
|
||||||
<CurrentView />
|
<CurrentView />
|
||||||
</main>
|
</main>
|
||||||
<NowPlaying />
|
<NowPlaying />
|
||||||
{#if !previewUi.open}
|
|
||||||
<PlayerBar />
|
<PlayerBar />
|
||||||
{/if}
|
|
||||||
<PreviewOverlay />
|
<PreviewOverlay />
|
||||||
<ArtworkViewer />
|
<ArtworkViewer />
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
|
<DebugOverlay />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -74,10 +77,11 @@
|
|||||||
no separate chrome color, the panels float beneath it. */
|
no separate chrome color, the panels float beneath it. */
|
||||||
/* While the preview modal is open: keyboard is contained by the dialog's
|
/* 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
|
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
|
panels + player bar stop taking pointer input here — the bar stays
|
||||||
rendered — see the markup note above.) */
|
visible (dimmed under the scrim) but cannot command main audio. */
|
||||||
.app.preview-open :global(.sidebar),
|
.app.preview-open :global(.sidebar),
|
||||||
.app.preview-open :global(.nowplaying) {
|
.app.preview-open :global(.nowplaying),
|
||||||
|
.app.preview-open :global(.player) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
// R1b capsule, T3 directional metadata swaps, folded control row with
|
// R1b capsule, T3 directional metadata swaps, folded control row with
|
||||||
// the P2 ghost pill, F3 footer.
|
// the P2 ghost pill, F3 footer.
|
||||||
import { tick, untrack } from "svelte";
|
import { tick, untrack } from "svelte";
|
||||||
|
import { fade, scale } from "svelte/transition";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
import {
|
import {
|
||||||
previewUi,
|
previewUi,
|
||||||
previewCommands,
|
previewCommands,
|
||||||
@@ -31,6 +33,16 @@
|
|||||||
const playing = $derived(st?.phase === PHASE.PLAYING);
|
const playing = $derived(st?.phase === PHASE.PLAYING);
|
||||||
const ended = $derived(st?.phase === PHASE.ENDED);
|
const ended = $derived(st?.phase === PHASE.ENDED);
|
||||||
const failed = $derived(st?.phase === PHASE.ITEM_FAILED);
|
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 playable = $derived(st ? playableManifestIndices(st) : []);
|
||||||
const cursor = $derived(st ? playableCursor(st) : null);
|
const cursor = $derived(st ? playableCursor(st) : null);
|
||||||
const unavailableCount = $derived(items.length - playable.length);
|
const unavailableCount = $derived(items.length - playable.length);
|
||||||
@@ -399,6 +411,16 @@
|
|||||||
const posLabel = $derived(fmtMs(st?.positionMs ?? 0) || "0:00");
|
const posLabel = $derived(fmtMs(st?.positionMs ?? 0) || "0:00");
|
||||||
const clipLabel = $derived(fmtMs(st?.clipDurationMs ?? 30_000) || "0:30");
|
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() {
|
async function onPlayFull() {
|
||||||
if (!previewUi.playFull || !activeItem) return;
|
if (!previewUi.playFull || !activeItem) return;
|
||||||
const starter = previewUi.playFull;
|
const starter = previewUi.playFull;
|
||||||
@@ -410,7 +432,15 @@
|
|||||||
<svelte:window onkeydown={onKeydown} />
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
{#if previewUi.open && st}
|
{#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
|
<div
|
||||||
class="modal"
|
class="modal"
|
||||||
class:is-playing={playing}
|
class:is-playing={playing}
|
||||||
@@ -419,6 +449,8 @@
|
|||||||
aria-label={manifest ? `Track previews: ${manifest.context.title}` : "Track previews"}
|
aria-label={manifest ? `Track previews: ${manifest.context.title}` : "Track previews"}
|
||||||
style="--pv-accent: {pvAccent}; --pv-accent-hover: {pvHover};"
|
style="--pv-accent: {pvAccent}; --pv-accent-hover: {pvHover};"
|
||||||
bind:this={dialogEl}
|
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">
|
<div class="bg" aria-hidden="true">
|
||||||
<img class="bleed" class:show={bleedSlot === 1 && bleedA} src={bleedA || undefined} alt="" />
|
<img class="bleed" class:show={bleedSlot === 1 && bleedA} src={bleedA || undefined} alt="" />
|
||||||
@@ -490,18 +522,18 @@
|
|||||||
class="m-step"
|
class="m-step"
|
||||||
title="Previous preview (↑)"
|
title="Previous preview (↑)"
|
||||||
aria-label="Previous preview"
|
aria-label="Previous preview"
|
||||||
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0}
|
disabled={prevDisabled}
|
||||||
onclick={() => step(-1)}
|
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>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2.2v12H6zm3.8 6l8.2 6V6z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="m-play"
|
class="m-play"
|
||||||
title={playing ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
|
title={wantsPlay ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
|
||||||
aria-label={playing ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
|
aria-label={wantsPlay ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
|
||||||
onclick={() => previewCommands.togglePlay()}
|
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>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
|
||||||
{:else if ended}
|
{: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>
|
<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"
|
class="m-step"
|
||||||
title="Next preview (↓)"
|
title="Next preview (↓)"
|
||||||
aria-label="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)}
|
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>
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -589,7 +641,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
<span class="m-pos">
|
<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} · {unavailableCount} unavailable{/if}
|
{#if unavailableCount > 0} · {unavailableCount} unavailable{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,14 +662,31 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
/* Spotify-style: the page behind darkens clearly and blurs a touch,
|
||||||
backdrop-filter: blur(2px);
|
so the panel reads as a floating modal rather than a full takeover. */
|
||||||
-webkit-backdrop-filter: blur(2px);
|
background: rgba(0, 0, 0, 0.62);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.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;
|
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;
|
z-index: 61;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -727,7 +802,7 @@
|
|||||||
/* -------------------------------------------------------------- body */
|
/* -------------------------------------------------------------- body */
|
||||||
.m-body {
|
.m-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(300px, 500px) auto;
|
grid-template-columns: minmax(320px, 680px) auto;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 36px;
|
gap: 36px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
@@ -743,13 +818,14 @@
|
|||||||
/* Fixed two-line title slot, BOTTOM-aligned: a one-line title sits against
|
/* Fixed two-line title slot, BOTTOM-aligned: a one-line title sits against
|
||||||
the artist row instead of floating a line above it. */
|
the artist row instead of floating a line above it. */
|
||||||
.title-slot {
|
.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;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.m-title {
|
.m-title {
|
||||||
font-size: 38px;
|
font-size: var(--pv-title, 38px);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
letter-spacing: -0.8px;
|
letter-spacing: -0.8px;
|
||||||
@@ -797,7 +873,7 @@
|
|||||||
gap: 11px;
|
gap: 11px;
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
/* Spans the same width as the control row beneath it. */
|
/* Spans the same width as the control row beneath it. */
|
||||||
max-width: 472px;
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
.m-wave {
|
.m-wave {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -830,7 +906,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
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;
|
width: 46px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -841,7 +920,7 @@
|
|||||||
transition: background 0.35s ease;
|
transition: background 0.35s ease;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.m-play:hover {
|
.m-transport .m-play:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
.m-step {
|
.m-step {
|
||||||
@@ -917,7 +996,11 @@
|
|||||||
}
|
}
|
||||||
.reel {
|
.reel {
|
||||||
/* Single source of geometry: everything below is calc() off these. */
|
/* 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;
|
--gap: 30px;
|
||||||
--stride: calc(var(--cover) + var(--gap));
|
--stride: calc(var(--cover) + var(--gap));
|
||||||
--bleed-room: 70px;
|
--bleed-room: 70px;
|
||||||
@@ -928,6 +1011,43 @@
|
|||||||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
|
-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%);
|
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 {
|
.reel-track {
|
||||||
/* A fixed anchor at the reel centre — it never moves. Cards position
|
/* A fixed anchor at the reel centre — it never moves. Cards position
|
||||||
themselves relative to it at (index − activeIndex) × stride and
|
themselves relative to it at (index − activeIndex) × stride and
|
||||||
@@ -1134,6 +1254,11 @@
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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 {
|
.rail i {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1149,6 +1274,10 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
.pos-n {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.sr-announce {
|
.sr-announce {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+7
-2
@@ -136,8 +136,13 @@ export const streamWaveform = (username, path, size, buckets) =>
|
|||||||
// previewResolve returns { ok, lease | failure, retryable } — the signed URL
|
// previewResolve returns { ok, lease | failure, retryable } — the signed URL
|
||||||
// never crosses this boundary, only an opaque lease id does.
|
// never crosses this boundary, only an opaque lease id does.
|
||||||
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
|
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
|
||||||
export const previewLoad = (leaseId, startPaused) =>
|
export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) =>
|
||||||
invoke("preview_load", { leaseId, startPaused });
|
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) =>
|
export const previewControl = (action, value = null) =>
|
||||||
invoke("preview_control", { action, value });
|
invoke("preview_control", { action, value });
|
||||||
export const previewDispose = () => invoke("preview_dispose");
|
export const previewDispose = () => invoke("preview_dispose");
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
// Production wiring for the Phase 1 focus coordinator: the real main player
|
// Production wiring for the Phase 1 focus coordinator: the real main player
|
||||||
// on one side, the preview session on the other.
|
// on one side, the preview session on the other.
|
||||||
//
|
//
|
||||||
// One honest deviation from the pure model, documented here because it is a
|
// Two honest deviations from the pure model, documented here because they
|
||||||
// real design decision: the renderer cannot know the NATIVE playback
|
// are real design decisions:
|
||||||
// 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:
|
|
||||||
//
|
//
|
||||||
// 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
|
// ∧ no renderer PLAYBACK generation change during negotiation
|
||||||
// (a new Buffering = a new track start = a new generation)
|
// (a new Buffering = a new track start = a new generation)
|
||||||
// ∧ no revision-gateway change during negotiation
|
// ∧ 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
|
// 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
|
// 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
|
// the grant exactly as a user command does. The native ack generation is
|
||||||
@@ -19,6 +28,7 @@
|
|||||||
|
|
||||||
import * as api from "../api.js";
|
import * as api from "../api.js";
|
||||||
import { createFocusCoordinator, createRevisionGateway } from "./focus.js";
|
import { createFocusCoordinator, createRevisionGateway } from "./focus.js";
|
||||||
|
import { CROSSFADE_MS } from "./service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} deps
|
* @param {object} deps
|
||||||
@@ -48,7 +58,17 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
requestSilence: async (generationAtSnapshot) => {
|
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();
|
const generationNow = readPlaybackGeneration();
|
||||||
return {
|
return {
|
||||||
// Applied only if no new main track started mid-negotiation;
|
// Applied only if no new main track started mid-negotiation;
|
||||||
@@ -57,8 +77,8 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
|||||||
applied: true,
|
applied: true,
|
||||||
generation:
|
generation:
|
||||||
generationNow === generationAtSnapshot ? generationAtSnapshot : generationNow,
|
generationNow === generationAtSnapshot ? generationAtSnapshot : generationNow,
|
||||||
nativeGeneration: ack.generation,
|
nativeGeneration: null,
|
||||||
latched: ack.latched,
|
latched: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
resumeMain: (snapshot, opts) => {
|
resumeMain: (snapshot, opts) => {
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ function availabilityFromCatalogTrack(t) {
|
|||||||
return AVAILABILITY.NOT_PROVIDED;
|
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 = {}) {
|
function itemFromCatalogTrack(t, ordinal, inherit = {}) {
|
||||||
return makeItem({
|
return makeItem({
|
||||||
sessionKey: `s${ordinal}-dz${t.id}`,
|
sessionKey: `s${ordinal}-dz${t.id}`,
|
||||||
@@ -25,7 +36,7 @@ function itemFromCatalogTrack(t, ordinal, inherit = {}) {
|
|||||||
artists: [t.artist || inherit.artist || ""].filter(Boolean),
|
artists: [t.artist || inherit.artist || ""].filter(Boolean),
|
||||||
album: t.album || inherit.album,
|
album: t.album || inherit.album,
|
||||||
albumId: t.album_id ?? inherit.albumId,
|
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,
|
fullDurationMs: Number.isFinite(t.duration_secs) ? t.duration_secs * 1000 : undefined,
|
||||||
sources: [makeSourceRef({ provider: "deezer", providerTrackId: t.id, matchConfidence: "native" })],
|
sources: [makeSourceRef({ provider: "deezer", providerTrackId: t.id, matchConfidence: "native" })],
|
||||||
initialAvailability: availabilityFromCatalogTrack(t),
|
initialAvailability: availabilityFromCatalogTrack(t),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function createNativeTransport(dispatch) {
|
|||||||
/** Accept a native lease and hand its clip to the preview player.
|
/** Accept a native lease and hand its clip to the preview player.
|
||||||
* `startPaused` maps to the backend's desired-paused latch, so a
|
* `startPaused` maps to the backend's desired-paused latch, so a
|
||||||
* parked open can never leak audio (Phase 0.4). */
|
* 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");
|
if (disposed) throw new Error("transport disposed");
|
||||||
await ensureSubscribed();
|
await ensureSubscribed();
|
||||||
if (!isValidLease(lease) || lease.transport !== "native") {
|
if (!isValidLease(lease) || lease.transport !== "native") {
|
||||||
@@ -67,7 +67,14 @@ export function createNativeTransport(dispatch) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx = { sessionId, generation, sessionKey };
|
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() {
|
async play() {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -84,6 +91,15 @@ export function createNativeTransport(dispatch) {
|
|||||||
async setVolume(v) {
|
async setVolume(v) {
|
||||||
await api.previewControl("volume", 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() {
|
async stop() {
|
||||||
ctx = null; // events from the dying clip stamp nothing
|
ctx = null; // events from the dying clip stamp nothing
|
||||||
await api.previewControl("stop");
|
await api.previewControl("stop");
|
||||||
|
|||||||
@@ -145,19 +145,25 @@ export async function closePreview() {
|
|||||||
demoTicker = null;
|
demoTicker = null;
|
||||||
}
|
}
|
||||||
if (!service) return { restored: false };
|
if (!service) return { restored: false };
|
||||||
const out = await service.close();
|
// Detach first (re-entrancy: a second Escape lands on the early return),
|
||||||
unsubscribe?.();
|
// and hide the modal BEFORE awaiting the close — service.close() now
|
||||||
unsubscribe = null;
|
// 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;
|
service = null;
|
||||||
previewUi.open = false;
|
previewUi.open = false;
|
||||||
previewUi.snapshot = null;
|
|
||||||
previewUi.focusWaiting = false;
|
|
||||||
previewUi.playFull = null;
|
|
||||||
const back = previewUi.returnFocusTo;
|
const back = previewUi.returnFocusTo;
|
||||||
previewUi.returnFocusTo = null;
|
previewUi.returnFocusTo = null;
|
||||||
if (back && typeof back.focus === "function" && back.isConnected) {
|
if (back && typeof back.focus === "function" && back.isConnected) {
|
||||||
back.focus();
|
back.focus();
|
||||||
}
|
}
|
||||||
|
const out = await svc.close();
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
previewUi.snapshot = null;
|
||||||
|
previewUi.focusWaiting = false;
|
||||||
|
previewUi.playFull = null;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+168
-28
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// The reducer decides WHAT happens; this service decides WHEN: the settle
|
// The reducer decides WHAT happens; this service decides WHEN: the settle
|
||||||
// window after navigation, the resolve→load→play pipeline, the single
|
// 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
|
// handshake at the session edges. Everything time-like is injected
|
||||||
// (scheduler, monotonic clock) so the whole loop runs under node:test with
|
// (scheduler, monotonic clock) so the whole loop runs under node:test with
|
||||||
// manual ticks — no real timers anywhere in the tests.
|
// manual ticks — no real timers anywhere in the tests.
|
||||||
@@ -20,6 +20,33 @@ const defaultScheduler = {
|
|||||||
clear: (id) => clearTimeout(id),
|
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({
|
export function createPreviewService({
|
||||||
resolver,
|
resolver,
|
||||||
// The service CONSTRUCTS the transport, binding it to its own dispatch —
|
// The service CONSTRUCTS the transport, binding it to its own dispatch —
|
||||||
@@ -37,7 +64,12 @@ export function createPreviewService({
|
|||||||
let settleTimer = null;
|
let settleTimer = null;
|
||||||
let focusToken = null;
|
let focusToken = null;
|
||||||
let focusWaiting = false;
|
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();
|
let prefetched = new Map();
|
||||||
|
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
@@ -62,20 +94,56 @@ export function createPreviewService({
|
|||||||
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
||||||
scheduleSettle();
|
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() {
|
function scheduleSettle() {
|
||||||
if (settleTimer != null) scheduler.clear(settleTimer);
|
if (settleTimer != null) scheduler.clear(settleTimer);
|
||||||
const sessionId = state.sessionId;
|
const sessionId = state.sessionId;
|
||||||
const generation = state.requestGeneration;
|
const generation = state.requestGeneration;
|
||||||
// Stop the outgoing clip immediately — silence between cards is
|
// Fade-capable transports (native) keep the outgoing clip SOUNDING
|
||||||
// correct; audio under the wrong artwork never is.
|
// through settle + resolve: the crossfade inside load() ramps it out
|
||||||
void transport.stop();
|
// 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 = scheduler.set(() => {
|
||||||
settleTimer = null;
|
settleTimer = null;
|
||||||
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
|
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
|
||||||
void runPipeline(sessionId, generation);
|
void runPipeline(sessionId, generation);
|
||||||
}, settleMs);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPipeline(sessionId, generation) {
|
async function runPipeline(sessionId, generation) {
|
||||||
@@ -83,8 +151,35 @@ export function createPreviewService({
|
|||||||
state && state.sessionId === sessionId && state.requestGeneration === generation;
|
state && state.sessionId === sessionId && state.requestGeneration === generation;
|
||||||
if (!fresh() || state.activeItemKey == null) return;
|
if (!fresh() || state.activeItemKey == null) return;
|
||||||
const item = state.manifest.items[state.activeManifestIndex];
|
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];
|
const source = item.sources[0];
|
||||||
if (!source) {
|
if (!source) {
|
||||||
|
retireOutgoing();
|
||||||
dispatch({ type: "ITEM_FAILED", sessionId, generation, sessionKey: item.sessionKey, errorClass: "no_source" });
|
dispatch({ type: "ITEM_FAILED", sessionId, generation, sessionKey: item.sessionKey, errorClass: "no_source" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,14 +195,15 @@ export function createPreviewService({
|
|||||||
|
|
||||||
if (!lease) {
|
if (!lease) {
|
||||||
let result = await resolver.resolve(source, { generation });
|
let result = await resolver.resolve(source, { generation });
|
||||||
if (!fresh()) return;
|
if (!fresh()) return clearOverstay();
|
||||||
if (!result.ok && result.retryable) {
|
if (!result.ok && result.retryable) {
|
||||||
// The capped retry: exactly one cache-bypass re-mint per
|
// The capped retry: exactly one cache-bypass re-mint per
|
||||||
// user intent, per the resolver contract. Never a loop.
|
// user intent, per the resolver contract. Never a loop.
|
||||||
result = await resolver.resolve(source, { generation, bypassCache: true });
|
result = await resolver.resolve(source, { generation, bypassCache: true });
|
||||||
if (!fresh()) return;
|
if (!fresh()) return clearOverstay();
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
retireOutgoing();
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ITEM_FAILED",
|
type: "ITEM_FAILED",
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -128,43 +224,67 @@ export function createPreviewService({
|
|||||||
sessionKey: item.sessionKey,
|
sessionKey: item.sessionKey,
|
||||||
clipDurationMs: lease.clipDurationMs,
|
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;
|
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()) {
|
if (!fresh()) {
|
||||||
void transport.stop();
|
void transport.stop();
|
||||||
return;
|
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();
|
if (audible) await transport.play();
|
||||||
|
|
||||||
prefetchNeighbor(sessionId, generation);
|
prefetchNeighbors(sessionId, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the NEXT playable item's lease in the background so stepping
|
/** Resolve BOTH playable neighbours' leases in the background so a step
|
||||||
* forward usually skips the network entirely. One item, forward only —
|
* in either direction usually skips the network entirely. Forward-only
|
||||||
* the bounded mechanism the plan allows without byte prefetch. */
|
* prefetch was the 2026-07-23 asymmetry bug: down-steps crossfaded in
|
||||||
function prefetchNeighbor(sessionId, generation) {
|
* ~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;
|
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
|
||||||
const playable = playableManifestIndices(state);
|
const playable = playableManifestIndices(state);
|
||||||
const cur = playableCursor(state);
|
const cur = playableCursor(state);
|
||||||
if (cur == null || cur + 1 >= playable.length) return;
|
if (cur == null) return;
|
||||||
const next = state.manifest.items[playable[cur + 1]];
|
const wanted = [];
|
||||||
if (prefetched.has(next.sessionKey) || !next.sources[0]) return;
|
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
|
void resolver
|
||||||
.resolve(next.sources[0], { generation })
|
.resolve(item.sources[0], { generation })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (r.ok && state && state.sessionId === sessionId) {
|
if (r.ok && state && state.sessionId === sessionId) {
|
||||||
prefetched.set(next.sessionKey, { lease: r.lease, receiptAt: monotonicNow() });
|
prefetched.set(item.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- focus --
|
// -------------------------------------------------------------- focus --
|
||||||
|
|
||||||
@@ -236,6 +356,18 @@ export function createPreviewService({
|
|||||||
await transport.pause();
|
await transport.pause();
|
||||||
return;
|
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) {
|
if (state.phase === PHASE.ENDED) {
|
||||||
dispatch({ type: "REPLAY", sessionId: sid });
|
dispatch({ type: "REPLAY", sessionId: sid });
|
||||||
return;
|
return;
|
||||||
@@ -262,7 +394,10 @@ export function createPreviewService({
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Close: terminal for the session; transport torn down; main
|
/** 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() {
|
async close() {
|
||||||
if (!state) return { restored: false };
|
if (!state) return { restored: false };
|
||||||
if (settleTimer != null) {
|
if (settleTimer != null) {
|
||||||
@@ -271,11 +406,16 @@ export function createPreviewService({
|
|||||||
}
|
}
|
||||||
dispatch({ type: "CLOSE", sessionId: state.sessionId });
|
dispatch({ type: "CLOSE", sessionId: state.sessionId });
|
||||||
prefetched.clear();
|
prefetched.clear();
|
||||||
await transport.stop();
|
const fades = typeof transport.fadeOut === "function";
|
||||||
await transport.dispose();
|
if (fades) void transport.fadeOut(CROSSFADE_MS);
|
||||||
|
else await transport.stop();
|
||||||
const token = focusToken;
|
const token = focusToken;
|
||||||
focusToken = null;
|
focusToken = null;
|
||||||
const restored = token ? focus.coordinator.release(token) : { restored: false };
|
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;
|
return restored;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
/** Activate the item at `manifestIndex`, bumping the generation — the
|
||||||
* common core of first-pick, step, click and auto-advance. */
|
* common core of first-pick, step, click and auto-advance. */
|
||||||
function activate(state, manifestIndex, { keepDesired }) {
|
function activate(state, manifestIndex, { keepDesired }) {
|
||||||
const item = itemAt(state, manifestIndex);
|
const item = itemAt(state, manifestIndex);
|
||||||
if (!item) return state;
|
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({
|
return Object.freeze({
|
||||||
...state,
|
...state,
|
||||||
|
itemRuntimeByKey: runtime,
|
||||||
phase: PHASE.RESOLVING_MEDIA,
|
phase: PHASE.RESOLVING_MEDIA,
|
||||||
activeItemKey: item.sessionKey,
|
activeItemKey: item.sessionKey,
|
||||||
activeManifestIndex: manifestIndex,
|
activeManifestIndex: manifestIndex,
|
||||||
@@ -178,8 +194,15 @@ export function reduce(state, action) {
|
|||||||
case "REPLAY": {
|
case "REPLAY": {
|
||||||
if (!guard(state, action, { needGeneration: false })) return state;
|
if (!guard(state, action, { needGeneration: false })) return state;
|
||||||
if (state.activeManifestIndex == null) 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({
|
return Object.freeze({
|
||||||
...activate(state, state.activeManifestIndex, { keepDesired: true }),
|
...activate(cleared, cleared.activeManifestIndex, { keepDesired: true }),
|
||||||
desiredPlaying: true,
|
desiredPlaying: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -237,7 +260,15 @@ export function reduce(state, action) {
|
|||||||
if (cur != null && cur + 1 < playable.length) {
|
if (cur != null && cur + 1 < playable.length) {
|
||||||
return activate(state, playable[cur + 1], { keepDesired: true });
|
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": {
|
case "ITEM_FAILED": {
|
||||||
if (!guard(state, action, { needGeneration: true })) return state;
|
if (!guard(state, action, { needGeneration: true })) return state;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
bumpMainRevision,
|
bumpMainRevision,
|
||||||
notePlaybackGeneration,
|
notePlaybackGeneration,
|
||||||
} from "./preview/previewSession.svelte.js";
|
} from "./preview/previewSession.svelte.js";
|
||||||
|
import { CROSSFADE_MS as PREVIEW_CROSSFADE_MS } from "./preview/service.js";
|
||||||
|
|
||||||
export const app = $state({
|
export const app = $state({
|
||||||
connection: "offline", // offline | connecting | online
|
connection: "offline", // offline | connecting | online
|
||||||
@@ -3567,7 +3568,9 @@ export function initEvents() {
|
|||||||
initPreviewBridge({
|
initPreviewBridge({
|
||||||
readPlayer: () => player,
|
readPlayer: () => player,
|
||||||
resumeMain: () => {
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setVolumeNormalization(next) {
|
||||||
|
if (next !== settings.volume_normalization) {
|
||||||
|
await commit({ volumeNormalization: next });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function chooseDownloadDir() {
|
async function chooseDownloadDir() {
|
||||||
const dir = await api.pickFolder();
|
const dir = await api.pickFolder();
|
||||||
if (dir) await commit({ downloadDir: dir });
|
if (dir) await commit({ downloadDir: dir });
|
||||||
@@ -226,6 +232,21 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ function manifest(n = 3) {
|
|||||||
function makeScheduler() {
|
function makeScheduler() {
|
||||||
let queue = new Map();
|
let queue = new Map();
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
const delays = [];
|
||||||
return {
|
return {
|
||||||
|
delays, // every ms passed to set(), in order — for adaptive-settle asserts
|
||||||
set(fn, ms) {
|
set(fn, ms) {
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
queue.set(id, fn);
|
queue.set(id, fn);
|
||||||
|
delays.push(ms);
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
clear(id) {
|
clear(id) {
|
||||||
@@ -56,7 +59,7 @@ function makeTransport() {
|
|||||||
log,
|
log,
|
||||||
async load(lease, c, opts) {
|
async load(lease, c, opts) {
|
||||||
ctx = c;
|
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() {
|
async play() {
|
||||||
log.push(["play"]);
|
log.push(["play"]);
|
||||||
@@ -79,6 +82,9 @@ function makeTransport() {
|
|||||||
end() {
|
end() {
|
||||||
if (ctx) dispatch({ type: "MEDIA_ENDED", ...ctx });
|
if (ctx) dispatch({ type: "MEDIA_ENDED", ...ctx });
|
||||||
},
|
},
|
||||||
|
position(ms) {
|
||||||
|
if (ctx) dispatch({ type: "MEDIA_POSITION", ...ctx, positionMs: ms });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
transport: t,
|
transport: t,
|
||||||
@@ -148,7 +154,7 @@ test("open: focus, settle, resolve, load audible, play", async () => {
|
|||||||
const st = service.getState();
|
const st = service.getState();
|
||||||
assert.equal(st.phase, PHASE.PLAYING); // lease landed, played, confirmed
|
assert.equal(st.phase, PHASE.PLAYING); // lease landed, played, confirmed
|
||||||
const load = transport.log.find((e) => e[0] === "load");
|
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"));
|
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");
|
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 ---
|
// ------------------------------------------------------------- prefetch ---
|
||||||
|
|
||||||
test("neighbor lease prefetch: stepping forward skips the resolve", async () => {
|
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);
|
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 () => {
|
test("a prefetched lease past 80% of its safe window is not trusted", async () => {
|
||||||
const { service, scheduler, resolver, advance, manifest } = makeService({
|
const { service, scheduler, resolver, advance, manifest } = makeService({
|
||||||
plan: {
|
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");
|
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 -----
|
// ------------------------------------------------------ close/handoff -----
|
||||||
|
|
||||||
test("close during resolve: terminal, transport torn down, focus released once", async () => {
|
test("close during resolve: terminal, transport torn down, focus released once", async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user