The Headless Studio
Chapter 1 — The Case for a Headless Studio
Take 47 is a diff
Here is a complete, honest description of how I changed the guitar sound on one of my songs last month:
-GUITAR_AMP = AMP_TWIN
+GUITAR_AMP = AMP_AC15
One line of code. I changed which amplifier the guitar plays through — from a Fender Twin to a Vox AC15 — the way you’d change any setting in any program: edit the variable, run it again. Ninety seconds later I had the same song, same performance, every note identical, through a different amp.
Not a performance like the last one. The same performance. When I couldn’t decide between the two amps, I generated both versions and listened blind. When I wondered whether the chorus effect belonged before the amp or after it, that was a one-line change too, and the answer took four minutes to settle beyond argument.
If you’ve ever used recording software — GarageBand, Ableton, Logic, any of the programs the industry calls DAWs (Digital Audio Workstations) — you know this is not how it normally goes. Changing your mind after the fact means clicking through the project, re-doing things by hand, and hoping you remember what else you touched. Your experiments live in the undo history and nowhere else.
My whole studio is a set of Python scripts. A song, for me, is a program
whose output is a finished audio file. This book is about how that studio
works and how to build your own — and you don’t need to be a Python expert
to follow along. If you can read a for loop and a function definition in
any language, you’re equipped; everything Python-specific gets explained
the first time it appears.
But first I owe you an argument for why anyone would do this.
What a DAW actually is
Strip the interface off any music production program and you find the same four machines inside:
- A scheduler — a list of what should happen when: play this note at this moment, trigger this drum sample at that moment.
- A plugin host — the part that loads instruments and effects (plugins) and pushes audio through them.
- A mixer — the part that combines many instrument tracks into one, each at its own volume.
- A renderer — the part that runs everything and writes the final audio file.
Everything else — the timeline you scroll, the piano roll, the faders — is user interface on top of those four functions. And the interface is the whole product: it’s what lets a musician who will never write a loop make records.
But here’s the thing about those four machines. A scheduler is a sorted list. A mixer is addition. A renderer is a loop. And the plugin host — the one genuinely hard piece of engineering — turns out to be something you can install with one command:
from pedalboard import load_plugin
amp = load_plugin("NeuralAmpModeler.vst3") # a real guitar amp plugin
produced = amp(guitar_recording, 44100) # audio in, amped audio out
That’s a real, professional-grade amplifier simulator — the same plugin
working guitarists use in Logic — loaded and running in two lines of
Python, no window anywhere. (The 44100 is the sample rate, which we’ll
properly meet in the next chapter; for now, it just means “CD-quality
audio.”)
Once I understood that plugins — the crown jewels of forty years of audio software — could be driven from a script, the rest of the DAW stopped looking necessary. What remained was a question: if the studio were a program, what would that buy you?
Buy-in #1: the performance that never has a bad night
Run one of my song scripts twice, and the two output files are identical. Not “sounds the same” — byte-for-byte identical. Programmers call this determinism: same input, same output, every time.
That sounds like a party trick until you see what it enables. Every choice in the song — every note, every volume level, every effect setting — is a line of code. So every experiment becomes a controlled experiment. When I compare the AC15 version against the Twin version, I know with complete certainty that the amp is the only difference. The drummer didn’t rush one take; there are no takes.
Even the “human” imperfections are deterministic. My bass and guitar parts get small random timing and volume variations — real hands drift, and music with zero drift sounds robotic. But the randomness comes from a random number generator with a fixed seed (a starting value that makes the “random” sequence repeatable). Take 47’s tiny stumble is identical to take 1’s, unless I choose to change one number and roll a new performance.
Determinism also makes curiosity cheap. Does the synth pad even earn its place in this song? Multiply the pad’s volume by zero, render, listen. Ninety seconds. When experiments cost that little, you stop settling questions by fatigue and start settling them by listening.
Buy-in #2: the studio that remembers why
Because a song is a text file, a song’s history is a version-control history. Mine reads like a producer’s notebook, except every entry is executable:
bass: added a distorted layer under the clean one, quiet — the clean
bass alone got lost under the drums
drums: swapped LinnDrum for MFB-512 after comparing all eight machines
chorus: lead guitar enters 8 bars later; doubled the quiet section
Every one of those is a decision I can revisit, reverse, or branch from. What did the song sound like before I doubled the quiet section? Check out the old version and render it. What if the whole album used the smaller-sounding room? That’s a branch, and the experiment runs across twelve songs while I make dinner.
Ask what the equivalent is in a DAW: project files named
song_final_v3_REAL_final, and the reason the bass changed in March
existing only in your head. I’m not claiming version control makes better
music. I’m claiming memory does — and code is the only studio medium
that remembers everything, including intent.
Buy-in #3: the for-loop is a producer’s superpower
The moment that converted me wasn’t about sound quality at all. I needed to pick a drum machine for a song. I had drum sounds from eight real machines — the famous LinnDrum, the Roland TR-808, six others. The DAW way to choose is to swap sounds in by hand, audition two or three, get tired, settle. The script way is a loop: render the song’s actual beat, through the song’s actual effects, once per machine. Eight audio files, one blind listen, one clear winner.
And the winner was not the machine the genre’s history said it should be. It was better. I’d never have found it by hand, because by option four I’d have stopped looking.
That’s the general shape of the leverage: anything your script expresses as a setting — the amp, the drum kit, the tempo, the arrangement — becomes something you can try every version of, for the cost of a loop. A DAW asks: is this good? A script can ask: is this the best of everything I have?
And it compounds. The band I built for one song — the guitar rig, the human-timing engine, the shared room reverb — played the next song the day I wrote its chords. There’s a twelve-track album on my drive that exists because the cost of one more song had fallen to: describe it, listen, revise.
What this costs you
Now the honest part, because the DAW’s interface is not a weakness — it’s the entire point of a DAW, and giving it up costs real things.
You lose immediacy. There’s no grabbing a knob while the music plays. My creative loop is edit → render → listen, and even at ninety seconds, that’s a different rhythm than turning a physical dial. Some ideas die in those ninety seconds. (Some survive because of them — being forced to listen to the whole section, rather than looping two bars forever, catches problems the DAW workflow hides. But the trade isn’t free.)
You lose recording. This book will not help you record a singer or mic a guitar cabinet. A code studio is an arrangement and production instrument: it plays sampled, synthesized, and simulated instruments with programmed performances. If your music is built on live takes, this pipeline can still be your sketchpad and your mixing back end — there’s a whole chapter on handing work between the script world and the DAW world — but the recording booth stays human.
You take on plumbing. Plugins built for the wrong kind of processor. Sample libraries in formats nobody documented. Libraries that won’t load for reasons that take an afternoon to diagnose. Several chapters of this book exist precisely because I lost those afternoons — you’re buying the afternoons back, but you should enjoy that kind of problem-solving to live here.
Who finds the trade worth it? Developers who make music and always felt the DAW wasn’t quite their instrument. Producers drowning in repetitive rendering work a loop should be doing. Tinkerers who want to know what the magic boxes actually do. And anyone building software that needs to produce music without a human at the controls.
The shape of the machine
Here is the entire studio this book builds, as one picture:
the song, as data sections, chords, patterns — plain lists
│
note events one list of notes per band member
│
instruments samplers, synths, sampled drums — one track each
│
effects each member's amp/pedal chain (real plugins)
│
the mix volumes, plus one shared "room" reverb
│
the record final file + per-instrument files for remixing
Every arrow is a plain function; every box is a chapter. Part I builds the instruments. Part II assembles the band. Part III makes an album with it and ships it.
One warning before we start: the first time you run a script and a finished song comes out — mixed, warm, breathing — the DAW in your dock starts to look strange, like a fax machine after email. I take no responsibility for what happens to your old projects after that.
Let’s build the instruments.
Next — Chapter 2: A Synthesizer You Can Script.
Chapter 2 — A Synthesizer You Can Script
Our studio’s first instrument needs to cover a lot of ground cheaply, because everything else in the book builds on the patterns we establish here. That instrument is FluidSynth: a free, open-source synthesizer that plays SoundFont files — portable sample libraries containing entire banks of instruments — and renders audio much faster than real time. By the end of this chapter you’ll have the studio’s most important function: give it a list of notes, get back finished audio, with a different instrument on every channel.
Thirty seconds on how digital audio works
Since we’ll be handling audio directly, let’s define the material once.
Digital audio is just a very long list of numbers. Each number — a sample — records where the speaker cone should be at one instant. CD-quality audio uses 44,100 samples per second (the sample rate, written as 44.1 kHz). Stereo audio is two such lists, left and right.
So three seconds of stereo music is 2 × 132,300 numbers. In Python we hold
those numbers in a NumPy array — think of it as a list that supports
math on every element at once. audio * 0.5 halves the volume of a million
samples in one go, without writing a loop. That one idea — audio is an
array, and array math is audio processing — powers the entire book. The
convention we’ll use everywhere: samples are decimal numbers between −1.0
and +1.0, and a stereo track is an array with 2 rows (left, right) and one
column per sample.
What a SoundFont is
A SoundFont (a .sf2 file) is a sampler instrument from the CD-ROM era:
recordings of real instruments plus the metadata that makes them playable —
which recording to use for which key, how notes fade out, and so on. One
SoundFont file typically contains all 128 instruments of the General
MIDI standard: piano is instrument 0, clean electric guitar is 27, picked
bass is 34, and so on, the same numbers in every SoundFont ever made.
General MIDI was much mocked in the 90s for the sound of cheap sound cards.
For us, that fixed numbering is a gift: it’s a stable API for
instruments. Your code can say CLEAN_GUITAR = 27 and mean it forever.
The SoundFont this book uses is GeneralUser GS — free, about 30 MB, and far better than anything you remember GM sounding like. Download it once and keep it next to your scripts.
Installing
FluidSynth is a C library; pyfluidsynth is the Python wrapper.
# macOS # Ubuntu/Debian
brew install fluid-synth sudo apt-get install fluidsynth libfluidsynth-dev
pip install pyfluidsynth pip install pyfluidsynth
# Windows (as administrator)
choco install fluidsynth
pip install pyfluidsynth
All three paths are tested on clean machines in this book’s CI. One trap is worth knowing on macOS: the Python wrapper sometimes can’t find the library Homebrew installed, and fails with “Couldn’t find the FluidSynth library.” The fix is to tell Python exactly where to look, before the import:
import ctypes.util, os
# Python finds C libraries via this function; teach it Homebrew's shelf.
_original = ctypes.util.find_library
def _patched(name):
found = _original(name)
if not found and name == "fluidsynth":
path = "/opt/homebrew/lib/libfluidsynth.dylib"
if os.path.exists(path):
return path
return found
ctypes.util.find_library = _patched
import fluidsynth # now succeeds
(ctypes is Python’s built-in bridge to C libraries; all we’re doing is
wrapping its lookup function with a fallback path. On Windows, the
chocolatey install works out of the box.)
Talking to the synthesizer
FluidSynth’s API, reduced to what we need:
fs = fluidsynth.Synth(samplerate=44100.0)
fs.setting("synth.gain", 0.6) # overall volume; see notes below
sfid = fs.sfload("GeneralUser-GS.sf2") # load the SoundFont
fs.program_select(0, sfid, 0, 27) # channel 0 plays instrument 27 (clean guitar)
fs.program_select(1, sfid, 0, 34) # channel 1 plays instrument 34 (picked bass)
fs.noteon(0, 60, 100) # channel 0: start middle C at volume 100 (max 127)
fs.noteoff(0, 60) # channel 0: release middle C
A synthesizer has 16 channels — think of each channel as one band
member, playing one instrument. Notes are numbered like piano keys (60 is
middle C; each step is one semitone), and the third argument to noteon is
velocity — how hard the key was struck, 1 to 127. In good SoundFonts,
velocity changes the character of the note, not just its loudness,
because the library contains different recordings for soft and hard
playing.
You can also position each player in the stereo field:
fs.cc(0, 10, 44) # "control change": controller 10 is pan.
fs.cc(1, 10, 84) # 0 = hard left, 64 = center, 127 = hard right
The key idea: pull audio between events
Here’s the mechanism the whole studio is built on. FluidSynth has a method
fs.get_samples(n) that returns the next n samples of audio, with
whatever notes are currently sounding rendered into them. The synthesizer
only moves forward in time when you ask it for samples.
That suggests a simple and perfect scheduler. Say the song’s first note starts at sample 44,100 (one second in) and the second at sample 66,150:
- Ask for 44,100 samples (silence so far). Then press the first key.
- Ask for 22,050 more samples (the first note, sounding). Press the second key.
- Continue to the end.
Every note lands on its exact sample, every time. No timers, no audio driver, no drift. This is why rendering offline isn’t a lesser version of real-time audio — it’s the same thing with the hard part (the deadline) removed.
The renderer
Here it is in full. Events are tuples — small fixed bundles of values —
of the form (sample_position, "on" or "off", channel, note, velocity):
import numpy as np
SR = 44100 # sample rate, used everywhere
def render_bus(events, total_secs, sf2_path, setup):
"""Render one instrument track ("bus"). Returns stereo audio as a
NumPy array with shape (2, total_samples)."""
n_samples = int(total_secs * SR)
fs = fluidsynth.Synth(samplerate=float(SR))
fs.setting("synth.gain", 0.6)
sfid = fs.sfload(sf2_path)
setup(fs, sfid) # caller's function: assigns instruments/pans
# FluidSynth hands back interleaved stereo: L,R,L,R,... We collect it
# into one flat array and split it at the end.
audio = np.zeros(n_samples * 2, dtype=np.float32)
pos = 0 # our current position, in samples
for ev in sorted(events, key=lambda e: e[0]): # sort by time
event_pos = min(ev[0], n_samples)
if event_pos > pos:
# Render everything between "now" and this event.
raw = fs.get_samples(event_pos - pos) # integers, -32768..32767
end = min(pos * 2 + len(raw), len(audio))
audio[pos * 2:end] += raw[:end - pos * 2].astype(np.float32) / 32768.0
pos = event_pos
if ev[1] == "on":
fs.noteon(ev[2], ev[3], ev[4])
else:
fs.noteoff(ev[2], ev[3])
if pos < n_samples: # render the tail: notes fading out
raw = fs.get_samples(n_samples - pos)
end = min(pos * 2 + len(raw), len(audio))
audio[pos * 2:end] += raw[:end - pos * 2].astype(np.float32) / 32768.0
fs.delete()
# De-interleave: every 2nd value starting at 0 is left, at 1 is right.
return np.stack([audio[0::2], audio[1::2]])
A few lines deserve translation:
sorted(events, key=lambda e: e[0])— sort the events by their first element (the time). Alambdais just a one-line unnamed function.raw.astype(np.float32) / 32768.0— FluidSynth returns whole numbers in the range ±32768 (the format called 16-bit); dividing converts to our standard ±1.0 decimals.audio[0::2]— NumPy slicing: “every second element, starting at index 0.” That pulls the left channel out of the interleaved L,R,L,R stream.- The block after the loop matters: notes released near the end still
ring out (piano decay, reverb tails). Always budget a few extra seconds
of
total_secsfor the tail.
Why events store sample positions rather than seconds: decimal seconds accumulate tiny rounding differences; integer sample positions make every render land identically — which Chapter 1’s whole reproducibility argument depends on. Convert seconds to samples once, when creating the event:
def add_note(events, t, dur, ch, note, vel):
events.append((int(t * SR), "on", ch, note, vel))
events.append((int((t + dur) * SR), "off", ch, note, 0))
One track per band member
You could render a whole band in one pass — sixteen channels is plenty. Don’t. Render one bus (one audio array) per band member:
gtr = render_bus(guitar_events, total_secs, SF2, setup_gtr)
bass = render_bus(bass_events, total_secs, SF2, setup_bass)
pad = render_bus(pad_events, total_secs, SF2, setup_pad)
Three reasons, in rising importance. Effects: the next chapter puts a guitar amp on the guitar and a compressor on the bass — only possible if they’re separate arrays. Mixing: combining buses at chosen volumes is mixing, and you want those volumes adjustable per member. Stems: near the end of the book we’ll export each member as its own file so any DAW can remix the song — impossible if they were merged early.
The cost is a few extra seconds of rendering. The songs in this book render a full band in well under a minute.
Practical notes
synth.gain0.6: FluidSynth’s default overall volume is very conservative. 0.6 gives healthy levels with headroom. If a dense arrangement distorts, lower this — don’t lower note velocities, which would change the tone.- Same input, same output: render a bus twice and the arrays are equal. This is what makes listening tests trustworthy and lets us put renders under automated test.
- The honest limitation: a SoundFont guitar, played dry, will not fool a guitarist. What you get is correct notes with sterile air. The next chapter is about that air — we’ll take these tracks and run them through the same amplifiers and effects real records use, and the sterility turns out to be exactly what a good amp wants to be fed.
Chapter 3 — Real Plugins, No DAW
Chapter 2 ended with a complaint: our instruments play correct notes into sterile air. The audio industry solved sterile air decades ago, and the solution is the plugin ecosystem — forty years of amplifier simulation, reverbs, compressors, and synthesizers, shipped as small installable programs (the formats are called VST3 and Audio Unit). Everyone assumes plugins belong to DAWs. They don’t. A plugin is a code library with a standard interface; a DAW is one program that calls it, and your script can be another. By the end of this chapter you’ll run the same neural-network amp simulation that professional guitarists record with — from Python, no window, no clicking — and learn a trick for controlling even the settings plugins don’t officially expose.
One import gets you a plugin host
Spotify maintains a Python library called
pedalboard that embeds a full
plugin host. Install it (pip install pedalboard), and loading a plugin is
one call:
from pedalboard import load_plugin
amp = load_plugin("/path/to/NeuralAmpModeler.vst3")
Plugins install into standard folders, so you know where to point that path:
| Platform | System-wide | Per-user |
|---|---|---|
| macOS | /Library/Audio/Plug-Ins/VST3 |
~/Library/Audio/Plug-Ins/VST3 |
| Windows | C:\Program Files\Common Files\VST3 |
— |
| Linux | /usr/lib/vst3 |
~/.vst3 |
Our running example is the free Neural Amp Modeler (NAM). NAM plays capture files — small neural networks trained to imitate a specific physical amplifier, made by recording the real hardware. The community has captured thousands of amps; the Vox AC15 and Fender Twin this book’s records use cost nothing. Let that sink in: a neural model of a vintage tube amplifier, running in your script.
Every knob is an attribute
A loaded plugin can tell you about itself:
>>> amp.parameters.keys()
dict_keys(['input_db', 'bass', 'middle', 'treble', 'output_db', ...])
Each parameter — each knob you’d see in the plugin’s window — becomes a Python attribute you can read and set:
amp.input_db = -3.0 # how hard the guitar drives the amp
Two habits will save you grief. First, check a parameter’s allowed range
before setting it (printing amp.parameters['input_db'] shows it) —
plugins measure knobs in decibels, percent, or arbitrary units as they
please. Second, read the value back after setting: some plugins snap your
value to the nearest allowed step, and the snapped value is what you’ll
actually hear.
About that -3.0: it holds the AC15 just below the point where it
starts to distort, so that how hard each note is played decides, note by
note, whether it stays clean or barks. One number, and it’s the difference
between polite and alive.
Processing audio
A loaded plugin is callable — you use it like a function:
amped = amp(guitar_audio, 44100) # stereo array in, stereo array out
Chains of effects compose with a Pedalboard, which is a list of effects
that acts like a single one:
from pedalboard import Pedalboard, Chorus, Delay, Reverb
rig = Pedalboard([
Chorus(rate_hz=0.8, depth=0.2, mix=0.5), # pedal in front of the amp
amp, # the real VST3
Delay(delay_seconds=0.318, feedback=0.28, mix=0.16),
Reverb(room_size=0.85, wet_level=0.24, dry_level=0.76),
])
produced = rig(guitar_audio, 44100)
(Chorus, Delay, and Reverb here are pedalboard’s built-in effects —
no plugin files needed.) Order matters exactly like it does on a
guitarist’s floor: chorus before the amp gives the amp a shimmering
signal to distort; chorus after the amp wobbles the already-distorted
sound — a different, swooshier effect. In a DAW, trying both means
re-cabling; here it’s reordering a list.
Pedalboard also has routing primitives — Mix runs branches in parallel
and adds the results, Chain makes a serial branch. We’ll use them in
Part II to build a classic studio bass sound (clean signal and distorted
signal side by side). For now just know the console-style wiring exists.
Instruments too, not just effects
Some plugins generate sound from notes rather than processing audio. Those take a list of timed MIDI messages (note-on and note-off events, with times in seconds) plus how long to render:
from mido import Message # mido: a small library for MIDI messages
notes = [Message("note_on", note=45, velocity=100, time=0.0),
Message("note_off", note=45, time=1.5)]
audio = synth_plugin(notes, duration=3.0, sample_rate=44100)
This is our second instrument loader (after Chapter 2’s), and it will matter in the next chapter when we rescue a beautiful bass instrument that happens to be trapped in an outdated plugin.
The setting that isn’t a parameter
Here’s where this chapter earns its keep. NAM’s most important setting —
which amp capture file to load — does not appear in
amp.parameters. Neither does a sampler plugin’s sample folder or a
reverb plugin’s room file. File choices live in the plugin’s saved
state, and state is normally set by clicking around the plugin’s window.
We don’t have a window. We have two workarounds, and together they handle
every plugin you’ll meet.
The easy way: save the state once, reuse it forever
Pedalboard exposes the plugin’s entire saved state as a chunk of raw bytes
called preset_data — and it’s writable. So: open the plugin’s real
window once on your own machine, click what needs clicking, then keep
the bytes:
plugin.show_editor() # opens the actual plugin window; configure it
with open("ac15_setup.preset", "wb") as f:
f.write(bytes(plugin.preset_data))
# From then on, in any script, no window:
amp = load_plugin(NAM_PATH)
amp.preset_data = open("ac15_setup.preset", "rb").read()
This works for any plugin, and if your rig never changes, it’s all you need.
The powerful way: edit the bytes
The easy way freezes your choice. But Chapter 1 promised for-loops over amps — programmatically swapping capture files. For that we have to open the black box.
It’s less scary than it sounds. The preset bytes have a documented outer structure, and inside, NAM stores its settings in a simple pattern: a marker string, then some length-prefixed text fields — including the file path of the amp capture. “Length-prefixed” means each text field is stored as a number (how many bytes of text) followed by the text itself. So the surgery is: find the marker, skip one field, and replace the next field with our own path — updating its length number to match.
Python’s built-in struct module does the byte-level reading and writing.
Two calls cover everything: struct.unpack("<i", data) reads bytes as a
number, and struct.pack("<i", n) writes a number as bytes (the "<i"
describes the number format — a standard 4-byte integer).
import os, struct
from pedalboard import load_plugin
def load_nam(vst3_path, model_path):
"""Load the NAM plugin with a chosen amp capture pre-selected."""
p = load_plugin(vst3_path)
data = bytes(p.preset_data)
# The preset's table of contents lives at a fixed offset and tells us
# where the plugin's own state ("component chunk") ends.
comp_end = struct.unpack("<q", data[40:48])[0]
comp, tail = data[48:comp_end], data[comp_end:]
# Find the marker, skip the version field, land on the model path.
marker = b"###NeuralAmpModeler###"
i = comp.index(marker) + len(marker)
version_len = struct.unpack("<i", comp[i:i + 4])[0]
i += 4 + version_len
old_len = struct.unpack("<i", comp[i:i + 4])[0]
# Splice in our path, with its length in front.
path = os.path.abspath(model_path).encode()
comp = comp[:i] + struct.pack("<i", len(path)) + path \
+ comp[i + 4 + old_len:]
# Our splice changed the chunk's size, so the table of contents at the
# end must be rebuilt to match — otherwise the plugin silently ignores
# the whole preset. (Full walkthrough of this stanza in Appendix C.)
n = struct.unpack("<i", tail[4:8])[0]
entries = b""
for k in range(n):
e = tail[8 + k * 20:8 + (k + 1) * 20]
chunk_id = e[:4]
if chunk_id == b"Comp":
off, size = 48, len(comp)
else:
off = 48 + len(comp)
size = struct.unpack("<qq", e[4:20])[1]
entries += chunk_id + struct.pack("<qq", off, size)
p.preset_data = (data[:40] + struct.pack("<q", 48 + len(comp)) + comp
+ b"List" + struct.pack("<i", n) + entries)
return p
amp = load_nam(NAM_VST3, "captures/Vox_AC15_TopBoost.nam")
Every guitar and bass note on this book’s records passes through that function. And note the failure mode the table-of-contents rebuild prevents: get it wrong and there’s no error — the plugin just quietly uses its default sound. When byte surgery misbehaves, suspect the bookkeeping before the splice.
If you ever need this trick on a different plugin: save two presets that differ only in the file path, and compare the bytes. The region that differs is the path field, and the bytes around it reveal the pattern.
Practical notes
- A plugin instance is stateful. Don’t share one instance between two effect chains; load a fresh one per chain. Loading is cheap.
- Some plugins report latency (they delay the signal to look ahead); pedalboard compensates automatically when rendering.
- Determinism holds. Amp sims, EQs, and compressors give identical output for identical input — your renders stay reproducible.
Two loaders down. But this chapter quietly assumed the plugin loads on your machine. The best bass instrument I know is built for a kind of processor my Mac doesn’t have — and the next chapter is about refusing to take that for an answer.
Chapter 4 — Rescuing Plugins That Won’t Load
Every plugin ecosystem accumulates orphans: plugins that are excellent, often free, irreplaceable in character — and built for a kind of computer your Python can’t pretend to be. On newer Macs it’s plugins compiled for Intel processors; on Windows it’s the beloved 32-bit plugins of the 2000s that a modern 64-bit Python can’t touch; on Linux it’s most commercial plugins, which never shipped for Linux at all.
The fix rests on one idea worth framing: a process boundary is a compatibility boundary. Your script can’t load an incompatible plugin, but it can launch a second program that can — a small helper process of whatever type the plugin needs — and trade files with it. This chapter builds that helper for the hardest everyday case, then shows the same pattern on the other platforms, and along the way covers two sampler-plugin gotchas that apply everywhere and cost me an afternoon each.
The case study
Ample Bass P Lite II: a gorgeous, deeply-sampled Fender bass, free — and, permanently, a 2016 Intel-only build. A modern Mac runs an “Apple Silicon” (ARM) processor; a program built for Intel processors simply cannot be loaded into an ARM program. No setting fixes this; it’s like trying to play a Blu-ray in a cassette deck.
But macOS ships a translator called Rosetta 2 that runs entire Intel programs on ARM Macs, seamlessly. It can’t translate a plugin inside your ARM Python — it works on whole processes. So run a whole Intel Python:
# One-time setup: create an Intel-flavored Python environment.
arch -x86_64 /usr/bin/python3 -m venv ~/.venvs/x86-audio
arch -x86_64 ~/.venvs/x86-audio/bin/pip install pedalboard mido numpy scipy
(arch -x86_64 means “run this as an Intel program”; the system Python
contains both flavors. A venv is Python’s standard isolated
environment — this one, created by the Intel Python, stays Intel,
including every package installed into it.) You now have two Pythons: your
normal one, and an Intel one for plugin archaeology.
Keep the interface primitive: files in, files out
The helper program does one job: read a list of notes from a JSON file,
play them through the plugin, write a WAV file. Deliberately no sockets,
no clever inter-process anything — files. Files can be inspected when
something goes wrong (cat the JSON; listen to the WAV), the helper can
be tested by hand from a terminal, and the overhead — a few milliseconds —
is nothing against renders measured in seconds.
The note format:
{"duration": 201.3,
"notes": [{"t": 0.0, "dur": 0.42, "note": 45, "vel": 105}, ...]}
The helper, complete:
"""render_helper.py — runs under the Intel Python.
Usage: python render_helper.py notes.json out.wav"""
import json, os, sys
import numpy as np
from mido import Message
from pedalboard import load_plugin
from scipy.io import wavfile
SR = 44100
PLUGIN = os.path.expanduser(
"~/Library/Audio/Plug-Ins/Components/ABPL2.component")
def main():
events_path, out_path = sys.argv[1], sys.argv[2]
with open(events_path) as f:
spec = json.load(f)
plugin = load_plugin(PLUGIN)
# Warm-up: see "the two gotchas" below.
warmup = [Message("note_on", note=45, velocity=100, time=0.1),
Message("note_off", note=45, time=0.6)]
for attempt in range(8):
test = plugin(warmup, duration=1.0, sample_rate=SR,
buffer_size=8192, reset=False)
if float(np.abs(test).max()) > 0.001: # did any sound come out?
break
else:
sys.exit("plugin produced no audio after warm-up attempts")
# Convert the JSON notes into MIDI messages.
msgs = []
for n in spec["notes"]:
msgs.append(Message("note_on", note=n["note"],
velocity=n["vel"], time=n["t"]))
msgs.append(Message("note_off", note=n["note"],
time=n["t"] + n["dur"]))
msgs.sort(key=lambda m: m.time)
audio = plugin(msgs, duration=spec["duration"], sample_rate=SR,
buffer_size=8192, reset=False)
if float(np.abs(audio).max()) < 0.001:
sys.exit("plugin rendered silence")
wavfile.write(out_path, SR,
(np.clip(audio.T, -1, 1) * 32767).astype(np.int16))
if __name__ == "__main__":
main()
One design point to steal: both failure checks measure audio, not
success codes. np.abs(test).max() asks “what was the loudest sample?” —
because a sampler’s signature failure is to load happily, report success,
and produce silence. Only listening (numerically) catches it.
The caller, and the dignity of a fallback
Back in your normal script, running the helper is Python’s standard
subprocess module — launch a program, wait, check the result:
import json, os, subprocess, tempfile
from scipy.io import wavfile
X86_PYTHON = os.path.expanduser("~/.venvs/x86-audio/bin/python")
def render_ample_bass(notes, total_secs):
"""Returns stereo audio, or None if the helper fails."""
with tempfile.TemporaryDirectory() as td: # auto-cleaned temp folder
ev = os.path.join(td, "in.json")
stem = os.path.join(td, "out.wav")
with open(ev, "w") as f:
json.dump({"duration": total_secs, "notes": notes}, f)
result = subprocess.run(
["arch", "-x86_64", X86_PYTHON, "render_helper.py", ev, stem],
capture_output=True, text=True)
if result.returncode != 0:
print("helper failed, falling back:", result.stderr.strip())
return None
_, data = wavfile.read(stem)
return (data.astype("float32") / 32768.0).T
And at the call site:
bass = render_ample_bass(bass_notes, total_secs)
if bass is None:
bass = render_bus(bass_events, total_secs, SF2, setup_bass) # Chapter 2
That if is a production philosophy. The deep-sampled bass is better;
the Chapter 2 SoundFont bass is always available. The song always
renders — on a bad day the bass just sounds slightly less expensive, and
the console says why. When your pipeline runs unattended, every luxury
needs a boring understudy.
The two gotchas that cost an afternoon each
Both apply to any sampler that streams its sound files from disk (most sampled pianos, basses, and orchestral instruments), no matter how you host it.
Gotcha 1: disk streamers start silent. These plugins load instantly but keep their samples on disk, read by a background thread. In a DAW, that thread has warmed up long before a human presses play. A script renders immediately — and captures silence. Hence the warm-up loop: render a short throwaway phrase repeatedly until sound actually comes out. In my logs it’s never needed more than two attempts; the loop shape means a genuinely broken install fails with a clear message instead of a silent bass track.
Gotcha 2: pass reset=False, always, for these plugins. By default,
pedalboard resets a plugin to factory-fresh state on every call — a
sensible default for effects. For a disk streamer it means the warm-up you
just did is thrown away before the real render. The symptom is maddening:
warm-up works, real render is silent, no error anywhere. reset=False
keeps the same living plugin across all the calls. This one keyword
argument is the most expensive thing this book knows, per character.
The same trick elsewhere
Linux: the tool yabridge
runs Windows plugins on Linux (via the Wine compatibility layer) and
presents them as normal Linux plugins — the incompatible code is already
isolated in its own process. After yabridgectl sync, load_plugin
just sees them.
Windows: for a 32-bit plugin, install a 32-bit Python alongside your 64-bit one and run the identical helper script under it:
subprocess.run([r"C:\Python311-32\python.exe", "render_helper.py", ev, stem])
Notice what changed across all three platforms: nothing in the helper. Only which interpreter runs it. Compatibility is a property of the process, so pick the right process per plugin.
The bonus that becomes the reason
Old plugins crash. When a plugin lives inside your script’s process, its crash is your crash — the whole pipeline dies mid-album, uncatchably. When it lives in a helper process, the same crash is just a non-zero return code: log it, fall back, finish the record. Professional DAWs (Bitwig, Reaper) evolved this exact per-plugin sandboxing after decades of crashes; we get it free because we started at the boundary.
Two loaders in two chapters, plus a rescue procedure for the ones that resist. Next: several gigabytes of professionally recorded instruments that are probably already on your Mac — locked in a file format nobody documented. We’re going to document it.
Chapter 5 — The Treasure in GarageBand: Reading an Undocumented Format
If you have a Mac with GarageBand’s sound library installed, you’re sitting on several gigabytes of professionally recorded instruments: a Steinway grand piano, drum kits recorded in real rooms, a Mellotron (the tape-based keyboard from every Beatles record you’re thinking of), electric and acoustic guitars sampled fret by fret. All of it is locked in Apple’s EXS format — no export button, no documentation, no API.
This chapter picks the lock. We’ll reverse-engineer the format, extract the instruments, and then build the piece no one else writes about: a working sampler — the program that turns a folder of extracted recordings back into a playable instrument — in about ninety lines. Two of this book’s albums run on it.
First, the legal shape of this, because it matters: Apple’s license lets you use this content in your own music, commercial music included. It does not let you redistribute the samples as samples. Extract for your own pipeline; never ship a sample pack. (The format knowledge itself is yours forever.)
What’s actually on disk
/Library/Application Support/GarageBand/Instrument Library/Sampler/
├── Sampler Instruments/ the .exs files — small "map" files
└── Sampler Files/ the audio — often many hits packed into
one big container file
The audio files are often consolidated: dozens of individual recordings
packed end-to-end into one container. That’s why you can’t just browse the
folders and grab WAVs — without the map, you don’t know where one piano
note ends and the next begins. The .exs map file knows. So the map is
what we parse.
How you read a format nobody documented
A .exs file is binary — raw bytes, not text. Binary formats sound
intimidating, but most are just C structures written straight to disk:
fixed-size records whose fields live at known offsets. The whole game is
learning the offsets. (How does anyone learn them without documentation?
Patient hex-dumping: change one thing in the app, save, and see which
bytes changed. What follows is the result of that process so you don’t
have to repeat it.)
An EXS file is a sequence of chunks. Every chunk starts with the same 84-byte header:
| bytes | meaning |
|---|---|
| 0–3 | flags — the chunk’s kind hides in byte 3 |
| 4–7 | size of the data that follows (a 4-byte integer) |
| 8–15 | id + padding |
| 16–19 | the magic marker TBOS — how we know we’re aligned |
| 20–83 | the chunk’s name, as text padded with zeros |
Three kinds of chunk matter:
- Zones (kind 1) — one playable region each: which key range, which velocity range (how hard the key is hit), and — crucially — the exact start and end position, in samples, inside which audio file.
- Groups (kind 2) — named categories of zones (“hits”, “palm-mute”, or a Mellotron tape bank name).
- Samples (kind 3) — the audio files being referenced, by name.
Python reads binary with the built-in struct module — you met it in
Chapter 3. struct.unpack_from("<I", data, offset) means “read a 4-byte
unsigned integer at this offset.” Single bytes can be read by plain
indexing: data[pos + 3] is a number 0–255.
import struct
def parse_exs(path):
data = open(path, "rb").read() # "rb": read as raw bytes
zones, samples, groups = [], [], []
pos = 0
while pos + 84 <= len(data):
if data[pos + 16:pos + 20] != b"TBOS":
pos += 1 # not aligned on a chunk — slide forward and resync
continue
kind = data[pos + 3] & 0x0F # keep only the low bits of byte 3
size = struct.unpack_from("<I", data, pos + 4)[0]
name = data[pos + 20:pos + 84].split(b"\x00")[0].decode("ascii", "replace")
body = data[pos + 84:pos + 84 + size]
if kind == 1 and size >= 96: # a zone
zones.append({
"name": name,
"root": body[1], # the key the sample was recorded at
"keylo": body[6], "keyhi": body[7],
"vello": body[9], "velhi": body[10],
"start": struct.unpack_from("<I", body, 12)[0],
"end": struct.unpack_from("<I", body, 16)[0],
"group_idx": struct.unpack_from("<I", body, 88)[0],
"sample_idx": struct.unpack_from("<I", body, 92)[0],
})
elif kind == 2: # a group name
groups.append(name)
elif kind == 3: # an audio file reference
samples.append(name)
pos += 84 + size
for z in zones: # attach group names to zones
z["group"] = groups[z["group_idx"]] if z["group_idx"] < len(groups) else ""
return zones, samples
That byte-by-byte “resync” when the magic marker isn’t where we expect is inelegant and necessary — some files carry padding between chunks, and a parser that trusts arithmetic alone walks off a cliff mid-file.
Run this on a factory drum kit and you get something wonderful: a complete map. “MIDI key 38, velocities 80–112, samples 1,204,551 through 1,310,006 of audio file 0.” No guesswork; the format tells you where every hit lives, down to the sample.
Extraction
The container files are Apple’s .caf format; macOS’s built-in
afconvert tool converts them to WAV (on other platforms, ffmpeg does):
import subprocess, json
from scipy.io import wavfile
subprocess.run(["afconvert", "-f", "WAVE", "-d", "LEI16@44100",
"Kit_consolidated.caf", "kit.wav"], check=True)
sr, x = wavfile.read("kit.wav")
manifest = []
for i, z in enumerate(zones):
hit = x[z["start"]:z["end"]] # slice out one recording, exactly
fname = f"note{z['keylo']:03d}_vel{z['vello']:03d}-{z['velhi']:03d}_{i:03d}.wav"
wavfile.write(f"out/{fname}", sr, hit)
manifest.append({**z, "file": fname}) # the zone info, plus its filename
with open("out/manifest.json", "w") as f:
json.dump(manifest, f, indent=1)
The real deliverable is that manifest.json — the zone map, preserved as
readable JSON next to the extracted WAVs. The folder becomes a
self-describing instrument: audio plus map. Everything downstream reads
the manifest, never the filenames.
Building the sampler
Extracting samples is where most write-ups stop. But a folder of WAVs isn’t an instrument until something can play it. Here’s what a sampler actually has to do — and it’s less magic than the products suggest:
- Given a note and how hard it’s played, choose the right recording.
- Re-pitch it if the exact note wasn’t sampled.
- End it gracefully when the note is released.
Choosing the recording is a filter over the manifest:
class ExsSampler:
def __init__(self, sample_dir, groups=None):
with open(os.path.join(sample_dir, "manifest.json")) as f:
self.zones = json.load(f)
if groups: # keep only zones whose group name matches
self.zones = [z for z in self.zones
if any(g in z["group"] for g in groups)]
self.dir = sample_dir
self.cache = {}
def _pick_zone(self, note, vel):
candidates = [z for z in self.zones
if z["keylo"] <= note <= z["keyhi"]]
by_vel = [z for z in candidates
if z["vello"] <= vel <= z["velhi"]] or candidates
return by_vel[int(rng.integers(len(by_vel)))]
Read _pick_zone slowly — it’s the entire intelligence of every sampler
you’ve ever bought, in a few lines. Filter by key range. Filter by
velocity, which selects among the soft, medium, and hard recordings the
library actually contains — this is why sampled instruments respond to
touch. And when several recordings survive both filters, they’re alternate
takes of the same note, and picking one at random is the industry’s
round-robin trick: it prevents rapid repeated notes from sounding like
a stuck record (the dreaded “machine-gun effect”).
Re-pitching is one formula. Playing a recording faster raises its pitch:
to shift by k semitones, read it 2^(k/12) times faster. The code reads
the recording at fractional positions, interpolating between neighboring
samples:
def _play(self, zone, note):
data = self._load(zone["file"])
ratio = 2.0 ** ((note - zone["root"]) / 12.0) # speed factor
idx = np.arange(int(len(data) / ratio)) * ratio # fractional positions
i0 = np.floor(idx).astype(int) # sample before
i1 = np.minimum(i0 + 1, len(data) - 1) # sample after
frac = (idx - i0).astype(np.float32)
return data[i0] * (1 - frac) + data[i1] * frac # blend between them
(This is why real libraries sample every two or three keys — so no recording ever gets stretched far enough to sound like a chipmunk.)
Ending gracefully: keep the recording’s own natural decay — that decay is the instrument — and only if the note is released early, fade the last 90 milliseconds to zero. A synthetic volume envelope pasted over a sampled piano is precisely how cheap keyboards sound cheap.
One extraction, many instruments
That groups argument in the constructor multiplies your instruments for
free. The Mellotron extraction’s groups are its tape banks — so
ExsSampler(mello_dir, groups=["Strings"]) and groups=["Choir"] are
different band members from one folder, exactly how the original
hardware’s swappable tape frames worked. The acoustic guitar uses it in
reverse: excluding the “palm-mute” group keeps the muted articulations
from randomly round-robining into open strums.
And a bonus trick from the codebase: a three-line filter that makes a guitar sample sound like it’s being heard through a different pickup position — physics says a pickup subtracts a delayed copy of the string’s vibration from itself, and code can do exactly that subtraction. One extracted Stratocaster became two usable guitar characters. (The full version is in the companion code.)
What this buys
On this book’s albums: the Steinway carries an entire piano record; the Mellotron banks pad half the synth record; the extracted Strat answers lead lines. After extraction there is no plugin in the signal path — just JSON and array math — which means Chapter 4’s compatibility worries simply don’t exist for these instruments, on any platform, forever.
Next up: the one band member that was never anything but a sample player — and why that’s a feature to lean into rather than an economy to apologize for.
Chapter 6 — Drum Machines Are Sample Players
Every chapter so far has chased more realism. This one is about the band member whose realism points the other way. A 1982 drum machine is, in its entirety, a box that plays short fixed recordings at scheduled times through volume knobs. That’s not a limitation to transcend — it is the sound — and it means your script can be a bit-perfect drum machine, not an imitation of one, in about forty lines. This chapter builds that player, adds the two refinements the real boxes had, and then spends its back half on the decision that actually shapes your record: which machine — answered with a harness that turns the choice into a science experiment.
Why the boxes sound like the boxes
When a LinnDrum plays hi-hat sixteenth-notes for three minutes, every single closed hat is the identical recording — same chip, same output circuit, same level. That relentless sameness is the plodding, unmoved-machine quality that defines entire genres: the dead-eyed post-punk beat, the immaculate 80s pop groove. If you synthesize drums, you approximate that quality. If you “humanize” it, you destroy it. The faithful implementation is the obvious one — place identical recordings on a grid:
def render_drum_bus(events, total_secs, kit):
"""events: a list of (seconds, piece_name, gain) triples.
kit: {"kick": loaded_audio, "snare": ..., "hhc": ..., "hho": ...}"""
n = int(total_secs * SR)
bus = np.zeros((2, n), dtype=np.float32) # empty stereo track
for t, name, gain in events:
hit = kit[name] * gain
start = int(t * SR)
end = min(start + len(hit), n)
bus[:, start:end] += hit[:end - start] # add the hit onto the grid
return bus
Loading a kit takes two decisions worth defending:
def load_kit(folder, files, gains):
kit = {}
for name, fname in files.items():
_, d = wavfile.read(os.path.join(folder, fname))
d = d.astype(np.float32) / 32768.0
if d.ndim == 2: # stereo file?
d = d.mean(axis=1) # average to mono
loudest = max(np.abs(d).max(), 1e-9)
kit[name] = d / loudest * gains[name] # normalize, then set level
return kit
Normalize each hit first (divide by its own loudest sample), then apply your chosen gain. One-shot collections arrive with wildly inconsistent recording levels; normalizing means the gains express musical balance and carry between machines. My standing starting point: kick 0.95, snare 0.8, closed hat 0.4, open hat 0.45. Sum stereo files to mono, because stereo placement belongs to the mixing stages later — one decision, one place.
Where the sounds come from
The smpldsnds/drum-machines repository collects freely shared recordings from dozens of hardware boxes. The eight in my studio, each in one line:
| Machine | Character |
|---|---|
| LinnDrum LM-2 | the 80s pop kit — real drums in memory chips, roomy, confident |
| Sequential Drumtraks | the Linn’s new-wave cousin — tighter, brighter |
| Roland TR-808 | electronic circuits, not recordings: the deep boomy kick of hip-hop |
| Roland CR-8000 | the 808’s home-organ sibling — thinner, charming |
| Casio RZ-1 | mid-80s low-resolution sampler crunch |
| MFB-512 | stark German box; short punchy kicks that leave room for bass |
| Korg MR10 | scrappy cheapie, all attack |
| Casio SK-1 | the toy sampler — beautiful garbage |
Give each machine a folder with consistent file names, and “which drum machine” becomes a single config value in your song.
Patterns as data, and two hardware refinements
A beat, in this system, is just a function that emits (time, piece,
gain) triples for one bar:
def linn_beat(bar_t, full):
"""Kick on 1 and 3, snare on 2 and 4, eighth-note hats."""
B = BEAT # seconds per beat
hits = [(bar_t, "kick", 0.50),
(bar_t + 2 * B, "kick", 0.35),
(bar_t + 1 * B, "snare", 0.28),
(bar_t + 3 * B, "snare", 0.30)]
if full: # hats join in full sections
for k in range(8):
level = 0.17 if k % 2 == 0 else 0.12 # accent alternate hats
hits.append((bar_t + k * B / 2, "hhc", level))
return hits
Notice the accent structure riding in the gains — louder downbeat kicks, alternating hat levels. That’s the accent button every real box had, and gains are exactly how the hardware did dynamics (most machines had two levels per sound, total).
The second hardware behavior worth copying is the hi-hat choke. On a real box, the open and closed hat shared one output circuit — a closed hit silenced a ringing open hat. Sample playback that skips this sounds subtly wrong to anyone who’s heard the real thing, because open hats smear across the closed hits that should have cut them off. The fix: when placing a hat, first cut short any still-ringing hat before it (with a 5-millisecond fade at the cut so it doesn’t click). The abruptness that remains is authentic — it’s what the circuit did. (Implementation in the companion code; it’s a dictionary remembering the last hat’s position.)
Choosing: the audition harness
Now the payoff section. Which machine serves this song? The wrong way to decide is auditioning drum sounds in a file browser. A kick heard alone tells you almost nothing about that kick under a bass line, through your compressor, in your reverb — drum sounds only mean anything in context. The 808’s long boom is either the glue of the track or mud all over your bassline, and you cannot tell which from the sample alone.
The right way costs a for-loop:
for machine in ("LM-2", "Drumtraks", "TR-808", "CR-8000",
"RZ-1", "MFB-512", "MR10", "SK-1"):
kit = load_kit(machine_folder(machine), FILES[machine], GAINS)
bus = render_drum_bus(song_beat_events, secs, kit)
processed = drum_effects(bus, SR) # the song's real drum chain
write_wav(f"auditions/{machine}.wav", processed)
Render the actual beat from your actual song, through the song’s actual processing, once per machine. Eight files, seconds of compute, then a blind back-to-back listen where the machine is the only thing varying. That’s a controlled experiment — the thing a DAW workflow only ever approximates with a lot of clicking.
I’ll repeat the result that converted me. For a darkwave song whose genre history demands the LinnDrum, I ran the harness expecting a coronation. Under the driving bassline, the Linn’s roomy kick fought the low end; the MFB-512’s short thump sat perfectly in the pocket and handed the bass its space. I shipped the German box. Heard solo I’d have picked the Linn every time; in context it wasn’t close — and by hand, I’d have stopped auditioning long before machine number six.
Processing the bus
The drums get the least processing of any band member, on purpose:
drum_fx = Pedalboard([
Compressor(threshold_db=-15, ratio=3, attack_ms=2, release_ms=60),
])
A compressor evens out volume differences — it turns down the loud
moments, squeezing the track together. (First appearance in the book, so:
threshold is the level where it starts working, ratio is how hard it
squeezes, attack/release are how fast it reacts and recovers.) Here
its job is “glue”: with a quick-but-not-instant attack, the drum hits keep
their snap while the space between them tightens, and the kit gels the
way one machine through one output jack gels.
No reverb here at all — the drums’ sense of space comes from the shared room in Chapter 11, where they’ll get the biggest share of it, because nothing fills a room like a drum kit.
Practical notes
- When identical hits are wrong. This whole chapter inverts the moment
you want a drummer instead of a drum machine — identical hits on an
acoustic kit trigger the machine-gun effect instantly. That problem was
already solved last chapter: extract a sampled acoustic kit (GarageBand’s
kits have velocity layers and alternate takes) and let the
ExsSampler’s round-robin do drummer-realism. Two drum philosophies, one event format. - Swing. The hardware’s swing knob delays every off-beat hit by a fixed fraction. In code, it’s a transform on the event list — and note that it’s a pattern change, not humanization: swung events repeat identically bar after bar, exactly like the hardware.
- Layering. A classic production move made trivial here: put the 808’s boomy kick underneath the LinnDrum’s knock by emitting both machines’ kicks in one event list, each at its own gain.
The rhythm section is complete. One sound remains that no sample and no plugin provides — the analog synthesizer voices this book’s synth album is made of. Next chapter, we build sound itself from trigonometry, and make it wobble like an old tape machine.
Chapter 7 — Building Sound From Scratch (and Making It Wobble)
Part I ends with the instrument that ships with nothing: sounds built directly from math. Why bother, when the last five chapters handed you a studio’s worth of instruments? Two honest reasons. Control — a synthesized voice exposes everything (pitch, brightness, envelope, drift) as a parameter your code can play with. And period truth — one of this book’s albums is a nostalgic 1983 synth record, and in 1983 the sounds were raw sawtooth waves, brand-new digital bells, and square-wave arpeggios, all warped by the tape they were recorded to. This chapter builds that album’s entire instrument rack — five voices, a tape-wobble engine, and a hiss bed — in about a hundred lines, and closes with an honest note about the corner we’re cutting.
Oscillators: sound is a repeating shape
Pull up a tone generator and you’re hearing an oscillator: a wave shape repeating fast enough to be a pitch (440 repetitions per second is the A above middle C). The shape determines the character:
- Sine (
np.sin) — pure, round, no edge. Sub-bass and soft flutes. - Sawtooth (
scipy.signal.sawtooth) — a ramp; bright and buzzy. The classic analog synth string/brass sound. - Square (
np.sign(np.sin(...))) — hollow, woody, video-game-y.
Generating one at a fixed pitch is a one-liner. But every interesting voice in this chapter bends its pitch over time — slides, vibrato, tape drift — and there’s a trap waiting there. You might write:
sig = np.sin(2 * np.pi * freq_array * t) # WRONG for changing pitch
That formula assumes the frequency was freq_array[i] for all time up to
sample i — so when the frequency moves, the pitch lurches absurdly. The
fix is one idea from calculus put into one line of NumPy: a wave’s
phase — how far along its cycle it is — is the running total of its
frequency. Running total = np.cumsum (cumulative sum):
phase = 2 * np.pi * np.cumsum(freq_array) / SR # accumulate the frequency
sig = np.sin(phase) # then shape it
Internalize this pattern — build the per-sample frequency array, cumsum it, apply the wave shape — and every analog trick below becomes easy. It’s this chapter’s version of Chapter 2’s render loop.
The tape-wow engine
What makes cheap oscillators feel alive is instability, and the most flattering instability is the kind an old tape machine adds: slow, compound pitch drift, called wow. Every voice in this rack draws its frequency through this little engine:
class Synth:
"""Every note's pitch drifts on two slow sine waves ("LFOs" — low-
frequency oscillators). `wobble` scales the depth per song."""
def __init__(self, rng, wobble=1.0):
self.rng = rng # the song's seeded random generator
self.wob = wobble
def _freq(self, f0, n):
t = np.arange(n) / SR
p1, p2 = self.rng.uniform(0, 2 * np.pi, 2) # random start phases
drift = (0.0012 * np.sin(2 * np.pi * 0.31 * t + p1)
+ 0.0007 * np.sin(2 * np.pi * 0.077 * t + p2)) * self.wob
return f0 * (1.0 + drift) # base pitch, bent by ±0.1-ish percent
Read the numbers like a spec sheet. Two drift waves: one wobbling 0.31 times per second at ±0.12% depth (a slightly worn tape-drive motor), one at 0.077 Hz and ±0.07% (the slower breathing of tape tension). Both are far below the speed of vibrato, which is why the ear reads them as atmosphere, not effect.
The subtle masterstroke is that the two starting phases are random per note. When the pad plays a three-note chord, each note drifts on its own schedule, and the chord shimmers — the notes beat gently against each other exactly as three analog oscillators recorded to one tape would. And because the randomness comes from the song’s seeded generator, the shimmer is identical on every render: wobble without giving up Chapter 1’s reproducibility.
wobble is set per song. The confident opener runs 1.0. A queasy
interlude titled “Vertical Hold” runs 2.2 — and the mood shift from that
one number is remarkable.
The voice rack
Five voices cover the whole record. Each is a dozen lines; each encodes a piece of synth history. Two shown in full, three sketched:
The pad — the lush sustained chord bed:
def pad(self, midi, dur):
n = int(dur * SR)
f = self._freq(440 * 2 ** ((midi - 69) / 12), n) # note -> Hz
ph = 2 * np.pi * np.cumsum(f) / SR
sig = (sawtooth(ph * 1.0035) # two saws, detuned a third of
+ sawtooth(ph * 0.9965) # a percent apart: instant width
+ 0.5 * np.sin(ph * 0.5)) # quiet sine an octave below: floor
env = adsr(n, dur * 0.3, 0.2, 0.85, dur * 0.35) # slow swell
return lowpass(sig * env, 1300) / 2.5 # darken, level
Two slightly-detuned sawtooths is the analog pad recipe — their slow
beating against each other is the lushness. The envelope’s attack scales
with the note’s duration (dur * 0.3), so long pad notes bloom slowly and
short ones still speak. Downstream this bus gets a chorus effect, and the
album script’s comment says why in six words: “the Juno move: chorus
makes the pad” — how a famously modest 1982 synthesizer made its one
oscillator sound huge.
(The helpers: adsr builds the classic attack/decay/sustain/release
volume envelope by gluing straight-line ramps together; lowpass wraps
SciPy’s filter functions — “remove frequencies above this cutoff.” Both
are ten lines, in the companion code.)
The bell — 1983’s brand-new sound, FM synthesis:
def bell(self, midi, dur):
n = int(dur * SR)
t = np.arange(n) / SR
f = self._freq(440 * 2 ** ((midi - 69) / 12), n)
ph = 2 * np.pi * np.cumsum(f) / SR
index = 2.2 * np.exp(-t / (dur * 0.35)) # how hard to modulate,
sig = np.sin(ph + index * np.sin(3.53 * ph)) # fading over time
return sig * np.exp(-t / (dur * 0.5)) * adsr(n, 0.005, 0.1, 0.8, 0.2) / 1.4
One sine wave bending the phase of another — that’s FM synthesis, the
technology of the Yamaha DX7 that had just hit the shops in our album’s
year. The modulation amount (index) decays over the note, which is what
makes it a bell: bright metallic attack mellowing into a pure tone. And
the magic constant 3.53 — the ratio between the two sines — is chosen to
be not a whole number: that’s what makes the overtones clang like metal
instead of humming like an organ. (The DX7 had six sines to our two; now
you know what the other four were for.)
The lead melody voice adds two performance behaviors: an 80 ms pitch slide from the previous note (the way a mono synth glides when you play legato) and vibrato that only fades in a third of a second into each note — because that’s what a player’s wrist does. That late-vibrato ramp does more for “a human is playing this” than any tone shaping. The arp is a square wave with a sharp exponential fade, plucky and video-game-adjacent. The bass is nearly a pure sine with a whisper of overtone — the polite foundation a nostalgic record wants.
And under every track, a tape hiss bed: filtered noise at whisper level, its volume breathing on a 20-second cycle. It reads as “machine in the room” long before it reads as “noise,” and its absence is conspicuously digital.
Where these plug in
A voice returns a mono array; placing it on a bus is the same “add it at sample position t” arithmetic as placing a drum hit; the buses flow into the same effects and mix stages as every other chapter’s instruments. That’s the note to end Part I on: synthesis isn’t a separate studio. It’s the sixth loader plugged into the same four sockets — events, buses, effects, mix — that the other five use.
The honest footnote: aliasing
The perfect sawtooth has infinitely many overtones; digital audio can only represent frequencies up to half the sample rate. Generate a naive sawtooth and the overtones past that limit don’t vanish — they fold back down into the audible range as inharmonic garbage, called aliasing. Textbook synthesis uses cleverer oscillators that avoid it.
This book’s voices are the naive kind, and they get away with it for reasons you should know rather than trust: the album’s melodies live in low registers (weak overtones near the limit); every voice is filtered darker, which removes most of the folding energy; and the genre wears grime — wow, hiss, a soft treble shelf — as costume. Move any of those assumptions (a screaming high lead, a bright open filter, a hi-fi genre) and aliasing becomes audible as a metallic whistle that slides the wrong way as pitch rises.
The fixes, in order of effort: render at double the sample rate and downsample at the end (no changes to the voices — costs only compute, which is the cheapest thing this studio owns); or upgrade to band-limited oscillators (the standard softsynth technique, “polyBLEP,” if you want the search term). The 1983 record shipped on naive oscillators and no ear I trust has flagged it — but know where the safe envelope ends, because someday you’ll leave it.
Part I is complete: a synthesizer you can script, real plugins, rescued plugins, an unlocked sample library with its own sampler, eight drum machines, and now raw synthesis — six instrument loaders, one event format, one bus convention. Part II puts players behind the instruments: the arrangement that tells everyone what to play, the imperfections that make hands sound like hands, each member’s rig, and the shared room that turns six audio files into a band.
Chapter 8 — The Song Is a Data Structure
Part I built six instrument loaders behind one shared interface. Part II is about the band that plays them, and it starts where songs start: structure. The claim of this chapter is that a song’s arrangement — who plays when, what patterns they play, how the sections stack up — is best written not as code that does things but as data that means things: plain lists and dictionaries that a simple loop walks through. The payoff lands immediately and compounds all book long: data can be diffed, swept in a loop, and eventually generated — and “double the quiet section and cut verse two” becomes a two-line edit you can hear ninety seconds later.
Three layers, three lifetimes
Arrangement information changes at three different speeds, so we keep it in three separate structures:
- Harmony — what chords the song uses (decided once per song)
- Patterns — what a player does within one bar (changes per section)
- Form — the order of sections (changes when the song changes shape)
Harmony: the chord table
CHORDS = [
{"name": "Am", "bass": 45, "gtr": [57, 60, 64, 69], "pad": [57, 64]},
{"name": "F", "bass": 41, "gtr": [53, 57, 60, 65], "pad": [53, 60]},
{"name": "G", "bass": 43, "gtr": [55, 59, 62, 67], "pad": [55, 62]},
{"name": "Em", "bass": 40, "gtr": [52, 55, 59, 64], "pad": [52, 59]},
]
(The numbers are MIDI note numbers — piano keys, where 60 is middle C and each step is one semitone.) Look at what this table encodes beyond “which chords”: a voicing per instrument. The guitar’s four notes sit mid-neck, where a guitarist’s hand would actually be. The pad gets only two notes — root and fifth — because a pad playing the chord’s third muddies the guitar playing the same third. The bass gets one note. All your knowledge about each instrument’s register lives here, written once.
The rest of the song asks only CHORDS[bar_number % 4] — the %
(remainder) makes the four chords cycle forever, bar after bar.
Patterns: one bar of behavior
# Verse bass: relentless eighth notes on the chord's root note, with a
# little pickup into the next bar. Each entry is:
# (position_in_bar_in_beats, length_in_beats, pitch_offset_in_semitones)
BASS_DRIVE = []
for i in range(7):
BASS_DRIVE.append((i * 0.5, 0.5, 0)) # seven eighth-notes on the root
BASS_DRIVE.append((3.5, 0.5, 7)) # last eighth jumps up a fifth
# Chorus bass: bouncing through the octave — the hook.
BASS_HOOK = [
(0.0, 0.5, 0), (0.5, 0.5, 0), (1.0, 0.5, 12), (1.5, 0.5, 0),
(2.0, 0.5, 10), (2.5, 0.5, 0), (3.0, 0.5, 7), (3.5, 0.5, 5),
]
The crucial trick is that patterns are relative: they say “the chord’s
bass note, plus 7 semitones,” never “the note A.” One pattern therefore
works over all four chords, and the verse writes itself for as many bars
as needed. The guitar’s pattern is even simpler — a list of indexes into
the chord’s guitar voicing ([0, 2, 1, 2, 3, 2, 1, 2] — a picking
pattern across the chord shape).
Longer thoughts — the lead melody — add a bar coordinate:
(bar, beat, length, note). At that scale the data is the melody,
visible and editable note by note. When the melody’s third phrase felt
crowded, the fix was deleting one tuple.
Form: the movements list
movements = [
dict(n_bars=4, bass="hook"), # bass opens alone
dict(n_bars=4, bass="drive", drums=True), # the machine locks in
dict(n_bars=16, bass="drive", drums=True, arp="muted"), # verse 1
dict(n_bars=16, bass="hook", drums=True, arp="open",
pad=True, lead=True), # chorus 1
dict(n_bars=8, bass="drive", drums=True, arp="muted"), # verse 2
dict(n_bars=16, bass="hook", drums=True, arp="open",
pad=True, lead=True), # chorus 2
dict(n_bars=8, arp="open", pad=True), # the quiet breakdown
dict(n_bars=16, bass="hook", drums=True, arp="open",
pad=True, lead=True), # final chorus
dict(n_bars=8, bass="drive", drums=True), # outro
]
Each movement is a cast list: which band members play, and in what
mode. The values pull double duty as pattern selectors — bass="drive"
versus bass="hook", guitar "muted" versus "open" — so a section’s
character and its lineup live in one dictionary.
Read the list top to bottom and you can see the song: the lone bass intro, the drum machine entering, verse/chorus alternation, the breakdown where the rhythm section drops out and the guitar hangs in the reverb, the full final statement, the outro that returns to the opening image. Arrangement craft — tension, release, symmetry — has become list-shaped, and that’s the chapter’s whole point: in a DAW, structure is implicit in a thousand clip positions; here it’s explicit in twenty lines you can read.
The walker
The code that turns the data into Chapter 2 note events is deliberately boring:
bar_cursor = 0 # running position, in bars
for m in movements:
for bar_i in range(m["n_bars"]):
bar_time = (bar_cursor + bar_i) * BAR # seconds
chord = CHORDS[bar_i % 4]
write_bar(bar_time, chord,
bass=m.get("bass"), # .get returns None/False
drums=m.get("drums", False), # if the key is absent —
arp=m.get("arp", False), # absent means "sits out"
pad=m.get("pad", False))
if m.get("lead"):
write_lead(bar_cursor, m["n_bars"])
bar_cursor += m["n_bars"]
write_bar dispatches to small per-instrument functions that walk a
pattern and emit notes. Every interesting decision already happened — in
the data. When something’s wrong with the song, you debug a list, not a
timeline.
One deliberate subtlety: bar_cursor accumulates as it goes, so no
section knows its absolute position in the song. That’s what makes
structural surgery free: insert a movement, delete one, or duplicate the
breakdown with movements[6:7] * 2 (Python list-repeat), and everything
downstream re-flows automatically.
When one song becomes twelve
At album scale, the movements list graduates into a named template. The synth album’s writer names its section types and lets each song’s spec pick a sequence:
sections = ["intro", "A", "A2", "breath", "B", "A3",
"breath", "B2", "A4", "outro"]
A sections carry the song’s hook at rising intensity (A2 adds the
arpeggio and percussion, A3 adds a bell doubling the melody an octave up);
B sections shift to a second chord progression; "breath" is two bars
of pads alone — the exhale between statements. Twelve songs share this
skeleton with different keys, tempos, melodies, and drum machines, and the
album coheres the way a band’s album coheres: same structural vocabulary,
different songs. Building that engine is Part III’s case study; the point
here is that it’s the same three-layer idea, one level up.
Practical notes
- Comments are part of the notation.
# breakdown: drums and bass cut, the guitar hangs in the reverb— the why layer. A change to the movements list plus its commit message is a complete record of a structural decision, which is Chapter 1’s producer’s-notebook promise coming true. - Resist formalizing. It’s tempting to turn movements into a class
with declared fields and validation. Every song I’ve written wanted one
field the previous song didn’t have. Dictionaries with
.get()defaults absorb that evolution for free — the walker just ignores keys it doesn’t know. Let structure emerge from use. - Tempo changes? This book’s songs run a fixed tempo (true to the
genre). If you need a slowdown, the change is contained:
BARstops being a constant and becomes a lookup from bar number to seconds — and nothing else changes, because every writer already thinks in bars.
The arrangement now specifies everything — and if you render it as written, it sounds programmed, because every note lands exactly where the data says. The next chapter is about ruining that perfection. Carefully. By a few milliseconds at a time.
Chapter 9 — Humanization: The Hands Drift, the Machine Doesn’t
Render Chapter 8’s arrangement exactly as written and you’ll get something technically perfect and emotionally dead: every note precisely on the grid, every volume exactly as specified, every bar a clone of the last. Real performances live in the flaws — the few milliseconds a bassist leans ahead of the beat, the extra weight of a fingertip on an accented note. This chapter adds those flaws with a random number generator. Its thesis: the craft is all in restraint. The randomness must be small, clipped, repeatable — and above all unevenly applied, because the contrast between what drifts and what doesn’t is where the life actually comes from.
The whole engine
rng = np.random.default_rng(SEED) # one seeded generator for the song
def human(t, vel, t_sd=0.005, v_sd=6):
"""Nudge a note's start time and velocity by a small random amount."""
nudge = rng.normal(0, t_sd) # bell-curve random
nudge = np.clip(nudge, -2.5 * t_sd, 2.5 * t_sd) # cut off the extremes
t = max(t + float(nudge), 0.0)
vel = int(np.clip(vel + rng.normal(0, v_sd), 1, 127))
return t, vel
Eight lines. Everything else in the chapter is about how to use them.
The sizes. rng.normal(0, t_sd) draws from a bell curve centered on
zero — most nudges tiny, a few larger — with a spread (t_sd) of 5
milliseconds. Is that a lot? At this song’s tempo an eighth note lasts 254
ms, so we’re moving notes by about two percent of their slot: felt, not
heard as sloppiness. Measured against real players, ±5 ms is a tight
bassist on a good night — we’re not simulating error, we’re simulating a
professional. Push past ±15 ms and the illusion flips: instead of a player
breathing, you hear a machine glitching.
The clip. Bell curves have tails — rare, large draws. One note in a
thousand would land 20+ ms early, and a single such note reads as a
mistake. np.clip(x, lo, hi) (clamp a value into a range) amputates the
tails while keeping the natural bell shape. The extra max(..., 0.0)
guards one absurd edge case: the very first note of the song drawing a
negative start time. Across an album, every possible random draw will
eventually be drawn; cheap paranoia is mandatory.
The seed. All randomness flows from one generator started with a fixed
SEED. So the “random” performance is a take in the fullest sense —
render 47 is note-for-note identical to render 1, and every listening
comparison in this book compares mixes, never accidentally-different
performances. The seed even becomes a musical control: if this
performance’s particular constellation of nudges bothers you in the second
chorus, SEED = 8 is a different take of the same song. I’ve re-rolled
a performance exactly twice across an album; both times the new take fixed
the bar in question, and it lives in version control now, where takes live.
The contrast principle
Here’s what separates this chapter from the “humanize” checkbox in a DAW. In the darkwave band, the jitter applies to the bass and the guitars — and not to the drum machine, and not to the synth pad. The script’s comment states the rule:
# Humanization: the hands drift, the machine doesn't.
Think about why. If everything drifts, the listener has no reference grid to feel the drift against — the whole song is just slightly loose. If nothing drifts, there’s no life. But a drum machine ticking immovably while a bassist pushes eighth-notes a few milliseconds hot against it? That tension is a sound — arguably the defining sound of every band that ever played alongside a drum machine, which is this genre’s entire family tree. The pad stays on the grid for a different reason: it’s texture, not performance. Nobody “plays” a pad.
So the rule generalizes: for each band member, decide — hands or machine — and commit. It’s a musical decision, and it changes per record: the synth album humanizes nearly everything (that album’s fiction is “played live onto tape”), the darkwave record humanizes exactly three players.
Three more dimensions
Velocity is tone, not just volume. Remember from Chapters 2 and 5 that good sample libraries switch recordings based on how hard a note is played. So ±6 of velocity jitter doesn’t make notes slightly louder/quieter — it varies the attack character hit by hit, which is most of what “a hand” sounds like. One discipline: write the accents into the base velocities first (downbeats 105, offbeats 96), then let noise perturb around them. Accents are composition; jitter is performance; don’t let the second wash out the first.
Duration. Real note lengths vary even more than start times — fingers release early, linger late:
dur = dur_beats * BEAT * 0.82 * rng.uniform(0.92, 1.08)
The 0.82 is articulation — this bassline is short-and-punchy by
intent. The ±8% (rng.uniform — evenly spread randomness, no bell curve
needed here) is the hand.
The byproducts — the best trick in the chapter. The most persuasive humanization isn’t jitter at all; it’s the sounds a performance makes besides the notes:
# Occasionally, a fret squeak as the hand shifts position between bars.
if rng.random() < 0.28: # 28% of bars
t, vel = human(bar_t + 3.45 * BEAT, 46, t_sd=0.03)
add_note("gtr", t, 0.3, FRET_NOISE_CHANNEL, 60, vel)
General MIDI instrument #120 — “Guitar Fret Noise,” the joke instrument of every 90s sound card — earns its existence here: a quiet squeak near the end of about a quarter of the guitar bars, placed where a real left hand shifts, with extra timing looseness because squeaks aren’t played, they happen. Listeners never consciously notice it. But mute that channel after a day of living with it, and the guitarist evaporates — what’s left is unmistakably a sequencer. The principle travels: model the byproducts, not just the product. Breath before a sung phrase, the scrape of a pick on a heavy downstroke, a piano’s pedal thump — every instrument has them, and each costs a conditional and a quiet note.
(Chapter 7’s lead voice was doing humanization too, by the way — the pitch slide between notes and the vibrato that fades in late are continuous human behaviors, where this chapter’s jitter is per-note. Melodic instruments carry their humanity between the notes.)
What NOT to humanize
A checklist, each entry paid for with a mistake:
- The drum machine — covered above. Note that even its volumes stay fixed: the real hardware had accent buttons, not touch sensitivity. A velocity-humanized LinnDrum is a category error.
- Notes of one chord, independently. A pad chord is one gesture; give its notes independent ±5 ms nudges and you get a sloppy flam, not warmth. If you want a strum, model a strum — a deliberate roll in one direction — not noise.
- Structure. Don’t randomize what gets played per render… which the fret squeak, at 28% per bar, seems to violate. The resolution: it’s drawn from the seeded generator, so the take includes its accidents, reproducibly. Randomness in content is fine when it’s committed to the take; what’s forbidden is randomness that escapes the seed and changes between renders.
One architectural note
human() runs when the events are written (Chapter 8’s walker), not
when audio renders. The event lists that reach the instruments are the
finished performance, fully decided. Consequence: stems, re-renders
through different amps, and every downstream experiment all share the
identical performance by construction. Humanization is the last thing
that happens to the score; nothing that happens to the sound ever
touches timing again.
The band now plays like a band. But it still sounds like six signals in a void — every player recorded direct into nothing. The next two chapters build what you can’t see in the arrangement: each member’s rig, and then the room they’re all standing in.
Chapter 10 — Every Band Member Gets a Rig
A band isn’t six instruments; it’s six rigs. The guitarist owns a chorus pedal, an amp with the volume parked exactly where pick attack decides between clean and dirty, and a delay pedal set to the song’s tempo. The bassist’s sound is a recording-studio trick older than most studios. This chapter builds each member’s signal chain out of Chapter 3’s plugin hosting, and its organizing idea is that a rig is knowledge written as a chain — the order of effects, how hard each stage drives the next, and the occasional parallel split are where genre actually lives. We’ll build the three rigs that carry the darkwave record, each number explained.
First, one unit of measure, since this chapter speaks it constantly:
audio levels are measured in decibels (dB) — a relative scale where
+6 dB is roughly “twice as loud a signal,” −6 dB half, and 0 dB “no
change.” Effect parameters like gain_db=-8 mean “turn this down to
about 40% of its level.”
The guitar rig: chorus into the amp, delay after
amp = load_nam(NAM_VST3, "Amps/Vox_AC15_TopBoost.nam")
amp.input_db = -3.0 # hold the amp just below break-up
dotted_8th = BEAT * 0.75 # a delay time locked to the tempo
gtr_fx = Pedalboard([
Chorus(rate_hz=0.8, depth=0.2, centre_delay_ms=12.0, mix=0.5),
amp,
Delay(delay_seconds=dotted_8th, feedback=0.28, mix=0.16),
Reverb(room_size=0.85, damping=0.45, wet_level=0.24, dry_level=0.76),
])
Four devices, four decisions:
Chorus placement. A chorus makes one signal sound like several slightly-detuned copies. Placed before the amp — on the floor, where this genre’s players put it — the amp distorts the shimmering signal, and the amp’s natural compression glues the wobble into the sound. Placed after the amp, it wobbles the finished distortion instead: wider, swooshier, more of an 80s-studio-rack move. Both are legitimate; only one is this record; the difference is the order of a Python list.
The amp’s one critical knob. input_db = -3.0 sets how hard the
guitar signal hits the amp — parked just under the point where the AC15
starts to growl. Result: each note’s velocity decides, note by note,
whether it stays clean or barks. Feel the system click together: Chapter
9’s velocity jitter only becomes audible because the amp sits at this
sensitive spot. Humanization and amp settings are one mechanism wearing
two chapters.
The tempo-locked delay. A delay repeats the signal like an echo.
BEAT * 0.75 — a “dotted eighth note” — is the post-punk echo: repeats
that land between the notes being played, weaving a single picked line
into perpetual motion. feedback=0.28 gives about two audible repeats;
mix=0.16 keeps them felt more than heard. The delay sits after the
amp because echoes of a distorted note stay distinct, while distorting an
echoing signal turns to mush.
A private reverb, small. This is the amp’s own halo, not the band’s shared room (that’s next chapter — the two coexist deliberately).
One trap for anyone using amp captures: some .nam files capture a full
rig — amplifier plus speaker cabinet plus microphone — and some capture
the amplifier head only. A guitar speaker cabinet acts as a brutal
treble filter; play a head-only capture without one and you get a fizzy
mess that sounds broken. Head-only captures need a cabinet simulation
after them (one Convolution effect loaded with a speaker recording —
next chapter explains the technique). Half the “this amp capture is bad”
complaints online are a missing cabinet.
The bass rig: the parallel split
The oldest trick in bass recording, as code:
from pedalboard import Mix, Chain, Gain, HighpassFilter, Compressor
dug = load_nam(NAM_VST3, "Amps/Tech21_dUg_BassPreamp.nam")
dug.input_db = -6.0
bass_fx = Pedalboard([
HighpassFilter(cutoff_frequency_hz=35), # remove sub-rumble
Mix([ # run BOTH in parallel:
Chain([Gain(gain_db=0.0)]), # path 1: untouched
Chain([dug, Gain(gain_db=-8.0)]), # path 2: distorted, quiet
]),
Compressor(threshold_db=-18, ratio=4, attack_ms=4, release_ms=90),
])
Mix splits the signal into branches, runs each Chain, and adds the
results — a parallel wiring, like a console’s routing.
Why split at all? Distortion is how a bass gets heard on small speakers — but distorting the bass directly makes it thinner, because distortion squashes the deep fundamental tone while adding buzz. So the studio trick: keep the clean low end untouched in one path, add a distorted copy underneath it — 8 dB down, reinforcement rather than replacement. On good speakers you mostly hear the clean path; on a phone speaker, the gritty path is the bass. That’s not compromise; that’s engineering for how people actually hear records.
Placement details that matter: the rumble filter comes before the split so both paths agree about the low end; the compressor comes after the split so it squeezes both paths together and they move as one instrument (its release time, 90 ms, is chosen to recover between eighth-notes at this tempo, so it breathes with the bassline). Compress before the split and the two paths’ dynamics drift apart on accented notes.
The others, briefly
Pad: slow chorus into a big reverb, nothing else — texture wants width and depth. On the synth album this is the entire pad sound; the script’s comment is six words of synth history: the Juno move: chorus makes the pad. Drums: the Chapter 6 compressor, and no reverb at all — their space comes exclusively from the shared room, because nothing exposes fake space like a drum kit in two rooms at once. The synth lead: the same dotted-eighth delay as the guitar — that delay is the genre’s signature, portable across instruments.
Across all of them, one pattern: two to four devices, each with a reason. A rig is not a place to accumulate insurance effects. When a four-device chain sounds wrong, commenting out devices one render at a time finds the culprit in minutes. A twelve-device chain, never.
Rigs are for sweeping
Because a rig is a data-shaped object, every knob is one for-loop away from being a controlled experiment — Chapter 1’s leverage argument, landing where it pays most:
# Which amp? (this is how the AC15 beat the Twin for this record)
for capture in glob.glob("Amps/*.nam"): # glob: list files by pattern
render_song_with(load_nam(NAM_VST3, capture))
# How hard into the amp?
for drive in (-9, -6, -3, 0, 3):
amp.input_db = drive
render_song_with(amp)
# Chorus in front of the amp, or behind?
for chain in ([chorus, amp, delay], [amp, chorus, delay]):
render_song_with(Pedalboard(chain + [reverb]))
Each render costs about a minute; each settles a question that studio folklore usually settles by argument.
Practical notes
- One plugin instance per chain. Plugins keep internal state; sharing one instance between two chains eventually misbehaves. Loading is cheap — load per rig.
- Fix “too hot” at the amp’s input, not in the mix. If a bus is
overdriving its amp, add a
Gainbefore the amp (that’s a volume pedal). Turning the bus down after the amp changes loudness but keeps the wrong tone — the classic mixup when there are no meters to look at. - Process the whole bus once, at the end. Render the complete performance dry, then run it through the rig in one call. Processing note-by-note would restart every delay and reverb per note — echoes that never repeat, reverbs that never bloom. The rig hears the performance, the way an amp does.
Six players, six rigs — still six separate rooms. The next chapter builds the one thing they’ll share, and it’s where the band finally sounds like it’s standing somewhere.
Chapter 11 — The Shared Room
Play any single track from Chapter 10 alone and it sounds finished. Add them together and something’s missing that no volume adjustment fixes: the players don’t sound like they’re anywhere — or worse, each rig’s private reverb puts each player somewhere different. Real records solve this with the oldest wiring on a mixing console: every channel sends a little of its signal to one shared reverb, and that reverb’s output sits quietly under the whole mix. This chapter builds it — about six lines of code with the highest sound-per-line ratio in the book — and it’s the chapter where the band walks into a building.
A room in a WAV file
The reverb we’ll use isn’t a simulation. It’s a recording of an actual room, applied by a technique called convolution.
Here’s the idea. Record one sharp clap in a room, and the recording captures everything the room does to sound: every wall reflection, the flutter, the decay tail. That recording is called an impulse response (IR) — and mathematics gives us an operation (convolution) that applies “what the room did to the clap” to any sound, as if it had been played there. It’s not an effect inspired by the room. It’s the room, factored out into a file.
You don’t implement convolution; pedalboard has it:
from pedalboard import Convolution
room = Convolution("IRs/Nice Drum Room.wav", mix=1.0)
Free, professionally recorded IRs are easy to find — Voxengo’s free pack
covers rooms, halls, and plates; this record uses its “Nice Drum Room” (a
tight, wooden, honest space). mix=1.0 means the output is only the
room sound — we’ll blend it with the dry band ourselves, next.
Why not use this for the rigs’ private reverbs too? Chapter 10’s
synthetic Reverb is smooth, adjustable, and idealized — right for an
amp’s halo. But the shared space is the one the listener’s ear
interrogates for realism, and there, a real room wins outright.
The send architecture
# The mix you already have (Chapter 10's processed buses, at their levels):
dry = bass + 0.95 * drums + 0.85 * gtr + 0.5 * pad
# Everyone plays into the same room — each at their own "send" level:
room_send = 0.7 * drums + 0.55 * gtr + 0.45 * pad + 0.25 * bass
room_wet = room(room_send.astype(np.float32), SR)
# Set the room's loudness RELATIVE to the band (explained below):
def rms(a): # "root mean square": a signal's
return np.sqrt((a ** 2).mean()) # average energy — how loud it
# feels, rather than its peak
room_wet *= 0.22 * rms(dry) / max(rms(room_wet), 1e-9)
master = dry + room_wet
This is a mixing console’s “aux send” wiring in four statements. Every coefficient is a decision:
Send levels are not mix levels. Look at the drums: 0.95 in the mix, but 0.7 into the room — the loudest send of any player — because in a real space, a drum kit excites the room like nothing else in a band. The room you hear on a record is mostly drum reflections, and it’s the first thing your ear checks for realism. Now look at the bass: 0.25, almost token. Low frequencies in a reverb turn instantly to mud, so engineers have always starved the reverb of bass. The guitar and pad sit between: enough to join the band, not enough to wash out.
The relative-loudness trick. That rms stanza is the mixing insight
of the chapter. The room’s absolute level is meaningless — it depends on
how loud the IR file was recorded, your send levels, the song’s density.
But its level relative to the band is a perceptual constant you can
reason about. The line scales the room to 22% of the band’s energy —
about 13 dB quieter — which lands at “clearly audible as air, never
audible as an effect.” And it stays there when you change the IR, the
sends, or the arrangement, with no re-balancing. (The synth album runs
0.14 — a drier, closer, tape-machine intimacy. One number is the room’s
entire personality.)
One space, singular. The temptation arrives quickly: shouldn’t the pad get a bigger room than the drums? That instinct produces exactly the disease this chapter cures — a band standing in several places at once. Private space is the rig’s business; the shared room is one room. If the genre wants a huge pad wash, enlarge the rig’s reverb and leave the band’s room alone.
Hear it
This chapter has the most dramatic one-line experiment in the book:
render master = dry (no room), render with the room, and listen on
headphones. Without: six excellent recordings sitting side by side in a
void. With: depth appears — the drums step back a foot, the guitar’s
echoes land on a wall, the band acquires a location. Nothing got louder
or brighter; the change is entirely in your ear’s model of space, which
is why no amount of per-track polish could substitute.
And run the second experiment, because it proves the shared part matters: give each player a private copy of the same IR (same total level) instead of one summed send. It sounds… thinner, less together — because when the send is summed, the room is excited by the ensemble: the kick and the bass note strike the space together and produce one combined reflection, not two independent ones. “Playing in the same room at the same time” is, physically, that summation. The sum is the point.
Choosing your room
Audition IRs like Chapter 6 auditions drum kits — a loop over the IR folder, same eight bars — but go in with priors:
- Small real rooms (½-second decay): glue. The default for any music that could plausibly be played by people in a club.
- Damped studios: presence with almost invisible decay — right when the record’s fiction is “tracked in a studio,” like the synth album.
- Halls and churches: gorgeous, and instantly a statement. Better used as a featured effect than as glue. (The post-rock records send to a room and a church as two separate returns — a lead melody soaked in the church is an event, not glue.)
- Plates and springs: not rooms at all — vintage studio hardware that happens to ship as IRs. Rig-tier tools.
One production reality: the IR’s recording quality is your ceiling — a noisy IR adds its noise to every note you send it. The reputable free packs (Voxengo, OpenAIR) are clean; audition anything else solo and loud before it meets your mix.
Practical notes
- Convolution is heavy math, and it doesn’t matter. A three-second room applied to a six-minute song is billions of multiplications; modern FFT-based convolution does it in seconds, offline. DAW users ration convolution reverbs to spare their CPUs during playback; we send fourteen tracks to two rooms without thinking. No deadline, no tax.
- The room’s output is a track of its own. When the next chapter
exports the song’s individual instrument files,
room_wetgets written out as its own file — “Room Reverb Return” — exactly like a console’s reverb channel. Whoever remixes the song later gets one fader that moves the whole band closer or further away. - A ghost trick for free. We computed the send from the buses after their mix volumes (turn a player down, their room echo follows — usually what you want). Compute a player’s send from the pre-volume bus instead and you get a player who is quiet in the band but huge in the room — a ghost at the back of the hall. Every console manual has a name for this switch; here it’s just which variable you multiply.
The band is in the room. All that remains is to press record properly: final levels, fades, and the export machinery that proves — with an actual number — that the files you hand the world recombine into the record you made. That number turns out to be −87.
Chapter 12 — Mix, Master, and the Proof in the Silence
The last chapter of Part II turns renders into records. It covers the final stage of the pipeline — balancing the band, the fade-in and fade-out, setting the overall loudness — and then the machinery this studio treats as a shipping requirement: exporting stems (one audio file per band member) that provably recombine into the finished master, verified by an automatic test on every render. It ends at the border crossing where script-land hands work to the rest of the audio world: a folder of WAVs that opens as a faithful session in any music software on earth.
The mix: five numbers
bass *= 1.0
drums *= 0.95
gtr *= 0.85
pad *= 0.5
dry = bass + drums + gtr + pad
master = dry + room_wet # the shared room, from Chapter 11
That’s the mix. Five multipliers. It looks impossibly few next to a DAW’s hundred-fader console — until you notice how much mixing already happened upstream, on purpose. Balance within each instrument happened in Chapter 2 (note velocities, per-channel panning). Tone happened in the rigs (Chapter 10 — the compressor is why the bass level stays put; the amp’s input knob is the guitar’s presence). Space happened on the sends (Chapter 11). By the time the buses meet, mixing has been reduced to its irreducible question — how loud is each member? — and five numbers answer it.
This layering is also your debugging map: when the balance feels wrong here, the cause is usually upstream — a rig too dark, a send too wet — and the architecture tells you where to look, instead of offering four hundred equally-plausible knobs.
Convention: the most important member gets 1.0 (here the bass — it’s that kind of record) so every other number reads as a relationship. Drums nearly equal; guitars a step behind; pad at half, as texture. The mix is legible as intent.
Fades and final loudness
n = master.shape[1] # total number of samples
env = np.ones(n, dtype=np.float32) # start with "multiply by 1"
fade_in, fade_out = int(0.05 * SR), int(5.0 * SR)
env[:fade_in] = np.linspace(0, 1, fade_in) # 50 ms ramp up
env[-fade_out:] = np.linspace(1, 0, fade_out) # 5 s ramp down
master *= env
peak = max(np.abs(master).max(), 1e-9) # the loudest single sample
master = master / peak * 0.9 # scale so the peak sits at 0.9
(np.linspace(a, b, n) makes n evenly spaced values from a to b —
our fade ramps.) The 50 ms fade-in guards against a click at the start;
the long fade-out is a musical decision that happens to live in the
mastering code. The final scaling — peak normalization — sets the
loudest sample to 0.9 rather than the maximum 1.0, leaving a little
headroom that MP3/AAC encoding will thank you for.
For an album, matching peaks isn’t enough: two songs with identical peaks can feel very different in loudness. The album scripts therefore match average energy (the RMS measure from last chapter), with the peak as a ceiling:
scale = min(0.055 / max(rms(mix), 1e-9), # loudness target
0.9 / max(np.abs(mix).max(), 1e-9)) # peak ceiling
mix *= scale
Whichever constraint is tighter wins. A song that would clip at the loudness target simply sits a hair quieter — the right direction to lose.
Stems: files that owe you silence
A stereo master is a studio with no multitrack tape. The moment your record needs other hands — yours in GarageBand, a collaborator’s, a mastering engineer’s — you need stems: one WAV per band member which, played together with all faders at zero adjustment, reproduce the master exactly.
That “exactly” is a testable claim, with a classic test: play the stems against the master with one of them inverted — or digitally, subtract — and listen to what’s left. If the stems are honest, the answer is silence. It’s called the null test, and making it pass requires three rules.
Rule 1: whatever happened to the master must happen to every stem. The fades and the normalization above were applied to the summed master — so apply the identical envelope and the identical scale factor to each stem:
buses = {"bass": bass, "drums": drums * 0.95, "gtr": gtr * 0.85,
"pad": pad * 0.5, "room_return": room_wet}
mix = sum(buses.values())
env = master_envelope(mix.shape[1])
norm = 0.9 / max(np.abs(mix * env).max(), 1e-9) # ONE number, from the mix
for name, bus in buses.items():
write_wav(f"stems/{name}.wav", bus * env * norm)
write_wav("master.wav", mix * env * norm)
Why does this work? Because multiplication distributes over addition —
grade-school algebra: (a + b) × n equals a×n + b×n. Each stem carries
its share of the master processing, and the sum reassembles perfectly.
The classic mistake is normalizing each stem to its own loudest peak —
which destroys the balance and the null in one stroke. One envelope, one
scale factor, computed from the mix, applied to everything.
(The fine print: this only works for linear operations — gains, fades, addition. A master-bus limiter or compressor is not linear — its behavior at each instant depends on the sum of everything — so it cannot be distributed onto stems. This pipeline deliberately has no master limiter; if yours does, follow the industry convention and export stems from before it, with a note saying so.)
Rule 2: the shared room is its own stem. Notice room_return in the
dictionary. The room belongs to everyone, so it ships as its own file —
like the reverb channel on a console. The tempting shortcut — folding a
share of the room into each instrument’s stem — fails twice: each stem
then carries reverb of the other instruments (unmixable), and the
person remixing loses the best fader they had: the one that moves the
whole band nearer or further.
Rule 3: make the machine check.
resid = master - sum_of_written_stems
resid_db = 20 * np.log10(max(rms(resid), 1e-12)) # convert to decibels
assert resid_db < -80, f"stems do not null: {resid_db:.1f} dB"
This runs on every export. The only bug it can catch is the only bug this system ever has — a change made to the master path and forgotten in the stem path — and it catches it at render time instead of in someone’s session next month.
For calibration: on a real fourteen-stem track from this pipeline, the leftover measures −87 dB relative to full scale — that’s the unavoidable rounding noise of 16-bit audio files themselves, about 68 dB below the music, beneath any possibility of hearing. The stems don’t approximately recombine. They are the mix.
The payoff: a one-command handoff
Why all this rigor? For what it makes possible:
open -a GarageBand stems/*.wav
One command (macOS — or drag the folder into any DAW anywhere), and GarageBand builds a session: one track per stem, and it plays back identical to the shipped master, because the stems null. From there it’s a human mixing session with everything a script can’t do — riding the lead by feel through the bridge, pulling the room down for one verse, a collaborator’s taste layered on yours.
This is Part II’s closing argument, so let me say it plainly. Code-first production is not a claim that fader instincts are obsolete. It’s a claim about which decisions belong to which medium: placement, performance, rigs, and space are better expressed as reproducible, diffable, loop-sweepable code — while final taste is better expressed by ears on faders in real time. Honest stems are the treaty line between those two worlds, and the null test is what keeps the treaty honest. The pipeline hands over a perfect session; what comes back is the five percent only a human can add — and because the handoff is lossless, that five percent cost nothing to acquire.
Practical notes
- Check for silent stems too. The null test passes if a bus is accidentally silent in both the master and its stem (0 − 0 = 0). A second assertion — every stem’s peak is nonzero — catches the other bug. Two checks, two different failure modes.
- Name for the sort order.
01 Bass.wav,02 Drums.wav, … — DAWs sort by filename, so the numbers arrange the session like a sensible console, returns last. - Exact lengths. Every stem is padded/trimmed to exactly the master’s length. DAWs align files by their starting edge; a stem one buffer short becomes a smeared ending and a confused collaborator.
Part II is done: the band plays (8), like humans (9), through their rigs (10), in one room (11), onto tape anyone can remix (12). Part III makes records with it — an album engine, an honest chapter about AI, and shipping. But before turning the page: change one number in a movements list, render, and listen. That loop is the whole point, and it’s yours now.
Chapter 13 — Case Study: Twelve Songs, One Script
Everything in this book so far has been machinery. This chapter is the record. We’re going to walk through the actual program that produced Sign-Off — a twelve-track album of nostalgic 1983-flavored synth music, rendered start to finish by one command:
python3 generate_album_sign_off.py
# 01 Test Pattern.wav: 2.9 min
# 02 Sodium Lights.wav: 2.8 min
# ...
# 12 Sign-Off.wav: 3.4 min
# Album complete: Sign-Off
The interesting question isn’t whether that works — you’ve now seen every piece it’s built from. The question is a design question: when one song becomes twelve, what stays code and what becomes data? Get that split right and an album is twelve entries in a list. Get it wrong and it’s twelve copied-and-diverged scripts you’ll never dare touch again.
The split: an engine and twelve specs
The script has two halves. The engine — the Synth voices from
Chapter 7, the drum kits from Chapter 6, a TrackWriter class that knows
the shared song form — is written once, about four hundred lines. The
album is a list of twelve dictionaries, one per song. Here’s track 6,
complete:
dict(num=6, title="Drive Home, 1983",
root=1, mode="aeo", bpm=88, # key of C#, minor-ish mode, tempo
kit="TR-808", beat_style="pulse", # which drum machine, which beat
arp=True, lead="lead", # arpeggio on; melodic lead voice
long=True, # extended form (three B sections)
wobble=0.9, # tape-wow depth (Chapter 7)
prog_a=[0, 6, 5, 4], # verse chord progression
prog_b=[3, 6, 0, 4], # bridge progression
hook=[(0,0,2,4), (0,2,1,4), (0,3,1,5), ...], # the melody itself
hook_b=[(0,0,3,9), (0,3,1,8), ...]), # the bridge melody
Read it like a session sheet: everything a producer would say out loud about a song — key, tempo, drum machine, mood, the tune — and nothing else. Every number here is a musical decision. Everything mechanical (how a pad voices a chord, how the drum pattern breathes, how sections sequence) lives in the engine, decided once for the whole record.
That’s the design rule this chapter exists to teach: the spec should read like intent. When you’re deciding whether something belongs in the spec or the engine, ask whether two songs would ever want different values. Tempo? Obviously per-song. The rule that A-sections escalate their instrumentation? That’s the album’s identity — engine.
Writing melodies as data
The hook is the actual melody, in the (bar, beat, length, scale_degree)
tuples of Chapter 8. One decision made these humanly writable: the pitch
numbers are scale degrees, not notes — 0 is “the home note of the
key,” 4 is “the fifth step up the scale,” and the engine translates
through the song’s key and mode:
MODES = {"ion": [0, 2, 4, 5, 7, 9, 11], # major
"dor": [0, 2, 3, 5, 7, 9, 10], # minor with a hopeful lift
"aeo": [0, 2, 3, 5, 7, 8, 10]} # natural minor: melancholy
def deg(self, degree, base_oct=5):
"""Scale degree -> MIDI note, in this song's key and mode."""
iv = MODES[self.s["mode"]]
return 12 * (base_oct + degree // 7) + self.s["root"] + iv[degree % 7]
(// is whole-number division — every seven degrees climbs an octave;
% wraps within the scale.) Two consequences. Melodies are portable:
the same hook data works in any key, so hooks can be sketched without
committing to a key. And melodies are safe: a scale degree cannot be
out of key, so every experiment stays musical. The chord progressions
(prog_a=[0, 6, 5, 4]) are scale degrees too — the engine builds each
chord by stacking alternate scale steps on the degree.
Where did the hooks come from? A keyboard and a notebook, honestly — I hummed, found it on keys, and transcribed into tuples. The engine doesn’t compose. It performs. (Whether a machine should compose is Chapter 14’s question.)
The form, escalating
Chapter 8 promised the named-form template; here it is earning an album:
sections = ["intro", "A", "A2", "breath", "B", "A3",
"breath", "B2", "A4", "outro"]
if spec["long"]: # three songs stretch out
sections = ["intro", "A", "A2", "breath", "B", "A3", "breath",
"B2", "A4", "breath", "B3", "A5", "outro"]
Every song on the record walks one of these two shapes. A sections carry
the hook at escalating intensity — level 1 is pads, bass, and melody;
level 2 adds the arpeggio and percussion; level 3 doubles the melody with
the FM bell an octave up, the record’s “fullest” gesture. B sections
shift to the second progression, where the melody either rests or answers
(hook_b). breath is two bars of pads alone.
Twelve songs on two skeletons could feel like a template showing through. It doesn’t, and it’s worth being precise about why: the skeleton is shared but every surface differs — key, mode, tempo (56 to 88 BPM), drum machine (five different real boxes), beat style, wobble depth, and above all the melodies. That’s the same reason a band’s album coheres: the same people, habits, and instincts applied to twelve different songs. The form template is the band’s habits, formalized.
The specs also encode the album’s dramaturgy. Track 3 (“Rabbit Ears”)
and track 7 (“Static Bloom”) have kit=None — no drums at all, pure
interludes. Their lead="bell" swaps the melodic voice for the FM bell.
“Vertical Hold” runs wobble=2.2, the queasiest tape on the record,
exactly where an album needs its unease. The final track is the slowest,
in the saddest mode, with hook_b sinking instead of rising — a sign-off
in the data itself.
Production details that make it an album
Per-song seeds. Each song’s random generator starts at 500 +
track_number — so every song has its own reproducible performance, and
re-rendering track 9 alone never changes track 4.
Loudness matching. Chapter 12’s RMS-target normalization runs on every track with the same target. Twelve songs, one perceived level — the thing that makes a folder of WAVs feel like a record on shuffle.
One shared room. Every track sends to the same damped-studio impulse response at the same relative level. Same band, same room, all night.
The bounded batch loop. The engine over-allocates each song’s audio buffers from a generous bar estimate, builds the song, then trims to the bars actually used. Boring — and the reason adding a thirteenth track is appending a dict, rendering, and walking away.
The whole album renders in a few minutes. The writing took the time writing takes: evenings of hook-sketching, kit auditions (Chapter 6’s harness, once per song), wobble tastings, listening in the car. The pipeline never made the record fast. It made every decision cheap to revisit, which is a different and better gift.
What I’d tell you to steal
If you build one album engine, steal these four choices in order: specs that read like intent; melodies as scale degrees, not notes; a named form with an intensity dial per section; per-song seeds under one loudness target. Those four are the difference between “a script that made twelve WAVs” and “a record.”
What’s deliberately not here is a machine that writes the tunes. That absence is a choice, and the next chapter is about making it honestly — because there’s plenty of AI in this studio’s story, just not where people assume.
Chapter 14 — AI in the Loop, Honestly
This is the chapter people expect this book to be, so let’s clear the air first: nothing you’ve built in the last thirteen chapters is generative AI. The pipeline is deterministic code playing instruments — closer to a player piano than to a music-generating model. The one neural network in the signal path (the amp captures of Chapter 3) is a network imitating a circuit, not composing anything.
But AI music generators exist, they’re getting good, and a code-first studio is the best imaginable harness for them — precisely because it treats them as one more instrument instead of a magician. This chapter is how I actually use them, where they’ve earned a place on records, where they haven’t, and the licensing reality nobody puts in the demo video.
The right mental slot: texture, not composer
The generative music services fall into two rough families. Vocal song-makers (Suno, Udio) produce complete sung songs from a text prompt. Instrumental engines (Google’s Lyria, and others) stream instrumental audio matching a described style. Both share a property that matters enormously to this book’s philosophy: you can’t diff a prompt. Ask twice, get two different songs; there’s no take 47, no one-line amp swap, no null test. Everything Part II built — reproducibility, controlled experiments, honest stems — evaporates inside a generation.
So the slot this studio gives generative audio is the slot it gives any other recorded material: it’s a sample source. Generate, audition, keep the minutes that work, and from then on treat the audio as a fixed recording — placed on a bus, processed through rigs, mixed under the same rules as everything else. The moment generated audio becomes a file in version control, determinism is restored: the take is committed, even if the taker was a model.
Where it actually earns a place: beds
The technique that’s produced real keepers for me is the bed: a long, low, textural layer under a composed track. Ambient washes, room-tone drones, distant-city atmospheres — material that needs to be organic and unrepetitive for minutes at a stretch, which is exactly what’s tedious to synthesize and exactly what generative models exude effortlessly.
The workflow, concretely:
- Generate long, keep little. Ask for several minutes of “distant ambient wash, no melody, no percussion” — then audition like a documentarian, clipping the thirty-second stretches that breathe right. The prompt matters less than the curation.
- Demote it to a bed. High-pass it (the pipeline owns the low end), darken it, and mix it 15–20 dB under the band — felt, not heard, exactly like Chapter 7’s tape hiss but with weather inside it.
- Send it to the shared room like everyone else, so it lives in the same space as the band.
One of the post-rock records started life exactly this way: an ambient generation I liked became the bed, and the composed band — bass, drums, guitars, all the deterministic machinery of this book — was arranged over it, in its key, at its tempo. Demo-as-bed: the generation is the canvas, never the painting.
A practical note if you use a streaming service like Lyria: session
limits exist (mine capped around two and a half minutes per request), and
the workaround is the oldest tape edit there is — generate overlapping
chunks and crossfade them: fade one out while the next fades in over
a second or two of overlap. Twenty lines with NumPy’s linspace ramps,
and the seams disappear into texture.
Where it hasn’t earned a place
Melody and structure. Not for capability reasons — the models can write tunes — but for authorship ones. The hooks on my records are the part I’d play someone at a party. Outsourcing them felt, when I tried it, like hiring someone to have the fun. Your line may sit elsewhere; the architecture genuinely doesn’t care. What I’d defend universally is the shape: whatever the model contributes should enter as auditioned, committed material under your structure — because “the model made something and I kept it” is a take, while “the model decides at render time” is a slot machine.
Anything that must recur. A chorus that returns three times must be the same chorus — identity is what makes it a chorus. Generation can’t promise identity; data can.
Mixing and mastering. The temptation is real (“AI mastering” is a whole industry), but Part II’s argument applies: these are decisions you want legible. A limiter you can read beats a black box you can’t ask.
The licensing reality
Here’s the section to reread before you ship anything. As of this writing — and this area moves fast, so verify everything against current terms:
- Commercial rights to generated audio vary by service and by subscription tier. Free tiers frequently grant no commercial use. Paid tiers typically grant broad use of your generations, with varying fine print about exclusivity and training data. Read the terms of the tier you actually pay for, on the day you ship.
- Copyright status is genuinely unsettled. Several jurisdictions currently decline copyright protection for purely machine-generated work. Practically: a record that’s 95% your composition with a generated texture bed under it is a very different object from a record that is a generation — in protectability, and in conscience.
- Distributors and platforms increasingly ask. Expect “was AI used in this recording?” checkboxes, AI-content flags, and stores that reject or segregate fully-generated music. Answer honestly; the metadata trail matters more every year. A disclosure like “contains ambient textures generated with [service], all composition and production by the artist” costs nothing and ages well.
- Keep receipts. Save the prompt, the raw generation, and the date alongside the project. If a rights question ever arrives, “here is exactly what the model contributed” is the answer that ends the conversation.
The honest summary
The generative services are astonishing and mostly aimed at people who don’t want to make the thousand decisions this book is about. You do — that’s why you’re on chapter fourteen. So use the models the way this studio uses everything: as an instrument with a job description. Auditioned in, committed to git, processed like any player, mixed like any bus. AI in the loop — never as the loop.
One chapter left: the record is rendered, the stems null, the covers are done. Time to put it where people can hear it.
Chapter 15 — Shipping It
The album renders. The stems null. There’s a folder on your drive that sounds like a record. The last mile — getting it onto the platforms where music lives — is the least technical chapter in this book and the one where code-first production quietly hands you a few last advantages. It’s also where expectations need calibrating, so let’s do both.
Prepare the release like a build artifact
Treat “the release” as one more deterministic output: a folder your script assembles, complete and self-describing.
Audio. Distributors want WAV or FLAC, 16-bit/44.1 kHz minimum — which is exactly what the pipeline writes. Two checks worth automating into an export script, since you’ll release more than once:
assert peak_db(track) <= -0.9, "leave true-peak headroom for lossy encoding"
assert abs(rms_db(track) - album_target) < 1.0, "album loudness consistency"
Streaming platforms normalize loudness on playback (turning hot masters down), so the war is over; don’t crush your dynamics chasing volume. Chapter 12’s RMS-matched masters land comfortably in the modern norm.
Metadata is data — your favorite kind. Title, artist, track numbers,
year, genre, cover art: embed it in the files themselves rather than
trusting web forms. The mutagen library tags WAV/FLAC/MP3 in a dozen
lines, driven by the same specs list that rendered the album — one
source of truth from TRACKS[5]["title"] all the way to the phone
screen. Cover art wants 3000×3000 JPEG/PNG, RGB, under platform size
caps; text must be legible at thumbnail size (the covers this book’s
albums use were themselves rendered by script — of course they were).
Provenance. Chapter 14’s receipts, plus the git tag of the exact
commit that rendered the shipped masters: git tag album-sign-off-v1.
A year from now, “which code made the record” has one answer.
Where to put it
Three channels, three jobs — do all three:
A distributor (DistroKid, TuneCore, CD Baby and similar) is the toll booth to Spotify, Apple Music, and every other streaming service. Costs are modest (annual-fee or per-release models); upload the folder you just assembled, and allow a week or two of lead time before your release date. This is the discoverability channel — expect streams, not income; per-stream rates are fractions of a cent.
Bandcamp is the income and audience channel: fans pay real money for downloads, you set prices, and buyers become an email list you own. Instrumental niche genres — exactly what this pipeline makes — do disproportionately well there. Upload lossless; offer the full album at a fair price and let singles float.
Your own site is the sovereignty channel. You already run one (if you’ve been following the blog thread of this book’s story): a page per album, streaming embeds, a link to Bandcamp. Platforms change terms; your domain doesn’t.
The AI question, at upload time. Distributors and platforms increasingly ask whether AI was involved, and some stores flag or filter accordingly. Answer truthfully per Chapter 14’s disclosure line. A pipeline like this book’s — composed by you, performed by code, with at most a generated texture bed — sits firmly on the “made by a person with tools” side of every current policy, and saying exactly that, plainly, is both honest and good positioning.
Calibrating expectations (the kind part)
Releasing instrumental music with no audience is planting a tree: the first season’s numbers are compost. Streams will be small; that’s not a verdict on the music. The realistic assets a release creates are:
- A finished, public body of work — the difference between “I have scripts” and “I make records,” visible to anyone you ever show.
- A Bandcamp page where the first fifty true fans can pay you.
- Proof of pipeline — the next record costs a fraction of this one, and the one after that less still. Catalogs compound; singles don’t.
Release on a cadence the pipeline makes almost free (a single every few weeks beats an album-shaped silence), put each one somewhere a human might hear it — a subreddit that matches the genre, a newsletter, the blog — and let the catalog do the slow work catalogs do.
The last render
Here’s the whole system, one final time, as a shipping checklist:
compose specs + hooks in git (Chapters 8, 13)
perform seeded humanized events (Chapter 9)
sound six loaders, six rigs (Chapters 2–7, 10)
place one shared room (Chapter 11)
prove stems null at -87 dB (Chapter 12)
package tagged, loudness-matched WAVs (this chapter)
ship distributor + Bandcamp + site
tag git tag album-vN — the record IS the commit
Every line is a script you now have. The first time through, this list took me months of lost afternoons — that was the tuition this book refunds. The second time through, it’s an evening’s work surrounding the only part that was ever the point: writing the songs.
Somewhere back in Chapter 1, I promised that once a song is a program, everything composes. Here’s the compounding truth that promise matures into: a catalog is a codebase. Every rig you build, every room you measure, every form you name is available to every future record, and each album teaches the next one. DAW projects end; codebases grow.
Go write track thirteen. The band’s already warmed up.
— end of Part III —
Appendices: A — per-OS setup; B — the EXS binary format reference; C — VST3 preset chunk anatomy; D — General MIDI program numbers; E — free resources (soundfonts, amp captures, IRs, drum machines).
Appendix A — Setup, Per Operating System
Every install command in the book, collected in one place. Everything in this appendix runs in the book’s continuous-integration harness on clean Ubuntu, Windows, and macOS machines — these are tested instructions, not remembered ones.
The Python packages (all platforms)
pip install pedalboard pyfluidsynth numpy scipy mido
# optional extras used in later chapters:
pip install mutagen # metadata tagging (Chapter 15)
Python 3.9 or newer. All five core packages install from pre-built wheels on all three OSes — no compilers needed.
FluidSynth (Chapter 2)
macOS
brew install fluid-synth
If import fluidsynth then fails with “Couldn’t find the FluidSynth
library,” add the loader shim before the import (from the Sign-Off
album script, where it ships in production):
import ctypes.util, os
_orig_find_library = ctypes.util.find_library
def _find_library(name):
found = _orig_find_library(name)
if not found and name == "fluidsynth":
path = "/opt/homebrew/lib/libfluidsynth.dylib"
if os.path.exists(path):
return path
return found
ctypes.util.find_library = _find_library
Linux (Debian/Ubuntu)
sudo apt-get install fluidsynth libfluidsynth-dev fluid-soundfont-gm
The third package installs a General MIDI soundfont at
/usr/share/sounds/sf2/FluidR3_GM.sf2 — usable immediately.
Windows
choco install fluidsynth
Verified on clean machines: the chocolatey install works with no further
configuration (pyfluidsynth special-cases its install directory,
C:\tools\fluidsynth\bin). If you installed FluidSynth anywhere else,
register the directory both ways before importing — find_library only
searches PATH, while the DLL’s own dependencies resolve via
add_dll_directory:
import os
os.add_dll_directory(r"C:\path\to\fluidsynth\bin")
os.environ["PATH"] = r"C:\path\to\fluidsynth\bin;" + os.environ["PATH"]
Plugin folders (Chapter 3)
load_plugin needs a path; installers put plugins here:
| Platform | System-wide | Per-user |
|---|---|---|
| macOS VST3 | /Library/Audio/Plug-Ins/VST3 |
~/Library/Audio/Plug-Ins/VST3 |
| macOS Audio Unit | /Library/Audio/Plug-Ins/Components |
~/Library/Audio/Plug-Ins/Components |
| Windows VST3 | C:\Program Files\Common Files\VST3 |
— |
| Linux VST3 | /usr/lib/vst3 |
~/.vst3 |
The Intel-Python rescue environment (Chapter 4, macOS only)
For Intel-only plugins on Apple Silicon:
arch -x86_64 /usr/bin/python3 -m venv ~/.venvs/x86-audio
arch -x86_64 ~/.venvs/x86-audio/bin/pip install pedalboard mido numpy scipy
Invoke helpers with subprocess.run(["arch", "-x86_64",
"~/.venvs/x86-audio/bin/python", "helper.py", ...]).
Windows plugins on Linux (Chapter 4)
# Arch: yay -S yabridge yabridgectl wine-staging
# (see the yabridge README for apt/dnf equivalents)
yabridgectl add "$HOME/.wine/drive_c/Program Files/Common Files/VST3"
yabridgectl sync # bridged plugins appear in ~/.vst3
32-bit plugins on Windows (Chapter 4)
Install a 32-bit Python from python.org alongside your 64-bit one, then run the same helper script under it:
subprocess.run([r"C:\Python311-32\python.exe", "render_helper.py",
"notes.json", "out.wav"])
Format conversion tools (Chapters 5–6)
- macOS:
afconvertis built in.afconvert -f WAVE -d LEI16@44100 in.caf out.wav - Windows/Linux:
ffmpeg -i in.caf out.wav(install via choco/apt/brew).
Sanity check
After setup, this five-liner proves the two load-bearing pieces work:
import numpy as np, fluidsynth # (shim first on macOS)
from pedalboard import Pedalboard, Chorus
noise = np.random.default_rng(0).uniform(-0.3, 0.3, (2, 44100)).astype("float32")
out = Pedalboard([Chorus()])(noise, 44100)
print("pedalboard OK:", out.shape, "— fluidsynth OK:", fluidsynth.Synth() is not None)
Appendix B — The EXS Format Reference
The reverse-engineered layout of Apple’s EXS24 sampler instrument format
(.exs files), as used by Chapter 5’s parser and extractor. This is, to
my knowledge, the only written reference for these offsets; it was
recovered by differential hex-dumping and validated against the entire
GarageBand factory library.
File structure
An .exs file is a flat sequence of chunks. No file header — the first
chunk starts at byte 0. All integers are little-endian.
Chunk header (84 bytes)
| offset | size | field |
|---|---|---|
| 0 | 4 | flags. Chunk kind is in the low nibble of byte 3 (data[pos+3] & 0x0F); high bits flag name presence |
| 4 | 4 | payload size in bytes (u32) — the header’s 84 bytes not included |
| 8 | 4 | chunk id (u32) |
| 12 | 4 | padding |
| 16 | 4 | magic: ASCII TBOS |
| 20 | 64 | chunk name: ASCII, NUL-terminated, zero-padded |
The next chunk begins at pos + 84 + size. Parse defensively: some
factory files carry padding between chunks; if the magic isn’t at the
expected position, advance one byte at a time until TBOS reappears.
Chunk kinds
| kind | meaning |
|---|---|
0x01 |
zone — one playable region |
0x02 |
group — articulation/bank name (zones reference by index, in file order) |
0x03 |
sample — audio file reference (indexed in file order) |
Zone payload (kind 0x01, ≥ 96 bytes)
| payload offset | size | field |
|---|---|---|
| 1 | 1 | root note (MIDI number — the pitch the sample was recorded at) |
| 6 | 1 | key range low |
| 7 | 1 | key range high |
| 9 | 1 | velocity range low |
| 10 | 1 | velocity range high |
| 12 | 4 | sample start, in sample frames, into the referenced audio file (u32) |
| 16 | 4 | sample end, in frames (u32) |
| 88 | 4 | group index (u32) |
| 92 | 4 | sample index (u32) |
The frame-exact start/end offsets are the treasure: they let an extractor
cut individual hits out of consolidated .caf containers with no
transient detection and no guesswork.
Zones can also carry sustain-loop metadata (not used by this book’s decay-and-release playback model) — relevant if you extend the sampler to looped synth-style patches.
Field-tested wrinkles
Name truncation. The 64-byte name field truncates long sample
filenames to the pattern prefix#HASH.ext. When the literal name isn’t
found on disk, fuzzy-match the prefix:
find <sample root> -name "prefix*.ext" — unique in practice across the
factory library.
Locating the audio. Search in this order: (1) the instrument’s own
directory; (2) the mirrored path with Sampler Instruments replaced by
EXS Factory Samples (Logic’s layout); (3) a filesystem search of the
factory-samples root.
Where the files live.
/Library/Application Support/GarageBand/Instrument Library/Sampler/
├── Sampler Instruments/ .exs files
└── Sampler Files/ audio (often *_consolidated.caf)
/Library/Application Support/Logic/
├── Sampler Instruments/
└── EXS Factory Samples/
The extraction manifest
Chapter 5’s extractor writes each zone’s metadata plus its extracted
filename to manifest.json — that file, not the WAV names, is the
interface every downstream consumer (the ExsSampler) reads:
{"name": "Kick soft", "root": 36, "keylo": 36, "keyhi": 36,
"vello": 1, "velhi": 79, "start": 0, "end": 44100,
"group": "hits", "file": "note036_v001-079_000.wav"}
Legal reminder
Apple’s license permits using factory content in your own musical compositions, commercial ones included. It does not permit redistributing the samples as samples. This reference describes the format; what you extract stays in your own productions.
Appendix C — VST3 Preset Anatomy
The byte-level reference behind Chapter 3’s load_nam — for when you
need to set plugin state that isn’t exposed as a parameter, and the
save-a-preset-once workflow isn’t enough because you want to swap files
programmatically.
The .vstpreset container
What plugin.preset_data hands you is a VST3 preset: a container with a
header, one or more state chunks, and a trailing index.
| offset | size | field |
|---|---|---|
| 0 | 40 | header (magic VST3, version, the plugin’s class ID) |
| 40 | 8 | offset of the chunk index (i64) — everything between 48 and this offset is chunk data |
| 48 | … | chunk data — for a simple plugin, one Comp (component state) chunk |
| index offset | … | the index: ASCII List, a chunk count (i32), then one 20-byte entry per chunk |
Each 20-byte index entry: a 4-byte chunk ID (Comp = the plugin’s own
serialized state; Cont = controller/UI state), then offset (i64) and
size (i64).
The rule that bites: if you change the length of a chunk, you must rebuild the index — every offset and size must match reality, or the plugin silently ignores the entire preset. No error. Default sound. When byte surgery “doesn’t work,” check the bookkeeping before the splice.
Inside the component chunk
The layout belongs to whatever framework the plugin was built with. The two you’ll actually meet:
iPlug2 plugins (Neural Amp Modeler and family)
A marker string, then length-prefixed fields, then parameter values:
###PluginName### ASCII marker
[i32 length][version string]
[i32 length][file path 1] <- NAM: the .nam model path
[i32 length][file path 2] <- NAM: the impulse-response path
[parameter doubles...]
“Length-prefixed” = a 4-byte count followed by exactly that many bytes of
text. To swap a file: find the marker, skip fields to the one you want,
splice in your path with a corrected length prefix, rebuild the index.
Chapter 3’s load_nam is the complete worked implementation.
JUCE plugins (a large fraction of everything else)
JUCE’s convention serializes state as an XML document. Search the
component chunk for <?xml — if found, the state is readable text, often
self-describing (<PARAM id="filePath" value="/old/path"/>), and you can
edit it as a string, re-encode, and rebuild the index. Considerably
friendlier than offsets.
Cracking an unknown plugin
The universal method, no documentation required:
- Load the plugin, save
bytes(plugin.preset_data)to a file. - Change exactly one thing (via
plugin.show_editor()— usually the file picker) and save again. - Diff the two files byte by byte.
The differing region is the field you care about, and the bytes immediately around it reveal the convention — a length prefix that changed with the path length, an XML attribute, a fixed-width slot. A third save with a path of a very different length disambiguates the length-prefix question immediately.
a = open("preset_ac15.bin", "rb").read()
b = open("preset_twin.bin", "rb").read()
for i, (x, y) in enumerate(zip(a, b)):
if x != y:
print(f"first difference at byte {i}")
print(a[max(0, i-24):i+40])
print(b[max(0, i-24):i+40])
break
When not to bother
If the file choice never changes at render time, skip all of this: the
save-once workflow (Chapter 3, “the easy way”) — configure in the
editor, persist preset_data to disk, reload the bytes forever — is
robust, format-agnostic, and survives plugin updates better than offset
arithmetic. Byte surgery is for the for-loop: rendering the same
performance through six amps, which is where this studio’s whole argument
lives.
Appendix D — General MIDI Program Numbers
The 128 instruments every GM soundfont provides, in the standard sixteen
families. Programs are numbered 0–127 here, matching
fs.program_select(channel, sfid, bank, program) — some references list
them 1–128; if your soundfont seems off by one instrument, that’s why.
Bolded entries are the ones this book’s records actually use.
| # | Piano | # | Chromatic Percussion |
|---|---|---|---|
| 0 | Acoustic Grand Piano | 8 | Celesta |
| 1 | Bright Acoustic Piano | 9 | Glockenspiel |
| 2 | Electric Grand Piano | 10 | Music Box |
| 3 | Honky-tonk Piano | 11 | Vibraphone |
| 4 | Electric Piano 1 | 12 | Marimba |
| 5 | Electric Piano 2 | 13 | Xylophone |
| 6 | Harpsichord | 14 | Tubular Bells |
| 7 | Clavinet | 15 | Dulcimer |
| # | Organ | # | Guitar |
|---|---|---|---|
| 16 | Drawbar Organ | 24 | Acoustic Guitar (nylon) |
| 17 | Percussive Organ | 25 | Acoustic Guitar (steel) |
| 18 | Rock Organ | 26 | Electric Guitar (jazz) |
| 19 | Church Organ | 27 | Electric Guitar (clean) |
| 20 | Reed Organ | 28 | Electric Guitar (muted) |
| 21 | Accordion | 29 | Overdriven Guitar |
| 22 | Harmonica | 30 | Distortion Guitar |
| 23 | Tango Accordion | 31 | Guitar Harmonics |
| # | Bass | # | Strings |
|---|---|---|---|
| 32 | Acoustic Bass | 40 | Violin |
| 33 | Electric Bass (finger) | 41 | Viola |
| 34 | Electric Bass (pick) | 42 | Cello |
| 35 | Fretless Bass | 43 | Contrabass |
| 36 | Slap Bass 1 | 44 | Tremolo Strings |
| 37 | Slap Bass 2 | 45 | Pizzicato Strings |
| 38 | Synth Bass 1 | 46 | Orchestral Harp |
| 39 | Synth Bass 2 | 47 | Timpani |
| # | Ensemble | # | Brass |
|---|---|---|---|
| 48 | String Ensemble 1 | 56 | Trumpet |
| 49 | String Ensemble 2 | 57 | Trombone |
| 50 | Synth Strings 1 | 58 | Tuba |
| 51 | Synth Strings 2 | 59 | Muted Trumpet |
| 52 | Choir Aahs | 60 | French Horn |
| 53 | Voice Oohs | 61 | Brass Section |
| 54 | Synth Choir | 62 | Synth Brass 1 |
| 55 | Orchestra Hit | 63 | Synth Brass 2 |
| # | Reed | # | Pipe |
|---|---|---|---|
| 64 | Soprano Sax | 72 | Piccolo |
| 65 | Alto Sax | 73 | Flute |
| 66 | Tenor Sax | 74 | Recorder |
| 67 | Baritone Sax | 75 | Pan Flute |
| 68 | Oboe | 76 | Blown Bottle |
| 69 | English Horn | 77 | Shakuhachi |
| 70 | Bassoon | 78 | Whistle |
| 71 | Clarinet | 79 | Ocarina |
| # | Synth Lead | # | Synth Pad |
|---|---|---|---|
| 80 | Lead 1 (square) | 88 | Pad 1 (new age) |
| 81 | Lead 2 (sawtooth) | 89 | Pad 2 (warm) |
| 82 | Lead 3 (calliope) | 90 | Pad 3 (polysynth) |
| 83 | Lead 4 (chiff) | 91 | Pad 4 (choir) |
| 84 | Lead 5 (charang) | 92 | Pad 5 (bowed) |
| 85 | Lead 6 (voice) | 93 | Pad 6 (metallic) |
| 86 | Lead 7 (fifths) | 94 | Pad 7 (halo) |
| 87 | Lead 8 (bass+lead) | 95 | Pad 8 (sweep) |
| # | Synth Effects | # | Ethnic |
|---|---|---|---|
| 96 | FX 1 (rain) | 104 | Sitar |
| 97 | FX 2 (soundtrack) | 105 | Banjo |
| 98 | FX 3 (crystal) | 106 | Shamisen |
| 99 | FX 4 (atmosphere) | 107 | Koto |
| 100 | FX 5 (brightness) | 108 | Kalimba |
| 101 | FX 6 (goblins) | 109 | Bag pipe |
| 102 | FX 7 (echoes) | 110 | Fiddle |
| 103 | FX 8 (sci-fi) | 111 | Shanai |
| # | Percussive | # | Sound Effects |
|---|---|---|---|
| 112 | Tinkle Bell | 120 | Guitar Fret Noise |
| 113 | Agogo | 121 | Breath Noise |
| 114 | Steel Drums | 122 | Seashore |
| 115 | Woodblock | 123 | Bird Tweet |
| 116 | Taiko Drum | 124 | Telephone Ring |
| 117 | Melodic Tom | 125 | Helicopter |
| 118 | Synth Drum | 126 | Applause |
| 119 | Reverse Cymbal | 127 | Gunshot |
Program 120 — the joke instrument of the sound-card era — is the fret squeak that carries Chapter 9’s most persuasive humanization. Programs 121–127 reward similar re-examination: Breath Noise is a wind-player’s byproduct waiting for the same trick.
The percussion exception. MIDI channel 10 (index 9) is traditionally percussion: note numbers select drums (35/36 kicks, 38/40 snares, 42/44/46 hats…) rather than pitches, and the program number selects a kit. This book’s records skip GM drums entirely in favor of Chapter 6’s real drum machine samples — but the channel-10 convention explains why a melody accidentally assigned there plays as drum hits, a rite of passage worth having named.
Reality check. GM defines the names; your soundfont defines the sounds. In GeneralUser GS, the bolded instruments above are genuinely good; quality across the full 128 varies. Audition before you trust — a for-loop over candidate programs rendering the same phrase is Chapter 6’s kit audition, transposed.
Appendix E — Free Resources
Everything this book’s records were built with that you can download today for nothing. URLs current as of publication; the names are stable enough to search if a link drifts.
Sound sources
- GeneralUser GS — the General MIDI soundfont behind Chapter 2. Free, ~30 MB, all 128 programs at consistent levels. → schristiancollins.com/generaluser.php
- FluidR3 GM — the soundfont Ubuntu ships as the
fluid-soundfont-gmpackage; a fine alternative. - TimGM6mb — a tiny (6 MB) GM soundfont, handy for tests and CI; it
ships inside the
pretty_midipip package, which makes it the most reliably downloadable soundfont in existence:python import pretty_midi, os, shutil shutil.copy(os.path.join(os.path.dirname(pretty_midi.__file__), "TimGM6mb.sf2"), "TimGM6mb.sf2")
Amps and effects
- Neural Amp Modeler (plugin) — the free VST3/AU that plays
.namamp captures. → neuralampmodeler.com - Tone Hunt — the community library of thousands of free
.namcaptures; the AC15, Twin, and dUg bass preamp on this book’s records all came from community captures. → tonehunt.org - Voxengo free impulse responses — the measured rooms behind Chapter 11, including the drum room on the darkwave record. → voxengo.com (Impulse Modeler / free IR pack)
- OpenAIR — academic library of measured spaces (churches, concert halls) under permissive licenses; the featured-space IRs.
Drum machines
- smpldsnds/drum-machines — one-shots from dozens of hardware boxes; all eight machines in Chapter 6 (LinnDrum LM-2, Drumtraks, TR-808, CR-8000, RZ-1, MFB-512, MR10, SK-1) came from here. → github.com/smpldsnds/drum-machines
Python libraries
| library | role in the book |
|---|---|
pedalboard |
plugin hosting + built-in effects (Spotify, ch. 3–4, 10–11) |
pyfluidsynth |
the FluidSynth binding (ch. 2) |
numpy |
audio is NumPy arrays, everywhere |
scipy |
WAV I/O, filters, sawtooth (ch. 5–7) |
mido |
MIDI messages for instrument plugins (ch. 3–4) |
mutagen |
release metadata tagging (ch. 15) |
pretty_midi |
mostly for the soundfont in its pocket (tests) |
Instruments already on your Mac
- The GarageBand factory library — the several-GB sampler content Chapter 5 unlocks (Steinway, Mellotron, kits, guitars). Free with every Mac; extraction is Chapter 5, format is Appendix B, license note in both.
- Ample Bass P Lite II — the free deep-sampled P-bass rescued in Chapter 4. → amplesound.net (free line)
The companion code
The complete working pipeline — every loader, the band scripts, the album engine, the audition harnesses, the stems exporter with its null test, and the CI harness that keeps the book’s claims honest — is free and open on GitHub, MIT-licensed: github.com/clintuitive/headless-studio. Code only: the sound sources above are downloads, not redistributables, which is why this appendix exists.
If a link here has rotted: the search terms in bold survive, and the book’s errata page lives at the blog — clintjohnson.cloud/headless-studio.