Music and sound effects for games. Plain-text scores, one synth, two console eras, and a previewer that lets you hear them against each other.
Hear it in the browser → — every
track and effect in both eras, the layer lanes, the keyboard and the editor. It
is the site/ that sound build writes, rebuilt from this repo on every push,
so it plays exactly what is committed here.
Status: the core works. ls, check, play, render, hear, fold,
fx, instruments, explain, view, build and runtime are built and
tested — 56 tests, npm test. Recording lives in the previewer's Keyboard tab.
AXI imports what runtime emits; VECTRENCH has not been moved over yet.
Not yet built: import (MIDI/MusicXML/ABC) and compose.
DESIGN.md is the spec; FORMAT.md is the file format;
LAYERING.md is how to make these sounds better.
npm install # only needed for MP3 output; WAV has no dependencies
node cli/index.js viewsound ls # 11 tracks, 108 effects
sound check --all # the gate: bar widths, tokens, instruments
sound play runner --era 16bit # hear it in the terminal
sound render --all --both # 8-bit and 16-bit side by side, to ./audio
sound render runner --format mp3 --depth 24
sound fx play splash --both
sound fx explain metal # the layer stack, line by line
sound explain lead # an instrument's layers
sound view # the browser previewersound view serves a page that decodes both eras of a track and starts them
together, sample-aligned. Switching 8-BIT / 16-BIT mid-playback only moves
gain between them, so you land in the same bar instead of restarting and
comparing a timbre to your memory of one four seconds ago.
Both versions are set to the same volume before you hear them. This matters more than it sounds: louder always wins a blind comparison, even when it is the worse take, and rendered raw the 16-bit rig lands about 5 dB above the 8-bit one on the same score. Levelling them means the difference you hear between the two buttons is the sound of the two machines and nothing else. The layer lanes are deliberately not levelled, because there the point is which voice is louder.
SHOW LAYERS renders each voice on its own and stacks them as lanes with mute and solo. Lanes are drawn at their true level in the mix and are not normalised, so a quiet pad looks quiet — which is the thing worth seeing. The window steps through the track in bars, so you can watch Canon's second violin enter at bar 17. Effects have the same thing under inspect layers.
There is no server. That is why the published copy above is the whole tool
rather than a screenshot of it. The page loads the scores as text and
synthesises them in the tab, using the same core/ modules the CLI imports — verified
byte-identical, 0 differing bytes across a 16 MB render. sound view only
serves files, and sound build writes the same thing as a static folder:
sound build # -> site/ (about half a megabyte, no dependencies)Drop site/ on GitHub Pages, S3 or python3 -m http.server and it works.
Rendering happens in a small pool of module workers, so the page never freezes
and both eras of an A/B render at once.
Progress is real, not estimated. The renderer reports how far through it is and the bar draws that. A cost model built from note counts was tried first and came out 45% off in the median case, because vibrato makes a held note several times costlier per sample than a short one and counting notes cannot see that.
The Keyboard tab plays live: computer keys in the tracker layout (Z–M lower octave, Q–U upper), the mouse or touch, or a MIDI device if one is plugged in. Arm REC, play, press stop. The first thing offered is PLAY BACK — the notes at the times you played them, no grid involved. Turning a take into a score is a separate step, because a score is a grid of steps and converting rounds your timing to it. Name it and SAVE keeps it, and it appears under YOURS on the Tracks tab.
It runs an AudioWorklet over the same core/voice.js the renderer uses, so a
note played on the keyboard and the same note written into a score are the same
samples. A graph of OscillatorNodes would have been quicker to write and would
have been a second synth. 128-sample blocks at 44.1 kHz put the floor on latency
at 2.9 ms.
Two things in the recording are worth knowing because they are the parts people notice:
- Quantising rounds to the nearest step, not down. Rounding down drags everything late, which is what makes a quantised take feel behind the beat.
- A chord becomes one voice per note, since a score voice holds one note per step. Lanes are assigned by which is free rather than by pitch, so a held note stays in one lane instead of moving every time a chord changes shape. Past four at once, notes are reported as dropped rather than silently overwritten.
The Tracks tab has two groups and the line between them is where the score is:
| YOURS | saved in this browser, in its own database — what you recorded or edited here |
| INCLUDED | the .snd files in tracks/, whatever came with the tool |
Yours are kept in the browser rather than on a server because there usually is no server: the previewer is a static page, and on GitHub Pages there is nothing to POST a take to. Saving in the tab is the one thing that works everywhere, so it is what SAVE does. They survive a reload and a restart, they are per-browser and per-machine, and clearing site data clears them — so DOWNLOAD .snd is how one leaves, and it is the only copy that outlives the browser.
Editing an INCLUDED track and saving makes a copy under YOURS. A page cannot be
allowed to quietly rewrite canon-in-d.snd in the repo, and the edit is still
worth keeping, so it asks for a name instead.
ADD TO tracks/ promotes one into the library, writing the file and dropping
it from the browser so the same score is not listed twice under two different
claims about where it lives. That one needs sound view, because writing a file
needs something that can write files.
The Editor tab opens a song as a set of tracks on a timeline, in the words editing software uses:
| track | one instrument's part — a @voice in the file |
| arm | which track a recording goes into |
| overdub | record a new track while the others play |
| playhead | where you are |
| selection | a span of bars, which INSERT and DELETE act on |
Click to add a note, drag to move it, drag its right edge to lengthen, alt-click or right-click to delete. INSERT BAR and DELETE BARS act on the bar range, so you can open space in the middle or cut the end. CLEAR empties the range on the selected track only. Other tracks show as grey ghosts behind the one you are editing.
Overdub is the point of it: arm a track (or make a new one), press OVERDUB, and everything else plays while what you play is recorded into the armed track, quantised against the song's own tempo. That is how a song gets built — one part at a time against the parts already there.
A song can be written two ways and they render identically:
- flat — every bar, one after another. Nothing to resolve. Easiest to read, to diff, to edit, and for a model to write.
- folded — short sections named in an
@order. Smaller, and it keeps the leverage: edit the hook once and all seven repeats change.
The editor unrolls a song to work on it and can fold it again on the way out.
sound fold runner # 32 played, 12 written, 3 sections, 8 slots 13K -> 4K
sound fold runner --unfold # back to one flat timelineFolding rediscovers structure rather than inventing it. It chunks at a fixed phrase length — 8, 4, 2 or 1 bars, every offset — and keeps whichever writes the fewest bars, charging each order slot a third of a bar so phrases win over a hundred-slot order of single bars. On RUNNER it finds exactly the 12 bars and 3 sections the original was written with; on SCRAMBLE it finds 30 where the hand-written file has 34, and on GREENSLEEVES 8 against 9.
It is verified, not assumed: every fold is expanded again and compared note by
note before it is written, and sound fold refuses to write one that does not
match. All nine tracks round-trip, and a folded track renders to samples
identical to its flat form — 0.00e+0 apart.
A greedy longest-repeat-first pass was tried first and is a good lesson in why this needed measuring. On RUNNER it took bars 4–11 as one block (the hook, then the hook again), then 12–19 (bridge, hook), then 20–27 (hook, bridge) — three blocks each containing the hook and none matching any other. Zero reuse on a song that is four bars repeated.
Four words, used precisely, because three of them sound like each other:
| layer | part of one instrument. lead is a 25% pulse plus a triangle an octave down — two layers, one sound. You do not write these in a score; they live in core/instruments.js. |
| voice | one part in a track: a @voice block, one note at a time. A melody is a voice, a bass line is a voice. A recorded chord becomes one voice per note. |
| section | a named block of bars — @section A. Written once, played wherever @order names it. |
| track | one .snd file: header, voices, order. This is a song. |
So a song is a file with several voices in it. Recording gives you one take at a
time; building a song from takes means merging their @voice blocks into one
file, which today is a text edit. Overdub — recording a new part while an
existing track plays — is the missing piece, and it is the next thing worth
building.
One track is one file, one bar is one line, one step is a column.
@track runner
name RUNNER
bpm 150
beats 16
era 8bit
@voice lead inst=lead mix=0.20
d5 . a4 . d5 f5 e5 d5 a4 . . . d5 . . .
c5 . g4 . c5 e5 d5 c5 g4 . . . bb4 . . .
@voice drums inst=kit mix=0.11
k . h . s . h . k . h . s s x x
. holds, - rests, / and \ sweep, ~ adds vibrato, ! accents. Effects
use the same discipline — one layer per line — in fx/*.fx.
This is chosen so music is reviewable the way code is: a diff shows which bar
moved, git blame says who wrote the chorus, and a merge conflict is confined
to the bars two people both edited. Rendered audio is derived and gitignored;
the renderer is deterministic, so sound render reproduces any of it.
See FORMAT.md.
Tracks — five public-domain classical pieces, two imported from the games, and two originals.
prelude-c |
Bach, Prelude No. 1 in C, BWV 846. Bars 1–12. One long arpeggio — the best piece in the book for hearing what a chip arp voice is for |
fur-elise |
Beethoven, WoO 59, the A section. Right hand as lead, the left hand's rising arpeggio as bass |
ode-to-joy |
Beethoven, Symphony 9. Almost all melody and no rhythm, so the A/B is purely timbre |
greensleeves |
Traditional, c. 1580. 6/8, both strains |
canon-in-d |
Pachelbel. The ground bass, the violin entry, and the canon at one statement's distance — the file to read for what @section and @order buy |
scramble-1-vectrench |
From VECTRENCH, its training-canyon theme. A minor, 150 BPM, 74 bars, nine sections over nineteen order slots |
overworld-1-axi |
From AXI, overworld theme 1. Stored there as MIDI arrays with the bass built at runtime; transcribed here as a score |
runner |
Original stage theme, native 8-bit. Sections, fills, sweeps, an arrangement |
layers |
A teaching track. Four bars, six times, one more voice each pass |
Effects — 100, each defining both eras in the same file. The previewer has a
search box (/ to focus) that filters on name, description and tags.
| water | splash splash-small splash-big drip drips bubble bubbles underwater pour wave swim thud-wet |
| impact | thud thud-heavy metal wood glass land stomp |
| weapons | laser machinegun shotgun reload ricochet shell rocket |
| explosions | explosion -small -huge -distant -mine -barrel -plasma -underwater -chain -debris -firework |
| fighting | punch block slash ko |
| platformer | jump bounce dash coin checkpoint footstep |
| rpg / magic | levelup spell sparkle chest potion heal powerup |
| horror | heartbeat stinger creak door-creak ghost thunder halloween |
| sci-fi | teleport forcefield scan powerdown robot warp |
| racing | engine skid gearshift countdown |
| puzzle / ui | blip select pop correct wrong notify whoop |
| strategy | build |
| arcade | gameover extralife hurt death |
| ambience | fire wind door fireworks fireworks-finale |
| animals | birds dog-bark cat-meow snake-hiss owl |
| vehicles | engine skid gearshift car-horn truck-horn boat-horn siren-police siren-twotone |
| tabletop | dice-roll wheel-spin |
The eleven explosions differ by mechanism, not by gain. -mine is 79% of its
energy in the first 20 ms with a 0.05 s tail; -distant has no transient at
all and 91% of its energy below 200 Hz, because air absorbs high frequencies
over distance; -underwater has 0.5% above 2 kHz; -plasma is tonal where the
rest are broadband; -huge runs 3.65 s. -debris is the aftermath on its own,
meant to be fired 200 ms behind any of the others.
fireworks is a seven-second display of six shells at three apparent
distances. Distance is modelled, not faked: air absorbs high frequencies, so
the near shells measure 31% above 3 kHz while the far ones measure 0.0%,
and per-layer send= makes the far ones mostly reflection — which is what puts
them behind the near ones rather than merely under them.
43: eleven 8-bit, thirty-two 16-bit.
sound instruments # the list
sound explain flute # its layers, spelled out
sound hear flute # play a phrase on itThe Instruments tab plays the same phrase from the same source. The phrase
follows the instrument's role rather than being one generic run, because a
four-note arpeggio says nothing about a flute's attack and a held whole note
says nothing about a marimba's decay: sustained voices get a legato line,
struck voices get a run that exposes each decay, basses get a bass part, kits
get a beat. It lives in core/probe.js so the CLI and the page cannot
disagree.
8-bit is close to complete by definition — the 2A03 had two pulse channels,
a triangle and noise, and all three duty cycles are here, plus noise-lead
(short-mode noise resampled to track pitch, which is a real melodic voice on
that chip) and dpcm (five-bit quantisation, like the DMC channel).
16-bit covers brass (brass, horn), winds (flute, clarinet, oboe,
accordion), voice (choir), keys (piano, epiano, wurli, clav,
harpsichord, organ), guitars (guitar, guitar-nylon, guitar-clean,
guitar-dist, banjo, harp, pluck), tuned percussion (marimba, vibes,
glock, timpani, fm-bell), strings, pads and four basses.
Measured on a sustained A3, the keys separate by decay and the guitars by
brightness — harpsichord has no sustain mechanism at all, accordion is a
wind instrument and holds:
| fundamental | 2nd+3rd | 1–4 kHz | level at 0.5 s | |
|---|---|---|---|---|
piano |
93% | 4% | 3% | −6.4 dB |
epiano |
66% | 21% | 12% | −13.1 dB |
wurli |
54% | 26% | 20% | −28.4 dB |
harpsichord |
74% | 14% | 10% | −34.2 dB |
accordion |
64% | 21% | 14% | −6.3 dB |
guitar-nylon |
96% | 3% | 1% | −9.6 dB |
guitar |
81% | 8% | 11% | −14.0 dB |
guitar-clean |
48% | 21% | 29% | −4.4 dB |
guitar-dist |
62% | 18% | 21% | −4.1 dB |
banjo |
43% | 34% | 21% | −38.8 dB |
Measured harmonic content on a sustained A3 — clarinet is a 50% pulse, and a
50% pulse has no even harmonics, which is why its second is 0%:
| fundamental | 2nd | 3rd | 1–6 kHz | |
|---|---|---|---|---|
flute |
97% | 3% | 0% | 0% |
clarinet |
86% | 0% | 6% | 7% |
oboe |
42% | 26% | 14% | 18% |
horn |
27% | 61% | 10% | 2% |
brass |
41% | 45% | 11% | 4% |
slap |
56% | 4% | 15% | 25% |
Every instrument carries a trim: a calibration that brings it to a common
K-weighted loudness. Without it the library had a 21.5 dB spread — a 50%
pulse was twelve decibels louder than a pluck for identical settings — so a
score's balance was an accident of which synthesis method each voice happened
to use, and it changed when the era changed. That is why 16-bit renders came
out bass-heavy while 8-bit ones did not: the chord voice was simply vanishing.
node tools/calibrate.mjs --check # nonzero exit if any instrument driftedRun it after changing any instrument's layers.
Scores get tone controls of their own — lp=, hp= and tilt= per voice —
for the case where a part is right but sits wrong. Dropping mix there just
makes the part quiet instead of making it fit. sound render --tilt N does the
same to a whole mix, and the previewer has BASS and TREBLE for auditioning
without changing anything on disk.
--era 8bit|16bit is a console generation: the NES 2A03 versus the
SNES/Genesis class. --depth 16|24|32 is PCM word length in the output file.
They are unrelated axes and live on separate flags so they cannot be confused.
| 8bit | 16bit | |
|---|---|---|
| pitch | quantised to the 11-bit period register | equal temperament |
| envelope | 15 hardware steps | ADSR |
| voices | pulse, pulse, triangle, noise | 4-op FM, detuned saw stacks, drawbars |
| space | mono, dry | stereo, echo send |
A score names instruments from one era; rendering it in the other substitutes
through a table in core/instruments.js. That is why one file plays on both.
core/ dsp, score parser, instruments, renderer, wav (no I/O, testable)
cli/ the `sound` command, and the preview server
ui/ the previewer page
tracks/ *.snd
fx/ *.fx
test/ 22 tests
There is exactly one synth. VECTRENCH has three today — the browser player, the copy vendored into AXI, and an offline mirror whose own first line admits it "mirrors src/music.js closely enough" — and nothing tests that they agree. Deleting that arrangement is what this package is for.