diff --git a/.gitignore b/.gitignore
index 37d632e..5245139 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@ zig-cache/
# macOS
.DS_Store
+
+# Audio test artifacts produced by the standalone processor
+*.wav
diff --git a/build.zig b/build.zig
index a89a067..35b15e3 100644
--- a/build.zig
+++ b/build.zig
@@ -1,5 +1,7 @@
const std = @import("std");
+const VERSION = "0.1.0";
+
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
@@ -24,8 +26,9 @@ pub fn build(b: *std.Build) void {
.target = target,
.optimize = optimize,
});
+ danzig_gain.root_module.addImport("danzig", danzig_module);
danzig_gain.linkLibrary(danzig_lib);
-
+
// Install to zig-out/lib/
b.installArtifact(danzig_gain);
@@ -50,6 +53,10 @@ pub fn build(b: *std.Build) void {
danzig_gain_standalone.linkLibrary(danzig_lib);
b.installArtifact(danzig_gain_standalone);
+ const run_standalone = b.addRunArtifact(danzig_gain_standalone);
+ const run_standalone_step = b.step("run-standalone", "Run the standalone audio processor");
+ run_standalone_step.dependOn(&run_standalone.step);
+
// Web UI server
const danzig_webui = b.addExecutable(.{
.name = "danzig-webui",
@@ -57,11 +64,30 @@ pub fn build(b: *std.Build) void {
.target = target,
.optimize = optimize,
});
-
danzig_webui.root_module.addImport("danzig", danzig_module);
danzig_webui.linkLibrary(danzig_lib);
b.installArtifact(danzig_webui);
+ // --- Universal VST3 bundle (macOS) ---------------------------------------
+ //
+ // A VST3 plugin ships as a bundle holding one universal binary, so hosts of
+ // either architecture load the same file. Built per-arch and merged with
+ // lipo. Kept behind the `vst3` step rather than the default install,
+ // because it doubles compile work and only applies to macOS.
+ if (target.result.os.tag == .macos) {
+ addVst3Bundle(b, optimize, danzig_module);
+ }
+
+ // --- GUI example (macOS) -------------------------------------------------
+ //
+ // Needs the webview dependency, so it is only wired up when that has been
+ // fetched. Links CoreAudio for device IO and WebKit via webview.
+ if (target.result.os.tag == .macos) {
+ if (b.lazyDependency("webview", .{ .target = target, .optimize = optimize })) |webview_dep| {
+ addGuiExample(b, target, optimize, danzig_module, danzig_lib, webview_dep);
+ }
+ }
+
// Unit tests — pure Zig, no artifact or host required
const unit_tests = b.addTest(.{
.root_source_file = b.path("src/tests.zig"),
@@ -73,10 +99,148 @@ pub fn build(b: *std.Build) void {
const unit_test_step = b.step("test-unit", "Run unit tests only");
unit_test_step.dependOn(&run_unit_tests.step);
+ // Integration tests — drives the built plugin through the raw VST3 C ABI
+ const run_test = b.addRunArtifact(danzig_test);
+ const integration_test_step = b.step("test-integration", "Run VST3 ABI integration tests only");
+ integration_test_step.dependOn(&run_test.step);
+
// Test step
const test_step = b.step("test", "Run tests");
- const run_test = b.addRunArtifact(danzig_test);
test_step.dependOn(&run_unit_tests.step);
test_step.dependOn(&run_test.step);
}
+/// Build DanzigGain for both macOS architectures, merge them into one
+/// universal binary, and lay out the .vst3 bundle around it.
+fn addVst3Bundle(
+ b: *std.Build,
+ optimize: std.builtin.OptimizeMode,
+ danzig_module: *std.Build.Module,
+) void {
+ const arches = [_]std.Target.Cpu.Arch{ .aarch64, .x86_64 };
+ const suffixes = [_][]const u8{ "arm64", "x86" };
+
+ const lipo = b.addSystemCommand(&.{ "lipo", "-create" });
+
+ inline for (arches, suffixes) |arch, suffix| {
+ const arch_target = b.resolveTargetQuery(.{ .cpu_arch = arch, .os_tag = .macos });
+
+ const lib = b.addStaticLibrary(.{
+ .name = "danzig_" ++ suffix,
+ .root_source_file = b.path("src/root.zig"),
+ .target = arch_target,
+ .optimize = optimize,
+ });
+
+ const plugin = b.addSharedLibrary(.{
+ .name = "DanzigGain_" ++ suffix,
+ .root_source_file = b.path("examples/danzig-gain/root.zig"),
+ .target = arch_target,
+ .optimize = optimize,
+ });
+ plugin.root_module.addImport("danzig", danzig_module);
+ plugin.linkLibrary(lib);
+ b.installArtifact(plugin);
+
+ lipo.addArtifactArg(plugin);
+ }
+
+ lipo.addArg("-output");
+ const universal = lipo.addOutputFileArg("DanzigGain");
+
+ // VST3 bundles carry no file extension on the executable itself.
+ const bundle = b.addWriteFiles();
+ _ = bundle.add("Contents/Info.plist", infoPlist(b));
+ _ = bundle.add("Contents/PkgInfo", "BNDL????");
+ _ = bundle.addCopyFile(universal, "Contents/MacOS/DanzigGain");
+
+ const install_bundle = b.addInstallDirectory(.{
+ .source_dir = bundle.getDirectory(),
+ .install_dir = .prefix,
+ .install_subdir = "DanzigGain.vst3",
+ });
+
+ const vst3_step = b.step("vst3", "Build and package the universal VST3 bundle");
+ vst3_step.dependOn(&install_bundle.step);
+
+ // Hosts scan ~/Library/Audio/Plug-Ins/VST3 on macOS.
+ const install_cmd = b.addSystemCommand(&.{ "sh", "-c", "rm -rf \"$HOME/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3\" && " ++
+ "mkdir -p \"$HOME/Library/Audio/Plug-Ins/VST3\" && " ++
+ "cp -R \"$1\" \"$HOME/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3\"", "sh" });
+ install_cmd.addDirectoryArg(bundle.getDirectory());
+
+ const install_vst3_step = b.step("install-vst3", "Install the VST3 bundle to ~/Library/Audio/Plug-Ins/VST3/");
+ install_vst3_step.dependOn(&install_cmd.step);
+}
+
+/// Standalone GUI host: a webview front end driving the gain processor over
+/// CoreAudio.
+fn addGuiExample(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ danzig_module: *std.Build.Module,
+ danzig_lib: *std.Build.Step.Compile,
+ webview_dep: *std.Build.Dependency,
+) void {
+ const coreaudio_module = b.addModule("coreaudio", .{
+ .root_source_file = b.path("examples/danzig-gain-ui/coreaudio.zig"),
+ });
+
+ const gui = b.addExecutable(.{
+ .name = "danzig-gain-ui",
+ .root_source_file = b.path("examples/danzig-gain-ui/root.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ gui.root_module.addImport("danzig", danzig_module);
+ gui.root_module.addImport("coreaudio", coreaudio_module);
+ gui.root_module.addImport("webview", webview_dep.module("webview"));
+ // The Zig bindings are declarations only; the implementation is webview's
+ // C++ core, which the dependency exposes as a static library.
+ gui.linkLibrary(webview_dep.artifact("webviewStatic"));
+ // The UI is embedded rather than read at runtime, so the binary is
+ // self-contained.
+ gui.root_module.addAnonymousImport("ui_html", .{ .root_source_file = b.path("ui/index.html") });
+ gui.linkLibrary(danzig_lib);
+ gui.linkFramework("CoreAudio");
+ gui.linkFramework("CoreFoundation");
+ b.installArtifact(gui);
+
+ const run_gui = b.addRunArtifact(gui);
+ const run_gui_step = b.step("run-gui", "Run the standalone GUI app");
+ run_gui_step.dependOn(&run_gui.step);
+}
+
+fn infoPlist(b: *std.Build) []const u8 {
+ const template =
+ \\
+ \\
+ \\
+ \\
+ \\ CFBundleExecutable
+ \\ DanzigGain
+ \\ CFBundleIdentifier
+ \\ com.danzig.DanzigGain
+ \\ CFBundleName
+ \\ DanzigGain
+ \\ CFBundleDisplayName
+ \\ Danzig Gain
+ \\ CFBundlePackageType
+ \\ BNDL
+ \\ CFBundleSignature
+ \\ ????
+ \\ CFBundleVersion
+ \\ {s}
+ \\ CFBundleShortVersionString
+ \\ {s}
+ \\ CFBundleInfoDictionaryVersion
+ \\ 6.0
+ \\ CSResourcesFileMapped
+ \\
+ \\
+ \\
+ \\
+ ;
+ return b.fmt(template, .{ VERSION, VERSION });
+}
diff --git a/build.zig.zon b/build.zig.zon
new file mode 100644
index 0000000..04f3d1a
--- /dev/null
+++ b/build.zig.zon
@@ -0,0 +1,18 @@
+.{
+ .name = .danzig,
+ .version = "0.1.0",
+ .fingerprint = 0x6383fbb1d610c5a6,
+ .dependencies = .{
+ .webview = .{
+ .url = "https://github.com/thechampagne/webview-zig/archive/5abc215.tar.gz",
+ .hash = "webview-0.1.0-ImQK_yb6IADsgQL8f72LGd4xkaSrxqQn9RyYy3U36s1J",
+ },
+ },
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "src",
+ "examples",
+ "ui",
+ },
+}
diff --git a/docs/architecture.svg b/docs/architecture.svg
new file mode 100644
index 0000000..fc7c414
--- /dev/null
+++ b/docs/architecture.svg
@@ -0,0 +1,190 @@
+
diff --git a/examples/danzig-gain-ui/coreaudio.zig b/examples/danzig-gain-ui/coreaudio.zig
new file mode 100644
index 0000000..41f997d
--- /dev/null
+++ b/examples/danzig-gain-ui/coreaudio.zig
@@ -0,0 +1,168 @@
+// CoreAudio device enumeration for macOS
+// Uses AudioObjectGetPropertyData to list available audio devices
+
+const std = @import("std");
+const c = @cImport({
+ @cInclude("CoreAudio/CoreAudio.h");
+});
+
+pub const AudioDevice = struct {
+ id: u32,
+ name: [256]u8 = undefined,
+ name_len: usize = 0,
+ is_input: bool = false,
+ is_output: bool = false,
+ is_default: bool = false,
+};
+
+fn getStringProperty(device_id: u32, selector: u32, buf: []u8) ?[]u8 {
+ var cf_string: c.CFStringRef = null;
+ var size: u32 = @sizeOf(c.CFStringRef);
+ var address = c.AudioObjectPropertyAddress{
+ .mSelector = selector,
+ .mScope = c.kAudioObjectPropertyScopeGlobal,
+ .mElement = c.kAudioObjectPropertyElementMain,
+ };
+
+ const status = c.AudioObjectGetPropertyData(device_id, &address, 0, null, &size, @ptrCast(&cf_string));
+ if (status != 0 or cf_string == null) return null;
+ defer c.CFRelease(cf_string);
+
+ if (c.CFStringGetCString(cf_string, buf.ptr, @intCast(buf.len), c.kCFStringEncodingUTF8) != 0) {
+ const len = std.mem.indexOfScalar(u8, buf, 0) orelse buf.len;
+ return buf[0..len];
+ }
+ return null;
+}
+
+fn getChannelCount(device_id: u32, scope: u32) u32 {
+ var size: u32 = 0;
+ var address = c.AudioObjectPropertyAddress{
+ .mSelector = c.kAudioDevicePropertyStreamConfiguration,
+ .mScope = scope,
+ .mElement = c.kAudioObjectPropertyElementMain,
+ };
+
+ var status = c.AudioObjectGetPropertyDataSize(device_id, &address, 0, null, &size);
+ if (status != 0 or size == 0) return 0;
+
+ var buf: [4096]u8 align(@alignOf(c.AudioBufferList)) = undefined;
+ if (size > buf.len) return 0;
+
+ status = c.AudioObjectGetPropertyData(device_id, &address, 0, null, &size, &buf);
+ if (status != 0) return 0;
+
+ const list: *const c.AudioBufferList = @ptrCast(&buf);
+ var channels: u32 = 0;
+ for (0..list.mNumberBuffers) |i| {
+ const buffers: [*]const c.AudioBuffer = &list.mBuffers;
+ channels += buffers[i].mNumberChannels;
+ }
+ return channels;
+}
+
+fn getDefaultDevice(scope: u32) u32 {
+ const selector: u32 = if (scope == c.kAudioDevicePropertyScopeInput)
+ c.kAudioHardwarePropertyDefaultInputDevice
+ else
+ c.kAudioHardwarePropertyDefaultOutputDevice;
+
+ var device_id: u32 = 0;
+ var size: u32 = @sizeOf(u32);
+ var address = c.AudioObjectPropertyAddress{
+ .mSelector = selector,
+ .mScope = c.kAudioObjectPropertyScopeGlobal,
+ .mElement = c.kAudioObjectPropertyElementMain,
+ };
+
+ const status = c.AudioObjectGetPropertyData(c.kAudioObjectSystemObject, &address, 0, null, &size, &device_id);
+ if (status != 0) return 0;
+ return device_id;
+}
+
+pub fn enumerateDevices(allocator: std.mem.Allocator) ![]AudioDevice {
+ // Get device count
+ var size: u32 = 0;
+ var address = c.AudioObjectPropertyAddress{
+ .mSelector = c.kAudioHardwarePropertyDevices,
+ .mScope = c.kAudioObjectPropertyScopeGlobal,
+ .mElement = c.kAudioObjectPropertyElementMain,
+ };
+
+ var status = c.AudioObjectGetPropertyDataSize(c.kAudioObjectSystemObject, &address, 0, null, &size);
+ if (status != 0) return error.CoreAudioError;
+
+ const device_count = size / @sizeOf(u32);
+ if (device_count == 0) return &[_]AudioDevice{};
+
+ const device_ids = try allocator.alloc(u32, device_count);
+ defer allocator.free(device_ids);
+
+ status = c.AudioObjectGetPropertyData(c.kAudioObjectSystemObject, &address, 0, null, &size, device_ids.ptr);
+ if (status != 0) return error.CoreAudioError;
+
+ const default_input = getDefaultDevice(c.kAudioDevicePropertyScopeInput);
+ const default_output = getDefaultDevice(c.kAudioDevicePropertyScopeOutput);
+
+ var devices = std.ArrayList(AudioDevice).init(allocator);
+
+ for (device_ids) |did| {
+ const input_channels = getChannelCount(did, c.kAudioDevicePropertyScopeInput);
+ const output_channels = getChannelCount(did, c.kAudioDevicePropertyScopeOutput);
+
+ // Skip devices with no audio channels
+ if (input_channels == 0 and output_channels == 0) continue;
+
+ var dev = AudioDevice{
+ .id = did,
+ .is_input = input_channels > 0,
+ .is_output = output_channels > 0,
+ .is_default = (did == default_input) or (did == default_output),
+ };
+
+ if (getStringProperty(did, c.kAudioObjectPropertyName, &dev.name)) |name| {
+ dev.name_len = name.len;
+ } else {
+ const fallback = "Unknown Device";
+ @memcpy(dev.name[0..fallback.len], fallback);
+ dev.name_len = fallback.len;
+ }
+
+ try devices.append(dev);
+ }
+
+ return devices.toOwnedSlice();
+}
+
+pub fn devicesToJson(allocator: std.mem.Allocator, devices: []const AudioDevice) ![]u8 {
+ var json = std.ArrayList(u8).init(allocator);
+ const w = json.writer();
+
+ try w.writeAll("{\"inputs\":[");
+ var first_in = true;
+ for (devices) |d| {
+ if (!d.is_input) continue;
+ if (!first_in) try w.writeAll(",");
+ first_in = false;
+ try w.print("{{\"id\":\"{d}\",\"name\":\"{s}\",\"isDefault\":{s}}}", .{
+ d.id,
+ d.name[0..d.name_len],
+ if (d.is_default) "true" else "false",
+ });
+ }
+ try w.writeAll("],\"outputs\":[");
+ var first_out = true;
+ for (devices) |d| {
+ if (!d.is_output) continue;
+ if (!first_out) try w.writeAll(",");
+ first_out = false;
+ try w.print("{{\"id\":\"{d}\",\"name\":\"{s}\",\"isDefault\":{s}}}", .{
+ d.id,
+ d.name[0..d.name_len],
+ if (d.is_default) "true" else "false",
+ });
+ }
+ try w.writeAll("]}");
+
+ return json.toOwnedSlice();
+}
diff --git a/examples/danzig-gain-ui/root.zig b/examples/danzig-gain-ui/root.zig
new file mode 100644
index 0000000..5c4f62a
--- /dev/null
+++ b/examples/danzig-gain-ui/root.zig
@@ -0,0 +1,39 @@
+// DanzigGain Standalone App
+// Native window with embedded WebView + CoreAudio device enumeration
+
+const std = @import("std");
+const danzig = @import("danzig");
+const webview = @import("webview");
+const coreaudio = @import("coreaudio");
+
+const UI_HTML: [:0]const u8 = @embedFile("ui_html");
+
+var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+
+pub fn main() !void {
+ const allocator = gpa.allocator();
+
+ const wv = webview.WebView.create(true, null);
+ defer wv.destroy() catch {};
+
+ try wv.setTitle("DanzigGain");
+ try wv.setSize(600, 700, .Fixed);
+
+ // Enumerate CoreAudio devices and inject into JS before loading UI
+ const devices = coreaudio.enumerateDevices(allocator) catch &[_]coreaudio.AudioDevice{};
+ const json = coreaudio.devicesToJson(allocator, devices) catch "{}";
+
+ // Inject the device list as a global JS variable, then load the HTML
+ var init_js_buf: [8192]u8 = undefined;
+ const init_js = std.fmt.bufPrintZ(&init_js_buf,
+ "window.__audioDevices = {s};",
+ .{json},
+ ) catch "window.__audioDevices = {};";
+ try wv.init(init_js);
+
+ try wv.setHtml(UI_HTML);
+
+ std.debug.print("DanzigGain standalone app running.\n", .{});
+
+ try wv.run();
+}
diff --git a/src/params.zig b/src/params.zig
new file mode 100644
index 0000000..a9c367e
--- /dev/null
+++ b/src/params.zig
@@ -0,0 +1,166 @@
+// Lock-free atomic parameter system for real-time audio
+//
+// Architecture:
+// Host/UI thread ---[atomic store]--> ParamStore ---[atomic load]--> DSP kernel
+//
+// - Zero allocations on the audio thread
+// - No locks, no mutexes — pure atomics
+// - Smoothed reads via per-sample exponential ramp
+// - Compact: one cache line per parameter (64 bytes)
+
+const std = @import("std");
+const audio = @import("audio.zig");
+
+/// Single atomic parameter with smoothing for the audio thread.
+/// Fits in one cache line (64 bytes) to avoid false sharing.
+pub const AtomicParam = extern struct {
+ /// Normalized value [0, 1] — written by host/UI, read by DSP
+ raw: std.atomic.Value(u32) = std.atomic.Value(u32).init(@bitCast(@as(f32, 0.0))),
+ /// Smoothed value — only touched by the audio thread
+ smoothed: f32 = 0.0,
+ /// Plain (denormalized) range
+ min: f32 = 0.0,
+ max: f32 = 1.0,
+ default_normalized: f32 = 0.5,
+ /// Smoothing coefficient (0 = instant, 0.999 = very slow)
+ smooth_coeff: f32 = 0.0,
+ _pad: [40]u8 = undefined, // pad to 64 bytes
+
+ const Self = @This();
+
+ pub fn init(min: f32, max: f32, default_norm: f32, smooth_ms: f32, sample_rate: f32) Self {
+ var p = Self{
+ .min = min,
+ .max = max,
+ .default_normalized = default_norm,
+ };
+ p.setSmoothingMs(smooth_ms, sample_rate);
+ p.setNormalized(default_norm);
+ // Initialize smoothed to the default value immediately
+ p.smoothed = denorm(default_norm, min, max);
+ return p;
+ }
+
+ /// Set from host/UI thread — lock-free, wait-free
+ pub fn setNormalized(self: *Self, value: f32) void {
+ const clamped = std.math.clamp(value, 0.0, 1.0);
+ self.raw.store(@bitCast(clamped), .release);
+ }
+
+ /// Get normalized value (for host reporting)
+ pub fn getNormalized(self: *const Self) f32 {
+ return @bitCast(self.raw.load(.acquire));
+ }
+
+ /// Get the target plain value (no smoothing)
+ pub fn getTargetPlain(self: *const Self) f32 {
+ return denorm(self.getNormalized(), self.min, self.max);
+ }
+
+ /// Advance smoothing by one sample and return the smoothed plain value.
+ /// Call this once per sample in the audio callback.
+ 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;
+ }
+
+ /// Snap smoothed value to target (call on transport start, preset load, etc.)
+ pub fn snap(self: *Self) void {
+ self.smoothed = self.getTargetPlain();
+ }
+
+ /// Recalculate smoothing coefficient for a given time constant in ms
+ pub fn setSmoothingMs(self: *Self, ms: f32, sample_rate: f32) void {
+ if (ms <= 0.0 or sample_rate <= 0.0) {
+ self.smooth_coeff = 0.0;
+ } else {
+ // exp(-1 / (ms * sr / 1000)) — standard one-pole coefficient
+ self.smooth_coeff = @exp(-1000.0 / (ms * sample_rate));
+ }
+ }
+};
+
+/// Fixed-size parameter store — no heap, no locks, cache-friendly.
+/// Max 64 parameters (more than enough for any plugin).
+pub fn ParamStore(comptime max_params: u32) type {
+ return struct {
+ params: [max_params]AtomicParam = undefined,
+ count: u32 = 0,
+
+ const Self = @This();
+
+ /// Register a parameter. Returns its index. Call during init only.
+ pub fn add(self: *Self, min: f32, max: f32, default_norm: f32, smooth_ms: f32, sample_rate: f32) u32 {
+ const idx = self.count;
+ std.debug.assert(idx < max_params);
+ self.params[idx] = AtomicParam.init(min, max, default_norm, smooth_ms, sample_rate);
+ self.count += 1;
+ return idx;
+ }
+
+ /// Set normalized value by index — host/UI thread
+ pub fn setNormalized(self: *Self, idx: u32, value: f32) void {
+ if (idx < self.count) self.params[idx].setNormalized(value);
+ }
+
+ /// Get normalized value by index — any thread
+ pub fn getNormalized(self: *const Self, idx: u32) f32 {
+ if (idx < self.count) return self.params[idx].getNormalized();
+ return 0.0;
+ }
+
+ /// Tick one sample for parameter at index — audio thread only
+ pub fn tick(self: *Self, idx: u32) f32 {
+ return self.params[idx].tick();
+ }
+
+ /// Tick all parameters one sample — audio thread only
+ pub fn tickAll(self: *Self) void {
+ for (0..self.count) |i| _ = self.params[i].tick();
+ }
+
+ /// Snap all smoothed values to targets — audio thread
+ pub fn snapAll(self: *Self) void {
+ for (0..self.count) |i| self.params[i].snap();
+ }
+
+ /// Get smoothed plain value — audio thread only (call after tick)
+ pub fn getSmoothed(self: *const Self, idx: u32) f32 {
+ return self.params[idx].smoothed;
+ }
+
+ /// Update sample rate for all smoothing coefficients
+ pub fn setSampleRate(self: *Self, sample_rate: f32) void {
+ for (0..self.count) |i| {
+ // Recalculate with same ms but new rate — store the ms? No,
+ // just recalc from current coeff. Simpler: plugins should call
+ // setSmoothingMs on each param individually if they want rate-dependent smoothing.
+ _ = self.params[i]; // no-op for now; smooth_coeff is sample-rate-dependent
+ // so setupProcessing should reinit params.
+ }
+ _ = sample_rate;
+ }
+ };
+}
+
+fn denorm(normalized: f32, min: f32, max: f32) f32 {
+ return min + normalized * (max - min);
+}
+
+fn norm(plain: f32, min: f32, max: f32) f32 {
+ if (max <= min) return 0.5;
+ return (plain - min) / (max - min);
+}
+
+// Compile-time tests
+comptime {
+ // AtomicParam must be exactly 64 bytes (one cache line)
+ if (@sizeOf(AtomicParam) != 64) {
+ @compileError("AtomicParam must be 64 bytes for cache line alignment");
+ }
+}
diff --git a/src/root.zig b/src/root.zig
index 834784c..1d69a58 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -3,6 +3,7 @@
pub const vst3 = @import("vst3.zig");
pub const plugin = @import("plugin.zig");
pub const audio = @import("audio.zig");
+pub const params = @import("params.zig");
pub const Plugin = plugin.Plugin;
pub const Parameter = plugin.Parameter;
@@ -14,6 +15,9 @@ pub const AudioBuffer = audio.AudioBuffer;
pub const GainProcessor = audio.GainProcessor;
pub const SimpleRamp = audio.SimpleRamp;
+pub const AtomicParam = params.AtomicParam;
+pub const ParamStore = params.ParamStore;
+
pub const normalize = plugin.normalize;
pub const denormalize = plugin.denormalize;
pub const dBToLinear = audio.dBToLinear;
diff --git a/src/tests.zig b/src/tests.zig
index 09df94d..941aa81 100644
--- a/src/tests.zig
+++ b/src/tests.zig
@@ -8,6 +8,7 @@ const std = @import("std");
const testing = std.testing;
const audio = @import("audio.zig");
+const params = @import("params.zig");
const plugin = @import("plugin.zig");
// --- dB <-> linear ---------------------------------------------------------
@@ -184,3 +185,109 @@ test "denormalize inverts normalize" {
try testing.expectApproxEqAbs(plain, plugin.denormalize(n, -48.0, 48.0), 1e-9);
}
}
+
+// --- AtomicParam -----------------------------------------------------------
+
+test "AtomicParam: occupies exactly one cache line" {
+ try testing.expectEqual(@as(usize, 64), @sizeOf(params.AtomicParam));
+}
+
+test "AtomicParam: init seeds smoothed to the default plain value" {
+ var p = params.AtomicParam.init(-48.0, 48.0, 0.5, 0.0, 48000.0);
+ try testing.expectApproxEqAbs(@as(f32, 0.5), p.getNormalized(), 1e-6);
+ try testing.expectApproxEqAbs(@as(f32, 0.0), p.smoothed, 1e-4);
+}
+
+test "AtomicParam: setNormalized clamps to [0, 1]" {
+ var p = params.AtomicParam.init(0.0, 1.0, 0.5, 0.0, 48000.0);
+ p.setNormalized(2.0);
+ try testing.expectEqual(@as(f32, 1.0), p.getNormalized());
+ p.setNormalized(-1.0);
+ try testing.expectEqual(@as(f32, 0.0), p.getNormalized());
+}
+
+test "AtomicParam: getTargetPlain denormalizes into the declared range" {
+ var p = params.AtomicParam.init(100.0, 200.0, 0.0, 0.0, 48000.0);
+ p.setNormalized(0.25);
+ try testing.expectApproxEqAbs(@as(f32, 125.0), p.getTargetPlain(), 1e-4);
+}
+
+test "AtomicParam: zero smoothing makes tick jump straight to target" {
+ var p = params.AtomicParam.init(0.0, 10.0, 0.0, 0.0, 48000.0);
+ p.setNormalized(1.0);
+ try testing.expectApproxEqAbs(@as(f32, 10.0), p.tick(), 1e-4);
+}
+
+test "AtomicParam: smoothing approaches the target without overshooting" {
+ var p = params.AtomicParam.init(0.0, 1.0, 0.0, 50.0, 48000.0);
+ p.setNormalized(1.0);
+ try testing.expect(p.smooth_coeff > 0.0);
+
+ var prev: f32 = p.smoothed;
+ for (0..64) |_| {
+ const v = p.tick();
+ try testing.expect(v >= prev); // monotone
+ try testing.expect(v <= 1.0); // never overshoots
+ prev = v;
+ }
+ try testing.expect(prev > 0.0); // and it actually moved
+}
+
+test "AtomicParam: snap jumps smoothed to target immediately" {
+ var p = params.AtomicParam.init(0.0, 10.0, 0.0, 100.0, 48000.0);
+ p.setNormalized(1.0);
+ p.snap();
+ try testing.expectApproxEqAbs(@as(f32, 10.0), p.smoothed, 1e-4);
+}
+
+test "AtomicParam: setSmoothingMs treats non-positive input as instant" {
+ var p = params.AtomicParam.init(0.0, 1.0, 0.0, 10.0, 48000.0);
+ p.setSmoothingMs(0.0, 48000.0);
+ try testing.expectEqual(@as(f32, 0.0), p.smooth_coeff);
+ p.setSmoothingMs(10.0, 0.0);
+ try testing.expectEqual(@as(f32, 0.0), p.smooth_coeff);
+}
+
+// --- ParamStore ------------------------------------------------------------
+
+test "ParamStore: add returns sequential indices and tracks count" {
+ var store = params.ParamStore(8){};
+ try testing.expectEqual(@as(u32, 0), store.add(0.0, 1.0, 0.5, 0.0, 48000.0));
+ try testing.expectEqual(@as(u32, 1), store.add(-48.0, 48.0, 0.5, 0.0, 48000.0));
+ try testing.expectEqual(@as(u32, 2), store.count);
+}
+
+test "ParamStore: set and get round-trip by index" {
+ var store = params.ParamStore(4){};
+ const idx = store.add(0.0, 1.0, 0.5, 0.0, 48000.0);
+ store.setNormalized(idx, 0.25);
+ try testing.expectApproxEqAbs(@as(f32, 0.25), store.getNormalized(idx), 1e-6);
+}
+
+test "ParamStore: out-of-range index is ignored rather than trapping" {
+ var store = params.ParamStore(4){};
+ _ = store.add(0.0, 1.0, 0.5, 0.0, 48000.0);
+ store.setNormalized(99, 1.0); // no-op
+ try testing.expectEqual(@as(f32, 0.0), store.getNormalized(99));
+}
+
+test "ParamStore: tickAll advances every registered parameter" {
+ var store = params.ParamStore(4){};
+ const a = store.add(0.0, 10.0, 0.0, 0.0, 48000.0);
+ const b = store.add(0.0, 20.0, 0.0, 0.0, 48000.0);
+ store.setNormalized(a, 1.0);
+ store.setNormalized(b, 1.0);
+
+ store.tickAll();
+
+ try testing.expectApproxEqAbs(@as(f32, 10.0), store.getSmoothed(a), 1e-4);
+ try testing.expectApproxEqAbs(@as(f32, 20.0), store.getSmoothed(b), 1e-4);
+}
+
+test "ParamStore: snapAll jumps every smoothed value to its target" {
+ var store = params.ParamStore(4){};
+ const a = store.add(0.0, 10.0, 0.0, 500.0, 48000.0);
+ store.setNormalized(a, 1.0);
+ store.snapAll();
+ try testing.expectApproxEqAbs(@as(f32, 10.0), store.getSmoothed(a), 1e-4);
+}