Skip to main content
Back to articles
2026 / 01
| 7 min read

Deep Dive: Bell 103 Audio Modem and FSK Implementation

Implementing the 1962 modem standard behind 300 baud dialup: FSK modulation, Goertzel demodulation, and the two-band split that makes full duplex work.

emulator modem fsk audio signal-processing
On this page

Two modems, both talking, neither one hearing the other. That was the state of things for most of an afternoon: a Rust prototype in one window, a spectrogram in another, and no idea why the audio looked correct and decoded to nothing.

The goal was to make the emulator’s modem warble. Not play a recording, but generate the tones that made 300 baud dialup sound like dialup, which meant real FSK modulation, real demodulation, and a lot of listening to tones trying to work out why some of them decoded and others didn’t.

Before any of it went into the emulator, I built a standalone harness in experimental/1200fsk/: two modem objects talking to each other through arrays of audio samples. That was the right call. Debugging FSK while also debugging the serial layer and the audio routing would have been miserable.

How Bell 103 Keeps Two Conversations Apart

Bell 103 is full duplex at 300 baud, which sounds simple until you remember both sides are transmitting and receiving on the same phone line at the same time. They stay out of each other’s way by using different frequency bands. The originate modem (the one that dialed) transmits 1270 Hz for a binary 1 (mark) and 1070 Hz for a binary 0 (space). The answer modem transmits higher: 2225 Hz for mark, 2025 Hz for space. Each side listens in the other’s band.

That was the afternoon I lost. I had wired both modems to the originate frequencies, transmitting and listening at 1270/1070. Each modem could hear itself beautifully. Neither could hear the other. I checked the modulator, the demodulator, the sample rate, and the Goertzel coefficients before I checked the one thing that mattered, which was the frequency table.

In code, the modulator and demodulator split by role. The modulator uses its transmit pair; the demodulator listens on the other pair:

// web/src/serial/modems/fsk/fsk-modulator.ts
static createBell103Originate(sampleRate: number = 8000): FSKModulator {
  return new FSKModulator({
    sampleRate,
    markFrequency: 1270,
    spaceFrequency: 1070,
    baudRate: 300,
  });
}

static createBell103Answer(sampleRate: number = 8000): FSKModulator {
  return new FSKModulator({
    sampleRate,
    markFrequency: 2225,
    spaceFrequency: 2025,
    baudRate: 300,
  });
}
// web/src/serial/modems/fsk/fsk-demodulator.ts
static createBell103Originate(sampleRate: number = 8000, debug: boolean = false): FSKDemodulator {
  return new FSKDemodulator({
    sampleRate,
    markFrequency: 2225,
    spaceFrequency: 2025,
    baudRate: 300,
  }, debug);
}

static createBell103Answer(sampleRate: number = 8000, debug: boolean = false): FSKDemodulator {
  return new FSKDemodulator({
    sampleRate,
    markFrequency: 1270,
    spaceFrequency: 1070,
    baudRate: 300,
  }, debug);
}

That asymmetry (originate transmits low and listens high, answer transmits high and listens low) is the entire duplex mechanism. I have wired it backwards more than once.

FSK Modulation: Bits Into Sine Waves

The modulator takes serial frames rather than raw bytes, so each byte becomes a start bit (0), eight data bits (LSB first), and a stop bit (1). At 300 baud with an 8000 Hz sample rate, that works out to about 26.7 samples per bit, which is enough for clean tone transitions without much CPU cost.

The detail that took me a while to get right was phase continuity. My first version reset the phase to zero on every frequency change, which seemed tidy and produced a click at every bit boundary. I could hear the problem before I could describe it: a faint crackling under the tones. Those transients are broadband, so the Goertzel detector on the other end saw energy in both bands at once. The fix was to leave the oscillator spinning and change only the frequency:

// web/src/serial/modems/fsk/fsk-modulator.ts
public modulate(data: Uint8Array): Float32Array {
  const bits: boolean[] = [];

  for (let i = 0; i < data.length; i++) {
    const byte = data[i];
    bits.push(false); // start bit
    for (let bit = 0; bit < 8; bit++) {
      bits.push((byte & (1 << bit)) !== 0);
    }
    bits.push(true); // stop bit
  }

  const totalSamples = Math.ceil(bits.length * this.samplesPerBit);
  const samples = new Float32Array(totalSamples);

  let sampleIndex = 0;
  for (let bitIndex = 0; bitIndex < bits.length; bitIndex++) {
    const bit = bits[bitIndex];
    const frequency = bit ? this.config.markFrequency : this.config.spaceFrequency;
    const samplesInBit = Math.ceil(this.samplesPerBit);

    for (let i = 0; i < samplesInBit && sampleIndex < totalSamples; i++) {
      samples[sampleIndex] = Math.sin(this.phase);
      this.phase += (2 * Math.PI * frequency) / this.config.sampleRate;
      if (this.phase > 2 * Math.PI) {
        this.phase -= 2 * Math.PI;
      }
      sampleIndex++;
    }
  }

  return samples;
}

This is not a hi‑fi synthesizer; it’s a deterministic oscillator that never drops a bit boundary. The phase variable persists across calls, so consecutive modulate() invocations produce one continuous waveform.

Goertzel Demodulation: One DFT Bin, Twice

On the receive side I need to look at one bit’s worth of samples and answer two questions: how much mark energy is here, and how much space energy? A full FFT would answer them, along with a few thousand questions I didn’t ask. The Goertzel algorithm computes a single DFT bin at a fixed frequency, which is exactly the shape of the problem.

I took the standard formula, precomputed the coefficients at construction time (markOmega, spaceOmega, markCoeff, spaceCoeff), and compared the two energies:

