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:
@@ -56,7 +56,9 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
||||
#[cfg(target_os = "windows")]
|
||||
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
|
||||
// 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<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")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
|
||||
@@ -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<Endpoint> {
|
||||
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<Option<Wiring>> = 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<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.
|
||||
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
|
||||
@@ -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<Vec<f32>>,
|
||||
@@ -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<Vec<f32>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
ready: SyncSender<Result<()>>,
|
||||
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<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 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<u8> = 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<u8> = 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<u8> = 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)]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user