feat(windows-host): mic passthrough — auto-wire audio devices + bundle VB-CABLE

The Windows virtual mic worked only with manual Sound-settings fiddling: on a
headless host (no real audio output) BOTH the desktop-audio loopback and the
virtual mic must run on virtual cables, and on DIFFERENT ones or the loopback
re-captures the injected mic (echo). The Steam pair gives only one usable cable
(Steam Streaming Speakers loopback is silent — validated), so the mic + loopback
collided and echoed, and when the default playback happened to be the mic device
the anti-echo guard reported the mic "unavailable".

Host now auto-wires the devices at startup (audio/windows/audio_control.rs,
ensure_wired_once, hooked from open_audio_capture/open_virtual_mic): default
playback = a loopback-capable render that is NOT a cable and NOT the dead Steam
Speakers (real output > Steam Streaming Microphone); default recording = the mic
capture (VB-Cable "CABLE Output" preferred). Uses a hand-rolled IPolicyConfig
vtable (the only way to set a default endpoint; not in windows/wasapi crates).
Opt out with PUNKTFUNK_KEEP_DEFAULT. wasapi_mic candidates now prefer "cable
input". Validated live: from a deliberately-wrong start (playback=CABLE Input)
the host corrected both default endpoints at the OS level.

A Windows audio endpoint can only be created by a kernel-mode driver (no UMDF
path — ACX is KMDF-only), so we cannot self-sign our own like the UMDF gamepad/
display drivers. Instead the installer bundles + silently installs the official
base VB-CABLE (VB-Audio donationware, vendor-signed → loads with no test-signing,
redistributed under VB-Audio's bundling grant): install-vbcable.ps1 (seed the
VB-Audio cert into TrustedPublisher, run -i -h) + an installaudiocable task,
gated on -VbCableDir/$env:VBCABLE_DIR (the package binary is not in the repo).
Attribution in packaging/windows/licenses/VB-CABLE-NOTICE.txt. .iss compiles
with the path enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 01:18:03 +02:00
parent 0f798d62b6
commit 83ee53290e
9 changed files with 422 additions and 5 deletions
+3
View File
@@ -182,6 +182,9 @@ windows = { version = "0.62", features = [
# Windows service supervisor (src/service.rs): a kill-on-close job object so a service crash never # Windows service supervisor (src/service.rs): a kill-on-close job object so a service crash never
# orphans the SYSTEM host it launched into the interactive session. # orphans the SYSTEM host it launched into the interactive session.
"Win32_System_JobObjects", "Win32_System_JobObjects",
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
"Win32_System_Com",
] } ] }
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control # The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses # handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
+5
View File
@@ -42,6 +42,7 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> { pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
audio_control::ensure_wired_once();
wasapi_cap::WasapiLoopbackCapturer::open(channels) wasapi_cap::WasapiLoopbackCapturer::open(channels)
.map(|c| Box::new(c) as Box<dyn AudioCapturer>) .map(|c| Box::new(c) as Box<dyn AudioCapturer>)
} }
@@ -77,6 +78,7 @@ pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> { pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
audio_control::ensure_wired_once();
wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box<dyn VirtualMic>) wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box<dyn VirtualMic>)
} }
@@ -85,6 +87,9 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device") anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device")
} }
#[cfg(target_os = "windows")]
#[path = "audio/windows/audio_control.rs"]
mod audio_control;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -0,0 +1,227 @@
//! Windows audio device auto-wiring — production mic + desktop-audio passthrough with zero manual
//! setup.
//!
//! 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
//! 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 at startup so no manual Sound-settings fiddling is ever needed:
//!
//! * default **PLAYBACK** → a loopback-capable render that is NOT the mic cable (a real output device
//! if one exists, else the Steam Streaming Microphone; **never** the Steam Streaming Speakers, whose
//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] loopback-captures
//! for desktop audio.
//! * default **RECORDING** → the virtual mic's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default.
//!
//! [`super::wasapi_mic::find_device`] then resolves the mic INJECT target to "CABLE Input" — a render
//! candidate that is NOT the default playback — guaranteeing loopback ≠ mic, so there is no echo.
//!
//! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set
//! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the
//! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen
//! defaults untouched.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, bail, Result};
use std::ffi::c_void;
use std::sync::Once;
use wasapi::Direction;
/// Run the audio device auto-wiring exactly once per process, before the first capturer/mic opens.
/// Blocks until done so the default playback is set before the loopback captures it. Best-effort:
/// every failure is logged, never fatal (the host then falls back to whatever the current defaults
/// are — exactly the pre-wiring behaviour).
pub(crate) fn ensure_wired_once() {
static WIRED: Once = Once::new();
WIRED.call_once(|| {
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
tracing::info!("PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched");
return;
}
// Run on a dedicated COM-MTA thread so we never collide with the caller's apartment mode
// (the capture/mic threads each initialize their own COM separately).
let handle = std::thread::Builder::new()
.name("pf-audio-wiring".into())
.spawn(|| {
if wasapi::initialize_mta().ok().is_err() {
tracing::warn!("audio wiring: COM init (MTA) failed — skipping");
return;
}
if let Err(e) = ensure_audio_wiring() {
tracing::warn!(error = %format!("{e:#}"),
"audio auto-wiring failed — mic/desktop audio may need manual device defaults");
}
});
if let Ok(h) = handle {
let _ = h.join();
}
});
}
/// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`.
fn list_endpoints(dir: Direction) -> Vec<(String, String)> {
let mut out = Vec::new();
let Ok(en) = wasapi::DeviceEnumerator::new() else {
return out;
};
let Ok(coll) = en.get_device_collection(&dir) else {
return out;
};
let Ok(n) = coll.get_nbr_devices() else {
return out;
};
for i in 0..n {
if let Ok(dev) = coll.get_device_at_index(i) {
let id = dev.get_id().unwrap_or_default();
if id.is_empty() {
continue;
}
out.push((dev.get_friendlyname().unwrap_or_default(), id));
}
}
out
}
/// Pick the loopback + mic-capture devices and set them as the default playback/recording.
fn ensure_audio_wiring() -> Result<()> {
let renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture);
if renders.is_empty() {
bail!("no active render endpoints to wire");
}
// A render is unusable as the desktop-audio loopback if it is a VB-Cable endpoint (reserved for
// the mic inject) or the Steam Streaming Speakers (its loopback is silent — validated live).
let excluded_loopback =
|ln: &str| ln.contains("cable") || ln.contains("steam streaming speakers");
// "virtual-ish" = a known virtual cable; a render WITHOUT these markers is a real output device,
// the best loopback source (apps render there and the operator can also hear it).
let virtualish = |ln: &str| {
ln.contains("virtual")
|| ln.contains("cable")
|| ln.contains("steam streaming")
|| ln.contains("voicemeeter")
};
let loopback = renders
.iter()
.find(|(n, _)| {
let ln = n.to_lowercase();
!excluded_loopback(&ln) && !virtualish(&ln)
})
.or_else(|| {
renders
.iter()
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone"))
})
.or_else(|| {
renders
.iter()
.find(|(n, _)| !excluded_loopback(&n.to_lowercase()))
});
// The virtual mic's CAPTURE endpoint host apps record from — VB-Cable "CABLE Output" preferred.
let mic_capture = captures
.iter()
.find(|(n, _)| n.to_lowercase().contains("cable output"))
.or_else(|| {
captures
.iter()
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone"))
})
.or_else(|| {
captures.iter().find(|(n, _)| {
let ln = n.to_lowercase();
ln.contains("voicemeeter") || ln.contains("virtual")
})
});
match loopback {
Some((name, id)) => match set_default_endpoint(id) {
Ok(()) => tracing::info!(device = %name,
"audio wiring: default playback = desktop-audio loopback source"),
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default playback device"),
},
None => {
tracing::warn!("audio wiring: no usable desktop-audio loopback render endpoint found")
}
}
if let Some((name, id)) = mic_capture {
match set_default_endpoint(id) {
Ok(()) => tracing::info!(device = %name,
"audio wiring: default recording = virtual mic (apps record the client's mic)"),
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"),
}
}
Ok(())
}
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release`
/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct.
#[repr(C)]
struct IPolicyConfigVtbl {
query_interface: unsafe extern "system" fn(
*mut c_void,
*const windows::core::GUID,
*mut *mut c_void,
) -> windows::core::HRESULT,
add_ref: unsafe extern "system" fn(*mut c_void) -> u32,
release: unsafe extern "system" fn(*mut c_void) -> u32,
_reserved: [*const c_void; 10],
set_default_endpoint: unsafe extern "system" fn(
*mut c_void,
windows::core::PCWSTR,
u32,
) -> windows::core::HRESULT,
// SetEndpointVisibility follows — unused.
}
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
/// fails.
fn set_default_endpoint(device_id: &str) -> Result<()> {
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
// PolicyConfigClient coclass + IPolicyConfig (Win7+) IID.
const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8);
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: CoCreateInstance with a valid CLSID returns an owned, refcounted IUnknown. We QI it for
// IPolicyConfig; on success (HRESULT ok + non-null pointer) we invoke its SetDefaultEndpoint slot
// through the documented vtable layout (3 IUnknown + 10 placeholder methods precede it) with a
// NUL-terminated UTF-16 id and an in-range ERole (0..=2), then Release the QI'd pointer. Every
// pointer is checked non-null before deref; `unk` is Released by its Drop on scope exit.
unsafe {
let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL)
.map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?;
let mut raw: *mut c_void = std::ptr::null_mut();
unk.query(&IID_IPOLICY_CONFIG, &mut raw)
.ok()
.map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?;
if raw.is_null() {
bail!("IPolicyConfig QueryInterface returned null");
}
let vtbl = *(raw as *const *const IPolicyConfigVtbl);
let mut result = Ok(());
for role in 0u32..=2 {
let hr = ((*vtbl).set_default_endpoint)(raw, PCWSTR(wide.as_ptr()), role);
if hr.is_err() {
result = hr
.ok()
.map_err(|e| anyhow!("SetDefaultEndpoint(role {role}): {e}"));
}
}
((*vtbl).release)(raw);
result
}
}
@@ -4,10 +4,12 @@
//! **capture** endpoint then surfaces as a microphone that host apps can record from. //! **capture** endpoint then surfaces as a microphone that host apps can record from.
//! //!
//! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`): //! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`):
//! "Steam Streaming Microphone" (ships with Steam Remote Play — exactly this purpose), VB-Audio //! VB-Audio "CABLE Input" (bundled by the installer — the preferred, dedicated mic target), the
//! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none is present we //! "Steam Streaming Microphone", VoiceMeeter, or anything with "virtual" in the name.
//! auto-install the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we //! [`super::audio_control`] sets the default playback to a DIFFERENT loopback-capable device so the
//! return an error with install guidance and the host runs without mic passthrough. //! chosen mic is never the endpoint the loopback captures. If no candidate is present we auto-install
//! the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we return an error
//! with install guidance and the host runs without mic passthrough.
//! //!
//! **Anti-echo guard (the whole point of this being non-trivial).** The desktop-audio plane //! **Anti-echo guard (the whole point of this being non-trivial).** The desktop-audio plane
//! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback //! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback
@@ -45,8 +47,8 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 80 / 1000) * BLOCK_ALIGN;
/// Render-endpoint friendly-name substrings (lowercased) we can write into so the device's capture /// Render-endpoint friendly-name substrings (lowercased) we can write into so the device's capture
/// endpoint becomes a host mic. Ordered by preference. /// endpoint becomes a host mic. Ordered by preference.
const CANDIDATES: &[&str] = &[ const CANDIDATES: &[&str] = &[
"cable input", // VB-Audio Virtual Cable — bundled by the installer; the preferred dedicated mic target
"steam streaming microphone", "steam streaming microphone",
"cable input",
"voicemeeter input", "voicemeeter input",
"voicemeeter aux input", "voicemeeter aux input",
"virtual", "virtual",
+11
View File
@@ -65,6 +65,16 @@ read it from `%ProgramData%\punktfunk\web-password`.
- **Virtual gamepads need no prerequisite.** The DualSense / DualShock 4 / Xbox 360 (XUSB) UMDF drivers - **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 are **bundled** in the installer (the *Install the virtual gamepad drivers* task) and
`pnputil`-installed. **ViGEmBus is no longer used.** `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); see
`licenses/VB-CABLE-NOTICE.txt`. The package binary is **not** in the repo — supply it to the packer via
`-VbCableDir` / `$env:VBCABLE_DIR` (the extracted official package, containing `VBCABLE_Setup_x64.exe`).
Absent → the installer is built without it and the host falls back to auto-installing the Steam
Streaming pair. *(Endgame: attestation-sign our own MIT virtual-audio driver to drop this dependency.)*
## Files here ## Files here
@@ -74,6 +84,7 @@ read it from `%ProgramData%\punktfunk\web-password`.
| `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, 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, 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-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-dualsense` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. | | `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-dualsense` + `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). |
| `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). | | `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. | | `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. |
| `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `bun` on the Nitro server (`:3000`). | | `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `bun` on the Nitro server (`:3000`). |
+97
View File
@@ -0,0 +1,97 @@
<#
.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
@@ -0,0 +1,26 @@
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.
+24
View File
@@ -28,6 +28,7 @@ param(
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build) [string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
[string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console [string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console
[string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console [string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console
[string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic
[switch]$NoDriver, # build without the bundled pf-vdisplay driver [switch]$NoDriver, # build without the bundled pf-vdisplay driver
[switch]$NoSign # skip signing (local debug) [switch]$NoSign # skip signing (local debug)
) )
@@ -189,6 +190,29 @@ if (-not $NoDriver) {
Write-Host "==> built + staged gamepad UMDF drivers -> $gpStage" Write-Host "==> built + staged gamepad UMDF drivers -> $gpStage"
} }
# --- 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 (Test-Path $VbCableDir) -and (Get-ChildItem -Path $VbCableDir -Filter 'VBCABLE_Setup*.exe' -ErrorAction SilentlyContinue)) {
$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 (or no VBCABLE_Setup*.exe in it) -> installer built WITHOUT the bundled VB-CABLE virtual mic" }
# --- stage the FFmpeg shared DLLs (AMD/Intel AMF/QSV build) ------------------------------------ # --- 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 # A host built with --features amf-qsv link-imports avcodec/avutil/swscale/... so the shared DLLs
# MUST sit next to the exe (it won't start otherwise). Bundle them from $FfmpegDir\bin - the same # MUST sit next to the exe (it won't start otherwise). Bundle them from $FfmpegDir\bin - the same
+22
View File
@@ -41,6 +41,12 @@
#ifdef GamepadStageDir #ifdef GamepadStageDir
#define WithGamepad #define WithGamepad
#endif #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
; FfmpegBin (a dir of FFmpeg shared DLLs) is optional - present when the host is built with ; 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). ; --features amf-qsv (the AMD/Intel AMF/QSV encode backend link-imports the FFmpeg libs).
#ifdef FfmpegBin #ifdef FfmpegBin
@@ -93,6 +99,9 @@ Name: "installdriver"; Description: "Install the pf-vdisplay virtual display dri
#ifdef WithGamepad #ifdef WithGamepad
Name: "installgamepad"; Description: "Install the virtual gamepad drivers (DualSense / DualShock 4 / Xbox 360 - no ViGEmBus needed)" Name: "installgamepad"; Description: "Install the virtual gamepad drivers (DualSense / DualShock 4 / Xbox 360 - no ViGEmBus needed)"
#endif #endif
#ifdef WithAudioCable
Name: "installaudiocable"; Description: "Install VB-CABLE virtual audio (microphone passthrough - VB-Audio donationware, www.vb-cable.com)"
#endif
#ifdef WithVkLayer #ifdef WithVkLayer
Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan games like Doom use HDR on the virtual display)" Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan games like Doom use HDR on the virtual display)"
#endif #endif
@@ -132,6 +141,10 @@ Source: "{#StageDir}\*"; DestDir: "{tmp}\pfvdisplay"; Flags: deleteafterinstall
; The built-from-source UMDF gamepad drivers + install-gamepad-drivers.ps1, extracted to {tmp}, removed after. ; 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 Source: "{#GamepadStageDir}\*"; DestDir: "{tmp}\gamepad"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installgamepad
#endif #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 #ifdef WithVkLayer
; The HDR Vulkan implicit layer (cdylib + its JSON manifest) laid into {app}\vklayer and registered ; 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 ; below. The manifest's library_path is ".\pf_vkhdr_layer.dll" (relative to the JSON), so the two
@@ -160,6 +173,15 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "driver install --gamepad --di
StatusMsg: "Installing the virtual gamepad drivers..."; \ StatusMsg: "Installing the virtual gamepad drivers..."; \
Flags: runhidden waituntilterminated; Tasks: installgamepad Flags: runhidden waituntilterminated; Tasks: installgamepad
#endif #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: ; 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}. ; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}.
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: "{app}"; \ Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: "{app}"; \