Skip to content

Duplex support#1096

Open
gulbrand wants to merge 4 commits intoRustAudio:masterfrom
gulbrand:duplex-support
Open

Duplex support#1096
gulbrand wants to merge 4 commits intoRustAudio:masterfrom
gulbrand:duplex-support

Conversation

@gulbrand
Copy link
Copy Markdown

@gulbrand gulbrand commented Jan 17, 2026

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:

  • Adds latency due to buffering
  • Requires manual synchronization logic
  • Can experience drift between input and output clocks
  • Is more complex to implement correctly

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

use cpal::duplex::DuplexStreamConfig;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};

let host = cpal::default_host();
let device = host.default_output_device()?;


let config = DuplexStreamConfig {
    input_channels: opt.input_channels,
    output_channels: opt.output_channels,
    sample_rate: opt.sample_rate,
    buffer_size: BufferSize::Fixed(opt.buffer_size),
};
let stream = device.build_duplex_stream::<f32, _, _>(
    &config,
    |input, output, info| {
        // Process audio with guaranteed synchronization
        output.copy_from_slice(input);
    },
    |err| eprintln!("Stream error: {}", err),
    None,
)?;

stream.play()?;

Potentially Breaking Changes

build_duplex_stream and build_duplex_stream_raw have been added to DeviceTrait with a default impl. This shouldn't break any existing implementations, but just calling this out.

@gulbrand gulbrand marked this pull request as ready for review January 17, 2026 20:02
@roderickvd
Copy link
Copy Markdown
Member

@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 build_duplex_stream analogous to the input and output stream build functions, but wonder if we need a separate DuplexStream or whether we could unify it with the existing Stream. I admit I've given the latter very little thought, so tell me if I'm missing something entirely obvious.

@gulbrand
Copy link
Copy Markdown
Author

@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 build_duplex_stream analogous to the input and output stream build functions, but wonder if we need a separate DuplexStream or whether we could unify it with the existing Stream. I admit I've given the latter very little thought, so tell me if I'm missing something entirely obvious.

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.

Copy link
Copy Markdown
Member

@roderickvd roderickvd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took a while but here's my first code review.

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))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use expect here as well?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to expect this is demo code so its ok either way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wrong in my initial comment. expect(&format!("{x}")) allocates eagerly. unwrap_or_else(|| panic!("{x}")) is better.

// 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| {})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we shouldn't silently swallow disconnects, and propagate the error instead. A duplex stream is broken when either direction changes device.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I lost track of this one, but I blieve this was about sending an error when the device disconnect. The DeviceManager is updated to include buffer change detection. Now that I am looking at all the early returns from the callback I wonder if we need to stop the callback and invoke the error callback. This means adding another channel to pass eror flags to the DeviceManager. Should we do that?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now done as per #1096 (review) right?

@roderickvd
Copy link
Copy Markdown
Member

@gulbrand checking in - what's your planning on this one? No pressure, just asking.

@gulbrand
Copy link
Copy Markdown
Author

gulbrand commented Mar 2, 2026

@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.

@gulbrand
Copy link
Copy Markdown
Author

@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:

  1. refactor the buffer pre-alloc/re-alloc so we don't have to allocate the max buffer size;
  2. refactor the code generally to try to put anything we might want in coreaudio-rs in its own file / set of files.

I think those two steps would answer the remaining feedback comments and answer the question about what might move to coreaudio-rs.

@gulbrand
Copy link
Copy Markdown
Author

I made some progress today answering the question of what would belong in coreaudio-rs vs cpal. I tried a couple of prototypes and there's one more I want to look at. I'll need another round on this one before I can form a helpful opinion.

On the allocation issue -- I will try to rework that so that the stream is invalidated with an error if coreaudio asks for a different buffer size during streaming. I looked at what a few pro audio apps do and they detect this and stop audio processing and some of them even ask the user what to do about it. This seems like the right approach here as well. I think that's going to be better than just blindly maxing out the allocation buffer.

@roderickvd thoughts on this approach?

The other change I want to make is I think this should be feature flagged behind os target macos. I haven't been able to test this anywhere else, like iOS etc. I won't be able to test those anytime soon either.

@roderickvd
Copy link
Copy Markdown
Member

On the allocation issue -- I will try to rework that so that the stream is invalidated with an error if coreaudio asks for a different buffer size during streaming. I looked at what a few pro audio apps do and they detect this and stop audio processing and some of them even ask the user what to do about it. This seems like the right approach here as well. I think that's going to be better than just blindly maxing out the allocation buffer.

Yes, I think that's better.

With regard to "asking what to do about it" - not sure if and how we could do that in cpal beyond firing the error callback with a specific error type. I'm planning on refactoring cpal errors into one std::io::Error kind of error type with a non-exhaustive list of error kinds, if that helps. I hope to put up that PR shortly.

@roderickvd roderickvd linked an issue Mar 17, 2026 that may be closed by this pull request
@roderickvd
Copy link
Copy Markdown
Member

@gulbrand I linked #349 but not yet #628 as the latter is specific to ALSA.

@gulbrand
Copy link
Copy Markdown
Author

gulbrand commented Mar 24, 2026

@roderickvd

