/** * The spike, out loud. * * One oscillator per beep, scheduled by how much fuse is left: a slow tick * when it is planted, a stutter when it is about to go. No audio files, and * nothing is created until the person has clicked the page — a browser will * not start an audio context before that, and should not. */ /** Seconds between beeps for this much fuse left. */ export function beepInterval(fuse) { if (fuse <= 0) return 0; return Math.max(0.12, Math.min(1, fuse / 30)); } /** The pitch rises as the fuse burns, so it is audible without watching. */ export const beepPitch = (fuse) => 760 + 620 * (1 - Math.min(1, fuse / 45)); export function makeBeeper() { let audio = null; let next = 0; const context = () => { if (audio) return audio; const Ctor = globalThis.AudioContext || globalThis.webkitAudioContext; if (!Ctor) return null; audio = new Ctor(); return audio; }; return { /** Called on a click: browsers only allow audio to start from a gesture. */ wake() { const ctx = context(); if (ctx && ctx.state === "suspended") ctx.resume(); }, /** Called every frame with the fuse the server last sent. */ update(planted, fuse, now) { if (!planted || fuse <= 0) { next = 0; return; } if (!audio) return; // no gesture yet; nothing to play into if (now < next) return; next = now + beepInterval(fuse); beep(audio, beepPitch(fuse)); }, }; } function beep(audio, hz) { const osc = audio.createOscillator(); const gain = audio.createGain(); osc.type = "square"; osc.frequency.value = hz; gain.gain.setValueAtTime(0.0001, audio.currentTime); gain.gain.exponentialRampToValueAtTime(0.09, audio.currentTime + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.09); osc.connect(gain).connect(audio.destination); osc.start(); osc.stop(audio.currentTime + 0.1); }