mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
perf(core): a paused preview stops running its audio output (#4534)
* perf(core): a paused preview stops running its audio output The preview runtime opened its AudioContext at load and left it running for the whole session, so a paused Studio kept an audio output stream rendering silence in Chrome's audio service and output threads, and on Chrome's fake sink once a context has been silent for 30 s. The transport now suspends the context once it has been paused for a second with no captured track playing, and resumes it for Play and for a captured track that plays while paused (the scrub route). The delay keeps a stop-and-reschedule inside one play (a rate change, a hidden track, a seek while playing) from bouncing the output. A Play that lands while a suspend is pending resumes anyway, and a Pause that lands while Play waits on resume() lets the context go back to sleep. * fix(core): decoded audio after an idle pause starts where the picture is Review follow-ups to the idle suspend: - A decoded track read its start time before waiting for the context to wake, so every Play after an idle pause anchored the clock that far back and the picture stepped back. The transport now reads the time after the wake. - The transport kept every element it ever captured. It now holds only captured tracks that are playing, so a replaced track is free once it pauses. - A failed suspend and destroy() both clear the pending-suspend flag, so the next pause, or a fresh init(), can suspend again. - A test pins the one-second idle delay; the redundant "ended" listener is gone, since a track that ends fires "pause" first. * fix(core): a preview whose browser starts audio late still goes quiet when idle The idle timer was armed only if the context was already running when the transport opened it or resumed it, so a browser that starts the context a moment after construction left it running until the first pause. The transport now arms the timer from the context's own statechange event, which Chrome fires after construction and on every resume, so the separate arming in init() and after a stale Play are gone. * fix(core): a reloaded track no longer keeps a paused preview's audio awake Reloading a playing captured track (el.load(), a src swap) stops it without a "pause" event, so the transport kept counting it as playing and a paused preview never went quiet. The idle check now drops tracks that are already paused before it counts, and "emptied" is handled like "pause" so the idle timer is armed when a reload stops a track. * perf(core): a paused preview waits 10 s before its audio output goes to sleep * docs(core): say why a paused preview's audio waits 10 s before it sleeps * fix(core): a browser-side audio suspend drops the preview's idle timer
This commit is contained in:
@@ -50,6 +50,9 @@ class GraphAudioContext {
|
||||
resume() {
|
||||
return pendingResume ?? Promise.resolve();
|
||||
}
|
||||
suspend() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
close() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -4311,6 +4311,9 @@ describe("initSandboxRuntimeModular", () => {
|
||||
resume() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
suspend() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
createGain() {
|
||||
return { gain: { value: 1 }, connect() {}, disconnect() {} };
|
||||
}
|
||||
|
||||
@@ -4366,7 +4366,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
buffer,
|
||||
compStart,
|
||||
mediaStart,
|
||||
clock.now(),
|
||||
() => clock.now(),
|
||||
vol,
|
||||
gen,
|
||||
state.playbackRate,
|
||||
|
||||
@@ -39,6 +39,7 @@ function createMockAudioContext(currentTime = 100) {
|
||||
currentTime,
|
||||
state: "running",
|
||||
resume: vi.fn(),
|
||||
suspend: vi.fn(() => Promise.resolve()),
|
||||
createBufferSource: vi.fn(() => sourceNode),
|
||||
createMediaElementSource: vi.fn(() => mediaElementSourceNode),
|
||||
createGain: vi.fn(() => gainNode),
|
||||
@@ -62,6 +63,8 @@ const mockBuffer = {} as AudioBuffer;
|
||||
const mockEl = {
|
||||
muted: false,
|
||||
volume: 0.4,
|
||||
paused: true,
|
||||
addEventListener: () => {},
|
||||
getAttribute: (name: string) => (name === "data-playback-rate" ? "1" : null),
|
||||
} as unknown as HTMLMediaElement;
|
||||
|
||||
@@ -435,7 +438,7 @@ describe("WebAudioTransport", () => {
|
||||
it("keeps author boost above unity on the per-element gain node", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, () => 0, 1, gen);
|
||||
transport.setElementVolume(mockEl, 3.98);
|
||||
|
||||
expect(mock.gainNode.gain.value).toBeCloseTo(3.98, 5);
|
||||
@@ -444,7 +447,7 @@ describe("WebAudioTransport", () => {
|
||||
it("starts in-progress clips immediately with correct buffer offset", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
|
||||
});
|
||||
@@ -452,7 +455,7 @@ describe("WebAudioTransport", () => {
|
||||
it("starts in-progress clips with mediaStart offset", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 2, 8, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 2, () => 8, 1, gen);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 5);
|
||||
});
|
||||
@@ -460,7 +463,7 @@ describe("WebAudioTransport", () => {
|
||||
it("schedules future clips with delay instead of playing immediately", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, 2, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, () => 2, 1, gen);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(108, 0);
|
||||
});
|
||||
@@ -468,7 +471,7 @@ describe("WebAudioTransport", () => {
|
||||
it("schedules future clips with correct mediaStart", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, 2, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, () => 2, 1, gen);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(108, 1.5);
|
||||
});
|
||||
@@ -476,7 +479,7 @@ describe("WebAudioTransport", () => {
|
||||
it("starts clips at exact composition start time immediately", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 5, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 5, 1, gen);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
@@ -486,21 +489,31 @@ describe("WebAudioTransport", () => {
|
||||
it("bounds an in-progress clip to its remaining authored window", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
// compStart=5, mediaStart=0, compTime=8 → elapsed=3; clipDuration=10 → 7 left
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1, 10);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 1, 10);
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 3, 7);
|
||||
});
|
||||
|
||||
it("bounds a future clip to its full authored window", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
// compStart=10, mediaStart=1.5, compTime=2 → elapsed=-8 → delay 8; clipDuration=4
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, 2, 1, gen, 1, 4);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, () => 2, 1, gen, 1, 4);
|
||||
expect(mock.startFn).toHaveBeenCalledWith(108, 1.5, 4);
|
||||
});
|
||||
|
||||
it("does not schedule a clip whose window has already elapsed", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
// elapsed=15 > clipDuration=10 → nothing to play
|
||||
const result = await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 20, 1, gen, 1, 10);
|
||||
const result = await transport.schedulePlayback(
|
||||
mockEl,
|
||||
mockBuffer,
|
||||
5,
|
||||
0,
|
||||
() => 20,
|
||||
1,
|
||||
gen,
|
||||
1,
|
||||
10,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mock.startFn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -508,13 +521,13 @@ describe("WebAudioTransport", () => {
|
||||
it("keeps source bounds in authored media time when global rate changes", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
// Global rate=2 changes wallclock speed, not source-time span.
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2, 10);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2, 10);
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 3, 7);
|
||||
});
|
||||
|
||||
it("plays unbounded when clipDuration is omitted (legacy behavior)", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen);
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
|
||||
});
|
||||
});
|
||||
@@ -530,7 +543,7 @@ describe("WebAudioTransport", () => {
|
||||
// At composition t=0.5 with mediaStart=1, authored 2x has consumed one
|
||||
// source second. Global 0.5x makes the source node's wallclock rate 1x,
|
||||
// but the composition clock must still advance at global 0.5x.
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 1, 0.5, 1, gen, 0.5, 2);
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 1, () => 0.5, 1, gen, 0.5, 2);
|
||||
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 2, 3);
|
||||
@@ -544,7 +557,7 @@ describe("WebAudioTransport", () => {
|
||||
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);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
|
||||
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(2);
|
||||
});
|
||||
@@ -552,7 +565,7 @@ describe("WebAudioTransport", () => {
|
||||
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);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen);
|
||||
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||
});
|
||||
@@ -561,7 +574,7 @@ describe("WebAudioTransport", () => {
|
||||
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);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, () => 2, 1, gen, 2);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(104, 0);
|
||||
});
|
||||
@@ -569,7 +582,7 @@ describe("WebAudioTransport", () => {
|
||||
it("keeps in-progress buffer offset at elapsed + mediaStart regardless of rate", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
|
||||
|
||||
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
|
||||
});
|
||||
@@ -577,7 +590,7 @@ describe("WebAudioTransport", () => {
|
||||
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);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 1);
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||
|
||||
transport.setRate(2);
|
||||
@@ -590,7 +603,7 @@ describe("WebAudioTransport", () => {
|
||||
// scheduled, so bumping playbackRate alone left every automated parameter
|
||||
// running its original plan over audio moving at a different speed.
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 1);
|
||||
const active = (transport as unknown as { _activeSources: { fx?: unknown }[] })
|
||||
._activeSources;
|
||||
const setRate = vi.fn();
|
||||
@@ -609,7 +622,7 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
it("setRate is a no-op when the rate is unchanged", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
|
||||
|
||||
mock.ctx.currentTime = 100.5;
|
||||
const timeBefore = transport.getTime();
|
||||
@@ -624,7 +637,7 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
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, 2);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
|
||||
expect(mock.sourceNode.playbackRate.value).toBe(2);
|
||||
|
||||
transport.setRate(Number.NaN);
|
||||
@@ -642,7 +655,7 @@ describe("WebAudioTransport", () => {
|
||||
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);
|
||||
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);
|
||||
@@ -656,7 +669,7 @@ describe("WebAudioTransport", () => {
|
||||
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);
|
||||
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
|
||||
@@ -681,7 +694,7 @@ describe("WebAudioTransport", () => {
|
||||
getAttribute: (name: string) => (name === "data-playback-rate" ? "1" : null),
|
||||
} as unknown as HTMLMediaElement;
|
||||
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, () => 0, 1, gen);
|
||||
expect(transport.ownsClock()).toBe(true);
|
||||
expect(el.muted).toBe(true);
|
||||
|
||||
@@ -698,7 +711,7 @@ describe("WebAudioTransport", () => {
|
||||
getAttribute: (name: string) => (name === "data-playback-rate" ? "1" : null),
|
||||
} as unknown as HTMLMediaElement;
|
||||
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, () => 0, 1, gen);
|
||||
expect(el.muted).toBe(true);
|
||||
|
||||
mock.sourceNode._fireEnded();
|
||||
@@ -712,7 +725,7 @@ describe("WebAudioTransport", () => {
|
||||
// already removed this entry — so the handle, its MutationObserver and any
|
||||
// running LFO survived the clip for the rest of the session.
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, () => 0, 1, gen);
|
||||
|
||||
mock.sourceNode._fireEnded();
|
||||
|
||||
@@ -723,7 +736,7 @@ describe("WebAudioTransport", () => {
|
||||
it("registers onended listener on the sourceNode", async () => {
|
||||
const { transport, mock, gen } = setupTransport(100);
|
||||
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen);
|
||||
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, () => 0, 1, gen);
|
||||
|
||||
expect(mock.sourceNode.addEventListener).toHaveBeenCalledWith("ended", expect.any(Function));
|
||||
});
|
||||
@@ -735,7 +748,7 @@ describe("WebAudioTransport", () => {
|
||||
getAttribute: (name: string) => (name === "data-playback-rate" ? "1" : null),
|
||||
} as unknown as HTMLMediaElement;
|
||||
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, () => 0, 1, gen);
|
||||
expect(el.muted).toBe(true);
|
||||
|
||||
transport.stopAll();
|
||||
@@ -774,6 +787,7 @@ describe("WebAudioTransport", () => {
|
||||
currentTime,
|
||||
state: "running",
|
||||
resume: vi.fn(),
|
||||
suspend: vi.fn(() => Promise.resolve()),
|
||||
createBufferSource: vi.fn(() => ({
|
||||
buffer: null as AudioBuffer | null,
|
||||
playbackRate: { value: 1 },
|
||||
@@ -853,7 +867,7 @@ describe("WebAudioTransport", () => {
|
||||
groupId?: string,
|
||||
): Promise<HTMLMediaElement> {
|
||||
const el = groupedAudioEl(id, groupId);
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
|
||||
await transport.schedulePlayback(el, mockBuffer, 0, 0, () => 0, 1, gen);
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,9 @@ function isBufferSource(
|
||||
return source.sourceKind === "buffer";
|
||||
}
|
||||
|
||||
// A pause to scrub or a reschedule mid-play keeps the output awake; a real idle lets it sleep.
|
||||
const IDLE_SUSPEND_MS = 10000;
|
||||
|
||||
export class WebAudioTransport {
|
||||
private _ctx: AudioContext | null = null;
|
||||
private _bufferCache = new Map<string, AudioBuffer>();
|
||||
@@ -181,10 +184,16 @@ export class WebAudioTransport {
|
||||
private _rate = 1;
|
||||
private _paused = true;
|
||||
private _playGeneration = 0;
|
||||
// A running context renders silence nonstop, so a paused transport keeps it
|
||||
// suspended unless a captured track is sounding on the idle route (scrub).
|
||||
private _playingCaptured = new Set<HTMLMediaElement>();
|
||||
private _suspendPending = false;
|
||||
private _restTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async init(): Promise<boolean> {
|
||||
try {
|
||||
this._ctx = new AudioContext();
|
||||
this._ctx.onstatechange = () => this.rest();
|
||||
this._masterGain = this._ctx.createGain();
|
||||
this._monitorGain = this._ctx.createGain();
|
||||
this._masterGain.connect(this._monitorGain);
|
||||
@@ -197,6 +206,34 @@ export class WebAudioTransport {
|
||||
}
|
||||
}
|
||||
|
||||
private async wake(): Promise<void> {
|
||||
if (!this._ctx || (this._ctx.state === "running" && !this._suspendPending)) return;
|
||||
this._suspendPending = false;
|
||||
await this._ctx.resume();
|
||||
}
|
||||
|
||||
private rest(): void {
|
||||
if (this._restTimer !== null) clearTimeout(this._restTimer);
|
||||
this._restTimer = null;
|
||||
if (!this._ctx || this._ctx.state !== "running" || this._suspendPending) return;
|
||||
this._restTimer = setTimeout(() => {
|
||||
this._restTimer = null;
|
||||
this.suspendIfIdle();
|
||||
}, IDLE_SUSPEND_MS);
|
||||
}
|
||||
|
||||
private suspendIfIdle(): void {
|
||||
// `paused` is the truth; the load algorithm clears it without a "pause" event.
|
||||
for (const el of this._playingCaptured) if (el.paused) this._playingCaptured.delete(el);
|
||||
if (!this._ctx || !this._paused || this._suspendPending || this._playingCaptured.size > 0)
|
||||
return;
|
||||
this._suspendPending = true;
|
||||
this._ctx.suspend().catch((err) => {
|
||||
this._suspendPending = false;
|
||||
swallow("webAudioTransport.suspend", err);
|
||||
});
|
||||
}
|
||||
|
||||
get context(): AudioContext | null {
|
||||
return this._ctx;
|
||||
}
|
||||
@@ -318,6 +355,17 @@ export class WebAudioTransport {
|
||||
}
|
||||
const sourceNode = this._ctx.createMediaElementSource(el);
|
||||
this._mediaElementSources.set(el, sourceNode);
|
||||
if (!el.paused) this._playingCaptured.add(el);
|
||||
el.addEventListener("play", () => {
|
||||
this._playingCaptured.add(el);
|
||||
void this.wake();
|
||||
});
|
||||
const stopped = () => {
|
||||
this._playingCaptured.delete(el);
|
||||
this.rest();
|
||||
};
|
||||
el.addEventListener("pause", stopped);
|
||||
el.addEventListener("emptied", stopped);
|
||||
return sourceNode;
|
||||
}
|
||||
|
||||
@@ -340,7 +388,7 @@ export class WebAudioTransport {
|
||||
if (generation !== this._playGeneration) return null;
|
||||
|
||||
try {
|
||||
if (this._ctx.state === "suspended") await this._ctx.resume();
|
||||
await this.wake();
|
||||
if (generation !== this._playGeneration) return null;
|
||||
|
||||
const sourceNode = this.acquireMediaElementSource(el);
|
||||
@@ -614,7 +662,7 @@ export class WebAudioTransport {
|
||||
buffer: AudioBuffer,
|
||||
compositionStart: number,
|
||||
mediaStart: number,
|
||||
compositionTime: number,
|
||||
readCompositionTime: () => number,
|
||||
volume: number,
|
||||
generation: number,
|
||||
rate = 1,
|
||||
@@ -624,10 +672,10 @@ export class WebAudioTransport {
|
||||
if (generation !== this._playGeneration) return null;
|
||||
|
||||
try {
|
||||
if (this._ctx.state === "suspended") {
|
||||
await this._ctx.resume();
|
||||
}
|
||||
await this.wake();
|
||||
if (generation !== this._playGeneration) return null;
|
||||
// Read after the wake: the clock kept running while the context resumed.
|
||||
const compositionTime = readCompositionTime();
|
||||
|
||||
const safeRate = normalizeRate(rate);
|
||||
const mediaRate = readElementPlaybackRate(el);
|
||||
@@ -780,6 +828,7 @@ export class WebAudioTransport {
|
||||
this._activeSources = [];
|
||||
this._paused = true;
|
||||
this._playGeneration += 1;
|
||||
this.rest();
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
@@ -844,6 +893,10 @@ export class WebAudioTransport {
|
||||
this._bufferCache.clear();
|
||||
this._failedSrcs.clear();
|
||||
this._mediaElementSources = new WeakMap();
|
||||
this._playingCaptured.clear();
|
||||
this._suspendPending = false;
|
||||
if (this._restTimer !== null) clearTimeout(this._restTimer);
|
||||
this._restTimer = null;
|
||||
if (this._ctx) {
|
||||
try {
|
||||
void this._ctx.close();
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
|
||||
function makeNode() {
|
||||
return {
|
||||
gain: { value: 1 },
|
||||
connect(dest: unknown) {
|
||||
return dest;
|
||||
},
|
||||
disconnect() {},
|
||||
};
|
||||
}
|
||||
|
||||
let resumeGate: Promise<void> | null = null;
|
||||
let suspendGate: Promise<void> | null = null;
|
||||
let suspendFails = false;
|
||||
let startsLate = false;
|
||||
const contexts: FakeAudioContext[] = [];
|
||||
|
||||
class FakeAudioContext {
|
||||
state: "running" | "suspended" | "closed" = startsLate ? "suspended" : "running";
|
||||
currentTime = 0;
|
||||
destination = makeNode();
|
||||
onstatechange: (() => void) | null = null;
|
||||
// Chrome fires "statechange" after construction and on every transition.
|
||||
constructor() {
|
||||
contexts.push(this);
|
||||
queueMicrotask(() => {
|
||||
this.state = "running";
|
||||
this.onstatechange?.();
|
||||
});
|
||||
}
|
||||
createGain() {
|
||||
return makeNode();
|
||||
}
|
||||
createMediaElementSource() {
|
||||
return makeNode();
|
||||
}
|
||||
createBufferSource() {
|
||||
return {
|
||||
...makeNode(),
|
||||
buffer: null,
|
||||
playbackRate: { value: 1 },
|
||||
start() {},
|
||||
stop() {},
|
||||
addEventListener() {},
|
||||
};
|
||||
}
|
||||
suspends = 0;
|
||||
// Chrome applies suspend() and resume() in call order.
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private enqueue(gate: Promise<void> | null, next: "running" | "suspended") {
|
||||
this.queue = this.queue
|
||||
.then(() => gate)
|
||||
.then(() => {
|
||||
this.state = next;
|
||||
this.onstatechange?.();
|
||||
});
|
||||
return this.queue;
|
||||
}
|
||||
suspend() {
|
||||
this.suspends += 1;
|
||||
if (suspendFails) return Promise.reject(new Error("closed"));
|
||||
return this.enqueue(suspendGate, "suspended");
|
||||
}
|
||||
resume() {
|
||||
return this.enqueue(resumeGate, "running");
|
||||
}
|
||||
close() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const idle = () => vi.advanceTimersByTimeAsync(20000);
|
||||
const originalAudioContext = (globalThis as Record<string, unknown>).AudioContext;
|
||||
|
||||
async function startTransport() {
|
||||
const transport = new WebAudioTransport();
|
||||
await transport.init();
|
||||
await idle();
|
||||
return { transport, ctx: contexts[contexts.length - 1]! };
|
||||
}
|
||||
|
||||
function makeTrack(): HTMLAudioElement {
|
||||
const el = document.createElement("audio");
|
||||
el.src = "/track.mp3";
|
||||
document.body.append(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function setTrackPlaying(el: HTMLAudioElement, playing: boolean) {
|
||||
Object.defineProperty(el, "paused", { configurable: true, get: () => !playing });
|
||||
el.dispatchEvent(new Event(playing ? "play" : "pause"));
|
||||
}
|
||||
|
||||
const play = (transport: WebAudioTransport, el: HTMLMediaElement) =>
|
||||
transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, transport.startGeneration());
|
||||
|
||||
describe("WebAudioTransport keeps its context suspended while nothing sounds", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as Record<string, unknown>).AudioContext = FakeAudioContext;
|
||||
resumeGate = null;
|
||||
suspendGate = null;
|
||||
suspendFails = false;
|
||||
startsLate = false;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
(globalThis as Record<string, unknown>).AudioContext = originalAudioContext;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("suspends a freshly opened context while the transport is paused", async () => {
|
||||
const { ctx } = await startTransport();
|
||||
expect(ctx.state).toBe("suspended");
|
||||
});
|
||||
|
||||
it("suspends a context the browser starts only after construction", async () => {
|
||||
startsLate = true;
|
||||
const { ctx } = await startTransport();
|
||||
expect(ctx.state).toBe("suspended");
|
||||
});
|
||||
|
||||
it("resumes for Play and suspends again on Pause", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
expect(await play(transport, makeTrack())).not.toBeNull();
|
||||
expect(ctx.state).toBe("running");
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
expect(ctx.state).toBe("suspended");
|
||||
});
|
||||
|
||||
it("resumes for a Play that lands while the Pause's suspend is still pending", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
const el = makeTrack();
|
||||
await play(transport, el);
|
||||
let release!: () => void;
|
||||
suspendGate = new Promise((resolve) => (release = resolve));
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
const pending = play(transport, el);
|
||||
release();
|
||||
expect(await pending).not.toBeNull();
|
||||
await idle();
|
||||
expect(ctx.state).toBe("running");
|
||||
});
|
||||
|
||||
it("never suspends for a stop-and-reschedule inside one play", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
const el = makeTrack();
|
||||
await play(transport, el);
|
||||
const suspendsBefore = ctx.suspends;
|
||||
transport.stopAll();
|
||||
await play(transport, el);
|
||||
await idle();
|
||||
expect(ctx.suspends).toBe(suspendsBefore);
|
||||
expect(ctx.state).toBe("running");
|
||||
});
|
||||
|
||||
it("goes back to sleep when Pause lands while Play waits on resume", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
let release!: () => void;
|
||||
resumeGate = new Promise((resolve) => (release = resolve));
|
||||
const pending = play(transport, makeTrack());
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
release();
|
||||
expect(await pending).toBeNull();
|
||||
await idle();
|
||||
expect(ctx.state).toBe("suspended");
|
||||
});
|
||||
|
||||
it("ends up running when a track pauses while Play waits on a slow resume()", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
const el = makeTrack();
|
||||
await play(transport, el);
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
let release!: () => void;
|
||||
resumeGate = new Promise((resolve) => (release = resolve));
|
||||
const pending = play(transport, el);
|
||||
setTrackPlaying(el, false);
|
||||
await idle();
|
||||
release();
|
||||
expect(await pending).not.toBeNull();
|
||||
await idle();
|
||||
expect(ctx.state).toBe("running");
|
||||
});
|
||||
|
||||
it("stays running while a captured track sounds on the paused idle route", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
const el = makeTrack();
|
||||
await play(transport, el);
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
setTrackPlaying(el, true);
|
||||
await idle();
|
||||
expect(ctx.state).toBe("running");
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
expect(ctx.state).toBe("running");
|
||||
setTrackPlaying(el, false);
|
||||
await idle();
|
||||
expect(ctx.state).toBe("suspended");
|
||||
});
|
||||
it("waits the full idle delay before suspending", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
await play(transport, makeTrack());
|
||||
const suspendsBefore = ctx.suspends;
|
||||
transport.stopAll();
|
||||
await vi.advanceTimersByTimeAsync(9999);
|
||||
expect(ctx.suspends).toBe(suspendsBefore);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(ctx.suspends).toBe(suspendsBefore + 1);
|
||||
});
|
||||
|
||||
it("drops its idle timer when the browser suspends the context itself", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
await play(transport, makeTrack());
|
||||
transport.stopAll();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
// An interruption outside the transport (an OS or WebKit audio session) suspends the context.
|
||||
ctx.state = "suspended";
|
||||
ctx.onstatechange?.();
|
||||
let release!: () => void;
|
||||
resumeGate = new Promise((resolve) => (release = resolve));
|
||||
const playing = play(transport, makeTrack());
|
||||
await vi.advanceTimersByTimeAsync(9000);
|
||||
release();
|
||||
await playing;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(ctx.state).toBe("running");
|
||||
});
|
||||
|
||||
it("anchors a decoded Play to the time read after the wake", async () => {
|
||||
const { transport } = await startTransport();
|
||||
let now = 10;
|
||||
let release!: () => void;
|
||||
resumeGate = new Promise((resolve) => (release = resolve));
|
||||
const gen = transport.startGeneration();
|
||||
const pending = transport.schedulePlayback(
|
||||
makeTrack(),
|
||||
{} as AudioBuffer,
|
||||
0,
|
||||
0,
|
||||
() => now,
|
||||
1,
|
||||
gen,
|
||||
);
|
||||
now = 10.25;
|
||||
release();
|
||||
expect(await pending).not.toBeNull();
|
||||
expect(transport.getTime()).toBeCloseTo(10.25, 6);
|
||||
});
|
||||
|
||||
it("keeps no reference to a captured track once it stops playing", async () => {
|
||||
const { transport } = await startTransport();
|
||||
const el = makeTrack();
|
||||
await play(transport, el);
|
||||
transport.stopAll();
|
||||
setTrackPlaying(el, true);
|
||||
setTrackPlaying(el, false);
|
||||
el.remove();
|
||||
const holders = Object.values(transport).filter((v) => v instanceof Set && v.has(el));
|
||||
expect(holders).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("suspends a context opened by a second init() after destroy()", async () => {
|
||||
const { transport } = await startTransport();
|
||||
transport.destroy();
|
||||
await transport.init();
|
||||
await idle();
|
||||
expect(contexts[contexts.length - 1]!.state).toBe("suspended");
|
||||
});
|
||||
|
||||
it("tries again on the next Pause after a suspend that failed", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
const el = makeTrack();
|
||||
await play(transport, el);
|
||||
suspendFails = true;
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
expect(ctx.state).toBe("running");
|
||||
suspendFails = false;
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
expect(ctx.state).toBe("suspended");
|
||||
});
|
||||
it("suspends on the next Pause after a playing track stopped with no pause event", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
const el = makeTrack();
|
||||
await play(transport, el);
|
||||
transport.stopAll();
|
||||
setTrackPlaying(el, true);
|
||||
await idle();
|
||||
// The load algorithm (el.load(), a src swap) clears playback without firing "pause".
|
||||
Object.defineProperty(el, "paused", { configurable: true, get: () => true });
|
||||
transport.stopAll();
|
||||
await idle();
|
||||
expect(ctx.state).toBe("suspended");
|
||||
});
|
||||
|
||||
it("suspends after a playing track is emptied by a reload, with no further Pause", async () => {
|
||||
const { transport, ctx } = await startTransport();
|
||||
const el = makeTrack();
|
||||
await play(transport, el);
|
||||
transport.stopAll();
|
||||
setTrackPlaying(el, true);
|
||||
await idle();
|
||||
Object.defineProperty(el, "paused", { configurable: true, get: () => true });
|
||||
el.dispatchEvent(new Event("emptied"));
|
||||
await idle();
|
||||
expect(ctx.state).toBe("suspended");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user