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).