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
@@ -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<f64>,
|
||||
},
|
||||
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,
|
||||
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<PauseAck> {
|
||||
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<TrackSource> = 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);
|
||||
|
||||
Reference in new issue
Block a user