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:
+62
-19
@@ -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 {
|
||||
/**
|
||||
|
||||
+67
-33
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user