# Salat Engine — Technical Reference

Companion to `salat-engine-blueprint.md` (historical bootstrap notes live in `archive/step0-instruction.md`).
This document provides the implementation-level knowledge a coding agent needs.

---

## 1. MoonBit Idioms for Real-Time Audio

### 1.1 The Golden Rule

**In any function called at audio rate (≈375 times/sec for 128-sample blocks, or 48000 times/sec for per-sample), never allocate heap objects.**

According to MoonBit's FFI/runtime documentation, the Wasm and C backends use
compiler-optimized reference counting, while the Wasm GC and JavaScript
backends reuse the host runtime's garbage collector. Either way, allocation in
audio-rate code risks latency spikes and should be treated as unsafe.

### 1.2 Safe Types (No Allocation)

```moonbit
// SAFE — these are value types, live on stack or in registers
let x : Int = 42
let y : Double = 3.14
let z : Bool = true
let b : Byte = b'\x00'

// SAFE — FixedArray is pre-allocated, fixed-length
// Create ONCE outside the hot path, reuse forever
let buffer : FixedArray[Double] = FixedArray::make(128, 0.0)

// SAFE — mutable struct fields (struct is pre-allocated)
struct OscState {
  mut phase : Double
  mut frequency : Double
}
```

### 1.3 Dangerous Patterns (Allocate — Avoid in Hot Path)

```moonbit
// DANGEROUS — Array is dynamically sized, allocates on resize
let a : Array[Double] = []    // allocation
a.push(1.0)                   // possible reallocation

// DANGEROUS — String creation allocates
let s = "hello"               // allocation
let t = "\{x}"                // allocation (interpolation)

// DANGEROUS / SUSPECT — creating fresh composite values
// Depending on representation and optimization, these may allocate or box.
// Treat them as unsafe in the hot path unless you have checked the generated code.
let p = { x: 1.0, y: 2.0 }
let e = Some(42)

// DANGEROUS — closures capture environment → allocate
let f = fn(x) { x + offset }  // captures `offset` → heap allocation

// DANGEROUS — map/filter/collect create new collections
buffer.map(fn(x) { x * 2.0 }) // allocates new array

// DANGEROUS — println / string formatting
println("debug: \{value}")     // allocates String, calls FFI
```

### 1.4 Audio-Safe Patterns

```moonbit
// Pattern: Shared execution context — sample rate + block size only.
// Constructed once on the main thread, passed by value to every per-block
// process() call on the audio thread. Labelled args avoid argument-order
// confusion between the two Doubles a positional API would expose.
pub struct DspContext {
  sample_rate : Double
  block_size : Int

  fn new(sample_rate~ : Double, block_size~ : Int) -> DspContext
}

// Pattern: Pre-allocate buffers at initialization time, never in the hot path.
// In moondsp these live as AudioBuffer wrappers around FixedArray[Double];
// the audio thread only reads/writes existing slots.
let scratch : FixedArray[Double] = FixedArray::make(128, 0.0)

// Pattern: In-place buffer operations (no allocation)
fn apply_gain(buf : FixedArray[Double], gain : Double) -> Unit {
  for i in 0..<buf.length() {
    buf[i] = buf[i] * gain
  }
}

// Pattern: Mutable state in struct fields
struct Oscillator {
  mut phase : Double
}

// This function allocates NOTHING — only reads/writes mut fields and
// AudioBuffer slots. Labels name all call-site values whose position would
// otherwise be easy to swap.
pub fn Oscillator::process(
  self : Oscillator,
  context~ : DspContext,
  output~ : AudioBuffer,
  freq_hz~ : Double,
) -> Unit {
  let phase_inc = freq_hz / context.sample_rate()
  let two_pi = 6.283185307179586
  for i in 0..<output.length() {
    output[i] = @math.sin(self.phase * two_pi)
    self.phase = self.phase + phase_inc
    if self.phase >= 1.0 {
      self.phase = self.phase - 1.0
    }
  }
}
```

### 1.5 ReadOnlyArray for Lookup Tables

MoonBit's `ReadOnlyArray` is statically initialized on C/LLVM/Wasmlinear backends. Ideal for wavetables, filter coefficient tables, and MIDI-to-frequency mappings.

```moonbit
// Statically initialized — no runtime allocation
let MIDI_FREQ : ReadOnlyArray[Double] = [
  8.1758,    // MIDI 0 (C-1)
  8.6620,    // MIDI 1
  9.1770,    // MIDI 2
  // ... 128 entries
  12543.854, // MIDI 127 (G9)
]

// Wavetable (256 samples of one cycle)
let SINE_TABLE : ReadOnlyArray[Double] = [
  // ... 256 pre-computed sin values
]
```

### 1.6 Backend-Specific Code

Use `#cfg` for FFI differences between backends:

```moonbit
#cfg(target="wasm-gc")
fn get_time() -> Double {
  // wasm-gc: call JS performance.now()
  js_performance_now()
}

#cfg(target="js")
fn get_time() -> Double {
  js_performance_now()
}

#cfg(target="native")
fn get_time() -> Double {
  c_clock_gettime()
}
```

### 1.7 MoonBit-Specific Gotchas

- **`@math.sin` / `@math.cos`**: Available in the standard library. Use these, don't implement your own.
- **No `fmod`**: MoonBit doesn't have a float modulus operator. Use `if phase >= 1.0 { phase = phase - 1.0 }` for phase wrapping (sufficient when increment < 1.0). For general modulus: `x - @math.floor(x / y) * y`.
- **`FixedArray` vs `Array`**: `FixedArray` is fixed-length (like C arrays). `Array` is dynamic (like `Vec`). Always use `FixedArray` for audio buffers.
- **Integer division**: `10 / 3 = 3` (integer division). Use `10.0 / 3.0` for float.
- **No implicit numeric conversion**: `let x : Double = 42` works, but in some contexts you need `42.0` explicitly.

---

## 2. DSP Algorithm Cookbook

All algorithms assume:
- `sample_rate`: 48000.0 Hz
- `block_size`: 128 samples (WebAudio render quantum)
- All state is `mut` fields on a struct
- All processing is in-place on `FixedArray[Double]`

### 2.1 Oscillators

#### Phase Accumulator (Core Technique)

Every oscillator uses the same principle: a phase variable that increments by `freq / sample_rate` each sample, wrapping at 1.0.

```
phase += freq / sample_rate
if phase >= 1.0: phase -= 1.0
```

The waveform is a function of `phase ∈ [0, 1)`:

| Waveform | Formula | Range |
|----------|---------|-------|
| Sine | `sin(phase * 2π)` | [-1, 1] |
| Saw (naive) | `2 * phase - 1` | [-1, 1] |
| Square (naive) | `if phase < 0.5 then 1 else -1` | {-1, 1} |
| Triangle | `4 * phase - 1 if phase < 0.5 else 3 - 4 * phase` | [-1, 1] |
| Pulse | `if phase < pulse_width then 1 else -1` | {-1, 1} |

