7
44
Fork
You've already forked nice-plug
22

Fix input deinterleaving + rate mismatch and shutdown deadlock in standalone CPAL backend #23

Closed
auejin wants to merge 1 commit from auejin/nice-plug:fix/cpal-standalone-rate-mismatch-and-shutdown-deadlock into main
pull from: auejin/nice-plug:fix/cpal-standalone-rate-mismatch-and-shutdown-deadlock
merge into: RustAudio:main
RustAudio:main
RustAudio:resizing
RustAudio:dev
RustAudio:standalone_fixes
RustAudio:FoobarIT/main
RustAudio:fixes
RustAudio:egui_3rd_party
RustAudio:vizia_baseview_update
RustAudio:egui_32
RustAudio:softbuffer
RustAudio:byo_gui_examples
RustAudio:raw_graphics_examples
First-time contributor
Copy link

Summary

Fix two distinct bugs in the standalone CPAL backend that together cause audible echo on asymmetric audio layouts (e.g. mono input / stereo output) and a hang at shutdown.

Bug 1: Rate mismatch + broken deinterleaving in input fill

build_output_data_callback fills main_io_storage (size num_output_channels ×ばつ buffer_size) by sequentially popping from the input ring buffer:

forchannelinmain_io_storage.iter_mut(){forsampleinchannel{loop{ifletOk(s)=input_rb_consumer.pop(){*sample=s;break;}}}}

This has two problems:

  • It pops num_output_channels ×ばつ buffer_size samples per output callback, but the input callback only pushes num_input_channels ×ばつ buffer_size. When num_input_channels < num_output_channels (e.g. mono mic into a stereo layout — extremely common on macOS where built-in mics are mono) the output callback consumes input twice as fast as it arrives. The output device starves, CoreAudio fills the gap by repeating the previous buffer, and the result sounds like a continuous echo of whatever was last spoken.
  • CPAL delivers input as interleaved frames ([L0, R0, L1, R1, ...]), but the fill loop iterates channel-major. With stereo input, main_io_storage[0] ends up containing [L0, R0, L1, R1, ..., L511, R511] and [1] contains [L512, R512, ...] — both channels mixed together.

The fix pops exactly num_input_channels ×ばつ buffer_size samples per callback in frame-major order, then zero-fills any excess output channels:

fornin0..buffer_size{forchin0..num_input_channels{loop{/* ... */main_io_storage[ch][n]=sample;break;/* ... */}}}forchannelinmain_io_storage.iter_mut().skip(num_input_channels){channel.fill(0.0);}

Bug 2: Shutdown deadlock between input/output callback spin loops

After parker.park() returns in run(), the scope ends and locals drop in reverse declaration order: output_stream first, then _input_stream. Once the output stream is gone, nobody drains the ring buffer. The input callback still fires until its stream drops, and hits this spin in build_input_data_callback:

whileinput_rb_producer.push(sample.to_sample()).is_err(){}

With the RB full and no consumer, this spins forever. Stream::drop on the input then blocks waiting for the in-flight callback that will never return. The whole process hangs.

The output fill's loop { if pop succeeds break } has the symmetric problem if it were ever called after the input stream is gone.

The fix: an Arc<AtomicBool> shared with both callbacks, set immediately after parker.park() returns. Both spin loops check it and bail out (input drops the sample, output zero-fills the rest of the buffer) so the streams can drop promptly.

Reproduction

Set up a plugin with an asymmetric layout (e.g. main_input_channels = 1, main_output_channels = 2) and run the standalone with a mono input device. Voice/input audio plays as a continuous echo. Close the window → process hangs indefinitely.

With this patch: clean passthrough, prompt shutdown.

