Files
Infernal-Pinball/public/js/audio.js
T
2026-09-12 01:33:07 -04:00

195 lines
7.4 KiB
JavaScript

// 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;
}
}
}