Encoding ideas: making the Megademica soundtrack - Part II

Subliminal Extacy #07

Encoding ideas: making the Megademica soundtrack - Part II

And that brings us to the heart of the challenge. It's all very well defining all of these ideas — rhythms, melodies, scales in isolation, but eventually you have to string them together into a timeline to indicate which ideas should be brought in at which times. For an 8-minute soundtrack, that's the most expensive idea of all. I chose to split the timeline into 256 bars, each one consisting of 16 rows (corresponding to the 16-bit value for the rhythm that we looked at earlier) and being played back at a tempo of six frames per row — which works out as 24576 frames, or 491.5 seconds.

This meant that we would have a list of 256 items, each one capturing all of the things that should happen in that bar. These would largely consist of pointers to reusable ideas defined elsewhere in the data, but given the 2K limit for the entire player and data, we'd only have a handful of bytes available for each bar. In the end, I got it down to three bytes.

The first byte is a pointer into the melody data — this data is stored as one big stream containing all the melody notes used throughout the piece, packed as two notes per byte, and the pointer tells us where to start reading the notes for that bar.

This gave us a maximum of 256 notes, or 128 bytes — I used 68 of those bytes.

The second byte holds the rhythm pattern and musical scale for the bar: the top five bits select one of 32 rhythms, such as %1001001010010010 for the "DAN-dan-dan DAN-dan-dan" riff, and the bottom three bits select one of 8 scales. I ended up only using 9 distinct rhythms (taking up 18 bytes) and 5 different scales (defined as 8 bytes each, so 40 bytes in total).

The third and final byte handles the drums and bassline. Given the limited space, we set ourselves the constraint that the bass has to stay on the same pitch for the whole bar. Four bits of the byte select which note that is, out of a selection of 16. We use the standard AY trick of building the bass sound from the combination of a square-wave tone and a triangle or sawtooth wave produced by the AY's envelope generator at a strategically chosen speed, and this means that each different bass note is defined by four bytes of parameters — high and low byte of the tone period, envelope type, and envelope speed. I used 14 of those bass notes, taking up 56 bytes for the parameter data.

Meanwhile, the other four bits of the byte select one of 16 combined drum / bass patterns.

This is yet another packed data representation, this time using four bits per row. A 16-row pattern might look like:

c8 3a d0 b0 98 ba d8 30

which splits into the high bit (11 01 10 10 11 11 11 00), indicating which rows our bass note is playing, and the lower 3 bits (40 32 50 30 10 32 50 30) giving the drum pattern. There are five drum sounds defined, with 0 being silence:

  • 1 = bass drum
  • 2 = quieter bass drum
  • 3 = hihat
  • 4 = crash
  • 5 = snare

In a regular soundtracker module, each of these samples would be an array of 32 volume levels, noise values and pitches, defining how those parameters vary over time, and taking up 100 or so bytes. But once again, we can ask ourselves: what are the ideas that go into that data? 99% of the time, all of these parameters follow a linear path, and that's exactly the case with our samples here. Each one can be entirely defined in five bytes: initial volume, rate of volume decay, mixer mask (in other words, whether noise or tone are enabled), rate of change of noise value, rate of change of pitch.

I used 13 of the 16 available drum / bass patterns, at 8 bytes each. And that covers the entirety of the music data — an 8 minute soundtrack in 1200 or so bytes.

To keep things friendly for the packer, the overall sequence data was arranged as three distinct tables of 256 bytes, rather than keeping all three bytes that define a pattern together.

This way, if you had four bars with four different melody lines but keeping the same rhythm, drums and bass, two of those bytes would end up the same for all patterns, and the packer will see that as runs of the same repeated bytes. This allowed us to comfortably reach the target of 700-800 bytes after compression.

It's worth taking a moment to appreciate what isn't included here. In particular, there's no control over the dynamics of the melody — everything is played with the same sample at the same volume. There is an echo effect to add some extra interest, where we play the note from two rows ago at a quieter volume whenever there isn't a melody note playing. In a conventional tracker module that's something you'd do by hand, stepping through the pattern data and inserting the echo notes at the appropriate spots — but not only is that incredibly tedious, it's several hundred bytes of additional data. On the other hand, the idea of an echo effect is simple enough that I just explained it in one sentence, and implementing it in code only takes a few bytes. Efficiently expressing ideas isn't just about data representations — it also includes knowing when your idea is a code idea rather than a data idea.

One lesson I learned from Haiku — the last 4K intro where I went all-in on a custom music player — is not to try writing the music directly in this compact format. On that occasion, I had the entire composition planned out beforehand and could effectively treat it as a transcription exercise, but that came back to bite me when I achieved everything I set out to do in the demo with a kilobyte to spare, and would have been quite happy to add another few parts, if it wasn't for the fact that extending the music would involve pulling apart the existing data. Haiku also didn't go nearly as hard on the "lookup tables pointing to other lookup tables" front as Megademica — trying to keep track of that while writing a soundtrack would certainly have sent me round the bend.

This time, I wrote a Python script to manage the whole process.

It was pretty bare-bones as far as music tooling goes, but it allowed me to build up the track as an array of pattern objects, with all the capabilities you'd expect from a high-level language — assigning meaningful variable names to different sections of the composition, and concatenating those together into the final sequence. Each pattern was written in a text-based notation pretty close to what you'd see in a tracker. Everything was then handed off to the Python code, that would pull out the rhythms, note sequences and scales, identify the places where those musical ideas were repeating ones that we'd already encountered, and generate the efficiently-encoded data.

And from there, it was just a small matter of composing a soundtrack to take full advantage of this scheme, getting as much impact as possible from as few distinct ideas as possible. At the start of the piece, you'll hear several four-bar themes being introduced — often as an unaccompanied melody — before being repeated with a drone bassline, then a simple drum pattern, then the melody repeated an octave up (done by reusing the same melody data against a different scale), then bringing in a more intense drum line. Part two approaches things slightly differently — the melody motifs are strategically chosen to fit well when paired up with various different bass notes, taking on a different character for each one. In this way, the bass line is driving the direction of the music, disguising the repetition of the melody as it does so.

I'm very pleased with how it all turned out — and you can apply these principles too, at any scale. Whether you're laying down musical notes, pixels, or opcodes, be sure to ask yourself: what are the fundamental ideas embedded in this piece of work? Maybe you'll find a better way to represent them, and reap the benefits in file size.

Gasman

Share your thoughts about the article