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