perf(nests): bound encoder CSD loop, single-alloc audio framing, cache catalog JSON

Three audit follow-ups, all in the audio publish hot path:

Audit-3: MediaCodecOpusEncoder.encode could busy-loop on a buggy
encoder that emits BUFFER_FLAG_CODEC_CONFIG on every dequeue. The
existing format-change path has a one-shot `formatChangeAbsorbed`
guard for exactly this reason; the new CSD-skip path didn't. Cap
consecutive CSD skips per encode call at MAX_CSD_SKIPS_PER_CALL=4
(generous: Codec2 emits 1 OpusHead or 2 OpusHead+OpusTags in
practice). On overshoot, log a warning and return ByteArray(0) —
the broadcaster's `if (opus.isEmpty()) continue` already handles
that contract.

Audit-6: NestMoqLiteBroadcaster's per-frame send path allocated
twice per Opus packet — once for the timestamp Varint and once
for the concatenated `varint+opus` payload. At 50 fps × N
speakers that's measurable young-gen pressure. Switch to the
same single-allocation pattern PublisherStateImpl.send already
uses: compute Varint.size(timestampUs), allocate the final
payload buffer once, write the varint directly into it via
Varint.writeTo, then copy the opus bytes. Drops audio-thread
allocations from 3/frame to 1/frame (the third was the wrap in
PublisherStateImpl.send, already optimised).

Audit-7: MoqLiteNestsSpeaker.startBroadcasting and
ReconnectingNestsSpeaker.runHotSwapIteration both encode the same
fixed catalog JSON on every call — once per broadcast start and
once per JWT-refresh hot-swap iteration. Cache the encoded bytes
on MoqLiteHangCatalog.Companion.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES
so the kotlinx.serialization run happens at class-init time and
both call sites read a static reference. Trivial perf, but
co-locates the "default Amethyst publish shape" in one place.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
Claude
2026-05-06 18:37:19 +00:00
parent ade8da3b5b
commit a94d12638f
5 changed files with 68 additions and 8 deletions
@@ -91,6 +91,13 @@ class MediaCodecOpusEncoder(
// without producing output, which would otherwise busy-spin at
// 100 Hz against the 10 ms dequeue timeout).
var formatChangeAbsorbed = false
// CSD-skip iteration cap. Codec2 stacks have been observed to
// emit OpusHead AND OpusTags as separate CSD buffers; some
// future stack might emit more. Cap the per-call CSD-skip
// count so a buggy encoder that emits CSD on every dequeue
// can't busy-loop the per-frame budget. 4 is generous: the
// expected case is 1 (OpusHead) or 2 (OpusHead + OpusTags).
var csdSkipsThisCall = 0
while (true) {
val outputIndex = codec.dequeueOutputBuffer(bufferInfo, DEQUEUE_TIMEOUT_US)
when {
@@ -119,6 +126,20 @@ class MediaCodecOpusEncoder(
"MediaCodecOpusEncoder skipped ${bufferInfo.size}-byte CODEC_CONFIG (OpusHead/OpusTags) — not an audio frame"
}
}
csdSkipsThisCall += 1
if (csdSkipsThisCall >= MAX_CSD_SKIPS_PER_CALL) {
// A buggy encoder is emitting CSD on every
// dequeue; bail this call rather than
// busy-looping the per-frame budget.
// Returning empty is the existing "warmup"
// contract — broadcaster's
// `if (opus.isEmpty()) continue` handles it
// and the next encode call retries.
com.vitorpamplona.quartz.utils.Log.w("NestTx") {
"MediaCodecOpusEncoder hit MAX_CSD_SKIPS_PER_CALL=$MAX_CSD_SKIPS_PER_CALL; bailing this encode call (encoder may be misbehaving)"
}
return ByteArray(0)
}
// Don't return; loop to find the next output
// buffer (the next dequeue may be the actual
// first audio frame).
@@ -163,6 +184,16 @@ class MediaCodecOpusEncoder(
private const val DEQUEUE_TIMEOUT_US = 10_000L
private const val FRAME_DURATION_US = 20_000L
/**
* Per-encode-call cap on consecutive
* [MediaCodec.BUFFER_FLAG_CODEC_CONFIG] outputs we'll skip
* before bailing the call. Expected steady state is 0; the
* one-time startup cost is 1 (OpusHead) or 2 (OpusHead +
* OpusTags on Codec2). 4 leaves headroom for a future stack
* that emits more without uncapping the loop entirely.
*/
private const val MAX_CSD_SKIPS_PER_CALL: Int = 4
private fun buildFormat(bitrate: Int): MediaFormat =
MediaFormat
.createAudioFormat(
@@ -157,8 +157,7 @@ class MoqLiteNestsSpeaker internal constructor(
// practice the relay's SUBSCRIBE bidi takes a network
// round-trip after our ANNOUNCE Active, so this is safe
// even though the setter is non-suspending.
val catalogJson =
MoqLiteHangCatalog.opusMono48k(MoqLiteNestsListener.AUDIO_TRACK).encodeJsonBytes()
val catalogJson = MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES
catalogPublisher.setOnNewSubscriber {
runCatching {
catalogPublisher.send(catalogJson)
@@ -586,8 +586,7 @@ private class ReissuingBroadcastHandle(
// and any watcher that attaches AFTER the recycle sees nothing
// to subscribe to. Mirror of [MoqLiteNestsSpeaker.startBroadcasting]'s
// catalog setup; same JSON, same emit-on-subscribe pattern.
val catalogPayload =
MoqLiteHangCatalog.opusMono48k(MoqLiteNestsListener.AUDIO_TRACK).encodeJsonBytes()
val catalogPayload = MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES
val priorCatalogPublisher = hotSwapCatalogPublisher
val newCatalogPublisher =
try {
@@ -325,10 +325,23 @@ class NestMoqLiteBroadcaster(
// for the production cliff this works around.
val sendOutcome =
runCatching {
val tsBytes = Varint.encode(timestampUs)
val payload = ByteArray(tsBytes.size + opus.size)
tsBytes.copyInto(payload, 0)
opus.copyInto(payload, tsBytes.size)
// Single-allocation framing: write the
// timestamp varint directly into a buffer
// sized for `varint + opus`, then copy
// the opus bytes after it. The earlier
// shape (`Varint.encode(...) + opus`)
// allocated twice per frame — once for
// the varint ByteArray, once for the
// concatenated payload. At 50 fps × N
// speakers this is measurable young-gen
// pressure on the audio hot path; the
// mirror optimisation already lives in
// `PublisherStateImpl.send` for the
// outer size-prefix wrap.
val tsLen = Varint.size(timestampUs)
val payload = ByteArray(tsLen + opus.size)
Varint.writeTo(timestampUs, payload, 0)
opus.copyInto(payload, tsLen)
val accepted = current.send(payload)
framesInCurrentGroup += 1
if (framesInCurrentGroup >= framesPerGroup) {
@@ -106,6 +106,24 @@ internal data class MoqLiteHangCatalog(
explicitNulls = false
}
/**
* Cached canonical-shape catalog JSON bytes for the default
* Opus mono 48 kHz audio track ([MoqLiteNestsListener.AUDIO_TRACK]
* keyed under `audio.renditions["audio/data"]`). The catalog is
* a fixed string for the whole publisher lifetime caching
* avoids re-running kotlinx.serialization on every
* [com.vitorpamplona.nestsclient.MoqLiteNestsSpeaker.startBroadcasting]
* call and every JWT-refresh hot-swap iteration in
* [com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker].
*
* Hard-coded to track name `"audio/data"` because that's the
* only track Amethyst publishes today; if a future caller
* needs a different name, fall back to
* [opusMono48k] + [encodeJsonBytes].
*/
val OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES: ByteArray =
opusMono48k("audio/data").encodeJsonBytes()
/**
* Canonical Amethyst speaker catalog: a single `legacy`-container
* Opus rendition under [audioTrackName], matching the encoder