diff --git a/desktop/src/features/notifications/lib/sound.test.mjs b/desktop/src/features/notifications/lib/sound.test.mjs index dfa9c84060..ee21a504d8 100644 --- a/desktop/src/features/notifications/lib/sound.test.mjs +++ b/desktop/src/features/notifications/lib/sound.test.mjs @@ -1,7 +1,84 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { shouldPlayNotificationSound } from "./sound.ts"; +class FakeBufferSource extends EventTarget { + buffer = null; + connectedTo = null; + started = false; + stopped = false; + + connect(destination) { + this.connectedTo = destination; + } + + start() { + this.started = true; + } + + stop() { + this.stopped = true; + this.dispatchEvent(new Event("ended")); + } + + end() { + this.dispatchEvent(new Event("ended")); + } +} + +class FakeAudioContext { + static instances = []; + + destination = { id: "destination" }; + state = "running"; + sources = []; + decodeCalls = 0; + resumeCalls = 0; + + constructor(options) { + this.options = options; + FakeAudioContext.instances.push(this); + } + + async decodeAudioData(data) { + this.decodeCalls += 1; + return { data }; + } + + createBufferSource() { + const source = new FakeBufferSource(); + this.sources.push(source); + return source; + } + + async resume() { + this.resumeCalls += 1; + this.state = "running"; + } +} + +const fetchCalls = []; +globalThis.Audio = class { + constructor() { + throw new Error("notification sounds must not create HTML media elements"); + } +}; +globalThis.AudioContext = FakeAudioContext; +const successfulFetch = async (url) => { + fetchCalls.push(url); + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(4), + }; +}; +globalThis.fetch = successfulFetch; + +const { playNotificationSound, shouldPlayNotificationSound } = await import( + `./sound.ts?test=${Date.now()}` +); + +async function flushPlayback() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} test("silences notifications from Huddle backing channels", () => { const silentChannelIds = new Set(["active-huddle"]); @@ -16,3 +93,133 @@ test("silences notifications from Huddle backing channels", () => { ); assert.equal(shouldPlayNotificationSound(null, silentChannelIds), true); }); + +test("plays notification cues through Web Audio without HTML media", async () => { + const playback = playNotificationSound("flutter"); + assert.ok(playback); + + await flushPlayback(); + + assert.equal(FakeAudioContext.instances.length, 1); + const context = FakeAudioContext.instances[0]; + assert.deepEqual(context.options, { latencyHint: "interactive" }); + assert.equal(context.sources.length, 1); + assert.equal(context.sources[0].started, true); + assert.equal(context.sources[0].connectedTo, context.destination); + assert.equal(fetchCalls[0], "/sounds/flutter.mp3"); + context.sources[0].end(); +}); + +test("caches decoded buffers and replaces only the same active cue", async () => { + const context = FakeAudioContext.instances[0]; + const first = playNotificationSound("flutter"); + let firstEnded = 0; + first.onEnded(() => { + firstEnded += 1; + }); + await flushPlayback(); + const firstSource = context.sources.at(-1); + + const second = playNotificationSound("flutter"); + await flushPlayback(); + + assert.equal(firstEnded, 1); + assert.equal(firstSource.stopped, true); + assert.equal( + fetchCalls.filter((url) => url.endsWith("flutter.mp3")).length, + 1, + ); + assert.equal(context.decodeCalls, 1); + assert.notEqual(context.sources.at(-1), firstSource); + + let secondEnded = 0; + second.onEnded(() => { + secondEnded += 1; + }); + context.sources.at(-1).end(); + assert.equal(secondEnded, 1); +}); + +test("allows different notification cues to overlap", async () => { + const context = FakeAudioContext.instances[0]; + playNotificationSound("dng"); + await flushPlayback(); + const firstSource = context.sources.at(-1); + + playNotificationSound("doo"); + await flushPlayback(); + + assert.equal(firstSource.stopped, false); + assert.equal(context.sources.at(-1).started, true); + firstSource.end(); + context.sources.at(-1).end(); +}); + +test("resumes a suspended context before starting a cue", async () => { + const context = FakeAudioContext.instances[0]; + context.state = "suspended"; + + playNotificationSound("ping"); + await flushPlayback(); + + assert.equal(context.resumeCalls, 1); + assert.equal(context.sources.at(-1).started, true); +}); + +test("a stopped loading cue never starts later", async () => { + let releaseFetch; + globalThis.fetch = async (url) => { + fetchCalls.push(url); + if (url.endsWith("boo.mp3")) { + await new Promise((resolve) => { + releaseFetch = resolve; + }); + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(4), + }; + }; + + const context = FakeAudioContext.instances[0]; + const sourceCount = context.sources.length; + const playback = playNotificationSound("boo"); + playback.stop(); + releaseFetch(); + await flushPlayback(); + + assert.equal(context.sources.length, sourceCount); +}); + +test("evicts failed loads so a later playback can retry", async () => { + let attempts = 0; + globalThis.fetch = async (url) => { + fetchCalls.push(url); + if (url.endsWith("oh-no.mp3") && attempts++ === 0) { + return { ok: false, status: 503 }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(4), + }; + }; + + const context = FakeAudioContext.instances[0]; + const sourceCount = context.sources.length; + let failedEnded = 0; + playNotificationSound("oh-no").onEnded(() => { + failedEnded += 1; + }); + await flushPlayback(); + + assert.equal(failedEnded, 1); + assert.equal(context.sources.length, sourceCount); + + playNotificationSound("oh-no"); + await flushPlayback(); + + assert.equal(attempts, 2); + assert.equal(context.sources.length, sourceCount + 1); + context.sources.at(-1).end(); + globalThis.fetch = successfulFetch; +}); diff --git a/desktop/src/features/notifications/lib/sound.ts b/desktop/src/features/notifications/lib/sound.ts index 1e9ccb3839..cb9ffab21d 100644 --- a/desktop/src/features/notifications/lib/sound.ts +++ b/desktop/src/features/notifications/lib/sound.ts @@ -135,27 +135,127 @@ export function shouldPlayNotificationSound( return !channelId || !silentChannelIds?.has(channelId); } -const cache = new Map(); +const bufferCache = new Map>(); +let audioContext: AudioContext | null = null; +const activePlaybacks = new Map(); -function getAudio(name: SoundName): HTMLAudioElement { - let audio = cache.get(name); - if (!audio) { - audio = new Audio(`/sounds/${name}.mp3`); - cache.set(name, audio); - } - return audio; +export type SoundPlayback = { + stop: () => void; + onEnded: (listener: () => void) => () => void; +}; + +function getAudioContext(): AudioContext { + audioContext ??= new AudioContext({ latencyHint: "interactive" }); + return audioContext; } -export function playNotificationSound( +function getAudioBuffer( + context: AudioContext, name: SoundName, -): HTMLAudioElement | null { +): Promise { + const cached = bufferCache.get(name); + if (cached) return cached; + + const pending = fetch(`/sounds/${name}.mp3`) + .then((response) => { + if (!response.ok) { + throw new Error( + `Failed to load notification sound: ${response.status}`, + ); + } + return response.arrayBuffer(); + }) + .then((data) => context.decodeAudioData(data)) + .catch((error) => { + if (bufferCache.get(name) === pending) { + bufferCache.delete(name); + } + throw error; + }); + bufferCache.set(name, pending); + return pending; +} + +function createPlayback(): { + playback: SoundPlayback; + setSource: (source: AudioBufferSourceNode) => void; + finish: () => void; + isStopped: () => boolean; +} { + let source: AudioBufferSourceNode | null = null; + let stopped = false; + let ended = false; + const listeners = new Set<() => void>(); + + const finish = () => { + if (ended) return; + ended = true; + for (const listener of listeners) listener(); + listeners.clear(); + }; + + return { + playback: { + stop: () => { + if (stopped) return; + stopped = true; + try { + source?.stop(); + } catch { + // The source may not have started yet. + } + finish(); + }, + onEnded: (listener) => { + if (ended) { + queueMicrotask(listener); + return () => {}; + } + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + setSource: (nextSource) => { + source = nextSource; + }, + finish, + isStopped: () => stopped, + }; +} + +export function playNotificationSound(name: SoundName): SoundPlayback | null { try { - const audio = getAudio(name); - audio.currentTime = 0; - audio.play().catch(() => { - // Best-effort — user may not have interacted with the page yet. + const context = getAudioContext(); + activePlaybacks.get(name)?.stop(); + + const controller = createPlayback(); + activePlaybacks.set(name, controller.playback); + controller.playback.onEnded(() => { + if (activePlaybacks.get(name) === controller.playback) { + activePlaybacks.delete(name); + } }); - return audio; + + void (async () => { + try { + const buffer = await getAudioBuffer(context, name); + if (controller.isStopped()) return; + if (context.state === "suspended") await context.resume(); + if (controller.isStopped()) return; + + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect(context.destination); + source.addEventListener("ended", controller.finish, { once: true }); + controller.setSource(source); + source.start(); + } catch { + // Best-effort — audio can be blocked or unavailable. + controller.finish(); + } + })(); + + return controller.playback; } catch { // Best-effort only. return null; diff --git a/desktop/src/features/settings/ui/SoundPicker.tsx b/desktop/src/features/settings/ui/SoundPicker.tsx index 7f7694cfb7..4df45e9574 100644 --- a/desktop/src/features/settings/ui/SoundPicker.tsx +++ b/desktop/src/features/settings/ui/SoundPicker.tsx @@ -64,21 +64,20 @@ export function SoundPicker({ }) { const items = sortedSounds(recommended); const [isPlaying, setIsPlaying] = useState(false); - const audioRef = useRef(null); + const playbackRef = useRef>(null); function togglePreview() { if (isPlaying) { - audioRef.current?.pause(); + playbackRef.current?.stop(); setIsPlaying(false); return; } - const audio = playNotificationSound(value); - if (!audio) return; - audioRef.current = audio; + const playback = playNotificationSound(value); + if (!playback) return; + playbackRef.current = playback; setIsPlaying(true); const stop = () => setIsPlaying(false); - audio.addEventListener("ended", stop, { once: true }); - audio.addEventListener("pause", stop, { once: true }); + playback.onEnded(stop); } return ( diff --git a/desktop/src/shared/ui/PoofBurstProvider.test.mjs b/desktop/src/shared/ui/PoofBurstProvider.test.mjs new file mode 100644 index 0000000000..0eedfe375b --- /dev/null +++ b/desktop/src/shared/ui/PoofBurstProvider.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +let htmlAudioConstructions = 0; +const sources = []; + +class FakeBufferSource { + buffer = null; + started = false; + + addEventListener() {} + connect() {} + start() { + this.started = true; + } +} + +class FakeAudioContext { + destination = {}; + state = "running"; + + createBufferSource() { + const source = new FakeBufferSource(); + sources.push(source); + return source; + } + + createGain() { + return { + connect() {}, + gain: { value: 1 }, + }; + } + + async decodeAudioData() { + return { id: "poof-buffer" }; + } +} + +before(() => { + Object.assign(globalThis, { + Audio: class { + constructor() { + htmlAudioConstructions += 1; + } + }, + AudioContext: FakeAudioContext, + document: dom.window.document, + Element: dom.window.Element, + Image: dom.window.Image, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + globalThis.fetch = async () => ({ + arrayBuffer: async () => new ArrayBuffer(4), + ok: true, + }); +}); + +after(() => dom.window.close()); + +test("poof effects never create resumable HTML media", async () => { + const { createElement } = await import("react"); + const { act, fireEvent, render, waitFor } = await import( + "@testing-library/react" + ); + const { POOF_TRIGGER_CLASS, PoofBurstProvider } = await import( + "./PoofBurstProvider.tsx" + ); + + const view = render( + createElement( + PoofBurstProvider, + null, + createElement("button", { className: POOF_TRIGGER_CLASS }, "Remove"), + ), + ); + + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + assert.equal(htmlAudioConstructions, 0); + + fireEvent.click(view.getByRole("button", { name: "Remove" })); + await waitFor(() => assert.equal(sources.at(-1)?.started, true)); + assert.equal(htmlAudioConstructions, 0); + + view.unmount(); +}); diff --git a/desktop/src/shared/ui/PoofBurstProvider.tsx b/desktop/src/shared/ui/PoofBurstProvider.tsx index b6651e5fae..350e64dafb 100644 --- a/desktop/src/shared/ui/PoofBurstProvider.tsx +++ b/desktop/src/shared/ui/PoofBurstProvider.tsx @@ -16,7 +16,6 @@ const POOF_FRAMES = [ { id: "poof-5", src: "/pow/poof5@3x.png" }, ] as const; -let poofAudio: HTMLAudioElement | null = null; let poofAudioContext: AudioContext | null = null; let poofAudioBuffer: AudioBuffer | null = null; let poofAudioBufferPromise: Promise | null = null; @@ -94,24 +93,15 @@ function loadPoofAudioBuffer() { return poofAudioBufferPromise; } -function playFallbackPoofSound() { - try { - poofAudio ??= new Audio(POOF_SOUND_URL); - poofAudio.volume = 0.34; - poofAudio.currentTime = 0; - poofAudio.play().catch(() => { - // Best-effort — browsers can still block audio playback. - }); - } catch { - // Best-effort only: audio may be unavailable or blocked. - } -} - function playPoofSound() { const audioContext = getPoofAudioContext(); - if (!audioContext || !poofAudioBuffer) { - playFallbackPoofSound(); - void loadPoofAudioBuffer(); + if (!audioContext) { + return; + } + if (!poofAudioBuffer) { + void loadPoofAudioBuffer().then((audioBuffer) => { + if (audioBuffer) playPoofSound(); + }); return; } @@ -124,14 +114,22 @@ function playPoofSound() { gain.connect(audioContext.destination); if (audioContext.state === "suspended") { void audioContext.resume().then( - () => source.start(), - () => playFallbackPoofSound(), + () => { + try { + source.start(); + } catch { + // Best-effort only. + } + }, + () => { + // Best-effort only. + }, ); } else { source.start(); } } catch { - playFallbackPoofSound(); + // Best-effort only: audio may be unavailable or blocked. } } @@ -147,13 +145,6 @@ export function PoofBurstProvider({ children }: { children: React.ReactNode }) { } void loadPoofAudioBuffer(); - try { - poofAudio ??= new Audio(POOF_SOUND_URL); - poofAudio.preload = "auto"; - poofAudio.load(); - } catch { - // Best-effort only. - } }, []); useEffect(() => {