Conversation
|
@gulbrand thanks for your contribution. I don't mind the AI usage as long as the output is of high quality and what I'd expect from a seasoned developer. Let me know when you've addressed @Decodetalkers review points, and are ready for my detailed review. Very quickly, I like having |
Agreed. I've updated the PR but need a bit of time to test in separate projects again and to double check the safety claims. I'll ping again once I'm done with testing but I think this PR is in a much better state now. |
95278cd to
a344d4c
Compare
| // Create error callback for stream - either dummy or real based on device type. | ||
| // For duplex, only swallow disconnect if the device is the default for both | ||
| // roles — otherwise Core Audio won't re-route both directions. | ||
| let error_callback_for_stream: super::ErrorCallback = |
There was a problem hiding this comment.
I've changed my mind on this. I'm not sure though. I think if a disconnect happens at all in duplex mode, the stream needs to be torn down and recreated.
@roderickvd what do you think?
roderickvd
left a comment
There was a problem hiding this comment.
Took a while but here's my first code review.
examples/duplex_feedback.rs
Outdated
|
|
||
| /// Number of input channels | ||
| #[arg(long, value_name = "CHANNELS", default_value_t = 2)] | ||
| input_channels: u16, |
There was a problem hiding this comment.
Please use type aliases like ChannelCount and SampleRate.
examples/duplex_feedback.rs
Outdated
|
|
||
| /// Buffer size in frames | ||
| #[arg(short, long, value_name = "FRAMES", default_value_t = 512)] | ||
| buffer_size: u32, |
There was a problem hiding this comment.
Idea: make it Option<usize> and then have it use either BufferSize::Default or Fixed.
examples/duplex_feedback.rs
Outdated
| let host = cpal::default_host(); | ||
|
|
||
| // Find the device by device ID or use default | ||
| let device = if let Some(device_id_str) = opt.device { |
There was a problem hiding this comment.
match seems more idiomatic here.
examples/duplex_feedback.rs
Outdated
| 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) | ||
| .unwrap_or_else(|| panic!("failed to find device with id: {}", device_id_str)) |
There was a problem hiding this comment.
Why not use expect here as well?
There was a problem hiding this comment.
expect can't take a format string. I've done this .unwrap_or_else(|| panic!(...)) pattern myself a few times, It's the best way I know of to custom-format the panic message.
There was a problem hiding this comment.
I like expect better. They both seem idiomatic, but to me personally, expect feels a little more "gooder" here :) . This is just the example app so I'm not sure it matters too much.
examples/duplex_feedback.rs
Outdated
| let stream = device.build_duplex_stream::<f32, _, _>( | ||
| &config, | ||
| move |input, output, _info| { | ||
| output.fill(0.0); |
There was a problem hiding this comment.
Better to use Sample::EQUILIBRIUM instead of 0.0.
README.md
Outdated
| - `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) |
There was a problem hiding this comment.
Let's drop the "macOS only" part: we'll forget when we add duplex support for other hosts.
| /// audio thread with no Rust frames above to catch an unwind). Per RFC 2945 | ||
| /// (https://github.com/rust-lang/rust/issues/115285), `extern "C"` aborts on | ||
| /// panic, which would be the correct behavior here. | ||
| extern "C-unwind" fn duplex_input_proc( |
There was a problem hiding this comment.
A standard workaround is catch_unwind:
extern "C-unwind" fn duplex_input_proc(...) -> i32 {
let wrapper = unsafe { in_ref_con.cast::<DuplexProcWrapper>().as_mut() };
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(wrapper.callback)(...)
}));
match result {
Ok(ret) => ret,
Err(_) => {
// Log or invoke error callback if possible; the panic was caught
kAudio_ParamError
}
}
}There was a problem hiding this comment.
To be transparent, this is a bit above my understanding and I'm going off of what I'm learning about this aspect of Rust and C FFI.
IIUC, catch_unwind would prevent UB by returning an error to CoreAudio which would kill the audio stream and keep the process away from UB.
That makes sense to me so I'll make this change.
| // Stop the audio unit to ensure the callback is no longer being called | ||
| // before reclaiming duplex_callback_ptr below. We must stop here regardless | ||
| // of AudioUnit::drop's behavior. | ||
| // Note: AudioUnit::drop will also call stop() — likely safe, but we stop here anyway. |
There was a problem hiding this comment.
Following your points over at Discord, you are right to be careful about assuming audio_unit.stop() being idempotent. ManuallyDrop<AudioUnit> would be cleaner and more explicit.
There was a problem hiding this comment.
Creating a record here of what I pointed out in Discord:
The issue is that StreamInner now holds a duplex_callback_ptr: Option<*mut ...> (b/c coreaudio-rs doesn't have a builder for duplex streams, we have to implement it here unless we also want to change coreaudio-rs).
struct StreamInner {
playing: bool,
audio_unit: AudioUnit,
duplex_callback_ptr: Option<*mut device::DuplexProcWrapper>,
}
The problem is the StreamInner.audio_unit needs to be stopped before duplex_callback_ptr is dropped--something cpal hasn't had to worry about due to coreaudio-rs doing the work.
It looks like I'll need to implement Drop for StreamInner, but this needs to call audio_unit.stop first which leaves self.audio_unit for full drop later, and that also calls audio_unit.stop. I believe this works and is safe, but this feels awkard and I don't know this is safe to rely on audio_unit.stop being idempotent now and in the future.
or
struct StreamInner {
playing: bool,
audio_unit: ManuallyDrop<AudioUnit>,
}
This feels much better. Now in StreamInner::drop we can manually drop the audio_unit and then drop the duplex_callback_ptr.
I'm leaning toward ManuallyDrop, but I'm looking for feedback from folks that know better than I do (probably everyone here 🙂 )
There was a problem hiding this comment.
Per the above, I will try the ManuallyDrop approach and test. I think that's the best path forward.
| // roles — otherwise Core Audio won't re-route both directions. | ||
| let error_callback_for_stream: super::ErrorCallback = | ||
| if is_default_input_device(self) && is_default_output_device(self) { | ||
| Box::new(|_: StreamError| {}) |
There was a problem hiding this comment.
I think we shouldn't silently swallow disconnects, and propagate the error instead. A duplex stream is broken when either direction changes device.
src/host/coreaudio/macos/device.rs
Outdated
| /// indicate some kind of major bug or failure in the OS since callback_instant is derived | ||
| /// from host time. Still, I think the best practice is NOT to panic but tell the app | ||
| /// and fallback to a degraded latency estimate of no latency. | ||
| fn calculate_duplex_timestamps<E>( |
There was a problem hiding this comment.
This utility method could be a module-level function instead of on the struct.
|
@gulbrand checking in - what's your planning on this one? No pressure, just asking. |
I'll be able to focus on this PR this weekend. |
89c629b to
ca12b28
Compare
|
@roderickvd Thank you for your feedback! FYI: I squashed my branch. I had 40+ commits and rebasing was a pain. I finished a first pass at addressing your feedback--mostly focussing on the easy fixes 🤣 . I want to follow-up on:
I think those two steps would answer the remaining feedback comments and answer the question about what might move to coreaudio-rs. |
| println!("Building duplex stream with config: {config:?}"); | ||
|
|
||
| let stream = device.build_duplex_stream::<f32, _, _>( | ||
| &config, |
There was a problem hiding this comment.
Since it is copy able. So we do not need & I think
UPDATE on AI Usage
The Rust Audio AI policy has been updated and this PR is compliant with the policy.
Add synchronized duplex stream support
Summary
This PR introduces synchronized duplex streams to cpal, starting with CoreAudio support on macOS.
Development Note
Developed with assistance from Claude Code (Anthropic's AI coding assistant).
Motivation
Currently, applications requiring synchronized input/output (like DAWs, real-time effects, or audio analysis tools) must use separate input and output streams with ring buffers for synchronization. This approach:
Duplex streams solve this by using a single device context for both input and output, guaranteeing sample-accurate alignment at the hardware level.
API Overview
Potentially Breaking Changes
build_duplex_stream and build_duplex_stream_raw have been added to
DeviceTraitwith a default impl. This shouldn't break any existing implementations, but just calling this out.