mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
fix(core): thread playback rate into WebAudio audio sources
WebAudioTransport scheduled AudioBufferSourceNodes with the implicit default playbackRate of 1, so non-1x transport rates desynced visuals from audio: GSAP timelines, the transport clock, and native <video> all sped up while WebAudio-routed <audio> clips kept playing at 1x. - schedulePlayback now accepts a rate, sets sourceNode.playbackRate, and scales the future-clip start delay by the rate (the in-progress buffer offset stays elapsed + mediaStart, which is rate-independent). - New setRate() updates active sources in place and rebases the getTime() reference frame so the audio-master clock stays continuous across mid-playback rate changes. - Runtime onSetPlaybackRate now forwards into webAudio.setRate, and player.play() schedules each clip with state.playbackRate. Fixes #713
This commit is contained in:
@@ -86,12 +86,16 @@ describe("WebAudioTransport", () => {
|
||||
const startFn = vi.fn();
|
||||
const sourceNode = {
|
||||
buffer: null as AudioBuffer | null,
|
||||
playbackRate: { value: 1 },
|
||||
start: startFn,
|
||||
stop: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
};
|
||||
const gainNode = {
|
||||
gain: { value: 1 },
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
const masterGain = {
|
||||
gain: { value: 1 },
|
||||
@@ -161,4 +165,149 @@ describe("WebAudioTransport", () => {
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("playback rate", () => {
|
||||
function createMockAudioContext(currentTime = 100) {
|
||||
const startFn = vi.fn();
|
||||
const sourceNode = {
|
||||
buffer: null as AudioBuffer | null,
|
||||
playbackRate: { value: 1 },
|
||||
start: startFn,
|
||||
stop: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
};
|
||||
const gainNode = {
|
||||
gain: { value: 1 },
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
const masterGain = {
|
||||
gain: { value: 1 },
|
||||
connect: vi.fn(),
|
||||
};
|
||||
const ctx = {
|
||||
currentTime,
|
||||
state: "running",
|
||||
resume: vi.fn(),
|
||||
createBufferSource: vi.fn(() => sourceNode),
|
||||
createGain: vi.fn(() => gainNode),
|
||||
destination: {},
|
||||
close: vi.fn(),
|
||||
};
|
||||
return { ctx, sourceNode, gainNode, masterGain, startFn };
|
||||
}
|
||||
|
||||
function setupTransport(currentTime = 100) {
|
||||
const transport = new WebAudioTransport();
|
||||
const mock = createMockAudioContext(currentTime);
|
||||
(transport as unknown as { _ctx: unknown })._ctx = mock.ctx;
|
||||
(transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain;
|
||||
const gen = transport.startGeneration();
|
||||
return { transport, mock, gen };
|
||||
}
|
||||
|
||||
const mockBuffer = {} as AudioBuffer;
|
||||
const mockEl = { muted: false } as HTMLMediaElement;
|
||||
|
||||
it("sets sourceNode.playbackRate.value when rate is provided", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
|
||||
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(2);
|
||||
});
|
||||
|
||||
it("defaults rate to 1 when not provided", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
|
||||
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||
});
|
||||
|
||||
it("scales delay by rate for future clips so they fire at the right wallclock", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
// compStart=10, compositionTime=2, rate=2 → 8s of comp time = 4s wallclock
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, 2, 1, gen, 2);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(104, 0);
|
||||
});
|
||||
|
||||
it("keeps in-progress buffer offset at elapsed + mediaStart regardless of rate", async () => {
|
||||
// Audio that has been playing for `elapsed` composition seconds at rate r:
|
||||
// wallclock elapsed = elapsed / r
|
||||
// buffer advanced = (elapsed / r) × r = elapsed
|
||||
// so the buffer offset is `elapsed + mediaStart` for any rate.
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
|
||||
});
|
||||
|
||||
it("setRate updates active sources in place", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||
|
||||
transport.setRate(2);
|
||||
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(2);
|
||||
});
|
||||
|
||||
it("setRate before any sources are scheduled does not throw", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
expect(() => transport.setRate(2)).not.toThrow();
|
||||
});
|
||||
|
||||
it("setRate clamps non-finite or non-positive values to 1", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
|
||||
|
||||
transport.setRate(Number.NaN);
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||
|
||||
transport.setRate(0);
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||
|
||||
transport.setRate(-1);
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||
});
|
||||
|
||||
it("getTime advances at the configured rate", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
|
||||
|
||||
// At schedule time, ctx.currentTime=100, compositionTime=8.
|
||||
expect(transport.getTime()).toBeCloseTo(8, 10);
|
||||
|
||||
// Advance the audio-context clock by 0.5 wallclock seconds; at rate=2,
|
||||
// composition time should have advanced 1s.
|
||||
mock.ctx.currentTime = 100.5;
|
||||
expect(transport.getTime()).toBeCloseTo(9, 10);
|
||||
});
|
||||
|
||||
it("getTime tracks composition time after a mid-playback setRate", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
|
||||
expect(transport.getTime()).toBeCloseTo(8, 10);
|
||||
|
||||
// 0.5s passes at rate=1 → composition time = 8.5
|
||||
mock.ctx.currentTime = 100.5;
|
||||
expect(transport.getTime()).toBeCloseTo(8.5, 10);
|
||||
|
||||
// Bump rate to 2 — composition time should NOT jump.
|
||||
transport.setRate(2);
|
||||
expect(transport.getTime()).toBeCloseTo(8.5, 10);
|
||||
|
||||
// Another 0.5s wallclock at rate=2 → composition time = 9.5
|
||||
mock.ctx.currentTime = 101;
|
||||
expect(transport.getTime()).toBeCloseTo(9.5, 10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user