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}