From 1b0740d4a83dc2185580dd4a2913d3ea4111f25e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 15:18:54 +0000 Subject: [PATCH] feat(nests): emit catalog jitter hint matching Opus frame duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../viewmodels/RoomSpeakerCatalogTest.kt | 7 ++++++- .../moq/lite/MoqLiteHangCatalog.kt | 21 +++++++++++++++++++ .../moq/lite/MoqLiteHangCatalogTest.kt | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt index 104099ef3..7286d83e8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt @@ -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. } } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt index 020756a47..6e2483790 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt @@ -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 } } diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt index 1eb7adbde..138bb17b1 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt @@ -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)