Deep Dive: Kansas City Standard Cassette Storage
Building a Kansas City Standard cassette peripheral: FSK at 2400/1200 Hz, tape transport simulation, and why saving 8 KB takes five minutes.
On this page
The bug was clicks at every bit boundary. The audio sounded like someone typing rather than the smooth warble I remembered from 1982, and I spent an evening staring at waveforms in Audacity before I found it: my encoder was resetting the sine wave phase to zero at every bit transition instead of letting it run.
Chasing that click is also the clearest answer to why I built a cassette interface at all. The emulator already has instant save and load, so this was never about storage. It was about the sound. The warble from a Commodore Datasette told you data was moving, and you developed an ear for it — you could tell a good recording from a bad one by the clarity of the tone.
So the peripheral does real FSK encoding, moves a tape transport in real time, and makes noise while it does it.
The November 1975 Compromise
Everyone was building cassette interfaces and none of them were compatible; your Altair couldn’t read tapes written by your friend’s IMSAI. A group of hobbyists met in Kansas City, Missouri to settle on one encoding, and the result is the Kansas City Standard, also called the Byte standard after the magazine that published it.
The spec is a clock contract between a byte stream and an audio channel:
- Mark (binary 1): 8 cycles of 2400 Hz
- Space (binary 0): 4 cycles of 1200 Hz
- 300 baud: 300 bits per second on the line
- Frame: 1 start bit, 8 data bits (LSB first), 2 stop bits
2400 is exactly double 1200, which is the whole trick: a single oscillator and a frequency divider gets you both tones, and the receiver only has to distinguish “twice as fast” from “half as fast.” The standard is shaped by what was cheap to build in 1975.
At 300 baud with 11 bits per byte you get about 27 bytes per second, or roughly 1.6 KB per minute. An 8 KB BASIC program takes about five minutes to save. That constraint shapes the workflow around it: you don’t casually save your work. You save it once, at the end, after you’ve tested it.
FSK Encoding: Turning Bits into Tones
Frequency-shift keying turns bits into tones. A cassette recorder doesn’t care about DC levels or sharp edges; it reproduces audio frequencies, because that is the only thing it was built to do.
The encoder is a direct translation of the spec:
export class FSKEncoder {
private readonly MARK_FREQ = 2400; // Binary 1
private readonly SPACE_FREQ = 1200; // Binary 0
encode(data: Uint8Array): Float32Array {
const bits = this.bytesToBits(data);
const samplesPerBit = Math.floor(this.config.sampleRate / this.config.baudRate);
const samples = new Float32Array(bits.length * samplesPerBit);
let sampleIndex = 0;
for (const bit of bits) {
const frequency = bit ? this.MARK_FREQ : this.SPACE_FREQ;
for (let j = 0; j < samplesPerBit; j++) {
const t = sampleIndex / this.config.sampleRate;
samples[sampleIndex] = Math.sin(2 * Math.PI * frequency * t);
sampleIndex++;
}
}
return samples;
}
}
At a 44,100 Hz sample rate and 300 baud, each bit gets 147 samples. At 2400 Hz that’s 8 complete cycles per bit; at 1200 Hz it’s 4. Those are the numbers in the original spec, which is a nice thing to arrive at by division rather than by copying.
The framing is ordinary asynchronous serial:
private bytesToBits(data: Uint8Array): number[] {
const bits: number[] = [];
for (const byte of data) {
bits.push(0); // Start bit
for (let j = 0; j < 8; j++) {
bits.push((byte >> j) & 1); // Data bits, LSB first
}
bits.push(1); // Stop bit
bits.push(1); // Stop bit
}
return bits;
}
The start bit (always 0) tells the decoder a byte is coming. The two stop bits (always 1) give it recovery time before the next one. It’s RS-232 framing with a different physical layer.
The Phase Bug
Back to the clicks. My first encoder generated each bit’s sine wave starting from phase zero:
// Broken: restarts phase at each bit
for (let j = 0; j < samplesPerBit; j++) {
const t = j / this.config.sampleRate; // t resets to 0
samples[sampleIndex++] = Math.sin(2 * Math.PI * frequency * t);
}
When the frequency changes between bits, restarting the phase produces an amplitude discontinuity, and a discontinuity is a click. The fix is a continuously incrementing sample index:
// Fixed: phase continues across bits
for (let j = 0; j < samplesPerBit; j++) {
const t = sampleIndex / this.config.sampleRate; // t never resets
samples[sampleIndex++] = Math.sin(2 * Math.PI * frequency * t);
}
At 44,100 Hz and 300 baud the two versions happen to agree, because 147 samples is a whole number of cycles at both 2400 and 1200 Hz, so every bit boundary lands at zero phase either way. The difference shows up at the higher baud rates the peripheral also supports, where samples per bit is not a whole number of cycles and the reset version snaps the waveform back to zero mid-cycle. That is where the clicking came from, and it is why the encoder now tracks absolute sample position rather than position within a bit.
Decoding: Zero-Crossing Detection
Reading data back is where cassette systems earned their reputation. Real recorders introduce wow, flutter, noise, and level variation, and the decoder has to tolerate all of it.
Zero-crossing analysis works because it ignores amplitude entirely:
private detectFrequency(samples: Float32Array): number {
let zeroCrossings = 0;
for (let i = 1; i < samples.length; i++) {
if ((samples[i - 1] < 0 && samples[i] >= 0) ||
(samples[i - 1] >= 0 && samples[i] < 0)) {
zeroCrossings++;
}
}
const duration = samples.length / this.config.sampleRate;
const estimatedFreq = zeroCrossings / (2 * duration);
const markDiff = Math.abs(estimatedFreq - this.MARK_FREQ);
const spaceDiff = Math.abs(estimatedFreq - this.SPACE_FREQ);
return markDiff < spaceDiff ? 1 : 0;
}
A 2400 Hz wave crosses zero about 4800 times per second; a 1200 Hz wave about 2400 times. Count the crossings over a bit window, estimate the frequency, take the closer match. The two frequencies are far enough apart that moderate tape drift doesn’t cause a misread.
The edge case is silence, or anything close to it. The waveform hovers around zero and generates spurious crossings. I added a small dead zone: if the samples on both sides of a crossing are below a threshold, it doesn’t count. Real analog detectors used hysteresis for the same reason.
Tape Transport: State Beyond Sound
A cassette peripheral isn’t just an encoder. It’s a mechanical system with position and state.
export interface TapeImage {
label: string;
tapeLength: number; // Total length in seconds
currentPosition: number; // Current position in seconds
baudRate: BaudRate;
programs: TapeProgram[];
}
export interface TapeProgram {
name: string;
position: number; // Where on the tape
duration: number; // How long it takes to read
data: string; // Base64-encoded content
baudRate: BaudRate;
checksum?: string;
}
Programs live at specific tape offsets. To load the third program you have to fast-forward past the first two. That’s the mechanical reality that made LOAD feel nothing like a floppy seek.
The transport simulation runs two clocks. The position clock updates every 100 ms from actual elapsed time:
private startTransport(speed: number): void {
this.transportTimer = window.setInterval(() => {
this.currentTape.currentPosition += speed * 0.1;
if (this.currentTape.currentPosition < 0) {
this.currentTape.currentPosition = 0;
this.stop();
} else if (this.currentTape.currentPosition > this.currentTape.tapeLength) {
this.currentTape.currentPosition = this.currentTape.tapeLength;
this.stop();
}
this.litElement.updateCounter(Math.floor(this.currentTape.currentPosition));
}, 100);
}
Play and record run at speed 1.0, rewind at -10.0, fast-forward at +10.0. Hitting either end of the tape stops the transport, the same way a real deck does.
The animation clock is separate, driven by requestAnimationFrame, and the reels spin at a constant visual rate regardless of position changes. I coupled them at first and it felt wrong: the animation stuttered whenever the position timer fired. Decoupling made the peripheral look smooth while keeping the position accurate.
Leader and Trailer: The Sync Protocol
Before data comes the leader: two seconds of continuous 2400 Hz tone.
encodeWithLeader(data: Uint8Array, leaderDuration: number = 2.0): Float32Array {
const leaderSamples = this.generateLeaderTone(leaderDuration);
const dataSamples = this.encode(data);
const trailerSamples = this.generateTrailerTone(0.5);
const result = new Float32Array(
leaderSamples.length + dataSamples.length + trailerSamples.length
);
result.set(leaderSamples, 0);
result.set(dataSamples, leaderSamples.length);
result.set(trailerSamples, leaderSamples.length + dataSamples.length);
return result;
}
That leader isn’t decoration. It’s a sync window, giving the decoder time to lock onto the signal before data arrives. Press PLAY on an old Datasette and the computer waits for a steady tone before it starts parsing bits; without one, the first few bytes get read while the frequency estimate is still settling.
The trailer is half a second of silence, which is how the system knows the transmission ended and it can stop the motor.
Why Keep It Slow
The cassette peripheral could have been a “save to JSON” button. Keeping the analog model surfaces the original constraint: reliability against speed over a weak physical layer.
At 300 baud, saving 8 KB takes about five minutes. That’s the system working as specified, and the warble is the evidence that bytes are moving.
What I didn’t expect was how much of the effect comes from sounds that have nothing to do with the data. Motor hum when the transport engages. The relay click on PLAY. The rising pitch of fast-forward. I added those chasing authenticity and found they did more work than the FSK did: a silent peripheral reads as fake even when it is functionally correct, and I now suspect that is true of most simulated hardware, not just this one.