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
41 changes: 39 additions & 2 deletions doc/concept/audio-jitter.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,11 +499,48 @@ them. The cost is over-buffering by the smallest subscribed `delay` when the
broadcast's earliest rendition is not subscribed.

The playout is recomputed when a rendition is subscribed, unsubscribed, or its
catalog entry rises, so dropping a slow rendition lowers latency. Each track's
own arrivals only ever move its part of the shared reference earlier. A track
catalog entry rises, so dropping a slow rendition lowers latency. A track's
arrivals move its part of the shared reference earlier at once, and later only
after [a lasting step](#a-lasting-step). A track
that leaves, or whose timeline restarts, releases its part: the earliest
remaining track's anchor takes over, or the next frame re-anchors.

### A lasting step

The estimator forgets a lasting increase in path delay within `WINDOW`, since
it measures each frame against the fastest of the last two seconds. An anchor
that only moved earlier would not: every frame after the step would be late for
it by the step, and once the target fell back, audio would underrun on it and
video would show each frame late.

So each track's anchor follows its arrivals later on sustained evidence. A
track's arrivals fall into back-to-back windows of `WINDOW` of arrival time, the
arrival that closes one counting in it and opening the next. When a window
closes and every arrival in it trailed the track's anchor by more than **half
the delay**, the anchor moves later to the window's earliest arrival. The
reference is still the earliest anchor, so it moves fully once every arriving
track has followed; a track that stops arriving keeps its anchor and holds the
reference where it was.

Half the delay is the deadband because a smaller trail is not worth a move: it
spends at most half the buffer, and a noisy path's fastest frame per window
wanders by far less than the target that same spread sizes. Replayed through the
Nightly's captured arrivals, a 10 or 20 ms deadband moved the reference two to
seven times a minute on `wide`, and a deadband of the whole delay never moved it
on the Opus `step` row, whose 55 ms step matched its 60 ms target. An outage and
its backlog span no whole window, so they never move it; a congestion that
holds every frame past half the delay for a whole window is a step for as long
as it lasts, and the first frame through after it moves the anchor back.

A move is lag like any other: audio slows down within its stretch threshold or
pads past it, and video holds a frame. No latency is added to absorb a step
before it is evidence, so the frames between the step and the move are late.

The `clock/corpus.json` cases hold `js/watch` `Sync` and `moq_mux::sync` to the
same reference series. Each case fixes the delay and plays runs of arrivals,
`frames` of them `every` milliseconds from `start`, cycling through `tracks` and
the arrival-minus-media `offsets`, and gives the `reference` offset after each run.

A track that replaces another in the same slot and on the same broadcast path,
such as the new name a codec change publishes under, continues the outgoing
track's histogram, prior weight included, instead of starting over. The path's
Expand Down
52 changes: 52 additions & 0 deletions doc/concept/audio-jitter/clock/corpus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
[
{
"name": "step",
"about": "A lasting step later moves the reference once a whole window trails it by more than half the delay, and a faster frame moves it back at once",
"delay": 60,
"runs": [
{ "tracks": ["audio"], "start": 0, "every": 20, "frames": 150, "offsets": [5], "reference": 5 },
{ "tracks": ["audio"], "start": 3000, "every": 20, "frames": 150, "offsets": [60], "reference": 5 },
{ "tracks": ["audio"], "start": 6000, "every": 20, "frames": 1, "offsets": [60], "reference": 60 },
{ "tracks": ["audio"], "start": 6020, "every": 20, "frames": 50, "offsets": [60], "reference": 60 },
{ "tracks": ["audio"], "start": 7020, "every": 20, "frames": 1, "offsets": [40], "reference": 40 }
]
},
{
"name": "shallow",
"about": "A trail within half the delay leaves the reference where it is",
"delay": 60,
"runs": [
{ "tracks": ["audio"], "start": 0, "every": 20, "frames": 100, "offsets": [5], "reference": 5 },
{ "tracks": ["audio"], "start": 2000, "every": 20, "frames": 400, "offsets": [35, 30, 50], "reference": 5 }
]
},
{
"name": "spike",
"about": "An outage and the backlog behind it span no whole window, so the reference stays",
"delay": 60,
"runs": [
{ "tracks": ["audio"], "start": 0, "every": 20, "frames": 150, "offsets": [5], "reference": 5 },
{ "tracks": ["audio"], "start": 4500, "every": 5, "frames": 300, "offsets": [600], "reference": 5 },
{ "tracks": ["audio"], "start": 6000, "every": 20, "frames": 150, "offsets": [5], "reference": 5 }
]
},
{
"name": "tracks",
"about": "Each track's anchor follows its own window, and the reference is the earliest anchor, so it moves fully only once every track has followed",
"delay": 60,
"runs": [
{ "tracks": ["video", "audio"], "start": 0, "every": 10, "frames": 300, "offsets": [0, 10], "reference": 0 },
{ "tracks": ["video", "audio"], "start": 3000, "every": 10, "frames": 300, "offsets": [60, 70], "reference": 0 },
{ "tracks": ["video", "audio"], "start": 6000, "every": 10, "frames": 100, "offsets": [60, 70], "reference": 60 }
]
},
{
"name": "silent",
"about": "A track that stops arriving keeps its anchor, and the reference with it",
"delay": 60,
"runs": [
{ "tracks": ["video", "audio"], "start": 0, "every": 10, "frames": 300, "offsets": [0, 10], "reference": 0 },
{ "tracks": ["audio"], "start": 3000, "every": 20, "frames": 300, "offsets": [70], "reference": 0 }
]
}
]
43 changes: 43 additions & 0 deletions js/watch/src/sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Duration, type Installed, Instant, now, yieldNow } from "@moq/clock";
import { Manual } from "@moq/clock/test";

