The Parameter System
src/params.zig. Two types: AtomicParam for one value, ParamStore(N) for a
fixed set of them.
The problem
A parameter is written by one thread and read by another. The UI or the host automation lane writes; the audio callback reads. The audio callback cannot block, so a mutex is not available. It cannot allocate, so a queue that grows is not available either.
The value is a single f32. A lock-free atomic is sufficient and is the whole
solution.
AtomicParam
pub const AtomicParam = extern struct {
raw: std.atomic.Value(u32) = ..., // normalized [0, 1], bit-cast from f32
smoothed: f32 = 0.0, // audio thread only
min: f32 = 0.0,
max: f32 = 1.0,
default_normalized: f32 = 0.5,
smooth_coeff: f32 = 0.0,
_pad: [40]u8 = undefined, // pad to 64 bytes
};
The writer side:
pub fn setNormalized(self: *Self, value: f32) void {
const clamped = std.math.clamp(value, 0.0, 1.0);
self.raw.store(@bitCast(clamped), .release);
}
One clamp and one release store. Wait-free. The f32 is bit-cast to u32
because std.atomic.Value wants an integer, and a bit-cast of a clamped finite
float is exact.
The reader side runs once per sample:
pub fn tick(self: *Self) f32 {
const target = self.getTargetPlain();
if (self.smooth_coeff <= 0.0) {
self.smoothed = target;
} else {
self.smoothed += (target - self.smoothed) * (1.0 - self.smooth_coeff);
}
return self.smoothed;
}
getTargetPlain does an acquire load, then denormalizes into [min, max]. The
one-pole filter that follows turns a step into an exponential approach. The
coefficient comes from a time constant in milliseconds:
self.smooth_coeff = @exp(-1000.0 / (ms * sample_rate));
smoothed is deliberately non-atomic. Only the audio thread touches it.
Use snap() to jump smoothed to the target with no ramp. That is what you
want on preset load or transport relocation, where a ramp would be a glide.
Why exactly one cache line
AtomicParam is padded to 64 bytes and the size is enforced at compile time:
comptime {
if (@sizeOf(AtomicParam) != 64) {
@compileError("AtomicParam must be 64 bytes for cache line alignment");
}
}
Without the padding, several parameters would share a cache line. When the UI thread stores to parameter 0, the cache coherence protocol invalidates the whole line on every other core. The audio thread reading parameter 1, which nobody wrote, would still take a coherence miss. This is false sharing, and it shows up as jitter in the audio callback rather than as a wrong answer, which makes it unpleasant to find.
64 bytes is the line size on x86_64 and on Apple Silicon's L1 data cache. One parameter per line means a store to one parameter never disturbs the read of another. The cost is 40 wasted bytes per parameter. For 64 parameters that is 2.5 KB of padding, which is nothing against the price of one stalled audio callback.
The compile-time check exists so that adding a field silently breaks the build instead of silently reintroducing false sharing.
There is a second, smaller reason for the fixed size. extern struct with a
known size means ParamStore(N) is a flat [N]AtomicParam array, so parameter
i is at a computable offset with no indirection.
ParamStore
var store = danzig.ParamStore(4){};
const gain = store.add(-48.0, 48.0, 0.5, 20.0, 48000.0);
// min max default smooth_ms sample_rate
add returns the index and asserts you have not exceeded N. Call it during
init only.
| Call | Thread | Notes |
|---|---|---|
add(min, max, default, ms, sr) | init | Returns the index. Asserts on overflow. |
setNormalized(i, v) | host / UI | Ignores an out-of-range index rather than trapping. |
getNormalized(i) | any | Returns 0.0 for an out-of-range index. |
tick(i) | audio | Advances one sample, returns the plain value. |
tickAll() | audio | Advances every registered parameter. |
getSmoothed(i) | audio | Reads the last ticked value. Call after tick. |
snapAll() | audio | Jumps every smoothed value to its target. |
The out-of-range behaviour is deliberate. A host sending a stale parameter index during a preset change should not take down the audio thread.
One gap to know about: setSampleRate is currently a no-op. If the sample rate
changes, re-run setSmoothingMs(ms, new_rate) on each parameter, or rebuild the
store in setupProcessing.