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")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
||||||
audio_control::ensure_wired_once();
|
// 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)
|
wasapi_cap::WasapiLoopbackCapturer::open(channels)
|
||||||
.map(|c| Box::new(c) as Box<dyn AudioCapturer>)
|
.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
|
/// 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
|
/// 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).
|
/// 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 {
|
pub trait VirtualMic: Send {
|
||||||
/// Push one chunk of interleaved `f32` PCM. Non-blocking — drops if PipeWire is behind
|
/// 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).
|
/// (mic audio is lossy/real-time; a stale chunk is worse than a dropped one). Returns
|
||||||
fn push(&self, pcm: &[f32]);
|
/// `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.
|
/// The interleaved channel count the source was opened with.
|
||||||
fn channels(&self) -> u32 {
|
fn channels(&self) -> u32 {
|
||||||
@@ -78,7 +96,8 @@ pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
|
pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||||
audio_control::ensure_wired_once();
|
// 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>)
|
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")
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "audio/windows/audio_control.rs"]
|
#[path = "audio/windows/audio_control.rs"]
|
||||||
mod audio_control;
|
mod audio_control;
|
||||||
@@ -98,3 +300,188 @@ mod wasapi_cap;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "audio/windows/wasapi_mic.rs"]
|
#[path = "audio/windows/wasapi_mic.rs"]
|
||||||
mod wasapi_mic;
|
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 super::{AudioCapturer, VirtualMic, SAMPLE_RATE};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
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
|
/// 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
|
/// 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).
|
/// 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 {
|
pub struct PwMicSource {
|
||||||
pcm: std::sync::mpsc::SyncSender<Vec<f32>>,
|
pcm: std::sync::mpsc::SyncSender<(std::time::Instant, Vec<f32>)>,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
quit: pipewire::channel::Sender<Terminate>,
|
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 {
|
impl PwMicSource {
|
||||||
@@ -123,20 +143,27 @@ impl PwMicSource {
|
|||||||
matches!(channels, 1 | 2),
|
matches!(channels, 1 | 2),
|
||||||
"virtual mic supports 1 or 2 channels, got {channels}"
|
"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 (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()
|
thread::Builder::new()
|
||||||
.name("punktfunk-pw-mic".into())
|
.name("punktfunk-pw-mic".into())
|
||||||
.spawn(move || {
|
.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");
|
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")?;
|
.context("spawn pipewire virtual-mic thread")?;
|
||||||
Ok(PwMicSource {
|
Ok(PwMicSource {
|
||||||
pcm: pcm_tx,
|
pcm: pcm_tx,
|
||||||
channels,
|
channels,
|
||||||
quit: quit_tx,
|
quit: quit_tx,
|
||||||
|
alive,
|
||||||
|
flush,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,8 +175,24 @@ impl Drop for PwMicSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VirtualMic for PwMicSource {
|
impl VirtualMic for PwMicSource {
|
||||||
fn push(&self, pcm: &[f32]) {
|
fn push(&self, pcm: &[f32]) -> bool {
|
||||||
let _ = self.pcm.try_send(pcm.to_vec()); // drop if the PipeWire side is behind
|
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 {
|
fn channels(&self) -> u32 {
|
||||||
self.channels
|
self.channels
|
||||||
@@ -160,16 +203,27 @@ impl VirtualMic for PwMicSource {
|
|||||||
/// the process callback drains into PipeWire buffers (capped, so latency stays bounded).
|
/// the process callback drains into PipeWire buffers (capped, so latency stays bounded).
|
||||||
/// `primed` is a jitter buffer gate — see the process callback.
|
/// `primed` is a jitter buffer gate — see the process callback.
|
||||||
struct MicUserData {
|
struct MicUserData {
|
||||||
rx: Receiver<Vec<f32>>,
|
rx: Receiver<(std::time::Instant, Vec<f32>)>,
|
||||||
ring: VecDeque<f32>,
|
ring: VecDeque<f32>,
|
||||||
channels: usize,
|
channels: usize,
|
||||||
primed: bool,
|
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(
|
fn mic_pw_thread(
|
||||||
pcm_rx: Receiver<Vec<f32>>,
|
pcm_rx: Receiver<(std::time::Instant, Vec<f32>)>,
|
||||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
|
flush: Arc<AtomicBool>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use pipewire as pw;
|
use pipewire as pw;
|
||||||
use pw::{properties::properties, spa};
|
use pw::{properties::properties, spa};
|
||||||
@@ -188,6 +242,26 @@ fn mic_pw_thread(
|
|||||||
move |_| mainloop.quit()
|
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
|
// 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.
|
// playback stream — without it, Direction::Output + Playback would route to the speakers.
|
||||||
let stream = pw::stream::StreamBox::new(
|
let stream = pw::stream::StreamBox::new(
|
||||||
@@ -226,12 +300,21 @@ fn mic_pw_thread(
|
|||||||
ring: VecDeque::new(),
|
ring: VecDeque::new(),
|
||||||
channels: channels as usize,
|
channels: channels as usize,
|
||||||
primed: false,
|
primed: false,
|
||||||
|
flush,
|
||||||
|
last_run: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _listener = stream
|
let _listener = stream
|
||||||
.add_local_listener_with_user_data(ud)
|
.add_local_listener_with_user_data(ud)
|
||||||
.state_changed(|_s, _ud, old, new| {
|
.state_changed({
|
||||||
tracing::info!(?old, ?new, "pipewire virtual-mic stream state");
|
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| {
|
.param_changed(|_s, _ud, id, param| {
|
||||||
let Some(param) = param else { return };
|
let Some(param) = param else { return };
|
||||||
@@ -253,9 +336,25 @@ fn mic_pw_thread(
|
|||||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// Pull all newly-decoded PCM into the ring.
|
// Stale-audio guard, BEFORE pulling new frames: drop the ring when a flush was
|
||||||
while let Ok(frame) = ud.rx.try_recv() {
|
// requested (uplink gap — see the pump) or when this callback itself hasn't run
|
||||||
ud.ring.extend(frame);
|
// 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 stride = 4 * ud.channels; // F32LE interleaved
|
||||||
let datas = buffer.datas_mut();
|
let datas = buffer.datas_mut();
|
||||||
|
|||||||
@@ -6,64 +6,39 @@
|
|||||||
//! ones, or the loopback re-captures the injected mic (an infinite echo). The installer bundles
|
//! 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)
|
//! 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
|
//! 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
|
//! 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
|
//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] captures;
|
||||||
//! for desktop audio.
|
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
|
||||||
//! * default **RECORDING** → the virtual mic's capture endpoint (VB-Cable "CABLE Output") so host apps
|
|
||||||
//! record the client's mic by default.
|
//! record the client's mic by default.
|
||||||
//!
|
//!
|
||||||
//! [`super::wasapi_mic::find_device`] then resolves the mic INJECT target to "CABLE Input" — a render
|
//! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every
|
||||||
//! candidate that is NOT the default playback — guaranteeing loopback ≠ mic, so there is no echo.
|
//! 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
|
//! 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
|
//! 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
|
//! 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.
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use super::wiring_plan::{plan, Endpoint, Wiring};
|
||||||
use anyhow::{anyhow, bail, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::sync::Once;
|
use std::sync::Mutex;
|
||||||
use wasapi::Direction;
|
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`.
|
/// `(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 mut out = Vec::new();
|
||||||
let Ok(en) = wasapi::DeviceEnumerator::new() else {
|
let Ok(en) = wasapi::DeviceEnumerator::new() else {
|
||||||
return out;
|
return out;
|
||||||
@@ -86,79 +61,85 @@ fn list_endpoints(dir: Direction) -> Vec<(String, String)> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick the loopback + mic-capture devices and set them as the default playback/recording.
|
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||||
fn ensure_audio_wiring() -> Result<()> {
|
/// `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 renders = list_endpoints(Direction::Render);
|
||||||
let captures = list_endpoints(Direction::Capture);
|
let captures = list_endpoints(Direction::Capture);
|
||||||
if renders.is_empty() {
|
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||||
bail!("no active render endpoints to wire");
|
.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
|
// Log assignment changes exactly once (first plan included).
|
||||||
// the mic inject) or the Steam Streaming Speakers (its loopback is silent — validated live).
|
static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
|
||||||
let excluded_loopback =
|
let changed = {
|
||||||
|ln: &str| ln.contains("cable") || ln.contains("steam streaming speakers");
|
let mut last = LAST.lock().unwrap();
|
||||||
// "virtual-ish" = a known virtual cable; a render WITHOUT these markers is a real output device,
|
let changed = last.as_ref() != Some(&wiring);
|
||||||
// the best loopback source (apps render there and the operator can also hear it).
|
*last = Some(wiring.clone());
|
||||||
let virtualish = |ln: &str| {
|
changed
|
||||||
ln.contains("virtual")
|
|
||||||
|| ln.contains("cable")
|
|
||||||
|| ln.contains("steam streaming")
|
|
||||||
|| ln.contains("voicemeeter")
|
|
||||||
};
|
};
|
||||||
let loopback = renders
|
if changed {
|
||||||
.iter()
|
tracing::info!(
|
||||||
.find(|(n, _)| {
|
mic_render = wiring.mic_render.as_ref().map(|(n, _)| n.as_str()),
|
||||||
let ln = n.to_lowercase();
|
mic_capture = wiring.mic_capture.as_ref().map(|(n, _)| n.as_str()),
|
||||||
!excluded_loopback(&ln) && !virtualish(&ln)
|
loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()),
|
||||||
})
|
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
|
||||||
.or_else(|| {
|
"audio wiring plan"
|
||||||
renders
|
);
|
||||||
.iter()
|
if wiring.mic_render.is_some() && wiring.loopback_render.is_none() {
|
||||||
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone"))
|
tracing::warn!(
|
||||||
})
|
"the virtual mic reserved the only usable render endpoint — desktop audio will be \
|
||||||
.or_else(|| {
|
unavailable until another output device exists (attach one, or let the host \
|
||||||
renders
|
install the Steam Streaming pair)"
|
||||||
.iter()
|
);
|
||||||
.find(|(n, _)| !excluded_loopback(&n.to_lowercase()))
|
|
||||||
});
|
|
||||||
|
|
||||||
// The virtual mic's CAPTURE endpoint host apps record from — VB-Cable "CABLE Output" preferred.
|
|
||||||
let mic_capture = captures
|
|
||||||
.iter()
|
|
||||||
.find(|(n, _)| n.to_lowercase().contains("cable output"))
|
|
||||||
.or_else(|| {
|
|
||||||
captures
|
|
||||||
.iter()
|
|
||||||
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone"))
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
captures.iter().find(|(n, _)| {
|
|
||||||
let ln = n.to_lowercase();
|
|
||||||
ln.contains("voicemeeter") || ln.contains("virtual")
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
match loopback {
|
|
||||||
Some((name, id)) => match set_default_endpoint(id) {
|
|
||||||
Ok(()) => tracing::info!(device = %name,
|
|
||||||
"audio wiring: default playback = desktop-audio loopback source"),
|
|
||||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
|
||||||
"audio wiring: failed to set the default playback device"),
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
tracing::warn!("audio wiring: no usable desktop-audio loopback render endpoint found")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some((name, id)) = mic_capture {
|
|
||||||
|
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) {
|
match set_default_endpoint(id) {
|
||||||
Ok(()) => tracing::info!(device = %name,
|
Ok(()) => {
|
||||||
"audio wiring: default recording = virtual mic (apps record the client's mic)"),
|
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:#}"),
|
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||||
"audio wiring: failed to set the default recording device"),
|
"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. ---
|
// --- 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
|
//! 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.
|
//! `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 anyhow::{anyhow, Context, Result};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -109,14 +109,36 @@ fn capture_thread(
|
|||||||
}
|
}
|
||||||
let res = (|| -> Result<()> {
|
let res = (|| -> Result<()> {
|
||||||
// Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE
|
// 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
|
// client with loopback=true over it. ECHO GUARD: the wiring plan reserves one endpoint for
|
||||||
// to NEVER target this same endpoint — otherwise the client's injected mic would be captured
|
// the virtual mic (`super::wasapi_mic` writes the client's voice there) — capturing THAT
|
||||||
// here and streamed back to the client (infinite echo). Keep that guard in sync if this
|
// endpoint would stream the client's own mic straight back to it. Normally the plan has
|
||||||
// device selection ever changes.
|
// already moved the default playback elsewhere; if the default still IS the mic target
|
||||||
let device = DeviceEnumerator::new()
|
// (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")?
|
.context("DeviceEnumerator")?
|
||||||
.get_default_device(&Direction::Render)
|
.get_default_device(&Direction::Render)
|
||||||
.context("default render endpoint (loopback needs a render device)")?;
|
.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")?;
|
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||||
// 48 kHz f32 interleaved in the requested channel layout; autoconvert lets WASAPI's
|
// 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
|
// 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
|
//! 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.
|
//! **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`):
|
//! The target comes from the [`audio_control::wire_now`] plan (recomputed on every open): VB-Audio
|
||||||
//! VB-Audio "CABLE Input" (bundled by the installer — the preferred, dedicated mic target), the
|
//! "CABLE Input" (bundled by the installer — the dedicated mic target), the Steam Streaming
|
||||||
//! "Steam Streaming Microphone", VoiceMeeter, or anything with "virtual" in the name.
|
//! Microphone, VoiceMeeter, or anything with "virtual" in the name; `PUNKTFUNK_MIC_DEVICE` overrides.
|
||||||
//! [`super::audio_control`] sets the default playback to a DIFFERENT loopback-capable device so the
|
//! The plan reserves the mic target and points the desktop-audio loopback at a DIFFERENT endpoint, so
|
||||||
//! chosen mic is never the endpoint the loopback captures. If no candidate is present we auto-install
|
//! injecting here can never echo into the host→client audio stream (see
|
||||||
//! the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we return an error
|
//! [`wiring_plan`](super::wiring_plan) for the precedence rules and the headless cable-only case).
|
||||||
//! with install guidance and the host runs without mic passthrough.
|
//! 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
|
//! **Liveness.** Any WASAPI error in the render loop (endpoint invalidated/removed, audio engine
|
||||||
//! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback
|
//! restart) exits the worker thread, which flips the `alive` flag — [`VirtualMic::push`] then
|
||||||
//! captures the *mixed* output of an endpoint — i.e. everything any app renders to it, including
|
//! returns `false` and the pump reopens (re-planning, so endpoint churn re-resolves). Before this
|
||||||
//! what THIS module writes. So if the virtual-mic target is the same device the loopback captures,
|
//! existed, the first device change silently killed mic passthrough for the rest of the host's life.
|
||||||
//! the client's uplinked mic is captured straight back into the host→client audio stream: an
|
|
||||||
//! infinite echo. [`find_device`] therefore **excludes the default render endpoint** from the
|
|
||||||
//! candidates — the mic is guaranteed to land on a different device. (Linux gets this for free: its
|
|
||||||
//! mic is a dedicated `Audio/Source` node, structurally separate from the monitored sink.)
|
|
||||||
//!
|
//!
|
||||||
//! `push` enqueues decoded interleaved-f32 PCM into a bounded ring (drop-oldest beyond ~80 ms so mic
|
//! `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
|
//! 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.
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{VirtualMic, SAMPLE_RATE};
|
use super::{audio_control, VirtualMic, SAMPLE_RATE};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
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).
|
/// 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;
|
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 {
|
pub struct WasapiVirtualMic {
|
||||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||||
stop: Arc<AtomicBool>,
|
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<()>>,
|
join: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,25 +59,29 @@ impl WasapiVirtualMic {
|
|||||||
);
|
);
|
||||||
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
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
|
// 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.
|
// 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 (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()
|
let join = thread::Builder::new()
|
||||||
.name("punktfunk-wasapi-mic".into())
|
.name("punktfunk-wasapi-mic".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = render_thread(q, st, ready_tx) {
|
if let Err(e) = render_thread(q, st, ready_tx) {
|
||||||
tracing::error!(error = %format!("{e:#}"), "wasapi virtual-mic thread failed");
|
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")?;
|
.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)) => {
|
Ok(Ok(name)) => {
|
||||||
tracing::info!(device = %name,
|
tracing::info!(device = %name,
|
||||||
"WASAPI virtual mic ready (client mic → this device's render endpoint)");
|
"WASAPI virtual mic ready (client mic → this device's render endpoint)");
|
||||||
Ok(WasapiVirtualMic {
|
Ok(WasapiVirtualMic {
|
||||||
queue,
|
queue,
|
||||||
stop,
|
stop,
|
||||||
|
alive,
|
||||||
join: Some(join),
|
join: Some(join),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -106,9 +101,12 @@ impl Drop for WasapiVirtualMic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VirtualMic 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 {
|
let Ok(mut q) = self.queue.lock() else {
|
||||||
return;
|
return false;
|
||||||
};
|
};
|
||||||
q.reserve(pcm.len() * 4);
|
q.reserve(pcm.len() * 4);
|
||||||
for &s in pcm {
|
for &s in pcm {
|
||||||
@@ -119,109 +117,50 @@ impl VirtualMic for WasapiVirtualMic {
|
|||||||
let excess = q.len() - MAX_QUEUE_BYTES;
|
let excess = q.len() - MAX_QUEUE_BYTES;
|
||||||
q.drain(..excess);
|
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 {
|
fn channels(&self) -> u32 {
|
||||||
CHANNELS
|
CHANNELS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The endpoint ID of the device the desktop-audio loopback records (the **default render
|
/// Resolve the mic inject target from the wiring plan, auto-installing the Steam Streaming pair
|
||||||
/// endpoint**, see [`super::wasapi_cap`]). The virtual mic must never target this device — injecting
|
/// when nothing usable exists (then re-planning). Runs on the COM-initialized render thread.
|
||||||
/// there echoes the client's mic back into the host→client audio stream. `None` if it can't be
|
fn resolve_target() -> Result<(wasapi::Device, String)> {
|
||||||
/// resolved (then [`find_device`] can't prove a candidate is safe and falls back to name-only
|
let mut wiring = audio_control::wire_now();
|
||||||
/// matching — no worse than before the guard existed).
|
if wiring.mic_render.is_none() {
|
||||||
fn default_render_id() -> Option<String> {
|
tracing::info!("no usable virtual mic device present — attempting auto-install");
|
||||||
wasapi::DeviceEnumerator::new()
|
// SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s
|
||||||
.ok()?
|
// `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer;
|
||||||
.get_default_device(&Direction::Render)
|
// calling it imposes no extra precondition here (it takes no args and aliases nothing).
|
||||||
.ok()?
|
// Its internal contract holds: the `DiInstall` type matches the documented
|
||||||
.get_id()
|
// `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a
|
||||||
.ok()
|
// NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
|
||||||
}
|
// dedicated mic thread.
|
||||||
|
if unsafe { install_steam_audio_pair() } {
|
||||||
/// Resolve the virtual-mic target among render endpoints by friendly-name, **excluding the endpoint
|
wiring = audio_control::wire_now();
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// **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
|
/// the mic land on different devices and never echo (see [`super::wiring_plan`]). Returns true if
|
||||||
/// installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs admin —
|
/// either installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs
|
||||||
/// the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set.
|
/// admin — the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set.
|
||||||
unsafe fn install_steam_audio_pair() -> bool {
|
unsafe fn install_steam_audio_pair() -> bool {
|
||||||
// Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink).
|
// Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink).
|
||||||
let mic = try_install_steam_audio("SteamStreamingMicrophone.inf");
|
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
|
// 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.
|
// 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 setup = (|| -> Result<(wasapi::AudioClient, wasapi::AudioRenderClient, wasapi::Handle, String)> {
|
||||||
let device = find_or_install_device()?;
|
let (device, name) = resolve_target()?;
|
||||||
let name = device.get_friendlyname().unwrap_or_else(|_| "virtual mic".into());
|
|
||||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||||
// 48 kHz stereo f32; autoconvert lets WASAPI shared-mode SRC match the device mix format.
|
// 48 kHz stereo f32; autoconvert lets WASAPI shared-mode SRC match the device mix format.
|
||||||
let desired = WaveFormat::new(
|
let desired = WaveFormat::new(
|
||||||
@@ -359,6 +297,8 @@ fn render_thread(
|
|||||||
};
|
};
|
||||||
let _ = ready.send(Ok(name));
|
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();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
// The device signals when it wants more data; finite timeout keeps `stop` responsive.
|
// 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
|
// 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).
|
// wedged KWin's EIS setup ("EIS setup timed out"). Gamepads stay per-session (uinput).
|
||||||
let injector = crate::inject::InjectorService::start();
|
let injector = crate::inject::InjectorService::start();
|
||||||
// One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink
|
// One virtual microphone for the whole host lifetime (see [`crate::audio::MicPump`]): the
|
||||||
// (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps record from (Linux
|
// client's mic uplink (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps
|
||||||
// PipeWire Audio/Source; Windows a virtual audio device's render endpoint).
|
// record from (Linux PipeWire Audio/Source; Windows a virtual audio device's render endpoint).
|
||||||
let mic_service = MicService::start();
|
// 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
|
// 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
|
// 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.
|
// `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.
|
/// actual pad creation at its own MAX_PADS.
|
||||||
const MAX_WIRE_PADS: usize = 16;
|
const MAX_WIRE_PADS: usize = 16;
|
||||||
|
|
||||||
/// Backoff between reopen attempts after a host-lifetime service's backend (the mic source, a
|
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
||||||
/// capturer) fails to open or its worker dies, so a persistently-unavailable resource isn't hammered.
|
/// 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);
|
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).
|
/// 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)),
|
/// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)),
|
||||||
|
|||||||
Reference in New Issue
Block a user