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
18 changes: 18 additions & 0 deletions js/watch/src/audio/replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ describe("replay", () => {
).toBe(0);
});

it("counts each run-dry the way the player's stats do", async () => {
// One frame 200 ms late against a 100 ms target: the ring runs dry once and re-stalls to refill.
const trace = paced(10, (i) => (i === 250 ? 230 : 30)).sort((a, b) => a.at - b.at);
const counted = async (arrivals: Arrival[]) => {
let last = 0;
for await (const { underruns } of replay(arrivals, {
rate: RATE,
config: CONFIG,
delay: Milli(100),
duration: observed(10),
}))
last = underruns;
return last;
};
expect(await counted(paced(10, () => 30))).toBe(0);
expect(await counted(trace)).toBe(1);
});

it("holds newer groups behind a missing one until the max delay 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
Expand Down
3 changes: 3 additions & 0 deletions js/watch/src/audio/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export interface Quantum {
timestamp: number;
/** The delay `Sync` resolved as of this quantum, in ms: what the ring and the decode gate hold. */
delay: number;
/** The ring's cumulative underruns, as the player's stats count them: each run-dry and each short quantum. */
underruns: number;
}

/** Samples as the milliseconds `AudioBuffer` posts to the worklet. */
Expand Down Expand Up @@ -280,6 +282,7 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato
stalled: ring.stalled,
timestamp: Milli.fromMicro(ring.timestamp),
delay,
underruns: ring.underruns,
};
}
} finally {
Expand Down
2 changes: 1 addition & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ per-frame CPU growth since #5926.
- [moq-quic's API improves on quinn's](/quest/m1/quic-api/README.md) - moq-quic and both QUIC drivers ship one mirrored API: `send::`/`recv::` namespaces, one error per direction, consuming terminal operations, plain configs, no quinn baggage
- [On-demand JavaScript FETCH](/quest/m1/js-fetch-on-demand.md) - browser publishers answer IETF FETCH for evicted or uncached groups through the JS ranges request surface
- [Native rung disable proof](/quest/m1/simulcast-rung-disable.md) - a shaped uplink through moq-ffi proves the top rendition stops and resumes over real BBR3 probing
- [Audio jitter target](/quest/m1/audio-jitter-target/README.md) - the playout target is default in both languages; a manual browser proof and a native trace replay remain
- [Audio jitter target](/quest/m1/audio-jitter-target/README.md) - the playout target is default in both languages; time-stretched browser playback without steady-state underruns and a native trace replay remain
- [A/V clock](/quest/m1/av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles
- [Container reads drop backfill](/quest/m1/read-backfill.md) - the JS container consumer drops groups below its read cursor like Rust
- [Buffered playback subscribes in ascending order](/quest/m1/buffered-ascending.md) - a buffered `<moq-watch>` asks for the oldest group first, so a voice reply's opening arrives first, and audio's head hold shrinks to the floor
Expand Down
15 changes: 7 additions & 8 deletions quest/m1/audio-jitter-target/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ at once got a target far too shallow to play through the next flush. One
written algorithm, cited by both implementations, produces the same target from
the same arrival trace.

Boundaries: convergence still uses skip-ahead and silence, so playing slightly
faster or slower to converge stays [Time
stretch](/quest/m2/watch-audio-time-stretch.md). No packet loss concealment.
Boundaries: converging by playing slightly faster or slower is the browser's
[Watch](/quest/m1/audio-jitter-target/watch.md) quest. No packet loss concealment.
Video keeps its own target; making the audio playhead the clock is [A/V
clock](/quest/m1/av-clock.md).

Expand All @@ -27,7 +26,10 @@ every viewer, so `"auto"` shipped ahead of the Chrome and Safari proof the
Watch quest still owes; the maintainer accepted that, and the re-measured
replay budgets, on 2026-10-04. Decided 2026-10-13: retain the remaining proof in m1 because the A/V
clock and worker release contracts depend on it. Native quality expansion
remains separately scoped in m2.
remains separately scoped in m2. The 2026-11-03 proof run found the 0.95
quantile leaves about six re-stalls a minute on real paths by design; decided
2026-11-03 (maintainer), Watch closes that by time stretch rather than a deeper
target.

What the line still owes once Watch lands: its recorded trace replayed through
the native decode path too, asserting the same target series the browser's
Expand All @@ -36,13 +38,10 @@ native playback against its budgets.

## Required

- [Watch](/quest/m1/audio-jitter-target/watch.md) - the browser's measured target, proven on Chrome and Safari against the public relay
- [Watch](/quest/m1/audio-jitter-target/watch.md) - the browser plays the measured real-microphone rows with zero steady-state underruns by time-stretching the ring
- [Capped budgets](/quest/m1/audio-jitter-target/capped-budgets.md) - post-cap nightlies confirm AAC bursty discards about as much audio as Opus bursty and the first row no longer stalls, and every browser row's budgets are re-measured from them

## Closes

- [#2812](https://github.com/moq-dev/moq/issues/2812) - the iOS stutter report the same estimator fixes

## Related

- [Time stretch](/quest/m2/watch-audio-time-stretch.md) - inaudible convergence, on top of this
217 changes: 156 additions & 61 deletions quest/m1/audio-jitter-target/watch.md
Original file line number Diff line number Diff line change
@@ -1,74 +1,169 @@
# [S] js/watch: prove the measured playout target in a real browser
# [L] js/watch: zero steady-state audio underruns by time-stretching the ring

## Goal

The "Real-time" preset plays clean audio on a LAN and against the public relay:
zero underruns after convergence and no skip-aheads in steady state, confirmed
by a manual run on Chrome and Safari with a real microphone and 40 ms or more of added RTT.
On the measured real-microphone rows, the "Real-time" (`"auto"`) preset plays
with zero player underruns after convergence and no skip-aheads in steady
state, with the estimator's `QUANTILE` left at 0.95. Audio holds back or
catches up by playing slightly slower or faster, the way NetEq's preemptive
expand and accelerate do, rather than running dry, discarding buffered audio,
or rendering silence. Convergence is inaudible at ordinary drift and burst
sizes.

Boundaries: the estimator already conforms to `doc/concept/audio-jitter.md` and
passes the corpus in `js/hang/src/container/jitter.test.ts`. This quest measures
it, and fixes only what the measurement shows.
The measured rows are the `relay-mic-rtt40` replay row, whose
`player_underruns_per_min` must reach 0, and a live re-run of the 2026-11-03
rows below on Chrome and Safari with a real microphone and 40 ms or more of
added RTT, read from the stats panel's audio Underruns.

Boundaries: no packet loss concealment, so a frame that never arrives is still
missing audio; only received audio is stretched. The estimator stays as
`doc/concept/audio-jitter.md` specifies it. Native playback is out of scope
here.

## Plan

`playout.test.ts` measures synthetic traces through a transport subscription
into `Container.Consumer` on a stubbed clock and sizes the ring from the
target that settles. In `"auto"` the audio subscription asks for at least the
estimator's 2 s ceiling, as native does; a subscription cut to the target hid
every frame later than it. `demo/web` sets no delay and `js/watch` stores none,
so a fresh tile runs auto.

The harness's `js/watch/src/audio/replay.ts` replays the version 3 traces in
`test/audio-quality/traces/` through `Container.Consumer`, a real `Sync`, and
the ring on a simulated clock. Each arrival carries an encoded duration
from container metadata, the Opus TOC, or the codec's fixed sample count.
Replay never infers a frame's duration from timestamp spacing. The AAC-LC
fixtures retain their original arrivals with exact 1024-sample durations;
`relay-mic` was re-recorded with Chromium's fake capture device because the
old recording retained no payloads from which to recover Opus durations.

Re-measured on 2026-10-22: all four replay rows have zero underruns. These
numbers predate
[moq-dev/moq#6484](https://github.com/moq-dev/moq/pull/6484),
[moq-dev/moq#6609](https://github.com/moq-dev/moq/pull/6609), and
[moq-dev/moq#6595](https://github.com/moq-dev/moq/pull/6595); the manual run
re-measures them (decided 2026-10-27, maintainer). The
fresh `relay-mic` has zero skips, stalls, or silence, a 60 ms p95 target,
and 3.5 s convergence, over a 2 ms minimum RTT. It is a new
fake-device recording, so that result proves neither an improvement on
the old path nor the manual goal. The AAC recordings still refill: worst
stalled share is 1.7% (`relay-bbb`), with `local-aac` skipping
about twice a minute while converging over 15.5 s.

What remains is one manual run by a person on Apple hardware; nothing
deterministic is left to automate. Decided 2026-10-22:

- Run against the public relay on Chrome and Safari, the two rows the
issue measured, with a real microphone and 40 ms or more of added RTT. Watch
the stats panel's audio underrun counter and the latency tab's auto readout.
Re-record the
`relay-mic` trace in the same run (`just test audio-quality-record`); the
checked-in one used Chromium's fake capture device and proves nothing here.
- Keep the production refill policy until that run's measurements exist. A
ring that runs dry re-stalls and refills to the target; change that only if
the run shows a cause and a justified alternative beats it. The
deterministic replay ceilings match the recorded durations. Tighten browser
rows only from nightly runner measurements, re-measured as
`test/audio-quality/budgets.json`'s note describes.
- Measure the publisher's audio encoder input-to-output lag in the same run
using the reporter's instrumented harness; #3518 fixed the known cause, so
the 7.35 s lag and the 88 to 275 ms/s drift the issue reported stand
unconfirmed. If drift survives, the suspects are `writeFrame` opening a
group per audio frame under WebTransport stream credit and the main-thread
task queue delivering encoder output. Audio now defaults `groupDuration` to
20 ms. A/B that default against a group of 100 ms or so in the same run.
Decided 2026-11-03 (maintainer, on
[moq-dev/moq#7679](https://github.com/moq-dev/moq/pull/7679)): pull time
stretch into m1 as this quest's fix, keeping `QUANTILE` at 0.95. The options
were restating the Goal to the measured rate, or raising the quantile. The m2
Time stretch quest was folded in here.

### Baseline, 2026-11-03

An agent ran the maintainer's MacBook Air (macOS 26.6.2) against
`cdn.moq.dev/anon`. The built-in microphone was published by `<moq-publish>` in
Chrome 156 and played by the harness page in a second Chrome 156 profile and in
Safari 26.6.2. The added RTT was a userspace UDP relay on the laptop holding
each packet 20 ms each way, reached through Chrome's `--host-resolver-rules`.
Safari cannot be redirected that way and negotiated WebSocket, so its rows ran
on the direct path. Underruns are the stats panel's counter after the first
30 s.

| Row | Transport | RTT min | Underruns after 30 s | Skip-aheads after 30 s | Auto delay p50 / max |
| --- | --- | --- | --- | --- | --- |
| Chrome, direct, 20 ms groups | WebTransport | 5 ms | 9 in 88 s (6.1/min) | 0 | 40 / 60 ms |
| Chrome, +40 ms, 20 ms groups | WebTransport | 47 ms | 26 in 149 s (10.5/min) | 1 (43 ms) | 100 / 120 ms |
| Chrome, +40 ms, 100 ms groups | WebTransport | 47 ms | 9 in 149 s (3.6/min) | 0 | 100 / 120 ms |
| Safari, direct, 20 ms groups | WebSocket | 5 ms | 5 in 118 s (2.5/min) | 0 | 120 / 140 ms |
| Safari, direct, 100 ms groups | WebSocket | 5 ms | 12 in 119 s (6.1/min) | 0 | 120 / 140 ms |

The Chrome +40 ms, 20 ms-group capture is checked in as the `relay-mic-rtt40`
replay row, budgeted exactly as measured: 7.9 player underruns a minute. Every
replay row now also budgets `player_underruns_per_min`, the player's own
counter, which counts the run-dry re-stalls the output tap files as stalled
time.

Replayed, each row's arrivals reproduce its live re-stalls and short quanta
almost exactly, so the browser's decode and render path adds nothing. The ring
sits at the target less one frame, and each run-dry is one frame arriving later
than that cushion. The target is the 95th percentile of each 500 ms
interval's worst delay, so about 5% of intervals, some six a minute, hold such
a frame. Raising `QUANTILE` in a replay sweep cut the rate but not to zero:
0.99 gave 1.0 to 3.5 a minute at a 60 to 200 ms p50 target, against 5.6 to
13.2 at 40 to 120 ms.

### How deep the stretch must go

Against the cushion each frame met, the five rows' late frames (12 to 27 in
each after 30 s) exceed it by a median of 10 to 16 ms and at most 44 to 81 ms.
Stretching the cushion across that wait means playing at `1 - k` of real time
from the moment the ring dips, with `k = excess / delay`. That is 11 to 31% at
the median, 30 to 47% at p90, and up to 38 to 71%. A bound of a few percent
per quantum cannot meet the Goal.

### Implementation: copy NetEq

Decided 2026-11-03 (maintainer): copy what WebRTC's NetEq does. The stretch
removes or repeats whole pitch periods of received audio, found by
autocorrelation, and cross-fades across the seam. It is not a generic
resampler or a phase vocoder. The mirrored files are in WebRTC's
`modules/audio_coding/neteq/`, read at `a057d00e46` (2026-11-03):

- `time_stretch.cc` and `time_stretch.h`, the shared search. Downsample to
4 kHz and autocorrelate over lags `kMinLag` 10 to `kMaxLag` 60 (2.5 to 15 ms
pitch periods), over `kCorrelationLen` 50. Then refine the best peak at full
rate (`DspHelper::PeakDetection`), and normalise the cross-correlation by
the two segments' energies. `SpeechDetection` treats a segment as passive
when its energy per pitch sample is at most 8 times the background noise's
(a fixed 75000 before noise is estimated). Passive audio may stretch
regardless of correlation.
- `accelerate.cc`. With at least 30 ms of input, keep the first 15 ms,
cross-fade one pitch period onto it, and drop that period. It stretches only
when the correlation is over `kCorrelationThreshold` (0.9 in Q14) or the
audio is passive. Fast accelerate lowers the bar to 0.5 and removes as many
whole periods as fit in 15 ms.
- `preemptive_expand.cc`. Under the same 0.9 test, with at most 15 ms of old
data ahead of the seam, repeat one pitch period after the unmodified part
and cross-fade it in.
- `decision_logic.cc` and `decision_logic.h`, when to stretch. The low limit
is the target and the high limit is the target plus the arrival history's
maximum delay plus `kDelayAdjustmentGranularityMs` (20 ms).
- Preemptive expand when the playout delay is under the low limit.
- Accelerate at or above the high limit.
- Fast accelerate at four times the high limit.
- At most one stretch every `kMinTimescaleInterval` (5) ticks of 10 ms.

Here the target is `Sync`'s resolved delay, and the arrival history's
maximum is the estimator's.

NetEq decides once per 10 ms of output, and our worklet renders 128-sample
quanta. So the stretch runs on blocks of decoded PCM before they reach the
render quantum, where at least 30 ms are buffered.

One boundary differs from NetEq. NetEq preemptive-expands only while the next
packet is already buffered (`ExpectedPacketAvailable`). Once it is missing,
NetEq runs Expand, which is concealment and outside this quest. At NetEq's
cadence, one period of at most 15 ms every 50 ms, preemptive expand slows
playback by about a quarter at most. That covers the median late frame above
but not the 38 to 71% tail. So the work is: start at NetEq's thresholds,
measure `relay-mic-rtt40`, and only then decide how to close the tail. Options
are expanding sooner or more often as the ring nears empty, or accepting the
remaining re-stalls. Each departure from NetEq's constants is recorded with
the replay numbers that justify it.

- Put the stretch where `js/watch/src/audio/replay.ts` exercises it, such as
`AudioRingBuffer`'s read path or a module the render worklet and the replay
share, so the replay rows measure the stretch and not a model of it. Port
the search with unit tests against NetEq's behaviour on synthetic periodic
and noise signals.
- Accelerate replaces the skip-ahead when the ring sits above the high
limit. A re-stall remains for a ring that empties despite the stretch, and a
skip-ahead remains only above what fast accelerate can close.
- Verification: `relay-mic-rtt40` reaches 0 `player_underruns_per_min` and its
budget is tightened to the new measurement. No other replay row regresses,
and the nightly browser rows are re-measured by the budgets note's rule. A
listening check confirms that a 2 ms/s drift and the measured expansions are
inaudible or close to it. The 2026-11-03 Chrome and Safari +40 ms rows are
re-run on real hardware.

### Publisher, measured in the same run

`AudioEncoder` input-to-output lag on live input is p50 0.9 ms and p99 1.1 ms,
with drift under 0.04 ms/s at 20 ms and 100 ms groups. The 7.35 s lag and 88
to 275 ms/s drift of
[moq-dev/moq#3477](https://github.com/moq-dev/moq/issues/3477) did not recur.
Each demand session opens with up to 30 s of retained history
(`js/publish/src/audio/backlog.ts`), which the watcher discards. 100 ms groups
halved Chrome's rate at +40 ms but more than doubled Safari's on the direct
path, so the 20 ms default stays.

Decided 2026-10-15: keep this proof in m1 with the existing playback and
A/V clock work. The estimator defaults on; deferring watch-worker does not
defer validation of the current player.
A/V clock work.

## Waiting

- Maintainer: Safari with 40 ms or more of added RTT, the baseline row the
2026-11-03 run could not shape. On the MacBook Air (`kixtop`), install
Network Link Conditioner (Additional Tools for Xcode) or allow
`sudo dnctl`/`pfctl`, then play `cdn.moq.dev/anon` in Safari with the stats
panel open for two minutes, and record its audio Underruns after 30 s and the
latency tab's auto delay.

## Required

- [A/V clock](/quest/m1/av-clock.md) - the clock the stretch converges toward

## Related

- [A/V clock](/quest/m1/av-clock.md) - reshapes `SyncInput` around the per-track target this line produces
- [Capped budgets](/quest/m1/audio-jitter-target/capped-budgets.md) - confirms the build-flush fix on the nightlies, no gate on this proof
- [Watch worker](/quest/m2/watch-worker.md) - moves the worklet this stretch changes
2 changes: 1 addition & 1 deletion quest/m1/av-clock.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,5 @@ whose remaining manual browser proof does not gate reshaping the clock.

## Related

- [Time stretch](/quest/m2/watch-audio-time-stretch.md) - stretching needs a clock to converge toward
- [Watch](/quest/m1/audio-jitter-target/watch.md) - time-stretches the audio ring toward the clock this produces
- [Watch worker](/quest/m2/watch-worker.md) - moves `Sync` into a worker afterwards; keep the handles free of main-thread assumptions
1 change: 0 additions & 1 deletion quest/m2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ lite-07; bitrate enforcement is not part of this release.
- [E2EE](/quest/m2/e2ee/README.md) - TypeScript and Rust peers interoperate over encrypted broadcasts no relay can decrypt
- [iOS capture](/quest/m2/mobile-capture-ios.md) - Rust captures the camera and screen on iOS, setting the catalog rotation
- [Android capture](/quest/m2/mobile-capture-android.md) - Rust captures through NDK/JNI on Android, reusing the existing codecs and setting the catalog rotation
- [Time stretch](/quest/m2/watch-audio-time-stretch.md) - js/watch: the audio ring converges by time-stretching instead of skipping or going silent
- [Latest readers use Range::live()](/quest/m2/readers-range-live.md) - after #6278, readers start at the live edge without waiting on `largest()`
- [Audio outlives video](/quest/m2/audio-bufferbloat-av.md) - under bufferbloat audio stays within budget while video may skip, rendered frames stay in sync, and native audio rows are enforced
- [ViSQOL grading](/quest/m2/audio-visqol.md) - the nightly scores played audio against a reference clip and fails below measured floors
Expand Down
Loading