## Summary Fix two distinct bugs in the standalone CPAL backend that together cause audible echo on asymmetric audio layouts (e.g. mono input / stereo output) and a hang at shutdown. ## Bug 1: Rate mismatch + broken deinterleaving in input fill `build_output_data_callback` fills `main_io_storage` (size `num_output_channels ×ばつ buffer_size`) by sequentially popping from the input ring buffer: ```rust for channel in main_io_storage.iter_mut() { for sample in channel { loop { if let Ok(s) = input_rb_consumer.pop() { *sample = s; break; } } } } ``` This has two problems: - It pops **`num_output_channels ×ばつ buffer_size`** samples per output callback, but the input callback only pushes **`num_input_channels ×ばつ buffer_size`**. When `num_input_channels < num_output_channels` (e.g. mono mic into a stereo layout — extremely common on macOS where built-in mics are mono) the output callback consumes input twice as fast as it arrives. The output device starves, CoreAudio fills the gap by repeating the previous buffer, and the result sounds like a continuous echo of whatever was last spoken. - CPAL delivers input as interleaved frames (`[L0, R0, L1, R1, ...]`), but the fill loop iterates channel-major. With stereo input, `main_io_storage[0]` ends up containing `[L0, R0, L1, R1, ..., L511, R511]` and `[1]` contains `[L512, R512, ...]` — both channels mixed together. The fix pops exactly `num_input_channels ×ばつ buffer_size` samples per callback in frame-major order, then zero-fills any excess output channels: ```rust for n in 0..buffer_size { for ch in 0..num_input_channels { loop { /* ... */ main_io_storage[ch][n] = sample; break; /* ... */ } } } for channel in main_io_storage.iter_mut().skip(num_input_channels) { channel.fill(0.0); } ``` ## Bug 2: Shutdown deadlock between input/output callback spin loops After `parker.park()` returns in `run()`, the scope ends and locals drop in reverse declaration order: `output_stream` first, then `_input_stream`. Once the output stream is gone, nobody drains the ring buffer. The input callback still fires until its stream drops, and hits this spin in `build_input_data_callback`: ```rust while input_rb_producer.push(sample.to_sample()).is_err() {} ``` With the RB full and no consumer, this spins forever. `Stream::drop` on the input then blocks waiting for the in-flight callback that will never return. The whole process hangs. The output fill's `loop { if pop succeeds break }` has the symmetric problem if it were ever called after the input stream is gone. The fix: an `Arc<AtomicBool>` shared with both callbacks, set immediately after `parker.park()` returns. Both spin loops check it and bail out (input drops the sample, output zero-fills the rest of the buffer) so the streams can drop promptly. ## Reproduction Set up a plugin with an asymmetric layout (e.g. `main_input_channels = 1, main_output_channels = 2`) and run the standalone with a mono input device. Voice/input audio plays as a continuous echo. Close the window → process hangs indefinitely. With this patch: clean passthrough, prompt shutdown.

Hmm, pushing samples into a ring buffer one sample at a time is very inefficient anyway. I might just redo this using some of the code from my Firewheel audio engine: https://github.com/BillyDM/Firewheel/blob/main/crates/firewheel-cpal/src/lib.rs

Hmm, pushing samples into a ring buffer one sample at a time is very inefficient anyway. I might just redo this using some of the code from my Firewheel audio engine: <https://github.com/BillyDM/Firewheel/blob/main/crates/firewheel-cpal/src/lib.rs>
Author
First-time contributor
Copy link

@BillyDM wrote in BillyDM/nice-plug#23 (comment):

Hmm, pushing samples into a ring buffer one sample at a time is very inefficient anyway. I might just redo this using some of the code from my Firewheel audio engine: https://github.com/BillyDM/Firewheel/blob/main/crates/firewheel-cpal/src/lib.rs

Sounds good! Please let me know when you push the fix, and I will close this PR.

@BillyDM wrote in https://codeberg.org/BillyDM/nice-plug/pulls/23#issuecomment-15688619: > Hmm, pushing samples into a ring buffer one sample at a time is very inefficient anyway. I might just redo this using some of the code from my Firewheel audio engine: https://github.com/BillyDM/Firewheel/blob/main/crates/firewheel-cpal/src/lib.rs Sounds good! Please let me know when you push the fix, and I will close this PR.
Contributor
Copy link

@BillyDM wrote in #23 (comment):

Hmm, pushing samples into a ring buffer one sample at a time is very inefficient anyway.

One could use https://docs.rs/rtrb/latest/rtrb/struct.Producer.html#method.push_entire_slice instead, for a relatively small refactor.

@BillyDM wrote in https://codeberg.org/RustAudio/nice-plug/pulls/23#issuecomment-15688619: > Hmm, pushing samples into a ring buffer one sample at a time is very inefficient anyway. One could use https://docs.rs/rtrb/latest/rtrb/struct.Producer.html#method.push_entire_slice instead, for a relatively small refactor.

Oh interesting. If you want to add that, then I can merge this PR.

I might rework it to use code from Firewheel in the future, but it's not a guarantee. It's just whether I find the time and the inclination to do it.

Oh interesting. If you want to add that, then I can merge this PR. I *might* rework it to use code from Firewheel in the future, but it's not a guarantee. It's just whether I find the time and the inclination to do it.

I made a new PR with my fixes in #34. Let me know if it is working for you!

I made a new PR with my fixes in #34. Let me know if it is working for you!

Fixed in 3fe7775a37

Fixed in 3fe7775a37
BillyDM closed this pull request 2026年06月02日 21:11:40 +02:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.
No reviewers
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
RustAudio/nice-plug!23
Reference in a new issue
RustAudio/nice-plug
No description provided.
Delete branch "auejin/nice-plug:fix/cpal-standalone-rate-mismatch-and-shutdown-deadlock"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?