Repository navigation
test(audio-quality): record real arrival traces and grade their replay per PR #4525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kixelated
merged 9 commits into
quest/m0/audio-quality-harness/README
from
quest/m0/audio-quality-harness/traces
Sep 29, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
36bc3b7
quest(audio-quality-harness/traces): claim
kixelated 188e6a0
Merge remote-tracking branch 'origin/main' into quest/m0/audio-qualit…
kixelated 59b94d5
test(audio-quality): record real arrival traces and grade their replay
kixelated 7ae5051
Merge remote-tracking branch 'origin/quest/m0/audio-quality-harness/R…
kixelated caa7635
quest(audio-quality): plan replay CI and the real-mic re-record
kixelated 69c1430
test(audio-quality): replay traces through the consumer and gate PRs …
kixelated e7334db
test(audio-quality): gate replays on net and signals, and validate th…
kixelated d360b87
test(audio-quality): refuse an unknown replay profile
kixelated 3004f8a
Merge remote-tracking branch 'origin/quest/m0/audio-quality-harness/R…
kixelated File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| name: Audio quality | ||
|
|
||
| # Grades the recorded-trace replays of test/audio-quality against their exact | ||
| # budgets on every PR that can move them. They replay the player's container | ||
| # consumer and rings on a simulated clock, so they are deterministic and take | ||
| # seconds, unlike the browser rows, which stay in nightly.yml. | ||
| on: | ||
| pull_request: | ||
| paths: | ||
| - "js/watch/**" | ||
| # The container consumer decides what a replay delivers, waits for, and skips, on the | ||
| # groups, tracks, and signals underneath it. | ||
| - "js/hang/**" | ||
| - "js/net/**" | ||
| - "js/signals/**" | ||
| - "test/audio-quality/**" | ||
| - "test/lib/**" | ||
| - "test/justfile" | ||
| - ".github/workflows/audio-quality.yml" | ||
|
|
||
| concurrency: | ||
| group: audio-quality-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| replay: | ||
| name: Replay | ||
| runs-on: ubuntu-24.04-arm | ||
| timeout-minutes: 20 | ||
|
|
||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main | ||
| with: | ||
| determinate: false | ||
| # Trust the flake's cachix substituter; see check.yml. | ||
| extra-conf: | | ||
| extra-substituters = https://kixelated.cachix.org | ||
| extra-trusted-public-keys = kixelated.cachix.org-1:CmFcV0lyM6KuVM2m9mih0q4SrAa0XyCsiM7GHrz3KKk= | ||
|
|
||
| - name: Replay | ||
| run: nix develop --command just test audio-quality --replays --enforce | ||
| shell: bash -leo pipefail {0} | ||
| env: | ||
| # Somewhere upload-artifact can reach; the harness default is under $TMPDIR. | ||
| MOQ_TEST_RUNS: ${{ runner.temp }}/moq-test-runs | ||
|
|
||
| # The harness keeps a failing run's directory: every row's samples and summary. | ||
| - name: Keep the failing run | ||
| if: ${{ failure() }} | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | ||
| with: | ||
| name: audio-quality-replay | ||
| path: ${{ runner.temp }}/moq-test-runs/ | ||
| retention-days: 7 | ||
| if-no-files-found: ignore | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, type Mock, spyOn } from "bun:test"; | ||
| import type * as Catalog from "@moq/hang/catalog"; | ||
| import { Time } from "@moq/net"; | ||
| import { type Arrival, type Options, QUANTUM, replay, target } from "./replay"; | ||
|
|
||
| const RATE = 48000; | ||
|
|
||
| /** Twenty ms frames for `seconds`, one per group, each arriving `lateness(i)` ms after it was captured. */ | ||
| function paced(seconds: number, lateness: (i: number) => number): Arrival[] { | ||
| return Array.from({ length: seconds * 50 }, (_, i) => ({ at: i * 20 + lateness(i), timestamp: i * 20, group: i })); | ||
| } | ||
|
|
||
| /** Quanta that came back short or empty while playing, once the ring had first played. */ | ||
| async function underruns(trace: Arrival[], options: Options): Promise<number> { | ||
| let started = false; | ||
| let short = 0; | ||
| for await (const { output, stalled } of replay(trace, options)) { | ||
| const filled = output.findLastIndex((v) => v !== 0) + 1; | ||
| if (started && !stalled && filled < QUANTUM) short++; | ||
| if (filled > 0) started = true; | ||
| } | ||
| return short; | ||
| } | ||
|
|
||
| describe.each(["shared", "post"] as const)("%s ring", (ring) => { | ||
| // The consumer warns on every group it skips. | ||
| let warn: Mock<typeof console.warn>; | ||
| beforeEach(() => { | ||
| warn = spyOn(console, "warn").mockImplementation(() => {}); | ||
| }); | ||
| afterEach(() => warn.mockRestore()); | ||
|
|
||
| it("plays an evenly paced sender without a gap", async () => { | ||
| expect( | ||
| await underruns( | ||
| paced(10, () => 30), | ||
| { ring, rate: RATE, delay: 100 }, | ||
| ), | ||
| ).toBe(0); | ||
| }); | ||
|
|
||
| it("underruns when a flush span outlasts the target, and not when it is covered", async () => { | ||
| // Five frames held and flushed at once: 80 ms of arrival spread. | ||
| const trace = paced(10, (i) => 30 + (4 - (i % 5)) * 20); | ||
| expect(await underruns(trace, { ring, rate: RATE, delay: 40 })).toBeGreaterThan(50); | ||
| expect(await underruns(trace, { ring, rate: RATE, delay: 150 })).toBe(0); | ||
| }); | ||
|
|
||
| it("holds newer groups behind a missing one until the max age gives up on it", async () => { | ||
| const trace = paced(10, () => 30).filter((arrival) => arrival.group !== 250); | ||
| // The consumer delivers in group order, so the ring runs dry for longer than the missing frame | ||
| // while the groups behind it wait. Writing arrivals straight into the ring plays straight through. | ||
| const frame = Math.ceil((20 / 1000) * (RATE / QUANTUM)); | ||
| expect(await underruns(trace, { ring, rate: RATE, delay: 100 })).toBeGreaterThan(frame); | ||
| expect(warn).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("target", () => { | ||
| const config = { codec: "opus", sampleRate: RATE, numberOfChannels: 1 } as Catalog.AudioConfig; | ||
|
|
||
| it("sizes auto from the RTT and the rendition's jitter", async () => { | ||
| // 1.25 x 40 ms of RTT, plus the Opus frame and a render quantum. | ||
| expect(await target({ delay: "auto", config, rtt: 40 })).toBe(Time.Milli(50 + 20 + 3)); | ||
| }); | ||
|
|
||
| it("adds the rendition's jitter to a fixed delay", async () => { | ||
| expect(await target({ delay: Time.Milli(250), config })).toBe(Time.Milli(250 + 20 + 3)); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The path filter covers
js/watchandjs/hangbut notjs/netorjs/signals. A PR that only changes Group/Track scheduling or signal effect delivery can change replay results, yet this workflow is skipped; these are direct runtime dependencies becausereplay.tsimports Group, Track, Time, and Signal. Include those paths so deterministic budget regressions block the affected PR rather than waiting for the nightly run.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in e7334db: the workflow also runs on
js/net/**andjs/signals/**.(Written by Claude Opus 5.5)