fix(host/audio): rebuild mic passthrough — eager, self-healing virtual mic on both hosts
apple / swift (push) Successful in 1m7s
ci / rust (push) Successful in 1m57s
ci / web (push) Successful in 59s
android / android (push) Successful in 3m19s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m12s
windows-host / package (push) Successful in 7m2s
ci / bench (push) Successful in 4m52s
decky / build-publish (push) Successful in 14s
deb / build-publish (push) Successful in 4m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m14s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m40s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m28s
apple / swift (push) Successful in 1m7s
ci / rust (push) Successful in 1m57s
ci / web (push) Successful in 59s
android / android (push) Successful in 3m19s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m12s
windows-host / package (push) Successful in 7m2s
ci / bench (push) Successful in 4m52s
decky / build-publish (push) Successful in 14s
deb / build-publish (push) Successful in 4m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m14s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m40s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m28s
Mic passthrough silently died on real hosts. Root causes, all fixed: - No liveness anywhere: a PipeWire restart (Linux) or any WASAPI device error (Windows) killed the backend worker; push() fed the dead queue for the rest of the host's life. VirtualMic now has a liveness contract (push -> bool, alive(), discard()) and the new shared audio::MicPump reopens with backoff, probing on an idle heartbeat so the mic heals BETWEEN sessions too. Validated live: systemctl restart pipewire -> node back in ~0.5 s, tone flows through the reopened backend. - Lazy creation: the mic device didn't exist until the first 0xCB frame, but games bind their capture device at launch and never re-follow. The pump opens eagerly at host start (node exists with zero clients, elected default source). - Windows headless dead-end: with VB-CABLE as the ONLY render endpoint (exactly what the installer ships), the anti-echo guard rejected the cable as the default render endpoint -> mic permanently dead. The new wiring_plan (pure, unit-tested on every platform) assigns the mic its endpoint FIRST (cable reserved for the mic), points the loopback at a DIFFERENT endpoint, and the capture side now yields (explicit endpoint or honest error) instead of the mic dying. Plan recomputed per (re)open — endpoints churn at boot/logon/driver installs. - Stale bursts: buffered audio from a previous session played into a newly-attached recorder (observed live). Timestamped chunks + a consumer-gap check in the process callback age everything past 1 s. The Linux node mechanism stays the stream-based Audio/Source with RT_PROCESS + priority.session: the canonical null-audio-sink adapter recipe was tested on this box (PipeWire 1.6.2) and never gets a clock (QUANT 0 -> pure silence), and WirePlumber reroutes a feeder targeting it to the default sink (echo). Decision documented in the module docs. Live-validated on this box (synthetic host + probe --mic-test, pw-record): eager node, both attach orderings, PipeWire-restart self-heal, post-session silence. Windows side compile/CI + on-glass validation pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,64 +6,39 @@
|
||||
//! 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:
|
||||
//! them up 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
|
||||
//! * the **mic inject target** is assigned FIRST (VB-Cable "CABLE Input" preferred) — mic passthrough
|
||||
//! is what the cable is bundled for, so it wins the cable even when the cable is the only render
|
||||
//! endpoint on the box (the loopback then reports itself unavailable instead of echoing);
|
||||
//! * default **PLAYBACK** → a loopback-capable render that is NOT the mic target (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
|
||||
//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] captures;
|
||||
//! * default **RECORDING** → the mic target'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.
|
||||
//! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every
|
||||
//! platform); this module only enumerates endpoints, applies the plan, and logs. [`wire_now`] runs on
|
||||
//! every mic/capture (re)open — NOT once per process — because endpoints churn (boot-time
|
||||
//! registration, hotplug, driver installs) and a stale plan was one of the ways mic passthrough died
|
||||
//! permanently.
|
||||
//!
|
||||
//! 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.
|
||||
//! defaults untouched (the plan is still computed — the mic must still pick a target).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::wiring_plan::{plan, Endpoint, Wiring};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::Once;
|
||||
use std::sync::Mutex;
|
||||
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)> {
|
||||
fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(en) = wasapi::DeviceEnumerator::new() else {
|
||||
return out;
|
||||
@@ -86,79 +61,85 @@ fn list_endpoints(dir: Direction) -> Vec<(String, String)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Pick the loopback + mic-capture devices and set them as the default playback/recording.
|
||||
fn ensure_audio_wiring() -> Result<()> {
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). Must run on a COM-initialized thread (the WASAPI worker threads all
|
||||
/// `initialize_mta` first). Logged only when the assignment changes, so per-open recomputation
|
||||
/// stays quiet in the steady state.
|
||||
pub(crate) fn wire_now() -> Wiring {
|
||||
let renders = list_endpoints(Direction::Render);
|
||||
let captures = list_endpoints(Direction::Capture);
|
||||
if renders.is_empty() {
|
||||
bail!("no active render endpoints to wire");
|
||||
}
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
let wiring = plan(&renders, &captures, want.as_deref());
|
||||
|
||||
// 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")
|
||||
// Log assignment changes exactly once (first plan included).
|
||||
static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
|
||||
let changed = {
|
||||
let mut last = LAST.lock().unwrap();
|
||||
let changed = last.as_ref() != Some(&wiring);
|
||||
*last = Some(wiring.clone());
|
||||
changed
|
||||
};
|
||||
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 changed {
|
||||
tracing::info!(
|
||||
mic_render = wiring.mic_render.as_ref().map(|(n, _)| n.as_str()),
|
||||
mic_capture = wiring.mic_capture.as_ref().map(|(n, _)| n.as_str()),
|
||||
loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()),
|
||||
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
|
||||
"audio wiring plan"
|
||||
);
|
||||
if wiring.mic_render.is_some() && wiring.loopback_render.is_none() {
|
||||
tracing::warn!(
|
||||
"the virtual mic reserved the only usable render endpoint — desktop audio will be \
|
||||
unavailable until another output device exists (attach one, or let the host \
|
||||
install the Steam Streaming pair)"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = mic_capture {
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
if changed {
|
||||
tracing::info!(
|
||||
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"
|
||||
);
|
||||
}
|
||||
return wiring;
|
||||
}
|
||||
if let Some((name, id)) = &wiring.loopback_render {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)"),
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
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(())
|
||||
wiring
|
||||
}
|
||||
|
||||
/// Open a device by endpoint id, with a name for error context.
|
||||
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
|
||||
.get_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! COM-apartment-bound and not `Send`, so they live on a dedicated thread (mirrors
|
||||
//! `linux::PwAudioCapturer`); only the channel + stop flag + join handle are in the struct.
|
||||
|
||||
use super::{AudioCapturer, SAMPLE_RATE};
|
||||
use super::{audio_control, AudioCapturer, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -109,14 +109,36 @@ 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. 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()
|
||||
// client with loopback=true over it. ECHO GUARD: the wiring plan reserves one endpoint for
|
||||
// the virtual mic (`super::wasapi_mic` writes the client's voice there) — capturing THAT
|
||||
// endpoint would stream the client's own mic straight back to it. Normally the plan has
|
||||
// already moved the default playback elsewhere; if the default still IS the mic target
|
||||
// (PUNKTFUNK_KEEP_DEFAULT, or the cable is the only endpoint), capture the plan's loopback
|
||||
// endpoint explicitly, or refuse — no desktop audio beats an echo loop.
|
||||
let wiring = audio_control::wire_now();
|
||||
let default = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Render)
|
||||
.context("default render endpoint (loopback needs a render device)")?;
|
||||
let default_is_mic = match (&wiring.mic_render, default.get_id()) {
|
||||
(Some((_, mic_id)), Ok(id)) => *mic_id == id,
|
||||
_ => false,
|
||||
};
|
||||
let device = if default_is_mic {
|
||||
let Some(lb) = &wiring.loopback_render else {
|
||||
anyhow::bail!(
|
||||
"the only render endpoint is reserved for the virtual mic (capturing it would \
|
||||
echo the client's voice back) — attach another output device or install the \
|
||||
Steam Streaming pair to get desktop audio"
|
||||
);
|
||||
};
|
||||
tracing::warn!(mic = %wiring.mic_render.as_ref().unwrap().0, loopback = %lb.0,
|
||||
"default render endpoint is the virtual-mic target — loopback-capturing the plan's \
|
||||
endpoint instead");
|
||||
audio_control::open_endpoint(lb)?
|
||||
} else {
|
||||
default
|
||||
};
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
// 48 kHz f32 interleaved in the requested channel layout; autoconvert lets WASAPI's
|
||||
// shared-mode SRC match the engine mix format to ours (incl. up/downmix to the requested
|
||||
|
||||
@@ -3,22 +3,21 @@
|
||||
//! device and write the client's decoded mic PCM into that device's **render** endpoint; the device's
|
||||
//! **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`):
|
||||
//! 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.
|
||||
//! The target comes from the [`audio_control::wire_now`] plan (recomputed on every open): VB-Audio
|
||||
//! "CABLE Input" (bundled by the installer — the dedicated mic target), the Steam Streaming
|
||||
//! Microphone, VoiceMeeter, or anything with "virtual" in the name; `PUNKTFUNK_MIC_DEVICE` overrides.
|
||||
//! The plan reserves the mic target and points the desktop-audio loopback at a DIFFERENT endpoint, so
|
||||
//! injecting here can never echo into the host→client audio stream (see
|
||||
//! [`wiring_plan`](super::wiring_plan) for the precedence rules and the headless cable-only case).
|
||||
//! 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
|
||||
//! caller (the mic pump) retries with backoff — a cable that appears later (driver install finishing
|
||||
//! after boot) is picked up without a host restart.
|
||||
//!
|
||||
//! **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.)
|
||||
//! **Liveness.** Any WASAPI error in the render loop (endpoint invalidated/removed, audio engine
|
||||
//! restart) exits the worker thread, which flips the `alive` flag — [`VirtualMic::push`] then
|
||||
//! returns `false` and the pump reopens (re-planning, so endpoint churn re-resolves). Before this
|
||||
//! existed, the first device change silently killed mic passthrough for the rest of the host's life.
|
||||
//!
|
||||
//! `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
|
||||
@@ -28,7 +27,7 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{VirtualMic, SAMPLE_RATE};
|
||||
use super::{audio_control, VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -44,19 +43,11 @@ const BLOCK_ALIGN: usize = 2 * 4;
|
||||
/// Bound the inject queue at ~80 ms so the passed-through mic stays low-latency (drop oldest beyond).
|
||||
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",
|
||||
"voicemeeter input",
|
||||
"voicemeeter aux input",
|
||||
"virtual",
|
||||
];
|
||||
|
||||
pub struct WasapiVirtualMic {
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// False once the render thread has exited (device error or stop) — the pump's reopen signal.
|
||||
alive: Arc<AtomicBool>,
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -68,25 +59,29 @@ impl WasapiVirtualMic {
|
||||
);
|
||||
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
// Bring-up handshake: report the resolved device (or the error) before returning, so a missing
|
||||
// virtual-mic device surfaces as Err (the caller retries with backoff) not a silent dead thread.
|
||||
let (ready_tx, ready_rx) = sync_channel::<Result<String>>(1);
|
||||
let (q, st) = (queue.clone(), stop.clone());
|
||||
let (q, st, al) = (queue.clone(), stop.clone(), alive.clone());
|
||||
let join = thread::Builder::new()
|
||||
.name("punktfunk-wasapi-mic".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = render_thread(q, st, ready_tx) {
|
||||
tracing::error!(error = %format!("{e:#}"), "wasapi virtual-mic thread failed");
|
||||
}
|
||||
// Normal stop or device error alike: this instance is done — the pump reopens.
|
||||
al.store(false, Ordering::Release);
|
||||
})
|
||||
.context("spawn wasapi mic thread")?;
|
||||
match ready_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
match ready_rx.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(Ok(name)) => {
|
||||
tracing::info!(device = %name,
|
||||
"WASAPI virtual mic ready (client mic → this device's render endpoint)");
|
||||
Ok(WasapiVirtualMic {
|
||||
queue,
|
||||
stop,
|
||||
alive,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
@@ -106,9 +101,12 @@ impl Drop for WasapiVirtualMic {
|
||||
}
|
||||
|
||||
impl VirtualMic for WasapiVirtualMic {
|
||||
fn push(&self, pcm: &[f32]) {
|
||||
fn push(&self, pcm: &[f32]) -> bool {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut q) = self.queue.lock() else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
q.reserve(pcm.len() * 4);
|
||||
for &s in pcm {
|
||||
@@ -119,109 +117,50 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
let excess = q.len() - MAX_QUEUE_BYTES;
|
||||
q.drain(..excess);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn discard(&self) {
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
q.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(&self) -> u32 {
|
||||
CHANNELS
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<wasapi::Device> {
|
||||
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
|
||||
let collection = enumerator
|
||||
.get_device_collection(&Direction::Render)
|
||||
.context("render device collection")?;
|
||||
let n = collection.get_nbr_devices().context("device count")?;
|
||||
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;
|
||||
};
|
||||
let name = dev.get_friendlyname().unwrap_or_default();
|
||||
let lname = name.to_lowercase();
|
||||
let hit = match &want {
|
||||
Some(w) => lname.contains(w),
|
||||
None => CANDIDATES.iter().any(|c| lname.contains(c)),
|
||||
};
|
||||
if hit && found.is_none() {
|
||||
// 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(|| {
|
||||
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=<friendly-name substring>."
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the virtual-mic device, and if none exists, try to AUTO-INSTALL one so mic passthrough works
|
||||
/// out of the box (then re-find). Falls back to the guidance error if nothing can be installed.
|
||||
fn find_or_install_device() -> Result<wasapi::Device> {
|
||||
match find_device() {
|
||||
Ok(d) => Ok(d),
|
||||
Err(e) => {
|
||||
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 { install_steam_audio_pair() } {
|
||||
find_device()
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
/// Resolve the mic inject target from the wiring plan, auto-installing the Steam Streaming pair
|
||||
/// when nothing usable exists (then re-planning). Runs on the COM-initialized render thread.
|
||||
fn resolve_target() -> Result<(wasapi::Device, String)> {
|
||||
let mut wiring = audio_control::wire_now();
|
||||
if wiring.mic_render.is_none() {
|
||||
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 { install_steam_audio_pair() } {
|
||||
wiring = audio_control::wire_now();
|
||||
}
|
||||
}
|
||||
let Some(ep) = wiring.mic_render else {
|
||||
anyhow::bail!(
|
||||
"no virtual-mic render endpoint on this box. Install VB-Audio Virtual Cable (the host \
|
||||
installer bundles it) or enable Steam Remote Play's microphone (Steam Streaming \
|
||||
Microphone), or set PUNKTFUNK_MIC_DEVICE=<friendly-name substring>."
|
||||
);
|
||||
};
|
||||
let name = ep.0.clone();
|
||||
Ok((audio_control::open_endpoint(&ep)?, name))
|
||||
}
|
||||
|
||||
/// Best-effort: install BOTH Steam Streaming audio devices (the "Steam pair") so mic passthrough
|
||||
@@ -229,9 +168,9 @@ fn find_or_install_device() -> Result<wasapi::Device> {
|
||||
/// 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.
|
||||
/// the mic land on different devices and never echo (see [`super::wiring_plan`]). 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");
|
||||
@@ -320,8 +259,7 @@ fn render_thread(
|
||||
// Open + start the render stream. The WASAPI objects must outlive the loop, so build them here and
|
||||
// keep them (a closure that *returned* them would drop them); on any failure report Err and exit.
|
||||
let setup = (|| -> Result<(wasapi::AudioClient, wasapi::AudioRenderClient, wasapi::Handle, String)> {
|
||||
let device = find_or_install_device()?;
|
||||
let name = device.get_friendlyname().unwrap_or_else(|_| "virtual mic".into());
|
||||
let (device, name) = resolve_target()?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
// 48 kHz stereo f32; autoconvert lets WASAPI shared-mode SRC match the device mix format.
|
||||
let desired = WaveFormat::new(
|
||||
@@ -359,6 +297,8 @@ fn render_thread(
|
||||
};
|
||||
let _ = ready.send(Ok(name));
|
||||
|
||||
// Any error below (endpoint invalidated/removed, engine restart) propagates out of the loop,
|
||||
// ending the thread — the `alive` flag flips in the spawn wrapper and the pump reopens.
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// The device signals when it wants more data; finite timeout keeps `stop` responsive.
|
||||
|
||||
Reference in New Issue
Block a user