Tzopilotl — Live Controls & Notebooks
Building performance surfaces with the ui module, and working with notebook documents in the app.
1. Overview
The app is both a code editor and a host for live control surfaces. The ui module creates widgets — sliders, XY pads, meters, scopes, spectrum analyzers — from code, and binds them to running audio. There are two places widgets can live:
- Floating panel windows. Evaluate
uicode from any.xeditor tab (or the REPL) and widgets appear in floating windows over the editor. Nothing else changes; this works exactly like the editor always has. - Notebook documents. A notebook (File > New Notebook) replaces the editor pane with a vertical list of cells: prose, runnable code, and panel cells that embed widgets right in the document. Notebooks save to
.tzdfiles, remember widget values, and have undo/redo over a persistent history tree.
The guiding rule of the whole design: code creates behavior; the document stores state. Widgets are always created and bound by running code. What the document persists is the widgets' names, ranges, and current values — never their bindings. Re-running the code is what brings a loaded document back to life (see §4.5).
Two sources of truth, one rule. If you wonder “where does X live?”: behavior (which node a slider drives, what a callback does) lives in code; state (the slider's position, the cell order) lives in the document.
2. Quick Start
Paste this into an editor tab and evaluate it (Cmd+Shift+Enter evaluates the whole file):
-- A synth with two declared controls
import synthdef.*;
import synthc.compile.*;
import common_ugens.*;
import audio_engine.*;
import ui.*;
fn demo() S {
let freq = control("freq", ControlSpec { lo: 50.0, hi: 2000.0, init: 220.0, warp: ControlWarp.exponential });
let amp = control("amp", ControlSpec { lo: 0.0, hi: 1.0, init: 0.2, warp: ControlWarp.linear });
freq sinosc(0) mul(amp) outlet
}
demo defSynthX("demo") await;
let node = play("demo");
-- One line: a slider per control, named, ranged, warped, and bound
-- from the synthdef's own ControlSpecs.
controls(node);
-- And an oscilloscope on its output.
scope("out", node);
A window named demo appears with a freq slider (exponential, 50–2000 Hz) and an amp slider — widgets derived from a synthdef default into a panel named after the def. The scope, created by hand without a panel, lands in the default Controls window. Drag the sliders — the sound follows immediately, even while other code is evaluating.
3. The ui Module
import ui.*; to use it. All constructors are idempotent upserts keyed by (panel, name): re-running the same code adopts the existing widget — it keeps its current value and refreshes its range/spec — and clears stale bindings so the code can attach fresh ones. Running your setup code twice never duplicates widgets; this is also how saved documents come back to life.
3.1 Widgets
| Constructor | Widget | Notes |
|---|---|---|
slider(name, spec) | horizontal slider | spec is a synthdef.ControlSpec; the warp (linear / exponential / step / signedSquare / cubed) shapes the mapping |
slider(name, lo, hi, init) | horizontal slider | linear convenience form |
range(name, spec) | range slider | two ends (lo, hi) on one scale; sweep, slide, and end-adjust gestures in §3.3 |
range(name, lo, hi, initLo, initHi) | range slider | linear convenience form |
number(name, init) | drag/type number box | |
button(name) | momentary button | sends 1 while held, 0 on release (a gate) |
toggle(name, init) | checkbox | sends 1 / 0 |
xy(name, xspec, yspec) | 2-D pad | two ControlSpecs; bind each axis separately |
meter(name, node) | level meter | rms bar + peak marker, −60..0 dB; reads an engine tap |
scope(name, node) | oscilloscope | all channels of a node outlet, zero-cross triggered; the ch button cycles all / single channel (stacked lanes in “all” view share one trigger, preserving phase) |
spectrum(name, node) | spectrum analyzer | magnitudes in dBFS on a log frequency axis (20 Hz–20 kHz), 2048-point Hann FFT; peaks fall ~21 dB/s so they stay readable. A full-scale sine reads 0 dB |
masterMeter(name) | master level meter | the master output bus — what the device actually plays |
masterScope(name) | master oscilloscope | as scope, on the master bus |
masterSpectrum(name) | master spectrum | as spectrum, on the master bus |
multislider(name, n, spec?) | N vertical bars | drag across to paint; values mapped through the spec (default 0..1) |
matrix(name, rows, cols) | toggle grid | click cells on/off; a step-sequencer surface (values are 0/1, row-major) |
buttonMatrix(name, rows, cols, labels?) | labeled momentary grid | momentary buttons, like button(): a cell reads 1 while held, 0 on release (both edges dispatch); dragging onto another cell releases the held one and presses the new one. Per-cell labels are optional at construction and settable any time with setLabels(w, labels) / setLabel(w, row, col, label); read them back with labels(w). Cell changes deliver per-cell through onCell and whole-array through onChangeVec. Not stored by presets (its at-rest state is all zeros) |
toggleMatrix(name, rows, cols, labels?) | labeled toggle grid | toggle buttons, like toggle(): click flips a cell (0/1, row-major). Same labels and callbacks as buttonMatrix; values are stored by presets and saved with the document |
pianoRoll(name, beats = 4.0, edo = 12) | grid note editor | 16th grid; click to add/remove notes; rollRange(w, lowPitch, rows) sets the pitch window; notes are (pitch, startBeat, durBeats) triplets via notes()/setNotes(). Pitch is in steps of 1/edo octave — 12 (default) gives MIDI note numbers, 19/31/53 those equal temperaments, 1200 cents, 1 octaves. Fractional pitches are legal and drawn at their true (unquantized) position; clicking adds whole steps and deletes the nearest note within 1/24 octave |
label(name, text) | static text | |
plot(name, data) | static line plot | update with setData(w, data) |
waveform(name, node, buf) | audio file overview | shows the file loaded by audio_engine.loadBuffer |
Every constructor returns a Widget handle used by the binding and value functions below.
Metering the master bus. meter, scope and spectrum tap a node outlet, which cannot reach the final mix: the built-in Audio Out node (node 0) has only an inlet, and the master gain and safety limiter are applied after the graph has run. The master… constructors tap the master bus itself — post-limiter, post-gain — so they show what leaves the application. They take a name only; there is no node, outlet or silo to choose:
masterMeter("out");
masterSpectrum("spec");
Because they read after the safety limiter, a master meter also tells you when the limiter is pulling the mix down — the level stops rising while a node meter upstream keeps climbing. The trade-off is that the limiter holds one audio block, so with it enabled the master readings lag node taps by one block (a few milliseconds, invisible in practice).
The app also has metering you don't have to write code for: the status bar along the bottom of the window carries a master level meter, DSP load, and a dropout indicator (see §6), and in the graph view you can right-click any node for Meter (level bars hung off its outlets), Scope, or Spectrum — the last two build the same widgets these constructors do, in the node's panel. Use the ui constructors when you want metering laid out as part of a notebook or control surface.
3.2 Binding to Sound
There are two binding paths, and a widget may use both at once:
let cutoff = slider("cutoff", ControlSpec { lo: 20.0, hi: 20000.0, init: 440.0, warp: ControlWarp.exponential });
-- FAST PATH: drive an engine control directly. The GUI thread sends
-- setControl without entering the VM, so the slider keeps working at
-- full rate even while a long eval (say, a synthdef compile) runs.
cutoff bindControl(node, "cutoff");
-- FLEXIBLE PATH: a callback, for logic (sequencing, bookkeeping, math).
-- Coalesced: fires with the LATEST value at most once per GUI frame.
cutoff onChange(fn(v Float) Void { "cutoff -> %^" fmt(v) println; });
bindControl resolves the control by name against the node's synthdef and returns an audio_engine error code (0 = ok; a wrong name prints errControlNotFound to the console). For an XY pad, bind each axis: bindControl drives X and bindControlY drives Y; onChangeXY receives both values. A range slider works the same way: bindControl drives the lo end, bindControlY the hi end, and onChangeRange receives (lo, hi).
Prefer bindControl for anything that shapes sound in real time. Use onChange when you need language-side logic. If an eval holds the VM busy, fast-path bindings keep sounding; callbacks queue their latest value and fire when the VM frees up.
When a node is freed (stop(node), freeNode, freeAllNodes), widgets bound to it are automatically unbound: they stay on screen with their values but drive nothing until code rebinds them (e.g. re-running the cell after playing the synth again). onChange callbacks are untouched — they're yours.
3.3 Mouse & Keyboard Control
Sliders, range sliders, xy pads, and multisliders are played with drag gestures and single-key hover commands. While a slider or range is hovered or dragged, a graduated tick scale appears on its background — decimal steps for linear ranges, decades (1–9 log-spaced, longer ticks at 1 and 5) for exponential ones.
Pointer
| Gesture | Slider | Range slider |
|---|---|---|
| drag | positions absolutely | sweeps out a new range from the click point |
| option-drag | fine: 1/10 rate | slides the whole range (width preserved) |
| option+cmd-drag | ultra fine: 1/100 rate | — |
| shift-drag | — | adjusts whichever end is nearer |
| cmd-click | type the value (Enter commits, Esc cancels) | type one end — left half = lo, right half = hi (clamped so the ends can't cross) |
| scroll wheel | nudges by 0.01 per tick (up = higher) | slides the whole range by 0.01 per tick |
The fine modes are anchored at the value where you enter them, so you can slam close with a plain drag, then hold option and land it exactly — switching modes mid-drag never jumps. A range drag reads its modifiers every frame too: start a sweep, then hold option to slide it into place.
Hover keys
Hovering a widget enables quick single-key adjustments, all in the unmapped 0..1 position (so they respect the warp) except where noted. Keys auto-repeat while held, and key and wheel bursts alike commit one history entry when the adjustments stop.
| Key | Action |
|---|---|
| c | center — position 0.5 |
| [ | jump to lo (position 0.0) |
| ] | jump to hi (position 1.0) |
| 1–9 | jump to position 0.1–0.9 (sliders only) |
| r | uniform random position |
| j | jitter by a random ±0.05, bouncing off the ends rather than clamping (hold to walk) |
| J | fine jitter, ±0.005 |
| , | step the position down by 0.05 |
| . | step the position up by 0.05 |
| z | set the mapped value to zero (clamped into range) |
| i | set the spec's init value |
| - | negate the mapped value (only when the result stays in range — handy on a ± range) |
| / | reciprocal of the mapped value (when in range — handy on a ratio) |
What a key acts on follows the widget:
| Widget | Keys apply to | Wheel |
|---|---|---|
| slider | the value | nudges the value |
| xy pad | both axes (independent randoms for r/j) | vertical wheel drives Y, horizontal drives X — a trackpad plays the pad in 2-D |
| multislider | all bars (independent randoms per bar — r scatters, held j makes the whole surface shimmer) | adjusts the single bar under the cursor |
| range slider | both ends: c, z, and i collapse the range; [ and ] send one end to its rail; r, j, and J randomize or jitter both ends; - negates and / inverts the range; , and . slide it, width preserved | slides the whole range |
| toggle matrix | every cell at once: z and [ all off; ] all on; r a 50/50 coin flip per cell; j / J a 5% / 1% chance per cell of toggling (hold j for a slow churn); - inverts the pattern; 1–9 set each cell on with 10%–90% probability, else off. Other keys do nothing, and a momentary buttonMatrix ignores them all | — |
A hovered control owns the keyboard. While the pointer rests on a slider, range, xy pad, multislider, or toggle matrix, typed keys act on that widget and go nowhere else — not to a focused cell editor, not to bindKey bindings. To type code or fire key bindings, hover somewhere else. (The one exception: an open cmd-click value edit keeps its keystrokes until you commit or cancel it.)
Key bindings
Buttons and toggles can be bound to the keyboard: kick bindKey("a") makes the a key press the button (momentary — down sends 1, up sends 0) and shows kick [a] on its face; on a toggle the key flips it. Accepted keys: a single character "a"–"z" / "0"–"9", or "space"; "" unbinds. Key bindings fire whenever no text field has focus — in the notebook, in perform mode, and over floating panels — and are saved with the document.
3.4 Widgets from Synthdefs
Compiled synthdefs carry each declared control's name and ControlSpec. The ui module can materialize widgets directly from them:
-- One widget, everything (name, range, warp, init, binding) from the def:
let c = control(node, "cutoff");
-- The node's whole interface at once:
let ws = controls(node);
This is the shortest path from “synth is playing” to “synth has a control surface.” The synthdef declaration is the single source of truth for ranges and warps — change the ControlSpec in the def and every derived widget follows on the next run.
Derived widgets default into a panel named after the node's synthdef — each synth automatically gets its own window (or notebook panel cell of that name). Pass an explicit panel name to place them elsewhere, e.g. a differently-named notebook cell (§4.4):
controls(node); -- into the panel named after node's def
controls(node, "mixer"); -- into the panel named "mixer"
control(node, "freq", "mixer"); -- one control, same destination
Widgets are keyed by (panel, name). Calling controls twice for the same node updates the same widgets — no duplicates, and different synths never collide (they default to different panels). The one case needing care is two instances of the same def: both default to the same panel, so the second call would rebind the shared widgets. Give each instance an explicit panel: controls(bass1, "bass 1"); controls(bass2, "bass 2");
3.5 Reading Values & Taps from Code
cutoff value; -- current value (Float)
pad valueY; -- Y of an xy pad
cutoff setValue(880.0); -- move the widget AND fire its bindings
-- Tap-backed widgets can be read from scripts:
let m = meter("m", node);
m rmsLevel; -- smoothed level, published every 512 samples
m peakLevel;
let s = scope("sc", node);
s tapChannels; -- channels captured by the scope
let xs = s samples(4096); -- raw samples: interleaved frames of
-- tapChannels(s) channels, whole frames only
-- The master widgets read the same way.
let out = masterMeter("out");
if (out peakLevel > 0.99) { println("clipping!"); }
All of these read a widget, so metering from a script means creating one. A script that wants a level but no visible display can park the widget in its own panel and leave that panel closed.
One consumer per sample stream. A scope's or spectrum's sample FIFO feeds either the on-screen display or samples() — whatever one drains, the other doesn't see. Levels (rmsLevel/peakLevel) are fine to read from both places. If you need both a display and script access to the same signal, make two widgets: each gets its own tap.
3.6 Panels
Every widget belongs to a named panel. Constructors place widgets into the current panel, set by panel(name); the default is "", shown as the floating Controls window:
panel("mixer"); -- subsequent widgets go to "mixer"
slider("ch1", 0.0, 1.0, 0.8);
slider("ch2", 0.0, 1.0, 0.6);
panel(""); -- back to the default Controls window
Where a panel appears depends on context: normally each panel is its own floating window; but if an open notebook has a panel cell with the same name, the widgets render inside that cell instead (§4.4).
Closing a floating panel window (its × button) removes its widgets — the window is just a view of them, and code recreates them by re-running (values reset to their inits, since floating panels aren't part of a document). It's the direct-manipulation equivalent of ui.clear() for that panel. Notebook panel cells are different: their widgets are document state, saved and covered by undo.
3.7 Function Reference
| Function | Description |
|---|---|
panel(name String) Void / currentPanel() String | set / read the target panel for subsequent constructors |
slider(name, spec) Widgetslider(name, lo, hi, init) Widget | slider (ControlSpec / linear form) |
range(name, spec) Widgetrange(name, lo, hi, initLo, initHi) Widget | range slider (ControlSpec / linear form); value/valueY read the ends, bindControl/bindControlY drive them |
number(name, init) Widget | number box |
button(name) Widget | momentary gate button |
toggle(name, init = false) Widget | checkbox |
xy(name, xspec, yspec) Widget | 2-D pad |
meter(name, node, outlet = 0, silo = 0) Widget | level meter (engine tap) |
scope(name, node, outlet = 0, silo = 0) Widget | oscilloscope (engine tap) |
spectrum(name, node, outlet = 0, silo = 0) Widget | spectrum analyzer, dBFS on a log frequency axis (engine tap) |
masterMeter(name) WidgetmasterScope(name) WidgetmasterSpectrum(name) Widget | the same three displays on the master output bus (post-limiter, post-gain) rather than a node outlet |
plot(name, data [Float]) Widget / setData(w, data) | static plot / replace its data |
waveform(name, node, buf) Widget | overview of the file loaded into a buffer slot |
bindControl(w, node, control String, silo = 0) Int | fast-path engine binding, by control name |
bindControlY(w, node, control String, silo = 0) Int | fast-path binding for an xy pad's Y axis |
bindKey(w, key String) | keyboard binding for a button (momentary) or toggle (flip): "a"–"z", "0"–"9", "space"; "" unbinds |
onChange(w, f (Float) Void) / onChangeXY(w, f (Float, Float) Void) / onChangeRange(w, f (Float, Float) Void) | coalesced value callback |
value(w) Float / valueY(w) Float / values(w) [Float] | read the widget |
setValue(w, v) / setValueXY(w, x, y) / setRange(w, lo, hi) / setValues(w, vals) | move the widget and fire its bindings |
notes(w) [Float] / setNotes(w, ns) | piano-roll notes as flat (pitch, startBeat, durBeats) triplets |
onChangeVec(w, f ([Float]) Void) | coalesced vector callback: multislider/matrix/buttonMatrix/toggleMatrix values, or roll note triplets |
onCell(w, f (Int, Int, Float) Void) | per-cell event callback for matrix/buttonMatrix/toggleMatrix: fired once per cell press, flip, or release with (row, col, value), in interaction order. Unlike onChangeVec it preserves both edges of a fast click and the ordering of multi-cell changes. GUI interaction fires it; whole-state syncs (setValues, preset recall, undo) fire only onChangeVec |
labels(w) [String] / setLabels(w, labels [String]) / setLabel(w, row, col, label String) | buttonMatrix/toggleMatrix cell labels (row-major; missing entries draw blank). Saved with the document |
setFrame(w, x, y, wd, ht) | layout frame within the panel (what arrange mode sets by dragging) |
rollRange(w, lowPitch, rows) | piano-roll pitch window, in steps of the roll’s edo |
show(name, v) | materialize a value as its natural widget: Float→number, Bool→toggle, [Float]→multislider (≤64) or plot, String→label |
rmsLevel(w) / peakLevel(w) / tapChannels(w) / samples(w, max = 4096) | read a tap widget from code; samples are interleaved frames of tapChannels(w) channels |
control(node, name String, silo = 0) Widgetcontrol(node, name, panel String, silo = 0) | one widget derived + bound from the def's ControlSpec; defaults into a panel named after the def |
controls(node, silo = 0) [Widget]controls(node, panel String, silo = 0) | materialize the node's whole interface; defaults into a panel named after the def |
defName(node) String | the def name of a live node ("" if unknown) |
remove(w) / clear() | remove one widget / all widgets (taps are released) |
4. Notebooks
4.1 Creating a Notebook
File > New Notebook (Cmd+Shift+N). The notebook takes over the editor pane, with the console as a resizable column on the right (the cell strip gets the full window height), and your .x editor tabs are untouched underneath — the Editor button at the right of the notebook toolbar (or Cmd+\, View > Toggle Notebook / Editor) swaps back to your editor tabs without closing the notebook — its widgets, history, and queued runs stay live (its panel controls are simply out of sight until you swap back; scheduled code and bindings keep running). Close (or Cmd+W) ends the document.
Only one notebook is open at a time: New Notebook or opening a .tzd replaces the current one (you are asked to save first if it has unsaved changes).
The toolbar across the top: + code / + prose / + panel / + presets insert a cell after the selected cell (or at the end if none is selected); Run All runs every code cell top to bottom (and becomes Stop (n) while runs are queued); Undo / Redo / History drive the document history (§4.6); Perform enters the locked stage view (§4.4); Editor swaps to the editor tabs (notebook stays open); Close ends the document. The title shows the file path, with * when modified.
Click anywhere in a cell to select it — the selected cell shows an accent bar along its left edge. Selection determines where new cells insert and which cell Cmd+Enter runs.
4.2 Cell Types
- code — a Tzopilotl editor with syntax highlighting, an editable name (purely descriptive, saved with the document), a Run button, a run on load checkbox, and its own output area underneath. A small glyph at the left of the header shows run status by shape (color reinforces it but is never the only cue): ○ hollow circle — never ran; ✓ check — ran clean; ▲ triangle — edited since it last ran; ✗ cross — last run errored.
- prose — text for notes and titles, written in Markdown and shown rendered: headings, bold/italic, inline
code, lists, tables, quotes, and fenced code blocks (GitHub dialect, via md4c). Double-click the rendered text to edit the Markdown source in a word-wrapping editor. To go back to the rendered view, press Esc or simply click anywhere outside the cell's text — your edit is kept either way; there is nothing to confirm. A brand-new prose cell starts in edit mode. - panel — an embedded control surface; see below.
- presets — a matrix of stored control snapshots. A presets cell governs the panel cells after it, up to the next presets cell: + store snapshots the current values of every input widget in those panels into a new slot; click a slot to recall (audible, one undoable step); right-click or cmd-click a slot for its menu — name it, overwrite it with the current values, or delete it — without recalling; the overwrite button does the same for the selected slot. Presets store the values of sliders, range sliders, numbers, toggles, xy pads, multisliders, matrices, and toggle matrices — not meters/scopes/spectrums, buttons, button matrices (momentary), or piano-roll notes. Saved with the document; collapse the cell to a single strip when performing without it.
Every cell header starts with a collapse triangle that hides the body (code, output, or panel canvas), leaving just the header strip — collapsed state is saved with the document. Each header also has ↑ / ↓ (move) and x (delete) on the right. Collapsed code cells still run (Run, Run All, run-on-load).
With a code cell focused, File > Open (Cmd+O) reads the chosen file's text into that cell (one undoable history commit) — a quick way to pull an existing .x script into a notebook. With no code cell focused, Open behaves as usual (.tzd opens as a notebook; other files open in the editor).
4.3 Running Code
Cells share the one live session — the same globals, modules, and running audio as the editor and REPL. There is no hidden kernel: lets from one cell are visible to the next, and evaluation happens only when you ask.
- Cmd+Enter or Shift+Enter — run the selected cell (click a cell to select it; the accent bar shows which one).
- Run button — run that cell.
- Cmd+Shift+Enter or Run All — run all code cells top to bottom, stopping at the first error. While cells are queued the toolbar button becomes Stop (n) — click it to cancel the remaining runs. If a cell errors, the run stops there and the cell's output notes how many queued cells were skipped.
Output printed while a cell runs appears under that cell; a returned value is shown as → value : Type. Errors underline the offending line in the cell and print the message below it. Output from timers, actors, and the engine (things that print when no cell is running) goes to the console column on the right, since it can't be attributed to any one cell.
4.4 Panel Cells
A panel cell embeds the widgets of the panel with the same name. The connection is by name, nothing more:
- Add a panel cell. Its name (editable right in the cell header — it starts as something like
panel1) is what code targets. Naming it after your synthdef meanscontrols(node)lands there with no extra arguments. - In a code cell:
controls(node);(cell named after the def) orcontrols(node, "panel1");— or set a sticky target withpanel("panel1")and create widgets individually. - Run the cell — the widgets appear inside the panel cell.
Tabs within a panel cell
A panel named "<cell>/<tab>" renders as a tab inside the cell: panel("mixer/eq") and panel("mixer/sends") give the cell named mixer an eq and a sends page, one showing at a time (widgets in the bare "mixer" panel become a (main) tab). Which tab is selected is pure view state — never in history. Sub-panels follow their root everywhere: saved with the document, captured by undo, removed with the cell. Separate control sets per cell, created entirely from code.
Panels claimed by the document render only there, never as floating windows — hiding the notebook (Cmd+\) just puts its controls out of sight until it returns. Panels no cell claims (including the default "") float as windows, as always. Closing or replacing the notebook (Close, New Notebook, opening another file) removes its claimed panels' widgets along with it.
Arrange mode
To enter or leave it: click the arrange control in a panel cell's header, beside the cell's editable name and its reflow button. It is a toggle: click it again to leave. There is no menu item and no keyboard shortcut. Arrange is per cell, not per document: each panel cell has its own toggle, and only panel cells have one (code, prose, and preset cells do not). It also only works in the normal notebook at 1× scale — perform mode locks the layout, so you cannot rearrange from the stage view.
While arrange is on, widgets stop responding to input and instead become draggable, each outlined with a move border: drag a widget to move it, drag the small grip at its bottom-right corner to resize, both snapping to an 8-pixel grid as you drag. The bar under the canvas drags to change the panel's height. Layout is document state: frames save into the .tzd, are covered by undo (one history entry per drag), and survive re-running the creating code (upserts adopt position along with value). Widgets you never arrange flow top-to-bottom in creation order — and keep flowing: edit the creating code and re-run, and they re-lay out to match. Only an arrange drag (or setFrame(w, x, y, wd, ht) from code) pins a widget to a fixed frame. The panel header's reflow button unpins everything in the panel back to code-order flow (one undoable commit) — useful after experiments, and to heal notebooks saved by older builds that pinned auto-flowed positions.
Perform mode
The toolbar's Perform button (or Cmd+Shift+P) swaps the notebook for a stage view: one tab per panel cell, the selected panel filling the view at 1.5× scale for reliable targets, with the layout locked — no cell chrome, no arrange, no structural edits, nothing to grab by accident mid-set. Tabs give you separate control sets one page at a time (switching tabs is pure view state — not in history). Widgets stay fully live (bindings, taps, key bindings), and code keeps running; you just can't edit the document's shape. Esc or Exit Perform returns to the normal notebook.
4.5 Saving & Loading
Cmd+S saves the notebook to a .tzd file (a compact binary container in the TZB format, documented in FFI Guide §15). Saved: the cells (kind, text, order, run-on-load flags), for each panel cell its widgets' names, kinds, ranges, and current values — and the whole undo history tree (§4.6). Not saved: bindings, eval outputs, scope and spectrum data — anything that is behavior or transient display. A meter, scope or spectrum widget is saved (so the panel comes back with it in place) but reconnects to the engine only when the code that created it runs again.
On open, widgets reappear immediately with their saved positions and values, but unbound — they drive nothing until their creating code runs again. Because constructors are upserts, re-running the setup cell adopts each saved widget (keeping its saved value, which is pushed through the fresh binding) rather than resetting it. So the loading ritual is simply:
- Open the
.tzd. - Run the setup cells (or mark them run on load and they run themselves).
- Perform.
Why not save bindings? Bindings reference closures and engine node IDs that only exist in a live session. Rather than invent a second, weaker way to describe behavior, the document re-runs your code — the same code path as the first time, guaranteed to agree with it.
4.6 Undo & History
The notebook keeps one immutable history tree covering cell structure, code text, and widget values in document panels. Commits are coalesced so the tree stays readable: one entry per slider drag (committed when you release), one per pause in typing (≈1 s), one per cell run, one per structural edit.
- Cmd+Z / Cmd+Shift+Z — undo / redo, at two granularities. Text edits are always part of document history (each typing burst commits as one “edit” node when you pause); what focus selects is the step size. While your cursor is in a text cell, Cmd+Z is the editor's character-by-character undo — right for fixing a typo. Click outside the text (or on a widget) and Cmd+Z steps through document commits: whole text edits, control moves, cell operations, runs. Pending (un-coalesced) typing is committed before a document undo, so the first step lands on the pre-typing state. Undoing a control move re-sends the old value through the widget's binding — you hear the undo. (A document-level text restore replaces the cell's editor buffer, which resets that cell's character-level undo stack.)
- History window (toolbar button) — the whole tree. Editing after an undo doesn't discard the redo path; it starts a branch, listed indented with a “(n branches)” marker on the fork. Click any entry to jump straight to it; redo then follows the branch you jumped into. While the window is focused, ↑ / ↓ step the cursor through the list one entry at a time, recalling each state as you go (hold to scrub; steps across a fork jump into the neighboring branch, exactly like clicking it). The footer shows the node count and how many unique cells, widget states, and presets back them.
History is saved with the document. The .tzd file stores the whole tree — every node, its branches, and where the cursor was — so after save and reopen, Undo/Redo and the History window pick up exactly where you left off (undoing a control move is still audible once the setup cells have re-run and rebound the widgets). Storage is content-addressed: each distinct cell, widget state, and preset is written once and shared by every history node that contains it, so a session of hundreds of small tweaks costs little more than its unique content. Files saved by older builds open normally (they simply carry no history), and older concerns about file bloat don't apply — a few hundred slider tweaks typically add only tens of kilobytes.
The same content addressing works in memory: recreating a state you were in before (toggling a control back, re-storing an identical preset, re-editing a branch to match another) shares the earlier objects instead of duplicating them, and committing a state identical to the current history tip is a no-op. The History window's footer shows the live tally — node count and the number of unique cells, widget states, and presets behind them.
The tree is capped at 500 nodes — past that, the oldest states fall off the root (along with any branches hanging off them) as new commits arrive.
5. Keyboard Shortcuts
| Key | Editor mode | Notebook mode |
|---|---|---|
| Cmd+Enter | evaluate selection / block | run focused cell |
| Shift+Enter | evaluate line | run focused cell |
| Cmd+Shift+Enter | evaluate file | run all cells |
| Cmd+Shift+N | new notebook | |
| Cmd+S | save file | save notebook (.tzd) |
| Cmd+W | close tab | close notebook |
| Cmd+\ | toggle notebook / editor view (notebook stays open) | |
| Cmd+Shift+P | toggle perform mode (Esc also exits) | |
| Cmd+Z / Cmd+Shift+Z | text undo / redo | character undo in the focused cell; document-history undo otherwise (text edits are in both — see §4.6) |
| Cmd+K | clear console | |
The View menu also holds the editor font size and the color theme (View > Theme: Dark, Corporate Gray, Dark 2, Cherry, Dark Grey).
6. Monitoring the Engine
Three surfaces show what the audio engine is doing. The ui constructors of §3.1 are the one you compose yourself; the other two are always there.
6.1 The Status Bar
A strip along the bottom of the window, always visible except in perform mode:
- Device format — sample rate and buffer size, or (stopped) when audio isn't running.
- CPU — the engine's own DSP time as a share of one audio block's budget, averaged. Green under 50%, amber under 80%, red above. This is the number that predicts dropouts: at 100% the engine is taking longer to compute a block than the block lasts.
- Master level — peak-hold bars for the master bus, with a clip square that latches red the first time a sample hits full scale. Click the red square to clear it (and only it — the dropout counters are left alone).
- XRUN — a dropout counter. Hidden while zero; once audio has broken it stays lit until you clear it. New dropouts also flash the strip and log one line to the console (rate-limited, so a burst can't flood it).
Click the XRUN readout to reset the counters and clear both latches; clicking the clip square clears just that one. Nothing else resets them — a dropout should never quietly disappear while you're looking away. Note that the device's own xrun counter belongs to the driver and cannot be zeroed, so what you see after a reset is the number of xruns since it.
Click anywhere else on the strip to expand a detail panel:
- Per silo — DSP time (average and worst-case), share of budget, time spent waiting to mix, live tap count, and the depths of the three command queues.
- Garbage collection — step counts and the worst audio-thread pause, per silo VM and for the main (NRT) VM. The audio-thread figure is the one that matters for real-time safety.
- Device — the driver's own xrun and CPU figures alongside the engine's. These are reported separately on purpose: the device figure covers work the engine doesn't do (and doesn't control), so it normally reads a little higher. Also here: dropped blocks, real-time exceptions, clipped samples, and how far the safety limiter is pulling the mix down.
6.2 Meters in the Graph View
In the graph view (Cmd+Shift+\), right-click a node > Meter to hang a level bar off each of its outlets. A node with several outlets gets a submenu so you can meter just one; outlets carrying something other than audio are greyed out, since only audio can be metered. A dot in the node's title bar marks it as metered when you're zoomed too far out to read the bars.
The bars sit under the node, one per metered outlet, running left to right the width of the box — the same orientation as the master meter in the status bar. Each carries the outlet's name at the left and a peak-hold reading in dB at the right, faint ticks every 12 dB across a −60..0 dB span, a green fill for RMS (red when the outlet clips), and an orange peak-hold marker. The labels drop out when you zoom out far enough that they would be unreadable; the bars themselves stay.
Meters are per node and opt-in because each one costs a little real-time work, and each silo can carry only so many at once — if you hit the limit the console says so, and turning one off frees a slot. They last for the session: switching silos or leaving the graph view drops them, and they are not saved with a document.
The same context menu carries Scope and Spectrum (with a per-outlet submenu on a node with several outlets). These are not drawn in the graph: they create exactly the widgets scope() and spectrum() build, in the node's own panel, so a node ends up with one window holding its sliders and its displays. Unlike Meter they are not toggles — close the panel window to remove them and release their taps.
The menu also opens a node's Controls… panel (so does double-clicking the node). It is greyed out for a node whose synthdef declares no controls and no buffer slots — there would be nothing to put in the window.
6.3 Master Output in the Graph View
The right of the graph view's toolbar carries the two engine-wide output settings, next to the per-silo controls on the left:
- Master — output gain in dB, applied to the summed output of every silo after the safety limiter. Double-click the slider for unity; the bottom of its travel is silence, not −60 dB, so it can mute.
- Limiter — the safety limiter. On by default. Turning it off removes its one block of latency along with its protection; the status bar's detail panel shows how far it is pulling the mix down while it is on.
Both are the same settings the masterGain and safetyLimiter functions write, and both live in the engine rather than in the window: the controls re-read the engine's values each time the graph view is shown, so a script that changes either is reflected here. They are engine-wide, so the silo selector does not affect them. On a narrow window the section is hidden rather than crowding the silo buttons.
7. Current Limitations
- Floating panel windows stack widgets top-to-bottom; arrange mode (drag/resize) applies to notebook panel cells, where layout is document state.
- Piano roll is a fixed 16th grid: click to add/remove quarter-beat notes; no velocity lanes, note dragging, or CC curves. Longer notes and fractional pitches via
setNotes(). - One notebook at a time. Opening or creating another replaces the current one (with a save prompt if modified).
- Prose Markdown is display-only — no WYSIWYG editing (double-click drops to the plain-text source), and links are shown but not clickable yet.
- Scopes/spectrums/waveforms: taps read audio (f32) outlets only, up to 16 channels captured; waveforms show file-loaded buffer contents (not live-recorded audio). The spectrum analyzer is a fixed 2048-point Hann FFT — no adjustable size, window, or averaging yet.
- Graph-view meters are session state: they are not saved with a document, and they are dropped when you switch silos or leave the graph view.