File size: 3,252 Bytes
843a4b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
 * 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 = [];
    }
}