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:
@@ -42,7 +42,8 @@ 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();
|
||||
// The capture thread runs the audio wiring plan itself (audio_control::wire_now) before
|
||||
// resolving its endpoint — a fresh plan per open, because Windows endpoints churn.
|
||||
wasapi_cap::WasapiLoopbackCapturer::open(channels)
|
||||
.map(|c| Box::new(c) as Box<dyn AudioCapturer>)
|
||||
}
|
||||
@@ -57,10 +58,27 @@ pub fn open_audio_capture(_channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
||||
/// decoded client-mic PCM (interleaved `f32` at [`SAMPLE_RATE`]) into it, and PipeWire delivers
|
||||
/// it to whichever app records the source — silence when no input is flowing. This is how the
|
||||
/// client's microphone reaches host applications (mic passthrough).
|
||||
///
|
||||
/// **Liveness contract.** Both backends run a worker thread that CAN die under the host's feet
|
||||
/// (Linux: the PipeWire daemon restarts with the session; Windows: the audio endpoint is
|
||||
/// invalidated/removed). A dead backend must be observable — [`push`](Self::push) returns `false`
|
||||
/// and [`alive`](Self::alive) turns false — so the owning [`MicPump`] drops the instance and
|
||||
/// reopens. Before this contract existed, a single backend death left `push` feeding a dead
|
||||
/// queue for the rest of the host's life: the historical "mic passthrough works on no host" bug.
|
||||
pub trait VirtualMic: Send {
|
||||
/// Push one chunk of interleaved `f32` PCM. Non-blocking — drops if PipeWire is behind
|
||||
/// (mic audio is lossy/real-time; a stale chunk is worse than a dropped one).
|
||||
fn push(&self, pcm: &[f32]);
|
||||
/// Push one chunk of interleaved `f32` PCM. Non-blocking — drops if the backend is behind
|
||||
/// (mic audio is lossy/real-time; a stale chunk is worse than a dropped one). Returns
|
||||
/// `false` iff the backend is DEAD (worker thread gone) — the caller must reopen; a merely
|
||||
/// congested backend drops the chunk and returns `true`.
|
||||
fn push(&self, pcm: &[f32]) -> bool;
|
||||
|
||||
/// Backend liveness without pushing data — lets an idle pump notice a death between
|
||||
/// sessions, so the mic is already healthy again when the next client connects.
|
||||
fn alive(&self) -> bool;
|
||||
|
||||
/// Drop any buffered-but-unplayed audio. Called after an uplink gap (client muted,
|
||||
/// session ended) so a recorder never hears a stale burst when audio resumes.
|
||||
fn discard(&self);
|
||||
|
||||
/// The interleaved channel count the source was opened with.
|
||||
fn channels(&self) -> u32 {
|
||||
@@ -78,7 +96,8 @@ 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();
|
||||
// The render thread runs the wiring plan itself (audio_control::wire_now) to resolve — and,
|
||||
// via the plan's default-device changes, to RESERVE — its target endpoint.
|
||||
wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box<dyn VirtualMic>)
|
||||
}
|
||||
|
||||
@@ -87,6 +106,189 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device")
|
||||
}
|
||||
|
||||
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
|
||||
pub const MIC_CHANNELS: u32 = 2;
|
||||
/// Bound for the shared mic frame queue (drop-newest when full): the host-lifetime queue is
|
||||
/// shared across all concurrent sessions and must not grow without limit under a near-line-rate
|
||||
/// flood (security-review 2026-06-28 S6). 64 × 5–20 ms frames ≈ 0.3–1.3 s of slack.
|
||||
const MIC_QUEUE_CAP: usize = 64;
|
||||
|
||||
/// Tuning for [`MicPump`]'s open/reopen/flush behaviour — parameterized so the tests can run the
|
||||
/// real pump loop in milliseconds instead of seconds.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PumpTuning {
|
||||
/// First-retry delay after a failed backend open; doubles per failure up to `backoff_cap`
|
||||
/// (a persistently-absent PipeWire session / audio endpoint isn't hammered), resets on
|
||||
/// success.
|
||||
backoff_start: std::time::Duration,
|
||||
backoff_cap: std::time::Duration,
|
||||
/// Idle liveness-probe interval: with no frames flowing, the pump still notices a dead
|
||||
/// backend this often and reopens — so the mic is healthy BEFORE the next session starts.
|
||||
heartbeat: std::time::Duration,
|
||||
/// An uplink gap longer than this discards the backend's buffered audio before pushing the
|
||||
/// next frame (a recorder must never hear a stale burst from before a mute/session end).
|
||||
stale_gap: std::time::Duration,
|
||||
}
|
||||
|
||||
const PUMP_TUNING: PumpTuning = PumpTuning {
|
||||
backoff_start: std::time::Duration::from_secs(2),
|
||||
backoff_cap: std::time::Duration::from_secs(60),
|
||||
heartbeat: std::time::Duration::from_secs(1),
|
||||
stale_gap: std::time::Duration::from_millis(600),
|
||||
};
|
||||
|
||||
/// Host-lifetime virtual-microphone pump: one thread owns the [`VirtualMic`] backend + an Opus
|
||||
/// decoder; sessions forward the client's Opus mic frames (0xCB) over a clonable `Send` sender,
|
||||
/// the thread decodes and feeds the backend.
|
||||
///
|
||||
/// The rock-solid properties live HERE, not in the backends:
|
||||
/// - **Eager**: the backend opens at host start (retrying with backoff), NOT on the first mic
|
||||
/// frame — so the virtual mic device already exists when host apps/games launch and bind
|
||||
/// their capture device (most games never re-follow a default-device change mid-run).
|
||||
/// - **Self-healing**: a dead backend (PipeWire restart, Windows endpoint churn) is detected on
|
||||
/// every push and on an idle heartbeat, and reopened with backoff. Sessions keep their
|
||||
/// senders; nothing upstream notices.
|
||||
/// - **Stale-flush**: buffered audio is discarded after an uplink gap (see [`PumpTuning`]).
|
||||
///
|
||||
/// Per-frame Opus DECODE errors stay non-fatal (dropped frame): the mic is shared across every
|
||||
/// concurrent session, so one paired client's junk frames must not deny everyone's mic
|
||||
/// (security-review 2026-06-28 S2). The thread exits when every sender is dropped (host
|
||||
/// shutdown), tearing the backend down.
|
||||
pub struct MicPump {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MicPump {
|
||||
/// Start the host-lifetime pump (Linux/Windows). On platforms without a virtual-mic backend
|
||||
/// the thread just drains and drops frames (sessions still count the datagrams).
|
||||
pub fn start() -> MicPump {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("punktfunk-mic-pump".into())
|
||||
.spawn(move || {
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pump_thread(rx, || open_virtual_mic(MIC_CHANNELS), PUMP_TUNING);
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
tracing::warn!("mic passthrough unsupported on this platform — frames dropped");
|
||||
for _ in rx {}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::error!(error = %e, "mic pump thread spawn failed — mic passthrough disabled");
|
||||
}
|
||||
MicPump { tx }
|
||||
}
|
||||
|
||||
/// A sender a session forwards the client's Opus mic frames to (`try_send` — never block a
|
||||
/// datagram loop). Cloned per session; dropping a clone does NOT stop the pump (it holds
|
||||
/// the original sender for the host life).
|
||||
pub fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The pump loop. `opener` is injected so the tests can run the REAL loop against a mock
|
||||
/// backend; production passes [`open_virtual_mic`].
|
||||
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
|
||||
fn pump_thread<O>(rx: std::sync::mpsc::Receiver<Vec<u8>>, opener: O, tuning: PumpTuning)
|
||||
where
|
||||
O: Fn() -> Result<Box<dyn VirtualMic>>,
|
||||
{
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
use std::time::Instant;
|
||||
|
||||
let mut backoff = tuning.backoff_start;
|
||||
let mut open_fails: u64 = 0;
|
||||
'reopen: loop {
|
||||
// Open phase — eager, from thread start. While closed, keep draining the queue so a
|
||||
// reopen never replays a backlog of stale frames (and senders never see a wedged queue).
|
||||
let (mic, mut decoder) = loop {
|
||||
let opened = opener().and_then(|m| {
|
||||
let d = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo)
|
||||
.map_err(|e| anyhow::anyhow!("opus decoder: {e}"))?;
|
||||
Ok((m, d))
|
||||
});
|
||||
match opened {
|
||||
Ok(pair) => break pair,
|
||||
Err(e) => {
|
||||
// Throttle (1st, 2nd, 4th, 8th … failure): a box without a PipeWire session
|
||||
// or virtual audio device would otherwise log every backoff forever.
|
||||
open_fails += 1;
|
||||
if open_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %format!("{e:#}"), attempts = open_fails,
|
||||
"virtual mic unavailable — retrying with backoff");
|
||||
}
|
||||
let deadline = Instant::now() + backoff;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
break;
|
||||
}
|
||||
match rx.recv_timeout(left.min(std::time::Duration::from_millis(250))) {
|
||||
Ok(_) => {} // drop frames while closed
|
||||
Err(RecvTimeoutError::Timeout) => {} // keep waiting
|
||||
Err(RecvTimeoutError::Disconnected) => return, // host shutdown
|
||||
}
|
||||
}
|
||||
backoff = (backoff * 2).min(tuning.backoff_cap);
|
||||
}
|
||||
}
|
||||
};
|
||||
backoff = tuning.backoff_start;
|
||||
open_fails = 0;
|
||||
tracing::info!("virtual mic ready (host-lifetime)");
|
||||
// Drop anything queued while (re)opening — it predates the backend.
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
let mut decode_fails: u64 = 0;
|
||||
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
|
||||
let mut last_push = Instant::now();
|
||||
loop {
|
||||
match rx.recv_timeout(tuning.heartbeat) {
|
||||
Ok(frame) => {
|
||||
if frame.is_empty() {
|
||||
continue; // DTX silence — the source underruns to silence on its own
|
||||
}
|
||||
if last_push.elapsed() > tuning.stale_gap {
|
||||
mic.discard();
|
||||
}
|
||||
match decoder.decode_float(&frame, &mut pcm, false) {
|
||||
Ok(samples_per_ch) => {
|
||||
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
|
||||
if !mic.push(&pcm[..total]) {
|
||||
tracing::warn!("virtual mic backend died — reopening");
|
||||
continue 'reopen;
|
||||
}
|
||||
last_push = Instant::now();
|
||||
decode_fails = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
// Malformed/garbage frame: drop it, keep the shared mic + decoder
|
||||
// (see the struct docs). Throttled log (1, 2, 4, … fails).
|
||||
decode_fails += 1;
|
||||
if decode_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %e, fails = decode_fails,
|
||||
"mic opus decode failed — dropping frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if !mic.alive() {
|
||||
tracing::warn!("virtual mic backend died while idle — reopening");
|
||||
continue 'reopen;
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
tracing::debug!("mic pump stopped (host shutting down)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/audio_control.rs"]
|
||||
mod audio_control;
|
||||
@@ -98,3 +300,188 @@ mod wasapi_cap;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_mic.rs"]
|
||||
mod wasapi_mic;
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
#[path = "audio/wiring_plan.rs"]
|
||||
pub(crate) mod wiring_plan;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pump_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Mock backend: records pushes/discards, dies on command.
|
||||
struct MockMic {
|
||||
alive: Arc<AtomicBool>,
|
||||
pushed: Arc<AtomicUsize>,
|
||||
discards: Arc<AtomicUsize>,
|
||||
}
|
||||
impl VirtualMic for MockMic {
|
||||
fn push(&self, pcm: &[f32]) -> bool {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
self.pushed.fetch_add(pcm.len(), Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
fn alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
fn discard(&self) {
|
||||
self.discards.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
opens: Arc<AtomicUsize>,
|
||||
alive: Arc<Mutex<Option<Arc<AtomicBool>>>>, // latest instance's kill switch
|
||||
pushed: Arc<AtomicUsize>,
|
||||
discards: Arc<AtomicUsize>,
|
||||
join: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Run the REAL pump loop against mock backends; `fail_first` opens fail before the first
|
||||
/// success (exercises the eager retry/backoff path).
|
||||
fn start(fail_first: usize) -> Harness {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
let opens = Arc::new(AtomicUsize::new(0));
|
||||
let alive = Arc::new(Mutex::new(None::<Arc<AtomicBool>>));
|
||||
let pushed = Arc::new(AtomicUsize::new(0));
|
||||
let discards = Arc::new(AtomicUsize::new(0));
|
||||
let (opens2, alive2, pushed2, discards2) = (
|
||||
opens.clone(),
|
||||
alive.clone(),
|
||||
pushed.clone(),
|
||||
discards.clone(),
|
||||
);
|
||||
let tuning = PumpTuning {
|
||||
backoff_start: Duration::from_millis(10),
|
||||
backoff_cap: Duration::from_millis(40),
|
||||
heartbeat: Duration::from_millis(20),
|
||||
stale_gap: Duration::from_millis(80),
|
||||
};
|
||||
let join = std::thread::spawn(move || {
|
||||
pump_thread(
|
||||
rx,
|
||||
move || {
|
||||
let n = opens2.fetch_add(1, Ordering::SeqCst);
|
||||
if n < fail_first {
|
||||
anyhow::bail!("backend not up yet (simulated)");
|
||||
}
|
||||
let a = Arc::new(AtomicBool::new(true));
|
||||
*alive2.lock().unwrap() = Some(a.clone());
|
||||
Ok(Box::new(MockMic {
|
||||
alive: a,
|
||||
pushed: pushed2.clone(),
|
||||
discards: discards2.clone(),
|
||||
}) as Box<dyn VirtualMic>)
|
||||
},
|
||||
tuning,
|
||||
)
|
||||
});
|
||||
Harness {
|
||||
tx,
|
||||
opens,
|
||||
alive,
|
||||
pushed,
|
||||
discards,
|
||||
join,
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
|
||||
for _ in 0..200 {
|
||||
if cond() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
panic!("timed out waiting for: {what}");
|
||||
}
|
||||
|
||||
fn opus_frame() -> Vec<u8> {
|
||||
let mut enc = opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip)
|
||||
.expect("opus encoder");
|
||||
let pcm = [0.1f32; 960 * 2]; // 20 ms stereo
|
||||
let mut out = vec![0u8; 4000];
|
||||
let n = enc.encode_float(&pcm, &mut out).expect("encode");
|
||||
out.truncate(n);
|
||||
out
|
||||
}
|
||||
|
||||
/// Eager: the backend opens (after transient failures) with NO frame ever sent.
|
||||
#[test]
|
||||
fn opens_eagerly_with_backoff() {
|
||||
let h = start(3);
|
||||
wait_until("eager open after 3 failures", || {
|
||||
h.opens.load(Ordering::SeqCst) >= 4 && h.alive.lock().unwrap().is_some()
|
||||
});
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// Frames flow: opus in → PCM pushed to the backend.
|
||||
#[test]
|
||||
fn decodes_and_pushes() {
|
||||
let h = start(0);
|
||||
wait_until("open", || h.alive.lock().unwrap().is_some());
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("pcm pushed", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// A dead backend is noticed WHILE IDLE (heartbeat) and reopened without any traffic.
|
||||
#[test]
|
||||
fn reopens_after_idle_death() {
|
||||
let h = start(0);
|
||||
wait_until("first open", || h.opens.load(Ordering::SeqCst) >= 1);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.alive
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store(false, Ordering::Release); // kill it
|
||||
wait_until("reopen after idle death", || {
|
||||
h.opens.load(Ordering::SeqCst) >= 2
|
||||
});
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// A death detected on push (frame flowing) also reopens, and the frame after reopen flows.
|
||||
#[test]
|
||||
fn reopens_after_push_death() {
|
||||
let h = start(0);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.alive
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store(false, Ordering::Release);
|
||||
h.tx.send(opus_frame()).unwrap(); // push sees death → reopen
|
||||
wait_until("reopen", || h.opens.load(Ordering::SeqCst) >= 2);
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("pcm after reopen", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
|
||||
/// An uplink gap discards buffered-stale audio before the next frame plays.
|
||||
#[test]
|
||||
fn discards_after_gap() {
|
||||
let h = start(0);
|
||||
wait_until("instance", || h.alive.lock().unwrap().is_some());
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("first push", || h.pushed.load(Ordering::SeqCst) > 0);
|
||||
std::thread::sleep(Duration::from_millis(150)); // > stale_gap
|
||||
h.tx.send(opus_frame()).unwrap();
|
||||
wait_until("discard on gap", || h.discards.load(Ordering::SeqCst) >= 1);
|
||||
drop(h.tx);
|
||||
h.join.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
use super::{AudioCapturer, VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -111,10 +113,28 @@ fn spa_positions(channels: u32) -> [u32; 64] {
|
||||
/// Virtual microphone: a PipeWire `Audio/Source` node host apps can record from. The host pushes
|
||||
/// decoded client-mic PCM in; the loop thread's producer callback drains it (silence on
|
||||
/// underrun) into PipeWire buffers. Mirrors [`PwAudioCapturer`] but inverted (Direction::Output).
|
||||
///
|
||||
/// **Why a stream node and not a `support.null-audio-sink` adapter** (the canonical
|
||||
/// virtual-mic recipe): tested live on this project's headless graph (PipeWire 1.6.2,
|
||||
/// 2026-07-03), an adapter with `media.class=Audio/Source/Virtual` never gets a clock — the
|
||||
/// {source, recorder} group runs with QUANT/RATE 0 and delivers pure silence — and WirePlumber
|
||||
/// rerouted a feeder stream targeting it to the *default sink* instead (which would play the
|
||||
/// client's voice out of the speakers, straight into the desktop-audio capture: echo). The
|
||||
/// stream node below, with `RT_PROCESS` + `priority.session` (see the property comments), is
|
||||
/// validated working on PipeWire 1.4 (Bazzite) and 1.6 (this box) in both attach orderings.
|
||||
/// Do not "modernize" this to the adapter recipe without re-running that validation.
|
||||
///
|
||||
/// **Liveness contract** (see [`VirtualMic`]): the loop thread exits on a core error (PipeWire
|
||||
/// daemon restart — the node is gone) or a stream error, which flips `alive` — `push` then
|
||||
/// returns `false` and the owning pump reopens against the new daemon, recreating the node.
|
||||
pub struct PwMicSource {
|
||||
pcm: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
pcm: std::sync::mpsc::SyncSender<(std::time::Instant, Vec<f32>)>,
|
||||
channels: u32,
|
||||
quit: pipewire::channel::Sender<Terminate>,
|
||||
/// False once the loop thread has exited (daemon/stream death or teardown).
|
||||
alive: Arc<AtomicBool>,
|
||||
/// One-shot flush request, consumed by the process callback (clears the jitter ring).
|
||||
flush: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl PwMicSource {
|
||||
@@ -123,20 +143,27 @@ impl PwMicSource {
|
||||
matches!(channels, 1 | 2),
|
||||
"virtual mic supports 1 or 2 channels, got {channels}"
|
||||
);
|
||||
let (pcm_tx, pcm_rx) = sync_channel::<Vec<f32>>(64);
|
||||
let (pcm_tx, pcm_rx) = sync_channel::<(std::time::Instant, Vec<f32>)>(64);
|
||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let flush = Arc::new(AtomicBool::new(false));
|
||||
let (alive_t, flush_t) = (alive.clone(), flush.clone());
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-pw-mic".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels) {
|
||||
if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels, flush_t) {
|
||||
tracing::error!(error = %format!("{e:#}"), "pipewire virtual-mic thread failed");
|
||||
}
|
||||
// Whether a clean quit or a daemon death: this instance is done — the pump reopens.
|
||||
alive_t.store(false, Ordering::Release);
|
||||
})
|
||||
.context("spawn pipewire virtual-mic thread")?;
|
||||
Ok(PwMicSource {
|
||||
pcm: pcm_tx,
|
||||
channels,
|
||||
quit: quit_tx,
|
||||
alive,
|
||||
flush,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -148,8 +175,24 @@ impl Drop for PwMicSource {
|
||||
}
|
||||
|
||||
impl VirtualMic for PwMicSource {
|
||||
fn push(&self, pcm: &[f32]) {
|
||||
let _ = self.pcm.try_send(pcm.to_vec()); // drop if the PipeWire side is behind
|
||||
fn push(&self, pcm: &[f32]) -> bool {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
// Timestamped so the process callback can age out chunks that sat in the channel while
|
||||
// no recorder was attached (see the staleness logic there).
|
||||
match self.pcm.try_send((std::time::Instant::now(), pcm.to_vec())) {
|
||||
Ok(()) => true,
|
||||
// Behind is fine (drop the chunk); a gone receiver means the loop thread exited.
|
||||
Err(std::sync::mpsc::TrySendError::Full(_)) => true,
|
||||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
|
||||
}
|
||||
}
|
||||
fn alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
fn discard(&self) {
|
||||
self.flush.store(true, Ordering::Release);
|
||||
}
|
||||
fn channels(&self) -> u32 {
|
||||
self.channels
|
||||
@@ -160,16 +203,27 @@ impl VirtualMic for PwMicSource {
|
||||
/// the process callback drains into PipeWire buffers (capped, so latency stays bounded).
|
||||
/// `primed` is a jitter buffer gate — see the process callback.
|
||||
struct MicUserData {
|
||||
rx: Receiver<Vec<f32>>,
|
||||
rx: Receiver<(std::time::Instant, Vec<f32>)>,
|
||||
ring: VecDeque<f32>,
|
||||
channels: usize,
|
||||
primed: bool,
|
||||
/// One-shot flush request from [`PwMicSource::discard`] (stale-audio drop after a gap).
|
||||
flush: Arc<AtomicBool>,
|
||||
/// When the process callback last ran — a long gap means the ring content predates the
|
||||
/// current consumer (the stream idles with no recorder attached) and must be dropped.
|
||||
last_run: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// PCM older than this never reaches a recorder: chunks that aged in the channel while no
|
||||
/// recorder was attached, and ring content from before a consumer gap, are dropped instead of
|
||||
/// bursting out as stale audio when recording (re)starts.
|
||||
const MIC_STALE: Duration = Duration::from_secs(1);
|
||||
|
||||
fn mic_pw_thread(
|
||||
pcm_rx: Receiver<Vec<f32>>,
|
||||
pcm_rx: Receiver<(std::time::Instant, Vec<f32>)>,
|
||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||
channels: u32,
|
||||
flush: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
@@ -188,6 +242,26 @@ fn mic_pw_thread(
|
||||
move |_| mainloop.quit()
|
||||
});
|
||||
|
||||
// Death detection: a core error (the daemon restarted/went away — our remote node no longer
|
||||
// exists) ends this thread, flipping the owner's `alive` flag so the pump reopens against the
|
||||
// new daemon. Without this, a PipeWire restart left the loop idling on a dead connection and
|
||||
// the mic silently broken for the rest of the host's life.
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.error({
|
||||
let mainloop = mainloop.clone();
|
||||
move |id, _seq, res, message| {
|
||||
tracing::warn!(
|
||||
id,
|
||||
res,
|
||||
message,
|
||||
"pipewire core error — virtual mic reopening"
|
||||
);
|
||||
mainloop.quit();
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
// media.class=Audio/Source advertises us as a microphone (a recordable source), NOT a
|
||||
// playback stream — without it, Direction::Output + Playback would route to the speakers.
|
||||
let stream = pw::stream::StreamBox::new(
|
||||
@@ -226,12 +300,21 @@ fn mic_pw_thread(
|
||||
ring: VecDeque::new(),
|
||||
channels: channels as usize,
|
||||
primed: false,
|
||||
flush,
|
||||
last_run: None,
|
||||
};
|
||||
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(ud)
|
||||
.state_changed(|_s, _ud, old, new| {
|
||||
.state_changed({
|
||||
let mainloop = mainloop.clone();
|
||||
move |_s, _ud, old, new| {
|
||||
tracing::info!(?old, ?new, "pipewire virtual-mic stream state");
|
||||
// A stream error is unrecoverable for this instance — exit so the pump reopens.
|
||||
if matches!(new, pw::stream::StreamState::Error(_)) {
|
||||
mainloop.quit();
|
||||
}
|
||||
}
|
||||
})
|
||||
.param_changed(|_s, _ud, id, param| {
|
||||
let Some(param) = param else { return };
|
||||
@@ -253,10 +336,26 @@ fn mic_pw_thread(
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
// Pull all newly-decoded PCM into the ring.
|
||||
while let Ok(frame) = ud.rx.try_recv() {
|
||||
// Stale-audio guard, BEFORE pulling new frames: drop the ring when a flush was
|
||||
// requested (uplink gap — see the pump) or when this callback itself hasn't run
|
||||
// for a while (the stream idled with no recorder attached; whatever the ring
|
||||
// holds predates the consumer). A recorder must never hear a burst of old audio.
|
||||
let now = std::time::Instant::now();
|
||||
let idled = ud
|
||||
.last_run
|
||||
.is_some_and(|t| now.duration_since(t) > MIC_STALE);
|
||||
if ud.flush.swap(false, std::sync::atomic::Ordering::AcqRel) || idled {
|
||||
ud.ring.clear();
|
||||
ud.primed = false;
|
||||
}
|
||||
ud.last_run = Some(now);
|
||||
// Pull all newly-decoded PCM into the ring, aging out chunks that sat in the
|
||||
// channel while nothing consumed them (same staleness rule).
|
||||
while let Ok((t, frame)) = ud.rx.try_recv() {
|
||||
if now.duration_since(t) <= MIC_STALE {
|
||||
ud.ring.extend(frame);
|
||||
}
|
||||
}
|
||||
let stride = 4 * ud.channels; // F32LE interleaved
|
||||
let datas = buffer.datas_mut();
|
||||
if datas.is_empty() {
|
||||
|
||||
@@ -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());
|
||||
|
||||
// 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
|
||||
};
|
||||
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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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"),
|
||||
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(()) => {
|
||||
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"),
|
||||
},
|
||||
None => {
|
||||
tracing::warn!("audio wiring: no usable desktop-audio loopback render endpoint found")
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = mic_capture {
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
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 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,94 +117,29 @@ 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) => {
|
||||
/// 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;
|
||||
@@ -216,12 +149,18 @@ fn find_or_install_device() -> Result<wasapi::Device> {
|
||||
// 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)
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Windows audio endpoint assignment — the PURE planning logic behind
|
||||
//! [`audio_control`](super::audio_control), split out so it compiles (and its unit tests run) on
|
||||
//! every platform: the precedence rules here encode the hard-won field knowledge, and regressing
|
||||
//! them must fail CI on Linux too, not only on a Windows box.
|
||||
//!
|
||||
//! Two jobs share the render endpoints and must never collide:
|
||||
//!
|
||||
//! * the **virtual mic** writes the client's decoded mic PCM into a virtual cable's render
|
||||
//! endpoint (its capture side surfaces as a host microphone), and
|
||||
//! * the **desktop-audio loopback** captures a render endpoint's mix for the host→client
|
||||
//! audio stream.
|
||||
//!
|
||||
//! WASAPI loopback captures *everything* an endpoint renders — including what the virtual mic
|
||||
//! writes — so if both land on the same device the client's voice echoes straight back into the
|
||||
//! client's own audio stream. The plan therefore assigns the mic its endpoint FIRST (VB-CABLE is
|
||||
//! bundled by the installer for exactly this) and gives the loopback a *different* one; when only
|
||||
//! the cable exists (headless box, no other output), the MIC wins and the loopback is honestly
|
||||
//! unavailable. The old code did the opposite — the mic refused the cable because it was the
|
||||
//! default render endpoint — which permanently killed mic passthrough in the exact configuration
|
||||
//! the installer ships (VB-CABLE as the only render device).
|
||||
|
||||
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
|
||||
pub(crate) type Endpoint = (String, String);
|
||||
|
||||
/// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture
|
||||
/// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a
|
||||
/// once-per-process plan goes stale.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct Wiring {
|
||||
/// Render endpoint RESERVED for the virtual mic (the write target). The loopback must never
|
||||
/// capture this device.
|
||||
pub mic_render: Option<Endpoint>,
|
||||
/// The mic device's CAPTURE side — host apps record this; made the default recording device.
|
||||
pub mic_capture: Option<Endpoint>,
|
||||
/// Render endpoint for the desktop-audio loopback; made the default playback device.
|
||||
pub loopback_render: Option<Endpoint>,
|
||||
}
|
||||
|
||||
/// Render-endpoint friendly-name substrings (lowercased) usable as the virtual-mic write target,
|
||||
/// ordered by preference. VB-CABLE first: the installer bundles it for this exact purpose.
|
||||
const MIC_CANDIDATES: &[&str] = &[
|
||||
"cable input", // VB-Audio Virtual Cable — bundled by the installer
|
||||
"steam streaming microphone",
|
||||
"voicemeeter input",
|
||||
"voicemeeter aux input",
|
||||
"virtual",
|
||||
];
|
||||
|
||||
/// `(mic render substring, matching capture substring)` — which capture endpoint surfaces the
|
||||
/// audio written to a given mic render target.
|
||||
fn capture_for(mic_render_lname: &str) -> &'static [&'static str] {
|
||||
if mic_render_lname.contains("cable") {
|
||||
&["cable output"]
|
||||
} else if mic_render_lname.contains("steam streaming microphone") {
|
||||
&["steam streaming microphone"]
|
||||
} else if mic_render_lname.contains("voicemeeter") {
|
||||
&["voicemeeter out", "voicemeeter"]
|
||||
} else {
|
||||
&["virtual"]
|
||||
}
|
||||
}
|
||||
|
||||
/// A render endpoint no loopback should capture: the VB-CABLE (reserved for the mic even when it
|
||||
/// isn't the chosen target — capturing a cable someone else feeds echoes too) and the Steam
|
||||
/// Streaming Speakers, whose loopback is silent (validated live).
|
||||
fn excluded_from_loopback(lname: &str) -> bool {
|
||||
lname.contains("cable") || lname.contains("steam streaming speakers")
|
||||
}
|
||||
|
||||
/// A known-virtual device (cables/streaming endpoints). A render WITHOUT these markers is real
|
||||
/// hardware — the best loopback source (apps render there by default and the operator can also
|
||||
/// hear it).
|
||||
fn virtualish(lname: &str) -> bool {
|
||||
lname.contains("virtual")
|
||||
|| lname.contains("cable")
|
||||
|| lname.contains("steam streaming")
|
||||
|| lname.contains("voicemeeter")
|
||||
}
|
||||
|
||||
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
|
||||
/// lowercased): when set it beats the built-in candidate order for the mic target.
|
||||
pub(crate) fn plan(renders: &[Endpoint], captures: &[Endpoint], mic_want: Option<&str>) -> Wiring {
|
||||
let find_render = |needle: &str| {
|
||||
renders
|
||||
.iter()
|
||||
.find(|(n, _)| n.to_lowercase().contains(needle))
|
||||
.cloned()
|
||||
};
|
||||
|
||||
// 1. Mic target first — it has the narrower requirements (must be a virtual cable).
|
||||
let mic_render = match mic_want {
|
||||
Some(w) => find_render(w),
|
||||
None => MIC_CANDIDATES.iter().find_map(|c| find_render(c)),
|
||||
};
|
||||
|
||||
// 2. Its capture side (what host apps record).
|
||||
let mic_capture = mic_render.as_ref().and_then(|(name, _)| {
|
||||
capture_for(&name.to_lowercase()).iter().find_map(|c| {
|
||||
captures
|
||||
.iter()
|
||||
.find(|(n, _)| n.to_lowercase().contains(c))
|
||||
.cloned()
|
||||
})
|
||||
});
|
||||
|
||||
// 3. Loopback from the REMAINING renders: real hardware > Steam Streaming Microphone (its
|
||||
// loopback works, unlike the Speakers') > any non-excluded leftover.
|
||||
let not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id);
|
||||
let loopback_render = renders
|
||||
.iter()
|
||||
.find(|(n, id)| {
|
||||
let ln = n.to_lowercase();
|
||||
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
|
||||
})
|
||||
.or_else(|| {
|
||||
renders.iter().find(|(n, id)| {
|
||||
not_mic(id) && n.to_lowercase().contains("steam streaming microphone")
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
renders
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && !excluded_from_loopback(&n.to_lowercase()))
|
||||
})
|
||||
.cloned();
|
||||
|
||||
Wiring {
|
||||
mic_render,
|
||||
mic_capture,
|
||||
loopback_render,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ep(name: &str) -> Endpoint {
|
||||
(name.to_string(), format!("id-{}", name.to_lowercase()))
|
||||
}
|
||||
|
||||
/// The shipped configuration: real output + VB-CABLE. Mic gets the cable, loopback the
|
||||
/// speakers, recording default = CABLE Output.
|
||||
#[test]
|
||||
fn gaming_pc_with_cable() {
|
||||
let renders = [
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
];
|
||||
let captures = [
|
||||
ep("Microphone (Webcam)"),
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
);
|
||||
assert_eq!(
|
||||
w.mic_capture.unwrap().0,
|
||||
"CABLE Output (VB-Audio Virtual Cable)"
|
||||
);
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
}
|
||||
|
||||
/// THE historical dead-end: headless box where VB-CABLE is the ONLY render endpoint (and
|
||||
/// therefore the default). The mic must WIN the cable; the loopback is honestly absent.
|
||||
/// (The old anti-echo guard rejected the cable here → mic permanently dead.)
|
||||
#[test]
|
||||
fn headless_cable_only_mic_wins() {
|
||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
let w = plan(&renders, &captures, None);
|
||||
assert!(w.mic_render.is_some(), "mic must claim the only cable");
|
||||
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
|
||||
}
|
||||
|
||||
/// Headless with the Steam pair installed: cable = mic, Steam Streaming Microphone = the
|
||||
/// loopback (its loopback works; the Speakers' is silent — validated live).
|
||||
#[test]
|
||||
fn headless_with_steam_pair() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let captures = [
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
);
|
||||
assert_eq!(
|
||||
w.loopback_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
);
|
||||
assert_eq!(
|
||||
w.mic_capture.unwrap().0,
|
||||
"CABLE Output (VB-Audio Virtual Cable)"
|
||||
);
|
||||
}
|
||||
|
||||
/// No cable: the Steam Streaming Microphone doubles as the mic target, and the loopback
|
||||
/// must NOT then pick the same endpoint (real hardware wins).
|
||||
#[test]
|
||||
fn steam_mic_as_target_never_doubles_as_loopback() {
|
||||
let renders = [
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
);
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
}
|
||||
|
||||
/// No cable and ONLY the Steam mic: mic wins it, loopback honestly absent (never the same
|
||||
/// device — that would echo).
|
||||
#[test]
|
||||
fn steam_mic_only_no_echo() {
|
||||
let renders = [ep("Speakers (Steam Streaming Microphone)")];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None);
|
||||
assert!(w.mic_render.is_some());
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
/// Steam Streaming Speakers never become the loopback (silent loopback, validated live) —
|
||||
/// even when they're the only non-mic endpoint.
|
||||
#[test]
|
||||
fn steam_speakers_never_loopback() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
let w = plan(&renders, &[], None);
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
/// Operator override beats the candidate order.
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
|
||||
];
|
||||
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
||||
let w = plan(&renders, &captures, Some("voicemeeter input"));
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
|
||||
);
|
||||
assert_eq!(
|
||||
w.mic_capture.unwrap().0,
|
||||
"Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)"
|
||||
);
|
||||
}
|
||||
|
||||
/// No virtual device anywhere: no mic target (open fails with guidance), loopback = the
|
||||
/// real output — desktop audio unaffected.
|
||||
#[test]
|
||||
fn no_virtual_device() {
|
||||
let renders = [ep("Speakers (Realtek HD Audio)")];
|
||||
let w = plan(&renders, &[], None);
|
||||
assert!(w.mic_render.is_none());
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
}
|
||||
}
|
||||
@@ -222,10 +222,13 @@ pub(crate) async fn serve(
|
||||
// session — which, under rapid client reconnects, raced a prior session's portal teardown and
|
||||
// wedged KWin's EIS setup ("EIS setup timed out"). Gamepads stay per-session (uinput).
|
||||
let injector = crate::inject::InjectorService::start();
|
||||
// One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink
|
||||
// (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps record from (Linux
|
||||
// PipeWire Audio/Source; Windows a virtual audio device's render endpoint).
|
||||
let mic_service = MicService::start();
|
||||
// One virtual microphone for the whole host lifetime (see [`crate::audio::MicPump`]): the
|
||||
// client's mic uplink (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps
|
||||
// record from (Linux PipeWire Audio/Source; Windows a virtual audio device's render endpoint).
|
||||
// The pump opens the backend EAGERLY (the mic device exists before any game launches and
|
||||
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
|
||||
// endpoint churn).
|
||||
let mic_service = crate::audio::MicPump::start();
|
||||
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
|
||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||
@@ -1310,119 +1313,11 @@ impl PadState {
|
||||
/// actual pad creation at its own MAX_PADS.
|
||||
const MAX_WIRE_PADS: usize = 16;
|
||||
|
||||
/// Backoff between reopen attempts after a host-lifetime service's backend (the mic source, a
|
||||
/// capturer) fails to open or its worker dies, so a persistently-unavailable resource isn't hammered.
|
||||
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
||||
/// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The
|
||||
/// virtual mic has its own tuning — see [`crate::audio::MicPump`].)
|
||||
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
|
||||
const MIC_CHANNELS: u32 = 2;
|
||||
/// Bound for the shared mic frame queue (drop-newest when full). See [`MicService::start`].
|
||||
const MIC_QUEUE_CAP: usize = 64;
|
||||
|
||||
/// Host-lifetime virtual microphone, shared across punktfunk/1 sessions (mirror of
|
||||
/// [`InjectorService`]). One thread owns the PipeWire `Audio/Source` + an Opus decoder; sessions
|
||||
/// forward the client's Opus mic frames over a clonable `Send` channel, the thread decodes and
|
||||
/// feeds the source. Opened lazily on the first frame, the source node persists across sessions
|
||||
/// (no per-session registration churn), and reopens after a backoff if the source/decoder fails.
|
||||
struct MicService {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MicService {
|
||||
fn start() -> MicService {
|
||||
// Bounded so the host-lifetime mic queue (shared across all concurrent sessions) can't grow
|
||||
// without limit under a near-line-rate flood; the producer drops the newest frame when full
|
||||
// (audio is lossy by design) rather than buffering unboundedly (security-review 2026-06-28
|
||||
// S6). 64 × 5–10 ms frames ≈ 0.3–0.6 s of slack, far more than the decode loop ever lags.
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk1-mic".into())
|
||||
.spawn(move || mic_service_thread(rx))
|
||||
{
|
||||
tracing::error!(error = %e, "mic service thread spawn failed — mic passthrough disabled");
|
||||
}
|
||||
MicService { tx }
|
||||
}
|
||||
|
||||
/// A sender a session forwards the client's Opus mic frames to. Cloned per session; dropping a
|
||||
/// clone does NOT stop the service (it holds the original sender for the host life).
|
||||
fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub — mic passthrough needs a virtual-mic backend (Linux PipeWire source / Windows virtual audio
|
||||
/// device); other platforms drain and drop the frames (sessions still count the datagrams).
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
tracing::warn!("punktfunk/1 mic passthrough unsupported on this platform — frames dropped");
|
||||
for _ in rx {}
|
||||
}
|
||||
|
||||
/// The host-lifetime mic worker: lazily open the virtual mic + decoder, then Opus-decode each
|
||||
/// forwarded frame and push the PCM into the source. Reopen (after [`INJECTOR_REOPEN_BACKOFF`])
|
||||
/// only on a backend OPEN failure; a per-frame Opus DECODE error is just a dropped frame (it must
|
||||
/// not tear down this mic, which is shared across every concurrent session — otherwise one paired
|
||||
/// client's junk frames would deny everyone's mic; security-review 2026-06-28 S2). Exits when every
|
||||
/// session sender and the service's own sender drop (host shutdown), tearing the virtual mic down.
|
||||
/// Linux = PipeWire `Audio/Source`; Windows = a virtual audio device's render endpoint.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
let mut mic: Option<Box<dyn crate::audio::VirtualMic>> = None;
|
||||
let mut decoder: Option<opus::Decoder> = None;
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
let mut decode_fails: u64 = 0;
|
||||
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
|
||||
for opus_frame in rx {
|
||||
if opus_frame.is_empty() {
|
||||
continue; // DTX silence — the source underruns to silence on its own
|
||||
}
|
||||
if mic.is_none() || decoder.is_none() {
|
||||
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
|
||||
continue; // still within the reopen backoff window
|
||||
}
|
||||
let opened = crate::audio::open_virtual_mic(MIC_CHANNELS).and_then(|m| {
|
||||
let d = opus::Decoder::new(48_000, opus::Channels::Stereo)
|
||||
.map_err(|e| anyhow!("opus decoder: {e}"))?;
|
||||
Ok((m, d))
|
||||
});
|
||||
match opened {
|
||||
Ok((m, d)) => {
|
||||
tracing::info!("punktfunk/1 virtual mic ready (host-lifetime)");
|
||||
mic = Some(m);
|
||||
decoder = Some(d);
|
||||
last_failed = None;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual mic unavailable — will retry");
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (Some(m), Some(dec)) = (mic.as_ref(), decoder.as_mut()) else {
|
||||
continue;
|
||||
};
|
||||
match dec.decode_float(&opus_frame, &mut pcm, false) {
|
||||
Ok(samples_per_ch) => {
|
||||
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
|
||||
m.push(&pcm[..total]);
|
||||
decode_fails = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
// Malformed/garbage frame: drop it and keep the (shared) mic + decoder open. The
|
||||
// next valid frame decodes normally; only a backend OPEN failure reopens. Throttle
|
||||
// the log (1, 2, 4, … fails) so a junk flood can't spam.
|
||||
decode_fails += 1;
|
||||
if decode_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %e, fails = decode_fails, "mic opus decode failed — dropping frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!("mic service stopped (host shutting down)");
|
||||
}
|
||||
|
||||
/// The session's virtual-gamepad backend, resolved once per session (sessions run serially).
|
||||
///
|
||||
/// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)),
|
||||
|
||||
Reference in New Issue
Block a user