From 043d3c04341a17faf1fe3fef2a6d3ffa71bf4410 Mon Sep 17 00:00:00 2001 From: godofecht Date: Tue, 21 Jul 2026 12:16:34 +0100 Subject: [PATCH 1/2] Add docs/WIKI.md, per-example READMEs, a minimal example, and setup.sh docs/WIKI.md is a single self-contained page: what danzig is, how the COM vtable machinery in src/vst3.zig maps to Zig extern structs, plugin registration, the audio callback path, the parameter system and its one-cache-line layout, the audio helpers, how the universal VST3 bundle is built, testing, and troubleshooting. Every command and every block of output in it was run. examples/danzig-minimal is new: one parameter, one line of DSP, and the single symbol a VST3 host looks for. The same source builds as the shared library a host would load and as an executable, so the DSP can be checked without a DAW. It is the file to copy when starting a plugin. examples/danzig-test was a print statement claiming to be an integration test. It now links the built DanzigGain plugin, calls its exported GetPluginFactory, and drives the returned object through the raw VST3 C ABI using its own copy of the vtable layout, so a change to the object layout fails here rather than in a DAW. It exits non-zero on failure. Each example directory gets a README with what it demonstrates and the exact commands, plus an examples/README.md index that also flags the two unbuilt leftover files. setup.sh checks the Zig version, warns outside 0.14.1 and 0.15.2, builds, tests, packages the bundle, and prints the install path. Idempotent, non-zero on failure. The README's Build Artifacts section listed sizes and a libdanzig.a that do not exist. Corrected against a real ReleaseFast build. Both the README and the wiki now state plainly that the gain plugin's factory is a stub and that a host currently finds zero classes in the bundle. Verified on 0.14.1 and 0.15.2: 29/29 build steps, 35/35 tests, and lipo reporting x86_64 arm64 for the bundle binary. --- README.md | 209 +++-- build.zig | 37 +- docs/WIKI.md | 883 ++++++++++++++++++++++ examples/README.md | 68 ++ examples/danzig-gain-standalone/README.md | 120 +++ examples/danzig-gain-ui/README.md | 114 +++ examples/danzig-gain/README.md | 137 ++++ examples/danzig-minimal/README.md | 102 +++ examples/danzig-minimal/root.zig | 120 +++ examples/danzig-test/README.md | 107 +++ examples/danzig-test/root.zig | 129 +++- examples/danzig-webui/README.md | 86 +++ setup.sh | 134 ++++ 13 files changed, 2125 insertions(+), 121 deletions(-) create mode 100644 docs/WIKI.md create mode 100644 examples/README.md create mode 100644 examples/danzig-gain-standalone/README.md create mode 100644 examples/danzig-gain-ui/README.md create mode 100644 examples/danzig-gain/README.md create mode 100644 examples/danzig-minimal/README.md create mode 100644 examples/danzig-minimal/root.zig create mode 100644 examples/danzig-test/README.md create mode 100644 examples/danzig-webui/README.md create mode 100755 setup.sh 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(' -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..059978f 100644 --- a/examples/danzig-test/root.zig +++ b/examples/danzig-test/root.zig @@ -1,14 +1,129 @@ -// 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. +// +// 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 IPluginFactory +// layout. They are deliberately independent of the plugin's own definitions. + +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, *anyopaque) callconv(.c) i32, + countClasses: *const fn (*anyopaque) callconv(.c) i32, + getClassInfo: *const fn (*anyopaque, i32, *anyopaque) callconv(.c) i32, + createInstance: *const fn (*anyopaque, [*]const u8, [*]const u8, *?*anyopaque) callconv(.c) i32, +}; + +/// Any VST3 interface pointer, viewed as the host views it. +const FactoryObject = extern struct { + vtbl: *const IPluginFactoryVTable, +}; + +/// The module entry point every VST3 binary must export. +extern fn GetPluginFactory() ?*anyopaque; + +// --- 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}); + } +} + +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; -pub fn main() !void { + // 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 other interface, so a query must fail rather + // than hand back a garbage pointer. + const iid = [_]u8{0} ** 16; + var out: ?*anyopaque = @ptrFromInt(@as(usize, 0xdead)); + const qi = vtbl.base.queryInterface(raw.?, &iid, &out); + check(qi != 0, "queryInterface for an unknown IID reports failure"); + + var info: [512]u8 = undefined; + check(vtbl.getFactoryInfo(raw.?, &info) == 0, "getFactoryInfo returns kResultOk"); + check(vtbl.getClassInfo(raw.?, 0, &info) == 0, "getClassInfo(0) returns kResultOk"); +} + +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", .{}); + + 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 < Date: Tue, 21 Jul 2026 12:55:26 +0100 Subject: [PATCH 2/2] Implement the VST3 factory and plugin object The example plugin could not be loaded by any host. The factory claimed one class, wrote nothing into the PClassInfo it was handed, and returned kResultOk from createInstance without producing an object. The bundle also exported no bundleEntry, so a macOS host discarded the module before it ever reached the factory. pluginval reported "Num plugins found: 0". src/vst3.zig now carries the real C ABI: interface ids, PClassInfo and PClassInfo2, bus and process structs, and a vtable type per interface, with comptime size assertions on the layouts a host reads. The old result codes were wrong (kResultFalse was 0, kBypass was 1 << 5) and are corrected. examples/danzig-gain is a working single-component effect. One object exposes IComponent, IAudioProcessor and IEditController, recovering itself from each interface with @fieldParentPtr, sharing one atomic refcount. Gain comes from the lock-free ParamStore and is applied per sample; bypass passes the input through. State is a versioned blob of normalized values. Anything not implemented returns kNotImplemented rather than a false success. The integration harness now asserts on content instead of result codes: buffers are zeroed before every call, the class info must name the Audio Module Class category, and createInstance must yield an object that queries, counts references, and renders audio at the gain it was told to use. Nine of those checks fail against the old stub. pluginval passes at strictness 10 on both Zig 0.14.1 and 0.15.2. Co-Authored-By: Claude Opus 4.8 (1M context) --- DANZIG.md | 21 +- examples/danzig-gain/root.zig | 962 +++++++++++++++++++++++++++++----- examples/danzig-test/root.zig | 431 ++++++++++++++- src/vst3.zig | 529 +++++++++++++++---- 4 files changed, 1686 insertions(+), 257 deletions(-) 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/examples/danzig-gain/root.zig b/examples/danzig-gain/root.zig index f533184..8c861cf 100644 --- a/examples/danzig-gain/root.zig +++ b/examples/danzig-gain/root.zig @@ -1,187 +1,901 @@ -// Danzig Gain - Simple VST3 Gain Plugin Example -// Demonstrates danzig library usage with a stereo gain effect +// Danzig Gain: a loadable VST3 gain plugin. +// +// This file is the whole plugin. It has three parts: +// +// 1. The DSP core (GainPlugin), which knows nothing about VST3. +// 2. One COM-style object exposing IComponent, IAudioProcessor and +// IEditController over the same instance. VST3 usually splits processing +// and editing into two classes; combining them is legal and is what the +// SDK calls a single-component effect. It keeps the parameter store in +// one place. +// 3. A static IPluginFactory, plus the module entry points a host looks for. +// +// The parameter store is lock-free, so the host thread can write a value while +// the audio thread is mid-block without either waiting on the other. const std = @import("std"); const danzig = @import("danzig"); +const vst3 = danzig.vst3; -// Plugin IDs - generated UUIDs (example) -const PLUGIN_UID = [16]u8{ +// --- identity -------------------------------------------------------------- + +/// The class id the host stores in its project files. Changing it orphans +/// every session that already loaded this plugin. +const processor_cid = vst3.TUID{ 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, }; -const CONTROLLER_UID = [16]u8{ - 0x87, 0x65, 0x43, 0x21, 0xf0, 0xed, 0xcb, 0xa9, - 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, -}; +const plugin_name = "Danzig Gain"; +const vendor_name = "Superelectric"; +const vendor_url = "https://superelectric.dev"; +const vendor_email = "danzig@superelectric.dev"; +const plugin_version = "0.1.0"; +const sdk_version = "VST 3.7.0"; + +// --- parameters ------------------------------------------------------------ -const ParamID = struct { - pub const Gain: u32 = 0; - pub const Bypass: u32 = 1; +const num_params = 2; + +/// Index into the ParamStore. Also the VST3 ParamID, which keeps the mapping +/// between the two trivial. +const ParamIndex = struct { + pub const gain: u32 = 0; + pub const bypass: u32 = 1; }; -var gpa = std.heap.GeneralPurposeAllocator(.{}){}; -var gPluginInstance: ?*GainPlugin = null; +const gain_min_db: f32 = -48.0; +const gain_max_db: f32 = 48.0; +const gain_default_norm: f32 = 0.5; // 0 dB +const gain_smoothing_ms: f32 = 20.0; -pub const GainPlugin = struct { - plugin: danzig.Plugin, - gainProcessor: danzig.GainProcessor = .{}, - - pub fn init(allocator: std.mem.Allocator) !*GainPlugin { - const self = try allocator.create(GainPlugin); - self.plugin = danzig.Plugin.init(allocator); - self.gainProcessor = .{}; - - // Add Gain parameter - var gainParam = danzig.Parameter{ - .id = ParamID.Gain, - .normalized = 0.5, - .plain = 0.0, - .title = undefined, - .units = undefined, - .minValue = -48.0, - .maxValue = 48.0, - .defaultValue = 0.0, - .stepCount = 0, - }; - @memcpy(gainParam.title[0..4], "Gain"); - @memcpy(gainParam.units[0..2], "dB"); - - try self.plugin.addParameter(gainParam); +// --- 1. DSP core ----------------------------------------------------------- +/// The audio half of the plugin. No allocation, no locks, no VST3 types. +pub const GainPlugin = struct { + params: danzig.ParamStore(num_params) = .{}, + sample_rate: f32 = 48000.0, + + pub fn init(sample_rate: f32) GainPlugin { + var self = GainPlugin{ .sample_rate = sample_rate }; + _ = self.params.add(gain_min_db, gain_max_db, gain_default_norm, gain_smoothing_ms, sample_rate); + // Bypass is a switch, so smoothing it would produce a half-bypassed + // state that means nothing. + _ = self.params.add(0.0, 1.0, 0.0, 0.0, sample_rate); return self; } - pub fn deinit(self: *GainPlugin, allocator: std.mem.Allocator) void { - self.plugin.deinit(); - allocator.destroy(self); + /// Re-derive the smoothing coefficient. It is a function of the sample + /// rate, so a rate change invalidates it. + pub fn setSampleRate(self: *GainPlugin, sample_rate: f32) void { + self.sample_rate = sample_rate; + self.params.params[ParamIndex.gain].setSmoothingMs(gain_smoothing_ms, sample_rate); + self.params.snapAll(); } - pub fn setupProcessing(self: *GainPlugin, sampleRate: f64) void { - self.plugin.setupProcessing(sampleRate, 256); + pub fn isBypassed(self: *const GainPlugin) bool { + return self.params.getNormalized(ParamIndex.bypass) >= 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-test/root.zig b/examples/danzig-test/root.zig index 059978f..42f9c9d 100644 --- a/examples/danzig-test/root.zig +++ b/examples/danzig-test/root.zig @@ -7,6 +7,11 @@ // 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"); @@ -15,8 +20,10 @@ 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 IPluginFactory -// layout. They are deliberately independent of the plugin's own definitions. +// 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, @@ -26,20 +33,186 @@ const IUnknownVTable = extern struct { const IPluginFactoryVTable = extern struct { base: IUnknownVTable, - getFactoryInfo: *const fn (*anyopaque, *anyopaque) callconv(.c) i32, + getFactoryInfo: *const fn (*anyopaque, *PFactoryInfo) callconv(.c) i32, countClasses: *const fn (*anyopaque) callconv(.c) i32, - getClassInfo: *const fn (*anyopaque, i32, *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; @@ -53,6 +226,27 @@ fn check(ok: bool, comptime label: []const u8) void { } } +/// 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", .{}); @@ -74,16 +268,227 @@ fn checkFactoryAbi() void { 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 other interface, so a query must fail rather - // than hand back a garbage pointer. - const iid = [_]u8{0} ** 16; + // 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, &out); - check(qi != 0, "queryInterface for an unknown IID reports failure"); + 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; - var info: [512]u8 = undefined; - check(vtbl.getFactoryInfo(raw.?, &info) == 0, "getFactoryInfo returns kResultOk"); - check(vtbl.getClassInfo(raw.?, 0, &info) == 0, "getClassInfo(0) returns kResultOk"); + 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.?); + + 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 { @@ -115,6 +520,8 @@ pub fn main() !u8 { std.debug.print("danzig integration harness\n\n", .{}); + checkModuleEntry(); + std.debug.print("\n", .{}); checkFactoryAbi(); std.debug.print("\n", .{}); checkLibraryLinkage(allocator); diff --git a/src/vst3.zig b/src/vst3.zig index 0a6cee2..05b64e8 100644 --- a/src/vst3.zig +++ b/src/vst3.zig @@ -1,18 +1,135 @@ -// VST3 C ABI Bindings -// Minimal VST3 interface definitions for plugin development +// VST3 C ABI bindings. +// +// A VST3 interface is a pointer to a struct whose first word points at a +// vtable of C function pointers. The host never sees a Zig type, so every +// layout here has to match Steinberg's headers byte for byte. The types are +// therefore split in two: a `*VTable` struct holding the function pointers in +// declaration order, and a one-field `extern struct` that is the object the +// host actually holds. +// +// Interface inheritance is flattened: an IComponent vtable starts with the +// three FUnknown slots, then the two IPluginBase slots, then its own. +// +// Result codes follow the non-Windows branch of funknown.h, which is what +// macOS and Linux builds use. COM_COMPATIBLE is only set on Windows, where +// these become HRESULTs instead. -pub const CUID = [16]u8; - -pub const kInfinite = 0x7FFFFFFF; - -pub const IID = [16]u8; +const std = @import("std"); +pub const TUID = [16]u8; +pub const CUID = TUID; +pub const IID = TUID; pub const TResult = i32; + +pub const kNoInterface: TResult = -1; pub const kResultOk: TResult = 0; -pub const kResultTrue: TResult = 1; -pub const kResultFalse: TResult = 0; -pub const kResultInvalidArgument: TResult = 2; +pub const kResultTrue: TResult = 0; +pub const kResultFalse: TResult = 1; +pub const kInvalidArgument: TResult = 2; pub const kNotImplemented: TResult = 3; +pub const kInternalError: TResult = 4; +pub const kNotInitialized: TResult = 5; +pub const kOutOfMemory: TResult = 6; + +/// Kept under its old name so existing callers keep compiling. +pub const kResultInvalidArgument: TResult = kInvalidArgument; + +/// Build a TUID from the four 32-bit words used by DECLARE_CLASS_IID. +/// +/// Steinberg stores them big-endian on every platform except Windows, where +/// COM_COMPATIBLE reorders the first three words to match a Windows GUID. +pub fn uid(a: u32, b: u32, c: u32, d: u32) TUID { + var out: TUID = undefined; + std.mem.writeInt(u32, out[0..4], a, .big); + std.mem.writeInt(u32, out[4..8], b, .big); + std.mem.writeInt(u32, out[8..12], c, .big); + std.mem.writeInt(u32, out[12..16], d, .big); + return out; +} + +pub fn uidEqual(a: TUID, b: TUID) bool { + return std.mem.eql(u8, &a, &b); +} + +// --- interface identifiers ------------------------------------------------- + +pub const IID_FUnknown = uid(0x00000000, 0x00000000, 0xC0000000, 0x00000046); +pub const IID_IPluginBase = uid(0x22888DDB, 0x156E45AE, 0x8358B348, 0x08190625); +pub const IID_IPluginFactory = uid(0x7A4D811C, 0x52114A1F, 0xAED9D2EE, 0x0B43BF9F); +pub const IID_IPluginFactory2 = uid(0x0007B650, 0xF24B4C0B, 0xA464EDB9, 0xF00B2ABB); +pub const IID_IPluginFactory3 = uid(0x4555A2AB, 0xC1234E57, 0x9B122910, 0x36878931); + +pub const IID_IComponent = uid(0xE831FF31, 0xF2D54301, 0x928EBBEE, 0x25697802); +pub const IID_IAudioProcessor = uid(0x42043F99, 0xB7DA453C, 0xA569E79D, 0x9AAEC33D); +pub const IID_IEditController = uid(0xDCD7BBE3, 0x7742448D, 0xA874AACC, 0x979C759E); +pub const IID_IComponentHandler = uid(0x93A0BEA3, 0x0BD045DB, 0x8E890B0C, 0xC1E46AC6); +pub const IID_IConnectionPoint = uid(0x70A4156F, 0x6E6E4026, 0x989148BF, 0xAA60D8D1); +pub const IID_IUnitInfo = uid(0x3D4BD6B5, 0x913A4FD2, 0xA886E768, 0xA5EB92C1); +pub const IID_IMidiMapping = uid(0xDF0FF9F7, 0x49B74669, 0xB63AB732, 0x7ADBF5E5); +pub const IID_IBStream = uid(0xC3BF6EA2, 0x30994752, 0x9B6BF990, 0x1EE33E9B); +pub const IID_IParameterChanges = uid(0xA4779663, 0x0BB64A56, 0xB44384A8, 0x466FEB9D); +pub const IID_IParamValueQueue = uid(0x01263A18, 0xED074F6F, 0x98C9D356, 0x4686F9BA); + +// --- class registry -------------------------------------------------------- + +pub const kCategoryAudioEffect = "Audio Module Class"; +pub const kCategoryComponentController = "Component Controller Class"; + +pub const kManyInstances: i32 = 0x7FFFFFFF; +/// Old name for the same constant. +pub const kInfinite = kManyInstances; + +pub const PFactoryInfo = extern struct { + vendor: [64]u8 = [_]u8{0} ** 64, + url: [256]u8 = [_]u8{0} ** 256, + email: [128]u8 = [_]u8{0} ** 128, + flags: i32 = 0, +}; + +pub const kFactoryNoFlags: i32 = 0; +pub const kClassesDiscardable: i32 = 1 << 0; +pub const kLicenseCheck: i32 = 1 << 1; +pub const kComponentNonDiscardable: i32 = 1 << 3; +pub const kFactoryUnicode: i32 = 1 << 4; + +pub const PClassInfo = extern struct { + cid: TUID = [_]u8{0} ** 16, + cardinality: i32 = kManyInstances, + category: [32]u8 = [_]u8{0} ** 32, + name: [64]u8 = [_]u8{0} ** 64, +}; + +pub const PClassInfo2 = extern struct { + cid: TUID = [_]u8{0} ** 16, + cardinality: i32 = kManyInstances, + category: [32]u8 = [_]u8{0} ** 32, + name: [64]u8 = [_]u8{0} ** 64, + classFlags: u32 = 0, + subCategories: [128]u8 = [_]u8{0} ** 128, + vendor: [64]u8 = [_]u8{0} ** 64, + version: [64]u8 = [_]u8{0} ** 64, + sdkVersion: [64]u8 = [_]u8{0} ** 64, +}; + +pub const PClassInfoW = extern struct { + cid: TUID = [_]u8{0} ** 16, + cardinality: i32 = kManyInstances, + category: [32]u8 = [_]u8{0} ** 32, + name: [64]u16 = [_]u16{0} ** 64, + classFlags: u32 = 0, + subCategories: [128]u8 = [_]u8{0} ** 128, + vendor: [64]u16 = [_]u16{0} ** 64, + version: [64]u16 = [_]u16{0} ** 64, + sdkVersion: [64]u16 = [_]u16{0} ** 64, +}; + +// --- media, buses, speakers ------------------------------------------------ + +pub const MediaType = i32; +pub const kAudio: MediaType = 0; +pub const kEvent: MediaType = 1; +pub const kMediaTypeAudio: MediaType = kAudio; +pub const kMediaTypeEvent: MediaType = kEvent; pub const BusDirection = i32; pub const kInput: BusDirection = 0; @@ -22,147 +139,333 @@ pub const BusType = i32; pub const kMain: BusType = 0; pub const kAux: BusType = 1; +pub const kDefaultActive: u32 = 1 << 0; + +pub const SpeakerArrangement = u64; +pub const kEmptyArrangement: SpeakerArrangement = 0; +pub const kSpeakerL: SpeakerArrangement = 1 << 0; +pub const kSpeakerR: SpeakerArrangement = 1 << 1; +pub const kSpeakerM: SpeakerArrangement = 1 << 19; +pub const kMono: SpeakerArrangement = kSpeakerM; +pub const kStereo: SpeakerArrangement = kSpeakerL | kSpeakerR; + +pub const BusInfo = extern struct { + mediaType: MediaType = kAudio, + direction: BusDirection = kInput, + channelCount: i32 = 0, + name: [128]u16 = [_]u16{0} ** 128, + busType: BusType = kMain, + flags: u32 = 0, +}; + +pub const RoutingInfo = extern struct { + mediaType: MediaType = kAudio, + busIndex: i32 = 0, + channel: i32 = -1, +}; + +pub const IoMode = i32; +pub const kSimple: IoMode = 0; +pub const kAdvanced: IoMode = 1; +pub const kOfflineProcessing: IoMode = 2; + +// --- processing ------------------------------------------------------------ + pub const SampleRate = f64; pub const Sample32 = f32; pub const Sample64 = f64; +pub const ParamID = u32; +pub const ParamValue = f64; -pub const AudioBusBuffers = extern struct { - numChannels: u32, - silenceFlags: u64, - channelBuffers32: [*][*]Sample32 = null, -}; +pub const SymbolicSampleSizes = i32; +pub const kSample32: SymbolicSampleSizes = 0; +pub const kSample64: SymbolicSampleSizes = 1; -pub const ProcessData = extern struct { +pub const ProcessModes = i32; +pub const kRealtime: ProcessModes = 0; +pub const kPrefetch: ProcessModes = 1; +pub const kOffline: ProcessModes = 2; + +pub const ProcessSetup = extern struct { processMode: i32 = 0, - symbolicSampleSize: i32 = 0, - numSamples: i32 = 0, - numInputs: u32 = 0, - numOutputs: u32 = 0, - inputs: [*]AudioBusBuffers = null, - outputs: [*]AudioBusBuffers = null, - inputParameterChanges: ?*anyopaque = null, - outputParameterChanges: ?*anyopaque = null, - inputEvents: ?*anyopaque = null, - outputEvents: ?*anyopaque = null, - processContext: ?*ProcessContext = null, + symbolicSampleSize: i32 = kSample32, + maxSamplesPerBlock: i32 = 0, + sampleRate: SampleRate = 0, +}; + +pub const AudioBusBuffers = extern struct { + numChannels: i32 = 0, + silenceFlags: u64 = 0, + /// Union of Sample32** and Sample64**; which one is live depends on + /// ProcessData.symbolicSampleSize. + channelBuffers: ?[*][*]Sample32 = null, }; pub const ProcessContext = extern struct { state: u32 = 0, - sampleRate: SampleRate = 0, + sampleRate: f64 = 0, projectTimeSamples: i64 = 0, systemTime: i64 = 0, continousTimeSamples: i64 = 0, - tempo: f64 = 0, + projectTimeMusic: f64 = 0, barPositionMusic: f64 = 0, cycleStartMusic: f64 = 0, cycleEndMusic: f64 = 0, + tempo: f64 = 0, timeSigNumerator: i32 = 0, timeSigDenominator: i32 = 0, + chordKeyNote: u8 = 0, + chordRootNote: u8 = 0, + chordMask: i16 = 0, + smpteOffsetSubframes: i32 = 0, + framesPerSecond: u32 = 0, + frameRateFlags: u32 = 0, + samplesToNextClock: i32 = 0, }; -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, +pub const ProcessData = extern struct { + processMode: i32 = 0, + symbolicSampleSize: i32 = kSample32, + numSamples: i32 = 0, + numInputs: i32 = 0, + numOutputs: i32 = 0, + inputs: ?[*]AudioBusBuffers = null, + outputs: ?[*]AudioBusBuffers = null, + inputParameterChanges: ?*ParameterChanges = null, + outputParameterChanges: ?*ParameterChanges = null, + inputEvents: ?*anyopaque = null, + outputEvents: ?*anyopaque = null, + processContext: ?*ProcessContext = null, }; -pub const IPluginBase = extern struct { - unknown: IUnknown, - initialize: *const fn (?*IPluginBase, ?*anyopaque) callconv(.c) TResult = undefined, - terminate: *const fn (?*IPluginBase) callconv(.c) TResult = undefined, +// --- parameters ------------------------------------------------------------ + +pub const ParameterInfo = extern struct { + id: ParamID = 0, + title: [128]u16 = [_]u16{0} ** 128, + shortTitle: [128]u16 = [_]u16{0} ** 128, + units: [128]u16 = [_]u16{0} ** 128, + stepCount: i32 = 0, + defaultNormalizedValue: ParamValue = 0.0, + unitId: i32 = 0, + flags: i32 = 0, }; -pub const IComponent = extern struct { - pluginBase: IPluginBase, - getControllerClassId: *const fn (?*IComponent, ?*CUID) callconv(.c) TResult = undefined, - setIoMode: *const fn (?*IComponent, i32) callconv(.c) TResult = undefined, - getBusCount: *const fn (?*IComponent, BusDirection, u32) callconv(.c) u32 = undefined, - getBusInfo: *const fn (?*IComponent, BusDirection, u32, ?*BusInfo) callconv(.c) TResult = undefined, - getRoutingInfo: *const fn (?*IComponent, ?*RoutingInfo, ?*RoutingInfo) callconv(.c) TResult = undefined, - activateBus: *const fn (?*IComponent, BusDirection, u32, u8) callconv(.c) TResult = undefined, - setActive: *const fn (?*IComponent, u8) callconv(.c) TResult = undefined, - setState: *const fn (?*IComponent, ?*anyopaque) callconv(.c) TResult = undefined, - getState: *const fn (?*IComponent, ?*anyopaque) callconv(.c) TResult = undefined, +pub const kNoFlags: i32 = 0; +pub const kCanAutomate: i32 = 1 << 0; +pub const kIsReadOnly: i32 = 1 << 1; +pub const kIsWrapAround: i32 = 1 << 2; +pub const kIsList: i32 = 1 << 3; +pub const kIsHidden: i32 = 1 << 4; +pub const kIsProgramChange: i32 = 1 << 15; +pub const kIsBypass: i32 = 1 << 16; + +pub const kReloadComponent: i32 = 1 << 0; +pub const kIoChanged: i32 = 1 << 1; +pub const kParamValuesChanged: i32 = 1 << 2; +pub const kLatencyChanged: i32 = 1 << 3; +pub const kParamTitlesChanged: i32 = 1 << 4; + +// --- vtables --------------------------------------------------------------- +// +// `this` is typed *anyopaque throughout. Each implementation knows which of +// its embedded interface objects a given vtable belongs to and recovers the +// owner with @fieldParentPtr. + +pub const FUnknownVTable = extern struct { + queryInterface: *const fn (*anyopaque, *const TUID, *?*anyopaque) callconv(.c) TResult, + addRef: *const fn (*anyopaque) callconv(.c) u32, + release: *const fn (*anyopaque) callconv(.c) u32, }; -pub const BusInfo = extern struct { - mediaType: i32 = 0, - direction: BusDirection = 0, - channelCount: i32 = 0, - name: [128]u16 = undefined, - busType: BusType = 0, - flags: u32 = 0, +pub const PluginFactoryVTable = extern struct { + unknown: FUnknownVTable, + getFactoryInfo: *const fn (*anyopaque, *PFactoryInfo) callconv(.c) TResult, + countClasses: *const fn (*anyopaque) callconv(.c) i32, + getClassInfo: *const fn (*anyopaque, i32, *PClassInfo) callconv(.c) TResult, + createInstance: *const fn (*anyopaque, [*]const u8, [*]const u8, *?*anyopaque) callconv(.c) TResult, }; -pub const RoutingInfo = extern struct { - inputBusIndex: i32 = 0, - outputBusIndex: i32 = 0, +pub const PluginFactory2VTable = extern struct { + factory: PluginFactoryVTable, + getClassInfo2: *const fn (*anyopaque, i32, *PClassInfo2) callconv(.c) TResult, }; -pub const IAudioProcessor = extern struct { - queryInterface: *const fn (?*IAudioProcessor, *const IID, ?*[*]?*anyopaque) callconv(.c) TResult = undefined, - addRef: *const fn (?*IAudioProcessor) callconv(.c) u32 = undefined, - release: *const fn (?*IAudioProcessor) callconv(.c) u32 = undefined, - setBusArrangements: *const fn (?*IAudioProcessor, [*]i64, u32, [*]i64, u32) callconv(.c) TResult = undefined, - getBusArrangement: *const fn (?*IAudioProcessor, i32, i32, [*]i64) callconv(.c) TResult = undefined, - canProcessSampleSize: *const fn (?*IAudioProcessor, i32) callconv(.c) TResult = undefined, - getLatencySamples: *const fn (?*IAudioProcessor) callconv(.c) u32 = undefined, - setupProcessing: *const fn (?*IAudioProcessor, ?*ProcessSetup) callconv(.c) TResult = undefined, - setProcessing: *const fn (?*IAudioProcessor, u8) callconv(.c) TResult = undefined, - process: *const fn (?*IAudioProcessor, ?*ProcessData) callconv(.c) TResult = undefined, - getTailSamples: *const fn (?*IAudioProcessor) callconv(.c) u32 = undefined, +pub const PluginBaseVTable = extern struct { + unknown: FUnknownVTable, + initialize: *const fn (*anyopaque, ?*anyopaque) callconv(.c) TResult, + terminate: *const fn (*anyopaque) callconv(.c) TResult, }; -pub const ProcessSetup = extern struct { - processMode: i32 = 0, - symbolicSampleSize: i32 = 0, - maxSamplesPerBlock: i32 = 0, - sampleRate: SampleRate = 0, +pub const ComponentVTable = extern struct { + base: PluginBaseVTable, + getControllerClassId: *const fn (*anyopaque, *TUID) callconv(.c) TResult, + setIoMode: *const fn (*anyopaque, IoMode) callconv(.c) TResult, + getBusCount: *const fn (*anyopaque, MediaType, BusDirection) callconv(.c) i32, + getBusInfo: *const fn (*anyopaque, MediaType, BusDirection, i32, *BusInfo) callconv(.c) TResult, + getRoutingInfo: *const fn (*anyopaque, *RoutingInfo, *RoutingInfo) callconv(.c) TResult, + activateBus: *const fn (*anyopaque, MediaType, BusDirection, i32, u8) callconv(.c) TResult, + setActive: *const fn (*anyopaque, u8) callconv(.c) TResult, + setState: *const fn (*anyopaque, ?*BStream) callconv(.c) TResult, + getState: *const fn (*anyopaque, ?*BStream) callconv(.c) TResult, }; -pub const IEditController = extern struct { - pluginBase: IPluginBase, - setComponentState: *const fn (?*IEditController, ?*anyopaque) callconv(.c) TResult = undefined, - setState: *const fn (?*IEditController, ?*anyopaque) callconv(.c) TResult = undefined, - getState: *const fn (?*IEditController, ?*anyopaque) callconv(.c) TResult = undefined, - getParameterCount: *const fn (?*IEditController) callconv(.c) i32 = undefined, - getParameterInfo: *const fn (?*IEditController, i32, ?*ParameterInfo) callconv(.c) TResult = undefined, - getParamStringByValue: *const fn (?*IEditController, i32, f64, [*]u16) callconv(.c) TResult = undefined, - getParamValueByString: *const fn (?*IEditController, i32, [*]const u16, [*]f64) callconv(.c) TResult = undefined, - normalizedParamToPlain: *const fn (?*IEditController, i32, f64) callconv(.c) f64 = undefined, - plainParamToNormalized: *const fn (?*IEditController, i32, f64) callconv(.c) f64 = undefined, - getParamNormalized: *const fn (?*IEditController, i32) callconv(.c) f64 = undefined, - setParamNormalized: *const fn (?*IEditController, i32, f64) callconv(.c) TResult = undefined, - setComponentHandler: *const fn (?*IEditController, ?*anyopaque) callconv(.c) TResult = undefined, - createView: *const fn (?*IEditController, [*:0]const u8) callconv(.c) ?*anyopaque = undefined, +pub const AudioProcessorVTable = extern struct { + unknown: FUnknownVTable, + setBusArrangements: *const fn (*anyopaque, ?[*]SpeakerArrangement, i32, ?[*]SpeakerArrangement, i32) callconv(.c) TResult, + getBusArrangement: *const fn (*anyopaque, BusDirection, i32, *SpeakerArrangement) callconv(.c) TResult, + canProcessSampleSize: *const fn (*anyopaque, i32) callconv(.c) TResult, + getLatencySamples: *const fn (*anyopaque) callconv(.c) u32, + setupProcessing: *const fn (*anyopaque, *ProcessSetup) callconv(.c) TResult, + setProcessing: *const fn (*anyopaque, u8) callconv(.c) TResult, + process: *const fn (*anyopaque, *ProcessData) callconv(.c) TResult, + getTailSamples: *const fn (*anyopaque) callconv(.c) u32, }; -pub const ParameterInfo = extern struct { - id: u32 = 0, - title: [128]u16 = undefined, - shortTitle: [128]u16 = undefined, - units: [128]u16 = undefined, - stepCount: i32 = 0, - defaultNormalizedValue: f64 = 0.5, - unitId: i32 = 0, - flags: i32 = 0, +pub const EditControllerVTable = extern struct { + base: PluginBaseVTable, + setComponentState: *const fn (*anyopaque, ?*BStream) callconv(.c) TResult, + setState: *const fn (*anyopaque, ?*BStream) callconv(.c) TResult, + getState: *const fn (*anyopaque, ?*BStream) callconv(.c) TResult, + getParameterCount: *const fn (*anyopaque) callconv(.c) i32, + getParameterInfo: *const fn (*anyopaque, i32, *ParameterInfo) callconv(.c) TResult, + getParamStringByValue: *const fn (*anyopaque, ParamID, ParamValue, *[128]u16) callconv(.c) TResult, + getParamValueByString: *const fn (*anyopaque, ParamID, [*]const u16, *ParamValue) callconv(.c) TResult, + normalizedParamToPlain: *const fn (*anyopaque, ParamID, ParamValue) callconv(.c) ParamValue, + plainParamToNormalized: *const fn (*anyopaque, ParamID, ParamValue) callconv(.c) ParamValue, + getParamNormalized: *const fn (*anyopaque, ParamID) callconv(.c) ParamValue, + setParamNormalized: *const fn (*anyopaque, ParamID, ParamValue) callconv(.c) TResult, + setComponentHandler: *const fn (*anyopaque, ?*ComponentHandler) callconv(.c) TResult, + createView: *const fn (*anyopaque, [*:0]const u8) callconv(.c) ?*anyopaque, }; -pub const kCanAutomate: i32 = 1 << 0; -pub const kIsReadOnly: i32 = 1 << 1; -pub const kIsWrapAround: i32 = 1 << 2; -pub const kIsList: i32 = 1 << 3; -pub const kIsProgramChange: i32 = 1 << 4; -pub const kBypass: i32 = 1 << 5; +// --- host-side interfaces we call ------------------------------------------ -pub const kAudio = 0; -pub const kEvent = 1; +pub const kSeekSet: i32 = 0; +pub const kSeekCurrent: i32 = 1; +pub const kSeekEnd: i32 = 2; -pub const MediaType = i32; -pub const kMediaTypeAudio: MediaType = 0; -pub const kMediaTypeEvent: MediaType = 1; +pub const BStreamVTable = extern struct { + unknown: FUnknownVTable, + read: *const fn (*anyopaque, *anyopaque, i32, ?*i32) callconv(.c) TResult, + write: *const fn (*anyopaque, *anyopaque, i32, ?*i32) callconv(.c) TResult, + seek: *const fn (*anyopaque, i64, i32, ?*i64) callconv(.c) TResult, + tell: *const fn (*anyopaque, *i64) callconv(.c) TResult, +}; -pub const SymbolicSampleSizes = i32; -pub const kSample32: SymbolicSampleSizes = 0; -pub const kSample64: SymbolicSampleSizes = 1; +/// A host-provided byte stream. Only the vtable pointer is ours to read. +pub const BStream = extern struct { + vtbl: *const BStreamVTable, + + pub fn write(self: *BStream, bytes: []const u8) bool { + var written: i32 = 0; + const buf: *anyopaque = @constCast(@ptrCast(bytes.ptr)); + const r = self.vtbl.write(self, buf, @intCast(bytes.len), &written); + return r == kResultOk and written == @as(i32, @intCast(bytes.len)); + } + + /// Reads up to `buf.len` bytes. Returns the slice actually filled. + pub fn read(self: *BStream, buf: []u8) []u8 { + var total: usize = 0; + while (total < buf.len) { + var got: i32 = 0; + const dest: *anyopaque = @ptrCast(buf[total..].ptr); + const r = self.vtbl.read(self, dest, @intCast(buf.len - total), &got); + if (r != kResultOk or got <= 0) break; + total += @intCast(got); + } + return buf[0..total]; + } +}; + +pub const ParamValueQueueVTable = extern struct { + unknown: FUnknownVTable, + getParameterId: *const fn (*anyopaque) callconv(.c) ParamID, + getPointCount: *const fn (*anyopaque) callconv(.c) i32, + getPoint: *const fn (*anyopaque, i32, *i32, *ParamValue) callconv(.c) TResult, + addPoint: *const fn (*anyopaque, i32, ParamValue, *i32) callconv(.c) TResult, +}; + +pub const ParamValueQueue = extern struct { + vtbl: *const ParamValueQueueVTable, + + pub fn parameterId(self: *ParamValueQueue) ParamID { + return self.vtbl.getParameterId(self); + } + + pub fn pointCount(self: *ParamValueQueue) i32 { + return self.vtbl.getPointCount(self); + } + + pub fn pointValue(self: *ParamValueQueue, index: i32) ?ParamValue { + var offset: i32 = 0; + var value: ParamValue = 0; + if (self.vtbl.getPoint(self, index, &offset, &value) != kResultOk) return null; + return value; + } +}; + +pub const ParameterChangesVTable = extern struct { + unknown: FUnknownVTable, + getParameterCount: *const fn (*anyopaque) callconv(.c) i32, + getParameterData: *const fn (*anyopaque, i32) callconv(.c) ?*ParamValueQueue, + addParameterData: *const fn (*anyopaque, *const ParamID, *i32) callconv(.c) ?*ParamValueQueue, +}; + +pub const ParameterChanges = extern struct { + vtbl: *const ParameterChangesVTable, + + pub fn count(self: *ParameterChanges) i32 { + return self.vtbl.getParameterCount(self); + } + + pub fn queue(self: *ParameterChanges, index: i32) ?*ParamValueQueue { + return self.vtbl.getParameterData(self, index); + } +}; + +pub const ComponentHandlerVTable = extern struct { + unknown: FUnknownVTable, + beginEdit: *const fn (*anyopaque, ParamID) callconv(.c) TResult, + performEdit: *const fn (*anyopaque, ParamID, ParamValue) callconv(.c) TResult, + endEdit: *const fn (*anyopaque, ParamID) callconv(.c) TResult, + restartComponent: *const fn (*anyopaque, i32) callconv(.c) TResult, +}; + +pub const ComponentHandler = extern struct { + vtbl: *const ComponentHandlerVTable, + + pub fn restart(self: *ComponentHandler, flags: i32) void { + _ = self.vtbl.restartComponent(self, flags); + } +}; + +// --- string helpers -------------------------------------------------------- + +/// Copy an ASCII string into a NUL-padded fixed-size char array. The last byte +/// is always left as NUL so the host can read it as a C string. +pub fn setAscii(dest: []u8, src: []const u8) void { + @memset(dest, 0); + const n = @min(dest.len - 1, src.len); + @memcpy(dest[0..n], src[0..n]); +} + +/// Copy ASCII into a NUL-padded UTF-16 array, the String128 convention. +pub fn setUtf16(dest: []u16, src: []const u8) void { + @memset(dest, 0); + const n = @min(dest.len - 1, src.len); + for (0..n) |i| dest[i] = src[i]; +} + +comptime { + // These sizes are part of the ABI. If one drifts, a host reading the + // struct reads the wrong fields and there is no error to catch it at + // runtime. + std.debug.assert(@sizeOf(PClassInfo) == 116); + std.debug.assert(@sizeOf(PClassInfo2) == 440); + std.debug.assert(@sizeOf(PFactoryInfo) == 452); + std.debug.assert(@sizeOf(ParameterInfo) == 792); + std.debug.assert(@sizeOf(AudioBusBuffers) == 24); + std.debug.assert(@sizeOf(ProcessSetup) == 24); + std.debug.assert(@offsetOf(ProcessData, "inputs") == 24); +}