From 2c7ded0f3c26d07f4b58aec24ef2f050b035b5e0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 3 Jul 2026 20:41:19 +0000 Subject: [PATCH] =?UTF-8?q?fix(host/audio):=20rebuild=20mic=20passthrough?= =?UTF-8?q?=20=E2=80=94=20eager,=20self-healing=20virtual=20mic=20on=20bot?= =?UTF-8?q?h=20hosts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mic passthrough silently died on real hosts. Root causes, all fixed: - No liveness anywhere: a PipeWire restart (Linux) or any WASAPI device error (Windows) killed the backend worker; push() fed the dead queue for the rest of the host's life. VirtualMic now has a liveness contract (push -> bool, alive(), discard()) and the new shared audio::MicPump reopens with backoff, probing on an idle heartbeat so the mic heals BETWEEN sessions too. Validated live: systemctl restart pipewire -> node back in ~0.5 s, tone flows through the reopened backend. - Lazy creation: the mic device didn't exist until the first 0xCB frame, but games bind their capture device at launch and never re-follow. The pump opens eagerly at host start (node exists with zero clients, elected default source). - Windows headless dead-end: with VB-CABLE as the ONLY render endpoint (exactly what the installer ships), the anti-echo guard rejected the cable as the default render endpoint -> mic permanently dead. The new wiring_plan (pure, unit-tested on every platform) assigns the mic its endpoint FIRST (cable reserved for the mic), points the loopback at a DIFFERENT endpoint, and the capture side now yields (explicit endpoint or honest error) instead of the mic dying. Plan recomputed per (re)open — endpoints churn at boot/logon/driver installs. - Stale bursts: buffered audio from a previous session played into a newly-attached recorder (observed live). Timestamped chunks + a consumer-gap check in the process callback age everything past 1 s. The Linux node mechanism stays the stream-based Audio/Source with RT_PROCESS + priority.session: the canonical null-audio-sink adapter recipe was tested on this box (PipeWire 1.6.2) and never gets a clock (QUANT 0 -> pure silence), and WirePlumber reroutes a feeder targeting it to the default sink (echo). Decision documented in the module docs. Live-validated on this box (synthetic host + probe --mic-test, pw-record): eager node, both attach orderings, PipeWire-restart self-heal, post-session silence. Windows side compile/CI + on-glass validation pending. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/audio.rs | 397 +++++++++++++++++- crates/punktfunk-host/src/audio/linux/mod.rs | 123 +++++- .../src/audio/windows/audio_control.rs | 187 ++++----- .../src/audio/windows/wasapi_cap.rs | 34 +- .../src/audio/windows/wasapi_mic.rs | 200 +++------ .../punktfunk-host/src/audio/wiring_plan.rs | 274 ++++++++++++ crates/punktfunk-host/src/punktfunk1.rs | 125 +----- 7 files changed, 969 insertions(+), 371 deletions(-) create mode 100644 crates/punktfunk-host/src/audio/wiring_plan.rs diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index e2fea2c..3d8853b 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -42,7 +42,8 @@ pub fn open_audio_capture(channels: u32) -> Result> { #[cfg(target_os = "windows")] pub fn open_audio_capture(channels: u32) -> Result> { - audio_control::ensure_wired_once(); + // The capture thread runs the audio wiring plan itself (audio_control::wire_now) before + // resolving its endpoint — a fresh plan per open, because Windows endpoints churn. wasapi_cap::WasapiLoopbackCapturer::open(channels) .map(|c| Box::new(c) as Box) } @@ -57,10 +58,27 @@ pub fn open_audio_capture(_channels: u32) -> Result> { /// decoded client-mic PCM (interleaved `f32` at [`SAMPLE_RATE`]) into it, and PipeWire delivers /// it to whichever app records the source — silence when no input is flowing. This is how the /// client's microphone reaches host applications (mic passthrough). +/// +/// **Liveness contract.** Both backends run a worker thread that CAN die under the host's feet +/// (Linux: the PipeWire daemon restarts with the session; Windows: the audio endpoint is +/// invalidated/removed). A dead backend must be observable — [`push`](Self::push) returns `false` +/// and [`alive`](Self::alive) turns false — so the owning [`MicPump`] drops the instance and +/// reopens. Before this contract existed, a single backend death left `push` feeding a dead +/// queue for the rest of the host's life: the historical "mic passthrough works on no host" bug. pub trait VirtualMic: Send { - /// Push one chunk of interleaved `f32` PCM. Non-blocking — drops if PipeWire is behind - /// (mic audio is lossy/real-time; a stale chunk is worse than a dropped one). - fn push(&self, pcm: &[f32]); + /// Push one chunk of interleaved `f32` PCM. Non-blocking — drops if the backend is behind + /// (mic audio is lossy/real-time; a stale chunk is worse than a dropped one). Returns + /// `false` iff the backend is DEAD (worker thread gone) — the caller must reopen; a merely + /// congested backend drops the chunk and returns `true`. + fn push(&self, pcm: &[f32]) -> bool; + + /// Backend liveness without pushing data — lets an idle pump notice a death between + /// sessions, so the mic is already healthy again when the next client connects. + fn alive(&self) -> bool; + + /// Drop any buffered-but-unplayed audio. Called after an uplink gap (client muted, + /// session ended) so a recorder never hears a stale burst when audio resumes. + fn discard(&self); /// The interleaved channel count the source was opened with. fn channels(&self) -> u32 { @@ -78,7 +96,8 @@ pub fn open_virtual_mic(channels: u32) -> Result> { #[cfg(target_os = "windows")] pub fn open_virtual_mic(channels: u32) -> Result> { - audio_control::ensure_wired_once(); + // The render thread runs the wiring plan itself (audio_control::wire_now) to resolve — and, + // via the plan's default-device changes, to RESERVE — its target endpoint. wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box) } @@ -87,6 +106,189 @@ pub fn open_virtual_mic(_channels: u32) -> Result> { anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device") } +/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout. +pub const MIC_CHANNELS: u32 = 2; +/// Bound for the shared mic frame queue (drop-newest when full): the host-lifetime queue is +/// shared across all concurrent sessions and must not grow without limit under a near-line-rate +/// flood (security-review 2026-06-28 S6). 64 × 5–20 ms frames ≈ 0.3–1.3 s of slack. +const MIC_QUEUE_CAP: usize = 64; + +/// Tuning for [`MicPump`]'s open/reopen/flush behaviour — parameterized so the tests can run the +/// real pump loop in milliseconds instead of seconds. +#[derive(Clone, Copy)] +struct PumpTuning { + /// First-retry delay after a failed backend open; doubles per failure up to `backoff_cap` + /// (a persistently-absent PipeWire session / audio endpoint isn't hammered), resets on + /// success. + backoff_start: std::time::Duration, + backoff_cap: std::time::Duration, + /// Idle liveness-probe interval: with no frames flowing, the pump still notices a dead + /// backend this often and reopens — so the mic is healthy BEFORE the next session starts. + heartbeat: std::time::Duration, + /// An uplink gap longer than this discards the backend's buffered audio before pushing the + /// next frame (a recorder must never hear a stale burst from before a mute/session end). + stale_gap: std::time::Duration, +} + +const PUMP_TUNING: PumpTuning = PumpTuning { + backoff_start: std::time::Duration::from_secs(2), + backoff_cap: std::time::Duration::from_secs(60), + heartbeat: std::time::Duration::from_secs(1), + stale_gap: std::time::Duration::from_millis(600), +}; + +/// Host-lifetime virtual-microphone pump: one thread owns the [`VirtualMic`] backend + an Opus +/// decoder; sessions forward the client's Opus mic frames (0xCB) over a clonable `Send` sender, +/// the thread decodes and feeds the backend. +/// +/// The rock-solid properties live HERE, not in the backends: +/// - **Eager**: the backend opens at host start (retrying with backoff), NOT on the first mic +/// frame — so the virtual mic device already exists when host apps/games launch and bind +/// their capture device (most games never re-follow a default-device change mid-run). +/// - **Self-healing**: a dead backend (PipeWire restart, Windows endpoint churn) is detected on +/// every push and on an idle heartbeat, and reopened with backoff. Sessions keep their +/// senders; nothing upstream notices. +/// - **Stale-flush**: buffered audio is discarded after an uplink gap (see [`PumpTuning`]). +/// +/// Per-frame Opus DECODE errors stay non-fatal (dropped frame): the mic is shared across every +/// concurrent session, so one paired client's junk frames must not deny everyone's mic +/// (security-review 2026-06-28 S2). The thread exits when every sender is dropped (host +/// shutdown), tearing the backend down. +pub struct MicPump { + tx: std::sync::mpsc::SyncSender>, +} + +impl MicPump { + /// Start the host-lifetime pump (Linux/Windows). On platforms without a virtual-mic backend + /// the thread just drains and drops frames (sessions still count the datagrams). + pub fn start() -> MicPump { + let (tx, rx) = std::sync::mpsc::sync_channel::>(MIC_QUEUE_CAP); + let spawned = std::thread::Builder::new() + .name("punktfunk-mic-pump".into()) + .spawn(move || { + #[cfg(any(target_os = "linux", target_os = "windows"))] + pump_thread(rx, || open_virtual_mic(MIC_CHANNELS), PUMP_TUNING); + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + tracing::warn!("mic passthrough unsupported on this platform — frames dropped"); + for _ in rx {} + } + }); + if let Err(e) = spawned { + tracing::error!(error = %e, "mic pump thread spawn failed — mic passthrough disabled"); + } + MicPump { tx } + } + + /// A sender a session forwards the client's Opus mic frames to (`try_send` — never block a + /// datagram loop). Cloned per session; dropping a clone does NOT stop the pump (it holds + /// the original sender for the host life). + pub fn sender(&self) -> std::sync::mpsc::SyncSender> { + self.tx.clone() + } +} + +/// The pump loop. `opener` is injected so the tests can run the REAL loop against a mock +/// backend; production passes [`open_virtual_mic`]. +#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))] +fn pump_thread(rx: std::sync::mpsc::Receiver>, opener: O, tuning: PumpTuning) +where + O: Fn() -> Result>, +{ + use std::sync::mpsc::RecvTimeoutError; + use std::time::Instant; + + let mut backoff = tuning.backoff_start; + let mut open_fails: u64 = 0; + 'reopen: loop { + // Open phase — eager, from thread start. While closed, keep draining the queue so a + // reopen never replays a backlog of stale frames (and senders never see a wedged queue). + let (mic, mut decoder) = loop { + let opened = opener().and_then(|m| { + let d = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo) + .map_err(|e| anyhow::anyhow!("opus decoder: {e}"))?; + Ok((m, d)) + }); + match opened { + Ok(pair) => break pair, + Err(e) => { + // Throttle (1st, 2nd, 4th, 8th … failure): a box without a PipeWire session + // or virtual audio device would otherwise log every backoff forever. + open_fails += 1; + if open_fails.is_power_of_two() { + tracing::warn!(error = %format!("{e:#}"), attempts = open_fails, + "virtual mic unavailable — retrying with backoff"); + } + let deadline = Instant::now() + backoff; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + match rx.recv_timeout(left.min(std::time::Duration::from_millis(250))) { + Ok(_) => {} // drop frames while closed + Err(RecvTimeoutError::Timeout) => {} // keep waiting + Err(RecvTimeoutError::Disconnected) => return, // host shutdown + } + } + backoff = (backoff * 2).min(tuning.backoff_cap); + } + } + }; + backoff = tuning.backoff_start; + open_fails = 0; + tracing::info!("virtual mic ready (host-lifetime)"); + // Drop anything queued while (re)opening — it predates the backend. + while rx.try_recv().is_ok() {} + + let mut decode_fails: u64 = 0; + let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch + let mut last_push = Instant::now(); + loop { + match rx.recv_timeout(tuning.heartbeat) { + Ok(frame) => { + if frame.is_empty() { + continue; // DTX silence — the source underruns to silence on its own + } + if last_push.elapsed() > tuning.stale_gap { + mic.discard(); + } + match decoder.decode_float(&frame, &mut pcm, false) { + Ok(samples_per_ch) => { + let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len()); + if !mic.push(&pcm[..total]) { + tracing::warn!("virtual mic backend died — reopening"); + continue 'reopen; + } + last_push = Instant::now(); + decode_fails = 0; + } + Err(e) => { + // Malformed/garbage frame: drop it, keep the shared mic + decoder + // (see the struct docs). Throttled log (1, 2, 4, … fails). + decode_fails += 1; + if decode_fails.is_power_of_two() { + tracing::warn!(error = %e, fails = decode_fails, + "mic opus decode failed — dropping frame"); + } + } + } + } + Err(RecvTimeoutError::Timeout) => { + if !mic.alive() { + tracing::warn!("virtual mic backend died while idle — reopening"); + continue 'reopen; + } + } + Err(RecvTimeoutError::Disconnected) => { + tracing::debug!("mic pump stopped (host shutting down)"); + return; + } + } + } + } +} + #[cfg(target_os = "windows")] #[path = "audio/windows/audio_control.rs"] mod audio_control; @@ -98,3 +300,188 @@ mod wasapi_cap; #[cfg(target_os = "windows")] #[path = "audio/windows/wasapi_mic.rs"] mod wasapi_mic; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +#[path = "audio/wiring_plan.rs"] +pub(crate) mod wiring_plan; + +#[cfg(test)] +mod pump_tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + /// Mock backend: records pushes/discards, dies on command. + struct MockMic { + alive: Arc, + pushed: Arc, + discards: Arc, + } + impl VirtualMic for MockMic { + fn push(&self, pcm: &[f32]) -> bool { + if !self.alive.load(Ordering::Acquire) { + return false; + } + self.pushed.fetch_add(pcm.len(), Ordering::Relaxed); + true + } + fn alive(&self) -> bool { + self.alive.load(Ordering::Acquire) + } + fn discard(&self) { + self.discards.fetch_add(1, Ordering::Relaxed); + } + } + + struct Harness { + tx: std::sync::mpsc::SyncSender>, + opens: Arc, + alive: Arc>>>, // latest instance's kill switch + pushed: Arc, + discards: Arc, + join: std::thread::JoinHandle<()>, + } + + /// Run the REAL pump loop against mock backends; `fail_first` opens fail before the first + /// success (exercises the eager retry/backoff path). + fn start(fail_first: usize) -> Harness { + let (tx, rx) = std::sync::mpsc::sync_channel::>(MIC_QUEUE_CAP); + let opens = Arc::new(AtomicUsize::new(0)); + let alive = Arc::new(Mutex::new(None::>)); + let pushed = Arc::new(AtomicUsize::new(0)); + let discards = Arc::new(AtomicUsize::new(0)); + let (opens2, alive2, pushed2, discards2) = ( + opens.clone(), + alive.clone(), + pushed.clone(), + discards.clone(), + ); + let tuning = PumpTuning { + backoff_start: Duration::from_millis(10), + backoff_cap: Duration::from_millis(40), + heartbeat: Duration::from_millis(20), + stale_gap: Duration::from_millis(80), + }; + let join = std::thread::spawn(move || { + pump_thread( + rx, + move || { + let n = opens2.fetch_add(1, Ordering::SeqCst); + if n < fail_first { + anyhow::bail!("backend not up yet (simulated)"); + } + let a = Arc::new(AtomicBool::new(true)); + *alive2.lock().unwrap() = Some(a.clone()); + Ok(Box::new(MockMic { + alive: a, + pushed: pushed2.clone(), + discards: discards2.clone(), + }) as Box) + }, + tuning, + ) + }); + Harness { + tx, + opens, + alive, + pushed, + discards, + join, + } + } + + fn wait_until(what: &str, mut cond: impl FnMut() -> bool) { + for _ in 0..200 { + if cond() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("timed out waiting for: {what}"); + } + + fn opus_frame() -> Vec { + let mut enc = opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip) + .expect("opus encoder"); + let pcm = [0.1f32; 960 * 2]; // 20 ms stereo + let mut out = vec![0u8; 4000]; + let n = enc.encode_float(&pcm, &mut out).expect("encode"); + out.truncate(n); + out + } + + /// Eager: the backend opens (after transient failures) with NO frame ever sent. + #[test] + fn opens_eagerly_with_backoff() { + let h = start(3); + wait_until("eager open after 3 failures", || { + h.opens.load(Ordering::SeqCst) >= 4 && h.alive.lock().unwrap().is_some() + }); + drop(h.tx); + h.join.join().unwrap(); + } + + /// Frames flow: opus in → PCM pushed to the backend. + #[test] + fn decodes_and_pushes() { + let h = start(0); + wait_until("open", || h.alive.lock().unwrap().is_some()); + h.tx.send(opus_frame()).unwrap(); + wait_until("pcm pushed", || h.pushed.load(Ordering::SeqCst) > 0); + drop(h.tx); + h.join.join().unwrap(); + } + + /// A dead backend is noticed WHILE IDLE (heartbeat) and reopened without any traffic. + #[test] + fn reopens_after_idle_death() { + let h = start(0); + wait_until("first open", || h.opens.load(Ordering::SeqCst) >= 1); + wait_until("instance", || h.alive.lock().unwrap().is_some()); + h.alive + .lock() + .unwrap() + .as_ref() + .unwrap() + .store(false, Ordering::Release); // kill it + wait_until("reopen after idle death", || { + h.opens.load(Ordering::SeqCst) >= 2 + }); + drop(h.tx); + h.join.join().unwrap(); + } + + /// A death detected on push (frame flowing) also reopens, and the frame after reopen flows. + #[test] + fn reopens_after_push_death() { + let h = start(0); + wait_until("instance", || h.alive.lock().unwrap().is_some()); + h.alive + .lock() + .unwrap() + .as_ref() + .unwrap() + .store(false, Ordering::Release); + h.tx.send(opus_frame()).unwrap(); // push sees death → reopen + wait_until("reopen", || h.opens.load(Ordering::SeqCst) >= 2); + h.tx.send(opus_frame()).unwrap(); + wait_until("pcm after reopen", || h.pushed.load(Ordering::SeqCst) > 0); + drop(h.tx); + h.join.join().unwrap(); + } + + /// An uplink gap discards buffered-stale audio before the next frame plays. + #[test] + fn discards_after_gap() { + let h = start(0); + wait_until("instance", || h.alive.lock().unwrap().is_some()); + h.tx.send(opus_frame()).unwrap(); + wait_until("first push", || h.pushed.load(Ordering::SeqCst) > 0); + std::thread::sleep(Duration::from_millis(150)); // > stale_gap + h.tx.send(opus_frame()).unwrap(); + wait_until("discard on gap", || h.discards.load(Ordering::SeqCst) >= 1); + drop(h.tx); + h.join.join().unwrap(); + } +} diff --git a/crates/punktfunk-host/src/audio/linux/mod.rs b/crates/punktfunk-host/src/audio/linux/mod.rs index cd02897..a9fccd7 100644 --- a/crates/punktfunk-host/src/audio/linux/mod.rs +++ b/crates/punktfunk-host/src/audio/linux/mod.rs @@ -16,7 +16,9 @@ use super::{AudioCapturer, VirtualMic, SAMPLE_RATE}; use anyhow::{anyhow, Context, Result}; use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError}; +use std::sync::Arc; use std::thread; use std::time::Duration; @@ -111,10 +113,28 @@ fn spa_positions(channels: u32) -> [u32; 64] { /// Virtual microphone: a PipeWire `Audio/Source` node host apps can record from. The host pushes /// decoded client-mic PCM in; the loop thread's producer callback drains it (silence on /// underrun) into PipeWire buffers. Mirrors [`PwAudioCapturer`] but inverted (Direction::Output). +/// +/// **Why a stream node and not a `support.null-audio-sink` adapter** (the canonical +/// virtual-mic recipe): tested live on this project's headless graph (PipeWire 1.6.2, +/// 2026-07-03), an adapter with `media.class=Audio/Source/Virtual` never gets a clock — the +/// {source, recorder} group runs with QUANT/RATE 0 and delivers pure silence — and WirePlumber +/// rerouted a feeder stream targeting it to the *default sink* instead (which would play the +/// client's voice out of the speakers, straight into the desktop-audio capture: echo). The +/// stream node below, with `RT_PROCESS` + `priority.session` (see the property comments), is +/// validated working on PipeWire 1.4 (Bazzite) and 1.6 (this box) in both attach orderings. +/// Do not "modernize" this to the adapter recipe without re-running that validation. +/// +/// **Liveness contract** (see [`VirtualMic`]): the loop thread exits on a core error (PipeWire +/// daemon restart — the node is gone) or a stream error, which flips `alive` — `push` then +/// returns `false` and the owning pump reopens against the new daemon, recreating the node. pub struct PwMicSource { - pcm: std::sync::mpsc::SyncSender>, + pcm: std::sync::mpsc::SyncSender<(std::time::Instant, Vec)>, channels: u32, quit: pipewire::channel::Sender, + /// False once the loop thread has exited (daemon/stream death or teardown). + alive: Arc, + /// One-shot flush request, consumed by the process callback (clears the jitter ring). + flush: Arc, } impl PwMicSource { @@ -123,20 +143,27 @@ impl PwMicSource { matches!(channels, 1 | 2), "virtual mic supports 1 or 2 channels, got {channels}" ); - let (pcm_tx, pcm_rx) = sync_channel::>(64); + let (pcm_tx, pcm_rx) = sync_channel::<(std::time::Instant, Vec)>(64); let (quit_tx, quit_rx) = pipewire::channel::channel::(); + let alive = Arc::new(AtomicBool::new(true)); + let flush = Arc::new(AtomicBool::new(false)); + let (alive_t, flush_t) = (alive.clone(), flush.clone()); thread::Builder::new() .name("punktfunk-pw-mic".into()) .spawn(move || { - if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels) { + if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels, flush_t) { tracing::error!(error = %format!("{e:#}"), "pipewire virtual-mic thread failed"); } + // Whether a clean quit or a daemon death: this instance is done — the pump reopens. + alive_t.store(false, Ordering::Release); }) .context("spawn pipewire virtual-mic thread")?; Ok(PwMicSource { pcm: pcm_tx, channels, quit: quit_tx, + alive, + flush, }) } } @@ -148,8 +175,24 @@ impl Drop for PwMicSource { } impl VirtualMic for PwMicSource { - fn push(&self, pcm: &[f32]) { - let _ = self.pcm.try_send(pcm.to_vec()); // drop if the PipeWire side is behind + fn push(&self, pcm: &[f32]) -> bool { + if !self.alive.load(Ordering::Acquire) { + return false; + } + // Timestamped so the process callback can age out chunks that sat in the channel while + // no recorder was attached (see the staleness logic there). + match self.pcm.try_send((std::time::Instant::now(), pcm.to_vec())) { + Ok(()) => true, + // Behind is fine (drop the chunk); a gone receiver means the loop thread exited. + Err(std::sync::mpsc::TrySendError::Full(_)) => true, + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false, + } + } + fn alive(&self) -> bool { + self.alive.load(Ordering::Acquire) + } + fn discard(&self) { + self.flush.store(true, Ordering::Release); } fn channels(&self) -> u32 { self.channels @@ -160,16 +203,27 @@ impl VirtualMic for PwMicSource { /// the process callback drains into PipeWire buffers (capped, so latency stays bounded). /// `primed` is a jitter buffer gate — see the process callback. struct MicUserData { - rx: Receiver>, + rx: Receiver<(std::time::Instant, Vec)>, ring: VecDeque, channels: usize, primed: bool, + /// One-shot flush request from [`PwMicSource::discard`] (stale-audio drop after a gap). + flush: Arc, + /// When the process callback last ran — a long gap means the ring content predates the + /// current consumer (the stream idles with no recorder attached) and must be dropped. + last_run: Option, } +/// PCM older than this never reaches a recorder: chunks that aged in the channel while no +/// recorder was attached, and ring content from before a consumer gap, are dropped instead of +/// bursting out as stale audio when recording (re)starts. +const MIC_STALE: Duration = Duration::from_secs(1); + fn mic_pw_thread( - pcm_rx: Receiver>, + pcm_rx: Receiver<(std::time::Instant, Vec)>, quit_rx: pipewire::channel::Receiver, channels: u32, + flush: Arc, ) -> Result<()> { use pipewire as pw; use pw::{properties::properties, spa}; @@ -188,6 +242,26 @@ fn mic_pw_thread( move |_| mainloop.quit() }); + // Death detection: a core error (the daemon restarted/went away — our remote node no longer + // exists) ends this thread, flipping the owner's `alive` flag so the pump reopens against the + // new daemon. Without this, a PipeWire restart left the loop idling on a dead connection and + // the mic silently broken for the rest of the host's life. + let _core_listener = core + .add_listener_local() + .error({ + let mainloop = mainloop.clone(); + move |id, _seq, res, message| { + tracing::warn!( + id, + res, + message, + "pipewire core error — virtual mic reopening" + ); + mainloop.quit(); + } + }) + .register(); + // media.class=Audio/Source advertises us as a microphone (a recordable source), NOT a // playback stream — without it, Direction::Output + Playback would route to the speakers. let stream = pw::stream::StreamBox::new( @@ -226,12 +300,21 @@ fn mic_pw_thread( ring: VecDeque::new(), channels: channels as usize, primed: false, + flush, + last_run: None, }; let _listener = stream .add_local_listener_with_user_data(ud) - .state_changed(|_s, _ud, old, new| { - tracing::info!(?old, ?new, "pipewire virtual-mic stream state"); + .state_changed({ + let mainloop = mainloop.clone(); + move |_s, _ud, old, new| { + tracing::info!(?old, ?new, "pipewire virtual-mic stream state"); + // A stream error is unrecoverable for this instance — exit so the pump reopens. + if matches!(new, pw::stream::StreamState::Error(_)) { + mainloop.quit(); + } + } }) .param_changed(|_s, _ud, id, param| { let Some(param) = param else { return }; @@ -253,9 +336,25 @@ fn mic_pw_thread( let Some(mut buffer) = stream.dequeue_buffer() else { return; }; - // Pull all newly-decoded PCM into the ring. - while let Ok(frame) = ud.rx.try_recv() { - ud.ring.extend(frame); + // Stale-audio guard, BEFORE pulling new frames: drop the ring when a flush was + // requested (uplink gap — see the pump) or when this callback itself hasn't run + // for a while (the stream idled with no recorder attached; whatever the ring + // holds predates the consumer). A recorder must never hear a burst of old audio. + let now = std::time::Instant::now(); + let idled = ud + .last_run + .is_some_and(|t| now.duration_since(t) > MIC_STALE); + if ud.flush.swap(false, std::sync::atomic::Ordering::AcqRel) || idled { + ud.ring.clear(); + ud.primed = false; + } + ud.last_run = Some(now); + // Pull all newly-decoded PCM into the ring, aging out chunks that sat in the + // channel while nothing consumed them (same staleness rule). + while let Ok((t, frame)) = ud.rx.try_recv() { + if now.duration_since(t) <= MIC_STALE { + ud.ring.extend(frame); + } } let stride = 4 * ud.channels; // F32LE interleaved let datas = buffer.datas_mut(); diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index a92de34..92e7a3a 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -6,64 +6,39 @@ //! ones, or the loopback re-captures the injected mic (an infinite echo). The installer bundles //! VB-Audio Virtual Cable (the mic target: its "CABLE Input" render endpoint → "CABLE Output" capture) //! and the host auto-installs the Steam Streaming pair (a loopback-capable render). This module wires -//! them up at startup so no manual Sound-settings fiddling is ever needed: +//! them up so no manual Sound-settings fiddling is ever needed: //! -//! * default **PLAYBACK** → a loopback-capable render that is NOT the mic cable (a real output device +//! * the **mic inject target** is assigned FIRST (VB-Cable "CABLE Input" preferred) — mic passthrough +//! is what the cable is bundled for, so it wins the cable even when the cable is the only render +//! endpoint on the box (the loopback then reports itself unavailable instead of echoing); +//! * default **PLAYBACK** → a loopback-capable render that is NOT the mic target (a real output device //! if one exists, else the Steam Streaming Microphone; **never** the Steam Streaming Speakers, whose -//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] loopback-captures -//! for desktop audio. -//! * default **RECORDING** → the virtual mic's capture endpoint (VB-Cable "CABLE Output") so host apps +//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] captures; +//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps //! record the client's mic by default. //! -//! [`super::wasapi_mic::find_device`] then resolves the mic INJECT target to "CABLE Input" — a render -//! candidate that is NOT the default playback — guaranteeing loopback ≠ mic, so there is no echo. +//! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every +//! platform); this module only enumerates endpoints, applies the plan, and logs. [`wire_now`] runs on +//! every mic/capture (re)open — NOT once per process — because endpoints churn (boot-time +//! registration, hotplug, driver installs) and a stale plan was one of the ways mic passthrough died +//! permanently. //! //! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set //! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the //! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen -//! defaults untouched. +//! defaults untouched (the plan is still computed — the mic must still pick a target). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it. #![deny(clippy::undocumented_unsafe_blocks)] +use super::wiring_plan::{plan, Endpoint, Wiring}; use anyhow::{anyhow, bail, Result}; use std::ffi::c_void; -use std::sync::Once; +use std::sync::Mutex; use wasapi::Direction; -/// Run the audio device auto-wiring exactly once per process, before the first capturer/mic opens. -/// Blocks until done so the default playback is set before the loopback captures it. Best-effort: -/// every failure is logged, never fatal (the host then falls back to whatever the current defaults -/// are — exactly the pre-wiring behaviour). -pub(crate) fn ensure_wired_once() { - static WIRED: Once = Once::new(); - WIRED.call_once(|| { - if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() { - tracing::info!("PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"); - return; - } - // Run on a dedicated COM-MTA thread so we never collide with the caller's apartment mode - // (the capture/mic threads each initialize their own COM separately). - let handle = std::thread::Builder::new() - .name("pf-audio-wiring".into()) - .spawn(|| { - if wasapi::initialize_mta().ok().is_err() { - tracing::warn!("audio wiring: COM init (MTA) failed — skipping"); - return; - } - if let Err(e) = ensure_audio_wiring() { - tracing::warn!(error = %format!("{e:#}"), - "audio auto-wiring failed — mic/desktop audio may need manual device defaults"); - } - }); - if let Ok(h) = handle { - let _ = h.join(); - } - }); -} - /// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`. -fn list_endpoints(dir: Direction) -> Vec<(String, String)> { +fn list_endpoints(dir: Direction) -> Vec { let mut out = Vec::new(); let Ok(en) = wasapi::DeviceEnumerator::new() else { return out; @@ -86,79 +61,85 @@ fn list_endpoints(dir: Direction) -> Vec<(String, String)> { out } -/// Pick the loopback + mic-capture devices and set them as the default playback/recording. -fn ensure_audio_wiring() -> Result<()> { +/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless +/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback +/// echo guard). Must run on a COM-initialized thread (the WASAPI worker threads all +/// `initialize_mta` first). Logged only when the assignment changes, so per-open recomputation +/// stays quiet in the steady state. +pub(crate) fn wire_now() -> Wiring { let renders = list_endpoints(Direction::Render); let captures = list_endpoints(Direction::Capture); - if renders.is_empty() { - bail!("no active render endpoints to wire"); - } + let want = std::env::var("PUNKTFUNK_MIC_DEVICE") + .ok() + .map(|s| s.to_lowercase()); + let wiring = plan(&renders, &captures, want.as_deref()); - // A render is unusable as the desktop-audio loopback if it is a VB-Cable endpoint (reserved for - // the mic inject) or the Steam Streaming Speakers (its loopback is silent — validated live). - let excluded_loopback = - |ln: &str| ln.contains("cable") || ln.contains("steam streaming speakers"); - // "virtual-ish" = a known virtual cable; a render WITHOUT these markers is a real output device, - // the best loopback source (apps render there and the operator can also hear it). - let virtualish = |ln: &str| { - ln.contains("virtual") - || ln.contains("cable") - || ln.contains("steam streaming") - || ln.contains("voicemeeter") + // Log assignment changes exactly once (first plan included). + static LAST: Mutex> = Mutex::new(None); + let changed = { + let mut last = LAST.lock().unwrap(); + let changed = last.as_ref() != Some(&wiring); + *last = Some(wiring.clone()); + changed }; - let loopback = renders - .iter() - .find(|(n, _)| { - let ln = n.to_lowercase(); - !excluded_loopback(&ln) && !virtualish(&ln) - }) - .or_else(|| { - renders - .iter() - .find(|(n, _)| n.to_lowercase().contains("steam streaming microphone")) - }) - .or_else(|| { - renders - .iter() - .find(|(n, _)| !excluded_loopback(&n.to_lowercase())) - }); - - // The virtual mic's CAPTURE endpoint host apps record from — VB-Cable "CABLE Output" preferred. - let mic_capture = captures - .iter() - .find(|(n, _)| n.to_lowercase().contains("cable output")) - .or_else(|| { - captures - .iter() - .find(|(n, _)| n.to_lowercase().contains("steam streaming microphone")) - }) - .or_else(|| { - captures.iter().find(|(n, _)| { - let ln = n.to_lowercase(); - ln.contains("voicemeeter") || ln.contains("virtual") - }) - }); - - match loopback { - Some((name, id)) => match set_default_endpoint(id) { - Ok(()) => tracing::info!(device = %name, - "audio wiring: default playback = desktop-audio loopback source"), - Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), - "audio wiring: failed to set the default playback device"), - }, - None => { - tracing::warn!("audio wiring: no usable desktop-audio loopback render endpoint found") + if changed { + tracing::info!( + mic_render = wiring.mic_render.as_ref().map(|(n, _)| n.as_str()), + mic_capture = wiring.mic_capture.as_ref().map(|(n, _)| n.as_str()), + loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()), + renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::>(), + "audio wiring plan" + ); + if wiring.mic_render.is_some() && wiring.loopback_render.is_none() { + tracing::warn!( + "the virtual mic reserved the only usable render endpoint — desktop audio will be \ + unavailable until another output device exists (attach one, or let the host \ + install the Steam Streaming pair)" + ); } } - if let Some((name, id)) = mic_capture { + + if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() { + if changed { + tracing::info!( + "PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched" + ); + } + return wiring; + } + if let Some((name, id)) = &wiring.loopback_render { match set_default_endpoint(id) { - Ok(()) => tracing::info!(device = %name, - "audio wiring: default recording = virtual mic (apps record the client's mic)"), + Ok(()) => { + if changed { + tracing::info!(device = %name, + "audio wiring: default playback = desktop-audio loopback source"); + } + } + Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), + "audio wiring: failed to set the default playback device"), + } + } + if let Some((name, id)) = &wiring.mic_capture { + match set_default_endpoint(id) { + Ok(()) => { + if changed { + tracing::info!(device = %name, + "audio wiring: default recording = virtual mic (apps record the client's mic)"); + } + } Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), "audio wiring: failed to set the default recording device"), } } - Ok(()) + wiring +} + +/// Open a device by endpoint id, with a name for error context. +pub(crate) fn open_endpoint(ep: &Endpoint) -> Result { + wasapi::DeviceEnumerator::new() + .map_err(|e| anyhow!("DeviceEnumerator: {e}"))? + .get_device(&ep.1) + .map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0)) } // --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. --- diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index fcb84ab..108c8be 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -6,7 +6,7 @@ //! COM-apartment-bound and not `Send`, so they live on a dedicated thread (mirrors //! `linux::PwAudioCapturer`); only the channel + stop flag + join handle are in the struct. -use super::{AudioCapturer, SAMPLE_RATE}; +use super::{audio_control, AudioCapturer, SAMPLE_RATE}; use anyhow::{anyhow, Context, Result}; use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, Ordering}; @@ -109,14 +109,36 @@ fn capture_thread( } let res = (|| -> Result<()> { // Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE - // client with loopback=true over it. NOTE: the virtual mic (`super::wasapi_mic`) is guarded - // to NEVER target this same endpoint — otherwise the client's injected mic would be captured - // here and streamed back to the client (infinite echo). Keep that guard in sync if this - // device selection ever changes. - let device = DeviceEnumerator::new() + // client with loopback=true over it. ECHO GUARD: the wiring plan reserves one endpoint for + // the virtual mic (`super::wasapi_mic` writes the client's voice there) — capturing THAT + // endpoint would stream the client's own mic straight back to it. Normally the plan has + // already moved the default playback elsewhere; if the default still IS the mic target + // (PUNKTFUNK_KEEP_DEFAULT, or the cable is the only endpoint), capture the plan's loopback + // endpoint explicitly, or refuse — no desktop audio beats an echo loop. + let wiring = audio_control::wire_now(); + let default = DeviceEnumerator::new() .context("DeviceEnumerator")? .get_default_device(&Direction::Render) .context("default render endpoint (loopback needs a render device)")?; + let default_is_mic = match (&wiring.mic_render, default.get_id()) { + (Some((_, mic_id)), Ok(id)) => *mic_id == id, + _ => false, + }; + let device = if default_is_mic { + let Some(lb) = &wiring.loopback_render else { + anyhow::bail!( + "the only render endpoint is reserved for the virtual mic (capturing it would \ + echo the client's voice back) — attach another output device or install the \ + Steam Streaming pair to get desktop audio" + ); + }; + tracing::warn!(mic = %wiring.mic_render.as_ref().unwrap().0, loopback = %lb.0, + "default render endpoint is the virtual-mic target — loopback-capturing the plan's \ + endpoint instead"); + audio_control::open_endpoint(lb)? + } else { + default + }; let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; // 48 kHz f32 interleaved in the requested channel layout; autoconvert lets WASAPI's // shared-mode SRC match the engine mix format to ours (incl. up/downmix to the requested diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index b1aae2e..cfb8f8b 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -3,22 +3,21 @@ //! device and write the client's decoded mic PCM into that device's **render** endpoint; the device's //! **capture** endpoint then surfaces as a microphone that host apps can record from. //! -//! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`): -//! VB-Audio "CABLE Input" (bundled by the installer — the preferred, dedicated mic target), the -//! "Steam Streaming Microphone", VoiceMeeter, or anything with "virtual" in the name. -//! [`super::audio_control`] sets the default playback to a DIFFERENT loopback-capable device so the -//! chosen mic is never the endpoint the loopback captures. If no candidate is present we auto-install -//! the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we return an error -//! with install guidance and the host runs without mic passthrough. +//! The target comes from the [`audio_control::wire_now`] plan (recomputed on every open): VB-Audio +//! "CABLE Input" (bundled by the installer — the dedicated mic target), the Steam Streaming +//! Microphone, VoiceMeeter, or anything with "virtual" in the name; `PUNKTFUNK_MIC_DEVICE` overrides. +//! The plan reserves the mic target and points the desktop-audio loopback at a DIFFERENT endpoint, so +//! injecting here can never echo into the host→client audio stream (see +//! [`wiring_plan`](super::wiring_plan) for the precedence rules and the headless cable-only case). +//! If no candidate is present we auto-install the Steam Streaming audio pair (see +//! [`install_steam_audio_pair`]); failing that we return an error with install guidance and the +//! caller (the mic pump) retries with backoff — a cable that appears later (driver install finishing +//! after boot) is picked up without a host restart. //! -//! **Anti-echo guard (the whole point of this being non-trivial).** The desktop-audio plane -//! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback -//! captures the *mixed* output of an endpoint — i.e. everything any app renders to it, including -//! what THIS module writes. So if the virtual-mic target is the same device the loopback captures, -//! the client's uplinked mic is captured straight back into the host→client audio stream: an -//! infinite echo. [`find_device`] therefore **excludes the default render endpoint** from the -//! candidates — the mic is guaranteed to land on a different device. (Linux gets this for free: its -//! mic is a dedicated `Audio/Source` node, structurally separate from the monitored sink.) +//! **Liveness.** Any WASAPI error in the render loop (endpoint invalidated/removed, audio engine +//! restart) exits the worker thread, which flips the `alive` flag — [`VirtualMic::push`] then +//! returns `false` and the pump reopens (re-planning, so endpoint churn re-resolves). Before this +//! existed, the first device change silently killed mic passthrough for the rest of the host's life. //! //! `push` enqueues decoded interleaved-f32 PCM into a bounded ring (drop-oldest beyond ~80 ms so mic //! latency stays bounded); a dedicated COM-apartment thread renders it event-driven, filling silence @@ -28,7 +27,7 @@ // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it. #![deny(clippy::undocumented_unsafe_blocks)] -use super::{VirtualMic, SAMPLE_RATE}; +use super::{audio_control, VirtualMic, SAMPLE_RATE}; use anyhow::{anyhow, Context, Result}; use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, Ordering}; @@ -44,19 +43,11 @@ const BLOCK_ALIGN: usize = 2 * 4; /// Bound the inject queue at ~80 ms so the passed-through mic stays low-latency (drop oldest beyond). const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 80 / 1000) * BLOCK_ALIGN; -/// Render-endpoint friendly-name substrings (lowercased) we can write into so the device's capture -/// endpoint becomes a host mic. Ordered by preference. -const CANDIDATES: &[&str] = &[ - "cable input", // VB-Audio Virtual Cable — bundled by the installer; the preferred dedicated mic target - "steam streaming microphone", - "voicemeeter input", - "voicemeeter aux input", - "virtual", -]; - pub struct WasapiVirtualMic { queue: Arc>>, stop: Arc, + /// False once the render thread has exited (device error or stop) — the pump's reopen signal. + alive: Arc, join: Option>, } @@ -68,25 +59,29 @@ impl WasapiVirtualMic { ); let queue = Arc::new(Mutex::new(VecDeque::::new())); let stop = Arc::new(AtomicBool::new(false)); + let alive = Arc::new(AtomicBool::new(true)); // Bring-up handshake: report the resolved device (or the error) before returning, so a missing // virtual-mic device surfaces as Err (the caller retries with backoff) not a silent dead thread. let (ready_tx, ready_rx) = sync_channel::>(1); - let (q, st) = (queue.clone(), stop.clone()); + let (q, st, al) = (queue.clone(), stop.clone(), alive.clone()); let join = thread::Builder::new() .name("punktfunk-wasapi-mic".into()) .spawn(move || { if let Err(e) = render_thread(q, st, ready_tx) { tracing::error!(error = %format!("{e:#}"), "wasapi virtual-mic thread failed"); } + // Normal stop or device error alike: this instance is done — the pump reopens. + al.store(false, Ordering::Release); }) .context("spawn wasapi mic thread")?; - match ready_rx.recv_timeout(Duration::from_secs(3)) { + match ready_rx.recv_timeout(Duration::from_secs(5)) { Ok(Ok(name)) => { tracing::info!(device = %name, "WASAPI virtual mic ready (client mic → this device's render endpoint)"); Ok(WasapiVirtualMic { queue, stop, + alive, join: Some(join), }) } @@ -106,9 +101,12 @@ impl Drop for WasapiVirtualMic { } impl VirtualMic for WasapiVirtualMic { - fn push(&self, pcm: &[f32]) { + fn push(&self, pcm: &[f32]) -> bool { + if !self.alive.load(Ordering::Acquire) { + return false; + } let Ok(mut q) = self.queue.lock() else { - return; + return false; }; q.reserve(pcm.len() * 4); for &s in pcm { @@ -119,109 +117,50 @@ impl VirtualMic for WasapiVirtualMic { let excess = q.len() - MAX_QUEUE_BYTES; q.drain(..excess); } + true } + + fn alive(&self) -> bool { + self.alive.load(Ordering::Acquire) + } + + fn discard(&self) { + if let Ok(mut q) = self.queue.lock() { + q.clear(); + } + } + fn channels(&self) -> u32 { CHANNELS } } -/// The endpoint ID of the device the desktop-audio loopback records (the **default render -/// endpoint**, see [`super::wasapi_cap`]). The virtual mic must never target this device — injecting -/// there echoes the client's mic back into the host→client audio stream. `None` if it can't be -/// resolved (then [`find_device`] can't prove a candidate is safe and falls back to name-only -/// matching — no worse than before the guard existed). -fn default_render_id() -> Option { - wasapi::DeviceEnumerator::new() - .ok()? - .get_default_device(&Direction::Render) - .ok()? - .get_id() - .ok() -} - -/// Resolve the virtual-mic target among render endpoints by friendly-name, **excluding the endpoint -/// the loopback captures** (the [`default_render_id`] anti-echo guard). Logs all candidates so a -/// missing/skipped device is diagnosable. -fn find_device() -> Result { - let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?; - let collection = enumerator - .get_device_collection(&Direction::Render) - .context("render device collection")?; - let n = collection.get_nbr_devices().context("device count")?; - let want = std::env::var("PUNKTFUNK_MIC_DEVICE") - .ok() - .map(|s| s.to_lowercase()); - // The device the loopback captures — a name match on it is rejected below (would echo). - let loopback_id = default_render_id(); - let mut names = Vec::new(); - let mut found = None; - let mut skipped_loopback = false; - for i in 0..n { - let Ok(dev) = collection.get_device_at_index(i) else { - continue; - }; - let name = dev.get_friendlyname().unwrap_or_default(); - let lname = name.to_lowercase(); - let hit = match &want { - Some(w) => lname.contains(w), - None => CANDIDATES.iter().any(|c| lname.contains(c)), - }; - if hit && found.is_none() { - // Anti-echo guard: never inject into the endpoint the loopback captures. - let is_loopback = match (dev.get_id().ok(), loopback_id.as_deref()) { - (Some(id), Some(lb)) => id == lb, - _ => false, - }; - if is_loopback { - skipped_loopback = true; - tracing::warn!(device = %name, - "virtual-mic candidate is the loopback (default render) endpoint — skipping; \ - injecting there would echo the client's mic into the desktop-audio stream"); - } else { - found = Some(dev); - } - } - names.push(name); - } - found.ok_or_else(|| { - if skipped_loopback { - anyhow!( - "the only virtual-mic candidate among render endpoints {names:?} is the default \ - playback device the host loopback-captures — injecting there would echo the mic \ - back to the client. Add a SEPARATE virtual audio device for the mic (e.g. the Steam \ - Streaming Microphone) or set a different default playback device, then reconnect." - ) - } else { - anyhow!( - "no virtual-mic device among render endpoints {names:?}. Install VB-Audio Virtual \ - Cable or enable Steam Remote Play's microphone (Steam Streaming Microphone), or set \ - PUNKTFUNK_MIC_DEVICE=." - ) - } - }) -} - -/// Find the virtual-mic device, and if none exists, try to AUTO-INSTALL one so mic passthrough works -/// out of the box (then re-find). Falls back to the guidance error if nothing can be installed. -fn find_or_install_device() -> Result { - match find_device() { - Ok(d) => Ok(d), - Err(e) => { - tracing::info!("no usable virtual mic device present — attempting auto-install"); - // SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s - // `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer; - // calling it imposes no extra precondition here (it takes no args and aliases nothing). - // Its internal contract holds: the `DiInstall` type matches the documented - // `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a - // NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the - // dedicated mic thread. - if unsafe { install_steam_audio_pair() } { - find_device() - } else { - Err(e) - } +/// Resolve the mic inject target from the wiring plan, auto-installing the Steam Streaming pair +/// when nothing usable exists (then re-planning). Runs on the COM-initialized render thread. +fn resolve_target() -> Result<(wasapi::Device, String)> { + let mut wiring = audio_control::wire_now(); + if wiring.mic_render.is_none() { + tracing::info!("no usable virtual mic device present — attempting auto-install"); + // SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s + // `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer; + // calling it imposes no extra precondition here (it takes no args and aliases nothing). + // Its internal contract holds: the `DiInstall` type matches the documented + // `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a + // NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the + // dedicated mic thread. + if unsafe { install_steam_audio_pair() } { + wiring = audio_control::wire_now(); } } + let Some(ep) = wiring.mic_render else { + anyhow::bail!( + "no virtual-mic render endpoint on this box. Install VB-Audio Virtual Cable (the host \ + installer bundles it) or enable Steam Remote Play's microphone (Steam Streaming \ + Microphone), or set PUNKTFUNK_MIC_DEVICE=." + ); + }; + let name = ep.0.clone(); + Ok((audio_control::open_endpoint(&ep)?, name)) } /// Best-effort: install BOTH Steam Streaming audio devices (the "Steam pair") so mic passthrough @@ -229,9 +168,9 @@ fn find_or_install_device() -> Result { /// Play ships `SteamStreamingMicrophone.inf` + `SteamStreamingSpeakers.inf`: the microphone gives the /// virtual mic a target whose **capture** endpoint apps record from, and the speakers give a /// **render** endpoint a headless box can loopback-capture that is NOT the mic — so the loopback and -/// the mic land on different devices and never echo (see [`find_device`]). Returns true if either -/// installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs admin — -/// the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set. +/// the mic land on different devices and never echo (see [`super::wiring_plan`]). Returns true if +/// either installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs +/// admin — the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set. unsafe fn install_steam_audio_pair() -> bool { // Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink). let mic = try_install_steam_audio("SteamStreamingMicrophone.inf"); @@ -320,8 +259,7 @@ fn render_thread( // Open + start the render stream. The WASAPI objects must outlive the loop, so build them here and // keep them (a closure that *returned* them would drop them); on any failure report Err and exit. let setup = (|| -> Result<(wasapi::AudioClient, wasapi::AudioRenderClient, wasapi::Handle, String)> { - let device = find_or_install_device()?; - let name = device.get_friendlyname().unwrap_or_else(|_| "virtual mic".into()); + let (device, name) = resolve_target()?; let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; // 48 kHz stereo f32; autoconvert lets WASAPI shared-mode SRC match the device mix format. let desired = WaveFormat::new( @@ -359,6 +297,8 @@ fn render_thread( }; let _ = ready.send(Ok(name)); + // Any error below (endpoint invalidated/removed, engine restart) propagates out of the loop, + // ending the thread — the `alive` flag flips in the spawn wrapper and the pump reopens. let mut buf: Vec = Vec::new(); while !stop.load(Ordering::Relaxed) { // The device signals when it wants more data; finite timeout keeps `stop` responsive. diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs new file mode 100644 index 0000000..be432e5 --- /dev/null +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -0,0 +1,274 @@ +//! Windows audio endpoint assignment — the PURE planning logic behind +//! [`audio_control`](super::audio_control), split out so it compiles (and its unit tests run) on +//! every platform: the precedence rules here encode the hard-won field knowledge, and regressing +//! them must fail CI on Linux too, not only on a Windows box. +//! +//! Two jobs share the render endpoints and must never collide: +//! +//! * the **virtual mic** writes the client's decoded mic PCM into a virtual cable's render +//! endpoint (its capture side surfaces as a host microphone), and +//! * the **desktop-audio loopback** captures a render endpoint's mix for the host→client +//! audio stream. +//! +//! WASAPI loopback captures *everything* an endpoint renders — including what the virtual mic +//! writes — so if both land on the same device the client's voice echoes straight back into the +//! client's own audio stream. The plan therefore assigns the mic its endpoint FIRST (VB-CABLE is +//! bundled by the installer for exactly this) and gives the loopback a *different* one; when only +//! the cable exists (headless box, no other output), the MIC wins and the loopback is honestly +//! unavailable. The old code did the opposite — the mic refused the cable because it was the +//! default render endpoint — which permanently killed mic passthrough in the exact configuration +//! the installer ships (VB-CABLE as the only render device). + +/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI. +pub(crate) type Endpoint = (String, String); + +/// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture +/// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a +/// once-per-process plan goes stale. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Wiring { + /// Render endpoint RESERVED for the virtual mic (the write target). The loopback must never + /// capture this device. + pub mic_render: Option, + /// The mic device's CAPTURE side — host apps record this; made the default recording device. + pub mic_capture: Option, + /// Render endpoint for the desktop-audio loopback; made the default playback device. + pub loopback_render: Option, +} + +/// Render-endpoint friendly-name substrings (lowercased) usable as the virtual-mic write target, +/// ordered by preference. VB-CABLE first: the installer bundles it for this exact purpose. +const MIC_CANDIDATES: &[&str] = &[ + "cable input", // VB-Audio Virtual Cable — bundled by the installer + "steam streaming microphone", + "voicemeeter input", + "voicemeeter aux input", + "virtual", +]; + +/// `(mic render substring, matching capture substring)` — which capture endpoint surfaces the +/// audio written to a given mic render target. +fn capture_for(mic_render_lname: &str) -> &'static [&'static str] { + if mic_render_lname.contains("cable") { + &["cable output"] + } else if mic_render_lname.contains("steam streaming microphone") { + &["steam streaming microphone"] + } else if mic_render_lname.contains("voicemeeter") { + &["voicemeeter out", "voicemeeter"] + } else { + &["virtual"] + } +} + +/// A render endpoint no loopback should capture: the VB-CABLE (reserved for the mic even when it +/// isn't the chosen target — capturing a cable someone else feeds echoes too) and the Steam +/// Streaming Speakers, whose loopback is silent (validated live). +fn excluded_from_loopback(lname: &str) -> bool { + lname.contains("cable") || lname.contains("steam streaming speakers") +} + +/// A known-virtual device (cables/streaming endpoints). A render WITHOUT these markers is real +/// hardware — the best loopback source (apps render there by default and the operator can also +/// hear it). +fn virtualish(lname: &str) -> bool { + lname.contains("virtual") + || lname.contains("cable") + || lname.contains("steam streaming") + || lname.contains("voicemeeter") +} + +/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`, +/// lowercased): when set it beats the built-in candidate order for the mic target. +pub(crate) fn plan(renders: &[Endpoint], captures: &[Endpoint], mic_want: Option<&str>) -> Wiring { + let find_render = |needle: &str| { + renders + .iter() + .find(|(n, _)| n.to_lowercase().contains(needle)) + .cloned() + }; + + // 1. Mic target first — it has the narrower requirements (must be a virtual cable). + let mic_render = match mic_want { + Some(w) => find_render(w), + None => MIC_CANDIDATES.iter().find_map(|c| find_render(c)), + }; + + // 2. Its capture side (what host apps record). + let mic_capture = mic_render.as_ref().and_then(|(name, _)| { + capture_for(&name.to_lowercase()).iter().find_map(|c| { + captures + .iter() + .find(|(n, _)| n.to_lowercase().contains(c)) + .cloned() + }) + }); + + // 3. Loopback from the REMAINING renders: real hardware > Steam Streaming Microphone (its + // loopback works, unlike the Speakers') > any non-excluded leftover. + let not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id); + let loopback_render = renders + .iter() + .find(|(n, id)| { + let ln = n.to_lowercase(); + not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln) + }) + .or_else(|| { + renders.iter().find(|(n, id)| { + not_mic(id) && n.to_lowercase().contains("steam streaming microphone") + }) + }) + .or_else(|| { + renders + .iter() + .find(|(n, id)| not_mic(id) && !excluded_from_loopback(&n.to_lowercase())) + }) + .cloned(); + + Wiring { + mic_render, + mic_capture, + loopback_render, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ep(name: &str) -> Endpoint { + (name.to_string(), format!("id-{}", name.to_lowercase())) + } + + /// The shipped configuration: real output + VB-CABLE. Mic gets the cable, loopback the + /// speakers, recording default = CABLE Output. + #[test] + fn gaming_pc_with_cable() { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ]; + let captures = [ + ep("Microphone (Webcam)"), + ep("CABLE Output (VB-Audio Virtual Cable)"), + ]; + let w = plan(&renders, &captures, None); + assert_eq!( + w.mic_render.unwrap().0, + "CABLE Input (VB-Audio Virtual Cable)" + ); + assert_eq!( + w.mic_capture.unwrap().0, + "CABLE Output (VB-Audio Virtual Cable)" + ); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + } + + /// THE historical dead-end: headless box where VB-CABLE is the ONLY render endpoint (and + /// therefore the default). The mic must WIN the cable; the loopback is honestly absent. + /// (The old anti-echo guard rejected the cable here → mic permanently dead.) + #[test] + fn headless_cable_only_mic_wins() { + let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; + let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; + let w = plan(&renders, &captures, None); + assert!(w.mic_render.is_some(), "mic must claim the only cable"); + assert!(w.loopback_render.is_none(), "no echo-safe loopback exists"); + } + + /// Headless with the Steam pair installed: cable = mic, Steam Streaming Microphone = the + /// loopback (its loopback works; the Speakers' is silent — validated live). + #[test] + fn headless_with_steam_pair() { + let renders = [ + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Speakers (Steam Streaming Speakers)"), + ep("Speakers (Steam Streaming Microphone)"), + ]; + let captures = [ + ep("CABLE Output (VB-Audio Virtual Cable)"), + ep("Microphone (Steam Streaming Microphone)"), + ]; + let w = plan(&renders, &captures, None); + assert_eq!( + w.mic_render.unwrap().0, + "CABLE Input (VB-Audio Virtual Cable)" + ); + assert_eq!( + w.loopback_render.unwrap().0, + "Speakers (Steam Streaming Microphone)" + ); + assert_eq!( + w.mic_capture.unwrap().0, + "CABLE Output (VB-Audio Virtual Cable)" + ); + } + + /// No cable: the Steam Streaming Microphone doubles as the mic target, and the loopback + /// must NOT then pick the same endpoint (real hardware wins). + #[test] + fn steam_mic_as_target_never_doubles_as_loopback() { + let renders = [ + ep("Speakers (Steam Streaming Microphone)"), + ep("Speakers (Realtek HD Audio)"), + ]; + let captures = [ep("Microphone (Steam Streaming Microphone)")]; + let w = plan(&renders, &captures, None); + assert_eq!( + w.mic_render.unwrap().0, + "Speakers (Steam Streaming Microphone)" + ); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + } + + /// No cable and ONLY the Steam mic: mic wins it, loopback honestly absent (never the same + /// device — that would echo). + #[test] + fn steam_mic_only_no_echo() { + let renders = [ep("Speakers (Steam Streaming Microphone)")]; + let captures = [ep("Microphone (Steam Streaming Microphone)")]; + let w = plan(&renders, &captures, None); + assert!(w.mic_render.is_some()); + assert!(w.loopback_render.is_none()); + } + + /// Steam Streaming Speakers never become the loopback (silent loopback, validated live) — + /// even when they're the only non-mic endpoint. + #[test] + fn steam_speakers_never_loopback() { + let renders = [ + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Speakers (Steam Streaming Speakers)"), + ]; + let w = plan(&renders, &[], None); + assert!(w.loopback_render.is_none()); + } + + /// Operator override beats the candidate order. + #[test] + fn env_override_wins() { + let renders = [ + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"), + ]; + let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")]; + let w = plan(&renders, &captures, Some("voicemeeter input")); + assert_eq!( + w.mic_render.unwrap().0, + "Voicemeeter Input (VB-Audio Voicemeeter VAIO)" + ); + assert_eq!( + w.mic_capture.unwrap().0, + "Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)" + ); + } + + /// No virtual device anywhere: no mic target (open fails with guidance), loopback = the + /// real output — desktop audio unaffected. + #[test] + fn no_virtual_device() { + let renders = [ep("Speakers (Realtek HD Audio)")]; + let w = plan(&renders, &[], None); + assert!(w.mic_render.is_none()); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + } +} diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index c5d5452..f0fc460 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -222,10 +222,13 @@ pub(crate) async fn serve( // session — which, under rapid client reconnects, raced a prior session's portal teardown and // wedged KWin's EIS setup ("EIS setup timed out"). Gamepads stay per-session (uinput). let injector = crate::inject::InjectorService::start(); - // One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink - // (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps record from (Linux - // PipeWire Audio/Source; Windows a virtual audio device's render endpoint). - let mic_service = MicService::start(); + // One virtual microphone for the whole host lifetime (see [`crate::audio::MicPump`]): the + // client's mic uplink (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps + // record from (Linux PipeWire Audio/Source; Windows a virtual audio device's render endpoint). + // The pump opens the backend EAGERLY (the mic device exists before any game launches and + // binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows + // endpoint churn). + let mic_service = crate::audio::MicPump::start(); // Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path // restores the box's autologin gaming session on idle, not per-disconnect — see // `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it. @@ -1310,119 +1313,11 @@ impl PadState { /// actual pad creation at its own MAX_PADS. const MAX_WIRE_PADS: usize = 16; -/// Backoff between reopen attempts after a host-lifetime service's backend (the mic source, a -/// capturer) fails to open or its worker dies, so a persistently-unavailable resource isn't hammered. +/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails +/// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The +/// virtual mic has its own tuning — see [`crate::audio::MicPump`].) const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2); -/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout. -const MIC_CHANNELS: u32 = 2; -/// Bound for the shared mic frame queue (drop-newest when full). See [`MicService::start`]. -const MIC_QUEUE_CAP: usize = 64; - -/// Host-lifetime virtual microphone, shared across punktfunk/1 sessions (mirror of -/// [`InjectorService`]). One thread owns the PipeWire `Audio/Source` + an Opus decoder; sessions -/// forward the client's Opus mic frames over a clonable `Send` channel, the thread decodes and -/// feeds the source. Opened lazily on the first frame, the source node persists across sessions -/// (no per-session registration churn), and reopens after a backoff if the source/decoder fails. -struct MicService { - tx: std::sync::mpsc::SyncSender>, -} - -impl MicService { - fn start() -> MicService { - // Bounded so the host-lifetime mic queue (shared across all concurrent sessions) can't grow - // without limit under a near-line-rate flood; the producer drops the newest frame when full - // (audio is lossy by design) rather than buffering unboundedly (security-review 2026-06-28 - // S6). 64 × 5–10 ms frames ≈ 0.3–0.6 s of slack, far more than the decode loop ever lags. - let (tx, rx) = std::sync::mpsc::sync_channel::>(MIC_QUEUE_CAP); - if let Err(e) = std::thread::Builder::new() - .name("punktfunk1-mic".into()) - .spawn(move || mic_service_thread(rx)) - { - tracing::error!(error = %e, "mic service thread spawn failed — mic passthrough disabled"); - } - MicService { tx } - } - - /// A sender a session forwards the client's Opus mic frames to. Cloned per session; dropping a - /// clone does NOT stop the service (it holds the original sender for the host life). - fn sender(&self) -> std::sync::mpsc::SyncSender> { - self.tx.clone() - } -} - -/// Stub — mic passthrough needs a virtual-mic backend (Linux PipeWire source / Windows virtual audio -/// device); other platforms drain and drop the frames (sessions still count the datagrams). -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -fn mic_service_thread(rx: std::sync::mpsc::Receiver>) { - tracing::warn!("punktfunk/1 mic passthrough unsupported on this platform — frames dropped"); - for _ in rx {} -} - -/// The host-lifetime mic worker: lazily open the virtual mic + decoder, then Opus-decode each -/// forwarded frame and push the PCM into the source. Reopen (after [`INJECTOR_REOPEN_BACKOFF`]) -/// only on a backend OPEN failure; a per-frame Opus DECODE error is just a dropped frame (it must -/// not tear down this mic, which is shared across every concurrent session — otherwise one paired -/// client's junk frames would deny everyone's mic; security-review 2026-06-28 S2). Exits when every -/// session sender and the service's own sender drop (host shutdown), tearing the virtual mic down. -/// Linux = PipeWire `Audio/Source`; Windows = a virtual audio device's render endpoint. -#[cfg(any(target_os = "linux", target_os = "windows"))] -fn mic_service_thread(rx: std::sync::mpsc::Receiver>) { - let mut mic: Option> = None; - let mut decoder: Option = None; - let mut last_failed: Option = None; - let mut decode_fails: u64 = 0; - let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch - for opus_frame in rx { - if opus_frame.is_empty() { - continue; // DTX silence — the source underruns to silence on its own - } - if mic.is_none() || decoder.is_none() { - if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) { - continue; // still within the reopen backoff window - } - let opened = crate::audio::open_virtual_mic(MIC_CHANNELS).and_then(|m| { - let d = opus::Decoder::new(48_000, opus::Channels::Stereo) - .map_err(|e| anyhow!("opus decoder: {e}"))?; - Ok((m, d)) - }); - match opened { - Ok((m, d)) => { - tracing::info!("punktfunk/1 virtual mic ready (host-lifetime)"); - mic = Some(m); - decoder = Some(d); - last_failed = None; - } - Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual mic unavailable — will retry"); - last_failed = Some(std::time::Instant::now()); - continue; - } - } - } - let (Some(m), Some(dec)) = (mic.as_ref(), decoder.as_mut()) else { - continue; - }; - match dec.decode_float(&opus_frame, &mut pcm, false) { - Ok(samples_per_ch) => { - let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len()); - m.push(&pcm[..total]); - decode_fails = 0; - } - Err(e) => { - // Malformed/garbage frame: drop it and keep the (shared) mic + decoder open. The - // next valid frame decodes normally; only a backend OPEN failure reopens. Throttle - // the log (1, 2, 4, … fails) so a junk flood can't spam. - decode_fails += 1; - if decode_fails.is_power_of_two() { - tracing::warn!(error = %e, fails = decode_fails, "mic opus decode failed — dropping frame"); - } - } - } - } - tracing::debug!("mic service stopped (host shutting down)"); -} - /// The session's virtual-gamepad backend, resolved once per session (sessions run serially). /// /// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)),