Windows audio substrate: minted Punktfunk endpoints retire VB-Cable #98

Merged
enricobuehler merged 22 commits from worktree-audio-substrate into main 2026-08-07 16:18:59 +00:00
33 changed files with 2497 additions and 465 deletions
+1 -1
View File
@@ -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
-8
View File
@@ -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) {
+56
View File
@@ -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."
@@ -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",
+22
View File
@@ -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 S1S3 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<wiring_plan::Wiring> {
audio_control::last_wiring()
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn wiring_snapshot() -> Option<wiring_plan::Wiring> {
None
}
@@ -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<MixFormat> {
pub(crate) fn mix_format_of(ep: &Endpoint) -> Option<MixFormat> {
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<Option<Wiring>> = 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<Wiring> {
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<Option<Wiring>> = 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::<Vec<_>>(),
"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<String> {
/// 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<String> {
pub(crate) fn default_capture_id() -> Option<String> {
wasapi::DeviceEnumerator::new()
.ok()?
.get_default_device(&Direction::Capture)
@@ -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<super::wiring_plan::Endpoint>| 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 \
<ssm|sink|sss-primary|mint|plan|micpitch|micpins|cleanup> [--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<u16> = 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<Vec<(String, u32)>> {
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<u16> = 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<String> {
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<String> {
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<T>(
target: &Option<String>,
tone_secs: u32,
hz: f32,
body: impl FnOnce() -> T,
) -> Result<T> {
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<u8> = std::collections::VecDeque::new();
let mut peak = 0f32;
let mut frames = 0u64;
let mut crossings = 0u64;
let mut prev_positive: Option<bool> = 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<u64>, Option<u64>) = (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<u8> = 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<String>, prev_capture: Option<String>) {
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:#}"),
}
}
}
}
@@ -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<String>,
pub speakers_render: Option<String>,
pub mic_devnode: Option<String>,
pub mic_render: Option<String>,
pub mic_capture: Option<String>,
}
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<Arc<MintedAudio>> = 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<Option<Instant>> = 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<Arc<MintedAudio>> {
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<MintedAudio> {
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<String>)> {
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<String> {
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<Option<String>> {
let set = pe::media_class_devs()?;
for i in 0.. {
let mut did = pe::devinfo_data();
// SAFETY: live set; `did` is a live out-param with cbSize set.
if unsafe {
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<String> {
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(())
}
@@ -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}.<device instance id>"` — 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<Stamp> {
// --- small encoding helpers ----------------------------------------------------------------
/// NUL-terminated UTF-16.
fn wide(s: &str) -> Vec<u16> {
pub(crate) fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
@@ -468,7 +479,7 @@ fn pv_bytes(pv: &PROPVARIANT) -> Option<Vec<u8>> {
// --- 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<DevInfoSet> {
pub(crate) fn media_class_devs() -> Result<DevInfoSet> {
// 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<DevInfoSet> {
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::<SP_DEVINFO_DATA>() as u32,
..Default::default()
}
}
fn instance_id(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option<String> {
pub(crate) fn instance_id(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option<String> {
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<String> {
}
/// 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<String> {
pub(crate) fn devnode_inf_path(set: &DevInfoSet, did: &SP_DEVINFO_DATA) -> Option<String> {
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<String> {
(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<u32> {
/// 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<u32> {
// 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<u32> {
)
}
.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<u32> {
(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<Option<String>> {
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<String> {
// 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<u32> {
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<Option<String>> {
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<String> {
// 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<String> {
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<String> {
)
}
/// 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}.<instance id>"` under `…\MMDevices\Audio\Render\{ep}\Properties`).
fn find_endpoint_for_devnode(instance_id: &str) -> Result<Option<String>> {
pub(crate) fn find_endpoint_for_devnode(instance_id: &str) -> Result<Option<String>> {
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<Option<String>> {
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<Option<String>> {
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::<String, _>(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::<Vec<_>>(),
"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 ----------------------------------------------------------------
@@ -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<Option<bool>> = Mutex::new(None);
let should_try = !have_silent(&plan.wiring) && {
let infs = super::wasapi_mic::steam_infs_present();
let mut tried = TRIED_WITH_INFS.lock().unwrap();
let go = match *tried {
None => true,
Some(had_infs) => !had_infs && infs,
};
if go {
*tried = Some(infs);
}
go
};
if should_try {
if super::wasapi_mic::install_steam_audio_pair() {
plan = audio_control::wire_now_full(true);
}
@@ -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=<friendly-name \
substring> to force a target."
);
}
anyhow::bail!(
"no virtual-mic render endpoint on this box. Install VB-Audio Virtual Cable (the host \
installer bundles it) or enable Steam Remote Play's microphone (Steam Streaming \
Microphone), or set PUNKTFUNK_MIC_DEVICE=<friendly-name substring>."
"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=<friendly-name \
substring>."
);
};
let name = ep.0.clone();
@@ -287,6 +305,23 @@ pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
Some(path)
}
/// Do Steam's streaming-audio driver INFs exist on this box? The auto-install RE-ARM trigger:
/// INF files appearing later (Steam installed mid-run) are invisible to the endpoint-set
/// fingerprint — files are not endpoints — so the desktop-audio capture's install latch keys on
/// this instead of staying once-per-process ([`super::wasapi_cap`]).
pub(crate) fn steam_infs_present() -> bool {
use std::os::windows::ffi::OsStringExt;
["SteamStreamingMicrophone.inf", "SteamStreamingSpeakers.inf"]
.iter()
.any(|inf| {
steam_driver_inf_path(inf).is_some_and(|wide| {
// Drop the trailing NUL the FFI callers need; `exists` wants the bare path.
let len = wide.len().saturating_sub(1);
std::path::PathBuf::from(std::ffi::OsString::from_wide(&wide[..len])).exists()
})
})
}
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
+547 -56
View File
@@ -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<MixFormat> {
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<String>,
/// "Punktfunk Microphone" render side — the virtual mic's write target.
pub mic_render: Option<String>,
/// "Punktfunk Microphone" capture side — the microphone host apps record.
pub mic_capture: Option<String>,
}
/// 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<String>,
/// The mic was DENIED the Steam Streaming Microphone because taking it would have left the
/// loopback with only the known-silent last resort or nothing — game audio outranks the
/// optional mic. (`mic_render` may still hold a lesser candidate; when it is `None` the
/// mic open fails with guidance naming the trade — a cable gives the mic its own device
/// without costing the loopback.)
pub mic_withheld: bool,
}
impl Wiring {
@@ -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<String>| -> Option<Endpoint> {
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);
}
}
+12
View File
@@ -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 S1S3 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;
+4
View File
@@ -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).
+50
View File
@@ -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<ActiveGame>,
/// 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<AudioWiring>,
}
/// 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<String>,
/// Friendly name of the virtual-mic write target; absent = mic passthrough unavailable.
#[serde(skip_serializing_if = "Option::is_none")]
mic: Option<String>,
/// 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<String>,
}
/// The wiring snapshot mapped for the API — `None` off-Windows or before the first pass.
fn audio_wiring() -> Option<AudioWiring> {
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<Arc<MgmtState>>) -> Json<Runtime
grace_remaining_s: g.grace_remaining_s,
})
.collect(),
audio: audio_wiring(),
})
}
+5
View File
@@ -359,6 +359,11 @@ pub(crate) async fn serve(
// Failures log once and leave the feature off: pads still work, just without pad audio.
#[cfg(target_os = "windows")]
crate::audio::pad_endpoint::provision_at_startup();
// Windows: mint the punktfunk-owned audio endpoints ("Punktfunk Speakers/Microphone" —
// instances of Valve's streaming drivers, the wiring plan's tier-0). Best-effort on a
// worker thread; without Steam's drivers the wiring plan keeps its name-based ladder.
#[cfg(target_os = "windows")]
crate::audio::minted::provision_at_startup();
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
// restores the box's autologin gaming session on idle, not per-disconnect — see
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
+5 -4
View File
@@ -27,11 +27,12 @@ leaving the stream; see [Input](/docs/input#getting-your-input-back).
## "Listen to this device" and app monitoring (Windows hosts)
Windows can play a microphone straight out of the speakers. If **Listen to this device** is
ticked for the Punktfunk mic (usually *CABLE Output*), your voice plays on the host's output —
which the stream then captures and sends right back to you.
ticked for the Punktfunk mic — a *Steam Streaming Microphone*-class device on current hosts,
*CABLE Output* on older ones — your voice plays on the host's output, which the stream then
captures and sends right back to you.
Open **Sound settings → More sound settings → Recording**, double-click *CABLE Output*, and on
the **Listen** tab untick *Listen to this device*.
Open **Sound settings → More sound settings → Recording**, double-click the Punktfunk mic, and
on the **Listen** tab untick *Listen to this device*.
The same loop hides in apps: **Discord's** *Mic Test* / input monitoring, **OBS's** *Monitor
audio* on a mic source, and similar monitoring features in other tools all play your mic into
+8 -6
View File
@@ -167,12 +167,14 @@ We mitigate this deliberately:
- **Punktfunk's own drivers are user-mode.** The virtual display, both virtual-gamepad drivers
(DualSense / DualShock 4 / Edge / Deck, and Xbox 360 / XInput) and the virtual pointer are
**user-mode (UMDF)** drivers, so a driver bug is contained to a restricted service account — never
ring-0, never full-system. (This is why Punktfunk dropped ViGEmBus.) **One exception:** the
microphone-passthrough option installs VB-CABLE, a third-party **kernel-mode** audio driver from
VB-Audio. It's a ticked-by-default checkbox on the installer's task page — clear it if you don't
want it, though on a headless host (no real sound device) a virtual cable is also what desktop
audio plays into — and because other applications may use it, uninstalling Punktfunk leaves it in
place; remove it through its own uninstaller.
ring-0, never full-system. (This is why Punktfunk dropped ViGEmBus.) Audio is the one place a
kernel-mode driver is unavoidable (Windows has no user-mode way to create an audio device), and
Punktfunk deliberately ships none of its own: the "Punktfunk Speakers" and "Punktfunk
Microphone" endpoints are instances of **Steam's vendor-signed streaming-audio drivers**,
created on your box from your own Steam install. Older Punktfunk versions bundled VB-CABLE
(a third-party kernel-mode driver from VB-Audio) for the microphone; if you have one, other
applications may use it, so uninstalling Punktfunk leaves it in place — remove it through its
own uninstaller.
- **Sealed internal channels.** The desktop-frame ring and the gamepad input/output channels are
passed between the host and its drivers as duplicated handles to unnamed objects, so another local
service can't open them by name to read your screen or forge controller input.
+5 -4
View File
@@ -210,10 +210,11 @@ Three things are left on purpose:
Remove-Item -Recurse -Force "$env:ProgramData\punktfunk"
```
- **VB-CABLE**, unless you cleared its checkbox during setup — it is ticked by default. It is a
third-party VB-Audio component other apps may be using, so the Punktfunk uninstaller never touches
it. Remove it with its own uninstaller —
`VBCABLE_Setup_x64.exe -u -h` — or the **VB-Audio Virtual Cable** entry in Installed apps.
- **VB-CABLE**, if an older Punktfunk version installed it (releases used to bundle it for the
microphone; current hosts use Steam's streaming drivers instead). It is a third-party VB-Audio
component other apps may be using, so the Punktfunk uninstaller never touches it. Remove it
with its own uninstaller — `VBCABLE_Setup_x64.exe -u -h` — or the **VB-Audio Virtual Cable**
entry in Installed apps.
- **The publisher certificate**, if you imported it by hand to silence the Unknown Publisher prompt.
Remove it in `certlm.msc` under **Trusted Publishers** and **Trusted Root Certification
Authorities**. (This is *not* the driver certificate above, which the uninstaller does remove.)
+15 -12
View File
@@ -51,9 +51,10 @@ Download the signed `punktfunk-host-setup-<ver>.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
+14 -19
View File
@@ -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-<ver>.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` (100200% 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. |
-97
View File
@@ -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
@@ -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.
+4 -28
View File
@@ -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
+31 -27
View File
@@ -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;
+2 -2
View File
@@ -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
@@ -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-
@@ -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,
+2 -2
View File
@@ -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).
@@ -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."
+10
View File
@@ -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",
+10
View File
@@ -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",
+58
View File
@@ -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<{
</Card>
</div>
{/* 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 && <AudioWiringCard audio={s.audio} />}
{/* Above the session card: a game the host is about to close is the most
time-sensitive thing on this page. */}
<RunningGames
@@ -193,6 +200,57 @@ export const DashboardView: FC<{
);
};
/**
* One line per role plus a readiness badge; the degradation notes are spelled out because the
* failure they describe (silent audio, a mic that quietly vanished) is invisible everywhere
* else except the host log.
*/
const AudioWiringCard: FC<{ audio: AudioWiring }> = ({ 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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2">
<Volume2 className="size-4" />
{m.audio_wiring_title()}
</CardTitle>
<Badge variant={badge.variant}>{badge.text}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<dl className="grid gap-4 sm:grid-cols-2">
<Field
label={m.audio_output()}
value={audio.loopback ?? m.audio_unavailable()}
/>
<Field
label={m.audio_microphone()}
value={audio.mic ?? m.audio_unavailable()}
/>
</dl>
{notes.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{notes.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
)}
</CardContent>
</Card>
);
};
const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
icon,
label,