From a7ea77a35a123082fe28826c845522f3f3df080d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 02:55:25 +0000 Subject: [PATCH] =?UTF-8?q?test(nests):=20T16=20Phase=204=20=E2=80=94=20I1?= =?UTF-8?q?3=20long=20broadcast=20+=20I14=20WebCodecs=20warmup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- nestsClient-browser-interop/src/listen.ts | 18 +- .../tests/harness.spec.ts | 7 + .../interop/native/BrowserInteropTest.kt | 166 ++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) diff --git a/nestsClient-browser-interop/src/listen.ts b/nestsClient-browser-interop/src/listen.ts index c150d7d4f..b3b9bf8de 100644 --- a/nestsClient-browser-interop/src/listen.ts +++ b/nestsClient-browser-interop/src/listen.ts @@ -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 { diff --git a/nestsClient-browser-interop/tests/harness.spec.ts b/nestsClient-browser-interop/tests/harness.spec.ts index fb48eba8f..adba9942c 100644 --- a/nestsClient-browser-interop/tests/harness.spec.ts +++ b/nestsClient-browser-interop/tests/harness.spec.ts @@ -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). diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt index 1393e4548..258546dc6 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt @@ -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 —