I had a go at trying to figure out how to generate arbitrary MIDI events and
send them out over a JACK MIDI channel.
Setting up JACK and Pipewire
Pipewire has a JACK interface, which in theory means one could use JACK clients
out of the box without extra setup.
In practice, one need to tell JACK clients which set of libraries to use to
communicate to servers, and Pipewire's JACK server is not the default choice.
To tell JACK clients to use Pipewire's server, you can either:
- on a client-by-client basis, wrap the commands with
pw-jack
- to change the system default:
cp /usr/share/doc/pipewire/examples/ld.so.conf.d/pipewire-jack-*.conf /etc/ld.so.conf.d/ and run ldconfig (see the Debian wiki for details)
Programming with JACK
Python has a
JACK client library
that worked flawlessly for me so far.
Everything with JACK is designed around minimizing latency. Everything happens
around a callback that gets called form a separate thread, and which gets a
buffer to fill with events.
All the heavy processing needs to happen outside the callback, and the callback
is only there to do the minimal amount of work needed to shovel the data your
application produced into JACK channels.
Generating MIDI messages
The
Mido library can be used to parse
and create MIDI messages and it also worked flawlessly for me so far.
One needs to study a bit what kind of MIDI message one needs to generate (like
"note on", "note off", "program change") and what arguments they get.
It also helps to read about the
General MIDI
standard which defines mappings between well-known instruments and channels and
instrument numbers in MIDI messages.
A timed message queue
To keep a queue of events that happen over time, I implemented a
Delta
List that indexes events by
their future frame number.
I called the humble container for my audio experiments
pyeep
and
here's my delta list implementation.
A JACK player
The simple JACK MIDI player backend is
also in pyeep.
It needs to protect the delta list with a mutex since we are working across
thread boundaries, but it tries to do as little work under lock as possible, to
minimize the risk of locking the realtime thread for too long.
The
play method converts delays in seconds to frame counts, and the
on_process callback moves events from the queue to the jack output.
Here's
an example script that plays a simple drum pattern:
#!/usr/bin/python3
# Example JACK midi event generator
#
# Play a drum pattern over JACK
import time
from pyeep.jackmidi import MidiPlayer
# See:
# https://soundprogramming.net/file-formats/general-midi-instrument-list/
# https://www.pgmusic.com/tutorial_gm.htm
DRUM_CHANNEL = 9
with MidiPlayer("pyeep drums") as player:
beat: int = 0
while True:
player.play("note_on", velocity=64, note=35, channel=DRUM_CHANNEL)
player.play("note_off", note=38, channel=DRUM_CHANNEL, delay_sec=0.5)
if beat == 0:
player.play("note_on", velocity=100, note=38, channel=DRUM_CHANNEL)
player.play("note_off", note=36, channel=DRUM_CHANNEL, delay_sec=0.3)
if beat + 1 == 2:
player.play("note_on", velocity=100, note=42, channel=DRUM_CHANNEL)
player.play("note_off", note=42, channel=DRUM_CHANNEL, delay_sec=0.3)
beat = (beat + 1) % 4
time.sleep(0.3)
Running the example
I ran the
jack_drums script, and of course not much happened.
First I needed a MIDI synthesizer. I installed
fluidsynth, and ran it on the
command line with no arguments. it registered with JACK, ready to do its thing.
Then I connected things together. I used
qjackctl, opened the graph view, and
connected the MIDI output of "pyeep drums" to the "FLUID Synth input port".
fluidsynth's output was already automatically connected to the audio card and I
started hearing the drums playing!