Tzopilotl
Docs
GitHub

TZPL News

Curated highlights of what has changed, newest first. The full history is in git; this file records the changes worth knowing about as a user of the platform. Rendered on the documentation site as the Changelog page.

Unreleased

v0.2.5 (14 September 2026)

Changed

own definition. A proxy's anchor (its stable summing point) and monitor (its fadeable path to Audio Out) used to be generated synthdefs, each run through clang, the linker and dlopen once per channel count per session -- and the anchor was built three times over by <-, play and meter racing into it -- so the first sound from drone <- ...; drone play arrived some four seconds after the cell. They are now the engine's native _wire<N> / _gain<N> node defs (audio_engine.wireDef / gainDef), registered on first use at any width, so the only thing that compiles is the definition you wrote.

generated synthdef starts with (the plugin ABI, matrix, random and delay headers plus the standard headers), which was most of the cost of a compile: a typical synthdef now takes ~60 ms of clang instead of ~500 ms. The PCH lives in ~/tzpl-build/pch/, keyed by a hash of the headers, flags, compiler version and the headers' modification times so it rebuilds itself after any of those change, and a compile that fails with it is retried without it. Net effect for live: a redefine lands in roughly 200 ms, of which about 100 ms is macOS's Gatekeeper assessment of the freshly written dylib (syspolicyd scans every new Mach-O and consults Apple's notarization service) rather than anything under our control.

Changed

now also stops delay-driven silo actors -- a spawned actor looping on await delay (the generative-sequence pattern in Cookbook §7) was left running because its timers live in the silo VM’s async queue, not the task scheduler. It is also scriptable now as panicSchedulers().

Fixed

failed to compile with siloLoad: compile error -- the same for import std.fs.* and other modules that define NRT wrappers. Importing such a module type-checked every wrapper body against the silo's real-time restriction, so a wrapper the silo never calls (e.g. loadSampleBank) failed the whole load. A non-RT-safe function may now be defined in an imported module compiled for a silo; only an actual call to one from silo code is rejected. A companion bug is fixed too: a silo's own top-level code was not being RT-checked at all (the persistent incremental checker captured its real-time flag before its target was current), so a silo that called a non-RT-safe function slipped through -- it is now correctly rejected.

result Future, not just printed to the console, so the app's output panel shows the actual error (with its source line) instead of a bare siloLoad: compile error.

render script hung forever. The render clock is driven by the render thread only after the setup script returns, but the await parked that script waiting for the clock to advance -- a deadlock. The render now drives its clock (rendering and writing output blocks) while the setup script is parked in such an await, so the delay resolves at logical render time as documented; an await whose deadline falls past the render duration is released when the render ends. (The idiomatic pattern -- a render-clock-driven coro fn ... yield -- was unaffected.)

unless the cell also contained an await. A fire-and-forget async call -- which is what <- and play are -- resolves its background compile on the I/O worker thread, but resolving a future only moves its waiters to the ready queue; with no top-level await anywhere, nothing drained that queue, so the continuation that swaps the compiled source into the proxy never ran. Cross-thread resolvers (the async I/O worker, the tempo scheduler) and each REPL/cell evaluation now drain ready continuations when no evaluation is on the stack to do it.

trailing f(x) -- was compiled as a tail call, which replaced the async frame; when f returned it jumped to that frame's null return address and the process crashed. Tail calls are no longer emitted inside async fns (their result must go through the Future). In the app this was drone play evaluated a second time while already playing: the "adjust the volume" branch of play is exactly such a return.

compiling -- Cookbook §12.4 run as a single cell -- read an empty parameter list, warned that the proxy had no noteParams, and then played every note at the spec defaults (all 440 Hz) once the sound existed. The player now parks the sequence and derives its Voice the moment the definition lands, so the pattern starts as written; replace and enqueue during that window park likewise.

(reproduced locally as a silent live-coding session: live: anchor def failed: ... failed to load plugin; the likely cause of one reported on the forum). The build revision counter was seeded from disk once per process; if another tzpl process (a second app instance, a CLI render, a test run) sharing ~/tzpl-build had since written higher-numbered revisions of the same def, the new build was numbered below them and the keep-three-newest prune removed it a moment before dlopen. The counter is now re-synced with the disk before every build and the prune never touches the revision just built.

newer libstdc++ no longer pulls in <climits> or <unistd.h> transitively, so INT_MAX in the type checker's "did you mean" typo suggestions and usleep in the NRT _sleepMs bridge were undeclared. The missing <climits> includes are added, and usleep is replaced with the standard std::this_thread::sleep_for.

v0.2.4 (13 September 2026)

Fixed

(the machine they were built on) rather than the intended macOS 13.3 floor, so the app refused to launch on Ventura, Sonoma, and early Sequoia. The 13.3 pin in CMakeLists.txt was guarded by "unless already set", and a build directory configured before the pin existed had the host's version cached, which silently won. The deployment target is now forced from TZPL_MACOS_DEPLOYMENT_TARGET (default 13.3) on every configure, and make_dist_dmg.sh refuses to package a binary whose Mach-O minos is above the configured floor. The DMG is Apple Silicon only, as before.

v0.2.3 (13 September 2026)

Fixed

app: opening examples/sc_conversions/sc2_examples_1.x and running it stopped at import sc2_common.*; with "Cannot find module". Examples open as unsaved copies so an edit is never saved back into the distribution folder, and a copy had no path for document-relative imports to anchor to. A copy (editor tab or detached notebook) now anchors to the example it was opened from, while Save still asks for a location. The CLI had the same gap for a bare filename: tzpl foo.x run from inside foo's directory skipped the sibling lookup because the path had no directory component; it is made absolute first now.

return since the v0.2.2 anonymous-coroutine fix: the "no implicit return in a coroutine body" rule keyed on the enclosing function rather than the lambda itself, so every fn() T { expr } nested in a coroutine returned nothing. A live session's define(px, fn() S { ... }) graph functions hit this -- the defs rendered silence and a voicer def reported no noteParams. async fn lambdas already keyed on their own flag; coroutine lambdas now do too.

inside a bare if_ (if_(trig, fn(){ y <- white(1) }), the lfnoise shape): the control-flow node had no consumers, so findGraphCuts marked it Unused and dead-code removal swept the whole branch, ring buffer write included. The synthc port had the fix; the C++ side now keeps such a node as a ControlFlow cut, declares its phi result up front (it was assigned but never declared), advances a ring buffer's write head where the write runs rather than where the buffer was declared (so a conditionally written buffer holds the last N written values, as the lfnoise generators need), and shares delay lines fed the same signal the way synthc always has (its merge pass had keyed on the writer node and never fired). The nestedDelayVoicer codegen validity case, left failing on purpose, now passes byte parity on both compilers.

'ec'" in synthdef_compile_link.cpp: the Linux-only Sleef staging block redeclared a variable that the v0.2.2 error-handling rework had hoisted to function scope. The block now shares that variable and reports a failed Sleef header or library copy as a proper error instead of ignoring it. The Linux CI jobs are now required, so a Linux-only build break fails the workflow instead of hiding behind a green badge (issue #11).

it instead of creating a node against a def that does not exist yet (silence, plus a bare "errNodeDefNotFound" in the console). The compile FFI tracks in-flight compiles by def name and play awaits the one for its def, if any; synthDefReady(name) exposes the same future. A newNode for a def that is not loaded at all now says so by name, with the usual cause.

a trailing expression, if-else, or match crashed the process when it finished -- so a go task that ran to completion took the app down with it. The lambda code path compiled the trailing expression as a return, which popped the coroutine's frame and jumped through its null return address; it now falls through to the coroutine's done path, as a named coro fn always did.

v0.2.2 (7 September 2026)

Fixed

control(node, ...) binding) reached the engine only after the next click or drag in the app. The dispatcher that forwards widget values stops its timer when idle and was woken only by GUI events, so a sequencer coroutine driving a synth through its control widgets stayed silent until the user touched something. Code-side changes now wake it.

