From 652abeb397b438d1fc6abf7f48f4bd68cdc1235a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 09:44:14 +0200 Subject: [PATCH] fix(host/audio): an unsatisfiable wiring plan waits for an endpoint change instead of hammering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/audio/windows/audio_control.rs | 86 +++++- .../src/audio/windows/wasapi_cap.rs | 254 ++++++++++++--- .../punktfunk-host/src/audio/wiring_plan.rs | 292 ++++++++++++++++-- 3 files changed, 551 insertions(+), 81 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 785373bb..2760a8f1 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -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, +} + +/// 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> = 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::>(), "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 { .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 { + 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 diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 2dfcbfe2..0e985754 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -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 = 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::().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::() { + // 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, +) -> 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>, 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 = 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)); + } + } } } diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 10314419..15bdca75 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -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, /// Render endpoint for the desktop-audio loopback; made the default playback device. pub loopback_render: Option, + /// `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 = 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}"); + } }