-
Notifications
You must be signed in to change notification settings - Fork 474
Duplex support #1096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Duplex support #1096
Conversation
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<T>(): 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
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.
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.
- Add duplex stream entries to CHANGELOG - Fix clippy useless_conversion warning in duplex_feedback example - Run rustfmt
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.
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
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.
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.
Added explicit BREAKING notice for DeviceTrait changes. External implementations of DeviceTrait must now provide DuplexStream type and build_duplex_stream_raw() method.
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.
Add missing StreamTrait implementation for iOS backend's DuplexStream wrapper. This fixes cross-compilation to aarch64-apple-ios target.
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/duplex.rs
Outdated
| use crate::{PauseStreamError, PlayStreamError, SampleRate, StreamInstant}; | ||
|
|
||
| /// Hardware timestamp information from the audio device. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there something inherent about supporting duplex streams that makes AudioTimestamp required? Or is this something that would also be useful for normal streams?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No I don't think the duplex stream case has a unique or special need for AudioTimestamp (one caveat below). I think it would be helpful for input and output streams as well. But, adding support to input and output streams might be a breaking change? (I'm unsure).
I added it to support sample accurate operations in the application. For example, a DAW with automation events on nodes in a graph needs to know with high degrees of accuracy the sample position as a reference point for position and offset calculations for ramp ups, ramp downs, parameter automation, etc.
I believe it is technically possible to implement those without the hardware clock derived sample offset, but I think the edge case count increases when not doing so.
The only caveat I can think of for duplex is during simultaneous playback and recording in a DAW where its far more convenient to the user for the software to calculate latencies and offsets and adjust the final position of the recorded audio on the timeline. The math is precise (nearly perfectly so) with hardware clock sample offset rather than application-based timing calculations. Not critical though, just far simpler and more precise.
If removing this would be more consistent with the existing API would help, I can do that and then perhaps this concept could be introduced across all APIs in a future PR.
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My opinion here shouldn't have a huge amount of weight since I'm pretty new to this community. But I'll give it anyway :)
For something like this that seems to have had several attempts over the years, I think it might be worth figuring out what the minimum surface area would be for the feature and try and get that supported for all the target platforms.
I haven't looked closely enough to figure out if AudioTimestamp is something that can only be obtained from host-specific data? If it's something that could be entirely derived from information that's already in OutputCallbackInfo then it seems like a no-brainer to remove it for now.
If it is an abstraction that can be provided by all hosts, and would be useful for simplex as well as duplex, then IMO it'd be good to add it to both in one go. I'm not sure what the bar is for introducing breaking changes.
Similar to the concern with breaking changes, I'd suggest having proofs of concept, if not full implementations, for all platforms so we can be confident that the proposed API can work everywhere and won't need to be revised as soon as, say, Windows support is added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the feedback. This is helpful.
If I'm reading the feedback correctly, then if I remove the AudioTimestamp change from this, it greatly simplifies the scope since we don't need to worry about testing all the different platforms and host implementations for compatibility with the AudioTimestamp interface. Doing all of that work would be time consuming and is arguably orthogonal to adding duplex support to one platform / host.
Am I understanding the feedback correctly? (If so, I agree and I will remove AudioTimestamp)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's an accurate summary of my feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great, thanks, I'll remove AudioTimestamp
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR is updated to remove AudioTimestamp. Any concerns with resolving this conversation?
src/host/coreaudio/macos/mod.rs
Outdated
| fn play(&mut self) -> Result<(), PlayStreamError> { | ||
| if !self.playing { | ||
| if let Err(e) = self.audio_unit.start() { | ||
| let description = format!("{e}"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this line can just be e.to_string()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I changed the rest of them as well. Thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR is updated to change these to to_string. Thank you! Any concerns if I resolve this?
src/host/coreaudio/macos/mod.rs
Outdated
| let (ready_tx, ready_rx) = mpsc::channel(); | ||
|
|
||
| let disconnect_tx_clone = disconnect_tx.clone(); | ||
| std::thread::spawn(move || { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe we also need to collect the thread handler?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, good catch. I need to think about this one a bit more, but I think your suggestion is correct because the thread should be joined during drop and without the handle, drop can't join.
I need to make sure I understand how this works. I'll update this comment when I'm more confident on this.
Thanks for pointing this out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for your feedback!
I dug deeper into this and I have a weakly held opinion that this is fine as-is.
I reviewed the threads and their terminating conditions and to me it looks like the shutdown_tx value does a good enough job of making sure the thread terminates after the StreamInner is dropped.
This thread's sole job is to make sure the property_address value is dropped in the same thread that it is created in ... The CoreAudio docs are not clear to me that this is indeed 100% required, but it is safer to keep the allocation and the drop on the same thread just in case.
That said, I'm working on getting rid of DuplexStream entirely so that the duplex code paths can work with Stream and StreamInner directly. If that works, then this extra code for DuplexDisconnectManager will be deleted and the implementations will be unified to the DisconnectManager.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In terms of the join, I think a case could be made to change this code to allow for a join, but I'm not convinced it is necessary for this to be correct. The lifetimes do appear to be correct and the only benefit of joining would be to catch an error.
I agree there is a case to be made for that, but I'd like to try to keep that change and discussion separate from this PR. Is that a reasonable approach?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've updated the PR to reuse the existing DisconnectManager. You're concern is still valid, but I think it should be addressed outside of this PR as it is an existing issue. I think this is ok though. Any concerns if I resolve this?
src/host/coreaudio/macos/mod.rs
Outdated
| impl DuplexDisconnectManager { | ||
| fn new( | ||
| device_id: AudioDeviceID, | ||
| stream_weak: Weak<Mutex<DuplexStreamInner>>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what is the meaning of Weak<Mutex>? I cannot understand
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe there the purposes here are:
- Allow the StreamInner to be dropped regardless of the DisconnectManager's state. Using an Arc means the DisconnectManager will block dropping until it is ready to drop.
- Additionally this is also the key indication to the thread that is handling disconnect events to exit its while loop.
See the else block here:
If the upgrade from Weak to Arc fails, it means the stream has been dropped and this thread should exit immediately.
std::thread::spawn(move || {
while disconnect_rx.recv().is_ok() {
// Check if stream still exists
if let Some(stream_arc) = stream_weak_clone.upgrade() {
// First, try to pause the stream to stop playback
if let Ok(mut stream_inner) = stream_arc.try_lock() {
let _ = stream_inner.pause();
}
// Always try to notify about device disconnection
invoke_error_callback(
&error_callback_clone,
crate::StreamError::DeviceNotAvailable,
);
} else {
// Stream is gone, exit the handler thread
break;
}
}
});All of that said, and like I mentioned in the other comment, I am working on removing DuplexStream and DuplexStreamInner which would unify this change onto the existing Stream types and also reuse the existing DisconnectManager. At that point, this area would seem to me to be out of scope for this PR.
Any thoughts on this I should consider?
Thank you!
src/duplex.rs
Outdated
| /// Backend implementations should replace this with their own type once | ||
| /// duplex support is implemented. | ||
| pub struct UnsupportedDuplexStream { | ||
| _private: (), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not know why we need a _private here.. And maybe here just use #[derive(Default)] will be ok
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think your suggestion is correct. I am not sure but I think this was added in anticipation of this maybe, possibly, needing an additional field for one of the backends. But now that I've implemented the necessary traits for each backend, it is clear this is not needed. I'll remove this and make this a unit struct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've updated to PR to not even have this type anymore and to just use the StreamConfigNotSupported error instead.
Any concerns if I resolve this? Thank you!
…o unit struct rather than making it defensive for future field addition
|
@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 |
…or 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.
…s point, the default impl for build_duplex_stream_raw should mean this is not a breaking change, but just calling this out
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. |
UPDATE on AI Usage
I've since reviewed the Rust Audio AI policy and want to be fully transparent: the implementation in this PR was generated by Claude Code under my direction and design. While I provided the architectural decisions, API design, and iterative feedback throughout development, the code itself was AI-generated rather than human-written with AI review.
I understand this likely falls outside the policy's acceptable use guidelines, regardless of the level of human oversight involved. I respect the community's concerns around maintainer burden and copyright, and I'll defer to the maintainers on how to proceed.
If this work is useful as a reference or design proposal, I'm happy for it to serve that purpose. If the preference is to close the PR, I completely understand.
Add synchronized duplex stream support
Summary
This PR introduces synchronized duplex streams to cpal, starting with CoreAudio support on macOS. Duplex streams provide hardware-level clock synchronization between audio input and output, eliminating the need for ring buffers and manual latency management and synchoronization between separate audio callbacks (one for input and one for output). With this change, one callback handles both input and output.
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
Example Comparison
See examples/duplex_feedback.rs for a complete example. Compare with examples/feedback.rs to see the simplification:
Before (separate streams):
After (duplex stream):
Current Status
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.Future Work
I have a lab setup ready to implement Windows and Linux support once the CoreAudio implementation and API design are reviewed and approved.
Feedback Requested
While I have some audio/DSP programming experience, I'm relatively new to this domain. I'd especially appreciate feedback on:
Testing
All existing tests pass. New tests added: