feat(signal-engine): support dynamic routed sinks
This commit is contained in:
parent
120eb0bb45
commit
67d04bf229
10 changed files with 319 additions and 56 deletions
|
|
@ -49,6 +49,7 @@ static const char* node_op_name(sig::NodeOp op) {
|
||||||
case sig::NodeOp::RawTimeLoad: return "RawTimeLoad";
|
case sig::NodeOp::RawTimeLoad: return "RawTimeLoad";
|
||||||
case sig::NodeOp::CellLoad: return "CellLoad";
|
case sig::NodeOp::CellLoad: return "CellLoad";
|
||||||
case sig::NodeOp::InputLoad: return "InputLoad";
|
case sig::NodeOp::InputLoad: return "InputLoad";
|
||||||
|
case sig::NodeOp::InputSelect: return "InputSelect";
|
||||||
case sig::NodeOp::PrevOutputLoad: return "PrevOutputLoad";
|
case sig::NodeOp::PrevOutputLoad: return "PrevOutputLoad";
|
||||||
case sig::NodeOp::Add: return "Add";
|
case sig::NodeOp::Add: return "Add";
|
||||||
case sig::NodeOp::Sub: return "Sub";
|
case sig::NodeOp::Sub: return "Sub";
|
||||||
|
|
|
||||||
|
|
@ -2372,21 +2372,21 @@ static void release_output_slot(SignalEngine& engine, uint16_t slot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
static void unassign_sink(SignalEngine& engine, SymbolID sink_sym) {
|
static void unassign_sink(SignalEngine& engine, SymbolID sink_sym) {
|
||||||
uint8_t index = engine.sink_binding_count;
|
// A normal sink has one row. Multi-binding routers may have several;
|
||||||
for (uint8_t i = 0; i < engine.sink_binding_count; i++) {
|
// ordinary (unassign name) deliberately clears the whole route family.
|
||||||
if (engine.sink_bindings[i].sink == sink_sym) {
|
// Do not advance after a removal because swap-remove puts a fresh row at
|
||||||
index = i;
|
// the same index.
|
||||||
break;
|
uint8_t index = 0;
|
||||||
|
while (index < engine.sink_binding_count) {
|
||||||
|
if (engine.sink_bindings[index].sink != sink_sym) {
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (index == engine.sink_binding_count) return; // not bound — idempotent
|
|
||||||
|
|
||||||
const SinkBinding binding = engine.sink_bindings[index];
|
const SinkBinding binding = engine.sink_bindings[index];
|
||||||
for (uint8_t ch = 0; ch < binding.arity; ch++)
|
for (uint8_t ch = 0; ch < binding.arity; ch++)
|
||||||
release_output_slot(engine, binding.value_index[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;
|
const uint8_t last = engine.sink_binding_count - 1;
|
||||||
engine.sink_bindings[index] = engine.sink_bindings[last];
|
engine.sink_bindings[index] = engine.sink_bindings[last];
|
||||||
memcpy(engine.sink_values[index], engine.sink_values[last],
|
memcpy(engine.sink_values[index], engine.sink_values[last],
|
||||||
|
|
@ -2396,6 +2396,7 @@ static void unassign_sink(SignalEngine& engine, SymbolID sink_sym) {
|
||||||
memset(engine.sink_values[last], 0, sizeof(engine.sink_values[last]));
|
memset(engine.sink_values[last], 0, sizeof(engine.sink_values[last]));
|
||||||
engine.sink_dirty[last] = false;
|
engine.sink_dirty[last] = false;
|
||||||
engine.sink_binding_count--;
|
engine.sink_binding_count--;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile channel `ch` of a sink binding from the next expression in the
|
// Compile channel `ch` of a sink binding from the next expression in the
|
||||||
|
|
@ -2436,24 +2437,85 @@ static void compile_sink_zero_channel(SignalEngine& engine,
|
||||||
candidate.dep_count = 0;
|
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,
|
static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts,
|
||||||
SignalEngine& engine, const char* source,
|
SignalEngine& engine, const char* source,
|
||||||
SharedLiveEditIDs* shared_ids = nullptr) {
|
SharedLiveEditIDs* shared_ids = nullptr) {
|
||||||
const ExternalSinkDesc* desc = find_external_sink(sink_sym);
|
const ExternalSinkDesc* desc = find_external_sink(sink_sym);
|
||||||
|
|
||||||
// Existing binding for this sink is the per-sink LKG baseline: nothing
|
uint64_t route_key = 0;
|
||||||
// below touches it unless every channel compiles.
|
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
|
uint8_t binding_index = engine.sink_binding_count; // append position
|
||||||
bool rebinding = false;
|
bool rebinding = false;
|
||||||
SinkBinding previous = {};
|
SinkBinding previous = {};
|
||||||
|
if (!desc->allow_multiple_bindings || has_route_key) {
|
||||||
for (uint8_t i = 0; i < engine.sink_binding_count; i++) {
|
for (uint8_t i = 0; i < engine.sink_binding_count; i++) {
|
||||||
if (engine.sink_bindings[i].sink == sink_sym) {
|
const SinkBinding& candidate = engine.sink_bindings[i];
|
||||||
|
if (candidate.sink == sink_sym &&
|
||||||
|
(!desc->allow_multiple_bindings ||
|
||||||
|
candidate.route_key == route_key)) {
|
||||||
binding_index = i;
|
binding_index = i;
|
||||||
rebinding = true;
|
rebinding = true;
|
||||||
previous = engine.sink_bindings[i];
|
previous = candidate;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!rebinding && engine.sink_binding_count >= MAX_SINK_BINDINGS) {
|
if (!rebinding && engine.sink_binding_count >= MAX_SINK_BINDINGS) {
|
||||||
return make_error("Too many sink assignments — binding slots are full",
|
return make_error("Too many sink assignments — binding slots are full",
|
||||||
"Unassign a sink first with (unassign name)");
|
"Unassign a sink first with (unassign name)");
|
||||||
|
|
@ -2465,6 +2527,13 @@ static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts,
|
||||||
// above the named a/d/s range.
|
// above the named a/d/s range.
|
||||||
uint16_t slots[MAX_SINK_ARITY] = {};
|
uint16_t slots[MAX_SINK_ARITY] = {};
|
||||||
uint8_t planned = 0;
|
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;
|
for (uint16_t s = SINK_SLOT_BASE;
|
||||||
s < MAX_OUTPUTS && planned < desc->arity; s++) {
|
s < MAX_OUTPUTS && planned < desc->arity; s++) {
|
||||||
if (engine.pool.outputs[s].root_node != NODE_NONE) continue;
|
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;
|
SinkBinding binding;
|
||||||
binding.sink = sink_sym;
|
binding.sink = sink_sym;
|
||||||
|
binding.route_key = route_key;
|
||||||
binding.arity = desc->arity;
|
binding.arity = desc->arity;
|
||||||
for (uint8_t ch = 0; ch < desc->arity; ch++) {
|
for (uint8_t ch = 0; ch < desc->arity; ch++) {
|
||||||
GraphBuildResult plan;
|
GraphBuildResult plan;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,15 @@ static inline Sample eval_node(
|
||||||
case NodeOp::LoadDt: return dt;
|
case NodeOp::LoadDt: return dt;
|
||||||
case NodeOp::CellLoad: return cell_values[(uint16_t)n.imm];
|
case NodeOp::CellLoad: return cell_values[(uint16_t)n.imm];
|
||||||
case NodeOp::InputLoad: return hw_inputs[(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:
|
case NodeOp::PrevOutputLoad:
|
||||||
return prev_output_values[(uint16_t)n.imm];
|
return prev_output_values[(uint16_t)n.imm];
|
||||||
case NodeOp::SlotLoad: return 0.0; // handled in execution loop
|
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];
|
const Sample previous = engine.sink_values[b][ch];
|
||||||
Sample delta = value - previous;
|
Sample delta = value - previous;
|
||||||
if (delta < 0.0) delta = -delta;
|
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;
|
engine.sink_values[b][ch] = value;
|
||||||
}
|
}
|
||||||
if (dirty) engine.sink_dirty[b] = true;
|
if (dirty) engine.sink_dirty[b] = true;
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ bool register_external_sink(const ExternalSinkDesc& desc) {
|
||||||
if (!valid_name(desc.name)) return false;
|
if (!valid_name(desc.name)) return false;
|
||||||
if (desc.arity == 0 || desc.arity > MAX_SINK_ARITY) return false;
|
if (desc.arity == 0 || desc.arity > MAX_SINK_ARITY) return false;
|
||||||
if (desc.min > desc.max) 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
|
// Vector-packing: all-or-nothing, and the split must cover the arity
|
||||||
// (leading base channels + trailing modulation channels).
|
// (leading base channels + trailing modulation channels).
|
||||||
const bool packed = desc.vec_base_ch != 0 || desc.vec_mod_ch != 0;
|
const bool packed = desc.vec_base_ch != 0 || desc.vec_mod_ch != 0;
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,9 @@ namespace sig {
|
||||||
// (useq-clear) and is deliberately NOT touched by SignalEngine session resets.
|
// (useq-clear) and is deliberately NOT touched by SignalEngine session resets.
|
||||||
// reset_registry() exists for tests and explicit profile re-initialisation.
|
// reset_registry() exists for tests and explicit profile re-initialisation.
|
||||||
|
|
||||||
// Registry capacities sized for the uSEQ+NISPS firmware profile plus headroom:
|
// Portable defaults with headroom for embedded profiles. A constrained or
|
||||||
// 19 meml/* control registers + 8 nn/out* neural outputs = 27 inputs (cap 32,
|
// wider integration may override these at compile time, while descriptor
|
||||||
// aligned with MAX_HW_INPUT_CHANNELS); 32 midi/cc* transports + nn/in = 33
|
// registration remains the single runtime API.
|
||||||
// sinks (cap 40); 14 nn/* commands (cap 16).
|
|
||||||
constexpr uint8_t MAX_EXTERNAL_INPUTS = 32;
|
constexpr uint8_t MAX_EXTERNAL_INPUTS = 32;
|
||||||
constexpr uint8_t MAX_EXTERNAL_SINKS = 40;
|
constexpr uint8_t MAX_EXTERNAL_SINKS = 40;
|
||||||
constexpr uint8_t MAX_EXTERNAL_COMMANDS = 16;
|
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) ───────────────────────────────
|
// ── External input registers (spec §3.1/§3.2) ───────────────────────────────
|
||||||
|
|
||||||
struct ExternalInputDesc {
|
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
|
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
|
float neutral; // profile-neutral value before first update
|
||||||
const char* units_or_range; // static metadata for UI/diagnostics
|
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) ────────────────────────────────────
|
// ── External sinks (spec §3.3/§3.5/§7.4) ────────────────────────────────────
|
||||||
|
|
||||||
struct ExternalSinkDesc {
|
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
|
uint8_t arity; // exact argument count of the sink form
|
||||||
float min; // accepted range (deadband scaling + metadata)
|
float min; // accepted range (deadband scaling + metadata)
|
||||||
float max;
|
float max;
|
||||||
|
|
@ -62,6 +61,20 @@ struct ExternalSinkDesc {
|
||||||
// arity (vec_base_ch leading base channels, vec_mod_ch trailing mods).
|
// arity (vec_base_ch leading base channels, vec_mod_ch trailing mods).
|
||||||
uint8_t vec_base_ch = 0;
|
uint8_t vec_base_ch = 0;
|
||||||
uint8_t vec_mod_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 {
|
struct ExternalCommandDesc {
|
||||||
|
|
@ -94,7 +107,7 @@ bool external_sink_registered(SymbolID name);
|
||||||
const ExternalCommandDesc* find_external_command(SymbolID cmd);
|
const ExternalCommandDesc* find_external_command(SymbolID cmd);
|
||||||
|
|
||||||
// ── Sink bindings ───────────────────────────────────────────────────────────
|
// ── 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
|
// compiled graph is published as a real pool output slot (indices
|
||||||
// SINK_SLOT_BASE..MAX_OUTPUTS-1) so execution order, LKG fallback, and GC
|
// SINK_SLOT_BASE..MAX_OUTPUTS-1) so execution order, LKG fallback, and GC
|
||||||
// reachability are the ordinary output machinery — no second mechanism.
|
// 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 {
|
struct SinkBinding {
|
||||||
SymbolID sink = SymbolIntern::INVALID_ID;
|
SymbolID sink = SymbolIntern::INVALID_ID;
|
||||||
uint16_t value_index[MAX_SINK_ARITY] = {}; // pool output slot per channel
|
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;
|
uint8_t arity = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -939,7 +939,15 @@ uint16_t GraphBuilder::compile_symbol(SymbolID sym_id, Scope& scope,
|
||||||
return pool.make_binop(NodeOp::Mul, beat_dur, bpb_load);
|
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);
|
uint16_t input_idx = resolve_hardware_input(sym_id);
|
||||||
if (input_idx != NODE_NONE) {
|
if (input_idx != NODE_NONE) {
|
||||||
return pool.make_input_load(input_idx);
|
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,
|
uint16_t GraphBuilder::compile_form(SymbolID op, TokenStream& ts,
|
||||||
Scope& scope, TimeContext& ctx, Token op_tok) {
|
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);
|
const NamespacedOperator resolved = resolve_namespaced_operator(op);
|
||||||
if (resolved.name_space != OperatorNamespace::None) {
|
if (resolved.name_space != OperatorNamespace::None) {
|
||||||
return compile_namespaced_form(resolved, ts, scope, ctx, op_tok);
|
return compile_namespaced_form(resolved, ts, scope, ctx, op_tok);
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,19 @@ uint16_t NodePool::make_input_load(uint16_t input_index) {
|
||||||
return intern_node(n);
|
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) {
|
uint16_t NodePool::make_prev_output_load(uint16_t output_index) {
|
||||||
Node n;
|
Node n;
|
||||||
n.op = NodeOp::PrevOutputLoad;
|
n.op = NodeOp::PrevOutputLoad;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ enum class NodeOp : uint8_t {
|
||||||
RawTimeLoad, // loads the single 't' input
|
RawTimeLoad, // loads the single 't' input
|
||||||
CellLoad, // imm = cell_id
|
CellLoad, // imm = cell_id
|
||||||
InputLoad, // imm = input_index (hardware input channel)
|
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
|
PrevOutputLoad, // imm = output_index; previous tick's value
|
||||||
|
|
||||||
// Binary arithmetic
|
// Binary arithmetic
|
||||||
|
|
@ -239,6 +240,8 @@ struct NodePool {
|
||||||
uint16_t make_raw_time_load();
|
uint16_t make_raw_time_load();
|
||||||
uint16_t make_cell_load(SymbolID cell_id);
|
uint16_t make_cell_load(SymbolID cell_id);
|
||||||
uint16_t make_input_load(uint16_t input_index);
|
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_prev_output_load(uint16_t output_index);
|
||||||
|
|
||||||
uint16_t make_state_load(uint16_t state_slot);
|
uint16_t make_state_load(uint16_t state_slot);
|
||||||
|
|
|
||||||
|
|
@ -171,14 +171,48 @@ TEST_CASE("external inputs resolve as graph leaves", "[ext_registry]")
|
||||||
SECTION("registered input usable inside a sink expression")
|
SECTION("registered input usable inside a sink expression")
|
||||||
{
|
{
|
||||||
REQUIRE(register_external_input({"nn/out1", 4, 1, 0.5f, "[0,1]"}));
|
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.hw_inputs[4] = 0.125;
|
||||||
h.eval_ok("(midi/cc74 nn/out1)");
|
h.eval_ok("(transport/mono nn/out1)");
|
||||||
h.tick_sinks(0.0);
|
h.tick_sinks(0.0);
|
||||||
REQUIRE(h.engine.sink_binding_count == 1);
|
REQUIRE(h.engine.sink_binding_count == 1);
|
||||||
REQUIRE(h.engine.sink_values[0][0] == Approx(0.125));
|
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")
|
SECTION("invalid descriptors are rejected")
|
||||||
{
|
{
|
||||||
REQUIRE(!register_external_input({nullptr, 0, 1, 0.0f, ""}));
|
REQUIRE(!register_external_input({nullptr, 0, 1, 0.0f, ""}));
|
||||||
|
|
@ -198,11 +232,11 @@ TEST_CASE("external sinks publish evaluated values", "[ext_registry]")
|
||||||
{
|
{
|
||||||
ExtHarness h;
|
ExtHarness h;
|
||||||
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
|
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")
|
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);
|
REQUIRE(h.engine.sink_binding_count == 1);
|
||||||
|
|
||||||
h.hw_inputs[2] = 0.5;
|
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")
|
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.hw_inputs[2] = 0.5;
|
||||||
h.tick_sinks(0.0);
|
h.tick_sinks(0.0);
|
||||||
h.engine.sink_dirty[0] = false;
|
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")
|
SECTION("wrong arity is a compile error and binds nothing")
|
||||||
{
|
{
|
||||||
h.expect_error("(midi/cc74 0.1 0.2)", DiagnosticCategory::Arity);
|
h.expect_error("(transport/mono 0.1 0.2)", DiagnosticCategory::Arity);
|
||||||
h.expect_error("(midi/cc74)", DiagnosticCategory::Arity);
|
h.expect_error("(transport/mono)", DiagnosticCategory::Arity);
|
||||||
REQUIRE(h.engine.sink_binding_count == 0);
|
REQUIRE(h.engine.sink_binding_count == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("unassign releases the binding")
|
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);
|
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.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.
|
// Idempotent, and outputs keep working afterwards.
|
||||||
h.eval_ok("(unassign midi/cc74)");
|
h.eval_ok("(unassign transport/mono)");
|
||||||
h.eval_ok("(a1 0.25)");
|
h.eval_ok("(a1 0.25)");
|
||||||
REQUIRE(h.tick("a1", 0.0) == Approx(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);
|
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 ───────────────────────────────────────────────────────────
|
// ── Cold commands ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
TEST_CASE("registered cold commands dispatch to the handler", "[ext_registry]")
|
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;
|
ExtHarness h;
|
||||||
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
|
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();
|
reset_registry();
|
||||||
// Names no longer resolve as inputs or sinks.
|
// Names no longer resolve as inputs or sinks.
|
||||||
h.expect_error("(a1 ctl/x)", DiagnosticCategory::UndefinedName);
|
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(r.kind == EvalResult::Error);
|
||||||
REQUIRE(h.engine.sink_binding_count == 0);
|
REQUIRE(h.engine.sink_binding_count == 0);
|
||||||
REQUIRE(cold_command_handler() == nullptr);
|
REQUIRE(cold_command_handler() == nullptr);
|
||||||
|
|
|
||||||
|
|
@ -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_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
|
||||||
REQUIRE(register_external_sink(
|
REQUIRE(register_external_sink(
|
||||||
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
|
{"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(kFlat);
|
||||||
h.eval_ok("(midi/cc74 ctl/x)");
|
h.eval_ok("(transport/mono ctl/x)");
|
||||||
h.hw_inputs[2] = 0.5;
|
h.hw_inputs[2] = 0.5;
|
||||||
h.tick_sinks(0.0);
|
h.tick_sinks(0.0);
|
||||||
|
|
||||||
const uint8_t vec_row = h.binding_row("vec/in");
|
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(vec_row != MAX_SINK_BINDINGS);
|
||||||
REQUIRE(cc_row != MAX_SINK_BINDINGS);
|
REQUIRE(cc_row != MAX_SINK_BINDINGS);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue