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
209 changes: 208 additions & 1 deletion desktop/src/features/notifications/lib/sound.test.mjs
Original file line number Diff line number Diff line change
@@ -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"]);
Expand All @@ -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;
});
130 changes: 115 additions & 15 deletions desktop/src/features/notifications/lib/sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,27 +135,127 @@ export function shouldPlayNotificationSound(
return !channelId || !silentChannelIds?.has(channelId);
}

const cache = new Map<SoundName, HTMLAudioElement>();
const bufferCache = new Map<SoundName, Promise<AudioBuffer>>();
let audioContext: AudioContext | null = null;
const activePlaybacks = new Map<SoundName, SoundPlayback>();

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<AudioBuffer> {
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;
Expand Down
13 changes: 6 additions & 7 deletions desktop/src/features/settings/ui/SoundPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,21 +64,20 @@ export function SoundPicker({
}) {
const items = sortedSounds(recommended);
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const playbackRef = useRef<ReturnType<typeof playNotificationSound>>(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 (
Expand Down
Loading