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. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::wiring_plan::{plan, Endpoint, Wiring}; use super::wiring_plan::{self, plan, Endpoint, Wiring};
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::Mutex; use std::sync::Mutex;
@@ -75,6 +75,33 @@ pub(crate) fn host_audio_requested() -> bool {
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() 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 /// 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 /// `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 /// 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). /// 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 /// Logged only when the assignment changes, so per-open recomputation stays quiet in the steady
/// state. /// state.
pub(crate) fn wire_now(set_playback: bool) -> Wiring { pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
recover_orphaned_default(); recover_orphaned_default();
let renders = list_endpoints(Direction::Render); let renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture); let captures = list_endpoints(Direction::Capture);
let fingerprint = wiring_plan::fingerprint(&renders, &captures);
let want = std::env::var("PUNKTFUNK_MIC_DEVICE") let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok() .ok()
.map(|s| s.to_lowercase()); .map(|s| s.to_lowercase());
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested()); 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). // Log assignment changes exactly once (first plan included).
static LAST: Mutex<Option<Wiring>> = Mutex::new(None); 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_render = wiring.mic_render.as_ref().map(|(n, _)| n.as_str()),
mic_capture = wiring.mic_capture.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_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<_>>(), renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
"audio wiring plan" "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!( tracing::warn!(
"the virtual mic reserved the only usable render endpoint — desktop audio will be \ "desktop audio unavailable: {}",
unavailable until another output device exists (attach one, or let the host \ wiring_plan::describe_no_loopback(&renders, &wiring)
install the Steam Streaming pair)"
); );
} }
} }
@@ -123,7 +159,7 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched" "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 // 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 // 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 { if let Some((name, id)) = &wiring.mic_capture {
match set_default_endpoint(id) { // `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
Ok(()) => { // fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
if changed { // device-graph notification, each). Re-asserting on every wiring pass therefore both
tracing::info!(device = %name, // churned the policy store AND silently stomped an operator's own recording-device
"audio wiring: default recording = virtual mic (apps record the client's mic)"); // 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: /// 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() .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 /// 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 /// 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 /// 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: //! 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 //! 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 //! 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 //! plan. Device errors (endpoint invalidated, engine restart) reopen with a capped exponential
//! killing audio for the rest of the session. On thread exit (capturer dropped at stream end) //! backoff that an endpoint-set change cuts short. A plan with NO loopback endpoint at all is
//! the parked default playback device is restored. //! 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 super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
@@ -131,8 +136,20 @@ enum Next {
Reopen(TargetMode), Reopen(TargetMode),
} }
/// Backoff between self-heal reopen attempts after a capture failure. /// Reopen backoff after a TRANSIENT capture failure: starts here and doubles per consecutive
const REOPEN_BACKOFF: Duration = Duration::from_secs(2); /// 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. /// Watchdog cadence for "did the default render device change under us?" checks.
const DEFAULT_CHECK_EVERY: Duration = Duration::from_secs(1); const DEFAULT_CHECK_EVERY: Duration = Duration::from_secs(1);
/// Total attempts for the FIRST open before its failure surfaces through the `ready` handshake. /// 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 mode = TargetMode::Assert;
let mut failures: u64 = 0; let mut failures: u64 = 0;
let mut first_attempts: u32 = 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) { while !stop.load(Ordering::Relaxed) {
match capture_once(&tx, &stop, &mut ready, channels, mode) { match capture_once(&tx, &stop, &mut ready, channels, mode) {
Ok(Next::Stopped) => break, Ok(Next::Stopped) => break,
Ok(Next::Reopen(m)) => { Ok(Next::Reopen(m)) => {
mode = m; mode = m;
failures = 0; failures = 0;
backoff = REOPEN_BACKOFF_START;
unsat_logged = None;
} }
Err(e) if ready.is_some() => { 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; first_attempts += 1;
if first_attempts >= FIRST_OPEN_ATTEMPTS || stop.load(Ordering::Relaxed) { if first_attempts >= FIRST_OPEN_ATTEMPTS || stop.load(Ordering::Relaxed) {
let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}"))); let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}")));
@@ -190,16 +223,43 @@ fn capture_thread(
} }
} }
Err(e) => { 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; mode = TargetMode::Assert;
// Backoff in stop-responsive slices. if let Some(unsat) = e.downcast_ref::<PlanUnsatisfiable>() {
let until = Instant::now() + REOPEN_BACKOFF; // Structural: retrying against the same endpoints repeats the same verdict,
while Instant::now() < until && !stop.load(Ordering::Relaxed) { // and every retry used to re-run the wiring pass — IPolicyConfig writes
thread::sleep(Duration::from_millis(100)); // 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(()) 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 — /// The current default render endpoint, with its id (`None` on any enumeration failure —
/// transient failures must not kill the capture). /// transient failures must not kill the capture).
fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> { 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: /// 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 /// 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( fn capture_once(
tx: &SyncSender<Vec<f32>>, tx: &SyncSender<Vec<f32>>,
stop: &AtomicBool, stop: &AtomicBool,
@@ -233,26 +362,23 @@ fn capture_once(
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some(); 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. // Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
let assert_plan = mode == TargetMode::Assert && !keep_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 // 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 — // 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. // once per process — to install the Steam pair (present when Steam is), then re-plan.
if assert_plan && !audio_control::host_audio_requested() { if assert_plan && !audio_control::host_audio_requested() {
let have_silent = wiring let have_silent = |w: &wiring_plan::Wiring| {
.loopback_render 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 && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
if super::wasapi_mic::install_steam_audio_pair() {
wiring = audio_control::wire_now(true);
}
if !wiring
.loopback_render
.as_ref() .as_ref()
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase())) .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!( tracing::info!(
"no silent virtual sink for client-only audio — desktop audio will also play \ "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)" 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")?; let en = DeviceEnumerator::new().context("DeviceEnumerator")?;
// Resolve the endpoint to capture. ECHO GUARD (Follow/KEEP_DEFAULT shapes): the wiring plan // 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. // 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 (device, dev_name, dev_id) = if assert_plan {
let Some(ep) = wiring.loopback_render.clone() else { let Some(ep) = wiring.loopback_render.clone() else {
anyhow::bail!( // Detected BEFORE any open attempt, and typed: the plan is a pure function of the
"no loopback-capturable render endpoint (every usable endpoint is reserved for \ // endpoint set, so this cannot resolve until the set changes — the reopen loop
the virtual mic or has a silent loopback) — attach an output device or install \ // waits on the fingerprint instead of retrying (the old untyped bail was retried
the Steam Streaming pair to get desktop audio" // 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)?; let d = audio_control::open_endpoint(&ep)?;
(d, ep.0, ep.1) (d, ep.0, ep.1)
@@ -285,10 +417,15 @@ fn capture_once(
.is_some_and(|(_, mic_id)| *mic_id == id); .is_some_and(|(_, mic_id)| *mic_id == id);
if default_is_mic { if default_is_mic {
let Some(lb) = wiring.loopback_render.clone() else { 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!( anyhow::bail!(
"the only render endpoint is reserved for the virtual mic (capturing it would \ "the default render endpoint is reserved for the virtual mic (capturing it \
echo the client's voice back) — attach another output device or install the \ would echo the client's voice back) — {}",
Steam Streaming pair to get desktop audio" wiring_plan::describe_no_loopback(&plan.renders, wiring)
); );
}; };
tracing::warn!(mic = %wiring.mic_render.as_ref().unwrap().0, loopback = %lb.0, 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, tracing::info!(device = %dev_name,
follow = matches!(mode, TargetMode::Follow) || keep_default, follow = matches!(mode, TargetMode::Follow) || keep_default,
last_resort,
"audio loopback capturing"); "audio loopback capturing");
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just // 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 assert_plan {
if let Some(d) = seen_default.as_deref() { if let Some(d) = seen_default.as_deref() {
if d != dev_id { if d != dev_id {
match judge_default(&en, &wiring, d) { match judge_default(&en, wiring, d) {
DefaultKind::Capturable(name) => { DefaultKind::Capturable(name) => {
tracing::info!(default = %name, planned = %dev_name, tracing::info!(default = %name, planned = %dev_name,
"could not park the default playback on the planned endpoint — \ "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 bytes: VecDeque<u8> = VecDeque::new();
let mut last_check = Instant::now(); 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 // Triage breadcrumb: a broken loopback (endpoint renders but its loopback tap delivers
// nothing — the Steam Streaming Speakers failure shape) is indistinguishable from a simply // 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 // quiet desktop, so after 30 s with zero packets say so ONCE. Info, not warn an idle host
// is legitimately silent. // 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 opened_at = Instant::now();
let mut saw_packets = false; let mut saw_packets = false;
let mut silence_noted = 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) { if !saw_packets && !silence_noted && opened_at.elapsed() >= Duration::from_secs(30) {
silence_noted = true; silence_noted = true;
tracing::info!(device = %dev_name, if last_resort {
"no audio captured in the first 30 s — fine if the host is quiet; if it should \ tracing::warn!(device = %dev_name,
be playing audio, this endpoint's loopback may be broken (set \ "no audio captured in the first 30 s from the LAST-RESORT loopback — the \
PUNKTFUNK_HOST_AUDIO=1 to prefer real hardware)"); 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; let whole = (bytes.len() / block_align) * block_align;
if whole > 0 { if whole > 0 {
@@ -428,7 +576,7 @@ fn capture_once(
); );
return Ok(Next::Reopen(TargetMode::Follow)); 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) => { DefaultKind::Capturable(name) => {
tracing::info!(device = %name, tracing::info!(device = %name,
"operator changed the output device mid-stream — following \ "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). //! 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 //! With `host_audio` (the `PUNKTFUNK_HOST_AUDIO` opt-in) the order flips back: real hardware
//! first, so the operator hears the stream locally. //! 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. /// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
pub(crate) type Endpoint = (String, String); pub(crate) type Endpoint = (String, String);
@@ -42,6 +55,22 @@ pub(crate) struct Wiring {
pub mic_capture: Option<Endpoint>, pub mic_capture: Option<Endpoint>,
/// Render endpoint for the desktop-audio loopback; made the default playback device. /// Render endpoint for the desktop-audio loopback; made the default playback device.
pub loopback_render: Option<Endpoint>, 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, /// 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 // 3. Loopback from the REMAINING renders. Client-only (default): the silent sink (Steam
// Streaming Microphone — its loopback works, unlike the Speakers') > real hardware // 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 not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id);
let real_hw = || { let real_hw = || {
renders.iter().find(|(n, id)| { renders.iter().find(|(n, id)| {
@@ -147,29 +177,131 @@ pub(crate) fn plan(
.iter() .iter()
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase())) .find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
}; };
// `virtualish` here too: a virtual endpoint that slipped past `excluded_from_loopback`'s // LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent
// name list (a future cable/mixer sibling) is an internal-feedback loop waiting to happen — // (validated live): a QUALITY risk, flagged so the capture side can warn when the silence
// no loopback is the honest answer, exactly like the cable-only case. // materializes and re-plan when the endpoint set changes — but a parked endpoint beats none
let leftover = || { // (2026-08: the display isolate invalidated the only real render endpoint mid-session and a
renders.iter().find(|(n, id)| { // loopback-less plan left the session unrecoverable). Never a cable, a VoiceMeeter strip, or
let ln = n.to_lowercase(); // a generically-"virtual" endpoint: those re-capture what the mic writes — echo/feedback
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln) // 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 { let preferred = if host_audio {
real_hw().or_else(silent).or_else(leftover) real_hw().or_else(silent)
} else { } else {
silent().or_else(real_hw).or_else(leftover) silent().or_else(real_hw)
} };
.cloned(); 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 { Wiring {
mic_render, mic_render,
mic_capture, mic_capture,
loopback_render, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -296,6 +428,10 @@ mod tests {
w.loopback_render.unwrap().0, w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)" "Speakers (Steam Streaming Microphone)"
); );
assert!(
!w.loopback_last_resort,
"the silent sink is a PREFERRED pick"
);
assert_eq!( assert_eq!(
w.mic_capture.unwrap().0, w.mic_capture.unwrap().0,
"CABLE Output (VB-Audio Virtual Cable)" "CABLE Output (VB-Audio Virtual Cable)"
@@ -330,16 +466,88 @@ mod tests {
assert!(w.loopback_render.is_none()); assert!(w.loopback_render.is_none());
} }
/// Steam Streaming Speakers never become the loopback (silent loopback, validated live) /// Steam Streaming Speakers are never a PREFERRED loopback (their loopback is silent
/// even when they're the only non-mic endpoint. /// 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] #[test]
fn steam_speakers_never_loopback() { fn steam_speakers_only_as_last_resort() {
let renders = [ let renders = [
ep("CABLE Input (VB-Audio Virtual Cable)"), ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Speakers)"), ep("Speakers (Steam Streaming Speakers)"),
]; ];
let w = plan(&renders, &[], None, false); for host_audio in [false, true] {
assert!(w.loopback_render.is_none()); 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. /// Operator override beats the candidate order.
@@ -413,9 +621,9 @@ mod tests {
} }
} }
/// A generically-"virtual" leftover (unknown vendor cable) is refused too: `leftover()` /// A generically-"virtual" leftover (unknown vendor cable) is refused too: the last resort
/// applies `virtualish`, so a virtual endpoint that slips past the name list can't become /// accepts ONLY the Steam Streaming Speakers, so a virtual endpoint that slips past
/// the loopback. /// `excluded_from_loopback`'s name list still can't become the loopback.
#[test] #[test]
fn unknown_virtual_never_loopback() { fn unknown_virtual_never_loopback() {
let renders = [ let renders = [
@@ -425,4 +633,44 @@ mod tests {
let w = plan(&renders, &[], None, false); let w = plan(&renders, &[], None, false);
assert!(w.loopback_render.is_none()); 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}");
}
} }