Compare commits

..

No commits in common. "feat/dynamic-routed-sinks" and "main" have entirely different histories.

11 changed files with 60 additions and 365 deletions

View file

@ -49,7 +49,6 @@ static const char* node_op_name(sig::NodeOp op) {
case sig::NodeOp::RawTimeLoad: return "RawTimeLoad";
case sig::NodeOp::CellLoad: return "CellLoad";
case sig::NodeOp::InputLoad: return "InputLoad";
case sig::NodeOp::InputSelect: return "InputSelect";
case sig::NodeOp::PrevOutputLoad: return "PrevOutputLoad";
case sig::NodeOp::Add: return "Add";
case sig::NodeOp::Sub: return "Sub";

View file

@ -30,23 +30,10 @@ void SignalEngine::init_defaults(Sample bpm, int beats_per_bar,
GraphBuilder::init_symbols();
state = EngineState{};
session_generation = 0;
memset(cold_hw_inputs, 0, sizeof(cold_hw_inputs));
reset_session_storage(bpm, beats_per_bar, bars_per_phrase,
phrases_per_section, false);
}
void SignalEngine::snapshot_cold_hw_inputs(const Sample* values,
uint16_t count) {
if (!values) count = 0;
if (count > MAX_HW_INPUT_CHANNELS) count = MAX_HW_INPUT_CHANNELS;
if (count > 0)
memcpy(cold_hw_inputs, values, count * sizeof(Sample));
if (count < MAX_HW_INPUT_CHANNELS) {
memset(cold_hw_inputs + count, 0,
(MAX_HW_INPUT_CHANNELS - count) * sizeof(Sample));
}
}
void SignalEngine::reset_session_storage(Sample bpm, int beats_per_bar,
int bars_per_phrase,
int phrases_per_section,
@ -820,6 +807,7 @@ static EvalResult do_set(TokenStream& ts, SignalEngine& engine,
Sample cell_vals[MAX_CELLS];
engine.cells.snapshot_values(cell_vals, MAX_CELLS);
Sample hw_inputs[32] = {};
Sample workspace[MAX_TOTAL_NODES] = {};
Sample outputs[MAX_OUTPUTS] = {};
@ -827,7 +815,7 @@ static EvalResult do_set(TokenStream& ts, SignalEngine& engine,
ctx.t = engine.state.current_time;
ctx.dt = engine.state.current_dt;
ctx.cell_values = cell_vals;
ctx.hw_inputs = engine.cold_hw_inputs;
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;
@ -2384,31 +2372,30 @@ static void release_output_slot(SignalEngine& engine, uint16_t slot) {
}
static void unassign_sink(SignalEngine& engine, SymbolID sink_sym) {
// A normal sink has one row. Multi-binding routers may have several;
// ordinary (unassign name) deliberately clears the whole route family.
// Do not advance after a removal because swap-remove puts a fresh row at
// the same index.
uint8_t index = 0;
while (index < engine.sink_binding_count) {
if (engine.sink_bindings[index].sink != sink_sym) {
index++;
continue;
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;
}
const SinkBinding binding = engine.sink_bindings[index];
for (uint8_t ch = 0; ch < binding.arity; ch++)
release_output_slot(engine, binding.value_index[ch]);
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--;
}
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--;
}
// Compile channel `ch` of a sink binding from the next expression in the
@ -2449,83 +2436,22 @@ static void compile_sink_zero_channel(SignalEngine& engine,
candidate.dep_count = 0;
}
// Multi-binding routers need a stable edit identity without turning a routing
// signal into a cold parameter. Hash the token structure of the leading route
// expressions: whitespace changes do not duplicate a route, changing only its
// payload rebinds it, and a genuinely different route expression may coexist.
static bool sink_route_key(TokenStream& ts, uint8_t route_ch,
uint64_t& key) {
if (route_ch == 0) return false;
const uint16_t saved = ts.pos;
uint64_t hash = UINT64_C(1469598103934665603);
auto mix = [&hash](uint64_t value) {
for (uint8_t byte = 0; byte < 8; ++byte) {
hash ^= static_cast<uint8_t>(value >> (byte * 8));
hash *= UINT64_C(1099511628211);
}
};
for (uint8_t route = 0; route < route_ch; ++route) {
if (ts.peek().kind == TokenKind::RParen || ts.at_end()) {
ts.rewind(saved);
return false;
}
const uint16_t begin = ts.pos;
GraphBuilder::skip_form(ts);
const uint16_t end = ts.pos;
if (end <= begin) {
ts.rewind(saved);
return false;
}
for (uint16_t i = begin; i < end; ++i) {
const Token& token = ts.tokens[i];
mix(static_cast<uint8_t>(token.kind));
if (token.kind == TokenKind::Number) {
uint64_t bits = 0;
static_assert(sizeof(bits) == sizeof(token.number));
memcpy(&bits, &token.number, sizeof(bits));
mix(bits);
} else if (token.kind == TokenKind::Symbol) {
mix(token.symbol);
} else if (token.kind == TokenKind::String) {
mix(token.string.length);
mix(token.string.offset);
}
}
}
ts.rewind(saved);
key = hash;
return true;
}
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);
uint64_t route_key = 0;
const bool has_route_key =
desc->allow_multiple_bindings &&
sink_route_key(ts, desc->route_ch, route_key);
// Existing binding for an ordinary sink is its LKG baseline: nothing
// below touches it unless every channel compiles. A multi-binding router
// appends one independently compiled route instance instead.
// 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 = {};
if (!desc->allow_multiple_bindings || has_route_key) {
for (uint8_t i = 0; i < engine.sink_binding_count; i++) {
const SinkBinding& candidate = engine.sink_bindings[i];
if (candidate.sink == sink_sym &&
(!desc->allow_multiple_bindings ||
candidate.route_key == route_key)) {
binding_index = i;
rebinding = true;
previous = candidate;
break;
}
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) {
@ -2539,13 +2465,6 @@ static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts,
// above the named a/d/s range.
uint16_t slots[MAX_SINK_ARITY] = {};
uint8_t planned = 0;
if (rebinding) {
const uint8_t reused = previous.arity < desc->arity
? previous.arity : desc->arity;
for (uint8_t ch = 0; ch < reused; ++ch) {
slots[planned++] = previous.value_index[ch];
}
}
for (uint16_t s = SINK_SLOT_BASE;
s < MAX_OUTPUTS && planned < desc->arity; s++) {
if (engine.pool.outputs[s].root_node != NODE_NONE) continue;
@ -2730,7 +2649,6 @@ static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts,
SinkBinding binding;
binding.sink = sink_sym;
binding.route_key = route_key;
binding.arity = desc->arity;
for (uint8_t ch = 0; ch < desc->arity; ch++) {
GraphBuildResult plan;
@ -2876,6 +2794,7 @@ EvalResult eval_expression(const char* source, uint32_t length,
engine.cells.snapshot_values(cell_values, MAX_CELLS);
// Execute one sample
Sample hw_inputs[32] = {};
Sample outputs[MAX_OUTPUTS] = {};
Sample workspace[MAX_TOTAL_NODES] = {};
@ -2883,7 +2802,7 @@ EvalResult eval_expression(const char* source, uint32_t length,
ctx.t = engine.state.current_time;
ctx.dt = engine.state.current_dt;
ctx.cell_values = cell_values;
ctx.hw_inputs = engine.cold_hw_inputs;
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;

View file

@ -161,12 +161,6 @@ struct SignalEngine {
NodePool scratch_pool;
char eval_text_buf[512] = {};
// Latest host-owned external-input snapshot used only by top-level cold
// expression evaluation. Hot graphs receive their input pointer through
// ExecutionContext on every tick. Keeping a copy here gives queries the
// same last-known values without retaining a host-lifetime pointer.
Sample cold_hw_inputs[MAX_HW_INPUT_CHANNELS] = {};
#if USEQ_HAS_SYNTH_ENGINE
// ── Host synth compiler domain (synth-nodes.md) ─────────────────────
// Published synth artefacts: identity-keyed declarations + control
@ -203,8 +197,6 @@ struct SignalEngine {
int bars_per_phrase = 4,
int phrases_per_section = 4,
bool publish_session_clear = true);
void snapshot_cold_hw_inputs(const Sample* values, uint16_t count);
};
// ── Cold-Path Evaluation ────────────────────────────────────────────────────

View file

@ -58,15 +58,6 @@ static inline Sample eval_node(
case NodeOp::LoadDt: return dt;
case NodeOp::CellLoad: return cell_values[(uint16_t)n.imm];
case NodeOp::InputLoad: return hw_inputs[(uint16_t)n.imm];
case NodeOp::InputSelect: {
if (!std::isfinite(a)) return b;
const uint32_t packed = static_cast<uint32_t>(n.imm);
const uint16_t base = static_cast<uint16_t>(packed >> 8);
const uint8_t count = static_cast<uint8_t>(packed & 0xffu);
const long selected = std::lround(a);
if (selected < 1 || selected > count) return b;
return hw_inputs[base + static_cast<uint16_t>(selected - 1)];
}
case NodeOp::PrevOutputLoad:
return prev_output_values[(uint16_t)n.imm];
case NodeOp::SlotLoad: return 0.0; // handled in execution loop
@ -234,17 +225,7 @@ void publish_sink_values(SignalEngine& engine, const Sample* output_values) {
const Sample previous = engine.sink_values[b][ch];
Sample delta = value - previous;
if (delta < 0.0) delta = -delta;
// Routing channels are addresses, not quantised payload. Any
// movement must reach the backend, which performs its discrete
// route conversion (for MIDI CC, nearest integer and range check).
const bool route_channel = desc != nullptr && ch < desc->route_ch;
const bool channel_dirty =
route_channel ? value != previous
: (step > 0.0 ? delta > step
: value != previous);
if (channel_dirty) {
dirty = true;
}
if (step > 0.0 ? delta > step : value != previous) dirty = true;
engine.sink_values[b][ch] = value;
}
if (dirty) engine.sink_dirty[b] = true;

View file

@ -60,8 +60,6 @@ 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;
if (desc.route_ch > desc.arity) return false;
if (desc.route_ch != 0 && desc.route_min > desc.route_max) return false;
// Vector-packing: all-or-nothing, and the split must cover the arity
// (leading base channels + trailing modulation channels).
const bool packed = desc.vec_base_ch != 0 || desc.vec_mod_ch != 0;

View file

@ -18,9 +18,10 @@ namespace sig {
// (useq-clear) and is deliberately NOT touched by SignalEngine session resets.
// reset_registry() exists for tests and explicit profile re-initialisation.
// Portable defaults with headroom for embedded profiles. A constrained or
// wider integration may override these at compile time, while descriptor
// registration remains the single runtime API.
// Registry capacities sized for the uSEQ+NISPS firmware profile plus headroom:
// 19 meml/* control registers + 8 nn/out* neural outputs = 27 inputs (cap 32,
// aligned with MAX_HW_INPUT_CHANNELS); 32 midi/cc* transports + nn/in = 33
// sinks (cap 40); 14 nn/* commands (cap 16).
constexpr uint8_t MAX_EXTERNAL_INPUTS = 32;
constexpr uint8_t MAX_EXTERNAL_SINKS = 40;
constexpr uint8_t MAX_EXTERNAL_COMMANDS = 16;
@ -33,9 +34,9 @@ constexpr uint16_t MAX_HW_INPUT_CHANNELS = 32;
// ── External input registers (spec §3.1/§3.2) ───────────────────────────────
struct ExternalInputDesc {
const char* name; // e.g. "nn/out"; multi-channel names are callable
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; >1 enables (name selector)
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
};
@ -43,7 +44,7 @@ struct ExternalInputDesc {
// ── External sinks (spec §3.3/§3.5/§7.4) ────────────────────────────────────
struct ExternalSinkDesc {
const char* name; // static-lifetime spelling, e.g. "midi/cc"
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;
@ -61,20 +62,6 @@ struct ExternalSinkDesc {
// arity (vec_base_ch leading base channels, vec_mod_ch trailing mods).
uint8_t vec_base_ch = 0;
uint8_t vec_mod_ch = 0;
// Optional leading routing channels. They are evaluated signals, but
// select a destination rather than carrying a transport payload, so
// payload quantisation/deadband does not apply to them. The firmware
// validates and quantises them according to route_min/route_max.
uint8_t route_ch = 0;
float route_min = 0.0f;
float route_max = 0.0f;
// Most sink names own exactly one replaceable LKG binding. A dynamic
// router such as (midi/cc cc-signal value-signal) needs several instances
// of the same sink name to coexist. The leading route expressions identify
// an instance for LKG replacement; (unassign name) removes every instance.
bool allow_multiple_bindings = false;
};
struct ExternalCommandDesc {
@ -107,7 +94,7 @@ bool external_sink_registered(SymbolID name);
const ExternalCommandDesc* find_external_command(SymbolID cmd);
// ── Sink bindings ───────────────────────────────────────────────────────────
// One entry per assigned external sink instance, owned by SignalEngine. Each channel's
// 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.
@ -131,10 +118,6 @@ static_assert(SINK_SLOT_BASE + (uint32_t)MAX_SINK_BINDINGS * MAX_SINK_ARITY
struct SinkBinding {
SymbolID sink = SymbolIntern::INVALID_ID;
uint16_t value_index[MAX_SINK_ARITY] = {}; // pool output slot per channel
// Stable token-structure hash of a multi-binding sink's leading routing
// expressions. Re-evaluating the same route replaces it; a distinct route
// expression coexists as another binding.
uint64_t route_key = 0;
uint8_t arity = 0;
};

View file

@ -939,15 +939,7 @@ uint16_t GraphBuilder::compile_symbol(SymbolID sym_id, Scope& scope,
return pool.make_binop(NodeOp::Mul, beat_dur, bpb_load);
}
// 5. Hardware inputs. Multi-channel registers are callable selectors;
// reading one bare would silently select channel 1.
const ExternalInputDesc* external_desc = find_external_input(sym_id);
if (external_desc != nullptr && external_desc->channels > 1) {
return report_error_at_cat(
DiagnosticCategory::Arity, span_start, span_len,
"This multi-channel input needs a channel signal",
"Call it with a 1-based selector, for example (nn/out 1)");
}
// 5. Hardware inputs
uint16_t input_idx = resolve_hardware_input(sym_id);
if (input_idx != NODE_NONE) {
return pool.make_input_load(input_idx);
@ -1308,30 +1300,6 @@ uint16_t GraphBuilder::compile_namespaced_form(
uint16_t GraphBuilder::compile_form(SymbolID op, TokenStream& ts,
Scope& scope, TimeContext& ctx, Token op_tok) {
// A registered multi-channel external input is a hot selector function.
// The channel argument remains a signal; invalid frames read the
// descriptor's neutral value rather than an unrelated channel.
if (const ExternalInputDesc* desc = find_external_input(op);
desc != nullptr && desc->channels > 1) {
if (ts.peek().kind == TokenKind::RParen) {
return report_error_cat(
DiagnosticCategory::Arity, op_tok,
"This external input needs one channel signal",
"Try: (nn/out 1)");
}
const uint16_t selector = compile_expr(ts, scope, ctx);
if (selector == NODE_NONE) return NODE_NONE;
if (ts.peek().kind != TokenKind::RParen) {
return report_error_cat(
DiagnosticCategory::Arity, op_tok,
"This external input takes one channel signal",
"Remove the extra arguments");
}
return pool.make_input_select(
selector, pool.make_const(desc->neutral), desc->hw_index,
desc->channels);
}
const NamespacedOperator resolved = resolve_namespaced_operator(op);
if (resolved.name_space != OperatorNamespace::None) {
return compile_namespaced_form(resolved, ts, scope, ctx, op_tok);

View file

@ -145,19 +145,6 @@ uint16_t NodePool::make_input_load(uint16_t input_index) {
return intern_node(n);
}
uint16_t NodePool::make_input_select(uint16_t selector, uint16_t neutral,
uint16_t input_base,
uint8_t channel_count) {
Node n;
n.op = NodeOp::InputSelect;
n.flags = 0;
n.input_a = selector;
n.input_b = neutral;
n.imm = static_cast<Sample>((static_cast<uint32_t>(input_base) << 8) |
channel_count);
return intern_node(n);
}
uint16_t NodePool::make_prev_output_load(uint16_t output_index) {
Node n;
n.op = NodeOp::PrevOutputLoad;

View file

@ -16,7 +16,6 @@ enum class NodeOp : uint8_t {
RawTimeLoad, // loads the single 't' input
CellLoad, // imm = cell_id
InputLoad, // imm = input_index (hardware input channel)
InputSelect, // input_a=1-based selector, input_b=neutral, imm=base/count
PrevOutputLoad, // imm = output_index; previous tick's value
// Binary arithmetic
@ -240,8 +239,6 @@ struct NodePool {
uint16_t make_raw_time_load();
uint16_t make_cell_load(SymbolID cell_id);
uint16_t make_input_load(uint16_t input_index);
uint16_t make_input_select(uint16_t selector, uint16_t neutral,
uint16_t input_base, uint8_t channel_count);
uint16_t make_prev_output_load(uint16_t output_index);
uint16_t make_state_load(uint16_t state_slot);

View file

@ -171,71 +171,14 @@ TEST_CASE("external inputs resolve as graph leaves", "[ext_registry]")
SECTION("registered input usable inside a sink expression")
{
REQUIRE(register_external_input({"nn/out1", 4, 1, 0.5f, "[0,1]"}));
REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
h.hw_inputs[4] = 0.125;
h.eval_ok("(transport/mono nn/out1)");
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("multi-channel input is selected by a hot 1-based signal")
{
REQUIRE(register_external_input(
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
h.hw_inputs[4] = 0.1;
h.hw_inputs[5] = 0.2;
h.hw_inputs[6] = 0.3;
h.hw_inputs[7] = 0.4;
h.eval_ok("(a1 (nn/out (from-list [1 4] bar)))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.1));
REQUIRE(h.tick("a1", 1.5) == Approx(0.4));
}
SECTION("cold expressions read the latest external-input snapshot")
{
REQUIRE(register_external_input(
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
h.hw_inputs[4] = 0.1;
h.hw_inputs[5] = 0.2;
h.hw_inputs[6] = 0.3;
h.hw_inputs[7] = 0.4;
h.engine.snapshot_cold_hw_inputs(h.hw_inputs, 32);
EvalResult selected = h.eval("(nn/out 4)");
REQUIRE(selected.kind == EvalResult::Number);
REQUIRE(selected.number == Approx(0.4));
EvalResult composed = h.eval("(* 2 (nn/out 2))");
REQUIRE(composed.kind == EvalResult::Number);
REQUIRE(composed.number == Approx(0.4));
EvalResult invalid = h.eval("(nn/out 9)");
REQUIRE(invalid.kind == EvalResult::Number);
REQUIRE(invalid.number == Approx(0.5));
}
SECTION("invalid multi-channel selectors return the declared neutral")
{
REQUIRE(register_external_input(
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
h.eval_ok("(a1 (nn/out 0))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
h.eval_ok("(a1 (nn/out 5))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
h.eval_ok("(a1 (nn/out (/ 0 0)))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
}
SECTION("multi-channel input cannot be read bare")
{
REQUIRE(register_external_input(
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
h.expect_error("(a1 nn/out)", DiagnosticCategory::Arity);
h.expect_error("(a1 (nn/out))", DiagnosticCategory::Arity);
h.expect_error("(a1 (nn/out 1 2))", DiagnosticCategory::Arity);
}
SECTION("invalid descriptors are rejected")
{
REQUIRE(!register_external_input({nullptr, 0, 1, 0.0f, ""}));
@ -255,11 +198,11 @@ 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({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
SECTION("assignment evaluates and raises the dirty flag")
{
h.eval_ok("(transport/mono ctl/x)");
h.eval_ok("(midi/cc74 ctl/x)");
REQUIRE(h.engine.sink_binding_count == 1);
h.hw_inputs[2] = 0.5;
@ -279,7 +222,7 @@ TEST_CASE("external sinks publish evaluated values", "[ext_registry]")
SECTION("dirty uses one quantisation step as deadband")
{
h.eval_ok("(transport/mono ctl/x)");
h.eval_ok("(midi/cc74 ctl/x)");
h.hw_inputs[2] = 0.5;
h.tick_sinks(0.0);
h.engine.sink_dirty[0] = false;
@ -347,21 +290,21 @@ TEST_CASE("external sinks publish evaluated values", "[ext_registry]")
SECTION("wrong arity is a compile error and binds nothing")
{
h.expect_error("(transport/mono 0.1 0.2)", DiagnosticCategory::Arity);
h.expect_error("(transport/mono)", DiagnosticCategory::Arity);
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("(transport/mono ctl/x)");
h.eval_ok("(midi/cc74 ctl/x)");
REQUIRE(h.engine.sink_binding_count == 1);
h.eval_ok("(unassign transport/mono)");
h.eval_ok("(unassign midi/cc74)");
REQUIRE(h.engine.sink_binding_count == 0);
REQUIRE(h.binding_for("transport/mono") == nullptr);
REQUIRE(h.binding_for("midi/cc74") == nullptr);
// Idempotent, and outputs keep working afterwards.
h.eval_ok("(unassign transport/mono)");
h.eval_ok("(unassign midi/cc74)");
h.eval_ok("(a1 0.25)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.25));
}
@ -415,78 +358,6 @@ TEST_CASE("sink LKG is independent per sink", "[ext_registry]")
REQUIRE(h.engine.sink_binding_count == 2);
}
TEST_CASE("multi-binding routed sink keeps independent route instances",
"[ext_registry][routed_sink]")
{
ExtHarness h;
REQUIRE(register_external_input({"ctl/value", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_input({"ctl/cc", 3, 1, 74.0f, "[1,127]"}));
ExternalSinkDesc midi{};
midi.name = "midi/cc";
midi.arity = 2;
midi.min = 0.0f;
midi.max = 1.0f;
midi.max_rate_hz = 50;
midi.quant_bits = 7;
midi.route_ch = 1;
midi.route_min = 1.0f;
midi.route_max = 127.0f;
midi.allow_multiple_bindings = true;
REQUIRE(register_external_sink(midi));
h.hw_inputs[2] = 0.25;
h.hw_inputs[3] = 75.0;
h.eval_ok("(midi/cc 74 ctl/value)");
h.eval_ok("(midi/cc ctl/cc 0.5)");
REQUIRE(h.engine.sink_binding_count == 2);
h.tick_sinks(0.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(74.0));
REQUIRE(h.engine.sink_values[0][1] == Approx(0.25));
REQUIRE(h.engine.sink_values[1][0] == Approx(75.0));
REQUIRE(h.engine.sink_values[1][1] == Approx(0.5));
// The documented patterned route is an ordinary signal expression. Its
// token structure, not whitespace, is the stable route edit identity.
h.eval_ok("(midi/cc (from-list [76 77] bar) 0.125)");
REQUIRE(h.engine.sink_binding_count == 3);
h.eval_ok("(midi/cc (from-list [76 77] bar) 0.25)");
REQUIRE(h.engine.sink_binding_count == 3);
h.tick_sinks(0.5);
REQUIRE(h.engine.sink_values[2][1] == Approx(0.25));
// The leading CC expression is the route identity. Re-entering the same
// route replaces its value graph instead of leaking a duplicate binding.
h.eval_ok("(midi/cc 74 0.75)");
REQUIRE(h.engine.sink_binding_count == 3);
h.tick_sinks(1.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(74.0));
REQUIRE(h.engine.sink_values[0][1] == Approx(0.75));
// A failed edit retains all prior route instances.
h.expect_error("(midi/cc 74 nosuch/input)",
DiagnosticCategory::UndefinedName);
REQUIRE(h.engine.sink_binding_count == 3);
h.tick_sinks(2.0);
REQUIRE(h.engine.sink_values[0][1] == Approx(0.75));
REQUIRE(h.engine.sink_values[1][1] == Approx(0.5));
// Route channels bypass the payload deadband so patterned addressing is
// observed even when the payload itself is unchanged.
h.engine.sink_dirty[0] = false;
h.engine.sink_dirty[1] = false;
h.hw_inputs[3] = 75.001;
h.tick_sinks(3.0);
REQUIRE(!h.engine.sink_dirty[0]);
REQUIRE(h.engine.sink_dirty[1]);
// Ordinary unassignment clears the whole dynamic route family.
h.eval_ok("(unassign midi/cc)");
REQUIRE(h.engine.sink_binding_count == 0);
REQUIRE(h.binding_for("midi/cc") == nullptr);
}
// ── Cold commands ───────────────────────────────────────────────────────────
TEST_CASE("registered cold commands dispatch to the handler", "[ext_registry]")
@ -562,11 +433,11 @@ 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({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
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("(transport/mono 0.5)");
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);

View file

@ -387,15 +387,15 @@ TEST_CASE("a bad sugar edit keeps the prior binding while another sink stays "
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_sink(
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
h.eval_ok(kFlat);
h.eval_ok("(transport/mono ctl/x)");
h.eval_ok("(midi/cc74 ctl/x)");
h.hw_inputs[2] = 0.5;
h.tick_sinks(0.0);
const uint8_t vec_row = h.binding_row("vec/in");
const uint8_t cc_row = h.binding_row("transport/mono");
const uint8_t cc_row = h.binding_row("midi/cc74");
REQUIRE(vec_row != MAX_SINK_BINDINGS);
REQUIRE(cc_row != MAX_SINK_BINDINGS);