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 3 of #1. Depends on #3. Still offline, still nothing deleted.
Scope changed. This issue originally covered both built-in effects and the user plugin system. User-addable plugins must not require rebuilding CodaW — they're added to a project, by the person making the song. That's a different problem with a different answer, and it now lives in #7. This issue is only about the effects we ship, and the registry both kinds plug into.
Problem
Effects today are miniaudio nodes wired into a graph (internal/audio/effect.go): an EQ is three ma_*_nodes in series, "reverb" is a delay node with feedback. Consequences:
compressor is valid project data with no DSP at all — buildEffect logs "no DSP yet" and passes audio through unprocessed
master.limiter = true prints a warning and does nothing
reverb is an echo, not a reverb
Adding any effect of our own needs a custom ma_node with a C process callback
This must land before#5 (the device swap), so that when the C is deleted there is no window where CodaW has fewer effects than it does today.
Before you start
A biquad is the single most useful thing to understand in audio DSP. Five coefficients and two samples of memory give you lowpass, highpass, bandpass, peak, low-shelf and high-shelf — that's your whole EQ. Read the Audio EQ Cookbook; you only need the shelving and peaking formulas.
Filters have memory. A biquad remembers the last two input and output samples. That memory is runtime state, not project data — it must not be rebuilt when someone saves a TOML file, or the filter clicks on every keystroke. This is the one genuinely subtle thing here.
Zipper noise. If drive jumps from 0.3 to 0.6 between two blocks, that step is a click. Every control value needs smoothing. Not polish — the difference between "sounds broken" and "sounds fine".
Read internal/audio/effect.go before deleting it. The EQ band layout (low shelf → peak → high shelf) and the parameter names stay; only the implementation changes.
Proposed solution
flowchart LR
T["[[fx]]<br/>type = "eq_3band""] --> R{{"registry<br/>map[string]Plugin"}}
R --> N["New(params, rate)"]
N --> E["Effect func<br/>(closure over its state)"]
E --> M["called by MixBlock()"]
L["#7 — Lua plugins<br/>from the project dir"] -.registers into.-> R
Loading
One type carries every effect, built-in or not:
// An Effect processes one block of interleaved audio in place.typeEffectfunc(buf []float32, frames, channelsint)
// Plugin is one registered effect type.typePluginstruct {
IDstring// matches `type = "..."` in TOMLNamestringParams []Param// declared so the UI knows real ranges and validation catches typosNewfunc(pParams, rateint) (Effect, error)
}
typeParamstruct {
Key, Label, UnitstringDefault, Min, Maxfloat64Logbool// frequency-ish params want a log-feel knob
}
Keeping this type dead simple is what lets #7 plug a Lua-backed effect into the same registry without the mixer knowing the difference.
Built-ins are plain Go functions — no abstraction to pay for, and native speed where it matters most (the effects on every track of every project).
Declared params pay for themselves twice: validate can reject an unknown fx.type at load time with a file and line (today it's a runtime "skipping" log), and fxParams.ts can stop guessing knob ranges from name suffixes.
Closures give per-instance state for free — no handle table, no init/destroy lifecycle, no void *userdata.
One registry for both kinds of effect means the mixer, the UI and validation are written once.
Alternatives considered
Effect as an interface rather than a func. More conventional Go. Rejected: type Effect func(...) means writing a function and nothing else — no struct, no method set, no interface to satisfy.
Skip built-ins; make everything a Lua plugin (#7). Tempting for uniformity. Rejected: the EQ and compressor run on every track of every project and should be native, and shipping a working DAW shouldn't depend on the scripting host being finished.
Port the existing miniaudio EQ/reverb params to something new. Rejected — keep the exact param names (low_hz, room_size, …) so no existing TOML breaks.
Done when
dsp/ has biquad, 3-band EQ (same param names as today), compressor, soft limiter, Freeverb-style reverb
master.limiter = true actually limits
compressor actually compresses
plugin.Register + registry; MixBlock runs chains on tracks, buses and master
Every control value smoothed — no zipper on a TOML save mid-playback
Filter state survives a project reload (keyed by track ID + fx index, not rebuilt on every swap)
Unknown fx.type is a load-time error with file and line
Tests: EQ boost at the target frequency raises that band's energy; limiter output never exceeds threshold; silence in → silence out for every effect
Not in this issue
User-addable plugins (#7), the UI reading declared params, generator/instrument plugins.
Part 3 of #1. Depends on #3. Still offline, still nothing deleted.
Problem
Effects today are miniaudio nodes wired into a graph (
internal/audio/effect.go): an EQ is threema_*_nodes in series, "reverb" is a delay node with feedback. Consequences:compressoris valid project data with no DSP at all —buildEffectlogs "no DSP yet" and passes audio through unprocessedmaster.limiter = trueprints a warning and does nothingreverbis an echo, not a reverbma_nodewith a C process callbackThis must land before #5 (the device swap), so that when the C is deleted there is no window where CodaW has fewer effects than it does today.
Before you start
drivejumps from 0.3 to 0.6 between two blocks, that step is a click. Every control value needs smoothing. Not polish — the difference between "sounds broken" and "sounds fine".internal/audio/effect.gobefore deleting it. The EQ band layout (low shelf → peak → high shelf) and the parameter names stay; only the implementation changes.Proposed solution
flowchart LR T["[[fx]]<br/>type = "eq_3band""] --> R{{"registry<br/>map[string]Plugin"}} R --> N["New(params, rate)"] N --> E["Effect func<br/>(closure over its state)"] E --> M["called by MixBlock()"] L["#7 — Lua plugins<br/>from the project dir"] -.registers into.-> ROne type carries every effect, built-in or not:
Keeping this type dead simple is what lets #7 plug a Lua-backed effect into the same registry without the mixer knowing the difference.
A built-in, start to finish
The stateful case
Three of these per channel is the whole 3-band EQ — a satisfying amount of code for something that currently needs 200 lines of cgo.
Wiring into the mixer
Step 2 of
MixBlockfrom #3 becomes:Why this solution
validatecan reject an unknownfx.typeat load time with a file and line (today it's a runtime "skipping" log), andfxParams.tscan stop guessing knob ranges from name suffixes.void *userdata.Alternatives considered
Effectas an interface rather than a func. More conventional Go. Rejected:type Effect func(...)means writing a function and nothing else — no struct, no method set, no interface to satisfy.Skip built-ins; make everything a Lua plugin (#7). Tempting for uniformity. Rejected: the EQ and compressor run on every track of every project and should be native, and shipping a working DAW shouldn't depend on the scripting host being finished.
Port the existing miniaudio EQ/reverb params to something new. Rejected — keep the exact param names (
low_hz,room_size, …) so no existing TOML breaks.Done when
dsp/has biquad, 3-band EQ (same param names as today), compressor, soft limiter, Freeverb-style reverbmaster.limiter = trueactually limitscompressoractually compressesplugin.Register+ registry;MixBlockruns chains on tracks, buses and masterfx.typeis a load-time error with file and lineNot in this issue
User-addable plugins (#7), the UI reading declared params, generator/instrument plugins.