// web/src/serial/modems/fsk/fsk-demodulator.ts
private decodeBit(samples: Float32Array): boolean {
  const N = this.bufferIndex;
  const markPower = this.goertzel(samples, N, this.markOmega, this.markCoeff);
  const spacePower = this.goertzel(samples, N, this.spaceOmega, this.spaceCoeff);
  return markPower > spacePower;
}

One deliberate simplification: this demodulator does no clock recovery. It assumes the sample rate and baud rate stay aligned, which holds here because both ends live inside the emulator and the audio pipeline is sample‑accurate. A real phone line drifts and jitters, and would need adaptive timing. I left that out, and it will have to come back if the audio path ever crosses a real device.

Framing: Finding Bytes in Bits

Once I have a bit stream, I still have to find bytes in it. The demodulator uses a minimal frame detector: wait for a start bit (a 0), clock eight data bits LSB first, then check for a stop bit (a 1). A frame with a missing stop bit is discarded and the detector starts over.

// web/src/serial/modems/fsk/fsk-demodulator.ts
private processBit(bit: boolean): void {
  if (!this.inFrame) {
    if (!bit) {
      this.inFrame = true;
      this.currentByte = 0;
      this.bitIndex = 0;
    }
  } else {
    if (this.bitIndex < 8) {
      if (bit) {
        this.currentByte |= 1 << this.bitIndex;
      }
      this.bitIndex++;
    } else {
      if (bit) {
        this.byteBuffer.push(this.currentByte);
        if (this.byteBuffer.length >= 1) {
          this.emitData();
        }
      }
      this.inFrame = false;
      this.currentByte = 0;
      this.bitIndex = 0;
    }
  }
}

That is the smallest frame model I could get away with. It works because the audio path is deterministic: no jitter, no dropped samples, nothing to resynchronize against. On a real line with noise and timing drift you would want something sturdier, but I don’t have a real line.

The Handshake: 0x55, 0xAA, and a 5‑Second Wait

The audio modem uses a simple alternating pattern to lock timing and confirm that both sides speak the same standard:

// web/src/serial/audio-modem.ts
private readonly HANDSHAKE_PATTERN = new Uint8Array([0x55, 0xaa, 0x55, 0xaa]);
private readonly HANDSHAKE_ACK = new Uint8Array([0xaa, 0x55, 0xaa, 0x55]);

Why 0x55 and 0xAA? In binary they’re 01010101 and 10101010, which force a mark/space transition on every bit. That exercises both frequencies equally and stays recognizable even when the framing is slightly off. Real modems used the same trick.

There are two timeouts involved, and confusing them cost me an evening. AudioModem gives the handshake 5000 ms to complete, up from the 3000 ms I started with. The dial sequence has its own handshakeMs, set to 4500 ms, because the Bell 103 handshake audio itself runs about 4200 ms and CONNECT must not appear before the warble finishes. Both are fixed constants where the audio system should really be signalling completion, and dial-timing.ts carries a note to that effect. A modem that connects instantly doesn’t feel like a modem, but one that hangs for six seconds feels broken, and neither of those constraints is expressible as “wait for the audio to end” right now.

One bug took longer to find than it should have. During the handshake I accumulate incoming bytes in a buffer while looking for the pattern. If the pattern never arrives, because of the wrong role or noise or anything else, the buffer keeps growing, so failed connections leaked memory until the next successful one cleared it. The fix is a length check: past 100 bytes, keep the last 50 and discard the rest.

The Adapter: Where Serial Meets Audio

The adapter is the impedance matcher between serial bytes and audio samples. It owns the AudioModem, feeds it bytes from the serial port, pulls audio for the line, and pushes incoming audio back through the demodulator. It is the only piece that has to understand both clocks: the byte clock from the serial port and the sample clock from the audio path.

// web/src/serial/audio-modem-adapter.ts
receiveAudioFromLine(samples: Float32Array): void {
  if (this.audioModem) {
    this.audioModem.processAudio(samples);
  }
  if (this.audioPlayback) {
    this.audioPlayback.playRxAudio(samples);
  }
}

getAudioForLine(): Float32Array | null {
  if (!this.audioModem) {
    return null;
  }
  const samples = this.audioModem.getAudioOutput();
  if (samples && this.audioPlayback) {
    this.audioPlayback.playTxAudio(samples);
  }
  return samples;
}

I went back and forth on how much logic belonged in the adapter versus the modem. The adapter stayed thin, doing nothing but wiring, and the modem owns its own state, which made both sides testable on their own.

Audio Playback: The Debugging Tool That Stayed

The audio playback layer started as a debugging tool. When I couldn’t work out why bits were getting mangled, I piped the audio through speakers and listened. TX pans left, RX pans right, volume defaults to 0.3 so the tones are audible without being punishing. A successful handshake and a failed one sound different: the rhythm changes and the pitches either line up or they don’t.

It was useful enough to keep as a feature. When the modem is connected you hear the warble, and the warble carries information.

How It All Fits Together

Serial Port → AudioModemAdapter → AudioModem → FSK Mod/Demod → Line (Audio)

Bell 103 is one layer in that chain, and it’s the layer that forces the rest of the system to be specific about timing, framing, and frequency separation. The warble is what those constraints sound like when they’re actually enforced.


The thing I keep coming back to is that 300 baud is slow enough to listen to. At 26.7 samples per bit and 300 bits per second, every bit is audible if you pipe the output through speakers, so a failure usually reaches my ears before it reaches the logs. Nothing about that is good engineering practice, but for a modem that exists to sound like 1962, hearing the bug first turned out to be the fastest debugger I had.