**Naive waveforms produce aliasing** (audible artifacts above ~5kHz). For production:
- Use **PolyBLEP** (polynomial bandlimited step) for saw/square — adds a small correction near discontinuities
- Or use **wavetable** synthesis — pre-compute one cycle at multiple sample rates

For the prototype, naive waveforms are fine. Add PolyBLEP in Phase 1 if aliasing is audible.

#### PolyBLEP Correction (Optional Enhancement)

```
fn poly_blep(t : Double, dt : Double) -> Double {
  // t = phase, dt = freq / sample_rate
  if t < dt {
    let t = t / dt
    2.0 * t - t * t - 1.0
  } else if t > 1.0 - dt {
    let t = (t - 1.0) / dt
    t * t + 2.0 * t + 1.0
  } else {
    0.0
  }
}

// Saw with PolyBLEP:
// output = (2 * phase - 1) - poly_blep(phase, phase_inc)

// Helper because MoonBit has no float modulus operator:
fn wrap01(x : Double) -> Double {
  x - @math.floor(x)
}

// Square with PolyBLEP:
// output = (if phase < 0.5 then 1 else -1)
//        - poly_blep(phase, phase_inc)
//        + poly_blep(wrap01(phase + 0.5), phase_inc)
```

### 2.2 Biquad Filter (Robert Bristow-Johnson's Audio EQ Cookbook)

The biquad is the workhorse of audio DSP. One filter handles LPF, HPF, BPF, notch, peaking EQ, low/high shelf — only the coefficient calculation differs.

#### Transfer Function

```
H(z) = (b0 + b1*z⁻¹ + b2*z⁻²) / (a0 + a1*z⁻¹ + a2*z⁻²)
```

Normalize by a0 (divide all coefficients by a0) so the denominator leading coefficient is 1.

#### Direct Form II Transposed (Recommended)

```
y[n] = b0*x[n] + z1
z1   = b1*x[n] - a1*y[n] + z2
z2   = b2*x[n] - a2*y[n]
```

State: `z1`, `z2` (two Doubles). This form has better numerical stability than Direct Form I.

#### Coefficient Calculation

Common intermediate values:
```
w0    = 2π * cutoff_freq / sample_rate
alpha = sin(w0) / (2 * Q)
cos_w0 = cos(w0)
```

**Low-Pass Filter (LPF):**
```
b0 = (1 - cos_w0) / 2
b1 = 1 - cos_w0
b2 = (1 - cos_w0) / 2
a0 = 1 + alpha
a1 = -2 * cos_w0
a2 = 1 - alpha
```

**High-Pass Filter (HPF):**
```
b0 = (1 + cos_w0) / 2
b1 = -(1 + cos_w0)
b2 = (1 + cos_w0) / 2
a0 = 1 + alpha
a1 = -2 * cos_w0
a2 = 1 - alpha
```

**Band-Pass Filter (BPF, constant skirt gain):**
```
b0 = alpha
b1 = 0
b2 = -alpha
a0 = 1 + alpha
a1 = -2 * cos_w0
a2 = 1 - alpha
```

After computing, normalize: `b0/=a0, b1/=a0, b2/=a0, a1/=a0, a2/=a0`.

**Important**: Recalculate coefficients only when cutoff or Q changes, not every sample. Store as `mut` fields and recompute in `set_param()`.

#### Q (Resonance) Values

- `Q = 0.707` (1/√2): Butterworth (maximally flat, no resonance)
- `Q = 1.0`: Slight resonance
- `Q = 10.0`: Strong resonance (careful — can blow up levels)
- `Q` must be positive because the RBJ formulas divide by `Q`
- Smaller `Q` means a broader, less resonant response; use a practical lower
  bound in UI code (for example `0.1`) to avoid degenerate parameter values

### 2.3 ADSR Envelope

Linear ADSR with four stages:

```
         1.0  ──────┐
              /      \
             /        \  sustain_level
            /          ──────────┐
           /                      \
     0.0 ─┘                       └─── 0.0
         │A│  D  │    S    │  R  │

     gate ON ─────────────── gate OFF
```

Per-sample computation (times in **milliseconds**):
```
match stage:
  Attack:
    level += 1000.0 / (attack_ms * sample_rate)
    if level >= 1.0: level = 1.0, stage = Decay
  Decay:
    level -= (1.0 - sustain) * 1000.0 / (decay_ms * sample_rate)
    if level <= sustain: level = sustain, stage = Sustain
  Sustain:
    level = sustain  (no change)
  Release:
    level -= level_at_release * 1000.0 / (release_ms * sample_rate)
    if level <= 0.0: level = 0.0, stage = Idle
  Idle:
    level = 0.0
```

**Gotcha**: On `note_off`, store `level_at_release = current_level` so release starts from wherever the envelope actually is (it may not have reached sustain yet).

**Enhancement**: Exponential curves sound more natural than linear:
```
// Exponential attack: level = 1 - e^(-t/τ)
// Approximate with: level += (target - level) * coeff
// where coeff = 1 - e^(-1 / (time * sample_rate))
```

### 2.4 Delay Line

A circular buffer with a read pointer trailing the write pointer.

```moonbit
struct DelayLine {
  buffer : FixedArray[Double]  // length = max_delay_samples
  mut write_pos : Int
  delay_samples : Int
}

fn DelayLine::new(max_delay_samples : Int) -> DelayLine {
  {
    buffer: FixedArray::make(max_delay_samples, 0.0),
    write_pos: 0,
    delay_samples: max_delay_samples,
  }
}

fn DelayLine::process(self : DelayLine, input : Double) -> Double {
  // Write input
  self.buffer[self.write_pos] = input
  // Read from delay_samples ago
  let read_pos = self.write_pos - self.delay_samples
  let read_pos = if read_pos < 0 {
    read_pos + self.buffer.length()
  } else {
    read_pos
  }
  let output = self.buffer[read_pos]
  // Advance write pointer
  self.write_pos = self.write_pos + 1
  if self.write_pos >= self.buffer.length() {
    self.write_pos = 0
  }
  output
}
```

For fractional delay (sub-sample precision), use linear interpolation between adjacent samples.

### 2.5 Parameter Smoothing (One-Pole Filter)

Prevents clicks/pops when parameters change abruptly.

```moonbit
struct ParamSmoother {
  mut current : Double
  mut target : Double
  coeff : Double  // smoothing coefficient
}

fn ParamSmoother::new(initial : Double, smoothing_ms : Double, sample_rate : Double) -> ParamSmoother {
  {
    current: initial,
    target: initial,
    // coeff = e^(-1 / (smoothing_time_in_samples))
    // Typical smoothing_ms = 5-20ms
    coeff: @math.exp(-1000.0 / (smoothing_ms * sample_rate)),
  }
}

// Call once per sample in the audio loop
fn ParamSmoother::tick(self : ParamSmoother) -> Double {
  self.current = self.target + self.coeff * (self.current - self.target)
  self.current
}

// Call from main thread (via postMessage handler)
fn ParamSmoother::set(self : ParamSmoother, value : Double) -> Unit {
  self.target = value
}
```

### 2.6 Noise

```moonbit
// White noise: uniform random in [-1, 1]
// MoonBit has @random, but it may allocate.
// For audio-safe noise, use a simple LCG or xorshift:

struct NoiseGen {
  mut state : UInt  // xorshift state, must be nonzero
}

fn NoiseGen::next(self : NoiseGen) -> Double {
  // xorshift32
  let mut x = self.state
  x = x.lxor(x.lsl(13))
  x = x.lxor(x.lsr(17))
  x = x.lxor(x.lsl(5))
  self.state = x
  // Convert to [-1.0, 1.0]
  x.to_double() / 2147483648.0 - 1.0
}
```

### 2.7 Mix and Gain

Trivial but important to get right:

```
// Gain: output[i] = input[i] * gain_value
// Gain (envelope): output[i] = input[i] * envelope[i] * gain_value
//   when input1 >= 0, Gain multiplies by a second buffer (e.g. ADSR output)
// Pan (equal-power): left = input * cos(pan * π/4), right = input * sin(pan * π/4)
//   where pan ∈ [-1, 1], center = 0
// Mix: output[i] = sum(inputs[j][i]) — may need scaling by 1/sqrt(N) to prevent clipping
```

In the current Phase 1 implementation these are split into separate primitives:
`gain.mbt`, `mix.mbt`, `clip.mbt`, and `pan.mbt`, all built around
`DspContext` plus `AudioBuffer`.

---

## 3. Graph Compilation Strategy

Lessons distilled from kabelsalat and noisecraft analysis.

### 3.1 The Pipeline

```
User DSL code
     │
     ▼
Node (tree structure, type + ins[])
     │ flatten()
     ▼
FlatNode[] (array, ins are indices)
     │ topoSort()
     ▼
FlatNode[] (sorted: dependencies before dependents)
     │ compile()
     ▼
Executable form (interpreter loop or generated code)
```

In the concrete library API, the pipeline is
`Array[DspNode] → CompiledTemplate::analyze → CompiledTemplate →
CompiledDsp::compile → CompiledDsp`. `CompiledTemplate` is the single
runtime exchange type between authoring and compile. See ADR-0010 for
the contract.

### 3.2 Flatten

Convert the recursive tree into a flat array where `ins` are integer indices:

```
// Before (tree):
{ type: Sine, ins: [{ type: Num, value: 200 }] }

// After (flat array):
[
  { type: Num, value: 200, ins: [] },      // index 0
  { type: Sine, ins: [0] },                // index 1
]
```

### 3.3 Topological Sort

Kahn's algorithm (BFS-based) is simplest:

```
1. Compute in-degree for each node
2. Enqueue all nodes with in-degree 0
3. While queue non-empty:
   a. Dequeue node, add to sorted output
   b. For each node that depends on it, decrement in-degree
   c. If in-degree reaches 0, enqueue it
4. If sorted.length != total nodes → cycle detected
```

After sorting, every node's inputs appear before the node itself in the array.

### 3.4 Cycle Detection and Feedback

Cycles are intentional in DSP (feedback delay, flangers, etc.).

kabelsalat's approach: detect back-edges during topological sort. For each back-edge, insert a **z⁻¹ node** (one-sample delay). The previous sample's output is stored and used as input for the current sample.

```
// During topo sort, if a node references a later (not-yet-processed) node:
// 1. Mark it as a feedback edge
// 2. Insert a FeedbackRead node at the input point
// 3. Insert a FeedbackWrite node at the output point
// 4. FeedbackRead returns last sample's value from a shared register
// 5. FeedbackWrite stores current sample's value to that register
```

Current status note: the implementation now uses a self-register feedback model
in both compiled mono graphs and terminal-stereo graphs. `CompiledDsp` and
`CompiledStereoDsp` detect back-edges and resolve them as zero-initialized
implicit `z^-1` reads during runtime using self-registers rather than
linked-list infrastructure. Stereo and mixed-shape feedback are now accepted.
Supported shapes include direct self-feedback, multiple simultaneous
back-edges, and loops that lift through `Pan` into a terminal stereo suffix.
Node-local recirculation on `Delay` and `StereoDelay` remains a separate
feature.

### 3.5 Compilation Approaches

#### Approach A: Interpreter (Start Here)

```moonbit
// A flat array of instructions, executed in order per sample
fn run_sample(
  nodes : FixedArray[FlatNode],
  slots : FixedArray[Double],       // output value of each node
  state : FixedArray[ProcessorState], // persistent state per node
  sample_rate : Double,
) -> Double {
  for i = 0; i < nodes.length(); i = i + 1 {
    slots[i] = match nodes[i].node_type {
      Num(v) => v
      Sine => {
        let freq = slots[nodes[i].ins[0]]
        // update phase in state[i], return sin
        process_sine(state[i], freq, sample_rate)
      }
      Mul => slots[nodes[i].ins[0]] * slots[nodes[i].ins[1]]
      Add => slots[nodes[i].ins[0]] + slots[nodes[i].ins[1]]
      LPF => {
        let input = slots[nodes[i].ins[0]]
        let cutoff = slots[nodes[i].ins[1]]
        let q = slots[nodes[i].ins[2]]
        process_biquad(state[i], input, cutoff, q, sample_rate)
      }
      // ... etc
    }
  }
  slots[nodes.length() - 1]  // last node = output
}
```

Advantages: simple, easy to debug, easy to add new node types.
Disadvantage: match dispatch per node per sample. For 50 nodes at 48kHz = 2.4M dispatches/sec.

#### Approach B: Code Generation (kabelsalat/noisecraft Style)

Generate a JS string where each node becomes a line of code:

```javascript
// Generated code (kabelsalat style):
const n0 = 0.5;                          // Num
const n1 = 200;                           // Num
const n2 = nodes[0].update(n1, 0);        // Sine (stateful)
const n3 = n2 * n0;                       // Mul (inlined)
return [n3 * 0.3, n3 * 0.3];             // stereo out
```

Advantages: V8 JIT compiles this to near-native speed. No dispatch overhead.
Disadvantage: requires `new Function()` or `eval()`. Harder to debug.

#### Approach C: Per-Buffer Processing (Recommended for MoonBit)

Process each node for the entire 128-sample buffer before moving to the next node. Better cache locality than per-sample processing.

