Skip to content

feat(irl-source): video delay follows the audio playout offset - #36

Merged
datagutt merged 13 commits into
masterfrom
refactor/sync
Oct 2, 2026
Merged

datagutt merged 13 commits into
masterfrom
refactor/sync

Conversation

@datagutt

@datagutt datagutt commented Oct 2, 2026

Copy link
Copy Markdown
Member

Stacked on #35. Merge that first; this PR's base then moves to master.

Why

The video delay and the audio hold estimate the same thing (how far video trails its audio) in two ways, and three hand-offs kept them in step: the pump credited hold growth, the video thread absorbed it from the delay, and the re-anchor netted the hold out of its drift. The hand-offs missed cases. A new end-to-end simulation shows two of them:

  • A Target Buffer edit during a hold build left the video delay stuck, so the picture stayed 83 ms behind the sound for the rest of the connection.
  • After a stall or loss during a hold build, sync came back only 17 to 24 s later, because concealment growth of the playout offset never reached the delay.

What changed

  • Video delay follows the audio playout offset. The delay is now an absolute floor F (worst windowed arrival minus PTS, plus a tick). A frame is due at pts + max(O, F), capped, so the visible delay max(0, F − O) is derived and never stored. Raise, relax and ceiling policies stay, applied to F. The absorb and hold credit plumbing is gone. video_delay_ms reports the live picture behind sound error.
  • The relax window is now a sliding 10 s window that a video gap over 1 s restarts. With fixed blocks, a skew that rose and recovered left the picture up to 85 ms behind for about 10 s, because the hold released before the delay relaxed.
  • Logs: a warning when F rises or hits the ceiling, and one info line each time the visible delay starts or ends because the audio offset moved.
  • The "video lead beyond what OBS can queue" warning is removed. Pacing hands frames to libobs only a couple of ticks early, so its premise did not hold.
  • The pump's timing plumbing is two types in irl-core: OutputClock (one restart path for prime, stall and re-anchor) and PlayoutMapping (the offset and its drift baseline, shared by the pump and av_drift).
  • Fixes: both arrival floors reset on a timeline reset, an audio format change forgets the old drift baseline, and the startup audio lead uses the same test as the mapping.
  • VideoThread reads one injected clock, which removes the timing flake in tests/video_pipeline.rs.
  • New tests/av_sync_sim.rs drives the real pump and video thread on one virtual clock and measures the actual offset between picture and sound in 10 scenarios.
  • New crates/irl-core/examples/skew-relay.rs and docs/skew-testing.md reproduce late video against a real OBS.

Simulation results

scenario before after
Target Buffer edit during a hold build 83 ms behind for good in sync
3 s stall during a hold build back in sync after 17.4 s 0.1 s
loss while the delay stands back in sync after 24.4 s 1.6 s
clean link, pocketSRT style, stabiliser, low latency, relay catch-up, video only unchanged unchanged

How to review

  1. Start with crates/irl-core/src/video_delay.rs and its tests: that is the model.
  2. Then crates/irl-source/src/video/thread.rs and video/output.rs, where due times are derived.
  3. Then crates/irl-source/tests/av_sync_sim.rs, the scenarios and their bounds.

The commits before 94d82b4 change no playback behaviour, apart from removing the lead warning in fbd03e6. 94d82b4 is the model change, and its body explains every test whose numbers moved.

Risks

  • Inside a 3 s stall burst, frames now go out up to 515 ms late (was 217 ms), since no stored delay absorbs the burst. Sync is back as soon as the burst is through.
  • On a clean link, the frame that anchors libobs's play head can be up to one canvas tick late, and libobs keeps that error (+16 ms at 60 fps). This was already true; the clean-link scenario now pins it at one tick.
  • Not yet validated on a real stream. docs/skew-testing.md is the recipe; real Moblin or pocketSRT captures would be the stronger check.

The audio hold's monotonic min/max queue moves to irl_core::window so the
video delay can judge its relax window the same way.
The "Video lead is beyond what OBS can queue" line assumed frames reach
libobs as early as the audio mapping places them. Pacing hands each frame
over a couple of canvas ticks before it is due, so libobs never holds more
than that and the warning described a queue that does not exist. Its frame
budget, throttle and default frame interval go with it; the frame-interval
EMA stays for the stats line's vfps.
…d of tracking it by hand

The video delay was a stored offset on top of the audio playout offset,
kept in step by hand-offs: the pump credited hold growth to
AudioState::hold_built_ns, the video thread absorbed it, and raises and
ramps shifted the queue. Those missed cases. After a Target Buffer edit
during a hold build the delay stood at 66 ms for good, and concealment
growth during an outage was never absorbed, so the picture stayed some
300 ms behind the sound for 17 to 24 s after the link came back.

irl_core::video_delay now keeps an absolute floor F under the schedule:
the worst arrival minus PTS seen, plus a tick for the decode. A frame is
due at pts + max(O, min(F, O + max)), so the delay is clamp(F - O, 0, max)
and is derived wherever it is needed. The raise, window, relax and ceiling
rules are unchanged but act on F, and a raise still sets a whole number of
ticks of delay against the offset in force. The liveness check moves into
the same type, and a PTS timeline reset now forgets it with the floor.

The relax window slides instead of running in fixed blocks, and the part
of the floor below the offset, which is not on screen, drops at once
instead of slewing. Otherwise a hold released for a recovered sender
leaves a stale floor showing for up to ten seconds.

Removed: VideoDelay::absorb, hold::credit_build, AudioState::hold_built_ns
and hold_unbuilt_ns, VideoThread::absorb_audio_hold, PacingQueue::shift and
shift_earlier. Queued frames keep their undelayed due time, so reschedule
re-derives every due time, fallback-scheduled ones included. The audio
hold itself is unchanged.

video_delay_ms and vdelay= report the derived delay, republished every
video cycle. When the playout alone takes it to or from zero, one info
line says so.

The fallback adds the startup audio lead whenever no mapping is
published, matching the test that picks the mapping, so a chunk whose PTS
ends at or below zero no longer leaves video with neither.

Tests whose numbers moved:
- a_delay_set_by_a_bad_first_frame_ramps_back_without_a_jump settles at
  0, not one tick: the relax target is what frames need plus a tick of
  headroom in floor terms, and 100 ms in hand covers both.
- the hand-back test drives only the mapping; nothing credits the build.
- av_sync_sim: a Target Buffer edit during a build ends in sync (82.8 ms
  to 16.1 ms, the anchoring tick) and is no longer ignored. A stall during
  a build is in sync 0.1 s after it (was 17.4 s), a loss under the delay
  1.6 s after it (was 24.4 s); both assert that now, and their settled
  windows start at 34 s and 30 s instead of 55 s and 60 s. The loss
  scenario checks that the delay stood before the loss rather than at the
  re-anchor, since concealment now takes it away first.
- network_sim checks the built hold by the buffer level and the video
  margin instead of the removed credit.
- video_delay unit tests are restated against the floor; the absorb ones
  go, and tests for a raise against a moved offset, the derived delay,
  slew visibility and reset are added.
The Video delay and Audio hold sections describe the floor the delay is
derived from, why the relax window slides, and the hold building without
any hand-off to the video thread.
…th the mapping

the output clock restarts on a reconfigure, so the offset it primes on is
not comparable with the baseline of the old format, and the re-anchor check
measured drift against it.
@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 9aa7ee8d-6a71-4306-a66a-21c18f43d0ea

📥 Commits

Reviewing files that changed from the base of the PR and between 5ec18e6 and 89bf510.

📒 Files selected for processing (32)
  • CLAUDE.md
  • crates/irl-core/examples/skew-relay.rs
  • crates/irl-core/src/audio_hold.rs
  • crates/irl-core/src/consts.rs
  • crates/irl-core/src/lib.rs
  • crates/irl-core/src/pacing.rs
  • crates/irl-core/src/playout.rs
  • crates/irl-core/src/timing.rs
  • crates/irl-core/src/video_delay.rs
  • crates/irl-core/src/video_time.rs
  • crates/irl-core/src/window.rs
  • crates/irl-source/src/audio/hold.rs
  • crates/irl-source/src/audio/mod.rs
  • crates/irl-source/src/audio/pump.rs
  • crates/irl-source/src/config.rs
  • crates/irl-source/src/receiver/audio_in.rs
  • crates/irl-source/src/receiver/decode.rs
  • crates/irl-source/src/receiver/stream.rs
  • crates/irl-source/src/shared.rs
  • crates/irl-source/src/source.rs
  • crates/irl-source/src/video/intake.rs
  • crates/irl-source/src/video/output.rs
  • crates/irl-source/src/video/thread.rs
  • crates/irl-source/tests/audio_core.rs
  • crates/irl-source/tests/av_sync_sim.rs
  • crates/irl-source/tests/common/mod.rs
  • crates/irl-source/tests/network_sim.rs
  • crates/irl-source/tests/video_pipeline.rs
  • docs/architecture.md
  • docs/audio-pipeline.md
  • docs/skew-testing.md
  • scripts/test-with-libobs-shim.sh
 __________________________
< Needle. Haystack. Found. >
 --------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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

Comment @coderabbitai help to get the list of available commands.

@datagutt
datagutt changed the base branch from refactor/lean to master October 2, 2026 11:40
@datagutt
datagutt merged commit 0cc826a into master Oct 2, 2026
3 of 4 checks passed
@datagutt
datagutt deleted the refactor/sync branch October 12, 2026 10:56
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