Back to Articles
2026 / 02
| 7 min read

Deep Dive: Audio Bus Architecture

Taming audio chaos in a modem emulator—centralizing multiple sound sources through a bus-based routing system with Tone.js, and the journey from 8 event hops to 3.

emulator web-audio tone-js architecture audio

Deep Dive: Audio Bus Architecture

When every component wants to make noise, someone has to be the bouncer


The emulator has a lot of things that want to make sounds. DTMF tones when you dial. Ringback while waiting. Modem handshake screeching. Floppy drives seeking. Cassette motors whirring. A line printer clacking. Backend audio for easter eggs. And they all used to do whatever they wanted, whenever they wanted, at whatever volume seemed reasonable to the code that wrote them.

The result was audio chaos. Some sounds were deafening. Others were inaudible. Nothing respected the master volume. Peripherals that initialized before the audio system would crash trying to use AudioContext before user interaction. And changing the volume in one place did absolutely nothing to the floppy drive motor that was happily whining at full blast.

I needed a central authority for audio. A single point of control that everything could route through. A bus architecture.

The Problem in Detail

Let me paint you a picture of what “audio chaos” actually looked like:

  1. The modem generated DTMF tones, dial tones, ringback, busy signals, SIT tones, and handshake sequences. Each generator had its own volume.

  2. Peripherals (floppy drives, cassette deck, hard drive, line printer) each created their own AudioContext or Tone.js nodes, connected directly to destination.

  3. Backends like the Jenny easter egg played synthesized melodies and WAV files, also direct to destination.

  4. Volume control existed in the modem panel, but it only affected… some things. The ones that happened to listen to it.

  5. Web Audio restrictions meant anything that tried to make sound before the first user click would silently fail, or worse, throw errors that broke initialization.

The architecture looked something like this:

  DTMF ─────────────────────────────→ Destination
  LineTones ────────────────────────→ Destination  
  Handshake ────────────────────────→ Destination
  Floppy Drive (own AudioContext) ──→ Destination
  Cassette (own AudioContext) ──────→ Destination
  Printer (own AudioContext) ───────→ Destination
  Jenny Backend ────────────────────→ Destination
  
  Volume Control: "I exist! Hello? Anyone?"

(Narrator: No one was listening to the volume control.)

The Bus Architecture

The fix was to create dedicated audio buses that all sources route through, feeding into a single master chain:

  ┌─────────────────────────────────────────────────────────────┐
  │                                                             │
  │   LineTone ──┬──→ LineBus ──────┐                          │
  │   SIT Tone ──┘                  │                          │
  │                                 │                          │
  │   DTMF ──────┬──→ ModemBus ─────┼──→ MasterGain ──→ Reverb │
  │   Handshake ─┘                  │         ↑                │
  │   Click ─────┘                  │         │                │
  │                                 │    Volume Control        │
  │   Jenny ─────────→ BackendBus ──┤                          │
  │                                 │                          │
  │   Floppy ────┬──→ PeripheralBus─┘                          │
  │   Cassette ──┤                                             │
  │   Printer ───┘                                             │
  │                                                             │
  └─────────────────────────────────────────────────────────────┘


                              Destination

Four buses, each with its own character:

  • LineBus: Central office tones (dial tone, ringback, busy). Gets a 4kHz lowpass filter to simulate phone line characteristics.

  • ModemBus: Modem speaker sounds (DTMF, handshake, clicks). Gets a 3.5kHz lowpass and a 3-band EQ to simulate that tinny modem speaker.

  • BackendBus: Backend-generated audio. Clean path to reverb, no filtering. Backends are responsible for their own character.

  • PeripheralBus: Mechanical sounds (motors, seeks, clacks). Gets an 8kHz lowpass for clean mechanical character without harshness.

Everything flows into a master gain node (volume control), then through a subtle room reverb, then to destination.

AudioStateManager: The Implementation

The AudioStateManager class owns all of this:

