From 0bba8d7f8cab01e6cb4383d57cb3425dd090ba46 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 10:11:58 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(audio/windows):=20the=20default=20recor?= =?UTF-8?q?ding=20device=20is=20session-scoped=20now=20=E2=80=94=20an=20id?= =?UTF-8?q?le=20host=20parked=20every=20game's=20voice=20input=20on=20a=20?= =?UTF-8?q?dead=20virtual=20mic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wiring pass asserted 'default recording = virtual mic capture' on EVERY pass — including the mic pump's eager boot pass — so an idle box permanently held the Windows default recording device (and, since SetDefaultEndpoint covers eCommunications, every game's voice input) on a virtual microphone whose render feeder is idle-stopped, with no restore path at all: not at session end, not at service stop. Field-measured 2026-08-14: Helldivers 2 (Wwise + always-on voice) played LOCALLY on an idle host tanks to 2–5 FPS 1% lows, and mmsys.cpl's own Recording tab goes unresponsive polling the same endpoint; the reporter's Sound settings showed 'Punktfunk Microphone — Dispositivo predefinito' with the host idle. The recording default now follows the exact discipline the playback default has always had — parked only while a desktop-audio capture is open, with the operator's device remembered (in memory + an on-disk crash marker, audio-default-rec.prev), restored on capture close, recovered after a crash on the next boot's first wiring pass, and unparked by the uninstaller. A game launched during a stream still binds the client's mic (the park runs before the session's game does); one launched before the stream keeps the operator's own microphone — the honest answer. Because earlier builds recorded nothing to restore, an upgraded box would have stayed wedged on the virtual mic forever: an idle-pass hygiene now moves a default found sitting on the plan's mic capture back to the first REAL microphone (pure picker wiring_plan::real_capture, unit-tested against the field box's exact recording-tab inventory). Session passes are exempt, and a box with no real microphone is left alone. Also folded in: the mid-idle drift re-assert is gone with the gating, so a mic-pump reopen no longer stomps a recording device the operator chose themselves. --- .../src/audio/windows/audio_control.rs | 245 +++++++++++++----- .../src/audio/windows/devnode_cleanup.rs | 6 +- .../src/audio/windows/wasapi_cap.rs | 12 +- .../src/audio/windows/wasapi_mic.rs | 6 +- .../punktfunk-host/src/audio/wiring_plan.rs | 47 ++++ 5 files changed, 235 insertions(+), 81 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index c05fc116..34b64bdb 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -26,13 +26,22 @@ //! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **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. +//! record the client's mic by default — applied, like the playback default, ONLY while a +//! desktop-audio capture is open. It used to be asserted on EVERY wiring pass, mic pump at boot +//! included, which left an IDLE box's default recording/communication device parked on a virtual +//! microphone nothing feeds — and games bind the default microphone at launch (`SetDefaultEndpoint` +//! covers eCommunications, so in-game voice binds it too). The 2026-08 Helldivers 2 field reports +//! measured that as 1% lows of 2–5 FPS in a LOCALLY played game while the host sat idle (HD2 is +//! Wwise + always-on voice, exactly the "finicky with audio devices" case its own wiki warns +//! about). An idle host must leave the box's audio defaults exactly as the operator set them. //! -//! 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). +//! Because both defaults are *parked* during a stream — playback on a silent sink, recording on the +//! virtual mic — the operator's devices are remembered ([`park_default_playback`] / +//! [`park_default_recording`], plus on-disk crash markers) and put back when the capture closes +//! ([`restore_default_playback`] / [`restore_default_recording`]) or, after a crash, on the next +//! process's first wiring pass — an operator must never be stranded with silent speakers or a dead +//! mic. 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 @@ -142,8 +151,8 @@ pub(crate) fn endpoint_fingerprint() -> u64 { } /// [`wire_now_full`] for callers that only need the assignment (the mic paths). -pub(crate) fn wire_now(set_playback: bool) -> Wiring { - wire_now_full(set_playback).wiring +pub(crate) fn wire_now(park_defaults: bool) -> Wiring { + wire_now_full(park_defaults).wiring } /// The most recent wiring verdict, as the LAST wiring pass computed it (the mic pump wires @@ -170,13 +179,15 @@ fn pad_render_ids(renders: &[Endpoint]) -> Vec { /// 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). `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_full(set_playback: bool) -> WiredPlan { +/// echo guard). `park_defaults` — true only from the desktop-audio capture open — additionally +/// parks the default PLAYBACK device on the plan's loopback endpoint and the default RECORDING +/// device on the virtual mic's capture side, both for the capture's lifetime (the mic pump passes +/// false: it runs while the host is idle and must neither silence the box nor hold its default +/// microphone — the idle-parked recording default is the 2026-08 Helldivers 2 tank, see the +/// module docs). 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_full(park_defaults: bool) -> WiredPlan { recover_orphaned_default(); let renders = list_endpoints(Direction::Render); let captures = list_endpoints(Direction::Capture); @@ -188,11 +199,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { // them out of every role. Identity is platform data (stamped container / devnode marker), // so it is collected HERE and passed in, like the candidate lists themselves. let pad_ids = pad_render_ids(&renders); - // Mix formats are read only when we are actually going to park the playback default (i.e. a + // Mix formats are read only when we are actually going to park the defaults (i.e. a // desktop-audio capture is opening). The mic pump wires on every open while the host is idle // and does not care which loopback endpoint wins, so it must not pay an IAudioClient // activation per render endpoint on every pass. - let probe: &dyn Fn(&Endpoint) -> Option = if set_playback { + let probe: &dyn Fn(&Endpoint) -> Option = if park_defaults { &mix_format_of } else { &wiring_plan::no_formats @@ -311,30 +322,44 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { } } } - if set_playback { + // Recording-default hygiene, IDLE passes only: builds before 2026-08-14 parked the default + // recording on the virtual mic on EVERY wiring pass (boot included) and recorded nothing to + // restore — so an upgraded box would otherwise sit wedged on a microphone nothing feeds + // until the operator noticed (the Helldivers 2 idle tank; the session-scoped park below + // can't heal it either: it remembers a previous default only when the default isn't already + // ours). While nothing is parked, a default found sitting on the plan's mic capture moves to + // the first real microphone. Session passes own the default and are exempt; a box with no + // real microphone is left alone. + if !park_defaults && PARKED_REC.lock().unwrap().is_none() { + if let Some((mic_name, mic_id)) = &wiring.mic_capture { + if default_capture_id().as_deref() == Some(mic_id.as_str()) { + if let Some((name, id)) = + wiring_plan::real_capture(&captures, Some(mic_id.as_str())) + { + match set_default_endpoint(id) { + Ok(()) => tracing::info!(from = %mic_name, device = %name, + "default recording was left on the virtual mic outside a stream — \ + moved it back to a real microphone"), + Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), + "failed to move the default recording off the virtual mic"), + } + } + } + } + } + if park_defaults { 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 { - // `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally - // fires SetDefaultEndpoint for all three roles (an audio-policy write plus a - // device-graph notification, each). Re-asserting on every wiring pass therefore both - // churned the policy store AND silently stomped an operator's own recording-device - // choice within one reopen cycle — write only when the plan changed or the default - // actually drifted off the target. - if changed || default_capture_id().as_deref() != Some(id.as_str()) { - match set_default_endpoint(id) { - Ok(()) => { - if changed { - tracing::info!(device = %name, - "audio wiring: default recording = virtual mic (apps record the client's mic)"); - } - } - Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), - "audio wiring: failed to set the default recording device"), - } + // The recording default is SESSION-SCOPED like the playback default, and for the same + // reason inverted: parking it while idle handed the box's default microphone (and, via + // eCommunications, every game's voice input) to a virtual mic nothing feeds — the + // 2026-08 Helldivers 2 idle tank (see the module docs). A game launched DURING the + // stream still binds the client's mic (this runs before the session's game does); + // one launched before the stream keeps the operator's mic, which is the honest answer. + if let Some((name, id)) = &wiring.mic_capture { + park_default_recording(name, id, changed); } } done(wiring) @@ -350,6 +375,26 @@ fn park_marker_path() -> std::path::PathBuf { pf_paths::config_dir().join("audio-default.prev") } +/// The operator's default recording endpoint while we have it parked on the virtual mic: +/// `(previous_id, id_we_set)` — the recording-side twin of [`PARKED`]. +static PARKED_REC: Mutex> = Mutex::new(None); + +/// On-disk crash marker mirroring [`PARKED_REC`] (two lines: previous id, set id). +fn rec_marker_path() -> std::path::PathBuf { + pf_paths::config_dir().join("audio-default-rec.prev") +} + +/// Consume a park marker file: returns the PREVIOUS default's id when the marker existed AND the +/// current default still is the endpoint we set — a default the operator changed since wins, like +/// on every other restore path. The file is removed either way (it describes a park that is over). +fn take_marker(path: &std::path::Path, current_default: Option) -> Option { + let s = std::fs::read_to_string(path).ok()?; + let _ = std::fs::remove_file(path); + let mut lines = s.lines(); + let (prev, set) = (lines.next()?, lines.next()?); + (current_default.as_deref() == Some(set)).then(|| prev.to_string()) +} + /// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning /// uses it for its default-device guard (a freshly minted pad endpoint must never stay the /// default playback device). @@ -374,31 +419,28 @@ pub(crate) fn default_capture_id() -> Option { .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). +/// Once per process: if a crash marker from a previous run exists, the host died while a default +/// (playback and/or recording) 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"), + for (path, current, what) in [ + (park_marker_path(), default_render_id(), "playback"), + (rec_marker_path(), default_capture_id(), "recording"), + ] { + let Some(prev) = take_marker(&path, current) else { + continue; + }; + match set_default_endpoint(&prev) { + Ok(()) => tracing::info!( + "restored the default {what} device a previous host run left parked" + ), + Err(e) => tracing::warn!(error = %format!("{e:#}"), + "failed to restore the default {what} device left by a previous run"), + } } }); } @@ -415,20 +457,18 @@ fn recover_orphaned_default() { /// /// Returns whether a device was actually put back — the caller only logs it. pub(crate) fn unpark_default_for_uninstall() -> bool { - let path = park_marker_path(); - let Ok(s) = std::fs::read_to_string(&path) else { - return false; - }; - let _ = std::fs::remove_file(&path); - let mut lines = s.lines(); - let (Some(prev), Some(set)) = (lines.next(), lines.next()) else { - return false; - }; - // A default the operator changed by hand since the park wins, exactly as on the recovery path. - if default_render_id().as_deref() != Some(set) { - return false; + let mut restored = false; + for (path, current) in [ + (park_marker_path(), default_render_id()), + (rec_marker_path(), default_capture_id()), + ] { + // A default the operator changed by hand since the park wins, exactly as on the + // recovery path (`take_marker` answers None then). + if let Some(prev) = take_marker(&path, current) { + restored |= set_default_endpoint(&prev).is_ok(); + } } - set_default_endpoint(prev).is_ok() + restored } /// Make `id` the default playback device for the duration of the desktop-audio capture, @@ -469,6 +509,48 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st } } +/// Make `id` the default recording device for the duration of the desktop-audio capture — +/// [`park_default_playback`]'s recording twin, remembering the operator's current default (in +/// memory + the crash marker) the FIRST time so [`restore_default_recording`] can put it back. +/// Nothing is remembered when `id` already is the default — there is nothing to restore. +fn park_default_recording(name: &str, id: &str, changed: bool) { + let cur = default_capture_id(); + if cur.as_deref() != Some(id) { + let mut parked = PARKED_REC.lock().unwrap(); + match parked.as_mut() { + None => { + if let Some(prev) = cur.clone() { + let _ = std::fs::write(rec_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(rec_marker_path(), format!("{prev}\n{id}")); + *set = id.to_string(); + } + Some(_) => {} + } + } + // `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally fires + // SetDefaultEndpoint for all three roles (an audio-policy write plus a device-graph + // notification, each) — write only when the plan changed or the default actually drifted + // off the target, or the policy store churns on every reopen. + if changed || cur.as_deref() != Some(id) { + match set_default_endpoint(id) { + Ok(()) => { + if changed { + tracing::info!(device = %name, + "audio wiring: default recording = virtual mic (apps record the client's mic)"); + } + } + Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), + "audio wiring: failed to set the default recording device"), + } + } +} + /// Put the default playback device back on the endpoint we are already capturing, WITHOUT a /// wiring pass (WP2.4). /// @@ -507,6 +589,25 @@ pub(crate) fn restore_default_playback() { } } +/// Put the operator's default recording device back after streaming — the inverse of +/// [`park_default_recording`], with [`restore_default_playback`]'s exact rules: no-op if we never +/// parked it, and a default the operator changed themselves mid-stream is left alone. Must run on +/// a COM-initialized thread (called from the capture thread's exit path). +pub(crate) fn restore_default_recording() { + let Some((prev, set)) = PARKED_REC.lock().unwrap().take() else { + return; + }; + let _ = std::fs::remove_file(rec_marker_path()); + if default_capture_id().as_deref() != Some(set.as_str()) { + return; + } + match set_default_endpoint(&prev) { + Ok(()) => tracing::info!("default recording device restored after streaming"), + Err(e) => tracing::warn!(error = %format!("{e:#}"), + "failed to restore the default recording device after streaming"), + } +} + /// Open a device by endpoint id, with a name for error context. /// /// Resolves through [`super::pad_endpoint::open_wasapi_device`] rather than the `wasapi` crate's diff --git a/crates/punktfunk-host/src/audio/windows/devnode_cleanup.rs b/crates/punktfunk-host/src/audio/windows/devnode_cleanup.rs index 97b5f070..670f5b10 100644 --- a/crates/punktfunk-host/src/audio/windows/devnode_cleanup.rs +++ b/crates/punktfunk-host/src/audio/windows/devnode_cleanup.rs @@ -46,8 +46,8 @@ pub(crate) struct Removed { pub endpoint_records: usize, } -/// Restore the default playback device if we left it parked, then remove every audio devnode -/// this product minted, newest registry record and all. +/// Restore the default playback/recording devices if we left them parked, then remove every +/// audio devnode this product minted, newest registry record and all. /// /// Best-effort throughout, like the rest of the (un)install path: a devnode that refuses to go /// is counted and reported, never fatal — a non-zero exit here would abort the whole uninstaller @@ -59,7 +59,7 @@ pub(crate) fn purge() -> Result { // what the operator had. Putting it back is the difference between "the box works again" // and "the box works again, on the device it started with". if audio_control::unpark_default_for_uninstall() { - println!("restored the default playback device this host had parked"); + println!("restored the default audio device(s) this host had parked"); } let mut out = Removed::default(); diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 0a1d72dc..6ac3891e 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -24,8 +24,8 @@ //! the set changes — the thread says why once, then parks on a cheap fingerprint poll and //! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass — //! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to -//! succeed). On thread exit (capturer dropped at stream end) the parked default playback -//! device is restored. +//! succeed). On thread exit (capturer dropped at stream end) the parked default playback AND +//! recording devices are restored — both defaults are strictly session-scoped. use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY}; use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE}; @@ -290,9 +290,13 @@ fn capture_thread( } } } - // 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. + // Hand the default playback AND recording devices back to the operator (no-ops if we never + // parked them, or if they changed them themselves mid-stream). COM is initialized on this + // thread. The recording restore is what keeps the parked default session-scoped — an idle + // box holding the default microphone on a virtual mic nothing feeds is the 2026-08 + // Helldivers 2 tank (see `audio_control`'s module docs). audio_control::restore_default_playback(); + audio_control::restore_default_recording(); Ok(()) } diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index db35914a..0a1bec76 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -261,8 +261,10 @@ fn resolve_target() -> Result<(wasapi::Device, String)> { // on the cable while later plans paired the default recording with the minted microphone // nothing wrote into (see `minted::ensure_blocking`). Instant once latched. super::minted::ensure_blocking(); - // 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. + // park_defaults=false: the mic pump runs while the host is idle — only the desktop-audio + // capture may park the box's defaults (playback on the silent sink, recording on the virtual + // mic) for a stream's lifetime. An idle box must keep the operator's own devices default — + // an idle-parked recording default is the 2026-08 Helldivers 2 tank (`audio_control` docs). let mut wiring = audio_control::wire_now(false); if wiring.mic_render.is_none() && !wiring.mic_withheld { // A WITHHELD mic skips the install attempt: the Streaming Microphone exists — the plan diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 04c9b059..f09a0447 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -241,6 +241,30 @@ pub(crate) fn silent_sink(lname: &str) -> bool { lname.contains("steam streaming microphone") } +/// A capture endpoint that surfaces a VIRTUAL device's audio (cables, streaming mics, mixer +/// strips, the host's own minted "Punktfunk" microphone) rather than a real microphone. The +/// recording-default hygiene pass must never move the box's default onto one of these. +pub(crate) fn virtual_capture(lname: &str) -> bool { + lname.contains("cable output") + || lname.contains("steam streaming") + || lname.contains("voicemeeter") + || lname.contains("virtual") + || lname.contains("punktfunk") +} + +/// The first REAL capture endpoint (skipping `avoid_id` and every [`virtual_capture`]) — where +/// the recording-default hygiene sends a default an earlier build left parked on the virtual mic +/// while the host is idle. `None` on a box with no real microphone: nothing sane to move to, so +/// the default is left alone. +pub(crate) fn real_capture<'a>( + captures: &'a [Endpoint], + avoid_id: Option<&str>, +) -> Option<&'a Endpoint> { + captures + .iter() + .find(|(n, id)| Some(id.as_str()) != avoid_id && !virtual_capture(&n.to_lowercase())) +} + /// 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). @@ -1137,6 +1161,29 @@ mod tests { assert!(both.contains("16000") && both.contains("channel"), "{both}"); } + /// The recording-default hygiene picker: skips every virtual capture (cable, streaming mic, + /// the minted "Punktfunk" pair, VoiceMeeter) and lands on the real microphone — the exact + /// recording-tab zoo of the 2026-08-14 Helldivers 2 field box. + #[test] + fn recording_hygiene_picks_the_real_microphone() { + let captures = [ + ep("Microphone (2- Punktfunk)"), + ep("CABLE Output (VB-Audio Virtual Cable)"), + ep("Microphone (Steam Streaming Microphone)"), + ep("VoiceMeeter Output (VB-Audio VoiceMeeter VAIO)"), + ep("Desktop Microphone (2- Microsoft LifeCam HD-3000)"), + ]; + assert_eq!( + real_capture(&captures, None).unwrap().0, + "Desktop Microphone (2- Microsoft LifeCam HD-3000)" + ); + // `avoid_id` guards the plan's own mic capture even when its name would pass the + // virtual test; with nothing else real, the answer is honestly None. + let only = [ep("Desk Mic (USB)")]; + assert!(real_capture(&only, Some("id-desk mic (usb)")).is_none()); + assert!(real_capture(&[], None).is_none()); + } + /// Operator override beats the candidate order. #[test] fn env_override_wins() { -- 2.54.0 From e5c0d6b4eb5770cae38264c413602b70c759c0b6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 10:12:00 +0200 Subject: [PATCH 2/4] docs(changelog): an idle Windows host no longer owns the box's default microphone --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d71d7f8b..f5287162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,34 @@ with the version table of the release you are moving to, then read **Breaking ch ## v0.28.1 — in development +### An idle Windows host no longer owns the box's default microphone + +Field report (the second Helldivers 2 one — the first led to v0.28.0's mint-retry fix): with the +host **idle**, a locally played Helldivers 2 tanks to 2–5 FPS 1% lows, and Windows' own Sound +settings Recording tab goes unresponsive. Root cause: the audio wiring pass asserted *default +recording = the virtual mic's capture side* on **every** pass, including the mic pump's eager +boot pass — and `SetDefaultEndpoint` covers eCommunications, so every game's voice input bound a +virtual microphone whose feeder only runs during a stream. Nothing ever restored it: not session +end, not service stop. Games that hold an always-open voice capture (Helldivers 2 is Wwise + +in-game voice — its own wiki calls the game "finicky with audio devices") stall on that dead +endpoint. + +The recording default is now **session-scoped**, exactly like the playback default has always +been: parked on the virtual mic only while a desktop-audio capture is open, the operator's device +remembered (plus an on-disk crash marker, `audio-default-rec.prev`), restored when the capture +closes, recovered at next boot after a crash, and unparked by the uninstaller. A game launched +*during* a stream still records the client's mic; one launched before the stream keeps the +operator's own microphone. + +Boxes wedged by earlier builds (which recorded nothing to restore) heal themselves: an idle +wiring pass that finds the default recording sitting on the plan's mic capture moves it back to +the first real microphone. + +⚠ **Operator-visible:** outside a stream, the default recording device is now whatever you set — +Punktfunk only takes it for the duration of a stream. If you *want* apps to record the client mic +while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle +re-assertion used to stomp a manual choice within one mic-pump reopen). + ### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not Field report — the plugin installed, the grid stayed empty, and the only clue was one host warn per -- 2.54.0 From ec3659705888350ee6f5f79e65f36dae9d18c8fe Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 10:26:13 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(audio/windows):=20the=20pad-audio=20Dua?= =?UTF-8?q?lSense=20speaker=20hides=20while=20no=20client=20pad=20is=20att?= =?UTF-8?q?ached=20=E2=80=94=20idle=20libScePad=20titles=20stalled=20on=20?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-pad endpoint is stamped to be indistinguishable from a real DualSense speaker — that is the feature during a pad session (libScePad titles route haptics audio at it) and a trap the rest of the time: the endpoint is pre-provisioned at EVERY host start and stayed visible forever, so an idle Helldivers 2 found it by identity, engaged its DualSense-haptics path against a device nothing services, and dropped to 2–5 FPS 1% lows — host idle, no controller plugged in, no session ever run (field-confirmed 2026-08-14: the reporter isolated the 'DualSense speaker' and disabling it in mmsys.cpl restored full performance). That manual remedy is now automatic: the endpoint parks HIDDEN (DEVICE_STATE_DISABLED, IPolicyConfig::SetEndpointVisibility — the call behind mmsys.cpl's own Disable, vtable slot pinned next to the SetDefaultEndpoint we already bind) whenever no client pad is attached. Provisioning hides it at startup, a PUNKTFUNK_PAD_AUDIO=0 host hides leftovers from earlier runs, and the per-pad streamer shows it for exactly the pad's lifetime — to a game, a DualSense arriving and leaving. The devnode, driver binding and stamps stay put (registry-based resolution finds a disabled endpoint at the next boot), so the flips raise no PnP traffic and the expensive provisioning still happens once at boot — the #185 lesson holds. Devtest: pad-endpoint grew show/hide verbs; tone/capture need a show first on a parked box. --- .../src/audio/windows/audio_control.rs | 62 ++++++++++++++--- .../src/audio/windows/pad_endpoint.rs | 69 +++++++++++++++++++ crates/punktfunk-host/src/devtest.rs | 31 ++++++++- crates/punktfunk-host/src/native/pad_audio.rs | 12 +++- 4 files changed, 162 insertions(+), 12 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 34b64bdb..764dd680 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -619,10 +619,11 @@ pub(crate) fn open_endpoint(ep: &Endpoint) -> Result { .map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0)) } -// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. --- +// --- IPolicyConfig (undocumented): default-endpoint and endpoint-visibility writes. --- -/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release` -/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct. +/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` and `SetEndpointVisibility` are called; +/// the 10 methods between `Release` and them (`GetMixFormat` … `SetPropertyValue`) are +/// placeholders so the slot offsets are correct. #[repr(C)] struct IPolicyConfigVtbl { query_interface: unsafe extern "system" fn( @@ -638,7 +639,11 @@ struct IPolicyConfigVtbl { windows::core::PCWSTR, u32, ) -> windows::core::HRESULT, - // SetEndpointVisibility follows — unused. + set_endpoint_visibility: unsafe extern "system" fn( + *mut c_void, + windows::core::PCWSTR, + i32, + ) -> windows::core::HRESULT, } // This mirrors the vtable of the UNDOCUMENTED `IPolicyConfig` COM interface, so there is no header @@ -647,18 +652,21 @@ struct IPolicyConfigVtbl { // table" — so a field added, removed or resized above it does not fail to compile: it silently calls // a DIFFERENT function through a mismatched signature, which is arbitrary-code territory rather // than a wrong answer. The `_reserved` gap is what makes that easy to get wrong, since its ten slots -// carry no names to anchor a review. These assertions pin the two things the call actually depends -// on: the slot index of `set_default_endpoint`, and the size of the table up to it. +// carry no names to anchor a review. These assertions pin the things the calls actually depend +// on: the slot indexes of `set_default_endpoint` and `set_endpoint_visibility`, and the size of +// the table up to them. const _: () = { use std::mem::{offset_of, size_of}; type P = *const c_void; - // 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based). + // 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based), + // `set_endpoint_visibility` the slot after. assert!(offset_of!(IPolicyConfigVtbl, query_interface) == 0); assert!(offset_of!(IPolicyConfigVtbl, add_ref) == size_of::

()); assert!(offset_of!(IPolicyConfigVtbl, release) == 2 * size_of::

()); assert!(offset_of!(IPolicyConfigVtbl, _reserved) == 3 * size_of::

()); assert!(offset_of!(IPolicyConfigVtbl, set_default_endpoint) == 13 * size_of::

()); - assert!(size_of::() == 14 * size_of::

()); + assert!(offset_of!(IPolicyConfigVtbl, set_endpoint_visibility) == 14 * size_of::

()); + assert!(size_of::() == 15 * size_of::

()); }; /// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the @@ -704,3 +712,41 @@ pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> { result } } + +/// Show or hide an audio endpoint via the undocumented `IPolicyConfig::SetEndpointVisibility` — +/// the exact call behind mmsys.cpl's "Disable"/"Enable" device menu. A hidden endpoint drops to +/// `DEVICE_STATE_DISABLED`: it vanishes from every ACTIVE enumeration and cannot be opened, but +/// its devnode, driver binding and stamped identity all stay put — showing it again is instant +/// and raises no PnP traffic. pub(crate): the pad-endpoint provider parks its "Wireless +/// Controller" speaker hidden while no client pad is attached (a visible idle pad speaker makes +/// libScePad titles engage their DualSense-haptics path against an endpoint nothing services — +/// the 2026-08-14 Helldivers 2 field confirmation). +pub(crate) fn set_endpoint_visibility(device_id: &str, visible: bool) -> Result<()> { + use windows::core::{IUnknown, Interface, GUID, PCWSTR}; + use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL}; + + const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9); + const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8); + + let wide: Vec = device_id.encode_utf16().chain(std::iter::once(0)).collect(); + + // SAFETY: same contract as `set_default_endpoint` — owned IUnknown from CoCreateInstance, + // QI'd pointer checked non-null, the call goes through the assertion-pinned vtable slot with + // a NUL-terminated UTF-16 id and an INT bool, and the QI'd pointer is Released before return. + unsafe { + let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL) + .map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?; + let mut raw: *mut c_void = std::ptr::null_mut(); + unk.query(&IID_IPOLICY_CONFIG, &mut raw) + .ok() + .map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?; + if raw.is_null() { + bail!("IPolicyConfig QueryInterface returned null"); + } + let vtbl = *(raw as *const *const IPolicyConfigVtbl); + let hr = ((*vtbl).set_endpoint_visibility)(raw, PCWSTR(wide.as_ptr()), visible as i32); + ((*vtbl).release)(raw); + hr.ok() + .map_err(|e| anyhow!("SetEndpointVisibility({visible}): {e}")) + } +} diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 383ce98e..2cfa6704 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -25,6 +25,12 @@ //! behind the measured MMDevices ACL repair (see [`grant_system_full_control`]). //! 3. **Capture**: sessions loopback-capture the endpoint ([`PadLoopbackCapturer`], 4 ch f32 //! interleaved) and ship the PCM to the client's pad speaker/haptics. +//! 4. **Visibility** ([`set_visibility`]): the endpoint parks HIDDEN (`DEVICE_STATE_DISABLED`) +//! whenever no client pad is attached — provisioning hides it at startup, the per-pad +//! streamer shows it for exactly the pad's lifetime. The DualSense disguise that makes games +//! route haptics at it during a session makes idle libScePad titles STALL on it otherwise +//! (Helldivers 2, field-confirmed 2026-08-14: 2–5 FPS 1% lows with the host idle). The +//! devnode, driver binding and stamps stay put, so flips raise no PnP traffic. //! //! The wiring plan must never route desktop audio or the virtual mic onto these endpoints — //! [`audio_control`](super::audio_control) collects the exclusion ids via @@ -1484,6 +1490,10 @@ static PROVISIONING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBo pub(crate) fn provision_at_startup() { if !pad_audio_enabled() { tracing::info!("pad audio disabled (PUNKTFUNK_PAD_AUDIO=0)"); + // Endpoints a previous run provisioned persist and stay VISIBLE — and a visible idle + // pad speaker is exactly what libScePad titles stall on (see [`set_visibility`]). + // Turning the feature off must also park the leftovers. + hide_leftover_endpoints(); return; } if PROVISIONED.get().is_some() { @@ -1531,6 +1541,17 @@ pub(crate) fn provision_at_startup() { stored-but-not-served until the next reboot"), } } + // Park every provisioned endpoint HIDDEN until a client pad actually attaches. The + // expensive work (devnode, driver bind, stamps, the AEB kick above) stays at boot — + // the #185 lesson: no PnP traffic at session boundaries — but the ENDPOINT must not + // sit visible on an idle box: libScePad titles (Helldivers 2, field-confirmed + // 2026-08-14) find the "Wireless Controller" speaker BY IDENTITY, engage their + // DualSense-haptics path against it, and stall on an endpoint nothing services — + // 1% lows of 2–5 FPS with the host completely idle. The per-pad streamer shows it + // for exactly the pad's lifetime, like a real DualSense arriving. + for pe in &eps { + set_visibility(&pe.endpoint_id, pe.pad_index, false); + } // R5: latch the result ONLY if we actually provisioned something. This used to store // whatever `eps` held even when the loop broke on the first error — an empty vec — // and `OnceLock` made that permanent: one transient failure (a busy audio stack, a @@ -1570,6 +1591,54 @@ pub(crate) fn ensure_provisioned() { } } +/// Show or hide a pad endpoint (best-effort, logged). Hidden = `DEVICE_STATE_DISABLED` via +/// [`audio_control::set_endpoint_visibility`] — the endpoint keeps its devnode, driver binding +/// and DualSense stamps, but vanishes from every ACTIVE enumeration and cannot be opened. +/// +/// WHY pad endpoints park hidden: the stamp set exists so libScePad titles read the endpoint as +/// a real DualSense speaker and route haptics audio at it — during a pad session that is the +/// feature, on an idle box it is a trap. Helldivers 2 (field-confirmed 2026-08-14) finds the +/// idle "Wireless Controller" speaker, engages its DualSense-haptics path against an endpoint +/// nothing services, and drops to 2–5 FPS 1% lows with the host completely idle; the manual +/// community remedy is disabling the device in mmsys.cpl — this is that remedy, automated and +/// scoped to "no pad attached". Visibility flips raise no PnP traffic (the #185 lesson), only +/// an endpoint state notification — the same event a real pad's arrival/departure raises. +pub(crate) fn set_visibility(endpoint_id: &str, pad_index: u8, visible: bool) { + match audio_control::set_endpoint_visibility(endpoint_id, visible) { + Ok(()) => tracing::info!(pad = pad_index, endpoint = %endpoint_id, + state = if visible { "shown (client pad attached)" } else { "hidden (no pad attached)" }, + "pad-audio endpoint visibility"), + Err(e) => tracing::warn!(pad = pad_index, endpoint = %endpoint_id, visible, + error = %format!("{e:#}"), + "pad-audio endpoint visibility change failed — an idle visible pad speaker can \ + stall libScePad titles (disable it in mmsys.cpl as a manual fallback)"), + } +} + +/// Hide any pad endpoints a previous run left behind — the `PUNKTFUNK_PAD_AUDIO=0` path, where +/// the provisioning worker never runs but persisted endpoints would otherwise stay visible (and +/// stall idle libScePad titles) forever. +fn hide_leftover_endpoints() { + let spawned = thread::Builder::new() + .name("punktfunk-pad-audio-hide".into()) + .spawn(|| { + if wasapi::initialize_mta().ok().is_err() { + return; + } + for idx in 0..4u8 { + match find(idx) { + Ok(Some(pe)) if !pe.endpoint_id.is_empty() => { + set_visibility(&pe.endpoint_id, idx, false); + } + _ => {} + } + } + }); + if let Err(e) = spawned { + tracing::warn!(error = %e, "could not spawn the pad-endpoint hide sweep"); + } +} + /// The provisioned endpoint for one pad slot — what a session queries when a client pad with /// speaker support arrives, to attach a [`PadLoopbackCapturer`]. #[allow(dead_code)] diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 4f4370b0..db07031b 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -623,12 +623,15 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { Ok(()) } -/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`. +/// Windows: pad-audio endpoint provisioning — `pad-endpoint +/// ensure|remove|status|tone|capture|show|hide [--index N]`. /// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam /// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether /// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served /// state without changing anything; `remove` deletes the devnode via pnputil — the escape -/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL); +/// hatch only, endpoints are persistent by design; `show`/`hide` flip the endpoint's +/// visibility (the host parks it hidden while no client pad is attached — show it before +/// `tone`/`capture`). Stamping needs SYSTEM (the MMDevices ACL); /// run `ensure` under the service account or PsExec when the property-store route is denied. /// Windows: the audio-substrate toolbox (`windows-audio-endpoints-and-vbcable.md`) — /// `audio-probe ssm|sink|sss-primary|mint|plan|cleanup [--keep]`. The S1–S3 spikes (`ssm` = @@ -744,7 +747,29 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> { pe::capture_probe(&endpoint_id, secs) } Some("status") => pe::print_status(idx), - _ => anyhow::bail!("usage: punktfunk-host pad-endpoint [--index N]"), + // `show`/`hide` — flip the endpoint's visibility (DEVICE_STATE_DISABLED). The host parks + // pad endpoints hidden while no client pad is attached (idle libScePad titles stall on a + // visible one — the 2026-08-14 Helldivers 2 field case); `tone`/`capture` need the + // endpoint SHOWN first, and `hide` puts the box back to the idle-safe state after. + Some(verb @ ("show" | "hide")) => { + let endpoint_id = match endpoint_override { + Some(id) => id, + None => match pe::find(idx)? { + Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id, + _ => { + println!("pad-endpoint {verb}: pad {idx} has no endpoint — run `ensure`"); + return Ok(()); + } + }, + }; + pe::set_visibility(&endpoint_id, idx, verb == "show"); + println!("pad-endpoint {verb}: {endpoint_id}"); + Ok(()) + } + _ => anyhow::bail!( + "usage: punktfunk-host pad-endpoint \ + [--index N]" + ), } } diff --git a/crates/punktfunk-host/src/native/pad_audio.rs b/crates/punktfunk-host/src/native/pad_audio.rs index 6cc5473c..9438d642 100644 --- a/crates/punktfunk-host/src/native/pad_audio.rs +++ b/crates/punktfunk-host/src/native/pad_audio.rs @@ -321,16 +321,26 @@ pub(super) fn spawn( } let stop_t = stop.clone(); let endpoint_id = ep.endpoint_id; + let vis_id = endpoint_id.clone(); match std::thread::Builder::new() .name(format!("punktfunk1-pad{pad}")) .spawn(move || { + // COM for the visibility flips (the capturer's opens run on their own thread). + let _ = wasapi::initialize_mta(); + // The endpoint parks HIDDEN while no pad is attached — an idle visible "Wireless + // Controller" speaker makes libScePad titles engage their DualSense-haptics path + // against an endpoint nothing services (the 2026-08-14 Helldivers 2 field tank). + // Show it for exactly this pad's lifetime, like a real DualSense arriving; the + // capturer's open/backoff loop absorbs the moment audiosrv takes to re-activate. + crate::audio::pad_endpoint::set_visibility(&vis_id, pad, true); pad_audio_thread( conn, pad, kinds, move || crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id), stop_t, - ) + ); + crate::audio::pad_endpoint::set_visibility(&vis_id, pad, false); }) { Ok(join) => Some(PadAudioHandle { stop, -- 2.54.0 From 652de8b5e03cc8e3bf09c1603c7d803943b4c118 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 10:26:16 +0200 Subject: [PATCH 4/4] docs(changelog): the pad-audio DualSense speaker hides while no client pad is attached --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5287162..fbb03444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,30 @@ with the version table of the release you are moving to, then read **Breaking ch ## v0.28.1 — in development +### The pad-audio "Wireless Controller" speaker hides while no client pad is attached + +Field-confirmed (2026-08-14, the same Helldivers 2 reports as below): the per-pad audio endpoint +the Windows host mints — a Steam-Streaming-Speakers instance stamped with a DualSense's name, +container and 4 ch/48 kHz formats, **pre-provisioned at every host start** — is deliberately +indistinguishable from a real DualSense speaker. That disguise is the feature during a pad +session (libScePad titles route haptics audio at it) and a trap the rest of the time: an idle +Helldivers 2 finds the endpoint by identity, engages its DualSense-haptics path against a device +nothing services, and drops to 2–5 FPS 1% lows — with the host completely idle, no controller +plugged in, and no session ever run. The reporter isolating "the DualSense speaker" and disabling +it in mmsys.cpl restored full performance; that manual remedy is now automatic. + +The endpoint now parks **hidden** (`DEVICE_STATE_DISABLED`, via `IPolicyConfig:: +SetEndpointVisibility` — the exact call behind mmsys.cpl's Disable) whenever no client pad is +attached: provisioning hides it at startup (and a `PUNKTFUNK_PAD_AUDIO=0` host hides leftovers +from earlier runs), the per-pad streamer shows it for exactly the pad's lifetime — to a game, +indistinguishable from a DualSense arriving and leaving. The devnode, driver binding and stamps +stay put, so the flips raise no PnP traffic and the expensive provisioning still happens once at +boot. + +⚠ **Operator-visible:** "Speakers (Wireless Controller)" now shows as *disabled* in the Sound +control panel while no client pad is connected — that is the parked state, not a defect. The +`pad-endpoint` devtest grew `show`/`hide` verbs; `tone`/`capture` need a `show` first. + ### An idle Windows host no longer owns the box's default microphone Field report (the second Helldivers 2 one — the first led to v0.28.0's mint-retry fix): with the -- 2.54.0