All posts
v0.4.1

v0.4.1

⚠ BREAKING — CLI binaries renamed: nkido-clinkido, akkado-cliakkado

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 order freq, 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 to 0 or to an explicit {cutoff = 0.5} destructure default. The historical (freq, gate, vel) positional form stays valid; mono and legato 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/legato instrument, or called directly, not only when passed inline as a direct poly() 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 forms e.notes / e.freqs are 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, and map() over a dynamic array stays dynamic. Combined with step() / 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, use poly()). New SEQPAT_VALUES opcode; demo patches arpeggio-demo and harmonizer-demo.

  • Unified dry/wet convention across every effect builtin — all 33 effect builtins (delays incl. pingpong, reverbs, modulation, comb, filters, distortion, dynamics) now expose dry and wet as their last two parameters and apply the standard mix out = dry_in * dry + processed * wet per 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; set dry>0 for NY compression / parallel filter / parallel distortion).
  • 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 <> 3 routes to bus 3), numbered buses with always-safe master, 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 / /N rate modifiers in mini-notationn"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, runtime fast() / slow() (EVENT_RATE_SCALE), structural EVENT_REORDER / EVENT_FANOUT, and stdlib key / scale / voice / invert / swing / swingBy / early / late / 5 property modifiers. Chord-array READ/WRITE inside event_map closures. New fmod builtin + stdlib .ak embed mechanism.

  • Block-rate control flowwhen() { … } conditional bypass, loop(N) { body } bounded static iteration, #inline annotation with recursion rejection, each() / reduce() over event records, FOREACH_EVENT + subprogram table (POLY migrated onto it), BLOCK_CALL shared-block fn dispatch, BLOCK_BIND for shareable fns with >5 params.

  • Parameter type annotations Phase 2evs / sig / num / rec / arr / str / fn annotations parsed via name: type grammar, propagated through analyzer, dispatched in handle_user_function_call. New E184 type-mismatch diagnostic.

  • Built-in Tidal Drum Machines sample catalog — TR-808/909/etc. packs ship inside the WASM bundle, addressable from s"…" patterns.

  • SF_VOICE opcode — 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, phaser extend their existing ExtendedParams with dry / wet slots (chorus 1→3, flanger 1→3, phaser 3→5). The positional argument order is unchanged for existing call sites.
  • phaser internal topology: the opcode now emits just the all-pass cascade output and lets the dry/wet mix produce the canonical Bode/MXR phaser sum. With default dry=1, wet=0.5 the phaser sounds milder than the pre-migration canonical sum; set wet=1.0 to recover the previous +6 dB-peak topology. Math is bit-identical to the prior code at dry=1, wet=1.
  • freeverb, dattorro, moog, diode, formant, sallenkey, tape, xfmr, excite, gate, fold, pingpong migrate to ExtendedParams<2> for dry/wet (their input slots were full). pingpong's custom codegen handler now emits the ExtendedParams init manually and accepts dry: / wet: as kwargs in both overloads (pingpong(stereo, t, fb) and pingpong(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-effect dry/wet rows 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 both mono and legato, so legato re-attacked the envelope identically to mono. 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 robustnessExtendedParams<N> slots, SEQPAT_QUERY cycle cache, and foreach_event state 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_STEP mid-block cycle wrapstate.output is 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.
  • fn arrow body — no longer swallows the line that follows.
  • ..record spread — 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.py notch-depth check now reports ~9–10 dB (threshold 12 dB) — investigation shows the algorithm is bit-identical pre/post when called with dry=1, wet=1, but the test's _find_notch measurement 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.py failures are pre-existing (unrelated to this PRD).