refactor(nests): replace hand-built catalog JSON with typed MoqLiteHangCatalog

The two SPEAKER_CATALOG_JSON call sites (MoqLiteNestsSpeaker for the
non-reconnecting path, ReconnectingNestsSpeaker for the JWT-refresh hot-
swap path) both built the manifest as a `\\` + `\"` + `+`-concatenated
string literal. One missed escape and the watcher's parser fails
silently — the broadcast becomes invisible to hang.js with no
indication of why. The two literals had also drifted independently in
review (same content today; nothing prevents drift tomorrow).

Replace both with `MoqLiteHangCatalog.opusMono48k(track).encodeJsonBytes()`,
a Serializable data class that encodes via kotlinx.serialization with
`encodeDefaults = false` + `explicitNulls = false` pinned (so a future
global default-flip can't surface `"bitrate": null` on hang.js's
parser).

The new type lives in :nestsClient because :nestsClient does not depend
on :commons and the parser-side `RoomSpeakerCatalog` (in :commons)
isn't reachable from the publish path. The two classes target the same
wire shape independently; a byte-exact assertion in the new
MoqLiteHangCatalogTest plus the existing RoomSpeakerCatalogTest's
inline-literal round-trip together pin both sides — desync on either
end fails one of the two tests immediately.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
Claude
2026-05-06 15:13:47 +00:00
parent aa37a931e1
commit ff9bb25921
5 changed files with 196 additions and 34 deletions
@@ -23,6 +23,7 @@ package com.vitorpamplona.nestsclient
import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteHangCatalog
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
import kotlinx.coroutines.CancellationException
@@ -155,7 +156,8 @@ class MoqLiteNestsSpeaker internal constructor(
// every CATALOG_REPUBLISH_INTERVAL_MS so late-joining
// watchers don't wait an arbitrary amount of time for the
// next refresh.
val catalogJson = SPEAKER_CATALOG_JSON.encodeToByteArray()
val catalogJson =
MoqLiteHangCatalog.opusMono48k(MoqLiteNestsListener.AUDIO_TRACK).encodeJsonBytes()
val republishJob =
try {
catalogPublisher.send(catalogJson)
@@ -201,32 +203,6 @@ class MoqLiteNestsSpeaker internal constructor(
}
companion object {
/**
* moq-lite catalog manifest broadcast on the
* [MoqLiteNestsListener.CATALOG_TRACK] track. Verbatim
* canonical kixelated/moq `hang` shape (camelCase, nested
* `audio.renditions[<trackName>]`, `container.kind = "legacy"`).
* The rendition key MUST equal the moq-lite track we publish
* audio frames on ([MoqLiteNestsListener.AUDIO_TRACK]) so the
* watcher's "subscribe to this rendition" path resolves to our
* actual audio stream.
*
* `container.kind = "legacy"` declares the wire layout the
* watcher must expect inside each moq-lite frame: a single
* `varint(timestamp_us)` prefix followed by the raw codec
* payload (no MOOF/MDAT wrapping). The publisher side honours
* this contract in
* [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster].
*
* Hard-coded because the speaker's encoder is fixed at
* 48 kHz mono Opus today; if [OpusEncoder] becomes parameterised
* this should be derived from the encoder config instead.
*/
const val SPEAKER_CATALOG_JSON: String =
"{\"audio\":{\"renditions\":{\"" + MoqLiteNestsListener.AUDIO_TRACK + "\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
/**
* How often the catalog group is re-emitted. The relay drops
* the catalog group when its last subscriber drops, so a
@@ -23,6 +23,7 @@ package com.vitorpamplona.nestsclient
import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteHangCatalog
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -572,7 +573,8 @@ 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 republish cadence.
val catalogPayload = MoqLiteNestsSpeaker.SPEAKER_CATALOG_JSON.encodeToByteArray()
val catalogPayload =
MoqLiteHangCatalog.opusMono48k(MoqLiteNestsListener.AUDIO_TRACK).encodeJsonBytes()
val priorCatalogPublisher = hotSwapCatalogPublisher
val priorCatalogJob = hotSwapCatalogJob
val newCatalogPublisher =
@@ -0,0 +1,132 @@
/*
* 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.moq.lite
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Publisher-side model for the kixelated/moq `hang` `catalog.json`
* track. Kept distinct from
* `com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog`
* (the parser in `:commons`) because `:nestsClient` does not depend on
* `:commons` — both classes target the same wire shape independently.
*
* Wire shape (verbatim from `kixelated/moq/rs/hang/src/catalog/`):
*
* {
* "audio": {
* "renditions": {
* "<trackName>": {
* "codec": "opus",
* "container": { "kind": "legacy" },
* "sampleRate": 48000,
* "numberOfChannels": 1,
* "bitrate": 32000 // optional
* }
* }
* }
* }
*
* Field names are camelCase per the upstream serde
* `rename_all = "camelCase"`. Every Amethyst-emitted field is required
* here (no nullable defaults) so the encoded JSON is stable byte-for-
* byte across runs and we don't accidentally publish `"bitrate": null`
* if hang.js's parser turns out to reject it.
*/
@Serializable
internal data class MoqLiteHangCatalog(
val audio: Audio,
) {
@Serializable
data class Audio(
val renditions: Map<String, AudioRendition>,
)
@Serializable
data class AudioRendition(
val codec: String,
val container: Container,
val sampleRate: Int,
val numberOfChannels: Int,
)
@Serializable
data class Container(
val kind: String,
)
/** UTF-8 JSON bytes ready to push on the `catalog.json` moq-lite track. */
fun encodeJsonBytes(): ByteArray = JSON.encodeToString(serializer(), this).encodeToByteArray()
companion object {
/**
* Json instance dedicated to catalog encoding. We can't reuse
* `:quartz`'s `JsonMapper` from common code without pulling
* `:commons` (which already imports it) into `:nestsClient`'s
* dependency closure — `:nestsClient` deliberately stays free
* of the UI/state layer. A local instance keeps the wire-format
* configuration (camelCase serde defaults, no pretty-printing,
* deterministic field order) co-located with the model.
*/
private val JSON =
Json {
// Default for kotlinx.serialization is `false`, but pin
// explicitly so a future global default-flip doesn't
// surface `"bitrate": null` on hang.js's parser.
encodeDefaults = false
explicitNulls = false
}
/**
* 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).
*
* 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(
audio =
Audio(
renditions =
mapOf(
audioTrackName to
AudioRendition(
codec = "opus",
container = Container(kind = "legacy"),
sampleRate = 48_000,
numberOfChannels = 1,
),
),
),
)
}
}
@@ -0,0 +1,51 @@
/*
* 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.moq.lite
import kotlin.test.Test
import kotlin.test.assertEquals
class MoqLiteHangCatalogTest {
@Test
fun opusMono48kEmitsCanonicalHangShape() {
// 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
// fail at once.
val expected =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
val actual =
MoqLiteHangCatalog.opusMono48k("audio/data").encodeJsonBytes().decodeToString()
assertEquals(expected, actual)
}
@Test
fun renditionKeyMatchesCallerSuppliedTrackName() {
val actual =
MoqLiteHangCatalog.opusMono48k("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\":{"))
}
}