```moonbit
fn run_buffer(
  nodes : FixedArray[FlatNode],
  buffers : FixedArray[FixedArray[Double]], // one buffer per node
  state : FixedArray[ProcessorState],
  ctx : DspContext,
) -> Unit {
  for i = 0; i < nodes.length(); i = i + 1 {
    let out = buffers[i]
    match nodes[i].node_type {
      Num(v) => {
        for j = 0; j < ctx.block_size(); j = j + 1 {
          out[j] = v
        }
      }
      Sine => {
        let freq_buf = buffers[nodes[i].ins[0]]
        process_sine_buffer(state[i], freq_buf, out, ctx)
      }
      Mul => {
        let a = buffers[nodes[i].ins[0]]
        let b = buffers[nodes[i].ins[1]]
        for j = 0; j < ctx.block_size(); j = j + 1 {
          out[j] = a[j] * b[j]
        }
      }
      // ... etc
    }
  }
}
```

Advantages: buffer-based processing enables SIMD optimization, better cache behavior. Match dispatch only happens once per node per buffer (not per sample). Natural fit for MoonBit (no code generation needed).

**Recommendation**: Start with Approach C. It's a good balance of performance and simplicity for MoonBit. If it's not fast enough (unlikely for < 100 nodes), investigate code generation later.

### 3.5.1 Current Phase 2 Status

This section is the authoritative description of the current compiled-graph
runtime-control surface. Update it first when Phase 2 runtime behavior changes;
keep `docs/salat-engine-blueprint.md` as a summary-level pointer back to this
section (the early Phase 0/1/2 status log lives at `docs/archive/RESULTS.md`
for historical context).

The current repository already implements:

- a compiled mono graph path: `DspNode` authoring graphs compile into an opaque
  `CompiledDsp`, including explicit `Mono -> Stereo -> Mono` subgraphs through
  `Pan` and `StereoMixDown`, plus supported mono feedback cycles through
  automatic `z^-1` back-edge insertion
- a first stereo graph path: the same `DspNode` authoring language can compile
  into `CompiledStereoDsp` for `Mono -> Pan -> Stereo post-processing ->
  StereoOutput`, where the current stereo post-processing node set is
  `StereoGain`, `StereoClip`, `StereoBiquad`, and `StereoDelay`, and where the
  feedback uses a self-register model: stereo, mixed-shape, and mono `z^-1`
  feedback loops are accepted through `Pan` and in the stereo post-processing
  path
- input nodes may be declared in authoring order; the compiler topologically
  sorts reachable nodes from a single terminal output node
- `CompiledDsp::compile(CompiledTemplate, DspContext) -> Self?` is the
  single entry point; `CompiledTemplate::analyze(Array[DspNode])` produces
  the input. See ADR-0010 for the boundary contract.
- `CompiledTemplate::analyze(...)` captures the authoring template, the
  optimizer's authoring-index map, and the optimized node array; both mono
  and stereo graphs compile from the same analyzed template via
  `CompiledDsp::compile(...)` or `CompiledStereoDsp::compile(...)`,
  reusing the optimized nodes without running the graph optimizer a second time
- compile rejects:
  - unsupported feedback cycles
  - multiple outputs
  - missing outputs
  - unreachable nodes
  - invalid references
  - non-finite constants
  - invalid fixed `Biquad` parameters
  - fixed `Delay` / `StereoDelay` feedback outside the live supported range
- runtime processing fails closed to silence if the caller requests a block size
  larger than the graph was compiled for

Current graph node support:

- `Constant`
- `Oscillator`
- `Oscillator` in FM mode (`input0 >= 0`): reads frequency per-sample from input buffer
- `Noise`
- `Adsr`
- `Biquad`
- `Delay`
- `Gain`
- `Mul`
- `Mix`
- `Clip`
- `Pan`
- `StereoGain`
- `StereoClip`
- `StereoBiquad`
- `StereoDelay`
- `StereoMixDown`
- `Output`
- `StereoOutput`

Current runtime control support:

- `apply_control(GraphControl) -> Result[Unit, GraphControlError]` is the
  runtime-control entrypoint. Direct compiled mono/stereo graphs and their
  hot-swap/topology wrappers all return `Result[Unit, GraphControlError]` and
  report the specific rejection reason (`InvalidNodeIndex`, `OrphanNode`,
  `InvalidGateNode`, `InvalidSlotForNode`, `InvalidParamValue`,
  `MissingRuntimeState`) when a control is refused
- hot-swap and topology wrappers participate in the same `GraphControllable`
  trait surface; the trait returns `Result[Unit, GraphControlError]` so
  consumer code can be written generically over any controllable graph
- `apply_controls(Array[GraphControl]) -> Result[Unit, GraphControlError]`
  applies control batches transactionally in batch order while targeting nodes
  by authoring index; the batch is validated against a simulated copy first
  and rejected as a unit on the first error
- per-kind convenience methods on every wrapper:
  - `gate_on(node_index)` / `gate_off(node_index)` for `Adsr`
  - `set_param(node_index, slot, value)` for selected numeric params
    (`Gain`, `Clip`, `Biquad`, `Delay`, `Constant`, `Oscillator`, `Pan`,
    `StereoGain`, `StereoClip`, `StereoBiquad`, `StereoDelay`)
  - all return `Result[Unit, GraphControlError]` with the same rejection
    reasons as `apply_control(...)`
- `Delay` and `StereoDelay` now each expose a node-local feedback coefficient:
  `Value0 = feedback` and `DelaySamples = delay length`
- integration coverage now includes successful runtime `Biquad` retunes in
  compiled mono graphs for `LowPass`, `HighPass`, and `BandPass`
- the current graph tests also include directional runtime-retune assertions for
  `HighPass` and `BandPass`, not just output-difference checks
- accepted mono feedback graphs keep the existing supported runtime-control
  surface; coverage now includes direct `Gain` retunes and transactional
  `apply_controls(...)` batches inside a compiled `z^-1` loop, plus direct
  `Delay` and `Biquad` runtime-update equivalence checks against fixed feedback
  graph compiles
- stereo graph coverage now includes:
  - graph-unit checks for `Pan -> StereoOutput` shape enforcement
  - stereo post-processing through `StereoGain`, `StereoClip`, and
    `StereoBiquad`, plus `StereoDelay`
  - direct runtime updates for `Pan`, `StereoGain`, `StereoClip`, and
    `StereoBiquad`, plus `StereoDelay`
  - accepted mono `z^-1` feedback loops before `Pan`, including graph-unit
    gain-retune coverage and bounded block-persistence plus batched
    gain/pan-retune integration checks
  - end-to-end compiled stereo voice-path and batched-control integration tests
- mono graph coverage now includes explicit stereo fold-down through
  `StereoMixDown`, including stereo-filtered and stereo-delayed paths through
  `StereoBiquad` and `StereoDelay`
- mono graph coverage now also includes a bounded `z^-1` feedback recurrence in
  `CompiledDsp`, direct self-feedback acceptance with zero-initialized state,
  a direct multi-back-edge fanout regression, runtime
  gain/delay/biquad/control-batch retunes on accepted loops, and rejection
  coverage for unsupported output/stereo cycles
- browser automation now also exercises the mono `CompiledDsp` feedback path
  through the wasm-side stereo-init-failure fallback route, checking both the
  first-block `z^-1` recurrence preview and a live loop-gain retune in the
  AudioWorklet pipeline
- browser automation now also exercises the `CompiledStereoDsp` feedback path
  on the main browser wasm, checking the first-block center-pan recurrence of a
  mono `z^-1` loop before `Pan` plus live loop-gain retuning and directional
  pan behavior in the AudioWorklet pipeline

Current `set_param(node_index, slot, value)` support matrix:

| Node kind | Supported slots | Notes |
|-----------|-----------------|-------|
| `Constant` | `Value0` | Finite values only |
| `Oscillator` | `Value0` | Finite frequency values only |
| `Oscillator` (FM mode) | none | FM mode: frequency comes from input buffer, no runtime freq param |
| `Noise` | none | No runtime seed update yet |
| `Adsr` | none | Runtime control is `gate_on` / `gate_off` only |
| `Biquad` | `Value0`, `Value1` | `Value0 = cutoff`, `Value1 = q`; validated against the compile-time sample rate |
| `Delay` | `Value0`, `DelaySamples` | `Value0 = feedback`; finite values in `[-0.99, 0.99]` only. `DelaySamples` requires exact integer values. Both are applied to the live `DelayLine` state |
| `Gain` | `Value0` | Finite gain only |
| `Mul` | none | No runtime params |
| `Mix` | none | No runtime params |
| `Clip` | `Value0` | Positive finite threshold only |
| `Pan` | `Value0` | Finite pan position only |
| `StereoGain` | `Value0` | Finite gain only |
| `StereoClip` | `Value0` | Positive finite threshold only |
| `StereoBiquad` | `Value0`, `Value1` | `Value0 = cutoff`, `Value1 = q`; validated against the compile-time sample rate |
| `StereoDelay` | `Value0`, `DelaySamples` | `Value0 = feedback`; finite values in `[-0.99, 0.99]` only. `DelaySamples` requires exact integer values. Both are applied to the live left/right `DelayLine` states |
| `StereoMixDown` | none | Fixed equal-weight fold-down: `0.5 * (left + right)` |
| `Output` | none | No runtime params |
| `StereoOutput` | none | No runtime params |

Current limits:

- stereo graph support is still narrow: terminal stereo remains
  `Pan -> stereo post-processing -> StereoOutput`, while mono graphs may now
  fold stereo back through `StereoMixDown`
- stereo post-processing remains intentionally small: the current effect slice
  is `StereoBiquad` plus `StereoDelay`, with no broader stereo mix/effect set
  yet
- feedback-edge insertion now uses a self-register model: stereo and
  mixed-shape feedback are accepted, and both `CompiledDsp` and
  `CompiledStereoDsp` process feedback through a unified per-sample loop
  with self-registers rather than a separate linked-list infrastructure
- constant folding and dead-node elimination run exactly once inside
  `CompiledTemplate::analyze(...)` via `optimize_graph()`; both
  `CompiledDsp::compile(...)` and `CompiledStereoDsp::compile(...)` receive
  the pre-optimized template and do not re-run the optimizer
- `InsertChain` / `DeleteChain` topology edit variants support multi-node
  subgraph operations with stereo parity
- state preservation across topology edit recompilation is supported
- graph hot-swap is now narrow but no longer mono-only:
  `CompiledDspHotSwap` supports mono `CompiledDsp` replacement and
  `CompiledStereoDspHotSwap` supports terminal-stereo `CompiledStereoDsp`
  replacement, both with block-boundary `queue_swap(...)` and optional
  equal-power crossfade
- runtime parameter updates are partial, not universal across node kinds

### 3.5.2 Control Frames

The current graph runtime now has an explicit control-frame model for Phase 2.

A control frame is an ordered batch of `GraphControl` messages applied once
between render blocks:

```moonbit
compiled.apply_controls([
  GraphControl::gate_on(env_node),
  GraphControl::set_param(gain_node, GraphParamSlot::Value0, 0.5),
  GraphControl::set_param(filter_node, GraphParamSlot::Value0, 1200.0),
]).unwrap()
compiled.process(context, output)
```

`apply_controls(...)` returns `Result[Unit, GraphControlError]`; the
example uses `.unwrap()` for brevity, but real callers typically pattern
match on the specific rejection reason (`InvalidNodeIndex`,
`InvalidSlotForNode`, `InvalidParamValue`, etc.).

Current semantics:

- controls are evaluated in the array's batch order
- controls target nodes by original authoring index, not topo-sorted index
- `apply_controls(...)` is transactional:
  - if any control in the batch is invalid, none of them are applied
  - if the batch succeeds, all controls are committed before the next
    `process(...)` call
- `apply_control(...)` remains the single-message form of the same runtime API
- control preflight checks verify the same runtime control rules without
  mutating compiled graph state; live orchestration uses this non-mutating check
  to keep multi-voice edits all-or-nothing

This is enough for the current compiled mono and terminal-stereo graph paths to
support per-block parameter and gate updates from a host, UI, or future pattern
engine.

Current limits of the control-frame model:

- controls are still block-boundary updates, not sample-accurate events
- runtime-updatable slots are still limited to the support matrix above
- `GraphControl` still does not cover topology changes or stereo graph routing
  changes directly; hot-swap and the first topology-edit slice remain separate
  wrapper APIs in this phase

### 3.5.3 Pattern-to-DSP Control Binding

Pattern playback now has a bound voice-pool layer that keeps template validation
and control-key routing together.

Current semantics:

- `VoicePool` is the low-level polyphonic mono-voice allocator and mixer
  with priority stealing, per-slot template snapshots, and per-voice pan gains
- `BoundVoicePool` owns both a `VoicePool` and the `ControlBindingMap` proven
  against that pool's current `CompiledTemplate`
- `BoundVoicePool::new(...)` analyzes the template once, validates voice-pool
  requirements, and builds bindings against the same analyzed template
- `BoundVoicePool::set_template(...)` is transactional:
  - it analyzes and validates the replacement template first
  - it builds the replacement `ControlBindingMap` against that replacement
    template before mutating the live pool
  - if validation or binding fails, the previous template and bindings remain
    active
- `BoundVoicePool::note_on_controls(...)` accepts pattern/control-map values,
  resolves them through the current binding map, and delegates to the inner
  `VoicePool::note_on(...)`
- voice-control validation and application target already-sounding voices by
  active voice identifier; stale identifiers and invalid control changes are
  rejected without changing the voice-pool template or bindings
- each `VoicePool::note_on(...)` compiles the already analyzed template through
  `CompiledDsp::compile(template, ctx)`, so per-voice graph creation reuses the
  optimized nodes captured during template validation
- `PatternScheduler` stores tempo, sample position, a `DspContext`, active note
  handles, a `ControlMapper`, and an optional active + pending
  `PlaybackSnapshot` pair; it no longer stores a separate `ControlBindingMap`.
  All struct fields are `priv` as of v0.3.0 — public observation goes through
  named methods (`bpm`, `sample_counter`, `current_block`, `active_note_count`,
  `active_note_source`, `active_note_matches`)
- `PatternScheduler::process_block(...)` takes a `BoundVoicePool`, expires old
  notes, queries the pattern for the current block arc, converts raw control
  maps through the scheduler's mapper, calls `note_on_controls(...)`, applies
  per-voice side effects such as pan, then renders through the bound pool
- Phase 6 incremental authoring adds a snapshot-swap layer over the same
  block-processing loop. `queue_pattern_snapshot` / `queue_song_snapshot`
  stage a lowered `PlaybackSnapshot` without changing playback immediately;
  `process_snapshot_block` (and the pattern/song/playback variants) commits
  the pending snapshot at block start before note expiry and event query,
  giving callers a stable block boundary for pattern edits during playback.
  Multiple staged snapshots coalesce so the latest staged state wins
- `apply_affected_voice_policy(...)` and `apply_affected_voice_policy_for_edit(...)`
  preserve, release, or immediately stop scheduler-owned active voices whose
  authored provenance matches a selector, while
  `queue_playback_snapshot_edit(...)` (and the pattern/song-specific wrappers)
  combines staged replacement with an affected-voice policy and optional
  validated live-control changes in one atomic call. See
  `scheduler/README.mbt.md` for a checked end-to-end edit orchestration example

This prevents a stale scheduler-owned binding map from being paired with a
voice pool after a template swap, and it removes the previous double
`optimize_graph(...)` pass from the voice-template path. The boundary type
makes single-optimize a static guarantee, not just a dynamic property —
`optimize_graph` is package-private and runs exactly once inside
`CompiledTemplate::analyze`.

### 3.6 Graph Hot-Swap

The current implementation provides narrow mono and terminal-stereo hot-swap
wrappers, plus a first mono topology-edit wrapper layered on top of mono
hot-swap:

Mono hot-swap example:

```moonbit
let old_template = CompiledTemplate::analyze(old_nodes)
let new_template = CompiledTemplate::analyze(new_nodes)
let active = CompiledDsp::compile(old_template, context).unwrap()
let replacement = CompiledDsp::compile(new_template, context).unwrap()
let hot_swap = CompiledDspHotSwap::from_graph(active, crossfade_samples=128)

hot_swap.queue_swap(replacement).unwrap()
hot_swap.process(context, output)
```

Stereo hot-swap example:

```moonbit
let old_template = CompiledTemplate::analyze(old_nodes)
let new_template = CompiledTemplate::analyze(new_nodes)
let active_stereo = CompiledStereoDsp::compile(old_template, context).unwrap()
let replacement_stereo = CompiledStereoDsp::compile(new_template, context).unwrap()
let hot_swap_stereo = CompiledStereoDspHotSwap::from_graph(
  active_stereo,
  crossfade_samples=128,
)

hot_swap_stereo.queue_swap(replacement_stereo).unwrap()
hot_swap_stereo.process(context, left_output, right_output)
```

```moonbit
let topology = CompiledDspTopologyController::from_nodes(
  old_nodes,
  context,
  crossfade_samples=128,
).unwrap()

topology
  .queue_topology_edit(
    GraphTopologyEdit::delete_node(
      gain_node,
      output_node,
      GraphTopologyInputSlot::Input0,
      gain_node,
    ),
  )
  .unwrap()
topology.process(context, output)
```

Current semantics:

- `CompiledDspHotSwap` owns one active mono `CompiledDsp`
- `CompiledStereoDspHotSwap` owns one active terminal-stereo `CompiledStereoDsp`
- `CompiledDspTopologyController` owns authoring-order mono nodes plus an inner
  `CompiledDspHotSwap`
- `queue_swap(...) -> Result[Unit, HotSwapQueueError]` stages one replacement
  graph for the next `process(...)` call on direct mono/stereo hot-swap
  wrappers; reports `SampleRateMismatch` or `BlockCapacityMismatch` explicitly
- `queue_topology_edit(...) -> Result[Unit, GraphTopologyQueueError]` /
  `queue_topology_edits(...) -> Result[Unit, GraphTopologyQueueError]` apply
  an ordered `GraphTopologyEdit` batch to the stored authoring nodes,
  recompile a replacement graph, and stage that replacement through the inner
  hot-swap wrapper. The batch is transactional and reports `PendingSwap`,
  `InvalidEdit(index, reason)`, `RecompileRejected`, or a wrapped
  `HotSwapQueueError`
- `InvalidEdit(index, reason)` reports the zero-based edit position in the
  submitted batch and a stable `GraphTopologyEditError` reason such as invalid
  node/source indices, unsupported input slots, unsupported inserted node
  templates, invalid delete ranges, replacement sources inside a deleted chain,
  or delete shapes that are not unary/single-consumer chains
- topology-edit batches are transactional:
  - if any edit has an invalid authoring index, nothing is changed
  - if the edited node array fails recompilation, nothing is staged
  - `RewireInput` also rejects unsupported input slots for the targeted node
  - `InsertNode` also rejects unsupported unary template nodes
  - `DeleteNode` also rejects non-unary targets or delete shapes without a
    single deterministic downstream consumer
- replacement graphs must match the active graph's compile-time sample rate and
  block capacity
- `process(...)` runs only the active graph when no swap is pending
- when a swap is pending and `crossfade_samples > 0`, `process(...)` runs both
  graphs and mixes them with equal-power gains:
  - `old_gain = cos(t * π/2)`
  - `new_gain = sin(t * π/2)`
- when `crossfade_samples <= 0`, the swap is instantaneous on the next
  `process(...)` call
- runtime `apply_control(...)` / `apply_controls(...)` target the active graph
  when no swap is pending
- during an in-flight crossfade, runtime controls are validated against both
  active and pending graphs and applied to both graphs transactionally
  - if either graph rejects the control batch, nothing is applied
  - result-typed wrapper APIs return that rejection as `GraphControlError`
- topology controllers also mirror accepted runtime `set_param(...)` updates
  into their stored authoring-order nodes, so later `queue_topology_edit(...)`
  recompiles preserve the current parameter baseline instead of rebuilding from
  stale pre-control node values

Current limits:

- no state migration between old and new graphs; the replacement graph starts
  from its own freshly compiled internal state
- in-flight control mirroring requires both graphs to accept the same
  node-index / slot updates during the crossfade window
