mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user