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 to0or to an explicit{cutoff = 0.5}destructure default. The historical(freq, gate, vel)positional form stays valid;monoandlegatoaccept 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 apoly/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()). NewSEQPAT_VALUESopcode; demo patchesarpeggio-demoandharmonizer-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 mixout = 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>0for 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.cppcovering 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: typegrammar, propagated through analyzer, dispatched inhandle_user_function_call. NewE184type-mismatch diagnostic.Built-in Tidal Drum Machines sample catalog — TR-808/909/etc. packs ship inside the WASM bundle, addressable from
s"…"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 existingExtendedParamswithdry/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; setwet=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 toExtendedParams<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, solegatore-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_QUERYcycle 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).