File size: 2,869 Bytes
843a4b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5eb6ab5
 
 
 
 
 
 
 
 
 
 
843a4b2
5eb6ab5
 
 
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
/**
 * Timing configuration for the impulse wire code.
 *
 * Everything is expressed as a multiple of one base `unitMs`, the single
 * speed knob. The gap hierarchy must stay well-separated so the decoder can
 * bin each inter-onset interval unambiguously:
 *
 *   dahGapMs  < elemGapMs < letterGapMs < wordGapMs
 *
 * Defaults are deliberately *slow and robust* (large gaps) — through-air
 * impulse detection with room reverb needs generous spacing. Calibration on
 * real hardware can tighten these; nothing in the codec hard-codes a value.
 *
 * Note: the detector enforces a minimum onset separation (~150-200 ms) to
 * reject reverb tails, so `dahGapMs` (the *smallest* meaningful gap, between
 * the two taps of a dash) must stay comfortably above that floor.
 */

/**
 * Build a full timing profile from a base unit.
 *
 * @param {number} unitMs base time unit in milliseconds
 * @returns {{unitMs:number, dahGapMs:number, elemGapMs:number,
 *            letterGapMs:number, wordGapMs:number, clickMs:number,
 *            decisions:{elem:number, letter:number, word:number}}}
 */
export function makeTiming(unitMs = 120) {
    const u = Math.max(40, Math.round(unitMs));
    const dahGapMs = 2 * u; // between the two taps of a dash
    const elemGapMs = 4 * u; // between elements of one letter
    const letterGapMs = 8 * u; // between letters
    const wordGapMs = 14 * u; // between words
    return Object.freeze({
        unitMs: u,
        dahGapMs,
        elemGapMs,
        letterGapMs,
        wordGapMs,
        // Nominal audible click length on the speaker (synth only). The
        // detector keys off onsets, so this does not affect decoding.
        clickMs: Math.min(60, Math.round(u * 0.5)),
        // Decision boundaries (geometric midpoints) used by the decoder to
        // classify an observed inter-onset interval into a gap class.
        decisions: Object.freeze({
            elem: Math.round(Math.sqrt(dahGapMs * elemGapMs)), // dah|elem split
            letter: Math.round(Math.sqrt(elemGapMs * letterGapMs)),
            word: Math.round(Math.sqrt(letterGapMs * wordGapMs)),
        }),
    });
}

/**
 * Named speed presets (unit in ms). Smaller = faster.
 *
 * Floor calibrated through-air on a MacBook (tools/calibrate_audio.py,
 * 2026-06-01): unit=120 decoded 18/18, unit=90 failed because a dash's two
 * taps (2×unit = 180 ms) dropped below the detector's ~200 ms reverb-rejection
 * window and merged into one. So `dahGap = 2×unit` must stay comfortably above
 * ~200 ms → keep unit ≳ 120. `brisk` is pinned to that proven floor; `normal`
 * and `relaxed` add margin since the robot's antenna clicks reverberate more
 * than a speaker blip.
 */
export const SPEED_PRESETS = Object.freeze({
    relaxed: 190,
    normal: 150,
    brisk: 120,
});

export const DEFAULT_TIMING = makeTiming(SPEED_PRESETS.normal);