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]
|
* 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 -> {
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
|
|||||||
+8
-6
@@ -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(
|
||||||
|
|||||||
+4
-3
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -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.
|
||||||
|
|||||||
+22
-4
@@ -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)
|
||||||
|
|||||||
+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].
|
* 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -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
|
||||||
|
|||||||
+50
-28
@@ -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 =
|
||||||
|
|||||||
+35
-3
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user