// audio-state-manager.ts
export class AudioStateManager {
  // State
  private _state: AudioState = 'idle';
  
  // Master chain
  private masterGain: Tone.Gain | null = null;
  private masterReverb: Tone.Reverb | null = null;
  
  // Audio buses
  private lineBus: Tone.Gain | null = null;
  private modemBus: Tone.Gain | null = null;
  private backendBus: Tone.Gain | null = null;
  private peripheralBus: Tone.Gain | null = null;
  
  // Bus filters/effects
  private lineFilter: Tone.Filter | null = null;
  private modemFilter: Tone.Filter | null = null;
  private modemEQ: Tone.EQ3 | null = null;
  private peripheralFilter: Tone.Filter | null = null;

  async init(): Promise<boolean> {
    // Master gain - default to -30dB for web-safe volume
    const gainCoef = Tone.dbToGain(this.volumeDb);
    this.masterGain = new Tone.Gain(gainCoef).toDestination();
    
    // Master reverb for room acoustics
    this.masterReverb = new Tone.Reverb({
      decay: 1.5,
      preDelay: 0.01,
      wet: 0.15,
    }).connect(this.masterGain);
    
    // Line bus with phone line filter
    this.lineFilter = new Tone.Filter({
      frequency: 4000,
      type: 'lowpass',
      rolloff: -12,
    }).connect(this.masterReverb);
    this.lineBus = new Tone.Gain(1).connect(this.lineFilter);
    
    // Modem bus with speaker simulation
    this.modemEQ = new Tone.EQ3({
      low: -3, mid: 0, high: -6,
      lowFrequency: 400, highFrequency: 2500,
    }).connect(this.masterReverb);
    this.modemFilter = new Tone.Filter({
      frequency: 3500,
      type: 'lowpass',
      rolloff: -24,
    }).connect(this.modemEQ);
    this.modemBus = new Tone.Gain(1).connect(this.modemFilter);
    
    // Backend bus - clean path
    this.backendBus = new Tone.Gain(1).connect(this.masterReverb);
    
    // Peripheral bus with filter
    this.peripheralFilter = new Tone.Filter({
      frequency: 8000,
      type: 'lowpass',
      rolloff: -12,
    }).connect(this.masterReverb);
    this.peripheralBus = new Tone.Gain(1).connect(this.peripheralFilter);
    
    return true;
  }
}

The key insight: initialization doesn’t call Tone.start(). That happens lazily on first user interaction. The audio graph exists, but nothing flows through it until the browser allows it.

The Volume Problem

Here’s a fun fact from MDN’s Web Audio best practices: default to low volume. The reasoning is solid—if your code has a bug that causes unexpected audio, better to have it be quiet than to blast the user’s ears.

I learned this the hard way. My original defaults were -6dB for master volume and -3dB to -8dB for individual generators. Sounds reasonable, right? Each source at near-unity, master at a comfortable level.

Except when three sources played simultaneously, they summed. And suddenly you’re at +6dB and the browser’s limiter kicks in, causing distortion. Or worse, no limiter, and you’re clipping.

The fix was aggressive:

constructor(config: AudioStateManagerConfig = {}) {
  // Default to -30dB (~3% amplitude) - conservative for web apps
  this.volumeDb = config.volumeDb ?? -30;
}

And all the generators got similar treatment:

// Before: Generators at -3dB to -8dB
const dtmfVolume = -3;  // Way too loud when mixed

// After: Generators at -18dB to -20dB
const dtmfVolume = -18;  // Plenty of headroom for mixing

The modem bus also lost its mid-frequency boost. I’d added +3dB at 400-2500Hz to make the modem speaker sound fuller. But that boost, combined with multiple sources, was causing clipping. Removed.

PeripheralAudioHelper: Decoupling Peripherals

Peripherals presented a special challenge. They’re Lit components that can run standalone (in demo pages) or integrated into the main emulator. They shouldn’t need to know about AudioStateManager or buses.

