Hi ModernMube,
Following up on #37 — multichannel ASIO capture has been running nicely in my recording
setup on the 4.0 release.
I've now hit one subtle thing in production that I think I've tracked down to the input ring buffer.
Environment
- OwnAudioSharp 4.0.1 (NuGet), .NET 10, Windows 11 (x64)
- RME UFX III, 12-channel ASIO capture at 48 kHz / F32
What I saw
After a busy process start, the whole capture was shifted by one channel: the audio coming
out on slot n was actually physical input n+1 (a mic patched to input 10 showed up on
input 9's slot, the last slot went silent). It stays that way until the stream is
restarted. Nothing reports an error — Receives() keeps returning healthy counts, so from
the consumer side it's undetectable.
What I think happens (reading the 4.0 source)
If I'm reading RustAudioEngine right:
_inputCallback does _inputRing?.Write(args.Buffer) and ignores the return value —
the comment says "on overflow we drop rather than block the audio thread", which is a
sound policy in itself.
LockFreeRingBuffer<T>.Write copies Math.Min(data.Length, WritableCount) floats and
silently discards the rest. The ring has no concept of frames, and WritableCount is
capacity - Available - 1 (one slot burned to tell full from empty), so the count that
fits is almost never a whole number of frames.
- So a single overflow where
dropped % channels != 0 leaves a mid-frame gap in the
stream, and any consumer slicing fixed-size interleaved blocks out of Receives() is
rotated from that point on — permanently and silently.
The trigger is just the consumer stalling longer than the ring depth (~8 engine buffers).
The window between Start() and the first Receives() call on a loaded machine is
already enough — that's exactly my startup case.
Repro sketch
- ASIO,
EnableInput, N ≥ 2 channels, a distinct tone per physical channel.
Start(), then wait ~500 ms before the first Receives() (simulates a busy startup).
- Consume fixed-size interleaved blocks: the tones sit on rotated slots until restart.
The output side has the mirrored hazard
Send itself looks safe (it blocks and retries partial writes), but _outputCallback
does _outputRing?.Read(args.Buffer): if Available isn't frame-aligned at callback time
(an underrun racing a partial producer commit), the read ends mid-frame and the
continuation lands on channel 0 of the next period's buffer — the same rotation, on
render. The trigger window is much narrower, but it's the same class of defect.
One possible fix
From the outside it looks like the lock-free design and the drop-on-overflow policy could
both stay as they are — one idea would be to clamp the two RT call sites to whole frames:
- Input: on overflow, round the written count down to a multiple of the channel count
(dropping audio on overflow seems fine and unavoidable; it's only the alignment loss
that hurts).
- Output: round the callback's read down to a multiple of the channel count and leave
the partial frame in the ring for the next period.
- And maybe a dropped-frames counter surfaced through the engine, so hosts can report
overflow instead of guessing.
If the callbacks clamp, the ring itself could stay frame-agnostic — but there may well be
a better place to solve this that I'm not seeing.
I've worked around it on my side for now (a dedicated drain thread started right after
Start(), feeding a frame-aligned buffer), so no urgency — but the in-engine fix would
make the capture path safe by construction. Happy to submit a PR for this if you'd take
it.
Cheers,
Tommi
Hi ModernMube,
Following up on #37 — multichannel ASIO capture has been running nicely in my recording
setup on the 4.0 release.
I've now hit one subtle thing in production that I think I've tracked down to the input ring buffer.
Environment
What I saw
After a busy process start, the whole capture was shifted by one channel: the audio coming
out on slot n was actually physical input n+1 (a mic patched to input 10 showed up on
input 9's slot, the last slot went silent). It stays that way until the stream is
restarted. Nothing reports an error —
Receives()keeps returning healthy counts, so fromthe consumer side it's undetectable.
What I think happens (reading the 4.0 source)
If I'm reading
RustAudioEngineright:_inputCallbackdoes_inputRing?.Write(args.Buffer)and ignores the return value —the comment says "on overflow we drop rather than block the audio thread", which is a
sound policy in itself.
LockFreeRingBuffer<T>.WritecopiesMath.Min(data.Length, WritableCount)floats andsilently discards the rest. The ring has no concept of frames, and
WritableCountiscapacity - Available - 1(one slot burned to tell full from empty), so the count thatfits is almost never a whole number of frames.
dropped % channels != 0leaves a mid-frame gap in thestream, and any consumer slicing fixed-size interleaved blocks out of
Receives()isrotated from that point on — permanently and silently.
The trigger is just the consumer stalling longer than the ring depth (~8 engine buffers).
The window between
Start()and the firstReceives()call on a loaded machine isalready enough — that's exactly my startup case.
Repro sketch
EnableInput, N ≥ 2 channels, a distinct tone per physical channel.Start(), then wait ~500 ms before the firstReceives()(simulates a busy startup).The output side has the mirrored hazard
Senditself looks safe (it blocks and retries partial writes), but_outputCallbackdoes
_outputRing?.Read(args.Buffer): ifAvailableisn't frame-aligned at callback time(an underrun racing a partial producer commit), the read ends mid-frame and the
continuation lands on channel 0 of the next period's buffer — the same rotation, on
render. The trigger window is much narrower, but it's the same class of defect.
One possible fix
From the outside it looks like the lock-free design and the drop-on-overflow policy could
both stay as they are — one idea would be to clamp the two RT call sites to whole frames:
(dropping audio on overflow seems fine and unavoidable; it's only the alignment loss
that hurts).
the partial frame in the ring for the next period.
overflow instead of guessing.
If the callbacks clamp, the ring itself could stay frame-agnostic — but there may well be
a better place to solve this that I'm not seeing.
I've worked around it on my side for now (a dedicated drain thread started right after
Start(), feeding a frame-aligned buffer), so no urgency — but the in-engine fix wouldmake the capture path safe by construction. Happy to submit a PR for this if you'd take
it.
Cheers,
Tommi