Tool ·

Hum

Music described as constraints instead of notes, rendered to separate layers a game fades in as things get worse.

StackTypeScript · Web Audio · Web Workers · Cloudflare Workers
StatusLive
LinksVisit

I am building a space colony game and it needed music. The options were all bad. Licensing loops costs money per track and they loop audibly. Writing them by hand means being a composer. Generating them randomly means most of what comes out is not music.

Hum is the third option, with the usual problem fixed.

Two panels. On the left, eight lines describing a key, tempo, chords and three layers. On the right, five coloured waveforms, one per layer.

Eight lines in, five layers out. The version this replaced was sixty lines of hand-written note names.

You do not write notes. You write the rules the notes have to follow: a key, a chord progression in roman numerals, a groove, and how busy each layer should be. The notes get picked at render time from what those rules allow. That is why you can roll it again and get something different that still works.

Why random output usually sounds wrong

Because the format lets it be wrong. Fix that and most of the problem goes away.

Notes outside the key are never in the list to pick from, so a random pick is still in key. Hits land on half-beat positions only. A note starting at 3.37 beats does not sound loose, it sounds like a bug. Rhythm parts pick chord tones about 80% of the time, which is easier to get right than writing rules about passing notes and does most of the work.

Adaptive music is a mixing problem

The obvious approach is to generate music live in the game and change it as things happen. I would avoid that. Generating audio from game code costs CPU you want for pathfinding, and every bug becomes an audio glitch.

Hum renders the layers up front instead. They are all the same length and all in sync. The game only changes how loud each one is.

Five horizontal bars labelled pad, bass, perc, rhythm and melody. Pad and bass span the full width; the others begin further along a scale marked from calm to everything at once.

Each layer has a pressure it comes in at. The game sends one number and the mix follows.

A quiet colony gets a pad and a bass line. A fire in hydroponics brings in drums and a melody. Nothing is generated while the game runs.

Two rules make this work, and I got both wrong first.

Every layer plays the whole time. They are never started or stopped separately, so they cannot drift apart. Only the volume changes.

Section changes wait for a bar boundary. Cutting mid-phrase is still in time and still sounds broken. That one rule is most of the difference between an adaptive score and a glitch.

Drums are the exception

Every other layer is generated. A wrong note inside the key is still music, so randomness is safe. Randomly placed drums are not a groove, so drums are written out by hand.

A grid of sixteen cells across three rows labelled kick, snare and hat. Some cells are filled red for a hit, some yellow for an accent.

Grooves are written as a grid. Change the bar from sixteen steps to twelve and it swings in triplets. That one number is the whole identity of one of the styles.

Each groove has a fill that plays on the last bar before the chords come round again. Without it you hear the same bar for ninety seconds and it registers as a loop, however good the bar is.

The grid in the editor is that same grid. Tap a step to switch it between off, hit and accent, and the drum layer re-renders in about a second.

The same code renders files and drives the browser

The engine is arithmetic over a Float64Array. No browser APIs, no Node APIs. So the same code renders WAV files from the command line and drives Web Audio in the editor.

I did this so the preview cannot drift from what ships. Two separate synthesisers would eventually disagree, and you would not notice until it mattered.

The editor uses the same mixing logic as the game's player, so what you hear in a browser is what the game will do.

Proving the port first

Hum started as a port of a Python generator that already scored the game. Before building anything on top, it had to reproduce an existing track sample for sample. It does: 3,175,200 samples, 100% identical, worst error zero.

That test could have failed, which is why it was worth writing. A port that only sounds right is what a slightly wrong envelope gives you, and everything built afterwards inherits the error without ever showing it.

Three details decided it, and all three look like tidying up:

  • Truncate toward zero when converting to 16-bit, because that is what Python's int() does. Rounding puts every sample one bit out.
  • Shorten the release, not the attack, when a note is too short for its envelope. This affects every short drum hit in the existing tracks.
  • Compare after converting both sides to 16-bit. A floating point difference that does not survive to disk is not a difference.

