Time: an evening to get your first piece playing; an afternoon, as it turned out, for a seven-track soundtrack. Cost: nothing. Difficulty: you need to be able to run a Python script. You don't need to read music. It helps to be able to say what you like and what you don't, and that turns out to be the important job.
At the end of this you will have a script that writes a piece of music as data, checks it the way an orchestra would, and performs it with real sampled instruments: piano, strings, church organ, cello. It hands you a finished MP3 at a sensible level. It runs on a Raspberry Pi or any ordinary computer. It needs no music generator and no graphics card, and every note in the result is yours to use however you like, including in something you sell.
You will also have the method that turned exactly this into a seven-track, twenty-one-minute soundtrack for my game, in one afternoon. Start by listening to it, because the rest of the guide is about how it was made:
Calliope is the music and voice pipeline in my fleet, run by an AI agent called Helena. She is Claude, working in a terminal on my behalf. Helena wrote every note of that track as lines of Python. A free program called FluidSynth then played those notes using sampled instruments, on a Raspberry Pi 5 sitting on a shelf in my house. No text-to-music model was involved at any point, and no graphics card. She also cannot hear, which, as you'll see, is the most interesting thing about how it was done.
The Rift Haul suite was written and rendered on 1 October 2026 on Nexus, a Raspberry Pi 5 with 16GB, running Debian 12, Python 3.11.2, FluidSynth 2.3.1, pyfluidsynth 1.4.0, numpy 2.4.6 and ffmpeg 5.1. The script in this guide was written and run on the same machine the same evening, and every line of its output below is real. The figures about the suite are Helena's own, read off her renders and session logs.
What happened
Rift Haul is the browser space-trading game I'm building as the commercial successor to Meridium Star Traders. You are a hauler working the trade lanes between stations, a long way out and mostly alone. It needed music that plays almost all the time: at the login screen, in transit and in port. The music goes quiet for combat and for trading, and comes back when those end.
At 11:28 that morning I gave Helena the brief. This is what I typed:
"Pretty soon you will get a request from the Geordie for sound effects, ambient music, all manner of items for Rifthaul his game. The first part of that request I can give you now - the ambient background track that plays nearly all the time in the game, unless in combat, trading working an action. What I'd like initially is for you to derive and generate our own score, inspired by [Interstellar]. Keep in mind Rifthaul will be a commercial game, it's the successor to meridium star traders, so licensing is important. How does that sound? I'm thinking we'll end up with some form of say 5-6 actual tracks inside some form of playlist/player that the player can control."
That was the whole brief. Fifteen minutes later I was listening to a ninety-second sketch of a house theme. By 13:59 I had seven finished pieces, 21 minutes 32 seconds in total, all in one key family and all built on that one theme. I approved each of them on the first take I heard. I have since had the in-game playlist running for three or four hours. Honestly, it's the most incredible work I've ever seen from a Claude.
The seven:
| Track | Length | Key and tempo | Carried by | Its one idea |
|---|---|---|---|---|
| Rift Haul | 1:44 | D dorian, 72 | piano | the house theme, stated plainly; opens every session |
| Drydock | 3:01 | B dorian, 80 | piano, French horn | one figure split note by note between pizzicato strings and marimba: two crews on one job |
| Long Haul | 3:38 | E dorian, 66 | piano | a clock ticks throughout and stops on the last downbeat |
| Dead Reckoning | 3:18 | G dorian, 63 | soft piano, cello | a duet: the two swap the tune |
| The Rift | 3:35 | C dorian, 60 | solo organ | the one real climax, and it ends on a cut, not a fade |
| Station Lights | 3:06 | E♭ dorian, 68 | vibraphone, clarinet | glockenspiel "lights" blinking on a clock that never lines up with the music |
| Far Shore | 3:11 | F major, 60 | piano, celesta | arrival: the house theme in a major key, for the only time |
Why a score, and not a music generator
I have two text-to-music models running on a graphics card in this house, and Helena used neither. That was the first decision, taken before a single note was written, and I'd make the same one for anything you plan to sell.
- A generator gives you texture and mood. It can't give you a tune. At least, not one specific, in-tune, hummable melody that comes back, changed, across seven pieces. That returning tune is what makes seven tracks sound like one soundtrack rather than a playlist, and a written score does it naturally.
- The licence is clean all the way down. The notes are original, so they're mine outright. The instruments come from two free sample libraries under the MIT licence, which allows commercial use. Neither the FluidR3 General MIDI set nor MuseScore General asks for registration, has a revenue cap, or requires a credit in the audio. There's no model licence anywhere in the chain to read the small print of.
- A score can be edited. Change one note and render it again, and you get exactly the same performance with that one note different. You can't do that with a generator.
"Inspired by Interstellar" was handled the same way, and I think this is the right rule for anyone working from a reference. Borrow the method, never the notes. What was borrowed from Hans Zimmer's score was a set of choices: pipe organ as a lead colour, a mechanical repeating figure underneath, a clock-like tick, slow-moving harmony, and a plain piano in a big space. What wasn't borrowed was any melody, figure or chord progression. Helena deliberately avoided the film's home key of A minor across the whole suite. She also built her own repeating figure, six notes cycling against eight beats, so it drifts against the bar in a way Zimmer's famous one doesn't.
Before you start
What you need to buy: nothing.
What you need to have: a Raspberry Pi 4 or 5, or any Linux machine, running Debian, Raspberry Pi OS or Ubuntu. A Mac works with Homebrew's fluidsynth, with one change: point SOUNDFONT in the script at wherever you have put the MuseScore General file. On Windows the easy route is WSL, which gives you an Ubuntu terminal on the same machine. You don't need a sound card or speakers on the machine doing the work, because nothing is played live: the music is written straight to a file, and you listen on whatever you like.
What you need to decide first: the licence. If there's any chance you'll sell the thing the music goes in, write down now where every sound will come from, and only use sources that permit it. Everything in this guide does. Deciding this first is what made the rest of Helena's choices obvious.
The build
Five steps, and the output is a 41-second render of the opening of the Rift Haul house theme. Once it works, it's yours to change.
1. Install the synthesiser, the instruments and the measuring tools
sudo apt install fluidsynth ffmpeg musescore-general-soundfont python3 -m venv ~/compose ~/compose/bin/pip install pyfluidsynth numpy
That gives you FluidSynth, the program that performs the notes. musescore-general-soundfont is the instruments themselves: an 86MB file of recorded samples, installed at /usr/share/sounds/sf3/MuseScore_General_Full.sf3. ffmpeg does the measuring and makes the MP3. The two Python libraries let a script drive the synthesiser and do the arithmetic.
What you should see:
fluidsynth --version FluidSynth runtime version 2.3.1 ls -l /usr/share/sounds/sf3/MuseScore_General_Full.sf3 -rw-r--r-- 1 root root 85967114 Oct 4 2021 /usr/share/sounds/sf3/MuseScore_General_Full.sf3
2. Write the tune down as data
This is the part that feels strange and then quickly doesn't. A piece of music here is just a Python list. This is the first eight bars of the Rift Haul house theme, exactly as Helena wrote it. Each line is one bar: the chord, then the melody notes as (which beat of the bar it starts on, how many beats it lasts, which note):
A_BARS = [
([(4, 'Dm')], [(0, 2, 69), (2, 1, 74), (3, 1, 76)]),
([(4, 'G/D')], [(0, 1.5, 77), (1.5, .5, 79), (2, 2, 76)]),
([(4, 'Bbmaj7')], [(0, 3, 74), (3, 1, 72)]),
([(4, 'C')], [(0, 2, 67), (2, 2, 76)]), # the reach: a sixth
([(4, 'F/A')], [(0, 2, 77), (2, 1, 76), (3, 1, 72)]),
([(4, 'Gm7')], [(0, 2, 74), (2, 1, 70), (3, 1, 69)]),
([(2, 'Csus4'), (2, 'C')], [(0, 2, 67), (2, 1, 72), (3, 1, 76)]),
([(4, 'Dm')], [(0, 4, 74)]),
]
The notes are MIDI numbers: 60 is middle C, and each step of 1 is one semitone. So 69 is the A above middle C, and 74 is the D above that. You don't need to know any more theory than that to change it. Swap a 76 for a 77 and render it again, and you'll hear exactly what that one note does.
Underneath the tune runs what Helena calls the engine:
OST_CYCLE = [0, 1, 2, 3, 2, 1] # six notes against eight quavers: the accent drifts
That line takes the four notes of each chord and plays them up and back down, one note per half-beat, on the organ. The pattern is six notes long and a bar is eight half-beats long, so the pattern starts in a different place in every bar. The figure never quite repeats against the bar, and that slow drift is most of what makes it sound like machinery running in the distance rather than a loop. Some version of this line runs under every track in the suite.
3. The script
Save this as theme.py. It's long because it does the whole job: write the notes, check them, perform them, measure the result, and make the MP3. Every section is labelled, and the four checks that matter are explained after it.
"""Write a piece of music as data, check it, and render it to an MP3 with sampled instruments.
The tune is the opening of the Rift Haul house theme, by Calliope. Change the notes and it is yours.
Usage: python theme.py
Needs: sudo apt install fluidsynth ffmpeg musescore-general-soundfont
pip install pyfluidsynth numpy
"""
import random, re, subprocess, sys, wave
from pathlib import Path
import fluidsynth
import numpy as np
SOUNDFONT = "/usr/share/sounds/sf3/MuseScore_General_Full.sf3"
RATE, BPM, SEED = 44100, 72, 1972
TARGET_P95 = -14.0 # how loud the loud passages get, in LUFS: the level the Rift Haul suite is set to
BEAT = 60 / BPM
# ---- 1. THE INSTRUMENTS: channel, General MIDI program, level (0-127), and the notes a real player can reach
INSTRUMENTS = {
"piano": dict(ch=0, program=0, level=127, low=21, high=108, solo=False), # carries the tune
"organ": dict(ch=1, program=19, level=85 , low=36, high=96, solo=False), # the ostinato
"strings": dict(ch=2, program=48, level=85 , low=28, high=96, solo=False), # the pad
"cello": dict(ch=3, program=42, level=85 , low=36, high=76, solo=True), # one player, one note
}
LEAD = "piano"
# ---- 2. THE SCORE: chord per bar, and melody as (beat in bar, length in beats, MIDI note)
A_BARS = [
([(4, 'Dm')], [(0, 2, 69), (2, 1, 74), (3, 1, 76)]),
([(4, 'G/D')], [(0, 1.5, 77), (1.5, .5, 79), (2, 2, 76)]),
([(4, 'Bbmaj7')], [(0, 3, 74), (3, 1, 72)]),
([(4, 'C')], [(0, 2, 67), (2, 2, 76)]), # the reach: a sixth
([(4, 'F/A')], [(0, 2, 77), (2, 1, 76), (3, 1, 72)]),
([(4, 'Gm7')], [(0, 2, 74), (2, 1, 70), (3, 1, 69)]),
([(2, 'Csus4'), (2, 'C')], [(0, 2, 67), (2, 1, 72), (3, 1, 76)]),
([(4, 'Dm')], [(0, 4, 74)]),
]
# Each chord: four notes for the ostinato and the pad, and one bass note for the cello.
CHORDS = {
'Dm': ([50, 53, 57, 62], 38), 'G/D': ([50, 55, 59, 62], 38),
'Bbmaj7': ([46, 50, 53, 57], 46), 'C': ([48, 52, 55, 60], 36),
'F/A': ([45, 48, 53, 57], 45), 'Gm7': ([43, 50, 53, 58], 43),
'Csus4': ([48, 53, 55, 60], 36),
}
OST_CYCLE = [0, 1, 2, 3, 2, 1] # six notes against eight quavers: the accent drifts
INTRO_BARS = 2 # the ostinato and the bass alone, before the tune arrives
def compose():
"""Turn the score into notes: (start beat, length in beats, instrument, pitch, velocity)."""
bars = [[(4, 'Dm')]] * INTRO_BARS + [b[0] for b in A_BARS] + [[(4, 'Dm')]]
notes, beat, step = [], 0.0, 0
for n, chords in enumerate(bars):
bar_start = beat
for length, name in chords:
tones, bass = CHORDS[name]
notes.append((beat, length, "cello", bass, 70))
if n >= INTRO_BARS:
notes += [(beat, length, "strings", p + 12, 55) for p in tones[1:]]
for q in range(int(length * 2)): # quavers
notes.append((beat + q / 2, 0.5, "organ", tones[OST_CYCLE[step % 6]], 52))
step += 1
beat += length
if INTRO_BARS <= n < INTRO_BARS + len(A_BARS):
for at, length, pitch in A_BARS[n - INTRO_BARS][1]:
notes.append((bar_start + at, length, LEAD, pitch, 84))
return notes, beat
def gates(notes):
"""Refuse to render a score no real ensemble could play, or that FluidSynth would mangle."""
problems, sounding = [], {}
for start, length, inst, pitch, _ in sorted(notes):
spec = INSTRUMENTS[inst]
if not spec["low"] <= pitch <= spec["high"]:
problems.append(f"beat {start}: {inst} note {pitch} is outside its range {spec['low']}-{spec['high']}")
ringing = [(p, end) for p, end in sounding.get(inst, []) if end > start + 1e-9]
if any(p == pitch for p, _ in ringing):
problems.append(f"beat {start}: {inst} re-strikes {pitch} while it is still sounding")
if spec["solo"] and ringing:
problems.append(f"beat {start}: {inst} is one player but is asked for two notes at once")
sounding[inst] = ringing + [(pitch, start + length)]
return problems
def to_events(notes, only=None):
"""Notes to (sample, kind, channel, pitch, velocity) on ONE absolute clock, with seeded humanising."""
rng, events = random.Random(SEED), []
for start, length, inst, pitch, vel in notes:
jitter, vel = rng.uniform(-0.01, 0.01), max(1, min(127, vel + rng.randint(-3, 3)))
if only is not None and inst not in only:
continue # same random draws either way, so stems line up
ch = INSTRUMENTS[inst]["ch"]
on = max(0.0, start * BEAT + jitter)
events.append((round(on * RATE), 1, ch, pitch, vel))
events.append((round((on + length * BEAT * 0.95) * RATE), 0, ch, pitch, 0))
return sorted(events)
def render(events, total_beats, path):
fs = fluidsynth.Synth(gain=0.4, samplerate=float(RATE))
sfid = fs.sfload(SOUNDFONT)
for spec in INSTRUMENTS.values():
fs.program_select(spec["ch"], sfid, 0, spec["program"])
fs.cc(spec["ch"], 7, spec["level"]) # channel volume: the mixing desk
chunks, now = [], 0
for sample, kind, ch, pitch, vel in events:
if sample > now:
chunks.append(fs.get_samples(sample - now)); now = sample
fs.noteon(ch, pitch, vel) if kind else fs.noteoff(ch, pitch)
end = round((total_beats * BEAT + 4) * RATE) # four seconds of reverb tail
chunks.append(fs.get_samples(end - now))
fs.delete()
audio = np.concatenate(chunks).astype(np.int16)
clipped = int(np.sum(np.abs(audio.astype(np.int32)) >= 32767))
with wave.open(str(path), "wb") as w:
w.setnchannels(2); w.setsampwidth(2); w.setframerate(RATE); w.writeframes(audio.tobytes())
return audio, clipped
def loudness(path, start=None, end=None):
"""Integrated loudness (LUFS), true peak (dBTP) and P95 short-term loudness, from ffmpeg's EBU R128 meter."""
cut = ["-ss", str(start), "-to", str(end)] if start is not None else []
out = subprocess.run(["ffmpeg", "-nostats", *cut, "-i", str(path), "-af", "ebur128=peak=true",
"-f", "null", "-"], capture_output=True, text=True).stderr
summary = out[out.rfind("Summary:"):]
short_term = sorted(float(v) for v in re.findall(r"S:\s*(-?[\d.]+)", out[:out.rfind("Summary:")]) if float(v) > -70)
p95 = short_term[int(0.95 * (len(short_term) - 1))] if short_term else float("nan")
return (float(re.search(r"I:\s+(-?[\d.]+) LUFS", summary).group(1)),
float(re.search(r"Peak:\s+(-?[\d.]+) dBFS", summary).group(1)), p95)
def main():
notes, total = compose()
problems = gates(notes)
if problems:
print("REFUSING TO RENDER:", *problems, sep="\n "); sys.exit(1)
print(f"Score: {len(notes)} notes, {total:.0f} beats, gates clean")
out = Path("out"); out.mkdir(exist_ok=True)
mix, clipped = render(to_events(notes), total, out / "mix.wav")
if clipped:
print(f"REFUSING TO MASTER: {clipped} samples clipped inside the synth - lower the gain"); sys.exit(2)
lead, _ = render(to_events(notes, only={LEAD}), total, out / "lead.wav")
rest, _ = render(to_events(notes, only=set(INSTRUMENTS) - {LEAD}), total, out / "rest.wav")
# Null test: the stems must add back up to the mix, or they are not measuring the mix.
n = min(len(mix), len(lead), len(rest))
residue = mix[:n].astype(np.float64) - lead[:n] - rest[:n]
null_db = 20 * np.log10(np.sqrt(np.mean(mix[:n].astype(np.float64) ** 2)) / max(np.sqrt(np.mean(residue ** 2)), 1e-9))
if null_db < 40:
print(f"REFUSING TO MASTER: stems differ from the mix (null only {null_db:.0f} dB)"); sys.exit(4)
print(f"Null test: stems sum to the mix within {null_db:.0f} dB")
# Balance: is the tune audible over everything else, section by section?
worst = 0.0
for name, a, b in [("A, bars 1-4", 2, 6), ("A, bars 5-8", 6, 10)]:
t0, t1 = a * 4 * BEAT, b * 4 * BEAT
tune, others = loudness(out / "lead.wav", t0, t1)[0], loudness(out / "rest.wav", t0, t1)[0]
worst = max(worst, abs(tune - others))
print(f" {name}: tune {tune:.1f} LUFS, everything else {others:.1f} LUFS, gap {tune - others:+.1f}")
if worst > 3:
print("REFUSING TO MASTER: the tune is not within 3 dB of the accompaniment - adjust the levels"); sys.exit(3)
print("Balance: tune within 3 dB of the accompaniment")
# Master: ONE gain change, set by how loud the piece GETS (P95), not by its average or its peak.
# No compressor, no limiter. If that would push the peak over -1 dBTP, the peak wins.
lufs, peak, p95 = loudness(out / "mix.wav")
gain = min(TARGET_P95 - p95, -1.0 - peak)
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(out / "mix.wav"), "-af", f"volume={gain:.2f}dB",
"-b:a", "256k", str(out / "theme.mp3")], check=True)
lufs, peak, p95 = loudness(out / "theme.mp3")
print(f"theme.mp3: P95 {p95:.1f} LUFS, integrated {lufs:.1f} LUFS, true peak {peak:.1f} dBTP, gain {gain:+.1f} dB")
if __name__ == "__main__":
main()
The four ideas that make it trustworthy:
- Checks on the score, before a note is played. These are the
gates()function. A synthesiser will happily play a cello note lower than any cello can reach, or ask one cellist to play two notes at once. Each instrument has a range written into its definition, and the score is refused if anything falls outside it. The same function catches the most confusing fault in FluidSynth, which gets its own section below. - One clock, and seeded imperfection. Every note-on and note-off is placed on a single absolute sample count. Small human-sized wobbles in timing (up to 10 milliseconds) and loudness come from a random number generator with a fixed seed. So the music doesn't sound mechanical, but every render of the same score is byte-for-byte identical. I rendered it twice and got the same checksum both times.
- It renders the tune and the accompaniment separately, and measures them. This is the check that matters most. The script renders the whole piece, then the tune alone, then everything else alone. It confirms that the two parts add back up to the whole (the "null test"), then measures each, section by section, in LUFS, the standard broadcast unit of loudness. If the tune isn't within 3 decibels of the accompaniment, it refuses to make the MP3.
- One volume change at the end, set by how loud the music gets. There's no compressor and no limiter. The level is set by the 95th percentile of the music's short-term loudness, which is roughly how loud its loud passages are, rather than by its average. There's a whole section on why below. It's the single most useful mastering idea in this guide.
4. Run it, and let it tell you what's wrong
~/compose/bin/python theme.py
With every instrument at full level, this is what comes back:
Score: 150 notes, 44 beats, gates clean Null test: stems sum to the mix within 60 dB A, bars 1-4: tune -32.2 LUFS, everything else -23.7 LUFS, gap -8.5 A, bars 5-8: tune -32.3 LUFS, everything else -24.0 LUFS, gap -8.3 REFUSING TO MASTER: the tune is not within 3 dB of the accompaniment - adjust the levels
The score was fine and the render was fine. But the tune was eight and a half decibels under the accompaniment, which means you'd have heard an organ and some strings and struggled to find the melody. My own first run hit exactly this, and so did Helena on the real theme. Her first take had the organ pedal as the loudest thing in the piece and the melody 9 to 12 dB underneath it. The overall loudness looked perfectly normal. Only measuring the parts separately shows it.
5. Fix the balance, and listen
Each instrument has a level, from 0 to 127, which works like a fader on a mixing desk. MIDI volume works on a squared curve, so 85 is about 7 dB below 127. I set the organ, strings and cello to 85, left the piano at full, and ran it again:
Score: 150 notes, 44 beats, gates clean Null test: stems sum to the mix within 55 dB A, bars 1-4: tune -32.2 LUFS, everything else -30.7 LUFS, gap -1.5 A, bars 5-8: tune -32.3 LUFS, everything else -31.0 LUFS, gap -1.3 Balance: tune within 3 dB of the accompaniment theme.mp3: P95 -14.0 LUFS, integrated -15.6 LUFS, true peak -3.0 dBTP, gain +14.3 dB
That took 16 seconds on the Pi. Most of it is ffmpeg measuring. The first load of the 86MB instrument file takes 2.6 seconds, and the performance itself, 41 seconds of music, renders in under one. This is the file it made, unedited:
Now change it. Change the notes in A_BARS, the chords in CHORDS, the BPM, or the instruments. The program numbers are the standard General MIDI list, so 0 is piano, 19 church organ, 42 cello, 46 harp and 48 strings, and any General MIDI chart online gives you the other 123. Every change takes fifteen seconds to hear.
From one theme to a whole soundtrack
The script above is one idea played once. Here is how Helena turned that into seven pieces that belong together. It's the most transferable part of the guide, and it's an eight-point method.
- One house theme, written and heard alone, first. Before any suite existed there was a ninety-second sketch of one tune. As Helena put it: if the tune is wrong, six tracks of it are wrong. Every track then carries that theme, but as the theme in a new setting, plus one or two sections of genuinely new material, so the suite develops rather than repeats itself.
- One identity per track. Each piece has its own key, its own tempo, its own lead instrument, and one signature device: the clock in Long Haul, the cello duet in Dead Reckoning, the blinking glockenspiel in Station Lights. One idea per track is enough, and more than one muddies it.
- The engine recurs, but on a different instrument each time. The six-against-eight figure is on the organ in the theme, the piano's left hand in Dead Reckoning and the harp in Station Lights. In Drydock it's split note by note between two instruments. Hearing the same figure tells you it's the same world, and hearing it in a new place tells you you've moved.
- A key plan. The house key is D dorian, a minor mode with one brighter note, which turns melancholy into something closer to longing. Each track moves to a different dorian key, and the last one, Far Shore, is the only one in a major key, because it is the arrival.
- Ask the ear about instruments, blind. I scored the first sketch 8.5 out of 10, and my one doubt was the piano carrying the tune. So Helena rendered the same sketch five times, changing only the instrument playing the tune. Everything else was identical, down to the random seed, and the tunes were level-matched. She shuffled the five, hid which was which, and included the original as a hidden control. I named the MuseScore piano as the winner, and rated the remaining three all better than the original. Those three became the lead colours of later tracks: the solo organ for The Rift, the soft piano for Dead Reckoning and the celesta for Far Shore.
- Quiet means sparse, not static. Every quiet passage still changes chord every bar and has a real line moving through it. Ambient music that stops moving stops being listened to.
- One swell per track, sized against that track's own body: about 4 loudness units above its average, and 5.6 for The Rift, which is the big one.
- One shared palette. The same two instrument libraries, the same reverb settings and the same playing habits in every track, so the seven sound like one recording session.
This is the A/B in miniature: the original sketch next to the winner. The same notes, the same performance, the same seed, and only the piano changed:
And two more from the finished suite. Drydock is the fastest and busiest, and The Rift has the one big climax:
Putting it in a game
They don't loop, on purpose. The usual advice for game music is seamless loops. But a seamless loop can't build to anything or arrive anywhere, because its end has to be identical to its start. So the brief's "five or six tracks in a player" became a suite of pieces with real endings, and the player provides the endlessness. If you do need a seamless loop for something else, Helena's trick is to compose it to end on a chord that leads back to the start, render three passes, and keep the middle one.
The player (her specification, built into the game by Geordie, the agent who runs Rift Haul):
- shuffle the tracks like a bag, so each plays once before any repeats, and never the same one twice in a row
- leave three to eight seconds of silence between tracks, with no crossfade, because the endings are written
- stop the music for combat and the market, with a 1.5-second fade
- resume where it left off if the interruption was under a minute; otherwise move to the next track
- start playing on the player's first click or keypress, because browsers block audio until then
- open every session with the house theme
- let the player mute it or skip to another track whenever they like; it's their ship
Format: MP3 at 256 kbps. Ogg is smaller but doesn't play reliably in Safari, and MP3 plays everywhere.
Level them by how loud they get, not by their average. This is the most counter-intuitive idea in the guide, and it's right. The normal move is to make every track the same average loudness. But a piece with long quiet stretches and one big climax has a low average, so bringing its average up to match the others pushes its climax far above everything else in the playlist. Helena set every track so its loud passages (the 95th percentile of short-term loudness) landed in the same place, and let the averages fall where they fell. The suite's averages range from −16.8 to −18.5 LUFS, and no limiter was used anywhere. The files ship with a note telling the game to play every one at the same volume and never to normalise them in code. The script in this guide masters to the same target, so its output sits at the same level as the tracks above.
The things that will catch you out
All of these happened during the Rift Haul afternoon. Not one of them reached my ears, because each was caught by a check or a measurement first. I'd guess you'll meet most of them in your first week.
- The tune you didn't write. The first draft of the theme's second bar ended A-D-E-F-E-D. That is the tail of Scarborough Fair, in the same mode. It's public domain, so there was no legal problem, but a house theme that reminds you of a folk song isn't a house theme. Helena caught it reading the score back before anything rendered. If you're steering clear of one named source, check the folk and public-domain tunes too, because the tune you invented may be three hundred years old.
- The buried tune. You've already met this one in step 4. The overall loudness looks fine while the melody is lost underneath. Measure the tune and the accompaniment separately, in LUFS rather than plain RMS level. RMS gives the bass too much weight and overstated the organ pedal.
- The sour piano. The FluidR3 piano runs between 5 and 19 cents sharp (a cent is a hundredth of a semitone), worst on exactly the theme's long notes. The organ doubling it was nearly in tune, so together they sounded sour. Helena measured every exposed instrument's tuning note by note and bent each following instrument to match the lead. The piano itself is never corrected. I'd turned down an "equal-temperament corrected" piano by ear in August, and the MuseScore piano that won the A/B measured within 1 to 3 cents anyway.
- The documentation that was out by a factor of two. pyfluidsynth's own documentation says 2,048 units of pitch bend make a semitone. Measured, it is 4,096. Work from the documentation and every tuning correction lands at half strength. If a control matters, measure what it does.
- The same-key trap. This is the confusing one. In FluidSynth, a note-off on a channel silences whatever is sounding at that pitch on that channel. So if a note is struck again before the earlier note's off arrives, the old off kills the new note. You hear a passage that sounds hollow, and it measures far quieter than it was written. It turned up three ways: a bass sharing a pitch with the repeating figure on one channel, repeated notes that overlapped slightly, and a ringing harp at chord changes. The fix is to put parts that can collide on their own channels, and to have a check that counts these overlaps and must read zero. That's the "re-strikes" check in
gates(). - Stems that drift apart. Helena's first renderer worked out each gap between events and rounded it to whole samples. In a single render everything drifts together, by 7 milliseconds over 100 seconds, which is harmless. But the separately rendered tune and accompaniment drifted 5.8 ms apart, and the null test against the mix failed from ten seconds in. The fix is the absolute clock in the script above: every event's position is calculated from the start, never from the previous event.
- Instruments that shout, and instruments that whisper. At normal settings, MuseScore's solo cello and French horn measured 8 to 16 loudness units over the accompaniment, and its vibraphone and glockenspiel were effectively silent. There is no safe default level for "a solo instrument". Measure each new instrument on its own before you trust it in a mix. As a starting point for MuseScore's solo voices, Helena's expression setting is around 50, never 70.
- The quiet bit that wasn't. Far Shore's dawn section, written as the hush before the arrival, measured louder than the arrival itself, because the piano's climb ran straight through it. An arrival needs the quiet before it. Check the shape, meaning the loudest moment in each section, not just the totals.
The check that catches them
The per-step output above tells you that step worked. These checks tell you the music is right, and every one of them can fail. Helena ran all of them on every track before I heard anything.
By machine, in this order:
- The score is playable: every note is inside its instrument's range, no instrument re-strikes a sounding pitch, and no single player plays two notes at once. I added a cello note below the cello, an overlapping piano note and a cello chord to the script's score, and the gates named all three.
- Nothing clipped inside the synthesiser. The script counts samples at full scale and refuses to master if there are any. It doesn't hide the problem by turning the whole thing down.
- The stems null against the mix by more than 40 dB. Mine read 55; Helena's typically read 60 or more. If they don't, your balance figures describe something other than what you'll hear.
- The tune is within about 3 dB of everything else, in every section.
- Every exposed instrument is in tune with the lead, to within about 2 cents after correction.
- The shape is right: the swell lands where it was written, and the quiet passages are actually quieter.
- The delivered file, not the working file, is measured: the loud-passage level matches the rest of the suite, the true peak is under −1 dBTP, and a checksum is recorded so you know which file you approved.
By ear:
- One person who didn't write it listens to each track, and nothing known to be broken is in front of them when they do.
- Leave it running. This is the real test for music that plays behind a game. Of Dead Reckoning I said I would happily have it on repeat for an hour, and tonight I've had the whole playlist going in the game for three or four hours. Background music that survives an evening has passed. Music that only survives one careful listen hasn't.
What it costs
Money: nothing. FluidSynth, pyfluidsynth, numpy and ffmpeg are free. The two instrument libraries are MIT-licensed, with parts in the public domain or CC0. MuseScore's grand piano is public domain, and its ensemble strings are from the VSCO 2 Community Edition, CC0. Helena's own time came out of the Claude subscription I already pay for. No API was called, nothing ran in the cloud, and nothing was bought.
Time:
- The brief arrived at 11:28 and I heard the first sketch at 11:43.
- The first five tracks were approved by 12:50, roughly ten minutes apart.
- The last two waited for Geordie's lore, so that their subtitles could name real places in the game: Tau Ceti Orbital and Deneb Freeport.
- All seven were delivered at 13:59.
Almost all of that time went on writing scores and fixing what the measurements found. Rendering is close to free: Dead Reckoning, 3 minutes 18 seconds of music, rendered in 5.6 seconds on the Pi, about 35 times faster than real time, including loading the instruments.
The other real cost is the listener's attention, and that's the one worth protecting. Every take I heard had already passed every check. I never once had to listen to something broken.
Getting an assistant to do this for you
This is how Rift Haul's music was made, so it's less a tip than a description. I've no reason to think this needs Claude in particular. What it needs is the division of labour.
The composer can't hear, and that's the method, not a weakness. Helena has no ears. Her "listening" is entirely measurement: is the tune audible, is the swell the right size and in the right place, is anything out of tune, did anything clip, is any note impossible. Whether the result is beautiful was my call, every time. So the measurements guarantee that nothing broken reaches a human, and the human decides whether it's music. Set your own project up the same way, whoever or whatever is writing the notes.
Give it the checks, not just the request. An assistant will cheerfully write you a cello note below the cello. What made Rift Haul work wasn't the wording of my brief, which was one paragraph, but the gates and measurements standing between the score and my ears. Ask for those first: range checks, the balance measurement and the null test. Then ask for music.
Use the blind A/B for any creative choice you're unsure about. Change one thing, keep everything else identical including the random seed, hide which is which, and put the original in as a control. It works for instruments, and it would work just as well for fonts, voices or cover designs.
Name your reference, and say what's off-limits. "Inspired by Interstellar" plus "it's commercial, licensing matters" was enough for Helena to draw the line herself: the method, never the notes. Say both halves.
Be the ear, and be specific. "Piano's the weak link" was the most useful thing I said all day. It cost me three words and produced the blind test that chose the instrument and, along the way, the colours of three later tracks.
Worth it?
For me it isn't one moment. I've been playing Rift Haul itself, in the middle of beta testing, with that playlist running inside the game, where you can mute it or change track like any other setting. What it does is add atmosphere and depth: a feeling of being in your ship, out in space. That is the job, and it does it all evening.
I'd say it's the best thing in this whole collection.
Twenty-one and a half minutes of original music, in one coherent style, built on one theme, for a commercial game, in one afternoon, for nothing. Every note is owned outright, there's no licence in the chain I'd need a lawyer to read, and the "recording" is a Python file. If Geordie wants the clock in Long Haul a beat slower next month, that's one number and five seconds of rendering, and the performance is otherwise identical. If he wants an eighth track for a new region, the house theme, the engine, the key plan and the palette are all there waiting for it.
The script at the top of this guide is the same machinery, cut down to forty-one seconds you can play with tonight. Change the tune and it's yours. Then write a second piece around the same tune in a different key, with one new idea in it, and you'll have started a soundtrack.