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:
Miguel Ángel
2026-05-08 07:53:46 +02:00
committed by GitHub
parent 7bf30d5423
commit b009288df7
10 changed files with 1400 additions and 125 deletions
+36
View File
@@ -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.