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.