diff --git a/browser-tests/cleanup.html b/browser-tests/cleanup.html
new file mode 100644
index 00000000..f6f2df63
--- /dev/null
+++ b/browser-tests/cleanup.html
@@ -0,0 +1,48 @@
+
+
+
+
+ Cacophony native source cleanup
+
+
+
+
+
diff --git a/browser-tests/cleanup.spec.ts b/browser-tests/cleanup.spec.ts
new file mode 100644
index 00000000..72ceb43a
--- /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 OfflineAudioContext",
+ );
+ 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;