feat(host/mgmt): the audio wiring verdict joins /status

RuntimeStatus gains an 'audio' object (Windows hosts): readiness
(full/audio_only/mic_only/none), the friendly names carrying each role,
and the three degradation flags (mic_withheld, last_resort, narrowing) —
the verdicts that previously lived only in tracing logs. Snapshot of the
last wiring pass (the mic pump wires at host start and on every reopen);
a status poll never triggers COM work or IPolicyConfig writes.
This commit is contained in:
2026-08-07 11:53:15 +02:00
parent 79c72fa64d
commit 3870cdd1da
3 changed files with 74 additions and 2 deletions
+12
View File
@@ -217,3 +217,15 @@ pub(crate) mod capture_policy;
mod mic_jitter;
mod mic_pump;
pub use mic_pump::{MicFrame, MicPump};
/// The most recent audio wiring verdict — the LAST wiring pass's assignment on a Windows host,
/// `None` elsewhere or before the first pass. A read-only snapshot for the status API; never
/// triggers a pass.
#[cfg(target_os = "windows")]
pub(crate) fn wiring_snapshot() -> Option<wiring_plan::Wiring> {
audio_control::last_wiring()
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn wiring_snapshot() -> Option<wiring_plan::Wiring> {
None
}
@@ -149,6 +149,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
wire_now_full(set_playback).wiring
}
/// The most recent wiring verdict, as the LAST wiring pass computed it (the mic pump wires
/// eagerly at host start and on every reopen, so this is fresh in the steady state). Change
/// detection for the once-per-change log lives on the same cell.
static LAST_WIRING: Mutex<Option<Wiring>> = Mutex::new(None);
/// Read-only snapshot of [`LAST_WIRING`] for the status API — never triggers a wiring pass
/// (a pass does COM work and IPolicyConfig writes; a status poll must do neither).
pub(crate) fn last_wiring() -> Option<Wiring> {
LAST_WIRING.lock().unwrap().clone()
}
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
/// devnode marker, registry-only reads); this is just the per-pass collection.
@@ -217,9 +228,8 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
};
// Log assignment changes exactly once (first plan included).
static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
let changed = {
let mut last = LAST.lock().unwrap();
let mut last = LAST_WIRING.lock().unwrap();
let changed = last.as_ref() != Some(&wiring);
*last = Some(wiring.clone());
changed
+50
View File
@@ -129,6 +129,55 @@ pub(crate) struct RuntimeStatus {
/// any game whose session has ended and which is waiting out its reconnect window before being
/// ended (`state: "grace"`). Empty when nothing was launched — a plain desktop stream has no game.
games: Vec<ActiveGame>,
/// The audio wiring verdict (Windows hosts; absent on other platforms and before the first
/// wiring pass). Present even while idle — the wiring exists for the host's lifetime.
#[serde(skip_serializing_if = "Option::is_none")]
audio: Option<AudioWiring>,
}
/// The Windows host's audio wiring verdict — which endpoint carries each role. The names are
/// the endpoints' friendly names as the Sound settings show them (on current hosts the minted
/// "Punktfunk" instances of Steam's streaming drivers).
#[derive(Serialize, ToSchema)]
pub(crate) struct AudioWiring {
/// `full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough
/// each have an endpoint at all.
#[schema(example = "full")]
readiness: String,
/// Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable.
#[serde(skip_serializing_if = "Option::is_none")]
loopback: Option<String>,
/// Friendly name of the virtual-mic write target; absent = mic passthrough unavailable.
#[serde(skip_serializing_if = "Option::is_none")]
mic: Option<String>,
/// The mic was WITHHELD so game audio could keep the only working sink — mic passthrough
/// needs Steam installed (the host mints its own microphone) or a virtual cable.
mic_withheld: bool,
/// The loopback is the known-degraded last resort — desktop audio may be silent until the
/// endpoint set changes.
last_resort: bool,
/// Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does.
#[serde(skip_serializing_if = "Option::is_none")]
narrowing: Option<String>,
}
/// The wiring snapshot mapped for the API — `None` off-Windows or before the first pass.
fn audio_wiring() -> Option<AudioWiring> {
use crate::audio::wiring_plan as wp;
crate::audio::wiring_snapshot().map(|w| AudioWiring {
readiness: match wp::readiness(&w) {
wp::AudioReadiness::Full => "full",
wp::AudioReadiness::AudioOnly => "audio_only",
wp::AudioReadiness::MicOnly => "mic_only",
wp::AudioReadiness::Nothing => "none",
}
.into(),
loopback: w.loopback_render.map(|(n, _)| n),
mic: w.mic_render.map(|(n, _)| n),
mic_withheld: w.mic_withheld,
last_resort: w.loopback_last_resort,
narrowing: w.loopback_narrowing,
})
}
/// One launched game, for the console's running-game card.
@@ -461,6 +510,7 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
grace_remaining_s: g.grace_remaining_s,
})
.collect(),
audio: audio_wiring(),
})
}