All books

The Box-DSL — toBox syntax

~6 min read · updated 2026-08-21 · markdown

The Box-DSL is a tiny declarative expression language pack authors write inside toBox.outputs[port] (and optionally toBox.defs[name]) strings. The Builder parses each expression at compile time, evaluates it against a substitution env (params + inputs + defs), and produces a Box AST that the Faust generator emits as DSL source. Same primitive the Builder uses internally — your pack composes Box ASTs the same way faustGen composes graphs.

Why Box-DSL

The pre-Phase-4 surface used {{params.X}} / {{inputs.Y}} template strings — pure substitution into hand-written Faust strings. That's gone. Box-DSL replaced it because:

  • Box ASTs compose. Wrapping a kind's expression in another transform (Sound-Quality Welle's drift / multi-voice / smoothing) means walking + rewriting structure, not regexing template strings.
  • The same primitives the graph-level generator uses. faustGen.ts itself emits Box ASTs (seq, par, withDefs, parLoop) for FX-shape graphs + multi-voice unison. Pack authors and generator authors speak the same DSL.
  • Arity is checkable. Every Box carries inputs / outputs counts — wire mismatches surface as build-time errors instead of opaque libfaust diagnostics.

Migration is complete: no remaining string-template Kinds in the bundled packs as of v0.64.x.

The constructors

A toBox expression is a tree of these constructors. Whitespace + newlines are ignored — write them inline or pretty-print across lines.

Leaves

ConstructorEmitsArityNotes
input('id')The input port's upstream wire expression, or 0 if unconnected(0, 1)Match the id field of an entry in inputs[]
param('id')The param's resolved Faust source (hslider with smoothing / velocity / drift wraps applied)(0, 1)Match the id field of an entry in params[]
def('name')A previously-declared defs[name] Box, in declaration order(0, 1+)Forward refs are an error
prim('symbol', args...)A bare Faust function call: symbol(a, b, …)dependsFor primitives not aliased in stdfaust (e.g. T1_12AX7 from tubes.lib)
real(0.5) / int(2)A literal number(0, 1)Use int for indices, real for everything else
wire()The identity wire _(1, 1)For par(wire(), …) route-passthrough patterns
cut()The cut wire !(1, 0)For dropping unused parallel branches
mem and mem(box)Faust mem (one-sample memory)(1, 1)Used in feedback loops; see ping_pong_delay

Composers

ConstructorEmitsNotes
seq(a, b, …)a : b : … (sequential composition)Output arity of left must match input arity of right
par(a, b, …)a, b, … (parallel composition)Independent inputs + outputs
parLoop('i', N, body)par(i, N, body) — bind i to 0..N-1Used by faustGen for unison multi-voice
withDefs([{name, body}], outer)outer with { name = body; … };Local definitions inside an expression
merge(a, b)a :> b (multi-source merge)Sum N-out into M-in
split(a, b)a <: b (split + fan-out)Fan one signal to many

Library calls

ConstructorEmitsNotes
lib('ns', 'fn', args...)ns.fn(args)ns is a stdfaust alias (os, fi, re, ef, pm, an, ma, ba, si, de, en, no) — they're declared in stdfaust.lib
prim('fn', args...)fn(args) (bare name)For libraries NOT auto-imported by stdfaust — declare imports: ["xyz.lib"] on the kind so faustGen emits the explicit import("xyz.lib") (see § 3)

Arithmetic + comparators

Available as constructors for cleaner reading vs prim('+') / prim('*'):

plus(a, b), minus(a, b), times(a, b), divide(a, b), over(a, b) (same as divide), intCast(box), realCast(box).

These compose like any other Box and respect arity. times(param('mix'), input('in')) reads as mix * in.

Worked examples — from the bundled packs

1. Lowpass filter — one library call, one param

{
  "kind": "lp",
  "inputs": [
    { "id": "in", "label": "in", "kind": "audio" },
    { "id": "cutoff_in", "label": "cutoff", "kind": "control" }
  ],
  "outputs": [{ "id": "out", "label": "out", "kind": "audio" }],
  "params": [
    { "type": "hslider", "id": "cutoff", "label": "Cutoff", "init": 1000,
      "min": 20, "max": 20000, "step": 1, "unit": "Hz", "scale": "log",
      "style": "knob", "smoothing_class": "cutoff", "velocity_responds": 0.3 }
  ],
  "toBox": {
    "outputs": {
      "out": "lib('fi', 'lowpass', int(2), param('cutoff'), input('in'))"
    }
  }
}

Emits roughly: fi.lowpass(2, (cutoff_hslider : si.smooth(...) : <velocity-wrap>), in). The smoothing_class: "cutoff" + velocity_responds: 0.3 wraps automatically — see § 4.

2. Vintage tube stage — bare-name primitive + imports

