v0.4.1
⚠ BREAKING — CLI binaries renamed: nkido-cli → nkido, akkado-cli → akkado
The bytecode player and compiler CLIs were renamed and their build output
moved to build/bin/. Update any wrapper scripts, CI, or shell aliases.
Source folders tools/nkido-cli/ and tools/akkado-cli/ were renamed to
tools/nkido/ and tools/akkado/ to match.
⚠ BREAKING — pat builtin and p"…" literal removed
The untyped pat("…") builtin and the p"…" raw-pattern literal were
removed in favor of typed prefixes (n"…", s"…", etc.). The typed
forms carry full event semantics; the raw form only ever surfaced step
indices and is unused by any shipped patch.
⚠ BREAKING — euclid() default span changed from 1 cycle to 4 cycles (1 bar)
The runtime euclid(hits, steps) builtin previously packed all steps into a
single cycle (= 1 beat under cycle=beat). At common BPMs this ran at near-32nd-
note rate — far from the "tresillo" feel the docs claimed. We added an explicit
dur parameter (audio-rate signal, default 4 cycles) so euclid(3, 8) now
spans 1 bar at 4/4 by default, matching the classic Strudel/Tidal feel.
Existing patches using euclid(3, 8) will now sound 4× slower. To preserve the
old feel pass dur=1 explicitly: euclid(3, 8, 0, 1). To stretch further, raise
dur: euclid(5, 16, 0, 8) spans 2 bars.
.fast() and .slow() remain pattern-only and still do not apply to
euclid() (it returns a signal, not a pattern). Trying to use them now emits a
targeted hint pointing at the dur parameter instead of the generic
E133 first argument must be a pattern. For pattern-style rate scaling over an
Euclidean rhythm, use mini-notation Euclidean syntax: n"c4(3,8)".slow(2).
⚠ BREAKING — mini-notation: cycle = beat, top-level = alternation
The clock unit and the mini-notation top-level grouping rule both
change to a single coherent model:
- 1 cycle = 1 beat. BPM directly sets the cycle rate.
- Top-level spaces play one element per cycle.
"a b c d"plays
four cycles in sequence (one element per cycle), exactly equivalent
to the angle-bracket form<a b c d>. [a b c d]packs four elements into one cycle (the explicit
subdivision form). Use this when you want Strudel/Tidal-style
in-cycle subdivision.<a b c>is a documented synonym of the top-level form.
This is a deliberate divergence from Strudel/Tidal, which treats
top-level as subdivision. We chose per-cycle alternation so long
melodies stay readable as "c d e f g a b c5" without anyone having
to count elements to predict playback speed.
Engine-side: ExecutionContext::samples_per_cycle() no longer
multiplies by 4; every cycle_length default flipped from 4 beats to
1 beat; bar phase collapses to beat phase. The codegen dispatch for
MiniPattern routes through compile_alternate_sequence (the same
path as <…>), with the existing single-child inline guard preserving
pat("a") ≡ pat("<a>") ≡ pat("[a]") byte-equivalence.
⚠ BREAKING — delay-family default mix changed
The delay, delay_ms, delay_smp, tap_delay, tap_delay_ms,
tap_delay_smp, and pingpong builtins migrated from full-wet output
to the new unified Category-A defaults dry=1, wet=0.5 (parallel mix).
Patches that called delay(in, time, fb) or pingpong(s, time, fb)
and relied on a fully-wet output now get a balanced parallel mix and
will sound different. Set wet=1 (or dry=0, wet=1) explicitly to
restore the previous behaviour. The pingpong opcode previously did
out = in + delayed (effectively dry=1, wet=1) — pass wet: 1.0 to
reproduce the prior echo loudness.
⚠ BREAKING — ChordLit (C4') syntax removed
The Strudel-style chord literal — an identifier-shaped token with a
trailing apostrophe, e.g. C4', F#m7_4' — has been removed from the
language. It was an MVP stub that only ever emitted the chord's root
note, was unused in any shipped patch, and is superseded by pattern
events carrying real chord data. Write chords as patterns instead
(n"[c4,e4,g4]", chord("Am G C")); C4' now lexes as the identifier
C4 followed by a quote. The internal chord_parser API (used by
mini-notation) is unaffected.
⚠ BREAKING — scale() array builtin removed
The scale(array, lo, hi) array builtin has been removed. It was
functionally identical to normalize(array, lo, hi), which already
maps an array's value range to an arbitrary [lo, hi] range (its
lo/hi arguments are optional, defaulting to 0/1) and emits
identical bytecode. Replace any scale(arr, lo, hi) call with
normalize(arr, lo, hi). Removing the builtin frees the scale name
for the planned Strudel-style scale-quantize transform
(prd-runtime-event-transforms.md).
Added
-
Flexible
poly()/mono()/legato()instrument callbacks — the
instrument is no longer fixed to a 3-parameter(freq, gate, vel)
signature. It can read any pattern event field: by record destructure
(({freq, note, dur, cutoff}) -> …), by positional prefix (canonical
orderfreq, gate, vel, trig, type, note, dur, chance, time, phase, sample_id), by a mix of the two, or by a rest param binding the whole
event ((...e) -> e.freq). Custom mini-notation record-suffix fields
(c4{cutoff:0.8}) are readable per voice; an absent field binds to0
or to an explicit{cutoff = 0.5}destructure default. The historical
(freq, gate, vel)positional form stays valid;monoandlegato
accept every callback shape identically. Record form is now the
canonical idiom across all docs and example patches. -
Destructure-param closures compile in every context — a closure
assigned to a name (stab = ({freq, gate, vel}) -> …) and used as a
poly/mono/legatoinstrument, or called directly, not only when
passed inline as a directpoly()argument. -
Pattern event arrays —
notes()/freqs()— surface a pattern
event's chord notes as a first-class dynamic array (an array
whose length is a runtime signal).notes(e)returns MIDI numbers,
freqs(e)returns Hz; the method formse.notes/e.freqsare
equivalent.len()is now polymorphic (compile-time constant for
static arrays, runtime signal for dynamic ones),arr[i]indexes
dynamic arrays with wrap-by-default, andmap()over a dynamic
array stays dynamic. Combined withstep()/counter()this makes
arpeggiators and harmonizers userspace closures — no new C++ opcode
per musical operator. A stateful UGen cannot auto-fan-out over a
dynamic array (osc("sin", e.freqs)→ E181, usepoly()). New
SEQPAT_VALUESopcode; demo patchesarpeggio-demoand
harmonizer-demo. -
Unified
dry/wetconvention across every effect builtin — all 33
effect builtins (delays incl.pingpong, reverbs, modulation, comb,
filters, distortion, dynamics) now exposedryandwetas their
last two parameters and apply the standard mix
out = dry_in * dry + processed * wetper channel. Two category-based
defaults:- Category A — Additive (delays, reverbs, modulation, comb):
dry=1.0, wet=0.5(balanced parallel mix out of the box). - Category B — Transform (filters, distortion, dynamics):
dry=0.0, wet=1.0(back-compat — no audible change; setdry>0
for NY compression / parallel filter / parallel distortion).
- Category A — Additive (delays, reverbs, modulation, comb):
-
cedar::drywet::{coeff, mix}inline helpers
(cedar/include/cedar/opcodes/drywet.hpp) used by every effect
opcode for the standard resolve + mix line. -
Catch2 dry/wet contract tests in
cedar/tests/test_drywet.cpp
covering one slot-based and one ExtendedParams-based example per
category. -
Bus routing — diamond
<>operator (signal <> 3routes to bus 3),
numbered buses with always-safemaster, per-bus FX via mixer/master
closures. Three phases shipped: numbered buses + master, per-bus FX,
and the<>operator at pipe precedence. -
Per-element
*N//Nrate modifiers in mini-notation —
n"c4*2 d4/2"doubles/halves individual event durations under one
uniform per-element mechanism (no top-level vs inner split). -
Runtime event transforms — closure-taking
event_map/
event_filter, runtimefast()/slow()(EVENT_RATE_SCALE),
structuralEVENT_REORDER/EVENT_FANOUT, and stdlibkey/
scale/voice/invert/swing/swingBy/early/late
/ 5 property modifiers. Chord-array READ/WRITE inside event_map
closures. Newfmodbuiltin + stdlib.akembed mechanism. -
Block-rate control flow —
when() { … }conditional bypass,
loop(N) { body }bounded static iteration,#inlineannotation
with recursion rejection,each()/reduce()over event records,
FOREACH_EVENT+ subprogram table (POLY migrated onto it),
BLOCK_CALLshared-block fn dispatch,BLOCK_BINDfor shareable
fns with >5 params. -
Parameter type annotations Phase 2 —
evs/sig/num/
rec/arr/str/fnannotations parsed vianame: type
grammar, propagated through analyzer, dispatched in
handle_user_function_call. NewE184type-mismatch diagnostic. -
Built-in Tidal Drum Machines sample catalog — TR-808/909/etc.
packs ship inside the WASM bundle, addressable froms"…"patterns. -
SF_VOICEopcode — single-voice soundfont primitive (poly
unification Phase 1). -
transport()builtin is now reachable — previously declared but
unregistered. -
Live-editor → embedding parent postMessage — iframe embeds can
observe code edits.
Changed
chorus,flanger,phaserextend their existingExtendedParams
withdry/wetslots (chorus 1→3, flanger 1→3, phaser 3→5). The
positional argument order is unchanged for existing call sites.phaserinternal topology: the opcode now emits just the
all-pass cascade output and lets thedry/wetmix produce the
canonical Bode/MXR phaser sum. With defaultdry=1, wet=0.5the
phaser sounds milder than the pre-migration canonical sum; set
wet=1.0to recover the previous +6 dB-peak topology. Math is
bit-identical to the prior code atdry=1, wet=1.freeverb,dattorro,moog,diode,formant,sallenkey,
tape,xfmr,excite,gate,fold,pingpongmigrate to
ExtendedParams<2>fordry/wet(their input slots were full).
pingpong's custom codegen handler now emits the ExtendedParams init
manually and acceptsdry:/wet:as kwargs in both overloads
(pingpong(stereo, t, fb)andpingpong(L, R, t, fb, width)).- Documentation: every effect doc page in
web/static/docs/reference/builtins/carries a Category-A/B intro
paragraph and per-effectdry/wetrows in the parameter tables.
CLAUDE.md §"Effect Parameters" rewritten with the full convention.
Fixed
legato()no longer retriggers on every note. A pre-existing VM bug
set the gate-on edge unconditionally for bothmonoandlegato, so
legatore-attacked the envelope identically tomono. Overlapping
legato notes now glide without re-attacking (gate stays high); a note
arriving after the previous one's release tail still retriggers, as
documented.- Hot-swap robustness —
ExtendedParams<N>slots,SEQPAT_QUERY
cycle cache, andforeach_eventstate all survive recompiles; audio
arena buffers are deep-copied into the crossfade snapshot; state pool
is snapshotted across crossfade dual-execution; byte-identical
recompiles skip the crossfade entirely. SEQPAT_STEPmid-block cycle wrap —state.outputis refreshed
on the wrap, eliminating a one-block stale-value glitch.- Rest as first event — no longer fires a phantom trigger on the
cycle wrap. - Mixer-closure stereo copy-back — must not alias the L bus into
both channels. fnarrow body — no longer swallows the line that follows...recordspread — fields now bind to builtin params by name.<>operator — parses as a pipe-precedence infix, not
statement-only.- Canonical closure-body recovery — handles bare-identifier bodies.
- Step-highlighting offsets — accurate source ranges for pattern
step highlights in the editor. - StatePool tables are heap-allocated so the VM fits on the default
thread stack.
Known limitations / deferred work
experiments/test_op_phaser.pynotch-depth check now reports
~9–10 dB (threshold 12 dB) — investigation shows the algorithm is
bit-identical pre/post when called withdry=1, wet=1, but the
test's_find_notchmeasurement is sensitive to RNG / FFT window
alignment that drifts when the StatePool state-id layout changes.
Pre-existing measurement fragility, not a behavioral regression.experiments/test_op_diode.pyfailures are pre-existing (unrelated
to this PRD).