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

A game I am building needed music, and the honest options were bad. Licensing loops means paying per track for something that loops audibly. Writing them by hand means being a composer. Generating them at random means most of what comes out is not music.

Hum takes the third option and fixes the reason it usually fails. You do not describe notes. You describe the constraints the notes have to satisfy: a key, a mode, a chord progression in roman numerals, a groove, and how busy each layer should be. Everything inside those constraints is chosen at render time, which means it can be rolled again and still be music.

Twelve lines describe a track. The version this replaced was sixty lines of hand-written note names that could not be varied without a musician.

The idea it turns on

A format you can safely randomise is one where being wrong is not expressible.

Notes outside the key are never in the bag to be picked, so a random choice inside the format is still in key. Figures land on half-beat positions only, because something that can start at 3.37 beats does not sound loose, it sounds broken. Chord tones are weighted at around 80% for rhythm parts, which is cheaper and far more robust than writing rules about passing notes, and is most of why random output sounds deliberate rather than accidental.

It is the same principle as a good test fixture or a well-drawn API. Describe what has to stay true and let the thing that renders it choose the specifics.

Adaptive music is a mixing problem, not a synthesis problem

The obvious approach is to synthesise live in the game and change the music as things happen. That approach is a trap. Pushing samples from game code costs CPU that pathfinding wants, and turns every bug into an audio glitch.

So Hum renders offline into separate layers, all the same length and all in sync, and the game only changes how loud each one is. A calm colony hears a pad and a bass line. A fire in hydroponics brings in percussion and a melody. Nothing is composed while the game runs.

Two rules make that work, and both were learned by getting them wrong first.

Every layer plays all of the time. They are never started or stopped independently, so there is nothing to drift out of sync. Only the volume changes.

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

One renderer, running in two places

The engine is pure arithmetic over a Float64Array with no browser and no server APIs in it. The same code renders files from the command line and drives the browser editor. Two synthesisers would drift, and the preview would gradually start lying about what ships.

The editor is deliberately built the same way as the game's player: the intensity control does exactly what the game does, so what you hear in a browser is what the game will do.

That went further than intended and turned into the interface itself. The layer stack is the control. Left to right is pressure, each layer is a band starting at its own threshold, and you drag a line across the whole thing. Layers ignite as it passes them. The diagram and the knob are the same object because they were always describing the same thing.

Proving the port before building on it

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

That test could have failed, which is the point of it. A port that merely sounds right is what a subtly wrong envelope produces, and everything built afterwards inherits the error silently.

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

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

The WAV encoder is hand rolled for the same reason. A library that disagreed with Python about rounding would have made a correct port look broken.

What the tests caught that listening did not

A melody that could never sound. A slow tempo with four bars a chord can make one cycle longer than half the requested length, so the length snaps to a single cycle. The melody enters on the second pass through the progression, so it became a silent layer inside a piece that still sounded finished. A test across every seed found it in milliseconds. Nobody would ever have heard it, because you cannot hear a layer that is not there.

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

A drum machine that was checking nothing. The archive export writes ZIP files by hand, and the test extracted them with the tar on my machine to prove an outside tool could read them. That tar is GNU tar, which cannot read ZIP at all. The test passed happily and verified nothing. It now goes through a genuinely separate extractor.

That third one is the general lesson and the one I keep relearning: a check that cannot fail is not a check.

Where the variety comes from

The first version sounded the same every time, and the seed was the smallest cause.

Pad and bass carry no randomness at all, since they come straight from the key and the progression, so every seed shared an identical harmonic bed. Worse, every chord invented a fresh figure, so nothing ever came back. Music sounds composed when a phrase returns; constant novelty is heard as texture, and all textures drawn from the same pool sound alike.

Now a piece invents two or three figures once and plays them against each chord in a shape like ABAC. A figure stores a position in the note pool rather than a note name, so the rhythm holds still while the pitch follows the harmony. That is a phrase developing rather than repeating.

The other half is that a style is now data. A mood carries a mode, a tempo range, progressions, how many bars a chord is held for, which grooves suit it, which instruments to prefer, and where each layer sits on the intensity scale. Adding a genre is a block of data, not a change to the generator.

The interesting field is the last one. In an ambient piece, drums arriving mean something has gone wrong. In an upbeat piece the beat is the floor and arrives at zero, so rising intensity adds the melody instead. Same mechanism, opposite meaning, expressed entirely as configuration.

Drums are the one thing that is not generated

Every other layer is invented, because a wrong note inside the key is still music. A drum pattern chosen at random is not a groove.

So grooves are written out, as the grid they are:

kick   X···X···X···X···
snare  ····X·······X···
hat    ··x···x···x···x·

Sixteen steps to a bar, X for an accent, x for a hit. Set the bar to twelve steps instead and it becomes a triplet feel, which is the entire identity of one of the styles and costs one number.

Each groove also carries a fill, played on the last bar before the progression comes round. Without one, a drum part is the same bar repeated for ninety seconds, and that is heard as a loop however good the bar is.

Running costs

Nothing. It is static files on a Cloudflare Worker with no bindings, no database and no analytics. All the audio is synthesised in the visitor's browser, in a Web Worker so the page never freezes, which means there is no server that could see what anyone writes and no bill that grows with use.

A ninety second track renders in about three seconds in a browser, which is faster than the same code from the command line.