/** * Web Audio emitter — plays an onset schedule as audible clicks/beeps from * the device speaker (phone or laptop). This is the "device transmits" path; * the robot path lives in robot-tapper.js. Both consume the same schedule * from wire.js, so a beeping phone and a tapping robot are interoperable. * * Each onset is a short percussive blip: a fast-decaying tone with a hard * attack so the listener's onset detector keys cleanly off its start. The * default tone sits in the detector's high-pass band (~2 kHz+) for a clean, * Morse-buzzer feel. */ export class Synth { constructor({ freq = 2200, clickMs = 50 } = {}) { this.freq = freq; this.clickMs = clickMs; this.ctx = null; this._stopFns = []; } _ensureCtx() { if (!this.ctx) { const AC = window.AudioContext || window.webkitAudioContext; this.ctx = new AC(); } if (this.ctx.state === "suspended") this.ctx.resume(); return this.ctx; } /** Schedule one blip at AudioContext time `when` (seconds). */ _blip(when, { freq = this.freq, durMs = this.clickMs } = {}) { const ctx = this.ctx; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.value = freq; const dur = durMs / 1000; // Hard attack (1 ms), exponential-ish decay → percussive click. gain.gain.setValueAtTime(0.0001, when); gain.gain.exponentialRampToValueAtTime(0.9, when + 0.001); gain.gain.exponentialRampToValueAtTime(0.0001, when + dur); osc.connect(gain).connect(ctx.destination); osc.start(when); osc.stop(when + dur + 0.02); } /** * Play a schedule (onset times in ms from start). * @param {number[]} onsetsMs * @param {object} [o] * @param {(i:number, total:number)=>void} [o.onBlip] progress per blip * @param {()=>void} [o.onDone] * @returns {()=>void} cancel function */ play(onsetsMs, { onBlip, onDone, clickMs } = {}) { const ctx = this._ensureCtx(); const t0 = ctx.currentTime + 0.08; // small lead so the first blip isn't clipped const dur = clickMs ?? this.clickMs; onsetsMs.forEach((ms) => this._blip(t0 + ms / 1000, { durMs: dur })); // UI progress + completion via wall-clock timers (audio is sample-accurate; // these are just for the highlight animation). const startWall = performance.now(); const total = onsetsMs.length; const timers = []; onsetsMs.forEach((ms, i) => { timers.push(setTimeout(() => onBlip?.(i, total), ms + 80)); }); const endMs = (onsetsMs.length ? onsetsMs[onsetsMs.length - 1] : 0) + dur + 120; const doneTimer = setTimeout(() => onDone?.(), endMs); const cancel = () => { timers.forEach(clearTimeout); clearTimeout(doneTimer); }; this._stopFns.push(cancel); void startWall; return cancel; } /** Cancel any pending UI timers (scheduled audio blips are tiny and let ring). */ stop() { this._stopFns.forEach((fn) => fn()); this._stopFns = []; } }