Skip to content

6/6 — user plugins: Lua files that live in the project #7

Description

@KevinGruber2001

Part 6 of #1. Depends on #4 (the registry) and #5 (the ring buffer, for reasons that matter — see below).

The requirement

A plugin belongs to a project, not to a release. Someone making a song must be able to add an effect to their project, on their own machine, without rebuilding CodaW and without waiting for a release. That rules out the compiled-in design that #4 originally proposed.

Three more things it has to be:

  • Writable by a normal person. Not Rust, not a C ABI, not "set up a toolchain first".
  • Part of the project. It lives in the project directory, gets committed with the song, and a fresh clone in five years still renders — no registry service, no install step.
  • Understandable by you. If the host is 800 lines of marshalling, it's the wrong host.

Proposed solution: a .lua file in the project

my-song/
  project.toml
  tracks/kick.toml
  plugins/
    tape_sat.lua      <- committed with the song
# tracks/kick.toml — used exactly like a built-in
[[fx]]
type  = "tape_sat"
drive = 0.6
-- plugins/tape_sat.lua
params {
  drive = { default = 0.3, min = 0, max = 1, label = "Drive" },
}

function process(block, p)
  local d = 1 + p.drive * 24
  for i = 1, #block do
    block[i] = tanh(block[i] * d)
  end
end

Save the file and hear it. No compile step, no toolchain, no restart — the watcher already reloads the project on save, and a .lua file is just another file in it.

That last property is the argument. CodaW's whole premise is your project is plain text you can edit and immediately hear. A Lua plugin is the only option that extends that promise to effects instead of carving out an exception to it.

flowchart LR
  F["plugins/tape_sat.lua"] --> S["scan project dir<br/>at load"]
  S --> P["gopher-lua state<br/>one per effect instance"]
  P --> R{{"the same registry<br/>from #4"}}
  R --> M["MixBlock()"]
  W["watcher"] -. "file saved" .-> S
Loading

