Morse Code in C: A Complete Encoder, Decoder, and WAV Generator

2026-02-09 · Programming

Some assignments never go out of style. Every year, thousands of students in C programming courses get the same brief: read text, convert it to Morse code, maybe play it back. It is a perfect teaching problem — it exercises strings, arrays, lookup tables, pointer arithmetic, and memory sizing all at once, and it has a satisfying payoff when your terminal finally prints .... .. and your speaker actually says it back.

C is arguably the best language to build this in, not because it is the fastest to write but because it hides nothing. A Python dictionary lookup happens by magic; in C you see the exact array index, the exact byte, and the exact off-by-one that breaks it. By the end of this walkthrough you will have a working encoder, a decoder, and a plan for generating real WAV audio — plus the field guide to the pitfalls that sink most first attempts.

Everything below targets standard International Morse — the same code you can hear on every letter page and cross-check against the reference chart. Treat that chart as your unit test: if your table disagrees with it, your table is wrong.

What rules does a Morse program actually have to encode?

Before writing any code, nail down the specification. International Morse is not just dots and dashes — it is a timing system built on one unit of time, conventionally the duration of a dot. Everything else is a multiple of that unit, which is why Morse translates so cleanly into loops and delays.

The one number most assignments also want is speed. Morse speed is measured in words per minute using the word PARIS as the standard reference, and because PARIS works out to exactly 50 units, the conversion is beautifully short: dot duration in milliseconds = 1200 / WPM. At 12 WPM the dot is 100 ms; at 20 WPM it is 60 ms. You will reuse this constant from the beeper in section six all the way to the Arduino version of this project. With the rules collected, the whole specification fits in one table:

ElementDurationWritten as
Dot1 unit.
Dash3 units-
Gap inside a character1 unitnothing
Gap between letters3 unitsspace
Gap between words7 units/

How do you build the Morse lookup table in C?

The cleanest representation is an array of string literals, indexed by alphabet position. Letters A–Z occupy indices 0–25 and digits 0–9 occupy 26–35, so a single array of 36 pointers covers every character a basic assignment asks for:

The key detail is the index arithmetic built on that layout: for a letter c, the code string is simply CODE[c - 'A'], and for a digit it is CODE[26 + c - '0']. Two characters of arithmetic replace a hundred-line switch statement — this is the whole reason the array order matters. Mark it static const so the table lives in read-only memory and never costs a copy at runtime.

Verify the table before building anything on top of it. A one-line loop that prints every entry next to the interactive alphabet chart catches transcription errors in seconds — the classic ones are swapping Q (--.-) and Y (-.--), or mangling the five-symbol letters J and digits.

  • static const char *CODE[36] = {
  • ".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....",
  • "..", ".---", "-.-", ".-..", "--", "-.", "---", ".--.",
  • "--.-", ".-.", "...", "-", "..-", "...-", ".--", "-..-",
  • "-.--", "--..",
  • "-----", ".----", "..---", "...--", "....-",
  • ".....", "-....", "--...", "---..", "----."
  • };

How do you write the encoder function?

The encoder walks the input once, classifies each character, and copies the matching code into an output buffer with spaces between letters. Since letters never exceed five symbols plus a separator, a safe output buffer is roughly six times the input length plus one — write that inequality down, because undersizing this buffer is the number one crash in student submissions.

Read the shape rather than the syntax: classify with toupper, guard both range checks, insert a separating space only when something has already been written, and always terminate with \0. Unknown characters — punctuation, emoji, accented letters — fall through silently here, which is a policy decision you should make consciously (skip them, or translate them to an error marker). Full punctuation does exist in the International standard; see the punctuation reference if your spec demands it.

  • void encode(const char *text, char *out) {
  • int pos = 0;
  • for (int i = 0; text[i]; i++) {
  • unsigned char c = toupper((unsigned char) text[i]);
  • if (c >= 'A' && c <= 'Z') {
  • if (pos) out[pos++] = ' ';
  • strcpy(out + pos, CODE[c - 'A']);
  • pos += strlen(CODE[c - 'A']);
  • } else if (c >= '0' && c <= '9') {
  • if (pos) out[pos++] = ' ';
  • strcpy(out + pos, CODE[26 + c - '0']);
  • pos += strlen(CODE[26 + c - '0']);
  • } else if (c == ' ') {
  • if (pos) out[pos++] = ' ';
  • strcpy(out + pos, "/"); pos += 1;
  • }
  • }
  • out[pos] = '\0';
  • }

How do you write the decoder in C?

Decoding is the reverse walk: split the Morse string on spaces, look each token up, and rebuild text. The lookup is a linear scan over the same 36-entry table — at this size, a hash table is pure overkill.

Two decisions deserve attention. First, the manual token scan instead of strtokstrtok mutates the string you scan and is not reentrant, so the hand-rolled loop is both safer and more instructive. Second, the explicit '?' for undefined sequences: a token like ..... (five dots) matches nothing in the standard, and silently dropping it makes bugs invisible, while marking it keeps every malformed input countable.

