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

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:
2026-07-03 20:41:19 +00:00
parent b7048446c4
commit 2c7ded0f3c
7 changed files with 969 additions and 371 deletions
@@ -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)");
}
}