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