c81b60a06d
* feat(web): add opt-in notification sound for waiting-for-input Play a configurable per-device sound when a session starts waiting for user input. Sounds are synthesized with the Web Audio API (no binary assets), follow the same suppression rules as desktop notifications, and work independently of the browser Notification permission. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): drop stale sound plays when AudioContext resume is delayed Scheduling notes on a suspended context queued every pending alert and played them stacked once the user finally interacted. Schedule only after resume() fulfills and drop plays older than 2s. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): harden notification sound playback edge cases Catch AudioContext construction failures so a broken audio stack cannot abort the WS handler before the desktop notification is shown, route interrupted (iOS) contexts through the resume path, and coalesce plays queued while the context is not running so only one pending cue fires. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): make sound test stubs constructible and refine resume queue vi.fn with an arrow implementation is not constructible, so the AudioContext stubs silently misbehaved on CI's Node runtime. Also keep the newest pending play instead of dropping later requests, recheck the preference before a resumed waiting-for-input cue fires, and validate picker values with the existing type guard. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
270 lines
8.6 KiB
TypeScript
270 lines
8.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
function makeFakeAudioContext() {
|
|
const oscillator = {
|
|
type: "",
|
|
frequency: { value: 0 },
|
|
connect: vi.fn(),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
};
|
|
const gain = {
|
|
gain: {
|
|
setValueAtTime: vi.fn(),
|
|
linearRampToValueAtTime: vi.fn(),
|
|
exponentialRampToValueAtTime: vi.fn(),
|
|
},
|
|
connect: vi.fn(),
|
|
};
|
|
const ctx = {
|
|
currentTime: 0,
|
|
state: "running",
|
|
destination: {},
|
|
resume: vi.fn().mockResolvedValue(undefined),
|
|
createOscillator: vi.fn(() => oscillator),
|
|
createGain: vi.fn(() => gain),
|
|
};
|
|
return { ctx, oscillator, gain };
|
|
}
|
|
|
|
async function loadSoundModule() {
|
|
return import("./sound");
|
|
}
|
|
|
|
// The stub is invoked with `new`, so the implementation must be a real
|
|
// `function` — an arrow implementation is not constructible.
|
|
function stubAudioContextCtor(implementation: () => unknown) {
|
|
const ctor = vi.fn(function (this: unknown) {
|
|
return implementation();
|
|
});
|
|
vi.stubGlobal("AudioContext", ctor);
|
|
return ctor;
|
|
}
|
|
|
|
describe("sound preferences", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it("defaults to disabled with the plim preset", async () => {
|
|
const { getSoundPreferences } = await loadSoundModule();
|
|
expect(getSoundPreferences()).toEqual({ enabled: false, presetId: "plim" });
|
|
});
|
|
|
|
it("round-trips preferences through localStorage", async () => {
|
|
const { getSoundPreferences, setSoundPreferences } = await loadSoundModule();
|
|
setSoundPreferences({ enabled: true, presetId: "chime" });
|
|
expect(getSoundPreferences()).toEqual({ enabled: true, presetId: "chime" });
|
|
});
|
|
|
|
it("falls back to the default preset for unknown stored ids", async () => {
|
|
const { getSoundPreferences } = await loadSoundModule();
|
|
window.localStorage.setItem(
|
|
"kandev.notifications.sound",
|
|
JSON.stringify({ enabled: true, presetId: "airhorn" }),
|
|
);
|
|
expect(getSoundPreferences()).toEqual({ enabled: true, presetId: "plim" });
|
|
});
|
|
|
|
it("ignores corrupted stored values", async () => {
|
|
const { getSoundPreferences } = await loadSoundModule();
|
|
window.localStorage.setItem("kandev.notifications.sound", "not-json");
|
|
expect(getSoundPreferences()).toEqual({ enabled: false, presetId: "plim" });
|
|
});
|
|
});
|
|
|
|
describe("playSoundPreset", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("schedules one oscillator per note of the preset", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
|
|
|
|
playSoundPreset("chime");
|
|
|
|
const chime = SOUND_PRESETS.find((p) => p.id === "chime")!;
|
|
expect(ctx.createOscillator).toHaveBeenCalledTimes(chime.notes.length);
|
|
});
|
|
|
|
it("falls back to the first preset for unknown ids", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
|
|
|
|
playSoundPreset("nope");
|
|
|
|
expect(ctx.createOscillator).toHaveBeenCalledTimes(SOUND_PRESETS[0].notes.length);
|
|
});
|
|
|
|
it("does not throw when AudioContext construction fails", async () => {
|
|
stubAudioContextCtor(() => {
|
|
throw new Error("audio stack broken");
|
|
});
|
|
const { playSoundPreset } = await loadSoundModule();
|
|
|
|
expect(() => playSoundPreset("plim")).not.toThrow();
|
|
});
|
|
|
|
it("reuses a single AudioContext across plays", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
const ctor = stubAudioContextCtor(() => ctx);
|
|
const { playSoundPreset } = await loadSoundModule();
|
|
|
|
playSoundPreset("plim");
|
|
playSoundPreset("ding");
|
|
|
|
expect(ctor).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is a no-op when AudioContext is unavailable", async () => {
|
|
const { playSoundPreset } = await loadSoundModule();
|
|
expect(() => playSoundPreset("plim")).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("playSoundPreset with a non-running context", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("plays after a suspended context resumes promptly", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
ctx.state = "suspended";
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
|
|
|
|
playSoundPreset("plim");
|
|
expect(ctx.resume).toHaveBeenCalled();
|
|
expect(ctx.createOscillator).not.toHaveBeenCalled();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const plim = SOUND_PRESETS.find((p) => p.id === "plim")!;
|
|
expect(ctx.createOscillator).toHaveBeenCalledTimes(plim.notes.length);
|
|
});
|
|
|
|
it("drops the play when a suspended context resumes too late", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
ctx.state = "suspended";
|
|
let resolveResume!: () => void;
|
|
ctx.resume = vi.fn(() => new Promise<void>((resolve) => (resolveResume = resolve)));
|
|
stubAudioContextCtor(() => ctx);
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0);
|
|
const { playSoundPreset } = await loadSoundModule();
|
|
|
|
playSoundPreset("plim");
|
|
nowSpy.mockReturnValue(60_000);
|
|
resolveResume();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(ctx.createOscillator).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("coalesces plays queued while the context is not running", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
ctx.state = "suspended";
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
|
|
|
|
playSoundPreset("plim");
|
|
playSoundPreset("plim");
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(ctx.resume).toHaveBeenCalledTimes(1);
|
|
const plim = SOUND_PRESETS.find((p) => p.id === "plim")!;
|
|
expect(ctx.createOscillator).toHaveBeenCalledTimes(plim.notes.length);
|
|
});
|
|
|
|
it("keeps only the newest request while a resume is in flight", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
ctx.state = "suspended";
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
|
|
|
|
playSoundPreset("plim");
|
|
playSoundPreset("ding");
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(ctx.resume).toHaveBeenCalledTimes(1);
|
|
const ding = SOUND_PRESETS.find((p) => p.id === "ding")!;
|
|
expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length);
|
|
});
|
|
|
|
it("resumes an interrupted context before playing", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
ctx.state = "interrupted";
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
|
|
|
|
playSoundPreset("ding");
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const ding = SOUND_PRESETS.find((p) => p.id === "ding")!;
|
|
expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length);
|
|
});
|
|
});
|
|
|
|
describe("playWaitingForInputSound", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("does not play a resumed cue when sound was disabled while waiting", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
ctx.state = "suspended";
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playWaitingForInputSound, setSoundPreferences } = await loadSoundModule();
|
|
setSoundPreferences({ enabled: true, presetId: "plim" });
|
|
|
|
playWaitingForInputSound();
|
|
setSoundPreferences({ enabled: false, presetId: "plim" });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(ctx.resume).toHaveBeenCalled();
|
|
expect(ctx.createOscillator).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not play when sound is disabled (default)", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playWaitingForInputSound } = await loadSoundModule();
|
|
|
|
playWaitingForInputSound();
|
|
|
|
expect(ctx.createOscillator).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("plays the configured preset when enabled", async () => {
|
|
const { ctx } = makeFakeAudioContext();
|
|
stubAudioContextCtor(() => ctx);
|
|
const { playWaitingForInputSound, setSoundPreferences, SOUND_PRESETS } =
|
|
await loadSoundModule();
|
|
setSoundPreferences({ enabled: true, presetId: "ding" });
|
|
|
|
playWaitingForInputSound();
|
|
|
|
const ding = SOUND_PRESETS.find((p) => p.id === "ding")!;
|
|
expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length);
|
|
});
|
|
});
|