fix(host/audio): rebuild mic passthrough — eager, self-healing virtual mic on both hosts
apple / swift (push) Successful in 1m7s
ci / rust (push) Successful in 1m57s
ci / web (push) Successful in 59s
android / android (push) Successful in 3m19s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m12s
windows-host / package (push) Successful in 7m2s
ci / bench (push) Successful in 4m52s
decky / build-publish (push) Successful in 14s
deb / build-publish (push) Successful in 4m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m14s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m40s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m28s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 20:41:19 +00:00
parent b7048446c4
commit 2c7ded0f3c
7 changed files with 969 additions and 371 deletions
+392 -5
View File
@@ -42,7 +42,8 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> { pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
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) wasapi_cap::WasapiLoopbackCapturer::open(channels)
.map(|c| Box::new(c) as Box<dyn AudioCapturer>) .map(|c| Box::new(c) as Box<dyn AudioCapturer>)
} }
@@ -57,10 +58,27 @@ pub fn open_audio_capture(_channels: u32) -> Result<Box<dyn AudioCapturer>> {
/// decoded client-mic PCM (interleaved `f32` at [`SAMPLE_RATE`]) into it, and PipeWire delivers /// 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 /// 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). /// 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 { pub trait VirtualMic: Send {
/// Push one chunk of interleaved `f32` PCM. Non-blocking — drops if PipeWire is behind /// 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). /// (mic audio is lossy/real-time; a stale chunk is worse than a dropped one). Returns
fn push(&self, pcm: &[f32]); /// `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. /// The interleaved channel count the source was opened with.
fn channels(&self) -> u32 { fn channels(&self) -> u32 {
@@ -78,7 +96,8 @@ pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> { pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
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<dyn VirtualMic>) wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box<dyn VirtualMic>)
} }
@@ -87,6 +106,189 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device") 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 × 520 ms frames ≈ 0.31.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<Vec<u8>>,
}
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::<Vec<u8>>(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<Vec<u8>> {
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<O>(rx: std::sync::mpsc::Receiver<Vec<u8>>, opener: O, tuning: PumpTuning)
where
O: Fn() -> Result<Box<dyn VirtualMic>>,
{
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")] #[cfg(target_os = "windows")]
#[path = "audio/windows/audio_control.rs"] #[path = "audio/windows/audio_control.rs"]
mod audio_control; mod audio_control;
@@ -98,3 +300,188 @@ mod wasapi_cap;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_mic.rs"] #[path = "audio/windows/wasapi_mic.rs"]
mod wasapi_mic; 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<AtomicBool>,
pushed: Arc<AtomicUsize>,
discards: Arc<AtomicUsize>,
}
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<Vec<u8>>,
opens: Arc<AtomicUsize>,
alive: Arc<Mutex<Option<Arc<AtomicBool>>>>, // latest instance's kill switch
pushed: Arc<AtomicUsize>,
discards: Arc<AtomicUsize>,
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::<Vec<u8>>(MIC_QUEUE_CAP);
let opens = Arc::new(AtomicUsize::new(0));
let alive = Arc::new(Mutex::new(None::<Arc<AtomicBool>>));
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<dyn VirtualMic>)
},
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<u8> {
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();
}
}
+111 -12
View File
@@ -16,7 +16,9 @@
use super::{AudioCapturer, VirtualMic, SAMPLE_RATE}; use super::{AudioCapturer, VirtualMic, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError}; use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration; 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 /// 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 /// 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). /// 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 { pub struct PwMicSource {
pcm: std::sync::mpsc::SyncSender<Vec<f32>>, pcm: std::sync::mpsc::SyncSender<(std::time::Instant, Vec<f32>)>,
channels: u32, channels: u32,
quit: pipewire::channel::Sender<Terminate>, quit: pipewire::channel::Sender<Terminate>,
/// False once the loop thread has exited (daemon/stream death or teardown).
alive: Arc<AtomicBool>,
/// One-shot flush request, consumed by the process callback (clears the jitter ring).
flush: Arc<AtomicBool>,
} }
impl PwMicSource { impl PwMicSource {
@@ -123,20 +143,27 @@ impl PwMicSource {
matches!(channels, 1 | 2), matches!(channels, 1 | 2),
"virtual mic supports 1 or 2 channels, got {channels}" "virtual mic supports 1 or 2 channels, got {channels}"
); );
let (pcm_tx, pcm_rx) = sync_channel::<Vec<f32>>(64); let (pcm_tx, pcm_rx) = sync_channel::<(std::time::Instant, Vec<f32>)>(64);
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>(); let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
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() thread::Builder::new()
.name("punktfunk-pw-mic".into()) .name("punktfunk-pw-mic".into())
.spawn(move || { .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"); 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")?; .context("spawn pipewire virtual-mic thread")?;
Ok(PwMicSource { Ok(PwMicSource {
pcm: pcm_tx, pcm: pcm_tx,
channels, channels,
quit: quit_tx, quit: quit_tx,
alive,
flush,
}) })
} }
} }
@@ -148,8 +175,24 @@ impl Drop for PwMicSource {
} }
impl VirtualMic for PwMicSource { impl VirtualMic for PwMicSource {
fn push(&self, pcm: &[f32]) { fn push(&self, pcm: &[f32]) -> bool {
let _ = self.pcm.try_send(pcm.to_vec()); // drop if the PipeWire side is behind 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 { fn channels(&self) -> u32 {
self.channels self.channels
@@ -160,16 +203,27 @@ impl VirtualMic for PwMicSource {
/// the process callback drains into PipeWire buffers (capped, so latency stays bounded). /// the process callback drains into PipeWire buffers (capped, so latency stays bounded).
/// `primed` is a jitter buffer gate — see the process callback. /// `primed` is a jitter buffer gate — see the process callback.
struct MicUserData { struct MicUserData {
rx: Receiver<Vec<f32>>, rx: Receiver<(std::time::Instant, Vec<f32>)>,
ring: VecDeque<f32>, ring: VecDeque<f32>,
channels: usize, channels: usize,
primed: bool, primed: bool,
/// One-shot flush request from [`PwMicSource::discard`] (stale-audio drop after a gap).
flush: Arc<AtomicBool>,
/// 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<std::time::Instant>,
} }
/// 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( fn mic_pw_thread(
pcm_rx: Receiver<Vec<f32>>, pcm_rx: Receiver<(std::time::Instant, Vec<f32>)>,
quit_rx: pipewire::channel::Receiver<Terminate>, quit_rx: pipewire::channel::Receiver<Terminate>,
channels: u32, channels: u32,
flush: Arc<AtomicBool>,
) -> Result<()> { ) -> Result<()> {
use pipewire as pw; use pipewire as pw;
use pw::{properties::properties, spa}; use pw::{properties::properties, spa};
@@ -188,6 +242,26 @@ fn mic_pw_thread(
move |_| mainloop.quit() 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 // 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. // playback stream — without it, Direction::Output + Playback would route to the speakers.
let stream = pw::stream::StreamBox::new( let stream = pw::stream::StreamBox::new(
@@ -226,12 +300,21 @@ fn mic_pw_thread(
ring: VecDeque::new(), ring: VecDeque::new(),
channels: channels as usize, channels: channels as usize,
primed: false, primed: false,
flush,
last_run: None,
}; };
let _listener = stream let _listener = stream
.add_local_listener_with_user_data(ud) .add_local_listener_with_user_data(ud)
.state_changed(|_s, _ud, old, new| { .state_changed({
tracing::info!(?old, ?new, "pipewire virtual-mic stream state"); 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| { .param_changed(|_s, _ud, id, param| {
let Some(param) = param else { return }; let Some(param) = param else { return };
@@ -253,9 +336,25 @@ fn mic_pw_thread(
let Some(mut buffer) = stream.dequeue_buffer() else { let Some(mut buffer) = stream.dequeue_buffer() else {
return; return;
}; };
// Pull all newly-decoded PCM into the ring. // Stale-audio guard, BEFORE pulling new frames: drop the ring when a flush was
while let Ok(frame) = ud.rx.try_recv() { // requested (uplink gap — see the pump) or when this callback itself hasn't run
ud.ring.extend(frame); // 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 stride = 4 * ud.channels; // F32LE interleaved
let datas = buffer.datas_mut(); let datas = buffer.datas_mut();
@@ -6,64 +6,39 @@
//! ones, or the loopback re-captures the injected mic (an infinite echo). The installer bundles //! 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) //! 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 //! 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 //! 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 //! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] captures;
//! for desktop audio. //! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
//! * default **RECORDING** → the virtual mic's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default. //! record the client's mic by default.
//! //!
//! [`super::wasapi_mic::find_device`] then resolves the mic INJECT target to "CABLE Input" — a render //! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every
//! candidate that is NOT the default playback — guaranteeing loopback ≠ mic, so there is no echo. //! 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 //! 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 //! 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 //! 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. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::wiring_plan::{plan, Endpoint, Wiring};
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::Once; use std::sync::Mutex;
use wasapi::Direction; 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`. /// `(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<Endpoint> {
let mut out = Vec::new(); let mut out = Vec::new();
let Ok(en) = wasapi::DeviceEnumerator::new() else { let Ok(en) = wasapi::DeviceEnumerator::new() else {
return out; return out;
@@ -86,79 +61,85 @@ fn list_endpoints(dir: Direction) -> Vec<(String, String)> {
out out
} }
/// Pick the loopback + mic-capture devices and set them as the default playback/recording. /// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
fn ensure_audio_wiring() -> Result<()> { /// `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 renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture); let captures = list_endpoints(Direction::Capture);
if renders.is_empty() { let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
bail!("no active render endpoints to wire"); .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 // Log assignment changes exactly once (first plan included).
// the mic inject) or the Steam Streaming Speakers (its loopback is silent — validated live). static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
let excluded_loopback = let changed = {
|ln: &str| ln.contains("cable") || ln.contains("steam streaming speakers"); let mut last = LAST.lock().unwrap();
// "virtual-ish" = a known virtual cable; a render WITHOUT these markers is a real output device, let changed = last.as_ref() != Some(&wiring);
// the best loopback source (apps render there and the operator can also hear it). *last = Some(wiring.clone());
let virtualish = |ln: &str| { changed
ln.contains("virtual")
|| ln.contains("cable")
|| ln.contains("steam streaming")
|| ln.contains("voicemeeter")
}; };
let loopback = renders if changed {
.iter() tracing::info!(
.find(|(n, _)| { mic_render = wiring.mic_render.as_ref().map(|(n, _)| n.as_str()),
let ln = n.to_lowercase(); mic_capture = wiring.mic_capture.as_ref().map(|(n, _)| n.as_str()),
!excluded_loopback(&ln) && !virtualish(&ln) loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()),
}) renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
.or_else(|| { "audio wiring plan"
renders );
.iter() if wiring.mic_render.is_some() && wiring.loopback_render.is_none() {
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone")) tracing::warn!(
}) "the virtual mic reserved the only usable render endpoint — desktop audio will be \
.or_else(|| { unavailable until another output device exists (attach one, or let the host \
renders install the Steam Streaming pair)"
.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 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) { match set_default_endpoint(id) {
Ok(()) => tracing::info!(device = %name, Ok(()) => {
"audio wiring: default recording = virtual mic (apps record the client's mic)"), 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:#}"), Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"), "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::Device> {
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. --- // --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
@@ -6,7 +6,7 @@
//! COM-apartment-bound and not `Send`, so they live on a dedicated thread (mirrors //! 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. //! `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 anyhow::{anyhow, Context, Result};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -109,14 +109,36 @@ fn capture_thread(
} }
let res = (|| -> Result<()> { let res = (|| -> Result<()> {
// Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE // 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 // client with loopback=true over it. ECHO GUARD: the wiring plan reserves one endpoint for
// to NEVER target this same endpoint — otherwise the client's injected mic would be captured // the virtual mic (`super::wasapi_mic` writes the client's voice there) — capturing THAT
// here and streamed back to the client (infinite echo). Keep that guard in sync if this // endpoint would stream the client's own mic straight back to it. Normally the plan has
// device selection ever changes. // already moved the default playback elsewhere; if the default still IS the mic target
let device = DeviceEnumerator::new() // (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")? .context("DeviceEnumerator")?
.get_default_device(&Direction::Render) .get_default_device(&Direction::Render)
.context("default render endpoint (loopback needs a render device)")?; .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")?; let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
// 48 kHz f32 interleaved in the requested channel layout; autoconvert lets WASAPI's // 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 // shared-mode SRC match the engine mix format to ours (incl. up/downmix to the requested
@@ -3,22 +3,21 @@
//! device and write the client's decoded mic PCM into that device's **render** endpoint; the device's //! 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. //! **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`): //! The target comes from the [`audio_control::wire_now`] plan (recomputed on every open): VB-Audio
//! VB-Audio "CABLE Input" (bundled by the installer — the preferred, dedicated mic target), the //! "CABLE Input" (bundled by the installer — the dedicated mic target), the Steam Streaming
//! "Steam Streaming Microphone", VoiceMeeter, or anything with "virtual" in the name. //! Microphone, VoiceMeeter, or anything with "virtual" in the name; `PUNKTFUNK_MIC_DEVICE` overrides.
//! [`super::audio_control`] sets the default playback to a DIFFERENT loopback-capable device so the //! The plan reserves the mic target and points the desktop-audio loopback at a DIFFERENT endpoint, so
//! chosen mic is never the endpoint the loopback captures. If no candidate is present we auto-install //! injecting here can never echo into the host→client audio stream (see
//! the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we return an error //! [`wiring_plan`](super::wiring_plan) for the precedence rules and the headless cable-only case).
//! with install guidance and the host runs without mic passthrough. //! 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 //! **Liveness.** Any WASAPI error in the render loop (endpoint invalidated/removed, audio engine
//! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback //! restart) exits the worker thread, which flips the `alive` flag — [`VirtualMic::push`] then
//! captures the *mixed* output of an endpoint — i.e. everything any app renders to it, including //! returns `false` and the pump reopens (re-planning, so endpoint churn re-resolves). Before this
//! what THIS module writes. So if the virtual-mic target is the same device the loopback captures, //! existed, the first device change silently killed mic passthrough for the rest of the host's life.
//! 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.)
//! //!
//! `push` enqueues decoded interleaved-f32 PCM into a bounded ring (drop-oldest beyond ~80 ms so mic //! `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 //! 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. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::{VirtualMic, SAMPLE_RATE}; use super::{audio_control, VirtualMic, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering}; 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). /// 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; 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 { pub struct WasapiVirtualMic {
queue: Arc<Mutex<VecDeque<u8>>>, queue: Arc<Mutex<VecDeque<u8>>>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
/// False once the render thread has exited (device error or stop) — the pump's reopen signal.
alive: Arc<AtomicBool>,
join: Option<JoinHandle<()>>, join: Option<JoinHandle<()>>,
} }
@@ -68,25 +59,29 @@ impl WasapiVirtualMic {
); );
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new())); let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
let stop = Arc::new(AtomicBool::new(false)); 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 // 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. // virtual-mic device surfaces as Err (the caller retries with backoff) not a silent dead thread.
let (ready_tx, ready_rx) = sync_channel::<Result<String>>(1); let (ready_tx, ready_rx) = sync_channel::<Result<String>>(1);
let (q, st) = (queue.clone(), stop.clone()); let (q, st, al) = (queue.clone(), stop.clone(), alive.clone());
let join = thread::Builder::new() let join = thread::Builder::new()
.name("punktfunk-wasapi-mic".into()) .name("punktfunk-wasapi-mic".into())
.spawn(move || { .spawn(move || {
if let Err(e) = render_thread(q, st, ready_tx) { if let Err(e) = render_thread(q, st, ready_tx) {
tracing::error!(error = %format!("{e:#}"), "wasapi virtual-mic thread failed"); 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")?; .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)) => { Ok(Ok(name)) => {
tracing::info!(device = %name, tracing::info!(device = %name,
"WASAPI virtual mic ready (client mic → this device's render endpoint)"); "WASAPI virtual mic ready (client mic → this device's render endpoint)");
Ok(WasapiVirtualMic { Ok(WasapiVirtualMic {
queue, queue,
stop, stop,
alive,
join: Some(join), join: Some(join),
}) })
} }
@@ -106,9 +101,12 @@ impl Drop for WasapiVirtualMic {
} }
impl VirtualMic 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 { let Ok(mut q) = self.queue.lock() else {
return; return false;
}; };
q.reserve(pcm.len() * 4); q.reserve(pcm.len() * 4);
for &s in pcm { for &s in pcm {
@@ -119,109 +117,50 @@ impl VirtualMic for WasapiVirtualMic {
let excess = q.len() - MAX_QUEUE_BYTES; let excess = q.len() - MAX_QUEUE_BYTES;
q.drain(..excess); 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 { fn channels(&self) -> u32 {
CHANNELS CHANNELS
} }
} }
/// The endpoint ID of the device the desktop-audio loopback records (the **default render /// Resolve the mic inject target from the wiring plan, auto-installing the Steam Streaming pair
/// endpoint**, see [`super::wasapi_cap`]). The virtual mic must never target this device — injecting /// when nothing usable exists (then re-planning). Runs on the COM-initialized render thread.
/// there echoes the client's mic back into the host→client audio stream. `None` if it can't be fn resolve_target() -> Result<(wasapi::Device, String)> {
/// resolved (then [`find_device`] can't prove a candidate is safe and falls back to name-only let mut wiring = audio_control::wire_now();
/// matching — no worse than before the guard existed). if wiring.mic_render.is_none() {
fn default_render_id() -> Option<String> { tracing::info!("no usable virtual mic device present — attempting auto-install");
wasapi::DeviceEnumerator::new() // SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s
.ok()? // `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer;
.get_default_device(&Direction::Render) // calling it imposes no extra precondition here (it takes no args and aliases nothing).
.ok()? // Its internal contract holds: the `DiInstall` type matches the documented
.get_id() // `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a
.ok() // NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
} // dedicated mic thread.
if unsafe { install_steam_audio_pair() } {
/// Resolve the virtual-mic target among render endpoints by friendly-name, **excluding the endpoint wiring = audio_control::wire_now();
/// the loopback captures** (the [`default_render_id`] anti-echo guard). Logs all candidates so a
/// missing/skipped device is diagnosable.
fn find_device() -> Result<wasapi::Device> {
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=<friendly-name substring>."
)
}
})
}
/// 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<wasapi::Device> {
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)
}
} }
} }
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=<friendly-name substring>."
);
};
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 /// Best-effort: install BOTH Steam Streaming audio devices (the "Steam pair") so mic passthrough
@@ -229,9 +168,9 @@ fn find_or_install_device() -> Result<wasapi::Device> {
/// Play ships `SteamStreamingMicrophone.inf` + `SteamStreamingSpeakers.inf`: the microphone gives the /// 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 /// 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 /// **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 /// the mic land on different devices and never echo (see [`super::wiring_plan`]). Returns true if
/// installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs admin — /// either installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs
/// the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set. /// admin — the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set.
unsafe fn install_steam_audio_pair() -> bool { unsafe fn install_steam_audio_pair() -> bool {
// Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink). // Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink).
let mic = try_install_steam_audio("SteamStreamingMicrophone.inf"); 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 // 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. // 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 setup = (|| -> Result<(wasapi::AudioClient, wasapi::AudioRenderClient, wasapi::Handle, String)> {
let device = find_or_install_device()?; let (device, name) = resolve_target()?;
let name = device.get_friendlyname().unwrap_or_else(|_| "virtual mic".into());
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
// 48 kHz stereo f32; autoconvert lets WASAPI shared-mode SRC match the device mix format. // 48 kHz stereo f32; autoconvert lets WASAPI shared-mode SRC match the device mix format.
let desired = WaveFormat::new( let desired = WaveFormat::new(
@@ -359,6 +297,8 @@ fn render_thread(
}; };
let _ = ready.send(Ok(name)); 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<u8> = Vec::new(); let mut buf: Vec<u8> = Vec::new();
while !stop.load(Ordering::Relaxed) { while !stop.load(Ordering::Relaxed) {
// The device signals when it wants more data; finite timeout keeps `stop` responsive. // The device signals when it wants more data; finite timeout keeps `stop` responsive.
@@ -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<Endpoint>,
/// The mic device's CAPTURE side — host apps record this; made the default recording device.
pub mic_capture: Option<Endpoint>,
/// Render endpoint for the desktop-audio loopback; made the default playback device.
pub loopback_render: Option<Endpoint>,
}
/// 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)");
}
}
+10 -115
View File
@@ -222,10 +222,13 @@ pub(crate) async fn serve(
// session — which, under rapid client reconnects, raced a prior session's portal teardown and // 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). // wedged KWin's EIS setup ("EIS setup timed out"). Gamepads stay per-session (uinput).
let injector = crate::inject::InjectorService::start(); let injector = crate::inject::InjectorService::start();
// One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink // One virtual microphone for the whole host lifetime (see [`crate::audio::MicPump`]): the
// (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps record from (Linux // client's mic uplink (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps
// PipeWire Audio/Source; Windows a virtual audio device's render endpoint). // record from (Linux PipeWire Audio/Source; Windows a virtual audio device's render endpoint).
let mic_service = MicService::start(); // 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 // 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 // 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. // `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. /// actual pad creation at its own MAX_PADS.
const MAX_WIRE_PADS: usize = 16; const MAX_WIRE_PADS: usize = 16;
/// Backoff between reopen attempts after a host-lifetime service's backend (the mic source, a /// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
/// capturer) fails to open or its worker dies, so a persistently-unavailable resource isn't hammered. /// 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); 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<Vec<u8>>,
}
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 × 510 ms frames ≈ 0.30.6 s of slack, far more than the decode loop ever lags.
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(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<Vec<u8>> {
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<Vec<u8>>) {
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<Vec<u8>>) {
let mut mic: Option<Box<dyn crate::audio::VirtualMic>> = None;
let mut decoder: Option<opus::Decoder> = None;
let mut last_failed: Option<std::time::Instant> = 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). /// 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)), /// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)),