gen~ turns an embedded Gen patcher into a fully compiled, sample-accurate DSP routine that runs on the audio thread. Inside M4L, reach for gen~ when:
- You need single-sample feedback (filters with
history,delay 1) - You need sample-accurate buffer reads/writes (granular, scrubbing)
- You're writing a hand-coded oscillator where vector-rate MSP would alias or quantize timing
- You need frequency-domain operations inside
pfft~
Don't use gen~ for control-rate logic — that belongs in JS or message-domain Max objects.
1. codebox — the GenExpr text operator
A codebox inside a gen~ patcher holds GenExpr code. Its inlets/outlets are derived from the code: the compiler scans for inN / outN keywords where N is the index.
// single-pole lowpass (1-pole IIR)
Param cutoff(0.1, min=0.001, max=0.99);
History y1(0);
y1 = y1 + cutoff * (in1 - y1);
out1 = y1;
Wrap that codebox in a gen~ subpatcher with an in 1 and out 1 box and you have an audio-rate filter with a cutoff parameter accessible from outside.
2. GenExpr language essentials
- Syntax is C / JavaScript-like.
- Single-expression line: trailing
;optional. Multi-statement: required. - Comments:
//and/* ... */. - I/O:
in1,in2, …,out1 = …,out2 = …. Numbered, not named. - Multi-return:
r, theta = cartopol(x, y);— unused returns are eliminated by the optimizer. - Control flow:
if/else,for,while,break,continue. The compiler protects against infinite loops. - Custom functions: define before main statements.
Operators
Arithmetic: + - * / %
Comparison: == != < > <= >=
Logical: && || !
Bitwise: & | ^ ~ << >>
Math functions: sin cos tan asin acos atan atan2 exp log log2 log10 sqrt pow abs sign min max clamp wrap fold mix lerp scale
DSP-specific: phasor click train rate cycle noise delta change history delay sah peek poke sample lookup nearest wave splat
3. Declarations
Param — externally settable, persisted
Param name (default, min=lo, max=hi);
Each Param becomes an inlet / message-settable parameter on the wrapping gen~. From outside Max:
- Send
cutoff 0.5to set - Connect a
live.dial(withparameter_enableandvalueofblock) to agen~inlet labeled with the same param name to make it Live-automatable
Range constraints clamp inputs. Default is used at instantiation.
History — single-sample state
History y1(0); // initial value 0
y1 = y1 + 0.01 * (in1 - y1); // implicit Z⁻¹ — read y1, then assign new value, next sample reads the new value
out1 = y1;
History IS the Z⁻¹ register. Reading y1 returns the previous sample's value; writing updates it for the next sample. This is the unique GenExpr feature — MSP cannot do this with pow_pow_one~-style feedback at single-sample precision.
History is also externally settable/readable with the same naming rule as Param.
Delay — multi-sample delay line
Delay buf(48000); // max delay in samples
buf.write(in1);
out1 = buf.read(samplerate * 0.25); // 250 ms tap
buf.write(x), buf.read(samples), buf.read(samples, interpolation) where interpolation = linear, cubic, spline, cosine.
Buffer — reference an external buffer~
Buffer mybuf("kick");
out1 = peek(mybuf, phasor(1) * dim(mybuf), 0); // play through "kick" buffer
Buffer operators: peek (interpolated read by sample frame), poke (write), sample (interpolated read by phase 0..1), lookup (table lookup), nearest, wave (resampled), splat (accumulating write).
dim(buf) = length in samples. channels(buf) = channel count.
Data — local storage (read/write at sample rate)
Data mybuf(512, 1); // 512 samples, 1 channel
poke(mybuf, in1, in2);
out1 = peek(mybuf, in3);
Default Data(512, 1). 64-bit floats. Persists for the lifetime of the gen~ object.
4. Integration with the outer patcher
A gen~ subpatcher receives signals through in N boxes and emits via out N boxes inside the gen patcher (these are gen-specific, not the Max inlet/outlet classes).
- Audio signals flow through
in/out. - Param values are settable via messages or by connecting a signal to a matching
paraminlet — Max routescutoff 0.5to the codebox'sParam cutoff. - History initial values can be reset by sending
history y1 0to thegen~.
Driving Params from live.dial
[live.dial @parameter_longname cutoff @parameter_mmin 0.001 @parameter_mmax 0.99]
|
v
[gen~ filter @t cutoff] ;; --- or send via a [s cutoff] / [r cutoff] inside gen
Or directly via a message connected to the gen~ inlet labeled cutoff. Inside the gen patcher, the Param cutoff(...) declaration creates that inlet automatically.
5. mc.gen~ — multichannel
mc.gen~ runs the same GenExpr body in N parallel voices, one per channel of the wrapping object. Used for polyphonic synthesis without poly~:
[mc.gen~ @chans 8 voice]
Each channel has its own History state. Configure voice count via @chans attribute or chans <n> message.
mc.live.gain~ etc. integrate with Live 11+ multichannel routing.
6. pfft~ integration
For frequency-domain DSP:
[pfft~ fft-patch 2048 4] ;; FFT size 2048, overlap 4
Inside the pfft~ subpatcher, you can use gen~ for sample-accurate per-bin processing. The bin streams carry real/imag pairs.
7. When NOT to use gen~
- Control-rate logic (parameter scaling, MIDI processing) — use JS or Max objects.
- Per-block DSP that vanilla MSP handles fine (a
delay~cascade,cycle~+*~). - LiveAPI traffic — gen~ has no access to it.
8. Common GenExpr mistakes
- Forgetting
Historyfor feedback — writingy = y * 0.5inside a codebox without declaringHistory ywon't compile. - Using
Paramnames that don't show up on the gen~ object — they appear as message destinations and as inlet labels visible in the gen~ Inspector; check. - Floating-point comparison — never
if (x == 0.0); useif (abs(x) < 1e-6). - Loop bounds at compile time —
for (i = 0; i < n; i += 1)requiresnto be a constant orParam; not all expressions are allowed. Buffername collisions — a Buffer declaration likeBuffer kick("kick")references thebuffer~named "kick" in Max; if a buffer with that name doesn't exist, the gen~ silently does nothing.- No floats below 0 or above 1 for
peekphase argument when usingsample(buf, phase)— phase is 0..1, not 0..length. out1not assigned — gen~ emits silence. Always set every declared output.samplerateinside expressions —samplerateis a built-in constant (samples per second) for use in calculations.
9. Worked example: stereo allpass reverb
// Schroeder-style allpass
Param feedback(0.7, min=0, max=0.95);
Param dryWet(0.5, min=0, max=1);
Delay ap1(2048);
Delay ap2(2048);
Delay ap3(2048);
Delay ap4(2048);
ap_size = [347, 113, 37, 59]; // sample delays
x = in1;
y = x;
History h1(0);
v = h1 + (-feedback) * y;
ap1.write(v);
y = ap1.read(ap_size[0]) + feedback * v;
h1 = y;
// ...repeat for ap2..4
out1 = mix(in1, y, dryWet);
out2 = mix(in2, y, dryWet);
Drive feedback and dryWet from live.dials in the outer patcher's presentation mode.
10. Lifecycle
- gen~ recompiles on edit. There's no visible "compile" step — just save.
- Compilation errors appear in the Max Console with line numbers in the codebox.
- gen~ patchers can be exported to C++ (gen.exportcode) for use as a standalone DSP plugin or VST/AU — but this is outside M4L's scope.
11. Recommended workflow
- Sketch the DSP in MSP first if possible; only move to gen~ for the parts that genuinely need single-sample feedback or sample-accurate buffer access.
- Wrap the gen~ in a
bpatchersubpatch so the params are clearly visible from the outer patch. - Expose every
Paramas alive.dial/live.numboxin the outer patcher (seem4l-live-api). - Test with extreme values — gen~ doesn't auto-protect from blowups; add
clamp/tanhsaturation where feedback paths can grow. - Freeze the device before distribution so the embedded gen patcher travels with the
.amxd.