feat(nests): align moq-lite catalog with kixelated/hang shape

Switch RoomSpeakerCatalog to the canonical hang catalog format
(camelCase audio.renditions[<track>] with container.kind), wrap
audio frames in the legacy varint(timestamp_us) prefix on publish,
and strip it on subscribe. Also reply to inbound Probe/Fetch/Session
bidis with a bitrate hint or clean FIN instead of hanging.
This commit is contained in:
Claude
2026-05-06 03:16:01 +00:00
parent 5389b32e52
commit 5b7e347ac0
7 changed files with 281 additions and 65 deletions
@@ -22,35 +22,67 @@ package com.vitorpamplona.amethyst.commons.viewmodels
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Decoded shape of a moq-lite `catalog.json` track for a single
* audio-room speaker. Each broadcast advertises one or more tracks
* (one per codec / quality tier) — for nests audio rooms there's
* exactly one Opus track today, but the model carries a list for
* forward compatibility.
* audio-room speaker. Mirrors the kixelated/moq `hang` reference
* catalog format — the canonical shape that `@kixelated/hang`'s
* browser watcher and the `moq-rs` Rust hang crate both produce and
* consume — so Amethyst publishers are visible to the moq-lite browser
* reference, and Amethyst listeners parse standards-aligned catalogs
* from non-Amethyst publishers.
*
* Best-effort schema: nostrnests' upstream moq-lite catalog spec
* (kixelated/moq) has evolved across revisions, and not every
* publisher fills in every field. The parser tolerates missing
* keys via `ignoreUnknownKeys = true` on [JsonMapper] and the
* `?`-marked properties.
* Shape (verbatim from `kixelated/moq/rs/hang/src/catalog/`):
*
* {
* "audio": {
* "renditions": {
* "<trackName>": {
* "codec": "opus",
* "container": { "kind": "legacy" },
* "sampleRate": 48000,
* "numberOfChannels": 1,
* "bitrate": 32000 // optional
* }
* }
* }
* }
*
* Keys are camelCase per the upstream serde `rename_all = "camelCase"`.
* Every field except the rendition-key string is optional in the parser
* — older / partial publishers are tolerated. Field semantics:
*
* - rendition key: the moq-lite `track` name a subscriber should
* subscribe to for that audio rendition's frames (commonly the same
* string for single-rendition broadcasts). Subscribers pick a
* rendition (e.g. by codec / bitrate) and use this key as the
* Subscribe.track string.
* - `codec`: codec mimetype string (`"opus"`, `"mp4a.40.2"` for AAC).
* - `container.kind`: frame wrapper. `"legacy"` = each frame is
* `varint(timestamp_us) + raw_codec_payload` inside the moq-lite
* group. `"cmaf"` = MOOF/MDAT-fragmented MP4. We only emit/parse
* `legacy` today; unknown kinds are ignored at parse time.
* - `sampleRate` / `numberOfChannels`: PCM source params.
*/
@Immutable
@Serializable
data class RoomSpeakerCatalog(
val version: Int? = null,
val audio: List<AudioTrack> = emptyList(),
val audio: Audio? = null,
) {
@Immutable
@Serializable
data class AudioTrack(
val track: String? = null,
data class Audio(
val renditions: Map<String, AudioConfig> = emptyMap(),
)
@Immutable
@Serializable
data class AudioConfig(
val codec: String? = null,
@SerialName("sample_rate") val sampleRate: Int? = null,
@SerialName("channel_count") val channelCount: Int? = null,
val container: Container? = null,
val sampleRate: Int? = null,
val numberOfChannels: Int? = null,
val bitrate: Int? = null,
) {
/**
@@ -64,14 +96,25 @@ data class RoomSpeakerCatalog(
buildList {
codec?.takeIf { it.isNotBlank() }?.let { add(it.uppercase()) }
sampleRate?.let { add("${it / 1000}kHz") }
channelCount?.let { add(if (it == 1) "mono" else "${it}ch") }
numberOfChannels?.let { add(if (it == 1) "mono" else "${it}ch") }
}
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
}
/** First audio track, if any. The current single-Opus reality. */
fun primaryAudio(): AudioTrack? = audio.firstOrNull()
@Immutable
@Serializable
data class Container(
val kind: String? = null,
)
/**
* First audio rendition, if any. The current single-Opus reality.
* Map iteration order is the JSON insertion order (kotlinx.serialization
* uses LinkedHashMap), so this picks the first rendition the
* publisher declared rather than an arbitrary one.
*/
fun primaryAudio(): AudioConfig? = audio?.renditions?.values?.firstOrNull()
companion object {
/**
@@ -27,69 +27,72 @@ import kotlin.test.assertNull
class RoomSpeakerCatalogTest {
@Test
fun parsesNostrnestsShape() {
fun parsesKixelatedHangShape() {
// Verbatim shape produced by the canonical kixelated/moq `hang`
// crate (`rs/hang/src/catalog/`). Verifies that an Amethyst
// listener picks up a standards-aligned publisher's catalog.
val json =
"""
{
"version": 1,
"audio": [
{
"track": "audio/data",
"codec": "opus",
"sample_rate": 48000,
"channel_count": 2,
"bitrate": 64000
"audio": {
"renditions": {
"audio/data": {
"codec": "opus",
"container": { "kind": "legacy" },
"sampleRate": 48000,
"numberOfChannels": 2,
"bitrate": 64000
}
}
]
}
}
""".trimIndent()
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
assertNotNull(catalog)
assertEquals(1, catalog.version)
assertEquals(1, catalog.audio.size)
val track = catalog.primaryAudio()
assertNotNull(track)
assertEquals("audio/data", track.track)
assertEquals("opus", track.codec)
assertEquals(48_000, track.sampleRate)
assertEquals(2, track.channelCount)
assertEquals(64_000, track.bitrate)
assertEquals(1, catalog.audio?.renditions?.size)
val rendition = catalog.primaryAudio()
assertNotNull(rendition)
assertEquals("opus", rendition.codec)
assertEquals("legacy", rendition.container?.kind)
assertEquals(48_000, rendition.sampleRate)
assertEquals(2, rendition.numberOfChannels)
assertEquals(64_000, rendition.bitrate)
}
@Test
fun describeFormatsHumanReadable() {
val track =
RoomSpeakerCatalog.AudioTrack(
val rendition =
RoomSpeakerCatalog.AudioConfig(
codec = "opus",
sampleRate = 48_000,
channelCount = 2,
numberOfChannels = 2,
)
// Codec uppercased, kHz / channel count short-formed —
// intended for a single-line tooltip in the participant
// sheet, not a verbose codec dump.
assertEquals("OPUS · 48kHz · 2ch", track.describe())
assertEquals("OPUS · 48kHz · 2ch", rendition.describe())
}
@Test
fun describeMonoIsLabelled() {
val track = RoomSpeakerCatalog.AudioTrack(codec = "opus", channelCount = 1)
assertEquals("OPUS · mono", track.describe())
val rendition = RoomSpeakerCatalog.AudioConfig(codec = "opus", numberOfChannels = 1)
assertEquals("OPUS · mono", rendition.describe())
}
@Test
fun describeAllNullReturnsNull() {
val track = RoomSpeakerCatalog.AudioTrack()
val rendition = RoomSpeakerCatalog.AudioConfig()
// Empty catalog → caller doesn't render a "unknown · unknown"
// line; describe() returns null so the UI can omit cleanly.
assertNull(track.describe())
assertNull(rendition.describe())
}
@Test
fun toleratesUnknownKeys() {
// Forward-compat: future moq-lite catalog revisions can add
// Forward-compat: future hang catalog revisions can add
// fields without breaking older clients.
val json =
"""{"version":2,"audio":[{"track":"audio/data","codec":"opus","extra":"future-only"}],"new_top_level":true}"""
"""{"audio":{"renditions":{"audio/data":{"codec":"opus","extra":"future-only"}}},"video":{},"newTopLevel":true}"""
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
assertNotNull(catalog)
assertEquals("opus", catalog.primaryAudio()?.codec)
@@ -104,12 +107,43 @@ class RoomSpeakerCatalogTest {
}
@Test
fun emptyAudioListIsAllowed() {
fun emptyAudioRenditionsIsAllowed() {
// A publisher might emit an "advertising" catalog with no
// tracks yet (e.g. between codec switches). Accept the shape
// and let primaryAudio() return null.
val catalog = RoomSpeakerCatalog.parseOrNull("""{"version":1,"audio":[]}""".encodeToByteArray())
// renditions yet (e.g. between codec switches). Accept the
// shape and let primaryAudio() return null.
val catalog = RoomSpeakerCatalog.parseOrNull("""{"audio":{"renditions":{}}}""".encodeToByteArray())
assertNotNull(catalog)
assertNull(catalog.primaryAudio())
}
@Test
fun missingAudioReturnsNullPrimary() {
// hang catalogs declaring only video MUST still parse without
// throwing — primaryAudio() returns null.
val catalog = RoomSpeakerCatalog.parseOrNull("""{}""".encodeToByteArray())
assertNotNull(catalog)
assertNull(catalog.primaryAudio())
}
@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.
val emitted =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
val catalog = RoomSpeakerCatalog.parseOrNull(emitted.encodeToByteArray())
assertNotNull(catalog)
val rendition = catalog.primaryAudio()
assertNotNull(rendition)
assertEquals("opus", rendition.codec)
assertEquals("legacy", rendition.container?.kind)
assertEquals(48_000, rendition.sampleRate)
assertEquals(1, rendition.numberOfChannels)
}
}