You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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?funcBenchmarkLuaBlock(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
funcLoadDir(dirstring, reg*plugin.Registry) error {
files, _:=filepath.Glob(filepath.Join(dir, "*.lua"))
for_, f:=rangefiles {
id:=strings.TrimSuffix(filepath.Base(f), ".lua")
src, err:=os.ReadFile(f)
iferr!=nil {
returnerr
}
params, err:=readParams(src) // run the script once, capture params{}iferr!=nil {
returnfmt.Errorf("%s: %w", f, err)
}
reg.Register(plugin.Plugin{
ID: id, Name: id, Params: params,
New: func(p plugin.Params, rateint) (plugin.Effect, error) {
returnnewLuaEffect(src, p, rate)
},
})
}
returnnil
}
// 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().funcnewLuaEffect(src []byte, p plugin.Params, rateint) (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))
iferr:=L.DoString(string(src)); err!=nil {
returnnil, err
}
fn:=L.GetGlobal("process")
block:=newBlockUserData(L) // wraps the []float32 we're handed each callreturnfunc(buf []float32, frames, channelsint) {
block.bind(buf) // no copy — just repoint at the caller's sliceL.Push(fn)
L.Push(block.lv)
L.Push(paramsTable) // refreshed only when TOML params changeiferr:=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.
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.
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:
Proposed solution: a
.luafile in the projectSave the file and hear it. No compile step, no toolchain, no restart — the watcher already reloads the project on save, and a
.luafile 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" .-> SWhy 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
lua.LStateper 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 inNew(), keep it for the effect's life.LNumberper sample and pressure on Lua's GC. A userdata wrapping the[]float32with__index/__newindexavoids the copy but pays a metamethod call per access. Benchmark both before committing — see the first task below.io,os, orpackage.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.
Decision point, written down now so it isn't argued about later:
Offline render has no realtime constraint at all, so even a slow result still leaves Lua plugins usable for bouncing.
Draft host
Two behaviours worth designing in from the start, because they're what makes this pleasant instead of frightening:
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 buildcommand — 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 sameEffecttype, switching later doesn't change how plugins are used, only how they're written.vs. Go's
pluginpackage (.soviaplugin.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
collectgarbage("setstepmul")tuning if neededwhile true do end)plugins/dir. No registry, no versioning, no package managerDone when
plugins/*.luain a project directory are discovered and registered[[fx]]exactly like a built-in, including automation of its paramsparams{}reach the mixer UI as real ranges and labels.luafile while playing reloads it — no restarttestdata/, written to be copiedNot in this issue
Instrument/generator plugins (needs note data), sharing or distributing plugins beyond "copy the file", wasm.