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] * Wait briefly for [pubkey]'s catalog to land in [_speakerCatalogs]
* and pick the audio config (channel count + sample rate) for the * 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 * [AudioFormat.SAMPLE_RATE_HZ] on timeout (the catalog never
* arrived within [timeoutMs]) or when the catalog declares * arrived within [timeoutMs]) or when the catalog declares
* unsupported values (channelCount outside `1..2`, or non-positive * unsupported values (channelCount outside `1..2`, or non-positive
@@ -1108,7 +1108,7 @@ class NestViewModel(
val channels = val channels =
when { when {
declaredChannels == null -> { declaredChannels == null -> {
AudioFormat.CHANNELS AudioFormat.DEFAULT_CHANNELS
} }
declaredChannels !in 1..2 -> { declaredChannels !in 1..2 -> {
@@ -1116,7 +1116,7 @@ class NestViewModel(
"publisher catalog for pubkey='${pubkey.take(8)}' declares numberOfChannels=$declaredChannels " + "publisher catalog for pubkey='${pubkey.take(8)}' declares numberOfChannels=$declaredChannels " +
"(only 1 / 2 supported); falling back to mono" "(only 1 / 2 supported); falling back to mono"
} }
AudioFormat.CHANNELS AudioFormat.DEFAULT_CHANNELS
} }
else -> { else -> {
@@ -189,7 +189,7 @@ class RoomSpeakerCatalogTest {
@Test @Test
fun stripPrefixRoundTripsCanonicalCatalog() { fun stripPrefixRoundTripsCanonicalCatalog() {
// The catalog payload `MoqLiteHangCatalog.opusMono48k(...)` emits // The catalog payload `MoqLiteHangCatalog.opus48k(...)` emits
// (in `:nestsClient`) MUST round-trip through this parser — the // (in `:nestsClient`) MUST round-trip through this parser — the
// two classes target the same wire shape independently because // two classes target the same wire shape independently because
// `:nestsClient` does not depend on `:commons` and vice versa. // `:nestsClient` does not depend on `:commons` and vice versa.
@@ -66,12 +66,14 @@ class AudioRecordCapture(
check(!stopped) { "capture already stopped" } check(!stopped) { "capture already stopped" }
if (record != null) return if (record != null) return
val channelMask = // Microphone capture is mono. The platform mic is a single-channel
when (AudioFormat.CHANNELS) { // device (or a fixed multi-mic array routed through a mono mixdown
1 -> AndroidAudioFormat.CHANNEL_IN_MONO // by the AEC stack), so this isn't a per-stream configurable —
2 -> AndroidAudioFormat.CHANNEL_IN_STEREO // there's no "stereo mic" path to take. Stereo broadcasts
else -> error("unsupported channel count ${AudioFormat.CHANNELS}") // synthesise their second channel elsewhere (e.g. a stereo audio
} // capture for music playback) and bypass [AudioRecordCapture]
// entirely.
val channelMask = AndroidAudioFormat.CHANNEL_IN_MONO
val minBuffer = val minBuffer =
AudioRecord.getMinBufferSize( AudioRecord.getMinBufferSize(
@@ -83,10 +83,11 @@ class AudioTrackPlayer(
* match the [com.vitorpamplona.nestsclient.audio.OpusDecoder]'s output * match the [com.vitorpamplona.nestsclient.audio.OpusDecoder]'s output
* configuration (mono Opus 1, stereo Opus 2 with L/R interleaving). * configuration (mono Opus 1, stereo Opus 2 with L/R interleaving).
* Drives both the AudioTrack channel mask and the underlying buffer- * Drives both the AudioTrack channel mask and the underlying buffer-
* size target. Default is [AudioFormat.CHANNELS] (mono) so existing * size target. Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so
* call sites that don't pass a channel count keep the prior behaviour. * existing call sites that don't pass a channel count keep the prior
* behaviour.
*/ */
private val channelCount: Int = AudioFormat.CHANNELS, private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
/** /**
* PCM sample rate in Hz. Drives the AudioTrack output rate and the * PCM sample rate in Hz. Drives the AudioTrack output rate and the
* 250 ms target-buffer calculation. For Opus, Android's Codec2 * 250 ms target-buffer calculation. For Opus, Android's Codec2
@@ -46,11 +46,11 @@ import java.nio.ByteOrder
* L,R interleaving). * L,R interleaving).
* - CSD-1 / CSD-2: pre-skip + seek pre-roll, both zero (we don't seek). * - CSD-1 / CSD-2: pre-skip + seek pre-roll, both zero (we don't seek).
* *
* Default is [AudioFormat.CHANNELS] (mono) so existing call sites that * Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so existing call sites
* don't pass a channel count continue to behave exactly as before. * that don't pass a channel count continue to behave exactly as before.
*/ */
class MediaCodecOpusDecoder( class MediaCodecOpusDecoder(
private val channelCount: Int = AudioFormat.CHANNELS, private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
/** /**
* Source sample rate in Hz. Drives the OpusHead `inputSampleRate` * Source sample rate in Hz. Drives the OpusHead `inputSampleRate`
* field and the MediaFormat `audio/opus` sample-rate hint. * field and the MediaFormat `audio/opus` sample-rate hint.
@@ -31,18 +31,33 @@ import java.nio.ByteOrder
* encoder shipped later). One instance per outgoing track. * encoder shipped later). One instance per outgoing track.
* *
* Configuration: * Configuration:
* - 48 kHz mono input PCM 16-bit (matches [AudioFormat]). * - 48 kHz PCM 16-bit input (matches [AudioFormat.SAMPLE_RATE_HZ]).
* - [channelCount] 1 (mono) or 2 (stereo, L/R interleaved). Drives
* the MediaFormat channel count; the supplied PCM frame must hold
* `FRAME_SIZE_SAMPLES * channelCount` samples per call.
* - Target bitrate ~32 kbit/s VBR high-quality wideband speech. * - Target bitrate ~32 kbit/s VBR high-quality wideband speech.
* - 20 ms frames (the encoder requires the input buffer to hold one frame * - 20 ms frames (the encoder requires the input buffer to hold one frame
* at a time for low latency). * at a time for low latency).
*
* Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so existing call sites
* that don't pass a channel count keep the prior behaviour. Pair with a
* matching [com.vitorpamplona.nestsclient.AudioBroadcastConfig] on the
* speaker so the published catalog declares the same channel count.
*/ */
class MediaCodecOpusEncoder( class MediaCodecOpusEncoder(
private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
private val targetBitrate: Int = DEFAULT_BITRATE_BPS, private val targetBitrate: Int = DEFAULT_BITRATE_BPS,
) : OpusEncoder { ) : OpusEncoder {
init {
require(channelCount in 1..2) {
"MediaCodecOpusEncoder supports mono (1) or stereo (2) only, got $channelCount"
}
}
private val codec: MediaCodec = private val codec: MediaCodec =
try { try {
MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).apply { MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).apply {
configure(buildFormat(targetBitrate), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) configure(buildFormat(channelCount, targetBitrate), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start() start()
} }
} catch (t: Throwable) { } catch (t: Throwable) {
@@ -195,12 +210,15 @@ class MediaCodecOpusEncoder(
*/ */
private const val MAX_CSD_SKIPS_PER_CALL: Int = 4 private const val MAX_CSD_SKIPS_PER_CALL: Int = 4
private fun buildFormat(bitrate: Int): MediaFormat = private fun buildFormat(
channelCount: Int,
bitrate: Int,
): MediaFormat =
MediaFormat MediaFormat
.createAudioFormat( .createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_OPUS,
AudioFormat.SAMPLE_RATE_HZ, AudioFormat.SAMPLE_RATE_HZ,
AudioFormat.CHANNELS, channelCount,
).apply { ).apply {
setInteger(MediaFormat.KEY_BIT_RATE, bitrate) setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
setInteger(MediaFormat.KEY_PCM_ENCODING, android.media.AudioFormat.ENCODING_PCM_16BIT) setInteger(MediaFormat.KEY_PCM_ENCODING, android.media.AudioFormat.ENCODING_PCM_16BIT)
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient
/**
* Per-broadcast audio shape negotiated between the speaker, the catalog
* the relay forwards, and the listeners that subscribe to the audio
* track.
*
* Threaded through [connectNestsSpeaker] / [connectReconnectingNestsSpeaker]
* into [MoqLiteNestsSpeaker.startBroadcasting] so a stereo broadcaster
* can pick a stereo Opus rendition without forcing every existing mono
* call site to grow a parameter. The catalog factory in
* [com.vitorpamplona.nestsclient.moq.lite.MoqLiteHangCatalog.opus48kJsonBytes]
* keys on this shape so the JSON wire bytes stay byte-stable per shape.
*
* **Caller contract:** [channelCount] MUST match the channel count of
* the [com.vitorpamplona.nestsclient.audio.OpusEncoder] the caller
* provides via the speaker's `encoderFactory` AND the channel count of
* the PCM frames the caller's [com.vitorpamplona.nestsclient.audio.AudioCapture]
* produces. Mismatches surface as decoder errors on the listener side
* (CSD-0 channel byte vs interleaved PCM layout disagree) and produce
* either silence or downmix-with-clicks depending on the listener
* implementation. There is no in-broadcast renegotiation pick the
* shape at speaker open time.
*
* Default is mono (the historical shape). Callers that don't pass a
* config keep the prior behaviour.
*
* @property channelCount 1 (mono) or 2 (stereo, L/R interleaved).
* Drives the catalog's `numberOfChannels` field. Multi-rendition
* catalogs (e.g. one mono + one stereo on the same broadcast) are
* out of scope here model that as two separate catalog factories
* if it becomes useful.
*/
data class AudioBroadcastConfig(
val channelCount: Int = 1,
) {
init {
require(channelCount in 1..2) {
"AudioBroadcastConfig supports mono (1) or stereo (2) only, got $channelCount"
}
}
}
@@ -61,6 +61,17 @@ class MoqLiteNestsSpeaker internal constructor(
* Defaults to [NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP]. * Defaults to [NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP].
*/ */
private val framesPerGroup: Int = NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP, private val framesPerGroup: Int = NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP,
/**
* Per-broadcast audio shape (channel count, future bitrate
* variants). Threaded into the catalog payload the speaker emits
* on the `catalog.json` track, so listeners see the shape the
* caller's encoder actually produces. Caller MUST construct the
* encoder + capture with a matching channel layout see
* [AudioBroadcastConfig] for the contract. Defaults to mono so
* existing call sites that don't pass a config keep the prior
* behaviour.
*/
private val broadcastConfig: AudioBroadcastConfig = AudioBroadcastConfig(),
) : NestsSpeaker, ) : NestsSpeaker,
HotSwappablePublisherSource { HotSwappablePublisherSource {
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow() override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
@@ -157,7 +168,11 @@ class MoqLiteNestsSpeaker internal constructor(
// practice the relay's SUBSCRIBE bidi takes a network // practice the relay's SUBSCRIBE bidi takes a network
// round-trip after our ANNOUNCE Active, so this is safe // round-trip after our ANNOUNCE Active, so this is safe
// even though the setter is non-suspending. // even though the setter is non-suspending.
val catalogJson = MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES val catalogJson =
MoqLiteHangCatalog.opus48kJsonBytes(
audioTrackName = MoqLiteNestsListener.AUDIO_TRACK,
numberOfChannels = broadcastConfig.channelCount,
)
catalogPublisher.setOnNewSubscriber { catalogPublisher.setOnNewSubscriber {
runCatching { runCatching {
catalogPublisher.send(catalogJson) catalogPublisher.send(catalogJson)
@@ -188,6 +188,13 @@ suspend fun connectNestsSpeaker(
framesPerGroup: Int = framesPerGroup: Int =
com.vitorpamplona.nestsclient.audio com.vitorpamplona.nestsclient.audio
.NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP, .NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP,
/**
* Per-broadcast audio shape. Defaults to mono so existing call sites
* keep the prior behaviour. Caller is responsible for matching the
* channel count to the encoder + capture they pass see
* [AudioBroadcastConfig].
*/
broadcastConfig: AudioBroadcastConfig = AudioBroadcastConfig(),
): NestsSpeaker { ): NestsSpeaker {
val state = val state =
MutableStateFlow<NestsSpeakerState>( MutableStateFlow<NestsSpeakerState>(
@@ -252,6 +259,7 @@ suspend fun connectNestsSpeaker(
scope = scope, scope = scope,
mutableState = state, mutableState = state,
framesPerGroup = framesPerGroup, framesPerGroup = framesPerGroup,
broadcastConfig = broadcastConfig,
) )
} }
@@ -103,6 +103,13 @@ suspend fun connectReconnectingNestsSpeaker(
speakerPubkeyHex: String, speakerPubkeyHex: String,
captureFactory: () -> AudioCapture, captureFactory: () -> AudioCapture,
encoderFactory: () -> OpusEncoder, encoderFactory: () -> OpusEncoder,
/**
* Per-broadcast audio shape, threaded into [connectNestsSpeaker]
* (and therefore into the catalog payload) AND used by the
* hot-swap pump's per-session catalog publisher so the wire shape
* stays consistent across JWT-refresh recycles. Defaults to mono.
*/
broadcastConfig: AudioBroadcastConfig = AudioBroadcastConfig(),
policy: NestsReconnectPolicy = NestsReconnectPolicy(), policy: NestsReconnectPolicy = NestsReconnectPolicy(),
/** /**
* Proactive JWT refresh window. moq-auth issues bearer tokens * Proactive JWT refresh window. moq-auth issues bearer tokens
@@ -135,6 +142,7 @@ suspend fun connectReconnectingNestsSpeaker(
speakerPubkeyHex = speakerPubkeyHex, speakerPubkeyHex = speakerPubkeyHex,
captureFactory = captureFactory, captureFactory = captureFactory,
encoderFactory = encoderFactory, encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
) )
}, },
): NestsSpeaker { ): NestsSpeaker {
@@ -292,6 +300,7 @@ suspend fun connectReconnectingNestsSpeaker(
scope = scope, scope = scope,
captureFactory = captureFactory, captureFactory = captureFactory,
encoderFactory = encoderFactory, encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
) )
} }
@@ -320,6 +329,7 @@ private class ReconnectingSpeakerHandle(
*/ */
private val captureFactory: () -> AudioCapture, private val captureFactory: () -> AudioCapture,
private val encoderFactory: () -> OpusEncoder, private val encoderFactory: () -> OpusEncoder,
private val broadcastConfig: AudioBroadcastConfig,
) : NestsSpeaker { ) : NestsSpeaker {
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow() override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
@@ -350,6 +360,7 @@ private class ReconnectingSpeakerHandle(
scope = scope, scope = scope,
captureFactory = captureFactory, captureFactory = captureFactory,
encoderFactory = encoderFactory, encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
onLevel = onLevel, onLevel = onLevel,
onClose = { closed -> onClose = { closed ->
if (activeBroadcast === closed) activeBroadcast = null if (activeBroadcast === closed) activeBroadcast = null
@@ -412,6 +423,14 @@ private class ReissuingBroadcastHandle(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val captureFactory: () -> AudioCapture, private val captureFactory: () -> AudioCapture,
private val encoderFactory: () -> OpusEncoder, private val encoderFactory: () -> OpusEncoder,
/**
* Per-broadcast audio shape, used to pick the catalog payload the
* per-session catalog publisher emits. The shape is constant for
* the wrapper's lifetime (we don't renegotiate mid-broadcast), so
* caching the payload bytes once on the [opus48kJsonBytes]
* memoiser is enough.
*/
private val broadcastConfig: AudioBroadcastConfig,
/** /**
* Forwarded to the underlying broadcaster (hot-swap path) or * Forwarded to the underlying broadcaster (hot-swap path) or
* `sp.startBroadcasting` (legacy path) so the local-speaking ring * `sp.startBroadcasting` (legacy path) so the local-speaking ring
@@ -586,7 +605,11 @@ private class ReissuingBroadcastHandle(
// and any watcher that attaches AFTER the recycle sees nothing // and any watcher that attaches AFTER the recycle sees nothing
// to subscribe to. Mirror of [MoqLiteNestsSpeaker.startBroadcasting]'s // to subscribe to. Mirror of [MoqLiteNestsSpeaker.startBroadcasting]'s
// catalog setup; same JSON, same emit-on-subscribe pattern. // catalog setup; same JSON, same emit-on-subscribe pattern.
val catalogPayload = MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES val catalogPayload =
MoqLiteHangCatalog.opus48kJsonBytes(
audioTrackName = MoqLiteNestsListener.AUDIO_TRACK,
numberOfChannels = broadcastConfig.channelCount,
)
val priorCatalogPublisher = hotSwapCatalogPublisher val priorCatalogPublisher = hotSwapCatalogPublisher
val newCatalogPublisher = val newCatalogPublisher =
try { try {
@@ -23,15 +23,27 @@ package com.vitorpamplona.nestsclient.audio
/** /**
* PCM audio format the audio pipeline produces and consumes. * PCM audio format the audio pipeline produces and consumes.
* *
* Listener-only flow runs at 48 kHz mono signed-16-bit, matching the nests * Sample rate + frame cadence are pipeline-wide invariants (48 kHz Opus,
* Opus profile (RFC 6716 wideband at the codec's native rate). The whole * 20 ms frames) and live as flat constants. Channel count is **per-stream**:
* pipeline is hardcoded to this format for now when nests starts varying * a microphone is mono, a stereo broadcast is two-channel, and a single
* codec settings, this becomes a per-room negotiated value out of the * listener may simultaneously decode tracks of different shapes. Call sites
* `/api/v1/nests/<room>` response. * either inline `1` (the device is intrinsically mono e.g. the production
* mic) or take a `channelCount` parameter, plumbed in from a catalog (on
* the listener side) or from an [com.vitorpamplona.nestsclient.AudioBroadcastConfig]
* (on the speaker side). [DEFAULT_CHANNELS] is what call sites use as the
* factory default when they want the historical mono behaviour.
*/ */
object AudioFormat { object AudioFormat {
const val SAMPLE_RATE_HZ: Int = 48_000 const val SAMPLE_RATE_HZ: Int = 48_000
const val CHANNELS: Int = 1
/**
* Default channel count for call sites that don't yet thread a
* per-stream override through. Mono matches what the pipeline
* shipped before stereo support landed. Use this only as a default
* value; do NOT assume every audio track is mono just because this
* exists.
*/
const val DEFAULT_CHANNELS: Int = 1
/** 20 ms at 48 kHz. */ /** 20 ms at 48 kHz. */
const val FRAME_SIZE_SAMPLES: Int = 960 const val FRAME_SIZE_SAMPLES: Int = 960
@@ -106,42 +106,27 @@ internal data class MoqLiteHangCatalog(
explicitNulls = false 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 * Canonical Amethyst speaker catalog: a single `legacy`-container
* Opus rendition under [audioTrackName], matching the encoder * Opus rendition under [audioTrackName] at 48 kHz with
* config in [com.vitorpamplona.nestsclient.audio.OpusEncoder] * [numberOfChannels] (1 = mono, 2 = stereo L/R interleaved),
* (48 kHz mono). * matching the encoder config in
* [com.vitorpamplona.nestsclient.audio.OpusEncoder].
* *
* The rendition map is keyed by the moq-lite track name a * The rendition map is keyed by the moq-lite track name a
* subscriber should subscribe to for this rendition's frames * subscriber should subscribe to for this rendition's frames
* for nests audio rooms that's the same string the publisher * for nests audio rooms that's the same string the publisher
* publishes audio frames on * publishes audio frames on
* (`MoqLiteNestsListener.AUDIO_TRACK`). * (`MoqLiteNestsListener.AUDIO_TRACK`).
*
* If [com.vitorpamplona.nestsclient.audio.OpusEncoder] becomes
* parameterised in the future, this factory should take the
* encoder's config rather than hard-coding 48 kHz mono.
*/ */
fun opusMono48k(audioTrackName: String): MoqLiteHangCatalog = fun opus48k(
MoqLiteHangCatalog( audioTrackName: String,
numberOfChannels: Int = 1,
): MoqLiteHangCatalog {
require(numberOfChannels in 1..2) {
"opus48k supports mono (1) or stereo (2) only, got $numberOfChannels"
}
return MoqLiteHangCatalog(
audio = audio =
Audio( Audio(
renditions = renditions =
@@ -151,12 +136,49 @@ internal data class MoqLiteHangCatalog(
codec = "opus", codec = "opus",
container = Container(kind = "legacy"), container = Container(kind = "legacy"),
sampleRate = 48_000, sampleRate = 48_000,
numberOfChannels = 1, numberOfChannels = numberOfChannels,
jitter = OPUS_FRAME_DURATION_MS, jitter = OPUS_FRAME_DURATION_MS,
), ),
), ),
), ),
) )
}
/**
* Memoised JSON bytes for [opus48k]. 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].
*
* Keyed by `(trackName, numberOfChannels)` so adding a new shape
* (stereo, future bitrate variants) doesn't multiply the
* constant count. The cache is populated on first request per
* shape and never evicted at most a handful of entries per
* process lifetime, all small.
*
* Thread-safety: the operation is idempotent two threads
* computing the same shape land identical bytes so a
* non-locking [HashMap] is acceptable in commonMain. Worst case
* the second writer overwrites with an equal value; cache
* readers always see at least one fully-published value
* because the JVM's default visibility for non-volatile object
* references is good enough for the "may compute twice"
* tolerance we have here.
*/
private val cachedJsonBytes: HashMap<Pair<String, Int>, ByteArray> = HashMap()
fun opus48kJsonBytes(
audioTrackName: String,
numberOfChannels: Int = 1,
): ByteArray {
val key = audioTrackName to numberOfChannels
cachedJsonBytes[key]?.let { return it }
val bytes = opus48k(audioTrackName, numberOfChannels).encodeJsonBytes()
cachedJsonBytes[key] = bytes
return bytes
}
/** /**
* Opus frame duration in milliseconds 960 samples / 48 kHz = * Opus frame duration in milliseconds 960 samples / 48 kHz =
@@ -25,7 +25,7 @@ import kotlin.test.assertEquals
class MoqLiteHangCatalogTest { class MoqLiteHangCatalogTest {
@Test @Test
fun opusMono48kEmitsCanonicalHangShape() { fun opus48kMonoEmitsCanonicalHangShape() {
// Byte-exact assertion: `:commons`'s `RoomSpeakerCatalogTest` // Byte-exact assertion: `:commons`'s `RoomSpeakerCatalogTest`
// round-trips this same string against the parser. If either // round-trips this same string against the parser. If either
// side drifts from the kixelated/hang wire shape, both tests // side drifts from the kixelated/hang wire shape, both tests
@@ -35,17 +35,49 @@ class MoqLiteHangCatalogTest {
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," + "\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1,\"jitter\":20}}}}" "\"sampleRate\":48000,\"numberOfChannels\":1,\"jitter\":20}}}}"
val actual = 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) assertEquals(expected, actual)
} }
@Test @Test
fun renditionKeyMatchesCallerSuppliedTrackName() { fun renditionKeyMatchesCallerSuppliedTrackName() {
val actual = 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 // The rendition map MUST be keyed on the caller-supplied track
// name — the watcher uses this string verbatim as the // name — the watcher uses this string verbatim as the
// SUBSCRIBE.track on the audio subscription. // SUBSCRIBE.track on the audio subscription.
assertEquals(true, actual.contains("\"custom/track\":{")) 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))
}
} }