How it works
A pure compiler, one register vocabulary, and the decisions worth stealing.
The architecture in one sentence: a pure compiler turns MIDI plus your arrangement into a frame-by-frame register script, and one deterministic chip engine plays that script everywhere. Live playback, WAV export, and video export are the same code path with different destinations.
This chapter is the only one that is about 8BEETY's insides rather than about method. Read it anyway, because four of the five decisions below are not about chiptune at all. They are about making an agent's work checkable, and they would look almost the same in an invoicing app.
The pipeline
.mid file user edits (Zustand store)
| |
@tonejs/midi import |
v v
Song (normalized) ------------> compile(song, project, profile)
| pure function, 60 fps frames
v
FrameScript
(per-channel typed arrays:
period, volume, duty, pan, trig)
|
+---------------------------------+--------------------------+
v v v
AudioWorklet (live) OfflineAudioContext Canvas renderer
ApuCore renders same ApuCore, then a (Game Boy View and
samples in realtime hand-written WAV encoder 9:16 video export)
Four hops, and each one is worth naming.
MIDI to Song. Import normalizes whatever a MIDI file happens to contain into notes and a tempo map. Everything downstream sees Song, never a .mid.
Song plus Project to FrameScript. Project is the user's arrangement: which track goes to which channel, which instrument, which poly mode, which regions. compile() takes both plus a chip profile and returns a FrameScript. This is the function the whole app hangs off.
FrameScript. Per-channel parallel typed arrays, one entry per frame: period, volume, duty, pan, trig. That is the entire vocabulary. Every chip is described in those five arrays.
FrameScript to sound. One ApuCore walks the script frame by frame and produces samples. In the browser it runs inside an AudioWorklet for live playback. For export it runs in an OfflineAudioContext and the samples go to a hand-written WAV encoder. For video, the canvas renderer draws the same script while that audio plays into a recording stream. Same core, three destinations, which is why the WAV sounds like the browser did.
The key modules
| Module | Job |
|---|---|
src/engine/midi-import.ts |
MIDI to a normalized Song: notes and a tempo map |
src/engine/compile.ts |
The heart. Pure function producing FrameScript. Polyphony resolution (top, bottom, arp, split), GM drum mapping, instrument macro rendering, regions, layer modes, per-chip pitch conversion |
src/engine/pitch.ts |
Register math for all four chips: NES timers, GB periods, YM2612 fnum and block packing, SPC pitch |
src/engine/instruments.ts |
FamiTracker-style macro presets plus the tweak system |
src/engine/chip-profiles.ts |
Channel layouts per chip: what kinds of lanes exist and what ranges they accept |
src/audio/apu-worklet.ts |
All the DSP. NES pulse, triangle and noise; GB channels and wave presets; the four-operator FM engine; the procedural sample bank, SPC voices and echo bus; and ApuCore, which walks the script frame by frame |
src/audio/render.ts |
Offline render through the same worklet in an OfflineAudioContext, then WAV |
src/store.ts |
Zustand store. An edit triggers a debounced recompile, hot-swapped into the running worklet without stopping playback |
src/viz/ |
Game Boy View lane renderer, the drawn console shells for GB, SNES and Genesis, and the MediaRecorder video export |
Read that table as a map of the pipeline, because it is one. Nothing in src/engine/ knows that audio exists. Nothing in src/audio/ knows that a MIDI file exists.
Five decisions, and which ones are yours
1. The frame clock is the contract. Everything musical is quantized to 60 fps, the clock real console sound drivers ran on. That single decision makes the whole system deterministic: the same script always produces the same samples. Exporting becomes "render faster than realtime" and testing becomes "assert on the samples".
Transfers. Pick one deterministic unit for your domain and quantize everything to it. A ledger that thinks in whole cents, a scheduler that thinks in minutes, a game that thinks in ticks. The moment two parts of your system disagree about what time it is, your tests start passing sometimes.
2. The compiler is pure. No global state, no audio APIs, no randomness. Hundreds of fast unit tests can pin down every musical behavior, arpeggio cycling and drum priority and octave clamping with warnings, without ever opening an AudioContext.
Transfers. Find the part of your app that is genuinely a transformation and get all the I/O out of it. What is left runs in milliseconds under a test runner and can be asserted on exactly. This is the decision that makes an agent's output checkable instead of demoable, and if you steal only one thing from this chapter, steal this one.
3. The DSP runs in two worlds on purpose. apu-worklet.ts loads as an AudioWorklet in the browser and is imported by Vitest under Node. The rule that makes that possible, no runtime imports and no worklet globals at the top level, is enforced by convention and caught instantly by the suite. A parity test proves chunked realtime rendering and whole-file offline rendering are sample-exact identical.
Transfers. Write the hard part so it runs in your test environment as well as in production, and then test the real thing rather than a stand-in. A mock of your audio engine, or your payment gateway, or your scheduler, tests your understanding of it. This tests it.
4. One register vocabulary for four very different chips. Square-wave timers, wavetable periods, FM frequency numbers, and sampler pitch registers all fit the same five typed arrays. Adding the 16-bit chips changed what the numbers mean, not the shape of the data, so the store, the worklet transport, the visualizer, and both exporters needed almost no changes.
Transfers. One data shape that absorbs new variants without reshaping the system is the difference between a feature that takes a day and a feature that touches every file. You will not get it right by intuition. You get it by writing the shape into the spec and then trying to break it with the second variant before you build the first one.
5. No assets, no server. The SNES sample bank is synthesized deterministically in code: fixed-seed noise, additive synthesis, 8-bit quantization. Share links compress the whole project, MIDI included, into the URL fragment with lz-string. The deployed site is static files.
Transfers in shape, not in detail. Yours will not be a sample bank. The transferable move is generating a thing deterministically instead of shipping it, so it can be regenerated in a test and diffed, and putting state somewhere a URL can carry it instead of somewhere a database has to. Both of those started as non-goals in the spec, which is where constraints like this belong.
What does not transfer
The chip DSP itself. The YM2612 operator algorithms, the LFSR that makes NES noise, the BRR grit and gaussian warmth on the SNES voices, the Game Boy's 64 Hz envelope clock, the NES nonlinear mixer. That is a few thousand lines of domain knowledge about four specific pieces of 1980s silicon, and none of it will help you build anything else.
Which is fine, and worth saying plainly. Every project has a core that is only about its own subject. The mistake is assuming that core is the interesting part of the architecture. It is the part that took the longest and the part that transfers least.
These were prompt-engineering choices
Here is the framing that makes this chapter part of a guide about method rather than a tour of somebody's repo.
The pure compiler, the frame clock, the two-worlds DSP, and the single data vocabulary were all written into SPEC.md before the code existed, and they were chosen partly because they make an agent's work checkable. A pure function can be tested exhaustively and cheaply, so "it works" becomes a command instead of a claim. A deterministic clock means a regression shows up as a sample that changed, in a diff, rather than as a feeling that something sounds off. A DSP core that runs under Node means the audio engine, the scariest part of the codebase, is the part with the most tests on it.
Architecture that is hard to test is architecture where you have to trust the agent. Every hour you spend making the core checkable buys back ten hours of reading diffs and hoping.