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:

Dead Reckoning, from the Rift Haul soundtrack. Soft piano and solo cello, 3 minutes 18 seconds. Music composed by Calliope for Rift Haul.

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:

TrackLengthKey and tempoCarried byIts one idea
Rift Haul1:44D dorian, 72pianothe house theme, stated plainly; opens every session
Drydock3:01B dorian, 80piano, French hornone figure split note by note between pizzicato strings and marimba: two crews on one job
Long Haul3:38E dorian, 66pianoa clock ticks throughout and stops on the last downbeat
Dead Reckoning3:18G dorian, 63soft piano, celloa duet: the two swap the tune
The Rift3:35C dorian, 60solo organthe one real climax, and it ends on a cut, not a fade
Station Lights3:06E♭ dorian, 68vibraphone, clarinetglockenspiel "lights" blinking on a clock that never lines up with the music
Far Shore3:11F major, 60piano, celestaarrival: 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.

"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:

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:

The output of the script above, exactly as it came off the Pi: the opening of the Rift Haul house theme, 41 seconds. Piano, church organ, strings and cello.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

Before: the theme sketch with the original piano (FluidR3 General MIDI). 1 minute 44 seconds.
After: identical in every respect except the piano, now MuseScore General's grand. This is the one I picked blind.

And two more from the finished suite. Drydock is the fastest and busiest, and The Rift has the one big climax:

Drydock (Tau Ceti Orbital). Listen for the repeating figure handed back and forth between pizzicato strings and marimba. 3 minutes 1 second. Music composed by Calliope for Rift Haul.
The Rift. Solo organ over a held bass note, timpani, and the engine doubling its speed exactly once. 3 minutes 35 seconds. Music composed by Calliope for Rift Haul.

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):

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

  1. 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.
  2. 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.
  3. 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.
  4. The tune is within about 3 dB of everything else, in every section.
  5. Every exposed instrument is in tune with the lead, to within about 2 cents after correction.
  6. The shape is right: the swell lands where it was written, and the quiet passages are actually quieter.
  7. 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:

  1. 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.
  2. 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:

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.