feat: signed A/V sync offset (−500..+500), negative advances by eating live stream head (OBS eat-head semantics)

Positive offsets still delay the whole mix via the delay line (lip-sync fix);
negative offsets now ARM once at StartLive and drop |N| ms off the pipe's write
head so audio events land earlier when audio runs BEHIND video. Slider relabeled
AUDIO SYNC, Min −500, locked while live/recording (IsEditMode). LayoutStore and
VM clamp to −500..500.

OBS reference for eat-the-head negative sync: https://obsproject.com/kb/obs-studio/buffering-time (negative sync values pull audio earlier by discarding buffered player audio).

Test: StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead (6x0.9 head
must be eaten before 0.2 bed reaches the wire).
This commit is contained in:
2026-09-14 12:30:44 -07:00
parent 11a7af2dc0
commit b22d08eca6
12 changed files with 219 additions and 144 deletions
+22
View File
@@ -495,3 +495,25 @@ stamp BEFORE theorizing. (Also this session: a sentinel-byte test where the sour
GENERATE the sentinel, and a fixed-point bilinear that shifted BOTH stages and silently drew nothing —
see the rawvideo recipe.)
## Signed A/V sync: negative offset = eat the buffer head (OBS semantics), armed at go-live
(2026-09-14) The ring-backlog fix shrank A/V lag from ~2.2s to ~0.54s, and ~300ms of that was
baked into the DB (`Audio.SyncOffsetMs=300`) via a POSITIVE-only control. For audio running BEHIND
video you cannot push audio later — positive delay makes it worse. OBS's answer is a NEGATIVE offset
that eats the buffer head: drop the first |N| ms of the written stream so every audio event lands
|N| ms EARLIER relative to video. It only makes sense at the head of the stream, so it is armed once
at `StartLive` (positive stays live-reactive via the delay line). Rule: record the signed semantics
together — positive = delay (ahead), negative = eat-head advance (behind) — and lock the control
while live (`IsEditMode`) since a mid-stream advance flip is meaningless. Test recipe:
`StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead` (emit 6×0.9 into an 8-tick budget,
long 0.2 bed, wire must show only 0.2).
## A pipe-read test that samples one tick is a timing flake by construction
(2026-09-14, re-discovered) `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` fails
sporadically in ISOLATION (3/3 on the clean tree) but passes in the full suite: it reads exactly one
tick's worth after muting, and if the pipe still carries pre-mute buffered bytes the read spikes at
0.4 instead of silence. Any caller of `ReadFullyAsync` on a live pipe must either close/drain the
pipe first or assert on a multi-tick window. Do not blame a sync change for this — verify the grain
against the clean tree in the same mode before touching the mixer.