fix(host/audio): game audio outranks the mic on cable-less boxes

The wiring plan reserved the mic target unconditionally first, so on a box
without VB-Cable the mic took the Steam Streaming Microphone — the only
working client-only loopback sink — and desktop audio fell to the
known-silent Speakers last resort: a headless Steam-only host streamed
SILENCE (the 2026-08 field case), and the installer's 'optional (mic
passthrough)' wording never warned anyone.

The mic may now hold the Streaming Microphone only while the loopback still
gets a preferred (non-last-resort) pick without it — another silent sink or
real hardware. Otherwise the loopback takes the endpoint and the mic falls
to a lesser candidate or is honestly withheld (Wiring::mic_withheld), with
the open error naming the trade and the remedy. An operator
PUNKTFUNK_MIC_DEVICE override is exempt: an explicit choice may still
strand the loopback on the last resort.

Also: the Steam-pair auto-install latch is now once per INF-state instead
of once per process — an attempt made while Steam was absent re-arms when
its driver INFs later appear (files are invisible to the endpoint-set
fingerprint, so nothing else would ever retry), and a withheld mic skips
the pointless reinstall (the pair exists; the plan gave it to the loopback).
This commit is contained in:
2026-08-07 10:29:37 +02:00
parent b5205fef52
commit ef8214415f
4 changed files with 205 additions and 25 deletions
@@ -10,7 +10,10 @@
//!
//! * 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);
//! endpoint on the box (the loopback then reports itself unavailable instead of echoing). One
//! exception: the Steam Streaming Microphone is surrendered to the loopback when taking it would
//! leave desktop audio on the known-silent last resort or nothing — game audio outranks the mic
//! (see [`wiring_plan`], `Wiring::mic_withheld`);
//! * 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
@@ -216,6 +219,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
mic_capture = wiring.mic_capture.as_ref().map(|(n, _)| n.as_str()),
loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()),
loopback_last_resort = wiring.loopback_last_resort,
mic_withheld = wiring.mic_withheld,
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
"audio wiring plan"
);
@@ -33,7 +33,7 @@ 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::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use wasapi::{Device, DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
@@ -202,7 +202,7 @@ fn capture_thread(
}
Err(e) if ready.is_some() => {
// An unsatisfiable PLAN cannot improve within the handshake window — the
// once-per-process Steam-pair install already ran inside `capture_once` — so
// Steam-pair install latch already ran inside `capture_once` — so
// fail the open now with the full diagnosis instead of spending the transient
// retry budget on a structural verdict. The native plane owns first-open
// retries and backs off on its own.
@@ -366,16 +366,31 @@ fn capture_once(
let mut plan = audio_control::wire_now_full(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.
// Microphone's render side). If the plan had to settle for real hardware (or nothing), try to
// install the Steam pair (present when Steam is), then re-plan. The latch is once per
// INF-STATE, not once per process: an attempt made while Steam was absent re-arms when its
// driver INFs later appear (Steam installed mid-run) — files are invisible to the
// endpoint-set fingerprint, so nothing else would ever retry.
if assert_plan && !audio_control::host_audio_requested() {
let have_silent = |w: &wiring_plan::Wiring| {
w.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(&plan.wiring) && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
static TRIED_WITH_INFS: Mutex<Option<bool>> = Mutex::new(None);
let should_try = !have_silent(&plan.wiring) && {
let infs = super::wasapi_mic::steam_infs_present();
let mut tried = TRIED_WITH_INFS.lock().unwrap();
let go = match *tried {
None => true,
Some(had_infs) => !had_infs && infs,
};
if go {
*tried = Some(infs);
}
go
};
if should_try {
if super::wasapi_mic::install_steam_audio_pair() {
plan = audio_control::wire_now_full(true);
}
@@ -219,13 +219,24 @@ fn resolve_target() -> Result<(wasapi::Device, String)> {
// 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() {
if wiring.mic_render.is_none() && !wiring.mic_withheld {
// A WITHHELD mic skips the install attempt: the Streaming Microphone exists — the plan
// gave it to the loopback — so reinstalling the pair changes nothing and costs a 5 s
// endpoint-settle sleep per reopen.
tracing::info!("no usable virtual mic device present — attempting auto-install");
if install_steam_audio_pair() {
wiring = audio_control::wire_now(false);
}
}
let Some(ep) = wiring.mic_render else {
if wiring.mic_withheld {
anyhow::bail!(
"the Steam Streaming Microphone is carrying desktop audio (game audio outranks \
the mic; taking it would have silenced the stream) — install VB-Audio Virtual \
Cable to give the mic its own device, or set PUNKTFUNK_MIC_DEVICE=<friendly-name \
substring> to force a target."
);
}
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 \
@@ -287,6 +298,23 @@ pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
Some(path)
}
/// Do Steam's streaming-audio driver INFs exist on this box? The auto-install RE-ARM trigger:
/// INF files appearing later (Steam installed mid-run) are invisible to the endpoint-set
/// fingerprint — files are not endpoints — so the desktop-audio capture's install latch keys on
/// this instead of staying once-per-process ([`super::wasapi_cap`]).
pub(crate) fn steam_infs_present() -> bool {
use std::os::windows::ffi::OsStringExt;
["SteamStreamingMicrophone.inf", "SteamStreamingSpeakers.inf"]
.iter()
.any(|inf| {
steam_driver_inf_path(inf).is_some_and(|wide| {
// Drop the trailing NUL the FFI callers need; `exists` wants the bare path.
let len = wide.len().saturating_sub(1);
std::path::PathBuf::from(std::ffi::OsString::from_wide(&wide[..len])).exists()
})
})
}
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
+150 -17
View File
@@ -19,6 +19,17 @@
//! default render endpoint — which permanently killed mic passthrough in the exact configuration
//! the installer ships (VB-CABLE as the only render device).
//!
//! **One exception to mic-first — game audio outranks the mic.** The Steam Streaming
//! Microphone's render side is ALSO the only silent client-only loopback sink, so the mic may
//! take it only while the loopback still gets a preferred (non-last-resort) pick without it:
//! another silent sink, or real hardware. When taking it would leave desktop audio on the
//! known-silent Speakers or on nothing — the cable-less headless box, the recurring field
//! failure — the loopback gets the endpoint and the mic falls to a lesser candidate or is
//! honestly unavailable ([`Wiring::mic_withheld`]), with guidance naming the trade. The
//! cable-only rule above is untouched (a cable can never be a loopback, so the mic still wins
//! it), and an operator `PUNKTFUNK_MIC_DEVICE` override also still wins — an explicit choice
//! beats the trade-off.
//!
//! **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
@@ -118,6 +129,12 @@ pub(crate) struct Wiring {
/// the human-readable reason for the capture side to log — a quality risk the operator can act
/// on (attach a real output, or set the output mode to prefer hardware), not a failure.
pub loopback_narrowing: Option<String>,
/// The mic was DENIED the Steam Streaming Microphone because taking it would have left the
/// loopback with only the known-silent last resort or nothing — game audio outranks the
/// optional mic. (`mic_render` may still hold a lesser candidate; when it is `None` the
/// mic open fails with guidance naming the trade — a cable gives the mic its own device
/// without costing the loopback.)
pub mic_withheld: bool,
}
impl Wiring {
@@ -267,6 +284,36 @@ pub(crate) fn plan_with_formats(
Some(w) => find_render(w),
None => MIC_CANDIDATES.iter().find_map(|c| find_render(c)),
};
// Game audio outranks the mic: the Steam Streaming Microphone's render side is also the
// only silent client-only loopback sink, so the mic may hold it only while the loopback
// still gets a PREFERRED (non-last-resort) pick without it — another silent sink or real
// hardware, the same two tiers both preference orders draw from. Otherwise the endpoint
// goes to the loopback and the mic falls to a lesser candidate or (honestly) to none.
// Before this rule, the cable-less headless Steam box streamed SILENCE: the mic held the
// Streaming Microphone and the loopback got the known-silent Speakers (the 2026-08 field
// case). An operator override is exempt — an explicit PUNKTFUNK_MIC_DEVICE beats the
// trade-off.
let mut mic_withheld = false;
let mic_render = match mic_render {
Some((name, id)) if mic_want.is_none() && silent_sink(&name.to_lowercase()) => {
let loopback_survives = renders.iter().any(|(n, rid)| {
let ln = n.to_lowercase();
*rid != id
&& (silent_sink(&ln) || (!excluded_from_loopback(&ln) && !virtualish(&ln)))
});
if loopback_survives {
Some((name, id))
} else {
mic_withheld = true;
// Skip the silent-sink candidate; a lesser candidate may still serve the mic.
MIC_CANDIDATES
.iter()
.filter(|c| !silent_sink(c))
.find_map(|c| find_render(c))
}
}
other => other,
};
// 2. Its capture side (what host apps record).
let mic_capture = mic_render.as_ref().and_then(|(name, _)| {
@@ -342,6 +389,7 @@ pub(crate) fn plan_with_formats(
loopback_render,
loopback_last_resort,
loopback_narrowing,
mic_withheld,
}
}
@@ -570,8 +618,9 @@ mod tests {
);
}
/// No cable: the Steam Streaming Microphone doubles as the mic target, and the loopback
/// must NOT then pick the same endpoint (real hardware wins).
/// No cable: the Steam Streaming Microphone doubles as the mic target — allowed, because
/// the loopback still gets real hardware — and the loopback must NOT then pick the same
/// endpoint.
#[test]
fn steam_mic_as_target_never_doubles_as_loopback() {
let renders = [
@@ -584,18 +633,56 @@ mod tests {
w.mic_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
assert!(!w.mic_withheld);
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).
/// No cable and ONLY the Steam mic: GAME AUDIO wins the endpoint — the loopback takes the
/// render side (a working silent sink) and the mic is honestly withheld. The old rule gave
/// the mic the endpoint and the stream was silent.
#[test]
fn steam_mic_only_no_echo() {
fn steam_mic_only_audio_wins() {
let renders = [ep("Speakers (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.mic_render.is_some());
assert!(w.loopback_render.is_none());
assert!(w.mic_render.is_none());
assert!(w.mic_withheld);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
assert!(!w.loopback_last_resort);
}
/// Cable absent but a VoiceMeeter strip exists: the withheld mic falls to the lesser
/// candidate instead of dying — mic on the strip, loopback on the freed Streaming
/// Microphone render side. Both features work without a cable.
#[test]
fn withheld_mic_falls_to_voicemeeter() {
let renders = [
ep("Speakers (Steam Streaming Speakers)"),
ep("Speakers (Steam Streaming Microphone)"),
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
];
let captures = [
ep("Microphone (Steam Streaming Microphone)"),
ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)"),
];
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.as_ref().unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
);
assert!(w.mic_withheld);
assert_eq!(
w.mic_capture.unwrap().0,
"Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)"
);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
assert!(!w.loopback_last_resort);
}
/// Steam Streaming Speakers are never a PREFERRED loopback (their loopback is silent —
@@ -619,22 +706,53 @@ mod tests {
}
}
/// THE 2026-08 field case: no cable, only the Steam pair left after the display isolate
/// invalidated the monitor's DP audio endpoint. The mic reserves the Streaming Microphone
/// (the only mic candidate), and the plan must then take the Speakers as the last resort —
/// the old plan yielded no loopback here and the session never recovered.
/// THE 2026-08 field case, re-decided: no cable, only the Steam pair left after the display
/// isolate invalidated the monitor's DP audio endpoint. Game audio now OUTRANKS the mic —
/// the loopback takes the Streaming Microphone's render side (a WORKING silent sink)
/// instead of the mic holding it and stranding the loopback on the known-silent Speakers.
/// Audio streams; the mic is honestly withheld. Holds in both preference modes.
#[test]
fn field_case_steam_pair_only_takes_speakers_as_last_resort() {
fn field_case_steam_pair_only_audio_outranks_mic() {
let renders = [
ep("Altavoces (Steam Streaming Speakers)"),
ep("Altavoces (Steam Streaming Microphone)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false, &[]);
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio, &[]);
assert!(w.mic_render.is_none(), "host_audio={host_audio}");
assert!(w.mic_withheld, "host_audio={host_audio}");
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Altavoces (Steam Streaming Microphone)",
"host_audio={host_audio}"
);
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
}
}
/// The operator override is exempt from game-audio-outranks-the-mic: pinning the mic to
/// the Streaming Microphone strands the loopback on the last resort, and that is the
/// operator's explicit call.
#[test]
fn env_override_may_strand_the_loopback() {
let renders = [
ep("Altavoces (Steam Streaming Speakers)"),
ep("Altavoces (Steam Streaming Microphone)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(
&renders,
&captures,
Some("steam streaming microphone"),
false,
&[],
);
assert_eq!(
w.mic_render.unwrap().0,
"Altavoces (Steam Streaming Microphone)"
);
assert!(!w.mic_withheld);
assert_eq!(
w.loopback_render.unwrap().0,
"Altavoces (Steam Streaming Speakers)"
@@ -948,10 +1066,18 @@ mod tests {
/// is the advice that actually frees the silent sink).
#[test]
fn describe_no_loopback_skips_satisfied_remedies() {
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
// Mic PINNED to the Streaming Microphone by operator override — the only way the mic
// may strand the loopback now that game audio outranks the candidate order — with
// nothing else present.
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false, &[]);
let w = plan(
&renders,
&captures,
Some("steam streaming microphone"),
false,
&[],
);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
@@ -1001,7 +1127,8 @@ mod tests {
/// desktop mix would be routed into the controller's voice coils.
#[test]
fn a_pad_is_never_the_last_resort() {
// Only the pad and the Steam pair exist; the mic reserves the Streaming Microphone, so
// Only the pad and the Steam pair exist, the mic PINNED to the Streaming Microphone by
// operator override (game audio otherwise outranks the mic and takes the endpoint), so
// the plan falls all the way through to the last resort.
let renders = [
ep("DualSense Wireless Controller"),
@@ -1010,7 +1137,13 @@ mod tests {
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let pads = [renders[0].1.clone()];
let w = plan(&renders, &captures, None, false, &pads);
let w = plan(
&renders,
&captures,
Some("steam streaming microphone"),
false,
&pads,
);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Speakers)",