From 44b7bbf4d25df20ebb95cb100b2df538615eaf0a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 24 Sep 2026 00:51:39 -0600 Subject: [PATCH 1/2] fix: stop native audio sources during playback cleanup --- browser-tests/cleanup.html | 40 ++++++++++++++++++++++ browser-tests/cleanup.spec.ts | 25 ++++++++++++++ src/playback.test.ts | 62 +++++++++++++++++++++++++++++++++++ src/playback.ts | 5 +++ 4 files changed, 132 insertions(+) create mode 100644 browser-tests/cleanup.html create mode 100644 browser-tests/cleanup.spec.ts diff --git a/browser-tests/cleanup.html b/browser-tests/cleanup.html new file mode 100644 index 00000000..8641ff90 --- /dev/null +++ b/browser-tests/cleanup.html @@ -0,0 +1,40 @@ + + + + + Cacophony native source cleanup + + + + + diff --git a/browser-tests/cleanup.spec.ts b/browser-tests/cleanup.spec.ts new file mode 100644 index 00000000..4cf160be --- /dev/null +++ b/browser-tests/cleanup.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; + +declare global { + interface Window { + runCleanupCheck(scheduled: boolean): Promise<{ + supported: boolean; + terminated?: boolean; + bufferPreserved?: boolean; + }>; + } +} + +for (const scheduled of [false, true]) { + test(`cleanup terminates a native ${scheduled ? "scheduled" : "immediate"} looping source`, async ({ page }) => { + await page.goto("/browser-tests/cleanup.html"); + const result = await page.evaluate((scheduled) => window.runCleanupCheck(scheduled), scheduled); + test.skip( + !result.supported && process.platform === "win32", + "The Windows WebKit build does not expose AudioContext", + ); + expect(result.supported).toBe(true); + expect(result.terminated).toBe(true); + expect(result.bufferPreserved).toBe(true); + }); +} diff --git a/src/playback.test.ts b/src/playback.test.ts index 222cd5c7..fff819dd 100644 --- a/src/playback.test.ts +++ b/src/playback.test.ts @@ -585,6 +585,68 @@ describe("Playback cloning for media-element-backed playback", () => { }); describe("Playback cleanup functionality", () => { + it.each([0, 60])("terminates a looping source scheduled at %s before disconnecting", (at) => { + const buffer = new AudioBuffer({ length: 44100, sampleRate: 44100 }); + const sound = new Sound("test-url", buffer, audioContextMock, audioContextMock.createGain()); + const [voice] = sound.play({ at, loopCount: "infinite" }); + const source = voice.source as AudioBufferSourceNode; + const order: string[] = []; + const stop = vi.spyOn(source, "stop").mockImplementation(() => { + expect(source.onended).toBeNull(); + order.push("stop"); + }); + vi.spyOn(source, "disconnect").mockImplementation(() => { + order.push("disconnect"); + }); + + voice.cleanup(); + voice.cleanup(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(order.slice(0, 2)).toEqual(["stop", "disconnect"]); + expect(() => voice.play()).toThrow("cleaned up"); + sound.cleanup(); + }); + + it.each([ + "unplayed", + "paused", + "stopped", + ] as const)("cleans a %s voice without stopping its source again", (state) => { + const buffer = new AudioBuffer({ length: 44100, sampleRate: 44100 }); + const sound = new Sound("test-url", buffer, audioContextMock, audioContextMock.createGain()); + const [voice] = sound.preplay(); + if (state !== "unplayed") { + voice.play(); + if (state === "paused") voice.pause(); + else voice.stop(); + } + const stop = vi.spyOn(voice.source as AudioBufferSourceNode, "stop"); + + voice.cleanup(); + + expect(stop).not.toHaveBeenCalled(); + expect(voice.source).toBeUndefined(); + sound.cleanup(); + }); + + it("preserves sibling voices and the Sound buffer for future playback", () => { + const buffer = new AudioBuffer({ length: 44100, sampleRate: 44100 }); + const sound = new Sound("test-url", buffer, audioContextMock, audioContextMock.createGain()); + const [first] = sound.play(); + const [sibling] = sound.play(); + const siblingStop = vi.spyOn(sibling.source as AudioBufferSourceNode, "stop"); + + first.cleanup(); + + expect(siblingStop).not.toHaveBeenCalled(); + expect(sibling.isPlaying).toBe(true); + const [next] = sound.play(); + expect((next.source as AudioBufferSourceNode).buffer).toBe(buffer); + expect(next.isPlaying).toBe(true); + sound.cleanup(); + }); + let playback: Playback; let buffer: AudioBuffer; let source: AudioBufferSourceNode; diff --git a/src/playback.ts b/src/playback.ts index 13381055..1f83f963 100644 --- a/src/playback.ts +++ b/src/playback.ts @@ -569,6 +569,11 @@ export class Playback extends BasePlayback implements BaseSound { mediaElement.load(); } else if ("onended" in this.source) { this.source.onended = null; + // Disconnecting alone leaves a started source running, including native loops. + // Scheduled starts also have playing state; unplayed/paused/stopped sources do not need stop(). + if ("stop" in this.source && this._state === "playing") { + this.source.stop(); + } } this._offset = 0; this._startTime = 0; From 9bb784f7d8449d54e9ad0f0d65738515b33227d0 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 24 Sep 2026 01:00:16 -0600 Subject: [PATCH 2/2] test: render cleanup regressions without an audio device --- browser-tests/cleanup.html | 18 +++++++++++++----- browser-tests/cleanup.spec.ts | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/browser-tests/cleanup.html b/browser-tests/cleanup.html index 8641ff90..f6f2df63 100644 --- a/browser-tests/cleanup.html +++ b/browser-tests/cleanup.html @@ -9,12 +9,17 @@ import { Cacophony } from "/src/cacophony.ts"; window.runCleanupCheck = async (scheduled) => { - if (typeof AudioContext === "undefined") return { supported: false }; - const cacophony = new Cacophony(undefined, undefined, { autoUnlock: false, quiet: true }); - const context = cacophony.context; + if (typeof OfflineAudioContext === "undefined") return { supported: false }; + const sampleRate = 48_000; + const length = 4_800; + const context = new OfflineAudioContext(2, length, sampleRate); + const cacophony = Cacophony.createOffline( + { context, length, numberOfChannels: 2, sampleRate }, + undefined, + { autoUnlock: false, quiet: true }, + ); let timer; try { - await context.resume(); const buffer = context.createBuffer(2, context.sampleRate * 6, context.sampleRate); const sound = await cacophony.createSound(buffer, "buffer", "stereo"); const [voice] = sound.play({ @@ -25,6 +30,10 @@ const ended = new Promise((resolve) => source.addEventListener("ended", () => resolve(true), { once: true })); // Exercise the client-facing Sound.cleanup cascade, keeping the asset alive. sound.cleanup(); + // Pull the silent source during offline rendering: WebKit does not process + // disconnected sources. Reconnecting must not revive a terminated voice. + source.connect(context.destination); + await context.startRendering(); const terminated = await Promise.race([ ended, new Promise((resolve) => { timer = setTimeout(() => resolve(false), 3000); }), @@ -32,7 +41,6 @@ return { supported: true, terminated, bufferPreserved: sound.buffer === buffer }; } finally { clearTimeout(timer); - await context.close(); } }; diff --git a/browser-tests/cleanup.spec.ts b/browser-tests/cleanup.spec.ts index 4cf160be..72ceb43a 100644 --- a/browser-tests/cleanup.spec.ts +++ b/browser-tests/cleanup.spec.ts @@ -16,7 +16,7 @@ for (const scheduled of [false, true]) { const result = await page.evaluate((scheduled) => window.runCleanupCheck(scheduled), scheduled); test.skip( !result.supported && process.platform === "win32", - "The Windows WebKit build does not expose AudioContext", + "The Windows WebKit build does not expose OfflineAudioContext", ); expect(result.supported).toBe(true); expect(result.terminated).toBe(true);