feat(signal-engine): generic external input/sink/command registries with per-sink LKG (spec §3)

This commit is contained in:
w1n5t0n 2026-08-17 15:08:21 +03:00
parent 5ca6124715
commit db2031ae9e
13 changed files with 1102 additions and 9 deletions

24
MAP.md
View file

@ -8,14 +8,19 @@ modulisp/
│ ├── pch.h # precompiled header (std includes)
│ ├── modulisp/
│ │ └── lisp/symbol_intern.h # header-only symbol interning table
│ ├── signal_engine/ # 11 .cpp + 12 .h + symbols.def
│ ├── signal_engine/ # 12 .cpp + 13 .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
│ │ │ # (resolve_hardware_input consults ext_registry first)
│ │ ├── compiler_pipeline.{h,cpp} # source → tokens → cells → DAG orchestration
│ │ ├── cold_eval.{h,cpp} + eval_ops.h # constant folding / cold evaluation
│ │ │ # (top-level sink forms + cold commands dispatch here)
│ │ ├── executor.{h,cpp} # tick-time DAG executor
│ │ │ # (+ publish_sink_values post-tick publication)
│ │ ├── ext_registry.{h,cpp} # generic named external-input/sink/command registries
│ │ │ # (firmware profile seam; no nn/* or midi/* builtins)
│ │ ├── 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
@ -29,9 +34,20 @@ modulisp/
│ ├── 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
├── meson.build # 18 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)
└── signal_engine/ # 18 test .cpp (host-only subset; exclusions in README.md)
```
Build artifacts: `build/` (meson/ninja). 3 libs + 17 test executables.
External-register seam (NISPS-USEQ spec §3, generic mechanism only):
`ext_registry.{h,cpp}` holds firmware-profile registrations for named
external inputs, external sinks, and cold commands. `graph_builder` resolves
registered input names before the builtin board inputs; `cold_eval` binds
registered sinks with ordinary top-level assignment (channels publish as pool
output slots ≥ SINK_SLOT_BASE, so per-sink LKG rides the existing
GraphMutationTransaction machinery) and dispatches registered commands to the
installed handler after the submission's earlier forms compiled.
`executor::publish_sink_values` writes `SignalEngine::sink_values` /
`sink_dirty` per tick for the firmware to poll.
Build artifacts: `build/` (meson/ninja). 3 libs + 18 test executables.

View file

@ -1 +1,27 @@
# ModuLisp
# ModuLisp
Standalone build of the portable uSEQ signal engine. Meson + ninja:
```
meson setup build && ninja -C build && meson test -C build
```
## Module map
- `src/modulisp/lisp/` — header-only symbol interning (`symbol_intern.h`).
- `src/signal_engine/` — the language core:
- `token``cell_store``graph_builder``node_pool`: source text to
fixed-pool signal DAG (deterministic compile, CSE, bounded diagnostics).
- `compiler_pipeline`: parse + graph-mutation transactions (LKG rollback).
- `cold_eval`: top-level evaluation — definitions, output assignment,
external sink assignment, cold commands, transport control.
- `executor`: tick-time DAG execution + `publish_sink_values`.
- `ext_registry`: generic named external-input / external-sink /
cold-command registries — the firmware-profile seam (see MAP.md).
- `state_registry`, `synth_registry`, `synth_graph`, `diagnostics`.
- `src/utils/`, `src/devtools/`, `src/ports/` — support libraries, gated
telemetry, and hardware port interfaces with host mocks.
- `test/` — Catch2 executables over the host subset of the engine.
External registers are generic: firmware registers names like `nn/*` or
`midi/*` at boot; the engine ships no such builtins.

View file

@ -86,6 +86,7 @@ signal_engine_lib = static_library('useq_signal_engine',
'src/signal_engine/node_pool.cpp',
'src/signal_engine/executor.cpp',
'src/signal_engine/graph_builder.cpp',
'src/signal_engine/ext_registry.cpp',
'src/signal_engine/compiler_pipeline.cpp',
'src/signal_engine/cold_eval.cpp',
'src/signal_engine/state_registry.cpp',

View file

@ -50,6 +50,13 @@ void SignalEngine::reset_session_storage(Sample bpm, int beats_per_bar,
state_sources[i] = StateUpdateSource{};
state_compile_diagnostics[i] = ActiveCompileDiagnostic{};
}
// Sink bindings are live-code-owned session state (the registry itself is
// firmware-profile state and survives a session clear).
for (uint16_t i = 0; i < MAX_SINK_BINDINGS; i++)
sink_bindings[i] = SinkBinding{};
sink_binding_count = 0;
memset(sink_values, 0, sizeof(sink_values));
memset(sink_dirty, 0, sizeof(sink_dirty));
registry.clear();
#if USEQ_HAS_SYNTH_ENGINE
if (publish_session_clear) {
@ -2184,18 +2191,35 @@ static EvalResult do_reset_clock_int(TokenStream& ts, EngineState& state) {
// ── Output assignment ───────────────────────────────────────────────────────
static void unassign_sink(SignalEngine& engine, SymbolID sink_sym);
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",
if (output_tok.kind != TokenKind::Symbol) {
return make_error("unassign needs one output or sink name",
"Try: (unassign a1)");
}
const bool is_sink = external_sink_registered(output_tok.symbol);
if (!is_sink && !GraphBuilder::is_output_symbol(output_tok.symbol)) {
return make_error("unassign needs one output or sink name",
"Try: (unassign a1)");
}
if (ts.peek().kind != TokenKind::RParen) {
return make_error("unassign accepts exactly one output name",
return make_error("unassign accepts exactly one name",
"Try: (unassign a1)");
}
// External sinks leave their binding; the next tick publishes nothing
// for them and the firmware falls back to its profile-neutral value.
if (is_sink) {
unassign_sink(engine, output_tok.symbol);
reclaim_unowned_resources(engine);
engine.pool.rebuild_execution_order();
classify_outputs(engine.pool);
return make_ok();
}
const uint16_t output_index =
GraphBuilder::resolve_output_index(output_tok.symbol);
@ -2310,6 +2334,248 @@ static EvalResult do_output_assign(SymbolID output_sym, TokenStream& ts,
return make_ok();
}
// ── External sink assignment (ext_registry.h, spec §3.3/§3.5/§7.4) ──────────
static EvalResult make_cold_arity_error(const char* message,
const char* suggestion) {
EvalResult r;
r.kind = EvalResult::Error;
r.diagnostics[r.diagnostic_count++] = {
DiagnosticSeverity::Error, DiagnosticCategory::Arity,
0, 0, message, suggestion
};
return r;
}
// Compact per-channel candidate kept between graph construction and the
// single publication point (a full GraphBuildResult per channel would hold
// diagnostic storage that is only needed on the failing build).
struct SinkChannelCandidate {
uint16_t root_node = NODE_NONE;
CellIndex dep_cells[MAX_OUTPUT_DEPS] = {};
uint8_t dep_count = 0;
};
// Tear down one pool output slot the way (unassign a1) does, retiring the
// state/live resources its context owned. Used for sink channel slots.
static void release_output_slot(SignalEngine& engine, uint16_t slot) {
engine.pool.outputs[slot] = OutputSlot{};
engine.pool.prev_output_values[slot] = 0.0;
engine.pool.output_deps[slot].clear();
engine.pool.runtime_fallback_mask &= ~((uint64_t)1 << slot);
engine.output_compile_diagnostics[slot] = ActiveCompileDiagnostic{};
engine.registry.begin_context(slot);
engine.registry.commit_context(slot,
engine.pool.state_update_roots,
engine.pool.state_owner_context);
}
static void unassign_sink(SignalEngine& engine, SymbolID sink_sym) {
uint8_t index = engine.sink_binding_count;
for (uint8_t i = 0; i < engine.sink_binding_count; i++) {
if (engine.sink_bindings[i].sink == sink_sym) {
index = i;
break;
}
}
if (index == engine.sink_binding_count) return; // not bound — idempotent
const SinkBinding binding = engine.sink_bindings[index];
for (uint8_t ch = 0; ch < binding.arity; ch++)
release_output_slot(engine, binding.value_index[ch]);
// Swap-remove so rows stay dense; the value/dirty rows follow their
// binding. Consumers key off the sink symbol, not the row index.
const uint8_t last = engine.sink_binding_count - 1;
engine.sink_bindings[index] = engine.sink_bindings[last];
memcpy(engine.sink_values[index], engine.sink_values[last],
sizeof(engine.sink_values[index]));
engine.sink_dirty[index] = engine.sink_dirty[last];
engine.sink_bindings[last] = SinkBinding{};
memset(engine.sink_values[last], 0, sizeof(engine.sink_values[last]));
engine.sink_dirty[last] = false;
engine.sink_binding_count--;
}
static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts,
SignalEngine& engine, const char* source,
SharedLiveEditIDs* shared_ids = nullptr) {
const ExternalSinkDesc* desc = find_external_sink(sink_sym);
// Existing binding for this sink is the per-sink LKG baseline: nothing
// below touches it unless every channel compiles.
uint8_t binding_index = engine.sink_binding_count; // append position
bool rebinding = false;
SinkBinding previous = {};
for (uint8_t i = 0; i < engine.sink_binding_count; i++) {
if (engine.sink_bindings[i].sink == sink_sym) {
binding_index = i;
rebinding = true;
previous = engine.sink_bindings[i];
break;
}
}
if (!rebinding && engine.sink_binding_count >= MAX_SINK_BINDINGS) {
return make_error("Too many sink assignments — binding slots are full",
"Unassign a sink first with (unassign name)");
}
// Channel slot plan: reuse this sink's previous slots in order (stable
// indices keep anonymous state identity alive across reassignment, like
// a named output keeps its index), then take free pool output slots
// above the named a/d/s range.
uint16_t slots[MAX_SINK_ARITY] = {};
uint8_t planned = 0;
for (uint16_t s = SINK_SLOT_BASE;
s < MAX_OUTPUTS && planned < desc->arity; s++) {
if (engine.pool.outputs[s].root_node != NODE_NONE) continue;
bool taken = false;
for (uint8_t p = 0; p < planned; p++) {
if (slots[p] == s) {
taken = true;
break;
}
}
if (!taken) slots[planned++] = s;
}
if (planned < desc->arity) {
return make_error(
"Sink channel slots are exhausted",
"Free sink channels with (unassign name) or use fewer sinks");
}
// One transaction across every channel: a failure in any channel rolls
// the whole form back to the previous published binding.
GraphMutationTransaction graph_transaction(engine);
SinkChannelCandidate candidates[MAX_SINK_ARITY];
for (uint8_t ch = 0; ch < desc->arity; ch++) {
if (ts.peek().kind == TokenKind::RParen || ts.at_end()) {
return make_cold_arity_error(
"Wrong number of sink arguments",
"The sink descriptor fixes the argument count");
}
engine.registry.begin_context(slots[ch]);
GraphBuildResult result = build_output_graph(
engine.pool, ts, engine.cells, engine.arena, source,
&engine.registry, shared_ids, slots[ch]);
if (result.has_error) {
EvalResult r;
r.kind = EvalResult::Error;
memcpy(r.diagnostics, result.diagnostics,
result.diagnostic_count * sizeof(Diagnostic));
r.diagnostic_count = result.diagnostic_count;
return r;
}
candidates[ch].root_node = result.root_node;
candidates[ch].dep_count = result.dep_count;
memcpy(candidates[ch].dep_cells, result.dep_cells,
result.dep_count * sizeof(CellIndex));
}
if (ts.peek().kind != TokenKind::RParen) {
return make_cold_arity_error(
"Wrong number of sink arguments",
"The sink descriptor fixes the argument count");
}
// Publication: retire previous slots the smaller new binding no longer
// uses, then install every channel root as one coherent state change.
if (rebinding) {
for (uint8_t ch = desc->arity; ch < previous.arity; ch++)
release_output_slot(engine, previous.value_index[ch]);
}
SinkBinding binding;
binding.sink = sink_sym;
binding.arity = desc->arity;
for (uint8_t ch = 0; ch < desc->arity; ch++) {
GraphBuildResult plan;
plan.root_node = candidates[ch].root_node;
plan.dep_count = candidates[ch].dep_count;
memcpy(plan.dep_cells, candidates[ch].dep_cells,
plan.dep_count * sizeof(CellIndex));
publish_output_graph_plan(engine, slots[ch], plan);
binding.value_index[ch] = slots[ch];
}
engine.sink_bindings[binding_index] = binding;
if (!rebinding) engine.sink_binding_count++;
graph_transaction.accept();
reclaim_unowned_resources(engine);
engine.pool.rebuild_execution_order();
classify_outputs(engine.pool);
return make_ok();
}
// ── Registered cold commands (ext_registry.h, spec §2.3/§6.1) ───────────────
static EvalResult do_cold_command(const ExternalCommandDesc* desc,
TokenStream& ts) {
ColdCommandHandler handler = cold_command_handler();
if (handler == nullptr) {
return make_error("No command handler is installed",
"The firmware profile must install one with "
"set_cold_command_handler()");
}
ColdArg args[MAX_COLD_ARGS];
uint8_t nargs = 0;
while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) {
if (nargs >= MAX_COLD_ARGS) {
return make_cold_arity_error(
"Too many command arguments",
"A command accepts at most 8 arguments");
}
const Token tok = ts.peek();
ColdArg& arg = args[nargs];
if (tok.kind == TokenKind::Number) {
ts.consume();
const Sample value = tok.number;
if (value == std::floor(value) && value >= INT32_MIN &&
value <= INT32_MAX) {
arg.kind = ColdArg::Kind::Int;
arg.integer = (int32_t)value;
} else {
arg.kind = ColdArg::Kind::Number;
arg.number = (float)value;
}
} else if (tok.kind == TokenKind::Symbol) {
ts.consume();
arg.kind = ColdArg::Kind::Symbol;
arg.symbol = tok.symbol;
} else if (tok.kind == TokenKind::LBracket) {
Sample values[64];
uint16_t count = 0;
EvalResult error;
if (!parse_numeric_vector(ts, values, count, error)) return error;
if (count > ColdArg::MAX_VEC) {
return make_cold_arity_error(
"Vector command arguments support at most 8 values",
"Shorten the vector");
}
arg.kind = ColdArg::Kind::Vector;
for (uint16_t v = 0; v < count; v++)
arg.vec[v] = (float)values[v];
} else {
ts.consume();
return make_error("Unsupported command argument",
"Use a number, a name, or a numeric vector");
}
nargs++;
}
if (nargs < desc->arity_min || nargs > desc->arity_max) {
return make_cold_arity_error("Wrong number of command arguments",
"Check the command's argument count");
}
if (!handler(desc->cmd, args, nargs, cold_command_user())) {
return make_error("The command handler rejected this call",
"Check the command's arguments");
}
return make_ok();
}
// ── Scratch-isolated expression evaluation ─────────────────────────────────
EvalResult eval_expression(const char* source, uint32_t length,
@ -2944,6 +3210,28 @@ static EvalResult eval_form(TokenStream& ts, SignalEngine& engine,
return r;
}
// External sink assignment (spec §3.3/§7.4): a registered sink name
// binds its channel expressions with an ordinary top-level form.
if (external_sink_registered(op)) {
EvalResult r = do_sink_assign(op, ts, engine, source, shared_ids);
ts.expect(TokenKind::RParen);
return r;
}
// Registered cold command (spec §2.3/§6.1 generic hook): dispatch
// parsed constant arguments to the firmware handler. eval_cold runs
// forms as a sequence of per-form transactions that stops at the
// first error, so the handler only ever observes a call after every
// earlier edit in the same submission compiled and published. Heads
// that are neither outputs, sinks, nor commands keep the unknown
// expression fallback below.
const ExternalCommandDesc* command = find_external_command(op);
if (command != nullptr) {
EvalResult r = do_cold_command(command, ts);
ts.expect(TokenKind::RParen);
return r;
}
// do / scope — evaluate children sequentially
if (op == sym.do_ || op == sym.scope) {
EvalResult last = make_ok();

View file

@ -4,6 +4,7 @@
#include "types.h"
#include "cell_store.h"
#include "node_pool.h"
#include "ext_registry.h"
#include "state_registry.h"
#if USEQ_HAS_SYNTH_ENGINE
#include "synth_graph.h"
@ -146,6 +147,17 @@ struct SignalEngine {
ActiveCompileDiagnostic state_compile_diagnostics[MAX_STATE_SLOTS] = {};
StateResourceRegistry registry;
// ── External sink bindings (ext_registry.h, spec §3.3/§3.5/§7.4) ─────
// One entry per assigned external sink. Each channel's graph occupies a
// real pool output slot (SinkBinding::value_index) so execution, LKG
// fallback and GC reuse the ordinary output machinery. sink_values holds
// the last published per-channel values; sink_dirty latches when a value
// moves beyond one quantisation step and is cleared by the consumer.
SinkBinding sink_bindings[MAX_SINK_BINDINGS] = {};
uint8_t sink_binding_count = 0;
Sample sink_values[MAX_SINK_BINDINGS][MAX_SINK_ARITY] = {};
bool sink_dirty[MAX_SINK_BINDINGS] = {};
NodePool scratch_pool;
char eval_text_buf[512] = {};

View file

@ -1,4 +1,5 @@
#include "executor.h"
#include "cold_eval.h"
#include "eval_ops.h"
#include <cmath>
#include <cstring>
@ -201,6 +202,36 @@ void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx) {
pool.runtime_fallback_mask = fallback_mask;
}
// ── External Sink Publication ───────────────────────────────────────────────
void publish_sink_values(SignalEngine& engine, const Sample* output_values) {
for (uint8_t b = 0; b < engine.sink_binding_count; b++) {
const SinkBinding& binding = engine.sink_bindings[b];
const ExternalSinkDesc* desc = find_external_sink(binding.sink);
// One quantisation step of the descriptor's range; a missing
// descriptor (profile re-registered without the sink) degrades to
// any-change dirtying rather than silencing the sink.
Sample step = 0.0;
if (desc != nullptr && desc->quant_bits > 0 &&
(Sample)desc->max > (Sample)desc->min) {
const Sample levels = std::ldexp(1.0, (int)desc->quant_bits);
step = ((Sample)desc->max - (Sample)desc->min) / levels;
}
bool dirty = false;
for (uint8_t ch = 0; ch < binding.arity; ch++) {
const Sample value = output_values[binding.value_index[ch]];
const Sample previous = engine.sink_values[b][ch];
Sample delta = value - previous;
if (delta < 0.0) delta = -delta;
if (step > 0.0 ? delta > step : value != previous) dirty = true;
engine.sink_values[b][ch] = value;
}
if (dirty) engine.sink_dirty[b] = true;
}
}
// ── Post-Tick Commit ───────────────────────────────────────────────────────
void commit_outputs(NodePool& pool, const Sample* output_values) {

View file

@ -105,6 +105,17 @@ void execute_batch(
// bitmask. Call after compilation or graph changes. Writes directly into
// pool.output_class[] and pool.output_input_mask[].
struct SignalEngine;
// ── External Sink Publication ───────────────────────────────────────────────
// After execute_all_outputs (and commit), copy each bound sink channel's
// evaluated value from the output buffer into SignalEngine::sink_values and
// latch sink_dirty when any channel moved beyond one quantisation step of
// its descriptor (quant_bits; unquantised sinks dirty on any change). The
// firmware polls and clears the flags; rate limiting stays firmware-side.
void publish_sink_values(SignalEngine& engine, const Sample* output_values);
void classify_outputs(NodePool& pool);
} // namespace sig

View file

@ -0,0 +1,146 @@
#include "ext_registry.h"
#include "../modulisp/lisp/symbol_intern.h"
#include <cstring>
namespace sig {
namespace {
struct StoredInput {
ExternalInputDesc desc = {};
SymbolID name_id = SymbolIntern::INVALID_ID;
};
struct StoredSink {
ExternalSinkDesc desc = {};
SymbolID name_id = SymbolIntern::INVALID_ID;
};
StoredInput g_inputs[MAX_EXTERNAL_INPUTS] = {};
StoredSink g_sinks[MAX_EXTERNAL_SINKS] = {};
ExternalCommandDesc g_commands[MAX_EXTERNAL_COMMANDS] = {};
uint8_t g_input_count = 0;
uint8_t g_sink_count = 0;
uint8_t g_command_count = 0;
ColdCommandHandler g_cold_handler = nullptr;
void* g_cold_user = nullptr;
bool valid_name(const char* name) {
return name != nullptr && name[0] != '\0';
}
} // namespace
bool register_external_input(const ExternalInputDesc& desc) {
if (!valid_name(desc.name)) return false;
if (desc.channels == 0) return false;
if (desc.hw_index >= MAX_HW_INPUT_CHANNELS ||
(uint32_t)desc.hw_index + desc.channels > MAX_HW_INPUT_CHANNELS) {
return false;
}
const SymbolID name_id = internSymbol(String(desc.name));
for (uint8_t i = 0; i < g_input_count; i++) {
if (g_inputs[i].name_id == name_id) {
g_inputs[i].desc = desc;
return true;
}
}
if (g_input_count >= MAX_EXTERNAL_INPUTS) return false;
g_inputs[g_input_count].desc = desc;
g_inputs[g_input_count].name_id = name_id;
g_input_count++;
return true;
}
bool register_external_sink(const ExternalSinkDesc& desc) {
if (!valid_name(desc.name)) return false;
if (desc.arity == 0 || desc.arity > MAX_SINK_ARITY) return false;
if (desc.min > desc.max) return false;
const SymbolID name_id = internSymbol(String(desc.name));
for (uint8_t i = 0; i < g_sink_count; i++) {
if (g_sinks[i].name_id == name_id) {
g_sinks[i].desc = desc;
return true;
}
}
if (g_sink_count >= MAX_EXTERNAL_SINKS) return false;
g_sinks[g_sink_count].desc = desc;
g_sinks[g_sink_count].name_id = name_id;
g_sink_count++;
return true;
}
bool register_external_command(SymbolID cmd, uint8_t arity_min,
uint8_t arity_max) {
if (cmd == SymbolIntern::INVALID_ID) return false;
if (arity_min > arity_max || arity_max > MAX_COLD_ARGS) return false;
for (uint8_t i = 0; i < g_command_count; i++) {
if (g_commands[i].cmd == cmd) {
g_commands[i].arity_min = arity_min;
g_commands[i].arity_max = arity_max;
return true;
}
}
if (g_command_count >= MAX_EXTERNAL_COMMANDS) return false;
g_commands[g_command_count] = ExternalCommandDesc{cmd, arity_min,
arity_max};
g_command_count++;
return true;
}
void reset_registry() {
memset(g_inputs, 0, sizeof(g_inputs));
memset(g_sinks, 0, sizeof(g_sinks));
memset(g_commands, 0, sizeof(g_commands));
g_input_count = 0;
g_sink_count = 0;
g_command_count = 0;
g_cold_handler = nullptr;
g_cold_user = nullptr;
}
const ExternalInputDesc* find_external_input(SymbolID name) {
for (uint8_t i = 0; i < g_input_count; i++) {
if (g_inputs[i].name_id == name) return &g_inputs[i].desc;
}
return nullptr;
}
uint16_t external_input_index_for(SymbolID name) {
const ExternalInputDesc* desc = find_external_input(name);
return desc ? desc->hw_index : NODE_NONE;
}
const ExternalSinkDesc* find_external_sink(SymbolID name) {
for (uint8_t i = 0; i < g_sink_count; i++) {
if (g_sinks[i].name_id == name) return &g_sinks[i].desc;
}
return nullptr;
}
bool external_sink_registered(SymbolID name) {
return find_external_sink(name) != nullptr;
}
const ExternalCommandDesc* find_external_command(SymbolID cmd) {
for (uint8_t i = 0; i < g_command_count; i++) {
if (g_commands[i].cmd == cmd) return &g_commands[i];
}
return nullptr;
}
void set_cold_command_handler(ColdCommandHandler handler, void* user) {
g_cold_handler = handler;
g_cold_user = user;
}
ColdCommandHandler cold_command_handler() { return g_cold_handler; }
void* cold_command_user() { return g_cold_user; }
} // namespace sig

View file

@ -0,0 +1,137 @@
#ifndef SIGNAL_ENGINE_EXT_REGISTRY_H
#define SIGNAL_ENGINE_EXT_REGISTRY_H
#include "types.h"
namespace sig {
// ── Generic external registers (NISPS-USEQ spec §3) ─────────────────────────
//
// A firmware profile owns the names and metadata of everything that surrounds
// the engine: named external inputs (producers stage latest-value state that
// signal graphs read as leaves), named external sinks (live code binds
// evaluated signals to transports), and discrete cold commands. The engine
// provides only the generic registration and dispatch seam — no nn/* or
// midi/* name is compiled in; those arrive via registration at firmware boot.
//
// Registration is profile state, not livecoding session state: it survives
// (useq-clear) and is deliberately NOT touched by SignalEngine session resets.
// reset_registry() exists for tests and explicit profile re-initialisation.
constexpr uint8_t MAX_EXTERNAL_INPUTS = 24;
constexpr uint8_t MAX_EXTERNAL_SINKS = 16;
constexpr uint8_t MAX_EXTERNAL_COMMANDS = 16;
// Width of the executor-side hw_inputs[] snapshot array (see
// ExecutionContext::hw_inputs). Registered descriptors must address channels
// inside this span.
constexpr uint16_t MAX_HW_INPUT_CHANNELS = 32;
// ── External input registers (spec §3.1/§3.2) ───────────────────────────────
struct ExternalInputDesc {
const char* name; // static-lifetime spelling, e.g. "nn/out1"
uint16_t hw_index; // first hw_inputs[] channel behind the name
uint8_t channels; // consecutive channels the register spans
float neutral; // profile-neutral value before first update
const char* units_or_range; // static metadata for UI/diagnostics
};
// ── External sinks (spec §3.3/§3.5/§7.4) ────────────────────────────────────
struct ExternalSinkDesc {
const char* name; // static-lifetime spelling, e.g. "midi/cc74"
uint8_t arity; // exact argument count of the sink form
float min; // accepted range (deadband scaling + metadata)
float max;
uint32_t max_rate_hz; // profile transport ceiling; enforced firmware-side
uint16_t quant_bits; // 0 = unquantised; else step = (max-min)/2^bits
};
struct ExternalCommandDesc {
SymbolID cmd;
uint8_t arity_min;
uint8_t arity_max;
};
// ── Registration / lookup ───────────────────────────────────────────────────
// Re-registering a name replaces its descriptor in place (idempotent profile
// init). Return false on a full registry or an invalid descriptor. Lookup is
// by SymbolID-interned name: registration interns desc->name, and the
// tokenizer interns the same spelling to the same ID.
bool register_external_input(const ExternalInputDesc& desc);
bool register_external_sink(const ExternalSinkDesc& desc);
bool register_external_command(SymbolID cmd, uint8_t arity_min,
uint8_t arity_max);
// Clears inputs, sinks, commands, and the cold-command handler.
void reset_registry();
const ExternalInputDesc* find_external_input(SymbolID name);
// Hardware-input channel for a registered name, or NODE_NONE. This is the
// compiler-facing lookup used by GraphBuilder::resolve_hardware_input.
uint16_t external_input_index_for(SymbolID name);
const ExternalSinkDesc* find_external_sink(SymbolID name);
bool external_sink_registered(SymbolID name);
const ExternalCommandDesc* find_external_command(SymbolID cmd);
// ── Sink bindings ───────────────────────────────────────────────────────────
// One entry per assigned external sink, owned by SignalEngine. Each channel's
// compiled graph is published as a real pool output slot (indices
// SINK_SLOT_BASE..MAX_OUTPUTS-1) so execution order, LKG fallback, and GC
// reachability are the ordinary output machinery — no second mechanism.
constexpr uint8_t MAX_SINK_ARITY = 8;
constexpr uint8_t MAX_SINK_BINDINGS = 16;
// Pool output slots 0..23 are the named a/d/s outputs; sink channel graphs
// occupy the remainder.
constexpr uint16_t SINK_SLOT_BASE = 24;
static_assert(SINK_SLOT_BASE < MAX_OUTPUTS,
"sink channel slots must fit inside the pool output table");
struct SinkBinding {
SymbolID sink = SymbolIntern::INVALID_ID;
uint16_t value_index[MAX_SINK_ARITY] = {}; // pool output slot per channel
uint8_t arity = 0;
};
// ── Cold command handler (spec §2.3/§6.1 — generic hook) ────────────────────
// A top-level form whose head is a registered command symbol parses its
// constant arguments into ColdArg values and calls the installed handler
// after every earlier form in the same eval compiled successfully. The
// handler must not re-enter the evaluator.
constexpr uint8_t MAX_COLD_ARGS = 8;
struct ColdArg {
enum class Kind : uint8_t { Int, Number, Symbol, Vector };
static constexpr uint8_t MAX_VEC = MAX_SINK_ARITY;
Kind kind = Kind::Int;
// Number tokens without a fractional part that fit in int32 parse as
// Int; everything numeric else is Number. Names parse as their interned
// SymbolID; bracketed numeric literals parse as Vector.
union {
float number;
int32_t integer;
SymbolID symbol;
float vec[MAX_VEC];
};
ColdArg() : integer(0) {}
};
using ColdCommandHandler = bool (*)(SymbolID cmd, const ColdArg* args,
uint8_t nargs, void* user);
void set_cold_command_handler(ColdCommandHandler handler, void* user);
ColdCommandHandler cold_command_handler();
void* cold_command_user();
} // namespace sig
#endif // SIGNAL_ENGINE_EXT_REGISTRY_H

View file

@ -1,4 +1,5 @@
#include "graph_builder.h"
#include "ext_registry.h"
#include "compiler_pipeline.h"
#include "../modulisp/lisp/symbol_intern.h"
#include <cstring>
@ -568,6 +569,12 @@ uint16_t GraphBuilder::resolve_output_index(SymbolID op) {
}
uint16_t GraphBuilder::resolve_hardware_input(SymbolID sym_id) {
// Registered external-input registers (spec §3.1) resolve before the
// built-in board names so a firmware profile owns the meaning of the
// hardware input space; unregistered names fall through unchanged.
const uint16_t external = external_input_index_for(sym_id);
if (external != NODE_NONE) return external;
const String& name = getSymbolString(sym_id);
if (name == "in1") return 0; // INP_I1
if (name == "in2") return 1; // INP_I2

View file

@ -10,6 +10,7 @@
#include "node_pool.h"
#include "graph_builder.h"
#include "executor.h"
#include "ext_registry.h"
#include "cold_eval.h"
#endif // SIGNAL_ENGINE_H

View file

@ -42,6 +42,7 @@ test_env = [
[ 'signal_engine/test_health_diagnostics.cpp', 'test_health_diagnostics', 'health_diagnostics_test', 60 ],
[ 'signal_engine/test_synth_compiler.cpp', 'test_synth_compiler', 'synth_compiler_test', 60 ],
[ 'signal_engine/test_synth_wasm_abi.cpp', 'test_synth_wasm_abi', 'synth_wasm_abi_test', 60 ],
[ 'signal_engine/test_ext_registry.cpp', 'test_ext_registry', 'ext_registry_test', 60 ],
]
foreach t : test_env

View file

@ -0,0 +1,416 @@
// External registers: named external inputs, external sinks, and cold
// commands (ext_registry.{h,cpp}).
//
// Written at the language boundary like the golden tests: eval source text,
// tick the engine, and assert user-visible values plus the registry seams'
// observable contracts (compile-time LKG independence, dirty publication,
// handler dispatch).
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cmath>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct CommandCapture {
SymbolID cmd = 0;
ColdArg args[MAX_COLD_ARGS] = {};
uint8_t nargs = 0;
void* user = nullptr;
uint32_t calls = 0;
bool accept = true;
};
CommandCapture g_capture = {};
bool test_command_handler(SymbolID cmd, const ColdArg* args, uint8_t nargs,
void* user)
{
g_capture.cmd = cmd;
g_capture.nargs = nargs;
g_capture.user = user;
for (uint8_t i = 0; i < nargs; i++) g_capture.args[i] = args[i];
g_capture.calls++;
return g_capture.accept;
}
struct ExtHarness {
SignalEngine engine;
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExtHarness() {
engine.init_defaults();
reset_registry();
g_capture = CommandCapture{};
}
~ExtHarness() { reset_registry(); }
EvalResult eval(const std::string& code)
{
return eval_cold(code.c_str(), static_cast<uint32_t>(code.size()),
engine);
}
void eval_ok(const std::string& code)
{
EvalResult r = eval(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: "
<< (r.diagnostics[0].message ? r.diagnostics[0].message
: ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
void expect_error(const std::string& code, DiagnosticCategory category)
{
EvalResult r = eval(code);
INFO("code: " << code);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
bool found = false;
for (uint8_t i = 0; i < r.diagnostic_count; ++i)
if (r.diagnostics[i].category == category) found = true;
INFO("expected category: " << category_to_cstr(category));
REQUIRE(found);
}
uint16_t output_index(const char* output_name)
{
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
REQUIRE(idx != NODE_NONE);
return idx;
}
const SinkBinding* binding_for(const char* sink_name)
{
const SymbolID sink = internSymbol(sink_name);
for (uint8_t i = 0; i < engine.sink_binding_count; i++)
if (engine.sink_bindings[i].sink == sink)
return &engine.sink_bindings[i];
return nullptr;
}
double tick(const char* output_name, double t)
{
std::memset(outputs, 0, sizeof(outputs));
std::memset(workspace, 0, sizeof(workspace));
engine.cells.snapshot_values(cell_values, MAX_CELLS);
ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.0;
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.pool, ctx);
publish_sink_values(engine, outputs);
const double value = outputs[output_index(output_name)];
commit_outputs(engine.pool, outputs);
return value;
}
// Tick without naming an output; sink publication still runs.
void tick_sinks(double t)
{
std::memset(outputs, 0, sizeof(outputs));
std::memset(workspace, 0, sizeof(workspace));
engine.cells.snapshot_values(cell_values, MAX_CELLS);
ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.0;
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.pool, ctx);
publish_sink_values(engine, outputs);
commit_outputs(engine.pool, outputs);
}
};
} // namespace
// ── External inputs ─────────────────────────────────────────────────────────
TEST_CASE("external inputs resolve as graph leaves", "[ext_registry]")
{
ExtHarness h;
SECTION("registered input reads the executor hw snapshot")
{
REQUIRE(register_external_input(
{"meml/joy-x", 3, 1, 0.5f, "[0,1]"}));
h.hw_inputs[3] = 0.25;
h.eval_ok("(a1 (* 2 meml/joy-x))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
}
SECTION("registered input usable inside a sink expression")
{
REQUIRE(register_external_input({"nn/out1", 4, 1, 0.5f, "[0,1]"}));
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
h.hw_inputs[4] = 0.125;
h.eval_ok("(midi/cc74 nn/out1)");
h.tick_sinks(0.0);
REQUIRE(h.engine.sink_binding_count == 1);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.125));
}
SECTION("invalid descriptors are rejected")
{
REQUIRE(!register_external_input({nullptr, 0, 1, 0.0f, ""}));
REQUIRE(!register_external_input({"", 0, 1, 0.0f, ""}));
REQUIRE(!register_external_input({"bad/chan", 31, 2, 0.0f, ""}));
REQUIRE(register_external_input({"ok/in", 31, 1, 0.0f, ""}));
REQUIRE(register_external_input({"ok/in", 12, 1, 0.0f, ""})); // replace
h.hw_inputs[12] = 0.5;
h.eval_ok("(a1 ok/in)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
}
}
// ── External sinks ──────────────────────────────────────────────────────────
TEST_CASE("external sinks publish evaluated values", "[ext_registry]")
{
ExtHarness h;
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
SECTION("assignment evaluates and raises the dirty flag")
{
h.eval_ok("(midi/cc74 ctl/x)");
REQUIRE(h.engine.sink_binding_count == 1);
h.hw_inputs[2] = 0.5;
h.tick_sinks(0.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.5));
REQUIRE(h.engine.sink_dirty[0]);
h.engine.sink_dirty[0] = false; // firmware acknowledgement
h.tick_sinks(1.0);
REQUIRE(!h.engine.sink_dirty[0]); // unchanged value suppressed
h.hw_inputs[2] = 0.9;
h.tick_sinks(2.0);
REQUIRE(h.engine.sink_dirty[0]);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.9));
}
SECTION("dirty uses one quantisation step as deadband")
{
h.eval_ok("(midi/cc74 ctl/x)");
h.hw_inputs[2] = 0.5;
h.tick_sinks(0.0);
h.engine.sink_dirty[0] = false;
// step = (1-0)/2^7 = 0.0078125; 0.003 stays inside the deadband
h.hw_inputs[2] = 0.503;
h.tick_sinks(1.0);
REQUIRE(!h.engine.sink_dirty[0]);
// 0.52 - 0.503 = 0.017 exceeds the step
h.hw_inputs[2] = 0.52;
h.tick_sinks(2.0);
REQUIRE(h.engine.sink_dirty[0]);
}
SECTION("multi-channel sink publishes every channel")
{
REQUIRE(register_external_sink({"bus/out", 3, 0.0f, 1.0f, 200, 0}));
h.eval_ok("(bus/out 0.1 0.2 0.3)");
h.tick_sinks(0.0);
const SinkBinding* binding = h.binding_for("bus/out");
REQUIRE(binding != nullptr);
REQUIRE(binding->arity == 3);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.1));
REQUIRE(h.engine.sink_values[0][1] == Approx(0.2));
REQUIRE(h.engine.sink_values[0][2] == Approx(0.3));
// Reassignment keeps one binding and swaps every channel.
h.eval_ok("(bus/out 0.4 0.5 0.6)");
REQUIRE(h.engine.sink_binding_count == 1);
h.tick_sinks(1.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.4));
REQUIRE(h.engine.sink_values[0][1] == Approx(0.5));
REQUIRE(h.engine.sink_values[0][2] == Approx(0.6));
}
SECTION("wrong arity is a compile error and binds nothing")
{
h.expect_error("(midi/cc74 0.1 0.2)", DiagnosticCategory::Arity);
h.expect_error("(midi/cc74)", DiagnosticCategory::Arity);
REQUIRE(h.engine.sink_binding_count == 0);
}
SECTION("unassign releases the binding")
{
h.eval_ok("(midi/cc74 ctl/x)");
REQUIRE(h.engine.sink_binding_count == 1);
h.eval_ok("(unassign midi/cc74)");
REQUIRE(h.engine.sink_binding_count == 0);
REQUIRE(h.binding_for("midi/cc74") == nullptr);
// Idempotent, and outputs keep working afterwards.
h.eval_ok("(unassign midi/cc74)");
h.eval_ok("(a1 0.25)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.25));
}
}
TEST_CASE("sink LKG is independent per sink", "[ext_registry]")
{
ExtHarness h;
REQUIRE(register_external_input({"ctl/a", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_input({"ctl/b", 3, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_sink({"sink/a", 1, 0.0f, 1.0f, 50, 0}));
REQUIRE(register_external_sink({"sink/b", 1, 0.0f, 1.0f, 50, 0}));
h.hw_inputs[2] = 0.25;
h.hw_inputs[3] = 0.75;
h.eval_ok("(sink/a ctl/a)");
h.eval_ok("(sink/b ctl/b)");
h.eval_ok("(a1 ctl/a)");
h.tick_sinks(0.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.25));
REQUIRE(h.engine.sink_values[1][0] == Approx(0.75));
const SinkBinding* binding_a = h.binding_for("sink/a");
REQUIRE(binding_a != nullptr);
const uint16_t root_before =
h.engine.pool.outputs[binding_a->value_index[0]].root_node;
// A failing edit to sink/a must retain sink/a's published graph while
// sink/b and the outputs keep running.
h.expect_error("(sink/a nosuch/input)", DiagnosticCategory::UndefinedName);
REQUIRE(h.engine.sink_binding_count == 2);
const SinkBinding* after = h.binding_for("sink/a");
REQUIRE(after != nullptr);
REQUIRE(after->arity == 1);
REQUIRE(h.engine.pool.outputs[after->value_index[0]].root_node ==
root_before);
// The retained graph still tracks its input.
h.hw_inputs[2] = 0.5;
h.hw_inputs[3] = 0.8;
h.tick_sinks(1.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.5));
REQUIRE(h.engine.sink_values[1][0] == Approx(0.8));
REQUIRE(h.tick("a1", 2.0) == Approx(0.5));
// A repaired edit replaces the binding.
h.eval_ok("(sink/a 0.125)");
h.tick_sinks(3.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.125));
REQUIRE(h.engine.sink_binding_count == 2);
}
// ── Cold commands ───────────────────────────────────────────────────────────
TEST_CASE("registered cold commands dispatch to the handler", "[ext_registry]")
{
ExtHarness h;
int cookie = 0;
REQUIRE(register_external_command(internSymbol("meml/cmd"), 1, 2));
set_cold_command_handler(&test_command_handler, &cookie);
const SymbolID cmd_id = internSymbol("meml/cmd");
SECTION("integer and float arguments parse by shape")
{
h.eval_ok("(meml/cmd 3)");
REQUIRE(g_capture.calls == 1);
REQUIRE(g_capture.cmd == cmd_id);
REQUIRE(g_capture.user == &cookie);
REQUIRE(g_capture.nargs == 1);
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Int);
REQUIRE(g_capture.args[0].integer == 3);
h.eval_ok("(meml/cmd 2.5)");
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Number);
REQUIRE(g_capture.args[0].number == Approx(2.5f));
}
SECTION("symbol and vector arguments parse")
{
h.eval_ok("(meml/cmd level [1 2 3])");
REQUIRE(g_capture.nargs == 2);
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Symbol);
REQUIRE(g_capture.args[0].symbol == internSymbol("level"));
REQUIRE(g_capture.args[1].kind == ColdArg::Kind::Vector);
REQUIRE(g_capture.args[1].vec[0] == Approx(1.0f));
REQUIRE(g_capture.args[1].vec[1] == Approx(2.0f));
REQUIRE(g_capture.args[1].vec[2] == Approx(3.0f));
}
SECTION("arity mismatch is an error and never calls the handler")
{
h.expect_error("(meml/cmd)", DiagnosticCategory::Arity);
h.expect_error("(meml/cmd 1 2 3)", DiagnosticCategory::Arity);
REQUIRE(g_capture.calls == 0);
}
SECTION("handler rejection is diagnosed")
{
g_capture.accept = false;
h.expect_error("(meml/cmd 1)", DiagnosticCategory::Runtime);
REQUIRE(g_capture.calls == 1);
}
SECTION("handler runs only after earlier edits compiled")
{
h.eval_ok("(a1 0.25)(meml/cmd 1)");
REQUIRE(g_capture.calls == 1);
REQUIRE(h.tick("a1", 0.0) == Approx(0.25));
// The failing edit stops the sequence before the command form.
g_capture.calls = 0;
h.expect_error("(a1 nosuch/input)(meml/cmd 1)",
DiagnosticCategory::UndefinedName);
REQUIRE(g_capture.calls == 0);
}
SECTION("unregistered heads keep the unknown-form behaviour")
{
EvalResult r = h.eval("(meml/nope 1)");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(g_capture.calls == 0);
}
}
TEST_CASE("registry reset clears profile state", "[ext_registry]")
{
ExtHarness h;
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
reset_registry();
// Names no longer resolve as inputs or sinks.
h.expect_error("(a1 ctl/x)", DiagnosticCategory::UndefinedName);
EvalResult r = h.eval("(midi/cc74 0.5)");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(h.engine.sink_binding_count == 0);
REQUIRE(cold_command_handler() == nullptr);
}