{
  "kind": "tube_12ax7_t1",
  "imports": ["tubes.lib"],
  "inputs": [{ "id": "in", "label": "in", "kind": "audio" }],
  "outputs": [{ "id": "out", "label": "out", "kind": "audio" }],
  "params": [],
  "toBox": {
    "outputs": { "out": "prim('T1_12AX7', input('in'))" }
  }
}

prim (not lib) because T1_12AX7 lives in tubes.lib, which isn't aliased by stdfaust. The imports: ["tubes.lib"] field tells faustGen to emit import("tubes.lib"); in the DSP header so the bare name resolves. See § 3 for the plumbing.

3. Ping-pong delay — shared defs + per-channel wet_l / wet_r

{
  "kind": "ping_pong_delay",
  "inputs": [
    { "id": "in_l", "label": "L", "kind": "audio", "channels": 2 },
    { "id": "in_r", "label": "R", "kind": "audio", "channels": 2 }
  ],
  "outputs": [
    { "id": "out_l", "label": "L", "kind": "audio", "channels": 2 },
    { "id": "out_r", "label": "R", "kind": "audio", "channels": 2 }
  ],
  "params": [
    { "type": "hslider", "id": "time_ms", "label": "Time", "init": 250,
      "min": 1, "max": 2000, "step": 0.1, "unit": "ms", "style": "knob" },
    { "type": "hslider", "id": "feedback", "label": "Feedback", "init": 0.4,
      "min": 0, "max": 0.95, "step": 0.001, "style": "knob",
      "smoothing_class": "mix" },
    { "type": "hslider", "id": "mix", "label": "Mix", "init": 0.4,
      "min": 0, "max": 1, "step": 0.001, "style": "knob",
      "smoothing_class": "mix" }
  ],
  "toBox": {
    "defs": {
      "samples": "intCast(over(times(param('time_ms'), lib('ma', 'SR')), int(1000)))",
      "wet_l": "seq(plus(input('in_l'), times(mem(input('in_r')), param('feedback'))), lib('de', 'delay', int(96000), def('samples')))",
      "wet_r": "seq(plus(input('in_r'), times(mem(input('in_l')), param('feedback'))), lib('de', 'delay', int(96000), def('samples')))"
    },
    "outputs": {
      "out_l": "plus(times(input('in_l'), minus(real(1), param('mix'))), times(def('wet_l'), param('mix')))",
      "out_r": "plus(times(input('in_r'), minus(real(1), param('mix'))), times(def('wet_r'), param('mix')))"
    }
  }
}

defs.samples resolves first (referenced by both wet_l + wet_r), then the two wet paths, then the dry+wet mix per output. Notice mem(input('in_r')) in wet_l — that's the ping-pong feedback: L's delay reads from the previous-sample R, R reads from previous L. The channels: 2 on every port + every paired pair flags this as a stereo-bundle Kind (see § 3).

Substitution semantics — what fires when

When the Builder compiles a kind instance, the adapter (packKindAdapter.ts) walks toBox:

  1. Resolve params — each ParamDef becomes a Faust source string with the Sound-Quality wraps applied (smoothing → velocity → drift in that order). Wrapped in a raw Box.
  2. Resolve inputs — each input port becomes either the upstream's emitted expression (if wired) or the literal 0 (if unconnected).
  3. Walk defs in declaration order — each def's expression parses against { params, inputs, defs-so-far, literals, faustId }. Later defs can reference earlier ones via def('name').
  4. Walk outputs — same env, plus all defs in scope.
  5. Each output's Box becomes the value for its port id in the kind's toBox(ctx) return.

The Builder's faustGen then plugs the per-kind output Boxes into the graph's wire structure, wraps multi-voice / FX-shape / vgroup as needed, and emits the final Faust source.

Common patterns

  • Mono effect: seq(input('in'), lib(...)). The classic shape — take input, transform, emit.
  • Param-controlled lib call: lib('fi', 'lowpass', int(2), param('cutoff'), input('in')). Standard 2nd-order Butterworth.
  • Mix dry/wet: plus(times(input('in'), minus(real(1), param('mix'))), times(def('wet'), param('mix'))). The 1−mix on dry pattern, with the wet path in a def for reuse.
  • Feedback line: mem(...) wraps a one-sample memory; combine with plus inside a seq for delay lines + comb filters.
  • Stereo splitter: par(input('in_l'), input('in_r')). Brings paired ports into a single (2, 2) Box for chaining through a stereo lib like re.jpverb.
Tip

when stuck, look at how the bundled packs do it. packages/app/build-resources/bundled-node-packs/*.nodepack.json are all Box-DSL — every category of toBox you'd want to write has a precedent there.

Try it yourself — the IDE runs in your browser. Open the IDE → Get the desktop app

Text licensed under CC BY 4.0 — Mani Weber / FaustWave. For language models: llms.txt · llms-full.txt

AUDIO · 48k · 48.0ms FAUST · 3 KB · 1171 docs CPU · 8.4% BPM · 120.0 UTF-8 BETA· v0.90.0