Compare commits
No commits in common. "feat/dynamic-routed-sinks" and "main" have entirely different histories.
feat/dynam
...
main
11 changed files with 60 additions and 365 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue