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
test: test-audio-editing
-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).
`make test-audio-editing` checks source preservation and exact boundary output.
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
wires. Escape cancels a drag preview. Canvas scrollbars pan the view; positions
are retained across edits/reconnect but not across application restart. There
is no graph zoom, multiselection, undo or external routing UI yet.
The editor supports 64 nodes on a fixed 2000x4000 canvas.
is no graph zoom, multiselection or external hardware routing UI. Semantic
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
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
[AUDIO_QUALITY.md](AUDIO_QUALITY.md) for timing, overload/denormal behavior, latency
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
use tick resolution; GUI safety fades can create a short dip at split edges.
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.
- 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
### 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.
See [AUDIO_FILES.md](AUDIO_FILES.md#phase-22-non-destructive-clip-editing) for
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;
} 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
* parameters are written only on success, except create/compile/apply_edit
* which set their output pointer to NULL on failure. */
@ -67,7 +67,8 @@ typedef enum {
XTC_EDIT_ADD_NODE = 1,
XTC_EDIT_REMOVE_NODE,
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;
typedef struct {
@ -84,6 +85,18 @@ typedef struct {
} data;
} 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.
* 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);
/* Transactional command: on success caller owns a new graph; source 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.
*

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:
result = disconnect_ports(graph, &edit->data.connection);
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:
result = XTC_ERR_INVALID_ARGUMENT;
break;
@ -506,7 +514,7 @@ xtc_result xtc_graph_apply_edit(const xtc_graph *source, const xtc_graph_edit *e
xtc_graph_destroy(graph);
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_graph = graph;
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;
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 <FL/Fl.H>
#include <FL/Fl_Box.H>
#include <FL/Fl_Group.H>
#include <FL/fl_draw.H>
#include <FL/Fl_Menu_Item.H>
#include <algorithm>
#include <cmath>
#include <cstdio>
@ -22,7 +24,7 @@ const char *node_name(xtc_node_type type)
case XTC_NODE_OSCILLATOR:
return "Oscillator";
case XTC_NODE_GAIN:
return "Gain";
return "Gain / bus";
case XTC_NODE_METER:
return "Meter";
case XTC_NODE_PLUGIN:
@ -30,11 +32,11 @@ const char *node_name(xtc_node_type type)
case XTC_NODE_NATIVE:
return "Native processor";
case XTC_NODE_INSTRUMENT:
return "Instrument";
return "MIDI mix";
case XTC_NODE_AUDIO:
return "Audio clips";
return "Audio mix";
case XTC_NODE_OUTPUT:
return "Output";
return "Output sum";
}
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)
{
graph_ = graph;
routing_count_ = 0;
(void)xtc_graph_analyze(graph_, routing_.data(), routing_.size(), &routing_count_);
size_t nodes = 0U, edges = 0U;
if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) {
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 &&
std::abs(old[j].top - position.top) < node_height + 12)
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)
break;
}
@ -117,6 +125,7 @@ void GraphWidget::set_graph(const xtc_graph *graph)
selected_node_ = SIZE_MAX;
if (edge_selected_ && !select_connection(selected_edge_.source_node, selected_edge_.destination_node))
edge_selected_ = false;
grow_canvas();
redraw();
}
@ -174,6 +183,7 @@ bool GraphWidget::select_node(size_t id)
return false;
selected_node_ = id;
edge_selected_ = false;
describe_selection();
redraw();
return true;
}
@ -197,6 +207,7 @@ bool GraphWidget::select_connection(size_t source, size_t destination)
selected_edge_ = edge;
edge_selected_ = true;
selected_node_ = SIZE_MAX;
describe_selection();
redraw();
return true;
}
@ -219,8 +230,10 @@ void GraphWidget::feedback(xtc_result result, const char *success)
std::snprintf(
text, sizeof(text),
"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
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);
}
@ -282,7 +295,7 @@ xtc_result GraphWidget::delete_selected()
return XTC_ERR_INVALID_STATE;
}
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;
}
@ -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) ||
!port_position(edge.destination_node, 0U, dx, dy))
continue;
bool selected = edge_selected_ && selected_edge_.source_node == edge.source_node &&
selected_edge_.destination_node == edge.destination_node;
fl_color(selected ? FL_YELLOW : fl_rgb_color(100, 200, 190));
bool selected = (edge_selected_ && selected_edge_.source_node == edge.source_node &&
selected_edge_.destination_node == edge.destination_node) ||
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(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);
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)
continue;
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_color(selected_node_ == node.id ? FL_YELLOW : fl_rgb_color(115, 130, 145));
fl_rect(left, top, node_width, node_height);
@ -368,6 +394,7 @@ void GraphWidget::draw()
fl_font(FL_HELVETICA_BOLD, 12);
char text[96];
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_font(FL_HELVETICA, 12);
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_INSTRUMENT ? "MIDI sine source"
: node.type == XTC_NODE_AUDIO ? "Timeline mix source"
: "Mono audio output");
: "Summed engine output");
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) {
int 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_pie(x() + px - 5, y() + py - 5, 10, 10, 0, 360);
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_) {
@ -420,8 +453,20 @@ int GraphWidget::handle(int event)
case FL_UNFOCUS:
return 1;
case FL_PUSH: {
if (Fl::event_button() != FL_LEFT_MOUSE)
return 0;
if (Fl::event_button() == FL_RIGHT_MOUSE) {
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();
mouse_x_ = left;
mouse_y_ = top;
@ -485,6 +530,16 @@ int GraphWidget::handle(int event)
}
return 1;
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) {
delete_selected();
return 1;
@ -500,3 +555,70 @@ int GraphWidget::handle(int 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.
*/
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.
@ -156,7 +160,10 @@ class GraphWidget : public Fl_Widget {
};
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_;
EditHandler handler_;
void *context_;

View file

@ -217,6 +217,8 @@ MainWindow::MainWindow(bool prompt_unsaved)
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);
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");
oscillator->callback(
[](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->sequencer_->set_project(replacement, false);
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;
window->refresh_status();
return XTC_OK;

View file

@ -63,6 +63,15 @@ SequencerWidget *timeline(Fl_Group *group)
}
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)
{
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"));
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")) {
reset();
MainWindow window(false);

View file

@ -72,6 +72,22 @@ int main(int argc, char **argv)
const xtc_graph *unchanged = canvas->graph();
CHECK(canvas->connect_ports({1U, 1U, 1U, 0U}) == XTC_ERR_GRAPH_CYCLE);
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);
pump();
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;
}