Skip to content

fix(publish): render the audio capture graph to no output device - #7881

Merged
kixelated merged 5 commits into
mainfrom
quest/m1/push-to-talk/capture-smoothing
Nov 4, 2026
Merged

kixelated merged 5 commits into
mainfrom
quest/m1/push-to-talk/capture-smoothing

Conversation

@kixelated

@kixelated kixelated commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

moq-dev/moq#7760 measured a 46 ms median per-press arrival spread on an ideal path. The cause was the capture graph's bursts, not the network. In "auto", the listener's jitter estimate reads that spread as jitter.

Where the bursts form

The capture AudioWorklet posts each 128-sample quantum as it renders. Rendering runs one output callback at a time, so every quantum in a callback reaches the main thread together. The burst length is therefore the capture AudioContext's callback size:

Host Capture context callback at "interactive" 60 ms frames submitted
desktop: Linux, PipeWire, real analog sink (where #7760 ran) 2048 samples, 43 ms 36-94 ms apart
nixos2: Linux, no output sink 480 samples, 10 ms 50-70 ms apart
kixtop: macOS 26.6.2 10 ms grid (256-sample baseLatency) 58-61 ms apart

The rest of the path adds no bursts. Worklet → Fanout → framer → AudioEncoder → container write all run within about 1 ms. The worklet's per-quantum posting cadence was ruled out too: the main thread received each callback's quanta within 8 ms of rendering.

The capture graph is never audible, since the worklet has no outputs. Chromium already treats it as silent: after 30 s, SilentSinkSuspender moves it onto a timer worker at the device's callback size (content/renderer/media/renderer_webaudiodevice_impl.cc).

Fix

The capture context is now built with sinkId: { type: "none" }. It renders to no device, and Chromium 155+ paces it every 10 ms (GetSilentSinkAudioParameters: sample_rate / 100), whatever the device. That function first ships between tags 155.0.8038.0 and 155.0.8059.91. Chromium 154 and earlier give a silent sink the default device's parameters, so there the bursts are unchanged and no worse. Browsers without sinkId ignore the option and keep the default device. On kixtop that was checked for Firefox 157 and WebKit 27.2: both construct and run the context.

Nothing re-paces on a timer of ours. The 10 ms floor that remains is Chromium's own capture chunking.

Also in this PR:

  • ptt-analyze reports each press's spread, and latency.spread across presses. That is the arrival-minus-media-timestamp range over the utterance's frames, the quantity "auto" measures. There is no assertion, matching the rest of its latency report.
  • quest/m1/push-to-talk/capture-smoothing.md is deleted.
  • push-to-talk-latency.md gets the new capture numbers and explains the spread that remains.
  • dtx.md notes the release-flush tail (below), so suppression doesn't carry it over.

Measurements

just test push-to-talk ran unmodified: 22 presses, 60 ms Opus, a fixed 300 ms delay, Chromium 156.0.8078.4 headless with the fake tone microphone, local relay. "Spread" covers the utterance's own frames. "Submit spread" is the same range at the encoder input, which isolates capture.

Host Run Spread median (max) ms Submit spread median ms path.capture median ms
desktop (Linux, PipeWire) before 44.5 (54.6) 43.6 29.1
after 1 11.8 (30.8) 10.7 9.4
after 2 11.4 (24.7) 7.2 0.7
kixtop (macOS) before 1 13.3 (39.9) 10.3 10.0
before 2 13.1 (53.2) 10.4 10.0
after 1 7.6 (22.9) 4.7 0.8
after 2 11.5 (27.2) 5.0 2.8
nixos2 (Linux, no sink) before 10.0 (72.2) 6.8 0.2
after 9.3 (464.8) 1.4 0.3
  • Desktop reproduces test(publish): measure push-to-talk on an ideal path and split its latency #7760's 46 ms before the fix and drops to about 12 ms after it.
  • kixtop, where "interactive" already gave a short callback, improves less.
  • nixos2: its 465 ms max is one 425 ms stall in which submits, encoder outputs, and decodes all stopped, so the whole renderer stalled, not capture. A second nixos2 run with the fix failed its completeness gate: capture lagged 1.3 s after a 60 s mute. nixos2 was compiling an unrelated build throughout, and its baseline run also shows a 140 ms jump at the last press. I don't count either nixos2 run as evidence either way.

Press to projected output didn't change under the fixed delay: desktop median 464 → 467 ms, kixtop 419 → 411 ms. The gain is in what "auto" measures.

Because delivery shrank while the onset stayed put, desktop's playout max rose from 278 to 314 ms. The difference is the watch context's own 43 ms render callback, which capture bursts used to hide. The latency quest owns that budget.

Per-press median capture lateness wanders 10-40 ms over a run, before and after (desktop before +32 ms, after +27 ms; kixtop before within ±10 ms, after +16 to +21 ms). Chromium's timer worker skips ticks it wakes too late for (media/base/fake_audio_worker.cc), so the sample-count clock falls behind the page clock. A silent capture graph runs on that worker after 30 s either way. The fix gives it 10 ms ticks instead of the device's.

Other options measured on desktop and nixos2:

  • latencyHint: 0 gives 128-sample callbacks. A busy host then misses callbacks and the capture clock falls behind, with lateness climbing 5 → 40 ms in 30 s on nixos2.
  • latencyHint: 0 with sinkId: none was worse: 75 → 160 ms in 30 s.
  • latencyHint: 0.01 still gave 2048 samples on PipeWire.

Impact

  • Public API: no signature change. Audio.Capture's and Audio.Encoder's out.root now sit in a context with no output device in Chromium. A caller who connected root to its context's destination to monitor the mic hears nothing there. Both doc comments say so, and js/publish/MIGRATE.md has an ## Unreleased note. Nothing in the repo does that.
  • Wire: none.

Choices made without asking

  • sinkId: none rather than a lower latencyHint: see the measurements above.
  • The root change isn't marked breaking, but it gets a MIGRATE.md note. Its documented use is tapping the capture (analysis), which still works. The note is there because the file already lists behavior-only changes (muted/invisible) and it's where consumers look. It was added in iteration after pullfrog's review.
  • The release-flush tail is left alone. On release, the encoder's flush() sends the Opus lookahead as an extra chunk at once, up to a frame ahead of its media. That adds 10-22 ms to the median per-press spread across hosts, and "auto" measures a re-press within its 2 s window against it. Push-to-talk suppresses silence replaces the mute path that flushes, so dtx.md now carries the warning instead.
  • spread excludes that tail chunk, since it isn't one of the utterance's submitted frames.

Suggested follow-ups

  • Capture clock wander under Chromium's timer worker. The worklet stamps by counting rendered samples, so a skipped tick adds capture latency for the rest of the session. That drift is 10-40 ms over 10 minutes here, more under load. Worth measuring over a long real session and stamping from a clock that doesn't skip.

Validation

  • just check: everything passed except biome, which flagged formatting in ptt-analyze.ts. After biome check --write, bun biome check --error-on-warnings . passed.
  • bun test ptt-analyze.test.ts: 6 pass. The new test covers a press whose spread a held-back frame widens.
  • After merging origin/main (head c3d6f47e9f), js/publish: bun run test passed (342 pass), as did bun run check and bun biome check --error-on-warnings js/publish, plus quest check. The new capture.test.ts case, "renders the capture graph to no output device", fails when sinkId is removed.
  • just test push-to-talk runs: see the table. The desktop baseline and every desktop and kixtop run with the fix passed. One more desktop run with the fix was invalid: it failed "expected 22 complete presses" after I edited source mid-run and Vite reloaded the page. The rerun is "after 2".

Review

Codex gpt-6.1-sol, medium effort, via quest review --base origin/main, round 2 on head c3d6f47e9f: no findings. (Round 1 on 4990d623bc also had none.)

Pullfrog's two threads on 4990d623bc, both fixed in c3d6f47e9f and resolved:

  • Scope the 10 ms claim: fixed. The comment and push-to-talk-latency.md now say Chromium 155+, which matches the tag bisect above. The "after 30 s" clause is dropped.
  • Pin sinkId in a test: fixed. capture.test.ts now asserts it.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 3 commits November 4, 2026 06:51
The capture worklet delivers samples one render callback at a time. Chromium
on PipeWire renders 2048 samples (43 ms) per callback even at "interactive",
so 60 ms Opus frames left 36-94 ms apart and per-press arrival spread was
45 ms on an ideal path. Rendering to `sinkId: { type: "none" }` paces the
graph every 10 ms instead; browsers without sinkId ignore the option.

The push-to-talk analyzer now reports each press's arrival spread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Browser capture delivers audio frames on a steady cadence fix(publish): render the audio capture graph to no output device Nov 4, 2026
@kixelated
kixelated marked this pull request as ready for review November 4, 2026 16:05
@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 10 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f7adc142-48ca-46d8-ad85-1dd848d75b97
Commits

Reviewing files that changed from the base of the PR and between cb087b6 and c3d6f47.

Files selected for processing: 11
  • js/publish/MIGRATE.md
  • js/publish/src/audio/capture.test.ts
  • js/publish/src/audio/capture.ts
  • js/publish/src/audio/encoder.ts
  • quest/m1/push-to-talk-latency.md
  • quest/m1/push-to-talk/README.md
  • quest/m1/push-to-talk/capture-smoothing.md
  • quest/m1/push-to-talk/dtx.md
  • test/audio-quality/README.md
  • test/audio-quality/clients/js/ptt-analyze.test.ts
  • test/audio-quality/clients/js/ptt-analyze.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@kixelated kixelated left a comment

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.

Automated review by review (OpenAI)

Reviewed head 4990d62 against base 635dd54. No actionable correctness findings. Using a silent capture sink to reduce callback batching is a reasonable direction; the capture-root documentation now states the monitoring consequence, and the spread calculation measures arrival offsets against media time.

Verification: GitHub-only static review of capture setup, lifecycle, browser fallback, analyzer and tests, including an independent pass. I did not run the browser harness or independently reproduce the reported measurements; the PR's loaded-host and clock-wander limits remain relevant.

(Written by review (OpenAI))

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No blocking issues — a few suggestions inline, plus one migration-note question.

Reviewed changes

  • Silent capture sink — Audio.Capture now builds its AudioContext with sinkId: { type: "none" }, so the capture graph renders to no device instead of a long device callback, and CaptureOutput.root's doc notes the context is silent.
  • Spread reporting — ptt-analyze now reports each press's spread (arrival minus media timestamp, max minus min) and latency.spread across presses, excluding the release-flush tail chunk.
  • Quest and docs — deletes the capture-smoothing quest, drops its references from the push-to-talk README and latency Required list, records the new capture numbers, and notes the release-flush tail in dtx.md.
  • Tests — adds a case where a frame held back by 30 ms widens its press's spread, and renames the shared summary helper to summarize.

ℹ️ The silent root behavior change isn't in the migration notes

AGENTS.md asks that public API behavior changes add an ## Unreleased note to the package's MIGRATE.md, and existing entries cover behavior-only changes such as muted/invisible keeping capture open. root's context no longer reaches the default output device, so an app that connected it to destination for local monitoring now hears nothing. The new TSDoc says so, but a migration note is what consumers actually scan.

Technical details
# Silent capture root migration note

## Affected sites
- js/publish/MIGRATE.md — no `## Unreleased` entry for the `root` behavior change
- js/publish/src/audio/capture.ts:74-77 — TSDoc already describes the silent context
- js/publish/src/audio/encoder.ts:133-135 — re-exports the same `root` and doesn't mention the change

## Required outcome
- A consumer upgrading `@moq/publish` can find, in one place, that `Capture.root`/`Encoder.root` now live in a context that renders to no device in Chromium.

## Open questions for the human
- Is deliberately not marking it breaking the right call, given the documented use is tapping for analysis rather than monitoring?

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Comment thread js/publish/src/audio/capture.ts Resolved
Comment thread js/publish/src/audio/capture.ts Resolved
kixelated and others added 2 commits November 4, 2026 10:54
… a test

The 10 ms silent-sink pacing (GetSilentSinkAudioParameters) first ships in
Chromium 155; older versions pace a `sinkId: "none"` context at the default
device's callback size. Say so in the capture comment and the latency quest,
assert the option in capture.test.ts, and add a MIGRATE.md note for `root`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

The delta since the prior Pullfrog review (4990d62..c3d6f47) resolves both round-1 threads and the migration-note question.

  • Scoped the silent-sink pacing claim — the capture.ts comment and push-to-talk-latency.md now say Chromium 155+ paces a no-device context at 10 ms, with older Chromium keeping the device callback size, and the inaccurate "after 30 s" rationale is gone.
  • Added the migration note — js/publish/MIGRATE.md gains an ## Unreleased entry for the silent root context, and EncoderOutput.root carries the matching TSDoc.
  • Pinned the capture options in a test — the fake AudioContext now records the full options object and renders the capture graph to no output device asserts sinkId: { type: "none" }, which fails if the option is removed.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

@kixelated kixelated left a comment

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.

Automated review by review (OpenAI)

Reviewed commit: c3d6f47

No new actionable findings. Compared with my prior review at 4990d62, accounting for the main merge: the follow-up correctly limits the 10 ms pacing claim to Chromium 155+, adds a constructor-options regression assertion without dropping the sample-rate checks, and documents the silent-root monitoring change in both migration notes and EncoderOutput. The silent capture-sink direction remains sound.

Verification: GitHub-only static incremental review, including an independent pass and Chromium source comparison. I did not run tests, browsers or latency measurements; the earlier loaded-host and capture-clock limits remain.

(Written by review (OpenAI))

@kixelated
kixelated added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 6bdb023 Nov 4, 2026
9 of 10 checks passed
@kixelated
kixelated deleted the quest/m1/push-to-talk/capture-smoothing branch November 4, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant