From bb348b896459b30b573c0a82b103777272432e24 Mon Sep 17 00:00:00 2001 From: aschumann-virtualcable Date: Fri, 19 Jun 2026 12:24:53 +0200 Subject: [PATCH] fix(audio): match output stream sample format to device, never panic RDP server->client audio crashed the launcher: get_stream_config picked the first supported config (e.g. U8 on some USB headsets) while the stream was always built with an f32 callback. In WASAPI shared mode the stream sample format must match the device mix format, so the mismatch returned UnsupportedConfig, and the .unwrap() turned it into a panic that killed the whole launcher. - get_stream_config now prefers an F32 config (the usual shared-mode mix format and what our f32 buffer feeds), falling back to any supported one. - New build_output_stream_typed:: builds a stream typed to the device's actual sample format, converting the f32 buffer via FromSample. - Build failures now disable audio and log instead of panicking. --- crates/channels/src/audio/output.rs | 110 +++++++++++++++++++--------- 1 file changed, 77 insertions(+), 33 deletions(-) diff --git a/crates/channels/src/audio/output.rs b/crates/channels/src/audio/output.rs index 30e8548..0b9b50c 100644 --- a/crates/channels/src/audio/output.rs +++ b/crates/channels/src/audio/output.rs @@ -9,6 +9,7 @@ use std::sync::{Arc, Mutex, RwLock}; use std::thread; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{FromSample, SampleFormat, SizedSample}; use shared::log; use super::tools::{ResamplerIterator, pcm_to_f32}; @@ -42,20 +43,50 @@ impl AudioHandle { dev: &cpal::Device, sample_rate: u32, ) -> Option { - if let Ok(configs) = dev.supported_output_configs() { - for cfg in configs { - if cfg.min_sample_rate() <= sample_rate && sample_rate <= cfg.max_sample_rate() { - return Some( - cfg.try_with_sample_rate(sample_rate) - .unwrap_or(cfg.with_max_sample_rate()), - ); - } - } - } - None + let configs: Vec<_> = dev.supported_output_configs().ok()?.collect(); + let supports = |cfg: &cpal::SupportedStreamConfigRange| { + cfg.min_sample_rate() <= sample_rate && sample_rate <= cfg.max_sample_rate() + }; + // Prefer an F32 config: it matches our f32 playback buffer and is the usual + // WASAPI shared-mode mix format. Picking a non-matching format (e.g. U8) and + // building an f32 stream is what made the headset fail with UnsupportedConfig. + let cfg = configs + .iter() + .find(|c| supports(c) && c.sample_format() == SampleFormat::F32) + .or_else(|| configs.iter().find(|c| supports(c))) + .cloned()?; + Some( + cfg.try_with_sample_rate(sample_rate) + .unwrap_or_else(|| cfg.with_max_sample_rate()), + ) } } +/// Builds an output stream typed to the device's chosen sample format, converting +/// our internal f32 buffer to `T` on the fly. In WASAPI shared mode the stream +/// sample format must match the device mix format, so we can't always use f32. +fn build_output_stream_typed( + dev: &cpal::Device, + cfg: cpal::StreamConfig, + buffer: Arc>>, +) -> Result +where + T: SizedSample + FromSample + Send + 'static, +{ + dev.build_output_stream( + cfg, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + let mut buf_guard = buffer.write().unwrap(); + for sample in data.iter_mut() { + let val = buf_guard.pop_front().unwrap_or(0.0); // silence when no data + *sample = T::from_sample(val); + } + }, + |err| log::error!("Stream error: {}", err), + None, + ) +} + impl AudioOutputIntegration for AudioHandle { fn open( &self, @@ -94,35 +125,48 @@ impl AudioOutputIntegration for AudioHandle { if let Some(dev) = device && let Some(cfg) = AudioHandle::get_stream_config(&dev, sample_rate) { + let sample_format = cfg.sample_format(); log::debug!( "Using audio format: {:?}, range={}", - cfg.sample_format(), + sample_format, cfg.sample_rate() ); let cfg = cfg.config(); // Store real output sample rate output_sample_rate = cfg.sample_rate; - stream = Some( - dev.build_output_stream( - cfg, - { - let buffer = Arc::clone(&buffer); - move |data: &mut [f32], _| { - let mut buf_guard = buffer.write().unwrap(); - for sample in data.iter_mut() { - if let Some(val) = buf_guard.pop_front() { - *sample = val; - } else { - *sample = 0.0; // No more data, output silence - } - } - } - }, - move |err| log::error!("Stream error: {}", err), - None, - ) - .unwrap(), - ); + // Build a stream matching the device's sample format. Never unwrap: + // a failed build must disable audio, not crash the whole launcher. + let built = match sample_format { + SampleFormat::F32 => { + build_output_stream_typed::(&dev, cfg, Arc::clone(&buffer)) + } + SampleFormat::I16 => { + build_output_stream_typed::(&dev, cfg, Arc::clone(&buffer)) + } + SampleFormat::U16 => { + build_output_stream_typed::(&dev, cfg, Arc::clone(&buffer)) + } + SampleFormat::U8 => { + build_output_stream_typed::(&dev, cfg, Arc::clone(&buffer)) + } + SampleFormat::I32 => { + build_output_stream_typed::(&dev, cfg, Arc::clone(&buffer)) + } + SampleFormat::F64 => { + build_output_stream_typed::(&dev, cfg, Arc::clone(&buffer)) + } + other => Err(cpal::Error::with_message( + cpal::ErrorKind::UnsupportedConfig, + format!("unsupported sample format {:?}", other), + )), + }; + stream = match built { + Ok(s) => Some(s), + Err(e) => { + log::error!("Audio disabled: cannot build output stream: {}", e); + None + } + }; } if let Some(s) = &stream {