From 83ee53290ed5890293232e449e3f15f8e84a1893 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 30 Jun 2026 01:18:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows-host):=20mic=20passthrough=20?= =?UTF-8?q?=E2=80=94=20auto-wire=20audio=20devices=20+=20bundle=20VB-CABLE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/punktfunk-host/Cargo.toml | 3 + crates/punktfunk-host/src/audio.rs | 5 + .../src/audio/windows/audio_control.rs | 227 ++++++++++++++++++ .../src/audio/windows/wasapi_mic.rs | 12 +- packaging/windows/README.md | 11 + packaging/windows/install-vbcable.ps1 | 97 ++++++++ .../windows/licenses/VB-CABLE-NOTICE.txt | 26 ++ packaging/windows/pack-host-installer.ps1 | 24 ++ packaging/windows/punktfunk-host.iss | 22 ++ 9 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 crates/punktfunk-host/src/audio/windows/audio_control.rs create mode 100644 packaging/windows/install-vbcable.ps1 create mode 100644 packaging/windows/licenses/VB-CABLE-NOTICE.txt diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 337459e..33f4bec 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -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 # orphans the SYSTEM host it launched into the interactive session. "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 # handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index 5146dbc..e2fea2c 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -42,6 +42,7 @@ pub fn open_audio_capture(channels: u32) -> Result> { #[cfg(target_os = "windows")] pub fn open_audio_capture(channels: u32) -> Result> { + audio_control::ensure_wired_once(); wasapi_cap::WasapiLoopbackCapturer::open(channels) .map(|c| Box::new(c) as Box) } @@ -77,6 +78,7 @@ pub fn open_virtual_mic(channels: u32) -> Result> { #[cfg(target_os = "windows")] pub fn open_virtual_mic(channels: u32) -> Result> { + audio_control::ensure_wired_once(); wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box) } @@ -85,6 +87,9 @@ pub fn open_virtual_mic(_channels: u32) -> Result> { 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")] mod linux; #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs new file mode 100644 index 0000000..a92de34 --- /dev/null +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -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 = 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 + } +} diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index c89091c..b1aae2e 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -4,10 +4,12 @@ //! **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`): -//! "Steam Streaming Microphone" (ships with Steam Remote Play — exactly this purpose), VB-Audio -//! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none 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. +//! VB-Audio "CABLE Input" (bundled by the installer — the preferred, dedicated mic target), the +//! "Steam Streaming Microphone", VoiceMeeter, or anything with "virtual" in the name. +//! [`super::audio_control`] sets the default playback to a DIFFERENT loopback-capable device so the +//! 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 //! ([`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 /// endpoint becomes a host mic. Ordered by preference. const CANDIDATES: &[&str] = &[ + "cable input", // VB-Audio Virtual Cable — bundled by the installer; the preferred dedicated mic target "steam streaming microphone", - "cable input", "voicemeeter input", "voicemeeter aux input", "virtual", diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 021b3a4..58fa0f4 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -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 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); 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 @@ -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. | | `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. | +| `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). | | `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`). | diff --git a/packaging/windows/install-vbcable.ps1 b/packaging/windows/install-vbcable.ps1 new file mode 100644 index 0000000..4bca2be --- /dev/null +++ b/packaging/windows/install-vbcable.ps1 @@ -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 diff --git a/packaging/windows/licenses/VB-CABLE-NOTICE.txt b/packaging/windows/licenses/VB-CABLE-NOTICE.txt new file mode 100644 index 0000000..c3d3150 --- /dev/null +++ b/packaging/windows/licenses/VB-CABLE-NOTICE.txt @@ -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. diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index 26f1908..daafc92 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -28,6 +28,7 @@ param( [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]$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]$NoSign # skip signing (local debug) ) @@ -189,6 +190,29 @@ if (-not $NoDriver) { 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) ------------------------------------ # 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 diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index 14d2123..2b19314 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -41,6 +41,12 @@ #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 ; 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 @@ -93,6 +99,9 @@ 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 +Name: "installaudiocable"; Description: "Install VB-CABLE virtual audio (microphone passthrough - VB-Audio donationware, www.vb-cable.com)" +#endif #ifdef WithVkLayer Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan games like Doom use HDR on the virtual display)" #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. 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 @@ -160,6 +173,15 @@ 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}. Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: "{app}"; \