Repository navigation
feat(watch): size the auto delay from the measured arrival estimator #3954
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/main/audio-jitter-target/README
from
quest/main/audio-jitter-target/watch
Sep 25, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
e8f1738
quest: claim audio-jitter-target/watch
kixelated 809585f
feat(watch): size the auto delay from the measured arrival estimator
kixelated 9bc447a
Merge remote-tracking branch 'origin/quest/main/audio-jitter-target/R…
kixelated 6b4ce03
fix(watch): treat an implicit CMAF duration as absent, mark probe int…
kixelated cde659e
Merge remote-tracking branch 'origin/quest/main/audio-jitter-target/R…
kixelated 8bc2e87
fix(watch): accumulate small target rises before re-stalling, no audi…
kixelated 076f062
fix(watch): skip the re-stall when the ring already holds the target
kixelated d23899a
fix(watch): record a target decrease before debouncing the next rise
kixelated 271518c
quest(audio-jitter-target): note the auto age budget for the watch run
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
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
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
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
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
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
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
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,95 @@ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { readdirSync, readFileSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import type { Time } from "@moq/net"; | ||
| import { Jitter } from "./jitter"; | ||
|
|
||
| // The conformance corpus beside doc/concept/audio-jitter.md, read directly so this is graded | ||
| // against the document rather than against another implementation. | ||
| const CORPUS = join(dirname(fileURLToPath(import.meta.url)), "../../../../doc/concept/audio-jitter"); | ||
|
|
||
| interface Case { | ||
| name: string; | ||
| frame: number; | ||
| advertised?: number; | ||
| delay?: number; | ||
| arrival: number[]; | ||
| media: number[]; | ||
| target: [number, number][]; | ||
| } | ||
|
|
||
| const cases: Case[] = readdirSync(CORPUS) | ||
| .filter((file) => file.endsWith(".json")) | ||
| .map((file) => JSON.parse(readFileSync(join(CORPUS, file), "utf8"))); | ||
|
|
||
| // The composition the document specifies on top of the measured term. `@moq/watch` owns it in | ||
| // production (`audio/latency.ts`); it is repeated here so the whole target series is checked. | ||
| function target(jitter: Jitter, c: Case): number { | ||
| if (c.delay !== undefined) return c.delay; | ||
| return Math.max(jitter.measured, c.advertised ?? 0) + c.frame; | ||
| } | ||
|
|
||
| describe("audio jitter conformance", () => { | ||
| it("reads the whole corpus", () => { | ||
| expect(cases.map((c) => c.name).sort()).toEqual([ | ||
| "advertised", | ||
| "buildup", | ||
| "fixed", | ||
| "flush", | ||
| "idle", | ||
| "paced", | ||
| "reorder", | ||
| "spike", | ||
| "tunein", | ||
| ]); | ||
| }); | ||
|
|
||
| for (const c of cases) { | ||
| it(c.name, () => { | ||
| const jitter = new Jitter(); | ||
| const expected = new Map(c.target); | ||
| let current: number | undefined; | ||
| for (let i = 0; i < c.arrival.length; i++) { | ||
| jitter.observe(c.arrival[i] as Time.Milli, c.media[i] as Time.Milli); | ||
| const value = target(jitter, c); | ||
| const listed = expected.get(i); | ||
| if (listed !== undefined) { | ||
| expect(value).toBeCloseTo(listed, 6); | ||
| } else if (current !== undefined) { | ||
| // Unlisted frames hold the previous target. | ||
| expect(value).toBeCloseTo(current, 6); | ||
| } | ||
| current = value; | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| describe("Jitter", () => { | ||
| it("starts at the prior's 95th percentile", () => { | ||
| expect(new Jitter().measured).toBe(100 as Time.Milli); | ||
| }); | ||
|
|
||
| it("is not moved by a stale frame ahead of the live edge", () => { | ||
| // The #3517 regression: one frame from a stale group, then the live edge 14.5s of media later, | ||
| // then evenly paced audio with no real jitter. The jump must not read as a delay or a frame. | ||
| const jitter = new Jitter(); | ||
| jitter.observe(0 as Time.Milli, 0 as Time.Milli); | ||
| for (let i = 0; i < 500; i++) { | ||
| jitter.observe((10 + i * 20) as Time.Milli, (14_500 + i * 20) as Time.Milli); | ||
| } | ||
| expect(jitter.measured).toBe(20 as Time.Milli); | ||
| }); | ||
|
|
||
| it("does not follow the absolute delay", () => { | ||
| // Only the spread of arrivals matters, not how far away the sender is. | ||
| const near = new Jitter(); | ||
| const far = new Jitter(); | ||
| for (let i = 0; i < 500; i++) { | ||
| near.observe((10 + i * 20) as Time.Milli, (i * 20) as Time.Milli); | ||
| far.observe((2000 + i * 20) as Time.Milli, (i * 20) as Time.Milli); | ||
| } | ||
| expect(far.measured).toBe(near.measured); | ||
| }); | ||
| }); |
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,125 @@ | ||
| import type { Time } from "@moq/net"; | ||
|
|
||
| // Implements the estimator in doc/concept/audio-jitter.md, which is normative. The design and | ||
| // every constant come from WebRTC NetEq's underrun optimizer; this is written from the document, | ||
| // not from NetEq's source. | ||
|
|
||
| /** Milliseconds of media time the reference search looks back over. */ | ||
| const WINDOW = 2000; | ||
|
|
||
| /** Milliseconds of delay per histogram bucket. */ | ||
| const BUCKET = 20; | ||
|
|
||
| /** Histogram buckets, so the measured term saturates at `BUCKET * BUCKETS`. */ | ||
| const BUCKETS = 100; | ||
|
|
||
| /** The quantile of the delay distribution the estimate is read at. */ | ||
| const QUANTILE = 0.95; | ||
|
|
||
| /** Weight retained per folded observation, a ~20s half-life at one fold per `RESAMPLE`. */ | ||
| const FORGET = 0.983; | ||
|
|
||
| /** Milliseconds of arrival time per resampled observation. */ | ||
| const RESAMPLE = 500; | ||
|
|
||
| /** Holds the forget factor below `FORGET` early on, so evidence overwrites the prior. */ | ||
| const RAMP = 2; | ||
|
|
||
| /** Idle intervals folded at once, purely to bound the work. */ | ||
| const IDLE_MAX = 1024; | ||
|
|
||
| /** The cold-start distribution, which is also what an idle interval relaxes back to. */ | ||
| const PRIOR = Float64Array.from({ length: BUCKETS }, (_, i) => 0.5 ** (i + 1)); | ||
|
|
||
| /** | ||
| * How much buffer late arrivals need, measured from arrival timing alone. | ||
| * | ||
| * Each frame's delay is measured against the fastest frame in the last `WINDOW` of media time, the | ||
| * largest delay per `RESAMPLE` interval lands in a decaying histogram, and the estimate is its 95th | ||
| * percentile. Excludes the codec frame and any advertised floor, which the caller composes on top. | ||
| */ | ||
| export class Jitter { | ||
| /** Arrivals still inside the reference window, in media order. */ | ||
| #history: { arrival: number; media: number }[] = []; | ||
| /** The largest media timestamp observed, so a reordered frame is recognized. */ | ||
| #newest = Number.NEGATIVE_INFINITY; | ||
|
|
||
| /** The arrival resample intervals are counted from, unset until the first observation. */ | ||
| #origin?: number; | ||
| /** The interval currently accumulating, counted from `#origin`. */ | ||
| #interval = 0; | ||
| /** The largest delay seen in that interval. */ | ||
| #peak = 0; | ||
|
|
||
| #histogram = Float64Array.from(PRIOR); | ||
| /** Observations folded in, which drives the cold-start ramp. */ | ||
| #count = 0; | ||
|
|
||
| /** Fold one frame in: `arrival` on the receiver's monotonic clock, `media` its timestamp. */ | ||
| observe(arrival: Time.Milli, media: Time.Milli): void { | ||
| // A reordered frame is excluded rather than measured: the media-time gap back to the frame | ||
| // that overtook it would otherwise be added to the delay. | ||
| if (media <= this.#newest) return; | ||
| this.#newest = media; | ||
|
|
||
| // Pruning by media time is what makes a timestamp jump self-correcting: everything before it | ||
| // leaves the window at once and the new frame becomes its own reference. | ||
| let stale = 0; | ||
| while (stale < this.#history.length && this.#history[stale].media < media - WINDOW) stale++; | ||
| if (stale > 0) this.#history.splice(0, stale); | ||
| this.#history.push({ arrival, media }); | ||
|
|
||
| // Delay against the fastest frame in the window, not the previous one, so a queue that | ||
| // fills a little per frame reads as the build-up it is. | ||
| let reference = this.#history[0]; | ||
| for (const entry of this.#history) { | ||
| if (entry.arrival - entry.media < reference.arrival - reference.media) reference = entry; | ||
| } | ||
| const delay = Math.max(0, arrival - reference.arrival - (media - reference.media)); | ||
|
|
||
| if (this.#origin === undefined) { | ||
| this.#origin = arrival; | ||
| this.#peak = delay; | ||
| return; | ||
| } | ||
|
|
||
| const interval = Math.floor((arrival - this.#origin) / RESAMPLE); | ||
| if (interval <= this.#interval) { | ||
| this.#peak = Math.max(this.#peak, delay); | ||
| return; | ||
| } | ||
|
|
||
| this.#fold(Math.min(BUCKETS - 1, Math.floor(this.#peak / BUCKET))); | ||
|
|
||
| // An interval with no arrival relaxes the estimate toward the prior at the same rate. | ||
| const idle = Math.min(interval - this.#interval - 1, IDLE_MAX); | ||
| for (let i = 0; i < idle; i++) this.#fold(undefined); | ||
|
|
||
| this.#interval = interval; | ||
| this.#peak = delay; | ||
| } | ||
|
|
||
| /** The measured term: the upper edge of the bucket holding the 95th percentile. */ | ||
| get measured(): Time.Milli { | ||
| let cumulative = 0; | ||
| for (let i = 0; i < BUCKETS; i++) { | ||
| cumulative += this.#histogram[i]; | ||
| if (cumulative > QUANTILE) return ((i + 1) * BUCKET) as Time.Milli; | ||
| } | ||
| return (BUCKETS * BUCKET) as Time.Milli; | ||
| } | ||
|
|
||
| // `h = f*h + (1-f)*x`, where x is all weight on the observed bucket, or the prior when the | ||
| // interval held no arrival. The total stays at 1 without renormalizing. | ||
| #fold(bucket: number | undefined): void { | ||
| this.#count += 1; | ||
| const forget = Math.min(FORGET, this.#count / (this.#count + RAMP)); | ||
| const fresh = 1 - forget; | ||
|
|
||
| for (let i = 0; i < BUCKETS; i++) { | ||
| this.#histogram[i] *= forget; | ||
| if (bucket === undefined) this.#histogram[i] += fresh * PRIOR[i]; | ||
| } | ||
| if (bucket !== undefined) this.#histogram[bucket] += fresh; | ||
| } | ||
| } |
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
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.
When auto mode has converged to a shallow target and a group arrives farther behind the live edge than that target,
subscribeMediasendssync.out.maxAgeto the track andTrack.Subscriber.recvGroup()discards the stale group before this observation point. The estimator therefore only sees frames that fit its current estimate, despite the comment's claim, and repeated late or reordered groups can keep causing underruns without ever raising the target. In auto mode, use a separate subscription observation budget at least as large as the estimator's 2 s ceiling while retaining the resolved target for local playback skipping. (Written by GPT-5.6 Sol)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.
Declining for this PR. In auto mode the subscription's
maxAgestill tracks the target, so a group older than the target is dropped before anything observes it. Native avoids this with a separate 2 s budget under auto. Doing the same in the browser changes what goes on the wire, and the open watch quest ("fixes only what the measurement shows") is where to decide it against the real-browser run. I'll note it there.(Written by Claude Opus 5.5)