diff --git a/desktop/src/shared/ui/PoofBurstProvider.tsx b/desktop/src/shared/ui/PoofBurstProvider.tsx index b6651e5fae..b8a89647e2 100644 --- a/desktop/src/shared/ui/PoofBurstProvider.tsx +++ b/desktop/src/shared/ui/PoofBurstProvider.tsx @@ -1,5 +1,7 @@ import React, { type CSSProperties, useEffect, useRef, useState } from "react"; +import { createPoofAudioPlayer } from "./poofAudioLifecycle"; + export const POOF_TRIGGER_CLASS = "buzz-poof-trigger"; export const POOF_ORIGIN_CLASS = "buzz-poof-origin"; export const POOF_POINTER_ORIGIN_CLASS = "buzz-poof-pointer-origin"; @@ -21,6 +23,7 @@ let poofAudioContext: AudioContext | null = null; let poofAudioBuffer: AudioBuffer | null = null; let poofAudioBufferPromise: Promise | null = null; let lastPointerDownTrigger: Element | null = null; +const poofAudioPlayer = createPoofAudioPlayer(); type PoofBurst = { id: number; @@ -57,7 +60,10 @@ function getPoofOrigin(target: Element, pointer?: PoofPointer) { function getPoofAudioContext() { try { - poofAudioContext ??= new AudioContext({ latencyHint: "interactive" }); + if (!poofAudioContext) { + poofAudioContext = new AudioContext({ latencyHint: "interactive" }); + poofAudioPlayer.armIdleSuspend(poofAudioContext); + } return poofAudioContext; } catch { return null; @@ -115,24 +121,7 @@ function playPoofSound() { return; } - try { - const source = audioContext.createBufferSource(); - const gain = audioContext.createGain(); - source.buffer = poofAudioBuffer; - gain.gain.value = 0.34; - source.connect(gain); - gain.connect(audioContext.destination); - if (audioContext.state === "suspended") { - void audioContext.resume().then( - () => source.start(), - () => playFallbackPoofSound(), - ); - } else { - source.start(); - } - } catch { - playFallbackPoofSound(); - } + poofAudioPlayer.play(audioContext, poofAudioBuffer, playFallbackPoofSound); } export function PoofBurstProvider({ children }: { children: React.ReactNode }) { diff --git a/desktop/src/shared/ui/poofAudioLifecycle.test.mjs b/desktop/src/shared/ui/poofAudioLifecycle.test.mjs new file mode 100644 index 0000000000..2ac29711b4 --- /dev/null +++ b/desktop/src/shared/ui/poofAudioLifecycle.test.mjs @@ -0,0 +1,296 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createPoofAudioPlayer } from "./poofAudioLifecycle.ts"; + +function createScheduler() { + let nextId = 1; + const callbacks = new Map(); + + return { + clearTimeout(id) { + callbacks.delete(id); + }, + pendingCount() { + return callbacks.size; + }, + runAll() { + const pending = [...callbacks.values()]; + callbacks.clear(); + for (const callback of pending) callback(); + }, + setTimeout(callback) { + const id = nextId; + nextId += 1; + callbacks.set(id, callback); + return id; + }, + }; +} + +function createAudioHarness({ + deferSuspend = false, + initialState = "running", + resumeError = null, + startError = null, +} = {}) { + const calls = { + fallback: 0, + resume: 0, + suspend: 0, + }; + const sources = []; + const gains = []; + let state = initialState; + let resolveDeferredSuspend = null; + const deferredSuspend = deferSuspend + ? new Promise((resolve) => { + resolveDeferredSuspend = () => { + state = "suspended"; + resolve(); + }; + }) + : null; + + const context = { + createBufferSource() { + const source = { + buffer: null, + connectedTo: null, + disconnectCalls: 0, + onended: null, + startCalls: 0, + startError, + connect(target) { + this.connectedTo = target; + }, + disconnect() { + this.disconnectCalls += 1; + }, + finish() { + this.onended?.(); + }, + start() { + this.startCalls += 1; + if (this.startError) throw this.startError; + }, + }; + sources.push(source); + return source; + }, + createGain() { + const gain = { + connectedTo: null, + disconnectCalls: 0, + gain: { value: 0 }, + connect(target) { + this.connectedTo = target; + }, + disconnect() { + this.disconnectCalls += 1; + }, + }; + gains.push(gain); + return gain; + }, + destination: {}, + resume() { + calls.resume += 1; + if (resumeError) return Promise.reject(resumeError); + state = "running"; + return Promise.resolve(); + }, + get state() { + return state; + }, + suspend() { + calls.suspend += 1; + if (deferredSuspend) return deferredSuspend; + state = "suspended"; + return Promise.resolve(); + }, + }; + + return { + buffer: {}, + calls, + context, + fallback() { + calls.fallback += 1; + }, + gains, + resolveSuspend() { + resolveDeferredSuspend?.(); + }, + sources, + }; +} + +function createPlayer(scheduler) { + return createPoofAudioPlayer({ + clearTimeout: scheduler.clearTimeout, + idleDelayMs: 1_500, + setTimeout: scheduler.setTimeout, + }); +} + +test("suspends an idle context that starts running without playback", async () => { + const scheduler = createScheduler(); + const player = createPlayer(scheduler); + let state = "suspended"; + let stateChange = null; + let suspendCalls = 0; + const context = { + addEventListener(event, listener) { + if (event === "statechange") stateChange = listener; + }, + get state() { + return state; + }, + suspend() { + suspendCalls += 1; + state = "suspended"; + return Promise.resolve(); + }, + }; + + player.armIdleSuspend(context); + assert.equal(scheduler.pendingCount(), 0); + + state = "running"; + stateChange(); + assert.equal(scheduler.pendingCount(), 1); + + scheduler.runAll(); + await Promise.resolve(); + assert.equal(suspendCalls, 1); +}); + +test("disconnects finished source and gain nodes, then suspends when idle", async () => { + const scheduler = createScheduler(); + const audio = createAudioHarness(); + const player = createPlayer(scheduler); + + player.play(audio.context, audio.buffer, audio.fallback); + audio.sources[0].finish(); + + assert.equal(audio.sources[0].disconnectCalls, 1); + assert.equal(audio.gains[0].disconnectCalls, 1); + assert.equal(scheduler.pendingCount(), 1); + assert.equal(audio.calls.suspend, 0); + + scheduler.runAll(); + await Promise.resolve(); + assert.equal(audio.calls.suspend, 1); +}); + +test("waits for every overlapping playback before scheduling suspension", async () => { + const scheduler = createScheduler(); + const audio = createAudioHarness(); + const player = createPlayer(scheduler); + + player.play(audio.context, audio.buffer, audio.fallback); + player.play(audio.context, audio.buffer, audio.fallback); + + audio.sources[0].finish(); + assert.equal(scheduler.pendingCount(), 0); + + audio.sources[1].finish(); + assert.equal(scheduler.pendingCount(), 1); + scheduler.runAll(); + await Promise.resolve(); + assert.equal(audio.calls.suspend, 1); +}); + +test("new playback cancels the pending idle suspension", async () => { + const scheduler = createScheduler(); + const audio = createAudioHarness(); + const player = createPlayer(scheduler); + + player.play(audio.context, audio.buffer, audio.fallback); + audio.sources[0].finish(); + assert.equal(scheduler.pendingCount(), 1); + + player.play(audio.context, audio.buffer, audio.fallback); + assert.equal(scheduler.pendingCount(), 0); + + audio.sources[1].finish(); + scheduler.runAll(); + await Promise.resolve(); + assert.equal(audio.calls.suspend, 1); +}); + +test("stale suspend completion resumes a newer active playback", async () => { + const scheduler = createScheduler(); + const audio = createAudioHarness({ deferSuspend: true }); + const player = createPlayer(scheduler); + + player.play(audio.context, audio.buffer, audio.fallback); + audio.sources[0].finish(); + scheduler.runAll(); + assert.equal(audio.calls.suspend, 1); + + player.play(audio.context, audio.buffer, audio.fallback); + assert.equal(audio.sources[1].startCalls, 0); + + audio.resolveSuspend(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(audio.calls.resume, 1); + assert.equal(audio.sources[1].startCalls, 1); + assert.equal(audio.context.state, "running"); +}); + +test("graph creation failure re-arms idle suspension after cancelling it", async () => { + const scheduler = createScheduler(); + const audio = createAudioHarness(); + const player = createPlayer(scheduler); + + player.play(audio.context, audio.buffer, audio.fallback); + audio.sources[0].finish(); + assert.equal(scheduler.pendingCount(), 1); + + audio.context.createGain = () => { + throw new Error("gain creation failed"); + }; + player.play(audio.context, audio.buffer, audio.fallback); + + assert.equal(audio.calls.fallback, 1); + assert.equal(audio.sources[1].disconnectCalls, 1); + assert.equal(scheduler.pendingCount(), 1); + + scheduler.runAll(); + await Promise.resolve(); + assert.equal(audio.calls.suspend, 1); +}); + +test("resume failure cleans up the graph and falls back", async () => { + const scheduler = createScheduler(); + const audio = createAudioHarness({ + initialState: "suspended", + resumeError: new Error("resume blocked"), + }); + const player = createPlayer(scheduler); + + player.play(audio.context, audio.buffer, audio.fallback); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(audio.calls.resume, 1); + assert.equal(audio.calls.fallback, 1); + assert.equal(audio.sources[0].startCalls, 0); + assert.equal(audio.sources[0].disconnectCalls, 1); + assert.equal(audio.gains[0].disconnectCalls, 1); +}); + +test("start failure cleans up the graph and falls back", () => { + const scheduler = createScheduler(); + const audio = createAudioHarness({ startError: new Error("start failed") }); + const player = createPlayer(scheduler); + + player.play(audio.context, audio.buffer, audio.fallback); + + assert.equal(audio.calls.fallback, 1); + assert.equal(audio.sources[0].disconnectCalls, 1); + assert.equal(audio.gains[0].disconnectCalls, 1); +}); diff --git a/desktop/src/shared/ui/poofAudioLifecycle.ts b/desktop/src/shared/ui/poofAudioLifecycle.ts new file mode 100644 index 0000000000..2af2952d40 --- /dev/null +++ b/desktop/src/shared/ui/poofAudioLifecycle.ts @@ -0,0 +1,149 @@ +const DEFAULT_IDLE_DELAY_MS = 1_500; +const POOF_GAIN = 0.34; + +type TimerHandle = ReturnType; + +type PoofAudioPlayerOptions = { + idleDelayMs?: number; + setTimeout?: (callback: () => void, delayMs: number) => TimerHandle; + clearTimeout?: (handle: TimerHandle) => void; +}; + +function disconnectQuietly(node: { disconnect: () => void } | null) { + try { + node?.disconnect(); + } catch { + // The platform may already have disconnected or closed the node. + } +} + +export function createPoofAudioPlayer({ + idleDelayMs = DEFAULT_IDLE_DELAY_MS, + setTimeout: schedule = (callback, delayMs) => + globalThis.setTimeout(callback, delayMs), + clearTimeout: cancel = (handle) => globalThis.clearTimeout(handle), +}: PoofAudioPlayerOptions = {}) { + let activePlaybacks = 0; + let suspendTimer: TimerHandle | null = null; + let suspendGeneration = 0; + let pendingSuspend: Promise | null = null; + + function cancelPendingSuspend() { + suspendGeneration += 1; + if (suspendTimer !== null) { + cancel(suspendTimer); + suspendTimer = null; + } + } + + function scheduleSuspend(context: AudioContext) { + const generation = suspendGeneration + 1; + suspendGeneration = generation; + suspendTimer = schedule(() => { + suspendTimer = null; + if ( + generation !== suspendGeneration || + activePlaybacks !== 0 || + context.state !== "running" + ) { + return; + } + const request = context.suspend().then( + () => {}, + () => { + // Best-effort only: a closed or platform-rejected context is harmless. + }, + ); + pendingSuspend = request; + void request.finally(() => { + if (pendingSuspend === request) pendingSuspend = null; + }); + }, idleDelayMs); + } + + function armIdleSuspend(context: AudioContext) { + // WebKit may start an autoplay-enabled context without any playback. + const scheduleIfIdle = () => { + if (context.state !== "running" || activePlaybacks !== 0) return; + cancelPendingSuspend(); + scheduleSuspend(context); + }; + + context.addEventListener("statechange", scheduleIfIdle); + scheduleIfIdle(); + } + + function play( + context: AudioContext, + buffer: AudioBuffer, + playFallback: () => void, + ) { + cancelPendingSuspend(); + + let source: AudioBufferSourceNode | null = null; + let gain: GainNode | null = null; + try { + source = context.createBufferSource(); + gain = context.createGain(); + source.buffer = buffer; + gain.gain.value = POOF_GAIN; + source.connect(gain); + gain.connect(context.destination); + } catch { + disconnectQuietly(source); + disconnectQuietly(gain); + if (activePlaybacks === 0) scheduleSuspend(context); + playFallback(); + return; + } + + const connectedSource = source; + const connectedGain = gain; + + activePlaybacks += 1; + let cleanedUp = false; + + function cleanup() { + if (cleanedUp) return; + cleanedUp = true; + connectedSource.onended = null; + disconnectQuietly(connectedSource); + disconnectQuietly(connectedGain); + activePlaybacks = Math.max(0, activePlaybacks - 1); + if (activePlaybacks === 0) { + scheduleSuspend(context); + } + } + + connectedSource.onended = cleanup; + + function start() { + try { + connectedSource.start(); + } catch { + cleanup(); + playFallback(); + } + } + + function resumeThenStart() { + if (context.state === "running") { + start(); + return; + } + void context.resume().then(start, () => { + cleanup(); + playFallback(); + }); + } + + const suspendInFlight = pendingSuspend; + if (suspendInFlight) { + void suspendInFlight.then(resumeThenStart); + } else { + resumeThenStart(); + } + } + + return { armIdleSuspend, play }; +}