<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Morsy Engineering Notes]]></title><description><![CDATA[Morsy Engineering Notes]]></description><link>https://learnmorsy.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Morsy Engineering Notes</title><link>https://learnmorsy.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 16 Oct 2026 11:59:05 GMT</lastBuildDate><atom:link href="https://learnmorsy.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I built a Morse code translator that renders WAV audio entirely in the browser]]></title><description><![CDATA[Every free Morse code translator I tried had at least one dealbreaker. Some had no audio at all. Some paywalled the playback. Some wouldn't let me set Farnsworth spacing. And none of them could export]]></description><link>https://learnmorsy.hashnode.dev/i-built-a-morse-code-translator-that-renders-wav-audio-entirely-in-the-browser</link><guid isPermaLink="true">https://learnmorsy.hashnode.dev/i-built-a-morse-code-translator-that-renders-wav-audio-entirely-in-the-browser</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webaudio]]></category><dc:creator><![CDATA[TrSaka]]></dc:creator><pubDate>Fri, 10 Jul 2026 19:54:28 GMT</pubDate><content:encoded><![CDATA[<p>Every free Morse code translator I tried had at least one dealbreaker. Some had no audio at all. Some paywalled the playback. Some wouldn't let me set Farnsworth spacing. And none of them could export the audio as a WAV file without a server round-trip.</p>
<p>So I spent a weekend building one that fixes all four, and turned out to be a nice little engineering puzzle.</p>
<p><strong>Live tool:</strong> <a href="https://learnmorsy.com/translator/">learnmorsy.com/translator</a></p>
<h2>What I actually wanted from it</h2>
<p>I've been slowly picking up CW (Morse code on ham radio) and kept needing to check what a specific word sounds like at a target WPM speed. The workflow I wanted:</p>
<ol>
<li><p>Type text.</p>
</li>
<li><p>Hear it played at any speed from 5 to 30 WPM.</p>
</li>
<li><p>Adjust the tone frequency (600 Hz is stancheap earbuds vs decent headphones).</p>
</li>
</ol>
<p>DEV.to article body — hazır:</p>
<p>Every free Morse code translator I tried hadme had no audio at all. Some paywalled the playback. Some wouldn't let me set Farnswortould export the audio as a WAV file without a server round-trip.</p>
<p>So I spent a weekend building one that fixesbe a nice little engineering puzzle.</p>
<p><strong>Live tool:</strong> <a href="htor/">learnmorsy.com/translator</a></p>
<h2>What I actually wanted from it</h2>
<p>I've been slowly picking up CW (Morse code on ham radio) and kept needing to check what a specific word sounds like at a target WPM speed. The workflow I wanted:</p>
<ol>
<li><p>Type text.</p>
</li>
<li><p>Hear it played at any speed from 5 to 30</p>
</li>
<li><p>Adjust the tone frequency (600 Hz is standard but sounds different on cheap earbuds vs decent headphones).</p>
</li>
<li><p>Set Farnsworth spacing — independent character speed and text speed, the way real trainers teach it.</p>
</li>
<li><p>Download the audio as a WAV so I can drill it on a walk without opening the tool again.</p>
</li>
</ol>
<ul>
<li><p>Astro static site</p>
</li>
<li><p>Vanilla JavaScript for the translator itself, one file, no bundler</p>
</li>
<li><p>Web Audio API for playback</p>
</li>
<li><p>OfflineAudioContext + a small PCM 16-bit LE encoder for WAV export</p>
</li>
<li><p>Tailwind 4 for the UI</p>
</li>
<li><p>Cloudflare Pages for hosting</p>
</li>
</ul>
<p>The translator script is under 500 lines and there's no framework. Turned out that was the right call — I ship changes with a single file edit and a cache-bust query param.</p>
<h2>Timing model</h2>
<p>Morse code is a timing system, not a symbol ured in "units":</p>
<ul>
<li><p>Dit: 1 unit ON</p>
</li>
<li><p>Dah: 3 units ON</p>
</li>
<li><p>Gap between symbols in a letter: 1 unit OFF</p>
</li>
<li><p>Gap between letters: 3 units OFF</p>
</li>
<li><p>Gap between words: 7 units OFF</p>
</li>
</ul>
<p>The unit length is calibrated so the word <code>PARIS</code> takes 60/WPM seconds. That comes out to <code>1200 / WPM</code> milliseconds per unit. So at 20 WPM, one unit is 60 ms. The full math is on the <a href="https://learnmorsy.com/blog/how-morse-code-works/">companion blog post</a> if you want the whole story.</p>
<p>Once the timing rules are clear, the encoded Morse string is easy to schedule as audio.</p>
<h2>The trick — unifying playback and WAV exp</h2>
<p>Here's the part I got wrong on the first pasack first, using a Web Audio API oscillator that scheduled tones on the audio graph. Wor</p>
<p>Then I added WAV export. I wrote it with an OfflineAudioContext + a separate scheduler. Worked... almost.</p>
<p>The exported WAV had subtle off-by-one gaps ser preview didn't have. Different code paths, different timing bugs.</p>
<p>The fix was to unify the schedule generationconsumers read from:</p>
<pre><code class="language-js">function buildSchedule(pattern, timings) {
  const events = [];
  let t = 0.05; // small offset from t=0 so
  for (let i = 0; i &lt; pattern.length; i++) {
    const ch = pattern[i];
    const next = pattern[i + 1];
    if (ch === '.' || ch === '-') {
      const dur = ch === '.' ? timings.dit : timings.dah;
      events.push({ t, dur });
      t += dur;
      // Only add intra-symbol gap if next char is another symbol
      // in the same letter. Space and slash overwrite the gap.
      if (next === '.' || next === '-') t += timings.intra;
    } else if (ch === ' ') {
      t += timings.letter;
    } else if (ch === '/') {
      t += timings.word;
    }
  }
  return { events, totalDur: t + 0.1 };
}
</code></pre>
<p>One schedule builder. Two consumers. Both realtime and offline reads the same list of <code>{t, dur}</code> events, so "play then download" produces the same audio down to th</p>
<h2>Scheduling a tone</h2>
<p>Same helper for both paths:</p>
<pre><code class="language-js">function scheduleToneOn(ctx, t, dur, freq) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = 'sine';
  osc.frequency.value = freq;
  const fade = Math.min(0.005, dur / 4);
  gain.gain.setValueAtTime(0, t);
  gain.gain.linearRampToValueAtTime(0.4, t +
produces audible clicks at the start and end of every dit. The linear ramp kills them.

## PCM 16-bit LE WAV encoder

OfflineAudioContext gives you an AudioBuffer once you call `startRendering()`. To download it, encode as WAV manually — no library needed, the RIFF header is small:

```js
function encodeWav(audioBuffer) {
  const numCh = 1;
  const sampleRate = audioBuffer.sampleRate;
  const samples = audioBuffer.getChannelData(0);
  const bytesPerSample = 2;
  const blockAlign = numCh * bytesPerSample;
  const byteRate = sampleRate * blockAlign;
  const dataSize = samples.length * bytesPerSample;
  const buffer = new ArrayBuffer(44 + dataSi
  const view = new DataView(buffer);

  writeString(view, 0, 'RIFF');
  view.setUint32(4, 36 + dataSize, true);
  writeString(view, 8, 'WAVE');
  writeString(view, 12, 'fmt ');

  view.setUint32(28, byteRate, true);
  view.setUint16(32, blockAlign, true);
  view.setUint16(34, 16, true);
  writeString(view, 36, 'data');
  view.setUint32(40, dataSize, true);

  let off = 44;
  for (let i = 0; i &lt; samples.length; i++, off += 2) {
    const s = Math.max(-1, Math.min(1, samples[i]));
    view.setInt16(off, s &lt; 0 ? s * 0x8000 : s * 0x7fff, true);
  }
  return buffer;
}
</code></pre>
<p>Wrap the returned <code>ArrayBuffer</code> in a `Blob({e an object URL, click a temporary anchor, and the browser saves the file. No server touched.</p>
<h2>Farnsworth done as two sliders</h2>
<p>Every free translator I saw either skipped Farnsworth or hid it in a menu. Real training needs two knobs:</p>
<ul>
<li><p><strong>Character speed</strong> — how fast each letter sounds</p>
</li>
<li><p><strong>Text speed</strong> — the effective gap between letters</p>
</li>
</ul>
<p>Beginners drill 20 WPM characters with 8 WPMshapes, but enough thinking room. That's howARRL and every serious trainer teaches it. Two sliders is the correct UI for this, not one.</p>
<p>In code, this changes the letter/word gap unit while keeping the intra-symbol dit unit at character speed:</p>
<pre><code class="language-js">const unit = 1.2 / wpm;                       // char speed
const gapUnit = 1.2 / Math.min(farnsWpm, wpm
</code></pre>
<h2>Prosign parser — SOS the correct way</h2>
<p>A prosign is a Morse pattern that doesn't spell a letter, it's a procedural signal. SOS is the most famous. On the air the proper SOS is one nine-symbol shape (<code>...---...</code>) with no letter gaps between the S, O, and S. Most Morse translators send SOS as three separate letters with 3-unit gaps in between, which is technically not a distress signal at all.</p>
<p>Fixed it with angle-bracket syntax. Type <code>&lt;SOS&gt;</code>, <code>&lt;AR&gt;</code>, <code>&lt;BT&gt;</code>, <code>&lt;SK&gt;</code>, <code>&lt;KN&gt;</code>, <code>&lt;VE&gt;</code>, <code>&lt;KA&gt;</code>, <code>&lt;HH&gt;</code> and each renders as one run-together shape:</p>
<pre><code class="language-js">function encode(input) {
  return input.toUpperCase().split(' ').map(
    const m = word.match(/^&lt;([A-Z]+)&gt;$/);
    if (m &amp;&amp; PROSIGNS[m[1]]) return PROSIGNS
    return word.split('').map((ch) =&gt; TEXT_TO_MORSE[ch] ?? '').filter(Boolean).join(' ');
  }).filter(Boolean).join(' / ');
}
</code></pre>
<p>The decoder does the reverse — a token that matches a known prosign pattern with no internal spaces comes back as <code>&lt;XXX&gt;</code> in angle brackets, so a round-trip is lossless.</p>
<h2>Puzzle share mode</h2>
<p>Standard share links carry the plain text. Fine for showing a friend what a phrase looks like in Morse. Bad for setting a decoding challenge — the URL leaks the answer.</p>
<p>Puzzle share links carry only the Morse pattern in the URL, never the source text:</p>
<pre><code class="language-plaintext">learnmorsy.com/translator/?puzzle=1&amp;m=....%20.%20.-..%20.-..%20---
</code></pre>
<p>The recipient's browser detects <code>puzzle=1</code>, loads the pattern into a read-only main input, and shows a separate guess field with Check and Reveal buttons. Anyone poking at the URL can't spoil it.</p>
<h2>Wrap up</h2>
<p>Whole thing is under 500 lines of vanilla JS. No framework, no bundler, no backend. It runs offline once the page has loaded.</p>
<p><strong>Try it:</strong> <a href="https://learnmorsy.com/translator/">learnmorsy.com/translator</a></p>
<p>If you want the theory, the <a href="https://learnmorsy.com/blog/how-morse-code-works/">Morse timing math writeup</a> covers why PARIS is the reference word and how Farnswor</p>
<p>There's a Koch-method Morse trainer app I'm shipping separately (iOS + Android + Apple Watch), but the translator is standalone and stays free. Feedback welcome, especially decoder edge cases I might have missed.</p>
]]></content:encoded></item></channel></rss>