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
63 changes: 63 additions & 0 deletions .github/workflows/audio-quality.yml
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/**"
Comment on lines +10 to +13

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 Gate changes to every replay dependency

The path filter covers js/watch and js/hang but not js/net or js/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 because replay.ts imports 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 👍 / 👎.

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.

Fixed in e7334db: the workflow also runs on js/net/** and js/signals/**.

(Written by Claude Opus 5.5)

- "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
4 changes: 3 additions & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ name: Nightly
# number it grades is a timing one that moves with whatever else the runner is
# doing. A per-PR arm would fail on unrelated load often enough to be ignored;
# nightly it competes with nothing, and a regression surfaces a day late against
# a budget that still means something.
# a budget that still means something. The recorded-trace replays are the
# exception: deterministic and seconds long, so audio-quality.yml also gates
# PRs on them.
#
# A failure here lands on main rather than being caught in review, which is the
# accepted trade. Anything that must block a merge belongs in check.yml.
Expand Down
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions js/watch/src/audio/replay.test.ts
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));
});
});
Loading
Loading