Tzopilotl
Docs
GitHub

Writing SynthDefs

7. Controls

A control is a named, settable parameter of the synth. Controls are event-rate sources: the engine delivers value changes as events, and only the subgraph that depends on a changed control recomputes. Set them from Tzopilotl with the audio_engine bridge — setControl(nodeID, controlID, value), where controlID is the control's index in declaration order — or let the app's UI generate widgets from them — the control's spec and kind determine the widget (see Live Controls and Notebooks).

Constructors

FunctionKindDescription
control(name, spec, chans = 1)continuous Ranged value; UIs render a slider.
trigger(name, chans = 1)trigger Momentary event (0..1, init 0). A nonzero arrival is a one-shot edge — retrigger an envelope, reset a phase. UIs render a button.
toggle(name, init = 0.0, chans = 1)boolean Latched 0/1 state. UIs render a toggle.
choice(name, numChoices, init = 0, chans = 1)select Integer choice 0 .. numChoices-1 in unit steps. UIs render a number box. Pairs naturally with switch (§11).
noteParam(name, spec, chans = 1) Per-note parameter inside a voicer body (§12).
gate() The implicit per-note gate: noteParam("gate", …), high while the note is held.

ControlSpec and warps

struct ControlSpec { lo Float, hi Float, init Float, warp ControlWarp }

enum ControlWarp { linear, exponential, step Float, signedSquare, cubed }

The spec gives the control's range, initial value, and warp — the curve a UI uses to map slider position to value (exponential for frequencies and gains, step(sz) for quantized values, signedSquare / cubed for finer resolution near zero). The warp does not change what your graph receives; it shapes the widget.

Smoothing. Control changes arrive as steps. For anything audible (gains, frequencies, filter cutoffs), smooth the control with lag to avoid zipper noise. This is the standard output-gain idiom used by the examples:
fn gainOutlet(x S) S {
    let gain = control("gain",
        ControlSpec{0.001, 1.0, 0.2, ControlWarp.exponential}) lag(0.1);
    x * gain |> outlet
}

Shared input: the mouse without messages

A control change is a message: it is bundled, scheduled, and delivered to one node. For values that change continuously and matter to every playing synth — the mouse position — the engine keeps a small shared-input table instead: the app writes it in the background (~60× a second for the mouse), and any graph reads it at audio rate with sharedIn. No messages are sent at all.

FunctionDescription
sharedIn(slot, rate = Rate.audio) Reads one table slot (a single f32 channel). Rate.init samples it once at synth start. Slots 0..2 are the mouse (x, y, button); slots 3..15 are free for your own values.
mouseX(lo = 0.0, hi = 1.0, lagTime = 0.2) (common_ugens) Mouse x mapped linearly: lo at the left screen edge, hi at the right.
mouseY(lo = 0.0, hi = 1.0, lagTime = 0.2) (common_ugens) Mouse y mapped linearly: lo at the bottom of the screen, hi at the top.
mouseXExp(lo, hi, lagTime = 0.2)
mouseYExp(lo, hi, lagTime = 0.2)
(common_ugens) Exponential mapping, for frequencies and gains. lo and hi must be nonzero and the same sign.
mouseButton() (common_ugens) 1 while the primary button is held, else 0. Lag it to soften the edges.
-- theremin: x = pitch, y = amplitude; every instance follows the mouse
fn theremin() S =
    sinosc(mouseXExp(110.0, 1760.0)) * mouseY(0.0, 0.5) |> outlet;

From the language side, setSharedInput(slot, value) writes a free slot and getSharedInput(slot) reads one back (audio_engine module) — a lightweight channel for any continuously varying value that would otherwise be a stream of setControl messages. Two things to keep in mind: shared-input changes are not sample-accurately scheduled (use controls when timing must be exact), and the raw value steps at the writer's update rate, so smooth it with lag as the mouse ugens do.

↑ Back to top