diff --git a/public/audio/ui-notify.mp3 b/public/audio/ui-notify.mp3 deleted file mode 100644 index 326d3fa533..0000000000 Binary files a/public/audio/ui-notify.mp3 and /dev/null differ diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx index 3850268653..c44cdc85a0 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button, Card, Toggle } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; @@ -23,6 +23,66 @@ const DEFAULTS: ModelLockoutSettings = { useExponentialBackoff: true, }; +type WebkitAudioWindow = Window & { + webkitAudioContext?: typeof AudioContext; +}; + +function scheduleNotifyChime(context: AudioContext): void { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + const startsAt = context.currentTime; + const endsAt = startsAt + 0.1; + + // A short, locally synthesized tone avoids shipping a third-party audio asset. + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(880, startsAt); + gain.gain.setValueAtTime(0.0001, startsAt); + gain.gain.exponentialRampToValueAtTime(0.045, startsAt + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + 0.09); + + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.onended = () => { + oscillator.disconnect(); + gain.disconnect(); + }; + oscillator.start(startsAt); + oscillator.stop(endsAt); +} + +function playNotifyChime(contextRef: { current: AudioContext | null }): void { + try { + if (typeof window === "undefined") return; + + const AudioContextConstructor = + window.AudioContext ?? (window as WebkitAudioWindow).webkitAudioContext; + if (!AudioContextConstructor) return; + + if (!contextRef.current || contextRef.current.state === "closed") { + contextRef.current = new AudioContextConstructor(); + } + + const context = contextRef.current; + if (context.state !== "running") { + void context + .resume() + .then(() => { + try { + scheduleNotifyChime(context); + } catch { + // Sound is optional and must never block a settings change. + } + }) + .catch(() => undefined); + return; + } + + scheduleNotifyChime(context); + } catch { + // Sound is optional and must never block a settings change. + } +} + function NumberField({ label, value, @@ -71,6 +131,7 @@ export default function ModelLockoutCard() { const t = useTranslations("settings"); const tc = useTranslations("common"); const notify = useNotificationStore(); + const notifyAudioContextRef = useRef(null); const [data, setData] = useState(DEFAULTS); const [draft, setDraft] = useState(DEFAULTS); @@ -78,6 +139,21 @@ export default function ModelLockoutCard() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + useEffect( + () => () => { + const context = notifyAudioContextRef.current; + notifyAudioContextRef.current = null; + if (context && context.state !== "closed") { + try { + void context.close().catch(() => undefined); + } catch { + // Sound cleanup is optional and must never block the page from unmounting. + } + } + }, + [] + ); + useEffect(() => { let mounted = true; @@ -254,22 +330,6 @@ export default function ModelLockoutCard() { return `${ms}ms`; }; - const notifyRef = useRef(null); - const playNotify = useCallback(() => { - try { - if (notifyRef.current) { - notifyRef.current.pause(); - notifyRef.current.currentTime = 0; - } else { - notifyRef.current = new Audio("/audio/ui-notify.mp3"); - notifyRef.current.volume = 0.3; - } - void notifyRef.current.play(); - } catch { - // Audio is optional. - } - }, []); - if (loading) { return ( @@ -314,7 +374,7 @@ export default function ModelLockoutCard() { checked={draft.enabled} onChange={(checked) => { setDraft((prev) => ({ ...prev, enabled: checked })); - playNotify(); + playNotifyChime(notifyAudioContextRef); }} label={t("modelLockoutEnabled")} description={t("modelLockoutEnabledDescription")} @@ -443,7 +503,7 @@ export default function ModelLockoutCard() { ...prev, useExponentialBackoff: checked, })); - playNotify(); + playNotifyChime(notifyAudioContextRef); }} label={t("modelLockoutExponentialBackoff")} description={t("modelLockoutExponentialBackoffDescription")} diff --git a/tests/unit/ui/model-lockout-notify-chime.test.tsx b/tests/unit/ui/model-lockout-notify-chime.test.tsx new file mode 100644 index 0000000000..98cb9c8752 --- /dev/null +++ b/tests/unit/ui/model-lockout-notify-chime.test.tsx @@ -0,0 +1,252 @@ +// @vitest-environment jsdom +import fs from "node:fs"; +import path from "node:path"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const translate = (key: string) => key; +const notifications = { + error: vi.fn(), + success: vi.fn(), +}; + +vi.mock("next-intl", () => ({ + useTranslations: () => translate, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => notifications, +})); + +import ModelLockoutCard from "../../../src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard"; + +type OscillatorMock = { + connect: ReturnType; + disconnect: ReturnType; + frequency: { setValueAtTime: ReturnType }; + onended: (() => void) | null; + start: ReturnType; + stop: ReturnType; + type: OscillatorType; +}; + +function createAudioContextMock( + options: { resumeRejects?: boolean; state?: AudioContextState } = {} +) { + const contexts: AudioContextMock[] = []; + const oscillators: OscillatorMock[] = []; + const gains: Array<{ + connect: ReturnType; + disconnect: ReturnType; + gain: { + cancelScheduledValues: ReturnType; + exponentialRampToValueAtTime: ReturnType; + setValueAtTime: ReturnType; + }; + }> = []; + + class AudioContextMock { + close = vi.fn().mockResolvedValue(undefined); + currentTime = 1; + destination = {}; + state: AudioContextState = options.state ?? "running"; + resume = options.resumeRejects + ? vi.fn().mockRejectedValue(new Error("audio resume denied")) + : vi.fn().mockResolvedValue(undefined); + + constructor() { + contexts.push(this); + } + + createOscillator() { + const oscillator: OscillatorMock = { + connect: vi.fn(), + disconnect: vi.fn(), + frequency: { setValueAtTime: vi.fn() }, + onended: null, + start: vi.fn(), + stop: vi.fn(), + type: "sine", + }; + oscillators.push(oscillator); + return oscillator; + } + + createGain() { + const gain = { + connect: vi.fn(), + disconnect: vi.fn(), + gain: { + cancelScheduledValues: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + setValueAtTime: vi.fn(), + }, + }; + gains.push(gain); + return gain; + } + } + + return { AudioContextMock, contexts, gains, oscillators }; +} + +const roots: Array<{ container: HTMLDivElement; root: Root }> = []; + +async function renderCard(): Promise<{ container: HTMLDivElement; root: Root }> { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push({ container, root }); + + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + + return { container, root }; +} + +function disposeCard(rendered: { container: HTMLDivElement; root: Root }): void { + act(() => rendered.root.unmount()); + rendered.container.remove(); + const index = roots.findIndex(({ root }) => root === rendered.root); + if (index >= 0) roots.splice(index, 1); +} + +describe("Model lockout optional notification sound", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + modelLockout: { + enabled: false, + errorCodes: [403, 404, 429, 502, 503, 504], + baseCooldownMs: 120_000, + maxCooldownMs: 1_800_000, + maxBackoffSteps: 10, + useExponentialBackoff: true, + }, + }), + { status: 200 } + ) + ) + ); + }); + + afterEach(() => { + for (const { container, root } of roots) { + act(() => root.unmount()); + container.remove(); + } + roots.length = 0; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("does not ship or reference the unprovenanced MP3", () => { + const legacyAssetName = "ui-notify.mp3"; + const legacyAssetUrl = ["/audio", legacyAssetName].join("/"); + const assetPath = path.join(process.cwd(), "public/audio", legacyAssetName); + const componentPath = path.join( + process.cwd(), + "src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx" + ); + + expect(fs.existsSync(assetPath)).toBe(false); + expect(fs.readFileSync(componentPath, "utf8")).not.toContain(legacyAssetUrl); + }); + + it("plays generated feedback for both model-lockout toggles", async () => { + const { AudioContextMock, oscillators } = createAudioContextMock(); + vi.stubGlobal("AudioContext", AudioContextMock); + const legacyAudio = vi.fn(() => ({ + currentTime: 0, + pause: vi.fn(), + play: vi.fn(), + volume: 1, + })); + vi.stubGlobal("Audio", legacyAudio); + + const { container } = await renderCard(); + const toggles = [...container.querySelectorAll('button[role="switch"]')]; + expect(toggles).toHaveLength(2); + + act(() => toggles[0]?.click()); + act(() => toggles[1]?.click()); + + expect(oscillators).toHaveLength(2); + expect(oscillators.every((oscillator) => oscillator.start.mock.calls.length === 1)).toBe(true); + expect(oscillators.every((oscillator) => oscillator.stop.mock.calls.length === 1)).toBe(true); + expect(legacyAudio).not.toHaveBeenCalled(); + }); + + it("starts the optional chime after a suspended context resumes", async () => { + const { AudioContextMock, contexts, oscillators } = createAudioContextMock({ + state: "suspended", + }); + vi.stubGlobal("AudioContext", AudioContextMock); + + const { container } = await renderCard(); + const toggle = container.querySelector('button[role="switch"]'); + await act(async () => { + toggle?.click(); + await Promise.resolve(); + }); + + expect(contexts[0]?.resume).toHaveBeenCalledOnce(); + expect(oscillators).toHaveLength(1); + }); + + it("keeps both toggles working when Web Audio is unavailable", async () => { + vi.stubGlobal("AudioContext", undefined); + + const { container } = await renderCard(); + const toggles = [...container.querySelectorAll('button[role="switch"]')]; + act(() => toggles[0]?.click()); + act(() => toggles[1]?.click()); + + expect(toggles[0]?.getAttribute("aria-checked")).toBe("true"); + expect(toggles[1]?.getAttribute("aria-checked")).toBe("false"); + }); + + it("keeps the toggle working when a suspended context cannot resume", async () => { + const { AudioContextMock, contexts, oscillators } = createAudioContextMock({ + resumeRejects: true, + state: "suspended", + }); + vi.stubGlobal("AudioContext", AudioContextMock); + + const { container } = await renderCard(); + const toggle = container.querySelector('button[role="switch"]'); + await act(async () => { + toggle?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + expect(contexts[0]?.resume).toHaveBeenCalledOnce(); + expect(oscillators).toHaveLength(0); + }); + + it("releases its audio context when the settings card unmounts", async () => { + const { AudioContextMock, contexts } = createAudioContextMock(); + vi.stubGlobal("AudioContext", AudioContextMock); + + const rendered = await renderCard(); + const toggle = rendered.container.querySelector('button[role="switch"]'); + act(() => toggle?.click()); + expect(contexts).toHaveLength(1); + + disposeCard(rendered); + + expect(contexts[0]?.close).toHaveBeenCalledOnce(); + }); +});