diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index c02ef5e0..2f3af04d 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -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 { + audio_control::last_wiring() +} +#[cfg(not(target_os = "windows"))] +pub(crate) fn wiring_snapshot() -> Option { + None +} diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index b40d5e93..0f7f6e97 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -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> = 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 { + 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> = 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 diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs index 5bd49ebf..f72f4b7b 100644 --- a/crates/punktfunk-host/src/mgmt/host.rs +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -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, + /// 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, +} + +/// 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, + /// Friendly name of the virtual-mic write target; absent = mic passthrough unavailable. + #[serde(skip_serializing_if = "Option::is_none")] + mic: Option, + /// 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, +} + +/// The wiring snapshot mapped for the API — `None` off-Windows or before the first pass. +fn audio_wiring() -> Option { + 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>) -> Json