Expand Down Expand Up @@ -658,3 +661,43 @@ describe("tracks", () => {
sync.close();
});
});

describe("clock corpus", () => {
type Run = {
tracks: ("audio" | "video")[];
start: number;
every: number;
frames: number;
offsets: number[];
reference: number;
};
type Case = { name: string; delay: number; runs: Run[] };

// Shared with moq-mux, so neither implementation is graded against the other.
const corpus = join(dirname(fileURLToPath(import.meta.url)), "../../../doc/concept/audio-jitter/clock/corpus.json");
const cases = JSON.parse(readFileSync(corpus, "utf8")) as Case[];

// Arrivals start this far after the clock origin and media this far into the track, so neither is negative.
const ARRIVAL = 5000;
const MEDIA = 100_000;

for (const { name, delay, runs } of cases) {
it(`follows the corpus reference: ${name}`, async () => {
const sync = new Sync({ delay: Duration.millis(delay) });
const held = () => ({ target: new Signal<Duration | undefined>(undefined) });
const tracks = { audio: sync.track(held()), video: sync.track(held()) };
await flush();
for (const [index, run] of runs.entries()) {
for (let i = 0; i < run.frames; i++) {
const arrival = run.start + i * run.every;
const offset = run.offsets[i % run.offsets.length];
const track = tracks[run.tracks[i % run.tracks.length]];
track.received(Duration.millis(MEDIA + arrival - offset), at(ARRIVAL + arrival));
}
const reference = (sync.out.reference.peek() ?? Number.NaN) - ARRIVAL + MEDIA;
expect([index, reference]).toEqual([index, run.reference]);
}
sync.close();
});
}
});
68 changes: 61 additions & 7 deletions js/watch/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export const STRETCH = Duration.millis(200);
/** The most an `"auto"` delay holds, and how far catalog `warmup` reaches back. @internal */
export const DELAY_MAX = Container.DELAY_MAX;

// How long a track's arrivals must all trail its anchor before the anchor follows them later: the
// estimator's window, so the clock and the target measure lateness against the same fastest frame.
const FOLLOW = 2000;

