edge-runner / src /audio.js
jedisct1's picture
Update Edge Runner
09c40ff verified
Raw History Blame Contribute Delete
6.02 kB
// 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 });
});
}
}