From 38ab1f1a2db1222e5a90b9f985ee2f1b8d02afc5 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:00:40 +0400 Subject: [PATCH] feat: interface sounds --- e2e/lib/app.mjs | 3 + e2e/tests/ui.test.mjs | 18 ++ package.json | 3 +- src-tauri/src/settings_manager.rs | 11 + src/components/client-providers.tsx | 9 + src/components/settings-dialog.tsx | 25 ++ src/i18n/locales/de.json | 4 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/ko.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/tr.json | 4 +- src/i18n/locales/vi.json | 4 +- src/i18n/locales/zh.json | 4 +- src/lib/toast-utils.ts | 3 + src/lib/ui-sounds.test.mjs | 82 +++++++ src/lib/ui-sounds.ts | 358 ++++++++++++++++++++++++++++ 20 files changed, 544 insertions(+), 12 deletions(-) create mode 100644 src/lib/ui-sounds.test.mjs create mode 100644 src/lib/ui-sounds.ts diff --git a/e2e/lib/app.mjs b/e2e/lib/app.mjs index bdc19ce0..65c7ee96 100644 --- a/e2e/lib/app.mjs +++ b/e2e/lib/app.mjs @@ -156,6 +156,9 @@ export class AppSession { // A tip opening by itself mid-test is a modal nobody asked for; // the tips suite turns it back on for the one session that wants it. tips_auto_show: false, + // Hundreds of scripted clicks must not play through the speakers + // of the machine running the suite. + ui_sounds: false, ...this.settings, }, null, diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 9e40c7b1..219fb5a8 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -2279,6 +2279,24 @@ test("first-run onboarding stays recoverable, responsive, and platform-aware", a ); }); +test("interface sounds turn on and off in Appearance and stay as saved", async () => { + await withApp("ui-sounds-setting", async (app) => { + const sounds = async () => (await app.invoke("get_app_settings")).ui_sounds; + assert.equal(await sounds(), false, "the harness keeps the suite silent"); + await app.clickSelector('[aria-label="Settings"]'); + await app.waitForText(en.settings.appearance.soundsDescription); + const toggle = `[role="switch"][aria-label="${en.settings.appearance.sounds}"]`; + await app.clickSelector(toggle); + await saveSettings(app); + assert.equal(await sounds(), true); + await app.clickSelector(toggle); + await saveSettings(app); + assert.equal(await sounds(), false); + await app.restart(); + assert.equal(await sounds(), false, "the choice survives a restart"); + }); +}); + test("predefined theme remains rendered across navigation and restart", async () => { await withApp("ui-theme-predefined", async (app) => { await app.clickSelector('[aria-label="Settings"]'); diff --git a/package.json b/package.json index 811cb85c..2ecac0b0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "prebuild": "pnpm licenses:generate", "build": "next build", "start": "next start", - "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", + "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:ui-sounds && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", "test:themes": "node --test src/lib/themes.test.mjs", "test:tips": "node --test src/lib/tips.test.mjs", "test:window-decorations": "node --test src/lib/window-decorations.test.mjs", @@ -25,6 +25,7 @@ "test:proxy-type": "node --test src/lib/proxy-type.test.mjs", "test:proxy-first-hop-claims": "node --test src/lib/proxy-first-hop-claims.test.mjs", "test:profile-search": "node --test src/lib/profile-search.test.mjs", + "test:ui-sounds": "node --test src/lib/ui-sounds.test.mjs", "test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check", "test:xray-packaging": "node --test src-tauri/download-xray.test.mjs", "licenses:generate": "node scripts/generate-licenses.mjs", diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index d0ea2728..34f6ade1 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -239,6 +239,9 @@ pub struct AppSettings { /// byte counts only, so nothing on disk lists where a profile went. #[serde(default = "default_record_traffic_domains")] pub record_traffic_domains: bool, + /// Soft interface sounds for presses, toggles and results. + #[serde(default = "default_ui_sounds")] + pub ui_sounds: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -271,6 +274,10 @@ fn default_record_traffic_domains() -> bool { true } +fn default_ui_sounds() -> bool { + true +} + /// The automatic tip flow waits a random gap between two tips, drawn from /// this range (both ends included), so tips arrive now and then instead of on /// a fixed day and a busy day of restarts never turns into a tip per launch. @@ -313,6 +320,7 @@ impl Default for AppSettings { require_route_for_launch: false, trash_retention_days: DEFAULT_TRASH_RETENTION_DAYS, record_traffic_domains: true, + ui_sounds: true, } } } @@ -1458,6 +1466,7 @@ mod tests { require_route_for_launch: true, trash_retention_days: 7, record_traffic_domains: false, + ui_sounds: false, }; let save_result = manager.save_settings(&test_settings); @@ -1478,6 +1487,7 @@ mod tests { assert!(loaded_settings.require_route_for_launch); assert_eq!(loaded_settings.trash_retention_days, 7); assert!(!loaded_settings.record_traffic_domains); + assert!(!loaded_settings.ui_sounds); } #[test] @@ -1486,6 +1496,7 @@ mod tests { assert!(!settings.require_route_for_launch); assert_eq!(settings.trash_retention_days, DEFAULT_TRASH_RETENTION_DAYS); assert!(settings.record_traffic_domains); + assert!(settings.ui_sounds, "sounds are on until turned off"); } #[test] diff --git a/src/components/client-providers.tsx b/src/components/client-providers.tsx index 289a4ac4..c34a17ff 100644 --- a/src/components/client-providers.tsx +++ b/src/components/client-providers.tsx @@ -1,5 +1,6 @@ "use client"; +import { invoke } from "@tauri-apps/api/core"; import { MotionConfig } from "motion/react"; import { useEffect } from "react"; import { I18nProvider } from "@/components/i18n-provider"; @@ -11,12 +12,20 @@ import { WindowDragArea } from "@/components/window-drag-area"; import { InputModalityProvider } from "@/hooks/use-input-modality"; import { LaunchActivityProvider } from "@/hooks/use-launch-activity"; import { setupLogging } from "@/lib/logger"; +import { installUiSounds, setUiSoundsEnabled } from "@/lib/ui-sounds"; export function ClientProviders({ children }: { children: React.ReactNode }) { useEffect(() => { void setupLogging(); }, []); + useEffect(() => { + void invoke<{ ui_sounds?: boolean }>("get_app_settings") + .then((settings) => setUiSoundsEnabled(settings.ui_sounds ?? true)) + .catch(() => {}); + return installUiSounds(); + }, []); + return ( diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index f4125116..16453f66 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -68,6 +68,7 @@ import { withThemeTransition, } from "@/lib/themes"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { setUiSoundsEnabled } from "@/lib/ui-sounds"; import { cn } from "@/lib/utils"; import type { SetDefaultBrowserOutcome } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -86,6 +87,7 @@ interface AppSettings { require_route_for_launch?: boolean; trash_retention_days?: number; record_traffic_domains?: boolean; + ui_sounds?: boolean; } /** The retention choices offered, in days. 0 turns the trash off. */ @@ -573,6 +575,7 @@ export function SettingsDialog({ // Update settings with any generated tokens setSettings(savedSettings); + setUiSoundsEnabled(savedSettings.ui_sounds ?? true); settingsToSave = savedSettings; setOriginalSettings(savedSettings); originalSettingsRef.current = savedSettings; @@ -847,6 +850,9 @@ export function SettingsDialog({ (originalSettings.record_traffic_domains ?? true) ? [t("settings.privacy.recordSites")] : []), + ...((settings.ui_sounds ?? true) !== (originalSettings.ui_sounds ?? true) + ? [t("settings.appearance.sounds")] + : []), ...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !== (originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) ? [t("settings.trashRetention")] @@ -1239,6 +1245,25 @@ export function SettingsDialog({ )} + +
+
+ + {t("settings.appearance.sounds")} + + + {t("settings.appearance.soundsDescription")} + +
+ { + updateSetting("ui_sounds", v === true); + }} + /> +
{/* Language Section */} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index a2fde79e..66f260a4 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -121,7 +121,9 @@ "yourOwn": "Eigenes", "light": "Hell", "dark": "Dunkel", - "system": "System" + "system": "System", + "sounds": "Oberflächenklänge", + "soundsDescription": "Leise, sanfte Klänge, wenn Sie eine Schaltfläche drücken, einen Schalter umlegen oder eine Aktion gelingt oder fehlschlägt." }, "language": { "title": "Sprache", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0dcc68aa..334805b5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -121,7 +121,9 @@ "yourOwn": "Your Own", "light": "Light", "dark": "Dark", - "system": "System" + "system": "System", + "sounds": "Interface sounds", + "soundsDescription": "Soft, quiet sounds when you press a button, flip a switch, or when an action succeeds or fails." }, "language": { "title": "Language", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0316fa9d..5685e5ea 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -121,7 +121,9 @@ "yourOwn": "Tu Propio", "light": "Claro", "dark": "Oscuro", - "system": "Sistema" + "system": "Sistema", + "sounds": "Sonidos de la interfaz", + "soundsDescription": "Sonidos suaves y bajos al pulsar un botón, cambiar un interruptor o cuando una acción sale bien o falla." }, "language": { "title": "Idioma", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3dcb5810..c8c7450f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -121,7 +121,9 @@ "yourOwn": "Le vôtre", "light": "Clair", "dark": "Sombre", - "system": "Système" + "system": "Système", + "sounds": "Sons de l'interface", + "soundsDescription": "Des sons doux et discrets quand vous appuyez sur un bouton, basculez un interrupteur, ou quand une action réussit ou échoue." }, "language": { "title": "Langue", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9d2425d7..77aa4b98 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -121,7 +121,9 @@ "yourOwn": "カスタム", "light": "ライト", "dark": "ダーク", - "system": "システム" + "system": "システム", + "sounds": "インターフェースの効果音", + "soundsDescription": "ボタンを押したとき、スイッチを切り替えたとき、操作が成功または失敗したときに、小さく柔らかい音を鳴らします。" }, "language": { "title": "言語", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 4f4afda1..64dfd5d8 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -121,7 +121,9 @@ "yourOwn": "직접 설정", "light": "라이트", "dark": "다크", - "system": "시스템" + "system": "시스템", + "sounds": "인터페이스 소리", + "soundsDescription": "버튼을 누르거나 스위치를 전환하거나 작업이 성공 또는 실패할 때 작고 부드러운 소리를 냅니다." }, "language": { "title": "언어", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9121456f..cc27a7c4 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -121,7 +121,9 @@ "yourOwn": "Seu Próprio", "light": "Claro", "dark": "Escuro", - "system": "Sistema" + "system": "Sistema", + "sounds": "Sons da interface", + "soundsDescription": "Sons suaves e baixos ao pressionar um botão, mudar uma chave ou quando uma ação dá certo ou falha." }, "language": { "title": "Idioma", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 9f49cd59..c605bf9a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -121,7 +121,9 @@ "yourOwn": "Собственная", "light": "Светлая", "dark": "Тёмная", - "system": "Системная" + "system": "Системная", + "sounds": "Звуки интерфейса", + "soundsDescription": "Тихие мягкие звуки при нажатии кнопки, переключении переключателя, а также при успехе или ошибке действия." }, "language": { "title": "Язык", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 4d6248dc..27beda0d 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -121,7 +121,9 @@ "yourOwn": "Kendi Temanız", "light": "Açık", "dark": "Koyu", - "system": "Sistem" + "system": "Sistem", + "sounds": "Arayüz sesleri", + "soundsDescription": "Bir düğmeye bastığınızda, bir anahtarı değiştirdiğinizde veya bir işlem başarılı olduğunda ya da başarısız olduğunda yumuşak ve sessiz sesler." }, "language": { "title": "Dil", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 9e8ff5ac..d983113f 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -121,7 +121,9 @@ "yourOwn": "Của riêng bạn", "light": "Sáng", "dark": "Tối", - "system": "Theo hệ thống" + "system": "Theo hệ thống", + "sounds": "Âm thanh giao diện", + "soundsDescription": "Âm thanh nhỏ, nhẹ khi bạn nhấn nút, bật tắt công tắc, hoặc khi một thao tác thành công hay thất bại." }, "language": { "title": "Ngôn ngữ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 753c6964..705b4260 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -121,7 +121,9 @@ "yourOwn": "自定义", "light": "浅色", "dark": "深色", - "system": "跟随系统" + "system": "跟随系统", + "sounds": "界面音效", + "soundsDescription": "按下按钮、切换开关,或操作成功或失败时,播放轻柔安静的声音。" }, "language": { "title": "语言", diff --git a/src/lib/toast-utils.ts b/src/lib/toast-utils.ts index 19a80b97..5e9bb0ee 100644 --- a/src/lib/toast-utils.ts +++ b/src/lib/toast-utils.ts @@ -3,6 +3,7 @@ import React from "react"; import { type ExternalToast, toast as sonnerToast } from "sonner"; import { UnifiedToast } from "@/components/custom-toast"; import i18n from "@/i18n"; +import { playUiSound } from "@/lib/ui-sounds"; interface BaseToastProps { id?: string; @@ -201,6 +202,7 @@ export function showSuccessToast( duration?: number; }, ) { + playUiSound("success"); return showToast({ type: "success", title, @@ -216,6 +218,7 @@ export function showErrorToast( duration?: number; }, ) { + playUiSound("error"); return showToast({ type: "error", title, diff --git a/src/lib/ui-sounds.test.mjs b/src/lib/ui-sounds.test.mjs new file mode 100644 index 00000000..8908be6a --- /dev/null +++ b/src/lib/ui-sounds.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + SOUND_CONTROL_SELECTOR, + SOUND_GAP_MS, + SOUNDS, + soundForControl, +} from "./ui-sounds.ts"; + +/** + * What is pinned here is "never annoying": every sound is short and quiet, + * starts without a click, can not repeat faster than its gap, and only the + * controls a person deliberately used make one. + */ + +const length = (voices) => + Math.max(...voices.map((v) => v.delay + v.attack + v.decay)); + +test("every sound is short, quiet and starts softly", () => { + for (const [name, voices] of Object.entries(SOUNDS)) { + assert.ok(voices.length > 0, name); + assert.ok(length(voices) <= 0.3, `${name} lasts ${length(voices)} s`); + for (const voice of voices) { + assert.ok(voice.peak > 0 && voice.peak <= 0.1, `${name} peak`); + assert.ok(voice.attack >= 0.001, `${name} must not start with a click`); + } + assert.ok(SOUND_GAP_MS[name] > 0, `${name} needs a gap`); + } + assert.ok(length(SOUNDS.tap) <= 0.04, "a tap is a tick, not a tone"); + assert.ok( + SOUND_GAP_MS.error >= 1000 && SOUND_GAP_MS.success >= 1000, + "a run of results makes one sound, not a burst", + ); +}); + +const control = (overrides) => ({ + role: null, + tag: "button", + checked: null, + disabled: false, + silenced: false, + ...overrides, +}); + +test("a button taps when pressed, and once only", () => { + assert.equal(soundForControl(control({}), "press", false), "tap"); + assert.equal( + soundForControl(control({}), "activate", false), + null, + "the click after a pointer press must not tap again", + ); + assert.equal(soundForControl(control({}), "activate", true), "tap"); + assert.equal( + soundForControl(control({ tag: "div", role: "option" }), "press", false), + "tap", + ); + assert.equal(soundForControl(control({ tag: "a" }), "press", false), "tap"); +}); + +test("a toggle sounds its new state once it changed", () => { + const on = control({ role: "switch", checked: "true" }); + const off = control({ role: "checkbox", checked: "false" }); + assert.equal(soundForControl(on, "press", false), null); + assert.equal(soundForControl(on, "activate", false), "toggleOn"); + assert.equal(soundForControl(off, "activate", true), "toggleOff"); +}); + +test("disabled, silenced and passive elements make no sound", () => { + assert.equal( + soundForControl(control({ disabled: true }), "press", false), + null, + ); + assert.equal( + soundForControl(control({ silenced: true }), "press", false), + null, + ); + for (const tag of ["div", "input", "span", "textarea"]) { + assert.equal(soundForControl(control({ tag }), "press", false), null, tag); + } + assert.ok(!SOUND_CONTROL_SELECTOR.includes("input")); + assert.ok(SOUND_CONTROL_SELECTOR.includes('[role="switch"]')); +}); diff --git a/src/lib/ui-sounds.ts b/src/lib/ui-sounds.ts new file mode 100644 index 00000000..6a3c3e94 --- /dev/null +++ b/src/lib/ui-sounds.ts @@ -0,0 +1,358 @@ +/** + * Interface sounds: short, quiet, synthesized at play time. Nothing is + * recorded or loaded, and a sound only plays while the window is in front. + * + * Each sound is a few voices: a sine, triangle or noise burst with a soft + * attack (no click at the start) and an exponential decay, like a small + * physical switch. Every play shifts the pitch and level a little, so a run + * of presses never sounds like a machine repeating one sample. + */ + +export type UiSound = "tap" | "toggleOn" | "toggleOff" | "success" | "error"; + +export interface SoundVoice { + wave: "sine" | "triangle" | "noise"; + /** Hz at the start. Ignored for noise. */ + frequency: number; + /** Hz at the end of the decay, for a slight glide. */ + endFrequency?: number; + /** Seconds after the sound starts. */ + delay: number; + attack: number; + decay: number; + /** Peak level before the master level. */ + peak: number; + /** A band-pass that shapes a noise burst into a soft tick. */ + bandPass?: { frequency: number; q: number }; +} + +export const SOUNDS: Record = { + tap: [ + { + wave: "sine", + frequency: 1750, + delay: 0, + attack: 0.002, + decay: 0.03, + peak: 0.07, + }, + { + wave: "noise", + frequency: 0, + delay: 0, + attack: 0.001, + decay: 0.012, + peak: 0.035, + bandPass: { frequency: 3600, q: 1.1 }, + }, + ], + toggleOn: [ + { + wave: "sine", + frequency: 1100, + delay: 0, + attack: 0.002, + decay: 0.035, + peak: 0.06, + }, + { + wave: "sine", + frequency: 1650, + delay: 0.045, + attack: 0.002, + decay: 0.05, + peak: 0.06, + }, + ], + toggleOff: [ + { + wave: "sine", + frequency: 1650, + delay: 0, + attack: 0.002, + decay: 0.035, + peak: 0.055, + }, + { + wave: "sine", + frequency: 1100, + delay: 0.045, + attack: 0.002, + decay: 0.05, + peak: 0.055, + }, + ], + success: [ + { + wave: "sine", + frequency: 880, + delay: 0, + attack: 0.004, + decay: 0.16, + peak: 0.05, + }, + { + wave: "sine", + frequency: 1318.5, + delay: 0.07, + attack: 0.004, + decay: 0.22, + peak: 0.045, + }, + ], + error: [ + { + wave: "triangle", + frequency: 330, + endFrequency: 247, + delay: 0, + attack: 0.004, + decay: 0.14, + peak: 0.08, + }, + { + wave: "triangle", + frequency: 262, + endFrequency: 196, + delay: 0.09, + attack: 0.004, + decay: 0.16, + peak: 0.06, + }, + ], +}; + +/** The shortest time between two plays of one sound, in ms. */ +export const SOUND_GAP_MS: Record = { + tap: 35, + toggleOn: 60, + toggleOff: 60, + success: 1200, + error: 1200, +}; + +/** What the sound picker needs to know about the control that was used. */ +export interface ControlInfo { + role: string | null; + tag: string; + /** `aria-checked` after the change, for switches and checkboxes. */ + checked: string | null; + disabled: boolean; + /** `data-sound="off"` on the control or a parent. */ + silenced: boolean; +} + +const PRESSABLE_ROLES = new Set([ + "button", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "option", + "tab", + "link", +]); +const TOGGLE_ROLES = new Set(["switch", "checkbox", "radio"]); + +/** + * `press` is a pointer going down: a button sounds at once, as a real one + * does. `activate` is the click that follows, or a key press: a toggle sounds + * then, once it holds its new state, and a button sounds only when a key + * pressed it (a pointer already made it sound). + */ +export function soundForControl( + control: ControlInfo, + phase: "press" | "activate", + byKeyboard: boolean, +): UiSound | null { + if (control.disabled || control.silenced) return null; + const role = control.role ?? implicitRole(control.tag); + if (role && TOGGLE_ROLES.has(role)) { + if (phase !== "activate") return null; + return control.checked === "true" ? "toggleOn" : "toggleOff"; + } + if (role && PRESSABLE_ROLES.has(role)) { + if (phase === "press") return "tap"; + return byKeyboard ? "tap" : null; + } + return null; +} + +function implicitRole(tag: string): string | null { + switch (tag) { + case "button": + case "summary": + return "button"; + case "a": + return "link"; + default: + return null; + } +} + +/** The elements that can make a sound; the closest one to the event wins. */ +export const SOUND_CONTROL_SELECTOR = [ + "button", + "summary", + "a[href]", + ...[...PRESSABLE_ROLES, ...TOGGLE_ROLES].map((role) => `[role="${role}"]`), +].join(","); + +let enabled = true; +let context: AudioContext | null = null; +let output: GainNode | null = null; +let noise: AudioBuffer | null = null; +const lastPlayed = new Map(); + +export function setUiSoundsEnabled(on: boolean) { + enabled = on; +} + +function audio(): AudioContext | null { + if (context) return context; + const Context = window.AudioContext; + if (!Context) return null; + context = new Context({ latencyHint: "interactive" }); + // A gentle low-pass takes the edge off every voice; the master level keeps + // the whole set well under anything else a computer plays. + const soften = context.createBiquadFilter(); + soften.type = "lowpass"; + soften.frequency.value = 7000; + output = context.createGain(); + output.gain.value = 0.6; + output.connect(soften); + soften.connect(context.destination); + noise = context.createBuffer( + 1, + Math.ceil(context.sampleRate * 0.05), + context.sampleRate, + ); + const samples = noise.getChannelData(0); + for (let i = 0; i < samples.length; i++) samples[i] = Math.random() * 2 - 1; + return context; +} + +function schedule( + ctx: AudioContext, + voice: SoundVoice, + at: number, + pitch: number, + level: number, +) { + if (!output) return; + const start = at + voice.delay; + const end = start + voice.attack + voice.decay; + const envelope = ctx.createGain(); + envelope.gain.setValueAtTime(0, start); + envelope.gain.linearRampToValueAtTime( + voice.peak * level, + start + voice.attack, + ); + envelope.gain.exponentialRampToValueAtTime(0.0001, end); + + let source: AudioScheduledSourceNode; + if (voice.wave === "noise") { + const buffer = ctx.createBufferSource(); + buffer.buffer = noise; + source = buffer; + } else { + const oscillator = ctx.createOscillator(); + oscillator.type = voice.wave; + oscillator.frequency.setValueAtTime(voice.frequency * pitch, start); + if (voice.endFrequency) { + oscillator.frequency.exponentialRampToValueAtTime( + voice.endFrequency * pitch, + end, + ); + } + source = oscillator; + } + let last: AudioNode = source; + if (voice.bandPass) { + const band = ctx.createBiquadFilter(); + band.type = "bandpass"; + band.frequency.value = voice.bandPass.frequency * pitch; + band.Q.value = voice.bandPass.q; + last.connect(band); + last = band; + } + last.connect(envelope); + envelope.connect(output); + source.start(start); + source.stop(end + 0.02); +} + +export function playUiSound(sound: UiSound) { + if (!enabled || typeof window === "undefined") return; + if (document.visibilityState !== "visible" || !document.hasFocus()) return; + const now = performance.now(); + if ( + now - (lastPlayed.get(sound) ?? Number.NEGATIVE_INFINITY) < + SOUND_GAP_MS[sound] + ) { + return; + } + lastPlayed.set(sound, now); + try { + const ctx = audio(); + if (!ctx) return; + if (ctx.state === "suspended") void ctx.resume(); + const pitch = 1 + (Math.random() - 0.5) * 0.08; + const level = 1 + (Math.random() - 0.5) * 0.2; + const at = ctx.currentTime + 0.004; + for (const voice of SOUNDS[sound]) schedule(ctx, voice, at, pitch, level); + } catch (error) { + console.debug("UI sound not played:", error); + } +} + +function controlInfo(element: Element): ControlInfo { + return { + role: element.getAttribute("role"), + tag: element.tagName.toLowerCase(), + checked: element.getAttribute("aria-checked"), + disabled: + element.hasAttribute("disabled") || + element.getAttribute("aria-disabled") === "true" || + element.getAttribute("data-disabled") === "true", + silenced: element.closest('[data-sound="off"]') !== null, + }; +} + +/** Listen for presses and toggles across the whole window. */ +export function installUiSounds(): () => void { + const control = (target: EventTarget | null) => + target instanceof Element ? target.closest(SOUND_CONTROL_SELECTOR) : null; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + const element = control(event.target); + if (!element) return; + const sound = soundForControl(controlInfo(element), "press", false); + if (sound) playUiSound(sound); + }; + const onClick = (event: MouseEvent) => { + const element = control(event.target); + if (!element) return; + // A click with no pointer behind it (detail 0) came from Enter or Space. + const byKeyboard = event.detail === 0; + // Read a toggle's state once the app has drawn the change. + requestAnimationFrame(() => { + const sound = soundForControl( + controlInfo(element), + "activate", + byKeyboard, + ); + if (sound) playUiSound(sound); + }); + }; + + window.addEventListener("pointerdown", onPointerDown, { + capture: true, + passive: true, + }); + window.addEventListener("click", onClick, { capture: true, passive: true }); + return () => { + window.removeEventListener("pointerdown", onPointerDown, { capture: true }); + window.removeEventListener("click", onClick, { capture: true }); + }; +}