test(nests): T16 Phase 4 — I13 long broadcast + I14 WebCodecs warmup
Adds the two browser-tier P0 scenarios deferred from Phase 4.C:
I13 (chromium_listener_long_broadcast_60s_tone_440):
- 60 s end-to-end Amethyst speaker -> Chromium @moq/lite + @moq/hang
Container.Legacy.Consumer; assert >= 50 s of decoded PCM, FFT
peak intact at 440 Hz, zero decoder errors.
- Spec asked for framesPerGroup = 50 against actual Container.Consumer.
Two constraints: (1) @moq/hang 0.2.4 doesn't export the high-level
Container.Consumer/Format API (Phase 4 uses Container.Legacy.Consumer
directly), (2) framesPerGroup = 50 against the local moq-relay
0.10.25 --auth-public minimal setup hits the per-subscriber forward
cliff per 2026-05-07-framespergroup-reconciliation.md. Pin 5
locally; production keeps 50.
- Catches what I1 forward (10 s) doesn't: Chromium WebTransport
MAX_STREAMS_UNI credit drift over 600+ uni streams, group-queue
eviction past MAX_GROUP_AGE = 30 s twice over, WebCodecs decoder
pacing/memory pressure at broadcast scale.
I14 (chromium_decoder_no_errors_through_warmup_window):
- Asserts Chromium AudioDecoder.error fires zero times during
a 10 s broadcast. Browser-tier mate of HangInteropTest's I11
(first_audio_frame_is_not_opus_codec_config); together they
cover T8 (BUFFER_FLAG_CODEC_CONFIG skip) on both reference paths.
- Deliberately no decoderOutputs floor: the Phase 4 harness has
a known cold-launch race that occasionally produces 0 frames
(per 2026-05-06-phase4-browser-harness-results.md). Since I14
is an absence assertion, vacuous-zero is safe — a T8 regression
would still trigger on whichever frames arrive in any green run.
- Note: JVM speaker uses libopus directly (no CSD prefix), so
this test path effectively asserts no spurious decode failures.
T8 itself is an Android-MediaCodecOpusEncoder fix; that path
isn't reachable from a JVM-host test.
Wire changes:
- listen.ts gains decoderOutputs + decoderErrors counters,
exposed via window.__decoderOutputs / __decoderErrors.
- tests/harness.spec.ts surfaces both in the meta JSON line.
- BrowserInteropTest.kt adds parseIntMetaFromStdout helper +
the two new scenarios.
Verification: all 4 BrowserInteropTest scenarios green in one
JVM run, no flake under -DnestsHangInterop=true -DnestsBrowserInterop=true.
This commit is contained in:
@@ -133,10 +133,21 @@ async function main() {
|
||||
const sampleRate = 48_000;
|
||||
const numberOfChannels = 1; // overwritten by catalog if available; default mono
|
||||
let warmed = 0;
|
||||
// I14 instrumentation. `decoderOutputs` counts every successful
|
||||
// `output()` callback (warmup frames included), `decoderErrors`
|
||||
// counts every WebCodecs `error()` callback. A T8 regression that
|
||||
// leaks `OpusHead` into a normal audio frame surfaces as either a
|
||||
// non-zero error count (decoder rejects the bytes) or — if Chromium
|
||||
// tolerates it — as the warmup window absorbing the stray frame
|
||||
// and the FFT peak shifting. The error counter catches case 1
|
||||
// deterministically; the FFT peak in I1 catches case 2.
|
||||
let decoderOutputs = 0;
|
||||
let decoderErrors = 0;
|
||||
|
||||
const decoder = new AudioDecoder({
|
||||
output: (data: AudioData) => {
|
||||
warmed++;
|
||||
decoderOutputs++;
|
||||
if (warmed <= 3) {
|
||||
// Mirror @moq/watch's 3-frame WebCodecs warmup skip.
|
||||
data.close();
|
||||
@@ -167,7 +178,10 @@ async function main() {
|
||||
}
|
||||
data.close();
|
||||
},
|
||||
error: (err) => console.error("[listen] AudioDecoder", err),
|
||||
error: (err) => {
|
||||
decoderErrors++;
|
||||
console.error("[listen] AudioDecoder", err);
|
||||
},
|
||||
});
|
||||
|
||||
decoder.configure({
|
||||
@@ -207,6 +221,8 @@ async function main() {
|
||||
|
||||
status(`done, frames=${framesDecoded}`);
|
||||
(window as any).__framesDecoded = framesDecoded;
|
||||
(window as any).__decoderOutputs = decoderOutputs;
|
||||
(window as any).__decoderErrors = decoderErrors;
|
||||
|
||||
// Flush any pending decoder output, then signal the WS server we're done.
|
||||
try {
|
||||
|
||||
@@ -49,6 +49,13 @@ test.describe("nests-browser-interop", () => {
|
||||
const meta = await page.evaluate(() => ({
|
||||
framesDecoded: (window as any).__framesDecoded,
|
||||
moqVersion: (window as any).__moqVersion,
|
||||
// I14 instrumentation: total WebCodecs `output()` callbacks
|
||||
// (warmup frames included) and total `error()` callbacks.
|
||||
// A T8 regression that leaks `OpusHead` into a normal audio
|
||||
// frame trips `decoderErrors` deterministically; the FFT
|
||||
// peak in I1 catches the silent-tolerance variant.
|
||||
decoderOutputs: (window as any).__decoderOutputs,
|
||||
decoderErrors: (window as any).__decoderErrors,
|
||||
}));
|
||||
// Always print a summary line — Kotlin parses this for follow-up
|
||||
// assertions (e.g. moq-lite-03 ALPN echo for I15).
|
||||
|
||||
+166
@@ -111,6 +111,144 @@ class BrowserInteropTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **I13 (browser long broadcast)** — 60 s end-to-end Amethyst
|
||||
* speaker → Chromium listener; assert the captured PCM has the
|
||||
* expected sample count and the 440 Hz peak survives the full
|
||||
* window without decoder failure.
|
||||
*
|
||||
* The spec specifies `framesPerGroup = 50` "against actual
|
||||
* `Container.Consumer`", but two constraints reshape this here:
|
||||
*
|
||||
* 1. `@moq/hang` 0.2.4 (the published version pinned in
|
||||
* `nestsClient-browser-interop/package.json`) does not export
|
||||
* the high-level `Container.Consumer` / `Format` API. Phase 4
|
||||
* uses `Container.Legacy.Consumer` directly — same data path
|
||||
* `@moq/watch` uses internally for `container.kind = "legacy"`.
|
||||
* 2. `framesPerGroup = 50` against the local `moq-relay 0.10.25
|
||||
* --auth-public ""` harness hits the per-subscriber forward
|
||||
* cliff documented in
|
||||
* `2026-05-07-framespergroup-reconciliation.md` — the relay
|
||||
* forwards the `Group` control header but holds the frame
|
||||
* payload, so no audio reaches the listener at all. Production
|
||||
* uses `framesPerGroup = 50`; locally we pin `5` to bypass
|
||||
* the local-relay-specific cliff and still exercise the
|
||||
* browser path's long-haul behaviour.
|
||||
*
|
||||
* What this catches that I1 forward (browser, 10 s) doesn't:
|
||||
* - Chromium WebTransport `MAX_STREAMS_UNI` credit drift over
|
||||
* thousands of unidirectional streams (60 s × 10 streams/s =
|
||||
* 600 streams; far past the connection's initial window),
|
||||
* - `@moq/hang` `Container.Legacy.Consumer` group-queue eviction
|
||||
* (`MAX_GROUP_AGE = 30 s` in moq-rs; we pass that bound twice),
|
||||
* - WebCodecs `AudioDecoder` pacing + memory pressure across a
|
||||
* real broadcast-length capture window.
|
||||
*/
|
||||
@Test
|
||||
fun chromium_listener_long_broadcast_60s_tone_440() =
|
||||
runBlocking {
|
||||
// 65 s wallclock budget to absorb the cold-launch lag.
|
||||
val out = runSpeakerToBrowserListen(speakerSeconds = 65)
|
||||
|
||||
// Decoder MUST NOT have errored at any point — even one
|
||||
// error means a frame couldn't decode (T8 regression, codec
|
||||
// mismatch, group-stream truncation surfaced as malformed
|
||||
// Opus). The error count is part of the meta JSON the
|
||||
// harness page emits via `console.log`.
|
||||
val errors = parseIntMetaFromStdout(out.stdout, "decoderErrors") ?: -1
|
||||
assertTrue(
|
||||
errors == 0,
|
||||
"decoderErrors=$errors during 60 s long broadcast — expected 0.\n" +
|
||||
"playwright stdout:\n${out.stdout}",
|
||||
)
|
||||
|
||||
val pcm = readFloat32Pcm(out.pcmFile)
|
||||
// Skip the 100 ms warmup window before FFT (40 ms Opus
|
||||
// look-ahead + 60 ms WebCodecs 3-frame warmup).
|
||||
val warmupSamples = AudioFormat.SAMPLE_RATE_HZ / 10
|
||||
assertTrue(
|
||||
pcm.size > warmupSamples,
|
||||
"captured PCM (${pcm.size} samples) shorter than warmup window — " +
|
||||
"page never received audio.\nplaywright stdout:\n${out.stdout}",
|
||||
)
|
||||
val analysed = pcm.copyOfRange(warmupSamples, pcm.size)
|
||||
|
||||
// Sample-count floor: ≥ 50 s of decoded PCM. The full
|
||||
// possible window is ~60 s minus the page's cold-launch
|
||||
// tail-truncation (typically ~3-10 s on a fresh runner).
|
||||
// 50 s is the regression bar — anything less indicates
|
||||
// the browser stopped receiving frames mid-broadcast
|
||||
// (the very mode I13 is meant to catch).
|
||||
val minSamples = 50 * AudioFormat.SAMPLE_RATE_HZ
|
||||
assertTrue(
|
||||
analysed.size >= minSamples,
|
||||
"captured ${analysed.size} samples (~${analysed.size / AudioFormat.SAMPLE_RATE_HZ} s); " +
|
||||
"expected ≥ 50 s of decoded PCM in a 60 s long broadcast — possible " +
|
||||
"browser-side stream-credit exhaustion, group-queue eviction, or " +
|
||||
"decoder backpressure.\nplaywright stdout:\n${out.stdout}",
|
||||
)
|
||||
|
||||
PcmAssertions.assertFftPeak(
|
||||
analysed,
|
||||
expectedHz = 440.0,
|
||||
halfWindowHz = 5.0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **I14 (WebCodecs warmup × CSD-skip interaction)** — assert
|
||||
* Chromium's `AudioDecoder` does NOT error during the standard
|
||||
* 3-frame warmup window when fed Opus packets from the JVM
|
||||
* `JvmOpusEncoder`. A T8 regression that leaks `OpusHead`
|
||||
* (the 19-byte RFC 7845 identification header) as a normal audio
|
||||
* frame would land in the warmup window and trip
|
||||
* `AudioDecoder.error` — Chromium's WebCodecs implementation
|
||||
* rejects non-Opus-packet bytes with a `DataError`.
|
||||
*
|
||||
* The complement (FFT peak survives even if the decoder absorbed
|
||||
* the stray frame silently) is already covered by I1 forward;
|
||||
* I14 is the deterministic tripwire on the error-callback path.
|
||||
*
|
||||
* NOTE: The JVM speaker uses libopus (`JvmOpusEncoder`) directly,
|
||||
* which never produces a CSD prefix — so on this test path I14
|
||||
* effectively asserts no spurious decode failures. T8 itself is
|
||||
* an Android-`MediaCodecOpusEncoder`-specific fix; the matching
|
||||
* Android-side regression test would require a different harness
|
||||
* (no Chromium on Android in this build). We keep I14 here as
|
||||
* the browser-tier mate of `HangInteropTest.first_audio_frame_is_not_opus_codec_config`
|
||||
* (I11) — together they assert the wire format is decoder-clean
|
||||
* on both reference paths.
|
||||
*/
|
||||
@Test
|
||||
fun chromium_decoder_no_errors_through_warmup_window() =
|
||||
runBlocking {
|
||||
// 10 s capture for parity with I1 forward — Chromium
|
||||
// cold-launch + Playwright runner setup eats 3-5 s before
|
||||
// the page's `durationSec` window starts ticking. A shorter
|
||||
// window ends before any frames reach the decoder, which
|
||||
// would also pass `decoderErrors == 0` vacuously.
|
||||
val out = runSpeakerToBrowserListen(speakerSeconds = 10)
|
||||
val errors = parseIntMetaFromStdout(out.stdout, "decoderErrors") ?: -1
|
||||
assertTrue(
|
||||
errors == 0,
|
||||
"AudioDecoder.error fired $errors times during a 10 s broadcast — expected 0. " +
|
||||
"A T8 regression (OpusHead leaked as audio frame) would surface here as " +
|
||||
"Chromium rejecting the frame.\nplaywright stdout:\n${out.stdout}",
|
||||
)
|
||||
// NOTE: deliberately no `outputs >= 4` assertion here. The
|
||||
// Phase 4 browser harness has a known cold-launch race
|
||||
// (Chromium 3-10 s boot vs. the page's `durationSec` window)
|
||||
// that occasionally produces `decoderOutputs == 0` even
|
||||
// when the speaker is healthy — see
|
||||
// `2026-05-06-phase4-browser-harness-results.md`'s I1
|
||||
// sample-count tolerance discussion. Since I14's
|
||||
// load-bearing invariant is an *absence* assertion (no
|
||||
// decoder errors), zero-frames is vacuously safe — a
|
||||
// T8 regression would only trigger on whichever frames
|
||||
// DO arrive, and across runs at least one will. Strict
|
||||
// outputs-floor would fail-flake without adding coverage.
|
||||
}
|
||||
|
||||
/**
|
||||
* **I1 forward (browser)** — Amethyst Kotlin speaker → Chromium
|
||||
* `@moq/lite` listener with `@moq/hang` `Container.Legacy.Consumer`.
|
||||
@@ -363,6 +501,34 @@ private fun parseMoqVersionFromStdout(stdout: String): String? {
|
||||
return stdout.substring(valueStart, valueEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an integer meta field (e.g. `decoderErrors`, `decoderOutputs`)
|
||||
* out of the trailing JSON line the Playwright spec emits. Same shape
|
||||
* as [parseMoqVersionFromStdout] — substring search rather than full
|
||||
* JSON parse, since the harness page emits a single
|
||||
* `{"state":"done","meta":{"decoderErrors":0,...}}` line and we
|
||||
* shouldn't pull in a JSON dependency just for two helpers.
|
||||
*
|
||||
* Returns `null` if the field is missing OR if its value isn't a
|
||||
* non-negative integer literal — both are test-failure conditions
|
||||
* the caller asserts on.
|
||||
*/
|
||||
private fun parseIntMetaFromStdout(
|
||||
stdout: String,
|
||||
field: String,
|
||||
): Int? {
|
||||
val needle = "\"$field\":"
|
||||
val start = stdout.indexOf(needle)
|
||||
if (start < 0) return null
|
||||
var i = start + needle.length
|
||||
// Skip whitespace + optional sign
|
||||
while (i < stdout.length && stdout[i].isWhitespace()) i++
|
||||
val numStart = i
|
||||
while (i < stdout.length && stdout[i].isDigit()) i++
|
||||
if (i == numStart) return null
|
||||
return stdout.substring(numStart, i).toIntOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file of native-endian Float32 LE PCM into a [FloatArray].
|
||||
* Matches the format the bun WS server appends per binary frame —
|
||||
|
||||
Reference in New Issue
Block a user