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
|
||||
|
||||
Reference in New Issue
Block a user