Windows audio substrate: minted Punktfunk endpoints retire VB-Cable #98
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -189,6 +189,16 @@ mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/pad_endpoint.rs"]
|
||||
pub(crate) mod pad_endpoint;
|
||||
// `audio-probe` devtest — the S1–S3 spike measurements for the Windows audio-substrate design
|
||||
// (mint Steam-driver instances, measure their render→capture / loopback paths).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/audio_probe.rs"]
|
||||
pub(crate) mod audio_probe;
|
||||
// The minted "Punktfunk Speakers/Microphone" provider — punktfunk-owned instances of Valve's
|
||||
// streaming-audio drivers, the wiring plan's tier-0 (the audio-substrate program).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/minted.rs"]
|
||||
pub(crate) mod minted;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
@@ -207,3 +217,15 @@ pub(crate) mod capture_policy;
|
||||
mod mic_jitter;
|
||||
mod mic_pump;
|
||||
pub use mic_pump::{MicFrame, MicPump};
|
||||
|
||||
/// The most recent audio wiring verdict — the LAST wiring pass's assignment on a Windows host,
|
||||
/// `None` elsewhere or before the first pass. A read-only snapshot for the status API; never
|
||||
/// triggers a pass.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn wiring_snapshot() -> Option<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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +495,18 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||
/// Windows: the audio-substrate toolbox (`windows-audio-endpoints-and-vbcable.md`) —
|
||||
/// `audio-probe ssm|sink|sss-primary|mint|plan|cleanup [--keep]`. The S1–S3 spikes (`ssm` =
|
||||
/// the decision gate: mint a second Steam Streaming Microphone devnode and prove
|
||||
/// render→capture end to end; `sink` parks the default on a minted Speakers instance and
|
||||
/// loopback-measures it; `sss-primary` re-measures the primary Speakers' loopback), plus the
|
||||
/// product paths: `mint` runs the minted-endpoint provider synchronously and `plan` prints
|
||||
/// one real wiring pass with its readiness verdict.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn audio_probe(args: &[String]) -> Result<()> {
|
||||
crate::audio::audio_probe::run(args)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||
use crate::audio::pad_endpoint as pe;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
@@ -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` (100–200% DPI) + `punktfunk.ico`. Re-run only on a brand change. |
|
||||
| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + VB-CABLE + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. |
|
||||
| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. |
|
||||
| `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. |
|
||||
| `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-gamepad` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. |
|
||||
| `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). |
|
||||
| `make-driver-cert.ps1` | Generate the stable `CN=punktfunk-driver` code-signing cert (the `DRIVER_CERT_PFX_B64` / `DRIVER_CERT_PASSWORD` secrets). No key container, so it works over SSH; self-tests with signtool where it can. See *Driver signing* above. |
|
||||
| `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). |
|
||||
| `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user