Skip to content

Commit e93cdf1

Browse files
osamu620claude
andcommitted
fix(wasm): adaptive pace-drop + event-driven backpressure
When decode throughput is below source rate (e.g. 25 fps decode vs 30 fps source), the fixed PACE_DROP_PERIODS threshold caused burst-drops after long smooth segments followed by a timeline rebase — producing a visible sawtooth in paced+drop mode. Two changes fix this: 1. Adaptive threshold (Option 2): when decode EMA exceeds source period by >5%, lower the drop threshold from 8× to 2× frame period. Drops fire earlier with less accumulated lateness. Per-drop, wallStart advances by (lateness - period) to absorb the drift and prevent cascade drops. Net effect: uniform 1-drop-every-N-frames cadence instead of burst-then-rebase. 2. Event-driven backpressure (Option 4): replace the 8 ms setTimeout polling in the feed-loop gate with a Promise resolved by the rAF display loop when it consumes a frame. Eliminates 0–8 ms random wake jitter in decode dispatch. The backpressure condition now counts pace-drops as consumed (frameCount + droppedByPace) so frequent adaptive drops don't falsely stall the feed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9fd3bde commit e93cdf1

1 file changed

Lines changed: 41 additions & 20 deletions

File tree

web/rtp_demo.html

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -790,11 +790,23 @@ <h1>HTJ2K RTP Replay — Browser WASM Demo</h1>
790790
let framePeriodMs = 0; // derived from inter-frame RTP Δts; 0 before second frame arrives
791791
// Paced-mode backpressure counter: incremented for each marker packet pushed
792792
// to the worker; throttle holds the for-await loop while
793-
// (pacedFramesPushed - frameCount) >= RING_CAPACITY so the worker can't
794-
// buffer more in-flight frames than the rAF consumer can drain. Reset per
795-
// playback in startPlayback().
793+
// (pacedFramesPushed - (frameCount + droppedByPace)) >= RING_CAPACITY so the
794+
// worker can't buffer more in-flight frames than the consumer can drain.
796795
let pacedFramesPushed = 0;
797796

797+
// Event-driven backpressure: the feed loop awaits this instead of polling.
798+
// Resolved by the rAF tick or onFrameFromWorker when a frame is consumed.
799+
let _bpResolve = null;
800+
function notifyBackpressure() {
801+
if (_bpResolve) { const r = _bpResolve; _bpResolve = null; r(); }
802+
}
803+
function waitBackpressure() {
804+
return new Promise(resolve => {
805+
_bpResolve = resolve;
806+
setTimeout(resolve, 64);
807+
});
808+
}
809+
798810
// Has the one-shot per-playback diagnostic dump been printed?
799811
let firstFrameDiagnosticsDumped = false;
800812

@@ -1533,6 +1545,7 @@ <h1>HTJ2K RTP Replay — Browser WASM Demo</h1>
15331545
if (displayedPreRoll < PRE_ROLL_FRAMES) {
15341546
renderEntry(ringPop());
15351547
displayedPreRoll++;
1548+
notifyBackpressure();
15361549
if (!firstFrameDrawn) { setOverlay(null); firstFrameDrawn = true; }
15371550
continue; // drain pre-roll frames if available
15381551
}
@@ -1547,14 +1560,26 @@ <h1>HTJ2K RTP Replay — Browser WASM Demo</h1>
15471560
const target = wallStart + totalPausedMs + dtMs;
15481561
const period = entry.framePeriodMs || (1000 / 60);
15491562

