/** * The "wire code": the bridge between symbolic Morse and physical impulses. * * Encoding (emit): morse tokens -> a schedule of onset times (ms from start). * - dot = 1 impulse * - dash = 2 impulses spaced by `dahGapMs` * - elem/letter/word gaps advance the cursor between elements * * Decoding (listen): a list of detected onset times (ms) -> morse -> text. * - Cluster onsets separated by < the dah/elem decision boundary into one * element (1 impulse = dot, >=2 = dash). * - Classify the gap between clusters as elem / letter / word. * * Onset-only: nothing here depends on how *long* a sound is, so a robot's * antenna click and a phone's short beep are the same on the wire. Pure * module (no DOM / audio / SDK) — unit-tested directly. */ import { morseToTokens, morseToText, textToMorse } from "./morse.js"; import { DEFAULT_TIMING } from "./timing.js"; /** * morse tokens -> onset schedule. * @returns {number[]} onset times in ms, starting at 0, sorted ascending. */ export function tokensToOnsets(tokens, timing = DEFAULT_TIMING) { const onsets = []; let cursor = 0; for (const tok of tokens) { switch (tok) { case "dot": onsets.push(cursor); break; case "dah": onsets.push(cursor); cursor += timing.dahGapMs; onsets.push(cursor); break; case "elemGap": cursor += timing.elemGapMs; break; case "letterGap": cursor += timing.letterGapMs; break; case "wordGap": cursor += timing.wordGapMs; break; default: break; } } return onsets; } /** * text -> everything an emitter needs. * @returns {{morse:string, onsets:number[], durationMs:number, skipped:string[]}} */ export function textToSchedule(text, timing = DEFAULT_TIMING) { const { morse, skipped } = textToMorse(text); const onsets = tokensToOnsets(morseToTokens(morse), timing); const durationMs = onsets.length ? onsets[onsets.length - 1] + timing.clickMs : 0; return { morse, onsets, durationMs, skipped }; } /** * Detected onset times (ms, any order) -> reconstructed morse string. * Robust to jitter via the geometric decision boundaries in `timing`. */ export function onsetsToMorse(onsetTimes, timing = DEFAULT_TIMING) { const onsets = [...onsetTimes].sort((a, b) => a - b); if (onsets.length === 0) return ""; // 1) Cluster impulses into elements. A gap below the dah/elem boundary // means "same element" (the two taps of a dash). const clusters = []; // { size, gapBefore } gapBefore = ioi from prev cluster's last onset let size = 1; let prevClusterLast = null; for (let i = 1; i <= onsets.length; i++) { const ioi = i < onsets.length ? onsets[i] - onsets[i - 1] : Infinity; if (i < onsets.length && ioi < timing.decisions.elem) { size += 1; // still same element continue; } // close current cluster const gapBefore = prevClusterLast === null ? null : onsets[i - size] - prevClusterLast; clusters.push({ size, gapBefore }); prevClusterLast = onsets[i - 1]; size = 1; } // 2) Walk clusters, emitting symbols and letter/word separators. const d = timing.decisions; let out = ""; clusters.forEach((c, idx) => { if (idx > 0) { const g = c.gapBefore; if (g >= d.word) out += " / "; else if (g >= d.letter) out += " "; // else elem gap -> no separator (same letter) } out += c.size >= 2 ? "-" : "."; }); return out; } /** * Detected onsets -> { morse, text }. */ export function decodeOnsets(onsetTimes, timing = DEFAULT_TIMING) { const morse = onsetsToMorse(onsetTimes, timing); return { morse, text: morseToText(morse) }; }