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
|
||||
# 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
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
||||
audio_control::ensure_wired_once();
|
||||
wasapi_cap::WasapiLoopbackCapturer::open(channels)
|
||||
.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")]
|
||||
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>)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/audio_control.rs"]
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[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.
|
||||
//!
|
||||
//! 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",
|
||||
|
||||
Reference in New Issue
Block a user