From 2a977ddec4df400b72436a1d0efd169f528feb3e Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 27 Dec 2025 20:59:48 +0200 Subject: [PATCH] sound effects --- frontend/src/hooks/useSounds.ts | 59 ++++++++++++++++++++++++ frontend/src/sounds.ts | 81 +++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 frontend/src/hooks/useSounds.ts create mode 100644 frontend/src/sounds.ts diff --git a/frontend/src/hooks/useSounds.ts b/frontend/src/hooks/useSounds.ts new file mode 100644 index 0000000..0356257 --- /dev/null +++ b/frontend/src/hooks/useSounds.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react' +import { useStore } from '../store' +import { playKeyClick, playDelete, playComplete, playHint, playWordComplete } from '../sounds' + +export function useSounds() { + const prevEntries = useRef(null) + const prevSolved = useRef(false) + const prevHints = useRef(0) + + useEffect(() => { + const unsub = useStore.subscribe((state) => { + if (!state.puzzle) return + + // letter entry or deletion + if (prevEntries.current && state.entries !== prevEntries.current) { + for (let r = 0; r < state.entries.length; r++) { + for (let c = 0; c < state.entries[r].length; c++) { + const cur = state.entries[r][c] + const old = prevEntries.current[r]?.[c] || '' + if (cur && !old) { + playKeyClick() + // word completion check + const span = state.wordSpans.find(s => + s.cells.some(([sr, sc]) => sr === r && sc === c) + ) + if (span) { + const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc]) + if (allFilled) { + const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc]) + if (!wasFilled) setTimeout(() => playWordComplete(), 50) + } + } + break + } + if (!cur && old) { + playDelete() + break + } + } + } + } + prevEntries.current = state.entries + + // solved + if (state.solved && !prevSolved.current) { + playComplete() + } + prevSolved.current = state.solved + + // hints + if (state.hintsUsed > prevHints.current) { + playHint() + } + prevHints.current = state.hintsUsed + }) + + return unsub + }, []) +} diff --git a/frontend/src/sounds.ts b/frontend/src/sounds.ts new file mode 100644 index 0000000..4156925 --- /dev/null +++ b/frontend/src/sounds.ts @@ -0,0 +1,81 @@ +import { useStore } from './store' + +let ctx: AudioContext | null = null + +function getCtx(): AudioContext | null { + if (ctx && ctx.state !== 'closed') return ctx + try { ctx = new AudioContext() } catch { return null } + return ctx +} + +function isSoundEnabled(): boolean { + return useStore.getState().narratorSettings.sounds +} + +function tone(freq: number, duration: number, type: OscillatorType = 'sine', gain = 0.15) { + if (!isSoundEnabled()) return + const ac = getCtx() + if (!ac) return + if (ac.state === 'suspended') ac.resume() + const osc = ac.createOscillator() + const g = ac.createGain() + osc.type = type + osc.frequency.value = freq + g.gain.value = gain + g.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + duration / 1000) + osc.connect(g).connect(ac.destination) + osc.start() + osc.stop(ac.currentTime + duration / 1000) +} + +export function playKeyClick() { + tone(1200, 50, 'sine', 0.08) +} + +export function playDelete() { + tone(400, 50, 'sine', 0.08) +} + +export function playComplete() { + if (!isSoundEnabled()) return + const ac = getCtx() + if (!ac) return + if (ac.state === 'suspended') ac.resume() + const notes = [523, 659, 784] + notes.forEach((freq, i) => { + const osc = ac.createOscillator() + const g = ac.createGain() + osc.type = 'sine' + osc.frequency.value = freq + g.gain.value = 0.15 + const start = ac.currentTime + i * 0.1 + g.gain.exponentialRampToValueAtTime(0.001, start + 0.15) + osc.connect(g).connect(ac.destination) + osc.start(start) + osc.stop(start + 0.15) + }) +} + +export function playHint() { + tone(660, 100, 'sine', 0.1) +} + +export function playWordComplete() { + if (!isSoundEnabled()) return + const ac = getCtx() + if (!ac) return + if (ac.state === 'suspended') ac.resume() + const notes = [587, 784] + notes.forEach((freq, i) => { + const osc = ac.createOscillator() + const g = ac.createGain() + osc.type = 'sine' + osc.frequency.value = freq + g.gain.value = 0.12 + const start = ac.currentTime + i * 0.1 + g.gain.exponentialRampToValueAtTime(0.001, start + 0.12) + osc.connect(g).connect(ac.destination) + osc.start(start) + osc.stop(start + 0.12) + }) +}