- topology edits support both single-node and multi-node operations:
  - `GraphTopologyEdit::replace_node(...)` swaps one authoring-order node
  - `GraphTopologyEdit::rewire_input(...)` retargets one existing input edge
  - `GraphTopologyEdit::insert_node(...)` appends one unary node and retargets
    one existing downstream input to it
  - `GraphTopologyEdit::delete_node(...)` removes one unary node and retargets
    one downstream input to a replacement upstream source
  - `GraphTopologyEdit::insert_chain(...)` inserts a chain of unary nodes
    between a source and a downstream input
  - `GraphTopologyEdit::delete_chain(...)` removes a contiguous chain of unary
    nodes and retargets the downstream input to a replacement source
  - all edit variants work for both mono and stereo topology controllers
  - state preservation across topology-edit recompilation is supported:
    unchanged nodes (matched by authoring index and kind) inherit runtime state
    (oscillator phase, filter coefficients, delay buffers, self-register values)
    from the previous compiled graph
  - only one topology replacement may be staged at a time
- browser/AudioWorklet hot-swap proof is now narrow but present for both paths:
  the `browser/` wrapper exports dedicated mono `CompiledDspHotSwap` and
  terminal-stereo `CompiledStereoDspHotSwap` proof paths, and Playwright checks
  both the mixed crossfade block and the settled replacement block in the
  AudioWorklet pipeline
- browser queue/control paths route through the result-typed APIs and retain a
  last graph-error string/code for JavaScript callers:
  `get_browser_error_code()`, `get_browser_error_length()`, and
  `get_browser_error_char(i)`. The queue/process exports keep their boolean ABI
  and return `false` while the helper exports carry the specific
  `HotSwapQueueError`, `GraphTopologyQueueError`, or `GraphControlError`
  summary.
- browser/AudioWorklet topology-edit proof is now present for the mono slice:
  the `browser/` wrapper exports a dedicated `CompiledDspTopologyController`
  proof path, and Playwright checks an `InsertNode` / `DeleteNode` round-trip:
  - `queue_compiled_topology_edit()` explicitly queues the fixed unary insert
  - `queue_compiled_topology_delete_edit()` explicitly queues the matching
    unary delete back to the baseline graph
  - the first queued edit inserts one unary node with the expected mixed and
    settled rebuilt blocks, and the second queued edit deletes that node and
    returns the browser output to the original baseline shape
  - the browser proof also applies a live runtime gain control while the insert
    crossfade is already in flight, and Playwright checks that the mixed block
    reflects the mirrored control on both the active and pending rebuilt graphs
  - terminal-stereo parity now exists through a dedicated
    `CompiledStereoDspTopologyController` browser proof path, with Playwright
    checking the mixed and settled channel-shape transition from a queued
    stereo topology edit
  - the stereo browser proof also applies a live runtime level control while
    the queued pan-replacement crossfade is already in flight, and Playwright
    checks that the mixed left/right block reflects the mirrored control on
    both the active and pending stereo graphs

### 3.7 Multichannel Expansion (SuperCollider-Style)

When a node receives an array instead of a scalar, the entire upstream graph is duplicated per channel:

```
sine([200, 300, 400]).out()
// Expands to 3 parallel sine oscillators mixed together
```

Implementation: during flatten(), detect array inputs and duplicate the subgraph. This is a pre-processing step before topological sort.

Defer this to later Phase 2 work. The current implementation starts with mono
only.

---

## 4. AudioWorklet Threading Model

### 4.1 Two Threads, Strict Separation

```
Main Thread                          Audio Thread
─────────────────────                ─────────────────────
- DOM / UI                           - AudioWorkletProcessor.process()
- User input handling                - Called every 128 samples (~2.67ms)
- Graph compilation                  - Must return within deadline
- AudioContext management            - No DOM access
- postMessage sender                 - Avoid loading/network work in the render path
                                     - Do not assume worker-only APIs such as
                                       `importScripts()` exist here
```

### 4.2 Communication Patterns

#### postMessage (Simple, Sufficient for Most Cases)

```
Main → Audio: graph updates, parameter changes, note on/off
Audio → Main: visualization data, meter levels
```

Latency: typically < 1ms on modern browsers, but not guaranteed. Acceptable for parameter changes (smoothed anyway) and graph updates.

#### SharedArrayBuffer (Low-Latency, Complex Setup)

Required for:
- High-frequency parameter automation (100+ changes per second)
- Audio data streaming to main thread for visualization
- MIDI input with minimal latency

Setup requirements:
- Server must send `Cross-Origin-Opener-Policy: same-origin` header
- Server must send `Cross-Origin-Embedder-Policy: require-corp` header
- Use `Int32Array` or `Float32Array` views on the SharedArrayBuffer
- Use `Atomics.load()` / `Atomics.store()` for safe reads/writes
- **Cannot use `Atomics` on `Float64Array`** — use `Float32Array` (sufficient precision for audio parameters) or encode doubles as two Int32 values

For the prototype, use postMessage only. Add SharedArrayBuffer in Phase 5+ if needed.

### 4.3 Loading wasm-gc in AudioWorklet

The AudioWorkletGlobalScope is a restricted environment. Key constraints:

- Load the processor script with `audioWorklet.addModule(...)` on the main
  thread; do not treat AudioWorklet like a classic worker
- Do not rely on worker-only APIs such as `importScripts()`
- Prefer fetching and compiling the Wasm module on the main thread, then
  transfer the compiled `WebAssembly.Module` to the processor