1550-
// Pace-drop: too far behind? Discard and re-check next entry.
1551-
// If the ring drains completely, reset the anchor so the next
1552-
// decoded frame starts a fresh timeline instead of cascading
1553-
// into further drops that stall playback.
1554-
if (paceDropEnabled() && rafTimestamp - target > PACE_DROP_PERIODS * period) {
1563+
// Adaptive pace-drop: when decode throughput is below source rate,
1564+
// lower the threshold so drops fire earlier (smaller lateness jumps,
1565+
// more uniform cadence). Per-drop timeline advance prevents cascade.
1566+
const decodeAvg = lastDecodeTimes.length >= 5
1567+
? lastDecodeTimes.reduce((a, b) => a + b, 0) / lastDecodeTimes.length : 0;
1568+
const cantKeepUp = decodeAvg > 0 && period > 0 && decodeAvg > period * 1.05;
1569+
const dropThresholdMs = cantKeepUp ? 2 * period : PACE_DROP_PERIODS * period;
1570+
1571+
if (paceDropEnabled() && rafTimestamp - target > dropThresholdMs) {
15551572
ringPop();
15561573
droppedByPace++;
1574+
// Advance the timeline to absorb the lateness, leaving one period
1575+
// of margin. This prevents the next frame from also exceeding the
1576+
// threshold (cascade) and distributes drops evenly over time.
1577+
const behindMs = rafTimestamp - target;
1578+
if (behindMs > period) {
1579+
wallStart += (behindMs - period);
1580+
}
15571581
if (_ringCount === 0) { wallStart = 0; totalPausedMs = 0; }
1582+
notifyBackpressure();
15581583
continue;
15591584
}
15601585

@@ -1565,6 +1590,7 @@ <h1>HTJ2K RTP Replay — Browser WASM Demo</h1>
15651590
if (rafTimestamp < target - 0.5) break;
15661591

15671592
renderEntry(ringPop());
1593+
notifyBackpressure();
15681594
if (!firstFrameDrawn) { setOverlay(null); firstFrameDrawn = true; }
15691595
break; // one frame per VSync after pre-roll
15701596
}
@@ -1678,6 +1704,7 @@ <h1>HTJ2K RTP Replay — Browser WASM Demo</h1>
16781704
droppedByPace = 0;
16791705
framePeriodMs = 0;
16801706
pacedFramesPushed = 0;
1707+
_bpResolve = null;
16811708
workerStats = { framesEmitted: 0, framesDropped: 0, seqGaps: 0, readyCount: 0, lastError: '' };
16821709
// rAF-path state (see module-scope declarations).
16831710
decodeCount = 0;
@@ -1766,22 +1793,16 @@ <h1>HTJ2K RTP Replay — Browser WASM Demo</h1>
17661793
}
17671794

17681795
// Paced-mode backpressure: cap total pipeline depth (markers pushed
1769-
// minus frames rendered) at RING_CAPACITY. Gating on the ring alone
1770-
// is insufficient because by the time the ring fills (~½ s in), main
1771-
// has already shovelled dozens of frames' worth of markers into the
1772-
// worker's postMessage queue; the decoder churns through that backlog
1773-
// at decoder rate, ringPush keeps overflowing on drop-oldest, the
1774-
// ring ends up holding only frames whose RTP-target is far in the
1775-
// future, target advances faster than wall time and playback freezes
1776-
// after pre-roll. Throttling on (pushed - rendered) prevents the
1777-
// worker from ever buffering more than RING_CAPACITY frames worth of
1778-
// markers, so the decoder rate-matches the consumer end to end.
1796+
// minus frames consumed) at RING_CAPACITY. "Consumed" = rendered +
1797+
// pace-dropped — both free a slot for new decode work. Awaits an
1798+
// event-driven notification from the rAF display loop instead of
1799+
// polling, eliminating the 0–8 ms random wake delay.
17791800
// ASAP mode skips the gate (user opted in to "as fast as possible").
17801801
if (pacing === 'paced' && pkt.marker) {
1781-
while (pacedFramesPushed - frameCount >= RING_CAPACITY) {
1802+
while (pacedFramesPushed - (frameCount + droppedByPace) >= RING_CAPACITY) {
17821803
if (playbackGen !== myGen || !running) break;
17831804
if (paused) { await waitIfPaused(); continue; }
1784-
await new Promise(r => setTimeout(r, 8)); // ~½ vsync at 60 Hz
1805+
await waitBackpressure();
17851806
}
17861807
if (playbackGen !== myGen || !running) break;
17871808
pacedFramesPushed++;

0 commit comments

Comments
 (0)