//! 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 -- use std::sync::mpsc::RecvTimeoutError; use std::time::{Duration, Instant}; use player::{Player, PlayerEvent}; fn collect(rx: &std::sync::mpsc::Receiver, for_ms: u64) -> Vec { 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 { 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"); }