The Box-DSL — toBox syntax
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.tsitself 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/outputscounts — 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
| Constructor | Emits | Arity | Notes |
|---|---|---|---|
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, …) | depends | For 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
| Constructor | Emits | Notes |
|---|---|---|
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-1 | Used 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
| Constructor | Emits | Notes |
|---|---|---|
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:
- Resolve
params— eachParamDefbecomes a Faust source string with the Sound-Quality wraps applied (smoothing → velocity → drift in that order). Wrapped in arawBox. - Resolve
inputs— each input port becomes either the upstream's emitted expression (if wired) or the literal0(if unconnected). - Walk
defsin declaration order — each def's expression parses against{ params, inputs, defs-so-far, literals, faustId }. Later defs can reference earlier ones viadef('name'). - Walk
outputs— same env, plus all defs in scope. - 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 adeffor reuse. - Feedback line:
mem(...)wraps a one-sample memory; combine withplusinside aseqfor 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 likere.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.