before its def existed and was silent, with "errNodeDefNotFound" in the console; instrument_synthdefs.x, effect_synthdefs.x, and note_synthdefs.x returned from import while their defs were still compiling, so instrumentTest.x lost its first instrument the same way. Every defSynthX in those files now carries the await the docs prescribe; a single worker compiles the defs in order either way, so nothing gets slower.

message listener was active, so a --nogui --wait script driving the tempo scheduler exited after the first scheduled callback.

any machine other than the one it was built on. Compiling a synth stages the plugin headers (tzpl_plugin_abi.h and friends) into ~/tzpl-build, and the release build took them from the build machine's source tree; when that path did not exist the filesystem error escaped the compile worker thread and aborted the process. The distribution folder now ships those headers in include/, found relative to the app like modules/ (or via TZPL_HOME), and a missing header directory is reported as a compile error for that synthdef instead of a crash.

prints as nil, and a Fraction value no longer crashes the REPL.

App

face, Xcode-style, listing every match across the open folders' documents and the editor tabs (unsaved text included), grouped by file with the matched span highlighted. Clicking a hit opens the file and selects it; Escape returns to the file tree. The sidebar column now has Files / Find tabs.

caret that is in scope -- enclosing blocks first, then the file's top level, then the modules it imports (following export re-exports and as aliases; mod.name looks only in mod). Lists overloads, parameters, let/var bindings, enum cases, and type declarations. A name with no Tzopilotl source is named for what it is: a built-in function, or a foreign function of the bridge's native module.