The WAV encoder is hand written for the same reason. A library that rounded differently from Python would have made a correct port look broken.

Bugs the tests found that listening did not

A melody that never played. A slow tempo with four bars per chord can make one cycle longer than half the requested length, so the length rounds down to a single cycle. The melody only starts on the second pass through the chords, so it came out as a silent layer inside a piece that otherwise sounded finished. A test across every seed found it in milliseconds. Nobody would have heard it, because there was nothing to hear.

A note the engine could not play. The note table covered seven octaves. A melody builds its pool two octaves above its own setting, so a high melody asked for a note that did not exist and killed the render partway through.

Sliders that rewrote every track. A range input snaps whatever you give it to its own step. A generated volume of 0.054 became 0.055 the moment the editor displayed it. What you heard was never quite what was generated and nothing said so. I fixed it in the generator rather than the sliders, so the format only produces values its own controls can hold.

A test that checked nothing. The export writes ZIP files by hand, and the test extracted them with tar to prove an outside tool could read them. The tar on my machine is GNU tar, which cannot read ZIP at all. The test passed and verified nothing.

Why the first version sounded the same every time

The seed was the smallest reason.

Pad and bass have no randomness in them. They come straight from the key and the chords, so every seed shares the same harmonic bed. On top of that, every chord invented a brand new figure, so nothing ever came back. Music sounds composed when a phrase repeats. Constant novelty just sounds like texture, and textures from the same pool of notes all sound alike.

Now a piece invents two or three figures once and plays them across the chords in a pattern like ABAC. A figure stores a position in the note pool rather than a note name, so the rhythm stays put while the pitch follows the chord underneath.

The other half is that a style is data. A style holds a mode, a tempo range, chord progressions, how many bars each chord lasts, which grooves fit, which instruments to prefer, how much reverb, and where each layer comes in. Adding a new genre is a block of data, not a change to the generator.

The last field is the interesting one. In an ambient style, drums arriving means something has gone wrong. In an upbeat style the drums are the floor and arrive at zero, so rising pressure brings in the melody instead. Same mechanism, opposite meaning, just configuration.

What made it sound finished

Everything was completely dry. No reverb, no delay. That is a large part of why generated music sounds generated. A note with no reflections around it sounds like a test tone.

There is now a hand-written Schroeder reverb: six comb filters build the tail, three allpass filters smear it so you stop hearing individual echoes. It costs nothing measurable and it improved the output more than any amount of work on the notes.

It runs on each layer separately, never on the mix. The game fades layers independently, so reverb on the mixdown would cut the melody's tail off the moment the melody dropped out.

Applying the same idea to the interface

The editor used to ask for density 0.54. Nobody wants 0.54. They want it busier, and they find out by listening. The tool was arguing for describing things and then asking you to specify them.

So the controls are verbs now, in pairs: calmer and busier, darker and brighter, slower and faster, less beat and more beat, lower and higher. Each one moves several settings at once. Brighter walks through the modes in order of actual brightness (lydian, major, mixolydian, dorian, minor, phrygian), where each step flattens one more note of the scale.

The other half was speed. At four or five seconds per change you try three things and stop.

Giving each layer its own random stream meant one layer could be re-rendered without disturbing the others. A full roll still does all five and takes four to five seconds. Nudging one thing now does one or two layers, which is about two seconds of work. On top of that is a half-second pause so pressing "busier" three times quickly is one render rather than three. End to end you wait about three seconds instead of five.

Better, not solved. The whole loop still gets re-rendered at full length for a change you would judge in eight bars. Rendering a short preview first is the obvious next step.

Running costs

Nothing. Static files on a Cloudflare Worker, no database, no analytics. All the audio is generated in the visitor's browser inside a Web Worker so the page never freezes. There is no server that could see what anyone writes, and no bill that grows with use.

Every track is also a URL, so a good one is a link rather than something you lose when the tab closes. Exports come with a Creative Commons Attribution licence: use them in anything, including commercial work, as long as there is a credit somewhere findable.

Reading