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")]
|
#[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");
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user