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 androidx.compose.runtime.Immutable
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decoded shape of a moq-lite `catalog.json` track for a single
|
* Decoded shape of a moq-lite `catalog.json` track for a single
|
||||||
* audio-room speaker. Each broadcast advertises one or more tracks
|
* audio-room speaker. Mirrors the kixelated/moq `hang` reference
|
||||||
* (one per codec / quality tier) — for nests audio rooms there's
|
* catalog format — the canonical shape that `@kixelated/hang`'s
|
||||||
* exactly one Opus track today, but the model carries a list for
|
* browser watcher and the `moq-rs` Rust hang crate both produce and
|
||||||
* forward compatibility.
|
* 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
|
* Shape (verbatim from `kixelated/moq/rs/hang/src/catalog/`):
|
||||||
* (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
|
* "audio": {
|
||||||
* `?`-marked properties.
|
* "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
|
@Immutable
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RoomSpeakerCatalog(
|
data class RoomSpeakerCatalog(
|
||||||
val version: Int? = null,
|
val audio: Audio? = null,
|
||||||
val audio: List<AudioTrack> = emptyList(),
|
|
||||||
) {
|
) {
|
||||||
@Immutable
|
@Immutable
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AudioTrack(
|
data class Audio(
|
||||||
val track: String? = null,
|
val renditions: Map<String, AudioConfig> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
@Serializable
|
||||||
|
data class AudioConfig(
|
||||||
val codec: String? = null,
|
val codec: String? = null,
|
||||||
@SerialName("sample_rate") val sampleRate: Int? = null,
|
val container: Container? = null,
|
||||||
@SerialName("channel_count") val channelCount: Int? = null,
|
val sampleRate: Int? = null,
|
||||||
|
val numberOfChannels: Int? = null,
|
||||||
val bitrate: Int? = null,
|
val bitrate: Int? = null,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
@@ -64,14 +96,25 @@ data class RoomSpeakerCatalog(
|
|||||||
buildList {
|
buildList {
|
||||||
codec?.takeIf { it.isNotBlank() }?.let { add(it.uppercase()) }
|
codec?.takeIf { it.isNotBlank() }?.let { add(it.uppercase()) }
|
||||||
sampleRate?.let { add("${it / 1000}kHz") }
|
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(" · ")
|
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First audio track, if any. The current single-Opus reality. */
|
@Immutable
|
||||||
fun primaryAudio(): AudioTrack? = audio.firstOrNull()
|
@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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
|
|||||||
+67
-33
@@ -27,69 +27,72 @@ import kotlin.test.assertNull
|
|||||||
|
|
||||||
class RoomSpeakerCatalogTest {
|
class RoomSpeakerCatalogTest {
|
||||||
@Test
|
@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 =
|
val json =
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"version": 1,
|
"audio": {
|
||||||
"audio": [
|
"renditions": {
|
||||||
{
|
"audio/data": {
|
||||||
"track": "audio/data",
|
"codec": "opus",
|
||||||
"codec": "opus",
|
"container": { "kind": "legacy" },
|
||||||
"sample_rate": 48000,
|
"sampleRate": 48000,
|
||||||
"channel_count": 2,
|
"numberOfChannels": 2,
|
||||||
"bitrate": 64000
|
"bitrate": 64000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
|
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
|
||||||
assertNotNull(catalog)
|
assertNotNull(catalog)
|
||||||
assertEquals(1, catalog.version)
|
assertEquals(1, catalog.audio?.renditions?.size)
|
||||||
assertEquals(1, catalog.audio.size)
|
val rendition = catalog.primaryAudio()
|
||||||
val track = catalog.primaryAudio()
|
assertNotNull(rendition)
|
||||||
assertNotNull(track)
|
assertEquals("opus", rendition.codec)
|
||||||
assertEquals("audio/data", track.track)
|
assertEquals("legacy", rendition.container?.kind)
|
||||||
assertEquals("opus", track.codec)
|
assertEquals(48_000, rendition.sampleRate)
|
||||||
assertEquals(48_000, track.sampleRate)
|
assertEquals(2, rendition.numberOfChannels)
|
||||||
assertEquals(2, track.channelCount)
|
assertEquals(64_000, rendition.bitrate)
|
||||||
assertEquals(64_000, track.bitrate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun describeFormatsHumanReadable() {
|
fun describeFormatsHumanReadable() {
|
||||||
val track =
|
val rendition =
|
||||||
RoomSpeakerCatalog.AudioTrack(
|
RoomSpeakerCatalog.AudioConfig(
|
||||||
codec = "opus",
|
codec = "opus",
|
||||||
sampleRate = 48_000,
|
sampleRate = 48_000,
|
||||||
channelCount = 2,
|
numberOfChannels = 2,
|
||||||
)
|
)
|
||||||
// Codec uppercased, kHz / channel count short-formed —
|
// Codec uppercased, kHz / channel count short-formed —
|
||||||
// intended for a single-line tooltip in the participant
|
// intended for a single-line tooltip in the participant
|
||||||
// sheet, not a verbose codec dump.
|
// sheet, not a verbose codec dump.
|
||||||
assertEquals("OPUS · 48kHz · 2ch", track.describe())
|
assertEquals("OPUS · 48kHz · 2ch", rendition.describe())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun describeMonoIsLabelled() {
|
fun describeMonoIsLabelled() {
|
||||||
val track = RoomSpeakerCatalog.AudioTrack(codec = "opus", channelCount = 1)
|
val rendition = RoomSpeakerCatalog.AudioConfig(codec = "opus", numberOfChannels = 1)
|
||||||
assertEquals("OPUS · mono", track.describe())
|
assertEquals("OPUS · mono", rendition.describe())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun describeAllNullReturnsNull() {
|
fun describeAllNullReturnsNull() {
|
||||||
val track = RoomSpeakerCatalog.AudioTrack()
|
val rendition = RoomSpeakerCatalog.AudioConfig()
|
||||||
// Empty catalog → caller doesn't render a "unknown · unknown"
|
// Empty catalog → caller doesn't render a "unknown · unknown"
|
||||||
// line; describe() returns null so the UI can omit cleanly.
|
// line; describe() returns null so the UI can omit cleanly.
|
||||||
assertNull(track.describe())
|
assertNull(rendition.describe())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun toleratesUnknownKeys() {
|
fun toleratesUnknownKeys() {
|
||||||
// Forward-compat: future moq-lite catalog revisions can add
|
// Forward-compat: future hang catalog revisions can add
|
||||||
// fields without breaking older clients.
|
// fields without breaking older clients.
|
||||||
val json =
|
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())
|
val catalog = RoomSpeakerCatalog.parseOrNull(json.encodeToByteArray())
|
||||||
assertNotNull(catalog)
|
assertNotNull(catalog)
|
||||||
assertEquals("opus", catalog.primaryAudio()?.codec)
|
assertEquals("opus", catalog.primaryAudio()?.codec)
|
||||||
@@ -104,12 +107,43 @@ class RoomSpeakerCatalogTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun emptyAudioListIsAllowed() {
|
fun emptyAudioRenditionsIsAllowed() {
|
||||||
// A publisher might emit an "advertising" catalog with no
|
// A publisher might emit an "advertising" catalog with no
|
||||||
// tracks yet (e.g. between codec switches). Accept the shape
|
// renditions yet (e.g. between codec switches). Accept the
|
||||||
// and let primaryAudio() return null.
|
// shape and let primaryAudio() return null.
|
||||||
val catalog = RoomSpeakerCatalog.parseOrNull("""{"version":1,"audio":[]}""".encodeToByteArray())
|
val catalog = RoomSpeakerCatalog.parseOrNull("""{"audio":{"renditions":{}}}""".encodeToByteArray())
|
||||||
assertNotNull(catalog)
|
assertNotNull(catalog)
|
||||||
assertNull(catalog.primaryAudio())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-3
@@ -82,14 +82,36 @@ class MoqLiteNestsListener internal constructor(
|
|||||||
override suspend fun subscribeSpeaker(
|
override suspend fun subscribeSpeaker(
|
||||||
speakerPubkeyHex: String,
|
speakerPubkeyHex: String,
|
||||||
maxLatencyMs: Long,
|
maxLatencyMs: Long,
|
||||||
): SubscribeHandle = wrapSubscription(broadcast = speakerPubkeyHex, track = AUDIO_TRACK, maxLatencyMs = maxLatencyMs)
|
): SubscribeHandle =
|
||||||
|
wrapSubscription(
|
||||||
|
broadcast = speakerPubkeyHex,
|
||||||
|
track = AUDIO_TRACK,
|
||||||
|
maxLatencyMs = maxLatencyMs,
|
||||||
|
// Audio frames arrive in kixelated/moq `hang` "legacy"
|
||||||
|
// container layout: each moq-lite frame is
|
||||||
|
// `varint(timestamp_us) + raw_opus_packet`. Strip the
|
||||||
|
// leading varint so downstream decoders see a pristine
|
||||||
|
// Opus packet exactly as if it came from
|
||||||
|
// [com.vitorpamplona.nestsclient.audio.OpusEncoder].
|
||||||
|
stripLegacyTimestamp = true,
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun subscribeCatalog(speakerPubkeyHex: String): SubscribeHandle = wrapSubscription(broadcast = speakerPubkeyHex, track = CATALOG_TRACK, maxLatencyMs = 0L)
|
override suspend fun subscribeCatalog(speakerPubkeyHex: String): SubscribeHandle =
|
||||||
|
wrapSubscription(
|
||||||
|
broadcast = speakerPubkeyHex,
|
||||||
|
track = CATALOG_TRACK,
|
||||||
|
maxLatencyMs = 0L,
|
||||||
|
// Catalog frames carry raw JSON bytes — no container
|
||||||
|
// wrapping (the catalog itself is what tells you which
|
||||||
|
// container the audio track uses).
|
||||||
|
stripLegacyTimestamp = false,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun wrapSubscription(
|
private suspend fun wrapSubscription(
|
||||||
broadcast: String,
|
broadcast: String,
|
||||||
track: String,
|
track: String,
|
||||||
maxLatencyMs: Long,
|
maxLatencyMs: Long,
|
||||||
|
stripLegacyTimestamp: Boolean,
|
||||||
): SubscribeHandle {
|
): SubscribeHandle {
|
||||||
check(state.value is NestsListenerState.Connected) {
|
check(state.value is NestsListenerState.Connected) {
|
||||||
"NestsListener.subscribe requires Connected state, was ${state.value}"
|
"NestsListener.subscribe requires Connected state, was ${state.value}"
|
||||||
@@ -112,12 +134,13 @@ class MoqLiteNestsListener internal constructor(
|
|||||||
val objectIdSeq = AtomicLong(0L)
|
val objectIdSeq = AtomicLong(0L)
|
||||||
val mapped =
|
val mapped =
|
||||||
handle.frames.map { frame ->
|
handle.frames.map { frame ->
|
||||||
|
val payload = if (stripLegacyTimestamp) stripLegacyTimestampPrefix(frame.payload) else frame.payload
|
||||||
MoqObject(
|
MoqObject(
|
||||||
trackAlias = handle.id,
|
trackAlias = handle.id,
|
||||||
groupId = frame.groupSequence,
|
groupId = frame.groupSequence,
|
||||||
objectId = objectIdSeq.getAndIncrement(),
|
objectId = objectIdSeq.getAndIncrement(),
|
||||||
publisherPriority = MoqLiteSession.DEFAULT_PRIORITY,
|
publisherPriority = MoqLiteSession.DEFAULT_PRIORITY,
|
||||||
payload = frame.payload,
|
payload = payload,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,5 +265,23 @@ class MoqLiteNestsListener internal constructor(
|
|||||||
* [subscribeCatalog].
|
* [subscribeCatalog].
|
||||||
*/
|
*/
|
||||||
const val CATALOG_TRACK: String = "catalog.json"
|
const val CATALOG_TRACK: String = "catalog.json"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the leading `varint(timestamp_us)` prefix from a
|
||||||
|
* kixelated/moq `hang` "legacy" container frame, returning
|
||||||
|
* the bare codec payload (e.g. an Opus packet) for downstream
|
||||||
|
* decoders. The varint length is encoded in the top 2 bits of
|
||||||
|
* the first byte per RFC 9000 §16: `00`→1, `01`→2, `10`→4,
|
||||||
|
* `11`→8 bytes. Returns the payload unchanged when the
|
||||||
|
* varint header would overrun the payload (malformed frame —
|
||||||
|
* surface upstream rather than silently mask).
|
||||||
|
*/
|
||||||
|
internal fun stripLegacyTimestampPrefix(payload: ByteArray): ByteArray {
|
||||||
|
if (payload.isEmpty()) return payload
|
||||||
|
val tag = (payload[0].toInt() ushr 6) and 0x3
|
||||||
|
val varintLen = 1 shl tag
|
||||||
|
if (payload.size < varintLen) return payload
|
||||||
|
return payload.copyOfRange(varintLen, payload.size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-6
@@ -203,17 +203,29 @@ class MoqLiteNestsSpeaker internal constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* moq-lite catalog manifest broadcast on the
|
* moq-lite catalog manifest broadcast on the
|
||||||
* [MoqLiteNestsListener.CATALOG_TRACK] track. The shape mirrors
|
* [MoqLiteNestsListener.CATALOG_TRACK] track. Verbatim
|
||||||
* what the kixelated/moq browser watcher parses
|
* canonical kixelated/moq `hang` shape (camelCase, nested
|
||||||
* ([com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog]):
|
* `audio.renditions[<trackName>]`, `container.kind = "legacy"`).
|
||||||
* a versioned envelope with an `audio[]` of track descriptors.
|
* 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
|
* Hard-coded because the speaker's encoder is fixed at
|
||||||
* 48 kHz mono Opus today; if [OpusEncoder] becomes parameterised
|
* 48 kHz mono Opus today; if [OpusEncoder] becomes parameterised
|
||||||
* this should be derived from the encoder config instead.
|
* this should be derived from the encoder config instead.
|
||||||
*/
|
*/
|
||||||
const val SPEAKER_CATALOG_JSON: String =
|
const val SPEAKER_CATALOG_JSON: String =
|
||||||
"{\"version\":1,\"audio\":[{\"track\":\"audio/data\"," +
|
"{\"audio\":{\"renditions\":{\"" + MoqLiteNestsListener.AUDIO_TRACK + "\":{" +
|
||||||
"\"codec\":\"opus\",\"sample_rate\":48000,\"channel_count\":1}]}"
|
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
|
||||||
|
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How often the catalog group is re-emitted. The relay drops
|
* How often the catalog group is re-emitted. The relay drops
|
||||||
|
|||||||
+23
-1
@@ -21,11 +21,14 @@
|
|||||||
package com.vitorpamplona.nestsclient.audio
|
package com.vitorpamplona.nestsclient.audio
|
||||||
|
|
||||||
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
|
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
|
||||||
|
import com.vitorpamplona.quic.Varint
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.TimeMark
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirror of [NestBroadcaster] but driving a moq-lite
|
* Mirror of [NestBroadcaster] but driving a moq-lite
|
||||||
@@ -195,6 +198,21 @@ class NestMoqLiteBroadcaster(
|
|||||||
// and the relay would see two unrelated uni streams under
|
// and the relay would see two unrelated uni streams under
|
||||||
// the same logical group.
|
// the same logical group.
|
||||||
var lastPublisher: MoqLitePublisherHandle = publisher
|
var lastPublisher: MoqLitePublisherHandle = publisher
|
||||||
|
// kixelated/moq `hang` "legacy" container wire format:
|
||||||
|
// every frame inside a moq-lite group is
|
||||||
|
// varint(timestamp_us) + raw_codec_payload
|
||||||
|
// (`rs/hang/src/container/frame.rs`,
|
||||||
|
// Timescale<1_000_000>). Watchers that read our
|
||||||
|
// catalog's `container.kind = "legacy"` declaration
|
||||||
|
// will skip the leading varint as a microsecond
|
||||||
|
// timestamp; they MUST receive a real timestamp or
|
||||||
|
// their decoder picks up garbage bytes ahead of the
|
||||||
|
// Opus packet. We use a monotonic mark captured at
|
||||||
|
// capture-loop start so timestamps are robust to mute
|
||||||
|
// gaps and survive publisher hot-swap (the wire is
|
||||||
|
// single-broadcast, single-encoder; timestamps are
|
||||||
|
// codec-payload metadata, not stream-position).
|
||||||
|
val frameStartMark: TimeMark = TimeSource.Monotonic.markNow()
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
val pcm = capture.readFrame() ?: break
|
val pcm = capture.readFrame() ?: break
|
||||||
@@ -233,7 +251,11 @@ class NestMoqLiteBroadcaster(
|
|||||||
// for the production cliff this works around.
|
// for the production cliff this works around.
|
||||||
val sendOutcome =
|
val sendOutcome =
|
||||||
runCatching {
|
runCatching {
|
||||||
val accepted = current.send(opus)
|
val tsBytes = Varint.encode(frameStartMark.elapsedNow().inWholeMicroseconds)
|
||||||
|
val payload = ByteArray(tsBytes.size + opus.size)
|
||||||
|
tsBytes.copyInto(payload, 0)
|
||||||
|
opus.copyInto(payload, tsBytes.size)
|
||||||
|
val accepted = current.send(payload)
|
||||||
framesInCurrentGroup += 1
|
framesInCurrentGroup += 1
|
||||||
if (framesInCurrentGroup >= framesPerGroup) {
|
if (framesInCurrentGroup >= framesPerGroup) {
|
||||||
current.endGroup()
|
current.endGroup()
|
||||||
|
|||||||
+14
@@ -251,6 +251,20 @@ object MoqLiteCodec {
|
|||||||
return MoqLiteProbe(bitrate = bitrate)
|
return MoqLiteProbe(bitrate = bitrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a single Lite-03 Probe message body
|
||||||
|
* (`lite/probe.rs` — `bitrate: u62` only; `rtt` is Lite-04+).
|
||||||
|
* The publisher writes these size-prefixed onto a Probe bidi the
|
||||||
|
* subscriber opened, advertising the publisher's expected
|
||||||
|
* bandwidth. Wrapping (size prefix) is the caller's responsibility,
|
||||||
|
* matching [encodeAnnouncePlease] / [encodeAnnounce].
|
||||||
|
*/
|
||||||
|
fun encodeProbe(probe: MoqLiteProbe): ByteArray {
|
||||||
|
val body = MoqWriter()
|
||||||
|
body.writeVarint(probe.bitrate)
|
||||||
|
return wrapSizePrefixed(body)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- internals ----------------
|
// ---------------- internals ----------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+53
-3
@@ -883,9 +883,50 @@ class MoqLiteSession internal constructor(
|
|||||||
dispatched = true
|
dispatched = true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
MoqLiteControlType.Probe -> {
|
||||||
// Lite-03 treats Session/Fetch/Probe as
|
// Subscriber-opened bidi asking us (the
|
||||||
// separate flows; we don't implement them.
|
// publisher) for a bitrate hint. Per
|
||||||
|
// `lite/probe.rs:Lite03+`, the publisher
|
||||||
|
// writes one or more size-prefixed
|
||||||
|
// `Probe { bitrate: u62 }` messages on
|
||||||
|
// this bidi. We're a fixed-rate Opus
|
||||||
|
// publisher, so emit a single hint and
|
||||||
|
// FIN our write side — the peer treats
|
||||||
|
// FIN as "no further updates" rather than
|
||||||
|
// an error. Better than the old behaviour
|
||||||
|
// of FINing without writing anything,
|
||||||
|
// which left the subscriber's ABR
|
||||||
|
// estimator with no signal.
|
||||||
|
runCatching {
|
||||||
|
bidi.write(MoqLiteCodec.encodeProbe(MoqLiteProbe(bitrate = NESTS_AUDIO_BITRATE_HINT_BPS)))
|
||||||
|
bidi.finish()
|
||||||
|
}
|
||||||
|
dispatched = true
|
||||||
|
}
|
||||||
|
|
||||||
|
MoqLiteControlType.Fetch -> {
|
||||||
|
// Subscriber-opened bidi requesting a
|
||||||
|
// historical group (`lite/fetch.rs`).
|
||||||
|
// Audio rooms are live-only — we have no
|
||||||
|
// group history to serve. FINing the
|
||||||
|
// write side without any reply is the
|
||||||
|
// spec-clean way to signal "no groups
|
||||||
|
// available"; the subscriber's wait on
|
||||||
|
// its receive side resolves to
|
||||||
|
// end-of-stream and it falls back to a
|
||||||
|
// live Subscribe. Ignoring inbound bytes
|
||||||
|
// (the request body) is fine: we don't
|
||||||
|
// need to know which group was requested
|
||||||
|
// because we couldn't serve any of them.
|
||||||
|
runCatching { bidi.finish() }
|
||||||
|
dispatched = true
|
||||||
|
}
|
||||||
|
|
||||||
|
MoqLiteControlType.Session -> {
|
||||||
|
// ControlType=0 was the Lite-01/02 setup
|
||||||
|
// exchange. Lite-03 doesn't use it; if a
|
||||||
|
// legacy peer sends one, FIN cleanly so
|
||||||
|
// their bidi resolves rather than hanging.
|
||||||
runCatching { bidi.finish() }
|
runCatching { bidi.finish() }
|
||||||
dispatched = true
|
dispatched = true
|
||||||
}
|
}
|
||||||
@@ -1194,6 +1235,15 @@ class MoqLiteSession internal constructor(
|
|||||||
/** moq-lite priority byte midpoint — neutral default. */
|
/** moq-lite priority byte midpoint — neutral default. */
|
||||||
const val DEFAULT_PRIORITY: Int = 0x80
|
const val DEFAULT_PRIORITY: Int = 0x80
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bitrate hint (bits/sec) we report on inbound moq-lite Probe
|
||||||
|
* bidis as a publisher. Mirrors the upper-bound of an Opus
|
||||||
|
* voice profile at 48 kHz mono, ≈32 kbps. Subscriber-side ABR
|
||||||
|
* estimators use this to size their forward queue; we emit the
|
||||||
|
* single hint and FIN since our encoder runs at a fixed bitrate.
|
||||||
|
*/
|
||||||
|
const val NESTS_AUDIO_BITRATE_HINT_BPS: Long = 32_000L
|
||||||
|
|
||||||
// Diagnostic: log "send returned false" once every N invocations.
|
// Diagnostic: log "send returned false" once every N invocations.
|
||||||
// At 50 fps and N=50 → ≤ 1 log/sec for a sustained no-sub window.
|
// At 50 fps and N=50 → ≤ 1 log/sec for a sustained no-sub window.
|
||||||
private const val SEND_LOG_THROTTLE: Long = 50L
|
private const val SEND_LOG_THROTTLE: Long = 50L
|
||||||
|
|||||||
Reference in New Issue
Block a user