Spaces:
Running
Running
File size: 14,066 Bytes
55018c3 1bb4a67 55018c3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Morse Code Timing Visualiser</title>
<meta name="description" content="See every dit, dah and gap of Morse code on a timeline, in units and milliseconds, at any WPM and Farnsworth speed." />
<style>
:root {
--bg: #fbfaf7; --card: #ffffff; --ink: #1d1d1b; --muted: #66645e; --line: #e4e1d9;
--on: #c2410c; --gap-el: #d8d4ca; --gap-ch: #8fb3d9; --gap-wd: #3d6fa8; --accent: #c2410c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161614; --card: #1f1f1c; --ink: #eceae4; --muted: #a29f96; --line: #34332e;
--on: #fb923c; --gap-el: #45433d; --gap-ch: #4f78a3; --gap-wd: #8db7e6; --accent: #fb923c;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 900px; margin: 0 auto; padding: 24px 16px 48px; }
h1 { font-size: 1.7rem; margin: 0 0 4px; }
h2 { font-size: 1.15rem; margin: 0 0 8px; }
p { margin: 0 0 12px; }
.lead { color: var(--muted); margin-bottom: 20px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.controls { display: grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 640px) { .controls { grid-template-columns: 2fr 1fr 1fr; } }
label { display: block; font-size: .85rem; color: var(--muted); margin-bottom: 4px; }
input[type=text] { width: 100%; font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); }
input[type=range] { width: 100%; accent-color: var(--accent); }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 12px; }
button { font: inherit; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
button.ghost { background: transparent; color: var(--accent); }
.morse { font-family: ui-monospace, Menlo, monospace; font-size: 1.05rem; letter-spacing: .08em; word-break: break-all; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 8px; }
@media (min-width: 640px) { .stats { grid-template-columns: repeat(4, 1fr); } }
.stat { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
.stat b { display: block; font-size: 1.15rem; }
.stat span { font-size: .8rem; color: var(--muted); }
.tl-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); }
svg { display: block; }
.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: .85rem; color: var(--muted); margin-top: 8px; }
.sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: -1px; margin-right: 5px; }
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; }
.note { font-size: .88rem; color: var(--muted); }
a { color: var(--accent); }
</style>
</head>
<body>
<main>
<h1>Morse Code Timing Visualiser</h1>
<p class="lead">Morse code is a timing code. Type a word and see every dit, dah and gap on one timeline, in units and milliseconds.</p>
<section class="card">
<div class="controls">
<div><label for="text">Text</label><input id="text" type="text" value="PARIS" maxlength="60" autocomplete="off" /></div>
<div><label for="wpm">Character speed: <b id="wpmv"></b> WPM</label><input id="wpm" type="range" min="5" max="40" value="20" /></div>
<div><label for="eff">Effective speed (Farnsworth): <b id="effv"></b> WPM</label><input id="eff" type="range" min="5" max="40" value="20" /></div>
</div>
<div class="row">
<button id="play" type="button">Play</button>
<span class="morse" id="morse"></span>
</div>
<div class="stats">
<div class="stat"><b id="sUnit"></b><span>one unit (dit)</span></div>
<div class="stat"><b id="sUnits"></b><span>units in total</span></div>
<div class="stat"><b id="sTime"></b><span>to send it</span></div>
<div class="stat"><b id="sOn"></b><span>of the time the tone is on</span></div>
</div>
</section>
<section class="card">
<h2>The timeline</h2>
<div class="tl-wrap"><svg id="tl" height="96" role="img" aria-label="Timeline of tone on and off periods"></svg></div>
<div class="legend">
<span><i class="sw" style="background:var(--on)"></i>Tone on (dit 1 unit, dah 3)</span>
<span><i class="sw" style="background:var(--gap-el)"></i>Gap inside a letter (1)</span>
<span><i class="sw" style="background:var(--gap-ch)"></i>Gap between letters (3)</span>
<span><i class="sw" style="background:var(--gap-wd)"></i>Gap between words (7)</span>
</div>
<p class="note" id="farnNote" style="margin-top:10px"></p>
</section>
<section class="card">
<h2>Same word, three speeds</h2>
<p>The pattern is identical; only the clock changes. A dah at 30 WPM (120 ms) is shorter than a dit at 5 WPM (240 ms).</p>
<div class="tl-wrap"><svg id="cmp" height="150" role="img" aria-label="The same word at 5, 15 and 30 WPM on one time scale"></svg></div>
<p class="note" style="margin-top:10px">This is why a Morse audio decoder can't use fixed thresholds like "anything over 100 ms is a dah". It has to estimate the unit length from the signal itself, for example by grouping the measured tone lengths into a short cluster (dits) and a long one (dahs), and keep adjusting as the sender speeds up or slows down. Hand-sent Morse also drifts from the ideal 1:3:7 ratios, so the decoder has to allow for that too.</p>
</section>
<section class="card">
<h2>The rules</h2>
<table>
<tr><th>Element</th><th>Length</th><th id="hMs">At 20 WPM</th></tr>
<tbody id="rules"></tbody>
</table>
<p class="note" style="margin-top:10px">Speed is defined by the word PARIS, which is exactly 50 units long including the gap after it. At <i>W</i> words per minute, one unit is 60 000 / (50 × <i>W</i>) = 1200 / <i>W</i> milliseconds. With Farnsworth spacing the characters keep their full speed and only the gaps between letters and words are stretched (ARRL formula), so beginners hear fast character shapes with more thinking time.</p>
</section>
<p class="note">Made by <a href="https://simplemorse.com/morse-code-audio-decoder" target="_blank" rel="noopener">SimpleMorse</a>, which has a free Morse audio decoder for files and the microphone. This page is public domain (CC0).</p>
</main>
<script>
const CODES = {
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: '----.',
'.': '.-.-.-', ',': '--..--', '?': '..--..', '/': '-..-.', '=': '-...-', '+': '.-.-.', '-': '-....-', '@': '.--.-.',
};
const $ = (id) => document.getElementById(id);
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
/** Words of letter codes, skipping characters with no Morse code. */
function encode(text) {
return text.toUpperCase().split(/\s+/).filter(Boolean)
.map((w) => [...w].map((c) => CODES[c]).filter(Boolean)).filter((w) => w.length);
}
/**
* Segments for the timeline: { kind: 'dit'|'dah'|'el'|'ch'|'wd', units, ms }.
* Farnsworth gaps use the ARRL formula; units for stretched gaps are in character-speed units.
*/
function segments(words, wpm, eff) {
const u = 1200 / wpm;
let ch = 3 * u, wd = 7 * u;
if (eff < wpm) {
const ta = ((60 * wpm - 37.2 * eff) / (eff * wpm)) * 1000;
ch = (3 * ta) / 19; wd = (7 * ta) / 19;
}
const out = [];
words.forEach((word, wi) => {
if (wi) out.push({ kind: 'wd', ms: wd });
word.forEach((code, li) => {
if (li) out.push({ kind: 'ch', ms: ch });
[...code].forEach((s, si) => {
if (si) out.push({ kind: 'el', ms: u });
out.push({ kind: s === '.' ? 'dit' : 'dah', ms: s === '.' ? u : 3 * u });
});
});
});
out.forEach((s) => (s.units = s.ms / u));
return { out, u };
}
const fmtMs = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(2) + ' s' : Math.round(ms) + ' ms');
const fill = { dit: '--on', dah: '--on', el: '--gap-el', ch: '--gap-ch', wd: '--gap-wd' };
function drawTimeline(svg, segs, { pxPerMs, rowY = 10, h = 34, labels = true, offset = 0 }) {
let x = offset, html = '';
for (const s of segs) {
const w = Math.max(1, s.ms * pxPerMs);
const on = s.kind === 'dit' || s.kind === 'dah';
const y = on ? rowY : rowY + h * 0.35, hh = on ? h : h * 0.3;
html += `<rect x="${x.toFixed(2)}" y="${y}" width="${w.toFixed(2)}" height="${hh}" rx="2" fill="${css(fill[s.kind])}"><title>${s.kind === 'dit' ? 'Dit' : s.kind === 'dah' ? 'Dah' : s.kind === 'el' ? 'Gap in letter' : s.kind === 'ch' ? 'Gap between letters' : 'Gap between words'}: ${Math.round(s.ms)} ms (${+s.units.toFixed(2)} units)</title></rect>`;
if (labels && w > 16) html += `<text x="${(x + w / 2).toFixed(2)}" y="${rowY + h + 16}" font-size="11" text-anchor="middle" fill="${css('--muted')}">${+s.units.toFixed(1)}</text>`;
x += w;
}
return { html, end: x };
}
let state = null;
function render() {
const wpm = +$('wpm').value;
if (+$('eff').value > wpm) $('eff').value = wpm;
const eff = +$('eff').value;
$('wpmv').textContent = wpm; $('effv').textContent = eff; $('hMs').textContent = `At ${wpm} WPM`;
const words = encode($('text').value);
$('morse').textContent = words.map((w) => w.join(' ')).join(' / ') || 'Type letters or digits';
const { out, u } = segments(words, wpm, eff);
const total = out.reduce((a, s) => a + s.ms, 0);
const onMs = out.filter((s) => s.kind === 'dit' || s.kind === 'dah').reduce((a, s) => a + s.ms, 0);
$('sUnit').textContent = Math.round(u) + ' ms';
$('sUnits').textContent = +(total / u).toFixed(1);
$('sTime').textContent = fmtMs(total);
$('sOn').textContent = total ? Math.round((100 * onMs) / total) + '%' : '0%';
$('farnNote').textContent = eff < wpm
? `Farnsworth on: characters at ${wpm} WPM, but gaps between letters and words stretched so the overall speed is ${eff} WPM.`
: 'Numbers under the bars are lengths in units. Hover a bar for milliseconds.';
const tl = $('tl');
const width = Math.max(tl.parentElement.clientWidth - 2, 300);
const pxPerMs = total ? Math.max((width - 20) / total, 0.04) : 1;
const d = drawTimeline(tl, out, { pxPerMs, offset: 10 });
tl.setAttribute('width', Math.max(width, d.end + 10));
tl.innerHTML = d.html + `<line id="head" x1="10" x2="10" y1="2" y2="70" stroke="${css('--ink')}" stroke-width="2" opacity="0"/>`;
state = { out, total, pxPerMs };
$('rules').innerHTML = [
['Dit', '1 unit', u], ['Dah', '3 units', 3 * u], ['Gap inside a letter', '1 unit', u],
['Gap between letters', '3 units', 3 * u], ['Gap between words', '7 units', 7 * u],
].map(([a, b, ms]) => `<tr><td>${a}</td><td>${b}</td><td>${Math.round(ms)} ms</td></tr>`).join('');
drawCompare(words);
}
function drawCompare(words) {
const svg = $('cmp');
const width = Math.max(svg.parentElement.clientWidth - 2, 300);
const speeds = [5, 15, 30];
const sets = speeds.map((s) => segments(words, s, s).out);
const longest = Math.max(1, ...sets.map((o) => o.reduce((a, x) => a + x.ms, 0)));
const pxPerMs = (width - 80) / longest;
let html = '';
sets.forEach((segs, i) => {
const y = 12 + i * 46;
html += `<text x="6" y="${y + 18}" font-size="12" fill="${css('--muted')}">${speeds[i]} WPM</text>`;
html += drawTimeline(svg, segs, { pxPerMs, rowY: y, h: 26, labels: false, offset: 70 }).html;
});
svg.setAttribute('width', width);
svg.innerHTML = html;
}
let ctx = null, playing = null;
function play() {
if (!state || !state.total) return;
if (playing) { stop(); return; }
ctx = ctx || new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator(), gain = ctx.createGain();
osc.frequency.value = 600; osc.type = 'sine'; gain.gain.value = 0;
osc.connect(gain).connect(ctx.destination);
const t0 = ctx.currentTime + 0.05, ramp = 0.004;
let t = t0;
for (const s of state.out) {
const dur = s.ms / 1000;
if (s.kind === 'dit' || s.kind === 'dah') {
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.4, t + ramp);
gain.gain.setValueAtTime(0.4, t + dur - ramp);
gain.gain.linearRampToValueAtTime(0, t + dur);
}
t += dur;
}
osc.start(t0); osc.stop(t + 0.05);
const head = document.getElementById('head');
head.setAttribute('opacity', '1');
playing = { osc, t0, end: t };
$('play').textContent = 'Stop';
const step = () => {
if (!playing) return;
const ms = (ctx.currentTime - playing.t0) * 1000;
const x = 10 + Math.max(0, Math.min(ms, state.total)) * state.pxPerMs;
head.setAttribute('x1', x); head.setAttribute('x2', x);
if (ctx.currentTime >= playing.end) stop(); else requestAnimationFrame(step);
};
requestAnimationFrame(step);
osc.onended = () => { if (playing && playing.osc === osc) stop(); };
}
function stop() {
if (playing) { try { playing.osc.stop(); } catch (e) {} }
playing = null;
$('play').textContent = 'Play';
const head = document.getElementById('head');
if (head) head.setAttribute('opacity', '0');
}
['text', 'wpm', 'eff'].forEach((id) => $(id).addEventListener('input', () => { stop(); render(); }));
$('play').addEventListener('click', play);
window.addEventListener('resize', render);
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
render();
</script>
</body>
</html>
|