diff --git a/src/utils/notificationSounds.test.ts b/src/utils/notificationSounds.test.ts new file mode 100644 index 000000000..e3fb19560 --- /dev/null +++ b/src/utils/notificationSounds.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type MockAudioContextState = "running" | "suspended" | "closed"; + +describe("playNotificationSound", () => { + const originalAudioContext = window.AudioContext; + const originalWebkitAudioContext = ( + window as typeof window & { webkitAudioContext?: typeof AudioContext } + ).webkitAudioContext; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + window.AudioContext = originalAudioContext; + ( + window as typeof window & { webkitAudioContext?: typeof AudioContext } + ).webkitAudioContext = originalWebkitAudioContext; + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + function installAudioMocks(state: MockAudioContextState = "running") { + const source = { + buffer: null as AudioBuffer | null, + connect: vi.fn(), + start: vi.fn(), + }; + const gainNode = { + gain: { value: 0 }, + connect: vi.fn(), + }; + const decodeAudioData = vi.fn().mockResolvedValue({} as AudioBuffer); + const resume = vi.fn().mockResolvedValue(undefined); + + class MockAudioContext { + state = state; + destination = {} as AudioNode; + decodeAudioData = decodeAudioData; + createBufferSource = vi.fn(() => source); + createGain = vi.fn(() => gainNode); + resume = resume; + } + + window.AudioContext = MockAudioContext as unknown as typeof AudioContext; + + return { decodeAudioData, gainNode, source, resume }; + } + + it("plays notification audio via Web Audio API", async () => { + const { decodeAudioData, gainNode, source } = installAudioMocks("running"); + const arrayBuffer = new ArrayBuffer(8); + globalThis.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(arrayBuffer), + } as unknown as Response); + + const { playNotificationSound } = await import("./notificationSounds"); + + playNotificationSound("https://example.com/success.mp3", "success"); + await vi.waitFor(() => { + expect(source.start).toHaveBeenCalledTimes(1); + }); + + expect(globalThis.fetch).toHaveBeenCalledWith("https://example.com/success.mp3"); + expect(decodeAudioData).toHaveBeenCalledWith(arrayBuffer); + expect(gainNode.gain.value).toBe(0.05); + }); + + it("logs debug information when fetch fails", async () => { + installAudioMocks("running"); + const onDebug = vi.fn(); + globalThis.fetch = vi.fn().mockRejectedValue(new Error("network")); + const { playNotificationSound } = await import("./notificationSounds"); + + playNotificationSound("https://example.com/fail.mp3", "error", onDebug); + + await vi.waitFor(() => { + expect(onDebug).toHaveBeenCalledWith( + expect.objectContaining({ + label: "audio/error load/play error", + payload: "network", + }), + ); + }); + }); + + it("attempts to resume suspended contexts before playback", async () => { + const { resume, source } = installAudioMocks("suspended"); + globalThis.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(4)), + } as unknown as Response); + const { playNotificationSound } = await import("./notificationSounds"); + + playNotificationSound("https://example.com/test.mp3", "test"); + + await vi.waitFor(() => { + expect(source.start).toHaveBeenCalledTimes(1); + }); + expect(resume).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/notificationSounds.ts b/src/utils/notificationSounds.ts index bb6267825..ef3bd2e3a 100644 --- a/src/utils/notificationSounds.ts +++ b/src/utils/notificationSounds.ts @@ -4,33 +4,72 @@ type DebugLogger = (entry: DebugEntry) => void; type SoundLabel = "success" | "error" | "test"; +type AudioContextConstructor = new () => AudioContext; + +let audioContext: AudioContext | null = null; + +function resolveAudioContextConstructor(): AudioContextConstructor | null { + if (typeof window === "undefined") { + return null; + } + + return (window.AudioContext ?? + ( + window as typeof window & { + webkitAudioContext?: AudioContextConstructor; + } + ).webkitAudioContext ?? + null); +} + +function getAudioContext(): AudioContext { + if (audioContext && audioContext.state !== "closed") { + return audioContext; + } + + const AudioContextImpl = resolveAudioContextConstructor(); + if (!AudioContextImpl) { + throw new Error("Web Audio API is not available in this environment"); + } + + audioContext = new AudioContextImpl(); + return audioContext; +} + export function playNotificationSound( url: string, label: SoundLabel, onDebug?: DebugLogger, ) { try { - const audio = new Audio(url); - audio.volume = 0.05; - audio.preload = "auto"; - audio.addEventListener("error", () => { - onDebug?.({ - id: `${Date.now()}-audio-${label}-load-error`, - timestamp: Date.now(), - source: "error", - label: `audio/${label} load error`, - payload: `Failed to load audio: ${url}`, - }); - }); - void audio.play().catch((error) => { - onDebug?.({ - id: `${Date.now()}-audio-${label}-play-error`, - timestamp: Date.now(), - source: "error", - label: `audio/${label} play error`, - payload: error instanceof Error ? error.message : String(error), + const ctx = getAudioContext(); + + if (ctx.state === "suspended") { + void ctx.resume(); + } + + void fetch(url) + .then((response) => response.arrayBuffer()) + .then((audioFileBuffer) => ctx.decodeAudioData(audioFileBuffer)) + .then((audioBuffer) => { + const source = ctx.createBufferSource(); + const gainNode = ctx.createGain(); + + gainNode.gain.value = 0.05; + source.buffer = audioBuffer; + source.connect(gainNode); + gainNode.connect(ctx.destination); + source.start(); + }) + .catch((error) => { + onDebug?.({ + id: `${Date.now()}-audio-${label}-load-or-play-error`, + timestamp: Date.now(), + source: "error", + label: `audio/${label} load/play error`, + payload: error instanceof Error ? error.message : String(error), + }); }); - }); } catch (error) { onDebug?.({ id: `${Date.now()}-audio-${label}-init-error`,