Architecture
COM in Zig, how a plugin is registered, and the audio callback path.
COM in Zig
A C++ object with virtual functions is a pointer to a vtable followed by the
object's fields. A COM interface is that, plus the convention that the first
three vtable slots are queryInterface, addRef, and release.
src/vst3.zig writes this out as plain Zig:
pub const IUnknown = extern struct {
queryInterface: *const fn (?*IUnknown, *const IID, ?*[*]?*anyopaque) callconv(.c) TResult = undefined,
addRef: *const fn (?*IUnknown) callconv(.c) u32 = undefined,
release: *const fn (?*IUnknown) callconv(.c) u32 = undefined,
};
Three things make this work.
extern struct guarantees C layout: fields in declaration order, C alignment
rules, no reordering. This is the whole reason the trick is safe.
callconv(.c) gives each function pointer the platform C calling convention,
so arguments land in the registers the host expects.
Interface inheritance becomes struct embedding. IComponent starts with an
IPluginBase field, which starts with an IUnknown field. Because extern
struct puts fields at ascending offsets with the first at offset zero, a
*IComponent is bit-identical to a *IPluginBase and to a *IUnknown. That
is exactly what single inheritance produces in C++.
pub const IComponent = extern struct {
pluginBase: IPluginBase, // offset 0, itself starting with IUnknown
getControllerClassId: *const fn (?*IComponent, ?*CUID) callconv(.c) TResult = undefined,
setIoMode: ...
};
The rest of vst3.zig is the data the ABI passes around: ProcessData,
AudioBusBuffers, ProcessSetup, ParameterInfo, BusInfo, plus the
TResult constants and bus and media type enums. All extern struct, all
laid out to match the SDK headers.
How a plugin is registered
A VST3 binary exports one symbol. That is the entire registration mechanism.
export fn GetPluginFactory() ?*anyopaque {
gFactory.vtbl = @ptrCast(&factoryVtable);
return @ptrCast(&gFactory);
}
gFactory is a static whose first field is a pointer to a static vtable. The
host receives the address of gFactory, reads the first word to get
&factoryVtable, and calls through it. Nothing is allocated. Nothing is
registered anywhere else. There is no plugin database, no manifest, and no
macro.
From there the host does:
countClasses()to learn how many classes the binary exports.getClassInfo(i, &info)for each, reading the class ID, category, and name.createInstance(class_id, iid, &out)to get an object implementing the requested interface.
examples/danzig-test performs exactly steps 1 through 3 against the built
plugin, going through the C function pointers rather than through Zig types, so
a layout change that would break a real host breaks the test first.
The audio callback path
The host owns the buffers. It hands you a ProcessData describing them and
expects you to be finished by the time the callback returns.
host audio thread
|
+-- IAudioProcessor.setupProcessing(&setup) once, before playback
| sample rate, max block size, 32- or 64-bit samples
|
+-- IAudioProcessor.setProcessing(true) transport starts
|
+-- IAudioProcessor.process(&data) every block, on the audio thread
| data.numSamples
| data.inputs[bus].channelBuffers32[ch]
| data.outputs[bus].channelBuffers32[ch]
|
+-- IAudioProcessor.setProcessing(false) transport stops
Inside process the rules are the usual real-time rules. No allocation, no
locks, no file or network access, no logging that touches a mutex. danzig's
contribution is that the parameter path obeys them by construction: the host's
UI thread writes a normalized f32 with an atomic store, and the audio thread
reads it with an atomic load. There is nothing between the two that can block.
A minimal per-sample loop looks like this, from
examples/danzig-minimal/root.zig:
pub fn process(
self: *MinimalPlugin,
input: []const []const f32,
output: []const []f32,
frames: usize,
) void {
for (0..frames) |i| {
const gain = danzig.dBToLinear(self.params.tick(ParamIndex.trim));
for (input, output) |in_ch, out_ch| {
out_ch[i] = in_ch[i] * gain;
}
}
}
tick advances the smoother by one sample and returns the plain value, so a
parameter change becomes a ramp rather than a step. That is what stops a slider
drag from producing clicks.