avbridge 2.12.0 → 2.13.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 (55) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +33 -0
  3. package/dist/{avi-EQE6AR75.cjs → avi-32UABODO.cjs} +12 -4
  4. package/dist/avi-32UABODO.cjs.map +1 -0
  5. package/dist/{avi-Y3N325WZ.cjs → avi-5BPR6QUX.cjs} +12 -4
  6. package/dist/avi-5BPR6QUX.cjs.map +1 -0
  7. package/dist/{avi-NNHH4AAA.js → avi-BLIH7KKV.js} +12 -4
  8. package/dist/avi-BLIH7KKV.js.map +1 -0
  9. package/dist/{avi-S7EY54YA.js → avi-GX2H34IQ.js} +12 -4
  10. package/dist/avi-GX2H34IQ.js.map +1 -0
  11. package/dist/{chunk-2LNXMGT6.js → chunk-5CX7BVVV.js} +5 -5
  12. package/dist/{chunk-2LNXMGT6.js.map → chunk-5CX7BVVV.js.map} +1 -1
  13. package/dist/{chunk-5Y5BTB5D.js → chunk-B76QWPFM.js} +3 -3
  14. package/dist/{chunk-5Y5BTB5D.js.map → chunk-B76QWPFM.js.map} +1 -1
  15. package/dist/{chunk-GJBNLPGI.cjs → chunk-E5MAM2P4.cjs} +9 -9
  16. package/dist/{chunk-GJBNLPGI.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
  17. package/dist/{chunk-7EF4VTUS.cjs → chunk-OFJYEITB.cjs} +489 -113
  18. package/dist/chunk-OFJYEITB.cjs.map +1 -0
  19. package/dist/{chunk-HBHSUGNI.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
  20. package/dist/{chunk-HBHSUGNI.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
  21. package/dist/{chunk-Z26PXRUY.js → chunk-VOC24LYF.js} +486 -110
  22. package/dist/chunk-VOC24LYF.js.map +1 -0
  23. package/dist/element-browser.js +492 -130
  24. package/dist/element-browser.js.map +1 -1
  25. package/dist/element.cjs +3 -3
  26. package/dist/element.js +2 -2
  27. package/dist/index.cjs +18 -18
  28. package/dist/index.js +6 -6
  29. package/dist/player.cjs +658 -170
  30. package/dist/player.cjs.map +1 -1
  31. package/dist/player.d.cts +36 -4
  32. package/dist/player.d.ts +36 -4
  33. package/dist/player.js +658 -170
  34. package/dist/player.js.map +1 -1
  35. package/dist/{remux-VPKCLHHM.cjs → remux-NSBJFMLG.cjs} +9 -9
  36. package/dist/{remux-VPKCLHHM.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
  37. package/dist/{remux-7TA4FKTY.js → remux-PHUHO3VV.js} +4 -4
  38. package/dist/{remux-7TA4FKTY.js.map → remux-PHUHO3VV.js.map} +1 -1
  39. package/package.json +1 -1
  40. package/src/element/avbridge-player.ts +223 -43
  41. package/src/probe/avi.ts +34 -2
  42. package/src/strategies/fallback/audio-output.ts +164 -35
  43. package/src/strategies/fallback/decoder.ts +467 -60
  44. package/src/strategies/fallback/video-renderer.ts +209 -29
  45. package/src/strategies/hybrid/decoder.ts +56 -28
  46. package/src/strategies/remux/pipeline.ts +12 -3
  47. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  48. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  49. package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
  50. package/dist/avi-EQE6AR75.cjs.map +0 -1
  51. package/dist/avi-NNHH4AAA.js.map +0 -1
  52. package/dist/avi-S7EY54YA.js.map +0 -1
  53. package/dist/avi-Y3N325WZ.cjs.map +0 -1
  54. package/dist/chunk-7EF4VTUS.cjs.map +0 -1
  55. package/dist/chunk-Z26PXRUY.js.map +0 -1
@@ -35,6 +35,17 @@ interface PendingChunk {
35
35
  sampleRate: number;
36
36
  frameCount: number;
37
37
  durationSec: number;
38
+ /** Source-domain content PTS in seconds. `null` for legacy callers
39
+ * that schedule sequentially without PTS information. */
40
+ ptsSec: number | null;
41
+ }
42
+
43
+ /** True when `globalThis.AVBRIDGE_DEBUG` is set. Used to gate [TRACE-AUD]
44
+ * per-chunk logs that are useful for diagnosing scheduling drift but
45
+ * unreadable in normal use. */
46
+ function isDebug(): boolean {
47
+ return typeof globalThis !== "undefined"
48
+ && !!(globalThis as Record<string, unknown>).AVBRIDGE_DEBUG;
38
49
  }
39
50
 
40
51
  export interface ClockSource {
@@ -42,6 +53,14 @@ export interface ClockSource {
42
53
  now(): number;
43
54
  /** True if media is currently playing (audio scheduler is running). */
44
55
  isPlaying(): boolean;
56
+ /**
57
+ * Media time at which the current playback session was anchored — i.e. the
58
+ * seek target after the most recent `reset()`, or 0 on cold start. Used by
59
+ * the video renderer for post-flush PTS calibration: `now()` includes any
60
+ * decode-stall lag accumulated since playback resumed, but the anchor is
61
+ * a stable reference that maps directly to the user's intended position.
62
+ */
63
+ anchorTime(): number;
45
64
  }
46
65
 
47
66
  export class AudioOutput implements ClockSource {
@@ -69,6 +88,17 @@ export class AudioOutput implements ClockSource {
69
88
 
70
89
  /** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
71
90
  private mediaTimeOfAnchor = 0;
91
+
92
+ /**
93
+ * Ctx time at which the first audible chunk will start playing. `-1`
94
+ * before any chunk has been scheduled successfully (clock is frozen);
95
+ * the actual ctx time once one has. The renderer's `clock.now()` uses
96
+ * this to avoid advancing during the silent-gap window between
97
+ * `audio.start()` and the first chunk that schedules without being
98
+ * dropped — that gap is what produces the "audio-less fast-forward"
99
+ * the user sees post-seek when the gate releases on video-only grace.
100
+ */
101
+ private firstAudibleCtxStart = -1;
72
102
  private ctxTimeAtAnchor = 0;
73
103
 
74
104
  private pendingQueue: PendingChunk[] = [];
@@ -154,11 +184,30 @@ export class AudioOutput implements ClockSource {
154
184
  return this.mediaTimeOfAnchor;
155
185
  }
156
186
  if (this.state === "playing") {
187
+ // Freeze the clock until the first audio chunk has actually been
188
+ // scheduled. Without this, when `audio.start()` fires before any
189
+ // post-seek audio packets have made it through the decoder (e.g. the
190
+ // gate's "video-only grace" path released early), `clock.now()`
191
+ // would advance from `mediaTimeOfAnchor` at 1× wall time while the
192
+ // audio scheduler is dropping every chunk that arrives (their
193
+ // PTS-derived `ctxStart` is already in the past). The renderer would
194
+ // paint frames during that silent window — the user perceives that
195
+ // as a "fast-forward burst with no audio." When the first chunk
196
+ // finally arrives and schedules normally, `firstAudibleCtxStart` is
197
+ // set and the clock unfreezes from there in sync with the audible
198
+ // content's PTS.
199
+ if (this.firstAudibleCtxStart < 0) {
200
+ return this.mediaTimeOfAnchor;
201
+ }
157
202
  return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
158
203
  }
159
204
  return this.mediaTimeOfAnchor;
160
205
  }
161
206
 
207
+ anchorTime(): number {
208
+ return this.mediaTimeOfAnchor;
209
+ }
210
+
162
211
  isPlaying(): boolean {
163
212
  return this.state === "playing";
164
213
  }
@@ -192,18 +241,55 @@ export class AudioOutput implements ClockSource {
192
241
  * Schedule a chunk of decoded samples. Queues internally while idle (cold
193
242
  * start or post-seek), schedules directly to the audio graph while playing.
194
243
  * In wall-clock mode, samples are silently discarded.
244
+ *
245
+ * `ptsSec` is the chunk's source-domain content PTS in seconds, from
246
+ * the demuxer. When provided, the chunk plays at the ctx-time
247
+ * corresponding to that PTS — so pre-target audio after a seek
248
+ * naturally drops (its computed `ctxStart` falls in the past) and
249
+ * post-target audio plays at its true content time, without any
250
+ * external trim or anchor rebase. When `ptsSec` is null (cold start
251
+ * with no PTS yet, or codecs whose packet→frame mapping isn't 1:1),
252
+ * the chunk is scheduled sequentially after `mediaTimeOfNext` — the
253
+ * pre-refactor behavior.
195
254
  */
196
- schedule(samples: Float32Array, channels: number, sampleRate: number): void {
255
+ schedule(
256
+ samples: Float32Array,
257
+ channels: number,
258
+ sampleRate: number,
259
+ ptsSec?: number | null,
260
+ ): void {
197
261
  if (this.destroyed || this.noAudio) return;
198
262
  const frameCount = samples.length / channels;
199
263
  const durationSec = frameCount / sampleRate;
264
+ const hasPts = ptsSec != null && Number.isFinite(ptsSec);
265
+
266
+ // Pre-target gate: a chunk whose entire PTS span is before the
267
+ // current media anchor will be silently dropped by `scheduleNow`
268
+ // (its `ctxStart` falls in the past). We must apply the same drop
269
+ // here in idle/paused state too — otherwise the chunk sits in
270
+ // `pendingQueue`, `bufferAhead()` reports it as buffered audio,
271
+ // `waitForBuffer()`'s gate releases on a phantom audio buffer, and
272
+ // `audio.start()` fires with a queue full of chunks that immediately
273
+ // drop on drain. The user sees post-seek "sped up no audio" while
274
+ // the demuxer slowly chews through pre-target packets — `clock.now()`
275
+ // is advancing on wall time and the renderer paints video against
276
+ // it, but `node.start()` is never being called.
277
+ if (hasPts && (ptsSec as number) + durationSec / this._rate < this.mediaTimeOfAnchor) {
278
+ return;
279
+ }
200
280
 
201
281
  if (this.state === "idle" || this.state === "paused") {
202
- this.pendingQueue.push({ samples, channels, sampleRate, frameCount, durationSec });
282
+ this.pendingQueue.push({
283
+ samples, channels, sampleRate, frameCount, durationSec,
284
+ ptsSec: hasPts ? (ptsSec as number) : null,
285
+ });
203
286
  return;
204
287
  }
205
288
 
206
- this.scheduleNow(samples, channels, sampleRate, frameCount);
289
+ this.scheduleNow(
290
+ samples, channels, sampleRate, frameCount,
291
+ hasPts ? (ptsSec as number) : null,
292
+ );
207
293
  }
208
294
 
209
295
  private scheduleNow(
@@ -211,7 +297,67 @@ export class AudioOutput implements ClockSource {
211
297
  channels: number,
212
298
  sampleRate: number,
213
299
  frameCount: number,
300
+ ptsSec: number | null,
214
301
  ): void {
302
+ const durationSec = frameCount / sampleRate;
303
+
304
+ // Compute ctxStart. Two paths:
305
+ //
306
+ // PTS-known: the chunk's content PTS maps to a specific ctx time
307
+ // via (mediaTimeOfAnchor, ctxTimeAtAnchor). If that ctx time is
308
+ // already in the past, the chunk represents audio the user should
309
+ // have heard before now — drop it. After a seek, this is what
310
+ // *automatically* skips pre-target audio packets returned by a
311
+ // keyframe-aligned demuxer seek; no manual trim needed.
312
+ //
313
+ // PTS-unknown (legacy): chain after the last-scheduled sample
314
+ // via `mediaTimeOfNext`. Same behavior as before the refactor.
315
+ let ctxStart: number;
316
+ if (ptsSec != null) {
317
+ ctxStart = this.ctxTimeAtAnchor + (ptsSec - this.mediaTimeOfAnchor) / this._rate;
318
+ if (isDebug()) {
319
+ // eslint-disable-next-line no-console
320
+ console.log(`[TRACE-AUD] PTS sched #${this.framesScheduled} pts=${ptsSec.toFixed(3)} dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} mtNext=${this.mediaTimeOfNext.toFixed(3)} rate=${this._rate}`);
321
+ }
322
+ if (ctxStart < this.ctx.currentTime - 0.001) {
323
+ if (isDebug()) {
324
+ // eslint-disable-next-line no-console
325
+ console.log(`[TRACE-AUD] DROP late chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} < ctxNow=${this.ctx.currentTime.toFixed(4)}`);
326
+ }
327
+ return;
328
+ }
329
+ // First chunk to schedule successfully unfreezes `clock.now()`.
330
+ // We rebase the anchor onto this chunk: when ctx reaches `ctxStart`,
331
+ // clock should equal `ptsSec` (so `audioNow` matches audible content
332
+ // PTS exactly when the chunk plays). The renderer's deadline will
333
+ // then advance from there, in lockstep with what's audible.
334
+ if (this.firstAudibleCtxStart < 0) {
335
+ this.firstAudibleCtxStart = ctxStart;
336
+ this.mediaTimeOfAnchor = ptsSec;
337
+ this.ctxTimeAtAnchor = ctxStart;
338
+ if (isDebug()) {
339
+ // eslint-disable-next-line no-console
340
+ console.log(`[TRACE-AUD] UNFREEZE clock — first audible chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} → anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)}`);
341
+ }
342
+ }
343
+ const endMediaTime = ptsSec + durationSec / this._rate;
344
+ if (endMediaTime > this.mediaTimeOfNext) {
345
+ this.mediaTimeOfNext = endMediaTime;
346
+ }
347
+ } else {
348
+ ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
349
+ // eslint-disable-next-line no-console
350
+ console.warn(`[TRACE-AUD] LEGACY (no PTS) sched dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)}`);
351
+ if (ctxStart < this.ctx.currentTime) {
352
+ // eslint-disable-next-line no-console
353
+ console.warn(`[TRACE-AUD] REBASE anchor was=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor was=${this.ctxTimeAtAnchor.toFixed(4)} → anchor=${this.mediaTimeOfNext.toFixed(3)} ctxAnchor=${this.ctx.currentTime.toFixed(4)}`);
354
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
355
+ this.mediaTimeOfAnchor = this.mediaTimeOfNext;
356
+ ctxStart = this.ctx.currentTime;
357
+ }
358
+ this.mediaTimeOfNext += durationSec;
359
+ }
360
+
215
361
  const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
216
362
  for (let ch = 0; ch < channels; ch++) {
217
363
  const channelData = buffer.getChannelData(ch);
@@ -222,38 +368,8 @@ export class AudioOutput implements ClockSource {
222
368
  const node = this.ctx.createBufferSource();
223
369
  node.buffer = buffer;
224
370
  node.connect(this.gain);
225
- // Pitch the audio to match the playback rate (same as native <video>).
226
371
  if (this._rate !== 1) node.playbackRate.value = this._rate;
227
-
228
- // Convert media time → ctx time using the anchor + rate. At rate=2,
229
- // each second of media time occupies 0.5s of ctx time.
230
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
231
-
232
- // When the decoder is slower than realtime, `ctxStart` falls into
233
- // the past (ctx.currentTime has already passed it). Clamping each
234
- // sample to `ctx.currentTime` individually (the old behavior)
235
- // caused every stale sample in a burst to start at *the same
236
- // instant*, stacking them on top of each other — the audible
237
- // symptom was a series of clicks / a chord of stuttering cook
238
- // packets.
239
- //
240
- // Correct behavior: when the first sample of a burst is behind,
241
- // *rebase the anchor forward* so ctxStart = ctx.currentTime now.
242
- // Subsequent samples in the same burst then schedule at
243
- // ctxStart + offset as usual, laying out sequentially on the
244
- // timeline instead of piling up. The downside is a visible jump
245
- // in the audio clock — but the alternative was silent corruption.
246
- // `now()` readers (the video renderer) just see the clock step
247
- // forward and drop any frames older than the new time.
248
- if (ctxStart < this.ctx.currentTime) {
249
- this.ctxTimeAtAnchor = this.ctx.currentTime;
250
- this.mediaTimeOfAnchor = this.mediaTimeOfNext;
251
- ctxStart = this.ctx.currentTime;
252
- }
253
-
254
372
  node.start(ctxStart);
255
-
256
- this.mediaTimeOfNext += frameCount / sampleRate;
257
373
  this.framesScheduled++;
258
374
  }
259
375
 
@@ -286,6 +402,10 @@ export class AudioOutput implements ClockSource {
286
402
  try { this.gain.connect(this.ctx.destination); } catch { /* ignore */ }
287
403
 
288
404
  if (this.state === "paused") {
405
+ if (isDebug()) {
406
+ // eslint-disable-next-line no-console
407
+ console.log(`[TRACE-AUD] START(resume) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} → ctxAnchor=${this.ctx.currentTime.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
408
+ }
289
409
  // Resume: media time should continue from where we paused. ctx.currentTime
290
410
  // is preserved across suspend/resume, so re-anchoring it to "now" with
291
411
  // the same mediaTimeOfAnchor gives a continuous clock.
@@ -295,7 +415,7 @@ export class AudioOutput implements ClockSource {
295
415
  const drain = this.pendingQueue;
296
416
  this.pendingQueue = [];
297
417
  for (const c of drain) {
298
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
418
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
299
419
  }
300
420
  return;
301
421
  }
@@ -307,11 +427,15 @@ export class AudioOutput implements ClockSource {
307
427
  this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
308
428
  this.mediaTimeOfNext = this.mediaTimeOfAnchor;
309
429
  this.state = "playing";
430
+ if (isDebug()) {
431
+ // eslint-disable-next-line no-console
432
+ console.log(`[TRACE-AUD] START(cold) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} mtNext=${this.mediaTimeOfNext.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
433
+ }
310
434
 
311
435
  const drain = this.pendingQueue;
312
436
  this.pendingQueue = [];
313
437
  for (const c of drain) {
314
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
438
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
315
439
  }
316
440
  }
317
441
 
@@ -341,6 +465,10 @@ export class AudioOutput implements ClockSource {
341
465
  * supplying new samples) and then call `start()` to resume playback.
342
466
  */
343
467
  async reset(newMediaTime: number): Promise<void> {
468
+ if (isDebug()) {
469
+ // eslint-disable-next-line no-console
470
+ console.log(`[TRACE-AUD] RESET to=${newMediaTime.toFixed(3)} prev_anchor=${this.mediaTimeOfAnchor.toFixed(3)} prev_mtNext=${this.mediaTimeOfNext.toFixed(3)} prev_ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} state=${this.state}`);
471
+ }
344
472
  if (this.noAudio) {
345
473
  this.pendingQueue = [];
346
474
  this.mediaTimeOfAnchor = newMediaTime;
@@ -358,6 +486,7 @@ export class AudioOutput implements ClockSource {
358
486
  this.mediaTimeOfAnchor = newMediaTime;
359
487
  this.mediaTimeOfNext = newMediaTime;
360
488
  this.ctxTimeAtAnchor = this.ctx.currentTime;
489
+ this.firstAudibleCtxStart = -1;
361
490
  this.state = "idle";
362
491
 
363
492
  if (this.ctx.state === "running") {