From ef8214415f57f9f6f9106c4eeff70eaf9cd05ff4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 10:29:37 +0200 Subject: [PATCH] fix(host/audio): game audio outranks the mic on cable-less boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/audio/windows/audio_control.rs | 6 +- .../src/audio/windows/wasapi_cap.rs | 27 ++- .../src/audio/windows/wasapi_mic.rs | 30 +++- .../punktfunk-host/src/audio/wiring_plan.rs | 167 ++++++++++++++++-- 4 files changed, 205 insertions(+), 25 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 5674ff45..1a021841 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -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::>(), "audio wiring plan" ); diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 0c39cae2..bad81aff 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -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> = 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); } diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index f52ed991..09b85bff 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -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= 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> { 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 diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 464a8832..9614cfed 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -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, + /// 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)",