/** Signals the sync reads. */
export type SyncInput = {
/** How far playback trails the live edge. See {@link Delay}. */
Expand Down Expand Up @@ -66,7 +70,8 @@ export type SyncOutput = {

// The earliest time we've received a frame, relative to its timestamp: arrival minus timestamp
// in milliseconds, an offset that is negative when the timestamps run ahead of the local clock.
// This will keep being updated as we catch up to the live playhead then will be relatively static.
// This will keep being updated as we catch up to the live playhead then will be relatively static,
// moving later only after a lasting step in path delay (see doc/concept/audio-jitter.md).
// Each track holds its own part of it; see `Track.reset()`.
reference: Signal<number | undefined>;

Expand Down Expand Up @@ -107,8 +112,13 @@ export type Part = {
anchor?: number;
// The furthest playhead this track's own skips jumped to, restored when it takes the reference.
floor?: number;
// The window of arrivals that decides whether the anchor follows a lasting step later.
window?: Window;
};

/** A window of one track's arrivals: when it opened and the earliest reference among them. @internal */
export type Window = { start: number; least: number };

/**
* Where the clock's current timeline stands. `idle` has not played and holds nothing back: it is
* unanchored, or anchored with no buffered startup. `starting` holds the media consumers until every
Expand Down Expand Up @@ -171,11 +181,33 @@ export class Clock {
return ref + cap - delay;
}

// Move a track's anchor later once a whole window of its arrivals trailed it by more than half the
// delay, as after a lasting increase in path delay. Reports whether the anchor moved.
follow(part: Part, at: number, ref: number): boolean {
const window = part.window;
if (!window) {
part.window = { start: at, least: ref };
return false;
}
window.least = Math.min(window.least, ref);
if (at - window.start < FOLLOW) return false;

// The arrival that closes a window counts in it and opens the next.
part.window = { start: at, least: ref };
if (part.anchor === undefined || window.least - part.anchor <= this.out.delay.peek().asMillis() / 2) {
return false;
}
part.anchor = window.least;
return true;
}

received(part: Part, timestamp: Duration, arrival: Instant): boolean {
this.out.timestamp.update((current) => (current === undefined || timestamp.gt(current) ? timestamp : current));
const ref = millis(arrival) - timestamp.asMillis();
const current = this.out.reference.peek();
part.anchor = this.fold(part.anchor, ref);
// Startup holds the opening still, so only a playing clock follows a step.
const followed = this.playing.peek() && this.follow(part, millis(arrival), ref);

// First frame anchors the reference.
if (current === undefined) {
Expand All @@ -195,7 +227,12 @@ export class Clock {
const late = current - ref + delay < 0;

const reference = this.fold(current, ref);
if (reference === current) return late;
if (reference === current) {
// The owner's anchor moving later moves the reference to the earliest anchor left, which only
// a whole window of every track's arrivals trailing it moves.
if (followed && this.owner === part) this.handoff(current);
return late;
}
this.out.reference.set(reference);
this.owner = part;

Expand All @@ -210,19 +247,35 @@ export class Clock {
return late;
}

// The track with the earliest anchor, which holds the reference.
earliest(): Part | undefined {
let earliest: Part | undefined;
for (const other of this.parts.peek()) {
if (other.anchor === undefined) continue;
if (earliest?.anchor === undefined || other.anchor < earliest.anchor) earliest = other;
}
return earliest;
}

// The owner's anchor moved later: the earliest anchor now holds the reference, never earlier than
// `current`, since every other track's arrivals already folded into it.
handoff(current: number): void {
const earliest = this.earliest();
if (earliest?.anchor === undefined) return;
this.owner = earliest;
if (earliest.anchor > current) this.out.reference.set(earliest.anchor);
}

// End one track's timeline: its parked frames drop and it releases its part of the reference.
end(part: Part): void {
part.epoch.update((epoch) => epoch + 1);
part.anchor = undefined;
part.floor = undefined;
part.window = undefined;
if (this.owner !== part) return;

// The earliest remaining track holds the reference, else the next frame anchors a new one.
let earliest: Part | undefined;
for (const other of this.parts.peek()) {
if (other.anchor === undefined) continue;
if (earliest?.anchor === undefined || other.anchor < earliest.anchor) earliest = other;
}
const earliest = this.earliest();
this.owner = earliest;
if (earliest?.anchor === undefined) {
this.unanchor();
Expand All @@ -244,6 +297,7 @@ export class Clock {
// Clear the reference so the next frame anchors a new timeline. Every parked frame belongs to
// the old one.
unanchor(): void {
for (const part of this.parts.peek()) part.window = undefined;
this.epoch.update((epoch) => epoch + 1);
this.floor = undefined;
this.owner = undefined;
Expand Down
5 changes: 3 additions & 2 deletions quest/m2/audio-playout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ shared vectors for both players.

Order: the 200 ms snap both ways and the
[lag rule](https://github.com/moq-dev/moq/pull/8722), a continuous stretch
that replaced the NetEq controller and splices under it, are done, and the
[clock step](/quest/m2/audio-playout/clock-step.md) builds on the lag rule.
that replaced the NetEq controller and splices under it, are done, as is the
shared clock following a lasting path-delay step
([moq-dev/moq#8749](https://github.com/moq-dev/moq/pull/8749)).
The estimator stops folding a join's intervals
([moq-dev/moq#8730](https://github.com/moq-dev/moq/pull/8730));
[output latency](/quest/m2/audio-playout/output-latency.md) and the
Expand Down
38 changes: 0 additions & 38 deletions quest/m2/audio-playout/clock-step.md

This file was deleted.

Loading