fix(host/audio): an unsatisfiable wiring plan waits for an endpoint change instead of hammering

Field case 2026-08: the display isolate invalidated the only real render
endpoint; the mic held the Steam Streaming Microphone, the Speakers were
blacklisted, and the capture loop re-ran the full wiring pass — three
IPolicyConfig SetDefaultEndpoint writes included — every 2 s for 8+
minutes, retrying a verdict that could never change.

- wiring_plan: a plan with no loopback is a typed structural verdict
  (Wiring::loopback_unsatisfiable + an endpoint-set fingerprint); the
  dead leftover() tier (byte-identical to real_hw()) becomes a real last
  resort that accepts ONLY the Steam Streaming Speakers, flagged
  loopback_last_resort — a known-silent-loopback QUALITY risk, never the
  cable/VoiceMeeter echo CORRECTNESS risks. excluded_from_loopback stays
  untouched (judge_default's mid-stream snap-back semantics).
- wasapi_cap: an unsatisfiable plan errors ONCE per topology with the
  render inventory, per-endpoint rejection reasons and only the remedies
  not already taken, then parks on a cheap enumerate-and-hash poll and
  re-plans the instant the set changes; transient failures get a real
  capped exponential backoff (2 s → 60 s, reset on success or set
  change); a last-resort capture re-plans on any set change and promotes
  the 30 s zero-packet breadcrumb to warn.
- audio_control: the recording default is asserted only when the plan
  changed or the default drifted — set_default_endpoint fires all three
  SetDefaultEndpoint roles unconditionally, so the 2 s loop silently
  stomped any operator recording-device change; the "attach one, or let
  the host install the Steam Streaming pair" warn (already satisfied in
  the field case) is replaced by the same diagnosis.

