From ce38c362d588094eda3b1e997647ec279ca2faab Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 14:16:55 -0700 Subject: [PATCH 1/6] chore(quest): claim capture channel-count investigation Co-Authored-By: GPT-6 From a2227326b1a3db7d647586e49d09d2b3dc602383 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 14:41:45 -0700 Subject: [PATCH 2/6] test(publish): isolate capture audio format overrides Co-Authored-By: GPT-6 --- quest/m1/publish-audio-channel-count.md | 17 ++++ .../js/capture-channel-count.browser.ts | 27 +++++++ .../clients/js/capture-channel-count.html | 2 + .../clients/js/src/capture-channel-count.ts | 79 +++++++++++++++++++ test/interop/clients/js/src/fixture.ts | 7 +- test/interop/clients/js/tsconfig.json | 1 + test/justfile | 2 +- 7 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 test/interop/clients/js/capture-channel-count.browser.ts create mode 100644 test/interop/clients/js/capture-channel-count.html create mode 100644 test/interop/clients/js/src/capture-channel-count.ts diff --git a/quest/m1/publish-audio-channel-count.md b/quest/m1/publish-audio-channel-count.md index dddfafba752..fb6a200d230 100644 --- a/quest/m1/publish-audio-channel-count.md +++ b/quest/m1/publish-audio-channel-count.md @@ -59,6 +59,23 @@ The `just test media` audio-tone misses under load were not this: the fixture takes the `"max"` path, and every miss was the player's own buffering silence with audio still arriving. +### Investigation on Linux + +The isolated real-worklet matrix is available in +`test/interop/clients/js/capture-channel-count.browser.ts` and runs in +`just test harness`. Chromium 153 on Linux delivered 512 quanta in every +none/rate/channels/both case, with zero timestamp gaps and the requested output +formats. The diagnostic counted brief silence near startup in the baseline, +rate-only, and channels-only cases, not a channel-specific failure. The +destination-node track refused a channelCount constraint with +OverconstrainedError, so this does not cover the applied microphone constraint +path. + +This is evidence against attributing the macOS report to channelCount from +the existing combined-override experiment. It is not evidence that the macOS +issue is fixed. Run the same matrix on macOS, then the full media fixture and +a real constrained microphone before changing the capture graph. + ## Related - [Browser benchmarks](/quest/m2/browser-benchmarks.md) - the other place browser-side capture and encode costs get measured diff --git a/test/interop/clients/js/capture-channel-count.browser.ts b/test/interop/clients/js/capture-channel-count.browser.ts new file mode 100644 index 00000000000..4feb8d3a192 --- /dev/null +++ b/test/interop/clients/js/capture-channel-count.browser.ts @@ -0,0 +1,27 @@ +/** Capture diagnostic uses the real browser and worklet, with each override isolated. */ +import { expect, test } from "bun:test"; +import { createServer } from "vite"; +import { launch } from "./harness"; + +test("capture format override matrix", async () => { + const server = await createServer({ server: { host: "127.0.0.1", port: 0 } }); + await server.listen(); + const browser = await launch(["--autoplay-policy=no-user-gesture-required"]); + try { + const page = await browser.newPage(); + await page.goto(`${server.resolvedUrls?.local[0]}capture-channel-count.html`); + await page.waitForFunction(() => typeof window.captureMatrix === "function"); + const result = await page.evaluate(() => window.captureMatrix()); + console.log(JSON.stringify({ platform: process.platform, browser: browser.version(), result })); + expect(result).toHaveLength(5); + expect(result.slice(0, 4)).toMatchObject([ + { mode: "none", audible: true, format: { sampleRate: 48_000, channelCount: 2 } }, + { mode: "rate", audible: true, format: { sampleRate: 48_000, channelCount: 2 } }, + { mode: "channels", audible: true, format: { sampleRate: 48_000, channelCount: 1 } }, + { mode: "both", audible: true, format: { sampleRate: 48_000, channelCount: 1 } }, + ]); + } finally { + await browser.close(); + await server.close(); + } +}, 30_000); diff --git a/test/interop/clients/js/capture-channel-count.html b/test/interop/clients/js/capture-channel-count.html new file mode 100644 index 00000000000..d19426181b3 --- /dev/null +++ b/test/interop/clients/js/capture-channel-count.html @@ -0,0 +1,2 @@ + + diff --git a/test/interop/clients/js/src/capture-channel-count.ts b/test/interop/clients/js/src/capture-channel-count.ts new file mode 100644 index 00000000000..0e33eecdf3d --- /dev/null +++ b/test/interop/clients/js/src/capture-channel-count.ts @@ -0,0 +1,79 @@ +/** Real capture measurements isolating format overrides before encoding or playout. */ +import * as Publish from "@moq/publish"; +import { Effect } from "@moq/signals"; + +declare global { + interface Window { + captureMatrix: () => Promise; + } +} + +window.captureMatrix = async () => { + const results = []; + for (const mode of ["none", "rate", "channels", "both", "constraint"]) { + const source = new AudioContext({ sampleRate: 48_000 }); + const oscillator = source.createOscillator(); + const destination = source.createMediaStreamDestination(); + oscillator.connect(destination); + oscillator.start(); + await source.resume(); + const track = destination.stream.getAudioTracks()[0]; + if (mode === "constraint") { + try { + await track.applyConstraints({ channelCount: 1 }); + } catch (error) { + results.push({ mode, unsupported: String(error) }); + track.stop(); + oscillator.stop(); + await source.close(); + continue; + } + } + const capture = new Publish.Audio.Capture({ + source: { track: track as Publish.Audio.StreamTrack, kind: "music" }, + sampleRate: mode === "rate" || mode === "both" ? 48_000 : undefined, + channelCount: mode === "channels" || mode === "both" ? 1 : undefined, + }); + const effect = new Effect(); + try { + let fanout = capture.out.frames.peek(); + while (!fanout) { + await capture.out.frames.changed(); + fanout = capture.out.frames.peek(); + } + const reader = fanout.subscribe(effect).getReader(); + let audible = false; + let leadingSilent = 0; + let silent = 0; + let gaps = 0; + let expected: number | undefined; + for (let i = 0; i < 512; i++) { + const { value: frame } = await reader.read(); + if (!frame) throw new Error("capture ended"); + const rms = Math.sqrt(frame.channels[0].reduce((sum, v) => sum + v * v, 0) / frame.channels[0].length); + if (rms > 0.01) audible = true; + else if (audible) silent++; + else leadingSilent++; + if (expected !== undefined && Math.abs(frame.timestamp - expected) > 2) gaps++; + expected = frame.timestamp + (frame.channels[0].length / 48_000) * 1_000_000; + } + await reader.cancel(); + results.push({ + mode, + audible, + leadingSilent, + silent, + gaps, + format: capture.out.format.peek(), + constraints: track.getConstraints(), + }); + } finally { + effect.close(); + capture.close(); + track.stop(); + oscillator.stop(); + await source.close(); + } + } + return results; +}; diff --git a/test/interop/clients/js/src/fixture.ts b/test/interop/clients/js/src/fixture.ts index dbbe25ab678..406aeb0a598 100644 --- a/test/interop/clients/js/src/fixture.ts +++ b/test/interop/clients/js/src/fixture.ts @@ -108,11 +108,8 @@ export class Fixture { // then requires an audio catalog after the click, which covers the capture resuming its own // suspended graph. // - // No sampleRate or channelCount override: the graph already runs at SAMPLE_RATE, and asking - // the capture for one channel puts the AudioWorklet behind an explicit Web Audio downmix - // whose output drops PCM under load, which reaches the player as gaps of silence. Publishing - // the destination node's own format costs nothing here, since the tone is the same in both - // channels. + // Keep both overrides absent until the macOS silence report is isolated. Removing both + // improved that run, but it did not establish which override caused the loss or why. const audioCapture = new Publish.Audio.Capture({ source: { track: audioTrack, kind: "music" }, }); diff --git a/test/interop/clients/js/tsconfig.json b/test/interop/clients/js/tsconfig.json index 54c825befcb..d0bb8c474c0 100644 --- a/test/interop/clients/js/tsconfig.json +++ b/test/interop/clients/js/tsconfig.json @@ -8,6 +8,7 @@ "driver.ts", "harness.ts", "harness.browser.ts", + "capture-channel-count.browser.ts", "media.ts", "src", "vite.config.ts", diff --git a/test/justfile b/test/justfile index 30114d289d7..8e134aeaf77 100644 --- a/test/justfile +++ b/test/justfile @@ -25,7 +25,7 @@ harness: cd .. && bun test test/interop/compat/resolve.test.ts cd .. && bun install --frozen-lockfile cd interop/clients/js && bunx playwright install chromium - cd interop/clients/js && bun test ./harness.browser.ts ./src/contract.test.ts + cd interop/clients/js && bun test ./harness.browser.ts ./capture-channel-count.browser.ts ./src/contract.test.ts # Cross-language media interop test, built from this checkout. Stands up a # relay and runs the publisher x subscriber matrix. Default: rust only (fast From 239240ba578f9a5961dc14ab71488fa24be55071 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 20:56:27 -0700 Subject: [PATCH 3/6] test(publish): inspect every captured audio channel Reject invalid PCM and prove a missing stereo channel cannot pass the override matrix. Co-Authored-By: GPT-6 Astra --- quest/m1/publish-audio-channel-count.md | 5 +++- .../js/capture-channel-count.browser.ts | 23 +++++++++++++++---- .../clients/js/src/capture-channel-count.ts | 9 ++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/quest/m1/publish-audio-channel-count.md b/quest/m1/publish-audio-channel-count.md index fb6a200d230..65a69fd5af9 100644 --- a/quest/m1/publish-audio-channel-count.md +++ b/quest/m1/publish-audio-channel-count.md @@ -65,7 +65,10 @@ The isolated real-worklet matrix is available in `test/interop/clients/js/capture-channel-count.browser.ts` and runs in `just test harness`. Chromium 153 on Linux delivered 512 quanta in every none/rate/channels/both case, with zero timestamp gaps and the requested output -formats. The diagnostic counted brief silence near startup in the baseline, +formats. The diagnostic now checks every output channel for finite, nonempty +PCM and audibility, rather than trusting channel zero and format metadata. +A missing-second-channel negative control is part of its validation evidence. +The diagnostic counted brief silence near startup in the baseline, rate-only, and channels-only cases, not a channel-specific failure. The destination-node track refused a channelCount constraint with OverconstrainedError, so this does not cover the applied microphone constraint diff --git a/test/interop/clients/js/capture-channel-count.browser.ts b/test/interop/clients/js/capture-channel-count.browser.ts index 4feb8d3a192..df2183b9c2e 100644 --- a/test/interop/clients/js/capture-channel-count.browser.ts +++ b/test/interop/clients/js/capture-channel-count.browser.ts @@ -15,10 +15,25 @@ test("capture format override matrix", async () => { console.log(JSON.stringify({ platform: process.platform, browser: browser.version(), result })); expect(result).toHaveLength(5); expect(result.slice(0, 4)).toMatchObject([ - { mode: "none", audible: true, format: { sampleRate: 48_000, channelCount: 2 } }, - { mode: "rate", audible: true, format: { sampleRate: 48_000, channelCount: 2 } }, - { mode: "channels", audible: true, format: { sampleRate: 48_000, channelCount: 1 } }, - { mode: "both", audible: true, format: { sampleRate: 48_000, channelCount: 1 } }, + { + mode: "none", + audible: true, + channelsAudible: [true, true], + format: { sampleRate: 48_000, channelCount: 2 }, + }, + { + mode: "rate", + audible: true, + channelsAudible: [true, true], + format: { sampleRate: 48_000, channelCount: 2 }, + }, + { + mode: "channels", + audible: true, + channelsAudible: [true], + format: { sampleRate: 48_000, channelCount: 1 }, + }, + { mode: "both", audible: true, channelsAudible: [true], format: { sampleRate: 48_000, channelCount: 1 } }, ]); } finally { await browser.close(); diff --git a/test/interop/clients/js/src/capture-channel-count.ts b/test/interop/clients/js/src/capture-channel-count.ts index 0e33eecdf3d..0519e6ab8c1 100644 --- a/test/interop/clients/js/src/capture-channel-count.ts +++ b/test/interop/clients/js/src/capture-channel-count.ts @@ -43,6 +43,7 @@ window.captureMatrix = async () => { } const reader = fanout.subscribe(effect).getReader(); let audible = false; + const channelsAudible: boolean[] = []; let leadingSilent = 0; let silent = 0; let gaps = 0; @@ -50,6 +51,13 @@ window.captureMatrix = async () => { for (let i = 0; i < 512; i++) { const { value: frame } = await reader.read(); if (!frame) throw new Error("capture ended"); + for (const [channel, samples] of frame.channels.entries()) { + if (!samples.length || samples.some((sample) => !Number.isFinite(sample))) { + throw new Error(`capture channel ${channel} has invalid PCM`); + } + const rms = Math.sqrt(samples.reduce((sum, value) => sum + value * value, 0) / samples.length); + channelsAudible[channel] = (channelsAudible[channel] ?? false) || rms > 0.01; + } const rms = Math.sqrt(frame.channels[0].reduce((sum, v) => sum + v * v, 0) / frame.channels[0].length); if (rms > 0.01) audible = true; else if (audible) silent++; @@ -61,6 +69,7 @@ window.captureMatrix = async () => { results.push({ mode, audible, + channelsAudible, leadingSilent, silent, gaps, From b05e88176bc05a2f5118a2cd4ac3bfae4a5c85f1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 11:21:19 -0700 Subject: [PATCH 4/6] test(publish): pin the media fixture to mono capture again The capture override matrix and the full media fixture pass on macOS with channelCount 1, so the reported loss was not the explicit downmix. Restore the pin so every media run covers that path, assert zero timestamp gaps in the matrix, and delete the investigation quest. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/publish-audio-channel-count.md | 84 ------------------- .../js/capture-channel-count.browser.ts | 29 ++----- .../clients/js/src/capture-channel-count.ts | 31 +------ test/interop/clients/js/src/fixture.ts | 6 +- 5 files changed, 12 insertions(+), 139 deletions(-) delete mode 100644 quest/m1/publish-audio-channel-count.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 38a87c57bec..46d7e3847b6 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -173,7 +173,6 @@ disabled rung strands WHEP, HLS, and moq-mux export viewers. - [Duration parser defaults](/quest/m1/cli-duration-defaults.md) - a moq-tokio duration set in code beats the parser default on a parsed or merged config, zero included, after #5350 - [Front parking](/quest/m1/origin-front-parks.md) - an unroutable request waits on a front instead of re-asking on every route-table move - [Route wakes](/quest/m1/route-wakes.md) - a route change wakes only the fronts it can move, so pool churn stops scaling with served paths -- [Publish channel count](/quest/m1/publish-audio-channel-count.md) - forcing a channel count on an Audio.Capture stops costing the subscriber gaps of silence - [JS request deadline](/quest/m1/js-request-deadline.md) - each JS request, PUBLISH_NAMESPACE included, has one fatal 10 s timer from create to answer and fails fast without stream credit, so nothing queues or retries - [Rust request credit](/quest/m1/rs-request-credit.md) - a moq-net request fails at once without stream credit instead of waiting - [qmux no-wait opens](/quest/m1/qmux-no-wait.md) - @moq/qmux rejects an over-limit create when waitUntilAvailable is false, like Chrome diff --git a/quest/m1/publish-audio-channel-count.md b/quest/m1/publish-audio-channel-count.md deleted file mode 100644 index 65a69fd5af9..00000000000 --- a/quest/m1/publish-audio-channel-count.md +++ /dev/null @@ -1,84 +0,0 @@ -# [M] Forcing a channel count on Audio.Capture does not cost audio - -## Goal - -`new Audio.Capture({ source, channelCount: 1 })` publishes continuous audio. -A subscriber hears no gaps that the same capture without the override does not -also have, so the documented way to pin a publisher's channel count stops being -a way to degrade it. - -Boundaries: `channelCount` keeps its meaning, "force the captured channel -count", and the published shape does not move. Whether the fix is to stop -forcing `channelCountMode: "explicit"`, to do the downmix off the realtime -graph, or to keep the mode and remove whatever it starves, is the quest's to -decide. - -## Plan - -`js/publish/src/audio/capture.ts` sets -`channelCountMode: requestedChannels !== undefined ? "explicit" : "max"`, so -supplying `channelCount` is what puts the AudioWorklet behind a forced Web -Audio downmix. Omitting it does not always take the `"max"` path: -`requestedChannels` falls back to `requestedChannelCount(source.track)`, -so an applied `getUserMedia` channelCount constraint forces `"explicit"` too. -Account for that path when isolating the cause. - -What was measured, on macOS headless Chromium through `just test media`, -publishing a `MediaStreamAudioDestinationNode` track into a graph already -running at 48 kHz: - -- With `sampleRate: 48000, channelCount: 1`: 11 probe samples across a run read - a flat spectrum (peak equal to median, below `AUDIBLE_RMS`), which is - silence, not a weak tone. The `audio tone` check fell to 82-88% against its - 90% floor and took two negative controls down with it. -- With both overrides removed: zero such samples, and every media check passed. -- `main`, whose fixture pinned the same format on the encoder rather than the - capture, reported `tone 150dB above the floor` and no silent samples at all. - -What is not established, and should be first: - -- The two overrides were removed together, so `channelCount` is implicated by - reading the `channelCountMode` choice, not by an isolated run. Re-run with - `sampleRate` alone and with `channelCount` alone before believing the - attribution. -- The mechanism. A forced downmix starving or stalling the worklet is the - obvious guess and is not evidence. -- Whether a `getUserMedia` microphone track shows it, or only a - destination-node track, whose channel count Web Audio reports as 2 by - default. `requestedChannelCount` exists for the macOS - mono-mic misreport, so that path has a real caller and cannot simply lose the - override. - -The regression belongs where the evidence came from: the browser fixture can -pin a channel count again once this is fixed, and -`test/interop/clients/js/src/fixture.ts` carries a comment saying why it does -not. A unit test that counts worklet callbacks under an explicit downmix would -be cheaper than a full media run, if one can be made to fail reliably. - -The `just test media` audio-tone misses under load were not this: the fixture -takes the `"max"` path, and every miss was the player's own buffering silence -with audio still arriving. - -### Investigation on Linux - -The isolated real-worklet matrix is available in -`test/interop/clients/js/capture-channel-count.browser.ts` and runs in -`just test harness`. Chromium 153 on Linux delivered 512 quanta in every -none/rate/channels/both case, with zero timestamp gaps and the requested output -formats. The diagnostic now checks every output channel for finite, nonempty -PCM and audibility, rather than trusting channel zero and format metadata. -A missing-second-channel negative control is part of its validation evidence. -The diagnostic counted brief silence near startup in the baseline, -rate-only, and channels-only cases, not a channel-specific failure. The -destination-node track refused a channelCount constraint with -OverconstrainedError, so this does not cover the applied microphone constraint -path. - -This is evidence against attributing the macOS report to channelCount from -the existing combined-override experiment. It is not evidence that the macOS -issue is fixed. Run the same matrix on macOS, then the full media fixture and -a real constrained microphone before changing the capture graph. - -## Related - -- [Browser benchmarks](/quest/m2/browser-benchmarks.md) - the other place browser-side capture and encode costs get measured diff --git a/test/interop/clients/js/capture-channel-count.browser.ts b/test/interop/clients/js/capture-channel-count.browser.ts index df2183b9c2e..9f248210f99 100644 --- a/test/interop/clients/js/capture-channel-count.browser.ts +++ b/test/interop/clients/js/capture-channel-count.browser.ts @@ -1,4 +1,4 @@ -/** Capture diagnostic uses the real browser and worklet, with each override isolated. */ +/** Capture diagnostic uses the real browser and worklet, with each format override isolated. */ import { expect, test } from "bun:test"; import { createServer } from "vite"; import { launch } from "./harness"; @@ -12,28 +12,11 @@ test("capture format override matrix", async () => { await page.goto(`${server.resolvedUrls?.local[0]}capture-channel-count.html`); await page.waitForFunction(() => typeof window.captureMatrix === "function"); const result = await page.evaluate(() => window.captureMatrix()); - console.log(JSON.stringify({ platform: process.platform, browser: browser.version(), result })); - expect(result).toHaveLength(5); - expect(result.slice(0, 4)).toMatchObject([ - { - mode: "none", - audible: true, - channelsAudible: [true, true], - format: { sampleRate: 48_000, channelCount: 2 }, - }, - { - mode: "rate", - audible: true, - channelsAudible: [true, true], - format: { sampleRate: 48_000, channelCount: 2 }, - }, - { - mode: "channels", - audible: true, - channelsAudible: [true], - format: { sampleRate: 48_000, channelCount: 1 }, - }, - { mode: "both", audible: true, channelsAudible: [true], format: { sampleRate: 48_000, channelCount: 1 } }, + expect(result).toEqual([ + { mode: "none", channelsAudible: [true, true], gaps: 0, format: { sampleRate: 48_000, channelCount: 2 } }, + { mode: "rate", channelsAudible: [true, true], gaps: 0, format: { sampleRate: 48_000, channelCount: 2 } }, + { mode: "channels", channelsAudible: [true], gaps: 0, format: { sampleRate: 48_000, channelCount: 1 } }, + { mode: "both", channelsAudible: [true], gaps: 0, format: { sampleRate: 48_000, channelCount: 1 } }, ]); } finally { await browser.close(); diff --git a/test/interop/clients/js/src/capture-channel-count.ts b/test/interop/clients/js/src/capture-channel-count.ts index 0519e6ab8c1..f4d6701edbc 100644 --- a/test/interop/clients/js/src/capture-channel-count.ts +++ b/test/interop/clients/js/src/capture-channel-count.ts @@ -10,7 +10,7 @@ declare global { window.captureMatrix = async () => { const results = []; - for (const mode of ["none", "rate", "channels", "both", "constraint"]) { + for (const mode of ["none", "rate", "channels", "both"]) { const source = new AudioContext({ sampleRate: 48_000 }); const oscillator = source.createOscillator(); const destination = source.createMediaStreamDestination(); @@ -18,17 +18,6 @@ window.captureMatrix = async () => { oscillator.start(); await source.resume(); const track = destination.stream.getAudioTracks()[0]; - if (mode === "constraint") { - try { - await track.applyConstraints({ channelCount: 1 }); - } catch (error) { - results.push({ mode, unsupported: String(error) }); - track.stop(); - oscillator.stop(); - await source.close(); - continue; - } - } const capture = new Publish.Audio.Capture({ source: { track: track as Publish.Audio.StreamTrack, kind: "music" }, sampleRate: mode === "rate" || mode === "both" ? 48_000 : undefined, @@ -42,10 +31,7 @@ window.captureMatrix = async () => { fanout = capture.out.frames.peek(); } const reader = fanout.subscribe(effect).getReader(); - let audible = false; const channelsAudible: boolean[] = []; - let leadingSilent = 0; - let silent = 0; let gaps = 0; let expected: number | undefined; for (let i = 0; i < 512; i++) { @@ -58,24 +44,11 @@ window.captureMatrix = async () => { const rms = Math.sqrt(samples.reduce((sum, value) => sum + value * value, 0) / samples.length); channelsAudible[channel] = (channelsAudible[channel] ?? false) || rms > 0.01; } - const rms = Math.sqrt(frame.channels[0].reduce((sum, v) => sum + v * v, 0) / frame.channels[0].length); - if (rms > 0.01) audible = true; - else if (audible) silent++; - else leadingSilent++; if (expected !== undefined && Math.abs(frame.timestamp - expected) > 2) gaps++; expected = frame.timestamp + (frame.channels[0].length / 48_000) * 1_000_000; } await reader.cancel(); - results.push({ - mode, - audible, - channelsAudible, - leadingSilent, - silent, - gaps, - format: capture.out.format.peek(), - constraints: track.getConstraints(), - }); + results.push({ mode, channelsAudible, gaps, format: capture.out.format.peek() }); } finally { effect.close(); capture.close(); diff --git a/test/interop/clients/js/src/fixture.ts b/test/interop/clients/js/src/fixture.ts index 406aeb0a598..2f0e7c835a0 100644 --- a/test/interop/clients/js/src/fixture.ts +++ b/test/interop/clients/js/src/fixture.ts @@ -108,10 +108,12 @@ export class Fixture { // then requires an audio catalog after the click, which covers the capture resuming its own // suspended graph. // - // Keep both overrides absent until the macOS silence report is isolated. Removing both - // improved that run, but it did not establish which override caused the loss or why. + // Pinning mono puts the worklet behind Web Audio's explicit downmix of the stereo destination + // track, so the tone checks also cover that path. const audioCapture = new Publish.Audio.Capture({ source: { track: audioTrack, kind: "music" }, + sampleRate: SAMPLE_RATE, + channelCount: 1, }); this.#signals.cleanup(() => audioCapture.close()); From 328ba5f0c021473c793dc3d336be1779670d13c9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 11:17:40 -0700 Subject: [PATCH 5/6] test(publish): keep the media fixture unpinned, track the stall CI failed the media audio-tone check in 2 of 3 runs with the mono pin, against ~5% unpinned, with the same player stall either way while the captured PCM stays continuous. Drop the pin, keep the capture matrix, and keep the quest open as the home for that stall and the unpinned cold-start flake. Give the first harness browser test the same 30 s timeout as its siblings, since a cold browser launch can exceed bun's default. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 + quest/m1/publish-audio-channel-count.md | 49 ++++++++++++++++++++++ quest/m1/test-flakes-2/README.md | 1 + test/interop/clients/js/harness.browser.ts | 2 +- test/interop/clients/js/src/fixture.ts | 9 ++-- 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 quest/m1/publish-audio-channel-count.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 2de176b1b76..721588e1278 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -143,6 +143,7 @@ above them. - [Watch worker](/quest/m1/watch-worker.md) - watch playback runs in a worker onto an OffscreenCanvas, so main-thread jank never stalls video or audio - [Rust papercuts](/quest/m1/papercuts-rs.md) - HTTP refusals are counted, and a `u64::MAX` resume is unbounded - [Duration parser defaults](/quest/m1/cli-duration-defaults.md) - a moq-tokio duration set in code beats the parser default on a parsed or merged config, zero included, after #5350 +- [Publish channel count](/quest/m1/publish-audio-channel-count.md) - forcing a channel count on an Audio.Capture stops costing the subscriber gaps of silence, and the media fixture's cold-start audio-tone flake is fixed at its cause - [JS request deadline](/quest/m1/js-request-deadline.md) - each JS request, PUBLISH_NAMESPACE included, has one fatal 10 s timer from create to answer and fails fast without stream credit, so nothing queues or retries - [Handshake deadline covers admission](/quest/m1/handshake-admission-deadline.md) - `listen.timeout` bounds a connection through auth admission on both listeners, replacing the hardcoded admission timeout - [JS cancellation bench](/quest/m1/js-forget-bench.md) - the requests bench sweeps cancellation over competing dynamic routes, so a per-cancel cost that grows with routes shows as a slope diff --git a/quest/m1/publish-audio-channel-count.md b/quest/m1/publish-audio-channel-count.md new file mode 100644 index 00000000000..ea944fe946c --- /dev/null +++ b/quest/m1/publish-audio-channel-count.md @@ -0,0 +1,49 @@ +# [M] Forcing a channel count on Audio.Capture does not cost audio + +## Goal + +`new Audio.Capture({ source, channelCount: 1 })` publishes audio a subscriber +plays as smoothly as the same capture without the override, so pinning a +publisher's channel count stops being a way to degrade it. The browser media +fixture can then pin mono again. + +This is also the home for the media fixture's cold-start audio-tone flake +without the pin, which shows the same symptom. Fix it at its cause, not by +lowering the tone floor. + +`channelCount` keeps its meaning, "force the captured channel count". + +## Plan + +What is known: + +- The capture's PCM is not the problem. The real-worklet matrix in + `test/interop/clients/js/capture-channel-count.browser.ts` (in + `just test harness`) shows every override case continuous, with no timestamp + gaps and audible output in every channel, on Linux and macOS. Every case, + the no-override baseline included, drops one short run of silent quanta + during startup. +- The player is. In CI (#5270), the pinned fixture failed the `just test media` + audio-tone check in 2 of 3 runs, against ~5% of unpinned runs (5 of 108, + all cold start). Failures look the same either way: player samples go + `stalled` while received audio bytes keep growing, and the jitter delay jumps + from ~77 ms to 500-700 ms. + +So the explicit downmix (`channelCountMode: "explicit"` in +`js/publish/src/audio/capture.ts`) seems to change something the player +reacts to, most likely frame cadence or burstiness, not content. Measure the +publisher's frame arrival timing pinned vs unpinned before picking a fix, and +check whether the player's re-stall behavior +([watch jitter target](/quest/m1/audio-jitter-target/watch.md)) is the +amplifier. A loaded local host fails the media fixture either way, so compare +on CI or a quiet machine. + +Not covered: an applied `getUserMedia` channelCount constraint also takes the +explicit path, but a destination-node track refuses that constraint, so the +harness cannot exercise it. + +## Related + +- [Watch jitter target](/quest/m1/audio-jitter-target/watch.md) - a ring that runs dry re-stalls and refills to the target +- [Test flakes, round 2](/quest/m1/test-flakes-2/README.md) - load flakes fixed at their cause +- [Browser benchmarks](/quest/m2/browser-benchmarks.md) - the other place browser-side capture and encode costs get measured diff --git a/quest/m1/test-flakes-2/README.md b/quest/m1/test-flakes-2/README.md index a260c2ec95e..f4b81052287 100644 --- a/quest/m1/test-flakes-2/README.md +++ b/quest/m1/test-flakes-2/README.md @@ -45,3 +45,4 @@ Public API: none. Wire: none. ## Related - [Controlled time](/quest/m1/time/README.md) - the systemic fix these rules ask for: every crate on a controlled clock +- [Publish channel count](/quest/m1/publish-audio-channel-count.md) - tracks the media fixture's cold-start audio-tone flake, a player stall it shares with the mono pin diff --git a/test/interop/clients/js/harness.browser.ts b/test/interop/clients/js/harness.browser.ts index 676add78a8a..ebe1d35c81e 100644 --- a/test/interop/clients/js/harness.browser.ts +++ b/test/interop/clients/js/harness.browser.ts @@ -22,7 +22,7 @@ test("pause activates its button when a canvas intercepts pointer input", async } finally { await browser.close(); } -}); +}, 30_000); for (const stall of ["audio", "video"]) { test(`the paused play button stays clickable during ${stall} stalling`, async () => { diff --git a/test/interop/clients/js/src/fixture.ts b/test/interop/clients/js/src/fixture.ts index 2f0e7c835a0..20f539be271 100644 --- a/test/interop/clients/js/src/fixture.ts +++ b/test/interop/clients/js/src/fixture.ts @@ -108,12 +108,13 @@ export class Fixture { // then requires an audio catalog after the click, which covers the capture resuming its own // suspended graph. // - // Pinning mono puts the worklet behind Web Audio's explicit downmix of the stereo destination - // track, so the tone checks also cover that path. + // No sampleRate or channelCount override: the graph already runs at SAMPLE_RATE, and asking + // the capture for one channel puts the AudioWorklet behind an explicit Web Audio downmix that + // makes the player stall far more often, though the captured PCM stays continuous; see + // /quest/m1/publish-audio-channel-count.md. Publishing the destination node's own format + // costs nothing here, since the tone is the same in both channels. const audioCapture = new Publish.Audio.Capture({ source: { track: audioTrack, kind: "music" }, - sampleRate: SAMPLE_RATE, - channelCount: 1, }); this.#signals.cleanup(() => audioCapture.close()); From 60c76cfeeefd02de86e17c2f1c9f8a29bbca1cf5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 11:26:36 -0700 Subject: [PATCH 6/6] docs(quest): state what the capture matrix proves, and what it does not Co-Authored-By: Claude Opus 5.5 --- quest/m1/publish-audio-channel-count.md | 23 ++++++++++++++--------- test/interop/clients/js/src/fixture.ts | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/quest/m1/publish-audio-channel-count.md b/quest/m1/publish-audio-channel-count.md index ea944fe946c..154758631bd 100644 --- a/quest/m1/publish-audio-channel-count.md +++ b/quest/m1/publish-audio-channel-count.md @@ -17,21 +17,26 @@ lowering the tone floor. What is known: -- The capture's PCM is not the problem. The real-worklet matrix in +- The real-worklet matrix in `test/interop/clients/js/capture-channel-count.browser.ts` (in - `just test harness`) shows every override case continuous, with no timestamp - gaps and audible output in every channel, on Linux and macOS. Every case, - the no-override baseline included, drops one short run of silent quanta - during startup. -- The player is. In CI (#5270), the pinned fixture failed the `just test media` + `just test harness`) finds nothing override-specific: no timestamp gaps, + the requested format, and audible output in every channel, on Linux and + macOS. It only asserts each channel is audible at least once. An uncommitted + per-quantum trace (#5270) found one short silent run during startup in + every case, the no-override baseline included, and none after. Under heavy + load the baseline also showed a later run, so a strict per-quantum check + flakes on the source. Tightening the matrix needs a baseline-relative + measure. +- The player stalls. In CI (#5270), the pinned fixture failed the `just test media` audio-tone check in 2 of 3 runs, against ~5% of unpinned runs (5 of 108, all cold start). Failures look the same either way: player samples go `stalled` while received audio bytes keep growing, and the jitter delay jumps from ~77 ms to 500-700 ms. -So the explicit downmix (`channelCountMode: "explicit"` in -`js/publish/src/audio/capture.ts`) seems to change something the player -reacts to, most likely frame cadence or burstiness, not content. Measure the +The working hypothesis is that the explicit downmix (`channelCountMode: +"explicit"` in `js/publish/src/audio/capture.ts`) changes something the player +reacts to, such as frame cadence or burstiness, rather than dropping PCM. +Neither half is proven. Measure the publisher's frame arrival timing pinned vs unpinned before picking a fix, and check whether the player's re-stall behavior ([watch jitter target](/quest/m1/audio-jitter-target/watch.md)) is the diff --git a/test/interop/clients/js/src/fixture.ts b/test/interop/clients/js/src/fixture.ts index 20f539be271..732754be118 100644 --- a/test/interop/clients/js/src/fixture.ts +++ b/test/interop/clients/js/src/fixture.ts @@ -110,7 +110,7 @@ export class Fixture { // // No sampleRate or channelCount override: the graph already runs at SAMPLE_RATE, and asking // the capture for one channel puts the AudioWorklet behind an explicit Web Audio downmix that - // makes the player stall far more often, though the captured PCM stays continuous; see + // made the player stall far more often in CI, with no capture loss found in isolation; see // /quest/m1/publish-audio-channel-count.md. Publishing the destination node's own format // costs nothing here, since the tone is the same in both channels. const audioCapture = new Publish.Audio.Capture({