fix(audio/windows): explicit-endpoint capture, client-only playback, self-healing watchdog

Root cause of the field report (Android client, Windows host: no audio until
the user manually cycled Sound-output devices, then audio on BOTH PC and
phone): the WASAPI loopback captured whatever the default render endpoint
was at open time — not the wiring plan's chosen endpoint — the plan's
IPolicyConfig default-set is warn-only and racy, and nothing reacted to
mid-stream default-device changes.

- Explicit-endpoint capture: the capture thread opens the plan's
  loopback_render by id, never "the default" (KEEP_DEFAULT preserves the old
  default-capturing behavior with the echo guard).
- Client-only playback default: wiring_plan::plan(..., host_audio) prefers a
  silent sink (Steam Streaming Microphone render side — loopback-validated,
  silent on host) over real hardware, so stream audio plays on the client
  only; PUNKTFUNK_HOST_AUDIO=1 restores real-hw-first (audible on the host).
  The capture side auto-installs the Steam pair once per process when no
  silent sink exists; open() handshake timeout 3s -> 30s to cover it, and a
  handshake timeout now stops the detached thread (it used to run for the
  process lifetime with the default still parked).
- Self-healing capture thread (wasapi_cap): outer capture_once loop
  (Assert|Follow) with a ~1s watchdog on the default render id. A user
  switch to a capturable endpoint is followed (their choice wins, audio on
  both); a switch to a dud (cable/SSS/mic target) re-asserts the plan;
  IPolicyConfig-denied converges to Follow instead of churning. Device
  errors reopen with 2s backoff; only the FIRST open failure is fatal.
  Zero-packets breadcrumb after 30s distinguishes broken-loopback from
  quiet-desktop.
- Park/restore of the default playback device (audio_control):
  wire_now(set_playback) parks the default on the loopback sink only for the
  capture's lifetime (the mic pump passes false — it runs while the host is
  idle); crash marker audio-default.prev + recover_orphaned_default() at
  first wire; restore is skipped if the operator changed the default
  themselves. A mic-default hygiene pass keeps VB-Cable installs audible and
  never records the mic target as the restore target.
- Session-end park_audio_capture(): Windows DROPS the capturer (thread join
  restores the default) instead of caching it; Linux keeps the parked
  PipeWire thread. Composes with the stream-sink idle() hook at all three
  park sites (idle is a no-op on Windows).

Verified: Linux (.21) clippy -D warnings + 176 punktfunk-host tests green
(incl. the new wiring-plan preference tests); Windows (.173) clippy with
nvenc,amf-qsv --all-targets -D warnings green at this exact tree.
On-glass winbox/Android validation still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 17:00:00 +02:00
parent 97c5778a36
commit 4d89dcd3d7
7 changed files with 658 additions and 149 deletions
+19 -1
View File
@@ -56,7 +56,9 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
#[cfg(target_os = "windows")]
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
// 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.
// resolving its endpoint — a fresh plan per open, because Windows endpoints churn — and
// parks the default playback device on the plan's loopback endpoint (a silent sink by
// default: audio plays on the client only) until the capturer is dropped.
wasapi_cap::WasapiLoopbackCapturer::open(channels)
.map(|c| Box::new(c) as Box<dyn AudioCapturer>)
}
@@ -66,6 +68,22 @@ pub fn open_audio_capture(_channels: u32) -> Result<Box<dyn AudioCapturer>> {
anyhow::bail!("audio capture requires Linux + PipeWire or Windows + WASAPI")
}
/// Park a capturer at session end. Linux: store it in the persistent slot so the next session
/// reuses it (no PipeWire thread churn). Windows: DROP it instead — closing the capture restores
/// the operator's default playback device (it was parked on the loopback sink for the stream's
/// lifetime, silencing the host), and a WASAPI reopen at the next session start is cheap and
/// re-runs the wiring plan against the then-current endpoints.
pub fn park_audio_capture(
slot: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
cap: Box<dyn AudioCapturer>,
) {
if cfg!(target_os = "windows") {
drop(cap);
} else {
*slot.lock().unwrap() = Some(cap);
}
}
/// The inverse of [`AudioCapturer`]: a virtual microphone the host *produces*. It registers a
/// PipeWire `Audio/Source` node that host apps can record from; the host [`push`](Self::push)es
/// decoded client-mic PCM (interleaved `f32` at [`SAMPLE_RATE`]) into it, and PipeWire delivers
@@ -11,12 +11,20 @@
//! * 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`] captures;
//! * default **PLAYBACK** → the plan's loopback endpoint, applied ONLY while a desktop-audio capture
//! is open (`set_playback` — the mic pump must never park the playback default while the host is
//! idle). By default that endpoint is the SILENT sink (Steam Streaming Microphone render side) so
//! audio plays on the client only; `PUNKTFUNK_HOST_AUDIO` prefers real hardware instead (audible on
//! both ends). **Never** the Steam Streaming Speakers, whose loopback is silent — validated live;
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default.
//!
//! Because the playback default is *parked* on a silent sink during a stream, it is remembered
//! ([`park_default_playback`], plus an on-disk crash marker) and put back when the capture closes
//! ([`restore_default_playback`]) or, after a crash, on the next process's first wiring pass — an
//! operator must never be stranded with silent speakers. A default the operator changed themselves
//! mid-stream is respected (no restore over their choice).
//!
//! 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
@@ -61,18 +69,28 @@ fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
out
}
/// `PUNKTFUNK_HOST_AUDIO`: the operator wants the stream audible on the host too — the loopback
/// plan prefers real hardware over the silent sink (the pre-client-only-default behavior).
pub(crate) fn host_audio_requested() -> bool {
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
}
/// 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 {
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
/// parks the default PLAYBACK device on the plan's loopback endpoint for the capture's lifetime
/// (the mic pump passes false: it runs while the host is idle and must not silence the box).
/// 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(set_playback: bool) -> Wiring {
recover_orphaned_default();
let renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture);
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok()
.map(|s| s.to_lowercase());
let wiring = plan(&renders, &captures, want.as_deref());
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
// Log assignment changes exactly once (first plan included).
static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
@@ -107,16 +125,38 @@ pub(crate) fn wire_now() -> Wiring {
}
return wiring;
}
if let Some((name, id)) = &wiring.loopback_render {
match set_default_endpoint(id) {
Ok(()) => {
if changed {
tracing::info!(device = %name,
"audio wiring: default playback = desktop-audio loopback source");
}
}
// Default-playback hygiene, on EVERY wire (mic pump at boot included): if the default render
// endpoint IS the mic target — VB-CABLE installs have been seen grabbing the default — every
// app renders its audio INTO the virtual mic: recorders hear the desktop mix and the operator
// hears nothing. Move the default to an audible endpoint. The pre-split wire_now covered this
// as a side effect of always setting the playback default; the `set_playback` split must not
// lose it — and it must run BEFORE parking, so a parked `prev` can never be the mic target
// (restoring the cable as default after a stream would re-break the box).
if let Some((mic_name, mic_id)) = &wiring.mic_render {
if default_render_id().as_deref() == Some(mic_id.as_str()) {
// Audible preference = the host_audio plan's loopback pick (real hardware first).
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
Some((name, id)) => match set_default_endpoint(&id) {
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
"default playback was the virtual-mic target — moved it so desktop \
audio no longer feeds the mic"),
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default playback device"),
"failed to move the default playback off the virtual-mic target"),
},
None => {
if changed {
tracing::warn!(mic = %mic_name,
"default playback is the virtual-mic target and no other usable \
render endpoint exists — desktop audio will feed the mic");
}
}
}
}
}
if set_playback {
if let Some((name, id)) = &wiring.loopback_render {
let mic_id = wiring.mic_render.as_ref().map(|(_, m)| m.as_str());
park_default_playback(name, id, changed, mic_id);
}
}
if let Some((name, id)) = &wiring.mic_capture {
@@ -134,6 +174,112 @@ pub(crate) fn wire_now() -> Wiring {
wiring
}
/// The operator's default playback endpoint while we have it parked on the loopback sink:
/// `(previous_id, id_we_set)`. In-memory source of truth; mirrored to [`park_marker_path`] so a
/// crashed host can't strand the box on the silent sink.
static PARKED: Mutex<Option<(String, String)>> = Mutex::new(None);
/// On-disk crash marker mirroring [`PARKED`] (two lines: previous id, set id).
fn park_marker_path() -> std::path::PathBuf {
pf_paths::config_dir().join("audio-default.prev")
}
/// The current default RENDER endpoint id, if any.
fn default_render_id() -> Option<String> {
wasapi::DeviceEnumerator::new()
.ok()?
.get_default_device(&Direction::Render)
.ok()?
.get_id()
.ok()
}
/// Once per process: if a crash marker from a previous run exists, the host died while the
/// playback default was parked — put the operator's device back, but only if the default still
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
/// (the mic pump wires eagerly at host start, so this fires at boot, not at the first stream).
fn recover_orphaned_default() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let path = park_marker_path();
let Ok(s) = std::fs::read_to_string(&path) else {
return;
};
let _ = std::fs::remove_file(&path);
let mut lines = s.lines();
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
return;
};
if default_render_id().as_deref() != Some(set) {
return;
}
match set_default_endpoint(prev) {
Ok(()) => tracing::info!(
"restored the default playback device a previous host run left parked"
),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"failed to restore the default playback device left by a previous run"),
}
});
}
/// Make `id` the default playback device for the duration of the desktop-audio capture,
/// remembering the operator's current default (in memory + the crash marker) the FIRST time so
/// [`restore_default_playback`] can put it back. Nothing is remembered when `id` already is the
/// default — there is nothing to restore. The MIC target is never remembered as the previous
/// default (restoring it would feed desktop audio into the virtual mic — the hygiene pass in
/// [`wire_now`] normally moved the default off it already; this guards the propagation race).
fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&str>) {
let cur = default_render_id();
if cur.as_deref() != Some(id) {
let mut parked = PARKED.lock().unwrap();
match parked.as_mut() {
None => {
if let Some(prev) = cur.filter(|c| Some(c.as_str()) != mic_id) {
let _ = std::fs::write(park_marker_path(), format!("{prev}\n{id}"));
*parked = Some((prev, id.to_string()));
}
}
// Re-park onto a different endpoint mid-stream (plan changed): keep the ORIGINAL
// previous default, update what we set.
Some((prev, set)) if set != id => {
let _ = std::fs::write(park_marker_path(), format!("{prev}\n{id}"));
*set = id.to_string();
}
Some(_) => {}
}
}
match set_default_endpoint(id) {
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"),
}
}
/// Put the operator's default playback device back after streaming — the inverse of
/// [`park_default_playback`]. No-op if we never parked it, and a default the operator changed
/// themselves mid-stream is left alone (their choice wins). Must run on a COM-initialized thread
/// (called from the capture thread's exit path).
pub(crate) fn restore_default_playback() {
let Some((prev, set)) = PARKED.lock().unwrap().take() else {
return;
};
let _ = std::fs::remove_file(park_marker_path());
if default_render_id().as_deref() != Some(set.as_str()) {
return;
}
match set_default_endpoint(&prev) {
Ok(()) => tracing::info!("default playback device restored after streaming"),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"failed to restore the default playback device after streaming"),
}
}
/// 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()
@@ -1,20 +1,36 @@
//! WASAPI loopback capture of the default render endpoint (system output) — the Windows analogue
//! of the PipeWire sink-monitor backend. Delivers interleaved f32 PCM at 48 kHz in the requested
//! WASAPI loopback capture of the desktop mix (system output) — the Windows analogue of the
//! PipeWire sink-monitor backend. Delivers interleaved f32 PCM at 48 kHz in the requested
//! channel count (stereo / 5.1 / 7.1, canonical wire order FL FR FC LFE RL RR SL SR via the
//! explicit `dwChannelMask`), ready for the Opus path with NO resampling (WASAPI shared-mode
//! autoconvert does any SRC + up/downmix to the requested layout). WASAPI objects are
//! 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.
//!
//! **Which endpoint, and self-healing.** The capture thread opens the wiring plan's loopback
//! endpoint EXPLICITLY — never "whatever the default happens to be": binding to the default
//! raced the plan's own `IPolicyConfig` default change and captured duds when that change failed
//! or the operator's default sat on a silent endpoint ("CABLE In 16ch", Steam Streaming
//! Speakers) — the field-reported "no audio until I cycled output devices" failure. The plan
//! also parks the default playback device on the loopback endpoint (a silent sink by default —
//! client-only audio; see [`super::wiring_plan`]) so app streams migrate to it.
//!
//! The thread then self-heals for its whole life: a ~1 s watchdog notices the default render
//! device changing under us — the operator picked a different output mid-stream — and reacts:
//! a loopback-capturable choice is FOLLOWED (their explicit choice wins; audio then also plays
//! on the host), a known-dud choice (cable/Steam Speakers/the mic target) snaps back to the
//! plan. Device errors (endpoint invalidated, engine restart) reopen with backoff instead of
//! killing audio for the rest of the session. On thread exit (capturer dropped at stream end)
//! the parked default playback device is restored.
use super::{audio_control, AudioCapturer, SAMPLE_RATE};
use super::{audio_control, wiring_plan, AudioCapturer, 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, SyncSender};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
use std::time::{Duration, Instant};
use wasapi::{Device, DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
pub struct WasapiLoopbackCapturer {
chunks: Receiver<Vec<f32>>,
@@ -43,12 +59,11 @@ impl WasapiLoopbackCapturer {
}
})
.context("spawn wasapi audio thread")?;
match ready_rx.recv_timeout(Duration::from_secs(3)) {
// Generous handshake: the first open may auto-install the Steam Streaming pair (two
// driver installs, ~5 s of settling each) before the endpoint exists.
match ready_rx.recv_timeout(Duration::from_secs(30)) {
Ok(Ok(())) => {
tracing::info!(
channels,
"WASAPI loopback capture: 48 kHz f32 (default render endpoint)"
);
tracing::info!(channels, "WASAPI loopback capture: 48 kHz f32");
Ok(WasapiLoopbackCapturer {
chunks: rx,
channels,
@@ -57,9 +72,13 @@ impl WasapiLoopbackCapturer {
})
}
Ok(Err(e)) => Err(e),
Err(_) => Err(anyhow!(
"wasapi loopback init timed out (no default render endpoint?)"
)),
Err(_) => {
// The thread outlived the handshake (stalled driver install / hung endpoint).
// Tell it to stop — otherwise it would keep capturing detached for the process
// lifetime WITH the playback default still parked (restore only runs on exit).
stop.store(true, Ordering::SeqCst);
Err(anyhow!("wasapi loopback init timed out"))
}
}
}
}
@@ -91,14 +110,37 @@ impl AudioCapturer for WasapiLoopbackCapturer {
}
}
/// How one open chooses its capture endpoint.
#[derive(Clone, Copy, PartialEq, Eq)]
enum TargetMode {
/// Capture the wiring plan's loopback endpoint and park the default playback device on it
/// (client-only audio when the plan found a silent sink). The initial and snap-back mode.
Assert,
/// Capture the CURRENT default render endpoint — the operator changed the default mid-stream
/// to a capturable device and their choice wins (audio then also plays on the host). Also the
/// resolution under `PUNKTFUNK_KEEP_DEFAULT`.
Follow,
}
/// Why one open's inner loop ended.
enum Next {
/// `stop` was set — the capturer is being dropped.
Stopped,
/// Reopen in the given mode (default-device change observed).
Reopen(TargetMode),
}
/// Backoff between self-heal reopen attempts after a capture failure.
const REOPEN_BACKOFF: Duration = Duration::from_secs(2);
/// Watchdog cadence for "did the default render device change under us?" checks.
const DEFAULT_CHECK_EVERY: Duration = Duration::from_secs(1);
fn capture_thread(
tx: SyncSender<Vec<f32>>,
stop: Arc<AtomicBool>,
ready: SyncSender<Result<()>>,
channels: u32,
) -> Result<()> {
// Interleaved f32: channels * 4 bytes per frame.
let block_align = channels as usize * 4;
// COM must be initialized on THIS thread (MTA), before any device call.
if let Err(e) = wasapi::initialize_mta()
.ok()
@@ -107,25 +149,125 @@ fn capture_thread(
let _ = ready.send(Err(e));
return Ok(());
}
let res = (|| -> Result<()> {
// Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE
// 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,
// Self-heal for the capturer's whole life: each `capture_once` is one endpoint open + inner
// capture loop; it returns to reopen (default-device change) or errors (device invalidated,
// engine restart), and only the FIRST open's failure is fatal — it surfaces as `open()`'s Err
// so the session honestly runs without audio (the native plane retries with its own backoff).
let mut ready = Some(ready);
let mut mode = TargetMode::Assert;
let mut failures: u64 = 0;
while !stop.load(Ordering::Relaxed) {
match capture_once(&tx, &stop, &mut ready, channels, mode) {
Ok(Next::Stopped) => break,
Ok(Next::Reopen(m)) => {
mode = m;
failures = 0;
}
Err(e) => {
if let Some(r) = ready.take() {
let _ = r.send(Err(anyhow!("{e:#}")));
break;
}
failures += 1;
if failures.is_power_of_two() {
tracing::warn!(error = %format!("{e:#}"), count = failures,
"audio loopback capture failed — reopening");
}
mode = TargetMode::Assert;
// Backoff in stop-responsive slices.
let until = Instant::now() + REOPEN_BACKOFF;
while Instant::now() < until && !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(100));
}
}
}
}
// Hand the default playback device back to the operator (no-op if we never parked it, or if
// they changed it themselves mid-stream). COM is initialized on this thread.
audio_control::restore_default_playback();
Ok(())
}
/// The current default render endpoint, with its id (`None` on any enumeration failure —
/// transient failures must not kill the capture).
fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
let d = en.get_default_device(&Direction::Render).ok()?;
let id = d.get_id().ok()?;
Some((d, id))
}
/// One endpoint open + capture loop. Returns how to continue ([`Next`]) or an error (first open:
/// fatal via the `ready` handshake; later: reopen with backoff).
fn capture_once(
tx: &SyncSender<Vec<f32>>,
stop: &AtomicBool,
ready: &mut Option<SyncSender<Result<()>>>,
channels: u32,
mode: TargetMode,
) -> Result<Next> {
// Interleaved f32: channels * 4 bytes per frame.
let block_align = channels as usize * 4;
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some();
// Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
let assert_plan = mode == TargetMode::Assert && !keep_default;
let mut wiring = audio_control::wire_now(assert_plan);
// Client-only audio needs a silent-on-host sink with a working loopback (the Steam Streaming
// Microphone's render side). If the plan had to settle for real hardware (or nothing), try —
// once per process — to install the Steam pair (present when Steam is), then re-plan.
if assert_plan && !audio_control::host_audio_requested() {
let have_silent = wiring
.loopback_render
.as_ref()
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase()));
static INSTALL_TRIED: AtomicBool = AtomicBool::new(false);
if !have_silent && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
// 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.
if unsafe { super::wasapi_mic::install_steam_audio_pair() } {
wiring = audio_control::wire_now(true);
}
if !wiring
.loopback_render
.as_ref()
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase()))
{
tracing::info!(
"no silent virtual sink for client-only audio — desktop audio will also play \
on the host (install Steam, whose Remote Play streaming drivers provide one)"
);
}
}
}
let en = DeviceEnumerator::new().context("DeviceEnumerator")?;
// Resolve the endpoint to capture. ECHO GUARD (Follow/KEEP_DEFAULT shapes): 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, so
// fall back to the plan's loopback endpoint, or refuse — no desktop audio beats an echo loop.
let (device, dev_name, dev_id) = if assert_plan {
let Some(ep) = wiring.loopback_render.clone() else {
anyhow::bail!(
"no loopback-capturable render endpoint (every usable endpoint is reserved for \
the virtual mic or has a silent loopback) — attach an output device or install \
the Steam Streaming pair to get desktop audio"
);
};
let device = if default_is_mic {
let Some(lb) = &wiring.loopback_render else {
let d = audio_control::open_endpoint(&ep)?;
(d, ep.0, ep.1)
} else {
let (default, id) = default_render(&en)
.context("default render endpoint (loopback needs a render device)")?;
let default_is_mic = wiring
.mic_render
.as_ref()
.is_some_and(|(_, mic_id)| *mic_id == id);
if default_is_mic {
let Some(lb) = wiring.loopback_render.clone() 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 \
@@ -135,10 +277,14 @@ fn capture_thread(
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)?
let d = audio_control::open_endpoint(&lb)?;
(d, lb.0, lb.1)
} else {
default
let name = default.get_friendlyname().unwrap_or_default();
(default, name, id)
}
};
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
@@ -156,12 +302,12 @@ fn capture_thread(
);
let (default_period, _min_period) =
audio_client.get_device_period().context("device period")?;
let mode = StreamMode::EventsShared {
let stream_mode = StreamMode::EventsShared {
autoconvert: true,
buffer_duration_hns: default_period,
};
audio_client
.initialize_client(&desired, &Direction::Capture, &mode)
.initialize_client(&desired, &Direction::Capture, &stream_mode)
.context("initialize loopback client")?;
let h_event = audio_client.set_get_eventhandle().context("event handle")?;
let capture_client = audio_client
@@ -170,18 +316,60 @@ fn capture_thread(
audio_client
.start_stream()
.context("start loopback stream")?;
let _ = ready.send(Ok(()));
if let Some(r) = ready.take() {
let _ = r.send(Ok(()));
}
tracing::info!(device = %dev_name,
follow = matches!(mode, TargetMode::Follow) || keep_default,
"audio loopback capturing");
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just
// parked the default on our endpoint — if it did NOT stick (IPolicyConfig denied) converge
// instead of churning: follow a capturable default (audio plays on both ends), warn once on a
// dud. Afterwards only a CHANGE of the observed default id triggers a reaction, so a
// permanently-denied default set can never reopen-loop.
let mut seen_default = default_render(&en).map(|(_, id)| id);
if assert_plan {
if let Some(d) = seen_default.as_deref() {
if d != dev_id {
match judge_default(&en, &wiring, d) {
DefaultKind::Capturable(name) => {
tracing::info!(default = %name, planned = %dev_name,
"could not park the default playback on the planned endpoint — \
capturing the actual default instead (audio audible on the host)");
return Ok(Next::Reopen(TargetMode::Follow));
}
DefaultKind::Dud(name) => tracing::warn!(default = %name, planned = %dev_name,
"default playback stayed on an endpoint whose loopback cannot work — \
capturing the planned endpoint; desktop audio may be silent"),
DefaultKind::Unknown => {}
}
}
}
}
let mut bytes: VecDeque<u8> = VecDeque::new();
while !stop.load(Ordering::Relaxed) {
// Loopback fires events only while audio renders; the finite timeout keeps `stop` responsive.
if h_event.wait_for_event(100).is_err() {
continue;
let mut last_check = Instant::now();
// Triage breadcrumb: a broken loopback (endpoint renders but its loopback tap delivers
// nothing — the Steam Streaming Speakers failure shape) is indistinguishable from a simply
// quiet desktop, so after 30 s with zero packets say so ONCE. Info, not warn: an idle host
// is legitimately silent.
let opened_at = Instant::now();
let mut saw_packets = false;
let mut silence_noted = false;
loop {
if stop.load(Ordering::Relaxed) {
audio_client.stop_stream().ok();
return Ok(Next::Stopped);
}
// Loopback fires events only while audio renders; the finite timeout keeps `stop` (and
// the watchdog) responsive.
let _ = h_event.wait_for_event(100);
loop {
match capture_client.get_next_packet_size() {
Ok(Some(0)) | Ok(None) => break,
Ok(Some(_n)) => {
saw_packets = true;
capture_client
.read_from_device_to_deque(&mut bytes)
.context("read loopback")?;
@@ -189,10 +377,15 @@ fn capture_thread(
Err(e) => return Err(anyhow!("get_next_packet_size: {e}")),
}
}
let whole = (bytes.len() / block_align) * block_align;
if whole == 0 {
continue;
if !saw_packets && !silence_noted && opened_at.elapsed() >= Duration::from_secs(30) {
silence_noted = true;
tracing::info!(device = %dev_name,
"no audio captured in the first 30 s — fine if the host is quiet; if it should \
be playing audio, this endpoint's loopback may be broken (set \
PUNKTFUNK_HOST_AUDIO=1 to prefer real hardware)");
}
let whole = (bytes.len() / block_align) * block_align;
if whole > 0 {
let raw: Vec<u8> = bytes.drain(..whole).collect();
let mut samples = Vec::with_capacity(whole / 4);
for c in raw.chunks_exact(4) {
@@ -200,13 +393,72 @@ fn capture_thread(
}
let _ = tx.try_send(samples); // non-blocking, lossy — same discipline as PipeWire
}
// Watchdog: react when the default render device CHANGES from what we last observed —
// the operator picked a different output mid-stream (the old code never noticed and
// captured the stale endpoint forever; "cycle your output devices" was the workaround).
if last_check.elapsed() >= DEFAULT_CHECK_EVERY {
last_check = Instant::now();
if let Some((_, nid)) = default_render(&en) {
if seen_default.as_deref() != Some(nid.as_str()) {
seen_default = Some(nid.clone());
if nid != dev_id {
audio_client.stop_stream().ok();
Ok(())
})();
if let Err(ref e) = res {
let _ = ready.send(Err(anyhow!("{e:#}")));
if keep_default {
tracing::info!(
"default render device changed (PUNKTFUNK_KEEP_DEFAULT) — \
following it"
);
return Ok(Next::Reopen(TargetMode::Follow));
}
return Ok(match judge_default(&en, &wiring, &nid) {
DefaultKind::Capturable(name) => {
tracing::info!(device = %name,
"operator changed the output device mid-stream — following \
it (audio now also plays on the host)");
Next::Reopen(TargetMode::Follow)
}
DefaultKind::Dud(name) => {
tracing::warn!(device = %name,
"default playback moved to an endpoint whose loopback cannot \
work — re-asserting the audio wiring plan");
Next::Reopen(TargetMode::Assert)
}
DefaultKind::Unknown => Next::Reopen(TargetMode::Assert),
});
}
}
}
}
}
}
/// The watchdog's verdict on a newly-observed default render endpoint.
enum DefaultKind {
/// Loopback-capturable — following it yields working audio (audible on the host too).
Capturable(String),
/// The mic target or a known-silent/echoing loopback (cable, Steam Streaming Speakers) —
/// following it can only produce silence or an echo loop.
Dud(String),
/// Could not resolve the endpoint (transient churn).
Unknown,
}
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = en.get_device(id) else {
return DefaultKind::Unknown;
};
let name = dev.get_friendlyname().unwrap_or_default();
let ln = name.to_lowercase();
let is_mic = wiring
.mic_render
.as_ref()
.is_some_and(|(_, mic_id)| mic_id == id);
if is_mic || wiring_plan::excluded_from_loopback(&ln) {
DefaultKind::Dud(name)
} else {
DefaultKind::Capturable(name)
}
res
}
#[cfg(test)]
@@ -148,7 +148,9 @@ impl VirtualMic for WasapiVirtualMic {
/// 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();
// set_playback=false: the mic pump runs while the host is idle — only the desktop-audio
// capture may park the playback default (on the silent sink) for a stream's lifetime.
let mut wiring = audio_control::wire_now(false);
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
@@ -159,7 +161,7 @@ fn resolve_target() -> Result<(wasapi::Device, String)> {
// 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();
wiring = audio_control::wire_now(false);
}
}
let Some(ep) = wiring.mic_render else {
@@ -178,10 +180,12 @@ fn resolve_target() -> Result<(wasapi::Device, String)> {
/// 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 [`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 {
/// the mic land on different devices and never echo (see [`super::wiring_plan`]). The Streaming
/// Microphone's render side doubles as the client-only-audio silent sink, so the desktop-audio
/// capture ([`super::wasapi_cap`]) also installs the pair when no silent sink exists. 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.
pub(crate) 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");
let spk = try_install_steam_audio("SteamStreamingSpeakers.inf");
+114 -26
View File
@@ -18,6 +18,14 @@
//! 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).
//!
//! **Loopback preference depends on where the audio should be heard.** The default is
//! *client-only*: prefer a render endpoint that is silent on the host but has a WORKING loopback
//! (the Steam Streaming *Microphone*'s render side — validated live; the Steam Streaming
//! *Speakers*' loopback is silent) so the desktop mix reaches the stream without also blasting
//! out of the host's speakers. Real hardware is the fallback (audio then plays on both ends).
//! With `host_audio` (the `PUNKTFUNK_HOST_AUDIO` opt-in) the order flips back: real hardware
//! first, so the operator hears the stream locally.
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
pub(crate) type Endpoint = (String, String);
@@ -62,11 +70,18 @@ fn capture_for(mic_render_lname: &str) -> &'static [&'static str] {
/// 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 {
/// Streaming Speakers, whose loopback is silent (validated live). Also the capture-side
/// watchdog's test for "the operator's new default can never work — snap back to the plan".
pub(crate) fn excluded_from_loopback(lname: &str) -> bool {
lname.contains("cable") || lname.contains("steam streaming speakers")
}
/// A render endpoint that is SILENT on the host but loopback-capturable — the client-only audio
/// sink. Only the Steam Streaming Microphone's render side qualifies today (validated live).
pub(crate) fn silent_sink(lname: &str) -> bool {
lname.contains("steam streaming microphone")
}
/// 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).
@@ -78,8 +93,15 @@ fn virtualish(lname: &str) -> bool {
}
/// 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 {
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
/// flips the loopback preference to real hardware (audio audible on the host too); the default
/// (`false`) prefers the silent sink so audio plays on the client only.
pub(crate) fn plan(
renders: &[Endpoint],
captures: &[Endpoint],
mic_want: Option<&str>,
host_audio: bool,
) -> Wiring {
let find_render = |needle: &str| {
renders
.iter()
@@ -103,25 +125,31 @@ pub(crate) fn plan(renders: &[Endpoint], captures: &[Endpoint], mic_want: Option
})
});
// 3. Loopback from the REMAINING renders: real hardware > Steam Streaming Microphone (its
// loopback works, unlike the Speakers') > any non-excluded leftover.
// 3. Loopback from the REMAINING renders. Client-only (default): the silent sink (Steam
// Streaming Microphone — its loopback works, unlike the Speakers') > real hardware
// (audible fallback) > any non-excluded leftover. `host_audio`: real hardware first.
let not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id);
let loopback_render = renders
.iter()
.find(|(n, id)| {
let real_hw = || {
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(|| {
};
let silent = || {
renders
.iter()
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
};
let leftover = || {
renders
.iter()
.find(|(n, id)| not_mic(id) && !excluded_from_loopback(&n.to_lowercase()))
})
};
let loopback_render = if host_audio {
real_hw().or_else(silent).or_else(leftover)
} else {
silent().or_else(real_hw).or_else(leftover)
}
.cloned();
Wiring {
@@ -139,8 +167,9 @@ mod tests {
(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.
/// The shipped configuration: real output + VB-CABLE (no Steam pair). Mic gets the cable;
/// with no silent sink the loopback falls back to the speakers (audio audible on both
/// ends), recording default = CABLE Output.
#[test]
fn gaming_pc_with_cable() {
let renders = [
@@ -151,7 +180,7 @@ mod tests {
ep("Microphone (Webcam)"),
ep("CABLE Output (VB-Audio Virtual Cable)"),
];
let w = plan(&renders, &captures, None);
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -163,6 +192,65 @@ mod tests {
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
/// Client-only (the default): with the full device zoo present — real output, VB-CABLE,
/// BOTH Steam endpoints — the loopback prefers the silent sink (Steam Streaming
/// Microphone's render side) over real hardware, so the host speakers stay quiet while
/// streaming. This is the dissidius/"audio from both PC and phone" configuration.
#[test]
fn client_only_prefers_silent_sink_over_hardware() {
let renders = [
ep("Speakers (Apple Audio Device)"),
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Speakers)"),
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
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, false);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
}
/// `PUNKTFUNK_HOST_AUDIO` flips the preference back: real hardware wins the loopback even
/// when the silent sink exists (the operator wants to hear the stream locally).
#[test]
fn host_audio_prefers_real_hardware() {
let renders = [
ep("Speakers (Apple Audio Device)"),
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let w = plan(&renders, &[], None, true);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Apple Audio Device)"
);
}
/// The multi-render VB-CABLE ("CABLE In 16ch" is a second render endpoint feeding the same
/// CABLE Output) must never be the loopback in EITHER mode: it feeds the mic's capture side,
/// and capturing it delivers silence (nothing renders there) — the reported no-audio dud.
#[test]
fn cable_16ch_never_loopback() {
let renders = [
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio);
assert!(w.loopback_render.is_none(), "host_audio={host_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.)
@@ -170,7 +258,7 @@ mod tests {
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);
let w = plan(&renders, &captures, None, false);
assert!(w.mic_render.is_some(), "mic must claim the only cable");
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
}
@@ -188,7 +276,7 @@ mod tests {
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
let w = plan(&renders, &captures, None);
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -212,7 +300,7 @@ mod tests {
ep("Speakers (Realtek HD Audio)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None);
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -226,7 +314,7 @@ mod tests {
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);
let w = plan(&renders, &captures, None, false);
assert!(w.mic_render.is_some());
assert!(w.loopback_render.is_none());
}
@@ -239,7 +327,7 @@ mod tests {
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Speakers)"),
];
let w = plan(&renders, &[], None);
let w = plan(&renders, &[], None, false);
assert!(w.loopback_render.is_none());
}
@@ -251,7 +339,7 @@ mod tests {
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"));
let w = plan(&renders, &captures, Some("voicemeeter input"), false);
assert_eq!(
w.mic_render.unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
@@ -267,7 +355,7 @@ mod tests {
#[test]
fn no_virtual_device() {
let renders = [ep("Speakers (Realtek HD Audio)")];
let w = plan(&renders, &[], None);
let w = plan(&renders, &[], None, false);
assert!(w.mic_render.is_none());
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
@@ -298,7 +298,7 @@ fn run(
};
let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, params, running);
cap.idle(); // parked between sessions — release the routing claim (Linux stream sink)
*audio_cap.lock().unwrap() = Some(cap);
audio::park_audio_capture(audio_cap, cap); // drop on Windows (restores the default), keep on Linux
result
}
+5 -4
View File
@@ -92,7 +92,7 @@ pub(super) fn audio_thread(
Err(e) => {
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
capturer.idle(); // parked, not streaming — release the routing claim
*audio_cap.lock().unwrap() = Some(capturer);
crate::audio::park_audio_capture(&audio_cap, capturer);
return;
}
};
@@ -173,11 +173,12 @@ pub(super) fn audio_thread(
}
}
}
// Return the live capturer for the next session (None if it died and never reopened),
// releasing its session-scoped routing claim (Linux: the default sink moves back).
// Park the live capturer for the next session (None if it died and never reopened),
// releasing its session-scoped routing claim (Linux: the default sink moves back;
// Windows: dropped, restoring the operator's default playback device).
if let Some(mut c) = capturer {
c.idle();
*audio_cap.lock().unwrap() = Some(c);
crate::audio::park_audio_capture(&audio_cap, c);
}
}