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.

// 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 =
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'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?

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

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.

Disconnect propagates DeviceNotAvailable via DisconnectManager; the stream is paused and the error callback is invoked.

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.


/// Number of input channels
#[arg(long, value_name = "CHANNELS", default_value_t = 2)]
input_channels: u16,
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.

Please use type aliases like ChannelCount and SampleRate.

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.

Now uses ChannelCount, SampleRate, and FrameCount type aliases.


/// Buffer size in frames
#[arg(short, long, value_name = "FRAMES", default_value_t = 512)]
buffer_size: u32,
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.

Idea: make it Option<usize> and then have it use either BufferSize::Default or Fixed.

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.

Buffer size is now Option<FrameCount>, mapped to BufferSize::Fixed or BufferSize::Default as the default.

let host = cpal::default_host();

// Find the device by device ID or use default
let device = if let Some(device_id_str) = opt.device {
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.

match seems more idiomatic here.

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.

using match.

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.

let stream = device.build_duplex_stream::<f32, _, _>(
&config,
move |input, output, _info| {
output.fill(0.0);
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.

Better to use Sample::EQUILIBRIUM instead of 0.0.

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.

yep, using Sample::EQUILIBRIUM and I also updated other locations as well.

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

Let's drop the "macOS only" part: we'll forget when we add duplex support for other hosts.

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.

Will be fixed in next commit

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.

done

/// 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(
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.

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
        }
    }
}

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.

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.

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.

done. I understand this better, but to be totally transparent, I do not have any experience with this part of rust and FFI so I need to lean on your advice here. that said, this does make sense.

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

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.

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.

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 🙂 )

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.

Per the above, I will try the ManuallyDrop approach and test. I think that's the best path forward.

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.

ManuallyDrop worked. I recommend we go with this. One thing I noticed: this still relies on the audio_unit self stopping. That could change in the future, however likely or unlikely that may be, and if it does, boom. That will be a panic or UB I think. I do not see a clear win here, we either rely on stop being idempotent or rely on Drop stopping the audio unit.

// 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?

/// 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>(
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 utility method could be a module-level function instead of on the struct.

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.

added two module-level utility methods : estimate_capture_isntant and estimate_playback_instant . all streams use these.

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

println!("Building duplex stream with config: {config:?}");

let stream = device.build_duplex_stream::<f32, _, _>(
&config,
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.

Since it is copy able. So we do not need & I think

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.

True, it probably should be Copy. but the current interface for input/output streams are not, even though they can be Copy today. I would imagine that this would always be Copy able, but I'm inclined to keep the interface the same for now.

TBH, I'm also trying to keep scope as limited as possible for this change. I'm happy to take on more work around these kinds of things, but this being not Copy isn't a correctness issue and changing it to Copy opens scope to changing the other Streams and their builder functions. I'd rather stick to a smaller scope to get this wrapped up.

@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

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