Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions browser-tests/cleanup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Cacophony native source cleanup</title>
</head>
<body>
<script type="module">
import { Cacophony } from "/src/cacophony.ts";

window.runCleanupCheck = async (scheduled) => {
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 {
const buffer = context.createBuffer(2, context.sampleRate * 6, context.sampleRate);
const sound = await cacophony.createSound(buffer, "buffer", "stereo");
const [voice] = sound.play({
at: context.currentTime + (scheduled ? 60 : 0),
loopCount: "infinite",
});
const source = voice.source;
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); }),
]);
return { supported: true, terminated, bufferPreserved: sound.buffer === buffer };
} finally {
clearTimeout(timer);
}
};
</script>
</body>
</html>
25 changes: 25 additions & 0 deletions browser-tests/cleanup.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
62 changes: 62 additions & 0 deletions src/playback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/playback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading