Composing Original Music in Python: Thirty Tracks, One Motif
No samples and no DAW: plucked strings from Karplus-Strong, a reverb made of noise, loudness measured the broadcast way (ITU-R BS.1770), and seamless loops.
Why synthesize? #
Music is the asset a small team most often licenses. We didn't, for the same reasons as everything else in this series: the game's content is built from code, so it stays original, consistent and changeable. Every track in Dekiro is composed as data and rendered by a Python script with numpy and scipy into a looping Ogg file. The first set had eleven: one per map, a calmer variation for the Vale's town, and one for the Crypt dungeon.
Every one of the 20 dungeons has since got its own track too, so the game now ships 30, all made the same way.
The music was also the first job given to a parallel subagent, a separate Claude session working in its own lane while the main session coordinated.1 See the coordinator post, number 11.
A song is data #
A track is a Python dictionary: tempo, reverb length, a seed, chord voicings and a list of sections. Each section is a chord progression (one chord per bar) plus the parts that play over it. Here is the opening of the Sanctuary Vale theme:
"sanctuary_vale": {
"bpm": 76, "reverb": 2.6, "seed": 11, "chords": CHORDS,
"sections": [
("Dm Bb F C", [("pad", None, 1.0), ("drone", None, .8), ("arp", "eighths", .9), ("bass", "whole", .45), ("koto", INTRO_HINT, .9)]),
(PROG_A, [("pad", None, .6), ("arp", "eighths", .5), ("bass", "roots", .6), ("koto", MOTIF_A, 1.0)]),
(PROG_A, [("pad", None, .6), ("arp", "eighths", .5), ("bass", "roots", .7), ("koto", MOTIF_A, 1.0),
("flute", ANSWER_A, .8), ("drums", "full", .8)]),
Melodies are written as text, NOTE:beats[:velocity], with r for rests and | for bar lines:
MOTIF_A = """
r:1 A4:.5 C5:.5 D5:1.5 F5:.5 | D5:3 r:.5 C5:.5 | A4:1 C5:.5 A4:.5 G4:1 F4:1 | G4:3 r:1 |
r:1 A4:.5 C5:.5 D5:1.5 G5:.5 | F5:2 D5:1 F5:.5 G5:.5 | A5:1.5 G5:.5 F5:1 D5:1 | D5:1 A4:3
"""
The parser checks that every bar adds up to four beats and fails with the offending bar if it doesn't. It sounds pedantic, but it's the musical version of a type check, and it catches the most common mistake when writing melodies as text.
The engine (music_lib.py) and the songs are separate. The library holds the instruments, the
arrangement engine and the mastering. Each map's track lives in its own module in
tools/audio/music_tracks/. It didn't start that way. The Vale theme came first as a single script, and
when nine more tracks were needed, it was split into a shared library and one data module per map. After
that, a new track means new data for the same engine.
The instruments #
Every instrument is a function that returns a numpy array. A few highlights:
-
Koto (and the oud in the desert) is a Karplus-Strong plucked string: a burst of noise fed back through a delay line one period long, with a filter that keeps the tuning exact. The docstring:
def pluck(f, ring, vel, rng, bright=1.0, vibrato=0.0, pos_frac=0.13): """Karplus-Strong string: block-wise feedback (blocks of one period) so numpy does the work. The loop filter is the classic two-point average convolved with a linear fractional delay, which keeps the tuning exact. vibrato > 0 adds a guzheng-like left-hand wobble after the attack."""The trick in "block-wise" is that the feedback loop runs one period at a time instead of one sample at a time, so numpy can vectorize it.
-
Bells and glass are FM synthesis: a carrier with a modulator at a non-integer ratio, which gives the inharmonic shimmer of metal.
-
Flute, shakuhachi and ney are additive tones with a scooped onset, delayed vibrato and band-passed breath noise.
-
Choir is detuned saw waves with vibrato, sent through vowel formant filters.
-
Percussion (taiko, frame drum, rim, finger cymbals, anvil, heartbeat) is shaped noise and pitched sweeps.
Notes are slightly humanized in timing and velocity, and the seed makes every render repeatable, so a rerun gives the same music unless the data changes.
One motif, eleven moods #
To tie the maps together, every track quotes the Vale's opening pickup, the three notes A C D.2 Scale degrees 5, flat 7 and 1 in D minor. Each map transposes and bends it into its own scale, as the track headers record:
| Map | Mode, tempo | How it quotes the motif |
|---|---|---|
| Sanctuary Vale | D minor pentatonic, 76 BPM | The source: koto, then bamboo flute |
| Shadow Marsh | E "in" scale, 58 BPM | B D E opens the koto |
| Crypt of Whispers | A minor + phrygian Bb, 72 BPM | Lone koto plinks "half-remember" E G A |
| Orc Valley | A minor pentatonic, 92 BPM | Every horn call opens with E G A |
| Duskmoor | C minor + phrygian Db, 66 BPM | The koto opens with G Bb C |
| Sunscar Desert | D hijaz, 84 BPM | The ney's opening line borrows A C D |
| Frostpeak | B minor pentatonic, 70 BPM | The flute opens with F# A B |
| Ember Wastes | D phrygian, 80 BPM | A C D on horn, "dragged down a step into Eb" |
| Hellmouth Rift | C phrygian dominant, 96 BPM | G Bb C in the choir, "bent into Db" |
| Shadowlands | sliding away from D minor, 54 BPM | The Vale theme itself on a detuned, sagging piano |
The Shadowlands track is the payoff. It's the place the Shadows come from, and its music is the Vale's own theme slowed down and "heard through the Shadows", over chords that slide away from D minor. Players who have heard the Vale theme for 80 levels should recognize it, and feel that something is wrong with it.
Twenty more, one per dungeon #
When the dungeons arrived, each one got a track written for its mechanic, by the same engine and the same text notation. A few examples, from the track headers:
- Bog Witch's Hollow: "a crooked nursery tune gone off in the damp", a detuned music-box piano in D phrygian with a raised third now and then ("the witch's wrong note"), drips, glass plinks and a heartbeat drum stirring the pot.
- The Demon Tower: a stair motif in the low horns "that climbs a step every time it comes back", because the dungeon is nine floors that each get worse.
- The Hall of Mirrors: glass phrases answered by their own retrograde, the music of a dungeon made of two mirrored mazes.
- The Shadow Citadel, the final raid: "the Shadowlands' twisted Vale motif grown into a march", with temple bells tolling four times, one per school. A detuned piano states the motif bent out of the scale; then a horn answers with it straight, "the four disciples' version".
So the motif from the first village still closes the story, more than eighty levels after you first heard it in its home key.
Mixing: reverb from noise, loops without seams #
There is no plugin chain. The reverb is a synthetic stereo impulse response: decorrelated noise for
each channel, with the highs decaying faster than the lows, and a 22 ms pre-delay. The whole wet signal is
convolved with it in one oaconvolve call.
Game music loops, and a loop that clicks or drops its reverb tail at the seam is worse than no loop. The trick is in how the render ends. Everything is rendered past the loop end (16 seconds of notes and reverb tail) and then wrapped back onto the start:
n_loop = int(round(length * RATE))
out = np.zeros((n_loop, 2))
pos = (np.arange(len(full)) - PRE) % n_loop
for ch in range(2):
out[:, ch] = np.bincount(pos, weights=full[:, ch], minlength=n_loop)
The tail of the last chord rings into the first bar, just as it would if the piece had really been played
twice. Every later processing step is circular for the same reason: the high-pass filter, the section
loudness rider, and the limiter's gain smoothing all use mode="wrap", so nothing happens at the seam that
doesn't happen everywhere else.
Mastering to a standard #
Each track is mastered to -15 LUFS integrated loudness, measured by a small implementation of ITU-R BS.1770 (K-weighting, 400 ms blocks, gating) right in the library. A limiter holds sample peaks at -1.5 dBFS. Then comes a step many people skip. Vorbis encoding can overshoot on sharp plucks, so the script decodes the Ogg it just wrote, measures the true peak with 4x oversampling, and trims and re-encodes until it's at or below -1 dBFS:
# Vorbis overshoots on sharp plucks: decode, measure the true peak, trim and re-encode until <= -1 dBFS
for _ in range(4):
write_ogg(path, audio)
over = true_peak_db(sf.read(path)[0]) - TRUE_PEAK_DB
if over <= 0:
break
audio = audio * 10 ** (-(over + 0.1) / 20)
Each render prints a report line with length, loudness, true peak and file size, so a check is one glance.3 One honest note: libsndfile's Vorbis encoder crashed on one huge write, so write_ogg feeds it a second at
a time.
In the game #
MusicPlayer plays the track of the zone you stand in, or else the map's, with a 1.5 s crossfade between
two players. The Vale's town has its own calmer variation (62 BPM, temple bells, quarter-note arps). Walking
out of the gate crossfades into the field theme. A map can name its music before the file exists; the
player stays silent until it's there.
Sound effects: from placeholder tones to a synthesizer #
When this series was first drafted, sound effects were far behind the music. data/sounds.json mapped
every event (swing, hit, skill by tier, rare drop, level up, UI click) to real files when they existed,
and to a placeholder tone generated in code when they didn't:
"rare_drop": { "files": [], "bus": "SFX", "pitch_jitter": 0.0, "synth": { "wave": "sine", "from": 880, "to": 1760, "length": 0.45, "volume": 0.4 } },
Only the Shadow stone breaking had a designed sound ("a sharp crack, a heavy low thud, then crumbling debris that settles, with a little room echo"). The placeholder system meant the game had sound everywhere from day one, and real sounds could slot in by adding a file path.
They did, a few hours later, from the same approach as the music. tools/audio/sfx.py synthesizes every
effect from noise, oscillators and filters, with "an East Asian touch (wooden clacks for the interface,
gongs and temple bells for milestones, plucked silk strings)". The plucks reuse the music library's
Karplus-Strong string. Frequent sounds get several variations, which the game picks at random (never the
same one twice in a row) and jitters in pitch. Loudness is set per category from the loudest 100 ms of
each sound, and peaks are limited to -1 dBFS. Footsteps change with the ground, fists sound different
from swords. At the time of writing, 62 of the 63 sound events have synthesized files (126 WAV files in
all); the one still on a placeholder tone is waystone travel.
Like the music, the script can render a "listening sheet": a waveform and spectrogram per category plus a montage WAV, so the effects can be reviewed at a glance before anyone listens to them in the game. The human listens and decides; the numbers just make sure nothing clips or drowns out the rest.
Takeaways #
- Treat songs as data. A small arrangement engine plus text melodies lets you write, review and diff music like code. Bar-length checks catch typos.
- Make loops circular end to end. Render past the end, wrap the tail onto the start, and keep every later filter circular.
- Measure loudness and true peak after encoding. The encoder can undo your limiter.
- A shared motif ties a world together for almost no cost, and it lets you tell a story with the last map's music.
What's next #
The next post is about a calmer side of the game: fishing, a Stardew-style minigame inside a server-authoritative ARPG.