From e51c490d095a0130287b709ef3a718bc2b13fa64 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Mon, 17 Aug 2026 14:38:51 +0300 Subject: [PATCH] feat: extract ModuLisp engine from uSEQ (src-useq @1081ae8) --- .gitignore | 14 +- ALIGNMENT.md | 36 + MAP.md | 37 + library.properties | 9 + meson.build | 107 + src/devtools/devtools.cpp | 916 + src/devtools/devtools.h | 94 + src/modulisp/lisp/symbol_intern.h | 87 + src/pch.h | 61 + src/ports/II2CTransport.h | 34 + src/ports/IStorage.h | 15 + src/ports/mocks/MockI2CBus.h | 134 + src/ports/mocks/MockStorage.h | 60 + src/signal_engine/build_profile.h | 14 + src/signal_engine/cell_store.cpp | 148 + src/signal_engine/cell_store.h | 105 + src/signal_engine/cold_eval.cpp | 3323 +++ src/signal_engine/cold_eval.h | 236 + src/signal_engine/compiler_pipeline.cpp | 125 + src/signal_engine/compiler_pipeline.h | 95 + src/signal_engine/diagnostics.cpp | 111 + src/signal_engine/diagnostics.h | 49 + src/signal_engine/eval_ops.h | 91 + src/signal_engine/executor.cpp | 447 + src/signal_engine/executor.h | 112 + src/signal_engine/graph_builder.cpp | 4004 +++ src/signal_engine/graph_builder.h | 471 + src/signal_engine/node_pool.cpp | 517 + src/signal_engine/node_pool.h | 275 + src/signal_engine/signal_engine.h | 15 + src/signal_engine/state_registry.cpp | 113 + src/signal_engine/state_registry.h | 102 + src/signal_engine/symbols.def | 310 + src/signal_engine/synth_graph.cpp | 208 + src/signal_engine/synth_graph.h | 331 + src/signal_engine/synth_registry.cpp | 160 + src/signal_engine/synth_registry.h | 116 + src/signal_engine/token.cpp | 329 + src/signal_engine/token.h | 54 + src/signal_engine/types.h | 110 + src/utils/common.cpp | 10 + src/utils/common.h | 192 + src/utils/compiler_config.h | 20 + src/utils/dtostrf.h | 52 + src/utils/itoa.cpp | 82 + src/utils/itoa.h | 46 + src/utils/json_builder.h | 220 + src/utils/json_cursor.h | 303 + src/utils/log.cpp | 424 + src/utils/log.h | 110 + src/utils/serial_message.h | 28 + src/utils/string.cpp | 887 + src/utils/string.h | 460 + src/utils/time.h | 59 + test/catch.hpp | 21260 ++++++++++++++++ test/meson.build | 57 + test/signal_engine/test_audit_fixes.cpp | 824 + .../test_builtin_conformance.cpp | 598 + test/signal_engine/test_compiler_p1.cpp | 193 + test/signal_engine/test_failure_mode.cpp | 274 + .../signal_engine/test_health_diagnostics.cpp | 369 + test/signal_engine/test_live_edit.cpp | 340 + .../test_node_pool_traversal.cpp | 81 + .../test_output_classification.cpp | 168 + test/signal_engine/test_resource_reclaim.cpp | 598 + test/signal_engine/test_signal_engine.cpp | 3951 +++ .../test_signal_engine_golden.cpp | 1906 ++ .../test_signal_engine_phase4.cpp | 610 + .../test_signal_engine_robustness.cpp | 999 + test/signal_engine/test_state_identity.cpp | 636 + test/signal_engine/test_synth_compiler.cpp | 935 + test/signal_engine/test_synth_wasm_abi.cpp | 248 + test/signal_engine/test_ugens.cpp | 479 + 73 files changed, 50985 insertions(+), 9 deletions(-) create mode 100644 ALIGNMENT.md create mode 100644 MAP.md create mode 100644 library.properties create mode 100644 meson.build create mode 100644 src/devtools/devtools.cpp create mode 100644 src/devtools/devtools.h create mode 100644 src/modulisp/lisp/symbol_intern.h create mode 100644 src/pch.h create mode 100644 src/ports/II2CTransport.h create mode 100644 src/ports/IStorage.h create mode 100644 src/ports/mocks/MockI2CBus.h create mode 100644 src/ports/mocks/MockStorage.h create mode 100644 src/signal_engine/build_profile.h create mode 100644 src/signal_engine/cell_store.cpp create mode 100644 src/signal_engine/cell_store.h create mode 100644 src/signal_engine/cold_eval.cpp create mode 100644 src/signal_engine/cold_eval.h create mode 100644 src/signal_engine/compiler_pipeline.cpp create mode 100644 src/signal_engine/compiler_pipeline.h create mode 100644 src/signal_engine/diagnostics.cpp create mode 100644 src/signal_engine/diagnostics.h create mode 100644 src/signal_engine/eval_ops.h create mode 100644 src/signal_engine/executor.cpp create mode 100644 src/signal_engine/executor.h create mode 100644 src/signal_engine/graph_builder.cpp create mode 100644 src/signal_engine/graph_builder.h create mode 100644 src/signal_engine/node_pool.cpp create mode 100644 src/signal_engine/node_pool.h create mode 100644 src/signal_engine/signal_engine.h create mode 100644 src/signal_engine/state_registry.cpp create mode 100644 src/signal_engine/state_registry.h create mode 100644 src/signal_engine/symbols.def create mode 100644 src/signal_engine/synth_graph.cpp create mode 100644 src/signal_engine/synth_graph.h create mode 100644 src/signal_engine/synth_registry.cpp create mode 100644 src/signal_engine/synth_registry.h create mode 100644 src/signal_engine/token.cpp create mode 100644 src/signal_engine/token.h create mode 100644 src/signal_engine/types.h create mode 100644 src/utils/common.cpp create mode 100644 src/utils/common.h create mode 100644 src/utils/compiler_config.h create mode 100644 src/utils/dtostrf.h create mode 100644 src/utils/itoa.cpp create mode 100644 src/utils/itoa.h create mode 100644 src/utils/json_builder.h create mode 100644 src/utils/json_cursor.h create mode 100644 src/utils/log.cpp create mode 100644 src/utils/log.h create mode 100644 src/utils/serial_message.h create mode 100644 src/utils/string.cpp create mode 100644 src/utils/string.h create mode 100644 src/utils/time.h create mode 100644 test/catch.hpp create mode 100644 test/meson.build create mode 100644 test/signal_engine/test_audit_fixes.cpp create mode 100644 test/signal_engine/test_builtin_conformance.cpp create mode 100644 test/signal_engine/test_compiler_p1.cpp create mode 100644 test/signal_engine/test_failure_mode.cpp create mode 100644 test/signal_engine/test_health_diagnostics.cpp create mode 100644 test/signal_engine/test_live_edit.cpp create mode 100644 test/signal_engine/test_node_pool_traversal.cpp create mode 100644 test/signal_engine/test_output_classification.cpp create mode 100644 test/signal_engine/test_resource_reclaim.cpp create mode 100644 test/signal_engine/test_signal_engine.cpp create mode 100644 test/signal_engine/test_signal_engine_golden.cpp create mode 100644 test/signal_engine/test_signal_engine_phase4.cpp create mode 100644 test/signal_engine/test_signal_engine_robustness.cpp create mode 100644 test/signal_engine/test_state_identity.cpp create mode 100644 test/signal_engine/test_synth_compiler.cpp create mode 100644 test/signal_engine/test_synth_wasm_abi.cpp create mode 100644 test/signal_engine/test_ugens.cpp diff --git a/.gitignore b/.gitignore index 259148f..187fae8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,15 +11,6 @@ *.gch *.pch -# Compiled Dynamic libraries -*.so -*.dylib -*.dll - -# Fortran module files -*.mod -*.smod - # Compiled Static libraries *.lai *.la @@ -30,3 +21,8 @@ *.exe *.out *.app + +# Build dirs / tooling +build/ +.pio/ +compile_commands.json diff --git a/ALIGNMENT.md b/ALIGNMENT.md new file mode 100644 index 0000000..4288fbb --- /dev/null +++ b/ALIGNMENT.md @@ -0,0 +1,36 @@ +# ALIGNMENT — ModuLisp standalone repo + +## Mission + +Give the ModuLisp engine — the portable language + DAG signal-engine core of +uSEQ — its own repository so it can evolve and be tested independently of the +firmware, and be consumed two ways: + +1. **Host builds** (`meson setup build && ninja -C build && meson test -C + build`) — the unit-test surface for engine work. +2. **Arduino library** (via `library.properties`) — included by the RP2350 + uSEQ firmware. + +The uSEQ tree remains the system of record for the full product; this repo is +the engine core only. Divergence policy: changes land here first (with tests), +and the uSEQ firmware updates its consumption, not the other way round — until +uSEQ's own copy is retired. + +## Open questions + +1. **License.** The uSEQ tree carries `license/cern_ohl_s_v2.txt` (a hardware + license, wrong fit for C++ sources). No software license has been chosen for + this extracted engine; nothing here is licensed for redistribution yet. +2. **Test exclusions.** `test_flash_storage.cpp`, `test_wasm_wrapper_projection.cpp`, + and `test_osc_sine_nodedef.cpp` stay behind in uSEQ because they link the + firmware, wasm, and nodedef modules respectively (details in README.md). If + those modules are ever extracted, their tests should follow. +3. **Upstream sync.** This extraction was a one-time copy of the working tree + at src-useq git `1081ae8` (2026-08-17). There is no automation keeping this + repo in sync with later uSEQ engine changes; the first divergence will need + a cherry-pick/replay decision. +4. **`modulisp/` crate is a stub.** Only `modulisp/lisp/symbol_intern.h` lives + there today. The expected growth path is that language-level machinery + moves out of `signal_engine/` into `modulisp/` over time; the sibling + directory layout under `src/` exists to keep relative includes stable while + that happens. diff --git a/MAP.md b/MAP.md new file mode 100644 index 0000000..6c30766 --- /dev/null +++ b/MAP.md @@ -0,0 +1,37 @@ +# MAP — ModuLisp repo inventory + +``` +modulisp/ +├── meson.build # project root: useq_utils → useq_devtools → useq_signal_engine static libs +├── library.properties # Arduino library manifest (ModuLisp 0.1.0, includes=src/signal_engine/signal_engine.h) +├── src/ +│ ├── pch.h # precompiled header (std includes) +│ ├── modulisp/ +│ │ └── lisp/symbol_intern.h # header-only symbol interning table +│ ├── signal_engine/ # 11 .cpp + 12 .h + symbols.def +│ │ ├── token.{h,cpp} # tokenizer +│ │ ├── cell_store.{h,cpp} # S-expression cell arena +│ │ ├── node_pool.{h,cpp} # fixed-pool DAG node storage + traversal +│ │ ├── graph_builder.{h,cpp} # cell graph → node DAG compiler +│ │ ├── compiler_pipeline.{h,cpp} # source → tokens → cells → DAG orchestration +│ │ ├── cold_eval.{h,cpp} + eval_ops.h # constant folding / cold evaluation +│ │ ├── executor.{h,cpp} # tick-time DAG executor +│ │ ├── state_registry.{h,cpp} # defstate slots, live-edit slots +│ │ ├── synth_registry.{h,cpp} # synth graph snapshot registry +│ │ ├── synth_graph.{h,cpp} # serialisable synth artefact graph +│ │ ├── diagnostics.{h,cpp} # health/persistent-output diagnostics +│ │ ├── signal_engine.h # umbrella header +│ │ ├── types.h, build_profile.h # shared types, build tuning knobs +│ │ └── symbols.def # builtin symbol catalogue (X-macro list) +│ ├── utils/ # string.{h,cpp}, common.{h,cpp}, itoa.{h,cpp}, log.{h,cpp}, +│ │ # json_builder.h, json_cursor.h, dtostrf.h, serial_message.h, +│ │ # time.h, compiler_config.h +│ ├── devtools/ # devtools.{h,cpp} — USEQ_DEVTOOLS-gated telemetry +│ └── ports/ # IStorage.h, II2CTransport.h, mocks/{MockStorage.h,MockI2CBus.h} +└── test/ + ├── meson.build # 17 Catch2 executables, fresh file + ├── catch.hpp # vendored Catch2 v2 (from src-useq/test/catch.hpp) + └── signal_engine/ # 17 test .cpp (host-only subset; exclusions in README.md) +``` + +Build artifacts: `build/` (meson/ninja). 3 libs + 17 test executables. diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..7af8aeb --- /dev/null +++ b/library.properties @@ -0,0 +1,9 @@ +name=ModuLisp +version=0.1.0 +author=Dimi (w1n5t0n) +maintainer=Dimi (w1n5t0n) +sentence=ModuLisp live-coding signal engine extracted from uSEQ +paragraph=ModuLisp language, DAG signal engine, external-input/sink registries +url=https://code.lnfinitemonkeys.org/w1n5t0n/modulisp +includes=src/signal_engine/signal_engine.h +architectures=* diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..3721d6f --- /dev/null +++ b/meson.build @@ -0,0 +1,107 @@ +# ModuLisp — standalone build of the portable engine extracted from uSEQ. +# +# Provenance: extracted from /home/w1n5t0n/src/useq-perform/src-useq @ git +# 1081ae8 (working-tree state, 2026-08-17). Library structure, flags and +# source lists mirror src-useq/meson.build; the firmware library and every +# firmware/WASM/nodedef-dependent test are intentionally absent. + +project('modulisp', 'cpp', + default_options : ['cpp_std=c++17'], +) + +# Add global compiler arguments with sensible warning configuration +# (identical to src-useq/meson.build). +common_warning_args = [ + '-Wall', # Enable most warnings + '-Wextra', # Enable extra warnings + '-Wpedantic', # Pedantic warnings for ISO C++ compliance + '-Wshadow', # Warn about variable shadowing + '-Wunused', # Warn about unused variables/functions + '-Wuninitialized', # Warn about uninitialized variables + '-Wdouble-promotion', # Warn about float to double promotions + '-Wformat=2', # Strict format string checking + '-Wnull-dereference', # Warn about null pointer dereferences +] + +add_global_arguments(common_warning_args, language : 'cpp') + +# Define include directories +src_inc = include_directories('src') + +# Define precompiled header for faster compilation +pch_file = 'src/pch.h' + +# Host (non-Arduino) compile flags, mirroring src-useq with the always-on +# test configuration baked in: signal engine + devtools telemetry enabled. +standalone_args = [ + '-DUSE_OWN_ARDUINO_STR', + '-DUSE_STD_IO', + '-DNO_ETL', + '-D__not_in_flash(section)=', + '-D__not_in_flash_func(x)=', + '-DENABLE_SIGNAL_ENGINE', + '-DUSEQ_DEVTOOLS=1', +] + +# === STATIC LIBRARIES FOR PROPER DEPENDENCY TRACKING === + +# Utils library - lowest level, no dependencies +utils_lib = static_library('useq_utils', + 'src/utils/string.cpp', + 'src/utils/common.cpp', + 'src/utils/itoa.cpp', + 'src/utils/log.cpp', + include_directories : src_inc, + cpp_args : standalone_args, + cpp_pch : pch_file, +) + +# Create a dependency object for utils +utils_dep = declare_dependency( + link_with : utils_lib, + include_directories : src_inc +) + +# Devtools library - compile-time-gated telemetry instrumentation +devtools_inc = include_directories('src/devtools') +devtools_lib = static_library('useq_devtools', + 'src/devtools/devtools.cpp', + include_directories : [src_inc, devtools_inc], + cpp_args : standalone_args, + cpp_pch : pch_file, + dependencies : utils_dep, +) + +devtools_dep = declare_dependency( + link_with : devtools_lib, + include_directories : [src_inc, devtools_inc], + dependencies : utils_dep +) + +# Signal engine library - depends on utils (symbol_intern.h is header-only) +signal_engine_lib = static_library('useq_signal_engine', + 'src/signal_engine/diagnostics.cpp', + 'src/signal_engine/token.cpp', + 'src/signal_engine/cell_store.cpp', + 'src/signal_engine/node_pool.cpp', + 'src/signal_engine/executor.cpp', + 'src/signal_engine/graph_builder.cpp', + 'src/signal_engine/compiler_pipeline.cpp', + 'src/signal_engine/cold_eval.cpp', + 'src/signal_engine/state_registry.cpp', + 'src/signal_engine/synth_registry.cpp', + 'src/signal_engine/synth_graph.cpp', + include_directories : src_inc, + cpp_args : standalone_args, + cpp_pch : pch_file, + dependencies : [utils_dep, devtools_dep], +) + +signal_engine_dep = declare_dependency( + link_with : signal_engine_lib, + include_directories : src_inc, + dependencies : [utils_dep, devtools_dep] +) + +# Add test subdirectory +subdir('test') diff --git a/src/devtools/devtools.cpp b/src/devtools/devtools.cpp new file mode 100644 index 0000000..565619d --- /dev/null +++ b/src/devtools/devtools.cpp @@ -0,0 +1,916 @@ +#include "devtools.h" + +#if USEQ_DEVTOOLS + +#include "../signal_engine/signal_engine.h" +#include "../utils/json_builder.h" +#include "../utils/json_cursor.h" +#include "../utils/log.h" +#include +#include + +#if defined(ARDUINO_ARCH_RP2040) +extern "C" { +extern uint8_t __StackBottom; +extern uint8_t __StackTop; +} +#endif + +#ifdef USE_STD_IO +#include +static uint32_t dt_micros() { + static const auto start = std::chrono::steady_clock::now(); + auto now = std::chrono::steady_clock::now(); + return static_cast( + std::chrono::duration_cast(now - start).count()); +} +#else +#include +static uint32_t dt_micros() { return micros(); } +#endif + +// ── Output name table ────────────────────────────────────────────────────── + +static const char* output_name(uint16_t idx) { + static const char* names[] = { + "a1","a2","a3","a4","a5","a6","a7","a8", + "d1","d2","d3","d4","d5","d6","d7","d8", + "s1","s2","s3","s4","s5","s6","s7","s8", + }; + if (idx < 24) return names[idx]; + return "?"; +} + +// ── NodeOp name table ────────────────────────────────────────────────────── + +static const char* node_op_name(sig::NodeOp op) { + switch (op) { + case sig::NodeOp::Const: return "Const"; + case sig::NodeOp::RawTimeLoad: return "RawTimeLoad"; + case sig::NodeOp::CellLoad: return "CellLoad"; + case sig::NodeOp::InputLoad: return "InputLoad"; + case sig::NodeOp::PrevOutputLoad: return "PrevOutputLoad"; + case sig::NodeOp::Add: return "Add"; + case sig::NodeOp::Sub: return "Sub"; + case sig::NodeOp::Mul: return "Mul"; + case sig::NodeOp::Div: return "Div"; + case sig::NodeOp::Mod: return "Mod"; + case sig::NodeOp::Expt: return "Expt"; + case sig::NodeOp::Min: return "Min"; + case sig::NodeOp::Max: return "Max"; + case sig::NodeOp::Neg: return "Neg"; + case sig::NodeOp::Abs: return "Abs"; + case sig::NodeOp::Floor: return "Floor"; + case sig::NodeOp::Ceil: return "Ceil"; + case sig::NodeOp::Frac: return "Frac"; + case sig::NodeOp::Sqrt: return "Sqrt"; + case sig::NodeOp::Clamp: return "Clamp"; + case sig::NodeOp::Sin: return "Sin"; + case sig::NodeOp::Cos: return "Cos"; + case sig::NodeOp::Tan: return "Tan"; + case sig::NodeOp::USin: return "USin"; + case sig::NodeOp::UCos: return "UCos"; + case sig::NodeOp::Tri: return "Tri"; + case sig::NodeOp::Sqr: return "Sqr"; + case sig::NodeOp::Pulse: return "Pulse"; + case sig::NodeOp::CmpGt: return "CmpGt"; + case sig::NodeOp::CmpLt: return "CmpLt"; + case sig::NodeOp::CmpGe: return "CmpGe"; + case sig::NodeOp::CmpLe: return "CmpLe"; + case sig::NodeOp::CmpEq: return "CmpEq"; + case sig::NodeOp::Not: return "Not"; + case sig::NodeOp::And: return "And"; + case sig::NodeOp::Or: return "Or"; + case sig::NodeOp::Select: return "Select"; + case sig::NodeOp::VecIndex: return "VecIndex"; + case sig::NodeOp::VecLerp: return "VecLerp"; + case sig::NodeOp::BiToUni: return "BiToUni"; + case sig::NodeOp::UniToBi: return "UniToBi"; + case sig::NodeOp::Scale: return "Scale"; + case sig::NodeOp::Lerp: return "Lerp"; + case sig::NodeOp::HashIndex: return "HashIndex"; + case sig::NodeOp::LoadState: return "LoadState"; + case sig::NodeOp::LoadDt: return "LoadDt"; + case sig::NodeOp::SlotLoad: return "SlotLoad"; + } + return "Unknown"; +} + +static const char* health_name(const sig::NodePool& pool, + uint16_t output_index) { + return sig::output_health_to_cstr( + sig::output_health(pool, output_index)); +} + +static bool op_has_imm(sig::NodeOp op) { + return op == sig::NodeOp::Const || op == sig::NodeOp::CellLoad || + op == sig::NodeOp::InputLoad || op == sig::NodeOp::PrevOutputLoad || + op == sig::NodeOp::VecIndex || op == sig::NodeOp::VecLerp || + op == sig::NodeOp::LoadState || op == sig::NodeOp::SlotLoad; +} + +static bool op_is_binary(sig::NodeOp op) { + return (op >= sig::NodeOp::Add && op <= sig::NodeOp::Max) || + (op >= sig::NodeOp::CmpGt && op <= sig::NodeOp::CmpEq) || + op == sig::NodeOp::And || op == sig::NodeOp::Or || + op == sig::NodeOp::Pulse; +} + +static bool op_is_ternary(sig::NodeOp op) { + return op == sig::NodeOp::Select || op == sig::NodeOp::Scale || + op == sig::NodeOp::Lerp || op == sig::NodeOp::Clamp; +} + +// ══════════════════════════════════════════════════════════════════════════ +// State +// ══════════════════════════════════════════════════════════════════════════ + +namespace { + +enum Channel : uint8_t { + CH_TICK, CH_GRAPH, CH_STATE, CH_EVAL, CH_RESOURCES, CH_IO, CH_PROTOCOL, CH_COUNT +}; + +enum Mode : uint8_t { OFF, POLL, STREAM, EVENTS }; + +struct DevToolsState { + sig::SignalEngine* engine = nullptr; + Mode modes[CH_COUNT] = {}; + uint32_t stream_interval_us = 100000; + + struct { + uint32_t tick_start_us = 0; + uint32_t phase_start_us = 0; + uint32_t phase_durations[8] = {}; + const char* phase_names[8] = {}; + uint8_t phase_count = 0; + uint32_t tick_total_us = 0; + uint32_t tick_count = 0; + uint32_t win_min = UINT32_MAX; + uint32_t win_max = 0; + uint32_t win_sum = 0; + uint32_t win_count = 0; + } tick; + + struct { + uint32_t start_us = 0; + uint32_t last_us = 0; + uint32_t max_us = 0; + uint32_t count = 0; + uint32_t error_count = 0; + } eval; + + struct Event { + uint32_t ts_us = 0; + const char* channel = nullptr; + const char* message = nullptr; + const char* detail_str = nullptr; + int detail_int = 0; + bool has_int = false; + }; + Event events[32] = {}; + uint8_t ev_head = 0; + uint8_t ev_count = 0; + + struct Counter { const char* name = nullptr; uint32_t value = 0; }; + Counter counters[16] = {}; + uint8_t counter_count = 0; + + struct Gauge { const char* name = nullptr; uint32_t value = 0; uint32_t capacity = 0; }; + Gauge gauges[16] = {}; + uint8_t gauge_count = 0; + + struct { + uint32_t heap_free = 0; + uint32_t heap_min_free = UINT32_MAX; + uint32_t core0_stack_capacity = 0; + uint32_t core0_stack_high_water = 0; + uintptr_t core0_stack_fill_end = 0; + bool core0_stack_initialized = false; + bool core0_stack_margin_intact = true; + } memory; + + uint32_t last_stream_us = 0; +}; + +static DevToolsState s; + +constexpr uint32_t TICK_WINDOW_SIZE = 100; + +} // anonymous namespace + +// ══════════════════════════════════════════════════════════════════════════ +// Collection +// ══════════════════════════════════════════════════════════════════════════ + +namespace dt { + +namespace { +void sample_core0_stack() { +#if defined(ARDUINO_ARCH_RP2040) + if (s.memory.core0_stack_initialized) { + constexpr uintptr_t SDK_STACK_GUARD_BYTES = 32; + constexpr uint8_t STACK_PATTERN = 0xA5; + const uintptr_t bottom = reinterpret_cast(&__StackBottom); + const uintptr_t top = reinterpret_cast(&__StackTop); + const uintptr_t sample_begin = bottom + SDK_STACK_GUARD_BYTES; + uintptr_t first_touched = sample_begin; + while (first_touched < s.memory.core0_stack_fill_end && + *reinterpret_cast(first_touched) == + STACK_PATTERN) { + first_touched++; + } + const uint32_t used = static_cast(top - first_touched); + if (used > s.memory.core0_stack_high_water) + s.memory.core0_stack_high_water = used; + s.memory.core0_stack_margin_intact = + *reinterpret_cast(sample_begin) == + STACK_PATTERN; + } +#endif +} +} // namespace + +void init(sig::SignalEngine* engine) { + s = DevToolsState{}; + s.engine = engine; + +#if defined(ARDUINO_ARCH_RP2040) + // The RP2040 SDK may configure the lowest aligned 32-byte stack + // subregion as an MPU guard. Keep the watermark outside that region. + constexpr uintptr_t SDK_STACK_GUARD_BYTES = 32; + constexpr uintptr_t STACK_SAMPLE_GUARD = 128; + constexpr uint8_t STACK_PATTERN = 0xA5; + uintptr_t stack_pointer = 0; + asm volatile("mov %0, sp" : "=r"(stack_pointer)); + const uintptr_t bottom = reinterpret_cast(&__StackBottom); + const uintptr_t top = reinterpret_cast(&__StackTop); + s.memory.core0_stack_capacity = static_cast(top - bottom); + const uintptr_t sample_begin = bottom + SDK_STACK_GUARD_BYTES; + if (stack_pointer > sample_begin + STACK_SAMPLE_GUARD && + stack_pointer <= top) { + const uintptr_t fill_end = stack_pointer - STACK_SAMPLE_GUARD; + std::memset(reinterpret_cast(sample_begin), STACK_PATTERN, + fill_end - sample_begin); + s.memory.core0_stack_fill_end = fill_end; + s.memory.core0_stack_initialized = true; + } +#endif + sample_runtime_memory(); +} + +void sample_runtime_memory() { + int heap = free_heap(); + if (heap < 0) heap = 0; + s.memory.heap_free = static_cast(heap); + if (s.memory.heap_free < s.memory.heap_min_free) + s.memory.heap_min_free = s.memory.heap_free; +} + +void tick_begin() { + s.tick.tick_start_us = dt_micros(); + s.tick.phase_start_us = s.tick.tick_start_us; + s.tick.phase_count = 0; +} + +void mark(const char* phase) { + uint32_t now = dt_micros(); + if (s.tick.phase_count < 8) { + s.tick.phase_durations[s.tick.phase_count] = now - s.tick.phase_start_us; + s.tick.phase_names[s.tick.phase_count] = phase; + s.tick.phase_count++; + } + s.tick.phase_start_us = now; +} + +void tick_end() { + s.tick.tick_total_us = dt_micros() - s.tick.tick_start_us; + s.tick.tick_count++; + + uint32_t t = s.tick.tick_total_us; + if (t < s.tick.win_min) s.tick.win_min = t; + if (t > s.tick.win_max) s.tick.win_max = t; + s.tick.win_sum += t; + s.tick.win_count++; + + if (s.tick.win_count >= TICK_WINDOW_SIZE) { + s.tick.win_min = UINT32_MAX; + s.tick.win_max = 0; + s.tick.win_sum = 0; + s.tick.win_count = 0; + } +} + +void eval_begin() { + s.eval.start_us = dt_micros(); + event("eval", "begin"); +} + +void eval_end(bool success) { + s.eval.last_us = dt_micros() - s.eval.start_us; + if (s.eval.last_us > s.eval.max_us) s.eval.max_us = s.eval.last_us; + s.eval.count++; + count("eval_count"); + if (!success) s.eval.error_count++; + event("eval", success ? "done" : "error", + static_cast(s.eval.last_us)); +} + +void event(const char* channel, const char* message, const char* detail) { + auto& e = s.events[s.ev_head]; + e.ts_us = dt_micros(); + e.channel = channel; + e.message = message; + e.detail_str = detail; + e.has_int = false; + s.ev_head = (s.ev_head + 1) & 31; + if (s.ev_count < 32) s.ev_count++; +} + +void event(const char* channel, const char* message, int detail) { + auto& e = s.events[s.ev_head]; + e.ts_us = dt_micros(); + e.channel = channel; + e.message = message; + e.detail_str = nullptr; + e.detail_int = detail; + e.has_int = true; + s.ev_head = (s.ev_head + 1) & 31; + if (s.ev_count < 32) s.ev_count++; +} + +void count(const char* name) { + for (uint8_t i = 0; i < s.counter_count; ++i) { + if (std::strcmp(s.counters[i].name, name) == 0) { + s.counters[i].value++; + return; + } + } + if (s.counter_count < 16) { + s.counters[s.counter_count] = {name, 1}; + s.counter_count++; + } +} + +void gauge(const char* name, uint32_t value) { + for (uint8_t i = 0; i < s.gauge_count; ++i) { + if (std::strcmp(s.gauges[i].name, name) == 0) { + s.gauges[i].value = value; + return; + } + } + if (s.gauge_count < 16) { + s.gauges[s.gauge_count] = {name, value, 0}; + s.gauge_count++; + } +} + +void gauge(const char* name, uint32_t value, uint32_t capacity) { + for (uint8_t i = 0; i < s.gauge_count; ++i) { + if (std::strcmp(s.gauges[i].name, name) == 0) { + s.gauges[i].value = value; + s.gauges[i].capacity = capacity; + return; + } + } + if (s.gauge_count < 16) { + s.gauges[s.gauge_count] = {name, value, capacity}; + s.gauge_count++; + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// JSON helpers +// ══════════════════════════════════════════════════════════════════════════ + +static void send_json(WriteFn write_fn, const String& json) { + write_fn(json.c_str(), json.length()); +} + +static const char* mode_str(Mode m) { + switch (m) { + case OFF: return "off"; + case POLL: return "poll"; + case STREAM: return "stream"; + case EVENTS: return "events"; + } + return "off"; +} + +static Mode parse_mode(const char* str) { + if (!str) return OFF; + if (strcmp(str, "poll") == 0) return POLL; + if (strcmp(str, "stream") == 0) return STREAM; + if (strcmp(str, "events") == 0) return EVENTS; + return OFF; +} + +static size_t extract_str(const char* json, size_t len, + const char* key, char* out, size_t out_sz) { + if (!out || out_sz == 0) return 0; + out[0] = '\0'; + useq::json::ObjectCursor object(json, len); + useq::json::Value value; + return object.find(key, value) && + useq::json::copy_string(value, out, out_sz) + ? strlen(out) + : 0; +} + +static int extract_int(const char* json, size_t len, const char* key, int def) { + useq::json::ObjectCursor object(json, len); + useq::json::Value value; + int parsed = def; + return object.find(key, value) && useq::json::integer(value, parsed) + ? parsed + : def; +} + +// ══════════════════════════════════════════════════════════════════════════ +// Channel serializers +// ══════════════════════════════════════════════════════════════════════════ + +static String serialize_tick() { + JsonBuilder j; + j.object_begin() + .field("tick_count", static_cast(s.tick.tick_count)) + .field("last_total_us", static_cast(s.tick.tick_total_us)); + + { + JsonBuilder phases; + phases.object_begin(); + for (uint8_t i = 0; i < s.tick.phase_count; ++i) { + if (s.tick.phase_names[i]) + phases.field(s.tick.phase_names[i], static_cast(s.tick.phase_durations[i])); + } + phases.object_end(); + j.field_raw("phases", phases.build()); + } + + uint32_t avg = s.tick.win_count > 0 ? s.tick.win_sum / s.tick.win_count : 0; + j.field("win_min_us", static_cast(s.tick.win_min == UINT32_MAX ? 0 : s.tick.win_min)) + .field("win_max_us", static_cast(s.tick.win_max)) + .field("win_avg_us", static_cast(avg)) + .field("win_count", static_cast(s.tick.win_count)); + + j.object_end(); + return j.build(); +} + +static String serialize_graph(const char* output_filter) { + if (!s.engine) return "[]"; + const auto& pool = s.engine->pool; + + JsonBuilder j; + j.array_begin_unkeyed(); + + for (uint16_t oi = 0; oi < 24; ++oi) { + const auto& slot = pool.outputs[oi]; + if (slot.root_node == sig::NODE_NONE) continue; + + const char* name = output_name(oi); + if (output_filter && output_filter[0] != '\0' && strcmp(output_filter, name) != 0) + continue; + + j.object_begin() + .field("name", name) + .field("health", health_name(pool, oi)) + .field("root", static_cast(slot.root_node)); + + // Nodes + j.array_begin("nodes"); + for (uint16_t ei = 0; ei < pool.exec_count; ++ei) { + uint16_t ni = pool.exec_order[ei]; + if (ni >= pool.node_count) continue; + const auto& node = pool.nodes[ni]; + + j.object_begin() + .field("id", static_cast(ni)) + .field("op", node_op_name(node.op)); + + if (op_has_imm(node.op)) + j.field("imm", static_cast(node.imm)); + + if (node.input_a != sig::NODE_NONE) + j.field("a", static_cast(node.input_a)); + if ((op_is_binary(node.op) || op_is_ternary(node.op)) && + node.input_b != sig::NODE_NONE) + j.field("b", static_cast(node.input_b)); + if (op_is_ternary(node.op) && node.input_c != sig::NODE_NONE) + j.field("c", static_cast(node.input_c)); + + j.object_end(); + } + j.array_end(); + + // Source text + if (oi < sig::MAX_OUTPUTS && s.engine->output_sources[oi].has_source) { + const auto& os = s.engine->output_sources[oi]; + const char* src = s.engine->arena.read(os.arena_offset); + if (src) + j.field("source", String(src, static_cast(os.arena_length))); + } + + // Dependencies + const auto& deps = pool.output_deps[oi]; + if (deps.count > 0) { + JsonBuilder da; + da.array_begin_unkeyed(); + for (uint8_t d = 0; d < deps.count; ++d) { + const String& sym = getSymbolString(deps.cells[d]); + if (sym.length() > 0) + da.field("", sym); // unkeyed string won't work; use raw + } + da.array_end(); + j.field_raw("deps", da.build()); + } + + j.object_end(); + } + j.array_end(); + return j.build(); +} + +static String serialize_state() { + if (!s.engine) return "{}"; + const auto& engine = *s.engine; + + JsonBuilder j; + j.object_begin() + .field("is_playing", engine.state.is_playing); + + // Cells + j.array_begin("cells"); + for (uint16_t i = 1; i < sig::MAX_CELLS; ++i) { + const auto& cell = engine.cells.cells[i]; + if (cell.kind == sig::CellKind::Empty) continue; + const String& name = getSymbolString(static_cast(i)); + j.object_begin().field("name", name); + switch (cell.kind) { + case sig::CellKind::Number: j.field("kind", "number"); break; + case sig::CellKind::Data: j.field("kind", "data"); break; + case sig::CellKind::Callable: j.field("kind", "callable"); break; + case sig::CellKind::Nil: j.field("kind", "nil"); break; + default: break; + } + j.object_end(); + } + j.array_end(); + + // Outputs + j.array_begin("outputs"); + for (uint16_t i = 0; i < 24; ++i) { + j.object_begin() + .field("name", output_name(i)) + .field("health", health_name(engine.pool, i)) + .object_end(); + } + j.array_end(); + + // State slots + if (engine.pool.state_slot_count > 0) { + j.array_begin("state_slots"); + for (uint16_t i = 0; i < engine.pool.state_slot_count; ++i) { + j.object_begin() + .field("id", static_cast(i)) + .object_end(); + } + j.array_end(); + } + + j.object_end(); + return j.build(); +} + +static String serialize_eval_timing() { + JsonBuilder j; + j.object_begin() + .field("last_us", static_cast(s.eval.last_us)) + .field("max_us", static_cast(s.eval.max_us)) + .field("count", static_cast(s.eval.count)) + .field("error_count", static_cast(s.eval.error_count)) + .object_end(); + return j.build(); +} + +static String serialize_eval() { + JsonBuilder j; + j.object_begin() + .field("last_us", static_cast(s.eval.last_us)) + .field("max_us", static_cast(s.eval.max_us)) + .field("count", static_cast(s.eval.count)) + .field("error_count", static_cast(s.eval.error_count)); + { + JsonBuilder events; + events.array_begin_unkeyed(); + uint8_t count = s.ev_count < 32 ? s.ev_count : 32; + for (uint8_t i = 0; i < count; ++i) { + uint8_t idx = (s.ev_head - 1 - i) & 31; + const auto& e = s.events[idx]; + if (!e.channel) continue; + events.object_begin() + .field("ts_us", static_cast(e.ts_us)) + .field("channel", e.channel) + .field("message", e.message); + if (e.has_int) + events.field("detail", e.detail_int); + else if (e.detail_str) + events.field("detail", e.detail_str); + events.object_end(); + } + events.array_end(); + j.field_raw("events", events.build()); + } + j.object_end(); + return j.build(); +} + +static String serialize_resources() { + sample_runtime_memory(); + sample_core0_stack(); + JsonBuilder j; + j.object_begin() + .field("heap_free", static_cast(s.memory.heap_free)) + .field("heap_min_free", static_cast(s.memory.heap_min_free)) + .field("core0_stack_margin_intact", + s.memory.core0_stack_margin_intact); + { + JsonBuilder stack; + stack.object_begin() + .field("initialized", s.memory.core0_stack_initialized) + .field("used", static_cast(s.memory.core0_stack_high_water)) + .field("capacity", static_cast(s.memory.core0_stack_capacity)) + .object_end(); + j.field_raw("core0_stack", stack.build()); + } + if (s.engine) { + uint16_t cells_used = 0; + for (uint16_t i = 0; i < sig::MAX_CELLS; ++i) { + if (s.engine->cells.cells[i].kind != sig::CellKind::Empty) + cells_used++; + } + uint16_t data_entries_used = 0; + if (s.engine->cells.data_table_count > 0) { + const uint16_t last = s.engine->cells.data_table_count - 1; + data_entries_used = static_cast( + s.engine->cells.data_offsets[last] + + s.engine->cells.data_lengths[last]); + } + auto ratio = [&j](const char* name, uint32_t used, + uint32_t capacity) { + JsonBuilder value; + value.object_begin() + .field("used", static_cast(used)) + .field("capacity", static_cast(capacity)) + .object_end(); + j.field_raw(name, value.build()); + }; + ratio("nodes", s.engine->pool.node_count, sig::MAX_TOTAL_NODES); + ratio("arena", s.engine->arena.write_head, sig::SOURCE_ARENA_SIZE); + ratio("cells", cells_used, sig::MAX_CELLS); + ratio("data_entries", data_entries_used, sig::MAX_DATA_ENTRIES); + ratio("state_slots", s.engine->pool.state_slot_count, + sig::MAX_STATE_SLOTS); + ratio("live_slots", s.engine->pool.live_slot_count, + sig::MAX_LIVE_SLOTS); +#if USEQ_HAS_SYNTH_ENGINE + ratio("synth_declarations", s.engine->synth_graph.declaration_count(), + sig::MAX_SYNTH_DECLARATIONS); + ratio("synth_controls", s.engine->synth_graph.control_count(), + sig::MAX_SYNTH_CONTROLS); +#endif + } + for (uint8_t i = 0; i < s.gauge_count; ++i) { + if (s.gauges[i].capacity > 0) { + JsonBuilder g; + g.object_begin() + .field("used", static_cast(s.gauges[i].value)) + .field("capacity", static_cast(s.gauges[i].capacity)) + .object_end(); + j.field_raw(s.gauges[i].name, g.build()); + } else { + j.field(s.gauges[i].name, static_cast(s.gauges[i].value)); + } + } + j.object_end(); + return j.build(); +} + +static String serialize_io() { + if (!s.engine) return "{}"; + JsonBuilder j; + j.object_begin(); + j.array_begin("outputs"); + for (uint16_t i = 0; i < 24; ++i) { + j.object_begin() + .field("name", output_name(i)) + .object_end(); + } + j.array_end(); + j.object_end(); + return j.build(); +} + +static String serialize_protocol() { + JsonBuilder j; + j.object_begin(); + for (uint8_t i = 0; i < s.counter_count; ++i) + j.field(s.counters[i].name, static_cast(s.counters[i].value)); + if (s.tick.tick_count > 0 && s.tick.win_count > 0) { + uint32_t avg_us = s.tick.win_sum / s.tick.win_count; + uint32_t uptime_s = static_cast( + (static_cast(s.tick.tick_count) * avg_us) / 1000000ULL); + j.field("uptime_s", static_cast(uptime_s)); + } + j.object_end(); + return j.build(); +} + +// ══════════════════════════════════════════════════════════════════════════ +// Protocol handler +// ══════════════════════════════════════════════════════════════════════════ + +static const struct { const char* name; Channel ch; } s_ch_map[] = { + {"tick", CH_TICK}, {"graph", CH_GRAPH}, {"state", CH_STATE}, + {"eval", CH_EVAL}, {"resources", CH_RESOURCES}, + {"io", CH_IO}, {"protocol", CH_PROTOCOL}, +}; + +static Channel parse_channel_name(const char* name) { + for (const auto& m : s_ch_map) + if (strcmp(name, m.name) == 0) return m.ch; + return CH_COUNT; +} + +static void handle_capabilities(const char* json, size_t len, WriteFn write_fn) { + char req_id[64] = {}; + extract_str(json, len, "requestId", req_id, sizeof(req_id)); + + static const struct { const char* name; const char* modes; const char* desc; } ch_info[] = { + {"tick", "[\"off\",\"poll\",\"stream\"]", "Per-phase tick timing (us)"}, + {"graph", "[\"off\",\"poll\"]", "Signal graph topology and node values"}, + {"state", "[\"off\",\"poll\"]", "Cells, outputs, health, LKG"}, + {"eval", "[\"off\",\"events\"]", "Compilation events and recompile cascades"}, + {"resources", "[\"off\",\"poll\",\"stream\"]", "Heap, node pool, arena utilization"}, + {"io", "[\"off\",\"poll\",\"stream\"]", "Hardware input and output values"}, + {"protocol", "[\"off\",\"poll\",\"stream\"]", "Message counters, drops, backpressure"}, + }; + + JsonBuilder j; + j.object_begin() + .field("type", "response") + .field("requestId", req_id) + .field("success", true) + .field("devtools", true); + + j.array_begin("channels"); + for (int i = 0; i < CH_COUNT; ++i) { + j.object_begin() + .field("name", ch_info[i].name) + .field_raw("modes", ch_info[i].modes) + .field("current", mode_str(s.modes[i])) + .field("description", ch_info[i].desc) + .object_end(); + } + j.array_end(); + j.object_end(); + + send_json(write_fn, j.build()); +} + +static void handle_configure(const char* json, size_t len, WriteFn write_fn) { + char req_id[64] = {}; + extract_str(json, len, "requestId", req_id, sizeof(req_id)); + + for (const auto& cm : s_ch_map) { + char mode_buf[16] = {}; + if (extract_str(json, len, cm.name, mode_buf, sizeof(mode_buf)) > 0) + s.modes[cm.ch] = parse_mode(mode_buf); + } + + int rate = extract_int(json, len, "streamRateHz", 0); + if (rate >= 1 && rate <= 100) + s.stream_interval_us = 1000000 / static_cast(rate); + + JsonBuilder j; + j.object_begin() + .field("type", "response") + .field("requestId", req_id) + .field("success", true); + { + JsonBuilder ch; + ch.object_begin(); + for (const auto& cm : s_ch_map) + ch.field(cm.name, mode_str(s.modes[cm.ch])); + ch.object_end(); + j.field_raw("channels", ch.build()); + } + j.object_end(); + send_json(write_fn, j.build()); +} + +static void handle_query(const char* json, size_t len, WriteFn write_fn) { + char req_id[64] = {}; + char channel[32] = {}; + char output[8] = {}; + extract_str(json, len, "requestId", req_id, sizeof(req_id)); + extract_str(json, len, "channel", channel, sizeof(channel)); + extract_str(json, len, "output", output, sizeof(output)); + + String data; + Channel ch = parse_channel_name(channel); + switch (ch) { + case CH_TICK: data = serialize_tick(); break; + case CH_GRAPH: data = serialize_graph(output); break; + case CH_STATE: data = serialize_state(); break; + case CH_EVAL: + data = strcmp(output, "timing") == 0 + ? serialize_eval_timing() : serialize_eval(); + break; + case CH_RESOURCES: data = serialize_resources(); break; + case CH_IO: data = serialize_io(); break; + case CH_PROTOCOL: data = serialize_protocol(); break; + default: { + JsonBuilder err; + err.object_begin() + .field("type", "response") + .field("requestId", req_id) + .field("success", false) + .field("error", "unknown channel") + .object_end(); + send_json(write_fn, err.build()); + return; + } + } + + JsonBuilder j; + j.object_begin() + .field("type", "response") + .field("requestId", req_id) + .field("success", true) + .field("channel", channel) + .field_raw("data", data) + .object_end(); + send_json(write_fn, j.build()); +} + +static void handle_status(const char* json, size_t len, WriteFn write_fn) { + char req_id[64] = {}; + extract_str(json, len, "requestId", req_id, sizeof(req_id)); + + JsonBuilder j; + j.object_begin() + .field("type", "response") + .field("requestId", req_id) + .field("success", true); + { + JsonBuilder ch; + ch.object_begin(); + for (const auto& cm : s_ch_map) + ch.field(cm.name, mode_str(s.modes[cm.ch])); + ch.object_end(); + j.field_raw("channels", ch.build()); + } + j.field("streamRateHz", static_cast(1000000 / s.stream_interval_us)); + j.object_end(); + send_json(write_fn, j.build()); +} + +bool handle_debug_message(const char* json, size_t len, WriteFn write_fn) { + char action[32] = {}; + extract_str(json, len, "action", action, sizeof(action)); + + if (strcmp(action, "capabilities") == 0) { handle_capabilities(json, len, write_fn); return true; } + if (strcmp(action, "configure") == 0) { handle_configure(json, len, write_fn); return true; } + if (strcmp(action, "query") == 0) { handle_query(json, len, write_fn); return true; } + if (strcmp(action, "status") == 0) { handle_status(json, len, write_fn); return true; } + return false; +} + +void emit_streaming(WriteFn write_fn, bool can_write) { + if (!can_write) return; + + uint32_t now = dt_micros(); + if (now - s.last_stream_us < s.stream_interval_us) return; + s.last_stream_us = now; + + auto emit = [&](Channel ch, const char* name, String (*fn)()) { + if (s.modes[ch] != STREAM) return; + JsonBuilder j; + j.object_begin() + .field("type", "debug") + .field("channel", name) + .field_raw("data", fn()) + .object_end(); + send_json(write_fn, j.build()); + }; + + emit(CH_TICK, "tick", serialize_tick); + emit(CH_RESOURCES, "resources", serialize_resources); + emit(CH_IO, "io", serialize_io); + emit(CH_PROTOCOL, "protocol", serialize_protocol); +} + +} // namespace dt + +#endif // USEQ_DEVTOOLS diff --git a/src/devtools/devtools.h b/src/devtools/devtools.h new file mode 100644 index 0000000..37744c1 --- /dev/null +++ b/src/devtools/devtools.h @@ -0,0 +1,94 @@ +#ifndef DEVTOOLS_H +#define DEVTOOLS_H + +// DevTools: compile-time-gated instrumentation for firmware telemetry. +// +// When USEQ_DEVTOOLS is defined: collects timing, events, counters, gauges +// into fixed-size buffers and exposes them via the "debug" wire protocol. +// When not defined: every function is an empty inline stub that vanishes at -O1. +// +// See docs/specs/devtools.md for the full specification. + +#include +#include + +// Forward-declare SignalEngine so devtools can accept it without pulling headers +namespace sig { struct SignalEngine; } + +namespace dt { + +#if USEQ_DEVTOOLS + +// ── Tick Profiling ───────────────────────────────────────────────────────── +void tick_begin(); +void mark(const char* phase); +void tick_end(); + +// Measure the compiler/evaluator interval around one wire-level eval. The +// duration excludes serial parsing and response emission, and is retained for +// poll-based target acceptance. +void eval_begin(); +void eval_end(bool success); + +// ── Events ───────────────────────────────────────────────────────────────── +void event(const char* channel, const char* message, + const char* detail = nullptr); +void event(const char* channel, const char* message, int detail); + +// ── Counters (monotonic) ─────────────────────────────────────────────────── +void count(const char* name); + +// ── Gauges (point-in-time) ───────────────────────────────────────────────── +void gauge(const char* name, uint32_t value); +void gauge(const char* name, uint32_t value, uint32_t capacity); + +// ── Initialization ───────────────────────────────────────────────────────── +// Called once from Firmware::init(). Stores the engine pointer for +// graph/state queries. Must be called before any other dt:: function. +void init(sig::SignalEngine* engine); + +// Sample heap usage into the monotonic runtime low-water record. The compiler +// calls this immediately after its largest transient allocations; the +// firmware tick samples steady state. The retained stack canary is scanned +// when the resources channel is queried or emitted. +void sample_runtime_memory(); + +// ── Protocol Integration ─────────────────────────────────────────────────── +// Called by SerialProtocol::dispatch_message() for type "debug". +// write_fn emits a JSON string to serial (caller provides the function). +// Returns true if the message was handled. +using WriteFn = void(*)(const char*, size_t); +bool handle_debug_message(const char* json, size_t len, WriteFn write_fn); + +// Called once per tick after tick_end(). Emits data for streaming channels. +// Checks backpressure via can_write before emitting. +void emit_streaming(WriteFn write_fn, bool can_write); + +#else // !USEQ_DEVTOOLS — everything compiles away + +inline void tick_begin() {} +inline void mark(const char*) {} +inline void tick_end() {} +inline void eval_begin() {} +inline void eval_end(bool) {} + +inline void event(const char*, const char*, const char* = nullptr) {} +inline void event(const char*, const char*, int) {} + +inline void count(const char*) {} + +inline void gauge(const char*, uint32_t) {} +inline void gauge(const char*, uint32_t, uint32_t) {} + +inline void init(sig::SignalEngine*) {} +inline void sample_runtime_memory() {} + +using WriteFn = void(*)(const char*, size_t); +inline bool handle_debug_message(const char*, size_t, WriteFn) { return false; } +inline void emit_streaming(WriteFn, bool) {} + +#endif // USEQ_DEVTOOLS + +} // namespace dt + +#endif // DEVTOOLS_H diff --git a/src/modulisp/lisp/symbol_intern.h b/src/modulisp/lisp/symbol_intern.h new file mode 100644 index 0000000..a2c81c2 --- /dev/null +++ b/src/modulisp/lisp/symbol_intern.h @@ -0,0 +1,87 @@ +#ifndef SYMBOL_INTERN_H_ +#define SYMBOL_INTERN_H_ + +#include "../../utils/string.h" +#include // Using std::map instead of unordered_map due to arduino::String compatibility +#include +#include + +// Symbol interning system for fast symbol comparison +// Instead of comparing strings, we compare integer IDs +class SymbolIntern { +public: + using SymbolID = uint32_t; + static constexpr SymbolID INVALID_ID = 0; + + // Get the singleton instance + static SymbolIntern& getInstance() { + static SymbolIntern instance; + return instance; + } + + // Intern a symbol and get its ID + // If the symbol already exists, returns the existing ID + SymbolID intern(const String& symbol) { + auto it = symbol_to_id.find(symbol); + if (it != symbol_to_id.end()) { + return it->second; + } + + // Allocate new ID + SymbolID id = next_id++; + symbol_to_id[symbol] = id; + + // Store the string for reverse lookup if needed + if (id >= id_to_symbol.size()) { + id_to_symbol.resize(id + 1); + } + id_to_symbol[id] = symbol; + + return id; + } + + // Get the string for a symbol ID (for debugging/display) + const String& getString(SymbolID id) const { + static const String empty_string; + if (id == INVALID_ID || id >= id_to_symbol.size()) { + return empty_string; + } + return id_to_symbol[id]; + } + + // Get ID without interning (returns INVALID_ID if not found) + SymbolID getID(const String& symbol) const { + auto it = symbol_to_id.find(symbol); + return (it != symbol_to_id.end()) ? it->second : INVALID_ID; + } + +private: + SymbolIntern() : next_id(1) { + // Reserve space for common case + id_to_symbol.reserve(256); + // Note: std::map doesn't have reserve() + } + + std::map symbol_to_id; + std::vector id_to_symbol; + SymbolID next_id; + + // Prevent copying + SymbolIntern(const SymbolIntern&) = delete; + SymbolIntern& operator=(const SymbolIntern&) = delete; +}; + +// Convenience functions for quick access +inline SymbolIntern::SymbolID internSymbol(const String& symbol) { + return SymbolIntern::getInstance().intern(symbol); +} + +inline SymbolIntern::SymbolID internSymbol(const char* str, size_t len) { + return SymbolIntern::getInstance().intern(String(str, (unsigned int)len)); +} + +inline const String& getSymbolString(SymbolIntern::SymbolID id) { + return SymbolIntern::getInstance().getString(id); +} + +#endif // SYMBOL_INTERN_H_ diff --git a/src/pch.h b/src/pch.h new file mode 100644 index 0000000..1c8c483 --- /dev/null +++ b/src/pch.h @@ -0,0 +1,61 @@ +// Precompiled header for uSEQ project +// Contains stable system includes to speed up compilation + +#ifndef USEQ_PCH_H +#define USEQ_PCH_H + +// Standard Library - Containers +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Standard Library - Memory Management +#include + +// Standard Library - Algorithms & Utilities +#include +#include +#include +#include +#include +#include + +// Standard Library - Numeric +#include +#include +#include +#include + +// Standard Library - I/O +#include +#include +#include +#include + +// Standard Library - Time & Chrono +#include +#include + +// Standard Library - Other +#include +#include +#include +#include +#include +#include +#include +#include + +// Conditional Arduino headers (if building for Arduino) +#ifdef ARDUINO +#include +#endif + +#endif // USEQ_PCH_H \ No newline at end of file diff --git a/src/ports/II2CTransport.h b/src/ports/II2CTransport.h new file mode 100644 index 0000000..aa2349d --- /dev/null +++ b/src/ports/II2CTransport.h @@ -0,0 +1,34 @@ +#ifndef PORTS_II2C_TRANSPORT_H +#define PORTS_II2C_TRANSPORT_H + +#include +#include + +namespace firmware +{ + +using I2CReceiveCallback = void (*)(void* context, const uint8_t* data, + size_t length); +using I2CRequestCallback = size_t (*)(void* context, uint8_t* data, size_t capacity); + +// The hardware adapter owns bus mechanics. I2CNetwork owns discovery, +// protocol framing, buffering, and expander state. This keeps the latter +// executable in native tests without emulating Arduino's Wire globals. +struct II2CTransport +{ + virtual ~II2CTransport() = default; + + virtual bool begin_host(uint8_t sda_pin, uint8_t scl_pin) = 0; + virtual bool begin_client(uint8_t address, uint8_t sda_pin, uint8_t scl_pin, + void* callback_context, + I2CReceiveCallback receive_callback, + I2CRequestCallback request_callback) = 0; + + virtual bool probe(uint8_t address) = 0; + virtual bool write(uint8_t address, const uint8_t* data, size_t length) = 0; + virtual size_t request(uint8_t address, uint8_t* data, size_t capacity) = 0; +}; + +} // namespace firmware + +#endif // PORTS_II2C_TRANSPORT_H diff --git a/src/ports/IStorage.h b/src/ports/IStorage.h new file mode 100644 index 0000000..6e7f41d --- /dev/null +++ b/src/ports/IStorage.h @@ -0,0 +1,15 @@ +#ifndef ISTORAGE_H_ +#define ISTORAGE_H_ + +#include +#include + +struct IStorage +{ + virtual ~IStorage() = default; + virtual bool write(uint32_t offset, const uint8_t* data, size_t len) = 0; + virtual bool read(uint32_t offset, uint8_t* data, size_t len) = 0; + virtual bool erase(uint32_t offset, size_t len) = 0; +}; + +#endif // ISTORAGE_H_ \ No newline at end of file diff --git a/src/ports/mocks/MockI2CBus.h b/src/ports/mocks/MockI2CBus.h new file mode 100644 index 0000000..ff11667 --- /dev/null +++ b/src/ports/mocks/MockI2CBus.h @@ -0,0 +1,134 @@ +#ifndef PORTS_MOCKS_MOCK_I2C_BUS_H +#define PORTS_MOCKS_MOCK_I2C_BUS_H + +#include "../II2CTransport.h" + +#include + +namespace firmware +{ + +class MockI2CBus +{ +public: + static constexpr size_t ADDRESS_COUNT = 127; + + struct Endpoint + { + bool attached = false; + void* context = nullptr; + I2CReceiveCallback receive_callback = nullptr; + I2CRequestCallback request_callback = nullptr; + }; + + bool attach(uint8_t address, void* context, I2CReceiveCallback receive_callback, + I2CRequestCallback request_callback) + { + if (address == 0 || address >= ADDRESS_COUNT || + receive_callback == nullptr || request_callback == nullptr) + { + return false; + } + endpoints_[address] = { true, context, receive_callback, request_callback }; + return true; + } + + bool probe(uint8_t address) const + { + return address < ADDRESS_COUNT && endpoints_[address].attached && + !nack_[address]; + } + + bool write(uint8_t address, const uint8_t* data, size_t length) + { + if (!probe(address) || data == nullptr || length == 0) + { + return false; + } + ++write_count_; + endpoints_[address].receive_callback(endpoints_[address].context, data, + length); + return true; + } + + size_t request(uint8_t address, uint8_t* data, size_t capacity) + { + if (!probe(address) || data == nullptr || capacity == 0) + { + return 0; + } + return endpoints_[address].request_callback(endpoints_[address].context, + data, capacity); + } + + void set_nack(uint8_t address, bool nack) + { + if (address < ADDRESS_COUNT) + { + nack_[address] = nack; + } + } + + size_t write_count() const { return write_count_; } + +private: + std::array endpoints_ = {}; + std::array nack_ = {}; + size_t write_count_ = 0; +}; + +class MockI2CTransport final : public II2CTransport +{ +public: + explicit MockI2CTransport(MockI2CBus& bus) : bus_(bus) {} + + bool begin_host(uint8_t sda_pin, uint8_t scl_pin) override + { + host_started_ = true; + sda_pin_ = sda_pin; + scl_pin_ = scl_pin; + return true; + } + + bool begin_client(uint8_t address, uint8_t sda_pin, uint8_t scl_pin, + void* callback_context, I2CReceiveCallback receive_callback, + I2CRequestCallback request_callback) override + { + address_ = address; + sda_pin_ = sda_pin; + scl_pin_ = scl_pin; + client_started_ = bus_.attach(address, callback_context, receive_callback, + request_callback); + return client_started_; + } + + bool probe(uint8_t address) override { return bus_.probe(address); } + + bool write(uint8_t address, const uint8_t* data, size_t length) override + { + return bus_.write(address, data, length); + } + + size_t request(uint8_t address, uint8_t* data, size_t capacity) override + { + return bus_.request(address, data, capacity); + } + + bool host_started() const { return host_started_; } + bool client_started() const { return client_started_; } + uint8_t address() const { return address_; } + uint8_t sda_pin() const { return sda_pin_; } + uint8_t scl_pin() const { return scl_pin_; } + +private: + MockI2CBus& bus_; + bool host_started_ = false; + bool client_started_ = false; + uint8_t address_ = 0; + uint8_t sda_pin_ = 0; + uint8_t scl_pin_ = 0; +}; + +} // namespace firmware + +#endif // PORTS_MOCKS_MOCK_I2C_BUS_H diff --git a/src/ports/mocks/MockStorage.h b/src/ports/mocks/MockStorage.h new file mode 100644 index 0000000..021e118 --- /dev/null +++ b/src/ports/mocks/MockStorage.h @@ -0,0 +1,60 @@ +#ifndef MOCKSTORAGE_H_ +#define MOCKSTORAGE_H_ + +#include "../IStorage.h" +#include +#include + +class MockStorage : public IStorage +{ +public: + explicit MockStorage(size_t size = 64 * 1024) : buffer_(size, 0) {} + ~MockStorage() override = default; + + bool write(uint32_t offset, const uint8_t* data, size_t len) override + { + if (offset + len > buffer_.size()) + { + return false; // Out of bounds + } + + std::memcpy(buffer_.data() + offset, data, len); + return true; + } + + bool read(uint32_t offset, uint8_t* data, size_t len) override + { + if (offset + len > buffer_.size()) + { + return false; // Out of bounds + } + + std::memcpy(data, buffer_.data() + offset, len); + return true; + } + + bool erase(uint32_t offset, size_t len) override + { + if (offset + len > buffer_.size()) + { + return false; // Out of bounds + } + + std::memset(buffer_.data() + offset, 0, len); + return true; + } + + // Test helpers + size_t size() const { return buffer_.size(); } + + const uint8_t* data() const { return buffer_.data(); } + + void clear() { std::fill(buffer_.begin(), buffer_.end(), 0); } + + void resize(size_t new_size) { buffer_.resize(new_size, 0); } + +private: + std::vector buffer_; +}; + +#endif // MOCKSTORAGE_H_ \ No newline at end of file diff --git a/src/signal_engine/build_profile.h b/src/signal_engine/build_profile.h new file mode 100644 index 0000000..109cfec --- /dev/null +++ b/src/signal_engine/build_profile.h @@ -0,0 +1,14 @@ +#ifndef SIGNAL_ENGINE_BUILD_PROFILE_H +#define SIGNAL_ENGINE_BUILD_PROFILE_H + +// The synth compiler and host artefact graph belong to the desktop/WASM +// profile. Firmware and the native firmware-capacity harness deliberately do +// not expose or retain that domain. Derive this capability from the target so +// an Arduino build cannot accidentally opt it back in with a stale flag. +#if defined(ARDUINO) || defined(USEQ_FIRMWARE_PROFILE) +#define USEQ_HAS_SYNTH_ENGINE 0 +#else +#define USEQ_HAS_SYNTH_ENGINE 1 +#endif + +#endif // SIGNAL_ENGINE_BUILD_PROFILE_H diff --git a/src/signal_engine/cell_store.cpp b/src/signal_engine/cell_store.cpp new file mode 100644 index 0000000..3125356 --- /dev/null +++ b/src/signal_engine/cell_store.cpp @@ -0,0 +1,148 @@ +#include "cell_store.h" +#include "../modulisp/lisp/symbol_intern.h" +#include + +namespace sig { + +// ── SourceArena ───────────────────────────────────────────────────────────── + +uint32_t SourceArena::store(const char* text, uint32_t length) { + if (write_head > SOURCE_ARENA_SIZE || + length > SOURCE_ARENA_SIZE - write_head) return UINT32_MAX; + uint32_t offset = write_head; + memcpy(data + write_head, text, length); + write_head += length; + return offset; +} + +uint32_t SourceArena::store_reuse(uint32_t existing_offset, + uint32_t existing_length, + const char* text, uint32_t length) { + // A source slot owns its whole previous region. A shorter replacement can + // occupy that same region without consuming any more arena space. + bool existing_region_fits = + existing_offset <= SOURCE_ARENA_SIZE && + existing_length <= SOURCE_ARENA_SIZE - existing_offset; + if (existing_region_fits && length <= existing_length) { + if (length > 0) { + // Recompilation can read the old source directly from the arena, + // so use memmove for the same-region case as well. + memmove(data + existing_offset, text, length); + } + return existing_offset; + } + + return store(text, length); +} + +const char* SourceArena::read(uint32_t offset) const { + if (offset >= SOURCE_ARENA_SIZE) return nullptr; + return data + offset; +} + +void SourceArena::reset() { + memset(data, 0, sizeof(data)); + write_head = 0; +} + +// ── CellStore ─────────────────────────────────────────────────────────────── + +uint16_t CellStore::store_data_table(const Sample* values, uint16_t count) { + // Content-intern: identical source text compiles to identical tables, and + // tables are immutable after storage, so recompiles (on_cell_changed, + // output reassign) must reuse the existing table instead of appending a + // duplicate — otherwise every recompile of a program containing a vector + // literal leaks a table until the pool is exhausted (MAX_DATA_TABLES is + // 32 on firmware). Cold path only; linear scan over <= MAX_DATA_TABLES. + for (uint16_t t = 0; t < data_table_count; t++) { + if (data_lengths[t] != count) continue; + if (memcmp(data_pool + data_offsets[t], values, + count * sizeof(Sample)) == 0) { + return t; + } + } + + if (data_table_count >= MAX_DATA_TABLES) return UINT8_MAX; + + // Find where to append in the pool + uint16_t pool_offset = 0; + if (data_table_count > 0) { + uint16_t last = data_table_count - 1; + pool_offset = data_offsets[last] + data_lengths[last]; + } + + if (pool_offset + count > MAX_DATA_ENTRIES) return UINT8_MAX; + + uint16_t table_id = data_table_count; + data_offsets[table_id] = pool_offset; + data_lengths[table_id] = count; + memcpy(data_pool + pool_offset, values, count * sizeof(Sample)); + data_table_count++; + + return table_id; +} + +const Sample* CellStore::get_data_table(uint16_t table_id, uint16_t& out_length) const { + if (table_id >= data_table_count) { + out_length = 0; + return nullptr; + } + out_length = data_lengths[table_id]; + return data_pool + data_offsets[table_id]; +} + +void CellStore::snapshot_values(Sample* out, size_t max_count) const { + size_t n = max_count < MAX_CELLS ? max_count : MAX_CELLS; + for (size_t i = 0; i < n; i++) { + out[i] = cells[i].value; + } +} + +void CellStore::reset(Sample bpm, int beats_per_bar, + int bars_per_phrase, int phrases_per_section) { + // Keep the monotonic store revision across resets so cached snapshots can + // never mistake a freshly-cleared store for their previous generation. + uint32_t next_revision = store_revision + 1; + if (next_revision == 0) next_revision = 1; + + for (size_t i = 0; i < MAX_CELLS; i++) { + cells[i] = Cell{}; + callables[i] = CallableInfo{}; + } + memset(data_pool, 0, sizeof(data_pool)); + memset(data_offsets, 0, sizeof(data_offsets)); + memset(data_lengths, 0, sizeof(data_lengths)); + data_table_count = 0; + store_revision = next_revision; + + init_timing_defaults(bpm, beats_per_bar, bars_per_phrase, + phrases_per_section); +} + +void CellStore::init_timing_defaults(Sample bpm, int beats_per_bar, + int bars_per_phrase, int phrases_per_section) { + store_revision++; // A12: cell values change below + auto& si = SymbolIntern::getInstance(); + + SymbolID bpm_sym = si.intern("bpm"); + cells[bpm_sym].kind = CellKind::Number; + cells[bpm_sym].value = bpm; + cells[bpm_sym].revision = 1; + + SymbolID bpb_sym = si.intern("beats-per-bar"); + cells[bpb_sym].kind = CellKind::Number; + cells[bpb_sym].value = (Sample)beats_per_bar; + cells[bpb_sym].revision = 1; + + SymbolID bpp_sym = si.intern("bars-per-phrase"); + cells[bpp_sym].kind = CellKind::Number; + cells[bpp_sym].value = (Sample)bars_per_phrase; + cells[bpp_sym].revision = 1; + + SymbolID pps_sym = si.intern("phrases-per-section"); + cells[pps_sym].kind = CellKind::Number; + cells[pps_sym].value = (Sample)phrases_per_section; + cells[pps_sym].revision = 1; +} + +} // namespace sig diff --git a/src/signal_engine/cell_store.h b/src/signal_engine/cell_store.h new file mode 100644 index 0000000..12c9315 --- /dev/null +++ b/src/signal_engine/cell_store.h @@ -0,0 +1,105 @@ +#ifndef SIGNAL_ENGINE_CELL_STORE_H +#define SIGNAL_ENGINE_CELL_STORE_H + +#include "types.h" + +namespace sig { + +// ── Cell Table ────────────────────────────────────────────────────────────── +// Replaces Value (88 bytes) + Environment (string-keyed map) with +// Cell (16 bytes) + flat array indexed by SymbolID. + +enum class CellKind : uint8_t { + Empty, // unused slot + Number, // Sample constant + Data, // numeric array (vectors, step patterns, scale tables) + Callable, // user function template (params + body token offset) + Nil // explicitly nil +}; + +struct Cell { + CellKind kind = CellKind::Empty; + uint8_t flags = 0; // 0x01 = frozen + uint16_t data_table_id = 0; // for Data cells: index into shared data pool + uint32_t revision = 0; // bumped on every change; dirty detection + Sample value = 0.0; // Number: the value. Data: length as Sample. +}; +// sizeof(Cell) == 16 bytes + +struct CallableInfo { + SymbolID params[MAX_CALLABLE_PARAMS] = {}; + uint8_t param_count = 0; + uint8_t pad[3] = {}; + uint32_t source_offset = 0; // byte offset into source arena + uint32_t source_length = 0; // byte length of body source text +}; +// sizeof(CallableInfo) == 24 bytes (with 4-byte SymbolID: 32+4+4+4 = 44... adjust) + +// ── Source Arena ──────────────────────────────────────────────────────────── +// Append-only string buffer for callable body text. + +struct SourceArena { + char data[SOURCE_ARENA_SIZE] = {}; + uint32_t write_head = 0; + + // Store source text, return offset. Returns UINT32_MAX on overflow. + uint32_t store(const char* text, uint32_t length); + + // Re-store source text for a slot. Reuses the slot's existing region when + // the new text fits; otherwise appends a fresh region with store(). + // Returns UINT32_MAX when the fresh region does not fit. + uint32_t store_reuse(uint32_t existing_offset, uint32_t existing_length, + const char* text, uint32_t length); + + // Read back source text. + const char* read(uint32_t offset) const; + + // Reset the arena (e.g. after useq-clear). + void reset(); +}; + +// ── Cell Store ────────────────────────────────────────────────────────────── + +struct CellStore { + Cell cells[MAX_CELLS] = {}; + CallableInfo callables[MAX_CELLS] = {}; // parallel array; valid when kind==Callable + + // Store-wide revision (A12). Bumped whenever cell values may have changed + // (every cold eval, timing init, flash load). Lets per-tick consumers skip + // re-snapshotting all MAX_CELLS values when nothing changed — measured at + // ~40% of the firmware engine tick. Overcounting (bumping without an + // actual change) is safe; missing a bump is not, so bumps happen at the + // coarse mutation entry points rather than per cell write. + uint32_t store_revision = 1; + + // Shared data pool + Sample data_pool[MAX_DATA_ENTRIES] = {}; + uint16_t data_offsets[MAX_DATA_TABLES] = {}; + uint16_t data_lengths[MAX_DATA_TABLES] = {}; + uint8_t data_table_count = 0; + + // Store a new data table. Returns table ID, or UINT8_MAX on overflow. + uint16_t store_data_table(const Sample* values, uint16_t count); + + // Get data table pointer and length. + const Sample* get_data_table(uint16_t table_id, uint16_t& out_length) const; + + // Snapshot cell numeric values for executor (copies cell[i].value for all). + void snapshot_values(Sample* out, size_t max_count) const; + + // Clear all session-owned definitions, callable metadata, and immutable + // data tables, then restore only the well-known timing cells. Used by a + // full session reset (`useq-clear`) and by engine initialisation. + void reset(Sample bpm = 120.0, int beats_per_bar = 4, + int bars_per_phrase = 4, int phrases_per_section = 4); + + // Convenience: initialise the four well-known timing cells. + // bpm (default 120), beats-per-bar (default 4), + // bars-per-phrase (default 4), phrases-per-section (default 4). + void init_timing_defaults(Sample bpm = 120.0, int beats_per_bar = 4, + int bars_per_phrase = 4, int phrases_per_section = 4); +}; + +} // namespace sig + +#endif // SIGNAL_ENGINE_CELL_STORE_H diff --git a/src/signal_engine/cold_eval.cpp b/src/signal_engine/cold_eval.cpp new file mode 100644 index 0000000..c733840 --- /dev/null +++ b/src/signal_engine/cold_eval.cpp @@ -0,0 +1,3323 @@ +#include "cold_eval.h" + +#include +#include "token.h" +#include "graph_builder.h" +#include "compiler_pipeline.h" +#include "executor.h" +#if USEQ_HAS_SYNTH_ENGINE +#include "synth_registry.h" +#endif +#include "../devtools/devtools.h" +#include "../modulisp/lisp/symbol_intern.h" +#include +#include +#include + +namespace sig { + +namespace { +struct RuntimeMemorySampleScope { + RuntimeMemorySampleScope() { dt::sample_runtime_memory(); } + ~RuntimeMemorySampleScope() { dt::sample_runtime_memory(); } +}; +} // namespace + +// ── SignalEngine::init_defaults ──────────────────────────────────────────── + +void SignalEngine::init_defaults(Sample bpm, int beats_per_bar, + int bars_per_phrase, int phrases_per_section) { + GraphBuilder::init_symbols(); + state = EngineState{}; + session_generation = 0; + reset_session_storage(bpm, beats_per_bar, bars_per_phrase, + phrases_per_section, false); +} + +void SignalEngine::reset_session_storage(Sample bpm, int beats_per_bar, + int bars_per_phrase, + int phrases_per_section, + bool publish_session_clear) { + cells.reset(bpm, beats_per_bar, bars_per_phrase, phrases_per_section); + arena.reset(); + pool.reset(); + scratch_pool.reset(); + for (uint16_t i = 0; i < MAX_OUTPUTS; i++) + output_sources[i] = OutputSource{}; + for (uint16_t i = 0; i < MAX_OUTPUTS; i++) + output_compile_diagnostics[i] = ActiveCompileDiagnostic{}; + for (uint16_t i = 0; i < MAX_STATE_SLOTS; i++) { + state_sources[i] = StateUpdateSource{}; + state_compile_diagnostics[i] = ActiveCompileDiagnostic{}; + } + registry.clear(); +#if USEQ_HAS_SYNTH_ENGINE + if (publish_session_clear) { + SynthRevision next_revision = synth_graph.revision + 1; + synth_graph = SynthGraph{}; + synth_graph.revision = next_revision; + } else { + synth_graph = SynthGraph{}; + } +#endif + if (publish_session_clear) session_generation++; + memset(eval_text_buf, 0, sizeof(eval_text_buf)); +#if USEQ_HAS_SYNTH_ENGINE + memset(pending_state_identity, 0, sizeof(pending_state_identity)); + has_pending_state_identity = false; + eval_anon_synth_ordinal = 0; +#endif +} + +// ── Helper constructors ───────────────────────────────────────────────────── + +static EvalResult make_ok() { + EvalResult r; + r.kind = EvalResult::Ok; + return r; +} + +static EvalResult make_number(Sample v) { + EvalResult r; + r.kind = EvalResult::Number; + r.number = v; + return r; +} + +static EvalResult make_error(const char* message, const char* suggestion) { + EvalResult r; + r.kind = EvalResult::Error; + if (r.diagnostic_count < 8) { + r.diagnostics[r.diagnostic_count++] = { + DiagnosticSeverity::Error, DiagnosticCategory::Runtime, + 0, 0, message, suggestion + }; + } + return r; +} + +// Cell/callable arrays are sized MAX_CELLS but symbol IDs are unbounded +// (the interner keeps handing out fresh IDs). Every cell WRITE path must +// bounds-check the symbol ID or it writes out of bounds (A1). Read paths +// already guard. +static bool cell_id_out_of_range(SymbolID sym) { + return sym >= MAX_CELLS; +} + +static EvalResult make_too_many_definitions_error() { + EvalResult r; + r.kind = EvalResult::Error; + r.diagnostics[r.diagnostic_count++] = { + DiagnosticSeverity::Error, DiagnosticCategory::Overflow, + 0, 0, "Too many definitions — no cell space left for this name", + "Remove unused definitions or reuse existing names" + }; + return r; +} + +static void publish_reactive_diagnostic(ActiveCompileDiagnostic& active, + SymbolID triggered_by, + const GraphBuildResult& result) { + active = ActiveCompileDiagnostic{}; + active.active = true; + active.triggered_by = triggered_by; + if (result.diagnostic_count > 0) { + active.diagnostic = result.diagnostics[0]; + } else { + active.diagnostic = { + DiagnosticSeverity::Error, DiagnosticCategory::Runtime, + 0, 0, + "A dependency change could not be applied; the previous program is still running", + "Repair the changed definition" + }; + } +} + +#if USEQ_HAS_SYNTH_ENGINE +static void publish_synth_reactive_diagnostic( + SynthControlChannel& control, SymbolID triggered_by, + const Diagnostic& diagnostic) { + control.compile_diagnostic.publish(triggered_by, diagnostic); +} + +static void clear_synth_reactive_diagnostic(SynthControlChannel& control) { + control.compile_diagnostic.clear(); +} +#endif + +// Publication is deliberately separate from graph construction: the bounded +// GraphBuildResult is a candidate IR until one of these functions installs its +// root, dependencies, ownership, and health as one coherent state change. +static void publish_output_graph_plan(SignalEngine& engine, + uint16_t output_index, + const GraphBuildResult& result) { + engine.pool.outputs[output_index].root_node = result.root_node; + engine.pool.outputs[output_index].valid = true; + engine.pool.runtime_fallback_mask &= ~((uint64_t)1 << output_index); + engine.output_compile_diagnostics[output_index] = + ActiveCompileDiagnostic{}; + engine.pool.output_deps[output_index].clear(); + for (uint8_t d = 0; d < result.dep_count; d++) + engine.pool.output_deps[output_index].add(result.dep_cells[d]); + engine.registry.commit_context(output_index, + engine.pool.state_update_roots, + engine.pool.state_owner_context); +} + +static void publish_state_update_plan(SignalEngine& engine, + uint16_t state_slot, + const GraphBuildResult& result) { + engine.pool.state_update_roots[state_slot] = result.root_node; + engine.state_sources[state_slot].dep_count = result.dep_count; + for (uint8_t d = 0; d < result.dep_count; d++) + engine.state_sources[state_slot].dep_cells[d] = result.dep_cells[d]; + const uint16_t owner_context = + static_cast(MAX_OUTPUTS + state_slot); + engine.registry.commit_context(owner_context, + engine.pool.state_update_roots, + engine.pool.state_owner_context); + engine.state_compile_diagnostics[state_slot] = + ActiveCompileDiagnostic{}; +} + +// ── Cold-path form evaluation ─────────────────────────────────────────────── + +static EvalResult eval_form(TokenStream& ts, SignalEngine& engine, + const char* source, uint32_t source_length, + SharedLiveEditIDs* shared_ids = nullptr); + +// ── Live-edit argument rejection helper ──────────────────────────────────── +// Returns true if the next form in the token stream is (live-edit ...). +// Does not consume any tokens. + +static bool next_is_live_edit(const TokenStream& ts) { + if (ts.pos + 1 >= ts.count) return false; + if (ts.tokens[ts.pos].kind != TokenKind::LParen) return false; + if (ts.tokens[ts.pos + 1].kind != TokenKind::Symbol) return false; + return ts.tokens[ts.pos + 1].symbol == GraphBuilder::sym.live_edit; +} + +// ── Source span helpers ───────────────────────────────────────────────────── + +static uint32_t span_begin(const TokenStream& ts, uint16_t token_pos) { + return ts.tokens[token_pos].span_start; +} + +static uint32_t span_end_of(const TokenStream& ts, uint16_t token_pos) { + if (token_pos == 0) return 0; + const Token& last = ts.tokens[token_pos - 1]; + return last.span_start + last.span_len; +} + +// Reclaim fixed-pool resources from the graph that was actually published. +// State update roots are retained only when a published root (or a live named +// defstate cell) reaches their slot. This preserves a retained LKG graph after +// failed reactive compilation while successful replacement releases ghosts. +static void compact_reachable_state_slots(SignalEngine& engine) { + NodePool& pool = engine.pool; + if (pool.state_slot_count == 0) return; + + bool queued[MAX_TOTAL_NODES] = {}; + bool slot_live[MAX_STATE_SLOTS] = {}; + uint16_t stack[MAX_TOTAL_NODES] = {}; + uint16_t stack_top = 0; + auto push = [&](uint16_t node) { + if (node == NODE_NONE || node >= pool.node_count || queued[node]) return; + queued[node] = true; + if (stack_top < MAX_TOTAL_NODES) stack[stack_top++] = node; + }; + for (uint16_t o = 0; o < MAX_OUTPUTS; o++) + push(pool.outputs[o].root_node); +#if USEQ_HAS_SYNTH_ENGINE + for (uint16_t e = 0; e < pool.external_root_count; e++) + push(pool.external_roots[e]); +#endif + for (uint32_t c = 0; c < MAX_CELLS; c++) { + const Cell& cell = engine.cells.cells[c]; + if (cell.kind != CellKind::Number || cell.flags != 0x02) continue; + uint16_t slot = cell.data_table_id; + if (slot >= pool.state_slot_count) continue; + slot_live[slot] = true; + push(pool.state_update_roots[slot]); + } + while (stack_top > 0) { + uint16_t idx = stack[--stack_top]; + const Node& node = pool.nodes[idx]; + if (node.op == NodeOp::LoadState) { + uint16_t slot = (uint16_t)node.imm; + if (slot < pool.state_slot_count && !slot_live[slot]) { + slot_live[slot] = true; + push(pool.state_update_roots[slot]); + } + } + push(node.input_a); + push(node.input_b); + push(node.input_c); + } + + uint16_t remap[MAX_STATE_SLOTS]; + for (uint16_t s = 0; s < MAX_STATE_SLOTS; s++) remap[s] = NODE_NONE; + uint16_t new_count = 0; + for (uint16_t old = 0; old < pool.state_slot_count; old++) { + if (!slot_live[old]) continue; + uint16_t fresh = new_count++; + remap[old] = fresh; + if (fresh != old) { + pool.state_values[fresh] = pool.state_values[old]; + pool.state_update_roots[fresh] = pool.state_update_roots[old]; + pool.state_owner_context[fresh] = pool.state_owner_context[old]; + engine.state_sources[fresh] = engine.state_sources[old]; + engine.state_compile_diagnostics[fresh] = + engine.state_compile_diagnostics[old]; + } + } + if (new_count == pool.state_slot_count) return; + + for (uint16_t n = 0; n < pool.node_count; n++) { + if (pool.nodes[n].op != NodeOp::LoadState) continue; + uint16_t old = (uint16_t)pool.nodes[n].imm; + if (old < MAX_STATE_SLOTS && remap[old] != NODE_NONE) + pool.nodes[n].imm = (Sample)remap[old]; + } + for (uint32_t c = 0; c < MAX_CELLS; c++) { + Cell& cell = engine.cells.cells[c]; + if (cell.kind != CellKind::Number || cell.flags != 0x02) continue; + uint16_t old = cell.data_table_id; + if (old < MAX_STATE_SLOTS && remap[old] != NODE_NONE) + cell.data_table_id = remap[old]; + } + + uint16_t new_entry_count = 0; + for (uint16_t i = 0; i < engine.registry.entry_count; i++) { + StateResourceEntry entry = engine.registry.entries[i]; + if (entry.slot_index >= MAX_STATE_SLOTS || + remap[entry.slot_index] == NODE_NONE) continue; + entry.slot_index = remap[entry.slot_index]; + if (entry.owner_context >= MAX_OUTPUTS && + entry.owner_context < MAX_OUTPUTS + MAX_STATE_SLOTS) { + uint16_t owner_slot = entry.owner_context - MAX_OUTPUTS; + if (remap[owner_slot] != NODE_NONE) + entry.owner_context = (uint16_t)(MAX_OUTPUTS + remap[owner_slot]); + } + if ((entry.key.state_id & ANON_STATE_ID_BASE) != 0) { + uint16_t context = (uint16_t) + ((entry.key.state_id & ~ANON_STATE_ID_BASE) >> 12); + if (context >= MAX_OUTPUTS && + context < MAX_OUTPUTS + MAX_STATE_SLOTS) { + uint16_t owner_slot = context - MAX_OUTPUTS; + if (remap[owner_slot] != NODE_NONE) { + StateID ordinal = entry.key.state_id & 0xFFFu; + entry.key.state_id = make_anon_state_id( + (uint16_t)(MAX_OUTPUTS + remap[owner_slot]), + (uint16_t)ordinal); + } + } + } + engine.registry.entries[new_entry_count++] = entry; + } + for (uint16_t i = new_entry_count; i < engine.registry.entry_count; i++) + engine.registry.entries[i] = StateResourceEntry{}; + engine.registry.entry_count = new_entry_count; + engine.registry.free_slot_count = 0; + for (uint16_t i = 0; i < MAX_STATE_SLOTS; i++) + engine.registry.free_slots[i] = NODE_NONE; + + for (uint16_t i = 0; i < pool.live_slot_count; i++) { + uint16_t context = pool.live_slots[i].owner_context; + if (context < MAX_OUTPUTS || context >= MAX_OUTPUTS + MAX_STATE_SLOTS) + continue; + uint16_t owner_slot = context - MAX_OUTPUTS; + if (remap[owner_slot] != NODE_NONE) + pool.live_slots[i].owner_context = + (uint16_t)(MAX_OUTPUTS + remap[owner_slot]); + } + for (uint16_t s = 0; s < new_count; s++) { + uint16_t context = pool.state_owner_context[s]; + if (context < MAX_OUTPUTS || context >= MAX_OUTPUTS + MAX_STATE_SLOTS) + continue; + uint16_t owner_slot = context - MAX_OUTPUTS; + if (remap[owner_slot] != NODE_NONE) + pool.state_owner_context[s] = + (uint16_t)(MAX_OUTPUTS + remap[owner_slot]); + } + for (uint16_t s = new_count; s < pool.state_slot_count; s++) { + pool.state_values[s] = 0.0; + pool.state_update_roots[s] = NODE_NONE; + pool.state_owner_context[s] = ANON_STATE_CONTEXT_NONE; + engine.state_sources[s] = StateUpdateSource{}; + engine.state_compile_diagnostics[s] = ActiveCompileDiagnostic{}; + } + uint64_t remapped_state_failures = 0; + for (uint16_t old = 0; old < MAX_STATE_SLOTS; old++) { + if (remap[old] == NODE_NONE) continue; + if ((pool.state_update_failure_mask & ((uint64_t)1 << old)) != 0) + remapped_state_failures |= (uint64_t)1 << remap[old]; + } + pool.state_update_failure_mask = remapped_state_failures; + pool.state_slot_count = new_count; +} + +static void compact_reachable_live_slots(SignalEngine& engine) { + NodePool& pool = engine.pool; + if (pool.live_slot_count == 0) return; + bool live[MAX_LIVE_SLOTS] = {}; + for (uint16_t n = 0; n < pool.node_count; n++) { + if (pool.nodes[n].op != NodeOp::SlotLoad) continue; + uint16_t slot = (uint16_t)pool.nodes[n].imm; + if (slot < pool.live_slot_count) live[slot] = true; + } + uint16_t remap[MAX_LIVE_SLOTS]; + for (uint16_t i = 0; i < MAX_LIVE_SLOTS; i++) remap[i] = NODE_NONE; + uint16_t new_count = 0; + for (uint16_t old = 0; old < pool.live_slot_count; old++) { + if (!live[old]) continue; + uint16_t fresh = new_count++; + remap[old] = fresh; + if (fresh != old) pool.live_slots[fresh] = pool.live_slots[old]; + } + if (new_count == pool.live_slot_count) return; + for (uint16_t n = 0; n < pool.node_count; n++) { + if (pool.nodes[n].op != NodeOp::SlotLoad) continue; + uint16_t old = (uint16_t)pool.nodes[n].imm; + if (old < MAX_LIVE_SLOTS && remap[old] != NODE_NONE) + pool.nodes[n].imm = (Sample)remap[old]; + } + for (uint16_t o = 0; o < MAX_OUTPUTS; o++) { + OutputDeps& deps = pool.output_deps[o]; + uint16_t kept = 0; + for (uint16_t i = 0; i < deps.slot_count; i++) { + uint16_t old = deps.slots[i]; + if (old < MAX_LIVE_SLOTS && remap[old] != NODE_NONE) + deps.slots[kept++] = remap[old]; + } + deps.slot_count = kept; + } + for (uint16_t s = new_count; s < pool.live_slot_count; s++) + pool.live_slots[s] = NodePool::LiveSlot{}; + pool.live_slot_count = new_count; +} + +static void reclaim_unowned_resources(SignalEngine& engine) { +#if USEQ_HAS_SYNTH_ENGINE + register_synth_external_roots(engine); +#endif + compact_reachable_state_slots(engine); + engine.pool.gc_unreachable_nodes(); + compact_reachable_live_slots(engine); + engine.pool.gc_unreachable_nodes(); +#if USEQ_HAS_SYNTH_ENGINE + commit_synth_external_roots(engine); +#endif +} + +// Compact every source region still owned by published compiler state. +// +// SourceArena::store_reuse() prevents growth when a replacement fits its old +// slot, but a deliberately omitted synth control has no live slot to reuse +// when it is later reintroduced. Alternating an optional control present / +// absent therefore used to consume the append-only arena until a one-node +// patch could no longer be edited. Successful synth publication is a safe +// compaction boundary: parsing and graph construction are complete, all live +// owners have their final offsets, and no later step in the transaction can +// fail. +// +// The high bit is a temporary in-place visited marker. SOURCE_ARENA_SIZE is +// far below 2^31 in every build profile, so no valid arena offset can carry +// it. Repeated minimum selection avoids a large firmware-stack descriptor +// array; the bounded O(owner_count^2) work occurs only on the cold edit path. +static void compact_live_source_arena(SignalEngine& engine) { + constexpr uint32_t moved_bit = UINT32_C(0x80000000); + + auto visit_live_sources = [&](auto&& visit) { + for (uint32_t c = 0; c < MAX_CELLS; c++) { + if (engine.cells.cells[c].kind != CellKind::Callable) continue; + CallableInfo& info = engine.cells.callables[c]; + if (info.source_length > 0) + visit(info.source_offset, info.source_length); + } + for (uint16_t o = 0; o < MAX_OUTPUTS; o++) { + OutputSource& output = engine.output_sources[o]; + if (output.has_source && output.arena_length > 0) + visit(output.arena_offset, output.arena_length); + } + for (uint16_t s = 0; s < MAX_STATE_SLOTS; s++) { + StateUpdateSource& state = engine.state_sources[s]; + if (state.has_source && state.arena_length > 0) + visit(state.arena_offset, state.arena_length); + } +#if USEQ_HAS_SYNTH_ENGINE + for (uint16_t i = 0; i < engine.synth_graph.control_count(); i++) { + SynthControlChannel& control = engine.synth_graph.controls[i]; + if (control.source_length > 0) + visit(control.source_offset, control.source_length); + } +#endif + }; + + // Validate every reference before changing any offset. Published state + // should make this impossible; returning preserves the debuggable corrupt + // state rather than turning a bad reference into an out-of-bounds move. + bool valid = true; + visit_live_sources([&](uint32_t& offset, uint32_t length) { + if (offset >= SOURCE_ARENA_SIZE || + length > SOURCE_ARENA_SIZE - offset || + offset + length > engine.arena.write_head) { + valid = false; + } + }); + if (!valid) return; + + uint32_t compacted_head = 0; + while (true) { + uint32_t next_offset = UINT32_MAX; + uint32_t next_length = 0; + visit_live_sources([&](uint32_t& offset, uint32_t length) { + if ((offset & moved_bit) != 0) return; + if (offset < next_offset) { + next_offset = offset; + next_length = length; + } else if (offset == next_offset && length > next_length) { + // Shared regions are not currently emitted, but treating an + // exact-offset alias as one region keeps compaction coherent. + next_length = length; + } + }); + if (next_offset == UINT32_MAX) break; + + if (next_length > 0 && compacted_head != next_offset) { + memmove(engine.arena.data + compacted_head, + engine.arena.data + next_offset, next_length); + } + visit_live_sources([&](uint32_t& offset, uint32_t) { + if (offset == next_offset) offset = moved_bit | compacted_head; + }); + compacted_head += next_length; + } + + visit_live_sources([&](uint32_t& offset, uint32_t) { + offset &= ~moved_bit; + }); + engine.arena.write_head = compacted_head; +} + +static bool source_aliases_live_arena(const char* source, + const SignalEngine& engine) { + if (!source) return false; + uintptr_t address = reinterpret_cast(source); + uintptr_t begin = reinterpret_cast(engine.arena.data); + uintptr_t end = begin + SOURCE_ARENA_SIZE; + return address >= begin && address < end; +} + +static bool parse_numeric_vector(TokenStream& ts, Sample* values, + uint16_t& count, EvalResult& error) { + if (!ts.expect(TokenKind::LBracket)) { + error = make_error("Expected a vector", "Try: [1 2 3]"); + return false; + } + count = 0; + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) { + if (count >= 64) { + error = make_error("Vector definition is too long (max 64 values)", + "Split the data into smaller vectors"); + return false; + } + Token element = ts.consume(); + if (element.kind != TokenKind::Number) { + error = make_error( + "Vector definitions require numeric literal elements", + "Replace the nonnumeric element or use a signal expression outside the data vector"); + return false; + } + values[count++] = element.number; + } + if (!ts.expect(TokenKind::RBracket)) { + error = make_error("Vector definition is missing ']'", + "Close the vector with ]"); + return false; + } + return true; +} + +// ── define ────────────────────────────────────────────────────────────────── + +static EvalResult do_define(TokenStream& ts, SignalEngine& engine, + const char* source, uint32_t source_length) { + Token name_tok = ts.consume(); + if (name_tok.kind != TokenKind::Symbol) { + return make_error("define needs a name", "Try: (define freq 440)"); + } + SymbolID sym = name_tok.symbol; + if (cell_id_out_of_range(sym)) return make_too_many_definitions_error(); + + Token val_tok = ts.peek(); + + if (val_tok.kind == TokenKind::Number) { + // Simple numeric constant + ts.consume(); + if (ts.peek().kind != TokenKind::RParen) { + return make_error("define accepts exactly one value", + "Try: (define name value)"); + } + engine.cells.cells[sym].kind = CellKind::Number; + engine.cells.cells[sym].revision++; + engine.cells.cells[sym].value = val_tok.number; + // A plain define establishes a fresh, non-state binding. Clear any stale + // defstate marker (flags 0x02) — otherwise graph_builder keeps emitting a + // state_load from the old slot and this define is silently ignored. + engine.cells.cells[sym].flags = 0; + engine.cells.callables[sym] = CallableInfo{}; + } + else if (val_tok.kind == TokenKind::LBracket) { + // Vector data: [1 2 3 4] + Sample values[64]; + uint16_t count = 0; + EvalResult parse_error; + if (!parse_numeric_vector(ts, values, count, parse_error)) + return parse_error; + if (ts.peek().kind != TokenKind::RParen) + return make_error("define accepts exactly one value", + "Try: (define name [1 2 3])"); + + uint16_t table_id = engine.cells.store_data_table(values, count); + if (table_id == UINT8_MAX) + return make_error("Data table storage is full — definition not applied", + "Free space with (useq-clear) or reuse an existing vector"); + engine.cells.cells[sym].kind = CellKind::Data; + engine.cells.cells[sym].data_table_id = table_id; + engine.cells.cells[sym].revision++; + engine.cells.cells[sym].value = (Sample)count; + engine.cells.cells[sym].flags = 0; // clear stale defstate marker (see above) + engine.cells.callables[sym] = CallableInfo{}; + } + else { + // Expression — store source text as callable with 0 params + uint16_t expr_start = ts.pos; + uint32_t byte_start = span_begin(ts, expr_start); + + // Skip past the expression to find its extent + GraphBuilder::skip_form(ts); + uint32_t byte_end = span_end_of(ts, ts.pos); + + if (ts.peek().kind != TokenKind::RParen) { + return make_error("define accepts exactly one value", + "Try: (define name value)"); + } + + // Copy the expression source text into the arena FIRST. If the arena + // is full, fail the define without touching the cell — otherwise the + // cell would keep its OLD source text and dependents would silently + // recompile a stale definition (F5). + uint32_t offset = UINT32_MAX; + uint32_t len = 0; + if (source && byte_end > byte_start && byte_end <= source_length) { + len = byte_end - byte_start; + const CallableInfo previous = + engine.cells.cells[sym].kind == CellKind::Callable + ? engine.cells.callables[sym] : CallableInfo{}; + offset = engine.arena.store_reuse( + previous.source_offset, previous.source_length, + source + byte_start, len); + if (offset == UINT32_MAX) { + return make_error( + "Program storage is full — definition not applied", + "Free space with (useq-clear) or shorten your program"); + } + } + + engine.cells.cells[sym].kind = CellKind::Callable; + engine.cells.cells[sym].revision++; + engine.cells.cells[sym].flags = 0; // clear stale defstate marker (see above) + engine.cells.callables[sym].param_count = 0; + if (offset != UINT32_MAX) { + engine.cells.callables[sym].source_offset = offset; + engine.cells.callables[sym].source_length = len; + } + } + + // Notify dependents + on_cell_changed(sym, engine); + + return make_ok(); +} + +// ── defn ──────────────────────────────────────────────────────────────────── + +static EvalResult do_defn(TokenStream& ts, SignalEngine& engine, + const char* source, uint32_t source_length) { + Token name_tok = ts.consume(); + if (name_tok.kind != TokenKind::Symbol) { + return make_error("defn needs a name", "Try: (defn osc [f ph] (sin (* ph f)))"); + } + SymbolID sym = name_tok.symbol; + if (cell_id_out_of_range(sym)) return make_too_many_definitions_error(); + + // Parse parameter list + if (!ts.expect(TokenKind::LBracket)) { + return make_error("defn needs a parameter list in brackets", + "Try: (defn osc [f ph] (sin (* ph f)))"); + } + + // Parse into a local so a failed arena store leaves the old definition + // fully intact (params AND source must update atomically). + CallableInfo info{}; + info.param_count = 0; + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) { + Token param = ts.consume(); + if (param.kind != TokenKind::Symbol) { + return make_error("defn parameter names must be symbols", + "Try: (defn name [arg] body)"); + } + if (info.param_count >= MAX_CALLABLE_PARAMS) { + return make_error("defn has too many parameters", + "Split the function or use fewer parameters"); + } + info.params[info.param_count++] = param.symbol; + } + if (!ts.expect(TokenKind::RBracket)) { + return make_error("defn has an invalid parameter list", + "Try: (defn name [arg] body)"); + } + + // Store body source — skip body and record extent + uint16_t body_start = ts.pos; + uint32_t byte_start = span_begin(ts, body_start); + + GraphBuilder::skip_form(ts); + uint32_t byte_end = span_end_of(ts, ts.pos); + + if (ts.peek().kind != TokenKind::RParen) { + return make_error("defn accepts exactly one body expression", + "Try: (defn name [arg] body)"); + } + + // Copy the body source text into the arena FIRST. If the arena is full, + // fail the defn without touching the cell so dependents never recompile + // a half-updated definition (F5). + if (source && byte_end > byte_start && byte_end <= source_length) { + uint32_t len = byte_end - byte_start; + const CallableInfo previous = + engine.cells.cells[sym].kind == CellKind::Callable + ? engine.cells.callables[sym] : CallableInfo{}; + uint32_t offset = engine.arena.store_reuse( + previous.source_offset, previous.source_length, + source + byte_start, len); + if (offset == UINT32_MAX) { + return make_error( + "Program storage is full — definition not applied", + "Free space with (useq-clear) or shorten your program"); + } + info.source_offset = offset; + info.source_length = len; + } + + engine.cells.callables[sym] = info; + engine.cells.cells[sym].kind = CellKind::Callable; + engine.cells.cells[sym].flags = 0; + engine.cells.cells[sym].revision++; + + on_cell_changed(sym, engine); + + return make_ok(); +} + +// ── set ───────────────────────────────────────────────────────────────────── + +static EvalResult do_set(TokenStream& ts, SignalEngine& engine, + const char* source = nullptr) { + Token name_tok = ts.consume(); + if (name_tok.kind != TokenKind::Symbol) { + return make_error("set needs a name", "Try: (set x 42)"); + } + SymbolID sym = name_tok.symbol; + if (cell_id_out_of_range(sym)) return make_too_many_definitions_error(); + + // If the target is a defstate cell (flags 0x02), the live value lives in + // pool.state_values[slot], not the cell — a plain cell write was silently + // ignored by every LoadState reader (A7). Write the state slot and keep + // the marker so the update program keeps running from the new value. + // (Mirrors define's handling of stale markers, which clears them instead + // because define establishes a fresh non-state binding.) + bool is_state_cell = engine.cells.cells[sym].kind == CellKind::Number + && engine.cells.cells[sym].flags == 0x02; + + auto store_number = [&](Sample v) { + if (is_state_cell) { + uint16_t slot = (uint16_t)engine.cells.cells[sym].data_table_id; + engine.pool.state_values[slot] = v; + engine.cells.cells[sym].value = v; + engine.cells.cells[sym].revision++; + } else { + engine.cells.cells[sym].kind = CellKind::Number; + engine.cells.cells[sym].revision++; + engine.cells.cells[sym].value = v; + engine.cells.callables[sym] = CallableInfo{}; + } + }; + + Token val_tok = ts.peek(); + if (val_tok.kind == TokenKind::Number) { + ts.consume(); + if (ts.peek().kind != TokenKind::RParen) { + return make_error("set accepts exactly one value", + "Try: (set name value)"); + } + store_number(val_tok.number); + } else { + // Non-numeric: compile in scratch pool, evaluate once, store result. + // This avoids leaking nodes/CSE/data into the live pool. + uint8_t saved_tables = engine.cells.data_table_count; + engine.scratch_pool.reset(); + + // Mirror live state BEFORE compiling (A5, state-identity.md §6.6): + // compilation writes init values into freshly-allocated scratch + // slots; copying live values afterwards clobbered them, so stateful + // expressions in a set/eval saw a stale live value instead of their + // own :init. + memcpy(engine.scratch_pool.state_values, engine.pool.state_values, + sizeof(engine.pool.state_values)); + + GraphBuildResult gr = build_output_graph(engine.scratch_pool, ts, + engine.cells, engine.arena, source); + if (gr.has_error) { + engine.cells.data_table_count = saved_tables; + return make_error("set: expression could not be evaluated", + "Try: (set x 42)"); + } + + if (ts.peek().kind != TokenKind::RParen) { + engine.cells.data_table_count = saved_tables; + return make_error("set accepts exactly one value", + "Try: (set name value)"); + } + + if (engine.scratch_pool.nodes[gr.root_node].op == NodeOp::Const) { + store_number(engine.scratch_pool.nodes[gr.root_node].imm); + } else { + engine.scratch_pool.outputs[0].root_node = gr.root_node; + engine.scratch_pool.outputs[0].valid = true; + engine.scratch_pool.rebuild_execution_order(); + + Sample cell_vals[MAX_CELLS]; + engine.cells.snapshot_values(cell_vals, MAX_CELLS); + Sample hw_inputs[32] = {}; + Sample workspace[MAX_TOTAL_NODES] = {}; + Sample outputs[MAX_OUTPUTS] = {}; + + ExecutionContext ctx; + ctx.t = engine.state.current_time; + ctx.dt = engine.state.current_dt; + ctx.cell_values = cell_vals; + ctx.hw_inputs = hw_inputs; + ctx.data_pool = engine.cells.data_pool; + ctx.data_offsets = engine.cells.data_offsets; + ctx.data_lengths = engine.cells.data_lengths; + ctx.prev_outputs = engine.pool.prev_output_values; + ctx.output_values = outputs; + ctx.workspace = workspace; + execute_all_outputs(engine.scratch_pool, ctx); + + store_number(outputs[0]); + } + + engine.cells.data_table_count = saved_tables; + } + + on_cell_changed(sym, engine); + + return make_ok(); +} + +// ── defstate ──────────────────────────────────────────────────────────────── + +static EvalResult do_defstate(TokenStream& ts, SignalEngine& engine, + const char* source, uint32_t source_length) { + // (defstate name init_expr update_expr) + Token name_tok = ts.consume(); + if (name_tok.kind != TokenKind::Symbol) { + return make_error("defstate needs a name", + "Try: (defstate counter 0 (+ counter 1))"); + } + SymbolID sym = name_tok.symbol; + if (cell_id_out_of_range(sym)) return make_too_many_definitions_error(); + + // Parse init value — must be a number literal for simplicity. + // Specifically reject live-edit in this position (§4.1.9). + if (next_is_live_edit(ts)) { + // Skip to closing paren + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) ts.consume(); + ts.expect(TokenKind::RParen); + return make_error( + "live-edit is not valid here — defstate initial value must be a literal number", + "Try: (defstate counter 0 (+ counter 1))"); + } + Token init_tok = ts.consume(); + if (init_tok.kind != TokenKind::Number) { + return make_error("defstate initial value must be a number", + "Try: (defstate counter 0 (+ counter 1))"); + } + Sample init_value = init_tok.number; + + // Resolve an existing state slot before storing its update source so a + // re-definition can reuse that slot's arena region. + uint16_t state_slot = NODE_NONE; + if (engine.cells.cells[sym].kind == CellKind::Number + && engine.cells.cells[sym].flags == 0x02) { + // Existing state cell — reuse its slot, do NOT reset the value. + state_slot = (uint16_t)engine.cells.cells[sym].data_table_id; + } + + // Locate and preflight the update source, but do not write it yet. + // store_reuse() may overwrite the old region in place; performing it + // before compilation made a rejected redefinition poison the source used + // by later dependency recompilation. + uint16_t expr_start = ts.pos; + uint32_t byte_start = span_begin(ts, expr_start); + uint32_t previous_offset = UINT32_MAX; + uint32_t previous_length = 0; + if (state_slot != NODE_NONE && + engine.state_sources[state_slot].has_source) { + previous_offset = engine.state_sources[state_slot].arena_offset; + previous_length = engine.state_sources[state_slot].arena_length; + } + uint16_t saved = ts.pos; + GraphBuilder::skip_form(ts); + uint32_t byte_end = span_end_of(ts, ts.pos); + ts.rewind(saved); + SourceMutationPlan source_plan = SourceMutationPlan::prepare( + engine.arena, source, source_length, byte_start, byte_end, + previous_offset, previous_length); + if (source_plan.status == SourcePlanStatus::CapacityExceeded) { + return make_error( + "Program storage is full — defstate not applied", + "Free space with (useq-clear) or shorten your program"); + } + if (source_plan.status == SourcePlanStatus::InvalidSpan) { + return make_error("defstate update has an invalid source span", + "Resubmit the complete defstate form"); + } + + // Snapshot everything graph construction can mutate. The source bytes are + // still untouched and are published only after a successful compile. + Cell saved_cell = engine.cells.cells[sym]; + GraphMutationTransaction graph_transaction(engine); + + // Allocate a state slot (if this name already has a state slot, reuse it) + if (state_slot == NODE_NONE) { + // New state cell — allocate slot and set initial value + state_slot = engine.registry.take_free_slot(); + if (state_slot == NODE_NONE) { + if (engine.pool.state_slot_count >= MAX_STATE_SLOTS) { + return make_error(state_slots_exhausted_msg(), + "Remove unused defstate declarations"); + } + state_slot = engine.pool.state_slot_count++; + } + engine.pool.state_values[state_slot] = init_value; + engine.pool.state_update_roots[state_slot] = NODE_NONE; + engine.pool.state_owner_context[state_slot] = + (uint16_t)(MAX_OUTPUTS + state_slot); + engine.state_sources[state_slot] = StateUpdateSource{}; + } + StateUpdateSource saved_source = engine.state_sources[state_slot]; + + // Mark this cell as a state cell: kind=Number (readable), flags=0x02 (state marker), + // data_table_id stores the state slot index + engine.cells.cells[sym].kind = CellKind::Number; + engine.cells.cells[sym].flags = 0x02; // state cell marker + engine.cells.cells[sym].data_table_id = state_slot; + engine.cells.cells[sym].revision++; + engine.cells.cells[sym].value = init_value; + + // Compile the update expression as a signal graph + uint16_t owner_context = (uint16_t)(MAX_OUTPUTS + state_slot); + engine.pool.state_owner_context[state_slot] = owner_context; + engine.registry.begin_context(owner_context); + GraphBuildResult result = build_output_graph(engine.pool, ts, + engine.cells, engine.arena, source, + &engine.registry, nullptr, + owner_context); + + if (result.has_error || ts.peek().kind != TokenKind::RParen) { + // Restore the old cell plus every live graph-side mutation. GC drops + // nodes interned by the rejected build after old roots are restored. + engine.cells.cells[sym] = saved_cell; + engine.cells.cells[sym].revision++; + engine.state_sources[state_slot] = saved_source; + return make_error( + result.has_error + ? "defstate update expression failed to compile" + : "defstate accepts exactly one update expression", + result.has_error + ? "Check the update expression" + : "Try: (defstate name init update)"); + } + + // Compilation succeeded, so publishing the source cannot corrupt a + // last-known-good definition. The preflight above guarantees capacity. + if (source_plan.has_source()) { + uint32_t src_offset = source_plan.publish(engine.arena); + if (src_offset == UINT32_MAX) { + engine.cells.cells[sym] = saved_cell; + engine.cells.cells[sym].revision++; + engine.state_sources[state_slot] = saved_source; + return make_error( + "Program storage is full — defstate not applied", + "Free space with (useq-clear) or shorten your program"); + } + engine.state_sources[state_slot].arena_offset = src_offset; + engine.state_sources[state_slot].arena_length = source_plan.length; + engine.state_sources[state_slot].has_source = true; + } + engine.state_sources[state_slot].state_symbol = sym; + + publish_state_update_plan(engine, state_slot, result); + graph_transaction.accept(); + + // Reclaim nodes orphaned by the recompile (e.g. the previous update + // graph of this state slot), then rebuild execution order to include + // state update subgraphs (F4). + reclaim_unowned_resources(engine); + engine.pool.rebuild_execution_order(); + classify_outputs(engine.pool); + + // Notify dependents so outputs referencing this cell get recompiled + on_cell_changed(sym, engine); + engine.cells.callables[sym] = CallableInfo{}; + + return make_ok(); +} + +// ── Transport / time management ───────────────────────────────────────────── + +static EvalResult do_set_bpm(TokenStream& ts, SignalEngine& engine) { + Token val = ts.consume(); + if (val.kind != TokenKind::Number) { + return make_error("set-bpm needs a number", "Try: (set-bpm 120)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("set-bpm accepts exactly one number", + "Try: (set-bpm 120)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("set-bpm accepts exactly one number", + "Try: (set-bpm 120)"); + } + auto& si = SymbolIntern::getInstance(); + SymbolID bpm_sym = si.intern("bpm"); + engine.cells.cells[bpm_sym].kind = CellKind::Number; + engine.cells.cells[bpm_sym].value = val.number; + engine.cells.cells[bpm_sym].revision++; + engine.cells.callables[bpm_sym] = CallableInfo{}; + on_cell_changed(bpm_sym, engine); + return make_ok(); +} + +static EvalResult do_set_time_sig(TokenStream& ts, SignalEngine& engine) { + Token beats_tok = ts.consume(); + if (beats_tok.kind != TokenKind::Number) { + return make_error("set-time-sig needs two numbers", + "Try: (set-time-sig 4 4)"); + } + Token subdivision_tok = ts.consume(); + if (subdivision_tok.kind != TokenKind::Number) { + return make_error("set-time-sig needs two numbers", + "Try: (set-time-sig 4 4)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("set-time-sig accepts exactly two numbers", + "Try: (set-time-sig 4 4)"); + } + if (!std::isfinite(beats_tok.number) || beats_tok.number <= 0.0 || + std::floor(beats_tok.number) != beats_tok.number) { + return make_error("set-time-sig needs a positive whole beat count", + "Try: (set-time-sig 3 4)"); + } + // The current clock model defines bpm and `beat` in quarter-note units; + // it has no denominator/subdivision cell. Silently accepting 6/8 while + // calculating a six-quarter-note bar is wrong, so the supported surface + // is deliberately limited to */4 until that model is extended. + if (!std::isfinite(subdivision_tok.number) || + subdivision_tok.number != 4.0) { + return make_error( + "set-time-sig currently supports quarter-note subdivision only", + "Try: (set-time-sig 3 4)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("set-time-sig accepts exactly two numbers", + "Try: (set-time-sig 4 4)"); + } + + auto& si = SymbolIntern::getInstance(); + SymbolID bpb_sym = si.intern("beats-per-bar"); + engine.cells.cells[bpb_sym].kind = CellKind::Number; + engine.cells.cells[bpb_sym].value = beats_tok.number; + engine.cells.cells[bpb_sym].revision++; + engine.cells.callables[bpb_sym] = CallableInfo{}; + on_cell_changed(bpb_sym, engine); + + return make_ok(); +} + +static EvalResult do_useq_clear(SignalEngine& engine) { + // A session clear is a semantic reset, not only a compiler-storage reset. + // Monotonic session/synth notification tokens remain owned by + // reset_session_storage(). + engine.state = EngineState{}; + engine.reset_session_storage(); + return make_ok(); +} + +static EvalResult do_set_time_offset(TokenStream& ts, EngineState& state) { + Token val = ts.consume(); + if (val.kind != TokenKind::Number) { + return make_error("useq-set-time-offset needs a number in seconds", + "Try: (useq-set-time-offset 1.0)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("useq-set-time-offset accepts exactly one number", + "Try: (useq-set-time-offset 1.0)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("useq-set-time-offset accepts exactly one number", + "Try: (useq-set-time-offset 1.0)"); + } + state.time_offset = val.number; + return make_ok(); +} + +static EvalResult do_nudge_time(TokenStream& ts, EngineState& state) { + Token val = ts.consume(); + if (val.kind != TokenKind::Number) { + return make_error("useq-nudge-time needs a number in seconds", + "Try: (useq-nudge-time 0.1)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("useq-nudge-time accepts exactly one number", + "Try: (useq-nudge-time 0.1)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("useq-nudge-time accepts exactly one number", + "Try: (useq-nudge-time 0.1)"); + } + state.time_offset += val.number; + return make_ok(); +} + +#if USEQ_HAS_SYNTH_ENGINE +// ── Host synth declaration (synth-nodes.md §3) ───────────────────────────── +// +// Top-level form that instantiates one NodeDef instance with bound ModuLisp +// control expressions. The declaration is staged into the live synth_graph +// immediately; eval_cold snapshots synth_graph at the start of every eval +// and restores the snapshot on any downstream error, so a later failing +// form in the same eval unit rolls back the staged declaration +// (VAL-COMP-008, VAL-COMP-010). +// +// Grammar (synth-nodes.md §3.1): +// (synth +// [:version ] ; optional, default 0 (= latest) +// [:name ] ; optional explicit identity +// [:id ] ; hidden identity (payload builder) +// : ...) ; one or more param bindings +// +// M1 enforces SYNTH_M1_MAX_NODES (1) active declaration at a time; over- +// capacity evals fail transactionally with a precise diagnostic +// (VAL-COMP-019). Same-identity re-declaration is an update-in-place. + +// Tiny static-string wrapper for messages built from a small buffer. The +// diagnostic message pointer is required to remain valid for the lifetime +// of the Diagnostic struct; since the engine consumes diagnostics +// synchronously inside eval_cold and never persists the pointer past the +// next eval, a small static rotating buffer pool is sufficient and avoids +// heap allocation on the firmware hot path. +// +// We keep 8 slots (matching MAX_DIAGNOSTICS) so a single eval can build up +// to 8 distinct messages without clobbering each other. +static const char* strdup_safe(const char* s) { + static char pool[8][160]; + static uint8_t rotating = 0; + char* slot = pool[rotating]; + rotating = (uint8_t)((rotating + 1) & 7); + std::strncpy(slot, s, sizeof(pool[0]) - 1); + slot[sizeof(pool[0]) - 1] = '\0'; + return slot; +} + +static EvalResult make_synth_error_at(uint16_t span_start, uint16_t span_len, + DiagnosticCategory cat, + const char* message, + const char* suggestion = nullptr) { + EvalResult r; + r.kind = EvalResult::Error; + if (r.diagnostic_count < 8) { + r.diagnostics[r.diagnostic_count++] = { + DiagnosticSeverity::Error, cat, + span_start, span_len, message, suggestion + }; + } + return r; +} + +static EvalResult make_synth_error_at(const Token& tok, + DiagnosticCategory cat, + const char* message, + const char* suggestion = nullptr) { + return make_synth_error_at(tok.span_start, tok.span_len, cat, + message, suggestion); +} + +// Resolve a NodeDef by name+version, with precise diagnostics for each +// failure mode (VAL-COMP-005). +static const NodeDefDescriptor* resolve_nodedef( + const Token& name_tok, const Token* version_tok, + const char* source_base, + EvalResult& out_error, + bool allow_symbol_name = false) +{ + out_error = EvalResult{}; + + if (name_tok.kind != TokenKind::String && + !(allow_symbol_name && name_tok.kind == TokenKind::Symbol)) { + out_error = make_synth_error_at( + name_tok, DiagnosticCategory::Type, + "synth needs a NodeDef name in quotes", + "Try: (synth \"osc/sine\" :freq 440)"); + return nullptr; + } + + char name_buf[MAX_NODEDEF_NAME]; + const bool symbol_name = name_tok.kind == TokenKind::Symbol; + uint16_t name_len = symbol_name ? name_tok.span_len : name_tok.string.length; + if (name_len >= MAX_NODEDEF_NAME) name_len = MAX_NODEDEF_NAME - 1; + const uint32_t name_offset = symbol_name + ? name_tok.span_start + : name_tok.string.offset; + std::memcpy(name_buf, source_base + name_offset, name_len); + name_buf[name_len] = '\0'; + + uint16_t version = 0; + if (version_tok && version_tok->kind == TokenKind::Number) { + Sample requested = version_tok->number; + if (!std::isfinite(requested) || requested < 1.0 || + requested > 65535.0 || std::floor(requested) != requested) { + out_error = make_synth_error_at( + *version_tok, DiagnosticCategory::Type, + ":version needs a whole number from 1 to 65535", + "Try: (synth \"osc/sine\" :version 2 :freq 440)"); + return nullptr; + } + version = (uint16_t)requested; + } + + const NodeDefDescriptor* def = synth_registry_find(name_buf, version); + if (!def) { + if (version != 0) { + char msg_buf[128]; + std::snprintf(msg_buf, sizeof(msg_buf), + "NodeDef \"%s\" version %u is not available", + name_buf, (unsigned)version); + out_error = make_synth_error_at( + name_tok, DiagnosticCategory::UndefinedName, + strdup_safe(msg_buf), + "Try: (synth \"osc/sine\" :freq 440)"); + } else { + char msg_buf[128]; + std::snprintf(msg_buf, sizeof(msg_buf), + "Unknown NodeDef \"%s\"", name_buf); + out_error = make_synth_error_at( + name_tok, DiagnosticCategory::UndefinedName, + strdup_safe(msg_buf), + "Try: (synth \"osc/sine\" :freq 440)"); + } + return nullptr; + } + return def; +} + +static int16_t synth_declaration_index(const SynthGraph& graph, + const char* identity) { + if (!identity) return -1; + for (uint16_t i = 0; i < graph.declaration_count(); i++) { + if (std::strcmp(graph.declarations[i].identity, identity) == 0) + return (int16_t)i; + } + return -1; +} + +static EvalResult validate_synth_patch_graph(const SynthGraph& graph, + const Token& anchor) { + uint16_t indegree[MAX_SYNTH_DECLARATIONS] = {}; + + for (uint16_t i = 0; i < graph.connection_count(); i++) { + const SynthConnection& edge = graph.connections[i]; + int16_t from = synth_declaration_index(graph, edge.from); + int16_t to = synth_declaration_index(graph, edge.to); + if (from < 0 || to < 0) { + return make_synth_error_at( + anchor, DiagnosticCategory::UndefinedName, + "Synth connection references an unknown node identity", + "Declare the source node before connecting it"); + } + if (from == to) { + return make_synth_error_at( + anchor, DiagnosticCategory::Boundary, + "A synth node cannot connect an audio input to itself", + "Route the input from a different synth node"); + } + + const SynthDeclaration& source_decl = graph.declarations[from]; + const SynthDeclaration& dest_decl = graph.declarations[to]; + if (source_decl.audio_outputs == 0) { + return make_synth_error_at( + anchor, DiagnosticCategory::Boundary, + "Synth connection source has no audio output", + "Choose a NodeDef that produces audio"); + } + const NodeDefDescriptor* dest_def = + synth_registry_find(dest_decl.def_name, dest_decl.def_version); + if (!dest_def || edge.port_index >= dest_def->audio_inputs || + edge.port_index >= MAX_NODEDEF_AUDIO_INPUTS || + !dest_def->audio_input_names[edge.port_index] || + std::strcmp(dest_def->audio_input_names[edge.port_index], + edge.port) != 0) { + return make_synth_error_at( + anchor, DiagnosticCategory::Boundary, + "Synth connection does not match the destination audio port", + "Recompile against the installed NodeDef descriptor"); + } + for (uint16_t j = 0; j < i; j++) { + const SynthConnection& prior = graph.connections[j]; + if (prior.port_index == edge.port_index && + std::strcmp(prior.to, edge.to) == 0) { + return make_synth_error_at( + anchor, DiagnosticCategory::Boundary, + "A synth audio input can have only one source", + "Remove the duplicate input connection"); + } + } + indegree[to]++; + } + + uint16_t queue[MAX_SYNTH_DECLARATIONS] = {}; + uint16_t read = 0; + uint16_t write = 0; + for (uint16_t i = 0; i < graph.declaration_count(); i++) { + if (indegree[i] == 0) queue[write++] = i; + } + uint16_t visited = 0; + while (read < write) { + uint16_t from = queue[read++]; + visited++; + for (uint16_t i = 0; i < graph.connection_count(); i++) { + const SynthConnection& edge = graph.connections[i]; + if (std::strcmp(graph.declarations[from].identity, edge.from) != 0) + continue; + int16_t to = synth_declaration_index(graph, edge.to); + if (to >= 0 && indegree[to] > 0 && --indegree[to] == 0) + queue[write++] = (uint16_t)to; + } + } + if (visited != graph.declaration_count()) { + return make_synth_error_at( + anchor, DiagnosticCategory::Boundary, + "Synth audio connections must not contain a cycle", + "Remove one connection from the feedback loop"); + } + return make_ok(); +} + +static const SynthControlChannel* find_synth_control( + const SynthGraph& graph, const char* identity, const char* param_name) { + const SynthDeclaration* declaration = graph.find(identity); + if (!declaration) return nullptr; + const uint16_t end = static_cast( + declaration->first_control_index + declaration->control_count); + for (uint16_t i = declaration->first_control_index; i < end; i++) { + const SynthControlChannel& control = graph.controls[i]; + const NodeDefParam* parameter = graph.parameter_for_control(i); + if (parameter && std::strcmp(parameter->name, param_name) == 0) + return &control; + } + return nullptr; +} + +static uint16_t allocate_synth_owner_context( + const SynthGraph& old_graph, const SynthGraph& candidate, + const char* identity, const char* param_name) { + constexpr uint16_t base = (uint16_t)(MAX_OUTPUTS + MAX_STATE_SLOTS); + const SynthControlChannel* old = + find_synth_control(old_graph, identity, param_name); + if (old) return old->owner_context; + + const SynthDeclaration* old_declaration = old_graph.find(identity); + + // Mark the bounded owner-context namespace once. The previous search + // rescanned both graphs for every candidate context, making allocation + // quadratic in the number of controls for a fresh dense synth graph. + bool used[MAX_SYNTH_CONTROLS] = {}; + for (uint16_t i = 0; i < old_graph.control_count(); i++) { + const SynthControlChannel& c = old_graph.controls[i]; + const bool same_declaration = old_declaration && + i >= old_declaration->first_control_index && + static_cast(i) < + static_cast(old_declaration->first_control_index) + + old_declaration->control_count; + if (!same_declaration && c.owner_context >= base) { + const uint16_t offset = static_cast(c.owner_context - base); + if (offset < MAX_SYNTH_CONTROLS) used[offset] = true; + } + } + for (uint16_t i = 0; i < candidate.control_count(); i++) { + const uint16_t context = candidate.controls[i].owner_context; + if (context >= base) { + const uint16_t offset = static_cast(context - base); + if (offset < MAX_SYNTH_CONTROLS) used[offset] = true; + } + } + + for (uint16_t offset = 0; offset < MAX_SYNTH_CONTROLS; offset++) { + if (!used[offset]) return static_cast(base + offset); + } + return ANON_STATE_CONTEXT_NONE; +} + +static EvalResult do_synth(TokenStream& ts, SignalEngine& engine, + const char* source, uint32_t source_length, + char* out_identity = nullptr, + bool nested = false, + const SynthGraph* transaction_snapshot = nullptr, + uint16_t nested_depth = 0, + const Token* explicit_def_name = nullptr) { + GraphBuilder::init_symbols(); + auto& sym = GraphBuilder::sym; + + if (nested_depth >= MAX_SYNTH_NESTING) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Overflow, + "Synth routing is nested too deeply", + "Use at most 16 nested synth nodes in one routing chain"); + } + + // ── Parse def name (required string) ──────────────────────────────── + Token def_name_tok = explicit_def_name ? *explicit_def_name : ts.consume(); + if (def_name_tok.kind != TokenKind::String && !explicit_def_name) { + return make_synth_error_at( + def_name_tok, DiagnosticCategory::Type, + "synth needs a NodeDef name in quotes", + "Try: (synth \"osc/sine\" :freq 440)"); + } + + // ── Optional keywords before param pairs: :version / :name / :id ─── + Token version_tok; + version_tok.kind = TokenKind::Eof; + bool have_explicit_version = false; + + Token identity_tok; + identity_tok.kind = TokenKind::Eof; + bool have_explicit_identity = false; + + // Track param bindings declared in this form. Required: :freq. + struct ParamBinding { + const NodeDefParam* desc; + int16_t audio_input_port; + Token kw_tok; + uint16_t expr_start_pos; + uint16_t expr_end_pos; + uint32_t expr_byte_start; + uint32_t expr_byte_end; + bool present; + }; + constexpr uint16_t max_synth_bindings = + MAX_NODEDEF_PARAMS + MAX_NODEDEF_AUDIO_INPUTS; + constexpr uint16_t max_synth_keywords = max_synth_bindings + 3; + ParamBinding bindings[max_synth_bindings] = {}; + uint16_t binding_count = 0; + + // Track keywords seen (for duplicate detection). + SymbolID seen_kws[max_synth_keywords] = {}; + uint16_t seen_kw_count = 0; + + auto remember_keyword = [&](const Token& kw_tok) -> EvalResult { + for (uint16_t i = 0; i < seen_kw_count; ++i) { + if (seen_kws[i] == kw_tok.symbol) { + const String& spelling = + SymbolIntern::getInstance().getString(kw_tok.symbol); + char message[96]; + std::snprintf(message, sizeof(message), + "Synth keyword %s was already set in this form", + spelling.c_str()); + return make_synth_error_at( + kw_tok, DiagnosticCategory::Arity, + strdup_safe(message), + "Remove the duplicate binding"); + } + } + seen_kws[seen_kw_count++] = kw_tok.symbol; + return make_ok(); + }; + + bool kw_phase = true; + while (kw_phase && ts.peek().kind == TokenKind::Symbol) { + Token kw_tok = ts.peek(); + if (kw_tok.symbol == sym.kw_version) { + EvalResult unique = remember_keyword(kw_tok); + if (unique.kind == EvalResult::Error) return unique; + ts.consume(); + Token v = ts.consume(); + if (v.kind != TokenKind::Number) { + return make_synth_error_at( + v, DiagnosticCategory::Type, + ":version needs a whole number", + "Try: (synth \"osc/sine\" :version 1 :freq 440)"); + } + version_tok = v; + have_explicit_version = true; + continue; + } + if (kw_tok.symbol == sym.kw_name || kw_tok.symbol == sym.kw_id) { + EvalResult unique = remember_keyword(kw_tok); + if (unique.kind == EvalResult::Error) return unique; + ts.consume(); + Token s = ts.consume(); + if (s.kind != TokenKind::String) { + const char* kw_label = + (kw_tok.symbol == sym.kw_name) ? ":name" : ":id"; + char msg[96]; + std::snprintf(msg, sizeof(msg), + "%s needs a string in quotes", kw_label); + return make_synth_error_at( + s, DiagnosticCategory::Type, + strdup_safe(msg), + "Try: (synth \"osc/sine\" :name \"lead\" :freq 440)"); + } + // Last identity keyword wins; :id is treated as the hidden + // payload-builder injection and is preserved verbatim. + identity_tok = s; + have_explicit_identity = true; + continue; + } + // Any other symbol is either a param keyword or malformed. + kw_phase = false; + } + + // ── Resolve NodeDef (VAL-COMP-005) ───────────────────────────────── + version_tok.kind = have_explicit_version ? TokenKind::Number : TokenKind::Eof; + EvalResult resolve_err; + const NodeDefDescriptor* def = resolve_nodedef( + def_name_tok, + have_explicit_version ? &version_tok : nullptr, + source, resolve_err, explicit_def_name != nullptr); + if (!def) { + return resolve_err; + } + + // ── Parse param pairs (VAL-COMP-006) ─────────────────────────────── + // Each pair is `: `. We collect all pairs first, then + // compile each expression into the live pool. + while (ts.peek().kind == TokenKind::Symbol && ts.peek().symbol != sym.kw_name + && ts.peek().symbol != sym.kw_id && ts.peek().symbol != sym.kw_version) { + Token kw_tok = ts.consume(); + + // The keyword's string spelling lives in the symbol interner. + auto& si = SymbolIntern::getInstance(); + const String& kw_str = si.getString(kw_tok.symbol); + if (kw_str.length() == 0 || kw_str[0] != ':') { + // Not a keyword — malformed. + return make_synth_error_at( + kw_tok, DiagnosticCategory::Syntax, + "Expected a parameter keyword like :freq or :amp", + "Try: (synth \"osc/sine\" :freq 440 :amp 0.2)"); + } + + // Convert interned symbol back to a C-string for registry lookup. + char param_buf[MAX_NODEDEF_NAME]; + uint16_t plen = (uint16_t)kw_str.length(); + if (plen >= MAX_NODEDEF_NAME) plen = MAX_NODEDEF_NAME - 1; + std::memcpy(param_buf, kw_str.c_str(), plen); + param_buf[plen] = '\0'; + const char* param_name = param_buf + 1; // strip leading ':' + + // Duplicate detection. + EvalResult unique = remember_keyword(kw_tok); + if (unique.kind == EvalResult::Error) return unique; + + // Validate the parameter is declared by this NodeDef. + const NodeDefParam* pdesc = nodedef_find_param(def, param_name); + int16_t audio_input_port = + nodedef_find_audio_input(def, param_name); + if (!pdesc && audio_input_port < 0) { + const char* suggestion = nodedef_suggest_param(def, param_name); + char msg[128]; + std::snprintf(msg, sizeof(msg), + "NodeDef \"%s\" has no parameter \"%s\"", + def->name, param_name); + char sug_buf[128]; + if (suggestion) { + std::snprintf(sug_buf, sizeof(sug_buf), + "Did you mean :%s? Try: (synth \"%s\" :%s ...)", + suggestion, def->name, suggestion); + } else { + std::snprintf(sug_buf, sizeof(sug_buf), + "Check the NodeDef documentation for \"%s\"", + def->name); + } + return make_synth_error_at( + kw_tok, DiagnosticCategory::UndefinedName, + strdup_safe(msg), strdup_safe(sug_buf)); + } + + // Read the value expression. It must be present and non-keyword. + if (ts.at_end() || ts.peek().kind == TokenKind::RParen) { + char msg[96]; + std::snprintf(msg, sizeof(msg), + "Parameter %s needs a value expression", param_buf); + return make_synth_error_at( + kw_tok, DiagnosticCategory::Arity, + strdup_safe(msg), + "Try: (synth \"osc/sine\" :freq 440)"); + } + // Reject a bare keyword following a keyword (malformed pair). + if (ts.peek().kind == TokenKind::Symbol) { + const String& next_str = si.getString(ts.peek().symbol); + if (next_str.length() > 0 && next_str[0] == ':') { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Syntax, + "Expected a value between parameters", + "Try: (synth \"osc/sine\" :freq 440 :amp 0.2)"); + } + } + + uint16_t expr_start_pos = ts.pos; + uint32_t byte_start = span_begin(ts, expr_start_pos); + GraphBuilder::skip_form(ts); + uint32_t byte_end = span_end_of(ts, ts.pos); + + if (binding_count >= max_synth_bindings) { + return make_synth_error_at( + kw_tok, DiagnosticCategory::Overflow, + "Too many parameters on this synth form", + "Check the NodeDef documentation"); + } + bindings[binding_count].desc = pdesc; + bindings[binding_count].audio_input_port = audio_input_port; + bindings[binding_count].kw_tok = kw_tok; + bindings[binding_count].expr_start_pos = expr_start_pos; + bindings[binding_count].expr_end_pos = ts.pos; + bindings[binding_count].expr_byte_start = byte_start; + bindings[binding_count].expr_byte_end = byte_end; + bindings[binding_count].present = true; + binding_count++; + } + + // Reject trailing atoms or malformed material before identity resolution + // or any graph publication. `ts.expect()` in the caller is too late: + // do_synth has already committed by then. + if (ts.peek().kind != TokenKind::RParen) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Syntax, + "Unexpected value after synth parameter bindings", + "Use :parameter value pairs only"); + } + + // ── Required :freq (VAL-COMP-006) ────────────────────────────────── + bool have_freq = false; + for (uint16_t i = 0; i < binding_count; i++) { + if (bindings[i].desc && + std::strcmp(bindings[i].desc->name, "freq") == 0) { + have_freq = true; + break; + } + } + if (!have_freq) { + return make_synth_error_at( + def_name_tok, DiagnosticCategory::Arity, + "synth \"osc/sine\" needs a :freq parameter", + "Try: (synth \"osc/sine\" :freq 440)"); + } + + // ── Identity resolution ──────────────────────────────────────────── + // Order of authority: explicit :name/:id > pending with-state-id + // wrapper id > anonymous fallback (state-identity.md §2.2, ergo + // e58f128f). The editor payload builder wraps anonymous synth forms in + // `(with-state-id "" ...)`; the wrapper handler stashes that id as + // the engine's pending state identity and the first synth declaration + // under the wrapper consumes it, so the same document form keeps the + // same identity across re-evals (update-in-place, synth-nodes.md §5.5). + char identity_buf[MAX_SYNTH_IDENTITY]; + if (have_explicit_identity) { + uint16_t n = identity_tok.string.length; + if (n == 0 || n >= MAX_SYNTH_IDENTITY) { + return make_synth_error_at( + identity_tok, DiagnosticCategory::Overflow, + "Synth identity is empty or too long", + "Use from 1 to 31 bytes for :name or :id"); + } + std::memcpy(identity_buf, source + identity_tok.string.offset, n); + identity_buf[n] = '\0'; + // The explicit identity supersedes and consumes any pending + // wrapper id so it cannot fall through to a later anonymous + // sibling inside the same wrapped form. + engine.has_pending_state_identity = false; + } else if (engine.has_pending_state_identity) { + std::strncpy(identity_buf, engine.pending_state_identity, + MAX_SYNTH_IDENTITY - 1); + identity_buf[MAX_SYNTH_IDENTITY - 1] = '\0'; + engine.has_pending_state_identity = false; // first synth wins + } else { + // Anonymous fallback for direct eval without editor sidecar (raw + // REPL). Keyed by ordinal position within the current eval, so + // re-evaluating the same program reuses its identity instead of + // minting a fresh one per eval (state-identity.md §2.5). + std::snprintf(identity_buf, sizeof(identity_buf), "::anon-%u", + (unsigned)engine.eval_anon_synth_ordinal++); + } + + // ── Capacity check (VAL-COMP-019) ────────────────────────────────── + SynthDeclaration* existing = engine.synth_graph.find(identity_buf); + if (existing == nullptr && + engine.synth_graph.declaration_count() >= SYNTH_MAX_NODES) { + char msg[160]; + std::snprintf(msg, sizeof(msg), + "Synth graph exceeds the %u-node capacity", + (unsigned)SYNTH_MAX_NODES); + return make_synth_error_at( + def_name_tok, DiagnosticCategory::Overflow, + strdup_safe(msg), + "Use (useq-clear) to free synths, or update an existing identity"); + } + + // A synth form is one publication transaction. Snapshot both the + // descriptor/control table and every live graph-side structure before + // removing the old declaration or compiling replacement controls. + // Restoring the SynthGraph first ensures graph rollback preserves its + // old external roots during reachability GC. + SynthGraph* owned_snapshot = nullptr; + if (!transaction_snapshot) { + owned_snapshot = new (std::nothrow) SynthGraph(engine.synth_graph); + dt::sample_runtime_memory(); + if (!owned_snapshot) { + return make_synth_error_at( + def_name_tok, DiagnosticCategory::Overflow, + "Not enough memory to stage the synth transaction", + "Reduce the synth graph and retry"); + } + transaction_snapshot = owned_snapshot; + } + const SynthGraph& synth_snapshot = *transaction_snapshot; + // The outer synth transaction owns the graph rollback image. A nested + // synth is part of that same transaction; taking another image in the + // engine's single scratch pool would overwrite the outer pre-state. + GraphMutationTransaction graph_transaction; + if (!nested) graph_transaction.begin(engine); + auto rollback_synth = [&](EvalResult error) { + engine.synth_graph = synth_snapshot; + if (!nested) graph_transaction.rollback(); + delete owned_snapshot; + return error; + }; + + // Existing control contexts are stable per (identity,param). Mark only + // this declaration's old contexts unseen; successful replacement retires + // removed parameters, while rollback restores the registry snapshot. + const SynthDeclaration* old_declaration = synth_snapshot.find(identity_buf); + if (old_declaration) { + const uint16_t old_end = static_cast( + old_declaration->first_control_index + + old_declaration->control_count); + for (uint16_t i = old_declaration->first_control_index; + i < old_end; i++) { + const SynthControlChannel& old = synth_snapshot.controls[i]; + engine.registry.begin_context(old.owner_context); + } + } + + // ── Commit declaration + controls to synth_graph ─────────────────── + // Same-identity re-declaration is update-in-place: drop the existing + // declaration's control rows first, then re-append fresh ones. + if (existing) { + // Remove the existing declaration and its control rows. The control + // table is dense (rows are appended in declaration order); we + // rebuild it in-place by shifting. + uint16_t kill_first = existing->first_control_index; + uint16_t kill_count = existing->control_count; + + // Compact the control table. + for (uint16_t i = kill_first; i + kill_count < engine.synth_graph.control_count_value; i++) { + engine.synth_graph.controls[i] = + engine.synth_graph.controls[i + kill_count]; + } + engine.synth_graph.control_count_value -= kill_count; + + for (uint16_t i = 0; i < engine.synth_graph.declaration_count(); i++) { + SynthDeclaration& other = engine.synth_graph.declarations[i]; + if (&other != existing && other.first_control_index > kill_first) + other.first_control_index -= kill_count; + } + + // Updating a destination replaces its incoming routing, while its + // outgoing edges remain attached to its stable identity. + uint16_t edge_write = 0; + for (uint16_t edge_read = 0; + edge_read < engine.synth_graph.connection_count_value; + edge_read++) { + const SynthConnection& edge = + engine.synth_graph.connections[edge_read]; + if (std::strcmp(edge.to, identity_buf) == 0) continue; + if (edge_write != edge_read) + engine.synth_graph.connections[edge_write] = edge; + edge_write++; + } + engine.synth_graph.connection_count_value = edge_write; + + // Compact the declaration table. + uint16_t decl_idx = (uint16_t)(existing - engine.synth_graph.declarations); + for (uint16_t i = decl_idx; i + 1 < engine.synth_graph.declaration_count_value; i++) { + engine.synth_graph.declarations[i] = + engine.synth_graph.declarations[i + 1]; + } + engine.synth_graph.declaration_count_value--; + } + + SynthDeclaration* decl = engine.synth_graph.append_declaration(); + if (!decl) { + return rollback_synth(make_synth_error_at( + def_name_tok, DiagnosticCategory::Overflow, + "Synth declaration table is full", + "Use (useq-clear) to free earlier synths")); + } + std::strncpy(decl->identity, identity_buf, MAX_SYNTH_IDENTITY - 1); + decl->identity[MAX_SYNTH_IDENTITY - 1] = '\0'; + std::strncpy(decl->def_name, def->name, MAX_NODEDEF_NAME - 1); + decl->def_name[MAX_NODEDEF_NAME - 1] = '\0'; + decl->def_version = def->version; + decl->audio_inputs = def->audio_inputs; + decl->audio_outputs = def->audio_outputs; + decl->voice_fanout = def->voice_fanout; + decl->first_control_index = engine.synth_graph.control_count_value; + decl->control_count = 0; + + // Preflight persistent source storage for every control. Nested synths + // append rather than overwrite old source so an outer rollback can restore + // only the arena write head without having to copy the whole arena. + uint32_t required_source_bytes = 0; + for (uint16_t i = 0; i < binding_count; i++) { + ParamBinding& b = bindings[i]; + if (!b.present || !b.desc) continue; + uint32_t expr_len = b.expr_byte_end - b.expr_byte_start; + const SynthControlChannel* old = find_synth_control( + synth_snapshot, identity_buf, b.desc->name); + bool can_reuse = !nested && old && + source_region_can_store(engine.arena, old->source_offset, + old->source_length, expr_len) && + expr_len <= old->source_length; + if (!can_reuse) { + if (UINT32_MAX - required_source_bytes < expr_len) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Overflow, + "Synth control source is too large", nullptr)); + } + required_source_bytes += expr_len; + } + } + if (engine.arena.write_head > SOURCE_ARENA_SIZE || + required_source_bytes > SOURCE_ARENA_SIZE - engine.arena.write_head) { + return rollback_synth(make_synth_error_at( + def_name_tok, DiagnosticCategory::Overflow, + "Source storage full — synth control was not published", + "Use (useq-clear) to reclaim session source storage")); + } + + uint32_t staged_source_offsets[max_synth_bindings]; + for (uint16_t i = 0; i < max_synth_bindings; i++) + staged_source_offsets[i] = UINT32_MAX; + + // Compile controls and resolve audio routing. All mutations remain behind + // the synth + graph snapshots until the complete post-diff graph validates. + for (uint16_t i = 0; i < binding_count; i++) { + ParamBinding& b = bindings[i]; + if (!b.present) continue; + uint32_t expr_len = b.expr_byte_end - b.expr_byte_start; + if (expr_len == 0 || b.expr_byte_start > source_length || + expr_len > source_length - b.expr_byte_start) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Syntax, + "Synth parameter expression has an invalid source span", + nullptr)); + } + + if (!b.desc) { + // Audio inputs accept only `(node "identity")` or a nested + // `(synth ...)`. Arbitrary signal expressions are controls, not + // audio-routing endpoints. + uint16_t token_count = b.expr_end_pos - b.expr_start_pos; + if (token_count < 4 || + ts.tokens[b.expr_start_pos].kind != TokenKind::LParen || + ts.tokens[b.expr_end_pos - 1].kind != TokenKind::RParen || + ts.tokens[b.expr_start_pos + 1].kind != TokenKind::Symbol) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Boundary, + "Audio inputs need (node \"identity\") or a nested synth", + "Try: :fm (node \"lfo\")")); + } + const String& head = SymbolIntern::getInstance().getString( + ts.tokens[b.expr_start_pos + 1].symbol); + char from_identity[MAX_SYNTH_IDENTITY] = {}; + if (head == "node") { + if (token_count != 4 || + ts.tokens[b.expr_start_pos + 2].kind != TokenKind::String) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Syntax, + "node reference needs exactly one quoted identity", + "Try: (node \"lfo\")")); + } + const Token& id_tok = ts.tokens[b.expr_start_pos + 2]; + if (id_tok.string.length == 0 || + id_tok.string.length >= MAX_SYNTH_IDENTITY) { + return rollback_synth(make_synth_error_at( + id_tok, DiagnosticCategory::Overflow, + "Referenced synth identity is empty or too long", + "Use an identity from 1 to 31 bytes")); + } + std::memcpy(from_identity, source + id_tok.string.offset, + id_tok.string.length); + from_identity[id_tok.string.length] = '\0'; + } else if (head == "synth" || + GraphBuilder::resolve_namespaced_operator( + ts.tokens[b.expr_start_pos + 1].symbol).name_space == + OperatorNamespace::Osc) { + TokenStream nested_ts; + std::memcpy(nested_ts.tokens, + ts.tokens + b.expr_start_pos, + token_count * sizeof(Token)); + nested_ts.count = token_count; + nested_ts.pos = 2; // after `(` and declaration head + const Token* nested_explicit_def = head == "synth" + ? nullptr + : &nested_ts.tokens[1]; + EvalResult child = do_synth( + nested_ts, engine, source, source_length, + from_identity, true, transaction_snapshot, + (uint16_t)(nested_depth + 1), nested_explicit_def); + if (child.kind == EvalResult::Error) + return rollback_synth(child); + // Updating an existing child compacts the declaration table; + // reacquire the parent by stable identity before touching it + // again instead of retaining an invalidated array pointer. + decl = engine.synth_graph.find(identity_buf); + if (!decl) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Runtime, + "Nested synth invalidated its parent declaration", + nullptr)); + } + if (!nested_ts.expect(TokenKind::RParen) || + !nested_ts.at_end()) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Syntax, + "Nested synth has trailing input", nullptr)); + } + } else { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Boundary, + "Audio inputs need (node \"identity\") or a nested synth", + "Try: :fm (node \"lfo\")")); + } + + SynthConnection* edge = engine.synth_graph.append_connection(); + if (!edge) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Overflow, + "Synth connection table is full", + "Remove a routed synth node")); + } + std::strncpy(edge->from, from_identity, MAX_SYNTH_IDENTITY - 1); + std::strncpy(edge->to, identity_buf, MAX_SYNTH_IDENTITY - 1); + std::strncpy(edge->port, + def->audio_input_names[b.audio_input_port], + MAX_NODEDEF_NAME - 1); + edge->port_index = (uint16_t)b.audio_input_port; + continue; + } + + uint16_t owner_context = allocate_synth_owner_context( + synth_snapshot, engine.synth_graph, + identity_buf, b.desc->name); + if (owner_context == ANON_STATE_CONTEXT_NONE) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Overflow, + "Synth control ownership table is full", + "Use (useq-clear) to free earlier synths")); + } + engine.registry.begin_context(owner_context); + + const char* expr_source = source + b.expr_byte_start; + ParsedProgram control_program; + control_program.parse(expr_source, expr_len); + if (!control_program.ok()) { + EvalResult r; + r.kind = EvalResult::Error; + for (uint8_t e = 0; + e < control_program.diagnostic_count && + r.diagnostic_count < 8; e++) { + Diagnostic d = control_program.diagnostics[e]; + d.span_start = (uint16_t)(d.span_start + b.expr_byte_start); + r.diagnostics[r.diagnostic_count++] = d; + } + return rollback_synth(r); + } + + GraphBuildResult gbr = build_output_graph( + engine.pool, control_program.stream, engine.cells, engine.arena, + expr_source, &engine.registry, nullptr, owner_context); + if (gbr.has_error) { + EvalResult r; + r.kind = EvalResult::Error; + for (uint8_t e = 0; + e < gbr.diagnostic_count && r.diagnostic_count < 8; e++) { + Diagnostic d = gbr.diagnostics[e]; + d.span_start = (uint16_t)(d.span_start + b.expr_byte_start); + r.diagnostics[r.diagnostic_count++] = d; + } + return rollback_synth(r); + } + + SynthControlChannel* ctl = engine.synth_graph.append_control(); + if (!ctl) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Overflow, + "Synth control table is full", + "Use (useq-clear) to free earlier synths")); + } + const ptrdiff_t parameter_index = b.desc - def->params; + if (parameter_index < 0 || + parameter_index >= static_cast(def->param_count) || + parameter_index >= static_cast(MAX_NODEDEF_PARAMS)) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Runtime, + "NodeDef parameter metadata is inconsistent", nullptr)); + } + ctl->param_index = static_cast(parameter_index); + ctl->rate_class = b.desc->rate_class; + ctl->smoothing_class = b.desc->smoothing_class; + ctl->root_node = gbr.root_node; + ctl->owner_context = owner_context; + ctl->source_length = expr_len; + ctl->dep_count = gbr.dep_count; + for (uint8_t d = 0; d < gbr.dep_count; d++) + ctl->dep_cells[d] = gbr.dep_cells[d]; + const SynthControlChannel* old = find_synth_control( + synth_snapshot, identity_buf, b.desc->name); + if (old) { + ctl->lkg_value = old->lkg_value; + ctl->has_lkg = old->has_lkg; + } + decl->control_count++; + } + + if (!nested) { + EvalResult graph_validation = + validate_synth_patch_graph(engine.synth_graph, def_name_tok); + if (graph_validation.kind == EvalResult::Error) + return rollback_synth(graph_validation); + } + + // Stage append-only writes first. They are reversible by restoring the + // arena head. Reused regions are overwritten only after no fallible step + // remains, so a rejected candidate can never poison prior source text. + for (uint16_t i = 0; i < binding_count; i++) { + ParamBinding& b = bindings[i]; + if (!b.present || !b.desc) continue; + uint32_t expr_len = b.expr_byte_end - b.expr_byte_start; + const SynthControlChannel* old = find_synth_control( + synth_snapshot, identity_buf, b.desc->name); + bool can_reuse = !nested && old && expr_len <= old->source_length; + if (!can_reuse) { + staged_source_offsets[i] = engine.arena.store( + source + b.expr_byte_start, expr_len); + if (staged_source_offsets[i] == UINT32_MAX) { + return rollback_synth(make_synth_error_at( + b.kw_tok, DiagnosticCategory::Overflow, + "Source storage full — synth control was not published", + "Use (useq-clear) to reclaim session source storage")); + } + } + } + uint16_t control_index = decl->first_control_index; + for (uint16_t i = 0; i < binding_count; i++) { + ParamBinding& b = bindings[i]; + if (!b.present || !b.desc) continue; + SynthControlChannel& ctl = engine.synth_graph.controls[control_index++]; + uint32_t expr_len = b.expr_byte_end - b.expr_byte_start; + const SynthControlChannel* old = find_synth_control( + synth_snapshot, identity_buf, b.desc->name); + if (staged_source_offsets[i] != UINT32_MAX) { + ctl.source_offset = staged_source_offsets[i]; + } else { + ctl.source_offset = engine.arena.store_reuse( + old->source_offset, old->source_length, + source + b.expr_byte_start, expr_len); + } + } + + // Retire state resources for removed controls, and publish the state + // writers for controls that survived or were added. + if (old_declaration) { + const uint16_t old_end = static_cast( + old_declaration->first_control_index + + old_declaration->control_count); + for (uint16_t i = old_declaration->first_control_index; + i < old_end; i++) { + const SynthControlChannel& old = synth_snapshot.controls[i]; + engine.registry.commit_context( + old.owner_context, engine.pool.state_update_roots, + engine.pool.state_owner_context); + } + } + for (uint16_t i = decl->first_control_index; + i < decl->first_control_index + decl->control_count; i++) { + engine.registry.commit_context( + engine.synth_graph.controls[i].owner_context, + engine.pool.state_update_roots, + engine.pool.state_owner_context); + } + + if (out_identity) { + std::strncpy(out_identity, identity_buf, MAX_SYNTH_IDENTITY - 1); + out_identity[MAX_SYNTH_IDENTITY - 1] = '\0'; + } + if (nested) return make_ok(); + + // Graph + controls + routing share one revision and one publication. + engine.synth_graph.advance_revision(); + reclaim_unowned_resources(engine); + engine.pool.rebuild_execution_order(); + classify_outputs(engine.pool); + graph_transaction.accept(); + delete owned_snapshot; + return make_ok(); +} +#endif + +// ── Clock source management ───────────────────────────────────────────────── +// Sets EngineState clock fields; the firmware tick loop reads these and +// reconfigures its ClockSync module. On non-hardware builds (WASM, desktop) +// these still set the state — they just have no observable effect since no +// gate pulses arrive. + +static EvalResult do_set_clock_ext(TokenStream& ts, EngineState& state) { + Token input_tok = ts.consume(); + if (input_tok.kind != TokenKind::Number || + !std::isfinite(input_tok.number) || + std::floor(input_tok.number) != input_tok.number) { + return make_error("set-clock-ext needs input 1 or 2 and an optional divisor", + "Try: (set-clock-ext 1 1)"); + } + int input = (int)input_tok.number; + if (input != 1 && input != 2) { + return make_error("set-clock-ext input must be 1 or 2", + "Try: (set-clock-ext 1 1) or (set-clock-ext 2 4)"); + } + + int divisor = 1; + if (ts.peek().kind == TokenKind::Number) { + Token div_tok = ts.consume(); + if (!std::isfinite(div_tok.number) || div_tok.number < 1.0 || + div_tok.number > 255.0 || + std::floor(div_tok.number) != div_tok.number) { + return make_error( + "set-clock-ext divisor must be a whole number from 1 to 255", + "Try: (set-clock-ext 1 4)"); + } + divisor = (int)div_tok.number; + } else if (ts.peek().kind != TokenKind::RParen) { + return make_error("set-clock-ext divisor must be a number", + "Try: (set-clock-ext 1 4)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("set-clock-ext accepts an input and optional divisor", + "Try: (set-clock-ext 1 4)"); + } + + state.clock_source = (uint8_t)input; + state.ext_clock_divisor = (uint8_t)divisor; + state.ext_clock_reset = true; + return make_ok(); +} + +static EvalResult clock_command_no_args(TokenStream& ts, const char* name) { + if (ts.peek().kind == TokenKind::RParen) return make_ok(); + static char message[96]; + std::snprintf(message, sizeof(message), "%s accepts no arguments", name); + return make_error(message, nullptr); +} + +static EvalResult do_set_clock_int(TokenStream& ts, EngineState& state) { + EvalResult arity = clock_command_no_args(ts, "set-clock-int"); + if (arity.kind == EvalResult::Error) return arity; + state.clock_source = 0; + return make_ok(); +} + +static EvalResult do_get_clock_source(TokenStream& ts, EngineState& state) { + EvalResult arity = clock_command_no_args(ts, "get-clock-source"); + if (arity.kind == EvalResult::Error) return arity; + EvalResult r; + r.kind = EvalResult::Number; + r.number = (Sample)state.clock_source; + return r; +} + +static EvalResult do_reset_clock_ext(TokenStream& ts, EngineState& state) { + EvalResult arity = clock_command_no_args(ts, "reset-clock-ext"); + if (arity.kind == EvalResult::Error) return arity; + state.ext_clock_reset = true; + return make_ok(); +} + +static EvalResult do_reset_clock_int(TokenStream& ts, EngineState& state) { + EvalResult arity = clock_command_no_args(ts, "reset-clock-int"); + if (arity.kind == EvalResult::Error) return arity; + state.rewind(); + return make_ok(); +} + +// ── Output assignment ─────────────────────────────────────────────────────── + +static EvalResult do_unassign(TokenStream& ts, SignalEngine& engine) { + Token output_tok = ts.consume(); + if (output_tok.kind != TokenKind::Symbol || + !GraphBuilder::is_output_symbol(output_tok.symbol)) { + return make_error("unassign needs one output name", + "Try: (unassign a1)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("unassign accepts exactly one output name", + "Try: (unassign a1)"); + } + + const uint16_t output_index = + GraphBuilder::resolve_output_index(output_tok.symbol); + + // Validation above is complete before the publication point. Removing + // a program also removes its sample history, source/dependencies and + // owner-scoped state/live resources; the next running tick emits neutral + // zero for the now-inactive slot. + engine.pool.outputs[output_index] = OutputSlot{}; + engine.pool.prev_output_values[output_index] = 0.0; + engine.pool.output_deps[output_index].clear(); + engine.output_sources[output_index] = OutputSource{}; + engine.pool.runtime_fallback_mask &= + ~((uint64_t)1 << output_index); + engine.output_compile_diagnostics[output_index] = + ActiveCompileDiagnostic{}; + + engine.registry.begin_context(output_index); + engine.registry.commit_context(output_index, + engine.pool.state_update_roots, + engine.pool.state_owner_context); + reclaim_unowned_resources(engine); + engine.pool.rebuild_execution_order(); + classify_outputs(engine.pool); + return make_ok(); +} + +static EvalResult do_output_assign(SymbolID output_sym, TokenStream& ts, + SignalEngine& engine, + const char* source, uint32_t source_length, + SharedLiveEditIDs* shared_ids = nullptr) { + uint16_t output_index = GraphBuilder::resolve_output_index(output_sym); + if (output_index == NODE_NONE) { + return make_error("Unknown output", "Try: (a1 expression)"); + } + + // Locate and preflight expression storage, but publish the bytes only + // after graph construction succeeds. store_reuse() may overwrite an old + // region in place, so writing here would poison reactive recompilation + // when the replacement later fails. + uint16_t expr_start_pos = ts.pos; + uint32_t byte_start = span_begin(ts, expr_start_pos); + const OutputSource previous_source = engine.output_sources[output_index]; + uint16_t saved = ts.pos; + GraphBuilder::skip_form(ts); + uint32_t byte_end = span_end_of(ts, ts.pos); + ts.rewind(saved); + SourceMutationPlan source_plan = SourceMutationPlan::prepare( + engine.arena, source, source_length, byte_start, byte_end, + previous_source.has_source + ? previous_source.arena_offset : UINT32_MAX, + previous_source.has_source ? previous_source.arena_length : 0); + if (source_plan.status == SourcePlanStatus::CapacityExceeded) { + return make_error( + "Program storage is full — output not changed", + "Free space with (useq-clear) or shorten your program"); + } + if (source_plan.status == SourcePlanStatus::InvalidSpan) { + return make_error("Output expression has an invalid source span", + "Resubmit the complete output form"); + } + + GraphMutationTransaction graph_transaction(engine); + engine.registry.begin_context(output_index); + GraphBuildResult result = build_output_graph(engine.pool, ts, + engine.cells, engine.arena, source, + &engine.registry, shared_ids, + output_index); + + if (result.has_error || ts.peek().kind != TokenKind::RParen) { + // Per failure-model.md §2.6, a compile-time error leaves the active + // program unchanged: do NOT demote `valid`. Demoting here was also the + // root cause of A2 — sig::commit_outputs resurrects valid=true for any + // output with a root node, so the flag flapped and the WASM batch-vis + // row packing drifted mid-batch. + EvalResult r; + r.kind = EvalResult::Error; + if (result.has_error) { + memcpy(r.diagnostics, result.diagnostics, + result.diagnostic_count * sizeof(Diagnostic)); + r.diagnostic_count = result.diagnostic_count; + } else { + r = make_error("Output assignment accepts exactly one expression", + "Try: (a1 expression)"); + } + return r; + } + + if (source_plan.has_source()) { + uint32_t offset = source_plan.publish(engine.arena); + if (offset == UINT32_MAX) { + return make_error( + "Program storage is full — output not changed", + "Free space with (useq-clear) or shorten your program"); + } + engine.output_sources[output_index].arena_offset = offset; + engine.output_sources[output_index].arena_length = source_plan.length; + engine.output_sources[output_index].has_source = true; + } + + // Publish the candidate root, dependencies, ownership, and health only + // after parsing, storage capacity, and graph construction all succeeded. + publish_output_graph_plan(engine, output_index, result); + graph_transaction.accept(); + + // Reclaim nodes no longer reachable from any output root + reclaim_unowned_resources(engine); + + // Re-sort execution order + engine.pool.rebuild_execution_order(); + classify_outputs(engine.pool); + + return make_ok(); +} + +// ── Scratch-isolated expression evaluation ───────────────────────────────── + +EvalResult eval_expression(const char* source, uint32_t length, + SignalEngine& engine) { + ParsedProgram program; + program.parse(source, length); + if (!program.ok()) { + EvalResult r; + r.kind = EvalResult::Error; + memcpy(r.diagnostics, program.diagnostics, + program.diagnostic_count * sizeof(Diagnostic)); + r.diagnostic_count = program.diagnostic_count; + return r; + } + if (program.empty()) return make_ok(); + TokenStream& ts = program.stream; + + // Save CellStore data table state (compilation may append vector literals) + uint8_t saved_table_count = engine.cells.data_table_count; + + // Reset scratch pool + engine.scratch_pool.reset(); + + // Mirror live state values into the scratch pool BEFORE compiling (A5, + // state-identity.md §6.6): compiling a stateful expression writes its + // init value into a freshly-allocated scratch slot; copying live values + // afterwards clobbered those inits. + memcpy(engine.scratch_pool.state_values, engine.pool.state_values, + sizeof(engine.pool.state_values)); + + // Compile into scratch pool + GraphBuildResult result = build_output_graph( + engine.scratch_pool, ts, engine.cells, engine.arena, source); + + if (result.has_error) { + engine.cells.data_table_count = saved_table_count; + EvalResult r; + r.kind = EvalResult::Error; + memcpy(r.diagnostics, result.diagnostics, + result.diagnostic_count * sizeof(Diagnostic)); + r.diagnostic_count = result.diagnostic_count; + return r; + } + + // Install as output 0 and build execution order + engine.scratch_pool.outputs[0].root_node = result.root_node; + engine.scratch_pool.outputs[0].valid = true; + engine.scratch_pool.rebuild_execution_order(); + + // Snapshot cell values + Sample cell_values[MAX_CELLS]; + engine.cells.snapshot_values(cell_values, MAX_CELLS); + + // Execute one sample + Sample hw_inputs[32] = {}; + Sample outputs[MAX_OUTPUTS] = {}; + Sample workspace[MAX_TOTAL_NODES] = {}; + + ExecutionContext ctx; + ctx.t = engine.state.current_time; + ctx.dt = engine.state.current_dt; + ctx.cell_values = cell_values; + ctx.hw_inputs = hw_inputs; + ctx.data_pool = engine.cells.data_pool; + ctx.data_offsets = engine.cells.data_offsets; + ctx.data_lengths = engine.cells.data_lengths; + ctx.prev_outputs = engine.pool.prev_output_values; + ctx.output_values = outputs; + ctx.workspace = workspace; + execute_all_outputs(engine.scratch_pool, ctx); + + // Restore CellStore data table state + engine.cells.data_table_count = saved_table_count; + + return make_number(outputs[0]); +} + +// ── Atomic bulk definitions ─────────────────────────────────────────────── + +static EvalResult do_defs(TokenStream& ts, SignalEngine& engine, + const char* source, uint32_t source_length) { + if (!ts.expect(TokenKind::LBracket)) { + return make_error("defs needs a bracket list of name-value pairs", + "Try: (defs [x 1 y 2 z 3])"); + } + + constexpr uint16_t MAX_DEFS_BINDINGS = 64; + SymbolID symbols[MAX_DEFS_BINDINGS] = {}; + uint16_t binding_count = 0; + uint16_t list_start = ts.pos; + uint16_t new_table_count = 0; + uint32_t new_data_entries = 0; + uint32_t new_source_bytes = 0; + + // Validation/preflight pass. No live store is touched until the complete + // list, outer arity, and cumulative fixed-store capacity are proven. + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) { + if (binding_count >= MAX_DEFS_BINDINGS) { + return make_error("defs has too many bindings", + "Split the definitions into smaller forms"); + } + Token name_tok = ts.consume(); + if (name_tok.kind != TokenKind::Symbol) { + return make_error("defs: expected a name", + "Try: (defs [x 1 y 2])"); + } + if (cell_id_out_of_range(name_tok.symbol)) + return make_too_many_definitions_error(); + for (uint16_t i = 0; i < binding_count; i++) { + if (symbols[i] == name_tok.symbol) { + return make_error("defs contains the same name twice", + "Keep one value for each name"); + } + } + symbols[binding_count++] = name_tok.symbol; + + if (ts.at_end() || ts.peek().kind == TokenKind::RBracket) { + return make_error("defs: each name needs a value", + "Try: (defs [x 1 y 2])"); + } + + Token val_tok = ts.peek(); + if (val_tok.kind == TokenKind::Number) { + ts.consume(); + } else if (val_tok.kind == TokenKind::LBracket) { + ts.consume(); + uint16_t count = 0; + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) { + Token elem = ts.consume(); + if (elem.kind != TokenKind::Number) { + return make_error("defs vectors contain only numbers", + "Try: (defs [steps [1 0 1 0]])"); + } + if (++count > 64) { + return make_error("defs vector is too long", + "Use at most 64 values"); + } + } + if (!ts.expect(TokenKind::RBracket)) { + return make_error("defs has an unterminated vector", + "Close the vector with ]"); + } + new_table_count++; + new_data_entries += count; + } else { + uint32_t byte_start = span_begin(ts, ts.pos); + GraphBuilder::skip_form(ts); + uint32_t byte_end = span_end_of(ts, ts.pos); + if (!source || byte_end <= byte_start || + byte_end > source_length) { + return make_error("defs has an invalid value expression", + "Try: (defs [x (+ 1 2)])"); + } + uint32_t len = byte_end - byte_start; + const CallableInfo previous = + engine.cells.cells[name_tok.symbol].kind == CellKind::Callable + ? engine.cells.callables[name_tok.symbol] : CallableInfo{}; + bool can_reuse = + previous.source_offset <= SOURCE_ARENA_SIZE && + previous.source_length <= + SOURCE_ARENA_SIZE - previous.source_offset && + len <= previous.source_length; + if (!can_reuse) { + new_source_bytes += len; + } + } + } + + if (!ts.expect(TokenKind::RBracket)) { + return make_error("defs needs a closing bracket", + "Try: (defs [x 1 y 2])"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("defs accepts exactly one binding vector", + "Try: (defs [x 1 y 2])"); + } + + uint32_t data_used = 0; + if (engine.cells.data_table_count > 0) { + uint16_t last = engine.cells.data_table_count - 1; + data_used = engine.cells.data_offsets[last] + + engine.cells.data_lengths[last]; + } + if ((uint32_t)engine.cells.data_table_count + new_table_count > + MAX_DATA_TABLES || + data_used + new_data_entries > MAX_DATA_ENTRIES) { + return make_error("Data table storage is full — defs not applied", + "Free space with (useq-clear) or use fewer vectors"); + } + if (engine.arena.write_head > SOURCE_ARENA_SIZE || + new_source_bytes > SOURCE_ARENA_SIZE - engine.arena.write_head) { + return make_error("Program storage is full — defs not applied", + "Free space with (useq-clear) or shorten your program"); + } + + // Commit pass. Every operation below has been capacity-checked above. + ts.rewind(list_start); + for (uint16_t binding = 0; binding < binding_count; binding++) { + Token name_tok = ts.consume(); + SymbolID cell_sym = name_tok.symbol; + Token val_tok = ts.peek(); + + if (val_tok.kind == TokenKind::Number) { + ts.consume(); + engine.cells.cells[cell_sym].kind = CellKind::Number; + engine.cells.cells[cell_sym].flags = 0; + engine.cells.cells[cell_sym].revision++; + engine.cells.cells[cell_sym].value = val_tok.number; + engine.cells.callables[cell_sym] = CallableInfo{}; + } else if (val_tok.kind == TokenKind::LBracket) { + ts.consume(); + Sample values[64]; + uint16_t count = 0; + while (ts.peek().kind != TokenKind::RBracket) { + values[count++] = ts.consume().number; + } + ts.expect(TokenKind::RBracket); + uint16_t table_id = + engine.cells.store_data_table(values, count); + engine.cells.cells[cell_sym].kind = CellKind::Data; + engine.cells.cells[cell_sym].flags = 0; + engine.cells.cells[cell_sym].data_table_id = table_id; + engine.cells.cells[cell_sym].revision++; + engine.cells.cells[cell_sym].value = (Sample)count; + engine.cells.callables[cell_sym] = CallableInfo{}; + } else { + uint32_t byte_start = span_begin(ts, ts.pos); + GraphBuilder::skip_form(ts); + uint32_t byte_end = span_end_of(ts, ts.pos); + uint32_t len = byte_end - byte_start; + const CallableInfo previous = + engine.cells.cells[cell_sym].kind == CellKind::Callable + ? engine.cells.callables[cell_sym] : CallableInfo{}; + uint32_t offset = engine.arena.store_reuse( + previous.source_offset, previous.source_length, + source + byte_start, len); + engine.cells.cells[cell_sym].kind = CellKind::Callable; + engine.cells.cells[cell_sym].flags = 0; + engine.cells.cells[cell_sym].revision++; + engine.cells.callables[cell_sym] = CallableInfo{}; + engine.cells.callables[cell_sym].source_offset = offset; + engine.cells.callables[cell_sym].source_length = len; + } + } + ts.expect(TokenKind::RBracket); + + for (uint16_t i = 0; i < binding_count; i++) { + on_cell_changed(symbols[i], engine); + } + return make_ok(); +} + +// ── Top-level eval ────────────────────────────────────────────────────────── + +struct EvalDispatchResult { + bool handled = false; + EvalResult result = {}; +}; + +static EvalResult finish_parenthesized_form(TokenStream& ts, + EvalResult result) { + ts.expect(TokenKind::RParen); + return result; +} + +static EvalDispatchResult eval_cell_mutation_form( + SymbolID op, TokenStream& ts, SignalEngine& engine, + const char* source, uint32_t source_length, + const GraphBuilder::Symbols& symbols) { + EvalResult result; + if (op == symbols.define || op == symbols.def) { + result = do_define(ts, engine, source, source_length); + } else if (op == symbols.defn || op == symbols.defun) { + result = do_defn(ts, engine, source, source_length); + } else if (op == symbols.defs) { + result = do_defs(ts, engine, source, source_length); + } else if (op == symbols.defstate) { + result = do_defstate(ts, engine, source, source_length); + } else if (op == symbols.set) { + result = do_set(ts, engine, source); + } else if (op == symbols.unassign) { + result = do_unassign(ts, engine); + } else { + return {}; + } + return {true, finish_parenthesized_form(ts, result)}; +} + +static EvalDispatchResult reject_direct_live_edit_argument( + TokenStream& ts, const char* message) { + if (!next_is_live_edit(ts)) return {}; + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) ts.consume(); + ts.expect(TokenKind::RParen); + return {true, make_error( + message, + "live-edit must be used inside an output expression like (a1 ...)")}; +} + +static EvalDispatchResult eval_transport_form( + SymbolID op, TokenStream& ts, SignalEngine& engine, + const GraphBuilder::Symbols& symbols) { + EvalDispatchResult rejected; + EvalResult result; + if (op == symbols.set_bpm) { + rejected = reject_direct_live_edit_argument( + ts, "live-edit is not allowed as a direct argument of set-bpm"); + if (rejected.handled) return rejected; + result = do_set_bpm(ts, engine); + } else if (op == symbols.set_time_sig) { + rejected = reject_direct_live_edit_argument( + ts, "live-edit is not allowed as a direct argument of set-time-sig"); + if (rejected.handled) return rejected; + result = do_set_time_sig(ts, engine); + } else if (op == symbols.set_time_offset) { + rejected = reject_direct_live_edit_argument( + ts, "live-edit is not allowed as a direct argument of set-time-offset"); + if (rejected.handled) return rejected; + result = do_set_time_offset(ts, engine.state); + } else if (op == symbols.nudge_time) { + rejected = reject_direct_live_edit_argument( + ts, "live-edit is not allowed as a direct argument of nudge-time"); + if (rejected.handled) return rejected; + result = do_nudge_time(ts, engine.state); + } else if (op == symbols.useq_clear) { + if (ts.peek().kind != TokenKind::RParen) { + return {true, make_error("useq-clear accepts no arguments", + "Try: (useq-clear)")}; + } + result = do_useq_clear(engine); + } else if (op == symbols.useq_play || op == symbols.useq_pause || + op == symbols.useq_stop || op == symbols.useq_rewind) { + if (ts.peek().kind != TokenKind::RParen) { + const char* suggestion = op == symbols.useq_play ? "Try: (useq-play)" : + op == symbols.useq_pause ? "Try: (useq-pause)" : + op == symbols.useq_stop ? "Try: (useq-stop)" : + "Try: (useq-rewind)"; + const char* message = op == symbols.useq_play + ? "useq-play accepts no arguments" + : op == symbols.useq_pause + ? "useq-pause accepts no arguments" + : op == symbols.useq_stop + ? "useq-stop accepts no arguments" + : "useq-rewind accepts no arguments"; + return {true, make_error(message, suggestion)}; + } + if (op == symbols.useq_play) engine.state.play(); + else if (op == symbols.useq_pause) engine.state.pause(); + else if (op == symbols.useq_stop) engine.state.stop(); + else engine.state.rewind(); + result = make_ok(); + } else { + return {}; + } + return {true, finish_parenthesized_form(ts, result)}; +} + +static EvalResult eval_top_level_vector(TokenStream& ts, SignalEngine& engine, + const char* source, + uint32_t source_length) { + ts.consume(); + engine.scratch_pool.reset(); + const uint8_t saved_table_count = engine.cells.data_table_count; + + char* buffer = engine.eval_text_buf; + uint16_t buffer_position = 0; + buffer[buffer_position++] = '['; + bool first = true; + EvalResult error = {}; + + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) { + const Token element_start = ts.peek(); + if (element_start.kind == TokenKind::LParen || + element_start.kind == TokenKind::LBracket) { + GraphBuilder::skip_form(ts); + } else { + ts.consume(); + } + + const uint32_t span_start = element_start.span_start; + const Token previous = ts.tokens[ts.pos > 0 ? ts.pos - 1 : 0]; + const uint32_t span_end = + static_cast(previous.span_start) + previous.span_len; + if (!source || span_end <= span_start || span_end > source_length) { + continue; + } + + const EvalResult element = eval_expression( + source + span_start, span_end - span_start, engine); + if (element.kind == EvalResult::Error) { + error = element; + break; + } + constexpr uint16_t capacity = sizeof(engine.eval_text_buf); + if (!first && buffer_position < capacity - 20) { + buffer[buffer_position++] = ' '; + } + first = false; + const int written = snprintf(buffer + buffer_position, + capacity - buffer_position, + "%.15g", element.number); + if (written > 0) buffer_position += static_cast(written); + } + + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) ts.consume(); + ts.expect(TokenKind::RBracket); + engine.cells.data_table_count = saved_table_count; + if (error.kind == EvalResult::Error) return error; + + constexpr uint16_t capacity = sizeof(engine.eval_text_buf); + if (buffer_position < capacity - 1) buffer[buffer_position++] = ']'; + buffer[buffer_position] = '\0'; + EvalResult result; + result.kind = EvalResult::Text; + result.text = buffer; + result.text_length = buffer_position; + return result; +} + +static EvalResult eval_form(TokenStream& ts, SignalEngine& engine, + const char* source, uint32_t source_length, + SharedLiveEditIDs* shared_ids) { + Token tok = ts.peek(); + + if (tok.kind == TokenKind::Number) { + ts.consume(); + return make_number(tok.number); + } + + if (tok.kind == TokenKind::Symbol) { + ts.consume(); + SymbolID sym = tok.symbol; + if (sym < MAX_CELLS && engine.cells.cells[sym].kind == CellKind::Number) { + const Cell& cell = engine.cells.cells[sym]; + if (cell.flags == 0x02 && + cell.data_table_id < engine.pool.state_slot_count) { + return make_number(engine.pool.state_values[cell.data_table_id]); + } + return make_number(cell.value); + } + if (source && tok.span_start + tok.span_len <= source_length) { + return eval_expression( + source + tok.span_start, tok.span_len, engine); + } + return make_ok(); + } + + if (tok.kind == TokenKind::LBracket) { + return eval_top_level_vector(ts, engine, source, source_length); + } + + if (tok.kind == TokenKind::LParen) { + ts.consume(); // eat '(' + Token op_tok = ts.consume(); + if (op_tok.kind != TokenKind::Symbol) { + return make_error("Expected a function name after '('", + "Try: (define name value)"); + } + SymbolID op = op_tok.symbol; + + GraphBuilder::init_symbols(); + auto& sym = GraphBuilder::sym; + + const EvalDispatchResult mutation = eval_cell_mutation_form( + op, ts, engine, source, source_length, sym); + if (mutation.handled) return mutation.result; + + const EvalDispatchResult transport = + eval_transport_form(op, ts, engine, sym); + if (transport.handled) return transport.result; + +#if USEQ_HAS_SYNTH_ENGINE + const NamespacedOperator namespaced = + GraphBuilder::resolve_namespaced_operator(op); + + // synth — top-level NodeDef instantiation (synth-nodes.md §3) + if (op == sym.synth) { + EvalResult r = do_synth(ts, engine, source, source_length); + ts.expect(TokenKind::RParen); + return r; + } + + // osc/ — registry-backed sugar for (synth "osc/" ...). + if (namespaced.name_space == OperatorNamespace::Osc) { + EvalResult r = do_synth( + ts, engine, source, source_length, + nullptr, false, nullptr, 0, &op_tok); + ts.expect(TokenKind::RParen); + return r; + } + + // with-state-id — identity wrapper (state-identity.md §2.2). + // The editor payload builder wraps anonymous stateful forms in + // `(with-state-id ""
)`. The wrapper and `:id` normalise + // to the same internal identity annotation: the id is stashed as + // the pending state identity, and the first synth declaration + // evaluated under the wrapper consumes it (using it only when the + // form has no explicit :name/:id — ergo e58f128f). Nested wrappers + // scope via save/restore; the slot is always restored on return so + // an id can never leak past its wrapped form. + if (op == sym.with_state_id) { + Token id_tok = ts.consume(); + if (id_tok.kind != TokenKind::String) { + // Malformed wrapper — drain to RParen and report. + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) ts.consume(); + ts.expect(TokenKind::RParen); + return make_error( + "with-state-id needs a string identity in the first position", + "This form is generated by the editor; if you are seeing " + "this error, the editor payload builder may be out of " + "sync with the runtime."); + } + + // Prove the wrapper contains exactly one complete child before + // evaluating that potentially effectful child. + uint16_t child_start = ts.pos; + GraphBuilder::skip_form(ts); + if (ts.peek().kind != TokenKind::RParen) { + return make_error( + "with-state-id accepts exactly one wrapped form", + "Try: (with-state-id \"id\" (a1 expression))"); + } + ts.rewind(child_start); + + char saved_id[MAX_SYNTH_IDENTITY]; + std::memcpy(saved_id, engine.pending_state_identity, + MAX_SYNTH_IDENTITY); + bool saved_flag = engine.has_pending_state_identity; + + uint16_t n = id_tok.string.length; + if (n >= MAX_SYNTH_IDENTITY) n = MAX_SYNTH_IDENTITY - 1; + std::memcpy(engine.pending_state_identity, + source + id_tok.string.offset, n); + engine.pending_state_identity[n] = '\0'; + engine.has_pending_state_identity = true; + + EvalResult r = eval_form(ts, engine, source, source_length, shared_ids); + + std::memcpy(engine.pending_state_identity, saved_id, + MAX_SYNTH_IDENTITY); + engine.has_pending_state_identity = saved_flag; + ts.expect(TokenKind::RParen); + return r; + } +#endif + + // Clock source management + if (op == sym.set_clock_ext) { + EvalResult r = do_set_clock_ext(ts, engine.state); + ts.expect(TokenKind::RParen); + return r; + } + if (op == sym.set_clock_int) { + EvalResult r = do_set_clock_int(ts, engine.state); + ts.expect(TokenKind::RParen); + return r; + } + if (op == sym.get_clock_source) { + EvalResult r = do_get_clock_source(ts, engine.state); + ts.expect(TokenKind::RParen); + return r; + } + if (op == sym.reset_clock_ext) { + EvalResult r = do_reset_clock_ext(ts, engine.state); + ts.expect(TokenKind::RParen); + return r; + } + if (op == sym.reset_clock_int) { + EvalResult r = do_reset_clock_int(ts, engine.state); + ts.expect(TokenKind::RParen); + return r; + } + + // zeros — create a vector of N zeros + if (op == sym.zeros_) { + Token n_tok = ts.consume(); + if (n_tok.kind != TokenKind::Number) { + ts.expect(TokenKind::RParen); + return make_error("zeros needs a number", + "Try: (zeros 8)"); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_error("zeros accepts exactly one number", + "Try: (zeros 8)"); + } + int n = (int)n_tok.number; + if (n < 1) n = 1; + if (n > 64) n = 64; + Sample values[64] = {}; + uint16_t table_id = engine.cells.store_data_table(values, (uint16_t)n); + ts.expect(TokenKind::RParen); + EvalResult r; + r.kind = EvalResult::DataRef; + r.number = (Sample)table_id; + return r; + } + + // get-expr — return stored source expression for a symbol + if (op == sym.get_expr) { + Token name_tok = ts.consume(); + if (ts.peek().kind != TokenKind::RParen) { + return make_error("get-expr accepts exactly one name", + "Try: (get-expr my-fn)"); + } + ts.expect(TokenKind::RParen); + if (name_tok.kind != TokenKind::Symbol) { + return make_error("get-expr needs a name", + "Try: (get-expr my-fn)"); + } + SymbolID name_sym = name_tok.symbol; + if (name_sym < MAX_CELLS && + engine.cells.cells[name_sym].kind == CellKind::Callable && + engine.cells.callables[name_sym].source_length > 0) { + const char* src = engine.arena.read( + engine.cells.callables[name_sym].source_offset); + if (src) { + EvalResult r; + r.kind = EvalResult::Text; + r.text = src; + r.text_length = (uint16_t)engine.cells.callables[name_sym].source_length; + return r; + } + } + return make_error("No expression stored for this name", + "Define it first with (define name expr) or (defn name [args] body)"); + } + + // Output assignment + if (GraphBuilder::is_output_symbol(op)) { + EvalResult r = do_output_assign(op, ts, engine, + source, source_length, shared_ids); + ts.expect(TokenKind::RParen); + return r; + } + + // do / scope — evaluate children sequentially + if (op == sym.do_ || op == sym.scope) { + EvalResult last = make_ok(); + bool compact_between_forms = + !source_aliases_live_arena(source, engine); + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + last = eval_form(ts, engine, source, source_length, shared_ids); + if (last.kind == EvalResult::Error) { + // A submission is a sequence of per-form transactions. + // Earlier successful children remain committed, but the + // first failure stops the sequence and later children are + // not evaluated. + while (ts.peek().kind != TokenKind::RParen && + !ts.at_end()) { + GraphBuilder::skip_form(ts); + } + ts.expect(TokenKind::RParen); + return last; + } + // Each child is its own publication transaction. Compact at + // that boundary so one explicit do/scope cannot strand source + // proportional to its edit history. A Text result can point + // into the arena (get-expr), so defer until a later non-Text + // child or the next external eval. + if (compact_between_forms && last.kind != EvalResult::Text) + compact_live_source_arena(engine); + } + ts.expect(TokenKind::RParen); + return last; + } + + // Unknown form at top level — try as signal expression. + // Extract the full form source from the opening '(' through ')'. + // Track paren/bracket depth so nested forms (e.g. (eval-at-time T (* 0.5 bar))) + // don't terminate the slice at the first inner ')' and leave the outer + // ')' dangling for the next eval_form iteration. + { + uint32_t form_start = tok.span_start; // position of '(' + int depth = 0; + while (!ts.at_end()) { + Token nxt = ts.peek(); + if (depth == 0 && nxt.kind == TokenKind::RParen) break; + ts.consume(); + if (nxt.kind == TokenKind::LParen || + nxt.kind == TokenKind::LBracket) { + depth++; + } else if (nxt.kind == TokenKind::RParen || + nxt.kind == TokenKind::RBracket) { + if (depth > 0) depth--; + } + } + Token rparen = ts.consume(); // eat outer ')' + uint32_t form_end = rparen.span_start + rparen.span_len; + if (source && form_end > form_start && form_end <= source_length) { + return eval_expression( + source + form_start, form_end - form_start, engine); + } + return make_ok(); + } + } + + return make_error("Unexpected input", "Try: (define name value) or (a1 expression)"); +} + +#if USEQ_HAS_SYNTH_ENGINE +// ── Host synth GC integration ─────────────────────────────────────────────── +// +// Synth control channel expressions compile to real nodes in the live +// NodePool (synth-nodes.md §7.2). The pool's GC pass must keep those roots +// reachable and remap their indices, otherwise forced GC after a synth +// eval drops the control expressions and the host reads freed memory +// (VAL-COMP-011). We register the synth control roots with the pool's +// external-roots array; the GC walks and remaps them exactly like output +// roots. The synth_graph owns the authoritative indices — after GC, the +// pool updates them in place via external_roots[]. + +void register_synth_external_roots(SignalEngine& engine) { + engine.pool.clear_external_roots(); + for (uint16_t i = 0; i < engine.synth_graph.control_count(); i++) { + uint16_t root = engine.synth_graph.controls[i].root_node; + if (root != NODE_NONE) { + engine.pool.register_external_root(root); + } + } +} + +void commit_synth_external_roots(SignalEngine& engine) { + uint16_t e = 0; + for (uint16_t i = 0; i < engine.synth_graph.control_count(); i++) { + uint16_t root = engine.synth_graph.controls[i].root_node; + if (root == NODE_NONE) continue; + if (e < engine.pool.external_root_count) { + engine.synth_graph.controls[i].root_node = + engine.pool.external_roots[e++]; + } + } +} +#endif + +// ── eval_cold entry point (SignalEngine version) ─────────────────────────── + +EvalResult eval_cold(const char* source, uint32_t length, SignalEngine& engine) { + RuntimeMemorySampleScope memory_sample_scope; + ParsedProgram program; + program.parse(source, length); + + if (!program.ok()) { + EvalResult r; + r.kind = EvalResult::Error; + memcpy(r.diagnostics, program.diagnostics, + program.diagnostic_count * sizeof(Diagnostic)); + r.diagnostic_count = program.diagnostic_count; + return r; + } + TokenStream& ts = program.stream; + + // Cross-output live-edit ID tracking — cleared per eval batch + SharedLiveEditIDs shared_ids; + +#if USEQ_HAS_SYNTH_ENGINE + // Reset the per-eval anonymous synth ordinal and defensively clear any + // stale pending wrapper identity (state-identity.md §2.2/§2.5). Both + // are eval-scoped: the ordinal keys the anonymous fallback identity, + // and the pending id only lives inside a with-state-id wrapper. + engine.eval_anon_synth_ordinal = 0; + engine.has_pending_state_identity = false; +#endif + + // Any cold eval may mutate cell values — bump the store revision so + // per-tick snapshot consumers know to refresh (A12). Coarse but sound. + engine.cells.store_revision++; + + // Handle multiple forms (implicit do) + EvalResult last = make_ok(); + bool compact_between_forms = !source_aliases_live_arena(source, engine); + while (!ts.at_end() && ts.peek().kind != TokenKind::Eof) { + last = eval_form(ts, engine, source, length, &shared_ids); + if (last.kind == EvalResult::Error) { + // A submission is a sequence of per-form transactions: retain + // earlier committed forms, stop at the first rejected one. + return last; + } + // All fallible work for this per-form transaction is complete. Keep + // arena use proportional to published owners rather than replacement + // history. Do not relocate an arena-backed input buffer or a Text + // result whose pointer is itself the requested stored source. + if (compact_between_forms && last.kind != EvalResult::Text) + compact_live_source_arena(engine); + } + + return last; +} + +// ── Bulk recompilation ───────────────────────────────────────────────────── +// Rebuilds every output graph from stored source text. Mirrors the +// per-output recompilation in on_cell_changed() but operates on ALL outputs +// unconditionally — used after flash load when no graphs exist yet. + +void recompile_all_outputs(SignalEngine& engine) { + for (uint16_t i = 0; i < MAX_OUTPUTS; i++) { + if (!engine.output_sources[i].has_source) continue; + + const char* src = engine.arena.read( + engine.output_sources[i].arena_offset); + if (!src) continue; + + ParsedProgram program; + program.parse(src, engine.output_sources[i].arena_length); + + if (!program.ok()) { + // Stored source can be corrupt (for example after loading an old + // flash image), but recompilation is still a publication + // transaction. Keep any already-live graph intact. + continue; + } + + GraphMutationTransaction graph_transaction(engine); + engine.registry.begin_context(i); + GraphBuildResult result = build_output_graph( + engine.pool, program.stream, engine.cells, engine.arena, src, + &engine.registry, nullptr, i); + + if (!result.has_error) { + publish_output_graph_plan(engine, i, result); + graph_transaction.accept(); + } + } + + // Reclaim stale nodes from any previous compilation (idempotent due to CSE) + reclaim_unowned_resources(engine); + engine.pool.rebuild_execution_order(); + classify_outputs(engine.pool); +} + +// ── Dependency tracking (SignalEngine version) ───────────────────────────── + +void on_cell_changed(SymbolID cell_id, SignalEngine& engine) { + for (uint16_t i = 0; i < MAX_OUTPUTS; i++) { + if (engine.pool.outputs[i].root_node == NODE_NONE) continue; + if (!engine.pool.output_deps[i].contains(cell_id)) continue; + + // This output needs recompilation + if (engine.output_sources[i].has_source) { + const char* src = engine.arena.read( + engine.output_sources[i].arena_offset); + if (src) { + ParsedProgram program; + program.parse(src, engine.output_sources[i].arena_length); + + if (program.ok()) { + GraphMutationTransaction graph_transaction(engine); + engine.registry.begin_context(i); + GraphBuildResult result = build_output_graph( + engine.pool, program.stream, engine.cells, + engine.arena, src, + &engine.registry, nullptr, i); + if (!result.has_error) { + publish_output_graph_plan(engine, i, result); + graph_transaction.accept(); + } else { + // A reactive compile is a candidate publication just + // like a direct output assignment. Retain the old + // root, validity, dependencies, state values, and + // capacity when the candidate is rejected. + publish_reactive_diagnostic( + engine.output_compile_diagnostics[i], cell_id, + result); + } + } else { + ActiveCompileDiagnostic& active = + engine.output_compile_diagnostics[i]; + active = ActiveCompileDiagnostic{}; + active.active = true; + active.triggered_by = cell_id; + active.diagnostic = { + DiagnosticSeverity::Error, + DiagnosticCategory::Syntax, 0, 0, + "Stored output source could not be parsed; the previous program is still running", + "Reassign this output with valid source" + }; + } + } + } + } + + // Recompile state update graphs that depend on the changed cell + for (uint16_t s = 0; s < engine.pool.state_slot_count; s++) { + if (!engine.state_sources[s].has_source) continue; + + bool depends = false; + for (uint8_t d = 0; d < engine.state_sources[s].dep_count; d++) { + if (engine.state_sources[s].dep_cells[d] == cell_id) { + depends = true; + break; + } + } + if (!depends) continue; + + const char* src = engine.arena.read(engine.state_sources[s].arena_offset); + if (!src) continue; + + ParsedProgram program; + program.parse(src, engine.state_sources[s].arena_length); + + if (program.ok()) { + GraphMutationTransaction graph_transaction(engine); + uint16_t owner_context = + (uint16_t)(MAX_OUTPUTS + s); + engine.registry.begin_context(owner_context); + GraphBuildResult result = build_output_graph( + engine.pool, program.stream, engine.cells, engine.arena, src, + &engine.registry, nullptr, owner_context); + if (!result.has_error) { + publish_state_update_plan(engine, s, result); + graph_transaction.accept(); + } else { + publish_reactive_diagnostic( + engine.state_compile_diagnostics[s], cell_id, result); + } + } else { + ActiveCompileDiagnostic& active = + engine.state_compile_diagnostics[s]; + active = ActiveCompileDiagnostic{}; + active.active = true; + active.triggered_by = cell_id; + active.diagnostic = { + DiagnosticSeverity::Error, DiagnosticCategory::Syntax, + 0, 0, + "Stored state update source could not be parsed; the previous update is still running", + "Redefine this state with a valid update expression" + }; + } + } + +#if USEQ_HAS_SYNTH_ENGINE + // Synth controls are persistent programs too. Recompile only channels + // whose recorded cell dependency changed, preserving the prior root and + // its state/resources when the candidate cannot be built. + for (uint16_t i = 0; i < engine.synth_graph.control_count(); i++) { + SynthControlChannel& control = engine.synth_graph.controls[i]; + bool depends = false; + for (uint8_t d = 0; d < control.dep_count; d++) { + if (control.dep_cells[d] == cell_id) { + depends = true; + break; + } + } + if (!depends || control.source_length == 0) continue; + + const char* src = engine.arena.read(control.source_offset); + if (!src) { + const Diagnostic diagnostic = { + DiagnosticSeverity::Error, DiagnosticCategory::Runtime, + 0, 0, + "Stored synth-control source is unavailable; the previous control is still running", + "Replace the synth declaration with valid control source" + }; + publish_synth_reactive_diagnostic(control, cell_id, diagnostic); + continue; + } + ParsedProgram program; + program.parse(src, control.source_length); + if (!program.ok()) { + publish_synth_reactive_diagnostic( + control, cell_id, program.diagnostics[0]); + continue; + } + + GraphMutationTransaction graph_transaction(engine); + engine.registry.begin_context(control.owner_context); + GraphBuildResult result = build_output_graph( + engine.pool, program.stream, engine.cells, engine.arena, src, + &engine.registry, nullptr, control.owner_context); + if (result.has_error) { + if (result.diagnostic_count > 0) { + publish_synth_reactive_diagnostic( + control, cell_id, result.diagnostics[0]); + } else { + const Diagnostic diagnostic = { + DiagnosticSeverity::Error, + DiagnosticCategory::Runtime, 0, 0, + "A synth-control dependency change could not be applied; the previous control is still running", + "Repair the changed definition" + }; + publish_synth_reactive_diagnostic( + control, cell_id, diagnostic); + } + continue; + } + control.root_node = result.root_node; + control.dep_count = result.dep_count; + for (uint8_t d = 0; d < result.dep_count; d++) + control.dep_cells[d] = result.dep_cells[d]; + engine.registry.commit_context( + control.owner_context, engine.pool.state_update_roots, + engine.pool.state_owner_context); + clear_synth_reactive_diagnostic(control); + graph_transaction.accept(); + } +#endif + + // Reclaim nodes orphaned by the recompiles above (F4). Every sibling + // recompile path (eval_output, recompile_all_outputs, do_output_assign) + // gc's before rebuilding; without this, live cell edits leak the old + // graphs until the fixed node pool (360 nodes on firmware) fills up and + // compilation silently fails. + reclaim_unowned_resources(engine); + engine.pool.rebuild_execution_order(); + + // Recompilation may have changed which load ops each output references + // (e.g. an output that used to be Pure now reads an input, or a feedback / + // state dependency appeared/disappeared). Refresh the classification so + // output_class / output_input_mask don't go stale after a cell change. + classify_outputs(engine.pool); +} + +} // namespace sig diff --git a/src/signal_engine/cold_eval.h b/src/signal_engine/cold_eval.h new file mode 100644 index 0000000..69173c3 --- /dev/null +++ b/src/signal_engine/cold_eval.h @@ -0,0 +1,236 @@ +#ifndef SIGNAL_ENGINE_COLD_EVAL_H +#define SIGNAL_ENGINE_COLD_EVAL_H + +#include "types.h" +#include "cell_store.h" +#include "node_pool.h" +#include "state_registry.h" +#if USEQ_HAS_SYNTH_ENGINE +#include "synth_graph.h" +#endif +#include "diagnostics.h" + +namespace sig { + +// ── Engine State ─────────────────────────────────────────────────────────── +// Transport and timing state managed by cold-path commands. + +// Clock source for tempo: internal (free-running) or driven by a gate input. +// Stored as uint8_t so EngineState stays POD-friendly across the engine/firmware +// boundary; the firmware-side ClockSync module interprets the values. +// 0 = internal, 1 = external on input I1, 2 = external on input I2 +struct EngineState { + Sample time_offset = 0.0; + // Internal transport-origin correction. Kept separate from the + // user-visible time_offset reported over the wire. + Sample transport_offset = 0.0; + bool is_playing = true; + Sample current_time = 0.0; + Sample current_dt = 0.0; + Sample current_wall_time = 0.0; + Sample paused_time = 0.0; + bool has_pause_anchor = false; + bool reset_dt_on_next_tick = true; + + // Translate a host/hardware monotonic clock into session-logical time. + // Paused time is an anchor, not a wall clock that continues invisibly. + Sample logical_time(Sample wall_time) const { + return (!is_playing && has_pause_anchor) + ? paused_time : wall_time + time_offset + transport_offset; + } + + void pause() { + if (is_playing) { + paused_time = current_time; + has_pause_anchor = true; + } + is_playing = false; + current_dt = 0.0; + } + + void play() { + if (!is_playing) { + if (has_pause_anchor) { + transport_offset = + paused_time - current_wall_time - time_offset; + current_time = paused_time; + } + is_playing = true; + reset_dt_on_next_tick = true; + current_dt = 0.0; + } + } + + void rewind() { + // At the current wall instant, wall + user offset + transport-origin + // correction must equal logical zero. + transport_offset = -current_wall_time - time_offset; + current_time = 0.0; + current_dt = 0.0; + reset_dt_on_next_tick = true; + if (!is_playing) { + paused_time = 0.0; + has_pause_anchor = true; + } + } + + void stop() { + rewind(); + paused_time = 0.0; + has_pause_anchor = true; + is_playing = false; + } + + uint8_t clock_source = 0; // see comment above + uint8_t ext_clock_divisor = 1; // pulses per beat (MIDI: typically 1, 2, 4, 24) + bool ext_clock_reset = false; // one-shot: firmware resets tracker, then clears +}; + +// ── Output Source Storage ─────────────────────────────────────────────────── +// Stores the source text for each output expression (needed for recompilation). + +struct OutputSource { + uint32_t arena_offset = 0; + uint32_t arena_length = 0; + bool has_source = false; +}; + +// ── Eval Result ───────────────────────────────────────────────────────────── + +struct EvalResult { + enum Kind : uint8_t { Number, Text, DataRef, Ok, Error } kind = Ok; + Sample number = 0.0; + const char* text = nullptr; + uint16_t text_length = 0; + Diagnostic diagnostics[8] = {}; + uint8_t diagnostic_count = 0; +}; + +// ── SignalEngine ──────────────────────────────────────────────────────────── +// Bundles all persistent engine state that was previously spread across +// separate globals and local variables. + +// ── State Update Source ───────────────────────────────────────────────────── +// Stores the source text and dependency info for a state update expression +// (needed for recompilation when cells change). + +struct StateUpdateSource { + uint32_t arena_offset = 0; + uint32_t arena_length = 0; + bool has_source = false; + // Named defstate attribution for runtime/reactive diagnostics. Anonymous + // output-owned state leaves this as INVALID_ID (0). + CellIndex state_symbol = 0; + CellIndex dep_cells[MAX_OUTPUT_DEPS] = {}; + uint8_t dep_count = 0; +}; + +// A rejected background recompile is not the result of the current eval's +// output form, so its diagnostic must outlive EvalResult. The indexed owner +// (output slot or named-state slot) plus triggered_by forms the chain of +// blame while the previous compiled consumer remains published. +struct ActiveCompileDiagnostic { + bool active = false; + SymbolID triggered_by = 0; + Diagnostic diagnostic = {}; +}; + +struct SignalEngine { + CellStore cells; + SourceArena arena; + NodePool pool; + EngineState state; + OutputSource output_sources[MAX_OUTPUTS] = {}; + StateUpdateSource state_sources[MAX_STATE_SLOTS] = {}; + ActiveCompileDiagnostic output_compile_diagnostics[MAX_OUTPUTS] = {}; + ActiveCompileDiagnostic state_compile_diagnostics[MAX_STATE_SLOTS] = {}; + + StateResourceRegistry registry; + NodePool scratch_pool; + char eval_text_buf[512] = {}; + +#if USEQ_HAS_SYNTH_ENGINE + // ── Host synth compiler domain (synth-nodes.md) ───────────────────── + // Published synth artefacts: identity-keyed declarations + control + // channel table, sharing one compiler revision. Each top-level synth form + // advances atomically; earlier forms survive a later sibling's failure. + SynthGraph synth_graph; + + // Pending wrapper-injected state identity (state-identity.md §2.2: + // `with-state-id` and `:id` normalise to the same internal identity + // annotation). Set by the cold-path `with-state-id` handler before it + // evaluates its wrapped form, consumed first-synth-wins by do_synth + // when the form carries no explicit :name/:id, and always restored + // when the wrapper handler returns. Never inspected on the hot path. + char pending_state_identity[MAX_SYNTH_IDENTITY] = {}; + bool has_pending_state_identity = false; + + // Ordinal of anonymous synth declarations within the current cold + // eval, reset at eval_cold entry. The anonymous fallback identity is + // "::anon-" so recompiling the same program reuses its + // identity instead of leaking one per eval (state-identity.md §2.5). + uint16_t eval_anon_synth_ordinal = 0; +#endif + + // Increments on each user-visible full-session clear. Wrappers with + // compiler caches use this to discard references outside SignalEngine. + uint32_t session_generation = 0; + + void init_defaults(Sample bpm = 120.0, int beats_per_bar = 4, + int bars_per_phrase = 4, int phrases_per_section = 4); + + // Reset all compiler/runtime storage owned by a livecoding session while + // leaving transport state and any persistent flash image untouched. + void reset_session_storage(Sample bpm = 120.0, int beats_per_bar = 4, + int bars_per_phrase = 4, + int phrases_per_section = 4, + bool publish_session_clear = true); +}; + +// ── Cold-Path Evaluation ──────────────────────────────────────────────────── +// Handles everything that isn't signal sampling: define, defn, set-bpm, etc. + +EvalResult eval_cold(const char* source, uint32_t length, SignalEngine& engine); + +// ── Top-Level Expression Evaluation ──────────────────────────────────────── +// Compile and execute a signal expression in scratch isolation. Used for +// top-level queries (bare symbols, unknown forms, vector eval). Does not +// commit state or mutate live output programs. + +EvalResult eval_expression(const char* source, uint32_t length, + SignalEngine& engine); + +// ── Bulk Recompilation ────────────────────────────────────────────────────── +// Recompile all outputs that have stored source text. Used after flash load +// to rebuild signal graphs from persisted source. +// +// Idempotent: the node pool uses hash-consing (CSE), so recompiling the same +// expression twice yields the same node indices. A gc pass after rebuild +// reclaims any stale nodes left from a previous compilation. + +void recompile_all_outputs(SignalEngine& engine); + +// ── Dependency Tracking ───────────────────────────────────────────────────── +// When a cell changes, recompile outputs that depend on it. + +void on_cell_changed(SymbolID cell_id, SignalEngine& engine); + +#if USEQ_HAS_SYNTH_ENGINE +// ── Host synth GC integration ─────────────────────────────────────────────── +// Register every committed synth control root with the NodePool's external +// roots array so the next GC pass keeps them reachable and remaps their +// indices. Callers that invoke pool.gc_unreachable_nodes() directly must +// call this first. Safe to call repeatedly; clears and re-registers each +// time so stale indices do not linger after a synth-graph edit. + +void register_synth_external_roots(SignalEngine& engine); + +// After a GC pass, copy the remapped external root indices back into the +// synth_graph.controls[] table. Pairs with register_synth_external_roots(). + +void commit_synth_external_roots(SignalEngine& engine); +#endif + +} // namespace sig + +#endif // SIGNAL_ENGINE_COLD_EVAL_H diff --git a/src/signal_engine/compiler_pipeline.cpp b/src/signal_engine/compiler_pipeline.cpp new file mode 100644 index 0000000..e8138c0 --- /dev/null +++ b/src/signal_engine/compiler_pipeline.cpp @@ -0,0 +1,125 @@ +#include "compiler_pipeline.h" + +#include "cold_eval.h" +#include "executor.h" + +#include + +namespace sig { + +bool source_region_can_store(const SourceArena& arena, + uint32_t previous_offset, + uint32_t previous_length, + uint32_t new_length) { + const bool existing_region_fits = + previous_offset <= SOURCE_ARENA_SIZE && + previous_length <= SOURCE_ARENA_SIZE - previous_offset; + if (existing_region_fits && new_length <= previous_length) return true; + return arena.write_head <= SOURCE_ARENA_SIZE && + new_length <= SOURCE_ARENA_SIZE - arena.write_head; +} + +void ParsedProgram::parse(const char* source, uint32_t length) { + stream.count = 0; + stream.pos = 0; + diagnostic_count = 0; + stream.count = TokenStream::tokenize( + source, length, stream.tokens, MAX_TOKENS, + diagnostics, &diagnostic_count); + stream.pos = 0; +} + +SourceMutationPlan SourceMutationPlan::prepare( + const SourceArena& arena, + const char* source, + uint32_t source_length, + uint32_t span_start, + uint32_t span_end, + uint32_t previous_offset, + uint32_t previous_length) { + SourceMutationPlan plan; + plan.previous_offset = previous_offset; + plan.previous_length = previous_length; + + if (!source) return plan; + if (span_end <= span_start || span_start > source_length || + span_end > source_length) { + plan.status = SourcePlanStatus::InvalidSpan; + return plan; + } + + plan.bytes = source + span_start; + plan.length = span_end - span_start; + + plan.status = source_region_can_store( + arena, previous_offset, previous_length, plan.length) + ? SourcePlanStatus::Ready + : SourcePlanStatus::CapacityExceeded; + return plan; +} + +uint32_t SourceMutationPlan::publish(SourceArena& arena) const { + if (status != SourcePlanStatus::Ready) return UINT32_MAX; + return arena.store_reuse( + previous_offset, previous_length, bytes, length); +} + +GraphMutationTransaction::~GraphMutationTransaction() { + rollback(); +} + +void GraphMutationTransaction::begin(SignalEngine& engine) { + if (active_) rollback(); + engine_ = &engine; + registry_ = engine.registry; + data_table_count_ = engine.cells.data_table_count; + state_slot_count_ = engine.pool.state_slot_count; + live_slot_count_ = engine.pool.live_slot_count; + arena_write_head_ = engine.arena.write_head; + + std::memcpy(engine.scratch_pool.state_values, engine.pool.state_values, + sizeof(engine.pool.state_values)); + std::memcpy(engine.scratch_pool.state_update_roots, + engine.pool.state_update_roots, + sizeof(engine.pool.state_update_roots)); + std::memcpy(engine.scratch_pool.state_owner_context, + engine.pool.state_owner_context, + sizeof(engine.pool.state_owner_context)); + std::memcpy(engine.scratch_pool.live_slots, engine.pool.live_slots, + sizeof(engine.pool.live_slots)); + active_ = true; +} + +void GraphMutationTransaction::rollback() { + if (!active_ || !engine_) return; + SignalEngine& engine = *engine_; + engine.registry = registry_; + engine.cells.data_table_count = data_table_count_; + engine.pool.state_slot_count = state_slot_count_; + engine.pool.live_slot_count = live_slot_count_; + engine.arena.write_head = arena_write_head_; + std::memcpy(engine.pool.state_values, engine.scratch_pool.state_values, + sizeof(engine.pool.state_values)); + std::memcpy(engine.pool.state_update_roots, + engine.scratch_pool.state_update_roots, + sizeof(engine.pool.state_update_roots)); + std::memcpy(engine.pool.state_owner_context, + engine.scratch_pool.state_owner_context, + sizeof(engine.pool.state_owner_context)); + std::memcpy(engine.pool.live_slots, engine.scratch_pool.live_slots, + sizeof(engine.pool.live_slots)); + +#if USEQ_HAS_SYNTH_ENGINE + register_synth_external_roots(engine); +#endif + engine.pool.gc_unreachable_nodes(); +#if USEQ_HAS_SYNTH_ENGINE + commit_synth_external_roots(engine); +#endif + engine.pool.rebuild_execution_order(); + classify_outputs(engine.pool); + + active_ = false; +} + +} // namespace sig diff --git a/src/signal_engine/compiler_pipeline.h b/src/signal_engine/compiler_pipeline.h new file mode 100644 index 0000000..80477da --- /dev/null +++ b/src/signal_engine/compiler_pipeline.h @@ -0,0 +1,95 @@ +#ifndef SIGNAL_ENGINE_COMPILER_PIPELINE_H +#define SIGNAL_ENGINE_COMPILER_PIPELINE_H + +#include "cell_store.h" +#include "diagnostics.h" +#include "state_registry.h" +#include "token.h" + +namespace sig { + +struct SignalEngine; + +// Fixed-capacity parse IR. Tokenization writes directly into the retained +// stream, avoiding the old MAX_TOKENS temporary followed by a second copy. +// Diagnostics and token storage are bounded identically on every target. +struct ParsedProgram { + static constexpr uint8_t MAX_PARSE_DIAGNOSTICS = 8; + + TokenStream stream = {}; + Diagnostic diagnostics[MAX_PARSE_DIAGNOSTICS] = {}; + uint8_t diagnostic_count = 0; + + void parse(const char* source, uint32_t length); + bool ok() const { return diagnostic_count == 0; } + bool empty() const { return stream.count == 0 || stream.at_end(); } +}; + +enum class SourcePlanStatus : uint8_t { + NoSource, + Ready, + InvalidSpan, + CapacityExceeded, +}; + +bool source_region_can_store(const SourceArena& arena, + uint32_t previous_offset, + uint32_t previous_length, + uint32_t new_length); + +// Immutable plan for the source-arena part of a compiler publication. Span +// resolution and capacity validation happen before graph mutation; publish() +// is therefore the only write and has no policy decisions left to make. +struct SourceMutationPlan { + SourcePlanStatus status = SourcePlanStatus::NoSource; + const char* bytes = nullptr; + uint32_t length = 0; + uint32_t previous_offset = UINT32_MAX; + uint32_t previous_length = 0; + + static SourceMutationPlan prepare( + const SourceArena& arena, + const char* source, + uint32_t source_length, + uint32_t span_start, + uint32_t span_end, + uint32_t previous_offset = UINT32_MAX, + uint32_t previous_length = 0); + + bool has_source() const { return status == SourcePlanStatus::Ready; } + uint32_t publish(SourceArena& arena) const; +}; + +// Bounded checkpoint for graph-building side effects. GraphBuilder interns +// directly into the live fixed-capacity pool, so candidate construction is a +// mutation stage, not a pure tree allocation. This transaction snapshots the +// mutable metadata and uses SignalEngine::scratch_pool for the fixed arrays. +// Unless accept() is called after publication, destruction restores the last +// published graph and capacity. +class GraphMutationTransaction { +public: + GraphMutationTransaction() = default; + explicit GraphMutationTransaction(SignalEngine& engine) { begin(engine); } + ~GraphMutationTransaction(); + + GraphMutationTransaction(const GraphMutationTransaction&) = delete; + GraphMutationTransaction& operator=(const GraphMutationTransaction&) = delete; + + void begin(SignalEngine& engine); + void accept() { active_ = false; } + void rollback(); + bool active() const { return active_; } + +private: + SignalEngine* engine_ = nullptr; + StateResourceRegistry registry_ = {}; + uint8_t data_table_count_ = 0; + uint16_t state_slot_count_ = 0; + uint16_t live_slot_count_ = 0; + uint32_t arena_write_head_ = 0; + bool active_ = false; +}; + +} // namespace sig + +#endif // SIGNAL_ENGINE_COMPILER_PIPELINE_H diff --git a/src/signal_engine/diagnostics.cpp b/src/signal_engine/diagnostics.cpp new file mode 100644 index 0000000..0f72354 --- /dev/null +++ b/src/signal_engine/diagnostics.cpp @@ -0,0 +1,111 @@ +#include "diagnostics.h" +#include "cell_store.h" +#include "../modulisp/lisp/symbol_intern.h" +#include +#include + +namespace sig { + +const char* severity_to_cstr(DiagnosticSeverity s) { + switch (s) { + case DiagnosticSeverity::Warning: return "warning"; + case DiagnosticSeverity::Error: return "error"; + } + return "error"; +} + +const char* category_to_cstr(DiagnosticCategory c) { + switch (c) { + case DiagnosticCategory::Syntax: return "syntax"; + case DiagnosticCategory::UndefinedName: return "undefinedName"; + case DiagnosticCategory::Arity: return "arity"; + case DiagnosticCategory::Type: return "type"; + case DiagnosticCategory::Boundary: return "boundary"; + case DiagnosticCategory::Arithmetic: return "arithmetic"; + case DiagnosticCategory::Runtime: return "runtime"; + case DiagnosticCategory::Overflow: return "overflow"; + } + return "runtime"; +} + +// ── Levenshtein distance (single-row DP, max symbol length 63) ────────────── + +static int levenshtein(const char* s1, int len1, const char* s2, int len2) { + if (len1 == 0) return len2; + if (len2 == 0) return len1; + constexpr int MAX_LEN = 64; + if (len2 >= MAX_LEN) len2 = MAX_LEN - 1; + int row[MAX_LEN]; + for (int i = 0; i <= len2; i++) row[i] = i; + for (int i = 1; i <= len1; i++) { + int prev = i - 1; + row[0] = i; + for (int j = 1; j <= len2; j++) { + int temp = row[j]; + if (s1[i - 1] == s2[j - 1]) + row[j] = prev; + else + row[j] = 1 + std::min({prev, row[j], row[j - 1]}); + prev = temp; + } + } + return row[len2]; +} + +// Well-known built-in symbol names that should be suggested even when +// they are not in the cell table (temporal phasors, operators, etc.) +static const char* const builtin_names[] = { + "beat", "bar", "phrase", "section", "beat-num", "bar-num", + "bpm", "beats-per-bar", "bars-per-phrase", "phrases-per-section", + "sin", "cos", "tan", "abs", "floor", "ceil", "sqrt", "neg", "frac", + "usin", "ucos", "bi-to-uni", "uni-to-bi", + "min", "max", "pow", "expt", "mod", "pulse", "clamp", "lerp", "scale", + "tri", "sqr", "step", "gates", "trigs", "euclid", "eu", + "seq", "from-list", "interp", "flatseq", "dm", "range", "gatesw", + "random", "index-rand", "loop-at", "rpulse", "rstep", "ridx", "rwarp", + "time-as", "fast", "slow", "offset", "shift", + "if", "let", "do", "for", "while", "fn", "lambda", + "not", "and", "or", + "define", "def", "defn", "set", "input", "t", + nullptr +}; + +SymbolID find_fuzzy_match(SymbolID unknown_sym, const CellStore& cells) { + auto& si = SymbolIntern::getInstance(); + const String& name = si.getString(unknown_sym); + if (name.length() == 0) return SymbolIntern::INVALID_ID; + + const char* name_cstr = name.c_str(); + int name_len = (int)name.length(); + + SymbolID best = SymbolIntern::INVALID_ID; + int best_dist = 3; // threshold: only suggest if distance < 3 + + // Search defined cells + for (uint16_t i = 1; i < MAX_CELLS; i++) { + if (cells.cells[i].kind == CellKind::Empty) continue; + const String& candidate = si.getString((SymbolID)i); + if (candidate.length() == 0) continue; + int d = levenshtein(name_cstr, name_len, + candidate.c_str(), (int)candidate.length()); + if (d > 0 && d < best_dist) { + best_dist = d; + best = (SymbolID)i; + } + } + + // Search well-known built-in names + for (int k = 0; builtin_names[k] != nullptr; k++) { + const char* cand = builtin_names[k]; + int cand_len = (int)strlen(cand); + int d = levenshtein(name_cstr, name_len, cand, cand_len); + if (d > 0 && d < best_dist) { + best_dist = d; + best = si.intern(cand); + } + } + + return best; +} + +} // namespace sig diff --git a/src/signal_engine/diagnostics.h b/src/signal_engine/diagnostics.h new file mode 100644 index 0000000..ced42ef --- /dev/null +++ b/src/signal_engine/diagnostics.h @@ -0,0 +1,49 @@ +#ifndef SIGNAL_ENGINE_DIAGNOSTICS_H +#define SIGNAL_ENGINE_DIAGNOSTICS_H + +#include "types.h" + +namespace sig { + +// Forward declaration +struct CellStore; + +// ── Diagnostic Types ──────────────────────────────────────────────────────── +// Compatible with the existing diagnostic.h types but using borrowed, +// static-lifetime strings (no heap allocation for messages). Producers MUST +// NOT pass stack buffers or temporary String storage: Diagnostic is copied by +// value across builder/eval/active-health boundaries without copying text. + +enum class DiagnosticSeverity : uint8_t { Warning, Error }; + +enum class DiagnosticCategory : uint8_t { + Syntax, // parse errors + UndefinedName, // unknown symbol + Arity, // wrong argument count + Type, // wrong argument type + Boundary, // side effect in signal context + Arithmetic, // division by zero, NaN + Runtime, // loop budget, recursion depth + Overflow // value out of range +}; + +struct Diagnostic { + DiagnosticSeverity severity = DiagnosticSeverity::Error; + DiagnosticCategory category = DiagnosticCategory::Runtime; + uint16_t span_start = 0; + uint16_t span_len = 0; + const char* message = nullptr; // borrowed static-lifetime text + const char* suggestion = nullptr; // borrowed static-lifetime text +}; + +const char* severity_to_cstr(DiagnosticSeverity s); +const char* category_to_cstr(DiagnosticCategory c); + +// ── Fuzzy matching ────────────────────────────────────────────────────────── +// Returns the SymbolID of the closest match, or INVALID_ID if none is close enough. +// Searches defined cells and well-known built-in symbols. +SymbolID find_fuzzy_match(SymbolID unknown_sym, const CellStore& cells); + +} // namespace sig + +#endif // SIGNAL_ENGINE_DIAGNOSTICS_H diff --git a/src/signal_engine/eval_ops.h b/src/signal_engine/eval_ops.h new file mode 100644 index 0000000..d4a340c --- /dev/null +++ b/src/signal_engine/eval_ops.h @@ -0,0 +1,91 @@ +#ifndef SIGNAL_ENGINE_EVAL_OPS_H +#define SIGNAL_ENGINE_EVAL_OPS_H + +// Pure-math NodeOp evaluation — shared by executor (hot path) and constant +// folding (node_pool). Load ops (Const, CellLoad, InputLoad, etc.) and +// data ops (VecIndex, VecLerp) are NOT included here; they require runtime +// context and stay in executor.cpp. + +#include "node_pool.h" +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace sig { + +static inline Sample eval_unary_op(NodeOp op, Sample a) { + switch (op) { + case NodeOp::Neg: return -a; + case NodeOp::Abs: return fabs(a); + case NodeOp::Floor: return floor(a); + case NodeOp::Ceil: return ceil(a); + case NodeOp::Frac: return a - floor(a); + case NodeOp::Sqrt: return sqrt(fabs(a)); + case NodeOp::Sin: return sin(a); + case NodeOp::Cos: return cos(a); + case NodeOp::Tan: return tan(a); + case NodeOp::USin: return (sin(a * 2.0 * M_PI) + 1.0) * 0.5; + case NodeOp::UCos: return (cos(a * 2.0 * M_PI) + 1.0) * 0.5; + case NodeOp::Tri: return 1.0 - fabs(2.0 * (a - floor(a)) - 1.0); + case NodeOp::Sqr: return ((a - floor(a)) < 0.5) ? 1.0 : 0.0; + case NodeOp::Not: return (a == 0.0) ? 1.0 : 0.0; + case NodeOp::BiToUni: return (a + 1.0) * 0.5; + case NodeOp::UniToBi: return a * 2.0 - 1.0; + case NodeOp::HashIndex: { + uint32_t v = (uint32_t)(int32_t)a; + v = ((v >> 16) ^ v) * 0x45d9f3bu; + v = ((v >> 16) ^ v) * 0x45d9f3bu; + v = (v >> 16) ^ v; + return (Sample)(v & 0x7fffffffu) / (Sample)0x7fffffffu; + } + default: return 0.0; + } +} + +static inline Sample eval_binary_op(NodeOp op, Sample a, Sample b) { + switch (op) { + case NodeOp::Add: return a + b; + case NodeOp::Sub: return a - b; + case NodeOp::Mul: return a * b; + // Preserve IEEE non-finite results. The output boundary owns recovery: + // default mode substitutes LKG and records unhealthy state; legacy + // ZeroSquash mode clamps the same result per node. Returning zero here + // would erase the runtime error before either policy can observe it. + case NodeOp::Div: return a / b; + case NodeOp::Mod: return fmod(a, b); + case NodeOp::Expt: return pow(a, b); + case NodeOp::Min: return (a < b) ? a : b; + case NodeOp::Max: return (a > b) ? a : b; + case NodeOp::Pulse: return ((a - floor(a)) < b) ? 1.0 : 0.0; + case NodeOp::CmpGt: return (a > b) ? 1.0 : 0.0; + case NodeOp::CmpLt: return (a < b) ? 1.0 : 0.0; + case NodeOp::CmpGe: return (a >= b) ? 1.0 : 0.0; + case NodeOp::CmpLe: return (a <= b) ? 1.0 : 0.0; + case NodeOp::CmpEq: return (a == b) ? 1.0 : 0.0; + case NodeOp::And: return (a != 0.0 && b != 0.0) ? 1.0 : 0.0; + case NodeOp::Or: return (a != 0.0 || b != 0.0) ? 1.0 : 0.0; + default: return 0.0; + } +} + +// Convention: ternary ops take their "main" argument LAST so that the +// thing-being-affected can be a nested expression at the end of a lisp form. +// (clamp lo hi value) — value is last +// (scale min max value) — value is last +// (lerp a b t) — interpolation parameter t is last +// (if cond then else) — condition is first (Select) +static inline Sample eval_ternary_op(NodeOp op, Sample a, Sample b, Sample c) { + switch (op) { + case NodeOp::Clamp: return (c < a) ? a : (c > b) ? b : c; // (clamp lo hi value) + case NodeOp::Lerp: return a + (b - a) * c; + case NodeOp::Scale: return c * (b - a) + a; + case NodeOp::Select: return (a != 0.0) ? b : c; + default: return 0.0; + } +} + +} // namespace sig + +#endif // SIGNAL_ENGINE_EVAL_OPS_H diff --git a/src/signal_engine/executor.cpp b/src/signal_engine/executor.cpp new file mode 100644 index 0000000..f1c3ec4 --- /dev/null +++ b/src/signal_engine/executor.cpp @@ -0,0 +1,447 @@ +#include "executor.h" +#include "eval_ops.h" +#include +#include +#include + +namespace sig { + +// ── Failure Mode ──────────────────────────────────────────────────────────── + +static FailureMode g_failure_mode = FailureMode::LkgFallback; + +void set_failure_mode(FailureMode mode) { g_failure_mode = mode; } +FailureMode get_failure_mode() { return g_failure_mode; } + +OutputHealth output_health(const NodePool& pool, uint16_t output_index) { + if (output_index >= MAX_OUTPUTS) return OutputHealth::Idle; + const OutputSlot& slot = pool.outputs[output_index]; + if (!slot.valid || slot.root_node == NODE_NONE) return OutputHealth::Idle; + if (((pool.runtime_fallback_mask >> output_index) & 1u) == 0) + return OutputHealth::Running; + return slot.has_lkg ? OutputHealth::Fallback : OutputHealth::Error; +} + +const char* output_health_to_cstr(OutputHealth health) { + switch (health) { + case OutputHealth::Idle: return "idle"; + case OutputHealth::Running: return "running"; + case OutputHealth::Fallback: return "fallback"; + case OutputHealth::Error: return "error"; + } + return "idle"; +} + +// ── Single-node evaluation ────────────────────────────────────────────────── +// Load ops and data ops need runtime context and are handled here directly. +// Pure-math ops delegate to the shared eval_ops.h functions. + +static inline Sample eval_node( + const Node& n, + Sample a, Sample b, Sample c, + Sample t, + Sample dt, + const Sample* cell_values, + const Sample* hw_inputs, + const Sample* data_pool, + const uint16_t* data_offsets, + const uint16_t* data_lengths, + const Sample* prev_output_values, + const Sample* state_values +) { + switch (n.op) { + // ── Load ops (runtime context required) ───────────────────────── + case NodeOp::Const: return n.imm; + case NodeOp::RawTimeLoad: return t; + case NodeOp::LoadState: return state_values[(uint16_t)n.imm]; + case NodeOp::LoadDt: return dt; + case NodeOp::CellLoad: return cell_values[(uint16_t)n.imm]; + case NodeOp::InputLoad: return hw_inputs[(uint16_t)n.imm]; + case NodeOp::PrevOutputLoad: + return prev_output_values[(uint16_t)n.imm]; + case NodeOp::SlotLoad: return 0.0; // handled in execution loop + + // ── Data ops (need data_pool arrays) ──────────────────────────── + case NodeOp::VecIndex: { + uint16_t tid = (uint16_t)n.imm; + uint16_t off = data_offsets[tid]; + uint16_t len = data_lengths[tid]; + if (len == 0) return 0.0; + int index = ((int)floor(a) % len + len) % len; + return data_pool[off + index]; + } + case NodeOp::VecLerp: { + uint16_t tid = (uint16_t)n.imm; + uint16_t off = data_offsets[tid]; + uint16_t len = data_lengths[tid]; + if (len <= 1) return (len == 1) ? data_pool[off] : 0.0; + Sample scaled = a * (len - 1); + int i0 = (int)floor(scaled); + if (i0 < 0) i0 = 0; + if (i0 >= len - 1) i0 = len - 2; + int i1 = i0 + 1; + // frac relative to the (possibly clamped) i0, not floor(scaled). + // At integral phase == 1.0, scaled == len-1 and i0 is clamped to + // len-2, so frac == 1.0 and we correctly return the LAST element + // (data[len-1]); using floor(scaled) here yielded frac == 0 and the + // wrong element (data[len-2]). Also gracefully clamps phase > 1. + Sample frac = scaled - i0; + if (frac < 0.0) frac = 0.0; + if (frac > 1.0) frac = 1.0; + return data_pool[off + i0] + (data_pool[off + i1] - data_pool[off + i0]) * frac; + } + + // ── Pure-math ops (shared with constant folding) ──────────────── + // Unary + case NodeOp::Neg: + case NodeOp::Abs: + case NodeOp::Floor: + case NodeOp::Ceil: + case NodeOp::Frac: + case NodeOp::Sqrt: + case NodeOp::Sin: + case NodeOp::Cos: + case NodeOp::Tan: + case NodeOp::USin: + case NodeOp::UCos: + case NodeOp::Tri: + case NodeOp::Sqr: + case NodeOp::Not: + case NodeOp::BiToUni: + case NodeOp::UniToBi: + case NodeOp::HashIndex: + return eval_unary_op(n.op, a); + + // Binary + case NodeOp::Add: + case NodeOp::Sub: + case NodeOp::Mul: + case NodeOp::Div: + case NodeOp::Mod: + case NodeOp::Expt: + case NodeOp::Min: + case NodeOp::Max: + case NodeOp::Pulse: + case NodeOp::CmpGt: + case NodeOp::CmpLt: + case NodeOp::CmpGe: + case NodeOp::CmpLe: + case NodeOp::CmpEq: + case NodeOp::And: + case NodeOp::Or: + return eval_binary_op(n.op, a, b); + + // Ternary + case NodeOp::Clamp: + case NodeOp::Lerp: + case NodeOp::Scale: + case NodeOp::Select: + return eval_ternary_op(n.op, a, b, c); + + default: return 0.0; + } +} + +// ── Single-Sample Execution ───────────────────────────────────────────────── + +void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx) { + for (uint16_t i = 0; i < pool.exec_count; i++) { + uint16_t idx = pool.exec_order[i]; + const Node& n = pool.nodes[idx]; + + Sample result; + if (n.op == NodeOp::SlotLoad) { + uint16_t slot_idx = (uint16_t)n.imm; + result = (slot_idx < pool.live_slot_count) + ? pool.live_slots[slot_idx].value : 0.0; + } else { + Sample a = (n.input_a != NODE_NONE) ? ctx.workspace[n.input_a] : 0.0; + Sample b = (n.input_b != NODE_NONE) ? ctx.workspace[n.input_b] : 0.0; + Sample c = (n.input_c != NODE_NONE) ? ctx.workspace[n.input_c] : 0.0; + + result = eval_node(n, a, b, c, ctx.t, ctx.dt, + ctx.cell_values, ctx.hw_inputs, + ctx.data_pool, ctx.data_offsets, ctx.data_lengths, + ctx.prev_outputs, pool.state_values); + } + + // Per-node NaN/Inf guard — legacy ZeroSquash mode only. In + // LkgFallback mode non-finite values propagate to the output root, + // where the LKG substitution below declares the failure + // (failure-model.md §3.1). + if (g_failure_mode == FailureMode::ZeroSquash && !std::isfinite(result)) + result = 0.0; + + ctx.workspace[idx] = result; + } + + // Read output values with LKG fallback + uint64_t fallback_mask = 0; + for (uint16_t i = 0; i < MAX_OUTPUTS; i++) { + if (pool.outputs[i].root_node != NODE_NONE) { + Sample v = ctx.workspace[pool.outputs[i].root_node]; + if (g_failure_mode == FailureMode::LkgFallback && + !std::isfinite(v)) { + // Non-finite at the root: substitute the last-known-good + // value (or the neutral default when no LKG exists — + // failure-model.md §2.4) and record the fallback. + v = pool.outputs[i].has_lkg ? pool.outputs[i].lkg_value : 0.0; + fallback_mask |= (uint64_t)1 << i; + } + ctx.output_values[i] = v; + } else if (pool.outputs[i].valid) { + // No graph assigned but we have a last-known-good value — use it + ctx.output_values[i] = pool.outputs[i].lkg_value; + } else { + // Inactive outputs are always the compiler/runtime neutral value, + // even when a caller reuses a dirty output buffer. + ctx.output_values[i] = 0.0; + } + } + pool.runtime_fallback_mask = fallback_mask; +} + +// ── Post-Tick Commit ─────────────────────────────────────────────────────── + +void commit_outputs(NodePool& pool, const Sample* output_values) { + for (uint16_t i = 0; i < MAX_OUTPUTS; i++) { + pool.prev_output_values[i] = output_values[i]; + bool substituted = + ((pool.runtime_fallback_mask >> i) & 1u) != 0; + if (pool.outputs[i].root_node != NODE_NONE && !substituted && + std::isfinite(output_values[i])) { + pool.outputs[i].lkg_value = output_values[i]; + pool.outputs[i].has_lkg = true; + } + } +} + +// ── Post-Tick State Commit ──────────────────────────────────────────────── + +void commit_state(NodePool& pool, const Sample* workspace, + const uint16_t* failed_owner_contexts, + uint16_t failed_owner_count) { + for (uint16_t s = 0; s < pool.state_slot_count; ++s) { + uint64_t state_bit = (uint64_t)1 << s; + if (pool.state_update_roots[s] != NODE_NONE) { + uint16_t owner = pool.state_owner_context[s]; + if (owner < MAX_OUTPUTS && + ((pool.runtime_fallback_mask >> owner) & 1u) != 0) { + // Stateful nodes owned by an output advance only when that + // output publishes a healthy sample. Otherwise an oscillator + // or integrator could run invisibly behind scalar fallback, + // making recovery jump to state the listener never heard. + continue; + } + bool owner_failed = false; + for (uint16_t i = 0; i < failed_owner_count; i++) { + if (failed_owner_contexts[i] == owner) { + owner_failed = true; + break; + } + } + if (owner_failed) continue; + Sample v = workspace[pool.state_update_roots[s]]; + // Never commit non-finite state: in LkgFallback mode NaN/Inf can + // flow through the workspace, and a poisoned state slot would + // never recover. Keep the previous (finite) value instead. + if (std::isfinite(v)) { + pool.state_values[s] = v; + pool.state_update_failure_mask &= ~state_bit; + } else { + pool.state_update_failure_mask |= state_bit; + } + } else { + pool.state_update_failure_mask &= ~state_bit; + } + } + if (pool.state_slot_count < 64) { + uint64_t live_bits = pool.state_slot_count == 0 + ? 0 : (((uint64_t)1 << pool.state_slot_count) - 1); + pool.state_update_failure_mask &= live_bits; + } +} + +// ── Batched Execution ─────────────────────────────────────────────────────── + +void execute_batch( + const NodePool& pool, + const Sample* t_array, + size_t sample_count, + const Sample* cell_values, + const Sample* hw_inputs, + const Sample* data_pool, + const uint16_t* data_offsets, + const uint16_t* data_lengths, + Sample* output_buffer, + uint16_t num_outputs +) { + if (!pool.batch_workspace) return; + + const size_t CHUNK = pool.batch_chunk_size; + Sample* regs = pool.batch_workspace.get(); + uint64_t fallback_mask = 0; + + for (size_t chunk_start = 0; chunk_start < sample_count; chunk_start += CHUNK) { + size_t chunk_size = std::min(CHUNK, sample_count - chunk_start); + + for (uint16_t ni = 0; ni < pool.exec_count; ni++) { + uint16_t idx = pool.exec_order[ni]; + const Node& n = pool.nodes[idx]; + Sample* reg_out = regs + (size_t)idx * CHUNK; + + if (n.op == NodeOp::SlotLoad) { + uint16_t slot_idx = (uint16_t)n.imm; + Sample val = (slot_idx < pool.live_slot_count) + ? pool.live_slots[slot_idx].value : 0.0; + for (size_t s = 0; s < chunk_size; s++) reg_out[s] = val; + } else if (n.flags & FLAG_TIME_INVARIANT) { + // Compute once, broadcast. + // Read input values from registers — time-invariant inputs + // were already computed and are the same for every sample, + // so reading at index 0 is sufficient. + Sample a = (n.input_a != NODE_NONE) ? regs[(size_t)n.input_a * CHUNK] : 0.0; + Sample b = (n.input_b != NODE_NONE) ? regs[(size_t)n.input_b * CHUNK] : 0.0; + Sample c = (n.input_c != NODE_NONE) ? regs[(size_t)n.input_c * CHUNK] : 0.0; + Sample val = eval_node(n, a, b, c, 0.0, 0.0, + cell_values, hw_inputs, + data_pool, data_offsets, data_lengths, + pool.prev_output_values, + pool.state_values); + if (g_failure_mode == FailureMode::ZeroSquash && + !std::isfinite(val)) val = 0.0; + for (size_t s = 0; s < chunk_size; s++) reg_out[s] = val; + } else { + for (size_t s = 0; s < chunk_size; s++) { + Sample t = t_array[chunk_start + s]; + + Sample a = (n.input_a != NODE_NONE) ? regs[(size_t)n.input_a * CHUNK + s] : 0.0; + Sample b = (n.input_b != NODE_NONE) ? regs[(size_t)n.input_b * CHUNK + s] : 0.0; + Sample c = (n.input_c != NODE_NONE) ? regs[(size_t)n.input_c * CHUNK + s] : 0.0; + + Sample result = eval_node(n, a, b, c, t, 0.0, + cell_values, hw_inputs, + data_pool, data_offsets, data_lengths, + pool.prev_output_values, + pool.state_values); + if (g_failure_mode == FailureMode::ZeroSquash && + !std::isfinite(result)) result = 0.0; + reg_out[s] = result; + } + } + } + + // Copy output values for this chunk. + // Row-packing MUST match how callers count active outputs and build + // their index_to_row map. Callers (wasm_wrapper.cpp) and the other + // batch paths (execute_batch_sequential, project_from_fork) all key on + // outputs[o].valid, so we do too. Keying on root_node != NODE_NONE here + // diverged from that during the post-compile-fail window (valid==false + // but root_node preserved), which mislabelled one output's samples as + // another's. An output with valid && root_node==NODE_NONE holds only an + // LKG scalar and has no register row, so fall back to its lkg_value. + uint16_t out_idx = 0; + for (uint16_t o = 0; o < MAX_OUTPUTS && out_idx < num_outputs; o++) { + if (pool.outputs[o].valid) { + Sample* dst = output_buffer + (size_t)out_idx * sample_count + chunk_start; + if (pool.outputs[o].root_node != NODE_NONE) { + Sample* src = regs + (size_t)pool.outputs[o].root_node * CHUNK; + if (g_failure_mode == FailureMode::LkgFallback) { + // Non-finite at the root → substitute LKG per sample + // and record the fallback (failure-model.md §10.2). + Sample lkg = pool.outputs[o].lkg_value; + for (size_t s = 0; s < chunk_size; s++) { + Sample v = src[s]; + if (!std::isfinite(v)) { + v = lkg; + fallback_mask |= (uint64_t)1 << o; + } + dst[s] = v; + } + } else { + memcpy(dst, src, chunk_size * sizeof(Sample)); + } + } else { + Sample lkg = pool.outputs[o].lkg_value; + for (size_t s = 0; s < chunk_size; s++) dst[s] = lkg; + } + out_idx++; + } + } + } + pool.runtime_fallback_mask = fallback_mask; +} + +// ── Output Classification ─────────────────────────────────────────────────── + +struct ClassifyResult { + bool has_state; // LoadState, LoadDt, or PrevOutputLoad + bool has_input; // InputLoad + uint32_t input_mask; // bitmask of hw input channels +}; + +static void classify_node_tree(const NodePool& pool, uint16_t root, ClassifyResult& result) { + if (root == NODE_NONE || root >= pool.node_count) return; + + bool visited[MAX_TOTAL_NODES] = {}; + uint16_t stack[MAX_TOTAL_NODES]; + uint16_t sp = 0; + stack[sp++] = root; + + while (sp > 0) { + uint16_t idx = stack[--sp]; + if (idx == NODE_NONE || idx >= pool.node_count) continue; + if (visited[idx]) continue; + visited[idx] = true; + + const Node& n = pool.nodes[idx]; + switch (n.op) { + case NodeOp::LoadState: + case NodeOp::LoadDt: + case NodeOp::PrevOutputLoad: + result.has_state = true; + break; + case NodeOp::InputLoad: + result.has_input = true; + if ((uint16_t)n.imm < 32) + result.input_mask |= (1u << (uint16_t)n.imm); + break; + default: + break; + } + + // Check visited before pushing to avoid stack overflow in dense DAGs + if (n.input_a != NODE_NONE && !visited[n.input_a] && sp < MAX_TOTAL_NODES) stack[sp++] = n.input_a; + if (n.input_b != NODE_NONE && !visited[n.input_b] && sp < MAX_TOTAL_NODES) stack[sp++] = n.input_b; + if (n.input_c != NODE_NONE && !visited[n.input_c] && sp < MAX_TOTAL_NODES) stack[sp++] = n.input_c; + } +} + +void classify_outputs(NodePool& pool) { + // First pass: classify each output by its own node tree only + for (uint16_t i = 0; i < MAX_OUTPUTS; i++) { + if (!pool.outputs[i].valid || pool.outputs[i].root_node == NODE_NONE) { + pool.output_class[i] = OutputClass::Inactive; + pool.output_input_mask[i] = 0; + continue; + } + + ClassifyResult cr = {}; + classify_node_tree(pool, pool.outputs[i].root_node, cr); + + if (cr.has_state) { + pool.output_class[i] = OutputClass::Stateful; + } else if (cr.has_input) { + pool.output_class[i] = OutputClass::InputDep; + } else { + pool.output_class[i] = OutputClass::Pure; + } + pool.output_input_mask[i] = cr.input_mask; + } + + // If any state slots exist, outputs that reference LoadState/LoadDt are + // already marked Stateful. State update roots are part of the stateful + // outputs' computation — they don't pollute pure outputs. +} + +} // namespace sig diff --git a/src/signal_engine/executor.h b/src/signal_engine/executor.h new file mode 100644 index 0000000..ee51c79 --- /dev/null +++ b/src/signal_engine/executor.h @@ -0,0 +1,112 @@ +#ifndef SIGNAL_ENGINE_EXECUTOR_H +#define SIGNAL_ENGINE_EXECUTOR_H + +#include "node_pool.h" +#include "cell_store.h" + +namespace sig { + +// ── Failure Mode (failure-model.md §2.1/§3.1) ─────────────────────────────── +// Global runtime policy for non-finite values (NaN/Inf) during execution. +// +// LkgFallback (default, spec-mandated): values propagate freely through +// the node graph; a non-finite value reaching an OUTPUT ROOT makes that +// output substitute its last-known-good value (or 0 if none) and marks +// it as being in fallback (see NodePool::runtime_fallback_mask). +// ZeroSquash (legacy): every node's result is clamped to 0.0 when +// non-finite. No fallback, no diagnostic — pre-v1.2 behaviour. +// +// The mode is engine-global (not per-output); configurable over the serial +// wire protocol ("set-failure-mode") and the WASM export +// useq_set_failure_mode(). See docs/specs/failure-model.md §3. + +enum class FailureMode : uint8_t { + LkgFallback = 0, + ZeroSquash = 1, +}; + +void set_failure_mode(FailureMode mode); +FailureMode get_failure_mode(); + +// Output assignment and last-good existence are independent. In +// particular, an active program whose first sample is non-finite is Error, +// while the same failure after a finite commit is Fallback. +enum class OutputHealth : uint8_t { + Idle, + Running, + Fallback, + Error, +}; + +OutputHealth output_health(const NodePool& pool, uint16_t output_index); +const char* output_health_to_cstr(OutputHealth health); + +// ── Execution Context ────────────────────────────────────────────────────── +// Bundles every per-tick datum the executor needs, replacing the previous +// 10-parameter execute_all_outputs signature. + +struct ExecutionContext { + Sample t; + Sample dt; // time delta since last tick + const Sample* cell_values; + const Sample* hw_inputs; + const Sample* data_pool; + const uint16_t* data_offsets; + const uint16_t* data_lengths; + const Sample* prev_outputs; + Sample* output_values; // out [MAX_OUTPUTS] + Sample* workspace; // scratch [MAX_TOTAL_NODES] +}; + +// ── Single-Sample Execution ───────────────────────────────────────────────── +// One forward pass through topologically-sorted nodes. +// Uses LKG fallback for outputs with a current graph and a finite committed +// previous value; a first-ever failure emits neutral zero and remains Error. + +void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx); + +// ── Post-Tick Commit ──────────────────────────────────────────────────────── +// After execute_all_outputs, call this to: +// 1. Copy output_values → pool.prev_output_values (for next tick's PrevOutputLoad) +// 2. Promote only finite, non-substituted roots to lkg_value / has_lkg +// This mutates pool state, so it is NOT used in the batch/visualization path. + +void commit_outputs(NodePool& pool, const Sample* output_values); + +// ── Post-Tick State Commit ───────────────────────────────────────────────── +// After execute_all_outputs, call this to update state slots from their +// update graphs. State update roots must already have been executed as +// part of the node graph (they share the workspace). Non-finite candidates +// retain the previous value and publish state_update_failure_mask until a +// later finite candidate clears it. + +void commit_state(NodePool& pool, const Sample* workspace, + const uint16_t* failed_owner_contexts = nullptr, + uint16_t failed_owner_count = 0); + +// ── Batched Execution (WASM Visualization) ────────────────────────────────── +// SOA execution across a time window for efficient visualization. + +void execute_batch( + const NodePool& pool, + const Sample* t_array, + size_t sample_count, + const Sample* cell_values, + const Sample* hw_inputs, + const Sample* data_pool, + const uint16_t* data_offsets, + const uint16_t* data_lengths, + Sample* output_buffer, // [num_outputs × sample_count], row-major + uint16_t num_outputs +); + +// ── Output Classification ─────────────────────────────────────────────────── +// Walk each output's node graph to determine OutputClass and input dependency +// bitmask. Call after compilation or graph changes. Writes directly into +// pool.output_class[] and pool.output_input_mask[]. + +void classify_outputs(NodePool& pool); + +} // namespace sig + +#endif // SIGNAL_ENGINE_EXECUTOR_H diff --git a/src/signal_engine/graph_builder.cpp b/src/signal_engine/graph_builder.cpp new file mode 100644 index 0000000..162b7bf --- /dev/null +++ b/src/signal_engine/graph_builder.cpp @@ -0,0 +1,4004 @@ +#include "graph_builder.h" +#include "compiler_pipeline.h" +#include "../modulisp/lisp/symbol_intern.h" +#include +#include +#include +#include + +namespace sig { + +// ── Static symbol cache ───────────────────────────────────────────────────── + +GraphBuilder::Symbols GraphBuilder::sym = {}; +bool GraphBuilder::symbols_initialized = false; +OperatorDeclaration GraphBuilder::operator_declarations[MAX_OPERATOR_DECLARATIONS] = {}; +uint16_t GraphBuilder::operator_declaration_count = 0; + +// ── Form dispatch table statics ───────────────────────────────────────────── + +GraphBuilder::FormEntry GraphBuilder::form_table[FORM_TABLE_CAPACITY] = {}; +uint16_t GraphBuilder::form_table_count = 0; +bool GraphBuilder::form_table_sorted = false; + +// Keyword options are single-assignment within one form. Last-value-wins +// would make source meaning depend on parser order and can hide duplicated +// editor payload fields, so every keyword parser uses this common guard. +template +static bool remember_keyword_once(SymbolID keyword, SymbolID (&seen)[N], + uint8_t& seen_count) { + for (uint8_t i = 0; i < seen_count; ++i) { + if (seen[i] == keyword) return false; + } + if (seen_count >= N) return false; + seen[seen_count++] = keyword; + return true; +} + +void GraphBuilder::init_symbols() { + if (symbols_initialized) return; + auto& si = SymbolIntern::getInstance(); + #define SYM(f, s, c) sym.f = si.intern(s); + #include "symbols.def" + #undef SYM + + operator_declaration_count = 0; + #define SYM(f, s, c) \ + operator_declarations[operator_declaration_count++] = { \ + sym.f, s, SymbolCategory::c, OperatorInputDomain::None, \ + OperatorOutputRange::None, OperatorRegime::None, \ + ColdEvaluable::No, BareIdentity::Native \ + }; + #include "symbols.def" + #undef SYM + + #define SYM(f, s, c) + #define OP_META(f, domain, range, regime_value, cold, identity) \ + do { \ + OperatorDeclaration* declaration = const_cast( \ + find_operator_declaration(sym.f)); \ + if (declaration) { \ + declaration->input_domain = OperatorInputDomain::domain; \ + declaration->output_range = OperatorOutputRange::range; \ + declaration->regime = OperatorRegime::regime_value; \ + declaration->cold_evaluable = ColdEvaluable::cold; \ + declaration->bare_identity = BareIdentity::identity; \ + } \ + } while (false); + #include "symbols.def" + #undef OP_META + #undef SYM + + symbols_initialized = true; + init_form_table(); +} + +const OperatorDeclaration* GraphBuilder::find_operator_declaration(SymbolID symbol) { + for (uint16_t i = 0; i < operator_declaration_count; ++i) { + if (operator_declarations[i].symbol == symbol) { + return &operator_declarations[i]; + } + } + return nullptr; +} + +static OperatorNamespace parse_namespace_name(const char* name, size_t length) { + if ((length == 1 && name[0] == 'n') || + (length == 4 && std::strncmp(name, "norm", 4) == 0)) { + return OperatorNamespace::Normalized; + } + if ((length == 1 && name[0] == 'r') || + (length == 3 && std::strncmp(name, "rad", 3) == 0)) { + return OperatorNamespace::Radians; + } + if ((length == 1 && name[0] == 'u') || + (length == 3 && std::strncmp(name, "uni", 3) == 0)) { + return OperatorNamespace::Unipolar; + } + if ((length == 1 && name[0] == 'b') || + (length == 2 && std::strncmp(name, "bi", 2) == 0)) { + return OperatorNamespace::Bipolar; + } + if (length == 3 && std::strncmp(name, "lfo", 3) == 0) { + return OperatorNamespace::Lfo; + } + if (length == 4 && std::strncmp(name, "blfo", 4) == 0) { + return OperatorNamespace::BipolarLfo; + } + if (length == 3 && std::strncmp(name, "osc", 3) == 0) { + return OperatorNamespace::Osc; + } + if ((length == 1 && name[0] == 'k') || + (length == 4 && std::strncmp(name, "once", 4) == 0)) { + return OperatorNamespace::Cold; + } + if (length == 3 && std::strncmp(name, "raw", 3) == 0) { + return OperatorNamespace::Raw; + } + return OperatorNamespace::Unknown; +} + +NamespacedOperator GraphBuilder::resolve_namespaced_operator(SymbolID symbol) { + NamespacedOperator resolved; + const String& spelling = getSymbolString(symbol); + if (spelling == "/") return resolved; + + const char* text = spelling.c_str(); + const size_t length = spelling.length(); + const char* slash = std::strchr(text, '/'); + if (!slash) return resolved; + if (slash == text || slash == text + length - 1 || + std::strchr(slash + 1, '/') != nullptr) { + resolved.name_space = OperatorNamespace::Malformed; + return resolved; + } + + resolved.namespace_span_len = static_cast(slash - text); + resolved.name_space = parse_namespace_name(text, resolved.namespace_span_len); + if (resolved.name_space == OperatorNamespace::Unknown) return resolved; + + auto& interner = SymbolIntern::getInstance(); + resolved.base_symbol = interner.getID(String( + slash + 1, static_cast(length - resolved.namespace_span_len - 1))); + if (resolved.base_symbol != SymbolIntern::INVALID_ID) { + resolved.declaration = find_operator_declaration(resolved.base_symbol); + } + return resolved; +} + +bool GraphBuilder::namespace_applies( + OperatorNamespace name_space, const OperatorDeclaration& declaration) { + switch (name_space) { + case OperatorNamespace::Normalized: + case OperatorNamespace::Radians: + return declaration.input_domain == OperatorInputDomain::Angle || + declaration.input_domain == OperatorInputDomain::Phase; + case OperatorNamespace::Unipolar: + case OperatorNamespace::Bipolar: + return declaration.output_range != OperatorOutputRange::None; + case OperatorNamespace::Lfo: + case OperatorNamespace::BipolarLfo: + return declaration.regime == OperatorRegime::PureShaper; + case OperatorNamespace::Cold: + return declaration.cold_evaluable == ColdEvaluable::Yes; + case OperatorNamespace::Raw: + return declaration.category != SymbolCategory::none; + case OperatorNamespace::Osc: + return false; + case OperatorNamespace::None: + case OperatorNamespace::Unknown: + case OperatorNamespace::Malformed: + return false; + } + return false; +} + +void GraphBuilder::init_form_table() { + if (form_table_sorted) return; + form_table_count = 0; + + auto add = [](SymbolID s, uint16_t (GraphBuilder::*h)(TokenStream&, Scope&, TimeContext&)) { + if (form_table_count < FORM_TABLE_CAPACITY) { + form_table[form_table_count++] = {s, h}; + } + }; + + // Time transforms + add(sym.time_as, &GraphBuilder::compile_eval_at_time); + add(sym.fast, &GraphBuilder::compile_fast); + add(sym.slow, &GraphBuilder::compile_slow); + add(sym.offset, &GraphBuilder::compile_offset); + add(sym.shift, &GraphBuilder::compile_offset); // alias + add(sym.loop_at, &GraphBuilder::compile_loop_at); + add(sym.eval_at_time, &GraphBuilder::compile_eval_at_time); + + // Control flow + add(sym.if_, &GraphBuilder::compile_if); + add(sym.let_, &GraphBuilder::compile_let); + add(sym.do_, &GraphBuilder::compile_do); + add(sym.scope, &GraphBuilder::compile_do); // alias + add(sym.for_, &GraphBuilder::compile_for); + add(sym.while_, &GraphBuilder::compile_while_gate); + + // Domain signal functions + add(sym.step, &GraphBuilder::compile_step); + add(sym.seq, &GraphBuilder::compile_seq); + add(sym.from_list, &GraphBuilder::compile_seq); // alias + add(sym.euclid, &GraphBuilder::compile_euclid); + add(sym.eu, &GraphBuilder::compile_euclid); // alias + add(sym.interp, &GraphBuilder::compile_interp); + add(sym.flatseq, &GraphBuilder::compile_interp); // alias + add(sym.dm, &GraphBuilder::compile_dm); + add(sym.range, &GraphBuilder::compile_range); + add(sym.gatesw, &GraphBuilder::compile_gatesw); + add(sym.random_, &GraphBuilder::compile_random); + add(sym.index_rand, &GraphBuilder::compile_index_rand); + + // Ratio-rhythm functions + add(sym.rpulse, &GraphBuilder::compile_rpulse); + add(sym.rstep, &GraphBuilder::compile_rstep); + add(sym.ridx, &GraphBuilder::compile_ridx); + add(sym.rwarp, &GraphBuilder::compile_rwarp); + + // State + add(sym.integrate, &GraphBuilder::compile_integrate); + + // UGens — primary names + add(sym.phasor_, &GraphBuilder::compile_phasor); + add(sym.lfo, &GraphBuilder::compile_lfo); + add(sym.blfo, &GraphBuilder::compile_blfo); + add(sym.slew, &GraphBuilder::compile_slew); + add(sym.one_pole, &GraphBuilder::compile_one_pole); + add(sym.env_follow, &GraphBuilder::compile_env_follow); + add(sym.sah, &GraphBuilder::compile_sah); + add(sym.noise, &GraphBuilder::compile_noise); + add(sym.toggle, &GraphBuilder::compile_toggle); + add(sym.count, &GraphBuilder::compile_count); + + // Compatibility aliases unrelated to the namespace cut. + add(sym.envelope_follower, &GraphBuilder::compile_env_follow); + add(sym.latch, &GraphBuilder::compile_sah); + + // Live-edit + add(sym.live_edit, &GraphBuilder::compile_live_edit); + + // Output feedback + add(sym.prev, &GraphBuilder::compile_prev); + + // Sort by SymbolID for binary search + std::sort(form_table, form_table + form_table_count, + [](const FormEntry& a, const FormEntry& b) { return a.sym < b.sym; }); + form_table_sorted = true; +} + +// ── Scope ─────────────────────────────────────────────────────────────────── + +bool Scope::bind(SymbolID name, uint16_t node_index) { + if (local_count < MAX_LOCAL_BINDINGS) { + locals[local_count++] = { name, node_index }; + return true; + } + return false; +} + +const Scope::Binding* Scope::find(SymbolID name) const { + // Search local scope first, then parent + for (int i = (int)local_count - 1; i >= 0; i--) { + if (locals[i].name == name) return &locals[i]; + } + if (parent) return parent->find(name); + return nullptr; +} + +// ── GraphBuilder Construction ─────────────────────────────────────────────── + +GraphBuilder::GraphBuilder(NodePool& p, CellStore& c, const SourceArena& s) + : pool(p), cells(c), source(s) +{ + init_symbols(); + live_slot_count_at_start = pool.live_slot_count; +} + +void GraphBuilder::remember_live_slot(uint16_t slot_index) { + if (slot_index >= live_slot_count_at_start) return; + for (uint16_t i = 0; i < live_slot_undo_count; i++) { + if (live_slot_undo[i].slot_index == slot_index) return; + } + if (live_slot_undo_count >= MAX_LIVE_SLOT_UNDO) return; + const NodePool::LiveSlot& slot = pool.live_slots[slot_index]; + LiveSlotUndo& undo = live_slot_undo[live_slot_undo_count++]; + undo.slot_index = slot_index; + undo.value = slot.value; + undo.min_val = slot.min_val; + undo.max_val = slot.max_val; + undo.seed = slot.seed; +} + +void GraphBuilder::rollback_live_slots() { + for (uint16_t i = 0; i < live_slot_undo_count; i++) { + const LiveSlotUndo& undo = live_slot_undo[i]; + NodePool::LiveSlot& slot = pool.live_slots[undo.slot_index]; + slot.value = undo.value; + slot.min_val = undo.min_val; + slot.max_val = undo.max_val; + slot.seed = undo.seed; + } + for (uint16_t i = live_slot_count_at_start; i < pool.live_slot_count; i++) + pool.live_slots[i] = NodePool::LiveSlot{}; + pool.live_slot_count = live_slot_count_at_start; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +bool GraphBuilder::is_const(uint16_t node_idx) const { + return node_idx != NODE_NONE && pool.nodes[node_idx].op == NodeOp::Const; +} + +Sample GraphBuilder::const_value(uint16_t node_idx) const { + return pool.nodes[node_idx].imm; +} + +void GraphBuilder::add_dependency(SymbolID s) { + if (s >= MAX_CELLS) { + report_error_at_cat( + DiagnosticCategory::Overflow, 0, 0, + "Cell dependency exceeds the retained definition range", + "Remove unused definitions or reuse an existing name"); + return; + } + const CellIndex cell_index = static_cast(s); + for (uint8_t i = 0; i < dep_count; i++) { + if (dep_cells[i] == cell_index) return; + } + if (dep_count < MAX_OUTPUT_DEPS) { + dep_cells[dep_count++] = cell_index; + return; + } + report_error_at_cat( + DiagnosticCategory::Overflow, 0, 0, + "Too many distinct cell dependencies in one program", + "Split the program into smaller outputs or reduce referenced definitions"); +} + +bool GraphBuilder::is_in_inline_stack(SymbolID s) const { + for (uint8_t i = 0; i < inline_depth; i++) { + if (inline_stack[i] == s) return true; + } + return false; +} + +void GraphBuilder::push_inline_stack(SymbolID s) { + if (inline_depth < MAX_INLINE_DEPTH) { + inline_stack[inline_depth++] = s; + } +} + +void GraphBuilder::pop_inline_stack() { + if (inline_depth > 0) inline_depth--; +} + +// ── Error reporting ───────────────────────────────────────────────────────── + +uint16_t GraphBuilder::report_error(const Token& tok, const char* message, + const char* suggestion) { + return report_error_at(tok.span_start, tok.span_len, message, suggestion); +} + +uint16_t GraphBuilder::report_error_at(uint16_t span_start, uint16_t span_len, + const char* message, const char* suggestion) { + return report_error_at_cat(DiagnosticCategory::Runtime, + span_start, span_len, message, suggestion); +} + +uint16_t GraphBuilder::report_error_cat(DiagnosticCategory cat, const Token& tok, + const char* message, const char* suggestion) { + return report_error_at_cat(cat, tok.span_start, tok.span_len, message, suggestion); +} + +uint16_t GraphBuilder::report_error_at_cat(DiagnosticCategory cat, + uint16_t span_start, uint16_t span_len, + const char* message, const char* suggestion) { + if (diagnostic_count < MAX_DIAGNOSTICS) { + diagnostics[diagnostic_count++] = { + DiagnosticSeverity::Error, cat, + span_start, span_len, message, suggestion + }; + } + has_error = true; + return NODE_NONE; +} + +// Static buffers for fuzzy match error messages (avoids heap allocation). +// These persist for the lifetime of the diagnostic, which is fine since +// graph building is single-threaded and diagnostics are copied out. +static char fuzzy_msg_buf[128]; +static char fuzzy_sug_buf[128]; + +uint16_t GraphBuilder::report_error_with_fuzzy_match(SymbolID sym_id, + uint16_t span_start, + uint16_t span_len) { + auto& si = SymbolIntern::getInstance(); + const String& unknown_str = si.getString(sym_id); + + struct RemovedName { + const char* name; + const char* replacement; + const char* suggestion; + }; + static constexpr RemovedName removed_names[] = { + {"usin", "sin", "Try: (sin phasor)"}, + {"ucos", "cos", "Try: (cos phasor)"}, + {"osc", "lfo/sin", "Try: (lfo/sin 2)"}, + {"tri-osc", "lfo/tri", "Try: (lfo/tri 2)"}, + {"sqr-osc", "lfo/sqr", "Try: (lfo/sqr 2)"}, + }; + for (const RemovedName& removed : removed_names) { + if (unknown_str == removed.name) { + snprintf(fuzzy_msg_buf, sizeof(fuzzy_msg_buf), + "'%s' was removed in v1.2.0; use '%s'", + removed.name, removed.replacement); + return report_error_at_cat(DiagnosticCategory::UndefinedName, + span_start, span_len, + fuzzy_msg_buf, removed.suggestion); + } + } + + SymbolID match = find_fuzzy_match(sym_id, cells); + + if (match != SymbolIntern::INVALID_ID) { + const String& match_str = si.getString(match); + snprintf(fuzzy_msg_buf, sizeof(fuzzy_msg_buf), + "Unknown '%s'. Did you mean '%s'?", + unknown_str.c_str(), match_str.c_str()); + snprintf(fuzzy_sug_buf, sizeof(fuzzy_sug_buf), + "Try: %s", match_str.c_str()); + return report_error_at_cat(DiagnosticCategory::UndefinedName, + span_start, span_len, + fuzzy_msg_buf, fuzzy_sug_buf); + } + + return report_error_at_cat(DiagnosticCategory::UndefinedName, + span_start, span_len, + "Unknown name", + "Check your spelling"); +} + +uint16_t GraphBuilder::report_warning(uint16_t span_start, uint16_t span_len, + const char* message, const char* suggestion) { + if (diagnostic_count < MAX_DIAGNOSTICS) { + diagnostics[diagnostic_count++] = { + DiagnosticSeverity::Warning, DiagnosticCategory::Runtime, + span_start, span_len, message, suggestion + }; + } + return NODE_NONE; // warnings don't abort compilation +} + +// ── Side-effect detection ─────────────────────────────────────────────────── +// All symbols tagged "side_effect" in symbols.def. + +bool GraphBuilder::is_side_effect_form(SymbolID op) const { + return op == sym.define || op == sym.def || op == sym.defn || + op == sym.defun || op == sym.defs || op == sym.set || + op == sym.defstate || + op == sym.zeros_ || op == sym.get_expr || + op == sym.set_bpm || op == sym.set_time_sig || + op == sym.useq_clear || op == sym.set_time_offset || + op == sym.nudge_time || op == sym.useq_play || + op == sym.useq_pause || op == sym.useq_stop || + op == sym.useq_rewind +#if USEQ_HAS_SYNTH_ENGINE + || op == sym.synth +#endif + ; +} + +// ── Operator classification ───────────────────────────────────────────────── +// Uses cached symbol IDs from init_symbols() — no intern() calls at runtime. + +bool GraphBuilder::is_arithmetic_op(SymbolID op) const { + return op == sym.plus || op == sym.minus || op == sym.star || + op == sym.slash || op == sym.mod_pct || + op == sym.min_ || op == sym.max_; +} + +bool GraphBuilder::is_comparison_op(SymbolID op) const { + return op == sym.gt || op == sym.lt || op == sym.ge || + op == sym.le || op == sym.eq; +} + +bool GraphBuilder::is_logic_op(SymbolID op) const { + return op == sym.not_ || op == sym.and_ || op == sym.or_; +} + +bool GraphBuilder::is_unary_math(SymbolID op) const { + return op == sym.sin_ || op == sym.cos_ || op == sym.tan_ || + op == sym.abs_ || op == sym.floor_ || op == sym.ceil_ || + op == sym.sqrt_ || op == sym.neg || op == sym.frac_ || + op == sym.bsin || op == sym.bcos || + op == sym.bi_to_uni || op == sym.b_to_u || + op == sym.uni_to_bi || op == sym.u_to_b; +} + +bool GraphBuilder::is_binary_math(SymbolID op) const { + return op == sym.pow_ || op == sym.expt || op == sym.mod_ || op == sym.pulse; +} + +bool GraphBuilder::is_ternary_math(SymbolID op) const { + return op == sym.clamp || op == sym.lerp || op == sym.scale; +} + +NodeOp GraphBuilder::arithmetic_sym_to_op(SymbolID op) const { + if (op == sym.plus) return NodeOp::Add; + if (op == sym.minus) return NodeOp::Sub; + if (op == sym.star) return NodeOp::Mul; + if (op == sym.slash) return NodeOp::Div; + if (op == sym.mod_pct) return NodeOp::Mod; + if (op == sym.min_) return NodeOp::Min; + if (op == sym.max_) return NodeOp::Max; + return NodeOp::Add; +} + +NodeOp GraphBuilder::comparison_sym_to_op(SymbolID op) const { + if (op == sym.gt) return NodeOp::CmpGt; + if (op == sym.lt) return NodeOp::CmpLt; + if (op == sym.ge) return NodeOp::CmpGe; + if (op == sym.le) return NodeOp::CmpLe; + if (op == sym.eq) return NodeOp::CmpEq; + return NodeOp::CmpEq; +} + +NodeOp GraphBuilder::unary_sym_to_op(SymbolID op) const { + if (op == sym.sin_) return NodeOp::Sin; + if (op == sym.cos_) return NodeOp::Cos; + if (op == sym.tan_) return NodeOp::Tan; + if (op == sym.abs_) return NodeOp::Abs; + if (op == sym.floor_) return NodeOp::Floor; + if (op == sym.ceil_) return NodeOp::Ceil; + if (op == sym.sqrt_) return NodeOp::Sqrt; + if (op == sym.neg) return NodeOp::Neg; + if (op == sym.frac_) return NodeOp::Frac; + if (op == sym.bsin) return NodeOp::Sin; + if (op == sym.bcos) return NodeOp::Cos; + if (op == sym.bi_to_uni || op == sym.b_to_u) return NodeOp::BiToUni; + if (op == sym.uni_to_bi || op == sym.u_to_b) return NodeOp::UniToBi; + return NodeOp::Abs; +} + +bool GraphBuilder::is_output_symbol(SymbolID op) { + // Check if symbol matches a1-a8, d1-d8, s1-s8 + const String& name = getSymbolString(op); + if (name.length() < 2 || name.length() > 2) return false; + char prefix = name[0]; + char digit = name[1]; + if (digit < '1' || digit > '8') return false; + return prefix == 'a' || prefix == 'd' || prefix == 's'; +} + +uint16_t GraphBuilder::resolve_output_index(SymbolID op) { + const String& name = getSymbolString(op); + char prefix = name[0]; + uint16_t num = (uint16_t)(name[1] - '1'); + switch (prefix) { + case 'a': return num; // 0-7 + case 'd': return 8 + num; // 8-15 + case 's': return 16 + num; // 16-23 + default: return NODE_NONE; + } +} + +uint16_t GraphBuilder::resolve_hardware_input(SymbolID sym_id) { + const String& name = getSymbolString(sym_id); + if (name == "in1") return 0; // INP_I1 + if (name == "in2") return 1; // INP_I2 + if (name == "ain1") return 8; // INP_AI1 (was incorrectly 2, which is INP_M1) + if (name == "ain2") return 9; // INP_AI2 (was incorrectly 3, which is INP_M2) + // knobs, etc. can be added later + return NODE_NONE; +} + +void GraphBuilder::skip_form(TokenStream& ts) { + int depth = 0; + while (!ts.at_end()) { + Token tok = ts.consume(); + if (tok.kind == TokenKind::LParen || tok.kind == TokenKind::LBracket) depth++; + if (tok.kind == TokenKind::RParen || tok.kind == TokenKind::RBracket) { + if (depth <= 0) return; + depth--; + } + if (depth == 0 && tok.kind != TokenKind::LParen && tok.kind != TokenKind::LBracket) return; + } +} + +void GraphBuilder::report_oversized_vector(TokenStream& ts, + const Token& excess) { + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) + skip_form(ts); + ts.expect(TokenKind::RBracket); + report_error_at_cat( + DiagnosticCategory::Overflow, + excess.span_start, excess.span_len, + "A vector can contain at most 64 elements", + "Remove elements until the vector has 64 or fewer"); +} + +// ── Temporal Templates ────────────────────────────────────────────────────── + +uint16_t GraphBuilder::expand_beat(TimeContext& ctx) { + uint16_t bpm = pool.make_cell_load(sym.bpm); + uint16_t rate = pool.make_binop(NodeOp::Div, bpm, pool.make_const(60.0)); + uint16_t phase = pool.make_binop(NodeOp::Mul, ctx.t_node, rate); + return pool.make_binop(NodeOp::Mod, phase, pool.make_const(1.0)); +} + +uint16_t GraphBuilder::expand_bar(TimeContext& ctx) { + uint16_t bpm = pool.make_cell_load(sym.bpm); + uint16_t bpb = pool.make_cell_load(sym.beats_per_bar); + uint16_t rate = pool.make_binop(NodeOp::Div, + pool.make_binop(NodeOp::Div, bpm, pool.make_const(60.0)), bpb); + uint16_t phase = pool.make_binop(NodeOp::Mul, ctx.t_node, rate); + return pool.make_binop(NodeOp::Mod, phase, pool.make_const(1.0)); +} + +uint16_t GraphBuilder::expand_phrase(TimeContext& ctx) { + uint16_t bpm = pool.make_cell_load(sym.bpm); + uint16_t bpb = pool.make_cell_load(sym.beats_per_bar); + uint16_t bpp = pool.make_cell_load(sym.bars_per_phrase); + uint16_t rate = pool.make_binop(NodeOp::Div, + pool.make_binop(NodeOp::Div, + pool.make_binop(NodeOp::Div, bpm, pool.make_const(60.0)), bpb), bpp); + uint16_t phase = pool.make_binop(NodeOp::Mul, ctx.t_node, rate); + return pool.make_binop(NodeOp::Mod, phase, pool.make_const(1.0)); +} + +uint16_t GraphBuilder::expand_section(TimeContext& ctx) { + uint16_t bpm = pool.make_cell_load(sym.bpm); + uint16_t bpb = pool.make_cell_load(sym.beats_per_bar); + uint16_t bpp = pool.make_cell_load(sym.bars_per_phrase); + uint16_t pps = pool.make_cell_load(sym.phrases_per_section); + uint16_t rate = pool.make_binop(NodeOp::Div, + pool.make_binop(NodeOp::Div, + pool.make_binop(NodeOp::Div, + pool.make_binop(NodeOp::Div, bpm, pool.make_const(60.0)), bpb), bpp), pps); + uint16_t phase = pool.make_binop(NodeOp::Mul, ctx.t_node, rate); + return pool.make_binop(NodeOp::Mod, phase, pool.make_const(1.0)); +} + +uint16_t GraphBuilder::expand_beat_num(TimeContext& ctx) { + uint16_t bpm = pool.make_cell_load(sym.bpm); + uint16_t rate = pool.make_binop(NodeOp::Div, bpm, pool.make_const(60.0)); + uint16_t count = pool.make_binop(NodeOp::Mul, ctx.t_node, rate); + return pool.make_unary(NodeOp::Floor, count); +} + +uint16_t GraphBuilder::expand_bar_num(TimeContext& ctx) { + uint16_t bpm = pool.make_cell_load(sym.bpm); + uint16_t bpb = pool.make_cell_load(sym.beats_per_bar); + uint16_t rate = pool.make_binop(NodeOp::Div, + pool.make_binop(NodeOp::Div, bpm, pool.make_const(60.0)), bpb); + uint16_t count = pool.make_binop(NodeOp::Mul, ctx.t_node, rate); + return pool.make_unary(NodeOp::Floor, count); +} + +// ── Expression Compilation ────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_expr(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // Nesting-depth guard: compile_expr recurses on nested forms on the small + // RP2040 stack (this runs inside tick() for live edits). Abort with a clear + // diagnostic before a deeply-nested program can overflow the hardware stack. + // RAII guard decrements on every return path. + struct DepthGuard { + uint16_t& d; + DepthGuard(uint16_t& d_) : d(d_) { ++d; } + ~DepthGuard() { --d; } + } depth_guard(compile_depth); + if (compile_depth > MAX_COMPILE_DEPTH) { + Token tok = ts.peek(); + // Consume one token so error-recovery callers keep making progress. + if (tok.kind == TokenKind::LParen) { + skip_form(ts); + } else if (tok.kind != TokenKind::RParen && tok.kind != TokenKind::Eof) { + ts.consume(); + } + return report_error_cat(DiagnosticCategory::Syntax, tok, + "Expression nested too deeply", + "Simplify or split this deeply-nested form"); + } + + // If already in error state, still consume one expression to keep the + // token stream advancing (prevents infinite loops in variadic callers). + if (has_error) { + Token tok = ts.peek(); + if (tok.kind == TokenKind::LParen) { + skip_form(ts); + } else if (tok.kind != TokenKind::RParen && tok.kind != TokenKind::Eof) { + ts.consume(); + } + return NODE_NONE; + } + + Token tok = ts.peek(); + + // Number literal + if (tok.kind == TokenKind::Number) { + ts.consume(); + return pool.make_const(tok.number); + } + + // Symbol + if (tok.kind == TokenKind::Symbol) { + ts.consume(); + return compile_symbol(tok.symbol, scope, ctx, tok.span_start, tok.span_len); + } + + // Vector literal [1 2 3] + if (tok.kind == TokenKind::LBracket) { + return compile_vector_literal(ts, scope, ctx); + } + + // List form (op args...) + if (tok.kind == TokenKind::LParen) { + ts.consume(); // eat '(' + Token op_tok = ts.peek(); + if (op_tok.kind == TokenKind::RParen) { + ts.consume(); + return pool.make_const(0.0); // empty list + } + + // A nested lambda is a valid callable head: + // ((fn [x y] (+ x y)) 2 3) + // It must be handled before consuming the head as a normal symbol. + // Other non-symbol heads retain the ordinary syntax diagnostic. + bool nested_lambda = op_tok.kind == TokenKind::LParen && + ts.pos + 1 < ts.count && + ts.tokens[ts.pos + 1].kind == TokenKind::Symbol && + (ts.tokens[ts.pos + 1].symbol == sym.fn || + ts.tokens[ts.pos + 1].symbol == sym.lambda); + + uint16_t result = NODE_NONE; + if (nested_lambda) { + result = compile_inline_lambda_call(ts, scope, ctx); + } else { + // Leave a non-lambda list head intact so error recovery can skip + // the complete nested form instead of stopping at its close. + if (op_tok.kind != TokenKind::LParen) ts.consume(); + if (op_tok.kind != TokenKind::Symbol) { + result = report_error_cat(DiagnosticCategory::Syntax, op_tok, + "Expected a function name after '('", + "Try: (sin (* t 440))"); + } else { + result = compile_form(op_tok.symbol, ts, scope, ctx, op_tok); + } + } + + // If compile_form errored, skip any unconsumed args to avoid hangs + if (has_error) { + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + if (ts.peek().kind == TokenKind::LParen) { + skip_form(ts); + } else { + ts.consume(); + } + } + } + // Catch unconsumed arguments: if the form compiled without error but + // left extra tokens before the closing paren, report a clear arity + // error naming the function. Previously these extras silently + // desynchronised the token stream, producing confusing errors later. + if (!has_error && !nested_lambda && + ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + result = report_error_at_cat(DiagnosticCategory::Arity, + op_tok.span_start, op_tok.span_len, + "This function got more arguments than expected", + "Remove the extra arguments"); + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + if (ts.peek().kind == TokenKind::LParen) { + skip_form(ts); + } else { + ts.consume(); + } + } + } + ts.expect(TokenKind::RParen); + return result; + } + + if (tok.kind == TokenKind::Eof) { + return report_error_cat(DiagnosticCategory::Syntax, tok, + "Unexpected end of expression", + "Expression seems incomplete"); + } + + return report_error_cat(DiagnosticCategory::Syntax, tok, + "Unexpected token", + "Expected a number, name, or '('"); +} + +uint16_t GraphBuilder::compile_inline_lambda_call(TokenStream& ts, + Scope& scope, + TimeContext& ctx) { + // The caller has identified the next form as a lambda head. Capture its + // body before compiling the call arguments, since the arguments live + // after the lambda's closing parenthesis in the outer token stream. + Token lambda_open = ts.consume(); // '(' + Token lambda_tok = ts.consume(); // fn or lambda + if (lambda_open.kind != TokenKind::LParen || + lambda_tok.kind != TokenKind::Symbol || + (lambda_tok.symbol != sym.fn && lambda_tok.symbol != sym.lambda)) { + return report_error_cat(DiagnosticCategory::Syntax, lambda_tok, + "Expected a lambda form", + "Try: ((fn [x] x) 1)"); + } + + Token params_open = ts.consume(); + if (params_open.kind != TokenKind::LBracket) { + return report_error_cat(DiagnosticCategory::Syntax, params_open, + "Lambda parameters must be in a vector", + "Try: ((fn [x] x) 1)"); + } + + SymbolID params[MAX_CALLABLE_PARAMS] = {}; + uint8_t param_count = 0; + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) { + Token param = ts.consume(); + if (param.kind != TokenKind::Symbol) { + return report_error_cat(DiagnosticCategory::Syntax, param, + "Lambda parameters must be names", + "Try: ((fn [x] x) 1)"); + } + if (param_count >= MAX_CALLABLE_PARAMS) { + return report_error_cat(DiagnosticCategory::Overflow, param, + "Too many lambda parameters", + "Use at most 8 parameters"); + } + params[param_count++] = param.symbol; + } + if (!ts.expect(TokenKind::RBracket)) { + return report_error_cat(DiagnosticCategory::Syntax, ts.peek(), + "Lambda parameter vector is not closed", + "Close the parameter vector with ']'"); + } + + if (ts.peek().kind == TokenKind::RParen || ts.at_end()) { + return report_error_cat(DiagnosticCategory::Syntax, ts.peek(), + "Lambda needs a body expression", + "Try: ((fn [x] x) 1)"); + } + + uint16_t body_start = ts.pos; + skip_form(ts); + uint16_t body_end = ts.pos; + if (ts.peek().kind != TokenKind::RParen) { + return report_error_cat(DiagnosticCategory::Arity, ts.peek(), + "Lambda needs exactly one body expression", + "Wrap multiple expressions in (do ...)"); + } + ts.consume(); // close the lambda head + + uint16_t arg_nodes[MAX_CALLABLE_PARAMS] = {}; + uint8_t arg_count = 0; + while (ts.peek().kind != TokenKind::RParen && arg_count < param_count && + !ts.at_end()) { + uint16_t arg = compile_expr(ts, scope, ctx); + if (arg == NODE_NONE) return NODE_NONE; + arg_nodes[arg_count++] = arg; + } + + if (arg_count != param_count) { + return report_error_cat(DiagnosticCategory::Arity, lambda_tok, + "Wrong number of arguments", + "Check the lambda parameters"); + } + if (ts.peek().kind != TokenKind::RParen) { + return report_error_cat(DiagnosticCategory::Arity, lambda_tok, + "Too many arguments", + "Check the lambda parameters"); + } + + Scope inner_scope = {}; + inner_scope.parent = &scope; + for (uint8_t i = 0; i < arg_count; i++) { + if (!inner_scope.bind(params[i], arg_nodes[i])) { + return report_error_cat(DiagnosticCategory::Overflow, lambda_tok, + "Too many lambda bindings", + "Use fewer parameters in the lambda"); + } + } + + uint16_t body_count = body_end - body_start; + TokenStream body_ts; + memcpy(body_ts.tokens, ts.tokens + body_start, + body_count * sizeof(Token)); + body_ts.count = body_count; + body_ts.pos = 0; + return compile_expr(body_ts, inner_scope, ctx); +} + +// ── Symbol Resolution ─────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_symbol(SymbolID sym_id, Scope& scope, + TimeContext& ctx, + uint16_t span_start, uint16_t span_len) { + // 1. Local scope + if (auto local = scope.find(sym_id)) { + return local->node_index; + } + + // 2. Raw time input + if (sym_id == sym.t) return ctx.t_node; + + // 2b. dt (time delta) + if (sym_id == sym.dt) return pool.make_dt_load(); + + // 3. Well-known temporal templates + if (sym_id == sym.beat) return expand_beat(ctx); + if (sym_id == sym.bar) return expand_bar(ctx); + if (sym_id == sym.phrase) return expand_phrase(ctx); + if (sym_id == sym.section) return expand_section(ctx); + if (sym_id == sym.beat_num) return expand_beat_num(ctx); + if (sym_id == sym.bar_num) return expand_bar_num(ctx); + + // 4. Derived timing + if (sym_id == sym.beat_dur) { + uint16_t bpm_load = pool.make_cell_load(sym.bpm); + uint16_t sixty = pool.make_const(60.0); + return pool.make_binop(NodeOp::Div, sixty, bpm_load); + } + if (sym_id == sym.bar_dur) { + uint16_t bpm_load = pool.make_cell_load(sym.bpm); + uint16_t sixty = pool.make_const(60.0); + uint16_t beat_dur = pool.make_binop(NodeOp::Div, sixty, bpm_load); + uint16_t bpb_load = pool.make_cell_load(sym.beats_per_bar); + return pool.make_binop(NodeOp::Mul, beat_dur, bpb_load); + } + + // 5. Hardware inputs + uint16_t input_idx = resolve_hardware_input(sym_id); + if (input_idx != NODE_NONE) { + return pool.make_input_load(input_idx); + } + + // 6. Output references — previous tick's value + if (is_output_symbol(sym_id)) { + uint16_t idx = resolve_output_index(sym_id); + return pool.make_prev_output_load(idx); + } + + // 7. Cell table + if (sym_id < MAX_CELLS) { + const Cell& cell = cells.cells[sym_id]; + switch (cell.kind) { + case CellKind::Number: + add_dependency(sym_id); + // State cells (flags 0x02) load from state slot, not const + if (cell.flags == 0x02) { + return pool.make_state_load(cell.data_table_id); + } + return pool.make_const(cell.value); + + case CellKind::Data: + add_dependency(sym_id); + if (cell.data_table_id >= cells.data_table_count) { + return report_error_at_cat( + DiagnosticCategory::Overflow, + span_start, span_len, + "This vector binding has no valid data table", + "Redefine the vector or clear exhausted data storage"); + } + return pool.make_const(cell.value); // length + + case CellKind::Callable: { + const CallableInfo& info = cells.callables[sym_id]; + if (info.param_count == 0) { + // Expression cell — inline the body + add_dependency(sym_id); + return inline_expression_cell(sym_id, info, scope, ctx); + } + // Function with params — not valid as bare symbol + return report_error_at_cat(DiagnosticCategory::Arity, + span_start, span_len, + "This is a function — it needs arguments", + "Try calling it: (name arg1 arg2)"); + } + + case CellKind::Empty: + return report_error_with_fuzzy_match(sym_id, span_start, span_len); + + default: + return pool.make_const(0.0); // nil + } + } + + return report_error_with_fuzzy_match(sym_id, span_start, span_len); +} + +// ── Inline Expression Cell ────────────────────────────────────────────────── + +uint16_t GraphBuilder::inline_expression_cell(SymbolID sym_id, const CallableInfo& info, + Scope& scope, TimeContext& ctx) { + if (is_in_inline_stack(sym_id)) { + return report_error_at(0, 0, + "This definition references itself — recursive definitions can't be used in outputs", + "Try using 'for' over a fixed collection instead"); + } + push_inline_stack(sym_id); + + const char* body_src = source.read(info.source_offset); + if (!body_src) { + pop_inline_stack(); + return pool.make_const(0.0); + } + + ParsedProgram body_program; + body_program.parse(body_src, info.source_length); + if (!body_program.ok()) { + pop_inline_stack(); + return report_error_at_cat( + DiagnosticCategory::Syntax, 0, 0, + "Stored definition body could not be parsed", + "Redefine this name with valid source"); + } + + const char* saved_base = source_base; + source_base = body_src; + uint16_t result = compile_expr(body_program.stream, scope, ctx); + source_base = saved_base; + pop_inline_stack(); + return result; +} + +// ── Form Compilation ──────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_waveform( + SymbolID waveform, OperatorNamespace name_space, TokenStream& ts, + Scope& scope, TimeContext& ctx, Token op_tok) { + const bool is_pulse = waveform == sym.pulse; + uint16_t width = pool.make_const(0.5); + uint16_t input = NODE_NONE; + + if (is_pulse) { + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat( + DiagnosticCategory::Arity, op_tok.span_start, op_tok.span_len, + "pulse needs a width followed by a phasor", + "Try: (pulse 0.5 beat)"); + } + width = compile_expr(ts, scope, ctx); + if (width == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat( + DiagnosticCategory::Arity, op_tok.span_start, op_tok.span_len, + "pulse needs a final phasor argument", + "Try: (pulse 0.5 beat)"); + } + input = compile_expr(ts, scope, ctx); + } else { + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat( + DiagnosticCategory::Arity, op_tok.span_start, op_tok.span_len, + "This waveform needs exactly one input value", + "Put the driving phasor last"); + } + input = compile_expr(ts, scope, ctx); + } + if (input == NODE_NONE) return NODE_NONE; + if (ts.peek().kind != TokenKind::RParen) { + return report_error_at_cat( + DiagnosticCategory::Arity, op_tok.span_start, op_tok.span_len, + "This waveform got more arguments than expected", + "Remove the extra arguments"); + } + + constexpr Sample TWO_PI = 6.28318530717958647692; + const bool is_trig = waveform == sym.sin_ || waveform == sym.cos_ || + waveform == sym.bsin || waveform == sym.bcos || + waveform == sym.tan_; + const bool cosine = waveform == sym.cos_ || waveform == sym.bcos; + const bool bipolar_bare = waveform == sym.bsin || waveform == sym.bcos; + + uint16_t phase_or_angle = input; + if (is_trig) { + if (name_space != OperatorNamespace::Radians && + name_space != OperatorNamespace::Raw) { + phase_or_angle = pool.make_binop( + NodeOp::Mul, input, pool.make_const(TWO_PI)); + } + } else if (name_space == OperatorNamespace::Radians) { + phase_or_angle = pool.make_binop( + NodeOp::Div, input, pool.make_const(TWO_PI)); + } + + uint16_t result = NODE_NONE; + if (is_trig) { + result = pool.make_unary( + waveform == sym.tan_ ? NodeOp::Tan + : (cosine ? NodeOp::Cos : NodeOp::Sin), + phase_or_angle); + } else if (waveform == sym.tri) { + result = pool.make_unary(NodeOp::Tri, phase_or_angle); + } else if (waveform == sym.sqr) { + result = pool.make_unary(NodeOp::Sqr, phase_or_angle); + } else if (waveform == sym.saw) { + result = pool.make_unary(NodeOp::Frac, phase_or_angle); + } else if (waveform == sym.pulse) { + result = pool.make_binop(NodeOp::Pulse, phase_or_angle, width); + } + + if (result == NODE_NONE) { + return report_error_cat( + DiagnosticCategory::UndefinedName, op_tok, + "This operator has no waveform adapter", + "Use one of sin, cos, saw, tri, sqr, or pulse"); + } + + const OperatorDeclaration* declaration = + find_operator_declaration(waveform); + const OperatorOutputRange natural_range = declaration + ? declaration->output_range + : OperatorOutputRange::None; + + bool want_unipolar = false; + bool want_bipolar = false; + switch (name_space) { + case OperatorNamespace::Unipolar: + want_unipolar = true; + break; + case OperatorNamespace::Bipolar: + want_bipolar = true; + break; + case OperatorNamespace::Normalized: + want_unipolar = !bipolar_bare; + want_bipolar = bipolar_bare; + break; + case OperatorNamespace::None: + want_unipolar = waveform == sym.sin_ || waveform == sym.cos_ || + natural_range == OperatorOutputRange::Unipolar; + want_bipolar = bipolar_bare; + break; + case OperatorNamespace::Radians: + case OperatorNamespace::Raw: + want_bipolar = natural_range == OperatorOutputRange::Bipolar; + want_unipolar = natural_range == OperatorOutputRange::Unipolar; + break; + default: + break; + } + + if (want_unipolar && natural_range == OperatorOutputRange::Bipolar) { + result = pool.make_unary(NodeOp::BiToUni, result); + } else if (want_bipolar && natural_range == OperatorOutputRange::Unipolar) { + result = pool.make_unary(NodeOp::UniToBi, result); + } + return result; +} + +uint16_t GraphBuilder::compile_lfo_namespace( + SymbolID waveform, bool bipolar, TokenStream& ts, Scope& scope, + TimeContext& ctx, Token op_tok) { + uint16_t wave_type = 0; + if (waveform == sym.sin_ || waveform == sym.bsin) wave_type = 0; + else if (waveform == sym.tri) wave_type = 1; + else if (waveform == sym.saw) wave_type = 2; + else if (waveform == sym.sqr || waveform == sym.pulse) wave_type = 3; + else if (waveform == sym.cos_ || waveform == sym.bcos) wave_type = 4; + else { + return report_error_cat( + DiagnosticCategory::UndefinedName, op_tok, + "This operator has no free-running form", + "Use lfo/sin, lfo/cos, lfo/saw, lfo/tri, or lfo/sqr"); + } + + uint16_t result = build_lfo(ts, scope, ctx, wave_type); + if (result == NODE_NONE) return NODE_NONE; + const bool base_is_bipolar = waveform == sym.bsin || waveform == sym.bcos; + if (bipolar || base_is_bipolar) { + result = pool.make_unary(NodeOp::UniToBi, result); + } + return result; +} + +uint16_t GraphBuilder::compile_cold_namespace( + SymbolID op, TokenStream& ts, Scope& scope, TimeContext& ctx, + Token op_tok) { + if (op == sym.random_) { + uint16_t lo = pool.make_const(0.0); + uint16_t hi = pool.make_const(1.0); + if (ts.peek().kind != TokenKind::RParen) { + lo = compile_expr(ts, scope, ctx); + if (lo == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + hi = lo; + lo = pool.make_const(0.0); + } else { + hi = compile_expr(ts, scope, ctx); + if (hi == NODE_NONE) return NODE_NONE; + } + } + if (ts.peek().kind != TokenKind::RParen || !is_const(lo) || !is_const(hi)) { + return report_error_cat( + DiagnosticCategory::Type, op_tok, + "k/random needs zero, one, or two constant bounds", + "Try: (k/random) or (k/random -1 1)"); + } + const uint32_t nonce = cells.store_revision * 2654435761u; + const Sample unit = static_cast(nonce & 0x00ffffffu) / + static_cast(0x01000000u); + return pool.make_const( + const_value(lo) + unit * (const_value(hi) - const_value(lo))); + } + + uint16_t result = compile_form(op, ts, scope, ctx, op_tok); + if (result == NODE_NONE) return NODE_NONE; + if (!is_const(result)) { + return report_error_cat( + DiagnosticCategory::Type, op_tok, + "This k/ form is not constant at evaluation time", + "Use only literal inputs with k/, or remove the k/ prefix"); + } + return result; +} + +uint16_t GraphBuilder::compile_namespaced_form( + const NamespacedOperator& resolved, TokenStream& ts, Scope& scope, + TimeContext& ctx, Token op_tok) { + if (resolved.name_space == OperatorNamespace::Malformed) { + return report_error_cat( + DiagnosticCategory::Syntax, op_tok, + "A symbol can contain at most one namespace separator", + "Use one namespace in the form ns/operator"); + } + if (resolved.name_space == OperatorNamespace::Unknown) { + return report_error_cat( + DiagnosticCategory::UndefinedName, op_tok, + "Unknown operator namespace", + "Valid namespaces: n, r, u, b, lfo, blfo, osc, k, raw"); + } + if (resolved.name_space == OperatorNamespace::Osc) { + return report_error_cat( + DiagnosticCategory::Boundary, op_tok, + "Audio-rate osc/ nodes are top-level declarations", + "Use osc/name at the top level, not inside an output expression"); + } + if (!resolved.declaration || + !namespace_applies(resolved.name_space, *resolved.declaration)) { + return report_error_cat( + DiagnosticCategory::UndefinedName, op_tok, + "This operator has no form under that namespace", + "Choose a namespace offered for this operator"); + } + + const SymbolID base = resolved.base_symbol; + if (resolved.name_space == OperatorNamespace::Lfo || + resolved.name_space == OperatorNamespace::BipolarLfo) { + return compile_lfo_namespace( + base, resolved.name_space == OperatorNamespace::BipolarLfo, + ts, scope, ctx, op_tok); + } + if (resolved.name_space == OperatorNamespace::Cold) { + return compile_cold_namespace(base, ts, scope, ctx, op_tok); + } + if (resolved.name_space == OperatorNamespace::Raw) { + if (base == sym.sin_) return compile_unary_math(NodeOp::Sin, ts, scope, ctx); + if (base == sym.cos_) return compile_unary_math(NodeOp::Cos, ts, scope, ctx); + if (base == sym.bsin) return compile_unary_math(NodeOp::Sin, ts, scope, ctx); + if (base == sym.bcos) return compile_unary_math(NodeOp::Cos, ts, scope, ctx); + if (base == sym.saw) return compile_lfo_saw(ts, scope, ctx); + if (base == sym.tri || base == sym.sqr || base == sym.pulse) { + return compile_waveform( + base, OperatorNamespace::Raw, ts, scope, ctx, op_tok); + } + return compile_form(base, ts, scope, ctx, op_tok); + } + + if (resolved.declaration->regime == OperatorRegime::PureShaper || + ((resolved.name_space == OperatorNamespace::Normalized || + resolved.name_space == OperatorNamespace::Radians) && + (resolved.declaration->input_domain == OperatorInputDomain::Angle || + resolved.declaration->input_domain == OperatorInputDomain::Phase))) { + return compile_waveform(base, resolved.name_space, ts, scope, ctx, op_tok); + } + + uint16_t result = compile_form(base, ts, scope, ctx, op_tok); + if (result == NODE_NONE) return NODE_NONE; + if (resolved.name_space == OperatorNamespace::Unipolar && + resolved.declaration->output_range == OperatorOutputRange::Bipolar) { + return pool.make_unary(NodeOp::BiToUni, result); + } + if (resolved.name_space == OperatorNamespace::Bipolar && + resolved.declaration->output_range == OperatorOutputRange::Unipolar) { + return pool.make_unary(NodeOp::UniToBi, result); + } + return result; +} + +uint16_t GraphBuilder::compile_form(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx, Token op_tok) { + const NamespacedOperator resolved = resolve_namespaced_operator(op); + if (resolved.name_space != OperatorNamespace::None) { + return compile_namespaced_form(resolved, ts, scope, ctx, op_tok); + } + + if (op == sym.sin_ || op == sym.cos_ || op == sym.bsin || op == sym.bcos || + op == sym.tri || op == sym.sqr || op == sym.saw || op == sym.pulse) { + return compile_waveform(op, OperatorNamespace::None, + ts, scope, ctx, op_tok); + } + + // 1. Side effects → compile-time error in signal context + // (compile_expr drains remaining tokens to RParen after we return) + if (is_side_effect_form(op)) { + return report_error_cat(DiagnosticCategory::Boundary, op_tok, + "This can't be used inside an output expression", + "Use it at the top level instead"); + } + + // 2. Table lookup — binary search in sorted form_table + { + uint16_t lo = 0, hi = form_table_count; + while (lo < hi) { + uint16_t mid = (lo + hi) / 2; + if (form_table[mid].sym < op) lo = mid + 1; + else hi = mid; + } + if (lo < form_table_count && form_table[lo].sym == op) { + return (this->*form_table[lo].handler)(ts, scope, ctx); + } + } + + // 3. Variadic arithmetic (left-fold, not in table) + if (is_arithmetic_op(op)) return compile_variadic_arithmetic(op, ts, scope, ctx); + + // 4. Comparison (binary, needs op-to-nodeop mapping) + if (is_comparison_op(op)) return compile_comparison(op, ts, scope, ctx); + + // 5. Logic + if (is_logic_op(op)) return compile_logic(op, ts, scope, ctx); + + // 6. Unary / binary / ternary math + if (is_unary_math(op)) return compile_unary_math(unary_sym_to_op(op), ts, scope, ctx); + + if (is_binary_math(op)) { + NodeOp nop = NodeOp::Expt; + bool swap_args = false; + if (op == sym.pow_) { nop = NodeOp::Expt; swap_args = true; } // (pow a b) = b^a + if (op == sym.expt) nop = NodeOp::Expt; + if (op == sym.mod_) nop = NodeOp::Mod; + if (swap_args) + return compile_binary_math_swapped(nop, ts, scope, ctx); + return compile_binary_math(nop, ts, scope, ctx); + } + + if (is_ternary_math(op)) { + NodeOp nop = NodeOp::Clamp; + if (op == sym.clamp) nop = NodeOp::Clamp; + if (op == sym.lerp) nop = NodeOp::Lerp; + if (op == sym.scale) nop = NodeOp::Scale; + return compile_ternary_math(nop, ts, scope, ctx); + } + + // 7. Special forms with non-standard signatures + if (op == sym.gates || op == sym.trigs) return compile_gates(op, ts, scope, ctx); + + if (op == sym.fn || op == sym.lambda) return compile_lambda(ts, scope, ctx); + + if (op == sym.quote) { + return report_error_cat(DiagnosticCategory::Boundary, op_tok, + "'quote' can't be used inside an output expression", + "Use a literal vector instead: [1 0 1 0]"); + } + + if (op == sym.input) { + uint16_t arg = compile_expr(ts, scope, ctx); + if (arg == NODE_NONE) return NODE_NONE; + if (is_const(arg)) { + uint16_t ch = (uint16_t)const_value(arg); + return pool.make_input_load(ch); + } + return report_error_cat(DiagnosticCategory::Type, op_tok, + "input channel must be a constant", + "Try: (input 0) or (input 1)"); + } + + // 8. User-defined function call (fallback) + return compile_call(op, ts, scope, ctx, op_tok); +} + +// ── Time Transforms ───────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_fast(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t factor = compile_expr(ts, scope, ctx); + if (factor == NODE_NONE) return NODE_NONE; + TimeContext inner = { pool.make_binop(NodeOp::Mul, ctx.t_node, factor) }; + return compile_expr(ts, scope, inner); +} + +uint16_t GraphBuilder::compile_slow(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t factor = compile_expr(ts, scope, ctx); + if (factor == NODE_NONE) return NODE_NONE; + TimeContext inner = { pool.make_binop(NodeOp::Div, ctx.t_node, factor) }; + return compile_expr(ts, scope, inner); +} + +uint16_t GraphBuilder::compile_offset(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t amount = compile_expr(ts, scope, ctx); + if (amount == NODE_NONE) return NODE_NONE; + TimeContext inner = { pool.make_binop(NodeOp::Add, ctx.t_node, amount) }; + return compile_expr(ts, scope, inner); +} + +uint16_t GraphBuilder::compile_loop_at(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t duration = compile_expr(ts, scope, ctx); + if (duration == NODE_NONE) return NODE_NONE; + TimeContext inner = { pool.make_binop(NodeOp::Mod, ctx.t_node, duration) }; + return compile_expr(ts, scope, inner); +} + +uint16_t GraphBuilder::compile_eval_at_time(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t time_node = compile_expr(ts, scope, ctx); + if (time_node == NODE_NONE) return NODE_NONE; + TimeContext inner = { time_node }; + return compile_expr(ts, scope, inner); +} + +// ── Output Feedback ──────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_prev(TokenStream& ts, Scope& scope, TimeContext& ctx) { + (void)scope; + (void)ctx; + if (ts.pos >= ts.count || ts.tokens[ts.pos].kind != TokenKind::Symbol) { + return report_error_at_cat(DiagnosticCategory::Arity, + ts.pos > 0 ? ts.tokens[ts.pos - 1].span_start : 0, + ts.pos > 0 ? ts.tokens[ts.pos - 1].span_len : 0, + "(prev) needs an output name like a1, d1, etc.", + "(prev a1)"); + } + Token sym_tok = ts.consume(); + SymbolID sym_id = sym_tok.symbol; + + if (!is_output_symbol(sym_id)) { + return report_error_cat(DiagnosticCategory::Type, sym_tok, + "(prev) argument must be an output name (a1-a8, d1-d8, s1-s8)", + "(prev a1)"); + } + + uint16_t idx = resolve_output_index(sym_id); + return pool.make_prev_output_load(idx); +} + +// ── Control Flow ──────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_if(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t cond = compile_expr(ts, scope, ctx); + if (cond == NODE_NONE) return NODE_NONE; + uint16_t then_val = compile_expr(ts, scope, ctx); + if (then_val == NODE_NONE) return NODE_NONE; + uint16_t else_val = pool.make_const(0.0); + if (ts.peek().kind != TokenKind::RParen) { + else_val = compile_expr(ts, scope, ctx); + if (else_val == NODE_NONE) return NODE_NONE; + } + return pool.make_select(cond, then_val, else_val); +} + +uint16_t GraphBuilder::compile_let(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (let [name1 val1 name2 val2 ...] body) + // or (let ((name1 val1) (name2 val2) ...) body) + Scope inner = {}; + inner.parent = &scope; + + Token tok = ts.peek(); + if (tok.kind == TokenKind::LBracket) { + ts.consume(); // eat '[' + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end()) { + Token name_tok = ts.consume(); + if (name_tok.kind != TokenKind::Symbol) { + return report_error_cat(DiagnosticCategory::Syntax, name_tok, + "Expected a name in let binding", + "Try: (let [x 1 y 2] (+ x y))"); + } + uint16_t val = compile_expr(ts, inner, ctx); + if (!inner.bind(name_tok.symbol, val)) { + return report_error_cat(DiagnosticCategory::Overflow, name_tok, + "Too many let bindings in one form (limit 32)", + "Split into nested let forms or reduce the number of bindings"); + } + } + ts.expect(TokenKind::RBracket); + } else if (tok.kind == TokenKind::LParen) { + ts.consume(); // eat '(' + // Detect flat vs nested binding format: + // Flat: (let (x 1 y 2) body) — first element is a symbol, second is NOT '(' + // Nested: (let ((x 1) (y 2)) body) — first element is '(' + Token first = ts.peek(); + if (first.kind == TokenKind::Symbol) { + // Flat binding list: (name1 val1 name2 val2 ...) + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + Token name_tok = ts.consume(); + if (name_tok.kind != TokenKind::Symbol) { + return report_error_cat(DiagnosticCategory::Syntax, name_tok, + "Expected a name in let binding", + "Try: (let (x 1 y 2) (+ x y))"); + } + uint16_t val = compile_expr(ts, inner, ctx); + if (!inner.bind(name_tok.symbol, val)) { + return report_error_cat(DiagnosticCategory::Overflow, name_tok, + "Too many let bindings in one form (limit 32)", + "Split into nested let forms or reduce the number of bindings"); + } + } + } else { + // Nested binding list: ((name1 val1) (name2 val2) ...) + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + ts.expect(TokenKind::LParen); + Token name_tok = ts.consume(); + if (name_tok.kind != TokenKind::Symbol) { + return report_error_cat(DiagnosticCategory::Syntax, name_tok, + "Expected a name in let binding", + "Try: (let ((x 1) (y 2)) (+ x y))"); + } + uint16_t val = compile_expr(ts, inner, ctx); + if (!inner.bind(name_tok.symbol, val)) { + return report_error_cat(DiagnosticCategory::Overflow, name_tok, + "Too many let bindings in one form (limit 32)", + "Split into nested let forms or reduce the number of bindings"); + } + ts.expect(TokenKind::RParen); + } + } + ts.expect(TokenKind::RParen); + } + + // Compile body expressions, return last + uint16_t result = pool.make_const(0.0); + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + result = compile_expr(ts, inner, ctx); + } + return result; +} + +uint16_t GraphBuilder::compile_do(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t result = pool.make_const(0.0); + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + result = compile_expr(ts, scope, ctx); + } + return result; +} + +uint16_t GraphBuilder::compile_for(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (for var collection body) + Token var_tok = ts.consume(); + if (var_tok.kind != TokenKind::Symbol) { + return report_error_cat(DiagnosticCategory::Syntax, var_tok, + "'for' needs a variable name", + "Try: (for x [1 2 3] (* x 2))"); + } + SymbolID var = var_tok.symbol; + + Collection col = resolve_collection(ts, scope, ctx); + + uint16_t body_start = ts.pos; + + if (!col.ok) { + // Skip past body + skip_form(ts); + return report_error_cat(DiagnosticCategory::Type, var_tok, + "for's collection couldn't be resolved at compile time", + "Try using a literal vector: (for x [1 2 3 4] body)"); + } + + if (col.count == 0) { + skip_form(ts); + return pool.make_const(0.0); + } + + uint16_t result = pool.make_const(0.0); + for (uint16_t i = 0; i < col.count; i++) { + Scope iter_scope = {}; + iter_scope.parent = &scope; + iter_scope.bind(var, col.element_nodes[i]); + + ts.rewind(body_start); + result = compile_expr(ts, iter_scope, ctx); + } + + return result; +} + +uint16_t GraphBuilder::compile_while_gate(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (while cond body) — returns body when cond is true, 0.0 when false + uint16_t cond = compile_expr(ts, scope, ctx); + if (cond == NODE_NONE) return NODE_NONE; + uint16_t value = compile_expr(ts, scope, ctx); + if (value == NODE_NONE) return NODE_NONE; + return pool.make_select(cond, value, pool.make_const(0.0)); +} + +uint16_t GraphBuilder::compile_lambda(TokenStream& ts, Scope& scope, TimeContext& ctx) { + (void)ts; + (void)scope; + (void)ctx; + // Lambda in signal context is not supported as a value + // but if immediately applied, could work. For now, error. + return report_error_at_cat(DiagnosticCategory::Boundary, 0, 0, + "Lambda expressions can't be used directly in outputs", + "Define the function with 'defn' and call it by name"); +} + +// ── User-defined function call ────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_call(SymbolID fn_sym, TokenStream& ts, + Scope& scope, TimeContext& ctx, Token op_tok) { + if (fn_sym >= MAX_CELLS) { + return report_error_with_fuzzy_match(fn_sym, op_tok.span_start, op_tok.span_len); + } + + const Cell& cell = cells.cells[fn_sym]; + if (cell.kind != CellKind::Callable) { + return report_error_with_fuzzy_match(fn_sym, op_tok.span_start, op_tok.span_len); + } + + const CallableInfo& info = cells.callables[fn_sym]; + add_dependency(fn_sym); + + // Recursion guard — detects both direct and mutual recursion + if (is_in_inline_stack(fn_sym)) { + return report_error(op_tok, + "This function calls itself — recursive functions can't be used in outputs", + "Try using 'for' over a fixed collection instead"); + } + // Depth limit safety net — catches unbounded inlining chains + if (inline_depth >= MAX_INLINE_DEPTH) { + return report_error(op_tok, + "Function call chain is too deep", + "Simplify by reducing the number of nested function calls"); + } + // Compile arguments in the caller's context. The inline stack tracks + // callable bodies, not call-site argument expressions: (f (f x)) is a + // nested argument call, not recursion in f's body. + uint16_t arg_nodes[MAX_CALLABLE_PARAMS]; + uint8_t arg_count = 0; + while (ts.peek().kind != TokenKind::RParen && arg_count < info.param_count && !ts.at_end()) { + uint16_t arg = compile_expr(ts, scope, ctx); + if (arg == NODE_NONE) { + return NODE_NONE; + } + arg_nodes[arg_count++] = arg; + } + + if (arg_count != info.param_count) { + return report_error_cat(DiagnosticCategory::Arity, op_tok, + "Wrong number of arguments", + "Check the function definition"); + } + + // Check for too many arguments (extras not consumed by the loop) + if (ts.peek().kind != TokenKind::RParen) { + return report_error_cat(DiagnosticCategory::Arity, op_tok, + "Too many arguments", + "Check the function definition"); + } + + // Keep the callee on the stack only while compiling its body. A call to + // the same function from the body is true recursion and must be rejected. + push_inline_stack(fn_sym); + + // Create local scope with param bindings + Scope inner_scope = {}; + inner_scope.parent = &scope; + for (uint8_t i = 0; i < arg_count; i++) { + inner_scope.bind(info.params[i], arg_nodes[i]); + } + + // Tokenize callable body from source arena + const char* body_src = source.read(info.source_offset); + if (!body_src) { + pop_inline_stack(); + return pool.make_const(0.0); + } + + ParsedProgram body_program; + body_program.parse(body_src, info.source_length); + if (!body_program.ok()) { + pop_inline_stack(); + return report_error_at_cat( + DiagnosticCategory::Syntax, op_tok.span_start, op_tok.span_len, + "Stored function body could not be parsed", + "Redefine this function with valid source"); + } + + uint16_t result = compile_expr(body_program.stream, inner_scope, ctx); + pop_inline_stack(); + return result; +} + +// ── Arithmetic ────────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_variadic_arithmetic(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx) { + NodeOp nop = arithmetic_sym_to_op(op); + + // Nullary forms: (+) → 0, (*) → 1 + if (ts.peek().kind == TokenKind::RParen) { + if (nop == NodeOp::Add) return pool.make_const(0.0); + if (nop == NodeOp::Mul) return pool.make_const(1.0); + // (- ) and (/ ) with no args are errors + return report_error_at(0, 0, "This operator needs at least one argument", + "Try: (+ 1 2) or (* 3 4)"); + } + + // First argument + uint16_t result = compile_expr(ts, scope, ctx); + if (result == NODE_NONE) return NODE_NONE; + + // Handle unary minus: (- x) → negate + if (ts.peek().kind == TokenKind::RParen && nop == NodeOp::Sub) { + return pool.make_unary(NodeOp::Neg, result); + } + + // Handle unary division: (/ x) → (/ 1 x) + if (ts.peek().kind == TokenKind::RParen && nop == NodeOp::Div) { + return pool.make_binop(NodeOp::Div, pool.make_const(1.0), result); + } + + // `%` is a remainder operator, not an identity or unary transform. The + // historical unary form returned its argument unchanged, which made a + // missing operand silently look successful. Require a divisor. + if (ts.peek().kind == TokenKind::RParen && nop == NodeOp::Mod) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "'%' needs at least 2 values", + "Try: (% 7 3)"); + } + + // Left-fold remaining arguments + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + uint16_t rhs = compile_expr(ts, scope, ctx); + result = pool.make_binop(nop, result, rhs); + } + + return result; +} + +uint16_t GraphBuilder::compile_comparison(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx) { + NodeOp nop = comparison_sym_to_op(op); + uint16_t a = compile_expr(ts, scope, ctx); + uint16_t b = compile_expr(ts, scope, ctx); + return pool.make_binop(nop, a, b); +} + +uint16_t GraphBuilder::compile_logic(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx) { + if (op == sym.not_) { + uint16_t a = compile_expr(ts, scope, ctx); + return pool.make_unary(NodeOp::Not, a); + } + NodeOp nop = (op == sym.and_) ? NodeOp::And : NodeOp::Or; + uint16_t a = compile_expr(ts, scope, ctx); + uint16_t b = compile_expr(ts, scope, ctx); + return pool.make_binop(nop, a, b); +} + +uint16_t GraphBuilder::compile_unary_math(NodeOp op, TokenStream& ts, + Scope& scope, TimeContext& ctx) { + // Arity check: needs exactly 1 argument + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "This function needs 1 value", + "Try giving it an argument, like (sin beat)"); + } + uint16_t a = compile_expr(ts, scope, ctx); + if (a == NODE_NONE) return NODE_NONE; + // Check for extra arguments + if (ts.peek().kind != TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + ts.peek().span_start, ts.peek().span_len, + "This function takes 1 value, but got more", + "Remove the extra arguments"); + } + return pool.make_unary(op, a); +} + +uint16_t GraphBuilder::compile_binary_math(NodeOp op, TokenStream& ts, + Scope& scope, TimeContext& ctx) { + // Arity check: needs exactly 2 arguments + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "This function needs 2 values", + "Try: (pow 2 3) or (mod 10 3)"); + } + uint16_t a = compile_expr(ts, scope, ctx); + if (a == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "This function needs 2 values, but only got 1", + "Add another argument"); + } + uint16_t b = compile_expr(ts, scope, ctx); + return pool.make_binop(op, a, b); +} + +uint16_t GraphBuilder::compile_binary_math_swapped(NodeOp op, TokenStream& ts, + Scope& scope, TimeContext& ctx) { + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "This function needs 2 values", + "Try: (pow 2 10) — computes 10 raised to 2"); + } + uint16_t a = compile_expr(ts, scope, ctx); + if (a == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "This function needs 2 values, but only got 1", + "Add another argument"); + } + uint16_t b = compile_expr(ts, scope, ctx); + return pool.make_binop(op, b, a); +} + +uint16_t GraphBuilder::compile_ternary_math(NodeOp op, TokenStream& ts, + Scope& scope, TimeContext& ctx) { + // Arity check: needs exactly 3 arguments + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "This function needs 3 values", + "Try: (clamp value low high) or (lerp a b t)"); + } + uint16_t a = compile_expr(ts, scope, ctx); + if (a == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "This function needs 3 values, but got fewer", + "Make sure you provide all 3 arguments"); + } + uint16_t b = compile_expr(ts, scope, ctx); + if (b == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "This function needs 3 values, but only got 2", + "Add the third argument"); + } + uint16_t c = compile_expr(ts, scope, ctx); + return pool.make_ternary(op, a, b, c); +} + +// ── Domain-Specific Signal Functions ──────────────────────────────────────── + +uint16_t GraphBuilder::compile_step(TokenStream& ts, Scope& scope, TimeContext& ctx) { + DataRef data = resolve_data_table(ts, scope, ctx); + if (!data.ok) return NODE_NONE; + + uint16_t phase; + if (ts.peek().kind != TokenKind::RParen) { + phase = compile_expr(ts, scope, ctx); + } else { + phase = expand_beat(ctx); + } + + uint16_t len = pool.make_const((Sample)data.length); + uint16_t scaled = pool.make_binop(NodeOp::Mul, phase, len); + uint16_t idx = pool.make_unary(NodeOp::Floor, scaled); + Node n; + n.op = NodeOp::VecIndex; + n.input_a = idx; + n.imm = (Sample)data.table_id; + n.flags = 0; + return pool.intern_node(n); +} + +uint16_t GraphBuilder::compile_gates(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx) { + // (gates data) — width=0.5, phase=beat + // (gates data phase) — width=0.5 + // (gates data width phase) — explicit width (duty cycle 0-1) + DataRef data = resolve_data_table(ts, scope, ctx); + if (!data.ok) return NODE_NONE; + + uint16_t width; + uint16_t phase; + if (ts.peek().kind != TokenKind::RParen) { + uint16_t arg2 = compile_expr(ts, scope, ctx); + if (ts.peek().kind != TokenKind::RParen) { + // 3 args: (gates data width phase) + width = arg2; + phase = compile_expr(ts, scope, ctx); + } else { + // 2 args: (gates data phase) + width = pool.make_const(0.5); + phase = arg2; + } + } else { + width = pool.make_const(0.5); + phase = expand_beat(ctx); + } + + uint16_t len = pool.make_const((Sample)data.length); + uint16_t scaled = pool.make_binop(NodeOp::Mul, phase, len); + uint16_t idx = pool.make_unary(NodeOp::Floor, scaled); + Node vec_node; + vec_node.op = NodeOp::VecIndex; + vec_node.input_a = idx; + vec_node.imm = (Sample)data.table_id; + vec_node.flags = 0; + uint16_t raw = pool.intern_node(vec_node); + + if (op == sym.trigs) { + // trigs: value > 0 (no width — instantaneous trigger) + return pool.make_binop(NodeOp::CmpGt, raw, pool.make_const(0.0)); + } + + // gates: on when value > 0 AND fractional phase within step < width + uint16_t is_on = pool.make_binop(NodeOp::CmpGt, raw, pool.make_const(0.0)); + uint16_t frac = pool.make_binop(NodeOp::Sub, scaled, idx); + uint16_t in_width = pool.make_binop(NodeOp::CmpLt, frac, width); + return pool.make_binop(NodeOp::Mul, is_on, in_width); +} + +uint16_t GraphBuilder::compile_euclid(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (euclid hits total phasor) + // (euclid hits total pulse-width phasor) + // (euclid hits total pulse-width rotation phasor) + // The driving phasor is always final. Rotation follows the historical + // engine convention: positive values rotate the pattern to the right. + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "euclid needs hits, total steps, and a final phasor", + "Try: (euclid 3 8 beat)"); + } + uint16_t active = compile_expr(ts, scope, ctx); + if (active == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "euclid needs total steps and a final phasor", + "Try: (euclid 3 8 beat)"); + } + uint16_t total = compile_expr(ts, scope, ctx); + if (total == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + return report_error_at_cat(DiagnosticCategory::Arity, + 0, 0, "euclid needs a final phasor argument", + "Try: (euclid 3 8 beat)"); + } + + uint16_t pulse_width = pool.make_const(0.5); + uint16_t rotation = pool.make_const(0.0); + uint16_t phase = NODE_NONE; + uint16_t arg3 = compile_expr(ts, scope, ctx); + if (arg3 == NODE_NONE) return NODE_NONE; + if (ts.peek().kind == TokenKind::RParen) { + phase = arg3; + } else { + uint16_t arg4 = compile_expr(ts, scope, ctx); + if (arg4 == NODE_NONE) return NODE_NONE; + pulse_width = arg3; + if (ts.peek().kind == TokenKind::RParen) { + phase = arg4; + } else { + rotation = arg4; + phase = compile_expr(ts, scope, ctx); + } + } + if (phase == NODE_NONE) return NODE_NONE; + + // scaled = phase * total + uint16_t scaled = pool.make_binop(NodeOp::Mul, phase, total); + + // step_idx = min(floor(scaled), total - 1) — clamp for phase=1.0 edge case + uint16_t floored = pool.make_unary(NodeOp::Floor, scaled); + uint16_t max_idx = pool.make_binop(NodeOp::Sub, total, pool.make_const(1.0)); + uint16_t step_idx = pool.make_binop(NodeOp::Min, floored, max_idx); + + // rem = scaled - step_idx (fractional position within current step) + uint16_t rem = pool.make_binop(NodeOp::Sub, scaled, step_idx); + + // rotated_step = (step + total - rotation) % total + uint16_t shifted_step = pool.make_binop( + NodeOp::Sub, pool.make_binop(NodeOp::Add, step_idx, total), rotation); + uint16_t rotated_step = pool.make_binop(NodeOp::Mod, shifted_step, total); + + // idx = (rotated_step * active) % total + uint16_t product = pool.make_binop(NodeOp::Mul, rotated_step, active); + uint16_t idx = pool.make_binop(NodeOp::Mod, product, total); + + // hit = idx < active + uint16_t hit = pool.make_binop(NodeOp::CmpLt, idx, active); + + // gate = rem < pulse_width + uint16_t gate = pool.make_binop(NodeOp::CmpLt, rem, pulse_width); + + // result = hit ? gate : 0 + return pool.make_select(hit, gate, pool.make_const(0.0)); +} + +uint16_t GraphBuilder::compile_seq(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (seq data phase) — same as step + return compile_step(ts, scope, ctx); +} + +uint16_t GraphBuilder::compile_interp(TokenStream& ts, Scope& scope, TimeContext& ctx) { + DataRef data = resolve_data_table(ts, scope, ctx); + if (!data.ok) return NODE_NONE; + + uint16_t phase; + if (ts.peek().kind != TokenKind::RParen) { + phase = compile_expr(ts, scope, ctx); + } else { + phase = expand_beat(ctx); + } + + Node n; + n.op = NodeOp::VecLerp; + n.input_a = phase; + n.imm = (Sample)data.table_id; + n.flags = 0; + return pool.intern_node(n); +} + +uint16_t GraphBuilder::compile_dm(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (dm condition default value) + uint16_t cond = compile_expr(ts, scope, ctx); + uint16_t default_val = compile_expr(ts, scope, ctx); + uint16_t value = compile_expr(ts, scope, ctx); + uint16_t is_true = pool.make_binop(NodeOp::CmpGt, cond, pool.make_const(0.0)); + return pool.make_select(is_true, value, default_val); +} + +uint16_t GraphBuilder::compile_gatesw(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (gatesw data phase) — gate with width encoding + // Pattern values 1-9 control pulse width (value/9.0). Output is binary. + DataRef data = resolve_data_table(ts, scope, ctx); + if (!data.ok) return NODE_NONE; + + uint16_t phase; + if (ts.peek().kind != TokenKind::RParen) { + phase = compile_expr(ts, scope, ctx); + } else { + phase = expand_beat(ctx); + } + + uint16_t len = pool.make_const((Sample)data.length); + uint16_t scaled = pool.make_binop(NodeOp::Mul, phase, len); + uint16_t idx = pool.make_unary(NodeOp::Floor, scaled); + + // Read pattern value via VecIndex + Node vec_node; + vec_node.op = NodeOp::VecIndex; + vec_node.input_a = idx; + vec_node.input_b = NODE_NONE; + vec_node.input_c = NODE_NONE; + vec_node.imm = (Sample)data.table_id; + vec_node.flags = 0; + uint16_t raw = pool.intern_node(vec_node); + + // Width = value / 9.0 + uint16_t width = pool.make_binop(NodeOp::Div, raw, pool.make_const(9.0)); + + // Fractional phase within step + uint16_t frac_phase = pool.make_binop(NodeOp::Sub, scaled, idx); + + // Output: 1 if frac_phase < width, 0 otherwise + return pool.make_binop(NodeOp::CmpLt, frac_phase, width); +} + +// ── Ratio-Rhythm Functions ────────────────────────────────────────────────── + +// Helper: resolve a data table and read its raw constant values. +// Computes cumulative normalized boundaries into out_cum. +static bool resolve_ratio_table(GraphBuilder& gb, TokenStream& ts, Scope& scope, + TimeContext& ctx, Sample* out_values, Sample* out_cum, + uint16_t& out_count, Sample& out_total) { + auto data = gb.resolve_data_table(ts, scope, ctx); + if (!data.ok || data.length == 0) return false; + + uint16_t len = 0; + const Sample* raw = gb.cells.get_data_table(data.table_id, len); + if (!raw || len == 0) return false; + + out_count = len; + out_total = 0.0; + for (uint16_t i = 0; i < len; i++) { + out_values[i] = raw[i]; + out_total += raw[i]; + } + if (out_total == 0.0) return false; + + Sample acc = 0.0; + for (uint16_t i = 0; i < len; i++) { + acc += out_values[i]; + out_cum[i] = acc / out_total; + } + return true; +} + +uint16_t GraphBuilder::compile_ridx(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (ridx ratios phase) + // Returns index / ratios.size() (normalized index of current subdivision) + Sample values[64], cum[64]; + uint16_t count = 0; + Sample total = 0.0; + + if (!resolve_ratio_table(*this, ts, scope, ctx, values, cum, count, total)) { + return report_error_at_cat(DiagnosticCategory::Arity, 0, 0, + "ridx needs a non-empty ratio vector", + "Try: (ridx [1 2 1] beat)"); + } + + uint16_t phase; + if (ts.peek().kind != TokenKind::RParen) { + phase = compile_expr(ts, scope, ctx); + } else { + phase = expand_beat(ctx); + } + + // Build nested Select chain: + // if (phase <= cum[0]) => 0/N + // elif (phase <= cum[1]) => 1/N + // else => (N-1)/N + Sample n = (Sample)count; + uint16_t result = pool.make_const((Sample)(count - 1) / n); + + for (int i = (int)count - 2; i >= 0; i--) { + uint16_t boundary = pool.make_const(cum[i]); + uint16_t cmp = pool.make_binop(NodeOp::CmpLe, phase, boundary); + uint16_t this_val = pool.make_const((Sample)i / n); + result = pool.make_select(cmp, this_val, result); + } + + return result; +} + +uint16_t GraphBuilder::compile_rstep(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (rstep ratios phase) + // Returns lastAccumulatedSum / ratioSum (normalized start of current subdivision) + Sample values[64], cum[64]; + uint16_t count = 0; + Sample total = 0.0; + + if (!resolve_ratio_table(*this, ts, scope, ctx, values, cum, count, total)) { + return report_error_at_cat(DiagnosticCategory::Arity, 0, 0, + "rstep needs a non-empty ratio vector", + "Try: (rstep [1 2 1] beat)"); + } + + uint16_t phase; + if (ts.peek().kind != TokenKind::RParen) { + phase = compile_expr(ts, scope, ctx); + } else { + phase = expand_beat(ctx); + } + + // Build nested Select chain: + // if (phase <= cum[0]) => 0.0 + // elif (phase <= cum[1]) => cum[0] + // else => cum[N-2] + uint16_t result = pool.make_const(count >= 2 ? cum[count - 2] : 0.0); + + for (int i = (int)count - 2; i >= 0; i--) { + uint16_t boundary = pool.make_const(cum[i]); + uint16_t cmp = pool.make_binop(NodeOp::CmpLe, phase, boundary); + Sample start_val = (i == 0) ? 0.0 : cum[i - 1]; + uint16_t this_val = pool.make_const(start_val); + result = pool.make_select(cmp, this_val, result); + } + + return result; +} + +uint16_t GraphBuilder::compile_rpulse(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (rpulse ratios pulseWidth phase) + // Within current subdivision, compute local beat phase and compare to pulseWidth. + Sample values[64], cum[64]; + uint16_t count = 0; + Sample total = 0.0; + + if (!resolve_ratio_table(*this, ts, scope, ctx, values, cum, count, total)) { + return report_error_at_cat(DiagnosticCategory::Arity, 0, 0, + "rpulse needs a non-empty ratio vector", + "Try: (rpulse [1 2 1] 0.5 beat)"); + } + + uint16_t pulse_width = compile_expr(ts, scope, ctx); + + uint16_t phase; + if (ts.peek().kind != TokenKind::RParen) { + phase = compile_expr(ts, scope, ctx); + } else { + phase = expand_beat(ctx); + } + + // For each subdivision [start, end): + // local_phase = (phase - start) / (end - start) + // hit = local_phase <= pulseWidth + auto make_local_pulse = [&](int i) -> uint16_t { + Sample start = (i == 0) ? 0.0 : cum[i - 1]; + Sample end = cum[i]; + Sample width = end - start; + if (width <= 0.0) return pool.make_const(0.0); + uint16_t offset_phase = pool.make_binop(NodeOp::Sub, phase, pool.make_const(start)); + uint16_t local_phase = pool.make_binop(NodeOp::Div, offset_phase, pool.make_const(width)); + return pool.make_binop(NodeOp::CmpLe, local_phase, pulse_width); + }; + + uint16_t result = make_local_pulse(count - 1); + for (int i = (int)count - 2; i >= 0; i--) { + uint16_t boundary = pool.make_const(cum[i]); + uint16_t cmp = pool.make_binop(NodeOp::CmpLe, phase, boundary); + uint16_t this_val = make_local_pulse(i); + result = pool.make_select(cmp, this_val, result); + } + + return result; +} + +uint16_t GraphBuilder::compile_rwarp(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (rwarp ratios phase) + // Remap phase through ratio boundaries to uniform spacing. + // output = (index + beatPhase) / N + Sample values[64], cum[64]; + uint16_t count = 0; + Sample total = 0.0; + + if (!resolve_ratio_table(*this, ts, scope, ctx, values, cum, count, total)) { + return report_error_at_cat(DiagnosticCategory::Arity, 0, 0, + "rwarp needs a non-empty ratio vector", + "Try: (rwarp [1 2 1] beat)"); + } + + uint16_t phase; + if (ts.peek().kind != TokenKind::RParen) { + phase = compile_expr(ts, scope, ctx); + } else { + phase = expand_beat(ctx); + } + + Sample n = (Sample)count; + Sample iw = 1.0 / n; + + auto make_warped = [&](int i) -> uint16_t { + Sample start = (i == 0) ? 0.0 : cum[i - 1]; + Sample end = cum[i]; + Sample width = end - start; + if (width <= 0.0) return pool.make_const((Sample)i * iw); + uint16_t offset_phase = pool.make_binop(NodeOp::Sub, phase, pool.make_const(start)); + uint16_t local_phase = pool.make_binop(NodeOp::Div, offset_phase, pool.make_const(width)); + uint16_t idx_plus_local = pool.make_binop(NodeOp::Add, pool.make_const((Sample)i), local_phase); + return pool.make_binop(NodeOp::Mul, idx_plus_local, pool.make_const(iw)); + }; + + uint16_t result = make_warped(count - 1); + for (int i = (int)count - 2; i >= 0; i--) { + uint16_t boundary = pool.make_const(cum[i]); + uint16_t cmp = pool.make_binop(NodeOp::CmpLe, phase, boundary); + uint16_t this_val = make_warped(i); + result = pool.make_select(cmp, this_val, result); + } + + return result; +} + +// ── State Functions ───────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_integrate(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (integrate rate_expr [:id ]) + // Allocates a state slot, builds update graph: state + rate * dt + // Returns LoadState node for reads + + uint16_t rate = compile_expr(ts, scope, ctx); + if (rate == NODE_NONE) return NODE_NONE; + + StateID state_id = 0; + SymbolID seen_keywords[1] = {}; + uint8_t seen_keyword_count = 0; + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot = resolve_or_alloc(state_id, ResourceKind::Integrator, 0, 0.0); + if (slot == NODE_NONE) return NODE_NONE; + + // Build update graph: state_load + rate * dt_load + uint16_t state_load = pool.make_state_load(slot); + uint16_t dt_load = pool.make_dt_load(); + uint16_t rate_dt = pool.make_binop(NodeOp::Mul, rate, dt_load); + uint16_t updated = pool.make_binop(NodeOp::Add, state_load, rate_dt); + + // Store the update root + pool.state_update_roots[slot] = updated; + + // Return state_load for reads (caller reads current state value) + return state_load; +} + +// ── UGen Helpers ─────────────────────────────────────────────────────────── + +// Shared "state slots exhausted" message, formatted once with the actual +// firmware/desktop cap (MAX_STATE_SLOTS differs between builds). +const char* state_slots_exhausted_msg() { + static char buf[64]; + static bool initialized = false; + if (!initialized) { + snprintf(buf, sizeof(buf), "Too many state variables (max %u)", + (unsigned)MAX_STATE_SLOTS); + initialized = true; + } + return buf; +} + +// Unknown keyword on a stateful primitive — silently swallowing these hid +// typos and unsupported options (A9, values-types.md §2.3). :fresh is a +// spec'd-but-unimplemented identity option; reject it explicitly +// (state-identity.md §8.3). +uint16_t GraphBuilder::report_unknown_ugen_keyword(const Token& kw) { + if (kw.symbol == sym.kw_fresh) { + return report_error_cat(DiagnosticCategory::Type, kw, + ":fresh is not implemented yet", + "Remove :fresh, or use a distinct :id instead"); + } + static char msg[96]; + const String& kw_str = getSymbolString(kw.symbol); + snprintf(msg, sizeof(msg), "Unknown keyword %s for this form", + kw_str.c_str()); + return report_error_cat(DiagnosticCategory::Type, kw, msg, + "Check the keyword spelling — try :id (or :wave/:phase/:pw on oscillators)"); +} + +uint16_t GraphBuilder::alloc_state_slot(Sample init_value) { + if (pool.state_slot_count >= MAX_STATE_SLOTS) { + report_error_at(0, 0, + state_slots_exhausted_msg(), + "Remove unused integrate or defstate declarations"); + return NODE_NONE; + } + uint16_t slot = pool.state_slot_count++; + pool.state_values[slot] = init_value; + pool.state_owner_context[slot] = anon_state_context; + return slot; +} + +uint16_t GraphBuilder::resolve_or_alloc(StateID state_id, ResourceKind kind, + uint8_t role, Sample init_value) { + if (registry) { + // Anonymous stateful expressions (no :id) get a structural/positional + // key from the build context so recompiling the same program reuses + // its slots instead of leaking one per compile (state-identity.md + // §2.5). Without a context (scratch evals) fall through to the plain + // allocator — scratch pools are reset per eval anyway. + if (state_id == 0 && anon_state_context != ANON_STATE_CONTEXT_NONE) { + state_id = make_anon_state_id(anon_state_context, + anon_state_ordinal++); + } + if (state_id != 0) { + StateResourceKey key{state_id, kind, role}; + uint16_t slot = registry->resolve(key, init_value, + pool.state_values, + pool.state_slot_count, + anon_state_context); + if (slot == NODE_NONE) { + if (registry->last_owner_conflict) { + return report_error_at_cat( + DiagnosticCategory::Boundary, 0, 0, + "This state :id already has an update writer in another program", + "Declare one state source and share it through a pure named expression"); + } + report_error_at(0, 0, + state_slots_exhausted_msg(), + "Remove unused stateful expressions"); + return slot; + } + // Duplicate active :id (state-identity.md §5.3/§8.1): two + // stateful forms in the same compiled graph resolved to the same + // resource — both would install update roots on one slot, which + // is ambiguous. Reject instead of silently choosing one. + if (slot < MAX_STATE_SLOTS && slot_claimed_this_build[slot]) { + return report_error_at_cat(DiagnosticCategory::Boundary, 0, 0, + "This :id is already updated by another expression in the same program", + "Fork the :id, or use one state source (e.g. a shared phasor) with pure views"); + } + if (slot < MAX_STATE_SLOTS) slot_claimed_this_build[slot] = true; + if (slot < MAX_STATE_SLOTS) { + pool.state_owner_context[slot] = anon_state_context; + } + return slot; + } + } + return alloc_state_slot(init_value); +} + +// ── UGen: phasor ─────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_phasor(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t freq = compile_expr(ts, scope, ctx); + if (freq == NODE_NONE) return NODE_NONE; + + Sample init_phase = 0.0; + StateID state_id = 0; + SymbolID seen_keywords[2] = {}; + uint8_t seen_keyword_count = 0; + + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_phase) { + uint16_t val = compile_expr(ts, scope, ctx); + if (!is_const(val)) { + return report_error_cat(DiagnosticCategory::Type, kw, + ":phase must be a constant number", + "Try: :phase 0.25"); + } + init_phase = const_value(val); + } else if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot = resolve_or_alloc(state_id, ResourceKind::OscillatorPhase, 0, init_phase); + if (slot == NODE_NONE) return NODE_NONE; + + uint16_t state_load = pool.make_state_load(slot); + uint16_t dt_load = pool.make_dt_load(); + uint16_t freq_dt = pool.make_binop(NodeOp::Mul, freq, dt_load); + uint16_t updated = pool.make_binop(NodeOp::Add, state_load, freq_dt); + uint16_t wrapped = pool.make_unary(NodeOp::Frac, updated); + + pool.state_update_roots[slot] = wrapped; + return state_load; +} + +// ── UGen: lfo ────────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::build_osc_output(uint16_t state_load, uint16_t wave_type, uint16_t pw_node) { + switch (wave_type) { + case 0: return pool.make_unary(NodeOp::USin, state_load); + case 1: return pool.make_unary(NodeOp::Tri, state_load); + case 2: return state_load; // saw = phasor [0,1) + case 3: return pool.make_binop(NodeOp::CmpLt, state_load, pw_node); + case 4: return pool.make_unary(NodeOp::UCos, state_load); + default: return pool.make_unary(NodeOp::USin, state_load); + } +} + +uint16_t GraphBuilder::build_lfo(TokenStream& ts, Scope& scope, TimeContext& ctx, uint16_t default_wave) { + uint16_t freq = compile_expr(ts, scope, ctx); + if (freq == NODE_NONE) return NODE_NONE; + + uint16_t wave_type = default_wave; + Sample init_phase = 0.0; + uint16_t pulse_width_node = pool.make_const(0.5); + StateID state_id = 0; + SymbolID seen_keywords[4] = {}; + uint8_t seen_keyword_count = 0; + + auto& si = SymbolIntern::getInstance(); + + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = si.getString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + + ts.consume(); + + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + + if (kw.symbol == sym.kw_wave) { + Token val = ts.consume(); + if (val.kind != TokenKind::Symbol) { + return report_error_cat(DiagnosticCategory::Type, val, + ":wave must be a keyword like :sin, :cos, :tri, :saw, or :sqr", + "Try: (lfo 440 :wave :saw)"); + } + if (val.symbol == sym.kw_sin) wave_type = 0; + else if (val.symbol == sym.kw_cos) wave_type = 4; + else if (val.symbol == sym.kw_tri) wave_type = 1; + else if (val.symbol == sym.kw_saw_kw) wave_type = 2; + else if (val.symbol == sym.kw_sqr) wave_type = 3; + else { + return report_error_cat(DiagnosticCategory::Type, val, + "Unknown waveform", + "Try :sin, :cos, :tri, :saw, or :sqr"); + } + } else if (kw.symbol == sym.kw_phase) { + uint16_t val = compile_expr(ts, scope, ctx); + if (!is_const(val)) { + return report_error_cat(DiagnosticCategory::Type, kw, + ":phase must be a constant number", + "Try: :phase 0.25"); + } + init_phase = const_value(val); + } else if (kw.symbol == sym.kw_pw) { + pulse_width_node = compile_expr(ts, scope, ctx); + } else if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot = resolve_or_alloc(state_id, ResourceKind::OscillatorPhase, 0, init_phase); + if (slot == NODE_NONE) return NODE_NONE; + + uint16_t state_load = pool.make_state_load(slot); + uint16_t dt_load = pool.make_dt_load(); + uint16_t freq_dt = pool.make_binop(NodeOp::Mul, freq, dt_load); + uint16_t updated = pool.make_binop(NodeOp::Add, state_load, freq_dt); + uint16_t phase = pool.make_unary(NodeOp::Frac, updated); + + pool.state_update_roots[slot] = phase; + + return build_osc_output(state_load, wave_type, pulse_width_node); +} + +uint16_t GraphBuilder::compile_lfo(TokenStream& ts, Scope& scope, TimeContext& ctx) { + return build_lfo(ts, scope, ctx, 0); // default: :sin +} + +uint16_t GraphBuilder::compile_blfo(TokenStream& ts, Scope& scope, TimeContext& ctx) { + uint16_t result = build_lfo(ts, scope, ctx, 0); + if (result == NODE_NONE) return NODE_NONE; + return pool.make_unary(NodeOp::UniToBi, result); +} + +uint16_t GraphBuilder::compile_lfo_sin(TokenStream& ts, Scope& scope, TimeContext& ctx) { + return build_lfo(ts, scope, ctx, 0); +} + +uint16_t GraphBuilder::compile_lfo_tri(TokenStream& ts, Scope& scope, TimeContext& ctx) { + return build_lfo(ts, scope, ctx, 1); +} + +uint16_t GraphBuilder::compile_lfo_saw(TokenStream& ts, Scope& scope, TimeContext& ctx) { + return build_lfo(ts, scope, ctx, 2); +} + +uint16_t GraphBuilder::compile_lfo_sqr(TokenStream& ts, Scope& scope, TimeContext& ctx) { + return build_lfo(ts, scope, ctx, 3); +} + +// ── UGen: slew ───────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_slew(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (slew target rate [:id ]) + // Slew-rate limiter: moves toward target at max `rate` units/sec. + // update: state + clamp(target - state, -rate*dt, rate*dt) + + uint16_t target = compile_expr(ts, scope, ctx); + if (target == NODE_NONE) return NODE_NONE; + uint16_t rate = compile_expr(ts, scope, ctx); + if (rate == NODE_NONE) return NODE_NONE; + + StateID state_id = 0; + SymbolID seen_keywords[1] = {}; + uint8_t seen_keyword_count = 0; + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot = resolve_or_alloc(state_id, ResourceKind::SlewAccumulator, 0, 0.0); + if (slot == NODE_NONE) return NODE_NONE; + + uint16_t state_load = pool.make_state_load(slot); + uint16_t dt_load = pool.make_dt_load(); + + // delta = target - state + uint16_t delta = pool.make_binop(NodeOp::Sub, target, state_load); + + // step = rate * dt + uint16_t step = pool.make_binop(NodeOp::Mul, rate, dt_load); + + // neg_step = -step + uint16_t neg_step = pool.make_unary(NodeOp::Neg, step); + + // clamped_delta = clamp(-step, step, delta) — value-last convention + uint16_t clamped = pool.make_ternary(NodeOp::Clamp, neg_step, step, delta); + + // new_state = state + clamped_delta + uint16_t updated = pool.make_binop(NodeOp::Add, state_load, clamped); + + pool.state_update_roots[slot] = updated; + return state_load; +} + +// ── UGen: one-pole ───────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_one_pole(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (one-pole input cutoff [:id ]) + // First-order low-pass: alpha = min(1, 2*pi*cutoff*dt) + // update: state + alpha * (input - state) + + uint16_t input = compile_expr(ts, scope, ctx); + if (input == NODE_NONE) return NODE_NONE; + uint16_t cutoff = compile_expr(ts, scope, ctx); + if (cutoff == NODE_NONE) return NODE_NONE; + + StateID state_id = 0; + SymbolID seen_keywords[1] = {}; + uint8_t seen_keyword_count = 0; + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot = resolve_or_alloc(state_id, ResourceKind::OnePole, 0, 0.0); + if (slot == NODE_NONE) return NODE_NONE; + + uint16_t state_load = pool.make_state_load(slot); + uint16_t dt_load = pool.make_dt_load(); + + // 2*pi*cutoff*dt + uint16_t two_pi = pool.make_const(6.283185307179586); + uint16_t cutoff_dt = pool.make_binop(NodeOp::Mul, cutoff, dt_load); + uint16_t alpha_raw = pool.make_binop(NodeOp::Mul, two_pi, cutoff_dt); + + // alpha = min(1.0, alpha_raw) + uint16_t one = pool.make_const(1.0); + uint16_t alpha = pool.make_binop(NodeOp::Min, one, alpha_raw); + + // diff = input - state + uint16_t diff = pool.make_binop(NodeOp::Sub, input, state_load); + + // scaled = alpha * diff + uint16_t scaled = pool.make_binop(NodeOp::Mul, alpha, diff); + + // updated = state + scaled + uint16_t updated = pool.make_binop(NodeOp::Add, state_load, scaled); + + pool.state_update_roots[slot] = updated; + return state_load; +} + +// ── UGen: env-follow ─────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_env_follow(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (env-follow input attack release [:id ]) + // Asymmetric envelope follower. + // if |input| > state: state + attack * (|input| - state) + // else: state + release * (|input| - state) + // attack/release are rate coefficients (higher = faster tracking). + + uint16_t input = compile_expr(ts, scope, ctx); + if (input == NODE_NONE) return NODE_NONE; + + // Default attack/release if not provided + uint16_t attack_node; + uint16_t release_node; + + auto next_is_keyword = [&]() { + if (ts.peek().kind != TokenKind::Symbol) return false; + const String& name = getSymbolString(ts.peek().symbol); + return name.length() > 0 && name.c_str()[0] == ':'; + }; + + if (ts.peek().kind != TokenKind::RParen && !next_is_keyword()) { + attack_node = compile_expr(ts, scope, ctx); + } else { + attack_node = pool.make_const(10.0); + } + if (ts.peek().kind != TokenKind::RParen && !next_is_keyword()) { + release_node = compile_expr(ts, scope, ctx); + } else { + release_node = pool.make_const(5.0); + } + + StateID state_id = 0; + SymbolID seen_keywords[1] = {}; + uint8_t seen_keyword_count = 0; + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot = resolve_or_alloc(state_id, ResourceKind::EnvelopeFollower, 0, 0.0); + if (slot == NODE_NONE) return NODE_NONE; + + uint16_t state_load = pool.make_state_load(slot); + uint16_t dt_load = pool.make_dt_load(); + + // abs_input = abs(input) + uint16_t abs_input = pool.make_unary(NodeOp::Abs, input); + + // diff = abs_input - state + uint16_t diff = pool.make_binop(NodeOp::Sub, abs_input, state_load); + + // is_rising = diff > 0 + uint16_t zero = pool.make_const(0.0); + uint16_t is_rising = pool.make_binop(NodeOp::CmpGt, diff, zero); + + // attack_coeff = min(1, attack * dt) + uint16_t a_dt = pool.make_binop(NodeOp::Mul, attack_node, dt_load); + uint16_t a_coeff = pool.make_binop(NodeOp::Min, pool.make_const(1.0), a_dt); + + // release_coeff = min(1, release * dt) + uint16_t r_dt = pool.make_binop(NodeOp::Mul, release_node, dt_load); + uint16_t r_coeff = pool.make_binop(NodeOp::Min, pool.make_const(1.0), r_dt); + + // coeff = is_rising ? attack_coeff : release_coeff + uint16_t coeff = pool.make_select(is_rising, a_coeff, r_coeff); + + // updated = state + coeff * diff + uint16_t scaled = pool.make_binop(NodeOp::Mul, coeff, diff); + uint16_t updated = pool.make_binop(NodeOp::Add, state_load, scaled); + + pool.state_update_roots[slot] = updated; + return state_load; +} + +// ── UGen: sah (sample-and-hold) ──────────────────────────────────────────── + +uint16_t GraphBuilder::compile_sah(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (sah input trigger [:id ]) + // Aliases: latch + // When trigger rises through 0.5, sample input. Otherwise hold previous. + // update: (trigger > 0.5 && prev_trigger <= 0.5) ? input : state + // + // Rising-edge detection requires prev_trigger as a second state slot. + + uint16_t input = compile_expr(ts, scope, ctx); + if (input == NODE_NONE) return NODE_NONE; + uint16_t trigger = compile_expr(ts, scope, ctx); + if (trigger == NODE_NONE) return NODE_NONE; + + StateID state_id = 0; + SymbolID seen_keywords[1] = {}; + uint8_t seen_keyword_count = 0; + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + // Slot 0: held value + uint16_t slot0 = resolve_or_alloc(state_id, ResourceKind::HeldValue, 0, 0.0); + if (slot0 == NODE_NONE) return NODE_NONE; + + // Slot 1: previous trigger value + uint16_t slot1 = resolve_or_alloc(state_id, ResourceKind::TriggerMemory, 0, 0.0); + if (slot1 == NODE_NONE) return NODE_NONE; + + uint16_t held_load = pool.make_state_load(slot0); + uint16_t prev_trig_load = pool.make_state_load(slot1); + + // rising = (trigger > 0.5) && (prev_trigger <= 0.5) + uint16_t threshold = pool.make_const(0.5); + uint16_t trig_hi = pool.make_binop(NodeOp::CmpGt, trigger, threshold); + uint16_t prev_trig_lo = pool.make_binop(NodeOp::CmpLe, prev_trig_load, threshold); + uint16_t rising = pool.make_binop(NodeOp::And, trig_hi, prev_trig_lo); + + // held = rising ? input : held + uint16_t new_held = pool.make_select(rising, input, held_load); + + pool.state_update_roots[slot0] = new_held; + pool.state_update_roots[slot1] = trigger; // store current trigger for next tick + + return held_load; +} + +// ── UGen: noise ──────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_noise(TokenStream& ts, Scope& scope, TimeContext& ctx) { + (void)scope; + (void)ctx; + // (noise [:id ]) + // White noise source using deterministic hash of running counter. + // Uses one state slot as a counter that increments each tick. + // Output: HashIndex(counter) → [0,1] + + StateID state_id = 0; + SymbolID seen_keywords[1] = {}; + uint8_t seen_keyword_count = 0; + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot = resolve_or_alloc(state_id, ResourceKind::NoiseCounter, 0, 0.0); + if (slot == NODE_NONE) return NODE_NONE; + + uint16_t state_load = pool.make_state_load(slot); + uint16_t one = pool.make_const(1.0); + + // counter increments each tick + uint16_t updated = pool.make_binop(NodeOp::Add, state_load, one); + + pool.state_update_roots[slot] = updated; + + // output = HashIndex(state) → [0,1] + // Map to [-1, 1] for bipolar noise + uint16_t hash_raw = pool.make_unary(NodeOp::HashIndex, state_load); + return pool.make_unary(NodeOp::UniToBi, hash_raw); +} + +// ── UGen: toggle ─────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_toggle(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (toggle trigger [:id ]) + // T-flip-flop: toggles 0↔1 on each rising edge of trigger. + // Needs 2 state slots: toggle state + prev trigger. + + uint16_t trigger = compile_expr(ts, scope, ctx); + if (trigger == NODE_NONE) return NODE_NONE; + + StateID state_id = 0; + SymbolID seen_keywords[1] = {}; + uint8_t seen_keyword_count = 0; + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot0 = resolve_or_alloc(state_id, ResourceKind::ToggleState, 0, 0.0); // toggle state + if (slot0 == NODE_NONE) return NODE_NONE; + // role=1: toggle's trigger memory is a distinct resource from sah's + // (role 0) and count's (role 2) — cross-primitive :id sharing must not + // collide on the same TriggerMemory slot (A8, state-identity.md §3.2/§3.5). + uint16_t slot1 = resolve_or_alloc(state_id, ResourceKind::TriggerMemory, 1, 0.0); // prev trigger + if (slot1 == NODE_NONE) return NODE_NONE; + + uint16_t state_load = pool.make_state_load(slot0); + uint16_t prev_trig_load = pool.make_state_load(slot1); + + uint16_t threshold = pool.make_const(0.5); + uint16_t trig_hi = pool.make_binop(NodeOp::CmpGt, trigger, threshold); + uint16_t prev_trig_lo = pool.make_binop(NodeOp::CmpLe, prev_trig_load, threshold); + uint16_t rising = pool.make_binop(NodeOp::And, trig_hi, prev_trig_lo); + + // If rising: flip (1 - state). Otherwise: keep state. + uint16_t one = pool.make_const(1.0); + uint16_t flipped = pool.make_binop(NodeOp::Sub, one, state_load); + uint16_t new_state = pool.make_select(rising, flipped, state_load); + + pool.state_update_roots[slot0] = new_state; + pool.state_update_roots[slot1] = trigger; + + return state_load; +} + +// ── UGen: count ──────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_count(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (count trigger [:reset reset-trigger] [:id ]) + // Counts rising edges of trigger. Resets to 0 on rising edge of reset. + // Needs 3 state slots: counter, prev trigger, prev reset. + + uint16_t trigger = compile_expr(ts, scope, ctx); + if (trigger == NODE_NONE) return NODE_NONE; + + // Optional reset argument + uint16_t reset_trigger = pool.make_const(0.0); + StateID state_id = 0; + SymbolID seen_keywords[2] = {}; + uint8_t seen_keyword_count = 0; + + // Parse keywords for :reset and :id + while (ts.peek().kind == TokenKind::Symbol) { + Token kw = ts.peek(); + const String& kw_str = getSymbolString(kw.symbol); + if (kw_str.length() == 0 || kw_str.c_str()[0] != ':') break; + ts.consume(); + if (!remember_keyword_once(kw.symbol, seen_keywords, seen_keyword_count)) { + return report_error_cat(DiagnosticCategory::Arity, kw, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + } + if (kw.symbol == sym.kw_reset) { + reset_trigger = compile_expr(ts, scope, ctx); + } else if (kw.symbol == sym.kw_id) { + Token id_tok = ts.consume(); + if (id_tok.kind == TokenKind::String && source_base) { + state_id = internSymbol(source_base + id_tok.string.offset, id_tok.string.length); + } else if (id_tok.kind == TokenKind::Symbol) { + state_id = id_tok.symbol; + } else { + return report_error_cat(DiagnosticCategory::Type, id_tok, + ":id must be a string or keyword", + "Try: (phasor 1 :id \"my-phase\")"); + } + } else { + return report_unknown_ugen_keyword(kw); + } + } + + uint16_t slot0 = resolve_or_alloc(state_id, ResourceKind::Counter, 0, 0.0); // counter + if (slot0 == NODE_NONE) return NODE_NONE; + // role=2: distinct from sah (0) and toggle (1) — see A8 note in + // compile_toggle. + uint16_t slot1 = resolve_or_alloc(state_id, ResourceKind::TriggerMemory, 2, 0.0); // prev trigger + if (slot1 == NODE_NONE) return NODE_NONE; + uint16_t slot2 = resolve_or_alloc(state_id, ResourceKind::ResetLatch, 0, 0.0); // prev reset + if (slot2 == NODE_NONE) return NODE_NONE; + + uint16_t counter_load = pool.make_state_load(slot0); + uint16_t prev_trig_load = pool.make_state_load(slot1); + uint16_t prev_reset_load = pool.make_state_load(slot2); + + uint16_t threshold = pool.make_const(0.5); + uint16_t one = pool.make_const(1.0); + + // Detect trigger rising edge + uint16_t trig_hi = pool.make_binop(NodeOp::CmpGt, trigger, threshold); + uint16_t prev_trig_lo = pool.make_binop(NodeOp::CmpLe, prev_trig_load, threshold); + uint16_t trig_rising = pool.make_binop(NodeOp::And, trig_hi, prev_trig_lo); + + // Detect reset rising edge + uint16_t reset_hi = pool.make_binop(NodeOp::CmpGt, reset_trigger, threshold); + uint16_t prev_reset_lo = pool.make_binop(NodeOp::CmpLe, prev_reset_load, threshold); + uint16_t reset_rising = pool.make_binop(NodeOp::And, reset_hi, prev_reset_lo); + + // new_counter = reset_rising ? 0 : (trig_rising ? counter + 1 : counter) + uint16_t incremented = pool.make_binop(NodeOp::Add, counter_load, one); + uint16_t after_trig = pool.make_select(trig_rising, incremented, counter_load); + uint16_t new_counter = pool.make_select(reset_rising, pool.make_const(0.0), after_trig); + + pool.state_update_roots[slot0] = new_counter; + pool.state_update_roots[slot1] = trigger; + pool.state_update_roots[slot2] = reset_trigger; + + return counter_load; +} + +// -- Live-edit --------------------------------------------------------------- + +namespace { + +struct LiveEditSpec { + uint16_t form_start = 0; + NodePool::SlotVariant variant = NodePool::SlotVariant::Numeric; + Sample seed = 0.0; + char seed_keyword[MAX_LIVE_SLOT_OPTION_LEN] = {}; + + char id[MAX_LIVE_SLOT_ID] = {}; + bool has_id = false; + bool has_min = false; + bool has_max = false; + bool has_options = false; + Sample min_value = 0.0; + Sample max_value = 1.0; + Sample step = 0.0; + int precision = -1; + char options[MAX_LIVE_SLOT_OPTIONS][MAX_LIVE_SLOT_OPTION_LEN] = {}; + uint8_t option_count = 0; +}; + +template +void copy_bounded(char (&destination)[Capacity], const char* source) { + const size_t length = strnlen(source, Capacity - 1); + std::memcpy(destination, source, length); + destination[length] = '\0'; +} + +bool parse_live_edit_seed(GraphBuilder& builder, TokenStream& ts, + LiveEditSpec& spec) { + const Token seed_token = ts.peek(); + if (seed_token.kind == TokenKind::LParen) { + const uint16_t saved_position = ts.pos; + ts.consume(); + const Token inner_head = ts.peek(); + ts.rewind(saved_position); + if (inner_head.kind == TokenKind::Symbol && + inner_head.symbol == GraphBuilder::sym.live_edit) { + builder.report_error_at_cat( + DiagnosticCategory::Type, seed_token.span_start, + seed_token.span_len, + "Can't use a nested live-edit as the seed of another live-edit", + "Each live-edit wraps a single literal value"); + } else { + builder.report_error_at_cat( + DiagnosticCategory::Type, seed_token.span_start, + seed_token.span_len, + "live-edit seed must be a literal, not an expression", + "Try: (live-edit 0.5 :id \"x\" :min 0 :max 1)"); + } + return false; + } + + if (seed_token.kind == TokenKind::Number) { + if (!std::isfinite(seed_token.number)) { + builder.report_error_at_cat( + DiagnosticCategory::Type, seed_token.span_start, + seed_token.span_len, + "live-edit numeric seed must be finite", + "Use a finite numeric literal"); + return false; + } + spec.seed = seed_token.number; + } else if (seed_token.kind == TokenKind::Symbol) { + const String& name = getSymbolString(seed_token.symbol); + if (name == "true" || name == "false") { + spec.variant = NodePool::SlotVariant::Boolean; + spec.seed = name == "true" ? 1.0 : 0.0; + } else if (name.length() > 1 && name.c_str()[0] == ':') { + if (name.length() >= MAX_LIVE_SLOT_OPTION_LEN) { + builder.report_error_at_cat( + DiagnosticCategory::Overflow, seed_token.span_start, + seed_token.span_len, + "live-edit keyword seed is too long", + "Use a keyword shorter than 32 bytes"); + return false; + } + spec.variant = NodePool::SlotVariant::Keyword; + std::memcpy(spec.seed_keyword, name.c_str(), name.length()); + spec.seed_keyword[name.length()] = '\0'; + } else { + builder.report_error_at_cat( + DiagnosticCategory::Type, seed_token.span_start, + seed_token.span_len, + "live-edit seed must be a number, boolean, or keyword literal", + "Try 0.5, true, false, or :up"); + return false; + } + } else { + builder.report_error_at_cat( + DiagnosticCategory::Type, seed_token.span_start, + seed_token.span_len, + "live-edit seed must be a number, boolean, or keyword literal", + "Try: (live-edit 0.5 :id \"x\" :min 0 :max 1)"); + return false; + } + + ts.consume(); + return true; +} + +bool parse_live_edit_option_vector(GraphBuilder& builder, TokenStream& ts, + LiveEditSpec& spec, const Token& open) { + while (!ts.at_end() && ts.peek().kind != TokenKind::RBracket) { + const Token option = ts.consume(); + if (option.kind != TokenKind::Symbol) { + builder.report_error_at_cat( + DiagnosticCategory::Type, option.span_start, option.span_len, + ":options entries must be keywords", + "Try: :options [:up :down]"); + return false; + } + const String& option_name = getSymbolString(option.symbol); + if (option_name.length() <= 1 || option_name.c_str()[0] != ':') { + builder.report_error_at_cat( + DiagnosticCategory::Type, option.span_start, option.span_len, + ":options entries must be keywords", + "Try: :options [:up :down]"); + return false; + } + if (option_name.length() >= MAX_LIVE_SLOT_OPTION_LEN) { + builder.report_error_at_cat( + DiagnosticCategory::Overflow, option.span_start, + option.span_len, "A live-edit option is too long", + "Use keywords shorter than 32 bytes"); + return false; + } + if (spec.option_count >= MAX_LIVE_SLOT_OPTIONS) { + builder.report_error_at_cat( + DiagnosticCategory::Overflow, option.span_start, + option.span_len, "Too many live-edit options", + MAX_LIVE_SLOT_OPTIONS == 8 + ? "Use at most 8 options on firmware" + : "Use at most 16 options"); + return false; + } + for (uint8_t i = 0; i < spec.option_count; ++i) { + if (std::strncmp(spec.options[i], option_name.c_str(), + MAX_LIVE_SLOT_OPTION_LEN) == 0) { + builder.report_error_at_cat( + DiagnosticCategory::Arity, option.span_start, + option.span_len, + "A live-edit option may appear only once", + "Remove the duplicate option"); + return false; + } + } + std::memcpy(spec.options[spec.option_count], option_name.c_str(), + option_name.length()); + spec.options[spec.option_count][option_name.length()] = '\0'; + ++spec.option_count; + } + + if (ts.peek().kind != TokenKind::RBracket) { + builder.report_error_at_cat( + DiagnosticCategory::Syntax, open.span_start, open.span_len, + "Unclosed live-edit :options vector", "Add the closing ]"); + return false; + } + ts.consume(); + if (spec.option_count == 0) { + builder.report_error_at_cat( + DiagnosticCategory::Arity, open.span_start, open.span_len, + "live-edit :options cannot be empty", + "Include at least the seed keyword"); + return false; + } + spec.has_options = true; + return true; +} + +bool parse_live_edit_keywords(GraphBuilder& builder, TokenStream& ts, + LiveEditSpec& spec) { + SymbolID seen_keywords[7] = {}; + uint8_t seen_keyword_count = 0; + auto& symbols = SymbolIntern::getInstance(); + + while (ts.peek().kind == TokenKind::Symbol) { + const Token keyword_token = ts.peek(); + const String& keyword = symbols.getString(keyword_token.symbol); + if (keyword.length() == 0 || keyword.c_str()[0] != ':') break; + ts.consume(); + + if (!remember_keyword_once(keyword_token.symbol, seen_keywords, + seen_keyword_count)) { + builder.report_error_cat( + DiagnosticCategory::Arity, keyword_token, + "A keyword may appear only once in a form", + "Remove the duplicate keyword and keep one value"); + return false; + } + + if (keyword == ":id") { + const Token value = ts.consume(); + if (value.kind != TokenKind::String) { + builder.report_error_at_cat( + DiagnosticCategory::Type, value.span_start, value.span_len, + ":id must be a string", "Try: :id \"myknob\""); + return false; + } + if (!builder.source_base) { + builder.report_error_at( + value.span_start, value.span_len, + "Internal: source text unavailable for string resolution"); + return false; + } + uint16_t length = value.string.length; + if (length >= MAX_LIVE_SLOT_ID) length = MAX_LIVE_SLOT_ID - 1; + std::memcpy(spec.id, builder.source_base + value.string.offset, + length); + spec.id[length] = '\0'; + spec.has_id = true; + } else if (keyword == ":min" || keyword == ":max") { + const Token value = ts.consume(); + if (value.kind != TokenKind::Number || + !std::isfinite(value.number)) { + builder.report_error_at_cat( + DiagnosticCategory::Type, value.span_start, value.span_len, + keyword == ":min" ? ":min must be a finite number" + : ":max must be a finite number", + keyword == ":min" ? "Try: :min 0" : "Try: :max 1"); + return false; + } + if (keyword == ":min") { + spec.min_value = value.number; + spec.has_min = true; + } else { + spec.max_value = value.number; + spec.has_max = true; + } + } else if (keyword == ":name") { + const Token value = ts.consume(); + if (value.kind != TokenKind::String) { + builder.report_error_at_cat( + DiagnosticCategory::Type, value.span_start, value.span_len, + ":name must be a string", "Try: :name \"cutoff\""); + return false; + } + // The editor consumes the display name from source. It has no + // effect on compiler/runtime slot identity. + } else if (keyword == ":step") { + const Token value = ts.consume(); + if (value.kind != TokenKind::Number || + !std::isfinite(value.number) || value.number <= 0.0) { + builder.report_error_at_cat( + DiagnosticCategory::Type, value.span_start, value.span_len, + ":step must be a positive finite number", + "Try: :step 0.01"); + return false; + } + spec.step = value.number; + } else if (keyword == ":precision") { + const Token value = ts.consume(); + if (value.kind != TokenKind::Number || + !std::isfinite(value.number) || value.number < 0.0 || + std::floor(value.number) != value.number || + value.number > 2147483647.0) { + builder.report_error_at_cat( + DiagnosticCategory::Type, value.span_start, value.span_len, + ":precision must be a non-negative whole number", + "Try: :precision 2"); + return false; + } + spec.precision = static_cast(value.number); + } else if (keyword == ":options") { + const Token open = ts.consume(); + if (open.kind != TokenKind::LBracket) { + builder.report_error_at_cat( + DiagnosticCategory::Type, open.span_start, open.span_len, + ":options must be a vector of keywords", + "Try: :options [:up :down]"); + return false; + } + if (!parse_live_edit_option_vector(builder, ts, spec, open)) { + return false; + } + } else { + builder.report_unknown_ugen_keyword(keyword_token); + return false; + } + } + return true; +} + +bool validate_live_edit_spec(GraphBuilder& builder, LiveEditSpec& spec) { + if (!spec.has_id) { + builder.report_error_at_cat( + DiagnosticCategory::Arity, spec.form_start, 1, + "live-edit requires :id", + "Try: (live-edit 0.5 :id \"x\" :min 0 :max 1)"); + return false; + } + if (spec.variant == NodePool::SlotVariant::Numeric && !spec.has_min) { + builder.report_error_at_cat( + DiagnosticCategory::Arity, spec.form_start, 1, + "live-edit requires :min", + "Try: (live-edit 0.5 :id \"x\" :min 0 :max 1)"); + return false; + } + if (spec.variant == NodePool::SlotVariant::Numeric && !spec.has_max) { + builder.report_error_at_cat( + DiagnosticCategory::Arity, spec.form_start, 1, + "live-edit requires :max", + "Try: (live-edit 0.5 :id \"x\" :min 0 :max 1)"); + return false; + } + if (spec.variant == NodePool::SlotVariant::Numeric && + spec.min_value >= spec.max_value) { + builder.report_error_at_cat( + DiagnosticCategory::Overflow, spec.form_start, 1, + "live-edit :min must be less than :max", + "Swap :min and :max values"); + return false; + } + if (spec.variant != NodePool::SlotVariant::Keyword && spec.has_options) { + builder.report_error_at_cat( + DiagnosticCategory::Type, spec.form_start, 1, + "live-edit :options is valid only for a keyword seed", + "Remove :options or use a keyword seed such as :up"); + return false; + } + if (spec.variant != NodePool::SlotVariant::Keyword) return true; + + if (!spec.has_options) { + copy_bounded(spec.options[0], spec.seed_keyword); + spec.option_count = 1; + builder.report_warning( + spec.form_start, 1, + "Keyword live-edit omitted :options; using the seed only", + "Add :options with every allowed keyword"); + } + for (uint8_t i = 0; i < spec.option_count; ++i) { + if (std::strncmp(spec.options[i], spec.seed_keyword, + MAX_LIVE_SLOT_OPTION_LEN) == 0) { + spec.seed = static_cast(i); + return true; + } + } + builder.report_error_at_cat( + DiagnosticCategory::Type, spec.form_start, 1, + "live-edit keyword seed must appear in :options", + "Add the seed keyword to the options vector"); + return false; +} + +bool live_edit_id_seen(const GraphBuilder& builder, const char* id) { + for (uint16_t i = 0; i < builder.live_edit_ids_count; ++i) { + if (std::strncmp(builder.live_edit_ids_seen[i], id, + MAX_LIVE_SLOT_ID) == 0) { + return true; + } + } + return false; +} + +void record_live_edit_id(GraphBuilder& builder, const char* id) { + if (builder.live_edit_ids_count < GraphBuilder::MAX_IDS_PER_BUILD) { + copy_bounded( + builder.live_edit_ids_seen[builder.live_edit_ids_count], id); + ++builder.live_edit_ids_count; + } + if (builder.shared_live_edit_ids && builder.inline_depth == 0) { + builder.shared_live_edit_ids->add(id); + } +} + +void copy_live_edit_options(NodePool::LiveSlot& slot, + const LiveEditSpec& spec) { + slot.options_count = spec.option_count; + std::memset(slot.options, 0, sizeof(slot.options)); + for (uint8_t i = 0; i < spec.option_count; ++i) { + copy_bounded(slot.options[i], spec.options[i]); + } +} + +uint16_t update_existing_live_edit(GraphBuilder& builder, int16_t slot_index, + const LiveEditSpec& spec) { + builder.remember_live_slot(static_cast(slot_index)); + NodePool::LiveSlot& old_slot = builder.pool.live_slots[slot_index]; + const NodePool::SlotVariant old_variant = old_slot.variant; + const Sample preserved_value = old_slot.value; + char preserved_keyword[MAX_LIVE_SLOT_OPTION_LEN] = {}; + if (old_variant == NodePool::SlotVariant::Keyword) { + const int old_index = static_cast(old_slot.value); + if (old_index >= 0 && old_index < old_slot.options_count) { + copy_bounded(preserved_keyword, old_slot.options[old_index]); + } + } + + builder.pool.alloc_live_slot( + spec.id, spec.seed, spec.min_value, spec.max_value, spec.variant, + spec.step, spec.precision, builder.anon_state_context); + NodePool::LiveSlot& slot = builder.pool.live_slots[slot_index]; + copy_live_edit_options(slot, spec); + + if (old_variant != spec.variant) { + slot.value = spec.seed; + } else if (spec.variant == NodePool::SlotVariant::Keyword) { + slot.value = spec.seed; + for (uint8_t i = 0; i < spec.option_count; ++i) { + if (std::strncmp(spec.options[i], preserved_keyword, + MAX_LIVE_SLOT_OPTION_LEN) == 0) { + slot.value = static_cast(i); + break; + } + } + } else if (spec.variant == NodePool::SlotVariant::Numeric) { + slot.value = std::max(spec.min_value, + std::min(preserved_value, spec.max_value)); + } else { + slot.value = preserved_value != 0.0 ? 1.0 : 0.0; + } + return builder.pool.make_slot_load(static_cast(slot_index)); +} + +uint16_t bind_live_edit(GraphBuilder& builder, const LiveEditSpec& spec) { + if (builder.shared_live_edit_ids && builder.inline_depth == 0 && + builder.shared_live_edit_ids->contains(spec.id)) { + return builder.report_error_at_cat( + DiagnosticCategory::Boundary, spec.form_start, 1, + "duplicate live-edit :id in this document", + "Each live-edit must have a unique :id across all outputs"); + } + + const int16_t existing_slot = builder.pool.find_live_slot(spec.id); + if (existing_slot >= 0) { + if (builder.pool.live_slots[existing_slot].owner_context != + builder.anon_state_context) { + return builder.report_error_at_cat( + DiagnosticCategory::Boundary, spec.form_start, 1, + "duplicate live-edit :id is owned by another signal", + "Each output or state declaration must use distinct live-edit IDs"); + } + const bool seen_this_build = live_edit_id_seen(builder, spec.id); + if (seen_this_build && builder.inline_depth == 0) { + return builder.report_error_at_cat( + DiagnosticCategory::Boundary, spec.form_start, 1, + "duplicate live-edit :id in this document", + "Each live-edit must have a unique :id"); + } + if (!seen_this_build) record_live_edit_id(builder, spec.id); + return update_existing_live_edit(builder, existing_slot, spec); + } + + record_live_edit_id(builder, spec.id); + const int16_t slot_index = builder.pool.alloc_live_slot( + spec.id, spec.seed, spec.min_value, spec.max_value, spec.variant, + spec.step, spec.precision, builder.anon_state_context); + if (slot_index < 0) { + return builder.report_error_at_cat( + DiagnosticCategory::Overflow, spec.form_start, 1, + MAX_LIVE_SLOTS == 256 + ? "too many live-edit slots (max 256)" + : "too many live-edit slots (max 32)", + "Remove unused live-edit declarations"); + } + copy_live_edit_options(builder.pool.live_slots[slot_index], spec); + return builder.pool.make_slot_load(static_cast(slot_index)); +} + +} // namespace + +uint16_t GraphBuilder::compile_live_edit(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (live-edit :id [:min :max ] + // [:name ] [:options [ ...]] + // [:step ] [:precision ]) + (void)scope; + (void)ctx; + LiveEditSpec spec; + spec.form_start = ts.peek().span_start > 0 ? ts.peek().span_start - 1 : 0; + + // 0. Reject if in a context that forbids live-edit (defstate :initial, quote) + if (reject_live_edit) { + return report_error_at_cat(DiagnosticCategory::Boundary, spec.form_start, 1, + "live-edit is not valid here", + "live-edit cannot appear inside defstate initial values or quoted forms"); + } + if (!parse_live_edit_seed(*this, ts, spec) || + !parse_live_edit_keywords(*this, ts, spec) || + !validate_live_edit_spec(*this, spec)) { + return NODE_NONE; + } + return bind_live_edit(*this, spec); +} + +// -- Random / Hash ----------------------------------------------------------- + +uint16_t GraphBuilder::compile_random(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (random) -> HashIndex(beat_num) -> [0,1] + // (random lo hi) -> lo + HashIndex(beat_num) * (hi - lo) + if (ts.peek().kind == TokenKind::RParen) { + // No args: hash of beat_num + uint16_t beat_num = expand_beat_num(ctx); + return pool.make_unary(NodeOp::HashIndex, beat_num); + } + + uint16_t lo = compile_expr(ts, scope, ctx); + if (ts.peek().kind == TokenKind::RParen) { + // One arg: treat as (random 0 lo) + uint16_t beat_num = expand_beat_num(ctx); + uint16_t raw = pool.make_unary(NodeOp::HashIndex, beat_num); + return pool.make_binop(NodeOp::Mul, raw, lo); + } + + uint16_t hi = compile_expr(ts, scope, ctx); + // Two args: lo + hash * (hi - lo) + uint16_t beat_num = expand_beat_num(ctx); + uint16_t raw = pool.make_unary(NodeOp::HashIndex, beat_num); + uint16_t range_node = pool.make_binop(NodeOp::Sub, hi, lo); + uint16_t scaled = pool.make_binop(NodeOp::Mul, raw, range_node); + return pool.make_binop(NodeOp::Add, lo, scaled); +} + +uint16_t GraphBuilder::compile_index_rand(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (index-rand idx) -> HashIndex(idx) -> [0,1] + // (index-rand idx lo hi) -> lo + HashIndex(idx) * (hi - lo) + uint16_t idx = compile_expr(ts, scope, ctx); + + if (ts.peek().kind == TokenKind::RParen) { + // One arg: hash of idx + return pool.make_unary(NodeOp::HashIndex, idx); + } + + uint16_t lo = compile_expr(ts, scope, ctx); + if (ts.peek().kind == TokenKind::RParen) { + // Two args: treat as (index-rand idx 0 lo) -- hash * lo + uint16_t raw = pool.make_unary(NodeOp::HashIndex, idx); + return pool.make_binop(NodeOp::Mul, raw, lo); + } + + uint16_t hi = compile_expr(ts, scope, ctx); + // Three args: lo + hash * (hi - lo) + uint16_t raw = pool.make_unary(NodeOp::HashIndex, idx); + uint16_t range_node = pool.make_binop(NodeOp::Sub, hi, lo); + uint16_t scaled = pool.make_binop(NodeOp::Mul, raw, range_node); + return pool.make_binop(NodeOp::Add, lo, scaled); +} + +uint16_t GraphBuilder::compile_range(TokenStream& ts, Scope& scope, TimeContext& ctx) { + // (range end) or (range start end) or (range start end step) + // All arguments must fold to constants. + uint16_t arg1 = compile_expr(ts, scope, ctx); + + Sample start = 0, end_val, step_val = 1; + + if (ts.peek().kind == TokenKind::RParen) { + if (!is_const(arg1)) { + return report_error_at_cat(DiagnosticCategory::Type, 0, 0, + "range needs values known at compile time", + "Try: (range 1 8) or use a literal vector [1 2 3 4 5 6 7]"); + } + end_val = const_value(arg1); + } else { + uint16_t arg2 = compile_expr(ts, scope, ctx); + if (ts.peek().kind == TokenKind::RParen) { + if (!is_const(arg1) || !is_const(arg2)) { + return report_error_at_cat(DiagnosticCategory::Type, 0, 0, + "range needs values known at compile time", + "Try: (range 1 8) or use a literal vector"); + } + start = const_value(arg1); + end_val = const_value(arg2); + } else { + uint16_t arg3 = compile_expr(ts, scope, ctx); + if (!is_const(arg1) || !is_const(arg2) || !is_const(arg3)) { + return report_error_at_cat(DiagnosticCategory::Type, 0, 0, + "range needs values known at compile time", + "Try: (range 1 8) or use a literal vector"); + } + start = const_value(arg1); + end_val = const_value(arg2); + step_val = const_value(arg3); + } + } + + // This is used as a collection producer (e.g., for `for` loops) + // We return the last element as the scalar value + // The collection is built in resolve_collection which calls compile_range + Sample v = start; + uint16_t last = pool.make_const(0.0); + for (int i = 0; i < 64 && (step_val > 0 ? v < end_val : v > end_val); i++) { + last = pool.make_const(v); + v += step_val; + } + return last; +} + +// ── Vector Literal ────────────────────────────────────────────────────────── + +uint16_t GraphBuilder::compile_vector_literal(TokenStream& ts, Scope& scope, TimeContext& ctx) { + ts.consume(); // eat '[' + + // Collect elements as doubles (for data table) + Sample values[64]; + uint16_t count = 0; + + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end() && count < 64) { + Token elem_tok = ts.peek(); + uint16_t elem = compile_expr(ts, scope, ctx); + if (is_const(elem)) { + values[count++] = const_value(elem); + } else { + // A vector literal lowers to a static Sample table; a time-varying + // per-slot signal can't be represented. Fail loudly rather than + // silently substituting 0 (values-types.md §1.7). For time-varying + // values iterate with (for x [...] ...). + return report_error_at_cat(DiagnosticCategory::Type, + elem_tok.span_start, elem_tok.span_len, + "Vector elements here must be constant numbers", + "Use constants, or iterate with (for x [...] ...) for time-varying values"); + } + } + if (count == 64 && ts.peek().kind != TokenKind::RBracket && !ts.at_end()) { + Token excess = ts.peek(); + report_oversized_vector(ts, excess); + return NODE_NONE; + } + ts.expect(TokenKind::RBracket); + + // Store as data table and return the length as a constant + cells.store_data_table(values, count); + return pool.make_const((Sample)count); +} + +// ── Collection Resolution ─────────────────────────────────────────────────── + +GraphBuilder::Collection GraphBuilder::resolve_collection(TokenStream& ts, Scope& scope, TimeContext& ctx) { + switch (ts.peek().kind) { + case TokenKind::LBracket: + return resolve_literal_collection(ts, scope, ctx); + case TokenKind::Symbol: + return resolve_symbol_collection(ts); + case TokenKind::LParen: + return resolve_range_collection(ts, scope, ctx); + default: + return Collection{}; + } +} + +GraphBuilder::Collection GraphBuilder::resolve_literal_collection( + TokenStream& ts, Scope& scope, TimeContext& ctx) { + Collection collection; + ts.consume(); + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end() && + collection.count < 64) { + collection.element_nodes[collection.count++] = + compile_expr(ts, scope, ctx); + } + if (collection.count == 64 && ts.peek().kind != TokenKind::RBracket && + !ts.at_end()) { + report_oversized_vector(ts, ts.peek()); + return collection; + } + ts.expect(TokenKind::RBracket); + collection.ok = true; + return collection; +} + +GraphBuilder::Collection GraphBuilder::resolve_symbol_collection( + TokenStream& ts) { + Collection collection; + const SymbolID symbol = ts.consume().symbol; + if (symbol >= MAX_CELLS) return collection; + + const Cell& cell = cells.cells[symbol]; + if (cell.kind != CellKind::Data) return collection; + add_dependency(symbol); + uint16_t length = 0; + const Sample* data = cells.get_data_table(cell.data_table_id, length); + if (!data) return collection; + + for (uint16_t i = 0; i < length && collection.count < 64; ++i) { + collection.element_nodes[collection.count++] = pool.make_const(data[i]); + } + collection.ok = true; + return collection; +} + +void GraphBuilder::fill_range_collection(Collection& collection, Sample start, + Sample end, Sample step) { + for (Sample value = start; + collection.count < 64 && (step > 0 ? value < end : value > end); + value += step) { + collection.element_nodes[collection.count++] = pool.make_const(value); + } + collection.ok = true; +} + +GraphBuilder::Collection GraphBuilder::resolve_range_collection( + TokenStream& ts, Scope& scope, TimeContext& ctx) { + Collection collection; + const uint16_t form_start = ts.pos; + auto reject_form = [&]() { + ts.rewind(form_start); + skip_form(ts); + return collection; + }; + ts.consume(); + const Token function = ts.peek(); + if (function.kind != TokenKind::Symbol || function.symbol != sym.range) { + return reject_form(); + } + + ts.consume(); + const uint16_t first = compile_expr(ts, scope, ctx); + if (ts.peek().kind == TokenKind::RParen) { + if (is_const(first)) { + ts.consume(); + fill_range_collection(collection, 0.0, const_value(first), 1.0); + return collection; + } + return reject_form(); + } + + const uint16_t second = compile_expr(ts, scope, ctx); + if (ts.peek().kind == TokenKind::RParen) { + if (is_const(first) && is_const(second)) { + ts.consume(); + fill_range_collection(collection, const_value(first), + const_value(second), 1.0); + return collection; + } + return reject_form(); + } + + const uint16_t third = compile_expr(ts, scope, ctx); + if (ts.peek().kind == TokenKind::RParen && is_const(first) && + is_const(second) && is_const(third)) { + ts.consume(); + fill_range_collection(collection, const_value(first), + const_value(second), const_value(third)); + return collection; + } + return reject_form(); +} + +// ── Data Table Resolution ─────────────────────────────────────────────────── + +GraphBuilder::DataRef GraphBuilder::resolve_data_table(TokenStream& ts, Scope& scope, TimeContext& ctx) { + DataRef ref = { 0, 0, false }; + + Token tok = ts.peek(); + + // Literal vector [1 2 3] + if (tok.kind == TokenKind::LBracket) { + ts.consume(); // eat '[' + Sample values[64]; + uint16_t count = 0; + while (ts.peek().kind != TokenKind::RBracket && !ts.at_end() && count < 64) { + Token elem_tok = ts.peek(); + uint16_t elem = compile_expr(ts, scope, ctx); + if (is_const(elem)) { + values[count++] = const_value(elem); + } else { + // Data-consuming primitives (step/seq/gates/interp/…) lower a + // vector to a static Sample table read by index, so a per-slot + // time-varying signal can't be represented here. Fail loudly + // rather than silently substituting 0 (values-types.md §1.7). + report_error_at_cat(DiagnosticCategory::Type, + elem_tok.span_start, elem_tok.span_len, + "Vector elements here must be constant numbers", + "Use constants, or iterate with (for x [...] ...) for time-varying values"); + return ref; // ref.ok == false + } + } + if (count == 64 && ts.peek().kind != TokenKind::RBracket && + !ts.at_end()) { + Token excess = ts.peek(); + report_oversized_vector(ts, excess); + return ref; + } + ts.expect(TokenKind::RBracket); + + // Store in data pool + uint16_t table_id = cells.store_data_table(values, count); + if (table_id != UINT8_MAX) { + ref.table_id = table_id; + ref.length = count; + ref.ok = true; + } else { + // Table pool exhausted — surface it instead of failing silently + // (callers would otherwise report a misleading arity error). + report_error_at_cat(DiagnosticCategory::Overflow, + tok.span_start, tok.span_len, + "Out of data table storage for this vector", + "Remove unused vectors or reuse a defined vector name"); + } + return ref; + } + + // Symbol reference + if (tok.kind == TokenKind::Symbol) { + ts.consume(); + SymbolID sym_id = tok.symbol; + if (sym_id < MAX_CELLS) { + const Cell& cell = cells.cells[sym_id]; + if (cell.kind == CellKind::Data) { + add_dependency(sym_id); + if (cell.data_table_id >= cells.data_table_count) { + report_error_at_cat( + DiagnosticCategory::Overflow, + tok.span_start, tok.span_len, + "This vector binding has no valid data table", + "Redefine the vector or clear exhausted data storage"); + return ref; + } + ref.table_id = cell.data_table_id; + ref.length = (uint16_t)cell.value; + ref.ok = true; + return ref; + } + } + report_error_at_cat(DiagnosticCategory::Type, tok.span_start, tok.span_len, + "Expected a vector or data reference", + "Try: [1 0 1 0] or a defined vector name"); + return ref; + } + + // Quoted list '(1 0 1 0) — treat as vector + // TODO: handle quoted lists + + report_error_at_cat(DiagnosticCategory::Type, tok.span_start, tok.span_len, + "Expected a vector or data reference", + "Try: [1 0 1 0] or a defined vector name"); + return ref; +} + +// ── Top-level build function ──────────────────────────────────────────────── + +GraphBuildResult build_output_graph( + NodePool& pool, + TokenStream& ts, + CellStore& cells, + const SourceArena& source, + const char* source_base, + StateResourceRegistry* registry, + SharedLiveEditIDs* shared_ids, + uint16_t anon_state_context +) { + GraphBuilder builder(pool, cells, source); + builder.source_base = source_base; + builder.registry = registry; + builder.shared_live_edit_ids = shared_ids; + builder.anon_state_context = anon_state_context; + Scope root_scope = {}; + TimeContext ctx = { pool.make_raw_time_load() }; + + uint16_t root = builder.compile_expr(ts, root_scope, ctx); + + if (builder.has_error) builder.rollback_live_slots(); + + // Warning #3: check for live-edit slots allocated but never read in this graph. + // Walk from root, collect all SlotLoad imm values, then check which freshly + // allocated slots (those with index >= live_slot_count_at_start) are missing. + if (!builder.has_error && root != NODE_NONE && + pool.live_slot_count > builder.live_slot_count_at_start) { + // Collect referenced slot indices via DFS from root + bool slot_referenced[MAX_LIVE_SLOTS] = {}; + uint16_t walk_stack[MAX_TOTAL_NODES]; + uint16_t walk_top = 0; + bool visited[MAX_TOTAL_NODES] = {}; + walk_stack[walk_top++] = root; + while (walk_top > 0) { + uint16_t ni = walk_stack[--walk_top]; + if (ni == NODE_NONE || ni >= pool.node_count || visited[ni]) continue; + visited[ni] = true; + const Node& n = pool.nodes[ni]; + if (n.op == NodeOp::SlotLoad) { + uint16_t slot_idx = (uint16_t)n.imm; + if (slot_idx < MAX_LIVE_SLOTS) slot_referenced[slot_idx] = true; + } + if (n.input_a != NODE_NONE && walk_top < MAX_TOTAL_NODES) walk_stack[walk_top++] = n.input_a; + if (n.input_b != NODE_NONE && walk_top < MAX_TOTAL_NODES) walk_stack[walk_top++] = n.input_b; + if (n.input_c != NODE_NONE && walk_top < MAX_TOTAL_NODES) walk_stack[walk_top++] = n.input_c; + } + + // Emit warnings for unreferenced freshly-allocated slots + for (uint16_t s = builder.live_slot_count_at_start; + s < pool.live_slot_count; s++) { + if (!slot_referenced[s] && + builder.diagnostic_count < MAX_DIAGNOSTICS) { + builder.diagnostics[builder.diagnostic_count++] = { + DiagnosticSeverity::Warning, + DiagnosticCategory::Boundary, + 0, 0, + "live-edit slot allocated but never read in this signal graph", + "Ensure the live-edit value is used in the output expression" + }; + } + } + } + + GraphBuildResult result; + result.root_node = root; + memcpy(result.diagnostics, builder.diagnostics, + builder.diagnostic_count * sizeof(Diagnostic)); + result.diagnostic_count = builder.diagnostic_count; + result.has_error = builder.has_error; + + // Copy dependency info + memcpy(result.dep_cells, builder.dep_cells, + builder.dep_count * sizeof(CellIndex)); + result.dep_count = builder.dep_count; + + return result; +} + +} // namespace sig diff --git a/src/signal_engine/graph_builder.h b/src/signal_engine/graph_builder.h new file mode 100644 index 0000000..8652e94 --- /dev/null +++ b/src/signal_engine/graph_builder.h @@ -0,0 +1,471 @@ +#ifndef SIGNAL_ENGINE_GRAPH_BUILDER_H +#define SIGNAL_ENGINE_GRAPH_BUILDER_H + +#include "types.h" +#include "token.h" +#include "node_pool.h" +#include "cell_store.h" +#include "state_registry.h" +#include "diagnostics.h" +#include + +namespace sig { + +// ── Cross-output live-edit ID tracking ────────────────────────────────────── +// Shared across all build_output_graph calls within one eval batch. +// Detects duplicate :id across separate output compilations. + +struct SharedLiveEditIDs { + char ids[MAX_LIVE_SLOTS][MAX_LIVE_SLOT_ID] = {}; + uint16_t count = 0; + + void clear() { count = 0; } + + bool contains(const char* id) const { + for (uint16_t i = 0; i < count; i++) { + if (strncmp(ids[i], id, MAX_LIVE_SLOT_ID) == 0) return true; + } + return false; + } + + void add(const char* id) { + if (count < MAX_LIVE_SLOTS) { + const size_t length = strnlen(id, MAX_LIVE_SLOT_ID - 1); + memcpy(ids[count], id, length); + ids[count][length] = '\0'; + count++; + } + } +}; + +// ── Scope (local bindings for let, lambda params, for variables) ──────────── + +struct Scope { + struct Binding { + SymbolID name; + uint16_t node_index; + }; + Binding locals[MAX_LOCAL_BINDINGS] = {}; + uint8_t local_count = 0; + Scope* parent = nullptr; + + // Returns false if the local-binding pool is full (caller must report a + // diagnostic — a dropped binding would silently resolve to the wrong value). + bool bind(SymbolID name, uint16_t node_index); + const Binding* find(SymbolID name) const; +}; + +// ── Time Context ──────────────────────────────────────────────────────────── + +struct TimeContext { + uint16_t t_node; // node index representing "current t" (may be transformed) +}; + +// ── Bounded graph candidate IR ───────────────────────────────────────────── + +// A successful build identifies a root and its complete retained dependency +// set, but is not published by GraphBuilder. Callers keep it behind a +// GraphMutationTransaction until source capacity and enclosing-form validation +// succeed, then install it through the cold evaluator's publication stage. +struct GraphBuildResult { + uint16_t root_node = NODE_NONE; + Diagnostic diagnostics[MAX_DIAGNOSTICS] = {}; + uint8_t diagnostic_count = 0; + bool has_error = false; + + // Cell dependencies discovered during graph compilation + CellIndex dep_cells[MAX_OUTPUT_DEPS] = {}; + uint8_t dep_count = 0; +}; + +// ── Graph Builder ─────────────────────────────────────────────────────────── + +struct StateResourceRegistry; + +enum class SymbolCategory : uint8_t { + none, + arith, + cmp, + logic, + unary, + binary, + ternary, + time_warp, + control, + signal, + side_effect, + waveform +}; + +enum class OperatorInputDomain : uint8_t { None, Angle, Phase, Scalar }; +enum class OperatorOutputRange : uint8_t { None, Unipolar, Bipolar }; +enum class OperatorRegime : uint8_t { None, Pure, PureShaper, Stateful, TopLevel }; +enum class ColdEvaluable : uint8_t { No, Yes }; +enum class BareIdentity : uint8_t { + Native, + NormalizedUnipolar, + NormalizedBipolar, + UnipolarShaper, + LfoUnipolar, + LfoBipolar +}; + +enum class OperatorNamespace : uint8_t { + None, + Normalized, + Radians, + Unipolar, + Bipolar, + Lfo, + BipolarLfo, + Osc, + Cold, + Raw, + Unknown, + Malformed +}; + +struct OperatorDeclaration { + SymbolID symbol = SymbolIntern::INVALID_ID; + const char* spelling = nullptr; + SymbolCategory category = SymbolCategory::none; + OperatorInputDomain input_domain = OperatorInputDomain::None; + OperatorOutputRange output_range = OperatorOutputRange::None; + OperatorRegime regime = OperatorRegime::None; + ColdEvaluable cold_evaluable = ColdEvaluable::No; + BareIdentity bare_identity = BareIdentity::Native; +}; + +struct NamespacedOperator { + OperatorNamespace name_space = OperatorNamespace::None; + SymbolID base_symbol = SymbolIntern::INVALID_ID; + const OperatorDeclaration* declaration = nullptr; + uint16_t namespace_span_len = 0; +}; + +struct GraphBuilder { + NodePool& pool; + CellStore& cells; + const SourceArena& source; + const char* source_base = nullptr; // raw tokenized text for string resolution + StateResourceRegistry* registry = nullptr; + + // Diagnostics output + Diagnostic diagnostics[MAX_DIAGNOSTICS] = {}; + uint8_t diagnostic_count = 0; + bool has_error = false; + + // Dependency tracking (populated during build) + CellIndex dep_cells[MAX_OUTPUT_DEPS] = {}; + uint8_t dep_count = 0; + + // Recursion guard for inline stack + SymbolID inline_stack[MAX_INLINE_DEPTH] = {}; + uint8_t inline_depth = 0; + + // Syntactic nesting-depth guard for compile_expr recursion (RP2040 stack + // protection — see MAX_COMPILE_DEPTH in types.h). + uint16_t compile_depth = 0; + + // Live-edit: slot count at build start (to detect fresh allocations vs pre-existing) + uint16_t live_slot_count_at_start = 0; + // Live-edit ids seen during this build (duplicate detection within one graph). + // Capped at 64 per single output — a single output won't have 256 live-edits. + static constexpr uint16_t MAX_IDS_PER_BUILD = 64; + char live_edit_ids_seen[MAX_IDS_PER_BUILD][MAX_LIVE_SLOT_ID] = {}; + uint16_t live_edit_ids_count = 0; + // Cross-output shared ID tracking (set by build_output_graph when provided) + SharedLiveEditIDs* shared_live_edit_ids = nullptr; + + struct LiveSlotUndo { + uint16_t slot_index = 0; + Sample value = 0.0; + Sample min_val = 0.0; + Sample max_val = 1.0; + Sample seed = 0.0; + }; + static constexpr uint16_t MAX_LIVE_SLOT_UNDO = + MAX_LIVE_SLOTS < MAX_IDS_PER_BUILD ? MAX_LIVE_SLOTS : MAX_IDS_PER_BUILD; + LiveSlotUndo live_slot_undo[MAX_LIVE_SLOT_UNDO] = {}; + uint16_t live_slot_undo_count = 0; + + // Context flag: when true, compile_live_edit emits an error + bool reject_live_edit = false; + + // Anonymous state identity (state-identity.md §2.5): which program this + // build compiles (output index, or MAX_OUTPUTS + state slot for defstate + // update graphs), and the ordinal of the next anonymous state allocation + // within this build. Together they form a structural/positional key so + // recompiles of the same program REUSE state slots via the registry + // instead of leaking a fresh slot per compile. + uint16_t anon_state_context = ANON_STATE_CONTEXT_NONE; + uint16_t anon_state_ordinal = 0; + + // Duplicate-active-:id detection (state-identity.md §5.3/§8.1): slots + // claimed with an update root during THIS build. Two stateful forms in + // one compiled graph resolving to the same StateResourceKey would both + // install update roots on the same slot — ambiguous, must be rejected. + bool slot_claimed_this_build[MAX_STATE_SLOTS] = {}; + + // ── Well-known symbol IDs (populated at init) ─────────────────────── + // Generated from symbols.def — do not edit by hand. + struct Symbols { + #define SYM(field, str, cat) SymbolID field; + #include "symbols.def" + #undef SYM + }; + static Symbols sym; + static void init_symbols(); + static bool symbols_initialized; + + static constexpr uint16_t MAX_OPERATOR_DECLARATIONS = 192; + static OperatorDeclaration operator_declarations[MAX_OPERATOR_DECLARATIONS]; + static uint16_t operator_declaration_count; + static const OperatorDeclaration* find_operator_declaration(SymbolID symbol); + static NamespacedOperator resolve_namespaced_operator(SymbolID symbol); + static bool namespace_applies(OperatorNamespace name_space, + const OperatorDeclaration& declaration); + + // ── Form dispatch table ───────────────────────────────────────────── + struct FormEntry { + SymbolID sym; + uint16_t (GraphBuilder::*handler)(TokenStream&, Scope&, TimeContext&); + }; + static constexpr uint16_t FORM_TABLE_CAPACITY = 64; + static FormEntry form_table[FORM_TABLE_CAPACITY]; + static uint16_t form_table_count; + static bool form_table_sorted; + static void init_form_table(); + + // ── Construction ──────────────────────────────────────────────────── + + GraphBuilder(NodePool& pool, CellStore& cells, const SourceArena& source); + + // ── Compilation entry points ──────────────────────────────────────── + + uint16_t compile_expr(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_inline_lambda_call(TokenStream& ts, Scope& scope, + TimeContext& ctx); + uint16_t compile_symbol(SymbolID sym, Scope& scope, TimeContext& ctx, + uint16_t span_start, uint16_t span_len); + uint16_t compile_form(SymbolID op, TokenStream& ts, Scope& scope, + TimeContext& ctx, Token op_tok); + uint16_t compile_namespaced_form(const NamespacedOperator& resolved, + TokenStream& ts, Scope& scope, + TimeContext& ctx, Token op_tok); + + // ── Form handlers ─────────────────────────────────────────────────── + + // Time transforms + uint16_t compile_fast(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_slow(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_offset(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_loop_at(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_eval_at_time(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // Control flow + uint16_t compile_if(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_let(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_do(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_for(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_while_gate(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_lambda(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // Output feedback + uint16_t compile_prev(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // User-defined function call + uint16_t compile_call(SymbolID fn_sym, TokenStream& ts, Scope& scope, + TimeContext& ctx, Token op_tok); + + // Arithmetic and math + uint16_t compile_variadic_arithmetic(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx); + uint16_t compile_comparison(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx); + uint16_t compile_logic(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx); + uint16_t compile_unary_math(NodeOp op, TokenStream& ts, + Scope& scope, TimeContext& ctx); + uint16_t compile_binary_math(NodeOp op, TokenStream& ts, + Scope& scope, TimeContext& ctx); + uint16_t compile_binary_math_swapped(NodeOp op, TokenStream& ts, + Scope& scope, TimeContext& ctx); + uint16_t compile_ternary_math(NodeOp op, TokenStream& ts, + Scope& scope, TimeContext& ctx); + + // Domain-specific signal functions + uint16_t compile_step(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_gates(SymbolID op, TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_euclid(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_seq(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_interp(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_dm(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_gatesw(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_range(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // Ratio-rhythm functions + uint16_t compile_rpulse(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_rstep(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_ridx(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_rwarp(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // State + uint16_t compile_integrate(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // UGens + uint16_t compile_phasor(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_lfo(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_blfo(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_lfo_sin(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_lfo_tri(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_lfo_saw(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_lfo_sqr(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_slew(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_one_pole(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_env_follow(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_sah(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_noise(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_toggle(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_count(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // UGen helpers + uint16_t alloc_state_slot(Sample init_value); + uint16_t report_unknown_ugen_keyword(const Token& kw); + uint16_t resolve_or_alloc(StateID state_id, ResourceKind kind, uint8_t role, + Sample init_value); + uint16_t build_lfo(TokenStream& ts, Scope& scope, TimeContext& ctx, uint16_t default_wave); + uint16_t build_osc_output(uint16_t state_load, uint16_t wave_type, uint16_t pw_node); + uint16_t compile_lfo_namespace(SymbolID waveform, bool bipolar, + TokenStream& ts, Scope& scope, + TimeContext& ctx, Token op_tok); + uint16_t compile_waveform(SymbolID waveform, OperatorNamespace name_space, + TokenStream& ts, Scope& scope, + TimeContext& ctx, Token op_tok); + uint16_t compile_cold_namespace(SymbolID op, TokenStream& ts, + Scope& scope, TimeContext& ctx, + Token op_tok); + + // Live-edit + uint16_t compile_live_edit(TokenStream& ts, Scope& scope, TimeContext& ctx); + void remember_live_slot(uint16_t slot_index); + void rollback_live_slots(); + + // Random / hash + uint16_t compile_random(TokenStream& ts, Scope& scope, TimeContext& ctx); + uint16_t compile_index_rand(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // Vector literal [1 2 3] + uint16_t compile_vector_literal(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // ── Temporal templates ────────────────────────────────────────────── + + uint16_t expand_beat(TimeContext& ctx); + uint16_t expand_bar(TimeContext& ctx); + uint16_t expand_phrase(TimeContext& ctx); + uint16_t expand_section(TimeContext& ctx); + uint16_t expand_beat_num(TimeContext& ctx); + uint16_t expand_bar_num(TimeContext& ctx); + + // ── Collection resolution (for `for` loops) ───────────────────────── + + struct Collection { + uint16_t element_nodes[64] = {}; + uint16_t count = 0; + bool ok = false; + }; + Collection resolve_collection(TokenStream& ts, Scope& scope, TimeContext& ctx); + Collection resolve_literal_collection(TokenStream& ts, Scope& scope, + TimeContext& ctx); + Collection resolve_symbol_collection(TokenStream& ts); + Collection resolve_range_collection(TokenStream& ts, Scope& scope, + TimeContext& ctx); + void fill_range_collection(Collection& collection, Sample start, + Sample end, Sample step); + + // ── Data table resolution ─────────────────────────────────────────── + + struct DataRef { + uint16_t table_id; + uint16_t length; + bool ok; + }; + DataRef resolve_data_table(TokenStream& ts, Scope& scope, TimeContext& ctx); + + // ── Dependency tracking ───────────────────────────────────────────── + + void add_dependency(SymbolID sym); + + // ── Inline stack ──────────────────────────────────────────────────── + + bool is_in_inline_stack(SymbolID sym) const; + void push_inline_stack(SymbolID sym); + void pop_inline_stack(); + + // ── Inline expression cell ────────────────────────────────────────── + + uint16_t inline_expression_cell(SymbolID sym, const CallableInfo& info, + Scope& scope, TimeContext& ctx); + + // ── Error reporting ───────────────────────────────────────────────── + + uint16_t report_error(const Token& tok, const char* message, + const char* suggestion = nullptr); + uint16_t report_error_at(uint16_t span_start, uint16_t span_len, + const char* message, const char* suggestion = nullptr); + + // Category-aware error reporting — prefer these over the Runtime-defaulting overloads + uint16_t report_error_cat(DiagnosticCategory cat, const Token& tok, + const char* message, const char* suggestion = nullptr); + uint16_t report_error_at_cat(DiagnosticCategory cat, + uint16_t span_start, uint16_t span_len, + const char* message, const char* suggestion = nullptr); + + uint16_t report_error_with_fuzzy_match(SymbolID sym, + uint16_t span_start, uint16_t span_len); + uint16_t report_warning(uint16_t span_start, uint16_t span_len, + const char* message, const char* suggestion = nullptr); + + // ── Helpers ───────────────────────────────────────────────────────── + + bool is_const(uint16_t node_idx) const; + Sample const_value(uint16_t node_idx) const; + bool is_side_effect_form(SymbolID op) const; + bool is_arithmetic_op(SymbolID op) const; + bool is_comparison_op(SymbolID op) const; + bool is_logic_op(SymbolID op) const; + bool is_unary_math(SymbolID op) const; + bool is_binary_math(SymbolID op) const; + bool is_ternary_math(SymbolID op) const; + static bool is_output_symbol(SymbolID op); + static uint16_t resolve_output_index(SymbolID op); + static uint16_t resolve_hardware_input(SymbolID sym); + NodeOp arithmetic_sym_to_op(SymbolID op) const; + NodeOp comparison_sym_to_op(SymbolID op) const; + NodeOp unary_sym_to_op(SymbolID op) const; + + // Skip past a complete form in the token stream (for deferred parsing). + static void skip_form(TokenStream& ts); + void report_oversized_vector(TokenStream& ts, const Token& excess); +}; + +// Shared "Too many state variables (max N)" message with the build's actual +// MAX_STATE_SLOTS cap baked in (16 on firmware, 32 on desktop/WASM). +const char* state_slots_exhausted_msg(); + +// ── Top-level build function ──────────────────────────────────────────────── + +GraphBuildResult build_output_graph( + NodePool& pool, + TokenStream& ts, + CellStore& cells, + const SourceArena& source, + const char* source_base = nullptr, + StateResourceRegistry* registry = nullptr, + SharedLiveEditIDs* shared_ids = nullptr, + uint16_t anon_state_context = ANON_STATE_CONTEXT_NONE +); + +} // namespace sig + +#endif // SIGNAL_ENGINE_GRAPH_BUILDER_H diff --git a/src/signal_engine/node_pool.cpp b/src/signal_engine/node_pool.cpp new file mode 100644 index 0000000..8950bd5 --- /dev/null +++ b/src/signal_engine/node_pool.cpp @@ -0,0 +1,517 @@ +#include "node_pool.h" +#include "eval_ops.h" +#include +#include +#include + +namespace sig { + +// ── Constant folding evaluation ───────────────────────────────────────────── +// Thin wrappers that delegate to the shared eval_ops.h functions. + +Sample eval_unary(NodeOp op, Sample a) { return eval_unary_op(op, a); } +Sample eval_binop(NodeOp op, Sample a, Sample b) { return eval_binary_op(op, a, b); } +Sample eval_ternary(NodeOp op, Sample a, Sample b, Sample c) { return eval_ternary_op(op, a, b, c); } + +// ── OutputDeps ────────────────────────────────────────────────────────────── + +void OutputDeps::clear() { + count = 0; + slot_count = 0; +} + +void OutputDeps::add(CellIndex cell_index) { + // Deduplicate + for (uint8_t i = 0; i < count; i++) { + if (cells[i] == cell_index) return; + } + if (count < MAX_OUTPUT_DEPS) { + cells[count++] = cell_index; + } +} + +bool OutputDeps::contains(SymbolID sym) const { + if (sym >= MAX_CELLS) return false; + for (uint8_t i = 0; i < count; i++) { + if (cells[i] == static_cast(sym)) return true; + } + return false; +} + +void OutputDeps::add_slot(uint16_t slot_index) { + // Deduplicate + for (uint16_t i = 0; i < slot_count; i++) { + if (slots[i] == slot_index) return; + } + if (slot_count < MAX_LIVE_SLOTS) { + slots[slot_count++] = slot_index; + } +} + +bool OutputDeps::contains_slot(uint16_t slot_index) const { + for (uint16_t i = 0; i < slot_count; i++) { + if (slots[i] == slot_index) return true; + } + return false; +} + +// ── Hashing for CSE ───────────────────────────────────────────────────────── + +static uint32_t hash_node(const Node& n) { + // FNV-1a hash of the node's identity fields + uint32_t h = 2166136261u; + auto mix = [&](uint8_t byte) { h ^= byte; h *= 16777619u; }; + + mix((uint8_t)n.op); + mix((uint8_t)(n.input_a & 0xFF)); + mix((uint8_t)(n.input_a >> 8)); + mix((uint8_t)(n.input_b & 0xFF)); + mix((uint8_t)(n.input_b >> 8)); + mix((uint8_t)(n.input_c & 0xFF)); + mix((uint8_t)(n.input_c >> 8)); + + // Hash the immediate value bytes + const uint8_t* imm_bytes = reinterpret_cast(&n.imm); + for (int i = 0; i < 8; i++) mix(imm_bytes[i]); + + return h; +} + +static bool nodes_equal(const Node& a, const Node& b) { + return a.op == b.op + && a.input_a == b.input_a + && a.input_b == b.input_b + && a.input_c == b.input_c + && memcmp(&a.imm, &b.imm, sizeof(Sample)) == 0; +} + +// ── NodePool ──────────────────────────────────────────────────────────────── + +uint16_t NodePool::intern_node(const Node& n) { + uint32_t h = hash_node(n); + uint32_t slot = h % CSE_TABLE_SIZE; + + // Open-addressing probe + for (uint32_t probe = 0; probe < CSE_TABLE_SIZE; probe++) { + uint32_t idx = (slot + probe) % CSE_TABLE_SIZE; + if (cse_hashes[idx] == 0) { + // Empty slot — insert + if (node_count >= MAX_TOTAL_NODES) return NODE_NONE; // pool full + uint16_t ni = node_count++; + nodes[ni] = n; + cse_hashes[idx] = h | 1; // ensure non-zero (mark occupied) + cse_indices[idx] = ni; + return ni; + } + if (cse_hashes[idx] == (h | 1) && nodes_equal(nodes[cse_indices[idx]], n)) { + return cse_indices[idx]; // CSE hit + } + } + // Table full (shouldn't happen with 2x load factor) + if (node_count >= MAX_TOTAL_NODES) return NODE_NONE; + uint16_t ni = node_count++; + nodes[ni] = n; + return ni; +} + +uint16_t NodePool::make_const(Sample value) { + Node n; + n.op = NodeOp::Const; + n.flags = FLAG_TIME_INVARIANT; + n.imm = value; + return intern_node(n); +} + +uint16_t NodePool::make_raw_time_load() { + Node n; + n.op = NodeOp::RawTimeLoad; + n.flags = 0; // time-varying by definition + return intern_node(n); +} + +uint16_t NodePool::make_cell_load(SymbolID cell_id) { + Node n; + n.op = NodeOp::CellLoad; + n.flags = FLAG_TIME_INVARIANT; // cells don't change per-sample + n.imm = (Sample)cell_id; + return intern_node(n); +} + +uint16_t NodePool::make_input_load(uint16_t input_index) { + Node n; + n.op = NodeOp::InputLoad; + n.flags = 0; // hardware inputs can change per-sample + n.imm = (Sample)input_index; + return intern_node(n); +} + +uint16_t NodePool::make_prev_output_load(uint16_t output_index) { + Node n; + n.op = NodeOp::PrevOutputLoad; + n.flags = 0; + n.imm = (Sample)output_index; + return intern_node(n); +} + +uint16_t NodePool::make_state_load(uint16_t state_slot) { + Node n; + n.op = NodeOp::LoadState; + n.flags = 0; // state is time-varying (changes each tick) + n.imm = (Sample)state_slot; + return intern_node(n); +} + +uint16_t NodePool::make_slot_load(uint16_t slot_index) { + Node n; + n.op = NodeOp::SlotLoad; + n.flags = 0; // live slots change externally per tick + n.imm = (Sample)slot_index; + return intern_node(n); +} + +int16_t NodePool::find_live_slot(const char* id) const { + for (uint16_t i = 0; i < live_slot_count; i++) { + if (strncmp(live_slots[i].id, id, MAX_LIVE_SLOT_ID) == 0) { + return (int16_t)i; + } + } + return -1; +} + +int16_t NodePool::alloc_live_slot(const char* id, Sample seed, Sample min_val, Sample max_val, + SlotVariant variant, Sample step, int precision, + uint16_t owner_context) { + int16_t existing = find_live_slot(id); + if (existing >= 0) { + live_slots[existing].min_val = min_val; + live_slots[existing].max_val = max_val; + live_slots[existing].seed = seed; + live_slots[existing].variant = variant; + live_slots[existing].step = step; + live_slots[existing].precision = precision; + // Reclamp existing value to new bounds (numeric only) + if (variant == SlotVariant::Numeric) { + Sample& v = live_slots[existing].value; + if (v < min_val) v = min_val; + if (v > max_val) v = max_val; + } else if (variant == SlotVariant::Boolean) { + Sample& v = live_slots[existing].value; + v = (v != 0.0) ? 1.0 : 0.0; + } + // Keyword: value is index into options, validated by caller + return existing; + } + if (live_slot_count >= MAX_LIVE_SLOTS) return -1; + uint16_t idx = live_slot_count++; + strncpy(live_slots[idx].id, id, MAX_LIVE_SLOT_ID - 1); + live_slots[idx].id[MAX_LIVE_SLOT_ID - 1] = '\0'; + live_slots[idx].owner_context = owner_context; + live_slots[idx].value = seed; + live_slots[idx].min_val = min_val; + live_slots[idx].max_val = max_val; + live_slots[idx].seed = seed; + live_slots[idx].variant = variant; + live_slots[idx].step = step; + live_slots[idx].precision = precision; + return (int16_t)idx; +} + +void NodePool::set_live_slot_value(const char* id, Sample value) { + int16_t idx = find_live_slot(id); + if (idx < 0) return; + set_live_slot_value_by_index((uint16_t)idx, value); +} + +void NodePool::set_live_slot_value_by_index(uint16_t idx, Sample value) { + // Bounds-check against the live slot capacity. Out-of-range indices are + // ignored (wire-protocol §6.5: garbage/stale slot_index → skip). + if (idx >= live_slot_count) return; + + // §5.9: reject non-finite numbers — slot retains previous value + if (!std::isfinite(value)) return; + + switch (live_slots[idx].variant) { + case SlotVariant::Numeric: { + // Clamp to [min, max] + Sample clamped = value; + if (clamped < live_slots[idx].min_val) clamped = live_slots[idx].min_val; + if (clamped > live_slots[idx].max_val) clamped = live_slots[idx].max_val; + live_slots[idx].value = clamped; + break; + } + case SlotVariant::Boolean: + // Cast to 0.0 or 1.0 + live_slots[idx].value = (value != 0.0) ? 1.0 : 0.0; + break; + case SlotVariant::Keyword: { + // Validate against options vector length + int index = (int)value; + if (index < 0 || index >= (int)live_slots[idx].options_count) return; + live_slots[idx].value = (Sample)index; + break; + } + } +} + +uint16_t NodePool::make_dt_load() { + Node n; + n.op = NodeOp::LoadDt; + n.flags = 0; // dt varies per tick + return intern_node(n); +} + +uint16_t NodePool::make_unary(NodeOp op, uint16_t a) { + if (a == NODE_NONE) return NODE_NONE; + const Node& na = get(a); + + // Constant folding + if (na.op == NodeOp::Const) { + return make_const(eval_unary(op, na.imm)); + } + + uint8_t flags = na.flags & FLAG_TIME_INVARIANT; + Node n; + n.op = op; + n.flags = flags; + n.input_a = a; + return intern_node(n); +} + +uint16_t NodePool::make_binop(NodeOp op, uint16_t a, uint16_t b) { + if (a == NODE_NONE || b == NODE_NONE) return NODE_NONE; + const Node& na = get(a); + const Node& nb = get(b); + + // Constant folding + if (na.op == NodeOp::Const && nb.op == NodeOp::Const) { + return make_const(eval_binop(op, na.imm, nb.imm)); + } + + // Algebraic simplifications + if (op == NodeOp::Add && nb.op == NodeOp::Const && nb.imm == 0.0) return a; + if (op == NodeOp::Add && na.op == NodeOp::Const && na.imm == 0.0) return b; + if (op == NodeOp::Mul && nb.op == NodeOp::Const && nb.imm == 1.0) return a; + if (op == NodeOp::Mul && na.op == NodeOp::Const && na.imm == 1.0) return b; + // Do not fold x*0 or x-x without a proof that x is finite. IEEE-754 + // gives NaN for Inf*0, NaN*0, Inf-Inf, and NaN-NaN; replacing those + // results with zero would suppress the output-root failure signal and + // change LKG/health semantics. The all-constant case above remains safe + // because it is evaluated through the same primitive as the hot path. + // NOTE: no `Div a a -> 1` fold (A10): IEEE 0/0 and Inf/Inf are NaN and + // must remain visible to output-root health/LKG handling. The constant/ + // constant case is already folded through eval_binop above. + if (op == NodeOp::Div && nb.op == NodeOp::Const && nb.imm == 1.0) return a; + + uint8_t flags = 0; + if ((na.flags & FLAG_TIME_INVARIANT) && (nb.flags & FLAG_TIME_INVARIANT)) { + flags |= FLAG_TIME_INVARIANT; + } + + Node n; + n.op = op; + n.flags = flags; + n.input_a = a; + n.input_b = b; + return intern_node(n); +} + +uint16_t NodePool::make_ternary(NodeOp op, uint16_t a, uint16_t b, uint16_t c) { + if (a == NODE_NONE || b == NODE_NONE || c == NODE_NONE) return NODE_NONE; + const Node& na = get(a); + const Node& nb = get(b); + const Node& nc = get(c); + + // Constant folding + if (na.op == NodeOp::Const && nb.op == NodeOp::Const && nc.op == NodeOp::Const) { + return make_const(eval_ternary(op, na.imm, nb.imm, nc.imm)); + } + + // Select with constant condition + if (op == NodeOp::Select && na.op == NodeOp::Const) { + return (na.imm != 0.0) ? b : c; + } + + uint8_t flags = 0; + if ((na.flags & FLAG_TIME_INVARIANT) && (nb.flags & FLAG_TIME_INVARIANT) + && (nc.flags & FLAG_TIME_INVARIANT)) { + flags |= FLAG_TIME_INVARIANT; + } + + Node n; + n.op = op; + n.flags = flags; + n.input_a = a; + n.input_b = b; + n.input_c = c; + return intern_node(n); +} + +uint16_t NodePool::make_select(uint16_t cond, uint16_t true_val, uint16_t false_val) { + return make_ternary(NodeOp::Select, cond, true_val, false_val); +} + +// ── Topological sort ──────────────────────────────────────────────────────── + +static void mark_reachable_nodes(const NodePool& pool, bool* marked) { + memset(marked, 0, MAX_TOTAL_NODES * sizeof(bool)); + uint16_t stack[MAX_TOTAL_NODES]; + uint16_t stack_top = 0; + + // Mark on discovery, before enqueueing. Mark-on-pop allows a shared child + // to occupy the pending stack once per incoming edge; a high-sharing DAG + // can then fill a MAX_TOTAL_NODES stack with duplicates and silently drop + // a genuinely undiscovered dependency. With discovery marking, every + // valid node is pushed at most once, so stack_top is bounded by node_count + // (and node_count itself is bounded by MAX_TOTAL_NODES). + auto discover = [&](uint16_t child) { + if (child == NODE_NONE || child >= pool.node_count || marked[child]) return; + marked[child] = true; + stack[stack_top++] = child; + }; + + for (uint16_t o = 0; o < MAX_OUTPUTS; o++) { + discover(pool.outputs[o].root_node); + } + + // Include state update roots in reachability + for (uint16_t s = 0; s < pool.state_slot_count; s++) { + discover(pool.state_update_roots[s]); + } + +#if USEQ_HAS_SYNTH_ENGINE + // External roots are executable synth-control roots, not merely GC pins. + for (uint16_t e = 0; e < pool.external_root_count; e++) { + discover(pool.external_roots[e]); + } +#endif + + while (stack_top > 0) { + uint16_t idx = stack[--stack_top]; + const Node& n = pool.nodes[idx]; + discover(n.input_a); + discover(n.input_b); + discover(n.input_c); + } +} + +void NodePool::rebuild_execution_order() { + // Mark reachable nodes from every executable/publication root. + bool reachable[MAX_TOTAL_NODES]; + mark_reachable_nodes(*this, reachable); + + // Topological sort via Kahn's algorithm on reachable nodes + // Since node indices are allocated in dependency order (inputs before outputs), + // a simple forward scan of reachable nodes IS a valid topological order. + exec_count = 0; + for (uint16_t i = 0; i < node_count; i++) { + if (reachable[i]) { + exec_order[exec_count++] = i; + } + } +} + +void NodePool::gc_unreachable_nodes() { + // 1. Mark reachable from output roots + bool live[MAX_TOTAL_NODES]; + mark_reachable_nodes(*this, live); + + // 2. Build remap table and compact live nodes to front + uint16_t remap[MAX_TOTAL_NODES]; + memset(remap, 0xFF, sizeof(remap)); // NODE_NONE default + uint16_t new_count = 0; + for (uint16_t i = 0; i < node_count; i++) { + if (live[i]) { + remap[i] = new_count; + if (new_count != i) nodes[new_count] = nodes[i]; + new_count++; + } + } + + // 3. Update references in compacted nodes + for (uint16_t i = 0; i < new_count; i++) { + if (nodes[i].input_a != NODE_NONE) nodes[i].input_a = remap[nodes[i].input_a]; + if (nodes[i].input_b != NODE_NONE) nodes[i].input_b = remap[nodes[i].input_b]; + if (nodes[i].input_c != NODE_NONE) nodes[i].input_c = remap[nodes[i].input_c]; + } + + // 4. Update output roots + for (uint16_t o = 0; o < MAX_OUTPUTS; o++) { + if (outputs[o].root_node != NODE_NONE) + outputs[o].root_node = remap[outputs[o].root_node]; + } + + // 4b. Update state update roots + for (uint16_t s = 0; s < state_slot_count; s++) { + if (state_update_roots[s] != NODE_NONE) + state_update_roots[s] = remap[state_update_roots[s]]; + } + +#if USEQ_HAS_SYNTH_ENGINE + // 4c. Update host synth control roots. + for (uint16_t e = 0; e < external_root_count; e++) { + if (external_roots[e] != NODE_NONE) + external_roots[e] = remap[external_roots[e]]; + } +#endif + + node_count = new_count; + + // 5. Rebuild CSE table from scratch (indices changed) + memset(cse_hashes, 0, sizeof(cse_hashes)); + memset(cse_indices, 0, sizeof(cse_indices)); + for (uint16_t i = 0; i < node_count; i++) { + uint32_t h = hash_node(nodes[i]); + uint32_t slot = h % CSE_TABLE_SIZE; + for (uint32_t probe = 0; probe < CSE_TABLE_SIZE; probe++) { + uint32_t idx = (slot + probe) % CSE_TABLE_SIZE; + if (cse_hashes[idx] == 0) { + cse_hashes[idx] = h | 1; + cse_indices[idx] = i; + break; + } + } + } +} + +void NodePool::reset() { + for (uint16_t i = 0; i < MAX_TOTAL_NODES; i++) nodes[i] = Node{}; + node_count = 0; + memset(exec_order, 0, sizeof(exec_order)); + exec_count = 0; + memset(cse_hashes, 0, sizeof(cse_hashes)); + memset(cse_indices, 0, sizeof(cse_indices)); + for (uint16_t i = 0; i < MAX_OUTPUTS; i++) { + outputs[i] = OutputSlot{}; + output_deps[i] = OutputDeps{}; + } + memset(output_class, 0, sizeof(output_class)); + memset(output_input_mask, 0, sizeof(output_input_mask)); + memset(prev_output_values, 0, sizeof(prev_output_values)); + runtime_fallback_mask = 0; + state_update_failure_mask = 0; + memset(state_values, 0, sizeof(state_values)); + for (uint16_t s = 0; s < MAX_STATE_SLOTS; s++) { + state_update_roots[s] = NODE_NONE; + state_owner_context[s] = NODE_NONE; + } + state_slot_count = 0; + for (uint16_t i = 0; i < MAX_LIVE_SLOTS; i++) + live_slots[i] = LiveSlot{}; + live_slot_count = 0; +#if USEQ_HAS_SYNTH_ENGINE + memset(external_roots, 0, sizeof(external_roots)); + external_root_count = 0; +#endif +} + +void NodePool::allocate_batch_workspace() { + if (!batch_workspace) { + batch_workspace.reset(new Sample[MAX_TOTAL_NODES * batch_chunk_size]); + } +} + +void NodePool::free_batch_workspace() { + batch_workspace.reset(); +} + +} // namespace sig diff --git a/src/signal_engine/node_pool.h b/src/signal_engine/node_pool.h new file mode 100644 index 0000000..decfd1e --- /dev/null +++ b/src/signal_engine/node_pool.h @@ -0,0 +1,275 @@ +#ifndef SIGNAL_ENGINE_NODE_POOL_H +#define SIGNAL_ENGINE_NODE_POOL_H + +#include "types.h" +#include + +namespace sig { + +constexpr uint16_t LIVE_SLOT_OWNER_NONE = 0xFFFF; + +// ── Node Operations ───────────────────────────────────────────────────────── + +enum class NodeOp : uint8_t { + // Constants and loads + Const, // imm = value + RawTimeLoad, // loads the single 't' input + CellLoad, // imm = cell_id + InputLoad, // imm = input_index (hardware input channel) + PrevOutputLoad, // imm = output_index; previous tick's value + + // Binary arithmetic + Add, Sub, Mul, Div, Mod, Expt, Min, Max, + + // Unary math + Neg, Abs, Floor, Ceil, Frac, Sqrt, Clamp, + + // Trigonometry + Sin, Cos, Tan, + + // Domain waveforms (operate on phase [0,1)) + USin, UCos, + Tri, Sqr, Pulse, + + // Comparison (1.0 true, 0.0 false) + CmpGt, CmpLt, CmpGe, CmpLe, CmpEq, + + // Logic + Not, And, Or, + + // Control flow + Select, // input_a=cond, input_b=true_val, input_c=false_val + + // Vector/data + VecIndex, // input_a=fractional_index, imm=table_id; floor, wrap + VecLerp, // input_a=fractional_index, imm=table_id; lerp + + // Range conversion + BiToUni, // [-1,1] → [0,1] + UniToBi, // [0,1] → [-1,1] + Scale, // 3 inputs: value, out_min, out_max ([0,1]→[min,max]) + Lerp, // 3 inputs: a, b, t → a + (b-a)*t + + // Deterministic hash (pure function of input) + HashIndex, // input_a = index; deterministic hash → [0,1] + + // Cross-sample state + LoadState, // imm = state_slot; reads state_values[slot] + LoadDt, // reads dt (time delta since last tick) + + // Live-edit slots (externally-driven inputs from editor UI) + SlotLoad, // imm = live_slot_index; reads live_slots[slot].value +}; + +// ── Node ──────────────────────────────────────────────────────────────────── + +struct Node { + NodeOp op = NodeOp::Const; + uint8_t flags = 0; // FLAG_TIME_INVARIANT etc. + uint16_t input_a = NODE_NONE; + uint16_t input_b = NODE_NONE; + uint16_t input_c = NODE_NONE; + Sample imm = 0.0; // immediate value + uint16_t span_start = 0; // source location for diagnostics + uint16_t span_len = 0; +}; +// sizeof(Node) == 20 bytes + +// ── Output Slot ───────────────────────────────────────────────────────────── + +struct OutputSlot { + uint16_t root_node = NODE_NONE; + Sample lkg_value = 0.0; // last known good output value + // `valid` is retained as the active-assignment bit for source/API + // compatibility. It does NOT mean that a healthy sample has been + // observed: a freshly compiled program is active before its first tick. + bool valid = false; + bool has_lkg = false; // at least one finite root was committed + uint8_t pad[6] = {}; +}; + +// ── Per-Output Dependencies ───────────────────────────────────────────────── +// Populated during graph construction. Used for dirty recompilation. + +struct OutputDeps { + CellIndex cells[MAX_OUTPUT_DEPS] = {}; + uint8_t count = 0; + + // Live-edit slot indices referenced by this output's graph. + // slot_count must be wide enough to actually reach MAX_LIVE_SLOTS: on the + // WASM build MAX_LIVE_SLOTS == 256, which a uint8_t can never represent, so + // a `slot_count < MAX_LIVE_SLOTS` guard with a uint8_t counter is always + // true and never fires. uint16_t covers both the firmware (16) and WASM + // (256) capacities. + uint16_t slots[MAX_LIVE_SLOTS] = {}; + uint16_t slot_count = 0; + + void clear(); + void add(CellIndex cell_index); + bool contains(SymbolID sym) const; + + void add_slot(uint16_t slot_index); + bool contains_slot(uint16_t slot_index) const; +}; + +// ── Node Pool ─────────────────────────────────────────────────────────────── + +struct NodePool { + Node nodes[MAX_TOTAL_NODES] = {}; + uint16_t node_count = 0; + + // Bounds-checked node accessor. Returns a static null node for + // NODE_NONE or out-of-range indices, avoiding undefined behaviour. + const Node& get(uint16_t idx) const { + static const Node null_node{}; + if (idx == NODE_NONE || idx >= node_count) return null_node; + return nodes[idx]; + } + + // Hash-cons CSE table + uint32_t cse_hashes[CSE_TABLE_SIZE] = {}; + uint16_t cse_indices[CSE_TABLE_SIZE] = {}; + + // Topologically sorted execution order + uint16_t exec_order[MAX_TOTAL_NODES] = {}; + uint16_t exec_count = 0; + + // Per-output metadata + OutputSlot outputs[MAX_OUTPUTS] = {}; + OutputDeps output_deps[MAX_OUTPUTS] = {}; + + // Per-output classification (recomputed after each eval) + OutputClass output_class[MAX_OUTPUTS] = {}; + uint32_t output_input_mask[MAX_OUTPUTS] = {}; // bitmask of hw input channels referenced + + // Cross-output reads use previous-tick values + Sample prev_output_values[MAX_OUTPUTS] = {}; + + // Runtime fallback tracking (failure-model.md §2.1/§5): bit i is set when + // output i substituted its LKG value on the most recent execution pass + // because a non-finite value reached its root (FailureMode::LkgFallback + // only). Recomputed on every pass; mutable because execution paths take + // `const NodePool&` — this is diagnostic bookkeeping, not graph state. + mutable uint64_t runtime_fallback_mask = 0; + static_assert(MAX_OUTPUTS <= 64, "runtime_fallback_mask is 64-bit"); + + // Bit s is active when state slot s most recently produced a non-finite + // update candidate. The previous finite state remains installed until + // that same update root recovers. This is separate from output fallback: + // a named defstate can fail while every consuming output remains finite. + uint64_t state_update_failure_mask = 0; + static_assert(MAX_STATE_SLOTS <= 64, + "state_update_failure_mask is 64-bit"); + + // ── Cross-sample state ────────────────────────────────────────────── + Sample state_values[MAX_STATE_SLOTS] = {}; // current state (read during execution) + uint16_t state_update_roots[MAX_STATE_SLOTS] = {}; // root node for each state's update expr (init to 0, set to NODE_NONE by init) + // Compiler context owning the sole update writer. Output contexts are + // 0..MAX_OUTPUTS-1; other live state sources use disjoint contexts. + // Runtime fallback freezes only state owned by its failed output. + uint16_t state_owner_context[MAX_STATE_SLOTS] = {}; + uint16_t state_slot_count = 0; + + // ── Live-edit slots (externally written by editor UI) ───────────── + + enum class SlotVariant : uint8_t { Numeric = 0, Boolean = 1, Keyword = 2 }; + + struct LiveSlot { + char id[MAX_LIVE_SLOT_ID] = {}; + uint16_t owner_context = LIVE_SLOT_OWNER_NONE; + Sample value = 0.0; + Sample min_val = 0.0; + Sample max_val = 1.0; + Sample seed = 0.0; + SlotVariant variant = SlotVariant::Numeric; + Sample step = 0.0; + int precision = -1; // -1 means unset + char options[MAX_LIVE_SLOT_OPTIONS][MAX_LIVE_SLOT_OPTION_LEN] = {}; + uint8_t options_count = 0; + }; + LiveSlot live_slots[MAX_LIVE_SLOTS] = {}; + uint16_t live_slot_count = 0; + + int16_t find_live_slot(const char* id) const; + int16_t alloc_live_slot(const char* id, Sample seed, Sample min_val, Sample max_val, + SlotVariant variant = SlotVariant::Numeric, + Sample step = 0.0, int precision = -1, + uint16_t owner_context = LIVE_SLOT_OWNER_NONE); + void set_live_slot_value(const char* id, Sample value); + // Fast-path write addressed by array index (wire-protocol §6.5 binary + // INPUT_SET). `idx` is a direct index into live_slots[0..live_slot_count). + // Out-of-range indices are ignored. Applies the same clamp / variant + // coercion as set_live_slot_value(), but skips the string lookup so it is + // allocation-free and cheap on the serial RX hot path. + void set_live_slot_value_by_index(uint16_t idx, Sample value); + + // WASM batch workspace (heap-allocated once at init, null on firmware) + std::unique_ptr batch_workspace; + uint16_t batch_chunk_size = BATCH_CHUNK_SIZE; + +#if USEQ_HAS_SYNTH_ENGINE + // ── External root registration (host synth control roots) ─────────── + // Some compiled roots live outside the per-output table — notably + // synth control channel expressions (synth-nodes.md §7.2). These roots + // must participate in GC reachability and remap exactly like output + // roots, otherwise forced GC after a synth eval drops the control + // expressions (VAL-COMP-011). External clients register root indices + // here; the GC walks and remaps them. The pool itself does not attach + // semantics to these slots. + static constexpr uint16_t MAX_EXTERNAL_ROOTS = MAX_SYNTH_CONTROL_ROOTS; + uint16_t external_roots[MAX_EXTERNAL_ROOTS] = {}; + uint16_t external_root_count = 0; + + void clear_external_roots() { external_root_count = 0; } + bool register_external_root(uint16_t node_idx) { + if (external_root_count >= MAX_EXTERNAL_ROOTS) return false; + external_roots[external_root_count++] = node_idx; + return true; + } +#endif + + // ── Node construction (with CSE + constant folding) ───────────────── + + uint16_t make_const(Sample value); + uint16_t make_raw_time_load(); + uint16_t make_cell_load(SymbolID cell_id); + uint16_t make_input_load(uint16_t input_index); + uint16_t make_prev_output_load(uint16_t output_index); + + uint16_t make_state_load(uint16_t state_slot); + uint16_t make_slot_load(uint16_t slot_index); + uint16_t make_dt_load(); + + uint16_t make_unary(NodeOp op, uint16_t a); + uint16_t make_binop(NodeOp op, uint16_t a, uint16_t b); + uint16_t make_ternary(NodeOp op, uint16_t a, uint16_t b, uint16_t c); + uint16_t make_select(uint16_t cond, uint16_t true_val, uint16_t false_val); + + // ── Pool operations ───────────────────────────────────────────────── + + // Hash-cons: intern a node, return index (reuse if identical exists). + uint16_t intern_node(const Node& n); + + // Rebuild topological execution order from live output roots. + void rebuild_execution_order(); + + // Remove nodes not reachable from any output root. + void gc_unreachable_nodes(); + + // Reset the entire pool. + void reset(); + + // Allocate batch workspace (call once at init for WASM builds). + void allocate_batch_workspace(); + void free_batch_workspace(); +}; + +// ── Constant folding helpers ──────────────────────────────────────────────── + +Sample eval_unary(NodeOp op, Sample a); +Sample eval_binop(NodeOp op, Sample a, Sample b); +Sample eval_ternary(NodeOp op, Sample a, Sample b, Sample c); + +} // namespace sig + +#endif // SIGNAL_ENGINE_NODE_POOL_H diff --git a/src/signal_engine/signal_engine.h b/src/signal_engine/signal_engine.h new file mode 100644 index 0000000..f48771f --- /dev/null +++ b/src/signal_engine/signal_engine.h @@ -0,0 +1,15 @@ +#ifndef SIGNAL_ENGINE_H +#define SIGNAL_ENGINE_H + +// Convenience header: includes all signal engine components. + +#include "types.h" +#include "diagnostics.h" +#include "token.h" +#include "cell_store.h" +#include "node_pool.h" +#include "graph_builder.h" +#include "executor.h" +#include "cold_eval.h" + +#endif // SIGNAL_ENGINE_H diff --git a/src/signal_engine/state_registry.cpp b/src/signal_engine/state_registry.cpp new file mode 100644 index 0000000..da45614 --- /dev/null +++ b/src/signal_engine/state_registry.cpp @@ -0,0 +1,113 @@ +#include "state_registry.h" +#include + +namespace sig { + +uint16_t StateResourceRegistry::resolve(const StateResourceKey& key, + Sample init_value, + Sample* state_values, + uint16_t& state_slot_count, + uint16_t owner_context) { + last_owner_conflict = false; + // Search for existing entry + for (uint16_t i = 0; i < entry_count; i++) { + if (entries[i].key == key) { + if (owner_context != ANON_STATE_CONTEXT_NONE && + entries[i].owner_context != ANON_STATE_CONTEXT_NONE && + entries[i].owner_context != owner_context) { + last_owner_conflict = true; + return NODE_NONE; + } + if (entries[i].owner_context == ANON_STATE_CONTEXT_NONE) { + entries[i].owner_context = owner_context; + } + entries[i].active = true; + return entries[i].slot_index; + } + } + + // New entry — prefer a slot retired by a previously published graph. + if (entry_count >= MAX_STATE_SLOTS) return NODE_NONE; + uint16_t slot = take_free_slot(); + if (slot == NODE_NONE) { + if (state_slot_count >= MAX_STATE_SLOTS) return NODE_NONE; + slot = state_slot_count++; + } + state_values[slot] = init_value; + + entries[entry_count].key = key; + entries[entry_count].slot_index = slot; + entries[entry_count].owner_context = owner_context; + entries[entry_count].init_value = init_value; + entries[entry_count].active = true; + entry_count++; + + return slot; +} + +void StateResourceRegistry::begin_context(uint16_t owner_context) { + if (owner_context == ANON_STATE_CONTEXT_NONE) return; + for (uint16_t i = 0; i < entry_count; i++) { + if (entries[i].owner_context == owner_context) { + entries[i].active = false; + } + } +} + +void StateResourceRegistry::commit_context( + uint16_t owner_context, uint16_t* state_update_roots, + uint16_t* state_owner_contexts) { + if (owner_context == ANON_STATE_CONTEXT_NONE) return; + + uint16_t write = 0; + for (uint16_t read = 0; read < entry_count; read++) { + StateResourceEntry& entry = entries[read]; + if (entry.owner_context == owner_context && !entry.active) { + if (entry.slot_index < MAX_STATE_SLOTS) { + state_update_roots[entry.slot_index] = NODE_NONE; + state_owner_contexts[entry.slot_index] = + ANON_STATE_CONTEXT_NONE; + // Each live registry entry owns a unique slot. Avoid adding a + // duplicate defensively so a malformed legacy image cannot + // make the same slot available twice. + bool already_free = false; + for (uint16_t f = 0; f < free_slot_count; f++) { + if (free_slots[f] == entry.slot_index) { + already_free = true; + break; + } + } + if (!already_free && free_slot_count < MAX_STATE_SLOTS) { + free_slots[free_slot_count++] = entry.slot_index; + } + } + continue; + } + if (write != read) entries[write] = entries[read]; + write++; + } + entry_count = write; +} + +uint16_t StateResourceRegistry::take_free_slot() { + if (free_slot_count == 0) return NODE_NONE; + return free_slots[--free_slot_count]; +} + +void StateResourceRegistry::mark_all_inactive() { + for (uint16_t i = 0; i < entry_count; i++) { + entries[i].active = false; + } +} + +void StateResourceRegistry::clear() { + for (uint16_t i = 0; i < MAX_STATE_SLOTS; i++) { + entries[i] = StateResourceEntry{}; + free_slots[i] = NODE_NONE; + } + entry_count = 0; + free_slot_count = 0; + last_owner_conflict = false; +} + +} // namespace sig diff --git a/src/signal_engine/state_registry.h b/src/signal_engine/state_registry.h new file mode 100644 index 0000000..f412d43 --- /dev/null +++ b/src/signal_engine/state_registry.h @@ -0,0 +1,102 @@ +#ifndef SIGNAL_ENGINE_STATE_REGISTRY_H +#define SIGNAL_ENGINE_STATE_REGISTRY_H + +#include "types.h" + +namespace sig { + +using StateID = SymbolID; + +// ── Anonymous structural identity (state-identity.md §2.5) ───────────────── +// Stateful expressions without an explicit :id get a synthetic StateID derived +// from structural context: which program is being compiled (output index, or +// MAX_OUTPUTS + state slot for defstate update graphs) plus the ordinal +// position of the allocation within that compile. Compilation is +// deterministic over source text, so recompiling a program resolves to the +// same keys and REUSES its slots instead of leaking a fresh slot per +// recompile. Synthetic IDs live far above any interned SymbolID, so they can +// never collide with user-supplied :id symbols. +constexpr StateID ANON_STATE_ID_BASE = 0x80000000u; +constexpr uint16_t ANON_STATE_CONTEXT_NONE = 0xFFFF; + +inline StateID make_anon_state_id(uint16_t context, uint16_t ordinal) { + return ANON_STATE_ID_BASE | ((StateID)context << 12) | (StateID)ordinal; +} + +enum class ResourceKind : uint8_t { + OscillatorPhase, + TriggerMemory, + HeldValue, + ToggleState, + Counter, + ResetLatch, + SlewAccumulator, + Integrator, + OnePole, + EnvelopeFollower, + NoiseCounter, +}; + +struct StateResourceKey { + StateID state_id; + ResourceKind kind; + uint8_t role; + + bool operator==(const StateResourceKey& other) const { + return state_id == other.state_id && + kind == other.kind && + role == other.role; + } +}; + +struct StateResourceEntry { + StateResourceKey key; + uint16_t slot_index; + // Compiler context that owns the sole update writer for this resource. + // Output indices, defstate update contexts, and synth-control contexts + // inhabit one namespace; ANON_STATE_CONTEXT_NONE is reserved for legacy + // callers that do not publish a live graph. + uint16_t owner_context; + Sample init_value; + bool active; +}; + +struct StateResourceRegistry { + StateResourceEntry entries[MAX_STATE_SLOTS]; + uint16_t entry_count = 0; + uint16_t free_slots[MAX_STATE_SLOTS]; + uint16_t free_slot_count = 0; + bool last_owner_conflict = false; + + // Resolve a key to a dense state slot index. If the key already exists, + // returns its slot and preserves accumulated state. If new, allocates a + // fresh slot and writes init_value. Returns NODE_NONE on overflow. + uint16_t resolve(const StateResourceKey& key, Sample init_value, + Sample* state_values, uint16_t& state_slot_count, + uint16_t owner_context = ANON_STATE_CONTEXT_NONE); + + // Begin/commit publication for one compiler context. Existing resources + // owned by the context are marked unseen; resolving them reactivates + // them. On commit, resources no longer present are retired and their + // slots become reusable. A rejected build restores the registry snapshot + // instead of calling commit_context(). + void begin_context(uint16_t owner_context); + void commit_context(uint16_t owner_context, + uint16_t* state_update_roots, + uint16_t* state_owner_contexts); + + // Reclaimed UGen slots can also host a later named defstate. Returns + // NODE_NONE when there is no reusable hole. + uint16_t take_free_slot(); + + // Mark all entries inactive. Used before recompilation — entries that + // remain inactive after compilation are candidates for GC. + void mark_all_inactive(); + + // Full reset (useq-clear). + void clear(); +}; + +} // namespace sig + +#endif // SIGNAL_ENGINE_STATE_REGISTRY_H diff --git a/src/signal_engine/symbols.def b/src/signal_engine/symbols.def new file mode 100644 index 0000000..7126230 --- /dev/null +++ b/src/signal_engine/symbols.def @@ -0,0 +1,310 @@ +// symbols.def — X-macro symbol table for the signal engine graph builder. +// Each line: SYM(field_name, "lisp-string", category) +// +// Categories: +// none — bare symbol, no special dispatch +// arith — variadic arithmetic operator +// cmp — comparison operator +// logic — logic operator +// unary — unary math function +// binary — binary math function +// ternary — ternary math function +// time_warp — time transform +// control — control flow +// signal — domain signal function with custom compilation +// side_effect — side-effect forms (error in signal context) +// waveform — unary phase-to-value waveform functions +// +// Namespace metadata is declared with OP_META after the symbol inventory. +// Each row: OP_META(field, input-domain, output-range, regime, +// cold-evaluable, bare-identity) +// The compiler and reference-data generator derive namespace applicability +// from these declarations; neither owns a hand-curated applicability list. + +// ── Temporal / time symbols ──────────────────────────────────────────────── +SYM(t, "t", none) +SYM(beat, "beat", none) +SYM(bar, "bar", none) +SYM(phrase, "phrase", none) +SYM(section, "section", none) +SYM(beat_num, "beat-num", none) +SYM(bar_num, "bar-num", none) + +// ── Timing parameters ────────────────────────────────────────────────────── +SYM(bpm, "bpm", none) +SYM(beats_per_bar, "beats-per-bar", none) +SYM(bars_per_phrase, "bars-per-phrase", none) +SYM(phrases_per_section, "phrases-per-section", none) + +// ── Arithmetic operators ─────────────────────────────────────────────────── +SYM(plus, "+", arith) +SYM(minus, "-", arith) +SYM(star, "*", arith) +SYM(slash, "/", arith) +SYM(mod_pct, "%", arith) + +// ── Comparison operators ─────────────────────────────────────────────────── +SYM(gt, ">", cmp) +SYM(lt, "<", cmp) +SYM(ge, ">=", cmp) +SYM(le, "<=", cmp) +SYM(eq, "=", cmp) + +// ── Logic operators ──────────────────────────────────────────────────────── +SYM(not_, "not", logic) +SYM(and_, "and", logic) +SYM(or_, "or", logic) + +// ── Time transforms ──────────────────────────────────────────────────────── +SYM(time_as, "time-as", time_warp) +SYM(fast, "fast", time_warp) +SYM(slow, "slow", time_warp) +SYM(offset, "offset", time_warp) +SYM(shift, "shift", time_warp) + +// ── Control flow ─────────────────────────────────────────────────────────── +SYM(if_, "if", control) +SYM(let_, "let", control) +SYM(do_, "do", control) +SYM(for_, "for", control) +SYM(while_, "while", control) +SYM(fn, "fn", control) +SYM(lambda, "lambda", control) +SYM(scope, "scope", control) + +// ── Unary math functions ─────────────────────────────────────────────────── +SYM(sin_, "sin", unary) +SYM(cos_, "cos", unary) +SYM(tan_, "tan", unary) +SYM(abs_, "abs", unary) +SYM(floor_, "floor", unary) +SYM(ceil_, "ceil", unary) +SYM(sqrt_, "sqrt", unary) +SYM(neg, "neg", unary) +SYM(frac_, "frac", unary) +SYM(bsin, "bsin", unary) +SYM(bcos, "bcos", unary) +SYM(bi_to_uni, "bi-to-uni", unary) +SYM(b_to_u, "b>u", unary) +SYM(uni_to_bi, "uni-to-bi", unary) +SYM(u_to_b, "u>b", unary) + +// ── Binary math functions ────────────────────────────────────────────────── +SYM(min_, "min", arith) +SYM(max_, "max", arith) +SYM(pow_, "pow", binary) +SYM(expt, "expt", binary) +SYM(mod_, "mod", binary) +SYM(pulse, "pulse", binary) + +// ── Ternary math functions ───────────────────────────────────────────────── +SYM(clamp, "clamp", ternary) +SYM(lerp, "lerp", ternary) +SYM(scale, "scale", ternary) + +// ── Unary phase-to-value waveform functions ──────────────────────────────── +SYM(tri, "tri", waveform) +SYM(sqr, "sqr", waveform) +SYM(saw, "saw", waveform) + +// ── Domain signal functions ──────────────────────────────────────────────── +SYM(step, "step", signal) +SYM(gates, "gates", signal) +SYM(trigs, "trigs", signal) +SYM(euclid, "euclid", signal) +SYM(eu, "eu", signal) +SYM(seq, "seq", signal) +SYM(from_list, "from-list", signal) +SYM(interp, "interp", signal) +SYM(flatseq, "flatseq", signal) +SYM(dm, "dm", signal) +SYM(range, "range", signal) +SYM(gatesw, "gatesw", signal) +SYM(random_, "random", signal) +SYM(index_rand, "index-rand", signal) +SYM(loop_at, "loop-at", signal) +SYM(eval_at_time, "eval-at-time", signal) +SYM(rpulse, "rpulse", signal) +SYM(rstep, "rstep", signal) +SYM(ridx, "ridx", signal) +SYM(rwarp, "rwarp", signal) + +// ── State forms ──────────────────────────────────────────────────────────── +SYM(defstate, "defstate", side_effect) +SYM(integrate, "integrate", signal) +SYM(dt, "dt", none) + +// ── UGen forms (state-bearing unit generators) ───────────────────────────── +// Primary names +SYM(phasor_, "phasor", signal) +SYM(lfo, "lfo", signal) +SYM(blfo, "blfo", signal) +SYM(slew, "slew", signal) +SYM(one_pole, "one-pole", signal) +SYM(env_follow, "env-follow", signal) +SYM(sah, "sah", signal) +SYM(noise, "noise", signal) +SYM(toggle, "toggle", signal) +SYM(count, "count", signal) + +// Compatibility aliases unrelated to the v1.2 namespace cut. +SYM(envelope_follower, "envelope-follower", signal) +SYM(latch, "latch", signal) + +// UGen keyword symbols +SYM(kw_wave, ":wave", none) +SYM(kw_phase, ":phase", none) +SYM(kw_pw, ":pw", none) +SYM(kw_id, ":id", none) +SYM(kw_fresh, ":fresh", none) +SYM(kw_attack, ":attack", none) +SYM(kw_release, ":release", none) +SYM(kw_reset, ":reset", none) +SYM(kw_sin, ":sin", none) +SYM(kw_cos, ":cos", none) +SYM(kw_tri, ":tri", none) +SYM(kw_saw_kw, ":saw", none) +SYM(kw_sqr, ":sqr", none) + +// ── Side-effect forms (error in signal context) ──────────────────────────── +SYM(define, "define", side_effect) +SYM(def, "def", side_effect) +SYM(defn, "defn", side_effect) +SYM(defun, "defun", side_effect) +SYM(defs, "defs", side_effect) +SYM(set, "set", side_effect) +SYM(unassign, "unassign", side_effect) +SYM(zeros_, "zeros", side_effect) +SYM(get_expr, "get-expr", side_effect) +SYM(set_bpm, "set-bpm", side_effect) +SYM(set_time_sig, "set-time-sig", side_effect) +SYM(useq_clear, "useq-clear", side_effect) +SYM(set_time_offset, "useq-set-time-offset", side_effect) +SYM(nudge_time, "useq-nudge-time", side_effect) +SYM(useq_play, "useq-play", side_effect) +SYM(useq_pause, "useq-pause", side_effect) +SYM(useq_stop, "useq-stop", side_effect) +SYM(useq_rewind, "useq-rewind", side_effect) +SYM(set_clock_ext, "set-clock-ext", side_effect) +SYM(set_clock_int, "set-clock-int", side_effect) +SYM(get_clock_source, "get-clock-source", side_effect) +SYM(reset_clock_ext, "reset-clock-ext", side_effect) +SYM(reset_clock_int, "reset-clock-int", side_effect) + +// ── Derived timing ───────────────────────────────────────────────────────── +SYM(beat_dur, "beat-dur", none) +SYM(bar_dur, "bar-dur", none) + +// ── Output feedback ──────────────────────────────────────────────────────── +SYM(prev, "prev", none) + +// ── Live-edit ────────────────────────────────────────────────────────────── +SYM(live_edit, "live-edit", signal) + +// ── Misc ─────────────────────────────────────────────────────────────────── +SYM(input, "input", none) +SYM(quote, "quote", none) + +#if USEQ_HAS_SYNTH_ENGINE +// ── Synth form (top-level only, synth-nodes.md) ──────────────────────────── +// `synth` is a top-level declaration form, not a signal-context operator. +// Inside a signal expression it is a Boundary-category error +// (VAL-COMP-007). Keywords :freq / :amp / :name / :version / :id parameterise +// the declaration; :id is the hidden-identity injection point supplied by +// the editor payload builder. +SYM(synth, "synth", side_effect) +SYM(kw_freq, ":freq", none) +SYM(kw_amp, ":amp", none) +SYM(kw_name, ":name", none) +SYM(kw_version, ":version", none) +#endif +// Live-edit-only metadata. These are explicit inventory entries even though +// the generic symbol interner would otherwise create them on demand. +SYM(kw_min, ":min", none) +SYM(kw_max, ":max", none) +SYM(kw_options, ":options", none) +SYM(kw_step, ":step", none) +SYM(kw_precision, ":precision", none) + +// ── Operator namespace metadata ─────────────────────────────────────────── +// Tokens map directly to the enum members in graph_builder.h and to the +// generator's string vocabulary. Undeclared axes are `None`; `Native` means +// the bare spelling keeps the engine-native lowering. +#ifndef OP_META +#define OP_META(field, domain, range, regime, cold, identity) +#define USEQ_SYMBOLS_DEF_UNDEF_OP_META +#endif + +OP_META(plus, None, None, Pure, Yes, Native) +OP_META(minus, None, None, Pure, Yes, Native) +OP_META(star, None, None, Pure, Yes, Native) +OP_META(slash, None, None, Pure, Yes, Native) +OP_META(mod_pct, None, None, Pure, Yes, Native) +OP_META(min_, None, None, Pure, Yes, Native) +OP_META(max_, None, None, Pure, Yes, Native) + +OP_META(gt, None, Unipolar, Pure, Yes, Native) +OP_META(lt, None, Unipolar, Pure, Yes, Native) +OP_META(ge, None, Unipolar, Pure, Yes, Native) +OP_META(le, None, Unipolar, Pure, Yes, Native) +OP_META(eq, None, Unipolar, Pure, Yes, Native) +OP_META(not_, None, Unipolar, Pure, Yes, Native) +OP_META(and_, None, Unipolar, Pure, Yes, Native) +OP_META(or_, None, Unipolar, Pure, Yes, Native) + +OP_META(sin_, Phase, Bipolar, PureShaper, Yes, NormalizedUnipolar) +OP_META(cos_, Phase, Bipolar, PureShaper, Yes, NormalizedUnipolar) +OP_META(bsin, Phase, Bipolar, PureShaper, Yes, NormalizedBipolar) +OP_META(bcos, Phase, Bipolar, PureShaper, Yes, NormalizedBipolar) +OP_META(tan_, Angle, None, Pure, Yes, Native) +OP_META(abs_, Scalar, None, Pure, Yes, Native) +OP_META(floor_, Scalar, None, Pure, Yes, Native) +OP_META(ceil_, Scalar, None, Pure, Yes, Native) +OP_META(sqrt_, Scalar, None, Pure, Yes, Native) +OP_META(neg, Scalar, None, Pure, Yes, Native) +OP_META(frac_, Scalar, Unipolar, Pure, Yes, Native) +OP_META(bi_to_uni, Scalar, Unipolar, Pure, Yes, Native) +OP_META(b_to_u, Scalar, Unipolar, Pure, Yes, Native) +OP_META(uni_to_bi, Scalar, Bipolar, Pure, Yes, Native) +OP_META(u_to_b, Scalar, Bipolar, Pure, Yes, Native) + +OP_META(pow_, Scalar, None, Pure, Yes, Native) +OP_META(expt, Scalar, None, Pure, Yes, Native) +OP_META(mod_, Scalar, None, Pure, Yes, Native) +OP_META(pulse, Phase, Unipolar, PureShaper, Yes, UnipolarShaper) +OP_META(clamp, Scalar, None, Pure, Yes, Native) +OP_META(lerp, Scalar, None, Pure, Yes, Native) +OP_META(scale, Scalar, None, Pure, Yes, Native) + +OP_META(tri, Phase, Unipolar, PureShaper, Yes, UnipolarShaper) +OP_META(sqr, Phase, Unipolar, PureShaper, Yes, UnipolarShaper) +OP_META(saw, Phase, Unipolar, PureShaper, Yes, UnipolarShaper) + +OP_META(random_, None, Unipolar, Pure, Yes, Native) +OP_META(index_rand, Scalar, Unipolar, Pure, Yes, Native) +OP_META(phasor_, Scalar, Unipolar, Stateful, No, Native) +OP_META(lfo, Scalar, Unipolar, Stateful, No, LfoUnipolar) +OP_META(blfo, Scalar, Bipolar, Stateful, No, LfoBipolar) +OP_META(slew, Scalar, None, Stateful, No, Native) +OP_META(one_pole, Scalar, None, Stateful, No, Native) +OP_META(env_follow, Scalar, Unipolar, Stateful, No, Native) +OP_META(sah, Scalar, None, Stateful, No, Native) +OP_META(noise, None, Bipolar, Stateful, No, Native) +OP_META(toggle, Scalar, Unipolar, Stateful, No, Native) +OP_META(count, Scalar, None, Stateful, No, Native) + +#ifdef USEQ_SYMBOLS_DEF_UNDEF_OP_META +#undef OP_META +#undef USEQ_SYMBOLS_DEF_UNDEF_OP_META +#endif + +#if USEQ_HAS_SYNTH_ENGINE +// ── State-identity wrapper (state-identity.md §6.3) ──────────────────────── +// `(with-state-id "" )` is the hidden identity wrapper the editor +// payload builder injects around anonymous stateful forms. The runtime +// treats it as a transparent passthrough: the wrapper evaluates its second +// argument (the wrapped form) and ignores the identity string, which has +// already been used by the editor to assign stable identity. Without this +// passthrough every anonymous synth form would compile as "Unknown name". +SYM(with_state_id, "with-state-id", control) +#endif diff --git a/src/signal_engine/synth_graph.cpp b/src/signal_engine/synth_graph.cpp new file mode 100644 index 0000000..5116df5 --- /dev/null +++ b/src/signal_engine/synth_graph.cpp @@ -0,0 +1,208 @@ +#include "synth_graph.h" +#include "cold_eval.h" +#include +#include + +namespace sig { + +// ── Internal scratch buffer for serialised artefacts ─────────────────────── +// The WASM ABI returns const char* snapshots that are stable until the next +// eval, mirroring useq_last_diagnostics(). We back them with a static +// thread-local-ish buffer (single-threaded engine on both firmware and WASM). +static char g_artifact_scratch[SYNTH_ARTIFACT_JSON_CAP]; + +bool synth_graph_render_json(const SynthGraph& graph, char* out, uint32_t cap) { + if (!out || cap == 0) return false; + char* p = out; + const char* end = out + cap; + auto emit = [&](const char* s) -> bool { + size_t n = std::strlen(s); + if (p + n + 1 >= end) return false; + std::memcpy(p, s, n); + p += n; + return true; + }; + auto emit_quoted = [&](const char* s) -> bool { + if (!emit("\"")) return false; + // Escape nothing fancy for now: identities and def names are + // restricted to alphanumerics, '/', '-' and the editor sidecar + // "::" prefix. A defensive scan still strips any stray control + // characters or embedded quotes. + for (const char* c = s; *c; ++c) { + char ch = *c; + if (ch == '"' || ch == '\\') { + if (p + 3 >= end) return false; + *p++ = '\\'; + *p++ = ch; + } else if ((unsigned char)ch < 0x20) { + if (p + 2 >= end) return false; + *p++ = ' '; + } else { + if (p + 2 >= end) return false; + *p++ = ch; + } + } + if (!emit("\"")) return false; + return true; + }; + + // Single canonical render pass. The public schema is intentionally + // narrow (VAL-COMP-012): revision, declarations[], controls[] keyed by + // stable identity / param name. Internal GC-remapped node indices are + // never serialised. + if (!emit("{\"revision\":")) return false; + { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%lu", (unsigned long)graph.revision); + if (!emit(buf)) return false; + } + if (!emit(",\"declarations\":[")) return false; + for (uint16_t i = 0; i < graph.declaration_count(); i++) { + const SynthDeclaration& d = graph.declarations[i]; + if (i > 0 && !emit(",")) return false; + if (!emit("{\"identity\":")) return false; + if (!emit_quoted(d.identity)) return false; + if (!emit(",\"def\":")) return false; + if (!emit_quoted(d.def_name)) return false; + if (!emit(",\"version\":")) return false; + { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%u", (unsigned)d.def_version); + if (!emit(buf)) return false; + } + if (!emit(",\"audio_inputs\":")) return false; + { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%u", (unsigned)d.audio_inputs); + if (!emit(buf)) return false; + } + if (!emit(",\"audio_outputs\":")) return false; + { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%u", (unsigned)d.audio_outputs); + if (!emit(buf)) return false; + } + if (!emit("}")) return false; + } + if (!emit("],\"controls\":[")) return false; + for (uint16_t i = 0; i < graph.control_count(); i++) { + const SynthControlChannel& c = graph.controls[i]; + const SynthDeclaration* declaration = + graph.declaration_for_control(i); + const NodeDefParam* parameter = graph.parameter_for_control(i); + if (!declaration || !parameter) return false; + if (i > 0 && !emit(",")) return false; + if (!emit("{\"identity\":")) return false; + if (!emit_quoted(declaration->identity)) return false; + if (!emit(",\"param\":")) return false; + if (!emit_quoted(parameter->name)) return false; + if (!emit(",\"rate\":")) return false; + if (!emit(c.rate_class == SynthRateClass::Block ? "\"block\"" : "\"fast\"")) return false; + if (!emit(",\"smoothing\":")) return false; + const char* sm = "step"; + switch (c.smoothing_class) { + case SynthSmoothingClass::Step: sm = "step"; break; + case SynthSmoothingClass::Linear: sm = "linear"; break; + case SynthSmoothingClass::Slew: sm = "slew"; break; + case SynthSmoothingClass::Latch: sm = "latch"; break; + } + if (!emit_quoted(sm)) return false; + if (!emit("}")) return false; + } + if (!emit("],\"connections\":[")) return false; + for (uint16_t i = 0; i < graph.connection_count(); i++) { + const SynthConnection& c = graph.connections[i]; + if (i > 0 && !emit(",")) return false; + if (!emit("{\"from\":")) return false; + if (!emit_quoted(c.from)) return false; + if (!emit(",\"to\":")) return false; + if (!emit_quoted(c.to)) return false; + if (!emit(",\"port\":")) return false; + if (!emit_quoted(c.port)) return false; + if (!emit(",\"port_index\":")) return false; + { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%u", (unsigned)c.port_index); + if (!emit(buf)) return false; + } + if (!emit("}")) return false; + } + if (!emit("]}")) return false; + + if ((uint32_t)(p - out) >= cap) return false; + *p = '\0'; + return true; +} + +const char* synth_graph_render_json_scratch(const SynthGraph& graph) { + if (!synth_graph_render_json(graph, g_artifact_scratch, + SYNTH_ARTIFACT_JSON_CAP)) { + // Truncation / failure: emit a minimal valid JSON sentinel so the + // consumer never sees invalid JSON over the wire. + std::snprintf(g_artifact_scratch, sizeof(g_artifact_scratch), + "{\"revision\":%lu,\"error\":true}", + (unsigned long)graph.revision); + } + return g_artifact_scratch; +} + +// ── Versioned ABI surface (VAL-COMP-015) ─────────────────────────────────── + +bool synth_artifacts_supports_abi(uint16_t consumer_abi_version) { + // The current engine advertises exactly one ABI version. Consumers + // built against any other version must be rejected explicitly so we + // never let a newer or older consumer misread the payload layout. + return consumer_abi_version == SYNTH_ARTIFACT_ABI_VERSION; +} + +bool synth_artifacts_render_abi_wrapper(const SignalEngine& engine, + uint16_t consumer_abi_version, + char* out, uint32_t cap) { + if (!out || cap == 0) return false; + + // Reject incompatible consumers up front with a minimal valid-JSON + // error envelope. The caller MUST NOT interpret the body bytes when + // this function returns false. + if (!synth_artifacts_supports_abi(consumer_abi_version)) { + // The error envelope includes the engine's advertised ABI version + // and the rejected consumer version so the diagnostics surfaced + // to the user are actionable. + char err[96]; + std::snprintf(err, sizeof(err), + "{\"abi\":%u,\"abi_error\":true," + "\"engine_abi\":%u,\"consumer_abi\":%u}", + (unsigned)SYNTH_ARTIFACT_ABI_VERSION, + (unsigned)SYNTH_ARTIFACT_ABI_VERSION, + (unsigned)consumer_abi_version); + if (std::strlen(err) + 1 > cap) return false; + std::memcpy(out, err, std::strlen(err) + 1); + return false; + } + + // Render the body into a scratch buffer first so we can prepend the + // `abi` marker without a duplicate copy of the engine state. + static char body_scratch[SYNTH_ARTIFACT_JSON_CAP]; + const char* body = synth_graph_render_json_scratch(engine.synth_graph); + if (!body) return false; + // synth_graph_render_json_scratch returns a pointer into a separate + // scratch buffer; copy into body_scratch so the wrap below is stable + // even if we ever recurse into the scratch again. + std::strncpy(body_scratch, body, SYNTH_ARTIFACT_JSON_CAP - 1); + body_scratch[SYNTH_ARTIFACT_JSON_CAP - 1] = '\0'; + + // Wrap with the `abi` marker. We need to drop the leading '{' of the + // body so we don't produce `{{...}}`. + const char* body_open = body_scratch[0] == '{' ? body_scratch + 1 + : body_scratch; + int written = std::snprintf(out, cap, "{\"abi\":%u,%s", + (unsigned)SYNTH_ARTIFACT_ABI_VERSION, + body_open); + if (written < 0 || (uint32_t)written >= cap) return false; + return true; +} + +const char* synth_artifacts_json(const SignalEngine& engine) { + return synth_graph_render_json_scratch(engine.synth_graph); +} + +} // namespace sig diff --git a/src/signal_engine/synth_graph.h b/src/signal_engine/synth_graph.h new file mode 100644 index 0000000..6b3ff08 --- /dev/null +++ b/src/signal_engine/synth_graph.h @@ -0,0 +1,331 @@ +#ifndef SIGNAL_ENGINE_SYNTH_GRAPH_H +#define SIGNAL_ENGINE_SYNTH_GRAPH_H + +#include "types.h" +#include "synth_registry.h" +#include "diagnostics.h" +#include +#include + +namespace sig { + +// ── Synth Patch Graph and Control Table (synth-nodes.md §7.2) ────────────── +// +// Public artefacts exposed to the host after a successful synth eval. Both +// the patch graph (node instances, def names/versions, audio-port routing, +// fades) and the control channel table (one entry per bound param) are +// versioned together under one compiler revision so consumers can detect +// coherent updates (VAL-COMP-009). Failed evals retain the previous +// successful artefacts and do not advance the revision (VAL-COMP-010). +// +// Public identifiers are stable identity strings supplied by the editor +// (hidden :id or explicit :name). Internal GC-remapped node indices are +// never exposed in the serialised artefact (VAL-COMP-012). + +// ── Identity limits ──────────────────────────────────────────────────────── +// Identity strings are short editor-sidecar IDs (e.g. "::a3f1" or "lead"). +constexpr uint16_t MAX_SYNTH_IDENTITY = 32; + +// ── Patch-graph declaration entry ────────────────────────────────────────── +struct SynthDeclaration { + char identity[MAX_SYNTH_IDENTITY] = {}; + char def_name[MAX_NODEDEF_NAME] = {}; + uint16_t def_version = 0; + uint16_t audio_inputs = 0; + uint16_t audio_outputs = 0; + bool voice_fanout = false; + // The index of this declaration's entry within the control table's + // per-declaration slice. Public artefacts reference declarations by + // identity only; this index is an internal helper, not serialised. + uint16_t first_control_index = 0; + uint16_t control_count = 0; +}; + +// ── Control channel table entry ──────────────────────────────────────────── +// One row per bound (declaration, param [, voice]) triple. The host reads +// the param expression's compiled root_node out of the live NodePool each +// control block. The root_node value is internal and must NOT appear in +// the serialised public artefact (VAL-COMP-012). Ownership is represented by +// the declaration's dense first_control_index/control_count range. Parameter +// names are resolved from the immutable NodeDef descriptor by param_index. +// These two indices avoid repeated identity and parameter strings per row. +struct SynthControlChannel { + uint8_t param_index = 0; + SynthRateClass rate_class = SynthRateClass::Block; + SynthSmoothingClass smoothing_class = SynthSmoothingClass::Step; + + // Compiled control root node index. Internal: not serialised. The host + // samples this root via the NodePool at each control block. + uint16_t root_node = NODE_NONE; + + // Stable compiler ownership and reactive-recompile metadata. Internal; + // never exposed through the host artefact. + uint16_t owner_context = 0; + uint32_t source_offset = 0; + uint32_t source_length = 0; + CellIndex dep_cells[MAX_OUTPUT_DEPS] = {}; + uint8_t dep_count = 0; + + // Root-level non-finite containment for the audio control producer. + Sample lkg_value = 0.0; + bool has_lkg = false; + + // One fixed reactive-compile diagnostic slot per published control on the + // WASM/desktop synth host. Keeping it on the dense row makes replacement, + // removal, rollback, and artifact-order remapping atomic. Firmware does + // not implement the synth host (synth-nodes.md §6.1), so it must not spend + // scarce RP2040 SRAM on an ABI that cannot be observed there. +#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE) + struct CompileDiagnostic { + const char* message = nullptr; + const char* suggestion = nullptr; + // Cell-backed symbols are bounded by MAX_CELLS before mutation, so a + // 16-bit cause preserves every possible trigger without paying for + // the interner's unbounded 32-bit ID in all 512 control rows. + uint16_t triggered_by = 0; + uint16_t span_start = 0; + uint16_t span_len = 0; + DiagnosticSeverity severity = DiagnosticSeverity::Error; + DiagnosticCategory category = DiagnosticCategory::Runtime; + + void clear() { *this = CompileDiagnostic{}; } + bool active() const { return message != nullptr; } + void publish(SymbolID cause, const Diagnostic& diagnostic) { + // A non-null message is the active marker. Every compiler + // diagnostic is expected to have text; preserve the slot even if + // a future producer violates that expectation. + message = diagnostic.message ? diagnostic.message : ""; + suggestion = diagnostic.suggestion; + triggered_by = static_cast(cause); + span_start = diagnostic.span_start; + span_len = diagnostic.span_len; + severity = diagnostic.severity; + category = diagnostic.category; + } + } compile_diagnostic; +#endif +}; + +static_assert(MAX_NODEDEF_PARAMS <= UINT8_MAX, + "synth parameter indices must cover every NodeDef parameter"); + +#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE) +static_assert(MAX_CELLS <= UINT16_MAX, + "synth diagnostic trigger IDs must cover every mutable cell"); +#endif + +struct SynthConnection { + char from[MAX_SYNTH_IDENTITY] = {}; + char to[MAX_SYNTH_IDENTITY] = {}; + char port[MAX_NODEDEF_NAME] = {}; + uint16_t port_index = 0; +}; + +// ── Capacity ─────────────────────────────────────────────────────────────── +// The compiler and app share a 64-node ceiling. Current osc/sine has one +// audio input, so one edge per declaration covers the shipped registry. +constexpr uint16_t MAX_SYNTH_DECLARATIONS = SYNTH_MAX_NODES; +constexpr uint16_t MAX_SYNTH_CONTROLS = MAX_SYNTH_CONTROL_ROOTS; +constexpr uint16_t MAX_SYNTH_CONNECTIONS = MAX_SYNTH_DECLARATIONS; +#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE) +static_assert(MAX_SYNTH_CONTROLS == + MAX_SYNTH_DECLARATIONS * MAX_NODEDEF_PARAMS, + "host synth storage must cover the descriptor ceiling"); +#endif +// Bounds recursive audio-routing compilation and its cold-path stack use. +constexpr uint16_t MAX_SYNTH_NESTING = 16; + +// ── Compiler revision ────────────────────────────────────────────────────── +// One shared counter covers graph and control table. It advances ONLY when +// a full eval unit commits successfully; failed evals retain the previous +// revision (VAL-COMP-008, VAL-COMP-009, VAL-COMP-010). Revision 0 is the +// "empty graph" sentinel. +using SynthRevision = uint32_t; + +// ── Patch graph container ────────────────────────────────────────────────── +struct SynthGraph { + SynthDeclaration declarations[MAX_SYNTH_DECLARATIONS]; + SynthControlChannel controls[MAX_SYNTH_CONTROLS]; + SynthConnection connections[MAX_SYNTH_CONNECTIONS]; + + uint16_t declaration_count_value = 0; + uint16_t control_count_value = 0; + uint16_t connection_count_value = 0; + SynthRevision revision = 0; + + // ── Accessors ───────────────────────────────────────────────────────── + uint16_t declaration_count() const { return declaration_count_value; } + uint16_t control_count() const { return control_count_value; } + uint16_t connection_count() const { return connection_count_value; } + + const SynthDeclaration* declaration_for_control( + uint16_t control_index) const { + if (control_index >= control_count_value) return nullptr; + // Declaration control slices are appended in declaration order and + // remain dense after replacement/removal compaction. Search those + // ordered ranges logarithmically; this is used by every synth-control + // lookup during compilation and artifact inspection. + uint16_t first_decl = 0; + uint16_t past_last_decl = declaration_count_value; + while (first_decl < past_last_decl) { + const uint16_t middle = static_cast( + first_decl + (past_last_decl - first_decl) / 2); + const SynthDeclaration& declaration = declarations[middle]; + const uint32_t first = declaration.first_control_index; + const uint32_t past_last = first + declaration.control_count; + if (control_index < first) { + past_last_decl = middle; + } else if (control_index >= past_last) { + first_decl = static_cast(middle + 1); + } else { + return &declaration; + } + } + return nullptr; + } + + const NodeDefParam* parameter_for_control(uint16_t control_index) const { + const SynthDeclaration* declaration = + declaration_for_control(control_index); + if (!declaration) return nullptr; + const NodeDefDescriptor* descriptor = synth_registry_find( + declaration->def_name, declaration->def_version); + if (!descriptor) return nullptr; + const uint8_t parameter_index = controls[control_index].param_index; + if (parameter_index >= descriptor->param_count || + parameter_index >= MAX_NODEDEF_PARAMS) { + return nullptr; + } + return &descriptor->params[parameter_index]; + } + + // Reset to empty. Used by (useq-clear) and at startup. Does NOT advance + // the revision: an empty graph is a valid committed state, so callers + // that want to bump the revision should call advance_revision() after. + void clear_no_revision() { + declaration_count_value = 0; + control_count_value = 0; + connection_count_value = 0; + } + + void clear_and_advance() { + clear_no_revision(); + advance_revision(); + } + + void advance_revision() { revision++; } + + // Find a declaration by identity. Returns nullptr if absent. + SynthDeclaration* find(const char* identity) { + if (!identity) return nullptr; + for (uint16_t i = 0; i < declaration_count_value; i++) { + if (std::strcmp(declarations[i].identity, identity) == 0) + return &declarations[i]; + } + return nullptr; + } + const SynthDeclaration* find(const char* identity) const { + if (!identity) return nullptr; + for (uint16_t i = 0; i < declaration_count_value; i++) { + if (std::strcmp(declarations[i].identity, identity) == 0) + return &declarations[i]; + } + return nullptr; + } + + // Append a blank declaration slot. Returns nullptr if capacity is full. + SynthDeclaration* append_declaration() { + if (declaration_count_value >= MAX_SYNTH_DECLARATIONS) return nullptr; + return &declarations[declaration_count_value++]; + } + + // Append a control channel. Returns nullptr if capacity is full. + SynthControlChannel* append_control() { + if (control_count_value >= MAX_SYNTH_CONTROLS) return nullptr; + // Dense-table compaction leaves retired bytes above the logical end. + // A newly appended control is a new subject, so it must not inherit + // the prior row's LKG, dependencies, or reactive diagnostic slot. + SynthControlChannel& control = controls[control_count_value++]; + control = SynthControlChannel{}; + return &control; + } + + SynthConnection* append_connection() { + if (connection_count_value >= MAX_SYNTH_CONNECTIONS) return nullptr; + return &connections[connection_count_value++]; + } +}; + +// ── Public artefact serialisation (VAL-COMP-012) ─────────────────────────── +// +// Renders a JSON snapshot of the current synth graph suitable for WASM ABI +// consumption. The schema is intentionally narrow: only stable identity +// strings, def names/versions, parameter names, and rate/smoothing classes +// are emitted. Internal GC-remapped node indices are never serialised. +// +// The output is written into the supplied buffer and is null-terminated. +// Returns false if the buffer is too small (the caller should provide at +// least SYNTH_ARTIFACT_JSON_CAP bytes). +constexpr uint32_t SYNTH_ARTIFACT_JSON_CAP = 32768; + +bool synth_graph_render_json(const SynthGraph& graph, char* out, uint32_t cap); + +// Convenience wrapper that renders into the engine's own scratch buffer. +// The returned pointer is valid until the next call to this function or +// until the engine is destroyed. +const char* synth_graph_render_json_scratch(const SynthGraph& graph); + +// ── Versioned synth artefact ABI (VAL-COMP-015) ──────────────────────────── +// +// The synth artefact payload carries an `abi` version marker so future +// consumers can reject incompatible bundles explicitly. The native engine +// advertises a single canonical ABI version; any consumer built against a +// different version must refuse to read the payload. +// +// Version history: +// 1 — single-node declarations + controls; no routable audio graph. +// 2 — required connections[] and multi-node patch-graph semantics. +constexpr uint16_t SYNTH_ARTIFACT_ABI_VERSION = 2; + +/** + * Return true iff the engine's synth-artefact ABI can serve a consumer + * built against the supplied `consumer_abi_version`. The current engine + * accepts only its own declared ABI; future versions may accept a range. + * + * Callers MUST consult this helper before interpreting the body bytes of + * `synth_artifacts_json` / `useq_synth_artifacts`. Incompatible consumers + * receive a minimal error object instead of the artefact body (see + * `synth_artifacts_render_abi_wrapper`). + */ +bool synth_artifacts_supports_abi(uint16_t consumer_abi_version); + +/** + * Render the versioned synth artefact payload into `out` for a consumer + * built against `consumer_abi_version`. + * + * On success the buffer contains a JSON object shaped: + * {"abi":,"revision":N,"declarations":[...],"controls":[...]} + * and the function returns true. + * + * If the consumer ABI version is unsupported, the buffer is filled with a + * minimal JSON error object (still valid JSON) and the function returns + * false. The caller MUST NOT interpret the body bytes when this function + * returns false — the only safe interpretation is the `abi_error` field. + * + * This helper is the native counterpart of the WASM `useq_synth_artifacts` + * wrapper. Mirrors its byte shape exactly so native and WASM consumers + * observe identical payloads. + */ +bool synth_artifacts_render_abi_wrapper(const struct SignalEngine& engine, + uint16_t consumer_abi_version, + char* out, uint32_t cap); + +// ── Engine-level accessor ────────────────────────────────────────────────── +// Returns the engine's published synth artefact snapshot as JSON. Mirrors +// the useq_last_diagnostics() pattern: the pointer is stable until the +// next eval. Equivalent to synth_graph_render_json_scratch(engine.synth_graph). +const char* synth_artifacts_json(const struct SignalEngine& engine); + +} // namespace sig + +#endif // SIGNAL_ENGINE_SYNTH_GRAPH_H diff --git a/src/signal_engine/synth_registry.cpp b/src/signal_engine/synth_registry.cpp new file mode 100644 index 0000000..66a5728 --- /dev/null +++ b/src/signal_engine/synth_registry.cpp @@ -0,0 +1,160 @@ +#include "synth_registry.h" +#include +#include + +namespace sig { + +// ── Static NodeDef table ─────────────────────────────────────────────────── +// +// The current proof set contains osc/sine version 2, with: +// - one audio input (`fm`, per-sample Hz offset), one mono output +// - :freq: block rate, step smoothing, default 440 Hz +// - :amp: block rate, declared smoothing (linear, implemented by def), +// default 0.2 +// +// The compiler consults this table to validate def names, versions, +// parameters, and defaults. The host/worklet consult it for transport and +// zone layout (out of scope for this feature). + +static const NodeDefDescriptor kRegistryTable[] = { + { + "osc/sine", + 2, // version (v2 adds the fm audio-input contract) + 1, // audio_inputs + 1, // audio_outputs (mono) + false, // voice_fanout + { + // params[0]: freq + { + "freq", + 440.0, + SynthRateClass::Block, + SynthSmoothingClass::Step, + }, + // params[1]: amp + { + "amp", + 0.2, + SynthRateClass::Block, + SynthSmoothingClass::Linear, + }, + }, + 2, // param_count + { + "fm", + }, + // Convenience defaults surfaced for the form grammar: + 440.0, // freq_default + 0.2, // amp_default + }, +}; + +static constexpr uint16_t kRegistryCount = + sizeof(kRegistryTable) / sizeof(kRegistryTable[0]); + +const NodeDefDescriptor* synth_registry_table(uint16_t& count_out) { + count_out = kRegistryCount; + return kRegistryTable; +} + +const NodeDefDescriptor* synth_registry_find(const char* name, uint16_t version) { + if (!name) return nullptr; + const NodeDefDescriptor* best = nullptr; + uint16_t best_version = 0; + for (uint16_t i = 0; i < kRegistryCount; i++) { + const NodeDefDescriptor& d = kRegistryTable[i]; + if (std::strcmp(d.name, name) != 0) continue; + if (version != 0 && d.version != version) continue; + // version=0 means "any / latest": pick the highest available. + if (version == 0) { + if (best == nullptr || d.version > best_version) { + best = &d; + best_version = d.version; + } + } else { + return &d; + } + } + return best; +} + +const NodeDefParam* nodedef_find_param(const NodeDefDescriptor* def, + const char* param_name) { + if (!def || !param_name) return nullptr; + for (uint16_t i = 0; i < def->param_count; i++) { + if (std::strcmp(def->params[i].name, param_name) == 0) { + return &def->params[i]; + } + } + return nullptr; +} + +int16_t nodedef_find_audio_input(const NodeDefDescriptor* def, + const char* input_name) { + if (!def || !input_name) return -1; + for (uint16_t i = 0; i < def->audio_inputs && + i < MAX_NODEDEF_AUDIO_INPUTS; i++) { + if (def->audio_input_names[i] && + std::strcmp(def->audio_input_names[i], input_name) == 0) { + return (int16_t)i; + } + } + return -1; +} + +// ── Tiny Levenshtein for parameter suggestion ────────────────────────────── +// Mirrors the algorithm in diagnostics.cpp. We keep this local rather than +// reusing find_fuzzy_match() because the candidate pool here is the def's +// declared params (short, fixed), not the cell table. + +static int param_levenshtein(const char* s1, const char* s2) { + int len1 = (int)std::strlen(s1); + int len2 = (int)std::strlen(s2); + if (len1 == 0) return len2; + if (len2 == 0) return len1; + if (len2 > 32) len2 = 32; + int row[33]; + for (int j = 0; j <= len2; j++) row[j] = j; + for (int i = 1; i <= len1; i++) { + int prev = i - 1; + row[0] = i; + for (int j = 1; j <= len2; j++) { + int temp = row[j]; + int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; + row[j] = (std::min({prev + cost, row[j] + 1, row[j - 1] + 1})); + prev = temp; + } + } + return row[len2]; +} + +const char* nodedef_suggest_param(const NodeDefDescriptor* def, + const char* candidate) { + if (!def || !candidate) return nullptr; + int candidate_len = (int)std::strlen(candidate); + if (candidate_len == 0) return nullptr; + + const char* best = nullptr; + int best_dist = 3; // threshold: only suggest if distance < 3 + + for (uint16_t i = 0; i < def->param_count; i++) { + const char* name = def->params[i].name; + int d = param_levenshtein(candidate, name); + if (d > 0 && d < best_dist) { + best_dist = d; + best = name; + } + // Prefix / case-insensitive near-miss (e.g. "amplitude" → "amp"): + // if the candidate starts with the param name or vice versa, count + // it as a strong match. + size_t name_len = std::strlen(name); + size_t min_len = std::min(name_len, (size_t)candidate_len); + if (min_len >= 2 && std::strncmp(candidate, name, min_len) == 0) { + // Strong prefix match — prefer it. + return name; + } + } + return best; +} + +} // namespace sig diff --git a/src/signal_engine/synth_registry.h b/src/signal_engine/synth_registry.h new file mode 100644 index 0000000..62f423d --- /dev/null +++ b/src/signal_engine/synth_registry.h @@ -0,0 +1,116 @@ +#ifndef SIGNAL_ENGINE_SYNTH_REGISTRY_H +#define SIGNAL_ENGINE_SYNTH_REGISTRY_H + +#include "types.h" +#include + +namespace sig { + +// ── Synth NodeDef Registry (synth-nodes.md §2) ───────────────────────────── +// +// Source-agnostic metadata describing each audio-rate NodeDef the host knows +// how to instantiate. The registry is a small static table (currently just +// osc/sine); the language/compiler only ever consumes the contract metadata, +// never the DSP implementation. +// +// The registry owns: +// - def name (namespaced string like "osc/sine") +// - def version (positive integer; 0 means "latest") +// - named audio input ports and audio output count +// - parameter contract for :freq and :amp +// +// The v1 library is intentionally minimal: the compiler validates against +// this table and produces precise diagnostics for unknown defs, unknown +// parameters, or unsupported versions. + +// ── Rate class (synth-nodes.md §2.3) ─────────────────────────────────────── +enum class SynthRateClass : uint8_t { + Block, // sampled once per audio block + Fast, // declared higher control rate (points-per-block) +}; + +// ── Smoothing class (synth-nodes.md §2.4) ────────────────────────────────── +enum class SynthSmoothingClass : uint8_t { + Step, // hold until next control point (default for pitch/freq) + Linear,// ramp to next value (implemented inside the def) + Slew, // exponential approach (implemented inside the def) + Latch, // event-like edge records (gates, triggers) +}; + +// ── Parameter contract per NodeDef ───────────────────────────────────────── +struct NodeDefParam { + const char* name; // e.g. "freq", "amp" + Sample default_value; // static default; omitted bindings take this + SynthRateClass rate_class; + SynthSmoothingClass smoothing_class; +}; + +// Maximum parameters per NodeDef. osc/sine has 2 (freq, amp). We allow a few +// more so the registry can describe slightly richer defs without a refactor. +constexpr uint16_t MAX_NODEDEF_PARAMS = 8; + +// Maximum named audio inputs per NodeDef. Audio inputs accept node references +// and compile into patch edges; they are never sampled control parameters. +constexpr uint16_t MAX_NODEDEF_AUDIO_INPUTS = 8; + +// Maximum def entries in the static registry. M1 ships exactly one (osc/sine). +constexpr uint16_t MAX_NODEDEF_ENTRIES = 8; + +// Maximum length of a NodeDef name string (namespaced identifiers are short). +constexpr uint16_t MAX_NODEDEF_NAME = 32; + +struct NodeDefDescriptor { + const char* name; // e.g. "osc/sine" + uint16_t version; // e.g. 1 + uint16_t audio_inputs; // osc/sine v2 has one named input: fm + uint16_t audio_outputs; // osc/sine has 1 (mono) + bool voice_fanout; // osc/sine does not vector-fan-out in M1 + + NodeDefParam params[MAX_NODEDEF_PARAMS]; + uint16_t param_count; + + const char* audio_input_names[MAX_NODEDEF_AUDIO_INPUTS]; + + // Convenience accessors used by the compiler. freq_default and amp_default + // are surfaced directly because the synth form grammar is hard-wired to + // the freq/amp pair for M1. + Sample freq_default; // registry-declared default for :freq + Sample amp_default; // registry-declared default for :amp +}; + +// ── Static registry accessor ─────────────────────────────────────────────── +// +// Returns the canonical table of known NodeDefs. The table is a static +// constant (no dynamic registration in M1). The pointer is valid for the +// lifetime of the program. + +const NodeDefDescriptor* synth_registry_table(uint16_t& count_out); + +// Look up a NodeDef by (name, version). version=0 means "any / latest". +// Returns nullptr if no def matches both name and version. When version=0 +// the highest available version for the given name is returned. + +const NodeDefDescriptor* synth_registry_find(const char* name, uint16_t version); + +// Look up a parameter descriptor by name on a specific NodeDef. +const NodeDefParam* nodedef_find_param(const NodeDefDescriptor* def, + const char* param_name); + +// Return a zero-based audio-input port index, or -1 when the name is not an +// input on this NodeDef. +int16_t nodedef_find_audio_input(const NodeDefDescriptor* def, + const char* input_name); + +// Fuzzy-match a parameter name against the declared params. Returns the +// best candidate name (or nullptr if none is close enough). Used for the +// "did you mean" suggestion in unknown-parameter diagnostics. +const char* nodedef_suggest_param(const NodeDefDescriptor* def, + const char* candidate); + +// ── Patch-graph capacity ─────────────────────────────────────────────────── +// Shared with the app-side MAX_SYNTH_NODES gate. +constexpr uint16_t SYNTH_MAX_NODES = 64; + +} // namespace sig + +#endif // SIGNAL_ENGINE_SYNTH_REGISTRY_H diff --git a/src/signal_engine/token.cpp b/src/signal_engine/token.cpp new file mode 100644 index 0000000..fcc0f44 --- /dev/null +++ b/src/signal_engine/token.cpp @@ -0,0 +1,329 @@ +#include "token.h" +#include "../modulisp/lisp/symbol_intern.h" +#include +#include +#include + +namespace sig { + +// ── TokenStream methods ───────────────────────────────────────────────────── + +Token TokenStream::peek() const { + if (pos < count) return tokens[pos]; + Token eof; + eof.kind = TokenKind::Eof; + return eof; +} + +Token TokenStream::consume() { + if (pos < count) return tokens[pos++]; + Token eof; + eof.kind = TokenKind::Eof; + return eof; +} + +bool TokenStream::expect(TokenKind kind) { + if (pos < count && tokens[pos].kind == kind) { + pos++; + return true; + } + return false; +} + +void TokenStream::rewind(uint16_t position) { + pos = position; +} + +bool TokenStream::at_end() const { + return pos >= count || tokens[pos].kind == TokenKind::Eof; +} + +// ── Tokenizer ─────────────────────────────────────────────────────────────── + +static bool is_symbol_char(char c) { + if (c <= ' ') return false; + switch (c) { + case '(': case ')': case '[': case ']': + case '"': case '\'': case ';': case ',': + return false; + default: + return true; + } +} + +static bool is_digit(char c) { return c >= '0' && c <= '9'; } + +// A14: strtod accepts hex (0x10), "inf" and "nan" forms — ModuLisp numeric +// literals are plain decimal only. Restrict number tokens to decimal +// characters so those forms tokenize as symbols (→ UndefinedName) instead of +// silently becoming numbers. +static bool is_plain_number_text(const char* s, uint32_t len) { + for (uint32_t k = 0; k < len; k++) { + char c = s[k]; + if (!(is_digit(c) || c == '.' || c == '-' || c == '+' || + c == 'e' || c == 'E')) + return false; + } + return true; +} + +uint16_t TokenStream::tokenize(const char* source, uint32_t length, + Token* out, uint16_t max_tokens, + Diagnostic* errors, uint8_t* error_count) { + uint16_t count = 0; + uint32_t i = 0; + bool overflowed = false; + TokenKind delimiter_stack[MAX_TOKENS] = {}; + uint16_t delimiter_depth = 0; + + auto emit = [&](Token t) { + if (count < max_tokens) { + out[count++] = t; + } else { + // Buffer full: record the overflow instead of silently truncating. + overflowed = true; + } + }; + + auto emit_error = [&](uint32_t pos, uint16_t len, const char* msg, + const char* suggestion = nullptr) { + if (!error_count || *error_count >= 8) return; + if (errors) { + errors[*error_count] = { + DiagnosticSeverity::Error, DiagnosticCategory::Syntax, + (uint16_t)pos, len, msg, suggestion + }; + } + (*error_count)++; + }; + + // Token spans are represented by uint16_t. Reject a submission that + // cannot be represented before scanning so no later cast can wrap a + // diagnostic or source slice onto unrelated bytes. + if (length > UINT16_MAX) { + emit_error(0, UINT16_MAX, + "Program is too large for source-span tracking", + "Split the submission into smaller forms"); + Token eof; + eof.kind = TokenKind::Eof; + eof.span_start = UINT16_MAX; + if (max_tokens > 0) out[count++] = eof; + return count; + } + if (!source && length > 0) { + emit_error(0, 0, "Source buffer is null"); + return count; + } + + // The language source profile is ASCII. Preflight the complete byte + // string, including comments and string literals, before emitting any + // usable token. NUL is not whitespace and high bytes are never decoded + // through implementation-defined signed-char behaviour. + for (uint32_t j = 0; j < length; ++j) { + const unsigned char byte = + static_cast(source[j]); + if (byte == 0 || byte >= 0x80) { + emit_error(j, 1, + byte == 0 + ? "NUL is not allowed in source" + : "Source must contain ASCII bytes only"); + Token eof; + eof.kind = TokenKind::Eof; + eof.span_start = static_cast(length); + if (max_tokens > 0) out[count++] = eof; + return count; + } + } + + while (i < length) { + char c = source[i]; + + // Skip whitespace + if (c <= ' ') { i++; continue; } + + // Skip comments + if (c == ';') { + while (i < length && source[i] != '\n') i++; + continue; + } + + // Parens and brackets + if (c == '(') { + Token t; t.kind = TokenKind::LParen; t.span_start = (uint16_t)i; t.span_len = 1; + emit(t); + if (delimiter_depth < MAX_TOKENS) { + delimiter_stack[delimiter_depth++] = TokenKind::RParen; + } else { + emit_error(i, 1, "Delimiter nesting is too deep"); + } + i++; + continue; + } + if (c == ')') { + Token t; t.kind = TokenKind::RParen; t.span_start = (uint16_t)i; t.span_len = 1; + emit(t); + if (delimiter_depth == 0) { + emit_error(i, 1, "Unexpected closing delimiter"); + } else if (delimiter_stack[delimiter_depth - 1] != + TokenKind::RParen) { + emit_error(i, 1, "Mismatched closing delimiter", + "Close '[' with ']'"); + delimiter_depth--; + } else { + delimiter_depth--; + } + i++; + continue; + } + if (c == '[') { + Token t; t.kind = TokenKind::LBracket; t.span_start = (uint16_t)i; t.span_len = 1; + emit(t); + if (delimiter_depth < MAX_TOKENS) { + delimiter_stack[delimiter_depth++] = TokenKind::RBracket; + } else { + emit_error(i, 1, "Delimiter nesting is too deep"); + } + i++; + continue; + } + if (c == ']') { + Token t; t.kind = TokenKind::RBracket; t.span_start = (uint16_t)i; t.span_len = 1; + emit(t); + if (delimiter_depth == 0) { + emit_error(i, 1, "Unexpected closing delimiter"); + } else if (delimiter_stack[delimiter_depth - 1] != + TokenKind::RBracket) { + emit_error(i, 1, "Mismatched closing delimiter", + "Close '(' with ')'"); + delimiter_depth--; + } else { + delimiter_depth--; + } + i++; + continue; + } + + // String literal + if (c == '"') { + uint32_t start = i; + i++; // skip opening quote + uint32_t str_start = i; + while (i < length && source[i] != '"') { + if (source[i] == '\\' && i + 1 < length) i++; // skip escape + i++; + } + if (i >= length) { + emit_error((uint32_t)start, (uint16_t)(i - start), + "Unterminated string"); + break; + } + Token t; + t.kind = TokenKind::String; + t.span_start = (uint16_t)start; + t.span_len = (uint16_t)(i + 1 - start); + t.string.offset = str_start; + t.string.length = (uint16_t)(i - str_start); + emit(t); + i++; // skip closing quote + continue; + } + + // Numbers and symbols share one bounded token path. Scan only within + // the submitted slice, then convert the NUL-terminated copy below; + // strtod must never receive the caller's potentially unterminated + // source buffer directly. + if (is_symbol_char(c)) { + uint32_t start = i; + while (i < length && is_symbol_char(source[i])) i++; + // Intern the symbol + // We need a temporary null-terminated string + char buf[256]; + uint32_t sym_len = i - start; + if (sym_len >= sizeof(buf)) { + uint16_t diag_len = sym_len > UINT16_MAX + ? UINT16_MAX : (uint16_t)sym_len; + emit_error(start, diag_len, "Symbol is too long", + "Use a name shorter than 256 bytes"); + Token t; + t.kind = TokenKind::Error; + t.span_start = (uint16_t)start; + t.span_len = diag_len; + emit(t); + continue; + } + memcpy(buf, source + start, sym_len); + buf[sym_len] = '\0'; + + // Check if it's actually a number (e.g., "-1" when preceded by + // space). Restricted to plain decimal text (A14) so strtod's + // inf/nan/hex forms stay symbols. + char* end_ptr = nullptr; + Sample val = strtod(buf, &end_ptr); + if (end_ptr == buf + sym_len && + is_plain_number_text(buf, sym_len)) { + if (!std::isfinite(val)) { + emit_error(start, (uint16_t)sym_len, + "Numeric literal is outside the finite binary64 range", + "Use a smaller finite decimal literal"); + Token t; + t.kind = TokenKind::Error; + t.span_start = (uint16_t)start; + t.span_len = (uint16_t)sym_len; + emit(t); + continue; + } + Token t; + t.kind = TokenKind::Number; + t.span_start = (uint16_t)start; + t.span_len = (uint16_t)sym_len; + t.number = val; + emit(t); + continue; + } + + Token t; + t.kind = TokenKind::Symbol; + t.span_start = (uint16_t)start; + t.span_len = (uint16_t)sym_len; + t.symbol = SymbolIntern::getInstance().intern(String(buf)); + emit(t); + continue; + } + + // Unknown character + emit_error(i, 1, "Unexpected character"); + i++; + } + + // Append EOF + Token eof; + eof.kind = TokenKind::Eof; + eof.span_start = (uint16_t)length; + eof.span_len = 0; + emit(eof); + + // EOF is not an implicit closing delimiter. The parser's expect() calls + // are deliberately non-fatal for recovery, so validate the lexical + // delimiter balance here before a malformed form can compile as valid. + if (delimiter_depth > 0) { + emit_error(length, 0, "Unclosed form", + "Add the missing closing delimiter(s)"); + } + + // If the token buffer overflowed we dropped tokens (including, possibly, + // the EOF). Emit a clear diagnostic instead of silently truncating the + // program — silent truncation produces baffling parse errors downstream. + if (overflowed) { + // Ensure the stream is still well-formed for the parser: force the + // final slot to EOF so callers don't read past valid tokens. + if (count > 0 && out[count - 1].kind != TokenKind::Eof) { + out[count - 1] = eof; + } + emit_error(0, (uint16_t)(length > 0xFFFF ? 0xFFFF : length), + "Program too large (token limit exceeded)"); + } + + return count; +} + +} // namespace sig diff --git a/src/signal_engine/token.h b/src/signal_engine/token.h new file mode 100644 index 0000000..d7a5aa6 --- /dev/null +++ b/src/signal_engine/token.h @@ -0,0 +1,54 @@ +#ifndef SIGNAL_ENGINE_TOKEN_H +#define SIGNAL_ENGINE_TOKEN_H + +#include "types.h" +#include "diagnostics.h" + +namespace sig { + +// ── Token Types ───────────────────────────────────────────────────────────── + +enum class TokenKind : uint8_t { + LParen, RParen, LBracket, RBracket, + Number, Symbol, String, + Eof, Error +}; + +struct Token { + TokenKind kind = TokenKind::Eof; + uint8_t pad = 0; + uint16_t span_start = 0; + uint16_t span_len = 0; + union { + Sample number; + SymbolID symbol; + struct { uint32_t offset; uint16_t length; } string; + }; + + Token() : number(0.0) {} +}; +// sizeof(Token) == 16 bytes + +// ── Token Stream ──────────────────────────────────────────────────────────── + +struct TokenStream { + Token tokens[MAX_TOKENS]; + uint16_t count = 0; + uint16_t pos = 0; + + Token peek() const; + Token consume(); + bool expect(TokenKind kind); + void rewind(uint16_t position); + bool at_end() const; + + // Zero-allocation tokenizer. + // Returns number of tokens written. Parse errors are appended to `errors`. + static uint16_t tokenize(const char* source, uint32_t length, + Token* out, uint16_t max_tokens, + Diagnostic* errors, uint8_t* error_count); +}; + +} // namespace sig + +#endif // SIGNAL_ENGINE_TOKEN_H diff --git a/src/signal_engine/types.h b/src/signal_engine/types.h new file mode 100644 index 0000000..83682f8 --- /dev/null +++ b/src/signal_engine/types.h @@ -0,0 +1,110 @@ +#ifndef SIGNAL_ENGINE_TYPES_H +#define SIGNAL_ENGINE_TYPES_H + +#include +#include +#include +#include "build_profile.h" +#include "../modulisp/lisp/symbol_intern.h" + +namespace sig { + +using SymbolID = SymbolIntern::SymbolID; + +// ── Numeric Width ─────────────────────────────────────────────────────────── +// Sample is the engine's numeric scalar: signal values, cell values, time +// (t/dt), node results, and output samples all use it. One control point for +// the width so a measured firmware decision (software double vs hardware +// float on RP2040, workspace SRAM halves) can be made without touching call +// sites. Default stays double: phasor time accumulates over long sessions and +// float's 24-bit mantissa degrades beat phase measurably. +// +// Known costs of enabling USEQ_SAMPLE_F32 (deliberately not hidden): +// - double literals (0.5) will promote under -Wdouble-promotion; the build +// keeps a zero-warning policy, so suffixes/casts must be fixed then. +// - Unqualified math calls (floor, sin) resolve to double overloads unless +// std::-qualified; qualify before flipping. +// - Flash persistence layout and the WASM C ABI are double today; they +// convert at their boundary or version their formats. +#if defined(USEQ_SAMPLE_F32) +using Sample = float; +#else +using Sample = double; +#endif +static_assert(std::is_floating_point_v, + "Sample must be a floating-point type"); + +// Firmware builds use smaller limits to fit within the RP2040's 264 KB SRAM. +// USEQ_FIRMWARE_PROFILE selects the same retained capacities in a native +// process so capacity and endurance workloads can run without Arduino I/O. +// It is a resource-profile simulation, not target timing evidence. +// WASM/desktop builds keep generous limits since memory is plentiful. +// MAX_CELLS must exceed the number of built-in symbols (~177) since cells +// are indexed by SymbolID and user-defined names follow the built-ins. + +#if defined(ARDUINO) || defined(USEQ_FIRMWARE_PROFILE) +constexpr size_t MAX_CELLS = 256; +constexpr size_t MAX_TOTAL_NODES = 360; +constexpr size_t MAX_DATA_ENTRIES = 512; +constexpr size_t MAX_DATA_TABLES = 32; +constexpr size_t MAX_LIVE_SLOTS = 16; +constexpr size_t MAX_LIVE_SLOT_OPTIONS = 8; +constexpr size_t SOURCE_ARENA_SIZE = 4096; +constexpr size_t CSE_TABLE_SIZE = MAX_TOTAL_NODES; +constexpr size_t MAX_OUTPUT_DEPS = 32; +constexpr size_t MAX_STATE_SLOTS = 16; +#else +constexpr size_t MAX_CELLS = 512; +constexpr size_t MAX_TOTAL_NODES = 1024; +constexpr size_t MAX_DATA_ENTRIES = 2048; +constexpr size_t MAX_DATA_TABLES = 64; +constexpr size_t MAX_LIVE_SLOTS = 256; +constexpr size_t MAX_LIVE_SLOT_OPTIONS = 16; +constexpr size_t SOURCE_ARENA_SIZE = 16384; +constexpr size_t CSE_TABLE_SIZE = MAX_TOTAL_NODES * 2; +constexpr size_t MAX_OUTPUT_DEPS = 64; +constexpr size_t MAX_STATE_SLOTS = 32; +#endif + +constexpr size_t MAX_CALLABLE_PARAMS = 8; +constexpr size_t MAX_OUTPUTS = 42; +constexpr size_t MAX_SCOPE_DEPTH = 32; +constexpr size_t MAX_LOCAL_BINDINGS = 32; +constexpr size_t MAX_DIAGNOSTICS = 16; +constexpr size_t MAX_INLINE_DEPTH = 16; +// Max syntactic nesting depth for compile_expr recursion. compile_expr recurses +// on nested forms on the small RP2040 stack (inside tick() for live edits), so a +// pathologically deep program could overflow the hardware stack. This bound +// aborts compilation with a diagnostic well before that happens. +constexpr size_t MAX_COMPILE_DEPTH = 64; +constexpr size_t MAX_LIVE_SLOT_ID = 32; +constexpr size_t MAX_LIVE_SLOT_OPTION_LEN = 32; +constexpr size_t MAX_TOKENS = 256; +constexpr size_t BATCH_CHUNK_SIZE = 256; + +#if USEQ_HAS_SYNTH_ENGINE +constexpr size_t MAX_SYNTH_CONTROL_ROOTS = 512; +#endif + +// Persistent dependency records refer only to entries in CellStore, not to +// the unbounded symbol-interner namespace. A compact index therefore covers +// every valid dependency in every build profile without narrowing the public +// definition capacity. +using CellIndex = uint16_t; +static_assert(MAX_CELLS <= static_cast(UINT16_MAX) + 1, + "CellIndex must represent every retained cell"); + +constexpr uint16_t NODE_NONE = 0xFFFF; +constexpr uint8_t FLAG_TIME_INVARIANT = 0x01; + +// ── Output Classification (visualisation.md §4) ──────────────────────────── +enum class OutputClass : uint8_t { + Inactive = 0, // no graph assigned + Pure = 1, // closed-form function of t only (+ cells, data) + InputDep = 2, // references hardware inputs but no cross-sample state + Stateful = 3, // uses LoadState, LoadDt, or PrevOutputLoad +}; + +} // namespace sig + +#endif // SIGNAL_ENGINE_TYPES_H diff --git a/src/utils/common.cpp b/src/utils/common.cpp new file mode 100644 index 0000000..15e3493 --- /dev/null +++ b/src/utils/common.cpp @@ -0,0 +1,10 @@ +#include "common.h" + +/* C++ prototypes */ +long map(long x, long in_min, long in_max, long out_min, long out_max) +{ + return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; +} + +uint16_t makeWord(uint16_t w) { return w; } +uint16_t makeWord(uint8_t h, uint8_t l) { return (h << 8) | l; } diff --git a/src/utils/common.h b/src/utils/common.h new file mode 100644 index 0000000..19640ec --- /dev/null +++ b/src/utils/common.h @@ -0,0 +1,192 @@ +#ifndef COMMON_H_ +#define COMMON_H_ +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + void yield(void); + + typedef enum + { + LOW = 0, + HIGH = 1, + CHANGE = 2, + FALLING = 3, + RISING = 4, + } PinStatus; + + typedef enum + { + INPUT = 0x0, + OUTPUT = 0x1, + INPUT_PULLUP = 0x2, + INPUT_PULLDOWN = 0x3, + OUTPUT_OPENDRAIN = 0x4, + } PinMode; + + typedef enum + { + LSBFIRST = 0, + MSBFIRST = 1, + } BitOrder; + +#define PI 3.1415926535897932384626433832795 +#define HALF_PI 1.5707963267948966192313216916398 +#define TWO_PI 6.283185307179586476925286766559 +#define DEG_TO_RAD 0.017453292519943295769236907684886 +#define RAD_TO_DEG 57.295779513082320876798154814105 +#define EULER 2.718281828459045235360287471352 + +#define SERIAL 0x0 +#define DISPLAY 0x1 + +#ifndef constrain +#define constrain(amt, low, high) \ + ((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt))) +#endif + +#ifndef radians +#define radians(deg) ((deg) * DEG_TO_RAD) +#endif + +#ifndef degrees +#define degrees(rad) ((rad) * RAD_TO_DEG) +#endif + +#ifndef sq +#define sq(x) ((x) * (x)) +#endif + + typedef void (*voidFuncPtr)(void); + typedef void (*voidFuncPtrParam)(void*); + + // interrupts() / noInterrupts() must be defined by the core + +#define lowByte(w) ((uint8_t)((w) & 0xff)) +#define highByte(w) ((uint8_t)((w) >> 8)) + +#define bitRead(value, bit) (((value) >> (bit)) & 0x01) +#define bitSet(value, bit) ((value) |= (1UL << (bit))) +#define bitClear(value, bit) ((value) &= ~(1UL << (bit))) +#define bitToggle(value, bit) ((value) ^= (1UL << (bit))) +#define bitWrite(value, bit, bitvalue) \ + ((bitvalue) ? bitSet((value), (bit)) : bitClear((value), (bit))) + +#ifndef bit +#define bit(b) (1UL << (b)) +#endif + + /* TODO: request for removal */ + typedef bool boolean; + typedef uint8_t byte; + typedef uint16_t word; + + void init(void); + void initVariant(void); + +#ifndef HOST + int atexit(void (*func)()) __attribute__((weak)); +#endif + // The Arduino core's own `int main()` declaration is deliberately not + // mirrored here: nothing in this tree declares or calls main through this + // shim, and declaring ::main inside extern "C" is ill-formed. + +#ifdef EXTENDED_PIN_MODE + // Platforms who want to declare more than 256 pins need to define + // EXTENDED_PIN_MODE globally + typedef uint32_t pin_size_t; +#else +typedef uint8_t pin_size_t; +#endif + + void pinMode(pin_size_t pinNumber, PinMode pinMode); + void digitalWrite(pin_size_t pinNumber, PinStatus status); + PinStatus digitalRead(pin_size_t pinNumber); + int analogRead(pin_size_t pinNumber); + void analogReference(uint8_t mode); + void analogWrite(pin_size_t pinNumber, int value); + + unsigned long millis(void); + unsigned long micros(void); + void delay(unsigned long); + void delayMicroseconds(unsigned int us); + unsigned long pulseIn(pin_size_t pin, uint8_t state, unsigned long timeout); + unsigned long pulseInLong(pin_size_t pin, uint8_t state, unsigned long timeout); + + void shiftOut(pin_size_t dataPin, pin_size_t clockPin, BitOrder bitOrder, + uint8_t val); + uint8_t shiftIn(pin_size_t dataPin, pin_size_t clockPin, BitOrder bitOrder); + + void attachInterrupt(pin_size_t interruptNumber, voidFuncPtr callback, + PinStatus mode); + void attachInterruptParam(pin_size_t interruptNumber, voidFuncPtrParam callback, + PinStatus mode, void* param); + void detachInterrupt(pin_size_t interruptNumber); + + void setup(void); + void loop(void); + +#ifdef __cplusplus +} // extern "C" +#endif + +#ifdef __cplusplus +template +auto min(const T& a, const L& b) -> decltype((b < a) ? b : a) +{ + return (b < a) ? b : a; +} + +template +auto max(const T& a, const L& b) -> decltype((b < a) ? b : a) +{ + return (a < b) ? b : a; +} +#else +#ifndef min +#define min(a, b) \ + ({ \ + __typeof__(a) _a = (a); \ + __typeof__(b) _b = (b); \ + _a < _b ? _a : _b; \ + }) +#endif +#ifndef max +#define max(a, b) \ + ({ \ + __typeof__(a) _a = (a); \ + __typeof__(b) _b = (b); \ + _a > _b ? _a : _b; \ + }) +#endif +#endif + +#ifdef __cplusplus + +/* C++ prototypes */ +uint16_t makeWord(uint16_t w); +uint16_t makeWord(byte h, byte l); + +#define word(...) makeWord(__VA_ARGS__) + +unsigned long pulseIn(uint8_t pin, uint8_t state, unsigned long timeout = 1000000L); +unsigned long pulseInLong(uint8_t pin, uint8_t state, + unsigned long timeout = 1000000L); + +void tone(uint8_t _pin, unsigned int frequency, unsigned long duration = 0); +void noTone(uint8_t _pin); + +// WMath prototypes +long random(long); +long random(long, long); +void randomSeed(unsigned long); +long map(long, long, long, long, long); + +#endif // __cplusplus + +#endif // COMMON_H_ diff --git a/src/utils/compiler_config.h b/src/utils/compiler_config.h new file mode 100644 index 0000000..6573261 --- /dev/null +++ b/src/utils/compiler_config.h @@ -0,0 +1,20 @@ +#ifndef COMPILER_CONFIG_H_ +#define COMPILER_CONFIG_H_ + +// Warning suppression macros — used by dtostrf.h and third-party headers. + +#define USEQ_SUPPRESS_WARNINGS_PUSH \ + _Pragma("GCC diagnostic push") _Pragma("GCC diagnostic ignored \"-Wall\"") \ + _Pragma("GCC diagnostic ignored \"-Wextra\"") \ + _Pragma("GCC diagnostic ignored \"-Wpedantic\"") + +#define USEQ_SUPPRESS_WARNINGS_POP _Pragma("GCC diagnostic pop") + +#define USEQ_SUPPRESS_FORMAT_WARNINGS_PUSH \ + _Pragma("GCC diagnostic push") \ + _Pragma("GCC diagnostic ignored \"-Wformat-nonliteral\"") \ + _Pragma("GCC diagnostic ignored \"-Wdeprecated-declarations\"") + +#define USEQ_SUPPRESS_FORMAT_WARNINGS_POP _Pragma("GCC diagnostic pop") + +#endif // COMPILER_CONFIG_H_ diff --git a/src/utils/dtostrf.h b/src/utils/dtostrf.h new file mode 100644 index 0000000..79b09c5 --- /dev/null +++ b/src/utils/dtostrf.h @@ -0,0 +1,52 @@ +#ifndef DTOSTRF_H_ +#define DTOSTRF_H_ + +/* + dtostrf - Emulation for dtostrf function from avr-libc + Copyright (c) 2015 Arduino LLC. All rights reserved. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#pragma once + +#if !defined(ARDUINO_ARCH_AVR) +#ifdef __cplusplus +extern "C" +{ +#endif + +// char *dtostrf(double val, signed char width, unsigned char prec, char *sout); +#include + + char* dtostrf(double val, signed char width, unsigned char prec, char* sout) + { + asm(".global _printf_float"); + +#include "compiler_config.h" + USEQ_SUPPRESS_FORMAT_WARNINGS_PUSH + char fmt[20]; + sprintf(fmt, "%%%d.%df", width, prec); + sprintf(sout, fmt, val); + return sout; + USEQ_SUPPRESS_FORMAT_WARNINGS_POP + } + +#ifdef __cplusplus +} +#endif + +#endif +#endif // DTOSTRF_H_ diff --git a/src/utils/itoa.cpp b/src/utils/itoa.cpp new file mode 100644 index 0000000..f968675 --- /dev/null +++ b/src/utils/itoa.cpp @@ -0,0 +1,82 @@ +#include "itoa.h" +/* + * Copyright (c) 2020 Arduino. All rights reserved. + */ + +#ifdef ARDUINO + +#else + +/************************************************************************************** + * INCLUDE + **************************************************************************************/ + +#include "itoa.h" + +#include +#include + +#include + +/************************************************************************************** + * FUNCTION IMPLEMENTATION + **************************************************************************************/ + +std::string radixToFmtString(int const radix) +{ + if (radix == 8) + return std::string("%o"); + else if (radix == 10) + return std::string("%d"); + else if (radix == 16) + return std::string("%X"); + else + { +#ifndef ARDUINO + throw std::runtime_error("Invalid radix."); +#endif + return ""; + } +} + +char* itoa(int value, char* str, int radix) +{ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic ignored "-Wformat-nonliteral" + sprintf(str, radixToFmtString(radix).c_str(), value); +#pragma GCC diagnostic pop + return str; +} + +char* ltoa(long value, char* str, int radix) +{ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic ignored "-Wformat-nonliteral" + sprintf(str, radixToFmtString(radix).c_str(), value); +#pragma GCC diagnostic pop + return str; +} + +char* utoa(unsigned value, char* str, int radix) +{ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic ignored "-Wformat-nonliteral" + sprintf(str, radixToFmtString(radix).c_str(), value); +#pragma GCC diagnostic pop + return str; +} + +char* ultoa(unsigned long value, char* str, int radix) +{ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic ignored "-Wformat-nonliteral" + sprintf(str, radixToFmtString(radix).c_str(), value); +#pragma GCC diagnostic pop + return str; +} + +#endif // ifdef ARDUINO diff --git a/src/utils/itoa.h b/src/utils/itoa.h new file mode 100644 index 0000000..5a805c1 --- /dev/null +++ b/src/utils/itoa.h @@ -0,0 +1,46 @@ +#ifndef ITOA_H_ +#define ITOA_H_ +/* + Copyright (c) 2016 Arduino LLC. All right reserved. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifdef ARDUINO + +#else + +#pragma once + +// Standard C functions required in Arduino API +// If these functions are not provided by the standard library, the +// core should supply an implementation of them. + +#ifdef __cplusplus +extern "C" +{ +#endif + + extern char* itoa(int value, char* string, int radix); + extern char* ltoa(long value, char* string, int radix); + extern char* utoa(unsigned value, char* string, int radix); + extern char* ultoa(unsigned long value, char* string, int radix); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // ifdef ARDUINO +#endif // ITOA_H_ diff --git a/src/utils/json_builder.h b/src/utils/json_builder.h new file mode 100644 index 0000000..c87913d --- /dev/null +++ b/src/utils/json_builder.h @@ -0,0 +1,220 @@ +#ifndef JSON_BUILDER_H_ +#define JSON_BUILDER_H_ + +#include "string.h" +#include + +/** + * @brief Lightweight fluent JSON builder using the project's String type. + * + * Hand-rolled string concatenation behind a clean API so callers don't + * embed raw JSON punctuation. If we ever adopt ArduinoJson or similar, + * only this file needs to change. + * + * Usage: + * String json = JsonBuilder() + * .object_begin() + * .field("success", true) + * .field("mode", String("json")) + * .field("count", 42) + * .field_null("meta") + * .array_begin("items") + * .object_begin() + * .field("index", 1) + * .field("name", String("ssin1")) + * .object_end() + * .array_end() + * .object_end() + * .build(); + */ +class JsonBuilder +{ +public: + JsonBuilder() { m_buf.reserve(128); } + + // ---- structure ---- + + JsonBuilder& object_begin() + { + maybe_comma(); + m_buf += '{'; + push_scope(); + return *this; + } + + JsonBuilder& object_end() + { + pop_scope(); + m_buf += '}'; + return *this; + } + + JsonBuilder& array_begin(const String& key) + { + maybe_comma(); + append_key(key); + m_buf += '['; + push_scope(); + return *this; + } + + JsonBuilder& array_begin_unkeyed() + { + maybe_comma(); + m_buf += '['; + push_scope(); + return *this; + } + + JsonBuilder& array_end() + { + pop_scope(); + m_buf += ']'; + return *this; + } + + // ---- keyed fields ---- + + JsonBuilder& field(const String& key, const String& value) + { + maybe_comma(); + append_key(key); + m_buf += '"'; + append_escaped(value); + m_buf += '"'; + return *this; + } + + JsonBuilder& field(const String& key, const char* value) + { + return field(key, String(value)); + } + + JsonBuilder& field(const String& key, bool value) + { + maybe_comma(); + append_key(key); + m_buf += value ? "true" : "false"; + return *this; + } + + JsonBuilder& field(const String& key, int value) + { + maybe_comma(); + append_key(key); + char numbuf[16]; + snprintf(numbuf, sizeof(numbuf), "%d", value); + m_buf += numbuf; + return *this; + } + + JsonBuilder& field_null(const String& key) + { + maybe_comma(); + append_key(key); + m_buf += "null"; + return *this; + } + + /** Insert a pre-built JSON fragment as the value for @p key. */ + JsonBuilder& field_raw(const String& key, const String& raw_json) + { + maybe_comma(); + append_key(key); + m_buf += raw_json; + return *this; + } + + // ---- terminal ---- + + String build() const { return m_buf; } + +private: + static constexpr int MAX_DEPTH = 8; + + String m_buf; + bool m_need_comma[MAX_DEPTH] = {}; + int m_depth = 0; + + void push_scope() + { + if (m_depth < MAX_DEPTH) + { + m_need_comma[m_depth] = false; + ++m_depth; + } + } + + void pop_scope() + { + if (m_depth > 0) + { + --m_depth; + } + // After closing a scope, the parent scope needs a comma before the next element + if (m_depth > 0) + { + m_need_comma[m_depth - 1] = true; + } + } + + void maybe_comma() + { + if (m_depth > 0 && m_need_comma[m_depth - 1]) + { + m_buf += ','; + } + if (m_depth > 0) + { + m_need_comma[m_depth - 1] = true; + } + } + + void append_key(const String& key) + { + m_buf += '"'; + append_escaped(key); + m_buf += "\":"; + } + + void append_escaped(const String& s) + { + for (unsigned int i = 0; i < s.length(); ++i) + { + char c = s[i]; + switch (c) + { + case '"': + m_buf += "\\\""; + break; + case '\\': + m_buf += "\\\\"; + break; + case '\n': + m_buf += "\\n"; + break; + case '\r': + m_buf += "\\r"; + break; + case '\t': + m_buf += "\\t"; + break; + default: + if (static_cast(c) < 0x20) + { + char ubuf[7]; + snprintf(ubuf, sizeof(ubuf), "\\u%04X", + static_cast(c)); + m_buf += ubuf; + } + else + { + m_buf += c; + } + break; + } + } + } +}; + +#endif // JSON_BUILDER_H_ diff --git a/src/utils/json_cursor.h b/src/utils/json_cursor.h new file mode 100644 index 0000000..c4e024c --- /dev/null +++ b/src/utils/json_cursor.h @@ -0,0 +1,303 @@ +#pragma once + +#include +#include +#include +#include + +namespace useq::json { + +enum class Kind : unsigned char { + Invalid, + String, + Number, + Object, + Array, + Boolean, + Null, +}; + +struct Value { + const char* data = nullptr; + size_t size = 0; + Kind kind = Kind::Invalid; + + explicit operator bool() const { return data != nullptr; } +}; + +namespace detail { + +inline bool is_space(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +inline const char* skip_space(const char* cursor, const char* end) { + while (cursor < end && is_space(*cursor)) ++cursor; + return cursor; +} + +inline const char* scan_string(const char* cursor, const char* end) { + if (cursor >= end || *cursor != '"') return nullptr; + ++cursor; + bool escaped = false; + while (cursor < end) { + const char c = *cursor++; + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + return cursor; + } else if (static_cast(c) < 0x20) { + return nullptr; + } + } + return nullptr; +} + +inline const char* scan_compound(const char* cursor, const char* end) { + const char opening = *cursor; + const char closing = opening == '{' ? '}' : ']'; + size_t depth = 0; + while (cursor < end) { + if (*cursor == '"') { + cursor = scan_string(cursor, end); + if (!cursor) return nullptr; + continue; + } + if (*cursor == opening) { + ++depth; + } else if (*cursor == closing) { + if (--depth == 0) return cursor + 1; + } else if (*cursor == '{' || *cursor == '[') { + const char* nested = scan_compound(cursor, end); + if (!nested) return nullptr; + cursor = nested; + continue; + } + ++cursor; + } + return nullptr; +} + +inline bool scan_value(const char* cursor, const char* end, + Value& value, const char*& after) { + cursor = skip_space(cursor, end); + if (cursor >= end) return false; + const char* value_end = nullptr; + Kind kind = Kind::Invalid; + if (*cursor == '"') { + kind = Kind::String; + value_end = scan_string(cursor, end); + } else if (*cursor == '{') { + kind = Kind::Object; + value_end = scan_compound(cursor, end); + } else if (*cursor == '[') { + kind = Kind::Array; + value_end = scan_compound(cursor, end); + } else { + value_end = cursor; + while (value_end < end && !is_space(*value_end) && + *value_end != ',' && *value_end != '}' && *value_end != ']') { + ++value_end; + } + if (value_end == cursor) return false; + const size_t length = static_cast(value_end - cursor); + if (length == 4 && std::memcmp(cursor, "true", 4) == 0) { + kind = Kind::Boolean; + } else if (length == 5 && std::memcmp(cursor, "false", 5) == 0) { + kind = Kind::Boolean; + } else if (length == 4 && std::memcmp(cursor, "null", 4) == 0) { + kind = Kind::Null; + } else { + kind = Kind::Number; + } + } + if (!value_end) return false; + value = {cursor, static_cast(value_end - cursor), kind}; + after = value_end; + return true; +} + +} // namespace detail + +inline bool copy_string(const Value& value, char* out, size_t capacity, + bool* truncated = nullptr) { + if (truncated) *truncated = false; + if (!out || capacity == 0 || value.kind != Kind::String || value.size < 2) { + if (out && capacity) out[0] = '\0'; + return false; + } + const char* cursor = value.data + 1; + const char* end = value.data + value.size - 1; + size_t written = 0; + bool overflow = false; + while (cursor < end) { + char c = *cursor++; + if (c == '\\') { + if (cursor >= end) return false; + switch (*cursor++) { + case '"': c = '"'; break; + case '\\': c = '\\'; break; + case '/': c = '/'; break; + case 'b': c = '\b'; break; + case 'f': c = '\f'; break; + case 'n': c = '\n'; break; + case 'r': c = '\r'; break; + case 't': c = '\t'; break; + default: return false; // Unicode escapes are not in the wire subset. + } + } + if (written + 1 < capacity) out[written++] = c; + else overflow = true; + } + out[written] = '\0'; + if (truncated) *truncated = overflow; + return !overflow; +} + +inline bool number(const Value& value, double& result) { + if (value.kind != Kind::Number || value.size == 0 || value.size >= 64) { + return false; + } + char buffer[64]; + std::memcpy(buffer, value.data, value.size); + buffer[value.size] = '\0'; + char* after = nullptr; + result = std::strtod(buffer, &after); + return after == buffer + value.size; +} + +inline bool integer(const Value& value, int& result) { + double parsed = 0.0; + if (!number(value, parsed)) return false; + if (parsed != parsed) return false; // NaN + if (parsed < static_cast(INT_MIN) || + parsed > static_cast(INT_MAX)) return false; + const int converted = static_cast(parsed); + if (static_cast(converted) != parsed) return false; + result = converted; + return true; +} + +inline bool boolean(const Value& value, bool& result) { + if (value.kind != Kind::Boolean) return false; + result = value.size == 4; + return true; +} + +class ObjectCursor { +public: + ObjectCursor(const char* json, size_t size) + : begin_(json), cursor_(json), end_(json ? json + size : nullptr) { + if (!json) return; + cursor_ = detail::skip_space(cursor_, end_); + if (cursor_ >= end_ || *cursor_ != '{') return; + ++cursor_; + valid_ = true; + } + + explicit ObjectCursor(const Value& object) + : ObjectCursor(object.data, object.size) { + if (object.kind != Kind::Object) valid_ = false; + } + + bool next(Value& key, Value& value) { + if (!valid_ || finished_) return false; + cursor_ = detail::skip_space(cursor_, end_); + if (cursor_ < end_ && *cursor_ == '}') { + finished_ = true; + ++cursor_; + return false; + } + const char* key_end = detail::scan_string(cursor_, end_); + if (!key_end) return fail(); + key = {cursor_, static_cast(key_end - cursor_), Kind::String}; + cursor_ = detail::skip_space(key_end, end_); + if (cursor_ >= end_ || *cursor_ != ':') return fail(); + ++cursor_; + const char* after = nullptr; + if (!detail::scan_value(cursor_, end_, value, after)) return fail(); + cursor_ = detail::skip_space(after, end_); + if (cursor_ < end_ && *cursor_ == ',') { + ++cursor_; + } else if (cursor_ >= end_ || *cursor_ != '}') { + return fail(); + } + return true; + } + + bool valid() const { return valid_; } + bool finished() const { return finished_; } + + bool find(const char* name, Value& value) { + if (!name || !valid_ || !begin_ || !end_) return false; + ObjectCursor scan(begin_, static_cast(end_ - begin_)); + Value key; + char decoded[80]; + while (scan.next(key, value)) { + if (copy_string(key, decoded, sizeof(decoded)) && + std::strcmp(decoded, name) == 0) { + return true; + } + } + return false; + } + +private: + bool fail() { + valid_ = false; + return false; + } + + const char* begin_ = nullptr; + const char* cursor_ = nullptr; + const char* end_ = nullptr; + bool valid_ = false; + bool finished_ = false; +}; + +class ArrayCursor { +public: + explicit ArrayCursor(const Value& array) + : cursor_(array.data), end_(array.data ? array.data + array.size : nullptr) { + if (array.kind != Kind::Array || !cursor_ || cursor_ >= end_ || + *cursor_ != '[') return; + ++cursor_; + valid_ = true; + } + + bool next(Value& value) { + if (!valid_ || finished_) return false; + cursor_ = detail::skip_space(cursor_, end_); + if (cursor_ < end_ && *cursor_ == ']') { + finished_ = true; + ++cursor_; + return false; + } + const char* after = nullptr; + if (!detail::scan_value(cursor_, end_, value, after)) return fail(); + cursor_ = detail::skip_space(after, end_); + if (cursor_ < end_ && *cursor_ == ',') { + ++cursor_; + } else if (cursor_ >= end_ || *cursor_ != ']') { + return fail(); + } + return true; + } + + bool valid() const { return valid_; } + bool finished() const { return finished_; } + +private: + bool fail() { + valid_ = false; + return false; + } + const char* cursor_ = nullptr; + const char* end_ = nullptr; + bool valid_ = false; + bool finished_ = false; +}; + +} // namespace useq::json diff --git a/src/utils/log.cpp b/src/utils/log.cpp new file mode 100644 index 0000000..3695334 --- /dev/null +++ b/src/utils/log.cpp @@ -0,0 +1,424 @@ +#include "log.h" +#include "json_builder.h" + +#include +#include + +namespace +{ +struct JsonProtocolState +{ + bool json_mode_enabled = false; + bool request_active = false; + String request_id = ""; + String text_buffer = ""; +}; + +JsonProtocolState& state() +{ + static JsonProtocolState s; + return s; +} + +#ifdef ARDUINO +void write_serial_json(const String& payload) +{ + if (!Serial.availableForWrite()) + { + return; + } + + Serial.write(SerialMsg::message_begin_marker); + Serial.write((u_int8_t)SerialMsg::serial_message_types::JSON); + Serial.println(payload); +} +#else +void write_serial_json(const String&) +{ + // Desktop build has no serial transport; JSON responses are ignored. +} +#endif +} // namespace + +namespace Protocol +{ +void enable_json_mode() { state().json_mode_enabled = true; } + +void disable_json_mode() +{ + state().json_mode_enabled = false; + state().request_active = false; + state().text_buffer = ""; + state().request_id = ""; +} + +bool json_mode_enabled() { return state().json_mode_enabled; } + +void begin_request(const String& request_id) +{ + state().request_active = true; + state().request_id = request_id; + state().text_buffer = ""; +} + +void finish_request() +{ + state().request_active = false; + state().request_id = ""; + state().text_buffer = ""; +} + +bool request_active() { return state().request_active; } + +void append_request_text(const String& line) +{ + auto& json_state = state(); + if (!json_state.request_active) + { + return; + } + + if (json_state.text_buffer.length() > 0) + { + json_state.text_buffer += "\n"; + } + json_state.text_buffer += line; +} + +String consume_request_text() +{ + String copy = state().text_buffer; + state().text_buffer = ""; + return copy; +} + +void send_json_response(bool success, const String& text, + const std::optional& meta, + const String& request_id, + const std::optional& diagnostics_json) +{ + JsonBuilder b; + b.object_begin() + .field("type", "response") + .field("success", success) + .field("console", text) + .field("text", text); + + if (meta && meta->length() > 0) + { + b.field_raw("meta", *meta); + } + else + { + b.field_null("meta"); + } + + if (diagnostics_json && diagnostics_json->length() > 0) + { + b.field_raw("diagnostics", *diagnostics_json); + } + + b.field("requestId", request_id) + .object_end(); + + write_serial_json(b.build()); +} + +void send_json_error(const String& request_id, const String& message) +{ + send_json_response(false, message, std::nullopt, request_id); +} + +void send_raw_json(const String& payload) { write_serial_json(payload); } +} // namespace Protocol + +void message_editor(const String& s) +{ +#ifdef ARDUINO + if (Protocol::request_active()) + { + Protocol::append_request_text(s); + return; + } + + if (Protocol::json_mode_enabled()) + { + Protocol::send_json_response(true, s, std::nullopt, String()); + return; + } + + // Legacy framed-text (TEXT/MSG_TO_EDITOR bytes) removed per wire-protocol + // spec §5.6. firmware::Firmware always enables json_mode before reaching + // this point, so the branch above is the live path. +#else + std::cout << "[EDITOR] " << s.c_str() << std::endl; +#endif +} + +void println(const String& s) +{ +#ifdef ARDUINO + if (Protocol::request_active()) + { + Protocol::append_request_text(s); + return; + } + + if (Protocol::json_mode_enabled()) + { + Protocol::send_json_response(true, s, std::nullopt, String()); + return; + } + + // Legacy framed-text (TEXT byte) removed per wire-protocol spec §5.6. + // firmware::Firmware always enables json_mode before reaching this point, + // so the branch above is the live path. +#else + std::cout << s.c_str() << std::endl; +#endif +} + +// ERRORS +std::vector error_msg_q = {}; +void report_error(const String& s) { error_msg_q.push_back(s); } + +void report_generic_error(const String& s) +{ + report_error(String("**Error**: ") + s); +} + +void report_runtime_error(const String& s) +{ + report_error((String) "**Runtime Error**: " + s); +} + +void report_user_warning(const String& s) { report_error("**Warning**: " + s); } + +void report_evaluation_error(const String& error_msg, String atom) +{ + String msg = "**Evaluation Error**: While evaluating atom " + atom + + ", the following error ocurred:\n " + error_msg; + report_error(msg); +} + +String comp_to_string(NumArgsComparison comp) +{ + switch (comp) + { + case NumArgsComparison::EqualTo: + { + return "exactly"; + } + case NumArgsComparison::AtLeast: + { + return "at least"; + } + case NumArgsComparison::AtMost: + { + return "at most"; + } + case NumArgsComparison::Between: + { + return "between"; + } + default: + return ""; + } + + return ""; +} + +void report_error_wrong_num_args(const String& function_name, int num_received, + NumArgsComparison expected_comp, int num, + int num2 = 0) +{ + String expected_comparison_str = comp_to_string(expected_comp); + String received_num_str = String(num_received); + + expected_comparison_str += " " + String(num); + + if (expected_comp == NumArgsComparison::Between) + { + expected_comparison_str += " and " + String(num2); + } + + report_error("(`" + function_name + "`) Wrong number of arguments: expected " + + expected_comparison_str + " but received " + received_num_str + + " instead."); +} + +void report_error_arg_is_error(const String& function_name, int num, + const String& received_val_str) +{ + String msg = "(`" + function_name + "`) Argument #" + String(num) + + " evaluates to an error:"; + msg += "\n " + received_val_str; + report_error(msg); +} + +void report_error_wrong_all_pred(const String& function_name, int num, + const String& expected_str, + const String& received_val_str) +{ + String msg = "(`" + function_name + "`) All arguments should evaluate to " + + expected_str + ", but argument #" + String(num) + " does not:"; + msg += "\n " + received_val_str; + report_error(msg); +} + +void report_error_wrong_specific_pred(const String& function_name, int num, + const String& expected_str, + const String& received_val_str) +{ + String msg = "(`" + function_name + "`) Argument #" + String(num) + + " should evaluate to " + expected_str + ", but instead it is:"; + msg += "\n " + received_val_str; + report_error(msg); +} + +void report_error_atom_not_defined(const String& atom) +{ + report_error("Atom **" + atom + "** not defined."); +} + +void report_custom_function_error(const String& function_name, const String& msg) +{ + report_error("(`" + function_name + "`) " + msg); +} + +int free_heap() { + #ifdef ARDUINO + return rp2040.getFreeHeap(); + #else + return 1000000; // Dummy value for non-Arduino environments + #endif +} + +// DebugLogger + +#if USEQ_DEBUG + +std::unordered_set DebugLogger::mutes = {}; +std::unordered_set DebugLogger::solos = { "uSEQ::eval_at_time" }; + +bool DebugLogger::print_free_heap = false; + +int DebugLogger::m_level = -1; + +String DebugLogger::m_spaces = ""; + +void DebugLogger::inc_level() +{ + m_level += 1; + update_spaces(); +} + +void DebugLogger::dec_level() +{ + m_level -= 1; + update_spaces(); +} + +DebugLogger::DebugLogger(String s) : m_name(s) +{ + start_heap = free_heap(); + inc_level(); + + debug_print("[DBG] " + m_name + " START..."); + inc_level(); + if (print_free_heap) + { + debug_print("free heap (start): " + String(start_heap) + " bytes"); + } +} + +DebugLogger::~DebugLogger() +{ + int end_heap = free_heap(); + + dec_level(); + + debug_print("[DBG] " + m_name + " END."); + if (print_free_heap) + { + debug_print(m_spaces + "free heap (end): " + String(end_heap) + + " bytes"); + debug_print(m_spaces + "heap difference: " + String(end_heap - start_heap) + + " bytes"); + } + + dec_level(); +} + +void DebugLogger::filtered_log(const String& message) +{ + bool should_print = true; + + // If solos have been specified and this isn't in them, don't print + if (solos.size() > 0 && !solos.count(m_name)) + { + should_print = false; + } + // Otherwise, print as long as it's not in the mutes + else if (mutes.count(m_name)) + { + should_print = false; + } + + if (should_print) + { + debug_print("[" + m_name + "] " + addIndentAfterNewlines(message)); + } +} + +void DebugLogger::operator()(const String& message) { filtered_log(message); } + +void DebugLogger::debug_print(const String& s) +{ + print(m_spaces + s); + print("\n"); +} + +String DebugLogger::addIndentAfterNewlines(const String& input) +{ + String result; + int lastIndex = 0; + int currentIndex = 0; + + // Search for newline characters and modify the string + while ((currentIndex = input.indexOf('\n', lastIndex)) != -1) + { + // Add the current segment plus newline + result += input.substring(lastIndex, currentIndex + 1); + // Add spaces after the newline + result += m_spaces; + // Move past the newline character + lastIndex = currentIndex + 1; + } + + // Add the last part of the string if any + result += input.substring(lastIndex); + + return result; +} + +void DebugLogger::update_spaces() +{ + String s = ""; + + if (m_level > 0) + { + for (int i = 0; i < m_level; i++) + { + s += " "; + } + + m_spaces = s; + } + else + { + m_spaces = ""; + } +} + +#endif // USEQ_DEBUG diff --git a/src/utils/log.h b/src/utils/log.h new file mode 100644 index 0000000..dcc019c --- /dev/null +++ b/src/utils/log.h @@ -0,0 +1,110 @@ +#ifndef LOG_H_ +#define LOG_H_ + +#include "serial_message.h" +#include "string.h" +#include +#include + +extern std::vector error_msg_q; + +void message_editor(const String& s); +void println(const String& s); + +namespace Protocol +{ +void enable_json_mode(); +void disable_json_mode(); +bool json_mode_enabled(); + +void begin_request(const String& request_id); +void finish_request(); +bool request_active(); +void append_request_text(const String& line); +String consume_request_text(); + +void send_json_response(bool success, const String& text, + const std::optional& meta, + const String& request_id, + const std::optional& diagnostics_json = std::nullopt); +void send_json_error(const String& request_id, const String& message); +void send_raw_json(const String& payload); +} // namespace Protocol + +void debug(String s); + +void report_error(const String& s); +void report_generic_error(const String& s); +void report_runtime_error(const String& s); +void report_user_warning(const String& s); + +enum class UserError +{ + WrongNumArgs, + SpecificArgType, + AllArgsType +}; + +enum class NumArgsComparison +{ + EqualTo, + AtLeast, + AtMost, + Between +}; + +void report_error_wrong_num_args(const String& function_name, int num_received, + NumArgsComparison comp, int num, int num2); + +void report_error_arg_is_error(const String& function_name, int num, + const String& received_val_str); + +void report_error_wrong_all_pred(const String& function_name, int num, + const String& expected_str, + const String& received_val_str); + +void report_error_wrong_specific_pred(const String& function_name, int num, + const String& expected_str, + const String& received_val_str); + +void report_error_atom_not_defined(const String& atom); + +void report_custom_function_error(const String& function_name, const String& msg); + +int free_heap(); + +#if USEQ_DEBUG + +#include + +class DebugLogger +{ +public: + DebugLogger(String name); + ~DebugLogger(); + + // These are the same, log is more explicit + void log(const String& message); + void operator()(const String& message); + + static bool print_free_heap; + static std::unordered_set mutes; + static std::unordered_set solos; + +private: + String m_name; + void dbg_print(const String& s); + + int start_heap = 0; + String addIndentAfterNewlines(const String& input); + + static int m_level; + static String m_spaces; + static void update_spaces(); + static void inc_level(); + static void dec_level(); +}; + +#endif // USEQ_DEBUG + +#endif // LOG_H_ diff --git a/src/utils/serial_message.h b/src/utils/serial_message.h new file mode 100644 index 0000000..535697e --- /dev/null +++ b/src/utils/serial_message.h @@ -0,0 +1,28 @@ +#ifndef SERIAL_MESSAGE_H +#define SERIAL_MESSAGE_H + +#include + +namespace SerialMsg +{ + +constexpr uint8_t message_begin_marker = 31; + +enum serial_message_types +{ + // TEXT (0x20) and MSG_TO_EDITOR (0x64) removed: replaced by + // {type:"log",...} JSON envelope per wire-protocol spec §5.6. + // message_end_marker (0x03) removed: never sent by firmware::Firmware. + // execute_now_marker ('@') removed: wire is immediate-only per spec §1.1. + JSON = 101, + STREAM = 0, + // INPUT_SET (wire-protocol §6.5): editor → device high-rate batched + // live-edit slot writes. Frame: [0x1F][0x01][count:u16-LE] + // [(slot_index:u16-LE, value:f64-LE) × count] + INPUT_SET = 1 +}; +constexpr unsigned long serial_message_rate_limit = 1000000 / 100 /*Hz*/; + +}; // namespace SerialMsg + +#endif diff --git a/src/utils/string.cpp b/src/utils/string.cpp new file mode 100644 index 0000000..1e25b95 --- /dev/null +++ b/src/utils/string.cpp @@ -0,0 +1,887 @@ + +/* + String library for Wiring & Arduino + ...mostly rewritten by Paul Stoffregen... + Copyright (c) 2009-10 Hernando Barragan. All rights reserved. + Copyright 2011, Paul Stoffregen, paul@pjrc.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "string.h" +#include +#include + +#ifdef ARDUINO + +// nothing + +#elif defined(USE_STD_STR) + +// nothing + +#elif defined(USE_ARDUINO_STR) || defined(USE_OWN_ARDUINO_STR) + +/* + String library for Wiring & Arduino + ...mostly rewritten by Paul Stoffregen... + Copyright (c) 2009-10 Hernando Barragan. All rights reserved. + Copyright 2011, Paul Stoffregen, paul@pjrc.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "common.h" +#include "dtostrf.h" +#include "itoa.h" +#include "string.h" + +#include + +namespace arduino +{ + +/*********************************************/ +/* Static Member Initialisation */ +/*********************************************/ + +size_t const String::FLT_MAX_DECIMAL_PLACES; +size_t const String::DBL_MAX_DECIMAL_PLACES; + +/*********************************************/ +/* Constructors */ +/*********************************************/ + +String::String(const char* cstr) +{ + init(); + if (cstr) + copy(cstr, static_cast(strlen(cstr))); +} + +String::String(const char* cstr, unsigned int length) +{ + init(); + if (cstr) + copy(cstr, length); +} + +String::String(const String& value) +{ + init(); + *this = value; +} +// +// String::String(const __FlashStringHelper *pstr) +//{ +// init(); +// *this = pstr; +//} + +String::String(String&& rval) + : buffer(rval.buffer), capacity(rval.capacity), len(rval.len) +{ + rval.buffer = NULL; + rval.capacity = 0; + rval.len = 0; +} + +String::String(char c) +{ + init(); + char buf[2]; + buf[0] = c; + buf[1] = 0; + *this = buf; +} + +String::String(unsigned char value, unsigned char base) +{ + init(); + char buf[1 + 8 * sizeof(unsigned char)]; + utoa(value, buf, base); + *this = buf; +} + +String::String(int value, unsigned char base) +{ + init(); + char buf[2 + 8 * sizeof(int)]; + itoa(value, buf, base); + *this = buf; +} + +String::String(unsigned int value, unsigned char base) +{ + init(); + char buf[1 + 8 * sizeof(unsigned int)]; + utoa(value, buf, base); + *this = buf; +} + +String::String(long value, unsigned char base) +{ + init(); + char buf[2 + 8 * sizeof(long)]; + ltoa(value, buf, base); + *this = buf; +} + +String::String(unsigned long value, unsigned char base) +{ + init(); + char buf[1 + 8 * sizeof(unsigned long)]; + ultoa(value, buf, base); + *this = buf; +} + +String::String(float value, unsigned char decimalPlaces) +{ + static size_t const FLOAT_BUF_SIZE = FLT_MAX_10_EXP + FLT_MAX_DECIMAL_PLACES + + 1 /* '-' */ + 1 /* '.' */ + 1 /* '\0' */; + init(); + char buf[FLOAT_BUF_SIZE]; + decimalPlaces = + min(decimalPlaces, static_cast(FLT_MAX_DECIMAL_PLACES)); + *this = dtostrf(value, static_cast(decimalPlaces + 2), + decimalPlaces, buf); +} + +String::String(double value, unsigned char decimalPlaces) +{ + static size_t const DOUBLE_BUF_SIZE = DBL_MAX_10_EXP + DBL_MAX_DECIMAL_PLACES + + 1 /* '-' */ + 1 /* '.' */ + 1 /* '\0' */; + init(); + char buf[DOUBLE_BUF_SIZE]; + decimalPlaces = + min(decimalPlaces, static_cast(DBL_MAX_DECIMAL_PLACES)); + + // Check if the value is a whole number (no fractional part) + if (value == floor(value) && value >= INT_MIN && value <= INT_MAX) + { + // Format as integer (no decimal places) + *this = dtostrf(value, 1, 0, buf); + } + else + { + // Format with specified decimal places + *this = dtostrf(value, static_cast(decimalPlaces + 2), + decimalPlaces, buf); + } +} + +String::~String() +{ + if (buffer) + free(buffer); +} + +/*********************************************/ +/* Memory Management */ +/*********************************************/ + +inline void String::init(void) +{ + buffer = NULL; + capacity = 0; + len = 0; +} + +void String::invalidate(void) +{ + if (buffer) + free(buffer); + buffer = NULL; + capacity = len = 0; +} + +bool String::reserve(unsigned int size) +{ + if (buffer && capacity >= size) + return 1; + if (changeBuffer(size)) + { + if (len == 0) + buffer[0] = 0; + return true; + } + return false; +} + +bool String::changeBuffer(unsigned int maxStrLen) +{ + char* newbuffer = (char*)realloc(buffer, maxStrLen + 1); + if (newbuffer) + { + buffer = newbuffer; + capacity = maxStrLen; + return true; + } + return false; +} + +/*********************************************/ +/* Copy and Move */ +/*********************************************/ + +String& String::copy(const char* cstr, unsigned int length) +{ + if (!reserve(length)) + { + invalidate(); + return *this; + } + len = length; + memcpy(buffer, cstr, length); + buffer[len] = '\0'; + return *this; +} +// +// String & String::copy(const __FlashStringHelper *pstr, unsigned int length) +//{ +// if (!reserve(length)) { +// invalidate(); +// return *this; +// } +// len = length; +// strcpy_P(buffer, (PGM_P)pstr); +// return *this; +//} + +void String::move(String& rhs) +{ + if (this != &rhs) + { + free(buffer); + + buffer = rhs.buffer; + len = rhs.len; + capacity = rhs.capacity; + + rhs.buffer = NULL; + rhs.len = 0; + rhs.capacity = 0; + } +} + +String& String::operator=(const String& rhs) +{ + if (this == &rhs) + return *this; + + if (rhs.buffer) + copy(rhs.buffer, rhs.len); + else + invalidate(); + + return *this; +} + +String& String::operator=(String&& rval) +{ + move(rval); + return *this; +} + +String& String::operator=(const char* cstr) +{ + if (cstr) + copy(cstr, static_cast(strlen(cstr))); + else + invalidate(); + + return *this; +} +// +// String & String::operator = (const __FlashStringHelper *pstr) +//{ +// if (pstr) copy(pstr, strlen_P((PGM_P)pstr)); +// else invalidate(); +// +// return *this; +//} + +/*********************************************/ +/* concat */ +/*********************************************/ + +bool String::concat(const String& s) { return concat(s.buffer, s.len); } + +bool String::concat(const char* cstr, unsigned int length) +{ + unsigned int newlen = len + length; + if (!cstr) + return false; + if (length == 0) + return true; + if (!reserve(newlen)) + return false; + memcpy(buffer + len, cstr, length); + len = newlen; + buffer[len] = '\0'; + return true; +} + +bool String::concat(const char* cstr) +{ + if (!cstr) + return false; + return concat(cstr, static_cast(strlen(cstr))); +} + +bool String::concat(char c) { return concat(&c, 1); } + +bool String::concat(unsigned char num) +{ + char buf[1 + 3 * sizeof(unsigned char)]; + itoa(num, buf, 10); + return concat(buf); +} + +bool String::concat(int num) +{ + char buf[2 + 3 * sizeof(int)]; + itoa(num, buf, 10); + return concat(buf); +} + +bool String::concat(unsigned int num) +{ + char buf[1 + 3 * sizeof(unsigned int)]; + utoa(num, buf, 10); + return concat(buf); +} + +bool String::concat(long num) +{ + char buf[2 + 3 * sizeof(long)]; + ltoa(num, buf, 10); + return concat(buf); +} + +bool String::concat(unsigned long num) +{ + char buf[1 + 3 * sizeof(unsigned long)]; + ultoa(num, buf, 10); + return concat(buf); +} + +bool String::concat(float num) +{ + char buf[20]; + char* string = dtostrf(num, 4, 2, buf); + return concat(string); +} + +bool String::concat(double num) +{ + char buf[20]; + char* string = dtostrf(num, 4, 2, buf); + return concat(string); +} +// +// bool String::concat(const __FlashStringHelper * str) +//{ +// if (!str) return false; +// int length = strlen_P((const char *) str); +// if (length == 0) return true; +// unsigned int newlen = len + length; +// if (!reserve(newlen)) return false; +// strcpy_P(buffer + len, (const char *) str); +// len = newlen; +// return true; +//} + +/*********************************************/ +/* Concatenate */ +/*********************************************/ + +StringSumHelper& operator+(const StringSumHelper& lhs, const String& rhs) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(rhs.buffer, rhs.len)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, const char* cstr) +{ + StringSumHelper& a = const_cast(lhs); + if (!cstr || !a.concat(cstr)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, char c) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(c)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, unsigned char num) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(num)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, int num) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(num)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, unsigned int num) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(num)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, long num) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(num)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, unsigned long num) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(num)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, float num) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(num)) + a.invalidate(); + return a; +} + +StringSumHelper& operator+(const StringSumHelper& lhs, double num) +{ + StringSumHelper& a = const_cast(lhs); + if (!a.concat(num)) + a.invalidate(); + return a; +} +// +// StringSumHelper & operator + (const StringSumHelper &lhs, const +// __FlashStringHelper *rhs) +//{ +// StringSumHelper &a = const_cast(lhs); +// if (!a.concat(rhs)) a.invalidate(); +// return a; +//} + +/*********************************************/ +/* Comparison */ +/*********************************************/ + +int String::compareTo(const String& s) const +{ + if (!buffer || !s.buffer) + { + if (s.buffer && s.len > 0) + return 0 - *(unsigned char*)s.buffer; + if (buffer && len > 0) + return *(unsigned char*)buffer; + return 0; + } + return strcmp(buffer, s.buffer); +} + +int String::compareTo(const char* cstr) const +{ + if (!buffer || !cstr) + { + if (cstr && *cstr) + return 0 - *(unsigned char*)cstr; + if (buffer && len > 0) + return *(unsigned char*)buffer; + return 0; + } + return strcmp(buffer, cstr); +} + +bool String::equals(const String& s2) const +{ + return (len == s2.len && compareTo(s2) == 0); +} + +bool String::equals(const char* cstr) const +{ + if (len == 0) + return (cstr == NULL || *cstr == 0); + if (cstr == NULL) + return buffer[0] == 0; + return strcmp(buffer, cstr) == 0; +} + +bool String::equalsIgnoreCase(const String& s2) const +{ + if (this == &s2) + return true; + if (len != s2.len) + return false; + if (len == 0) + return true; + const char* p1 = buffer; + const char* p2 = s2.buffer; + while (*p1) + { + if (tolower(*p1++) != tolower(*p2++)) + return false; + } + return true; +} + +bool String::startsWith(const String& s2) const +{ + if (len < s2.len) + return false; + return startsWith(s2, 0); +} + +bool String::startsWith(const String& s2, unsigned int offset) const +{ + if (offset > len - s2.len || !buffer || !s2.buffer) + return false; + return strncmp(&buffer[offset], s2.buffer, s2.len) == 0; +} + +bool String::endsWith(const String& s2) const +{ + if (len < s2.len || !buffer || !s2.buffer) + return false; + return strcmp(&buffer[len - s2.len], s2.buffer) == 0; +} + +/*********************************************/ +/* Character Access */ +/*********************************************/ + +char String::charAt(unsigned int loc) const { return operator[](loc); } + +void String::setCharAt(unsigned int loc, char c) +{ + if (loc < len) + buffer[loc] = c; +} + +char& String::operator[](unsigned int index) +{ + static char dummy_writable_char; + if (index >= len || !buffer) + { + dummy_writable_char = 0; + return dummy_writable_char; + } + return buffer[index]; +} + +char String::operator[](unsigned int index) const +{ + if (index >= len || !buffer) + return 0; + return buffer[index]; +} + +void String::getBytes(unsigned char* buf, unsigned int bufsize, + unsigned int index) const +{ + if (!bufsize || !buf) + return; + if (index >= len) + { + buf[0] = 0; + return; + } + unsigned int n = bufsize - 1; + if (n > len - index) + n = len - index; + strncpy((char*)buf, buffer + index, n); + buf[n] = 0; +} + +/*********************************************/ +/* Search */ +/*********************************************/ + +int String::indexOf(char c) const { return indexOf(c, 0); } + +int String::indexOf(char ch, unsigned int fromIndex) const +{ + if (fromIndex >= len) + return -1; + const char* temp = strchr(buffer + fromIndex, ch); + if (temp == NULL) + return -1; + return static_cast(temp - buffer); +} + +int String::indexOf(const String& s2) const { return indexOf(s2, 0); } + +int String::indexOf(const String& s2, unsigned int fromIndex) const +{ + if (fromIndex >= len) + return -1; + const char* found = strstr(buffer + fromIndex, s2.buffer); + if (found == NULL) + return -1; + return static_cast(found - buffer); +} + +int String::lastIndexOf(char theChar) const { return lastIndexOf(theChar, len - 1); } + +int String::lastIndexOf(char ch, unsigned int fromIndex) const +{ + if (fromIndex >= len) + return -1; + char tempchar = buffer[fromIndex + 1]; + buffer[fromIndex + 1] = '\0'; + char* temp = strrchr(buffer, ch); + buffer[fromIndex + 1] = tempchar; + if (temp == NULL) + return -1; + return static_cast(temp - buffer); +} + +int String::lastIndexOf(const String& s2) const +{ + return lastIndexOf(s2, len - s2.len); +} + +int String::lastIndexOf(const String& s2, unsigned int fromIndex) const +{ + if (s2.len == 0 || len == 0 || s2.len > len) + return -1; + if (fromIndex >= len) + fromIndex = len - 1; + int found = -1; + for (char* p = buffer; p <= buffer + fromIndex; p++) + { + p = strstr(p, s2.buffer); + if (!p) + break; + if ((unsigned int)(p - buffer) <= fromIndex) + found = static_cast(p - buffer); + } + return found; +} + +String String::substring(unsigned int left, unsigned int right) const +{ + if (left > right) + { + unsigned int temp = right; + right = left; + left = temp; + } + String out; + if (left >= len) + return out; + if (right > len) + right = len; + out.copy(buffer + left, right - left); + return out; +} + +/*********************************************/ +/* Modification */ +/*********************************************/ + +void String::replace(char find, char replace) +{ + if (!buffer) + return; + for (char* p = buffer; *p; p++) + { + if (*p == find) + *p = replace; + } +} + +void String::replace(const String& find, const String& replace) +{ + if (len == 0 || find.len == 0) + return; + int diff = replace.len - find.len; + char* readFrom = buffer; + char* foundAt; + if (diff == 0) + { + while ((foundAt = strstr(readFrom, find.buffer)) != NULL) + { + memcpy(foundAt, replace.buffer, replace.len); + readFrom = foundAt + replace.len; + } + } + else if (diff < 0) + { + unsigned int size = len; // compute size needed for result + diff = 0 - diff; + while ((foundAt = strstr(readFrom, find.buffer)) != NULL) + { + readFrom = foundAt + find.len; + size -= diff; + } + if (size == len) + return; + int index = len - 1; + while (index >= 0 && (index = lastIndexOf(find, index)) >= 0) + { + readFrom = buffer + index + find.len; + memmove(readFrom - diff, readFrom, len - (readFrom - buffer)); + len -= diff; + buffer[len] = 0; + memcpy(buffer + index, replace.buffer, replace.len); + index--; + } + } + else + { + unsigned int size = len; // compute size needed for result + while ((foundAt = strstr(readFrom, find.buffer)) != NULL) + { + readFrom = foundAt + find.len; + size += diff; + } + if (size == len) + return; + if (size > capacity && !changeBuffer(size)) + return; // XXX: tell user! + int index = len - 1; + while (index >= 0 && (index = lastIndexOf(find, index)) >= 0) + { + readFrom = buffer + index + find.len; + memmove(readFrom + diff, readFrom, len - (readFrom - buffer)); + len += diff; + buffer[len] = 0; + memcpy(buffer + index, replace.buffer, replace.len); + index--; + } + } +} + +void String::remove(unsigned int index) +{ + // Pass the biggest integer as the count. The remove method + // below will take care of truncating it at the end of the + // string. + remove(index, (unsigned int)-1); +} + +void String::remove(unsigned int index, unsigned int count) +{ + if (index >= len) + { + return; + } + if (count <= 0) + { + return; + } + if (count > len - index) + { + count = len - index; + } + char* writeTo = buffer + index; + len = len - count; + memmove(writeTo, buffer + index + count, len - index); + buffer[len] = 0; +} + +void String::toLowerCase(void) +{ + if (!buffer) + return; + for (char* p = buffer; *p; p++) + { + *p = static_cast(tolower(*p)); + } +} + +void String::toUpperCase(void) +{ + if (!buffer) + return; + for (char* p = buffer; *p; p++) + { + *p = static_cast(toupper(*p)); + } +} + +void String::trim(void) +{ + if (!buffer || len == 0) + return; + char* begin = buffer; + while (isspace(*begin)) + begin++; + char* end = buffer + len - 1; + while (isspace(*end) && end >= begin) + end--; + len = static_cast(end + 1 - begin); + if (begin > buffer) + memmove(buffer, begin, len); + buffer[len] = 0; +} + +/*********************************************/ +/* Parsing / Conversion */ +/*********************************************/ + +long String::toInt(void) const +{ + if (buffer) + return atol(buffer); + return 0; +} + +float String::toFloat(void) const { return float(toDouble()); } + +double String::toDouble(void) const +{ + if (buffer) + return atof(buffer); + return 0; +} + +} // namespace arduino + +#endif // defined(ARDUINO) diff --git a/src/utils/string.h b/src/utils/string.h new file mode 100644 index 0000000..abf7d7c --- /dev/null +++ b/src/utils/string.h @@ -0,0 +1,460 @@ +#ifndef STRING_H_ +#define STRING_H_ + +/* + String library for Wiring & Arduino + ...mostly rewritten by Paul Stoffregen... + Copyright (c) 2009-10 Hernando Barragan. All right reserved. + Copyright 2011, Paul Stoffregen, paul@pjrc.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#if defined(ARDUINO) + +#include + +#elif defined(USE_STD_STR) + +#include +#include +#include + +class Extended_STD_String : public std::string +{ +public: + // Inherit all constructors from std::string + using std::string::string; + + // Default constructor + Extended_STD_String() : std::string() {} + + // Constructor from std::string + Extended_STD_String(const std::string& str) : std::string(str) {} + Extended_STD_String(std::string&& str) : std::string(std::move(str)) {} + + // Arduino String compatibility constructors + Extended_STD_String(char c) : std::string(1, c) {} + Extended_STD_String(int value) : std::string(std::to_string(value)) {} + Extended_STD_String(long value) : std::string(std::to_string(value)) {} + Extended_STD_String(unsigned int value) : std::string(std::to_string(value)) {} + Extended_STD_String(unsigned long value) : std::string(std::to_string(value)) {} + Extended_STD_String(float value) : std::string(std::to_string(value)) {} + Extended_STD_String(double value) : std::string() + { + // Check if the value is a whole number (no fractional part) + if (value == std::floor(value) && value >= INT_MIN && value <= INT_MAX) + { + // Format as integer (no decimal places) + *this = std::to_string(static_cast(value)); + } + else + { + // Format with default precision + *this = std::to_string(value); + } + } + + // Assignment operators to handle std::string results + Extended_STD_String& operator=(const std::string& str) + { + std::string::assign(str); + return *this; + } + + // Concatenation operators that return Extended_STD_String + Extended_STD_String operator+(const Extended_STD_String& rhs) const + { + return Extended_STD_String(std::string(*this) + std::string(rhs)); + } + + Extended_STD_String operator+(const std::string& rhs) const + { + return Extended_STD_String(std::string(*this) + rhs); + } + + Extended_STD_String operator+(const char* rhs) const + { + return Extended_STD_String(std::string(*this) + rhs); + } + + // Arduino String compatibility methods + int indexOf(char ch) const + { + size_t pos = find(ch); + return (pos == std::string::npos) ? -1 : static_cast(pos); + } + + int indexOf(const std::string& str) const + { + size_t pos = find(str); + return (pos == std::string::npos) ? -1 : static_cast(pos); + } + + int indexOf(const char* str) const + { + size_t pos = find(str); + return (pos == std::string::npos) ? -1 : static_cast(pos); + } + + // Define a method named substring + std::string substring(size_t pos, size_t len) const { return substr(pos, len); } + std::string substring(size_t len) const { return substr(len); } +}; + +// Global operators for const char* + Extended_STD_String +inline Extended_STD_String operator+(const char* lhs, const Extended_STD_String& rhs) +{ + return Extended_STD_String(std::string(lhs) + std::string(rhs)); +} + +inline Extended_STD_String operator+(const std::string& lhs, + const Extended_STD_String& rhs) +{ + return Extended_STD_String(lhs + std::string(rhs)); +} + +using String = Extended_STD_String; + +#elif defined(USE_OWN_ARDUINO_STR) + +/* + String library for Wiring & Arduino + ...mostly rewritten by Paul Stoffregen... + Copyright (c) 2009-10 Hernando Barragan. All right reserved. + Copyright 2011, Paul Stoffregen, paul@pjrc.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifdef __cplusplus + +#ifndef __ARDUINO_STRINGS__ +#define __ARDUINO_STRINGS__ + +#include +#include +#include +#include + +// #if defined(__AVR__) +// #include "avr/pgmspace.h" +// #else +// #include "deprecated-avr-comp/avr/pgmspace.h" +// #endif + +namespace arduino +{ + +// When compiling programs with this class, the following gcc parameters +// dramatically increase performance and memory (RAM) efficiency, typically +// with little or no increase in code size. +// -felide-constructors +// -std=c++0x + +// class __FlashStringHelper; +// #define F(string_literal) (reinterpret_cast(PSTR(string_literal))) + +// An inherited class for holding the result of a concatenation. These +// result objects are assumed to be writable by subsequent concatenations. +class StringSumHelper; + +// The string class +class String +{ + friend class StringSumHelper; + // use a function pointer to allow for "if (s)" without the + // complications of an operator bool(). for more information, see: + // http://www.artima.com/cppsource/safebool.html + typedef void (String::*StringIfHelperType)() const; + void StringIfHelper() const {} + + static size_t const FLT_MAX_DECIMAL_PLACES = 10; + static size_t const DBL_MAX_DECIMAL_PLACES = FLT_MAX_DECIMAL_PLACES; + +public: + // constructors + // creates a copy of the initial value. + // if the initial value is null or invalid, or if memory allocation + // fails, the string will be marked as invalid (i.e. "if (s)" will + // be false). + String(const char* cstr = ""); + String(const char* cstr, unsigned int length); + String(const uint8_t* cstr, unsigned int length) + : String((const char*)cstr, length) + { + } + String(const String& str); + // String(const __FlashStringHelper *str); + String(String&& rval); + explicit String(char c); + explicit String(unsigned char, unsigned char base = 10); + explicit String(int, unsigned char base = 10); + explicit String(unsigned int, unsigned char base = 10); + explicit String(long, unsigned char base = 10); + explicit String(unsigned long, unsigned char base = 10); + explicit String(float, unsigned char decimalPlaces = 2); + explicit String(double, unsigned char decimalPlaces = 2); + ~String(void); + + // memory management + // return true on success, false on failure (in which case, the string + // is left unchanged). reserve(0), if successful, will validate an + // invalid string (i.e., "if (s)" will be true afterwards) + bool reserve(unsigned int size); + inline unsigned int length(void) const { return len; } + + // creates a copy of the assigned value. if the value is null or + // invalid, or if the memory allocation fails, the string will be + // marked as invalid ("if (s)" will be false). + String& operator=(const String& rhs); + String& operator=(const char* cstr); + // String & operator = (const __FlashStringHelper *str); + String& operator=(String&& rval); + + // concatenate (works w/ built-in types) + + // returns true on success, false on failure (in which case, the string + // is left unchanged). if the argument is null or invalid, the + // concatenation is considered unsuccessful. + bool concat(const String& str); + bool concat(const char* cstr); + bool concat(const char* cstr, unsigned int length); + bool concat(const uint8_t* cstr, unsigned int length) + { + return concat((const char*)cstr, length); + } + bool concat(char c); + bool concat(unsigned char num); + bool concat(int num); + bool concat(unsigned int num); + bool concat(long num); + bool concat(unsigned long num); + bool concat(float num); + bool concat(double num); + // bool concat(const __FlashStringHelper * str); + + // if there's not enough memory for the concatenated value, the string + // will be left unchanged (but this isn't signalled in any way) + String& operator+=(const String& rhs) + { + concat(rhs); + return (*this); + } + String& operator+=(const char* cstr) + { + concat(cstr); + return (*this); + } + String& operator+=(char c) + { + concat(c); + return (*this); + } + String& operator+=(unsigned char num) + { + concat(num); + return (*this); + } + String& operator+=(int num) + { + concat(num); + return (*this); + } + String& operator+=(unsigned int num) + { + concat(num); + return (*this); + } + String& operator+=(long num) + { + concat(num); + return (*this); + } + String& operator+=(unsigned long num) + { + concat(num); + return (*this); + } + String& operator+=(float num) + { + concat(num); + return (*this); + } + String& operator+=(double num) + { + concat(num); + return (*this); + } + // String & operator += (const __FlashStringHelper *str){concat(str); return + //(*this);} + + friend StringSumHelper& operator+(const StringSumHelper& lhs, const String& rhs); + friend StringSumHelper& operator+(const StringSumHelper& lhs, const char* cstr); + friend StringSumHelper& operator+(const StringSumHelper& lhs, char c); + friend StringSumHelper& operator+(const StringSumHelper& lhs, unsigned char num); + friend StringSumHelper& operator+(const StringSumHelper& lhs, int num); + friend StringSumHelper& operator+(const StringSumHelper& lhs, unsigned int num); + friend StringSumHelper& operator+(const StringSumHelper& lhs, long num); + friend StringSumHelper& operator+(const StringSumHelper& lhs, unsigned long num); + friend StringSumHelper& operator+(const StringSumHelper& lhs, float num); + friend StringSumHelper& operator+(const StringSumHelper& lhs, double num); + // friend StringSumHelper & operator + (const StringSumHelper &lhs, const + //__FlashStringHelper *rhs); + + // comparison (only works w/ Strings and "strings") + operator StringIfHelperType() const + { + return buffer ? &String::StringIfHelper : 0; + } + int compareTo(const String& s) const; + int compareTo(const char* cstr) const; + bool equals(const String& s) const; + bool equals(const char* cstr) const; + + friend bool operator==(const String& a, const String& b) { return a.equals(b); } + friend bool operator==(const String& a, const char* b) { return a.equals(b); } + friend bool operator==(const char* a, const String& b) { return b == a; } + friend bool operator<(const String& a, const String& b) + { + return a.compareTo(b) < 0; + } + friend bool operator<(const String& a, const char* b) + { + return a.compareTo(b) < 0; + } + friend bool operator<(const char* a, const String& b) + { + return b.compareTo(a) > 0; + } + + friend bool operator!=(const String& a, const String& b) { return !(a == b); } + friend bool operator!=(const String& a, const char* b) { return !(a == b); } + friend bool operator!=(const char* a, const String& b) { return !(a == b); } + friend bool operator>(const String& a, const String& b) { return b < a; } + friend bool operator>(const String& a, const char* b) { return b < a; } + friend bool operator>(const char* a, const String& b) { return b < a; } + friend bool operator<=(const String& a, const String& b) { return !(b < a); } + friend bool operator<=(const String& a, const char* b) { return !(b < a); } + friend bool operator<=(const char* a, const String& b) { return !(b < a); } + friend bool operator>=(const String& a, const String& b) { return !(a < b); } + friend bool operator>=(const String& a, const char* b) { return !(a < b); } + friend bool operator>=(const char* a, const String& b) { return !(a < b); } + + bool equalsIgnoreCase(const String& s) const; + bool startsWith(const String& prefix) const; + bool startsWith(const String& prefix, unsigned int offset) const; + bool endsWith(const String& suffix) const; + + // character access + char charAt(unsigned int index) const; + void setCharAt(unsigned int index, char c); + char operator[](unsigned int index) const; + char& operator[](unsigned int index); + void getBytes(unsigned char* buf, unsigned int bufsize, + unsigned int index = 0) const; + void toCharArray(char* buf, unsigned int bufsize, unsigned int index = 0) const + { + getBytes((unsigned char*)buf, bufsize, index); + } + const char* c_str() const { return buffer; } + char* begin() { return buffer; } + char* end() { return buffer + length(); } + const char* begin() const { return c_str(); } + const char* end() const { return c_str() + length(); } + + // search + int indexOf(char ch) const; + int indexOf(char ch, unsigned int fromIndex) const; + int indexOf(const String& str) const; + int indexOf(const String& str, unsigned int fromIndex) const; + int lastIndexOf(char ch) const; + int lastIndexOf(char ch, unsigned int fromIndex) const; + int lastIndexOf(const String& str) const; + int lastIndexOf(const String& str, unsigned int fromIndex) const; + String substring(unsigned int beginIndex) const + { + return substring(beginIndex, len); + }; + String substring(unsigned int beginIndex, unsigned int endIndex) const; + + // modification + void replace(char find, char replace); + void replace(const String& find, const String& replace); + void remove(unsigned int index); + void remove(unsigned int index, unsigned int count); + void toLowerCase(void); + void toUpperCase(void); + void trim(void); + + // parsing/conversion + long toInt(void) const; + float toFloat(void) const; + double toDouble(void) const; + +protected: + char* buffer; // the actual char array + unsigned int capacity; // the array length minus one (for the '\0') + unsigned int len; // the String length (not counting the '\0') +protected: + void init(void); + void invalidate(void); + bool changeBuffer(unsigned int maxStrLen); + + // copy and move + String& copy(const char* cstr, unsigned int length); + // String & copy(const __FlashStringHelper *pstr, unsigned int length); + void move(String& rhs); +}; + +class StringSumHelper : public String +{ +public: + StringSumHelper(const String& s) : String(s) {} + StringSumHelper(const char* p) : String(p) {} + StringSumHelper(char c) : String(c) {} + StringSumHelper(unsigned char num) : String(num) {} + StringSumHelper(int num) : String(num) {} + StringSumHelper(unsigned int num) : String(num) {} + StringSumHelper(long num) : String(num) {} + StringSumHelper(unsigned long num) : String(num) {} + StringSumHelper(float num) : String(num) {} + StringSumHelper(double num) : String(num) {} +}; + +} // namespace arduino + +// using arduino::__FlashStringHelper; +using arduino::String; + +#endif // __cplusplus +#endif // __ARDUINO_STRINGS__ + +#endif // defined(ARDUINO) etc + +#endif // STRING_H_ diff --git a/src/utils/time.h b/src/utils/time.h new file mode 100644 index 0000000..2a02018 --- /dev/null +++ b/src/utils/time.h @@ -0,0 +1,59 @@ +#ifndef TIME_H_ +#define TIME_H_ + +// Platform-independent system time utility. +// Returns seconds since boot as a double. + +#if defined(__EMSCRIPTEN__) + +#include + +inline double get_system_time_seconds() +{ + return emscripten_get_now() / 1000.0; +} + +#elif defined(USE_STD_IO) + +#include + +// Test override: when non-null, get_system_time_seconds() returns *this +// instead of the real clock. Set from test harnesses for deterministic time. +inline double* g_test_time_ptr = nullptr; + +inline double get_system_time_seconds() +{ + if (g_test_time_ptr) return *g_test_time_ptr; + static const auto start = std::chrono::steady_clock::now(); + auto now = std::chrono::steady_clock::now(); + std::chrono::duration elapsed = now - start; + return elapsed.count(); +} + +#else // Arduino / RP2040 + +#include + +#if defined(ARDUINO_ARCH_RP2040) +#include + +inline double get_system_time_seconds() +{ + // micros() on the Philhower core returns uint32_t and wraps to 0 after + // ~71.6 minutes, snapping engine time back to boot mid-performance (A3). + // The RP2040 hardware timer is 64-bit; use it directly. + return (double)time_us_64() / 1e6; +} + +#else + +inline double get_system_time_seconds() +{ + return micros() / 1e6; +} + +#endif + +#endif + +#endif // TIME_H_ diff --git a/test/catch.hpp b/test/catch.hpp new file mode 100644 index 0000000..fdb1618 --- /dev/null +++ b/test/catch.hpp @@ -0,0 +1,21260 @@ +/* + * Catch v2.13.8 + * Generated: 2022-01-03 21:20:09.589503 + * ---------------------------------------------------------- + * This file has been merged from multiple headers. Please don't edit it directly + * Copyright (c) 2022 Two Blue Cubes Ltd. All rights reserved. + * + * Distributed under the Boost Software License, Version 1.0. (See accompanying + * file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + */ +#ifndef TWOBLUECUBES_SINGLE_INCLUDE_CATCH_HPP_INCLUDED +#define TWOBLUECUBES_SINGLE_INCLUDE_CATCH_HPP_INCLUDED +// start catch.hpp + +#define CATCH_VERSION_MAJOR 2 +#define CATCH_VERSION_MINOR 13 +#define CATCH_VERSION_PATCH 8 + +#ifdef __clang__ +#pragma clang system_header +#elif defined __GNUC__ +#pragma GCC system_header +#endif + +// start catch_suppress_warnings.h + +#ifdef __clang__ +#ifdef __ICC // icpc defines the __clang__ macro +#pragma warning(push) +#pragma warning(disable : 161 1682) +#else // __ICC +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wpadded" +#pragma clang diagnostic ignored "-Wswitch-enum" +#pragma clang diagnostic ignored "-Wcovered-switch-default" +#endif +#elif defined __GNUC__ +// Because REQUIREs trigger GCC's -Wparentheses, and because still +// supported version of g++ have only buggy support for _Pragmas, +// Wparentheses have to be suppressed globally. +#pragma GCC diagnostic ignored "-Wparentheses" // See #674 for details + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-variable" +#pragma GCC diagnostic ignored "-Wpadded" +#endif +// end catch_suppress_warnings.h +#if defined(CATCH_CONFIG_MAIN) || defined(CATCH_CONFIG_RUNNER) +#define CATCH_IMPL +#define CATCH_CONFIG_ALL_PARTS +#endif + +// In the impl file, we want to have access to all parts of the headers +// Can also be used to sanely support PCHs +#if defined(CATCH_CONFIG_ALL_PARTS) +#define CATCH_CONFIG_EXTERNAL_INTERFACES +#if defined(CATCH_CONFIG_DISABLE_MATCHERS) +#undef CATCH_CONFIG_DISABLE_MATCHERS +#endif +#if !defined(CATCH_CONFIG_ENABLE_CHRONO_STRINGMAKER) +#define CATCH_CONFIG_ENABLE_CHRONO_STRINGMAKER +#endif +#endif + +#if !defined(CATCH_CONFIG_IMPL_ONLY) +// start catch_platform.h + +// See e.g.: +// https://opensource.apple.com/source/CarbonHeaders/CarbonHeaders-18.1/TargetConditionals.h.auto.html +#ifdef __APPLE__ +#include +#if (defined(TARGET_OS_OSX) && TARGET_OS_OSX == 1) || \ + (defined(TARGET_OS_MAC) && TARGET_OS_MAC == 1) +#define CATCH_PLATFORM_MAC +#elif (defined(TARGET_OS_IPHONE) && TARGET_OS_IPHONE == 1) +#define CATCH_PLATFORM_IPHONE +#endif + +#elif defined(linux) || defined(__linux) || defined(__linux__) +#define CATCH_PLATFORM_LINUX + +#elif defined(WIN32) || defined(__WIN32__) || defined(_WIN32) || \ + defined(_MSC_VER) || defined(__MINGW32__) +#define CATCH_PLATFORM_WINDOWS +#endif + +// end catch_platform.h + +#ifdef CATCH_IMPL +#ifndef CLARA_CONFIG_MAIN +#define CLARA_CONFIG_MAIN_NOT_DEFINED +#define CLARA_CONFIG_MAIN +#endif +#endif + +// start catch_user_interfaces.h + +namespace Catch +{ +unsigned int rngSeed(); +} + +// end catch_user_interfaces.h +// start catch_tag_alias_autoregistrar.h + +// start catch_common.h + +// start catch_compiler_capabilities.h + +// Detect a number of compiler features - by compiler +// The following features are defined: +// +// CATCH_CONFIG_COUNTER : is the __COUNTER__ macro supported? +// CATCH_CONFIG_WINDOWS_SEH : is Windows SEH supported? +// CATCH_CONFIG_POSIX_SIGNALS : are POSIX signals supported? +// CATCH_CONFIG_DISABLE_EXCEPTIONS : Are exceptions enabled? +// **************** +// Note to maintainers: if new toggles are added please document them +// in configuration.md, too +// **************** + +// In general each macro has a _NO_ form +// (e.g. CATCH_CONFIG_NO_POSIX_SIGNALS) which disables the feature. +// Many features, at point of detection, define an _INTERNAL_ macro, so they +// can be combined, en-mass, with the _NO_ forms later. + +#ifdef __cplusplus + +#if (__cplusplus >= 201402L) || (defined(_MSVC_LANG) && _MSVC_LANG >= 201402L) +#define CATCH_CPP14_OR_GREATER +#endif + +#if (__cplusplus >= 201703L) || (defined(_MSVC_LANG) && _MSVC_LANG >= 201703L) +#define CATCH_CPP17_OR_GREATER +#endif + +#endif + +// Only GCC compiler should be used in this block, so other compilers trying to +// mask themselves as GCC should be ignored. +#if defined(__GNUC__) && !defined(__clang__) && !defined(__ICC) && \ + !defined(__CUDACC__) && !defined(__LCC__) +#define CATCH_INTERNAL_START_WARNINGS_SUPPRESSION _Pragma("GCC diagnostic push") +#define CATCH_INTERNAL_STOP_WARNINGS_SUPPRESSION _Pragma("GCC diagnostic pop") + +#define CATCH_INTERNAL_IGNORE_BUT_WARN(...) (void)__builtin_constant_p(__VA_ARGS__) + +#endif + +#if defined(__clang__) + +#define CATCH_INTERNAL_START_WARNINGS_SUPPRESSION _Pragma("clang diagnostic push") +#define CATCH_INTERNAL_STOP_WARNINGS_SUPPRESSION _Pragma("clang diagnostic pop") + +// As of this writing, IBM XL's implementation of __builtin_constant_p has a bug +// which results in calls to destructors being emitted for each temporary, +// without a matching initialization. In practice, this can result in something +// like `std::string::~string` being called on an uninitialized value. +// +// For example, this code will likely segfault under IBM XL: +// ``` +// REQUIRE(std::string("12") + "34" == "1234") +// ``` +// +// Therefore, `CATCH_INTERNAL_IGNORE_BUT_WARN` is not implemented. +#if !defined(__ibmxl__) && !defined(__CUDACC__) +#define CATCH_INTERNAL_IGNORE_BUT_WARN(...) \ + (void)__builtin_constant_p( \ + __VA_ARGS__) /* NOLINT(cppcoreguidelines-pro-type-vararg, hicpp-vararg) */ +#endif + +#define CATCH_INTERNAL_SUPPRESS_GLOBALS_WARNINGS \ + _Pragma("clang diagnostic ignored \"-Wexit-time-destructors\"") \ + _Pragma("clang diagnostic ignored \"-Wglobal-constructors\"") + +#define CATCH_INTERNAL_SUPPRESS_PARENTHESES_WARNINGS \ + _Pragma("clang diagnostic ignored \"-Wparentheses\"") + +#define CATCH_INTERNAL_SUPPRESS_UNUSED_WARNINGS \ + _Pragma("clang diagnostic ignored \"-Wunused-variable\"") + +#define CATCH_INTERNAL_SUPPRESS_ZERO_VARIADIC_WARNINGS \ + _Pragma("clang diagnostic ignored \"-Wgnu-zero-variadic-macro-arguments\"") + +#define CATCH_INTERNAL_SUPPRESS_UNUSED_TEMPLATE_WARNINGS \ + _Pragma("clang diagnostic ignored \"-Wunused-template\"") + +#endif // __clang__ + +//////////////////////////////////////////////////////////////////////////////// +// Assume that non-Windows platforms support posix signals by default +#if !defined(CATCH_PLATFORM_WINDOWS) +#define CATCH_INTERNAL_CONFIG_POSIX_SIGNALS +#endif + +//////////////////////////////////////////////////////////////////////////////// +// We know some environments not to support full POSIX signals +#if defined(__CYGWIN__) || defined(__QNX__) || defined(__EMSCRIPTEN__) || \ + defined(__DJGPP__) +#define CATCH_INTERNAL_CONFIG_NO_POSIX_SIGNALS +#endif + +#ifdef __OS400__ +#define CATCH_INTERNAL_CONFIG_NO_POSIX_SIGNALS +#define CATCH_CONFIG_COLOUR_NONE +#endif + +//////////////////////////////////////////////////////////////////////////////// +// Android somehow still does not support std::to_string +#if defined(__ANDROID__) +#define CATCH_INTERNAL_CONFIG_NO_CPP11_TO_STRING +#define CATCH_INTERNAL_CONFIG_ANDROID_LOGWRITE +#endif + +//////////////////////////////////////////////////////////////////////////////// +// Not all Windows environments support SEH properly +#if defined(__MINGW32__) +#define CATCH_INTERNAL_CONFIG_NO_WINDOWS_SEH +#endif + +//////////////////////////////////////////////////////////////////////////////// +// PS4 +#if defined(__ORBIS__) +#define CATCH_INTERNAL_CONFIG_NO_NEW_CAPTURE +#endif + +//////////////////////////////////////////////////////////////////////////////// +// Cygwin +#ifdef __CYGWIN__ + +// Required for some versions of Cygwin to declare gettimeofday +// see: +// http://stackoverflow.com/questions/36901803/gettimeofday-not-declared-in-this-scope-cygwin +#define _BSD_SOURCE +// some versions of cygwin (most) do not support std::to_string. Use the libstd +// check. https://gcc.gnu.org/onlinedocs/gcc-4.8.2/libstdc++/api/a01053_source.html +// line 2812-2813 +#if !((__cplusplus >= 201103L) && defined(_GLIBCXX_USE_C99) && \ + !defined(_GLIBCXX_HAVE_BROKEN_VSWPRINTF)) + +#define CATCH_INTERNAL_CONFIG_NO_CPP11_TO_STRING + +#endif +#endif // __CYGWIN__ + +//////////////////////////////////////////////////////////////////////////////// +// Visual C++ +#if defined(_MSC_VER) + +// Universal Windows platform does not support SEH +// Or console colours (or console at all...) +#if defined(WINAPI_FAMILY) && (WINAPI_FAMILY == WINAPI_FAMILY_APP) +#define CATCH_CONFIG_COLOUR_NONE +#else +#define CATCH_INTERNAL_CONFIG_WINDOWS_SEH +#endif + +#if !defined(__clang__) // Handle Clang masquerading for msvc + +// MSVC traditional preprocessor needs some workaround for __VA_ARGS__ +// _MSVC_TRADITIONAL == 0 means new conformant preprocessor +// _MSVC_TRADITIONAL == 1 means old traditional non-conformant preprocessor +#if !defined(_MSVC_TRADITIONAL) || (defined(_MSVC_TRADITIONAL) && _MSVC_TRADITIONAL) +#define CATCH_INTERNAL_CONFIG_TRADITIONAL_MSVC_PREPROCESSOR +#endif // MSVC_TRADITIONAL + +// Only do this if we're not using clang on Windows, which uses `diagnostic push` & +// `diagnostic pop` +#define CATCH_INTERNAL_START_WARNINGS_SUPPRESSION __pragma(warning(push)) +#define CATCH_INTERNAL_STOP_WARNINGS_SUPPRESSION __pragma(warning(pop)) +#endif // __clang__ + +#endif // _MSC_VER + +#if defined(_REENTRANT) || defined(_MSC_VER) +// Enable async processing, as -pthread is specified or no additional linking is +// required +#define CATCH_INTERNAL_CONFIG_USE_ASYNC +#endif // _MSC_VER + +//////////////////////////////////////////////////////////////////////////////// +// Check if we are compiled with -fno-exceptions or equivalent +#if defined(__EXCEPTIONS) || defined(__cpp_exceptions) || defined(_CPPUNWIND) +#define CATCH_INTERNAL_CONFIG_EXCEPTIONS_ENABLED +#endif + +//////////////////////////////////////////////////////////////////////////////// +// DJGPP +#ifdef __DJGPP__ +#define CATCH_INTERNAL_CONFIG_NO_WCHAR +#endif // __DJGPP__ + +//////////////////////////////////////////////////////////////////////////////// +// Embarcadero C++Build +#if defined(__BORLANDC__) +#define CATCH_INTERNAL_CONFIG_POLYFILL_ISNAN +#endif + +//////////////////////////////////////////////////////////////////////////////// + +// Use of __COUNTER__ is suppressed during code analysis in +// CLion/AppCode 2017.2.x and former, because __COUNTER__ is not properly +// handled by it. +// Otherwise all supported compilers support COUNTER macro, +// but user still might want to turn it off +#if (!defined(__JETBRAINS_IDE__) || __JETBRAINS_IDE__ >= 20170300L) +#define CATCH_INTERNAL_CONFIG_COUNTER +#endif + +//////////////////////////////////////////////////////////////////////////////// + +// RTX is a special version of Windows that is real time. +// This means that it is detected as Windows, but does not provide +// the same set of capabilities as real Windows does. +#if defined(UNDER_RTSS) || defined(RTX64_BUILD) +#define CATCH_INTERNAL_CONFIG_NO_WINDOWS_SEH +#define CATCH_INTERNAL_CONFIG_NO_ASYNC +#define CATCH_CONFIG_COLOUR_NONE +#endif + +#if !defined(_GLIBCXX_USE_C99_MATH_TR1) +#define CATCH_INTERNAL_CONFIG_GLOBAL_NEXTAFTER +#endif + +// Various stdlib support checks that require __has_include +#if defined(__has_include) +// Check if string_view is available and usable +#if __has_include() && defined(CATCH_CPP17_OR_GREATER) +#define CATCH_INTERNAL_CONFIG_CPP17_STRING_VIEW +#endif + +// Check if optional is available and usable +#if __has_include() && defined(CATCH_CPP17_OR_GREATER) +#define CATCH_INTERNAL_CONFIG_CPP17_OPTIONAL +#endif // __has_include() && defined(CATCH_CPP17_OR_GREATER) + +// Check if byte is available and usable +#if __has_include() && defined(CATCH_CPP17_OR_GREATER) +#include +#if defined(__cpp_lib_byte) && (__cpp_lib_byte > 0) +#define CATCH_INTERNAL_CONFIG_CPP17_BYTE +#endif +#endif // __has_include() && defined(CATCH_CPP17_OR_GREATER) + +// Check if variant is available and usable +#if __has_include() && defined(CATCH_CPP17_OR_GREATER) +#if defined(__clang__) && (__clang_major__ < 8) +// work around clang bug with libstdc++ https://bugs.llvm.org/show_bug.cgi?id=31852 +// fix should be in clang 8, workaround in libstdc++ 8.2 +#include +#if defined(__GLIBCXX__) && defined(_GLIBCXX_RELEASE) && (_GLIBCXX_RELEASE < 9) +#define CATCH_CONFIG_NO_CPP17_VARIANT +#else +#define CATCH_INTERNAL_CONFIG_CPP17_VARIANT +#endif // defined(__GLIBCXX__) && defined(_GLIBCXX_RELEASE) && (_GLIBCXX_RELEASE < 9) +#else +#define CATCH_INTERNAL_CONFIG_CPP17_VARIANT +#endif // defined(__clang__) && (__clang_major__ < 8) +#endif // __has_include() && defined(CATCH_CPP17_OR_GREATER) +#endif // defined(__has_include) + +#if defined(CATCH_INTERNAL_CONFIG_COUNTER) && !defined(CATCH_CONFIG_NO_COUNTER) && \ + !defined(CATCH_CONFIG_COUNTER) +#define CATCH_CONFIG_COUNTER +#endif +#if defined(CATCH_INTERNAL_CONFIG_WINDOWS_SEH) && \ + !defined(CATCH_CONFIG_NO_WINDOWS_SEH) && !defined(CATCH_CONFIG_WINDOWS_SEH) && \ + !defined(CATCH_INTERNAL_CONFIG_NO_WINDOWS_SEH) +#define CATCH_CONFIG_WINDOWS_SEH +#endif +// This is set by default, because we assume that unix compilers are +// posix-signal-compatible by default. +#if defined(CATCH_INTERNAL_CONFIG_POSIX_SIGNALS) && \ + !defined(CATCH_INTERNAL_CONFIG_NO_POSIX_SIGNALS) && \ + !defined(CATCH_CONFIG_NO_POSIX_SIGNALS) && !defined(CATCH_CONFIG_POSIX_SIGNALS) +#define CATCH_CONFIG_POSIX_SIGNALS +#endif +// This is set by default, because we assume that compilers with no wchar_t support +// are just rare exceptions. +#if !defined(CATCH_INTERNAL_CONFIG_NO_WCHAR) && !defined(CATCH_CONFIG_NO_WCHAR) && \ + !defined(CATCH_CONFIG_WCHAR) +#define CATCH_CONFIG_WCHAR +#endif + +#if !defined(CATCH_INTERNAL_CONFIG_NO_CPP11_TO_STRING) && \ + !defined(CATCH_CONFIG_NO_CPP11_TO_STRING) && \ + !defined(CATCH_CONFIG_CPP11_TO_STRING) +#define CATCH_CONFIG_CPP11_TO_STRING +#endif + +#if defined(CATCH_INTERNAL_CONFIG_CPP17_OPTIONAL) && \ + !defined(CATCH_CONFIG_NO_CPP17_OPTIONAL) && \ + !defined(CATCH_CONFIG_CPP17_OPTIONAL) +#define CATCH_CONFIG_CPP17_OPTIONAL +#endif + +#if defined(CATCH_INTERNAL_CONFIG_CPP17_STRING_VIEW) && \ + !defined(CATCH_CONFIG_NO_CPP17_STRING_VIEW) && \ + !defined(CATCH_CONFIG_CPP17_STRING_VIEW) +#define CATCH_CONFIG_CPP17_STRING_VIEW +#endif + +#if defined(CATCH_INTERNAL_CONFIG_CPP17_VARIANT) && \ + !defined(CATCH_CONFIG_NO_CPP17_VARIANT) && !defined(CATCH_CONFIG_CPP17_VARIANT) +#define CATCH_CONFIG_CPP17_VARIANT +#endif + +#if defined(CATCH_INTERNAL_CONFIG_CPP17_BYTE) && \ + !defined(CATCH_CONFIG_NO_CPP17_BYTE) && !defined(CATCH_CONFIG_CPP17_BYTE) +#define CATCH_CONFIG_CPP17_BYTE +#endif + +#if defined(CATCH_CONFIG_EXPERIMENTAL_REDIRECT) +#define CATCH_INTERNAL_CONFIG_NEW_CAPTURE +#endif + +#if defined(CATCH_INTERNAL_CONFIG_NEW_CAPTURE) && \ + !defined(CATCH_INTERNAL_CONFIG_NO_NEW_CAPTURE) && \ + !defined(CATCH_CONFIG_NO_NEW_CAPTURE) && !defined(CATCH_CONFIG_NEW_CAPTURE) +#define CATCH_CONFIG_NEW_CAPTURE +#endif + +#if !defined(CATCH_INTERNAL_CONFIG_EXCEPTIONS_ENABLED) && \ + !defined(CATCH_CONFIG_DISABLE_EXCEPTIONS) +#define CATCH_CONFIG_DISABLE_EXCEPTIONS +#endif + +#if defined(CATCH_INTERNAL_CONFIG_POLYFILL_ISNAN) && \ + !defined(CATCH_CONFIG_NO_POLYFILL_ISNAN) && \ + !defined(CATCH_CONFIG_POLYFILL_ISNAN) +#define CATCH_CONFIG_POLYFILL_ISNAN +#endif + +#if defined(CATCH_INTERNAL_CONFIG_USE_ASYNC) && \ + !defined(CATCH_INTERNAL_CONFIG_NO_ASYNC) && \ + !defined(CATCH_CONFIG_NO_USE_ASYNC) && !defined(CATCH_CONFIG_USE_ASYNC) +#define CATCH_CONFIG_USE_ASYNC +#endif + +#if defined(CATCH_INTERNAL_CONFIG_ANDROID_LOGWRITE) && \ + !defined(CATCH_CONFIG_NO_ANDROID_LOGWRITE) && \ + !defined(CATCH_CONFIG_ANDROID_LOGWRITE) +#define CATCH_CONFIG_ANDROID_LOGWRITE +#endif + +#if defined(CATCH_INTERNAL_CONFIG_GLOBAL_NEXTAFTER) && \ + !defined(CATCH_CONFIG_NO_GLOBAL_NEXTAFTER) && \ + !defined(CATCH_CONFIG_GLOBAL_NEXTAFTER) +#define CATCH_CONFIG_GLOBAL_NEXTAFTER +#endif + +// Even if we do not think the compiler has that warning, we still have +// to provide a macro that can be used by the code. +#if !defined(CATCH_INTERNAL_START_WARNINGS_SUPPRESSION) +#define CATCH_INTERNAL_START_WARNINGS_SUPPRESSION +#endif +#if !defined(CATCH_INTERNAL_STOP_WARNINGS_SUPPRESSION) +#define CATCH_INTERNAL_STOP_WARNINGS_SUPPRESSION +#endif +#if !defined(CATCH_INTERNAL_SUPPRESS_PARENTHESES_WARNINGS) +#define CATCH_INTERNAL_SUPPRESS_PARENTHESES_WARNINGS +#endif +#if !defined(CATCH_INTERNAL_SUPPRESS_GLOBALS_WARNINGS) +#define CATCH_INTERNAL_SUPPRESS_GLOBALS_WARNINGS +#endif +#if !defined(CATCH_INTERNAL_SUPPRESS_UNUSED_WARNINGS) +#define CATCH_INTERNAL_SUPPRESS_UNUSED_WARNINGS +#endif +#if !defined(CATCH_INTERNAL_SUPPRESS_ZERO_VARIADIC_WARNINGS) +#define CATCH_INTERNAL_SUPPRESS_ZERO_VARIADIC_WARNINGS +#endif + +// The goal of this macro is to avoid evaluation of the arguments, but +// still have the compiler warn on problems inside... +#if !defined(CATCH_INTERNAL_IGNORE_BUT_WARN) +#define CATCH_INTERNAL_IGNORE_BUT_WARN(...) +#endif + +#if defined(__APPLE__) && defined(__apple_build_version__) && (__clang_major__ < 10) +#undef CATCH_INTERNAL_SUPPRESS_UNUSED_TEMPLATE_WARNINGS +#elif defined(__clang__) && (__clang_major__ < 5) +#undef CATCH_INTERNAL_SUPPRESS_UNUSED_TEMPLATE_WARNINGS +#endif + +#if !defined(CATCH_INTERNAL_SUPPRESS_UNUSED_TEMPLATE_WARNINGS) +#define CATCH_INTERNAL_SUPPRESS_UNUSED_TEMPLATE_WARNINGS +#endif + +#if defined(CATCH_CONFIG_DISABLE_EXCEPTIONS) +#define CATCH_TRY if ((true)) +#define CATCH_CATCH_ALL if ((false)) +#define CATCH_CATCH_ANON(type) if ((false)) +#else +#define CATCH_TRY try +#define CATCH_CATCH_ALL catch (...) +#define CATCH_CATCH_ANON(type) catch (type) +#endif + +#if defined(CATCH_INTERNAL_CONFIG_TRADITIONAL_MSVC_PREPROCESSOR) && \ + !defined(CATCH_CONFIG_NO_TRADITIONAL_MSVC_PREPROCESSOR) && \ + !defined(CATCH_CONFIG_TRADITIONAL_MSVC_PREPROCESSOR) +#define CATCH_CONFIG_TRADITIONAL_MSVC_PREPROCESSOR +#endif + +// end catch_compiler_capabilities.h +#define INTERNAL_CATCH_UNIQUE_NAME_LINE2(name, line) name##line +#define INTERNAL_CATCH_UNIQUE_NAME_LINE(name, line) \ + INTERNAL_CATCH_UNIQUE_NAME_LINE2(name, line) +#ifdef CATCH_CONFIG_COUNTER +#define INTERNAL_CATCH_UNIQUE_NAME(name) \ + INTERNAL_CATCH_UNIQUE_NAME_LINE(name, __COUNTER__) +#else +#define INTERNAL_CATCH_UNIQUE_NAME(name) \ + INTERNAL_CATCH_UNIQUE_NAME_LINE(name, __LINE__) +#endif + +#include +#include +#include + +// We need a dummy global operator<< so we can bring it into Catch namespace later +struct Catch_global_namespace_dummy +{ +}; +std::ostream& operator<<(std::ostream&, Catch_global_namespace_dummy); + +namespace Catch +{ + +struct CaseSensitive +{ + enum Choice + { + Yes, + No + }; +}; + +class NonCopyable +{ + NonCopyable(NonCopyable const&) = delete; + NonCopyable(NonCopyable&&) = delete; + NonCopyable& operator=(NonCopyable const&) = delete; + NonCopyable& operator=(NonCopyable&&) = delete; + +protected: + NonCopyable(); + virtual ~NonCopyable(); +}; + +struct SourceLineInfo +{ + + SourceLineInfo() = delete; + SourceLineInfo(char const* _file, std::size_t _line) noexcept + : file(_file), line(_line) + { + } + + SourceLineInfo(SourceLineInfo const& other) = default; + SourceLineInfo& operator=(SourceLineInfo const&) = default; + SourceLineInfo(SourceLineInfo&&) noexcept = default; + SourceLineInfo& operator=(SourceLineInfo&&) noexcept = default; + + bool empty() const noexcept { return file[0] == '\0'; } + bool operator==(SourceLineInfo const& other) const noexcept; + bool operator<(SourceLineInfo const& other) const noexcept; + + char const* file; + std::size_t line; +}; + +std::ostream& operator<<(std::ostream& os, SourceLineInfo const& info); + +// Bring in operator<< from global namespace into Catch namespace +// This is necessary because the overload of operator<< above makes +// lookup stop at namespace Catch +using ::operator<<; + +// Use this in variadic streaming macros to allow +// >> +StreamEndStop +// as well as +// >> stuff +StreamEndStop +struct StreamEndStop +{ + std::string operator+() const; +}; +template +T const& operator+(T const& value, StreamEndStop) +{ + return value; +} +} // namespace Catch + +#define CATCH_INTERNAL_LINEINFO \ + ::Catch::SourceLineInfo(__FILE__, static_cast(__LINE__)) + +// end catch_common.h +namespace Catch +{ + +struct RegistrarForTagAliases +{ + RegistrarForTagAliases(char const* alias, char const* tag, + SourceLineInfo const& lineInfo); +}; + +} // end namespace Catch + +#define CATCH_REGISTER_TAG_ALIAS(alias, spec) \ + CATCH_INTERNAL_START_WARNINGS_SUPPRESSION \ + CATCH_INTERNAL_SUPPRESS_GLOBALS_WARNINGS \ + namespace \ + { \ + Catch::RegistrarForTagAliases \ + INTERNAL_CATCH_UNIQUE_NAME(AutoRegisterTagAlias)(alias, spec, \ + CATCH_INTERNAL_LINEINFO); \ + } \ + CATCH_INTERNAL_STOP_WARNINGS_SUPPRESSION + +// end catch_tag_alias_autoregistrar.h +// start catch_test_registry.h + +// start catch_interfaces_testcase.h + +#include + +namespace Catch +{ + +class TestSpec; + +struct ITestInvoker +{ + virtual void invoke() const = 0; + virtual ~ITestInvoker(); +}; + +class TestCase; +struct IConfig; + +struct ITestCaseRegistry +{ + virtual ~ITestCaseRegistry(); + virtual std::vector const& getAllTests() const = 0; + virtual std::vector const& + getAllTestsSorted(IConfig const& config) const = 0; +}; + +bool isThrowSafe(TestCase const& testCase, IConfig const& config); +bool matchTest(TestCase const& testCase, TestSpec const& testSpec, + IConfig const& config); +std::vector filterTests(std::vector const& testCases, + TestSpec const& testSpec, IConfig const& config); +std::vector const& getAllTestCasesSorted(IConfig const& config); + +} // namespace Catch + +// end catch_interfaces_testcase.h +// start catch_stringref.h + +#include +#include +#include +#include + +namespace Catch +{ + +/// A non-owning string class (similar to the forthcoming std::string_view) +/// Note that, because a StringRef may be a substring of another string, +/// it may not be null terminated. +class StringRef +{ +public: + using size_type = std::size_t; + using const_iterator = const char*; + +private: + static constexpr char const* const s_empty = ""; + + char const* m_start = s_empty; + size_type m_size = 0; + +public: // construction + constexpr StringRef() noexcept = default; + + StringRef(char const* rawChars) noexcept; + + constexpr StringRef(char const* rawChars, size_type size) noexcept + : m_start(rawChars), m_size(size) + { + } + + StringRef(std::string const& stdString) noexcept + : m_start(stdString.c_str()), m_size(stdString.size()) + { + } + + explicit operator std::string() const { return std::string(m_start, m_size); } + +public: // operators + auto operator==(StringRef const& other) const noexcept -> bool; + auto operator!=(StringRef const& other) const noexcept -> bool + { + return !(*this == other); + } + + auto operator[](size_type index) const noexcept -> char + { + assert(index < m_size); + return m_start[index]; + } + +public: // named queries + constexpr auto empty() const noexcept -> bool { return m_size == 0; } + constexpr auto size() const noexcept -> size_type { return m_size; } + + // Returns the current start pointer. If the StringRef is not + // null-terminated, throws std::domain_exception + auto c_str() const -> char const*; + +public: // substrings and searches + // Returns a substring of [start, start + length). + // If start + length > size(), then the substring is [start, size()). + // If start > size(), then the substring is empty. + auto substr(size_type start, size_type length) const noexcept -> StringRef; + + // Returns the current start pointer. May not be null-terminated. + auto data() const noexcept -> char const*; + + constexpr auto isNullTerminated() const noexcept -> bool + { + return m_start[m_size] == '\0'; + } + +public: // iterators + constexpr const_iterator begin() const { return m_start; } + constexpr const_iterator end() const { return m_start + m_size; } +}; + +auto operator+=(std::string& lhs, StringRef const& sr) -> std::string&; +auto operator<<(std::ostream& os, StringRef const& sr) -> std::ostream&; + +constexpr auto operator"" _sr(char const* rawChars, std::size_t size) noexcept + -> StringRef +{ + return StringRef(rawChars, size); +} +} // namespace Catch + +constexpr auto operator"" _catch_sr(char const* rawChars, std::size_t size) noexcept + -> Catch::StringRef +{ + return Catch::StringRef(rawChars, size); +} + +// end catch_stringref.h +// start catch_preprocessor.hpp + +#define CATCH_RECURSION_LEVEL0(...) __VA_ARGS__ +#define CATCH_RECURSION_LEVEL1(...) \ + CATCH_RECURSION_LEVEL0( \ + CATCH_RECURSION_LEVEL0(CATCH_RECURSION_LEVEL0(__VA_ARGS__))) +#define CATCH_RECURSION_LEVEL2(...) \ + CATCH_RECURSION_LEVEL1( \ + CATCH_RECURSION_LEVEL1(CATCH_RECURSION_LEVEL1(__VA_ARGS__))) +#define CATCH_RECURSION_LEVEL3(...) \ + CATCH_RECURSION_LEVEL2( \ + CATCH_RECURSION_LEVEL2(CATCH_RECURSION_LEVEL2(__VA_ARGS__))) +#define CATCH_RECURSION_LEVEL4(...) \ + CATCH_RECURSION_LEVEL3( \ + CATCH_RECURSION_LEVEL3(CATCH_RECURSION_LEVEL3(__VA_ARGS__))) +#define CATCH_RECURSION_LEVEL5(...) \ + CATCH_RECURSION_LEVEL4( \ + CATCH_RECURSION_LEVEL4(CATCH_RECURSION_LEVEL4(__VA_ARGS__))) + +#ifdef CATCH_CONFIG_TRADITIONAL_MSVC_PREPROCESSOR +#define INTERNAL_CATCH_EXPAND_VARGS(...) __VA_ARGS__ +// MSVC needs more evaluations +#define CATCH_RECURSION_LEVEL6(...) \ + CATCH_RECURSION_LEVEL5( \ + CATCH_RECURSION_LEVEL5(CATCH_RECURSION_LEVEL5(__VA_ARGS__))) +#define CATCH_RECURSE(...) \ + CATCH_RECURSION_LEVEL6(CATCH_RECURSION_LEVEL6(__VA_ARGS__)) +#else +#define CATCH_RECURSE(...) CATCH_RECURSION_LEVEL5(__VA_ARGS__) +#endif + +#define CATCH_REC_END(...) +#define CATCH_REC_OUT + +#define CATCH_EMPTY() +#define CATCH_DEFER(id) id CATCH_EMPTY() + +#define CATCH_REC_GET_END2() 0, CATCH_REC_END +#define CATCH_REC_GET_END1(...) CATCH_REC_GET_END2 +#define CATCH_REC_GET_END(...) CATCH_REC_GET_END1 +#define CATCH_REC_NEXT0(test, next, ...) next CATCH_REC_OUT +#define CATCH_REC_NEXT1(test, next) CATCH_DEFER(CATCH_REC_NEXT0)(test, next, 0) +#define CATCH_REC_NEXT(test, next) CATCH_REC_NEXT1(CATCH_REC_GET_END test, next) + +#define CATCH_REC_LIST0(f, x, peek, ...) \ + , f(x) CATCH_DEFER(CATCH_REC_NEXT(peek, CATCH_REC_LIST1))(f, peek, __VA_ARGS__) +#define CATCH_REC_LIST1(f, x, peek, ...) \ + , f(x) CATCH_DEFER(CATCH_REC_NEXT(peek, CATCH_REC_LIST0))(f, peek, __VA_ARGS__) +#define CATCH_REC_LIST2(f, x, peek, ...) \ + f(x) CATCH_DEFER(CATCH_REC_NEXT(peek, CATCH_REC_LIST1))(f, peek, __VA_ARGS__) + +#define CATCH_REC_LIST0_UD(f, userdata, x, peek, ...) \ + , f(userdata, x) CATCH_DEFER(CATCH_REC_NEXT(peek, CATCH_REC_LIST1_UD))( \ + f, userdata, peek, __VA_ARGS__) +#define CATCH_REC_LIST1_UD(f, userdata, x, peek, ...) \ + , f(userdata, x) CATCH_DEFER(CATCH_REC_NEXT(peek, CATCH_REC_LIST0_UD))( \ + f, userdata, peek, __VA_ARGS__) +#define CATCH_REC_LIST2_UD(f, userdata, x, peek, ...) \ + f(userdata, x) CATCH_DEFER(CATCH_REC_NEXT(peek, CATCH_REC_LIST1_UD))( \ + f, userdata, peek, __VA_ARGS__) + +// Applies the function macro `f` to each of the remaining parameters, inserts commas +// between the results, and passes userdata as the first parameter to each +// invocation, e.g. CATCH_REC_LIST_UD(f, x, a, b, c) evaluates to f(x, a), f(x, b), +// f(x, c) +#define CATCH_REC_LIST_UD(f, userdata, ...) \ + CATCH_RECURSE( \ + CATCH_REC_LIST2_UD(f, userdata, __VA_ARGS__, ()()(), ()()(), ()()(), 0)) + +#define CATCH_REC_LIST(f, ...) \ + CATCH_RECURSE(CATCH_REC_LIST2(f, __VA_ARGS__, ()()(), ()()(), ()()(), 0)) + +#define INTERNAL_CATCH_EXPAND1(param) INTERNAL_CATCH_EXPAND2(param) +#define INTERNAL_CATCH_EXPAND2(...) INTERNAL_CATCH_NO##__VA_ARGS__ +#define INTERNAL_CATCH_DEF(...) INTERNAL_CATCH_DEF __VA_ARGS__ +#define INTERNAL_CATCH_NOINTERNAL_CATCH_DEF +#define INTERNAL_CATCH_STRINGIZE(...) INTERNAL_CATCH_STRINGIZE2(__VA_ARGS__) +#ifndef CATCH_CONFIG_TRADITIONAL_MSVC_PREPROCESSOR +#define INTERNAL_CATCH_STRINGIZE2(...) #__VA_ARGS__ +#define INTERNAL_CATCH_STRINGIZE_WITHOUT_PARENS(param) \ + INTERNAL_CATCH_STRINGIZE(INTERNAL_CATCH_REMOVE_PARENS(param)) +#else +// MSVC is adding extra space and needs another indirection to expand +// INTERNAL_CATCH_NOINTERNAL_CATCH_DEF +#define INTERNAL_CATCH_STRINGIZE2(...) INTERNAL_CATCH_STRINGIZE3(__VA_ARGS__) +#define INTERNAL_CATCH_STRINGIZE3(...) #__VA_ARGS__ +#define INTERNAL_CATCH_STRINGIZE_WITHOUT_PARENS(param) \ + (INTERNAL_CATCH_STRINGIZE(INTERNAL_CATCH_REMOVE_PARENS(param)) + 1) +#endif + +#define INTERNAL_CATCH_MAKE_NAMESPACE2(...) ns_##__VA_ARGS__ +#define INTERNAL_CATCH_MAKE_NAMESPACE(name) INTERNAL_CATCH_MAKE_NAMESPACE2(name) + +#define INTERNAL_CATCH_REMOVE_PARENS(...) \ + INTERNAL_CATCH_EXPAND1(INTERNAL_CATCH_DEF __VA_ARGS__) + +#ifndef CATCH_CONFIG_TRADITIONAL_MSVC_PREPROCESSOR +#define INTERNAL_CATCH_MAKE_TYPE_LIST2(...) \ + decltype(get_wrapper()) +#define INTERNAL_CATCH_MAKE_TYPE_LIST(...) \ + INTERNAL_CATCH_MAKE_TYPE_LIST2(INTERNAL_CATCH_REMOVE_PARENS(__VA_ARGS__)) +#else +#define INTERNAL_CATCH_MAKE_TYPE_LIST2(...) \ + INTERNAL_CATCH_EXPAND_VARGS( \ + decltype(get_wrapper())) +#define INTERNAL_CATCH_MAKE_TYPE_LIST(...) \ + INTERNAL_CATCH_EXPAND_VARGS( \ + INTERNAL_CATCH_MAKE_TYPE_LIST2(INTERNAL_CATCH_REMOVE_PARENS(__VA_ARGS__))) +#endif + +#define INTERNAL_CATCH_MAKE_TYPE_LISTS_FROM_TYPES(...) \ + CATCH_REC_LIST(INTERNAL_CATCH_MAKE_TYPE_LIST, __VA_ARGS__) + +#define INTERNAL_CATCH_REMOVE_PARENS_1_ARG(_0) INTERNAL_CATCH_REMOVE_PARENS(_0) +#define INTERNAL_CATCH_REMOVE_PARENS_2_ARG(_0, _1) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), INTERNAL_CATCH_REMOVE_PARENS_1_ARG(_1) +#define INTERNAL_CATCH_REMOVE_PARENS_3_ARG(_0, _1, _2) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), INTERNAL_CATCH_REMOVE_PARENS_2_ARG(_1, _2) +#define INTERNAL_CATCH_REMOVE_PARENS_4_ARG(_0, _1, _2, _3) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), INTERNAL_CATCH_REMOVE_PARENS_3_ARG(_1, _2, _3) +#define INTERNAL_CATCH_REMOVE_PARENS_5_ARG(_0, _1, _2, _3, _4) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), \ + INTERNAL_CATCH_REMOVE_PARENS_4_ARG(_1, _2, _3, _4) +#define INTERNAL_CATCH_REMOVE_PARENS_6_ARG(_0, _1, _2, _3, _4, _5) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), \ + INTERNAL_CATCH_REMOVE_PARENS_5_ARG(_1, _2, _3, _4, _5) +#define INTERNAL_CATCH_REMOVE_PARENS_7_ARG(_0, _1, _2, _3, _4, _5, _6) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), \ + INTERNAL_CATCH_REMOVE_PARENS_6_ARG(_1, _2, _3, _4, _5, _6) +#define INTERNAL_CATCH_REMOVE_PARENS_8_ARG(_0, _1, _2, _3, _4, _5, _6, _7) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), \ + INTERNAL_CATCH_REMOVE_PARENS_7_ARG(_1, _2, _3, _4, _5, _6, _7) +#define INTERNAL_CATCH_REMOVE_PARENS_9_ARG(_0, _1, _2, _3, _4, _5, _6, _7, _8) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), \ + INTERNAL_CATCH_REMOVE_PARENS_8_ARG(_1, _2, _3, _4, _5, _6, _7, _8) +#define INTERNAL_CATCH_REMOVE_PARENS_10_ARG(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), \ + INTERNAL_CATCH_REMOVE_PARENS_9_ARG(_1, _2, _3, _4, _5, _6, _7, _8, _9) +#define INTERNAL_CATCH_REMOVE_PARENS_11_ARG(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, \ + _10) \ + INTERNAL_CATCH_REMOVE_PARENS(_0), INTERNAL_CATCH_REMOVE_PARENS_10_ARG( \ + _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) + +#define INTERNAL_CATCH_VA_NARGS_IMPL(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, \ + N, ...) \ + N + +#define INTERNAL_CATCH_TYPE_GEN \ + template \ + struct TypeList \ + { \ + }; \ + template \ + constexpr auto get_wrapper() noexcept -> TypeList \ + { \ + return {}; \ + } \ + template