Tuesday, February 03, 2026

Very good morse decoder from 101 things

Prolific ham radio hacker Jonathan P Dawson has just shared a remarkable project he calls Hamfist that is a morse decode device running in the Arduino runtime for Pi PICO processors (including the original). 

Go and read his description but it's quite a sophisticated decoder, rather like a CW skimmer it can decode several morse signals in the audio passband. The PICO does the audio sampling with one of its A/D pins with just a simple circuit on the input.

"It combines adaptive signal processing, automatic speed estimation, probabilistic decoding, dictionary-based correction, and multi-channel decoding, all while keeping memory and CPU usage firmly under control."

I grabbed the source and built it in the Arduino IDE targeting the PICO. All very smooth. Unfortunately I don't have the display he uses, the ili9341, so I've ordered some. The code seems to run but of course I can't see the morse.

So. I pointed Claude Code at the Arduino files and simply asked it to create a C++ command line program that could read a WAV file with morse audio in it and print out the decoded morse. It wrote wav_decoder.cpp and a Makefile. Jonathan includes some sample WAV files so I tried some of these. Here's how that goes:

cw_decoder % ./cw_wav_decoder examples/cw2.wav

WAV file: examples/cw2.wav

  Sample rate: 12000 Hz

  Channels: 1

  Bits per sample: 16

  Target sample rate: 15000 Hz

CQ SP3VT SP3VT TEST E4EQ SP3VE E3VT TEST PA3A IN PA3AT 5NN E 27 MIKEEE EAT/ HI TU S E3VT CQ SP3VT SE 3VT TEST CZ G3VT SPSE UT TEST CQ SW3VT SP EVE TEST CQ SP3VT SAME3VT TEST DK3T DA3T 5NT 

I would add that the decode all happens in a fraction of a second.

I'm not sure if I'm more impressed with Jonathan's contribution or Claude Code's capabilities. Here's the file it produced and the Makefile.

#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <cstring>
#include <string>

#include "cw_dsp.h"
#include "fft.h"

// WAV file header structure
struct WavHeader {
char riff[4]; // "RIFF"
uint32_t file_size; // File size - 8
char wave[4]; // "WAVE"
char fmt[4]; // "fmt "
uint32_t fmt_size; // Format chunk size
uint16_t audio_format; // 1 = PCM
uint16_t num_channels; // Number of channels
uint32_t sample_rate; // Sample rate
uint32_t byte_rate; // Bytes per second
uint16_t block_align; // Bytes per sample * channels
uint16_t bits_per_sample; // Bits per sample
};

// Derived DSP class that outputs decoded text to stdout
class c_wav_decoder : public c_cw_dsp {
private:
std::string last_text[NUM_CHANNELS];

protected:
void decode(uint16_t channel, std::string text, std::string partial) override {
if (!text.empty() && text != last_text[channel]) {
printf("%s", text.c_str());
fflush(stdout);
last_text[channel] = text;
}
}

public:
void print_final() {
// Flush any remaining text
flush();
printf("\n");
}
};

// Simple linear interpolation resampler
class Resampler {
private:
double ratio;
double position;
int16_t last_sample;
bool first_sample;

public:
Resampler(uint32_t input_rate, uint32_t output_rate)
: ratio((double)input_rate / output_rate)
, position(0.0)
, last_sample(0)
, first_sample(true) {}

// Process one input sample, may produce 0 or more output samples
// Returns number of output samples produced
int process(int16_t input, int16_t* output, int max_output) {
int count = 0;

if (first_sample) {
last_sample = input;
first_sample = false;
}

while (position < 1.0 && count < max_output) {
// Linear interpolation
double frac = position;
int32_t interpolated = (int32_t)((1.0 - frac) * last_sample + frac * input);
output[count++] = (int16_t)interpolated;
position += ratio;
}

position -= 1.0;
last_sample = input;

return count;
}
};

void print_usage(const char* program_name) {
fprintf(stderr, "Usage: %s <wav_file> [channel]\n", program_name);
fprintf(stderr, "\n");
fprintf(stderr, "Decodes Morse code (CW) from a WAV audio file.\n");
fprintf(stderr, "\n");
fprintf(stderr, "Arguments:\n");
fprintf(stderr, " wav_file Path to the input WAV file (mono or stereo, any sample rate)\n");
fprintf(stderr, " channel Optional: frequency channel 0-%d (default: all channels)\n", NUM_CHANNELS - 1);
fprintf(stderr, "\n");
fprintf(stderr, "The decoder uses %d frequency channels spanning 0-%.0f Hz.\n",
NUM_CHANNELS, NUM_CHANNELS * CHANNEL_SIZE * (SAMPLE_FREQUENCY / 2.0) / (FRAME_SIZE / 2));
}

