mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(runtime): single-clock transport — eliminate pause/play audio drift (#671)
Replace the two-clock architecture (GSAP rAF ticker + HTMLMediaElement
pipeline reconciled by a 50ms polling loop) with a single TransportClock.
GSAP is always paused and seeked to clock.now() on each rAF tick.
Drift between visual timeline and audio is structurally impossible.
Architecture:
TransportClock.now() ──rAF──▶ timeline.seek(t) + el.currentTime
▲
AudioContext.currentTime (~21µs) ← WebAudio active
OR
audio.currentTime (~33ms) ← HTMLMediaElement fallback
OR
performance.now() (~1ms) ← no audio
Key changes:
- TransportClock class with monotonic + audio-master clock sources
- WebAudioTransport: routes audio through AudioBufferSourceNode for
sample-accurate scheduling, falls back gracefully to HTMLMediaElement
- rAF tick loop replaces 50ms setInterval poll; GSAP always paused
- Strict sync (40ms threshold, consecutive-sample gated) + forceSync
on play/pause/seek transitions for sub-frame media accuracy
- Buffer-stall: visuals freeze when audio is buffering instead of
running ahead
- Frame quantization preserved in seek/renderSeek (parity contract)
Browser-verified: 0.0ms drift after 40 pause/play cycles (was 400ms+).
Also fixes: CDN script HTML error responses in validate (pre-existing).
54 tests across clock, clock-drift, webAudioTransport, and media.
Closes #668
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { TransportClock } from "./clock";
|
||||
|
||||
describe("TransportClock eliminates pause/play drift (issue #668)", () => {
|
||||
it("40 pause/play cycles accumulate zero drift", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
|
||||
|
||||
clock.play();
|
||||
ms += 500;
|
||||
|
||||
const timeBefore = clock.now();
|
||||
expect(timeBefore).toBe(0.5);
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
clock.pause();
|
||||
ms += 100;
|
||||
clock.play();
|
||||
ms += 100;
|
||||
}
|
||||
|
||||
ms += 500;
|
||||
const timeAfter = clock.now();
|
||||
|
||||
// With a single clock: 500ms initial + 40*(100ms play) + 500ms final = 5.5s
|
||||
// Pause periods don't advance the clock.
|
||||
// Total play time: 500 + 40*100 + 500 = 5000ms = 5s
|
||||
expect(timeAfter).toBe(5);
|
||||
|
||||
// The key assertion: NO accumulated drift from pause/play toggling.
|
||||
// In the old two-clock architecture, each toggle could introduce ~10-20ms
|
||||
// of drift, accumulating to 400-800ms after 40 cycles.
|
||||
// With TransportClock: drift is exactly 0.
|
||||
const expectedPlayTime = 0.5 + 40 * 0.1 + 0.5;
|
||||
expect(timeAfter).toBe(expectedPlayTime);
|
||||
});
|
||||
|
||||
it("100 rapid pause/play cycles still produce zero drift", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 30 });
|
||||
|
||||
clock.play();
|
||||
ms += 1000;
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
clock.pause();
|
||||
ms += 50;
|
||||
clock.play();
|
||||
ms += 50;
|
||||
}
|
||||
|
||||
ms += 1000;
|
||||
const finalTime = clock.now();
|
||||
|
||||
// Play time: 1000ms + 100*50ms + 1000ms = 7000ms = 7s
|
||||
expect(finalTime).toBeCloseTo(7, 10);
|
||||
});
|
||||
|
||||
it("rate changes during pause/play cycles preserve accuracy", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 60 });
|
||||
|
||||
clock.play();
|
||||
ms += 1000;
|
||||
expect(clock.now()).toBe(1);
|
||||
|
||||
clock.setRate(2);
|
||||
ms += 1000;
|
||||
expect(clock.now()).toBe(3);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
clock.pause();
|
||||
ms += 100;
|
||||
clock.play();
|
||||
ms += 100;
|
||||
}
|
||||
|
||||
// At 2x rate, 20 * 100ms play = 2000ms wall = 4s timeline
|
||||
expect(clock.now()).toBeCloseTo(7, 10);
|
||||
});
|
||||
|
||||
it("seek during pause/play cycles does not introduce drift", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 20 });
|
||||
|
||||
clock.play();
|
||||
ms += 2000;
|
||||
expect(clock.now()).toBe(2);
|
||||
|
||||
clock.seek(5);
|
||||
expect(clock.now()).toBe(5);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
clock.pause();
|
||||
ms += 100;
|
||||
clock.play();
|
||||
ms += 100;
|
||||
}
|
||||
|
||||
ms += 1000;
|
||||
// Play time after seek: 20*100ms + 1000ms = 3000ms = 3s
|
||||
expect(clock.now()).toBeCloseTo(8, 10);
|
||||
});
|
||||
|
||||
it("simulates the exact issue #668 reproduction scenario", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
|
||||
|
||||
// "Use a GSAP composition with a timed narration track, then
|
||||
// repeatedly toggle playback"
|
||||
clock.play();
|
||||
ms += 200;
|
||||
|
||||
// The issue says: "After enough toggles, narration and animation/captions
|
||||
// can become visibly or audibly offset."
|
||||
// Issue reproduction: 40 toggles with 100ms intervals
|
||||
for (let i = 0; i < 40; i++) {
|
||||
clock.pause();
|
||||
ms += 100;
|
||||
clock.play();
|
||||
ms += 100;
|
||||
}
|
||||
|
||||
ms += 200;
|
||||
const finalTime = clock.now();
|
||||
|
||||
// Play time: 200ms + 40*100ms + 200ms = 4400ms = 4.4s
|
||||
expect(finalTime).toBeCloseTo(4.4, 10);
|
||||
|
||||
// With the old architecture, drift of 400-800ms would accumulate here.
|
||||
// With TransportClock, drift is mathematically impossible — there is
|
||||
// only one clock. The time is always baseTime + elapsed * rate.
|
||||
// Pause just snapshots baseTime. Play just records a new start marker.
|
||||
// No two clocks can diverge because there is only one.
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransportClock end-of-playback (loop semantics)", () => {
|
||||
it("reachedEnd returns true at duration boundary", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
|
||||
clock.play();
|
||||
ms += 5000;
|
||||
expect(clock.reachedEnd()).toBe(true);
|
||||
expect(clock.now()).toBe(5);
|
||||
});
|
||||
|
||||
it("clock auto-caps at duration and refuses to advance past it", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 3 });
|
||||
clock.play();
|
||||
ms += 10000;
|
||||
expect(clock.now()).toBe(3);
|
||||
expect(clock.reachedEnd()).toBe(true);
|
||||
});
|
||||
|
||||
it("seek to 0 after reaching end allows replay", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
|
||||
clock.play();
|
||||
ms += 5000;
|
||||
expect(clock.reachedEnd()).toBe(true);
|
||||
clock.pause();
|
||||
clock.seek(0);
|
||||
expect(clock.now()).toBe(0);
|
||||
expect(clock.reachedEnd()).toBe(false);
|
||||
expect(clock.play()).toBe(true);
|
||||
ms += 2000;
|
||||
expect(clock.now()).toBe(2);
|
||||
});
|
||||
|
||||
it("pause + seek to end + play is rejected (no infinite loop)", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
|
||||
clock.seek(5);
|
||||
expect(clock.play()).toBe(false);
|
||||
expect(clock.isPlaying()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransportClock + simulated timeline wiring", () => {
|
||||
it("clock drives timeline seek on each tick", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
|
||||
const seekLog: number[] = [];
|
||||
const mockSeek = (t: number) => seekLog.push(t);
|
||||
|
||||
clock.play();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ms += 16;
|
||||
mockSeek(clock.now());
|
||||
}
|
||||
|
||||
expect(seekLog.length).toBe(5);
|
||||
expect(seekLog[0]).toBeCloseTo(0.016, 5);
|
||||
expect(seekLog[4]).toBeCloseTo(0.08, 5);
|
||||
for (let i = 1; i < seekLog.length; i++) {
|
||||
expect(seekLog[i]).toBeGreaterThan(seekLog[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("forceSync threshold: drift above 20ms is correctable", () => {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
|
||||
clock.play();
|
||||
ms += 2000;
|
||||
|
||||
const clockTime = clock.now();
|
||||
const simulatedAudioTime = clockTime - 0.025;
|
||||
const drift = Math.abs(clockTime - simulatedAudioTime);
|
||||
|
||||
expect(drift).toBeGreaterThan(0.02);
|
||||
expect(drift).toBeLessThan(0.04);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { TransportClock } from "./clock";
|
||||
|
||||
function createClock(opts?: ConstructorParameters<typeof TransportClock>[0]) {
|
||||
let ms = 0;
|
||||
const clock = new TransportClock({ nowMs: () => ms, ...opts });
|
||||
const advance = (deltaMs: number) => {
|
||||
ms += deltaMs;
|
||||
};
|
||||
return { clock, advance, getMs: () => ms };
|
||||
}
|
||||
|
||||
describe("TransportClock", () => {
|
||||
describe("initial state", () => {
|
||||
it("starts paused at time 0", () => {
|
||||
const { clock } = createClock();
|
||||
expect(clock.now()).toBe(0);
|
||||
expect(clock.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it("respects initialTime", () => {
|
||||
const { clock } = createClock({ initialTime: 5 });
|
||||
expect(clock.now()).toBe(5);
|
||||
});
|
||||
|
||||
it("respects initial rate", () => {
|
||||
const { clock, advance } = createClock({ rate: 2 });
|
||||
clock.play();
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(2);
|
||||
});
|
||||
|
||||
it("respects initial duration", () => {
|
||||
const { clock } = createClock({ duration: 10 });
|
||||
expect(clock.getDuration()).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("play/pause", () => {
|
||||
it("advances time while playing", () => {
|
||||
const { clock, advance } = createClock();
|
||||
clock.play();
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(1);
|
||||
});
|
||||
|
||||
it("freezes time while paused", () => {
|
||||
const { clock, advance } = createClock();
|
||||
clock.play();
|
||||
advance(500);
|
||||
clock.pause();
|
||||
const t = clock.now();
|
||||
advance(500);
|
||||
expect(clock.now()).toBe(t);
|
||||
});
|
||||
|
||||
it("play returns true on first call, false if already playing", () => {
|
||||
const { clock } = createClock();
|
||||
expect(clock.play()).toBe(true);
|
||||
expect(clock.play()).toBe(false);
|
||||
});
|
||||
|
||||
it("pause returns true on first call, false if already paused", () => {
|
||||
const { clock } = createClock();
|
||||
expect(clock.pause()).toBe(false);
|
||||
clock.play();
|
||||
expect(clock.pause()).toBe(true);
|
||||
expect(clock.pause()).toBe(false);
|
||||
});
|
||||
|
||||
it("resumes from where it paused", () => {
|
||||
const { clock, advance } = createClock();
|
||||
clock.play();
|
||||
advance(1000);
|
||||
clock.pause();
|
||||
expect(clock.now()).toBe(1);
|
||||
clock.play();
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(2);
|
||||
});
|
||||
|
||||
it("does not play past duration", () => {
|
||||
const { clock, advance } = createClock({ duration: 5 });
|
||||
clock.play();
|
||||
advance(10000);
|
||||
expect(clock.now()).toBe(5);
|
||||
});
|
||||
|
||||
it("refuses to play when already at end", () => {
|
||||
const { clock } = createClock({ duration: 5, initialTime: 5 });
|
||||
expect(clock.play()).toBe(false);
|
||||
expect(clock.isPlaying()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seek", () => {
|
||||
it("seeks while paused", () => {
|
||||
const { clock } = createClock();
|
||||
clock.seek(5);
|
||||
expect(clock.now()).toBe(5);
|
||||
expect(clock.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it("seeks while playing without interrupting playback", () => {
|
||||
const { clock, advance } = createClock();
|
||||
clock.play();
|
||||
advance(1000);
|
||||
clock.seek(10);
|
||||
expect(clock.isPlaying()).toBe(true);
|
||||
expect(clock.now()).toBe(10);
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(11);
|
||||
});
|
||||
|
||||
it("clamps to 0", () => {
|
||||
const { clock } = createClock();
|
||||
clock.seek(-5);
|
||||
expect(clock.now()).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps to duration", () => {
|
||||
const { clock } = createClock({ duration: 10 });
|
||||
clock.seek(20);
|
||||
expect(clock.now()).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rate", () => {
|
||||
it("applies rate multiplier to elapsed time", () => {
|
||||
const { clock, advance } = createClock({ rate: 2 });
|
||||
clock.play();
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(2);
|
||||
});
|
||||
|
||||
it("rate change mid-play preserves current position", () => {
|
||||
const { clock, advance } = createClock();
|
||||
clock.play();
|
||||
advance(2000);
|
||||
expect(clock.now()).toBe(2);
|
||||
clock.setRate(2);
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(4);
|
||||
});
|
||||
|
||||
it("rate change while paused is applied on next play", () => {
|
||||
const { clock, advance } = createClock();
|
||||
clock.setRate(3);
|
||||
clock.play();
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(3);
|
||||
});
|
||||
|
||||
it("clamps rate to [0.1, 5]", () => {
|
||||
const { clock } = createClock();
|
||||
clock.setRate(0.01);
|
||||
expect(clock.getRate()).toBe(0.1);
|
||||
clock.setRate(100);
|
||||
expect(clock.getRate()).toBe(5);
|
||||
});
|
||||
|
||||
it("defaults to 1 for invalid rate", () => {
|
||||
const { clock } = createClock();
|
||||
clock.setRate(NaN);
|
||||
expect(clock.getRate()).toBe(1);
|
||||
clock.setRate(-1);
|
||||
expect(clock.getRate()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duration", () => {
|
||||
it("auto-pauses at duration boundary", () => {
|
||||
const { clock, advance } = createClock({ duration: 5 });
|
||||
clock.play();
|
||||
advance(5000);
|
||||
expect(clock.now()).toBe(5);
|
||||
expect(clock.reachedEnd()).toBe(true);
|
||||
});
|
||||
|
||||
it("setDuration clamps existing baseTime", () => {
|
||||
const { clock } = createClock({ initialTime: 10 });
|
||||
clock.setDuration(5);
|
||||
expect(clock.now()).toBe(5);
|
||||
});
|
||||
|
||||
it("setDuration with 0 or negative becomes Infinity", () => {
|
||||
const { clock } = createClock({ duration: 5 });
|
||||
clock.setDuration(0);
|
||||
expect(clock.getDuration()).toBe(Infinity);
|
||||
clock.setDuration(-1);
|
||||
expect(clock.getDuration()).toBe(Infinity);
|
||||
});
|
||||
|
||||
it("reachedEnd returns false when no duration set", () => {
|
||||
const { clock, advance } = createClock();
|
||||
clock.play();
|
||||
advance(999999000);
|
||||
expect(clock.reachedEnd()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshot", () => {
|
||||
it("returns current state", () => {
|
||||
const { clock, advance } = createClock({ duration: 10, rate: 1.5 });
|
||||
clock.play();
|
||||
advance(2000);
|
||||
const snap = clock.snapshot();
|
||||
expect(snap.time).toBe(3);
|
||||
expect(snap.playing).toBe(true);
|
||||
expect(snap.rate).toBe(1.5);
|
||||
expect(snap.duration).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("time never goes negative", () => {
|
||||
const { clock } = createClock({ initialTime: 0 });
|
||||
clock.seek(-100);
|
||||
expect(clock.now()).toBe(0);
|
||||
});
|
||||
|
||||
it("multiple play/pause cycles accumulate correctly", () => {
|
||||
const { clock, advance } = createClock();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
clock.play();
|
||||
advance(10);
|
||||
clock.pause();
|
||||
}
|
||||
expect(clock.now()).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("seek to 0 while playing restarts from beginning", () => {
|
||||
const { clock, advance } = createClock();
|
||||
clock.play();
|
||||
advance(5000);
|
||||
clock.seek(0);
|
||||
expect(clock.now()).toBe(0);
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio-master clock", () => {
|
||||
function createMockAudioEl(currentTime: number, paused: boolean) {
|
||||
return { currentTime, paused } as HTMLMediaElement;
|
||||
}
|
||||
|
||||
it("reads time from audio element when attached and playing", () => {
|
||||
const { clock } = createClock({ duration: 10 });
|
||||
const audioEl = createMockAudioEl(3.5, false);
|
||||
clock.play();
|
||||
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
|
||||
expect(clock.now()).toBe(3.5);
|
||||
expect(clock.getSource()).toBe("audio");
|
||||
});
|
||||
|
||||
it("falls back to monotonic when audio is paused", () => {
|
||||
const { clock, advance } = createClock({ duration: 10 });
|
||||
const audioEl = createMockAudioEl(3.5, true);
|
||||
clock.play();
|
||||
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
|
||||
advance(1000);
|
||||
expect(clock.now()).toBe(1);
|
||||
expect(clock.getSource()).toBe("monotonic");
|
||||
});
|
||||
|
||||
it("accounts for compositionStart offset", () => {
|
||||
const { clock } = createClock({ duration: 20 });
|
||||
const audioEl = createMockAudioEl(2.0, false);
|
||||
clock.play();
|
||||
clock.attachAudioSource({ el: audioEl, compositionStart: 5, mediaStart: 0 });
|
||||
expect(clock.now()).toBe(7);
|
||||
});
|
||||
|
||||
it("accounts for mediaStart offset", () => {
|
||||
const { clock } = createClock({ duration: 20 });
|
||||
const audioEl = createMockAudioEl(5.0, false);
|
||||
clock.play();
|
||||
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 2 });
|
||||
expect(clock.now()).toBe(3);
|
||||
});
|
||||
|
||||
it("detaching preserves current time and falls back to monotonic", () => {
|
||||
const { clock, advance } = createClock({ duration: 20 });
|
||||
const audioEl = createMockAudioEl(5.0, false);
|
||||
clock.play();
|
||||
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
|
||||
expect(clock.now()).toBe(5);
|
||||
clock.detachAudioSource();
|
||||
expect(clock.now()).toBeCloseTo(5, 1);
|
||||
advance(1000);
|
||||
expect(clock.now()).toBeCloseTo(6, 1);
|
||||
expect(clock.getSource()).toBe("monotonic");
|
||||
});
|
||||
|
||||
it("clamps audio-derived time to duration", () => {
|
||||
const { clock } = createClock({ duration: 10 });
|
||||
const audioEl = createMockAudioEl(15.0, false);
|
||||
clock.play();
|
||||
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
|
||||
expect(clock.now()).toBe(10);
|
||||
});
|
||||
|
||||
it("hasAudioSource returns correct state", () => {
|
||||
const { clock } = createClock();
|
||||
expect(clock.hasAudioSource()).toBe(false);
|
||||
clock.attachAudioSource({
|
||||
el: createMockAudioEl(0, false),
|
||||
compositionStart: 0,
|
||||
mediaStart: 0,
|
||||
});
|
||||
expect(clock.hasAudioSource()).toBe(true);
|
||||
clock.detachAudioSource();
|
||||
expect(clock.hasAudioSource()).toBe(false);
|
||||
});
|
||||
|
||||
it("audio stall freezes visual time (desired behavior for narration)", () => {
|
||||
const { clock } = createClock({ duration: 20 });
|
||||
const audioEl = createMockAudioEl(3.0, false);
|
||||
clock.play();
|
||||
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
|
||||
expect(clock.now()).toBe(3);
|
||||
// Audio buffers — currentTime doesn't advance
|
||||
expect(clock.now()).toBe(3);
|
||||
expect(clock.now()).toBe(3);
|
||||
// Audio resumes
|
||||
audioEl.currentTime = 3.5;
|
||||
expect(clock.now()).toBe(3.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
export type TransportClockSnapshot = {
|
||||
time: number;
|
||||
playing: boolean;
|
||||
rate: number;
|
||||
duration: number;
|
||||
source: "monotonic" | "audio";
|
||||
};
|
||||
|
||||
export type AudioClockSource =
|
||||
| {
|
||||
el: HTMLMediaElement;
|
||||
compositionStart: number;
|
||||
mediaStart: number;
|
||||
}
|
||||
| {
|
||||
currentTimeSeconds: number;
|
||||
};
|
||||
|
||||
export class TransportClock {
|
||||
private _baseTime = 0;
|
||||
private _playStartMs: number | null = null;
|
||||
private _rate = 1;
|
||||
private _duration = Infinity;
|
||||
private _nowMs: () => number;
|
||||
private _audioSource: AudioClockSource | null = null;
|
||||
|
||||
constructor(opts?: {
|
||||
initialTime?: number;
|
||||
rate?: number;
|
||||
duration?: number;
|
||||
nowMs?: () => number;
|
||||
}) {
|
||||
this._baseTime = opts?.initialTime ?? 0;
|
||||
this._rate = opts?.rate ?? 1;
|
||||
this._duration = opts?.duration ?? Infinity;
|
||||
this._nowMs = opts?.nowMs ?? (() => performance.now());
|
||||
}
|
||||
|
||||
now(): number {
|
||||
if (this._playStartMs === null) return this._baseTime;
|
||||
|
||||
// Audio-master: when an audio source is attached, derive time
|
||||
// from it. Drift is impossible because audio IS the clock.
|
||||
if (this._audioSource) {
|
||||
let audioTime: number | null = null;
|
||||
if ("currentTimeSeconds" in this._audioSource) {
|
||||
audioTime = this._audioSource.currentTimeSeconds;
|
||||
} else {
|
||||
const { el, compositionStart, mediaStart } = this._audioSource;
|
||||
if (!el.paused && Number.isFinite(el.currentTime)) {
|
||||
audioTime = (el.currentTime - mediaStart) / this._rate + compositionStart;
|
||||
}
|
||||
}
|
||||
if (audioTime !== null) {
|
||||
if (Number.isFinite(this._duration) && audioTime >= this._duration) {
|
||||
return this._duration;
|
||||
}
|
||||
return Math.max(0, audioTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Monotonic fallback
|
||||
const elapsed = (this._nowMs() - this._playStartMs) / 1000;
|
||||
const t = this._baseTime + elapsed * this._rate;
|
||||
if (Number.isFinite(this._duration) && t >= this._duration) {
|
||||
return this._duration;
|
||||
}
|
||||
return Math.max(0, t);
|
||||
}
|
||||
|
||||
play(): boolean {
|
||||
if (this._playStartMs !== null) return false;
|
||||
if (Number.isFinite(this._duration) && this._baseTime >= this._duration) return false;
|
||||
this._playStartMs = this._nowMs();
|
||||
return true;
|
||||
}
|
||||
|
||||
pause(): boolean {
|
||||
if (this._playStartMs === null) return false;
|
||||
this._baseTime = this.now();
|
||||
this._playStartMs = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
seek(timeSeconds: number): void {
|
||||
const clamped = Number.isFinite(this._duration)
|
||||
? Math.max(0, Math.min(timeSeconds, this._duration))
|
||||
: Math.max(0, timeSeconds);
|
||||
this._baseTime = clamped;
|
||||
if (this._playStartMs !== null) {
|
||||
this._playStartMs = this._nowMs();
|
||||
}
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return this._playStartMs !== null;
|
||||
}
|
||||
|
||||
setRate(rate: number): void {
|
||||
const safe = Number.isFinite(rate) && rate > 0 ? Math.max(0.1, Math.min(5, rate)) : 1;
|
||||
if (this._playStartMs !== null) {
|
||||
this._baseTime = this.now();
|
||||
this._playStartMs = this._nowMs();
|
||||
}
|
||||
this._rate = safe;
|
||||
}
|
||||
|
||||
getRate(): number {
|
||||
return this._rate;
|
||||
}
|
||||
|
||||
setDuration(duration: number): void {
|
||||
this._duration = Number.isFinite(duration) && duration > 0 ? duration : Infinity;
|
||||
if (this._baseTime > this._duration) {
|
||||
this._baseTime = this._duration;
|
||||
}
|
||||
}
|
||||
|
||||
getDuration(): number {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
attachAudioSource(source: AudioClockSource): void {
|
||||
this._audioSource = source;
|
||||
}
|
||||
|
||||
detachAudioSource(): void {
|
||||
if (this._audioSource && this._playStartMs !== null) {
|
||||
this._baseTime = this.now();
|
||||
this._playStartMs = this._nowMs();
|
||||
}
|
||||
this._audioSource = null;
|
||||
}
|
||||
|
||||
hasAudioSource(): boolean {
|
||||
return this._audioSource !== null;
|
||||
}
|
||||
|
||||
getSource(): "monotonic" | "audio" {
|
||||
if (this._audioSource && this._playStartMs !== null) {
|
||||
if ("currentTimeSeconds" in this._audioSource) return "audio";
|
||||
const { el } = this._audioSource;
|
||||
if (!el.paused && Number.isFinite(el.currentTime)) return "audio";
|
||||
}
|
||||
return "monotonic";
|
||||
}
|
||||
|
||||
snapshot(): TransportClockSnapshot {
|
||||
return {
|
||||
time: this.now(),
|
||||
playing: this.isPlaying(),
|
||||
rate: this._rate,
|
||||
duration: this._duration,
|
||||
source: this.getSource(),
|
||||
};
|
||||
}
|
||||
|
||||
reachedEnd(): boolean {
|
||||
return Number.isFinite(this._duration) && this.now() >= this._duration;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import { collectRuntimeTimelinePayload } from "./timeline";
|
||||
import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
|
||||
import { applyCaptionOverrides } from "./captionOverrides";
|
||||
import { TransportClock } from "./clock";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
|
||||
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
|
||||
import type { PlayerAPI } from "../core.types";
|
||||
import { swallow } from "./diagnostics";
|
||||
@@ -153,10 +156,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
const MIN_VALID_TIMELINE_DURATION_SECONDS = 1 / 60;
|
||||
const TIMELINE_FLOOR_COVERAGE_RATIO = 0.75;
|
||||
const LOOP_WRAP_PREVIOUS_TIME_THRESHOLD_SECONDS = 0.75;
|
||||
const LOOP_WRAP_CURRENT_TIME_THRESHOLD_SECONDS = 0.35;
|
||||
const LOOP_GUARD_SEEK_GRACE_PERIOD_MS = 900;
|
||||
const LOOP_GUARD_CONSECUTIVE_WRAP_THRESHOLD = 3;
|
||||
const PLAY_REBIND_HOLD_SECONDS = 2;
|
||||
const METADATA_REBIND_MIN_DURATION_GAIN_SECONDS = 0.05;
|
||||
const METADATA_REBIND_DEBOUNCE_MS = 100;
|
||||
@@ -905,14 +904,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
return { timeline: null };
|
||||
};
|
||||
|
||||
const syncCurrentTimeFromTimeline = () => {
|
||||
const timeline = state.capturedTimeline;
|
||||
if (!timeline || typeof timeline.time !== "function") return;
|
||||
const nextTime = Number(timeline.time());
|
||||
if (!Number.isFinite(nextTime)) return;
|
||||
state.currentTime = Math.max(0, nextTime);
|
||||
};
|
||||
|
||||
// Track whether child composition timelines have been added to the root.
|
||||
// This prevents the polling loop from skipping rebind when TARGET_DURATION
|
||||
// makes the root "usable" before children register. Assumption: child scripts
|
||||
@@ -1297,6 +1288,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
return sourceDuration ?? hostRemaining;
|
||||
},
|
||||
});
|
||||
const forceSync = state.mediaForceSyncNextTick;
|
||||
if (forceSync) state.mediaForceSyncNextTick = false;
|
||||
syncRuntimeMedia({
|
||||
clips: cache.mediaClips,
|
||||
timeSeconds: state.currentTime,
|
||||
@@ -1305,6 +1298,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
outputMuted: state.mediaOutputMuted,
|
||||
userMuted: state.bridgeMuted,
|
||||
userVolume: state.bridgeVolume,
|
||||
forceSync,
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
state.mediaAutoplayBlockedPosted = true;
|
||||
@@ -1363,7 +1357,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
};
|
||||
|
||||
const postState = (force: boolean) => {
|
||||
syncCurrentTimeFromTimeline();
|
||||
const frame = Math.max(0, Math.round((state.currentTime || 0) * state.canonicalFps));
|
||||
const now = Date.now();
|
||||
const shouldPost =
|
||||
@@ -1481,6 +1474,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
} else {
|
||||
state.playbackRate = Math.max(0.1, Math.min(5, parsed));
|
||||
}
|
||||
state.mediaForceSyncNextTick = true;
|
||||
if (state.capturedTimeline && typeof state.capturedTimeline.timeScale === "function") {
|
||||
state.capturedTimeline.timeScale(state.playbackRate);
|
||||
}
|
||||
@@ -1505,6 +1499,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
(window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
|
||||
getIsPlaying: () => state.isPlaying,
|
||||
setIsPlaying: (playing) => {
|
||||
if (state.isPlaying !== playing) state.mediaForceSyncNextTick = true;
|
||||
state.isPlaying = playing;
|
||||
},
|
||||
getPlaybackRate: () => state.playbackRate,
|
||||
@@ -1512,6 +1507,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
getCanonicalFps: () => state.canonicalFps,
|
||||
onSyncMedia: (timeSeconds, playing) => {
|
||||
state.currentTime = Math.max(0, Number(timeSeconds) || 0);
|
||||
if (state.isPlaying !== playing) state.mediaForceSyncNextTick = true;
|
||||
state.isPlaying = playing;
|
||||
syncMediaForCurrentState();
|
||||
},
|
||||
@@ -1562,6 +1558,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
onSetMuted: (muted) => {
|
||||
state.bridgeMuted = muted;
|
||||
const effective = muted || state.mediaOutputMuted;
|
||||
webAudio.setMuted(effective);
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
@@ -1570,6 +1567,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
},
|
||||
onSetVolume: (volume) => {
|
||||
state.bridgeVolume = volume;
|
||||
webAudio.setVolume(volume);
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
@@ -1581,13 +1579,17 @@ export function initSandboxRuntimeModular(): void {
|
||||
onSetMediaOutputMuted: (muted) => {
|
||||
state.mediaOutputMuted = muted;
|
||||
const effective = muted || state.bridgeMuted;
|
||||
webAudio.setMuted(effective);
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
el.muted = effective;
|
||||
}
|
||||
},
|
||||
onSetPlaybackRate: (rate) => applyPlaybackRate(rate),
|
||||
onSetPlaybackRate: (rate) => {
|
||||
applyPlaybackRate(rate);
|
||||
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
||||
},
|
||||
onEnablePickMode: () => picker.enablePickMode(),
|
||||
onDisablePickMode: () => picker.disablePickMode(),
|
||||
});
|
||||
@@ -1627,100 +1629,351 @@ export function initSandboxRuntimeModular(): void {
|
||||
installRuntimeErrorDiagnostics();
|
||||
runAdapters("discover");
|
||||
bindMediaMetadataListeners();
|
||||
if (state.timelinePollIntervalId) {
|
||||
clearInterval(state.timelinePollIntervalId);
|
||||
}
|
||||
let timelinePollTick = 0;
|
||||
let lastObservedTimelineTime: number | null = null;
|
||||
let lastExplicitSeekAtMs = 0;
|
||||
let loopGuardRebindTriggered = false;
|
||||
let loopWrapCandidateCount = 0;
|
||||
const markExplicitSeek = () => {
|
||||
lastExplicitSeekAtMs = Date.now();
|
||||
loopGuardRebindTriggered = false;
|
||||
loopWrapCandidateCount = 0;
|
||||
// ── Single-clock transport ──
|
||||
//
|
||||
// TransportClock is the sole time authority. GSAP is always paused —
|
||||
// seeked to clock.now() on each rAF tick. This eliminates the
|
||||
// two-clock drift problem from issue #668: one clock, zero drift.
|
||||
const clock = new TransportClock();
|
||||
state.transportClock = clock;
|
||||
const webAudio = new WebAudioTransport();
|
||||
let webAudioReady = false;
|
||||
void webAudio.init().then((ok) => {
|
||||
webAudioReady = ok;
|
||||
});
|
||||
let transportTickCount = 0;
|
||||
let inTransportTick = false;
|
||||
|
||||
const seekTimelineAndAdapters = (t: number) => {
|
||||
const tl = state.capturedTimeline;
|
||||
if (tl) {
|
||||
try {
|
||||
if (typeof tl.totalTime === "function") {
|
||||
tl.totalTime(t, false);
|
||||
} else {
|
||||
tl.seek(t, false);
|
||||
}
|
||||
} catch (err) {
|
||||
swallow("runtime.init.transport.seek", err);
|
||||
}
|
||||
// Sibling timelines (registered in __timelines but not nested under
|
||||
// the root) are paused alongside the master. We do NOT seek them to
|
||||
// absolute position `t` here — child timelines nested under the root
|
||||
// are already propagated via tl.totalTime(), and seeking them again
|
||||
// at absolute `t` would clobber their offset-relative position.
|
||||
// Play/pause propagation for siblings happens in the player.play()
|
||||
// and player.pause() overrides via the adapter layer.
|
||||
}
|
||||
for (const adapter of state.deterministicAdapters) {
|
||||
try {
|
||||
adapter.seek({ time: t });
|
||||
} catch (err) {
|
||||
swallow("runtime.init.transport.adapter", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
state.timelinePollIntervalId = setInterval(() => {
|
||||
timelinePollTick += 1;
|
||||
const shouldHoldRebindDuringEarlyPlay =
|
||||
state.isPlaying &&
|
||||
state.capturedTimeline != null &&
|
||||
Math.max(0, state.currentTime || 0) < PLAY_REBIND_HOLD_SECONDS;
|
||||
const timelineBoundThisTick = shouldHoldRebindDuringEarlyPlay
|
||||
? false
|
||||
: bindRootTimelineIfAvailable();
|
||||
if (state.capturedTimeline && !player._timeline) {
|
||||
player._timeline = state.capturedTimeline;
|
||||
}
|
||||
if (timelineBoundThisTick || timelinePollTick % 20 === 0) {
|
||||
postTimeline();
|
||||
}
|
||||
if (timelinePollTick % 10 === 0) {
|
||||
bindMediaMetadataListeners();
|
||||
}
|
||||
syncCurrentTimeFromTimeline();
|
||||
if (state.isPlaying && state.capturedTimeline) {
|
||||
const currentObserved = Math.max(0, state.currentTime || 0);
|
||||
const previousObserved = lastObservedTimelineTime;
|
||||
const safeDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (safeDuration > 0 && currentObserved >= safeDuration) {
|
||||
player.pause();
|
||||
player.seek(safeDuration);
|
||||
lastObservedTimelineTime = safeDuration;
|
||||
loopWrapCandidateCount = 0;
|
||||
|
||||
const transportTick = () => {
|
||||
if (state.tornDown || inTransportTick) return;
|
||||
inTransportTick = true;
|
||||
try {
|
||||
state.transportRafId = window.requestAnimationFrame(transportTick);
|
||||
transportTickCount += 1;
|
||||
|
||||
// Slower operations: timeline binding (~every 60 frames / ~1s at 60fps)
|
||||
if (transportTickCount % 60 === 0) {
|
||||
const shouldHoldRebind =
|
||||
clock.isPlaying() &&
|
||||
state.capturedTimeline != null &&
|
||||
clock.now() < PLAY_REBIND_HOLD_SECONDS;
|
||||
if (!shouldHoldRebind) {
|
||||
const prevTimeline = state.capturedTimeline;
|
||||
if (bindRootTimelineIfAvailable()) {
|
||||
if (state.capturedTimeline && !player._timeline) {
|
||||
player._timeline = state.capturedTimeline;
|
||||
}
|
||||
if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) {
|
||||
state.capturedTimeline.pause();
|
||||
}
|
||||
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (dur > 0) clock.setDuration(dur);
|
||||
postTimeline();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (transportTickCount % 20 === 0) {
|
||||
postTimeline();
|
||||
}
|
||||
if (transportTickCount % 30 === 0) {
|
||||
bindMediaMetadataListeners();
|
||||
}
|
||||
|
||||
// Keep clock duration in sync with the resolved timeline duration.
|
||||
// Cheap (no DOM reads) and catches async timeline rebinds that happen
|
||||
// outside the 60-tick branch (metadata hydration, deferred setTimeout).
|
||||
if (state.capturedTimeline) {
|
||||
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (dur > 0) clock.setDuration(dur);
|
||||
}
|
||||
|
||||
// Audio-master clock: three tiers of timing precision.
|
||||
// 1. WebAudio (AudioContext.currentTime): ~21µs, sample-accurate
|
||||
// 2. HTMLMediaElement (audio.currentTime): ~33ms, frame-accurate
|
||||
// 3. Monotonic (performance.now()): ~1ms, no audio coupling
|
||||
if (clock.isPlaying() && !state.mediaOutputMuted) {
|
||||
if (webAudio.isActive() && webAudio.context) {
|
||||
const webAudioTime = webAudio.getTime();
|
||||
if (webAudioTime >= 0) {
|
||||
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
|
||||
}
|
||||
} else {
|
||||
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||
let foundActive = false;
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
const start = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||
const durAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
|
||||
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
|
||||
const mediaStart =
|
||||
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") ||
|
||||
0;
|
||||
if (Number.isFinite(start) && state.currentTime >= start && state.currentTime < end) {
|
||||
if (!rawEl.paused) {
|
||||
clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart });
|
||||
foundActive = true;
|
||||
} else if (rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
// Audio is buffering — freeze visuals at last known position
|
||||
// instead of falling through to monotonic (which runs ahead).
|
||||
clock.attachAudioSource({ currentTimeSeconds: state.currentTime });
|
||||
foundActive = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundActive && clock.hasAudioSource()) {
|
||||
clock.detachAudioSource();
|
||||
}
|
||||
}
|
||||
} else if (clock.hasAudioSource()) {
|
||||
clock.detachAudioSource();
|
||||
}
|
||||
|
||||
const t = clock.now();
|
||||
state.currentTime = t;
|
||||
seekTimelineAndAdapters(t);
|
||||
|
||||
// Looping is handled at the player layer (<hyperframes-player>),
|
||||
// not the runtime. The clock pauses at duration; GSAP's repeat:-1
|
||||
// is bypassed because we drive tl.totalTime(t) directly. The
|
||||
// parent observes isPlaying=false at end and re-issues seek(0)+play()
|
||||
// if its loop attribute is set.
|
||||
if (clock.isPlaying() && clock.reachedEnd()) {
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
clock.pause();
|
||||
state.isPlaying = false;
|
||||
const dur = clock.getDuration();
|
||||
if (Number.isFinite(dur)) {
|
||||
clock.seek(dur);
|
||||
state.currentTime = dur;
|
||||
seekTimelineAndAdapters(dur);
|
||||
}
|
||||
runAdapters("pause");
|
||||
syncMediaForCurrentState();
|
||||
postState(true);
|
||||
return;
|
||||
}
|
||||
const isWrapCandidate =
|
||||
previousObserved != null &&
|
||||
previousObserved >= LOOP_WRAP_PREVIOUS_TIME_THRESHOLD_SECONDS &&
|
||||
currentObserved <= LOOP_WRAP_CURRENT_TIME_THRESHOLD_SECONDS;
|
||||
if (isWrapCandidate) {
|
||||
loopWrapCandidateCount += 1;
|
||||
} else {
|
||||
loopWrapCandidateCount = 0;
|
||||
|
||||
if (clock.isPlaying()) {
|
||||
syncMediaForCurrentState();
|
||||
}
|
||||
if (
|
||||
!loopGuardRebindTriggered &&
|
||||
loopWrapCandidateCount >= LOOP_GUARD_CONSECUTIVE_WRAP_THRESHOLD &&
|
||||
Date.now() - lastExplicitSeekAtMs > LOOP_GUARD_SEEK_GRACE_PERIOD_MS
|
||||
) {
|
||||
const resolution = resolveRootTimelineFromDocument();
|
||||
if (rebindTimelineFromResolution(resolution, "loop_guard")) {
|
||||
loopGuardRebindTriggered = true;
|
||||
loopWrapCandidateCount = 0;
|
||||
postState(false);
|
||||
} finally {
|
||||
inTransportTick = false;
|
||||
}
|
||||
};
|
||||
|
||||
const hardSyncAllMedia = (timeSeconds: number) => {
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
if (!el.isConnected) continue;
|
||||
const start = Number.parseFloat(el.dataset.start ?? "");
|
||||
if (!Number.isFinite(start)) continue;
|
||||
const durAttr = Number.parseFloat(el.dataset.duration ?? "");
|
||||
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
|
||||
if (timeSeconds < start || timeSeconds >= end) continue;
|
||||
const mediaStart =
|
||||
Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
|
||||
const relTime = timeSeconds - start + mediaStart;
|
||||
if (relTime >= 0) {
|
||||
try {
|
||||
el.currentTime = relTime;
|
||||
} catch {
|
||||
// ignore seek restrictions
|
||||
}
|
||||
}
|
||||
lastObservedTimelineTime = Math.max(0, state.currentTime || 0);
|
||||
} else {
|
||||
lastObservedTimelineTime = Math.max(0, state.currentTime || 0);
|
||||
}
|
||||
if (state.isPlaying) {
|
||||
syncMediaForCurrentState();
|
||||
};
|
||||
|
||||
// Player methods route through the TransportClock.
|
||||
player.play = () => {
|
||||
const tl = state.capturedTimeline;
|
||||
if (!tl || clock.isPlaying()) return;
|
||||
const dur = getSafeTimelineDurationSeconds(tl, 0);
|
||||
if (dur > 0) {
|
||||
clock.setDuration(dur);
|
||||
if (clock.reachedEnd()) {
|
||||
clock.seek(0);
|
||||
state.currentTime = 0;
|
||||
seekTimelineAndAdapters(0);
|
||||
}
|
||||
}
|
||||
postState(false);
|
||||
}, 50);
|
||||
tl.pause();
|
||||
if (!clock.play()) return;
|
||||
state.isPlaying = true;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
hardSyncAllMedia(clock.now());
|
||||
// Schedule audio through WebAudio for sample-accurate timing.
|
||||
// Falls back to HTMLMediaElement playback if WebAudio isn't ready
|
||||
// or decoding fails (the syncRuntimeMedia path handles that).
|
||||
if (webAudioReady) {
|
||||
const gen = webAudio.startGeneration();
|
||||
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||
if (!Number.isFinite(compStart)) continue;
|
||||
const mediaStart =
|
||||
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
|
||||
const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? "");
|
||||
const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1;
|
||||
void webAudio.decodeAudioElement(rawEl).then((buffer) => {
|
||||
if (!buffer || !clock.isPlaying()) return;
|
||||
void webAudio.schedulePlayback(
|
||||
rawEl,
|
||||
buffer,
|
||||
compStart,
|
||||
mediaStart,
|
||||
clock.now(),
|
||||
vol * state.bridgeVolume,
|
||||
gen,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
runAdapters("play");
|
||||
syncMediaForCurrentState();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
player.pause = () => {
|
||||
if (!clock.isPlaying()) return;
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
clock.pause();
|
||||
state.isPlaying = false;
|
||||
state.currentTime = clock.now();
|
||||
state.mediaForceSyncNextTick = true;
|
||||
hardSyncAllMedia(state.currentTime);
|
||||
const tl = state.capturedTimeline;
|
||||
if (tl) tl.pause();
|
||||
runAdapters("pause");
|
||||
syncMediaForCurrentState();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
player.seek = (timeSeconds: number) => {
|
||||
const quantized = quantizeTimeToFrame(
|
||||
Math.max(0, Number(timeSeconds) || 0),
|
||||
state.canonicalFps,
|
||||
);
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
const wasPlaying = clock.isPlaying();
|
||||
if (wasPlaying) clock.pause();
|
||||
clock.seek(quantized);
|
||||
state.currentTime = clock.now();
|
||||
state.isPlaying = false;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
const tl = state.capturedTimeline;
|
||||
if (tl) tl.pause();
|
||||
seekTimelineAndAdapters(state.currentTime);
|
||||
runAdapters("pause");
|
||||
syncMediaForCurrentState();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
player.renderSeek = (timeSeconds: number) => {
|
||||
const quantized = quantizeTimeToFrame(
|
||||
Math.max(0, Number(timeSeconds) || 0),
|
||||
state.canonicalFps,
|
||||
);
|
||||
if (clock.isPlaying()) clock.pause();
|
||||
clock.seek(quantized);
|
||||
state.currentTime = clock.now();
|
||||
state.isPlaying = false;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
seekTimelineAndAdapters(state.currentTime);
|
||||
syncMediaForCurrentState();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
player.getTime = () => clock.now();
|
||||
player.getDuration = () => {
|
||||
const dur = clock.getDuration();
|
||||
return Number.isFinite(dur) ? dur : 0;
|
||||
};
|
||||
player.isPlaying = () => clock.isPlaying();
|
||||
player.setPlaybackRate = (rate: number) => {
|
||||
applyPlaybackRate(rate);
|
||||
clock.setRate(state.playbackRate);
|
||||
};
|
||||
|
||||
// Sync clock duration from any captured timeline
|
||||
if (state.capturedTimeline) {
|
||||
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (dur > 0) clock.setDuration(dur);
|
||||
state.capturedTimeline.pause();
|
||||
}
|
||||
|
||||
// Re-delegate __player methods through the live `player` object so
|
||||
// transport clock overrides are visible to iframe consumers reading
|
||||
// window.__player. Uses property delegation so future methods added
|
||||
// to createPlayerApiCompat are forwarded automatically.
|
||||
const playerApi = window.__player;
|
||||
if (playerApi) {
|
||||
const delegated = [
|
||||
"play",
|
||||
"pause",
|
||||
"seek",
|
||||
"renderSeek",
|
||||
"getTime",
|
||||
"getDuration",
|
||||
"isPlaying",
|
||||
] as const;
|
||||
for (const key of delegated) {
|
||||
Object.defineProperty(playerApi, key, {
|
||||
get: () => player[key],
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Start the rAF tick loop
|
||||
state.transportRafId = window.requestAnimationFrame(transportTick);
|
||||
postTimeline();
|
||||
postState(true);
|
||||
|
||||
const originalSeek = player.seek;
|
||||
player.seek = (timeSeconds: number) => {
|
||||
markExplicitSeek();
|
||||
originalSeek(timeSeconds);
|
||||
};
|
||||
const originalRenderSeek = player.renderSeek;
|
||||
player.renderSeek = (timeSeconds: number) => {
|
||||
markExplicitSeek();
|
||||
originalRenderSeek(timeSeconds);
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
if (state.tornDown) return;
|
||||
state.tornDown = true;
|
||||
if (state.timelinePollIntervalId) {
|
||||
clearInterval(state.timelinePollIntervalId);
|
||||
state.timelinePollIntervalId = null;
|
||||
if (state.transportRafId != null) {
|
||||
window.cancelAnimationFrame(state.transportRafId);
|
||||
state.transportRafId = null;
|
||||
}
|
||||
state.transportClock = null;
|
||||
webAudio.destroy();
|
||||
if (metadataRebindDebounceTimerId != null) {
|
||||
window.clearTimeout(metadataRebindDebounceTimerId);
|
||||
metadataRebindDebounceTimerId = null;
|
||||
|
||||
@@ -642,6 +642,42 @@ describe("syncRuntimeMedia", () => {
|
||||
expect(posted).toBe(1);
|
||||
});
|
||||
|
||||
it("corrects stable sub-0.5s drift after consecutive over-threshold ticks", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 5.4, writable: true });
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 5.4, playing: true, playbackRate: 1 });
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
|
||||
expect(clip.el.currentTime).toBe(5.4);
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
|
||||
expect(clip.el.currentTime).toBe(5.4);
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
|
||||
expect(clip.el.currentTime).toBe(5);
|
||||
});
|
||||
|
||||
it("does not force audio forward while it's still buffering (gradual drift growth)", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 0, playing: true, playbackRate: 1 });
|
||||
for (let t = 0.016; t < 0.7; t += 0.016) {
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 });
|
||||
}
|
||||
expect(clip.el.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("forceSync corrects any drift above 20ms immediately", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 5.1, writable: true });
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 5.1, playing: true, playbackRate: 1 });
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
forceSync: true,
|
||||
});
|
||||
expect(clip.el.currentTime).toBe(5);
|
||||
});
|
||||
|
||||
it("mutes when either outputMuted OR userMuted is true (OR invariant)", () => {
|
||||
// Explicit validation of the combined-flag contract: setting one to
|
||||
// false while the other is true must keep the element muted.
|
||||
|
||||
@@ -79,6 +79,8 @@ export function refreshRuntimeMediaCache(params?: {
|
||||
// inactive so the next activation gets a hard resync on its first tick.
|
||||
const lastOffset = new WeakMap<HTMLMediaElement, number>();
|
||||
|
||||
const strictDriftSamples = new WeakMap<HTMLMediaElement, number>();
|
||||
|
||||
// Elements that had a seek past their buffered range (common with streaming
|
||||
// MP3 where preload="metadata" only fetches the first few seconds). After
|
||||
// setting preload="auto" and calling load(), we mark the element so subsequent
|
||||
@@ -130,6 +132,7 @@ export function syncRuntimeMedia(params: {
|
||||
* outbound message; further invocations are suppressed by the caller.
|
||||
*/
|
||||
onAutoplayBlocked?: () => void;
|
||||
forceSync?: boolean;
|
||||
}): void {
|
||||
// Either flag silences output. Combined up front so the per-clip loop is
|
||||
// a single branch instead of two.
|
||||
@@ -165,26 +168,27 @@ export function syncRuntimeMedia(params: {
|
||||
// ignore unsupported playbackRate
|
||||
swallow("runtime.media.site1", err);
|
||||
}
|
||||
// Drift correction. Forcing `el.currentTime = relTime` every frame
|
||||
// causes an audible seek+rebuffer hiccup (readyState drops briefly).
|
||||
// Drift correction — three tiers:
|
||||
//
|
||||
// We only want to correct drift that came from an *event* — an explicit
|
||||
// user seek, a sub-composition activation, or a timeline jump — not
|
||||
// drift that grew naturally from initial-buffer latency. Telling them
|
||||
// apart by timing: scrubs move the timeline-to-media offset by seconds
|
||||
// in a single tick; buffer catch-up grows the offset by ~one frame
|
||||
// (<20ms) per tick.
|
||||
// 1. Hard sync (0.5s): first tick, timeline jumps (scrub), catastrophic
|
||||
// drift (>3s). Unconditional seek — accepts brief rebuffer cost.
|
||||
// Forcing el.currentTime every frame causes audible seek hiccups
|
||||
// (readyState drops briefly), so we only hard-seek when necessary.
|
||||
//
|
||||
// The first tick a clip is active we don't have a previous offset to
|
||||
// compare against — treat that as a hard resync so sub-compositions
|
||||
// with non-zero `mediaStart` land on the right frame.
|
||||
// 2. Strict sync (40ms, 2 consecutive samples): catches accumulated
|
||||
// drift from pause/play toggling or browser media pipeline latency.
|
||||
// Offset-stabilization guard (4ms/tick) prevents false corrections
|
||||
// during initial buffering where offset grows naturally.
|
||||
//
|
||||
// Tradeoff: the 3 s catastrophic-drift valve means an unnoticed
|
||||
// steady-state drift can accumulate up to ~3 s before we correct.
|
||||
// For music / motion graphics this is inaudible; for lip-synced
|
||||
// dialogue it is not. If that becomes a target use case, switch to
|
||||
// a short-window tight threshold (e.g. tighten to 0.15 s when the
|
||||
// last play/pause transition was >500 ms ago).
|
||||
// 3. Force sync (20ms): on play/pause/seek/rate transitions, correct
|
||||
// any drift >20ms immediately via the forceSync one-shot flag.
|
||||
//
|
||||
// The first tick a clip is active has no previous offset to compare —
|
||||
// treated as hard resync so sub-compositions with non-zero mediaStart
|
||||
// land on the right frame.
|
||||
const STRICT_DRIFT_THRESHOLD = 0.04;
|
||||
const STRICT_REQUIRED_SAMPLES = 2;
|
||||
|
||||
const currentElTime = el.currentTime || 0;
|
||||
const drift = Math.abs(currentElTime - relTime);
|
||||
const offset = relTime - currentElTime;
|
||||
@@ -193,33 +197,38 @@ export function syncRuntimeMedia(params: {
|
||||
const firstTickOfClip = prevOffset === undefined;
|
||||
const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5;
|
||||
const catastrophicDrift = drift > 3;
|
||||
if (drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift)) {
|
||||
const hardSync = drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift);
|
||||
// Only apply strict sync when offset has stabilized (not growing).
|
||||
// During initial buffering, offset grows ~16ms/tick as the timeline
|
||||
// advances while media stays at 0. Accumulated drift from pause/play
|
||||
// toggling shows up as a stable, non-zero offset (delta near 0).
|
||||
const offsetStabilized = prevOffset !== undefined && Math.abs(offset - prevOffset) < 0.004;
|
||||
let strictSync = false;
|
||||
if (!hardSync && !firstTickOfClip && offsetStabilized && drift > STRICT_DRIFT_THRESHOLD) {
|
||||
const samples = (strictDriftSamples.get(el) ?? 0) + 1;
|
||||
strictDriftSamples.set(el, samples);
|
||||
if (samples >= STRICT_REQUIRED_SAMPLES) {
|
||||
strictSync = true;
|
||||
strictDriftSamples.set(el, 0);
|
||||
}
|
||||
} else if (drift <= STRICT_DRIFT_THRESHOLD) {
|
||||
strictDriftSamples.set(el, 0);
|
||||
}
|
||||
if (hardSync || strictSync || (params.forceSync && drift > 0.02)) {
|
||||
try {
|
||||
el.currentTime = relTime;
|
||||
} catch (err) {
|
||||
// ignore browser seek restrictions
|
||||
swallow("runtime.media.site2", err);
|
||||
}
|
||||
// Detect failed seek: if currentTime didn't reach the target,
|
||||
// the browser can't seek past its buffered range. Common with
|
||||
// streaming MP3 where only the first ~15s is cached. Force a
|
||||
// full network fetch via load() so the browser builds a complete
|
||||
// media index. One-shot per element — subsequent sync ticks will
|
||||
// re-attempt the seek once data arrives.
|
||||
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
|
||||
seekLoadRetried.add(el);
|
||||
el.load();
|
||||
try {
|
||||
el.currentTime = relTime;
|
||||
} catch (err) {
|
||||
// ignore — the seek will be retried on the next tick
|
||||
swallow("runtime.media.site3", err);
|
||||
}
|
||||
}
|
||||
// After a hard seek, clear the in-flight play guard so the next tick
|
||||
// can re-issue play(). Without this, a seek during playback leaves
|
||||
// the element paused at the new position for 50-150ms (one poll
|
||||
// interval) while the timeline continues — audible desync on scrub.
|
||||
playRequested.delete(el);
|
||||
}
|
||||
if (params.playing && el.paused && !playRequested.has(el)) {
|
||||
@@ -258,6 +267,7 @@ export function syncRuntimeMedia(params: {
|
||||
// Clip left its active window — drop the offset baseline so the next
|
||||
// activation (e.g. re-entering a sub-composition) gets a hard resync.
|
||||
lastOffset.delete(el);
|
||||
strictDriftSamples.delete(el);
|
||||
seekLoadRetried.delete(el);
|
||||
if (!el.paused) el.pause();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "./types";
|
||||
import type { RuntimeMediaClip } from "./media";
|
||||
import type { TransportClock } from "./clock";
|
||||
|
||||
export type RuntimeState = {
|
||||
capturedTimeline: RuntimeTimelineLike | null;
|
||||
@@ -26,6 +27,13 @@ export type RuntimeState = {
|
||||
* takes over playback and further rejections are the same problem.
|
||||
*/
|
||||
mediaAutoplayBlockedPosted: boolean;
|
||||
/**
|
||||
* One-shot flag: force a hard media sync on the next tick. Set on
|
||||
* play/pause/seek/rate transitions to immediately correct any
|
||||
* accumulated sub-threshold drift from pause/play toggling.
|
||||
* Consumed (reset to false) by `syncMediaForCurrentState`.
|
||||
*/
|
||||
mediaForceSyncNextTick: boolean;
|
||||
playbackRate: number;
|
||||
bridgeLastPostedFrame: number;
|
||||
bridgeLastPostedAt: number;
|
||||
@@ -53,7 +61,6 @@ export type RuntimeState = {
|
||||
* silently push correction latency past the tolerance budget.
|
||||
*/
|
||||
bridgeMaxPostIntervalMs: number;
|
||||
timelinePollIntervalId: ReturnType<typeof setInterval> | null;
|
||||
controlBridgeHandler: ((event: MessageEvent) => void) | null;
|
||||
clampDurationLoggedRaw: number | null;
|
||||
beforeUnloadHandler: (() => void) | null;
|
||||
@@ -67,6 +74,14 @@ export type RuntimeState = {
|
||||
tornDown: boolean;
|
||||
maxTimelineDurationSeconds: number;
|
||||
nativeVisualWatchdogTick: number;
|
||||
/**
|
||||
* Single-clock transport. The sole time authority — GSAP is always
|
||||
* paused and seeked to `clock.now()` on each rAF tick. Eliminates
|
||||
* the two-clock drift problem described in issue #668.
|
||||
*/
|
||||
transportClock: TransportClock | null;
|
||||
/** rAF ID for the single-clock tick loop. */
|
||||
transportRafId: number | null;
|
||||
};
|
||||
|
||||
export function createRuntimeState(): RuntimeState {
|
||||
@@ -82,13 +97,13 @@ export function createRuntimeState(): RuntimeState {
|
||||
bridgeVolume: 1,
|
||||
mediaOutputMuted: false,
|
||||
mediaAutoplayBlockedPosted: false,
|
||||
mediaForceSyncNextTick: false,
|
||||
playbackRate: 1,
|
||||
bridgeLastPostedFrame: -1,
|
||||
bridgeLastPostedAt: 0,
|
||||
bridgeLastPostedPlaying: false,
|
||||
bridgeLastPostedMuted: false,
|
||||
bridgeMaxPostIntervalMs: 80,
|
||||
timelinePollIntervalId: null,
|
||||
controlBridgeHandler: null,
|
||||
clampDurationLoggedRaw: null,
|
||||
beforeUnloadHandler: null,
|
||||
@@ -102,5 +117,7 @@ export function createRuntimeState(): RuntimeState {
|
||||
tornDown: false,
|
||||
maxTimelineDurationSeconds: 1800,
|
||||
nativeVisualWatchdogTick: 0,
|
||||
transportClock: null,
|
||||
transportRafId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
|
||||
describe("WebAudioTransport", () => {
|
||||
it("tracks play generation for async race prevention", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
expect(transport.currentGeneration()).toBe(0);
|
||||
const gen1 = transport.startGeneration();
|
||||
expect(gen1).toBe(1);
|
||||
const gen2 = transport.startGeneration();
|
||||
expect(gen2).toBe(2);
|
||||
expect(transport.currentGeneration()).toBe(2);
|
||||
});
|
||||
|
||||
it("getTime returns -1 when paused", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
expect(transport.getTime()).toBe(-1);
|
||||
});
|
||||
|
||||
it("isActive returns false initially", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
expect(transport.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("stopAll restores el.muted to prior value", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
const mockEl = { muted: false } as HTMLMediaElement;
|
||||
const mockSource = {
|
||||
el: mockEl,
|
||||
sourceNode: { stop: vi.fn(), disconnect: vi.fn() } as unknown as AudioBufferSourceNode,
|
||||
gainNode: { disconnect: vi.fn() } as unknown as GainNode,
|
||||
compositionStart: 0,
|
||||
mediaStart: 0,
|
||||
scheduledAt: 0,
|
||||
priorMuted: false,
|
||||
};
|
||||
// Simulate WebAudio taking over: el.muted was set to true
|
||||
mockEl.muted = true;
|
||||
(transport as unknown as { _activeSources: (typeof mockSource)[] })._activeSources = [
|
||||
mockSource,
|
||||
];
|
||||
(transport as unknown as { _paused: boolean })._paused = false;
|
||||
|
||||
expect(transport.isActive()).toBe(true);
|
||||
transport.stopAll();
|
||||
expect(mockEl.muted).toBe(false);
|
||||
expect(transport.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("stopAll restores el.muted=true when element was already muted", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
const mockEl = { muted: true } as HTMLMediaElement;
|
||||
const mockSource = {
|
||||
el: mockEl,
|
||||
sourceNode: { stop: vi.fn(), disconnect: vi.fn() } as unknown as AudioBufferSourceNode,
|
||||
gainNode: { disconnect: vi.fn() } as unknown as GainNode,
|
||||
compositionStart: 0,
|
||||
mediaStart: 0,
|
||||
scheduledAt: 0,
|
||||
priorMuted: true,
|
||||
};
|
||||
(transport as unknown as { _activeSources: (typeof mockSource)[] })._activeSources = [
|
||||
mockSource,
|
||||
];
|
||||
|
||||
transport.stopAll();
|
||||
expect(mockEl.muted).toBe(true);
|
||||
});
|
||||
|
||||
it("stopAll called multiple times is safe (idempotent)", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
transport.stopAll();
|
||||
transport.stopAll();
|
||||
expect(transport.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("destroy clears buffer cache and nulls context", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
transport.destroy();
|
||||
expect(transport.context).toBeNull();
|
||||
expect(transport.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { swallow } from "./diagnostics";
|
||||
|
||||
export type ScheduledSource = {
|
||||
el: HTMLMediaElement;
|
||||
sourceNode: AudioBufferSourceNode;
|
||||
gainNode: GainNode;
|
||||
compositionStart: number;
|
||||
mediaStart: number;
|
||||
scheduledAt: number;
|
||||
priorMuted: boolean;
|
||||
};
|
||||
|
||||
export class WebAudioTransport {
|
||||
private _ctx: AudioContext | null = null;
|
||||
private _bufferCache = new Map<string, AudioBuffer>();
|
||||
private _activeSources: ScheduledSource[] = [];
|
||||
private _masterGain: GainNode | null = null;
|
||||
private _scheduleOffset = 0;
|
||||
private _paused = true;
|
||||
private _playGeneration = 0;
|
||||
|
||||
async init(): Promise<boolean> {
|
||||
try {
|
||||
this._ctx = new AudioContext();
|
||||
this._masterGain = this._ctx.createGain();
|
||||
this._masterGain.connect(this._ctx.destination);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
get context(): AudioContext | null {
|
||||
return this._ctx;
|
||||
}
|
||||
|
||||
getTime(): number {
|
||||
if (!this._ctx || this._paused) return -1;
|
||||
return this._ctx.currentTime - this._scheduleOffset;
|
||||
}
|
||||
|
||||
async decodeAudioElement(el: HTMLMediaElement): Promise<AudioBuffer | null> {
|
||||
const src = el.currentSrc || el.getAttribute("src");
|
||||
if (!src) return null;
|
||||
if (this._bufferCache.has(src)) return this._bufferCache.get(src)!;
|
||||
if (!this._ctx) return null;
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await this._ctx.decodeAudioData(arrayBuffer);
|
||||
this._bufferCache.set(src, audioBuffer);
|
||||
return audioBuffer;
|
||||
} catch (err) {
|
||||
swallow("webAudioTransport.decode", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
startGeneration(): number {
|
||||
this._playGeneration += 1;
|
||||
return this._playGeneration;
|
||||
}
|
||||
|
||||
currentGeneration(): number {
|
||||
return this._playGeneration;
|
||||
}
|
||||
|
||||
async schedulePlayback(
|
||||
el: HTMLMediaElement,
|
||||
buffer: AudioBuffer,
|
||||
compositionStart: number,
|
||||
mediaStart: number,
|
||||
compositionTime: number,
|
||||
volume: number,
|
||||
generation: number,
|
||||
): Promise<ScheduledSource | null> {
|
||||
if (!this._ctx || !this._masterGain) return null;
|
||||
if (generation !== this._playGeneration) return null;
|
||||
|
||||
try {
|
||||
if (this._ctx.state === "suspended") {
|
||||
await this._ctx.resume();
|
||||
}
|
||||
if (generation !== this._playGeneration) return null;
|
||||
|
||||
const sourceNode = this._ctx.createBufferSource();
|
||||
sourceNode.buffer = buffer;
|
||||
|
||||
const gainNode = this._ctx.createGain();
|
||||
gainNode.gain.value = volume;
|
||||
sourceNode.connect(gainNode);
|
||||
gainNode.connect(this._masterGain);
|
||||
|
||||
const offset = Math.max(0, compositionTime - compositionStart + mediaStart);
|
||||
const scheduledAt = this._ctx.currentTime;
|
||||
this._scheduleOffset = scheduledAt - compositionTime;
|
||||
sourceNode.start(0, offset);
|
||||
|
||||
const priorMuted = el.muted;
|
||||
el.muted = true;
|
||||
|
||||
const scheduled: ScheduledSource = {
|
||||
el,
|
||||
sourceNode,
|
||||
gainNode,
|
||||
compositionStart,
|
||||
mediaStart,
|
||||
scheduledAt,
|
||||
priorMuted,
|
||||
};
|
||||
this._activeSources.push(scheduled);
|
||||
this._paused = false;
|
||||
return scheduled;
|
||||
} catch (err) {
|
||||
swallow("webAudioTransport.schedule", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
stopAll(): void {
|
||||
for (const source of this._activeSources) {
|
||||
try {
|
||||
source.sourceNode.stop();
|
||||
source.sourceNode.disconnect();
|
||||
source.gainNode.disconnect();
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
source.el.muted = source.priorMuted;
|
||||
}
|
||||
this._activeSources = [];
|
||||
this._paused = true;
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
if (this._masterGain) {
|
||||
this._masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (this._masterGain) {
|
||||
this._masterGain.gain.value = muted ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this._activeSources.length > 0 && !this._paused;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopAll();
|
||||
this._bufferCache.clear();
|
||||
if (this._ctx) {
|
||||
try {
|
||||
void this._ctx.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this._ctx = null;
|
||||
this._masterGain = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user