Extracting Soundfonts from a Roland TD-11
October 18, 2020

In this post I discuss using Python to extract high quality sound samples and build a reusable soundfont from an electronic drum kit.

I own a Roland TD-11 (pictured) which allows me to practice my hobby while keeping the volume low for those around me. It has a variety of drum kits in memory that I've grown accustomed to after years of playing, and it would be cool if I could use those samples away from the drumkit, say in a Digital Audio Workstation (DAW), while I'm physically distant from my drumkit. I've heard of soundfonts, would it be possible to extract the samples and construct one of my own for this purpose? Let's find out.

Roland TD-11

What is a SoundFont?

Historically, SoundFont is the brand name associated with a binary file format that could encode samples associated with specific MIDI notes. This format became the standard for sampling across the industry, with many open source and proprietary VSTs supporting its use.

Authoring a SoundFont is done through creation software. You manually import your samples into the program and associate them with specific MIDI notes and velocities. Unless the software supports some kind of automation or bulk import, this is usually a laborious process. This is especially true at scale (like for example, downloading every note*velocity*drum kit from an electronic drumkit 😉).

Introducing SFZ

SFZ is a modern plain text format that serves the same purpose as the original SoundFont: Samples mapped to Notes and Velocities. I'm not covering the breadth of use cases that the SFZ format supports, I'm primarily focusing on replicating a drum set which typically lets the full sample ring out. Here's an example of what an SFZ file looks like:

drumkit.sfz
<group>
lovel=0
hivel=127
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=1 hivel=1 sample=36-1.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=2 hivel=2 sample=36-2.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=3 hivel=3 sample=36-3.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=4 hivel=4 sample=36-4.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=5 hivel=5 sample=36-5.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=6 hivel=6 sample=36-6.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=7 hivel=7 sample=36-7.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=8 hivel=8 sample=36-8.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=9 hivel=9 sample=36-9.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=10 hivel=10 sample=36-10.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=11 hivel=11 sample=36-11.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=12 hivel=12 sample=36-12.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=13 hivel=13 sample=36-13.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=14 hivel=14 sample=36-14.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=15 hivel=15 sample=36-15.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=16 hivel=16 sample=36-16.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=17 hivel=17 sample=36-17.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=18 hivel=18 sample=36-18.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=19 hivel=19 sample=36-19.wav
 <region> trigger=attack loop_mode=one_shot pitch_keycenter=36 lokey=36 hikey=36 lovel=20 hivel=20 sample=36-20.wav
...and so on...

What this code is doing is mapping a (note, velocity) tuple to a sound file. So in this example, note 36, velocity 9 maps to 36-9.wav. Simple! More information on the SFZ file format, and what is possible with it.

So how do we get the samples?

Through MIDI of course! The Roland TD-11 accepts MIDI input over USB, allowing us to send a combination of note and velocity to the drumset, in exchange for a rendered sound. Furthermore, we can set "drum kit change" events, allowing us to iterate through all the drum kits on the device without manual intervention (this part I admit I entered by hand, as there were only 25 drum kits -- although it should be easily possible with MIDIUtil).

Quick maths: with 20 notes, 127 velocities per note, that's 2540 events per drum kit. For 25 drum kits, that works out to 63500 events total. At 20 BPM (3 seconds per sample), we're talking about a recording time of 52.9 hours!

Generating this many events would be time-consuming if done by hand. Let's use Python and the MIDIUtil library to automate the job.

helpers.py
import os

OMIT_NOTES = [35, 39, 41, 54, 56]

def all_note_velocities():
    for note in all_notes():
        for velocity in all_velocities():
            yield (note, velocity)

def all_notes():
    for note in range(35, 59 + 1):
        if note in OMIT_NOTES:
            continue
        yield note

def all_velocities():
    return range(1, 127 + 1)

def get_wav_path(kit, note, velocity, output_path=None):
    filename = '{}-{}-{}.wav'.format(kit, note, velocity)
    return os.path.join(output_path, filename) if output_path else filename

