@@ -790,11 +790,23 @@ <h1>HTJ2K RTP Replay — Browser WASM Demo</h1>
790790let 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.
796795let 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?
799811let 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