Verified: 19/19 wiring_plan tests (native rustc --test and the Linux CI
image via docker); both Windows files type-check and clippy clean
against wasapi 0.23.0 / windows 0.62.2 for x86_64-pc-windows-msvc via an
xcheck-style stub harness (the in-tree target check dies in
openh264-sys2's build script on macOS, as scripts/xcheck.sh documents).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 09:53:36 +02:00
co-authored by Claude Fable 5
parent 48511d1267
commit 652abeb397
3 changed files with 551 additions and 81 deletions
@@ -39,7 +39,7 @@
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::wiring_plan::{plan, Endpoint, Wiring};
use super::wiring_plan::{self, plan, Endpoint, Wiring};
use anyhow::{anyhow, bail, Result};
use std::ffi::c_void;
use std::sync::Mutex;
@@ -75,6 +75,33 @@ pub(crate) fn host_audio_requested() -> bool {
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
}
/// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs:
/// the endpoint-set fingerprint ([`wiring_plan::fingerprint`] — the wait key while a plan is
/// unsatisfiable, snapshotted from the SAME enumeration the plan consumed so a device arriving
/// mid-pass can't leave the waiter keyed to a set the plan never saw) and the render inventory
/// (the one-shot "why is there no loopback" diagnosis).
pub(crate) struct WiredPlan {
pub wiring: Wiring,
pub fingerprint: u64,
pub renders: Vec<Endpoint>,
}
/// Fingerprint of the CURRENT endpoint set (both directions) WITHOUT a wiring pass: enumeration
/// and a hash — no plan, no default-device writes, no logs. This is the cheap poll the capture
/// loop runs while waiting out a failure; the full [`wire_now`] only runs again once this moves.
/// Must run on a COM-initialized thread, like [`wire_now`].
pub(crate) fn endpoint_fingerprint() -> u64 {
wiring_plan::fingerprint(
&list_endpoints(Direction::Render),
&list_endpoints(Direction::Capture),
)
}
/// [`wire_now_full`] for callers that only need the assignment (the mic paths).
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
wire_now_full(set_playback).wiring
}
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
@@ -83,14 +110,20 @@ pub(crate) fn host_audio_requested() -> bool {
/// 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(set_playback: bool) -> Wiring {
pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
recover_orphaned_default();
let renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture);
let fingerprint = wiring_plan::fingerprint(&renders, &captures);
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok()
.map(|s| s.to_lowercase());
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
let done = |wiring: Wiring| WiredPlan {
wiring,
fingerprint,
renders: renders.clone(),
};
// Log assignment changes exactly once (first plan included).
static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
@@ -105,14 +138,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
mic_render = wiring.mic_render.as_ref().map(|(n, _)| n.as_str()),
mic_capture = wiring.mic_capture.as_ref().map(|(n, _)| n.as_str()),
loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()),
loopback_last_resort = wiring.loopback_last_resort,
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
"audio wiring plan"
);
if wiring.mic_render.is_some() && wiring.loopback_render.is_none() {
if wiring.mic_render.is_some() && wiring.loopback_unsatisfiable() {
// Inventory + per-endpoint reasons + ONLY the remedies not already taken — the old
// static advice here suggested installing the Steam pair to a field box that had it
// installed (its Microphone half was exactly what the mic had reserved).
tracing::warn!(
"the virtual mic reserved the only usable render endpoint — desktop audio will be \
unavailable until another output device exists (attach one, or let the host \
install the Steam Streaming pair)"
"desktop audio unavailable: {}",
wiring_plan::describe_no_loopback(&renders, &wiring)
);
}
}
@@ -123,7 +159,7 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"
);
}
return wiring;
return done(wiring);
}
// Default-playback hygiene, on EVERY wire (mic pump at boot included): if the default render
// endpoint IS the mic target — VB-CABLE installs have been seen grabbing the default — every
@@ -160,18 +196,26 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
}
}
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)");
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
// fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
// device-graph notification, each). Re-asserting on every wiring pass therefore both
// churned the policy store AND silently stomped an operator's own recording-device
// choice within one reopen cycle — write only when the plan changed or the default
// actually drifted off the target.
if changed || default_capture_id().as_deref() != Some(id.as_str()) {
match set_default_endpoint(id) {
Ok(()) => {
if changed {
tracing::info!(device = %name,
"audio wiring: default recording = virtual mic (apps record the client's mic)");
}
}
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"),
}
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"),
}
}
wiring
done(wiring)
}
/// The operator's default playback endpoint while we have it parked on the loopback sink:
@@ -194,6 +238,18 @@ fn default_render_id() -> Option<String> {
.ok()
}
/// The current default CAPTURE endpoint id, if any — the recording-side analogue of
/// [`default_render_id`], read before asserting the recording default so an already-correct
/// default costs zero IPolicyConfig writes.
fn default_capture_id() -> Option<String> {
wasapi::DeviceEnumerator::new()
.ok()?
.get_default_device(&Direction::Capture)
.ok()?
.get_id()
.ok()
}
/// Once per process: if a crash marker from a previous run exists, the host died while the
/// playback default was parked — put the operator's device back, but only if the default still
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
@@ -18,9 +18,14 @@
//! device changing under us — the operator picked a different output mid-stream — and reacts:
//! a loopback-capturable choice is FOLLOWED (their explicit choice wins; audio then also plays
//! on the host), a known-dud choice (cable/Steam Speakers/the mic target) snaps back to the
//! plan. Device errors (endpoint invalidated, engine restart) reopen with backoff instead of
//! killing audio for the rest of the session. On thread exit (capturer dropped at stream end)
//! the parked default playback device is restored.
//! plan. Device errors (endpoint invalidated, engine restart) reopen with a capped exponential
//! backoff that an endpoint-set change cuts short. A plan with NO loopback endpoint at all is
//! never retried: `wiring_plan::plan` is pure in the endpoint set, so that verdict holds until
//! the set changes — the thread says why once, then parks on a cheap fingerprint poll and
//! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass —
//! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
//! device is restored.
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result};
@@ -131,8 +136,20 @@ enum Next {
Reopen(TargetMode),
}
/// Backoff between self-heal reopen attempts after a capture failure.
const REOPEN_BACKOFF: Duration = Duration::from_secs(2);
/// Reopen backoff after a TRANSIENT capture failure: starts here and doubles per consecutive
/// failure up to [`REOPEN_BACKOFF_CAP`], resetting on success or on an endpoint-set change
/// (mirrors the mic pump's `PUMP_TUNING` shape). The predecessor was a FLAT 2 s retry whose
/// every attempt re-ran the full wiring pass, IPolicyConfig writes included — tolerable for a
/// genuinely transient error, an 8-minute hammer in the 2026-08 field case where the failure
/// was structural.
const REOPEN_BACKOFF_START: Duration = Duration::from_secs(2);
const REOPEN_BACKOFF_CAP: Duration = Duration::from_secs(60);
/// Endpoint-set poll cadence while waiting out a failure (both the transient backoff sleep and
/// the unsatisfiable-plan wait): one enumerate-and-hash per tick, nothing else. A fingerprint
/// change ends the wait immediately — a (re)arrived endpoint (the display coming back, plugged
/// headphones) is exactly the recovery moment — so recovery stays as fast as the old 2 s hammer
/// without its side effects.
const ENDPOINT_POLL_EVERY: Duration = Duration::from_secs(2);
/// Watchdog cadence for "did the default render device change under us?" checks.
const DEFAULT_CHECK_EVERY: Duration = Duration::from_secs(1);
/// Total attempts for the FIRST open before its failure surfaces through the `ready` handshake.
@@ -168,14 +185,30 @@ fn capture_thread(
let mut mode = TargetMode::Assert;
let mut failures: u64 = 0;
let mut first_attempts: u32 = 0;
let mut backoff = REOPEN_BACKOFF_START;
// Endpoint-set fingerprint under which an unsatisfiable plan was already error-logged: an
// unchanged set means an unchanged verdict (`wiring_plan::plan` is pure), so the diagnosis
// is said once per topology — the field log drowned in 256+ copies of the same line.
let mut unsat_logged: Option<u64> = None;
while !stop.load(Ordering::Relaxed) {
match capture_once(&tx, &stop, &mut ready, channels, mode) {
Ok(Next::Stopped) => break,
Ok(Next::Reopen(m)) => {
mode = m;
failures = 0;
backoff = REOPEN_BACKOFF_START;
unsat_logged = None;
}
Err(e) if ready.is_some() => {
// An unsatisfiable PLAN cannot improve within the handshake window — the
// once-per-process Steam-pair install already ran inside `capture_once` — so
// fail the open now with the full diagnosis instead of spending the transient
// retry budget on a structural verdict. The native plane owns first-open
// retries and backs off on its own.
if e.downcast_ref::<PlanUnsatisfiable>().is_some() {
let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}")));
break;
}
first_attempts += 1;
if first_attempts >= FIRST_OPEN_ATTEMPTS || stop.load(Ordering::Relaxed) {
let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}")));
@@ -190,16 +223,43 @@ fn capture_thread(
}
}
Err(e) => {
failures += 1;
if failures.is_power_of_two() {
tracing::warn!(error = %format!("{e:#}"), count = failures,
"audio loopback capture failed — reopening");
}
mode = TargetMode::Assert;
// Backoff in stop-responsive slices.
let until = Instant::now() + REOPEN_BACKOFF;
while Instant::now() < until && !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(100));
if let Some(unsat) = e.downcast_ref::<PlanUnsatisfiable>() {
// Structural: retrying against the same endpoints repeats the same verdict,
// and every retry used to re-run the wiring pass — IPolicyConfig writes
// included, stomping any operator default-recording change within 2 s. Say
// why once per topology, then park on the cheap fingerprint poll; the set
// changing IS the recovery moment and re-plans immediately.
failures = 0;
backoff = REOPEN_BACKOFF_START;
if unsat_logged != Some(unsat.fingerprint) {
unsat_logged = Some(unsat.fingerprint);
tracing::error!(
"desktop audio unavailable, and retrying cannot help until the \
audio endpoint set changes — waiting for that change. {unsat}"
);
}
if wait_endpoint_change(&stop, unsat.fingerprint, None) == EndpointWait::Stopped
{
break;
}
} else {
unsat_logged = None;
failures += 1;
if failures.is_power_of_two() {
tracing::warn!(error = %format!("{e:#}"), count = failures,
backoff_secs = backoff.as_secs(),
"audio loopback capture failed — reopening after backoff");
}
// Capped exponential backoff, cut short (and reset) the moment the
// endpoint set changes — a re-arrived device is the likeliest cure for
// whatever killed the capture, and it must not wait out a 60 s sleep.
let fp = audio_control::endpoint_fingerprint();
match wait_endpoint_change(&stop, fp, Some(Instant::now() + backoff)) {
EndpointWait::Stopped => break,
EndpointWait::Changed => backoff = REOPEN_BACKOFF_START,
EndpointWait::Elapsed => backoff = (backoff * 2).min(REOPEN_BACKOFF_CAP),
}
}
}
}
@@ -210,6 +270,75 @@ fn capture_thread(
Ok(())
}
/// A wiring plan with NO loopback endpoint, as a typed error: [`wiring_plan::plan`] is pure in
/// the enumerated endpoint set, so unlike every other capture error this one is PERMANENT until
/// the topology changes — retrying it is guaranteed futile (the 2026-08 field case retried it
/// flat-out for 8+ minutes, one full wiring pass per retry). Carries the set's fingerprint
/// (what the reopen loop waits on) and the full diagnosis: inventory, per-endpoint rejection
/// reasons, and only the remedies not already taken.
#[derive(Debug)]
struct PlanUnsatisfiable {
fingerprint: u64,
detail: String,
}
impl PlanUnsatisfiable {
fn from_plan(plan: &audio_control::WiredPlan) -> PlanUnsatisfiable {
debug_assert!(plan.wiring.loopback_unsatisfiable());
PlanUnsatisfiable {
fingerprint: plan.fingerprint,
detail: wiring_plan::describe_no_loopback(&plan.renders, &plan.wiring),
}
}
}
impl std::fmt::Display for PlanUnsatisfiable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.detail)
}
}
impl std::error::Error for PlanUnsatisfiable {}
/// How a [`wait_endpoint_change`] ended.
#[derive(Clone, Copy, PartialEq, Eq)]
enum EndpointWait {
/// `stop` was set — the capturer is being dropped.
Stopped,
/// The endpoint-set fingerprint moved — re-plan NOW (this is the recovery moment).
Changed,
/// The deadline passed without a change (backoff waits only; `deadline: None` never ends
/// this way).
Elapsed,
}
/// Stop-responsive wait that polls the endpoint-set fingerprint every [`ENDPOINT_POLL_EVERY`] —
/// an enumerate-and-hash, no wiring pass, no IPolicyConfig writes, no logs — until the set
/// changes, `deadline` passes, or `stop` is set. `deadline: None` waits indefinitely: used while
/// the plan is unsatisfiable, where ONLY a topology change can alter the verdict.
fn wait_endpoint_change(
stop: &AtomicBool,
fingerprint: u64,
deadline: Option<Instant>,
) -> EndpointWait {
let mut next_poll = Instant::now() + ENDPOINT_POLL_EVERY;
loop {
if stop.load(Ordering::Relaxed) {
return EndpointWait::Stopped;
}
if deadline.is_some_and(|d| Instant::now() >= d) {
return EndpointWait::Elapsed;
}
thread::sleep(Duration::from_millis(100));
if Instant::now() >= next_poll {
next_poll = Instant::now() + ENDPOINT_POLL_EVERY;
if audio_control::endpoint_fingerprint() != fingerprint {
return EndpointWait::Changed;
}
}
}
}
/// The current default render endpoint, with its id (`None` on any enumeration failure —
/// transient failures must not kill the capture).
fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
@@ -220,7 +349,7 @@ fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
/// One endpoint open + capture loop. Returns how to continue ([`Next`]) or an error (first open:
/// retried [`FIRST_OPEN_ATTEMPTS`] times, then fatal via the `ready` handshake; later: reopen
/// with backoff).
/// with capped backoff — or, for a typed [`PlanUnsatisfiable`], an endpoint-set wait).
fn capture_once(
tx: &SyncSender<Vec<f32>>,
stop: &AtomicBool,
@@ -233,26 +362,23 @@ fn capture_once(
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some();
// Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
let assert_plan = mode == TargetMode::Assert && !keep_default;
let mut wiring = audio_control::wire_now(assert_plan);
let mut plan = audio_control::wire_now_full(assert_plan);
// Client-only audio needs a silent-on-host sink with a working loopback (the Steam Streaming
// Microphone's render side). If the plan had to settle for real hardware (or nothing), try —
// once per process — to install the Steam pair (present when Steam is), then re-plan.
if assert_plan && !audio_control::host_audio_requested() {
let have_silent = wiring
.loopback_render
.as_ref()
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase()));
static INSTALL_TRIED: AtomicBool = AtomicBool::new(false);
if !have_silent && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
if super::wasapi_mic::install_steam_audio_pair() {
wiring = audio_control::wire_now(true);
}
if !wiring
.loopback_render
let have_silent = |w: &wiring_plan::Wiring| {
w.loopback_render
.as_ref()
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase()))
{
};
static INSTALL_TRIED: AtomicBool = AtomicBool::new(false);
if !have_silent(&plan.wiring) && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
if super::wasapi_mic::install_steam_audio_pair() {
plan = audio_control::wire_now_full(true);
}
if !have_silent(&plan.wiring) {
tracing::info!(
"no silent virtual sink for client-only audio — desktop audio will also play \
on the host (install Steam, whose Remote Play streaming drivers provide one)"
@@ -260,6 +386,12 @@ fn capture_once(
}
}
}
let wiring = &plan.wiring;
// Only the Assert path can knowingly sit on the plan's LAST-RESORT endpoint: Follow captures
// the operator's chosen default, and `judge_default` never routes Follow onto the Steam
// Speakers (they are `excluded_from_loopback` — a Dud that snaps back to the plan).
let last_resort = assert_plan && wiring.loopback_last_resort;
let plan_fp = plan.fingerprint;
let en = DeviceEnumerator::new().context("DeviceEnumerator")?;
// Resolve the endpoint to capture. ECHO GUARD (Follow/KEEP_DEFAULT shapes): the wiring plan
@@ -268,11 +400,11 @@ fn capture_once(
// fall back to the plan's loopback endpoint, or refuse — no desktop audio beats an echo loop.
let (device, dev_name, dev_id) = if assert_plan {
let Some(ep) = wiring.loopback_render.clone() else {
anyhow::bail!(
"no loopback-capturable render endpoint (every usable endpoint is reserved for \
the virtual mic or has a silent loopback) — attach an output device or install \
the Steam Streaming pair to get desktop audio"
);
// Detected BEFORE any open attempt, and typed: the plan is a pure function of the
// endpoint set, so this cannot resolve until the set changes — the reopen loop
// waits on the fingerprint instead of retrying (the old untyped bail was retried
// flat-out every 2 s, forever, in the 2026-08 field case).
return Err(PlanUnsatisfiable::from_plan(&plan).into());
};
let d = audio_control::open_endpoint(&ep)?;
(d, ep.0, ep.1)
@@ -285,10 +417,15 @@ fn capture_once(
.is_some_and(|(_, mic_id)| *mic_id == id);
if default_is_mic {
let Some(lb) = wiring.loopback_render.clone() else {
// Same inventory shape as the Assert bail, but NOT typed as unsatisfiable:
// Follow's inputs include the DEFAULT device, which the operator can change
// without a topology change (especially under PUNKTFUNK_KEEP_DEFAULT) — the
// capped backoff must keep retrying rather than a fingerprint wait sleeping
// through a default-only change.
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"
"the default render endpoint is reserved for the virtual mic (capturing it \
would echo the client's voice back) — {}",
wiring_plan::describe_no_loopback(&plan.renders, wiring)
);
};
tracing::warn!(mic = %wiring.mic_render.as_ref().unwrap().0, loopback = %lb.0,
@@ -338,6 +475,7 @@ fn capture_once(
}
tracing::info!(device = %dev_name,
follow = matches!(mode, TargetMode::Follow) || keep_default,
last_resort,
"audio loopback capturing");
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just
@@ -349,7 +487,7 @@ fn capture_once(
if assert_plan {
if let Some(d) = seen_default.as_deref() {
if d != dev_id {
match judge_default(&en, &wiring, d) {
match judge_default(&en, wiring, d) {
DefaultKind::Capturable(name) => {
tracing::info!(default = %name, planned = %dev_name,
"could not park the default playback on the planned endpoint — \
@@ -367,10 +505,12 @@ fn capture_once(
let mut bytes: VecDeque<u8> = VecDeque::new();
let mut last_check = Instant::now();
let mut last_fp_check = Instant::now();
// Triage breadcrumb: a broken loopback (endpoint renders but its loopback tap delivers
// nothing — the Steam Streaming Speakers failure shape) is indistinguishable from a simply
// quiet desktop, so after 30 s with zero packets say so ONCE. Info, not warn: an idle host
// is legitimately silent.
// quiet desktop, so after 30 s with zero packets say so ONCE. Info, not warn an idle host
// is legitimately silent — EXCEPT on a last-resort endpoint, where the plan already knew
// the loopback is silent and zero packets all but confirms the quality risk materialized.
let opened_at = Instant::now();
let mut saw_packets = false;
let mut silence_noted = false;
@@ -396,10 +536,18 @@ fn capture_once(
}
if !saw_packets && !silence_noted && opened_at.elapsed() >= Duration::from_secs(30) {
silence_noted = true;
tracing::info!(device = %dev_name,
"no audio captured in the first 30 s — fine if the host is quiet; if it should \
be playing audio, this endpoint's loopback may be broken (set \
PUNKTFUNK_HOST_AUDIO=1 to prefer real hardware)");
if last_resort {
tracing::warn!(device = %dev_name,
"no audio captured in the first 30 s from the LAST-RESORT loopback — the \
Steam Streaming Speakers' loopback is known-silent, so desktop audio is \
most likely not reaching the client; attach any output device to give the \
plan a working endpoint (it re-plans on the change)");
} else {
tracing::info!(device = %dev_name,
"no audio captured in the first 30 s — fine if the host is quiet; if it \
should be playing audio, this endpoint's loopback may be broken (set \
PUNKTFUNK_HOST_AUDIO=1 to prefer real hardware)");
}
}
let whole = (bytes.len() / block_align) * block_align;
if whole > 0 {
@@ -428,7 +576,7 @@ fn capture_once(
);
return Ok(Next::Reopen(TargetMode::Follow));
}
return Ok(match judge_default(&en, &wiring, &nid) {
return Ok(match judge_default(&en, wiring, &nid) {
DefaultKind::Capturable(name) => {
tracing::info!(device = %name,
"operator changed the output device mid-stream — following \
@@ -447,6 +595,24 @@ fn capture_once(
}
}
}
// A LAST-RESORT capture is a stopgap, not a steady state: the plan chose the
// known-silent Steam Speakers only because nothing better existed, so any endpoint-set
// change — the display's audio endpoint re-arriving, headphones plugged in — may unlock
// a real plan. Re-plan on the change; without this the session would ride the silent
// loopback forever AFTER the real endpoint returned (the original field defect in a
// quieter costume). Preferred endpoints don't get this watch: mid-stream re-routing
// there is the default-device watchdog's job, on the operator's terms.
if last_resort && last_fp_check.elapsed() >= ENDPOINT_POLL_EVERY {
last_fp_check = Instant::now();
if audio_control::endpoint_fingerprint() != plan_fp {
audio_client.stop_stream().ok();
tracing::info!(
"endpoint set changed while capturing the last-resort loopback — re-planning"
);
return Ok(Next::Reopen(TargetMode::Assert));
}
}
}
}
+270 -22
View File
@@ -26,6 +26,19 @@
//! out of the host's speakers. Real hardware is the fallback (audio then plays on both ends).
//! With `host_audio` (the `PUNKTFUNK_HOST_AUDIO` opt-in) the order flips back: real hardware
//! first, so the operator hears the stream locally.
//!
//! **Last resort, and the honest failure.** When neither a silent sink nor real hardware
//! survives the mic reservation, the Steam Streaming *Speakers* are taken as a flagged LAST
//! resort ([`Wiring::loopback_last_resort`]): their loopback is known-silent (validated live) —
//! a QUALITY risk the capture side warns about and treats as a stopgap — but holding a parked
//! endpoint beats holding none (2026-08 field case: the display isolate invalidated the only
//! real render endpoint mid-session, the mic held the Streaming Microphone, and a plan with no
//! loopback left the session unrecoverable). Cables, VoiceMeeter strips and generically-
//! "virtual" endpoints are never a last resort — capturing them re-captures what the mic writes,
//! an echo/feedback CORRECTNESS risk, unlike silence — so with only those left the plan is
//! honestly unsatisfiable ([`Wiring::loopback_unsatisfiable`]): a pure verdict on the endpoint
//! set that cannot change until the set does. Callers must wait for an endpoint-set change
//! ([`fingerprint`]), not retry.
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
pub(crate) type Endpoint = (String, String);
@@ -42,6 +55,22 @@ pub(crate) struct Wiring {
pub mic_capture: Option<Endpoint>,
/// Render endpoint for the desktop-audio loopback; made the default playback device.
pub loopback_render: Option<Endpoint>,
/// `loopback_render` is the flagged LAST RESORT (the Steam Streaming Speakers, whose
/// loopback is known-silent — validated live), taken only because nothing better survived
/// the mic reservation. The capture side treats it as a stopgap: it warns when the silence
/// materializes and re-plans on any endpoint-set change instead of riding it out.
pub loopback_last_resort: bool,
}
impl Wiring {
/// This plan has NO loopback endpoint — not even the last resort. Because [`plan`] is pure,
/// this is a STRUCTURAL verdict on the endpoint set, not a transient device error:
/// reattempting a capture open without an endpoint-set change must fail identically (the
/// 2026-08 field case spent 8+ minutes of flat 2 s retries proving exactly that). Callers
/// wait for the set's [`fingerprint`] to move instead of retrying.
pub(crate) fn loopback_unsatisfiable(&self) -> bool {
self.loopback_render.is_none()
}
}
/// Render-endpoint friendly-name substrings (lowercased) usable as the virtual-mic write target,
@@ -134,7 +163,8 @@ pub(crate) fn plan(
// 3. Loopback from the REMAINING renders. Client-only (default): the silent sink (Steam
// Streaming Microphone — its loopback works, unlike the Speakers') > real hardware
// (audible fallback) > any non-excluded leftover. `host_audio`: real hardware first.
// (audible fallback). `host_audio`: real hardware first. Either order can fall through
// to the flagged last resort below.
let not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id);
let real_hw = || {
renders.iter().find(|(n, id)| {
@@ -147,29 +177,131 @@ pub(crate) fn plan(
.iter()
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
};
// `virtualish` here too: a virtual endpoint that slipped past `excluded_from_loopback`'s
// name list (a future cable/mixer sibling) is an internal-feedback loop waiting to happen —
// no loopback is the honest answer, exactly like the cable-only case.
let leftover = || {
renders.iter().find(|(n, id)| {
let ln = n.to_lowercase();
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
})
// LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent
// (validated live): a QUALITY risk, flagged so the capture side can warn when the silence
// materializes and re-plan when the endpoint set changes — but a parked endpoint beats none
// (2026-08: the display isolate invalidated the only real render endpoint mid-session and a
// loopback-less plan left the session unrecoverable). Never a cable, a VoiceMeeter strip, or
// a generically-"virtual" endpoint: those re-capture what the mic writes — echo/feedback
// CORRECTNESS risks — so "no loopback" stays the honest answer there. NOTE
// `excluded_from_loopback` itself stays untouched: it also powers the capture watchdog's
// judgement of a NEW operator-chosen default, where admitting the Speakers would change
// mid-stream snap-back semantics.
let last_resort = || {
renders
.iter()
.find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers"))
};
let loopback_render = if host_audio {
real_hw().or_else(silent).or_else(leftover)
let preferred = if host_audio {
real_hw().or_else(silent)
} else {
silent().or_else(real_hw).or_else(leftover)
}
.cloned();
silent().or_else(real_hw)
};
let (loopback_render, loopback_last_resort) = match preferred {
Some(ep) => (Some(ep.clone()), false),
None => match last_resort() {
Some(ep) => (Some(ep.clone()), true),
None => (None, false),
},
};
Wiring {
mic_render,
mic_capture,
loopback_render,
loopback_last_resort,
}
}
/// Order-independent fingerprint of an enumerated endpoint set. [`plan`] is a pure function of
/// these inputs (the env knobs are process-stable), so an unchanged fingerprint PROVES an
/// unchanged verdict: re-planning an unsatisfiable set before the fingerprint moves only repeats
/// the same answer, with IPolicyConfig default-device writes as the side effect. The capture
/// loop polls this instead of re-planning, and treats a change as the recovery moment.
pub(crate) fn fingerprint(renders: &[Endpoint], captures: &[Endpoint]) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
// Each direction hashes as a length-prefixed sorted slice, so renders and captures cannot
// alias each other and endpoint order (enumeration order churns) never matters.
for eps in [renders, captures] {
let mut sorted: Vec<&Endpoint> = eps.iter().collect();
sorted.sort();
sorted.hash(&mut h);
}
h.finish()
}
/// The one-shot diagnosis for a plan with no loopback endpoint: every enumerated render with WHY
/// it was rejected, then ONLY the remedies not already taken. The static advice this replaces
/// ("attach one, or let the host install the Steam Streaming pair") was already satisfied in the
/// 2026-08 field case — the pair WAS installed, its Microphone half reserved by the mic — so the
/// message pointed at a fix the box already had. Pure, like [`plan`]: callers pass the same
/// enumeration the plan consumed.
pub(crate) fn describe_no_loopback(renders: &[Endpoint], wiring: &Wiring) -> String {
debug_assert!(wiring.loopback_unsatisfiable());
let mic_id = wiring.mic_render.as_ref().map(|(_, id)| id.as_str());
let rejected: Vec<String> = renders
.iter()
.map(|(name, id)| {
let ln = name.to_lowercase();
let why = if Some(id.as_str()) == mic_id {
"reserved for the virtual mic (its loopback would echo the client's voice back)"
} else if ln.contains("cable") {
"virtual cable (its loopback re-captures what is written into it)"
} else if ln.contains("voicemeeter") {
"VoiceMeeter strip (shares the mixer the mic writes into — a feedback loop)"
} else if ln.contains("steam streaming speakers") {
// Reachable only when the Speakers ARE the mic target (operator override) —
// the last-resort tier takes them otherwise.
"known-silent loopback (validated live)"
} else if ln.contains("virtual") {
"unrecognized virtual endpoint (assumed feedback/silence risk)"
} else {
// `plan` accepts any non-virtual render — reaching this arm means a tier
// changed without updating this diagnosis.
"rejected by the wiring plan"
};
format!("{name:?}: {why}")
})
.collect();
let inventory = if rejected.is_empty() {
"no render endpoints exist at all".to_string()
} else {
rejected.join("; ")
};
let has = |needle: &str| {
renders
.iter()
.any(|(n, _)| n.to_lowercase().contains(needle))
};
let mut remedies = vec!["attach any output device (headphones, or a monitor/TV with audio)"];
// Only useful when the mic would actually vacate a loopback-capable endpoint: with the mic
// on the Steam Streaming Microphone, a cable frees that silent sink for the loopback. A mic
// on a VoiceMeeter strip frees nothing capturable, so the advice is withheld there.
if !has("cable")
&& wiring
.mic_render
.as_ref()
.is_some_and(|(n, _)| silent_sink(&n.to_lowercase()))
{
remedies.push(
"install VB-Audio Virtual Cable — the mic then takes the cable and frees the Steam \
Streaming Microphone's render side for the loopback",
);
}
if !has("steam streaming microphone") {
remedies.push(
"install Steam — its Remote Play streaming drivers add a loopback-capable virtual \
sink",
);
}
format!(
"no loopback-capturable render endpoint: {inventory}. Remedies: {}",
remedies.join("; or ")
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -296,6 +428,10 @@ mod tests {
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
assert!(
!w.loopback_last_resort,
"the silent sink is a PREFERRED pick"
);
assert_eq!(
w.mic_capture.unwrap().0,
"CABLE Output (VB-Audio Virtual Cable)"
@@ -330,16 +466,88 @@ mod tests {
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.
/// Steam Streaming Speakers are never a PREFERRED loopback (their loopback is silent
/// validated live) — but when they are the only non-mic endpoint they ARE taken, flagged as
/// the last resort: a silent loopback the capture side can warn about beats a plan with no
/// endpoint at all (which is unrecoverable until the topology changes).
#[test]
fn steam_speakers_never_loopback() {
fn steam_speakers_only_as_last_resort() {
let renders = [
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Speakers)"),
];
let w = plan(&renders, &[], None, false);
assert!(w.loopback_render.is_none());
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Speakers)",
"host_audio={host_audio}"
);
assert!(w.loopback_last_resort, "host_audio={host_audio}");
}
}
/// THE 2026-08 field case: no cable, only the Steam pair left after the display isolate
/// invalidated the monitor's DP audio endpoint. The mic reserves the Streaming Microphone
/// (the only mic candidate), and the plan must then take the Speakers as the last resort —
/// the old plan yielded no loopback here and the session never recovered.
#[test]
fn field_case_steam_pair_only_takes_speakers_as_last_resort() {
let renders = [
ep("Altavoces (Steam Streaming Speakers)"),
ep("Altavoces (Steam Streaming Microphone)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
assert_eq!(
w.mic_render.unwrap().0,
"Altavoces (Steam Streaming Microphone)"
);
assert_eq!(
w.loopback_render.unwrap().0,
"Altavoces (Steam Streaming Speakers)"
);
assert!(w.loopback_last_resort);
}
/// The last resort never shadows a real pick: with real hardware present the Speakers stay
/// unchosen and the flag stays down, in both preference modes.
#[test]
fn last_resort_never_beats_real_hardware() {
let renders = [
ep("Speakers (Steam Streaming Microphone)"),
ep("Speakers (Steam Streaming Speakers)"),
ep("Speakers (Realtek HD Audio)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Realtek HD Audio)",
"host_audio={host_audio}"
);
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
}
}
/// Cables and VoiceMeeter strips are CORRECTNESS risks (they re-capture what the mic
/// writes — echo/feedback), not quality risks: never the loopback, not even as a last
/// resort. The plan stays honestly unsatisfiable.
#[test]
fn cable_and_voicemeeter_never_last_resort() {
let renders = [
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio);
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
}
}
/// Operator override beats the candidate order.
@@ -413,9 +621,9 @@ mod tests {
}
}
/// A generically-"virtual" leftover (unknown vendor cable) is refused too: `leftover()`
/// applies `virtualish`, so a virtual endpoint that slips past the name list can't become
/// the loopback.
/// A generically-"virtual" leftover (unknown vendor cable) is refused too: the last resort
/// accepts ONLY the Steam Streaming Speakers, so a virtual endpoint that slips past
/// `excluded_from_loopback`'s name list still can't become the loopback.
#[test]
fn unknown_virtual_never_loopback() {
let renders = [
@@ -425,4 +633,44 @@ mod tests {
let w = plan(&renders, &[], None, false);
assert!(w.loopback_render.is_none());
}
/// The fingerprint keys the capture loop's "wait for an endpoint change" state: it must
/// ignore enumeration order (Windows churns it), react to any topology change, and never
/// alias the render and capture directions.
#[test]
fn fingerprint_order_independent_topology_sensitive() {
let a = [ep("Speakers (Realtek HD Audio)"), ep("CABLE Input")];
let a_rev = [ep("CABLE Input"), ep("Speakers (Realtek HD Audio)")];
let caps = [ep("CABLE Output")];
assert_eq!(fingerprint(&a, &caps), fingerprint(&a_rev, &caps));
assert_ne!(fingerprint(&a, &caps), fingerprint(&a[..1], &caps));
assert_ne!(fingerprint(&a, &caps), fingerprint(&caps, &a));
}
/// The unsatisfiable-plan diagnosis must name what the mic reserved and advise ONLY the
/// remedies not already taken: in the field case the Steam pair was installed (so "install
/// Steam" would point at a fix the box already had) and the cable was missing (so VB-CABLE
/// is the advice that actually frees the silent sink).
#[test]
fn describe_no_loopback_skips_satisfied_remedies() {
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
assert!(msg.contains("VB-Audio Virtual Cable"), "{msg}");
assert!(!msg.contains("install Steam"), "{msg}");
// Cable-only headless box: VB-CABLE is already installed (and freeing it wouldn't help
// anyway), while the Steam pair is the remedy that adds a capturable sink.
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
let w = plan(&renders, &captures, None, false);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("install Steam"), "{msg}");
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
}
}