Skip to content

Commit d77ab4b

Browse files
authored
feat: switch notification playback to Web Audio API to avoid macOS media session (#442)
1 parent abddbb2 commit d77ab4b

2 files changed

Lines changed: 164 additions & 20 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// @vitest-environment jsdom
2+
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
type MockAudioContextState = "running" | "suspended" | "closed";
6+
7+
describe("playNotificationSound", () => {
8+
const originalAudioContext = window.AudioContext;
9+
const originalWebkitAudioContext = (
10+
window as typeof window & { webkitAudioContext?: typeof AudioContext }
11+
).webkitAudioContext;
12+
const originalFetch = globalThis.fetch;
13+
14+
beforeEach(() => {
15+
vi.resetModules();
16+
});
17+
18+
afterEach(() => {
19+
window.AudioContext = originalAudioContext;
20+
(
21+
window as typeof window & { webkitAudioContext?: typeof AudioContext }
22+
).webkitAudioContext = originalWebkitAudioContext;
23+
globalThis.fetch = originalFetch;
24+
vi.restoreAllMocks();
25+
});
26+
27+
function installAudioMocks(state: MockAudioContextState = "running") {
28+
const source = {
29+
buffer: null as AudioBuffer | null,
30+
connect: vi.fn(),
31+
start: vi.fn(),
32+
};
33+
const gainNode = {
34+
gain: { value: 0 },
35+
connect: vi.fn(),
36+
};
37+
const decodeAudioData = vi.fn().mockResolvedValue({} as AudioBuffer);
38+
const resume = vi.fn().mockResolvedValue(undefined);
39+
40+
class MockAudioContext {
41+
state = state;
42+
destination = {} as AudioNode;
43+
decodeAudioData = decodeAudioData;
44+
createBufferSource = vi.fn(() => source);
45+
createGain = vi.fn(() => gainNode);
46+
resume = resume;
47+
}
48+
49+
window.AudioContext = MockAudioContext as unknown as typeof AudioContext;
50+
51+
return { decodeAudioData, gainNode, source, resume };
52+
}
53+
54+
it("plays notification audio via Web Audio API", async () => {
55+
const { decodeAudioData, gainNode, source } = installAudioMocks("running");
56+
const arrayBuffer = new ArrayBuffer(8);
57+
globalThis.fetch = vi.fn().mockResolvedValue({
58+
arrayBuffer: vi.fn().mockResolvedValue(arrayBuffer),
59+
} as unknown as Response);
60+
61+
const { playNotificationSound } = await import("./notificationSounds");
62+
63+
playNotificationSound("https://example.com/success.mp3", "success");
64+
await vi.waitFor(() => {
65+
expect(source.start).toHaveBeenCalledTimes(1);
66+
});
67+
68+
expect(globalThis.fetch).toHaveBeenCalledWith("https://example.com/success.mp3");
69+
expect(decodeAudioData).toHaveBeenCalledWith(arrayBuffer);
70+
expect(gainNode.gain.value).toBe(0.05);
71+
});
72+
73+
it("logs debug information when fetch fails", async () => {
74+
installAudioMocks("running");
75+
const onDebug = vi.fn();
76+
globalThis.fetch = vi.fn().mockRejectedValue(new Error("network"));
77+
const { playNotificationSound } = await import("./notificationSounds");
78+
79+
playNotificationSound("https://example.com/fail.mp3", "error", onDebug);
80+
81+
await vi.waitFor(() => {
82+
expect(onDebug).toHaveBeenCalledWith(
83+
expect.objectContaining({
84+
label: "audio/error load/play error",
85+
payload: "network",
86+
}),
87+
);
88+
});
89+
});
90+
91+
it("attempts to resume suspended contexts before playback", async () => {
92+
const { resume, source } = installAudioMocks("suspended");
93+
globalThis.fetch = vi.fn().mockResolvedValue({
94+
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(4)),
95+
} as unknown as Response);
96+
const { playNotificationSound } = await import("./notificationSounds");
97+
98+
playNotificationSound("https://example.com/test.mp3", "test");
99+
100+
await vi.waitFor(() => {
101+
expect(source.start).toHaveBeenCalledTimes(1);
102+
});
103+
expect(resume).toHaveBeenCalledTimes(1);
104+
});
105+
});

src/utils/notificationSounds.ts

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,72 @@ type DebugLogger = (entry: DebugEntry) => void;
44

55
type SoundLabel = "success" | "error" | "test";
66

7+
type AudioContextConstructor = new () => AudioContext;
8+
9+
let audioContext: AudioContext | null = null;
10+
11+
function resolveAudioContextConstructor(): AudioContextConstructor | null {
12+
if (typeof window === "undefined") {
13+
return null;
14+
}
15+
16+
return (window.AudioContext ??
17+
(
18+
window as typeof window & {
19+
webkitAudioContext?: AudioContextConstructor;
20+
}
21+
).webkitAudioContext ??
22+
null);
23+
}
24+
25+
function getAudioContext(): AudioContext {
26+
if (audioContext && audioContext.state !== "closed") {
27+
return audioContext;
28+
}
29+
30+
const AudioContextImpl = resolveAudioContextConstructor();
31+
if (!AudioContextImpl) {
32+
throw new Error("Web Audio API is not available in this environment");
33+
}
34+
35+
audioContext = new AudioContextImpl();
36+
return audioContext;
37+
}
38+
739
export function playNotificationSound(
840
url: string,
941
label: SoundLabel,
1042
onDebug?: DebugLogger,
1143
) {
1244
try {
13-
const audio = new Audio(url);
14-
audio.volume = 0.05;
15-
audio.preload = "auto";
16-
audio.addEventListener("error", () => {
17-
onDebug?.({
18-
id: `${Date.now()}-audio-${label}-load-error`,
19-
timestamp: Date.now(),
20-
source: "error",
21-
label: `audio/${label} load error`,
22-
payload: `Failed to load audio: ${url}`,
23-
});
24-
});
25-
void audio.play().catch((error) => {
26-
onDebug?.({
27-
id: `${Date.now()}-audio-${label}-play-error`,
28-
timestamp: Date.now(),
29-
source: "error",
30-
label: `audio/${label} play error`,
31-
payload: error instanceof Error ? error.message : String(error),
45+
const ctx = getAudioContext();
46+
47+
if (ctx.state === "suspended") {
48+
void ctx.resume();
49+
}
50+
51+
void fetch(url)
52+
.then((response) => response.arrayBuffer())
53+
.then((audioFileBuffer) => ctx.decodeAudioData(audioFileBuffer))
54+
.then((audioBuffer) => {
55+
const source = ctx.createBufferSource();
56+
const gainNode = ctx.createGain();
57+
58+
gainNode.gain.value = 0.05;
59+
source.buffer = audioBuffer;
60+
source.connect(gainNode);
61+
gainNode.connect(ctx.destination);
62+
source.start();
63+
})
64+
.catch((error) => {
65+
onDebug?.({
66+
id: `${Date.now()}-audio-${label}-load-or-play-error`,
67+
timestamp: Date.now(),
68+
source: "error",
69+
label: `audio/${label} load/play error`,
70+
payload: error instanceof Error ? error.message : String(error),
71+
});
3272
});
33-
});
3473
} catch (error) {
3574
onDebug?.({
3675
id: `${Date.now()}-audio-${label}-init-error`,

0 commit comments

Comments
 (0)