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.
C and C++ API entry point
|
|
|
|
+--------v-------+
| |
+-------------------------- Synth -----------------------------+
| | | |
| +----------------+ |
| | |
+--------v------+ | +---------v--------+
| | +---------v---------+ | |
| Region list | | Common resources | | Voice pool |
| | | and state | | |
+---------------+ | ----------------- | +------------------+
Built from the SFZ file | File pool |
| Envelope pool | The voices are the polyphony
Each region is a semi-passive | LFO pool | of the synth. They are idle
description object that can | Buffer pool | and they get activated by the
decide whether it is "active" | Midi state | synth to play a region on a
or not depending on the chain | ... | specific event. They are then
of MIDI events it receives. +-------------------+ "linked" to the region while
Once activated, a voice is There are a number of common it is played, and reset to
chosen to play the region until resources that are needed for their idle state when they
it ends naturally or through all the regions and in parti- are done playing the region.
note-offs or off-groups. cular 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.
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.