mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-01 03:06:54 +00:00
fix(core): preview time follows a voiceover that plays through Web Audio (#4476)
This commit is contained in:
@@ -4022,68 +4022,80 @@ describe("initSandboxRuntimeModular", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #3458: cross-origin media with no CORS opt-in. `createMediaElementSource`
|
||||
// returns a node that outputs silence per the Web Audio spec rather than
|
||||
// throwing, so the composition played through with visuals animating and no
|
||||
// sound, and nothing was logged.
|
||||
describe("cross-origin audio without a CORS opt-in", () => {
|
||||
// `WebAudioTransport.init()` does `new AudioContext()`, which jsdom does not
|
||||
// provide — without a stub it returns false, `webAudioReady` stays false,
|
||||
// and `scheduleWebAudioForActiveClips` is never reached at all, so every
|
||||
// assertion below would pass for the wrong reason.
|
||||
// jsdom has no AudioContext; without one `WebAudioTransport.init()` fails, Web Audio scheduling
|
||||
// never runs, and every Web Audio assertion passes for the wrong reason.
|
||||
function useMockAudioContext() {
|
||||
const ctx = { time: 0, mediaElementSources: 0 };
|
||||
class MockAudioContext {
|
||||
currentTime = 0;
|
||||
state = "running";
|
||||
destination = {};
|
||||
get currentTime() {
|
||||
return ctx.time;
|
||||
}
|
||||
resume() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
createGain() {
|
||||
return { gain: { value: 1 }, connect() {}, disconnect() {} };
|
||||
}
|
||||
createMediaElementSource() {
|
||||
ctx.mediaElementSources += 1;
|
||||
return { connect() {}, disconnect() {} };
|
||||
}
|
||||
}
|
||||
const originalAudioContext = (globalThis as Record<string, unknown>).AudioContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.time = 0;
|
||||
ctx.mediaElementSources = 0;
|
||||
(globalThis as Record<string, unknown>).AudioContext = MockAudioContext;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as Record<string, unknown>).AudioContext = originalAudioContext;
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** `webAudio.init()` resolves on a microtask, so `webAudioReady` is still
|
||||
* false on the tick `initSandboxRuntimeModular()` returns. */
|
||||
async function startPlayback() {
|
||||
initSandboxRuntimeModular();
|
||||
await Promise.resolve();
|
||||
window.__player?.play();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
/** `webAudio.init()` resolves on a microtask, so `webAudioReady` is still
|
||||
* false on the tick `initSandboxRuntimeModular()` returns. */
|
||||
async function startPlayback() {
|
||||
initSandboxRuntimeModular();
|
||||
await Promise.resolve();
|
||||
window.__player?.play();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function mountAudio(src: string, attrs: Record<string, string> = {}) {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "10");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
function mountAudio(src: string, attrs: Record<string, string> = {}) {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "10");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const audio = document.createElement("audio");
|
||||
audio.setAttribute("data-start", "0");
|
||||
audio.setAttribute("data-duration", "10");
|
||||
audio.setAttribute("src", src);
|
||||
for (const [name, value] of Object.entries(attrs)) audio.setAttribute(name, value);
|
||||
audio.load = () => {};
|
||||
audio.play = vi.fn(() => Promise.resolve());
|
||||
root.appendChild(audio);
|
||||
const audio = document.createElement("audio");
|
||||
audio.setAttribute("data-start", "0");
|
||||
audio.setAttribute("data-duration", "10");
|
||||
audio.setAttribute("src", src);
|
||||
for (const [name, value] of Object.entries(attrs)) audio.setAttribute(name, value);
|
||||
audio.load = () => {};
|
||||
audio.play = vi.fn(() => Promise.resolve());
|
||||
root.appendChild(audio);
|
||||
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
return audio;
|
||||
}
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
return audio;
|
||||
}
|
||||
|
||||
// #3458: cross-origin media with no CORS opt-in. `createMediaElementSource`
|
||||
// returns a node that outputs silence per the Web Audio spec rather than
|
||||
// throwing, so the composition played through with visuals animating and no
|
||||
// sound, and nothing was logged.
|
||||
describe("cross-origin audio without a CORS opt-in", () => {
|
||||
useMockAudioContext();
|
||||
|
||||
it("withholds Web Audio capture but still tries decode, which keeps the FX graph", async () => {
|
||||
// Decode is the BEST outcome here, not a consolation: a CDN that sends
|
||||
@@ -4237,6 +4249,39 @@ describe("initSandboxRuntimeModular", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a voiceover routed through Web Audio", () => {
|
||||
const ctx = useMockAudioContext();
|
||||
|
||||
it("holds the timeline while the voiceover buffers instead of seeking it forward", async () => {
|
||||
const raf = createManualRaf();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
|
||||
window.requestAnimationFrame =
|
||||
raf.requestAnimationFrame as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
|
||||
const audio = mountAudio("/assets/vo.mp3");
|
||||
// Playing, but stuck buffering at 0 like a cold mp3.
|
||||
const seeks: number[] = [];
|
||||
Object.defineProperty(audio, "paused", { value: false, configurable: true });
|
||||
Object.defineProperty(audio, "readyState", { value: 1, configurable: true });
|
||||
Object.defineProperty(audio, "currentTime", {
|
||||
get: () => 0,
|
||||
set: (t: number) => seeks.push(t),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await startPlayback();
|
||||
expect(ctx.mediaElementSources).toBe(1);
|
||||
|
||||
for (let frame = 0; frame < 120; frame++) {
|
||||
ctx.time += 1 / 60;
|
||||
raf.step(1000 / 60);
|
||||
}
|
||||
|
||||
expect(window.__player?.getTime()).toBeLessThan(0.1);
|
||||
expect(seeks.filter((t) => t > 0.1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -3880,14 +3880,14 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
|
||||
// Audio-master clock: three tiers of timing precision.
|
||||
// 1. WebAudio (AudioContext.currentTime): ~21µs, sample-accurate
|
||||
// 1. WebAudio (AudioContext.currentTime) while it plays a decoded buffer: ~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 (
|
||||
!state.nativeMediaSyncDisabled &&
|
||||
!state.webAudioMediaDisabled &&
|
||||
webAudio.isActive() &&
|
||||
webAudio.ownsClock() &&
|
||||
webAudio.context
|
||||
) {
|
||||
const webAudioTime = webAudio.getTime();
|
||||
|
||||
@@ -123,7 +123,15 @@ describe("WebAudioTransport", () => {
|
||||
expect(mock.gainNode.gain.value).toBe(0.8);
|
||||
expect(transport.ownsElement(mockEl)).toBe(false);
|
||||
expect(transport.routesElement(mockEl)).toBe(true);
|
||||
expect(transport.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the clock to a routed element, which keeps its own time", async () => {
|
||||
const { transport, gen } = setupTransport(100);
|
||||
|
||||
await transport.scheduleMediaElementPlayback(mockEl, 0, 0, 0, 1, gen, 1);
|
||||
|
||||
expect(transport.routesElement(mockEl)).toBe(true);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
});
|
||||
|
||||
it("creates one MediaElementAudioSourceNode per element and context", async () => {
|
||||
@@ -161,7 +169,7 @@ describe("WebAudioTransport", () => {
|
||||
expect(mock.mediaElementSourceNode.disconnect).toHaveBeenCalled();
|
||||
expect(mockEl.muted).toBe(false);
|
||||
expect(mockEl.volume).toBe(0.4);
|
||||
expect(transport.isActive()).toBe(false);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
});
|
||||
|
||||
// #3458. `createMediaElementSource` over a CORS-cross-origin resource does
|
||||
@@ -276,9 +284,9 @@ describe("WebAudioTransport", () => {
|
||||
expect(transport.getTime()).toBe(-1);
|
||||
});
|
||||
|
||||
it("isActive returns false initially", () => {
|
||||
it("ownsClock returns false initially", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
expect(transport.isActive()).toBe(false);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
});
|
||||
|
||||
it("stopAll restores el.muted to prior value", () => {
|
||||
@@ -301,10 +309,10 @@ describe("WebAudioTransport", () => {
|
||||
];
|
||||
(transport as unknown as { _paused: boolean })._paused = false;
|
||||
|
||||
expect(transport.isActive()).toBe(true);
|
||||
expect(transport.ownsClock()).toBe(true);
|
||||
transport.stopAll();
|
||||
expect(mockEl.muted).toBe(false);
|
||||
expect(transport.isActive()).toBe(false);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
});
|
||||
|
||||
it("stopAll restores el.muted=true when element was already muted", () => {
|
||||
@@ -332,14 +340,14 @@ describe("WebAudioTransport", () => {
|
||||
const transport = new WebAudioTransport();
|
||||
transport.stopAll();
|
||||
transport.stopAll();
|
||||
expect(transport.isActive()).toBe(false);
|
||||
expect(transport.ownsClock()).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);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
});
|
||||
|
||||
it("restores the configured master volume after user mute then unmute", () => {
|
||||
@@ -662,12 +670,12 @@ describe("WebAudioTransport", () => {
|
||||
} as unknown as HTMLMediaElement;
|
||||
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
|
||||
expect(transport.isActive()).toBe(true);
|
||||
expect(transport.ownsClock()).toBe(true);
|
||||
expect(el.muted).toBe(true);
|
||||
|
||||
mock.sourceNode._fireEnded();
|
||||
|
||||
expect(transport.isActive()).toBe(false);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
expect(el.muted).toBe(false);
|
||||
});
|
||||
|
||||
@@ -684,7 +692,7 @@ describe("WebAudioTransport", () => {
|
||||
mock.sourceNode._fireEnded();
|
||||
|
||||
expect(el.muted).toBe(true);
|
||||
expect(transport.isActive()).toBe(false);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
});
|
||||
|
||||
it("disposes the FX graph when a clip ends naturally", async () => {
|
||||
@@ -720,14 +728,14 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
transport.stopAll();
|
||||
expect(el.muted).toBe(false);
|
||||
expect(transport.isActive()).toBe(false);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
|
||||
el.muted = true;
|
||||
|
||||
mock.sourceNode._fireEnded();
|
||||
|
||||
expect(el.muted).toBe(true);
|
||||
expect(transport.isActive()).toBe(false);
|
||||
expect(transport.ownsClock()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -815,8 +815,8 @@ export class WebAudioTransport {
|
||||
this._monitorGain.gain.value = this._masterMuted ? 0 : this._masterVolume;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this._activeSources.length > 0 && !this._paused;
|
||||
ownsClock(): boolean {
|
||||
return !this._paused && this._activeSources.some(isBufferSource);
|
||||
}
|
||||
|
||||
/** Whether the transport currently plays THIS element (the runtime mutes it to
|
||||
|
||||
Reference in New Issue
Block a user