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:
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user