$sloprun.dev

Morse Translator

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 7 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

Type a message and it becomes dots and dashes as you type. Press play and the browser beeps it out for real, lighting up each dot and dash exactly as you hear it, and the speed slider takes it from a beginner's crawl to faster than most people can follow. Flip the switch to go the other way and paste dots and dashes back into words — anything it can't read comes back marked in red instead of quietly vanishing.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/morse-translator" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Morse Translator — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/morse-translator

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 870 lines 35.2 KB index.html
demos/morse-translator/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Morse Translator</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* Native UI (range track/thumb, textarea scrollbar, caret) has to follow the theme too.
   Kept outside the token block above so that block stays verbatim. */
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }

/* ---------- base ---------- */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; padding: 22px 18px 40px; line-height: 1.5; overflow-x: hidden; }
.wrap { max-width: 760px; margin: 0 auto; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
button { font: inherit; color: inherit; }

/* ---------- header ---------- */
.eyebrow {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--accent); margin: 0 0 6px;
  display: flex; align-items: center; gap: 8px;
}
.eyebrow .led {
  width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft); flex: none;
}
h1 { font-size: clamp(22px, 5vw, 30px); line-height: 1.15; margin: 0 0 8px; letter-spacing: -.02em; }
.lede { margin: 0 0 20px; color: var(--muted); font-size: 15px; max-width: 58ch; }

/* ---------- panels ---------- */
.panel {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); margin-bottom: 14px; overflow: hidden;
}
.phead {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding: 9px 12px; border-bottom: 1px solid var(--line);
}
.ptitle {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted); margin: 0; font-weight: 600;
}
.spacer { flex: 1 1 auto; min-width: 0; }
.pbody { padding: 14px 12px; }

/* ---------- controls ---------- */
.btn {
  font-family: var(--font-mono); font-size: 12px; line-height: 1;
  padding: 7px 10px; border: 1px solid var(--line); border-radius: 7px;
  background: transparent; cursor: pointer; transition: background .12s, border-color .12s, color .12s;
  white-space: nowrap;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.btn.ok { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.btn.bad { color: var(--danger); border-color: var(--danger); }
.btn-primary {
  background: var(--accent); border-color: var(--accent); color: var(--surface);
  font-weight: 600; padding: 8px 14px;
}
.btn-primary:hover { background: var(--accent); border-color: var(--accent); color: var(--surface); filter: brightness(1.08); }

/* direction switch */
.seg { display: flex; position: relative; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.seg label {
  font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .04em;
  padding: 7px 11px; cursor: pointer; color: var(--muted); background: transparent;
  transition: background .12s, color .12s; user-select: none; white-space: nowrap;
}
.seg label + label { border-left: 1px solid var(--line); }
.seg input:checked + label { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.seg input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: -2px; }

textarea {
  width: 100%; min-height: 92px; resize: vertical; padding: 12px;
  font-family: var(--font-mono); font-size: 15px; line-height: 1.55;
  color: var(--ink); background: var(--bg);
  border: 1px solid var(--line); border-radius: 8px;
}
textarea:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-soft); }

/* stat rail */
.stats {
  display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 11px;
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
}
.stats b { color: var(--ink); font-weight: 600; }

/* sample chips */
.samples { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 11px; }
.samples .slabel {
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted); margin-right: 2px;
}
.chipbtn {
  font-family: var(--font-mono); font-size: 11.5px; line-height: 1;
  padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
  background: var(--bg); color: var(--muted); cursor: pointer; white-space: nowrap;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis;
  transition: color .12s, border-color .12s, background .12s;
}
.chipbtn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.chipbtn[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }

