mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user