/** * Morse code tables + text <-> morse-string conversion. * * A "morse string" here uses the conventional ASCII rendering: * - '.' dot * - '-' dash * - ' ' (single space) gap between letters * - '/' gap between words (we accept ' / ' or '/' on input) * * Example: "SOS" <-> "... --- ..." * * This module is pure (no DOM, no audio, no SDK) so it is unit-tested * directly in Node. The physical *timing* of dots/dashes lives in * wire.js + timing.js; this layer is purely symbolic. */ // International Morse (ITU): letters, digits, and the common punctuation // that survives an impulse channel. Prosigns are intentionally omitted. export const CHAR_TO_MORSE = Object.freeze({ A: ".-", B: "-...", C: "-.-.", D: "-..", E: ".", F: "..-.", G: "--.", H: "....", I: "..", J: ".---", K: "-.-", L: ".-..", M: "--", N: "-.", O: "---", P: ".--.", Q: "--.-", R: ".-.", S: "...", T: "-", U: "..-", V: "...-", W: ".--", X: "-..-", Y: "-.--", Z: "--..", 0: "-----", 1: ".----", 2: "..---", 3: "...--", 4: "....-", 5: ".....", 6: "-....", 7: "--...", 8: "---..", 9: "----.", ".": ".-.-.-", ",": "--..--", "?": "..--..", "'": ".----.", "!": "-.-.--", "/": "-..-.", "(": "-.--.", ")": "-.--.-", "&": ".-...", ":": "---...", ";": "-.-.-.", "=": "-...-", "+": ".-.-.", "-": "-....-", "_": "..--.-", '"': ".-..-.", "$": "...-..-", "@": ".--.-.", }); // Reverse lookup: morse pattern -> character. export const MORSE_TO_CHAR = Object.freeze( Object.fromEntries(Object.entries(CHAR_TO_MORSE).map(([c, m]) => [m, c])), ); /** True if `ch` (single char) can be transmitted. Case-insensitive. */ export function isEncodable(ch) { return Object.prototype.hasOwnProperty.call(CHAR_TO_MORSE, ch.toUpperCase()); } /** * Encode arbitrary text to a morse string. * * - Case-insensitive. * - Runs of whitespace collapse to a single word break ('/'). * - Unknown characters are dropped, but reported in `.skipped` so the UI * can warn ("é, 😀 can't be sent in Morse"). * * Returns { morse, skipped }. */ export function textToMorse(text) { const words = String(text).trim().toUpperCase().split(/\s+/).filter(Boolean); const skipped = []; const encodedWords = words.map((word) => { const letters = []; for (const ch of word) { const code = CHAR_TO_MORSE[ch]; if (code) letters.push(code); else skipped.push(ch); } return letters.join(" "); }).filter(Boolean); return { morse: encodedWords.join(" / "), skipped }; } /** * Decode a morse string back to text. Tolerant of input shape: * "... --- ...", "...|---|...", "-.-. -.-- / -.-." etc. * Letter separators: one or more spaces. Word separators: '/' (optionally * space-padded). Unknown patterns become '?'. */ export function morseToText(morse) { return String(morse) .trim() .split(/\s*\/\s*|\s{2,}\/?\s*/) // word breaks .map((word) => word .trim() .split(/\s+/) .filter(Boolean) .map((pat) => MORSE_TO_CHAR[pat] ?? (pat ? "?" : "")) .join(""), ) .filter((w) => w.length > 0) .join(" "); } /** * Flatten a morse string into the element/gap token stream the wire layer * consumes. Tokens: * 'dot' | 'dah' | 'elemGap' | 'letterGap' | 'wordGap' * * No leading/trailing gaps are emitted. */ export function morseToTokens(morse) { const tokens = []; const words = String(morse).trim().split(/\s*\/\s*/).filter(Boolean); words.forEach((word, wi) => { if (wi > 0) tokens.push("wordGap"); const letters = word.trim().split(/\s+/).filter(Boolean); letters.forEach((letter, li) => { if (li > 0) tokens.push("letterGap"); [...letter].forEach((sym, si) => { if (si > 0) tokens.push("elemGap"); tokens.push(sym === "." ? "dot" : "dah"); }); }); }); return tokens; }