// Sound effects synthesised with WebAudio, so the game ships without audio // assets. Everything is routed through one gain node for the master mute. const BUFFERS = 2; class Voice { constructor(ctx, dest) { this.ctx = ctx; this.osc = ctx.createOscillator(); this.gain = ctx.createGain(); this.osc.connect(this.gain); this.gain.connect(dest); } type(t) { this.osc.type = t; return this; } // Frequent pitch moves make the blips feel snappy rather than organ-like. sweep(from, to, at, dur, curve = 'exp') { const o = this.osc.frequency; o.setValueAtTime(Math.max(1, from), at); if (curve === 'exp') o.exponentialRampToValueAtTime(Math.max(1, to), at + dur); else o.linearRampToValueAtTime(Math.max(1, to), at + dur); return this; } envelope(at, attack, dur, peak) { const g = this.gain.gain; g.setValueAtTime(0.0001, at); g.exponentialRampToValueAtTime(Math.max(0.0002, peak), at + attack); g.exponentialRampToValueAtTime(0.0001, at + dur); return this; } start(at, dur) { this.osc.start(at); this.osc.stop(at + dur + 0.02); } } export class AudioKit { constructor() { this.ctx = null; this.master = null; this.muted = false; this.noiseBuffer = null; } // Browsers only allow an AudioContext to start from a user gesture. unlock() { if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return true; } const Ctor = window.AudioContext || window.webkitAudioContext; if (!Ctor) return false; this.ctx = new Ctor(); this.master = this.ctx.createGain(); this.master.gain.value = this.muted ? 0 : 0.5; // A gentle limiter keeps stacked collect sounds from clipping. const comp = this.ctx.createDynamicsCompressor(); comp.threshold.value = -14; comp.knee.value = 22; comp.ratio.value = 9; comp.attack.value = 0.004; comp.release.value = 0.18; this.master.connect(comp); comp.connect(this.ctx.destination); this.noiseBuffer = this.#noiseBuffer(); return true; } #noiseBuffer() { const len = Math.floor(this.ctx.sampleRate * 0.7); const buf = this.ctx.createBuffer(BUFFERS, len, this.ctx.sampleRate); for (let ch = 0; ch < BUFFERS; ch++) { const data = buf.getChannelData(ch); for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 2; } return buf; } setMuted(muted) { this.muted = muted; if (this.master) this.master.gain.value = muted ? 0 : 0.5; } #now() { return this.ctx.currentTime + 0.001; } #voice() { return new Voice(this.ctx, this.master); } #ok() { return this.ctx && !this.muted; } tone(freq, { type = 'triangle', dur = 0.16, at = 0, gain = 0.3, to = null, attack = 0.006 } = {}) { if (!this.#ok()) return; const t = this.#now() + at; const v = this.#voice().type(type); if (to) v.sweep(freq, to, t, dur); else v.sweep(freq, freq, t, dur); v.envelope(t, attack, dur, gain); v.start(t, dur); } noise({ dur = 0.25, at = 0, gain = 0.2, freq = 900, q = 1.2, type = 'bandpass', sweepTo = null } = {}) { if (!this.#ok()) return; const t = this.#now() + at; const src = this.ctx.createBufferSource(); src.buffer = this.noiseBuffer; const filter = this.ctx.createBiquadFilter(); filter.type = type; filter.frequency.setValueAtTime(freq, t); if (sweepTo) filter.frequency.exponentialRampToValueAtTime(sweepTo, t + dur); filter.Q.value = q; const env = this.ctx.createGain(); env.gain.setValueAtTime(0.0001, t); env.gain.exponentialRampToValueAtTime(gain, t + 0.01); env.gain.exponentialRampToValueAtTime(0.0001, t + dur); src.connect(filter); filter.connect(env); env.connect(this.master); src.start(t); src.stop(t + dur + 0.02); } jump() { this.tone(280, { type: 'square', to: 720, dur: 0.16, gain: 0.18 }); this.noise({ dur: 0.1, gain: 0.06, freq: 2400, sweepTo: 900 }); } lane() { this.tone(520, { type: 'sine', to: 660, dur: 0.07, gain: 0.1 }); } land() { this.noise({ dur: 0.14, gain: 0.13, freq: 260, sweepTo: 90, type: 'lowpass', q: 0.9 }); this.tone(120, { type: 'sine', to: 70, dur: 0.12, gain: 0.1 }); } // Ascending pentatonic, so a streak of pickups sounds like a jingle. collect(step = 0) { const scale = [0, 3, 5, 7, 10, 12, 15, 17, 19, 22]; const semis = scale[Math.min(step, scale.length - 1)]; const f = 440 * 2 ** (semis / 12); this.tone(f, { type: 'triangle', dur: 0.22, gain: 0.24 }); this.tone(f * 2, { type: 'sine', dur: 0.14, gain: 0.1, at: 0.02 }); } word() { [0, 4, 7, 12, 16, 19].forEach((s, i) => { this.tone(392 * 2 ** (s / 12), { type: 'triangle', dur: 0.5, gain: 0.2, at: i * 0.07 }); }); this.noise({ dur: 0.7, gain: 0.1, freq: 400, sweepTo: 5000, type: 'highpass' }); } boost() { this.tone(140, { type: 'sawtooth', to: 620, dur: 0.5, gain: 0.16 }); this.noise({ dur: 0.5, gain: 0.12, freq: 300, sweepTo: 3200, type: 'bandpass', q: 0.7 }); } edge() { this.tone(90, { type: 'sawtooth', to: 1400, dur: 0.85, gain: 0.2 }); this.tone(180, { type: 'square', to: 700, dur: 0.85, gain: 0.08 }); this.noise({ dur: 0.9, gain: 0.16, freq: 200, sweepTo: 6000, type: 'highpass' }); } hit() { this.tone(180, { type: 'square', to: 55, dur: 0.4, gain: 0.3 }); this.noise({ dur: 0.35, gain: 0.28, freq: 380, sweepTo: 90, type: 'lowpass', q: 0.8 }); } gameOver() { [0, -2, -5, -9].forEach((s, i) => { this.tone(330 * 2 ** (s / 12), { type: 'triangle', dur: 0.8, gain: 0.22, at: i * 0.16 }); }); this.noise({ dur: 1.2, gain: 0.1, freq: 700, sweepTo: 120, type: 'lowpass' }); } ui() { this.tone(660, { type: 'sine', dur: 0.06, gain: 0.09 }); } start() { [0, 7, 12].forEach((s, i) => { this.tone(330 * 2 ** (s / 12), { type: 'triangle', dur: 0.34, gain: 0.18, at: i * 0.09 }); }); } }