Back to Articles
2026 / 01
| 9 min read

Deep Dive: Kansas City Standard Cassette Storage

Building a 1976-era cassette peripheral—FSK encoding at 2400/1200 Hz, tape transport simulation, and why saving 8KB took six minutes.

emulator cassette kcs fsk peripherals

Deep Dive: Kansas City Standard Cassette Storage

If you wanted to save your BASIC program in 1976, you had options. Bad ones. You could hope the paper tape reader didn’t jam. You could invest in a floppy drive that cost more than your car. Or you could plug a $30 cassette recorder into your computer and wait six minutes to save 8KB of code.

Guess which option won?

The cassette interface in emulator.ca isn’t strictly necessary. I could have just given every backend instant save/load and called it a day. But there’s something deeply satisfying about hearing that warbling FSK tone and watching a tape counter tick upward. It’s the audio equivalent of a spinning hard drive LED—you feel the data moving, even if you can’t see it.

So I built a Kansas City Standard cassette peripheral. It took longer than I expected. It works better than I hoped. And it taught me a few things about why those old cassette interfaces were simultaneously brilliant and terrible.


What Is the Kansas City Standard?

In November 1975, a group of hobbyists gathered at a symposium in Kansas City, Missouri, to solve a problem: everyone was building their own cassette interface, and none of them were compatible. You couldn’t share software if your Altair encoded data differently than your friend’s IMSAI.

The result was the Kansas City Standard (KCS), also called the Byte Standard after the magazine that promoted it. The spec is delightfully simple:

  • Binary 1 (mark): 8 cycles of 2400 Hz
  • Binary 0 (space): 4 cycles of 1200 Hz
  • Baud rate: 300 baud (30 characters per second)
  • Frame format: 1 start bit, 8 data bits, 2 stop bits

At 300 baud with 11 bits per byte, you get about 27 bytes per second. That’s roughly 1.6 KB per minute, or about 45 KB on a 30-minute tape. Slow? Absolutely. But it was reliable enough to work with consumer-grade tape recorders, which was the whole point.

(The 2400/1200 Hz frequencies were chosen because they’re harmonically related—2400 is exactly double 1200—which made the analog circuitry simpler. Engineering decisions like this are why I love old standards.)


FSK Encoding: Sine Waves All the Way Down

The heart of KCS is frequency-shift keying (FSK). Instead of encoding bits as voltage levels, you encode them as tones. A binary 1 is a high frequency; a binary 0 is a lower frequency. The cassette recorder doesn’t need to preserve DC levels or sharp edges—it just needs to faithfully reproduce audio frequencies, which it’s designed to do.

Here’s how the encoder works:

// fsk-encoder.ts
export class FSKEncoder {
  // Kansas City Standard frequencies
  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 totalSamples = bits.length * samplesPerBit;
    const samples = new Float32Array(totalSamples);

    let sampleIndex = 0;

    for (let i = 0; i < bits.length; i++) {
      const bit = bits[i];
      const frequency = bit ? this.MARK_FREQ : this.SPACE_FREQ;

      // Generate sine wave for this bit
      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 44100 Hz sample rate and 300 baud, each bit gets about 147 samples. At 2400 Hz, that’s roughly 8 complete sine wave cycles per bit. At 1200 Hz, it’s 4 cycles. Those numbers aren’t coincidental—they match the original KCS spec.

The bytesToBits() method handles the framing:

private bytesToBits(data: Uint8Array): number[] {
  const bits: number[] = [];

  for (let i = 0; i < data.length; i++) {
    const byte = data[i];

    // Add start bit (0)
    bits.push(0);

    // Add 8 data bits (LSB first for Kansas City Standard)
    for (let j = 0; j < 8; j++) {
      bits.push((byte >> j) & 1);
    }

    // Add stop bits (11)
    bits.push(1);
    bits.push(1);
  }

  return bits;
}

That start bit—always 0—is how the decoder knows a byte is coming. The stop bits—always 1—give the system time to recover before the next byte. It’s asynchronous serial communication, the same framing that RS-232 uses. The only difference is the physical layer.


Decoding: Zero-Crossing Detection

Reading data back is trickier than writing it. Real cassette recorders introduce noise, wow and flutter, and level variations. The decoder has to be robust against all of that.

Our approach uses zero-crossing analysis:

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++;
    }
  }

  // Estimate frequency from zero crossings
  const duration = samples.length / this.config.sampleRate;
  const estimatedFreq = zeroCrossings / (2 * duration);

  // Classify as mark or space
  const markDiff = Math.abs(estimatedFreq - this.MARK_FREQ);
  const spaceDiff = Math.abs(estimatedFreq - this.SPACE_FREQ);

  return markDiff < spaceDiff ? 1 : 0;
}

A 2400 Hz sine wave crosses zero roughly 4800 times per second. A 1200 Hz wave crosses about 2400 times per second. Divide by the measurement window, and you get an estimated frequency. Compare to the expected values, and you know if you’re looking at a 1 or a 0.

This works surprisingly well. It’s immune to amplitude variations (as long as they don’t go to zero), and it tolerates moderate frequency drift. Real cassette interfaces used similar techniques, though often implemented in analog with phase-locked loops.


