From 9dc4602964ad1e865ab57f5cd84f3753a75b9cd5 Mon Sep 17 00:00:00 2001
From: kyinhub <kevinpyin@gmail.com>
Date: Sat, 25 Jul 2026 16:12:47 -0700
Subject: [PATCH 1/3] fix(desktop): suspend idle poof audio context

Signed-off-by: kyinhub <kevinpyin@gmail.com>
---
 desktop/src/shared/ui/PoofBurstProvider.tsx   |  22 +-
 .../src/shared/ui/poofAudioLifecycle.test.mjs | 241 ++++++++++++++++++
 desktop/src/shared/ui/poofAudioLifecycle.ts   | 136 ++++++++++
 3 files changed, 381 insertions(+), 18 deletions(-)
 create mode 100644 desktop/src/shared/ui/poofAudioLifecycle.test.mjs
 create mode 100644 desktop/src/shared/ui/poofAudioLifecycle.ts

diff --git a/desktop/src/shared/ui/PoofBurstProvider.tsx b/desktop/src/shared/ui/PoofBurstProvider.tsx
index b6651e5fae..56fb10885f 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<AudioBuffer | null> | null = null;
 let lastPointerDownTrigger: Element | null = null;
+const poofAudioPlayer = createPoofAudioPlayer();
 
 type PoofBurst = {
   id: number;
@@ -115,24 +118,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..4c7bb8915f
--- /dev/null
+++ b/desktop/src/shared/ui/poofAudioLifecycle.test.mjs
@@ -0,0 +1,241 @@
+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("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("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..1bfea004f5
--- /dev/null
+++ b/desktop/src/shared/ui/poofAudioLifecycle.ts
@@ -0,0 +1,136 @@
+const DEFAULT_IDLE_DELAY_MS = 1_500;
+const POOF_GAIN = 0.34;
+
+type TimerHandle = ReturnType<typeof globalThis.setTimeout>;
+
+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<void> | 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 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);
+      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 { play };
+}

From 3675d5059c0b8d234aa2a6de455fe6480220b8c4 Mon Sep 17 00:00:00 2001
From: kyinhub <kevinpyin@gmail.com>
Date: Sat, 25 Jul 2026 16:16:56 -0700
Subject: [PATCH 2/3] fix(desktop): rearm poof suspension after fallback

Signed-off-by: kyinhub <kevinpyin@gmail.com>
---
 .../src/shared/ui/poofAudioLifecycle.test.mjs | 23 +++++++++++++++++++
 desktop/src/shared/ui/poofAudioLifecycle.ts   |  1 +
 2 files changed, 24 insertions(+)

diff --git a/desktop/src/shared/ui/poofAudioLifecycle.test.mjs b/desktop/src/shared/ui/poofAudioLifecycle.test.mjs
index 4c7bb8915f..4ad84481b3 100644
--- a/desktop/src/shared/ui/poofAudioLifecycle.test.mjs
+++ b/desktop/src/shared/ui/poofAudioLifecycle.test.mjs
@@ -209,6 +209,29 @@ test("stale suspend completion resumes a newer active playback", async () => {
   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({
diff --git a/desktop/src/shared/ui/poofAudioLifecycle.ts b/desktop/src/shared/ui/poofAudioLifecycle.ts
index 1bfea004f5..06fd5a3a35 100644
--- a/desktop/src/shared/ui/poofAudioLifecycle.ts
+++ b/desktop/src/shared/ui/poofAudioLifecycle.ts
@@ -80,6 +80,7 @@ export function createPoofAudioPlayer({
     } catch {
       disconnectQuietly(source);
       disconnectQuietly(gain);
+      if (activePlaybacks === 0) scheduleSuspend(context);
       playFallback();
       return;
     }

From 49c16271c94138fc5ecb7577870dcf8aee2a10b6 Mon Sep 17 00:00:00 2001
From: kyinhub <kevinpyin@gmail.com>
Date: Sun, 26 Jul 2026 08:51:36 -0700
Subject: [PATCH 3/3] fix(desktop): suspend auto-started poof context

Signed-off-by: Kevin Yin <182213728+yinkev@users.noreply.github.com>
---
 desktop/src/shared/ui/PoofBurstProvider.tsx   |  5 ++-
 .../src/shared/ui/poofAudioLifecycle.test.mjs | 32 +++++++++++++++++++
 desktop/src/shared/ui/poofAudioLifecycle.ts   | 14 +++++++-
 3 files changed, 49 insertions(+), 2 deletions(-)

diff --git a/desktop/src/shared/ui/PoofBurstProvider.tsx b/desktop/src/shared/ui/PoofBurstProvider.tsx
index 56fb10885f..b8a89647e2 100644
--- a/desktop/src/shared/ui/PoofBurstProvider.tsx
+++ b/desktop/src/shared/ui/PoofBurstProvider.tsx
@@ -60,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;
diff --git a/desktop/src/shared/ui/poofAudioLifecycle.test.mjs b/desktop/src/shared/ui/poofAudioLifecycle.test.mjs
index 4ad84481b3..2ac29711b4 100644
--- a/desktop/src/shared/ui/poofAudioLifecycle.test.mjs
+++ b/desktop/src/shared/ui/poofAudioLifecycle.test.mjs
@@ -134,6 +134,38 @@ function createPlayer(scheduler) {
   });
 }
 
+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();
diff --git a/desktop/src/shared/ui/poofAudioLifecycle.ts b/desktop/src/shared/ui/poofAudioLifecycle.ts
index 06fd5a3a35..2af2952d40 100644
--- a/desktop/src/shared/ui/poofAudioLifecycle.ts
+++ b/desktop/src/shared/ui/poofAudioLifecycle.ts
@@ -61,6 +61,18 @@ export function createPoofAudioPlayer({
     }, 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,
@@ -133,5 +145,5 @@ export function createPoofAudioPlayer({
     }
   }
 
-  return { play };
+  return { armIdleSuspend, play };
 }
