Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 8 additions & 19 deletions desktop/src/shared/ui/PoofBurstProvider.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }) {
Expand Down
296 changes: 296 additions & 0 deletions desktop/src/shared/ui/poofAudioLifecycle.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading