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;