332 lines
16 KiB
C
332 lines
16 KiB
C
|
|
#ifndef SIGNAL_ENGINE_SYNTH_GRAPH_H
|
||
|
|
#define SIGNAL_ENGINE_SYNTH_GRAPH_H
|
||
|
|
|
||
|
|
#include "types.h"
|
||
|
|
#include "synth_registry.h"
|
||
|
|
#include "diagnostics.h"
|
||
|
|
#include <cstdint>
|
||
|
|
#include <cstring>
|
||
|
|
|
||
|
|
namespace sig {
|
||
|
|
|
||
|
|
// ── Synth Patch Graph and Control Table (synth-nodes.md §7.2) ──────────────
|
||
|
|
//
|
||
|
|
// Public artefacts exposed to the host after a successful synth eval. Both
|
||
|
|
// the patch graph (node instances, def names/versions, audio-port routing,
|
||
|
|
// fades) and the control channel table (one entry per bound param) are
|
||
|
|
// versioned together under one compiler revision so consumers can detect
|
||
|
|
// coherent updates (VAL-COMP-009). Failed evals retain the previous
|
||
|
|
// successful artefacts and do not advance the revision (VAL-COMP-010).
|
||
|
|
//
|
||
|
|
// Public identifiers are stable identity strings supplied by the editor
|
||
|
|
// (hidden :id or explicit :name). Internal GC-remapped node indices are
|
||
|
|
// never exposed in the serialised artefact (VAL-COMP-012).
|
||
|
|
|
||
|
|
// ── Identity limits ────────────────────────────────────────────────────────
|
||
|
|
// Identity strings are short editor-sidecar IDs (e.g. "::a3f1" or "lead").
|
||
|
|
constexpr uint16_t MAX_SYNTH_IDENTITY = 32;
|
||
|
|
|
||
|
|
// ── Patch-graph declaration entry ──────────────────────────────────────────
|
||
|
|
struct SynthDeclaration {
|
||
|
|
char identity[MAX_SYNTH_IDENTITY] = {};
|
||
|
|
char def_name[MAX_NODEDEF_NAME] = {};
|
||
|
|
uint16_t def_version = 0;
|
||
|
|
uint16_t audio_inputs = 0;
|
||
|
|
uint16_t audio_outputs = 0;
|
||
|
|
bool voice_fanout = false;
|
||
|
|
// The index of this declaration's entry within the control table's
|
||
|
|
// per-declaration slice. Public artefacts reference declarations by
|
||
|
|
// identity only; this index is an internal helper, not serialised.
|
||
|
|
uint16_t first_control_index = 0;
|
||
|
|
uint16_t control_count = 0;
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── Control channel table entry ────────────────────────────────────────────
|
||
|
|
// One row per bound (declaration, param [, voice]) triple. The host reads
|
||
|
|
// the param expression's compiled root_node out of the live NodePool each
|
||
|
|
// control block. The root_node value is internal and must NOT appear in
|
||
|
|
// the serialised public artefact (VAL-COMP-012). Ownership is represented by
|
||
|
|
// the declaration's dense first_control_index/control_count range. Parameter
|
||
|
|
// names are resolved from the immutable NodeDef descriptor by param_index.
|
||
|
|
// These two indices avoid repeated identity and parameter strings per row.
|
||
|
|
struct SynthControlChannel {
|
||
|
|
uint8_t param_index = 0;
|
||
|
|
SynthRateClass rate_class = SynthRateClass::Block;
|
||
|
|
SynthSmoothingClass smoothing_class = SynthSmoothingClass::Step;
|
||
|
|
|
||
|
|
// Compiled control root node index. Internal: not serialised. The host
|
||
|
|
// samples this root via the NodePool at each control block.
|
||
|
|
uint16_t root_node = NODE_NONE;
|
||
|
|
|
||
|
|
// Stable compiler ownership and reactive-recompile metadata. Internal;
|
||
|
|
// never exposed through the host artefact.
|
||
|
|
uint16_t owner_context = 0;
|
||
|
|
uint32_t source_offset = 0;
|
||
|
|
uint32_t source_length = 0;
|
||
|
|
CellIndex dep_cells[MAX_OUTPUT_DEPS] = {};
|
||
|
|
uint8_t dep_count = 0;
|
||
|
|
|
||
|
|
// Root-level non-finite containment for the audio control producer.
|
||
|
|
Sample lkg_value = 0.0;
|
||
|
|
bool has_lkg = false;
|
||
|
|
|
||
|
|
// One fixed reactive-compile diagnostic slot per published control on the
|
||
|
|
// WASM/desktop synth host. Keeping it on the dense row makes replacement,
|
||
|
|
// removal, rollback, and artifact-order remapping atomic. Firmware does
|
||
|
|
// not implement the synth host (synth-nodes.md §6.1), so it must not spend
|
||
|
|
// scarce RP2040 SRAM on an ABI that cannot be observed there.
|
||
|
|
#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE)
|
||
|
|
struct CompileDiagnostic {
|
||
|
|
const char* message = nullptr;
|
||
|
|
const char* suggestion = nullptr;
|
||
|
|
// Cell-backed symbols are bounded by MAX_CELLS before mutation, so a
|
||
|
|
// 16-bit cause preserves every possible trigger without paying for
|
||
|
|
// the interner's unbounded 32-bit ID in all 512 control rows.
|
||
|
|
uint16_t triggered_by = 0;
|
||
|
|
uint16_t span_start = 0;
|
||
|
|
uint16_t span_len = 0;
|
||
|
|
DiagnosticSeverity severity = DiagnosticSeverity::Error;
|
||
|
|
DiagnosticCategory category = DiagnosticCategory::Runtime;
|
||
|
|
|
||
|
|
void clear() { *this = CompileDiagnostic{}; }
|
||
|
|
bool active() const { return message != nullptr; }
|
||
|
|
void publish(SymbolID cause, const Diagnostic& diagnostic) {
|
||
|
|
// A non-null message is the active marker. Every compiler
|
||
|
|
// diagnostic is expected to have text; preserve the slot even if
|
||
|
|
// a future producer violates that expectation.
|
||
|
|
message = diagnostic.message ? diagnostic.message : "";
|
||
|
|
suggestion = diagnostic.suggestion;
|
||
|
|
triggered_by = static_cast<uint16_t>(cause);
|
||
|
|
span_start = diagnostic.span_start;
|
||
|
|
span_len = diagnostic.span_len;
|
||
|
|
severity = diagnostic.severity;
|
||
|
|
category = diagnostic.category;
|
||
|
|
}
|
||
|
|
} compile_diagnostic;
|
||
|
|
#endif
|
||
|
|
};
|
||
|
|
|
||
|
|
static_assert(MAX_NODEDEF_PARAMS <= UINT8_MAX,
|
||
|
|
"synth parameter indices must cover every NodeDef parameter");
|
||
|
|
|
||
|
|
#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE)
|
||
|
|
static_assert(MAX_CELLS <= UINT16_MAX,
|
||
|
|
"synth diagnostic trigger IDs must cover every mutable cell");
|
||
|
|
#endif
|
||
|
|
|
||
|
|
struct SynthConnection {
|
||
|
|
char from[MAX_SYNTH_IDENTITY] = {};
|
||
|
|
char to[MAX_SYNTH_IDENTITY] = {};
|
||
|
|
char port[MAX_NODEDEF_NAME] = {};
|
||
|
|
uint16_t port_index = 0;
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── Capacity ───────────────────────────────────────────────────────────────
|
||
|
|
// The compiler and app share a 64-node ceiling. Current osc/sine has one
|
||
|
|
// audio input, so one edge per declaration covers the shipped registry.
|
||
|
|
constexpr uint16_t MAX_SYNTH_DECLARATIONS = SYNTH_MAX_NODES;
|
||
|
|
constexpr uint16_t MAX_SYNTH_CONTROLS = MAX_SYNTH_CONTROL_ROOTS;
|
||
|
|
constexpr uint16_t MAX_SYNTH_CONNECTIONS = MAX_SYNTH_DECLARATIONS;
|
||
|
|
#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE)
|
||
|
|
static_assert(MAX_SYNTH_CONTROLS ==
|
||
|
|
MAX_SYNTH_DECLARATIONS * MAX_NODEDEF_PARAMS,
|
||
|
|
"host synth storage must cover the descriptor ceiling");
|
||
|
|
#endif
|
||
|
|
// Bounds recursive audio-routing compilation and its cold-path stack use.
|
||
|
|
constexpr uint16_t MAX_SYNTH_NESTING = 16;
|
||
|
|
|
||
|
|
// ── Compiler revision ──────────────────────────────────────────────────────
|
||
|
|
// One shared counter covers graph and control table. It advances ONLY when
|
||
|
|
// a full eval unit commits successfully; failed evals retain the previous
|
||
|
|
// revision (VAL-COMP-008, VAL-COMP-009, VAL-COMP-010). Revision 0 is the
|
||
|
|
// "empty graph" sentinel.
|
||
|
|
using SynthRevision = uint32_t;
|
||
|
|
|
||
|
|
// ── Patch graph container ──────────────────────────────────────────────────
|
||
|
|
struct SynthGraph {
|
||
|
|
SynthDeclaration declarations[MAX_SYNTH_DECLARATIONS];
|
||
|
|
SynthControlChannel controls[MAX_SYNTH_CONTROLS];
|
||
|
|
SynthConnection connections[MAX_SYNTH_CONNECTIONS];
|
||
|
|
|
||
|
|
uint16_t declaration_count_value = 0;
|
||
|
|
uint16_t control_count_value = 0;
|
||
|
|
uint16_t connection_count_value = 0;
|
||
|
|
SynthRevision revision = 0;
|
||
|
|
|
||
|
|
// ── Accessors ─────────────────────────────────────────────────────────
|
||
|
|
uint16_t declaration_count() const { return declaration_count_value; }
|
||
|
|
uint16_t control_count() const { return control_count_value; }
|
||
|
|
uint16_t connection_count() const { return connection_count_value; }
|
||
|
|
|
||
|
|
const SynthDeclaration* declaration_for_control(
|
||
|
|
uint16_t control_index) const {
|
||
|
|
if (control_index >= control_count_value) return nullptr;
|
||
|
|
// Declaration control slices are appended in declaration order and
|
||
|
|
// remain dense after replacement/removal compaction. Search those
|
||
|
|
// ordered ranges logarithmically; this is used by every synth-control
|
||
|
|
// lookup during compilation and artifact inspection.
|
||
|
|
uint16_t first_decl = 0;
|
||
|
|
uint16_t past_last_decl = declaration_count_value;
|
||
|
|
while (first_decl < past_last_decl) {
|
||
|
|
const uint16_t middle = static_cast<uint16_t>(
|
||
|
|
first_decl + (past_last_decl - first_decl) / 2);
|
||
|
|
const SynthDeclaration& declaration = declarations[middle];
|
||
|
|
const uint32_t first = declaration.first_control_index;
|
||
|
|
const uint32_t past_last = first + declaration.control_count;
|
||
|
|
if (control_index < first) {
|
||
|
|
past_last_decl = middle;
|
||
|
|
} else if (control_index >= past_last) {
|
||
|
|
first_decl = static_cast<uint16_t>(middle + 1);
|
||
|
|
} else {
|
||
|
|
return &declaration;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nullptr;
|
||
|
|
}
|
||
|
|
|
||
|
|
const NodeDefParam* parameter_for_control(uint16_t control_index) const {
|
||
|
|
const SynthDeclaration* declaration =
|
||
|
|
declaration_for_control(control_index);
|
||
|
|
if (!declaration) return nullptr;
|
||
|
|
const NodeDefDescriptor* descriptor = synth_registry_find(
|
||
|
|
declaration->def_name, declaration->def_version);
|
||
|
|
if (!descriptor) return nullptr;
|
||
|
|
const uint8_t parameter_index = controls[control_index].param_index;
|
||
|
|
if (parameter_index >= descriptor->param_count ||
|
||
|
|
parameter_index >= MAX_NODEDEF_PARAMS) {
|
||
|
|
return nullptr;
|
||
|
|
}
|
||
|
|
return &descriptor->params[parameter_index];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reset to empty. Used by (useq-clear) and at startup. Does NOT advance
|
||
|
|
// the revision: an empty graph is a valid committed state, so callers
|
||
|
|
// that want to bump the revision should call advance_revision() after.
|
||
|
|
void clear_no_revision() {
|
||
|
|
declaration_count_value = 0;
|
||
|
|
control_count_value = 0;
|
||
|
|
connection_count_value = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
void clear_and_advance() {
|
||
|
|
clear_no_revision();
|
||
|
|
advance_revision();
|
||
|
|
}
|
||
|
|
|
||
|
|
void advance_revision() { revision++; }
|
||
|
|
|
||
|
|
// Find a declaration by identity. Returns nullptr if absent.
|
||
|
|
SynthDeclaration* find(const char* identity) {
|
||
|
|
if (!identity) return nullptr;
|
||
|
|
for (uint16_t i = 0; i < declaration_count_value; i++) {
|
||
|
|
if (std::strcmp(declarations[i].identity, identity) == 0)
|
||
|
|
return &declarations[i];
|
||
|
|
}
|
||
|
|
return nullptr;
|
||
|
|
}
|
||
|
|
const SynthDeclaration* find(const char* identity) const {
|
||
|
|
if (!identity) return nullptr;
|
||
|
|
for (uint16_t i = 0; i < declaration_count_value; i++) {
|
||
|
|
if (std::strcmp(declarations[i].identity, identity) == 0)
|
||
|
|
return &declarations[i];
|
||
|
|
}
|
||
|
|
return nullptr;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Append a blank declaration slot. Returns nullptr if capacity is full.
|
||
|
|
SynthDeclaration* append_declaration() {
|
||
|
|
if (declaration_count_value >= MAX_SYNTH_DECLARATIONS) return nullptr;
|
||
|
|
return &declarations[declaration_count_value++];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Append a control channel. Returns nullptr if capacity is full.
|
||
|
|
SynthControlChannel* append_control() {
|
||
|
|
if (control_count_value >= MAX_SYNTH_CONTROLS) return nullptr;
|
||
|
|
// Dense-table compaction leaves retired bytes above the logical end.
|
||
|
|
// A newly appended control is a new subject, so it must not inherit
|
||
|
|
// the prior row's LKG, dependencies, or reactive diagnostic slot.
|
||
|
|
SynthControlChannel& control = controls[control_count_value++];
|
||
|
|
control = SynthControlChannel{};
|
||
|
|
return &control;
|
||
|
|
}
|
||
|
|
|
||
|
|
SynthConnection* append_connection() {
|
||
|
|
if (connection_count_value >= MAX_SYNTH_CONNECTIONS) return nullptr;
|
||
|
|
return &connections[connection_count_value++];
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── Public artefact serialisation (VAL-COMP-012) ───────────────────────────
|
||
|
|
//
|
||
|
|
// Renders a JSON snapshot of the current synth graph suitable for WASM ABI
|
||
|
|
// consumption. The schema is intentionally narrow: only stable identity
|
||
|
|
// strings, def names/versions, parameter names, and rate/smoothing classes
|
||
|
|
// are emitted. Internal GC-remapped node indices are never serialised.
|
||
|
|
//
|
||
|
|
// The output is written into the supplied buffer and is null-terminated.
|
||
|
|
// Returns false if the buffer is too small (the caller should provide at
|
||
|
|
// least SYNTH_ARTIFACT_JSON_CAP bytes).
|
||
|
|
constexpr uint32_t SYNTH_ARTIFACT_JSON_CAP = 32768;
|
||
|
|
|
||
|
|
bool synth_graph_render_json(const SynthGraph& graph, char* out, uint32_t cap);
|
||
|
|
|
||
|
|
// Convenience wrapper that renders into the engine's own scratch buffer.
|
||
|
|
// The returned pointer is valid until the next call to this function or
|
||
|
|
// until the engine is destroyed.
|
||
|
|
const char* synth_graph_render_json_scratch(const SynthGraph& graph);
|
||
|
|
|
||
|
|
// ── Versioned synth artefact ABI (VAL-COMP-015) ────────────────────────────
|
||
|
|
//
|
||
|
|
// The synth artefact payload carries an `abi` version marker so future
|
||
|
|
// consumers can reject incompatible bundles explicitly. The native engine
|
||
|
|
// advertises a single canonical ABI version; any consumer built against a
|
||
|
|
// different version must refuse to read the payload.
|
||
|
|
//
|
||
|
|
// Version history:
|
||
|
|
// 1 — single-node declarations + controls; no routable audio graph.
|
||
|
|
// 2 — required connections[] and multi-node patch-graph semantics.
|
||
|
|
constexpr uint16_t SYNTH_ARTIFACT_ABI_VERSION = 2;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Return true iff the engine's synth-artefact ABI can serve a consumer
|
||
|
|
* built against the supplied `consumer_abi_version`. The current engine
|
||
|
|
* accepts only its own declared ABI; future versions may accept a range.
|
||
|
|
*
|
||
|
|
* Callers MUST consult this helper before interpreting the body bytes of
|
||
|
|
* `synth_artifacts_json` / `useq_synth_artifacts`. Incompatible consumers
|
||
|
|
* receive a minimal error object instead of the artefact body (see
|
||
|
|
* `synth_artifacts_render_abi_wrapper`).
|
||
|
|
*/
|
||
|
|
bool synth_artifacts_supports_abi(uint16_t consumer_abi_version);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Render the versioned synth artefact payload into `out` for a consumer
|
||
|
|
* built against `consumer_abi_version`.
|
||
|
|
*
|
||
|
|
* On success the buffer contains a JSON object shaped:
|
||
|
|
* {"abi":<version>,"revision":N,"declarations":[...],"controls":[...]}
|
||
|
|
* and the function returns true.
|
||
|
|
*
|
||
|
|
* If the consumer ABI version is unsupported, the buffer is filled with a
|
||
|
|
* minimal JSON error object (still valid JSON) and the function returns
|
||
|
|
* false. The caller MUST NOT interpret the body bytes when this function
|
||
|
|
* returns false — the only safe interpretation is the `abi_error` field.
|
||
|
|
*
|
||
|
|
* This helper is the native counterpart of the WASM `useq_synth_artifacts`
|
||
|
|
* wrapper. Mirrors its byte shape exactly so native and WASM consumers
|
||
|
|
* observe identical payloads.
|
||
|
|
*/
|
||
|
|
bool synth_artifacts_render_abi_wrapper(const struct SignalEngine& engine,
|
||
|
|
uint16_t consumer_abi_version,
|
||
|
|
char* out, uint32_t cap);
|
||
|
|
|
||
|
|
// ── Engine-level accessor ──────────────────────────────────────────────────
|
||
|
|
// Returns the engine's published synth artefact snapshot as JSON. Mirrors
|
||
|
|
// the useq_last_diagnostics() pattern: the pointer is stable until the
|
||
|
|
// next eval. Equivalent to synth_graph_render_json_scratch(engine.synth_graph).
|
||
|
|
const char* synth_artifacts_json(const struct SignalEngine& engine);
|
||
|
|
|
||
|
|
} // namespace sig
|
||
|
|
|
||
|
|
#endif // SIGNAL_ENGINE_SYNTH_GRAPH_H
|