The Engine

Global view

The sfizz engine is basically a "Synth" object that takes an SFZ file in, receives MIDI-type events and is able to render audio through successive calls to a callback function. This is in line with the way most audio applications and plugins are working. A high-level overview is presented in the following diagram.

graph TD
A[C and C++ API entry point]
B[Synth]
C[1. Region list]
D[2. Common resources and state

File pool
Envelope pool
LFO pool
Buffer pool
Midi state
...] E[3. Voice pool] A --> B B --> C B --> D B --> E
  1. Built from the SFZ file

    Each region is a semi-passive description object that can decide whether it is "active" or not depending on the chain of MIDI events it receives. Once activated, a voice is chosen to play the region until it ends naturally or through note-offs or off-groups.

  2. There are a number of common resources that are needed for all the regions and in particular the voices. This includes all the (preloaded) files for the SFZ instrument, but will include in the future the EG and LFOs that are needed to achieve compliance with the SFZ v2 specification. This will also include a temporary buffer holder that voices may share. A common resource of importance is the MIDI state: note durations are needed for some opcodes -- for example rt_decay -- and triggering velocities too.

  3. The voices are the polyphony of the synth. They are idle and they get activated by the synth to play a region on a specific event. They are then "linked" to the region while it is played, and reset to their idle state when they are done playing the region.

The Synth, Voices and Regions form the bulk of the code complexity. The rest of the engine is dedicated of mostly helper classes to enable easy management of floating-point buffers in which the audio data is held, signal processing and accelerated (SIMD) computations, and abstractions that are specific to the SFZ format such as envelope generators, curves or LFOs.

Parsing the SFZ files

The sfz file logic is pretty simple and well defined. The https://sfzformat.com website contains an extensive documentation on it. At its core, an SFZ file describes a list of region objects on which a certain number of "opcodes" will apply. Opcodes can determine the sample played, the event conditions that will trigger the sample such as the range of notes, channels, velocities, the processing to apply on the sample while playing, and many more things. It is also possible to describe a group of regions, as well as exclusive groups that will shut off other regions that may already be playing. There are also master groups, and global opcodes and some other types.

All the opcodes are declared within a header, in a pseudo-xml markup language that looks like this:

<global> volume=6
<control> set_cc4=5
<region> key=36 sample=kick.wav

Here we have 3 headers (global, control and region) and each header holds some opcodes. All of these opcodes have a value --- for example the volume is equal to 6 in the global header. Some opcodes also have parameters. The control header holds an opcode set_cc with the parameter 4 and value 5. The parameter here is the CC to set, and the value at which to set it is 5.

The parsing logic of sfizz is handled through a base class called Parser --- a very original choice. This parser has a virtual callback that gets called whenever a header description is "complete", along with a list of opcodes that apply to the header. Subclassing the Parser then allows to build different SFZ handlers, from full-blown synths as with sfizz to simpler things such as printers (see in particular https://github.com/sfztools/sfz-flat/). If we look at the core of the latter example, it will look something like the following:

class PrintingParser: public sfz::Parser
{
protected:
    void callback(absl::string_view header, const std::vector<sfz::Opcode>& members) final
    {
        switch (hash(header)) // The hash(...) function transforms strings to large integers
        {
        case hash("global"): // It is also compile-time defined, which allows to do switch-case
                             // statements on strings, something that is usually not possible
            globalMembers = members; // We save the global headers since they apply to the next
                                     // region (and groups and masters)
            masterMembers.clear();
            groupMembers.clear();
            break;
        case hash("master"):
            masterMembers = members; // So on
            groupMembers.clear();
            break;
        case hash("group"):
            groupMembers = members; // .. and so forth
            break;
        case hash("region"):
            std::cout << "<" << header << ">" << ' '; // Now we print the region along with all the opcodes
                                                      // we memorized from earlier headers.
            printMembers(globalMembers);
            printMembers(masterMembers);
            printMembers(groupMembers);
            printMembers(members);
            std::cout << '\n';
            break;
        default:
            std::cout << "<" << header << ">" << ' ';
            printMembers(members);
            std::cout << '\n';
            break;
        }
    }
private:
    std::vector<sfz::Opcode> globalMembers;
    std::vector<sfz::Opcode> masterMembers;
    std::vector<sfz::Opcode> groupMembers;
    void printMembers(const std::vector<sfz::Opcode>& members)
    {
        for (auto& member: members)
        {
            std::cout << member.opcode;
            if (member.parameter)
                std::cout << +*member.parameter;
            std::cout << "=" << member.value;
            std::cout << ' ';
        }
    }
};

The main function is then quite straightforward and we call a function from the Parser class that loads a file

PrintingParser parser;
parser.loadSfzFile("my_sfz_file.sfz");

If you circle back to the parser you will see that opcodes are stored in an Opcode class. This class does some parsing itself and separates the opcode name itself, parameters if any, and the value. Opcodes are very cheap to copy and pass around because they only refer to characters in the file that are stored inside the Parser class, so feel free to create vectors of them and move them around.

Note that you may also derive the loadSfzFile() method if you have any processing you need to do before the actual parsing happens.

Building the region list in sfizz

The callback method from sfizz is actually quite similar to the one shown above, except that instead of printing the region we actually fill a big structure from it.