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:
Claude
2026-05-07 02:55:25 +00:00
parent b94737de78
commit a7ea77a35a
3 changed files with 190 additions and 1 deletions
+17 -1
View File
@@ -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).
@@ -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 —