Building a Go Audio Plugin Framework Instead of “Just a Plugin”

I didn’t want this repo to become a one-off effect with a pile of host glue welded around it. The goal from the start was to build a small framework for audio plugins in Go: one place for the runtime contract, one place for the wrapper logic, and then a clean, repeatable way to add new plugins without re-solving the same VST3 and Audio Unit problems every time.

That decision shaped almost everything in the codebase.

The repo is organized around a pretty strict separation of concerns. The shared runtime lives in internal/framework/. The host wrappers live in internal/wrappers/. Each actual plugin gets its own self-contained package under plugins/<slug>/, where plugin.go holds the descriptor and parameter definitions, processor.go adapts into the framework’s processor interface, and the effect’s DSP stays private to that plugin. That structure matters more than it might look at first glance. It means the framework is the product boundary, not the example reverb or the guitar effect.

The problem I was trying to solve

The hard part of plugin development is usually not the DSP. The hard part is everything around it: lifecycle, format differences, metadata, parameter handling, UI, packaging, and not letting all of that leak back into the signal path.

I wanted a setup where adding a new plugin felt like this:

  1. Define metadata and parameters.
  2. Implement a processor with a tiny interface.
  3. Keep the DSP isolated.
  4. Let the framework and wrappers do the repetitive work.

That’s why the core contract is intentionally small. The framework’s Processor interface is basically:

  • Reset
  • SetSampleRate
  • SetBlockSize
  • SetParameter
  • GetParameter
  • ProcessSample(left, right)

That’s the center of the whole design. If a plugin can satisfy that interface, the runtime can host it and the wrappers can expose it.

Why I didn’t use JUCE

A big part of this project was deciding what I did not want the framework to become.

I could have built this on top of JUCE, and to be clear, JUCE solves a real problem for a lot of teams. But it solves a different problem than the one I wanted to solve here.

My goal was not to get a big all-in-one plugin application framework with a full UI toolkit attached. My goal was to build a smaller, more direct system where the plugin is mostly code, the runtime contract is obvious, and the development flow stays close to the actual audio logic. I wanted the plugin to feel programmatic first, not like I was stepping into a large framework and then negotiating with its architecture.

That mattered to me for a few reasons.

First, I wanted the core of the plugin to be simple enough to reason about end to end. With this setup, the path from descriptor to processor to wrapper is very explicit. I can trace how a parameter gets defined, how it reaches the host, and how it affects the DSP without crossing a huge amount of framework surface area. That kind of clarity is harder to keep once the stack gets much larger.

Second, I did not want UI architecture to become the center of the project. A lot of plugin frameworks naturally pull you toward building the product around views, widgets, editors, and app-style structure. That can be great when the UI is the product. But for this framework, I wanted UI to be a thin layer over a code-defined model. The editor system here reflects that: the plugin declares its layout and theme in Go, and the native wrappers render it. That keeps the UI intentionally lightweight and prevents the project from turning into a heavyweight GUI codebase just to ship an effect.

Third, I wanted a development process that is easy to test. That was a major motivation. The DSP lives in normal Go packages, beside normal Go tests, with as little host-specific complexity mixed in as possible. I can test behavior close to the signal-processing code instead of treating the plugin as a mostly opaque binary artifact. That makes iteration faster and gives me more confidence that I’m testing the actual logic rather than just clicking around in a host and hoping I notice regressions.

That also ties into the broader workflow goal: I wanted adding a new plugin to feel like creating a small package with a descriptor, a processor, and a DSP core, not spinning up a large app scaffold. The simpler the shape, the easier it is to repeat, automate, and trust.

So the decision not to use JUCE was really a decision about scope and philosophy. I wanted a framework that stayed closer to the metal, closer to the code, and closer to the testable parts of plugin development. Not because larger frameworks are wrong, but because for this project they would have pulled the center of gravity away from the thing I actually wanted to build.

The runtime is deliberately boring

The Runtime type in internal/framework/framework.go is probably the most important piece, and also the least flashy. It owns:

  • the plugin descriptor
  • the factory for constructing processors
  • an instance registry keyed by handles
  • the helper functions the wrappers need for parameters and strings

When a wrapper asks for a new instance, the runtime creates a processor, applies default parameter values from the descriptor, stores it in a registry, and returns a handle. From there the host-facing layers only need to call into exported functions like “set parameter,” “set sample rate,” or “process stereo.”

That sounds obvious, but it is the thing that keeps the architecture from collapsing. The wrapper does not know what a reverb is. The DSP does not know what VST3 or AU are. The runtime is the membrane between them.

The actual processing path is also intentionally simple. On the VST3 side, the C wrapper receives the host buffers, applies the latest parameter changes for the block, and then calls into a generated Go export that lands in Runtime.ProcessStereo. That method locks the instance once, iterates sample-by-sample, and writes the output buffers directly. No logging, no extra abstractions in the hot path, and no host-specific behavior inside the DSP.

That was one of the main goals: keep the realtime path boring enough that I can trust it.

Why code generation ended up being the right move

One of the more important decisions in this framework is that I generate plugin-specific bridge code at build time instead of hand-authoring wrapper glue for every plugin.

build.sh does a lot more than compile. It reads each plugin’s plugin.env, resolves IDs, bundle names, formats, and export prefixes, then emits generated Go and C header files into build/_generated/....

For VST3, it generates a Go package that exports functions like:

  • Create
  • Destroy
  • SetParameter
  • ProcessStereo
  • parameter metadata accessors
  • editor metadata accessors

Then it generates a vst3_config.h header that maps the generic wrapper to those plugin-specific symbols with macros.

That means the VST3 wrapper in internal/wrappers/vst3/plugin.c is shared. It doesn’t need to know anything about ExampleReverb or Guitarizer. It just calls macros like GO_PLUGIN_VST3_CREATE, GO_PLUGIN_VST3_SET_PARAMETER, and GO_PLUGIN_VST3_PROCESS_STEREO, and the generated config header binds those names to the active plugin.

That’s a huge win for maintainability. I only have one real VST3 wrapper to reason about, and each plugin gets a generated boundary layer that adapts the shared runtime into the wrapper’s expectations.

The same basic idea shows up on the AU side too. There’s a shared Audio Unit wrapper and a generated bridge header so the format-specific code stays generic while the plugin-specific symbols are injected at build time.

This was one of the points where the project stopped feeling like “some scripts around a plugin” and started feeling like an actual framework.

The descriptor became the single source of truth

Another design choice I’m glad I made was putting plugin metadata into a Descriptor instead of scattering it across wrappers.

The descriptor carries:

  • plugin name, vendor, product
  • unique ID and version
  • channel counts
  • category
  • parameter definitions
  • optional editor definition

That gives the framework one place to pull from when it needs defaults, parameter labels, formatted value strings, or editor data. It also makes the plugins feel declarative. plugins/examplereverb/plugin.go is a good example of that. It reads less like wrapper code and more like a manifest for the plugin.

I especially like that parameter formatting lives with the parameter itself. Parameter can optionally provide a custom Display function, which means things like Pickup in Guitarizer can render as Neck, Middle, or Bridge without teaching the wrapper anything domain-specific.

That is exactly the kind of small abstraction I wanted here: simple, local, and useful.

The editor system is one of the more interesting parts

A feature I’m especially happy with is the editor model.

Instead of making every plugin build a native UI separately, the framework defines an EditorDescriptor in Go. A plugin can specify:

  • window size
  • theme colors
  • fonts
  • header text
  • sections
  • control layout
  • control kinds like sliders or switches

The framework resolves that into an EditorPayload, serializes it to JSON, and exports both the full payload and some convenience accessors. Then the native VST3 view implementations on macOS and Windows consume that same data.

That means the UI definition is backend-neutral, but the rendering is native.

On macOS, the Objective-C layer reads the JSON and builds Cocoa controls. On Windows, the C layer reads the same editor data and constructs the Win32 UI. The plugin author only defines the editor once in Go.

That was very intentional. I wanted custom, branded plugin editors without turning every plugin into a UI portability project.

There is also a fallback path: if a plugin does not provide a custom editor descriptor, the framework can generate a default control layout from the parameter list. That’s a nice middle ground. I can keep moving quickly with generic UIs, then opt into a more designed presentation when a plugin deserves it.

Right now, that richer editor path is implemented for VST3 on macOS and Windows. AU still leans on the host’s generic parameter UI, which is fine for now and honest about the current scope.

The plugins themselves are small on purpose

The example plugins are there to prove the framework shape, not to define it.

ExampleReverb is a good reference because the processor adapter is basically nothing. It just forwards lifecycle and parameter calls into a private Reverb type. The DSP itself is a lightweight Schroeder/Freeverb-style design: comb filters, allpass filters, stereo width handling, and sample-rate-dependent buffer sizing.

That’s exactly the balance I wanted. The plugin package owns the sound. The framework owns the contract.

Guitarizer pushes the same architecture in a more character-heavy direction, and that’s useful too. If the framework only worked cleanly for one polite reference reverb, it wouldn’t really be a framework.

The build and release flow is intentionally explicit

I did not want a magical build system where plugin identity is implicit and everything is global. The scripts require explicit plugin selection, explicit format selection, and per-plugin config in plugin.env.

That gives each plugin its own version values, bundle IDs, names, format flags, and export prefixes. It also makes packaging more honest. The script knows whether a plugin should emit VST3, AU, or both, and it generates artifacts accordingly.

There’s also some practical detail here that matters in real plugin work:

  • debug variants get distinct IDs and bundle names
  • outputs are grouped by target OS and architecture
  • macOS and Windows packaging are both part of the same workflow
  • scaffolding for a new plugin follows the exact repo conventions

That last part matters more than it sounds. A framework only helps if adding the next plugin is easier than improvising.

What still isn’t there

This framework is opinionated, but it is not pretending to be finished.

The current limitations are pretty clear:

  • stereo only
  • no state chunk serialization yet
  • no MIDI support yet
  • no parameter smoothing helpers yet
  • no offline-render-specific behavior yet
  • AU uses generic host UI rather than the custom editor system

I actually like being explicit about that. It keeps the framework honest. The current code solves one clean slice of the problem well: shared Go DSP, shared wrappers, generated host bridges, reusable metadata, and custom VST3 editors without duplicating logic.

That is enough of a foundation to build on.

What I think the framework really is

At a high level, this repo is me trying to reduce plugin development to the parts I actually want to think about.

I want to think about DSP topology, parameter design, sonic behavior, and presentation. I do not want to rewrite host plumbing for every plugin, and I definitely do not want the host plumbing slowly dictating the shape of the DSP.

So the framework exists to keep those worlds separate.

If I had to summarize the architecture in one sentence, it would be this: define the plugin once in Go, generate the boundary code automatically, and keep the host wrappers generic enough that new effects mostly feel like adding sound, not infrastructure.

If I had to summarize the philosophy behind it, it would be this: keep the framework programmatic, keep the development loop simple, and keep the parts that matter testable. That’s really why this exists. Not to compete with larger plugin frameworks, but to create a smaller system that stays close to the code, close to the sound, and close to the way I actually want to build.