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;