Should we put this behind a feature 'duplex-macos' for now? I haven't tested this on iOS or anything else and I have no plans to do so right away. Nevermind, this is already behind macos

@gulbrand
Copy link
Copy Markdown
Author

@roderickvd I plan on an update ready for your review today/tomorrow. I'm really close, I just cleaning up comments now. The files have been separated and most of the comments are addressed either with a comment here or a code change.

As for what could move over to coreaudio-rs, that remains unclear to me how to best do that. The first challenge I ran into was the error handling. Keeping this code in cpal for now means we can control what to do on errors better. Moving that to coreaudio-rs may mean updating its error model.

With some work, I think it would be possible, but that would be more work over in coreaudio-rs. Looking at the changes that would go over to coreaudio-rs it isn't as much. Probably just the AudioUnit setup code to create the AudioUnits for the duplex callback.

I could be wrong, but that's my best read for now and my recommendation is to keep here. I'm biased toward minimal changes, and so my recommendation is based on minimizing what would need to change in coreaudio-rs--with the error model changing, that might be too much.

Do you think we should try to do it anyway?

@gulbrand
Copy link
Copy Markdown
Author

@roderickvd this is ready for another review. A few things to still highlight:

  1. The callback has a handful of early returns. As I looked at that again, I realized we might want to signal to the DeviceManager to close the stream as well as invoked_error_callback from the callback. This would mean plumbing another channel to the DeviceManager (I already added one for the buffer size change. Part of me thinks we should do this now, the other part thinks these are rare. I'm inclined to add this in now, but wanted to get your opinion first.
  2. Let me know if you think we should try to build this into coreaudio-rs first. The big hang-up there is going to be the error plumbing, that is in DeviceManager. My theory is that this could move to coreaudio-rs but the change would be larger than this to make sure error cases are properly handled.

Thoughts on how to proceed?

@Decodetalkers
Copy link
Copy Markdown
Contributor

I think maybe you need to do git rebase first, to resolve the conflicts

@gulbrand
Copy link
Copy Markdown
Author

gulbrand commented Apr 1, 2026

Conflicts resolved.

/// `Some(duration)` sets a maximum wait time. Not all backends support timeouts.
fn build_duplex_stream_raw<D, E>(
&self,
_config: &DuplexStreamConfig,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, we do not need & any more, it is copyable

Copy link
Copy Markdown
Member

@roderickvd roderickvd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is starting to look great. Almost ready to merge.


### Changed

- **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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This landed in the wrong section, probably due to rebasing.

You don't need to include "POTENTIALLY BREAKING" - SemVer will be bumped and this is listed under "Changed" so that's good enough for me.


// Timing information for a duplex callback, combining input and output timestamps.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct DuplexCallbackInfo {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these and other public types, please ensure that they have public Rustdoc.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module should probably have some short Rustdoc, as it's private. Compare with InputCallbackInfo/OutputCallbackInfo for tone and verbosity.

pub input_channels: ChannelCount,
pub output_channels: ChannelCount,
pub sample_rate: SampleRate,
pub buffer_size: crate::BufferSize,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: may be imported for consistency with the other types.

if listen_buffer_size {
let stream_weak_clone = stream_weak.clone();
let error_callback_clone = error_callback.clone();
std::thread::spawn(move || {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps capture the relevant discussion between @Decodetalkers and you earlier:

// thread handle intentionally dropped; same pattern as disconnect handler 

where
E: FnMut(StreamError) + Send,
{
callback_instant.sub(delay).unwrap_or_else(|| {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm soon going to merge #1139 which will change the StreamInstant API somewhat. Here's what you might then replace it with on CoreAudio: https://github.com/RustAudio/cpal/pull/1139/changes#diff-8e684b369b47b2adec5fdeb79b478a2533366a37c684eb9a1e686abed4939bc6

.lock()
.map_err(|_| BuildStreamError::BackendSpecific {
err: BackendSpecificError {
description: "A cpal stream operation panicked while holding the lock - this is a bug, please report it".to_string(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's been like this in other places, but I think that we should propagate poisoned mutexes. I'll probably submit a PR to do so in other places too.

## Examples

CPAL comes with several examples in `examples/`.
CPAL comes with several examples in `examples/`, including `duplex_feedback` for hardware-synchronized duplex streams.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to be so specific.

@roderickvd
Copy link
Copy Markdown
Member

roderickvd commented Apr 5, 2026

  1. The callback has a handful of early returns. As I looked at that again, I realized we might want to signal to the DeviceManager to close the stream as well as invoked_error_callback from the callback. This would mean plumbing another channel to the DeviceManager (I already added one for the buffer size change. Part of me thinks we should do this now, the other part thinks these are rare. I'm inclined to add this in now, but wanted to get your opinion first.

That sounds nice. Would it need another channel or could we use a single channel and send an enum over it with various payload?

  1. Let me know if you think we should try to build this into coreaudio-rs first. The big hang-up there is going to be the error plumbing, that is in DeviceManager. My theory is that this could move to coreaudio-rs but the change would be larger than this to make sure error cases are properly handled.

As you wish, though I'd be fine by doing it in cpal now and extracting it into coreaudio-rs later, should you or others feel so inclined.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duplex Stream Support

5 participants