fix(audio/windows): explicit-endpoint capture, client-only playback, self-healing watchdog

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 17:00:00 +02:00
parent 97c5778a36
commit 4d89dcd3d7
7 changed files with 658 additions and 149 deletions
+19 -1
View File
@@ -56,7 +56,9 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> { pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
// The capture thread runs the audio wiring plan itself (audio_control::wire_now) before // 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) wasapi_cap::WasapiLoopbackCapturer::open(channels)
.map(|c| Box::new(c) as Box<dyn AudioCapturer>) .map(|c| Box::new(c) as Box<dyn AudioCapturer>)
} }
@@ -66,6 +68,22 @@ pub fn open_audio_capture(_channels: u32) -> Result<Box<dyn AudioCapturer>> {
anyhow::bail!("audio capture requires Linux + PipeWire or Windows + WASAPI") 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<Option<Box<dyn AudioCapturer>>>,
cap: Box<dyn AudioCapturer>,
) {
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 /// 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 /// 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 /// decoded client-mic PCM (interleaved `f32` at [`SAMPLE_RATE`]) into it, and PipeWire delivers
@@ -11,12 +11,20 @@
//! * the **mic inject target** is assigned FIRST (VB-Cable "CABLE Input" preferred) — mic passthrough //! * 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 //! 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); //! 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 //! * default **PLAYBACK** → the plan's loopback endpoint, applied ONLY while a desktop-audio capture
//! if one exists, else the Steam Streaming Microphone; **never** the Steam Streaming Speakers, whose //! is open (`set_playback` — the mic pump must never park the playback default while the host is
//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] captures; //! 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 //! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default. //! 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 //! 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 //! 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 //! every mic/capture (re)open — NOT once per process — because endpoints churn (boot-time
@@ -61,18 +69,28 @@ fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
out 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 /// 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). Must run on a COM-initialized thread (the WASAPI worker threads all /// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
/// `initialize_mta` first). Logged only when the assignment changes, so per-open recomputation /// parks the default PLAYBACK device on the plan's loopback endpoint for the capture's lifetime
/// stays quiet in the steady state. /// (the mic pump passes false: it runs while the host is idle and must not silence the box).
pub(crate) fn wire_now() -> Wiring { /// 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 renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture); let captures = list_endpoints(Direction::Capture);
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()); let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
// 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);
@@ -107,16 +125,38 @@ pub(crate) fn wire_now() -> Wiring {
} }
return wiring; return wiring;
} }
if let Some((name, id)) = &wiring.loopback_render { // Default-playback hygiene, on EVERY wire (mic pump at boot included): if the default render
match set_default_endpoint(id) { // endpoint IS the mic target — VB-CABLE installs have been seen grabbing the default — every
Ok(()) => { // app renders its audio INTO the virtual mic: recorders hear the desktop mix and the operator
if changed { // hears nothing. Move the default to an audible endpoint. The pre-split wire_now covered this
tracing::info!(device = %name, // as a side effect of always setting the playback default; the `set_playback` split must not
"audio wiring: default playback = desktop-audio loopback source"); // 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:#}"), Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default playback device"), "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");
}
}
}
}
}
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 { if let Some((name, id)) = &wiring.mic_capture {
@@ -134,6 +174,112 @@ pub(crate) fn wire_now() -> Wiring {
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<Option<(String, String)>> = 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<String> {
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. /// Open a device by endpoint id, with a name for error context.
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> { pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
wasapi::DeviceEnumerator::new() wasapi::DeviceEnumerator::new()
@@ -1,20 +1,36 @@
//! WASAPI loopback capture of the default render endpoint (system output) — the Windows analogue //! WASAPI loopback capture of the desktop mix (system output) — the Windows analogue of the
//! of the PipeWire sink-monitor backend. Delivers interleaved f32 PCM at 48 kHz in the requested //! 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 //! 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 //! 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 //! 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 //! 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. //! `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 anyhow::{anyhow, Context, Result};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender}; use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender};
use std::sync::Arc; use std::sync::Arc;
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use std::time::Duration; use std::time::{Duration, Instant};
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat}; use wasapi::{Device, DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
pub struct WasapiLoopbackCapturer { pub struct WasapiLoopbackCapturer {
chunks: Receiver<Vec<f32>>, chunks: Receiver<Vec<f32>>,
@@ -43,12 +59,11 @@ impl WasapiLoopbackCapturer {
} }
}) })
.context("spawn wasapi audio thread")?; .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(())) => { Ok(Ok(())) => {
tracing::info!( tracing::info!(channels, "WASAPI loopback capture: 48 kHz f32");
channels,
"WASAPI loopback capture: 48 kHz f32 (default render endpoint)"
);
Ok(WasapiLoopbackCapturer { Ok(WasapiLoopbackCapturer {
chunks: rx, chunks: rx,
channels, channels,
@@ -57,9 +72,13 @@ impl WasapiLoopbackCapturer {
}) })
} }
Ok(Err(e)) => Err(e), Ok(Err(e)) => Err(e),
Err(_) => Err(anyhow!( Err(_) => {
"wasapi loopback init timed out (no default render endpoint?)" // 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( fn capture_thread(
tx: SyncSender<Vec<f32>>, tx: SyncSender<Vec<f32>>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
ready: SyncSender<Result<()>>, ready: SyncSender<Result<()>>,
channels: u32, channels: u32,
) -> Result<()> { ) -> 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. // COM must be initialized on THIS thread (MTA), before any device call.
if let Err(e) = wasapi::initialize_mta() if let Err(e) = wasapi::initialize_mta()
.ok() .ok()
@@ -107,25 +149,125 @@ fn capture_thread(
let _ = ready.send(Err(e)); let _ = ready.send(Err(e));
return Ok(()); return Ok(());
} }
let res = (|| -> Result<()> { // Self-heal for the capturer's whole life: each `capture_once` is one endpoint open + inner
// Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE // capture loop; it returns to reopen (default-device change) or errors (device invalidated,
// client with loopback=true over it. ECHO GUARD: the wiring plan reserves one endpoint for // engine restart), and only the FIRST open's failure is fatal — it surfaces as `open()`'s Err
// the virtual mic (`super::wasapi_mic` writes the client's voice there) — capturing THAT // so the session honestly runs without audio (the native plane retries with its own backoff).
// endpoint would stream the client's own mic straight back to it. Normally the plan has let mut ready = Some(ready);
// already moved the default playback elsewhere; if the default still IS the mic target let mut mode = TargetMode::Assert;
// (PUNKTFUNK_KEEP_DEFAULT, or the cable is the only endpoint), capture the plan's loopback let mut failures: u64 = 0;
// endpoint explicitly, or refuse — no desktop audio beats an echo loop. while !stop.load(Ordering::Relaxed) {
let wiring = audio_control::wire_now(); match capture_once(&tx, &stop, &mut ready, channels, mode) {
let default = DeviceEnumerator::new() Ok(Next::Stopped) => break,
.context("DeviceEnumerator")? Ok(Next::Reopen(m)) => {
.get_default_device(&Direction::Render) mode = m;
.context("default render endpoint (loopback needs a render device)")?; failures = 0;
let default_is_mic = match (&wiring.mic_render, default.get_id()) { }
(Some((_, mic_id)), Ok(id)) => *mic_id == id, Err(e) => {
_ => false, 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<Vec<f32>>,
stop: &AtomicBool,
ready: &mut Option<SyncSender<Result<()>>>,
channels: u32,
mode: TargetMode,
) -> Result<Next> {
// 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 d = audio_control::open_endpoint(&ep)?;
let Some(lb) = &wiring.loopback_render else { (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!( anyhow::bail!(
"the only render endpoint is reserved for the virtual mic (capturing it would \ "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 \ echo the client's voice back) — attach another output device or install the \
@@ -135,10 +277,14 @@ fn capture_thread(
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,
"default render endpoint is the virtual-mic target — loopback-capturing the plan's \ "default render endpoint is the virtual-mic target — loopback-capturing the plan's \
endpoint instead"); endpoint instead");
audio_control::open_endpoint(lb)? let d = audio_control::open_endpoint(&lb)?;
(d, lb.0, lb.1)
} else { } else {
default let name = default.get_friendlyname().unwrap_or_default();
(default, name, id)
}
}; };
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
// 48 kHz f32 interleaved in the requested channel layout; autoconvert lets WASAPI's // 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 // shared-mode SRC match the engine mix format to ours (incl. up/downmix to the requested
@@ -156,12 +302,12 @@ fn capture_thread(
); );
let (default_period, _min_period) = let (default_period, _min_period) =
audio_client.get_device_period().context("device period")?; audio_client.get_device_period().context("device period")?;
let mode = StreamMode::EventsShared { let stream_mode = StreamMode::EventsShared {
autoconvert: true, autoconvert: true,
buffer_duration_hns: default_period, buffer_duration_hns: default_period,
}; };
audio_client audio_client
.initialize_client(&desired, &Direction::Capture, &mode) .initialize_client(&desired, &Direction::Capture, &stream_mode)
.context("initialize loopback client")?; .context("initialize loopback client")?;
let h_event = audio_client.set_get_eventhandle().context("event handle")?; let h_event = audio_client.set_get_eventhandle().context("event handle")?;
let capture_client = audio_client let capture_client = audio_client
@@ -170,18 +316,60 @@ fn capture_thread(
audio_client audio_client
.start_stream() .start_stream()
.context("start loopback stream")?; .context("start loopback stream")?;
let _ = ready.send(Ok(())); 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));
}
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 mut bytes: VecDeque<u8> = VecDeque::new(); let mut bytes: VecDeque<u8> = VecDeque::new();
while !stop.load(Ordering::Relaxed) { let mut last_check = Instant::now();
// Loopback fires events only while audio renders; the finite timeout keeps `stop` responsive. // Triage breadcrumb: a broken loopback (endpoint renders but its loopback tap delivers
if h_event.wait_for_event(100).is_err() { // nothing — the Steam Streaming Speakers failure shape) is indistinguishable from a simply
continue; // 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 { loop {
match capture_client.get_next_packet_size() { match capture_client.get_next_packet_size() {
Ok(Some(0)) | Ok(None) => break, Ok(Some(0)) | Ok(None) => break,
Ok(Some(_n)) => { Ok(Some(_n)) => {
saw_packets = true;
capture_client capture_client
.read_from_device_to_deque(&mut bytes) .read_from_device_to_deque(&mut bytes)
.context("read loopback")?; .context("read loopback")?;
@@ -189,10 +377,15 @@ fn capture_thread(
Err(e) => return Err(anyhow!("get_next_packet_size: {e}")), Err(e) => return Err(anyhow!("get_next_packet_size: {e}")),
} }
} }
let whole = (bytes.len() / block_align) * block_align; if !saw_packets && !silence_noted && opened_at.elapsed() >= Duration::from_secs(30) {
if whole == 0 { silence_noted = true;
continue; 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<u8> = bytes.drain(..whole).collect(); let raw: Vec<u8> = bytes.drain(..whole).collect();
let mut samples = Vec::with_capacity(whole / 4); let mut samples = Vec::with_capacity(whole / 4);
for c in raw.chunks_exact(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 let _ = tx.try_send(samples); // non-blocking, lossy — same discipline as PipeWire
} }
// 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(); audio_client.stop_stream().ok();
Ok(()) if keep_default {
})(); tracing::info!(
if let Err(ref e) = res { "default render device changed (PUNKTFUNK_KEEP_DEFAULT) — \
let _ = ready.send(Err(anyhow!("{e:#}"))); 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)] #[cfg(test)]
@@ -148,7 +148,9 @@ impl VirtualMic for WasapiVirtualMic {
/// Resolve the mic inject target from the wiring plan, auto-installing the Steam Streaming pair /// 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. /// when nothing usable exists (then re-planning). Runs on the COM-initialized render thread.
fn resolve_target() -> Result<(wasapi::Device, String)> { 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() { if wiring.mic_render.is_none() {
tracing::info!("no usable virtual mic device present — attempting auto-install"); tracing::info!("no usable virtual mic device present — attempting auto-install");
// SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s // 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 // NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
// dedicated mic thread. // dedicated mic thread.
if unsafe { install_steam_audio_pair() } { if unsafe { install_steam_audio_pair() } {
wiring = audio_control::wire_now(); wiring = audio_control::wire_now(false);
} }
} }
let Some(ep) = wiring.mic_render else { 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 /// 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 /// 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 /// **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 /// the mic land on different devices and never echo (see [`super::wiring_plan`]). The Streaming
/// either installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs /// Microphone's render side doubles as the client-only-audio silent sink, so the desktop-audio
/// admin — the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set. /// capture ([`super::wasapi_cap`]) also installs the pair when no silent sink exists. Returns true
unsafe fn install_steam_audio_pair() -> bool { /// 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). // Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink).
let mic = try_install_steam_audio("SteamStreamingMicrophone.inf"); let mic = try_install_steam_audio("SteamStreamingMicrophone.inf");
let spk = try_install_steam_audio("SteamStreamingSpeakers.inf"); let spk = try_install_steam_audio("SteamStreamingSpeakers.inf");
+114 -26
View File
@@ -18,6 +18,14 @@
//! unavailable. The old code did the opposite — the mic refused the cable because it was the //! 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 //! default render endpoint — which permanently killed mic passthrough in the exact configuration
//! the installer ships (VB-CABLE as the only render device). //! 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. /// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
pub(crate) type Endpoint = (String, String); 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 /// 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 /// isn't the chosen target — capturing a cable someone else feeds echoes too) and the Steam
/// Streaming Speakers, whose loopback is silent (validated live). /// Streaming Speakers, whose loopback is silent (validated live). Also the capture-side
fn excluded_from_loopback(lname: &str) -> bool { /// 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") 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 /// 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 /// hardware — the best loopback source (apps render there by default and the operator can also
/// hear it). /// hear it).
@@ -78,8 +93,15 @@ fn virtualish(lname: &str) -> bool {
} }
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`, /// 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. /// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
pub(crate) fn plan(renders: &[Endpoint], captures: &[Endpoint], mic_want: Option<&str>) -> Wiring { /// 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| { let find_render = |needle: &str| {
renders renders
.iter() .iter()
@@ -103,25 +125,31 @@ pub(crate) fn plan(renders: &[Endpoint], captures: &[Endpoint], mic_want: Option
}) })
}); });
// 3. Loopback from the REMAINING renders: real hardware > Steam Streaming Microphone (its // 3. Loopback from the REMAINING renders. Client-only (default): the silent sink (Steam
// loopback works, unlike the Speakers') > any non-excluded leftover. // 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 not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id);
let loopback_render = renders let real_hw = || {
.iter() renders.iter().find(|(n, id)| {
.find(|(n, id)| {
let ln = n.to_lowercase(); let ln = n.to_lowercase();
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln) not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
}) })
.or_else(|| { };
renders.iter().find(|(n, id)| { let silent = || {
not_mic(id) && n.to_lowercase().contains("steam streaming microphone") renders
}) .iter()
}) .find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
.or_else(|| { };
let leftover = || {
renders renders
.iter() .iter()
.find(|(n, id)| not_mic(id) && !excluded_from_loopback(&n.to_lowercase())) .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(); .cloned();
Wiring { Wiring {
@@ -139,8 +167,9 @@ mod tests {
(name.to_string(), format!("id-{}", name.to_lowercase())) (name.to_string(), format!("id-{}", name.to_lowercase()))
} }
/// The shipped configuration: real output + VB-CABLE. Mic gets the cable, loopback the /// The shipped configuration: real output + VB-CABLE (no Steam pair). Mic gets the cable;
/// speakers, recording default = CABLE Output. /// with no silent sink the loopback falls back to the speakers (audio audible on both
/// ends), recording default = CABLE Output.
#[test] #[test]
fn gaming_pc_with_cable() { fn gaming_pc_with_cable() {
let renders = [ let renders = [
@@ -151,7 +180,7 @@ mod tests {
ep("Microphone (Webcam)"), ep("Microphone (Webcam)"),
ep("CABLE Output (VB-Audio Virtual Cable)"), ep("CABLE Output (VB-Audio Virtual Cable)"),
]; ];
let w = plan(&renders, &captures, None); let w = plan(&renders, &captures, None, false);
assert_eq!( assert_eq!(
w.mic_render.unwrap().0, w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)" "CABLE Input (VB-Audio Virtual Cable)"
@@ -163,6 +192,65 @@ mod tests {
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); 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 /// 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. /// 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.) /// (The old anti-echo guard rejected the cable here → mic permanently dead.)
@@ -170,7 +258,7 @@ mod tests {
fn headless_cable_only_mic_wins() { fn headless_cable_only_mic_wins() {
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")]; let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
let captures = [ep("CABLE Output (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.mic_render.is_some(), "mic must claim the only cable");
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists"); 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("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"), ep("Microphone (Steam Streaming Microphone)"),
]; ];
let w = plan(&renders, &captures, None); let w = plan(&renders, &captures, None, false);
assert_eq!( assert_eq!(
w.mic_render.unwrap().0, w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)" "CABLE Input (VB-Audio Virtual Cable)"
@@ -212,7 +300,7 @@ mod tests {
ep("Speakers (Realtek HD Audio)"), ep("Speakers (Realtek HD Audio)"),
]; ];
let captures = [ep("Microphone (Steam Streaming Microphone)")]; let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None); let w = plan(&renders, &captures, None, false);
assert_eq!( assert_eq!(
w.mic_render.unwrap().0, w.mic_render.unwrap().0,
"Speakers (Steam Streaming Microphone)" "Speakers (Steam Streaming Microphone)"
@@ -226,7 +314,7 @@ mod tests {
fn steam_mic_only_no_echo() { fn steam_mic_only_no_echo() {
let renders = [ep("Speakers (Steam Streaming Microphone)")]; let renders = [ep("Speakers (Steam Streaming Microphone)")];
let captures = [ep("Microphone (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.mic_render.is_some());
assert!(w.loopback_render.is_none()); assert!(w.loopback_render.is_none());
} }
@@ -239,7 +327,7 @@ mod tests {
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); let w = plan(&renders, &[], None, false);
assert!(w.loopback_render.is_none()); assert!(w.loopback_render.is_none());
} }
@@ -251,7 +339,7 @@ mod tests {
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"), ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
]; ];
let captures = [ep("Voicemeeter Out B1 (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!( assert_eq!(
w.mic_render.unwrap().0, w.mic_render.unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)" "Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
@@ -267,7 +355,7 @@ mod tests {
#[test] #[test]
fn no_virtual_device() { fn no_virtual_device() {
let renders = [ep("Speakers (Realtek HD Audio)")]; 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!(w.mic_render.is_none());
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
} }
@@ -298,7 +298,7 @@ fn run(
}; };
let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, params, running); let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, params, running);
cap.idle(); // parked between sessions — release the routing claim (Linux stream sink) 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 result
} }
+5 -4
View File
@@ -92,7 +92,7 @@ pub(super) fn audio_thread(
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio"); tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
capturer.idle(); // parked, not streaming — release the routing claim capturer.idle(); // parked, not streaming — release the routing claim
*audio_cap.lock().unwrap() = Some(capturer); crate::audio::park_audio_capture(&audio_cap, capturer);
return; 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), // 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). // 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 { if let Some(mut c) = capturer {
c.idle(); c.idle();
*audio_cap.lock().unwrap() = Some(c); crate::audio::park_audio_capture(&audio_cap, c);
} }
} }