refactor(nests): per-stream channel count + AudioBroadcastConfig (I4 prep)

Split the previously global `AudioFormat.CHANNELS = 1` into a
`DEFAULT_CHANNELS` constant + per-call-site `channelCount` parameters
so a single broadcast can advertise stereo Opus without forcing every
mono call site to grow a new argument. Generalises the catalog factory
to `MoqLiteHangCatalog.opus48k(name, channels)` with memoised JSON
bytes per shape, threads a new `AudioBroadcastConfig(channelCount)`
through `connectNestsSpeaker` / `connectReconnectingNestsSpeaker` /
`MoqLiteNestsSpeaker`, and adds a `channelCount` parameter to
`MediaCodecOpusEncoder`. Production behaviour is unchanged for
mono callers (the new config defaults to mono); the listener side
already discovers the channel count from the catalog via
`NestViewModel.awaitAudioPipelineConfig`. No test or wire changes.

Phase 1 of `nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md`.
The hang-interop test scaffolding (`HangInteropTest`, `runSpeakerToHangListen`,
Rust `hang-listen` / `hang-publish`, `JvmOpusEncoder`) doesn't exist on
this branch yet, so the I4 forward + reverse scenarios are deferred
until the parent T16 plan lands.

https://claude.ai/code/session_01EqJEADzH9yjSuoP5L9js8i
This commit is contained in:
Claude
2026-05-06 22:57:21 +00:00
parent 32e578dcc8
commit 23b8bfd34a
13 changed files with 254 additions and 59 deletions
@@ -25,7 +25,7 @@ import kotlin.test.assertEquals
class MoqLiteHangCatalogTest {
@Test
fun opusMono48kEmitsCanonicalHangShape() {
fun opus48kMonoEmitsCanonicalHangShape() {
// Byte-exact assertion: `:commons`'s `RoomSpeakerCatalogTest`
// round-trips this same string against the parser. If either
// side drifts from the kixelated/hang wire shape, both tests
@@ -35,17 +35,49 @@ class MoqLiteHangCatalogTest {
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1,\"jitter\":20}}}}"
val actual =
MoqLiteHangCatalog.opusMono48k("audio/data").encodeJsonBytes().decodeToString()
MoqLiteHangCatalog.opus48k("audio/data").encodeJsonBytes().decodeToString()
assertEquals(expected, actual)
}
@Test
fun opus48kStereoEmitsTwoChannelHangShape() {
// Stereo broadcasts (kixelated/moq web publisher with a stereo
// AudioContext) declare numberOfChannels = 2. Listeners pick
// this up via the catalog and configure their decoder + sink
// for L/R interleaved PCM.
val expected =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":2,\"jitter\":20}}}}"
val actual =
MoqLiteHangCatalog
.opus48k("audio/data", numberOfChannels = 2)
.encodeJsonBytes()
.decodeToString()
assertEquals(expected, actual)
}
@Test
fun renditionKeyMatchesCallerSuppliedTrackName() {
val actual =
MoqLiteHangCatalog.opusMono48k("custom/track").encodeJsonBytes().decodeToString()
MoqLiteHangCatalog.opus48k("custom/track").encodeJsonBytes().decodeToString()
// The rendition map MUST be keyed on the caller-supplied track
// name — the watcher uses this string verbatim as the
// SUBSCRIBE.track on the audio subscription.
assertEquals(true, actual.contains("\"custom/track\":{"))
}
@Test
fun opus48kJsonBytesMemoisesPerShape() {
// Same shape → same byte array reference (fast-path on every
// subsequent broadcast / hot-swap iteration).
val a = MoqLiteHangCatalog.opus48kJsonBytes("audio/data", 1)
val b = MoqLiteHangCatalog.opus48kJsonBytes("audio/data", 1)
assertEquals(true, a === b)
// Different shape → different bytes; both stay cached.
val stereo = MoqLiteHangCatalog.opus48kJsonBytes("audio/data", 2)
assertEquals(true, a !== stereo)
assertEquals(true, stereo === MoqLiteHangCatalog.opus48kJsonBytes("audio/data", 2))
}
}