Today I Learned...

Today I Learned...

The basics of the FluidSynth Sequencer API

Today I learned the basics of the FluidSynth Sequencer API. It is not super complicated, but it took me some time to understand the concepts, as not everything is explained in great detail. As often is the case when learning a new API, the best way to learn it is to read through the code of the provided examples, and check the API reference docs as needed.

Here is a sequence diagram that I came up with to summarize the flow for a typical program:

Now of course, just reading code can be a bit boring and tiresome, I really like to learn by doing/tinkering, so I decided to translate the metronome example to Zig – as I’m interested in learning Zig as well.

FluidSynth Metronome - Zig Version

Here is what I came up with:

const std = @import("std");
const fluid = @cImport(@cInclude("fluidsynth.h"));

const tempo = 120;
const beat_duration_ms: u32 = 60000 / tempo;
const pattern_size = 4;

// global variable to keep track of the current sequencer time
var TIME_MARKER: c_uint = 0;
var SYNTH_DESTINATION: fluid.fluid_seq_id_t = 0;
var CLIENT_DESTINATION: fluid.fluid_seq_id_t = 0;

fn schedule_timer_event(seq: ?*fluid.fluid_sequencer_t, time_marker: c_uint) void {
    const event = fluid.new_fluid_event();
    defer fluid.delete_fluid_event(event);

    fluid.fluid_event_set_source(event, -1);
    fluid.fluid_event_set_dest(event, CLIENT_DESTINATION);
    fluid.fluid_event_timer(event, null);
    _ = fluid.fluid_sequencer_send_at(seq, event, time_marker, 1);
}

fn schedule_note_on(seq: ?*fluid.fluid_sequencer_t, midi_chan: i16, time: c_uint, note: i16, velocity: i16) void {
    const event = fluid.new_fluid_event();
    defer fluid.delete_fluid_event(event);

    fluid.fluid_event_set_source(event, -1);
    fluid.fluid_event_set_dest(event, SYNTH_DESTINATION);
    fluid.fluid_event_noteon(event, midi_chan, note, velocity);
    _ = fluid.fluid_sequencer_send_at(seq, event, time, 1);
}

fn schedule_metronome_pattern(seq: ?*fluid.fluid_sequencer_t) void {
    const midi_chan = 9;

    const strong_note = 76; // woodblock high
    const weak_note = 77; // woodblock low

    var note_time: c_uint = TIME_MARKER;
    var i: i16 = 0;
    while (i < pattern_size) : (i += 1) {
        var note_to_play: i16 = weak_note;
        var velocity: i16 = 90;
        if (i == 0) {
            note_to_play = strong_note;
            velocity = 127;
        }
        schedule_note_on(seq, midi_chan, note_time, note_to_play, velocity);
        note_time += beat_duration_ms;
    }
    TIME_MARKER += beat_duration_ms * pattern_size;
}

// this callback will be called every time a sequencer event is received
fn sequencer_callback(time: c_uint, event: ?*fluid.fluid_event_t, seq: ?*fluid.fluid_sequencer_t, data: ?*anyopaque) callconv(.C) void {
    _ = time;
    _ = data;
    _ = event;

    schedule_timer_event(seq, TIME_MARKER);
    schedule_metronome_pattern(seq);
}

pub fn main() void {
    const settings = fluid.new_fluid_settings();
    defer fluid.delete_fluid_settings(settings);
    const synth = fluid.new_fluid_synth(settings);
    defer fluid.delete_fluid_synth(synth);

    // here we load the soundfont file
    // load_soundfont(synth, "soundfonts/GeneralUser_GS_v1.471.sf2");
    _ = fluid.fluid_synth_sfload(synth, "soundfonts/GeneralUser_GS_v1.471.sf2", 1);

    // set up the audio driver to play sounds from the synth
    const audio_driver = fluid.new_fluid_audio_driver(settings, synth);
    defer fluid.delete_fluid_audio_driver(audio_driver);

    // initialize the sequencer
    const seq = fluid.new_fluid_sequencer2(0);
    defer fluid.delete_fluid_sequencer(seq);

    // this will be destination port for the synth where we'll send note events
    SYNTH_DESTINATION = fluid.fluid_sequencer_register_fluidsynth(seq, synth);

    CLIENT_DESTINATION = fluid.fluid_sequencer_register_client(seq, "zig-fluid-metronome", sequencer_callback, null);

    // get the current sequencer time
    TIME_MARKER = fluid.fluid_sequencer_get_tick(seq);

    schedule_metronome_pattern(seq);
    schedule_timer_event(seq, TIME_MARKER);
    schedule_metronome_pattern(seq); // schedule the next one, to be in advance

    // run the sequencer for 1 minute
    std.time.sleep(std.time.ns_per_min * 1);
    std.debug.print("Practice time is over, stopping now\n", .{});
}

About 60% the size of the C program in terms of lines of code, that’s not bad!

I’ve hardcoded the soundfont to the GM GeneralUser GS that I already had a copy laying around.

I was quite happy with it, I struggled with compiler errors for some time but once I got it to compile, it worked on first try!

In terms of code quality it’s not that great though, I am not comfortable with creating abstractions in Zig – gotta learn that in order to build bigger programs. The fact that I had to rely on global variables here bothers me, I want to learn a better way of doing this.

FluidSynth Metronome - Python Version

By the end, I was curious how quickly I could come up with a Python implementation, using the pyfluidsynth Python bindings for the FluidSynth C API, which I already knew from using it when writing upiano.

It was relatively straightforward, except a few gotchas:

  • for some reason, with pyfluidsynth I have to call program_select to select the “Standard Drum” program on the appropriate MIDI channel, otherwise it won’t play any sound if you send events to channel 10 (the drum/percussion channel), something that I didn’t have to do when calling directly the C API from Zig.
  • I didn’t know which bank/preset number the “Standard Drum” was in, so I had to grab a tool to inspect the GM GeneralUser soundfont. I used the tinysoundfont tool, which you can use like tinysoundfont --info $SOUNDFONT_FILE, and it will describe the contents – FYI, it was the first preset of bank 120.
  • the Sequencer class provided by pyFluidSynth that wraps the Sequencer API is rather neat, though I had some runtime errors when inadverentely sending float instead of int for the time argument.

Here is the code:

import time
import fluidsynth


class Metronome:
    def __init__(self, tempo=120, pattern_size=4):
        self.tempo = tempo
        self.beat_duration_ms = int(60000 / tempo)
        self.pattern_size = pattern_size

        self._synth = fluidsynth.Synth()
        self._seq = fluidsynth.Sequencer(use_system_timer=False)
        self.current_time = self._seq.get_tick()

        # load my old friend General User GS SoundFont ...
        soundfont_id = self._synth.sfload("soundfonts/GeneralUser_GS_v1.471.sf2")
        # ... and select "Standard Drum" program for percussion channel
        self._synth.program_select(9, soundfont_id, 120, 0)

        # register the synth which will play the note events...
        self._synth_id = self._seq.register_fluidsynth(self._synth)
        # ... and the client callback which will be called each time the timer ticks
        self._callback_id = self._seq.register_client(
            "py-fluid-metronome", self.seq_callback
        )

    def start(self):
        self._synth.start()

        # schedule the pattern twice so as to always be 1 measure ahead
        self.schedule_metronome_pattern()
        self.schedule_timer_event()
        self.schedule_metronome_pattern()
        print("Metronome started, hit Ctrl+C to stop")

        try:
            # sleep for 24 hours (or until interrupted)
            time.sleep(60 * 60 * 24)
        except KeyboardInterrupt:
            pass
        finally:
            self._synth.delete()

    def schedule_timer_event(self):
        self._seq.timer(self.current_time, dest=self._callback_id)

    def seq_callback(self, _time, _tick, _event, _data):
        # This is called by the sequencer each time a timer event is triggered

        # First thing we do is to schedule the next timer event, in order
        # to keep the sequencer loop going ...
        self.schedule_timer_event()

        # ... and then we schedule the notes to be played
        self.schedule_metronome_pattern()

    def schedule_metronome_pattern(self):
        strong_note = 76  # wood block high
        weak_note = 77  # wood block low

        midi_channel = 9  # percussion channel

        for i in range(self.pattern_size):
            note, vel = weak_note, 80
            if i == 0:  # first beat
                note, vel = strong_note, 120

            self._seq.note_on(
                time=self.current_time + self.beat_duration_ms * i,
                channel=midi_channel,
                key=note,
                velocity=vel,
                dest=self._synth_id,
            )

        self.current_time += self.beat_duration_ms * self.pattern_size


if __name__ == "__main__":
    Metronome().start()

I’m quite happy with this code, because I could refactor the initial mess I had came up with into a nice Metronome class encapsulating the behavior. I was much more productive in Python obviously, besides having already got a good graps of the API, Python is the language I’ve been using on a daily basis over the past 10 years, so there was a lot less new things to learn.