From fdd532de5336bb4b6f7dec3540e5e2687efd931f Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 19 Jun 2026 04:21:53 -0400 Subject: [PATCH] Windows audio (cpal): surface device fallback + callback-size diagnostics (W7, W2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two safe, host-independent hardening steps from the Codex Windows-compat review. W7 — when a saved input/output device name no longer resolves (WASAPI friendly names can change across driver/endpoint changes), resolve() now logs the fallback to the system default instead of switching devices silently — so a "my audio went to the wrong device" report has a log line explaining why. (cpal 0.15 exposes only the device name, so a stable hardware id isn't available to persist; this surfaces the limitation rather than hiding it.) W2 — the output RT callback now records the largest interleaved buffer length it is ever asked for (a wait-free fetch_max into an atomic, kept off the log/alloc path). The once-per-second health-logger reports that size and, if a callback ever exceeds the prefill target (PLAYBACK_TARGET_SAMPLES), warns explicitly — that's the exact signature of the WASAPI-shared-mode underrun-every-cycle bug. This is the diagnostic a real-host test needs before committing to the structural fix (larger target / fixed buffer); no behavior change. Windows-only file (cfg(windows)); compile-verified via the windows-gnu cross-build, not yet exercised on a real WASAPI host. Co-Authored-By: Claude Opus 4.8 --- src/audio/cpal_impl.rs | 50 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/src/audio/cpal_impl.rs b/src/audio/cpal_impl.rs index e4e2fb8..144e4e0 100644 --- a/src/audio/cpal_impl.rs +++ b/src/audio/cpal_impl.rs @@ -254,7 +254,20 @@ fn resolve( } }; let device = match target { - Some(name) => find_device_by_name(&host, output, &name).or_else(default), + // A saved device name that no longer resolves falls back to the system + // default — but log it, because WASAPI friendly names can change across + // driver/endpoint changes, so a silent fallback otherwise looks like + // "audio went to the wrong device for no reason" (review W7). + Some(ref name) => match find_device_by_name(&host, output, name) { + Some(dev) => Some(dev), + None => { + crate::log_msg(&format!( + "cpal: saved {} device '{name}' not found; using system default", + if output { "output" } else { "input" }, + )); + default() + } + }, None => default(), } .ok_or_else(|| AudioError::Device("no audio device available".to_string()))?; @@ -509,6 +522,11 @@ fn run_playback( // Diagnostics (mirrors the PipeWire backend's playout-health line). let underrun = Arc::new(AtomicU64::new(0)); let dropped = Arc::new(AtomicU64::new(0)); + // Largest single output-callback length seen (interleaved samples). WASAPI + // shared-mode picks its own period, so this can exceed the prefill target — + // which would force an underrun every cycle (review W2). The callback only + // does a wait-free fetch_max; the health logger reports/warns off the RT path. + let max_cb = Arc::new(AtomicUsize::new(0)); // Fallible device/stream setup; report the real error to `start_playback` // before any work so a failure surfaces instead of a silent room. `consumer` @@ -517,13 +535,13 @@ fn run_playback( let (device, config, sample_format) = resolve(true, target)?; let stream = match sample_format { SampleFormat::F32 => { - build_output::(&device, &config, consumer, ring_fill.clone(), underrun.clone()) + build_output::(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone()) } SampleFormat::I16 => { - build_output::(&device, &config, consumer, ring_fill.clone(), underrun.clone()) + build_output::(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone()) } SampleFormat::U16 => { - build_output::(&device, &config, consumer, ring_fill.clone(), underrun.clone()) + build_output::(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone()) } other => Err(AudioError::Stream(format!( "unsupported playback sample format: {other:?}" @@ -553,6 +571,7 @@ fn run_playback( ring_fill.clone(), underrun.clone(), dropped.clone(), + max_cb.clone(), ); // Feed the ring from the network mixer until `stop()` flips `running` or the @@ -582,6 +601,7 @@ fn build_output( mut consumer: C, ring_fill: Arc, underrun: Arc, + max_cb: Arc, ) -> Result where T: SizedSample + FromSample + Send + 'static, @@ -592,6 +612,8 @@ where .build_output_stream::( config, move |data: &mut [T], _| { + // Wait-free; the logger thread reads this off the RT path. + max_cb.fetch_max(data.len(), Ordering::Relaxed); let (popped, starved) = fill_output(&mut consumer, data); if starved > 0 { underrun.fetch_add(starved, Ordering::Relaxed); @@ -640,10 +662,12 @@ fn spawn_health_logger( ring_fill: Arc, underrun: Arc, dropped: Arc, + max_cb: Arc, ) -> JoinHandle<()> { let verbose = std::env::var_os("PEERSPEAK_AUDIO_VERBOSE").is_some(); thread::spawn(move || { let (mut last_u, mut last_d) = (0u64, 0u64); + let mut reported_cb = 0usize; while running.load(Ordering::Relaxed) { thread::sleep(Duration::from_secs(1)); let u = underrun.load(Ordering::Relaxed); @@ -658,6 +682,24 @@ fn spawn_health_logger( fill / (48 * PLAYBACK_CHANNELS), )); } + // Report the device's callback size the first time it's seen (and on + // any new high). If a callback asks for more than the prefill target, + // the ring can't satisfy it and underruns every cycle — the W2 bug + // signature; warn so a real-host log shows whether it's biting. + let cb = max_cb.load(Ordering::Relaxed); + if cb > reported_cb { + reported_cb = cb; + let ms = cb / (48 * PLAYBACK_CHANNELS); + if cb > PLAYBACK_TARGET_SAMPLES { + crate::log_msg(&format!( + "cpal output callback up to {cb} samples/cycle (~{ms}ms) EXCEEDS prefill target {PLAYBACK_TARGET_SAMPLES} — expect periodic underruns; needs a larger target or a fixed buffer size (review W2)", + )); + } else if verbose { + crate::log_msg(&format!( + "cpal output callback up to {cb} samples/cycle (~{ms}ms), target {PLAYBACK_TARGET_SAMPLES}", + )); + } + } } }) }