From 3e04dc89bb078841c60f32337b15cab0861aaab3 Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Thu, 1 Jan 2026 01:09:41 -0800 Subject: [PATCH 01/16] feat(duplex): add synchronized duplex stream support for CoreAudio Add true duplex audio support with hardware-synchronized input/output using a single HAL AudioUnit with both input and output enabled. API additions: - DuplexStreamConfig: configuration for duplex streams - AudioTimestamp: hardware timing info (sample_time, host_time, rate_scalar) - DuplexCallbackInfo: passed to callbacks with timestamp - DeviceTrait::build_duplex_stream(): typed convenience method - DeviceTrait::build_duplex_stream_raw(): dynamic sample format support - DeviceTrait::supports_duplex(): check device capability Design decisions: - Follows cpal's existing API patterns (typed + raw variants) - Supports all sample formats (f32, i16, i24, etc.) via build_duplex_stream_raw - DuplexStream implements StreamTrait (play/pause) like regular streams - Xrun detection left to application via sample_time discontinuity tracking - UnsupportedDuplexStream placeholder for backends without duplex support --- examples/custom.rs | 16 ++ src/duplex.rs | 433 ++++++++++++++++++++++++++++ src/host/coreaudio/macos/device.rs | 368 +++++++++++++++++++++++- src/host/coreaudio/macos/mod.rs | 437 +++++++++++++++++++++++++++++ src/lib.rs | 1 + src/platform/mod.rs | 93 ++++++ src/traits.rs | 121 ++++++++ 7 files changed, 1467 insertions(+), 2 deletions(-) create mode 100644 src/duplex.rs diff --git a/examples/custom.rs b/examples/custom.rs index 639bc6943..661bb88ec 100644 --- a/examples/custom.rs +++ b/examples/custom.rs @@ -54,6 +54,7 @@ impl DeviceTrait for MyDevice { type SupportedInputConfigs = std::iter::Empty; type SupportedOutputConfigs = std::iter::Once; type Stream = MyStream; + type DuplexStream = cpal::duplex::UnsupportedDuplexStream; fn name(&self) -> Result { Ok(String::from("custom")) @@ -181,6 +182,21 @@ impl DeviceTrait for MyDevice { handle: Some(handle), }) } + + fn build_duplex_stream_raw( + &self, + _config: &cpal::duplex::DuplexStreamConfig, + _sample_format: cpal::SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&cpal::Data, &mut cpal::Data, &cpal::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(cpal::StreamError) + Send + 'static, + { + Err(cpal::BuildStreamError::StreamConfigNotSupported) + } } impl StreamTrait for MyStream { diff --git a/src/duplex.rs b/src/duplex.rs new file mode 100644 index 000000000..d3bc1ef75 --- /dev/null +++ b/src/duplex.rs @@ -0,0 +1,433 @@ +//! Duplex audio stream support with synchronized input/output. +//! +//! This module provides types for building duplex (simultaneous input/output) audio streams +//! with hardware clock synchronization. +//! +//! # Overview +//! +//! Unlike separate input and output streams which may have independent clocks, a duplex stream +//! uses a single device context for both input and output, ensuring they share the same +//! hardware clock. This is essential for applications like: +//! +//! - DAWs (Digital Audio Workstations) +//! - Real-time audio effects processing +//! - Audio measurement and analysis +//! - Any application requiring sample-accurate I/O synchronization +//! +//! # Example +//! +//! ```no_run +//! use cpal::duplex::DuplexStreamConfig; +//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +//! use cpal::BufferSize; +//! +//! let host = cpal::default_host(); +//! let device = host.default_output_device().expect("no device"); +//! +//! let config = DuplexStreamConfig::symmetric(2, 48000, BufferSize::Fixed(512)); +//! +//! let stream = device.build_duplex_stream::( +//! &config, +//! |input, output, info| { +//! // Passthrough: copy input to output +//! output[..input.len()].copy_from_slice(input); +//! }, +//! |err| eprintln!("Stream error: {}", err), +//! None, +//! ).expect("failed to build duplex stream"); +//! ``` + +use crate::{PauseStreamError, PlayStreamError, SampleRate, StreamInstant}; + +/// Hardware timestamp information from the audio device. +/// +/// This provides precise timing information from the audio hardware, essential for +/// sample-accurate synchronization between input and output, and for correlating +/// audio timing with other system events. +/// +/// # Detecting Xruns +/// +/// Applications can detect xruns (buffer underruns/overruns) by tracking the +/// `sample_time` field across callbacks. Under normal operation, `sample_time` +/// advances by exactly the buffer size each callback. A larger jump indicates +/// missed buffers: +/// +/// ```ignore +/// let mut last_sample_time: Option = None; +/// +/// // In your callback: +/// if let Some(last) = last_sample_time { +/// let expected = last + buffer_size as f64; +/// let discontinuity = (info.timestamp.sample_time - expected).abs(); +/// if discontinuity > 1.0 { +/// println!("Xrun detected: {} samples missed", discontinuity); +/// } +/// } +/// last_sample_time = Some(info.timestamp.sample_time); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AudioTimestamp { + /// Hardware sample counter from the device clock. + /// + /// This is the authoritative position from the device's clock and increments + /// by the buffer size each callback. Use this for xrun detection by tracking + /// discontinuities. + /// + /// This is an f64 to allow for sub-sample precision in rate-adjusted scenarios. + /// For most purposes, cast to i64 for an integer value. + pub sample_time: f64, + + /// System host time reference (platform-specific high-resolution timer). + /// + /// Can be used to correlate audio timing with other system events or for + /// debugging latency issues. + pub host_time: u64, + + /// Clock rate scalar (1.0 = nominal rate). + /// + /// Indicates if the hardware clock is running faster or slower than nominal. + /// Useful for applications that need to compensate for clock drift when + /// synchronizing with external sources. + pub rate_scalar: f64, + + /// Callback timestamp from cpal's existing timing system. + /// + /// This provides compatibility with cpal's existing `StreamInstant` timing + /// infrastructure. + pub callback_instant: StreamInstant, +} + +impl AudioTimestamp { + /// Create a new AudioTimestamp. + pub fn new( + sample_time: f64, + host_time: u64, + rate_scalar: f64, + callback_instant: StreamInstant, + ) -> Self { + Self { + sample_time, + host_time, + rate_scalar, + callback_instant, + } + } + + /// Get the sample position as an integer. + /// + /// This rounds the hardware sample time to the nearest integer. The result + /// is suitable for use as a timeline position or for sample-accurate event + /// scheduling. + #[inline] + pub fn sample_position(&self) -> i64 { + self.sample_time.round() as i64 + } + + /// Check if the clock is running at nominal rate. + /// + /// Returns `true` if `rate_scalar` is very close to 1.0 (within 0.0001). + #[inline] + pub fn is_nominal_rate(&self) -> bool { + (self.rate_scalar - 1.0).abs() < 0.0001 + } +} + +impl Default for AudioTimestamp { + fn default() -> Self { + Self { + sample_time: 0.0, + host_time: 0, + rate_scalar: 1.0, + callback_instant: StreamInstant::new(0, 0), + } + } +} + +/// Information passed to duplex callbacks. +/// +/// This contains timing information and metadata about the current audio buffer. +#[derive(Clone, Copy, Debug)] +pub struct DuplexCallbackInfo { + /// Hardware timestamp for this callback. + pub timestamp: AudioTimestamp, +} + +impl DuplexCallbackInfo { + /// Create a new DuplexCallbackInfo. + pub fn new(timestamp: AudioTimestamp) -> Self { + Self { timestamp } + } +} + +/// Configuration for a duplex audio stream. +/// +/// Unlike separate input/output streams, duplex streams require matching +/// configuration for both directions since they share a single device context. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DuplexStreamConfig { + /// Number of input channels. + pub input_channels: u16, + + /// Number of output channels. + pub output_channels: u16, + + /// Sample rate in Hz. + pub sample_rate: SampleRate, + + /// Requested buffer size in frames. + pub buffer_size: crate::BufferSize, +} + +impl DuplexStreamConfig { + /// Create a new duplex stream configuration. + /// + /// # Panics + /// + /// Panics if: + /// - `input_channels` or `output_channels` is zero + /// - `sample_rate` is zero + /// - `buffer_size` is `BufferSize::Fixed(0)` + pub fn new( + input_channels: u16, + output_channels: u16, + sample_rate: SampleRate, + buffer_size: crate::BufferSize, + ) -> Self { + assert!(input_channels > 0, "input_channels must be greater than 0"); + assert!( + output_channels > 0, + "output_channels must be greater than 0" + ); + assert!(sample_rate > 0, "sample_rate must be greater than 0"); + assert!( + !matches!(buffer_size, crate::BufferSize::Fixed(0)), + "buffer_size cannot be Fixed(0)" + ); + + Self { + input_channels, + output_channels, + sample_rate, + buffer_size, + } + } + + /// Create a symmetric configuration (same channel count for input and output). + /// + /// # Panics + /// + /// Panics if `channels` is zero or if `sample_rate` is zero. + pub fn symmetric( + channels: u16, + sample_rate: SampleRate, + buffer_size: crate::BufferSize, + ) -> Self { + Self::new(channels, channels, sample_rate, buffer_size) + } + + /// Convert to a basic StreamConfig using output channel count. + /// + /// Useful for compatibility with existing cpal APIs. + pub fn to_stream_config(&self) -> crate::StreamConfig { + crate::StreamConfig { + channels: self.output_channels, + sample_rate: self.sample_rate, + buffer_size: self.buffer_size, + } + } +} + +/// A placeholder duplex stream type for backends that don't yet support duplex. +/// +/// This type implements `StreamTrait` but all operations return errors. +/// Backend implementations should replace this with their own type once +/// duplex support is implemented. +pub struct UnsupportedDuplexStream { + _private: (), +} + +impl UnsupportedDuplexStream { + /// Create a new unsupported duplex stream marker. + /// + /// This should not normally be called - it exists only to satisfy + /// type requirements for backends without duplex support. + pub fn new() -> Self { + Self { _private: () } + } +} + +impl Default for UnsupportedDuplexStream { + fn default() -> Self { + Self::new() + } +} + +impl crate::traits::StreamTrait for UnsupportedDuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + Err(PlayStreamError::BackendSpecific { + err: crate::BackendSpecificError { + description: "Duplex streams are not yet supported on this backend".to_string(), + }, + }) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + Err(PauseStreamError::BackendSpecific { + err: crate::BackendSpecificError { + description: "Duplex streams are not yet supported on this backend".to_string(), + }, + }) + } +} + +// Safety: UnsupportedDuplexStream contains no mutable state +unsafe impl Send for UnsupportedDuplexStream {} +unsafe impl Sync for UnsupportedDuplexStream {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audio_timestamp_sample_position() { + let ts = AudioTimestamp::new(1234.5, 0, 1.0, StreamInstant::new(0, 0)); + assert_eq!(ts.sample_position(), 1235); // rounds up + + let ts = AudioTimestamp::new(1234.4, 0, 1.0, StreamInstant::new(0, 0)); + assert_eq!(ts.sample_position(), 1234); // rounds down + + let ts = AudioTimestamp::new(-100.0, 0, 1.0, StreamInstant::new(0, 0)); + assert_eq!(ts.sample_position(), -100); // negative values work + } + + #[test] + fn test_audio_timestamp_nominal_rate() { + let ts = AudioTimestamp::new(0.0, 0, 1.0, StreamInstant::new(0, 0)); + assert!(ts.is_nominal_rate()); + + let ts = AudioTimestamp::new(0.0, 0, 1.00005, StreamInstant::new(0, 0)); + assert!(ts.is_nominal_rate()); // within tolerance + + let ts = AudioTimestamp::new(0.0, 0, 1.001, StreamInstant::new(0, 0)); + assert!(!ts.is_nominal_rate()); // outside tolerance + } + + #[test] + fn test_audio_timestamp_default() { + let ts = AudioTimestamp::default(); + assert_eq!(ts.sample_time, 0.0); + assert_eq!(ts.host_time, 0); + assert_eq!(ts.rate_scalar, 1.0); + assert_eq!(ts.sample_position(), 0); + assert!(ts.is_nominal_rate()); + } + + #[test] + fn test_audio_timestamp_equality() { + let ts1 = AudioTimestamp::new(1000.0, 12345, 1.0, StreamInstant::new(0, 0)); + let ts2 = AudioTimestamp::new(1000.0, 12345, 1.0, StreamInstant::new(0, 0)); + let ts3 = AudioTimestamp::new(1000.0, 12346, 1.0, StreamInstant::new(0, 0)); + + assert_eq!(ts1, ts2); + assert_ne!(ts1, ts3); + } + + #[test] + fn test_duplex_callback_info() { + let ts = AudioTimestamp::new(512.0, 1000, 1.0, StreamInstant::new(0, 0)); + let info = DuplexCallbackInfo::new(ts); + assert_eq!(info.timestamp.sample_time, 512.0); + } + + #[test] + fn test_duplex_stream_config() { + let config = DuplexStreamConfig::symmetric(2, 48000, crate::BufferSize::Fixed(512)); + assert_eq!(config.input_channels, 2); + assert_eq!(config.output_channels, 2); + assert_eq!(config.sample_rate, 48000); + + let stream_config = config.to_stream_config(); + assert_eq!(stream_config.channels, 2); + assert_eq!(stream_config.sample_rate, 48000); + } + + #[test] + fn test_duplex_stream_config_asymmetric() { + let config = DuplexStreamConfig::new(1, 8, 96000, crate::BufferSize::Default); + assert_eq!(config.input_channels, 1); + assert_eq!(config.output_channels, 8); + assert_eq!(config.sample_rate, 96000); + } + + #[test] + fn test_duplex_stream_config_to_stream_config() { + let config = DuplexStreamConfig::new(1, 2, 48000, crate::BufferSize::Fixed(256)); + let stream_config = config.to_stream_config(); + + // to_stream_config uses output_channels + assert_eq!(stream_config.channels, 2); + assert_eq!(stream_config.sample_rate, 48000); + assert_eq!(stream_config.buffer_size, crate::BufferSize::Fixed(256)); + } + + #[test] + #[should_panic(expected = "input_channels must be greater than 0")] + fn test_duplex_stream_config_zero_input_channels() { + DuplexStreamConfig::new(0, 2, 48000, crate::BufferSize::Default); + } + + #[test] + #[should_panic(expected = "output_channels must be greater than 0")] + fn test_duplex_stream_config_zero_output_channels() { + DuplexStreamConfig::new(2, 0, 48000, crate::BufferSize::Default); + } + + #[test] + #[should_panic(expected = "sample_rate must be greater than 0")] + fn test_duplex_stream_config_zero_sample_rate() { + DuplexStreamConfig::new(2, 2, 0, crate::BufferSize::Default); + } + + #[test] + #[should_panic(expected = "buffer_size cannot be Fixed(0)")] + fn test_duplex_stream_config_zero_buffer_size() { + DuplexStreamConfig::new(2, 2, 48000, crate::BufferSize::Fixed(0)); + } + + #[test] + fn test_duplex_stream_config_clone_and_eq() { + let config1 = DuplexStreamConfig::new(2, 4, 48000, crate::BufferSize::Fixed(512)); + let config2 = config1.clone(); + + assert_eq!(config1, config2); + + let config3 = DuplexStreamConfig::new(2, 4, 44100, crate::BufferSize::Fixed(512)); + assert_ne!(config1, config3); + } + + #[test] + fn test_unsupported_duplex_stream() { + use crate::traits::StreamTrait; + + let stream = UnsupportedDuplexStream::new(); + + // play() should return an error + let play_result = stream.play(); + assert!(play_result.is_err()); + + // pause() should return an error + let pause_result = stream.pause(); + assert!(pause_result.is_err()); + } + + #[test] + fn test_unsupported_duplex_stream_default() { + let _stream = UnsupportedDuplexStream::default(); + } + + #[test] + fn test_unsupported_duplex_stream_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 1302299f3..37870933a 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -1,6 +1,8 @@ use super::OSStatus; use super::Stream; use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant}; +use super::{DuplexStream, DuplexStreamInner}; +use crate::duplex::{AudioTimestamp, DuplexCallbackInfo}; use crate::host::coreaudio::macos::loopback::LoopbackDevice; use crate::host::coreaudio::macos::StreamInner; use crate::traits::DeviceTrait; @@ -14,7 +16,8 @@ use coreaudio::audio_unit::render_callback::{self, data}; use coreaudio::audio_unit::{AudioUnit, Element, Scope}; use objc2_audio_toolbox::{ kAudioOutputUnitProperty_CurrentDevice, kAudioOutputUnitProperty_EnableIO, - kAudioUnitProperty_StreamFormat, + kAudioUnitProperty_SetRenderCallback, kAudioUnitProperty_StreamFormat, AURenderCallbackStruct, + AudioUnitRender, AudioUnitRenderActionFlags, }; use objc2_core_audio::kAudioDevicePropertyDeviceUID; use objc2_core_audio::kAudioObjectPropertyElementMain; @@ -29,7 +32,7 @@ use objc2_core_audio::{ AudioObjectPropertyScope, AudioObjectSetPropertyData, }; use objc2_core_audio_types::{ - AudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioValueRange, + AudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioTimeStamp, AudioValueRange, }; use objc2_core_foundation::CFString; use objc2_core_foundation::Type; @@ -263,6 +266,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) @@ -335,6 +339,21 @@ impl DeviceTrait for Device { timeout, ) } + + fn build_duplex_stream_raw( + &self, + config: &crate::duplex::DuplexStreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Device::build_duplex_stream_raw(self, config, sample_format, data_callback, error_callback) + } } #[derive(Clone, Eq, Hash, PartialEq)] @@ -716,6 +735,14 @@ impl Device { .map(|mut configs| configs.next().is_some()) .unwrap_or(false) } + + /// Check if this device supports output (playback). + fn supports_output(&self) -> bool { + // Check if the device has output channels by trying to get its output configuration + self.supported_output_configs() + .map(|mut configs| configs.next().is_some()) + .unwrap_or(false) + } } impl fmt::Debug for Device { @@ -945,6 +972,282 @@ impl Device { Ok(stream) } + + /// Build a duplex stream with synchronized input and output. + /// + /// This creates a single HAL AudioUnit with both input and output enabled, + /// ensuring they share the same hardware clock. + fn build_duplex_stream_raw( + &self, + config: &crate::duplex::DuplexStreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + ) -> Result + where + D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + // Validate that device supports duplex + if !self.supports_input() || !self.supports_output() { + return Err(BuildStreamError::StreamConfigNotSupported); + } + + // Potentially change the device sample rate to match the config. + set_sample_rate(self.audio_device_id, config.sample_rate)?; + + // Create HAL AudioUnit - always use HalOutput for duplex + let mut audio_unit = AudioUnit::new(coreaudio::audio_unit::IOType::HalOutput)?; + + // Enable BOTH input and output on the AudioUnit + let enable: u32 = 1; + + // Enable input on Element 1 + audio_unit.set_property( + kAudioOutputUnitProperty_EnableIO, + Scope::Input, + Element::Input, + Some(&enable), + )?; + + // Enable output on Element 0 (usually enabled by default, but be explicit) + audio_unit.set_property( + kAudioOutputUnitProperty_EnableIO, + Scope::Output, + Element::Output, + Some(&enable), + )?; + + // Set device for the unit (applies to both input and output) + audio_unit.set_property( + kAudioOutputUnitProperty_CurrentDevice, + Scope::Global, + Element::Output, + Some(&self.audio_device_id), + )?; + + // Create StreamConfig for input side + let input_stream_config = StreamConfig { + channels: config.input_channels as ChannelCount, + sample_rate: config.sample_rate, + buffer_size: config.buffer_size, + }; + + // Create StreamConfig for output side + let output_stream_config = StreamConfig { + channels: config.output_channels as ChannelCount, + sample_rate: config.sample_rate, + buffer_size: config.buffer_size, + }; + + // Configure input format (Scope::Output on Element::Input) + let input_asbd = asbd_from_config(&input_stream_config, sample_format); + audio_unit.set_property( + kAudioUnitProperty_StreamFormat, + Scope::Output, + Element::Input, + Some(&input_asbd), + )?; + + // Configure output format (Scope::Input on Element::Output) + let output_asbd = asbd_from_config(&output_stream_config, sample_format); + audio_unit.set_property( + kAudioUnitProperty_StreamFormat, + Scope::Input, + Element::Output, + Some(&output_asbd), + )?; + + // Configure buffer size if requested + if let BufferSize::Fixed(buffer_size) = &config.buffer_size { + audio_unit.set_property( + kAudioDevicePropertyBufferFrameSize, + Scope::Global, + Element::Output, + Some(buffer_size), + )?; + } + + // Get actual buffer size for pre-allocating input buffer + let buffer_size: u32 = audio_unit + .get_property( + kAudioDevicePropertyBufferFrameSize, + Scope::Global, + Element::Output, + ) + .unwrap_or(512); + + // Get the raw AudioUnit pointer for use in the callback + let raw_audio_unit = *audio_unit.as_ref(); + + // Configuration for callback + let input_channels = config.input_channels as usize; + let sample_bytes = sample_format.sample_size(); + + // Pre-allocate input buffer for the configured buffer size (in bytes) + let input_buffer_samples = buffer_size as usize * input_channels; + let input_buffer_bytes = input_buffer_samples * sample_bytes; + let mut input_buffer: Vec = vec![0u8; input_buffer_bytes]; + + // Wrap error callback in Arc for sharing between callback and disconnect handler + let error_callback = Arc::new(Mutex::new(error_callback)); + let error_callback_for_callback = error_callback.clone(); + + // Move data callback into closure + let mut data_callback = data_callback; + + // Create the duplex callback closure + // This closure owns all captured state - no Mutex needed for data_callback or input_buffer + let duplex_proc: Box = Box::new( + move |io_action_flags: NonNull, + in_time_stamp: NonNull, + _in_bus_number: u32, + in_number_frames: u32, + io_data: *mut AudioBufferList| + -> i32 { + let num_frames = in_number_frames as usize; + let input_samples = num_frames * input_channels; + let input_bytes = input_samples * sample_bytes; + + // SAFETY: in_time_stamp is valid per CoreAudio contract + let timestamp = unsafe { in_time_stamp.as_ref() }; + + // Create StreamInstant for callback_instant + let callback_instant = match host_time_to_stream_instant(timestamp.mHostTime) { + Err(err) => { + invoke_error_callback(&error_callback_for_callback, err.into()); + return 0; + } + Ok(cb) => cb, + }; + + // Create our AudioTimestamp from CoreAudio's + let audio_timestamp = AudioTimestamp::new( + timestamp.mSampleTime, + timestamp.mHostTime, + timestamp.mRateScalar, + callback_instant, + ); + + // Pull input from Element 1 using AudioUnitRender + // We use the pre-allocated input_buffer + unsafe { + // Set up AudioBufferList pointing to our input buffer + let mut input_buffer_list = AudioBufferList { + mNumberBuffers: 1, + mBuffers: [AudioBuffer { + mNumberChannels: input_channels as u32, + mDataByteSize: input_bytes as u32, + mData: input_buffer.as_mut_ptr() as *mut std::ffi::c_void, + }], + }; + + let status = AudioUnitRender( + raw_audio_unit, + io_action_flags.as_ptr(), + in_time_stamp, + 1, // Element 1 = input + in_number_frames, + NonNull::new_unchecked(&mut input_buffer_list), + ); + + if status != 0 { + // Failed to pull input - zero the input buffer so callback gets silence + for byte in input_buffer[..input_bytes].iter_mut() { + *byte = 0; + } + } + } + + // Get output buffer from CoreAudio + if io_data.is_null() { + return 0; + } + + // Create Data wrappers for input and output + let input_data = unsafe { + Data::from_parts( + input_buffer.as_mut_ptr() as *mut (), + input_samples, + sample_format, + ) + }; + + let mut output_data = unsafe { + let buffer_list = &mut *io_data; + if buffer_list.mNumberBuffers == 0 { + return 0; + } + let buffer = &mut buffer_list.mBuffers[0]; + if buffer.mData.is_null() { + return 0; + } + let output_samples = buffer.mDataByteSize as usize / sample_bytes; + Data::from_parts(buffer.mData as *mut (), output_samples, sample_format) + }; + + // Create callback info with timestamp + let callback_info = DuplexCallbackInfo::new(audio_timestamp); + + // Call user callback with input and output Data + data_callback(&input_data, &mut output_data, &callback_info); + + 0 // noErr + }, + ); + + // Box the wrapper and get raw pointer for CoreAudio + let wrapper = Box::new(DuplexProcWrapper { + callback: duplex_proc, + }); + let wrapper_ptr = Box::into_raw(wrapper); + + // Set up the render callback + let render_callback = AURenderCallbackStruct { + inputProc: Some(duplex_input_proc), + inputProcRefCon: wrapper_ptr as *mut std::ffi::c_void, + }; + + audio_unit.set_property( + kAudioUnitProperty_SetRenderCallback, + Scope::Global, + Element::Output, + Some(&render_callback), + )?; + + // Create the stream inner, storing the callback pointer for cleanup + let inner = DuplexStreamInner { + playing: true, + audio_unit, + device_id: self.audio_device_id, + duplex_callback_ptr: wrapper_ptr, + }; + + // Create error callback for stream (wrapper that invokes the shared callback) + let error_callback_for_stream: super::ErrorCallback = { + let error_callback_clone = error_callback.clone(); + Box::new(move |err: StreamError| { + invoke_error_callback(&error_callback_clone, err); + }) + }; + + // Create the duplex stream + let stream = DuplexStream::new(inner, error_callback_for_stream)?; + + // Start the audio unit + stream + .inner + .lock() + .map_err(|_| BuildStreamError::BackendSpecific { + err: BackendSpecificError { + description: "Failed to lock duplex stream".to_string(), + }, + })? + .audio_unit + .start()?; + + Ok(stream) + } } /// Configure stream format and buffer size for CoreAudio stream. @@ -1017,3 +1320,64 @@ fn get_device_buffer_frame_size(audio_unit: &AudioUnit) -> Result, + NonNull, + u32, // bus_number + u32, // num_frames + *mut AudioBufferList, +) -> i32; + +/// Wrapper for the boxed duplex callback closure. +/// +/// This struct is allocated on the heap and its pointer is passed to CoreAudio +/// as the refcon. The extern "C" callback function casts the refcon back to +/// this type and calls the closure. +pub(crate) struct DuplexProcWrapper { + callback: Box, +} + +// SAFETY: DuplexProcWrapper is Send because: +// 1. The boxed closure captures only Send types (the DuplexCallback trait requires Send) +// 2. The raw pointer stored in DuplexStreamInner is only accessed: +// - During Drop, after stopping the audio unit (callback no longer running) +// 3. CoreAudio guarantees single-threaded callback invocation +unsafe impl Send for DuplexProcWrapper {} + +/// CoreAudio render callback for duplex audio. +/// +/// This is a thin wrapper that casts the refcon back to our DuplexProcWrapper +/// and calls the inner closure. The closure owns all the callback state via +/// move semantics, so no Mutex is needed. +/// +/// # Safety +/// This is an unsafe extern "C-unwind" callback called by CoreAudio. The refcon +/// must be a valid pointer to a DuplexProcWrapper. +extern "C-unwind" fn duplex_input_proc( + in_ref_con: NonNull, + io_action_flags: NonNull, + in_time_stamp: NonNull, + in_bus_number: u32, + in_number_frames: u32, + io_data: *mut AudioBufferList, +) -> i32 { + let wrapper = unsafe { in_ref_con.cast::().as_mut() }; + (wrapper.callback)( + io_action_flags, + in_time_stamp, + in_bus_number, + in_number_frames, + io_data, + ) +} diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index a7a025166..4b20ed106 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -263,6 +263,203 @@ impl StreamTrait for Stream { } } +// ============================================================================ +// DuplexStream - Synchronized input/output with shared hardware clock +// ============================================================================ + +/// Internal state for the duplex stream. +pub(crate) struct DuplexStreamInner { + pub(crate) playing: bool, + pub(crate) audio_unit: AudioUnit, + pub(crate) device_id: AudioDeviceID, + /// Pointer to the callback wrapper, needed for cleanup. + /// This is set by build_duplex_stream_raw and freed in Drop. + pub(crate) duplex_callback_ptr: *mut device::DuplexProcWrapper, +} + +// SAFETY: DuplexStreamInner is Send because: +// 1. AudioUnit is Send (coreaudio crate marks it as such) +// 2. AudioDeviceID is Copy +// 3. duplex_callback_ptr points to a Send type (DuplexProcWrapper) +// and is only accessed during Drop after stopping the audio unit +unsafe impl Send for DuplexStreamInner {} + +impl DuplexStreamInner { + fn play(&mut self) -> Result<(), PlayStreamError> { + if !self.playing { + if let Err(e) = self.audio_unit.start() { + let description = format!("{e}"); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + self.playing = true; + } + Ok(()) + } + + fn pause(&mut self) -> Result<(), PauseStreamError> { + if self.playing { + if let Err(e) = self.audio_unit.stop() { + let description = format!("{e}"); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + self.playing = false; + } + Ok(()) + } +} + +impl Drop for DuplexStreamInner { + fn drop(&mut self) { + // Stop the audio unit first to ensure callback is no longer being called + let _ = self.audio_unit.stop(); + + // Now safe to free the callback wrapper + if !self.duplex_callback_ptr.is_null() { + unsafe { + let _ = Box::from_raw(self.duplex_callback_ptr); + } + self.duplex_callback_ptr = std::ptr::null_mut(); + } + + // AudioUnit's own Drop will handle uninitialize and dispose + } +} + +/// Duplex stream disconnect manager - handles device disconnection. +struct DuplexDisconnectManager { + _shutdown_tx: mpsc::Sender<()>, +} + +impl DuplexDisconnectManager { + fn new( + device_id: AudioDeviceID, + stream_weak: Weak>, + error_callback: Arc>, + ) -> Result { + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + let (disconnect_tx, disconnect_rx) = mpsc::channel(); + let (ready_tx, ready_rx) = mpsc::channel(); + + let disconnect_tx_clone = disconnect_tx.clone(); + std::thread::spawn(move || { + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyDeviceIsAlive, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain, + }; + + match AudioObjectPropertyListener::new(device_id, property_address, move || { + let _ = disconnect_tx_clone.send(()); + }) { + Ok(_listener) => { + let _ = ready_tx.send(Ok(())); + let _ = shutdown_rx.recv(); + } + Err(e) => { + let _ = ready_tx.send(Err(e)); + } + } + }); + + ready_rx + .recv() + .map_err(|_| crate::BuildStreamError::BackendSpecific { + err: BackendSpecificError { + description: "Disconnect listener thread terminated unexpectedly".to_string(), + }, + })??; + + // Handle disconnect events + std::thread::spawn(move || { + while disconnect_rx.recv().is_ok() { + if let Some(stream_arc) = stream_weak.upgrade() { + if let Ok(mut stream_inner) = stream_arc.try_lock() { + let _ = stream_inner.pause(); + } + invoke_error_callback(&error_callback, crate::StreamError::DeviceNotAvailable); + } else { + break; + } + } + }); + + Ok(DuplexDisconnectManager { + _shutdown_tx: shutdown_tx, + }) + } +} + +/// A duplex audio stream with synchronized input and output. +/// +/// Uses a single HAL AudioUnit with both input and output enabled, +/// ensuring they share the same hardware clock. +pub struct DuplexStream { + inner: Arc>, + _disconnect_manager: DuplexDisconnectManager, +} + +// Compile-time assertion that DuplexStream is Send and Sync +const _: () = { + const fn assert_send_sync() {} + assert_send_sync::(); +}; + +impl DuplexStream { + /// Create a new duplex stream. + /// + /// This is called by `Device::build_duplex_stream_raw`. + pub(crate) fn new( + inner: DuplexStreamInner, + error_callback: ErrorCallback, + ) -> Result { + let device_id = inner.device_id; + let inner_arc = Arc::new(Mutex::new(inner)); + let weak_inner = Arc::downgrade(&inner_arc); + + let error_callback = Arc::new(Mutex::new(error_callback)); + + let disconnect_manager = + DuplexDisconnectManager::new(device_id, weak_inner, error_callback)?; + + Ok(Self { + inner: inner_arc, + _disconnect_manager: disconnect_manager, + }) + } +} + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + let mut stream = self + .inner + .lock() + .map_err(|_| PlayStreamError::BackendSpecific { + err: BackendSpecificError { + description: "A cpal duplex stream operation panicked while holding the lock" + .to_string(), + }, + })?; + + stream.play() + } + + fn pause(&self) -> Result<(), PauseStreamError> { + let mut stream = self + .inner + .lock() + .map_err(|_| PauseStreamError::BackendSpecific { + err: BackendSpecificError { + description: "A cpal duplex stream operation panicked while holding the lock" + .to_string(), + }, + })?; + + stream.pause() + } +} + #[cfg(test)] mod test { use crate::{ @@ -365,4 +562,244 @@ mod test { *sample = Sample::EQUILIBRIUM; } } + + #[test] + fn test_duplex_stream() { + use crate::duplex::DuplexStreamConfig; + use crate::BufferSize; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + // Skip in CI due to audio device permissions + if std::env::var("CI").is_ok() { + println!("Skipping test_duplex_stream in CI environment due to permissions"); + return; + } + + let host = default_host(); + let device = host.default_output_device().expect("no output device"); + + // Check if device supports both input and output + let has_input = device + .supported_input_configs() + .map(|mut configs| configs.next().is_some()) + .unwrap_or(false); + let has_output = device + .supported_output_configs() + .map(|mut configs| configs.next().is_some()) + .unwrap_or(false); + + if !has_input || !has_output { + println!("Skipping test_duplex_stream: device doesn't support both input and output"); + return; + } + + let callback_count = Arc::new(AtomicU32::new(0)); + let callback_count_clone = callback_count.clone(); + + // Get supported sample rates from output config + let output_config = device + .supported_output_configs() + .unwrap() + .next() + .unwrap() + .with_max_sample_rate(); + + let config = DuplexStreamConfig { + input_channels: 2, + output_channels: 2, + sample_rate: output_config.sample_rate(), + buffer_size: BufferSize::Default, + }; + + println!("Building duplex stream with config: {:?}", config); + + let stream = device.build_duplex_stream::( + &config, + move |input, output, _info| { + callback_count_clone.fetch_add(1, Ordering::Relaxed); + // Simple passthrough: copy input to output + let copy_len = input.len().min(output.len()); + output[..copy_len].copy_from_slice(&input[..copy_len]); + // Zero any remaining output + for sample in output[copy_len..].iter_mut() { + *sample = 0.0; + } + }, + |err| println!("Error: {err}"), + None, + ); + + match stream { + Ok(stream) => { + stream.play().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(500)); + stream.pause().unwrap(); + + let count = callback_count.load(Ordering::Relaxed); + println!("Duplex callback was called {} times", count); + assert!( + count > 0, + "Duplex callback should have been called at least once" + ); + } + Err(e) => { + // This is acceptable if the device doesn't truly support duplex + println!("Could not create duplex stream: {:?}", e); + } + } + } + + /// Test that verifies duplex synchronization by checking timestamp continuity. + #[test] + fn test_duplex_synchronization_verification() { + use crate::duplex::DuplexStreamConfig; + use crate::BufferSize; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + + // Skip in CI due to audio device permissions + if std::env::var("CI").is_ok() { + println!("Skipping duplex sync test in CI environment"); + return; + } + + let host = default_host(); + let device = host.default_output_device().expect("no output device"); + + // Check device capabilities + let has_input = device + .supported_input_configs() + .map(|mut c| c.next().is_some()) + .unwrap_or(false); + let has_output = device + .supported_output_configs() + .map(|mut c| c.next().is_some()) + .unwrap_or(false); + + if !has_input || !has_output { + println!("Skipping: device doesn't support both input and output"); + return; + } + + /// Verification state collected during callbacks + #[derive(Debug, Default)] + struct SyncVerificationState { + callback_count: u64, + total_frames: u64, + last_sample_time: Option, + discontinuity_count: u64, + timestamp_regressions: u64, + } + + let state = Arc::new(Mutex::new(SyncVerificationState::default())); + let state_clone = state.clone(); + + // Get device config + let output_config = device + .supported_output_configs() + .unwrap() + .next() + .unwrap() + .with_max_sample_rate(); + + let sample_rate = output_config.sample_rate(); + let input_channels = 2u16; + let output_channels = 2u16; + let buffer_size = 512u32; + + let config = DuplexStreamConfig { + input_channels, + output_channels, + sample_rate, + buffer_size: BufferSize::Fixed(buffer_size), + }; + + println!("=== Duplex Synchronization Verification Test ==="); + println!("Config: {:?}", config); + + let error_count = Arc::new(AtomicU64::new(0)); + let error_count_cb = error_count.clone(); + + let stream = match device.build_duplex_stream::( + &config, + move |input, output, info| { + let mut state = state_clone.lock().unwrap(); + state.callback_count += 1; + + // Calculate frames from output buffer size + let frames = output.len() / output_channels as usize; + state.total_frames += frames as u64; + + // Check for timestamp discontinuities + if let Some(prev_sample_time) = state.last_sample_time { + let expected = prev_sample_time + frames as f64; + let discontinuity = (info.timestamp.sample_time - expected).abs(); + + if discontinuity > 1.0 { + state.discontinuity_count += 1; + } + + if info.timestamp.sample_time < prev_sample_time { + state.timestamp_regressions += 1; + } + } + + state.last_sample_time = Some(info.timestamp.sample_time); + + // Simple passthrough + let copy_len = input.len().min(output.len()); + output[..copy_len].copy_from_slice(&input[..copy_len]); + for sample in output[copy_len..].iter_mut() { + *sample = 0.0; + } + }, + move |err| { + println!("Stream error: {err}"); + error_count_cb.fetch_add(1, Ordering::Relaxed); + }, + None, + ) { + Ok(s) => s, + Err(e) => { + println!("Could not create duplex stream: {:?}", e); + return; + } + }; + + // Run for 1 second + println!("Running duplex stream for 1 second..."); + stream.play().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + stream.pause().unwrap(); + + // Collect results + let state = state.lock().unwrap(); + let stream_errors = error_count.load(Ordering::Relaxed); + + println!("\n=== Verification Results ==="); + println!("Callbacks: {}", state.callback_count); + println!("Total frames: {}", state.total_frames); + println!("Discontinuities: {}", state.discontinuity_count); + println!("Timestamp regressions: {}", state.timestamp_regressions); + println!("Stream errors: {}", stream_errors); + + // Assertions + assert!( + state.callback_count > 0, + "Callback should have been called at least once" + ); + assert_eq!( + state.timestamp_regressions, 0, + "Timestamps should never regress" + ); + assert_eq!(stream_errors, 0, "No stream errors should occur"); + assert!( + state.discontinuity_count <= 5, + "Too many discontinuities: {} (max allowed: 5)", + state.discontinuity_count + ); + + println!("\n=== All synchronization checks PASSED ==="); + } } diff --git a/src/lib.rs b/src/lib.rs index 2cc9d582d..0094a0eff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -192,6 +192,7 @@ use std::convert::TryInto; use std::time::Duration; pub mod device_description; +pub mod duplex; mod error; mod host; pub mod platform; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 0f62026d7..0d044bf76 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -70,6 +70,11 @@ macro_rules! impl_platform_host { #[must_use = "If the stream is not stored it will not play."] pub struct Stream(StreamInner); + /// The `DuplexStream` implementation associated with the platform's dynamically dispatched + /// [`Host`] type. + #[must_use = "If the stream is not stored it will not play."] + pub struct DuplexStream(DuplexStreamInner); + /// The `SupportedInputConfigs` iterator associated with the platform's dynamically /// dispatched [`Host`] type. #[derive(Clone)] @@ -168,6 +173,14 @@ macro_rules! impl_platform_host { )* } + /// Contains a platform specific [`DuplexStream`] implementation. + pub enum DuplexStreamInner { + $( + $(#[cfg($feat)])? + $HostVariant(<<$Host as crate::traits::HostTrait>::Device as crate::traits::DeviceTrait>::DuplexStream), + )* + } + #[derive(Clone)] enum SupportedInputConfigsInner { $( @@ -301,6 +314,25 @@ macro_rules! impl_platform_host { } } + impl DuplexStream { + /// Returns a reference to the underlying platform specific implementation of this + /// `DuplexStream`. + pub fn as_inner(&self) -> &DuplexStreamInner { + &self.0 + } + + /// Returns a mutable reference to the underlying platform specific implementation of + /// this `DuplexStream`. + pub fn as_inner_mut(&mut self) -> &mut DuplexStreamInner { + &mut self.0 + } + + /// Returns the underlying platform specific implementation of this `DuplexStream`. + pub fn into_inner(self) -> DuplexStreamInner { + self.0 + } + } + impl Iterator for Devices { type Item = Device; @@ -373,6 +405,7 @@ macro_rules! impl_platform_host { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; #[allow(deprecated)] fn name(&self) -> Result { @@ -521,6 +554,29 @@ macro_rules! impl_platform_host { )* } } + + fn build_duplex_stream_raw( + &self, + config: &crate::duplex::DuplexStreamConfig, + sample_format: crate::SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(crate::StreamError) + Send + 'static, + { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => d + .build_duplex_stream_raw(config, sample_format, data_callback, error_callback, timeout) + .map(DuplexStreamInner::$HostVariant) + .map(DuplexStream::from), + )* + } + } } impl crate::traits::HostTrait for Host { @@ -593,6 +649,30 @@ macro_rules! impl_platform_host { } } + impl crate::traits::StreamTrait for DuplexStream { + fn play(&self) -> Result<(), crate::PlayStreamError> { + match self.0 { + $( + $(#[cfg($feat)])? + DuplexStreamInner::$HostVariant(ref s) => { + s.play() + } + )* + } + } + + fn pause(&self) -> Result<(), crate::PauseStreamError> { + match self.0 { + $( + $(#[cfg($feat)])? + DuplexStreamInner::$HostVariant(ref s) => { + s.pause() + } + )* + } + } + } + impl From for Device { fn from(d: DeviceInner) -> Self { Device(d) @@ -617,6 +697,12 @@ macro_rules! impl_platform_host { } } + impl From for DuplexStream { + fn from(s: DuplexStreamInner) -> Self { + DuplexStream(s) + } + } + $( $(#[cfg($feat)])? impl From<<$Host as crate::traits::HostTrait>::Device> for Device { @@ -645,6 +731,13 @@ macro_rules! impl_platform_host { StreamInner::$HostVariant(h).into() } } + + $(#[cfg($feat)])? + impl From<<<$Host as crate::traits::HostTrait>::Device as crate::traits::DeviceTrait>::DuplexStream> for DuplexStream { + fn from(h: <<$Host as crate::traits::HostTrait>::Device as crate::traits::DeviceTrait>::DuplexStream) -> Self { + DuplexStreamInner::$HostVariant(h).into() + } + } )* /// Produces a list of hosts that are currently available on the system. diff --git a/src/traits.rs b/src/traits.rs index 2c3bccc28..65542734a 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -8,6 +8,7 @@ use std::time::Duration; use crate::{ + duplex::{DuplexCallbackInfo, DuplexStreamConfig}, BuildStreamError, Data, DefaultStreamConfigError, DeviceDescription, DeviceId, DeviceIdError, DeviceNameError, DevicesError, InputCallbackInfo, InputDevices, OutputCallbackInfo, OutputDevices, PauseStreamError, PlayStreamError, SampleFormat, SizedSample, StreamConfig, @@ -99,6 +100,10 @@ pub trait DeviceTrait { /// [`build_input_stream_raw`]: Self::build_input_stream_raw /// [`build_output_stream_raw`]: Self::build_output_stream_raw type Stream: StreamTrait; + /// The duplex stream type created by [`build_duplex_stream`]. + /// + /// [`build_duplex_stream`]: Self::build_duplex_stream + type DuplexStream: StreamTrait; /// The human-readable name of the device. #[deprecated( @@ -139,6 +144,15 @@ pub trait DeviceTrait { .is_ok_and(|mut iter| iter.next().is_some()) } + /// True if the device supports duplex (simultaneous input and output), otherwise false. + /// + /// Duplex operation requires the device to support both input and output with a shared + /// hardware clock. This is typically true for audio interfaces but may not be available + /// on all devices (e.g., output-only speakers or input-only microphones). + fn supports_duplex(&self) -> bool { + self.supports_input() && self.supports_output() + } + /// An iterator yielding formats that are supported by the backend. /// /// Can return an error if the device is no longer valid (e.g. it has been disconnected). @@ -286,6 +300,113 @@ pub trait DeviceTrait { where D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(StreamError) + Send + 'static; + + /// Create a duplex stream with synchronized input and output. + /// + /// A duplex stream uses a single audio unit with both input and output enabled, + /// ensuring they share the same hardware clock. This is essential for applications + /// requiring sample-accurate synchronization between input and output, such as: + /// + /// - DAWs (Digital Audio Workstations) + /// - Real-time audio effects processing + /// - Audio measurement and analysis + /// + /// # Parameters + /// + /// * `config` - The duplex stream configuration specifying channels, sample rate, and buffer size. + /// * `data_callback` - Called periodically with synchronized input and output buffers. + /// - `input`: Interleaved samples from the input device in format `T` + /// - `output`: Mutable buffer to fill with interleaved samples for output in format `T` + /// - `info`: Timing information including hardware timestamp + /// * `error_callback` - Called when a stream error occurs (e.g., device disconnected). + /// * `timeout` - Optional timeout for backend operations. `None` indicates blocking behavior, + /// `Some(duration)` sets a maximum wait time. Not all backends support timeouts. + /// + /// # Errors + /// + /// Returns an error if: + /// - The device doesn't support duplex operation ([`supports_duplex`](Self::supports_duplex) returns false) + /// - The requested configuration is not supported + /// - The device is no longer available + /// + /// # Example + /// + /// ```no_run + /// use cpal::duplex::DuplexStreamConfig; + /// use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + /// use cpal::BufferSize; + /// + /// let host = cpal::default_host(); + /// let device = host.default_output_device().expect("no device"); + /// + /// let config = DuplexStreamConfig::symmetric(2, 48000, BufferSize::Fixed(512)); + /// + /// let stream = device.build_duplex_stream::( + /// &config, + /// |input, output, info| { + /// // Passthrough: copy input to output + /// output[..input.len()].copy_from_slice(input); + /// }, + /// |err| eprintln!("Stream error: {}", err), + /// None, // No timeout + /// ); + /// ``` + fn build_duplex_stream( + &self, + config: &DuplexStreamConfig, + mut data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + T: SizedSample, + D: FnMut(&[T], &mut [T], &DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + self.build_duplex_stream_raw( + config, + T::FORMAT, + move |input, output, info| { + data_callback( + input + .as_slice() + .expect("host supplied incorrect sample type"), + output + .as_slice_mut() + .expect("host supplied incorrect sample type"), + info, + ) + }, + error_callback, + timeout, + ) + } + + /// Create a dynamically typed duplex stream. + /// + /// This method allows working with sample data as raw bytes, useful when the sample + /// format is determined at runtime. For compile-time known formats, prefer + /// [`build_duplex_stream`](Self::build_duplex_stream). + /// + /// # Parameters + /// + /// * `config` - The duplex stream configuration specifying channels, sample rate, and buffer size. + /// * `sample_format` - The sample format of the audio data. + /// * `data_callback` - Called periodically with synchronized input and output buffers as [`Data`]. + /// * `error_callback` - Called when a stream error occurs (e.g., device disconnected). + /// * `timeout` - Optional timeout for backend operations. `None` indicates blocking behavior, + /// `Some(duration)` sets a maximum wait time. Not all backends support timeouts. + fn build_duplex_stream_raw( + &self, + config: &DuplexStreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static; } /// A stream created from [`Device`](DeviceTrait), with methods to control playback. From 9d70617daf08a0c729c260f568f2d5950c61f170 Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Fri, 2 Jan 2026 22:56:43 -0800 Subject: [PATCH 02/16] fix(duplex): report AudioUnitRender failures via error callback Instead of silently zeroing the input buffer when AudioUnitRender fails, now reports the error via the error callback while still continuing with silence for graceful degradation. This follows the ALSA pattern of "report but continue" and ensures users are notified of input capture failures. Also includes latency-adjusted capture/playback timestamps in DuplexCallbackInfo for accurate timing information. --- src/duplex.rs | 34 +++++++++++++--- src/host/coreaudio/macos/device.rs | 62 +++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/duplex.rs b/src/duplex.rs index d3bc1ef75..4f22b02ee 100644 --- a/src/duplex.rs +++ b/src/duplex.rs @@ -145,17 +145,34 @@ impl Default for AudioTimestamp { /// Information passed to duplex callbacks. /// -/// This contains timing information and metadata about the current audio buffer. +/// This contains timing information and metadata about the current audio buffer, +/// including latency-adjusted timestamps for input capture and output playback. #[derive(Clone, Copy, Debug)] pub struct DuplexCallbackInfo { /// Hardware timestamp for this callback. pub timestamp: AudioTimestamp, + + /// Estimated time when the input audio was captured. + /// + /// This is calculated by subtracting the device latency from the callback time, + /// representing when the input samples were actually captured by the hardware. + pub capture: StreamInstant, + + /// Estimated time when the output audio will be played. + /// + /// This is calculated by adding the device latency to the callback time, + /// representing when the output samples will actually reach the hardware. + pub playback: StreamInstant, } impl DuplexCallbackInfo { /// Create a new DuplexCallbackInfo. - pub fn new(timestamp: AudioTimestamp) -> Self { - Self { timestamp } + pub fn new(timestamp: AudioTimestamp, capture: StreamInstant, playback: StreamInstant) -> Self { + Self { + timestamp, + capture, + playback, + } } } @@ -334,9 +351,16 @@ mod tests { #[test] fn test_duplex_callback_info() { - let ts = AudioTimestamp::new(512.0, 1000, 1.0, StreamInstant::new(0, 0)); - let info = DuplexCallbackInfo::new(ts); + let callback = StreamInstant::new(1, 0); + let capture = StreamInstant::new(0, 500_000_000); // 500ms before callback + let playback = StreamInstant::new(1, 500_000_000); // 500ms after callback + + let ts = AudioTimestamp::new(512.0, 1000, 1.0, callback); + let info = DuplexCallbackInfo::new(ts, capture, playback); + assert_eq!(info.timestamp.sample_time, 512.0); + assert_eq!(info.capture, capture); + assert_eq!(info.playback, playback); } #[test] diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 37870933a..c8f786341 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -1077,6 +1077,10 @@ impl Device { ) .unwrap_or(512); + // Get callback vars for latency calculation (matching input/output pattern) + let sample_rate = config.sample_rate; + let device_buffer_frames = get_device_buffer_frame_size(&audio_unit).ok(); + // Get the raw AudioUnit pointer for use in the callback let raw_audio_unit = *audio_unit.as_ref(); @@ -1121,6 +1125,25 @@ impl Device { Ok(cb) => cb, }; + // Calculate latency-adjusted timestamps (matching input/output pattern) + let buffer_frames = num_frames; + // Use device buffer size for latency calculation if available + let latency_frames = device_buffer_frames.unwrap_or( + // Fallback to callback buffer size if device buffer size is unknown + buffer_frames, + ); + let delay = frames_to_duration(latency_frames, sample_rate); + + // Capture time: when input was actually captured (in the past) + let capture = callback_instant + .sub(delay) + .expect("`capture` occurs before origin of `StreamInstant`"); + + // Playback time: when output will actually play (in the future) + let playback = callback_instant + .add(delay) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + // Create our AudioTimestamp from CoreAudio's let audio_timestamp = AudioTimestamp::new( timestamp.mSampleTime, @@ -1152,10 +1175,19 @@ impl Device { ); if status != 0 { - // Failed to pull input - zero the input buffer so callback gets silence - for byte in input_buffer[..input_bytes].iter_mut() { - *byte = 0; - } + // Report error but continue with silence for graceful degradation + invoke_error_callback( + &error_callback_for_callback, + StreamError::BackendSpecific { + err: BackendSpecificError { + description: format!( + "AudioUnitRender failed for input: OSStatus {}", + status + ), + }, + }, + ); + input_buffer[..input_bytes].fill(0); } } @@ -1186,8 +1218,8 @@ impl Device { Data::from_parts(buffer.mData as *mut (), output_samples, sample_format) }; - // Create callback info with timestamp - let callback_info = DuplexCallbackInfo::new(audio_timestamp); + // Create callback info with timestamp and latency-adjusted times + let callback_info = DuplexCallbackInfo::new(audio_timestamp, capture, playback); // Call user callback with input and output Data data_callback(&input_data, &mut output_data, &callback_info); @@ -1223,13 +1255,17 @@ impl Device { duplex_callback_ptr: wrapper_ptr, }; - // Create error callback for stream (wrapper that invokes the shared callback) - let error_callback_for_stream: super::ErrorCallback = { - let error_callback_clone = error_callback.clone(); - Box::new(move |err: StreamError| { - invoke_error_callback(&error_callback_clone, err); - }) - }; + // Create error callback for stream - either dummy or real based on device type + // For duplex, check both input and output default device status + let error_callback_for_stream: super::ErrorCallback = + if is_default_input_device(self) || is_default_output_device(self) { + Box::new(|_: StreamError| {}) + } else { + let error_callback_clone = error_callback.clone(); + Box::new(move |err: StreamError| { + invoke_error_callback(&error_callback_clone, err); + }) + }; // Create the duplex stream let stream = DuplexStream::new(inner, error_callback_for_stream)?; From 061072e227caa9a040afc9b401ac03c99d034ded Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 10:44:13 -0800 Subject: [PATCH 03/16] feat(examples): add duplex_feedback example Demonstrates synchronized duplex streams for real-time audio processing. Shows how duplex streams eliminate the need for ring buffer synchronization and provide hardware-level clock alignment between input and output. --- Cargo.toml | 3 + examples/duplex_feedback.rs | 136 ++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 examples/duplex_feedback.rs diff --git a/Cargo.toml b/Cargo.toml index fb47eb19a..56e635e3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -180,6 +180,9 @@ name = "record_wav" [[example]] name = "synth_tones" +[[example]] +name = "duplex_feedback" + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/examples/duplex_feedback.rs b/examples/duplex_feedback.rs new file mode 100644 index 000000000..cf72d8cd5 --- /dev/null +++ b/examples/duplex_feedback.rs @@ -0,0 +1,136 @@ +//! Feeds back the input stream directly into the output stream using a duplex stream. +//! +//! Unlike the `feedback.rs` example which uses separate input/output streams with a ring buffer, +//! duplex streams provide hardware-synchronized input/output without additional buffering. + +use clap::Parser; +use cpal::duplex::DuplexStreamConfig; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::BufferSize; + +#[derive(Parser, Debug)] +#[command(version, about = "CPAL duplex feedback example", long_about = None)] +struct Opt { + /// The audio device to use (must support duplex operation) + #[arg(short, long, value_name = "DEVICE")] + device: Option, + + /// Number of input channels + #[arg(long, value_name = "CHANNELS", default_value_t = 2)] + input_channels: u16, + + /// Number of output channels + #[arg(long, value_name = "CHANNELS", default_value_t = 2)] + output_channels: u16, + + /// Sample rate in Hz + #[arg(short, long, value_name = "RATE", default_value_t = 48000)] + sample_rate: u32, + + /// Buffer size in frames + #[arg(short, long, value_name = "FRAMES", default_value_t = 512)] + buffer_size: u32, + + /// Use the JACK host + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + #[arg(short, long)] + #[allow(dead_code)] + jack: bool, +} + +fn main() -> anyhow::Result<()> { + let opt = Opt::parse(); + + // Conditionally compile with jack if the feature is specified. + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + let host = if opt.jack { + cpal::host_from_id( + cpal::available_hosts() + .into_iter() + .find(|id| *id == cpal::HostId::Jack) + .expect( + "make sure --features jack is specified. only works on OSes where jack is available", + ), + ) + .expect("jack host unavailable") + } else { + cpal::default_host() + }; + + #[cfg(any( + not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + )), + not(feature = "jack") + ))] + let host = cpal::default_host(); + + // Find the device. + let device = if let Some(device_name) = opt.device { + let id = &device_name + .parse() + .expect("failed to parse device id"); + host.device_by_id(id) + } else { + host.default_output_device() + } + .expect("failed to find device"); + + println!("Using device: \"{}\"", device.id()?); + + // Create duplex stream configuration. + let config = DuplexStreamConfig::new( + opt.input_channels, + opt.output_channels, + opt.sample_rate.into(), + BufferSize::Fixed(opt.buffer_size), + ); + + println!("Building duplex stream with config: {config:?}"); + + let stream = device.build_duplex_stream::( + &config, + move |input, output, _info| { + output.fill(0.0); + let copy_len = input.len().min(output.len()); + output[..copy_len].copy_from_slice(&input[..copy_len]); + }, + |err| eprintln!("Stream error: {err}"), + None, + )?; + + println!("Successfully built duplex stream."); + println!( + "Input: {} channels, Output: {} channels, Sample rate: {} Hz, Buffer size: {} frames", + opt.input_channels, opt.output_channels, opt.sample_rate, opt.buffer_size + ); + + println!("Starting duplex stream..."); + stream.play()?; + + println!("Playing for 10 seconds... (speak into your microphone)"); + std::thread::sleep(std::time::Duration::from_secs(10)); + + drop(stream); + println!("Done!"); + Ok(()) +} From 0d4e6bd41f3fe74d3be1828c3389ebf49920532b Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 10:56:31 -0800 Subject: [PATCH 04/16] chore: update CHANGELOG and fix clippy warnings - Add duplex stream entries to CHANGELOG - Fix clippy useless_conversion warning in duplex_feedback example - Run rustfmt --- CHANGELOG.md | 4 ++++ examples/duplex_feedback.rs | 6 ++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a4e5e37..472f4e2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `DeviceTrait::build_duplex_stream` and `build_duplex_stream_raw` for synchronized input/output. +- `duplex` module with `DuplexStreamConfig`, `AudioTimestamp`, and `DuplexCallbackInfo` types. +- **CoreAudio**: Duplex stream support with hardware-synchronized input/output. +- Example `duplex_feedback` demonstrating duplex stream usage. - `DeviceBusy` error variant for retriable device access errors (EBUSY, EAGAIN). - **ALSA**: `Debug` implementations for `Host`, `Device`, `Stream`, and internal types. - **ALSA**: Example demonstrating ALSA error suppression during enumeration. diff --git a/examples/duplex_feedback.rs b/examples/duplex_feedback.rs index cf72d8cd5..6aeab0b5e 100644 --- a/examples/duplex_feedback.rs +++ b/examples/duplex_feedback.rs @@ -86,9 +86,7 @@ fn main() -> anyhow::Result<()> { // Find the device. let device = if let Some(device_name) = opt.device { - let id = &device_name - .parse() - .expect("failed to parse device id"); + let id = &device_name.parse().expect("failed to parse device id"); host.device_by_id(id) } else { host.default_output_device() @@ -101,7 +99,7 @@ fn main() -> anyhow::Result<()> { let config = DuplexStreamConfig::new( opt.input_channels, opt.output_channels, - opt.sample_rate.into(), + opt.sample_rate, BufferSize::Fixed(opt.buffer_size), ); From 089d117da24a7a09cd9386f4e7cd2b89c889805c Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 11:11:12 -0800 Subject: [PATCH 05/16] fix(examples): make duplex_feedback macOS-only Duplex streams are currently only implemented for CoreAudio. Add platform guards so the example compiles cleanly on other platforms with a helpful message about platform support status. --- examples/duplex_feedback.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/examples/duplex_feedback.rs b/examples/duplex_feedback.rs index 6aeab0b5e..38498329f 100644 --- a/examples/duplex_feedback.rs +++ b/examples/duplex_feedback.rs @@ -2,6 +2,9 @@ //! //! Unlike the `feedback.rs` example which uses separate input/output streams with a ring buffer, //! duplex streams provide hardware-synchronized input/output without additional buffering. +//! +//! Note: Currently only supported on macOS (CoreAudio). Windows (WASAPI) and Linux (ALSA) +//! implementations are planned. use clap::Parser; use cpal::duplex::DuplexStreamConfig; @@ -46,6 +49,7 @@ struct Opt { jack: bool, } +#[cfg(target_os = "macos")] fn main() -> anyhow::Result<()> { let opt = Opt::parse(); @@ -132,3 +136,9 @@ fn main() -> anyhow::Result<()> { println!("Done!"); Ok(()) } + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("Duplex streams are currently only supported on macOS."); + eprintln!("Windows (WASAPI) and Linux (ALSA) support is planned."); +} From 7dce2d2aa05109ae3d243cb015cbc9632b64d6e1 Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 11:17:00 -0800 Subject: [PATCH 06/16] fix: add duplex stream stubs for JACK and Custom backends When --all-features is enabled, JACK and Custom backends need duplex support to compile. Added unique DuplexStream wrapper types for each backend that delegate to UnsupportedDuplexStream. This prevents conflicting From implementations while maintaining API consistency. - Add jack::DuplexStream and custom::DuplexStream wrapper types - Implement build_duplex_stream_raw stubs returning StreamConfigNotSupported - All backends now compile with --all-features --- src/host/custom/mod.rs | 32 ++++++++++++++++++++++++++++++++ src/host/jack/device.rs | 16 ++++++++++++++++ src/host/jack/mod.rs | 15 +++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/host/custom/mod.rs b/src/host/custom/mod.rs index 68fdea9cc..e77ba881f 100644 --- a/src/host/custom/mod.rs +++ b/src/host/custom/mod.rs @@ -99,6 +99,21 @@ impl Stream { } } +/// Duplex stream placeholder for custom backends. +/// +/// Duplex streams are not yet supported for custom backends. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + // dyn-compatible versions of DeviceTrait, HostTrait, and StreamTrait // these only accept/return things via trait objects @@ -344,6 +359,8 @@ impl DeviceTrait for Device { type Stream = Stream; + type DuplexStream = DuplexStream; + fn name(&self) -> Result { self.0.name() } @@ -425,6 +442,21 @@ impl DeviceTrait for Device { timeout, ) } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } } impl StreamTrait for Stream { diff --git a/src/host/jack/device.rs b/src/host/jack/device.rs index 433cfadc1..859da40f9 100644 --- a/src/host/jack/device.rs +++ b/src/host/jack/device.rs @@ -152,6 +152,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = super::DuplexStream; fn description(&self) -> Result { Ok(DeviceDescriptionBuilder::new(self.name.clone()) @@ -270,6 +271,21 @@ impl DeviceTrait for Device { Ok(stream) } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: crate::SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(crate::StreamError) + Send + 'static, + { + Err(crate::BuildStreamError::StreamConfigNotSupported) + } } impl PartialEq for Device { diff --git a/src/host/jack/mod.rs b/src/host/jack/mod.rs index 07795b26d..26aa467d1 100644 --- a/src/host/jack/mod.rs +++ b/src/host/jack/mod.rs @@ -16,6 +16,21 @@ pub use self::{ stream::Stream, }; +/// Duplex stream placeholder for JACK. +/// +/// Duplex streams are not yet implemented for JACK. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl crate::traits::StreamTrait for DuplexStream { + fn play(&self) -> Result<(), crate::PlayStreamError> { + self.0.play() + } + + fn pause(&self) -> Result<(), crate::PauseStreamError> { + self.0.pause() + } +} + const JACK_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; pub type Devices = std::vec::IntoIter; From 65a11634052e8ca7882f18ecee08b9430e65c4f8 Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 11:53:18 -0800 Subject: [PATCH 07/16] fix(duplex): add duplex stream stubs to all backends Added DuplexStream placeholder type and build_duplex_stream_raw stub implementations to all backends that were missing duplex support: - null: Fallback backend for unsupported platforms - wasapi: Windows default backend - asio: Windows low-latency backend - aaudio: Android default backend - coreaudio/ios: iOS backend - emscripten: WebAssembly/Emscripten backend - webaudio: WebAssembly/Web Audio API backend - audioworklet: WebAssembly/Audio Worklet backend All implementations return StreamConfigNotSupported, following the existing precedent where backends that don't support a feature (like webaudio input streams) return this error instead of being feature-flagged. --- src/host/aaudio/mod.rs | 31 +++++++++++++++++++++++++++++++ src/host/alsa/mod.rs | 33 +++++++++++++++++++++++++++++++++ src/host/asio/mod.rs | 31 +++++++++++++++++++++++++++++++ src/host/audioworklet/mod.rs | 31 +++++++++++++++++++++++++++++++ src/host/coreaudio/ios/mod.rs | 31 +++++++++++++++++++++++++++++++ src/host/custom/mod.rs | 4 +++- src/host/emscripten/mod.rs | 31 +++++++++++++++++++++++++++++++ src/host/jack/device.rs | 4 +++- src/host/null/mod.rs | 28 ++++++++++++++++++++++++++++ src/host/wasapi/device.rs | 16 ++++++++++++++++ src/host/wasapi/mod.rs | 17 ++++++++++++++++- src/host/webaudio/mod.rs | 31 +++++++++++++++++++++++++++++++ 12 files changed, 285 insertions(+), 3 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index c2afe3b21..aac984f26 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -115,6 +115,21 @@ pub struct Host; #[derive(Clone)] pub struct Device(Option); +/// Duplex stream placeholder for AAudio. +/// +/// Duplex streams are not yet implemented for AAudio. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + /// Stream wraps AudioStream in Arc> to provide Send + Sync semantics. /// /// While the underlying ndk::audio::AudioStream is neither Send nor Sync in ndk 0.9.0 @@ -383,6 +398,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; fn name(&self) -> Result { match &self.0 { @@ -575,6 +591,21 @@ impl DeviceTrait for Device { sample_format, ) } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } } impl StreamTrait for Stream { diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index bb6a55ef2..a9e0b3ccb 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -88,6 +88,21 @@ const DEFAULT_DEVICE: &str = "default"; // TODO: Not yet defined in rust-lang/libc crate const LIBC_ENOTSUPP: libc::c_int = 524; +/// Duplex stream placeholder for ALSA. +/// +/// Duplex streams are not yet implemented for ALSA. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl crate::traits::StreamTrait for DuplexStream { + fn play(&self) -> Result<(), crate::PlayStreamError> { + crate::traits::StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), crate::PauseStreamError> { + crate::traits::StreamTrait::pause(&self.0) + } +} + /// The default Linux and BSD host type. #[derive(Debug, Clone)] pub struct Host { @@ -155,6 +170,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; // ALSA overrides name() to return pcm_id directly instead of from description fn name(&self) -> Result { @@ -253,6 +269,23 @@ impl DeviceTrait for Device { ); Ok(stream) } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: crate::SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + + Send + + 'static, + E: FnMut(crate::StreamError) + Send + 'static, + { + Err(crate::BuildStreamError::StreamConfigNotSupported) + } } #[derive(Debug)] diff --git a/src/host/asio/mod.rs b/src/host/asio/mod.rs index 87e3adfea..85b8c529f 100644 --- a/src/host/asio/mod.rs +++ b/src/host/asio/mod.rs @@ -21,6 +21,21 @@ use std::time::Duration; mod device; mod stream; +/// Duplex stream placeholder for ASIO. +/// +/// Duplex streams are not yet implemented for ASIO. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + /// Global ASIO instance shared across all Host instances. /// /// ASIO only supports loading a single driver at a time globally, so all Host instances @@ -71,6 +86,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) @@ -143,6 +159,21 @@ impl DeviceTrait for Device { timeout, ) } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } } impl StreamTrait for Stream { diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index 547bd1dba..ab173fc81 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -28,6 +28,21 @@ pub struct Device; pub struct Host; +/// Duplex stream placeholder for AudioWorklet. +/// +/// Duplex streams are not yet implemented for AudioWorklet. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + pub struct Stream { audio_context: web_sys::AudioContext, } @@ -96,6 +111,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; #[inline] fn description(&self) -> Result { @@ -176,6 +192,21 @@ impl DeviceTrait for Device { Err(BuildStreamError::StreamConfigNotSupported) } + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } + /// Create an output stream. fn build_output_stream_raw( &self, diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 4fbc9c104..16804aabb 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -37,6 +37,21 @@ pub struct Device; pub struct Host; +/// Duplex stream placeholder for CoreAudio iOS. +/// +/// Duplex streams are not yet implemented for iOS. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + impl Host { pub fn new() -> Result { Ok(Host) @@ -121,6 +136,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) @@ -222,6 +238,21 @@ impl DeviceTrait for Device { audio_unit, })) } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } } pub struct Stream { diff --git a/src/host/custom/mod.rs b/src/host/custom/mod.rs index e77ba881f..b220d505a 100644 --- a/src/host/custom/mod.rs +++ b/src/host/custom/mod.rs @@ -452,7 +452,9 @@ impl DeviceTrait for Device { _timeout: Option, ) -> Result where - D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + + Send + + 'static, E: FnMut(StreamError) + Send + 'static, { Err(BuildStreamError::StreamConfigNotSupported) diff --git a/src/host/emscripten/mod.rs b/src/host/emscripten/mod.rs index 81f73ec79..6dcc1c8f2 100644 --- a/src/host/emscripten/mod.rs +++ b/src/host/emscripten/mod.rs @@ -32,6 +32,21 @@ pub struct Devices(bool); #[derive(Clone, Debug, PartialEq, Eq)] pub struct Device; +/// Duplex stream placeholder for Emscripten. +/// +/// Duplex streams are not yet implemented for Emscripten. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + #[wasm_bindgen] #[derive(Clone)] pub struct Stream { @@ -153,6 +168,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) @@ -244,6 +260,21 @@ impl DeviceTrait for Device { Ok(stream) } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } } impl StreamTrait for Stream { diff --git a/src/host/jack/device.rs b/src/host/jack/device.rs index 859da40f9..f2b466d0f 100644 --- a/src/host/jack/device.rs +++ b/src/host/jack/device.rs @@ -281,7 +281,9 @@ impl DeviceTrait for Device { _timeout: Option, ) -> Result where - D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + + Send + + 'static, E: FnMut(crate::StreamError) + Send + 'static, { Err(crate::BuildStreamError::StreamConfigNotSupported) diff --git a/src/host/null/mod.rs b/src/host/null/mod.rs index f1bce59b8..4fad08572 100644 --- a/src/host/null/mod.rs +++ b/src/host/null/mod.rs @@ -27,6 +27,8 @@ pub struct Stream; crate::assert_stream_send!(Stream); crate::assert_stream_sync!(Stream); +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + #[derive(Clone)] pub struct SupportedInputConfigs; #[derive(Clone)] @@ -49,6 +51,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; fn name(&self) -> Result { Ok("null".to_string()) @@ -112,6 +115,21 @@ impl DeviceTrait for Device { { unimplemented!() } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + unimplemented!() + } } impl HostTrait for Host { @@ -145,6 +163,16 @@ impl StreamTrait for Stream { } } +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + impl Iterator for Devices { type Item = Device; diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 0caa0bd6f..74f66c84e 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -80,6 +80,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = super::DuplexStream; fn description(&self) -> Result { Device::description(self) @@ -156,6 +157,21 @@ impl DeviceTrait for Device { error_callback, )) } + + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } } struct Endpoint { diff --git a/src/host/wasapi/mod.rs b/src/host/wasapi/mod.rs index 2becafaa6..9b87dc158 100644 --- a/src/host/wasapi/mod.rs +++ b/src/host/wasapi/mod.rs @@ -9,12 +9,27 @@ pub use self::device::{ }; #[allow(unused_imports)] pub use self::stream::Stream; -use crate::traits::HostTrait; +use crate::traits::{HostTrait, StreamTrait}; use crate::BackendSpecificError; use crate::DevicesError; use std::io::Error as IoError; use windows::Win32::Media::Audio; +/// Duplex stream placeholder for WASAPI. +/// +/// Duplex streams are not yet implemented for WASAPI. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), crate::PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), crate::PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + mod com; mod device; mod stream; diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs index 4ca7a291f..6eb8e365b 100644 --- a/src/host/webaudio/mod.rs +++ b/src/host/webaudio/mod.rs @@ -32,6 +32,21 @@ pub struct Device; pub struct Host; +/// Duplex stream placeholder for WebAudio. +/// +/// Duplex streams are not yet implemented for WebAudio. +pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); + +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + pub struct Stream { ctx: Arc, on_ended_closures: Vec, @@ -155,6 +170,7 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; + type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) @@ -200,6 +216,21 @@ impl DeviceTrait for Device { Err(BuildStreamError::StreamConfigNotSupported) } + fn build_duplex_stream_raw( + &self, + _config: &crate::duplex::DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } + /// Create an output stream. fn build_output_stream_raw( &self, From 053da5ec85d6ba8b54fe4d67c451683e940cd3b6 Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 12:14:06 -0800 Subject: [PATCH 08/16] refactor(example): simplify duplex_feedback cfg guards Removed unreachable JACK-related configuration code from the macOS-only duplex_feedback example. Since duplex streams are currently only supported on macOS, the JACK host selection code (which is Linux/BSD-specific) was dead code that would never execute. Simplified from 145 lines to 97 lines by using cpal::default_host() directly. --- examples/duplex_feedback.rs | 48 ------------------------------------- 1 file changed, 48 deletions(-) diff --git a/examples/duplex_feedback.rs b/examples/duplex_feedback.rs index 38498329f..bae2d7efa 100644 --- a/examples/duplex_feedback.rs +++ b/examples/duplex_feedback.rs @@ -33,59 +33,11 @@ struct Opt { /// Buffer size in frames #[arg(short, long, value_name = "FRAMES", default_value_t = 512)] buffer_size: u32, - - /// Use the JACK host - #[cfg(all( - any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd" - ), - feature = "jack" - ))] - #[arg(short, long)] - #[allow(dead_code)] - jack: bool, } #[cfg(target_os = "macos")] fn main() -> anyhow::Result<()> { let opt = Opt::parse(); - - // Conditionally compile with jack if the feature is specified. - #[cfg(all( - any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd" - ), - feature = "jack" - ))] - let host = if opt.jack { - cpal::host_from_id( - cpal::available_hosts() - .into_iter() - .find(|id| *id == cpal::HostId::Jack) - .expect( - "make sure --features jack is specified. only works on OSes where jack is available", - ), - ) - .expect("jack host unavailable") - } else { - cpal::default_host() - }; - - #[cfg(any( - not(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd" - )), - not(feature = "jack") - ))] let host = cpal::default_host(); // Find the device. From c92dadd5888f31b05cc64105faf36f8eb27254cc Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 12:34:04 -0800 Subject: [PATCH 09/16] docs(changelog): clarify DeviceTrait changes are breaking Added explicit BREAKING notice for DeviceTrait changes. External implementations of DeviceTrait must now provide DuplexStream type and build_duplex_stream_raw() method. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 472f4e2bd..738c217f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING**: `DeviceTrait` now requires `DuplexStream` associated type and `build_duplex_stream_raw()` method. External implementations must add stubs returning `StreamConfigNotSupported`. - Overall MSRV increased to 1.78. - **ALSA**: Update `alsa` dependency from 0.10 to 0.11. - **ALSA**: MSRV increased from 1.77 to 1.82 (required by alsa-sys 0.4.0). From 3fc3a8773d71b0c72ebdd711ada63c5399ae4375 Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 15:21:26 -0800 Subject: [PATCH 10/16] refactor(duplex): add default impl for build_duplex_stream_raw Reduces duplex stub code by ~50% across all backends by providing a default implementation in DeviceTrait that returns StreamConfigNotSupported. Changes: - Add default implementation for build_duplex_stream_raw() in DeviceTrait - Remove build_duplex_stream_raw implementations from all backends - Keep minimal DuplexStream wrapper structs for type distinction - Wrapper structs delegate play/pause to inner UnsupportedDuplexStream This maintains the breaking change (backends must provide DuplexStream type) but significantly reduces boilerplate code per backend from ~30 to ~15 lines. --- src/host/aaudio/mod.rs | 30 +----------------------------- src/host/alsa/mod.rs | 32 +------------------------------- src/host/asio/mod.rs | 30 +----------------------------- src/host/audioworklet/mod.rs | 20 +------------------- src/host/coreaudio/ios/mod.rs | 30 +----------------------------- src/host/custom/mod.rs | 22 +--------------------- src/host/emscripten/mod.rs | 20 +------------------- src/host/jack/device.rs | 17 ----------------- src/host/jack/mod.rs | 19 ++++++++----------- src/host/null/mod.rs | 27 +-------------------------- src/host/wasapi/device.rs | 15 --------------- src/host/wasapi/mod.rs | 15 +-------------- src/host/webaudio/mod.rs | 20 +------------------- src/traits.rs | 15 +++++++++------ 14 files changed, 27 insertions(+), 285 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index aac984f26..7b325ee21 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -115,20 +115,7 @@ pub struct Host; #[derive(Clone)] pub struct Device(Option); -/// Duplex stream placeholder for AAudio. -/// -/// Duplex streams are not yet implemented for AAudio. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); /// Stream wraps AudioStream in Arc> to provide Send + Sync semantics. /// @@ -591,21 +578,6 @@ impl DeviceTrait for Device { sample_format, ) } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Err(BuildStreamError::StreamConfigNotSupported) - } } impl StreamTrait for Stream { diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index a9e0b3ccb..98e78f9ef 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -88,20 +88,7 @@ const DEFAULT_DEVICE: &str = "default"; // TODO: Not yet defined in rust-lang/libc crate const LIBC_ENOTSUPP: libc::c_int = 524; -/// Duplex stream placeholder for ALSA. -/// -/// Duplex streams are not yet implemented for ALSA. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); - -impl crate::traits::StreamTrait for DuplexStream { - fn play(&self) -> Result<(), crate::PlayStreamError> { - crate::traits::StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), crate::PauseStreamError> { - crate::traits::StreamTrait::pause(&self.0) - } -} +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); /// The default Linux and BSD host type. #[derive(Debug, Clone)] @@ -269,23 +256,6 @@ impl DeviceTrait for Device { ); Ok(stream) } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: crate::SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) - + Send - + 'static, - E: FnMut(crate::StreamError) + Send + 'static, - { - Err(crate::BuildStreamError::StreamConfigNotSupported) - } } #[derive(Debug)] diff --git a/src/host/asio/mod.rs b/src/host/asio/mod.rs index 85b8c529f..2712bef0e 100644 --- a/src/host/asio/mod.rs +++ b/src/host/asio/mod.rs @@ -21,20 +21,7 @@ use std::time::Duration; mod device; mod stream; -/// Duplex stream placeholder for ASIO. -/// -/// Duplex streams are not yet implemented for ASIO. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); /// Global ASIO instance shared across all Host instances. /// @@ -159,21 +146,6 @@ impl DeviceTrait for Device { timeout, ) } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Err(BuildStreamError::StreamConfigNotSupported) - } } impl StreamTrait for Stream { diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index ab173fc81..75d5b4cbd 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -28,10 +28,7 @@ pub struct Device; pub struct Host; -/// Duplex stream placeholder for AudioWorklet. -/// -/// Duplex streams are not yet implemented for AudioWorklet. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); impl StreamTrait for DuplexStream { fn play(&self) -> Result<(), PlayStreamError> { @@ -192,21 +189,6 @@ impl DeviceTrait for Device { Err(BuildStreamError::StreamConfigNotSupported) } - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Err(BuildStreamError::StreamConfigNotSupported) - } - /// Create an output stream. fn build_output_stream_raw( &self, diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 16804aabb..0071242c3 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -37,20 +37,7 @@ pub struct Device; pub struct Host; -/// Duplex stream placeholder for CoreAudio iOS. -/// -/// Duplex streams are not yet implemented for iOS. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); impl Host { pub fn new() -> Result { @@ -238,21 +225,6 @@ impl DeviceTrait for Device { audio_unit, })) } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Err(BuildStreamError::StreamConfigNotSupported) - } } pub struct Stream { diff --git a/src/host/custom/mod.rs b/src/host/custom/mod.rs index b220d505a..04e5eeb5e 100644 --- a/src/host/custom/mod.rs +++ b/src/host/custom/mod.rs @@ -99,10 +99,7 @@ impl Stream { } } -/// Duplex stream placeholder for custom backends. -/// -/// Duplex streams are not yet supported for custom backends. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); impl StreamTrait for DuplexStream { fn play(&self) -> Result<(), PlayStreamError> { @@ -442,23 +439,6 @@ impl DeviceTrait for Device { timeout, ) } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) - + Send - + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Err(BuildStreamError::StreamConfigNotSupported) - } } impl StreamTrait for Stream { diff --git a/src/host/emscripten/mod.rs b/src/host/emscripten/mod.rs index 6dcc1c8f2..3f5a0de9e 100644 --- a/src/host/emscripten/mod.rs +++ b/src/host/emscripten/mod.rs @@ -32,10 +32,7 @@ pub struct Devices(bool); #[derive(Clone, Debug, PartialEq, Eq)] pub struct Device; -/// Duplex stream placeholder for Emscripten. -/// -/// Duplex streams are not yet implemented for Emscripten. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); impl StreamTrait for DuplexStream { fn play(&self) -> Result<(), PlayStreamError> { @@ -260,21 +257,6 @@ impl DeviceTrait for Device { Ok(stream) } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Err(BuildStreamError::StreamConfigNotSupported) - } } impl StreamTrait for Stream { diff --git a/src/host/jack/device.rs b/src/host/jack/device.rs index f2b466d0f..d77eb917a 100644 --- a/src/host/jack/device.rs +++ b/src/host/jack/device.rs @@ -271,23 +271,6 @@ impl DeviceTrait for Device { Ok(stream) } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: crate::SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) - + Send - + 'static, - E: FnMut(crate::StreamError) + Send + 'static, - { - Err(crate::BuildStreamError::StreamConfigNotSupported) - } } impl PartialEq for Device { diff --git a/src/host/jack/mod.rs b/src/host/jack/mod.rs index 26aa467d1..094926318 100644 --- a/src/host/jack/mod.rs +++ b/src/host/jack/mod.rs @@ -4,8 +4,8 @@ extern crate jack; -use crate::traits::HostTrait; -use crate::{DevicesError, SampleFormat}; +use crate::traits::{HostTrait, StreamTrait}; +use crate::{DevicesError, PauseStreamError, PlayStreamError, SampleFormat}; mod device; mod stream; @@ -16,18 +16,15 @@ pub use self::{ stream::Stream, }; -/// Duplex stream placeholder for JACK. -/// -/// Duplex streams are not yet implemented for JACK. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); -impl crate::traits::StreamTrait for DuplexStream { - fn play(&self) -> Result<(), crate::PlayStreamError> { - self.0.play() +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) } - fn pause(&self) -> Result<(), crate::PauseStreamError> { - self.0.pause() + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) } } diff --git a/src/host/null/mod.rs b/src/host/null/mod.rs index 4fad08572..fd1cd8c77 100644 --- a/src/host/null/mod.rs +++ b/src/host/null/mod.rs @@ -27,7 +27,7 @@ pub struct Stream; crate::assert_stream_send!(Stream); crate::assert_stream_sync!(Stream); -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); #[derive(Clone)] pub struct SupportedInputConfigs; @@ -115,21 +115,6 @@ impl DeviceTrait for Device { { unimplemented!() } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - unimplemented!() - } } impl HostTrait for Host { @@ -163,16 +148,6 @@ impl StreamTrait for Stream { } } -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - impl Iterator for Devices { type Item = Device; diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 74f66c84e..433001900 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -157,21 +157,6 @@ impl DeviceTrait for Device { error_callback, )) } - - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Err(BuildStreamError::StreamConfigNotSupported) - } } struct Endpoint { diff --git a/src/host/wasapi/mod.rs b/src/host/wasapi/mod.rs index 9b87dc158..e98973ec4 100644 --- a/src/host/wasapi/mod.rs +++ b/src/host/wasapi/mod.rs @@ -15,20 +15,7 @@ use crate::DevicesError; use std::io::Error as IoError; use windows::Win32::Media::Audio; -/// Duplex stream placeholder for WASAPI. -/// -/// Duplex streams are not yet implemented for WASAPI. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), crate::PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), crate::PauseStreamError> { - StreamTrait::pause(&self.0) - } -} +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); mod com; mod device; diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs index 6eb8e365b..1a60cf00c 100644 --- a/src/host/webaudio/mod.rs +++ b/src/host/webaudio/mod.rs @@ -32,10 +32,7 @@ pub struct Device; pub struct Host; -/// Duplex stream placeholder for WebAudio. -/// -/// Duplex streams are not yet implemented for WebAudio. -pub struct DuplexStream(crate::duplex::UnsupportedDuplexStream); +pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); impl StreamTrait for DuplexStream { fn play(&self) -> Result<(), PlayStreamError> { @@ -216,21 +213,6 @@ impl DeviceTrait for Device { Err(BuildStreamError::StreamConfigNotSupported) } - fn build_duplex_stream_raw( - &self, - _config: &crate::duplex::DuplexStreamConfig, - _sample_format: SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Err(BuildStreamError::StreamConfigNotSupported) - } - /// Create an output stream. fn build_output_stream_raw( &self, diff --git a/src/traits.rs b/src/traits.rs index 65542734a..9a449805f 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -398,15 +398,18 @@ pub trait DeviceTrait { /// `Some(duration)` sets a maximum wait time. Not all backends support timeouts. fn build_duplex_stream_raw( &self, - config: &DuplexStreamConfig, - sample_format: SampleFormat, - data_callback: D, - error_callback: E, - timeout: Option, + _config: &DuplexStreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, ) -> Result where D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static; + E: FnMut(StreamError) + Send + 'static, + { + Err(BuildStreamError::StreamConfigNotSupported) + } } /// A stream created from [`Device`](DeviceTrait), with methods to control playback. From 09c1b634f3ac2983cf7e3d2d2eb5276159bd4ffd Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 16:05:27 -0800 Subject: [PATCH 11/16] fix(coreaudio-ios): add StreamTrait impl for DuplexStream Add missing StreamTrait implementation for iOS backend's DuplexStream wrapper. This fixes cross-compilation to aarch64-apple-ios target. --- src/host/coreaudio/ios/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 0071242c3..16b157129 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -39,6 +39,16 @@ pub struct Host; pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + impl Host { pub fn new() -> Result { Ok(Host) From 28198e3f7bc8b0bcd981abec4c8abf3ddf8f04cc Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sat, 17 Jan 2026 16:20:04 -0800 Subject: [PATCH 12/16] fix: add StreamTrait impl for all remaining backends Add missing StreamTrait implementations for platform-specific backends that couldn't be tested on macOS: - aaudio (Android) - asio (Windows) - wasapi (Windows) - alsa (Linux) - null (WASI) Each implementation delegates play/pause to the inner UnsupportedDuplexStream. --- src/host/aaudio/mod.rs | 10 ++++++++++ src/host/alsa/mod.rs | 10 ++++++++++ src/host/asio/mod.rs | 10 ++++++++++ src/host/null/mod.rs | 10 ++++++++++ src/host/wasapi/mod.rs | 10 ++++++++++ 5 files changed, 50 insertions(+) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 7b325ee21..250c1f83f 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -117,6 +117,16 @@ pub struct Device(Option); pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + /// Stream wraps AudioStream in Arc> to provide Send + Sync semantics. /// /// While the underlying ndk::audio::AudioStream is neither Send nor Sync in ndk 0.9.0 diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 98e78f9ef..46de2aa95 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -90,6 +90,16 @@ const LIBC_ENOTSUPP: libc::c_int = 524; pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); +impl crate::traits::StreamTrait for DuplexStream { + fn play(&self) -> Result<(), crate::PlayStreamError> { + crate::traits::StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), crate::PauseStreamError> { + crate::traits::StreamTrait::pause(&self.0) + } +} + /// The default Linux and BSD host type. #[derive(Debug, Clone)] pub struct Host { diff --git a/src/host/asio/mod.rs b/src/host/asio/mod.rs index 2712bef0e..62a283f13 100644 --- a/src/host/asio/mod.rs +++ b/src/host/asio/mod.rs @@ -23,6 +23,16 @@ mod stream; pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + /// Global ASIO instance shared across all Host instances. /// /// ASIO only supports loading a single driver at a time globally, so all Host instances diff --git a/src/host/null/mod.rs b/src/host/null/mod.rs index fd1cd8c77..bf1f15db6 100644 --- a/src/host/null/mod.rs +++ b/src/host/null/mod.rs @@ -29,6 +29,16 @@ crate::assert_stream_sync!(Stream); pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + #[derive(Clone)] pub struct SupportedInputConfigs; #[derive(Clone)] diff --git a/src/host/wasapi/mod.rs b/src/host/wasapi/mod.rs index e98973ec4..a1753c830 100644 --- a/src/host/wasapi/mod.rs +++ b/src/host/wasapi/mod.rs @@ -17,6 +17,16 @@ use windows::Win32::Media::Audio; pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); +impl StreamTrait for DuplexStream { + fn play(&self) -> Result<(), crate::PlayStreamError> { + StreamTrait::play(&self.0) + } + + fn pause(&self) -> Result<(), crate::PauseStreamError> { + StreamTrait::pause(&self.0) + } +} + mod com; mod device; mod stream; From 1f0fabba494bd1f70cd0610c267a3e191c430bde Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Wed, 21 Jan 2026 22:54:10 -0800 Subject: [PATCH 13/16] use to_string instead of format! and change UnsupportedDuplexStream to unit struct rather than making it defensive for future field addition --- src/duplex.rs | 13 +++---------- src/host/coreaudio/macos/mod.rs | 8 ++++---- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/duplex.rs b/src/duplex.rs index 4f22b02ee..6d5d8a889 100644 --- a/src/duplex.rs +++ b/src/duplex.rs @@ -259,9 +259,8 @@ impl DuplexStreamConfig { /// This type implements `StreamTrait` but all operations return errors. /// Backend implementations should replace this with their own type once /// duplex support is implemented. -pub struct UnsupportedDuplexStream { - _private: (), -} +#[derive(Default)] +pub struct UnsupportedDuplexStream; impl UnsupportedDuplexStream { /// Create a new unsupported duplex stream marker. @@ -269,13 +268,7 @@ impl UnsupportedDuplexStream { /// This should not normally be called - it exists only to satisfy /// type requirements for backends without duplex support. pub fn new() -> Self { - Self { _private: () } - } -} - -impl Default for UnsupportedDuplexStream { - fn default() -> Self { - Self::new() + Self } } diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index 4b20ed106..9d42fbacc 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -187,7 +187,7 @@ impl StreamInner { fn play(&mut self) -> Result<(), PlayStreamError> { if !self.playing { if let Err(e) = self.audio_unit.start() { - let description = format!("{e}"); + let description = e.to_string(); let err = BackendSpecificError { description }; return Err(err.into()); } @@ -199,7 +199,7 @@ impl StreamInner { fn pause(&mut self) -> Result<(), PauseStreamError> { if self.playing { if let Err(e) = self.audio_unit.stop() { - let description = format!("{e}"); + let description = e.to_string(); let err = BackendSpecificError { description }; return Err(err.into()); } @@ -288,7 +288,7 @@ impl DuplexStreamInner { fn play(&mut self) -> Result<(), PlayStreamError> { if !self.playing { if let Err(e) = self.audio_unit.start() { - let description = format!("{e}"); + let description = e.to_string(); let err = BackendSpecificError { description }; return Err(err.into()); } @@ -300,7 +300,7 @@ impl DuplexStreamInner { fn pause(&mut self) -> Result<(), PauseStreamError> { if self.playing { if let Err(e) = self.audio_unit.stop() { - let description = format!("{e}"); + let description = e.to_string(); let err = BackendSpecificError { description }; return Err(err.into()); } From 463745b435c67fded30d74bbf144109414ab3a63 Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sun, 25 Jan 2026 12:27:04 -0800 Subject: [PATCH 14/16] refactor(duplex): Remove AudioTimestamp and all Duplex stream and error types Simplify the duplex implementation by unifying it with existing Stream types instead of introducing separate types. This removes AudioTimestamp, DuplexStream wrappers, and the duplicate DuplexDisconnectManager, making duplex streams work the same way as regular input/output streams. DuplexCallbackInfo now uses StreamInstant fields directly, matching the existing Input/OutputCallbackInfo pattern. --- CHANGELOG.md | 4 +- README.md | 2 + examples/custom.rs | 3 +- examples/duplex_feedback.rs | 15 +- src/duplex.rs | 248 ++------------------------- src/host/aaudio/mod.rs | 13 -- src/host/alsa/mod.rs | 13 -- src/host/asio/mod.rs | 13 -- src/host/audioworklet/mod.rs | 13 -- src/host/coreaudio/ios/mod.rs | 13 -- src/host/coreaudio/macos/device.rs | 31 ++-- src/host/coreaudio/macos/mod.rs | 264 ++++------------------------- src/host/custom/mod.rs | 14 -- src/host/emscripten/mod.rs | 13 -- src/host/jack/device.rs | 1 - src/host/jack/mod.rs | 16 +- src/host/null/mod.rs | 13 -- src/host/wasapi/device.rs | 1 - src/host/wasapi/mod.rs | 14 +- src/host/webaudio/mod.rs | 13 -- src/platform/mod.rs | 76 +-------- src/traits.rs | 12 +- 22 files changed, 88 insertions(+), 717 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 738c217f2..914de1a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `DeviceTrait::build_duplex_stream` and `build_duplex_stream_raw` for synchronized input/output. -- `duplex` module with `DuplexStreamConfig`, `AudioTimestamp`, and `DuplexCallbackInfo` types. +- `duplex` module with `DuplexStreamConfig` and `DuplexCallbackInfo` types. - **CoreAudio**: Duplex stream support with hardware-synchronized input/output. - Example `duplex_feedback` demonstrating duplex stream usage. - `DeviceBusy` error variant for retriable device access errors (EBUSY, EAGAIN). @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING**: `DeviceTrait` now requires `DuplexStream` associated type and `build_duplex_stream_raw()` method. External implementations must add stubs returning `StreamConfigNotSupported`. +- **BREAKING**: `DeviceTrait` now includes `build_duplex_stream()` and `build_duplex_stream_raw()` methods. The default implementation returns `StreamConfigNotSupported`, so external implementations are compatible without changes. - Overall MSRV increased to 1.78. - **ALSA**: Update `alsa` dependency from 0.10 to 0.11. - **ALSA**: MSRV increased from 1.77 to 1.82 (required by alsa-sys 0.4.0). diff --git a/README.md b/README.md index 47d92c694..6355b6d96 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This library currently supports the following: - Enumerate known supported input and output stream formats for a device. - Get the current default input and output stream formats for a device. - Build and run input and output PCM streams on a chosen device with a given stream format. +- Build and run duplex (simultaneous input/output) streams with hardware clock synchronization (macOS only, more platforms coming soon). Currently, supported hosts include: @@ -209,6 +210,7 @@ CPAL comes with several examples demonstrating various features: - `beep` - Generate a simple sine wave tone - `enumerate` - List all available audio devices and their capabilities - `feedback` - Pass input audio directly to output (microphone loopback) +- `duplex_feedback` - Hardware-synchronized duplex stream loopback (macOS only) - `record_wav` - Record audio from the default input device to a WAV file - `synth_tones` - Generate multiple tones simultaneously diff --git a/examples/custom.rs b/examples/custom.rs index 661bb88ec..7b37a5d7a 100644 --- a/examples/custom.rs +++ b/examples/custom.rs @@ -54,7 +54,6 @@ impl DeviceTrait for MyDevice { type SupportedInputConfigs = std::iter::Empty; type SupportedOutputConfigs = std::iter::Once; type Stream = MyStream; - type DuplexStream = cpal::duplex::UnsupportedDuplexStream; fn name(&self) -> Result { Ok(String::from("custom")) @@ -190,7 +189,7 @@ impl DeviceTrait for MyDevice { _data_callback: D, _error_callback: E, _timeout: Option, - ) -> Result + ) -> Result where D: FnMut(&cpal::Data, &mut cpal::Data, &cpal::duplex::DuplexCallbackInfo) + Send + 'static, E: FnMut(cpal::StreamError) + Send + 'static, diff --git a/examples/duplex_feedback.rs b/examples/duplex_feedback.rs index bae2d7efa..4ea873146 100644 --- a/examples/duplex_feedback.rs +++ b/examples/duplex_feedback.rs @@ -40,16 +40,17 @@ fn main() -> anyhow::Result<()> { let opt = Opt::parse(); let host = cpal::default_host(); - // Find the device. - let device = if let Some(device_name) = opt.device { - let id = &device_name.parse().expect("failed to parse device id"); - host.device_by_id(id) + // Find the device by device ID or use default + let device = if let Some(device_id_str) = opt.device { + let device_id = device_id_str.parse().expect("failed to parse device id"); + host.device_by_id(&device_id) + .expect(&format!("failed to find device with id: {}", device_id_str)) } else { host.default_output_device() - } - .expect("failed to find device"); + .expect("no default output device") + }; - println!("Using device: \"{}\"", device.id()?); + println!("Using device: \"{}\"", device.description()?.name()); // Create duplex stream configuration. let config = DuplexStreamConfig::new( diff --git a/src/duplex.rs b/src/duplex.rs index 6d5d8a889..5bb149122 100644 --- a/src/duplex.rs +++ b/src/duplex.rs @@ -37,139 +37,36 @@ //! ).expect("failed to build duplex stream"); //! ``` -use crate::{PauseStreamError, PlayStreamError, SampleRate, StreamInstant}; - -/// Hardware timestamp information from the audio device. -/// -/// This provides precise timing information from the audio hardware, essential for -/// sample-accurate synchronization between input and output, and for correlating -/// audio timing with other system events. -/// -/// # Detecting Xruns -/// -/// Applications can detect xruns (buffer underruns/overruns) by tracking the -/// `sample_time` field across callbacks. Under normal operation, `sample_time` -/// advances by exactly the buffer size each callback. A larger jump indicates -/// missed buffers: -/// -/// ```ignore -/// let mut last_sample_time: Option = None; -/// -/// // In your callback: -/// if let Some(last) = last_sample_time { -/// let expected = last + buffer_size as f64; -/// let discontinuity = (info.timestamp.sample_time - expected).abs(); -/// if discontinuity > 1.0 { -/// println!("Xrun detected: {} samples missed", discontinuity); -/// } -/// } -/// last_sample_time = Some(info.timestamp.sample_time); -/// ``` -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct AudioTimestamp { - /// Hardware sample counter from the device clock. - /// - /// This is the authoritative position from the device's clock and increments - /// by the buffer size each callback. Use this for xrun detection by tracking - /// discontinuities. - /// - /// This is an f64 to allow for sub-sample precision in rate-adjusted scenarios. - /// For most purposes, cast to i64 for an integer value. - pub sample_time: f64, - - /// System host time reference (platform-specific high-resolution timer). - /// - /// Can be used to correlate audio timing with other system events or for - /// debugging latency issues. - pub host_time: u64, - - /// Clock rate scalar (1.0 = nominal rate). - /// - /// Indicates if the hardware clock is running faster or slower than nominal. - /// Useful for applications that need to compensate for clock drift when - /// synchronizing with external sources. - pub rate_scalar: f64, - - /// Callback timestamp from cpal's existing timing system. - /// - /// This provides compatibility with cpal's existing `StreamInstant` timing - /// infrastructure. - pub callback_instant: StreamInstant, -} - -impl AudioTimestamp { - /// Create a new AudioTimestamp. - pub fn new( - sample_time: f64, - host_time: u64, - rate_scalar: f64, - callback_instant: StreamInstant, - ) -> Self { - Self { - sample_time, - host_time, - rate_scalar, - callback_instant, - } - } - - /// Get the sample position as an integer. - /// - /// This rounds the hardware sample time to the nearest integer. The result - /// is suitable for use as a timeline position or for sample-accurate event - /// scheduling. - #[inline] - pub fn sample_position(&self) -> i64 { - self.sample_time.round() as i64 - } - - /// Check if the clock is running at nominal rate. - /// - /// Returns `true` if `rate_scalar` is very close to 1.0 (within 0.0001). - #[inline] - pub fn is_nominal_rate(&self) -> bool { - (self.rate_scalar - 1.0).abs() < 0.0001 - } -} - -impl Default for AudioTimestamp { - fn default() -> Self { - Self { - sample_time: 0.0, - host_time: 0, - rate_scalar: 1.0, - callback_instant: StreamInstant::new(0, 0), - } - } -} +use crate::{SampleRate, StreamInstant}; /// Information passed to duplex callbacks. /// -/// This contains timing information and metadata about the current audio buffer, -/// including latency-adjusted timestamps for input capture and output playback. -#[derive(Clone, Copy, Debug)] +/// This contains timing information for the current audio buffer, combining +/// both input and output timing similar to [`InputCallbackInfo`](crate::InputCallbackInfo) +/// and [`OutputCallbackInfo`](crate::OutputCallbackInfo). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct DuplexCallbackInfo { - /// Hardware timestamp for this callback. - pub timestamp: AudioTimestamp, + /// The instant the stream's data callback was invoked. + pub callback: StreamInstant, - /// Estimated time when the input audio was captured. + /// The instant that input data was captured from the device. /// - /// This is calculated by subtracting the device latency from the callback time, - /// representing when the input samples were actually captured by the hardware. + /// This is calculated by subtracting the input device latency from the callback time, + /// representing when the input samples were actually captured by the hardware (e.g., by an ADC). pub capture: StreamInstant, - /// Estimated time when the output audio will be played. + /// The predicted instant that output data will be delivered to the device for playback. /// - /// This is calculated by adding the device latency to the callback time, - /// representing when the output samples will actually reach the hardware. + /// This is calculated by adding the output device latency to the callback time, + /// representing when the output samples will actually be played by the hardware (e.g., by a DAC). pub playback: StreamInstant, } impl DuplexCallbackInfo { /// Create a new DuplexCallbackInfo. - pub fn new(timestamp: AudioTimestamp, capture: StreamInstant, playback: StreamInstant) -> Self { + pub fn new(callback: StreamInstant, capture: StreamInstant, playback: StreamInstant) -> Self { Self { - timestamp, + callback, capture, playback, } @@ -254,104 +151,19 @@ impl DuplexStreamConfig { } } -/// A placeholder duplex stream type for backends that don't yet support duplex. -/// -/// This type implements `StreamTrait` but all operations return errors. -/// Backend implementations should replace this with their own type once -/// duplex support is implemented. -#[derive(Default)] -pub struct UnsupportedDuplexStream; - -impl UnsupportedDuplexStream { - /// Create a new unsupported duplex stream marker. - /// - /// This should not normally be called - it exists only to satisfy - /// type requirements for backends without duplex support. - pub fn new() -> Self { - Self - } -} - -impl crate::traits::StreamTrait for UnsupportedDuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - Err(PlayStreamError::BackendSpecific { - err: crate::BackendSpecificError { - description: "Duplex streams are not yet supported on this backend".to_string(), - }, - }) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - Err(PauseStreamError::BackendSpecific { - err: crate::BackendSpecificError { - description: "Duplex streams are not yet supported on this backend".to_string(), - }, - }) - } -} - -// Safety: UnsupportedDuplexStream contains no mutable state -unsafe impl Send for UnsupportedDuplexStream {} -unsafe impl Sync for UnsupportedDuplexStream {} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_audio_timestamp_sample_position() { - let ts = AudioTimestamp::new(1234.5, 0, 1.0, StreamInstant::new(0, 0)); - assert_eq!(ts.sample_position(), 1235); // rounds up - - let ts = AudioTimestamp::new(1234.4, 0, 1.0, StreamInstant::new(0, 0)); - assert_eq!(ts.sample_position(), 1234); // rounds down - - let ts = AudioTimestamp::new(-100.0, 0, 1.0, StreamInstant::new(0, 0)); - assert_eq!(ts.sample_position(), -100); // negative values work - } - - #[test] - fn test_audio_timestamp_nominal_rate() { - let ts = AudioTimestamp::new(0.0, 0, 1.0, StreamInstant::new(0, 0)); - assert!(ts.is_nominal_rate()); - - let ts = AudioTimestamp::new(0.0, 0, 1.00005, StreamInstant::new(0, 0)); - assert!(ts.is_nominal_rate()); // within tolerance - - let ts = AudioTimestamp::new(0.0, 0, 1.001, StreamInstant::new(0, 0)); - assert!(!ts.is_nominal_rate()); // outside tolerance - } - - #[test] - fn test_audio_timestamp_default() { - let ts = AudioTimestamp::default(); - assert_eq!(ts.sample_time, 0.0); - assert_eq!(ts.host_time, 0); - assert_eq!(ts.rate_scalar, 1.0); - assert_eq!(ts.sample_position(), 0); - assert!(ts.is_nominal_rate()); - } - - #[test] - fn test_audio_timestamp_equality() { - let ts1 = AudioTimestamp::new(1000.0, 12345, 1.0, StreamInstant::new(0, 0)); - let ts2 = AudioTimestamp::new(1000.0, 12345, 1.0, StreamInstant::new(0, 0)); - let ts3 = AudioTimestamp::new(1000.0, 12346, 1.0, StreamInstant::new(0, 0)); - - assert_eq!(ts1, ts2); - assert_ne!(ts1, ts3); - } - #[test] fn test_duplex_callback_info() { let callback = StreamInstant::new(1, 0); let capture = StreamInstant::new(0, 500_000_000); // 500ms before callback let playback = StreamInstant::new(1, 500_000_000); // 500ms after callback - let ts = AudioTimestamp::new(512.0, 1000, 1.0, callback); - let info = DuplexCallbackInfo::new(ts, capture, playback); + let info = DuplexCallbackInfo::new(callback, capture, playback); - assert_eq!(info.timestamp.sample_time, 512.0); + assert_eq!(info.callback, callback); assert_eq!(info.capture, capture); assert_eq!(info.playback, playback); } @@ -421,30 +233,4 @@ mod tests { let config3 = DuplexStreamConfig::new(2, 4, 44100, crate::BufferSize::Fixed(512)); assert_ne!(config1, config3); } - - #[test] - fn test_unsupported_duplex_stream() { - use crate::traits::StreamTrait; - - let stream = UnsupportedDuplexStream::new(); - - // play() should return an error - let play_result = stream.play(); - assert!(play_result.is_err()); - - // pause() should return an error - let pause_result = stream.pause(); - assert!(pause_result.is_err()); - } - - #[test] - fn test_unsupported_duplex_stream_default() { - let _stream = UnsupportedDuplexStream::default(); - } - - #[test] - fn test_unsupported_duplex_stream_send_sync() { - fn assert_send_sync() {} - assert_send_sync::(); - } } diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 250c1f83f..c2afe3b21 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -115,18 +115,6 @@ pub struct Host; #[derive(Clone)] pub struct Device(Option); -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - /// Stream wraps AudioStream in Arc> to provide Send + Sync semantics. /// /// While the underlying ndk::audio::AudioStream is neither Send nor Sync in ndk 0.9.0 @@ -395,7 +383,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; fn name(&self) -> Result { match &self.0 { diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 46de2aa95..bb6a55ef2 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -88,18 +88,6 @@ const DEFAULT_DEVICE: &str = "default"; // TODO: Not yet defined in rust-lang/libc crate const LIBC_ENOTSUPP: libc::c_int = 524; -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl crate::traits::StreamTrait for DuplexStream { - fn play(&self) -> Result<(), crate::PlayStreamError> { - crate::traits::StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), crate::PauseStreamError> { - crate::traits::StreamTrait::pause(&self.0) - } -} - /// The default Linux and BSD host type. #[derive(Debug, Clone)] pub struct Host { @@ -167,7 +155,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; // ALSA overrides name() to return pcm_id directly instead of from description fn name(&self) -> Result { diff --git a/src/host/asio/mod.rs b/src/host/asio/mod.rs index 62a283f13..87e3adfea 100644 --- a/src/host/asio/mod.rs +++ b/src/host/asio/mod.rs @@ -21,18 +21,6 @@ use std::time::Duration; mod device; mod stream; -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - /// Global ASIO instance shared across all Host instances. /// /// ASIO only supports loading a single driver at a time globally, so all Host instances @@ -83,7 +71,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index 75d5b4cbd..547bd1dba 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -28,18 +28,6 @@ pub struct Device; pub struct Host; -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - pub struct Stream { audio_context: web_sys::AudioContext, } @@ -108,7 +96,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; #[inline] fn description(&self) -> Result { diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 16b157129..4fbc9c104 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -37,18 +37,6 @@ pub struct Device; pub struct Host; -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - impl Host { pub fn new() -> Result { Ok(Host) @@ -133,7 +121,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index c8f786341..0d4cf810a 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -1,8 +1,7 @@ use super::OSStatus; use super::Stream; use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant}; -use super::{DuplexStream, DuplexStreamInner}; -use crate::duplex::{AudioTimestamp, DuplexCallbackInfo}; +use crate::duplex::DuplexCallbackInfo; use crate::host::coreaudio::macos::loopback::LoopbackDevice; use crate::host::coreaudio::macos::StreamInner; use crate::traits::DeviceTrait; @@ -266,7 +265,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) @@ -347,7 +345,7 @@ impl DeviceTrait for Device { data_callback: D, error_callback: E, _timeout: Option, - ) -> Result + ) -> Result where D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, E: FnMut(StreamError) + Send + 'static, @@ -852,6 +850,7 @@ impl Device { audio_unit, device_id: self.audio_device_id, _loopback_device: loopback_aggregate, + duplex_callback_ptr: None, }, error_callback_for_stream, )?; @@ -955,6 +954,7 @@ impl Device { audio_unit, device_id: self.audio_device_id, _loopback_device: None, + duplex_callback_ptr: None, }, error_callback_for_stream, )?; @@ -983,7 +983,7 @@ impl Device { sample_format: SampleFormat, data_callback: D, error_callback: E, - ) -> Result + ) -> Result where D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, E: FnMut(StreamError) + Send + 'static, @@ -1144,14 +1144,6 @@ impl Device { .add(delay) .expect("`playback` occurs beyond representation supported by `StreamInstant`"); - // Create our AudioTimestamp from CoreAudio's - let audio_timestamp = AudioTimestamp::new( - timestamp.mSampleTime, - timestamp.mHostTime, - timestamp.mRateScalar, - callback_instant, - ); - // Pull input from Element 1 using AudioUnitRender // We use the pre-allocated input_buffer unsafe { @@ -1218,8 +1210,8 @@ impl Device { Data::from_parts(buffer.mData as *mut (), output_samples, sample_format) }; - // Create callback info with timestamp and latency-adjusted times - let callback_info = DuplexCallbackInfo::new(audio_timestamp, capture, playback); + // Create callback info with latency-adjusted times + let callback_info = DuplexCallbackInfo::new(callback_instant, capture, playback); // Call user callback with input and output Data data_callback(&input_data, &mut output_data, &callback_info); @@ -1248,11 +1240,12 @@ impl Device { )?; // Create the stream inner, storing the callback pointer for cleanup - let inner = DuplexStreamInner { + let inner = StreamInner { playing: true, audio_unit, device_id: self.audio_device_id, - duplex_callback_ptr: wrapper_ptr, + _loopback_device: None, + duplex_callback_ptr: Some(wrapper_ptr), }; // Create error callback for stream - either dummy or real based on device type @@ -1268,7 +1261,7 @@ impl Device { }; // Create the duplex stream - let stream = DuplexStream::new(inner, error_callback_for_stream)?; + let stream = Stream::new(inner, error_callback_for_stream)?; // Start the audio unit stream @@ -1386,7 +1379,7 @@ pub(crate) struct DuplexProcWrapper { // SAFETY: DuplexProcWrapper is Send because: // 1. The boxed closure captures only Send types (the DuplexCallback trait requires Send) -// 2. The raw pointer stored in DuplexStreamInner is only accessed: +// 2. The raw pointer stored in StreamInner is only accessed: // - During Drop, after stopping the audio unit (callback no longer running) // 3. CoreAudio guarantees single-threaded callback invocation unsafe impl Send for DuplexProcWrapper {} diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index 9d42fbacc..b88c4a879 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -179,112 +179,25 @@ struct StreamInner { #[allow(dead_code)] device_id: AudioDeviceID, /// Manage the lifetime of the aggregate device used - /// for loopback recording + /// for loopback recording (used by input streams only) _loopback_device: Option, + /// Pointer to the duplex callback wrapper, needed for cleanup. + /// This is only used by duplex streams and is None for regular input/output streams. + duplex_callback_ptr: Option<*mut device::DuplexProcWrapper>, } -impl StreamInner { - fn play(&mut self) -> Result<(), PlayStreamError> { - if !self.playing { - if let Err(e) = self.audio_unit.start() { - let description = e.to_string(); - let err = BackendSpecificError { description }; - return Err(err.into()); - } - self.playing = true; - } - Ok(()) - } - - fn pause(&mut self) -> Result<(), PauseStreamError> { - if self.playing { - if let Err(e) = self.audio_unit.stop() { - let description = e.to_string(); - let err = BackendSpecificError { description }; - return Err(err.into()); - } - self.playing = false; - } - Ok(()) - } -} - -pub struct Stream { - inner: Arc>, - // Manages the device disconnection listener separately to allow Stream to be Send. - // The DisconnectManager contains the non-Send AudioObjectPropertyListener. - _disconnect_manager: DisconnectManager, -} +// SAFETY: StreamInner is Send because: +// 1. AudioUnit is Send (handles thread safety internally) +// 2. AudioDeviceID is a simple integer type +// 3. LoopbackDevice is Send (contains only Send types) +// 4. The raw pointer duplex_callback_ptr is only accessed: +// - During Drop, after stopping the audio unit (callback no longer running) +// - The pointer was created from a Box that is Send +// - CoreAudio guarantees single-threaded callback invocation +// 5. The pointer is never dereferenced while the audio unit is running +unsafe impl Send for StreamInner {} -impl Stream { - fn new( - inner: StreamInner, - error_callback: ErrorCallback, - ) -> Result { - let device_id = inner.device_id; - let inner_arc = Arc::new(Mutex::new(inner)); - let weak_inner = Arc::downgrade(&inner_arc); - - let error_callback = Arc::new(Mutex::new(error_callback)); - let disconnect_manager = DisconnectManager::new(device_id, weak_inner, error_callback)?; - - Ok(Self { - inner: inner_arc, - _disconnect_manager: disconnect_manager, - }) - } -} - -impl StreamTrait for Stream { - fn play(&self) -> Result<(), PlayStreamError> { - let mut stream = self - .inner - .lock() - .map_err(|_| PlayStreamError::BackendSpecific { - err: BackendSpecificError { - description: "A cpal stream operation panicked while holding the lock - this is a bug, please report it".to_string(), - }, - })?; - - stream.play() - } - - fn pause(&self) -> Result<(), PauseStreamError> { - let mut stream = self - .inner - .lock() - .map_err(|_| PauseStreamError::BackendSpecific { - err: BackendSpecificError { - description: "A cpal stream operation panicked while holding the lock - this is a bug, please report it".to_string(), - }, - })?; - - stream.pause() - } -} - -// ============================================================================ -// DuplexStream - Synchronized input/output with shared hardware clock -// ============================================================================ - -/// Internal state for the duplex stream. -pub(crate) struct DuplexStreamInner { - pub(crate) playing: bool, - pub(crate) audio_unit: AudioUnit, - pub(crate) device_id: AudioDeviceID, - /// Pointer to the callback wrapper, needed for cleanup. - /// This is set by build_duplex_stream_raw and freed in Drop. - pub(crate) duplex_callback_ptr: *mut device::DuplexProcWrapper, -} - -// SAFETY: DuplexStreamInner is Send because: -// 1. AudioUnit is Send (coreaudio crate marks it as such) -// 2. AudioDeviceID is Copy -// 3. duplex_callback_ptr points to a Send type (DuplexProcWrapper) -// and is only accessed during Drop after stopping the audio unit -unsafe impl Send for DuplexStreamInner {} - -impl DuplexStreamInner { +impl StreamInner { fn play(&mut self) -> Result<(), PlayStreamError> { if !self.playing { if let Err(e) = self.audio_unit.start() { @@ -310,108 +223,35 @@ impl DuplexStreamInner { } } -impl Drop for DuplexStreamInner { +impl Drop for StreamInner { fn drop(&mut self) { // Stop the audio unit first to ensure callback is no longer being called let _ = self.audio_unit.stop(); - // Now safe to free the callback wrapper - if !self.duplex_callback_ptr.is_null() { - unsafe { - let _ = Box::from_raw(self.duplex_callback_ptr); + // Clean up duplex callback if present + if let Some(ptr) = self.duplex_callback_ptr { + if !ptr.is_null() { + unsafe { + let _ = Box::from_raw(ptr); + } } - self.duplex_callback_ptr = std::ptr::null_mut(); } // AudioUnit's own Drop will handle uninitialize and dispose + // _loopback_device's Drop will handle aggregate device cleanup } } -/// Duplex stream disconnect manager - handles device disconnection. -struct DuplexDisconnectManager { - _shutdown_tx: mpsc::Sender<()>, +pub struct Stream { + inner: Arc>, + // Manages the device disconnection listener separately to allow Stream to be Send. + // The DisconnectManager contains the non-Send AudioObjectPropertyListener. + _disconnect_manager: DisconnectManager, } -impl DuplexDisconnectManager { +impl Stream { fn new( - device_id: AudioDeviceID, - stream_weak: Weak>, - error_callback: Arc>, - ) -> Result { - let (shutdown_tx, shutdown_rx) = mpsc::channel(); - let (disconnect_tx, disconnect_rx) = mpsc::channel(); - let (ready_tx, ready_rx) = mpsc::channel(); - - let disconnect_tx_clone = disconnect_tx.clone(); - std::thread::spawn(move || { - let property_address = AudioObjectPropertyAddress { - mSelector: kAudioDevicePropertyDeviceIsAlive, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain, - }; - - match AudioObjectPropertyListener::new(device_id, property_address, move || { - let _ = disconnect_tx_clone.send(()); - }) { - Ok(_listener) => { - let _ = ready_tx.send(Ok(())); - let _ = shutdown_rx.recv(); - } - Err(e) => { - let _ = ready_tx.send(Err(e)); - } - } - }); - - ready_rx - .recv() - .map_err(|_| crate::BuildStreamError::BackendSpecific { - err: BackendSpecificError { - description: "Disconnect listener thread terminated unexpectedly".to_string(), - }, - })??; - - // Handle disconnect events - std::thread::spawn(move || { - while disconnect_rx.recv().is_ok() { - if let Some(stream_arc) = stream_weak.upgrade() { - if let Ok(mut stream_inner) = stream_arc.try_lock() { - let _ = stream_inner.pause(); - } - invoke_error_callback(&error_callback, crate::StreamError::DeviceNotAvailable); - } else { - break; - } - } - }); - - Ok(DuplexDisconnectManager { - _shutdown_tx: shutdown_tx, - }) - } -} - -/// A duplex audio stream with synchronized input and output. -/// -/// Uses a single HAL AudioUnit with both input and output enabled, -/// ensuring they share the same hardware clock. -pub struct DuplexStream { - inner: Arc>, - _disconnect_manager: DuplexDisconnectManager, -} - -// Compile-time assertion that DuplexStream is Send and Sync -const _: () = { - const fn assert_send_sync() {} - assert_send_sync::(); -}; - -impl DuplexStream { - /// Create a new duplex stream. - /// - /// This is called by `Device::build_duplex_stream_raw`. - pub(crate) fn new( - inner: DuplexStreamInner, + inner: StreamInner, error_callback: ErrorCallback, ) -> Result { let device_id = inner.device_id; @@ -419,9 +259,7 @@ impl DuplexStream { let weak_inner = Arc::downgrade(&inner_arc); let error_callback = Arc::new(Mutex::new(error_callback)); - - let disconnect_manager = - DuplexDisconnectManager::new(device_id, weak_inner, error_callback)?; + let disconnect_manager = DisconnectManager::new(device_id, weak_inner, error_callback)?; Ok(Self { inner: inner_arc, @@ -430,15 +268,14 @@ impl DuplexStream { } } -impl StreamTrait for DuplexStream { +impl StreamTrait for Stream { fn play(&self) -> Result<(), PlayStreamError> { let mut stream = self .inner .lock() .map_err(|_| PlayStreamError::BackendSpecific { err: BackendSpecificError { - description: "A cpal duplex stream operation panicked while holding the lock" - .to_string(), + description: "A cpal stream operation panicked while holding the lock - this is a bug, please report it".to_string(), }, })?; @@ -451,8 +288,7 @@ impl StreamTrait for DuplexStream { .lock() .map_err(|_| PauseStreamError::BackendSpecific { err: BackendSpecificError { - description: "A cpal duplex stream operation panicked while holding the lock" - .to_string(), + description: "A cpal stream operation panicked while holding the lock - this is a bug, please report it".to_string(), }, })?; @@ -687,9 +523,6 @@ mod test { struct SyncVerificationState { callback_count: u64, total_frames: u64, - last_sample_time: Option, - discontinuity_count: u64, - timestamp_regressions: u64, } let state = Arc::new(Mutex::new(SyncVerificationState::default())); @@ -723,7 +556,7 @@ mod test { let stream = match device.build_duplex_stream::( &config, - move |input, output, info| { + move |input, output, _info| { let mut state = state_clone.lock().unwrap(); state.callback_count += 1; @@ -731,22 +564,6 @@ mod test { let frames = output.len() / output_channels as usize; state.total_frames += frames as u64; - // Check for timestamp discontinuities - if let Some(prev_sample_time) = state.last_sample_time { - let expected = prev_sample_time + frames as f64; - let discontinuity = (info.timestamp.sample_time - expected).abs(); - - if discontinuity > 1.0 { - state.discontinuity_count += 1; - } - - if info.timestamp.sample_time < prev_sample_time { - state.timestamp_regressions += 1; - } - } - - state.last_sample_time = Some(info.timestamp.sample_time); - // Simple passthrough let copy_len = input.len().min(output.len()); output[..copy_len].copy_from_slice(&input[..copy_len]); @@ -780,8 +597,6 @@ mod test { println!("\n=== Verification Results ==="); println!("Callbacks: {}", state.callback_count); println!("Total frames: {}", state.total_frames); - println!("Discontinuities: {}", state.discontinuity_count); - println!("Timestamp regressions: {}", state.timestamp_regressions); println!("Stream errors: {}", stream_errors); // Assertions @@ -789,16 +604,7 @@ mod test { state.callback_count > 0, "Callback should have been called at least once" ); - assert_eq!( - state.timestamp_regressions, 0, - "Timestamps should never regress" - ); assert_eq!(stream_errors, 0, "No stream errors should occur"); - assert!( - state.discontinuity_count <= 5, - "Too many discontinuities: {} (max allowed: 5)", - state.discontinuity_count - ); println!("\n=== All synchronization checks PASSED ==="); } diff --git a/src/host/custom/mod.rs b/src/host/custom/mod.rs index 04e5eeb5e..68fdea9cc 100644 --- a/src/host/custom/mod.rs +++ b/src/host/custom/mod.rs @@ -99,18 +99,6 @@ impl Stream { } } -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - // dyn-compatible versions of DeviceTrait, HostTrait, and StreamTrait // these only accept/return things via trait objects @@ -356,8 +344,6 @@ impl DeviceTrait for Device { type Stream = Stream; - type DuplexStream = DuplexStream; - fn name(&self) -> Result { self.0.name() } diff --git a/src/host/emscripten/mod.rs b/src/host/emscripten/mod.rs index 3f5a0de9e..81f73ec79 100644 --- a/src/host/emscripten/mod.rs +++ b/src/host/emscripten/mod.rs @@ -32,18 +32,6 @@ pub struct Devices(bool); #[derive(Clone, Debug, PartialEq, Eq)] pub struct Device; -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - #[wasm_bindgen] #[derive(Clone)] pub struct Stream { @@ -165,7 +153,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) diff --git a/src/host/jack/device.rs b/src/host/jack/device.rs index d77eb917a..433cfadc1 100644 --- a/src/host/jack/device.rs +++ b/src/host/jack/device.rs @@ -152,7 +152,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = super::DuplexStream; fn description(&self) -> Result { Ok(DeviceDescriptionBuilder::new(self.name.clone()) diff --git a/src/host/jack/mod.rs b/src/host/jack/mod.rs index 094926318..07795b26d 100644 --- a/src/host/jack/mod.rs +++ b/src/host/jack/mod.rs @@ -4,8 +4,8 @@ extern crate jack; -use crate::traits::{HostTrait, StreamTrait}; -use crate::{DevicesError, PauseStreamError, PlayStreamError, SampleFormat}; +use crate::traits::HostTrait; +use crate::{DevicesError, SampleFormat}; mod device; mod stream; @@ -16,18 +16,6 @@ pub use self::{ stream::Stream, }; -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - const JACK_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; pub type Devices = std::vec::IntoIter; diff --git a/src/host/null/mod.rs b/src/host/null/mod.rs index bf1f15db6..f1bce59b8 100644 --- a/src/host/null/mod.rs +++ b/src/host/null/mod.rs @@ -27,18 +27,6 @@ pub struct Stream; crate::assert_stream_send!(Stream); crate::assert_stream_sync!(Stream); -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - #[derive(Clone)] pub struct SupportedInputConfigs; #[derive(Clone)] @@ -61,7 +49,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; fn name(&self) -> Result { Ok("null".to_string()) diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 433001900..0caa0bd6f 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -80,7 +80,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = super::DuplexStream; fn description(&self) -> Result { Device::description(self) diff --git a/src/host/wasapi/mod.rs b/src/host/wasapi/mod.rs index a1753c830..2becafaa6 100644 --- a/src/host/wasapi/mod.rs +++ b/src/host/wasapi/mod.rs @@ -9,24 +9,12 @@ pub use self::device::{ }; #[allow(unused_imports)] pub use self::stream::Stream; -use crate::traits::{HostTrait, StreamTrait}; +use crate::traits::HostTrait; use crate::BackendSpecificError; use crate::DevicesError; use std::io::Error as IoError; use windows::Win32::Media::Audio; -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), crate::PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), crate::PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - mod com; mod device; mod stream; diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs index 1a60cf00c..4ca7a291f 100644 --- a/src/host/webaudio/mod.rs +++ b/src/host/webaudio/mod.rs @@ -32,18 +32,6 @@ pub struct Device; pub struct Host; -pub struct DuplexStream(pub crate::duplex::UnsupportedDuplexStream); - -impl StreamTrait for DuplexStream { - fn play(&self) -> Result<(), PlayStreamError> { - StreamTrait::play(&self.0) - } - - fn pause(&self) -> Result<(), PauseStreamError> { - StreamTrait::pause(&self.0) - } -} - pub struct Stream { ctx: Arc, on_ended_closures: Vec, @@ -167,7 +155,6 @@ impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; fn description(&self) -> Result { Device::description(self) diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 0d044bf76..0d0d799dd 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -70,11 +70,6 @@ macro_rules! impl_platform_host { #[must_use = "If the stream is not stored it will not play."] pub struct Stream(StreamInner); - /// The `DuplexStream` implementation associated with the platform's dynamically dispatched - /// [`Host`] type. - #[must_use = "If the stream is not stored it will not play."] - pub struct DuplexStream(DuplexStreamInner); - /// The `SupportedInputConfigs` iterator associated with the platform's dynamically /// dispatched [`Host`] type. #[derive(Clone)] @@ -173,14 +168,6 @@ macro_rules! impl_platform_host { )* } - /// Contains a platform specific [`DuplexStream`] implementation. - pub enum DuplexStreamInner { - $( - $(#[cfg($feat)])? - $HostVariant(<<$Host as crate::traits::HostTrait>::Device as crate::traits::DeviceTrait>::DuplexStream), - )* - } - #[derive(Clone)] enum SupportedInputConfigsInner { $( @@ -314,25 +301,6 @@ macro_rules! impl_platform_host { } } - impl DuplexStream { - /// Returns a reference to the underlying platform specific implementation of this - /// `DuplexStream`. - pub fn as_inner(&self) -> &DuplexStreamInner { - &self.0 - } - - /// Returns a mutable reference to the underlying platform specific implementation of - /// this `DuplexStream`. - pub fn as_inner_mut(&mut self) -> &mut DuplexStreamInner { - &mut self.0 - } - - /// Returns the underlying platform specific implementation of this `DuplexStream`. - pub fn into_inner(self) -> DuplexStreamInner { - self.0 - } - } - impl Iterator for Devices { type Item = Device; @@ -405,7 +373,6 @@ macro_rules! impl_platform_host { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; type Stream = Stream; - type DuplexStream = DuplexStream; #[allow(deprecated)] fn name(&self) -> Result { @@ -562,7 +529,7 @@ macro_rules! impl_platform_host { data_callback: D, error_callback: E, timeout: Option, - ) -> Result + ) -> Result where D: FnMut(&crate::Data, &mut crate::Data, &crate::duplex::DuplexCallbackInfo) + Send + 'static, E: FnMut(crate::StreamError) + Send + 'static, @@ -572,8 +539,8 @@ macro_rules! impl_platform_host { $(#[cfg($feat)])? DeviceInner::$HostVariant(ref d) => d .build_duplex_stream_raw(config, sample_format, data_callback, error_callback, timeout) - .map(DuplexStreamInner::$HostVariant) - .map(DuplexStream::from), + .map(StreamInner::$HostVariant) + .map(Stream::from), )* } } @@ -649,30 +616,6 @@ macro_rules! impl_platform_host { } } - impl crate::traits::StreamTrait for DuplexStream { - fn play(&self) -> Result<(), crate::PlayStreamError> { - match self.0 { - $( - $(#[cfg($feat)])? - DuplexStreamInner::$HostVariant(ref s) => { - s.play() - } - )* - } - } - - fn pause(&self) -> Result<(), crate::PauseStreamError> { - match self.0 { - $( - $(#[cfg($feat)])? - DuplexStreamInner::$HostVariant(ref s) => { - s.pause() - } - )* - } - } - } - impl From for Device { fn from(d: DeviceInner) -> Self { Device(d) @@ -697,12 +640,6 @@ macro_rules! impl_platform_host { } } - impl From for DuplexStream { - fn from(s: DuplexStreamInner) -> Self { - DuplexStream(s) - } - } - $( $(#[cfg($feat)])? impl From<<$Host as crate::traits::HostTrait>::Device> for Device { @@ -731,13 +668,6 @@ macro_rules! impl_platform_host { StreamInner::$HostVariant(h).into() } } - - $(#[cfg($feat)])? - impl From<<<$Host as crate::traits::HostTrait>::Device as crate::traits::DeviceTrait>::DuplexStream> for DuplexStream { - fn from(h: <<$Host as crate::traits::HostTrait>::Device as crate::traits::DeviceTrait>::DuplexStream) -> Self { - DuplexStreamInner::$HostVariant(h).into() - } - } )* /// Produces a list of hosts that are currently available on the system. diff --git a/src/traits.rs b/src/traits.rs index 9a449805f..4003bf647 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -95,15 +95,13 @@ pub trait DeviceTrait { type SupportedInputConfigs: Iterator; /// The iterator type yielding supported output stream formats. type SupportedOutputConfigs: Iterator; - /// The stream type created by [`build_input_stream_raw`] and [`build_output_stream_raw`]. + /// The stream type created by [`build_input_stream_raw`], [`build_output_stream_raw`], + /// and [`build_duplex_stream_raw`]. /// /// [`build_input_stream_raw`]: Self::build_input_stream_raw /// [`build_output_stream_raw`]: Self::build_output_stream_raw + /// [`build_duplex_stream_raw`]: Self::build_duplex_stream_raw type Stream: StreamTrait; - /// The duplex stream type created by [`build_duplex_stream`]. - /// - /// [`build_duplex_stream`]: Self::build_duplex_stream - type DuplexStream: StreamTrait; /// The human-readable name of the device. #[deprecated( @@ -357,7 +355,7 @@ pub trait DeviceTrait { mut data_callback: D, error_callback: E, timeout: Option, - ) -> Result + ) -> Result where T: SizedSample, D: FnMut(&[T], &mut [T], &DuplexCallbackInfo) + Send + 'static, @@ -403,7 +401,7 @@ pub trait DeviceTrait { _data_callback: D, _error_callback: E, _timeout: Option, - ) -> Result + ) -> Result where D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, E: FnMut(StreamError) + Send + 'static, From ca93b20ffbdb248de6153eea8d5bf6a887505f7a Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sun, 25 Jan 2026 12:40:54 -0800 Subject: [PATCH 15/16] refactor(examples): custom.rs doesn't need to override the default impl in traits.rs --- examples/custom.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/examples/custom.rs b/examples/custom.rs index 7b37a5d7a..639bc6943 100644 --- a/examples/custom.rs +++ b/examples/custom.rs @@ -181,21 +181,6 @@ impl DeviceTrait for MyDevice { handle: Some(handle), }) } - - fn build_duplex_stream_raw( - &self, - _config: &cpal::duplex::DuplexStreamConfig, - _sample_format: cpal::SampleFormat, - _data_callback: D, - _error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&cpal::Data, &mut cpal::Data, &cpal::duplex::DuplexCallbackInfo) + Send + 'static, - E: FnMut(cpal::StreamError) + Send + 'static, - { - Err(cpal::BuildStreamError::StreamConfigNotSupported) - } } impl StreamTrait for MyStream { From 95278cd14cb39b52a132dcde4126cd03e02b7888 Mon Sep 17 00:00:00 2001 From: Mark Gulbrandsen Date: Sun, 25 Jan 2026 12:45:34 -0800 Subject: [PATCH 16/16] doc(duplex): I think this change is only potentially breaking. At this point, the default impl for build_duplex_stream_raw should mean this is not a breaking change, but just calling this out --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 914de1a0f..0a4bc1120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING**: `DeviceTrait` now includes `build_duplex_stream()` and `build_duplex_stream_raw()` methods. The default implementation returns `StreamConfigNotSupported`, so external implementations are compatible without changes. +- **POTENTIALLY BREAKING**: `DeviceTrait` now includes `build_duplex_stream()` and `build_duplex_stream_raw()` methods. The default implementation returns `StreamConfigNotSupported`, so external implementations are compatible without changes. - Overall MSRV increased to 1.78. - **ALSA**: Update `alsa` dependency from 0.10 to 0.11. - **ALSA**: MSRV increased from 1.77 to 1.82 (required by alsa-sys 0.4.0).