Deep Dive: Audio Bus Architecture
Centralizing modem, line, backend, and peripheral audio in a single bus system, after a demo where the volume knob only worked on half the sounds.
On this page
The master volume slider only worked for some of the sounds. I noticed during a demo: the dial tone respected the knob, and the floppy drive’s seek clatter played at full volume regardless. The audio system wasn’t a system, it was a pile of components that happened to make noise.
The Mess I Started With
Before the bus architecture, every component that wanted to make sound did its own thing. The modem panel created its own AudioContext. The floppy drive peripheral had a PeripheralAudioHelper that made another AudioContext. The line tones went through Tone.js. Some generators connected to the Tone.js destination, some went straight to the Web Audio destination, and the Jenny backend played WAV files wherever it felt like.
So each component had its own volume, its own path to the speakers, its own idea of what “loud” meant. The volume knob on the modem panel controlled the polite sounds. The dial tone would get quieter and the floppy drive would keep clattering at full blast, which is how I learned not to wear headphones during demos.
I also had an elaborate pub/sub system where audio events bounced through eight hops before reaching a generator. Line emits an event, line notifies callbacks, modem receives it, modem re-emits it unchanged, audio coordinator forwards it to the EventBus, EventBus iterates through its subscribers (of which there was exactly one), the subscriber runs a switch statement, and a generator finally plays the sound. I drew it out once and found that the Modem layer was passing events through without touching them.
What I Actually Wanted
I didn’t set out to redesign the audio architecture. I wanted the volume knob to work. But tracing why the floppy drive ignored the master volume kept turning up more components with their own audio paths, and at some point the honest fix was a spine: every sound in the emulator passes through a single master gain node before reaching the speakers.
The Architecture at a Glance
Generators connect to buses, buses pass through filters, filters converge on a master chain:
Generators → Buses → Bus Filters → Master Reverb → Master Gain → Destination
There are four buses. LineBus carries central-office tones — dial tone, ringback, busy signal — anything that would arrive over the line from the telephone company’s equipment. ModemBus handles DTMF digits and handshake audio, the sounds from the modem’s own speaker. BackendBus carries backend-generated audio like Jenny’s voice. PeripheralBus handles mechanical sounds: motors spinning up, disk seeks, the clatter of a line printer.
Each bus filters to constrain the character of its sounds. The line bus gets a 4 kHz lowpass, roughly the bandwidth of a phone line. The modem bus gets a 3.5 kHz filter plus EQ, because modem speakers were tiny and terrible. The peripheral bus has an 8 kHz filter, and the backend bus runs clean because pre-recorded speech doesn’t need me colouring it further.
The Master Chain
The AudioStateManager builds the entire chain in one place. The graph is constructed immediately, but nothing calls Tone.start() until user interaction allows it, because browser autoplay policy is one of those things I keep having to re-learn.
// web/src/audio/audio-state-manager.ts
this.masterGain = new Tone.Gain(gainCoef).toDestination();
this.masterReverb = new Tone.Reverb({ decay: 1.5, preDelay: 0.01, wet: 0.15 })
.connect(this.masterGain);
this.lineFilter = new Tone.Filter({ frequency: 4000, type: 'lowpass', rolloff: -12 })
.connect(this.masterReverb);
this.lineBus = new Tone.Gain(1).connect(this.lineFilter);
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);
this.backendBus = new Tone.Gain(1).connect(this.masterReverb);
this.peripheralFilter = new Tone.Filter({ frequency: 8000, type: 'lowpass', rolloff: -12 })
.connect(this.masterReverb);
this.peripheralBus = new Tone.Gain(1).connect(this.peripheralFilter);
The master gain defaults to -30 dB in the constructor, roughly 3% amplitude. That seems absurdly quiet until you remember that web audio bugs are loud when they fail; a stuck oscillator at full volume will damage your relationship with your headphones and possibly your ears. Generators also run at conservative levels (-18 dB is the common baseline in the config) to keep headroom for mixing. When four or five sounds play at once you want room to breathe, and you want room for the moment something goes wrong.
Killing the Pub/Sub Indirection
The pub/sub layer was the part that annoyed me most. I’d set it up thinking it was good architecture — decoupling, events, clean separation — and in practice it was a place for bugs to hide.
Events arrived out of order. Handlers missed teardown and kept playing sounds after the call ended. A dial tone that wouldn’t stop meant tracing Line to Modem to AudioEventBus to AudioCoordinator to the generator, with the bug somewhere in that chain.
I replaced it with a direct subscription. Three hops:
// web/src/main/modules/audio-coordinator.ts
const line: Line = connectionManager.getLine();
const handleAudioEvent = createAudioEventHandler({ /* handlers */ });
line.onAudio((event: LineAudioEvent) => {
handleAudioEvent(event);
});
Line emits an event, coordinator receives it, generator plays the sound. When a dial tone doesn’t stop now there are three places it can be. Whether this holds up if I need more complex audio behaviour later, I don’t know, but debuggability matters more to me here than decoupling.
PeripheralAudioHelper: Making Peripherals Portable
Peripherals need to work inside the full emulator and on standalone demo pages, so the floppy drive can’t assume the bus infrastructure exists. On a test page there’s no AudioStateManager and no master chain.
The PeripheralAudioHelper tries the bus first and falls back to direct output:
// web/src/peripheral/devices/shared/audio-helper.ts
const bus = peripheralBus || globalPeripheralBus.getBus();
if (bus) {
this.outputNode.connect(bus);
} else {
this.outputNode.toDestination();
}
The helper also subscribes to global volume changes, so a floppy drive respects the same master volume as the modem speaker either way. That keeps peripherals portable, which is what I want when testing a cassette deck without spinning up the whole emulator. The fallback is a bit magical for my taste and I haven’t thought of anything cleaner.
Backend Audio
The Jenny backend routes through the backend bus using the same pattern, falling back to destination if the bus is missing. The backend doesn’t own routing; it asks for a bus and trusts the global system to provide one. Every audio source now asks the same question and gets the same shape of answer, which at minimum makes the code predictable.
The Constraint That Forced All This
The constraint was never “better architecture.” It was consistent volume. A modem handshake and a floppy seek should sit at the same level relative to the master, regardless of which component initialized first or which AudioContext they originally targeted, and without buses you can’t guarantee that. Once everything goes through a bus, the master gain means something. The filters and the reverb are conveniences on top.
What This Enables (and What I Didn’t Expect)
You can still hear a floppy seek and a dial tone at the same time, and they no longer fight for ownership of the speakers. When something is too loud, there are three places to look: the generator level, the bus level, or the master. Adding a new peripheral means connecting to the bus and inheriting the volume behaviour.
The unexpected part was live tuning. Because the filter frequencies and EQ gains live in one place, I can expose them in a debug panel and adjust them while the emulator runs — make the dial tone more telephone-like or less, hear it immediately, adjust again. That would have been painful when each component owned its own chain. I spent an embarrassing amount of time in that panel getting the modem EQ to sound like a tiny speaker in a plastic case, and I have no idea whether anyone else notices.
The trade-off is coupling: every audio source now depends on the bus infrastructure. The alternative was each component choosing its own volume, its own context, and its own path to the speakers, which is what produced the mess in the first place.
I may regret this when I need something the buses can’t accommodate. For now the volume knob works on everything.