int main(int argc, char* argv[]) {
if (argc < 2 || argc > 3) {
print_usage(argv[0]);
return 1;
}

const char* filename = argv[1];
int selected_channel = -1; // -1 means all channels

if (argc == 3) {
selected_channel = atoi(argv[2]);
if (selected_channel < 0 || selected_channel >= NUM_CHANNELS) {
fprintf(stderr, "Error: channel must be between 0 and %d\n", NUM_CHANNELS - 1);
return 1;
}
}

// Open the WAV file
FILE* file = fopen(filename, "rb");
if (!file) {
fprintf(stderr, "Error: Cannot open file '%s'\n", filename);
return 1;
}

// Read WAV header
WavHeader header;
if (fread(&header, sizeof(WavHeader), 1, file) != 1) {
fprintf(stderr, "Error: Cannot read WAV header\n");
fclose(file);
return 1;
}

// Validate WAV format
if (strncmp(header.riff, "RIFF", 4) != 0 || strncmp(header.wave, "WAVE", 4) != 0) {
fprintf(stderr, "Error: Not a valid WAV file\n");
fclose(file);
return 1;
}

if (header.audio_format != 1) {
fprintf(stderr, "Error: Only PCM WAV files are supported (format=%d)\n", header.audio_format);
fclose(file);
return 1;
}

if (header.bits_per_sample != 16 && header.bits_per_sample != 8) {
fprintf(stderr, "Error: Only 8-bit or 16-bit WAV files are supported\n");
fclose(file);
return 1;
}

fprintf(stderr, "WAV file: %s\n", filename);
fprintf(stderr, " Sample rate: %u Hz\n", header.sample_rate);
fprintf(stderr, " Channels: %u\n", header.num_channels);
fprintf(stderr, " Bits per sample: %u\n", header.bits_per_sample);
fprintf(stderr, " Target sample rate: %.0f Hz\n", SAMPLE_FREQUENCY);
fprintf(stderr, "\n");

// Skip to data chunk
// The fmt chunk might be larger than our struct, and there might be other chunks
fseek(file, 12, SEEK_SET); // Skip RIFF header

char chunk_id[4];
uint32_t chunk_size;

while (fread(chunk_id, 4, 1, file) == 1) {
if (fread(&chunk_size, 4, 1, file) != 1) {
fprintf(stderr, "Error: Malformed WAV file\n");
fclose(file);
return 1;
}

if (strncmp(chunk_id, "data", 4) == 0) {
break; // Found data chunk
}

// Skip this chunk
fseek(file, chunk_size, SEEK_CUR);
}

if (strncmp(chunk_id, "data", 4) != 0) {
fprintf(stderr, "Error: Cannot find data chunk in WAV file\n");
fclose(file);
return 1;
}

// Initialize FFT and DSP
fft_initialise();
c_wav_decoder decoder;

// Create resampler
Resampler resampler(header.sample_rate, (uint32_t)SAMPLE_FREQUENCY);

// Process audio data
const int BUFFER_SIZE = 1024;
uint8_t buffer[BUFFER_SIZE * 4]; // Max size for stereo 16-bit
int16_t output_buffer[16]; // Resampler output buffer

int bytes_per_sample = header.bits_per_sample / 8;
int bytes_per_frame = bytes_per_sample * header.num_channels;
int samples_per_read = BUFFER_SIZE;

uint32_t total_samples = chunk_size / bytes_per_frame;
uint32_t samples_processed = 0;

while (samples_processed < total_samples) {
int samples_to_read = samples_per_read;
if (samples_processed + samples_to_read > total_samples) {
samples_to_read = total_samples - samples_processed;
}

size_t bytes_read = fread(buffer, bytes_per_frame, samples_to_read, file);
if (bytes_read == 0) break;

for (size_t i = 0; i < bytes_read; i++) {
int16_t sample;

if (header.bits_per_sample == 16) {
// 16-bit sample
int16_t* samples = (int16_t*)(buffer + i * bytes_per_frame);
if (header.num_channels == 1) {
sample = samples[0];
} else {
// Mix stereo to mono
sample = (samples[0] + samples[1]) / 2;
}
} else {
// 8-bit sample (unsigned)
uint8_t* samples = buffer + i * bytes_per_frame;
if (header.num_channels == 1) {
sample = ((int16_t)samples[0] - 128) * 256;
} else {
// Mix stereo to mono
sample = (((int16_t)samples[0] - 128) + ((int16_t)samples[1] - 128)) * 128;
}
}

// Resample to target rate
int num_outputs = resampler.process(sample, output_buffer, 16);

// Feed resampled samples to decoder
for (int j = 0; j < num_outputs; j++) {
decoder.process_sample(output_buffer[j]);
}
}

samples_processed += bytes_read;
}

// Flush remaining decoded text
decoder.print_final();

fclose(file);

return 0;
}

Makefile:

CXX = g++
CXXFLAGS = -std=c++17 -O2 -Wall

# Source files
SOURCES = wav_decoder.cpp \
cw_dsp.cpp \
cw_decode.cpp \
cw_classifier.cpp \
cw_data.cpp \
dictionary.cpp \
fft.cpp \
utils.cpp

# Object files
OBJECTS = $(SOURCES:.cpp=.o)

# Output binary
TARGET = cw_wav_decoder

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJECTS)
$(CXX) $(CXXFLAGS) -o $@ $^

%.o: %.cpp
$(CXX) $(CXXFLAGS) -c -o $@ $<

clean:
rm -f $(OBJECTS) $(TARGET)

# Dependencies
wav_decoder.o: wav_decoder.cpp cw_dsp.h fft.h
cw_dsp.o: cw_dsp.cpp cw_dsp.h cw_decode.h fft.h utils.h
cw_decode.o: cw_decode.cpp cw_decode.h cw_classifier.h cw_data.h dictionary.h
cw_classifier.o: cw_classifier.cpp cw_classifier.h
cw_data.o: cw_data.cpp cw_data.h
dictionary.o: dictionary.cpp cw_data.h
fft.o: fft.cpp fft.h
utils.o: utils.cpp utils.h

No comments:

Post a Comment