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:
+3
-3
@@ -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 -> {
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+8
-6
@@ -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(
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+22
-4
@@ -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)
|
||||
|
||||
+62
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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<NestsSpeakerState> = 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)
|
||||
|
||||
@@ -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<NestsSpeakerState>(
|
||||
@@ -252,6 +259,7 @@ suspend fun connectNestsSpeaker(
|
||||
scope = scope,
|
||||
mutableState = state,
|
||||
framesPerGroup = framesPerGroup,
|
||||
broadcastConfig = broadcastConfig,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+24
-1
@@ -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<NestsSpeakerState> = 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 {
|
||||
|
||||
@@ -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/<room>` 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
|
||||
|
||||
+50
-28
@@ -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<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 =
|
||||
|
||||
+35
-3
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user