Skip to content

Facade: let the app supply program audio (ProgramAudioSink) - #2

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

Facade: let the app supply program audio (ProgramAudioSink)#2
ustacode merged 1 commit into
mainfrom
feature/program-audio

Conversation

@ustacode

@ustacode ustacode commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Foundation for mixing alert/TTS audio into the broadcast.

Why the app has to do the mixing. The vendored audio path selects a source and never mixes — AudioUnit.captureOutput hands one buffer to the encoder, and selecting a buffered source makes the mic path return outright. There is no seam inside the engine where a second audio stream could join, so "streamer's voice and TTS" is not expressible there. Rather than fork the vendor for a mixing stage, the app — which already owns speech synthesis, alert sounds and the captions tap — mixes and pushes the result in.

Surface: ProgramAudioSink (startProgramAudio(targetLatency:) / appendProgramAudio(_:) / stopProgramAudio() / isProgramAudioActive), adopted by IRLStreamEngine and FakeStreamEngine. Deliberately not on StreamEngine: it trades in CMSampleBuffer, and that protocol stays media-type-free so a fake needs no hardware — same reasoning as CameraPreviewSource.

Two traps handled:

  • the takeover is re-established after every setNetStream rebuild (alongside the mute re-apply) — otherwise go-live silently reverts the broadcast to bare mic;
  • releasing re-attaches the default audio device rather than passing a nil buffered id, which would attach nothing and leave the stream silent.

Tests: 37 / 9 suites green on iPhone 17 Pro sim; check-containment.sh clean.

🤖 Generated with Claude Code

The vendored audio path SELECTS a source, it never mixes: AudioUnit's
captureOutput takes one buffer straight to the encoder, and selecting a
buffered source makes the microphone path bail out entirely. So "the
streamer's voice AND the alert/TTS audio" cannot be produced inside the
engine — there is nowhere for a second stream to join.

Rather than fork the vendor to add a mixing stage, hand the job to the app,
which is where the other audio already lives (speech synthesis, alert sounds,
the captions tap): it owns one graph, mixes what belongs on the broadcast, and
pushes the result through the new ProgramAudioSink seam. The engine treats it
as the program source and encodes it.

Kept off the StreamEngine protocol on purpose — it trades in CMSampleBuffer,
and that protocol stays free of media types so a fake needs zero hardware.
Same reasoning as CameraPreviewSource.

The takeover is re-established after every setNetStream rebuild, alongside the
mute re-apply: without that, go-live silently reverts the broadcast to bare
mic. Releasing it re-attaches the default audio device rather than passing a
nil buffered id, which would attach nothing and leave the stream silent.

Tests: fake conformance records the takeover and buffer counts; 37/9 green on
the simulator, containment clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ustacode
ustacode merged commit 8834650 into main Aug 2, 2026
1 check passed
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