Skip to content

SW path: fold PTS discontinuities on forward-only VOD, decouple audio decode-ahead from the video gate - #347

Open
tschuegy wants to merge 3 commits into
superuser404notfound:mainfrom
tschuegy:fix/sw-vod-fold-and-audio-lead
Open

SW path: fold PTS discontinuities on forward-only VOD, decouple audio decode-ahead from the video gate#347
tschuegy wants to merge 3 commits into
superuser404notfound:mainfrom
tschuegy:fix/sw-vod-fold-and-audio-lead

Conversation

@tschuegy

@tschuegy tschuegy commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Two SW-path weaknesses surfaced while chasing an IPTV catch-up bug on device (tvOS 26.6), both independent of the source that exposed them:

  1. The live PTS-discontinuity fold is gated isLive, and non-live sessions run unprotected. A forward-only VOD source (chunked timeshift archive) restarted its timestamps at PTS ~0 every ~85 s; FFmpeg's 33-bit wrap correction read the -2718 s jump as +92726 s, the renderer waited 25 hours for the frame's display time, and the video queue died with FigVideoQueueRemote -12080 - picture and sound frozen ~90 s into every session.
  2. The combined demux loop paces everything on the video renderer's ~10-frame queue, so interleaved audio can never build more than ~0.3 s of lead over the synchronizer clock (the master clock). Any decode/deinterlace jitter beyond that starves the audio renderer and the clock leaps; a timeline gap lets the free-running clock run away while the picture freezes. The DVR feeder arm has AudioLookaheadPolicy for exactly this; the combined loop had nothing.

Change

  • fix(sw) fold - the discontinuity fold gate widens to the new pure decision shouldFoldTimeline(isLive:sourceSeekable:): live folds as before, and a forward-only non-live source folds too (its timeline is served as-is and a non-seekable pb offers no seek-based recovery). Seekable VOD keeps its trusted container timeline untouched. Seams log per occurrence with a soft cap of 20 lines.
  • fix(sw) audio decoupling - video packets park in a bounded FIFO (drained at the renderer's pace) while audio keeps decoding up to AudioLookaheadPolicy.targetLeadSeconds ahead of the clock, and a genuine audio underrun pauses the clock for a rebuffer via the same policy the DVR feeder uses, with an everHadLead latch so exactly-realtime origins do not eat a spurious pause at session start. At a fold seam the parked pre-seam tail is decoded before the flush drops its reference chain. Live-without-ring sessions keep the historical lockstep pacing.
  • diag(sw) - the SW path gains a 1 Hz [SWDiag] line (clock + delta, decoded-audio lead, parked depth, rebuffer state, the display layer's own drop counter, accumulated render delay with per-second delta, queue-target status, surface state, isReadyForDisplay). The native path has LagDiag; SW sessions only had the 30 s memprobe, which is too coarse to see the clock leaps and layer-drop bursts a stuttering session is made of. It also names a headless-harness artifact: a never-bound layer (surf=DETACHED) drops ~8 frames/s that a bound one does not.

Verification

  • SWTimelineFoldTests pins the fold decision; full suite green (1632 tests).
  • Device: with the fold active, the chunked archive's seams fold cleanly instead of freezing into -12080; [SWDiag] on a healthy session reads dclk=1.00, aLead 3-5 s sawtooth, layerDrop flat, delay=0.00.
  • On an M-series Mac via aetherctl play: clock exact across 5+ minutes, no rebuffer churn on fast origins, rebuffer pause/resume behaves on origins that deliver below realtime.

🤖 Generated with Claude Code

tschuegy and others added 3 commits August 9, 2026 13:43
A sequential-origin timeshift archive is a chunked recording whose every
chunk restarts at PTS ~0 (device trace: an 89 s chunk ending at raw
2717.9 s, the next chunk opening at 0.04 s, with 44-76 junk bytes between
chunks). FFmpeg's 33-bit wrap correction reads that backward jump as
+92726 s, the renderer then waits 25 h for the frame's display time, and
the video queue dies with -12080 - picture and sound freeze about 90 s in.

The SW demux loop already folds exactly this shape for live sessions
(accrue jumped-minus-expected into an offset, flush decoders at the seam);
it was just gated on isLive. The gate widens to the new pure decision
shouldFoldTimeline(isLive:sourceSeekable:): live folds as before, and a
forward-only non-live source folds too, because its timeline is served
as-is and a non-seekable pb offers no seek-based recovery. Seekable VOD
keeps its trusted container timeline untouched. Seams now log per
occurrence (chunked archives seam every minute or two) with a soft cap of
20 lines so a pathological source cannot flood the log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…[SWDiag]

The combined demux loop paced everything on the video renderer's
~10-frame queue, so interleaved audio could never build more than ~0.3 s
of lead over the synchronizer clock - any decode/deinterlace jitter
beyond that starved the audio renderer and the master clock leapt, and a
timeline gap let the free-running clock run away while the picture
froze. Video packets now park in a bounded FIFO (drained at the
renderer's pace) while audio keeps decoding up to
AudioLookaheadPolicy.targetLeadSeconds ahead, and a genuine audio
underrun pauses the clock for a rebuffer via the same policy the DVR
feeder arm uses, with an everHadLead latch so exactly-realtime origins
do not eat a spurious pause at session start. At a fold seam the parked
pre-seam tail is decoded before the flush drops its reference chain.
Live-without-ring sessions keep the historical lockstep pacing.

The SW path also gains a 1 Hz [SWDiag] line (clock + delta, audio lead,
parked depth, rebuffer state, and the display layer's own drop counter
with per-second delta) - the native path has LagDiag, while SW sessions
only had the 30 s memprobe, which is too coarse to see the clock leaps
and layer-drop bursts a stuttering session is made of.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…state in [SWDiag]

A replay session on tvOS 26.6 shows every existing counter clean (dclk
1.00, aLead 3-5 s, enq +50/s, layerDrop flat) while the picture visibly
leaps forward every few seconds and the session eventually dies with
FigVideoQueueRemote -12080 - the documented shape of "the display layer
stops rendering (audio keeps going)". A layer that stalls and catches up
displays frames LATE without dropping them, so the only place the stall
can register is totalAccumulatedFrameDelay. The 1 Hz [SWDiag] line now
carries that delay (with per-second delta), the corrupted-frame count,
the queue-target status, the layer's superlayer/bounds state, and
isReadyForDisplay, so the next device run pins whether the stall is
queue-side, surface-side, or neither. Also explains the headless
aetherctl artifact: a never-bound layer (surf=DETACHED) drops ~8
frames/s that a bound one does not.

Co-Authored-By: Claude Fable 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

None yet

Development

Successfully merging this pull request may close these issues.

1 participant