Skip to content

Duplex support#1096

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

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

Conversation

@gulbrand
Copy link

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


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

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

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

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

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

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

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

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

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

@roderickvd
Copy link
Member

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

@gulbrand
Copy link
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
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
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

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.

5 participants