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
+111 -12
View File
@@ -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<Vec<f32>>,
pcm: std::sync::mpsc::SyncSender<(std::time::Instant, Vec<f32>)>,
channels: u32,
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 {
@@ -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::<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 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<Vec<f32>>,
rx: Receiver<(std::time::Instant, Vec<f32>)>,
ring: VecDeque<f32>,
channels: usize,
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(
pcm_rx: Receiver<Vec<f32>>,
pcm_rx: Receiver<(std::time::Instant, Vec<f32>)>,
quit_rx: pipewire::channel::Receiver<Terminate>,
channels: u32,
flush: Arc<AtomicBool>,
) -> 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();
@@ -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<Endpoint> {
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<Option<Wiring>> = 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::<Vec<_>>(),
"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::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. ---
@@ -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
@@ -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<Mutex<VecDeque<u8>>>,
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<()>>,
}
@@ -68,25 +59,29 @@ impl WasapiVirtualMic {
);
let queue = Arc::new(Mutex::new(VecDeque::<u8>::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::<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()
.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<String> {
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<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)
}
/// 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=<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
@@ -229,9 +168,9 @@ fn find_or_install_device() -> Result<wasapi::Device> {
/// 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<u8> = Vec::new();
while !stop.load(Ordering::Relaxed) {
// 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)");
}
}