Files
KeelrLeek 793f78370e 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.
2026-07-23 16:05:14 -05:00

106 lines
3.6 KiB
Rust

//! Phase 0.3 — native transport probe (isolated diagnostic, not feature code).
//!
//! Answers the native column's core questions with the existing crate, no new
//! dependencies:
//!
//! 1. Can a SECOND `Player` instance coexist with a "main" one — two sinks,
//! two event channels, no shared state?
//! 2. Does a real Deezer preview (MPEG-1 Layer III, 128k CBR, ~480 KB)
//! decode and play through rodio/symphonia?
//! 3. Do pause / resume / stop behave on the second instance while the
//! first is mid-track, without cross-talk on either event channel?
//!
//! The preview file is downloaded OUTSIDE this probe (curl) and passed as
//! argv[1]; argv[2] is any long local track for the "main" side. Run:
//!
//! cargo run -p player --example preview_probe -- preview.mp3 main.mp3
use std::sync::mpsc::RecvTimeoutError;
use std::time::{Duration, Instant};
use player::{Player, PlayerEvent};
fn drain(
label: &str,
rx: &std::sync::mpsc::Receiver<PlayerEvent>,
for_ms: u64,
log: &mut Vec<String>,
) {
let end = Instant::now() + Duration::from_millis(for_ms);
loop {
let left = end.saturating_duration_since(Instant::now());
if left.is_zero() {
break;
}
match rx.recv_timeout(left) {
Ok(ev) => log.push(format!("{label}: {ev:?}")),
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => {
log.push(format!("{label}: channel closed"));
break;
}
}
}
}
fn main() {
let mut args = std::env::args().skip(1);
let preview = args.next().expect("argv[1]: downloaded preview mp3");
let main_track = args.next();
let mut log = Vec::new();
// "Main" player — only if a second file was provided; the coexistence
// half of the probe is skipped otherwise.
let main_pair = main_track.map(|p| {
let (main, main_rx) = Player::spawn();
main.play_local(&p, None);
(main, main_rx, p)
});
// Preview player: a wholly separate instance and event channel.
let (prev, prev_rx) = Player::spawn();
let t0 = Instant::now();
prev.play_local(&preview, Some(30.0));
drain("preview", &prev_rx, 2500, &mut log);
let started = log.iter().any(|l| l.contains("Playing"));
println!(
"PROBE preview-starts: {started} ({} ms to first events)",
t0.elapsed().as_millis()
);
prev.pause();
drain("preview", &prev_rx, 600, &mut log);
let paused = log.iter().any(|l| l.contains("Paused"));
println!("PROBE preview-pauses: {paused}");
prev.resume();
drain("preview", &prev_rx, 900, &mut log);
if let Some((_main, main_rx, ref path)) = main_pair {
let mut main_log = Vec::new();
drain("main", &main_rx, 1200, &mut main_log);
let main_alive = main_log
.iter()
.any(|l| l.contains("Playing") || l.contains("Position"));
println!("PROBE main-coexists: {main_alive} ({path})");
// cross-talk check: no preview-channel event may appear on main's
// channel or vice versa — separate receivers make this structural,
// so what we actually assert is that both channels stayed live.
println!("PROBE channels-independent: true (separate mpsc receivers by construction)");
} else {
println!("PROBE main-coexists: skipped (no second file)");
}
prev.stop();
drain("preview", &prev_rx, 600, &mut log);
let stopped = log.iter().any(|l| l.contains("Stopped"));
println!("PROBE preview-stops: {stopped}");
println!("--- event log ---");
for l in &log {
println!("{l}");
}
}