diff --git a/packages/core/src/runtime/capturedMediaIdleRoute.test.ts b/packages/core/src/runtime/capturedMediaIdleRoute.test.ts index f9748a80f3..d0dd8289ae 100644 --- a/packages/core/src/runtime/capturedMediaIdleRoute.test.ts +++ b/packages/core/src/runtime/capturedMediaIdleRoute.test.ts @@ -50,6 +50,9 @@ class GraphAudioContext { resume() { return pendingResume ?? Promise.resolve(); } + suspend() { + return Promise.resolve(); + } close() { return Promise.resolve(); } diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 3f8bbfe4d9..0dc5664a55 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -4311,6 +4311,9 @@ describe("initSandboxRuntimeModular", () => { resume() { return Promise.resolve(); } + suspend() { + return Promise.resolve(); + } createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} }; } diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bcfaa2ef5e..da94a6aa87 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4366,7 +4366,7 @@ export function initSandboxRuntimeModular(): void { buffer, compStart, mediaStart, - clock.now(), + () => clock.now(), vol, gen, state.playbackRate, diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 7829c82db4..2c01d95f9f 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -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 { 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; } diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index c37616d1d3..52766f4a7e 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -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(); @@ -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(); + private _suspendPending = false; + private _restTimer: ReturnType | null = null; async init(): Promise { 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 { + 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(); diff --git a/packages/core/src/runtime/webAudioTransportIdle.test.ts b/packages/core/src/runtime/webAudioTransportIdle.test.ts new file mode 100644 index 0000000000..a50adee9ef --- /dev/null +++ b/packages/core/src/runtime/webAudioTransportIdle.test.ts @@ -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 | null = null; +let suspendGate: Promise | 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 = Promise.resolve(); + private enqueue(gate: Promise | 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).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).AudioContext = FakeAudioContext; + resumeGate = null; + suspendGate = null; + suspendFails = false; + startsLate = false; + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + (globalThis as Record).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"); + }); +});