Skip to content

ASIO capture: input ring overflow drops a raw float count → all channels permanently rotated #39

Description

@TommiIversen

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

  1. ASIO, EnableInput, N ≥ 2 channels, a distinct tone per physical channel.
  2. Start(), then wait ~500 ms before the first Receives() (simulates a busy startup).
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions