From ff9bb25921e2c89979b057c4d8a7417e20c23a05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 15:13:47 +0000 Subject: [PATCH] refactor(nests): replace hand-built catalog JSON with typed MoqLiteHangCatalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../viewmodels/RoomSpeakerCatalogTest.kt | 13 +- .../nestsclient/MoqLiteNestsSpeaker.kt | 30 +--- .../nestsclient/ReconnectingNestsSpeaker.kt | 4 +- .../moq/lite/MoqLiteHangCatalog.kt | 132 ++++++++++++++++++ .../moq/lite/MoqLiteHangCatalogTest.kt | 51 +++++++ 5 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt 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 f8a45bcff..104099ef3 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 @@ -127,12 +127,13 @@ class RoomSpeakerCatalogTest { @Test fun stripPrefixRoundTripsCanonicalCatalog() { - // The catalog payload [com.vitorpamplona.nestsclient.MoqLiteNestsSpeaker] - // emits MUST round-trip through the parser — guards against - // either side drifting from the kixelated/hang shape. - // SPEAKER_CATALOG_JSON lives in nestsClient and isn't - // accessible from commons; keep an inline literal that mirrors - // it verbatim so a desync triggers this assertion. + // The catalog payload `MoqLiteHangCatalog.opusMono48k(...)` 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. + // `MoqLiteHangCatalog` isn't reachable from this module; keep an + // inline literal that mirrors what the publisher emits verbatim + // so a desync on either side trips this assertion. val emitted = "{\"audio\":{\"renditions\":{\"audio/data\":{" + "\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," + diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt index 67cbf3217..2fdd8e44b 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt @@ -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[]`, `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 diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt index 5bdb479b6..85d3b0d8d 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt @@ -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 = 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 new file mode 100644 index 000000000..020756a47 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt @@ -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": { + * "": { + * "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, + ) + + @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, + ), + ), + ), + ) + } +} 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 new file mode 100644 index 000000000..1eb7adbde --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt @@ -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\":{")) + } +}