From 4d89dcd3d7e973fc94384c5653a087bb6a9f1500 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 17:00:00 +0200 Subject: [PATCH] fix(audio/windows): explicit-endpoint capture, client-only playback, self-healing watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the field report (Android client, Windows host: no audio until the user manually cycled Sound-output devices, then audio on BOTH PC and phone): the WASAPI loopback captured whatever the default render endpoint was at open time — not the wiring plan's chosen endpoint — the plan's IPolicyConfig default-set is warn-only and racy, and nothing reacted to mid-stream default-device changes. - Explicit-endpoint capture: the capture thread opens the plan's loopback_render by id, never "the default" (KEEP_DEFAULT preserves the old default-capturing behavior with the echo guard). - Client-only playback default: wiring_plan::plan(..., host_audio) prefers a silent sink (Steam Streaming Microphone render side — loopback-validated, silent on host) over real hardware, so stream audio plays on the client only; PUNKTFUNK_HOST_AUDIO=1 restores real-hw-first (audible on the host). The capture side auto-installs the Steam pair once per process when no silent sink exists; open() handshake timeout 3s -> 30s to cover it, and a handshake timeout now stops the detached thread (it used to run for the process lifetime with the default still parked). - Self-healing capture thread (wasapi_cap): outer capture_once loop (Assert|Follow) with a ~1s watchdog on the default render id. A user switch to a capturable endpoint is followed (their choice wins, audio on both); a switch to a dud (cable/SSS/mic target) re-asserts the plan; IPolicyConfig-denied converges to Follow instead of churning. Device errors reopen with 2s backoff; only the FIRST open failure is fatal. Zero-packets breadcrumb after 30s distinguishes broken-loopback from quiet-desktop. - Park/restore of the default playback device (audio_control): wire_now(set_playback) parks the default on the loopback sink only for the capture's lifetime (the mic pump passes false — it runs while the host is idle); crash marker audio-default.prev + recover_orphaned_default() at first wire; restore is skipped if the operator changed the default themselves. A mic-default hygiene pass keeps VB-Cable installs audible and never records the mic target as the restore target. - Session-end park_audio_capture(): Windows DROPS the capturer (thread join restores the default) instead of caching it; Linux keeps the parked PipeWire thread. Composes with the stream-sink idle() hook at all three park sites (idle is a no-op on Windows). Verified: Linux (.21) clippy -D warnings + 176 punktfunk-host tests green (incl. the new wiring-plan preference tests); Windows (.173) clippy with nvenc,amf-qsv --all-targets -D warnings green at this exact tree. On-glass winbox/Android validation still owed. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/audio.rs | 20 +- .../src/audio/windows/audio_control.rs | 178 ++++++- .../src/audio/windows/wasapi_cap.rs | 434 ++++++++++++++---- .../src/audio/windows/wasapi_mic.rs | 16 +- .../punktfunk-host/src/audio/wiring_plan.rs | 148 ++++-- crates/punktfunk-host/src/gamestream/audio.rs | 2 +- crates/punktfunk-host/src/native/audio.rs | 9 +- 7 files changed, 658 insertions(+), 149 deletions(-) diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index b37a6bb5..f1997202 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -56,7 +56,9 @@ pub fn open_audio_capture(channels: u32) -> Result> { #[cfg(target_os = "windows")] pub fn open_audio_capture(channels: u32) -> Result> { // The capture thread runs the audio wiring plan itself (audio_control::wire_now) before - // resolving its endpoint — a fresh plan per open, because Windows endpoints churn. + // resolving its endpoint — a fresh plan per open, because Windows endpoints churn — and + // parks the default playback device on the plan's loopback endpoint (a silent sink by + // default: audio plays on the client only) until the capturer is dropped. wasapi_cap::WasapiLoopbackCapturer::open(channels) .map(|c| Box::new(c) as Box) } @@ -66,6 +68,22 @@ pub fn open_audio_capture(_channels: u32) -> Result> { anyhow::bail!("audio capture requires Linux + PipeWire or Windows + WASAPI") } +/// Park a capturer at session end. Linux: store it in the persistent slot so the next session +/// reuses it (no PipeWire thread churn). Windows: DROP it instead — closing the capture restores +/// the operator's default playback device (it was parked on the loopback sink for the stream's +/// lifetime, silencing the host), and a WASAPI reopen at the next session start is cheap and +/// re-runs the wiring plan against the then-current endpoints. +pub fn park_audio_capture( + slot: &std::sync::Mutex>>, + cap: Box, +) { + if cfg!(target_os = "windows") { + drop(cap); + } else { + *slot.lock().unwrap() = Some(cap); + } +} + /// The inverse of [`AudioCapturer`]: a virtual microphone the host *produces*. It registers a /// PipeWire `Audio/Source` node that host apps can record from; the host [`push`](Self::push)es /// decoded client-mic PCM (interleaved `f32` at [`SAMPLE_RATE`]) into it, and PipeWire delivers diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 92e7a3a0..711c9324 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -11,12 +11,20 @@ //! * the **mic inject target** is assigned FIRST (VB-Cable "CABLE Input" preferred) — mic passthrough //! is what the cable is bundled for, so it wins the cable even when the cable is the only render //! endpoint on the box (the loopback then reports itself unavailable instead of echoing); -//! * default **PLAYBACK** → a loopback-capable render that is NOT the mic target (a real output device -//! if one exists, else the Steam Streaming Microphone; **never** the Steam Streaming Speakers, whose -//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] captures; +//! * default **PLAYBACK** → the plan's loopback endpoint, applied ONLY while a desktop-audio capture +//! is open (`set_playback` — the mic pump must never park the playback default while the host is +//! idle). By default that endpoint is the SILENT sink (Steam Streaming Microphone render side) so +//! audio plays on the client only; `PUNKTFUNK_HOST_AUDIO` prefers real hardware instead (audible on +//! both ends). **Never** the Steam Streaming Speakers, whose loopback is silent — validated live; //! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps //! record the client's mic by default. //! +//! Because the playback default is *parked* on a silent sink during a stream, it is remembered +//! ([`park_default_playback`], plus an on-disk crash marker) and put back when the capture closes +//! ([`restore_default_playback`]) or, after a crash, on the next process's first wiring pass — an +//! operator must never be stranded with silent speakers. A default the operator changed themselves +//! mid-stream is respected (no restore over their choice). +//! //! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every //! platform); this module only enumerates endpoints, applies the plan, and logs. [`wire_now`] runs on //! every mic/capture (re)open — NOT once per process — because endpoints churn (boot-time @@ -61,18 +69,28 @@ fn list_endpoints(dir: Direction) -> Vec { out } +/// `PUNKTFUNK_HOST_AUDIO`: the operator wants the stream audible on the host too — the loopback +/// plan prefers real hardware over the silent sink (the pre-client-only-default behavior). +pub(crate) fn host_audio_requested() -> bool { + std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() +} + /// 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). 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() -> Wiring { +/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally +/// parks the default PLAYBACK device on the plan's loopback endpoint for the capture's lifetime +/// (the mic pump passes false: it runs while the host is idle and must not silence the box). +/// 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 { + recover_orphaned_default(); let renders = list_endpoints(Direction::Render); let captures = list_endpoints(Direction::Capture); let want = std::env::var("PUNKTFUNK_MIC_DEVICE") .ok() .map(|s| s.to_lowercase()); - let wiring = plan(&renders, &captures, want.as_deref()); + let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested()); // Log assignment changes exactly once (first plan included). static LAST: Mutex> = Mutex::new(None); @@ -107,16 +125,38 @@ pub(crate) fn wire_now() -> Wiring { } return wiring; } - if let Some((name, id)) = &wiring.loopback_render { - match set_default_endpoint(id) { - Ok(()) => { - if changed { - tracing::info!(device = %name, - "audio wiring: default playback = desktop-audio loopback source"); + // 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 + // app renders its audio INTO the virtual mic: recorders hear the desktop mix and the operator + // hears nothing. Move the default to an audible endpoint. The pre-split wire_now covered this + // as a side effect of always setting the playback default; the `set_playback` split must not + // lose it — and it must run BEFORE parking, so a parked `prev` can never be the mic target + // (restoring the cable as default after a stream would re-break the box). + if let Some((mic_name, mic_id)) = &wiring.mic_render { + if default_render_id().as_deref() == Some(mic_id.as_str()) { + // Audible preference = the host_audio plan's loopback pick (real hardware first). + match plan(&renders, &captures, want.as_deref(), true).loopback_render { + Some((name, id)) => match set_default_endpoint(&id) { + Ok(()) => tracing::info!(mic = %mic_name, device = %name, + "default playback was the virtual-mic target — moved it so desktop \ + audio no longer feeds the mic"), + Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), + "failed to move the default playback off the virtual-mic target"), + }, + None => { + if changed { + tracing::warn!(mic = %mic_name, + "default playback is the virtual-mic target and no other usable \ + render endpoint exists — desktop audio will feed the mic"); + } } } - Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), - "audio wiring: failed to set the default playback device"), + } + } + if set_playback { + if let Some((name, id)) = &wiring.loopback_render { + let mic_id = wiring.mic_render.as_ref().map(|(_, m)| m.as_str()); + park_default_playback(name, id, changed, mic_id); } } if let Some((name, id)) = &wiring.mic_capture { @@ -134,6 +174,112 @@ pub(crate) fn wire_now() -> Wiring { wiring } +/// The operator's default playback endpoint while we have it parked on the loopback sink: +/// `(previous_id, id_we_set)`. In-memory source of truth; mirrored to [`park_marker_path`] so a +/// crashed host can't strand the box on the silent sink. +static PARKED: Mutex> = Mutex::new(None); + +/// On-disk crash marker mirroring [`PARKED`] (two lines: previous id, set id). +fn park_marker_path() -> std::path::PathBuf { + pf_paths::config_dir().join("audio-default.prev") +} + +/// The current default RENDER endpoint id, if any. +fn default_render_id() -> Option { + wasapi::DeviceEnumerator::new() + .ok()? + .get_default_device(&Direction::Render) + .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 +/// (the mic pump wires eagerly at host start, so this fires at boot, not at the first stream). +fn recover_orphaned_default() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let path = park_marker_path(); + let Ok(s) = std::fs::read_to_string(&path) else { + return; + }; + let _ = std::fs::remove_file(&path); + let mut lines = s.lines(); + let (Some(prev), Some(set)) = (lines.next(), lines.next()) else { + return; + }; + if default_render_id().as_deref() != Some(set) { + return; + } + match set_default_endpoint(prev) { + Ok(()) => tracing::info!( + "restored the default playback device a previous host run left parked" + ), + Err(e) => tracing::warn!(error = %format!("{e:#}"), + "failed to restore the default playback device left by a previous run"), + } + }); +} + +/// Make `id` the default playback device for the duration of the desktop-audio capture, +/// remembering the operator's current default (in memory + the crash marker) the FIRST time so +/// [`restore_default_playback`] can put it back. Nothing is remembered when `id` already is the +/// default — there is nothing to restore. The MIC target is never remembered as the previous +/// default (restoring it would feed desktop audio into the virtual mic — the hygiene pass in +/// [`wire_now`] normally moved the default off it already; this guards the propagation race). +fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&str>) { + let cur = default_render_id(); + if cur.as_deref() != Some(id) { + let mut parked = PARKED.lock().unwrap(); + match parked.as_mut() { + None => { + if let Some(prev) = cur.filter(|c| Some(c.as_str()) != mic_id) { + let _ = std::fs::write(park_marker_path(), format!("{prev}\n{id}")); + *parked = Some((prev, id.to_string())); + } + } + // Re-park onto a different endpoint mid-stream (plan changed): keep the ORIGINAL + // previous default, update what we set. + Some((prev, set)) if set != id => { + let _ = std::fs::write(park_marker_path(), format!("{prev}\n{id}")); + *set = id.to_string(); + } + Some(_) => {} + } + } + match set_default_endpoint(id) { + Ok(()) => { + if changed { + tracing::info!(device = %name, + "audio wiring: default playback = desktop-audio loopback source"); + } + } + Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"), + "audio wiring: failed to set the default playback device"), + } +} + +/// Put the operator's default playback device back after streaming — the inverse of +/// [`park_default_playback`]. No-op if we never parked it, and a default the operator changed +/// themselves mid-stream is left alone (their choice wins). Must run on a COM-initialized thread +/// (called from the capture thread's exit path). +pub(crate) fn restore_default_playback() { + let Some((prev, set)) = PARKED.lock().unwrap().take() else { + return; + }; + let _ = std::fs::remove_file(park_marker_path()); + if default_render_id().as_deref() != Some(set.as_str()) { + return; + } + match set_default_endpoint(&prev) { + Ok(()) => tracing::info!("default playback device restored after streaming"), + Err(e) => tracing::warn!(error = %format!("{e:#}"), + "failed to restore the default playback device after streaming"), + } +} + /// Open a device by endpoint id, with a name for error context. pub(crate) fn open_endpoint(ep: &Endpoint) -> Result { wasapi::DeviceEnumerator::new() diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 09f394e9..17469cbc 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -1,20 +1,36 @@ -//! WASAPI loopback capture of the default render endpoint (system output) — the Windows analogue -//! of the PipeWire sink-monitor backend. Delivers interleaved f32 PCM at 48 kHz in the requested +//! WASAPI loopback capture of the desktop mix (system output) — the Windows analogue of the +//! PipeWire sink-monitor backend. Delivers interleaved f32 PCM at 48 kHz in the requested //! channel count (stereo / 5.1 / 7.1, canonical wire order FL FR FC LFE RL RR SL SR via the //! explicit `dwChannelMask`), ready for the Opus path with NO resampling (WASAPI shared-mode //! autoconvert does any SRC + up/downmix to the requested layout). WASAPI objects are //! COM-apartment-bound and not `Send`, so they live on a dedicated thread (mirrors //! `linux::PwAudioCapturer`); only the channel + stop flag + join handle are in the struct. +//! +//! **Which endpoint, and self-healing.** The capture thread opens the wiring plan's loopback +//! endpoint EXPLICITLY — never "whatever the default happens to be": binding to the default +//! raced the plan's own `IPolicyConfig` default change and captured duds when that change failed +//! or the operator's default sat on a silent endpoint ("CABLE In 16ch", Steam Streaming +//! Speakers) — the field-reported "no audio until I cycled output devices" failure. The plan +//! also parks the default playback device on the loopback endpoint (a silent sink by default — +//! client-only audio; see [`super::wiring_plan`]) so app streams migrate to it. +//! +//! The thread then self-heals for its whole life: a ~1 s watchdog notices the default render +//! 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. -use super::{audio_control, AudioCapturer, SAMPLE_RATE}; +use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE}; use anyhow::{anyhow, Context, Result}; use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender}; use std::sync::Arc; use std::thread::{self, JoinHandle}; -use std::time::Duration; -use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat}; +use std::time::{Duration, Instant}; +use wasapi::{Device, DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat}; pub struct WasapiLoopbackCapturer { chunks: Receiver>, @@ -43,12 +59,11 @@ impl WasapiLoopbackCapturer { } }) .context("spawn wasapi audio thread")?; - match ready_rx.recv_timeout(Duration::from_secs(3)) { + // Generous handshake: the first open may auto-install the Steam Streaming pair (two + // driver installs, ~5 s of settling each) before the endpoint exists. + match ready_rx.recv_timeout(Duration::from_secs(30)) { Ok(Ok(())) => { - tracing::info!( - channels, - "WASAPI loopback capture: 48 kHz f32 (default render endpoint)" - ); + tracing::info!(channels, "WASAPI loopback capture: 48 kHz f32"); Ok(WasapiLoopbackCapturer { chunks: rx, channels, @@ -57,9 +72,13 @@ impl WasapiLoopbackCapturer { }) } Ok(Err(e)) => Err(e), - Err(_) => Err(anyhow!( - "wasapi loopback init timed out (no default render endpoint?)" - )), + Err(_) => { + // The thread outlived the handshake (stalled driver install / hung endpoint). + // Tell it to stop — otherwise it would keep capturing detached for the process + // lifetime WITH the playback default still parked (restore only runs on exit). + stop.store(true, Ordering::SeqCst); + Err(anyhow!("wasapi loopback init timed out")) + } } } } @@ -91,14 +110,37 @@ impl AudioCapturer for WasapiLoopbackCapturer { } } +/// How one open chooses its capture endpoint. +#[derive(Clone, Copy, PartialEq, Eq)] +enum TargetMode { + /// Capture the wiring plan's loopback endpoint and park the default playback device on it + /// (client-only audio when the plan found a silent sink). The initial and snap-back mode. + Assert, + /// Capture the CURRENT default render endpoint — the operator changed the default mid-stream + /// to a capturable device and their choice wins (audio then also plays on the host). Also the + /// resolution under `PUNKTFUNK_KEEP_DEFAULT`. + Follow, +} + +/// Why one open's inner loop ended. +enum Next { + /// `stop` was set — the capturer is being dropped. + Stopped, + /// Reopen in the given mode (default-device change observed). + Reopen(TargetMode), +} + +/// Backoff between self-heal reopen attempts after a capture failure. +const REOPEN_BACKOFF: 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); + fn capture_thread( tx: SyncSender>, stop: Arc, ready: SyncSender>, channels: u32, ) -> Result<()> { - // Interleaved f32: channels * 4 bytes per frame. - let block_align = channels as usize * 4; // COM must be initialized on THIS thread (MTA), before any device call. if let Err(e) = wasapi::initialize_mta() .ok() @@ -107,25 +149,125 @@ fn capture_thread( let _ = ready.send(Err(e)); return Ok(()); } - let res = (|| -> Result<()> { - // Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE - // client with loopback=true over it. ECHO GUARD: the wiring plan reserves one endpoint for - // the virtual mic (`super::wasapi_mic` writes the client's voice there) — capturing THAT - // endpoint would stream the client's own mic straight back to it. Normally the plan has - // already moved the default playback elsewhere; if the default still IS the mic target - // (PUNKTFUNK_KEEP_DEFAULT, or the cable is the only endpoint), capture the plan's loopback - // endpoint explicitly, or refuse — no desktop audio beats an echo loop. - let wiring = audio_control::wire_now(); - let default = DeviceEnumerator::new() - .context("DeviceEnumerator")? - .get_default_device(&Direction::Render) - .context("default render endpoint (loopback needs a render device)")?; - let default_is_mic = match (&wiring.mic_render, default.get_id()) { - (Some((_, mic_id)), Ok(id)) => *mic_id == id, - _ => false, + // Self-heal for the capturer's whole life: each `capture_once` is one endpoint open + inner + // capture loop; it returns to reopen (default-device change) or errors (device invalidated, + // engine restart), and only the FIRST open's failure is fatal — it surfaces as `open()`'s Err + // so the session honestly runs without audio (the native plane retries with its own backoff). + let mut ready = Some(ready); + let mut mode = TargetMode::Assert; + let mut failures: u64 = 0; + 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; + } + Err(e) => { + if let Some(r) = ready.take() { + let _ = r.send(Err(anyhow!("{e:#}"))); + break; + } + 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)); + } + } + } + } + // Hand the default playback device back to the operator (no-op if we never parked it, or if + // they changed it themselves mid-stream). COM is initialized on this thread. + audio_control::restore_default_playback(); + Ok(()) +} + +/// 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)> { + let d = en.get_default_device(&Direction::Render).ok()?; + let id = d.get_id().ok()?; + Some((d, id)) +} + +/// One endpoint open + capture loop. Returns how to continue ([`Next`]) or an error (first open: +/// fatal via the `ready` handshake; later: reopen with backoff). +fn capture_once( + tx: &SyncSender>, + stop: &AtomicBool, + ready: &mut Option>>, + channels: u32, + mode: TargetMode, +) -> Result { + // Interleaved f32: channels * 4 bytes per frame. + let block_align = channels as usize * 4; + 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); + + // 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) { + // SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s + // `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer; + // calling it imposes no extra precondition here (it takes no args and aliases + // nothing). Its internal contract holds: the `DiInstall` type matches the documented + // `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a + // NUL-terminated UTF-16 INF path with null/zero optional args. + if unsafe { super::wasapi_mic::install_steam_audio_pair() } { + wiring = audio_control::wire_now(true); + } + if !wiring + .loopback_render + .as_ref() + .is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase())) + { + 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)" + ); + } + } + } + + let en = DeviceEnumerator::new().context("DeviceEnumerator")?; + // Resolve the endpoint to capture. ECHO GUARD (Follow/KEEP_DEFAULT shapes): the wiring plan + // reserves one endpoint for the virtual mic (`super::wasapi_mic` writes the client's voice + // there) — capturing THAT endpoint would stream the client's own mic straight back to it, so + // 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" + ); }; - let device = if default_is_mic { - let Some(lb) = &wiring.loopback_render else { + let d = audio_control::open_endpoint(&ep)?; + (d, ep.0, ep.1) + } else { + let (default, id) = default_render(&en) + .context("default render endpoint (loopback needs a render device)")?; + let default_is_mic = wiring + .mic_render + .as_ref() + .is_some_and(|(_, mic_id)| *mic_id == id); + if default_is_mic { + let Some(lb) = wiring.loopback_render.clone() else { 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 \ @@ -135,64 +277,115 @@ fn capture_thread( tracing::warn!(mic = %wiring.mic_render.as_ref().unwrap().0, loopback = %lb.0, "default render endpoint is the virtual-mic target — loopback-capturing the plan's \ endpoint instead"); - audio_control::open_endpoint(lb)? + let d = audio_control::open_endpoint(&lb)?; + (d, lb.0, lb.1) } else { - default - }; - let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; - // 48 kHz f32 interleaved in the requested channel layout; autoconvert lets WASAPI's - // shared-mode SRC match the engine mix format to ours (incl. up/downmix to the requested - // channel count), so we never resample/remix in Rust. The explicit dwChannelMask pins the - // wire order (FL FR FC LFE RL RR SL SR; 7.1 = 0x63F, not 0xFF). Loopback is implied by - // capturing a RENDER device with Direction::Capture in shared mode (STREAMFLAGS_LOOPBACK). - let mask = punktfunk_core::audio::wasapi_channel_mask(channels as u8); - let desired = WaveFormat::new( - 32, - 32, - &SampleType::Float, - SAMPLE_RATE as usize, - channels as usize, - Some(mask), - ); - let (default_period, _min_period) = - audio_client.get_device_period().context("device period")?; - let mode = StreamMode::EventsShared { - autoconvert: true, - buffer_duration_hns: default_period, - }; - audio_client - .initialize_client(&desired, &Direction::Capture, &mode) - .context("initialize loopback client")?; - let h_event = audio_client.set_get_eventhandle().context("event handle")?; - let capture_client = audio_client - .get_audiocaptureclient() - .context("IAudioCaptureClient")?; - audio_client - .start_stream() - .context("start loopback stream")?; - let _ = ready.send(Ok(())); + let name = default.get_friendlyname().unwrap_or_default(); + (default, name, id) + } + }; - let mut bytes: VecDeque = VecDeque::new(); - while !stop.load(Ordering::Relaxed) { - // Loopback fires events only while audio renders; the finite timeout keeps `stop` responsive. - if h_event.wait_for_event(100).is_err() { - continue; - } - loop { - match capture_client.get_next_packet_size() { - Ok(Some(0)) | Ok(None) => break, - Ok(Some(_n)) => { - capture_client - .read_from_device_to_deque(&mut bytes) - .context("read loopback")?; + let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; + // 48 kHz f32 interleaved in the requested channel layout; autoconvert lets WASAPI's + // shared-mode SRC match the engine mix format to ours (incl. up/downmix to the requested + // channel count), so we never resample/remix in Rust. The explicit dwChannelMask pins the + // wire order (FL FR FC LFE RL RR SL SR; 7.1 = 0x63F, not 0xFF). Loopback is implied by + // capturing a RENDER device with Direction::Capture in shared mode (STREAMFLAGS_LOOPBACK). + let mask = punktfunk_core::audio::wasapi_channel_mask(channels as u8); + let desired = WaveFormat::new( + 32, + 32, + &SampleType::Float, + SAMPLE_RATE as usize, + channels as usize, + Some(mask), + ); + let (default_period, _min_period) = + audio_client.get_device_period().context("device period")?; + let stream_mode = StreamMode::EventsShared { + autoconvert: true, + buffer_duration_hns: default_period, + }; + audio_client + .initialize_client(&desired, &Direction::Capture, &stream_mode) + .context("initialize loopback client")?; + let h_event = audio_client.set_get_eventhandle().context("event handle")?; + let capture_client = audio_client + .get_audiocaptureclient() + .context("IAudioCaptureClient")?; + audio_client + .start_stream() + .context("start loopback stream")?; + if let Some(r) = ready.take() { + let _ = r.send(Ok(())); + } + tracing::info!(device = %dev_name, + follow = matches!(mode, TargetMode::Follow) || keep_default, + "audio loopback capturing"); + + // Watchdog seed: the default as it stands right after our open. In Assert mode the plan just + // parked the default on our endpoint — if it did NOT stick (IPolicyConfig denied) converge + // instead of churning: follow a capturable default (audio plays on both ends), warn once on a + // dud. Afterwards only a CHANGE of the observed default id triggers a reaction, so a + // permanently-denied default set can never reopen-loop. + let mut seen_default = default_render(&en).map(|(_, id)| id); + if assert_plan { + if let Some(d) = seen_default.as_deref() { + if d != dev_id { + 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 — \ + capturing the actual default instead (audio audible on the host)"); + return Ok(Next::Reopen(TargetMode::Follow)); } - Err(e) => return Err(anyhow!("get_next_packet_size: {e}")), + DefaultKind::Dud(name) => tracing::warn!(default = %name, planned = %dev_name, + "default playback stayed on an endpoint whose loopback cannot work — \ + capturing the planned endpoint; desktop audio may be silent"), + DefaultKind::Unknown => {} } } - let whole = (bytes.len() / block_align) * block_align; - if whole == 0 { - continue; + } + } + + let mut bytes: VecDeque = VecDeque::new(); + let mut last_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. + let opened_at = Instant::now(); + let mut saw_packets = false; + let mut silence_noted = false; + loop { + if stop.load(Ordering::Relaxed) { + audio_client.stop_stream().ok(); + return Ok(Next::Stopped); + } + // Loopback fires events only while audio renders; the finite timeout keeps `stop` (and + // the watchdog) responsive. + let _ = h_event.wait_for_event(100); + loop { + match capture_client.get_next_packet_size() { + Ok(Some(0)) | Ok(None) => break, + Ok(Some(_n)) => { + saw_packets = true; + capture_client + .read_from_device_to_deque(&mut bytes) + .context("read loopback")?; + } + Err(e) => return Err(anyhow!("get_next_packet_size: {e}")), } + } + 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)"); + } + let whole = (bytes.len() / block_align) * block_align; + if whole > 0 { let raw: Vec = bytes.drain(..whole).collect(); let mut samples = Vec::with_capacity(whole / 4); for c in raw.chunks_exact(4) { @@ -200,13 +393,72 @@ fn capture_thread( } let _ = tx.try_send(samples); // non-blocking, lossy — same discipline as PipeWire } - audio_client.stop_stream().ok(); - Ok(()) - })(); - if let Err(ref e) = res { - let _ = ready.send(Err(anyhow!("{e:#}"))); + + // Watchdog: react when the default render device CHANGES from what we last observed — + // the operator picked a different output mid-stream (the old code never noticed and + // captured the stale endpoint forever; "cycle your output devices" was the workaround). + if last_check.elapsed() >= DEFAULT_CHECK_EVERY { + last_check = Instant::now(); + if let Some((_, nid)) = default_render(&en) { + if seen_default.as_deref() != Some(nid.as_str()) { + seen_default = Some(nid.clone()); + if nid != dev_id { + audio_client.stop_stream().ok(); + if keep_default { + tracing::info!( + "default render device changed (PUNKTFUNK_KEEP_DEFAULT) — \ + following it" + ); + return Ok(Next::Reopen(TargetMode::Follow)); + } + return Ok(match judge_default(&en, &wiring, &nid) { + DefaultKind::Capturable(name) => { + tracing::info!(device = %name, + "operator changed the output device mid-stream — following \ + it (audio now also plays on the host)"); + Next::Reopen(TargetMode::Follow) + } + DefaultKind::Dud(name) => { + tracing::warn!(device = %name, + "default playback moved to an endpoint whose loopback cannot \ + work — re-asserting the audio wiring plan"); + Next::Reopen(TargetMode::Assert) + } + DefaultKind::Unknown => Next::Reopen(TargetMode::Assert), + }); + } + } + } + } + } +} + +/// The watchdog's verdict on a newly-observed default render endpoint. +enum DefaultKind { + /// Loopback-capturable — following it yields working audio (audible on the host too). + Capturable(String), + /// The mic target or a known-silent/echoing loopback (cable, Steam Streaming Speakers) — + /// following it can only produce silence or an echo loop. + Dud(String), + /// Could not resolve the endpoint (transient churn). + Unknown, +} + +fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind { + let Ok(dev) = en.get_device(id) else { + return DefaultKind::Unknown; + }; + let name = dev.get_friendlyname().unwrap_or_default(); + let ln = name.to_lowercase(); + let is_mic = wiring + .mic_render + .as_ref() + .is_some_and(|(_, mic_id)| mic_id == id); + if is_mic || wiring_plan::excluded_from_loopback(&ln) { + DefaultKind::Dud(name) + } else { + DefaultKind::Capturable(name) } - res } #[cfg(test)] diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index 1bf825f4..f317b374 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -148,7 +148,9 @@ impl VirtualMic for WasapiVirtualMic { /// Resolve the mic inject target from the wiring plan, auto-installing the Steam Streaming pair /// when nothing usable exists (then re-planning). Runs on the COM-initialized render thread. fn resolve_target() -> Result<(wasapi::Device, String)> { - let mut wiring = audio_control::wire_now(); + // set_playback=false: the mic pump runs while the host is idle — only the desktop-audio + // capture may park the playback default (on the silent sink) for a stream's lifetime. + let mut wiring = audio_control::wire_now(false); if wiring.mic_render.is_none() { tracing::info!("no usable virtual mic device present — attempting auto-install"); // SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s @@ -159,7 +161,7 @@ fn resolve_target() -> Result<(wasapi::Device, String)> { // NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the // dedicated mic thread. if unsafe { install_steam_audio_pair() } { - wiring = audio_control::wire_now(); + wiring = audio_control::wire_now(false); } } let Some(ep) = wiring.mic_render else { @@ -178,10 +180,12 @@ fn resolve_target() -> Result<(wasapi::Device, String)> { /// Play ships `SteamStreamingMicrophone.inf` + `SteamStreamingSpeakers.inf`: the microphone gives the /// virtual mic a target whose **capture** endpoint apps record from, and the speakers give a /// **render** endpoint a headless box can loopback-capture that is NOT the mic — so the loopback and -/// the mic land on different devices and never echo (see [`super::wiring_plan`]). Returns true if -/// either installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs -/// admin — the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set. -unsafe fn install_steam_audio_pair() -> bool { +/// the mic land on different devices and never echo (see [`super::wiring_plan`]). The Streaming +/// Microphone's render side doubles as the client-only-audio silent sink, so the desktop-audio +/// capture ([`super::wasapi_cap`]) also installs the pair when no silent sink exists. Returns true +/// if either installed. No-op when Steam isn't installed (INFs absent), the install is denied +/// (needs admin — the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set. +pub(crate) unsafe fn install_steam_audio_pair() -> bool { // Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink). let mic = try_install_steam_audio("SteamStreamingMicrophone.inf"); let spk = try_install_steam_audio("SteamStreamingSpeakers.inf"); diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index be432e56..172a4915 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -18,6 +18,14 @@ //! unavailable. The old code did the opposite — the mic refused the cable because it was the //! default render endpoint — which permanently killed mic passthrough in the exact configuration //! the installer ships (VB-CABLE as the only render device). +//! +//! **Loopback preference depends on where the audio should be heard.** The default is +//! *client-only*: prefer a render endpoint that is silent on the host but has a WORKING loopback +//! (the Steam Streaming *Microphone*'s render side — validated live; the Steam Streaming +//! *Speakers*' loopback is silent) so the desktop mix reaches the stream without also blasting +//! 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. /// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI. pub(crate) type Endpoint = (String, String); @@ -62,11 +70,18 @@ fn capture_for(mic_render_lname: &str) -> &'static [&'static str] { /// A render endpoint no loopback should capture: the VB-CABLE (reserved for the mic even when it /// isn't the chosen target — capturing a cable someone else feeds echoes too) and the Steam -/// Streaming Speakers, whose loopback is silent (validated live). -fn excluded_from_loopback(lname: &str) -> bool { +/// Streaming Speakers, whose loopback is silent (validated live). Also the capture-side +/// watchdog's test for "the operator's new default can never work — snap back to the plan". +pub(crate) fn excluded_from_loopback(lname: &str) -> bool { lname.contains("cable") || lname.contains("steam streaming speakers") } +/// A render endpoint that is SILENT on the host but loopback-capturable — the client-only audio +/// sink. Only the Steam Streaming Microphone's render side qualifies today (validated live). +pub(crate) fn silent_sink(lname: &str) -> bool { + lname.contains("steam streaming microphone") +} + /// A known-virtual device (cables/streaming endpoints). A render WITHOUT these markers is real /// hardware — the best loopback source (apps render there by default and the operator can also /// hear it). @@ -78,8 +93,15 @@ fn virtualish(lname: &str) -> bool { } /// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`, -/// lowercased): when set it beats the built-in candidate order for the mic target. -pub(crate) fn plan(renders: &[Endpoint], captures: &[Endpoint], mic_want: Option<&str>) -> Wiring { +/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio` +/// flips the loopback preference to real hardware (audio audible on the host too); the default +/// (`false`) prefers the silent sink so audio plays on the client only. +pub(crate) fn plan( + renders: &[Endpoint], + captures: &[Endpoint], + mic_want: Option<&str>, + host_audio: bool, +) -> Wiring { let find_render = |needle: &str| { renders .iter() @@ -103,26 +125,32 @@ pub(crate) fn plan(renders: &[Endpoint], captures: &[Endpoint], mic_want: Option }) }); - // 3. Loopback from the REMAINING renders: real hardware > Steam Streaming Microphone (its - // loopback works, unlike the Speakers') > any non-excluded leftover. + // 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. let not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id); - let loopback_render = renders - .iter() - .find(|(n, id)| { + let real_hw = || { + renders.iter().find(|(n, id)| { let ln = n.to_lowercase(); not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln) }) - .or_else(|| { - renders.iter().find(|(n, id)| { - not_mic(id) && n.to_lowercase().contains("steam streaming microphone") - }) - }) - .or_else(|| { - renders - .iter() - .find(|(n, id)| not_mic(id) && !excluded_from_loopback(&n.to_lowercase())) - }) - .cloned(); + }; + let silent = || { + renders + .iter() + .find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase())) + }; + let leftover = || { + renders + .iter() + .find(|(n, id)| not_mic(id) && !excluded_from_loopback(&n.to_lowercase())) + }; + let loopback_render = if host_audio { + real_hw().or_else(silent).or_else(leftover) + } else { + silent().or_else(real_hw).or_else(leftover) + } + .cloned(); Wiring { mic_render, @@ -139,8 +167,9 @@ mod tests { (name.to_string(), format!("id-{}", name.to_lowercase())) } - /// The shipped configuration: real output + VB-CABLE. Mic gets the cable, loopback the - /// speakers, recording default = CABLE Output. + /// The shipped configuration: real output + VB-CABLE (no Steam pair). Mic gets the cable; + /// with no silent sink the loopback falls back to the speakers (audio audible on both + /// ends), recording default = CABLE Output. #[test] fn gaming_pc_with_cable() { let renders = [ @@ -151,7 +180,7 @@ mod tests { ep("Microphone (Webcam)"), ep("CABLE Output (VB-Audio Virtual Cable)"), ]; - let w = plan(&renders, &captures, None); + let w = plan(&renders, &captures, None, false); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -163,6 +192,65 @@ mod tests { assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); } + /// Client-only (the default): with the full device zoo present — real output, VB-CABLE, + /// BOTH Steam endpoints — the loopback prefers the silent sink (Steam Streaming + /// Microphone's render side) over real hardware, so the host speakers stay quiet while + /// streaming. This is the dissidius/"audio from both PC and phone" configuration. + #[test] + fn client_only_prefers_silent_sink_over_hardware() { + let renders = [ + ep("Speakers (Apple Audio Device)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Speakers (Steam Streaming Speakers)"), + ep("CABLE In 16ch (VB-Audio Virtual Cable)"), + ep("Speakers (Steam Streaming Microphone)"), + ]; + let captures = [ + ep("CABLE Output (VB-Audio Virtual Cable)"), + ep("Microphone (Steam Streaming Microphone)"), + ]; + let w = plan(&renders, &captures, None, false); + assert_eq!( + w.mic_render.unwrap().0, + "CABLE Input (VB-Audio Virtual Cable)" + ); + assert_eq!( + w.loopback_render.unwrap().0, + "Speakers (Steam Streaming Microphone)" + ); + } + + /// `PUNKTFUNK_HOST_AUDIO` flips the preference back: real hardware wins the loopback even + /// when the silent sink exists (the operator wants to hear the stream locally). + #[test] + fn host_audio_prefers_real_hardware() { + let renders = [ + ep("Speakers (Apple Audio Device)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Speakers (Steam Streaming Microphone)"), + ]; + let w = plan(&renders, &[], None, true); + assert_eq!( + w.loopback_render.unwrap().0, + "Speakers (Apple Audio Device)" + ); + } + + /// The multi-render VB-CABLE ("CABLE In 16ch" is a second render endpoint feeding the same + /// CABLE Output) must never be the loopback in EITHER mode: it feeds the mic's capture side, + /// and capturing it delivers silence (nothing renders there) — the reported no-audio dud. + #[test] + fn cable_16ch_never_loopback() { + let renders = [ + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("CABLE In 16ch (VB-Audio Virtual Cable)"), + ]; + for host_audio in [false, true] { + let w = plan(&renders, &[], None, host_audio); + assert!(w.loopback_render.is_none(), "host_audio={host_audio}"); + } + } + /// THE historical dead-end: headless box where VB-CABLE is the ONLY render endpoint (and /// therefore the default). The mic must WIN the cable; the loopback is honestly absent. /// (The old anti-echo guard rejected the cable here → mic permanently dead.) @@ -170,7 +258,7 @@ mod tests { fn headless_cable_only_mic_wins() { let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; - let w = plan(&renders, &captures, None); + let w = plan(&renders, &captures, None, false); assert!(w.mic_render.is_some(), "mic must claim the only cable"); assert!(w.loopback_render.is_none(), "no echo-safe loopback exists"); } @@ -188,7 +276,7 @@ mod tests { ep("CABLE Output (VB-Audio Virtual Cable)"), ep("Microphone (Steam Streaming Microphone)"), ]; - let w = plan(&renders, &captures, None); + let w = plan(&renders, &captures, None, false); assert_eq!( w.mic_render.unwrap().0, "CABLE Input (VB-Audio Virtual Cable)" @@ -212,7 +300,7 @@ mod tests { ep("Speakers (Realtek HD Audio)"), ]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; - let w = plan(&renders, &captures, None); + let w = plan(&renders, &captures, None, false); assert_eq!( w.mic_render.unwrap().0, "Speakers (Steam Streaming Microphone)" @@ -226,7 +314,7 @@ mod tests { fn steam_mic_only_no_echo() { let renders = [ep("Speakers (Steam Streaming Microphone)")]; let captures = [ep("Microphone (Steam Streaming Microphone)")]; - let w = plan(&renders, &captures, None); + let w = plan(&renders, &captures, None, false); assert!(w.mic_render.is_some()); assert!(w.loopback_render.is_none()); } @@ -239,7 +327,7 @@ mod tests { ep("CABLE Input (VB-Audio Virtual Cable)"), ep("Speakers (Steam Streaming Speakers)"), ]; - let w = plan(&renders, &[], None); + let w = plan(&renders, &[], None, false); assert!(w.loopback_render.is_none()); } @@ -251,7 +339,7 @@ mod tests { ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"), ]; let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")]; - let w = plan(&renders, &captures, Some("voicemeeter input")); + let w = plan(&renders, &captures, Some("voicemeeter input"), false); assert_eq!( w.mic_render.unwrap().0, "Voicemeeter Input (VB-Audio Voicemeeter VAIO)" @@ -267,7 +355,7 @@ mod tests { #[test] fn no_virtual_device() { let renders = [ep("Speakers (Realtek HD Audio)")]; - let w = plan(&renders, &[], None); + let w = plan(&renders, &[], None, false); assert!(w.mic_render.is_none()); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); } diff --git a/crates/punktfunk-host/src/gamestream/audio.rs b/crates/punktfunk-host/src/gamestream/audio.rs index b86555e8..f9379b58 100644 --- a/crates/punktfunk-host/src/gamestream/audio.rs +++ b/crates/punktfunk-host/src/gamestream/audio.rs @@ -298,7 +298,7 @@ fn run( }; let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, params, running); cap.idle(); // parked between sessions — release the routing claim (Linux stream sink) - *audio_cap.lock().unwrap() = Some(cap); + audio::park_audio_capture(audio_cap, cap); // drop on Windows (restores the default), keep on Linux result } diff --git a/crates/punktfunk-host/src/native/audio.rs b/crates/punktfunk-host/src/native/audio.rs index 3e5b253d..bbb1909e 100644 --- a/crates/punktfunk-host/src/native/audio.rs +++ b/crates/punktfunk-host/src/native/audio.rs @@ -92,7 +92,7 @@ pub(super) fn audio_thread( Err(e) => { tracing::warn!(error = %e, "opus encoder init failed — session continues without audio"); capturer.idle(); // parked, not streaming — release the routing claim - *audio_cap.lock().unwrap() = Some(capturer); + crate::audio::park_audio_capture(&audio_cap, capturer); return; } }; @@ -173,11 +173,12 @@ pub(super) fn audio_thread( } } } - // Return the live capturer for the next session (None if it died and never reopened), - // releasing its session-scoped routing claim (Linux: the default sink moves back). + // Park the live capturer for the next session (None if it died and never reopened), + // releasing its session-scoped routing claim (Linux: the default sink moves back; + // Windows: dropped, restoring the operator's default playback device). if let Some(mut c) = capturer { c.idle(); - *audio_cap.lock().unwrap() = Some(c); + crate::audio::park_audio_capture(&audio_cap, c); } }