JavaScript · 12450 bytes Raw Blame History
1 // Web Audio API sound synthesis for dougk
2
3 let audioContext = null
4 let unlocked = false
5
6 function getContext() {
7 if (!audioContext) {
8 audioContext = new (window.AudioContext || window.webkitAudioContext)()
9 }
10 return audioContext
11 }
12
13 // Must be called on first user interaction to enable audio on mobile
14 export function unlockAudio() {
15 if (unlocked) return
16 unlocked = true // Set immediately to prevent multiple attempts
17
18 // Create context fresh during user gesture (mobile requirement)
19 if (!audioContext) {
20 audioContext = new (window.AudioContext || window.webkitAudioContext)()
21 }
22
23 const ctx = audioContext
24
25 // Resume synchronously - don't wait for promise
26 if (ctx.state === 'suspended') {
27 ctx.resume()
28 }
29
30 // Immediately play a sound to force audio pipeline open
31 // Must happen synchronously in the user gesture
32 try {
33 const osc = ctx.createOscillator()
34 const gain = ctx.createGain()
35
36 osc.type = 'triangle'
37 osc.frequency.value = 200
38 gain.gain.value = 0.001 // Nearly silent
39
40 osc.connect(gain)
41 gain.connect(ctx.destination)
42
43 osc.start(0)
44 osc.stop(ctx.currentTime + 0.1)
45 } catch (e) {
46 console.warn('Unlock sound failed:', e)
47 }
48 }
49
50 // Damp crunch sound - wet bread being chomped
51 export function playMonch() {
52 const ctx = getContext()
53
54 // Try to resume if needed, but don't block
55 if (ctx.state === 'suspended') {
56 ctx.resume()
57 }
58
59 const now = ctx.currentTime
60
61 // Master output - keep it gentle
62 const master = ctx.createGain()
63 master.gain.value = 0.25
64 master.connect(ctx.destination)
65
66 // High-pass to remove speaker-popping low frequencies
67 const highPass = ctx.createBiquadFilter()
68 highPass.type = 'highpass'
69 highPass.frequency.value = 150
70 highPass.connect(master)
71
72 // Create multiple small crunch "grains" for texture
73 const grainCount = 5
74 for (let i = 0; i < grainCount; i++) {
75 const delay = i * 0.018 + Math.random() * 0.01
76 const grainTime = now + delay
77
78 // Each grain is a short filtered noise burst
79 const grainLength = 0.04 + Math.random() * 0.03
80 const bufferSize = Math.floor(ctx.sampleRate * grainLength)
81 const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate)
82 const noiseData = noiseBuffer.getChannelData(0)
83
84 // Softer noise - not full amplitude
85 for (let j = 0; j < bufferSize; j++) {
86 noiseData[j] = (Math.random() * 2 - 1) * 0.7
87 }
88
89 const grain = ctx.createBufferSource()
90 grain.buffer = noiseBuffer
91
92 // Bandpass for crunch character - varied per grain
93 const filter = ctx.createBiquadFilter()
94 filter.type = 'bandpass'
95 filter.frequency.value = 300 + Math.random() * 400
96 filter.Q.value = 2 + Math.random() * 2
97
98 // Gentle envelope - no sharp attacks
99 const env = ctx.createGain()
100 const peakGain = 0.3 + Math.random() * 0.2
101 env.gain.setValueAtTime(0, grainTime)
102 env.gain.linearRampToValueAtTime(peakGain, grainTime + 0.008) // Soft attack
103 env.gain.linearRampToValueAtTime(peakGain * 0.6, grainTime + 0.02)
104 env.gain.linearRampToValueAtTime(0, grainTime + grainLength) // Soft release
105
106 grain.connect(filter)
107 filter.connect(env)
108 env.connect(highPass)
109
110 grain.start(grainTime)
111 grain.stop(grainTime + grainLength)
112 }
113
114 // Soft muffled "body" of the bite - no harsh transients
115 const bodyLength = 0.12
116 const bodyBuffer = ctx.createBuffer(1, Math.floor(ctx.sampleRate * bodyLength), ctx.sampleRate)
117 const bodyData = bodyBuffer.getChannelData(0)
118 for (let i = 0; i < bodyData.length; i++) {
119 bodyData[i] = (Math.random() * 2 - 1) * 0.5
120 }
121
122 const body = ctx.createBufferSource()
123 body.buffer = bodyBuffer
124
125 // Heavy lowpass for muffled wet sound
126 const wetFilter = ctx.createBiquadFilter()
127 wetFilter.type = 'lowpass'
128 wetFilter.frequency.setValueAtTime(600, now)
129 wetFilter.frequency.linearRampToValueAtTime(200, now + 0.1)
130 wetFilter.Q.value = 1
131
132 const bodyEnv = ctx.createGain()
133 bodyEnv.gain.setValueAtTime(0, now)
134 bodyEnv.gain.linearRampToValueAtTime(0.25, now + 0.02) // Gentle attack
135 bodyEnv.gain.linearRampToValueAtTime(0.15, now + 0.05)
136 bodyEnv.gain.linearRampToValueAtTime(0, now + bodyLength)
137
138 body.connect(wetFilter)
139 wetFilter.connect(bodyEnv)
140 bodyEnv.connect(highPass)
141
142 body.start(now)
143 body.stop(now + bodyLength)
144
145 // Tonal body - soft pitched "chomp" character
146 // Primary tone - warm mid frequency
147 const tone1 = ctx.createOscillator()
148 tone1.type = 'triangle'
149 tone1.frequency.setValueAtTime(280, now)
150 tone1.frequency.linearRampToValueAtTime(180, now + 0.08)
151
152 const tone1Env = ctx.createGain()
153 tone1Env.gain.setValueAtTime(0, now)
154 tone1Env.gain.linearRampToValueAtTime(0.12, now + 0.015) // Soft attack
155 tone1Env.gain.linearRampToValueAtTime(0.06, now + 0.05)
156 tone1Env.gain.linearRampToValueAtTime(0, now + 0.1)
157
158 tone1.connect(tone1Env)
159 tone1Env.connect(highPass)
160 tone1.start(now)
161 tone1.stop(now + 0.12)
162
163 // Secondary harmonic - adds richness
164 const tone2 = ctx.createOscillator()
165 tone2.type = 'sine'
166 tone2.frequency.setValueAtTime(420, now)
167 tone2.frequency.linearRampToValueAtTime(300, now + 0.06)
168
169 const tone2Env = ctx.createGain()
170 tone2Env.gain.setValueAtTime(0, now)
171 tone2Env.gain.linearRampToValueAtTime(0.06, now + 0.01)
172 tone2Env.gain.linearRampToValueAtTime(0, now + 0.07)
173
174 tone2.connect(tone2Env)
175 tone2Env.connect(highPass)
176 tone2.start(now)
177 tone2.stop(now + 0.1)
178
179 // Soft low "gulp" undertone - filtered to be safe
180 const gulp = ctx.createOscillator()
181 gulp.type = 'sine'
182 gulp.frequency.setValueAtTime(200, now + 0.02)
183 gulp.frequency.linearRampToValueAtTime(160, now + 0.1)
184
185 const gulpEnv = ctx.createGain()
186 gulpEnv.gain.setValueAtTime(0, now)
187 gulpEnv.gain.linearRampToValueAtTime(0, now + 0.02) // Delayed start
188 gulpEnv.gain.linearRampToValueAtTime(0.08, now + 0.04)
189 gulpEnv.gain.linearRampToValueAtTime(0, now + 0.12)
190
191 gulp.connect(gulpEnv)
192 gulpEnv.connect(highPass)
193 gulp.start(now)
194 gulp.stop(now + 0.15)
195 }
196
197 // Koi capture sound - magical sparkle pop
198 export function playCapture() {
199 const ctx = getContext()
200 if (ctx.state === 'suspended') ctx.resume()
201
202 const now = ctx.currentTime
203
204 const master = ctx.createGain()
205 master.gain.value = 0.3
206 master.connect(ctx.destination)
207
208 // Rising sparkle tones
209 const notes = [523, 659, 784, 1047] // C5, E5, G5, C6
210 notes.forEach((freq, i) => {
211 const osc = ctx.createOscillator()
212 osc.type = 'sine'
213 osc.frequency.value = freq
214
215 const env = ctx.createGain()
216 const startTime = now + i * 0.05
217 env.gain.setValueAtTime(0, startTime)
218 env.gain.linearRampToValueAtTime(0.15, startTime + 0.02)
219 env.gain.linearRampToValueAtTime(0, startTime + 0.15)
220
221 osc.connect(env)
222 env.connect(master)
223 osc.start(startTime)
224 osc.stop(startTime + 0.2)
225 })
226
227 // Soft pop at the end
228 const popOsc = ctx.createOscillator()
229 popOsc.type = 'sine'
230 popOsc.frequency.setValueAtTime(400, now + 0.15)
231 popOsc.frequency.linearRampToValueAtTime(200, now + 0.25)
232
233 const popEnv = ctx.createGain()
234 popEnv.gain.setValueAtTime(0, now + 0.15)
235 popEnv.gain.linearRampToValueAtTime(0.2, now + 0.17)
236 popEnv.gain.linearRampToValueAtTime(0, now + 0.3)
237
238 popOsc.connect(popEnv)
239 popEnv.connect(master)
240 popOsc.start(now + 0.15)
241 popOsc.stop(now + 0.35)
242 }
243
244 // Purchase/coin sound - satisfying cha-ching
245 export function playPurchase() {
246 const ctx = getContext()
247 if (ctx.state === 'suspended') ctx.resume()
248
249 const now = ctx.currentTime
250
251 const master = ctx.createGain()
252 master.gain.value = 0.25
253 master.connect(ctx.destination)
254
255 // High bell tone
256 const bell1 = ctx.createOscillator()
257 bell1.type = 'sine'
258 bell1.frequency.value = 1200
259
260 const bell1Env = ctx.createGain()
261 bell1Env.gain.setValueAtTime(0.3, now)
262 bell1Env.gain.exponentialRampToValueAtTime(0.01, now + 0.3)
263
264 bell1.connect(bell1Env)
265 bell1Env.connect(master)
266 bell1.start(now)
267 bell1.stop(now + 0.35)
268
269 // Second bell tone (slightly delayed)
270 const bell2 = ctx.createOscillator()
271 bell2.type = 'sine'
272 bell2.frequency.value = 1600
273
274 const bell2Env = ctx.createGain()
275 bell2Env.gain.setValueAtTime(0, now)
276 bell2Env.gain.setValueAtTime(0.25, now + 0.08)
277 bell2Env.gain.exponentialRampToValueAtTime(0.01, now + 0.35)
278
279 bell2.connect(bell2Env)
280 bell2Env.connect(master)
281 bell2.start(now)
282 bell2.stop(now + 0.4)
283
284 // Metallic shimmer (noise burst)
285 const shimmerLength = 0.15
286 const shimmerBuffer = ctx.createBuffer(1, ctx.sampleRate * shimmerLength, ctx.sampleRate)
287 const shimmerData = shimmerBuffer.getChannelData(0)
288 for (let i = 0; i < shimmerData.length; i++) {
289 shimmerData[i] = (Math.random() * 2 - 1) * 0.3
290 }
291
292 const shimmer = ctx.createBufferSource()
293 shimmer.buffer = shimmerBuffer
294
295 const shimmerFilter = ctx.createBiquadFilter()
296 shimmerFilter.type = 'highpass'
297 shimmerFilter.frequency.value = 3000
298
299 const shimmerEnv = ctx.createGain()
300 shimmerEnv.gain.setValueAtTime(0.15, now)
301 shimmerEnv.gain.linearRampToValueAtTime(0, now + shimmerLength)
302
303 shimmer.connect(shimmerFilter)
304 shimmerFilter.connect(shimmerEnv)
305 shimmerEnv.connect(master)
306 shimmer.start(now)
307 shimmer.stop(now + shimmerLength)
308 }
309
310 // Shop open sound - friendly chime
311 export function playShopOpen() {
312 const ctx = getContext()
313 if (ctx.state === 'suspended') ctx.resume()
314
315 const now = ctx.currentTime
316
317 const master = ctx.createGain()
318 master.gain.value = 0.2
319 master.connect(ctx.destination)
320
321 // Ascending arpeggio - warm and inviting
322 const notes = [392, 494, 587, 784] // G4, B4, D5, G5
323 notes.forEach((freq, i) => {
324 const osc = ctx.createOscillator()
325 osc.type = 'triangle'
326 osc.frequency.value = freq
327
328 const env = ctx.createGain()
329 const startTime = now + i * 0.08
330 env.gain.setValueAtTime(0, startTime)
331 env.gain.linearRampToValueAtTime(0.2, startTime + 0.03)
332 env.gain.exponentialRampToValueAtTime(0.01, startTime + 0.4)
333
334 osc.connect(env)
335 env.connect(master)
336 osc.start(startTime)
337 osc.stop(startTime + 0.45)
338 })
339 }
340
341 // Shop close sound - gentle descending tone
342 export function playShopClose() {
343 const ctx = getContext()
344 if (ctx.state === 'suspended') ctx.resume()
345
346 const now = ctx.currentTime
347
348 const master = ctx.createGain()
349 master.gain.value = 0.15
350 master.connect(ctx.destination)
351
352 // Single soft descending tone
353 const osc = ctx.createOscillator()
354 osc.type = 'triangle'
355 osc.frequency.setValueAtTime(600, now)
356 osc.frequency.linearRampToValueAtTime(400, now + 0.2)
357
358 const env = ctx.createGain()
359 env.gain.setValueAtTime(0.2, now)
360 env.gain.linearRampToValueAtTime(0, now + 0.25)
361
362 osc.connect(env)
363 env.connect(master)
364 osc.start(now)
365 osc.stop(now + 0.3)
366 }
367
368 // Dialog blip sound - for typewriter text
369 export function playDialogBlip() {
370 const ctx = getContext()
371 if (ctx.state === 'suspended') ctx.resume()
372
373 const now = ctx.currentTime
374
375 const osc = ctx.createOscillator()
376 osc.type = 'square'
377 osc.frequency.value = 440 + Math.random() * 60 // Slight variation
378
379 const env = ctx.createGain()
380 env.gain.setValueAtTime(0.08, now)
381 env.gain.linearRampToValueAtTime(0, now + 0.04)
382
383 const filter = ctx.createBiquadFilter()
384 filter.type = 'lowpass'
385 filter.frequency.value = 1000
386
387 osc.connect(filter)
388 filter.connect(env)
389 env.connect(ctx.destination)
390 osc.start(now)
391 osc.stop(now + 0.05)
392 }
393
394 // Placement confirm sound - solid thunk
395 export function playPlaceBuilding() {
396 const ctx = getContext()
397 if (ctx.state === 'suspended') ctx.resume()
398
399 const now = ctx.currentTime
400
401 const master = ctx.createGain()
402 master.gain.value = 0.25
403 master.connect(ctx.destination)
404
405 // Low thump
406 const thump = ctx.createOscillator()
407 thump.type = 'sine'
408 thump.frequency.setValueAtTime(150, now)
409 thump.frequency.linearRampToValueAtTime(60, now + 0.1)
410
411 const thumpEnv = ctx.createGain()
412 thumpEnv.gain.setValueAtTime(0.4, now)
413 thumpEnv.gain.linearRampToValueAtTime(0, now + 0.15)
414
415 thump.connect(thumpEnv)
416 thumpEnv.connect(master)
417 thump.start(now)
418 thump.stop(now + 0.2)
419
420 // Wood knock overtone
421 const knock = ctx.createOscillator()
422 knock.type = 'triangle'
423 knock.frequency.setValueAtTime(300, now)
424 knock.frequency.linearRampToValueAtTime(200, now + 0.05)
425
426 const knockEnv = ctx.createGain()
427 knockEnv.gain.setValueAtTime(0.2, now)
428 knockEnv.gain.linearRampToValueAtTime(0, now + 0.08)
429
430 knock.connect(knockEnv)
431 knockEnv.connect(master)
432 knock.start(now)
433 knock.stop(now + 0.1)
434 }