forked from unom/punktfunk
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.
|
// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user