diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index d26cb8d..92e1fd8 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -106,7 +106,10 @@ fn capture_thread( } let res = (|| -> Result<()> { // Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE - // client with loopback=true over it. + // client with loopback=true over it. NOTE: the virtual mic (`super::wasapi_mic`) is guarded + // to NEVER target this same endpoint — otherwise the client's injected mic would be captured + // here and streamed back to the client (infinite echo). Keep that guard in sync if this + // device selection ever changes. let device = DeviceEnumerator::new() .context("DeviceEnumerator")? .get_default_device(&Direction::Render) diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index 46b4c0e..c89091c 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -5,8 +5,18 @@ //! //! 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 return an -//! error with install guidance and the host runs without mic passthrough. +//! "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. +//! +//! **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 +//! captures the *mixed* output of an endpoint — i.e. everything any app renders to it, including +//! what THIS module writes. So if the virtual-mic target is the same device the loopback captures, +//! the client's uplinked mic is captured straight back into the host→client audio stream: an +//! infinite echo. [`find_device`] therefore **excludes the default render endpoint** from the +//! candidates — the mic is guaranteed to land on a different device. (Linux gets this for free: its +//! mic is a dedicated `Audio/Source` node, structurally separate from the monitored sink.) //! //! `push` enqueues decoded interleaved-f32 PCM into a bounded ring (drop-oldest beyond ~80 ms so mic //! latency stays bounded); a dedicated COM-apartment thread renders it event-driven, filling silence @@ -113,8 +123,23 @@ impl VirtualMic for WasapiVirtualMic { } } -/// Resolve the virtual-mic target among render endpoints by friendly-name. Logs all candidates so a -/// missing device is diagnosable. +/// The endpoint ID of the device the desktop-audio loopback records (the **default render +/// endpoint**, see [`super::wasapi_cap`]). The virtual mic must never target this device — injecting +/// there echoes the client's mic back into the host→client audio stream. `None` if it can't be +/// resolved (then [`find_device`] can't prove a candidate is safe and falls back to name-only +/// matching — no worse than before the guard existed). +fn default_render_id() -> Option { + wasapi::DeviceEnumerator::new() + .ok()? + .get_default_device(&Direction::Render) + .ok()? + .get_id() + .ok() +} + +/// Resolve the virtual-mic target among render endpoints by friendly-name, **excluding the endpoint +/// the loopback captures** (the [`default_render_id`] anti-echo guard). Logs all candidates so a +/// missing/skipped device is diagnosable. fn find_device() -> Result { let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?; let collection = enumerator @@ -124,8 +149,11 @@ fn find_device() -> Result { let want = std::env::var("PUNKTFUNK_MIC_DEVICE") .ok() .map(|s| s.to_lowercase()); + // The device the loopback captures — a name match on it is rejected below (would echo). + let loopback_id = default_render_id(); let mut names = Vec::new(); let mut found = None; + let mut skipped_loopback = false; for i in 0..n { let Ok(dev) = collection.get_device_at_index(i) else { continue; @@ -137,16 +165,37 @@ fn find_device() -> Result { None => CANDIDATES.iter().any(|c| lname.contains(c)), }; if hit && found.is_none() { - found = Some(dev); + // Anti-echo guard: never inject into the endpoint the loopback captures. + let is_loopback = match (dev.get_id().ok(), loopback_id.as_deref()) { + (Some(id), Some(lb)) => id == lb, + _ => false, + }; + if is_loopback { + skipped_loopback = true; + tracing::warn!(device = %name, + "virtual-mic candidate is the loopback (default render) endpoint — skipping; \ + injecting there would echo the client's mic into the desktop-audio stream"); + } else { + found = Some(dev); + } } names.push(name); } found.ok_or_else(|| { - anyhow!( - "no virtual-mic device among render endpoints {names:?}. Install VB-Audio Virtual Cable \ - or enable Steam Remote Play's microphone (Steam Streaming Microphone), or set \ - PUNKTFUNK_MIC_DEVICE=." - ) + if skipped_loopback { + anyhow!( + "the only virtual-mic candidate among render endpoints {names:?} is the default \ + playback device the host loopback-captures — injecting there would echo the mic \ + back to the client. Add a SEPARATE virtual audio device for the mic (e.g. the Steam \ + Streaming Microphone) or set a different default playback device, then reconnect." + ) + } else { + anyhow!( + "no virtual-mic device among render endpoints {names:?}. Install VB-Audio Virtual \ + Cable or enable Steam Remote Play's microphone (Steam Streaming Microphone), or set \ + PUNKTFUNK_MIC_DEVICE=." + ) + } }) } @@ -156,15 +205,15 @@ fn find_or_install_device() -> Result { match find_device() { Ok(d) => Ok(d), Err(e) => { - tracing::info!("no virtual mic device present — attempting auto-install"); - // SAFETY: `try_install_virtual_mic` is `unsafe` only because it `LoadLibraryExW`s + tracing::info!("no usable virtual mic device present — attempting auto-install"); + // SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s // `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer; // calling it imposes no extra precondition here (it takes no args and aliases nothing). // Its internal contract holds: the `DiInstall` type matches the documented // `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a // NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the // dedicated mic thread. - if unsafe { try_install_virtual_mic() } { + if unsafe { install_steam_audio_pair() } { find_device() } else { Err(e) @@ -173,13 +222,26 @@ fn find_or_install_device() -> Result { } } -/// Best-effort: install a virtual mic device so one exists without the user installing anything. -/// Mirrors Apollo's Steam Streaming Speakers install — Steam Remote Play ships -/// `SteamStreamingMicrophone.inf` next to the speakers INF, so install it via `DiInstallDriverW` -/// (loaded from `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). Needs admin (the -/// host runs as SYSTEM). Returns true on success; false (no-op) if Steam isn't installed (INF absent), -/// the install is denied, or `PUNKTFUNK_NO_MIC_INSTALL` is set. -unsafe fn try_install_virtual_mic() -> bool { +/// Best-effort: install BOTH Steam Streaming audio devices (the "Steam pair") so mic passthrough +/// works out of the box and the host has a desktop-audio sink distinct from the mic. Steam Remote +/// Play ships `SteamStreamingMicrophone.inf` + `SteamStreamingSpeakers.inf`: the microphone gives the +/// virtual mic a target whose **capture** endpoint apps record from, and the speakers give a +/// **render** endpoint a headless box can loopback-capture that is NOT the mic — so the loopback and +/// the mic land on different devices and never echo (see [`find_device`]). Returns true if either +/// installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs admin — +/// the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set. +unsafe fn install_steam_audio_pair() -> bool { + // Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink). + let mic = try_install_steam_audio("SteamStreamingMicrophone.inf"); + let spk = try_install_steam_audio("SteamStreamingSpeakers.inf"); + mic || spk +} + +/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from +/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See +/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's +/// per-arch `drivers\Windows10\{arch}\` directory. +unsafe fn try_install_steam_audio(inf_name: &str) -> bool { use windows::core::{s, w, PCWSTR}; use windows::Win32::Foundation::HWND; use windows::Win32::System::Environment::ExpandEnvironmentStringsW; @@ -197,12 +259,11 @@ unsafe fn try_install_virtual_mic() -> bool { let subdir = "arm64"; #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] let subdir = "x86"; - let template: Vec = format!( - "%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\{subdir}\\SteamStreamingMicrophone.inf" - ) - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); + let template: Vec = + format!("%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\{subdir}\\{inf_name}") + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); let mut path = vec![0u16; 1024]; let n = ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())); if n == 0 || n as usize > path.len() { @@ -210,7 +271,7 @@ unsafe fn try_install_virtual_mic() -> bool { } let Ok(newdev) = LoadLibraryExW(w!("newdev.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) else { - tracing::warn!("could not load newdev.dll — virtual-mic auto-install unavailable"); + tracing::warn!("could not load newdev.dll — Steam-audio auto-install unavailable"); return false; }; let Some(addr) = GetProcAddress(newdev, s!("DiInstallDriverW")) else { @@ -226,13 +287,17 @@ unsafe fn try_install_virtual_mic() -> bool { std::ptr::null_mut(), ) != 0; if ok { - tracing::info!("installed the Steam Streaming Microphone virtual device"); + tracing::info!( + inf = inf_name, + "installed a Steam Streaming virtual audio device" + ); std::thread::sleep(Duration::from_secs(5)); // let the audio subsystem register the endpoint } else { let err = windows::Win32::Foundation::GetLastError(); tracing::info!( + inf = inf_name, ?err, - "no virtual mic auto-installed (Steam absent / not admin) — see manual-install guidance" + "Steam-audio device not auto-installed (Steam absent / not admin) — see install guidance" ); } ok