From 08caf698850aebee1fd87865b48b8bdd3a8717d8 Mon Sep 17 00:00:00 2001 From: godofecht Date: Tue, 21 Jul 2026 11:01:18 +0100 Subject: [PATCH] Restore the universal VST3 build, GUI example, and parameter store I destroyed this work earlier with `git reset --hard` on a dirty tree. It was never committed, so none of it was recoverable from git. This reconstructs it. Recovered rather than guessed where possible: stale artifacts left in zig-out from the last good build confirmed the target layout (DanzigGain_arm64 as arm64, DanzigGain_x86 as x86_64, danzig-gain-ui as an arm64 executable linking CoreAudio, CoreFoundation and WebKit). Universal VST3 bundle, behind a `vst3` step: - Builds DanzigGain for aarch64-macos and x86_64-macos, then merges them with lipo so one bundle loads on either architecture. - Lays out Contents/{Info.plist,PkgInfo,MacOS/DanzigGain}. The plist validates with plutil; the binary reports as a 2-architecture fat Mach-O. - `install-vst3` copies it to ~/Library/Audio/Plug-Ins/VST3/. Kept off the default install: it doubles compile work and is macOS-only. GUI example (examples/danzig-gain-ui, which survived as untracked files): webview front end over CoreAudio, with ui/index.html embedded so the binary is self-contained. Needs the webview dependency, hence the new build.zig.zon. The Zig bindings are declarations only, so it links the dependency's webviewStatic artifact for the C++ implementation. Parameter store: src/params.zig also survived untracked, but its re-exports in root.zig and its tests were lost, leaving it orphaned and unreachable. Both restored, taking the unit suite from 22 to 35 tests. Also adds run-gui and run-standalone steps, and gitignores the .wav files the standalone processor writes. Verified: 24/24 build steps, 35/35 unit tests, 10/10 vst3 steps, lipo reports "x86_64 arm64". Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + build.zig | 170 ++++++++++++++++++++++- build.zig.zon | 18 +++ docs/architecture.svg | 190 ++++++++++++++++++++++++++ examples/danzig-gain-ui/coreaudio.zig | 168 +++++++++++++++++++++++ examples/danzig-gain-ui/root.zig | 39 ++++++ src/params.zig | 166 ++++++++++++++++++++++ src/root.zig | 4 + src/tests.zig | 107 +++++++++++++++ 9 files changed, 862 insertions(+), 3 deletions(-) create mode 100644 build.zig.zon create mode 100644 docs/architecture.svg create mode 100644 examples/danzig-gain-ui/coreaudio.zig create mode 100644 examples/danzig-gain-ui/root.zig create mode 100644 src/params.zig 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Danzig Architecture + Pure Zig VST3 Plugin Framework — Zero Dependencies + + + + DAW Host (Ableton, Logic, REAPER, etc.) + + + + GetPluginFactory() + + + + IPluginFactory / IPluginFactory2 + countClasses() · getClassInfo() · createInstance() + Vendor: Quilio · Category: "Audio Module Class" · SubCat: "Fx" + + + + createInstance(CID) + + + + Component (extern struct — COM Multiple Inheritance) + + + + + IComponent vtbl + initialize · terminate + getBusCount · getBusInfo + setActive · setState/getState + + + + IAudioProcessor vtbl + setupProcessing · setProcessing + process(ProcessData*) + getBusArrangement · canProcessSampleSize + + + + IEditController vtbl + getParameterCount · getParameterInfo + setParamNormalized · getParamNormalized + createView("editor") → IPluginView + + + + Shared State (extern struct fields) + sample_rate: f64 · active: u8 · processing: u8 · store: *ParamStore(2) + + + COM interface pointers resolved via @offsetOf — guaranteed by extern struct layout + + + + + + Lock-Free Atomic Parameter Store + ParamStore(2) — Fixed-size, zero-heap, cache-line aligned + + + + AtomicParam[0]: Gain + range: -48 to +48 dB · default: 0.5 (0 dB) + raw: atomic u32 (normalized f32) + smoothed: f32 · smooth_coeff: exp(-1000/(5ms*sr)) + + + AtomicParam[1]: Bypass + range: 0 to 1 · default: 0.0 (off) + raw: atomic u32 (normalized f32) + no smoothing · read directly via getNormalized() + + + Each AtomicParam = exactly 64 bytes (1 cache line) — no false sharing + + + + + + + Host / UI Thread + setParamNormalized(id, value) + + store.setNormalized(id, value) + + atomic store (release semantics) + WAIT-FREE · O(1) · NO LOCKS + + + Audio Thread (Real-Time) + readParamChanges(store, host_changes) + + store.tick(Param.Gain) per sample + atomic load → exponential smooth → dBToLinear() + out[ch][s] = in[ch][s] * gain_linear + LOCK-FREE · ZERO ALLOC · CACHE-FRIENDLY + + + + atomic + + + + + + DSP Kernel + gain_db = store.tick(0) → dBToLinear() + out[ch][s] = in[ch][s] × gain_linear + + + + + + IPluginView (WebView UI) + WKWebView created via ObjC runtime (dlsym) + HTML/CSS/JS embedded at compile time (@embedFile) + Attached to host NSView · 600×500 fixed size + Gain slider · Bypass · Waveform visualization + Settings panel: CoreAudio device enum · sample rate · buffer size + + + + Source Modules (src/) + + + vst3.zig — C ABI types + + + audio.zig — DSP utils + + + params.zig — Atomics + + + plugin.zig — Base types + + root.zig re-exports all modules · Universal binary: arm64 + x86_64 via lipo + + + + zig build vst3 + + + zig build run-gui + + + zig build test + + + zig build run-standalone + + + github.com/godofecht/danzig · Quilio · Pure Zig · Zero Dependencies + 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); +}