pub(crate) fn playback_run(
inner: &Inner,
settings: &PbSettings,
queue: &PlayQueue,
)Expand description
Drive one whole mic-playback run on the playback thread. The body handed to
spawn_worker; the mirror of capture_run for the uplink.
This thread solely owns the PA playback stream, so writes are serialized structurally
with no executor. It mirrors capture_run’s lifecycle: start_state goes RUNNING
once the first session is up, FAILED (with a last_error) when the run gives up, and
IDLE on a clean stop; it returns when stop_state leaves STOP_NONE or the retry
budget is spent.
Session loop: the sink can die under a live stream (PulseAudio/PipeWire restart, sink removed). Breaking out there would leave the mic uplink dead until something upstream noticed and restarted the whole playback, losing every packet in between, so the session is reopened with the same backoff and budgets capture uses.
Buffer sizing and the prebuf timing rule (load-bearing): tlength is the target
latency in bytes, and prebuf is a quarter of it, floored to one frame. prebuf must
NOT be zero: with prebuf == 0 PulseAudio starts playback instantly, the realtime read
pointer then runs ahead of the write index, and every SeekMode::Relative write lands
“in the past” — the server silently discards it forever (observed as bytes flowing at
exactly realtime rate while the sink monitor stayed silent). A quarter-buffer prebuf makes
the stream wait for data before starting and re-prebuffer after each underrun, so a late
chunk plays slightly delayed instead of vanishing.
Hot loop: pumps on the ~20 ms bound, then, whenever the server is writable, drains
that many bytes from the PlayQueue (clamped and frame-aligned) and writes them with
free_cb = None, so PA copies the bytes and the scratch buffer is reused next
iteration. Newly queued bytes are picked up on the next pump — no cross-thread wakeup is
needed, mirroring capture’s poll style — and a stop is observed within the pump bound.