From 23b8bfd34af856694a9e8442a604742a89f1196d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:57:21 +0000 Subject: [PATCH] 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 --- .../commons/viewmodels/NestViewModel.kt | 6 +- .../viewmodels/RoomSpeakerCatalogTest.kt | 2 +- .../nestsclient/audio/AudioRecordCapture.kt | 14 ++-- .../nestsclient/audio/AudioTrackPlayer.kt | 7 +- .../audio/MediaCodecOpusDecoder.kt | 6 +- .../audio/MediaCodecOpusEncoder.kt | 26 ++++++- .../nestsclient/AudioBroadcastConfig.kt | 62 +++++++++++++++ .../nestsclient/MoqLiteNestsSpeaker.kt | 17 +++- .../vitorpamplona/nestsclient/NestsConnect.kt | 8 ++ .../nestsclient/ReconnectingNestsSpeaker.kt | 25 +++++- .../vitorpamplona/nestsclient/audio/Audio.kt | 24 ++++-- .../moq/lite/MoqLiteHangCatalog.kt | 78 ++++++++++++------- .../moq/lite/MoqLiteHangCatalogTest.kt | 38 ++++++++- 13 files changed, 254 insertions(+), 59 deletions(-) create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/AudioBroadcastConfig.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 2fd58dfa0..33a5f1dbe 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -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 -> { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt index a4941b64a..5c3471546 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt @@ -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. diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt index b5645c85e..52b22b4ff 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt @@ -66,12 +66,14 @@ class AudioRecordCapture( check(!stopped) { "capture already stopped" } if (record != null) return - val channelMask = - when (AudioFormat.CHANNELS) { - 1 -> AndroidAudioFormat.CHANNEL_IN_MONO - 2 -> AndroidAudioFormat.CHANNEL_IN_STEREO - else -> error("unsupported channel count ${AudioFormat.CHANNELS}") - } + // Microphone capture is mono. The platform mic is a single-channel + // device (or a fixed multi-mic array routed through a mono mixdown + // by the AEC stack), so this isn't a per-stream configurable — + // there's no "stereo mic" path to take. Stereo broadcasts + // 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 = AudioRecord.getMinBufferSize( diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt index 0cba8be7f..e32824ca5 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt @@ -83,10 +83,11 @@ class AudioTrackPlayer( * match the [com.vitorpamplona.nestsclient.audio.OpusDecoder]'s output * configuration (mono Opus → 1, stereo Opus → 2 with L/R interleaving). * Drives both the AudioTrack channel mask and the underlying buffer- - * size target. Default is [AudioFormat.CHANNELS] (mono) so existing - * call sites that don't pass a channel count keep the prior behaviour. + * size target. Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so + * 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 * 250 ms target-buffer calculation. For Opus, Android's Codec2 diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt index d9abb819f..3c84340de 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt @@ -46,11 +46,11 @@ import java.nio.ByteOrder * L,R interleaving). * - 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 - * don't pass a channel count continue to behave exactly as before. + * Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so existing call sites + * that don't pass a channel count continue to behave exactly as before. */ class MediaCodecOpusDecoder( - private val channelCount: Int = AudioFormat.CHANNELS, + private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS, /** * Source sample rate in Hz. Drives the OpusHead `inputSampleRate` * field and the MediaFormat `audio/opus` sample-rate hint. diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt index f18f7da99..9ad2b7504 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt @@ -31,18 +31,33 @@ import java.nio.ByteOrder * encoder shipped later). One instance per outgoing track. * * 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. * - 20 ms frames (the encoder requires the input buffer to hold one frame * 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( + private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS, private val targetBitrate: Int = DEFAULT_BITRATE_BPS, ) : OpusEncoder { + init { + require(channelCount in 1..2) { + "MediaCodecOpusEncoder supports mono (1) or stereo (2) only, got $channelCount" + } + } + private val codec: MediaCodec = try { 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() } } catch (t: Throwable) { @@ -195,12 +210,15 @@ class MediaCodecOpusEncoder( */ 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 .createAudioFormat( MediaFormat.MIMETYPE_AUDIO_OPUS, AudioFormat.SAMPLE_RATE_HZ, - AudioFormat.CHANNELS, + channelCount, ).apply { setInteger(MediaFormat.KEY_BIT_RATE, bitrate) setInteger(MediaFormat.KEY_PCM_ENCODING, android.media.AudioFormat.ENCODING_PCM_16BIT) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/AudioBroadcastConfig.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/AudioBroadcastConfig.kt new file mode 100644 index 000000000..348db1978 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/AudioBroadcastConfig.kt @@ -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" + } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt index 0d02d1051..74c0766af 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt @@ -61,6 +61,17 @@ class MoqLiteNestsSpeaker internal constructor( * Defaults to [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, HotSwappablePublisherSource { override val state: StateFlow = mutableState.asStateFlow() @@ -157,7 +168,11 @@ 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.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES + val catalogJson = + MoqLiteHangCatalog.opus48kJsonBytes( + audioTrackName = MoqLiteNestsListener.AUDIO_TRACK, + numberOfChannels = broadcastConfig.channelCount, + ) catalogPublisher.setOnNewSubscriber { runCatching { catalogPublisher.send(catalogJson) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt index bcdcd83c3..bc5f3888c 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt @@ -188,6 +188,13 @@ suspend fun connectNestsSpeaker( framesPerGroup: Int = com.vitorpamplona.nestsclient.audio .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 { val state = MutableStateFlow( @@ -252,6 +259,7 @@ suspend fun connectNestsSpeaker( scope = scope, mutableState = state, framesPerGroup = framesPerGroup, + broadcastConfig = broadcastConfig, ) } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt index 897a93656..8cfd57665 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt @@ -103,6 +103,13 @@ suspend fun connectReconnectingNestsSpeaker( speakerPubkeyHex: String, captureFactory: () -> AudioCapture, 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(), /** * Proactive JWT refresh window. moq-auth issues bearer tokens @@ -135,6 +142,7 @@ suspend fun connectReconnectingNestsSpeaker( speakerPubkeyHex = speakerPubkeyHex, captureFactory = captureFactory, encoderFactory = encoderFactory, + broadcastConfig = broadcastConfig, ) }, ): NestsSpeaker { @@ -292,6 +300,7 @@ suspend fun connectReconnectingNestsSpeaker( scope = scope, captureFactory = captureFactory, encoderFactory = encoderFactory, + broadcastConfig = broadcastConfig, ) } @@ -320,6 +329,7 @@ private class ReconnectingSpeakerHandle( */ private val captureFactory: () -> AudioCapture, private val encoderFactory: () -> OpusEncoder, + private val broadcastConfig: AudioBroadcastConfig, ) : NestsSpeaker { override val state: StateFlow = mutableState.asStateFlow() @@ -350,6 +360,7 @@ private class ReconnectingSpeakerHandle( scope = scope, captureFactory = captureFactory, encoderFactory = encoderFactory, + broadcastConfig = broadcastConfig, onLevel = onLevel, onClose = { closed -> if (activeBroadcast === closed) activeBroadcast = null @@ -412,6 +423,14 @@ private class ReissuingBroadcastHandle( private val scope: CoroutineScope, private val captureFactory: () -> AudioCapture, 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 * `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 // to subscribe to. Mirror of [MoqLiteNestsSpeaker.startBroadcasting]'s // 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 newCatalogPublisher = try { diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt index c2be91b94..05a2feb82 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt @@ -23,15 +23,27 @@ package com.vitorpamplona.nestsclient.audio /** * PCM audio format the audio pipeline produces and consumes. * - * Listener-only flow runs at 48 kHz mono signed-16-bit, matching the nests - * Opus profile (RFC 6716 wideband at the codec's native rate). The whole - * pipeline is hardcoded to this format for now — when nests starts varying - * codec settings, this becomes a per-room negotiated value out of the - * `/api/v1/nests/` response. + * Sample rate + frame cadence are pipeline-wide invariants (48 kHz Opus, + * 20 ms frames) and live as flat constants. Channel count is **per-stream**: + * a microphone is mono, a stereo broadcast is two-channel, and a single + * listener may simultaneously decode tracks of different shapes. Call sites + * 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 { 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. */ const val FRAME_SIZE_SAMPLES: Int = 960 diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt index e52b75c08..7f07fc6eb 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt @@ -106,42 +106,27 @@ 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 - * config in [com.vitorpamplona.nestsclient.audio.OpusEncoder] - * (48 kHz mono). + * Opus rendition under [audioTrackName] at 48 kHz with + * [numberOfChannels] (1 = mono, 2 = stereo L/R interleaved), + * matching the encoder config in + * [com.vitorpamplona.nestsclient.audio.OpusEncoder]. * * The rendition map is keyed by the moq-lite track name a * subscriber should subscribe to for this rendition's frames — * for nests audio rooms that's the same string the publisher * publishes audio frames on * (`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 = - MoqLiteHangCatalog( + fun opus48k( + 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( renditions = @@ -151,12 +136,49 @@ internal data class MoqLiteHangCatalog( codec = "opus", container = Container(kind = "legacy"), sampleRate = 48_000, - numberOfChannels = 1, + numberOfChannels = numberOfChannels, 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, 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 = diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt index 138bb17b1..1742f3138 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt @@ -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)) + } }