diff --git a/crates/player/examples/focus_ack_probe.rs b/crates/player/examples/focus_ack_probe.rs new file mode 100644 index 0000000..425b2bc --- /dev/null +++ b/crates/player/examples/focus_ack_probe.rs @@ -0,0 +1,105 @@ +//! 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"); +} diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index 648931e..72db244 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -24,6 +24,26 @@ use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source}; use source::GrowingFile; +/// Acknowledgement for [`Player::pause_acked`], sent from the audio thread +/// AFTER the pause has been applied. Semantics, stated honestly: +/// +/// - `sink_was_active`: a live sink existed and `Sink::pause()` returned — +/// the control state is paused, so the mixer stops pulling samples; at +/// most one already-submitted device buffer can still drain (~ms). +/// - `latched`: no sink existed yet (decode in flight or idle). The +/// desired-paused latch is set, so a decoder completing for this same +/// generation starts PAUSED instead of auto-playing. `Resume` or a new +/// `Play` clears the latch. +/// - `generation`: the playback generation the acknowledgement is bound to. +/// A coordinator holding a snapshot from an older generation must treat +/// this ack as stale and re-negotiate. +#[derive(Debug, Clone, Copy)] +pub struct PauseAck { + pub generation: u64, + pub sink_was_active: bool, + pub latched: bool, +} + #[derive(Debug, Clone)] pub enum PlayerEvent { /// Decoder is being set up (streaming: waiting for first bytes). @@ -56,6 +76,14 @@ enum Cmd { duration_hint: Option, }, Pause, + /// Pause with an acknowledgement bound to the playback generation — the + /// audio-focus primitive (Phase 0.4). Unlike `Pause`, this also LATCHES: + /// a decoder still in flight for the current generation will come up + /// paused instead of auto-starting, which plain `Pause` silently allows + /// today (with no sink yet, `Pause` is a no-op and the track then plays). + PauseAcked { + reply: mpsc::Sender, + }, Resume, Seek(f64), Scratch { @@ -243,6 +271,16 @@ impl Player { let _ = self.cmd_tx.send(Cmd::Pause); } + /// Pause and receive a [`PauseAck`] once the audio thread has applied it. + /// The returned receiver yields exactly one ack (or disconnects if the + /// player is gone). Callers should `recv_timeout` and treat a timeout as + /// "not acknowledged" — never as silence. + pub fn pause_acked(&self) -> mpsc::Receiver { + let (reply, rx) = mpsc::channel(); + let _ = self.cmd_tx.send(Cmd::PauseAcked { reply }); + rx + } + pub fn resume(&self) { let _ = self.cmd_tx.send(Cmd::Resume); } @@ -425,6 +463,13 @@ fn audio_thread( let mut active_track: Option = None; let mut scratching = false; let mut scratch_was_playing = false; + // Desired-paused latch (audio-focus primitive): set by PauseAcked, and it + // means "nothing for the CURRENT generation may become audible on its + // own". Its one job is closing the window where a pause lands while the + // decoder is still in flight — plain Pause is a no-op then, and the track + // auto-starts moments after the caller believed playback was silenced. + // Cleared by Resume and by Play (a new track is a new intent). + let mut desired_paused = false; let emit = |ev: PlayerEvent| { let _ = event_tx.send(ev); @@ -455,6 +500,7 @@ fn audio_thread( pending_tracks.clear(); playing = false; scratching = false; + desired_paused = false; // a new Play is explicit listen intent let track = TrackSource { path, stream, @@ -497,7 +543,26 @@ fn audio_thread( emit(PlayerEvent::Paused); } } + Ok(Cmd::PauseAcked { reply }) => { + let sink_was_active = if let Some(s) = &sink { + s.pause(); + playing = false; + emit(PlayerEvent::Paused); + true + } else { + false + }; + // Latch regardless: if a decoder for this generation is still + // in flight, it must come up paused, not audible. + desired_paused = true; + let _ = reply.send(PauseAck { + generation, + sink_was_active, + latched: !sink_was_active, + }); + } Ok(Cmd::Resume) => { + desired_paused = false; if let Some(s) = &sink { s.play(); playing = true; @@ -752,7 +817,11 @@ fn audio_thread( }); } else { duration = track_duration; - if ready.start_paused { + // desired_paused: an acknowledged pause landed while + // this decoder was in flight. Honouring it here is + // the whole point of the latch — the caller was told + // "silent", so the track must not become audible. + if ready.start_paused || desired_paused { target.pause(); playing = false; emit(PlayerEvent::Paused);