Spaces:
Running
Running
| /** | |
| * 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 = []; | |
| } | |
| } | |