The Tape Transport Simulation

A cassette peripheral isn’t just an encoder/decoder—it’s a mechanical device with state. You can play, record, stop, rewind, and fast-forward. The tape has a position and a length. Programs live at specific positions on the tape.

// cassette-peripheral.ts
export type TransportState = 'stopped' | 'playing' | 'recording' | 'rewinding' | 'fast-forwarding';

export interface TapeImage {
  format: string;
  label: string;
  tapeLength: number;        // Total tape length in seconds (e.g., 1800 = 30 min)
  currentPosition: number;
  baudRate: BaudRate;
  programs: TapeProgram[];
}

The TapeProgram interface tracks what’s actually recorded:

export interface TapeProgram {
  name: string;
  position: number;   // Position in seconds
  duration: number;   // Duration in seconds
  data: string;       // Base64 encoded
  baudRate: BaudRate;
  checksum?: string;
}

When you save a program, the peripheral calculates how long it will take to record based on the data length and baud rate:

calculateDuration(dataLength: number, includeLeader: boolean = true): number {
  const bitsPerByte = 11;  // start + 8 data + 2 stop
  const totalBits = dataLength * bitsPerByte;
  const dataDuration = totalBits / this.config.baudRate;

  if (includeLeader) {
    return dataDuration + 2.0 + 0.5;  // leader + data + trailer
  }

  return dataDuration;
}

That 2-second leader is a burst of continuous 2400 Hz tone. It gives the receiving system time to sync up before actual data arrives. The 0.5-second trailer is silence—time for the system to recognize the transmission has ended.

The transport simulation updates position in real-time:

private startTransport(speed: number): void {
  // Update position every 100ms
  this.transportTimer = window.setInterval(() => {
    if (!this.currentTape) {
      this.stopTransport();
      return;
    }

    // Update position
    this.currentTape.currentPosition += speed * 0.1;

    // Clamp to tape bounds
    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();
    }

    // Update UI
    if (this.litElement) {
      this.litElement.updateCounter(Math.floor(this.currentTape.currentPosition));
      this.litElement.updateProgress(
        this.currentTape.currentPosition / this.currentTape.tapeLength
      );
    }
  }, 100);
}

Play and record run at speed 1.0 (real-time). Rewind runs at -10.0. Fast-forward runs at +10.0. When you hit the beginning or end of the tape, the transport stops automatically—just like a real cassette deck.


The UI: Spinning Reels and Glowing LEDs

The Lit component renders a visual cassette deck with animated reels, transport controls, and a tape counter:

// cassette-peripheral-lit.ts
@customElement('cassette-peripheral')
export class CassettePeripheralLit extends LitElement {
  @state() hasTape: boolean = false;
  @state() transportState: TransportState = 'stopped';
  @state() tapeCounter: number = 0;
  @state() tapeProgress: number = 0;  // 0.0 to 1.0
  @state() baudRate: BaudRate = 300;
  @state() recordLedActive: boolean = false;
}

The reels spin when the transport is running—slowly for play/record, faster for rewind/fast-forward:

.reel.spinning {
  animation: spin 2s linear infinite;
}

.reel.fast-spinning {
  animation: spin 0.3s linear infinite;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

The audio feedback comes from PeripheralAudioHelper, which synthesizes motor sounds using Tone.js:

private startMotor(fast: boolean = false): void {
  if (!this.soundEnabled) return;

  if (fast) {
    // Fast forward/rewind: higher pitch motor
    this.motorStopFn = this.audioHelper.startMotor(120, 240, 0.8, 0.03);
  } else {
    // Normal play/record: steady hum
    this.motorStopFn = this.audioHelper.startMotor(60, 120, 1.0, 0.02);
  }
}

That motor sound—a low sawtooth wave ramping up in frequency during spin-up—adds more to the “feel” of the peripheral than any visual element. When you press play and hear that motor engage, you believe there’s a tape moving.


Integration with the Peripheral System

The cassette fits into the emulator’s peripheral architecture through PeripheralInterface:

export class CassettePeripheral extends PeripheralInterface {
  constructor() {
    super({
      id: 'cassette-c2n',
      name: 'Commodore Datasette C2N',
      price: 100,
      description: 'Kansas City Standard cassette data recorder, 300-2400 baud',
      requiresPort: false,
      icon: '[CAS]',
      category: 'internal-storage',
    });

    this.storage = new BackendStorage({ namespace: 'cassette' });
    this.fskEncoder = new FSKEncoder({ baudRate: 300, sampleRate: 44100 });
  }
}

Unlike serial peripherals, the cassette doesn’t require a port—it uses dedicated I/O lines in the original hardware (the Commodore’s cassette port was a 6-pin DIN connector, not RS-232). The category: 'internal-storage' puts it in the drive bay alongside floppy drives and hard disks.

Tape images persist through BackendStorage:

private saveTapeImage(tapeImage: TapeImage): void {
  const key = `tape:${tapeImage.label}`;
  this.storage.writeJSON(key, tapeImage);
  console.log(`[Cassette] Saved tape: ${tapeImage.label}`);
}

private loadTapeImage(label: string): TapeImage | null {
  const key = `tape:${label}`;
  return this.storage.readJSON<TapeImage>(key);
}

This means your saved programs survive browser refreshes. Insert a tape, record some BASIC code, come back tomorrow—it’s still there.


Baud Rate Selection: 300, 1200, or 2400

The original Kansas City Standard specified 300 baud, but later implementations pushed faster. Our peripheral supports three speeds:

export type BaudRate = 300 | 1200 | 2400;

At 300 baud, you get the authentic early-microcomputer experience: six minutes per typical BASIC program, plenty of time to go make coffee. At 1200 baud, that drops to about 90 seconds. At 2400 baud, it’s under a minute—but you need better tape quality and a more precise recorder.

The UI lets you switch baud rates via radio buttons:

<div class="baud-rate-selector">
  <span class="baud-label">Baud Rate:</span>
  ${[300, 1200, 2400].map(rate => html`
    <div class="baud-option" @click="${() => this.handleBaudRateChange(rate as BaudRate)}">
      <div class="baud-radio ${this.baudRate === rate ? 'selected' : ''}"></div>
      <span>${rate}</span>
    </div>
  `)}
</div>

(The baud rate is stored per-tape, not globally. A tape recorded at 300 baud stays at 300 baud, even if you change the peripheral’s setting. This matches how real cassette systems worked—you couldn’t magically speed up a tape by changing a DIP switch.)


The Compact Slot View

For the internal bay display, we have a miniaturized slot view that shows tape status at a glance:

// cassette-slot-lit.ts
@customElement('cassette-slot')
export class CassetteSlotLit extends PeripheralSlotBase {
  @state() private transportState: TransportState = 'stop';

  render() {
    return html`
      <div class="slot-container">
        <div class="tape-window">
          <div class="reel"></div>
          <div class="tape"></div>
          <div class="reel"></div>
        </div>
        <div class="controls-row">
          <span class="transport-icon ${this.transportState === 'play' ? 'active' : ''}">▶</span>
          <span class="transport-icon ${this.transportState === 'stop' ? 'active' : ''}">■</span>
          <div class="led ${this.ledActive ? 'active' : ''}"></div>
        </div>
      </div>
    `;
  }
}

The slot shows two tiny reels connected by a brown tape strip, transport state indicators, and a record LED. Click to zoom opens the full peripheral view. It’s enough information to see “yes, I have a tape loaded and it’s playing” without taking up much screen real estate.


What Went Sideways

I had three categories of problems building this:

1. Phase continuity in FSK encoding. My first implementation generated each bit’s sine wave starting at phase 0. That created clicks at bit boundaries. The fix was to track phase across bits and let each new frequency pick up where the last left off. It’s a small thing, but audible.

2. Zero-crossing edge cases. A pure sine wave has clean zero crossings. Real audio (even synthetic) can hover near zero for multiple samples. The initial detector sometimes double-counted crossings. Adding a small dead zone helped.

3. Timing synchronization with the UI. The transport timer runs at 100ms intervals. The reel animation runs at requestAnimationFrame speed (usually 16ms). Getting them to feel synchronized required decoupling visual speed from actual position updates. The reels spin at a constant animation rate; the position advances by actual elapsed time.

None of these were showstoppers, but they each took a debugging session to find. The FSK phase issue in particular was one of those “why does this sound wrong?” problems that’s hard to articulate until you find the cause.


Why This Matters

I mentioned authenticity at the top. Here’s what I mean:

In 1978, if you wanted to save your program, you heard it happen. The modem warble from your cassette was proof that data was moving. You developed an ear for it—you could tell a good recording from a bad one by the clarity of the tone.

Modern storage is silent. SSDs don’t click. Network transfers don’t whistle. We’ve traded audio feedback for speed, and that’s mostly fine. But something is lost.

The cassette peripheral brings that back. Not because anyone needs to wait 6 minutes to save 8KB, but because the experience of computing used to include more senses than just sight. The emulator recreates a machine. The cassette recreates a relationship with that machine.

Also, it’s kind of fun to watch the little reels spin. I’m not going to pretend that wasn’t a factor.


  • 2026-01-29 — Storage peripherals and audio realism: the day this got added
  • Deep Dive: CTS Flow Control — How the modem system handles backpressure (similar timing concerns)
  • 2026-01-30 — CC-40 completion and BIOS audio: the broader audio bus architecture

Source Files

  • Cassette Peripheral: web/src/peripheral/devices/cassette/cassette-peripheral.ts
  • FSK Encoder: web/src/peripheral/devices/cassette/fsk-encoder.ts
  • Lit Component: web/src/peripheral/devices/cassette/cassette-peripheral-lit.ts
  • Slot View: web/src/peripheral/devices/cassette/cassette-slot-lit.ts
  • Audio Helper: web/src/peripheral/devices/shared/audio-helper.ts

The Kansas City Standard was obsolete within five years of its creation. Floppy drives got cheap, and nobody wanted to wait for tapes. But for a brief moment, it let a generation of hobbyists share software—and that warbling tone became the sound of the microcomputer revolution.