morse-code / lib /timing.js
RemiFabre
tune: calibrate speed presets from through-air mic test (unit floor ~120ms)
5eb6ab5
Raw
History Blame Contribute Delete
2.87 kB
/**
* 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);