/** * 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);