From 0bda5aa4943732146b3a53076e4059a6f5e90d22 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 20 Aug 2026 20:27:03 +0200 Subject: [PATCH 01/14] fix(alsa): validate format, channel and rate combinations when enumerating --- CHANGELOG.md | 1 + src/host/alsa/mod.rs | 98 ++++++++++++++++++++++++-------------------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 679a956ef..9fc376ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **ALSA**: Fix a remaining timestamp segfault on 32-bit platforms with a 64-bit kernel `time_t`. +- **ALSA**: Improved enumeration accuracy for supported format, channel and rate combinations. - **ASIO**: Fix a deadlock when dropping a `Stream` that owns another ASIO `Stream`. - **ASIO**: Fix loading a driver while a previous driver was still unloading. - **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`. diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 076306d29..c98a8386d 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -535,64 +535,46 @@ impl Device { //SND_PCM_FORMAT_U18_3BE, ]; - let min_rate = hw_params.get_rate_min()?; - let max_rate = hw_params.get_rate_max()?; - - let sample_rates = if min_rate == max_rate || hw_params.test_rate(min_rate + 1).is_ok() { - // Fixed rate or continuous range. - vec![(min_rate, max_rate)] - } else { - // Discrete rates: probe the standard list plus the hardware's own min and max so - // that rates outside `COMMON_SAMPLE_RATES` are not missed. - let mut probe: Vec = COMMON_SAMPLE_RATES.to_vec(); - probe.push(min_rate); - probe.push(max_rate); - probe.sort_unstable(); - probe.dedup(); - probe - .into_iter() - .filter(|&r| (min_rate..=max_rate).contains(&r) && hw_params.test_rate(r).is_ok()) - .map(|r| (r, r)) - .collect() - }; - - let min_channels = hw_params.get_channels_min()?; // 64 = AES10 (MADI) maximum; also prevents spinning on plugins like plughw that report u32::MAX. const CHANNEL_ENUM_CAP: u32 = 64; - let max_channels = hw_params - .get_channels_max()? - .min(CHANNEL_ENUM_CAP) - .min(ChannelCount::MAX as u32); - - let supported_channels: Vec = - if min_channels == max_channels || hw_params.test_channels(min_channels + 1).is_ok() { - (min_channels..=max_channels) - .map(|c| c as ChannelCount) - .collect() - } else { - (min_channels..=max_channels) - .filter(|&c| hw_params.test_channels(c).is_ok()) - .map(|c| c as ChannelCount) - .collect() - }; - let mut output = - Vec::with_capacity(FORMATS.len() * supported_channels.len() * sample_rates.len()); + let mut output = Vec::new(); let mut seen_formats: Vec = Vec::with_capacity(FORMATS.len()); // Key: (channels, physical width in bits) with 4 physical widths (8/16/32/64 bits) let mut buffer_size_cache: HashMap<(ChannelCount, u32), SupportedBufferSize> = - HashMap::with_capacity(supported_channels.len() * 4); + HashMap::new(); + // `test_*` checks a value, it doesn't apply it, so format/channels/rate are each set on a + // clone in sequence rather than tested independently against the same unconstrained params. for &(sample_format, alsa_format) in FORMATS.iter() { - if seen_formats.contains(&sample_format) || hw_params.test_format(alsa_format).is_err() - { + if seen_formats.contains(&sample_format) { + continue; + } + let format_params = hw_params.clone(); + if format_params.set_format(alsa_format).is_err() { continue; } seen_formats.push(sample_format); let width = alsa_format.physical_width().unwrap_or(0) as u32; - for &channels in &supported_channels { + let (Ok(min_channels), Ok(max_channels)) = ( + format_params.get_channels_min(), + format_params.get_channels_max(), + ) else { + continue; + }; + let max_channels = max_channels + .min(CHANNEL_ENUM_CAP) + .min(ChannelCount::MAX as u32); + + for raw_channels in min_channels..=max_channels { + let channel_params = format_params.clone(); + if channel_params.set_channels(raw_channels).is_err() { + continue; + } + let channels = raw_channels as ChannelCount; + let buffer_size = *buffer_size_cache .entry((channels, width)) @@ -600,6 +582,34 @@ impl Device { supported_period_size_range(&hw_params, alsa_format, channels) }); + let (Ok(min_rate), Ok(max_rate)) = + (channel_params.get_rate_min(), channel_params.get_rate_max()) + else { + continue; + }; + + let sample_rates = + if min_rate == max_rate || channel_params.test_rate(min_rate + 1).is_ok() { + // Fixed rate or continuous range. + vec![(min_rate, max_rate)] + } else { + // Discrete rates: probe the standard list plus the hardware's own min and max + // so that rates outside `COMMON_SAMPLE_RATES` are not missed. + let mut probe: Vec = COMMON_SAMPLE_RATES.to_vec(); + probe.push(min_rate); + probe.push(max_rate); + probe.sort_unstable(); + probe.dedup(); + probe + .into_iter() + .filter(|&r| { + (min_rate..=max_rate).contains(&r) + && channel_params.test_rate(r).is_ok() + }) + .map(|r| (r, r)) + .collect() + }; + for &(min_rate, max_rate) in sample_rates.iter() { output.push(SupportedStreamConfigRange { channels, From 5274a097b9ed01f0b15c4d062945361ed636aa8f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 20 Aug 2026 20:46:13 +0200 Subject: [PATCH 02/14] fix(pipewire): fall back to the last known frame count when none is requested --- CHANGELOG.md | 1 + src/host/pipewire/stream.rs | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc376ac7..ff198e1eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`. - **AudioWorklet**: Fix dropouts in output streams when the callback buffer grows. - **JACK**: Channel enumeration is capped at the physical system port count again. +- **PipeWire**: Fix an empty chunk being emitted when a cycle requests no frames. - **WASAPI**: Device enumeration no longer panics if the COM enumerator fails to initialize. ## [0.18.2] - 2026-08-16 diff --git a/src/host/pipewire/stream.rs b/src/host/pipewire/stream.rs index e9351dc97..25a75dd96 100644 --- a/src/host/pipewire/stream.rs +++ b/src/host/pipewire/stream.rs @@ -816,8 +816,12 @@ where } if let Some(mut buffer) = stream.dequeue_buffer() { - // Read the requested frame count before mutably borrowing datas_mut(). - let requested = buffer.requested() as usize; + // Read the requested frame count before mutably borrowing datas_mut(); fall back + // to the last negotiated quantum when a cycle outside the driver's schedule reports 0. + let requested = match buffer.requested() as usize { + 0 => user_data.last_quantum.load(Ordering::Relaxed) as usize, + requested => requested, + }; let datas = buffer.datas_mut(); if datas.is_empty() { return; From 04f6c1dcb875730cdbcfeeeed5479b88f3f092f0 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 20 Aug 2026 21:48:52 +0200 Subject: [PATCH 03/14] fix(pipewire): clamp the chunk size on capture --- CHANGELOG.md | 1 + src/host/pipewire/stream.rs | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff198e1eb..071cc4a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AudioWorklet**: Fix dropouts in output streams when the callback buffer grows. - **JACK**: Channel enumeration is capped at the physical system port count again. - **PipeWire**: Fix an empty chunk being emitted when a cycle requests no frames. +- **PipeWire**: Fix capture reading from the wrong offset in the buffer on some devices. - **WASAPI**: Device enumeration no longer panics if the COM enumerator fails to initialize. ## [0.18.2] - 2026-08-16 diff --git a/src/host/pipewire/stream.rs b/src/host/pipewire/stream.rs index 25a75dd96..1e0d4a834 100644 --- a/src/host/pipewire/stream.rs +++ b/src/host/pipewire/stream.rs @@ -1096,16 +1096,23 @@ where return; } let data = &mut datas[0]; - let n_samples = data.chunk().size() / user_data.sample_format.sample_size() as u32; - let frames = n_samples / n_channels; + let stride = user_data.sample_format.sample_size() * n_channels as usize; + let offset = data.chunk().offset() as usize; + let size = data.chunk().size() as usize; let Some(samples) = data.data() else { return; }; - let data = samples.as_mut_ptr() as *mut (); - let data = - unsafe { Data::from_parts(data, n_samples as usize, user_data.sample_format) }; - user_data.publish_data_in(stream, frames as usize, &data, xrun); + // offset/size semantics: spa/buffer/buffer.h. + let maxsize = samples.len(); + let offset = offset % maxsize; + let frames = size.min(maxsize - offset) / stride; + let valid = &mut samples[offset..offset + frames * stride]; + + let ptr = valid.as_mut_ptr() as *mut (); + let n_samples = frames * n_channels as usize; + let data = unsafe { Data::from_parts(ptr, n_samples, user_data.sample_format) }; + user_data.publish_data_in(stream, frames, &data, xrun); } }) .register()?; From 00d5c8a7786177417adc10ab98c60db6f7d9881a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 20 Aug 2026 23:25:38 +0200 Subject: [PATCH 04/14] fix(jack): clamp the process cycle to the buffer size instead of panicking --- CHANGELOG.md | 1 + src/host/jack/stream.rs | 29 ++++++++++++++++++++--------- src/host/mod.rs | 1 - 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 071cc4a5a..dcea497c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`. - **AudioWorklet**: Fix dropouts in output streams when the callback buffer grows. - **JACK**: Channel enumeration is capped at the physical system port count again. +- **JACK**: Streams no longer panic when the server delivers a larger period than the negotiated buffer size. - **PipeWire**: Fix an empty chunk being emitted when a cycle requests no frames. - **PipeWire**: Fix capture reading from the wrong offset in the buffer on some devices. - **WASAPI**: Device enumeration no longer panics if the COM enumerator fails to initialize. diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index 79da78084..b6b315f26 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -4,7 +4,6 @@ use std::sync::{ }; use super::JACK_SAMPLE_FORMAT; -#[cfg(feature = "realtime")] use crate::host::try_emit_error; use crate::{ CallbackInfo, ChannelCount, Data, Error, ErrorKind, FrameCount, ResultExt, Sample, SampleRate, @@ -79,7 +78,6 @@ impl Stream { None, playback_state.clone(), pending_xrun.clone(), - #[cfg(feature = "realtime")] error_callback_ptr.clone(), ); @@ -138,7 +136,6 @@ impl Stream { Some(Box::new(data_callback)), playback_state.clone(), pending_xrun.clone(), - #[cfg(feature = "realtime")] error_callback_ptr.clone(), ); @@ -285,8 +282,8 @@ struct LocalProcessHandler { temp_output_buffer: Vec, playback_state: Arc, pending_xrun: Arc, - #[cfg(feature = "realtime")] error_callback: ErrorCallbackArc, + oversized_reported: bool, #[cfg(feature = "realtime")] rt_checked: bool, } @@ -302,7 +299,7 @@ impl LocalProcessHandler { output_data_callback: Option, playback_state: Arc, pending_xrun: Arc, - #[cfg(feature = "realtime")] error_callback: ErrorCallbackArc, + error_callback: ErrorCallbackArc, ) -> Self { let temp_input_buffer = vec![f32::EQUILIBRIUM; in_ports.len() * buffer_size]; let temp_output_buffer = vec![f32::EQUILIBRIUM; out_ports.len() * buffer_size]; @@ -318,8 +315,8 @@ impl LocalProcessHandler { temp_output_buffer, playback_state, pending_xrun, - #[cfg(feature = "realtime")] error_callback, + oversized_reported: false, #[cfg(feature = "realtime")] rt_checked: false, } @@ -414,9 +411,21 @@ impl jack::ProcessHandler for LocalProcessHandler { } } - // This should be equal to self.buffer_size, but the implementation will - // work even if it is less. Will panic in `temp_buffer_to_data` if greater. - let current_frame_count = process_scope.n_frames() as usize; + // This should be equal to self.buffer_size, but the implementation will work even if + // it is less. A greater count is truncated to the temp buffers' capacity. + let requested_frame_count = process_scope.n_frames() as usize; + let current_frame_count = requested_frame_count.min(self.buffer_size); + if requested_frame_count > self.buffer_size && !self.oversized_reported { + let message = format!( + "JACK delivered a {requested_frame_count}-frame period, exceeding the configured buffer size of {}; truncated", + self.buffer_size + ); + self.oversized_reported = try_emit_error( + &self.error_callback, + Error::with_message(ErrorKind::BackendError, message), + ) + .is_ok(); + } // Get timestamp data let (current_start_usecs, next_usecs_opt) = match process_scope.cycle_times() { @@ -517,6 +526,8 @@ impl jack::ProcessHandler for LocalProcessHandler { for i in 0..current_frame_count { output_channel[i] = self.temp_output_buffer[ch_ix + i * num_out_channels]; } + // A truncated cycle leaves the tail of JACK's port buffer unwritten. + output_channel[current_frame_count..requested_frame_count].fill(f32::EQUILIBRIUM); } } diff --git a/src/host/mod.rs b/src/host/mod.rs index b1df58e1d..e25e818bf 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -216,7 +216,6 @@ pub(crate) use error_emit::emit_error; target_os = "android", all( feature = "jack", - feature = "realtime", any( target_os = "linux", target_os = "dragonfly", From 427190bc007cacbe505207cdb5d5ad0da2bf4247 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 20 Aug 2026 23:29:38 +0200 Subject: [PATCH 05/14] fix(jack): do not report xruns that occurred while paused --- src/host/jack/stream.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index b6b315f26..1c9ddb6fd 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -629,7 +629,7 @@ impl jack::NotificationHandler for JackNotificationHandler { } fn xrun(&mut self, _: &jack::Client) -> jack::Control { - if StreamState::load(&self.playback_state, Ordering::Relaxed) != StreamState::Starting { + if StreamState::load(&self.playback_state, Ordering::Relaxed) == StreamState::Playing { self.pending_xrun.store(true, Ordering::Relaxed); } jack::Control::Continue From 1e8f68ce019d71e7713149fe084bda6434a8c3c8 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 20 Aug 2026 23:38:29 +0200 Subject: [PATCH 06/14] fix(asio): do not panic on the stream lock in the buffer switch callback --- CHANGELOG.md | 1 + src/host/asio/stream.rs | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcea497c5..cd1d82c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ALSA**: Improved enumeration accuracy for supported format, channel and rate combinations. - **ASIO**: Fix a deadlock when dropping a `Stream` that owns another ASIO `Stream`. - **ASIO**: Fix loading a driver while a previous driver was still unloading. +- **ASIO**: `Stream` no longer risks blocking or panicking in the driver callback while another stream is being created or destroyed. - **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`. - **AudioWorklet**: Fix dropouts in output streams when the callback buffer grows. - **JACK**: Channel enumeration is capped at the physical system port count again. diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index ed316b2cb..3c266d892 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -223,8 +223,9 @@ impl Device { } last_buffer_index = callback_info.buffer_index; - // There is 0% chance of lock contention the host only locks when recreating streams. - let stream_lock = asio_streams.lock().unwrap(); + let Ok(stream_lock) = asio_streams.lock() else { + return; + }; let asio_stream = match stream_lock.input { Some(ref asio_stream) => asio_stream, None => return, @@ -577,8 +578,9 @@ impl Device { } last_buffer_index = callback_info.buffer_index; - // There is 0% chance of lock contention the host only locks when recreating streams. - let mut stream_lock = asio_streams.lock().unwrap(); + let Ok(mut stream_lock) = asio_streams.lock() else { + return; + }; let asio_stream = match stream_lock.output { Some(ref mut asio_stream) => asio_stream, None => return, From a70c71ed6a0250b8275749f085c4fb29be95ff47 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 20 Aug 2026 23:47:46 +0200 Subject: [PATCH 07/14] refactor(wasapi): prepare format negotiation for exclusive mode --- src/host/wasapi/device.rs | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 3068e29db..b1d9fa2c3 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -187,19 +187,15 @@ unsafe fn data_flow_from_immendpoint(endpoint: &Audio::IMMEndpoint) -> Audio::ED } // Given the audio client and format, returns whether the audio engine supports it natively in -// shared mode without format conversion. +// the given share mode without format conversion. pub unsafe fn is_format_supported( client: &Audio::IAudioClient, waveformatex_ptr: *const Audio::WAVEFORMATEX, + share_mode: Audio::AUDCLNT_SHAREMODE, ) -> Result { let mut closest_match: *mut Audio::WAVEFORMATEX = ptr::null_mut(); - let hr = unsafe { - client.IsFormatSupported( - Audio::AUDCLNT_SHAREMODE_SHARED, - waveformatex_ptr, - Some(&mut closest_match), - ) - }; + let hr = + unsafe { client.IsFormatSupported(share_mode, waveformatex_ptr, Some(&mut closest_match)) }; if !closest_match.is_null() { let _free = WaveFormatExPtr(closest_match); } @@ -646,7 +642,11 @@ impl Device { .context("Failed to get mix format")?; // If the default format can't succeed we have no hope of finding other formats. - if !is_format_supported(client, default_waveformatex_ptr.0)? { + if !is_format_supported( + client, + default_waveformatex_ptr.0, + Audio::AUDCLNT_SHAREMODE_SHARED, + )? { return Err(Error::with_message( ErrorKind::UnsupportedConfig, "Could not determine support for default audio format", @@ -717,11 +717,13 @@ impl Device { buffer_size: BufferSize::Default, }, sample_format, + None, ) { let usable = is_output || is_format_supported( client, &waveformat.Format as *const Audio::WAVEFORMATEX, + Audio::AUDCLNT_SHAREMODE_SHARED, )?; if usable { supported_formats.push(SupportedStreamConfigRange { @@ -867,7 +869,7 @@ impl Device { // Computing the format and initializing the device. let waveformatex = { - let format_attempt = config_to_waveformatextensible(config, sample_format) + let format_attempt = config_to_waveformatextensible(config, sample_format, None) .ok_or_else(|| { Error::with_message( ErrorKind::UnsupportedConfig, @@ -970,7 +972,7 @@ impl Device { // Computing the format and initializing the device. let waveformatex = { - let format_attempt = config_to_waveformatextensible(config, sample_format) + let format_attempt = config_to_waveformatextensible(config, sample_format, None) .ok_or_else(|| { Error::with_message( ErrorKind::UnsupportedConfig, @@ -1365,6 +1367,7 @@ const WAVEFORMATEXTENSIBLE_SAMPLE_FORMATS: [SampleFormat; 7] = [ fn config_to_waveformatextensible( config: StreamConfig, sample_format: SampleFormat, + channel_mask: Option, ) -> Option { let format_tag = match sample_format { SampleFormat::U8 | SampleFormat::I16 => Audio::WAVE_FORMAT_PCM, @@ -1405,8 +1408,9 @@ fn config_to_waveformatextensible( cbSize: cb_size, }; - // CPAL does not care about speaker positions, so pass audio right through. - let channel_mask = KernelStreaming::KSAUDIO_SPEAKER_DIRECTOUT; + // By default CPAL does not care about speaker positions, so pass audio right through. + // Exclusive mode negotiation supplies its own mask instead. + let channel_mask = channel_mask.unwrap_or(KernelStreaming::KSAUDIO_SPEAKER_DIRECTOUT); let sub_format = match sample_format { SampleFormat::U8 From 6bf04e439f12960fd24c828d5d81969899601d40 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 21 Aug 2026 08:35:30 +0200 Subject: [PATCH 08/14] fix(wasapi): prefill the render buffer before starting the stream --- CHANGELOG.md | 1 + src/host/wasapi/stream.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1d82c46..ee5004fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **PipeWire**: Fix an empty chunk being emitted when a cycle requests no frames. - **PipeWire**: Fix capture reading from the wrong offset in the buffer on some devices. - **WASAPI**: Device enumeration no longer panics if the COM enumerator fails to initialize. +- **WASAPI**: Output streams now start with silence instead of undefined content in the render buffer. ## [0.18.2] - 2026-08-16 diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index 873e2cf11..b19b129bb 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -577,6 +577,19 @@ fn process_commands(run_context: &mut RunContext) -> Result { match command { Command::PlayStream => unsafe { if !run_context.stream.playing { + // Start() needs a primed buffer, or there's an audible gap until the engine + // gets real data from the first callback. + if let AudioClientFlow::Render { ref render_client } = + run_context.stream.client_flow + { + // PlayStream also fires on resume from pause, where the buffer wasn't + // reset and may already be full. + let frames = get_available_frames(&run_context.stream)?; + if frames > 0 { + write_silence(render_client, &run_context.stream, frames)?; + } + } + run_context .stream .audio_client @@ -652,6 +665,22 @@ fn get_available_frames(stream: &StreamInner) -> Result { } } +// Fills `frames` of the render buffer with silence and releases it. +unsafe fn write_silence( + render_client: &Audio::IAudioRenderClient, + stream: &StreamInner, + frames: FrameCount, +) -> Result<(), Error> { + unsafe { + let buffer = render_client.GetBuffer(frames)?; + debug_assert!(!buffer.is_null()); + let byte_count = frames as usize * stream.bytes_per_frame as usize; + let buffer_slice = std::slice::from_raw_parts_mut(buffer, byte_count); + fill_equilibrium(buffer_slice, stream.sample_format); + render_client.ReleaseBuffer(frames, 0).map_err(Into::into) + } +} + fn run_input( mut run_ctxt: RunContext, data_callback: &mut dyn FnMut(&Data, &CallbackInfo), From 0e593537f358fa2a8f7bcb536c35674f434d9dcd Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 21 Aug 2026 21:14:41 +0200 Subject: [PATCH 09/14] fix(wasapi): stop reporting I64 and F64 as supported output formats --- CHANGELOG.md | 1 + src/host/wasapi/device.rs | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5004fb3..61de28140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **PipeWire**: Fix capture reading from the wrong offset in the buffer on some devices. - **WASAPI**: Device enumeration no longer panics if the COM enumerator fails to initialize. - **WASAPI**: Output streams now start with silence instead of undefined content in the render buffer. +- **WASAPI**: Fix `I64` and `F64` incorrectly reported as supported output formats. ## [0.18.2] - 2026-08-16 diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index b1d9fa2c3..74faa29b0 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -1351,14 +1351,12 @@ const OUTPUT_MAX_SAMPLE_RATE: SampleRate = 384_000; // Formats encodable as WAVEFORMATEXTENSIBLE. U8/I16 map to WAVE_FORMAT_PCM; the rest use // WAVE_FORMAT_EXTENSIBLE. Unsigned formats wider than 8 bits are omitted: KSDATAFORMAT_SUBTYPE_PCM // is always signed for 16-bit and wider, so submitting unsigned data would produce a DC offset. -const WAVEFORMATEXTENSIBLE_SAMPLE_FORMATS: [SampleFormat; 7] = [ +const WAVEFORMATEXTENSIBLE_SAMPLE_FORMATS: [SampleFormat; 5] = [ SampleFormat::U8, SampleFormat::I16, SampleFormat::I24, SampleFormat::I32, - SampleFormat::I64, SampleFormat::F32, - SampleFormat::F64, ]; // Turns a `Format` into a `WAVEFORMATEXTENSIBLE`. From e0b8bb2ff7ee4f1390e91b867f8777706891fe10 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 21 Aug 2026 21:38:26 +0200 Subject: [PATCH 10/14] fix(coreaudio): set the device rate when the physical format rate differs --- CHANGELOG.md | 1 + src/host/coreaudio/macos/device.rs | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61de28140..1c1b52322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ASIO**: `Stream` no longer risks blocking or panicking in the driver callback while another stream is being created or destroyed. - **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`. - **AudioWorklet**: Fix dropouts in output streams when the callback buffer grows. +- **CoreAudio**: Fix the device running at a different sample rate from the stream on hardware that reports a continuous rate range. - **JACK**: Channel enumeration is capped at the physical system port count again. - **JACK**: Streams no longer panic when the server delivers a larger period than the negotiated buffer size. - **PipeWire**: Fix an empty chunk being emitted when a cycle requests no frames. diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index bde36af6b..832aaa3f6 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -711,14 +711,15 @@ impl Device { // Set the physical stream format (bit depth + sample rate) on the hardware device. // This avoids unnecessary format conversions, which is especially important on aggregate - // devices. Falls back to sample-rate-only if no matching physical format is available. - if set_physical_format( + // devices. Falls back to sample-rate-only if no matching physical format is available, or + // if the closest match found doesn't actually run at the requested rate. + if !set_physical_format( self.audio_device_id, config.sample_rate, config.channels, sample_format, ) - .is_err() + .is_ok_and(|asbd| (asbd.mSampleRate - config.sample_rate as f64).abs() < 1.0) { set_sample_rate(self.audio_device_id, config.sample_rate, timeout)?; } @@ -845,14 +846,15 @@ impl Device { // 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. - if set_physical_format( + // it fails: the AudioUnit will handle format conversion as before. Also falls back if the + // closest match found doesn't actually run at the requested rate. + if !set_physical_format( self.audio_device_id, config.sample_rate, config.channels, sample_format, ) - .is_err() + .is_ok_and(|asbd| (asbd.mSampleRate - config.sample_rate as f64).abs() < 1.0) { set_sample_rate(self.audio_device_id, config.sample_rate, timeout)?; } From 98c85dd5ff13f6af0853bb40314f5755e258a761 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 21 Aug 2026 23:12:27 +0200 Subject: [PATCH 11/14] fix(coreaudio): enumerate supported sample formats from the hardware --- CHANGELOG.md | 1 + src/host/coreaudio/macos/device.rs | 75 ++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1b52322..57b4049ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`. - **AudioWorklet**: Fix dropouts in output streams when the callback buffer grows. - **CoreAudio**: Fix the device running at a different sample rate from the stream on hardware that reports a continuous rate range. +- **CoreAudio**: Fix `supported_configs()` only reporting `F32`, even on hardware that also supports other sample formats. - **JACK**: Channel enumeration is capped at the physical system port count again. - **JACK**: Streams no longer panic when the server delivers a larger period than the negotiated buffer size. - **PipeWire**: Fix an empty chunk being emitted when a cycle requests no frames. diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 832aaa3f6..7aec4cc87 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -15,7 +15,7 @@ use coreaudio::audio_unit::{ audio_format::LinearPcmFlags, macos_helpers::{ RateListener, audio_unit_from_device_id_uninitialized, find_matching_physical_format, - get_device_name, set_device_physical_stream_format, + get_device_name, get_supported_physical_stream_formats, set_device_physical_stream_format, }, render_callback::{self, data}, }; @@ -526,18 +526,7 @@ impl Device { n_channels += buf.mNumberChannels as usize; } - // TODO: macOS should support U8, I16, I32, F32 and F64. This should allow for using - // I16 but just use F32 for now as it's the default anyway. - let sample_format = SampleFormat::F32; - // Get available sample rate ranges. - // The property "kAudioDevicePropertyAvailableNominalSampleRates" returns a list of pairs of - // minimum and maximum sample rates but most of the devices returns pairs of same values though the underlying mechanism is unclear. - // This may cause issues when, for example, sorting the configs by the sample rates. - // We follows the implementation of RtAudio, which returns single element of config - // when all the pairs have the same values and returns multiple elements otherwise. - // See https://github.com/thestk/rtaudio/blob/master/RtAudio.cpp#L1369C1-L1375C39 - property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates; let mut data_size = 0u32; let status = AudioObjectGetPropertyDataSize( @@ -575,19 +564,55 @@ impl Device { } let buffer_size = get_io_buffer_frame_size_range(self.audio_device_id)?; - // Most hardware reports discrete rates (mMinimum == mMaximum); some aggregate or - // virtual devices report continuous ranges. - let fmts: Vec<_> = ranges - .iter() - .map(|range| SupportedStreamConfigRange { - channels: n_channels as ChannelCount, - min_sample_rate: range.mMinimum as u32, - max_sample_rate: range.mMaximum as u32, - buffer_size, - sample_format, - }) - .collect(); - Ok(fmts.into_iter()) + // AUHAL always converts to and from F32 regardless of the physical format, so + // advertise it at every nominal rate (most hardware reports discrete rates, i.e. + // mMinimum == mMaximum; some aggregate or virtual devices report continuous ranges). + let f32_fmts = ranges.iter().map(|range| SupportedStreamConfigRange { + channels: n_channels as ChannelCount, + min_sample_rate: range.mMinimum as u32, + max_sample_rate: range.mMaximum as u32, + buffer_size, + sample_format: SampleFormat::F32, + }); + + // The hardware's own physical formats, so integer-only devices advertise their + // bit-perfect paths instead of only the AUHAL-converted F32 one. + let physical_fmts = get_supported_physical_stream_formats(self.audio_device_id) + .unwrap_or_default() + .into_iter() + .filter_map(|fmt| { + let Some(coreaudio::audio_unit::AudioFormat::LinearPCM(flags)) = + coreaudio::audio_unit::AudioFormat::from_format_and_flag( + fmt.mFormat.mFormatID, + Some(fmt.mFormat.mFormatFlags), + ) + else { + return None; + }; + let sample_format = match CoreAudioSampleFormat::from_flags_and_bits_per_sample( + flags, + fmt.mFormat.mBitsPerChannel, + )? { + CoreAudioSampleFormat::I8 => SampleFormat::I8, + CoreAudioSampleFormat::I16 => SampleFormat::I16, + CoreAudioSampleFormat::I24 => SampleFormat::I24, + CoreAudioSampleFormat::I32 => SampleFormat::I32, + // Already covered by f32_fmts at every rate, not just this row's range. + CoreAudioSampleFormat::F32 => return None, + }; + Some(SupportedStreamConfigRange { + channels: fmt.mFormat.mChannelsPerFrame as ChannelCount, + min_sample_rate: fmt.mSampleRateRange.mMinimum as u32, + max_sample_rate: fmt.mSampleRateRange.mMaximum as u32, + buffer_size, + sample_format, + }) + }); + + Ok(f32_fmts + .chain(physical_fmts) + .collect::>() + .into_iter()) } } From 0d1871ad249828eba0d60e05b14476bdf9f1443a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sat, 22 Aug 2026 19:37:51 +0200 Subject: [PATCH 12/14] fix(jack): re-arm the oversized-cycle error after the period returns to size --- src/host/jack/stream.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index 1c9ddb6fd..f88306af7 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -415,16 +415,20 @@ impl jack::ProcessHandler for LocalProcessHandler { // it is less. A greater count is truncated to the temp buffers' capacity. let requested_frame_count = process_scope.n_frames() as usize; let current_frame_count = requested_frame_count.min(self.buffer_size); - if requested_frame_count > self.buffer_size && !self.oversized_reported { - let message = format!( - "JACK delivered a {requested_frame_count}-frame period, exceeding the configured buffer size of {}; truncated", - self.buffer_size - ); - self.oversized_reported = try_emit_error( - &self.error_callback, - Error::with_message(ErrorKind::BackendError, message), - ) - .is_ok(); + if requested_frame_count > self.buffer_size { + if !self.oversized_reported { + let message = format!( + "JACK delivered a {requested_frame_count}-frame period, exceeding the configured buffer size of {}; truncated", + self.buffer_size + ); + self.oversized_reported = try_emit_error( + &self.error_callback, + Error::with_message(ErrorKind::BackendError, message), + ) + .is_ok(); + } + } else { + self.oversized_reported = false; } // Get timestamp data From ca13908b1fffd918dfe3f8b428c81f8087307c00 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 1 Sep 2026 18:45:36 +0200 Subject: [PATCH 13/14] fix(wasapi): start output streams with real audio instead of a silence prefill --- CHANGELOG.md | 2 +- src/host/wasapi/device.rs | 6 +-- src/host/wasapi/stream.rs | 90 ++++++++++++++++++++++----------------- 3 files changed, 56 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b4049ab..15605afd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **PipeWire**: Fix an empty chunk being emitted when a cycle requests no frames. - **PipeWire**: Fix capture reading from the wrong offset in the buffer on some devices. - **WASAPI**: Device enumeration no longer panics if the COM enumerator fails to initialize. -- **WASAPI**: Output streams now start with silence instead of undefined content in the render buffer. +- **WASAPI**: Output streams now start with real audio immediately instead of undefined content in the render buffer. - **WASAPI**: Fix `I64` and `F64` incorrectly reported as supported output formats. ## [0.18.2] - 2026-08-16 diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 74faa29b0..708f57c4b 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -37,7 +37,7 @@ use windows::{ core::{GUID, Interface}, }; -use super::stream::{AudioClientFlow, DefaultDeviceMonitor, Stream, StreamInner}; +use super::stream::{AudioClientFlow, DefaultDeviceMonitor, PlaybackState, Stream, StreamInner}; pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; use crate::{host::com, traits::DeviceTrait}; @@ -935,7 +935,7 @@ impl Device { audio_clock, client_flow, event, - playing: false, + playback_state: PlaybackState::default(), max_frames_in_buffer, period_frames, bytes_per_frame: waveformatex.nBlockAlign, @@ -1040,7 +1040,7 @@ impl Device { audio_clock, client_flow, event, - playing: false, + playback_state: PlaybackState::default(), max_frames_in_buffer, period_frames, bytes_per_frame: waveformatex.nBlockAlign, diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index b19b129bb..1806ec50c 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -284,14 +284,25 @@ pub enum AudioClientFlow { }, } +/// Play/pause state of a [`StreamInner`]. `Priming` only ever applies to Render streams: set by +/// a cold-start `PlayStream`, it defers `Start()` until the run loop lands a real fill in the +/// buffer, so playback begins with actual audio instead of a silence-padded first period. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub enum PlaybackState { + #[default] + Stopped, + Priming, + Playing, +} + pub struct StreamInner { pub audio_client: Audio::IAudioClient, pub audio_clock: Audio::IAudioClock, pub client_flow: AudioClientFlow, // Event that is signalled by WASAPI whenever audio data must be written. pub event: Foundation::HANDLE, - // True if the stream is currently playing. False if paused. - pub playing: bool, + // Current playback state of the stream. + pub playback_state: PlaybackState, // Number of frames of audio data in the underlying buffer allocated by WASAPI. pub max_frames_in_buffer: FrameCount, // Callback size in frames. @@ -576,37 +587,40 @@ fn process_commands(run_context: &mut RunContext) -> Result { for command in run_context.commands.try_iter() { match command { Command::PlayStream => unsafe { - if !run_context.stream.playing { - // Start() needs a primed buffer, or there's an audible gap until the engine - // gets real data from the first callback. - if let AudioClientFlow::Render { ref render_client } = - run_context.stream.client_flow - { - // PlayStream also fires on resume from pause, where the buffer wasn't - // reset and may already be full. - let frames = get_available_frames(&run_context.stream)?; - if frames > 0 { - write_silence(render_client, &run_context.stream, frames)?; + if run_context.stream.playback_state == PlaybackState::Stopped { + // PlayStream also fires on resume from pause, where the buffer wasn't reset + // and may already hold real, unplayed data. + let cold_start = match run_context.stream.client_flow { + AudioClientFlow::Render { .. } => { + get_available_frames(&run_context.stream)? > 0 } + AudioClientFlow::Capture { .. } => false, + }; + if cold_start { + // Defer Start() until the run loop lands a real fill in the buffer, so + // playback begins with actual audio. + run_context.stream.playback_state = PlaybackState::Priming; + } else { + run_context + .stream + .audio_client + .Start() + .context("Failed to start audio client")?; + run_context.stream.playback_state = PlaybackState::Playing; } - - run_context - .stream - .audio_client - .Start() - .context("Failed to start audio client")?; - run_context.stream.playing = true; } }, Command::PauseStream | Command::StopStream => unsafe { - if run_context.stream.playing { + if run_context.stream.playback_state == PlaybackState::Playing { run_context .stream .audio_client .Stop() .context("Failed to stop audio client")?; - run_context.stream.playing = false; } + // Also cancels a deferred prefill that hasn't run yet, e.g. pause called right + // after play, before the run loop got a chance to act on it. + run_context.stream.playback_state = PlaybackState::Stopped; if matches!(command, Command::StopStream) { // Reset discards the render buffer so a future Start() plays no stale frames. run_context @@ -665,22 +679,6 @@ fn get_available_frames(stream: &StreamInner) -> Result { } } -// Fills `frames` of the render buffer with silence and releases it. -unsafe fn write_silence( - render_client: &Audio::IAudioRenderClient, - stream: &StreamInner, - frames: FrameCount, -) -> Result<(), Error> { - unsafe { - let buffer = render_client.GetBuffer(frames)?; - debug_assert!(!buffer.is_null()); - let byte_count = frames as usize * stream.bytes_per_frame as usize; - let buffer_slice = std::slice::from_raw_parts_mut(buffer, byte_count); - fill_equilibrium(buffer_slice, stream.sample_format); - render_client.ReleaseBuffer(frames, 0).map_err(Into::into) - } -} - fn run_input( mut run_ctxt: RunContext, data_callback: &mut dyn FnMut(&Data, &CallbackInfo), @@ -780,6 +778,16 @@ fn run_output( emit_error(error_callback, err); break; } + if run_ctxt.stream.playback_state == PlaybackState::Priming { + // The buffer above just received a real fill; start now so playback begins with it. + let start_result = unsafe { run_ctxt.stream.audio_client.Start() } + .context("Failed to start audio client"); + if let Err(err) = start_result { + emit_error(error_callback, err); + break; + } + run_ctxt.stream.playback_state = PlaybackState::Playing; + } } } @@ -821,6 +829,12 @@ fn process_commands_and_await_signal( } }; + if run_context.stream.playback_state == PlaybackState::Priming { + // The stream hasn't started yet, so its event never fires. Write the priming fill now + // instead of waiting on it. + return ControlFlow::Continue(true); + } + if let Some(ref flag) = run_context.pending_device_changed { if flag.swap(false, Ordering::Relaxed) { emit_error( From 67d6c47f3d0a17976e37564d26ad1473fd9446ae Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 1 Sep 2026 20:21:16 +0200 Subject: [PATCH 14/14] fix(wasapi): suppress the callback on pause --- src/host/wasapi/stream.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index 1806ec50c..ef1b9fd7a 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -315,7 +315,7 @@ pub struct StreamInner { pub sample_format: SampleFormat, // Hardware pipeline latency. pub stream_latency: Duration, - // Raised by `stop()` so the audio loop writes silence or skips delivering. + // Raised by `stop()` and `pause()` so the audio loop writes silence or skips delivering. pub draining: Arc, // Updated each output callback: latency + current buffer fill in microseconds. pub fill_usec: Arc, @@ -527,6 +527,7 @@ impl StreamTrait for Stream { } fn pause(&self) -> Result<(), Error> { + self.draining.store(true, Ordering::Relaxed); self.push_command(Command::PauseStream) }