fuzzyworld

nuts / files / src / sound.ts

sound.ts

3.7 KB · history · blame

1// Sound effects — Kenney Impact Sounds (CC0 1.0)
2// Uses Web Audio API with pre-decoded AudioBuffers so multiple sounds can
3// play simultaneously (polyphonic). HTMLAudio would serialize on mobile.
4import { CONFIG } from './config'
5
6export type ImpactKind =
7 | 'nut'
8 | 'gem'
9 | 'metalnut'
10 | 'rock'
11 | 'stressball'
12 | 'balloon'
13 | 'balloon_pop'
14 | 'coin'
15
16const BASE: Record<ImpactKind, string> = {
17 nut: '/sounds/impactWood_medium_',
18 gem: '/sounds/impactGlass_medium_',
19 metalnut: '/sounds/impactMetal_heavy_',
20 rock: '/sounds/impactMining_',
21 stressball: '/sounds/impactSoft_heavy_',
22 balloon: '/sounds/impactSoft_medium_',
23 balloon_pop: '/sounds/impactPunch_heavy_',
24 coin: '/sounds/impactTin_medium_',
25}
26
27const VARIANTS = 3
28const COOLDOWN_MS = 60 // min ms between sounds of the same kind
29
30let ctx: AudioContext | null = null
31// url → decoded buffer (null while fetch is in flight)
32const buffers = new Map<string, AudioBuffer | null>()
33
34function getCtx(): AudioContext {
35 if (!ctx) ctx = new AudioContext()
36 return ctx
37}
38
39function allUrls(): string[] {
40 return Object.values(BASE).flatMap(base =>
41 Array.from({ length: VARIANTS }, (_, v) =>
42 `${base}${String(v).padStart(3, '0')}.mp3`
43 )
44 )
45}
46
47async function loadBuffer(url: string): Promise<void> {
48 if (buffers.has(url)) return
49 buffers.set(url, null) // mark in-flight
50 try {
51 const res = await fetch(url)
52 const ab = await res.arrayBuffer()
53 const decoded = await getCtx().decodeAudioData(ab)
54 buffers.set(url, decoded)
55 } catch (e) {
56 remoteLog(`load failed ${url}: ${e}`)
57 buffers.delete(url)
58 }
59}
60
61/** Preload all sounds. Must be called from a user-gesture handler (unlocks AudioContext). */
62export function preloadSounds(): void {
63 if (ctx?.state === 'suspended') ctx.resume()
64 for (const url of allUrls()) loadBuffer(url)
65 remoteLog('preloadSounds called')
66}
67
68const lastPlay = new Map<ImpactKind, number>()
69
70/**
71 * Play an impact sound for the given kind, scaled by `intensity` (0–1) and
72 * `radius` (px). Larger radius → louder and deeper; smaller → quieter and higher.
73 * Silently ignored if the same kind was played too recently.
74 */
75export function playImpact(kind: ImpactKind, intensity: number, radius = 60): void {
76 const now = performance.now()
77 if ((now - (lastPlay.get(kind) ?? 0)) < COOLDOWN_MS) return
78 lastPlay.set(kind, now)
79
80 const variant = Math.floor(Math.random() * VARIANTS)
81 const url = `${BASE[kind]}${String(variant).padStart(3, '0')}.mp3`
82 const buf = buffers.get(url)
83 remoteLog(`${kind} r=${Math.round(radius)} vol=${intensity.toFixed(2)} buf=${buf ? 'ready' : 'missing'} → ${url}`)
84 if (!buf) return
85
86 // Pitch: map radius to playbackRate on a log scale.
87 // Reference 60px → rate 1.0. Half radius → ~1.5× (higher); double → ~0.67× (deeper).
88 const REF_RADIUS = 60
89 const playbackRate = Math.pow(REF_RADIUS / Math.max(radius, 4), 0.42)
90
91 // Volume: larger items are louder (sqrt keeps it from getting too extreme)
92 const sizeGain = Math.pow(radius / REF_RADIUS, 0.4)
93 const volume = Math.min(Math.max(intensity * sizeGain, 0), 1)
94
95 const ac = getCtx()
96 const gain = ac.createGain()
97 gain.gain.value = volume
98 gain.connect(ac.destination)
99 const src = ac.createBufferSource()
100 src.buffer = buf
101 src.playbackRate.value = playbackRate
102 src.connect(gain)
103 src.start()
104}
105
106function remoteLog(msg: string): void {
107 if (!CONFIG.logToVite) return
108 fetch('/__log', {
109 method: 'POST',
110 headers: { 'Content-Type': 'application/json' },
111 body: JSON.stringify({ level: 'info', msg: `[sound] ${msg}` }),
112 }).catch(() => { /* dev server not available */ })
113}