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