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.
106 lines
3.6 KiB
Rust
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}");
|
|
}
|
|
}
|