feat(nests): emit catalog jitter hint matching Opus frame duration

hang.js's encoder publishes a `jitter` field (ms) in every audio
rendition; the watcher uses it to size its jitter buffer. Without it
the watcher falls back to a built-in default — works in practice today
but means our publishes deviate from the canonical hang shape and the
deviation could surface as audible buffer-sizing differences on a
future watcher revision that takes the field as required.

Add `jitter: 20` to MoqLiteHangCatalog.AudioRendition (matching the
Opus frame cadence: 960 samples at 48 kHz = 20 ms; same value hang.js
hard-codes at `js/publish/src/audio/encoder.ts:#runConfig`). Updates
the byte-exact MoqLiteHangCatalogTest assertion and the parallel
inline-literal in RoomSpeakerCatalogTest so both sides stay pinned.

The commons-side `RoomSpeakerCatalog` parser doesn't surface the new
field — `JsonMapper`'s `ignoreUnknownKeys = true` lets the watcher
ingest it silently. Adding a typed `jitter` field there is a
forward-compat task; not needed for interop with hang today.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
Claude
2026-05-06 15:18:54 +00:00
parent ff9bb25921
commit 1b0740d4a8
3 changed files with 28 additions and 2 deletions
@@ -137,7 +137,7 @@ class RoomSpeakerCatalogTest {
val emitted =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
"\"sampleRate\":48000,\"numberOfChannels\":1,\"jitter\":20}}}}"
val catalog = RoomSpeakerCatalog.parseOrNull(emitted.encodeToByteArray())
assertNotNull(catalog)
val rendition = catalog.primaryAudio()
@@ -146,5 +146,10 @@ class RoomSpeakerCatalogTest {
assertEquals("legacy", rendition.container?.kind)
assertEquals(48_000, rendition.sampleRate)
assertEquals(1, rendition.numberOfChannels)
// `jitter` is intentionally not asserted on `rendition` — the
// commons-side parser drops unknown fields via the JsonMapper's
// `ignoreUnknownKeys = true`. Adding it to `RoomSpeakerCatalog`
// is forward work; the byte-exact match in the literal above
// already pins the wire shape.
}
}
@@ -40,6 +40,7 @@ import kotlinx.serialization.json.Json
* "container": { "kind": "legacy" },
* "sampleRate": 48000,
* "numberOfChannels": 1,
* "jitter": 20, // ms; matches one Opus frame
* "bitrate": 32000 // optional
* }
* }
@@ -67,6 +68,15 @@ internal data class MoqLiteHangCatalog(
val container: Container,
val sampleRate: Int,
val numberOfChannels: Int,
/**
* Publisher-side cadence hint (ms). hang.js's watcher uses
* this to size its jitter buffer — emit a real value rather
* than relying on the watcher's fallback default. For Opus
* this is the codec frame duration (20 ms at 48 kHz / 960
* samples) and matches what hang.js's encoder emits at
* `js/publish/src/audio/encoder.ts:#runConfig`.
*/
val jitter: Int,
)
@Serializable
@@ -124,9 +134,20 @@ internal data class MoqLiteHangCatalog(
container = Container(kind = "legacy"),
sampleRate = 48_000,
numberOfChannels = 1,
jitter = OPUS_FRAME_DURATION_MS,
),
),
),
)
/**
* Opus frame duration in milliseconds — 960 samples / 48 kHz =
* 20 ms. Matches
* [com.vitorpamplona.nestsclient.audio.Audio.FRAME_SIZE_SAMPLES].
* Published as the catalog `jitter` hint so hang.js's watcher
* sizes its jitter buffer to one Opus frame, the natural
* cadence of our encoder.
*/
private const val OPUS_FRAME_DURATION_MS: Int = 20
}
}
@@ -33,7 +33,7 @@ class MoqLiteHangCatalogTest {
val expected =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
"\"sampleRate\":48000,\"numberOfChannels\":1,\"jitter\":20}}}}"
val actual =
MoqLiteHangCatalog.opusMono48k("audio/data").encodeJsonBytes().decodeToString()
assertEquals(expected, actual)