Repository navigation
fix(publish): render the audio capture graph to no output device - #7881
Conversation
Quest-Claim: 683da9b2d13b3aa6
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>
…/capture-smoothing
|
Warning Review limit reachedYou'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. View limit details
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. |
kixelated
left a comment
There was a problem hiding this comment.
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))
There was a problem hiding this comment.
ℹ️ No blocking issues — a few suggestions inline, plus one migration-note question.
Reviewed changes
- Silent capture sink —
Audio.Capturenow builds itsAudioContextwithsinkId: { type: "none" }, so the capture graph renders to no device instead of a long device callback, andCaptureOutput.root's doc notes the context is silent. - Spread reporting —
ptt-analyzenow reports each press'sspread(arrival minus media timestamp, max minus min) andlatency.spreadacross presses, excluding the release-flush tail chunk. - Quest and docs — deletes the
capture-smoothingquest, drops its references from the push-to-talk README and latency Required list, records the new capture numbers, and notes the release-flush tail indtx.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?
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
… 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>
There was a problem hiding this comment.
✅ 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.tscomment andpush-to-talk-latency.mdnow 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.mdgains an## Unreleasedentry for the silentrootcontext, andEncoderOutput.rootcarries the matching TSDoc. - Pinned the capture options in a test — the fake
AudioContextnow records the full options object andrenders the capture graph to no output deviceassertssinkId: { type: "none" }, which fails if the option is removed.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
kixelated
left a comment
There was a problem hiding this comment.
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))

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:"interactive"desktop: Linux, PipeWire, real analog sink (where #7760 ran)nixos2: Linux, no output sinkkixtop: macOS 26.6.2baseLatency)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,
SilentSinkSuspendermoves 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 withoutsinkIdignore 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-analyzereports each press'sspread, andlatency.spreadacross 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.mdis deleted.push-to-talk-latency.mdgets the new capture numbers and explains the spread that remains.dtx.mdnotes the release-flush tail (below), so suppression doesn't carry it over.Measurements
just test push-to-talkran 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.path.capturemedian ms"interactive"already gave a short callback, improves less.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
deliveryshrank while the onset stayed put, desktop'splayoutmax 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: 0gives 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: 0withsinkId: nonewas worse: 75 → 160 ms in 30 s.latencyHint: 0.01still gave 2048 samples on PipeWire.Impact
Audio.Capture's andAudio.Encoder'sout.rootnow sit in a context with no output device in Chromium. A caller who connectedrootto its context'sdestinationto monitor the mic hears nothing there. Both doc comments say so, andjs/publish/MIGRATE.mdhas an## Unreleasednote. Nothing in the repo does that.Choices made without asking
sinkId: nonerather than a lowerlatencyHint: see the measurements above.rootchange isn't marked breaking, but it gets aMIGRATE.mdnote. 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.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, sodtx.mdnow carries the warning instead.spreadexcludes that tail chunk, since it isn't one of the utterance's submitted frames.Suggested follow-ups
Validation
just check: everything passed except biome, which flagged formatting inptt-analyze.ts. Afterbiome 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.origin/main(headc3d6f47e9f),js/publish:bun run testpassed (342 pass), as didbun run checkandbun biome check --error-on-warnings js/publish, plusquest check. The newcapture.test.tscase, "renders the capture graph to no output device", fails whensinkIdis removed.just test push-to-talkruns: 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, viaquest review --base origin/main, round 2 on headc3d6f47e9f: no findings. (Round 1 on4990d623bcalso had none.)Pullfrog's two threads on
4990d623bc, both fixed inc3d6f47e9fand resolved:push-to-talk-latency.mdnow say Chromium 155+, which matches the tag bisect above. The "after 30 s" clause is dropped.sinkIdin a test: fixed.capture.test.tsnow asserts it.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code