morse-code / lib /synth.js
RemiFabre
feat: initial Reachy Mini Morse Code app
843a4b2
Raw
History Blame Contribute Delete
3.25 kB
/**
* 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 = [];
}
}