Recommended pattern (Chrome's "Pattern B"):

```javascript
// Main thread:
const wasmBytes = await fetch('module.wasm').then(r => r.arrayBuffer());
const wasmModule = await WebAssembly.compile(wasmBytes);

// Transfer to audio thread via AudioWorkletNode constructor:
const node = new AudioWorkletNode(ctx, 'processor', {
  processorOptions: { wasmModule }
});

// Audio thread (processor.js):
constructor(options) {
  const mod = options.processorOptions.wasmModule;
  this.ready = false;
  this._initWasm(mod);
}

async _initWasm(mod) {
  this.instance = await WebAssembly.instantiate(mod, imports);
  this.ready = true;
}
```

### 4.4 MoonBit wasm-gc Module Loading

The wasm-gc module generated by MoonBit may require specific imports. Common patterns:

```javascript
const imports = {
  // For println support (can be no-op if not needed in DSP)
  "spectest": {
    "print_char": (ch) => {}
  },
  // For closures passed across FFI boundary
  "moonbit:ffi": {
    "make_closure": (funcref, closure) => funcref.bind(null, closure)
  }
};

// If strings cross the boundary outside AudioWorklet, JS string builtins may
// be needed. For the AudioWorklet prototype, prefer main-thread
// fetch/compile + constructor transfer instead of streaming fetch here.
```

For the DSP module, we only export numeric functions (no strings), so the imports should be minimal.

---

## 5. kabelsalat / noisecraft Architecture Summary

### 5.1 What kabelsalat Does

1. **DSL**: JavaScript with method chaining. `sine(200).mul(0.5).out()`
2. **Graph**: `Node` objects form a tree. Each node has `type` and `ins[]`.
3. **Compiler**: Flatten → topo sort → generate JS code string.
4. **Runtime**: Generated code runs in AudioWorkletProcessor via `new Function()`.
5. **Stateful nodes**: Each `AudioNode` (e.g., Sine) keeps its own state (phase, etc.). Stored in a `nodes[]` array, indexed by compiler-assigned ID.

### 5.2 Key Design Decisions and Their Rationale

| Decision | Rationale | Applicable to Salat? |
|----------|-----------|---------------------|
| JS code generation | V8 JIT optimizes generated code better than interpreter loops | Not directly (MoonBit doesn't have `eval`). Use buffer-based processing instead. |
| Single-sample processing | Enables single-sample feedback (z⁻¹) | Yes. Some nodes need per-sample processing (oscillators with FM). |
| Flat node array + indices | Cache-friendly, no pointer chasing | Yes. `FixedArray[FlatNode]` in MoonBit. |
| Compile on main thread, run on audio thread | Compilation can be slow, audio thread has hard deadline | Yes. Exact same pattern. |
| AudioNode class with `update()` method | Each node type encapsulates its DSP + state | Yes. Use MoonBit structs with `process()` method. |
| Method chaining as DSL | Natural expression syntax, reduces parenthesis nesting | Possible in MoonBit with extension methods, but not the priority. |

### 5.3 kabelsalat Limitations That Salat Addresses

| Limitation | kabelsalat | Salat Engine |
|------------|-----------|--------------|
| Type safety | None (JS dynamic types) | MoonBit static types, enums, pattern matching |
| Pattern engine | External (Strudel) | Built-in (`salat-pattern`) |
| Incremental updates | Full recompilation on every change | `incr` memoizes unchanged subgraphs |
| Collaboration | Not supported | CRDT-based (future) |
| Native target | C codegen (experimental) | MoonBit C/LLVM backend (first-class) |
| Voice management | Struggled with this | ECS-based design (planned) |

### 5.4 froos's Learning Journey (from garten.salat.dev)

The 120-post development blog reveals a progression that directly maps to our phases:

| Blog posts | Topic | Our phase |
|------------|-------|-----------|
| 022-030 | AudioWorklet basics, first wasm audio | Phase 0 |
| 063-070 | Oscillators, waveforms, Fourier series | Phase 1 |
| 072-073 | Envelopes, sequences, triggers | Phase 1 |
| 076-078 | Graph computer, audio worklets | Phase 2 |
| 079-081 | Spawning audio graphs (voice management) | Phase 3 |
| 087-095 | "Hello Audio in C" series (DSP from scratch) | Phase 1 alt |
| 096 | "The Superdough Puzzle" (architecture reflection) | Design |
| 101 | kabelsalat to WAT compiler | Phase 2 alt |
| 104-105 | Worklet buffers, graph updates | Phase 2 |
| 110-120 | AST language design (evaluator, macros, lambdas) | Phase 4+ |

Key lesson: froos spent months on voice management (079-081) and described it as the hardest problem. Our ECS-based approach is explicitly designed to address this.

---

## 6. Node Type Reference

Minimum set needed for a useful synthesizer (Phase 1-2):

### Sources (no input)
| Node | Params | State | Description |
|------|--------|-------|-------------|
| `Num` | value | — | Constant number |
| `Sine` | freq | phase | Sine oscillator |
| `Saw` | freq | phase | Sawtooth oscillator |
| `Square` | freq, pw | phase | Square/pulse oscillator |
| `Tri` | freq | phase | Triangle oscillator |
| `Noise` | — | rng_state | White noise |

### Filters (1 input + params)
| Node | Params | State | Description |
|------|--------|-------|-------------|
| `LPF` | cutoff, Q | z1, z2 | Biquad low-pass |
| `HPF` | cutoff, Q | z1, z2 | Biquad high-pass |
| `BPF` | cutoff, Q | z1, z2 | Biquad band-pass |

### Envelopes (1 input + params)
| Node | Params | State | Description |
|------|--------|-------|-------------|
| `ADSR` | a_ms, d_ms, s, r_ms | stage, level, gate | Attack-Decay-Sustain-Release (times in ms) |

### Arithmetic (2 inputs, stateless)
| Node | Description |
|------|-------------|
| `Add` | a + b |
| `Mul` | a * b |
| `Sub` | a - b |
| `Div` | a / b (with div-by-zero protection) |

### Effects
| Node | Params | State | Description |
|------|--------|-------|-------------|
| `Delay` | time, feedback | circular buffer | Echo/delay |
| `Gain` | amount | — | Volume control (with optional envelope buffer via input1) |
| `Pan` | position | — | Stereo panning |
| `Clip` | threshold | — | Hard clipping / distortion |

### Utility
| Node | Description |
|------|-------------|
| `Out` | Marks the final output node |
| `FeedbackRead` | Read from feedback register |
| `FeedbackWrite` | Write to feedback register |
| `Range` | Map [-1,1] to [min,max]: `(input + 1) / 2 * (max - min) + min` |
| `range()` | Composed `ArithSym` function — maps [-1,1] to [min,max] using arithmetic; no new enum variant |
| `lin_map()` | Composed `ArithSym` function — linear map from [in_min,in_max] to [out_min,out_max]; no new enum variant |

---

## 7. Glossary

| Term | Definition |
|------|-----------|
| **Render quantum** | 128 audio samples — the fixed block size of WebAudio's AudioWorkletProcessor |
| **Phase accumulator** | A counter that increments by `freq/sampleRate` each sample, wrapping at 1.0 |
| **Biquad** | A second-order IIR filter with 5 coefficients (b0, b1, b2, a1, a2) |
| **z⁻¹** | One-sample delay, the fundamental building block of digital filters |
| **Topological sort** | Ordering nodes so that every dependency is computed before the node that uses it |
| **PolyBLEP** | Polynomial correction applied near waveform discontinuities to reduce aliasing |
| **One-pole filter** | Simplest IIR filter: `y = target + coeff * (y_prev - target)`. Used for parameter smoothing. |
| **ADSR** | Attack-Decay-Sustain-Release envelope — shapes amplitude over time |
| **ControlMap** | A `Map[String, Double]` carrying event parameters from Pattern Engine to DSP Engine |
| **Finally Tagless** | A pattern where a DSL is defined as a trait. Each implementation is a different interpretation. |
| **Hylomorphism** | A recursion scheme combining an unfold (anamorphism) and a fold (catamorphism) |
| **`incr`** | MoonBit library for incremental computation (Signal/Memo). Salsa-inspired. |
| **CLAP** | Clever Audio Plugin format — modern alternative to VST3, designed for open-source |
