gen_dsp.graph Operator Coverage vs gen~ Operators¶
This document compares the operators available in gen_dsp.graph against the operators documented in the gen~ operator reference and gen common operator reference. It is both a per-operator coverage matrix (below) and the forward-looking gap analysis (roadmap and recommendation at the end).
Last updated: 2026-06-28
Scope and framing¶
Coverage is not a subset relation, and "close the gap" should not be read as "reach parity with gen~". The graph frontend exists to exercise gen-dsp's platform backends without requiring gen~ exports; the zero-dependency C++-export path (core/parser.py) already handles real gen~ patches for plugin generation. So the target is not full gen~ semantics -- it is enough scalar and stateful coverage that backend tests are representative, plus one escape hatch for everything else.
Two asymmetries matter:
- The graph is a pure dataflow DAG; gen~ / GenExpr is an imperative language. The whole pipeline (
toposort,simulate,optimize,transpile, and the 11 backends) depends on the acyclic scalar-dataflow invariant. Some gaps are cheap node additions; others cannot be expressed without breaking that invariant (see "Structural / semantic gaps" below). - gen-dsp is not strictly narrower than gen~. It adds higher-level DSP blocks that gen~ has no primitive for (
Biquad,SVF,OnePole,Allpass,ADSR, and theSinOsc/TriOsc/SawOsc/PulseOscfamily). So the frontend is a curated, higher-level DSP vocabulary: narrower on language expressiveness, wider on ready-made blocks.
The honest one-line summary: the frontend covers gen~'s straight-line scalar arithmetic, single-sample state, and 1-D buffer/delay access essentially completely, and adds convenience DSP blocks on top. It cannot express gen~'s imperative layer (loops, conditionals, functions), the spectral/complex domain, multichannel or host-bound buffers, or integer-mode operators.
Recent additions (2026-06-28)¶
Tier 1 of the roadmap below was implemented and fully tested (full suite green). Each item was wired through every touchpoint -- model, C++ emitter, simulator, constant-fold/CSE, transpiler, differential evaluator, DSL, serializer, visualizer -- with a dedicated test module and differential-corpus cases.
- Bitwise
bitand/bitor/bitxor/bitshift/bitnot. Shared 32-bit-int scalar semantics live ingraph/bitops.pyso the three Python evaluators (simulate, fold, transpile-eval) cannot drift. gen~bitshiftdirection is a defined-but-Max-unverified convention (non-negative shifts left, negative right, amount masked to[0, 31]). interpnode withlinearandcosinemodes (theMix-is-linear-only gap). 4-point cubic/spline is deferred.nearestinterpolation mode onBufRead/DelayRead(round-half-up index vsnone's truncation).trainimpulse-generator node (one-sample1.0each cycle, distinct from the bipolarPulseOsc).- Deferred:
rate(ambiguous gen~ ramp-resync semantics;RateDivalready covers rate-division).
Five demo patches in examples/dsl/ exercise these: bitcrush, bitglitch, sh_sequencer, wavemorph, lofi_wavetable.
Summary¶
| Category | gen~ Total | Implemented | Coverage |
|---|---|---|---|
| Math / Arithmetic | 12 | 12 | 100% |
| Comparison | 15 | 15 | 100% |
| Logic | 5 | 5 | 100% |
| Bitwise | 5 | 5 | 100% |
| Trigonometry | 19 | 19 | 100% |
| Powers | 9 | 9 | 100% |
| Numeric | 7 | 7 | 100% |
| Constants | 17 | 15 | 88% |
| Range | 4 | 4 | 100% |
| Route / Mixing | 7 | 5 | 71% |
| Filter | 8 | 8 | 100% |
| Waveform / Oscillators | 5 | 5 | 100% |
| Integrator / State | 3 | 3 | 100% |
| Feedback / Delay | 2 | 2 | 100% |
| Buffer / Data | 12 | 8 | 67% |
| Convert | 6 | 6 | 100% |
| DSP | 6 | 6 | 100% |
| FFT | 1 | 0 | 0% |
| Global | 5 | 1 | 20% |
| I/O / Declare | 4 | 3 | 75% |
| Subpatcher | 2 | 1 | 50% |
| Total | ~154 | ~139 | ~90% |
Note: Some gen~ operators have aliases (e.g. ln/log, clip/clamp). Each unique function is counted once. The "p" comparison variants (eqp, gtp, etc.) are counted as separate operators since they have distinct semantics (return value vs return 1/0).
Detailed Coverage by Category¶
Math / Arithmetic¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
+ / add |
BinOp("add") |
|
- / sub |
BinOp("sub") |
|
* / mul |
BinOp("mul") |
|
/ / div |
BinOp("div") |
|
% / mod |
BinOp("mod") |
|
neg |
UnaryOp("neg") |
|
absdiff |
BinOp("absdiff") |
|
pow |
BinOp("pow") |
|
hypot |
BinOp("hypot") |
|
!- / rsub |
BinOp("rsub") |
Reverse subtract: b - a |
!/ / rdiv |
BinOp("rdiv") |
Reverse divide: b / a |
!% / rmod |
BinOp("rmod") |
Reverse modulo: fmod(b, a) |
Full coverage.
Comparison¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
> / gt |
Compare("gt") |
|
< / lt |
Compare("lt") |
|
>= / gte |
Compare("gte") |
|
<= / lte |
Compare("lte") |
|
== / eq |
Compare("eq") |
|
!= / neq |
Compare("neq") |
|
max / maximum |
BinOp("max") |
|
min / minimum |
BinOp("min") |
|
step |
BinOp("step") |
|
>p / gtp |
BinOp("gtp") |
Returns a if a > b, else 0 |
<p / ltp |
BinOp("ltp") |
Returns a if a < b, else 0 |
>=p / gtep |
BinOp("gtep") |
Returns a if a >= b, else 0 |
<=p / ltep |
BinOp("ltep") |
Returns a if a <= b, else 0 |
==p / eqp |
BinOp("eqp") |
Returns a if a == b, else 0 |
!=p / neqp |
BinOp("neqp") |
Returns a if a != b, else 0 |
Full coverage.
Logic¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
! / not |
UnaryOp("not") |
|
&& / and |
BinOp("and") |
|
\|\| / or |
BinOp("or") |
|
^^ / xor |
BinOp("xor") |
|
bool |
UnaryOp("bool") |
Full coverage.
Bitwise¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
& / bitand |
BinOp("bitand") |
Operates on the 32-bit-int value |
\| / bitor |
BinOp("bitor") |
|
^ / bitxor |
BinOp("bitxor") |
|
<< >> / bitshift |
BinOp("bitshift") |
b >= 0 left, b < 0 right |
~ / bitnot |
UnaryOp("bitnot") |
Full coverage.
Trigonometry¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
sin |
UnaryOp("sin") |
|
cos |
UnaryOp("cos") |
|
tan |
UnaryOp("tan") |
|
asin |
UnaryOp("asin") |
|
acos |
UnaryOp("acos") |
|
atan |
UnaryOp("atan") |
|
atan2 |
BinOp("atan2") |
|
sinh |
UnaryOp("sinh") |
|
cosh |
UnaryOp("cosh") |
|
tanh |
UnaryOp("tanh") |
|
asinh |
UnaryOp("asinh") |
|
acosh |
UnaryOp("acosh") |
|
atanh |
UnaryOp("atanh") |
|
hypot |
BinOp("hypot") |
|
degrees |
UnaryOp("degrees") |
a * 180/pi |
radians |
UnaryOp("radians") |
a * pi/180 |
fastsin |
UnaryOp("fastsin") |
Bhaskara I approximation in C++ |
fastcos |
UnaryOp("fastcos") |
Bhaskara I approximation in C++ |
fasttan |
UnaryOp("fasttan") |
sinf/cosf ratio in C++ |
Full coverage.
Powers¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
exp |
UnaryOp("exp") |
|
exp2 |
UnaryOp("exp2") |
|
ln / log |
UnaryOp("log") |
|
log2 |
UnaryOp("log2") |
|
log10 |
UnaryOp("log10") |
|
pow |
BinOp("pow") |
|
sqrt |
UnaryOp("sqrt") |
|
fastexp |
UnaryOp("fastexp") |
Schraudolph's method in C++ |
fastpow |
BinOp("fastpow") |
exp2(b * log2(a)) in C++ |
Full coverage.
Numeric¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
abs |
UnaryOp("abs") |
|
ceil |
UnaryOp("ceil") |
|
floor |
UnaryOp("floor") |
|
trunc |
UnaryOp("trunc") |
|
fract |
UnaryOp("fract") |
|
round |
UnaryOp("round") |
|
sign |
UnaryOp("sign") |
Full coverage.
Constants¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
pi |
NamedConstant("pi") |
|
twopi |
NamedConstant("twopi") |
|
halfpi |
NamedConstant("halfpi") |
|
invpi |
NamedConstant("invpi") |
|
e |
NamedConstant("e") |
|
degtorad |
NamedConstant("degtorad") |
|
radtodeg |
NamedConstant("radtodeg") |
|
sqrt2 |
NamedConstant("sqrt2") |
|
sqrt1_2 |
NamedConstant("sqrt1_2") |
|
ln2 |
NamedConstant("ln2") |
|
ln10 |
NamedConstant("ln10") |
|
log2e |
NamedConstant("log2e") |
|
log10e |
NamedConstant("log10e") |
|
phi |
NamedConstant("phi") |
|
samplerate |
SampleRate |
Runtime node |
vectorsize |
-- | Runtime constant |
constant / f / i |
Constant |
Literal values |
Missing: vectorsize (block size -- not relevant to per-sample graph processing).
Range¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
clamp / clip |
Clamp |
|
wrap |
Wrap |
|
fold |
Fold |
|
scale |
Scale |
Full coverage.
Route / Mixing¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
? / switch |
Select |
Conditional select |
gate |
GateRoute + GateOut |
Container + satellite |
selector |
Selector |
N-to-1 mux |
mix |
Mix |
Linear interpolation |
smoothstep |
Smoothstep |
|
send / s |
-- | Named signal bus |
receive / r |
-- | Named signal bus |
Missing: send/receive (named signal buses). These are a graph-level wiring abstraction -- in the graph frontend, nodes reference each other by ID, which serves the same purpose.
Filter¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
change |
Change |
|
dcblock |
DCBlock |
|
delta |
Delta |
|
latch |
Latch |
|
sah |
SampleHold |
Sample-and-hold |
slide |
Slide |
Asymmetric slew limiter |
phasewrap |
UnaryOp("phasewrap") |
Wrap to [-pi, pi] |
interp |
Interp |
Two-point interpolation (linear/cosine) |
Full coverage for the two-point modes. gen~'s 4-point interp (cubic/spline) is deferred; the buf_read/delay_read cubic paths already cover 4-point table interpolation.
Waveform / Oscillators¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
noise |
Noise |
|
phasor |
Phasor |
|
train |
Train |
Impulse train (PulseOsc is the bipolar pulse oscillator) |
triangle |
TriOsc |
|
rate |
-- | Phase rate-scaling (composable) |
Note: gen_dsp.graph also provides SinOsc and SawOsc which are not direct gen~ operators (gen~ uses cycle for sine lookup and manual phasor+math for saw). The rate operator is phase multiplication, trivially expressed as BinOp("mul").
Integrator / State¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
+= / accum |
Accum |
|
counter |
Counter |
|
*= / mulequals |
MulAccum |
Multiplicative accumulator |
Full coverage.
Feedback / Delay¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
delay |
DelayLine + DelayRead + DelayWrite |
|
history |
History |
Single-sample feedback |
Full coverage.
Buffer / Data¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
data |
Buffer |
Internal sample array |
peek |
BufRead |
Read from buffer |
poke |
BufWrite |
Write to buffer |
dim |
BufSize |
Buffer length query |
cycle |
Cycle |
Sine wavetable oscillator |
lookup |
Lookup |
Waveshaping lookup |
wave |
Wave |
Wavetable synthesis |
splat |
Splat |
Overdub write (buf[idx] += value) |
buffer |
-- | External buffer~ ref |
channels |
-- | Multi-channel query |
nearest |
BufRead(interp="nearest") |
Round-to-nearest index read |
sample |
BufRead(interp="linear") |
Normalized/interpolated read |
Missing: buffer (external buffer~ references are out of scope -- see the Tier 2 spike finding below), channels (no multi-channel buffer support).
Convert¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
atodb |
UnaryOp("atodb") |
20 * log10(a) |
dbtoa |
UnaryOp("dbtoa") |
pow(10, a/20) |
ftom |
UnaryOp("ftom") |
69 + 12*log2(a/440) |
mtof |
UnaryOp("mtof") |
440 * pow(2, (a-69)/12) |
mstosamps |
UnaryOp("mstosamps") |
a * sr / 1000 (sr-dependent) |
sampstoms |
UnaryOp("sampstoms") |
a * 1000 / sr (sr-dependent) |
Full coverage. Note: mstosamps, sampstoms are sample-rate dependent and cannot be constant-folded.
DSP Utilities¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
fixdenorm |
UnaryOp("fixdenorm") |
Replace denormals with 0 |
fixnan |
UnaryOp("fixnan") |
Replace NaN with 0 |
isdenorm |
UnaryOp("isdenorm") |
Denormal detection (1 or 0) |
isnan |
UnaryOp("isnan") |
NaN detection (1 or 0) |
t60 |
UnaryOp("t60") |
Decay coefficient: exp(-6.9078/(a*sr)) |
t60time |
UnaryOp("t60time") |
Inverse decay time: -6.9078/(log(a)*sr) |
Full coverage.
FFT¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
fftinfo |
-- | FFT frame info |
Not implemented. FFT processing (pfft~) is out of scope for the graph frontend.
Global / Environment¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
elapsed |
Elapsed |
Sample counter |
mc_channel |
-- | MC channel index |
mc_channelcount |
-- | MC channel count |
voice |
-- | Poly voice index |
voicecount |
-- | Poly voice count |
Missing: mc_channel, mc_channelcount, voice, voicecount -- these are Max/MSP environment queries, out of scope for the graph frontend.
I/O and Declaration¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
in |
AudioInput |
Graph-level input |
out |
AudioOutput |
Graph-level output |
param |
Param |
Named parameter |
expr |
-- | Inline GenExpr |
expr (inline GenExpr code) is not applicable to the graph frontend.
Subpatcher¶
| gen~ Operator | gen_dsp.graph | Notes |
|---|---|---|
gen (subpatcher) |
Subgraph |
Nested graph |
setparam |
-- | Set subpatch param |
setparam is handled implicitly by Subgraph.params mapping.
Additional gen_dsp.graph Nodes (No gen~ Equivalent)¶
These nodes exist in gen_dsp.graph but have no direct gen~ operator counterpart:
| Node | Description |
|---|---|
SinOsc |
Direct sine oscillator (gen~ uses cycle with wavetable) |
SawOsc |
Direct sawtooth oscillator (gen~ uses phasor + scaling) |
SVF |
State-variable filter (gen~ builds from primitives) |
Biquad |
Direct biquad filter (gen~ builds from history + math) |
Allpass |
Direct allpass filter (gen~ builds from history + math) |
OnePole |
Direct one-pole filter (gen~ builds from history + math) |
SmoothParam |
Parameter smoothing (gen~ uses slide or history) |
RateDiv |
Rate divider (gen~ uses counter + latch) |
ADSR |
Attack-Decay-Sustain-Release envelope generator |
Peek |
Debug/passthrough (different from gen~'s buffer peek) |
Pass |
Identity node |
These higher-level nodes provide convenience abstractions that would require multiple gen~ operators to implement.
Remaining Gaps¶
Out of Scope¶
-
FFT operators (
fftinfo,fftsize, etc.) -- no pfft~ support -
Max/MSP environment (
voice,mc_channel, etc.) -- host-specific -
send/receive-- graph wiring abstraction (nodes reference by ID)
Trivially Composable¶
rate-- phase multiplication viaBinOp("mul")(a dedicated node is deferred; ambiguous gen~ semantics)
Not Yet Implemented¶
-
buffer-- external buffer~ references -
channels-- multi-channel buffer query -
vectorsize-- block size constant -
setparam-- handled bySubgraph.params -
expr-- inline GenExpr (not applicable)
Structural / semantic gaps (beyond the operator matrix)¶
The matrix above counts operators. The larger gaps are not missing operators but things the IR cannot represent by construction, because a Graph is a pure dataflow DAG:
- Imperative control flow and loops. GenExpr codeboxes have
if/else,for,while, local variables, and local arrays. Aforloop over buffer samples (the idiom for block / FFT-style work, custom convolution, variable-length scans) has no expressible form in a node DAG. This is the single largest gap; the transpiler emits into GenExpr but only uses its straight-line subset. - User-defined functions. GenExpr allows
f(x){...}declarations and calls.Subgraphcovers macro inlining and reuse, but not recursion or arbitrary-arity local functions. - Spectral / complex domain:
fft,ifft,fftinfo, and complex arithmetic (cartopol,poltocar, complex multiply). Absent entirely. - Multichannel buffers. gen~
Data/Bufferare N-channel; the frontendBufferis strictly mono (1-D). - Host-bound buffers. gen~
Buffer "name"references a Maxbuffer~by name; the frontendBufferis internal-only withfill in {zeros, sine}.
Roadmap: what could be added¶
Tiers are ordered by how much they disturb the dataflow-DAG invariant. The tier boundary, not the operator count, drives the decision.
Tier 1 -- new node types, zero IR change (done)¶
The cheap, in-scope additions -- each a Pydantic class in models.py plus emission in compile/nodes.py / simulate.py / transpile.py, a validate.py rule, and tests. This tier is implemented (bitwise, interp linear/cosine, nearest, train); see "Recent additions" above. rate and 4-point cubic/spline interp are deferred.
Tier 2 -- model extensions that bend the DAG but do not break it¶
Still dataflow, but they touch the buffer / IO model and ripple into adapter.py and each backend's buffer-header generation: multichannel buffers (channels on Buffer, a channel argument on the buffer ops, a channels node), host-bound buffers (let Buffer reference external sample data), and a vectorsize node. Rejected for the graph path -- see the spike finding below.
Tier 2 spike finding (2026-06-28): rejected for the graph path¶
A design spike for the combined "multichannel + host-bound buffers" path found it leads to a mess and was not implemented. The decisive facts:
- The multichannel host-buffer machinery already exists in the genlib / platform layer, not the graph path. A host buffer is a
DataInterface(channel-aware C++ class); the WebAudio backend already loads interleaved multichannel data viawrapper_load_buffer(index, data, frames, channels)withWebaudioBuffer : DataInterfacestoringdata[frame*channels + ch]. So multichannel sample playback is already achievable today on the platform built for it, through the gen~-export path. - The graph compile path is deliberately genlib-free: buffers are raw
float*arrays, noDataInterface. Giving them multichannel host loading would re-implement a channel-aware buffer abstraction next to the real one -- exactly the drift the header-isolation / manifest split exists to prevent. - The shared
ManifestIR carries only buffer names (buffers: list[str]). Adding size/channels metadata ripples into the gen~-export producer (manifest_from_export_info), which has no source for buffer channel counts. - Host buffer loading is per-platform and already fragmented: only WebAudio has a real multichannel bridge; the graph adapter hard-stubs
wrapper_load_buffer(...) { return -1; }everywhere else.
Routing by goal instead: multichannel sample playback in a shipping plugin -> use the genlib/export path (WebAudio already does it). Multichannel / wavetable synthesis inside the graph frontend -> the only self-contained win is small (Buffer.channels plus a 2D/bilinear lookup node, filled procedurally, no Manifest / IR / platform changes). Otherwise -> defer.
Tier 3 -- breaks the pure-DAG invariant (expensive, mostly out of scope)¶
Loops, if/for/while, local functions, and FFT/complex cannot be node-graph dataflow. Two options, the first not recommended:
- Rebuild the IR to a control-flow graph, or let nodes contain sub-blocks. This contaminates
toposort,simulate,optimize, and the differential harness, all of which assume acyclic scalar dataflow. High cost, high blast radius -- justified only if authoring in the graph frontend becomes a real goal, which it is not under the current project purpose. - An escape-hatch node:
RawCodebox/RawExprholding literal GenExpr (and/or literal C++) with declared input refs and output ids. The transpiler emits it verbatim;compile/nodes.pyinlines it;validatechecks only the I/O wiring;simulateandoptimizetreat it as opaque, andtranspile_evalskips it exactly as it skipsNON_DETERMINISTIC_OPSfornoise. This unblocks loops, functions, and FFT without touching the IR for the graphs that do not need them. The price is honest and bounded: a raw node is a black box to the optimizer and the differential harness.
FFT as first-class high-level nodes is separately large because the standalone C++ path has no genlib FFT to lean on; it would mean shipping an FFT implementation across backends. Worth it only with concrete spectral demand.
Recommendation¶
Done: Tier 1 operators (bitwise, train, interp linear/cosine, nearest) -> shipped, full suite green
Deferred: rate (ambiguous gen~ semantics) and 4-point cubic/spline interp
Do next: RawCodebox escape-hatch node -> unblocks loops/functions/FFT without IR damage
Rejected: multichannel + host buffers for the graph path -> duplicates the genlib path (spike finding)
Maybe: small 2D/bilinear lookup node -> only if graph-frontend wavetable synthesis is wanted
Don't: rebuild IR for native control flow -> wrong cost/scope for a backend-testing tool
Tier 1 plus the RawCodebox escape hatch reaches the actual goal (representative backend coverage, with an outlet for everything else) at a fraction of the cost of Tier 3.