avbridge 2.12.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.12.1",
3
+ "version": "2.13.0",
4
4
  "description": "Play and convert arbitrary video files in the browser. Native, remux, hybrid, fallback, and transcode — one API.",
5
5
  "license": "MIT",
6
6
  "author": "Keishi Hattori",
@@ -72,6 +72,44 @@ type FitMode = (typeof FIT_MODES)[number];
72
72
  export class AvbridgePlayerElement extends HTMLElement {
73
73
  static readonly observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
74
74
 
75
+ /**
76
+ * Returns `true` if a DOM event originated from one of the player's
77
+ * **interactive chrome elements** (seek bar, control buttons, settings
78
+ * menu, overlay play button) rather than the bare video surface.
79
+ *
80
+ * This is the escape hatch for host pages that wrap the player in a
81
+ * gesture recognizer (e.g. TikTok-style vertical-swipe pager). For
82
+ * bubble-phase listeners the player's own handlers already call
83
+ * `stopPropagation()` on chrome interactions — but **capture-phase**
84
+ * listeners run *before* the player's handlers, so they need to check
85
+ * the event's path themselves and bail. This helper does that check
86
+ * via `composedPath()`, which traverses shadow boundaries correctly.
87
+ *
88
+ * Returns `false` for events on the bare video surface — host pages
89
+ * remain free to claim those for their own gestures (e.g. swipe-to-pan
90
+ * to the next video). Returns `false` for events that never hit a
91
+ * player at all.
92
+ *
93
+ * @example
94
+ * // TikTok-style vertical swipe on the document, capture phase:
95
+ * document.addEventListener("pointerdown", (e) => {
96
+ * if (AvbridgePlayerElement.isPlayerChromeEvent(e)) return;
97
+ * startSwipeGesture(e);
98
+ * }, { capture: true });
99
+ */
100
+ static isPlayerChromeEvent(event: Event): boolean {
101
+ // Mirrors the selector used by the player's internal tap-target
102
+ // gate (see `_onPlayerSurfaceClick` and friends): anything inside
103
+ // these regions is "chrome", everything else is the bare video.
104
+ const CHROME_SELECTOR = ".avp-controls, .avp-settings, .avp-overlay-btn";
105
+ for (const node of event.composedPath()) {
106
+ if (node instanceof HTMLElement && node.matches?.(CHROME_SELECTOR)) {
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+
75
113
  // ── Internal DOM refs ──────────────────────────────────────────────────
76
114
 
77
115
  private _video!: AvbridgeVideoElement;
@@ -104,6 +142,11 @@ export class AvbridgePlayerElement extends HTMLElement {
104
142
  private _activeAudioTrackId: number | null = null;
105
143
  private _activeSubtitleTrackId: number | null = null;
106
144
  private _userSeeking = false;
145
+ /** Last seek target the user committed. The thumb stays here (and
146
+ * `_updateTime` skips updating from `timeupdate`) until the underlying
147
+ * `currentTime` actually catches up — otherwise the thumb visibly snaps
148
+ * back to the pre-seek position while the remux pipeline rebuilds. */
149
+ private _pendingSeekTarget: number | null = null;
107
150
  private _holdTimer: ReturnType<typeof setTimeout> | null = null;
108
151
  private _holdSpeedActive = false;
109
152
  private _savedPlaybackRate = 1;
@@ -233,7 +276,10 @@ export class AvbridgePlayerElement extends HTMLElement {
233
276
  }
234
277
 
235
278
  // State tracking
236
- on(this._video, "loadstart", () => this._setState("loading"));
279
+ on(this._video, "loadstart", () => {
280
+ this._pendingSeekTarget = null;
281
+ this._setState("loading");
282
+ });
237
283
  on(this._video, "ready", () => {
238
284
  this._setState(this._video.paused ? "paused" : "playing");
239
285
  this._seekInput.max = String(this._video.duration || 0);
@@ -430,7 +476,9 @@ export class AvbridgePlayerElement extends HTMLElement {
430
476
  }
431
477
 
432
478
  private _onSeekCommit(): void {
433
- this._video.currentTime = Number(this._seekInput.value);
479
+ const target = Number(this._seekInput.value);
480
+ this._pendingSeekTarget = target;
481
+ this._video.currentTime = target;
434
482
  this._userSeeking = false;
435
483
  }
436
484
 
@@ -442,49 +490,87 @@ export class AvbridgePlayerElement extends HTMLElement {
442
490
  return frac * (this._video.duration || 0);
443
491
  }
444
492
 
445
- /** Seekbar width below which drag-to-scrub seeks in real-time (vs
446
- * preview-only). On narrow bars precise positioning is hard, so
447
- * immediate video feedback is more useful than a time tooltip. */
448
- private static readonly SCRUB_WIDTH_THRESHOLD = 400;
449
-
450
493
  private _onSeekPointerDown(e: PointerEvent): void {
451
494
  // Ignore synthetic clicks originating from the input's own handling
452
495
  if (e.button !== 0 && e.pointerType === "mouse") return;
453
496
  e.preventDefault();
497
+ // Consume the event so host pages can layer the player inside a
498
+ // swipe-driven UI (e.g. TikTok-style vertical pager) without the
499
+ // pointerdown bubbling out and latching their gesture recognizer.
500
+ // The seekbar's CSS sets `touch-action: none` to suppress native
501
+ // browser pan/zoom — this complements that on the JS side, since
502
+ // swipe handlers built on PointerEvents wouldn't honor touch-action.
503
+ e.stopPropagation();
454
504
  this._userSeeking = true;
455
505
  const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
456
506
  seekBar.setPointerCapture(e.pointerId);
457
507
  seekBar.setAttribute("data-seeking", "");
458
508
 
459
- // Decide scrub mode based on physical width.
460
- const scrubMode = seekBar.getBoundingClientRect().width < AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
461
- let lastScrubCommit = 0;
509
+ // Two seek modes, picked by `(pointer: coarse)`:
510
+ // - **fine** (mouse / trackpad / stylus): absolute mapping —
511
+ // pointer X maps directly to seek time, thumb jumps under
512
+ // cursor. Standard desktop YouTube behavior.
513
+ // - **coarse** (touch): relative drag — initial tap doesn't move
514
+ // the thumb; finger Δx maps to a Δt added to the time at
515
+ // pointerdown. Standard YouTube-mobile behavior; matters because
516
+ // finger positioning is too imprecise for absolute on a small bar.
517
+ // Both modes commit live during drag (throttled to ~4 Hz so we don't
518
+ // overwhelm the seek pipeline — every commit restarts the decoder
519
+ // pump on canvas strategies) and once more on pointerup.
520
+ const coarse = typeof matchMedia !== "undefined"
521
+ && matchMedia("(pointer: coarse)").matches;
522
+ const startTime = coarse ? (this._video.currentTime || 0) : 0;
523
+ const startClientX = e.clientX;
524
+ let lastCommit = 0;
525
+
526
+ const timeAt = (clientX: number): number => {
527
+ if (coarse) {
528
+ const rect = seekBar.getBoundingClientRect();
529
+ const dx = clientX - startClientX;
530
+ const dt = (dx / rect.width) * (this._video.duration || 0);
531
+ return Math.max(0, Math.min(this._video.duration || 0, startTime + dt));
532
+ }
533
+ return this._timeFromSeekPointer(clientX);
534
+ };
462
535
 
463
- const initial = this._timeFromSeekPointer(e.clientX);
464
- this._seekInput.value = String(initial);
465
- this._onSeekInput();
466
- this._updateSeekTooltip(e.clientX);
467
- if (scrubMode) this._onSeekCommit();
536
+ const showTooltip = (t: number, clientX: number): void => {
537
+ if (coarse) this._updateSeekTooltipAtTime(t);
538
+ else this._updateSeekTooltip(clientX);
539
+ };
540
+
541
+ // Fine mode: tap commits immediately (thumb jumps under pointer).
542
+ // Coarse mode: tap parks at the current time; only drag moves it.
543
+ if (!coarse) {
544
+ const initial = timeAt(e.clientX);
545
+ this._seekInput.value = String(initial);
546
+ this._onSeekInput();
547
+ showTooltip(initial, e.clientX);
548
+ this._onSeekCommit();
549
+ this._userSeeking = true; // commit clears it; we're still seeking
550
+ } else {
551
+ showTooltip(startTime, e.clientX);
552
+ }
468
553
 
469
554
  const onMove = (ev: PointerEvent) => {
470
- const t = this._timeFromSeekPointer(ev.clientX);
555
+ // Belt-and-suspenders for the host's swipe handler. Pointer capture
556
+ // changes the *target* of subsequent pointermove events to seekBar,
557
+ // but they still bubble through ancestors — a swipe listener
558
+ // attached to document/window would otherwise see every drag tick.
559
+ ev.stopPropagation();
560
+ const t = timeAt(ev.clientX);
471
561
  this._seekInput.value = String(t);
472
562
  this._onSeekInput();
473
- this._updateSeekTooltip(ev.clientX);
474
- // In scrub mode, commit seeks throttled to ~4 Hz so we don't
475
- // overwhelm the seek pipeline (especially on canvas strategies
476
- // where each seek restarts the decoder pump).
477
- if (scrubMode) {
478
- const now = performance.now();
479
- if (now - lastScrubCommit > 250) {
480
- lastScrubCommit = now;
481
- this._onSeekCommit();
482
- this._userSeeking = true; // keep seeking flag live
483
- }
563
+ showTooltip(t, ev.clientX);
564
+ const now = performance.now();
565
+ if (now - lastCommit > 250) {
566
+ lastCommit = now;
567
+ this._onSeekCommit();
568
+ this._userSeeking = true;
484
569
  }
485
570
  };
486
571
  const onUp = (ev: PointerEvent) => {
487
- const t = this._timeFromSeekPointer(ev.clientX);
572
+ ev.stopPropagation();
573
+ const t = timeAt(ev.clientX);
488
574
  this._seekInput.value = String(t);
489
575
  this._onSeekCommit();
490
576
  this._seekInput.focus();
@@ -511,6 +597,16 @@ export class AvbridgePlayerElement extends HTMLElement {
511
597
  this._seekTooltip.style.left = `${frac * 100}%`;
512
598
  }
513
599
 
600
+ /** Position the tooltip over a specific time (vs. pointer X). Used by
601
+ * relative-drag scrub on coarse pointers, where the displayed time
602
+ * is decoupled from the finger position. */
603
+ private _updateSeekTooltipAtTime(t: number): void {
604
+ const dur = this._video.duration || 0;
605
+ const frac = dur > 0 ? Math.max(0, Math.min(1, t / dur)) : 0;
606
+ this._seekTooltip.textContent = formatTime(t);
607
+ this._seekTooltip.style.left = `${frac * 100}%`;
608
+ }
609
+
514
610
  private _updateSeekVisuals(t: number): void {
515
611
  const dur = this._video.duration || 0;
516
612
  const pct = dur > 0 ? (t / dur) * 100 : 0;
@@ -526,6 +622,18 @@ export class AvbridgePlayerElement extends HTMLElement {
526
622
  if (this._userSeeking) return;
527
623
  const t = this._video.currentTime;
528
624
  const d = this._video.duration;
625
+ // While a committed seek is still settling, keep the thumb at the
626
+ // target so it doesn't snap back to the pre-seek position. Clear once
627
+ // currentTime has landed within 0.5s of the target.
628
+ if (this._pendingSeekTarget !== null) {
629
+ if (Math.abs(t - this._pendingSeekTarget) < 0.5) {
630
+ this._pendingSeekTarget = null;
631
+ } else {
632
+ this._timeDisplay.textContent = `${formatTime(this._pendingSeekTarget)} / ${formatTime(d)}`;
633
+ this._updateBuffered();
634
+ return;
635
+ }
636
+ }
529
637
  this._seekInput.value = String(t);
530
638
  this._updateSeekVisuals(t);
531
639
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
@@ -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") {