Skip to content

Drop browser audio that arrives too late to mix - #7

Merged
zackees merged 2 commits into
obs-rust:masterfrom
rdoupe:fix/audio-stream-start
Oct 25, 2026
Merged

zackees merged 2 commits into
obs-rust:masterfrom
rdoupe:fix/audio-stream-start

Conversation

@rdoupe

@rdoupe rdoupe commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Refs obs-rust/obs-studio#2335

What and why

With "Control audio via OBS", CEF hands each audio packet over with its capture time, on OBS's clock. Under load, CEF's delivery to OBS can stall for hundreds of ms and then hand over the backlog all at once. During the stall the source's buffer runs short, and OBS treats it as stopped. When the backlog arrives, OBS places it at its capture time, which is behind the block it is mixing, and raises its global audio buffering to fit it in. That buffering applies to every source and never comes back down. A few stalls take a stream to the 960 ms maximum, and it stays there until OBS restarts.

The stalls come in the middle of a stream, not only at its start. In the runs that showed this, the first late packet was packet #14 or #24 of a stream. A page that plays a continuous tone hits the same rise as one that plays alerts.

The fix:

  • Each BrowserSource registers a raw audio callback on mix 1 that records the end of the block OBS mixed last. It is added in the constructor and removed in Destroy().
  • OnAudioStreamPacket drops a packet that OBS would place by its timestamp behind that point. OBS places a packet by its timestamp when it starts a stream, comes after a pause in delivery of at least 3 blocks, or leaves a gap beyond OBS's 70 ms timestamp smoothing. The check follows OBS's smoothing to find where OBS would put the packet.
  • These packets are always kept:
    • a packet that continues the stream (OBS appends it, whatever its timestamp);
    • a packet delivered within its own duration, so that OBS still buffers once for the capture's normal latency;
    • a packet more than 2 s off OBS's clock (OBS re-anchors those itself).
  • A packet up to one block later than that is dropped, unless such packets keep coming for 70 ms. That means OBS buffers less than the capture's normal latency, and it may raise the buffering.
  • Kept packets keep their timestamps, so the browser audio stays in sync with video.
  • Each run of dropped audio is logged: [obs-browser: '<source>'] Dropped N ms of audio that arrived too late to mix.

Behaviour change

  • Streaming and monitoring: during a CEF delivery stall, the browser source's late audio is dropped. The source is silent for about the length of the stall, up to about a second (960 ms for a 0.9 s stall in the test). Before, the audio delay of every source grew by about the stall's length, permanently, up to 960 ms. Other sources keep their latency: in the recording runs, OBS's mix latency ended at 43 ms with the fix and at 854 ms on master.
  • Recording: the browser track has a gap where the stall was, and the audio after the gap stays where it belongs. On master the recording loses audio in a stall too, but less of it, because master fills part of the gap by delaying everything: in the same 0.5 s stall, master left a 191 ms gap and the fix left a 544 ms gap. Sync did not get worse for any source (details below).

Recording measurements

Made with a scratch harness, not part of this PR. It used the real module and libobs, 150 s, isolated, load bursts as in the test, and two delivery stalls made by the harness: 0.5 s into alert 8 and 0.8 s into alert 20, each 0.3 s after the tone starts. It records MKV through ffmpeg_muxer with x264 and two AAC tracks: track 1 has the browser alert page, which shows white for its 1 s tone; track 2 has a reference source with a constant 1 kHz tone and a 100 ms beep every second, with a white video frame for each beep, both on OBS's clock. The analysis reads the MKV with ffmpeg.

