picker/js/audio.js
David 35a4dc2dbf
Some checks failed
Publish Docker image / image (push) Has been cancelled
fixes
2026-10-09 13:55:15 +02:00

166 lines
5.7 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(() => {});
// Older iOS Safari only unlocks once a sound starts inside the gesture.
const src = ctx.createBufferSource();
src.buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
src.connect(ctx.destination);
src.start();
}
} 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);
}
/** False until the browser has let us start audio (needs a tap, not just a touch down). */
export const audioUnlocked = () => !!ctx && ctx.state === 'running';
const live = () => audioUnlocked() && !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 });
},
};