@openmoq/playback 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE.txt +202 -0
  2. package/README.md +18 -2
  3. package/dist/adaptive-tolerance.d.ts +128 -0
  4. package/dist/adaptive-tolerance.d.ts.map +1 -0
  5. package/dist/adaptive-tolerance.js +339 -0
  6. package/dist/adaptive-tolerance.js.map +1 -0
  7. package/dist/bandwidth-estimator.d.ts +49 -0
  8. package/dist/bandwidth-estimator.d.ts.map +1 -0
  9. package/dist/bandwidth-estimator.js +77 -0
  10. package/dist/bandwidth-estimator.js.map +1 -0
  11. package/dist/browser/index.global.js +2813 -0
  12. package/dist/browser/index.global.js.map +1 -0
  13. package/dist/browser/index.js +2788 -0
  14. package/dist/browser/index.js.map +1 -0
  15. package/dist/buffer-based-controller.d.ts +87 -0
  16. package/dist/buffer-based-controller.d.ts.map +1 -0
  17. package/dist/buffer-based-controller.js +85 -0
  18. package/dist/buffer-based-controller.js.map +1 -0
  19. package/dist/decoder-state.d.ts +103 -0
  20. package/dist/decoder-state.d.ts.map +1 -0
  21. package/dist/decoder-state.js +145 -0
  22. package/dist/decoder-state.js.map +1 -0
  23. package/dist/gap-detector.d.ts +83 -0
  24. package/dist/gap-detector.d.ts.map +1 -0
  25. package/dist/gap-detector.js +129 -0
  26. package/dist/gap-detector.js.map +1 -0
  27. package/dist/index.d.ts +33 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +33 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/jitter-buffer.d.ts +73 -0
  32. package/dist/jitter-buffer.d.ts.map +1 -0
  33. package/dist/jitter-buffer.js +216 -0
  34. package/dist/jitter-buffer.js.map +1 -0
  35. package/dist/keyframe-validator.d.ts +26 -0
  36. package/dist/keyframe-validator.d.ts.map +1 -0
  37. package/dist/keyframe-validator.js +170 -0
  38. package/dist/keyframe-validator.js.map +1 -0
  39. package/dist/pipeline.d.ts +215 -0
  40. package/dist/pipeline.d.ts.map +1 -0
  41. package/dist/pipeline.js +852 -0
  42. package/dist/pipeline.js.map +1 -0
  43. package/dist/recovery.d.ts +166 -0
  44. package/dist/recovery.d.ts.map +1 -0
  45. package/dist/recovery.js +176 -0
  46. package/dist/recovery.js.map +1 -0
  47. package/dist/render-scheduler.d.ts +50 -0
  48. package/dist/render-scheduler.d.ts.map +1 -0
  49. package/dist/render-scheduler.js +84 -0
  50. package/dist/render-scheduler.js.map +1 -0
  51. package/dist/startup-buffer.d.ts +68 -0
  52. package/dist/startup-buffer.d.ts.map +1 -0
  53. package/dist/startup-buffer.js +107 -0
  54. package/dist/startup-buffer.js.map +1 -0
  55. package/dist/sync.d.ts +234 -0
  56. package/dist/sync.d.ts.map +1 -0
  57. package/dist/sync.js +343 -0
  58. package/dist/sync.js.map +1 -0
  59. package/dist/types.d.ts +229 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +12 -0
  62. package/dist/types.js.map +1 -0
  63. package/package.json +56 -3