master (obs-browser 7c92b29) with the fix (2aa2b34)
Global audio buffering 21 → 320 (load) → 512 (0.5 s stall) → 832 ms (0.8 s stall) 21 ms throughout
Mix latency, all sources (wall clock minus mixed timestamp) 22–48 ms at start, 854 ms at the end 21–43 ms at start, 43 ms at the end
Browser gap in the recording, 0.5 s stall 191 ms 544 ms
Browser, 0.8 s stall (it ran past the tone's end) 319 ms gap the tone ends 295 ms in; the rest was dropped
Browser gaps from load alone 298 ms (the stall that raised buffering to 320 ms) 85–384 ms, 8 times
Browser tone A/V offset, onset vs the page's white frame +17 to +108 ms, the same band before and after the stalls +11 to +105 ms, the same band before and after
Browser tone span from onset to end, gaps included 912–993 ms 901–1014 ms
Reference beep A/V constant; one 21 ms step at 2.5 s with a 20 ms dropout constant; one 21 ms step at 113.8 s with a 21 ms dropout
Reference tone continuity no other dropout no other dropout
  • The browser A/V offset moves in a ±35 ms sawtooth in both builds. That is the page's timer against the 30 fps frame grid, not drift. The tone span shows the audio after a gap is not shifted: with the fix, the alert with the 544 ms gap still spans 953 ms.
  • The reference source's single one-block step comes with a one-block dropout of its own audio under load. It shows up in both builds, at times with no browser drop nearby, so it is libobs's behaviour under load, not this change.
  • In the run with the fix, one alert is missing. CEF delivered no packets for it until about 1 s after it began, a late start of the capture stream. The fix dropped 107 ms of its first packets, and the rest were already silent.

RED → GREEN

  • RED commit test: browser audio buffering under load (8f04654). tests/audio_buffering.py compiles audio-buffering.c against the real libobs. It plays audio-buffering.html (WebAudio: a 1 s 440 Hz tone every 5 s, or --mode continuous) through a real browser source with reroute_audio. After 20 quiet seconds it loads every CPU in bursts: 32 busy processes that it starts and kills by PID, 4 s on and 6 s off, until 150 s.
    • Load alone did not stall CEF in every run. The harness therefore also stalls the delivery 3 times (0.5, 0.9 and 0.3 s, in alerts 6, 12 and 18). It interposes obs_source_output_audio, so it blocks the module's audio thread before libobs while holding no libobs lock. It drops the one packet the module had already passed, in both builds.
    • It requires that global buffering stays at its value from before the load, that every 5 s period has packets, and that every tone starts in the mix within one audio block (plus CEF's 1 ms timestamp resolution) of its capture timestamp.
    • On master it fails: buffering goes from 21 to 533 ms at the first stall and to 917 ms at the second (alerts), or from 21 to 512 to 938 ms (continuous).
  • GREEN Drop browser audio that arrives too late to mix (2aa2b34): passes in 5 of 5 alert runs and 1 continuous run. Buffering stays at 21 ms. Each run drops 1771 ms of late audio in 3 runs of up to 960 ms, and every tone (31 checked) starts in the mix within 1 ms of its capture timestamp, except one tone in one run that was 21.0 ms off. That was a one-block shift at a stream start with no drop nearby; libobs did the same once on master (+20.6 ms, no load), so the test allows one block.
  • Runs: Linux, CEF 8078, isolated: loopback-only network namespace, private Xvfb and dbus, a fresh HOME, no audio server, no core dumps. clang-format clean.
  • CI: not run in CI. Like the other tests/ harnesses it needs a CEF build and an X display. The command is in tests/README.md.

Notes

  • Verified on Linux only. The decision uses only OBS's clock and libobs's public API (obs_add_raw_audio_callback, AUDIO_OUTPUT_FRAMES). CEF's packet timestamps are on the same monotonic clock as os_gettime_ns() on each platform. If they ever weren't, the 2 s check leaves the packets to OBS's own re-anchoring, as before.
  • The constants mirror libobs's TS_SMOOTHING_THRESHOLD (70 ms) and MAX_TS_VAR (2 s) from obs-internal.h, which plugins cannot include.
  • The test is independent of Resume WebAudio after the audio service dies #6 and is based on master.

@zackees zackees left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Coordinator review: approved.

  • AudioTooLate follows libobs's placement: smoothing within 70 ms snaps to audio_next_ts, a gap of 3+ blocks counts as a restart, and anything more than 2 s off is left to OBS to re-anchor. It drops only packets that land behind audio_mix_end and later than their own duration, plus a grace run for packets up to one block late, which still lets OBS buffer once for normal latency.
  • I checked that block uses OBS's rate: OnAudioStreamStarted sets sample_rate from params.sample_rate = audio_output_get_sample_rate(obs_get_audio()), so it matches the mixer's block length.
  • Lifetime: the raw callback is added in the constructor and removed in Destroy(), which is the only free path (info.destroy). obs_remove_raw_audio_callback takes the callback lock, so no callback can run after it returns. audio_mix_end is atomic across the audio and CEF threads.
  • The measurements show the trade-off clearly (a longer browser gap during a stall, instead of permanent latency for every source). The behaviour change is spelled out in the description. RED → GREEN is documented.

Nit (non-blocking): the register in the constructor is unconditional, so sources without "Control audio via OBS" also take a per-block callback. That's cheap, but it could be skipped when reroute_audio is off.

tests/audio_buffering.py plays a WebAudio alert page (a 1 s tone every
5 s) through a real browser source with "Control audio via OBS", loads
every CPU in bursts and requires that OBS's global audio buffering stays
at its value from before the load, that packets keep flowing and that
each tone starts in the mix within one audio block of its capture
timestamp.

On master a burst stalls the capture for up to 0.9 s; the backlog then
arrives with its real timestamps and OBS raises the buffering of every
source to fit it in, for good (106 ms to 874 ms in one burst).

Refs obs-rust/obs-studio#2335
With "Control audio via OBS", CEF hands each packet over with its
capture time on OBS's clock. Under load its delivery can stall for up
to a second and then deliver the backlog at once. OBS places the
backlog at its capture time, behind the block it is mixing, and raises
its global audio buffering to fit it in. That buffering delays every
source and never comes back down: one stall could add up to 960 ms of
latency to the whole stream until OBS restarts.

Drop such packets instead. Each source now tracks where OBS's mix is
(a raw audio callback on mix 1), and OnAudioStreamPacket drops a packet
that OBS would place by its timestamp (a new stream, a pause in
delivery, a gap) behind the mixed block. Packets that continue the
stream are appended by OBS whatever their timestamp and are kept, as
are packets delivered within their own duration, so OBS still buffers
once for the capture's normal latency. Kept packets keep their
timestamps, so the browser audio stays in sync with video. Each run of
dropped audio is logged.

The cost: during a stall, the browser source is silent for about the
length of the stall, instead of the whole stream's audio staying that
much later for good.

tests/audio_buffering.py: buffering stays at 21 ms through 13 load
bursts and three delivery stalls (0.5, 0.9 and 0.3 s), every tone
starts in the mix within one audio block of its capture timestamp.

Fixes obs-rust/obs-studio#2335
@zackees
zackees force-pushed the fix/audio-stream-start branch from 2aa2b34 to 1ca296b Compare October 25, 2026 07:15
@zackees

zackees commented Oct 25, 2026

Copy link
Copy Markdown
Member

Coordinator: rebased onto master after #6 landed. The only conflict was tests/README.md, and I kept both sections (#6's WebAudio recovery first, then this one). No code changes. I'll merge once the format check is green again.

@zackees
zackees merged commit 27307dc into obs-rust:master Oct 25, 2026
1 check passed
chriscoveries pushed a commit to chriscoveries/obsrust-fork that referenced this pull request Oct 25, 2026
…s) (obs-rust#2378)

Moves plugins/obs-browser from 7bab463c9 to 4583599fe, with:

- obs-rust/obs-browser#3: the CEF sandbox where it can work on Linux
  (obs-rust#1092)
- obs-rust/obs-browser#5: os_event_init results checked in module load
  and ExecuteOnBrowser
- obs-rust/obs-browser#6: WebAudio resumes after the audio service dies
  (obs-rust#2328)
- obs-rust/obs-browser#7: browser audio that arrives too late to mix is
  dropped (obs-rust#2335)
- obs-rust/obs-browser#8: the fallback warning when the sandbox can't
  work (obs-rust#2218)

One bump instead of three (obs-rust#2333, obs-rust#2351 and ours), as agreed on obs-rust#2351.
docs/features.md gets the two audio lines from obs-rust#2333 and obs-rust#2351 as they
are, a line for the sandbox, and the browser on the fallback warning
line, which obs-rust#2238 merged on 2026-10-25.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants