Files
LlamaCasty/HANDOFF.md
T
gramps 11a7af2dc0 fix: clear pre-live capture ring backlog at StartLive (2.2s A/V sync lag)
AudioMixer.Start() begins mic/loopback capture at app startup to feed the
level meters, so both 2s ring buffers fill with pre-live audio. StartLive()
drained from the oldest tail sample, putting every recorded event ~2.2s late
in the audio track (confirmed by clap analysis + cross-correlation on two
takes: +2.11 to +2.22s).

Fix: AudioMixer.StartLive() now runs _micBuffer.Clear() + _loopbackBuffer.Clear()
immediately after the pipe starts, before the drain task runs. Recording now
begins at go-live; the <=10ms in-flight chunk evicted by Clear() is imperceptible.

Regression test: StartLive_DiscardsPreLiveBacklog_SoFirstAudioIsCurrent
saturates the loopback ring with stale 0.8 pre-live audio, then asserts the
wire carries fresh post-live 0.2 (max < 0.3).

Reference (external, per derivative-work rule): OBS 'Audio mixer' keeps its
buffers fed continuously and syncs the stream start timestamp at record time
rather than replaying pre-live capture; a go-live flush of the capture buffer
is the accepted pattern for live tools restarting a stream.

Docs: HANDOFF.md (fix shipped), MyMistakes.md (A/V sync measurement recipe).
2026-09-14 11:24:12 -07:00

8.2 KiB
Raw Blame History

HANDOFF — 2026-09-14 (AUDIO−VIDEO SYNC FIX SHIPPED: StartLive now clears the pre-live ring backlog)

Branch / Commit State

main HEAD = 5ba4d70. Ahead of origin by 27 commits. Working tree dirty: the sync fix is implemented + tests green below, HANDOFF/MyMistakes updated — NOT yet committed. NOT pushing — the prior no-push ruling (transparency + audio-silence) is clear on the first gate; the second was the sync issue, whose fix is now in the tree but the user has not greenlit a push. On-board next: verify a take, then decide.

Heads-up for any reader: the previous HANDOFF (a62a283 era) described webcam/audio/ truncation fixes as uncommitted — that file was stale on arrival. They were actually already committed:

  • 724af14 fix: audio silence (idempotent AudioSyncDelay.Configure), webcam gray block (cbW/cbH default), truncated videos (SceneGraph split/static bake docs + FramePump probe)
  • 5ba4d70 docs: audio-sync offset measured ~367ms w/ 300 offset → "set SyncOffsetMs=0"

Build was green (0 warnings) and 295 tests passing at the prior session end; no code changed this session (analysis only).

✅ SHIPPED — pre-live capture ring backlog fix (2026-09-14)

Root cause: AudioMixer.Start() begins mic and loopback capture at app startup for the level meters. The 2-second ring buffers (_micBuffer = 96k floats = 2.0s mono; _loopbackBuffer = 192k floats = 2.0s stereo) fill continuously with pre-live audio. StartLive → LiveLoopAsync drained from the ring's tail — the oldest sample — so every recorded event landed ~2.0s late in the audio track (fixed lag, scaled with time-since-app-launch, capped at ring depth).

Fix: AudioMixer.StartLive() now runs _micBuffer.Clear(); _loopbackBuffer.Clear(); immediately after the pipe starts, before the drain task runs — the in-flight ≤10ms chunk loss is imperceptible and correct ("recording begins at go-live").

Regression test (the ONE integration test for this change): StartLive_DiscardsPreLiveBacklog_SoFirstAudioIsCurrent in ytLive.Tests/AudioPipelineTests.cs — saturates the loopback ring with stale 0.8 (simulated pre-live meters), StartLive, then emits fresh 0.2; asserts the wire carries ~0.2 (max < 0.3), failing loudly if the stale 0.8 backlog survived the clear.

Test collateral: the 3 existing pipe-emit tests pre-filled the rings BEFORE StartLive (relying on the bug as a reservoir). Their emits now land right after StartLive (post-clear, pre-connect — the pipe drops pre-connect writes, so this keeps the ring primed for the client) with the 2026-09-14 comments updated. All passed.

Verified: AudioPipelineTests 26/26 green. Room-native build 0 warnings. Full gate pending (verify.sh clean build + full suite + scope check).

Read/Write semantics confirmed (AudioRingBuffer.cs)

Thread-safe via _sync: Write evicts oldest when full; Read returns from tail = (_head - _count + C) % C — when full tail == _head, so every drained sample is one lap behind the last write. Clear() is locked; it is the right seam.

Root cause (confirmed two independent takes): AudioMixer.Start() begins mic and loopback capture at app startup for the level meters. The 2-second ring buffers (_micBuffer = 96k floats = 2.0s mono; _loopbackBuffer = 192k floats = 2.0s stereo) fill continuously with pre-live audio. StartLive → LiveLoopAsync drains from the ring's tail — the oldest sample, which is the moment-of-go-live sample minus the full ring depth — so every recorded event appears ~2.0s late in the audio track. The lag is fixed for the entire recording and scales linearly with "how long the app was open before you hit record", capped at the 2.0s ring depth (plus the configured sync offset plus ~0.1s encoder pipeline).

Read/Write semantics confirmed in AudioRingBuffer.cs (thread-safe via _sync): Write evicts oldest samples when full (_count == _buffer.Length); Read returns from tail = (_head - _count + C) % C — when full, tail == _head, meaning every drained sample is exactly one lap behind what was last written. Clear() exists and is locked; it is the right seam.

Only one Clear call exists in the capture path: RestartMic() (AudioMixer.cs:132) clears _micBuffer on device swap. Neither buffer is cleared at StartLive — this is the bug.

Taking: the lag also explains the earlier 13:58 take that measured only ~367ms with 300ms offset active: the app had just been restarted for that take, so the rings were only partially pre-filled (lag = min(2.0s, time-since-app-launch)). Two takes hours later (rings fully saturated) reproduce the full ~2.0s backlog consistently.


Take 1 (ty-20260912-1923-0000-2.mp4, talking + 3 claps):

  • Audio 17.25s, video 17.05s (1023 frames), no frame drops — NOT truncated.
  • Per-clap offsets: +2.16 / +2.18 / +2.15s (audio later). Cross-correlation peak: +133 frames (+2.217s), sharp and unique. Fixed offset, no drift.
  • DB: Audio.SyncOffsetMs = 300 (not 0 as the 5ba4d70 commit message claimed — the write never actually landed).

Take 2 (ty-20260914-1104-0000-2.mp4, clean clap test):

  • Audio 22.31s, video 22.10s (1326 frames), no frame drops.
  • Audio clap peak at 14.04s; video motion peak at 11.93s (yavg 2.75 vs background ~0.1–0.5). Offset: +2.11s — confirms the same mechanism.
  • Tiny pre-clap blips at 0.2/1.5/4.5/13.5s are faint artifacts (< 100 RMS); the 11617-RMS clap is unambiguous.

Theory of the "cut off at the end": audio events land ~2s late; when the video ends the audio track is still playing the final 2s of real-time content, giving the impression of a cut. The audio track's slightly longer duration (17.25 vs 17.05; 22.31 vs 22.10) is the tail of that delayed signal — real audio outlives the pump-stopped video.

Theories RULED OUT by data:

  • ❌ Frame truncation / slow full-render — video is real-time; frame counts match wall-clock (FramePump log: 301/300 per 5s, dropped 0, avg render 14ms).
  • ❌ Named-pipe-connect drop — would shorten audio (events earlier), opposite sign. Pipe connected near-instantly in both takes.
  • ❌ AudioSyncDelay alone — capped at 0..500ms; 300ms is a subset, not the whole.
  • ❌ Audio drift/resampler rate — offset is constant, not growing.

Open threads

  • Audio-silence verification — FIXED code (idempotent Configure, committed 724af14); the 19:23 take had real peakMix. Final user confirmation still pending.
  • Audio sync offset DB value — Audio.SyncOffsetMs = 300 (the "set to 0" write in 5ba4d70 never actually landed). Fix for the ~2s ring backlog is now SHIPPED (local, uncommitted). Next: re-record the clap pattern, run the cross-correlation recipe → expect residual ≈ 400ms (300ms configured sync + ~100ms pipeline); then optionally zero the slider to hit ~100ms.
  • Truncation with DYNAMIC scenes — proven mechanism + probe in place; static scenes full-length. 19:23 take confirms dynamic scenes hold ~60fps (no truncation) but with 30ms stall cadence.
  • Webcam MJPG missing, web capture ~10–14Hz, layer SortOrder not persisting — queued, unchanged.

Landmines

  • testhost shares startup.log — filter by time.
  • cmd.exe /c "taskkill /F /IM ytLive.exe" (WSL form double-slashes mangle) before rebuilds.
  • Build/tests: Windows dotnet host (/mnt/c/Program Files/dotnet/dotnet.exe). 0 warnings.
  • ffmpeg/ffprobe: /mnt/c/Program Files/Krita (x64)/bin/ffmpeg.exe with Windows paths.
  • Audio.SyncOffsetMs currently = 300 in the DB (contradicts the "set to 0" line in 5ba4d70; the write likely never happened). Non-zero offsets still depend on the idempotent Configure guard to avoid the silence bug.
  • MyMistakes.md → Recipes registry now has the audio/video sync measurement recipe (claps + cross-correlation) — grep before re-deriving.

Next step

Verify the shipped fix: re-record with the same clap pattern and run the cross-correlation recipe from MyMistakes.md → expect offset ≈ 400ms (300ms configured sync + ~100ms pipeline) with a SyncOffsetMs=300 DB value, or ~100ms with the slider zeroed. Then commit this session's work (StartLive clear + regression test + docs) and decide with the user whether the no-push ruling is lifted.