fix(core): preview time follows a voiceover that plays through Web Audio (#4476)

This commit is contained in:
Miguel Ángel
2026-09-25 09:13:32 +00:00
committed by GitHub
parent 36f8bd0e91
commit 830f28bf4e
4 changed files with 109 additions and 56 deletions
+84 -39
View File
@@ -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([]);
});
});
});
/**
+2 -2
View File
@@ -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