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.
115 lines
4.3 KiB
Rust
115 lines
4.3 KiB
Rust
//! Phase 0.4 — silence-acknowledgement proof (isolated diagnostic).
|
|
//!
|
|
//! Proves the audio-focus primitive the preview feature's take-audio-on-open
|
|
//! contract depends on:
|
|
//!
|
|
//! A. pause_acked while PLAYING → ack(sink_was_active), position freezes
|
|
//! B. pause_acked while BUFFERING → ack(latched), the in-flight decoder
|
|
//! comes up PAUSED — never audible.
|
|
//! (Plain pause() provably fails this:
|
|
//! case D demonstrates the old hazard.)
|
|
//! C. resume after ack → Playing again (latch cleared)
|
|
//! D. plain pause() while BUFFERING → track auto-starts anyway — the
|
|
//! documented hazard the latch closes.
|
|
//!
|
|
//! Run: cargo run -p player --example focus_ack_probe -- <mp3>
|
|
|
|
use std::sync::mpsc::RecvTimeoutError;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use player::{Player, PlayerEvent};
|
|
|
|
fn collect(rx: &std::sync::mpsc::Receiver<PlayerEvent>, for_ms: u64) -> Vec<PlayerEvent> {
|
|
let end = Instant::now() + Duration::from_millis(for_ms);
|
|
let mut out = Vec::new();
|
|
loop {
|
|
let left = end.saturating_duration_since(Instant::now());
|
|
if left.is_zero() {
|
|
break;
|
|
}
|
|
match rx.recv_timeout(left) {
|
|
Ok(ev) => out.push(ev),
|
|
Err(RecvTimeoutError::Timeout) => break,
|
|
Err(RecvTimeoutError::Disconnected) => break,
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn saw_playing(evs: &[PlayerEvent]) -> bool {
|
|
evs.iter().any(|e| matches!(e, PlayerEvent::Playing))
|
|
}
|
|
|
|
fn last_pos(evs: &[PlayerEvent]) -> Option<f64> {
|
|
evs.iter().rev().find_map(|e| match e {
|
|
PlayerEvent::Position { secs, .. } => Some(*secs),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn main() {
|
|
let file = std::env::args().nth(1).expect("argv[1]: an mp3 file");
|
|
|
|
// ---- A: ack while playing ------------------------------------------
|
|
let (p, rx) = Player::spawn();
|
|
p.play_local(&file, Some(30.0));
|
|
let evs = collect(&rx, 2500);
|
|
assert!(saw_playing(&evs), "A: track should start");
|
|
let ack = p
|
|
.pause_acked()
|
|
.recv_timeout(Duration::from_millis(1000))
|
|
.expect("A: ack");
|
|
let pos_at_ack = last_pos(&collect(&rx, 200)).unwrap_or(0.0);
|
|
let after = collect(&rx, 800);
|
|
let pos_later = last_pos(&after).unwrap_or(pos_at_ack);
|
|
let frozen = (pos_later - pos_at_ack).abs() < 0.075; // one 60ms tick of slack
|
|
println!(
|
|
"PROBE A ack-while-playing: sink_was_active={} latched={} gen={} frozen={}",
|
|
ack.sink_was_active, ack.latched, ack.generation, frozen
|
|
);
|
|
assert!(ack.sink_was_active && !ack.latched && frozen, "A failed");
|
|
|
|
// ---- C (on the same instance): resume clears the latch --------------
|
|
p.resume();
|
|
let evs = collect(&rx, 900);
|
|
println!("PROBE C resume-after-ack: playing={}", saw_playing(&evs));
|
|
assert!(saw_playing(&evs), "C failed");
|
|
p.stop();
|
|
drop(p);
|
|
let _ = collect(&rx, 400);
|
|
|
|
// ---- B: ack while buffering — the latch case ------------------------
|
|
let (p2, rx2) = Player::spawn();
|
|
p2.play_local(&file, Some(30.0));
|
|
// no waiting: the decoder thread is still setting up
|
|
let ack2 = p2
|
|
.pause_acked()
|
|
.recv_timeout(Duration::from_millis(1000))
|
|
.expect("B: ack");
|
|
let evs = collect(&rx2, 2500);
|
|
let became_audible = saw_playing(&evs);
|
|
let came_up_paused = evs.iter().any(|e| matches!(e, PlayerEvent::Paused));
|
|
println!(
|
|
"PROBE B ack-while-buffering: latched={} sink_was_active={} came_up_paused={} became_audible={}",
|
|
ack2.latched, ack2.sink_was_active, came_up_paused, became_audible
|
|
);
|
|
assert!(
|
|
ack2.latched && !became_audible,
|
|
"B failed: latch leaked audio"
|
|
);
|
|
p2.stop();
|
|
drop(p2);
|
|
let _ = collect(&rx2, 400);
|
|
|
|
// ---- D: the pre-existing hazard, demonstrated on plain pause() ------
|
|
let (p3, rx3) = Player::spawn();
|
|
p3.play_local(&file, Some(30.0));
|
|
p3.pause(); // fire-and-forget, sink not built yet → dropped on the floor
|
|
let evs = collect(&rx3, 2500);
|
|
let auto_started = saw_playing(&evs);
|
|
println!("PROBE D plain-pause-while-buffering: auto_started={auto_started} (the hazard the latch closes)");
|
|
p3.stop();
|
|
|
|
println!("ALL PROBES PASSED");
|
|
}
|