156 lines
5.3 KiB
JavaScript
156 lines
5.3 KiB
JavaScript
// Sound effects synthesised with Web Audio (no audio files) + haptics.
|
|
//
|
|
// Browsers only allow audio and navigator.vibrate after a "user activation".
|
|
// A touch *down* doesn't count (only lifting a finger, a tap or a click), so
|
|
// we try to unlock on every interaction until it works.
|
|
|
|
let ctx = null;
|
|
let master = null;
|
|
let noiseBuf = null;
|
|
let muted = false;
|
|
let hapticsOn = true;
|
|
|
|
export function setMuted(m) {
|
|
muted = m;
|
|
if (master) master.gain.setTargetAtTime(m ? 0 : 0.8, ctx.currentTime, 0.02);
|
|
}
|
|
|
|
export function setHaptics(on) {
|
|
hapticsOn = on;
|
|
}
|
|
|
|
export const canVibrate = typeof navigator.vibrate === 'function';
|
|
|
|
export function buzz(pattern) {
|
|
if (!canVibrate || !hapticsOn) return;
|
|
try {
|
|
navigator.vibrate(pattern);
|
|
} catch {
|
|
/* blocked before user activation */
|
|
}
|
|
}
|
|
|
|
export function unlockAudio() {
|
|
try {
|
|
if (!ctx) {
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
if (!AC) return;
|
|
ctx = new AC();
|
|
master = ctx.createGain();
|
|
master.gain.value = muted ? 0 : 0.8;
|
|
const comp = ctx.createDynamicsCompressor();
|
|
master.connect(comp);
|
|
comp.connect(ctx.destination);
|
|
noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
|
|
const d = noiseBuf.getChannelData(0);
|
|
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
|
}
|
|
if (ctx.state !== 'running') ctx.resume().catch(() => {});
|
|
} catch {
|
|
/* audio unavailable */
|
|
}
|
|
}
|
|
|
|
export function installAudioUnlock() {
|
|
const opts = { capture: true, passive: true };
|
|
for (const t of ['pointerdown', 'pointerup', 'touchend', 'click', 'keydown']) window.addEventListener(t, unlockAudio, opts);
|
|
}
|
|
|
|
const live = () => ctx && ctx.state === 'running' && !muted;
|
|
|
|
function tone({ type = 'sine', freq, freqEnd, at = 0, dur, gain = 0.2, attack = 0.005, filter }) {
|
|
const t0 = ctx.currentTime + at;
|
|
const o = ctx.createOscillator();
|
|
const g = ctx.createGain();
|
|
o.type = type;
|
|
o.frequency.setValueAtTime(freq, t0);
|
|
if (freqEnd) o.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
|
|
g.gain.setValueAtTime(0.0001, t0);
|
|
g.gain.exponentialRampToValueAtTime(gain, t0 + attack);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
let node = o;
|
|
if (filter) {
|
|
const f = ctx.createBiquadFilter();
|
|
f.type = 'lowpass';
|
|
f.frequency.value = filter;
|
|
o.connect(f);
|
|
node = f;
|
|
}
|
|
node.connect(g);
|
|
g.connect(master);
|
|
o.start(t0);
|
|
o.stop(t0 + dur + 0.05);
|
|
}
|
|
|
|
function noise({ at = 0, dur, gain = 0.2, type = 'bandpass', freq = 1000, freqEnd, q = 1 }) {
|
|
const t0 = ctx.currentTime + at;
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = noiseBuf;
|
|
const f = ctx.createBiquadFilter();
|
|
f.type = type;
|
|
f.Q.value = q;
|
|
f.frequency.setValueAtTime(freq, t0);
|
|
if (freqEnd) f.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, t0);
|
|
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
src.connect(f);
|
|
f.connect(g);
|
|
g.connect(master);
|
|
src.start(t0);
|
|
src.stop(t0 + dur + 0.05);
|
|
}
|
|
|
|
const PENTA = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24];
|
|
const note = (base, semis) => base * Math.pow(2, semis / 12);
|
|
|
|
export const sfx = {
|
|
join(i) {
|
|
if (!live()) return;
|
|
tone({ freq: note(523, PENTA[i % PENTA.length]), dur: 0.14, gain: 0.12 });
|
|
},
|
|
beat(i) {
|
|
if (!live()) return;
|
|
tone({ type: 'triangle', freq: note(392, i * 5), dur: 0.18, gain: 0.25 });
|
|
},
|
|
/** Roulette tick, rising in pitch as the wheel slows. */
|
|
tick(progress, final) {
|
|
if (!live()) return;
|
|
if (final) {
|
|
tone({ type: 'square', freq: 1320, dur: 0.12, gain: 0.08, filter: 4000 });
|
|
return;
|
|
}
|
|
tone({ type: 'square', freq: 420 + progress * 900, dur: 0.035, gain: 0.07, filter: 3500 });
|
|
noise({ dur: 0.02, gain: 0.05, type: 'highpass', freq: 3000 });
|
|
},
|
|
reveal() {
|
|
if (!live()) return;
|
|
// whoosh + boom + bright major arpeggio
|
|
noise({ dur: 0.5, gain: 0.25, type: 'bandpass', freq: 400, freqEnd: 4000, q: 0.8 });
|
|
tone({ freq: 140, freqEnd: 45, dur: 0.6, gain: 0.5 });
|
|
[0, 4, 7, 12, 16].forEach((s, i) => {
|
|
tone({ type: 'triangle', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.9, gain: 0.16 });
|
|
tone({ type: 'sawtooth', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.7, gain: 0.04, filter: 2200 });
|
|
});
|
|
},
|
|
crack() {
|
|
if (!live()) return;
|
|
noise({ dur: 0.3, gain: 0.45, type: 'highpass', freq: 1800 });
|
|
noise({ at: 0.04, dur: 0.18, gain: 0.25, type: 'bandpass', freq: 5000, q: 4 });
|
|
tone({ freq: 220, freqEnd: 55, dur: 0.35, gain: 0.35 });
|
|
tone({ type: 'triangle', freq: 2637, at: 0.02, dur: 0.25, gain: 0.05 });
|
|
tone({ type: 'triangle', freq: 3520, at: 0.07, dur: 0.2, gain: 0.04 });
|
|
},
|
|
/** Sad trombone. */
|
|
chicken() {
|
|
if (!live()) return;
|
|
const parts = [[392, 370, 0], [370, 349, 0.28], [349, 330, 0.56]];
|
|
for (const [a, b, at] of parts) tone({ type: 'sawtooth', freq: a, freqEnd: b, at, dur: 0.26, gain: 0.12, filter: 1200, attack: 0.03 });
|
|
tone({ type: 'sawtooth', freq: 330, freqEnd: 247, at: 0.84, dur: 0.75, gain: 0.12, filter: 1000, attack: 0.03 });
|
|
},
|
|
pop(n) {
|
|
if (!live()) return;
|
|
tone({ freq: note(660, PENTA[(n - 1) % PENTA.length]), freqEnd: note(330, PENTA[(n - 1) % PENTA.length]), dur: 0.14, gain: 0.25 });
|
|
},
|
|
};
|