Note also that tok[8] is not arbitrary: the longest legal Morse character is five symbols, so a six-byte buffer plus terminator is enough, and the n < 7 guard means a runaway token can never overflow it even on hostile input. Decode buffers shrink rather than grow — the opposite sizing problem from the encoder.

  • char lookup(const char *tok) {
  • for (int i = 0; i < 36; i++)
  • if (strcmp(tok, CODE[i]) == 0)
  • return (i < 26) ? ('A' + i) : ('0' + i - 26);
  • return '?'; // undefined sequence
  • }
  • void decode(const char *morse, char *out) {
  • int pos = 0;
  • while (*morse) {
  • while (*morse == ' ') morse++;
  • if (!*morse) break;
  • char tok[8]; int n = 0;
  • while (*morse && *morse != ' ') {
  • if (n < 7) tok[n++] = *morse;
  • morse++;
  • }
  • tok[n] = '\0';
  • if (strcmp(tok, "/") == 0) out[pos++] = ' ';
  • else out[pos++] = lookup(tok);
  • }
  • out[pos] = '\0';
  • }

How do you write Morse audio to a WAV file in C?

Playing sound portably in C is a swamp of platform APIs, but writing a WAV file is pure file I/O and works everywhere. A WAV is a RIFF container around raw PCM: a fixed 44-byte header followed by amplitude bytes. For Morse, 8-bit mono at 8000 Hz is plenty — telephone-quality fidelity is irrelevant when your signal is a single 700 Hz tone.

Fill riff with "RIFF", wave with "WAVE", fmt with "fmt " (note the trailing space — a beloved trap), set format to 1 (PCM), channels to 1, rate to 8000, bits to 8, and compute dataBytes from your sample count. Then generate samples in exactly the same rhythm your decoder parses: a sine burst for each dot (1 unit) and dash (3 units), silence for every gap. Eight-bit PCM is centered at 128, so a dot at 700 Hz with modest amplitude looks like fputc(128 + 60 * sin(2 * 3.14159 * 700 * t / 8000), wav); inside the on-loop, and fputc(128, wav); inside the off-loop.

The elegant part: your generator can literally iterate over the output of your encoder, translating each . and - into timed samples. Feed it SOS as the first test — ... --- ... should sound like the movies, and you can double-check yourself against the audio decoder tool, which solves the reverse problem.

One last structural note: the header must be a packed struct, with #pragma pack(1) (or __attribute__((packed)) on GCC) around it — without packing, the compiler inserts padding bytes and every audio player rejects your file. The struct in full:

  • #pragma pack(push, 1)
  • typedef struct {
  • char riff[4]; int32_t size; char wave[4];
  • char fmt[4]; int32_t fmtSize; int16_t format, channels;
  • int32_t rate, byteRate; int16_t align, bits;
  • char data[4]; int32_t dataBytes;
  • } WavHeader;
  • #pragma pack(pop)

Which classic C pitfalls break Morse programs?

Every grader has seen these a hundred times. Each item below is a real crash or corruption, not a style nitpick, and all of them are catchable with one audit habit: grep your own code for every array index expression and every fixed-size buffer, and for each one write down the largest value it can legally hold. If you cannot prove it fits, it does not fit.

  • Lowercase indexes out of bounds. 'a' - 'A' is 32, so CODE[c - 'A'] on lowercase input reads past the array into unrelated memory. Always toupper first — this is the single most common bug in the genre.
  • Undersized encode buffer. Output runs up to 6× input length. char out[100] behind a 50-character sentence is a stack smash waiting for the demo.
  • Signed char traps. On most platforms char is signed, so any byte above 127 — a UTF-8 accented letter, for instance — becomes negative and indexes your array backwards. Cast through (unsigned char) before arithmetic.
  • strtok on shared memory. If you decode a string you also need later, strtok's in-place \0 writing will quietly destroy it between calls.
  • No undefined-sequence policy. Five-dot tokens, mixed separators, trailing spaces — decide whether they become '?', get skipped, or abort, and do it in one place.
  • Timing drift in playback. usleep and friends have jitter; if you later build a real-time beeper, count elapsed time with a clock and schedule edges, don't just sum sleeps.

How do you test and extend the project?

The core test is free: round-trip every stringdecode(encode(s)) must equal toupper(s) stripped of unsupported characters. Generate a hundred inputs including empty strings, pure whitespace, mixed digits, and one hostile input with emoji, and the two functions will police each other. Add a handful of golden cases with known answers: encode("SOS") is "... --- ...", and encode("HI YOU") is ".... .. / -.-- --- ..-".

Natural extensions, in rising order of effort: punctuation support via a second table from the punctuation page; digit handling through CODE[26 + c - '0'] if you started letters-only; a stdin loop so the tool works like a Unix filter; and a speed flag that converts WPM to milliseconds with the 1200/WPM rule. Comparing Morse's variable-length codes against fixed-length binary encodings also makes a genuinely interesting write-up — Morse is a prefix code from the 1840s, decades before Huffman formalized the idea.

Where should you go next?

Once the C version works, two sibling projects deepen the picture differently. The Python implementation shows how much of this machinery a modern language gives you for free — and where explicitness is worth buying back. The Arduino sketch moves the timing constants onto real hardware with a speaker and an LED, which is the closest you can get to how the code actually felt on a telegraph line.

And if the assignment has grown into an interest, spend an evening on the history of who really designed the code — the frequency-ordered alphabet your array encodes was itself a piece of clever engineering, and learning the letters by rhythm will make your debugging sessions dramatically faster.

Titanic and the Wireless: The Two Men atThe History of SOS: How Three Letters Be