Skip to content

test(audio-quality): grade browser audio playout against a budget, nightly - #4426

Merged
kixelated merged 6 commits into
quest/m0/audio-quality-harness/READMEfrom
quest/m0/audio-quality-harness/browser
Sep 29, 2026
Merged

kixelated merged 6 commits into
quest/m0/audio-quality-harness/READMEfrom
quest/m0/audio-quality-harness/browser

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Quest: /quest/m0/audio-quality-harness/browser.md (deleted here). Part of the questline #4395.

Summary

Adds just test audio-quality: a tone played in headless Chromium through moq-shaper, graded against a checked-in budget, nightly.

This upstreams the lane @fperex built on their fork for #3477 (fperex/moq, branches debug/rt-audio and debug-findings-solution): the black-box probe, the skip-ahead rule, the Playwright matrix over moq-shaper, and the --enforce budget grading. Only the harness lands. The fork's player, estimator, and shaper changes do not. fperex is credited as co-author on the main commit and in test/audio-quality/README.md.

  • Matrix: codec (Opus 48 kHz over fMP4, AAC 44.1 kHz over MPEG-TS) x profile (near-zero, mild, wide, fixed-250) x ring (isolated SharedArrayBuffer, plain postMessage). That is 16 rows at 60 s each.
  • Underruns without touching the player: a listening AudioWorklet taps the player's output node and reads each render quantum's fill level from its trailing zeros.
  • Metric contract: clients/js/src/schema.ts defines every counter, its unit, its clock, and its aggregation. The budget key names the aggregation (underruns_per_min), and the stages are exclusive spans with a named unaccounted remainder. The render clock is reduced to the viewer clock. Publisher and relay stages are reported unmeasured rather than guessed.
  • Void rules: a row is void if the shaper exits nonzero (the profile never acted), the session is not WebTransport, the wrong ring ran, the render clock is more than 1% off, or there is no audio after warmup. A void row fails --enforce.
  • Budgets: budgets.json is keyed by the whole row (runtime, codec, rate, profile, ring). The note field says how the ceilings were measured.
  • Nightly: the Audio quality job runs under --enforce and uploads a failing run directory for 7 days. The header comment explains why this is not a PR gate.
  • The JS check scope now includes test/audio-quality/clients/js, so the analyzer's 18 unit tests and its type check run on PRs.
  • The page is built into the run directory, the same fix fix(test): keep concurrent interop runs from rebuilding each other's clients #4411 made for interop, so concurrent runs cannot empty each other's dist/.

What it measured (local, seed 7)

row underruns/min target p95
fixed-250, every codec and ring 0 273 (opus) / 416 (aac)
opus near-zero / mild 1,200 to 3,000 43
opus wide 1,700 to 4,000 48 to 62
aac auto rows 13 to 380 186 to 226

The fixed control is clean on every counter, which is the check that the tap does not invent underruns. Every auto row underruns, including on an unimpaired path. The resolved target is 43 ms for Opus, which is the #3477 finding reproduced by a harness. So the auto-row ceilings (twice the worse run) record today's floor rather than a good one. The jitter target line is expected to tighten them.

Scope split

The previous attempt split two parts of the original quest into new quests on this line instead of landing them here:

  • traces.md: record real arrival traces and grade a replay. No public signal carries a per-frame arrival, so recording needs either a @moq/watch debug hook or a native recorder, and the fork's replay module is a player change.
  • shaper-profiles.md: the bursty and mid-run step profiles. These need moq-shaper batch and step features that exist on the fork and not on main.

audio-jitter-target/watch.md now depends on traces.md instead of this quest.

Public API / wire impact

None. The new workspace package @moq/audio-quality is private. No wire changes.

Verification

  • just check passes.
  • Two full 60 s matrix runs and a 20 s run: no void rows, and --enforce passes against the recorded budgets. A tightened budget copy fails as expected.

Open decisions

  • Budgets on the nightly runner. The ceilings were measured on a shared workstation. The ARM nightly runner has no audio device, so Chromium's fake sink may move the numbers. Recommendation: re-record from the first nightly runs instead of dispatching one from this branch.
  • Auto-row ceilings are loose. Run-to-run variance was about 2x to 20x, so they are set at twice the worse run. Recommendation: keep them loose until the jitter target lands, then tighten.

Suggested follow-ups

  • Tighten the auto-row budgets once the jitter target lands. This probably belongs to audio-jitter-target/watch.md, not a new quest.
  • Re-record the budgets from the first nightly runs.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 6 commits September 28, 2026 13:27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstreams the browser audio quality lane from fperex's fork
(fperex/moq, debug-findings-solution) as test/audio-quality/, without the
fork's player, estimator, or shaper changes. Plays a tone in headless
Chromium through moq-shaper and grades underruns, skip-aheads, silence,
and the resolved delay against budgets.json. Runs nightly under --enforce.

Co-Authored-By: fperex <270788582+fperex@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on PRs

Ceilings come from two full local runs plus one short one: the fixed-250
control measured zero on every counter, and every auto row underruns on
today's shallow target. The page builds into the run directory so concurrent
runs cannot empty each other's dist/, the JS check scope covers the new
workspace, and the nightly job sets MOQ_TEST_RUNS on the step, where the
runner context is allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matches wasm.yml, which moved its Playwright lane to the ARM runners that
cache.yml warms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review September 29, 2026 00:38
@kixelated
kixelated merged commit 29b0a64 into quest/m0/audio-quality-harness/README Sep 29, 2026
6 checks passed
@kixelated
kixelated deleted the quest/m0/audio-quality-harness/browser branch September 29, 2026 00:38
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T00:42:15.067111Z 8dc1be3 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8dc1be3bf0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

detail: `the AudioContext clock ran at ${round(renderRate, 3)}x the viewer's: throttled or stalled`,
});
}
const toViewer = (render: Ms) => render + (renderOffset ?? 0);

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 Apply the fitted render rate when converting clocks

When the AudioContext clock runs at any accepted non-unit rate, renderRate is calculated but ignored here, so render timestamps are translated using only a constant offset. At the allowed 1% drift over a 60-second row, a gap can be placed hundreds of milliseconds from its viewer-clock position and cross the warmup or end boundary, changing the graded underrun totals. Use the fitted affine mapping for toViewer, not just the median offset.

Useful? React with 👍 / 👎.

s.timestamp === undefined || s.stalled ? undefined : s.at - s.timestamp - (mediaRate ?? 0) * (s.at - base),
);
const skips = skipAheads(lags);
const stalledShare = window.length > 0 ? window.filter((s) => s.stalled).length / window.length : null;

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 Derive stalled share from the recorded stall spans

When a stall begins and ends between two 250 ms probe samples, the exact changes are present in Sample.stalls, but this calculation sees only the periodic s.stalled snapshots and reports no stalled time. Consequently stalled_share can remain zero despite repeated brief stalls and pass its budget; compute the duration of the clipped stallSpans instead of counting sampled booleans.

Useful? React with 👍 / 👎.

Comment on lines +100 to +102
/** Gaps closed since the last call. A gap still open is reported once a full quantum ends it. */
take(): Gap[] {
return this.#closed.splice(0, this.#closed.length);

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 Flush an underflow that remains open at row end

When playback enters an underflow near the end of a row and never produces another full quantum, #open is never moved into #closed, so every call to take() omits that final episode before the page is closed. This can exclude hundreds of milliseconds from all underrun and gap metrics, while still staying below the 1% silence ceiling, allowing a real trailing glitch to pass; expose or finalize the open gap at shutdown.

Useful? React with 👍 / 👎.

Comment on lines +132 to +136
if (environment.transport !== "webtransport") {
refuse("transport", `negotiated ${environment.transport}, which never crosses the UDP shaper`);
}
if (environment.crossOriginIsolated !== (ring === "isolated")) {
refuse("ring", `asked for ${ring}, but crossOriginIsolated is ${environment.crossOriginIsolated}`);

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 Refuse codec and sample-rate mismatches

When the publisher or catalog regresses to a different codec or sample rate, the page already reports those values in environment, but the driver validates only transport and ring. The run is then graded under the requested row's codec/rate budget even though another matrix cell actually ran, which can produce a false pass or failure; compare the reported codec and rate with the row before accepting the measurement.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant