From 671d35f699ef4e79372a7074dade73b3cf4fabb7 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:28:26 +0200 Subject: [PATCH 01/14] fix(asio): validate stream configuration and enforce buffer size constraints --- asio-sys/CHANGELOG.md | 5 +++-- asio-sys/src/bindings/mod.rs | 23 ++++++++++++++++++++--- src/host/asio/stream.rs | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/asio-sys/CHANGELOG.md b/asio-sys/CHANGELOG.md index 865f90b00..23200ccf6 100644 --- a/asio-sys/CHANGELOG.md +++ b/asio-sys/CHANGELOG.md @@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Added `Driver::latencies()` +- Added `Driver::latencies()` to query input and output stream latencies in frames +- Added `BufferPreference` enum expressing the driver's preferred buffer size and valid-size constraints - `asio_message` now dispatches `kAsioResyncRequest` and `kAsioLatenciesChanged` to callbacks instead of silently ignoring them - `sample_rate_did_change` now dispatches `AsioDriverEvent::SampleRateChanged` to registered @@ -25,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Public-facing `c_long` fields and return types replaced with `i32` - Public-facing `c_double` parameters and return types replaced with `f64` - `Driver::latencies()` now returns `Latencies { input, output }` -- `Driver::buffersize_range()` now returns `BufferSizeRange { min, max }` +- `BufferSizeRange` adds `preferred: BufferPreference` field - `CallbackInfo::system_time` is now `u64` nanoseconds - `AsioError::ASE_NoMemory` renamed to `AsioError::NoMemory` - `AsioTime::reserved`, `AsioTimeInfo::reserved`, `AsioTimeCode::future` fields made private. diff --git a/asio-sys/src/bindings/mod.rs b/asio-sys/src/bindings/mod.rs index 360546754..36be4e102 100644 --- a/asio-sys/src/bindings/mod.rs +++ b/asio-sys/src/bindings/mod.rs @@ -98,11 +98,20 @@ pub struct Latencies { pub output: i32, } +/// Hardware buffer size preferences and constraints. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum BufferPreference { + Only(u32), + Preferred(u32), + Stepped { preferred: u32, step: u32 }, +} + /// Minimum and maximum supported buffer sizes in frames. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct BufferSizeRange { pub min: i32, pub max: i32, + pub preferred: BufferPreference, } /// Information provided to the BufferCallback. @@ -389,7 +398,7 @@ impl Asio { let mut driver_names: [[c_char; MAX_DRIVER_NAME_LEN]; MAX_DRIVERS] = [[0; MAX_DRIVER_NAME_LEN]; MAX_DRIVERS]; // Pointer to each driver name. - let mut driver_name_ptrs: [*mut i8; MAX_DRIVERS] = [null_mut(); MAX_DRIVERS]; + let mut driver_name_ptrs: [*mut c_char; MAX_DRIVERS] = [null_mut(); MAX_DRIVERS]; for (ptr, name) in driver_name_ptrs.iter_mut().zip(&mut driver_names[..]) { *ptr = (*name).as_mut_ptr(); } @@ -450,7 +459,7 @@ impl Asio { let mut driver_info = std::mem::MaybeUninit::::uninit(); unsafe { - match ai::load_asio_driver(driver_name_cstring.as_ptr() as *mut i8) { + match ai::load_asio_driver(driver_name_cstring.as_ptr() as *mut c_char) { false => Err(LoadDriverError::LoadDriverFailed), true => { // Initialize ASIO. @@ -527,6 +536,14 @@ impl Driver { Ok(BufferSizeRange { min: buffer_sizes.min, max: buffer_sizes.max, + preferred: match buffer_sizes.grans { + -1 => BufferPreference::Only(buffer_sizes.pref as u32), + 0 => BufferPreference::Preferred(buffer_sizes.pref as u32), + granularity => BufferPreference::Stepped { + preferred: buffer_sizes.pref as u32, + step: granularity as u32, + }, + }, }) } @@ -600,7 +617,7 @@ impl Driver { let name_cstring = CString::new(self.inner.name.as_str()) .expect("driver name already stored must not contain null bytes"); unsafe { - if !ai::load_asio_driver(name_cstring.as_ptr() as *mut i8) { + if !ai::load_asio_driver(name_cstring.as_ptr() as *mut c_char) { return Err(AsioError::NoDrivers); } let mut driver_info = std::mem::MaybeUninit::::uninit(); diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index c74e9e15e..189f69880 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -133,6 +133,7 @@ impl Device { D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; com::com_initialized(); let description = self.description()?; let driver = super::GLOBAL_ASIO @@ -465,6 +466,7 @@ impl Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; com::com_initialized(); let description = self.description()?; let driver = super::GLOBAL_ASIO @@ -1117,6 +1119,18 @@ fn check_config( ), )); } + if let sys::BufferPreference::Stepped { step, .. } = range.preferred { + let offset = requested_size_i32 - range.min; + if offset % step as i32 != 0 { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {requested_size} is not valid; sizes must start at {min} and increment by {step}", + min = range.min + ), + )); + } + } } // Try and set the sample rate to what the user selected. From fcf3da00a80bd3ec80c8e266aff2d8d8556f1211 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:30:04 +0200 Subject: [PATCH 02/14] feat(aaudio): support arbitrary channel counts and validate stream configurations --- src/host/aaudio/mod.rs | 66 ++++++++++-------------------------------- 1 file changed, 16 insertions(+), 50 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 7bae66576..a5cb36c9f 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -3,7 +3,6 @@ //! Default backend on Android. use std::{ - cmp, convert::TryInto, fmt, hash::{Hash, Hasher}, @@ -104,14 +103,9 @@ impl From for InterfaceType { } } -// constants from android.media.AudioFormat -const CHANNEL_OUT_MONO: i32 = 4; -const CHANNEL_OUT_STEREO: i32 = 12; - -// Android Java API supports up to 8 channels -// TODO: more channels available in native AAudio -// Maps channel masks to their corresponding channel counts -const CHANNEL_CONFIGS: [(i32, ChannelCount); 2] = [(CHANNEL_OUT_MONO, 1), (CHANNEL_OUT_STEREO, 2)]; +// ITU-R BS.2051 standard surround channel counts; used as fallback when the device does not +// report its own via AudioDeviceInfo.getChannelCounts(). +const DEFAULT_CHANNEL_COUNTS: [i32; 5] = [1, 2, 4, 6, 8]; const SAMPLE_RATES: [i32; 15] = [ 5512, 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200, 96000, @@ -206,22 +200,22 @@ impl HostTrait for Host { } fn buffer_size_range() -> SupportedBufferSize { - SupportedBufferSize::Range { - min: 1, - max: i32::MAX as FrameCount, - } + // The valid range for frames_per_data_callback is any positive i32, but the meaningful + // lower bound (frames_per_burst) is only known after open_stream. + SupportedBufferSize::Unknown } fn default_supported_configs() -> VecIntoIter { const FORMATS: [SampleFormat; 2] = [SampleFormat::I16, SampleFormat::F32]; let buffer_size = buffer_size_range(); - let mut output = Vec::with_capacity(SAMPLE_RATES.len() * CHANNEL_CONFIGS.len() * FORMATS.len()); + let mut output = + Vec::with_capacity(SAMPLE_RATES.len() * DEFAULT_CHANNEL_COUNTS.len() * FORMATS.len()); for sample_format in &FORMATS { - for (_channel_mask, channel_count) in &CHANNEL_CONFIGS { + for channel_count in &DEFAULT_CHANNEL_COUNTS { for sample_rate in &SAMPLE_RATES { output.push(SupportedStreamConfigRange { - channels: *channel_count, + channels: *channel_count as ChannelCount, min_sample_rate: *sample_rate as SampleRate, max_sample_rate: *sample_rate as SampleRate, buffer_size, @@ -241,11 +235,10 @@ fn device_supported_configs(device: &AudioDeviceInfo) -> VecIntoIter VecIntoIter 0); - if *channel_count > 2 { - // could be supported by the device - // TODO: more channels available in native AAudio - continue; - } for format in formats { output.push(SupportedStreamConfigRange { - channels: cmp::min(*channel_count as ChannelCount, 2), + channels: *channel_count as ChannelCount, min_sample_rate: *sample_rate as SampleRate, max_sample_rate: *sample_rate as SampleRate, buffer_size, @@ -635,6 +623,7 @@ impl DeviceTrait for Device { D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; let format = match sample_format { SampleFormat::I16 => ndk::audio::AudioFormat::PCM_I16, SampleFormat::F32 => ndk::audio::AudioFormat::PCM_Float, @@ -645,21 +634,9 @@ impl DeviceTrait for Device { )) } }; - let channel_count = match config.channels { - 1 => 1, - 2 => 2, - channels => { - // TODO: more channels available in native AAudio - return Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!("Channel count {channels} is not supported"), - )); - } - }; - let builder = ndk::audio::AudioStreamBuilder::new()? .direction(ndk::audio::AudioDirection::Input) - .channel_count(channel_count) + .channel_count(config.channels as i32) .format(format); build_input_stream( @@ -684,6 +661,7 @@ impl DeviceTrait for Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; let format = match sample_format { SampleFormat::I16 => ndk::audio::AudioFormat::PCM_I16, SampleFormat::F32 => ndk::audio::AudioFormat::PCM_Float, @@ -694,21 +672,9 @@ impl DeviceTrait for Device { )) } }; - let channel_count = match config.channels { - 1 => 1, - 2 => 2, - channels => { - // TODO: more channels available in native AAudio - return Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!("Channel count {channels} is not supported"), - )); - } - }; - let builder = ndk::audio::AudioStreamBuilder::new()? .direction(ndk::audio::AudioDirection::Output) - .channel_count(channel_count) + .channel_count(config.channels as i32) .format(format); build_output_stream( From b75aed108636c756d5f9232851c7c5a21ac3031c Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:31:28 +0200 Subject: [PATCH 03/14] feat(audioworklet): support render quantum size and validate stream configurations --- src/host/audioworklet/mod.rs | 84 +++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 21 deletions(-) diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index ee8637a59..408b82a8f 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -50,15 +50,38 @@ pub struct Stream { pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; const MIN_CHANNELS: ChannelCount = 1; -const MAX_CHANNELS: ChannelCount = 32; -const MIN_SAMPLE_RATE: SampleRate = 8_000; -const MAX_SAMPLE_RATE: SampleRate = 96_000; +const MAX_CHANNELS: ChannelCount = 64; +const MAX_SAMPLE_RATE: SampleRate = 768_000; // Chrome's AudioContext const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; // https://webaudio.github.io/web-audio-api/#render-quantum-size const DEFAULT_RENDER_SIZE: u64 = 128; +fn render_quantum_size_supported() -> bool { + (|| -> Option { + let global = js_sys::global(); + let ctor = js_sys::Reflect::get(&global, &JsValue::from("AudioContext")).ok()?; + let proto = js_sys::Reflect::get(&ctor, &JsValue::from("prototype")).ok()?; + js_sys::Reflect::has(&proto, &JsValue::from("renderQuantumSize")).ok() + })() + .unwrap_or(false) +} + +fn supported_render_quantum_range() -> SupportedBufferSize { + if render_quantum_size_supported() { + SupportedBufferSize::Range { + min: DEFAULT_RENDER_SIZE as FrameCount, + max: FrameCount::MAX, + } + } else { + SupportedBufferSize::Range { + min: DEFAULT_RENDER_SIZE as FrameCount, + max: DEFAULT_RENDER_SIZE as FrameCount, + } + } +} + impl Host { pub fn new() -> Result { if Self::is_available() { @@ -141,18 +164,24 @@ impl DeviceTrait for Device { } fn supported_output_configs(&self) -> Result { - let buffer_size = SupportedBufferSize::Unknown; + let buffer_size = supported_render_quantum_range(); // In actuality the number of supported channels cannot be fully known until // the browser attempts to initialized the AudioWorklet. let configs: Vec<_> = (MIN_CHANNELS..=MAX_CHANNELS) - .map(|channels| SupportedStreamConfigRange { - channels, - min_sample_rate: MIN_SAMPLE_RATE, - max_sample_rate: MAX_SAMPLE_RATE, - buffer_size, - sample_format: SUPPORTED_SAMPLE_FORMAT, + .flat_map(|channels| { + crate::COMMON_SAMPLE_RATES + .iter() + .copied() + .filter(|&r| r <= MAX_SAMPLE_RATE) + .map(move |rate| SupportedStreamConfigRange { + channels, + min_sample_rate: rate, + max_sample_rate: rate, + buffer_size, + sample_format: SUPPORTED_SAMPLE_FORMAT, + }) }) .collect(); Ok(configs.into_iter()) @@ -225,6 +254,7 @@ impl DeviceTrait for Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; if config.channels < MIN_CHANNELS || config.channels > MAX_CHANNELS { return Err(Error::with_message( ErrorKind::UnsupportedConfig, @@ -234,15 +264,7 @@ impl DeviceTrait for Device { ), )); } - if config.sample_rate < MIN_SAMPLE_RATE || config.sample_rate > MAX_SAMPLE_RATE { - return Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!( - "Sample rate {} Hz is not in the supported range {} to {} Hz", - config.sample_rate, MIN_SAMPLE_RATE, MAX_SAMPLE_RATE - ), - )); - } + if sample_format != SUPPORTED_SAMPLE_FORMAT { return Err(Error::with_message( ErrorKind::UnsupportedConfig, @@ -252,6 +274,19 @@ impl DeviceTrait for Device { )); } + if let BufferSize::Fixed(n) = config.buffer_size { + if let SupportedBufferSize::Range { min, max } = supported_render_quantum_range() { + if !(min..=max).contains(&n) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {n} is not in the supported render quantum range {min}..={max}" + ), + )); + } + } + } + let stream_opts = web_sys::AudioContextOptions::new(); stream_opts.set_sample_rate(config.sample_rate as f32); if let BufferSize::Fixed(n) = config.buffer_size { @@ -272,6 +307,13 @@ impl DeviceTrait for Device { let destination = audio_context.destination(); + // Chrome rounds renderSizeHint to a power of two; read back the actual quantum. + let actual_render_quantum = + js_sys::Reflect::get(audio_context.as_ref(), &JsValue::from("renderQuantumSize")) + .ok() + .and_then(|v| v.as_f64()) + .map(|v| v as u64); + // If possible, set the destination's channel_count to the given config.channel. // If not, fallback on the default destination channel_count to keep previous behavior // and do not return an error. @@ -279,10 +321,10 @@ impl DeviceTrait for Device { destination.set_channel_count(config.channels as u32); } - let initial_quantum = match config.buffer_size { + let initial_quantum = actual_render_quantum.unwrap_or_else(|| match config.buffer_size { BufferSize::Fixed(n) => n as u64, BufferSize::Default => DEFAULT_RENDER_SIZE, - }; + }); let buffer_size_frames = Arc::new(AtomicU64::new(initial_quantum)); let buffer_size_frames_cb = buffer_size_frames.clone(); let ctx = audio_context.clone(); From dd1ac246a0d4964de2dd5ae785418dbc4aba4daa Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:33:14 +0200 Subject: [PATCH 04/14] fix(coreaudio): validate stream configurations --- src/host/coreaudio/ios/mod.rs | 21 ++++++++++++++---- src/host/coreaudio/macos/device.rs | 34 ++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index a08ed5fe0..3e8f16a6f 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -172,6 +172,7 @@ impl DeviceTrait for Device { D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; // Configure buffer size and create audio unit let mut audio_unit = setup_stream_audio_unit(config, sample_format, true)?; @@ -217,6 +218,7 @@ impl DeviceTrait for Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; // Configure buffer size and create audio unit let mut audio_unit = setup_stream_audio_unit(config, sample_format, false)?; @@ -388,6 +390,10 @@ fn get_device_buffer_frames() -> usize { } } +// Typical iOS hardware buffer frame limits according to Apple Technical Q&A QA1631. +const BUFFER_SIZE_MIN: FrameCount = 256; +const BUFFER_SIZE_MAX: FrameCount = 4096; + /// Get supported stream config ranges for input (is_input=true) or output (is_input=false). fn get_supported_stream_configs(is_input: bool) -> std::vec::IntoIter { // SAFETY: AVAudioSession methods are safe to call on the singleton instance @@ -402,10 +408,9 @@ fn get_supported_stream_configs(is_input: bool) -> std::vec::IntoIter Result { - // Configure buffer size via AVAudioSession if let BufferSize::Fixed(buffer_size) = config.buffer_size { + if !(BUFFER_SIZE_MIN..=BUFFER_SIZE_MAX).contains(&buffer_size) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {buffer_size} is not in the supported range \ + {BUFFER_SIZE_MIN}..={BUFFER_SIZE_MAX}" + ), + )); + } set_audio_session_buffer_size(buffer_size, config.sample_rate)?; } diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 783c9e5f7..8028c3d2d 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -704,6 +704,7 @@ impl Device { D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; // The scope and element for working with a device's input stream. let scope = Scope::Output; let element = Element::Input; @@ -734,7 +735,14 @@ impl Device { }; // Configure stream format and buffer size for predictable callback behavior. - configure_stream_format_and_buffer(&mut audio_unit, config, sample_format, scope, element)?; + configure_stream_format_and_buffer( + &mut audio_unit, + config, + sample_format, + scope, + element, + self.audio_device_id, + )?; let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let error_callback_disconnect = error_callback.clone(); @@ -811,6 +819,7 @@ impl Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; // Best-effort: set the physical stream format (bit depth + sample rate) on the hardware. // This avoids unnecessary conversions, especially on aggregate devices. Not an error if // it fails — the AudioUnit will handle format conversion as before. @@ -837,7 +846,14 @@ impl Device { let element = Element::Output; // Configure device buffer (see comprehensive documentation in input stream above) - configure_stream_format_and_buffer(&mut audio_unit, config, sample_format, scope, element)?; + configure_stream_format_and_buffer( + &mut audio_unit, + config, + sample_format, + scope, + element, + self.audio_device_id, + )?; let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let error_callback_for_render = error_callback.clone(); @@ -949,6 +965,7 @@ fn configure_stream_format_and_buffer( sample_format: SampleFormat, scope: Scope, element: Element, + device_id: AudioDeviceID, ) -> Result<(), Error> { // Set the stream format using stream-specific scope/element // - Input streams: scope=Output, element=Input (configuring output format of input element) @@ -958,6 +975,19 @@ fn configure_stream_format_and_buffer( // Configure device buffer size if requested if let BufferSize::Fixed(buffer_size) = config.buffer_size { + // Pre-validate against the hardware range so callers get a human-readable error. + if let Ok(SupportedBufferSize::Range { min, max }) = + get_io_buffer_frame_size_range(device_id) + { + if !(min..=max).contains(&buffer_size) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {buffer_size} is not in the supported range {min}..={max}" + ), + )); + } + } // IMPORTANT: Buffer frame size is a DEVICE-LEVEL property, not stream-specific. // Unlike stream format above, we ALWAYS use Scope::Global + Element::Output // for device properties, regardless of whether this is an input or output stream. From d0aea81389b3cb02cdb1ff8d68163ae565a73407 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:33:59 +0200 Subject: [PATCH 05/14] feat(jack): support arbitrary port counts and validate stream configuration validations --- src/host/jack/device.rs | 42 ++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/host/jack/device.rs b/src/host/jack/device.rs index 3dac8451e..bdc9db23c 100644 --- a/src/host/jack/device.rs +++ b/src/host/jack/device.rs @@ -7,20 +7,20 @@ use std::{ use super::{stream::Stream, JACK_SAMPLE_FORMAT}; pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; use crate::{ - traits::DeviceTrait, BufferSize, Data, DeviceDescription, DeviceDescriptionBuilder, - DeviceDirection, DeviceId, Error, ErrorKind, InputCallbackInfo, OutputCallbackInfo, - SampleFormat, SampleRate, StreamConfig, SupportedBufferSize, SupportedStreamConfig, - SupportedStreamConfigRange, + traits::DeviceTrait, BufferSize, ChannelCount, Data, DeviceDescription, + DeviceDescriptionBuilder, DeviceDirection, DeviceId, Error, ErrorKind, InputCallbackInfo, + OutputCallbackInfo, SampleFormat, SampleRate, StreamConfig, SupportedBufferSize, + SupportedStreamConfig, SupportedStreamConfigRange, }; -const DEFAULT_NUM_CHANNELS: u16 = 2; -const DEFAULT_SUPPORTED_CHANNELS: [u16; 10] = [1, 2, 4, 6, 8, 16, 24, 32, 48, 64]; +const DEFAULT_NUM_CHANNELS: ChannelCount = 2; #[derive(Clone, Debug)] pub struct Device { name: String, sample_rate: SampleRate, buffer_size: SupportedBufferSize, + max_channels: ChannelCount, direction: DeviceDirection, start_server_automatically: bool, connect_ports_automatically: bool, @@ -40,6 +40,22 @@ impl Device { // making the stream. This is a hack due to the fact that the Client must be moved to // create the AsyncClient. let client = super::get_client(&name, client_options)?; + let port_pattern = match direction { + DeviceDirection::Input => "system:capture_.*", + DeviceDirection::Output => "system:playback_.*", + _ => { + return Err(Error::with_message( + ErrorKind::UnsupportedOperation, + format!("JACK does not support {direction:?} direction"), + )) + } + }; + let max_channels = client + .ports(Some(port_pattern), None, jack::PortFlags::empty()) + .len() + .try_into() + .unwrap_or(DEFAULT_NUM_CHANNELS) + .max(DEFAULT_NUM_CHANNELS); Ok(Self { // The name given to the client by JACK, could potentially be different from the name // supplied e.g. if there is a name collision @@ -49,6 +65,7 @@ impl Device { min: client.buffer_size(), max: client.buffer_size(), }, + max_channels, direction, start_server_automatically, connect_ports_automatically, @@ -109,18 +126,15 @@ impl Device { Ok(f) => f, }; - let mut supported_configs = vec![]; - - for &channels in DEFAULT_SUPPORTED_CHANNELS.iter() { - supported_configs.push(SupportedStreamConfigRange { + (1..=self.max_channels) + .map(|channels| SupportedStreamConfigRange { channels, min_sample_rate: f.sample_rate, max_sample_rate: f.sample_rate, buffer_size: f.buffer_size, sample_format: f.sample_format, - }); - } - supported_configs + }) + .collect() } pub fn is_input(&self) -> bool { @@ -187,6 +201,7 @@ impl DeviceTrait for Device { "Device does not support input", )); } + crate::validate_stream_config(&conf)?; if sample_format != JACK_SAMPLE_FORMAT { return Err(Error::with_message( ErrorKind::UnsupportedConfig, @@ -265,6 +280,7 @@ impl DeviceTrait for Device { "Device does not support output", )); } + crate::validate_stream_config(&conf)?; if sample_format != JACK_SAMPLE_FORMAT { return Err(Error::with_message( ErrorKind::UnsupportedConfig, From 61baaef022b1eeb5d0970ce0b7107a7dfb26c89d Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:34:50 +0200 Subject: [PATCH 06/14] fix(pipewire): validate stream configurations --- src/host/pipewire/device.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index cccf744d8..ab0ef2009 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -346,6 +346,20 @@ impl DeviceTrait for Device { D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; + if let BufferSize::Fixed(n) = config.buffer_size { + // When max_quantum is 0 the server clock metadata has not been received yet. + if self.max_quantum > 0 && !(self.min_quantum..=self.max_quantum).contains(&n) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {n} is not in the supported quantum range {min}..={max}", + min = self.min_quantum, + max = self.max_quantum + ), + )); + } + } let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); let (init_tx, init_rx) = mpsc::channel::>(); @@ -513,6 +527,20 @@ impl DeviceTrait for Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; + if let BufferSize::Fixed(n) = config.buffer_size { + // When max_quantum is 0 the server clock metadata has not been received yet. + if self.max_quantum > 0 && !(self.min_quantum..=self.max_quantum).contains(&n) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {n} is not in the supported quantum range {min}..={max}", + min = self.min_quantum, + max = self.max_quantum + ), + )); + } + } let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); let (init_tx, init_rx) = mpsc::channel::>(); From d496e70a81baeaeaab6d2a77d51c36d62c1a5169 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:35:25 +0200 Subject: [PATCH 07/14] fix(pulseaudio): validate stream configurations --- src/host/pulseaudio/mod.rs | 51 ++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/host/pulseaudio/mod.rs b/src/host/pulseaudio/mod.rs index edccfdfac..df8da9d6f 100644 --- a/src/host/pulseaudio/mod.rs +++ b/src/host/pulseaudio/mod.rs @@ -224,12 +224,15 @@ pub enum Device { }, } -fn supported_config_ranges() -> Vec { +fn supported_config_ranges(is_playback: bool) -> Vec { let mut ranges = vec![]; for format in PULSE_FORMATS { for channel_count in 1..protocol::sample_spec::MAX_CHANNELS { let bytes_per_frame = channel_count as usize * format.sample_size(); - let max_frames = (protocol::MAX_MEMBLOCKQ_LENGTH / bytes_per_frame) as FrameCount; + // Playback uses a double-buffer. + let divisor = if is_playback { 2 } else { 1 }; + let max_frames = + (protocol::MAX_MEMBLOCKQ_LENGTH / (divisor * bytes_per_frame)) as FrameCount; ranges.push(SupportedStreamConfigRange { channels: channel_count as _, min_sample_rate: MIN_SAMPLE_RATE, @@ -248,6 +251,7 @@ fn supported_config_ranges() -> Vec { fn default_config_from_spec( sample_spec: &protocol::SampleSpec, channel_map: &protocol::ChannelMap, + is_playback: bool, ) -> Result { let sample_format: SampleFormat = sample_spec.format.try_into().map_err(|_| { Error::with_message( @@ -256,7 +260,8 @@ fn default_config_from_spec( ) })?; let bytes_per_frame = channel_map.num_channels() as usize * sample_format.sample_size(); - let max_frames = (protocol::MAX_MEMBLOCKQ_LENGTH / bytes_per_frame) as u32; + let divisor = if is_playback { 2 } else { 1 }; + let max_frames = (protocol::MAX_MEMBLOCKQ_LENGTH / (divisor * bytes_per_frame)) as u32; Ok(SupportedStreamConfig { channels: channel_map.num_channels() as _, sample_rate: sample_spec.sample_rate, @@ -277,14 +282,14 @@ impl DeviceTrait for Device { let Device::Source { .. } = self else { return Ok(vec![].into_iter()); }; - Ok(supported_config_ranges().into_iter()) + Ok(supported_config_ranges(false).into_iter()) } fn supported_output_configs(&self) -> Result { let Device::Sink { .. } = self else { return Ok(vec![].into_iter()); }; - Ok(supported_config_ranges().into_iter()) + Ok(supported_config_ranges(true).into_iter()) } fn default_input_config(&self) -> Result { @@ -294,7 +299,7 @@ impl DeviceTrait for Device { "Device does not support input", )); }; - default_config_from_spec(&info.sample_spec, &info.channel_map) + default_config_from_spec(&info.sample_spec, &info.channel_map, false) } fn default_output_config(&self) -> Result { @@ -304,7 +309,7 @@ impl DeviceTrait for Device { "Device does not support output", )); }; - default_config_from_spec(&info.sample_spec, &info.channel_map) + default_config_from_spec(&info.sample_spec, &info.channel_map, true) } fn build_input_stream_raw( @@ -326,6 +331,8 @@ impl DeviceTrait for Device { )); }; + crate::validate_stream_config(&config)?; + let format: protocol::SampleFormat = sample_format.try_into().map_err(|_| { Error::with_message( ErrorKind::UnsupportedConfig, @@ -333,6 +340,19 @@ impl DeviceTrait for Device { ) })?; + if let BufferSize::Fixed(frame_count) = config.buffer_size { + let bytes_per_frame = config.channels as usize * sample_format.sample_size(); + let max_frames = (protocol::MAX_MEMBLOCKQ_LENGTH / bytes_per_frame) as FrameCount; + if !(1..=max_frames).contains(&frame_count) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {frame_count} is not in the supported range 1..={max_frames}" + ), + )); + } + } + let sample_spec = make_sample_spec(config, format); let channel_map = make_channel_map(config); let buffer_attr = make_record_buffer_attr(config, format); @@ -401,6 +421,8 @@ impl DeviceTrait for Device { )); }; + crate::validate_stream_config(&config)?; + let format: protocol::SampleFormat = sample_format.try_into().map_err(|_| { Error::with_message( ErrorKind::UnsupportedConfig, @@ -408,6 +430,21 @@ impl DeviceTrait for Device { ) })?; + if let BufferSize::Fixed(frame_count) = config.buffer_size { + let bytes_per_frame = config.channels as usize * sample_format.sample_size(); + // Playback uses a double-buffer (max_length = 2 × frame_count × bytes_per_frame), + // so the max period that fits in MAX_MEMBLOCKQ_LENGTH is halved. + let max_frames = (protocol::MAX_MEMBLOCKQ_LENGTH / (2 * bytes_per_frame)) as FrameCount; + if !(1..=max_frames).contains(&frame_count) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {frame_count} is not in the supported range 1..={max_frames}" + ), + )); + } + } + let sample_spec = make_sample_spec(config, format); let channel_map = make_channel_map(config); let buffer_attr = make_playback_buffer_attr(config, format); From c57ad7830f0e91595b9563fa6e6a9720804f7a50 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:36:33 +0200 Subject: [PATCH 08/14] fix(wasapi): validate stream configurations --- src/host/wasapi/device.rs | 76 +++++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index f54096aae..ee9078258 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -283,10 +283,8 @@ unsafe fn format_from_waveformatex_ptr( max: buffer_duration_to_frames(max_buffer_duration, sample_rate), } } else { - SupportedBufferSize::Range { - min: 0, - max: u32::MAX, - } + // Software audio stack: no hardware buffer constraint to report. + SupportedBufferSize::Unknown }; let format = SupportedStreamConfig { @@ -671,8 +669,35 @@ impl Device { sample_rates.push(format.sample_rate); } + let mut default_period_hns: i64 = 0; + let device_period_hns = if client + .GetDevicePeriod(Some(&mut default_period_hns), None) + .is_ok() + && default_period_hns > 0 + { + Some(default_period_hns) + } else { + None + }; + let mut supported_formats = Vec::new(); for sample_rate in sample_rates { + let buffer_size = match format.buffer_size { + // Software stacks: substitute the device period expressed in frames + // at this sample rate. + SupportedBufferSize::Unknown => device_period_hns + .map(|p_hns| { + let frames = buffer_duration_to_frames(p_hns, sample_rate); + SupportedBufferSize::Range { + min: frames, + max: frames, + } + }) + .unwrap_or(SupportedBufferSize::Unknown), + // Hardware stacks: report the hardware buffer size limits as-is. + other => other, + }; + for sample_format in WAVEFORMATEXTENSIBLE_SAMPLE_FORMATS { if let Some(waveformat) = config_to_waveformatextensible( StreamConfig { @@ -692,7 +717,7 @@ impl Device { channels: format.channels, min_sample_rate: sample_rate, max_sample_rate: sample_rate, - buffer_size: format.buffer_size, + buffer_size, sample_format, }); } @@ -740,12 +765,29 @@ impl Device { .map(WaveFormatExPtr) .context("Failed to get mix format")?; - format_from_waveformatex_ptr(format_ptr.0, client).ok_or_else(|| { - Error::with_message( - ErrorKind::UnsupportedConfig, - "Device audio format could not be mapped to a supported format", - ) - }) + let mut config = + format_from_waveformatex_ptr(format_ptr.0, client).ok_or_else(|| { + Error::with_message( + ErrorKind::UnsupportedConfig, + "Device audio format could not be mapped to a supported format", + ) + })?; + + if config.buffer_size == SupportedBufferSize::Unknown { + let mut default_period_hns: i64 = 0; + if client + .GetDevicePeriod(Some(&mut default_period_hns), None) + .is_ok() + && default_period_hns > 0 + { + let frames = buffer_duration_to_frames(default_period_hns, config.sample_rate); + config.buffer_size = SupportedBufferSize::Range { + min: frames, + max: frames, + }; + } + } + Ok(config) } } @@ -789,6 +831,7 @@ impl Device { sample_format: SampleFormat, activation_timeout: Option, ) -> Result { + crate::validate_stream_config(&config)?; unsafe { // Making sure that COM is initialized. // It's not actually sure that this is required, but when in doubt do it. @@ -799,8 +842,9 @@ impl Device { .build_audioclient(activation_timeout) .context("Failed to build audio client")?; - // Note: Buffer size validation is not needed here - `IAudioClient::Initialize` - // will return `AUDCLNT_E_BUFFER_SIZE_ERROR` if the buffer size is not supported. + // No further range validation: IAudioClient::Initialize accepts any positive duration + // in shared mode. The callback period is always GetDevicePeriod() regardless of what + // is requested here; the value only affects ring-buffer latency. let buffer_duration = buffer_size_to_duration(&config.buffer_size, config.sample_rate); let mut stream_flags = DEFAULT_FLAGS; @@ -904,6 +948,7 @@ impl Device { sample_format: SampleFormat, activation_timeout: Option, ) -> Result { + crate::validate_stream_config(&config)?; unsafe { // Making sure that COM is initialized. // It's not actually sure that this is required, but when in doubt do it. @@ -914,8 +959,9 @@ impl Device { .build_audioclient(activation_timeout) .context("Failed to build audio client")?; - // Note: Buffer size validation is not needed here - `IAudioClient::Initialize` - // will return `AUDCLNT_E_BUFFER_SIZE_ERROR` if the buffer size is not supported. + // No further range validation: IAudioClient::Initialize accepts any positive duration + // in shared mode. The callback period is always GetDevicePeriod() regardless of what + // is requested here; the value only affects ring-buffer latency. let buffer_duration = buffer_size_to_duration(&config.buffer_size, config.sample_rate); // Computing the format and initializing the device. From 0723398fe4e8b49d93ae01019a50246a40402421 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:37:37 +0200 Subject: [PATCH 09/14] fix(webaudio): increase sample rate support and stream configuration validations --- src/host/webaudio/mod.rs | 44 ++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs index 3c4ef9723..64effc867 100644 --- a/src/host/webaudio/mod.rs +++ b/src/host/webaudio/mod.rs @@ -68,12 +68,9 @@ crate::assert_stream_sync!(Stream); pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; const MIN_CHANNELS: ChannelCount = 1; -const MAX_CHANNELS: ChannelCount = 32; -const MIN_SAMPLE_RATE: SampleRate = 8_000; -const MAX_SAMPLE_RATE: SampleRate = 96_000; +const MAX_CHANNELS: ChannelCount = 64; +const MAX_SAMPLE_RATE: SampleRate = 768_000; // Chrome's AudioContext ceiling -const MIN_BUFFER_SIZE: u32 = 1; -const MAX_BUFFER_SIZE: u32 = u32::MAX; const DEFAULT_BUFFER_SIZE: usize = 2048; const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; @@ -129,16 +126,22 @@ impl Device { fn supported_output_configs(&self) -> Result { let buffer_size = SupportedBufferSize::Range { - min: MIN_BUFFER_SIZE, - max: MAX_BUFFER_SIZE, + min: 1, + max: FrameCount::MAX, }; let configs: Vec<_> = (MIN_CHANNELS..=MAX_CHANNELS) - .map(|channels| SupportedStreamConfigRange { - channels, - min_sample_rate: MIN_SAMPLE_RATE, - max_sample_rate: MAX_SAMPLE_RATE, - buffer_size, - sample_format: SUPPORTED_SAMPLE_FORMAT, + .flat_map(|channels| { + crate::COMMON_SAMPLE_RATES + .iter() + .copied() + .filter(|&r| r <= MAX_SAMPLE_RATE) + .map(move |rate| SupportedStreamConfigRange { + channels, + min_sample_rate: rate, + max_sample_rate: rate, + buffer_size, + sample_format: SUPPORTED_SAMPLE_FORMAT, + }) }) .collect(); Ok(configs.into_iter()) @@ -229,6 +232,7 @@ impl DeviceTrait for Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + crate::validate_stream_config(&config)?; if !valid_config(config, sample_format) { return Err(Error::with_message( ErrorKind::UnsupportedConfig, @@ -242,17 +246,7 @@ impl DeviceTrait for Device { let n_channels = config.channels as usize; let buffer_size_frames = match config.buffer_size { - BufferSize::Fixed(v) => { - if !(MIN_BUFFER_SIZE..=MAX_BUFFER_SIZE).contains(&v) { - return Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!( - "Buffer size {v} is not in the supported range {MIN_BUFFER_SIZE}..={MAX_BUFFER_SIZE}" - ), - )); - } - v as usize - } + BufferSize::Fixed(v) => v as usize, BufferSize::Default => DEFAULT_BUFFER_SIZE, }; let buffer_size_samples = buffer_size_frames * n_channels; @@ -657,9 +651,7 @@ fn is_webaudio_available() -> bool { // Whether or not the given stream configuration is valid for building a stream. fn valid_config(conf: StreamConfig, sample_format: SampleFormat) -> bool { conf.channels <= MAX_CHANNELS - && conf.channels >= MIN_CHANNELS && conf.sample_rate <= MAX_SAMPLE_RATE - && conf.sample_rate >= MIN_SAMPLE_RATE && sample_format == SUPPORTED_SAMPLE_FORMAT } From e6f5dd2c93d0e962a6478a866cd835f4dd48e060 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:38:38 +0200 Subject: [PATCH 10/14] refactor(lib): remove dead code pragma --- src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index cf11e08eb..7fd2463fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -836,7 +836,6 @@ impl From for StreamConfig { } } -#[allow(dead_code)] pub(crate) fn validate_stream_config(config: &StreamConfig) -> Result<(), Error> { if config.channels == 0 { return Err(Error::with_message( From caec011ccf3e9a85da5114057cf5967b5d9dfed8 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 22:54:00 +0200 Subject: [PATCH 11/14] doc: log stream config validation fixes --- CHANGELOG.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d4723a4..a980b14d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,9 +72,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 silently returning an empty list. - **AAudio**: Bump MSRV to 1.85. - **AAudio**: Buffers with default sizes are now dynamically tuned. -- **AAudio**: `SupportedBufferSize` now reports `min: 1`. +- **AAudio**: `SupportedBufferSize` in enumeration is now `Unknown`. - **AAudio**: `default_input_config()` and `default_output_config()` now prefer 48 kHz, then 44.1 kHz, then the maximum supported sample rate, instead of always taking the maximum. +- **AAudio**: Channel enumeration extended to 8 channels. - **ALSA**: Stream error callback now receives `ErrorKind::DeviceNotAvailable` on device disconnection. - **ALSA**: Polling errors trigger underrun recovery instead of looping. @@ -99,6 +100,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AudioWorklet**: `BufferSize::Fixed` now sets `renderSizeHint` on the `AudioContext`. - **AudioWorklet**: `default_output_config()` now uses 48 kHz as the default sample rate instead of 44.1 kHz, reflecting the dominant native rate on modern hardware. +- **AudioWorklet**: Supported channel count upper bound raised from 32 to 64 (AES10 maximum). +- **AudioWorklet**: `channels: 0` or `sample_rate: 0` now return `InvalidInput` instead of `UnsupportedConfig`. +- **AudioWorklet**: Sample rates now enumerated as discrete standard rates up to 768 kHz. - **CoreAudio**: Bump MSRV to 1.85. - **CoreAudio**: Bump `mach2` to 0.6 (uses `core::ffi` instead of `libc`, enables tvOS builds). - **CoreAudio**: Timestamps now include device latency and safety offset. @@ -141,6 +145,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WebAudio**: Initial buffer scheduling offset now scales with buffer duration. - **WebAudio**: `default_output_config()` now uses 48 kHz as the default sample rate instead of 44.1 kHz, reflecting the dominant native rate on modern hardware. +- **WebAudio**: Supported channel count upper bound raised from 32 to 64 (AES10 maximum). +- **WebAudio**: Sample rates now enumerated as discrete standard rates up to 768 kHz. ### Removed @@ -161,6 +167,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AAudio**: Poisoned stream locks now return `ErrorKind::StreamInvalidated` instead of panicking. - **AAudio**: Output buffers are now zero-filled before the callback runs. - **AAudio**: Stream errors are now forwarded to `error_callback`. +- **AAudio**: Fix `channels: 0` returning `UnsupportedConfig` instead of `InvalidInput`. +- **AAudio**: Fix `sample_rate: 0` silently opening a stream at the NDK default rate instead of + returning `InvalidInput`. - **ALSA**: Fix capture stream hanging or spinning on overruns. - **ALSA**: Fix timestamps stepping backward during stream startup or after xrun recovery. - **ALSA**: Fix spurious timestamp errors during stream startup. @@ -177,6 +186,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ALSA**: Fix `supported_configs()` using the same buffer range for all formats and channels. - **ALSA**: Fix `supported_configs()` dropping sample rates outside of `COMMON_SAMPLE_RATES`. - **ALSA**: Fix `BufferSize::Fixed(0)` being silently accepted. +- **ALSA**: Fix `channels: 0` or `sample_rate: 0` returning `UnsupportedConfig` instead of `InvalidInput`. +- **ALSA**: Fix `build_*_stream_raw` returning `UnsupportedConfig` instead of `UnsupportedOperation` when + the device does not support the requested direction. - **ASIO**: Fix enumeration returning only the first device when using `collect()`. - **ASIO**: Fix device enumeration and stream creation failing when called from spawned threads. - **ASIO**: Fix buffer size not resizing when the driver reports `kAsioBufferSizeChange`. @@ -190,6 +202,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ASIO**: Fix `driver.sample_rate()` failures at stream creation being silently ignored. - **ASIO**: Fix callbacks firing before `build_*_stream` returns the `Stream` handle. - **ASIO**: Fix overrun not being reported when the driver reports `kAsioOverload`. +- **ASIO**: Fix `channels: 0`, `sample_rate: 0`, or `BufferSize::Fixed(0)` not returning + `ErrorKind::InvalidInput`; preventing a divide-by-zero panic. +- **ASIO**: Fix `BufferSize::Fixed` with a size that does not align to the driver's step constraint + not returning `ErrorKind::UnsupportedConfig`. - **AudioWorklet**: Fix `default_output_device()` to return `None` when AudioWorklet is unavailable. - **CoreAudio**: Fix default output streams silently stopping when the system default output device is unplugged; they now reroute automatically or report `ErrorKind::DeviceNotAvailable`. @@ -201,6 +217,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CoreAudio**: Fix crashes on certain drivers due to early initialization. - **CoreAudio**: Fix `supported_output_configs()` and `supported_input_configs()` collapsing non-continuous hardware rates into a continuous range of sample rates (regression since v0.17.0). +- **CoreAudio**: Fix `channels: 0`, `sample_rate: 0`, or `BufferSize::Fixed(0)` to return `ErrorKind::InvalidInput`. +- **CoreAudio**: Fix `BufferSize::Fixed` producing cryptic backend errors when not validated against + the hardware buffer frame size range before stream creation. +- **CoreAudio (iOS)**: Fix `BufferSize::Fixed` not being validated against the supported range before stream creation. - **JACK**: Fix input capture timestamp using callback execution time instead of cycle start. - **JACK**: Poisoned error callback mutex no longer silently drops subsequent error notifications. - **JACK**: Port registration failure now fails stream creation instead of silently failing. @@ -208,6 +228,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **JACK**: Sample rate is now validated against the live JACK server at stream creation time. - **JACK**: Underrun notification no longer blocks the notification thread. - **JACK**: Output buffers are now zero-filled before the callback runs. +- **JACK**: Fix `channels: 0`, `sample_rate: 0`, or `BufferSize::Fixed(0)` not returning `ErrorKind::InvalidInput` + before attempting server connection. +- **JACK**: Fix `supported_input_configs()` and `supported_output_configs()` reporting a hardcoded sparse channel + list instead of enumerating all counts up to the number of physical system ports. +- **PipeWire**: Fix `channels: 0` or `sample_rate: 0` silently using PipeWire-negotiated values instead of + returning `ErrorKind::InvalidInput`. +- **PulseAudio**: Fix `channels: 0` or `sample_rate: 0` reaching the server instead of returning `ErrorKind::InvalidInput`. - **WASAPI**: Poisoned locks now returns an error instead of panicking. - **WASAPI**: Output buffers are now zero-filled before the callback runs. - **WASAPI**: Fix audio worker thread spawn failure panicking instead of returning an error. @@ -215,9 +242,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WASAPI**: Fix Communications-class inputs to return silence. - **WASAPI**: Fix `supported_input_configs()` advertising unsupported sample rates on input devices. +- **WASAPI**: Fix `sample_rate: 0` with `BufferSize::Fixed` causing a divide-by-zero panic. +- **WASAPI**: Fix `channels: 0` or `sample_rate: 0` not returning `ErrorKind::InvalidInput`. +- **WASAPI**: Fix `supported_input_configs()`, `supported_output_configs()`, `default_input_config()`, + and `default_output_config()` reporting an unconstrained buffer range on software audio stacks. +- **PulseAudio**: Fix `supported_output_configs()` and `default_output_config()` to account for PulseAudio's double-buffer. - **WebAudio**: Fix duplicated callbacks on repeated `play()` calls. - **WebAudio**: Report errors through the callback instead of panicking. - **WebAudio**: Fix `default_output_device()` to return `None` when WebAudio is unavailable. +- **WebAudio**: Fix `channels: 0`, `sample_rate: 0`, or `BufferSize::Fixed(0)` not returning `ErrorKind::InvalidInput`. ## [0.17.3] - 2026-02-18 From 6a2d47257bf189db3d0544f96690d94fe502f9fd Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 24 May 2026 23:39:01 +0200 Subject: [PATCH 12/14] fix: address review points --- src/host/aaudio/mod.rs | 9 +++++++-- src/host/audioworklet/mod.rs | 2 +- src/host/webaudio/mod.rs | 22 +++++++++++----------- src/lib.rs | 1 + 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index a5cb36c9f..eaaa08897 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -252,10 +252,15 @@ fn device_supported_configs(device: &AudioDeviceInfo) -> VecIntoIter 0); + let Ok(channels) = ChannelCount::try_from(*channel_count) else { + continue; + }; + if channels == 0 { + continue; + } for format in formats { output.push(SupportedStreamConfigRange { - channels: *channel_count as ChannelCount, + channels, min_sample_rate: *sample_rate as SampleRate, max_sample_rate: *sample_rate as SampleRate, buffer_size, diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index 408b82a8f..d6f5c23d0 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -321,7 +321,7 @@ impl DeviceTrait for Device { destination.set_channel_count(config.channels as u32); } - let initial_quantum = actual_render_quantum.unwrap_or_else(|| match config.buffer_size { + let initial_quantum = actual_render_quantum.unwrap_or(match config.buffer_size { BufferSize::Fixed(n) => n as u64, BufferSize::Default => DEFAULT_RENDER_SIZE, }); diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs index 64effc867..421717438 100644 --- a/src/host/webaudio/mod.rs +++ b/src/host/webaudio/mod.rs @@ -233,12 +233,11 @@ impl DeviceTrait for Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; - if !valid_config(config, sample_format) { + if sample_format != SUPPORTED_SAMPLE_FORMAT { return Err(Error::with_message( ErrorKind::UnsupportedConfig, format!( - "Sample format {sample_format} or channel count {} is not supported", - config.channels + "Sample format {sample_format} is not supported; required format is {SUPPORTED_SAMPLE_FORMAT}" ), )); } @@ -249,7 +248,15 @@ impl DeviceTrait for Device { BufferSize::Fixed(v) => v as usize, BufferSize::Default => DEFAULT_BUFFER_SIZE, }; - let buffer_size_samples = buffer_size_frames * n_channels; + let buffer_size_samples = buffer_size_frames.checked_mul(n_channels).ok_or_else(|| { + Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {} * channel count {} overflows on this platform", + buffer_size_frames, config.channels + ), + ) + })?; let buffer_time_step_secs = buffer_time_step_secs(buffer_size_frames, config.sample_rate); let data_callback: OutputDataCallbackArc = Arc::new(Mutex::new(data_callback)); @@ -648,13 +655,6 @@ fn is_webaudio_available() -> bool { .is_truthy() } -// Whether or not the given stream configuration is valid for building a stream. -fn valid_config(conf: StreamConfig, sample_format: SampleFormat) -> bool { - conf.channels <= MAX_CHANNELS - && conf.sample_rate <= MAX_SAMPLE_RATE - && sample_format == SUPPORTED_SAMPLE_FORMAT -} - fn buffer_time_step_secs(buffer_size_frames: usize, sample_rate: SampleRate) -> f64 { buffer_size_frames as f64 / sample_rate as f64 } diff --git a/src/lib.rs b/src/lib.rs index 7fd2463fd..cf11e08eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -836,6 +836,7 @@ impl From for StreamConfig { } } +#[allow(dead_code)] pub(crate) fn validate_stream_config(config: &StreamConfig) -> Result<(), Error> { if config.channels == 0 { return Err(Error::with_message( From a793be641ec036ea90afa0311742d14ddc878f0c Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 25 May 2026 00:04:14 +0200 Subject: [PATCH 13/14] fix: address review points --- CHANGELOG.md | 4 ++-- src/host/audioworklet/mod.rs | 15 +++++++++------ src/host/webaudio/mod.rs | 20 +++++++++++++++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a980b14d9..46855da47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,7 +100,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AudioWorklet**: `BufferSize::Fixed` now sets `renderSizeHint` on the `AudioContext`. - **AudioWorklet**: `default_output_config()` now uses 48 kHz as the default sample rate instead of 44.1 kHz, reflecting the dominant native rate on modern hardware. -- **AudioWorklet**: Supported channel count upper bound raised from 32 to 64 (AES10 maximum). - **AudioWorklet**: `channels: 0` or `sample_rate: 0` now return `InvalidInput` instead of `UnsupportedConfig`. - **AudioWorklet**: Sample rates now enumerated as discrete standard rates up to 768 kHz. - **CoreAudio**: Bump MSRV to 1.85. @@ -145,7 +144,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WebAudio**: Initial buffer scheduling offset now scales with buffer duration. - **WebAudio**: `default_output_config()` now uses 48 kHz as the default sample rate instead of 44.1 kHz, reflecting the dominant native rate on modern hardware. -- **WebAudio**: Supported channel count upper bound raised from 32 to 64 (AES10 maximum). - **WebAudio**: Sample rates now enumerated as discrete standard rates up to 768 kHz. ### Removed @@ -160,6 +158,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix numeric overflows in calls to create `StreamInstant` in ASIO, CoreAudio and JACK. +- **AAudio**: Fix panic in device configuration enumeration for pathological channel counts. - **AAudio**: Fix thread lock when a stream is dropped before it fully starts. - **AAudio**: Fix capture and playback timestamps falling back to time-zero on error. - **AAudio**: Fix capture and playback timestamp not accounting for audio pipeline buffer depth. @@ -247,6 +246,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WASAPI**: Fix `supported_input_configs()`, `supported_output_configs()`, `default_input_config()`, and `default_output_config()` reporting an unconstrained buffer range on software audio stacks. - **PulseAudio**: Fix `supported_output_configs()` and `default_output_config()` to account for PulseAudio's double-buffer. +- **WebAudio**: Fix overflow with pathological channel counts. - **WebAudio**: Fix duplicated callbacks on repeated `play()` calls. - **WebAudio**: Report errors through the callback instead of panicking. - **WebAudio**: Fix `default_output_device()` to return `None` when WebAudio is unavailable. diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index d6f5c23d0..899d03937 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -49,10 +49,14 @@ pub struct Stream { pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; +// https://webaudio.github.io/web-audio-api/#dom-audioworkletnode-audioworkletnode const MIN_CHANNELS: ChannelCount = 1; -const MAX_CHANNELS: ChannelCount = 64; -const MAX_SAMPLE_RATE: SampleRate = 768_000; // Chrome's AudioContext +const MAX_CHANNELS: ChannelCount = 32; +// Chrome's AudioContext ceiling; no spec-defined limit +const MAX_SAMPLE_RATE: SampleRate = 768_000; + +// https://webaudio.github.io/web-audio-api/#audio-processing-model const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; // https://webaudio.github.io/web-audio-api/#render-quantum-size @@ -255,16 +259,15 @@ impl DeviceTrait for Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; - if config.channels < MIN_CHANNELS || config.channels > MAX_CHANNELS { + if config.channels > MAX_CHANNELS { return Err(Error::with_message( ErrorKind::UnsupportedConfig, format!( - "Channel count {} is not in the supported range {} to {}", - config.channels, MIN_CHANNELS, MAX_CHANNELS + "Channel count {} exceeds the maximum of {MAX_CHANNELS}", + config.channels ), )); } - if sample_format != SUPPORTED_SAMPLE_FORMAT { return Err(Error::with_message( ErrorKind::UnsupportedConfig, diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs index 421717438..1a5a5e98f 100644 --- a/src/host/webaudio/mod.rs +++ b/src/host/webaudio/mod.rs @@ -67,13 +67,18 @@ crate::assert_stream_sync!(Stream); pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; +// https://webaudio.github.io/web-audio-api/#dom-baseaudiocontext-createbuffer const MIN_CHANNELS: ChannelCount = 1; -const MAX_CHANNELS: ChannelCount = 64; -const MAX_SAMPLE_RATE: SampleRate = 768_000; // Chrome's AudioContext ceiling +const MAX_CHANNELS: ChannelCount = 32; -const DEFAULT_BUFFER_SIZE: usize = 2048; +// Chrome's AudioContext ceiling; no spec-defined limit +const MAX_SAMPLE_RATE: SampleRate = 768_000; + +// https://webaudio.github.io/web-audio-api/#audio-processing-model const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; +const DEFAULT_BUFFER_SIZE: usize = 2048; + impl Host { pub fn new() -> Result { Ok(Self) @@ -233,6 +238,15 @@ impl DeviceTrait for Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; + if config.channels > MAX_CHANNELS { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Channel count {} exceeds the maximum of {MAX_CHANNELS}", + config.channels + ), + )); + } if sample_format != SUPPORTED_SAMPLE_FORMAT { return Err(Error::with_message( ErrorKind::UnsupportedConfig, From 730de717865d807a4f76b7c351cc7db4e0c4c6d1 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 25 May 2026 21:49:02 +0200 Subject: [PATCH 14/14] fix: address review points --- CHANGELOG.md | 14 ++++++++-- src/host/audioworklet/mod.rs | 44 ++++++++++++++++++++---------- src/host/coreaudio/macos/device.rs | 6 +++- src/host/webaudio/mod.rs | 29 +++++++++++++++----- 4 files changed, 69 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46855da47..391e00d25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,7 +101,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AudioWorklet**: `default_output_config()` now uses 48 kHz as the default sample rate instead of 44.1 kHz, reflecting the dominant native rate on modern hardware. - **AudioWorklet**: `channels: 0` or `sample_rate: 0` now return `InvalidInput` instead of `UnsupportedConfig`. -- **AudioWorklet**: Sample rates now enumerated as discrete standard rates up to 768 kHz. +- **AudioWorklet**: Sample rates now enumerated as discrete standard rates in the spec-required + range of 3–768 kHz. - **CoreAudio**: Bump MSRV to 1.85. - **CoreAudio**: Bump `mach2` to 0.6 (uses `core::ffi` instead of `libc`, enables tvOS builds). - **CoreAudio**: Timestamps now include device latency and safety offset. @@ -144,7 +145,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WebAudio**: Initial buffer scheduling offset now scales with buffer duration. - **WebAudio**: `default_output_config()` now uses 48 kHz as the default sample rate instead of 44.1 kHz, reflecting the dominant native rate on modern hardware. -- **WebAudio**: Sample rates now enumerated as discrete standard rates up to 768 kHz. +- **WebAudio**: Sample rates now enumerated as discrete standard rates in the spec-required + range of 3–768 kHz. ### Removed @@ -206,6 +208,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ASIO**: Fix `BufferSize::Fixed` with a size that does not align to the driver's step constraint not returning `ErrorKind::UnsupportedConfig`. - **AudioWorklet**: Fix `default_output_device()` to return `None` when AudioWorklet is unavailable. +- **AudioWorklet**: Fix channel count exceeding `destination.maxChannelCount` silently using fewer + channels than requested. +- **AudioWorklet**: Fix `supported_output_configs()` reporting the buffer size upper bound as + `FrameCount::MAX`; now correctly `floor(6 × sample_rate)` per spec. +- **AudioWorklet**: Fix `supported_output_configs()` reporting the minimum render quantum size as + 128 when `renderQuantumSize` is supported; the spec minimum is 1. - **CoreAudio**: Fix default output streams silently stopping when the system default output device is unplugged; they now reroute automatically or report `ErrorKind::DeviceNotAvailable`. - **CoreAudio**: Fix undefined behaviour and silent failure in loopback device creation. @@ -251,6 +259,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WebAudio**: Report errors through the callback instead of panicking. - **WebAudio**: Fix `default_output_device()` to return `None` when WebAudio is unavailable. - **WebAudio**: Fix `channels: 0`, `sample_rate: 0`, or `BufferSize::Fixed(0)` not returning `ErrorKind::InvalidInput`. +- **WebAudio**: Fix channel count exceeding `destination.maxChannelCount` silently using fewer + channels than requested. ## [0.17.3] - 2026-02-18 diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index 899d03937..922e3c047 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -53,7 +53,8 @@ pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; const MIN_CHANNELS: ChannelCount = 1; const MAX_CHANNELS: ChannelCount = 32; -// Chrome's AudioContext ceiling; no spec-defined limit +// https://webaudio.github.io/web-audio-api/#supported-sample-rates +const MIN_SAMPLE_RATE: SampleRate = 3_000; const MAX_SAMPLE_RATE: SampleRate = 768_000; // https://webaudio.github.io/web-audio-api/#audio-processing-model @@ -72,11 +73,12 @@ fn render_quantum_size_supported() -> bool { .unwrap_or(false) } -fn supported_render_quantum_range() -> SupportedBufferSize { +fn supported_render_quantum_range(sample_rate: SampleRate) -> SupportedBufferSize { + // https://webaudio.github.io/web-audio-api/#supported-render-quantum-sizes if render_quantum_size_supported() { SupportedBufferSize::Range { - min: DEFAULT_RENDER_SIZE as FrameCount, - max: FrameCount::MAX, + min: 1, + max: sample_rate.saturating_mul(6), } } else { SupportedBufferSize::Range { @@ -168,8 +170,6 @@ impl DeviceTrait for Device { } fn supported_output_configs(&self) -> Result { - let buffer_size = supported_render_quantum_range(); - // In actuality the number of supported channels cannot be fully known until // the browser attempts to initialized the AudioWorklet. @@ -178,12 +178,12 @@ impl DeviceTrait for Device { crate::COMMON_SAMPLE_RATES .iter() .copied() - .filter(|&r| r <= MAX_SAMPLE_RATE) + .filter(|&r| (MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&r)) .map(move |rate| SupportedStreamConfigRange { channels, min_sample_rate: rate, max_sample_rate: rate, - buffer_size, + buffer_size: supported_render_quantum_range(rate), sample_format: SUPPORTED_SAMPLE_FORMAT, }) }) @@ -276,9 +276,20 @@ impl DeviceTrait for Device { ), )); } + if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&config.sample_rate) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Sample rate {} Hz is not in the supported range {MIN_SAMPLE_RATE}..={MAX_SAMPLE_RATE} Hz", + config.sample_rate + ), + )); + } if let BufferSize::Fixed(n) = config.buffer_size { - if let SupportedBufferSize::Range { min, max } = supported_render_quantum_range() { + if let SupportedBufferSize::Range { min, max } = + supported_render_quantum_range(config.sample_rate) + { if !(min..=max).contains(&n) { return Err(Error::with_message( ErrorKind::UnsupportedConfig, @@ -317,12 +328,17 @@ impl DeviceTrait for Device { .and_then(|v| v.as_f64()) .map(|v| v as u64); - // If possible, set the destination's channel_count to the given config.channel. - // If not, fallback on the default destination channel_count to keep previous behavior - // and do not return an error. - if config.channels as u32 <= destination.max_channel_count() { - destination.set_channel_count(config.channels as u32); + if config.channels as u32 > destination.max_channel_count() { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Channel count {} exceeds the destination's maximum of {}", + config.channels, + destination.max_channel_count() + ), + )); } + destination.set_channel_count(config.channels as u32); let initial_quantum = actual_render_quantum.unwrap_or(match config.buffer_size { BufferSize::Fixed(n) => n as u64, diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 8028c3d2d..d14d3c9d9 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -735,13 +735,17 @@ impl Device { }; // Configure stream format and buffer size for predictable callback behavior. + let effective_device_id = loopback_aggregate + .as_ref() + .map(|l| l.aggregate_device.audio_device_id) + .unwrap_or(self.audio_device_id); configure_stream_format_and_buffer( &mut audio_unit, config, sample_format, scope, element, - self.audio_device_id, + effective_device_id, )?; let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs index 1a5a5e98f..6dfa14573 100644 --- a/src/host/webaudio/mod.rs +++ b/src/host/webaudio/mod.rs @@ -71,7 +71,8 @@ pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; const MIN_CHANNELS: ChannelCount = 1; const MAX_CHANNELS: ChannelCount = 32; -// Chrome's AudioContext ceiling; no spec-defined limit +// https://webaudio.github.io/web-audio-api/#supported-sample-rates +const MIN_SAMPLE_RATE: SampleRate = 3_000; const MAX_SAMPLE_RATE: SampleRate = 768_000; // https://webaudio.github.io/web-audio-api/#audio-processing-model @@ -139,7 +140,7 @@ impl Device { crate::COMMON_SAMPLE_RATES .iter() .copied() - .filter(|&r| r <= MAX_SAMPLE_RATE) + .filter(|&r| (MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&r)) .map(move |rate| SupportedStreamConfigRange { channels, min_sample_rate: rate, @@ -255,6 +256,15 @@ impl DeviceTrait for Device { ), )); } + if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&config.sample_rate) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Sample rate {} Hz is not in the supported range {MIN_SAMPLE_RATE}..={MAX_SAMPLE_RATE} Hz", + config.sample_rate + ), + )); + } let n_channels = config.channels as usize; @@ -289,12 +299,17 @@ impl DeviceTrait for Device { let destination = ctx.destination(); - // If possible, set the destination's channel_count to the given config.channel. - // If not, fallback on the default destination channel_count to keep previous behavior - // and do not return an error. - if config.channels as u32 <= destination.max_channel_count() { - destination.set_channel_count(config.channels as u32); + if config.channels as u32 > destination.max_channel_count() { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Channel count {} exceeds the destination's maximum of {}", + config.channels, + destination.max_channel_count() + ), + )); } + destination.set_channel_count(config.channels as u32); // SAFETY: WASM is single-threaded, so Arc is safe even though AudioContext is not Send/Sync #[allow(clippy::arc_with_non_send_sync)]