diff --git a/DANZIG.md b/DANZIG.md index 70223e0..b3f3584 100644 --- a/DANZIG.md +++ b/DANZIG.md @@ -96,18 +96,23 @@ The `examples/danzig-gain` directory contains a complete stereo gain effect demo ```zig pub const GainPlugin = struct { - plugin: danzig.Plugin, - gainProcessor: danzig.GainProcessor, - - pub fn init(allocator: std.mem.Allocator) !*GainPlugin - pub fn process(self: *GainPlugin, inputs: []*[*]f32, outputs: []*[*]f32, numChannels: u32, numSamples: u32) void - pub fn setParameterNormalized(self: *GainPlugin, paramId: u32, normalized: f64) void + params: danzig.ParamStore(2), + sample_rate: f32, + + pub fn init(sample_rate: f32) GainPlugin + pub fn setSampleRate(self: *GainPlugin, sample_rate: f32) void + pub fn isBypassed(self: *const GainPlugin) bool + pub fn nextGain(self: *GainPlugin, bypassed: bool) f32 }; ``` +The rest of the file wraps that core in the VST3 C ABI: one object exposing +IComponent, IAudioProcessor and IEditController, a static IPluginFactory, and +the module entry points (`GetPluginFactory`, `bundleEntry`) a host looks for. + ### Parameter IDs -- `ParamID.Gain = 0`: Gain in dB (-48 to +48) -- `ParamID.Bypass = 1`: Bypass toggle +- `ParamIndex.gain = 0`: Gain in dB (-48 to +48) +- `ParamIndex.bypass = 1`: Bypass toggle ### Building ```bash diff --git a/README.md b/README.md index 5a7dd6e..692e3e5 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,144 @@ -# đŸŽĩ Danzig VST3 Framework +# danzig [![CI](https://github.com/godofecht/danzig/actions/workflows/ci.yml/badge.svg)](https://github.com/godofecht/danzig/actions/workflows/ci.yml) [![Zig](https://img.shields.io/badge/zig-0.14.1%20%7C%200.15.2-f7a41d)](https://ziglang.org/) -A modern, lightweight VST3 plugin development framework built in Zig with zero external dependencies. +A VST3 plugin framework in pure Zig. No JUCE, no Steinberg SDK, no C++ in the +core. `src/vst3.zig` implements the VST3 C ABI directly as `extern struct`s of +`callconv(.c)` function pointers, which is what a C++ vtable is at the machine +level. -## Project Status +**[Read the guide: docs/WIKI.md](docs/WIKI.md)** -✅ **Framework**: Complete and production-ready -✅ **Example Plugin**: Fully functional gain effect -✅ **Build System**: Standalone Zig-based build -✅ **Tests**: Passing verification suite -✅ **Documentation**: Comprehensive guides included +## Setup -## Quick Start - -### Build ```bash +git clone https://github.com/godofecht/danzig cd danzig -zig build -Doptimize=ReleaseFast +./setup.sh ``` -### Test +`setup.sh` checks your Zig version, builds, runs the tests, packages the +universal VST3 bundle, and prints where it landed. It exits non-zero on failure +and is safe to run repeatedly. Pass `--release` for a `ReleaseFast` build. + +By hand: + ```bash -zig build test +zig build # Build Summary: 29/29 steps succeeded +zig build test --summary all # Build Summary: 9/9 steps succeeded; 35/35 tests passed +zig build vst3 # universal arm64 + x86_64 bundle in zig-out/ +zig build install-vst3 # copy it to ~/Library/Audio/Plug-Ins/VST3/ ``` -Output: -``` -✓ Test executable compiles and links with danzig library -✓ Allocator initialized -✓ Danzig library linking successful! -``` +Then: -### Run the Example Plugin -The built VST3 plugin bundle is at: -``` -zig-out/DanzigGain.vst3/ +```bash +lipo -info zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain ``` -Copy to your DAW's VST3 plugin folder: -```bash -cp -r zig-out/DanzigGain.vst3 ~/Library/Audio/Plug-Ins/VST3/ +``` +Architectures in the fat file: zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain are: x86_64 arm64 ``` -## What's Included +## Requirements -### Core Library (`src/`) -- **vst3.zig** - VST3 C ABI bindings (IUnknown, IComponent, IAudioProcessor) -- **plugin.zig** - Plugin base class with parameter management -- **audio.zig** - Audio processing utilities (gain, ramping, DSP math) -- **root.zig** - Public API exports +- Zig 0.14.1 or 0.15.2. Both are tested in CI on every push. +- macOS for the VST3 bundle, `install-vst3`, and the GUI example. The library, + the unit tests, and the CLI examples are portable Zig. +- Xcode command line tools, for `lipo` and the macOS SDK. -### Example Plugin (`examples/danzig-gain/`) -- Complete 100+ line gain effect -- Demonstrates parameter system -- Shows proper audio processing patterns -- Ready to copy and modify +## What's here -### Documentation (`docs/`) -See [docs/INDEX.md](docs/INDEX.md) for: -- Complete API reference -- Architecture explanations -- Real-world plugin examples -- Performance optimization tips -- Best practices guide +### Core library (`src/`) -## Architecture +| File | Contents | +|---|---| +| `vst3.zig` | The VST3 C ABI: `IUnknown`, `IPluginBase`, `IComponent`, `IAudioProcessor`, `IEditController`, and the structs they pass. | +| `plugin.zig` | Plugin lifecycle and a heap-backed `ParameterMap`. | +| `audio.zig` | `dBToLinear`, `GainProcessor`, `SimpleRamp`, `AudioBuffer`. | +| `params.zig` | Lock-free `AtomicParam` and `ParamStore(N)`. One cache line per parameter, no heap, no locks. | +| `tests.zig` | 35 unit tests. | -Danzig wraps VST3's complex COM machinery with type-safe Zig abstractions: +### Examples (`examples/`) -```zig -const my_plugin = try danzig.Plugin.init(allocator); -my_plugin.addParameter(gain_param); -my_plugin.process(inputs, outputs, num_channels, num_samples); -``` +| Example | Run it | +|---|---| +| [danzig-minimal](examples/danzig-minimal/). The smallest complete plugin, and the file to copy. | `zig build run-minimal` | +| [danzig-gain](examples/danzig-gain/). The plugin packaged into the `.vst3` bundle. | `zig build vst3` | +| [danzig-test](examples/danzig-test/). Drives the built plugin through the raw VST3 C ABI. | `zig build test-integration` | +| [danzig-gain-standalone](examples/danzig-gain-standalone/). Offline WAV gain processing. | `zig build run-standalone` | +| [danzig-webui](examples/danzig-webui/). An HTTP server in pure `std.net`. | `./zig-out/bin/danzig-webui` | +| [danzig-gain-ui](examples/danzig-gain-ui/). Native macOS window, WebView plus CoreAudio. | `zig build run-gui` | -No hidden allocations - explicit memory management throughout. +See [examples/README.md](examples/README.md) for the index. -## Build Artifacts +## Build artifacts -After building, you'll find: +`zig build` installs: ``` zig-out/ -├── lib/ -│ ├── libdanzig.a (2.3 KB) - Static library -│ ├── libDanzigGain.dylib (17 KB) - Compiled plugin -│ └── libdanzig_gain.dylib ├── bin/ -│ └── danzig_test (208 KB) - Test executable -├── DanzigGain.vst3/ - VST3 bundle for macOS -│ └── Contents/MacOS/DanzigGain +│ ├── danzig-minimal offline demo of the minimal plugin +│ ├── danzig-gain-standalone WAV gain processor +│ ├── danzig-webui HTTP server for the web UI +│ ├── danzig-gain-ui native window (macOS) +│ └── danzig_test VST3 ABI integration harness +└── lib/ + ├── libDanzigGain.dylib gain plugin, native arch + ├── libDanzigGain_arm64.dylib gain plugin, arm64 + ├── libDanzigGain_x86.dylib gain plugin, x86_64 + └── libDanzigMinimal.dylib minimal plugin, native arch ``` -## Key Features - -✨ **Zero Dependencies** - Only Zig stdlib -✨ **Type-Safe** - Full compile-time checking -✨ **No Hidden Allocations** - Explicit memory management -✨ **Production-Ready** - Fully tested and documented -✨ **Modern Zig** - Using latest Zig patterns and idioms +`zig build vst3` adds the bundle: -## Plugin Features (Gain Example) - -- Stereo input/output -- -48 to +48 dB gain range -- Smooth parameter ramping -- Sample-rate aware processing -- Memory pre-allocation - -## System Requirements - -- Zig 0.14.1 or 0.15.2 -- macOS (currently Mach-O format) -- VST3-compatible DAW - -## Getting Started with Development - -1. Read [docs/INDEX.md](docs/INDEX.md) -2. Check out `examples/danzig-gain/root.zig` -3. Copy the example as a template -4. Implement your plugin logic in the `process()` method -5. Rebuild and test - -## Documentation Map +``` +zig-out/DanzigGain.vst3/ +└── Contents/ + ├── Info.plist + ├── PkgInfo + └── MacOS/ + └── DanzigGain universal arm64 + x86_64 +``` -- **[INDEX.md](docs/INDEX.md)** - Navigation and quick reference -- **[Danzig-Complete-Guide.md](docs/Danzig-Complete-Guide.md)** - Full tutorial -- **[VST3-Architecture.md](docs/VST3-Architecture.md)** - Deep technical dive -- **[Real-World-Guide.md](docs/Real-World-Guide.md)** - Practical examples +`libdanzig.a` is not installed. The static library is linked into each target +rather than shipped on its own. -## Testing with pluginval +Sizes, from `zig build -Doptimize=ReleaseFast`: -The plugin has been built and signed correctly. For advanced testing: +| Artifact | Size | +|---|---| +| `DanzigGain.vst3/Contents/MacOS/DanzigGain` | 84 KB (universal) | +| `lib/libDanzigGain.dylib` | 52 KB | +| `lib/libDanzigMinimal.dylib` | 52 KB | +| `bin/danzig-minimal` | 168 KB | +| `bin/danzig-gain-standalone` | 288 KB | +| `bin/danzig-gain-ui` | 572 KB | -```bash -# Verify plugin exports entry point -nm zig-out/lib/libDanzigGain.dylib | grep GetPluginFactory +The default Debug build produces the same artifacts at roughly 1 to 2 MB each. -# Check code signature -codesign -vvv ~/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3 -``` +## Current state -See [PLUGINVAL_REPORT.md](PLUGINVAL_REPORT.md) for detailed validation results. +The core library, the tests, the build pipeline, and the non-plugin examples all +work. The VST3 factory in `examples/danzig-gain` is a stub: `getClassInfo` and +`createInstance` do not yet produce a class or an object, so a host scans the +bundle and reports zero plugins. See +[Current state](docs/WIKI.md#current-state) for the detail and for what +finishing it involves. -## Next Steps +## Documentation -- Modify `examples/danzig-gain/root.zig` to create your own plugin -- Add new audio processing to `src/audio.zig` for common effects -- Test in your favorite DAW -- Share your creations! +- **[docs/WIKI.md](docs/WIKI.md)**. The single-page guide. Architecture, + quickstart, parameters, audio helpers, bundle packaging, testing, + troubleshooting. +- [docs/INDEX.md](docs/INDEX.md). The older multi-page docs. ## License MIT. See [LICENSE](LICENSE). ---- - -Built with â¤ī¸ in Zig | Framework for VST3 plugin development +VST is a trademark of Steinberg Media Technologies GmbH. danzig vendors no +Steinberg SDK code, and shipping plugins in VST3 format is governed by +Steinberg's own terms, independently of danzig's MIT license. diff --git a/build.zig b/build.zig index d0d4437..247b21c 100644 --- a/build.zig +++ b/build.zig @@ -38,7 +38,8 @@ pub fn build(b: *std.Build) void { // Install to zig-out/lib/ b.installArtifact(danzig_gain); - // Test executable + // Integration harness. Links the plugin itself so it can call the exported + // GetPluginFactory entry point and drive it through the raw VST3 C ABI. const danzig_test = b.addExecutable(.{ .name = "danzig_test", .root_module = b.createModule(.{ @@ -47,9 +48,43 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }), }); + danzig_test.root_module.addImport("danzig", danzig_module); danzig_test.linkLibrary(danzig_lib); + danzig_test.linkLibrary(danzig_gain); b.installArtifact(danzig_test); + // Minimal plugin template. Built twice from one source: as the shared + // library a host would load, and as an executable so the DSP can be run + // and checked without a host. + const danzig_minimal = b.addLibrary(.{ + .name = "DanzigMinimal", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/danzig-minimal/root.zig"), + .target = target, + .optimize = optimize, + }), + .linkage = .dynamic, + }); + danzig_minimal.root_module.addImport("danzig", danzig_module); + danzig_minimal.linkLibrary(danzig_lib); + b.installArtifact(danzig_minimal); + + const danzig_minimal_demo = b.addExecutable(.{ + .name = "danzig-minimal", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/danzig-minimal/root.zig"), + .target = target, + .optimize = optimize, + }), + }); + danzig_minimal_demo.root_module.addImport("danzig", danzig_module); + danzig_minimal_demo.linkLibrary(danzig_lib); + b.installArtifact(danzig_minimal_demo); + + const run_minimal = b.addRunArtifact(danzig_minimal_demo); + const run_minimal_step = b.step("run-minimal", "Run the minimal plugin template offline"); + run_minimal_step.dependOn(&run_minimal.step); + // Standalone audio processor const danzig_gain_standalone = b.addExecutable(.{ .name = "danzig-gain-standalone", diff --git a/docs/WIKI.md b/docs/WIKI.md new file mode 100644 index 0000000..258a74d --- /dev/null +++ b/docs/WIKI.md @@ -0,0 +1,883 @@ +# danzig + +A VST3 plugin framework written in pure Zig. No JUCE. No Steinberg SDK. No C++ +at all in the core. + +Source: [github.com/godofecht/danzig](https://github.com/godofecht/danzig) + +--- + +## Contents + +- [What danzig is](#what-danzig-is) +- [Current state](#current-state) +- [Architecture](#architecture) +- [Prerequisites](#prerequisites) +- [Quickstart](#quickstart) +- [The parameter system](#the-parameter-system) +- [The audio helpers](#the-audio-helpers) +- [Building the universal VST3 bundle](#building-the-universal-vst3-bundle) +- [Testing](#testing) +- [Examples](#examples) +- [Troubleshooting](#troubleshooting) +- [Licensing and trademarks](#licensing-and-trademarks) + +--- + +## What danzig is + +VST3 is a C ABI dressed up as COM. A plugin is a shared library exporting one +symbol, `GetPluginFactory`. The host calls it, reads the first machine word of +the returned pointer as a vtable pointer, and calls through that vtable to +discover classes, create objects, and push audio buffers. + +That contract is small. It is also the only part of Steinberg's SDK a plugin +strictly needs. Everything else in the SDK is C++ scaffolding around it. + +danzig implements the contract directly in Zig. `src/vst3.zig` declares the +interfaces as `extern struct`s of `callconv(.c)` function pointers, which is +exactly what a C++ vtable is at the machine level. A plugin fills in the +function pointers and returns a pointer to the struct. The host cannot tell the +difference. + +The reasons to do this rather than use JUCE: + +**Fast builds.** A clean build of the library, five example binaries, and both +architectures of the plugin takes about 5.6 seconds on an M-series Mac. A +no-change rebuild takes 0.6 seconds, and packaging the universal bundle on top +of a warm cache takes 0.5 seconds. There is no CMake step and no dependency +tree. + +**One binary format decision, made explicitly.** The bundle layout, the +`Info.plist`, and the `lipo` invocation are twenty lines of `build.zig` you can +read. Nothing is hidden behind a framework's packaging step. + +**Allocation is visible.** Zig has no hidden allocations and no destructors that +run at surprising times. On the audio thread that matters. The parameter store +in `src/params.zig` is a fixed array of atomics with no heap involvement at all, +which you can verify by reading 160 lines. + +**Cross-compilation is free.** Zig builds `x86_64-macos` from an arm64 machine +with no extra toolchain. That is what makes the universal bundle a build step +rather than a CI matrix: `build.zig` compiles the plugin for both architectures +and merges them with `lipo`, on whichever machine you happen to be on. + +macOS is the only supported platform today. The VST3 bundle layout, the +`install-vst3` step, and the GUI example are all macOS-specific. The library, +the unit tests, and the command-line examples are portable Zig and should build +anywhere Zig runs, though only macOS is tested. + +--- + +## Current state + +Honest summary, because the difference matters if you are choosing a framework. + +**Working and tested.** + +- The core library: `vst3.zig`, `plugin.zig`, `audio.zig`, `params.zig`. +- 35 unit tests covering dB conversion, ramps, buffers, and the atomic + parameter store. +- An integration harness that links the built plugin, calls its exported + `GetPluginFactory`, and drives the returned object through the raw C ABI. +- A universal arm64 + x86_64 `.vst3` bundle that installs into the macOS plugin + folder and is ad-hoc signed by the linker. +- Three runnable non-plugin examples: an offline WAV processor, an HTTP server + serving the web UI, and a native window with an embedded WebView and + CoreAudio device enumeration. + +**Not finished.** + +The factory in `examples/danzig-gain` is a stub. `countClasses` returns 1, but +`getClassInfo` writes nothing into the host's buffer and `createInstance` +returns without producing an object. A host therefore scans the bundle, finds +the entry point, and reports zero usable classes: + +```bash +/Applications/pluginval.app/Contents/MacOS/pluginval \ + --validate zig-out/DanzigGain.vst3 --strictness-level 5 --timeout-ms 20000 +``` + +``` +Started validating: .../danzig/zig-out/DanzigGain.vst3 +Random seed: 0x6afa9d8 +Validation started +Strictness level: 5 +----------------------------------------------------------------- +Starting tests in: pluginval / Scan for plugins located in: .../DanzigGain.vst3... +Num plugins found: 0 +!!! Test 1 failed: No types found. This usually means the plugin binary is missing +or damaged, an incompatible format or that it is an AU that isn't found by macOS +so can't be created. +FAILED!! 1 test failed, out of a total of 1 +FAILURE +*** FAILED +``` + +Completing it means filling in `getClassInfo` with a populated `PClassInfo` +(class ID, cardinality, category string, name) and having `createInstance` +return objects implementing `IComponent`, `IAudioProcessor`, and +`IEditController`. The interface declarations for all three already exist in +`src/vst3.zig`. The wiring does not. + +So: use danzig today as a DSP and parameter library with a working VST3 build +pipeline. The last mile into a DAW is the open work. + +--- + +## Architecture + +### 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: + +```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++. + +```zig +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. + +```zig +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: + +1. `countClasses()` to learn how many classes the binary exports. +2. `getClassInfo(i, &info)` for each, reading the class ID, category, and name. +3. `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`: + +```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. + +--- + +## Prerequisites + +- **Zig 0.14.1 or 0.15.2.** Both are tested in CI on every push. The sources + use spellings valid in both: the `root_module` build API, `callconv(.c)`, + and `net.Stream.read`. Other versions may work and are unsupported. +- **macOS** for the VST3 bundle, the `install-vst3` step, and the GUI example. +- **Xcode command line tools**, for `lipo` and the macOS SDK. +- **A VST3 host** if you want to scan the bundle. + +Nothing else. There is no CMake, no vendored SDK, and one optional Zig +dependency (`webview`) that is fetched lazily and only when you build the GUI +example. + +Install Zig with Homebrew or from ziglang.org: + +```bash +brew install zig # currently 0.15.2 +# or download 0.14.1 / 0.15.2 from https://ziglang.org/download/ +``` + +--- + +## Quickstart + +Five minutes, from clone to an installed bundle. + +### 1. Clone and run setup + +```bash +git clone https://github.com/godofecht/danzig +cd danzig +./setup.sh +``` + +`setup.sh` checks your Zig version, builds, runs the tests, builds the universal +bundle, and prints where it landed. It exits non-zero if any of that fails, and +it is safe to run repeatedly. Add `--release` for a `ReleaseFast` build. + +If you prefer to do it by hand, the four commands are below. + +### 2. Build + +```bash +zig build +``` + +``` +Build Summary: 29/29 steps succeeded +``` + +### 3. Test + +```bash +zig build test --summary all +``` + +``` +Build Summary: 9/9 steps succeeded; 35/35 tests passed +``` + +### 4. Package the bundle + +```bash +zig build vst3 +lipo -info zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain +``` + +``` +Architectures in the fat file: zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain are: x86_64 arm64 +``` + +### 5. Install it + +```bash +zig build install-vst3 +``` + +This removes any previous copy and writes a fresh one to +`~/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3`. Verify it: + +```bash +lipo -info ~/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3/Contents/MacOS/DanzigGain +``` + +``` +Architectures in the fat file: /Users/you/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3/Contents/MacOS/DanzigGain are: x86_64 arm64 +``` + +### 6. Load it in a DAW + +Restart your DAW so it rescans the plugin folder. As of today the scan finds the +bundle and the entry point but reports no instantiable classes, for the reason +described under [Current state](#current-state). The bundle structure, the +universal binary, the `Info.plist`, and the ad-hoc signature are all correct and +verifiable: + +```bash +codesign -dvv zig-out/DanzigGain.vst3 +``` + +``` +Executable=.../zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain +Identifier=libDanzigGain_arm64.dylib +Format=bundle with Mach-O universal (x86_64 arm64) +CodeDirectory v=20400 size=242 flags=0x20002(adhoc,linker-signed) hashes=4+0 location=embedded +Signature=adhoc +``` + +### 7. Hear the DSP without a DAW + +```bash +zig build run-minimal +``` + +``` +danzig-minimal: one parameter, one line of DSP + +Trim range is -24 to +24 dB, 20 ms smoothing, 48 kHz. + + full cut normalized 0.00 -> -24.00 dB (output 0.0631) + unity normalized 0.50 -> 0.00 dB (output 1.0000) + full boost normalized 1.00 -> 24.00 dB (output 15.8473) + +Copy examples/danzig-minimal/root.zig to start your own plugin. +``` + +That file is the template. Copy it and start editing `process`. + +--- + +## 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 + +```zig +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: + +```zig +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: + +```zig +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: + +```zig +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: + +```zig +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 + +```zig +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`. + +--- + +## The audio helpers + +`src/audio.zig`. Small, dependency-free, and covered by the unit tests. + +### dBToLinear and linearTodB + +```zig +pub fn dBToLinear(dB: f32) f32 { + return @exp(dB * 0.11512925464970229); // ln(10)/20 +} + +pub fn linearTodB(linear: f32) f32 { + if (linear <= 0.0) return -80.0; + return @log(linear) * 8.6858896380650365; // 20/ln(10) +} +``` + +Both avoid `pow` and `log10` in favour of a single `exp` or `log` and a +multiply. `linearTodB` floors at -80 dB for non-positive input, so silence +returns a finite number rather than negative infinity. + +The constants are worth a test of their own, and they have one. A previous copy +of this file carried a stray factor of ten in the exponent, which turned +`dBToLinear(6)` into 1000.0 instead of 1.9953. `src/tests.zig` now checks unity +at 0 dB, the factor of two at +6 dB, the factor of ten at +20 dB, and a full +round trip across -48 to +24 dB. + +### GainProcessor + +A gain stage with a built-in ramp. + +```zig +var g = danzig.GainProcessor{}; +g.setGain(6.0); // dB +g.process(&inputs, &outputs, channels, frames); +``` + +`setGain` converts to a linear target. `process` interpolates the current gain +toward the target by a fixed 0.001 per sample, so a change takes roughly a +thousand samples to substantially complete. `setNormalizedGain` maps `[0, 1]` +onto -48 to +48 dB, which is the range the example plugin exposes. + +The interpolation coefficient is fixed and not sample-rate aware. For a +rate-independent ramp, use `AtomicParam` with a millisecond time constant +instead. + +### SimpleRamp + +A linear ramp over a sample count, for anything that is not a gain. + +```zig +var r = danzig.SimpleRamp.init(0.0, 8); // start value, ramp length in samples +r.setTarget(1.0); +for (0..8) |_| _ = r.next(); +// r.getValue() == 1.0 +``` + +`setTarget` restarts the ramp from the current value. A ramp length of zero or +one is treated as instant. The final sample is snapped to the target exactly, so +the ramp does not leave a residue. + +### AudioBuffer + +An owned multi-channel buffer, for offline work and tests. It allocates, so keep +it off the audio thread. + +```zig +var buf = try danzig.AudioBuffer.init(allocator, 2, 512, 48000.0); +defer buf.deinit(allocator); +buf.clear(); +``` + +`init` zeroes every channel. `clear` and its alias `silence` re-zero. + +--- + +## Building the universal VST3 bundle + +macOS ships on two architectures. A plugin bundle holds one universal binary so +that hosts of either architecture load the same file. + +```bash +zig build vst3 +``` + +That step, in `build.zig`, does four things. + +**1. Compiles the plugin twice.** Once for `aarch64-macos` and once for +`x86_64-macos`, via `b.resolveTargetQuery`. Zig cross-compiles both from +whichever machine you are on, so no second toolchain is needed. + +```zig +const arches = [_]std.Target.Cpu.Arch{ .aarch64, .x86_64 }; +const suffixes = [_][]const u8{ "arm64", "x86" }; + +inline for (arches, suffixes) |arch, suffix| { + const arch_target = b.resolveTargetQuery(.{ .cpu_arch = arch, .os_tag = .macos }); + // ... build danzig_ and DanzigGain_ + lipo.addArtifactArg(plugin); +} +``` + +**2. Merges them with `lipo`.** `b.addSystemCommand(&.{ "lipo", "-create" })` +collects both artifacts and writes one fat Mach-O into the build cache. The +output path is a build-graph node, so the merge reruns only when an input +changes. + +**3. Lays out the bundle.** `b.addWriteFiles()` builds the directory: + +``` +DanzigGain.vst3/ + Contents/ + Info.plist generated from a template in build.zig + PkgInfo the 8 bytes "BNDL????" + MacOS/ + DanzigGain the universal binary, no file extension +``` + +The executable carries no extension. That is a bundle requirement, and it is why +the lipo output is copied rather than installed under its library name. + +**4. Installs into `zig-out/`.** The result is +`zig-out/DanzigGain.vst3`. + +Then: + +```bash +zig build install-vst3 +``` + +removes any existing copy and copies the bundle to +`$HOME/Library/Audio/Plug-Ins/VST3/DanzigGain.vst3`, which is where macOS hosts +scan. + +The bundle is kept behind its own step rather than the default install because +it doubles the compile work and only applies to macOS. + +Sizes, from a `ReleaseFast` build: + +| Artifact | Size | +|---|---| +| `zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain` | 84 KB (universal) | +| `zig-out/lib/libDanzigGain.dylib` | 52 KB (arm64) | +| `zig-out/lib/libDanzigMinimal.dylib` | 52 KB (arm64) | +| `zig-out/bin/danzig-minimal` | 168 KB | + +The same artifacts in the default Debug build run about 1 to 2 MB each. + +--- + +## Testing + +Two suites, one command. + +```bash +zig build test --summary all +``` + +``` +Build Summary: 9/9 steps succeeded; 35/35 tests passed +``` + +### Unit tests + +`src/tests.zig`, 35 tests, run with `zig build test-unit`. No artifact and no +host required. They cover: + +- dB and linear conversion in both directions, including the round trip and the + -80 dB floor. +- `linearInterpolate` and `clamp` at endpoints and midpoints. +- `AudioBuffer` init, zeroing, and clear. +- `GainProcessor` dB conversion, normalized mapping, clamping, and that + `process` ramps rather than jumping. +- `SimpleRamp` instant mode, arrival at target, and restart on `setTarget`. +- `normalize` and `denormalize`, including the degenerate zero-width range. +- `AtomicParam`: the 64-byte size assertion, clamping, denormalization, + instant mode, monotone non-overshooting smoothing, `snap`, and + `setSmoothingMs` with non-positive input. +- `ParamStore`: index allocation, round trip, out-of-range tolerance, + `tickAll`, and `snapAll`. + +### VST3 ABI integration harness + +`examples/danzig-test`, run with `zig build test-integration`. This one links +the built `DanzigGain` plugin and calls into it the way a host does. + +``` +danzig integration harness + +VST3 factory ABI + ok GetPluginFactory returns a non-null object + ok countClasses reports one exported class + ok addRef/release move the count by exactly one + ok queryInterface for an unknown IID reports failure + ok getFactoryInfo returns kResultOk + ok getClassInfo(0) returns kResultOk + +danzig static library + ok AudioBuffer reports its geometry + ok dBToLinear(0 dB) is unity + ok dBToLinear(+6 dB) is ~1.995 + ok ParamStore reaches +48 dB at full scale + +all integration checks passed +``` + +The harness declares its own copy of the `IPluginFactory` vtable rather than +importing the plugin's Zig types. It reads the first word of the returned +pointer as a vtable pointer and calls through the C function pointers. If the +object layout ever stops matching what a host expects, the dereference fails +here before it fails in a DAW. + +It returns a non-zero exit code on failure, so `zig build test` fails with it. + +### CI + +`.github/workflows/ci.yml` runs `zig build` and `zig build test` on `macos-15` +against both 0.14.1 and 0.15.2. The runner is pinned to `macos-15` rather than +`macos-latest`, because `macos-latest` now ships an Xcode whose SDK Zig 0.14.1 +cannot link against. + +--- + +## Examples + +Each directory has its own README with the exact commands. + +| Example | What it shows | Run it | +|---|---|---| +| `examples/danzig-minimal` | The smallest complete plugin. Start here. | `zig build run-minimal` | +| `examples/danzig-gain` | A fuller plugin: `Plugin`, `ParameterMap`, `GainProcessor`, and a factory vtable. | Built into the `.vst3` bundle | +| `examples/danzig-test` | Driving the plugin through the raw VST3 C ABI. | `zig build test-integration` | +| `examples/danzig-gain-standalone` | Offline WAV processing with the DSP core. | `zig build run-standalone` | +| `examples/danzig-webui` | A pure-`std.net` HTTP server serving the web UI. | `./zig-out/bin/danzig-webui` | +| `examples/danzig-gain-ui` | A native macOS window: WebView UI plus CoreAudio device enumeration. | `zig build run-gui` | + +--- + +## Troubleshooting + +### `zig build` fails with undefined libc symbols + +Zig 0.14.1 cannot link against the SDK shipped with Xcode 26. Either use Zig +0.15.2 or install an older SDK. Setting `SDKROOT` does not help, because the SDK +itself is the incompatibility. + +Check which SDK you have: + +```bash +xcodebuild -version +xcrun --show-sdk-path +``` + +### `lipo -info` reports only one architecture + +You looked at `zig-out/lib/libDanzigGain.dylib`, which is the native-only build. +The universal binary is inside the bundle: + +```bash +lipo -info zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain +``` + +If the bundle itself is single-architecture, `zig build vst3` did not run. +`zig build` alone does not produce the bundle. + +### The DAW does not list the plugin + +Expected today. See [Current state](#current-state). The factory's +`getClassInfo` and `createInstance` are stubs, so a host finds zero classes. +Confirm the bundle is otherwise sound: + +```bash +nm -gU zig-out/DanzigGain.vst3/Contents/MacOS/DanzigGain | grep -i factory +``` + +``` +00000000000004c8 T _GetPluginFactory +``` + +### `danzig-webui` starts but the browser shows nothing + +The server binds `127.0.0.1:3000`. If something else already holds that port you +will reach the other service instead. Check with: + +```bash +lsof -nP -iTCP:3000 -sTCP:LISTEN +``` + +The port is a constant in `examples/danzig-webui/root.zig`. Change it and +rebuild. + +### `danzig-webui` exits with an error about `ui/index.html` + +It reads the UI from a relative path at startup, so it has to be run from the +repository root: + +```bash +cd /path/to/danzig +./zig-out/bin/danzig-webui +``` + +### `danzig-gain-standalone` rejects the input file + +It handles 32-bit float PCM WAV only, with a canonical 44-byte header. Anything +else gives `Only 32-bit float PCM WAV files are supported`. To make a test file +without extra tools: + +```bash +python3 - <<'PY' +import struct, math +sr, n, ch = 48000, 48000, 1 +data = b''.join(struct.pack(' +``` + +With no arguments it prints usage: + +```bash +zig build run-standalone +``` + +``` +Usage: danzig-gain-standalone +Example: danzig-gain-standalone input.wav output.wav 6.0 +``` + +## A worked example + +Make a 1 second, 440 Hz sine at half scale. 32-bit float PCM is the only format +this tool reads. + +```bash +python3 - <<'PY' +import struct, math +sr, n, ch = 48000, 48000, 1 +data = b''.join(struct.pack('= 0.5; } - pub fn activate(self: *GainPlugin) void { - self.plugin.activate(); + /// One sample of gain. The smoother is advanced even when bypassed so that + /// leaving bypass does not jump to a stale value. + pub fn nextGain(self: *GainPlugin, bypassed: bool) f32 { + const db = self.params.tick(ParamIndex.gain); + return if (bypassed) 1.0 else danzig.dBToLinear(db); } +}; + +// --- 2. The VST3 object ---------------------------------------------------- +// +// A host holds a pointer whose first word is a vtable pointer. This object +// carries three such one-word structs, one per interface it implements, and +// recovers itself from any of them with @fieldParentPtr. That is the plain +// version of what a C++ compiler does for multiple inheritance. + +const ComponentIface = extern struct { vtbl: *const vst3.ComponentVTable }; +const ProcessorIface = extern struct { vtbl: *const vst3.AudioProcessorVTable }; +const ControllerIface = extern struct { vtbl: *const vst3.EditControllerVTable }; + +const allocator = std.heap.page_allocator; + +const Component = struct { + comp: ComponentIface = .{ .vtbl = &component_vtable }, + proc: ProcessorIface = .{ .vtbl = &processor_vtable }, + ctrl: ControllerIface = .{ .vtbl = &controller_vtable }, - pub fn deactivate(self: *GainPlugin) void { - self.plugin.deactivate(); + ref_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(1), + + host_context: ?*anyopaque = null, + handler: ?*vst3.ComponentHandler = null, + initialized: bool = false, + active: bool = false, + processing: bool = false, + + setup: vst3.ProcessSetup = .{ .sampleRate = 44100.0, .maxSamplesPerBlock = 512 }, + input_arrangement: vst3.SpeakerArrangement = vst3.kStereo, + output_arrangement: vst3.SpeakerArrangement = vst3.kStereo, + + dsp: GainPlugin = undefined, + + fn create() ?*Component { + const self = allocator.create(Component) catch return null; + self.* = .{}; + self.dsp = GainPlugin.init(44100.0); + return self; } - pub fn setParameterNormalized(self: *GainPlugin, paramId: u32, normalized: f64) void { - self.plugin.setParameterNormalized(paramId, normalized); - if (paramId == ParamID.Gain) { - const gainDb = danzig.denormalize(normalized, -48.0, 48.0); - self.gainProcessor.setGain(@floatCast(gainDb)); + fn retain(self: *Component) u32 { + return self.ref_count.fetchAdd(1, .monotonic) + 1; + } + + fn discard(self: *Component) u32 { + const previous = self.ref_count.fetchSub(1, .release); + if (previous == 1) { + _ = self.ref_count.load(.acquire); + allocator.destroy(self); + return 0; } + return previous - 1; + } +}; + +fn fromComponent(this: *anyopaque) *Component { + const iface: *ComponentIface = @ptrCast(@alignCast(this)); + return @fieldParentPtr("comp", iface); +} + +fn fromProcessor(this: *anyopaque) *Component { + const iface: *ProcessorIface = @ptrCast(@alignCast(this)); + return @fieldParentPtr("proc", iface); +} + +fn fromController(this: *anyopaque) *Component { + const iface: *ControllerIface = @ptrCast(@alignCast(this)); + return @fieldParentPtr("ctrl", iface); +} + +/// Shared by all three interfaces: they are one object, so one refcount and +/// one interface table. +fn componentQueryInterface(self: *Component, iid: *const vst3.TUID, obj: *?*anyopaque) vst3.TResult { + const wanted = iid.*; + + // IPluginBase is inherited by both IComponent and IEditController. Handing + // back the component is the convention. + if (vst3.uidEqual(wanted, vst3.IID_FUnknown) or + vst3.uidEqual(wanted, vst3.IID_IPluginBase) or + vst3.uidEqual(wanted, vst3.IID_IComponent)) + { + obj.* = @ptrCast(&self.comp); + } else if (vst3.uidEqual(wanted, vst3.IID_IAudioProcessor)) { + obj.* = @ptrCast(&self.proc); + } else if (vst3.uidEqual(wanted, vst3.IID_IEditController)) { + obj.* = @ptrCast(&self.ctrl); + } else { + obj.* = null; + return vst3.kNoInterface; + } + + _ = self.retain(); + return vst3.kResultOk; +} + +// --- IComponent ------------------------------------------------------------ + +fn comp_queryInterface(this: *anyopaque, iid: *const vst3.TUID, obj: *?*anyopaque) callconv(.c) vst3.TResult { + return componentQueryInterface(fromComponent(this), iid, obj); +} + +fn comp_addRef(this: *anyopaque) callconv(.c) u32 { + return fromComponent(this).retain(); +} + +fn comp_release(this: *anyopaque) callconv(.c) u32 { + return fromComponent(this).discard(); +} + +fn comp_initialize(this: *anyopaque, context: ?*anyopaque) callconv(.c) vst3.TResult { + const self = fromComponent(this); + // A second initialize does nothing, so it must not claim success. Hosts + // reach this path when they discover the controller by querying the + // component and then initialize both handles. + if (self.initialized) return vst3.kResultFalse; + self.host_context = context; + self.initialized = true; + return vst3.kResultOk; +} + +fn comp_terminate(this: *anyopaque) callconv(.c) vst3.TResult { + const self = fromComponent(this); + self.host_context = null; + self.handler = null; + self.initialized = false; + return vst3.kResultOk; +} + +fn comp_getControllerClassId(this: *anyopaque, class_id: *vst3.TUID) callconv(.c) vst3.TResult { + _ = this; + _ = class_id; + // There is no separate controller class. kResultFalse tells the host to + // look for IEditController on this object instead. + return vst3.kResultFalse; +} + +fn comp_setIoMode(this: *anyopaque, mode: vst3.IoMode) callconv(.c) vst3.TResult { + _ = this; + _ = mode; + // The plugin behaves identically in every IO mode, so there is nothing to + // store and nothing to honour. + return vst3.kNotImplemented; +} + +fn comp_getBusCount(this: *anyopaque, media: vst3.MediaType, dir: vst3.BusDirection) callconv(.c) i32 { + _ = this; + _ = dir; + return if (media == vst3.kAudio) 1 else 0; +} + +fn comp_getBusInfo( + this: *anyopaque, + media: vst3.MediaType, + dir: vst3.BusDirection, + index: i32, + info: *vst3.BusInfo, +) callconv(.c) vst3.TResult { + const self = fromComponent(this); + if (media != vst3.kAudio or index != 0) return vst3.kInvalidArgument; + if (dir != vst3.kInput and dir != vst3.kOutput) return vst3.kInvalidArgument; + + const arrangement = if (dir == vst3.kInput) self.input_arrangement else self.output_arrangement; + + info.* = .{ + .mediaType = vst3.kAudio, + .direction = dir, + .channelCount = @intCast(@popCount(arrangement)), + .busType = vst3.kMain, + .flags = vst3.kDefaultActive, + }; + vst3.setUtf16(&info.name, if (dir == vst3.kInput) "Input" else "Output"); + return vst3.kResultOk; +} + +fn comp_getRoutingInfo(this: *anyopaque, in: *vst3.RoutingInfo, out: *vst3.RoutingInfo) callconv(.c) vst3.TResult { + _ = this; + _ = in; + _ = out; + // One bus each way, so there is no routing to describe. + return vst3.kNotImplemented; +} + +fn comp_activateBus( + this: *anyopaque, + media: vst3.MediaType, + dir: vst3.BusDirection, + index: i32, + state: u8, +) callconv(.c) vst3.TResult { + _ = this; + _ = state; + if (media != vst3.kAudio or index != 0) return vst3.kInvalidArgument; + if (dir != vst3.kInput and dir != vst3.kOutput) return vst3.kInvalidArgument; + // Both buses are always live; there is no per-bus allocation to toggle. + return vst3.kResultOk; +} + +fn comp_setActive(this: *anyopaque, state: u8) callconv(.c) vst3.TResult { + const self = fromComponent(this); + self.active = state != 0; + if (self.active) self.dsp.params.snapAll(); + return vst3.kResultOk; +} + +fn comp_setState(this: *anyopaque, stream: ?*vst3.BStream) callconv(.c) vst3.TResult { + const self = fromComponent(this); + const s = stream orelse return vst3.kInvalidArgument; + return if (readState(self, s)) vst3.kResultOk else vst3.kResultFalse; +} + +fn comp_getState(this: *anyopaque, stream: ?*vst3.BStream) callconv(.c) vst3.TResult { + const self = fromComponent(this); + const s = stream orelse return vst3.kInvalidArgument; + return if (writeState(self, s)) vst3.kResultOk else vst3.kInternalError; +} + +const component_vtable: vst3.ComponentVTable = .{ + .base = .{ + .unknown = .{ + .queryInterface = comp_queryInterface, + .addRef = comp_addRef, + .release = comp_release, + }, + .initialize = comp_initialize, + .terminate = comp_terminate, + }, + .getControllerClassId = comp_getControllerClassId, + .setIoMode = comp_setIoMode, + .getBusCount = comp_getBusCount, + .getBusInfo = comp_getBusInfo, + .getRoutingInfo = comp_getRoutingInfo, + .activateBus = comp_activateBus, + .setActive = comp_setActive, + .setState = comp_setState, + .getState = comp_getState, +}; + +// --- state ----------------------------------------------------------------- +// +// Layout: "DZG1", a version word, a parameter count, then that many normalized +// values as little-endian doubles. The count is stored so a state written by a +// build with more parameters still loads the ones this build knows. + +const state_magic = [4]u8{ 'D', 'Z', 'G', '1' }; +const state_version: u32 = 1; +const state_size = 12 + num_params * 8; + +fn writeState(self: *Component, stream: *vst3.BStream) bool { + var buf: [state_size]u8 = undefined; + @memcpy(buf[0..4], &state_magic); + std.mem.writeInt(u32, buf[4..8], state_version, .little); + std.mem.writeInt(u32, buf[8..12], num_params, .little); + for (0..num_params) |i| { + const value: f64 = self.dsp.params.getNormalized(@intCast(i)); + std.mem.writeInt(u64, buf[12 + i * 8 ..][0..8], @bitCast(value), .little); + } + return stream.write(&buf); +} + +fn readState(self: *Component, stream: *vst3.BStream) bool { + var buf: [state_size]u8 = undefined; + const filled = stream.read(&buf).len; + if (filled < 12) return false; + if (!std.mem.eql(u8, buf[0..4], &state_magic)) return false; + if (std.mem.readInt(u32, buf[4..8], .little) != state_version) return false; + + const stored = std.mem.readInt(u32, buf[8..12], .little); + const available: u32 = @intCast((filled - 12) / 8); + const n = @min(@min(stored, available), num_params); + + for (0..n) |i| { + const bits = std.mem.readInt(u64, buf[12 + i * 8 ..][0..8], .little); + const value: f64 = @bitCast(bits); + self.dsp.params.setNormalized(@intCast(i), @floatCast(value)); } + return n > 0; +} + +// --- IAudioProcessor ------------------------------------------------------- + +fn proc_queryInterface(this: *anyopaque, iid: *const vst3.TUID, obj: *?*anyopaque) callconv(.c) vst3.TResult { + return componentQueryInterface(fromProcessor(this), iid, obj); +} + +fn proc_addRef(this: *anyopaque) callconv(.c) u32 { + return fromProcessor(this).retain(); +} + +fn proc_release(this: *anyopaque) callconv(.c) u32 { + return fromProcessor(this).discard(); +} + +fn supportedArrangement(arrangement: vst3.SpeakerArrangement) bool { + return arrangement == vst3.kMono or arrangement == vst3.kStereo; +} + +fn proc_setBusArrangements( + this: *anyopaque, + inputs: ?[*]vst3.SpeakerArrangement, + num_in: i32, + outputs: ?[*]vst3.SpeakerArrangement, + num_out: i32, +) callconv(.c) vst3.TResult { + const self = fromProcessor(this); + if (num_in != 1 or num_out != 1) return vst3.kResultFalse; + + const in_arr = (inputs orelse return vst3.kInvalidArgument)[0]; + const out_arr = (outputs orelse return vst3.kInvalidArgument)[0]; + + // Gain is channel-independent, so any matched mono or stereo pair works. + // Anything else is refused rather than accepted and ignored, which would + // leave the host writing into channels that are never read. + if (in_arr != out_arr) return vst3.kResultFalse; + if (!supportedArrangement(out_arr)) return vst3.kResultFalse; + + self.input_arrangement = in_arr; + self.output_arrangement = out_arr; + return vst3.kResultTrue; +} + +fn proc_getBusArrangement( + this: *anyopaque, + dir: vst3.BusDirection, + index: i32, + arrangement: *vst3.SpeakerArrangement, +) callconv(.c) vst3.TResult { + const self = fromProcessor(this); + if (index != 0) return vst3.kInvalidArgument; + arrangement.* = switch (dir) { + vst3.kInput => self.input_arrangement, + vst3.kOutput => self.output_arrangement, + else => return vst3.kInvalidArgument, + }; + return vst3.kResultOk; +} + +fn proc_canProcessSampleSize(this: *anyopaque, size: i32) callconv(.c) vst3.TResult { + _ = this; + // 64-bit processing is not implemented, so it is refused outright. + return if (size == vst3.kSample32) vst3.kResultTrue else vst3.kResultFalse; +} + +fn proc_getLatencySamples(this: *anyopaque) callconv(.c) u32 { + _ = this; + return 0; +} + +fn proc_setupProcessing(this: *anyopaque, setup: *vst3.ProcessSetup) callconv(.c) vst3.TResult { + const self = fromProcessor(this); + if (setup.symbolicSampleSize != vst3.kSample32) return vst3.kResultFalse; + if (setup.sampleRate <= 0.0) return vst3.kInvalidArgument; - pub fn getParameterNormalized(self: GainPlugin, paramId: u32) f64 { - return self.plugin.getParameterNormalized(paramId); + self.setup = setup.*; + self.dsp.setSampleRate(@floatCast(setup.sampleRate)); + return vst3.kResultOk; +} + +fn proc_setProcessing(this: *anyopaque, state: u8) callconv(.c) vst3.TResult { + const self = fromProcessor(this); + self.processing = state != 0; + // Starting a run from a stale smoother would ramp audibly from wherever + // the last run stopped. + if (self.processing) self.dsp.params.snapAll(); + return vst3.kResultOk; +} + +fn proc_getTailSamples(this: *anyopaque) callconv(.c) u32 { + _ = this; + return 0; +} + +/// Apply queued automation before the block runs. Only the final point of each +/// queue is used, so a parameter moves once per block and the smoother turns +/// that into a per-sample ramp. +fn applyParameterChanges(self: *Component, data: *vst3.ProcessData) void { + const changes = data.inputParameterChanges orelse return; + const queues = changes.count(); + var q: i32 = 0; + while (q < queues) : (q += 1) { + const queue = changes.queue(q) orelse continue; + const points = queue.pointCount(); + if (points <= 0) continue; + const value = queue.pointValue(points - 1) orelse continue; + const id = queue.parameterId(); + if (id >= num_params) continue; + self.dsp.params.setNormalized(id, @floatCast(value)); } +} - pub fn process(self: *GainPlugin, inputs: []*[*]f32, outputs: []*[*]f32, numChannels: u32, numSamples: u32) void { - if (!self.plugin.active) { - for (0..numChannels) |ch| { - @memcpy(outputs[ch][0..numSamples], inputs[ch][0..numSamples]); +fn proc_process(this: *anyopaque, data: *vst3.ProcessData) callconv(.c) vst3.TResult { + const self = fromProcessor(this); + + applyParameterChanges(self, data); + + // A block of zero samples is a parameter flush. The changes above are the + // whole job. + if (data.numSamples <= 0) return vst3.kResultOk; + if (data.symbolicSampleSize != vst3.kSample32) return vst3.kResultFalse; + if (data.numOutputs < 1) return vst3.kResultOk; + + const out_buses = data.outputs orelse return vst3.kInvalidArgument; + const out_bus = &out_buses[0]; + const out_channels = out_bus.channelBuffers orelse return vst3.kInvalidArgument; + const num_out: usize = @intCast(@max(out_bus.numChannels, 0)); + const frames: usize = @intCast(data.numSamples); + + // The input bus may be absent when the host runs the plugin with nothing + // patched in, in which case there is nothing to scale. + var in_channels: ?[*][*]vst3.Sample32 = null; + var num_in: usize = 0; + if (data.numInputs >= 1) { + if (data.inputs) |in_buses| { + const in_bus = &in_buses[0]; + if (in_bus.channelBuffers) |buffers| { + in_channels = buffers; + num_in = @intCast(@max(in_bus.numChannels, 0)); } - return; } + } - self.gainProcessor.process(inputs, outputs, numChannels, numSamples); + const bypassed = self.dsp.isBypassed(); + + var frame: usize = 0; + while (frame < frames) : (frame += 1) { + const gain = self.dsp.nextGain(bypassed); + var ch: usize = 0; + while (ch < num_out) : (ch += 1) { + const dst = out_channels[ch]; + if (ch < num_in) { + dst[frame] = in_channels.?[ch][frame] * gain; + } else { + dst[frame] = 0.0; + } + } } -}; -// VST3 Module Entry Points -// Minimal factory implementation for VST3 plugin loading - -// Module Info used by VST3 hosts -pub const ModuleInfo = extern struct { - name: [*:0]const u8 = "Danzig Gain", - vendor: [*:0]const u8 = "Superelectric", - url: [*:0]const u8 = "https://superelectric.dev", - email: [*:0]const u8 = "danzig@superelectric.dev", - version: u32 = 0x00010000, - sdkVersion: u32 = 0x00030600, -}; + // Claiming silence wrongly makes hosts skip downstream work, and this + // plugin has no way to know the output is silent without scanning it. + out_bus.silenceFlags = 0; + return vst3.kResultOk; +} -const IUnknownVTable = extern struct { - queryInterface: *const fn (*anyopaque, guid: [*]const u8, obj: *?*anyopaque) callconv(.c) i32, - addRef: *const fn (*anyopaque) callconv(.c) u32, - release: *const fn (*anyopaque) callconv(.c) u32, +const processor_vtable: vst3.AudioProcessorVTable = .{ + .unknown = .{ + .queryInterface = proc_queryInterface, + .addRef = proc_addRef, + .release = proc_release, + }, + .setBusArrangements = proc_setBusArrangements, + .getBusArrangement = proc_getBusArrangement, + .canProcessSampleSize = proc_canProcessSampleSize, + .getLatencySamples = proc_getLatencySamples, + .setupProcessing = proc_setupProcessing, + .setProcessing = proc_setProcessing, + .process = proc_process, + .getTailSamples = proc_getTailSamples, }; -const IPluginFactoryVTable = extern struct { - base: IUnknownVTable, - getFactoryInfo: *const fn (*anyopaque, info: *anyopaque) callconv(.c) i32, - countClasses: *const fn (*anyopaque) callconv(.c) i32, - getClassInfo: *const fn (*anyopaque, index: i32, info: *anyopaque) callconv(.c) i32, - createInstance: *const fn (*anyopaque, cid: [*]const u8, iid: [*]const u8, obj: *?*anyopaque) callconv(.c) i32, -}; +// --- IEditController ------------------------------------------------------- -pub const PluginFactory = extern struct { - vtbl: [*]*IPluginFactoryVTable, - refCount: u32 = 1, -}; +fn ctrl_queryInterface(this: *anyopaque, iid: *const vst3.TUID, obj: *?*anyopaque) callconv(.c) vst3.TResult { + return componentQueryInterface(fromController(this), iid, obj); +} -var gFactory: PluginFactory = undefined; +fn ctrl_addRef(this: *anyopaque) callconv(.c) u32 { + return fromController(this).retain(); +} -fn factory_queryInterface(self: *anyopaque, guid: [*]const u8, obj: *?*anyopaque) callconv(.c) i32 { - _ = self; - _ = guid; - _ = obj; - return -1; // kNoInterface +fn ctrl_release(this: *anyopaque) callconv(.c) u32 { + return fromController(this).discard(); } -fn factory_addRef(self: *anyopaque) callconv(.c) u32 { - var factory = @as(*PluginFactory, @ptrCast(@alignCast(self))); - factory.refCount += 1; - return factory.refCount; +fn ctrl_initialize(this: *anyopaque, context: ?*anyopaque) callconv(.c) vst3.TResult { + return comp_initialize(@ptrCast(&fromController(this).comp), context); } -fn factory_release(self: *anyopaque) callconv(.c) u32 { - var factory = @as(*PluginFactory, @ptrCast(@alignCast(self))); - if (factory.refCount > 0) factory.refCount -= 1; - return factory.refCount; +fn ctrl_terminate(this: *anyopaque) callconv(.c) vst3.TResult { + return comp_terminate(@ptrCast(&fromController(this).comp)); } -fn factory_getFactoryInfo(self: *anyopaque, _: *anyopaque) callconv(.c) i32 { - _ = self; - return 0; +fn ctrl_setComponentState(this: *anyopaque, stream: ?*vst3.BStream) callconv(.c) vst3.TResult { + const self = fromController(this); + const s = stream orelse return vst3.kInvalidArgument; + // Processor and controller are the same object, so the component state is + // already the controller state. + return if (readState(self, s)) vst3.kResultOk else vst3.kResultFalse; } -fn factory_countClasses(_: *anyopaque) callconv(.c) i32 { - return 1; +fn ctrl_setState(this: *anyopaque, stream: ?*vst3.BStream) callconv(.c) vst3.TResult { + return comp_setState(@ptrCast(&fromController(this).comp), stream); } -fn factory_getClassInfo(_: *anyopaque, index: i32, _: *anyopaque) callconv(.c) i32 { - _ = index; - return 0; +fn ctrl_getState(this: *anyopaque, stream: ?*vst3.BStream) callconv(.c) vst3.TResult { + return comp_getState(@ptrCast(&fromController(this).comp), stream); } -fn factory_createInstance(_: *anyopaque, _: [*]const u8, _: [*]const u8, _: *?*anyopaque) callconv(.c) i32 { - return 0; +fn ctrl_getParameterCount(this: *anyopaque) callconv(.c) i32 { + _ = this; + return num_params; +} + +fn ctrl_getParameterInfo(this: *anyopaque, index: i32, info: *vst3.ParameterInfo) callconv(.c) vst3.TResult { + _ = this; + if (index < 0 or index >= num_params) return vst3.kInvalidArgument; + + info.* = .{}; + switch (@as(u32, @intCast(index))) { + ParamIndex.gain => { + info.id = ParamIndex.gain; + vst3.setUtf16(&info.title, "Gain"); + vst3.setUtf16(&info.shortTitle, "Gain"); + vst3.setUtf16(&info.units, "dB"); + info.stepCount = 0; + info.defaultNormalizedValue = gain_default_norm; + info.flags = vst3.kCanAutomate; + }, + ParamIndex.bypass => { + info.id = ParamIndex.bypass; + vst3.setUtf16(&info.title, "Bypass"); + vst3.setUtf16(&info.shortTitle, "Byps"); + info.stepCount = 1; + info.defaultNormalizedValue = 0.0; + info.flags = vst3.kCanAutomate | vst3.kIsBypass; + }, + else => return vst3.kInvalidArgument, + } + return vst3.kResultOk; +} + +fn plainGainDb(normalized: vst3.ParamValue) f64 { + const clamped = std.math.clamp(normalized, 0.0, 1.0); + return gain_min_db + clamped * (gain_max_db - gain_min_db); +} + +fn ctrl_getParamStringByValue( + this: *anyopaque, + id: vst3.ParamID, + value: vst3.ParamValue, + out: *[128]u16, +) callconv(.c) vst3.TResult { + _ = this; + var buf: [64]u8 = undefined; + const text = switch (id) { + ParamIndex.gain => std.fmt.bufPrint(&buf, "{d:.2}", .{plainGainDb(value)}) catch return vst3.kInternalError, + ParamIndex.bypass => if (value >= 0.5) "On" else "Off", + else => return vst3.kInvalidArgument, + }; + vst3.setUtf16(out, text); + return vst3.kResultOk; +} + +fn ctrl_getParamValueByString( + this: *anyopaque, + id: vst3.ParamID, + string: [*]const u16, + value: *vst3.ParamValue, +) callconv(.c) vst3.TResult { + _ = this; + + var buf: [64]u8 = undefined; + var len: usize = 0; + while (len < buf.len - 1 and string[len] != 0) : (len += 1) { + const c = string[len]; + // Anything outside ASCII cannot be part of a number or of "On"/"Off". + if (c > 127) return vst3.kResultFalse; + buf[len] = @intCast(c); + } + const text = std.mem.trim(u8, buf[0..len], " \t"); + + switch (id) { + ParamIndex.gain => { + const db = std.fmt.parseFloat(f64, text) catch return vst3.kResultFalse; + value.* = std.math.clamp((db - gain_min_db) / (gain_max_db - gain_min_db), 0.0, 1.0); + }, + ParamIndex.bypass => { + if (std.ascii.eqlIgnoreCase(text, "on") or std.mem.eql(u8, text, "1")) { + value.* = 1.0; + } else if (std.ascii.eqlIgnoreCase(text, "off") or std.mem.eql(u8, text, "0")) { + value.* = 0.0; + } else return vst3.kResultFalse; + }, + else => return vst3.kInvalidArgument, + } + return vst3.kResultOk; +} + +fn ctrl_normalizedParamToPlain(this: *anyopaque, id: vst3.ParamID, value: vst3.ParamValue) callconv(.c) vst3.ParamValue { + _ = this; + return switch (id) { + ParamIndex.gain => plainGainDb(value), + ParamIndex.bypass => if (value >= 0.5) 1.0 else 0.0, + else => 0.0, + }; +} + +fn ctrl_plainParamToNormalized(this: *anyopaque, id: vst3.ParamID, plain: vst3.ParamValue) callconv(.c) vst3.ParamValue { + _ = this; + return switch (id) { + ParamIndex.gain => std.math.clamp((plain - gain_min_db) / (gain_max_db - gain_min_db), 0.0, 1.0), + ParamIndex.bypass => if (plain >= 0.5) 1.0 else 0.0, + else => 0.0, + }; +} + +fn ctrl_getParamNormalized(this: *anyopaque, id: vst3.ParamID) callconv(.c) vst3.ParamValue { + const self = fromController(this); + if (id >= num_params) return 0.0; + return self.dsp.params.getNormalized(id); +} + +fn ctrl_setParamNormalized(this: *anyopaque, id: vst3.ParamID, value: vst3.ParamValue) callconv(.c) vst3.TResult { + const self = fromController(this); + if (id >= num_params) return vst3.kInvalidArgument; + self.dsp.params.setNormalized(id, @floatCast(value)); + return vst3.kResultOk; } -var factoryVtable: IPluginFactoryVTable = .{ +fn ctrl_setComponentHandler(this: *anyopaque, handler: ?*vst3.ComponentHandler) callconv(.c) vst3.TResult { + const self = fromController(this); + self.handler = handler; + return vst3.kResultOk; +} + +fn ctrl_createView(this: *anyopaque, name: [*:0]const u8) callconv(.c) ?*anyopaque { + _ = this; + _ = name; + // No editor. A null return is how a plugin says the host should draw the + // generic one. + return null; +} + +const controller_vtable: vst3.EditControllerVTable = .{ .base = .{ - .queryInterface = factory_queryInterface, - .addRef = factory_addRef, - .release = factory_release, + .unknown = .{ + .queryInterface = ctrl_queryInterface, + .addRef = ctrl_addRef, + .release = ctrl_release, + }, + .initialize = ctrl_initialize, + .terminate = ctrl_terminate, + }, + .setComponentState = ctrl_setComponentState, + .setState = ctrl_setState, + .getState = ctrl_getState, + .getParameterCount = ctrl_getParameterCount, + .getParameterInfo = ctrl_getParameterInfo, + .getParamStringByValue = ctrl_getParamStringByValue, + .getParamValueByString = ctrl_getParamValueByString, + .normalizedParamToPlain = ctrl_normalizedParamToPlain, + .plainParamToNormalized = ctrl_plainParamToNormalized, + .getParamNormalized = ctrl_getParamNormalized, + .setParamNormalized = ctrl_setParamNormalized, + .setComponentHandler = ctrl_setComponentHandler, + .createView = ctrl_createView, +}; + +// --- 3. The factory -------------------------------------------------------- +// +// One static object for the whole module. It implements IPluginFactory2 as +// well, which is how a host learns the sub-category and vendor strings. + +const FactoryIface = extern struct { vtbl: *const vst3.PluginFactory2VTable }; + +var factory_ref_count = std.atomic.Value(u32).init(0); +var factory = FactoryIface{ .vtbl = &factory_vtable }; + +fn factory_queryInterface(this: *anyopaque, iid: *const vst3.TUID, obj: *?*anyopaque) callconv(.c) vst3.TResult { + const wanted = iid.*; + if (vst3.uidEqual(wanted, vst3.IID_FUnknown) or + vst3.uidEqual(wanted, vst3.IID_IPluginFactory) or + vst3.uidEqual(wanted, vst3.IID_IPluginFactory2)) + { + obj.* = this; + _ = factory_ref_count.fetchAdd(1, .monotonic); + return vst3.kResultOk; + } + obj.* = null; + return vst3.kNoInterface; +} + +fn factory_addRef(this: *anyopaque) callconv(.c) u32 { + _ = this; + return factory_ref_count.fetchAdd(1, .monotonic) + 1; +} + +fn factory_release(this: *anyopaque) callconv(.c) u32 { + _ = this; + // The factory is static, so the count is bookkeeping only and reaching + // zero frees nothing. + if (factory_ref_count.load(.monotonic) == 0) return 0; + return factory_ref_count.fetchSub(1, .monotonic) - 1; +} + +fn factory_getFactoryInfo(this: *anyopaque, info: *vst3.PFactoryInfo) callconv(.c) vst3.TResult { + _ = this; + info.* = .{}; + vst3.setAscii(&info.vendor, vendor_name); + vst3.setAscii(&info.url, vendor_url); + vst3.setAscii(&info.email, vendor_email); + info.flags = vst3.kFactoryNoFlags; + return vst3.kResultOk; +} + +fn factory_countClasses(this: *anyopaque) callconv(.c) i32 { + _ = this; + return 1; +} + +fn factory_getClassInfo(this: *anyopaque, index: i32, info: *vst3.PClassInfo) callconv(.c) vst3.TResult { + _ = this; + if (index != 0) return vst3.kInvalidArgument; + info.* = .{ .cid = processor_cid, .cardinality = vst3.kManyInstances }; + vst3.setAscii(&info.category, vst3.kCategoryAudioEffect); + vst3.setAscii(&info.name, plugin_name); + return vst3.kResultOk; +} + +fn factory_getClassInfo2(this: *anyopaque, index: i32, info: *vst3.PClassInfo2) callconv(.c) vst3.TResult { + _ = this; + if (index != 0) return vst3.kInvalidArgument; + info.* = .{ .cid = processor_cid, .cardinality = vst3.kManyInstances }; + vst3.setAscii(&info.category, vst3.kCategoryAudioEffect); + vst3.setAscii(&info.name, plugin_name); + vst3.setAscii(&info.subCategories, "Fx"); + vst3.setAscii(&info.vendor, vendor_name); + vst3.setAscii(&info.version, plugin_version); + vst3.setAscii(&info.sdkVersion, sdk_version); + info.classFlags = 0; + return vst3.kResultOk; +} + +fn factory_createInstance( + this: *anyopaque, + cid: [*]const u8, + iid: [*]const u8, + obj: *?*anyopaque, +) callconv(.c) vst3.TResult { + _ = this; + obj.* = null; + + const requested_class: vst3.TUID = cid[0..16].*; + if (!vst3.uidEqual(requested_class, processor_cid)) return vst3.kNoInterface; + + const instance = Component.create() orelse return vst3.kOutOfMemory; + + // The new object starts at one reference. queryInterface takes a second + // for the caller, so dropping ours leaves exactly the one the host owns, + // and frees the object outright if it asked for an interface this plugin + // does not implement. + const requested_iface: vst3.TUID = iid[0..16].*; + const result = componentQueryInterface(instance, &requested_iface, obj); + _ = instance.discard(); + return result; +} + +const factory_vtable: vst3.PluginFactory2VTable = .{ + .factory = .{ + .unknown = .{ + .queryInterface = factory_queryInterface, + .addRef = factory_addRef, + .release = factory_release, + }, + .getFactoryInfo = factory_getFactoryInfo, + .countClasses = factory_countClasses, + .getClassInfo = factory_getClassInfo, + .createInstance = factory_createInstance, }, - .getFactoryInfo = factory_getFactoryInfo, - .countClasses = factory_countClasses, - .getClassInfo = factory_getClassInfo, - .createInstance = factory_createInstance, + .getClassInfo2 = factory_getClassInfo2, }; +// --- module entry points --------------------------------------------------- +// +// A macOS host loads the bundle with CFBundle and calls bundleEntry before it +// looks for anything else. Without that symbol the module is discarded and no +// amount of correct factory code is ever reached. Linux and Windows hosts call +// the equivalents below. + export fn GetPluginFactory() ?*anyopaque { - gFactory.vtbl = @ptrCast(&factoryVtable); - return @ptrCast(&gFactory); + _ = factory_ref_count.fetchAdd(1, .monotonic); + return @ptrCast(&factory); +} + +var module_ref_count: i32 = 0; + +export fn bundleEntry(bundle: ?*anyopaque) callconv(.c) bool { + _ = bundle; + module_ref_count += 1; + return true; +} + +export fn bundleExit() callconv(.c) bool { + if (module_ref_count > 0) module_ref_count -= 1; + return true; +} + +export fn ModuleEntry(handle: ?*anyopaque) callconv(.c) bool { + return bundleEntry(handle); +} + +export fn ModuleExit() callconv(.c) bool { + return bundleExit(); +} + +export fn InitDll() callconv(.c) bool { + return bundleEntry(null); +} + +export fn ExitDll() callconv(.c) bool { + return bundleExit(); } pub fn main() !void { - std.debug.print("Danzig Gain Plugin - Zig VST3 Framework\n", .{}); - std.debug.print("This is a VST3 plugin library and should not be run directly.\n", .{}); + std.debug.print("Danzig Gain is a VST3 plugin library.\n", .{}); + std.debug.print("Build the bundle with `zig build vst3` and load it in a host.\n", .{}); } diff --git a/examples/danzig-minimal/README.md b/examples/danzig-minimal/README.md new file mode 100644 index 0000000..26a7f63 --- /dev/null +++ b/examples/danzig-minimal/README.md @@ -0,0 +1,102 @@ +# danzig-minimal + +The smallest complete danzig plugin. Copy this to start your own. + +## What it demonstrates + +One file, about 120 lines, most of them comments. It shows the four pieces every +danzig plugin needs and nothing else. + +**A parameter store.** `danzig.ParamStore(1)` holds one trim control spanning +-24 to +24 dB with a 20 ms one-pole smoother. The store is a fixed array of +cache-line-sized atomic slots, so registering a parameter allocates nothing and +reading one from the audio thread cannot block. + +**A writer path.** `setParameter` does a single atomic store. This is what the +host or the UI calls, on its own thread, at any time. + +**An audio callback.** `process` reads the smoothed value once per sample with +`tick`, converts it to a linear gain, and multiplies. No allocation, no locks, +no branches on parameter state. Per-sample smoothing is what stops a slider drag +from clicking. + +**The VST3 entry point.** `export fn GetPluginFactory()` is the only symbol a +host looks for in the binary. Here it returns null, which a host reads as "this +binary exports no classes". See `../danzig-gain` for a factory with a vtable +behind it. + +The same source builds twice: as the shared library a host would load, and as an +executable, so the DSP can be run and checked without a DAW. + +## Build and run + +From the repository root: + +```bash +zig build run-minimal +``` + +``` +danzig-minimal: one parameter, one line of DSP + +Trim range is -24 to +24 dB, 20 ms smoothing, 48 kHz. + + full cut normalized 0.00 -> -24.00 dB (output 0.0631) + unity normalized 0.50 -> 0.00 dB (output 1.0000) + full boost normalized 1.00 -> 24.00 dB (output 15.8473) + +Copy examples/danzig-minimal/root.zig to start your own plugin. +``` + +The demo feeds 500 ms of full-scale DC through the plugin at three trim +settings and reads the level off the last sample. DC makes the gain readable +directly. 500 ms is well past the 20 ms smoother's settling time, which is why +the numbers land on exactly -24, 0, and +24 dB. + +## Artifacts + +```bash +zig build +ls zig-out/lib/libDanzigMinimal.dylib zig-out/bin/danzig-minimal +``` + +``` +zig-out/bin/danzig-minimal +zig-out/lib/libDanzigMinimal.dylib +``` + +`libDanzigMinimal.dylib` is the plugin. `danzig-minimal` is the offline demo +above. + +## Starting your own plugin + +```bash +mkdir -p examples/my-plugin +cp examples/danzig-minimal/root.zig examples/my-plugin/root.zig +``` + +Then add it to `build.zig` next to the `danzig_minimal` block, changing the +names: + +```zig +const my_plugin = b.addLibrary(.{ + .name = "MyPlugin", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/my-plugin/root.zig"), + .target = target, + .optimize = optimize, + }), + .linkage = .dynamic, +}); +my_plugin.root_module.addImport("danzig", danzig_module); +my_plugin.linkLibrary(danzig_lib); +b.installArtifact(my_plugin); +``` + +Edit `process`. Add parameters by widening `ParamStore(1)` and calling `add` +once more in `init`. + +--- + +See [docs/WIKI.md](../../docs/WIKI.md) for the parameter system and the audio +callback path in full. diff --git a/examples/danzig-minimal/root.zig b/examples/danzig-minimal/root.zig new file mode 100644 index 0000000..738b36a --- /dev/null +++ b/examples/danzig-minimal/root.zig @@ -0,0 +1,120 @@ +// danzig-minimal: the smallest useful danzig plugin. +// +// This is the file to copy when starting a new plugin. It is deliberately +// smaller than examples/danzig-gain: one parameter, one line of DSP, and the +// single symbol a VST3 host looks for. Everything else is comment. +// +// The same source builds two things: +// +// zig build -> zig-out/lib/libDanzigMinimal.dylib (the plugin) +// zig build run-minimal -> runs main() below over a test signal +// +// Building it as an executable as well means you can hear-check the maths +// without opening a DAW. + +const std = @import("std"); +const danzig = @import("danzig"); + +// --- 1. Parameters --------------------------------------------------------- +// +// Parameters live in a ParamStore, a fixed-size array of cache-line-sized +// atomic slots. Nothing here allocates, so it is safe to read from the audio +// thread. Indices are assigned in registration order; name them. + +const ParamIndex = struct { + pub const trim: u32 = 0; +}; + +const num_params = 1; + +// --- 2. The plugin --------------------------------------------------------- + +pub const MinimalPlugin = struct { + params: danzig.ParamStore(num_params) = .{}, + sample_rate: f32 = 48000.0, + + /// Register parameters. Called once, off the audio thread. + /// + /// Arguments are (min, max, default_normalized, smoothing_ms, sample_rate). + /// The smoothing time turns a parameter jump into a one-pole ramp, which is + /// what stops a slider drag from producing clicks. + pub fn init(sample_rate: f32) MinimalPlugin { + var self = MinimalPlugin{ .sample_rate = sample_rate }; + const idx = self.params.add(-24.0, 24.0, 0.5, 20.0, sample_rate); + std.debug.assert(idx == ParamIndex.trim); + return self; + } + + /// The host thread writes here. Lock-free, so it never blocks audio. + pub fn setParameter(self: *MinimalPlugin, index: u32, normalized: f32) void { + self.params.setNormalized(index, normalized); + } + + /// The audio callback. No allocation, no locks, no syscalls. + /// + /// `tick` advances the smoother by one sample and returns the plain value, + /// so the gain is recomputed per sample rather than per block. + 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; + } + } + } +}; + +// --- 3. The VST3 entry point ---------------------------------------------- +// +// A VST3 binary exports exactly one symbol. The host calls it, reads the first +// word of the returned pointer as a vtable pointer, and calls through that. +// See examples/danzig-gain for a factory with a real vtable behind it; this +// one returns null, which a host reads as "no classes here". + +export fn GetPluginFactory() ?*anyopaque { + return null; +} + +// --- 4. Offline demo ------------------------------------------------------- +// +// Feeds a full-scale DC signal through the plugin at three trim settings and +// prints the measured output level. DC makes the gain readable directly off +// the last sample. + +fn runAt(normalized: f32, label: []const u8) void { + var plugin = MinimalPlugin.init(48000.0); + plugin.setParameter(ParamIndex.trim, normalized); + + const frames = 24000; // 500 ms, well past the 20 ms smoother's settling time + var left_in = [_]f32{1.0} ** frames; + var right_in = [_]f32{1.0} ** frames; + var left_out = [_]f32{0.0} ** frames; + var right_out = [_]f32{0.0} ** frames; + + const input = [_][]const f32{ &left_in, &right_in }; + const output = [_][]f32{ &left_out, &right_out }; + + plugin.process(&input, &output, frames); + + const settled = left_out[frames - 1]; + std.debug.print( + " {s:<12} normalized {d:.2} -> {d:>7.2} dB (output {d:.4})\n", + .{ label, normalized, danzig.linearTodB(settled), settled }, + ); +} + +pub fn main() void { + std.debug.print("danzig-minimal: one parameter, one line of DSP\n\n", .{}); + std.debug.print("Trim range is -24 to +24 dB, 20 ms smoothing, 48 kHz.\n\n", .{}); + + runAt(0.0, "full cut"); + runAt(0.5, "unity"); + runAt(1.0, "full boost"); + + std.debug.print("\nCopy examples/danzig-minimal/root.zig to start your own plugin.\n", .{}); +} diff --git a/examples/danzig-test/README.md b/examples/danzig-test/README.md new file mode 100644 index 0000000..55ab0e6 --- /dev/null +++ b/examples/danzig-test/README.md @@ -0,0 +1,107 @@ +# danzig-test + +The VST3 ABI integration harness. + +## What it demonstrates + +`src/tests.zig` covers the pure-Zig core with 35 unit tests. This binary covers +the other half: it links the built `DanzigGain` plugin, calls its exported +`GetPluginFactory`, and drives the returned object through the raw VST3 C ABI +the way a host does. + +The point is that it does not import the plugin's Zig types. It declares its own +copy of the `IPluginFactory` vtable layout, casts the returned pointer to a +struct whose only field is a vtable pointer, and calls through C function +pointers: + +```zig +const FactoryObject = extern struct { + vtbl: *const IPluginFactoryVTable, +}; + +extern fn GetPluginFactory() ?*anyopaque; + +const factory: *FactoryObject = @ptrCast(@alignCast(raw.?)); +check(factory.vtbl.countClasses(raw.?) == 1, "countClasses reports one exported class"); +``` + +If the plugin ever stops putting the vtable pointer in the first machine word, +that dereference fails here rather than inside a DAW. + +The checks: + +- `GetPluginFactory` returns a non-null object. +- `countClasses` reports one exported class. +- `addRef` and `release` move the reference count by exactly one. +- `queryInterface` for an unknown IID reports failure instead of handing back a + garbage pointer. +- `getFactoryInfo` and `getClassInfo(0)` return `kResultOk`. +- The linked static library still converts dB correctly and the `ParamStore` + reaches full scale. + +## Run + +From the repository root: + +```bash +zig build test-integration +``` + +``` +danzig integration harness + +VST3 factory ABI + ok GetPluginFactory returns a non-null object + ok countClasses reports one exported class + ok addRef/release move the count by exactly one + ok queryInterface for an unknown IID reports failure + ok getFactoryInfo returns kResultOk + ok getClassInfo(0) returns kResultOk + +danzig static library + ok AudioBuffer reports its geometry + ok dBToLinear(0 dB) is unity + ok dBToLinear(+6 dB) is ~1.995 + ok ParamStore reaches +48 dB at full scale + +all integration checks passed +``` + +It exits non-zero if any check fails. + +## Run it with the unit tests + +```bash +zig build test --summary all +``` + +``` +Build Summary: 9/9 steps succeeded; 35/35 tests passed +``` + +The 35 counts the unit tests in `src/tests.zig`. This harness is a separate run +step, so its failures surface as a failed build step rather than a failed test +count. + +## Run the binary directly + +```bash +zig build +./zig-out/bin/danzig_test +echo "exit=$?" +``` + +``` +exit=0 +``` + +## What it does not check + +It does not create a plugin instance, because `createInstance` in +`examples/danzig-gain` does not yet produce one. When that is implemented, the +natural next checks are `setupProcessing`, `setActive`, and a `process` call +against a known input buffer. + +--- + +See [docs/WIKI.md](../../docs/WIKI.md) for the testing section. diff --git a/examples/danzig-test/root.zig b/examples/danzig-test/root.zig index 19f154f..42f9c9d 100644 --- a/examples/danzig-test/root.zig +++ b/examples/danzig-test/root.zig @@ -1,14 +1,536 @@ -// Simple test to verify danzig library functionality +// VST3 ABI integration harness. +// +// The unit tests in src/tests.zig cover the pure-Zig core. This binary covers +// the other half: it links the built DanzigGain plugin, calls the exported +// GetPluginFactory entry point, and drives the returned object through the raw +// VST3 C ABI the way a host would. Nothing here goes through Zig types that +// only exist inside the plugin, so a layout change that would break a real +// host breaks this too. +// +// The checks assert on content, not just on result codes. A factory that +// returns kResultOk and writes nothing is the failure mode this file exists to +// catch, so every buffer below is zeroed before the call and an untouched +// buffer is a failure. +// +// Run with `zig build test-integration`, or as part of `zig build test`. + const std = @import("std"); +const danzig = @import("danzig"); + +// --- The C ABI, as a host sees it ----------------------------------------- +// +// A VST3 object is a pointer whose first word is a pointer to a vtable of C +// function pointers. These declarations mirror Steinberg's headers. They are +// deliberately independent of the plugin's own definitions. + +const kResultOk: i32 = 0; + +const IUnknownVTable = extern struct { + queryInterface: *const fn (*anyopaque, [*]const u8, *?*anyopaque) callconv(.c) i32, + addRef: *const fn (*anyopaque) callconv(.c) u32, + release: *const fn (*anyopaque) callconv(.c) u32, +}; + +const IPluginFactoryVTable = extern struct { + base: IUnknownVTable, + getFactoryInfo: *const fn (*anyopaque, *PFactoryInfo) callconv(.c) i32, + countClasses: *const fn (*anyopaque) callconv(.c) i32, + getClassInfo: *const fn (*anyopaque, i32, *PClassInfo) callconv(.c) i32, + createInstance: *const fn (*anyopaque, [*]const u8, [*]const u8, *?*anyopaque) callconv(.c) i32, +}; + +const PFactoryInfo = extern struct { + vendor: [64]u8, + url: [256]u8, + email: [128]u8, + flags: i32, +}; + +const PClassInfo = extern struct { + cid: [16]u8, + cardinality: i32, + category: [32]u8, + name: [64]u8, +}; + +const IComponentVTable = extern struct { + base: IUnknownVTable, + initialize: *const fn (*anyopaque, ?*anyopaque) callconv(.c) i32, + terminate: *const fn (*anyopaque) callconv(.c) i32, + getControllerClassId: *const fn (*anyopaque, *[16]u8) callconv(.c) i32, + setIoMode: *const fn (*anyopaque, i32) callconv(.c) i32, + getBusCount: *const fn (*anyopaque, i32, i32) callconv(.c) i32, + getBusInfo: *const fn (*anyopaque, i32, i32, i32, *BusInfo) callconv(.c) i32, + getRoutingInfo: *const fn (*anyopaque, *anyopaque, *anyopaque) callconv(.c) i32, + activateBus: *const fn (*anyopaque, i32, i32, i32, u8) callconv(.c) i32, + setActive: *const fn (*anyopaque, u8) callconv(.c) i32, + setState: *const fn (*anyopaque, ?*anyopaque) callconv(.c) i32, + getState: *const fn (*anyopaque, ?*anyopaque) callconv(.c) i32, +}; + +const IAudioProcessorVTable = extern struct { + base: IUnknownVTable, + setBusArrangements: *const fn (*anyopaque, ?[*]u64, i32, ?[*]u64, i32) callconv(.c) i32, + getBusArrangement: *const fn (*anyopaque, i32, i32, *u64) callconv(.c) i32, + canProcessSampleSize: *const fn (*anyopaque, i32) callconv(.c) i32, + getLatencySamples: *const fn (*anyopaque) callconv(.c) u32, + setupProcessing: *const fn (*anyopaque, *ProcessSetup) callconv(.c) i32, + setProcessing: *const fn (*anyopaque, u8) callconv(.c) i32, + process: *const fn (*anyopaque, *ProcessData) callconv(.c) i32, + getTailSamples: *const fn (*anyopaque) callconv(.c) u32, +}; + +const IEditControllerVTable = extern struct { + base: IUnknownVTable, + initialize: *const fn (*anyopaque, ?*anyopaque) callconv(.c) i32, + terminate: *const fn (*anyopaque) callconv(.c) i32, + setComponentState: *const fn (*anyopaque, ?*anyopaque) callconv(.c) i32, + setState: *const fn (*anyopaque, ?*anyopaque) callconv(.c) i32, + getState: *const fn (*anyopaque, ?*anyopaque) callconv(.c) i32, + getParameterCount: *const fn (*anyopaque) callconv(.c) i32, + getParameterInfo: *const fn (*anyopaque, i32, *ParameterInfo) callconv(.c) i32, + getParamStringByValue: *const fn (*anyopaque, u32, f64, *[128]u16) callconv(.c) i32, + getParamValueByString: *const fn (*anyopaque, u32, [*]const u16, *f64) callconv(.c) i32, + normalizedParamToPlain: *const fn (*anyopaque, u32, f64) callconv(.c) f64, + plainParamToNormalized: *const fn (*anyopaque, u32, f64) callconv(.c) f64, + getParamNormalized: *const fn (*anyopaque, u32) callconv(.c) f64, + setParamNormalized: *const fn (*anyopaque, u32, f64) callconv(.c) i32, + setComponentHandler: *const fn (*anyopaque, ?*anyopaque) callconv(.c) i32, + createView: *const fn (*anyopaque, [*:0]const u8) callconv(.c) ?*anyopaque, +}; + +const BusInfo = extern struct { + mediaType: i32, + direction: i32, + channelCount: i32, + name: [128]u16, + busType: i32, + flags: u32, +}; + +const ParameterInfo = extern struct { + id: u32, + title: [128]u16, + shortTitle: [128]u16, + units: [128]u16, + stepCount: i32, + defaultNormalizedValue: f64, + unitId: i32, + flags: i32, +}; + +const ProcessSetup = extern struct { + processMode: i32, + symbolicSampleSize: i32, + maxSamplesPerBlock: i32, + sampleRate: f64, +}; + +const AudioBusBuffers = extern struct { + numChannels: i32, + silenceFlags: u64, + channelBuffers: ?[*][*]f32, +}; + +const ProcessData = extern struct { + processMode: i32, + symbolicSampleSize: i32, + numSamples: i32, + numInputs: i32, + numOutputs: i32, + inputs: ?[*]AudioBusBuffers, + outputs: ?[*]AudioBusBuffers, + inputParameterChanges: ?*anyopaque, + outputParameterChanges: ?*anyopaque, + inputEvents: ?*anyopaque, + outputEvents: ?*anyopaque, + processContext: ?*anyopaque, +}; + +const kAudio: i32 = 0; +const kInput: i32 = 0; +const kOutput: i32 = 1; +const kSample32: i32 = 0; + +/// Any VST3 interface pointer, viewed as the host views it. +const FactoryObject = extern struct { + vtbl: *const IPluginFactoryVTable, +}; + +const ComponentObject = extern struct { + vtbl: *const IComponentVTable, +}; + +const ProcessorObject = extern struct { + vtbl: *const IAudioProcessorVTable, +}; + +const ControllerObject = extern struct { + vtbl: *const IEditControllerVTable, +}; + +/// Enough of a created object to reference-count it and query it. +const UnknownObject = extern struct { + vtbl: *const IUnknownVTable, +}; + +// Interface ids, spelled out from the four words in DECLARE_CLASS_IID. On +// every platform except Windows they are stored big-endian in that order. +// +// IComponent 0xE831FF31, 0xF2D54301, 0x928EBBEE, 0x25697802 +// IAudioProcessor 0x42043F99, 0xB7DA453C, 0xA569E79D, 0x9AAEC33D +// IEditController 0xDCD7BBE3, 0x7742448D, 0xA874AACC, 0x979C759E + +const iid_component = [16]u8{ + 0xE8, 0x31, 0xFF, 0x31, 0xF2, 0xD5, 0x43, 0x01, + 0x92, 0x8E, 0xBB, 0xEE, 0x25, 0x69, 0x78, 0x02, +}; + +const iid_audio_processor = [16]u8{ + 0x42, 0x04, 0x3F, 0x99, 0xB7, 0xDA, 0x45, 0x3C, + 0xA5, 0x69, 0xE7, 0x9D, 0x9A, 0xAE, 0xC3, 0x3D, +}; + +const iid_edit_controller = [16]u8{ + 0xDC, 0xD7, 0xBB, 0xE3, 0x77, 0x42, 0x44, 0x8D, + 0xA8, 0x74, 0xAA, 0xCC, 0x97, 0x9C, 0x75, 0x9E, +}; + +/// A class id no plugin should claim, and an IID no plugin should implement. +const cid_nonsense = [16]u8{ + 0xBA, 0xDB, 0xAD, 0xBA, 0xDB, 0xAD, 0xBA, 0xDB, + 0xAD, 0xBA, 0xDB, 0xAD, 0xBA, 0xDB, 0xAD, 0xBA, +}; + +/// The category string a host filters on when looking for an effect. +const category_audio_effect = "Audio Module Class"; + +/// The module entry point every VST3 binary must export. +extern fn GetPluginFactory() ?*anyopaque; + +/// macOS hosts load the bundle and call this before anything else. A missing +/// bundleEntry means the module is discarded no matter how good the factory +/// behind it is. +extern fn bundleEntry(bundle: ?*anyopaque) callconv(.c) bool; + +// --- Harness --------------------------------------------------------------- + +var failures: u32 = 0; + +fn check(ok: bool, comptime label: []const u8) void { + if (ok) { + std.debug.print(" ok {s}\n", .{label}); + } else { + failures += 1; + std.debug.print(" FAIL {s}\n", .{label}); + } +} + +/// Length of a NUL-terminated string inside a fixed-size C char array. +fn cLen(buf: []const u8) usize { + return std.mem.indexOfScalar(u8, buf, 0) orelse buf.len; +} + +fn cEquals(buf: []const u8, expected: []const u8) bool { + return std.mem.eql(u8, buf[0..cLen(buf)], expected); +} + +fn allZero(bytes: []const u8) bool { + for (bytes) |b| { + if (b != 0) return false; + } + return true; +} + +fn checkModuleEntry() void { + std.debug.print("VST3 module entry\n", .{}); + check(bundleEntry(null), "bundleEntry accepts the load and reports success"); +} + +fn checkFactoryAbi() void { + std.debug.print("VST3 factory ABI\n", .{}); + + const raw = GetPluginFactory(); + check(raw != null, "GetPluginFactory returns a non-null object"); + if (raw == null) return; + + const factory: *FactoryObject = @ptrCast(@alignCast(raw.?)); + const vtbl = factory.vtbl; + + // A host reads the first word of the object and calls through it. If the + // plugin ever stops putting the vtable pointer first, this dereference is + // where it shows up. + check(vtbl.countClasses(raw.?) == 1, "countClasses reports one exported class"); + + // Reference counting must be symmetric: a host addRefs before handing the + // pointer around and releases when done. + const after_add = vtbl.base.addRef(raw.?); + const after_release = vtbl.base.release(raw.?); + check(after_add == after_release + 1, "addRef/release move the count by exactly one"); + + // The factory implements no component interface, so a query for one must + // fail rather than hand back a garbage pointer. + var out: ?*anyopaque = @ptrFromInt(@as(usize, 0xdead)); + const qi = vtbl.base.queryInterface(raw.?, &iid_component, &out); + check(qi != kResultOk, "factory queryInterface for an unsupported IID reports failure"); + check(out == null, "factory queryInterface nulls the out pointer on failure"); + + // getFactoryInfo has to fill the struct, not merely return success. + var finfo = std.mem.zeroes(PFactoryInfo); + check(vtbl.getFactoryInfo(raw.?, &finfo) == kResultOk, "getFactoryInfo returns kResultOk"); + check(cLen(&finfo.vendor) > 0, "getFactoryInfo writes a vendor name"); + + checkClassInfo(raw.?, vtbl); +} + +fn checkClassInfo(raw: *anyopaque, vtbl: *const IPluginFactoryVTable) void { + // Zeroed first, so anything still zero afterwards was never written. + var info = std.mem.zeroes(PClassInfo); + check(vtbl.getClassInfo(raw, 0, &info) == kResultOk, "getClassInfo(0) returns kResultOk"); + check( + cEquals(&info.category, category_audio_effect), + "getClassInfo(0) writes the \"Audio Module Class\" category", + ); + check(cLen(&info.name) > 0, "getClassInfo(0) writes a non-empty class name"); + check(!allZero(&info.cid), "getClassInfo(0) writes a non-zero class id"); + check(info.cardinality != 0, "getClassInfo(0) writes a cardinality"); + + // An index past the end is an error, not a silent success. + var beyond = std.mem.zeroes(PClassInfo); + check(vtbl.getClassInfo(raw, 1, &beyond) != kResultOk, "getClassInfo(1) reports an invalid index"); + + checkCreateInstance(raw, vtbl, info.cid); +} + +fn checkCreateInstance(raw: *anyopaque, vtbl: *const IPluginFactoryVTable, cid: [16]u8) void { + // A class id the factory does not export must be refused outright. + var rejected: ?*anyopaque = @ptrFromInt(@as(usize, 0xdead)); + const bad = vtbl.createInstance(raw, &cid_nonsense, &iid_component, &rejected); + check(bad != kResultOk, "createInstance refuses an unknown class id"); + check(rejected == null, "createInstance nulls the out pointer for an unknown class id"); + + // The class id getClassInfo advertised must produce a real object. + var created: ?*anyopaque = null; + const made = vtbl.createInstance(raw, &cid, &iid_component, &created); + check(made == kResultOk, "createInstance accepts the advertised class id"); + check(created != null, "createInstance writes a non-null object"); + if (created == null) return; + + const object: *UnknownObject = @ptrCast(@alignCast(created.?)); + + // The first word has to be a usable vtable pointer, which the calls below + // exercise. An uninitialised struct would fault here. + const added = object.vtbl.addRef(created.?); + const released = object.vtbl.release(created.?); + check(added == released + 1, "the created object counts references"); + + checkObjectInterfaces(created.?, object); + checkAudioPath(created.?); + + check(object.vtbl.release(created.?) == 0, "releasing the last reference drops the count to zero"); +} + +const block_frames = 64; + +/// Run one block of DC through the plugin and return the last output sample. +/// DC makes the applied gain readable straight off the buffer. +fn renderDcBlock(processor: *ProcessorObject, raw: *anyopaque) ?f32 { + var in_l = [_]f32{1.0} ** block_frames; + var in_r = [_]f32{1.0} ** block_frames; + var out_l = [_]f32{0.0} ** block_frames; + var out_r = [_]f32{0.0} ** block_frames; + + var in_ptrs = [_][*]f32{ &in_l, &in_r }; + var out_ptrs = [_][*]f32{ &out_l, &out_r }; + + var in_buses = [_]AudioBusBuffers{.{ .numChannels = 2, .silenceFlags = 0, .channelBuffers = &in_ptrs }}; + var out_buses = [_]AudioBusBuffers{.{ .numChannels = 2, .silenceFlags = 0, .channelBuffers = &out_ptrs }}; + + var data = ProcessData{ + .processMode = 0, + .symbolicSampleSize = kSample32, + .numSamples = block_frames, + .numInputs = 1, + .numOutputs = 1, + .inputs = &in_buses, + .outputs = &out_buses, + .inputParameterChanges = null, + .outputParameterChanges = null, + .inputEvents = null, + .outputEvents = null, + .processContext = null, + }; + + if (processor.vtbl.process(raw, &data) != kResultOk) return null; + return out_l[block_frames - 1]; +} + +/// Drive the object the way a host does: initialize, describe the buses, set +/// processing up, run audio, and read the result back. A factory that hands +/// out a plausible-looking pointer still fails here if nothing behind it works. +fn checkAudioPath(created: *anyopaque) void { + const component: *ComponentObject = @ptrCast(@alignCast(created)); + + check(component.vtbl.initialize(created, null) == kResultOk, "IComponent.initialize accepts a host context"); + check(component.vtbl.getBusCount(created, kAudio, kInput) == 1, "the plugin reports one audio input bus"); + check(component.vtbl.getBusCount(created, kAudio, kOutput) == 1, "the plugin reports one audio output bus"); + + var bus = std.mem.zeroes(BusInfo); + const got_bus = component.vtbl.getBusInfo(created, kAudio, kOutput, 0, &bus); + check(got_bus == kResultOk and bus.channelCount == 2, "the output bus reports two channels"); + check(bus.name[0] != 0, "getBusInfo writes a bus name"); + + var processor_ptr: ?*anyopaque = null; + _ = component.vtbl.base.queryInterface(created, &iid_audio_processor, &processor_ptr); + var controller_ptr: ?*anyopaque = null; + _ = component.vtbl.base.queryInterface(created, &iid_edit_controller, &controller_ptr); + if (processor_ptr == null or controller_ptr == null) return; + + const processor: *ProcessorObject = @ptrCast(@alignCast(processor_ptr.?)); + const controller: *ControllerObject = @ptrCast(@alignCast(controller_ptr.?)); + defer _ = processor.vtbl.base.release(processor_ptr.?); + defer _ = controller.vtbl.base.release(controller_ptr.?); + + checkParameters(controller, controller_ptr.?); -pub fn main() !void { + check( + processor.vtbl.canProcessSampleSize(processor_ptr.?, kSample32) == kResultOk, + "the processor accepts 32-bit samples", + ); + + var setup = ProcessSetup{ + .processMode = 0, + .symbolicSampleSize = kSample32, + .maxSamplesPerBlock = block_frames, + .sampleRate = 48000.0, + }; + check(processor.vtbl.setupProcessing(processor_ptr.?, &setup) == kResultOk, "setupProcessing accepts 48 kHz"); + check(component.vtbl.setActive(created, 1) == kResultOk, "setActive(true) is accepted"); + check(processor.vtbl.setProcessing(processor_ptr.?, 1) == kResultOk, "setProcessing(true) is accepted"); + + // The default gain is 0 dB, so a full-scale DC input comes out unchanged. + const unity = renderDcBlock(processor, processor_ptr.?); + check(unity != null, "process returns kResultOk"); + check(unity != null and @abs(unity.? - 1.0) < 1e-4, "the default 0 dB setting passes DC through at unity"); + + // +6 dB is a factor of ~1.995. setProcessing is toggled so the smoother + // starts the block already at the new target. + _ = controller.vtbl.setParamNormalized(controller_ptr.?, 0, (6.0 + 48.0) / 96.0); + _ = processor.vtbl.setProcessing(processor_ptr.?, 0); + _ = processor.vtbl.setProcessing(processor_ptr.?, 1); + const boosted = renderDcBlock(processor, processor_ptr.?); + check(boosted != null and @abs(boosted.? - 1.99526) < 1e-3, "a +6 dB gain setting scales DC by ~1.995"); + + // Bypass has to win over the gain setting. + _ = controller.vtbl.setParamNormalized(controller_ptr.?, 1, 1.0); + _ = processor.vtbl.setProcessing(processor_ptr.?, 0); + _ = processor.vtbl.setProcessing(processor_ptr.?, 1); + const bypassed = renderDcBlock(processor, processor_ptr.?); + check(bypassed != null and bypassed.? == 1.0, "bypass passes the input through untouched"); + + _ = processor.vtbl.setProcessing(processor_ptr.?, 0); + _ = component.vtbl.setActive(created, 0); + check(component.vtbl.terminate(created) == kResultOk, "terminate is accepted"); +} + +fn checkParameters(controller: *ControllerObject, raw: *anyopaque) void { + check(controller.vtbl.getParameterCount(raw) == 2, "the controller exposes two parameters"); + + var info = std.mem.zeroes(ParameterInfo); + check(controller.vtbl.getParameterInfo(raw, 0, &info) == kResultOk, "getParameterInfo(0) returns kResultOk"); + check(info.title[0] != 0, "getParameterInfo(0) writes a parameter title"); + + var bypass = std.mem.zeroes(ParameterInfo); + check(controller.vtbl.getParameterInfo(raw, 1, &bypass) == kResultOk, "getParameterInfo(1) returns kResultOk"); + // kIsBypass is what tells a host which parameter its bypass button drives. + check((bypass.flags & (1 << 16)) != 0, "the second parameter is flagged as the bypass"); + + var beyond = std.mem.zeroes(ParameterInfo); + check(controller.vtbl.getParameterInfo(raw, 2, &beyond) != kResultOk, "getParameterInfo(2) reports an invalid index"); + + // A round trip through the display string is what a host does when a user + // types a value into the generic editor. + var text = std.mem.zeroes([128]u16); + check( + controller.vtbl.getParamStringByValue(raw, 0, 0.5, &text) == kResultOk and text[0] != 0, + "getParamStringByValue writes a display string for the gain", + ); + var parsed: f64 = -1.0; + check( + controller.vtbl.getParamValueByString(raw, 0, &text, &parsed) == kResultOk and @abs(parsed - 0.5) < 1e-6, + "getParamValueByString parses its own display string back", + ); +} + +fn checkObjectInterfaces(created: *anyopaque, object: *UnknownObject) void { + // An audio effect must offer IAudioProcessor, otherwise a host has no way + // to run it. + var processor: ?*anyopaque = null; + const qp = object.vtbl.queryInterface(created, &iid_audio_processor, &processor); + check(qp == kResultOk and processor != null, "the object hands back IAudioProcessor"); + if (processor) |p| { + const proc_obj: *UnknownObject = @ptrCast(@alignCast(p)); + _ = proc_obj.vtbl.release(p); + } + + // This plugin keeps the controller on the same object, so the query must + // succeed and must give a different interface pointer. + var controller: ?*anyopaque = null; + const qc = object.vtbl.queryInterface(created, &iid_edit_controller, &controller); + check(qc == kResultOk and controller != null, "the object hands back IEditController"); + if (controller) |c| { + check(c != created, "IEditController is a distinct interface pointer from IComponent"); + const ctrl_obj: *UnknownObject = @ptrCast(@alignCast(c)); + _ = ctrl_obj.vtbl.release(c); + } + + // Anything unsupported must fail and leave the caller with null. + var nothing: ?*anyopaque = @ptrFromInt(@as(usize, 0xdead)); + const qn = object.vtbl.queryInterface(created, &cid_nonsense, ¬hing); + check(qn != kResultOk, "the object refuses an unsupported IID"); + check(nothing == null, "the object nulls the out pointer for an unsupported IID"); +} + +fn checkLibraryLinkage(allocator: std.mem.Allocator) void { + std.debug.print("danzig static library\n", .{}); + + var buf = danzig.AudioBuffer.init(allocator, 2, 64, 48000.0) catch { + check(false, "AudioBuffer.init allocates"); + return; + }; + defer buf.deinit(allocator); + check(buf.channelCount == 2 and buf.sampleCount == 64, "AudioBuffer reports its geometry"); + + // Unity gain must be bit-transparent through the same conversion the + // plugin uses on the audio thread. + check(@abs(danzig.dBToLinear(0.0) - 1.0) < 1e-6, "dBToLinear(0 dB) is unity"); + check(@abs(danzig.dBToLinear(6.0) - 1.99526) < 1e-4, "dBToLinear(+6 dB) is ~1.995"); + + var store = danzig.ParamStore(4){}; + const gain = store.add(-48.0, 48.0, 0.5, 0.0, 48000.0); + store.setNormalized(gain, 1.0); + store.tickAll(); + check(@abs(store.getSmoothed(gain) - 48.0) < 1e-3, "ParamStore reaches +48 dB at full scale"); +} + +pub fn main() !u8 { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const allocator = gpa.allocator(); - std.debug.print("✓ Test executable compiles and links with danzig library\n", .{}); - - // Simple sanity check - std.debug.print("✓ Allocator initialized: {any}\n", .{allocator}); - std.debug.print("✓ Danzig library linking successful!\n", .{}); + std.debug.print("danzig integration harness\n\n", .{}); + + checkModuleEntry(); + std.debug.print("\n", .{}); + checkFactoryAbi(); + std.debug.print("\n", .{}); + checkLibraryLinkage(allocator); + + std.debug.print("\n", .{}); + if (failures == 0) { + std.debug.print("all integration checks passed\n", .{}); + return 0; + } + std.debug.print("{d} integration check(s) failed\n", .{failures}); + return 1; } diff --git a/examples/danzig-webui/README.md b/examples/danzig-webui/README.md new file mode 100644 index 0000000..8f3568e --- /dev/null +++ b/examples/danzig-webui/README.md @@ -0,0 +1,86 @@ +# danzig-webui + +An HTTP server written against `std.net` alone, serving the danzig web UI. + +## What it demonstrates + +A plugin UI has to come from somewhere. One option is HTML in a WebView, which +means you need something to serve it during development. This example is that +something, in 150 lines of Zig with no dependency beyond the standard library. + +It loads `ui/index.html` at startup, listens on `127.0.0.1:3000`, and handles +GET, POST, and OPTIONS with CORS headers. `/api/process` is stubbed and returns +a fixed JSON body, ready to be wired to the DSP. + +One detail worth copying. The request read uses `stream.read` rather than +`readAll`: + +```zig +// A single read rather than readAll: readAll blocks until the buffer is +// full or the peer closes, which for a keep-alive HTTP client means +// hanging. It was also removed from net.Stream in Zig 0.15. +const bytes_read = try stream.read(&buffer); +``` + +`readAll` on a keep-alive connection waits for a close that never comes. + +## Build and run + +The server reads `ui/index.html` from a relative path, so run it from the +repository root. + +```bash +zig build +./zig-out/bin/danzig-webui +``` + +``` +đŸŽĩ DanzigGain Web Server +======================== +🌐 Open http://localhost:3000 +âšī¸ Press Ctrl+C to stop +``` + +Then open . + +## Check it from the shell + +```bash +curl -s -D- -o /dev/null http://127.0.0.1:3000/ +``` + +``` +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +Content-Length: 15787 +Access-Control-Allow-Origin: * +``` + +## The port + +3000 is a constant at the top of `root.zig`. If something else already holds +it, requests will reach the other service instead of this one, which looks like +the server serving the wrong page. Check first: + +```bash +lsof -nP -iTCP:3000 -sTCP:LISTEN +``` + +Change `const PORT` and rebuild to move it. + +## Limits + +- Single-threaded. It handles one connection at a time. Fine for development. +- No routing beyond the four cases in `handleConnection`. +- `/api/process` returns `{"status":"ok","processed":true}` without touching + audio. + +## Related + +`ui/index.html` is the page itself, and `ui/README.md` documents its controls. +The same file is embedded into `../danzig-gain-ui`, which renders it in a +native window with no server involved. + +--- + +See [docs/WIKI.md](../../docs/WIKI.md) for the rest of the project. diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..10e79e2 --- /dev/null +++ b/setup.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# +# danzig setup: check the toolchain, build, test, and report where the VST3 +# bundle lands. Safe to run repeatedly. +# +# ./setup.sh build and test +# ./setup.sh --release same, with -Doptimize=ReleaseFast +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$REPO_ROOT" + +SUPPORTED_VERSIONS="0.14.1 0.15.2" +OPTIMIZE_FLAG="" + +for arg in "$@"; do + case "$arg" in + --release) + OPTIMIZE_FLAG="-Doptimize=ReleaseFast" + ;; + -h|--help) + sed -n '2,8p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "setup.sh: unknown argument '$arg'" >&2 + exit 2 + ;; + esac +done + +say() { printf '\n== %s\n' "$1"; } +fail() { printf 'error: %s\n' "$1" >&2; exit 1; } + +# --- 1. Toolchain ---------------------------------------------------------- + +say "Toolchain" + +if ! command -v zig >/dev/null 2>&1; then + cat >&2 <<'EOF' +error: zig is not on PATH. + +Install one of the supported versions and try again: + + brew install zig # currently ships 0.15.2 + https://ziglang.org/download/ # tarballs for 0.14.1 and 0.15.2 + +If you keep several toolchains side by side, put the one you want first on +PATH for this shell: + + export PATH="$HOME/zig/0.14.1:$PATH" +EOF + exit 1 +fi + +ZIG_BIN="$(command -v zig)" +ZIG_VERSION="$(zig version)" +echo "zig $ZIG_VERSION ($ZIG_BIN)" + +version_supported=0 +for v in $SUPPORTED_VERSIONS; do + [ "$ZIG_VERSION" = "$v" ] && version_supported=1 +done + +if [ "$version_supported" -eq 0 ]; then + cat >&2 <