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 dc412dc1..e380bf11 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -150,13 +150,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', '' @@ -406,7 +399,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/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/compliance/sbom/manual-components.cdx.json b/compliance/sbom/manual-components.cdx.json index 73dba20b..ff90d43f 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.rs b/crates/punktfunk-host/src/audio.rs index f2871580..2f3af04d 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -189,6 +189,16 @@ 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; +// 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; @@ -207,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 5674ff45..0f7f6e97 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -3,14 +3,20 @@ //! //! 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: //! //! * 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 @@ -60,7 +66,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() @@ -143,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. @@ -195,6 +212,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, @@ -203,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 @@ -216,6 +240,8 @@ 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, + readiness = ?wiring_plan::readiness(&wiring), renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::>(), "audio wiring plan" ); @@ -261,7 +287,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 \ @@ -333,7 +368,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..6643fba8 --- /dev/null +++ b/crates/punktfunk-host/src/audio/windows/audio_probe.rs @@ -0,0 +1,767 @@ +//! `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, +}; +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(), + // 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. 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 { + 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(()) + } + // 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(); + // 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}"); + 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(()) + } + // 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]" + ), + } +} + +// --- 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, 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}, tone 440 Hz read back as {hz:.0} Hz" + ); + 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, 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 \ + 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, 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}, 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: \ + {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 — shared with the minted provider ------------------------------------- + +use super::minted::discover_driver; + +// --- 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| 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). +/// +/// 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, 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; + 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 { + 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}"), + } + } + // 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 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 = l > 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} (est {est_hz:.0} Hz)", + if loopback { "loopback" } else { "record" }, + frames + ); + Ok((peak, est_hz)) +} + +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<(f32, f32)> { + 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/minted.rs b/crates/punktfunk-host/src/audio/windows/minted.rs new file mode 100644 index 00000000..e1da828d --- /dev/null +++ b/crates/punktfunk-host/src/audio/windows/minted.rs @@ -0,0 +1,555 @@ +//! 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. +/// +/// 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: m.mic_render.clone(), + mic_capture: m.mic_capture.clone(), + }, + 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() { + 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, + }; + + // 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. 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, + // 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)) +} + +/// 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); + +/// `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 + 0x02, 0x00, // nChannels = 2 + 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 + 0x00, 0xdc, 0x05, 0x00, // nAvgBytesPerSec = 384000 + 0x08, 0x00, // nBlockAlign = 8 + 0x20, 0x00, // wBitsPerSample = 32 + 0x16, 0x00, // cbSize = 22 + 0x20, 0x00, // wValidBitsPerSample = 32 + 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 +]; +/// 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 + 0x02, 0x00, // nChannels = 2 + 0x80, 0xbb, 0x00, 0x00, // nSamplesPerSec = 48000 + 0x00, 0xee, 0x02, 0x00, // nAvgBytesPerSec = 192000 + 0x04, 0x00, // nBlockAlign = 4 + 0x10, 0x00, // wBitsPerSample = 16 + 0x16, 0x00, // cbSize = 22 + 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 — +/// 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, + value: pe::StampValue::Str(role.desc()), + }, + pe::Stamp { + label: "device-name", + key: pe::PKEY_ENDPOINT_DEVICE_NAME, + value: pe::StampValue::Str("Punktfunk"), + }, + ]; + // 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.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. + 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; + 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. +/// 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 std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || 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!( + "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 ffaa05cb..fec1ab41 100644 --- a/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs +++ b/crates/punktfunk-host/src/audio/windows/pad_endpoint.rs @@ -91,8 +91,16 @@ 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}."; +/// …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 @@ -124,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), @@ -146,21 +156,22 @@ 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); /// `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] = [ @@ -261,7 +272,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 +479,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 +490,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 +505,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 +521,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 +549,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; @@ -567,9 +578,14 @@ fn devnode_inf_path(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option { (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( @@ -582,7 +598,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); @@ -605,70 +621,14 @@ 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)) } -/// 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> { - let set = media_class_devs()?; - for i in 0.. { - let mut did = 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; // ERROR_NO_MORE_ITEMS - } - let Some(inst) = instance_id(&set, &did) else { - continue; - }; - if !inst.to_ascii_uppercase().starts_with("ROOT\\") { - continue; - } - if devnode_pad_index(&set, &did) == Some(pad_index as u32) { - return Ok(Some(inst)); - } - } - 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 { - // 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); - // 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 { - SetupDiCreateDeviceInfoW( - set.0, - w!("MEDIA"), - &GUID_DEVCLASS_MEDIA, - PCWSTR(desc.as_ptr()), - None, - DICD_GENERATE_ID, - Some(&mut did), - ) - } - .context("SetupDiCreateDeviceInfo")?; - let hwid = multi_sz_bytes(&[SSS_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")?; - // NOT SetupDiCallClassInstaller(DIF_REGISTERDEVICE): that requires an interactive window - // station and fails with error 1459 from a service. Plain registration is all a root - // devnode needs before UpdateDriverForPlugAndPlayDevices binds the driver. - // 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")?; - tracing::info!(pad = pad_index, devnode = %inst, "created a pad-audio devnode"); - Ok(inst) -} - -/// 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<()> { +/// 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( @@ -695,9 +655,9 @@ fn write_pad_index(set: &DevInfoSet, did: &mut SP_DEVINFO_DATA, pad_index: u8) - PCWSTR::null(), ) } - .context("create the devnode's Device Parameters key")?, + .with_context(|| format!("create the Device Parameters key for {value_name}"))?, }; - let name = wide(PAD_INDEX_VALUE); + 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 { @@ -706,14 +666,102 @@ fn write_pad_index(set: &DevInfoSet, did: &mut SP_DEVINFO_DATA, pad_index: u8) - PCWSTR(name.as_ptr()), None, REG_DWORD, - Some(&(pad_index as u32).to_le_bytes()), + Some(&value.to_le_bytes()), ) }; // SAFETY: closing the key opened/created above, exactly once. unsafe { let _ = RegCloseKey(hkey); } - rc.ok().context("write PunktfunkPadIndex") + 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> { + let set = media_class_devs()?; + for i in 0.. { + let mut did = 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; // ERROR_NO_MORE_ITEMS + } + let Some(inst) = instance_id(&set, &did) else { + continue; + }; + if !inst.to_ascii_uppercase().starts_with("ROOT\\") { + continue; + } + if devnode_pad_index(&set, &did) == Some(pad_index as u32) { + return Ok(Some(inst)); + } + } + Ok(None) +} + +/// 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(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 { + SetupDiCreateDeviceInfoW( + set.0, + w!("MEDIA"), + &GUID_DEVCLASS_MEDIA, + PCWSTR(desc.as_ptr()), + None, + DICD_GENERATE_ID, + Some(&mut did), + ) + } + .context("SetupDiCreateDeviceInfo")?; + 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")?; + // NOT SetupDiCallClassInstaller(DIF_REGISTERDEVICE): that requires an interactive window + // station and fails with error 1459 from a service. Plain registration is all a root + // devnode needs before UpdateDriverForPlugAndPlayDevices binds the driver. + // SAFETY: live set + element; no compare callback. + unsafe { SetupDiRegisterDeviceInfo(set.0, &mut did, 0, None, None, None) } + .context("SetupDiRegisterDeviceInfo")?; + 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) +} + +/// 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<()> { + write_devparam_dword(set, did, PAD_INDEX_VALUE, pad_index as u32) } /// The Steam Streaming Speakers INF to feed `UpdateDriverForPlugAndPlayDevices`: prefer the @@ -755,12 +803,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 +821,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,26 +833,47 @@ 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, 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 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, CAPTURE_ENDPOINT_ID_PREFIX, instance_id) +} + +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}"); - 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 { continue; }; if link.eq_ignore_ascii_case(&want) { - return Ok(Some(format!("{ENDPOINT_ID_PREFIX}{key}"))); + return Ok(Some(format!("{id_prefix}{key}"))); } } Ok(None) @@ -926,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. @@ -935,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(); @@ -976,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(()) } @@ -1132,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; }; @@ -1139,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 ---------------------------------------------------------------- diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 0c39cae2..3e29b0c9 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,37 @@ 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() { + // "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 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..3339c27b 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 @@ -216,20 +217,37 @@ 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); - 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 \ - 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(); @@ -287,6 +305,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..04c9b059 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -12,12 +12,26 @@ //! //! 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 +//! 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 @@ -96,6 +110,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. @@ -118,6 +175,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 { @@ -132,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", @@ -207,6 +272,7 @@ pub(crate) fn plan( mic_want: Option<&str>, host_audio: bool, pad_renders: &[String], + minted: &MintedIds, ) -> Wiring { plan_with_formats( renders, @@ -216,6 +282,7 @@ pub(crate) fn plan( &no_formats, 2, pad_renders, + minted, ) } @@ -234,6 +301,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], @@ -242,6 +310,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 @@ -262,14 +331,67 @@ 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 + // 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, _)| { + // 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() @@ -317,13 +439,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), @@ -342,6 +489,7 @@ pub(crate) fn plan_with_formats( loopback_render, loopback_last_resort, loopback_narrowing, + mic_withheld, } } @@ -455,7 +603,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)" @@ -484,7 +632,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)" @@ -504,7 +652,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)" @@ -521,7 +669,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}"); } } @@ -533,7 +681,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"); } @@ -551,7 +699,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)" @@ -570,8 +718,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 = [ @@ -579,23 +728,61 @@ 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)" ); + 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()); + let w = plan(&renders, &captures, None, false, &[], &MintedIds::default()); + 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, &[], &MintedIds::default()); + 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 — @@ -609,7 +796,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)", @@ -619,22 +806,61 @@ 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, + &[], + &MintedIds::default(), + ); + 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, + &[], + &MintedIds::default(), + ); 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)" @@ -653,7 +879,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)", @@ -675,7 +908,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}"); @@ -724,7 +964,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)", @@ -754,7 +1003,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)" @@ -770,7 +1028,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)" @@ -786,7 +1053,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)" @@ -806,8 +1082,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()); } @@ -825,7 +1117,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)"); } @@ -853,7 +1145,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)" @@ -869,7 +1168,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)"); } @@ -887,7 +1186,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)", @@ -910,7 +1216,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}"); } @@ -925,7 +1231,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()); } @@ -948,10 +1254,19 @@ 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, + &[], + &MintedIds::default(), + ); assert!(w.loopback_unsatisfiable()); let msg = describe_no_loopback(&renders, &w); assert!(msg.contains("reserved for the virtual mic"), "{msg}"); @@ -962,7 +1277,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}"); @@ -980,7 +1295,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. @@ -990,6 +1305,7 @@ mod tests { Some("wireless controller"), false, &pads, + &MintedIds::default(), ); assert!(w.mic_render.is_none()); assert!(w.loopback_render.is_none()); @@ -1001,7 +1317,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 +1327,14 @@ 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, + &MintedIds::default(), + ); assert_eq!( w.loopback_render.as_ref().unwrap().0, "Speakers (Steam Streaming Speakers)", @@ -1020,7 +1344,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" @@ -1028,4 +1359,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 e6ef342b..2fff4e6c 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -495,6 +495,18 @@ 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 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) +} + #[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 ffa73ae7..ed28763d 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -626,6 +626,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). 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.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 dc3eac7c..63fe3b42 100644 --- a/scripts/ci/provision-windows-punktfunk-extras.ps1 +++ b/scripts/ci/provision-windows-punktfunk-extras.ps1 @@ -86,26 +86,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 @@ -115,9 +98,8 @@ 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:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH '@ | Set-Content -Encoding UTF8 $projectEnv -info "wrote $projectEnv (FFMPEG_DIR, VBCABLE_DIR) - restart the gitea-act-runner scheduled task to pick it up" +info "wrote $projectEnv (FFMPEG_DIR) - restart the gitea-act-runner scheduled task to pick it up" info "punktfunk extras provisioned OK." 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,