Skip to main content

pcmflux/
lib.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! pcmflux: PulseAudio/PipeWire audio capture with Opus encoding, plus mic-uplink
8//! playback, exposed as a pure-Rust PyO3 extension (the audio sibling of pixelflux).
9//!
10//! The capture thread pulls S16LE PCM fragments from a PulseAudio record stream,
11//! reassembles them into fixed-size Opus frames, encodes them (mono/stereo via the
12//! `opus` crate, 5.1/7.1 surround via the multistream API), optionally wraps them in
13//! RFC 2198 RED framing — redundant copies of recent frames, so a client on a lossy
14//! transport rebuilds a dropped packet from the next one it receives instead of stalling
15//! for a retransmit — and hands each frame to a delivery thread that runs the Python
16//! callback off the audio path, so a slow or GIL-blocked callback can never stall the
17//! PulseAudio pump. The playback path mirrors this in reverse: it decodes the Opus mic
18//! uplink and writes PCM into a virtual sink.
19//!
20//! Concurrency design (the invariants below are load-bearing):
21//!   - A lifecycle mutex serializes joining/reassigning the capture thread.
22//!   - A single stop_state atomic is the one source of truth (0 = running, -1 =
23//!     external stop, a positive value = self-stop recorded under the issuing thread's
24//!     tid — the delivery thread's for a callback-issued stop). The
25//!     external -1 is stored INSIDE that lock immediately before join, so a stop can
26//!     never be lost between observing a live thread and asking it to stop. A
27//!     re-entrant self-start undoes only its own self-stop via one compare-exchange,
28//!     so a racing external stop is never clobbered.
29//!   - The PulseAudio mainloop is pumped with a bounded ~20ms timeout, so a stop is
30//!     observed within ~20ms even if the audio source delivers no data (is wedged).
31//!   - The GIL is released around join, because joining the capture thread transitively
32//!     joins the delivery thread, whose in-flight Python callback needs the GIL; holding
33//!     it while joining would deadlock.
34//!   - A callback may itself call stop/start; the callback runs on the delivery thread,
35//!     so that re-entrant case is detected via the delivery thread's OS tid (the capture
36//!     thread's tid is checked too) and short-circuits without joining — a join from
37//!     inside the callback would cycle (stopper joins capture, capture joins delivery,
38//!     delivery is the stopper).
39
40use pyo3::buffer::PyUntypedBuffer;
41use pyo3::prelude::*;
42use pyo3::types::{PyBytes, PyString};
43use std::collections::VecDeque;
44use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, AtomicU64, AtomicU8, AtomicUsize, Ordering};
45use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak};
46use std::thread::{JoinHandle, ThreadId};
47use std::time::{Duration, Instant};
48
49use libpulse_binding as pulse;
50use pulse::callbacks::ListResult;
51use pulse::context::{Context, FlagSet as CtxFlags};
52use pulse::def::BufferAttr;
53use pulse::sample::{Format, Spec};
54use pulse::mainloop::standard::Mainloop;
55use pulse::stream::{FlagSet as StreamFlags, PeekResult, Stream};
56use pulse::time::MicroSeconds;
57
58use opus::{Application, Channels};
59
60/// Non-panicking `println!` replacement that swallows write errors (e.g. EPIPE) instead
61/// of panicking, so a broken output pipe can't unwind the capture thread or a callback.
62macro_rules! plog {
63    ($($arg:tt)*) => {{
64        use std::io::Write;
65        let _ = writeln!(std::io::stdout().lock(), $($arg)*);
66    }};
67}
68/// Non-panicking `eprintln!` replacement — the stderr sibling of `plog!`.
69macro_rules! elog {
70    ($($arg:tt)*) => {{
71        use std::io::Write;
72        let _ = writeln!(std::io::stderr().lock(), $($arg)*);
73    }};
74}
75
76/// Returns the calling thread's OS tid (`gettid` syscall) so a stop/start issued from
77/// inside the Python callback can detect it is on the capture thread and avoid self-joining.
78#[inline]
79fn gettid() -> i64 {
80    unsafe { libc::syscall(libc::SYS_gettid) as i64 }
81}
82
83/// `start_state`: no worker has run yet, or the last run stopped cleanly.
84const ST_IDLE: u8 = 0;
85/// `start_state` handshake: startup in progress.
86const ST_STARTING: u8 = 1;
87/// `start_state` handshake: the hot loop is running.
88const ST_RUNNING: u8 = 2;
89/// `start_state`: the last run ended in error (startup or mid-run); `last_error` says why.
90const ST_FAILED: u8 = 3;
91
92/// Opus target-bitrate bounds (bits/s); the initial `opus_bitrate` and every live update
93/// are clamped into this range so the encoder never sees a value it would reject.
94const OPUS_BITRATE_MIN: i32 = 6000;
95const OPUS_BITRATE_MAX: i32 = 510000;
96
97/// `stop_state` sentinel: no stop pending (running).
98const STOP_NONE: i64 = 0;
99/// `stop_state` sentinel: authoritative external stop (-1). Any positive value
100/// instead is the OS tid of a capture thread that self-stopped from its callback.
101const STOP_EXTERNAL: i64 = -1;
102
103/// PulseAudio mainloop pump timeout (~20 ms) — upper bound on how long a pending
104/// stop can go unobserved even when the audio source delivers nothing.
105const PUMP_TIMEOUT_US: u64 = 20 * 1000;
106/// Max Opus output packet size in bytes per stream; sizes the emit buffer pool.
107const MAX_OPUS_PACKET: usize = 4000;
108
109/// Max RED timestamp offset — 14-bit ceiling in 48 kHz samples from the primary.
110const RED_MAX_OFFSET: u64 = 16383;
111/// Max RED block length — 10-bit ceiling in bytes.
112const RED_MAX_LEN: usize = 1023;
113/// RED block payload type.
114const RED_BLOCK_PT: u8 = 0;
115/// Max redundant copies per frame and RED history depth.
116const RED_MAX_DISTANCE: i32 = 4;
117
118/// Test-only reference implementation of the WS audio frame body — builds the
119/// full RFC 2198 RED framing + primary Opus packet for byte-for-byte assertion
120/// against the pooled in-place `write_ws_prefix_into` on the hot path.
121///
122/// RED carries redundant copies of recent frames alongside each primary, so a client on a
123/// lossy transport can rebuild a dropped packet from the next one it receives with no
124/// retransmit — that packet-loss concealment is the whole reason the layout below exists.
125///
126/// `history` is oldest-first (front = oldest, largest timestamp offset back from
127/// `primary_pts`). The layout is chosen by `emit_header` and `red_distance`:
128///
129/// 1. **Header omitted** (`!emit_header`): the raw primary Opus packet, with no framing
130///    bytes — the header is what carries the redundant-block count, so without it there
131///    is no `[0x01,n]` prefix.
132/// 2. **`red_distance == 0`, or no usable redundancy**: the 2-byte `[0x01, 0x00]` framing
133///    then the primary. On this wire `n_red == 0` must mean **exactly** those two bytes,
134///    so a first frame after a (re)start (empty history) collapses here too; emitting a
135///    lone primary-only RED header instead would be mis-stripped by the client's
136///    `n_red == 0` path and corrupt the frame.
137/// 3. **`red_distance > 0` with usable history**: the full RFC 2198 RED framing —
138///    - `0x01` audio-chunk tag, then `n_red` (count of redundant blocks that actually fit).
139///    - The primary timestamp (low 32 bits, big-endian), letting the client order and dedup
140///      recovered frames against what it has already played.
141///    - One 4-byte header per redundant block, oldest-first: `0x80 | PT` (F bit set,
142///      i.e. another block follows), then `(offset14 << 10) | len10` big-endian.
143///    - The 1-byte primary block header (`PT`, F bit clear).
144///    - The block payloads in that same order: redundant oldest-first, then the primary.
145///
146/// A redundant block whose 48 kHz sample offset overflows `RED_MAX_OFFSET` (14 bits) or
147/// whose byte length overflows `RED_MAX_LEN` (10 bits) is skipped, so `n_red` counts only
148/// what fit.
149#[cfg(test)]
150fn build_ws_body(
151    primary: &[u8],
152    primary_pts: u64,
153    history: &VecDeque<(Vec<u8>, u64)>,
154    red_distance: usize,
155    emit_header: bool,
156) -> Vec<u8> {
157    if !emit_header {
158        return primary.to_vec();
159    }
160    if red_distance == 0 {
161        let mut out = Vec::with_capacity(2 + primary.len());
162        out.push(0x01);
163        out.push(0x00);
164        out.extend_from_slice(primary);
165        return out;
166    }
167    let start = history.len().saturating_sub(red_distance);
168    let mut blocks: Vec<(&[u8], u32)> = Vec::with_capacity(red_distance);
169    for (data, pts) in history.iter().skip(start) {
170        let offset = primary_pts.saturating_sub(*pts);
171        if offset > RED_MAX_OFFSET || data.len() > RED_MAX_LEN {
172            continue;
173        }
174        blocks.push((data.as_slice(), offset as u32));
175    }
176    if blocks.is_empty() {
177        let mut out = Vec::with_capacity(2 + primary.len());
178        out.push(0x01);
179        out.push(0x00);
180        out.extend_from_slice(primary);
181        return out;
182    }
183    let redundant_bytes: usize = blocks.iter().map(|(d, _)| d.len()).sum();
184    let mut out = Vec::with_capacity(6 + 4 * blocks.len() + 1 + redundant_bytes + primary.len());
185    out.push(0x01);
186    out.push(blocks.len() as u8);
187    out.extend_from_slice(&(primary_pts as u32).to_be_bytes());
188    for (data, offset) in &blocks {
189        out.push(0x80 | (RED_BLOCK_PT & 0x7F));
190        let word = ((offset & 0x3FFF) << 10) | (data.len() as u32 & 0x3FF);
191        out.push((word >> 16) as u8);
192        out.push((word >> 8) as u8);
193        out.push(word as u8);
194    }
195    out.push(RED_BLOCK_PT & 0x7F);
196    for (data, _) in &blocks {
197        out.extend_from_slice(data);
198    }
199    out.extend_from_slice(primary);
200    out
201}
202
203/// Worst-case byte length of the WS frame prefix (tag + n_red + pts + headers + payloads).
204const RED_PREFIX_MAX: usize =
205    6 + 4 * RED_MAX_DISTANCE as usize + 1 + RED_MAX_DISTANCE as usize * RED_MAX_LEN;
206
207/// Emit the RFC 2198 RED framing prefix into `buf` in-place and return its length,
208/// so the encoder can serialize the Opus packet directly after it with no scratch
209/// buffer and no per-frame allocation. The runtime counterpart of `build_ws_body`.
210///
211/// 1. **Header omitted**: returns `0`; the caller emits the raw primary with no prefix.
212/// 2. **`red_distance > 0` with usable history**: writes the full RED header — tag,
213///    `n_red`, the primary timestamp (low 32 bits, big-endian), one 4-byte header per
214///    redundant block (F bit set, then `(offset14 << 10) | len10`), the 1-byte primary
215///    header (F bit clear), and the redundant block payloads oldest-first. Usable blocks
216///    are selected by history index into a fixed `RED_MAX_DISTANCE` array — the distance is
217///    clamped at ingest, so this allocates nothing.
218/// 3. **`red_distance == 0`, or no usable redundancy**: the 2-byte `[0x01, 0x00]` framing.
219///
220/// `buf` must hold at least `RED_PREFIX_MAX` bytes when `emit_header && red_distance > 0`,
221/// and at least 2 bytes otherwise.
222fn write_ws_prefix_into(
223    buf: &mut [u8],
224    primary_pts: u64,
225    history: &VecDeque<(Vec<u8>, u64)>,
226    red_distance: usize,
227    emit_header: bool,
228) -> usize {
229    if !emit_header {
230        return 0;
231    }
232    if red_distance > 0 {
233        debug_assert!(red_distance <= RED_MAX_DISTANCE as usize);
234        let start = history.len().saturating_sub(red_distance);
235        let mut idx = [0usize; RED_MAX_DISTANCE as usize];
236        let mut n = 0usize;
237        for (i, (data, pts)) in history.iter().enumerate().skip(start) {
238            let offset = primary_pts.saturating_sub(*pts);
239            if offset <= RED_MAX_OFFSET && data.len() <= RED_MAX_LEN {
240                idx[n] = i;
241                n += 1;
242            }
243        }
244        if n > 0 {
245            buf[0] = 0x01;
246            buf[1] = n as u8;
247            buf[2..6].copy_from_slice(&(primary_pts as u32).to_be_bytes());
248            let mut i = 6;
249            for &k in &idx[..n] {
250                let (data, pts) = &history[k];
251                let offset = primary_pts.saturating_sub(*pts) as u32;
252                buf[i] = 0x80 | (RED_BLOCK_PT & 0x7F);
253                let word = ((offset & 0x3FFF) << 10) | (data.len() as u32 & 0x3FF);
254                buf[i + 1] = (word >> 16) as u8;
255                buf[i + 2] = (word >> 8) as u8;
256                buf[i + 3] = word as u8;
257                i += 4;
258            }
259            buf[i] = RED_BLOCK_PT & 0x7F;
260            i += 1;
261            for &k in &idx[..n] {
262                let data = &history[k].0;
263                buf[i..i + data.len()].copy_from_slice(data);
264                i += data.len();
265            }
266            return i;
267        }
268    }
269    buf[0] = 0x01;
270    buf[1] = 0x00;
271    2
272}
273
274/// Capture/encode settings, snapshotted from `AudioCaptureSettings` at start.
275#[derive(Clone)]
276struct Settings {
277    device_name: Option<String>,
278    sample_rate: u32,
279    channels: i32,
280    opus_bitrate: i32,
281    frame_duration_ms: f64,
282    use_vbr: bool,
283    use_silence_gate: bool,
284    debug_logging: bool,
285    latency_ms: i32,
286    omit_audio_header: bool,
287    red_distance: i32,
288}
289
290/// True if `ms` is a valid Opus frame duration (2.5, 5, 10, 20, 40, or 60 ms).
291///
292/// The comparison is done in tenths of a millisecond (`round(ms * 10)`) so the fractional
293/// 2.5 ms case is accepted exactly, without float-equality pitfalls.
294fn valid_opus_duration(ms: f64) -> bool {
295    matches!((ms * 10.0).round() as i64, 25 | 50 | 100 | 200 | 400 | 600)
296}
297
298/// Normalize a Python `device_name` (`str | bytes | None`) into `Option<String>`,
299/// mapping both `None` and the empty string to `None` (meaning the system default).
300/// Shared by the capture and playback settings extractors.
301///
302/// An interior NUL is rejected here with `ValueError`: the name becomes a C string for
303/// libpulse on the worker thread, where it could only surface as a panic.
304fn parse_device_name(dev_obj: &Bound<'_, PyAny>) -> PyResult<Option<String>> {
305    if dev_obj.is_none() {
306        return Ok(None);
307    }
308    let v: String = if let Ok(st) = dev_obj.cast::<PyString>() {
309        st.to_str()?.to_string()
310    } else if let Ok(b) = dev_obj.cast::<PyBytes>() {
311        String::from_utf8_lossy(b.as_bytes()).into_owned()
312    } else {
313        dev_obj.extract()?
314    };
315    if v.contains('\0') {
316        return Err(pyo3::exceptions::PyValueError::new_err(
317            "device_name must not contain NUL bytes",
318        ));
319    }
320    Ok(if v.is_empty() { None } else { Some(v) })
321}
322
323/// Read a Python `AudioCaptureSettings` into a Rust `Settings` by attribute name,
324/// rejecting with `ValueError` what the worker could only fail on later.
325///
326/// `opus_bitrate` is clamped into the Opus range exactly as `update_audio_bitrate`
327/// clamps a live update; `red_distance` into `[0, RED_MAX_DISTANCE]` — it selects how
328/// many redundant Opus copies each frame carries, and cannot exceed the RFC 2198
329/// history depth. `latency_ms == 0` selects the default fragment size.
330fn extract_settings(s: &Bound<'_, PyAny>) -> PyResult<Settings> {
331    let device_name = parse_device_name(&s.getattr("device_name")?)?;
332    let parsed = Settings {
333        device_name,
334        sample_rate: s.getattr("sample_rate")?.extract()?,
335        channels: s.getattr("channels")?.extract()?,
336        opus_bitrate: s
337            .getattr("opus_bitrate")?
338            .extract::<i32>()?
339            .clamp(OPUS_BITRATE_MIN, OPUS_BITRATE_MAX),
340        frame_duration_ms: s.getattr("frame_duration_ms")?.extract()?,
341        use_vbr: s.getattr("use_vbr")?.extract()?,
342        use_silence_gate: s.getattr("use_silence_gate")?.extract()?,
343        debug_logging: s.getattr("debug_logging")?.extract()?,
344        latency_ms: s.getattr("latency_ms")?.extract()?,
345        omit_audio_header: s.getattr("omit_audio_header")?.extract()?,
346        red_distance: s.getattr("red_distance")?.extract::<i32>()?.clamp(0, RED_MAX_DISTANCE),
347    };
348    check_opus_sample_rate(parsed.sample_rate)?;
349    if !valid_opus_duration(parsed.frame_duration_ms) {
350        return value_error(format!(
351            "frame_duration_ms must be one of 2.5, 5, 10, 20, 40 or 60 (got {})",
352            parsed.frame_duration_ms
353        ));
354    }
355    if !matches!(parsed.channels, 1 | 2 | 6 | 8) {
356        return value_error(format!("channels must be 1, 2, 6 or 8 (got {})", parsed.channels));
357    }
358    if parsed.latency_ms < 0 {
359        return value_error(format!(
360            "latency_ms must be >= 0 (got {}); 0 selects the default fragment size",
361            parsed.latency_ms
362        ));
363    }
364    Ok(parsed)
365}
366
367/// Build a `ValueError` for a rejected settings field.
368fn value_error<T>(msg: String) -> PyResult<T> {
369    Err(pyo3::exceptions::PyValueError::new_err(msg))
370}
371
372/// Opus codecs only run at 8, 12, 16, 24 or 48 kHz; anything else would fail encoder or
373/// decoder creation on the worker, so it is rejected up front.
374fn check_opus_sample_rate(rate: u32) -> PyResult<()> {
375    if matches!(rate, 8000 | 12000 | 16000 | 24000 | 48000) {
376        Ok(())
377    } else {
378        value_error(format!(
379            "sample_rate must be 8000, 12000, 16000, 24000 or 48000 (got {rate})"
380        ))
381    }
382}
383
384/// Playback settings, snapshotted from the Python `AudioPlaybackSettings` at
385/// start so the playback thread owns an immutable copy for the run's lifetime.
386#[derive(Clone)]
387struct PbSettings {
388    device_name: Option<String>,
389    sample_rate: u32,
390    channels: i32,
391    latency_ms: i32,
392    max_buffer_bytes: usize,
393    debug_logging: bool,
394}
395
396/// Read a Python `AudioPlaybackSettings` into a Rust `PbSettings` by attribute name,
397/// rejecting with `ValueError` what the playback thread could only fail on later.
398///
399/// `latency_ms` must be positive: it sizes the sink buffer, and a zero target would make
400/// PulseAudio start playback with nothing prebuffered and underrun on every write.
401/// `max_buffer_bytes` must be positive: it bounds the drop-oldest queue, and a zero bound
402/// would discard every chunk as soon as it was queued.
403fn extract_pb_settings(s: &Bound<'_, PyAny>) -> PyResult<PbSettings> {
404    let parsed = PbSettings {
405        device_name: parse_device_name(&s.getattr("device_name")?)?,
406        sample_rate: s.getattr("sample_rate")?.extract()?,
407        channels: s.getattr("channels")?.extract()?,
408        latency_ms: s.getattr("latency_ms")?.extract()?,
409        max_buffer_bytes: s.getattr("max_buffer_bytes")?.extract()?,
410        debug_logging: s.getattr("debug_logging")?.extract()?,
411    };
412    check_opus_sample_rate(parsed.sample_rate)?;
413    if !matches!(parsed.channels, 1 | 2) {
414        return value_error(format!("playback channels must be 1 or 2 (got {})", parsed.channels));
415    }
416    if parsed.latency_ms <= 0 {
417        return value_error(format!("latency_ms must be > 0 (got {})", parsed.latency_ms));
418    }
419    if parsed.max_buffer_bytes == 0 {
420        return value_error("max_buffer_bytes must be > 0 (got 0)".to_string());
421    }
422    Ok(parsed)
423}
424
425/// Python-facing capture/encode configuration read by `start_capture`.
426///
427/// Declared `#[pyclass(dict)]` so callers may stash extra attributes on instances; the
428/// fields below are the ones read by attribute name in `extract_settings`. `device_name`
429/// accepts `str | bytes | None`.
430#[pyclass(dict)]
431struct AudioCaptureSettings {
432    #[pyo3(get, set)]
433    device_name: Py<PyAny>,
434    #[pyo3(get, set)]
435    sample_rate: u32,
436    #[pyo3(get, set)]
437    channels: i32,
438    #[pyo3(get, set)]
439    opus_bitrate: i32,
440    #[pyo3(get, set)]
441    frame_duration_ms: f64,
442    #[pyo3(get, set)]
443    use_vbr: bool,
444    #[pyo3(get, set)]
445    use_silence_gate: bool,
446    #[pyo3(get, set)]
447    debug_logging: bool,
448    #[pyo3(get, set)]
449    latency_ms: i32,
450    #[pyo3(get, set)]
451    omit_audio_header: bool,
452    #[pyo3(get, set)]
453    red_distance: i32,
454}
455
456#[pymethods]
457impl AudioCaptureSettings {
458    #[new]
459    fn new(py: Python<'_>) -> Self {
460        AudioCaptureSettings {
461            device_name: py.None(),
462            sample_rate: 48000,
463            channels: 2,
464            opus_bitrate: 128000,
465            frame_duration_ms: 20.0,
466            use_vbr: true,
467            use_silence_gate: true,
468            debug_logging: false,
469            latency_ms: 0,
470            omit_audio_header: false,
471            red_distance: 0,
472        }
473    }
474}
475
476/// Python-facing mic-playback configuration read by `AudioPlayback.start`.
477///
478/// Defaults match the client mic wire (S16LE / mono / 24 kHz). `max_buffer_bytes` is the
479/// single byte bound on the drop-oldest playback queue (~2 s at 24 kHz mono s16), and
480/// `device_name` accepts `str | bytes | None`.
481#[pyclass]
482struct AudioPlaybackSettings {
483    #[pyo3(get, set)]
484    device_name: Py<PyAny>,
485    #[pyo3(get, set)]
486    sample_rate: u32,
487    #[pyo3(get, set)]
488    channels: i32,
489    #[pyo3(get, set)]
490    latency_ms: i32,
491    #[pyo3(get, set)]
492    max_buffer_bytes: usize,
493    #[pyo3(get, set)]
494    debug_logging: bool,
495}
496
497#[pymethods]
498impl AudioPlaybackSettings {
499    #[new]
500    fn new(py: Python<'_>) -> Self {
501        AudioPlaybackSettings {
502            device_name: PyString::new(py, "input").into_any().unbind(),
503            sample_rate: 24000,
504            channels: 1,
505            latency_ms: 40,
506            max_buffer_bytes: 96000,
507            debug_logging: false,
508        }
509    }
510}
511
512/// Zero-copy buffer-protocol result type handed to the Python callback.
513///
514/// Owns its `Vec<u8>` and exposes it read-only through the buffer protocol, so Python can
515/// read the encoded frame without a copy. When the last Python reference is released and
516/// the frame is dropped, a pooled buffer is recycled back to the capture thread (see the
517/// `Drop` impl), keeping the steady-state emit path allocation-free.
518#[pyclass]
519struct AudioFrame {
520    data: Vec<u8>,
521    pts: u64,
522    /// Set when the buffer came from a capture's `BufferPool`; recycled to it on drop.
523    pool: Option<Arc<BufferPool>>,
524}
525
526impl Drop for AudioFrame {
527    fn drop(&mut self) {
528        if let Some(pool) = self.pool.take() {
529            pool.put(std::mem::take(&mut self.data));
530        }
531    }
532}
533
534#[pymethods]
535impl AudioFrame {
536    fn __len__(&self) -> usize {
537        self.data.len()
538    }
539
540    #[getter]
541    fn pts(&self) -> u64 {
542        self.pts
543    }
544
545    /// Expose the owned bytes to Python's buffer protocol without a copy.
546    ///
547    /// `PyBuffer_FillInfo` INCREFs `slf` into `view->obj`, pinning the `Vec` alive until
548    /// every `memoryview` / slice over it is released. The view is readonly, so the
549    /// consumer cannot mutate the encoded frame.
550    unsafe fn __getbuffer__(
551        slf: PyRefMut<'_, Self>,
552        view: *mut pyo3::ffi::Py_buffer,
553        flags: std::os::raw::c_int,
554    ) -> PyResult<()> {
555        let r = unsafe {
556            pyo3::ffi::PyBuffer_FillInfo(
557                view,
558                slf.as_ptr(),
559                slf.data.as_ptr() as *mut std::os::raw::c_void,
560                slf.data.len() as pyo3::ffi::Py_ssize_t,
561                1,
562                flags,
563            )
564        };
565        if r != 0 {
566            return Err(PyErr::fetch(slf.py()));
567        }
568        Ok(())
569    }
570
571    unsafe fn __releasebuffer__(&self, _view: *mut pyo3::ffi::Py_buffer) {}
572}
573
574/// Shared state for one capture (or playback) run: the lifecycle state machine plus the
575/// per-frame settings mirrors the worker reads on the hot path — all lock-free except the
576/// rarely touched `last_error`.
577///
578/// The lifecycle is driven by two atomics — `stop_state` (the single source of truth for
579/// "should this run stop") and `start_state` (IDLE → STARTING → RUNNING, then FAILED with a
580/// `last_error` or back to IDLE on a clean stop) — plus `capture_tid` and `deliver_tid`, the
581/// worker and delivery threads' OS tids used to detect a re-entrant stop/start issued from
582/// inside the Python callback (which runs on the delivery thread). The remaining atomics
583/// mirror settings the worker consults each frame without re-snapshotting `Settings`, so
584/// `update_audio_bitrate` can retune the encoder mid-run without locking; the silence and
585/// header flags are published once at start and only read per frame.
586struct Inner {
587    /// Single lifecycle source of truth: `STOP_NONE` (running), `STOP_EXTERNAL`, or a
588    /// positive tid meaning the run self-stopped from inside its own callback (recorded
589    /// under the issuing thread's tid — the delivery thread's). A re-entrant start clears
590    /// only its own self-stop via compare-exchange, so it can never clobber an external
591    /// stop that raced in mid-join (which would strand it).
592    stop_state: AtomicI64,
593    started_ok: AtomicBool,
594    start_state: AtomicU8,
595    /// Why the last run failed (`start_state == ST_FAILED`), for Python's `last_error`.
596    /// Written before `ST_FAILED` is published and cleared when a new run is spawned, so
597    /// a failure that lands after the start handshake returned — the retry ladder giving
598    /// up, a mid-run reconnect budget spent, a worker panic — is still observable.
599    last_error: Mutex<Option<String>>,
600    /// OS tid of the running capture thread; `0` when no worker is live.
601    capture_tid: AtomicI64,
602    /// OS tid of the running delivery thread (the one that invokes the Python callback);
603    /// `0` when none is live. Checked by the re-entrancy guards alongside `capture_tid`,
604    /// because a stop/start issued from inside the callback executes on THIS thread — a
605    /// join from it would cycle (stopper joins capture, capture joins delivery).
606    deliver_tid: AtomicI64,
607    /// Lock-free per-frame settings mirrors, re-read by the worker each frame.
608    /// `opus_bitrate` is republished by `update_audio_bitrate`; the rest are published
609    /// once, by the run that starts.
610    opus_bitrate: AtomicI32,
611    use_silence_gate: AtomicBool,
612    debug_logging: AtomicBool,
613    emit_audio_header: AtomicBool,
614}
615
616impl Inner {
617    fn new() -> Self {
618        Inner {
619            stop_state: AtomicI64::new(STOP_NONE),
620            started_ok: AtomicBool::new(false),
621            start_state: AtomicU8::new(ST_IDLE),
622            last_error: Mutex::new(None),
623            capture_tid: AtomicI64::new(0),
624            deliver_tid: AtomicI64::new(0),
625            opus_bitrate: AtomicI32::new(128000),
626            use_silence_gate: AtomicBool::new(true),
627            debug_logging: AtomicBool::new(false),
628            emit_audio_header: AtomicBool::new(true),
629        }
630    }
631
632    /// Request an authoritative external stop, stored unconditionally.
633    ///
634    /// The single source of truth for stopping a run. External stops win every race: they
635    /// are published (inside the lifecycle lock, immediately before join) with a plain
636    /// store, so a concurrent self-stop/self-start — which only ever compare-exchanges
637    /// state it owns — can never clobber one and strand the join forever.
638    fn request_external_stop(&self) {
639        self.stop_state.store(STOP_EXTERNAL, Ordering::Release);
640    }
641
642    /// Record a re-entrant self-stop from inside the run's own callback.
643    ///
644    /// Only transitions `STOP_NONE -> me` via compare-exchange, so it never overwrites a
645    /// pending external stop (which must win the join).
646    fn request_self_stop(&self, me: i64) {
647        let _ = self
648            .stop_state
649            .compare_exchange(STOP_NONE, me, Ordering::AcqRel, Ordering::Acquire);
650    }
651
652    /// Undo a re-entrant self-stop (self-start from inside the callback).
653    ///
654    /// Compare-exchanges `me -> STOP_NONE`, clearing the stop only if this same thread
655    /// still owns it. If an external stop landed in between, the CAS fails and that stop
656    /// stands.
657    fn undo_self_stop(&self, me: i64) {
658        let _ = self
659            .stop_state
660            .compare_exchange(me, STOP_NONE, Ordering::AcqRel, Ordering::Acquire);
661    }
662
663    /// Clear the stop state back to running. Only ever called under the lifecycle
664    /// lock (after join, before spawn), where no external stop can be in flight — the
665    /// lost-stop invariant that lets this be an unconditional store.
666    fn clear_stop(&self) {
667        self.stop_state.store(STOP_NONE, Ordering::Release);
668    }
669
670    /// True once any stop (external or self) is pending; the hot loops poll this.
671    fn stop_pending(&self) -> bool {
672        self.stop_state.load(Ordering::Acquire) != STOP_NONE
673    }
674
675    /// True while a worker is (or is still becoming) live: the startup handshake
676    /// is in flight, or the hot loop is running with no stop pending.
677    ///
678    /// Goes false the moment the worker fails, is stopped, or dies mid-run — the hot loop
679    /// clears `started_ok` before breaking on error, even with no stop pending and
680    /// `start_state` still `RUNNING`. Producers (e.g. `AudioPlayback::write`) gate on this
681    /// so they surface a dead stream instead of feeding state nothing services.
682    fn worker_alive(&self) -> bool {
683        self.start_state.load(Ordering::Acquire) == ST_STARTING || self.running()
684    }
685
686    /// True while the worker is connected and running with no stop pending — the
687    /// Python `is_capturing` / `is_running` getters.
688    fn running(&self) -> bool {
689        self.started_ok.load(Ordering::Acquire) && !self.stop_pending()
690    }
691
692    /// Lifecycle phase for Python's `state`: `"idle"` (no run yet, or the last run stopped
693    /// cleanly), `"starting"` (worker spawned, first PulseAudio session not yet up),
694    /// `"running"` (connected, or reconnecting mid-run, with no stop pending), or
695    /// `"failed"` (the last run ended in error; `last_error` says why). A run that was
696    /// stopped reads `"idle"` again even while its thread is still winding down.
697    fn state_name(&self) -> &'static str {
698        match self.start_state.load(Ordering::Acquire) {
699            ST_STARTING => "starting",
700            ST_FAILED => "failed",
701            ST_RUNNING if self.running() => "running",
702            _ => "idle",
703        }
704    }
705
706    /// Mark the run dead with a reason: logs `msg` to stderr, records it for `last_error`,
707    /// and only then publishes `ST_FAILED`, so a reader that observes the failed state also
708    /// finds its message. Every terminal worker error goes through here.
709    fn fail(&self, msg: String) {
710        elog!("[pcmflux] ERROR: {msg}");
711        *self.last_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(msg);
712        self.started_ok.store(false, Ordering::Release);
713        self.start_state.store(ST_FAILED, Ordering::Release);
714    }
715
716    /// The message of the last run's failure, or `None` while no run has failed since the
717    /// last (re)start.
718    fn last_error(&self) -> Option<String> {
719        self.last_error.lock().unwrap_or_else(|e| e.into_inner()).clone()
720    }
721
722    /// Forget the previous run's failure; called by `spawn_worker` as a new run is armed.
723    fn clear_error(&self) {
724        *self.last_error.lock().unwrap_or_else(|e| e.into_inner()) = None;
725    }
726
727    /// True when the calling thread is one of this run's own threads — the capture
728    /// worker or the delivery thread that runs the Python callback — i.e. the call is a
729    /// re-entrant stop/start/drop from inside the callback. Such a caller must never
730    /// join: teardown has the capture thread join the delivery thread, so a join from
731    /// either one closes a cycle and deadlocks.
732    fn is_own_thread(&self, me: i64) -> bool {
733        self.capture_tid.load(Ordering::Acquire) == me
734            || self.deliver_tid.load(Ordering::Acquire) == me
735    }
736}
737
738/// Per-`AudioCapture` shared handle: the lock-free `Inner` state plus the
739/// lifecycle-locked join handle for the capture thread.
740struct Shared {
741    inner: Arc<Inner>,
742    /// Lifecycle lock: serializes take/join/reassign of the capture thread's handle.
743    thread: Mutex<Option<JoinHandle<()>>>,
744}
745
746/// Process-wide registry of live captures, swept at interpreter exit. Holds `Weak`
747/// references so it keeps nothing alive on its own.
748static REGISTRY: OnceLock<Mutex<Vec<Weak<Shared>>>> = OnceLock::new();
749/// Lazily initialize and return the capture registry.
750fn registry() -> &'static Mutex<Vec<Weak<Shared>>> {
751    REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
752}
753
754/// Locked takeover + spawn of a worker thread, shared by the capture and playback
755/// starts. Returns the new thread's id, or `None` if the spawn failed.
756///
757/// Under the lifecycle lock, in order:
758///
759/// 1. **Stop and join any prior worker**: sets the external stop INSIDE the lock,
760///    immediately before `join()`, then clears `capture_tid` — the set-before-join
761///    ordering that makes the lost-stop invariant hold.
762/// 2. **Reset the lifecycle state**: clears `stop_state` back to `STOP_NONE` (done ONLY
763///    here, under the lock, after the join and before the spawn, where no external stop
764///    can be in flight), forgets the previous run's `last_error`, and arms the startup
765///    handshake at `ST_STARTING`.
766/// 3. **Spawn `body` on a named thread**: the thread applies a best-effort `nice` boost
767///    (audio must not stutter when the captured workload saturates the CPU; EPERM without
768///    `CAP_SYS_NICE` is silently a no-op), publishes its OS tid into `capture_tid` for the
769///    re-entrancy guard, runs `body`, then clears the tid on exit.
770///
771/// The returned `ThreadId` is the identity a failed start later hands to
772/// `join_failed_start`, so a losing start tears down only the thread it spawned.
773fn spawn_worker(
774    slot: &Mutex<Option<JoinHandle<()>>>,
775    inner: &Arc<Inner>,
776    name: &str,
777    body: impl FnOnce() + Send + 'static,
778) -> Option<ThreadId> {
779    let mut guard = slot.lock().unwrap();
780    if let Some(handle) = guard.take() {
781        inner.request_external_stop();
782        let _ = handle.join();
783        inner.capture_tid.store(0, Ordering::Release);
784    }
785    inner.clear_stop();
786    inner.clear_error();
787    inner.started_ok.store(false, Ordering::Release);
788    inner.start_state.store(ST_STARTING, Ordering::Release);
789    let t_inner = inner.clone();
790    match std::thread::Builder::new().name(name.into()).spawn(move || {
791        unsafe {
792            let tid = libc::syscall(libc::SYS_gettid) as libc::id_t;
793            let _ = libc::setpriority(libc::PRIO_PROCESS, tid, -15);
794        }
795        t_inner.capture_tid.store(gettid(), Ordering::Release);
796        // A worker panic must flip the liveness contract (started_ok/start_state):
797        // an unguarded unwind would leave is_capturing reporting true forever with
798        // no frames flowing and no error anywhere.
799        if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {
800            let what = payload
801                .downcast_ref::<&str>()
802                .map(|s| s.to_string())
803                .or_else(|| payload.downcast_ref::<String>().cloned())
804                .unwrap_or_else(|| "unknown panic payload".to_string());
805            t_inner.fail(format!("worker thread panicked: {what}"));
806        }
807        t_inner.capture_tid.store(0, Ordering::Release);
808    }) {
809        Ok(h) => {
810            let id = h.thread().id();
811            *guard = Some(h);
812            Some(id)
813        }
814        Err(_) => None,
815    }
816}
817
818/// Failed-start teardown: stop and join the thread `spawned` by THIS start attempt,
819/// but only if it still owns the slot.
820///
821/// A concurrent start may have already joined this thread and published a live replacement,
822/// which must not be torn down. The guard is an identity check: `ThreadId`s are never reused
823/// within a process, so it cannot false-match. When it does own the slot, the external stop
824/// is set INSIDE the lock before `join()`, matching every other join site (the
825/// set-before-join / lost-stop invariant).
826fn join_failed_start(slot: &Mutex<Option<JoinHandle<()>>>, inner: &Inner, spawned: ThreadId) {
827    let mut guard = slot.lock().unwrap();
828    if guard.as_ref().map(|h| h.thread().id()) != Some(spawned) {
829        return;
830    }
831    if let Some(handle) = guard.take() {
832        inner.request_external_stop();
833        let _ = handle.join();
834        inner.capture_tid.store(0, Ordering::Release);
835    }
836}
837
838/// Startup handshake shared by the capture and playback starts: with the GIL released,
839/// waits up to ~2 s for the worker this call spawned (`spawned`) to publish `RUNNING` or
840/// `FAILED`. A worker still `STARTING` when the window closes counts as started — its
841/// retry ladder legitimately runs longer than this — and a failure after that is exposed
842/// through `state` / `last_error` instead. On `FAILED`, `join_failed_start` tears down only
843/// that thread (identity-checked, sparing a concurrent winner) and the worker's recorded
844/// error is raised as `RuntimeError`.
845fn await_start(
846    py: Python<'_>,
847    slot: &Mutex<Option<JoinHandle<()>>>,
848    inner: &Inner,
849    spawned: ThreadId,
850    what: &str,
851) -> PyResult<()> {
852    let mut state = inner.start_state.load(Ordering::Acquire);
853    if state == ST_STARTING {
854        py.detach(|| {
855            for _ in 0..200 {
856                state = inner.start_state.load(Ordering::Acquire);
857                if state != ST_STARTING {
858                    break;
859                }
860                std::thread::sleep(Duration::from_millis(10));
861            }
862        });
863    }
864    if state == ST_FAILED {
865        py.detach(|| join_failed_start(slot, inner, spawned));
866        let why = inner.last_error().unwrap_or_else(|| "unknown error".to_string());
867        return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
868            "{what} failed to start: {why}"
869        )));
870    }
871    Ok(())
872}
873
874/// Bounded, drop-oldest byte queue for the mic-PCM handoff into the virtual
875/// "input" sink — the whole of the playback path's buffering in a single bound.
876///
877/// `push` runs on the Python side with the GIL released; `drain_upto` runs on the
878/// playback thread. Overflow discards the OLDEST bytes, keeping the newest window (mic
879/// audio is drift-tolerant and stale samples are worthless).
880struct PlayQueue {
881    buf: Mutex<VecDeque<u8>>,
882    /// Bounds (re)published by `start()`; atomics so a restart can reconfigure the
883    /// `Arc`-shared queue in place without swapping it. `frame_bytes >= 1` (set at `new()`).
884    max_bytes: AtomicUsize,
885    frame_bytes: AtomicUsize,
886}
887
888impl PlayQueue {
889    fn new() -> Self {
890        PlayQueue {
891            buf: Mutex::new(VecDeque::new()),
892            max_bytes: AtomicUsize::new(96000),
893            frame_bytes: AtomicUsize::new(2),
894        }
895    }
896
897    /// Apply this run's byte bound and frame alignment, and drop any stale audio
898    /// left from a prior run. `frame_bytes` is floored to 1; the bound is floored
899    /// to a whole-frame multiple (min one frame) so overflow drops can never split
900    /// a sample frame.
901    fn configure(&self, max_bytes: usize, frame_bytes: usize) {
902        let fb = frame_bytes.max(1);
903        self.frame_bytes.store(fb, Ordering::Relaxed);
904        self.max_bytes.store((max_bytes / fb * fb).max(fb), Ordering::Relaxed);
905        self.clear();
906    }
907
908    /// Drop everything queued, keeping the bounds. Used when a run starts and whenever the
909    /// playback session is reopened, since audio buffered across an outage is stale.
910    fn clear(&self) {
911        self.buf.lock().unwrap().clear();
912    }
913
914    /// Append client PCM, dropping the OLDEST whole frames once the queue passes
915    /// the byte bound so the newest audio is always retained. Drops stay
916    /// frame-aligned: trimming mid-frame would phase-shift every later drain into
917    /// interleaved garbage.
918    fn push(&self, data: &[u8]) {
919        let max = self.max_bytes.load(Ordering::Relaxed);
920        let fb = self.frame_bytes.load(Ordering::Relaxed);
921        let mut q = self.buf.lock().unwrap();
922        q.extend(data.iter().copied());
923        let over = q.len().saturating_sub(max);
924        if over > 0 {
925            let drop = (over.div_ceil(fb) * fb).min(q.len());
926            q.drain(..drop);
927        }
928    }
929
930    /// Drain up to `n` bytes into `out`, clamped to what is queued and floored to a
931    /// whole frame, since a PA write must be a multiple of the sample-spec frame size.
932    fn drain_upto(&self, n: usize, out: &mut Vec<u8>) {
933        let fb = self.frame_bytes.load(Ordering::Relaxed);
934        let mut q = self.buf.lock().unwrap();
935        let mut take = n.min(q.len());
936        take -= take % fb;
937        out.clear();
938        out.extend(q.drain(..take));
939    }
940}
941
942/// Turns the mic uplink back into PCM: the client always sends the mic as Opus, so
943/// every inbound packet must be decoded before it can be queued for the virtual sink. It
944/// decodes one packet to interleaved S16LE PCM, reusing a scratch buffer across calls to
945/// stay off the per-decode allocation path. Lives behind a `Mutex` on `PbShared` and is
946/// driven from `write` / `write_red`.
947struct OpusPlaybackDecoder {
948    dec: opus::Decoder,
949    channels: usize,
950    pcm: Vec<i16>,
951    /// Inbound scratch for a buffer-protocol payload, copied here under the GIL so the
952    /// off-GIL decode never reads a buffer another Python thread could be mutating.
953    packet: Vec<u8>,
954    /// RFC 2198 RED recovery cursor: the timestamp of the last frame decoded, so a
955    /// redundant copy of a dropped frame is decoded exactly once, in order. `None` until
956    /// the first RED frame arrives.
957    last_ts: Option<i64>,
958}
959
960impl OpusPlaybackDecoder {
961    /// Create a mono/stereo Opus decoder for the mic uplink; `None` if creation
962    /// fails. `channels <= 1` decodes as mono, otherwise stereo.
963    fn new(sample_rate: u32, channels: i32) -> Option<Self> {
964        let ch = if channels <= 1 { Channels::Mono } else { Channels::Stereo };
965        let dec = opus::Decoder::new(sample_rate, ch).ok()?;
966        Some(OpusPlaybackDecoder {
967            dec,
968            channels: channels.max(1) as usize,
969            pcm: Vec::new(),
970            packet: Vec::new(),
971            last_ts: None,
972        })
973    }
974
975    /// Decode one Opus packet and return the interleaved S16LE PCM as bytes, or `None`
976    /// for an empty or undecodable packet.
977    ///
978    /// The scratch `pcm` buffer is grown once to `5760 * channels` — an Opus packet decodes
979    /// to at most 120 ms, which is 5760 samples per channel at 48 kHz — then reused across
980    /// calls, and the result is a view straight over it, so a decode never allocates. The
981    /// view borrows the decoder, so callers must queue it before decoding the next packet.
982    /// Reinterpreting the samples as S16LE bytes assumes a little-endian host, exactly as
983    /// the capture side does when it fills `accum` from PulseAudio fragments.
984    fn decode_to_pcm(&mut self, packet: &[u8]) -> Option<&[u8]> {
985        if packet.is_empty() {
986            return None;
987        }
988        let cap = 5760 * self.channels;
989        if self.pcm.len() < cap {
990            self.pcm.resize(cap, 0);
991        }
992        let samples = self.dec.decode(packet, &mut self.pcm[..cap], false).ok()?;
993        let n = samples * self.channels;
994        Some(bytemuck::cast_slice(&self.pcm[..n]))
995    }
996
997    /// Reconstruct the mic uplink across packet loss: recover any frames the sender
998    /// dropped from the redundant copies RED carries, and decode each new frame exactly once
999    /// into `queue`. This is what lets a lossy UDP/WebRTC uplink play through gaps without ever
1000    /// waiting for a retransmit. It all runs off the GIL — the work is pure byte-slicing plus
1001    /// Opus decode with no Python state, so releasing the GIL keeps the mic path from
1002    /// serializing behind the rest of the interpreter.
1003    ///
1004    /// 1. **Parse the block headers**: walk the redundant headers (F bit set) collecting each
1005    ///    block's 14-bit timestamp offset and 10-bit length, then consume the 1-byte primary
1006    ///    header (F bit clear). A truncated payload, or more redundancy than `RED_MAX_DISTANCE`,
1007    ///    bails out.
1008    /// 2. **Resolve block boundaries**: turn the headers into `(ts, start, len)` triples,
1009    ///    oldest-first, where each redundant `ts` is `primary_ts - offset` and the primary is
1010    ///    whatever bytes remain.
1011    /// 3. **Anchor the first packet**: with no prior `last_ts`, decode only the primary and set
1012    ///    `last_ts` to it — its trailing redundancy describes frames never played, so it is not
1013    ///    replayed.
1014    /// 4. **Recover and advance**: otherwise decode every block whose `ts` is strictly newer
1015    ///    than `last_ts` (in oldest-first order, so a gap left by a dropped packet is filled
1016    ///    before the primary), pushing PCM to `queue` and advancing `last_ts`. Blocks at or
1017    ///    below `last_ts` are already-played duplicates and are skipped — the timestamp dedup
1018    ///    that makes redundancy free of double-decoding under no loss.
1019    fn decode_red_into_queue(&mut self, payload: &[u8], primary_ts: i64, queue: &PlayQueue) {
1020        let n = payload.len();
1021        let mut i = 0usize;
1022        let mut offs = [0i64; RED_MAX_DISTANCE as usize];
1023        let mut lens = [0usize; RED_MAX_DISTANCE as usize];
1024        let mut nh = 0usize;
1025        while i < n && (payload[i] & 0x80) != 0 {
1026            if i + 4 > n || nh >= RED_MAX_DISTANCE as usize {
1027                return;
1028            }
1029            let field = ((payload[i + 1] as u32) << 16)
1030                | ((payload[i + 2] as u32) << 8)
1031                | (payload[i + 3] as u32);
1032            offs[nh] = ((field >> 10) & 0x3FFF) as i64;
1033            lens[nh] = (field & 0x3FF) as usize;
1034            nh += 1;
1035            i += 4;
1036        }
1037        if i >= n {
1038            return;
1039        }
1040        i += 1;
1041
1042        let mut frames = [(0i64, 0usize, 0usize); RED_MAX_DISTANCE as usize + 1];
1043        let mut nf = 0usize;
1044        for k in 0..nh {
1045            if i + lens[k] > n {
1046                return;
1047            }
1048            if lens[k] > 0 {
1049                frames[nf] = (primary_ts - offs[k], i, lens[k]);
1050                nf += 1;
1051            }
1052            i += lens[k];
1053        }
1054        frames[nf] = (primary_ts, i, n - i);
1055        nf += 1;
1056
1057        if self.last_ts.is_none() {
1058            let (ts, start, len) = frames[nf - 1];
1059            if len > 0
1060                && let Some(pcm) = self.decode_to_pcm(&payload[start..start + len]) {
1061                    queue.push(pcm);
1062                }
1063            self.last_ts = Some(ts);
1064            return;
1065        }
1066        let mut last = self.last_ts.unwrap();
1067        for &(ts, start, len) in frames.iter().take(nf) {
1068            // RTP timestamps are 32-bit and wrap; compare serial-number style so a
1069            // wraparound isn't mistaken for "already played" (ts > last would fail
1070            // for every frame after the rollover).
1071            let newer = ((ts.wrapping_sub(last)) as i32) > 0;
1072            if len > 0 && newer {
1073                if let Some(pcm) = self.decode_to_pcm(&payload[start..start + len]) {
1074                    queue.push(pcm);
1075                }
1076                last = ts;
1077            }
1078        }
1079        self.last_ts = Some(last);
1080    }
1081}
1082
1083/// Per-`AudioPlayback` shared handle — the mirror of `Shared` for the playback path.
1084///
1085/// Reuses the capture lifecycle core on `Inner` (the `stop_state` protocol, the start
1086/// handshake, and the worker-tid re-entrancy guard); the Opus/silence settings mirrors on
1087/// `Inner` are unused here. Adds the bounded PCM `queue` and the always-Opus mic decoder.
1088struct PbShared {
1089    inner: Arc<Inner>,
1090    thread: Mutex<Option<JoinHandle<()>>>,
1091    queue: Arc<PlayQueue>,
1092    /// The mic uplink is always Opus; `write` / `write_red` decode each packet through this
1093    /// before enqueuing PCM. Set at `start()`; `None` only before a successful start.
1094    opus_dec: Mutex<Option<OpusPlaybackDecoder>>,
1095}
1096
1097/// Process-wide registry of live playbacks, swept by the same atexit sweep as
1098/// captures. Holds `Weak` references so it keeps nothing alive on its own.
1099static PLAYBACK_REGISTRY: OnceLock<Mutex<Vec<Weak<PbShared>>>> = OnceLock::new();
1100/// Lazily initialize and return the playback registry.
1101fn playback_registry() -> &'static Mutex<Vec<Weak<PbShared>>> {
1102    PLAYBACK_REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
1103}
1104
1105/// Run one bounded iteration of a PulseAudio standard mainloop:
1106/// `prepare(timeout_us)` → `poll` → `dispatch`. Returns `false` if any stage errors.
1107///
1108/// The `timeout_us` bound is what makes a pending stop observable within ~20 ms even when
1109/// the audio source delivers nothing, since every loop that calls this re-checks
1110/// `stop_pending()` between pumps.
1111fn pump(ml: &mut pulse::mainloop::standard::Mainloop, timeout_us: u64) -> bool {
1112    if ml.prepare(Some(MicroSeconds(timeout_us))).is_err() {
1113        return false;
1114    }
1115    if ml.poll().is_err() {
1116        return false;
1117    }
1118    if ml.dispatch().is_err() {
1119        return false;
1120    }
1121    true
1122}
1123
1124/// Chromium's multistream-Opus surround layout for a channel count, as
1125/// `(streams, coupled, mapping)`; `None` for anything but 5.1 (6) or 7.1 (8).
1126///
1127/// The same tables are advertised in the WebRTC SDP (`multiopus`), so the browser's decoder
1128/// inverts exactly the stream/coupling/mapping this encoder applies.
1129fn multiopus_layout(channels: i32) -> Option<(i32, i32, &'static [u8])> {
1130    match channels {
1131        6 => Some((4, 2, &[0, 4, 1, 2, 3, 5])),
1132        8 => Some((5, 3, &[0, 6, 1, 2, 3, 4, 5, 7])),
1133        _ => None,
1134    }
1135}
1136
1137/// One encode surface over both Opus APIs: the `opus` crate for mono/stereo, and
1138/// the raw multistream C API for 6/8-channel surround.
1139enum PcmEncoder {
1140    Stereo(opus::Encoder),
1141    Multi(MultiOpus),
1142}
1143
1144/// Owning wrapper over a raw `OpusMSEncoder` (the surround multistream encoder).
1145///
1146/// The raw pointer is only ever touched from the capture thread that owns the enclosing
1147/// `RunState`, which is what makes the `unsafe impl Send` below sound; `Drop` destroys the
1148/// C encoder.
1149struct MultiOpus {
1150    st: *mut audiopus_sys::OpusMSEncoder,
1151}
1152
1153unsafe impl Send for MultiOpus {}
1154
1155impl Drop for MultiOpus {
1156    fn drop(&mut self) {
1157        unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st) }
1158    }
1159}
1160
1161impl PcmEncoder {
1162    /// Build the Opus encoder for a channel count, selecting the API by width.
1163    ///
1164    /// - **Mono/stereo** (`channels <= 2`): the safe `opus` crate encoder in `LowDelay`
1165    ///   application mode; a failure to apply the initial bitrate or VBR mode is logged but
1166    ///   not fatal.
1167    /// - **Surround** (6/8): the raw multistream C encoder created from `multiopus_layout`
1168    ///   in `RESTRICTED_LOWDELAY`; an unsupported channel count is a hard error.
1169    ///
1170    /// Bitrate and VBR are applied at creation and can be retuned live via `set_bitrate`.
1171    fn new(sample_rate: u32, channels: i32, vbr: bool, bitrate: i32) -> Result<Self, String> {
1172        if channels <= 2 {
1173            let ch = if channels == 1 { Channels::Mono } else { Channels::Stereo };
1174            let mut enc = opus::Encoder::new(sample_rate, ch, Application::LowDelay)
1175                .map_err(|e| format!("opus_encoder_create() failed: {e:?}"))?;
1176            if let Err(e) = enc.set_bitrate(opus::Bitrate::Bits(bitrate)) {
1177                elog!("[pcmflux] WARNING: failed to apply initial bitrate: {e:?}");
1178            }
1179            if let Err(e) = enc.set_vbr(vbr) {
1180                elog!("[pcmflux] WARNING: failed to apply VBR mode: {e:?}");
1181            }
1182            return Ok(PcmEncoder::Stereo(enc));
1183        }
1184        let (streams, coupled, mapping) = multiopus_layout(channels)
1185            .ok_or_else(|| format!("unsupported surround channel count {channels}"))?;
1186        unsafe {
1187            let mut err: i32 = 0;
1188            let st = audiopus_sys::opus_multistream_encoder_create(
1189                sample_rate as i32,
1190                channels,
1191                streams,
1192                coupled,
1193                mapping.as_ptr(),
1194                audiopus_sys::OPUS_APPLICATION_RESTRICTED_LOWDELAY,
1195                &mut err,
1196            );
1197            if st.is_null() || err != 0 {
1198                return Err(format!("opus_multistream_encoder_create() failed: {err}"));
1199            }
1200            if audiopus_sys::opus_multistream_encoder_ctl(
1201                st,
1202                audiopus_sys::OPUS_SET_BITRATE_REQUEST,
1203                bitrate,
1204            ) != 0
1205            {
1206                elog!("[pcmflux] WARNING: failed to apply initial surround bitrate");
1207            }
1208            if audiopus_sys::opus_multistream_encoder_ctl(
1209                st,
1210                audiopus_sys::OPUS_SET_VBR_REQUEST,
1211                vbr as i32,
1212            ) != 0
1213            {
1214                elog!("[pcmflux] WARNING: failed to apply surround VBR mode");
1215            }
1216            Ok(PcmEncoder::Multi(MultiOpus { st }))
1217        }
1218    }
1219
1220    /// Encode one interleaved-PCM frame into `out`, returning the packet byte length.
1221    ///
1222    /// Dispatches to the `opus` crate (mono/stereo) or the raw multistream encode (surround,
1223    /// which needs the explicit `frame_size_per_channel`). Either error surfaces as a `String`.
1224    fn encode(
1225        &mut self,
1226        pcm: &[i16],
1227        frame_size_per_channel: usize,
1228        out: &mut [u8],
1229    ) -> Result<usize, String> {
1230        match self {
1231            PcmEncoder::Stereo(enc) => enc
1232                .encode(pcm, out)
1233                .map_err(|e| format!("opus_encode() failed: {e:?}")),
1234            PcmEncoder::Multi(ms) => unsafe {
1235                let n = audiopus_sys::opus_multistream_encode(
1236                    ms.st,
1237                    pcm.as_ptr(),
1238                    frame_size_per_channel as i32,
1239                    out.as_mut_ptr(),
1240                    out.len() as i32,
1241                );
1242                if n < 0 {
1243                    Err(format!("opus_multistream_encode() failed: {n}"))
1244                } else {
1245                    Ok(n as usize)
1246                }
1247            },
1248        }
1249    }
1250
1251    /// Retune the encoder's target bitrate live (bits/s), for either API.
1252    fn set_bitrate(&mut self, bits: i32) -> Result<(), String> {
1253        match self {
1254            PcmEncoder::Stereo(enc) => enc
1255                .set_bitrate(opus::Bitrate::Bits(bits))
1256                .map_err(|e| format!("{e:?}")),
1257            PcmEncoder::Multi(ms) => unsafe {
1258                let ret = audiopus_sys::opus_multistream_encoder_ctl(
1259                    ms.st,
1260                    audiopus_sys::OPUS_SET_BITRATE_REQUEST,
1261                    bits,
1262                );
1263                if ret != 0 {
1264                    Err(format!("ctl error {ret}"))
1265                } else {
1266                    Ok(())
1267                }
1268            },
1269        }
1270    }
1271}
1272
1273/// Backing store for the delivery ring: `Some(queue)` while open, `None` once closed
1274/// so `pop` wakes and returns `None` for a clean shutdown.
1275type FrameQueue = Option<VecDeque<(Vec<u8>, u64)>>;
1276
1277/// Bounded, drop-oldest hand-off from the capture thread to the Python delivery
1278/// thread, so a slow or GIL-blocked callback can never stall the PulseAudio pump.
1279///
1280/// The capture thread `push`es encoded `(frame, pts)` pairs; the delivery thread blocks in
1281/// `pop`. Stale audio is worthless, so overflow past `capacity` (a few frames of slack)
1282/// discards the OLDEST frame and bumps `dropped`. `close` empties the queue to `None` and
1283/// wakes the consumer so it exits.
1284struct DeliveryRing {
1285    q: Mutex<FrameQueue>,
1286    cv: Condvar,
1287    dropped: AtomicU64,
1288    capacity: usize,
1289}
1290
1291impl DeliveryRing {
1292    /// Create an open ring pre-sized to `capacity` frames.
1293    fn new(capacity: usize) -> Self {
1294        Self {
1295            q: Mutex::new(Some(VecDeque::with_capacity(capacity))),
1296            cv: Condvar::new(),
1297            dropped: AtomicU64::new(0),
1298            capacity,
1299        }
1300    }
1301
1302    /// Enqueue one encoded frame, dropping the oldest (and bumping `dropped`) if the
1303    /// ring is at capacity, then wake the consumer. A no-op once closed.
1304    fn push(&self, data: Vec<u8>, pts: u64) {
1305        let mut g = self.q.lock().unwrap_or_else(|e| e.into_inner());
1306        if let Some(q) = g.as_mut() {
1307            if q.len() >= self.capacity {
1308                q.pop_front();
1309                self.dropped.fetch_add(1, Ordering::Relaxed);
1310            }
1311            q.push_back((data, pts));
1312            self.cv.notify_one();
1313        }
1314    }
1315
1316    /// Block until a frame is available and return it, or return `None` once the ring
1317    /// is closed and drained — the delivery thread's loop condition.
1318    fn pop(&self) -> Option<(Vec<u8>, u64)> {
1319        let mut g = self.q.lock().unwrap_or_else(|e| e.into_inner());
1320        loop {
1321            {
1322                let q = g.as_mut()?;
1323                if let Some(item) = q.pop_front() {
1324                    return Some(item);
1325                }
1326            }
1327            g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
1328        }
1329    }
1330
1331    /// Close the ring: drop any queued frames and wake every waiter so `pop` returns
1332    /// `None`. Called during capture teardown to join the delivery thread.
1333    fn close(&self) {
1334        *self.q.lock().unwrap_or_else(|e| e.into_inner()) = None;
1335        self.cv.notify_all();
1336    }
1337}
1338
1339/// Owns the delivery thread for the lifetime of one capture run and tears it down on
1340/// `Drop`, so teardown also happens when the capture thread UNWINDS.
1341///
1342/// Closing the ring is the delivery thread's only wake-up: skip it and the thread parks
1343/// in `pop()` forever, pinning the Python callback and leaving `deliver_tid` set to a tid
1344/// the OS may hand to an unrelated thread (whose `stop_capture` would then be mistaken
1345/// for a re-entrant self-stop and silently do nothing).
1346struct DeliveryThread<'a> {
1347    ring: Arc<DeliveryRing>,
1348    inner: &'a Inner,
1349    join: Option<JoinHandle<()>>,
1350}
1351
1352impl Drop for DeliveryThread<'_> {
1353    fn drop(&mut self) {
1354        self.ring.close();
1355        if let Some(j) = self.join.take() {
1356            let _ = j.join();
1357        }
1358        self.inner.deliver_tid.store(0, Ordering::Release);
1359    }
1360}
1361
1362/// Recycles outgoing frame buffers from dropped `AudioFrame`s back to the capture
1363/// thread, so the steady-state emit path allocates nothing.
1364///
1365/// INVARIANT: every buffer is born as `vec![0u8; buf_size]`, so bytes `[0, buf_size)` stay
1366/// initialized for the allocation's whole lifetime — `truncate` only shortens `len`, never
1367/// de-initializes memory. That is what makes `restore`'s `set_len` back to `buf_size` sound.
1368struct BufferPool {
1369    bufs: Mutex<Vec<Vec<u8>>>,
1370    buf_size: usize,
1371}
1372
1373impl BufferPool {
1374    /// Cap on pooled buffers. Outstanding frames rarely exceed the delivery-ring
1375    /// capacity plus a few Python-held references; anything past this goes back to the
1376    /// allocator rather than growing the pool unboundedly.
1377    const MAX_POOLED: usize = 16;
1378
1379    /// Create an empty pool that hands out (and accepts) `buf_size`-byte buffers.
1380    fn new(buf_size: usize) -> Self {
1381        Self { bufs: Mutex::new(Vec::new()), buf_size }
1382    }
1383
1384    /// Take a fully initialized buffer of exactly `buf_size` length, recycling a
1385    /// pooled one when available. The runtime path takes through `PoolTaker`; this direct,
1386    /// locking form serves the unit tests.
1387    #[cfg(test)]
1388    fn take(&self) -> Vec<u8> {
1389        let recycled = self.bufs.lock().unwrap_or_else(|e| e.into_inner()).pop();
1390        match recycled {
1391            Some(v) => self.restore(v),
1392            None => vec![0u8; self.buf_size],
1393        }
1394    }
1395
1396    /// Restore a recycled buffer to full `buf_size` length via `set_len`.
1397    ///
1398    /// Sound per the pool invariant: the bytes were written at allocation and truncation
1399    /// does not de-initialize them, so extending `len` back to `buf_size` never exposes
1400    /// uninitialized memory.
1401    fn restore(&self, mut v: Vec<u8>) -> Vec<u8> {
1402        debug_assert!(v.capacity() >= self.buf_size);
1403        unsafe { v.set_len(self.buf_size) };
1404        v
1405    }
1406
1407    /// Return a buffer to the pool, unless it is undersized or the pool is already at
1408    /// `MAX_POOLED` (in which case it is dropped to the allocator).
1409    fn put(&self, v: Vec<u8>) {
1410        if v.capacity() < self.buf_size {
1411            return;
1412        }
1413        let mut g = self.bufs.lock().unwrap_or_else(|e| e.into_inner());
1414        if g.len() < Self::MAX_POOLED {
1415            g.push(v);
1416        }
1417    }
1418
1419    /// Move every pooled buffer into `into` under a single lock — the batched refill
1420    /// for the sole-consumer `PoolTaker`, whose empty local stash is `into`.
1421    fn drain_into(&self, into: &mut Vec<Vec<u8>>) {
1422        let mut g = self.bufs.lock().unwrap_or_else(|e| e.into_inner());
1423        std::mem::swap(&mut *g, into);
1424    }
1425}
1426
1427/// Sole-consumer view over the shared `BufferPool` for the capture thread.
1428///
1429/// Refills are batched into a `local` stash, so the per-frame `take` is lock-free in the
1430/// steady state, while returns from the delivery/Python side (`AudioFrame` drops) still go
1431/// back through the shared pool. Only the capture thread owns a `PoolTaker`.
1432struct PoolTaker {
1433    pool: Arc<BufferPool>,
1434    local: Vec<Vec<u8>>,
1435}
1436
1437impl PoolTaker {
1438    /// Wrap a shared pool with an empty local stash.
1439    fn new(pool: Arc<BufferPool>) -> Self {
1440        Self { pool, local: Vec::new() }
1441    }
1442
1443    /// Take one `buf_size` buffer: pop from the local stash, batch-refilling it from
1444    /// the shared pool (one lock) only when empty, and allocating fresh when both are empty.
1445    fn take(&mut self) -> Vec<u8> {
1446        if self.local.is_empty() {
1447            self.pool.drain_into(&mut self.local);
1448        }
1449        match self.local.pop() {
1450            Some(v) => self.pool.restore(v),
1451            None => vec![0u8; self.pool.buf_size],
1452        }
1453    }
1454
1455    /// Return a buffer on an error path without locking — it stays in the local stash.
1456    fn put(&mut self, v: Vec<u8>) {
1457        if v.capacity() >= self.pool.buf_size && self.local.len() < BufferPool::MAX_POOLED {
1458            self.local.push(v);
1459        }
1460    }
1461}
1462
1463/// Per-run encode/deliver state, living on the capture thread's stack for the
1464/// lifetime of one capture. Holds the encoder, the frame-reassembly buffers, the outgoing
1465/// buffer recycler, the RED redundancy history, and the running debug-log counters.
1466struct RunState<'a> {
1467    inner: &'a Inner,
1468    ring: &'a DeliveryRing,
1469    encoder: PcmEncoder,
1470    frame_size_per_channel: usize,
1471    channels: usize,
1472    /// Reassembly buffer for exactly one Opus frame (`i16` samples, filled byte-wise from
1473    /// the incoming PulseAudio fragments).
1474    accum: Vec<i16>,
1475    /// A zeroed reference of the same length as `accum`; comparing `accum == silence_ref`
1476    /// lowers to a single vectorized memcmp for the silence gate, versus a scalar per-sample
1477    /// scan.
1478    silence_ref: Vec<i16>,
1479    pcm_fill_bytes: usize,
1480    /// Outgoing-buffer recycler, shared with delivered `AudioFrame`s whose drop refills it.
1481    pool: PoolTaker,
1482    /// RFC 2198 redundancy history: the last `red_distance` emitted `(opus, pts)` frames,
1483    /// oldest-first. Per-run — reset on start, and the frame size is fixed for a run.
1484    red_history: VecDeque<(Vec<u8>, u64)>,
1485    /// Retired `red_history` buffers, reused for the next entry so the steady state (a
1486    /// silence gap included) allocates nothing. Bounded by `red_distance`: every buffer
1487    /// is either in the history or here.
1488    red_spare: Vec<Vec<u8>>,
1489    red_distance: usize,
1490    total_samples_processed: u64,
1491    first_sound_detected: bool,
1492    current_applied_bitrate: i32,
1493    chunks_read: u64,
1494    chunks_silent: u64,
1495    chunks_encoded: u64,
1496    bytes_encoded: u64,
1497}
1498
1499impl<'a> RunState<'a> {
1500    /// Feed one PulseAudio PCM fragment into the reassembly buffer, emitting a frame
1501    /// each time `accum` fills to exactly one Opus frame.
1502    ///
1503    /// Fragments arrive at arbitrary byte boundaries, so this copies from `src` into `accum`
1504    /// at `pcm_fill_bytes`, calling `emit_frame` (and resetting the fill cursor) whenever a
1505    /// full `frame_size_per_channel * channels * 2`-byte chunk accumulates, and loops until
1506    /// `src` is drained. Any partial remainder is carried into the next fragment.
1507    fn feed(&mut self, mut src: &[u8]) {
1508        let chunk_bytes = self.frame_size_per_channel * self.channels * 2;
1509        while !src.is_empty() {
1510            let want = chunk_bytes - self.pcm_fill_bytes;
1511            let take = want.min(src.len());
1512            {
1513                let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut self.accum);
1514                dst[self.pcm_fill_bytes..self.pcm_fill_bytes + take]
1515                    .copy_from_slice(&src[..take]);
1516            }
1517            self.pcm_fill_bytes += take;
1518            src = &src[take..];
1519            if self.pcm_fill_bytes == chunk_bytes {
1520                self.emit_frame();
1521                self.pcm_fill_bytes = 0;
1522            }
1523        }
1524    }
1525
1526    /// Encode one reassembled frame and hand it to the delivery thread. The heart of
1527    /// the capture encode path.
1528    ///
1529    /// 1. **Dynamic bitrate**: re-reads the `opus_bitrate` mirror and reconfigures the encoder
1530    ///    only when it changed, so a live bitrate update costs nothing on unchanged frames.
1531    /// 2. **Timestamp**: `pts` is the running 48 kHz-domain sample count
1532    ///    (`total_samples_processed`) *before* this frame, then advanced by
1533    ///    `frame_size_per_channel` — a monotonic per-frame timestamp used for RED offsets and
1534    ///    client-side ordering.
1535    /// 3. **Silence gate**: when enabled, a frame equal to the zeroed `silence_ref` is counted
1536    ///    and dropped (nothing is sent), so pure silence costs no bandwidth. The first
1537    ///    non-silent frame logs once.
1538    /// 4. **Encode in place**: `write_ws_prefix_into` writes the RFC 2198 RED framing prefix
1539    ///    (which depends only on `pts` + history) into a pooled buffer, and the Opus packet is
1540    ///    encoded DIRECTLY after it — no assembly copy, and the buffer recycles through the
1541    ///    pool, so the steady state allocates nothing. An encode error or a zero-length packet
1542    ///    returns the buffer to the pool and drops the frame.
1543    /// 5. **Retain redundancy**: with `red_distance > 0`, the just-encoded primary is copied
1544    ///    onto `red_history` (bounded, oldest-first) to serve as a future redundant copy,
1545    ///    into the buffer the retiring entry hands back.
1546    /// 6. **Hand off**: the truncated buffer is pushed to the `DeliveryRing`; the capture
1547    ///    thread itself never touches the GIL.
1548    fn emit_frame(&mut self) {
1549        self.chunks_read += 1;
1550
1551        let requested = self.inner.opus_bitrate.load(Ordering::Relaxed);
1552        if requested != self.current_applied_bitrate {
1553            match self.encoder.set_bitrate(requested) {
1554                Ok(()) => {
1555                    plog!(
1556                        "[pcmflux] Dynamic Bitrate Update: {} -> {} kbps",
1557                        self.current_applied_bitrate / 1000,
1558                        requested / 1000
1559                    );
1560                    self.current_applied_bitrate = requested;
1561                }
1562                Err(e) => {
1563                    // current_applied_bitrate stays put, so the next frame retries the
1564                    // rejected value instead of latching it as if it had been applied.
1565                    elog!("[pcmflux] Failed to update bitrate ({requested}): {e:?}");
1566                }
1567            }
1568        }
1569
1570        let pts = self.total_samples_processed;
1571        self.total_samples_processed += self.frame_size_per_channel as u64;
1572
1573        if self.inner.use_silence_gate.load(Ordering::Relaxed)
1574            && self.accum == self.silence_ref
1575        {
1576            self.chunks_silent += 1;
1577            // Flush the RED backlog: if these pre-silence frames were kept, the first
1578            // packet after a long quiet stretch would ship minutes-old audio as
1579            // "redundant" data, and a receiver could reconstruct it into the gap. The
1580            // emptied buffers are kept for reuse, so a silence gap costs no allocations.
1581            self.red_spare
1582                .extend(self.red_history.drain(..).map(|(v, _)| v));
1583            return;
1584        }
1585        if !self.first_sound_detected {
1586            plog!("[pcmflux] First non-silent audio chunk detected! Encoding...");
1587            self.first_sound_detected = true;
1588        }
1589
1590        let n = self.frame_size_per_channel * self.channels;
1591        let emit_header = self.inner.emit_audio_header.load(Ordering::Relaxed);
1592        let mut data = self.pool.take();
1593        let prefix = write_ws_prefix_into(
1594            &mut data,
1595            pts,
1596            &self.red_history,
1597            self.red_distance,
1598            emit_header,
1599        );
1600        let encoded = match self.encoder.encode(
1601            &self.accum[..n],
1602            self.frame_size_per_channel,
1603            &mut data[prefix..],
1604        ) {
1605            Ok(b) => b,
1606            Err(e) => {
1607                elog!("[pcmflux] ERROR: {e}");
1608                self.pool.put(data);
1609                return;
1610            }
1611        };
1612        if encoded == 0 {
1613            self.pool.put(data);
1614            return;
1615        }
1616        self.chunks_encoded += 1;
1617        self.bytes_encoded += encoded as u64;
1618        if self.red_distance > 0 {
1619            let mut slot = if self.red_history.len() >= self.red_distance {
1620                self.red_history.pop_front().map(|(v, _)| v).unwrap_or_default()
1621            } else {
1622                self.red_spare.pop().unwrap_or_default()
1623            };
1624            slot.clear();
1625            slot.extend_from_slice(&data[prefix..prefix + encoded]);
1626            self.red_history.push_back((slot, pts));
1627        }
1628        data.truncate(prefix + encoded);
1629
1630        self.ring.push(data, pts);
1631    }
1632}
1633
1634/// Own one whole capture run end to end on a dedicated thread — connect PulseAudio,
1635/// encode, and deliver — until stopped. It runs on its own thread because the PulseAudio
1636/// mainloop must be pumped continuously and independently of Python: sharing the caller's
1637/// thread would tie capture cadence to the GIL and let any Python stall starve the audio.
1638/// The body handed to `spawn_worker`.
1639///
1640/// Publishes `start_state` for the handshake (`RUNNING` on entering the hot loop, `FAILED`
1641/// with a `last_error` on any terminal error, `IDLE` on a clean stop) and returns when
1642/// `stop_state` leaves `STOP_NONE` or on a fatal error. The startup sequence, in order:
1643///
1644/// 1. **Seed the mirrors**: copies the settings snapshot (already validated by
1645///    `extract_settings`) into the `Inner` per-frame atomics.
1646/// 2. **Buffer attr / latency**: a configured `latency_ms` uses `ADJUST_LATENCY` with
1647///    `fragsize` set to that latency; otherwise `fragsize` is floored at ~20 ms, which yields
1648///    a prompt first frame and avoids PipeWire's ~2 s default fragment.
1649/// 3. **Connect + probe**: drives the context to `Ready` on the bounded pump (re-checking
1650///    `stop_pending` each turn), and up-front validates a NAMED device via an introspect
1651///    probe — an async `connect_record` would not fail synchronously on a bad name. The probe
1652///    closure is called from C inside mainloop dispatch, so its body is `catch_unwind`-guarded
1653///    to keep a panic from unwinding across the FFI boundary.
1654/// 4. **Encoder + record stream**: creates the `PcmEncoder` (mono/stereo or surround) and
1655///    drives the record stream to `Ready`.
1656/// 5. **Delivery thread**: spawns the delivery thread that pops from the `DeliveryRing` and
1657///    runs the Python callback there, so GIL stalls cannot back up the PA pump; a callback
1658///    error is reported as an unraisable exception and never propagates into the loop. The
1659///    buffer pool is sized to the worst-case body — RED prefix plus a max Opus packet, scaled
1660///    by stream count for surround (one self-delimited packet per stream).
1661/// 6. **Hot loop**: `pump`s on the ~20 ms bound, then drains every buffered fragment via
1662///    peek/discard (a `Hole` is an xrun — the read index is just advanced), feeding each into
1663///    `RunState`. A stop is observed within the pump bound even when the source is wedged. On
1664///    exit it disconnects the stream, drops the encoder, closes and joins the delivery ring,
1665///    and reports any dropped stale frames.
1666///
1667/// One PulseAudio session for capture: mainloop, context, and the record stream,
1668/// all recreated together on reconnect.
1669/// Drop order matters (declaration order): the stream must die first, then its
1670/// owning context, then the mainloop both pulse threads pump — the reverse of
1671/// the build. A wrong order is a use-after-free on the libpulse side.
1672struct PaCaptureSession {
1673    stream: Stream,
1674    /// Must outlive the stream (the connection owns it); never read after open.
1675    #[allow(dead_code)]
1676    context: Context,
1677    mainloop: Mainloop,
1678}
1679
1680/// Why a session failed to open: drives the retry policy of the caller.
1681enum SessionOpenError {
1682    /// The named source is absent at startup (misconfiguration-ish); the caller gives it
1683    /// only its short bring-up window rather than the full startup retry budget.
1684    DeviceNotFound(String),
1685    /// Server down, busy, or a bring-up race: retryable.
1686    Transient(String),
1687    /// stop_pending observed while opening; caller must shut down cleanly.
1688    Aborted,
1689}
1690
1691/// Open a capture session: mainloop + context + record stream driven to `Ready` on the
1692/// bounded pump, honoring `stop_pending` at every turn. A NAMED device is validated by
1693/// an introspect probe on every call (an async connect_record would not fail
1694/// synchronously on a bad name, and on reconnect this is also what notices the device
1695/// reappearing after an outage). `device_was_present` distinguishes initial bring-up
1696/// from reconnect: a named device missing at startup is probably a misconfiguration, so
1697/// it is reported as `DeviceNotFound` and the caller spends only a short window on it;
1698/// the same device vanishing mid-run (PulseAudio/PipeWire restart kills every source) is
1699/// transient and must be retried or audio never comes back.
1700fn pa_capture_session_open(
1701    inner: &Inner,
1702    spec: &Spec,
1703    device: Option<&str>,
1704    attr: &BufferAttr,
1705    adjust_latency: bool,
1706    device_was_present: bool,
1707) -> Result<PaCaptureSession, SessionOpenError> {
1708    let tr = |e: &str| SessionOpenError::Transient(e.to_string());
1709    let mut mainloop = match Mainloop::new() {
1710        Some(m) => m,
1711        None => return Err(tr("pa_mainloop_new() failed")),
1712    };
1713    let mut context = match Context::new(&mainloop, "pcmflux") {
1714        Some(c) => c,
1715        None => return Err(tr("pa_context_new() failed")),
1716    };
1717    if context.connect(None, CtxFlags::NOFLAGS, None).is_err() {
1718        return Err(tr("pa_context_connect() failed"));
1719    }
1720    loop {
1721        let st = context.get_state();
1722        if st == pulse::context::State::Ready {
1723            break;
1724        }
1725        if !st.is_good() {
1726            return Err(tr("PulseAudio context connection failed"));
1727        }
1728        if inner.stop_pending() {
1729            return Err(SessionOpenError::Aborted);
1730        }
1731        if !pump(&mut mainloop, PUMP_TIMEOUT_US) {
1732            return Err(tr("mainloop iterate failed during connect"));
1733        }
1734    }
1735
1736    if let Some(dev) = device {
1737        let probe = Arc::new(Mutex::new((false, false)));
1738        let p2 = probe.clone();
1739        let op = context.introspect().get_source_info_by_name(dev, move |res| {
1740            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1741                let mut g = p2.lock().unwrap();
1742                match res {
1743                    ListResult::Item(_) => g.0 = true,
1744                    ListResult::End | ListResult::Error => g.1 = true,
1745                }
1746            }));
1747        });
1748        loop {
1749            if probe.lock().unwrap().1 {
1750                break;
1751            }
1752            if inner.stop_pending() {
1753                drop(op);
1754                return Err(SessionOpenError::Aborted);
1755            }
1756            if !context.get_state().is_good() {
1757                drop(op);
1758                return Err(tr("context failed during source probe"));
1759            }
1760            if !pump(&mut mainloop, PUMP_TIMEOUT_US) {
1761                drop(op);
1762                return Err(tr("mainloop iterate failed during source probe"));
1763            }
1764        }
1765        drop(op);
1766        if !probe.lock().unwrap().0 {
1767            let msg = format!("PulseAudio source not found: '{dev}'");
1768            return Err(if device_was_present {
1769                // Mid-run: the server was just restarted; its sources are all
1770                // gone for now, not misconfigured.
1771                SessionOpenError::Transient(msg)
1772            } else {
1773                SessionOpenError::DeviceNotFound(msg)
1774            });
1775        }
1776    }
1777
1778    let mut stream = match Stream::new(&mut context, "Audio Capture", spec, None) {
1779        Some(s) => s,
1780        None => return Err(tr("pa_stream_new() failed")),
1781    };
1782    let flags = if adjust_latency {
1783        StreamFlags::ADJUST_LATENCY
1784    } else {
1785        StreamFlags::NOFLAGS
1786    };
1787    if stream.connect_record(device, Some(attr), flags).is_err() {
1788        return Err(tr("pa_stream_connect_record() failed"));
1789    }
1790    loop {
1791        let st = stream.get_state();
1792        if st == pulse::stream::State::Ready {
1793            break;
1794        }
1795        if !st.is_good() {
1796            return Err(SessionOpenError::Transient(format!(
1797                "PulseAudio record stream failed (device '{}')",
1798                device.unwrap_or("default")
1799            )));
1800        }
1801        if inner.stop_pending() {
1802            return Err(SessionOpenError::Aborted);
1803        }
1804        if !pump(&mut mainloop, PUMP_TIMEOUT_US) {
1805            return Err(tr("mainloop iterate failed during stream connect"));
1806        }
1807    }
1808    Ok(PaCaptureSession {
1809        stream,
1810        context,
1811        mainloop,
1812    })
1813}
1814
1815fn capture_run(inner: &Arc<Inner>, settings: &Settings, callback: &Py<PyAny>) {
1816    inner.opus_bitrate.store(settings.opus_bitrate, Ordering::Relaxed);
1817    inner.use_silence_gate.store(settings.use_silence_gate, Ordering::Relaxed);
1818    inner.debug_logging.store(settings.debug_logging, Ordering::Relaxed);
1819    inner.emit_audio_header.store(!settings.omit_audio_header, Ordering::Relaxed);
1820
1821    // Sample rate, channel count and frame duration were validated by `extract_settings`.
1822    let spec = Spec {
1823        format: Format::S16le,
1824        rate: settings.sample_rate,
1825        channels: settings.channels as u8,
1826    };
1827
1828    let mut attr = BufferAttr {
1829        maxlength: u32::MAX,
1830        tlength: u32::MAX,
1831        prebuf: u32::MAX,
1832        minreq: u32::MAX,
1833        fragsize: u32::MAX,
1834    };
1835    let adjust_latency = settings.latency_ms > 0;
1836    if adjust_latency {
1837        attr.fragsize =
1838            spec.usec_to_bytes(MicroSeconds(settings.latency_ms as u64 * 1000)) as u32;
1839    } else {
1840        attr.fragsize = spec.usec_to_bytes(MicroSeconds(20 * 1000)) as u32;
1841    }
1842
1843    let device = settings.device_name.as_deref();
1844
1845    let encoder = match PcmEncoder::new(
1846        settings.sample_rate,
1847        settings.channels,
1848        settings.use_vbr,
1849        settings.opus_bitrate,
1850    ) {
1851        Ok(e) => e,
1852        Err(e) => {
1853            inner.fail(e);
1854            return;
1855        }
1856    };
1857    plog!("[pcmflux] SUCCESS: Opus encoder created ({} ch).", settings.channels);
1858
1859    let frame_size_per_channel =
1860        (settings.sample_rate as f64 * settings.frame_duration_ms / 1000.0) as usize;
1861    let channels = settings.channels as usize;
1862
1863    let ring = Arc::new(DeliveryRing::new(8));
1864    // Surround encodes one self-delimited packet per multistream stream (4 for 5.1, 5 for
1865    // 7.1), so the worst-case body scales with the stream count of the actual layout.
1866    let max_pkt = multiopus_layout(settings.channels)
1867        .map_or(1, |(streams, _, _)| streams as usize)
1868        * MAX_OPUS_PACKET;
1869    let pool = Arc::new(BufferPool::new(RED_PREFIX_MAX + max_pkt));
1870    let deliver_ring = Arc::clone(&ring);
1871    let deliver_pool = Arc::clone(&pool);
1872    let deliver_inner = Arc::clone(inner);
1873    let deliver_cb: Py<PyAny> = Python::attach(|py| callback.clone_ref(py));
1874    let spawned = std::thread::Builder::new()
1875        .name("pcmflux-deliver".into())
1876        .spawn(move || {
1877            unsafe {
1878                let tid = libc::syscall(libc::SYS_gettid) as libc::id_t;
1879                let _ = libc::setpriority(libc::PRIO_PROCESS, tid, -10);
1880            }
1881            deliver_inner.deliver_tid.store(gettid(), Ordering::Release);
1882            while let Some((data, pts)) = deliver_ring.pop() {
1883                Python::attach(|py| {
1884                    let frame = match Py::new(
1885                        py,
1886                        AudioFrame { data, pts, pool: Some(Arc::clone(&deliver_pool)) },
1887                    ) {
1888                        Ok(f) => f,
1889                        Err(e) => {
1890                            elog!("[pcmflux] AudioFrame alloc failed: {e:?}");
1891                            return;
1892                        }
1893                    };
1894                    if let Err(e) = deliver_cb.call1(py, (frame,)) {
1895                        e.write_unraisable(py, Some(deliver_cb.bind(py)));
1896                    }
1897                });
1898            }
1899            deliver_inner.deliver_tid.store(0, Ordering::Release);
1900        });
1901    let delivery = match spawned {
1902        Ok(join) => DeliveryThread {
1903            ring: Arc::clone(&ring),
1904            inner,
1905            join: Some(join),
1906        },
1907        Err(e) => {
1908            inner.fail(format!("delivery thread spawn failed: {e}"));
1909            return;
1910        }
1911    };
1912
1913    let mut run = RunState {
1914        inner,
1915        ring: &ring,
1916        encoder,
1917        frame_size_per_channel,
1918        channels,
1919        accum: vec![0i16; frame_size_per_channel * channels],
1920        silence_ref: vec![0i16; frame_size_per_channel * channels],
1921        pcm_fill_bytes: 0,
1922        pool: PoolTaker::new(Arc::clone(&pool)),
1923        red_history: VecDeque::new(),
1924        red_spare: Vec::new(),
1925        red_distance: settings.red_distance.max(0) as usize,
1926        total_samples_processed: 0,
1927        first_sound_detected: false,
1928        current_applied_bitrate: settings.opus_bitrate,
1929        chunks_read: 0,
1930        chunks_silent: 0,
1931        chunks_encoded: 0,
1932        bytes_encoded: 0,
1933    };
1934
1935    let mut last_log = Instant::now();
1936
1937    // Session loop: the record stream CAN die mid-run (PulseAudio/PipeWire restart,
1938    // source unplugged) and a plain `break` there leaves audio dead until some
1939    // unrelated settings change restarts the capture. Reopen with backoff instead.
1940    // Three retry budgets, shortest first:
1941    //   - DEVICE_WAIT_TRIES: a start that finds the NAMED source missing. The sink whose
1942    //     monitor is being recorded may still be materializing (container bring-up, or a
1943    //     capture start that raced a server restart), but a misconfigured name must still
1944    //     surface quickly, so this window is only a few seconds.
1945    //   - START_TRIES: any other failure to bring the first session up.
1946    //   - RECONNECT_TRIES: a mid-run reconnect, which has to outlast a whole
1947    //     PulseAudio/PipeWire restart or audio never comes back.
1948    const DEVICE_WAIT_TRIES: u32 = 6;
1949    const START_TRIES: u32 = 12;
1950    const RECONNECT_TRIES: u32 = 40;
1951    let mut session: Option<PaCaptureSession> = None;
1952    let mut ever_connected = false;
1953    let mut tries: u32 = 0;
1954    let mut backoff_ms: u64 = 250;
1955    let mut terminal_error: Option<String> = None;
1956
1957    loop {
1958        if inner.stop_pending() {
1959            break;
1960        }
1961        if session.is_none() {
1962            let opened =
1963                pa_capture_session_open(inner, &spec, device, &attr, adjust_latency, ever_connected);
1964            let cap = match (&opened, ever_connected) {
1965                (Err(SessionOpenError::DeviceNotFound(_)), _) => DEVICE_WAIT_TRIES,
1966                (_, true) => RECONNECT_TRIES,
1967                (_, false) => START_TRIES,
1968            };
1969            match opened {
1970                Ok(s) => {
1971                    session = Some(s);
1972                    tries = 0;
1973                    backoff_ms = 250;
1974                    if !ever_connected {
1975                        ever_connected = true;
1976                        inner.started_ok.store(true, Ordering::Release);
1977                        inner.start_state.store(ST_RUNNING, Ordering::Release);
1978                        plog!(
1979                            "[pcmflux] Capture loop started. Device: {}, Rate: {}, Channels: {}, Bitrate: {} kbps, \
1980                             VBR: {}, Silence Gate: {}",
1981                            device.unwrap_or("system_default"),
1982                            settings.sample_rate,
1983                            settings.channels,
1984                            settings.opus_bitrate / 1000,
1985                            if settings.use_vbr {
1986                                "On"
1987                            } else {
1988                                "Off"
1989                            },
1990                            if settings.use_silence_gate { "On" } else { "Off" }
1991                        );
1992                    } else {
1993                        plog!("[pcmflux] audio capture reconnected; resuming.");
1994                    }
1995                }
1996                Err(SessionOpenError::Aborted) => break,
1997                Err(SessionOpenError::DeviceNotFound(e)) | Err(SessionOpenError::Transient(e)) => {
1998                    tries += 1;
1999                    if tries >= cap {
2000                        terminal_error = Some(e);
2001                        break;
2002                    }
2003                    elog!("[pcmflux] audio capture open failed ({e}); retry {tries}/{cap} in {backoff_ms}ms");
2004                    let mut slept = 0u64;
2005                    while slept < backoff_ms && !inner.stop_pending() {
2006                        std::thread::sleep(Duration::from_millis(50));
2007                        slept += 50;
2008                    }
2009                    backoff_ms = (backoff_ms * 2).min(5000);
2010                    continue;
2011                }
2012            }
2013        }
2014        let s = session.as_mut().expect("session checked above");
2015        if !pump(&mut s.mainloop, PUMP_TIMEOUT_US) {
2016            elog!("[pcmflux] ERROR: mainloop iterate failed; reopening the session.");
2017            session = None;
2018            // Drop the partially reassembled frame: the next fragments come from after
2019            // the outage, and stitching them onto pre-outage PCM would emit one frame
2020            // with a discontinuity in the middle, charged to the wrong pts.
2021            run.pcm_fill_bytes = 0;
2022            continue;
2023        }
2024        let sstate = s.stream.get_state();
2025        if sstate != pulse::stream::State::Ready {
2026            elog!("[pcmflux] record stream lost; reopening the session.");
2027            session = None;
2028            run.pcm_fill_bytes = 0;
2029            continue;
2030        }
2031
2032        loop {
2033            let mut discard = false;
2034            let mut done = false;
2035            match s.stream.peek() {
2036                Ok(PeekResult::Empty) => done = true,
2037                Ok(PeekResult::Hole(_)) => discard = true,
2038                Ok(PeekResult::Data(buf)) => {
2039                    run.feed(buf);
2040                    discard = true;
2041                }
2042                Err(_) => {
2043                    elog!("[pcmflux] ERROR: pa_stream_peek() failed.");
2044                    done = true;
2045                }
2046            }
2047            if discard {
2048                let _ = s.stream.discard();
2049            }
2050            if done {
2051                break;
2052            }
2053        }
2054
2055        if run.inner.debug_logging.load(Ordering::Relaxed) {
2056            let elapsed = last_log.elapsed();
2057            if elapsed >= Duration::from_secs(2) {
2058                let secs = elapsed.as_secs_f64();
2059                let kbps = (run.bytes_encoded * 8) as f64 / (secs * 1000.0);
2060                let silent_pct = if run.chunks_read > 0 {
2061                    100.0 * run.chunks_silent as f64 / run.chunks_read as f64
2062                } else {
2063                    0.0
2064                };
2065                plog!(
2066                    "[pcmflux] Status | Read: {}, Silent: {} ({:.1}%), Encoded: {}, Rate: {:.2} kbps",
2067                    run.chunks_read, run.chunks_silent, silent_pct, run.chunks_encoded, kbps
2068                );
2069                last_log = Instant::now();
2070                run.chunks_read = 0;
2071                run.chunks_silent = 0;
2072                run.chunks_encoded = 0;
2073                run.bytes_encoded = 0;
2074            }
2075        }
2076    }
2077
2078    if let Some(e) = terminal_error {
2079        inner.fail(format!(
2080            "audio capture could not {} (last error: {e}); stopping.",
2081            if ever_connected { "stay connected" } else { "connect" }
2082        ));
2083    } else {
2084        plog!("[pcmflux] Stop requested. Cleaning up capture loop...");
2085        inner.started_ok.store(false, Ordering::Release);
2086        // A stop before the first session came up must also resolve the startup
2087        // handshake, or the waiting `start_capture` polls out with the run still STARTING.
2088        inner.start_state.store(ST_IDLE, Ordering::Release);
2089    }
2090    if let Some(s) = session.as_mut() {
2091        let _ = s.stream.disconnect();
2092    }
2093    drop(session);
2094    drop(run);
2095    drop(delivery);
2096    let dropped = ring.dropped.load(Ordering::Relaxed);
2097    if dropped > 0 {
2098        plog!("[pcmflux] Delivery ring dropped {dropped} stale frame(s) to a slow consumer.");
2099    }
2100    plog!("[pcmflux] Audio capture loop finished. Resources released.");
2101}
2102
2103
2104/// One PulseAudio session for playback: mainloop, context, and the playback stream, all
2105/// recreated together on reconnect — the mirror of `PaCaptureSession`, with the same
2106/// drop-order requirement (stream first, then its context, then the mainloop).
2107struct PaPlaybackSession {
2108    stream: Stream,
2109    /// Must outlive the stream (the connection owns it); never read after open.
2110    #[allow(dead_code)]
2111    context: Context,
2112    mainloop: Mainloop,
2113}
2114
2115/// Open a playback session: mainloop + context + playback stream driven to `Ready` on the
2116/// bounded pump, honoring `stop_pending` at every turn. The mirror of
2117/// `pa_capture_session_open`.
2118///
2119/// Every failure is `Transient`: `connect_playback` resolves a sink name asynchronously,
2120/// so a wrong device name and a server that is still coming up are the same failed stream
2121/// state here, and the caller's retry budget is what bounds either one.
2122fn pa_playback_session_open(
2123    inner: &Inner,
2124    spec: &Spec,
2125    device: Option<&str>,
2126    attr: &BufferAttr,
2127) -> Result<PaPlaybackSession, SessionOpenError> {
2128    let tr = |e: &str| SessionOpenError::Transient(e.to_string());
2129    let mut mainloop = match Mainloop::new() {
2130        Some(m) => m,
2131        None => return Err(tr("pa_mainloop_new() failed (playback)")),
2132    };
2133    let mut context = match Context::new(&mainloop, "pcmflux") {
2134        Some(c) => c,
2135        None => return Err(tr("pa_context_new() failed (playback)")),
2136    };
2137    if context.connect(None, CtxFlags::NOFLAGS, None).is_err() {
2138        return Err(tr("pa_context_connect() failed (playback)"));
2139    }
2140    loop {
2141        let st = context.get_state();
2142        if st == pulse::context::State::Ready {
2143            break;
2144        }
2145        if !st.is_good() {
2146            return Err(tr("PulseAudio context connection failed (playback)"));
2147        }
2148        if inner.stop_pending() {
2149            return Err(SessionOpenError::Aborted);
2150        }
2151        if !pump(&mut mainloop, PUMP_TIMEOUT_US) {
2152            return Err(tr("mainloop iterate failed during connect (playback)"));
2153        }
2154    }
2155
2156    let mut stream = match Stream::new(&mut context, "Microphone Playback", spec, None) {
2157        Some(s) => s,
2158        None => return Err(tr("pa_stream_new() failed (playback)")),
2159    };
2160    if stream
2161        .connect_playback(device, Some(attr), StreamFlags::ADJUST_LATENCY, None, None)
2162        .is_err()
2163    {
2164        return Err(SessionOpenError::Transient(format!(
2165            "pa_stream_connect_playback() failed (device '{}')",
2166            device.unwrap_or("default")
2167        )));
2168    }
2169    loop {
2170        let st = stream.get_state();
2171        if st == pulse::stream::State::Ready {
2172            break;
2173        }
2174        if !st.is_good() {
2175            return Err(SessionOpenError::Transient(format!(
2176                "PulseAudio playback stream failed (device '{}')",
2177                device.unwrap_or("default")
2178            )));
2179        }
2180        if inner.stop_pending() {
2181            return Err(SessionOpenError::Aborted);
2182        }
2183        if !pump(&mut mainloop, PUMP_TIMEOUT_US) {
2184            return Err(tr("mainloop iterate failed during stream connect (playback)"));
2185        }
2186    }
2187    Ok(PaPlaybackSession {
2188        stream,
2189        context,
2190        mainloop,
2191    })
2192}
2193
2194/// Drive one whole mic-playback run on the playback thread. The body handed to
2195/// `spawn_worker`; the mirror of `capture_run` for the uplink.
2196///
2197/// This thread solely owns the PA playback stream, so writes are serialized structurally
2198/// with no executor. It mirrors `capture_run`'s lifecycle: `start_state` goes `RUNNING`
2199/// once the first session is up, `FAILED` (with a `last_error`) when the run gives up, and
2200/// `IDLE` on a clean stop; it returns when `stop_state` leaves `STOP_NONE` or the retry
2201/// budget is spent.
2202///
2203/// **Session loop**: the sink can die under a live stream (PulseAudio/PipeWire restart,
2204/// sink removed). Breaking out there would leave the mic uplink dead until something
2205/// upstream noticed and restarted the whole playback, losing every packet in between, so
2206/// the session is reopened with the same backoff and budgets capture uses.
2207///
2208/// **Buffer sizing and the prebuf timing rule** (load-bearing): `tlength` is the target
2209/// latency in bytes, and `prebuf` is a quarter of it, floored to one frame. `prebuf` must
2210/// NOT be zero: with `prebuf == 0` PulseAudio starts playback instantly, the realtime read
2211/// pointer then runs ahead of the write index, and every `SeekMode::Relative` write lands
2212/// "in the past" — the server silently discards it forever (observed as bytes flowing at
2213/// exactly realtime rate while the sink monitor stayed silent). A quarter-buffer prebuf makes
2214/// the stream wait for data before starting and re-prebuffer after each underrun, so a late
2215/// chunk plays slightly delayed instead of vanishing.
2216///
2217/// **Hot loop**: `pump`s on the ~20 ms bound, then, whenever the server is writable, drains
2218/// that many bytes from the `PlayQueue` (clamped and frame-aligned) and writes them with
2219/// `free_cb = None`, so PA copies the bytes and the `scratch` buffer is reused next
2220/// iteration. Newly queued bytes are picked up on the next pump — no cross-thread wakeup is
2221/// needed, mirroring capture's poll style — and a stop is observed within the pump bound.
2222fn playback_run(inner: &Inner, settings: &PbSettings, queue: &PlayQueue) {
2223    inner.debug_logging.store(settings.debug_logging, Ordering::Relaxed);
2224
2225    // Sample rate and channel count were validated by `extract_pb_settings`.
2226    let spec = Spec {
2227        format: Format::S16le,
2228        rate: settings.sample_rate,
2229        channels: settings.channels as u8,
2230    };
2231
2232    let device = settings.device_name.as_deref();
2233    plog!(
2234        "[pcmflux] Attempting to connect playback to PulseAudio device: {} (latency {}ms)",
2235        device.unwrap_or("system_default"),
2236        settings.latency_ms
2237    );
2238
2239    let tlength =
2240        spec.usec_to_bytes(MicroSeconds(settings.latency_ms.max(0) as u64 * 1000)) as u32;
2241    let attr = BufferAttr {
2242        maxlength: u32::MAX,
2243        tlength,
2244        prebuf: (tlength / 4).max(spec.frame_size() as u32),
2245        minreq: u32::MAX,
2246        fragsize: u32::MAX,
2247    };
2248
2249    // Retry budgets, matching capture: a start gets a short window, a mid-run reconnect
2250    // one long enough to outlast a whole PulseAudio/PipeWire restart.
2251    const START_TRIES: u32 = 12;
2252    const RECONNECT_TRIES: u32 = 40;
2253    let mut session: Option<PaPlaybackSession> = None;
2254    let mut ever_connected = false;
2255    let mut tries: u32 = 0;
2256    let mut backoff_ms: u64 = 250;
2257    let mut terminal_error: Option<String> = None;
2258
2259    let mut scratch: Vec<u8> = Vec::new();
2260    let mut bytes_written: u64 = 0;
2261    let mut writable_hits: u64 = 0;
2262    let mut last_pb_log = Instant::now();
2263
2264    loop {
2265        if inner.stop_pending() {
2266            break;
2267        }
2268        if session.is_none() {
2269            match pa_playback_session_open(inner, &spec, device, &attr) {
2270                Ok(s) => {
2271                    session = Some(s);
2272                    tries = 0;
2273                    backoff_ms = 250;
2274                    if !ever_connected {
2275                        ever_connected = true;
2276                        inner.started_ok.store(true, Ordering::Release);
2277                        inner.start_state.store(ST_RUNNING, Ordering::Release);
2278                        plog!(
2279                            "[pcmflux] Playback loop started. Device: {}, Rate: {}, Channels: {}, Latency: {}ms",
2280                            device.unwrap_or("system_default"),
2281                            settings.sample_rate,
2282                            settings.channels,
2283                            settings.latency_ms
2284                        );
2285                    } else {
2286                        // Mic audio queued during the outage is stale; playing it out
2287                        // would only push that much extra latency into the uplink.
2288                        queue.clear();
2289                        plog!("[pcmflux] audio playback reconnected; resuming.");
2290                    }
2291                }
2292                Err(SessionOpenError::Aborted) => break,
2293                Err(SessionOpenError::DeviceNotFound(e)) | Err(SessionOpenError::Transient(e)) => {
2294                    tries += 1;
2295                    let cap = if ever_connected { RECONNECT_TRIES } else { START_TRIES };
2296                    if tries >= cap {
2297                        terminal_error = Some(e);
2298                        break;
2299                    }
2300                    elog!("[pcmflux] audio playback open failed ({e}); retry {tries}/{cap} in {backoff_ms}ms");
2301                    let mut slept = 0u64;
2302                    while slept < backoff_ms && !inner.stop_pending() {
2303                        std::thread::sleep(Duration::from_millis(50));
2304                        slept += 50;
2305                    }
2306                    backoff_ms = (backoff_ms * 2).min(5000);
2307                    continue;
2308                }
2309            }
2310        }
2311        let s = session.as_mut().expect("session checked above");
2312        if !pump(&mut s.mainloop, PUMP_TIMEOUT_US) {
2313            elog!("[pcmflux] ERROR: mainloop iterate failed; reopening the playback session.");
2314            session = None;
2315            continue;
2316        }
2317        if s.stream.get_state() != pulse::stream::State::Ready {
2318            elog!("[pcmflux] playback stream lost; reopening the session.");
2319            session = None;
2320            continue;
2321        }
2322        if let Some(can) = s.stream.writable_size()
2323            && can > 0 {
2324                writable_hits += 1;
2325                queue.drain_upto(can, &mut scratch);
2326                if !scratch.is_empty() {
2327                    if let Err(e) =
2328                        s.stream.write(&scratch, None, 0, pulse::stream::SeekMode::Relative)
2329                    {
2330                        elog!("[pcmflux] ERROR: pa_stream_write() failed: {e:?}");
2331                    } else {
2332                        bytes_written += scratch.len() as u64;
2333                    }
2334                }
2335            }
2336        if inner.debug_logging.load(Ordering::Relaxed) && last_pb_log.elapsed().as_secs() >= 1 {
2337            plog!(
2338                "[pcmflux] Playback | writable_hits: {writable_hits}, bytes_written: {bytes_written}, queued: {}",
2339                queue.buf.lock().map(|q| q.len()).unwrap_or(0)
2340            );
2341            last_pb_log = Instant::now();
2342        }
2343    }
2344
2345    if let Some(e) = terminal_error {
2346        inner.fail(format!(
2347            "audio playback could not {} (last error: {e}); stopping.",
2348            if ever_connected { "stay connected" } else { "connect" }
2349        ));
2350    } else {
2351        plog!("[pcmflux] Stop requested. Cleaning up playback loop...");
2352        inner.started_ok.store(false, Ordering::Release);
2353        // A stop before the first session came up must also resolve the startup
2354        // handshake, so `worker_alive` stops reporting a STARTING run that will never run.
2355        inner.start_state.store(ST_IDLE, Ordering::Release);
2356    }
2357    if let Some(s) = session.as_mut() {
2358        let _ = s.stream.disconnect();
2359    }
2360    drop(session);
2361    plog!("[pcmflux] Audio playback loop finished. Resources released.");
2362}
2363
2364/// Python-facing capture handle. Owns the `Shared` lifecycle state and exposes
2365/// `start_capture` / `stop_capture` / `update_audio_bitrate` / `is_capturing` to Python.
2366#[pyclass]
2367struct AudioCapture {
2368    shared: Arc<Shared>,
2369}
2370
2371impl AudioCapture {
2372    fn inner(&self) -> &Arc<Inner> {
2373        &self.shared.inner
2374    }
2375}
2376
2377#[pymethods]
2378impl AudioCapture {
2379    #[new]
2380    fn new() -> Self {
2381        AudioCapture {
2382            shared: Arc::new(Shared {
2383                inner: Arc::new(Inner::new()),
2384                thread: Mutex::new(None),
2385            }),
2386        }
2387    }
2388
2389    /// Start (or restart) audio capture, delivering encoded frames to `callback`.
2390    ///
2391    /// 1. **Re-entrancy guard**: if called on one of the run's own threads — the delivery
2392    ///    thread (where the Python callback actually executes) or the capture thread — it
2393    ///    cannot join/recreate the run it is part of (the capture thread joins the delivery
2394    ///    thread on teardown, so a join from either closes a cycle), so it just undoes a
2395    ///    nested SELF-stop and returns. That undo is a compare-exchange that clears the stop
2396    ///    ONLY if this thread still owns it — if an external stop stored `STOP_EXTERNAL`
2397    ///    meanwhile, the CAS fails and that stop stands (clearing it would strand its
2398    ///    in-flight join forever).
2399    /// 2. **Spawn with the GIL released**: `spawn_worker` stops/joins any prior thread and
2400    ///    spawns the new one via `py.detach`, because the lifecycle lock and `join()` must not
2401    ///    be held while holding the GIL — joining the capture thread transitively joins the
2402    ///    delivery thread, whose in-flight callback needs the GIL, so that would deadlock.
2403    ///    The stop/clear ordering (the lost-stop invariant) lives in `spawn_worker`.
2404    /// 3. **Register** the handle for the atexit sweep (best-effort), pruning dead weaks.
2405    /// 4. **Startup handshake** (`await_start`): waits up to ~2 s (GIL released) for the
2406    ///    thread to publish `RUNNING` or `FAILED`, returning `Ok` while it is still
2407    ///    `STARTING` — the retry ladder can run longer than the window, so a later failure
2408    ///    is observed through `state` / `last_error`. On `FAILED`, `join_failed_start` tears
2409    ///    down ONLY the thread this call spawned (identity-checked) — a concurrent start may
2410    ///    already own the slot with a live run that must survive — and `last_error` is raised.
2411    fn start_capture(
2412        &self,
2413        py: Python<'_>,
2414        settings: &Bound<'_, PyAny>,
2415        callback: Py<PyAny>,
2416    ) -> PyResult<()> {
2417        let inner = self.inner().clone();
2418
2419        let me = gettid();
2420        if inner.is_own_thread(me) {
2421            inner.undo_self_stop(me);
2422            return Ok(());
2423        }
2424
2425        let parsed = extract_settings(settings)?;
2426
2427        let shared = &self.shared;
2428        let inner_ref = &inner;
2429        let t_inner = inner.clone();
2430        let body = move || capture_run(&t_inner, &parsed, &callback);
2431        let spawned =
2432            py.detach(move || spawn_worker(&shared.thread, inner_ref, "pcmflux-capture", body));
2433        let my_thread = match spawned {
2434            Some(id) => id,
2435            None => {
2436                return Err(pyo3::exceptions::PyRuntimeError::new_err(
2437                    "capture thread spawn failed",
2438                ))
2439            }
2440        };
2441
2442        if let Ok(mut reg) = registry().lock() {
2443            reg.retain(|w| w.strong_count() > 0);
2444            reg.push(Arc::downgrade(&self.shared));
2445        }
2446
2447        await_start(py, &self.shared.thread, &inner, my_thread, "audio capture")
2448    }
2449
2450    /// Stop audio capture, joining the capture thread.
2451    ///
2452    /// A re-entrant stop from one of the run's own threads — the delivery thread (where the
2453    /// Python callback executes) or the capture thread — only records a self-stop: a join
2454    /// from inside the run would cycle (the capture thread joins the delivery thread on
2455    /// teardown). It must not clobber an external stop already in effect, which has to win
2456    /// the join. Otherwise it takes the lifecycle lock and joins with the GIL released (via
2457    /// `py.detach`): joining the capture thread transitively joins the delivery thread,
2458    /// whose in-flight callback needs the GIL, so holding it would deadlock. The
2459    /// authoritative external stop is set INSIDE the lock immediately before the join, so
2460    /// it wins over any concurrent self-stop.
2461    fn stop_capture(&self, py: Python<'_>) {
2462        let inner = self.inner();
2463        let me = gettid();
2464        if inner.is_own_thread(me) {
2465            inner.request_self_stop(me);
2466            return;
2467        }
2468        let shared = &self.shared;
2469        py.detach(|| {
2470            let mut guard = shared.thread.lock().unwrap();
2471            if let Some(handle) = guard.take() {
2472                inner.request_external_stop();
2473                let _ = handle.join();
2474                inner.capture_tid.store(0, Ordering::Release);
2475            }
2476        });
2477    }
2478
2479    /// Set the live Opus target bitrate (bits/s) via the atomic mirror; the capture
2480    /// loop applies it on the next frame, without a restart. Values are clamped to the
2481    /// valid Opus range so an out-of-range request can never wedge the encoder.
2482    fn update_audio_bitrate(&self, bps: i32) {
2483        let clamped = bps.clamp(OPUS_BITRATE_MIN, OPUS_BITRATE_MAX);
2484        self.inner().opus_bitrate.store(clamped, Ordering::Relaxed);
2485    }
2486
2487    /// True while a capture worker is connected and running with no stop pending; false
2488    /// while still starting and after a failure — see `state` to tell those apart.
2489    #[getter]
2490    fn is_capturing(&self) -> bool {
2491        self.inner().running()
2492    }
2493
2494    /// Lifecycle phase: `"idle"`, `"starting"`, `"running"` or `"failed"`. A run that
2495    /// fails after `start_capture` returned (its retry ladder gave up, or a mid-run
2496    /// reconnect budget was spent) reads `"failed"` with the reason in `last_error`.
2497    #[getter]
2498    fn state(&self) -> &'static str {
2499        self.inner().state_name()
2500    }
2501
2502    /// Why the last run failed, or `None` while no run has failed since the last start.
2503    #[getter]
2504    fn last_error(&self) -> Option<String> {
2505        self.inner().last_error()
2506    }
2507}
2508
2509impl Drop for AudioCapture {
2510    /// Best-effort stop on GC/dealloc: the re-entrant case (running on the run's own
2511    /// delivery or capture thread) records a self-stop only (never clobbering a pending
2512    /// external stop); otherwise it takes the lifecycle lock and joins the capture thread
2513    /// with the GIL released, matching `stop_capture`.
2514    fn drop(&mut self) {
2515        let inner = &self.shared.inner;
2516        let me = gettid();
2517        if inner.is_own_thread(me) {
2518            inner.request_self_stop(me);
2519            return;
2520        }
2521        let shared = &self.shared;
2522        Python::attach(|py| {
2523            py.detach(|| {
2524                if let Ok(mut guard) = shared.thread.lock()
2525                    && let Some(handle) = guard.take() {
2526                        inner.request_external_stop();
2527                        let _ = handle.join();
2528                        inner.capture_tid.store(0, Ordering::Release);
2529                    }
2530            });
2531        });
2532    }
2533}
2534
2535/// Python-facing mic-playback handle, symmetric to `AudioCapture`.
2536///
2537/// Same lifecycle protocol, but a PA playback stream instead of a record stream, and a
2538/// bounded drop-oldest queue fed by `write` / `write_red` instead of a Python callback.
2539/// Python never holds the PA handle — only the playback thread touches it — so a
2540/// close-versus-inflight-write use-after-free is structurally impossible here.
2541#[pyclass]
2542struct AudioPlayback {
2543    shared: Arc<PbShared>,
2544}
2545
2546impl AudioPlayback {
2547    fn inner(&self) -> &Arc<Inner> {
2548        &self.shared.inner
2549    }
2550
2551    /// Run `decode` on this run's Opus decoder with the bytes of a bytes-like `data`, off
2552    /// the GIL. The shared body of `write` and `write_red`.
2553    ///
2554    /// Gated on `worker_alive`: it raises once no playback thread services the queue (start
2555    /// failure, stop, or a PA outage the session loop could not reconnect through), so the
2556    /// caller's reopen-on-error path engages instead of the audio being swallowed silently.
2557    /// A reconnect in progress stays "alive" and keeps queueing.
2558    ///
2559    /// A `bytes` payload is immutable, so it is borrowed in place across the GIL release.
2560    /// Any other bytes-like object — a C-contiguous buffer-protocol exporter of any item
2561    /// format, the same set CPython's own `y*` argument parsing accepts (`memoryview`,
2562    /// `bytearray`, `array`, NumPy, an `AudioFrame`, ...) — may be mutated by another Python
2563    /// thread once the GIL is dropped, so its bytes are copied under the GIL into the
2564    /// decoder's reusable scratch buffer first; an Opus packet is a few hundred bytes, and
2565    /// the steady state allocates nothing.
2566    fn decode_packet(
2567        &self,
2568        py: Python<'_>,
2569        data: &Bound<'_, PyAny>,
2570        decode: impl FnOnce(&mut OpusPlaybackDecoder, &[u8], &PlayQueue) + Send,
2571    ) -> PyResult<()> {
2572        if !self.inner().worker_alive() {
2573            return Err(pyo3::exceptions::PyRuntimeError::new_err(
2574                "audio playback is not running (stream failed, stopped, or never started)",
2575            ));
2576        }
2577        let queue = &*self.shared.queue;
2578        if let Ok(b) = data.cast::<PyBytes>() {
2579            let packet = b.as_bytes();
2580            py.detach(|| {
2581                let mut dec = self.shared.opus_dec.lock().unwrap_or_else(|e| e.into_inner());
2582                if let Some(d) = dec.as_mut() {
2583                    decode(d, packet, queue);
2584                }
2585            });
2586            return Ok(());
2587        }
2588        let buf = PyUntypedBuffer::get(data)?;
2589        if !buf.is_c_contiguous() {
2590            return Err(pyo3::exceptions::PyBufferError::new_err(
2591                "a contiguous bytes-like object is required",
2592            ));
2593        }
2594        let mut dec = self.shared.opus_dec.lock().unwrap_or_else(|e| e.into_inner());
2595        let Some(d) = dec.as_mut() else {
2596            return Ok(());
2597        };
2598        let mut packet = std::mem::take(&mut d.packet);
2599        packet.clear();
2600        // A C-contiguous Py_buffer exposes exactly `len_bytes()` readable bytes at `buf`,
2601        // whatever its item format; the export is held (and the GIL, so no Python thread
2602        // can resize or mutate the exporter) for the duration of this copy.
2603        let n = buf.len_bytes();
2604        if n > 0 {
2605            packet.extend_from_slice(unsafe {
2606                std::slice::from_raw_parts(buf.buf_ptr() as *const u8, n)
2607            });
2608        }
2609        drop(buf);
2610        py.detach(|| decode(d, &packet, queue));
2611        d.packet = packet;
2612        Ok(())
2613    }
2614}
2615
2616#[pymethods]
2617impl AudioPlayback {
2618    #[new]
2619    fn new() -> Self {
2620        AudioPlayback {
2621            shared: Arc::new(PbShared {
2622                inner: Arc::new(Inner::new()),
2623                thread: Mutex::new(None),
2624                queue: Arc::new(PlayQueue::new()),
2625                opus_dec: Mutex::new(None),
2626            }),
2627        }
2628    }
2629
2630    /// Start (or restart) mic playback into the virtual sink. The playback mirror of
2631    /// `start_capture`.
2632    ///
2633    /// Same shape as capture: a re-entrant start from the playback thread just undoes a
2634    /// nested self-stop; the worker is spawned with the GIL released (the stop/clear ordering
2635    /// lives in `spawn_worker`); the handle is registered for the atexit sweep; and the ~2 s
2636    /// `await_start` handshake raises a `FAILED` start (with `last_error`) after tearing
2637    /// down only the thread THIS call spawned (identity-checked, sparing a concurrent
2638    /// winner), while a start still in its retry ladder returns `Ok` and is watched through
2639    /// `state` / `last_error`. Before spawning, it applies this run's byte bound + frame
2640    /// alignment to the queue (dropping any stale audio) and creates the Opus decoder up
2641    /// front, since the mic uplink is always Opus and `write` / `write_red` decode packets
2642    /// to PCM off the GIL for this same run.
2643    fn start(&self, py: Python<'_>, settings: &Bound<'_, PyAny>) -> PyResult<()> {
2644        let inner = self.inner().clone();
2645
2646        let me = gettid();
2647        if inner.capture_tid.load(Ordering::Acquire) == me {
2648            inner.undo_self_stop(me);
2649            return Ok(());
2650        }
2651
2652        let parsed = extract_pb_settings(settings)?;
2653        let frame_bytes = (parsed.channels.max(1) as usize) * 2;
2654        self.shared.queue.configure(parsed.max_buffer_bytes, frame_bytes);
2655
2656        let decoder = OpusPlaybackDecoder::new(parsed.sample_rate, parsed.channels);
2657        if decoder.is_none() {
2658            return Err(pyo3::exceptions::PyRuntimeError::new_err(
2659                "failed to create Opus decoder for playback",
2660            ));
2661        }
2662        *self.shared.opus_dec.lock().unwrap_or_else(|e| e.into_inner()) = decoder;
2663
2664        let shared = &self.shared;
2665        let inner_ref = &inner;
2666        let queue = self.shared.queue.clone();
2667        let t_inner = inner.clone();
2668        let body = move || playback_run(&t_inner, &parsed, &queue);
2669        let spawned =
2670            py.detach(move || spawn_worker(&shared.thread, inner_ref, "pcmflux-playback", body));
2671        let my_thread = match spawned {
2672            Some(id) => id,
2673            None => {
2674                return Err(pyo3::exceptions::PyRuntimeError::new_err(
2675                    "playback thread spawn failed",
2676                ))
2677            }
2678        };
2679
2680        if let Ok(mut reg) = playback_registry().lock() {
2681            reg.retain(|w| w.strong_count() > 0);
2682            reg.push(Arc::downgrade(&self.shared));
2683        }
2684
2685        await_start(py, &self.shared.thread, &inner, my_thread, "audio playback")
2686    }
2687
2688    /// Push one Opus mic packet for playback. The steady-state hot path.
2689    ///
2690    /// `data` is any bytes-like object (`bytes`, `memoryview`, `bytearray`, an
2691    /// `AudioFrame`, ...); see `decode_packet` for the liveness gate and how the payload is
2692    /// borrowed. The decode runs with the GIL released — it touches no Python state, so
2693    /// dropping the GIL lets it run concurrently with the rest of the app — and a bad packet
2694    /// is dropped rather than corrupting the stream. It never blocks on PA (drop-oldest
2695    /// happens inside `PlayQueue::push`).
2696    fn write(&self, py: Python<'_>, data: &Bound<'_, PyAny>) -> PyResult<()> {
2697        self.decode_packet(py, data, |dec, packet, queue| {
2698            if let Some(pcm) = dec.decode_to_pcm(packet) {
2699                queue.push(pcm);
2700            }
2701        })
2702    }
2703
2704    /// Play one RFC 2198 RED mic frame from the WebRTC/UDP uplink, recovering across any
2705    /// packet loss on the way in. The lossy-transport counterpart of `write`.
2706    ///
2707    /// The payload is de-framed, loss-recovered, and decoded entirely off the GIL by
2708    /// `decode_red_into_queue` (see there for why RED exists and why the decode runs off the
2709    /// GIL). `primary_ts` is the packet's monotonic RTP timestamp; the redundant blocks carry
2710    /// offsets back from it. Accepts any bytes-like `data` and is gated on `worker_alive`
2711    /// exactly like `write` (see `decode_packet`).
2712    fn write_red(&self, py: Python<'_>, data: &Bound<'_, PyAny>, primary_ts: i64) -> PyResult<()> {
2713        self.decode_packet(py, data, move |dec, packet, queue| {
2714            dec.decode_red_into_queue(packet, primary_ts, queue);
2715        })
2716    }
2717
2718    /// Stop mic playback, joining the playback thread.
2719    ///
2720    /// A re-entrant stop from the playback thread records a self-stop only (it cannot
2721    /// self-join) and never clobbers an external stop already in effect. Otherwise it joins
2722    /// with the GIL released so a slow PA disconnect cannot stall the interpreter; `stop`
2723    /// returns only once the thread is joined and the sink is released.
2724    fn stop(&self, py: Python<'_>) {
2725        let inner = self.inner();
2726        let me = gettid();
2727        if inner.capture_tid.load(Ordering::Acquire) == me {
2728            inner.request_self_stop(me);
2729            return;
2730        }
2731        let shared = &self.shared;
2732        py.detach(|| {
2733            let mut guard = shared.thread.lock().unwrap();
2734            if let Some(handle) = guard.take() {
2735                inner.request_external_stop();
2736                let _ = handle.join();
2737                inner.capture_tid.store(0, Ordering::Release);
2738            }
2739        });
2740    }
2741
2742    /// True while a playback worker is connected and running with no stop pending; false
2743    /// while still starting and after a failure — see `state` to tell those apart.
2744    #[getter]
2745    fn is_running(&self) -> bool {
2746        self.inner().running()
2747    }
2748
2749    /// Lifecycle phase: `"idle"`, `"starting"`, `"running"` or `"failed"`, the playback
2750    /// mirror of `AudioCapture.state`.
2751    #[getter]
2752    fn state(&self) -> &'static str {
2753        self.inner().state_name()
2754    }
2755
2756    /// Why the last run failed, or `None` while no run has failed since the last start.
2757    #[getter]
2758    fn last_error(&self) -> Option<String> {
2759        self.inner().last_error()
2760    }
2761}
2762
2763impl Drop for AudioPlayback {
2764    /// Best-effort stop on GC/dealloc; symmetric to `AudioCapture::drop`.
2765    fn drop(&mut self) {
2766        let inner = &self.shared.inner;
2767        let me = gettid();
2768        if inner.capture_tid.load(Ordering::Acquire) == me {
2769            inner.request_self_stop(me);
2770            return;
2771        }
2772        let shared = &self.shared;
2773        Python::attach(|py| {
2774            py.detach(|| {
2775                if let Ok(mut guard) = shared.thread.lock()
2776                    && let Some(handle) = guard.take() {
2777                        inner.request_external_stop();
2778                        let _ = handle.join();
2779                        inner.capture_tid.store(0, Ordering::Release);
2780                    }
2781            });
2782        });
2783    }
2784}
2785
2786/// atexit sweep: stop and join every live capture and playback before interpreter
2787/// shutdown, so no worker thread is still calling into Python during finalization.
2788///
2789/// Snapshots the two `Weak` registries into strong references (skipping any already
2790/// dropped), then for each takes the lifecycle lock, sets the external stop before joining,
2791/// and clears the tid — all with the GIL released. Registered on `atexit` from the module
2792/// init.
2793#[pyfunction]
2794fn _stop_all_captures(py: Python<'_>) {
2795    let snapshot: Vec<Arc<Shared>> = match registry().lock() {
2796        Ok(reg) => reg.iter().filter_map(|w| w.upgrade()).collect(),
2797        Err(_) => Vec::new(),
2798    };
2799    for shared in snapshot {
2800        py.detach(|| {
2801            if let Ok(mut guard) = shared.thread.lock()
2802                && let Some(handle) = guard.take() {
2803                    shared.inner.request_external_stop();
2804                    let _ = handle.join();
2805                    shared.inner.capture_tid.store(0, Ordering::Release);
2806                }
2807        });
2808    }
2809    let pb_snapshot: Vec<Arc<PbShared>> = match playback_registry().lock() {
2810        Ok(reg) => reg.iter().filter_map(|w| w.upgrade()).collect(),
2811        Err(_) => Vec::new(),
2812    };
2813    for shared in pb_snapshot {
2814        py.detach(|| {
2815            if let Ok(mut guard) = shared.thread.lock()
2816                && let Some(handle) = guard.take() {
2817                    shared.inner.request_external_stop();
2818                    let _ = handle.join();
2819                    shared.inner.capture_tid.store(0, Ordering::Release);
2820                }
2821        });
2822    }
2823}
2824
2825#[cfg(test)]
2826mod tests {
2827    use super::*;
2828
2829    /// Timestamps that wrap the 32-bit RTP range still decode in order — the
2830    /// post-rollover frame is not mistaken for an already-played duplicate.
2831    #[test]
2832    fn red_playback_timestamp_wraparound() {
2833        let f = opus_frames(3);
2834        let mut dec = OpusPlaybackDecoder::new(24000, 1).unwrap();
2835        let q = PlayQueue::new();
2836        q.configure(1 << 20, 2);
2837        let wrap = (u32::MAX as i64) - 100;
2838        dec.decode_red_into_queue(&build_red_payload(&[], &f[0]), wrap, &q);
2839        // One 20 ms step past the 32-bit rollover (mod 2^32): ts is numerically far
2840        // BELOW `wrap`, so a plain `ts > last` treats it as an already-played frame.
2841        let next = (wrap.wrapping_add(480)) & 0xFFFF_FFFF;
2842        dec.decode_red_into_queue(&build_red_payload(&[(480, &f[0])], &f[1]), next, &q);
2843        let mut out = Vec::new();
2844        q.drain_upto(1 << 20, &mut out);
2845        assert_eq!(out.len(), 2 * FRAME_PCM_BYTES,
2846            "frame after the 32-bit wrap was dropped as a duplicate");
2847    }
2848
2849    /// The re-entrancy guard must recognize BOTH of a run's own threads: the
2850    /// capture worker AND the delivery thread — the Python callback executes on the
2851    /// delivery thread, so a stop/start it issues arrives with the delivery tid, and
2852    /// treating it as external would join into the capture→delivery join cycle and
2853    /// deadlock.
2854    #[test]
2855    fn reentrancy_guard_matches_delivery_thread() {
2856        let inner = Inner::new();
2857        let me = gettid();
2858        assert!(!inner.is_own_thread(me), "no run live: nothing should match");
2859        inner.deliver_tid.store(me, Ordering::Release);
2860        assert!(inner.is_own_thread(me), "delivery tid must short-circuit the guard");
2861        inner.deliver_tid.store(0, Ordering::Release);
2862        inner.capture_tid.store(me, Ordering::Release);
2863        assert!(inner.is_own_thread(me), "capture tid must still short-circuit the guard");
2864    }
2865
2866    /// Encode 5.1 with a tone only on FC (input channel 2), decode with the same
2867    /// layout, and verify the energy comes back on that same channel — proving the
2868    /// `multiopus_layout` tables are self-consistent end to end.
2869    #[test]
2870    fn multiopus_surround_roundtrip() {
2871        let channels = 6usize;
2872        let frame = 480usize;
2873        let mut enc = PcmEncoder::new(48000, channels as i32, true, 256000).expect("encoder");
2874        let mut pcm = vec![0i16; frame * channels];
2875        for i in 0..frame {
2876            let v = (8000.0 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 48000.0).sin())
2877                as i16;
2878            pcm[i * channels + 2] = v;
2879        }
2880        let mut out = vec![0u8; 4 * MAX_OPUS_PACKET];
2881        let mut n = 0;
2882        for _ in 0..3 {
2883            n = enc.encode(&pcm, frame, &mut out).expect("encode");
2884        }
2885        assert!(n > 0, "surround encode produced no bytes");
2886
2887        unsafe {
2888            let (streams, coupled, mapping) = multiopus_layout(channels as i32).unwrap();
2889            let mut err = 0;
2890            let dec = audiopus_sys::opus_multistream_decoder_create(
2891                48000,
2892                channels as i32,
2893                streams,
2894                coupled,
2895                mapping.as_ptr(),
2896                &mut err,
2897            );
2898            assert!(!dec.is_null() && err == 0, "decoder create failed: {err}");
2899            let mut decoded = vec![0i16; frame * channels];
2900            let got = audiopus_sys::opus_multistream_decode(
2901                dec,
2902                out.as_ptr(),
2903                n as i32,
2904                decoded.as_mut_ptr(),
2905                frame as i32,
2906                0,
2907            );
2908            audiopus_sys::opus_multistream_decoder_destroy(dec);
2909            assert_eq!(got, frame as i32, "decode length mismatch");
2910            let mut rms = vec![0f64; channels];
2911            for i in 0..frame {
2912                for (c, r) in rms.iter_mut().enumerate() {
2913                    let s = decoded[i * channels + c] as f64;
2914                    *r += s * s;
2915                }
2916            }
2917            let loudest = rms
2918                .iter()
2919                .enumerate()
2920                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
2921                .unwrap()
2922                .0;
2923            assert_eq!(loudest, 2, "tone did not come back on FC: rms={rms:?}");
2924        }
2925    }
2926
2927    /// `valid_opus_duration` accepts exactly the six legal Opus frame durations and
2928    /// rejects everything else (including the near-misses 3, 15, 25, 30 ms).
2929    #[test]
2930    fn opus_durations() {
2931        for ms in [2.5, 5.0, 10.0, 20.0, 40.0, 60.0] {
2932            assert!(valid_opus_duration(ms));
2933        }
2934        for ms in [0.0, 1.0, 3.0, 15.0, 25.0, 30.0, 50.0, 100.0] {
2935            assert!(!valid_opus_duration(ms));
2936        }
2937    }
2938
2939    /// The samples-per-channel and PCM-byte arithmetic matches the wire cases:
2940    /// 48 kHz / 20 ms / stereo is 960 samples/ch and 3840 bytes, and 24 kHz / 10 ms / mono
2941    /// is 240 samples and 480 bytes.
2942    #[test]
2943    fn frame_geometry() {
2944        let fspc = (48000usize * 20) / 1000;
2945        assert_eq!(fspc, 960);
2946        assert_eq!(fspc * 2 * 2, 3840);
2947        let m = (24000usize * 10) / 1000;
2948        assert_eq!(m, 240);
2949        assert_eq!(m * 2, 480);
2950    }
2951
2952    /// `red_distance == 0` produces exactly the 2-byte `[0x01, 0x00]` + opus framing,
2953    /// and the omit-header path returns the raw opus with no prefix at all.
2954    #[test]
2955    fn red_zero_is_byte_identical() {
2956        let opus = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42];
2957        let hist: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
2958
2959        let legacy_expected = {
2960            let mut v = vec![0x01u8, 0x00];
2961            v.extend_from_slice(&opus);
2962            v
2963        };
2964        assert_eq!(build_ws_body(&opus, 4096, &hist, 0, true), legacy_expected);
2965        assert_eq!(build_ws_body(&opus, 4096, &hist, 0, false), opus);
2966    }
2967
2968    /// `red_distance == 2`: parse the emitted body back (`n_red` 4-byte headers, the
2969    /// 1-byte primary header, block datas split by their lengths) and assert the primary and
2970    /// the two redundant blocks round-trip with the expected oldest-first offsets 1920 & 960.
2971    #[test]
2972    fn red_two_roundtrips() {
2973        let f_n2 = vec![0xA0, 0xA1, 0xA2];
2974        let f_n1 = vec![0xB0, 0xB1, 0xB2, 0xB3];
2975        let primary = vec![0xC0, 0xC1];
2976        let mut hist: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
2977        hist.push_back((f_n2.clone(), 0));
2978        hist.push_back((f_n1.clone(), 960));
2979
2980        let body = build_ws_body(&primary, 1920, &hist, 2, true);
2981        assert_eq!(body[0], 0x01);
2982        let n_red = body[1] as usize;
2983        assert_eq!(n_red, 2);
2984        assert_eq!(u32::from_be_bytes([body[2], body[3], body[4], body[5]]), 1920);
2985
2986        let mut idx = 6;
2987        let mut offsets = Vec::new();
2988        let mut lens = Vec::new();
2989        for _ in 0..n_red {
2990            assert_eq!(body[idx] & 0x80, 0x80, "redundant header F bit must be set");
2991            let word = ((body[idx + 1] as u32) << 16)
2992                | ((body[idx + 2] as u32) << 8)
2993                | (body[idx + 3] as u32);
2994            offsets.push((word >> 10) & 0x3FFF);
2995            lens.push((word & 0x3FF) as usize);
2996            idx += 4;
2997        }
2998        assert_eq!(body[idx] & 0x80, 0x00, "primary header F bit must be clear");
2999        idx += 1;
3000
3001        assert_eq!(offsets, vec![1920, 960]);
3002        assert_eq!(lens, vec![f_n2.len(), f_n1.len()]);
3003
3004        let b0 = &body[idx..idx + lens[0]];
3005        idx += lens[0];
3006        let b1 = &body[idx..idx + lens[1]];
3007        idx += lens[1];
3008        let prim = &body[idx..];
3009        assert_eq!(b0, &f_n2[..]);
3010        assert_eq!(b1, &f_n1[..]);
3011        assert_eq!(prim, &primary[..]);
3012    }
3013
3014    /// Test helper: build an RFC 2198 RED payload — redundant blocks oldest-first,
3015    /// each with a 14-bit timestamp offset back from the primary, then the primary. Mirrors
3016    /// the wire format `decode_red_into_queue` parses (the mic-uplink counterpart of
3017    /// `build_ws_body`).
3018    fn build_red_payload(reds: &[(u64, &[u8])], primary: &[u8]) -> Vec<u8> {
3019        let mut v = Vec::new();
3020        for (off, blk) in reds {
3021            let field = (((*off as u32) & 0x3FFF) << 10) | ((blk.len() as u32) & 0x3FF);
3022            v.push(0x80 | (RED_BLOCK_PT & 0x7F));
3023            v.push((field >> 16) as u8);
3024            v.push((field >> 8) as u8);
3025            v.push(field as u8);
3026        }
3027        v.push(RED_BLOCK_PT & 0x7F);
3028        for (_, blk) in reds {
3029            v.extend_from_slice(blk);
3030        }
3031        v.extend_from_slice(primary);
3032        v
3033    }
3034
3035    /// Test helper: `n` distinct valid 20 ms mono Opus packets at 24 kHz
3036    /// (480 samples/frame).
3037    fn opus_frames(n: usize) -> Vec<Vec<u8>> {
3038        let mut enc = opus::Encoder::new(24000, Channels::Mono, Application::LowDelay).unwrap();
3039        (0..n)
3040            .map(|s| {
3041                let pcm: Vec<i16> = (0..480)
3042                    .map(|k| ((k * (s as i32 + 1)) % 4000 - 2000) as i16)
3043                    .collect();
3044                let mut out = vec![0u8; 4000];
3045                let len = enc.encode(&pcm, &mut out).unwrap();
3046                out.truncate(len);
3047                out
3048            })
3049            .collect()
3050    }
3051
3052    /// Test constant: each decoded 20 ms mono frame is 480 samples * 2 bytes.
3053    const FRAME_PCM_BYTES: usize = 480 * 2;
3054
3055    /// A dropped middle packet is recovered from the next packet's redundancy: the
3056    /// redundant copy of the gap frame is decoded, while the redundant copy of an
3057    /// already-played frame is not (timestamp dedup). Exercises the off-GIL RED playback path
3058    /// end to end — anchor on packet 1 (ts 1000), drop packet 2 (ts 1480), then packet 3
3059    /// (ts 1960) carries redundant copies of 1000 and 1480, so the output is exactly the
3060    /// three frames 1000 + 1480 + 1960.
3061    #[test]
3062    fn red_playback_recovers_lost_frame() {
3063        let f = opus_frames(3);
3064        let mut dec = OpusPlaybackDecoder::new(24000, 1).unwrap();
3065        let q = PlayQueue::new();
3066        q.configure(1 << 20, 2);
3067
3068        dec.decode_red_into_queue(&build_red_payload(&[], &f[0]), 1000, &q);
3069        let pkt3 = build_red_payload(&[(960, &f[0]), (480, &f[1])], &f[2]);
3070        dec.decode_red_into_queue(&pkt3, 1960, &q);
3071
3072        let mut out = Vec::new();
3073        q.drain_upto(1 << 20, &mut out);
3074        assert_eq!(out.len(), 3 * FRAME_PCM_BYTES);
3075        assert_eq!(dec.last_ts, Some(1960));
3076    }
3077
3078    /// With no loss, redundancy is pure overhead: every frame decodes exactly once and
3079    /// the redundant copies are dropped, so three packets yield exactly three frames.
3080    #[test]
3081    fn red_playback_no_double_decode() {
3082        let f = opus_frames(3);
3083        let mut dec = OpusPlaybackDecoder::new(24000, 1).unwrap();
3084        let q = PlayQueue::new();
3085        q.configure(1 << 20, 2);
3086
3087        dec.decode_red_into_queue(&build_red_payload(&[], &f[0]), 1000, &q);
3088        dec.decode_red_into_queue(&build_red_payload(&[(480, &f[0])], &f[1]), 1480, &q);
3089        dec.decode_red_into_queue(&build_red_payload(&[(480, &f[1])], &f[2]), 1960, &q);
3090
3091        let mut out = Vec::new();
3092        q.drain_upto(1 << 20, &mut out);
3093        assert_eq!(out.len(), 3 * FRAME_PCM_BYTES);
3094        assert_eq!(dec.last_ts, Some(1960));
3095    }
3096
3097    /// A too-old block (offset > 16383, overflowing the 14-bit field) and an oversize
3098    /// block (len > 1023, overflowing the 10-bit field) are both skipped, so `n_red` counts
3099    /// only the one in-range block that fit the RFC 2198 fields.
3100    #[test]
3101    fn red_skips_oversize_and_too_old() {
3102        let too_old = vec![0x11u8; 3];
3103        let oversize = vec![0x22u8; 1100];
3104        let good = vec![0x33u8; 5];
3105        let primary = vec![0x44u8; 2];
3106        let primary_pts = 100_000u64;
3107
3108        let mut hist: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
3109        hist.push_back((too_old, primary_pts - 20_000));
3110        hist.push_back((oversize, primary_pts - 1920));
3111        hist.push_back((good.clone(), primary_pts - 960));
3112
3113        let body = build_ws_body(&primary, primary_pts, &hist, 4, true);
3114        assert_eq!(body[0], 0x01);
3115        assert_eq!(body[1], 1, "only the in-range block survives");
3116        assert_eq!(u32::from_be_bytes([body[2], body[3], body[4], body[5]]), primary_pts as u32);
3117
3118        let word = ((body[7] as u32) << 16) | ((body[8] as u32) << 8) | (body[9] as u32);
3119        assert_eq!((word >> 10) & 0x3FFF, 960);
3120        let len = (word & 0x3FF) as usize;
3121        assert_eq!(len, good.len());
3122        assert_eq!(body[10] & 0x80, 0x00, "primary header follows the one redundant header");
3123        assert_eq!(&body[11..11 + len], &good[..]);
3124        assert_eq!(&body[11 + len..], &primary[..]);
3125    }
3126
3127    /// `red_distance > 0` with no usable history (e.g. the first frame after a
3128    /// (re)start) collapses to exactly the 2-byte `[0x01, 0x00]` + opus framing — the same
3129    /// bytes as `n_red == 0`, not a primary-only RED header. The client's `n_red == 0` path
3130    /// strips exactly 2 bytes, so emitting a lone primary-only RED header here would be
3131    /// mis-stripped and corrupt the frame; the body is asserted to be exactly those 2 bytes
3132    /// plus the opus.
3133    #[test]
3134    fn red_empty_history_collapses_to_bare_header() {
3135        let opus = vec![0x77u8; 4];
3136        let hist: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
3137        let body = build_ws_body(&opus, 960, &hist, 2, true);
3138        assert_eq!(body[0], 0x01);
3139        assert_eq!(body[1], 0x00);
3140        assert_eq!(&body[2..], &opus[..]);
3141        assert_eq!(body.len(), 2 + opus.len());
3142    }
3143
3144    /// The all-zero comparison behind the silence gate: an all-zero buffer reads as
3145    /// silent, and a single non-zero sample makes it non-silent.
3146    #[test]
3147    fn silence_detection() {
3148        let silent = vec![0i16; 960 * 2];
3149        assert!(silent.iter().all(|&s| s == 0));
3150        let mut not = silent.clone();
3151        not[123] = 7;
3152        assert!(!not.iter().all(|&s| s == 0));
3153    }
3154
3155    /// Deterministic lost-stop probe against the real `stop_state` protocol.
3156    ///
3157    /// A stand-in "capture thread" hammers `request_self_stop` + `undo_self_stop` (the
3158    /// re-entrant callback pattern) while another thread issues `request_external_stop` and
3159    /// waits for the loop's `stop_pending()` to observe it. The external stop must never be
3160    /// cleared by a self-start, so the thread always observes `STOP_EXTERNAL` and the join
3161    /// returns (no hang). The bounded spin count is the in-test watchdog; 5000 iterations
3162    /// shake out the race.
3163    #[test]
3164    fn external_stop_never_lost_to_self_restart() {
3165        use std::sync::Arc;
3166        let me: i64 = 987654;
3167        for _ in 0..5000 {
3168            let inner = Arc::new(Inner::new());
3169            let inner_c = inner.clone();
3170            let observed = Arc::new(AtomicBool::new(false));
3171            let observed_c = observed.clone();
3172            let h = std::thread::spawn(move || {
3173                let mut spins: u64 = 0;
3174                loop {
3175                    inner_c.request_self_stop(me);
3176                    inner_c.undo_self_stop(me);
3177                    if inner_c.stop_pending()
3178                        && inner_c.stop_state.load(Ordering::Acquire) == STOP_EXTERNAL
3179                    {
3180                        observed_c.store(true, Ordering::Release);
3181                        return;
3182                    }
3183                    spins += 1;
3184                    assert!(spins < 50_000_000, "external stop was lost (would hang the join)");
3185                }
3186            });
3187            inner.request_external_stop();
3188            h.join().unwrap();
3189            assert!(observed.load(Ordering::Acquire));
3190            assert_eq!(inner.stop_state.load(Ordering::Acquire), STOP_EXTERNAL);
3191            assert!(inner.stop_pending());
3192        }
3193    }
3194
3195    /// Pushing past the byte bound drops the OLDEST bytes, keeping the newest window:
3196    /// an 8-byte bound fed 12 bytes retains the last 8, and a second drain comes back empty.
3197    #[test]
3198    fn playqueue_drop_oldest_keeps_newest() {
3199        let q = PlayQueue::new();
3200        q.configure(8, 2);
3201        q.push(&[1, 2, 3, 4, 5, 6]);
3202        q.push(&[7, 8, 9, 10, 11, 12]);
3203        let mut out = Vec::new();
3204        q.drain_upto(100, &mut out);
3205        assert_eq!(out, vec![5, 6, 7, 8, 9, 10, 11, 12]);
3206        q.drain_upto(100, &mut out);
3207        assert!(out.is_empty());
3208    }
3209
3210    /// `drain_upto` clamps to the requested count AND floors to a whole frame, so a PA
3211    /// write never gets a partial frame: with 4-byte frames, a request of 7 yields 4 bytes,
3212    /// the next drain yields the next 4, and the trailing 2 bytes are withheld as a partial
3213    /// frame.
3214    #[test]
3215    fn playqueue_drain_is_frame_aligned() {
3216        let q = PlayQueue::new();
3217        q.configure(1000, 4);
3218        q.push(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
3219        let mut out = Vec::new();
3220        q.drain_upto(7, &mut out);
3221        assert_eq!(out, vec![0, 1, 2, 3]);
3222        q.drain_upto(100, &mut out);
3223        assert_eq!(out, vec![4, 5, 6, 7]);
3224        q.drain_upto(100, &mut out);
3225        assert!(out.is_empty());
3226    }
3227
3228    /// `configure()` applies the new bounds and drops stale audio from a prior run, so
3229    /// bytes queued before it are gone afterward.
3230    #[test]
3231    fn playqueue_configure_resets() {
3232        let q = PlayQueue::new();
3233        q.push(&[1, 2, 3, 4]);
3234        q.configure(96000, 2);
3235        let mut out = Vec::new();
3236        q.drain_upto(100, &mut out);
3237        assert!(out.is_empty(), "configure must clear stale audio");
3238    }
3239
3240    /// A byte bound that is not a whole-frame multiple must not let overflow drops
3241    /// split a sample frame — a mid-frame trim would phase-shift every later drain
3242    /// into interleaved garbage. The bound floors to whole frames and drops are made
3243    /// in whole frames.
3244    #[test]
3245    fn playqueue_misaligned_bound_never_splits_frames() {
3246        let q = PlayQueue::new();
3247        // A 9-byte bound over 4-byte frames floors to 8, which is two whole frames.
3248        q.configure(9, 4);
3249        q.push(&[0, 1, 2, 3]);
3250        q.push(&[4, 5, 6, 7]);
3251        // With 12 bytes queued against a bound of 8, exactly the oldest frame is dropped.
3252        q.push(&[8, 9, 10, 11]);
3253        let mut out = Vec::new();
3254        q.drain_upto(100, &mut out);
3255        assert_eq!(out, vec![4, 5, 6, 7, 8, 9, 10, 11]);
3256    }
3257
3258    /// `worker_alive` (which gates `AudioPlayback::write`) tracks the lifecycle: it is
3259    /// false before any worker, true through the startup handshake and the healthy run, and
3260    /// goes false the moment the hot loop's error exit clears `started_ok` — even with no stop
3261    /// pending and `start_state` still `RUNNING` (the silent mid-run death case). A pending
3262    /// external stop also reads as not-alive.
3263    #[test]
3264    fn worker_alive_tracks_loop_error_exit() {
3265        let inner = Inner::new();
3266        assert!(!inner.worker_alive(), "no worker yet");
3267
3268        inner.start_state.store(ST_STARTING, Ordering::Release);
3269        assert!(inner.worker_alive(), "startup handshake counts as alive");
3270
3271        inner.started_ok.store(true, Ordering::Release);
3272        inner.start_state.store(ST_RUNNING, Ordering::Release);
3273        assert!(inner.worker_alive());
3274
3275        inner.started_ok.store(false, Ordering::Release);
3276        assert_eq!(inner.stop_state.load(Ordering::Acquire), STOP_NONE);
3277        assert!(!inner.worker_alive(), "dead loop must be observable");
3278
3279        inner.started_ok.store(true, Ordering::Release);
3280        inner.request_external_stop();
3281        assert!(!inner.worker_alive());
3282    }
3283
3284    /// `state_name` / `last_error` follow the lifecycle the Python getters expose: idle
3285    /// before any run, starting through the handshake, running once connected, and
3286    /// failed — with the reason recorded BEFORE the state flips — after `fail`. A new run
3287    /// armed by `spawn_worker` forgets the previous failure.
3288    #[test]
3289    fn state_and_last_error_track_lifecycle() {
3290        let inner = Arc::new(Inner::new());
3291        assert_eq!(inner.state_name(), "idle");
3292        assert_eq!(inner.last_error(), None);
3293
3294        inner.start_state.store(ST_STARTING, Ordering::Release);
3295        assert_eq!(inner.state_name(), "starting");
3296        assert!(!inner.running());
3297
3298        inner.started_ok.store(true, Ordering::Release);
3299        inner.start_state.store(ST_RUNNING, Ordering::Release);
3300        assert_eq!(inner.state_name(), "running");
3301        assert!(inner.running());
3302
3303        inner.request_external_stop();
3304        assert_eq!(inner.state_name(), "idle", "a pending stop is no longer running");
3305        inner.clear_stop();
3306
3307        inner.fail("source vanished".to_string());
3308        assert_eq!(inner.state_name(), "failed");
3309        assert_eq!(inner.last_error().as_deref(), Some("source vanished"));
3310        assert!(!inner.running());
3311        assert!(!inner.worker_alive());
3312
3313        let slot: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
3314        let wi = inner.clone();
3315        spawn_worker(&slot, &inner, "fresh", move || {
3316            wi.started_ok.store(false, Ordering::Release);
3317            wi.start_state.store(ST_IDLE, Ordering::Release);
3318        })
3319        .unwrap();
3320        slot.lock().unwrap().take().unwrap().join().unwrap();
3321        assert_eq!(inner.last_error(), None, "a new run must start with no stale error");
3322        assert_eq!(inner.state_name(), "idle", "a clean stop reads idle again");
3323    }
3324
3325    /// A worker body that panics is caught by `spawn_worker`, which marks the run failed
3326    /// and records the panic message as `last_error`, so the death is observable from
3327    /// Python instead of leaving a silently dead capture.
3328    #[test]
3329    fn worker_panic_records_last_error() {
3330        let inner = Arc::new(Inner::new());
3331        let slot: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
3332        spawn_worker(&slot, &inner, "panicky", || panic!("boom"))
3333            .unwrap();
3334        slot.lock().unwrap().take().unwrap().join().unwrap();
3335        assert_eq!(inner.state_name(), "failed");
3336        assert_eq!(
3337            inner.last_error().as_deref(),
3338            Some("worker thread panicked: boom")
3339        );
3340        assert!(!inner.worker_alive());
3341        assert_eq!(inner.capture_tid.load(Ordering::Acquire), 0);
3342    }
3343
3344    /// A LOSING start's failed-start cleanup must never tear down a WINNING start's
3345    /// freshly spawned thread.
3346    ///
3347    /// Drives the bad interleaving directly: loser L's worker fails, winner W takes the slot
3348    /// over (joins dead L, spawns live W) BEFORE L runs its `ST_FAILED` cleanup. That late
3349    /// `join_failed_start` must be an identity-mismatch no-op, leaving W running and still
3350    /// tear-down-able through the ordinary external stop path.
3351    #[test]
3352    fn failed_start_cleanup_spares_concurrent_winner() {
3353        let inner = Arc::new(Inner::new());
3354        let slot: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
3355
3356        let li = inner.clone();
3357        let l_id = spawn_worker(&slot, &inner, "loser", move || {
3358            li.started_ok.store(false, Ordering::Release);
3359            li.start_state.store(ST_FAILED, Ordering::Release);
3360        })
3361        .unwrap();
3362        while inner.start_state.load(Ordering::Acquire) != ST_FAILED {
3363            std::thread::yield_now();
3364        }
3365
3366        let wi = inner.clone();
3367        let w_id = spawn_worker(&slot, &inner, "winner", move || {
3368            wi.started_ok.store(true, Ordering::Release);
3369            wi.start_state.store(ST_RUNNING, Ordering::Release);
3370            while !wi.stop_pending() {
3371                std::thread::sleep(Duration::from_millis(1));
3372            }
3373            wi.started_ok.store(false, Ordering::Release);
3374        })
3375        .unwrap();
3376        assert_ne!(l_id, w_id);
3377        while inner.start_state.load(Ordering::Acquire) != ST_RUNNING {
3378            std::thread::yield_now();
3379        }
3380
3381        join_failed_start(&slot, &inner, l_id);
3382        assert!(slot.lock().unwrap().is_some(), "winner's handle must survive");
3383        assert_eq!(inner.stop_state.load(Ordering::Acquire), STOP_NONE);
3384        assert!(inner.worker_alive(), "winner must still be running");
3385
3386        {
3387            let mut g = slot.lock().unwrap();
3388            let h = g.take().expect("winner handle present");
3389            inner.request_external_stop();
3390            h.join().unwrap();
3391        }
3392        assert!(!inner.worker_alive());
3393    }
3394
3395    /// Two threads race the full start sequence (`spawn_worker` + handshake poll +
3396    /// conditional failed-start cleanup), 200 times.
3397    ///
3398    /// `spawn_worker` joins the prior worker before spawning, so the bodies run in spawn
3399    /// order: the first fails, the second serves until stopped. Whatever the interleaving,
3400    /// the second run must end `RUNNING` with no stray stop — a loser cleanup that killed the
3401    /// winner would leave an empty slot and a `STOP_EXTERNAL` behind.
3402    #[test]
3403    fn concurrent_start_loser_never_kills_winner() {
3404        for _ in 0..200 {
3405            let inner = Arc::new(Inner::new());
3406            let slot = Arc::new(Mutex::new(None::<JoinHandle<()>>));
3407            let runs = Arc::new(AtomicUsize::new(0));
3408
3409            let starter = |name: &'static str| {
3410                let inner = inner.clone();
3411                let slot = slot.clone();
3412                let runs = runs.clone();
3413                std::thread::spawn(move || {
3414                    let bi = inner.clone();
3415                    let bruns = runs.clone();
3416                    let id = spawn_worker(&slot, &inner, name, move || {
3417                        if bruns.fetch_add(1, Ordering::AcqRel) == 0 {
3418                            bi.started_ok.store(false, Ordering::Release);
3419                            bi.start_state.store(ST_FAILED, Ordering::Release);
3420                        } else {
3421                            bi.started_ok.store(true, Ordering::Release);
3422                            bi.start_state.store(ST_RUNNING, Ordering::Release);
3423                            let mut spins: u64 = 0;
3424                            while !bi.stop_pending() {
3425                                std::thread::sleep(Duration::from_millis(1));
3426                                spins += 1;
3427                                assert!(spins < 10_000, "external stop was lost");
3428                            }
3429                            bi.started_ok.store(false, Ordering::Release);
3430                        }
3431                    })
3432                    .unwrap();
3433                    let mut state = inner.start_state.load(Ordering::Acquire);
3434                    let mut tries = 0;
3435                    while state == ST_STARTING && tries < 2000 {
3436                        std::thread::sleep(Duration::from_millis(1));
3437                        state = inner.start_state.load(Ordering::Acquire);
3438                        tries += 1;
3439                    }
3440                    if state == ST_FAILED {
3441                        join_failed_start(&slot, &inner, id);
3442                    }
3443                })
3444            };
3445            let a = starter("start-a");
3446            let b = starter("start-b");
3447            a.join().unwrap();
3448            b.join().unwrap();
3449
3450            assert_eq!(runs.load(Ordering::Acquire), 2);
3451            assert!(slot.lock().unwrap().is_some(), "winner's thread must survive");
3452            assert_eq!(inner.start_state.load(Ordering::Acquire), ST_RUNNING);
3453            assert!(inner.worker_alive(), "winner must still be alive after both starts");
3454
3455            {
3456                let mut g = slot.lock().unwrap();
3457                let h = g.take().expect("winner handle present");
3458                inner.request_external_stop();
3459                h.join().unwrap();
3460            }
3461            assert!(!inner.worker_alive());
3462        }
3463    }
3464
3465    /// The in-place `write_ws_prefix_into` + appended primary is byte-identical to the
3466    /// copy-based `build_ws_body` reference across the full matrix — empty vs mixed history
3467    /// (including one block aged out of the 14-bit offset at high pts and one oversized block
3468    /// that is always skipped), every `red_distance`, header on/off, and several pts values.
3469    #[test]
3470    fn prefix_writer_matches_build_ws_body() {
3471        let primary: Vec<u8> = (0u8..200).collect();
3472        let mut mixed: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
3473        mixed.push_back((vec![1u8; 100], 0));
3474        mixed.push_back((vec![2u8; RED_MAX_LEN + 1], 960));
3475        mixed.push_back((vec![3u8; 50], 1440));
3476        mixed.push_back((vec![4u8; 900], 1900));
3477        for hist in [VecDeque::new(), mixed] {
3478            for red in [0usize, 1, 2, 4] {
3479                for hdr in [true, false] {
3480                    for pts in [960u64, 3840, 20000] {
3481                        let reference = build_ws_body(&primary, pts, &hist, red, hdr);
3482                        let mut buf = vec![0u8; RED_PREFIX_MAX + MAX_OPUS_PACKET];
3483                        let prefix = write_ws_prefix_into(&mut buf, pts, &hist, red, hdr);
3484                        buf[prefix..prefix + primary.len()].copy_from_slice(&primary);
3485                        buf.truncate(prefix + primary.len());
3486                        assert_eq!(buf, reference, "red={red} hdr={hdr} pts={pts}");
3487                    }
3488                }
3489            }
3490        }
3491    }
3492
3493    /// A truncated buffer returned to the pool comes back as the same allocation
3494    /// (recycled) restored to full `buf_size` length, and an undersized foreign buffer is
3495    /// rejected rather than pooled.
3496    #[test]
3497    fn buffer_pool_recycles_and_restores_length() {
3498        let pool = BufferPool::new(64);
3499        let mut a = pool.take();
3500        assert_eq!(a.len(), 64);
3501        let ptr = a.as_ptr() as usize;
3502        a.truncate(7);
3503        pool.put(a);
3504        let b = pool.take();
3505        assert_eq!(b.as_ptr() as usize, ptr, "buffer must be recycled");
3506        assert_eq!(b.len(), 64, "length must be restored");
3507        pool.put(Vec::new());
3508        assert_eq!(pool.take().len(), 64);
3509    }
3510
3511    /// Dropping an `AudioFrame` whose `pool` is set returns its buffer to that pool,
3512    /// so the next `take` hands back the same allocation.
3513    #[test]
3514    fn audio_frame_drop_refills_pool() {
3515        let pool = Arc::new(BufferPool::new(32));
3516        let buf = pool.take();
3517        let ptr = buf.as_ptr() as usize;
3518        drop(AudioFrame { data: buf, pts: 0, pool: Some(Arc::clone(&pool)) });
3519        let recycled = pool.take();
3520        assert_eq!(recycled.as_ptr() as usize, ptr);
3521    }
3522
3523    /// Micro-benchmark (not a correctness test) isolating the emit-path assembly cost:
3524    /// the copy-based `build_ws_body` + per-frame alloc versus the pooled in-place prefix,
3525    /// with the encoder stubbed to a memcpy.
3526    ///
3527    /// `#[ignore]`d by default; run with
3528    /// `cargo test --release bench_emit_assembly -- --ignored --nocapture`. The pooled arm
3529    /// circulates buffers at the delivery-ring depth (>8 in flight) so refill batching engages
3530    /// as it does live — several returns per drain, not one.
3531    #[test]
3532    #[ignore]
3533    fn bench_emit_assembly() {
3534        use std::hint::black_box;
3535        use std::time::Instant;
3536
3537        const ITERS: u32 = 500_000;
3538        const PAYLOAD: usize = 200;
3539        let src = vec![0xA5u8; PAYLOAD];
3540        let mut hist: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
3541        hist.push_back((vec![6u8; 180], 0));
3542        hist.push_back((vec![7u8; 180], 480));
3543
3544        for (red, label) in [(0usize, "red=0"), (2usize, "red=2")] {
3545            let old = Instant::now();
3546            let mut out = vec![0u8; MAX_OPUS_PACKET];
3547            for i in 0..ITERS {
3548                out[..PAYLOAD].copy_from_slice(&src);
3549                let data = build_ws_body(&out[..PAYLOAD], 960 + i as u64, &hist, red, true);
3550                black_box(&data);
3551            }
3552            let old_ns = old.elapsed().as_nanos() / ITERS as u128;
3553
3554            let pool = Arc::new(BufferPool::new(RED_PREFIX_MAX + MAX_OPUS_PACKET));
3555            let mut taker = PoolTaker::new(Arc::clone(&pool));
3556            let mut inflight: VecDeque<Vec<u8>> = VecDeque::new();
3557            let new = Instant::now();
3558            for i in 0..ITERS {
3559                let mut data = taker.take();
3560                let prefix =
3561                    write_ws_prefix_into(&mut data, 960 + i as u64, &hist, red, true);
3562                data[prefix..prefix + PAYLOAD].copy_from_slice(&src);
3563                data.truncate(prefix + PAYLOAD);
3564                black_box(&data);
3565                inflight.push_back(data);
3566                if inflight.len() > 8 {
3567                    pool.put(inflight.pop_front().unwrap());
3568                }
3569            }
3570            let new_ns = new.elapsed().as_nanos() / ITERS as u128;
3571            println!("{label}: old {old_ns} ns/frame -> pooled in-place {new_ns} ns/frame");
3572        }
3573    }
3574}
3575
3576/// PyO3 module init: register the capture/playback classes and the atexit sweep.
3577///
3578/// Exposes `AudioCapture`, `AudioCaptureSettings`, `AudioFrame`, `AudioPlayback`, and
3579/// `AudioPlaybackSettings`, then registers `_stop_all_captures` on Python's `atexit` so no
3580/// still-running capture thread is calling into Python during interpreter finalization.
3581#[pymodule]
3582fn pcmflux(m: &Bound<'_, PyModule>) -> PyResult<()> {
3583    m.add_class::<AudioCapture>()?;
3584    m.add_class::<AudioCaptureSettings>()?;
3585    m.add_class::<AudioFrame>()?;
3586    m.add_class::<AudioPlayback>()?;
3587    m.add_class::<AudioPlaybackSettings>()?;
3588    m.add_function(wrap_pyfunction!(_stop_all_captures, m)?)?;
3589    if let Ok(atexit) = m.py().import("atexit") {
3590        let _ = atexit.call_method1("register", (m.getattr("_stop_all_captures")?,));
3591    }
3592    Ok(())
3593}