def get_sfz_path(kit, output_path):
    return os.path.join(output_path, '{}.sfz'.format(kit))
enumerate_midi.py
from midiutil import MIDIFile
from helpers import all_note_velocities

# MIDI note number
track = 0
channel = 9
duration = 1 # In beats
tempo = 20 # In BPM
time = 0

# One track, defaults to format 1 (tempo track is created automatically)
MyMIDI = MIDIFile(1)  
MyMIDI.addTempo(track, time, tempo)

# Loop over notes
for note, velocity in all_note_velocities():
    MyMIDI.addNote(track, channel, note, time, duration, velocity)
    time += 1

with open('all-drum-notes.mid', 'wb') as output_file:
    MyMIDI.writeFile(output_file)

This generates a MIDI file with all the important drum MIDI events. We can put this into our preferred DAW (Reaper, in my case ;) and record each sound. Let's .

Using MIDI to extract drum samples from the Acid Jazz drum kit

What we'll end up with is a long WAV file that will need to be spliced up into individual components. Let's also use Python and the pydub library for this task.

slice_wav.py
import os
import sys
from pydub import AudioSegment
from helpers import all_note_velocities, all_notes, all_velocities, get_wav_path, get_sfz_path

# all-drum-notes.mid has:
# 20 notes * 127 velocities = 2540 beats
beats = len(all_note_velocities)
bpm = 20.0
duration = beats / bpm * 60 * 1000 # ms
duration_per_beat = duration / beats

if len(sys.argv) != 3:
    print('slice_wav.py INPUT_PATH OUTPUT_PATH')
    sys.exit(1)

input_path = sys.argv[1]
output_path = sys.argv[2]

for audio_file in os.listdir(input_path):
    if audio_file.endswith(".wav"):
        audio_file = os.path.join(input_path, audio_file)
        print('Processing {}'.format(audio_file))
        old_audio = AudioSegment.from_wav(audio_file)
        kit = os.path.splitext(os.path.basename(audio_file))[0]
        soundfont_output_path = os.path.join(output_path, kit)

        try:
            os.makedirs(soundfont_output_path)
        except:
            pass

        time = 0

        # Loop over notes
        for note, velocity in all_note_velocities():
            t1 = time * duration_per_beat
            t2 = (time + 1) * duration_per_beat - 50 # ms
            wav_path = get_wav_path(kit, note, velocity, soundfont_output_path)
            new_audio = old_audio[t1:t2]
            new_audio.export(wav_path, format='wav', parameters=['-acodec', 'pcm_s16le', '-ar', '44100'])
            time += 1

        sfz_path = get_sfz_path(kit, output_path)

        with open(sfz_path, 'w') as sfz_file:
            for note in all_notes():
                group = '<group> loop_mode=one_shot pitch_keycenter={note} lokey={note} hikey={note} group={note}\n'.format(
                    note=note
                )
                sfz_file.write(group)

                for velocity in all_velocities():
                    wav_name = os.path.join(kit, get_wav_path(kit, note, velocity))
                    sfz_file.write('<region> lovel={velocity} hivel={velocity} sample={wav_name}\n'.format(
                        velocity=velocity,
                        wav_name=wav_name
                    ))

There you have it. You'll end up with a folder with 2540 WAV files and one SFZ file which can be imported into a free SFZ VST like sforzando. Your mileage may vary with the above code, but it can likely be adapted to extract samples from a wide range of MIDI devices.

One final optimization: the SFZ format supports OGG files in addition to WAV files. If we're fine with lossy audio, big size reductions can be achieved by encoding these files as OGG. Here's a little helper script using Ruby and FFMPEG that does that:

encode_as_ogg.py
require 'fileutils'

Dir.glob('**/*.wav').each do |path|
  `ffmpeg -y -i "#{path}" "#{path.gsub(/\.wav/, '.ogg')}"`
  FileUtils.rm(path)
end

Happy MIDIing!

programming music