/* transport */
.transport { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 13px; }
.wpm { display: flex; align-items: center; gap: 8px; flex: 1 1 190px; max-width: 340px; min-width: 145px; }
.wpm label { font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
input[type=range] { flex: 1 1 auto; min-width: 60px; accent-color: var(--accent); height: 28px; }
.wpmval { font-family: var(--font-mono); font-size: 12px; color: var(--ink); min-width: 52px; text-align: right; }

/* playback progress */
.prog { height: 3px; border-radius: 2px; background: var(--line); overflow: hidden; margin-top: 15px; }
.prog > span { display: block; height: 100%; width: 0; background: var(--accent); }

/* ---------- morse output ---------- */
.morse { font-family: var(--font-mono); font-size: 19px; line-height: 2.1; overflow-wrap: break-word; }
.ch { display: inline-block; padding: 1px 3px; border-radius: 5px; margin-right: 4px; }
.ch.on { background: var(--accent-soft); color: var(--accent); }
.sym { transition: background .06s linear, color .06s linear; padding: 0 1px; border-radius: 3px; letter-spacing: .06em; }
.sym.on { background: var(--accent); color: var(--surface); }
.wsep { color: var(--muted); margin-right: 4px; }
.unknown { color: var(--danger); border-bottom: 1px dashed var(--danger); margin-right: 4px; }

/* decoded text */
.decoded { font-family: var(--font-mono); font-size: 17px; word-break: break-word; }
.decoded .bad { color: var(--danger); }
.badge {
  font-family: var(--font-mono); font-size: 11px; padding: 3px 7px; border-radius: 5px;
  border: 1px solid var(--line); color: var(--muted); white-space: nowrap;
}
.badge.ok { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.badge.warn { color: var(--danger); border-color: var(--danger); }
.empty { color: var(--muted); font-style: italic; font-size: 14px; }
#playnote.done { color: var(--run); }

/* ---------- reference ---------- */
details.ref { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
details.ref > summary {
  cursor: pointer; padding: 11px 12px; font-family: var(--font-mono);
  font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
  list-style: none;
}
details.ref > summary::-webkit-details-marker { display: none; }
details.ref > summary::before { content: "\25B8 "; color: var(--accent); }
details.ref[open] > summary::before { content: "\25BE "; }
.refgrid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
  gap: 6px; padding: 0 12px 14px;
}
.refkey {
  display: flex; align-items: center; gap: 8px;
  text-align: left; background: var(--bg); border: 1px solid var(--line);
  border-radius: 6px; padding: 7px 9px; cursor: pointer;
  transition: border-color .12s, background .12s;
}
.refkey:hover { border-color: var(--accent); background: var(--accent-soft); }
.refkey .k { font-family: var(--font-mono); font-weight: 700; font-size: 13px; color: var(--accent); width: 1ch; }
.refkey .m { font-family: var(--font-mono); font-size: 12.5px; letter-spacing: .06em; }

footer {
  margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

@media (max-width: 420px) {
  body { padding: 18px 12px 32px; }
  .morse { font-size: 17px; }
  .pbody { padding: 12px 10px; }
}
@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <p class="eyebrow"><span class="led" aria-hidden="true"></span>Telegraph toy</p>
  <h1>Morse Translator</h1>
  <p class="lede">Type a message and watch it turn into dots and dashes as you go. Press play to
  hear it beeped out, one symbol at a time, as fast or as slow as you like.</p>

  <!-- ============ INPUT ============ -->
  <section class="panel">
    <div class="phead">
      <h2 class="ptitle">Input</h2>
      <div class="spacer"></div>
      <div class="seg" role="radiogroup" aria-label="Translation direction">
        <input type="radio" name="mode" id="mode-enc" value="encode" autocomplete="off" checked>
        <label for="mode-enc">TEXT &rarr; MORSE</label>
        <input type="radio" name="mode" id="mode-dec" value="decode" autocomplete="off">
        <label for="mode-dec">MORSE &rarr; TEXT</label>
      </div>
    </div>
    <div class="pbody">
      <label class="sr" for="src" id="srclabel">Text to translate</label>
      <textarea id="src" maxlength="600" spellcheck="false" autocapitalize="off"
        autocomplete="off" placeholder="Type anything">hello world</textarea>
      <div class="samples" id="samples"><span class="slabel">Try</span></div>
      <div class="stats" id="stats" aria-live="off"></div>
    </div>
  </section>

  <!-- ============ DECODED (decode mode only) ============ -->
  <section class="panel" id="p-text" hidden>
    <div class="phead">
      <h2 class="ptitle">Decoded text</h2>
      <span class="badge" id="decstatus"></span>
      <div class="spacer"></div>
      <button class="btn" type="button" data-copy="text">Copy</button>
    </div>
    <div class="pbody"><div class="decoded" id="out-text"></div></div>
  </section>

  <!-- ============ MORSE ============ -->
  <section class="panel">
    <div class="phead">
      <h2 class="ptitle">Morse</h2>
      <div class="spacer"></div>
      <button class="btn" type="button" data-copy="morse">Copy</button>
    </div>
    <div class="pbody">
      <div class="morse" id="out-morse" aria-live="off"></div>
      <div class="prog" aria-hidden="true"><span id="progbar"></span></div>
      <div class="transport">
        <button class="btn btn-primary" type="button" id="play" aria-pressed="false">&#9654;&nbsp; Play</button>
        <div class="wpm">
          <label for="wpm">Speed</label>
          <input type="range" id="wpm" min="5" max="35" step="1" value="15" autocomplete="off" aria-valuetext="15 words per minute">
          <span class="wpmval" id="wpmval">15 wpm</span>
        </div>
      </div>
      <p class="stats" id="playnote" aria-live="polite"></p>
    </div>
  </section>

  <!-- ============ REFERENCE ============ -->
  <details class="ref">
    <summary>The alphabet &mdash; click any letter to hear it</summary>
    <div class="refgrid" id="refgrid"></div>
  </details>

  <footer>
    <span>demo &middot; sloprun.dev</span>
    <span id="engine">morse &middot; webaudio</span>
  </footer>
</div>

<script>
(function () {
  "use strict";

  /* ---------- platform theme bridge ---------- */
  addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
    }
  });

  /* ---------- the code ---------- */
  var MORSE = {
    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":"----.",
    ".":".-.-.-", ",":"--..--", "?":"..--..", "'":".----.", "!":"-.-.--",
    "/":"-..-.", "(":"-.--.", ")":"-.--.-", "&":".-...", ":":"---...",
    ";":"-.-.-.", "=":"-...-", "+":".-.-.", "-":"-....-", "_":"..--.-",
    '"':".-..-.", "$":"...-..-", "@":".--.-."
  };
  var UNMORSE = {};
  for (var mk in MORSE) { if (!(MORSE[mk] in UNMORSE)) UNMORSE[MORSE[mk]] = mk; }

  /* everyday things worth hearing first — swapped per direction */
  var SAMPLES = {
    encode: ["hello world", "SOS", "meet me at 8", "happy birthday"],
    decode: [
      "... --- ...",
      ".... .. / - .... . .-. .",
      "-- --- .-. . / - . .- / .--. .-.. . .- ... ."
    ]
  };

  /* ---------- state ---------- */
  var state = { mode: "encode", wpm: 15, items: [], playing: false, decodedText: "" };
  var wpmTimer = 0;
  var audio = {
    ctx: null, nodes: [], raf: 0, t0: 0, events: [], chars: [], total: 0,
    ei: 0, ci: 0, curCi: null, curSi: null
  };

  var $ = function (id) { return document.getElementById(id); };
  var srcEl = $("src"), statsEl = $("stats"), playBtn = $("play"),
      wpmEl = $("wpm"), wpmVal = $("wpmval"), noteEl = $("playnote"),
      progEl = $("progbar"), samplesEl = $("samples");

  /* ---------- encoding ---------- */
  function normalize(text) {
    return text
      .replace(/[‘’ʼ]/g, "'")
      .replace(/[“”]/g, '"')
      .replace(/[–—−]/g, "-")
      .replace(/…/g, "...");
  }

  function encode(text) {
    var items = [], i, ch, low;
    text = normalize(text);
    for (i = 0; i < text.length; i++) {
      ch = text.charAt(i);
      if (/\s/.test(ch)) {
        if (items.length && items[items.length - 1].kind !== "space") items.push({ kind: "space" });
        continue;
      }
      low = ch.toLowerCase();
      if (Object.prototype.hasOwnProperty.call(MORSE, low)) {
        items.push({ kind: "char", ch: ch, key: low, morse: MORSE[low] });
      } else {
        items.push({ kind: "unknown", ch: ch });
      }
    }
    while (items.length && items[items.length - 1].kind === "space") items.pop();
    return items;
  }

  function decodeMorse(raw) {
    var norm = raw.replace(/[•·∙]/g, ".").replace(/[–—_]/g, "-");
    var words = norm.split(/\s*\/\s*|\s{3,}|[\n\r]+/);
    var out = "", total = 0, bad = 0, wi, tokens, ti, tk, chunk;
    for (wi = 0; wi < words.length; wi++) {
      tokens = words[wi].trim().split(/\s+/).filter(Boolean);
      if (!tokens.length) continue;
      /* build the word first: a group that decodes to nothing must not emit a
         separator, or "-.-. --.- / hello" comes back with a dangling space */
      chunk = "";
      for (ti = 0; ti < tokens.length; ti++) {
        tk = tokens[ti].replace(/[^.\-]/g, "");
        total++;
        /* a token with no dots or dashes in it at all is still something the user
           typed — flag it rather than dropping it, or pasting prose looks like a no-op */
        if (tk && Object.prototype.hasOwnProperty.call(UNMORSE, tk)) chunk += UNMORSE[tk];
        else { chunk += "¿"; bad++; }
      }
      if (!chunk) continue;
      if (out) out += " ";
      out += chunk;
    }
    return { text: out, total: total, bad: bad };
  }

  function morseString(items) {
    var parts = [];
    for (var i = 0; i < items.length; i++) {
      if (items[i].kind === "char") parts.push(items[i].morse);
      else if (items[i].kind === "space") parts.push("/");
    }
    return parts.join(" ").replace(/^(\s*\/\s*)+/, "").replace(/(\s*\/\s*)+$/, "");
  }

  /* ---------- timeline ---------- */
  function buildTimeline(items, wpm) {
    var unit = 1200 / wpm, t = 0, ev = [], chars = [], i, j, m, dur, cs;
    for (i = 0; i < items.length; i++) {
      if (items[i].kind === "space") { t += 4 * unit; continue; }
      if (items[i].kind === "unknown") { t += 3 * unit; continue; }
      m = items[i].morse;
      cs = t;
      for (j = 0; j < m.length; j++) {
        dur = (m.charAt(j) === "." ? 1 : 3) * unit;
        ev.push({ start: t, end: t + dur, ci: i, si: j });
        t += dur + unit;
      }
      /* a character stays lit from its first dot to the tail of its last one */
      chars.push({ ci: i, start: cs, end: t - unit });
      t += 2 * unit; /* intra-gap already added -> gap between letters = 3 units */
    }
    return { events: ev, chars: chars, total: t, unit: unit };
  }

  function fmtDur(ms) {
    var s = ms / 1000;
    if (s < 10) return s.toFixed(1) + "s";
    /* round to whole seconds BEFORE choosing the format, else 59.6s prints "60s" */
    var r = Math.round(s);
    if (r < 60) return r + "s";
    return Math.floor(r / 60) + "m " + String(r % 60).padStart(2, "0") + "s";
  }

  /* ---------- render ---------- */
  function spoken(m) {
    return m.split("").map(function (c) { return c === "." ? "dot" : "dash"; }).join(" ");
  }

  function esc(s) {
    return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
      .replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
  }

  function render() {
    var raw = srcEl.value, decoded = null, text;

    if (state.mode === "decode") {
      decoded = decodeMorse(raw);
      text = decoded.text;
    } else {
      text = raw;
    }
    var items = state.items = encode(text);
    state.decodedText = decoded ? decoded.text : "";

    /* decoded panel */
    var pText = $("p-text");
    if (state.mode === "decode") {
      pText.hidden = false;
      var dt = $("out-text");
      dt.innerHTML = decoded.text
        ? esc(decoded.text).replace(/¿/g, '<span class="bad">¿</span>')
        : '<span class="empty">Paste morse above &mdash; dots and dashes, a space between letters, a slash between words.</span>';
      var st = $("decstatus");
      if (!decoded.total) { st.className = "badge"; st.textContent = "0 letters"; }
      else if (decoded.bad) { st.className = "badge warn"; st.textContent = decoded.bad + " unreadable / " + decoded.total; }
      else { st.className = "badge ok"; st.textContent = decoded.total + (decoded.total === 1 ? " letter read ✓" : " letters read ✓"); }
    } else {
      pText.hidden = true;
    }

    /* morse */
    var mHtml = "", i, j, m;
    for (i = 0; i < items.length; i++) {
      /* real spaces between characters, not just margin — so selecting the line
         and copying it out of the page yields readable morse */
      if (items[i].kind === "space") { mHtml += '<span class="wsep" role="img" aria-label="word break">/</span> '; continue; }
      if (items[i].kind === "unknown") { mHtml += '<span class="unknown" title="no morse for this character">' + esc(items[i].ch) + "</span> "; continue; }
      m = items[i].morse;
      mHtml += '<span class="ch" data-ci="' + i + '" role="img" aria-label="' +
        esc(items[i].ch.toUpperCase()) + ", " + spoken(m) + '">';
      for (j = 0; j < m.length; j++) {
        mHtml += '<span class="sym" data-ci="' + i + '" data-si="' + j + '">' + (m.charAt(j) === "." ? "·" : "–") + "</span>";
      }
      mHtml += "</span> ";
    }
    $("out-morse").innerHTML = mHtml ||
      '<span class="empty">' + (state.mode === "decode" ? "Nothing to show yet." : "Type something above.") + "</span>";

    syncSamples();
    updateStats();
  }

  function updateStats() {
    var items = state.items, chars = 0, syms = 0, unknown = 0, words = 1, i;
    if (!items.length) words = 0;
    for (i = 0; i < items.length; i++) {
      if (items[i].kind === "char") { chars++; syms += items[i].morse.length; }
      else if (items[i].kind === "unknown") unknown++;
      else if (items[i].kind === "space") words++;
    }
    var tl = buildTimeline(items, state.wpm);
    statsEl.innerHTML =
      '<span>letters <b>' + chars + '</b></span>' +
      '<span>words <b>' + words + '</b></span>' +
      '<span>dots+dashes <b>' + syms + '</b></span>' +
      '<span>playtime <b>' + fmtDur(tl.total) + '</b></span>' +
      (unknown ? '<span style="color:var(--danger)">no morse for <b style="color:var(--danger)">' + unknown + '</b></span>' : "");
  }

  /* ---------- audio ---------- */
  /* Created lazily, and only ever from inside a click or key press, so no browser
     ever sees an AudioContext built without a user gesture. */
  function ensureCtx() {
    if (!audio.ctx) {
      var AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return null;
      audio.ctx = new AC();
    }
    if (audio.ctx.state === "suspended" && audio.ctx.resume) audio.ctx.resume();
    return audio.ctx;
  }

  /* Highlighting is split in two: the LETTER stays lit for as long as it is being
     keyed, while only the individual dot or dash lights while its tone is actually
     sounding. Doing it in one pass made the letter blink off during every gap inside it. */
  function marksFor(ci) {
    return ci == null ? [] : document.querySelectorAll('[data-ci="' + ci + '"]');
  }
  function setSym(ci, si, on) {
    if (ci == null || si == null) return;
    var els = marksFor(ci), i;
    for (i = 0; i < els.length; i++) {
      if (els[i].classList.contains("sym") &&
          parseInt(els[i].getAttribute("data-si"), 10) === si) els[i].classList.toggle("on", on);
    }
  }
  function setChar(ci, on) {
    if (ci == null) return;
    var els = marksFor(ci), i;
    for (i = 0; i < els.length; i++) {
      if (!els[i].classList.contains("sym")) els[i].classList.toggle("on", on);
    }
  }
  function highlight(ci, si) {
    if (ci === audio.curCi && si === audio.curSi) return;
    setSym(audio.curCi, audio.curSi, false);
    if (ci !== audio.curCi) { setChar(audio.curCi, false); setChar(ci, true); }
    setSym(ci, si, true);
    audio.curCi = ci; audio.curSi = si;
  }
  function clearHighlights() {
    audio.curCi = null; audio.curSi = null;
    var on = document.querySelectorAll(".on");
    for (var i = 0; i < on.length; i++) on[i].classList.remove("on");
  }

  function scheduleBeeps(ctx, events, t0, unitMs, sink) {
    var i, e, osc, g, s, en;
    var atk = Math.min(0.006, (unitMs / 1000) * 0.25);
    for (i = 0; i < events.length; i++) {
      e = events[i];
      s = t0 + e.start / 1000;
      en = t0 + e.end / 1000;
      osc = ctx.createOscillator();
      g = ctx.createGain();
      osc.type = "sine";
      osc.frequency.value = 620;
      g.gain.setValueAtTime(0.0001, s);
      g.gain.exponentialRampToValueAtTime(0.22, s + atk);
      g.gain.setValueAtTime(0.22, Math.max(s + atk, en - atk));
      g.gain.exponentialRampToValueAtTime(0.0001, en);
      osc.connect(g);
      g.connect(ctx.destination);
      osc.start(s);
      osc.stop(en + 0.02);
      if (sink) sink.push(osc, g);
      else osc.onended = (function (o, gn) {
        return function () { try { o.disconnect(); gn.disconnect(); } catch (err) {} };
      })(osc, g);
    }
  }

  function idleNote() {
    var unit = Math.round(1200 / state.wpm);
    return "dot " + unit + " ms · dash " + unit * 3 + " ms · press ▶ (or ⌘/Ctrl+Enter) to play";
  }

  /* Dragging the speed slider mid-playback re-keys the message on a short delay.
     Anything that interrupts playback must kill that pending timer too, otherwise
     typing (or switching direction, or hiding the tab) is followed by a surprise beep. */
  function cancelReKey() { if (wpmTimer) { clearTimeout(wpmTimer); wpmTimer = 0; } }

  /* One way back to rest from every state: playing, queued, or just-finished. */
  function halt() {
    cancelReKey();
    if (state.playing) { stopPlayback(false); return; }
    progEl.style.width = "0";
    noteEl.classList.remove("done");
    noteEl.textContent = idleNote();
  }

  function stopPlayback(finished) {
    cancelReKey();
    if (audio.raf) { cancelAnimationFrame(audio.raf); audio.raf = 0; }
    /* fade the tail rather than cutting the waveform — a hard stop clicks */
    var now = audio.ctx ? audio.ctx.currentTime : 0;
    for (var i = 0; i < audio.nodes.length; i++) {
      var n = audio.nodes[i];
      try {
        if (n.gain) { n.gain.cancelScheduledValues(now); n.gain.setTargetAtTime(0.0001, now, 0.004); }
      } catch (err) {}
      try { if (n.stop) n.stop(now + 0.05); } catch (err2) {}
    }
    var dead = audio.nodes;
    setTimeout(function () {
      for (var k = 0; k < dead.length; k++) { try { dead[k].disconnect(); } catch (e) {} }
    }, 120);
    audio.nodes = [];
    audio.events = [];
    audio.chars = [];
    state.playing = false;
    playBtn.innerHTML = "▶&nbsp; Play";
    playBtn.setAttribute("aria-pressed", "false");
    clearHighlights();
    progEl.style.width = finished ? "100%" : "0";
    noteEl.textContent = finished ? "Played to the end ✓" : idleNote();
    noteEl.classList.toggle("done", !!finished);
  }

  function tick() {
    if (!state.playing) return;
    var ms = (audio.ctx.currentTime - audio.t0) * 1000, ev = audio.events, chs = audio.chars;

    /* pointers only ever move forward, so long messages stay O(1) per frame */
    while (audio.ei < ev.length && ev[audio.ei].end <= ms) audio.ei++;
    while (audio.ci < chs.length && chs[audio.ci].end <= ms) audio.ci++;
    var e = ev[audio.ei], c = chs[audio.ci];
    var si = (e && ms >= e.start) ? e.si : null;
    var ci = (c && ms >= c.start) ? c.ci : null;
    highlight(ci, si);

    var pct = audio.total > 0 ? Math.max(0, Math.min(1, ms / audio.total)) : 0;
    progEl.style.width = (pct * 100).toFixed(2) + "%";

    if (ms >= audio.total + 120) { stopPlayback(true); return; }
    audio.raf = requestAnimationFrame(tick);
  }

  function play(items) {
    /* build first: an empty box must not spin up an AudioContext to say "nothing here" */
    var tl = buildTimeline(items, state.wpm);
    if (!tl.events.length) {
      noteEl.classList.remove("done");
      progEl.style.width = "0";
      noteEl.textContent = "Nothing to play yet — type a message above.";
      return;
    }
    var ctx = ensureCtx();
    if (!ctx) { noteEl.textContent = "This browser has no Web Audio support."; return; }
    audio.events = tl.events;
    audio.chars = tl.chars;
    audio.total = tl.total;
    audio.ei = 0; audio.ci = 0;
    audio.t0 = ctx.currentTime + 0.08;
    audio.nodes = [];
    clearHighlights();
    scheduleBeeps(ctx, tl.events, audio.t0, tl.unit, audio.nodes);
    state.playing = true;
    playBtn.innerHTML = "■&nbsp; Stop";
    playBtn.setAttribute("aria-pressed", "true");
    noteEl.classList.remove("done");
    progEl.style.width = "0";
    noteEl.textContent = "Playing — " + fmtDur(tl.total) + " at " + state.wpm + " wpm.";
    audio.raf = requestAnimationFrame(tick);
  }

  /* single letter preview from the alphabet list */
  function playChar(key) {
    halt();
    var ctx = ensureCtx();
    if (!ctx) return;
    var tl = buildTimeline([{ kind: "char", ch: key, key: key, morse: MORSE[key] }], state.wpm);
    scheduleBeeps(ctx, tl.events, ctx.currentTime + 0.05, tl.unit, null);
  }

  /* ---------- copy ---------- */
  function flash(btn, label, ok) {
    var old = btn.getAttribute("data-label") || btn.textContent;
    btn.setAttribute("data-label", old);
    btn.textContent = label;
    btn.classList.toggle("ok", ok === true);
    btn.classList.toggle("bad", ok === false);
    clearTimeout(btn._t);
    btn._t = setTimeout(function () {
      btn.textContent = btn.getAttribute("data-label") || "Copy";
      btn.classList.remove("ok", "bad");
    }, 1400);
  }

  function copyText(str, btn) {
    var done = function (ok) { flash(btn, ok ? "Copied ✓" : "Copy failed", ok); };
    var fallback = function () {
      try {
        var ta = document.createElement("textarea");
        ta.value = str;
        ta.setAttribute("readonly", "");
        ta.style.position = "fixed";
        ta.style.opacity = "0";
        document.body.appendChild(ta);
        ta.select();
        var ok = document.execCommand("copy");
        document.body.removeChild(ta);
        done(!!ok);
      } catch (e) { done(false); }
    };
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(str).then(function () { done(true); }, fallback);
    } else fallback();
  }

  /* ---------- alphabet list ---------- */
  (function buildRef() {
    var order = "abcdefghijklmnopqrstuvwxyz0123456789".split(""), html = "", i, k;
    for (i = 0; i < order.length; i++) {
      k = order[i];
      html += '<button class="refkey" type="button" data-char="' + k + '" ' +
        'aria-label="' + k.toUpperCase() + ", " + spoken(MORSE[k]) + '. Play it.">' +
        '<span class="k" aria-hidden="true">' + k.toUpperCase() + "</span>" +
        '<span class="m" aria-hidden="true">' + MORSE[k].replace(/\./g, "·").replace(/-/g, "–") + "</span>" +
        "</button>";
    }
    $("refgrid").innerHTML = html;
  })();

  /* ---------- sample chips ---------- */
  /* rebuilt only when the direction changes, so typing never yanks focus out of one */
  function buildSamples() {
    var list = SAMPLES[state.mode], html = '<span class="slabel">Try</span>', i, s;
    for (i = 0; i < list.length; i++) {
      s = list[i];
      html += '<button class="chipbtn" type="button" aria-pressed="false" data-sample="' + esc(s) + '">' +
        esc(state.mode === "decode" ? s.replace(/\./g, "·").replace(/-/g, "–") : s) + "</button>";
    }
    samplesEl.innerHTML = html;
    syncSamples();
  }
  function syncSamples() {
    var btns = samplesEl.querySelectorAll(".chipbtn"), cur = srcEl.value.trim(), i;
    for (i = 0; i < btns.length; i++) {
      btns[i].setAttribute("aria-pressed", btns[i].getAttribute("data-sample") === cur ? "true" : "false");
    }
  }

  /* ---------- events ---------- */
  srcEl.addEventListener("input", function () {
    halt();
    render();
  });

  /* the one shortcut worth having: play without reaching for the mouse. Bound on the
     document because the hint promises it works wherever focus happens to be. */
  document.addEventListener("keydown", function (ev) {
    if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) {
      ev.preventDefault();
      togglePlay();
    }
  });

  /* everything about a direction EXCEPT translating the box contents — boot reuses this */
  function applyMode(next) {
    state.mode = next;
    /* morse runs ~5x longer than its plain text — a 600 char cap would freeze editing */
    srcEl.setAttribute("maxlength", next === "decode" ? "3600" : "600");
    srcEl.setAttribute("aria-label", next === "decode" ? "Morse code to decode" : "Text to translate");
    srcEl.setAttribute("placeholder", next === "decode"
      ? "... --- ...   space between letters, / between words"
      : "Type anything");
    buildSamples();
  }

  var radios = document.querySelectorAll('input[name="mode"]');
  for (var r = 0; r < radios.length; r++) {
    radios[r].addEventListener("change", function (ev) {
      var next = ev.target.value;
      if (next === state.mode) return;
      halt();
      /* carry the work across: swap in the other side of the translation */
      if (next === "decode") srcEl.value = morseString(state.items);
      else srcEl.value = decodeMorse(srcEl.value).text;
      applyMode(next);
      render();
      srcEl.focus();
    });
  }

  /* a speed drag fires ~60 input events a second; announcing each one buries the
     live region. The range's own aria-valuetext already reports the number. */
  wpmEl.addEventListener("pointerdown", function () { noteEl.setAttribute("aria-live", "off"); });
  wpmEl.addEventListener("change", function () { noteEl.setAttribute("aria-live", "polite"); });

  wpmEl.addEventListener("input", function () {
    var v = parseInt(wpmEl.value, 10);
    state.wpm = (v >= 5 && v <= 35) ? v : 15;
    wpmVal.textContent = state.wpm + " wpm";
    wpmEl.setAttribute("aria-valuetext", state.wpm + " words per minute");
    /* A drag fires many input events. Only the first one sees state.playing, so the
       resume flag has to also count a re-key already queued from the previous frame —
       otherwise every frame after the first cancels the pending restart and playback
       is dropped for good halfway through the drag. */
    var resume = state.playing || wpmTimer !== 0;
    halt();
    updateStats();
    if (resume) {
      noteEl.classList.remove("done");
      noteEl.textContent = "Restarting at " + state.wpm + " wpm…";
      wpmTimer = setTimeout(function () { wpmTimer = 0; play(state.items); }, 200);
    }
  });

  /* a queued restart counts as "playing" for toggle purposes — otherwise hitting play
     during the 200 ms window schedules a second overlapping run */
  function togglePlay() {
    if (state.playing || wpmTimer) halt();
    else play(state.items);
  }
  playBtn.addEventListener("click", togglePlay);

  document.addEventListener("click", function (ev) {
    var t = ev.target;
    if (!t || !t.closest) return;
    var copyBtn = t.closest("[data-copy]");
    if (copyBtn) {
      var what = copyBtn.getAttribute("data-copy");
      var str = what === "morse" ? morseString(state.items) : (state.decodedText || "");
      if (!str) { flash(copyBtn, "Nothing yet", false); return; }
      copyText(str, copyBtn);
      return;
    }
    var samp = t.closest("[data-sample]");
    if (samp) {
      halt();
      srcEl.value = samp.getAttribute("data-sample");
      render();
      return;
    }
    var ref = t.closest(".refkey");
    if (ref) playChar(ref.getAttribute("data-char"));
  });

  document.addEventListener("visibilitychange", function () {
    if (document.hidden) halt();
  });

  /* ---------- boot ---------- */
  /* Read the controls rather than assuming the markup defaults: a reload can restore
     form state, and booting out of sync would render morse through the encoder. */
  var bootWpm = parseInt(wpmEl.value, 10);
  state.wpm = (bootWpm >= 5 && bootWpm <= 35) ? bootWpm : 15;
  wpmEl.value = String(state.wpm);
  wpmVal.textContent = state.wpm + " wpm";
  wpmEl.setAttribute("aria-valuetext", state.wpm + " words per minute");
  var bootMode = document.querySelector('input[name="mode"]:checked');
  applyMode(bootMode && bootMode.value === "decode" ? "decode" : "encode");
  render();
  noteEl.textContent = idleNote();
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong