Deep Dive: Bell 103 Audio Modem and FSK Implementation
Implementing the 1962 modem standard that made 300 baud dialup possible—FSK modulation, Goertzel demodulation, and the beautiful warble of two frequencies arguing over a phone line.
Deep Dive: Bell 103 Audio Modem and FSK Implementation
So here’s the thing about building a BBS emulator: at some point you have to decide whether you’re making a toy or a time machine. The toy just shuffles bytes around. The time machine makes your ears ring with the unmistakable warble of two frequencies arguing over a phone line.
We chose the time machine.
This is the story of implementing Bell 103 FSK modulation for emulator.ca—the 1962 standard that made 300 baud dialup possible, and the terrifying journey of turning serial data into actual audio signals that could (theoretically) travel through a real phone line.
What is Bell 103 and Why Should You Care?
Bell 103 was the first widely deployed full-duplex modem standard. Introduced by AT&T in 1962, it defined how two modems could talk simultaneously over a single phone line at 300 bits per second. That’s about 30 characters per second—slow enough that you could almost read each character as it appeared.
For BBS emulation, Bell 103 is historically interesting because it represents the original modem experience. Before V.32bis and V.90 turned dialup into background noise, every byte was an audible event. You could hear your data.
The emulator supports higher speeds too (Bell 212A at 1200 baud, V.22bis at 2400, and so on), but there’s something magical about 300 baud. It forces patience. It creates anticipation. And the audio is deeply satisfying in a way that faster modems never matched.
(Also, implementing 300 baud FSK first meant I could actually debug by ear. At 56k, everything sounds like static. At 300 baud, you can almost pick out the individual bits.)
FSK: Frequency Shift Keying
The magic of Bell 103 is FSK—Frequency Shift Keying. Instead of amplitude modulation or phase modulation, FSK represents binary data by switching between two distinct frequencies:
- Mark (binary 1): One frequency
- Space (binary 0): A different frequency
The clever part is the full-duplex trick. Bell 103 divides the phone line’s frequency spectrum into two bands:
| Role | Mark (1) | Space (0) | Frequency Band |
|---|---|---|---|
| Originate (caller) | 1270 Hz | 1070 Hz | Lower band (~1000-1300 Hz) |
| Answer (callee) | 2225 Hz | 2025 Hz | Upper band (~2000-2300 Hz) |
Because the originating and answering modems use different frequency pairs, they can both transmit simultaneously without interfering with each other. The caller talks in the 1000 Hz range; the answerer replies in the 2000 Hz range. Each modem listens for the other modem’s frequencies while ignoring its own transmissions.
Here’s how our profile captures this:
// uart-modem-profiles.ts
Bell103: {
name: 'Bell 103',
baudRate: 300,
bitsPerChar: 8,
stopBits: 1,
parity: 'none',
audio: {
originateCarrierHz: 1170, // Center of originate band
answerCarrierHz: 2125, // Center of answer band
markHz: 1270, // Binary 1 (originate mode)
spaceHz: 1070, // Binary 0 (originate mode)
},
dialSequence: {
dialToneMs: 250,
dtmfToneMs: 80,
dtmfGapMs: 70,
digitsMs: 150,
postDialSilenceMs: 700,
handshakeMs: 3000,
},
flowControl: 'RTS/CTS',
hardwareFlowControl: true,
},
The markHz and spaceHz are for the originate modem. When we create an answer modem, we swap to 2225/2025 Hz.
The FSK Modulator: Turning Bytes into Audio
The FSK modulator lives in fsk-modulator.ts and does exactly what you’d expect: it takes bytes and generates audio samples. But there are some subtleties.
First, we need to understand serial framing. Each byte is transmitted as a 10-bit frame:
- Start bit (always 0/space) — signals that a byte is coming
- Data bits (8 bits, LSB first) — the actual byte
- Stop bit (always 1/mark) — signals end of frame
The modulator converts this bit stream into audio:
// fsk-modulator.ts
public modulate(data: Uint8Array): Float32Array {
const bits: boolean[] = [];
// Convert bytes to bits (LSB first, with start and stop bits)
for (let i = 0; i < data.length; i++) {
const byte = data[i];
// Start bit (0)
bits.push(false);
// Data bits (LSB first)
for (let bit = 0; bit < 8; bit++) {
bits.push((byte & (1 << bit)) !== 0);
}
// Stop bit (1)
bits.push(true);
}
// Generate audio samples
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;
// Keep phase bounded to prevent numerical issues
if (this.phase > 2 * Math.PI) {
this.phase -= 2 * Math.PI;
}
sampleIndex++;
}
}
return samples;
}
The key insight is that we maintain continuous phase across bit transitions. If you naively reset the phase to 0 at each frequency change, you get audible clicks. By preserving phase, the waveform transitions smoothly—which is what a real modem does.
At 300 baud with an 8000 Hz sample rate, we get about 26.7 samples per bit (8000/300). That’s enough resolution to produce a clean sine wave at our target frequencies.
Creating modulator instances for the two roles:
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,
});
}
The FSK Demodulator: Turning Audio Back into Bytes
The demodulator is where things get interesting. We need to take audio samples and figure out which frequency is present at any given moment. The naive approach would be an FFT, but that’s computationally expensive for what we need.
Enter the Goertzel algorithm—a brilliant piece of signal processing that computes the magnitude of specific frequencies without calculating the entire frequency spectrum. It’s like asking “how much 1270 Hz is in this signal?” without caring about any other frequency.
// fsk-demodulator.ts
private goertzel(samples: Float32Array, N: number, omega: number, coeff: number): number {
if (N === 0) return 0;
let s1 = 0;
let s2 = 0;
for (let i = 0; i < N; i++) {
const s0 = samples[i] + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}
// Final calculation with precomputed omega
const realPart = s1 - s2 * Math.cos(omega);
const imagPart = s2 * Math.sin(omega);
const power = realPart * realPart + imagPart * imagPart;
return power;
}
(If you squint, you can see that Goertzel is basically computing one bin of a DFT using a recursive formula. The coeff and omega are precomputed for the target frequency, which makes the inner loop extremely fast.)
For each bit period, we run Goertzel twice—once for the mark frequency, once for the space frequency—and compare the power levels:
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;
}
Whichever frequency has more energy wins. Simple, robust, fast.
The tricky part is bit synchronization. In a real modem, there’s clock recovery circuitry that locks onto the incoming bit timing. We cheat slightly by assuming perfect timing—the modulator and demodulator share the same sample rate and baud rate configuration, so we know exactly how many samples to expect per bit.
The frame detection state machine:
private processBit(bit: boolean): void {
if (!this.inFrame) {
// Look for start bit (0)
if (!bit) {
this.inFrame = true;
this.currentByte = 0;
this.bitIndex = 0;
}
} else {
if (this.bitIndex < 8) {
// Data bits (LSB first)
if (bit) {
this.currentByte |= 1 << this.bitIndex;
}
this.bitIndex++;
} else {
// Stop bit (should be 1)
if (bit) {
// Valid frame - emit the byte
this.byteBuffer.push(this.currentByte);
if (this.byteBuffer.length >= 1) {
this.emitData();
}
}
// Reset for next frame (even if stop bit was invalid)
this.inFrame = false;
this.currentByte = 0;
this.bitIndex = 0;
}
}
}
We wait for a start bit (space/0), then clock in 8 data bits LSB-first, then verify the stop bit (mark/1). If the stop bit is wrong, we discard the frame. Framing errors happened on real phone lines too.
Note the cross-wiring for Bell 103 full duplex:
// The originating modem LISTENS on the answering frequencies
static createBell103Originate(sampleRate: number = 8000, debug: boolean = false): FSKDemodulator {
return new FSKDemodulator({
sampleRate,
markFrequency: 2225, // Answer's mark
spaceFrequency: 2025, // Answer's space
baudRate: 300,
}, debug);
}
// The answering modem LISTENS on the originating frequencies
static createBell103Answer(sampleRate: number = 8000, debug: boolean = false): FSKDemodulator {
return new FSKDemodulator({
sampleRate,
markFrequency: 1270, // Originate's mark
spaceFrequency: 1070, // Originate's space
baudRate: 300,
}, debug);
}
Each modem transmits on one frequency pair and receives on the other. The names can be confusing—the “originating” demodulator listens for the answering modem’s frequencies.
The Handshake: 0x55, 0xAA, and the Art of Synchronization
Before two modems can exchange data, they need to synchronize. The handshake serves several purposes:
- Confirm that both modems are using the same standard
- Allow the receiver to lock onto the transmitter’s timing
- Establish that the audio path is working in both directions
Our handshake uses alternating bit patterns:
// audio-modem.ts
private readonly HANDSHAKE_PATTERN = new Uint8Array([0x55, 0xaa, 0x55, 0xaa]);
private readonly HANDSHAKE_ACK = new Uint8Array([0xaa, 0x55, 0xaa, 0x55]);
Why these values? Let’s look at them in binary:
0x55=010101010xaa=10101010
These patterns produce rapid alternation between mark and space frequencies, which is ideal for synchronization. The receiver can detect the pattern reliably and verify it’s receiving valid Bell 103 modulation.
The handshake sequence:
- Originating modem sends
HANDSHAKE_PATTERN(0x55, 0xaa, 0x55, 0xaa) - Answering modem detects the pattern and replies with
HANDSHAKE_ACK(0xaa, 0x55, 0xaa, 0x55) - Originating modem detects the ACK
- Both modems transition to “connected” state
private handleReceivedData(data: Uint8Array): void {
if (this.state === 'handshaking') {
// Accumulate data in handshake buffer
for (let i = 0; i < data.length; i++) {
this.handshakeBuffer.push(data[i]);
}
const bufferData = new Uint8Array(this.handshakeBuffer);
if (this.config.role === 'answer') {
// Answering modem looks for handshake pattern
if (this.isHandshakePattern(bufferData)) {
this.sendHandshakeAck();
this.handshakeBuffer = [];
this.completeHandshake();
}
} else {
// Originating modem looks for handshake ACK
if (this.isHandshakeAck(bufferData)) {
this.handshakeBuffer = [];
this.completeHandshake();
}
}
// Prevent buffer from growing too large
if (this.handshakeBuffer.length > 100) {
this.handshakeBuffer = this.handshakeBuffer.slice(-50);
}
}
// ... handle connected state data
}
The buffer trimming at the end is defensive—if the handshake is failing due to noise or misconfiguration, we don’t want to accumulate garbage indefinitely.
The AudioModem Class: Putting It All Together
The AudioModem class wraps the modulator and demodulator into a coherent state machine:
export type AudioModemState = 'idle' | 'handshaking' | 'connected' | 'disconnected';
export class AudioModem implements AudioModemInterface {
private config: AudioModemConfig;
private modulator: FSKModulator;
private demodulator: FSKDemodulator;
private state: AudioModemState = 'idle';
// ...
constructor(config: AudioModemConfig) {
// Create modulator and demodulator based on standard and role
if (config.standard === 'bell202') {
this.modulator = FSKModulator.createBell202(this.sampleRate);
this.demodulator = FSKDemodulator.createBell202(this.sampleRate, this.debug);
} else {
// Bell 103
if (config.role === 'originate') {
this.modulator = FSKModulator.createBell103Originate(this.sampleRate);
this.demodulator = FSKDemodulator.createBell103Originate(this.sampleRate, this.debug);
} else {
this.modulator = FSKModulator.createBell103Answer(this.sampleRate);
this.demodulator = FSKDemodulator.createBell103Answer(this.sampleRate, this.debug);
}
}
// Set up demodulator callback
this.demodulator.setDataCallback((data: Uint8Array) => {
this.handleReceivedData(data);
});
}
}
The constructor sets up the appropriate modulator/demodulator pair based on role. The originate modem transmits on 1270/1070 Hz and listens on 2225/2025 Hz; the answer modem does the opposite.
The AudioModemAdapter: Bridging Serial and Audio
The real complexity is in audio-modem-adapter.ts, which bridges the character-based serial interface with the sample-based audio interface. The adapter has to:
- Accept bytes from the serial port and queue them for modulation
- Feed audio samples to the line
- Receive audio samples from the line
- Demodulate them back to bytes and send to the serial port
export class AudioModemAdapter implements Modem, AudioLineEndpoint {
private audioModem: AudioModem;
private serial: SerialPort | null = null;
private line: Line | null = null;
// ...
receiveAudioFromLine(samples: Float32Array): void {
if (this.audioModem) {
this.audioModem.processAudio(samples);
}
// Play RX audio if playback is enabled
if (this.audioPlayback) {
this.audioPlayback.playRxAudio(samples);
}
}
getAudioForLine(): Float32Array | null {
if (!this.audioModem) {
return null;
}
const samples = this.audioModem.getAudioOutput();
// Play TX audio if playback is enabled
if (samples && this.audioPlayback) {
this.audioPlayback.playTxAudio(samples);
}
return samples;
}
}
The AudioLineEndpoint interface is key—it defines the audio-level connection between the modem and the phone line abstraction. Audio samples flow in via receiveAudioFromLine() and out via getAudioForLine().
What Went Sideways
Building this system was not a smooth journey. Here are some of the fun problems I ran into:
Phase Discontinuities
My first modulator reset the phase to 0 at every frequency change. This produced audible clicks that made the demodulator very unhappy. The fix was simple once I understood the problem: maintain continuous phase across frequency transitions.
Sample Rate Mismatches
Early versions assumed 44100 Hz sample rate because that’s what most audio systems default to. But modem audio is low-frequency, and processing 44100 samples per second when 8000 would suffice is wasteful. The demodulator was also doing Goertzel calculations on way more samples than necessary. Standardizing on 8000 Hz made everything faster and the frequency detection cleaner.
Handshake Timing
The handshake timeout was originally 500ms. That’s not nearly long enough when you factor in the time to modulate 4 bytes at 300 baud (about 133ms per byte × 4 = 533ms). I bumped it to 5 seconds, which feels more like a real modem waiting for the answering modem to spin up.
The “Wrong Frequencies” Bug
I spent an embarrassing amount of time wondering why two modems couldn’t talk to each other before realizing I had them both using originate frequencies. Full-duplex requires that each side use different frequency pairs. The originate modem talks on 1270/1070 and listens on 2225/2025; the answer modem does the opposite. Once I got this straight, everything worked.
Buffer Overflow During Handshake
The handshake buffer was originally unbounded. If the handshake failed (wrong standard, noise, whatever), the buffer would grow without limit. The fix was to trim it when it gets too large, keeping only the most recent bytes.
Playing the Audio: Because Why Not?
One of the features I’m most pleased with is the optional audio playback. If you enable it in preferences, you can actually hear the modem doing its thing:
// modem-audio-playback.ts
export class ModemAudioPlayback {
private audioContext: AudioContext | null = null;
private txPanner: StereoPannerNode | null = null;
private rxPanner: StereoPannerNode | null = null;
constructor(config: Partial<ModemAudioPlaybackConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
playTxAudio(samples: Float32Array): void {
// TX audio panned slightly left
// ...schedule playback through Web Audio API
}
playRxAudio(samples: Float32Array): void {
// RX audio panned slightly right
// ...schedule playback through Web Audio API
}
}
The TX (transmit) audio is panned slightly left; the RX (receive) audio is panned slightly right. With headphones, you can hear both sides of the conversation—your modem on the left, the remote modem on the right. It’s completely unnecessary and deeply satisfying.
Default volume is 30% because modem audio is loud if you let it be. Nobody needs to be startled by 1270 Hz at full volume.
Dial Sequence Timing
The modem profile also captures dial sequence timing, which affects the audio experience even if you’re not doing FSK data:
dialSequence: {
dialToneMs: 250, // How long the dial tone plays
dtmfToneMs: 80, // Each DTMF digit duration
dtmfGapMs: 70, // Gap between DTMF digits
digitsMs: 150, // Total per-digit time
postDialSilenceMs: 700,
handshakeMs: 3000, // Handshake timeout
},
These timings were reverse-engineered from recordings of actual Bell 103 modems. The handshake time (3000ms) feels long by modern standards, but that’s what real 300 baud modems needed to exchange their synchronization patterns.
The Bigger Picture
Bell 103 FSK is just one layer in the emulator’s audio modem stack. It sits between:
- Above: The serial port abstraction (which doesn’t know or care that audio is involved)
- Below: The phone line abstraction (which routes audio between modems)
Serial Port ←→ AudioModemAdapter ←→ AudioModem ←→ FSK Mod/Demod ←→ Line (Audio)
The same architecture supports Bell 202 (1200 baud half-duplex FSK) by swapping in different frequency parameters. Higher-speed modems like V.32bis use entirely different modulation schemes (QAM instead of FSK), but the adapter layer remains the same.
(I haven’t implemented QAM yet. That’s a whole different kettle of fish—you need constellation diagrams and Trellis coding and eye patterns and… maybe next year.)
Related Reading
- Deep Dive: CTS Flow Control — How backpressure works in the serial/modem system
- Deep Dive: KCS Cassette Interface — Another FSK implementation, this time for cassette tape storage
300 baud was never fast, but it was honest. Every byte earned its place on the screen.