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:
+3
-27
@@ -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
|
||||
|
||||
+3
-1
@@ -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 =
|
||||
|
||||
+132
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+51
@@ -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\":{"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user