From ba5c2503a711eb9d22fd7e96d90a10d3e0e06736 Mon Sep 17 00:00:00 2001 From: hanez Date: Tue, 27 Oct 2026 16:00:29 +0100 Subject: [PATCH] Phase 23: refine routing inspection, signal-flow layout and atomic wire insertion --- Makefile | 8 ++ README.md | 4 + docs/GUI.md | 14 ++- docs/KNOWN_LIMITATIONS.md | 4 + docs/ROADMAP.md | 17 ++++ docs/ROUTING.md | 110 ++++++++++++++++++++ docs/USER_MANUAL.md | 9 ++ include/xtc/xtc_engine.h | 21 +++- src/graph/graph.c | 52 +++++++++- src/gui/graph_widget.cpp | 150 +++++++++++++++++++++++++--- src/gui/graph_widget.hpp | 9 +- src/gui/main_window.cpp | 4 +- tests/integration/test_gui.cpp | 53 ++++++++++ tests/integration/test_gui_jack.cpp | 16 +++ tests/unit/test_routing.c | 84 ++++++++++++++++ 15 files changed, 533 insertions(+), 22 deletions(-) create mode 100644 docs/ROUTING.md create mode 100644 tests/unit/test_routing.c diff --git a/Makefile b/Makefile index c6f1a30..7980f03 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 7c15c51..26337e2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/GUI.md b/docs/GUI.md index 77b626a..d025bce 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -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. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 9fe66ff..d3c684c 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d1e0e35..ce42e0a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/ROUTING.md b/docs/ROUTING.md new file mode 100644 index 0000000..e9d26a3 --- /dev/null +++ b/docs/ROUTING.md @@ -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. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index ec3976d..4e2641a 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -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). diff --git a/include/xtc/xtc_engine.h b/include/xtc/xtc_engine.h index 43a1082..79de4d0 100644 --- a/include/xtc/xtc_engine.h +++ b/include/xtc/xtc_engine.h @@ -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. * diff --git a/src/graph/graph.c b/src/graph/graph.c index a5ad397..e8d2aa2 100644 --- a/src/graph/graph.c +++ b/src/graph/graph.c @@ -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; +} diff --git a/src/gui/graph_widget.cpp b/src/gui/graph_widget.cpp index 5de655a..ca7ee1b 100644 --- a/src/gui/graph_widget.cpp +++ b/src/gui/graph_widget.cpp @@ -2,7 +2,9 @@ #include "xtc/xtc_plugin.h" #include #include +#include #include +#include #include #include #include @@ -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(ux*9-uy*5), ay-static_cast(uy*9+ux*5)); + fl_line(ax, ay, ax-static_cast(ux*9+uy*5), ay-static_cast(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 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(info.level) * 210; + positions_[i].top = 40 + static_cast(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); +} diff --git a/src/gui/graph_widget.hpp b/src/gui/graph_widget.hpp index ec0dc24..036e327 100644 --- a/src/gui/graph_widget.hpp +++ b/src/gui/graph_widget.hpp @@ -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 positions_{}; - size_t count_ = 0U; + size_t count_ = 0U, routing_count_ = 0U; + std::array routing_{}; + void describe_selection(); + void grow_canvas(); const xtc_graph *graph_; EditHandler handler_; void *context_; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index d29dbf6..9329feb 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -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(c)->auto_layout(); }, graph_widget_); + menu->add("Edit/Insert unity gain in selected wire", 0, [](Fl_Widget *, void *c) { (void)static_cast(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; diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 221d0ff..7e63a5d 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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(group->child(i))) return canvas; + if (auto *nested = dynamic_cast(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); diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp index 359bb0a..5861067 100644 --- a/tests/integration/test_gui_jack.cpp +++ b/tests/integration/test_gui_jack.cpp @@ -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); diff --git a/tests/unit/test_routing.c b/tests/unit/test_routing.c new file mode 100644 index 0000000..1646a72 --- /dev/null +++ b/tests/unit/test_routing.c @@ -0,0 +1,84 @@ +#include "xtc/xtc_engine.h" +#include "xtc/xtc_realtime.h" +#include +#include +#include + +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; +}