Why this is safe to do in Go now (it wasn't before)

Earlier in this design I argued against a scripting language because "an interpreter's garbage collector running on the audio thread is a click in the output." The ring buffer from #5 removes that objection.

The mixer runs on an ordinary goroutine. The device callback only copies bytes out of the ring. So a Lua plugin that occasionally takes too long, or triggers a GC, eats into ring headroom instead of glitching the device. It has to be persistently too slow to be audible at all.

This is worth internalising, because it's the reason a lot of "you can't do that in a DAW" advice doesn't apply to this design.

Before you start

  • gopher-lua is pure Go, no cgo. It must stay that way — we just deleted 96,550 lines of C and are not letting any back in through the plugin system.
  • One lua.LState per effect instance. An LState is not safe for concurrent use, and per-instance state (filter memory) naturally lives in the script's own globals. Create it in New(), keep it for the effect's life.
  • Crossing the Go↔Lua boundary is the expensive part, not Lua itself. This is why the API hands over a whole block, not one sample. One call per ~1024 frames instead of 96,000 calls per second.
  • How the block is exposed decides the performance. A plain Lua table of numbers means a boxed LNumber per sample and pressure on Lua's GC. A userdata wrapping the []float32 with __index/__newindex avoids the copy but pays a metamethod call per access. Benchmark both before committing — see the first task below.
  • Sandboxing: open only the safe standard libraries. A plugin has no business with io, os, or package.

Step 0, before any of the rest: measure it

This is the one issue in the epic with a real technical risk, so the first commit is a benchmark, not a feature.

// How many effect instances can we run at 48 kHz stereo before a 1024-frame
// block takes longer than 21 ms to produce?
func BenchmarkLuaBlock(b *testing.B) { /* table vs userdata */ }

Decision point, written down now so it isn't argued about later:

Result Action
≥ 16 instances realtime ship it, done
4–16 ship it, document the ceiling, keep built-ins native
< 4 fall back — see alternatives

Offline render has no realtime constraint at all, so even a slow result still leaves Lua plugins usable for bouncing.

Draft host

// Package luafx loads .lua effects from a project's plugins/ directory and
// registers them as ordinary plugins, so the mixer can't tell the difference.
package luafx

func LoadDir(dir string, reg *plugin.Registry) error {
    files, _ := filepath.Glob(filepath.Join(dir, "*.lua"))
    for _, f := range files {
        id := strings.TrimSuffix(filepath.Base(f), ".lua")
        src, err := os.ReadFile(f)
        if err != nil {
            return err
        }
        params, err := readParams(src) // run the script once, capture params{}
        if err != nil {
            return fmt.Errorf("%s: %w", f, err)
        }
        reg.Register(plugin.Plugin{
            ID: id, Name: id, Params: params,
            New: func(p plugin.Params, rate int) (plugin.Effect, error) {
                return newLuaEffect(src, p, rate)
            },
        })
    }
    return nil
}
// newLuaEffect builds one instance: its own LState, its own globals, its own
// filter memory. Everything that can be done once is done here, so the
// returned closure only calls process().
func newLuaEffect(src []byte, p plugin.Params, rate int) (plugin.Effect, error) {
    L := lua.NewState(lua.Options{SkipOpenLibs: true})
    openSafeLibs(L)              // base + math only. No io, os, package.
    L.SetGlobal("rate", lua.LNumber(rate))
    if err := L.DoString(string(src)); err != nil {
        return nil, err
    }
    fn := L.GetGlobal("process")
    block := newBlockUserData(L) // wraps the []float32 we're handed each call

    return func(buf []float32, frames, channels int) {
        block.bind(buf)          // no copy — just repoint at the caller's slice
        L.Push(fn)
        L.Push(block.lv)
        L.Push(paramsTable)      // refreshed only when TOML params change
        if err := L.PCall(2, 0, nil); err != nil {
            // A broken plugin must never kill playback or the process.
            // Log once, then pass audio through untouched.
            reportOnce(err)
        }
    }, nil
}

Two behaviours worth designing in from the start, because they're what makes this pleasant instead of frightening:

  • A plugin that errors passes audio through and logs once. Never panic, never stop playback. Someone editing a Lua file will save a syntax error mid-playback, and the right response is silence-from-that-effect plus a message in the UI.
  • A plugin that's too slow gets reported, not tolerated silently. Tie it to the underrun counter from 4/5 — internal/audio: malgo + ring buffer, and delete 96,550 lines of C #5 so "it crackles" becomes "tape_sat is using 40% of the block budget".

Why Lua over the alternatives

vs. WebAssembly. Wasm is the technically stronger answer: sandboxed, one artifact for every OS, any source language, near-native speed. It loses on this requirement set. The author needs a toolchain (TinyGo, Rust, or Zig) before writing a line; the artifact is a binary, so the "your song is plain text" property breaks; and the host needs an ABI, linear-memory marshalling and a plugin build command — several hundred lines of the least interesting code in the project. Wasm is the documented escape hatch if the benchmark comes back bad, and because it would register into the same registry with the same Effect type, switching later doesn't change how plugins are used, only how they're written.

vs. Go's plugin package (.so via plugin.Open). No Windows support, and it requires the plugin to be built with the exact same Go version and identical dependency versions as the binary. Unusable for end users. Genuinely disqualified, not just disfavoured.

vs. native shared libraries (dlopen). Fast and simple to load, but it hands each author a per-platform build matrix, gives every user an unsandboxed binary from a stranger, and a null dereference in a plugin takes the engine down. It also requires cgo, which we just removed.

vs. a declarative DSP graph in TOML (effects built by wiring primitives, no code). Still the simplest possible thing and it satisfies the requirement perfectly — plugins would be data. Rejected because you can only combine primitives we ship, never write a new algorithm, and every new capability becomes a CodaW release. But if the Lua benchmark disappoints and wasm feels too heavy, this is the honest third option, and the primitives it needs (biquad, delay, shape) are exactly what #4 builds anyway.

Risks

Risk Mitigation
Too slow for realistic track counts Benchmark first (step 0). Ceiling documented, built-ins stay native
Lua's GC causing irregular block times Ring buffer absorbs it; collectgarbage("setstepmul") tuning if needed
A plugin blocking forever (while true do end) Instruction-count hook that aborts the call and disables the plugin
Plugin name collides with a built-in Built-ins win; log a clear warning naming the file
Scope creep into a plugin ecosystem Not now. Projects carry their own plugins/ dir. No registry, no versioning, no package manager

Done when

  • Benchmark exists and the result is recorded in this issue
  • plugins/*.lua in a project directory are discovered and registered
  • A Lua effect is usable from [[fx]] exactly like a built-in, including automation of its params
  • Declared params{} reach the mixer UI as real ranges and labels
  • Editing a .lua file while playing reloads it — no restart
  • A syntax error or runtime error passes audio through, logs once, and surfaces in the extension
  • Only safe stdlib is open; a plugin cannot read files or start processes
  • An infinite loop in a plugin is aborted, not fatal
  • Two example plugins in testdata/, written to be copied
  • Docs: "write your first effect" — a page someone can follow without knowing Go

Not in this issue

Instrument/generator plugins (needs note data), sharing or distributing plugins beyond "copy the file", wasm.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions