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)