feat(signal-engine): generic external input/sink/command registries with per-sink LKG (spec §3)
This commit is contained in:
parent
5ca6124715
commit
db2031ae9e
13 changed files with 1102 additions and 9 deletions
24
MAP.md
24
MAP.md
|
|
@ -8,14 +8,19 @@ modulisp/
|
||||||
│ ├── pch.h # precompiled header (std includes)
|
│ ├── pch.h # precompiled header (std includes)
|
||||||
│ ├── modulisp/
|
│ ├── modulisp/
|
||||||
│ │ └── lisp/symbol_intern.h # header-only symbol interning table
|
│ │ └── 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
|
│ │ ├── token.{h,cpp} # tokenizer
|
||||||
│ │ ├── cell_store.{h,cpp} # S-expression cell arena
|
│ │ ├── cell_store.{h,cpp} # S-expression cell arena
|
||||||
│ │ ├── node_pool.{h,cpp} # fixed-pool DAG node storage + traversal
|
│ │ ├── node_pool.{h,cpp} # fixed-pool DAG node storage + traversal
|
||||||
│ │ ├── graph_builder.{h,cpp} # cell graph → node DAG compiler
|
│ │ ├── 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
|
│ │ ├── compiler_pipeline.{h,cpp} # source → tokens → cells → DAG orchestration
|
||||||
│ │ ├── cold_eval.{h,cpp} + eval_ops.h # constant folding / cold evaluation
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── state_registry.{h,cpp} # defstate slots, live-edit slots
|
||||||
│ │ ├── synth_registry.{h,cpp} # synth graph snapshot registry
|
│ │ ├── synth_registry.{h,cpp} # synth graph snapshot registry
|
||||||
│ │ ├── synth_graph.{h,cpp} # serialisable synth artefact graph
|
│ │ ├── synth_graph.{h,cpp} # serialisable synth artefact graph
|
||||||
|
|
@ -29,9 +34,20 @@ modulisp/
|
||||||
│ ├── devtools/ # devtools.{h,cpp} — USEQ_DEVTOOLS-gated telemetry
|
│ ├── devtools/ # devtools.{h,cpp} — USEQ_DEVTOOLS-gated telemetry
|
||||||
│ └── ports/ # IStorage.h, II2CTransport.h, mocks/{MockStorage.h,MockI2CBus.h}
|
│ └── ports/ # IStorage.h, II2CTransport.h, mocks/{MockStorage.h,MockI2CBus.h}
|
||||||
└── test/
|
└── 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)
|
├── 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.
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ signal_engine_lib = static_library('useq_signal_engine',
|
||||||
'src/signal_engine/node_pool.cpp',
|
'src/signal_engine/node_pool.cpp',
|
||||||
'src/signal_engine/executor.cpp',
|
'src/signal_engine/executor.cpp',
|
||||||
'src/signal_engine/graph_builder.cpp',
|
'src/signal_engine/graph_builder.cpp',
|
||||||
|
'src/signal_engine/ext_registry.cpp',
|
||||||
'src/signal_engine/compiler_pipeline.cpp',
|
'src/signal_engine/compiler_pipeline.cpp',
|
||||||
'src/signal_engine/cold_eval.cpp',
|
'src/signal_engine/cold_eval.cpp',
|
||||||
'src/signal_engine/state_registry.cpp',
|
'src/signal_engine/state_registry.cpp',
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,13 @@ void SignalEngine::reset_session_storage(Sample bpm, int beats_per_bar,
|
||||||
state_sources[i] = StateUpdateSource{};
|
state_sources[i] = StateUpdateSource{};
|
||||||
state_compile_diagnostics[i] = ActiveCompileDiagnostic{};
|
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();
|
registry.clear();
|
||||||
#if USEQ_HAS_SYNTH_ENGINE
|
#if USEQ_HAS_SYNTH_ENGINE
|
||||||
if (publish_session_clear) {
|
if (publish_session_clear) {
|
||||||
|
|
@ -2184,18 +2191,35 @@ static EvalResult do_reset_clock_int(TokenStream& ts, EngineState& state) {
|
||||||
|
|
||||||
// ── Output assignment ───────────────────────────────────────────────────────
|
// ── Output assignment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static void unassign_sink(SignalEngine& engine, SymbolID sink_sym);
|
||||||
|
|
||||||
static EvalResult do_unassign(TokenStream& ts, SignalEngine& engine) {
|
static EvalResult do_unassign(TokenStream& ts, SignalEngine& engine) {
|
||||||
Token output_tok = ts.consume();
|
Token output_tok = ts.consume();
|
||||||
if (output_tok.kind != TokenKind::Symbol ||
|
if (output_tok.kind != TokenKind::Symbol) {
|
||||||
!GraphBuilder::is_output_symbol(output_tok.symbol)) {
|
return make_error("unassign needs one output or sink name",
|
||||||
return make_error("unassign needs one output 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)");
|
"Try: (unassign a1)");
|
||||||
}
|
}
|
||||||
if (ts.peek().kind != TokenKind::RParen) {
|
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)");
|
"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 =
|
const uint16_t output_index =
|
||||||
GraphBuilder::resolve_output_index(output_tok.symbol);
|
GraphBuilder::resolve_output_index(output_tok.symbol);
|
||||||
|
|
||||||
|
|
@ -2310,6 +2334,248 @@ static EvalResult do_output_assign(SymbolID output_sym, TokenStream& ts,
|
||||||
return make_ok();
|
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 ─────────────────────────────────
|
// ── Scratch-isolated expression evaluation ─────────────────────────────────
|
||||||
|
|
||||||
EvalResult eval_expression(const char* source, uint32_t length,
|
EvalResult eval_expression(const char* source, uint32_t length,
|
||||||
|
|
@ -2944,6 +3210,28 @@ static EvalResult eval_form(TokenStream& ts, SignalEngine& engine,
|
||||||
return r;
|
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
|
// do / scope — evaluate children sequentially
|
||||||
if (op == sym.do_ || op == sym.scope) {
|
if (op == sym.do_ || op == sym.scope) {
|
||||||
EvalResult last = make_ok();
|
EvalResult last = make_ok();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include "types.h"
|
#include "types.h"
|
||||||
#include "cell_store.h"
|
#include "cell_store.h"
|
||||||
#include "node_pool.h"
|
#include "node_pool.h"
|
||||||
|
#include "ext_registry.h"
|
||||||
#include "state_registry.h"
|
#include "state_registry.h"
|
||||||
#if USEQ_HAS_SYNTH_ENGINE
|
#if USEQ_HAS_SYNTH_ENGINE
|
||||||
#include "synth_graph.h"
|
#include "synth_graph.h"
|
||||||
|
|
@ -146,6 +147,17 @@ struct SignalEngine {
|
||||||
ActiveCompileDiagnostic state_compile_diagnostics[MAX_STATE_SLOTS] = {};
|
ActiveCompileDiagnostic state_compile_diagnostics[MAX_STATE_SLOTS] = {};
|
||||||
|
|
||||||
StateResourceRegistry registry;
|
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;
|
NodePool scratch_pool;
|
||||||
char eval_text_buf[512] = {};
|
char eval_text_buf[512] = {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "executor.h"
|
#include "executor.h"
|
||||||
|
#include "cold_eval.h"
|
||||||
#include "eval_ops.h"
|
#include "eval_ops.h"
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
@ -201,6 +202,36 @@ void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx) {
|
||||||
pool.runtime_fallback_mask = fallback_mask;
|
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 ───────────────────────────────────────────────────────
|
// ── Post-Tick Commit ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
void commit_outputs(NodePool& pool, const Sample* output_values) {
|
void commit_outputs(NodePool& pool, const Sample* output_values) {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,17 @@ void execute_batch(
|
||||||
// bitmask. Call after compilation or graph changes. Writes directly into
|
// bitmask. Call after compilation or graph changes. Writes directly into
|
||||||
// pool.output_class[] and pool.output_input_mask[].
|
// 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);
|
void classify_outputs(NodePool& pool);
|
||||||
|
|
||||||
} // namespace sig
|
} // namespace sig
|
||||||
|
|
|
||||||
146
src/signal_engine/ext_registry.cpp
Normal file
146
src/signal_engine/ext_registry.cpp
Normal 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
|
||||||
137
src/signal_engine/ext_registry.h
Normal file
137
src/signal_engine/ext_registry.h
Normal 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
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "graph_builder.h"
|
#include "graph_builder.h"
|
||||||
|
#include "ext_registry.h"
|
||||||
#include "compiler_pipeline.h"
|
#include "compiler_pipeline.h"
|
||||||
#include "../modulisp/lisp/symbol_intern.h"
|
#include "../modulisp/lisp/symbol_intern.h"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
@ -568,6 +569,12 @@ uint16_t GraphBuilder::resolve_output_index(SymbolID op) {
|
||||||
}
|
}
|
||||||
|
|
||||||
uint16_t GraphBuilder::resolve_hardware_input(SymbolID sym_id) {
|
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);
|
const String& name = getSymbolString(sym_id);
|
||||||
if (name == "in1") return 0; // INP_I1
|
if (name == "in1") return 0; // INP_I1
|
||||||
if (name == "in2") return 1; // INP_I2
|
if (name == "in2") return 1; // INP_I2
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include "node_pool.h"
|
#include "node_pool.h"
|
||||||
#include "graph_builder.h"
|
#include "graph_builder.h"
|
||||||
#include "executor.h"
|
#include "executor.h"
|
||||||
|
#include "ext_registry.h"
|
||||||
#include "cold_eval.h"
|
#include "cold_eval.h"
|
||||||
|
|
||||||
#endif // SIGNAL_ENGINE_H
|
#endif // SIGNAL_ENGINE_H
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ test_env = [
|
||||||
[ 'signal_engine/test_health_diagnostics.cpp', 'test_health_diagnostics', 'health_diagnostics_test', 60 ],
|
[ '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_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_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
|
foreach t : test_env
|
||||||
|
|
|
||||||
416
test/signal_engine/test_ext_registry.cpp
Normal file
416
test/signal_engine/test_ext_registry.cpp
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue