From 399fda813926e1d78db3f23bf36f5530d3020e12 Mon Sep 17 00:00:00 2001 From: Erwan Legrand Date: Tue, 11 Aug 2026 19:05:35 +0200 Subject: [PATCH] fix(wasapi): left-justify samples in a container wider than they are A WAVEFORMATEXTENSIBLE describes a sample with two numbers: wBitsPerSample, the container, and wValidBitsPerSample, how much of it the sample fills. When they differ the valid bits sit at the *top* of the container -- "the valid bits (the actual PCM data) are left-aligned within the container", as ksmedia.h's WAVEFORMATEXTENSIBLE reference puts it. CPAL's I24 is the other way round: a dasp_sample::I24 is an i32 holding -(1 << 23)..=(1 << 23) - 1, at the bottom of its four-byte container. So the backend handed a 24-in-32 device every sample 2^8 too small and read every sample back 2^8 too large. It reaches both share modes: supported_formats reports I24 as supported, and the two fields go to the engine's converter in shared mode and to the driver in exclusive mode alike. Measured on a PreSonus AudioBox 22VSL (USB, Windows 11) at 24-in-32 / 48 kHz stereo, in both share modes and both directions, converting I24 to F32 on input and F32 to I24 on output. Output was 48 dB too quiet -- clean, just far below level -- and input pinned to full scale by anything above near-silence, moving only when the source was nearly silent. Instrumented on the capture side, a shared-mode I24 stream returned samples 2^8 too large, within 0.6 dB of an I32 reference on the same scale. Render shifts up in place, between the data callback and ReleaseBuffer: that buffer is the backend's to write until it is handed back. Capture cannot do the same, since GetBuffer lends WASAPI's own packet and lends it to be read, so the samples are shifted down into a staging buffer on their way to the callback -- sized once at stream build, so the callback still allocates nothing. It is a Vec rather than a Vec because that buffer reaches the callback as a Data, whose as_slice casts to the sample type, and a Vec guarantees no alignment. The shift is wBitsPerSample - wValidBitsPerSample read off the format handed to Initialize, not a test for I24, so every format whose container is exactly full -- I16, I32, F32 -- lands on zero by the same arithmetic and keeps its existing zero-copy path byte for byte. A container that is padded but not four bytes wide is refused rather than passed through unshifted, which would be silently wrong by the width of the padding. The integer half is a platform-neutral module so it can be tested off Windows: the round trip, the -2^23 and 2^23 - 1 boundaries, the sign carried down, padding bits discarded, the shift actually coming from the format, a trailing partial container left alone, and 16- and 32-bit formats passed through untouched. --- CHANGELOG.md | 4 + src/host/container_align.rs | 255 ++++++++++++++++++++++++++++++++++++ src/host/mod.rs | 7 + src/host/wasapi/device.rs | 116 +++++++++++++--- src/host/wasapi/stream.rs | 77 +++++++++-- 5 files changed, 429 insertions(+), 30 deletions(-) create mode 100644 src/host/container_align.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bb840e059..2af9f61fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WASAPI**: Reported buffer sizes are no longer off by one frame. - **WASAPI**: `Stream::drop`, `play`, and `pause` no longer panic when the device is lost. - **WASAPI**: Output streams no longer reject formats that the built-in resampler can convert. +- **WASAPI**: 24-bit streams are no longer 48 dB out. A container wider than the sample it carries + holds that sample left-justified, so `SampleFormat::I24` samples are now shifted up on their way + to the device and back down on their way in. Formats whose container is exactly full — `I16`, + `I32`, `F32` — are untouched. - **WebAudio**: Fix stale audio output when a data callback wrote a partial buffer. - **WebAudio**: Fix unsound `Send + Sync` on `Stream` when compiled with `+atomics`. - **WebAudio**: Fix `Host::is_available()` always returning `true`, even in non-window contexts. diff --git a/src/host/container_align.rs b/src/host/container_align.rs new file mode 100644 index 000000000..9763497b8 --- /dev/null +++ b/src/host/container_align.rs @@ -0,0 +1,255 @@ +//! Moving samples between CPAL's right-aligned sample types and a wider, left-justified container. +//! +//! A `WAVEFORMATEXTENSIBLE` describes a sample with two numbers: `wBitsPerSample`, the size of the +//! container, and `wValidBitsPerSample`, how much of that container the sample actually occupies. +//! When the two differ, the ksmedia.h `WAVEFORMATEXTENSIBLE` reference is normative: "If +//! wValidBitsPerSample is less than Format.wBitsPerSample, the valid bits (the actual PCM data) +//! are left-aligned within the container. The unused bits in the least-significant portion of the +//! container should be set to zero." +//! +//! CPAL's sample types are the other way round. [`SampleFormat::I24`] is a `dasp_sample::I24`, +//! which is an `i32` restricted to `-(1 << 23)..=(1 << 23) - 1`: the sample sits at the *bottom* +//! of its four-byte container. Handing those bytes straight to a device that asked for 24-in-32 +//! makes every sample 2^8 too small, and reading that device's bytes as if they were already +//! right-aligned makes every sample 2^8 too large — measured on a PreSonus AudioBox 22VSL at +//! 24-in-32 / 48 kHz as output 48 dB too quiet and input pinned to full scale. +//! +//! So the conversion happens at the edge of the backend — up on the way out to the device, down +//! on the way in from it. Formats whose container is exactly full (`I16`, `I32`, `F32`, …) get a +//! shift of zero from [`padding_bits`] and are not touched at all. +//! +//! A padded container of a width these functions cannot walk gets `None` rather than a shift, so +//! the caller has to refuse the format instead of quietly sending it out misaligned. +//! +//! [`SampleFormat::I24`]: crate::SampleFormat::I24 + +/// The container width these functions know how to walk, in bytes. +/// +/// The only padded container CPAL negotiates is `SampleFormat::I24` — 24 valid bits in four +/// bytes — so this is the one width worth handling; [`padding_bits`] refuses every other padded +/// width rather than guess at it. +const CONTAINER_BYTES: usize = 4; + +/// How far a sample must move up to sit left-justified in its container, given the container size +/// and the valid-bit count of the negotiated format, both in bits. +/// +/// `Some(0)` means "these bytes are already what the device wants": the container is exactly full, +/// or the format declares no valid-bit count. `None` means the container is padded but not one +/// [`left_justify`] and [`right_align_into`] can walk, which is a format to refuse — passing it +/// through unshifted would be silently wrong by the width of the padding. +pub(crate) fn padding_bits(container_bits: u16, valid_bits: u16) -> Option { + if valid_bits == 0 || valid_bits >= container_bits { + return Some(0); + } + if container_bits as usize != CONTAINER_BYTES * 8 { + return None; + } + Some(u32::from(container_bits - valid_bits)) +} + +/// Moves every container in `buffer` up by `shift` bits, in place: right-aligned → left-justified. +/// +/// A trailing partial container, which a correctly sized audio buffer does not have, is left +/// alone. +pub(crate) fn left_justify(buffer: &mut [u8], shift: u32) { + if shift == 0 { + return; + } + debug_assert!(shift < (CONTAINER_BYTES * 8) as u32); + for container in buffer.chunks_exact_mut(CONTAINER_BYTES) { + let mut bytes = [0u8; CONTAINER_BYTES]; + bytes.copy_from_slice(container); + // Shifted as unsigned: the bit pattern is the same either way, and the largest negative + // sample lands exactly on `i32::MIN`, which is a legal container and not an overflow. + let justified = u32::from_ne_bytes(bytes) << shift; + container.copy_from_slice(&justified.to_ne_bytes()); + } +} + +/// Copies `src` into `dst`, moving every container down by `shift` bits on the way: +/// left-justified → right-aligned. +/// +/// A copy rather than an in-place shift because the source is the buffer WASAPI lends the +/// backend for the duration of a callback, which is not the backend's to write to. +pub(crate) fn right_align_into(src: &[u8], dst: &mut [i32], shift: u32) { + debug_assert!(shift < (CONTAINER_BYTES * 8) as u32); + debug_assert_eq!(src.len(), dst.len() * CONTAINER_BYTES); + for (sample, container) in dst.iter_mut().zip(src.chunks_exact(CONTAINER_BYTES)) { + let mut bytes = [0u8; CONTAINER_BYTES]; + bytes.copy_from_slice(container); + // Arithmetic shift: the sign has to follow the sample down the container, or every + // negative sample arrives as a large positive one. The padding bits shifted off the + // bottom are exactly the ones the format declares meaningless. + *sample = i32::from_ne_bytes(bytes) >> shift; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The smallest and largest samples `SampleFormat::I24` can hold. + const I24_MIN: i32 = -(1 << 23); + const I24_MAX: i32 = (1 << 23) - 1; + + fn to_bytes(samples: &[i32]) -> Vec { + samples.iter().flat_map(|s| s.to_ne_bytes()).collect() + } + + fn to_samples(bytes: &[u8]) -> Vec { + let mut samples = vec![0i32; bytes.len() / CONTAINER_BYTES]; + right_align_into(bytes, &mut samples, 0); + samples + } + + #[test] + fn padding_bits_is_the_gap_between_the_container_and_the_sample() { + // 24-in-32, the one case CPAL actually negotiates. + assert_eq!(padding_bits(32, 24), Some(8)); + // Any other partly-used 32-bit container follows the same arithmetic. + assert_eq!(padding_bits(32, 20), Some(12)); + } + + #[test] + fn a_full_container_needs_no_shift() { + // Answered before the container width is looked at, so every width reaches this. + assert_eq!(padding_bits(8, 8), Some(0)); + assert_eq!(padding_bits(16, 16), Some(0)); + // Packed 24-bit: three-byte container, nothing spare in it. + assert_eq!(padding_bits(24, 24), Some(0)); + assert_eq!(padding_bits(32, 32), Some(0)); + assert_eq!(padding_bits(64, 64), Some(0)); + // Nonsense a format could still contain; neither of them declares padding. + assert_eq!(padding_bits(32, 0), Some(0)); + assert_eq!(padding_bits(32, 33), Some(0)); + } + + #[test] + fn a_padded_container_that_cannot_be_walked_is_refused() { + // Spare bits, but no walk behind the width: answering zero here would put the samples out + // by the width of the padding with nothing reporting it. + assert_eq!(padding_bits(16, 12), None); + assert_eq!(padding_bits(24, 20), None); + assert_eq!(padding_bits(64, 48), None); + } + + #[test] + fn a_sample_survives_the_round_trip_through_a_wider_container() { + let shift = padding_bits(32, 24).unwrap(); + let samples = [I24_MIN, -8_000_000, -12_345, -1, 0, 1, 12_345, I24_MAX]; + + let mut buffer = to_bytes(&samples); + left_justify(&mut buffer, shift); + + let mut read_back = vec![0i32; samples.len()]; + right_align_into(&buffer, &mut read_back, shift); + + assert_eq!(read_back, samples); + } + + #[test] + fn left_justify_puts_the_sample_at_the_top_of_the_container() { + let shift = padding_bits(32, 24).unwrap(); + let mut buffer = to_bytes(&[0, 1, -1, I24_MAX, I24_MIN]); + left_justify(&mut buffer, shift); + + assert_eq!( + to_samples(&buffer), + [ + 0, + 0x0000_0100, + 0xFFFF_FF00_u32 as i32, + 0x7FFF_FF00, + // The largest negative sample fills the container exactly. + i32::MIN, + ] + ); + } + + #[test] + fn right_align_carries_the_sign_down_and_drops_the_padding() { + let shift = padding_bits(32, 24).unwrap(); + // What a device hands over: samples at the top of the container. The last one has dirty + // padding bits, which the format declares meaningless and this must discard. + let from_device = to_bytes(&[ + 0, + 0x0000_0100, + 0xFFFF_FF00_u32 as i32, + 0x7FFF_FF00, + i32::MIN, + 0x0000_01FF, + ]); + + let mut samples = vec![0i32; from_device.len() / CONTAINER_BYTES]; + right_align_into(&from_device, &mut samples, shift); + + assert_eq!(samples, [0, 1, -1, I24_MAX, I24_MIN, 1]); + // Every sample is back inside the range `dasp_sample::I24` guarantees. + assert!(samples.iter().all(|s| (I24_MIN..=I24_MAX).contains(s))); + } + + #[test] + fn the_shift_is_the_one_the_format_asks_for() { + // 20-in-32 rather than 24-in-32: twelve spare bits, not eight. + let shift = padding_bits(32, 20).unwrap(); + let samples = [0, 1, -1, -(1 << 19), (1 << 19) - 1]; + + let mut buffer = to_bytes(&samples); + left_justify(&mut buffer, shift); + assert_eq!( + to_samples(&buffer), + [ + 0, + 0x0000_1000, + 0xFFFF_F000_u32 as i32, + i32::MIN, + 0x7FFF_F000, + ] + ); + + let mut read_back = vec![0i32; samples.len()]; + right_align_into(&buffer, &mut read_back, shift); + assert_eq!(read_back, samples); + } + + #[test] + fn a_trailing_partial_container_is_left_alone() { + let shift = padding_bits(32, 24).unwrap(); + let mut buffer = to_bytes(&[1, 2]); + buffer.extend_from_slice(&[0xAB, 0xCD]); + + left_justify(&mut buffer, shift); + + assert_eq!(to_samples(&buffer[..2 * CONTAINER_BYTES]), [0x100, 0x200]); + assert_eq!(&buffer[2 * CONTAINER_BYTES..], &[0xAB, 0xCD]); + } + + #[test] + fn a_sixteen_bit_format_is_passed_through_untouched() { + let shift = padding_bits(16, 16).unwrap(); + let samples: Vec = [0i16, 1, -1, i16::MIN, i16::MAX, 12_345] + .iter() + .flat_map(|s| s.to_ne_bytes()) + .collect(); + + let mut buffer = samples.clone(); + left_justify(&mut buffer, shift); + + assert_eq!(buffer, samples); + } + + #[test] + fn a_thirty_two_bit_format_is_passed_through_untouched() { + let shift = padding_bits(32, 32).unwrap(); + let samples = to_bytes(&[0, 1, -1, i32::MIN, i32::MAX, 12_345]); + + let mut buffer = samples.clone(); + left_justify(&mut buffer, shift); + assert_eq!(buffer, samples); + + // And the same on the way in: with no shift the copy is just a copy. + let mut read_back = vec![0i32; samples.len() / CONTAINER_BYTES]; + right_align_into(&samples, &mut read_back, shift); + assert_eq!(to_bytes(&read_back), samples); + } +} diff --git a/src/host/mod.rs b/src/host/mod.rs index effd77eba..0d60fd99e 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -9,6 +9,13 @@ ))] pub(crate) mod equilibrium; +/// Samples carried in a container wider than they are. +/// +/// Only WASAPI negotiates one today, but the arithmetic is plain integer work with no platform +/// types in it, so it is compiled — and unit-tested — everywhere rather than only on Windows. +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) mod container_align; + #[cfg(windows)] pub(crate) mod com; diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index f5208d793..6d7bf41cf 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -11,7 +11,7 @@ use std::{ use crate::{ error::ResultExt, - host::{com::ComString, ErrorCallbackArc}, + host::{com::ComString, container_align, ErrorCallbackArc}, BufferSize, Data, DeviceDescription, DeviceDescriptionBuilder, DeviceDirection, DeviceId, DeviceType, Error, ErrorKind, FrameCount, InputCallbackInfo, InterfaceType, OutputCallbackInfo, SampleFormat, SampleRate, StreamConfig, SupportedBufferSize, SupportedStreamConfig, @@ -700,7 +700,7 @@ impl Device { }; for sample_format in WAVEFORMATEXTENSIBLE_SAMPLE_FORMATS { - if let Some(waveformat) = config_to_waveformatextensible( + if let Some((waveformat, _)) = config_to_waveformatextensible( StreamConfig { channels: format.channels, sample_rate, @@ -856,14 +856,14 @@ impl Device { } // Computing the format and initializing the device. + let (format_attempt, container_shift) = + config_to_waveformatextensible(config, sample_format).ok_or_else(|| { + Error::with_message( + ErrorKind::UnsupportedConfig, + "Stream configuration could not be converted to a compatible format", + ) + })?; let waveformatex = { - let format_attempt = config_to_waveformatextensible(config, sample_format) - .ok_or_else(|| { - Error::with_message( - ErrorKind::UnsupportedConfig, - "Stream configuration could not be converted to a compatible format", - ) - })?; let share_mode = Audio::AUDCLNT_SHAREMODE_SHARED; // Finally, initializing the audio client @@ -918,6 +918,20 @@ impl Device { Duration::from_nanos(hns.max(0) as u64 * 100) }; + // WASAPI lends the capture buffer to be read, so samples arriving left-justified are + // shifted down into a staging buffer instead of in place. Sized once here, so the + // callback allocates nothing, and left empty for formats needing no shift. + // + // `i32` rather than `u8` because it reaches the callback as a `Data`, whose + // `as_slice` casts to the sample type, and a `Vec` guarantees no alignment. + let capture_scratch = if container_shift == 0 { + Vec::new() + } else { + let containers = max_frames_in_buffer as usize * waveformatex.nBlockAlign as usize + / mem::size_of::(); + vec![0i32; containers] + }; + Ok(StreamInner { audio_client, audio_clock, @@ -930,6 +944,8 @@ impl Device { config, sample_format, stream_latency, + container_shift, + capture_scratch, }) } } @@ -957,14 +973,14 @@ impl Device { let buffer_duration = buffer_size_to_duration(&config.buffer_size, config.sample_rate); // Computing the format and initializing the device. + let (format_attempt, container_shift) = + config_to_waveformatextensible(config, sample_format).ok_or_else(|| { + Error::with_message( + ErrorKind::UnsupportedConfig, + "Stream configuration could not be converted to a compatible format", + ) + })?; let waveformatex = { - let format_attempt = config_to_waveformatextensible(config, sample_format) - .ok_or_else(|| { - Error::with_message( - ErrorKind::UnsupportedConfig, - "Stream configuration could not be converted to a compatible format", - ) - })?; let share_mode = Audio::AUDCLNT_SHAREMODE_SHARED; // Finally, initializing the audio client @@ -1033,6 +1049,10 @@ impl Device { config, sample_format, stream_latency, + container_shift, + // Render writes into WASAPI's own buffer, which is the backend's to modify until + // `ReleaseBuffer`, so the shift happens where the samples already are. + capture_scratch: Vec::new(), }) } } @@ -1341,13 +1361,15 @@ const WAVEFORMATEXTENSIBLE_SAMPLE_FORMATS: [SampleFormat; 7] = [ SampleFormat::F64, ]; -// Turns a `Format` into a `WAVEFORMATEXTENSIBLE`. +// Turns a `Format` into a `WAVEFORMATEXTENSIBLE`, paired with the shift its samples need to sit +// left-justified in the container it declares. // -// Returns `None` if the WAVEFORMATEXTENSIBLE does not support the given format. +// Returns `None` if the WAVEFORMATEXTENSIBLE does not support the given format, or if the +// container it would ask for is padded in a way the backend cannot align. fn config_to_waveformatextensible( config: StreamConfig, sample_format: SampleFormat, -) -> Option { +) -> Option<(Audio::WAVEFORMATEXTENSIBLE, u32)> { let format_tag = match sample_format { SampleFormat::U8 | SampleFormat::I16 => Audio::WAVE_FORMAT_PCM, @@ -1410,7 +1432,27 @@ fn config_to_waveformatextensible( SubFormat: sub_format, }; - Some(waveformatextensible) + let shift = container_shift(&waveformatextensible)?; + + Some((waveformatextensible, shift)) +} + +/// How far the negotiated format's samples must move up to sit left-justified in their container, +/// or `None` for a padded container the backend cannot align. +/// +/// Read off the `WAVEFORMATEXTENSIBLE` handed to `Initialize` rather than off the `SampleFormat`, +/// so the answer comes from the format's own two bit counts and no format has to be named here. +fn container_shift(format: &Audio::WAVEFORMATEXTENSIBLE) -> Option { + // A plain `WAVE_FORMAT_PCM` header carries no extension for the device to read, so its + // `wValidBitsPerSample` means nothing and the container is full by definition. + if format.Format.cbSize == 0 { + return Some(0); + } + // SAFETY: `Samples` is a union of three `u16`s. `wValidBitsPerSample` is the member + // `config_to_waveformatextensible` writes, and the one `WAVE_FORMAT_EXTENSIBLE` defines for + // the PCM and IEEE-float subformats this backend emits. + let valid_bits = unsafe { format.Samples.wValidBitsPerSample }; + container_align::padding_bits(format.Format.wBitsPerSample, valid_bits) } /// Get the default device period in frames for a shared-mode stream. @@ -1444,3 +1486,37 @@ fn buffer_size_to_duration(buffer_size: &BufferSize, sample_rate: SampleRate) -> fn buffer_duration_to_frames(buffer_duration: i64, sample_rate: SampleRate) -> FrameCount { ((buffer_duration * sample_rate as i64 * 100 + 500_000_000) / 1_000_000_000) as FrameCount } + +#[cfg(test)] +mod tests { + use super::*; + + fn container_shift_for(sample_format: SampleFormat) -> u32 { + let config = StreamConfig { + channels: 2, + sample_rate: 48_000, + buffer_size: BufferSize::Default, + }; + config_to_waveformatextensible(config, sample_format) + .expect("a format the backend encodes") + .1 + } + + #[test] + fn only_a_padded_container_is_shifted() { + // I24 is the one format CPAL carries in a container wider than the sample: 24 valid bits + // in four bytes. + assert_eq!(container_shift_for(SampleFormat::I24), 8); + + for sample_format in [ + SampleFormat::U8, + SampleFormat::I16, + SampleFormat::I32, + SampleFormat::I64, + SampleFormat::F32, + SampleFormat::F64, + ] { + assert_eq!(container_shift_for(sample_format), 0, "{sample_format}"); + } + } +} diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index 74054750d..5b0a603c1 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -19,7 +19,8 @@ use windows::Win32::{ use crate::{ host::{ - emit_error, equilibrium::fill_equilibrium, latch::Latch, try_emit_error, ErrorCallbackArc, + container_align, emit_error, equilibrium::fill_equilibrium, latch::Latch, try_emit_error, + ErrorCallbackArc, }, traits::StreamTrait, Data, Error, ErrorKind, FrameCount, InputCallbackInfo, InputStreamTimestamp, @@ -308,6 +309,13 @@ pub struct StreamInner { pub sample_format: SampleFormat, // Hardware pipeline latency. pub stream_latency: Duration, + // Bits the samples must move up to sit left-justified in the negotiated container, as the + // device reads them. Zero for every format whose container is exactly full. + pub container_shift: u32, + // Capture only, and only when `container_shift` is non-zero: the staging buffer the samples + // are shifted into on their way from WASAPI's buffer to the data callback. Allocated at + // stream build; empty otherwise. + pub capture_scratch: Vec, } impl Stream { @@ -632,7 +640,7 @@ fn run_input( _ => unreachable!(), }; if let Err(err) = process_input( - &run_ctxt.stream, + &mut run_ctxt.stream, capture_client, data_callback, error_callback, @@ -773,7 +781,7 @@ fn process_commands_and_await_signal( // The loop for processing pending input data. fn process_input( - stream: &StreamInner, + stream: &mut StreamInner, capture_client: Audio::IAudioCaptureClient, data_callback: &mut dyn FnMut(&Data, &InputCallbackInfo), error_callback: &ErrorCallbackArc, @@ -816,14 +824,47 @@ fn process_input( debug_assert!(!buffer.is_null()); - let data = buffer as *mut (); - let len = frames_available as usize * stream.bytes_per_frame as usize - / stream.sample_format.sample_size(); - let data = Data::from_parts(data, len, stream.sample_format); + let byte_count = frames_available as usize * stream.bytes_per_frame as usize; + let len = byte_count / stream.sample_format.sample_size(); + // `GetBuffer` opened a transaction only `ReleaseBuffer` closes, so everything from + // here to the callback hands the packet back before it leaves with an error. + // // The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds. - let timestamp = input_timestamp(stream, qpc_position)?; + let timestamp = match input_timestamp(stream, qpc_position) { + Ok(timestamp) => timestamp, + Err(err) => { + let _ = capture_client.ReleaseBuffer(frames_available); + return Err(err); + } + }; let info = InputCallbackInfo { timestamp }; + + // WASAPI lends this buffer until `ReleaseBuffer` and lends it to be read: a stream + // whose samples arrive left-justified in a wider container is staged through + // `capture_scratch` on the way to the callback rather than shifted where it lies. + // Nothing here allocates — the staging buffer was sized at stream build. + let data = if stream.container_shift == 0 { + buffer as *mut () + } else { + // Only a four-byte container is ever shifted, so one container is one sample. + debug_assert_eq!(stream.sample_format.sample_size(), mem::size_of::()); + if len > stream.capture_scratch.len() { + let _ = capture_client.ReleaseBuffer(frames_available); + return Err(Error::with_message( + ErrorKind::BackendError, + "Capture packet is larger than the endpoint buffer holding it", + )); + } + let shift = stream.container_shift; + let scratch = &mut stream.capture_scratch[..len]; + // SAFETY: `buffer` is WASAPI's packet, valid for `byte_count` bytes until the + // `ReleaseBuffer` below, and a separate allocation from the staging buffer. + let packet = std::slice::from_raw_parts(buffer, byte_count); + container_align::right_align_into(packet, scratch, shift); + scratch.as_mut_ptr() as *mut () + }; + let data = Data::from_parts(data, len, stream.sample_format); data_callback(&data, &info); // Release the buffer. @@ -854,8 +895,13 @@ fn process_output( debug_assert!(!buffer.is_null()); let byte_count = frames_available 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); + // SAFETY: `buffer` is WASAPI's render buffer, valid for `byte_count` bytes until the + // `ReleaseBuffer` below. Not bound to a name, so it does not overlap the slice taken + // after the callback. + fill_equilibrium( + std::slice::from_raw_parts_mut(buffer, byte_count), + stream.sample_format, + ); let data = buffer as *mut (); let len = byte_count / stream.sample_format.sample_size(); @@ -865,6 +911,17 @@ fn process_output( let info = OutputCallbackInfo { timestamp }; data_callback(&mut data, &info); + // The callback wrote CPAL's right-aligned samples; the device reads the container as + // left-justified. Move them up here: after the callback, because it is the callback's + // output that has to be justified, and before `ReleaseBuffer`, which is where WASAPI + // takes the bytes. + if stream.container_shift != 0 { + // SAFETY: `buffer` is WASAPI's render buffer, valid for `byte_count` bytes until the + // `ReleaseBuffer` below; `data` is not read again. + let buffer_slice = std::slice::from_raw_parts_mut(buffer, byte_count); + container_align::left_justify(buffer_slice, stream.container_shift); + } + render_client.ReleaseBuffer(frames_available, 0)?; *frames_written += frames_available as u64;