From ef8214415f57f9f6f9106c4eeff70eaf9cd05ff4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 10:29:37 +0200 Subject: [PATCH 01/21] 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)", -- 2.54.0 From 152047051cedea4d2621e3f4717d522700984a14 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 10:38:03 +0200 Subject: [PATCH 02/21] fix(host/windows): three SID unsafe blocks get their safety proofs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.rs (landed 2026-08-05 with the security-review remediation, while the Windows CI runner was down) fails windows-host.yml's clippy gate: #![deny(clippy::undocumented_unsafe_blocks)] wants the SAFETY comment on the line preceding EACH unsafe block, and three blocks didn't have one — two sat behind a comment anchored to the enclosing closure/neighbouring statement, and EqualSid had none at all. Comments only; no behavior change. --- crates/punktfunk-host/src/windows/install.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index a1f04552..76411f11 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -159,14 +159,17 @@ fn ensure_admin_only_source(dir: &Path) -> Result<()> { let verdict = (|| -> Result<()> { rc.ok().context("GetNamedSecurityInfoW(owner + DACL)")?; let privileged = privileged_sids()?; - // SAFETY: `owner` points into the descriptor returned above and is valid for this scope. let is_privileged = |sid: PSID| -> bool { + // SAFETY: callers pass SIDs that point into the live security descriptor returned + // above (freed only after this scope); IsValidSid only reads the structure. if sid.is_invalid() || !unsafe { IsValidSid(sid) }.as_bool() { return false; } - privileged - .iter() - .any(|p| unsafe { EqualSid(sid, PSID(p.as_ptr().cast_mut().cast())) }.is_ok()) + privileged.iter().any(|p| { + // SAFETY: `sid` was just validated by IsValidSid; `p` is a self-contained SID + // byte copy built by `privileged_sids` (length measured by GetLengthSid). + unsafe { EqualSid(sid, PSID(p.as_ptr().cast_mut().cast())) }.is_ok() + }) }; if !is_privileged(owner) { @@ -235,8 +238,10 @@ fn privileged_sids() -> Result>> { // SAFETY: `wide` is NUL-terminated and outlives the call; psid is a live out-param. unsafe { ConvertStringSidToSidW(PCWSTR(wide.as_ptr()), &mut psid) } .with_context(|| format!("ConvertStringSidToSidW({s})"))?; - // SAFETY: psid is a valid SID; copy it out so the caller owns plain bytes. + // SAFETY: psid is a valid SID (the conversion above succeeded). let len = unsafe { GetLengthSid(psid) } as usize; + // SAFETY: a SID is `len` contiguous bytes at psid — GetLengthSid just measured it — and + // the copy detaches the bytes before the LocalFree below. let bytes = unsafe { std::slice::from_raw_parts(psid.0 as *const u8, len) }.to_vec(); // SAFETY: ConvertStringSidToSidW allocates with LocalAlloc. unsafe { -- 2.54.0 From 03ee3e55d7a9c622c34c139f642e755e6561d251 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 10:45:44 +0200 Subject: [PATCH 03/21] =?UTF-8?q?feat(host/devtest):=20audio-probe=20?= =?UTF-8?q?=E2=80=94=20the=20audio-substrate=20spike=20measurements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S1-S3 spikes from windows-audio-endpoints-and-vbcable.md as one runnable devtest (no game, no client, ssh-drivable): audio-probe ssm S3, the decision gate: mint a SECOND devnode of Valve's Steam Streaming Microphone driver and prove the pair end to end (tone into its render endpoint must come back out of its capture endpoint). Pass = a punktfunk-owned virtual mic needs no VB-Cable wherever Steam is installed. audio-probe sink S2: mint a Speakers instance, park the DEFAULT playback on it, tone through the default device, WASAPI-loopback the instance - the desktop-audio capture path minus the game. audio-probe sss-primary S1: the primary Speakers' known-silent loopback, re-measured, with mix format + steam.exe state. audio-probe cleanup remove every probe-minted devnode (marker value in Device Parameters, never name-guessing). pad_endpoint grows the first slice of the design's §C1 shared minting surface: create_media_devnode(desc, hwid, mark), bind_driver(hwid, inf), find_capture_endpoint_for_devnode — the pad provisioner now calls the same functions. The probe restores whatever default devices the minting disturbed before it exits. --- crates/punktfunk-host/src/audio.rs | 5 + .../src/audio/windows/audio_control.rs | 4 +- .../src/audio/windows/audio_probe.rs | 670 ++++++++++++++++++ .../src/audio/windows/pad_endpoint.rs | 89 ++- crates/punktfunk-host/src/devtest.rs | 10 + crates/punktfunk-host/src/main.rs | 4 + 6 files changed, 753 insertions(+), 29 deletions(-) create mode 100644 crates/punktfunk-host/src/audio/windows/audio_probe.rs diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index f2871580..a3a949ac 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -189,6 +189,11 @@ mod linux; #[cfg(target_os = "windows")] #[path = "audio/windows/pad_endpoint.rs"] pub(crate) mod pad_endpoint; +// `audio-probe` devtest — the S1–S3 spike measurements for the Windows audio-substrate design +// (mint Steam-driver instances, measure their render→capture / loopback paths). +#[cfg(target_os = "windows")] +#[path = "audio/windows/audio_probe.rs"] +pub(crate) mod audio_probe; #[cfg(target_os = "windows")] #[path = "audio/windows/wasapi_cap.rs"] mod wasapi_cap; diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 1a021841..0471377f 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -63,7 +63,7 @@ use wasapi::Direction; /// Deliberately total: EVERY failure maps to `None` ("assume it is fine"), because the wiring plan /// treats an unknown format as non-narrowing. A box where activation fails therefore plans exactly /// as it did before formats existed, instead of mis-demoting a perfectly good endpoint. -fn mix_format_of(ep: &Endpoint) -> Option { +pub(crate) fn mix_format_of(ep: &Endpoint) -> Option { let fmt = open_endpoint(ep) .ok()? .get_iaudioclient() @@ -337,7 +337,7 @@ pub(crate) fn default_render_id() -> Option { /// The current default CAPTURE endpoint id, if any — the recording-side analogue of /// [`default_render_id`], read before asserting the recording default so an already-correct /// default costs zero IPolicyConfig writes. -fn default_capture_id() -> Option { +pub(crate) fn default_capture_id() -> Option { wasapi::DeviceEnumerator::new() .ok()? .get_default_device(&Direction::Capture) diff --git a/crates/punktfunk-host/src/audio/windows/audio_probe.rs b/crates/punktfunk-host/src/audio/windows/audio_probe.rs new file mode 100644 index 00000000..f806521d --- /dev/null +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -0,0 +1,670 @@ +//! `audio-probe` devtest — the spike measurements behind the Windows audio-substrate decision +//! (punktfunk-planning `design/windows-audio-endpoints-and-vbcable.md` §3), runnable over ssh +//! with no game and no client: +//! +//! * `ssm` — **S3, the decision gate.** Mint a SECOND devnode of Valve's Steam Streaming +//! *Microphone* driver and prove the pair end to end: a tone rendered into the new +//! instance's render endpoint must come back out of its capture endpoint. Passing means a +//! punktfunk-owned virtual mic needs no VB-Cable on any box with Steam installed — +//! failing reverts the drop-VB-Cable decision to "cable stays, mic-only". +//! * `sink` — **S2.** Mint a Steam Streaming *Speakers* instance, park the DEFAULT playback +//! device on it (the real product routing), render a tone through the *default* device, and +//! WASAPI-loopback the instance — the desktop-audio capture path minus the game. +//! * `sss-primary` — **S1, informative.** Tone + loopback on the PRIMARY Steam Streaming +//! Speakers endpoint: the "loopback is silent (validated live)" verdict, re-measured, with +//! the endpoint's engine mix format and whether Steam is running recorded alongside. +//! * `cleanup` — remove every devnode this probe ever minted. +//! +//! Probe devnodes carry `PunktfunkAudioProbe=1` in their `Device Parameters` key so cleanup +//! finds them without guessing by name (DeviceDesc only survives until the INF installs). +//! Nothing here is product wiring: the wiring plan treats a minted instance like any other +//! endpoint of that name, and the probe restores the default playback/recording devices it +//! disturbed before exiting. + +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it. +#![deny(clippy::undocumented_unsafe_blocks)] + +use super::pad_endpoint as pe; +use super::{audio_control, SAMPLE_RATE}; +use anyhow::{anyhow, bail, Context, Result}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; +use wasapi::{Direction, SampleType, StreamMode, WaveFormat}; +use windows::core::PCWSTR; +use windows::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiEnumDeviceInfo, SetupDiOpenDevRegKey, DICS_FLAG_GLOBAL, DIREG_DEV, SPDRP_HARDWAREID, +}; +use windows::Win32::System::Registry::{ + RegCloseKey, RegQueryValueExW, RegSetValueExW, KEY_QUERY_VALUE, KEY_SET_VALUE, REG_DWORD, + REG_VALUE_TYPE, +}; + +/// Marker value in a probe devnode's `Device Parameters` key — how `cleanup` finds what this +/// devtest minted (and nothing else). +const PROBE_MARKER: &str = "PunktfunkAudioProbe"; +/// DeviceDesc for probe devnodes (visible in Device Manager until the INF install renames it). +const PROBE_DESC: &str = "Punktfunk Audio Probe"; +/// How long to wait for audiosrv to register a minted endpoint. +const ENDPOINT_WAIT: Duration = Duration::from_secs(15); +/// Tone amplitude — matches `pad-endpoint tone`, so peaks compare across probes. +const TONE_AMP: f32 = 0.5; +/// A measured peak above this is "signal" (tone renders at 0.5; autoconvert may attenuate). +const SIGNAL_FLOOR: f32 = 0.05; + +pub(crate) fn run(args: &[String]) -> Result<()> { + wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA)")?; + let keep = args.iter().any(|a| a == "--keep"); + match args.get(1).map(String::as_str) { + Some("ssm") => probe_ssm(keep), + Some("sink") => probe_sink(keep), + Some("sss-primary") => { + let secs = args + .get(2) + .and_then(|s| s.parse().ok()) + .unwrap_or(4u32) + .clamp(2, 30); + probe_sss_primary(secs) + } + Some("cleanup") => cleanup(), + _ => bail!("usage: punktfunk-host audio-probe [--keep]"), + } +} + +// --- S3: minted Steam Streaming Microphone instance ---------------------------------------- + +fn probe_ssm(keep: bool) -> Result<()> { + let (hwid, inf) = discover_driver("steamstreamingmicrophone", "SteamStreamingMicrophone.inf")?; + println!("audio-probe ssm: hwid={hwid} inf={inf}"); + let prev_render = audio_control::default_render_id(); + let prev_capture = audio_control::default_capture_id(); + + let inst = pe::create_media_devnode(PROBE_DESC, &hwid, write_probe_marker)?; + println!("audio-probe ssm: created devnode {inst}"); + pe::bind_driver(&hwid, &inf)?; + + let render_ep = wait_endpoint(&inst, Dir::Render)?; + let capture_ep = match wait_endpoint(&inst, Dir::Capture) { + Ok(ep) => ep, + Err(e) => { + // The load-bearing failure shape: an instance that minted a render side but no + // capture side cannot be a virtual mic — say it precisely, then clean up. + println!("audio-probe ssm: render endpoint {render_ep} appeared, but:"); + println!(" {e:#}"); + println!(" VERDICT: FAIL (S3) — the minted SSM instance has NO capture endpoint;"); + println!(" a punktfunk-owned virtual mic cannot come from this driver."); + restore_defaults(prev_render, prev_capture); + if !keep { + remove_devnode(&inst); + } + return Ok(()); + } + }; + println!("audio-probe ssm: render={render_ep}"); + println!("audio-probe ssm: capture={capture_ep}"); + report_mix_format("render", &render_ep); + + // E2E: tone into the instance's render side, recorded from its capture side. Concurrent — + // the driver only moves audio while both ends are open. + let peak = tone_while(&Some(render_ep.clone()), 5, 440.0, || { + record_peak(&capture_ep, 3) + })??; + println!("audio-probe ssm: capture peak over 3s = {peak:.4}"); + if peak > SIGNAL_FLOOR { + println!( + " VERDICT: PASS (S3) — the minted Steam Streaming Microphone instance carries \ + audio render→capture; a punktfunk-owned virtual mic needs no VB-Cable where \ + Steam is installed." + ); + } else { + println!( + " VERDICT: FAIL (S3) — both endpoints minted but no audio crossed the pair \ + (peak {peak:.4} ≤ {SIGNAL_FLOOR}); the drop-VB-Cable decision reverts to \ + cable-for-mic-only." + ); + } + + restore_defaults(prev_render, prev_capture); + if keep { + println!("audio-probe ssm: --keep — devnode {inst} left in place"); + } else { + remove_devnode(&inst); + } + Ok(()) +} + +// --- S2: minted Speakers instance as the parked default sink ------------------------------- + +fn probe_sink(keep: bool) -> Result<()> { + let (hwid, inf) = discover_driver("steamstreamingspeakers", "SteamStreamingSpeakers.inf")?; + println!("audio-probe sink: hwid={hwid} inf={inf}"); + let prev_render = audio_control::default_render_id(); + let prev_capture = audio_control::default_capture_id(); + + let inst = pe::create_media_devnode(PROBE_DESC, &hwid, write_probe_marker)?; + println!("audio-probe sink: created devnode {inst}"); + pe::bind_driver(&hwid, &inf)?; + let ep = wait_endpoint(&inst, Dir::Render)?; + println!("audio-probe sink: endpoint={ep}"); + report_mix_format("sink", &ep); + + // The product routing, not a shortcut: default playback parked on the minted endpoint, the + // tone rendered through the DEFAULT device (as any app would), the loopback reading the + // minted endpoint. This is `wasapi_cap`'s Assert shape minus the game. + audio_control::set_default_endpoint(&ep).context("park the default playback on the sink")?; + let peak = tone_while(&None, 5, 440.0, || loopback_peak(&ep, 3))??; + println!("audio-probe sink: loopback peak over 3s = {peak:.4}"); + if peak > SIGNAL_FLOOR { + println!( + " VERDICT: PASS (S2) — default-routed audio reaches the minted Speakers instance \ + and its WASAPI loopback carries it; \"Punktfunk Speakers\" can be the canonical \ + client-only sink." + ); + } else { + println!( + " VERDICT: FAIL (S2) — the minted instance's loopback stayed silent \ + (peak {peak:.4} ≤ {SIGNAL_FLOOR}) despite default routing; the speakers leg of \ + Phase 2 dies and Phase 1 remains the fix." + ); + } + + restore_defaults(prev_render, prev_capture); + if keep { + println!("audio-probe sink: --keep — devnode {inst} left in place"); + } else { + remove_devnode(&inst); + } + Ok(()) +} + +// --- S1: the primary Steam Streaming Speakers loopback, re-measured ------------------------ + +fn probe_sss_primary(secs: u32) -> Result<()> { + // The PRIMARY endpoint: name-matched, but never a devnode this probe minted (a leftover + // `--keep` instance would shadow the measurement). + let probes = probe_devnodes()?; + let en = wasapi::DeviceEnumerator::new().map_err(|e| anyhow!("DeviceEnumerator: {e}"))?; + let coll = en + .get_device_collection(&Direction::Render) + .map_err(|e| anyhow!("render collection: {e}"))?; + let n = coll.get_nbr_devices().map_err(|e| anyhow!("count: {e}"))?; + let mut target: Option<(String, String)> = None; + for i in 0..n { + let Ok(dev) = coll.get_device_at_index(i) else { + continue; + }; + let name = dev.get_friendlyname().unwrap_or_default(); + let id = dev.get_id().unwrap_or_default(); + if name.to_lowercase().contains("steam streaming speakers") + && !probes + .iter() + .any(|(pi, _)| endpoint_of(pi) == Some(id.clone())) + { + target = Some((name, id)); + break; + } + } + let Some((name, id)) = target else { + bail!("no primary Steam Streaming Speakers render endpoint on this box"); + }; + let steam_running = std::process::Command::new("tasklist") + .args(["/FI", "IMAGENAME eq steam.exe", "/NH"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .to_lowercase() + .contains("steam.exe") + }) + .unwrap_or(false); + println!( + "audio-probe sss-primary: endpoint {name:?} ({id}), steam.exe running: {steam_running}" + ); + report_mix_format("primary", &id); + + let peak = tone_while(&Some(id.clone()), secs + 1, 440.0, || { + loopback_peak(&id, secs) + })??; + println!("audio-probe sss-primary: loopback peak over {secs}s = {peak:.4}"); + if peak > SIGNAL_FLOOR { + println!( + " VERDICT: the primary SSS loopback CARRIES audio here (steam.exe running: \ + {steam_running}) — the \"validated silent\" verdict does not reproduce in this \ + state; record the state alongside." + ); + } else { + println!( + " VERDICT: the primary SSS loopback is SILENT (steam.exe running: \ + {steam_running}) — consistent with the wiring plan's last-resort tier." + ); + } + Ok(()) +} + +// --- driver discovery ---------------------------------------------------------------------- + +/// Find the (exact hardware id, INF path) for a Steam streaming driver: prefer any installed +/// devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver Windows +/// already trusts), else fall back to Steam's driver directory. +fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, String)> { + let set = pe::media_class_devs()?; + for i in 0.. { + let mut did = pe::devinfo_data(); + // SAFETY: live set; `did` is a live out-param with cbSize set. + if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() { + break; + } + let Some(hwid) = pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID) + .into_iter() + .find(|h| h.to_lowercase().contains(needle)) + else { + continue; + }; + if let Some(inf) = pe::devnode_inf_path(&set, &did) { + let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); + let full = format!(r"{windir}\INF\{inf}"); + if std::path::Path::new(&full).exists() { + return Ok((hwid, full)); + } + } + // Devnode exists but its INF is gone — keep its exact hwid, try Steam's directory. + if let Some(w) = super::wasapi_mic::steam_driver_inf_path(inf_name) { + let s = String::from_utf16_lossy(&w) + .trim_end_matches('\0') + .to_string(); + if std::path::Path::new(&s).exists() { + return Ok((hwid, s)); + } + } + } + // No installed devnode at all: canonical hwid + Steam's directory. + if let Some(w) = super::wasapi_mic::steam_driver_inf_path(inf_name) { + let s = String::from_utf16_lossy(&w) + .trim_end_matches('\0') + .to_string(); + if std::path::Path::new(&s).exists() { + let hwid = format!("ROOT\\{}", inf_name.trim_end_matches(".inf")); + return Ok((hwid, s)); + } + } + bail!( + "no installed devnode matches {needle:?} and Steam's driver directory has no \ + {inf_name} — install Steam (it never needs to run)" + ) +} + +// --- probe devnode marker + cleanup -------------------------------------------------------- + +/// Write the probe marker into a fresh devnode's `Device Parameters` key (the `mark` callback +/// of [`pe::create_media_devnode`]). +fn write_probe_marker( + set: &pe::DevInfoSet, + did: &mut windows::Win32::Devices::DeviceAndDriverInstallation::SP_DEVINFO_DATA, +) -> Result<()> { + // SAFETY: live set + element; DIREG_DEV opens (or the create below mints) the devnode's + // Device Parameters key. + let opened = unsafe { + SetupDiOpenDevRegKey( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + KEY_SET_VALUE.0, + ) + }; + let hkey = match opened { + Ok(k) => k, + // SAFETY: same set + element; a fresh devnode has no Device Parameters key yet. + Err(_) => unsafe { + windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiCreateDevRegKeyW( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + None, + PCWSTR::null(), + ) + } + .context("create the probe devnode's Device Parameters key")?, + }; + let name: Vec = PROBE_MARKER + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + // SAFETY: the value name is NUL-terminated and outlives the call; the DWORD bytes travel + // with the slice. + let rc = unsafe { + RegSetValueExW( + hkey, + PCWSTR(name.as_ptr()), + None, + REG_DWORD, + Some(&1u32.to_le_bytes()), + ) + }; + // SAFETY: closing the key opened/created above, exactly once. + unsafe { + let _ = RegCloseKey(hkey); + } + rc.ok().context("write PunktfunkAudioProbe") +} + +/// Every devnode carrying the probe marker, as `(instance_id, marker_value)`. +fn probe_devnodes() -> Result> { + let set = pe::media_class_devs()?; + let mut out = Vec::new(); + for i in 0.. { + let mut did = pe::devinfo_data(); + // SAFETY: live set; `did` is a live out-param with cbSize set. + if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() { + break; + } + // SAFETY: live set + element; read-only open of the Device Parameters key. + let Ok(hkey) = (unsafe { + SetupDiOpenDevRegKey( + set.0, + &did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + KEY_QUERY_VALUE.0, + ) + }) else { + continue; + }; + let name: Vec = PROBE_MARKER + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut ty = REG_VALUE_TYPE(0); + let mut data = [0u8; 4]; + let mut len = data.len() as u32; + // SAFETY: the value name is NUL-terminated; out-params are live locals; the buffer + // length travels in `len`. + let rc = unsafe { + RegQueryValueExW( + hkey, + PCWSTR(name.as_ptr()), + None, + Some(&mut ty), + Some(data.as_mut_ptr()), + Some(&mut len), + ) + }; + // SAFETY: closing the key opened above, exactly once. + unsafe { + let _ = RegCloseKey(hkey); + } + if rc.is_ok() && ty == REG_DWORD && len == 4 { + if let Some(inst) = pe::instance_id(&set, &did) { + out.push((inst, u32::from_le_bytes(data))); + } + } + } + Ok(out) +} + +fn cleanup() -> Result<()> { + let probes = probe_devnodes()?; + if probes.is_empty() { + println!("audio-probe cleanup: nothing to remove"); + return Ok(()); + } + for (inst, _) in probes { + remove_devnode(&inst); + } + Ok(()) +} + +/// `pnputil /remove-device` — same teardown as `pad-endpoint remove`. +fn remove_devnode(inst: &str) { + let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); + match std::process::Command::new(format!(r"{windir}\System32\pnputil.exe")) + .args(["/remove-device", inst]) + .output() + { + Ok(o) if o.status.success() => println!("audio-probe: removed devnode {inst}"), + Ok(o) => println!( + "audio-probe: pnputil could not remove {inst} (status {:?}): {}", + o.status.code(), + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => println!("audio-probe: could not run pnputil for {inst}: {e}"), + } +} + +// --- endpoints ------------------------------------------------------------------------------ + +enum Dir { + Render, + Capture, +} + +/// Poll for the endpoint audiosrv registers for `inst` in the given direction. +fn wait_endpoint(inst: &str, dir: Dir) -> Result { + let deadline = Instant::now() + ENDPOINT_WAIT; + loop { + let found = match dir { + Dir::Render => pe::find_endpoint_for_devnode(inst)?, + Dir::Capture => pe::find_capture_endpoint_for_devnode(inst)?, + }; + if let Some(ep) = found { + return Ok(ep); + } + if Instant::now() >= deadline { + let which = match dir { + Dir::Render => "render", + Dir::Capture => "capture", + }; + bail!( + "no {which} endpoint appeared for {inst} within {}s", + ENDPOINT_WAIT.as_secs() + ); + } + thread::sleep(Duration::from_millis(250)); + } +} + +/// The render endpoint id of a probe devnode, if it has one (best-effort — S1's exclusion). +fn endpoint_of(inst: &str) -> Option { + pe::find_endpoint_for_devnode(inst).ok().flatten() +} + +fn report_mix_format(label: &str, endpoint_id: &str) { + match audio_control::mix_format_of(&(label.to_string(), endpoint_id.to_string())) { + Some(f) => println!( + "audio-probe: {label} engine mix format = {} Hz, {} ch, {} bits", + f.rate_hz, f.channels, f.bits + ), + None => println!("audio-probe: {label} engine mix format = unknown (probe failed)"), + } +} + +// --- audio movement ------------------------------------------------------------------------ + +/// Render a stereo tone into `target` (an endpoint id, or the DEFAULT render device for +/// `None`) on a worker thread while `body` runs; the tone stops when `body` returns. +fn tone_while( + target: &Option, + tone_secs: u32, + hz: f32, + body: impl FnOnce() -> T, +) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let (stop_t, target_t) = (stop.clone(), target.clone()); + let join = thread::Builder::new() + .name("pf-audio-probe-tone".into()) + .spawn(move || render_tone(target_t.as_deref(), tone_secs, hz, &stop_t)) + .context("spawn tone thread")?; + // Give the render stream a beat to open before measuring, so the measurement window is + // fully inside the tone. + thread::sleep(Duration::from_millis(500)); + let out = body(); + stop.store(true, Ordering::SeqCst); + match join.join() { + Ok(Ok(())) => Ok(out), + Ok(Err(e)) => Err(e.context("tone render failed")), + Err(_) => Err(anyhow!("tone thread panicked")), + } +} + +/// Stereo 48 kHz tone, event-driven shared mode with autoconvert — the same open shape the +/// virtual mic uses, so "the probe could render" transfers. +fn render_tone(target: Option<&str>, seconds: u32, hz: f32, stop: &AtomicBool) -> Result<()> { + wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA, tone)")?; + let device = match target { + Some(id) => pe::open_wasapi_device(id)?, + None => wasapi::DeviceEnumerator::new() + .map_err(|e| anyhow!("DeviceEnumerator: {e}"))? + .get_default_device(&Direction::Render) + .map_err(|e| anyhow!("default render device: {e}"))?, + }; + let mut client = device.get_iaudioclient().context("IAudioClient")?; + let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE as usize, 2, None); + let (period, _) = client.get_device_period().context("device period")?; + client + .initialize_client( + &desired, + &Direction::Render, + &StreamMode::EventsShared { + autoconvert: true, + buffer_duration_hns: period, + }, + ) + .context("initialize tone render")?; + let h_event = client.set_get_eventhandle().context("event handle")?; + let render = client.get_audiorenderclient().context("render client")?; + let buf_frames = client.get_buffer_size().context("buffer size")? as usize; + let _ = render.write_to_device(buf_frames, &vec![0u8; buf_frames * 8], None); + client.start_stream().context("start tone stream")?; + + let total = u64::from(SAMPLE_RATE) * u64::from(seconds.clamp(1, 60)); + let step = std::f32::consts::TAU * hz / SAMPLE_RATE as f32; + let (mut phase, mut written) = (0.0f32, 0u64); + let mut bytes = vec![0u8; buf_frames * 8]; + while written < total && !stop.load(Ordering::Relaxed) { + if h_event.wait_for_event(1000).is_err() { + bail!("tone render event timed out after {written} frames"); + } + let free = client.get_available_space_in_frames().context("space")? as usize; + let n = free.min((total - written) as usize); + if n == 0 { + continue; + } + for f in 0..n { + let s = phase.sin() * TONE_AMP; + phase += step; + if phase >= std::f32::consts::TAU { + phase -= std::f32::consts::TAU; + } + for c in 0..2 { + let at = (f * 2 + c) * 4; + bytes[at..at + 4].copy_from_slice(&s.to_le_bytes()); + } + } + render + .write_to_device(n, &bytes[..n * 8], None) + .context("write tone")?; + written += n as u64; + } + thread::sleep(Duration::from_millis(200)); + let _ = client.stop_stream(); + Ok(()) +} + +/// Peak |sample| read from an endpoint for `seconds`. `loopback` taps a RENDER endpoint's mix +/// (the desktop-audio capture shape); otherwise a normal record from a CAPTURE endpoint (the +/// virtual-mic consumer shape). +fn measure_peak(endpoint_id: &str, seconds: u32, loopback: bool) -> Result { + let device = pe::open_wasapi_device(endpoint_id)?; + let mut client = device.get_iaudioclient().context("IAudioClient")?; + let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE as usize, 2, None); + let (period, _) = client.get_device_period().context("device period")?; + client + .initialize_client( + &desired, + &Direction::Capture, + &StreamMode::EventsShared { + autoconvert: true, + buffer_duration_hns: period, + }, + ) + .with_context(|| { + format!( + "initialize {} client", + if loopback { "loopback" } else { "record" } + ) + })?; + let h_event = client.set_get_eventhandle().context("event handle")?; + let capture = client.get_audiocaptureclient().context("capture client")?; + client.start_stream().context("start capture stream")?; + + let deadline = Instant::now() + Duration::from_secs(u64::from(seconds.clamp(1, 60))); + let mut bytes: std::collections::VecDeque = std::collections::VecDeque::new(); + let mut peak = 0f32; + let mut frames = 0u64; + while Instant::now() < deadline { + let _ = h_event.wait_for_event(100); + loop { + match capture.get_next_packet_size() { + Ok(Some(0)) | Ok(None) => break, + Ok(Some(_)) => { + capture + .read_from_device_to_deque(&mut bytes) + .context("read capture")?; + } + Err(e) => bail!("get_next_packet_size: {e}"), + } + } + let whole = (bytes.len() / 4) * 4; + if whole > 0 { + let raw: Vec = bytes.drain(..whole).collect(); + for c in raw.chunks_exact(4) { + peak = peak.max(f32::from_le_bytes([c[0], c[1], c[2], c[3]]).abs()); + frames += 1; + } + } + } + let _ = client.stop_stream(); + println!( + "audio-probe: {} read {} samples from {endpoint_id}", + if loopback { "loopback" } else { "record" }, + frames + ); + Ok(peak) +} + +fn loopback_peak(endpoint_id: &str, seconds: u32) -> Result { + measure_peak(endpoint_id, seconds, true) +} + +fn record_peak(endpoint_id: &str, seconds: u32) -> Result { + measure_peak(endpoint_id, seconds, false) +} + +/// Put back whatever default devices the minting disturbed (a fresh endpoint can grab either +/// default — measured on the pad program). No-ops when nothing moved. +fn restore_defaults(prev_render: Option, prev_capture: Option) { + if let Some(prev) = prev_render { + if audio_control::default_render_id().as_deref() != Some(prev.as_str()) { + match audio_control::set_default_endpoint(&prev) { + Ok(()) => println!("audio-probe: default playback restored"), + Err(e) => println!("audio-probe: could not restore default playback: {e:#}"), + } + } + } + if let Some(prev) = prev_capture { + if audio_control::default_capture_id().as_deref() != Some(prev.as_str()) { + match audio_control::set_default_endpoint(&prev) { + Ok(()) => println!("audio-probe: default recording restored"), + Err(e) => println!("audio-probe: could not restore default recording: {e:#}"), + } + } + } +} diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index ffaa05cb..0fed3aa4 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -91,6 +91,10 @@ const SSS_HWID: &str = "ROOT\\SteamStreamingSpeakers"; const PAD_INDEX_VALUE: &str = "PunktfunkPadIndex"; /// The endpoint store for render endpoints (each subkey = one endpoint GUID). const MMDEV_RENDER_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render"; +/// The capture-direction sibling of [`MMDEV_RENDER_PATH`] — where a paired device's microphone +/// half registers (the `audio-probe` devtest's S3 lookup). +const MMDEV_CAPTURE_PATH: &str = + r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Capture"; /// WASAPI endpoint-id prefix for render endpoints (`{0.0.0.00000000}.{guid}`). const ENDPOINT_ID_PREFIX: &str = "{0.0.0.00000000}."; /// How long [`ensure`] waits for the new render endpoint to materialise after driver install. @@ -261,7 +265,7 @@ fn active_stamps(pad_index: u8) -> Vec { // --- small encoding helpers ---------------------------------------------------------------- /// NUL-terminated UTF-16. -fn wide(s: &str) -> Vec { +pub(crate) fn wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } @@ -468,7 +472,7 @@ fn pv_bytes(pv: &PROPVARIANT) -> Option> { // --- devnode management (SetupAPI) ---------------------------------------------------------- /// Owns an HDEVINFO and destroys it on drop. -struct DevInfoSet(HDEVINFO); +pub(crate) struct DevInfoSet(pub(crate) HDEVINFO); impl Drop for DevInfoSet { fn drop(&mut self) { // SAFETY: the handle came from SetupDiGetClassDevsW/SetupDiCreateDeviceInfoList and is @@ -479,7 +483,7 @@ impl Drop for DevInfoSet { } } -fn media_class_devs() -> Result { +pub(crate) fn media_class_devs() -> Result { // SAFETY: the class GUID is a static const; flags 0 (not DIGCF_PRESENT) so a created-but- // never-installed phantom from a previous run is still found and reused, not duplicated. let set = unsafe { @@ -494,14 +498,14 @@ fn media_class_devs() -> Result { Ok(DevInfoSet(set)) } -fn devinfo_data() -> SP_DEVINFO_DATA { +pub(crate) fn devinfo_data() -> SP_DEVINFO_DATA { SP_DEVINFO_DATA { cbSize: std::mem::size_of::() as u32, ..Default::default() } } -fn instance_id(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { +pub(crate) fn instance_id(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { let mut buf = [0u16; 200]; // SAFETY: live devinfo set + element; the buffer length travels with the slice. unsafe { SetupDiGetDeviceInstanceIdW(set.0, did, Some(&mut buf), None) }.ok()?; @@ -510,7 +514,7 @@ fn instance_id(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { } /// A REG_MULTI_SZ SetupDi registry property (e.g. SPDRP_HARDWAREID) as strings. -fn devnode_multi_sz_prop( +pub(crate) fn devnode_multi_sz_prop( set: &DevInfoSet, did: &SP_DEVINFO_DATA, prop: windows::Win32::Devices::DeviceAndDriverInstallation::SETUP_DI_REGISTRY_PROPERTY, @@ -538,7 +542,7 @@ fn devnode_multi_sz_prop( /// The devnode's installed-driver INF filename (`DEVPKEY_Device_DriverInfPath`, e.g. /// `oem32.inf`) — absent on a devnode whose driver never installed. -fn devnode_inf_path(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { +pub(crate) fn devnode_inf_path(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { let mut ty = DEVPROPTYPE(0); let mut buf = vec![0u8; 1024]; let mut req = 0u32; @@ -628,15 +632,22 @@ fn find_devnode(pad_index: u8) -> Result> { Ok(None) } -/// Create + register a fresh MEDIA-class root devnode carrying the Steam Streaming Speakers -/// hardware id, and persist the pad slot in its `Device Parameters` key. -fn create_devnode(pad_index: u8) -> Result { +/// Create + register a fresh MEDIA-class root devnode carrying `hwid`, then let `mark` write +/// the caller's durable owner marker into its `Device Parameters` key (DeviceDesc only +/// survives until the INF installs — see the module doc). Shared by the pad provisioner and +/// the `audio-probe` devtest: the first slice of the shared minting surface the +/// audio-substrate design (`windows-audio-endpoints-and-vbcable.md` §C1) extracts. +pub(crate) fn create_media_devnode( + desc: &str, + hwid: &str, + mark: impl FnOnce(&DevInfoSet, &mut SP_DEVINFO_DATA) -> Result<()>, +) -> Result { // SAFETY: the class GUID is a static const. let set = unsafe { SetupDiCreateDeviceInfoList(Some(&GUID_DEVCLASS_MEDIA), None) } .context("SetupDiCreateDeviceInfoList(MEDIA)")?; let set = DevInfoSet(set); let mut did = devinfo_data(); - let desc = wide(DEVNODE_DESC); + let desc = wide(desc); // SAFETY: name/class/description are live NUL-terminated buffers; DICD_GENERATE_ID makes // PnP mint the ROOT\MEDIA\00NN instance id; `did` receives the element. unsafe { @@ -651,7 +662,7 @@ fn create_devnode(pad_index: u8) -> Result { ) } .context("SetupDiCreateDeviceInfo")?; - let hwid = multi_sz_bytes(&[SSS_HWID]); + let hwid = multi_sz_bytes(&[hwid]); // SAFETY: live set + element; the multi-sz property bytes travel with the slice. unsafe { SetupDiSetDeviceRegistryPropertyW(set.0, &mut did, SPDRP_HARDWAREID, Some(&hwid)) } .context("set SPDRP_HARDWAREID")?; @@ -661,8 +672,16 @@ fn create_devnode(pad_index: u8) -> Result { // SAFETY: live set + element; no compare callback. unsafe { SetupDiRegisterDeviceInfo(set.0, &mut did, 0, None, None, None) } .context("SetupDiRegisterDeviceInfo")?; - write_pad_index(&set, &mut did, pad_index)?; - let inst = instance_id(&set, &did).context("read the new devnode's instance id")?; + mark(&set, &mut did)?; + instance_id(&set, &did).context("read the new devnode's instance id") +} + +/// Create + register a fresh MEDIA-class root devnode carrying the Steam Streaming Speakers +/// hardware id, and persist the pad slot in its `Device Parameters` key. +fn create_devnode(pad_index: u8) -> Result { + let inst = create_media_devnode(DEVNODE_DESC, SSS_HWID, |set, did| { + write_pad_index(set, did, pad_index) + })?; tracing::info!(pad = pad_index, devnode = %inst, "created a pad-audio devnode"); Ok(inst) } @@ -755,12 +774,11 @@ fn resolve_sss_inf() -> Result { ) } -/// Bind the SSS driver to every unbound devnode carrying its hardware id (i.e. the pad -/// devnodes just created). Idempotent: "nothing needed an update" is success. -fn install_sss_driver() -> Result<()> { - let inf = resolve_sss_inf()?; - let inf_w = wide(&inf); - let hwid_w = wide(SSS_HWID); +/// Bind `inf` to every unbound devnode carrying `hwid`. Idempotent: "nothing needed an +/// update" is success. Shared with the `audio-probe` devtest (§C1 minting surface). +pub(crate) fn bind_driver(hwid: &str, inf: &str) -> Result<()> { + let inf_w = wide(inf); + let hwid_w = wide(hwid); // SAFETY: both strings are NUL-terminated and outlive the call; a null parent HWND and no // reboot-required out-param are documented as accepted. let r = unsafe { @@ -774,7 +792,7 @@ fn install_sss_driver() -> Result<()> { }; match r { Ok(()) => { - tracing::info!(inf = %inf, "bound the Steam Streaming Speakers driver to the pad devnode(s)"); + tracing::info!(hwid = %hwid, inf = %inf, "bound the driver to the unbound devnode(s)"); Ok(()) } // ERROR_NO_MORE_ITEMS (0x80070103): every matching devnode already runs this (or a @@ -786,19 +804,36 @@ fn install_sss_driver() -> Result<()> { } } +/// Bind the SSS driver to every unbound devnode carrying its hardware id (i.e. the pad +/// devnodes just created). Idempotent: "nothing needed an update" is success. +fn install_sss_driver() -> Result<()> { + bind_driver(SSS_HWID, &resolve_sss_inf()?) +} + // --- endpoint discovery + stamping ---------------------------------------------------------- /// The render endpoint owned by `instance_id`, identified through the endpoint store's devnode /// link (`"{1}."` under `…\MMDevices\Audio\Render\{ep}\Properties`). -fn find_endpoint_for_devnode(instance_id: &str) -> Result> { +pub(crate) fn find_endpoint_for_devnode(instance_id: &str) -> Result> { + endpoint_for_devnode_in(MMDEV_RENDER_PATH, instance_id) +} + +/// The CAPTURE endpoint owned by `instance_id` — the microphone half of a paired device like +/// the Steam Streaming Microphone. Pad devices are render-only; the `audio-probe` devtest's +/// S3 measurement is what needs this direction. +pub(crate) fn find_capture_endpoint_for_devnode(instance_id: &str) -> Result> { + endpoint_for_devnode_in(MMDEV_CAPTURE_PATH, instance_id) +} + +fn endpoint_for_devnode_in(reg_path: &str, instance_id: &str) -> Result> { use winreg::enums::HKEY_LOCAL_MACHINE; use winreg::RegKey; let want = format!("{{1}}.{instance_id}"); - let render = RegKey::predef(HKEY_LOCAL_MACHINE) - .open_subkey(MMDEV_RENDER_PATH) - .with_context(|| format!(r"open HKLM\{MMDEV_RENDER_PATH}"))?; - for key in render.enum_keys().flatten() { - let Ok(props) = render.open_subkey(format!(r"{key}\Properties")) else { + let root = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey(reg_path) + .with_context(|| format!(r"open HKLM\{reg_path}"))?; + for key in root.enum_keys().flatten() { + let Ok(props) = root.open_subkey(format!(r"{key}\Properties")) else { continue; }; let Ok(link) = props.get_value::(reg_value_name(&PKEY_ENDPOINT_DEVNODE)) else { diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index e6ef342b..81095888 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -495,6 +495,16 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { /// 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); /// run `ensure` under the service account or PsExec when the property-store route is denied. +/// Windows: the audio-substrate spike measurements (S1–S3 in +/// `windows-audio-endpoints-and-vbcable.md`) — `audio-probe ssm|sink|sss-primary|cleanup +/// [--keep]`. `ssm` is the decision gate: mint a second Steam Streaming Microphone devnode and +/// prove render→capture end to end; `sink` parks the default on a minted Speakers instance and +/// loopback-measures it; `sss-primary` re-measures the primary Speakers' known-silent loopback. +#[cfg(target_os = "windows")] +pub fn audio_probe(args: &[String]) -> Result<()> { + crate::audio::audio_probe::run(args) +} + #[cfg(target_os = "windows")] pub fn pad_endpoint(args: &[String]) -> Result<()> { use crate::audio::pad_endpoint as pe; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index a80b4659..b4d2a418 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -622,6 +622,10 @@ fn real_main() -> Result<()> { // escape hatch (`remove`). `--index N` selects the pad slot (default 0). #[cfg(target_os = "windows")] Some("pad-endpoint") => devtest::pad_endpoint(&args), + // Windows: audio-substrate spikes (design/windows-audio-endpoints-and-vbcable.md §3) — + // mint Steam-driver instances and measure render→capture / loopback end to end. + #[cfg(target_os = "windows")] + Some("audio-probe") => devtest::audio_probe(&args), // Capture→encode→file pipeline spike (dev tool). Some("spike") => spike::run(parse_spike(&args[1..])?), // Native punktfunk/1 host (QUIC control plane + UDP data plane). -- 2.54.0 From 0b160a4e22946b5600121265842ec925a84ee210 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 11:19:16 +0200 Subject: [PATCH 04/21] feat(host/audio): minted Punktfunk endpoints become the wiring plan's tier-0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audio-substrate program's Phase 2 (spikes S2+S3 measured green on the target box): the host mints its OWN instances of Valve's streaming-audio drivers and wires by IDENTITY instead of borrowing Steam's primaries — minted.rs the provider: one devnode per role ('Punktfunk Speakers' from SteamStreamingSpeakers.inf, 'Punktfunk Microphone' from SteamStreamingMicrophone.inf), marker-matched across restarts (PunktfunkAudioRole in Device Parameters — names are NOT identity, a minted instance is name-identical to the primaries), provisioned on a startup worker like pad audio, retried with a 60 s cool-down from wiring passes, defaults restored when a fresh endpoint grabs them. wiring_plan MintedIds tier-0: the mic takes its minted device outright (capture side paired by the provider's id — a name search cannot tell it from the primary), the loopback prefers the minted sink at the head of the silent tier, an operator override still beats everything, a narrowing minted sink demotes below real hardware, and stale ids fall back to the ladder unchanged. Plus AudioReadiness — the full/audio-only/mic-only/nothing classification, logged with every plan change (§C4's seed). audio-probe 'mint' runs the provider synchronously; 'plan' prints one real wiring pass + readiness — the field-triage command. Without Steam's drivers nothing changes: provisioning degrades to absent ids and the plan keeps the name-based ladder (primaries → cable → real hardware) exactly as before. --- crates/punktfunk-host/src/audio.rs | 5 + .../src/audio/windows/audio_control.rs | 20 +- .../src/audio/windows/audio_probe.rs | 85 ++-- .../src/audio/windows/minted.rs | 378 ++++++++++++++++ .../src/audio/windows/pad_endpoint.rs | 120 +++-- .../punktfunk-host/src/audio/wiring_plan.rs | 423 ++++++++++++++++-- crates/punktfunk-host/src/devtest.rs | 12 +- crates/punktfunk-host/src/native.rs | 5 + 8 files changed, 906 insertions(+), 142 deletions(-) create mode 100644 crates/punktfunk-host/src/audio/windows/minted.rs diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index a3a949ac..c02ef5e0 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -194,6 +194,11 @@ pub(crate) mod pad_endpoint; #[cfg(target_os = "windows")] #[path = "audio/windows/audio_probe.rs"] pub(crate) mod audio_probe; +// The minted "Punktfunk Speakers/Microphone" provider — punktfunk-owned instances of Valve's +// streaming-audio drivers, the wiring plan's tier-0 (the audio-substrate program). +#[cfg(target_os = "windows")] +#[path = "audio/windows/minted.rs"] +pub(crate) mod minted; #[cfg(target_os = "windows")] #[path = "audio/windows/wasapi_cap.rs"] mod wasapi_cap; diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 0471377f..f298296c 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -198,6 +198,14 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { // cannot carry stereo cannot carry 5.1 either. 2, &pad_ids, + // The minted "Punktfunk Speakers/Microphone" ids — tier-0 identity, empty until the + // provider latches. The ensure hook makes a box where Steam arrives later mint on a + // wiring pass instead of at the next reboot (cheap once latched; cooled-down retries + // while not). + &{ + super::minted::ensure_provisioned(); + super::minted::minted_ids() + }, ); let done = |wiring: Wiring| WiredPlan { wiring, @@ -220,6 +228,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()), loopback_last_resort = wiring.loopback_last_resort, mic_withheld = wiring.mic_withheld, + readiness = ?wiring_plan::readiness(&wiring), renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::>(), "audio wiring plan" ); @@ -265,7 +274,16 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { if let Some((mic_name, mic_id)) = &wiring.mic_render { if default_render_id().as_deref() == Some(mic_id.as_str()) { // Audible preference = the host_audio plan's loopback pick (real hardware first). - match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render { + match plan( + &renders, + &captures, + want.as_deref(), + true, + &pad_ids, + &super::minted::minted_ids(), + ) + .loopback_render + { Some((name, id)) => match set_default_endpoint(&id) { Ok(()) => tracing::info!(mic = %mic_name, device = %name, "default playback was the virtual-mic target — moved it so desktop \ diff --git a/crates/punktfunk-host/src/audio/windows/audio_probe.rs b/crates/punktfunk-host/src/audio/windows/audio_probe.rs index f806521d..d5bcf01a 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_probe.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -34,7 +34,7 @@ use std::time::{Duration, Instant}; use wasapi::{Direction, SampleType, StreamMode, WaveFormat}; use windows::core::PCWSTR; use windows::Win32::Devices::DeviceAndDriverInstallation::{ - SetupDiEnumDeviceInfo, SetupDiOpenDevRegKey, DICS_FLAG_GLOBAL, DIREG_DEV, SPDRP_HARDWAREID, + SetupDiEnumDeviceInfo, SetupDiOpenDevRegKey, DICS_FLAG_GLOBAL, DIREG_DEV, }; use windows::Win32::System::Registry::{ RegCloseKey, RegQueryValueExW, RegSetValueExW, KEY_QUERY_VALUE, KEY_SET_VALUE, REG_DWORD, @@ -70,7 +70,36 @@ pub(crate) fn run(args: &[String]) -> Result<()> { probe_sss_primary(secs) } Some("cleanup") => cleanup(), - _ => bail!("usage: punktfunk-host audio-probe [--keep]"), + // The provider's synchronous pass: mint (or re-find) "Punktfunk Speakers/Microphone" + // and publish them for THIS process — `plan` then shows the tier-0 pick. + Some("mint") => super::minted::devtest_mint(), + // One real wiring pass (no default parking) + the verdict, readiness included — the + // field-triage "what would the host do right now" command. + Some("plan") => { + let plan = super::audio_control::wire_now_full(false); + let w = &plan.wiring; + let show = |ep: &Option| match ep { + Some((name, id)) => format!("{name:?} ({id})"), + None => "-".into(), + }; + println!("audio-plan: mic_render = {}", show(&w.mic_render)); + println!("audio-plan: mic_capture = {}", show(&w.mic_capture)); + println!("audio-plan: loopback = {}", show(&w.loopback_render)); + println!("audio-plan: last_resort = {}", w.loopback_last_resort); + println!("audio-plan: mic_withheld = {}", w.mic_withheld); + println!( + "audio-plan: narrowing = {}", + w.loopback_narrowing.as_deref().unwrap_or("-") + ); + println!( + "audio-plan: readiness = {:?}", + super::wiring_plan::readiness(w) + ); + Ok(()) + } + _ => bail!( + "usage: punktfunk-host audio-probe [--keep]" + ), } } @@ -243,57 +272,9 @@ fn probe_sss_primary(secs: u32) -> Result<()> { Ok(()) } -// --- driver discovery ---------------------------------------------------------------------- +// --- driver discovery — shared with the minted provider ------------------------------------- -/// Find the (exact hardware id, INF path) for a Steam streaming driver: prefer any installed -/// devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver Windows -/// already trusts), else fall back to Steam's driver directory. -fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, String)> { - let set = pe::media_class_devs()?; - for i in 0.. { - let mut did = pe::devinfo_data(); - // SAFETY: live set; `did` is a live out-param with cbSize set. - if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() { - break; - } - let Some(hwid) = pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID) - .into_iter() - .find(|h| h.to_lowercase().contains(needle)) - else { - continue; - }; - if let Some(inf) = pe::devnode_inf_path(&set, &did) { - let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); - let full = format!(r"{windir}\INF\{inf}"); - if std::path::Path::new(&full).exists() { - return Ok((hwid, full)); - } - } - // Devnode exists but its INF is gone — keep its exact hwid, try Steam's directory. - if let Some(w) = super::wasapi_mic::steam_driver_inf_path(inf_name) { - let s = String::from_utf16_lossy(&w) - .trim_end_matches('\0') - .to_string(); - if std::path::Path::new(&s).exists() { - return Ok((hwid, s)); - } - } - } - // No installed devnode at all: canonical hwid + Steam's directory. - if let Some(w) = super::wasapi_mic::steam_driver_inf_path(inf_name) { - let s = String::from_utf16_lossy(&w) - .trim_end_matches('\0') - .to_string(); - if std::path::Path::new(&s).exists() { - let hwid = format!("ROOT\\{}", inf_name.trim_end_matches(".inf")); - return Ok((hwid, s)); - } - } - bail!( - "no installed devnode matches {needle:?} and Steam's driver directory has no \ - {inf_name} — install Steam (it never needs to run)" - ) -} +use super::minted::discover_driver; // --- probe devnode marker + cleanup -------------------------------------------------------- diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs new file mode 100644 index 00000000..bfbdfdd4 --- /dev/null +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -0,0 +1,378 @@ +//! Minted punktfunk-owned audio endpoints — the Windows audio substrate. +//! +//! The audio-substrate decision (`windows-audio-endpoints-and-vbcable.md`, 2026-08-07, spikes +//! S2+S3 measured green): instead of borrowing Steam's primary endpoints and bundling VB-Cable +//! for the mic, the host mints its OWN instances of Valve's streaming-audio drivers — +//! +//! * **"Punktfunk Speakers"** (`SteamStreamingSpeakers.inf`): the client-only loopback sink. +//! Desktop audio routes here (the wiring plan parks the default playback on it during a +//! stream), its WASAPI loopback feeds the encoder, and the host stays silent. Measured +//! clean: 48 kHz stereo f32, loopback peak == rendered peak (S2). +//! * **"Punktfunk Microphone"** (`SteamStreamingMicrophone.inf`): the virtual mic. The host +//! writes the client's decoded voice into its render side; its capture side surfaces as the +//! microphone host apps record. Measured bit-faithful render→capture (S3). +//! +//! Provisioning mirrors the pad-audio provider: a background worker at host start, idempotent +//! devnode-per-role with a durable `PunktfunkAudioRole` marker in `Device Parameters` (names +//! are NOT identity — a minted instance is name-identical to Steam's primaries), results +//! published once for the wiring plan to consume BY ID ([`minted_ids`] → +//! [`wiring_plan::MintedIds`]). Everything is best-effort: no Steam driver, a denied install, +//! or `PUNKTFUNK_NO_AUDIO_MINT` leaves the ids empty and the wiring plan falls back to the +//! name-based ladder (Steam primaries → cable → real hardware) unchanged. +//! +//! Endpoints are PERSISTENT by design, like pad endpoints — they survive host restarts and +//! re-resolve by marker on the next start. `punktfunk-host audio-probe` carries the manual +//! `mint` / `plan` inspection paths. + +use super::pad_endpoint as pe; +use super::{audio_control, wiring_plan}; +use anyhow::{bail, Context, Result}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Durable role marker in a minted devnode's `Device Parameters` key. +const ROLE_MARKER: &str = "PunktfunkAudioRole"; +/// How long to wait for audiosrv to register a freshly minted endpoint. +const ENDPOINT_WAIT: Duration = Duration::from_secs(15); +/// Minimum spacing between provisioning retries once the startup attempt failed +/// ([`ensure_provisioned`] is called from wiring passes, which recur freely). +const RETRY_COOLDOWN: Duration = Duration::from_secs(60); + +/// The two minted roles. `value` is the persisted marker; the needles drive +/// [`discover_driver`]. +#[derive(Clone, Copy, PartialEq)] +enum Role { + Speakers, + Mic, +} + +impl Role { + fn value(self) -> u32 { + match self { + Role::Speakers => 1, + Role::Mic => 2, + } + } + fn desc(self) -> &'static str { + match self { + Role::Speakers => "Punktfunk Speakers", + Role::Mic => "Punktfunk Microphone", + } + } + fn needle(self) -> &'static str { + match self { + Role::Speakers => "steamstreamingspeakers", + Role::Mic => "steamstreamingmicrophone", + } + } + fn inf_name(self) -> &'static str { + match self { + Role::Speakers => "SteamStreamingSpeakers.inf", + Role::Mic => "SteamStreamingMicrophone.inf", + } + } + fn label(self) -> &'static str { + match self { + Role::Speakers => "speakers", + Role::Mic => "mic", + } + } +} + +/// The provider's published result. Partial is possible and usable (one driver leg failing +/// must not cost the other role); consumers read the per-role `Option`s. +#[derive(Debug, Default, Clone)] +pub(crate) struct MintedAudio { + pub speakers_devnode: Option, + pub speakers_render: Option, + pub mic_devnode: Option, + pub mic_render: Option, + pub mic_capture: Option, +} + +impl MintedAudio { + fn any(&self) -> bool { + self.speakers_render.is_some() || self.mic_render.is_some() + } +} + +/// Set once by the worker, and only when at least one role provisioned (the pad provider's R5 +/// lesson: latching an empty result turns one transient failure into a process-lifetime +/// disability). +static PROVISIONED: OnceLock> = OnceLock::new(); +/// A provisioning attempt is in flight — keeps concurrent askers to one worker. +static PROVISIONING: AtomicBool = AtomicBool::new(false); +/// When the last attempt STARTED — the [`RETRY_COOLDOWN`] anchor. +static LAST_ATTEMPT: Mutex> = Mutex::new(None); + +/// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned. +pub(crate) fn minted_ids() -> wiring_plan::MintedIds { + match PROVISIONED.get() { + Some(m) => wiring_plan::MintedIds { + speakers_render: m.speakers_render.clone(), + mic_render: m.mic_render.clone(), + mic_capture: m.mic_capture.clone(), + }, + None => wiring_plan::MintedIds::default(), + } +} + +/// Spawn the provisioning worker (idempotent; returns immediately). Called at host start next +/// to the pad provider, and again from [`ensure_provisioned`] on the retry path. +pub(crate) fn provision_at_startup() { + if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() { + return; + } + if PROVISIONED.get().is_some() || PROVISIONING.swap(true, Ordering::SeqCst) { + return; + } + *LAST_ATTEMPT.lock().unwrap() = Some(Instant::now()); + let spawned = thread::Builder::new() + .name("punktfunk-audio-mint".into()) + .spawn(|| { + match ensure_all() { + Ok(m) if m.any() => { + tracing::info!( + speakers = m.speakers_render.as_deref().unwrap_or("-"), + mic_render = m.mic_render.as_deref().unwrap_or("-"), + mic_capture = m.mic_capture.as_deref().unwrap_or("-"), + "minted audio endpoints ready (the wiring plan's tier-0)" + ); + let _ = PROVISIONED.set(Arc::new(m)); + } + Ok(_) => tracing::info!( + "no minted audio endpoints (Steam's streaming drivers absent?) — the \ + wiring plan keeps the name-based ladder" + ), + Err(e) => tracing::warn!(error = %format!("{e:#}"), + "minted-audio provisioning failed — the wiring plan keeps the name-based \ + ladder and a later wiring pass retries"), + } + PROVISIONING.store(false, Ordering::SeqCst); + }); + if let Err(e) = spawned { + PROVISIONING.store(false, Ordering::SeqCst); + tracing::warn!(error = %e, "could not spawn the minted-audio provisioning thread"); + } +} + +/// Retry hook for wiring passes: cheap once latched; while unlatched it re-asks at most every +/// [`RETRY_COOLDOWN`] — a box where Steam arrives later mints on a later pass instead of at +/// the next reboot. +pub(crate) fn ensure_provisioned() { + if PROVISIONED.get().is_some() { + return; + } + { + let last = LAST_ATTEMPT.lock().unwrap(); + if last.is_some_and(|t| t.elapsed() < RETRY_COOLDOWN) { + return; + } + } + provision_at_startup(); +} + +/// One synchronous provisioning pass over both roles (worker thread + the `audio-probe mint` +/// devtest). Per-role failures degrade to that role being absent. +fn ensure_all() -> Result { + wasapi::initialize_mta() + .ok() + .context("CoInitializeEx (MTA, minted-audio)")?; + let mut out = MintedAudio::default(); + for role in [Role::Speakers, Role::Mic] { + match ensure_role(role) { + Ok((devnode, render, capture)) => match role { + Role::Speakers => { + out.speakers_devnode = Some(devnode); + out.speakers_render = Some(render); + } + Role::Mic => { + out.mic_devnode = Some(devnode); + out.mic_render = Some(render); + out.mic_capture = capture; + } + }, + Err(e) => tracing::info!(role = role.label(), error = %format!("{e:#}"), + "minted-audio role unavailable"), + } + } + Ok(out) +} + +/// Ensure one role's devnode + endpoint(s): reuse the marker-matched devnode from an earlier +/// run, else mint one; (re)bind the driver idempotently; wait for audiosrv's endpoints; put +/// back any default device the fresh endpoint grabbed (measured on the pad program: a newly +/// registered endpoint can take either default). +fn ensure_role(role: Role) -> Result<(String, String, Option)> { + let prev_render = audio_control::default_render_id(); + let prev_capture = audio_control::default_capture_id(); + + let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?; + let devnode = match find_role_devnode(role)? { + Some(inst) => inst, + None => { + let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| { + pe::write_devparam_dword(set, did, ROLE_MARKER, role.value()) + })?; + tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode"); + inst + } + }; + pe::bind_driver(&hwid, &inf)?; + + let render = wait_for(&devnode, false)?; + let capture = match role { + Role::Mic => Some(wait_for(&devnode, true).with_context(|| { + format!("the minted mic devnode {devnode} produced no capture endpoint") + })?), + Role::Speakers => None, + }; + + // Freshly registered endpoints can grab a default; the wiring plan owns default policy, + // not the mint. + if let Some(prev) = prev_render { + if audio_control::default_render_id().as_deref() != Some(prev.as_str()) + && audio_control::set_default_endpoint(&prev).is_ok() + { + tracing::info!( + role = role.label(), + "default playback restored after minting" + ); + } + } + if let Some(prev) = prev_capture { + if audio_control::default_capture_id().as_deref() != Some(prev.as_str()) + && audio_control::set_default_endpoint(&prev).is_ok() + { + tracing::info!( + role = role.label(), + "default recording restored after minting" + ); + } + } + Ok((devnode, render, capture)) +} + +/// Poll audiosrv for the endpoint a minted devnode registers in one direction. +fn wait_for(devnode: &str, capture: bool) -> Result { + let deadline = Instant::now() + ENDPOINT_WAIT; + loop { + let found = if capture { + pe::find_capture_endpoint_for_devnode(devnode)? + } else { + pe::find_endpoint_for_devnode(devnode)? + }; + if let Some(ep) = found { + return Ok(ep); + } + if Instant::now() >= deadline { + bail!( + "no {} endpoint appeared for {devnode} within {}s — is Audiosrv running?", + if capture { "capture" } else { "render" }, + ENDPOINT_WAIT.as_secs() + ); + } + thread::sleep(Duration::from_millis(250)); + } +} + +/// The devnode a previous run minted for `role` (marker-matched — names are not identity). +fn find_role_devnode(role: Role) -> Result> { + let set = pe::media_class_devs()?; + for i in 0.. { + let mut did = pe::devinfo_data(); + // SAFETY: live set; `did` is a live out-param with cbSize set. + if unsafe { + windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo( + set.0, i, &mut did, + ) + } + .is_err() + { + break; + } + if pe::read_devparam_dword(&set, &did, ROLE_MARKER) == Some(role.value()) { + if let Some(inst) = pe::instance_id(&set, &did) { + return Ok(Some(inst)); + } + } + } + Ok(None) +} + +/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any +/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver +/// Windows already trusts), else fall back to Steam's driver directory. Shared with the +/// `audio-probe` devtest. +pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, String)> { + use windows::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiEnumDeviceInfo, SPDRP_HARDWAREID, + }; + let steam_dir_inf = || -> Option { + let w = super::wasapi_mic::steam_driver_inf_path(inf_name)?; + let s = String::from_utf16_lossy(&w) + .trim_end_matches('\0') + .to_string(); + std::path::Path::new(&s).exists().then_some(s) + }; + let set = pe::media_class_devs()?; + for i in 0.. { + let mut did = pe::devinfo_data(); + // SAFETY: live set; `did` is a live out-param with cbSize set. + if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() { + break; + } + let Some(hwid) = pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID) + .into_iter() + .find(|h| h.to_lowercase().contains(needle)) + else { + continue; + }; + if let Some(inf) = pe::devnode_inf_path(&set, &did) { + let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); + let full = format!(r"{windir}\INF\{inf}"); + if std::path::Path::new(&full).exists() { + return Ok((hwid, full)); + } + } + // Devnode exists but its INF is gone — keep its exact hwid, try Steam's directory. + if let Some(s) = steam_dir_inf() { + return Ok((hwid, s)); + } + } + // No installed devnode at all: canonical hwid + Steam's directory. + if let Some(s) = steam_dir_inf() { + return Ok((format!("ROOT\\{}", inf_name.trim_end_matches(".inf")), s)); + } + bail!( + "no installed devnode matches {needle:?} and Steam's driver directory has no \ + {inf_name} — install Steam (it never needs to run)" + ) +} + +/// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed. +pub(crate) fn devtest_mint() -> Result<()> { + let m = ensure_all()?; + println!( + "audio-mint: speakers devnode={} render={}", + m.speakers_devnode.as_deref().unwrap_or("-"), + m.speakers_render.as_deref().unwrap_or("-") + ); + println!( + "audio-mint: mic devnode={} render={} capture={}", + m.mic_devnode.as_deref().unwrap_or("-"), + m.mic_render.as_deref().unwrap_or("-"), + m.mic_capture.as_deref().unwrap_or("-") + ); + if m.any() { + let _ = PROVISIONED.set(Arc::new(m)); + println!( + "audio-mint: published for this process — `audio-probe plan` shows the tier-0 pick" + ); + } else { + println!("audio-mint: nothing minted (Steam's streaming drivers absent?)"); + } + Ok(()) +} diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 0fed3aa4..0625ba66 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -571,9 +571,14 @@ pub(crate) fn devnode_inf_path(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Optio (len > 0).then(|| String::from_utf16_lossy(&units[..len])) } -/// The persisted pad slot of a devnode (the `PunktfunkPadIndex` value under its -/// `Device Parameters` key), or `None` for foreign devnodes. -fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { +/// Read a REG_DWORD from a devnode's `Device Parameters` key — the durable owner-marker +/// mechanism every punktfunk-minted devnode family uses (pad slot, minted-audio role, probe +/// marker). `None`: no key, no value, or wrong type — a foreign devnode. +pub(crate) fn read_devparam_dword( + set: &DevInfoSet, + did: &SP_DEVINFO_DATA, + value_name: &str, +) -> Option { // SAFETY: live set + element; DIREG_DEV opens the devnode's Device Parameters key. let hkey = unsafe { SetupDiOpenDevRegKey( @@ -586,7 +591,7 @@ fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { ) } .ok()?; - let name = wide(PAD_INDEX_VALUE); + let name = wide(value_name); let mut data = [0u8; 4]; let mut len = data.len() as u32; let mut ty = REG_VALUE_TYPE(0); @@ -609,6 +614,67 @@ fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { (rc.is_ok() && ty == REG_DWORD && len == 4).then(|| u32::from_le_bytes(data)) } +/// Write a REG_DWORD into a devnode's `Device Parameters` key, creating the key on a fresh +/// devnode — the write side of [`read_devparam_dword`]. +pub(crate) fn write_devparam_dword( + set: &DevInfoSet, + did: &mut SP_DEVINFO_DATA, + value_name: &str, + value: u32, +) -> Result<()> { + // SAFETY: live set + element; DIREG_DEV opens the devnode's Device Parameters key. + let opened = unsafe { + SetupDiOpenDevRegKey( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + KEY_SET_VALUE.0, + ) + }; + let hkey = match opened { + Ok(k) => k, + // SAFETY: same set + element; a fresh devnode has no Device Parameters key yet, so + // create it (no INF association). + Err(_) => unsafe { + SetupDiCreateDevRegKeyW( + set.0, + did, + DICS_FLAG_GLOBAL.0, + 0, + DIREG_DEV, + None, + PCWSTR::null(), + ) + } + .with_context(|| format!("create the Device Parameters key for {value_name}"))?, + }; + let name = wide(value_name); + // SAFETY: the value name is NUL-terminated and outlives the call; the DWORD bytes travel + // with the slice. + let rc = unsafe { + RegSetValueExW( + hkey, + PCWSTR(name.as_ptr()), + None, + REG_DWORD, + Some(&value.to_le_bytes()), + ) + }; + // SAFETY: closing the key opened/created above, exactly once. + unsafe { + let _ = RegCloseKey(hkey); + } + rc.ok().with_context(|| format!("write {value_name}")) +} + +/// The persisted pad slot of a devnode (the `PunktfunkPadIndex` value under its +/// `Device Parameters` key), or `None` for foreign devnodes. +fn devnode_pad_index(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { + read_devparam_dword(set, did, PAD_INDEX_VALUE) +} + /// Find the devnode previously created for `pad_index` (see the module doc: the persisted /// index value is the durable marker; DeviceDesc only survives until the INF installs). fn find_devnode(pad_index: u8) -> Result> { @@ -688,51 +754,7 @@ fn create_devnode(pad_index: u8) -> Result { /// Persist `pad_index` in the devnode's `Device Parameters` key (created on a fresh devnode). fn write_pad_index(set: &DevInfoSet, did: &mut SP_DEVINFO_DATA, pad_index: u8) -> Result<()> { - // SAFETY: live set + element; DIREG_DEV opens the devnode's Device Parameters key. - let opened = unsafe { - SetupDiOpenDevRegKey( - set.0, - did, - DICS_FLAG_GLOBAL.0, - 0, - DIREG_DEV, - KEY_SET_VALUE.0, - ) - }; - let hkey = match opened { - Ok(k) => k, - // SAFETY: same set + element; a fresh devnode has no Device Parameters key yet, so - // create it (no INF association). - Err(_) => unsafe { - SetupDiCreateDevRegKeyW( - set.0, - did, - DICS_FLAG_GLOBAL.0, - 0, - DIREG_DEV, - None, - PCWSTR::null(), - ) - } - .context("create the devnode's Device Parameters key")?, - }; - let name = wide(PAD_INDEX_VALUE); - // SAFETY: the value name is NUL-terminated and outlives the call; the DWORD bytes travel - // with the slice. - let rc = unsafe { - RegSetValueExW( - hkey, - PCWSTR(name.as_ptr()), - None, - REG_DWORD, - Some(&(pad_index as u32).to_le_bytes()), - ) - }; - // SAFETY: closing the key opened/created above, exactly once. - unsafe { - let _ = RegCloseKey(hkey); - } - rc.ok().context("write PunktfunkPadIndex") + write_devparam_dword(set, did, PAD_INDEX_VALUE, pad_index as u32) } /// The Steam Streaming Speakers INF to feed `UpdateDriverForPlugAndPlayDevices`: prefer the diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 9614cfed..95e84162 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -107,6 +107,49 @@ pub(crate) fn no_formats(_: &Endpoint) -> Option { None } +/// The host's own MINTED endpoints — instances of Valve's streaming-audio driver the +/// [`minted`](super::minted) provider created at startup — by WASAPI endpoint id. +/// +/// Tier-0 is an IDENTITY tier, not a name tier: a minted instance is indistinguishable by +/// friendly name from Steam's own primaries (S1 measured exactly that confusion — the probe's +/// name match grabbed a stamped instance instead of the primary), so the provider records what +/// it minted and the plan matches by id. All fields empty when nothing is minted (Steam +/// absent, provisioning disabled or still running) — every rule then falls back to the +/// name-based ladder unchanged. +#[derive(Debug, Default, Clone, PartialEq)] +pub(crate) struct MintedIds { + /// "Punktfunk Speakers" — an SSS-driver instance reserved as the client-only loopback + /// sink. Never contended by Steam's own Remote Play, deterministic across re-plans. + pub speakers_render: Option, + /// "Punktfunk Microphone" render side — the virtual mic's write target. + pub mic_render: Option, + /// "Punktfunk Microphone" capture side — the microphone host apps record. + pub mic_capture: Option, +} + +/// The one-line runtime answer "does desktop audio work, does the mic work" — the §C4 +/// classification (logged with every plan change; the status API surfaces it later). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AudioReadiness { + /// Both roles have endpoints. + Full, + /// Desktop audio yes, mic passthrough no. + AudioOnly, + /// Mic yes, desktop audio no. + MicOnly, + /// Neither role has an endpoint. + Nothing, +} + +pub(crate) fn readiness(w: &Wiring) -> AudioReadiness { + match (w.loopback_render.is_some(), w.mic_render.is_some()) { + (true, true) => AudioReadiness::Full, + (true, false) => AudioReadiness::AudioOnly, + (false, true) => AudioReadiness::MicOnly, + (false, false) => AudioReadiness::Nothing, + } +} + /// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture /// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a /// once-per-process plan goes stale. @@ -224,6 +267,7 @@ pub(crate) fn plan( mic_want: Option<&str>, host_audio: bool, pad_renders: &[String], + minted: &MintedIds, ) -> Wiring { plan_with_formats( renders, @@ -233,6 +277,7 @@ pub(crate) fn plan( &no_formats, 2, pad_renders, + minted, ) } @@ -251,6 +296,7 @@ pub(crate) fn plan( /// exists — narrow audio beats no audio — but flagged in [`Wiring::loopback_narrowing`] so the /// capture side can say why. An unknown format (probe failed) counts as fine, so this can never /// make the plan worse than it was before formats existed. +#[allow(clippy::too_many_arguments)] // mirrors the enumeration inputs; a param struct would only rename the problem pub(crate) fn plan_with_formats( renders: &[Endpoint], captures: &[Endpoint], @@ -259,6 +305,7 @@ pub(crate) fn plan_with_formats( format_of: FormatProbe, want_channels: u8, pad_renders: &[String], + minted: &MintedIds, ) -> Wiring { // 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice // would play out of a pad "speaker"), never a loopback source (a game's controller @@ -279,10 +326,24 @@ pub(crate) fn plan_with_formats( .cloned() }; - // 1. Mic target first — it has the narrower requirements (must be a virtual cable). + // Tier-0 lookups: the minted ids resolved against THIS enumeration (an id the provider + // recorded but audiosrv no longer serves must not produce a phantom assignment). + let find_by_id = |id: &Option| -> Option { + id.as_deref() + .and_then(|id| renders.iter().find(|(_, rid)| rid == id).cloned()) + }; + let minted_mic = find_by_id(&minted.mic_render); + let minted_sink = find_by_id(&minted.speakers_render); + + // 1. Mic target first — it has the narrower requirements (must be a virtual cable). The + // minted "Punktfunk Microphone" outranks every name-based candidate: it exists for + // exactly this role, and taking it can never cost the loopback anything (the minted + // sink is its counterpart). An operator override still beats it. let mic_render = match mic_want { Some(w) => find_render(w), - None => MIC_CANDIDATES.iter().find_map(|c| find_render(c)), + None => minted_mic + .clone() + .or_else(|| 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 @@ -315,8 +376,17 @@ pub(crate) fn plan_with_formats( other => other, }; - // 2. Its capture side (what host apps record). - let mic_capture = mic_render.as_ref().and_then(|(name, _)| { + // 2. Its capture side (what host apps record). A minted mic resolves by the provider's + // recorded CAPTURE id — a name search cannot tell the minted microphone from Steam's + // primary (same friendly name), and pairing the minted render with the primary's + // capture would record a mic nothing writes into. + let mic_capture = mic_render.as_ref().and_then(|(name, id)| { + if Some(id) == minted.mic_render.as_ref() { + return minted + .mic_capture + .as_deref() + .and_then(|cid| captures.iter().find(|(_, c)| c == cid).cloned()); + } capture_for(&name.to_lowercase()).iter().find_map(|c| { captures .iter() @@ -364,13 +434,38 @@ pub(crate) fn plan_with_formats( .iter() .find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers")) }; + // Tier-0 sink: the minted "Punktfunk Speakers". Same quality discipline as every silent + // sink — a narrowing minted instance demotes below real hardware rather than silently + // costing quality (S2 measured the driver clean at 48 kHz stereo, so this is a guard, not + // an expectation). + let minted_intact = || { + minted_sink + .as_ref() + .filter(|(_, id)| not_mic(id)) + .filter(|ep| narrowing_of(ep).is_none()) + }; + let minted_narrow = || { + minted_sink + .as_ref() + .filter(|(_, id)| not_mic(id)) + .filter(|ep| narrowing_of(ep).is_some()) + }; // A narrowing silent sink sits below real hardware in BOTH modes: preferring silence on the // host is a routing choice, but it must not silently cost audio quality when a clean endpoint - // is right there. + // is right there. The minted sink heads its tier in both modes — it is the one endpoint + // whose whole purpose is this role. let preferred = if host_audio { - real_hw().or_else(silent_intact).or_else(silent_narrow) + real_hw() + .or_else(minted_intact) + .or_else(silent_intact) + .or_else(minted_narrow) + .or_else(silent_narrow) } else { - silent_intact().or_else(real_hw).or_else(silent_narrow) + minted_intact() + .or_else(silent_intact) + .or_else(real_hw) + .or_else(minted_narrow) + .or_else(silent_narrow) }; let (loopback_render, loopback_last_resort) = match preferred { Some(ep) => (Some(ep.clone()), false), @@ -503,7 +598,7 @@ mod tests { ep("Microphone (Webcam)"), ep("CABLE Output (VB-Audio Virtual Cable)"), ]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -532,7 +627,7 @@ mod tests { ep("CABLE Output (VB-Audio Virtual Cable)"), ep("Microphone (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -552,7 +647,7 @@ mod tests { ep("CABLE Input (VB-Audio Virtual Cable)"), ep("Speakers (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &[], None, true, &[]); + let w = plan(&renders, &[], None, true, &[], &MintedIds::default()); assert_eq!( w.loopback_render.unwrap().0, "Speakers (Apple Audio Device)" @@ -569,7 +664,7 @@ mod tests { ep("CABLE In 16ch (VB-Audio Virtual Cable)"), ]; for host_audio in [false, true] { - let w = plan(&renders, &[], None, host_audio, &[]); + let w = plan(&renders, &[], None, host_audio, &[], &MintedIds::default()); assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); } } @@ -581,7 +676,7 @@ mod tests { fn headless_cable_only_mic_wins() { let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert!(w.mic_render.is_some(), "mic must claim the only cable"); assert!(w.loopback_render.is_none(), "no echo-safe loopback exists"); } @@ -599,7 +694,7 @@ mod tests { ep("CABLE Output (VB-Audio Virtual Cable)"), ep("Microphone (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -628,7 +723,7 @@ mod tests { ep("Speakers (Realtek HD Audio)"), ]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -644,7 +739,7 @@ mod tests { 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, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert!(w.mic_render.is_none()); assert!(w.mic_withheld); assert_eq!( @@ -668,7 +763,7 @@ mod tests { ep("Microphone (Steam Streaming Microphone)"), ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)"), ]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert_eq!( w.mic_render.as_ref().unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)" @@ -696,7 +791,7 @@ mod tests { ep("Speakers (Steam Streaming Speakers)"), ]; for host_audio in [false, true] { - let w = plan(&renders, &[], None, host_audio, &[]); + let w = plan(&renders, &[], None, host_audio, &[], &MintedIds::default()); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Speakers (Steam Streaming Speakers)", @@ -719,7 +814,14 @@ mod tests { ]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio, &[]); + let w = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); assert!(w.mic_render.is_none(), "host_audio={host_audio}"); assert!(w.mic_withheld, "host_audio={host_audio}"); assert_eq!( @@ -747,6 +849,7 @@ mod tests { Some("steam streaming microphone"), false, &[], + &MintedIds::default(), ); assert_eq!( w.mic_render.unwrap().0, @@ -771,7 +874,14 @@ mod tests { ]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio, &[]); + let w = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Speakers (Realtek HD Audio)", @@ -793,7 +903,14 @@ mod tests { ]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio, &[]); + let w = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); assert!(!w.loopback_last_resort, "host_audio={host_audio}"); assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}"); @@ -842,7 +959,16 @@ mod tests { ("steam streaming microphone", fmt(24_000, 1)), ("odyssey", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &captures, None, false, &p, 2, &[]); + let w = plan_with_formats( + &renders, + &captures, + None, + false, + &p, + 2, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "1 - Odyssey G60SD (AMD High Definition Audio Device)", @@ -872,7 +998,16 @@ mod tests { ("steam streaming microphone", fmt(48_000, 2)), ("realtek", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); + let w = plan_with_formats( + &renders, + &[], + None, + false, + &p, + 2, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -888,7 +1023,16 @@ mod tests { ep("Speakers (Steam Streaming Microphone)"), ]; let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); + let w = plan_with_formats( + &renders, + &[], + None, + false, + &p, + 2, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -904,7 +1048,16 @@ mod tests { fn narrowing_is_reported_for_real_hardware_too() { let renders = [ep("Headset (Hands-Free AG Audio)")]; let p = probe(vec![("headset", fmt(16_000, 1))]); - let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]); + let w = plan_with_formats( + &renders, + &[], + None, + false, + &p, + 2, + &[], + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Headset (Hands-Free AG Audio)" @@ -924,8 +1077,24 @@ mod tests { ]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; for host_audio in [false, true] { - let a = plan(&renders, &captures, None, host_audio, &[]); - let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2, &[]); + let a = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); + let b = plan_with_formats( + &renders, + &captures, + None, + host_audio, + &no_formats, + 2, + &[], + &MintedIds::default(), + ); assert_eq!(a, b, "host_audio={host_audio}"); assert!(a.loopback_narrowing.is_none()); } @@ -943,7 +1112,7 @@ mod tests { ("steam streaming microphone", fmt(24_000, 1)), ("realtek", fmt(48_000, 2)), ]); - let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[]); + let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[], &MintedIds::default()); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); } @@ -971,7 +1140,14 @@ mod tests { ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"), ]; let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")]; - let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]); + let w = plan( + &renders, + &captures, + Some("voicemeeter input"), + false, + &[], + &MintedIds::default(), + ); assert_eq!( w.mic_render.unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)" @@ -987,7 +1163,7 @@ mod tests { #[test] fn no_virtual_device() { let renders = [ep("Speakers (Realtek HD Audio)")]; - let w = plan(&renders, &[], None, false, &[]); + let w = plan(&renders, &[], None, false, &[], &MintedIds::default()); assert!(w.mic_render.is_none()); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); } @@ -1005,7 +1181,14 @@ mod tests { ]; let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")]; for host_audio in [false, true] { - let w = plan(&renders, &captures, None, host_audio, &[]); + let w = plan( + &renders, + &captures, + None, + host_audio, + &[], + &MintedIds::default(), + ); assert_eq!( w.mic_render.as_ref().unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)", @@ -1028,7 +1211,7 @@ mod tests { ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"), ]; for host_audio in [false, true] { - let w = plan(&renders, &[], None, host_audio, &[]); + let w = plan(&renders, &[], None, host_audio, &[], &MintedIds::default()); assert!(w.mic_render.is_some(), "host_audio={host_audio}"); assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); } @@ -1043,7 +1226,7 @@ mod tests { ep("CABLE Input (VB-Audio Virtual Cable)"), ep("Speakers (Some Virtual Audio Device)"), ]; - let w = plan(&renders, &[], None, false, &[]); + let w = plan(&renders, &[], None, false, &[], &MintedIds::default()); assert!(w.loopback_render.is_none()); } @@ -1077,6 +1260,7 @@ mod tests { Some("steam streaming microphone"), false, &[], + &MintedIds::default(), ); assert!(w.loopback_unsatisfiable()); let msg = describe_no_loopback(&renders, &w); @@ -1088,7 +1272,7 @@ mod tests { // anyway), while the Steam pair is the remedy that adds a capturable sink. let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; - let w = plan(&renders, &captures, None, false, &[]); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); assert!(w.loopback_unsatisfiable()); let msg = describe_no_loopback(&renders, &w); assert!(msg.contains("install Steam"), "{msg}"); @@ -1106,7 +1290,7 @@ mod tests { ep("Speakers (Realtek HD Audio)"), ]; let pads = [renders[0].1.clone()]; - let w = plan(&renders, &[], None, false, &pads); + let w = plan(&renders, &[], None, false, &pads, &MintedIds::default()); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); // Even an operator mic override matching the pad's name must not claim it; with the // pad as the only render endpoint there is honestly no mic target and no loopback. @@ -1116,6 +1300,7 @@ mod tests { Some("wireless controller"), false, &pads, + &MintedIds::default(), ); assert!(w.mic_render.is_none()); assert!(w.loopback_render.is_none()); @@ -1143,6 +1328,7 @@ mod tests { Some("steam streaming microphone"), false, &pads, + &MintedIds::default(), ); assert_eq!( w.loopback_render.as_ref().unwrap().0, @@ -1153,7 +1339,14 @@ mod tests { // …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable // rather than falling back onto the coils. - let w = plan(&renders[..1], &captures, None, false, &pads); + let w = plan( + &renders[..1], + &captures, + None, + false, + &pads, + &MintedIds::default(), + ); assert!( w.loopback_render.is_none(), "a pad was taken as the last resort" @@ -1161,4 +1354,164 @@ mod tests { assert!(!w.loopback_last_resort); assert!(w.loopback_unsatisfiable()); } + + // ---- minted tier-0 (the audio-substrate program) ------------------------------------- + + /// The minted zoo: both punktfunk instances present alongside the primaries, real + /// hardware, AND a cable — deliberately name-identical to the primaries, because that is + /// what the driver produces (S1 measured the confusion). + fn minted_zoo() -> ([Endpoint; 6], [Endpoint; 3], MintedIds) { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Lautsprecher (Steam Streaming Speakers)"), + ep("Lautsprecher (Steam Streaming Microphone)"), + ( + "Lautsprecher (Steam Streaming Speakers)".into(), + "id-minted-spk".into(), + ), + ( + "Lautsprecher (Steam Streaming Microphone)".into(), + "id-minted-mic-r".into(), + ), + ]; + let captures = [ + ep("CABLE Output (VB-Audio Virtual Cable)"), + ep("Mikrofon (Steam Streaming Microphone)"), + ( + "Mikrofon (Steam Streaming Microphone)".into(), + "id-minted-mic-c".into(), + ), + ]; + let minted = MintedIds { + speakers_render: Some("id-minted-spk".into()), + mic_render: Some("id-minted-mic-r".into()), + mic_capture: Some("id-minted-mic-c".into()), + }; + (renders, captures, minted) + } + + /// The end-state: with the minted pair present, the mic takes its own device and the + /// loopback takes the minted sink — by ID, ignoring the name-identical primaries, the + /// cable, and real hardware. Both features coexist without VB-Cable, client-only silent. + #[test] + fn minted_pair_is_tier_zero() { + let (renders, captures, minted) = minted_zoo(); + let w = plan(&renders, &captures, None, false, &[], &minted); + assert_eq!(w.mic_render.as_ref().unwrap().1, "id-minted-mic-r"); + assert_eq!( + w.mic_capture.as_ref().unwrap().1, + "id-minted-mic-c", + "the capture side must pair by the provider's id, never by name" + ); + assert_eq!(w.loopback_render.as_ref().unwrap().1, "id-minted-spk"); + assert!(!w.loopback_last_resort); + assert!(!w.mic_withheld); + assert_eq!(readiness(&w), AudioReadiness::Full); + } + + /// `host_audio` still prefers real hardware for the loopback; the mic keeps its minted + /// device either way. + #[test] + fn minted_host_audio_prefers_hardware() { + let (renders, captures, minted) = minted_zoo(); + let w = plan(&renders, &captures, None, true, &[], &minted); + assert_eq!(w.mic_render.as_ref().unwrap().1, "id-minted-mic-r"); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + } + + /// The operator override still beats the minted mic — an explicit choice wins everything. + #[test] + fn env_override_beats_minted() { + let (renders, captures, minted) = minted_zoo(); + let w = plan( + &renders, + &captures, + Some("cable input"), + false, + &[], + &minted, + ); + assert_eq!( + w.mic_render.unwrap().0, + "CABLE Input (VB-Audio Virtual Cable)" + ); + // The minted sink still serves the loopback. + assert_eq!(w.loopback_render.unwrap().1, "id-minted-spk"); + } + + /// Partial mint (speakers only — the SSM leg failed): the mic falls back to the name + /// ladder, the loopback keeps the minted sink. Nothing regresses below today's behavior. + #[test] + fn minted_speakers_only_mic_uses_ladder() { + let (renders, captures, mut minted) = minted_zoo(); + minted.mic_render = None; + minted.mic_capture = None; + let w = plan(&renders, &captures, None, false, &[], &minted); + assert_eq!( + w.mic_render.unwrap().0, + "CABLE Input (VB-Audio Virtual Cable)" + ); + assert_eq!(w.loopback_render.unwrap().1, "id-minted-spk"); + } + + /// A minted id the enumeration no longer serves must not produce a phantom assignment — + /// the plan falls back to the ladder exactly as if nothing were minted. + #[test] + fn stale_minted_ids_fall_back() { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ]; + let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; + let minted = MintedIds { + speakers_render: Some("id-gone".into()), + mic_render: Some("id-gone-too".into()), + mic_capture: Some("id-gone-three".into()), + }; + let a = plan(&renders, &captures, None, false, &[], &minted); + let b = plan(&renders, &captures, None, false, &[], &MintedIds::default()); + assert_eq!(a, b); + } + + /// A minted sink that NARROWS the mix demotes below real hardware like any silent sink — + /// tier-0 is an identity privilege, not a quality exemption. + #[test] + fn minted_sink_narrowing_demotes() { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ( + "Lautsprecher (Steam Streaming Speakers)".into(), + "id-minted-spk".into(), + ), + ]; + let minted = MintedIds { + speakers_render: Some("id-minted-spk".into()), + ..Default::default() + }; + let p = probe(vec![("steam streaming", fmt(16_000, 1))]); + let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[], &minted); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + } + + /// The readiness classification the log line (and later the status API) carries. + #[test] + fn readiness_table() { + let (renders, captures, minted) = minted_zoo(); + let full = plan(&renders, &captures, None, false, &[], &minted); + assert_eq!(readiness(&full), AudioReadiness::Full); + // Steam-pair-only, no cable: audio yes (withheld mic), mic no. + let renders = [ep("Altavoces (Steam Streaming Microphone)")]; + let captures = [ep("Microphone (Steam Streaming Microphone)")]; + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); + assert_eq!(readiness(&w), AudioReadiness::AudioOnly); + // Cable-only headless: mic yes, audio no. + let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; + let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); + assert_eq!(readiness(&w), AudioReadiness::MicOnly); + // Nothing at all. + let w = plan(&[], &[], None, false, &[], &MintedIds::default()); + assert_eq!(readiness(&w), AudioReadiness::Nothing); + } } diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 81095888..2fff4e6c 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -495,11 +495,13 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { /// 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); /// run `ensure` under the service account or PsExec when the property-store route is denied. -/// Windows: the audio-substrate spike measurements (S1–S3 in -/// `windows-audio-endpoints-and-vbcable.md`) — `audio-probe ssm|sink|sss-primary|cleanup -/// [--keep]`. `ssm` is the decision gate: mint a second Steam Streaming Microphone devnode and -/// prove render→capture end to end; `sink` parks the default on a minted Speakers instance and -/// loopback-measures it; `sss-primary` re-measures the primary Speakers' known-silent loopback. +/// 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` = +/// the decision gate: mint a second Steam Streaming Microphone devnode and prove +/// render→capture end to end; `sink` parks the default on a minted Speakers instance and +/// loopback-measures it; `sss-primary` re-measures the primary Speakers' loopback), plus the +/// product paths: `mint` runs the minted-endpoint provider synchronously and `plan` prints +/// one real wiring pass with its readiness verdict. #[cfg(target_os = "windows")] pub fn audio_probe(args: &[String]) -> Result<()> { crate::audio::audio_probe::run(args) diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index db99a22b..b617f654 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -359,6 +359,11 @@ pub(crate) async fn serve( // Failures log once and leave the feature off: pads still work, just without pad audio. #[cfg(target_os = "windows")] crate::audio::pad_endpoint::provision_at_startup(); + // Windows: mint the punktfunk-owned audio endpoints ("Punktfunk Speakers/Microphone" — + // instances of Valve's streaming drivers, the wiring plan's tier-0). Best-effort on a + // worker thread; without Steam's drivers the wiring plan keeps its name-based ladder. + #[cfg(target_os = "windows")] + crate::audio::minted::provision_at_startup(); // Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path // restores the box's autologin gaming session on idle, not per-disconnect — see // `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it. -- 2.54.0 From 4a621de6b1ff6623f35b9c0c8aa1a1419a5bda75 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 11:27:29 +0200 Subject: [PATCH 05/21] =?UTF-8?q?chore(packaging):=20retire=20VB-Cable=20?= =?UTF-8?q?=E2=80=94=20audio's=20substrate=20is=20Steam's=20drivers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of the audio-substrate decision (spikes S2+S3 green, minted endpoints landed in the previous commit): stop bundling a third-party kernel driver the host no longer needs. installer the VB-CABLE task, payload, silent-install run and the donationware notice are gone; a suppressible notice tells a Steam-less box that audio needs Steam INSTALLED (never running) and that installing it later just works. A cable from an older install is still deliberately not removed. packer + CI -VbCableDir/VBCABLE_DIR, the staged-payload check and the runner provisioning download are gone; SBOM drops the redistributed-driver component. winget the VB-Audio bundling-grant agreement becomes the honest Steam requirement (surfaced on the unattended path where no wizard is on screen). docs windows-host/uninstall/security/echo say what actually ships: no kernel-mode driver of our own, endpoints minted from Valve's vendor-signed drivers, VB-CABLE mentioned only as the historical fallback that keeps working. host wording the mic-open guidance and module headers lead with Steam; the NAME ladder itself is untouched — demoting 'cable input' was considered and rejected (on a box where minting transiently fails, the SSM would outrank an installed cable, steal the silent sink, and make audio host-audible). --- .gitea/workflows/sbom.yml | 2 +- .gitea/workflows/windows-host.yml | 8 -- compliance/sbom/manual-components.cdx.json | 8 -- .../src/audio/windows/audio_control.rs | 5 +- .../src/audio/windows/wasapi_mic.rs | 10 +- .../punktfunk-host/src/audio/wiring_plan.rs | 17 ++-- docs-site/content/docs/echo.md | 9 +- docs-site/content/docs/security.md | 14 +-- docs-site/content/docs/uninstall.md | 9 +- docs-site/content/docs/windows-host.md | 27 +++--- packaging/windows/README.md | 33 +++---- packaging/windows/install-vbcable.ps1 | 97 ------------------- .../windows/licenses/VB-CABLE-NOTICE.txt | 26 ----- packaging/windows/pack-host-installer.ps1 | 32 +----- packaging/windows/punktfunk-host.iss | 58 +++++------ packaging/winget/README.md | 4 +- .../winget/unom.PunktfunkHost.installer.yaml | 6 +- .../unom.PunktfunkHost.locale.en-US.yaml | 15 ++- scripts/ci/gen-sbom.sh | 4 +- .../ci/provision-windows-punktfunk-extras.ps1 | 26 +---- 20 files changed, 122 insertions(+), 288 deletions(-) delete mode 100644 packaging/windows/install-vbcable.ps1 delete mode 100644 packaging/windows/licenses/VB-CABLE-NOTICE.txt diff --git a/.gitea/workflows/sbom.yml b/.gitea/workflows/sbom.yml index 871bbdaa..8cfc4f34 100644 --- a/.gitea/workflows/sbom.yml +++ b/.gitea/workflows/sbom.yml @@ -9,7 +9,7 @@ # # What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in # both Rust workspaces + the JS trees + Swift Package.resolved) merged with -# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, VB-CABLE, gamescope). +# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, gamescope). name: sbom # One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels # it (a canary only needs the latest commit; each release tag is its own ref so tag runs never diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index 0b63931f..65050e73 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -148,13 +148,6 @@ jobs: if (-not $env:FFMPEG_DIR) { "FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } - # VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by - # provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the - # streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR - # (the daemon env only refreshes on a runner-task restart). - if (-not $env:VBCABLE_DIR) { - "VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - } $pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag $v = if ($env:GITHUB_REF -like 'refs/tags/v*') { $env:GITHUB_REF_NAME -replace '^v', '' @@ -404,7 +397,6 @@ jobs: @{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' } @{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' } @{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' } - @{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' } ) $missing = @() foreach ($x in $need) { diff --git a/compliance/sbom/manual-components.cdx.json b/compliance/sbom/manual-components.cdx.json index 56587039..ba80fbb8 100644 --- a/compliance/sbom/manual-components.cdx.json +++ b/compliance/sbom/manual-components.cdx.json @@ -59,14 +59,6 @@ "licenses": [{ "license": { "id": "Zlib" } }], "externalReferences": [{ "type": "vcs", "url": "https://github.com/libsdl-org/SDL" }] }, - { - "type": "application", - "name": "VB-CABLE", - "version": "redistributed installer, see packaging/windows/install-vbcable.ps1", - "description": "Third-party kernel-mode virtual audio driver redistributed with the Windows host; notice at packaging/windows/licenses/VB-CABLE-NOTICE.txt. Planned to be replaced by an attestation-signed first-party driver.", - "licenses": [{ "license": { "name": "Proprietary freeware (VB-Audio Software, redistribution permitted per notice)" } }], - "externalReferences": [{ "type": "website", "url": "https://vb-audio.com/Cable/" }] - }, { "type": "application", "name": "punktfunk-gamescope", diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index f298296c..b40d5e93 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -3,7 +3,10 @@ //! //! A headless host has no real audio output, so BOTH the desktop-audio loopback ([`super::wasapi_cap`]) //! and the virtual mic ([`super::wasapi_mic`]) must run on VIRTUAL audio cables — and on DIFFERENT -//! ones, or the loopback re-captures the injected mic (an infinite echo). The installer bundles +//! ones, or the loopback re-captures the injected mic (an infinite echo). The host mints its own +//! endpoint pair from Steam's streaming drivers (see [`super::minted`] — the plan's tier-0); the +//! name-based ladder below covers boxes where minting is unavailable. Historically the installer +//! bundled //! VB-Audio Virtual Cable (the mic target: its "CABLE Input" render endpoint → "CABLE Output" capture) //! and the host auto-installs the Steam Streaming pair (a loopback-capable render). This module wires //! them up so no manual Sound-settings fiddling is ever needed: diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index 09b85bff..b27b9590 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -4,7 +4,8 @@ //! **capture** endpoint then surfaces as a microphone that host apps can record from. //! //! The target comes from the [`audio_control::wire_now`] plan (recomputed on every open): VB-Audio -//! "CABLE Input" (bundled by the installer — the dedicated mic target), the Steam Streaming +//! the minted "Punktfunk Microphone" (tier-0, see `super::minted`), then by name: VB-Audio +//! "CABLE Input" (bundled by installers until the audio-substrate change), the Steam Streaming //! Microphone, VoiceMeeter, or anything with "virtual" in the name; `PUNKTFUNK_MIC_DEVICE` overrides. //! The plan reserves the mic target and points the desktop-audio loopback at a DIFFERENT endpoint, so //! injecting here can never echo into the host→client audio stream (see @@ -238,9 +239,10 @@ fn resolve_target() -> Result<(wasapi::Device, String)> { ); } 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 \ - Microphone), or set PUNKTFUNK_MIC_DEVICE=." + "no virtual-mic render endpoint on this box. Install Steam (the host mints its own \ + microphone endpoint from Steam's streaming drivers — Steam never needs to run), or \ + install VB-Audio Virtual Cable, or set PUNKTFUNK_MIC_DEVICE=." ); }; let name = ep.0.clone(); diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 95e84162..04c9b059 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -12,12 +12,15 @@ //! //! WASAPI loopback captures *everything* an endpoint renders — including what the virtual mic //! writes — so if both land on the same device the client's voice echoes straight back into the -//! client's own audio stream. The plan therefore assigns the mic its endpoint FIRST (VB-CABLE is -//! bundled by the installer for exactly this) and gives the loopback a *different* one; when only +//! client's own audio stream. **Tier-0** avoids the collision by construction: the host mints +//! its OWN pair from Steam's streaming drivers ([`MintedIds`] — "Punktfunk Microphone" for the +//! mic, "Punktfunk Speakers" for the loopback, matched by ID because their names are identical +//! to Steam's primaries). Below tier-0, the name ladder keeps the old discipline: the mic is +//! assigned FIRST (VB-CABLE was bundled by installers until the audio-substrate change; a +//! user-installed cable still serves) and the loopback gets a *different* endpoint; when only //! the cable exists (headless box, no other output), the MIC wins and the loopback is honestly //! unavailable. The old code did the opposite — the mic refused the cable because it was the -//! default render endpoint — which permanently killed mic passthrough in the exact configuration -//! the installer ships (VB-CABLE as the only render device). +//! default render endpoint — which permanently killed mic passthrough on exactly that box. //! //! **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 @@ -192,9 +195,11 @@ impl Wiring { } /// Render-endpoint friendly-name substrings (lowercased) usable as the virtual-mic write target, -/// ordered by preference. VB-CABLE first: the installer bundles it for this exact purpose. +/// ordered by preference — the NAME ladder below the minted tier-0 ([`MintedIds`] outranks all +/// of these). VB-CABLE first among the names: installers bundled it for the mic until the +/// audio-substrate change, and a user-installed cable still serves. const MIC_CANDIDATES: &[&str] = &[ - "cable input", // VB-Audio Virtual Cable — bundled by the installer + "cable input", // VB-Audio Virtual Cable — user-installed / from older bundled installs "steam streaming microphone", "voicemeeter input", "voicemeeter aux input", diff --git a/docs-site/content/docs/echo.md b/docs-site/content/docs/echo.md index 59acbc14..5b82e549 100644 --- a/docs-site/content/docs/echo.md +++ b/docs-site/content/docs/echo.md @@ -27,11 +27,12 @@ leaving the stream; see [Input](/docs/input#getting-your-input-back). ## "Listen to this device" and app monitoring (Windows hosts) Windows can play a microphone straight out of the speakers. If **Listen to this device** is -ticked for the Punktfunk mic (usually *CABLE Output*), your voice plays on the host's output — -which the stream then captures and sends right back to you. +ticked for the Punktfunk mic — a *Steam Streaming Microphone*-class device on current hosts, +*CABLE Output* on older ones — your voice plays on the host's output, which the stream then +captures and sends right back to you. -Open **Sound settings → More sound settings → Recording**, double-click *CABLE Output*, and on -the **Listen** tab untick *Listen to this device*. +Open **Sound settings → More sound settings → Recording**, double-click the Punktfunk mic, and +on the **Listen** tab untick *Listen to this device*. The same loop hides in apps: **Discord's** *Mic Test* / input monitoring, **OBS's** *Monitor audio* on a mic source, and similar monitoring features in other tools all play your mic into diff --git a/docs-site/content/docs/security.md b/docs-site/content/docs/security.md index ad9e9dc3..1f8bfe8a 100644 --- a/docs-site/content/docs/security.md +++ b/docs-site/content/docs/security.md @@ -167,12 +167,14 @@ We mitigate this deliberately: - **Punktfunk's own drivers are user-mode.** The virtual display, both virtual-gamepad drivers (DualSense / DualShock 4 / Edge / Deck, and Xbox 360 / XInput) and the virtual pointer are **user-mode (UMDF)** drivers, so a driver bug is contained to a restricted service account — never - ring-0, never full-system. (This is why Punktfunk dropped ViGEmBus.) **One exception:** the - microphone-passthrough option installs VB-CABLE, a third-party **kernel-mode** audio driver from - VB-Audio. It's a ticked-by-default checkbox on the installer's task page — clear it if you don't - want it, though on a headless host (no real sound device) a virtual cable is also what desktop - audio plays into — and because other applications may use it, uninstalling Punktfunk leaves it in - place; remove it through its own uninstaller. + ring-0, never full-system. (This is why Punktfunk dropped ViGEmBus.) Audio is the one place a + kernel-mode driver is unavoidable (Windows has no user-mode way to create an audio device), and + Punktfunk deliberately ships none of its own: the "Punktfunk Speakers" and "Punktfunk + Microphone" endpoints are instances of **Steam's vendor-signed streaming-audio drivers**, + created on your box from your own Steam install. Older Punktfunk versions bundled VB-CABLE + (a third-party kernel-mode driver from VB-Audio) for the microphone; if you have one, other + applications may use it, so uninstalling Punktfunk leaves it in place — remove it through its + own uninstaller. - **Sealed internal channels.** The desktop-frame ring and the gamepad input/output channels are passed between the host and its drivers as duplicated handles to unnamed objects, so another local service can't open them by name to read your screen or forge controller input. diff --git a/docs-site/content/docs/uninstall.md b/docs-site/content/docs/uninstall.md index 49d4bdb2..8a1972de 100644 --- a/docs-site/content/docs/uninstall.md +++ b/docs-site/content/docs/uninstall.md @@ -210,10 +210,11 @@ Three things are left on purpose: Remove-Item -Recurse -Force "$env:ProgramData\punktfunk" ``` -- **VB-CABLE**, unless you cleared its checkbox during setup — it is ticked by default. It is a - third-party VB-Audio component other apps may be using, so the Punktfunk uninstaller never touches - it. Remove it with its own uninstaller — - `VBCABLE_Setup_x64.exe -u -h` — or the **VB-Audio Virtual Cable** entry in Installed apps. +- **VB-CABLE**, if an older Punktfunk version installed it (releases used to bundle it for the + microphone; current hosts use Steam's streaming drivers instead). It is a third-party VB-Audio + component other apps may be using, so the Punktfunk uninstaller never touches it. Remove it + with its own uninstaller — `VBCABLE_Setup_x64.exe -u -h` — or the **VB-Audio Virtual Cable** + entry in Installed apps. - **The publisher certificate**, if you imported it by hand to silence the Unknown Publisher prompt. Remove it in `certlm.msc` under **Trusted Publishers** and **Trusted Root Certification Authorities**. (This is *not* the driver certificate above, which the uninstaller does remove.) diff --git a/docs-site/content/docs/windows-host.md b/docs-site/content/docs/windows-host.md index 2d23a93c..730884a9 100644 --- a/docs-site/content/docs/windows-host.md +++ b/docs-site/content/docs/windows-host.md @@ -51,9 +51,10 @@ Download the signed `punktfunk-host-setup-.exe` from the displays, - installs the bundled **virtual gamepad drivers** (DualSense, DualShock 4, Xbox 360), - registers the bundled **HDR Vulkan layer** so Vulkan games can enable HDR over the virtual display, -- installs **VB-CABLE** (VB-Audio, donationware) as the virtual microphone for client mic - passthrough — a checkbox in the installer, **ticked by default**; clear it, or pass - `/MERGETASKS="!installaudiocable"`, if you don't want it, +- checks for **Steam** — game audio and microphone passthrough run through Punktfunk's own + instances of Steam's streaming audio drivers ("Punktfunk Speakers" / "Punktfunk Microphone"), + so Steam needs to be **installed** on the host (it never has to run). Without it the host + streams video only, and picks Steam up automatically whenever you install it, - adds a **status icon** to the notification area (see [Status tray](#status-tray)), - sets up the **web management console** (see below). @@ -72,12 +73,13 @@ winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Res winget install unom.PunktfunkHost ``` -Before it downloads anything, winget shows the package's agreements — the bundled VB-CABLE notice, -and that Moonlight compatibility is off by default — and asks you to accept them. +Before it downloads anything, winget shows the package's agreements — that audio needs Steam +installed on the host, and that Moonlight compatibility is off by default — and asks you to +accept them. `winget install` runs setup silently with the same defaults the wizard shows, so the console password is generated for you (see [Unattended install](#unattended-install)). Add `--interactive` -for the full wizard instead (the task checkboxes, the console-password page, the VB-CABLE notice). +for the full wizard instead (the task checkboxes and the console-password page). To change an individual installer task on the silent path, pass the whole switch line through `--override` — not `--custom`, which *appends* and would leave two `/MERGETASKS` on one command line: @@ -230,8 +232,9 @@ Open **Settings → Apps → Installed apps → Punktfunk Host → Uninstall**, Three things are left behind on purpose: **`%ProgramData%\punktfunk`** (`host.env`, the host certificate and key, the management token, the console password, your paired devices and the logs — -keeping it is what makes a reinstall pick up where you left off), **VB-CABLE** unless you cleared its -checkbox, and **the publisher certificate** if you imported one by hand. +keeping it is what makes a reinstall pick up where you left off), **VB-CABLE** if an older +Punktfunk version installed it (releases used to bundle it for the microphone), and **the +publisher certificate** if you imported one by hand. [Uninstalling → Windows host](/docs/uninstall#windows-host) shows how to clear each one, and has the same walkthrough for the other platforms. @@ -251,9 +254,9 @@ the status icon's menu. Running as SYSTEM is what makes headless, log-in-optional streaming work — and it's why the host is a high-privilege component worth being deliberate about. Punktfunk mitigates this with **user-mode drivers** — the virtual display, the virtual gamepads and the virtual pointer are all UMDF, none of -ours is kernel-mode (the optional third-party VB-CABLE mic driver is the one exception) — **sealed -internal channels** between the host and its drivers, and Administrators/SYSTEM-only permissions on -its secrets. See +ours is kernel-mode; the audio endpoints are instances of Valve's own vendor-signed streaming +drivers — **sealed internal channels** between the host and its drivers, and +Administrators/SYSTEM-only permissions on its secrets. See [Security & Safe Use](/docs/security) for the full picture, including why we recommend not hosting on your most sensitive machine. @@ -272,7 +275,7 @@ pipeline orchestration are all shared with the Linux host. The Windows host is a | **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) | | **Input — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble | | **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** | -| **Virtual mic** | PipeWire `Audio/Source` | **VB-CABLE** virtual device (optional), captured via WASAPI | +| **Virtual mic** | PipeWire `Audio/Source` | **"Punktfunk Microphone"** — the host's own instance of Steam's streaming-mic driver | The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 0a250ffb..fcffaf61 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -82,8 +82,9 @@ parse breakage that silently failed installs on non-English boxes. firewall rules), removes the `PunktfunkWeb` task + its firewall rule, then `driver uninstall` (+ `--gamepad`) removes the punktfunk virtual-device drivers — the pf-vdisplay device node(s) and the pf-vdisplay / pf-gamepad / pf-xusb driver-store packages (the field report was that they survived - uninstall). **VB-CABLE is intentionally NOT removed** (a third-party shared component the user may - use elsewhere — its own uninstaller is `VBCABLE_Setup_x64.exe -u -h`); the `%ProgramData%\punktfunk` + uninstall). **A VB-CABLE from an older punktfunk install is intentionally NOT removed** (a + third-party shared component the user may use elsewhere — its own uninstaller is + `VBCABLE_Setup_x64.exe -u -h`); the `%ProgramData%\punktfunk` config (incl. `web-password`) is also left in place. Silent install: `punktfunk-host-setup-.exe /VERYSILENT` (omit the driver with @@ -100,21 +101,16 @@ fresh install uses the generated random console password — read it from - **Virtual gamepads need no prerequisite.** The DualSense / DualShock 4 / Xbox 360 (XUSB) UMDF drivers are **bundled** in the installer (the *Install the virtual gamepad drivers* task) and `pnputil`-installed. **ViGEmBus is no longer used.** -- **The streaming microphone uses VB-CABLE**, bundled + silently installed by the installer (the *Install - VB-CABLE virtual audio* task). The host writes the client's mic into VB-CABLE's input; its `CABLE - Output` capture endpoint surfaces as a host mic. A Windows audio device can only be created by a - **kernel-mode** driver (no UMDF path exists), so unlike our self-signed UMDF drivers we cannot ship our - own — VB-CABLE is a vendor-signed cable that loads with no test-signing. It is **donationware** by - VB-Audio, redistributed under VB-Audio's bundling grant (only the single base cable) — the grant - requires the end user to see VB-CABLE's origin + donationware status, which the wizard task text and - `licenses/VB-CABLE-NOTICE.txt` surface. The package binary is **not** in the repo — CI provisions the - **pinned, SHA-256-verified official package** onto the runner (`scripts/ci/provision-windows-punktfunk-extras.ps1` - → `C:\Users\Public\vbcable`) and `windows-host.yml` passes it via `$env:VBCABLE_DIR`, so **published - installers always bundle it**; locally supply `-VbCableDir` / `$env:VBCABLE_DIR` (the extracted - official package, containing `VBCABLE_Setup_x64.exe`). Unset → the installer is built without it and - the host falls back to auto-installing the Steam Streaming pair; set-but-invalid → the pack **fails** - (a broken provisioning must not silently ship a mic-less installer again). *(Endgame: - attestation-sign our own MIT virtual-audio driver to drop this dependency.)* +- **Audio uses Steam's streaming drivers — nothing is bundled.** A Windows audio device can only + be created by a **kernel-mode** driver (no UMDF path exists), so unlike our self-signed UMDF + drivers we cannot ship our own. The host instead mints its OWN devnode instances of Valve's + vendor-signed streaming-audio drivers on the target box: **"Punktfunk Speakers"** (the + client-only desktop-audio sink, from `SteamStreamingSpeakers.inf`) and **"Punktfunk + Microphone"** (mic passthrough, from `SteamStreamingMicrophone.inf`). Audio therefore requires + **Steam installed — never running**; the installer shows a suppressible notice when Steam is + absent, and the host re-checks live, so installing Steam later just works. VB-CABLE was + bundled for the mic until the audio-substrate change (2026-08) — a cable from an older install + (or one the user installs) keeps working as a fallback mic target. ## Files here @@ -122,10 +118,9 @@ fresh install uses the generated random console password — read it from |------|------| | `punktfunk-host.iss` | Inno Setup script (the installer definition). | | `branding/` | Wizard branding: `gen-branding.ps1` renders the brand mark into the committed `wizard-image-*.bmp` / `wizard-small-*.bmp` (100–200% DPI) + `punktfunk.ico`. Re-run only on a brand change. | -| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + VB-CABLE + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. | +| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. | | `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. | | `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-gamepad` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. | -| `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). | | `make-driver-cert.ps1` | Generate the stable `CN=punktfunk-driver` code-signing cert (the `DRIVER_CERT_PFX_B64` / `DRIVER_CERT_PASSWORD` secrets). No key container, so it works over SSH; self-tests with signtool where it can. See *Driver signing* above. | | `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). | | `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. | diff --git a/packaging/windows/install-vbcable.ps1 b/packaging/windows/install-vbcable.ps1 deleted file mode 100644 index 662ad30c..00000000 --- a/packaging/windows/install-vbcable.ps1 +++ /dev/null @@ -1,97 +0,0 @@ -<# -.SYNOPSIS - Silently install the bundled VB-Audio Virtual Cable (the punktfunk virtual microphone) on the host. - -.DESCRIPTION - punktfunk pipes the streaming client's microphone into a virtual audio cable's render endpoint; the - cable's capture endpoint ("CABLE Output") then surfaces as a host microphone that games/apps record - from (see crates/punktfunk-host/src/audio/windows/wasapi_mic.rs). On a headless host there is no real - audio output, so a virtual cable is required. We bundle the OFFICIAL base VB-CABLE package (VB-Audio, - https://vb-cable.com) and install it unattended: - - 1. If a "CABLE Input"/"CABLE Output" endpoint already exists, do nothing (idempotent). - 2. Pre-seed VB-Audio's Authenticode signing certificate (read from the bundled signed driver) into - LocalMachine\TrustedPublisher, so the kernel-driver-publisher prompt is suppressed and the - install is fully silent (required for the SYSTEM/Session-0 service install). - 3. Run the official silent installer: VBCABLE_Setup_x64.exe -i -h (arm64: the same exe name in the - arm64 package; x86 falls back to VBCABLE_Setup.exe). - 4. Wait briefly for the audio subsystem to register the new endpoint. - - VB-CABLE is donationware by VB-Audio Software, redistributed here under VB-Audio's bundling grant - (https://vb-audio.com/Services/licensing.htm); see {app}\licenses\VB-CABLE-NOTICE.txt. Only the base - single cable is bundled (A+B / C+D are not redistributable). - - Best-effort: any failure is logged and returns a non-zero exit, but the caller (the installer) treats - it as non-fatal - the host still runs (mic passthrough then needs a manually-installed cable, and the - host falls back to auto-installing the Steam Streaming pair). - -.PARAMETER Dir - The staged VB-CABLE package directory (contains VBCABLE_Setup_x64.exe + the signed driver files). -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)][string]$Dir -) -$ErrorActionPreference = 'Stop' -$ProgressPreference = 'SilentlyContinue' - -function Test-CablePresent { - # An active render OR capture endpoint named "CABLE ..." means VB-CABLE is already installed. - $eps = Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue | - Where-Object { $_.Status -eq 'OK' -and $_.FriendlyName -match 'CABLE (Input|Output|In)' } - return [bool]$eps -} - -if (Test-CablePresent) { - Write-Host 'VB-CABLE already installed (CABLE endpoint present) - skipping.' - exit 0 -} - -if (-not (Test-Path -LiteralPath $Dir)) { throw "VB-CABLE package dir not found: $Dir" } - -# Pick the silent installer for this architecture. The x64 package ships both; arm64 ships an arm64 -# VBCABLE_Setup_x64.exe (VB-Audio's naming); fall back to the 32-bit setup if that's all that's staged. -$setup = $null -foreach ($name in @('VBCABLE_Setup_x64.exe', 'VBCABLE_Setup.exe')) { - $p = Join-Path $Dir $name - if (Test-Path -LiteralPath $p) { $setup = $p; break } -} -if (-not $setup) { throw "no VBCABLE_Setup*.exe under $Dir" } -Write-Host "VB-CABLE silent installer: $setup" - -# --- pre-seed VB-Audio's signing cert into LocalMachine\TrustedPublisher (unattended driver install) --- -# Read the Authenticode signer from a bundled signed file (prefer a driver .sys/.cat; fall back to the -# setup exe). Importing it into TrustedPublisher makes Windows install the signed driver with no prompt. -try { - $signed = Get-ChildItem -LiteralPath $Dir -Recurse -Include '*.sys', '*.cat', '*.exe' -ErrorAction SilentlyContinue | - ForEach-Object { Get-AuthenticodeSignature -LiteralPath $_.FullName -ErrorAction SilentlyContinue } | - Where-Object { $_.Status -eq 'Valid' -and $_.SignerCertificate } | - Select-Object -First 1 - if ($signed -and $signed.SignerCertificate) { - $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('TrustedPublisher', 'LocalMachine') - $store.Open('ReadWrite') - $store.Add($signed.SignerCertificate) - $store.Close() - Write-Host "seeded VB-Audio cert into LocalMachine\TrustedPublisher (subject=$($signed.SignerCertificate.Subject))" - } - else { - Write-Warning 'no valid Authenticode signer found in the VB-CABLE package - the driver-publisher prompt may appear (install may stall under SYSTEM)' - } -} -catch { - Write-Warning "could not pre-seed the VB-Audio cert: $($_.Exception.Message)" -} - -# --- run the official silent install: -i (install) -h (hidden) ----------------------------------- -# VB-Audio documents these switches; the process returns before the endpoint is fully registered. -$proc = Start-Process -FilePath $setup -ArgumentList '-i', '-h' -Wait -PassThru -WindowStyle Hidden -Write-Host "VBCABLE setup exit code: $($proc.ExitCode)" - -# Give the audio subsystem time to enumerate the new endpoint, then verify. -for ($i = 0; $i -lt 10; $i++) { - Start-Sleep -Seconds 1 - if (Test-CablePresent) { Write-Host 'VB-CABLE installed - CABLE endpoint present.'; exit 0 } -} -Write-Warning 'VB-CABLE setup ran but no CABLE endpoint appeared yet (a reboot may be required).' -# Non-fatal: the device often appears after the next session/reboot; the host retries mic open with backoff. -exit 0 diff --git a/packaging/windows/licenses/VB-CABLE-NOTICE.txt b/packaging/windows/licenses/VB-CABLE-NOTICE.txt deleted file mode 100644 index c3d31503..00000000 --- a/packaging/windows/licenses/VB-CABLE-NOTICE.txt +++ /dev/null @@ -1,26 +0,0 @@ -VB-CABLE Virtual Audio Device — Attribution -=========================================== - -The punktfunk host installer bundles and silently installs VB-CABLE, the virtual -audio cable used as the streaming virtual microphone (the client's mic is written -into VB-CABLE's input, and its "CABLE Output" capture endpoint surfaces as a host -microphone that games and apps record from). - - VB-CABLE is a product of VB-Audio Software. - Origin: https://vb-cable.com (https://vb-audio.com) - VB-CABLE is DONATIONWARE — all participations are welcome. - Please consider donating to VB-Audio if you find it useful: - https://vb-audio.com/Cable/ - -VB-CABLE is redistributed here, unmodified (the official base VB-CABLE package), -under VB-Audio's distribution grant for bundling the base cable with another -application; see VB-Audio's licensing terms: - https://vb-audio.com/Services/licensing.htm - -Only the single base VB-CABLE is bundled. VB-CABLE A+B and C+D are not -redistributed. VB-Audio retains all rights to VB-CABLE; punktfunk claims no -ownership of it. - -To remove VB-CABLE, use its own uninstaller (VBCABLE_Setup_x64.exe -u -h) or the -"VB-Audio Virtual Cable" entry in Windows "Apps & features"; uninstalling the -punktfunk host does not remove VB-CABLE. diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index 9770ddcb..711ecdf6 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -31,7 +31,6 @@ param( [string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console [string]$ScriptingBundle = $env:SCRIPTING_BUNDLE, # built runner-cli.js -> bundle the plugin/script runner [string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console + runner - [string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic [switch]$NoDriver, # build without the bundled pf-vdisplay driver [switch]$NoSign, # skip signing (local debug) # 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below. @@ -222,33 +221,10 @@ if (-not $NoDriver) { } # --- stage the official base VB-CABLE package (the streaming virtual microphone) -------------- -# VB-CABLE is the virtual audio cable the host writes the client's mic into (its capture endpoint then -# surfaces as a host microphone). We bundle + silently install the OFFICIAL base VB-CABLE package -# (VB-Audio donationware, redistributed under VB-Audio's bundling grant - see the VB-CABLE notice added -# to the licenses payload). The package binary is NOT in the repo (it's a signed third-party blob, -# shipped intact); supply it via -VbCableDir / $env:VBCABLE_DIR pointing at the extracted official -# package (must contain VBCABLE_Setup_x64.exe). Absent -> installer built WITHOUT the bundled cable; the -# host then auto-installs the Steam Streaming pair as a fallback and mic passthrough needs a manual cable. -if ($VbCableDir -and -not ((Test-Path $VbCableDir) -and (Get-ChildItem -Path $VbCableDir -Filter 'VBCABLE_Setup*.exe' -ErrorAction SilentlyContinue))) { - # An explicitly-supplied dir that doesn't hold the package is a broken provisioning, not an - # opt-out - fail loudly instead of silently shipping an installer without the virtual mic - # (exactly the field regression this bundling fixes). Opt out by leaving VBCABLE_DIR unset. - throw "VbCableDir '$VbCableDir' has no VBCABLE_Setup*.exe - re-run scripts/ci/provision-windows-punktfunk-extras.ps1 (or unset VBCABLE_DIR to build without the virtual mic)" -} -if ($VbCableDir) { - $vbStage = Join-Path $OutDir 'vbcable' - if (Test-Path $vbStage) { Remove-Item -Recurse -Force $vbStage } - New-Item -ItemType Directory -Force -Path $vbStage | Out-Null - Copy-Item (Join-Path $VbCableDir '*') $vbStage -Recurse -Force - # The on-target installer script (seeds VB-Audio's cert into TrustedPublisher, runs -i -h) ships - # alongside the package so it's extracted to the same {tmp}\vbcable dir. - Copy-Item (Join-Path $here 'install-vbcable.ps1') $vbStage -Force - $defines += "/DAudioCableStageDir=$vbStage" - # Attribution: VB-Audio's bundling grant requires we surface VB-CABLE's origin + donationware status. - Copy-Item (Join-Path $here 'licenses\VB-CABLE-NOTICE.txt') -Destination $licStage -Force - Write-Host "==> bundling VB-CABLE (virtual mic) from $VbCableDir -> $vbStage" -} -else { Write-Host "no -VbCableDir/`$env:VBCABLE_DIR -> installer built WITHOUT the bundled VB-CABLE virtual mic (CI always bundles it; see provision-windows-punktfunk-extras.ps1)" } +# VB-CABLE is no longer bundled (the audio-substrate program, 2026-08): the host mints its own +# audio endpoints from Steam's streaming drivers ("Punktfunk Speakers/Microphone"), so audio needs +# Steam installed on the target box - never running - and no third-party cable. A user-installed +# VB-CABLE keeps working as a fallback mic target. # --- stage the FFmpeg shared DLLs (AMD/Intel AMF/QSV build) ------------------------------------ # A host built with --features amf-qsv link-imports avcodec/avutil/swscale/... so the shared DLLs diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index e845eb72..624d5591 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -48,12 +48,10 @@ #ifdef GamepadStageDir #define WithGamepad #endif -; AudioCableStageDir (the official base VB-CABLE package + install-vbcable.ps1) is optional - present -; when the VB-CABLE package was supplied to the packer. It is the streaming virtual microphone; on a -; headless host (no real audio output) a virtual cable is required for mic + desktop-audio passthrough. -#ifdef AudioCableStageDir - #define WithAudioCable -#endif +; VB-CABLE is no longer bundled (retired 2026-08, the audio-substrate program): the host mints its +; own audio endpoints from Steam's streaming drivers - "Punktfunk Speakers" for desktop audio and +; "Punktfunk Microphone" for mic passthrough - so audio needs Steam INSTALLED (never running). A +; VB-CABLE the user installed themselves keeps working as a fallback mic target. ; FfmpegBin (a dir of FFmpeg shared DLLs) is optional - present when the host is built with ; --features amf-qsv (the AMD/Intel AMF/QSV encode backend link-imports the FFmpeg libs). #ifdef FfmpegBin @@ -144,12 +142,6 @@ Name: "installdriver"; Description: "Install the pf-vdisplay virtual display dri #ifdef WithGamepad Name: "installgamepad"; Description: "Install the virtual gamepad drivers (DualSense / DualShock 4 / Xbox 360 - no ViGEmBus needed)" #endif -#ifdef WithAudioCable -; VB-Audio's bundling grant requires the end user to see VB-CABLE's origin + donationware status -; at install time - keep the vendor, URL, and donationware wording in this visible task text (the -; full notice ships in {app}\licenses\VB-CABLE-NOTICE.txt). -Name: "installaudiocable"; Description: "Install VB-CABLE virtual audio for microphone passthrough (VB-CABLE by VB-Audio, www.vb-cable.com - donationware, all participations welcome)" -#endif #ifdef WithVkLayer Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan games like Doom use HDR on the virtual display)" #endif @@ -233,10 +225,6 @@ Source: "{#StageDir}\*"; DestDir: "{tmp}\pfvdisplay"; Flags: deleteafterinstall ; The built-from-source UMDF gamepad drivers + install-gamepad-drivers.ps1, extracted to {tmp}, removed after. Source: "{#GamepadStageDir}\*"; DestDir: "{tmp}\gamepad"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installgamepad #endif -#ifdef WithAudioCable -; The official base VB-CABLE package + install-vbcable.ps1, extracted to {tmp}, removed after install. -Source: "{#AudioCableStageDir}\*"; DestDir: "{tmp}\vbcable"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installaudiocable -#endif #ifdef WithVkLayer ; The HDR Vulkan implicit layer (cdylib + its JSON manifest) laid into {app}\vklayer and registered ; below. The manifest's library_path is ".\pf_vkhdr_layer.dll" (relative to the JSON), so the two @@ -293,15 +281,6 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "driver install --gamepad --di StatusMsg: "Installing the virtual gamepad drivers..."; \ Flags: runhidden waituntilterminated; Tasks: installgamepad #endif -#ifdef WithAudioCable -; Silently install the bundled VB-CABLE (the streaming virtual microphone). Best-effort: install-vbcable.ps1 -; always exits 0 (a missing cable just disables mic passthrough; the host falls back + retries), so a -; cable hiccup never fails the whole install. -Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\vbcable\install-vbcable.ps1"" -Dir ""{tmp}\vbcable"""; \ - StatusMsg: "Installing VB-CABLE virtual audio (microphone passthrough)..."; \ - Flags: runhidden waituntilterminated; Tasks: installaudiocable -#endif ; Register (or re-point, on upgrade - idempotent) the SYSTEM service from its FINAL {app} location: ; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}. ; --gamestream=on|off carries the wizard's GameStream task choice into host.env's PUNKTFUNK_HOST_CMD. @@ -359,8 +338,10 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: ru ; driver packages). AFTER service uninstall so the host no longer holds the devices. Unconditional ; (not #ifdef'd on this build's bundled payload - an upgrade may have dropped a payload the original ; install laid down); `driver uninstall` is best-effort and no-ops when nothing is installed. -; VB-CABLE is deliberately NOT removed: it is a third-party shared component the user may use -; elsewhere - see licenses\VB-CABLE-NOTICE.txt for its own uninstall. +; A VB-CABLE from an OLDER punktfunk install (bundled until the audio-substrate change) is +; deliberately NOT removed: it is a third-party shared component the user may use elsewhere. +; The host's own minted audio devnodes ("Punktfunk Speakers/Microphone") are likewise left in +; place - they are plain instances of Steam's streaming drivers, inert without the host. Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkVdisplayDriverUninstall" Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --gamepad"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkGamepadDriverUninstall" #ifdef WithWeb @@ -423,6 +404,18 @@ end; { Runs before any wizard page - the earliest point we can warn. Detect a conflicting host and let the user abort (default) or continue. Returning False cancels setup. } +{ Steam's streaming-audio driver INFs - the host mints its audio endpoints from them (audio + needs Steam INSTALLED, never running). Checked per-arch like the host's own resolver. } +function SteamAudioDriversPresent(): Boolean; +var + Base: String; +begin + Base := ExpandConstant('{commoncf32}\Steam\drivers\Windows10\'); + Result := FileExists(Base + 'x64\SteamStreamingMicrophone.inf') + or FileExists(Base + 'arm64\SteamStreamingMicrophone.inf') + or FileExists(Base + 'x86\SteamStreamingMicrophone.inf'); +end; + function InitializeSetup(): Boolean; var Found: String; @@ -430,6 +423,17 @@ begin Result := True; { Record the fresh-vs-upgrade verdict while host.env still reflects the PREVIOUS run. } FreshHostInstall := not FileExists(HostEnvPath); + { Informational, suppressible (silent installs proceed): without Steam's streaming drivers + the host has no audio substrate to mint from - it streams video only, and says so in its + own logs/status too. The runtime re-checks live, so installing Steam later just works. } + if not SteamAudioDriversPresent() then + SuppressibleMsgBox( + 'Steam does not appear to be installed on this PC.' + #13#10 + #13#10 + + 'Punktfunk uses Steam''s streaming audio drivers for game audio and microphone ' + + 'passthrough (Steam only needs to be installed - it never has to run). Without it, ' + + 'this host streams video only.' + #13#10 + #13#10 + + 'You can install Steam at any time; the host picks it up automatically.', + mbInformation, MB_OK, IDOK); Found := ''; if StreamHostEnabled('SunshineService') then Found := Found + ' - Sunshine' + #13#10; if StreamHostEnabled('ApolloService') then Found := Found + ' - Apollo' + #13#10; diff --git a/packaging/winget/README.md b/packaging/winget/README.md index 90901588..b4e97c9d 100644 --- a/packaging/winget/README.md +++ b/packaging/winget/README.md @@ -21,7 +21,7 @@ agreements and installation notes stay under normal code review. `packaging/windows/punktfunk-host.iss`** — if that GUID ever changes, change it here too or upgrades silently stop being detected. - **`interactive` is in `InstallModes`.** `winget install unom.PunktfunkHost --interactive` runs the - full existing wizard: every task checkbox, the web-console password page, the VB-CABLE notice. + full existing wizard: every task checkbox and the web-console password page. Nothing about the installer changes to support it. - **No `/MERGETASKS` in the silent switches.** A silent install deliberately takes the *same* task defaults the wizard shows, so the product does not differ by install channel — a per-channel @@ -41,7 +41,7 @@ Inno's `/MERGETASKS` takes `!` prefixes to deselect a default-checked task. Use winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream" ``` -Task names: `installdriver`, `installgamepad`, `installaudiocable`, `installhdrlayer`, +Task names: `installdriver`, `installgamepad`, `installhdrlayer`, `gamestream`, `allowpublicfw`, `startservice`, `trayicon`. ## Two installer behaviours that exist for this path diff --git a/packaging/winget/unom.PunktfunkHost.installer.yaml b/packaging/winget/unom.PunktfunkHost.installer.yaml index ad8e64b8..09fe0557 100644 --- a/packaging/winget/unom.PunktfunkHost.installer.yaml +++ b/packaging/winget/unom.PunktfunkHost.installer.yaml @@ -16,8 +16,8 @@ ElevationRequirement: elevatesSelf MinimumOSVersion: 10.0.22621.0 InstallModes: - # interactive keeps the FULL wizard — every task checkbox, the web-console password page, and the - # VB-CABLE notice text. `winget install unom.PunktfunkHost --interactive`. + # interactive keeps the FULL wizard — every task checkbox and the web-console password page. + # `winget install unom.PunktfunkHost --interactive`. - interactive - silent - silentWithProgress @@ -34,7 +34,7 @@ InstallerSwitches: # enabling it unattended is the additive form: # winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=gamestream" # and dropping a default-on task is the negated form, e.g. /MERGETASKS=!trayicon - # Task names: installdriver, installgamepad, installaudiocable, installhdrlayer, + # Task names: installdriver, installgamepad, installhdrlayer, # gamestream, allowpublicfw, startservice, trayicon Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART /SP- diff --git a/packaging/winget/unom.PunktfunkHost.locale.en-US.yaml b/packaging/winget/unom.PunktfunkHost.locale.en-US.yaml index 67d33b3a..0a23db50 100644 --- a/packaging/winget/unom.PunktfunkHost.locale.en-US.yaml +++ b/packaging/winget/unom.PunktfunkHost.locale.en-US.yaml @@ -36,16 +36,15 @@ Documentations: # Shown BEFORE download/install; the user must accept or the install does not proceed. This is what # carries — on the unattended path, where no wizard page is on screen — the disclosures the wizard -# puts in its task text. VB-Audio's bundling grant specifically requires the end user to see -# VB-CABLE's origin + donationware status at install time. +# surfaces interactively. Agreements: - - AgreementLabel: Bundled virtual audio (VB-CABLE by VB-Audio) + - AgreementLabel: Audio requires Steam installed on this PC Agreement: >- - Punktfunk's streaming microphone uses VB-CABLE by VB-Audio (www.vb-cable.com), which this - installer bundles and installs. VB-CABLE is donationware — all participations welcome. It is - redistributed under VB-Audio's bundling grant; the full notice is installed to - %ProgramFiles%\punktfunk\licenses\VB-CABLE-NOTICE.txt. - AgreementUrl: https://vb-audio.com/Cable/ + Punktfunk streams game audio and microphone passthrough through its own instances of + Steam's streaming audio drivers. Steam only needs to be installed — it never has to run. + Without Steam, this host streams video only; installing Steam later is picked up + automatically. + AgreementUrl: https://store.steampowered.com/about/ - AgreementLabel: GameStream (Moonlight) compatibility is OFF by default Agreement: >- Punktfunk's own clients work out of the box. Support for stock Moonlight clients is a separate, diff --git a/scripts/ci/gen-sbom.sh b/scripts/ci/gen-sbom.sh index 5f514348..03558cc0 100755 --- a/scripts/ci/gen-sbom.sh +++ b/scripts/ci/gen-sbom.sh @@ -6,8 +6,8 @@ # their Cargo.locks, the Bun/pnpm/npm trees, the Swift Package.resolved); # compliance/sbom/manual-components.cdx.json contributes the components no lockfile records — # vendored C/C++ trees (pyrowave/Granite/volk/Vulkan-Headers, libvpl), dynamically-linked/bundled -# libraries (FFmpeg, SDL3), the redistributed VB-CABLE driver, and the patched gamescope. Keep -# that file current when vendoring changes (scripts/vendor-pyrowave.sh etc.). +# libraries (FFmpeg, SDL3), and the patched gamescope. Keep that file current when vendoring +# changes (scripts/vendor-pyrowave.sh etc.). # # Usage: scripts/ci/gen-sbom.sh VERSION [OUTPUT] # Requires: syft (pinned install in the workflow), python3 (a proven runner dependency). diff --git a/scripts/ci/provision-windows-punktfunk-extras.ps1 b/scripts/ci/provision-windows-punktfunk-extras.ps1 index 4367316d..2ba2042c 100644 --- a/scripts/ci/provision-windows-punktfunk-extras.ps1 +++ b/scripts/ci/provision-windows-punktfunk-extras.ps1 @@ -95,26 +95,9 @@ if (-not (Test-Path $isccPath) -or ($innoVer -and [version]$innoVer -lt [version } else { Write-Warning "Inno Setup missing or pre-6.6 ($innoVer) and choco unavailable - install/upgrade it for windows-host.yml." } } -# --- VB-CABLE (the streaming virtual microphone the host installer bundles). Pinned official -# package, SHA-256 verified - a silent hash change means VB-Audio shipped a new pack: verify it, -# then update BOTH the pin here and the notice if terms changed (packaging/windows/licenses/ -# VB-CABLE-NOTICE.txt). Donationware by VB-Audio (https://vb-audio.com), redistributed under -# VB-Audio's bundling grant; only the base cable, never A+B/C+D. windows-host.yml points -# VBCABLE_DIR here so pack-host-installer.ps1 bundles it. --- -$vbDir = "C:\Users\Public\vbcable" -$vbUrl = "https://download.vb-audio.com/Download_CABLE/VBCABLE_Driver_Pack45.zip" -$vbSha = "B950E39F01AF1D04EA623C8F6D8EB9B6EA5C477C637295FABF20631C85116BFB" -if (-not (Test-Path (Join-Path $vbDir 'VBCABLE_Setup_x64.exe'))) { - info "fetching VB-CABLE (official base package, pinned)" - $vbZip = "$vbDir.zip" - Invoke-WebRequest -Uri $vbUrl -OutFile $vbZip -UseBasicParsing - $got = (Get-FileHash $vbZip -Algorithm SHA256).Hash - if ($got -ne $vbSha) { Remove-Item $vbZip -Force; throw "VB-CABLE download hash mismatch (got $got, pinned $vbSha) - vendor package changed; re-verify before re-pinning." } - if (Test-Path $vbDir) { Remove-Item -Recurse -Force $vbDir } - Expand-Archive -Path $vbZip -DestinationPath $vbDir -Force # flat zip (setup exes + signed drivers) - Remove-Item $vbZip -Force - info "VB-CABLE staged at $vbDir" -} else { info "VB-CABLE already present at $vbDir" } +# VB-CABLE provisioning removed (the audio-substrate program, 2026-08): the installer no longer +# bundles a cable - the host mints its audio endpoints from Steam's streaming drivers on the +# target box. A stale C:\Users\Public\vbcable on a runner is harmless and can be deleted. # --- Drop punktfunk's env vars into the generic runner's daemon wrapper extension point (see # unom/infra's scripts/setup-gitea-runner-base.ps1) so the act_runner daemon - and therefore every @@ -122,10 +105,9 @@ if (-not (Test-Path (Join-Path $vbDir 'VBCABLE_Setup_x64.exe'))) { $projectEnv = "C:\Users\Public\act-runner\project-env.ps1" @' $env:FFMPEG_DIR = "C:\Users\Public\ffmpeg" -$env:VBCABLE_DIR = "C:\Users\Public\vbcable" $env:PF_FFVK_VULKAN_INCLUDE = "C:\Users\Public\vulkan-headers\include" $env:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH '@ | Set-Content -Encoding UTF8 $projectEnv -info "wrote $projectEnv (FFMPEG_DIR, VBCABLE_DIR, PF_FFVK_VULKAN_INCLUDE) - restart the gitea-act-runner scheduled task to pick it up" +info "wrote $projectEnv (FFMPEG_DIR, PF_FFVK_VULKAN_INCLUDE) - restart the gitea-act-runner scheduled task to pick it up" info "punktfunk extras provisioned OK." -- 2.54.0 From 64655c52753819f182d55ee534a96c57470309ca Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 11:32:05 +0200 Subject: [PATCH 06/21] fix(host/devtest): audio-probe plan provisions synchronously A fresh CLI process has no startup worker to have finished, so the plan devtest raced its own background provisioning thread and printed the name ladder instead of tier-0. ensure_blocking() re-resolves existing marker devnodes in milliseconds before the wiring pass runs. --- .../src/audio/windows/audio_probe.rs | 4 +++- crates/punktfunk-host/src/audio/windows/minted.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_probe.rs b/crates/punktfunk-host/src/audio/windows/audio_probe.rs index d5bcf01a..191e9baf 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_probe.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -74,8 +74,10 @@ pub(crate) fn run(args: &[String]) -> Result<()> { // and publish them for THIS process — `plan` then shows the tier-0 pick. Some("mint") => super::minted::devtest_mint(), // One real wiring pass (no default parking) + the verdict, readiness included — the - // field-triage "what would the host do right now" command. + // field-triage "what would the host do right now" command. Provisioning runs + // synchronously first: a fresh CLI process would otherwise race its own worker. Some("plan") => { + super::minted::ensure_blocking(); let plan = super::audio_control::wire_now_full(false); let w = &plan.wiring; let show = |ep: &Option| match ep { diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index bfbdfdd4..c8fb5523 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -353,6 +353,20 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S } /// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed. +/// Synchronous provisioning for devtests: a fresh CLI process has no startup worker to have +/// finished yet, so `audio-probe plan` would otherwise RACE the background thread and print +/// the name ladder instead of tier-0. Existing marker devnodes re-resolve in milliseconds. +pub(crate) fn ensure_blocking() { + if PROVISIONED.get().is_some() { + return; + } + if let Ok(m) = ensure_all() { + if m.any() { + let _ = PROVISIONED.set(Arc::new(m)); + } + } +} + pub(crate) fn devtest_mint() -> Result<()> { let m = ensure_all()?; println!( -- 2.54.0 From 79c72fa64d6d2a9c6e4a5cbf743c6fd8f89a3165 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 11:37:39 +0200 Subject: [PATCH 07/21] =?UTF-8?q?fix(host/audio):=20capture=20endpoints=20?= =?UTF-8?q?carry=20the=20{0.0.1.=E2=80=A6}=20id=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture-direction lookup built its endpoint id with the RENDER prefix {0.0.0.00000000}., but WASAPI's enumeration returns capture ids as {0.0.1.00000000}.{guid} — so the minted microphone's capture side never string-matched the enumeration and the wiring plan paired no recording device (audio-probe plan on the target box: mic_capture = '-'). Measured; IMMDeviceEnumerator::GetDevice tolerated the wrong prefix, which is why the S3 spike's direct open still passed. --- .../src/audio/windows/pad_endpoint.rs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index 0625ba66..c061d56c 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -97,6 +97,10 @@ const MMDEV_CAPTURE_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Capture"; /// WASAPI endpoint-id prefix for render endpoints (`{0.0.0.00000000}.{guid}`). const ENDPOINT_ID_PREFIX: &str = "{0.0.0.00000000}."; +/// …and for CAPTURE endpoints, whose ids carry `{0.0.1.…}` (measured: the enumeration returns +/// this form, and an id built with the render prefix never string-matches it — the minted +/// mic's capture side resolved to nothing until this was split). +const CAPTURE_ENDPOINT_ID_PREFIX: &str = "{0.0.1.00000000}."; /// How long [`ensure`] waits for the new render endpoint to materialise after driver install. const ENDPOINT_WAIT: Duration = Duration::from_secs(10); /// How many times [`ensure`] re-stamps before giving up and asking for an AudioEndpointBuilder @@ -837,17 +841,21 @@ fn install_sss_driver() -> Result<()> { /// The render endpoint owned by `instance_id`, identified through the endpoint store's devnode /// link (`"{1}."` under `…\MMDevices\Audio\Render\{ep}\Properties`). pub(crate) fn find_endpoint_for_devnode(instance_id: &str) -> Result> { - endpoint_for_devnode_in(MMDEV_RENDER_PATH, instance_id) + endpoint_for_devnode_in(MMDEV_RENDER_PATH, ENDPOINT_ID_PREFIX, instance_id) } /// The CAPTURE endpoint owned by `instance_id` — the microphone half of a paired device like -/// the Steam Streaming Microphone. Pad devices are render-only; the `audio-probe` devtest's -/// S3 measurement is what needs this direction. +/// the Steam Streaming Microphone. Pad devices are render-only; the minted-audio provider and +/// the `audio-probe` devtest need this direction. pub(crate) fn find_capture_endpoint_for_devnode(instance_id: &str) -> Result> { - endpoint_for_devnode_in(MMDEV_CAPTURE_PATH, instance_id) + endpoint_for_devnode_in(MMDEV_CAPTURE_PATH, CAPTURE_ENDPOINT_ID_PREFIX, instance_id) } -fn endpoint_for_devnode_in(reg_path: &str, instance_id: &str) -> Result> { +fn endpoint_for_devnode_in( + reg_path: &str, + id_prefix: &str, + instance_id: &str, +) -> Result> { use winreg::enums::HKEY_LOCAL_MACHINE; use winreg::RegKey; let want = format!("{{1}}.{instance_id}"); @@ -862,7 +870,7 @@ fn endpoint_for_devnode_in(reg_path: &str, instance_id: &str) -> Result Date: Fri, 7 Aug 2026 11:53:15 +0200 Subject: [PATCH 08/21] feat(host/mgmt): the audio wiring verdict joins /status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimeStatus gains an 'audio' object (Windows hosts): readiness (full/audio_only/mic_only/none), the friendly names carrying each role, and the three degradation flags (mic_withheld, last_resort, narrowing) — the verdicts that previously lived only in tracing logs. Snapshot of the last wiring pass (the mic pump wires at host start and on every reopen); a status poll never triggers COM work or IPolicyConfig writes. --- crates/punktfunk-host/src/audio.rs | 12 +++++ .../src/audio/windows/audio_control.rs | 14 +++++- crates/punktfunk-host/src/mgmt/host.rs | 50 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index c02ef5e0..2f3af04d 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -217,3 +217,15 @@ pub(crate) mod capture_policy; mod mic_jitter; mod mic_pump; pub use mic_pump::{MicFrame, MicPump}; + +/// The most recent audio wiring verdict — the LAST wiring pass's assignment on a Windows host, +/// `None` elsewhere or before the first pass. A read-only snapshot for the status API; never +/// triggers a pass. +#[cfg(target_os = "windows")] +pub(crate) fn wiring_snapshot() -> Option { + audio_control::last_wiring() +} +#[cfg(not(target_os = "windows"))] +pub(crate) fn wiring_snapshot() -> Option { + None +} diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index b40d5e93..0f7f6e97 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -149,6 +149,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring { wire_now_full(set_playback).wiring } +/// The most recent wiring verdict, as the LAST wiring pass computed it (the mic pump wires +/// eagerly at host start and on every reopen, so this is fresh in the steady state). Change +/// detection for the once-per-change log lives on the same cell. +static LAST_WIRING: Mutex> = Mutex::new(None); + +/// Read-only snapshot of [`LAST_WIRING`] for the status API — never triggers a wiring pass +/// (a pass does COM work and IPolicyConfig writes; a status poll must do neither). +pub(crate) fn last_wiring() -> Option { + LAST_WIRING.lock().unwrap().clone() +} + /// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion /// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container / /// devnode marker, registry-only reads); this is just the per-pass collection. @@ -217,9 +228,8 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { }; // Log assignment changes exactly once (first plan included). - static LAST: Mutex> = Mutex::new(None); let changed = { - let mut last = LAST.lock().unwrap(); + let mut last = LAST_WIRING.lock().unwrap(); let changed = last.as_ref() != Some(&wiring); *last = Some(wiring.clone()); changed diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs index 5bd49ebf..f72f4b7b 100644 --- a/crates/punktfunk-host/src/mgmt/host.rs +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -129,6 +129,55 @@ pub(crate) struct RuntimeStatus { /// any game whose session has ended and which is waiting out its reconnect window before being /// ended (`state: "grace"`). Empty when nothing was launched — a plain desktop stream has no game. games: Vec, + /// The audio wiring verdict (Windows hosts; absent on other platforms and before the first + /// wiring pass). Present even while idle — the wiring exists for the host's lifetime. + #[serde(skip_serializing_if = "Option::is_none")] + audio: Option, +} + +/// The Windows host's audio wiring verdict — which endpoint carries each role. The names are +/// the endpoints' friendly names as the Sound settings show them (on current hosts the minted +/// "Punktfunk" instances of Steam's streaming drivers). +#[derive(Serialize, ToSchema)] +pub(crate) struct AudioWiring { + /// `full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough + /// each have an endpoint at all. + #[schema(example = "full")] + readiness: String, + /// Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable. + #[serde(skip_serializing_if = "Option::is_none")] + loopback: Option, + /// Friendly name of the virtual-mic write target; absent = mic passthrough unavailable. + #[serde(skip_serializing_if = "Option::is_none")] + mic: Option, + /// The mic was WITHHELD so game audio could keep the only working sink — mic passthrough + /// needs Steam installed (the host mints its own microphone) or a virtual cable. + mic_withheld: bool, + /// The loopback is the known-degraded last resort — desktop audio may be silent until the + /// endpoint set changes. + last_resort: bool, + /// Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does. + #[serde(skip_serializing_if = "Option::is_none")] + narrowing: Option, +} + +/// The wiring snapshot mapped for the API — `None` off-Windows or before the first pass. +fn audio_wiring() -> Option { + use crate::audio::wiring_plan as wp; + crate::audio::wiring_snapshot().map(|w| AudioWiring { + readiness: match wp::readiness(&w) { + wp::AudioReadiness::Full => "full", + wp::AudioReadiness::AudioOnly => "audio_only", + wp::AudioReadiness::MicOnly => "mic_only", + wp::AudioReadiness::Nothing => "none", + } + .into(), + loopback: w.loopback_render.map(|(n, _)| n), + mic: w.mic_render.map(|(n, _)| n), + mic_withheld: w.mic_withheld, + last_resort: w.loopback_last_resort, + narrowing: w.loopback_narrowing, + }) } /// One launched game, for the console's running-game card. @@ -461,6 +510,7 @@ pub(crate) async fn get_status(State(st): State>) -> Json Date: Fri, 7 Aug 2026 12:00:13 +0200 Subject: [PATCH 09/21] feat(web): the Dashboard shows the audio wiring verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Audio wiring card (Windows hosts) below the status tiles: a readiness badge (Ready / No microphone / No game audio / Not wired), the friendly names carrying each role, and the degradation notes that were previously visible only in the host log — mic withheld for game audio, the known- degraded last resort, a narrowing endpoint. api/openapi.json regenerated from the host build (AudioWiring + RuntimeStatus.audio); en+de messages. --- api/openapi.json | 56 ++++++++++++++++++++++++++++ web/messages/de.json | 10 +++++ web/messages/en.json | 10 +++++ web/src/sections/Dashboard/view.tsx | 58 +++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+) diff --git a/api/openapi.json b/api/openapi.json index b62c484c..d463c6cb 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4045,6 +4045,51 @@ } } }, + "AudioWiring": { + "type": "object", + "description": "The Windows host's audio wiring verdict — which endpoint carries each role. The names are\nthe endpoints' friendly names as the Sound settings show them (on current hosts the minted\n\"Punktfunk\" instances of Steam's streaming drivers).", + "required": [ + "readiness", + "mic_withheld", + "last_resort" + ], + "properties": { + "last_resort": { + "type": "boolean", + "description": "The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes." + }, + "loopback": { + "type": [ + "string", + "null" + ], + "description": "Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable." + }, + "mic": { + "type": [ + "string", + "null" + ], + "description": "Friendly name of the virtual-mic write target; absent = mic passthrough unavailable." + }, + "mic_withheld": { + "type": "boolean", + "description": "The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable." + }, + "narrowing": { + "type": [ + "string", + "null" + ], + "description": "Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does." + }, + "readiness": { + "type": "string", + "description": "`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all.", + "example": "full" + } + } + }, "AvailableCompositor": { "type": "object", "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.", @@ -6805,6 +6850,17 @@ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "minimum": 0 }, + "audio": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/AudioWiring", + "description": "The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime." + } + ] + }, "audio_streaming": { "type": "boolean", "description": "True while the audio stream thread is running." diff --git a/web/messages/de.json b/web/messages/de.json index 6e29a9e8..56f2cb49 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -73,6 +73,16 @@ "status_paired_count": "Gekoppelte Geräte", "status_pin_waiting": "Wartet", "status_pin_none": "Keine", + "audio_wiring_title": "Audio-Verkabelung", + "audio_output": "Spielaudio", + "audio_microphone": "Mikrofon", + "audio_unavailable": "Nicht verfügbar", + "audio_ready": "Bereit", + "audio_ready_no_mic": "Kein Mikrofon", + "audio_no_output": "Kein Spielaudio", + "audio_none": "Nicht verkabelt", + "audio_mic_withheld": "Der Mikrofon-Endpunkt überträgt gerade das Spielaudio — installiere Steam, damit der Host sein eigenes Mikrofon anlegen kann (Steam muss nie laufen).", + "audio_last_resort": "Spielaudio läuft über einen eingeschränkten Ersatz-Endpunkt und kann stumm bleiben, bis ein Ausgabegerät erscheint.", "status_pin_pending": "Kopplungs-PIN ausstehend", "stream_codec": "Codec", "stream_resolution": "Auflösung", diff --git a/web/messages/en.json b/web/messages/en.json index 3e5f70f2..da6286ae 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -73,6 +73,16 @@ "status_paired_count": "Paired clients", "status_pin_waiting": "Waiting", "status_pin_none": "None", + "audio_wiring_title": "Audio wiring", + "audio_output": "Game audio", + "audio_microphone": "Microphone", + "audio_unavailable": "Unavailable", + "audio_ready": "Ready", + "audio_ready_no_mic": "No microphone", + "audio_no_output": "No game audio", + "audio_none": "Not wired", + "audio_mic_withheld": "The microphone endpoint is carrying game audio — install Steam so the host can mint its own microphone (Steam never has to run).", + "audio_last_resort": "Game audio is on a degraded fallback endpoint and may be silent until an output device appears.", "status_pin_pending": "Pairing PIN pending", "stream_codec": "Codec", "stream_resolution": "Resolution", diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx index 81d3dd9b..792b3994 100644 --- a/web/src/sections/Dashboard/view.tsx +++ b/web/src/sections/Dashboard/view.tsx @@ -2,6 +2,7 @@ import Section from "@unom/ui/section"; import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react"; import type { FC, ReactNode } from "react"; import type { ActiveGame } from "@/api/gen/model/activeGame"; +import type { AudioWiring } from "@/api/gen/model/audioWiring"; import type { GameEntry } from "@/api/gen/model/gameEntry"; import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus"; import { QueryState } from "@/components/query-state"; @@ -87,6 +88,12 @@ export const DashboardView: FC<{ + {/* The wiring verdict (Windows hosts): WHICH endpoints carry game + audio and the microphone, and the degradations that used to be + visible only in the host log — a silent host looks identical to a + quiet game without this. */} + {s.audio && } + {/* Above the session card: a game the host is about to close is the most time-sensitive thing on this page. */} = ({ audio }) => { + const badge: { variant: "success" | "secondary" | "destructive"; text: string } = + audio.readiness === "full" + ? { variant: "success", text: m.audio_ready() } + : audio.readiness === "audio_only" + ? { variant: "secondary", text: m.audio_ready_no_mic() } + : audio.readiness === "mic_only" + ? { variant: "destructive", text: m.audio_no_output() } + : { variant: "destructive", text: m.audio_none() }; + const notes = [ + audio.mic_withheld ? m.audio_mic_withheld() : undefined, + audio.last_resort ? m.audio_last_resort() : undefined, + audio.narrowing, + ].filter((n): n is string => !!n); + return ( + + + + + {m.audio_wiring_title()} + + {badge.text} + + +
+ + +
+ {notes.length > 0 && ( +
    + {notes.map((n) => ( +
  • {n}
  • + ))} +
+ )} +
+
+ ); +}; + const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({ icon, label, -- 2.54.0 From 99a59fb5c799b941d8087a6d666f80917d502c90 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 12:02:48 +0200 Subject: [PATCH 10/21] fix(host/audio): the mic pump's first resolve waits for the minted endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the target box: the pump wired 2 s before the provisioning worker latched, took the cable as its write target, and the next wiring pass would then have paired the default recording with the minted microphone — which nothing writes into: dead mic-air until a pump reopen. resolve_target now provisions synchronously (instant once latched; the opt-out env is honoured), so the pump's held device and the plan's verdict can never disagree. --- crates/punktfunk-host/src/audio/windows/minted.rs | 14 ++++++++++---- .../punktfunk-host/src/audio/windows/wasapi_mic.rs | 5 +++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index c8fb5523..a34e6c9a 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -353,11 +353,17 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S } /// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed. -/// Synchronous provisioning for devtests: a fresh CLI process has no startup worker to have -/// finished yet, so `audio-probe plan` would otherwise RACE the background thread and print -/// the name ladder instead of tier-0. Existing marker devnodes re-resolve in milliseconds. +/// Synchronous provisioning — for the mic pump's resolve and the devtests. +/// +/// The pump's FIRST open must not race the startup worker: measured on the target box, the +/// pump wired 2 s before the worker latched, took the cable as its write target, and the next +/// wiring pass would then have pointed the default recording at the minted microphone — +/// which nothing writes into: dead mic-air until a pump reopen. Blocking the first resolve +/// (existing marker devnodes re-resolve in milliseconds; a cold boot pays the one-time mint) +/// keeps the pump's target and the plan's verdict the same thing. Latched calls return +/// immediately; the opt-out env is honoured like everywhere else. pub(crate) fn ensure_blocking() { - if PROVISIONED.get().is_some() { + if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || PROVISIONED.get().is_some() { return; } if let Ok(m) = ensure_all() { diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index b27b9590..3339c27b 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -217,6 +217,11 @@ impl VirtualMic for WasapiVirtualMic { /// Resolve the mic inject target from the wiring plan, auto-installing the Steam Streaming pair /// when nothing usable exists (then re-planning). Runs on the COM-initialized render thread. fn resolve_target() -> Result<(wasapi::Device, String)> { + // The minted endpoints must exist BEFORE this open resolves its write target: the pump + // holds one device for its lifetime, so racing the provisioning worker here left the pump + // 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. let mut wiring = audio_control::wire_now(false); -- 2.54.0 From 507ea58da82c28c35390a56d8b969e51b70d9e04 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 12:59:15 +0200 Subject: [PATCH 11/21] fix(host/audio): the silent-sink check recognizes the minted Speakers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed in the first real session on the substrate: the loopback ran on the minted 'Punktfunk Speakers' (silent on the host by construction), but have_silent name-matches only the Streaming Microphone — so the capture open logged 'desktop audio will also play on the host' (false) and re-attempted the Steam-pair install it doesn't need. The minted sink is recognized by id; its name honestly says Speakers, which the name rule must keep refusing for FOREIGN instances. --- .../punktfunk-host/src/audio/windows/wasapi_cap.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index bad81aff..3e29b0c9 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -372,10 +372,16 @@ fn capture_once( // 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() { + // "Silent on the host" is true for the name-matched Streaming Microphone AND for the + // minted "Punktfunk Speakers" (identified by id — its NAME says Speakers, which the + // name rule rightly refuses). Without the id check, a session on the minted sink + // logged "desktop audio will also play on the host" (false) and re-attempted the + // Steam-pair install it doesn't need (observed live, first substrate session). let have_silent = |w: &wiring_plan::Wiring| { - w.loopback_render - .as_ref() - .is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase())) + w.loopback_render.as_ref().is_some_and(|(n, id)| { + wiring_plan::silent_sink(&n.to_lowercase()) + || super::minted::minted_ids().speakers_render.as_deref() == Some(id.as_str()) + }) }; static TRIED_WITH_INFS: Mutex> = Mutex::new(None); let should_try = !have_silent(&plan.wiring) && { -- 2.54.0 From 79933869005ceed97d90029d83140138c8a9801e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:33:52 +0200 Subject: [PATCH 12/21] =?UTF-8?q?feat(host/audio):=20the=20minted=20endpoi?= =?UTF-8?q?nts=20get=20their=20names=20=E2=80=94=20'Punktfunk=20Speakers/M?= =?UTF-8?q?icrophone'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-measured necessity, not cosmetics: unstamped, the minted instances read 'Lautsprecher (2- Steam Streaming Microphone)' and even the box's owner picked the wrong device out of the Sound settings zoo (as did the S1 probe's name match before it). The provider now stamps device-desc + device-name through the pad program's proven machinery — write_stamps/ stamps_served, extracted from the pad-only stamp functions — with the same store-first/registry-fallback routes and settle/re-pass discipline. Names only: a wider stamp set makes AudioEndpointBuilder re-mint the endpoint under a new GUID (measured on pads). Stamping is best-effort (SYSTEM ACL route); the wiring never depends on names — identity stays the recorded id. --- .../src/audio/windows/minted.rs | 64 +++++++++++++++++++ .../src/audio/windows/pad_endpoint.rs | 40 ++++++++---- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index a34e6c9a..b3145820 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -230,6 +230,17 @@ fn ensure_role(role: Role) -> Result<(String, String, Option)> { Role::Speakers => None, }; + // Stamp the human name onto every endpoint of the role. Field-measured necessity, not + // cosmetics: unstamped, the minted instances read "Lautsprecher (2- Steam Streaming + // Microphone)" etc. and even the box's owner picked the wrong device out of the Sound + // settings zoo. Names only (a wider stamp set makes AudioEndpointBuilder re-mint the + // endpoint under a new GUID — the pad program measured that); stamping needs the SYSTEM + // ACL route on the MMDevices keys, so a dev-run devtest may leave the names unstamped — + // the wiring never depends on them (identity is the recorded id). + for ep in [Some(&render), capture.as_ref()].into_iter().flatten() { + stamp_name(ep, role); + } + // Freshly registered endpoints can grab a default; the wiring plan owns default policy, // not the mint. if let Some(prev) = prev_render { @@ -255,6 +266,59 @@ fn ensure_role(role: Role) -> Result<(String, String, Option)> { Ok((devnode, render, capture)) } +/// How many stamp/settle passes a name gets before we accept "stored but not yet served" +/// (a settled endpoint takes the stamp on the first pass; a freshly minted one may need the +/// audio stack to notice — it serves after the next Audiosrv restart/reboot at the latest). +const STAMP_ATTEMPTS: usize = 3; +/// Settle time between a stamp write and its served-check (mirrors the pad provisioner: +/// checking immediately reports success on passes that later get reverted). +const STAMP_SETTLE: Duration = Duration::from_millis(1200); + +/// Best-effort: write the role's display name onto one endpoint and wait for the audio stack +/// to SERVE it. Never fails the role — an unnamed endpoint still wires correctly by id. +fn stamp_name(endpoint_id: &str, role: Role) { + let stamps = [ + pe::Stamp { + label: "device-desc", + key: pe::PKEY_DEVICE_DESC, + value: pe::StampValue::Str(role.desc()), + }, + pe::Stamp { + label: "device-name", + key: pe::PKEY_ENDPOINT_DEVICE_NAME, + value: pe::StampValue::Str("Punktfunk"), + }, + ]; + // Steady state (every boot after the first): the names are already served — no writes, + // no settle sleeps. + if pe::stamps_served(endpoint_id, &stamps) { + return; + } + for attempt in 0..STAMP_ATTEMPTS { + if let Err(e) = pe::write_stamps(endpoint_id, &stamps) { + tracing::info!(role = role.label(), endpoint = %endpoint_id, + error = %format!("{e:#}"), + "could not stamp the minted endpoint's name (needs the SYSTEM ACL route) — \ + the endpoint still wires correctly, it just keeps the driver's default name"); + return; + } + thread::sleep(STAMP_SETTLE); + if pe::stamps_served(endpoint_id, &stamps) { + if attempt > 0 { + tracing::debug!( + role = role.label(), + attempt = attempt + 1, + "minted endpoint name held after a re-pass" + ); + } + return; + } + } + tracing::info!(role = role.label(), endpoint = %endpoint_id, + "minted endpoint name is stored but not yet served — it appears after the next \ + audio-stack restart or reboot"); +} + /// Poll audiosrv for the endpoint a minted devnode registers in one direction. fn wait_for(devnode: &str, capture: bool) -> Result { let deadline = Instant::now() + ENDPOINT_WAIT; diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index c061d56c..f285c17b 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -132,13 +132,15 @@ pub struct PadEndpoint { // --- the stamp set ------------------------------------------------------------------------- /// One endpoint property to stamp: the property-store key, the value, a short log label. -struct Stamp { - label: &'static str, - key: PROPERTYKEY, - value: StampValue, +/// pub(crate): the minted-audio provider stamps its endpoint names through the same machinery +/// (store-first, registry fallback, served-check) — see [`write_stamps`]. +pub(crate) struct Stamp { + pub(crate) label: &'static str, + pub(crate) key: PROPERTYKEY, + pub(crate) value: StampValue, } -enum StampValue { +pub(crate) enum StampValue { Str(&'static str), /// The PFDS container (VT_CLSID / serialized-CLSID registry blob). Container(GUID), @@ -154,9 +156,10 @@ const fn pkey(fmtid: u128, pid: u32) -> PROPERTYKEY { } /// `PKEY_Device_DeviceDesc` — the "description" half of the endpoint display name. -const PKEY_DEVICE_DESC: PROPERTYKEY = pkey(0xa45c254e_df1c_4efd_8020_67d146a850e0, 2); +pub(crate) const PKEY_DEVICE_DESC: PROPERTYKEY = pkey(0xa45c254e_df1c_4efd_8020_67d146a850e0, 2); /// Endpoint-store "device name" half of the display name. -const PKEY_ENDPOINT_DEVICE_NAME: PROPERTYKEY = pkey(0xb3f8fa53_0004_438e_9003_51a46e139bfc, 6); +pub(crate) const PKEY_ENDPOINT_DEVICE_NAME: PROPERTYKEY = + pkey(0xb3f8fa53_0004_438e_9003_51a46e139bfc, 6); /// Endpoint-store devnode link: `"{1}."` — how an endpoint is tied back to /// the devnode that owns it. const PKEY_ENDPOINT_DEVNODE: PROPERTYKEY = pkey(0xb3f8fa53_0004_438e_9003_51a46e139bfc, 2); @@ -991,7 +994,14 @@ fn set_store_value(store: &IPropertyStore, s: &Stamp) -> Result<()> { /// restart), raw registry for whatever it rejects. Idempotent — already-served keys are /// skipped entirely. fn stamp_endpoint(endpoint_id: &str, pad_index: u8) -> Result<()> { - let stamps = active_stamps(pad_index); + write_stamps(endpoint_id, &active_stamps(pad_index)) +} + +/// The generic stamp writer behind [`stamp_endpoint`], shared with the minted-audio provider +/// (which stamps "Punktfunk Speakers/Microphone" names — field-measured necessity: without +/// them even the box's owner could not tell the minted instances from Steam's primaries in +/// the Sound settings zoo). +pub(crate) fn write_stamps(endpoint_id: &str, stamps: &[Stamp]) -> Result<()> { let dev = open_mmdevice(endpoint_id)?; let pending: Vec<&Stamp> = { // SAFETY: read-only property store on a COM-initialized thread. @@ -1000,7 +1010,7 @@ fn stamp_endpoint(endpoint_id: &str, pad_index: u8) -> Result<()> { stamps.iter().filter(|s| !stamp_served(&store, s)).collect() }; if pending.is_empty() { - tracing::debug!(endpoint = %endpoint_id, pad = pad_index, "pad endpoint already fully stamped"); + tracing::debug!(endpoint = %endpoint_id, "endpoint already fully stamped"); return Ok(()); } let mut via_store: Vec<&'static str> = Vec::new(); @@ -1041,10 +1051,9 @@ fn stamp_endpoint(endpoint_id: &str, pad_index: u8) -> Result<()> { } tracing::info!( endpoint = %endpoint_id, - pad = pad_index, property_store = ?via_store, registry = ?via_registry.iter().map(|s| s.label).collect::>(), - "pad endpoint stamped (route per key)" + "endpoint stamped (route per key)" ); Ok(()) } @@ -1197,6 +1206,11 @@ fn registry_stamp(endpoint_id: &str, stamps: &[&Stamp]) -> Result<()> { /// i.e. the audio stack SERVES the identity rather than merely storing it. Any error counts as /// "not served" (the only consumer is the needs-AEB-kick decision). fn all_served(endpoint_id: &str, pad_index: u8) -> bool { + stamps_served(endpoint_id, &active_stamps(pad_index)) +} + +/// [`all_served`]'s generic body — shared with the minted-audio provider. +pub(crate) fn stamps_served(endpoint_id: &str, stamps: &[Stamp]) -> bool { let Ok(dev) = open_mmdevice(endpoint_id) else { return false; }; @@ -1204,9 +1218,7 @@ fn all_served(endpoint_id: &str, pad_index: u8) -> bool { let Ok(store) = (unsafe { dev.OpenPropertyStore(STGM_READ) }) else { return false; }; - active_stamps(pad_index) - .iter() - .all(|s| stamp_served(&store, s)) + stamps.iter().all(|s| stamp_served(&store, s)) } // --- public provisioning API ---------------------------------------------------------------- -- 2.54.0 From 0cf76af0c2a4d47afc598bed9367970743d8c8e3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:52:39 +0200 Subject: [PATCH 13/21] feat(host/devtest): audio-probe measures pitch, not just peaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report through the minted microphone: voice plays back an octave low. Peaks are pitch-blind — S3 passed while a potential half-rate link hid in the numbers (288k samples fits both the honest and the half-speed story). Every probe measurement now estimates the dominant frequency by zero crossings over the signal span, and `audio-probe micpitch` runs the decisive experiment against the LIVE minted pair: 440 Hz in, frequency out — ~440 = pair innocent, ~220 = the stereo render stream is forwarded raw into the mono capture. --- .../src/audio/windows/audio_probe.rs | 91 +++++++++++++++---- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_probe.rs b/crates/punktfunk-host/src/audio/windows/audio_probe.rs index 191e9baf..680e282a 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_probe.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -99,8 +99,37 @@ pub(crate) fn run(args: &[String]) -> Result<()> { ); Ok(()) } + // The pitch instrument for the LIVE minted mic pair (field report: voice through the + // minted microphone played back "way lower"): a 440 Hz tone into the minted mic's + // render side, frequency-measured off its capture side. ~440 Hz = the pair is honest; + // ~220 Hz = a link runs at half the declared rate (the octave-down voice). + Some("micpitch") => { + super::minted::ensure_blocking(); + let ids = super::minted::minted_ids(); + let (Some(render), Some(capture)) = (ids.mic_render, ids.mic_capture) else { + bail!("no minted microphone pair on this box — run `audio-probe mint` first"); + }; + println!("audio-probe micpitch: render={render}"); + println!("audio-probe micpitch: capture={capture}"); + let (peak, hz) = tone_while(&Some(render), 6, 440.0, || record_peak(&capture, 4))??; + println!("audio-probe micpitch: peak={peak:.4}, 440 Hz read back as {hz:.0} Hz"); + if peak < SIGNAL_FLOOR { + println!(" VERDICT: no signal crossed the pair — is the mic pump holding it?"); + } else if (hz - 440.0).abs() < 40.0 { + println!(" VERDICT: pitch-true — the minted pair is innocent; the shift lives elsewhere."); + } else if (hz - 220.0).abs() < 30.0 { + println!( + " VERDICT: OCTAVE DOWN — the driver forwards the stereo render stream \ + into the mono capture raw; the render side must run MONO." + ); + } else { + println!(" VERDICT: off-pitch by an unusual ratio — measure again / check rates."); + } + Ok(()) + } _ => bail!( - "usage: punktfunk-host audio-probe [--keep]" + "usage: punktfunk-host audio-probe \ + [--keep]" ), } } @@ -140,10 +169,12 @@ fn probe_ssm(keep: bool) -> Result<()> { // E2E: tone into the instance's render side, recorded from its capture side. Concurrent — // the driver only moves audio while both ends are open. - let peak = tone_while(&Some(render_ep.clone()), 5, 440.0, || { + let (peak, hz) = tone_while(&Some(render_ep.clone()), 5, 440.0, || { record_peak(&capture_ep, 3) })??; - println!("audio-probe ssm: capture peak over 3s = {peak:.4}"); + println!( + "audio-probe ssm: capture peak over 3s = {peak:.4}, tone 440 Hz read back as {hz:.0} Hz" + ); if peak > SIGNAL_FLOOR { println!( " VERDICT: PASS (S3) — the minted Steam Streaming Microphone instance carries \ @@ -186,8 +217,10 @@ fn probe_sink(keep: bool) -> Result<()> { // tone rendered through the DEFAULT device (as any app would), the loopback reading the // minted endpoint. This is `wasapi_cap`'s Assert shape minus the game. audio_control::set_default_endpoint(&ep).context("park the default playback on the sink")?; - let peak = tone_while(&None, 5, 440.0, || loopback_peak(&ep, 3))??; - println!("audio-probe sink: loopback peak over 3s = {peak:.4}"); + let (peak, hz) = tone_while(&None, 5, 440.0, || loopback_peak(&ep, 3))??; + println!( + "audio-probe sink: loopback peak over 3s = {peak:.4}, tone 440 Hz read back as {hz:.0} Hz" + ); if peak > SIGNAL_FLOOR { println!( " VERDICT: PASS (S2) — default-routed audio reaches the minted Speakers instance \ @@ -255,10 +288,10 @@ fn probe_sss_primary(secs: u32) -> Result<()> { ); report_mix_format("primary", &id); - let peak = tone_while(&Some(id.clone()), secs + 1, 440.0, || { + let (peak, hz) = tone_while(&Some(id.clone()), secs + 1, 440.0, || { loopback_peak(&id, secs) })??; - println!("audio-probe sss-primary: loopback peak over {secs}s = {peak:.4}"); + println!("audio-probe sss-primary: loopback peak over {secs}s = {peak:.4}, tone 440 Hz read back as {hz:.0} Hz"); if peak > SIGNAL_FLOOR { println!( " VERDICT: the primary SSS loopback CARRIES audio here (steam.exe running: \ @@ -561,13 +594,16 @@ fn render_tone(target: Option<&str>, seconds: u32, hz: f32, stop: &AtomicBool) - Ok(()) } -/// Peak |sample| read from an endpoint for `seconds`. `loopback` taps a RENDER endpoint's mix -/// (the desktop-audio capture shape); otherwise a normal record from a CAPTURE endpoint (the -/// virtual-mic consumer shape). -fn measure_peak(endpoint_id: &str, seconds: u32, loopback: bool) -> Result { +/// Peak |sample| AND estimated dominant frequency (zero crossings — a pitch-shift detector: +/// a 440 Hz tone reading back as ~220 Hz means some link runs at half the declared rate, which +/// peaks alone can never see) read from an endpoint for `seconds`. `loopback` taps a RENDER +/// endpoint's mix (the desktop-audio capture shape); otherwise a normal record from a CAPTURE +/// endpoint (the virtual-mic consumer shape). MONO request — frequency counting needs a single +/// channel, and autoconvert downmix changes no frequencies. +fn measure_peak(endpoint_id: &str, seconds: u32, loopback: bool) -> Result<(f32, f32)> { let device = pe::open_wasapi_device(endpoint_id)?; let mut client = device.get_iaudioclient().context("IAudioClient")?; - let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE as usize, 2, None); + let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE as usize, 1, None); let (period, _) = client.get_device_period().context("device period")?; client .initialize_client( @@ -592,6 +628,11 @@ fn measure_peak(endpoint_id: &str, seconds: u32, loopback: bool) -> Result let mut bytes: std::collections::VecDeque = std::collections::VecDeque::new(); let mut peak = 0f32; let mut frames = 0u64; + let mut crossings = 0u64; + let mut prev_positive: Option = None; + // Frequency = crossings over the SIGNAL span only (audio starts mid-window; counting the + // leading silence into the denominator reads every tone low). + let (mut first_signal, mut last_signal): (Option, Option) = (None, None); while Instant::now() < deadline { let _ = h_event.wait_for_event(100); loop { @@ -609,25 +650,41 @@ fn measure_peak(endpoint_id: &str, seconds: u32, loopback: bool) -> Result if whole > 0 { let raw: Vec = bytes.drain(..whole).collect(); for c in raw.chunks_exact(4) { - peak = peak.max(f32::from_le_bytes([c[0], c[1], c[2], c[3]]).abs()); + let s = f32::from_le_bytes([c[0], c[1], c[2], c[3]]); + peak = peak.max(s.abs()); + if s.abs() > 0.01 { + first_signal.get_or_insert(frames); + last_signal = Some(frames); + let pos = s > 0.0; + if prev_positive.is_some_and(|p| p != pos) { + crossings += 1; + } + prev_positive = Some(pos); + } frames += 1; } } } let _ = client.stop_stream(); + let est_hz = match (first_signal, last_signal) { + (Some(a), Some(b)) if b > a + SAMPLE_RATE as u64 / 10 => { + crossings as f32 / 2.0 / ((b - a) as f32 / SAMPLE_RATE as f32) + } + _ => 0.0, + }; println!( - "audio-probe: {} read {} samples from {endpoint_id}", + "audio-probe: {} read {} samples from {endpoint_id} (est {est_hz:.0} Hz)", if loopback { "loopback" } else { "record" }, frames ); - Ok(peak) + Ok((peak, est_hz)) } -fn loopback_peak(endpoint_id: &str, seconds: u32) -> Result { +fn loopback_peak(endpoint_id: &str, seconds: u32) -> Result<(f32, f32)> { measure_peak(endpoint_id, seconds, true) } -fn record_peak(endpoint_id: &str, seconds: u32) -> Result { +fn record_peak(endpoint_id: &str, seconds: u32) -> Result<(f32, f32)> { measure_peak(endpoint_id, seconds, false) } -- 2.54.0 From ed98814145fe3f713d58cf2e3081140af87bbd6a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:57:19 +0200 Subject: [PATCH 14/21] =?UTF-8?q?fix(host/audio):=20the=20minted=20microph?= =?UTF-8?q?one=20renders=20MONO=20=E2=80=94=20voice=20was=20an=20octave=20?= =?UTF-8?q?low?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured with the new pitch probe: 440 Hz into the minted mic render came back as 220 Hz off its capture side. The driver forwards the render stream RAW into its mono capture, so a stereo-declared render (the driver-default we inherited) turns every stereo frame into two mono samples — half speed, octave down, exactly the field report. The mic render now gets a coherent MONO 48 kHz format set stamped alongside its name (PCM16 device format + float mix/host formats), making the engine downmix before the driver crossing. The mic pump keeps pushing stereo; shared-mode autoconvert handles the rest. --- .../src/audio/windows/minted.rs | 82 ++++++++++++++++--- .../src/audio/windows/pad_endpoint.rs | 8 +- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index b3145820..f2fbf982 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -233,12 +233,17 @@ fn ensure_role(role: Role) -> Result<(String, String, Option)> { // Stamp the human name onto every endpoint of the role. Field-measured necessity, not // cosmetics: unstamped, the minted instances read "Lautsprecher (2- Steam Streaming // Microphone)" etc. and even the box's owner picked the wrong device out of the Sound - // settings zoo. Names only (a wider stamp set makes AudioEndpointBuilder re-mint the - // endpoint under a new GUID — the pad program measured that); stamping needs the SYSTEM - // ACL route on the MMDevices keys, so a dev-run devtest may leave the names unstamped — - // the wiring never depends on them (identity is the recorded id). - for ep in [Some(&render), capture.as_ref()].into_iter().flatten() { - stamp_name(ep, role); + // settings zoo. The MIC RENDER additionally gets a MONO format set — measured (440 Hz in, + // 220 Hz out): the driver forwards the render stream RAW into its mono capture side, so a + // stereo-declared render plays back an octave low; declaring mono makes the engine + // downmix before the crossing. Minimal stamps only (a wider set makes + // AudioEndpointBuilder re-mint the endpoint under a new GUID — the pad program measured + // that); stamping needs the SYSTEM ACL route on the MMDevices keys, so a dev-run devtest + // may leave them unstamped — the wiring never depends on them (identity is the recorded + // id). + stamp_identity(&render, role, false); + if let Some(cap) = capture.as_ref() { + stamp_identity(cap, role, true); } // Freshly registered endpoints can grab a default; the wiring plan owns default policy, @@ -274,10 +279,43 @@ const STAMP_ATTEMPTS: usize = 3; /// checking immediately reports success on passes that later get reverted). const STAMP_SETTLE: Duration = Duration::from_millis(1200); -/// Best-effort: write the role's display name onto one endpoint and wait for the audio stack -/// to SERVE it. Never fails the role — an unnamed endpoint still wires correctly by id. -fn stamp_name(endpoint_id: &str, role: Role) { - let stamps = [ +/// `WAVEFORMATEXTENSIBLE`: 1 ch / 48 kHz / 16-bit PCM, mask 0x4 (FRONT_CENTER), PCM subtype — +/// the mic render's device format. The driver's capture side is mono 48 kHz by its own +/// default; the render must MATCH it (measured: a stereo render crosses the driver raw and +/// plays back an octave low). +const WFX_PCM16_1CH_48K: [u8; 40] = [ + 0xfe, 0xff, // wFormatTag = WAVE_FORMAT_EXTENSIBLE + 0x01, 0x00, // nChannels = 1 + 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 + 0x00, 0x77, 0x01, 0x00, // nAvgBytesPerSec = 96000 + 0x02, 0x00, // nBlockAlign = 2 + 0x10, 0x00, // wBitsPerSample = 16 + 0x16, 0x00, // cbSize = 22 + 0x10, 0x00, // wValidBitsPerSample = 16 + 0x04, 0x00, 0x00, 0x00, // dwChannelMask = SPEAKER_FRONT_CENTER + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, + 0x71, // KSDATAFORMAT_SUBTYPE_PCM +]; +/// The float leg of ↑ (mix/host formats). +const WFX_F32_1CH_48K: [u8; 40] = [ + 0xfe, 0xff, // wFormatTag = WAVE_FORMAT_EXTENSIBLE + 0x01, 0x00, // nChannels = 1 + 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 + 0x00, 0xee, 0x02, 0x00, // nAvgBytesPerSec = 192000 + 0x04, 0x00, // nBlockAlign = 4 + 0x20, 0x00, // wBitsPerSample = 32 + 0x16, 0x00, // cbSize = 22 + 0x20, 0x00, // wValidBitsPerSample = 32 + 0x04, 0x00, 0x00, 0x00, // dwChannelMask = SPEAKER_FRONT_CENTER + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, + 0x71, // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT +]; + +/// Best-effort: write the role's display name — plus, on the mic RENDER, the mono format set — +/// onto one endpoint and wait for the audio stack to SERVE it. Never fails the role — an +/// unstamped endpoint still wires correctly by id. +fn stamp_identity(endpoint_id: &str, role: Role, capture: bool) { + let mut stamps = vec![ pe::Stamp { label: "device-desc", key: pe::PKEY_DEVICE_DESC, @@ -289,6 +327,30 @@ fn stamp_name(endpoint_id: &str, role: Role) { value: pe::StampValue::Str("Punktfunk"), }, ]; + if role == Role::Mic && !capture { + stamps.extend([ + pe::Stamp { + label: "device-format", + key: pe::PKEY_DEVICE_FORMAT, + value: pe::StampValue::Format(&WFX_PCM16_1CH_48K), + }, + pe::Stamp { + label: "mix-format-2", + key: pe::PKEY_MIX_FORMAT_2, + value: pe::StampValue::Format(&WFX_F32_1CH_48K), + }, + pe::Stamp { + label: "mix-format-3", + key: pe::PKEY_MIX_FORMAT_3, + value: pe::StampValue::Format(&WFX_F32_1CH_48K), + }, + pe::Stamp { + label: "host-format", + key: pe::PKEY_HOST_FORMAT, + value: pe::StampValue::Format(&WFX_F32_1CH_48K), + }, + ]); + } // Steady state (every boot after the first): the names are already served — no writes, // no settle sleeps. if pe::stamps_served(endpoint_id, &stamps) { diff --git a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs index f285c17b..fec1ab41 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -166,12 +166,12 @@ const PKEY_ENDPOINT_DEVNODE: PROPERTYKEY = pkey(0xb3f8fa53_0004_438e_9003_51a46e /// `PKEY_Device_ContainerId` — what games match against the pad's HID container. const PKEY_CONTAINER_ID: PROPERTYKEY = pkey(0x8c7ed206_3f8a_4827_b3ab_ae9e1faefc6c, 2); /// `PKEY_AudioEngine_DeviceFormat` (16-bit PCM leg of the format set). -const PKEY_DEVICE_FORMAT: PROPERTYKEY = pkey(0xf19f064d_082c_4e27_bc73_6882a1bb8e4c, 0); +pub(crate) const PKEY_DEVICE_FORMAT: PROPERTYKEY = pkey(0xf19f064d_082c_4e27_bc73_6882a1bb8e4c, 0); /// Endpoint format pair (float leg) — pids 2 and 3 of the same fmtid. -const PKEY_MIX_FORMAT_2: PROPERTYKEY = pkey(0x3d6e1656_2e50_4c4c_8d85_d0acae3c6c68, 2); -const PKEY_MIX_FORMAT_3: PROPERTYKEY = pkey(0x3d6e1656_2e50_4c4c_8d85_d0acae3c6c68, 3); +pub(crate) const PKEY_MIX_FORMAT_2: PROPERTYKEY = pkey(0x3d6e1656_2e50_4c4c_8d85_d0acae3c6c68, 2); +pub(crate) const PKEY_MIX_FORMAT_3: PROPERTYKEY = pkey(0x3d6e1656_2e50_4c4c_8d85_d0acae3c6c68, 3); /// Host processing format (float leg). -const PKEY_HOST_FORMAT: PROPERTYKEY = pkey(0xe4870e26_3cc5_4cd2_ba46_ca0a9a70ed04, 0); +pub(crate) const PKEY_HOST_FORMAT: PROPERTYKEY = pkey(0xe4870e26_3cc5_4cd2_ba46_ca0a9a70ed04, 0); /// `WAVEFORMATEXTENSIBLE`: 4 ch / 48 kHz / 16-bit PCM, mask 0x33 (FL FR BL BR), PCM subtype. const WFX_PCM16_4CH_48K: [u8; 40] = [ -- 2.54.0 From 5922cbe3254aa1d774d04bc771884ccd29c795d5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:01:39 +0200 Subject: [PATCH 15/21] fix(host/audio): the minted mic pair declares stereo on BOTH sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second measurement round: the driver render pin is STEREO-ONLY — the mono render stamp turned the endpoint unopenable (0x88890008 on every open, the incoherent-stamp signature the pad program documented). Since the crossing is raw, the coherent choice inverts: the CAPTURE side now declares the stereo float stream that actually crosses (fixing the octave-low voice), and the render has its stereo float default stamped explicitly — pinning the pair AND healing any endpoint a previous build left mono-stamped. --- .../src/audio/windows/minted.rs | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index f2fbf982..68d7fddb 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -279,34 +279,27 @@ const STAMP_ATTEMPTS: usize = 3; /// checking immediately reports success on passes that later get reverted). const STAMP_SETTLE: Duration = Duration::from_millis(1200); -/// `WAVEFORMATEXTENSIBLE`: 1 ch / 48 kHz / 16-bit PCM, mask 0x4 (FRONT_CENTER), PCM subtype — -/// the mic render's device format. The driver's capture side is mono 48 kHz by its own -/// default; the render must MATCH it (measured: a stereo render crosses the driver raw and -/// plays back an octave low). -const WFX_PCM16_1CH_48K: [u8; 40] = [ +/// `WAVEFORMATEXTENSIBLE`: 2 ch / 48 kHz / 32-bit float, mask 0x3 (FL FR), IEEE-float subtype +/// — the ONE format both sides of the minted microphone declare. +/// +/// Measured ground truth (micpitch, 2026-08-07): the driver forwards the render stream RAW +/// into the capture side, and its render pin is STEREO-ONLY (a mono-stamped render turned the +/// endpoint unopenable — `AUDCLNT_E_UNSUPPORTED_FORMAT` on every open, the pad program's +/// incoherent-stamp signature). The driver-default capture side declares MONO, so the raw +/// stereo stream read as mono played voice an octave low. Declaring the CAPTURE side stereo — +/// matching what actually crosses — is the honest fix; the render's stereo float default is +/// stamped explicitly too, pinning the pair coherent (and healing any endpoint a previous +/// build left mono-stamped). +const WFX_F32_2CH_48K: [u8; 40] = [ 0xfe, 0xff, // wFormatTag = WAVE_FORMAT_EXTENSIBLE - 0x01, 0x00, // nChannels = 1 + 0x02, 0x00, // nChannels = 2 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 - 0x00, 0x77, 0x01, 0x00, // nAvgBytesPerSec = 96000 - 0x02, 0x00, // nBlockAlign = 2 - 0x10, 0x00, // wBitsPerSample = 16 - 0x16, 0x00, // cbSize = 22 - 0x10, 0x00, // wValidBitsPerSample = 16 - 0x04, 0x00, 0x00, 0x00, // dwChannelMask = SPEAKER_FRONT_CENTER - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, - 0x71, // KSDATAFORMAT_SUBTYPE_PCM -]; -/// The float leg of ↑ (mix/host formats). -const WFX_F32_1CH_48K: [u8; 40] = [ - 0xfe, 0xff, // wFormatTag = WAVE_FORMAT_EXTENSIBLE - 0x01, 0x00, // nChannels = 1 - 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 - 0x00, 0xee, 0x02, 0x00, // nAvgBytesPerSec = 192000 - 0x04, 0x00, // nBlockAlign = 4 + 0x00, 0xdc, 0x05, 0x00, // nAvgBytesPerSec = 384000 + 0x08, 0x00, // nBlockAlign = 8 0x20, 0x00, // wBitsPerSample = 32 0x16, 0x00, // cbSize = 22 0x20, 0x00, // wValidBitsPerSample = 32 - 0x04, 0x00, 0x00, 0x00, // dwChannelMask = SPEAKER_FRONT_CENTER + 0x03, 0x00, 0x00, 0x00, // dwChannelMask = FL | FR 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71, // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT ]; @@ -327,27 +320,31 @@ fn stamp_identity(endpoint_id: &str, role: Role, capture: bool) { value: pe::StampValue::Str("Punktfunk"), }, ]; - if role == Role::Mic && !capture { + // Both sides of the mic pair declare the SAME stereo float format (see WFX_F32_2CH_48K: + // the crossing is raw and the render pin is stereo-only, so stereo-everywhere is the one + // coherent choice). `capture` is accepted for symmetry — both directions get the set. + let _ = capture; + if role == Role::Mic { stamps.extend([ pe::Stamp { label: "device-format", key: pe::PKEY_DEVICE_FORMAT, - value: pe::StampValue::Format(&WFX_PCM16_1CH_48K), + value: pe::StampValue::Format(&WFX_F32_2CH_48K), }, pe::Stamp { label: "mix-format-2", key: pe::PKEY_MIX_FORMAT_2, - value: pe::StampValue::Format(&WFX_F32_1CH_48K), + value: pe::StampValue::Format(&WFX_F32_2CH_48K), }, pe::Stamp { label: "mix-format-3", key: pe::PKEY_MIX_FORMAT_3, - value: pe::StampValue::Format(&WFX_F32_1CH_48K), + value: pe::StampValue::Format(&WFX_F32_2CH_48K), }, pe::Stamp { label: "host-format", key: pe::PKEY_HOST_FORMAT, - value: pe::StampValue::Format(&WFX_F32_1CH_48K), + value: pe::StampValue::Format(&WFX_F32_2CH_48K), }, ]); } -- 2.54.0 From cc53b3d6b0429463760576e11eca44e7395d0e62 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:06:56 +0200 Subject: [PATCH 16/21] =?UTF-8?q?fix(host/audio):=20unwire=20the=20minted?= =?UTF-8?q?=20microphone=20=E2=80=94=20the=20driver=20mic=20path=20is=20un?= =?UTF-8?q?usable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pitch-probe verdict on the SSM driver pair: the render pin is stereo-only, the capture pin mono-only (stamping either differently makes the endpoint unopenable), and the crossing between them is a RAW byte pass — so voice fed through the render endpoint reads back an octave low and no format stamp can fix it. S3 peak-based PASS = false pass; per the design doc revert clause the mic falls back to the name ladder (a virtual cable), pending the user re-decision. The SPEAKERS substrate keeps tier-0 (no driver crossing — a plain engine loopback tap, measured clean). minted_ids() publishes speakers only; the mic endpoints stay minted and recorded (provisioned()) for the micpitch probe and a possible future non-render transport, and their format stamps now pin each side to its pin one true format — healing the endpoints this branch earlier mis-stamped. --- .../src/audio/windows/audio_probe.rs | 9 ++- .../src/audio/windows/minted.rs | 55 +++++++++++++++---- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_probe.rs b/crates/punktfunk-host/src/audio/windows/audio_probe.rs index 680e282a..38de8640 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_probe.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -105,8 +105,13 @@ pub(crate) fn run(args: &[String]) -> Result<()> { // ~220 Hz = a link runs at half the declared rate (the octave-down voice). Some("micpitch") => { super::minted::ensure_blocking(); - let ids = super::minted::minted_ids(); - let (Some(render), Some(capture)) = (ids.mic_render, ids.mic_capture) else { + // The RAW provisioning record: the wiring-facing `minted_ids` deliberately hides + // the mic pair (raw crossing, octave-low — this probe is how that was measured). + let Some(m) = super::minted::provisioned() else { + bail!("nothing minted on this box — run `audio-probe mint` first"); + }; + let (Some(render), Some(capture)) = (m.mic_render.clone(), m.mic_capture.clone()) + else { bail!("no minted microphone pair on this box — run `audio-probe mint` first"); }; println!("audio-probe micpitch: render={render}"); diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index 68d7fddb..1fa2a82f 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -108,17 +108,32 @@ static PROVISIONING: AtomicBool = AtomicBool::new(false); static LAST_ATTEMPT: Mutex> = Mutex::new(None); /// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned. +/// +/// **The mic ids are deliberately NOT published** (2026-08-07 pitch measurements): the SSM +/// driver's pins are format-locked (render stereo-only, capture mono-only) and the crossing +/// is a RAW byte pass, so voice fed through the render endpoint reads back an octave low and +/// no stamp can fix it — S3's peak-based PASS was a false pass. The mic therefore falls back +/// to the name ladder (a virtual cable) per the design's revert clause, while the SPEAKERS +/// substrate — which involves no driver crossing, just an engine loopback tap — stays tier-0. +/// The mic endpoints are still minted and recorded ([`provisioned`]) for the `micpitch` +/// probe and for a future transport that bypasses the render path. pub(crate) fn minted_ids() -> wiring_plan::MintedIds { match PROVISIONED.get() { Some(m) => wiring_plan::MintedIds { speakers_render: m.speakers_render.clone(), - mic_render: m.mic_render.clone(), - mic_capture: m.mic_capture.clone(), + mic_render: None, + mic_capture: None, }, None => wiring_plan::MintedIds::default(), } } +/// The raw provisioning record — the probe's view (unlike [`minted_ids`], the mic ids are +/// visible here). +pub(crate) fn provisioned() -> Option> { + PROVISIONED.get().cloned() +} + /// Spawn the provisioning worker (idempotent; returns immediately). Called at host start next /// to the pad provider, and again from [`ensure_provisioned`] on the retry path. pub(crate) fn provision_at_startup() { @@ -303,6 +318,20 @@ const WFX_F32_2CH_48K: [u8; 40] = [ 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71, // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT ]; +/// The capture pin's one format: 1 ch / 48 kHz / 32-bit float, mask 0x4 (FRONT_CENTER). +const WFX_F32_1CH_48K: [u8; 40] = [ + 0xfe, 0xff, // wFormatTag = WAVE_FORMAT_EXTENSIBLE + 0x01, 0x00, // nChannels = 1 + 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 + 0x00, 0xee, 0x02, 0x00, // nAvgBytesPerSec = 192000 + 0x04, 0x00, // nBlockAlign = 4 + 0x20, 0x00, // wBitsPerSample = 32 + 0x16, 0x00, // cbSize = 22 + 0x20, 0x00, // wValidBitsPerSample = 32 + 0x04, 0x00, 0x00, 0x00, // dwChannelMask = SPEAKER_FRONT_CENTER + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, + 0x71, // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT +]; /// Best-effort: write the role's display name — plus, on the mic RENDER, the mono format set — /// onto one endpoint and wait for the audio stack to SERVE it. Never fails the role — an @@ -320,31 +349,37 @@ fn stamp_identity(endpoint_id: &str, role: Role, capture: bool) { value: pe::StampValue::Str("Punktfunk"), }, ]; - // Both sides of the mic pair declare the SAME stereo float format (see WFX_F32_2CH_48K: - // the crossing is raw and the render pin is stereo-only, so stereo-everywhere is the one - // coherent choice). `capture` is accepted for symmetry — both directions get the set. - let _ = capture; + // Format stamps pin each mic-pair side to ITS pin's one supported format — measured: the + // render pin is stereo-only and the capture pin mono-only (stamping either differently + // makes the endpoint unopenable, 0x88890008 on every Initialize). These stamps exist to + // HEAL endpoints an earlier build of this branch mis-stamped, and to keep the pair pinned + // against drift; they cannot fix the raw crossing (see [`minted_ids`]). if role == Role::Mic { + let wfx: &'static [u8; 40] = if capture { + &WFX_F32_1CH_48K + } else { + &WFX_F32_2CH_48K + }; stamps.extend([ pe::Stamp { label: "device-format", key: pe::PKEY_DEVICE_FORMAT, - value: pe::StampValue::Format(&WFX_F32_2CH_48K), + value: pe::StampValue::Format(wfx), }, pe::Stamp { label: "mix-format-2", key: pe::PKEY_MIX_FORMAT_2, - value: pe::StampValue::Format(&WFX_F32_2CH_48K), + value: pe::StampValue::Format(wfx), }, pe::Stamp { label: "mix-format-3", key: pe::PKEY_MIX_FORMAT_3, - value: pe::StampValue::Format(&WFX_F32_2CH_48K), + value: pe::StampValue::Format(wfx), }, pe::Stamp { label: "host-format", key: pe::PKEY_HOST_FORMAT, - value: pe::StampValue::Format(&WFX_F32_2CH_48K), + value: pe::StampValue::Format(wfx), }, ]); } -- 2.54.0 From 2eed9823e5d2b9f95a82a6ad42a80e3f78ed8bdd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:33:20 +0200 Subject: [PATCH 17/21] fix(host/audio): the mic pair gets the pad-proven coherent stereo stamp set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user challenged the format-locked-pins verdict, and the pad program is the counter-evidence: it hit the SAME 0x88890008 unopenable-endpoint signature and cured it with a COHERENT stamp set, after which the same driver family served 4ch happily. This branch previous attempts were contaminated twice over — a float device-format (the pad bisect proved the split must be PCM16 device / float mix+host) and no AudioEndpointBuilder restart (Restart-Service Audiosrv never touches its dependency, so endpoint configs were never rebuilt). Both mic endpoints now get one identical coherent stereo set; the octave-low hypothesis shifts from "raw crossing by design" to "the two endpoint stores disagreed (stereo render default vs mono capture default)". --- .../src/audio/windows/minted.rs | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index 1fa2a82f..e837b432 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -318,19 +318,21 @@ const WFX_F32_2CH_48K: [u8; 40] = [ 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71, // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT ]; -/// The capture pin's one format: 1 ch / 48 kHz / 32-bit float, mask 0x4 (FRONT_CENTER). -const WFX_F32_1CH_48K: [u8; 40] = [ +/// The PCM16 leg of the stereo set — the pad program's measured coherence rule: the DEVICE +/// format is 16-bit PCM, the mix/host formats float (a float device-format was part of the +/// incoherent sets that made endpoints unopenable). +const WFX_PCM16_2CH_48K: [u8; 40] = [ 0xfe, 0xff, // wFormatTag = WAVE_FORMAT_EXTENSIBLE - 0x01, 0x00, // nChannels = 1 + 0x02, 0x00, // nChannels = 2 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 0x00, 0xee, 0x02, 0x00, // nAvgBytesPerSec = 192000 0x04, 0x00, // nBlockAlign = 4 - 0x20, 0x00, // wBitsPerSample = 32 + 0x10, 0x00, // wBitsPerSample = 16 0x16, 0x00, // cbSize = 22 - 0x20, 0x00, // wValidBitsPerSample = 32 - 0x04, 0x00, 0x00, 0x00, // dwChannelMask = SPEAKER_FRONT_CENTER - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, - 0x71, // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT + 0x10, 0x00, // wValidBitsPerSample = 16 + 0x03, 0x00, 0x00, 0x00, // dwChannelMask = FL | FR + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, + 0x71, // KSDATAFORMAT_SUBTYPE_PCM ]; /// Best-effort: write the role's display name — plus, on the mic RENDER, the mono format set — @@ -349,37 +351,36 @@ fn stamp_identity(endpoint_id: &str, role: Role, capture: bool) { value: pe::StampValue::Str("Punktfunk"), }, ]; - // Format stamps pin each mic-pair side to ITS pin's one supported format — measured: the - // render pin is stereo-only and the capture pin mono-only (stamping either differently - // makes the endpoint unopenable, 0x88890008 on every Initialize). These stamps exist to - // HEAL endpoints an earlier build of this branch mis-stamped, and to keep the pair pinned - // against drift; they cannot fix the raw crossing (see [`minted_ids`]). + // The mic pair gets ONE coherent stereo format set on BOTH endpoints, in the exact + // PCM16-device/float-mix split the pad program's stamp bisect proved the driver accepts + // (its first "unopenable endpoint" and "format can't be changed" verdicts were both + // incoherent-stamp artifacts — this branch re-derived the same false verdicts before the + // pad recipe was re-applied). Both sides identical: the driver moves one stream between + // the two endpoints, and the octave-low voice was the two sides DISAGREEING (stereo + // render default vs mono capture default). `capture` steers nothing today — kept so a + // per-direction split stays one edit away. + let _ = capture; if role == Role::Mic { - let wfx: &'static [u8; 40] = if capture { - &WFX_F32_1CH_48K - } else { - &WFX_F32_2CH_48K - }; stamps.extend([ pe::Stamp { label: "device-format", key: pe::PKEY_DEVICE_FORMAT, - value: pe::StampValue::Format(wfx), + value: pe::StampValue::Format(&WFX_PCM16_2CH_48K), }, pe::Stamp { label: "mix-format-2", key: pe::PKEY_MIX_FORMAT_2, - value: pe::StampValue::Format(wfx), + value: pe::StampValue::Format(&WFX_F32_2CH_48K), }, pe::Stamp { label: "mix-format-3", key: pe::PKEY_MIX_FORMAT_3, - value: pe::StampValue::Format(wfx), + value: pe::StampValue::Format(&WFX_F32_2CH_48K), }, pe::Stamp { label: "host-format", key: pe::PKEY_HOST_FORMAT, - value: pe::StampValue::Format(wfx), + value: pe::StampValue::Format(&WFX_F32_2CH_48K), }, ]); } -- 2.54.0 From 16e506f9437579a9823efaf42ea426e71c811132 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:39:48 +0200 Subject: [PATCH 18/21] =?UTF-8?q?feat(host/devtest):=20audio-probe=20micpi?= =?UTF-8?q?ns=20=E2=80=94=20the=20driver-capability=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exclusive+shared IsFormatSupported across {1,2}ch x {16,32}bit x {44.1,48,96}kHz on both minted mic pins. Interrogates the DRIVER, bypassing every endpoint-store stamping question: what the pins truly accept decides whether the mic leg has any coherent configuration, and whether an exclusive-mode mono open is an escape hatch. (The pad program made its own breakthrough with exactly this instrument on the sibling SSS driver.) --- .../src/audio/windows/audio_probe.rs | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_probe.rs b/crates/punktfunk-host/src/audio/windows/audio_probe.rs index 38de8640..8107b856 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_probe.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -132,9 +132,55 @@ pub(crate) fn run(args: &[String]) -> Result<()> { } Ok(()) } + // The driver-capability map for the minted mic pair: exclusive+shared + // IsFormatSupported across {1,2}ch × {16,32}bit × {44.1,48,96}kHz on BOTH pins — + // interrogates the DRIVER, bypassing every endpoint-store stamping question. What the + // pins truly accept decides whether the mic leg has any coherent configuration (and + // whether an exclusive-mode mono open is an escape hatch). + Some("micpins") => { + super::minted::ensure_blocking(); + let Some(m) = super::minted::provisioned() else { + bail!("nothing minted on this box — run `audio-probe mint` first"); + }; + let (Some(render), Some(capture)) = (m.mic_render.clone(), m.mic_capture.clone()) + else { + bail!("no minted microphone pair on this box"); + }; + for (label, id) in [("render", &render), ("capture", &capture)] { + println!("audio-probe micpins: {label} = {id}"); + let device = pe::open_wasapi_device(id)?; + let client = device.get_iaudioclient().context("IAudioClient")?; + for ch in [1usize, 2] { + for bits in [16usize, 32] { + for rate in [44_100usize, 48_000, 96_000] { + let stype = if bits == 16 { + SampleType::Int + } else { + SampleType::Float + }; + let fmt = WaveFormat::new(bits, bits, &stype, rate, ch, None); + let mut verdicts = Vec::new(); + for (mode_label, mode) in [ + ("excl", wasapi::ShareMode::Exclusive), + ("shared", wasapi::ShareMode::Shared), + ] { + let v = match client.is_supported(&fmt, &mode) { + Ok(None) => "OK", + Ok(Some(_)) => "alt", + Err(_) => "no", + }; + verdicts.push(format!("{mode_label}={v}")); + } + println!(" {ch}ch {bits:2}bit {rate:5}Hz {}", verdicts.join(" ")); + } + } + } + } + Ok(()) + } _ => bail!( "usage: punktfunk-host audio-probe \ - [--keep]" + [--keep]" ), } } -- 2.54.0 From 8c274d6256a1b04c8294dc488c1df3246d0024f3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:44:01 +0200 Subject: [PATCH 19/21] =?UTF-8?q?fix(host/devtest):=20the=20probe=20asks?= =?UTF-8?q?=20stereo=20=E2=80=94=20its=20mono=20ask=20WAS=20the=20unopenab?= =?UTF-8?q?le=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured resolution of the 0x88890008 mystery: IsFormatSupported said the capture accepts 2ch/48k shared while Initialize kept failing — because the probe itself had switched to a MONO ask for frequency counting, and this stack does not bridge channel counts on capture even under autoconvert. Every unopenable-endpoint verdict after that switch was the instrument, not the endpoint. Stereo ask restored; crossings counted on channel 0. --- .../src/audio/windows/audio_probe.rs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_probe.rs b/crates/punktfunk-host/src/audio/windows/audio_probe.rs index 8107b856..6643fba8 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_probe.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -649,12 +649,16 @@ fn render_tone(target: Option<&str>, seconds: u32, hz: f32, stop: &AtomicBool) - /// a 440 Hz tone reading back as ~220 Hz means some link runs at half the declared rate, which /// peaks alone can never see) read from an endpoint for `seconds`. `loopback` taps a RENDER /// endpoint's mix (the desktop-audio capture shape); otherwise a normal record from a CAPTURE -/// endpoint (the virtual-mic consumer shape). MONO request — frequency counting needs a single -/// channel, and autoconvert downmix changes no frequencies. +/// endpoint (the virtual-mic consumer shape). +/// +/// STEREO request, crossings counted on channel 0 — measured trap: a MONO ask made +/// `Initialize` fail with 0x88890008 on the SSM endpoints even under `autoconvert` (this +/// stack does not bridge channel counts on capture), and that probe artifact masqueraded as +/// "the endpoint is unopenable" through an entire debugging round. fn measure_peak(endpoint_id: &str, seconds: u32, loopback: bool) -> Result<(f32, f32)> { let device = pe::open_wasapi_device(endpoint_id)?; let mut client = device.get_iaudioclient().context("IAudioClient")?; - let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE as usize, 1, None); + let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE as usize, 2, None); let (period, _) = client.get_device_period().context("device period")?; client .initialize_client( @@ -697,16 +701,18 @@ fn measure_peak(endpoint_id: &str, seconds: u32, loopback: bool) -> Result<(f32, Err(e) => bail!("get_next_packet_size: {e}"), } } - let whole = (bytes.len() / 4) * 4; + // Whole stereo frames (8 bytes); peak over both channels, crossings on channel 0. + let whole = (bytes.len() / 8) * 8; if whole > 0 { let raw: Vec = bytes.drain(..whole).collect(); - for c in raw.chunks_exact(4) { - let s = f32::from_le_bytes([c[0], c[1], c[2], c[3]]); - peak = peak.max(s.abs()); - if s.abs() > 0.01 { + for f in raw.chunks_exact(8) { + let l = f32::from_le_bytes([f[0], f[1], f[2], f[3]]); + let r = f32::from_le_bytes([f[4], f[5], f[6], f[7]]); + peak = peak.max(l.abs()).max(r.abs()); + if l.abs() > 0.01 { first_signal.get_or_insert(frames); last_signal = Some(frames); - let pos = s > 0.0; + let pos = l > 0.0; if prev_positive.is_some_and(|p| p != pos) { crossings += 1; } -- 2.54.0 From 52cd42e91188f4be99e20cabe028bba69373a453 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:49:15 +0200 Subject: [PATCH 20/21] fix(host/audio): capture endpoints get the device format only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live bisect on a fresh endpoint: the mix/host format keys are RENDER-engine properties — stamped onto a capture endpoint they broke its shared-mode graph (IsFormatSupported reported 2ch/48k OK while Initialize failed 0x88890008 on a once-stamped fresh endpoint; unstamped it opened fine, S3). The capture now gets ONLY the device-format key — the knob mmsys.cpl itself writes — declaring the stereo the pins actually accept. --- .../src/audio/windows/minted.rs | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index e837b432..4f6b4a1d 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -351,38 +351,39 @@ fn stamp_identity(endpoint_id: &str, role: Role, capture: bool) { value: pe::StampValue::Str("Punktfunk"), }, ]; - // The mic pair gets ONE coherent stereo format set on BOTH endpoints, in the exact - // PCM16-device/float-mix split the pad program's stamp bisect proved the driver accepts - // (its first "unopenable endpoint" and "format can't be changed" verdicts were both - // incoherent-stamp artifacts — this branch re-derived the same false verdicts before the - // pad recipe was re-applied). Both sides identical: the driver moves one stream between - // the two endpoints, and the octave-low voice was the two sides DISAGREEING (stereo - // render default vs mono capture default). `capture` steers nothing today — kept so a - // per-direction split stays one edit away. - let _ = capture; + // The mic pair runs STEREO 48 kHz on both sides — the pins accept it (micpins), and the + // octave-low voice was the two sides DISAGREEING (stereo render default vs mono capture + // default). The stamp sets differ per direction, bisected live: + // * RENDER: the pad program's proven PCM16-device/float-mix split (its own bisect). + // * CAPTURE: the DEVICE format ONLY — the mix/host keys are RENDER-engine properties, + // and stamping them onto a capture endpoint broke its shared-mode graph + // (IsFormatSupported said 2ch/48k OK while Initialize failed 0x88890008 on a fresh, + // once-stamped endpoint; unstamped it opened fine). if role == Role::Mic { - stamps.extend([ - pe::Stamp { - label: "device-format", - key: pe::PKEY_DEVICE_FORMAT, - value: pe::StampValue::Format(&WFX_PCM16_2CH_48K), - }, - pe::Stamp { - label: "mix-format-2", - key: pe::PKEY_MIX_FORMAT_2, - value: pe::StampValue::Format(&WFX_F32_2CH_48K), - }, - pe::Stamp { - label: "mix-format-3", - key: pe::PKEY_MIX_FORMAT_3, - value: pe::StampValue::Format(&WFX_F32_2CH_48K), - }, - pe::Stamp { - label: "host-format", - key: pe::PKEY_HOST_FORMAT, - value: pe::StampValue::Format(&WFX_F32_2CH_48K), - }, - ]); + stamps.push(pe::Stamp { + label: "device-format", + key: pe::PKEY_DEVICE_FORMAT, + value: pe::StampValue::Format(&WFX_PCM16_2CH_48K), + }); + if !capture { + stamps.extend([ + pe::Stamp { + label: "mix-format-2", + key: pe::PKEY_MIX_FORMAT_2, + value: pe::StampValue::Format(&WFX_F32_2CH_48K), + }, + pe::Stamp { + label: "mix-format-3", + key: pe::PKEY_MIX_FORMAT_3, + value: pe::StampValue::Format(&WFX_F32_2CH_48K), + }, + pe::Stamp { + label: "host-format", + key: pe::PKEY_HOST_FORMAT, + value: pe::StampValue::Format(&WFX_F32_2CH_48K), + }, + ]); + } } // Steady state (every boot after the first): the names are already served — no writes, // no settle sleeps. -- 2.54.0 From ebc2f1cf922d82388c5c21cc84bb22bc81415ceb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:53:48 +0200 Subject: [PATCH 21/21] =?UTF-8?q?feat(host/audio):=20the=20minted=20microp?= =?UTF-8?q?hone=20returns=20to=20tier-0=20=E2=80=94=20pitch-true?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revert un-reverts, on measurement: with the per-direction stamp sets (render = the pad-proven PCM16-device/float-mix stereo split, capture = device-format only), micpitch reads 440 Hz in as 440 Hz out at exact peak. The octave-low voice was the driver DEFAULT endpoints disagreeing (stereo render vs mono capture), never a raw-crossing design. The user called the wrong verdict — the pad program 4ch success was the counter-evidence that reopened the case. --- .../punktfunk-host/src/audio/windows/minted.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs index 4f6b4a1d..e1da828d 100644 --- a/crates/punktfunk-host/src/audio/windows/minted.rs +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -109,20 +109,17 @@ static LAST_ATTEMPT: Mutex> = Mutex::new(None); /// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned. /// -/// **The mic ids are deliberately NOT published** (2026-08-07 pitch measurements): the SSM -/// driver's pins are format-locked (render stereo-only, capture mono-only) and the crossing -/// is a RAW byte pass, so voice fed through the render endpoint reads back an octave low and -/// no stamp can fix it — S3's peak-based PASS was a false pass. The mic therefore falls back -/// to the name ladder (a virtual cable) per the design's revert clause, while the SPEAKERS -/// substrate — which involves no driver crossing, just an engine loopback tap — stays tier-0. -/// The mic endpoints are still minted and recorded ([`provisioned`]) for the `micpitch` -/// probe and for a future transport that bypasses the render path. +/// The mic ids were briefly unpublished during the 2026-08-07 pitch investigation ("voice an +/// octave low") — the eventual measured truth: both pins run stereo/48 kHz fine, the octave +/// came from the driver's DEFAULT endpoints disagreeing (stereo render vs mono capture), and +/// the per-direction stamp sets in [`stamp_identity`] fix it permanently +/// (`audio-probe micpitch`: 440 Hz in → 440 Hz out, peak exact). Full tier-0 restored. pub(crate) fn minted_ids() -> wiring_plan::MintedIds { match PROVISIONED.get() { Some(m) => wiring_plan::MintedIds { speakers_render: m.speakers_render.clone(), - mic_render: None, - mic_capture: None, + mic_render: m.mic_render.clone(), + mic_capture: m.mic_capture.clone(), }, None => wiring_plan::MintedIds::default(), } -- 2.54.0