Skip to content
cfreder2Public

About

Music and sound effects for games: plain-text scores, one synth, 8-bit and 16-bit eras, and a previewer that hears them side by side

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

37 Commits

Folders and files

Repository files navigation

sound-cli

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 view
sound 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 previewer

The previewer

sound 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.

Playing and recording

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.

Yours, and where they live

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

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.

Unroll to edit, fold to keep

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 timeline

Folding 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.

How the pieces fit

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.

The format

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.

What ships

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.

Instruments

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 it

The 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%

Levels, and why mix= means something

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 drifted

Run 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.

Eras are not bit depth

--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.

Layout

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.

About

Music and sound effects for games: plain-text scores, one synth, 8-bit and 16-bit eras, and a previewer that hears them side by side

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages