Skip to content

Make the program-audio append path nonisolated - #3

Merged
ustacode merged 1 commit into
mainfrom
feature/program-audio-thread
Aug 2, 2026
Merged

Make the program-audio append path nonisolated#3
ustacode merged 1 commit into
mainfrom
feature/program-audio-thread

Conversation

@ustacode

@ustacode ustacode commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Why

ProgramAudioSink.appendProgramAudio was @MainActor. The consumer calls it once per hardware buffer from an AVAudioEngine tap — roughly 47 times a second — so every buffer needed a Task { @MainActor } hop. That is wrong twice over:

  • Ordering. Task { @MainActor } closures are not guaranteed to complete in submission order. Reordered audio buffers are audible.
  • Latency. The main actor is also driving SwiftUI. Audio queued behind view updates is jitter added on the way to the encoder.

What changed

appendProgramAudio is nonisolated; startProgramAudio/stopProgramAudio/isProgramAudioActive stay @MainActor (they are control-plane, called once). ProgramAudioSink picks up Sendable.

IRLStreamEngine keeps the buffered-source id and its Media reference in an OSAllocatedUnfairLock<ProgramAudioTarget?>, so the audio thread reads neither programAudio nor media — both are main-actor state. Downstream, AudioUnit.appendBufferedAudioSampleBuffer dispatches onto the serial processorPipelineQueue, so submission order is what survives.

FakeStreamEngine gets the same shape (locked counter, nonisolated append) rather than a main-actor-only convenience — a fake that only works from the main actor would hide exactly the bug it exists to catch.

Verification

  • 37 tests in 9 suites pass on iOS Simulator
  • Scripts/check-containment.sh clean

🤖 Generated with Claude Code

Feeding the encoder had to hop to the main actor for every hardware
buffer, which is wrong twice over. Ordering: `Task { @mainactor }`
closures complete in no guaranteed order, so ~47 buffers/sec could land
out of sequence — and reordered audio buffers are audible. Latency: the
main actor is also driving SwiftUI, so audio queued behind view updates.

`appendProgramAudio` is now `nonisolated`, callable straight from the
audio thread; downstream it lands on the vendor's serial pipeline queue,
which preserves submission order. The engine keeps the buffered-source id
and its `Media` reference in an `OSAllocatedUnfairLock` so the audio
thread never touches main-actor state.

The fake gets the same shape rather than a main-actor-only convenience —
a fake that only works when called from the main actor would hide exactly
the threading bug it exists to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ustacode
ustacode merged commit acdfe6f into main Aug 2, 2026
1 check passed
@ustacode
ustacode deleted the feature/program-audio-thread branch August 2, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant