Chiptunes.app
Live · started June 2026
Compose Game Boy music, edit every note, and share the song as a link or cartridge.
A deterministic composer, register-level emulation and a cartridge compiler share one score. Browser-agent tools can compose, analyse and export through the same engine.
Use it · Follow the build · View source
Scope: Product, interaction design, audio systems, engineering, infrastructure, launch, and operations
Pick a mood and Chiptunes writes a complete Game Boy song. Open the tracker to change any note, instrument or effect, then share it as a link or export the arrangement for another musician to keep working on. Composition, editing and sharing run in the browser without an account.
The same creative engine is available to an agent: sixteen browser tools can compose, analyse and export songs. This is a music system an assistant can operate, with a deterministic score underneath the interface, rather than a chat box that generates an audio file.
I designed and built the composer, editor, chip emulator and export paths with AI agents. The central decision was to make one score drive every output. Register-write checks compare browser and cartridge playback, and the cartridge drum renderer reaches 0.9918 spectrogram correlation with the browser. That is emulator evidence; physical Game Boy playback has not been verified.
Try the instrument
Open chiptunes.app and pick a mood. It writes a complete finite arrangement rather than a loop, plays it, and writes another when that one ends. A self-playing game runs to the same beat and energy data, drawn through one of two shader pipelines that reconstruct the artifacts of the Game Boy LCD and of an NES composite signal.
Open Create on whatever is playing and you get a four-channel tracker over the still-running game, where every note, instrument and effect is editable, with four export buttons beside it: Download WAV, Download MIDI, Download ROM (32 KB, .gb), and Download LSDj (.lsdsng, the file LSDj musicians pass around). The share link is the song: the whole arrangement is packed into the URL fragment, which browsers never send to a server, so sharing needs no database, stores nothing, and has no moderation surface.
chiptunes.app/webmcp is the same page with its agent surface explained. src/webmcp.js registers sixteen tools on document.modelContext. The composer, the chip, the MIDI writer and the cartridge builder are already in the tab because the page needs them, so the tools that compose, measure and export need no server, no key and no account.
An agent starts the score. You make it yours.
In a recorded September 8 session, an agent composed an arrangement and opened it in the tracker. I changed the first C4 note to Bell + Arp, then had the export tool turn the edited document into a share link and a 32 KB cartridge. The reopened link retained the edited score. You can play that exact result or download the file from the capture page.
The recording is three actual browser states, not simulated UI or a continuous performance. It also exposed an unmet composition constraint and inconsistent tempo reporting between the tool and tracker. Those limits are preserved with the responses. The document and downloaded file were checked; this session is not a hardware or audible-parity test.
One score, two paths to sound
Chiptunes.app emulates the Game Boy's four-channel sound chip at the level of its hardware registers and exports the same score as a cartridge image, an LSDj song, or a MIDI file. Every note becomes writes to $FF10 through $FF3F: duty and envelope on the pulse channels, the sweep unit on channel 1, the wave table on channel 3, the noise generator's shift register on channel 4. The browser runs those writes through a Web Audio worklet. The cartridge runs them through a driver executing on an emulated CPU.
Three decisions, and what settled each
The drum clock
The most demanding part was the drums. The DMG has a single sample buffer, 32 nibbles of wave RAM, so a sampled kit is played by rewriting that buffer while the channel runs. Channel 3 advances its nibbles at a rate set by its period register, and period 1792 works out to exactly 8192 samples a second, which makes one buffer last exactly a 256th of a second. The cartridge refills it from the hardware timer interrupt, where the 4096 Hz clock with a reload value of 240 fires exactly 256 times a second. The sample clock and the refill clock are therefore the same clock and nothing drifts. Taking the easier route of refilling once per video frame would have sampled at 1911 Hz for 955 Hz of usable bandwidth: muffled thuds with no click and no sizzle. Supporting the interrupt approach meant teaching the CPU emulator interrupts, the timer, and six additional opcodes, and giving the driver an interrupt service routine at the $0050 vector.
The four constants live together in src/gb-kits.js, and scripts/verify-kit.js asserts the two clocks against each other arithmetically rather than trusting the comment that explains them. It then plays every drum in the kit through both paths and compares spectrograms: they agree to 0.9918 correlation at 1.34 dB per band.
The unit an LSDj groove is measured in
An LSDj musician should be able to open a Chiptunes song and keep writing, which means the export has to arrive as an arrangement rather than a transcription.
The approach that failed was verifying that by reading our own file back. Our writer and our reader shared one assumption about tempo, so the round trip was perfect and meaningless. Reading it with liblsdj would have agreed too: the bytes were valid, they just meant something else.
What exposed it was booting Johan Kotlinski's actual LSDj ROM in mGBA and tracing what it wrote to the sound registers. A groove is counted in ticks, and we were writing frame counts into that field. A song exported at 128 bpm with a 7-frame row played at 8.17 frames a row: 110 bpm, 17 percent slow, on every song the project had ever exported.
The decision was that no export gate may verify itself with our own code. scripts/verify-lsdj.js reads the output back with liblsdj through tools/lsdjcheck.c; scripts/verify-lsdj-emulator.js boots the real ROM through tools/lsdjtrace.c and watches the chip. Neither uses our reader. The measurement that settled it: LSDj plays 9.955, 8.000, 7.000 and 5.970 frames a row where the model predicts 9.955, 7.999, 6.999 and 5.973. The same harness settled the note base by measurement instead of argument, which removed a workaround that had been transposing six or seven songs in every thirty-two by a whole octave to escape a floor that was not really there.
The surface an agent actually reads
src/webmcp.js registered its tools on navigator.modelContext. The specification's surface is document.modelContext. The test shim had been written against the same wrong name, so the code and the test agreed with each other and were both wrong, while the page looked healthy and the console API worked. In an agent browser, no tool would have registered at all.
The decision was to stop testing the shim and start testing the contract. scripts/verify-webmcp.js installs the spec's document.modelContext before any page script runs, exactly as an agent browser injects it, asserts that all sixteen tools arrive exactly once with a real description and an object input schema, and then executes the composing, analysing and exporting tools for real against the built bundle instead of a stub. npm run test:webmcp:live points the same gate at https://chiptunes.app, so the count is checked against what the site serves rather than against dist/. It caught a second live bug on its first run: variations() returns an envelope object rather than an array, and the tool was calling .map on it, a failure that could only ever have surfaced at the agent.
Measured, and where to run it again
Recorded in docs/HANDOFF.md, measured on the machine that built the project rather than asserted:
| measured | |
|---|---|
| compose a complete song | 1.6 ms |
| a thousand complete songs | 471 ms |
| build a 32 KB cartridge image | 1.19 ms |
| render the audio | 103 ms for 40.7 s, 395 times faster than real time |
Cheap composition is a constraint, not a boast. Because a song costs a millisecond and nothing is metered, the production path never needs generate-several-and-keep-the-best scoring, and handing an agent a dozen real alternatives costs nothing. src/composer.js uses no ambient randomness, no clock, no network state and no candidate scoring; the token in a link reproduces the same score on every machine. Its pattern pools are distilled offline from a 74,552-file game-music MIDI corpus into joint drum-kit bars, bass onset masks and chord movements.
The website, the WAV renderer, the cartridge exporter, the MIDI and LSDj writers, the radio stream and the video renderer all come out of one build.js, so there is no per-target fork to drift. The test script in package.json chains that build and 42 checks. Three heavier comparisons that render audio through both engines (test:rom-audio, test:kit, test:render-parity) run on their own rather than on every pass. Most of these exist because the behaviour they check was once wrong, and the comment above each one says what went wrong.
The artifacts, by path:
src/gb-cpu.js, a 192-line LR35902 subset with 76 opcodes: exactly whatsrc/gb-rom.jsemits, and it throws on anything else, because an unimplemented opcode means the assembler produced something the driver was never supposed to contain. It has no PPU and no MBC and does not pretend to be a general Game Boy emulator.scripts/verify-automation.js, which plays a score carrying every kind of automation through both players and asserts that each register receives the same values on the same frames in the same order, not merely a similar sound.scripts/verify-kit.jsandscripts/verify-rom-audio.js, the two spectral comparisons.scripts/verify-webmcp.js, plusnpm run test:webmcp:liveagainst production.scripts/verify-lsdj.jsandscripts/verify-lsdj-emulator.js, both of which read our output with somebody else's code.docs/how-it-works.mdanddocs/HANDOFF.md, the working record, including the failures below.
What the gates did not catch
After a rewrite that moved every step onto a whole number of frames, the station started sounding wrong, and every gate passed: no clipping, no sample discontinuities, per-channel levels within 0.5 percent of the previous build, render parity 1.000000, the drum comparison still at 0.9918. Measured by ear first and then by counting, 51 songs in 60 had picked up an audible limp.
The cause was the groove selection an agent had written to reach the tempi between the reachable rungs. It made some steps up to 19 percent longer than the average, repeating every four steps, forever. The float rounding it replaced had spread the same total error quasi-randomly, so it never formed a pattern and never became audible. The thing worth preserving had not been the drift; it was the absence of a repeating shape, because the ear locks onto anything that repeats every bar. Grooves are now even or a symmetric shuffle only, which is a feel a musician would choose: 60 straight, 20 shuffled and 0 lopsided out of 80.
Three gates then failed on the fix, and all three were wrong rather than the code. They had been asserting numbers where the hardware only offers a quantised ladder: three tempi per scene, at least twenty rungs, a bpm ceiling. Each asserts the property it actually meant now.
I keep that on the record because it is the honest shape of building this way. Gates catch what they were written to catch. A regression that every existing measurement calls identical gets caught by listening, and the real work starts afterwards, turning what you heard into a check a future agent cannot pass by accident.
What is verified, and what is not
- The cartridge is verified in an emulator, not on hardware.
scripts/verify-rom.jsexecutes the exported ROM on the LR35902 in this repository and captures every write the driver makes to the sound registers;scripts/verify-rom-audio.jsthen compares the result spectrally against the browser's own render. The image carries a valid header and boot logo. There is no dated physical Game Boy test in the record, and nothing here should be read as one. - Browser behaviour is verified in headless Chromium through Playwright. There is no WebKit or Firefox gate. Safari problems on this product were found and fixed by hand, from a real device, not by a check.
- LSDj parity is partial and the gaps are named. Tempo, pitch and the instrument bytes are measured against the real ROM in mGBA. Row-by-row frame placement differs from LSDj's by within one frame on 0 to 20 percent of rows, with averages matching to 0.01 percent, so the timing is not bit-identical and is not claimed to be. LSDj's envelope is software rather than a hardware field and is not reproduced, which is the largest remaining audible difference. Drums move to the noise channel because a
.savcannot carry kit samples, and instruments are stock defaults, one per channel. The export reports all of that in its own warnings rather than leaving it to be discovered by ear. - The strongest gate is conditional. The LSDj emulator check needs mGBA and the reader's own copy of the LSDj ROM, which is Johan Kotlinski's, is freeware for personal and educational use, and cannot live in the repository. Without both it skips and says so loudly, on the principle that a gate which silently does nothing is worse than no gate.
- Composition and sharing are local; the rest is not. The radio stream is a server because listening in a car needs one, and the site is hosted. Nothing in the repository builds, signs, publishes or deploys on a trigger; every deployment is a command someone runs.
The project began as Retro Radio. Its historical update remains attached so the progression from an early playable experiment into an operated product is visible rather than rewritten after the fact.