Skip to content

3/6 — internal/dsp: built-in effects as Go functions #4

Description

@KevinGruber2001

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 = &quot;eq_3band&quot;"] --> 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.
type Effect func(buf []float32, frames, channels int)

// Plugin is one registered effect type.
type Plugin struct {
    ID     string  // matches `type = "..."` in TOML
    Name   string
    Params []Param // declared so the UI knows real ranges and validation catches typos
    New    func(p Params, rate int) (Effect, error)
}

type Param struct {
    Key, Label, Unit string
    Default, Min, Max float64
    Log bool // 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.

A built-in, start to finish

// dsp/saturate.go
func init() {
    plugin.Register(plugin.Plugin{
        ID:   "saturate",
        Name: "Saturator",
        Params: []plugin.Param{
            {Key: "drive", Label: "Drive", Default: 0.3, Min: 0, Max: 1},
        },
        New: func(p plugin.Params, rate int) (plugin.Effect, error) {
            drive := newSmooth(p.Get("drive"), rate) // de-zipper
            return func(buf []float32, frames, channels int) {
                d := 1 + drive.next()*24 // once per block, not per sample
                for i := range buf {
                    buf[i] = tanh(buf[i] * float32(d))
                }
            }, nil
        },
    })
}

The stateful case

// dsp/biquad.go
type biquad struct {
    b0, b1, b2, a1, a2 float32
    x1, x2, y1, y2     float32 // the memory. One per channel.
}

func (f *biquad) process(x float32) float32 {
    y := f.b0*x + f.b1*f.x1 + f.b2*f.x2 - f.a1*f.y1 - f.a2*f.y2
    f.x2, f.x1 = f.x1, x
    f.y2, f.y1 = f.y1, y
    return y
}

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 MixBlock from #3 becomes:

for _, fx := range m.trackFX[ti] {
    fx(m.trackBuf, frames, m.chans)
}

Why this solution

  • 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.

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