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
@@ -1074,7 +1074,7 @@ class NestViewModel(
/**
* Wait briefly for [pubkey]'s catalog to land in [_speakerCatalogs]
* and pick the audio config (channel count + sample rate) for the
* decoder + AudioTrack. Falls back to [AudioFormat.CHANNELS] /
* decoder + AudioTrack. Falls back to [AudioFormat.DEFAULT_CHANNELS] /
* [AudioFormat.SAMPLE_RATE_HZ] on timeout (the catalog never
* arrived within [timeoutMs]) or when the catalog declares
* unsupported values (channelCount outside `1..2`, or non-positive
@@ -1108,7 +1108,7 @@ class NestViewModel(
val channels =
when {
declaredChannels == null -> {
AudioFormat.CHANNELS
AudioFormat.DEFAULT_CHANNELS
}
declaredChannels !in 1..2 -> {
@@ -1116,7 +1116,7 @@ class NestViewModel(
"publisher catalog for pubkey='${pubkey.take(8)}' declares numberOfChannels=$declaredChannels " +
"(only 1 / 2 supported); falling back to mono"
}
AudioFormat.CHANNELS
AudioFormat.DEFAULT_CHANNELS
}
else -> {
@@ -189,7 +189,7 @@ class RoomSpeakerCatalogTest {
@Test
fun stripPrefixRoundTripsCanonicalCatalog() {
// The catalog payload `MoqLiteHangCatalog.opusMono48k(...)` emits
// The catalog payload `MoqLiteHangCatalog.opus48k(...)` emits
// (in `:nestsClient`) MUST round-trip through this parser — the
// two classes target the same wire shape independently because
// `:nestsClient` does not depend on `:commons` and vice versa.