Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,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
- [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
- [Container reads drop backfill](/quest/m1/read-backfill.md) - the JS container consumer drops groups below its read cursor like Rust
Expand Down
94 changes: 42 additions & 52 deletions quest/m1/publish-audio-channel-count.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,53 @@

## 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.
`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.

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

## 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.
`channelCount` keeps its meaning, "force the captured channel count".

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.
## Plan

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.
What is known:

- The real-worklet matrix in
`test/interop/clients/js/capture-channel-count.browser.ts` (in
`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.

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
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
1 change: 1 addition & 0 deletions quest/m1/test-flakes-2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions test/interop/clients/js/capture-channel-count.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** 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";

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());
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();
await server.close();
}
}, 30_000);
2 changes: 2 additions & 0 deletions test/interop/clients/js/capture-channel-count.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!doctype html>
<script type="module" src="/src/capture-channel-count.ts"></script>
2 changes: 1 addition & 1 deletion test/interop/clients/js/harness.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
61 changes: 61 additions & 0 deletions test/interop/clients/js/src/capture-channel-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** 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<unknown[]>;
}
}

window.captureMatrix = async () => {
const results = [];
for (const mode of ["none", "rate", "channels", "both"]) {
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];
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();
const channelsAudible: boolean[] = [];
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");
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject silent quanta after capture becomes audible

When the explicit downmix emits one audible quantum and then intermittent or sustained silence, this assignment permanently latches the channel to true, so even 511 silent frames can produce the expected result. Those silent frames also retain contiguous timestamps, leaving gaps at zero. Count or bound inaudible quanta after startup so this test can detect the PCM-silence regression it is intended to cover.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the latch can't see intermittent silence, but I measured the stricter check and it doesn't hold, so I'm keeping the latch.

I traced per-quantum audibility over 5 runs (20 captures). Every mode, the no-override baseline included, drops exactly one 3-quantum silent run, somewhere in quanta 12 to 108. Then I required zero silent quanta after quantum 256 and ran it 8 times on a loaded host (load average ~44). One run failed, and it failed in mode none (silent: [3, 3]), the baseline with no override at all.

So the silence comes from the MediaStream source under load, not from the channelCount or sampleRate path. A strict check would flake on the baseline. A bound relative to the baseline would flake too, because each mode captures independently and the runs land at random. End-to-end coverage for sustained silence comes from the full media fixture's tone checks, which now run pinned to mono.

(Written by Claude Opus 5.5)

}
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, channelsAudible, gaps, format: capture.out.format.peek() });
} finally {
effect.close();
capture.close();
track.stop();
oscillator.stop();
await source.close();
}
}
return results;
};
8 changes: 4 additions & 4 deletions test/interop/clients/js/src/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ export class Fixture {
// 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.
// the capture for one channel puts the AudioWorklet behind an explicit Web Audio downmix that
// 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({
source: { track: audioTrack, kind: "music" },
});
Expand Down
1 change: 1 addition & 0 deletions test/interop/clients/js/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"driver.ts",
"harness.ts",
"harness.browser.ts",
"capture-channel-count.browser.ts",
"buffering",
"media.ts",
"src",
Expand Down
2 changes: 1 addition & 1 deletion test/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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 ./src/resume.test.ts
cd interop/clients/js && bun test ./harness.browser.ts ./capture-channel-count.browser.ts ./src/contract.test.ts ./src/resume.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
Expand Down
Loading