Enter PeripheralAudioHelper:

// audio-helper.ts
export class PeripheralAudioHelper {
  private outputNode: Tone.Gain | null = null;
  
  async init(peripheralBus?: Tone.ToneAudioNode): Promise<boolean> {
    await Tone.start();
    
    this.outputNode = new Tone.Gain(1);
    
    // Try: explicit bus > global provider > direct output
    const bus = peripheralBus || globalPeripheralBus.getBus();
    
    if (bus) {
      this.outputNode.connect(bus);
      console.log('[PeripheralAudio] Connected to peripheral bus');
    } else {
      this.outputNode.toDestination();
      console.log('[PeripheralAudio] Using direct output (no bus)');
    }
    
    // Subscribe to global volume changes
    this.volumeUnsubscribe = globalVolume.subscribe((db) => {
      if (this.outputNode) {
        const gain = Tone.dbToGain(db);
        this.outputNode.gain.rampTo(gain, 0.05);
      }
    });
    
    return true;
  }
  
  // Convenience methods for common peripheral sounds
  playTone(frequency: number, duration: number, volume: number = 0.1): void { /* ... */ }
  playSweep(startFreq: number, endFreq: number, duration: number): void { /* ... */ }
  playClicks(frequency: number, count: number, interval: number): void { /* ... */ }
  startMotor(startFreq: number, runFreq: number, spinupTime: number): (() => void) | null { /* ... */ }
}

Peripherals just call new PeripheralAudioHelper() and use its methods. The helper figures out where to route audio:

  1. If a bus is explicitly passed, use that
  2. If globalPeripheralBus has been configured, use that
  3. Otherwise, go direct to destination (standalone mode)

The globalPeripheralBus is a simple provider that gets wired up in the main emulator:

// In Emulator.init()
globalPeripheralBus.setBus(() => {
  try {
    return this.components.audioSystem!.stateManager.getPeripheralBus();
  } catch (e) {
    console.warn('[Emulator] Peripheral bus not available:', e);
    return null;
  }
});

Now a floppy drive component can make motor sounds without knowing anything about the audio system architecture. It just makes sounds. The architecture handles routing.

The Event Flow Simplification

Before the refactor, audio events took a scenic route through the codebase:

Line → Modem.handleLineAudio → emitAudio → AudioEventBus → Handler → Generator

That's 8 hops from "dial tone should start" to "dial tone starts."

Each hop added latency. Each hop was a place for bugs to hide. The AudioEventBus was particularly egregious—it was a pub/sub system that existed solely to decouple the modem layer from the audio layer. Noble goal, but in practice it just made debugging harder.

The fix was brutal simplification. AudioCoordinator now subscribes directly to Line.onAudio():

// audio-coordinator.ts
export async function setupAudioCoordinator(config: AudioCoordinatorConfig) {
  const line: Line = config.connectionManager.getLine();
  
  // Create event handler that routes to generators
  const handleAudioEvent = createAudioEventHandler({
    onDialTone: (start) => {
      if (start) {
        audioSystem.lineTone.start('dial-tone');
      } else {
        audioSystem.lineTone.stop();
      }
    },
    onDtmf: (digit) => {
      audioSystem.dtmf.playDigit(digit);
      modemPanel.flashLight('TX', 100);
    },
    onHandshake: (profile) => {
      audioSystem.handshake.play(profile);
    },
    // ... etc
  });
  
  // Direct subscription - no intermediaries
  line.onAudio((event: LineAudioEvent) => {
    handleAudioEvent(event);
  });
}

New flow:

Line.emitAudio() → AudioCoordinator (direct subscription) → Generator

Three hops. The AudioEventBus is gone. The modem layer no longer relays audio events. The Line emits, the coordinator handles, the generator plays.

The Jenny Easter Egg

There’s a secret backend triggered by dialing 867-5309. It plays a synthesized version of the Tommy Tutone melody while displaying nostalgic corrupted text, with the audio progressively degrading like an old tape recording.

Before the bus architecture, Jenny’s audio went direct to Tone.getDestination(). Now it routes through the backend bus:

// jenny/index.ts
async initAudio(): Promise<void> {
  // Get the audio bus from AudioStateManager (if available)
  const audioBus = this.getAudioBus();
  
  // Reverb for distance effect
  this.caseReverb = new Tone.Reverb({
    decay: 2.0,
    preDelay: 0.01,
    wet: 0.15,
  });
  
  // Connect to audio bus if available
  if (audioBus) {
    this.caseReverb.connect(audioBus);
    console.log('[Jenny] Audio routed through backendBus');
  } else {
    this.caseReverb.toDestination();
    console.log('[Jenny] Audio routed direct (no bus available)');
  }
  
  // ... synth chain connects to caseReverb
}

The backend factory provides the bus callback:

// In Emulator.init()
this.modemComponents.backendFactory!.setAudioBusCallback(() => {
  try {
    return this.components.audioSystem!.stateManager.getBackendBus();
  } catch (e) {
    console.warn('[Emulator] Audio bus not available:', e);
    return null;
  }
});

Jenny’s melody now respects the master volume. Turn down the modem, turn down Jenny. The degradation effects (noise, filter closing, reverb increasing) still work, but they’re operating on audio that’s already going through the central system.

(The real magic is in the degradation—the melody starts clear and progressively gets more muffled, warbled, and crackly, like you’re listening to a 40-year-old recording fading into the distance. But that’s a different deep-dive.)

User Controls

With everything routed through buses, user controls become trivial:

// Master volume affects everything
audioStateManager.setMasterVolume(volumeDb);

// Mute modem speaker during data transfer (classic modem behavior)
audioStateManager.setModemSpeakerEnabled(false);

// Peripheral audio toggle (user preference)
peripheralManager.setAllSoundEnabled(prefs.peripheralAudio);

The peripheral audio toggle deserves mention. Some users don’t want to hear floppy drives seeking while they’re in the middle of a BBS session. The preference system stores this, and every peripheral respects it:

// emulator.ts
this.modemComponents.peripheralManager.setAllSoundEnabled(prefs.peripheralAudio);

onPreferenceChange((key, value) => {
  if (key === 'peripheralAudio' && this.modemComponents.peripheralManager) {
    this.modemComponents.peripheralManager.setAllSoundEnabled(value as boolean);
  }
});

Individual peripherals check before making any sound.

What I Learned

  1. Bus architecture scales. Four buses (line, modem, backend, peripheral) turned out to be exactly right. Each has its own character, its own filters, its own purpose. Adding a fifth would be trivial.

  2. Default to quiet. -30dB for web audio. Always. You can turn it up; you can’t un-blast someone’s ears.

  3. Decouple via providers, not events. The globalPeripheralBus provider pattern is simpler than pub/sub for this use case. Components ask for resources when they need them.

  4. Fewer hops is better. Eight event hops was absurd. Three is right. Every hop is a place for bugs, latency, and confusion.

  5. User gesture requirements are serious. Never try to Tone.start() before user interaction. Build the graph, initialize everything, but don’t start the context until you know you can.

The Sound of Organization

The emulator now has a single, coherent audio system. Dial tones sound like phone lines. Modem handshakes sound like tinny speakers. Floppy drives whir in the background without drowning out the modem. Turn down the master volume and everything gets quieter together.

It’s the kind of change users probably don’t notice. But they would have noticed the chaos. They would have noticed the floppy drive at full blast while the modem was inaudible. They would have noticed volume controls that did nothing.

Sometimes the best infrastructure work is invisible. Everything just… works.


See also: Deep Dive: Modem Handshakes — the handshake audio that routes through the modem bus

See also: Deep Dive: KCS Cassette Audio — peripheral audio for the cassette deck