From 67d04bf2292dffcd25c4c382377ebf6e596192e4 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Thu, 27 Aug 2026 09:55:41 +0300 Subject: [PATCH] feat(signal-engine): support dynamic routed sinks --- src/devtools/devtools.cpp | 1 + src/signal_engine/cold_eval.cpp | 130 +++++++++++++---- src/signal_engine/executor.cpp | 21 ++- src/signal_engine/ext_registry.cpp | 2 + src/signal_engine/ext_registry.h | 33 +++-- src/signal_engine/graph_builder.cpp | 34 ++++- src/signal_engine/node_pool.cpp | 13 ++ src/signal_engine/node_pool.h | 3 + test/signal_engine/test_ext_registry.cpp | 132 ++++++++++++++++-- test/signal_engine/test_sink_vector_sugar.cpp | 6 +- 10 files changed, 319 insertions(+), 56 deletions(-) diff --git a/src/devtools/devtools.cpp b/src/devtools/devtools.cpp index 565619d..9d22f9b 100644 --- a/src/devtools/devtools.cpp +++ b/src/devtools/devtools.cpp @@ -49,6 +49,7 @@ 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"; diff --git a/src/signal_engine/cold_eval.cpp b/src/signal_engine/cold_eval.cpp index c34c488..e5ec482 100644 --- a/src/signal_engine/cold_eval.cpp +++ b/src/signal_engine/cold_eval.cpp @@ -2372,30 +2372,31 @@ static void release_output_slot(SignalEngine& engine, uint16_t slot) { } 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; + // 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; } + + 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 @@ -2436,22 +2437,83 @@ 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(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(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); - // Existing binding for this sink is the per-sink LKG baseline: nothing - // below touches it unless every channel compiles. + 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. 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 (!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; + } } } if (!rebinding && engine.sink_binding_count >= MAX_SINK_BINDINGS) { @@ -2465,6 +2527,13 @@ 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; @@ -2649,6 +2718,7 @@ 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; diff --git a/src/signal_engine/executor.cpp b/src/signal_engine/executor.cpp index 4d16c95..23413c4 100644 --- a/src/signal_engine/executor.cpp +++ b/src/signal_engine/executor.cpp @@ -58,6 +58,15 @@ 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(n.imm); + const uint16_t base = static_cast(packed >> 8); + const uint8_t count = static_cast(packed & 0xffu); + const long selected = std::lround(a); + if (selected < 1 || selected > count) return b; + return hw_inputs[base + static_cast(selected - 1)]; + } case NodeOp::PrevOutputLoad: return prev_output_values[(uint16_t)n.imm]; case NodeOp::SlotLoad: return 0.0; // handled in execution loop @@ -225,7 +234,17 @@ 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; - if (step > 0.0 ? delta > step : value != previous) dirty = true; + // 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; + } engine.sink_values[b][ch] = value; } if (dirty) engine.sink_dirty[b] = true; diff --git a/src/signal_engine/ext_registry.cpp b/src/signal_engine/ext_registry.cpp index dedaee5..a56dd40 100644 --- a/src/signal_engine/ext_registry.cpp +++ b/src/signal_engine/ext_registry.cpp @@ -60,6 +60,8 @@ 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; diff --git a/src/signal_engine/ext_registry.h b/src/signal_engine/ext_registry.h index 3fb40f8..e907490 100644 --- a/src/signal_engine/ext_registry.h +++ b/src/signal_engine/ext_registry.h @@ -18,10 +18,9 @@ namespace sig { // (useq-clear) and is deliberately NOT touched by SignalEngine session resets. // reset_registry() exists for tests and explicit profile re-initialisation. -// 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). +// 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. constexpr uint8_t MAX_EXTERNAL_INPUTS = 32; constexpr uint8_t MAX_EXTERNAL_SINKS = 40; constexpr uint8_t MAX_EXTERNAL_COMMANDS = 16; @@ -34,9 +33,9 @@ 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" + const char* name; // e.g. "nn/out"; multi-channel names are callable uint16_t hw_index; // first hw_inputs[] channel behind the name - uint8_t channels; // consecutive channels the register spans + uint8_t channels; // consecutive channels; >1 enables (name selector) float neutral; // profile-neutral value before first update const char* units_or_range; // static metadata for UI/diagnostics }; @@ -44,7 +43,7 @@ struct ExternalInputDesc { // ── External sinks (spec §3.3/§3.5/§7.4) ──────────────────────────────────── struct ExternalSinkDesc { - const char* name; // static-lifetime spelling, e.g. "midi/cc74" + const char* name; // static-lifetime spelling, e.g. "midi/cc" uint8_t arity; // exact argument count of the sink form float min; // accepted range (deadband scaling + metadata) float max; @@ -62,6 +61,20 @@ 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 { @@ -94,7 +107,7 @@ 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 +// One entry per assigned external sink instance, 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. @@ -118,6 +131,10 @@ 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; }; diff --git a/src/signal_engine/graph_builder.cpp b/src/signal_engine/graph_builder.cpp index 640ac88..78e0d15 100644 --- a/src/signal_engine/graph_builder.cpp +++ b/src/signal_engine/graph_builder.cpp @@ -939,7 +939,15 @@ uint16_t GraphBuilder::compile_symbol(SymbolID sym_id, Scope& scope, return pool.make_binop(NodeOp::Mul, beat_dur, bpb_load); } - // 5. Hardware inputs + // 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)"); + } uint16_t input_idx = resolve_hardware_input(sym_id); if (input_idx != NODE_NONE) { return pool.make_input_load(input_idx); @@ -1300,6 +1308,30 @@ 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); diff --git a/src/signal_engine/node_pool.cpp b/src/signal_engine/node_pool.cpp index a1edfcf..c3157a5 100644 --- a/src/signal_engine/node_pool.cpp +++ b/src/signal_engine/node_pool.cpp @@ -145,6 +145,19 @@ 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((static_cast(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; diff --git a/src/signal_engine/node_pool.h b/src/signal_engine/node_pool.h index d9f7b81..288b3fa 100644 --- a/src/signal_engine/node_pool.h +++ b/src/signal_engine/node_pool.h @@ -16,6 +16,7 @@ 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 @@ -239,6 +240,8 @@ 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); diff --git a/test/signal_engine/test_ext_registry.cpp b/test/signal_engine/test_ext_registry.cpp index 59343f5..8d78133 100644 --- a/test/signal_engine/test_ext_registry.cpp +++ b/test/signal_engine/test_ext_registry.cpp @@ -171,14 +171,48 @@ 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({"midi/cc74", 1, 0.0f, 1.0f, 50, 7})); + REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7})); h.hw_inputs[4] = 0.125; - h.eval_ok("(midi/cc74 nn/out1)"); + h.eval_ok("(transport/mono 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("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, ""})); @@ -198,11 +232,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({"midi/cc74", 1, 0.0f, 1.0f, 50, 7})); + REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7})); SECTION("assignment evaluates and raises the dirty flag") { - h.eval_ok("(midi/cc74 ctl/x)"); + h.eval_ok("(transport/mono ctl/x)"); REQUIRE(h.engine.sink_binding_count == 1); h.hw_inputs[2] = 0.5; @@ -222,7 +256,7 @@ TEST_CASE("external sinks publish evaluated values", "[ext_registry]") SECTION("dirty uses one quantisation step as deadband") { - h.eval_ok("(midi/cc74 ctl/x)"); + h.eval_ok("(transport/mono ctl/x)"); h.hw_inputs[2] = 0.5; h.tick_sinks(0.0); h.engine.sink_dirty[0] = false; @@ -290,21 +324,21 @@ TEST_CASE("external sinks publish evaluated values", "[ext_registry]") 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); + h.expect_error("(transport/mono 0.1 0.2)", DiagnosticCategory::Arity); + h.expect_error("(transport/mono)", DiagnosticCategory::Arity); REQUIRE(h.engine.sink_binding_count == 0); } SECTION("unassign releases the binding") { - h.eval_ok("(midi/cc74 ctl/x)"); + h.eval_ok("(transport/mono ctl/x)"); REQUIRE(h.engine.sink_binding_count == 1); - h.eval_ok("(unassign midi/cc74)"); + h.eval_ok("(unassign transport/mono)"); REQUIRE(h.engine.sink_binding_count == 0); - REQUIRE(h.binding_for("midi/cc74") == nullptr); + REQUIRE(h.binding_for("transport/mono") == nullptr); // Idempotent, and outputs keep working afterwards. - h.eval_ok("(unassign midi/cc74)"); + h.eval_ok("(unassign transport/mono)"); h.eval_ok("(a1 0.25)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.25)); } @@ -358,6 +392,78 @@ 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]") @@ -433,11 +539,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({"midi/cc74", 1, 0.0f, 1.0f, 50, 7})); + REQUIRE(register_external_sink({"transport/mono", 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)"); + EvalResult r = h.eval("(transport/mono 0.5)"); REQUIRE(r.kind == EvalResult::Error); REQUIRE(h.engine.sink_binding_count == 0); REQUIRE(cold_command_handler() == nullptr); diff --git a/test/signal_engine/test_sink_vector_sugar.cpp b/test/signal_engine/test_sink_vector_sugar.cpp index 083b7cf..ee410bc 100644 --- a/test/signal_engine/test_sink_vector_sugar.cpp +++ b/test/signal_engine/test_sink_vector_sugar.cpp @@ -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({"midi/cc74", 1, 0.0f, 1.0f, 50, 7})); + REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7})); h.eval_ok(kFlat); - h.eval_ok("(midi/cc74 ctl/x)"); + h.eval_ok("(transport/mono 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("midi/cc74"); + const uint8_t cc_row = h.binding_row("transport/mono"); REQUIRE(vec_row != MAX_SINK_BINDINGS); REQUIRE(cc_row != MAX_SINK_BINDINGS);