// Synthesised sound effects using the Web Audio API (no audio files needed). // Browsers keep an AudioContext suspended until a user gesture, so `unlock()` // is called from the first keydown. const MASTER_VOLUME = 0.35; export class Sound { constructor() { this.ctx = null; this.master = null; this.noiseBuffer = null; this.muted = false; this.lastPlayed = new Map(); } unlock() { if (typeof AudioContext === 'undefined') return; if (!this.ctx) { this.ctx = new AudioContext(); this.master = new GainNode(this.ctx, { gain: this.muted ? 0 : MASTER_VOLUME }); this.master.connect(this.ctx.destination); // One second of white noise, shared by every noise-based effect. const length = this.ctx.sampleRate; this.noiseBuffer = new AudioBuffer({ length, sampleRate: this.ctx.sampleRate }); const data = this.noiseBuffer.getChannelData(0); for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1; } if (this.ctx.state === 'suspended') this.ctx.resume(); } toggleMute() { this.muted = !this.muted; if (this.master) this.master.gain.value = this.muted ? 0 : MASTER_VOLUME; return this.muted; } get ready() { return this.ctx && this.ctx.state === 'running' && !this.muted; } /** Avoid stacking dozens of identical sounds in the same instant. */ throttle(name, seconds) { const now = this.ctx.currentTime; const last = this.lastPlayed.get(name) ?? -Infinity; if (now - last < seconds) return false; this.lastPlayed.set(name, now); return true; } tone({ freq, to = null, type = 'sine', dur = 0.1, gain = 0.4, delay = 0 }) { const ctx = this.ctx; const t = ctx.currentTime + delay; const osc = new OscillatorNode(ctx, { type, frequency: freq }); if (to) { osc.frequency.setValueAtTime(freq, t); osc.frequency.exponentialRampToValueAtTime(to, t + dur); } const env = new GainNode(ctx, { gain: 0 }); env.gain.setValueAtTime(0.0001, t); env.gain.exponentialRampToValueAtTime(gain, t + 0.004); env.gain.exponentialRampToValueAtTime(0.0001, t + dur); osc.connect(env).connect(this.master); osc.start(t); osc.stop(t + dur + 0.02); } noise({ freq = 1000, to = null, q = 1, type = 'bandpass', dur = 0.05, gain = 0.4, delay = 0 }) { const ctx = this.ctx; const t = ctx.currentTime + delay; const src = new AudioBufferSourceNode(ctx, { buffer: this.noiseBuffer }); const filter = new BiquadFilterNode(ctx, { type, frequency: freq }); filter.Q.value = q; if (to) { filter.frequency.setValueAtTime(freq, t); filter.frequency.exponentialRampToValueAtTime(to, t + dur); } const env = new GainNode(ctx, { gain: 0 }); env.gain.setValueAtTime(0.0001, t); env.gain.exponentialRampToValueAtTime(gain, t + 0.003); env.gain.exponentialRampToValueAtTime(0.0001, t + dur); src.connect(filter).connect(env).connect(this.master); src.start(t, Math.random() * 0.5); src.stop(t + dur + 0.02); } arpeggio(notes, { type = 'square', step = 0.07, dur = 0.09, gain = 0.18 } = {}) { notes.forEach((freq, i) => this.tone({ freq, type, dur, gain, delay: i * step })); } /** React to a game event (see Game.emit). */ play(event) { if (!this.ready) return; switch (event.type) { case 'flipper': if (event.up) { this.noise({ freq: 2200, type: 'highpass', dur: 0.03, gain: 0.3 }); this.tone({ freq: 120, to: 60, type: 'square', dur: 0.05, gain: 0.18 }); } else { this.noise({ freq: 1500, type: 'highpass', dur: 0.02, gain: 0.1 }); } break; case 'bumper': this.tone({ freq: 170, to: 70, type: 'triangle', dur: 0.14, gain: 0.55 }); this.noise({ freq: 2600, dur: 0.04, gain: 0.35, q: 2 }); break; case 'sling': this.noise({ freq: 1300, dur: 0.05, gain: 0.45, q: 1.5 }); this.tone({ freq: 95, to: 55, type: 'square', dur: 0.06, gain: 0.2 }); break; case 'kicker': case 'rubber': if (this.throttle('rubber', 0.04)) { const g = Math.min(0.35, (event.speed ?? 400) / 5000); this.noise({ freq: 900, dur: 0.03, gain: g, q: 3 }); } break; case 'drop': this.tone({ freq: 320, to: 110, type: 'square', dur: 0.08, gain: 0.22 }); this.noise({ freq: 500, type: 'lowpass', dur: 0.06, gain: 0.3 }); break; case 'dropReset': this.noise({ freq: 700, type: 'lowpass', dur: 0.08, gain: 0.3 }); break; case 'standup': this.tone({ freq: 540, to: 270, type: 'triangle', dur: 0.09, gain: 0.35 }); break; case 'rollover': if (event.kind === 'outlane') this.tone({ freq: 330, to: 150, type: 'sawtooth', dur: 0.25, gain: 0.18 }); else if (event.kind === 'inlane') this.tone({ freq: 660, to: 990, dur: 0.08, gain: 0.3 }); else { this.tone({ freq: 880, dur: 0.06, gain: 0.3 }); this.tone({ freq: 1320, dur: 0.07, gain: 0.3, delay: 0.06 }); } break; case 'complete': this.arpeggio(event.what === 'skill' ? [523, 659, 784, 1047, 1319, 1568] : [523, 659, 784, 1047]); break; case 'launch': this.noise({ freq: 300, to: 3000, type: 'lowpass', dur: 0.25, gain: 0.15 + 0.3 * event.power }); break; case 'plungerPull': this.tone({ freq: 90, type: 'sawtooth', dur: 0.05, gain: 0.08 }); break; case 'drain': this.tone({ freq: 440, to: 90, type: 'sawtooth', dur: 0.7, gain: 0.22 }); break; case 'newBall': this.arpeggio([660, 880], { step: 0.1, type: 'triangle', gain: 0.25 }); break; case 'start': this.arpeggio([392, 523, 659, 784], { step: 0.08, gain: 0.2 }); break; case 'gameover': this.arpeggio([523, 392, 330, 262], { step: 0.22, dur: 0.22, type: 'triangle', gain: 0.3 }); break; case 'kickback': this.tone({ freq: 150, to: 55, type: 'square', dur: 0.12, gain: 0.35 }); this.noise({ freq: 450, type: 'lowpass', dur: 0.1, gain: 0.45 }); break; case 'nudge': this.noise({ freq: 220, type: 'lowpass', dur: 0.12, gain: 0.4 }); break; case 'rampEnter': this.noise({ freq: 500, to: 2200, type: 'bandpass', dur: 0.35, gain: 0.25, q: 0.7 }); break; case 'ramp': this.tone({ freq: 700, to: 900, type: 'triangle', dur: 0.1, gain: 0.25 }); break; case 'lock': this.arpeggio([440, 660], { step: 0.09, type: 'square', gain: 0.28 }); this.noise({ freq: 300, type: 'lowpass', dur: 0.15, gain: 0.3 }); break; case 'multiball': this.arpeggio([392, 494, 587, 784, 987], { step: 0.06, type: 'square', gain: 0.32 }); this.noise({ freq: 1200, dur: 0.3, gain: 0.4, q: 0.6 }); break; case 'jackpot': this.arpeggio([784, 988, 1175], { step: 0.05, type: 'square', gain: 0.35 }); break; case 'superJackpot': this.arpeggio([523, 659, 784, 1047, 1319, 1568, 2093], { step: 0.06, type: 'square', gain: 0.4 }); this.noise({ freq: 1800, dur: 0.4, gain: 0.35, q: 0.5 }); break; case 'scoop': this.tone({ freq: 500, to: 300, type: 'sine', dur: 0.12, gain: 0.2 }); break; case 'ballLost': this.tone({ freq: 300, to: 120, type: 'sawtooth', dur: 0.3, gain: 0.15 }); break; default: break; } } }