(resizeBuffer, loadBuffer, fillBuffer, loadSampleBank, sampleZone), the shared-input functions, the TapMode enum and the missing Err cases, and a function index listing every audio_engine function with its section and thread class.

Matches Word, Starts With, Ends With, and Regular Expression (with $1-style groups in replacements), each with the case toggle.

run of parent directories that differs, dimmed after the name ("modules .../tzpl_1/tzpl/lang" next to "modules .../tzpl_2/tzpl/lang"), so a source tree's stdlib and an installed distribution's no longer look identical.

v0.2.1 (4 September 2026)

App

prompts to save when the app closes, and clicking an already-open example in the sidebar switches to its tab instead of opening another copy. Editing a copy marks it modified (asterisk) and prompts on close as usual, and Save still asks for a location outside the distribution folder.

v0.2.0 (4 September 2026)

Language & VM

bulk-appends an array or list to an array in place (the mutating analogue of $); isEmpty answers emptiness for every collection length covers (arrays, lists, maps, sets, strings, ranges, persistent vectors/maps). isEmpty on a List is O(1) and safe on infinite lists.

synthc) rewritten to use filter, auto-mapping, drop, clear!, append!, and isEmpty in place of manual loops.

Libraries & Examples

style: the hierarchy is walked lazily as the player pulls events, so selection decisions are made just-in-time (one event ahead of playback) and handing a huge form to play costs tree depth up front, not the whole form.

Platform

plugin compilation to .so), audio engine (ALSA/JACK/PulseAudio via RtAudio auto-selection), bridges, headless tzpl_app, and the JUCE app now build and run on Linux with Clang 19+. docs/LINUX.md covers requirements, the Docker dev environment (dev/linux/Dockerfile), and real-time configuration. The Dear ImGui GUI remains macOS-only; on Linux the JUCE app is the GUI, and it now also feeds the mouseX / mouseY / mouseButton ugens there.

v0.1.0 (3 September 2026)

Language & VM

defSynthX now fail loud instead of continuing on a broken graph.

Fraction-producing division.

SynthDefs & UGens

state and an in-place put peephole optimization.

red, gray, white refinements) with corrected Kellett filter state reads.

per-voice shape is kept through PhiNode with a voicer target; C++/synthc compiler parity on the instruments.x voicers.

Engine & Control

and NATS interfaces.

Libraries & Examples

(Karplus-Strong pluck, wavetable lead, resonator bank, sample players), documented in a new Music Cookbook chapter.

dialects, instruments, and effects libraries together in one persistent node graph -- also rendered in the site gallery.

App

Project

security policies, third-party notices.

search, per-chapter guide pages, and the audio gallery.