forked from Keelrleek/SoulStream
Phase 0.4: silence-ack primitive — pause_acked + desired-paused latch
Player::pause_acked() returns a generation-bound PauseAck applied on the audio thread; a desired-paused latch makes an in-flight decoder come up paused instead of auto-starting, closing the window where a pause lands during decode setup. Proven live by examples/focus_ack_probe.rs: ack-while-playing freezes position; ack-while-buffering never becomes audible; resume clears the latch; plain pause() during buffering is demonstrated to auto-start (pre-existing hazard, filed separately). Transport ADR recorded (native), Phase 0 marked complete in the plan.
This commit is contained in:
1 parent
58918cf79d
commit
6dcc62a2fd
2 files changed
+175
-1
No files matched your search
@@ -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 -- <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");
|
||||||
|
}
|
||||||
@@ -24,6 +24,26 @@ use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source};
|
|||||||
|
|
||||||
use source::GrowingFile;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum PlayerEvent {
|
pub enum PlayerEvent {
|
||||||
/// Decoder is being set up (streaming: waiting for first bytes).
|
/// Decoder is being set up (streaming: waiting for first bytes).
|
||||||
@@ -56,6 +76,14 @@ enum Cmd {
|
|||||||
duration_hint: Option<f64>,
|
duration_hint: Option<f64>,
|
||||||
},
|
},
|
||||||
Pause,
|
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<PauseAck>,
|
||||||
|
},
|
||||||
Resume,
|
Resume,
|
||||||
Seek(f64),
|
Seek(f64),
|
||||||
Scratch {
|
Scratch {
|
||||||
@@ -243,6 +271,16 @@ impl Player {
|
|||||||
let _ = self.cmd_tx.send(Cmd::Pause);
|
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<PauseAck> {
|
||||||
|
let (reply, rx) = mpsc::channel();
|
||||||
|
let _ = self.cmd_tx.send(Cmd::PauseAcked { reply });
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resume(&self) {
|
pub fn resume(&self) {
|
||||||
let _ = self.cmd_tx.send(Cmd::Resume);
|
let _ = self.cmd_tx.send(Cmd::Resume);
|
||||||
}
|
}
|
||||||
@@ -425,6 +463,13 @@ fn audio_thread(
|
|||||||
let mut active_track: Option<TrackSource> = None;
|
let mut active_track: Option<TrackSource> = None;
|
||||||
let mut scratching = false;
|
let mut scratching = false;
|
||||||
let mut scratch_was_playing = 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 emit = |ev: PlayerEvent| {
|
||||||
let _ = event_tx.send(ev);
|
let _ = event_tx.send(ev);
|
||||||
@@ -455,6 +500,7 @@ fn audio_thread(
|
|||||||
pending_tracks.clear();
|
pending_tracks.clear();
|
||||||
playing = false;
|
playing = false;
|
||||||
scratching = false;
|
scratching = false;
|
||||||
|
desired_paused = false; // a new Play is explicit listen intent
|
||||||
let track = TrackSource {
|
let track = TrackSource {
|
||||||
path,
|
path,
|
||||||
stream,
|
stream,
|
||||||
@@ -497,7 +543,26 @@ fn audio_thread(
|
|||||||
emit(PlayerEvent::Paused);
|
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) => {
|
Ok(Cmd::Resume) => {
|
||||||
|
desired_paused = false;
|
||||||
if let Some(s) = &sink {
|
if let Some(s) = &sink {
|
||||||
s.play();
|
s.play();
|
||||||
playing = true;
|
playing = true;
|
||||||
@@ -752,7 +817,11 @@ fn audio_thread(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
duration = track_duration;
|
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();
|
target.pause();
|
||||||
playing = false;
|
playing = false;
|
||||||
emit(PlayerEvent::Paused);
|
emit(PlayerEvent::Paused);
|
||||||
|
|||||||
Reference in new issue
Block a user