From 52c82bce249e53ac175afe48ac18be0d761e1749 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 20 Sep 2026 19:56:27 +0200 Subject: [PATCH 1/5] refactor(wasapi): use shared wait_for_drain helper in stop() --- src/host/mod.rs | 2 +- src/host/wasapi/stream.rs | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/host/mod.rs b/src/host/mod.rs index 3d4446d86..89f328d90 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -294,7 +294,7 @@ pub(crate) fn secs_to_nanos(secs: f64) -> u64 { /// `Some(Duration::ZERO)` returns immediately. /// /// [`StreamTrait::stop`]: crate::traits::StreamTrait::stop -#[cfg(any(all(windows, feature = "asio"), target_vendor = "apple"))] +#[cfg(any(windows, target_vendor = "apple"))] pub(crate) fn wait_for_drain(window: std::time::Duration, timeout: Option) { let wait = timeout.map_or(window, |t| window.min(t)); if !wait.is_zero() { diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index a7723e7f3..58afb5463 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -20,7 +20,9 @@ use windows::Win32::{ use crate::{ CallbackInfo, Data, Error, ErrorKind, FrameCount, ResultExt, SampleFormat, SampleRate, StreamConfig, StreamInstant, StreamTimestamp, - host::{ErrorCallbackArc, emit_error, equilibrium::fill_equilibrium, latch::Latch}, + host::{ + ErrorCallbackArc, emit_error, equilibrium::fill_equilibrium, latch::Latch, wait_for_drain, + }, traits::StreamTrait, }; @@ -534,13 +536,10 @@ impl StreamTrait for Stream { fn stop(&self, timeout: Option) -> Result<(), Error> { self.skip_callback.store(true, Ordering::Relaxed); - if timeout != Some(Duration::ZERO) { - let fill = Duration::from_micros(self.fill_usec.load(Ordering::Relaxed)); - let wait = timeout.map_or(fill, |t| fill.min(t)); - if !wait.is_zero() { - std::thread::sleep(wait); - } - } + wait_for_drain( + Duration::from_micros(self.fill_usec.load(Ordering::Relaxed)), + timeout, + ); self.push_command(Command::StopStream).map_err(|_| { Error::with_message( From 60499a781a7fcabf56b6e10001fc72beed87f95b Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 20 Sep 2026 19:56:28 +0200 Subject: [PATCH 2/5] fix(wasapi): stop() drains audio written by a callback that races it --- CHANGELOG.md | 1 + src/host/wasapi/stream.rs | 34 +++++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3276100..73293b882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WASAPI**: Output streams now start with real audio immediately instead of undefined content in the render buffer. - **WASAPI**: A stream paused immediately after starting no longer plays silence before real audio on resume. - **WASAPI**: Fix `I64` and `F64` incorrectly reported as supported output formats. +- **WASAPI**: Reduced risk of `stop()` draining short when it races a callback that just wrote more audio. ## [0.18.2] - 2026-08-16 diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index 58afb5463..54f16ccef 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -963,17 +963,9 @@ fn process_output( }; let padding = stream.max_frames_in_buffer - frames_available; - let fill_usec = (padding as u64) - .saturating_mul(1_000_000) - .saturating_div(stream.config.sample_rate as u64) - .saturating_add( - stream - .stream_latency - .as_micros() - .try_into() - .unwrap_or(u64::MAX), - ); - stream.fill_usec.store(fill_usec, Ordering::Relaxed); + stream + .fill_usec + .store(fill_usec(stream, padding), Ordering::Relaxed); if stream.skip_callback.load(Ordering::Relaxed) { // Skip the period instead of queuing silence a future resume would replay. @@ -1020,9 +1012,29 @@ fn process_output( *frames_written += frames_available as u64; } + // Republish now the write has landed, so a concurrent stop() drains the whole tail. + stream.fill_usec.store( + fill_usec(stream, padding + frames_available), + Ordering::Relaxed, + ); + Ok(()) } +// Time until the device has played out `frames` of queued audio, including its own latency. +fn fill_usec(stream: &StreamInner, frames: FrameCount) -> u64 { + (frames as u64) + .saturating_mul(1_000_000) + .saturating_div(stream.config.sample_rate as u64) + .saturating_add( + stream + .stream_latency + .as_micros() + .try_into() + .unwrap_or(u64::MAX), + ) +} + /// Reads the stream's `IAudioClock` in a single `GetPosition` call, returning the callback /// [`StreamInstant`] together with the device position from that same snapshot. #[inline] From 7c4ba223501efb3ac861dc43ed0f176b53f4f5a8 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 20 Sep 2026 20:44:39 +0200 Subject: [PATCH 3/5] refactor(wasapi): remove 64-bit sample format handling --- src/host/wasapi/device.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 0d73be057..120c24a4d 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -222,7 +222,6 @@ unsafe fn format_from_waveformatex_ptr( (8, Audio::WAVE_FORMAT_PCM) => SampleFormat::U8, (16, Audio::WAVE_FORMAT_PCM) => SampleFormat::I16, (32, Multimedia::WAVE_FORMAT_IEEE_FLOAT) => SampleFormat::F32, - (64, Multimedia::WAVE_FORMAT_IEEE_FLOAT) => SampleFormat::F64, (n_bits, KernelStreaming::WAVE_FORMAT_EXTENSIBLE) => { let waveformatextensible_ptr = waveformatex_ptr as *const Audio::WAVEFORMATEXTENSIBLE; let sub = unsafe { (*waveformatextensible_ptr).SubFormat }; @@ -235,13 +234,11 @@ unsafe fn format_from_waveformatex_ptr( 24 => SampleFormat::I24, 32 if valid_bits == 24 => SampleFormat::I24, 32 => SampleFormat::I32, - 64 => SampleFormat::I64, _ => return None, } } else if cmp_guid(&sub, &Multimedia::KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) { match n_bits { 32 => SampleFormat::F32, - 64 => SampleFormat::F64, _ => return None, } } else { @@ -1370,11 +1367,9 @@ fn config_to_waveformatextensible( let format_tag = match sample_format { SampleFormat::U8 | SampleFormat::I16 => Audio::WAVE_FORMAT_PCM, - SampleFormat::I24 - | SampleFormat::I32 - | SampleFormat::I64 - | SampleFormat::F32 - | SampleFormat::F64 => KernelStreaming::WAVE_FORMAT_EXTENSIBLE, + SampleFormat::I24 | SampleFormat::I32 | SampleFormat::F32 => { + KernelStreaming::WAVE_FORMAT_EXTENSIBLE + } _ => return None, }; @@ -1411,13 +1406,11 @@ fn config_to_waveformatextensible( let channel_mask = channel_mask.unwrap_or(KernelStreaming::KSAUDIO_SPEAKER_DIRECTOUT); let sub_format = match sample_format { - SampleFormat::U8 - | SampleFormat::I16 - | SampleFormat::I24 - | SampleFormat::I32 - | SampleFormat::I64 => KernelStreaming::KSDATAFORMAT_SUBTYPE_PCM, + SampleFormat::U8 | SampleFormat::I16 | SampleFormat::I24 | SampleFormat::I32 => { + KernelStreaming::KSDATAFORMAT_SUBTYPE_PCM + } - SampleFormat::F32 | SampleFormat::F64 => Multimedia::KSDATAFORMAT_SUBTYPE_IEEE_FLOAT, + SampleFormat::F32 => Multimedia::KSDATAFORMAT_SUBTYPE_IEEE_FLOAT, _ => return None, }; From e11f01bff267cbf9e2861d96fdcecdb232b0ce4d Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 20 Sep 2026 20:44:44 +0200 Subject: [PATCH 4/5] refactor(wasapi): extract packet_data and clone the capture client once --- src/host/wasapi/stream.rs | 67 ++++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index 54f16ccef..7822c02de 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -693,6 +693,7 @@ fn run_input( } let stream = &run_ctxt.stream; + let scratch_len = if stream.sample_format == SampleFormat::I24 { stream.max_frames_in_buffer as usize * stream.bytes_per_frame as usize / size_of::() } else { @@ -701,19 +702,20 @@ fn run_input( }; let mut scratch_buffer = vec![0; scratch_len].into_boxed_slice(); + let capture_client = match stream.client_flow { + AudioClientFlow::Capture { ref capture_client } => capture_client.clone(), + _ => unreachable!(), + }; + loop { match process_commands_and_await_signal(&mut run_ctxt, error_callback) { ControlFlow::Break(()) => break, ControlFlow::Continue(false) => continue, ControlFlow::Continue(true) => {} } - let capture_client = match run_ctxt.stream.client_flow { - AudioClientFlow::Capture { ref capture_client } => capture_client.clone(), - _ => unreachable!(), - }; if let Err(err) = process_input( &run_ctxt.stream, - capture_client, + &capture_client, data_callback, &mut scratch_buffer, ) { @@ -874,10 +876,12 @@ fn process_commands_and_await_signal( // The loop for processing pending input data. fn process_input( stream: &StreamInner, - capture_client: Audio::IAudioCaptureClient, + capture_client: &Audio::IAudioCaptureClient, data_callback: &mut dyn FnMut(&Data, &CallbackInfo), scratch_buffer: &mut [i32], ) -> Result<(), Error> { + let sample_size = stream.sample_format.sample_size(); + unsafe { // Get the available data in the shared buffer. let mut buffer: *mut u8 = ptr::null_mut(); @@ -913,26 +917,8 @@ fn process_input( debug_assert!(!buffer.is_null()); let byte_count = frames_available as usize * stream.bytes_per_frame as usize; - let data = if stream.sample_format == SampleFormat::I24 { - // WASAPI stores i24 in the upper bits - let source_data = - slice::from_raw_parts(buffer.cast(), byte_count / size_of::()); - // use a scratch buffer since the capture buffer isn't meant to be written - let dst = &mut scratch_buffer[..source_data.len()]; - dst.copy_from_slice(source_data); - for sample in dst.iter_mut() { - // On signed integers, >> is an arithmetic shift, - // which ensures the correct upper bits get shifted in - *sample >>= 8; - } - - dst.as_mut_ptr().cast() - } else { - buffer.cast() - }; - - let len = byte_count / stream.sample_format.sample_size(); - let data = Data::from_parts(data, len, stream.sample_format); + let data = packet_data(buffer, byte_count, stream.sample_format, scratch_buffer); + let data = Data::from_parts(data, byte_count / sample_size, stream.sample_format); if !stream.skip_callback.load(Ordering::Relaxed) { // The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds. @@ -948,6 +934,35 @@ fn process_input( } } +// Returns the packet data to hand to the callback: converted samples for I24, or the packet itself. +// +// Safety: `packet` must point to `byte_count` bytes that stay valid until the packet is released. +#[inline] +unsafe fn packet_data( + packet: *mut u8, + byte_count: usize, + sample_format: SampleFormat, + scratch_buffer: &mut [i32], +) -> *mut () { + unsafe { + if sample_format == SampleFormat::I24 { + // WASAPI stores i24 in the upper bits + let source_data = slice::from_raw_parts(packet.cast(), byte_count / size_of::()); + // use a scratch buffer since the capture buffer isn't meant to be written + let dst = &mut scratch_buffer[..source_data.len()]; + dst.copy_from_slice(source_data); + for sample in dst.iter_mut() { + // On signed integers, >> is an arithmetic shift, + // which ensures the correct upper bits get shifted in + *sample >>= 8; + } + dst.as_mut_ptr().cast() + } else { + packet.cast() + } + } +} + // The loop for writing output data. fn process_output( stream: &StreamInner, From c14f262446e74013eced53d47624347bc54c882a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 20 Sep 2026 20:44:44 +0200 Subject: [PATCH 5/5] fix(wasapi): deliver silence for input packets flagged silent --- CHANGELOG.md | 1 + src/host/wasapi/stream.rs | 33 +++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73293b882..35a008984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WASAPI**: A stream paused immediately after starting no longer plays silence before real audio on resume. - **WASAPI**: Fix `I64` and `F64` incorrectly reported as supported output formats. - **WASAPI**: Reduced risk of `stop()` draining short when it races a callback that just wrote more audio. +- **WASAPI**: Input streams now deliver silence for packets the driver flags as silent instead of undefined data. ## [0.18.2] - 2026-08-16 diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index 7822c02de..6db898f3d 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -694,13 +694,11 @@ fn run_input( let stream = &run_ctxt.stream; - let scratch_len = if stream.sample_format == SampleFormat::I24 { - stream.max_frames_in_buffer as usize * stream.bytes_per_frame as usize / size_of::() - } else { - // The scratch buffer won't be used in this case. - 0 // Vec::with_capacity(0) does not allocate. - }; - let mut scratch_buffer = vec![0; scratch_len].into_boxed_slice(); + // Create a scratch buffer for holding converted I24 data and silence for + // packets flagged `AUDCLNT_BUFFERFLAGS_SILENT`. + let scratch_len = (stream.max_frames_in_buffer as usize * stream.bytes_per_frame as usize) + .div_ceil(size_of::()); + let mut scratch_buffer = vec![0i32; scratch_len].into_boxed_slice(); let capture_client = match stream.client_flow { AudioClientFlow::Capture { ref capture_client } => capture_client.clone(), @@ -917,7 +915,14 @@ fn process_input( debug_assert!(!buffer.is_null()); let byte_count = frames_available as usize * stream.bytes_per_frame as usize; - let data = packet_data(buffer, byte_count, stream.sample_format, scratch_buffer); + let silent = flags & Audio::AUDCLNT_BUFFERFLAGS_SILENT.0 as u32 != 0; + let data = packet_data( + buffer, + byte_count, + silent, + stream.sample_format, + scratch_buffer, + ); let data = Data::from_parts(data, byte_count / sample_size, stream.sample_format); if !stream.skip_callback.load(Ordering::Relaxed) { @@ -934,18 +939,26 @@ fn process_input( } } -// Returns the packet data to hand to the callback: converted samples for I24, or the packet itself. +// Returns the packet data to hand to the callback: silence for a packet flagged silent, converted +// samples for I24, or the packet itself. // // Safety: `packet` must point to `byte_count` bytes that stay valid until the packet is released. #[inline] unsafe fn packet_data( packet: *mut u8, byte_count: usize, + silent: bool, sample_format: SampleFormat, scratch_buffer: &mut [i32], ) -> *mut () { unsafe { - if sample_format == SampleFormat::I24 { + if silent { + // The packet's data values must be ignored, so hand out silence instead. + let words = &mut scratch_buffer[..byte_count.div_ceil(size_of::())]; + let dst = slice::from_raw_parts_mut(words.as_mut_ptr().cast::(), byte_count); + fill_equilibrium(dst, sample_format); + dst.as_mut_ptr().cast() + } else if sample_format == SampleFormat::I24 { // WASAPI stores i24 in the upper bits let source_data = slice::from_raw_parts(packet.cast(), byte_count / size_of::()); // use a scratch buffer since the capture buffer isn't meant to be written