1
0
Fork 0
forked from hanez/xtc

Phase 23: refine routing inspection, signal-flow layout and atomic wire insertion

This commit is contained in:
Johannes Findeisen 2026-10-27 16:00:29 +01:00
commit ba5c2503a7
15 changed files with 533 additions and 22 deletions

View file

@ -391,3 +391,11 @@ test-audio-editing: $(BUILD_DIR)/test_audio_editing
./$(BUILD_DIR)/test_audio_editing ./$(BUILD_DIR)/test_audio_editing
test: test-audio-editing test: test-audio-editing
-include $(BUILD_DIR)/tests/unit/test_audio_editing.d -include $(BUILD_DIR)/tests/unit/test_audio_editing.d
$(BUILD_DIR)/test_routing: $(BUILD_DIR)/tests/unit/test_routing.o $(BUILD_DIR)/libxtc.a
$(CC) $(LDFLAGS) -o $@ $^ -lm -ldl
.PHONY: test-routing
test-routing: $(BUILD_DIR)/test_routing
./$(BUILD_DIR)/test_routing
test: test-routing
-include $(BUILD_DIR)/tests/unit/test_routing.d

View file

@ -134,3 +134,7 @@ waveforms, with undo and v8 persistence (reading v1–v7). See
[AUDIO_FILES.md](docs/AUDIO_FILES.md#phase-22-non-destructive-clip-editing). [AUDIO_FILES.md](docs/AUDIO_FILES.md#phase-22-non-destructive-clip-editing).
`make test-audio-editing` checks source preservation and exact boundary output. `make test-audio-editing` checks source preservation and exact boundary output.
Time stretching and independent pitch shifting remain deferred. Time stretching and independent pitch shifting remain deferred.
Phase 23 adds signal-flow graph layout, routing diagnostics, direction/role
indicators and atomic unity-gain insertion into a wire. See
[ROUTING.md](docs/ROUTING.md); `make test-routing` verifies the C routing contract.

View file

@ -77,8 +77,9 @@ to connect. Click a node or wire to select it (yellow outline/wire), then press
Delete/Backspace or **Delete selection**. Deleting a node deletes its incident Delete/Backspace or **Delete selection**. Deleting a node deletes its incident
wires. Escape cancels a drag preview. Canvas scrollbars pan the view; positions wires. Escape cancels a drag preview. Canvas scrollbars pan the view; positions
are retained across edits/reconnect but not across application restart. There are retained across edits/reconnect but not across application restart. There
is no graph zoom, multiselection, undo or external routing UI yet. is no graph zoom, multiselection or external hardware routing UI. Semantic
The editor supports 64 nodes on a fixed 2000x4000 canvas. graph edits participate in project undo/redo; layout does not. The default
project supports 64 nodes; auto-layout can grow the scrollable canvas.
Every semantic action becomes a C graph command. The window owns a C project containing the graph Every semantic action becomes a C graph command. The window owns a C project containing the graph
handle and publishes the C-created candidate through the backend. Cycle, handle and publishes the C-created candidate through the backend. Cycle,
@ -202,3 +203,12 @@ acknowledgement and deactivates JACK. Recording Stop, Home, fault handling and
project installation retain immediate semantics. See project installation retain immediate semantics. See
[AUDIO_QUALITY.md](AUDIO_QUALITY.md) for timing, overload/denormal behavior, latency [AUDIO_QUALITY.md](AUDIO_QUALITY.md) for timing, overload/denormal behavior, latency
limits, test commands and the pending human-listening checklist. limits, test commands and the pending human-listening checklist.
## Phase 23 routing refinement
Graph selection now reports edge counts, output reachability and external-input
fallback. Direction arrows, role colors, incident-wire highlighting and no-output
badges help inspect routes. View / Arrange graph by signal flow (L) is a layout
command; Edit / Insert unity gain in selected wire (I) is one undoable graph edit.
Left/Right selects nodes; Tab retains ordinary focus navigation. See [ROUTING.md](ROUTING.md) for summing, stereo-lane,
cycle, live publication and deferred grouping/template semantics.

View file

@ -38,3 +38,7 @@ PERFORMANCE.md, PLUGIN_HOSTING.md and RECOVERY.md for tested contracts/evidence.
64 paths/1024 bins each. Reopen after external media replacement. Audio cuts 64 paths/1024 bins each. Reopen after external media replacement. Audio cuts
use tick resolution; GUI safety fades can create a short dip at split edges. use tick resolution; GUI safety fades can create a short dip at split edges.
Time stretching, independent pitch shifting and crossfades are deferred. Time stretching, independent pitch shifting and crossfades are deferred.
- Graph folders, per-track graph routing and track templates remain deferred:
timeline and MIDI sources expose shared mixes. Layout is session-only; no
feedback cycles, plugin isolation or latency compensation are supplied.

View file

@ -554,6 +554,23 @@ Preserve the modular spirit of the application while making complex projects und
- Graph updates follow the documented real-time synchronization strategy. - Graph updates follow the documented real-time synchronization strategy.
- GUI routing concepts map cleanly to the public C engine API. - GUI routing concepts map cleanly to the public C engine API.
Implemented: cached public C routing analysis (edge counts, longest-path levels,
output reachability and external-input fallback); directional/highlighted wires,
role colors/labels, no-output badges, selection inspection and keyboard navigation;
scrollable signal-flow auto-layout and non-overlapping initial placement. Atomic
unity-gain wire insertion is one undoable C edit using existing node persistence.
Core and mock/live GUI tests cover rollback, complete plan adoption, cycles,
disconnect/restore and undo/redo. See ROUTING.md. Flat tracks/shared audio/MIDI
sources do not yet represent folders or per-track routing templates; these
conditional tasks are deferred. No format change, feedback processing, latency
compensation or physical listening qualification is claimed.
Acceptance evidence:
- [x] Direction/role labels, layout and public routing diagnostics expose paths.
- [x] Invalid/capacity/backend-failed commands retain the previous graph/plan.
- [x] Live JACK graph tests verify complete block-boundary plan adoption.
- [x] GUI routing analysis and edits use public C APIs; callback ownership is unchanged.
## Phase 24 — Mixing and Mastering Essentials ## Phase 24 — Mixing and Mastering Essentials
### Goal ### Goal

110
docs/ROUTING.md Normal file
View file

@ -0,0 +1,110 @@
# Modular routing workflow (Phase 23)
Open Modular Graph (Ctrl+3). Select a node or wire to inspect it in the feedback
line. Nodes have stable IDs, role labels and distinct colors: teal sources,
purple gain/bus nodes, blue processors/meters and red output sums. LADSPA nodes
also show the saved plugin label. Track names remain in Sequencer/Mixer; the
Audio mix and MIDI mix graph sources represent their shared mix buffers, not
individual tracks. MIDI mix is the internal sine-instrument mix; its graph
ports carry audio, not MIDI events. Native nodes are trusted extensions.
Wires show arrows from right-hand audio outputs to left-hand inputs. Selecting
a node highlights all its incoming/outgoing wires; selecting a wire shows its
endpoint IDs. Inputs sum all connected signals without normalization; fan-out
sends the same signal to each destination. Multiple output nodes sum into the
engine output. Gain/bus nodes are ordinary summing gain processors, not a new
bus/mixer-strip type. Add gain retains its existing 0.10 preset.
A yellow **! no output path** badge and dim wires identify branches that cannot
reach a rendered output. This is a structural warning, not an invalid graph:
isolated nodes are allowed, and may still consume processing time. Conversely,
“path to engine output” does not guarantee audible music: clips, MIDI, gain,
mute, plugin behavior and external hardware routing still matter. Graph output
is not an automatic JACK hardware connection.
An **ext in** port means it has no incoming graph wire: the existing renderer
uses external audio input there (subject to its input gain), rather than
silence. Connecting a wire replaces this fallback with the wired sum; deleting
the last input wire restores fallback. This applies to unwired effects, meters
and outputs. Generator/audio/MIDI sources have no input port. Removing an output
path is not a substitute for stopping playback or muting a track. The graph
models one audio lane per port; stereo compilation processes paired independent
lanes. It does not expose separate left/right, MIDI or control wires.
## Commands
| Action | Operation |
| --- | --- |
| Connect | Drag an output circle to an input circle |
| Disconnect | Select a wire and press Delete/Backspace |
| Remove node | Select node, Delete; its incident wires are removed too |
| Inspect nodes | Click, or Left / Right within the focused graph |
| Arrange by signal flow | View menu, L with graph focus, or context menu |
| Insert unity gain | Select wire, Edit menu, I with graph focus, or context menu |
| Move / pan | Drag node body; scroll the canvas |
| Cancel drag | Escape |
Right-click opens commands for the current selection. Auto-layout places each
node in a column matching its longest source-to-node path and in a distinct row
within that column. Disconnected roots remain in the first column. It enlarges
the scrollable canvas when necessary. Moving, selecting and arranging are
session UI changes; they do not publish audio plans, alter routing, or add undo
steps. Open/undo may rebuild layout. Node positions are not persisted. Whole-
project binding gives distinct initial grid positions to all new nodes.
Inserting unity gain replaces an existing wire `A -> B` by `A -> gain -> B`,
with gain exactly 1. This simple reusable routing operation preserves level,
allocates a fresh stable node ID and is **one** semantic edit/undo step. It
preserves all other edges. Failure (including capacity, a missing/stale wire or
backend refusal) keeps the previous graph and plan. It introduces no new node
type or project record: v8 persistence already represents the resulting graph.
There are no track templates, folders or per-track graph buses yet. The flat
track model and shared source buffers cannot represent those relationships
faithfully; these conditional roadmap tasks remain deferred.
## Transactions, cycles and realtime ownership
The public C `xtc_graph_apply_edit` command constructs a private candidate.
`XTC_EDIT_INSERT_GAIN` uses `data.connection` and returns the fresh ID through
`out_added_id` only on complete success. It disconnects, inserts and reconnects
inside that private copy. Any failure destroys it; the source and output ID
remain unchanged, and the graph output pointer is NULL. Ordinary connect/delete
commands retain their existing transactional behavior. Self-edges, direct and
indirect feedback cycles, wrong port directions, duplicate wires and missing
ports are explicitly rejected. No feedback/delay-loop processing is supported.
The GUI clones its project and compiles/publishes the exact candidate through
the existing backend before replacing the displayed model/history. When running,
a complete immutable plan is adopted at a block boundary; the control thread
collects acknowledged retired plans. The feedback line distinguishes prepared,
queued and active revisions. A queued graph can appear before it becomes audible.
The single pending slot can reject a busy edit with CAPACITY: wait for adoption
and retry. There is no hidden unbounded edit queue or callback mutation. Normal
graph edits can run during playback; undo/redo uses the existing stopped-plan
installation contract. Recording still blocks semantic graph edits. Plan swaps
may reset oscillator/plugin state and are not crossfaded.
`xtc_graph_analyze` computes input/output edge counts, longest-path level,
rendered-output reachability and external-input fallback in dense-node order.
It is a bounded, allocation-free **control-thread** API, not a callback service.
It never loads plugins. It honors explicit outputs; legacy terminal fallback
applies only when there are no explicit output nodes and the caller has not
required them. Errors leave result storage/count unchanged. The GUI caches this
analysis when binding a graph; drawing reads cached metadata only. It uses
public C snapshots/commands and never engine internals or callback storage.
## Verification and limits
`make test-routing` checks branch reachability, external input, levels, capacity
rollback, exact unity-insertion rendering and complete plan handoff. `make test`
also retains concurrent publication/lifecycle tests.
`make test-reliability` retains the callback-allocation guards.
`make test-gui-plugins` checks selection diagnostics, unique placement/layout,
failed backend publication, atomic insertion undo/redo and keyboard focus.
`make test-gui-jack` inserts/disconnects/restores real routes during live software
playback and checks engine revision acknowledgements and cycle rejection.
Physical routing/listening and hardware endurance remain unqualified. This is
an audio-lane DAG editor with a default 64-node project capacity (standalone
C graphs up to 256), no zoom/lasso or persisted layout, no latency compensation
and no plugin crash isolation. Unconnected branches are diagnosed, not pruned.

View file

@ -116,3 +116,12 @@ All are undoable and preserve the WAV file. Waveforms show cached raw source
peaks, before gain/fades; large media can take time to load on Open/import. peaks, before gain/fades; large media can take time to load on Open/import.
See [AUDIO_FILES.md](AUDIO_FILES.md#phase-22-non-destructive-clip-editing) for See [AUDIO_FILES.md](AUDIO_FILES.md#phase-22-non-destructive-clip-editing) for
boundary behavior, preview limits and the deferred stretch/pitch tools. boundary behavior, preview limits and the deferred stretch/pitch tools.
## Inspecting and organizing routing
In Modular Graph, select a node or wire to inspect its routing in the feedback
line. Arrows show direction; yellow no-output badges identify disconnected
branches. L arranges nodes by signal flow. Select a wire and press I to insert a
unity gain/bus node as one undoable edit. These commands are also in View/Edit
and the graph context menu. Unwired effect inputs use external audio, as shown
by “ext in”; they do not imply silence. See [ROUTING.md](ROUTING.md).

View file

@ -49,7 +49,7 @@ typedef struct {
size_t channel_count; size_t channel_count;
} xtc_port_info; } xtc_port_info;
/* Control-thread API. Limits: 256 nodes, mono audio only. IDs are stable /* Control-thread API. Limits: 256 nodes, one audio lane per port (stereo plans use paired lanes). IDs are stable
* monotonically assigned identifiers; removed IDs are never reused. Output * monotonically assigned identifiers; removed IDs are never reused. Output
* parameters are written only on success, except create/compile/apply_edit * parameters are written only on success, except create/compile/apply_edit
* which set their output pointer to NULL on failure. */ * which set their output pointer to NULL on failure. */
@ -67,7 +67,8 @@ typedef enum {
XTC_EDIT_ADD_NODE = 1, XTC_EDIT_ADD_NODE = 1,
XTC_EDIT_REMOVE_NODE, XTC_EDIT_REMOVE_NODE,
XTC_EDIT_CONNECT, XTC_EDIT_CONNECT,
XTC_EDIT_DISCONNECT XTC_EDIT_DISCONNECT,
XTC_EDIT_INSERT_GAIN /* Atomic wire replacement by a unity gain/bus node. */
} xtc_graph_edit_kind; } xtc_graph_edit_kind;
typedef struct { typedef struct {
@ -84,6 +85,18 @@ typedef struct {
} data; } data;
} xtc_graph_edit; } xtc_graph_edit;
/* Control-thread routing diagnostics in dense-node order, matching get_node.
* level = longest source-to-node edge count. reaches_output follows explicit
* output nodes (or legacy terminal fallback only when no output nodes exist).
* external_input = no incoming wire and an input port: renderer uses external
* audio there, NOT silence. Reachability is structural, not an audibility test.
* No allocation/plugin loading. Failure leaves items and out_count unchanged. */
typedef struct {
size_t id, inputs, outputs, level;
int reaches_output, external_input;
} xtc_routing_info;
xtc_result xtc_graph_analyze(const xtc_graph *graph, xtc_routing_info *items, size_t capacity, size_t *out_count);
/* Read-only snapshots; indexes enumerate dense lists, distinct from stable IDs. /* Read-only snapshots; indexes enumerate dense lists, distinct from stable IDs.
* No borrowed pointers into graph storage are exposed. */ * No borrowed pointers into graph storage are exposed. */
/** /**
@ -125,7 +138,9 @@ xtc_result xtc_graph_get_connection(const xtc_graph *graph, size_t index, xtc_co
xtc_result xtc_graph_require_output_nodes(xtc_graph *graph, int required); xtc_result xtc_graph_require_output_nodes(xtc_graph *graph, int required);
/* Transactional command: on success caller owns a new graph; source unchanged. /* Transactional command: on success caller owns a new graph; source unchanged.
* Failure sets *out_graph=NULL, leaves source and out_added_id unchanged. * Failure sets *out_graph=NULL, leaves source and out_added_id unchanged.
* out_added_id is optional and written only for successful ADD_NODE. */ * out_added_id is optional and written only for successful ADD_NODE/INSERT_GAIN.
* INSERT_GAIN uses data.connection: the existing wire is replaced by
* source -> new unity gain -> destination, without publishing intermediate states. */
/** /**
* @brief Applies an edit to a new graph copy, leaving the source unchanged. * @brief Applies an edit to a new graph copy, leaving the source unchanged.
* *

View file

@ -498,6 +498,14 @@ xtc_result xtc_graph_apply_edit(const xtc_graph *source, const xtc_graph_edit *e
case XTC_EDIT_DISCONNECT: case XTC_EDIT_DISCONNECT:
result = disconnect_ports(graph, &edit->data.connection); result = disconnect_ports(graph, &edit->data.connection);
break; break;
case XTC_EDIT_INSERT_GAIN: {
const xtc_connection_info *c = &edit->data.connection;
result = disconnect_ports(graph, c);
if (result == XTC_OK) result = xtc_graph_add_gain(graph, 1.0f, &added_id);
if (result == XTC_OK) result = xtc_graph_connect_ports(graph, c->source_node, c->source_port, added_id, 0);
if (result == XTC_OK) result = xtc_graph_connect_ports(graph, added_id, 1, c->destination_node, c->destination_port);
break;
}
default: default:
result = XTC_ERR_INVALID_ARGUMENT; result = XTC_ERR_INVALID_ARGUMENT;
break; break;
@ -506,7 +514,7 @@ xtc_result xtc_graph_apply_edit(const xtc_graph *source, const xtc_graph_edit *e
xtc_graph_destroy(graph); xtc_graph_destroy(graph);
return result; return result;
} }
if (out_added_id != NULL && edit->kind == XTC_EDIT_ADD_NODE) if (out_added_id != NULL && (edit->kind == XTC_EDIT_ADD_NODE || edit->kind == XTC_EDIT_INSERT_GAIN))
*out_added_id = added_id; *out_added_id = added_id;
*out_graph = graph; *out_graph = graph;
return XTC_OK; return XTC_OK;
@ -588,3 +596,45 @@ xtc_result xtc_graph_set_plugin(xtc_graph *g, size_t id, const xtc_plugin_spec *
memcmp(s->ports,g->nodes[i].plugin.ports,s->control_count*sizeof(*s->ports))) return XTC_ERR_UNSUPPORTED; memcmp(s->ports,g->nodes[i].plugin.ports,s->control_count*sizeof(*s->ports))) return XTC_ERR_UNSUPPORTED;
g->nodes[i].plugin = *s; return XTC_OK; g->nodes[i].plugin = *s; return XTC_OK;
} }
xtc_result xtc_graph_analyze(const xtc_graph *graph, xtc_routing_info *items, size_t capacity, size_t *count)
{
xtc_routing_info info[XTC_MAX_NODES] = {{0}};
size_t order[XTC_MAX_NODES], i, j;
int explicit_outputs = 0;
xtc_result result;
if (!graph || !count || (!items && graph->node_count)) return XTC_ERR_INVALID_ARGUMENT;
if (capacity < graph->node_count) return XTC_ERR_CAPACITY;
result = xtc_graph_order(graph, order);
if (result != XTC_OK) return result;
for (i = 0; i < graph->node_count; ++i) {
info[i].id = graph->nodes[i].id;
if (graph->nodes[i].type == XTC_NODE_OUTPUT) explicit_outputs = 1;
}
for (i = 0; i < graph->edge_count; ++i) {
++info[graph->edges[i].source].outputs;
++info[graph->edges[i].destination].inputs;
}
for (i = 0; i < graph->node_count; ++i) {
xtc_port_info port;
info[i].external_input = !info[i].inputs && xtc_graph_get_port(graph, info[i].id, 0, &port) == XTC_OK;
info[i].reaches_output = graph->nodes[i].type == XTC_NODE_OUTPUT ||
(!explicit_outputs && !graph->require_output_nodes && !info[i].outputs);
}
for (i = 0; i < graph->node_count; ++i) {
size_t node = order[i];
for (j = 0; j < graph->edge_count; ++j) if (graph->edges[j].source == node) {
size_t destination = graph->edges[j].destination;
if (info[destination].level <= info[node].level) info[destination].level = info[node].level + 1;
}
}
for (i = graph->node_count; i > 0; --i) {
size_t node = order[i-1];
if (info[node].reaches_output)
for (j = 0; j < graph->edge_count; ++j) if (graph->edges[j].destination == node)
info[graph->edges[j].source].reaches_output = 1;
}
if (graph->node_count) memcpy(items, info, graph->node_count * sizeof(*items));
*count = graph->node_count;
return XTC_OK;
}

View file

@ -2,7 +2,9 @@
#include "xtc/xtc_plugin.h" #include "xtc/xtc_plugin.h"
#include <FL/Fl.H> #include <FL/Fl.H>
#include <FL/Fl_Box.H> #include <FL/Fl_Box.H>
#include <FL/Fl_Group.H>
#include <FL/fl_draw.H> #include <FL/fl_draw.H>
#include <FL/Fl_Menu_Item.H>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
@ -22,7 +24,7 @@ const char *node_name(xtc_node_type type)
case XTC_NODE_OSCILLATOR: case XTC_NODE_OSCILLATOR:
return "Oscillator"; return "Oscillator";
case XTC_NODE_GAIN: case XTC_NODE_GAIN:
return "Gain"; return "Gain / bus";
case XTC_NODE_METER: case XTC_NODE_METER:
return "Meter"; return "Meter";
case XTC_NODE_PLUGIN: case XTC_NODE_PLUGIN:
@ -30,11 +32,11 @@ const char *node_name(xtc_node_type type)
case XTC_NODE_NATIVE: case XTC_NODE_NATIVE:
return "Native processor"; return "Native processor";
case XTC_NODE_INSTRUMENT: case XTC_NODE_INSTRUMENT:
return "Instrument"; return "MIDI mix";
case XTC_NODE_AUDIO: case XTC_NODE_AUDIO:
return "Audio clips"; return "Audio mix";
case XTC_NODE_OUTPUT: case XTC_NODE_OUTPUT:
return "Output"; return "Output sum";
} }
return "Unknown"; return "Unknown";
} }
@ -75,6 +77,8 @@ GraphWidget::GraphWidget(int left, int top, int width, int height, const xtc_gra
void GraphWidget::set_graph(const xtc_graph *graph) void GraphWidget::set_graph(const xtc_graph *graph)
{ {
graph_ = graph; graph_ = graph;
routing_count_ = 0;
(void)xtc_graph_analyze(graph_, routing_.data(), routing_.size(), &routing_count_);
size_t nodes = 0U, edges = 0U; size_t nodes = 0U, edges = 0U;
if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) { if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) {
count_ = 0U; count_ = 0U;
@ -106,6 +110,10 @@ void GraphWidget::set_graph(const xtc_graph *graph)
if (std::abs(old[j].left - position.left) < node_width + 12 && if (std::abs(old[j].left - position.left) < node_width + 12 &&
std::abs(old[j].top - position.top) < node_height + 12) std::abs(old[j].top - position.top) < node_height + 12)
occupied = true; occupied = true;
for (size_t j = 0U; j < count_; ++j)
if (std::abs(positions_[j].left - position.left) < node_width + 12 &&
std::abs(positions_[j].top - position.top) < node_height + 12)
occupied = true;
if (!occupied) if (!occupied)
break; break;
} }
@ -117,6 +125,7 @@ void GraphWidget::set_graph(const xtc_graph *graph)
selected_node_ = SIZE_MAX; selected_node_ = SIZE_MAX;
if (edge_selected_ && !select_connection(selected_edge_.source_node, selected_edge_.destination_node)) if (edge_selected_ && !select_connection(selected_edge_.source_node, selected_edge_.destination_node))
edge_selected_ = false; edge_selected_ = false;
grow_canvas();
redraw(); redraw();
} }
@ -174,6 +183,7 @@ bool GraphWidget::select_node(size_t id)
return false; return false;
selected_node_ = id; selected_node_ = id;
edge_selected_ = false; edge_selected_ = false;
describe_selection();
redraw(); redraw();
return true; return true;
} }
@ -197,6 +207,7 @@ bool GraphWidget::select_connection(size_t source, size_t destination)
selected_edge_ = edge; selected_edge_ = edge;
edge_selected_ = true; edge_selected_ = true;
selected_node_ = SIZE_MAX; selected_node_ = SIZE_MAX;
describe_selection();
redraw(); redraw();
return true; return true;
} }
@ -219,8 +230,10 @@ void GraphWidget::feedback(xtc_result result, const char *success)
std::snprintf( std::snprintf(
text, sizeof(text), text, sizeof(text),
"Graph unchanged: capacity exceeded (node limit or pending edit). Retry after the next audio block."); "Graph unchanged: capacity exceeded (node limit or pending edit). Retry after the next audio block.");
else if (result == XTC_ERR_GRAPH_CYCLE)
std::snprintf(text, sizeof(text), "Graph unchanged: %s. Feedback loops are unsupported.", xtc_result_string(result));
else else
std::snprintf(text, sizeof(text), "Graph unchanged: %s.", xtc_result_string(result)); std::snprintf(text, sizeof(text), "Graph unchanged: %s. Connect an audio output (right) to an input (left); duplicate wires are rejected.", xtc_result_string(result));
feedback_->copy_label(text); feedback_->copy_label(text);
} }
@ -282,7 +295,7 @@ xtc_result GraphWidget::delete_selected()
return XTC_ERR_INVALID_STATE; return XTC_ERR_INVALID_STATE;
} }
xtc_result result = handler_(context_, edit, nullptr); xtc_result result = handler_(context_, edit, nullptr);
feedback(result, "Selection deleted."); feedback(result, "Selection deleted. Unwired inputs use external audio; select a node to inspect routing.");
return result; return result;
} }
@ -348,11 +361,21 @@ void GraphWidget::draw()
if (xtc_graph_get_connection(graph_, i, &edge) != XTC_OK || !port_position(edge.source_node, 1U, sx, sy) || if (xtc_graph_get_connection(graph_, i, &edge) != XTC_OK || !port_position(edge.source_node, 1U, sx, sy) ||
!port_position(edge.destination_node, 0U, dx, dy)) !port_position(edge.destination_node, 0U, dx, dy))
continue; continue;
bool selected = edge_selected_ && selected_edge_.source_node == edge.source_node && bool selected = (edge_selected_ && selected_edge_.source_node == edge.source_node &&
selected_edge_.destination_node == edge.destination_node; selected_edge_.destination_node == edge.destination_node) ||
fl_color(selected ? FL_YELLOW : fl_rgb_color(100, 200, 190)); selected_node_ == edge.source_node || selected_node_ == edge.destination_node;
xtc_routing_info route{};
bool audible_path = routing(edge.destination_node, route) && route.reaches_output;
fl_color(selected ? FL_YELLOW : audible_path ? fl_rgb_color(100, 200, 190) : fl_rgb_color(110, 115, 125));
fl_line_style(FL_SOLID, selected ? 3 : 2); fl_line_style(FL_SOLID, selected ? 3 : 2);
fl_line(x() + sx, y() + sy, x() + dx, y() + dy); fl_line(x() + sx, y() + sy, x() + dx, y() + dy);
double length = std::hypot(dx-sx, dy-sy);
if (length > 20) {
double ux = (dx-sx)/length, uy = (dy-sy)/length;
int ax = x() + (sx+dx)/2, ay = y() + (sy+dy)/2;
fl_line(ax, ay, ax-static_cast<int>(ux*9-uy*5), ay-static_cast<int>(uy*9+ux*5));
fl_line(ax, ay, ax-static_cast<int>(ux*9+uy*5), ay-static_cast<int>(uy*9-ux*5));
}
} }
fl_line_style(0); fl_line_style(0);
for (size_t i = 0U; i < count_; ++i) { for (size_t i = 0U; i < count_; ++i) {
@ -360,7 +383,10 @@ void GraphWidget::draw()
if (xtc_graph_get_node(graph_, i, &node) != XTC_OK) if (xtc_graph_get_node(graph_, i, &node) != XTC_OK)
continue; continue;
int left = x() + positions_[i].left, top = y() + positions_[i].top; int left = x() + positions_[i].left, top = y() + positions_[i].top;
fl_color(fl_rgb_color(49, 54, 62)); fl_color(node.type == XTC_NODE_OUTPUT ? fl_rgb_color(100, 50, 55)
: node.type == XTC_NODE_GAIN ? fl_rgb_color(75, 55, 95)
: node.type == XTC_NODE_OSCILLATOR || node.type == XTC_NODE_AUDIO || node.type == XTC_NODE_INSTRUMENT
? fl_rgb_color(35, 80, 75) : fl_rgb_color(49, 65, 85));
fl_rectf(left, top, node_width, node_height); fl_rectf(left, top, node_width, node_height);
fl_color(selected_node_ == node.id ? FL_YELLOW : fl_rgb_color(115, 130, 145)); fl_color(selected_node_ == node.id ? FL_YELLOW : fl_rgb_color(115, 130, 145));
fl_rect(left, top, node_width, node_height); fl_rect(left, top, node_width, node_height);
@ -368,6 +394,7 @@ void GraphWidget::draw()
fl_font(FL_HELVETICA_BOLD, 12); fl_font(FL_HELVETICA_BOLD, 12);
char text[96]; char text[96];
std::snprintf(text, sizeof(text), "%s #%zu", node_name(node.type), node.id); std::snprintf(text, sizeof(text), "%s #%zu", node_name(node.type), node.id);
fl_push_clip(left+6, top+2, node_width-12, 40);
fl_draw(text, left + 10, top + 20); fl_draw(text, left + 10, top + 20);
fl_font(FL_HELVETICA, 12); fl_font(FL_HELVETICA, 12);
if (node.type == XTC_NODE_OSCILLATOR) if (node.type == XTC_NODE_OSCILLATOR)
@ -383,8 +410,14 @@ void GraphWidget::draw()
node.type == XTC_NODE_METER ? "Peak pass-through" node.type == XTC_NODE_METER ? "Peak pass-through"
: node.type == XTC_NODE_INSTRUMENT ? "MIDI sine source" : node.type == XTC_NODE_INSTRUMENT ? "MIDI sine source"
: node.type == XTC_NODE_AUDIO ? "Timeline mix source" : node.type == XTC_NODE_AUDIO ? "Timeline mix source"
: "Mono audio output"); : "Summed engine output");
fl_draw(text, left + 10, top + 37); fl_draw(text, left + 10, top + 37);
fl_pop_clip();
xtc_routing_info route{};
if (routing(node.id, route) && !route.reaches_output) {
fl_color(FL_YELLOW);
fl_draw("! no output path", left + 10, top + 72);
}
for (size_t port = 0U; port < 2U; ++port) { for (size_t port = 0U; port < 2U; ++port) {
int px, py; int px, py;
if (!port_position(node.id, port, px, py)) if (!port_position(node.id, port, px, py))
@ -392,7 +425,7 @@ void GraphWidget::draw()
fl_color(fl_rgb_color(110, 220, 185)); fl_color(fl_rgb_color(110, 220, 185));
fl_pie(x() + px - 5, y() + py - 5, 10, 10, 0, 360); fl_pie(x() + px - 5, y() + py - 5, 10, 10, 0, 360);
fl_color(FL_WHITE); fl_color(FL_WHITE);
fl_draw(port == 0U ? "in" : "out", left + (port == 0U ? 10 : 118), top + 62); fl_draw(port == 0U ? (route.external_input ? "ext in" : "sum in") : "audio out", left + (port == 0U ? 10 : 90), top + 56);
} }
} }
if (wiring_) { if (wiring_) {
@ -420,8 +453,20 @@ int GraphWidget::handle(int event)
case FL_UNFOCUS: case FL_UNFOCUS:
return 1; return 1;
case FL_PUSH: { case FL_PUSH: {
if (Fl::event_button() != FL_LEFT_MOUSE) if (Fl::event_button() == FL_RIGHT_MOUSE) {
return 0; Fl_Menu_Item items[5]{};
items[0].text = "Inspect selection";
items[1].text = "Arrange by signal flow (L)";
items[2].text = "Insert unity gain in selected wire (I)";
items[3].text = "Delete selection";
const auto *picked = items->popup(Fl::event_x(), Fl::event_y());
if (picked == items) describe_selection();
else if (picked == items+1) auto_layout();
else if (picked == items+2) insert_gain();
else if (picked == items+3) delete_selected();
return 1;
}
if (Fl::event_button() != FL_LEFT_MOUSE) return 0;
take_focus(); take_focus();
mouse_x_ = left; mouse_x_ = left;
mouse_y_ = top; mouse_y_ = top;
@ -485,6 +530,16 @@ int GraphWidget::handle(int event)
} }
return 1; return 1;
case FL_KEYDOWN: case FL_KEYDOWN:
if (Fl::event_key() == 'l' && !Fl::event_state(FL_CTRL | FL_ALT)) { auto_layout(); return 1; }
if (Fl::event_key() == 'i' && !Fl::event_state(FL_CTRL | FL_ALT)) { insert_gain(); return 1; }
if ((Fl::event_key() == FL_Left || Fl::event_key() == FL_Right) && count_) {
size_t selected = 0;
for (size_t i = 0; i < count_; ++i) if (positions_[i].id == selected_node_) {
selected = Fl::event_key() == FL_Left ? (i+count_-1)%count_ : (i+1)%count_; break;
}
select_node(positions_[selected].id);
return 1;
}
if (Fl::event_key() == FL_Delete || Fl::event_key() == FL_BackSpace) { if (Fl::event_key() == FL_Delete || Fl::event_key() == FL_BackSpace) {
delete_selected(); delete_selected();
return 1; return 1;
@ -500,3 +555,70 @@ int GraphWidget::handle(int event)
} }
return Fl_Widget::handle(event); return Fl_Widget::handle(event);
} }
bool GraphWidget::routing(size_t id, xtc_routing_info &info) const
{
for (size_t i = 0; i < routing_count_; ++i) if (routing_[i].id == id) { info = routing_[i]; return true; }
return false;
}
void GraphWidget::grow_canvas()
{
int width = 2000, height = 4000;
for (size_t i = 0; i < count_; ++i) {
width = std::max(width, positions_[i].left + node_width + 40);
height = std::max(height, positions_[i].top + node_height + 40);
}
size(width, height);
if (parent()) parent()->redraw();
}
bool GraphWidget::auto_layout()
{
if (routing_count_ != count_) return false;
std::array<size_t, 256> rows{};
for (size_t i = 0; i < count_; ++i) {
xtc_routing_info info{};
if (!routing(positions_[i].id, info) || info.level >= rows.size()) return false;
positions_[i].left = 40 + static_cast<int>(info.level) * 210;
positions_[i].top = 40 + static_cast<int>(rows[info.level]++) * 115;
}
wiring_ = dragging_ = false;
grow_canvas();
feedback_->copy_label("Arranged left to right by signal flow. Layout is session-only; no audio or routing changes.");
redraw();
return true;
}
xtc_result GraphWidget::insert_gain()
{
if (!edge_selected_) {
feedback_->copy_label("Select a wire first, then insert a unity gain/bus node.");
return XTC_ERR_INVALID_STATE;
}
xtc_graph_edit edit{};
edit.kind = XTC_EDIT_INSERT_GAIN;
edit.data.connection = selected_edge_;
size_t added = SIZE_MAX;
xtc_result result = handler_(context_, edit, &added);
if (result == XTC_OK) select_node(added);
feedback(result, "Unity gain inserted into wire as one edit. Inputs sum; fan-out copies the signal without normalization.");
return result;
}
void GraphWidget::describe_selection()
{
char text[320];
if (edge_selected_) {
std::snprintf(text, sizeof(text), "Wire: #%zu audio out -> #%zu sum in. Delete disconnects; I inserts a unity gain. Destination sums its inputs.",
selected_edge_.source_node, selected_edge_.destination_node);
} else {
xtc_routing_info info{};
if (!routing(selected_node_, info)) { feedback_->copy_label("Select a node or wire to inspect routing."); return; }
std::snprintf(text, sizeof(text), "Node #%zu: %zu incoming, %zu outgoing | %s | %s. Ports are audio lanes; stereo uses paired independent processing.",
info.id, info.inputs, info.outputs,
info.reaches_output ? "path to engine output" : "NO output path",
info.external_input ? "unwired input uses external audio" : "wired sum or source");
}
feedback_->copy_label(text);
}

View file

@ -80,6 +80,10 @@ class GraphWidget : public Fl_Widget {
* @return Result reported by the edit handler. * @return Result reported by the edit handler.
*/ */
xtc_result add_node(xtc_node_type type); xtc_result add_node(xtc_node_type type);
xtc_result insert_gain();
bool auto_layout(); // Presentation only, no engine publication or undo step.
bool routing(size_t id, xtc_routing_info &info) const;
/** /**
* @brief Requests a connection between two ports through the edit handler. * @brief Requests a connection between two ports through the edit handler.
@ -156,7 +160,10 @@ class GraphWidget : public Fl_Widget {
}; };
std::array<Position, 256> positions_{}; std::array<Position, 256> positions_{};
size_t count_ = 0U; size_t count_ = 0U, routing_count_ = 0U;
std::array<xtc_routing_info, 256> routing_{};
void describe_selection();
void grow_canvas();
const xtc_graph *graph_; const xtc_graph *graph_;
EditHandler handler_; EditHandler handler_;
void *context_; void *context_;

View file

@ -217,6 +217,8 @@ MainWindow::MainWindow(bool prompt_unsaved)
auto *scroll = new Fl_Scroll(20, 212, 956, 328); auto *scroll = new Fl_Scroll(20, 212, 956, 328);
graph_widget_ = new GraphWidget(20, 212, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback); graph_widget_ = new GraphWidget(20, 212, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback);
scroll->end(); scroll->end();
menu->add("View/Arrange graph by signal flow", 0, [](Fl_Widget *, void *c) { (void)static_cast<GraphWidget *>(c)->auto_layout(); }, graph_widget_);
menu->add("Edit/Insert unity gain in selected wire", 0, [](Fl_Widget *, void *c) { (void)static_cast<GraphWidget *>(c)->insert_gain(); }, graph_widget_);
auto *oscillator = new Fl_Button(20, 150, 130, 26, "Add oscillator"); auto *oscillator = new Fl_Button(20, 150, 130, 26, "Add oscillator");
oscillator->callback( oscillator->callback(
[](Fl_Widget *, void *context) { [](Fl_Widget *, void *context) {
@ -363,7 +365,7 @@ xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, siz
window->graph_widget_->set_graph(xtc_project_graph(replacement)); window->graph_widget_->set_graph(xtc_project_graph(replacement));
window->sequencer_->set_project(replacement, false); window->sequencer_->set_project(replacement, false);
window->mark_dirty(); window->mark_dirty();
if (out_added_id != nullptr && edit.kind == XTC_EDIT_ADD_NODE) if (out_added_id != nullptr && (edit.kind == XTC_EDIT_ADD_NODE || edit.kind == XTC_EDIT_INSERT_GAIN))
*out_added_id = added_id; *out_added_id = added_id;
window->refresh_status(); window->refresh_status();
return XTC_OK; return XTC_OK;

View file

@ -63,6 +63,15 @@ SequencerWidget *timeline(Fl_Group *group)
} }
return nullptr; return nullptr;
} }
GraphWidget *graph_canvas(Fl_Group *group)
{
for (int i = 0; i < group->children(); ++i) {
if (auto *canvas = dynamic_cast<GraphWidget *>(group->child(i))) return canvas;
if (auto *nested = dynamic_cast<Fl_Group *>(group->child(i)))
if (auto *canvas = graph_canvas(nested)) return canvas;
}
return nullptr;
}
void gesture(Fl_Widget *s, int left, int top, int dx, int dy) void gesture(Fl_Widget *s, int left, int top, int dx, int dy)
{ {
int old_x = Fl::e_x, old_y = Fl::e_y, old_key = Fl::e_keysym; int old_x = Fl::e_x, old_y = Fl::e_y, old_key = Fl::e_keysym;
@ -549,6 +558,50 @@ int main(int argc, char **argv)
CHECK(xtc_project_get_track(s->project(),0,&t) == XTC_OK && !std::strcmp(t.name,"Keys")); CHECK(xtc_project_get_track(s->project(),0,&t) == XTC_OK && !std::strcmp(t.name,"Keys"));
window.do_callback(); window.do_callback();
} }
{
reset();
MainWindow window(false);
auto *canvas = graph_canvas(&window);
CHECK(canvas != nullptr);
CHECK(canvas->add_node(XTC_NODE_OSCILLATOR) == XTC_OK);
CHECK(canvas->add_node(XTC_NODE_OUTPUT) == XTC_OK);
CHECK(canvas->connect_ports({0,1,1,0}) == XTC_OK);
CHECK(canvas->select_connection(0,1) && contains(&window, "Wire: #0 audio out -> #1 sum in"));
const xtc_graph *original = canvas->graph();
unsigned int submitted = submissions;
submit_result = XTC_ERR_CAPACITY;
CHECK(canvas->insert_gain() == XTC_ERR_CAPACITY && canvas->graph() == original && submissions == submitted+1);
submit_result = XTC_OK;
CHECK(canvas->insert_gain() == XTC_OK);
size_t inserted = canvas->selected_node(), nodes, edges;
CHECK(inserted == 2);
CHECK(xtc_graph_get_counts(canvas->graph(), &nodes, &edges) == XTC_OK && nodes == 3 && edges == 2);
xtc_routing_info route{};
CHECK(canvas->routing(inserted,route) && route.level == 1 && route.reaches_output && route.inputs == 1);
submitted = submissions;
CHECK(canvas->auto_layout() && submissions == submitted);
int sx = 0, sy = 0, gx = 0, gy = 0, ox = 0, oy = 0;
CHECK(canvas->node_position(0,sx,sy) && canvas->node_position(inserted,gx,gy) && canvas->node_position(1,ox,oy));
CHECK(sx < gx && gx < ox);
CHECK(canvas->select_node(inserted) && contains(&window,"1 incoming, 1 outgoing"));
CHECK(canvas->select_connection(0,inserted) && canvas->delete_selected() == XTC_OK);
CHECK(canvas->select_node(inserted) && contains(&window,"unwired input uses external audio"));
CHECK(window.undo() == XTC_OK); // Disconnect is independently undoable.
CHECK(window.undo() == XTC_OK); // Whole insertion is one undo step.
CHECK(xtc_graph_get_counts(canvas->graph(), &nodes, &edges) == XTC_OK && nodes == 2 && edges == 1);
CHECK(window.undo(true) == XTC_OK);
CHECK(xtc_graph_get_counts(canvas->graph(), &nodes, &edges) == XTC_OK && nodes == 3 && edges == 2);
CHECK(canvas->select_node(inserted));
int key = Fl::e_keysym, state = Fl::e_state;
Fl::e_keysym = FL_Right; Fl::e_state = 0;
CHECK(canvas->handle(FL_KEYDOWN) == 1 && canvas->selected_node() == 0);
Fl::e_keysym = key; Fl::e_state = state;
// Whole-project binding must give every new node a distinct position.
canvas->reset_layout(); canvas->set_graph(canvas->graph());
CHECK(canvas->node_position(0,sx,sy) && canvas->node_position(inserted,gx,gy) && canvas->node_position(1,ox,oy));
CHECK((sx != gx || sy != gy) && (sx != ox || sy != oy) && (gx != ox || gy != oy));
window.do_callback();
}
if (const char *module = std::getenv("XTC_TEST_PLUGIN")) { if (const char *module = std::getenv("XTC_TEST_PLUGIN")) {
reset(); reset();
MainWindow window(false); MainWindow window(false);

View file

@ -72,6 +72,22 @@ int main(int argc, char **argv)
const xtc_graph *unchanged = canvas->graph(); const xtc_graph *unchanged = canvas->graph();
CHECK(canvas->connect_ports({1U, 1U, 1U, 0U}) == XTC_ERR_GRAPH_CYCLE); CHECK(canvas->connect_ports({1U, 1U, 1U, 0U}) == XTC_ERR_GRAPH_CYCLE);
CHECK(canvas->graph() == unchanged); CHECK(canvas->graph() == unchanged);
// Replace a live wire by a complete unity-gain chain at one publication.
CHECK(canvas->select_connection(1,2));
CHECK(canvas->insert_gain() == XTC_OK);
size_t inserted = canvas->selected_node();
pump();
CHECK(find(window, "Graph: active in engine") != nullptr);
CHECK(find(window, "running — modular graph") != nullptr);
xtc_routing_info route{};
CHECK(canvas->routing(inserted, route) && route.inputs == 1 && route.outputs == 1 && route.reaches_output);
CHECK(canvas->connect_ports({inserted,1,1,0}) == XTC_ERR_GRAPH_CYCLE);
CHECK(canvas->select_connection(1,inserted) && canvas->delete_selected() == XTC_OK); pump();
CHECK(canvas->select_connection(inserted,2) && canvas->delete_selected() == XTC_OK); pump();
CHECK(canvas->connect_ports({1,1,2,0}) == XTC_OK); pump();
CHECK(canvas->select_node(inserted) && canvas->delete_selected() == XTC_OK);
pump();
CHECK(find(window, "Graph: active in engine") != nullptr);
CHECK(canvas->add_node(XTC_NODE_METER) == XTC_OK); CHECK(canvas->add_node(XTC_NODE_METER) == XTC_OK);
pump(); pump();
CHECK(find(window, "Graph: active in engine") != nullptr); CHECK(find(window, "Graph: active in engine") != nullptr);

84
tests/unit/test_routing.c Normal file
View file

@ -0,0 +1,84 @@
#include "xtc/xtc_engine.h"
#include "xtc/xtc_realtime.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
xtc_graph *graph = NULL, *inserted = NULL, *bad = NULL, *legacy = NULL, *full = NULL;
xtc_plan *old = NULL, *fresh = NULL;
xtc_realtime *engine = NULL;
xtc_graph_edit edit = {0};
xtc_routing_info info[256] = {{0}}, sentinel[256];
xtc_realtime_status status;
size_t source, gain, output, unused, added = SIZE_MAX, count = 999, nodes, edges, i;
float a[32], b[32];
assert(xtc_graph_create(8, &graph) == XTC_OK);
assert(xtc_graph_require_output_nodes(graph, 1) == XTC_OK);
assert(xtc_graph_add_oscillator(graph, 12000, &source) == XTC_OK);
assert(xtc_graph_add_gain(graph, 0.25f, &gain) == XTC_OK);
assert(xtc_graph_add_output(graph, &output) == XTC_OK);
assert(xtc_graph_add_meter(graph, &unused) == XTC_OK);
assert(xtc_graph_connect(graph, source, gain) == XTC_OK && xtc_graph_connect(graph, gain, output) == XTC_OK);
assert(xtc_graph_analyze(graph, info, 256, &count) == XTC_OK && count == 4);
assert(info[0].id == source && info[0].inputs == 0 && info[0].outputs == 1 && info[0].level == 0 && info[0].reaches_output && !info[0].external_input);
assert(info[1].inputs == 1 && info[1].outputs == 1 && info[1].level == 1 && info[1].reaches_output && !info[1].external_input);
assert(info[2].level == 2 && info[2].reaches_output && !info[2].external_input);
assert(!info[3].reaches_output && info[3].external_input);
memcpy(sentinel, info, sizeof(info)); count = 999;
assert(xtc_graph_analyze(graph, info, 3, &count) == XTC_ERR_CAPACITY && count == 999 && !memcmp(info, sentinel, sizeof(info)));
assert(xtc_graph_analyze(NULL, info, 256, &count) == XTC_ERR_INVALID_ARGUMENT);
assert(xtc_graph_analyze(graph, NULL, 256, &count) == XTC_ERR_INVALID_ARGUMENT);
edit.kind = XTC_EDIT_INSERT_GAIN;
edit.data.connection = (xtc_connection_info){source, 1, gain, 0};
assert(xtc_graph_apply_edit(graph, &edit, &inserted, &added) == XTC_OK && added > unused);
assert(xtc_graph_get_counts(graph, &nodes, &edges) == XTC_OK && nodes == 4 && edges == 2);
assert(xtc_graph_get_counts(inserted, &nodes, &edges) == XTC_OK && nodes == 5 && edges == 3);
assert(xtc_graph_analyze(inserted, info, 256, &count) == XTC_OK && count == 5);
assert(info[4].id == added && info[4].level == 1 && info[1].level == 2 && info[2].level == 3);
assert(xtc_graph_compile(graph, 48000, 32, &old) == XTC_OK);
assert(xtc_graph_compile(inserted, 48000, 32, &fresh) == XTC_OK);
assert(xtc_plan_process(old, NULL, a, 32) == XTC_OK && xtc_plan_process(fresh, NULL, b, 32) == XTC_OK && !memcmp(a, b, sizeof(a)));
xtc_plan_destroy(old); xtc_plan_destroy(fresh);
/* Missing/invalid wire never consumes IDs or returns a partial graph. */
size_t inserted_id = added;
added = 999;
edit.data.connection.destination_node = unused;
assert(xtc_graph_apply_edit(graph, &edit, &bad, &added) == XTC_ERR_NOT_FOUND && !bad && added == 999);
edit.data.connection = (xtc_connection_info){source, 0, gain, 0};
assert(xtc_graph_apply_edit(graph, &edit, &bad, &added) != XTC_OK && !bad && added == 999);
assert(xtc_graph_create(2, &full) == XTC_OK);
assert(xtc_graph_add_gain(full, 1, &source) == XTC_OK && xtc_graph_add_output(full, &output) == XTC_OK);
assert(xtc_graph_connect(full, source, output) == XTC_OK);
edit.data.connection = (xtc_connection_info){source, 1, output, 0};
assert(xtc_graph_apply_edit(full, &edit, &bad, &added) == XTC_ERR_CAPACITY && !bad && added == 999);
assert(xtc_graph_get_counts(full, &nodes, &edges) == XTC_OK && nodes == 2 && edges == 1);
xtc_graph_destroy(full);
/* Rejected cycle and disconnect leave an independently compiled plan usable. */
edit.kind = XTC_EDIT_CONNECT;
edit.data.connection = (xtc_connection_info){gain, 1, inserted_id, 0};
assert(xtc_graph_apply_edit(inserted, &edit, &bad, NULL) == XTC_ERR_GRAPH_CYCLE && !bad);
/* Pending/active plan handoff publishes the entire inserted chain at once. */
assert(xtc_realtime_create(48000, 32, &engine) == XTC_OK);
assert(xtc_realtime_submit(engine, graph) == XTC_OK);
assert(xtc_realtime_process(engine, NULL, a, 32) == XTC_OK);
for (i = 0; i < 100; ++i) {
assert(xtc_realtime_submit(engine, i % 2 ? graph : inserted) == XTC_OK);
assert(xtc_realtime_process(engine, NULL, b, 32) == XTC_OK && !memcmp(a, b, sizeof(a)));
xtc_realtime_collect(engine);
assert(xtc_realtime_get_status(engine, &status) == XTC_OK && status.active_revision == status.submitted_revision);
}
xtc_realtime_destroy(engine);
/* Legacy terminal fallback is disabled whenever an explicit output exists. */
assert(xtc_graph_create(4, &legacy) == XTC_OK);
assert(xtc_graph_add_gain(legacy, 1, &gain) == XTC_OK);
assert(xtc_graph_analyze(legacy, info, 256, &count) == XTC_OK && info[0].reaches_output && info[0].external_input);
assert(xtc_graph_add_output(legacy, &output) == XTC_OK);
assert(xtc_graph_analyze(legacy, info, 256, &count) == XTC_OK && !info[0].reaches_output && info[1].external_input);
assert(xtc_graph_connect(legacy, gain, output) == XTC_OK);
assert(xtc_graph_analyze(legacy, info, 256, &count) == XTC_OK && info[0].reaches_output && !info[1].external_input);
xtc_graph_destroy(legacy); xtc_graph_destroy(graph); xtc_graph_destroy(inserted);
puts("Routing reachability/levels/external input, atomic wire insertion/rollback and publication passed.");
return 0;
}