refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Web Audio engine for HUD sound effects.
|
||||
*
|
||||
* Uses AudioBufferSourceNode for low-latency, overlapping playback (HTMLAudio
|
||||
* can't overlap the same source and stalls when triggered rapidly). Adds a
|
||||
* small pitch jitter on each play so repeated sounds don't feel robotic — the
|
||||
* trick games use to keep keypress chirps from grating.
|
||||
*/
|
||||
|
||||
import { logError } from "@/lib/errors";
|
||||
|
||||
type EngineOptions = {
|
||||
/** Master volume 0..1 applied on top of per-call volume. */
|
||||
masterVolume: number;
|
||||
/** When false, all play() calls are no-ops. */
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type PlayOptions = {
|
||||
/** 0..1, multiplied with master volume. */
|
||||
volume?: number;
|
||||
/** ±fraction of playbackRate jitter; 0.05 = ±5%. */
|
||||
pitchVariance?: number;
|
||||
/** Skip if the same sound was played within this many ms. Prevents audible doubling on rapid triggers. */
|
||||
throttleMs?: number;
|
||||
};
|
||||
|
||||
class SoundEffectsEngine {
|
||||
private ctx: AudioContext | null = null;
|
||||
private masterGain: GainNode | null = null;
|
||||
private limiter: DynamicsCompressorNode | null = null;
|
||||
private buffers = new Map<string, AudioBuffer>();
|
||||
private loading = new Map<string, Promise<AudioBuffer | null>>();
|
||||
private lastPlayedAt = new Map<string, number>();
|
||||
private options: EngineOptions = { masterVolume: 0.02, enabled: true };
|
||||
|
||||
setOptions(next: Partial<EngineOptions>) {
|
||||
this.options = { ...this.options, ...next };
|
||||
if (this.masterGain) {
|
||||
this.masterGain.gain.value = this.options.masterVolume;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy AudioContext init. Browsers (and Electron in some configs) start it suspended until a user gesture. */
|
||||
private getContext(): AudioContext | null {
|
||||
if (this.ctx) return this.ctx;
|
||||
try {
|
||||
const Ctor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
this.ctx = new Ctor();
|
||||
this.masterGain = this.ctx.createGain();
|
||||
this.masterGain.gain.value = this.options.masterVolume;
|
||||
|
||||
// Brickwall limiter: caps peaks regardless of system volume so the
|
||||
// click stays controlled when the OS volume is cranked. Threshold sets
|
||||
// the ceiling; high ratio + fast attack make it a hard limiter.
|
||||
this.limiter = this.ctx.createDynamicsCompressor();
|
||||
this.limiter.threshold.value = -24; // dB ceiling for peaks
|
||||
this.limiter.knee.value = 0;
|
||||
this.limiter.ratio.value = 20;
|
||||
this.limiter.attack.value = 0.001;
|
||||
this.limiter.release.value = 0.08;
|
||||
|
||||
this.masterGain.connect(this.limiter);
|
||||
this.limiter.connect(this.ctx.destination);
|
||||
return this.ctx;
|
||||
} catch (err) {
|
||||
logError(err, { scope: "soundEffects.createContext" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a sound by name from a URL. Safe to call multiple times — caches.
|
||||
* Missing/failed loads resolve to null and the sound silently no-ops on play.
|
||||
*/
|
||||
preload(name: string, url: string): Promise<AudioBuffer | null> {
|
||||
if (this.buffers.has(name)) {
|
||||
return Promise.resolve(this.buffers.get(name)!);
|
||||
}
|
||||
const existing = this.loading.get(name);
|
||||
if (existing) return existing;
|
||||
|
||||
const ctx = this.getContext();
|
||||
if (!ctx) return Promise.resolve(null);
|
||||
|
||||
const promise = fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`);
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((data) => ctx.decodeAudioData(data))
|
||||
.then((buffer) => {
|
||||
this.buffers.set(name, buffer);
|
||||
return buffer;
|
||||
})
|
||||
.catch((err) => {
|
||||
logError(err, { scope: "soundEffects.preload", name });
|
||||
return null;
|
||||
});
|
||||
|
||||
this.loading.set(name, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
play(name: string, opts: PlayOptions = {}) {
|
||||
if (!this.options.enabled) return;
|
||||
|
||||
const buffer = this.buffers.get(name);
|
||||
if (!buffer) return; // silently skip if not loaded
|
||||
|
||||
const ctx = this.ctx;
|
||||
const master = this.masterGain;
|
||||
if (!ctx || !master) return;
|
||||
|
||||
// Resume suspended context (autoplay policies). Resume is async but
|
||||
// start(0) is queued correctly once the context resumes.
|
||||
if (ctx.state === "suspended") {
|
||||
void ctx.resume().catch((err) =>
|
||||
logError(err, { scope: "soundEffects.resume" }),
|
||||
);
|
||||
}
|
||||
|
||||
const throttleMs = opts.throttleMs ?? 15;
|
||||
const now = ctx.currentTime * 1000;
|
||||
const last = this.lastPlayedAt.get(name) ?? -Infinity;
|
||||
if (now - last < throttleMs) return;
|
||||
this.lastPlayedAt.set(name, now);
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const variance = opts.pitchVariance ?? 0.04;
|
||||
if (variance > 0) {
|
||||
// Centered around 1.0; e.g. variance 0.04 → 0.96..1.04
|
||||
const jitter = 1 + (Math.random() * 2 - 1) * variance;
|
||||
source.playbackRate.value = jitter;
|
||||
}
|
||||
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = opts.volume ?? 1;
|
||||
|
||||
source.connect(gain).connect(master);
|
||||
source.start(0);
|
||||
}
|
||||
}
|
||||
|
||||
export const soundEffects = new SoundEffectsEngine();
|
||||
export type { PlayOptions };
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from "react";
|
||||
import { preloadAllSounds, playSound } from "./sounds";
|
||||
|
||||
/**
|
||||
* Mounts global HUD sound behavior:
|
||||
* - Preloads every registered sound file once.
|
||||
* - Plays "click" on any click whose target (or ancestor) is recognizably
|
||||
* clickable: <button>, <a>, role="button"/etc., or `cursor: pointer`.
|
||||
* Centralizing here means div/span clickables get sound automatically
|
||||
* without touching every call site.
|
||||
* - Plays "key-tap" on keydown OUTSIDE text inputs, so navigating menus and
|
||||
* overlays feels tactile but typing into compose stays silent.
|
||||
*/
|
||||
export function SoundEffectsProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
preloadAllSounds();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return; // left click only
|
||||
if (!(e.target instanceof Element)) return;
|
||||
if (isClickable(e.target)) playSound("click");
|
||||
};
|
||||
document.addEventListener("click", handler);
|
||||
return () => document.removeEventListener("click", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.repeat) return;
|
||||
if (isPureModifier(e.key)) return;
|
||||
if (isTextInputTarget(e.target)) return;
|
||||
|
||||
if (e.key === "Escape" || e.key === "Enter" || e.key === "Tab") {
|
||||
playSound("key-action");
|
||||
} else {
|
||||
playSound("key-tap");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
const CLICKABLE_ROLES = new Set([
|
||||
"button",
|
||||
"link",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"tab",
|
||||
"switch",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"option",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Walks up to MAX_DEPTH ancestors looking for a recognizably-clickable element.
|
||||
* Tag/role checks are cheap — getComputedStyle is the fallback for div-style
|
||||
* clickables that only signal intent through `cursor: pointer`.
|
||||
*/
|
||||
function isClickable(target: Element): boolean {
|
||||
const MAX_DEPTH = 6;
|
||||
let el: Element | null = target;
|
||||
for (let depth = 0; el && depth < MAX_DEPTH; depth++, el = el.parentElement) {
|
||||
const tag = el.tagName;
|
||||
if (tag === "BUTTON" || tag === "A" || tag === "SUMMARY") return true;
|
||||
const role = el.getAttribute("role");
|
||||
if (role && CLICKABLE_ROLES.has(role)) return true;
|
||||
if (window.getComputedStyle(el).cursor === "pointer") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPureModifier(key: string): boolean {
|
||||
return key === "Shift" || key === "Control" || key === "Meta" || key === "Alt";
|
||||
}
|
||||
|
||||
function isTextInputTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === "TEXTAREA") return true;
|
||||
if (tag === "INPUT") {
|
||||
const type = (target as HTMLInputElement).type;
|
||||
return (
|
||||
type === "" ||
|
||||
type === "text" ||
|
||||
type === "search" ||
|
||||
type === "email" ||
|
||||
type === "url" ||
|
||||
type === "password" ||
|
||||
type === "tel" ||
|
||||
type === "number"
|
||||
);
|
||||
}
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Sound registry. To add a new effect, drop the file under
|
||||
* `js/assets/sounds/` and add a line to REGISTERED below.
|
||||
*
|
||||
* Names referenced from code that aren't in REGISTERED fall back to "click",
|
||||
* so the system feels alive end-to-end with one asset and stays consistent
|
||||
* as more are added.
|
||||
*/
|
||||
|
||||
import clickUrl from "../../../assets/sounds/click.mp3";
|
||||
import { soundEffects, type PlayOptions } from "./engine";
|
||||
|
||||
/** Effect names referenced from code. Unmapped names fall back to "click". */
|
||||
export type SoundName =
|
||||
| "click"
|
||||
| "key-tap"
|
||||
| "key-action"
|
||||
| "submit"
|
||||
| "error"
|
||||
| "open"
|
||||
| "close";
|
||||
|
||||
/** Files that exist on disk. Add a line when you drop a new file. */
|
||||
const REGISTERED: Partial<Record<SoundName, string>> = {
|
||||
click: clickUrl,
|
||||
};
|
||||
|
||||
/** Per-sound default play options. Tuned for HUD feel — subtle and slightly varied. */
|
||||
const SOUND_DEFAULTS: Record<SoundName, PlayOptions> = {
|
||||
click: { volume: 0.7, pitchVariance: 0.04 },
|
||||
"key-tap": { volume: 0.4, pitchVariance: 0.08, throttleMs: 25 },
|
||||
"key-action": { volume: 0.6, pitchVariance: 0.04 },
|
||||
submit: { volume: 0.85, pitchVariance: 0.02 },
|
||||
error: { volume: 0.7, pitchVariance: 0 },
|
||||
open: { volume: 0.6, pitchVariance: 0.03 },
|
||||
close: { volume: 0.5, pitchVariance: 0.03 },
|
||||
};
|
||||
|
||||
const FALLBACK: SoundName = "click";
|
||||
|
||||
let preloaded = false;
|
||||
|
||||
/** Preload every registered sound file. Idempotent. Called once at app start. */
|
||||
export function preloadAllSounds(): void {
|
||||
if (preloaded) return;
|
||||
preloaded = true;
|
||||
for (const [name, url] of Object.entries(REGISTERED)) {
|
||||
if (url) void soundEffects.preload(name, url);
|
||||
}
|
||||
}
|
||||
|
||||
/** Play a sound by name. Falls back to "click" if no dedicated file is registered. */
|
||||
export function playSound(name: SoundName, override?: PlayOptions) {
|
||||
const defaults = SOUND_DEFAULTS[name];
|
||||
const resolvedName = REGISTERED[name] ? name : FALLBACK;
|
||||
soundEffects.play(resolvedName, { ...defaults, ...override });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCallback } from "react";
|
||||
import { playSound, type SoundName } from "./sounds";
|
||||
import type { PlayOptions } from "./engine";
|
||||
|
||||
/**
|
||||
* Returns a stable `play(name, opts?)` function. The engine respects the
|
||||
* global enabled/volume state from the sound effects store, so callers don't
|
||||
* need to subscribe themselves.
|
||||
*/
|
||||
export function useSoundEffect() {
|
||||
const play = useCallback((name: SoundName, opts?: PlayOptions) => {
|
||||
playSound(name, opts);
|
||||
}, []);
|
||||
return { play };
|
||||
}
|
||||
Reference in New Issue
Block a user