mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 14:37:30 +02:00
feat: interface sounds
This commit is contained in:
1 parent
2cc91ed250
commit
38ab1f1a2d
20 files changed
+544
-12
No files matched your search
@@ -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 (
|
||||
<I18nProvider>
|
||||
<CustomThemeProvider>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.appearance.sounds")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.appearance.soundsDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
data-slot="settings-ui-sounds"
|
||||
aria-label={t("settings.appearance.sounds")}
|
||||
checked={settings.ui_sounds ?? true}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("ui_sounds", v === true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Section */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
"yourOwn": "カスタム",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"system": "システム"
|
||||
"system": "システム",
|
||||
"sounds": "インターフェースの効果音",
|
||||
"soundsDescription": "ボタンを押したとき、スイッチを切り替えたとき、操作が成功または失敗したときに、小さく柔らかい音を鳴らします。"
|
||||
},
|
||||
"language": {
|
||||
"title": "言語",
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
"yourOwn": "직접 설정",
|
||||
"light": "라이트",
|
||||
"dark": "다크",
|
||||
"system": "시스템"
|
||||
"system": "시스템",
|
||||
"sounds": "인터페이스 소리",
|
||||
"soundsDescription": "버튼을 누르거나 스위치를 전환하거나 작업이 성공 또는 실패할 때 작고 부드러운 소리를 냅니다."
|
||||
},
|
||||
"language": {
|
||||
"title": "언어",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
"yourOwn": "Собственная",
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная",
|
||||
"system": "Системная"
|
||||
"system": "Системная",
|
||||
"sounds": "Звуки интерфейса",
|
||||
"soundsDescription": "Тихие мягкие звуки при нажатии кнопки, переключении переключателя, а также при успехе или ошибке действия."
|
||||
},
|
||||
"language": {
|
||||
"title": "Язык",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ữ",
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
"yourOwn": "自定义",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "跟随系统"
|
||||
"system": "跟随系统",
|
||||
"sounds": "界面音效",
|
||||
"soundsDescription": "按下按钮、切换开关,或操作成功或失败时,播放轻柔安静的声音。"
|
||||
},
|
||||
"language": {
|
||||
"title": "语言",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]'));
|
||||
});
|
||||
@@ -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<UiSound, SoundVoice[]> = {
|
||||
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<UiSound, number> = {
|
||||
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<UiSound, number>();
|
||||
|
||||
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 });
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user