Windows audio (cpal): surface device fallback + callback-size diagnostics (W7, W2)
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 <noreply@anthropic.com>
This commit is contained in:
+46
-4
@@ -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::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
|
||||
}
|
||||
SampleFormat::I16 => {
|
||||
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
|
||||
}
|
||||
SampleFormat::U16 => {
|
||||
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||
build_output::<u16, _>(&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<T, C>(
|
||||
mut consumer: C,
|
||||
ring_fill: Arc<AtomicUsize>,
|
||||
underrun: Arc<AtomicU64>,
|
||||
max_cb: Arc<AtomicUsize>,
|
||||
) -> Result<Stream, AudioError>
|
||||
where
|
||||
T: SizedSample + FromSample<i16> + Send + 'static,
|
||||
@@ -592,6 +612,8 @@ where
|
||||
.build_output_stream::<T, _, _>(
|
||||
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<AtomicUsize>,
|
||||
underrun: Arc<AtomicU64>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
max_cb: Arc<AtomicUsize>,
|
||||
) -> 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}",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user