Skip to content

v0.4.1

Choose a tag to compare

@github-actions github-actions released this 27 May 22:38
· 69 commits to master since this release

⚠ 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-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, 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).