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.