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:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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`). |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"; \
|
||||||
|
|||||||
Reference in New Issue
Block a user