@@ -0,0 +1,852 @@
1
+ /**
2
+ * Playback pipeline orchestrator.
3
+ *
4
+ * Wires jitter-buffer + gap-detector + decoder-state + sync together.
5
+ * Accepts MoqtObject + LocHeaders, emits DecoderCommand + PlaybackEvent.
6
+ *
7
+ * Sans-I/O: no WebCodecs, no Canvas, no AudioContext — just pure logic.
8
+ * The browser adapter consumes the emitted DecoderCommands.
9
+ *
10
+ * @see draft-ietf-moq-loc-01 §4.2 (decode order)
11
+ * @see draft-ietf-moq-transport-16 §10.2.1.1 (Object Status)
12
+ * @module
13
+ */
14
+ import { ObjectStatus } from '@openmoq/transport';
15
+ import { toVideoChunkInit, toAudioChunkInit } from '@openmoq/loc';
16
+ import { isKeyframePayload } from './keyframe-validator.js';
17
+ import { JitterBuffer } from './jitter-buffer.js';
18
+ import { GapDetector, GapAction } from './gap-detector.js';
19
+ import { DecoderStateMachine } from './decoder-state.js';
20
+ import { SyncController } from './sync.js';
21
+ import { AdaptiveToleranceController, DEFAULT_TOLERANCE_CONFIG } from './adaptive-tolerance.js';
22
+ /**
23
+ * Playback pipeline orchestrator.
24
+ *
25
+ * @see draft-ietf-moq-loc-01 §4.2
26
+ */
27
+ export class PlaybackPipeline {
28
+ mediaType;
29
+ clock;
30
+ sync;
31
+ onCommand;
32
+ onEvent;
33
+ recovery;
34
+ buffer;
35
+ gapDetector;
36
+ decoderState;
37
+ /** Headers stored alongside objects, keyed by "groupId:objectId". */
38
+ headerMap = new Map();
39
+ /** Group reference counts in the jitter buffer. */
40
+ bufferedGroupCounts = new Map();
41
+ /** Next expected objectId within the current group for contiguity check. */
42
+ nextExpectedObjectId = 0n;
43
+ /** Highest group from which at least one object was consumed. -1n = nothing consumed yet. */
44
+ lastConsumedGroupId = -1n;
45
+ /** Groups for which END_OF_GROUP has been observed. */
46
+ endedGroups = new Set();
47
+ /** Timestamp when we first started waiting for active group completion. */
48
+ activeGroupWaitStartUs = null;
49
+ /**
50
+ * Minimum group ID accepted by pushObject().
51
+ * Set after SKIP_FORWARD to reject stale objects from pre-skip groups
52
+ * that arrive late due to async QUIC stream delivery.
53
+ */
54
+ minAcceptGroupId = -1n;
55
+ /** Last group for which keyframe_waiting was emitted (dedup). */
56
+ lastKeyframeWaitingGroupId = -1n;
57
+ /**
58
+ * When true, the first tick syncs to whatever group is available
59
+ * without gap detection — avoids cascading recovery on initial
60
+ * live join or after pause→resume on a live stream.
61
+ *
62
+ * Starts `true` so the first arriving group is accepted as the
63
+ * starting point (joining mid-broadcast is not a gap).
64
+ */
65
+ resetPending = true;
66
+ /** True once track_ended has been emitted — prevents re-emission on every tick. */
67
+ _trackEnded = false;
68
+ /** Last emitted playback rate — dedup to avoid spamming commands. */
69
+ _lastEmittedRate = 1.0;
70
+ /**
71
+ * Throttle flag — set by decoder feedback when queue depth is high.
72
+ * While true, tick() evaluates gaps but does NOT drain the buffer.
73
+ * Objects still enter the jitter buffer (needed for gap detection).
74
+ */
75
+ _throttled = false;
76
+ /** Adaptive jitter tolerance controller (null if disabled). */
77
+ adaptiveTolerance;
78
+ /** Max objects to release per tick (video only). 0 = unlimited. */
79
+ maxReleasePerTick;
80
+ /** Max groups before shedding old ones. 0 = unlimited. */
81
+ maxBacklogGroups;
82
+ /** Video codec string from catalog (e.g., 'avc1.42c01f'). Used for keyframe validation. */
83
+ _videoCodec;
84
+ _videoOnly;
85
+ constructor(opts) {
86
+ this.mediaType = opts.mediaType;
87
+ this.clock = opts.clock;
88
+ this.sync = opts.sync;
89
+ this.onCommand = opts.onCommand;
90
+ this.onEvent = opts.onEvent;
91
+ this.recovery = opts.recovery;
92
+ this._videoOnly = opts.videoOnly ?? false;
93
+ // Bounded release: live video defaults to 5 objects/tick + 3 max
94
+ // backlog groups. VOD/non-live and audio are unlimited — shedding
95
+ // old groups is wrong when you need the full timeline.
96
+ const liveVideo = opts.mediaType === 'video' && (opts.isLive ?? true);
97
+ this.maxReleasePerTick = liveVideo
98
+ ? (opts.config.maxReleasePerTick ?? 5)
99
+ : 0;
100
+ this.maxBacklogGroups = liveVideo
101
+ ? (opts.config.maxBacklogGroups ?? 3)
102
+ : 0;
103
+ this.buffer = new JitterBuffer(opts.config.maxBufferDepth);
104
+ this.gapDetector = new GapDetector({
105
+ gapTimeoutUs: opts.config.gapTimeoutUs,
106
+ clock: opts.clock,
107
+ });
108
+ this.decoderState = new DecoderStateMachine(opts.mediaType);
109
+ // Adaptive tolerance: auto-calibrate gap timeout and drift threshold
110
+ this.adaptiveTolerance = opts.config.adaptiveTolerance
111
+ ? new AdaptiveToleranceController(DEFAULT_TOLERANCE_CONFIG)
112
+ : null;
113
+ // Startup buffer not used — video join re-anchor handles startup timing.
114
+ }
115
+ /**
116
+ * Set the video codec string for keyframe payload validation.
117
+ *
118
+ * @param codec Codec string from catalog (e.g., 'avc1.42c01f', 'hvc1', 'av01.0.08M.10')
119
+ * @see draft-ietf-moq-loc-01 §4.2 (decode order)
120
+ */
121
+ setCodec(codec) {
122
+ this._videoCodec = codec;
123
+ }
124
+ /**
125
+ * Current effective gap timeout in microseconds.
126
+ * When adaptive tolerance is enabled, this is auto-calibrated from jitter.
127
+ * Otherwise, returns the fixed configured value.
128
+ */
129
+ get effectiveGapTimeoutUs() {
130
+ if (this.adaptiveTolerance) {
131
+ return this.adaptiveTolerance.effectiveGapTimeoutMs * 1000;
132
+ }
133
+ return this.gapDetector.gapTimeoutUs;
134
+ }
135
+ /** Last consumed group ID — for track switch subscription filters. */
136
+ get currentGroupId() {
137
+ return this.lastConsumedGroupId;
138
+ }
139
+ /** Number of distinct groups currently in the jitter buffer. */
140
+ get bufferedGroupCount() {
141
+ return this.bufferedGroupCounts.size;
142
+ }
143
+ /** Whether draining is throttled due to decoder backpressure. */
144
+ get throttled() {
145
+ return this._throttled;
146
+ }
147
+ /**
148
+ * Handle feedback from browser adapters (decoder queue, errors, drift).
149
+ *
150
+ * @see draft-ietf-moq-loc-01 §2.3.1.1 (drift detection)
151
+ */
152
+ handleFeedback(fb) {
153
+ switch (fb.type) {
154
+ case 'queue_pressure':
155
+ this._throttled = fb.depth >= fb.maxRecommended;
156
+ break;
157
+ case 'decode_error':
158
+ // Clear throttle — the decoder has likely been recreated with an
159
+ // empty queue. Without this, the pipeline deadlocks: throttled →
160
+ // no decode commands → no decoder output → no queue_pressure_low
161
+ // feedback → throttled forever.
162
+ this._throttled = false;
163
+ // Reset decoder FSM — the browser adapter has recreated the
164
+ // WebCodecs decoder with an empty queue. Video must wait for
165
+ // the next keyframe; audio resumes immediately (all key).
166
+ // Without this, the FSM stays in DECODING and feeds delta
167
+ // frames to a fresh decoder with no keyframe context → silent
168
+ // failures → permanent stall.
169
+ {
170
+ const decision = this.decoderState.notifyGap();
171
+ this.emitFsmDecision(decision);
172
+ }
173
+ if (this.recovery) {
174
+ const action = this.recovery.evaluate({ type: 'decode_error', message: fb.message });
175
+ this.onEvent({ type: 'recovery', action });
176
+ }
177
+ break;
178
+ case 'frame_rendered':
179
+ // PRESENTATION SCHEDULE drift: actual minus the exact time this
180
+ // frame was queued to present. Both are on the SAME local clock,
181
+ // so the old PTS-era guards no longer apply and are deliberately
182
+ // gone: a capture timestamp is not required, an active video-join
183
+ // offset is already represented in the schedule, and there is no
184
+ // cross-epoch case to hide.
185
+ //
186
+ // Without a schedule the whole measurement/event transaction is
187
+ // suppressed — returning BEFORE reading drift, so a stale value
188
+ // from an earlier frame cannot be re-emitted as a new event.
189
+ if (fb.scheduledRenderUs !== undefined) {
190
+ this.sync.reportPresentationTiming(fb.scheduledRenderUs, fb.actualRenderUs);
191
+ const drift = this.sync.currentDriftUs;
192
+ if (this.sync.presentationDriftExceeded) {
193
+ this.onEvent({ type: 'sync_drift', driftUs: drift });
194
+ }
195
+ }
196
+ break;
197
+ case 'flush_complete':
198
+ // No-op — included for forward compatibility (quality switch gating).
199
+ break;
200
+ }
201
+ }
202
+ /**
203
+ * Provide codec configuration (from catalog or initial headers).
204
+ *
205
+ * This is also triggered automatically when a LOC object carries the
206
+ * matching config property: Video Config for video, Audio Config for
207
+ * audio.
208
+ *
209
+ * @param config Codec-specific configuration bytes
210
+ */
211
+ configure(config) {
212
+ const decision = this.decoderState.configure(config);
213
+ if (decision.action === 'configure') {
214
+ this.onCommand({
215
+ type: 'configure',
216
+ mediaType: this.mediaType,
217
+ config,
218
+ });
219
+ }
220
+ }
221
+ /**
222
+ * Push an object into the pipeline.
223
+ *
224
+ * Data objects are buffered. Gap objects notify the gap detector.
225
+ *
226
+ * @param obj The MoqtObject (data or gap)
227
+ * @param headers Parsed LocHeaders (only meaningful for data objects)
228
+ */
229
+ pushObject(obj, headers) {
230
+ if (obj.kind === 'gap') {
231
+ // Gap signals — notify gap detector
232
+ if (obj.status === ObjectStatus.END_OF_GROUP) {
233
+ this.gapDetector.observeEndOfGroup(obj.groupId);
234
+ this.endedGroups.add(obj.groupId);
235
+ }
236
+ else if (obj.status === ObjectStatus.END_OF_TRACK) {
237
+ this.gapDetector.observeEndOfTrack();
238
+ }
239
+ return;
240
+ }
241
+ // Reject stale objects: both from before skip/reset targets AND
242
+ // from groups already consumed during normal forward progress.
243
+ // Late-arriving old groups from QUIC reordering can poison decode
244
+ // order and cause blockiness.
245
+ if (obj.groupId < this.minAcceptGroupId)
246
+ return;
247
+ if (this.lastConsumedGroupId >= 0n && obj.groupId < this.lastConsumedGroupId)
248
+ return;
249
+ // Reject already-consumed objects from the current group.
250
+ if (obj.groupId === this.lastConsumedGroupId && obj.objectId < this.nextExpectedObjectId)
251
+ return;
252
+ // Dedup: reject if this (groupId, objectId) is already buffered.
253
+ const key = `${obj.groupId}:${obj.objectId}`;
254
+ if (this.headerMap.has(key))
255
+ return;
256
+ // Data object — try to buffer it
257
+ if (this.buffer.insert(obj)) {
258
+ this.headerMap.set(key, headers ?? {});
259
+ const count = this.bufferedGroupCounts.get(obj.groupId) ?? 0;
260
+ this.bufferedGroupCounts.set(obj.groupId, count + 1);
261
+ }
262
+ else if (obj.publisherPriority !== undefined) {
263
+ // Buffer full — try priority-aware eviction (§7)
264
+ if (!this.tryEvictAndInsert(obj, headers) && this.recovery) {
265
+ this.onEvent({
266
+ type: 'recovery',
267
+ action: this.recovery.evaluate({ type: 'buffer_overflow' }),
268
+ });
269
+ }
270
+ }
271
+ else if (this.recovery) {
272
+ // No priority info, can't evict — notify recovery
273
+ this.onEvent({
274
+ type: 'recovery',
275
+ action: this.recovery.evaluate({ type: 'buffer_overflow' }),
276
+ });
277
+ }
278
+ // Interarrival jitter uses transit differences, so the timestamp epoch
279
+ // cancels out. Media time is suitable here, but not for latency/catch-up.
280
+ if (this.adaptiveTolerance && headers?.captureTimestamp !== undefined) {
281
+ const nowMs = this.clock.now() / 1000; // µs → ms
282
+ const captureUs = Number(headers.captureTimestamp);
283
+ this.adaptiveTolerance.onFrameArrived(nowMs, captureUs, nowMs);
284
+ }
285
+ // Observe group for gap timeout tracking
286
+ this.gapDetector.observeGroup(obj.groupId);
287
+ }
288
+ /**
289
+ * Reset pipeline state for pause→resume or seek.
290
+ *
291
+ * Clears the jitter buffer, gap detector, and decoder FSM so the first
292
+ * arriving group after resume is accepted cleanly without cascading
293
+ * gap-recovery escalation from the stale pre-pause position.
294
+ *
295
+ * Emits a decoder 'reset' command so the WebCodecs decoder clears its
296
+ * queue and waits for the next keyframe (video) or resumes immediately
297
+ * (audio — Opus/AAC-LC frames are independently decodable).
298
+ *
299
+ * @param targetGroupId When seeking, set this to the target group ID.
300
+ * Objects from groups before this ID are rejected — prevents stale
301
+ * in-flight objects from corrupting the post-seek decoder state.
302
+ * @see draft-ietf-moq-transport-16 §9.11.1 — "it might still receive
303
+ * Objects outside the new range if the publisher sent them before the
304
+ * update was processed."
305
+ */
306
+ /**
307
+ * Reset for a track switch — clears jitter buffer (old-track objects
308
+ * use the same groupIds as new-track per altGroup alignment), resets
309
+ * gap detector and group tracking. Does NOT reset decoder state or
310
+ * emit flush/reset commands — already-decoded frames in the renderer
311
+ * queue play out naturally for seamless visual transition.
312
+ *
313
+ * @param firstGroupId The first group ID from the new track — sets
314
+ * lastConsumedGroupId to firstGroupId-1 so the gap detector doesn't
315
+ * see a gap and trigger skip_forward (which would reset sync).
316
+ */
317
+ resetForTrackSwitch(firstGroupId) {
318
+ // Clear the jitter buffer — old-track encoded objects must not
319
+ // survive into the new decoder configuration. Without this,
320
+ // stale P-frames from the old track drain under the new codec
321
+ // and produce decode errors.
322
+ this.buffer.clear();
323
+ this.headerMap.clear();
324
+ this.bufferedGroupCounts.clear();
325
+ this.lastConsumedGroupId = firstGroupId - 1n;
326
+ this.endedGroups.add(this.lastConsumedGroupId);
327
+ this.nextExpectedObjectId = 0n;
328
+ this.minAcceptGroupId = firstGroupId;
329
+ this.lastKeyframeWaitingGroupId = -1n;
330
+ this._throttled = false;
331
+ this.activeGroupWaitStartUs = null;
332
+ this.gapDetector.reset();
333
+ // Force decoder back to NEEDS_KEYFRAME so partial-group deltas
334
+ // from a mid-stream subscription are skipped, not decoded.
335
+ const decision = this.decoderState.notifyGap();
336
+ this.emitFsmDecision(decision);
337
+ }
338
+ reset(targetGroupId) {
339
+ this.buffer.clear();
340
+ this.headerMap.clear();
341
+ this.bufferedGroupCounts.clear();
342
+ this.lastConsumedGroupId = -1n;
343
+ this.nextExpectedObjectId = 0n;
344
+ this.minAcceptGroupId = targetGroupId ?? -1n;
345
+ this.lastKeyframeWaitingGroupId = -1n;
346
+ this.resetPending = true;
347
+ this._throttled = false;
348
+ this._trackEnded = false;
349
+ this.endedGroups.clear();
350
+ this.activeGroupWaitStartUs = null;
351
+ this.gapDetector.reset();
352
+ this.adaptiveTolerance?.reset();
353
+ const decision = this.decoderState.notifyGap();
354
+ this.emitFsmDecision(decision);
355
+ }
356
+ /**
357
+ * Process buffered objects: evaluate gaps, feed decoder FSM, emit commands.
358
+ *
359
+ * Call this on a regular interval (e.g., requestAnimationFrame or timer).
360
+ */
361
+ tick() {
362
+ // Update adaptive tolerance → push auto-calibrated thresholds to gap detector and sync
363
+ if (this.adaptiveTolerance) {
364
+ const nowMs = this.clock.now() / 1000;
365
+ this.adaptiveTolerance.tick(nowMs);
366
+ this.gapDetector.gapTimeoutUs = this.adaptiveTolerance.effectiveGapTimeoutMs * 1000;
367
+ this.sync.driftThresholdUs = this.adaptiveTolerance.effectiveDriftThresholdMs * 1000;
368
+ }
369
+ // After reset (pause→resume, jump-to-live), sync up to the first
370
+ // group that starts with a keyframe (objectId 0). Stale P-frames
371
+ // from the old subscription that arrive after the reset must not
372
+ // become the new starting point — they lack keyframe context and
373
+ // would produce artifacts.
374
+ if (this.resetPending && this.buffer.size > 0) {
375
+ const top = this.buffer.peek();
376
+ // Only accept a group that starts with objectId 0 (keyframe).
377
+ // Stale mid-group objects from the old subscription are
378
+ // discarded by extracting + dropping them.
379
+ if (top.objectId !== 0n) {
380
+ this.buffer.extract();
381
+ this.decrementGroupCount(top.groupId);
382
+ return; // wait for a keyframe-starting group
383
+ }
384
+ this.lastConsumedGroupId = top.groupId - 1n;
385
+ this.endedGroups.add(this.lastConsumedGroupId);
386
+ this.resetPending = false;
387
+ }
388
+ // 1. Evaluate gap state
389
+ const availableGroupIds = [...this.bufferedGroupCounts.keys()];
390
+ const gapDecision = this.gapDetector.evaluate(this.lastConsumedGroupId, availableGroupIds);
391
+ // 2. Handle gap decision
392
+ if (gapDecision.action === GapAction.TRACK_ENDED) {
393
+ if (!this._trackEnded) {
394
+ this._trackEnded = true;
395
+ const decision = this.decoderState.notifyTrackEnded();
396
+ this.emitFsmDecision(decision);
397
+ this.onEvent({ type: 'track_ended' });
398
+ }
399
+ return;
400
+ }
401
+ if (gapDecision.action === GapAction.SKIP_FORWARD && gapDecision.targetGroupId !== undefined) {
402
+ const target = gapDecision.targetGroupId;
403
+ // Emit partial_group_abandoned if the current video group wasn't
404
+ // ended normally. This makes the transition observable for diagnostics.
405
+ if (this.mediaType === 'video'
406
+ && this.lastConsumedGroupId >= 0n
407
+ && !this.endedGroups.has(this.lastConsumedGroupId)) {
408
+ this.onEvent({
409
+ type: 'partial_group_abandoned',
410
+ fromGroupId: this.lastConsumedGroupId,
411
+ toGroupId: target,
412
+ reason: 'gap timeout',
413
+ });
414
+ }
415
+ // Clean up ended-group tracking for skipped groups
416
+ for (const gid of this.endedGroups) {
417
+ if (gid < target)
418
+ this.endedGroups.delete(gid);
419
+ }
420
+ this.onEvent({
421
+ type: 'skip_forward',
422
+ fromGroupId: this.lastConsumedGroupId,
423
+ toGroupId: target,
424
+ });
425
+ // Notify recovery controller for potential escalation
426
+ if (this.recovery) {
427
+ const action = this.recovery.evaluate({ type: 'gap', groupId: target });
428
+ this.onEvent({ type: 'recovery', action });
429
+ }
430
+ // Reject future pushObject() calls for groups before the target
431
+ this.minAcceptGroupId = target;
432
+ // Discard old groups from buffer
433
+ this.discardBefore(target);
434
+ // NOTE: Sync reset is NOT done here. When two pipelines (audio + video)
435
+ // share a SyncController, both would call reset() during skip_forward,
436
+ // causing a double-reset: audio resets → re-establishes reference →
437
+ // video resets → wipes reference. The caller (MoqtPlayer) coordinates
438
+ // sync reset via handlePipelineEvent, resetting once per tick cycle.
439
+ // Notify decoder of gap
440
+ const decision = this.decoderState.notifyGap();
441
+ this.emitFsmDecision(decision);
442
+ // Advance consumed position so we start from the target group
443
+ this.lastConsumedGroupId = target - 1n;
444
+ this.endedGroups.add(this.lastConsumedGroupId);
445
+ this.nextExpectedObjectId = 0n;
446
+ this.activeGroupWaitStartUs = null;
447
+ }
448
+ // 3. Backlog shedding: runs BEFORE throttle check so backlog is
449
+ // controlled even when decoder pressure is high. Without this,
450
+ // objects keep entering via pushObject() while throttled, and the
451
+ // shedding path never executes — defeating "move the control
452
+ // point earlier."
453
+ if (this.maxBacklogGroups > 0 && this.bufferedGroupCounts.size > this.maxBacklogGroups) {
454
+ this.shedBacklog();
455
+ }
456
+ // 4. Throttle: if decoder queue is deep, skip draining.
457
+ // Objects still enter via pushObject() (needed for gap detection).
458
+ // Un-throttle signals come from CommandDispatcher on frame output —
459
+ // as the decoder drains its queue, checkQueuePressure() fires on each
460
+ // decoded frame, crossing the low threshold to clear the throttle.
461
+ // @see draft-ietf-moq-transport-16 §7
462
+ if (this._throttled)
463
+ return;
464
+ // 4b. Intra-group timeout: abandon a partially-consumed video group
465
+ // when its suffix never arrives. The gap detector handles inter-group
466
+ // gaps (N+2 present, N+1 missing). This handles the case where N+1
467
+ // is present but N is incomplete — the gap detector won't fire because
468
+ // the next group IS available.
469
+ if (this.mediaType === 'video' && this.activeGroupWaitStartUs !== null) {
470
+ const elapsed = this.clock.now() - this.activeGroupWaitStartUs;
471
+ if (elapsed > this.gapDetector.gapTimeoutUs) {
472
+ const nextGroupId = this.lastConsumedGroupId + 1n;
473
+ this.onEvent({
474
+ type: 'partial_group_abandoned',
475
+ fromGroupId: this.lastConsumedGroupId,
476
+ toGroupId: nextGroupId,
477
+ reason: 'intra-group timeout',
478
+ });
479
+ this.endedGroups.add(this.lastConsumedGroupId);
480
+ const decision = this.decoderState.notifyGap();
481
+ this.emitFsmDecision(decision);
482
+ this.activeGroupWaitStartUs = null;
483
+ }
484
+ }
485
+ // 5. Release contiguous objects from the buffer (bounded).
486
+ // Within a group, objects MUST be contiguous (0, 1, 2, ...) before
487
+ // decoding. A missing objectId means a frame the decoder needs for
488
+ // reference is absent — decoding later objects produces artifacts.
489
+ // Wait for the missing object (QUIC delivers in-order per stream)
490
+ // or let the gap detector timeout and skip the group.
491
+ let released = 0;
492
+ const budget = this.maxReleasePerTick;
493
+ while (this.buffer.size > 0) {
494
+ // Bounded release: stop after budget objects (0 = unlimited).
495
+ if (budget > 0 && released >= budget)
496
+ break;
497
+ // Mid-tick throttle: decoder feedback during processDataObject
498
+ // may set _throttled via handleFeedback(queue_pressure).
499
+ if (this._throttled)
500
+ break;
501
+ const top = this.buffer.peek();
502
+ if (!top)
503
+ break;
504
+ const nextExpectedGroup = this.lastConsumedGroupId + 1n;
505
+ // Don't drain past the next expected group — there might be a gap
506
+ if (top.groupId > nextExpectedGroup) {
507
+ break;
508
+ }
509
+ // New group boundary: for video, don't silently transition from
510
+ // a partial group. If the current group wasn't ended (END_OF_GROUP),
511
+ // wait for the intra-group timeout to explicitly abandon it.
512
+ // Audio is independently decodable — transitions are always safe.
513
+ if (top.groupId > this.lastConsumedGroupId && this.lastConsumedGroupId >= 0n) {
514
+ if (this.mediaType === 'video' && !this.endedGroups.has(this.lastConsumedGroupId)) {
515
+ if (this.activeGroupWaitStartUs === null) {
516
+ this.activeGroupWaitStartUs = this.clock.now();
517
+ }
518
+ break;
519
+ }
520
+ this.activeGroupWaitStartUs = null;
521
+ this.endedGroups.delete(this.lastConsumedGroupId);
522
+ }
523
+ // New group: reset objectId contiguity tracker
524
+ if (top.groupId > this.lastConsumedGroupId) {
525
+ this.nextExpectedObjectId = 0n;
526
+ }
527
+ // Intra-group contiguity (video only): if object N is missing
528
+ // but N+1 is available, skip the rest of this group — decoding
529
+ // N+1 without N's reference produces artifacts. Audio chunks
530
+ // are independently decodable (LOC §4.1) so gaps are harmless.
531
+ if (this.mediaType === 'video' && top.objectId > this.nextExpectedObjectId) {
532
+ // Discard remaining objects in this group — they can't
533
+ // decode correctly without the missing reference.
534
+ const skipGroupId = top.groupId;
535
+ while (this.buffer.size > 0) {
536
+ const peek = this.buffer.peek();
537
+ if (!peek || peek.groupId !== skipGroupId)
538
+ break;
539
+ this.buffer.extract();
540
+ this.decrementGroupCount(skipGroupId);
541
+ }
542
+ this.lastConsumedGroupId = skipGroupId;
543
+ this.nextExpectedObjectId = 0n;
544
+ // Let decoder know it needs a fresh keyframe
545
+ const decision = this.decoderState.notifyGap();
546
+ this.emitFsmDecision(decision);
547
+ break;
548
+ }
549
+ const obj = this.buffer.extract();
550
+ this.decrementGroupCount(obj.groupId);
551
+ if (obj.kind === 'data') {
552
+ this.processDataObject(obj);
553
+ }
554
+ this.nextExpectedObjectId = obj.objectId + 1n;
555
+ released++;
556
+ // Track last consumed group
557
+ if (obj.groupId > this.lastConsumedGroupId) {
558
+ this.lastConsumedGroupId = obj.groupId;
559
+ }
560
+ }
561
+ }
562
+ // ─── Internal ───────────────────────────────────────────────────
563
+ /**
564
+ * Drop oldest whole groups when backlog exceeds maxBacklogGroups.
565
+ * Keeps the newest maxBacklogGroups groups, discards the rest.
566
+ * After shedding, notifies decoder that a gap occurred (needs keyframe).
567
+ */
568
+ shedBacklog() {
569
+ const groupIds = [...this.bufferedGroupCounts.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
570
+ const excess = groupIds.length - this.maxBacklogGroups;
571
+ if (excess <= 0)
572
+ return;
573
+ const groupsToShed = groupIds.slice(0, excess);
574
+ const cutoff = groupsToShed[groupsToShed.length - 1] + 1n;
575
+ this.buffer.discardBefore(cutoff);
576
+ for (const gid of groupsToShed) {
577
+ this.bufferedGroupCounts.delete(gid);
578
+ }
579
+ // Clean up leaked header entries for shed groups
580
+ for (const key of this.headerMap.keys()) {
581
+ const groupId = BigInt(key.split(':')[0]);
582
+ if (groupId < cutoff)
583
+ this.headerMap.delete(key);
584
+ }
585
+ // Advance consumed position past shed groups
586
+ const lastShed = groupsToShed[groupsToShed.length - 1];
587
+ if (lastShed >= this.lastConsumedGroupId) {
588
+ this.lastConsumedGroupId = lastShed;
589
+ }
590
+ this.minAcceptGroupId = lastShed + 1n;
591
+ // Decoder needs a fresh keyframe after shedding
592
+ const decision = this.decoderState.notifyGap();
593
+ this.emitFsmDecision(decision);
594
+ this.onEvent({
595
+ type: 'backlog_shed',
596
+ droppedGroups: excess,
597
+ remainingGroups: this.bufferedGroupCounts.size,
598
+ reason: `backlog exceeded ${this.maxBacklogGroups} groups`,
599
+ });
600
+ }
601
+ /**
602
+ * Whether late encoded VIDEO may be discarded before it reaches the decoder.
603
+ *
604
+ * Only explicitly discardable, non-independent frames may be. Everything
605
+ * else is NOT PROVEN SAFE TO DISCARD and is therefore preserved
606
+ * conservatively: RFC 9626 §3.1 defines D=1 as the sender asserting the
607
+ * frame can be removed while the stream stays decodable, so D=0 does not
608
+ * prove the frame is a reference — it means the receiver has no assurance
609
+ * that discarding it is safe. Absent marking is treated the same way, and
610
+ * an independent frame is preserved even if it also claims to be
611
+ * discardable.
612
+ *
613
+ * Lateness of decoded OUTPUT remains the output stage's decision.
614
+ *
615
+ * @see draft-ietf-moq-loc-01 §2.3.2.2 (Video Frame Marking)
616
+ */
617
+ isPreDecodeDroppable(marking) {
618
+ if (marking === undefined)
619
+ return false; // unknown dependencies — keep
620
+ if (marking.independent)
621
+ return false; // reference anchor — keep
622
+ return marking.discardable === true; // sender said it is safe
623
+ }
624
+ /**
625
+ * Attempt to evict a lower-importance object to make room.
626
+ * @returns true if the incoming object was inserted.
627
+ * @see draft-ietf-moq-transport-16 §7 (Priority-based dropping)
628
+ */
629
+ tryEvictAndInsert(obj, headers) {
630
+ const evicted = this.buffer.evictLowestImportance();
631
+ if (!evicted)
632
+ return false; // No evictable candidate
633
+ if (evicted.publisherPriority > obj.publisherPriority) {
634
+ // Evicted is less important — insert incoming
635
+ this.decrementGroupCount(evicted.groupId);
636
+ const evictedKey = `${evicted.groupId}:${evicted.objectId}`;
637
+ this.headerMap.delete(evictedKey);
638
+ this.buffer.insert(obj);
639
+ const key = `${obj.groupId}:${obj.objectId}`;
640
+ this.headerMap.set(key, headers ?? {});
641
+ const count = this.bufferedGroupCounts.get(obj.groupId) ?? 0;
642
+ this.bufferedGroupCounts.set(obj.groupId, count + 1);
643
+ return true;
644
+ }
645
+ else {
646
+ // Evicted is equally or more important — put it back, drop incoming
647
+ this.buffer.insert(evicted);
648
+ return false;
649
+ }
650
+ }
651
+ processDataObject(obj) {
652
+ const key = `${obj.groupId}:${obj.objectId}`;
653
+ const headers = this.headerMap.get(key) ?? {};
654
+ this.headerMap.delete(key);
655
+ // 1. Configure decoder if the object carries codec config for this
656
+ // media type. DecoderStateMachine.configure dedupes equal bytes.
657
+ // @see draft-ietf-moq-loc-04 §2.3.2.1 (Video Config), §2.3.3.1 (Audio Config)
658
+ const config = this.mediaType === 'video' ? headers.videoConfig : headers.audioConfig;
659
+ if (config) {
660
+ const configDecision = this.decoderState.configure(config);
661
+ if (configDecision.action === 'configure') {
662
+ this.onCommand({
663
+ type: 'configure',
664
+ mediaType: this.mediaType,
665
+ config,
666
+ });
667
+ }
668
+ }
669
+ // 2. Establish sync reference.
670
+ // Audio-master: first audio sample sets the reference (ONCE).
671
+ // Video-only: first video CaptureTimestamp sets the reference.
672
+ if (headers.captureTimestamp !== undefined && !this.sync.hasReference) {
673
+ if (this.mediaType === 'audio') {
674
+ this.sync.setAudioReference(headers.captureTimestamp);
675
+ }
676
+ else if (this._videoOnly) {
677
+ this.sync.setVideoReference(headers.captureTimestamp);
678
+ }
679
+ }
680
+ // 3. Compute render time.
681
+ // Returns null when no reference — CommandDispatcher holds the frame.
682
+ // Video dependency marking, resolved BEFORE the lateness decision so the
683
+ // drop policy and DecoderStateMachine see the same marking.
684
+ const videoMarking = this.mediaType === 'video'
685
+ ? (headers.videoFrameMarking
686
+ ?? (obj.objectId === 0n
687
+ ? { independent: true, startOfFrame: true, endOfFrame: true,
688
+ discardable: false, baseLayerSync: false, temporalId: 0 }
689
+ : undefined))
690
+ : undefined;
691
+ let renderTimeUs;
692
+ if (headers.captureTimestamp !== undefined) {
693
+ const timing = this.mediaType === 'video'
694
+ ? this.sync.computeVideoRenderTime(headers.captureTimestamp)
695
+ : this.sync.computeAudioRenderTime(headers.captureTimestamp);
696
+ if (timing === null) {
697
+ // No reference yet — use placeholder. CommandDispatcher will
698
+ // hold this frame and recompute when the reference is ready.
699
+ renderTimeUs = 0;
700
+ }
701
+ else if (timing.shouldDrop) {
702
+ if (timing.offsetUs < -5_000_000) {
703
+ renderTimeUs = this.clock.now();
704
+ }
705
+ else if (this.mediaType === 'video' && !this.isPreDecodeDroppable(videoMarking)) {
706
+ // Late, but the sender has not asserted this frame can be
707
+ // removed with the stream still decodable, so discarding it
708
+ // here risks every frame that may depend on it — a far
709
+ // larger cost than one late picture. Decode it and let the
710
+ // OUTPUT stage decide what to present: the dispatcher
711
+ // recomputes timing at decoder output and CanvasRenderer
712
+ // drops sufficiently late frames while keeping the newest.
713
+ renderTimeUs = this.clock.now();
714
+ }
715
+ else {
716
+ return; // Genuinely late and safe to discard — skip
717
+ }
718
+ }
719
+ else if (timing.offsetUs > 5_000_000) {
720
+ renderTimeUs = this.clock.now();
721
+ }
722
+ else {
723
+ renderTimeUs = timing.renderTimeUs;
724
+ }
725
+ }
726
+ else {
727
+ renderTimeUs = this.clock.now();
728
+ }
729
+ // 3b. Evaluate live catch-up from CaptureTimestamp
730
+ // @see draft-ietf-moq-loc-01 §2.3.1.1 (latency measurement)
731
+ // @see draft-ietf-moq-msf-00 §5.1.16 (targetLatency)
732
+ if (headers.captureTimestamp !== undefined) {
733
+ const catchUp = this.sync.evaluateCatchUp(headers.captureTimestamp, headers.timestampIsWallClock);
734
+ if (catchUp !== null && catchUp.currentRate !== this._lastEmittedRate) {
735
+ this._lastEmittedRate = catchUp.currentRate;
736
+ this.onEvent({ type: 'catch_up_changed', state: catchUp });
737
+ this.onCommand({ type: 'set_playback_rate', rate: catchUp.currentRate });
738
+ }
739
+ }
740
+ // 4. Create chunk init and feed through decoder FSM
741
+ if (this.mediaType === 'video') {
742
+ // Resolved above, before the lateness decision, so the drop policy
743
+ // and the decoder FSM cannot disagree about the same object.
744
+ const marking = videoMarking;
745
+ // Keyframe payload validation: verify bitstream actually starts with a keyframe
746
+ // when the LOC header claims independent. Catches broken publishers.
747
+ // Only check payloads > 5 bytes (too short = can't determine).
748
+ // @see draft-ietf-moq-loc-01 §4.2
749
+ if (marking?.independent && this._videoCodec && obj.payload.length > 5) {
750
+ const isKeyframe = isKeyframePayload(this._videoCodec, obj.payload);
751
+ if (isKeyframe === false) {
752
+ this.onEvent({
753
+ type: 'keyframe_validation_failed',
754
+ groupId: obj.groupId,
755
+ objectId: obj.objectId,
756
+ codec: this._videoCodec,
757
+ });
758
+ }
759
+ }
760
+ const headersWithMarking = marking !== undefined
761
+ ? { ...headers, videoFrameMarking: marking }
762
+ : headers;
763
+ const chunk = toVideoChunkInit(obj.payload, headersWithMarking);
764
+ const prevState = this.decoderState.state;
765
+ const decision = this.decoderState.processVideoChunk(chunk, marking);
766
+ // Detect live join: NEEDS_KEYFRAME → DECODING (first keyframe decoded)
767
+ // Apply video join re-anchor to prevent startup stutter.
768
+ if (prevState === 'needs_keyframe' && this.decoderState.state === 'decoding'
769
+ && headers.captureTimestamp !== undefined) {
770
+ this.sync.onVideoJoin(headers.captureTimestamp);
771
+ // Recompute render time with the offset applied
772
+ const recomputed = this.sync.computeVideoRenderTime(headers.captureTimestamp);
773
+ if (recomputed) {
774
+ renderTimeUs = recomputed.renderTimeUs;
775
+ }
776
+ }
777
+ if (decision.action === 'decode') {
778
+ this.onCommand({
779
+ type: 'decode_video',
780
+ chunk: decision.chunk,
781
+ renderTimeUs,
782
+ ...(headers.captureTimestamp !== undefined
783
+ ? { captureTimestampUs: headers.captureTimestamp }
784
+ : {}),
785
+ });
786
+ }
787
+ else if (decision.action === 'skip') {
788
+ // Emit keyframe_waiting once per group (avoid per-object spam)
789
+ if (decision.reason === 'waiting for keyframe' && obj.groupId !== this.lastKeyframeWaitingGroupId) {
790
+ this.lastKeyframeWaitingGroupId = obj.groupId;
791
+ this.onEvent({ type: 'keyframe_waiting', groupId: obj.groupId });
792
+ }
793
+ }
794
+ }
795
+ else {
796
+ const chunk = toAudioChunkInit(obj.payload, headers);
797
+ const decision = this.decoderState.processAudioChunk(chunk);
798
+ if (decision.action === 'decode') {
799
+ this.onCommand({
800
+ type: 'decode_audio',
801
+ chunk: decision.chunk,
802
+ renderTimeUs,
803
+ });
804
+ }
805
+ }
806
+ }
807
+ emitFsmDecision(decision) {
808
+ if (decision.action === 'reset') {
809
+ this.onCommand({
810
+ type: 'reset',
811
+ mediaType: this.mediaType,
812
+ reason: decision.reason ?? 'gap',
813
+ });
814
+ }
815
+ else if (decision.action === 'flush') {
816
+ this.onCommand({
817
+ type: 'flush',
818
+ mediaType: this.mediaType,
819
+ });
820
+ }
821
+ }
822
+ discardBefore(groupId) {
823
+ // Remove from buffer
824
+ this.buffer.discardBefore(groupId);
825
+ // Clean up group counts and headers for removed groups
826
+ for (const [gid] of this.bufferedGroupCounts) {
827
+ if (gid < groupId) {
828
+ this.bufferedGroupCounts.delete(gid);
829
+ }
830
+ }
831
+ // Clean up headers for discarded objects
832
+ for (const [key] of this.headerMap) {
833
+ const colonIdx = key.indexOf(':');
834
+ const gid = BigInt(key.slice(0, colonIdx));
835
+ if (gid < groupId) {
836
+ this.headerMap.delete(key);
837
+ }
838
+ }
839
+ }
840
+ decrementGroupCount(groupId) {
841
+ const count = this.bufferedGroupCounts.get(groupId);
842
+ if (count !== undefined) {
843
+ if (count <= 1) {
844
+ this.bufferedGroupCounts.delete(groupId);
845
+ }
846
+ else {
847
+ this.bufferedGroupCounts.set(groupId, count - 1);
848
+ }
849
+ }
850
+ }
851
+ }
852
+ //# sourceMappingURL=pipeline.js.map