DEV Community

Simple Morse
Simple Morse

Posted on

Decoding Morse code from a microphone in the browser (Goertzel, envelopes and a little clustering)

In the last post we made the browser beep in Morse. This one goes the other way: take sound from a microphone or an audio file and turn it back into text.

It is a nice small signal-processing problem, because you have to answer three questions in order:

  1. Which pitch is the Morse tone?
  2. When is that tone on, and when is it off?
  3. Which "on" lengths are dots, which are dashes, and which gaps separate letters and words?

The code below is close to what runs in the SimpleMorse audio decoder, trimmed for reading. Everything works on a Float32Array of samples, so the same functions run in the browser and in Node tests.

Step 1: measure one frequency with the Goertzel algorithm

You could run a full FFT, but we only care about one pitch at a time. The Goertzel algorithm measures the power of a single frequency over a block of samples, with one multiply-add per sample:

function goertzel(samples, start, len, freq, sampleRate) {
  const k = (2 * Math.PI * freq) / sampleRate;
  const coeff = 2 * Math.cos(k);
  let s1 = 0, s2 = 0;
  const end = Math.min(start + len, samples.length);
  for (let i = start; i < end; i++) {
    const s0 = samples[i] + coeff * s1 - s2;
    s2 = s1;
    s1 = s0;
  }
  return (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (len * len);
}
Enter fullscreen mode Exit fullscreen mode

Step 2: find the tone

Morse tones are usually somewhere between 300 and 1500 Hz. To find the right one:

  1. Split the audio into 50 ms windows and pick the loudest one. That way silence at the start of a recording does not matter.
  2. Take a short stretch around it and run Goertzel every 10 Hz from 300 to 1500 Hz.
  3. Keep the frequency with the most power.
function detectTone(samples, sampleRate, minHz = 300, maxHz = 1500, stepHz = 10) {
  const win = Math.floor(sampleRate * 0.05);
  let best = 0, bestStart = 0;
  for (let i = 0; i + win <= samples.length; i += win) {
    let e = 0;
    for (let j = i; j < i + win; j++) e += samples[j] * samples[j];
    if (e > best) { best = e; bestStart = i; }
  }
  const start = Math.max(0, bestStart - win * 2);
  const len = Math.min(win * 5, samples.length - start);
  let bestF = 0, bestP = -1;
  for (let f = minHz; f <= maxHz; f += stepHz) {
    const p = goertzel(samples, start, len, f, sampleRate);
    if (p > bestP) { bestP = p; bestF = f; }
  }
  return bestF;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: build an envelope

Now measure the strength of that one frequency in small blocks. 5 ms blocks are short enough to resolve a dot at fast speeds (a dot at 20 WPM lasts 60 ms) and long enough for Goertzel to be stable:

function envelope(samples, sampleRate, freq, blockMs = 5) {
  const len = Math.max(32, Math.round((sampleRate * blockMs) / 1000));
  const env = [];
  for (let i = 0; i + len <= samples.length; i += len) {
    env.push(Math.sqrt(goertzel(samples, i, len, freq, sampleRate)));
  }
  return { env, blockMs: (len / sampleRate) * 1000 };
}
Enter fullscreen mode Exit fullscreen mode

Step 4: threshold it into on and off segments

A fixed threshold breaks as soon as someone moves the phone further from the speaker. Instead, use percentiles of the envelope itself: the 20th percentile is roughly the noise floor, the 98th is roughly the beep level, and the threshold sits 40% of the way between them.

function segmentsFromEnvelope(env, blockMs, minMs = 12) {
  const sorted = [...env].sort((a, b) => a - b);
  const floor = sorted[Math.floor(sorted.length * 0.2)];
  const peak = sorted[Math.floor(sorted.length * 0.98)];
  if (peak <= floor * 2 || peak === 0) return []; // no clear beeps
  const thr = floor + (peak - floor) * 0.4;

  const segs = [];
  for (const v of env) {
    const on = v >= thr;
    const last = segs[segs.length - 1];
    if (last && last.on === on) last.ms += blockMs;
    else segs.push({ on, ms: blockMs });
  }
  // Merge blips shorter than minMs into their neighbours.
  let changed = true;
  while (changed) {
    changed = false;
    for (let i = 1; i < segs.length - 1; i++) {
      if (segs[i].ms < minMs) {
        segs[i - 1].ms += segs[i].ms + segs[i + 1].ms;
        segs.splice(i, 2);
        changed = true;
        break;
      }
    }
  }
  while (segs.length && !segs[0].on) segs.shift();
  while (segs.length && !segs[segs.length - 1].on) segs.pop();
  return segs;
}
Enter fullscreen mode Exit fullscreen mode

You now have something like [{on: true, ms: 62}, {on: false, ms: 58}, {on: true, ms: 181}, ...].

Step 5: dots and dashes without knowing the speed

The textbook rule is "a dash is three dots long", but you do not know how long a dot is until you have heard some. So let the data decide.

Take all the "on" lengths and split them into two groups with a tiny 2-means clustering. Doing it on a log scale helps, because the interesting relationship (dash about 3x dot) is a ratio:

const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;

function twoMeans(xs) {
  const logs = xs.map(Math.log);
  let lo = Math.min(...logs), hi = Math.max(...logs);
  for (let k = 0; k < 20; k++) {
    const mid = (lo + hi) / 2;
    const a = logs.filter((x) => x < mid);
    const b = logs.filter((x) => x >= mid);
    if (!a.length || !b.length) break;
    lo = mean(a);
    hi = mean(b);
  }
  return { lo: Math.exp(lo), hi: Math.exp(hi), cut: Math.exp((lo + hi) / 2) };
}
Enter fullscreen mode Exit fullscreen mode

If the high group is at least twice the low group, you have real dots and dashes. The cut between them is the geometric mean of the two, and the dot unit is an average of "the short group" and "a third of the long group".

Gaps work the same way, one level up. Anything shorter than two units is a gap inside a letter. The longer gaps get their own 2-means split into letter gaps and word gaps. That matters for Farnsworth timing, where the letters are sent fast but the gaps are stretched, so "7 units means a new word" is not true any more.

function classify(segments) {
  const ons = segments.filter((s) => s.on).map((s) => s.ms);
  const offs = segments.filter((s) => !s.on).map((s) => s.ms);
  const marks = twoMeans(ons);
  // (the all-same-length case is handled below)
  const unit = (marks.lo + marks.hi / 3) / 2;
  const dashCut = Math.sqrt(marks.lo * marks.hi);

  const long = offs.filter((g) => g >= unit * 2);
  let wordCut = Infinity;
  if (long.length >= 2) {
    const g = twoMeans(long);
    if (g.hi >= g.lo * 1.8) wordCut = g.cut;
  }

  let morse = '';
  for (const s of segments) {
    if (s.on) morse += s.ms < dashCut ? '.' : '-';
    else if (s.ms >= unit * 2) morse += s.ms >= wordCut ? ' / ' : ' ';
  }
  return morse.trim();
}
Enter fullscreen mode Exit fullscreen mode

From there it is a lookup table, the same one from the first post, in reverse.

The awkward case: every beep is the same length

Send "TTT" or "EEE" and every mark has the same length. Is that three dashes or three dots? The audio alone cannot tell you.

What helps is the gaps. If the shortest gap is about the same length as the marks, the marks are probably dots (the gap inside a letter is one unit). If it is a third of the mark length, they are probably dashes. You can score both readings, add a mild preference for a typical sending speed around 15 WPM, and pick the better one. Then flag the result as a guess, because "TT" and a slow "I" really do sound the same.

The other fix is to let the user set the speed when they know it. A known unit removes the ambiguity completely.

Is it Morse at all?

A live microphone hears fans, speech and music. Before decoding, it is worth checking that the sound at your chosen pitch is actually keyed on and off:

  • the loud level should be several times the quiet level,
  • during the loudest blocks, a good share of the total energy should be at that one pitch (a clean beep), not spread across all frequencies like speech,
  • and there should be at least two real beeps with a gap between them.

If any of those fail, show "listening..." instead of printing nonsense.

Known limits

This approach is simple on purpose, so it has the limits you would expect:

  • Background noise, echo, music and speech confuse it.
  • Very low or very high tones may need the pitch set by hand.
  • Hand-sent Morse with very uneven timing can split into the wrong groups.

Try it

If you want to play with a working version, the SimpleMorse decoder does all of the above in the browser; nothing is uploaded. A fun loop is to generate a message in the Morse code translator, export it as WAV at a known speed, and feed it back into your own decoder to see where it breaks.

Ideas if you build your own:

  • Show the envelope on a canvas with the threshold line drawn on top. It makes debugging much easier.
  • Track the pitch over time instead of picking it once, for tones that drift.
  • Decode in chunks while listening, and re-run the classifier as more marks arrive, so the dot length estimate improves as you go.

Top comments (0)