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.

CallThreadNotes
add(min, max, default, ms, sr)initReturns the index. Asserts on overflow.
setNormalized(i, v)host / UIIgnores an out-of-range index rather than trapping.
getNormalized(i)anyReturns 0.0 for an out-of-range index.
tick(i)audioAdvances one sample, returns the plain value.
tickAll()audioAdvances every registered parameter.
getSmoothed(i)audioReads the last ticked value. Call after tick.
snapAll()audioJumps 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.