diff --git a/Makefile b/Makefile index 1d1343b..9f3e5c4 100644 --- a/Makefile +++ b/Makefile @@ -68,8 +68,12 @@ $(BUILD_DIR)/test_realtime: $(BUILD_DIR)/tests/unit/test_realtime.o $(BUILD_DIR) $(BUILD_DIR)/test_backend: $(BUILD_DIR)/tests/unit/test_backend.o $(BUILD_DIR)/libxtc.a $(CC) $(LDFLAGS) -o $@ $^ -lm -test: $(BACKEND_TEST) $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check +$(BUILD_DIR)/test_graph_edits: $(BUILD_DIR)/tests/unit/test_graph_edits.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm + +test: $(BUILD_DIR)/test_graph_edits $(BACKEND_TEST) $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check $(if $(BACKEND_TEST),./$(BACKEND_TEST),@echo "JACK fault tests skipped: JACK development files unavailable") + ./$(BUILD_DIR)/test_graph_edits ./$(BUILD_DIR)/test_realtime ./$(BUILD_DIR)/test_core ./$(BUILD_DIR)/test_offline @@ -94,14 +98,14 @@ endif ifeq ($(GUI_AVAILABLE),yes) gui: $(BUILD_DIR)/xtc -$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a +$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/libxtc.a $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) -lm test-gui: $(BUILD_DIR)/test_gui xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui ifeq ($(JACK_AVAILABLE),yes) -$(BUILD_DIR)/test_gui_jack: $(BUILD_DIR)/tests/integration/test_gui_jack.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a +$(BUILD_DIR)/test_gui_jack: $(BUILD_DIR)/tests/integration/test_gui_jack.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/libxtc.a $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm test-gui-jack: $(BUILD_DIR)/test_gui_jack @@ -112,7 +116,7 @@ test-gui-jack: @exit 1 endif -$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a +$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/libxtc.a $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm else test-gui-jack test-gui: @@ -132,5 +136,5 @@ install: all clean: rm -rf "$(BUILD_DIR)" --include $(CORE_OBJECTS:.o=.d) $(TEST_OBJECT:.o=.d) $(BUILD_DIR)/src/main.d $(BUILD_DIR)/tests/unit/test_offline.d $(BUILD_DIR)/tests/unit/test_realtime.d $(BUILD_DIR)/tests/unit/test_backend.d \ - $(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/tests/integration/test_jack.d $(BUILD_DIR)/tests/integration/test_gui.d $(BUILD_DIR)/tests/integration/test_gui_jack.d +-include $(CORE_OBJECTS:.o=.d) $(TEST_OBJECT:.o=.d) $(BUILD_DIR)/src/main.d $(BUILD_DIR)/tests/unit/test_offline.d $(BUILD_DIR)/tests/unit/test_realtime.d $(BUILD_DIR)/tests/unit/test_backend.d $(BUILD_DIR)/tests/unit/test_graph_edits.d \ + $(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/src/gui/graph_widget.d $(BUILD_DIR)/tests/integration/test_jack.d $(BUILD_DIR)/tests/integration/test_gui.d $(BUILD_DIR)/tests/integration/test_gui_jack.d diff --git a/README.md b/README.md index ce7cffb..1d85a48 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This repository is a starter scaffold, not a production DAW. It currently provid - an optional JACK-compatible mono backend with start/stop, routing and status; - safe control/audio plan handoff and concurrent replacement tests; - an optional FLTK shell with engine controls, view switching and backend status; +- a modular graph editor with transactional edits and live plan adoption status; - architecture, format, GUI, roadmap, and decision documents. The JACK backend has passed software-loopback testing; physical-device playback @@ -52,10 +53,14 @@ Run the GUI: ``` The GUI opens JACK and offers Play/Stop/Reconnect, backend status and view -switching. Play starts the engine in silence; playback content and editors are -not implemented. F5 starts, F8 stops, Space toggles, F6 reconnects, and Ctrl+1/2/3 +switching. The initial empty graph is silent. Modular Graph supports adding, +moving and deleting oscillator/gain/meter/output nodes and connecting ports. +Sequencer playback and the mixer editor are not implemented. F5 starts, F8 stops, Space toggles, F6 reconnects, and Ctrl+1/2/3 switches views. Ctrl+Q closes the client and window. No physical ports are -connected automatically. See [GUI documentation](docs/GUI.md). +connected automatically. In Modular Graph, drag output to input to connect; +select a node or wire and press Delete to remove it. Invalid edits leave the +active graph intact; queued changes are labeled until the engine adopts them. +See [GUI documentation](docs/GUI.md). With JACK development files and an existing JACK-compatible server, run the isolated software-loopback test with `make test-jack`. It connects only test clients. See [engine documentation](docs/AUDIO_ENGINE.md) for configuration, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 55b258a..0e96038 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,7 +28,7 @@ stable node IDs, topological order, precomputed input adjacency, per-node mono buffers and oscillator/meter state. Each plan has one processing owner. Graph edits and destruction cannot affect an existing plan. All nodes are scheduled, including disconnected nodes; only explicit output nodes are rendered (or -terminal nodes when no output node exists). See AUDIO_ENGINE.md for contracts. +terminal nodes for legacy callers that do not require explicit outputs). See AUDIO_ENGINE.md for contracts. `xtc_realtime` now provides a one-pending/one-retired handoff around an exclusively audio-owned active plan. `xtc_backend` is the C backend boundary; the optional JACK implementation drives that same realtime engine. @@ -51,4 +51,20 @@ on the GUI thread. A GUI timer reads snapshots and collects retired resources; no audio callback calls FLTK. Successful window close first closes the JACK client (synchronizing callbacks), then removes the GUI timer and hides the window. Failed close leaves both handle and window available for retry. View -switching is GUI state only; editors have no domain mutations yet. See GUI.md. +switching and node positions are GUI state only. Semantic edits use transactional +C commands and publish independent compiled snapshots. See GUI.md. + +## Editable graph and canvas + +Phase 4 adds read-only C snapshots, monotonic node IDs, and a small set of C +edit commands. The GUI owns an opaque graph handle, never private arrays. +An edit produces a candidate, and the GUI commits it only after backend submit +succeeds (or locally while offline). A busy pending slot or invalid edit leaves +the prior model/plan unchanged. Active/submitted revisions distinguish the +queued drawing from the engine's acknowledged plan. Empty/output-less editor +graphs render silence; legacy offline terminal fallback remains available. + +GraphWidget owns only selection, node positions, pointer gestures and drawing. +Its fixed canvas is hosted by FLTK scrolling. C owns nodes, ports, edges, +validation, ID assignment, compilation and rendering. No audio thread accesses +GUI-owned state. Reconnect republishes the same retained editable graph. diff --git a/docs/AUDIO_ENGINE.md b/docs/AUDIO_ENGINE.md index 83750d4..6a3b931 100644 --- a/docs/AUDIO_ENGINE.md +++ b/docs/AUDIO_ENGINE.md @@ -28,8 +28,8 @@ tempo automation. ## Phase 1 offline API -`xtc_graph_create` accepts 1..256 nodes; IDs are insertion indexes, stable for -that graph's lifetime. Supported nodes are gain (any finite multiplier), sine +`xtc_graph_create` accepts 1..256 nodes; IDs are monotonic stable identifiers, never reused +within a graph's lifetime. Enumeration indexes are dense and distinct from IDs. Supported nodes are gain (any finite multiplier), sine oscillator (finite nonnegative Hz), pass-through peak meter and audio output. Ports carry mono float audio: input 0, output 1. Oscillators have no input and output nodes have no output. Port lookup reports direction, signal type and @@ -39,7 +39,7 @@ edit becomes visible. Fan-out copies a source to consumers; fan-in sums inputs without normalization. Failed additions leave IDs and graph contents unchanged. `xtc_graph_compile(graph, sample_rate, max_frames, &plan)` runs on the control -thread. Empty graphs are rejected. Sample rate must be finite and positive, +thread. Empty graphs compile to silence. Sample rate must be finite and positive, oscillators must be at or below Nyquist, and max_frames is 1..65536. Compilation allocates and copies topology/parameters, builds a topological schedule and input adjacency, and allocates one max_frames float buffer per node. The @@ -56,7 +56,9 @@ with processing, including reset, meter reads or destruction. Unconnected non-oscillator nodes receive external input. Connected inputs receive only the sum of their upstream buffers. Explicit output nodes are summed to the caller's output. If none exist, terminal nodes are summed for -compatibility with the original gain-chain API. Disconnected components cannot +compatibility with the original gain-chain API, unless +`xtc_graph_require_output_nodes(graph, 1)` is set. The GUI uses that policy; +no output nodes means silence. Disconnected components cannot contribute when explicit outputs exist, but they are still processed. Floating point overflow and nonfinite input become silence per sample. No clipping or normalization is applied to finite samples. @@ -103,7 +105,10 @@ Use `xtc_backend.h` on one serialized control thread: between enumeration and connection; route failures are returned as I/O errors. 4. During playback, submit edited graphs and periodically collect retired resources. Capacity means one pending graph is still awaiting adoption; - keep the current graph and retry later. get_status is a nonblocking snapshot + keep the current graph and retry later. Stopped submissions instead compile + a replacement engine off-thread and swap only after success, while callbacks + are quiescent. Its block/revision counters restart and oscillator phase resets; + no edit is blocked behind a plan that cannot be adopted until playback. get_status is a nonblocking snapshot of rate, frames, running/server-loss/rate-change flags, xruns, processed blocks and last process result. No active DSP memory is exposed. 5. Stop pauses the retained DSP state after deactivation. Restart resumes it. @@ -133,4 +138,46 @@ test README. Current limitations: mono input/output, no parameter ramping or crossfaded graph changes, no realtime meter bridge, no automatic reconnect, no physical-device or long-duration endurance qualification. Phase 3 connects the FLTK shell to backend controls and status; see GUI.md. Its editors remain -placeholders and the GUI submits no playback graph yet. +placeholders except the Phase 4 modular editor described below. + +## Phase 4 graph snapshots and edits + +The control-thread API exposes copies through get_counts, get_node(index) and +get_connection(index), including node IDs/types/values and port endpoints. No +mutable graph/engine storage is exposed. Node IDs increase monotonically; +removing a node compacts private storage and adjusts private edge indexes but +leaves surviving IDs unchanged. Exhausted ID space returns capacity. Compiled +meter lookup resolves stable IDs too. + +`xtc_graph_apply_edit(source, edit, &candidate, &added_id)` accepts ADD_NODE, +REMOVE_NODE, CONNECT or DISCONNECT. It copies the source, validates the edit, +and returns a caller-owned candidate on success. On failure candidate is NULL, +source and added_id are unchanged. added_id is optional and written only for +ADD_NODE. ADD_NODE validates type and the used parameter (finite gain, or finite +nonnegative oscillator Hz). Meter/output do not use the value field. Compile +additionally checks the oscillator against the actual sample rate. Removing +a node removes its incident connections; disconnecting a nonexistent edge +returns not-found. Missing nodes/ports, reversed directions, duplicate edges, +cycles and unknown command/type values are rejected without visible edits. + +The GUI keeps a 64-node graph requiring explicit output nodes. It creates a +candidate, submits that exact candidate to the backend, then swaps its owned +graph and refreshes the canvas only if submission succeeds. Failed compile or +pending-slot pressure destroys the candidate and retains both the displayed +graph and previously submitted engine plan. When disconnected at startup, +edits can be stored locally and are submitted on reconnect. Lost/changed active +backends reject edits until reconnect. Reconnect submits the retained graph. + +Submitted/active revision counters expose adoption without exposing plan +pointers. A submission increments the submitted revision; only the audio +consumer acknowledges the revision when adopting the plan at a block boundary. +The editor shows prepared while stopped, queued while an older plan is active, +and active when those revisions agree. Atomic fields remain independent +snapshots and wrap modulo UINT_MAX + 1. Rebuilding a stopped engine or reopening +a backend starts a new counter epoch. See decision 0004 for slot ordering. + +Movement, selection and scroll position are GUI-only state; they never compile +or publish audio plans. Empty graphs and graphs without explicit outputs are +silent under the GUI policy. Terminal fallback remains the default for earlier +offline/standalone callers. Input on unconnected nodes retains the existing +external-input contract; the GUI does not connect external JACK ports. diff --git a/docs/BUILD.md b/docs/BUILD.md index e1b94af..cd7da25 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -18,7 +18,7 @@ make gui core/offline tests, concurrent plan-handoff stress, JACK fault injection (when JACK headers are available), and the startup check. `debug` builds and tests separately in `build/debug`. `gui` builds the FLTK shell with backend controls and status. -The shell starts a silent engine and never connects physical ports. Generated files remain under `build` and header changes trigger rebuilds. +The shell starts with a silent graph and never connects physical ports. Generated files remain under `build` and header changes trigger rebuilds. Compiler, flags, `BUILD_DIR`, `PKG_CONFIG`, `FLTK_CONFIG`, and installation `PREFIX` can be overridden through Make variables. C uses C99; GUI uses C++17. @@ -92,8 +92,25 @@ exit-time font allocations; see tests/integration/README.md for the distinction and command. No compiler warnings remain with GCC or Clang. Core/backend tests pass with unsuppressed ASan/UBSan. No packages or system configuration changed. +## Phase 4 verification + +```sh +make clean all test gui debug test-gui test-gui-jack +make BUILD_DIR=build/nojack PKG_CONFIG=false all test gui test-gui +``` + +The normal suite adds test_graph_edits for stable IDs, dense snapshots, +transactional rejection/deletion/disconnect and known rendered samples before +and after edits. Core/backend/realtime tests pass under unsuppressed Clang +ASan/UBSan; ThreadSanitizer passes the concurrent handoff test with revision-to- +gain checks. GUI ASan/UBSan tests pass with the existing fontconfig-only leak +suppression. Live GUI tests build a connected graph, adopt accepted edits during +playback, reject cycles, reconnect and close active clients. Mouse/visual smoke +checks cover node creation, wiring, cycle rejection, dragging and deletion. +No physical routes, new dependencies or system changes are introduced. + ## Next milestone -Phase 4 implements the modular graph editor and submits validated graph edits -through the C API. Physical playback/endurance, project persistence and -sequencing remain unverified/unimplemented as applicable. +Phase 5 adds versioned project persistence and the sequencer foundation, stable +track/clip IDs and timeline editing. Physical-device playback/endurance remains +unverified; graph parameters are fixed GUI presets and undo/zoom are not present. diff --git a/docs/GUI.md b/docs/GUI.md index 92727f9..764b395 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -20,10 +20,10 @@ not energyXT's exact artwork or proprietary resources. ## Phase 3 shell The shell opens JACK on startup through `xtc_backend.h`. It never accesses -backend structures, processes samples, creates domain objects, or calls JACK -itself. All backend control calls run on the GUI thread; FLTK is never called +backend structures, processes samples, or calls JACK itself. Graph domain +data is owned by opaque C objects, manipulated only through the C API. All backend control calls run on the GUI thread; FLTK is never called from an audio callback. No server is started and no audio ports are connected -by the GUI. With no graph submitted, the running backend produces silence. +by the GUI. The initially empty graph produces silence. - **Play / F5:** start the engine. **Stop / F8:** deactivate it. **Space:** toggle. These are engine controls, not timeline playback or transport positioning. @@ -32,7 +32,7 @@ by the GUI. With no graph submitted, the running backend produces silence. the handle and reports the failure instead of opening another client. - **View menu / Ctrl+1, Ctrl+2, Ctrl+3:** switch between sequencer, mixer and modular graph. Tabs provide the same view switching. Editors remain clearly - labeled placeholders; no project editing is implied. + labeled placeholders for sequencer/mixer; Modular Graph is implemented below. - **File / Quit / Ctrl+Q** or the window close button: close the backend and synchronize audio callbacks before hiding/destroying widgets. A failed close leaves the window open for a retry. Unsupported New/Open/Save actions are @@ -62,3 +62,40 @@ removal. `make test-gui-jack` additionally requires a running JACK-compatible server, tests repeated start/stop, reconnect and active-client close, and creates no routes. Physical playback is outside this shell. See BUILD.md and the integration test README for sanitizer limitations and keyboard smoke results. + +## Phase 4 modular editor + +Open **Modular Graph** or press **Ctrl+3**. Add oscillator (440 Hz), gain (0.10), +pass-through meter or output nodes with the toolbar. Their parameters are fixed +presets in this milestone and are drawn on each node; parameter editing and +live meter display are not implemented. Connect oscillator → gain → meter → +output for a simple test chain. No sound reaches hardware until ports are +explicitly connected outside the GUI. Output nodes are required to render; +isolated sources and an empty graph produce silence. + +Drag a node body/title to move it. Drag a circular output port to an input port +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 zoom, multiselection, undo, project persistence or external routing UI yet. +The editor supports 64 nodes on a fixed 2000x4000 canvas. + +Every semantic action becomes a C graph command. The window owns the graph +handle and publishes the C-created candidate through the backend. Cycle, +invalid-direction, duplicate connection, missing-port, capacity and backend +failures are shown below the canvas; failed edits leave the graph unchanged. +Capacity feedback distinguishes the possible node limit/pending edit condition +and allows retry. Editing while stopped replaces the prepared plan without +starting playback. Editing with a disconnected startup backend stays local +until reconnect. A lost or changed live backend requires reconnect before +further semantic edits. Moving/selecting nodes does not touch the engine. + +The graph state line distinguishes offline, prepared, queued and active states. +Queued explicitly means the drawing represents the next submitted graph while +the engine finishes the previous plan. The 100 ms GUI timer observes audio +adoption revisions and then shows active. Graphs are not edited in place on the +audio thread. There is no automatic retry/hidden edit queue; users retry a +rejected capacity edit. Plan replacement can reset oscillator phase and is not +crossfaded. Render validation and active replacement tests are described in +tests/integration/README.md. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 96b17a8..a299799 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -35,13 +35,21 @@ and shutdown through the C API. Implemented: dark FLTK shell, Play/Stop/Reconnect, view/menu shortcuts, backend state/format/xrun/process status, persistent command errors, timer-based refresh and retirement collection, and callback-safe close/retry. Mock-backend GUI tests -and live GUI/JACK start/stop/reconnect/close tests pass under Xvfb. Editors and -project-file actions remain placeholders; engine runs in silence. See GUI.md. +and live GUI/JACK start/stop/reconnect/close tests pass under Xvfb. Sequencer, +mixer and project-file actions remain placeholders; initial graph is silent. See GUI.md. ## Phase 4 — Modular editor Draw nodes/ports/edges; create, move and connect nodes; reject invalid edges; safely submit edits to the C core. +Implemented: scrollable node/port/wire canvas; add/select/move/delete nodes; +drag connections, select/delete wires, and show invalid-edit feedback. C owns +stable IDs, read-only snapshots and transactional edits. Exact accepted graph +snapshots are submitted to the backend; prepared/queued/active state is exposed +through revision acknowledgements. Core/render, GUI rollback, mouse smoke and +live adoption tests pass. See GUI.md and AUDIO_ENGINE.md. Preset parameters, +mono-only processing, fixed canvas and no persistence/undo/zoom remain limits. + ## Phase 5 — Project and sequencer Versioned persistence, stable IDs, tracks, clips, timeline, selection, zoom, scrolling, and transport synchronization. diff --git a/docs/decisions/0004-jack-and-plan-handoff.md b/docs/decisions/0004-jack-and-plan-handoff.md index ec92f6f..b834ff9 100644 --- a/docs/decisions/0004-jack-and-plan-handoff.md +++ b/docs/decisions/0004-jack-and-plan-handoff.md @@ -39,7 +39,7 @@ objects. There is no mutex or libatomic fallback in the callback path. One serialized control producer and one audio consumer share two pointer slots: - `pending`: producer publishes a fully compiled plan with release; consumer - exchanges it for NULL with acquire at the next eligible block boundary. + exchanges it for NULL with acquire/release at the next eligible block boundary. - `retired`: consumer releases its previous active plan into this slot only after its final access. Producer exchanges with NULL using acquire/release, then destroys that acknowledged plan on the control thread. @@ -82,3 +82,27 @@ crossfade. Smooth parameter ramps and click-free graph transitions are future wo No stereo, recording, MIDI, native PipeWire API, GUI engine controls or hardware configuration dialogs are part of this phase. + +## Phase 4 adoption acknowledgement and stopped editing + +The pending pointer has an associated atomic revision. Producer stores the +revision before release-publishing the pointer. Consumer acquire-loads a +non-NULL pending pointer, reads its revision, and only then releases that slot +with an acquire/release exchange to NULL. Thus producer cannot reuse the +revision field until the previous consumer read is complete. Consumer publishes +active_revision when selecting that plan; the GUI compares it with the +producer's submitted_revision. These are wrapping counters and individual +snapshots, not plan pointers or a multi-field transactional snapshot. + +While JACK is stopped, a backend submission builds a separate realtime engine, +submits/compiles the graph there, and replaces the retained engine only on +success. Process callbacks cannot run on an inactive client or after successful +deactivation, and notification callbacks do not access the engine pointer. +The control thread can therefore reclaim all old state safely. Counters and +oscillator phases restart for that new engine epoch. The standalone realtime +API keeps its one-pending-slot contract regardless of external stop state. + +The callback still adopts at most one plan and never allocates/frees. Revision +ordering is tested alongside the plan's rendered gain under 2,000 concurrent +submissions and ThreadSanitizer. Stopped compile failure and active pending +capacity are checked with backend fault injection. diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index b90b225..cbf2964 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -18,6 +18,7 @@ typedef struct { unsigned int buffer_frames; unsigned int xruns; /* Wraps modulo UINT_MAX + 1. */ unsigned int processed_blocks; + unsigned int submitted_revision, active_revision; int running; int server_lost; int sample_rate_changed; /* Sticky: close/reopen and resubmit to recover. */ @@ -36,7 +37,9 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_ xtc_result xtc_backend_close(xtc_backend *backend); xtc_result xtc_backend_start(xtc_backend *backend); xtc_result xtc_backend_stop(xtc_backend *backend); -/* Submit allowed stopped or running. Copies graph; capacity = pending slot busy. */ +/* Copies graph. While running, capacity means pending slot busy. + * While stopped, transactionally replaces all retained plans (counters/revisions + * restart); failed compilation leaves the previous engine intact. */ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph); void xtc_backend_collect(xtc_backend *backend); /* Snapshot fields are individually coherent, not a transactional snapshot. */ diff --git a/include/xtc/xtc_engine.h b/include/xtc/xtc_engine.h index 481599c..6a372c4 100644 --- a/include/xtc/xtc_engine.h +++ b/include/xtc/xtc_engine.h @@ -42,8 +42,39 @@ typedef struct { } xtc_port_info; /* Control-thread API. Limits: 256 nodes, mono audio only. IDs are stable - * insertion indexes. Output parameters are written only on success, except - * create/compile which set their output pointer to NULL on failure. */ + * 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. */ +typedef struct { size_t id; xtc_node_type type; float value; } xtc_node_info; +typedef struct { + size_t source_node, source_port, destination_node, destination_port; +} xtc_connection_info; +typedef enum { + XTC_EDIT_ADD_NODE = 1, XTC_EDIT_REMOVE_NODE, + XTC_EDIT_CONNECT, XTC_EDIT_DISCONNECT +} xtc_graph_edit_kind; +typedef struct { + xtc_graph_edit_kind kind; + union { + struct { xtc_node_type type; float value; } add; + size_t node_id; + xtc_connection_info connection; + } data; +} xtc_graph_edit; + +/* Read-only snapshots; indexes enumerate dense lists, distinct from stable IDs. + * No borrowed pointers into graph storage are exposed. */ +xtc_result xtc_graph_get_counts(const xtc_graph *graph, size_t *out_nodes, size_t *out_connections); +xtc_result xtc_graph_get_node(const xtc_graph *graph, size_t index, xtc_node_info *out_node); +xtc_result xtc_graph_get_connection(const xtc_graph *graph, size_t index, xtc_connection_info *out_connection); +/* Set required=1 for explicit output nodes only (none means silence). + * Default 0 retains terminal-node fallback for original offline callers. */ +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. */ +xtc_result xtc_graph_apply_edit(const xtc_graph *source, const xtc_graph_edit *edit, + xtc_graph **out_graph, size_t *out_added_id); xtc_result xtc_graph_create(size_t max_nodes, xtc_graph **out_graph); void xtc_graph_destroy(xtc_graph *graph); xtc_result xtc_graph_add_gain(xtc_graph *graph, float gain, size_t *out_node_id); @@ -70,7 +101,8 @@ void xtc_plan_destroy(xtc_plan *plan); * No allocation, I/O, locking or graph validation. NULL input means silence. * output must hold frame_count floats; input may equal output. On invalid * arguments output and DSP state are unchanged. Zero frames is a no-op. - * Sums explicit output nodes; if absent, sums terminal nodes for compatibility. + * Sums explicit output nodes; if absent, sums terminal nodes for compatibility + * unless require_output_nodes is set. Empty graphs produce silence. * Unconnected gain/meter/output inputs receive external input. */ xtc_result xtc_plan_process(xtc_plan *plan, const float *input, float *output, size_t frame_count); diff --git a/include/xtc/xtc_realtime.h b/include/xtc/xtc_realtime.h index 6f032cc..97bb838 100644 --- a/include/xtc/xtc_realtime.h +++ b/include/xtc/xtc_realtime.h @@ -9,6 +9,7 @@ typedef struct xtc_realtime xtc_realtime; typedef struct { unsigned int processed_blocks; /* Wraps modulo UINT_MAX + 1. */ xtc_result last_result; + unsigned int submitted_revision, active_revision; /* Wrap like block counter. */ } xtc_realtime_status; /* Control thread only. A single control producer and single audio consumer. diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 994000e..652e5ac 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -151,6 +151,19 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) if (!backend->ready) return XTC_ERR_INVALID_STATE; if (__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) || __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE; + if (!backend->running) { + xtc_realtime *replacement = NULL; + xtc_result result = xtc_realtime_create((double)backend->expected_rate, backend->max_frames, &replacement); + if (result != XTC_OK) return result; + result = xtc_realtime_submit(replacement, graph); + if (result != XTC_OK) { xtc_realtime_destroy(replacement); return result; } + /* Inactive client, or successful deactivate: process callback quiescent. + * Notifications never access engine. Preserve old engine until ready. */ + xtc_realtime_destroy(backend->engine); + backend->engine = replacement; + __atomic_store_n(&backend->process_result, (unsigned int)XTC_OK, __ATOMIC_RELAXED); + return XTC_OK; + } return xtc_realtime_submit(backend->engine, graph); } void xtc_backend_collect(xtc_backend *backend) @@ -168,6 +181,8 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status out_status->sample_rate_changed = (int)__atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE); out_status->running = backend->running && !out_status->server_lost; out_status->processed_blocks = engine_status.processed_blocks; + out_status->submitted_revision = engine_status.submitted_revision; + out_status->active_revision = engine_status.active_revision; out_status->process_result = (xtc_result)__atomic_load_n(&backend->process_result, __ATOMIC_RELAXED); return XTC_OK; } diff --git a/src/engine/offline_engine.c b/src/engine/offline_engine.c index b708f59..819cc2c 100644 --- a/src/engine/offline_engine.c +++ b/src/engine/offline_engine.c @@ -35,13 +35,13 @@ xtc_result xtc_graph_compile(const xtc_graph *graph, double sample_rate, { xtc_plan *plan; xtc_result result; - size_t node, edge, cursor = 0U; + size_t node, edge, storage_nodes, cursor = 0U; int has_output = 0; if (out_plan == NULL) return XTC_ERR_INVALID_ARGUMENT; *out_plan = NULL; if (graph == NULL || !isfinite(sample_rate) || sample_rate <= 0.0 || max_frames == 0U || max_frames > XTC_MAX_FRAMES) return XTC_ERR_INVALID_ARGUMENT; - if (graph->node_count == 0U) return XTC_ERR_INVALID_STATE; + storage_nodes = graph->node_count == 0U ? 1U : graph->node_count; for (node = 0U; node < graph->node_count; ++node) { if (graph->nodes[node].type == XTC_NODE_OSCILLATOR && (double)graph->nodes[node].value > sample_rate * 0.5) @@ -55,14 +55,14 @@ xtc_result xtc_graph_compile(const xtc_graph *graph, double sample_rate, plan->max_frames = max_frames; plan->sample_rate = sample_rate; plan->input_gain = 1.0f; - plan->nodes = calloc(plan->node_count, sizeof(*plan->nodes)); - plan->order = calloc(plan->node_count, sizeof(*plan->order)); + plan->nodes = calloc(storage_nodes, sizeof(*plan->nodes)); + plan->order = calloc(storage_nodes, sizeof(*plan->order)); plan->offsets = calloc(plan->node_count + 1U, sizeof(*plan->offsets)); plan->sources = calloc(graph->edge_count + 1U, sizeof(*plan->sources)); - plan->render = calloc(plan->node_count, sizeof(*plan->render)); - plan->buffers = calloc(plan->node_count * max_frames, sizeof(*plan->buffers)); - plan->peaks = calloc(plan->node_count, sizeof(*plan->peaks)); - plan->phases = calloc(plan->node_count, sizeof(*plan->phases)); + plan->render = calloc(storage_nodes, sizeof(*plan->render)); + plan->buffers = calloc(storage_nodes * max_frames, sizeof(*plan->buffers)); + plan->peaks = calloc(storage_nodes, sizeof(*plan->peaks)); + plan->phases = calloc(storage_nodes, sizeof(*plan->phases)); if (plan->nodes == NULL || plan->order == NULL || plan->offsets == NULL || plan->sources == NULL || plan->render == NULL || plan->buffers == NULL || plan->peaks == NULL || plan->phases == NULL) { @@ -80,7 +80,7 @@ xtc_result xtc_graph_compile(const xtc_graph *graph, double sample_rate, plan->sources[cursor++] = graph->edges[edge].source; if (graph->edges[edge].source == node) terminal = 0; } - plan->render[node] = (unsigned char)(has_output ? + plan->render[node] = (unsigned char)(has_output || graph->require_output_nodes ? plan->nodes[node].type == XTC_NODE_OUTPUT : terminal); } plan->offsets[plan->node_count] = cursor; @@ -99,7 +99,11 @@ xtc_result xtc_plan_reset(xtc_plan *plan) xtc_result xtc_plan_meter_peak(const xtc_plan *plan, size_t node_id, float *out_peak) { if (plan == NULL || out_peak == NULL) return XTC_ERR_INVALID_ARGUMENT; - if (node_id >= plan->node_count) return XTC_ERR_NOT_FOUND; + size_t index; + for (index = 0U; index < plan->node_count; ++index) + if (plan->nodes[index].id == node_id) break; + if (index == plan->node_count) return XTC_ERR_NOT_FOUND; + node_id = index; if (plan->nodes[node_id].type != XTC_NODE_METER) return XTC_ERR_INVALID_ARGUMENT; *out_peak = plan->peaks[node_id]; return XTC_OK; diff --git a/src/engine/realtime.c b/src/engine/realtime.c index 711df70..0ca4a20 100644 --- a/src/engine/realtime.c +++ b/src/engine/realtime.c @@ -11,6 +11,7 @@ struct xtc_realtime { xtc_plan *active; /* Audio owner only, until consumer is stopped/joined. */ unsigned int blocks; unsigned int result; + unsigned int submitted_revision, pending_revision, active_revision; }; xtc_result xtc_realtime_create(double sample_rate, size_t max_frames, @@ -59,6 +60,11 @@ xtc_result xtc_realtime_submit(xtc_realtime *engine, const xtc_graph *graph) return XTC_ERR_CAPACITY; result = xtc_graph_compile(graph, engine->sample_rate, engine->max_frames, &plan); if (result != XTC_OK) return result; + { + unsigned int revision = __atomic_load_n(&engine->submitted_revision, __ATOMIC_RELAXED) + 1U; + __atomic_store_n(&engine->pending_revision, revision, __ATOMIC_RELAXED); + __atomic_store_n(&engine->submitted_revision, revision, __ATOMIC_RELAXED); + } __atomic_store_n(&engine->pending, plan, __ATOMIC_RELEASE); return XTC_OK; } @@ -68,6 +74,8 @@ xtc_result xtc_realtime_get_status(const xtc_realtime *engine, { if (engine == NULL || out_status == NULL) return XTC_ERR_INVALID_ARGUMENT; out_status->processed_blocks = __atomic_load_n(&engine->blocks, __ATOMIC_RELAXED); + out_status->submitted_revision = __atomic_load_n(&engine->submitted_revision, __ATOMIC_RELAXED); + out_status->active_revision = __atomic_load_n(&engine->active_revision, __ATOMIC_RELAXED); out_status->last_result = (xtc_result)__atomic_load_n(&engine->result, __ATOMIC_RELAXED); return XTC_OK; } @@ -85,10 +93,15 @@ xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, /* A full retirement slot delays adoption without delaying processing. * acquire pairs with control's release exchange after its last read. */ if (__atomic_load_n(&engine->retired, __ATOMIC_ACQUIRE) == NULL) { - xtc_plan *next = __atomic_exchange_n(&engine->pending, NULL, __ATOMIC_ACQUIRE); + xtc_plan *next = __atomic_load_n(&engine->pending, __ATOMIC_ACQUIRE); if (next != NULL) { xtc_plan *old = engine->active; + /* Read revision BEFORE releasing the pending slot. The producer + * cannot reuse pending_revision until its acquire observes NULL. */ + unsigned int revision = __atomic_load_n(&engine->pending_revision, __ATOMIC_RELAXED); + (void)__atomic_exchange_n(&engine->pending, NULL, __ATOMIC_ACQ_REL); engine->active = next; + __atomic_store_n(&engine->active_revision, revision, __ATOMIC_RELAXED); /* No reference to old is used after this release. */ __atomic_store_n(&engine->retired, old, __ATOMIC_RELEASE); } diff --git a/src/graph/graph.c b/src/graph/graph.c index 3e0ca6e..9262095 100644 --- a/src/graph/graph.c +++ b/src/graph/graph.c @@ -1,6 +1,7 @@ #include "graph_internal.h" #include #include +#include xtc_result xtc_graph_create(size_t max_nodes, xtc_graph **out_graph) { @@ -30,15 +31,23 @@ void xtc_graph_destroy(xtc_graph *graph) } } +static size_t node_index(const xtc_graph *graph, size_t id) +{ + size_t i; + for (i = 0U; i < graph->node_count; ++i) if (graph->nodes[i].id == id) return i; + return SIZE_MAX; +} + static xtc_result add_node(xtc_graph *graph, xtc_node_type type, float value, size_t *out_node_id) { if (graph == NULL || out_node_id == NULL || !isfinite(value)) return XTC_ERR_INVALID_ARGUMENT; - if (graph->node_count == graph->max_nodes) return XTC_ERR_CAPACITY; + if (graph->node_count == graph->max_nodes || graph->next_id == SIZE_MAX) return XTC_ERR_CAPACITY; graph->nodes[graph->node_count].type = type; graph->nodes[graph->node_count].value = value; - *out_node_id = graph->node_count++; + graph->nodes[graph->node_count++].id = graph->next_id; + *out_node_id = graph->next_id++; return XTC_OK; } xtc_result xtc_graph_add_gain(xtc_graph *graph, float gain, size_t *out_node_id) @@ -58,7 +67,8 @@ xtc_result xtc_graph_get_port(const xtc_graph *graph, size_t node_id, { xtc_node_type type; if (graph == NULL || out_port == NULL) return XTC_ERR_INVALID_ARGUMENT; - if (node_id >= graph->node_count) return XTC_ERR_NOT_FOUND; + node_id = node_index(graph, node_id); + if (node_id == SIZE_MAX) return XTC_ERR_NOT_FOUND; type = graph->nodes[node_id].type; if (port_id > 1U || (port_id == 0U && type == XTC_NODE_OSCILLATOR) || (port_id == 1U && type == XTC_NODE_OUTPUT)) return XTC_ERR_NOT_FOUND; @@ -108,6 +118,8 @@ xtc_result xtc_graph_connect_ports(xtc_graph *graph, size_t source_node, if (source.direction != XTC_PORT_OUTPUT || dest.direction != XTC_PORT_INPUT || source.signal_type != dest.signal_type || source.channel_count != dest.channel_count) return XTC_ERR_INVALID_ARGUMENT; + source_node = node_index(graph, source_node); + destination_node = node_index(graph, destination_node); for (i = 0U; i < graph->edge_count; ++i) if (graph->edges[i].source == source_node && graph->edges[i].destination == destination_node) return XTC_ERR_INVALID_STATE; @@ -120,3 +132,115 @@ xtc_result xtc_graph_connect_ports(xtc_graph *graph, size_t source_node, } xtc_result xtc_graph_connect(xtc_graph *graph, size_t source_node, size_t destination_node) { return xtc_graph_connect_ports(graph, source_node, 1U, destination_node, 0U); } + +xtc_result xtc_graph_get_counts(const xtc_graph *graph, size_t *out_nodes, size_t *out_connections) +{ + if (graph == NULL || out_nodes == NULL || out_connections == NULL) return XTC_ERR_INVALID_ARGUMENT; + *out_nodes = graph->node_count; + *out_connections = graph->edge_count; + return XTC_OK; +} +xtc_result xtc_graph_get_node(const xtc_graph *graph, size_t index, xtc_node_info *out_node) +{ + if (graph == NULL || out_node == NULL) return XTC_ERR_INVALID_ARGUMENT; + if (index >= graph->node_count) return XTC_ERR_NOT_FOUND; + out_node->id = graph->nodes[index].id; + out_node->type = graph->nodes[index].type; + out_node->value = graph->nodes[index].value; + return XTC_OK; +} +xtc_result xtc_graph_get_connection(const xtc_graph *graph, size_t index, xtc_connection_info *out_connection) +{ + if (graph == NULL || out_connection == NULL) return XTC_ERR_INVALID_ARGUMENT; + if (index >= graph->edge_count) return XTC_ERR_NOT_FOUND; + out_connection->source_node = graph->nodes[graph->edges[index].source].id; + out_connection->destination_node = graph->nodes[graph->edges[index].destination].id; + out_connection->source_port = 1U; + out_connection->destination_port = 0U; + return XTC_OK; +} +xtc_result xtc_graph_require_output_nodes(xtc_graph *graph, int required) +{ + if (graph == NULL || (required != 0 && required != 1)) return XTC_ERR_INVALID_ARGUMENT; + graph->require_output_nodes = required; + return XTC_OK; +} +static xtc_result remove_node(xtc_graph *graph, size_t id) +{ + size_t index = node_index(graph, id), i, count = 0U; + if (index == SIZE_MAX) return XTC_ERR_NOT_FOUND; + for (i = 0U; i < graph->edge_count; ++i) { + xtc_edge edge = graph->edges[i]; + if (edge.source == index || edge.destination == index) continue; + if (edge.source > index) --edge.source; + if (edge.destination > index) --edge.destination; + graph->edges[count++] = edge; + } + graph->edge_count = count; + memmove(graph->nodes + index, graph->nodes + index + 1U, + (graph->node_count - index - 1U) * sizeof(*graph->nodes)); + --graph->node_count; + return XTC_OK; +} +static xtc_result disconnect_ports(xtc_graph *graph, const xtc_connection_info *connection) +{ + size_t i; + xtc_port_info port; + xtc_result result = xtc_graph_get_port(graph, connection->source_node, connection->source_port, &port); + if (result != XTC_OK) return result; + if (port.direction != XTC_PORT_OUTPUT) return XTC_ERR_INVALID_ARGUMENT; + result = xtc_graph_get_port(graph, connection->destination_node, connection->destination_port, &port); + if (result != XTC_OK) return result; + if (port.direction != XTC_PORT_INPUT) return XTC_ERR_INVALID_ARGUMENT; + for (i = 0U; i < graph->edge_count; ++i) { + if (graph->edges[i].source == node_index(graph, connection->source_node) && + graph->edges[i].destination == node_index(graph, connection->destination_node)) { + memmove(graph->edges + i, graph->edges + i + 1U, + (graph->edge_count - i - 1U) * sizeof(*graph->edges)); + --graph->edge_count; + return XTC_OK; + } + } + return XTC_ERR_NOT_FOUND; +} +xtc_result xtc_graph_apply_edit(const xtc_graph *source, const xtc_graph_edit *edit, + xtc_graph **out_graph, size_t *out_added_id) +{ + xtc_graph *graph; + xtc_result result; + size_t added_id = 0U; + if (out_graph == NULL) return XTC_ERR_INVALID_ARGUMENT; + *out_graph = NULL; + if (source == NULL || edit == NULL) return XTC_ERR_INVALID_ARGUMENT; + result = xtc_graph_create(source->max_nodes, &graph); + if (result != XTC_OK) return result; + graph->node_count = source->node_count; + graph->edge_count = source->edge_count; + graph->next_id = source->next_id; + graph->require_output_nodes = source->require_output_nodes; + memcpy(graph->nodes, source->nodes, source->node_count * sizeof(*graph->nodes)); + memcpy(graph->edges, source->edges, source->edge_count * sizeof(*graph->edges)); + switch (edit->kind) { + case XTC_EDIT_ADD_NODE: + switch (edit->data.add.type) { + case XTC_NODE_GAIN: result = xtc_graph_add_gain(graph, edit->data.add.value, &added_id); break; + case XTC_NODE_OSCILLATOR: result = xtc_graph_add_oscillator(graph, edit->data.add.value, &added_id); break; + case XTC_NODE_METER: result = xtc_graph_add_meter(graph, &added_id); break; + case XTC_NODE_OUTPUT: result = xtc_graph_add_output(graph, &added_id); break; + default: result = XTC_ERR_INVALID_ARGUMENT; break; + } + break; + case XTC_EDIT_REMOVE_NODE: result = remove_node(graph, edit->data.node_id); break; + case XTC_EDIT_CONNECT: + result = xtc_graph_connect_ports(graph, edit->data.connection.source_node, + edit->data.connection.source_port, edit->data.connection.destination_node, + edit->data.connection.destination_port); + break; + case XTC_EDIT_DISCONNECT: result = disconnect_ports(graph, &edit->data.connection); break; + default: result = XTC_ERR_INVALID_ARGUMENT; break; + } + if (result != XTC_OK) { xtc_graph_destroy(graph); return result; } + if (out_added_id != NULL && edit->kind == XTC_EDIT_ADD_NODE) *out_added_id = added_id; + *out_graph = graph; + return XTC_OK; +} diff --git a/src/graph/graph_internal.h b/src/graph/graph_internal.h index 87ee743..fb8ffdc 100644 --- a/src/graph/graph_internal.h +++ b/src/graph/graph_internal.h @@ -3,10 +3,11 @@ #include "xtc/xtc_engine.h" #define XTC_MAX_NODES 256U #define XTC_MAX_FRAMES 65536U -typedef struct { xtc_node_type type; float value; } xtc_node; +typedef struct { xtc_node_type type; float value; size_t id; } xtc_node; typedef struct { size_t source; size_t destination; } xtc_edge; struct xtc_graph { - size_t max_nodes, node_count, edge_count; + size_t max_nodes, node_count, edge_count, next_id; + int require_output_nodes; xtc_node *nodes; xtc_edge *edges; }; diff --git a/src/gui/graph_widget.cpp b/src/gui/graph_widget.cpp new file mode 100644 index 0000000..b5eb607 --- /dev/null +++ b/src/gui/graph_widget.cpp @@ -0,0 +1,260 @@ +#include "graph_widget.hpp" +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int node_width = 150, node_height = 74; +const char *node_name(xtc_node_type type) +{ + switch (type) { + case XTC_NODE_OSCILLATOR: return "Oscillator"; + case XTC_NODE_GAIN: return "Gain"; + case XTC_NODE_METER: return "Meter"; + case XTC_NODE_OUTPUT: return "Output"; + } + return "Unknown"; +} +} +GraphWidget::GraphWidget(int left, int top, int width, int height, const xtc_graph *graph, + EditHandler handler, void *context, Fl_Box *feedback_box) + : Fl_Widget(left, top, width, height), graph_(graph), handler_(handler), + context_(context), feedback_(feedback_box) +{ set_graph(graph); } + +void GraphWidget::set_graph(const xtc_graph *graph) +{ + graph_ = graph; + size_t nodes = 0U, edges = 0U; + if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) { count_ = 0U; redraw(); return; } + auto old = positions_; + size_t old_count = count_; + count_ = 0U; + for (size_t i = 0U; i < nodes && i < positions_.size(); ++i) { + xtc_node_info node{}; + if (xtc_graph_get_node(graph_, i, &node) != XTC_OK) continue; + Position position; + position.id = node.id; + bool found = false; + for (size_t j = 0U; j < old_count; ++j) + if (old[j].id == node.id) { position = old[j]; found = true; break; } + if (!found) { + for (size_t slot = 0U; slot <= positions_.size(); ++slot) { + position.left = 40 + static_cast(slot % 8U) * 210; + position.top = 40 + static_cast(slot / 8U) * 115; + bool occupied = false; + for (size_t j = 0U; j < old_count; ++j) + if (std::abs(old[j].left - position.left) < node_width + 12 && + std::abs(old[j].top - position.top) < node_height + 12) occupied = true; + if (!occupied) break; + } + } + positions_[count_++] = position; + } + int left, top; + if (!node_position(selected_node_, left, top)) selected_node_ = SIZE_MAX; + if (edge_selected_ && !select_connection(selected_edge_.source_node, selected_edge_.destination_node)) + edge_selected_ = false; + redraw(); +} +bool GraphWidget::node_position(size_t id, int &left, int &top) const +{ + for (size_t i = 0U; i < count_; ++i) if (positions_[i].id == id) { + left = positions_[i].left; top = positions_[i].top; return true; + } + return false; +} +bool GraphWidget::move_node(size_t id, int left, int top) +{ + for (size_t i = 0U; i < count_; ++i) if (positions_[i].id == id) { + positions_[i].left = std::max(12, std::min(left, w() - node_width - 12)); + positions_[i].top = std::max(12, std::min(top, h() - node_height - 12)); + redraw(); return true; + } + return false; +} +bool GraphWidget::select_node(size_t id) +{ + int left, top; + if (!node_position(id, left, top)) return false; + selected_node_ = id; edge_selected_ = false; redraw(); return true; +} +bool GraphWidget::select_connection(size_t source, size_t destination) +{ + size_t nodes, edges; + if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) return false; + for (size_t i = 0U; i < edges; ++i) { + xtc_connection_info edge{}; + if (xtc_graph_get_connection(graph_, i, &edge) == XTC_OK && + edge.source_node == source && edge.destination_node == destination) { + selected_edge_ = edge; edge_selected_ = true; selected_node_ = SIZE_MAX; redraw(); return true; + } + } + return false; +} +void GraphWidget::feedback(xtc_result result, const char *success) +{ + char text[256]; + if (result == XTC_OK) std::snprintf(text, sizeof(text), "%s", success); + else if (result == XTC_ERR_CAPACITY) + std::snprintf(text, sizeof(text), "Graph unchanged: capacity exceeded (node limit or pending edit). Retry after the next audio block."); + else std::snprintf(text, sizeof(text), "Graph unchanged: %s.", xtc_result_string(result)); + feedback_->copy_label(text); +} +xtc_result GraphWidget::add_node(xtc_node_type type) +{ + xtc_graph_edit edit{}; + edit.kind = XTC_EDIT_ADD_NODE; + edit.data.add.type = type; + edit.data.add.value = type == XTC_NODE_OSCILLATOR ? 440.0f : type == XTC_NODE_GAIN ? 0.1f : 0.0f; + size_t added = SIZE_MAX; + xtc_result result = handler_(context_, edit, &added); + if (result == XTC_OK) select_node(added); + feedback(result, "Node added. Drag its title to move; drag an output port to an input to connect."); + return result; +} +xtc_result GraphWidget::connect_ports(const xtc_connection_info &connection) +{ + xtc_graph_edit edit{}; + edit.kind = XTC_EDIT_CONNECT; + edit.data.connection = connection; + xtc_result result = handler_(context_, edit, nullptr); + feedback(result, "Connection added. Click a wire and press Delete to disconnect."); + return result; +} +xtc_result GraphWidget::delete_selected() +{ + xtc_graph_edit edit{}; + if (edge_selected_) { edit.kind = XTC_EDIT_DISCONNECT; edit.data.connection = selected_edge_; } + else if (selected_node_ != SIZE_MAX) { edit.kind = XTC_EDIT_REMOVE_NODE; edit.data.node_id = selected_node_; } + else { feedback_->copy_label("Select a node or wire first."); return XTC_ERR_INVALID_STATE; } + xtc_result result = handler_(context_, edit, nullptr); + feedback(result, "Selection deleted."); + return result; +} +bool GraphWidget::port_position(size_t id, size_t port, int &left, int &top) const +{ + xtc_port_info info{}; + if (xtc_graph_get_port(graph_, id, port, &info) != XTC_OK || !node_position(id, left, top)) return false; + left += info.direction == XTC_PORT_OUTPUT ? node_width : 0; + top += 48; + return true; +} +bool GraphWidget::hit_port(int left, int top, size_t &node, size_t &port) const +{ + for (size_t i = count_; i > 0U; --i) for (size_t p = 0U; p < 2U; ++p) { + int px, py; + if (port_position(positions_[i - 1U].id, p, px, py) && + std::abs(px - left) <= 9 && std::abs(py - top) <= 9) { + node = positions_[i - 1U].id; port = p; return true; + } + } + return false; +} +void GraphWidget::draw() +{ + fl_push_clip(x(), y(), w(), h()); + fl_color(fl_rgb_color(25, 28, 33)); fl_rectf(x(), y(), w(), h()); + size_t nodes = 0U, edges = 0U; + (void)xtc_graph_get_counts(graph_, &nodes, &edges); + for (size_t i = 0U; i < edges; ++i) { + xtc_connection_info edge{}; + int sx, sy, dx, dy; + 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)); + fl_line_style(FL_SOLID, selected ? 3 : 2); + fl_line(x() + sx, y() + sy, x() + dx, y() + dy); + } + fl_line_style(0); + for (size_t i = 0U; i < count_; ++i) { + xtc_node_info node{}; + 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_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); + fl_color(FL_WHITE); fl_font(FL_HELVETICA_BOLD, 12); + char text[96]; + std::snprintf(text, sizeof(text), "%s #%zu", node_name(node.type), node.id); + fl_draw(text, left + 10, top + 20); + fl_font(FL_HELVETICA, 12); + if (node.type == XTC_NODE_OSCILLATOR) std::snprintf(text, sizeof(text), "%.0f Hz", static_cast(node.value)); + else if (node.type == XTC_NODE_GAIN) std::snprintf(text, sizeof(text), "x %.2f", static_cast(node.value)); + else std::snprintf(text, sizeof(text), "%s", node.type == XTC_NODE_METER ? "Peak pass-through" : "Mono audio output"); + fl_draw(text, left + 10, top + 37); + for (size_t port = 0U; port < 2U; ++port) { + int px, py; + if (!port_position(node.id, port, px, py)) continue; + 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); + } + } + if (wiring_) { + int px, py; + if (port_position(wire_node_, wire_port_, px, py)) { + fl_color(FL_YELLOW); fl_line(x() + px, y() + py, x() + mouse_x_, y() + mouse_y_); + } + } + fl_pop_clip(); +} +int GraphWidget::handle(int event) +{ + const int left = Fl::event_x() - x(), top = Fl::event_y() - y(); + switch (event) { + case FL_FOCUS: case FL_UNFOCUS: return 1; + case FL_PUSH: { + if (Fl::event_button() != FL_LEFT_MOUSE) return 0; + take_focus(); mouse_x_ = left; mouse_y_ = top; + dragging_ = false; wiring_ = false; + if (hit_port(left, top, wire_node_, wire_port_)) { wiring_ = true; redraw(); return 1; } + for (size_t i = count_; i > 0U; --i) { + const Position &pos = positions_[i - 1U]; + if (left >= pos.left && left <= pos.left + node_width && top >= pos.top && top <= pos.top + node_height) { + select_node(pos.id); dragging_ = true; drag_x_ = left - pos.left; drag_y_ = top - pos.top; return 1; + } + } + size_t nodes, edges; + (void)xtc_graph_get_counts(graph_, &nodes, &edges); + for (size_t i = 0U; i < edges; ++i) { + xtc_connection_info edge{}; + int sx, sy, dx, dy; + (void)xtc_graph_get_connection(graph_, i, &edge); + if (!port_position(edge.source_node, 1U, sx, sy) || !port_position(edge.destination_node, 0U, dx, dy)) continue; + double vx = dx - sx, vy = dy - sy; + double length = vx * vx + vy * vy; + double t = length == 0.0 ? 0.0 : std::max(0.0, std::min(1.0, ((left - sx) * vx + (top - sy) * vy) / length)); + if (std::hypot(left - sx - t * vx, top - sy - t * vy) <= 6.0) { + select_connection(edge.source_node, edge.destination_node); return 1; + } + } + selected_node_ = SIZE_MAX; edge_selected_ = false; redraw(); return 1; + } + case FL_DRAG: + mouse_x_ = left; mouse_y_ = top; + if (dragging_) move_node(selected_node_, left - drag_x_, top - drag_y_); + if (wiring_) redraw(); + return 1; + case FL_RELEASE: + dragging_ = false; + if (wiring_) { + size_t node, port; + wiring_ = false; + if (hit_port(left, top, node, port)) connect_ports({wire_node_, wire_port_, node, port}); + else feedback_->copy_label("Connection cancelled. Drag an output port to an input port."); + redraw(); + } + return 1; + case FL_KEYDOWN: + if (Fl::event_key() == FL_Delete || Fl::event_key() == FL_BackSpace) { delete_selected(); return 1; } + if (Fl::event_key() == FL_Escape) { wiring_ = dragging_ = false; redraw(); return 1; } + break; + default: break; + } + return Fl_Widget::handle(event); +} diff --git a/src/gui/graph_widget.hpp b/src/gui/graph_widget.hpp new file mode 100644 index 0000000..b4a2ee2 --- /dev/null +++ b/src/gui/graph_widget.hpp @@ -0,0 +1,44 @@ +#ifndef XTC_GRAPH_WIDGET_HPP +#define XTC_GRAPH_WIDGET_HPP +#include +#include +#include "xtc/xtc_engine.h" +class Fl_Box; + +/* GUI positions/selection only. Domain snapshots and edits stay in the C API. */ +class GraphWidget : public Fl_Widget { +public: + using EditHandler = xtc_result (*)(void *, const xtc_graph_edit &, size_t *); + GraphWidget(int x, int y, int width, int height, const xtc_graph *graph, + EditHandler handler, void *context, Fl_Box *feedback); + void set_graph(const xtc_graph *graph); + const xtc_graph *graph() const { return graph_; } // Borrowed, GUI thread only. + xtc_result add_node(xtc_node_type type); + xtc_result connect_ports(const xtc_connection_info &connection); + xtc_result delete_selected(); + bool select_node(size_t id); + bool select_connection(size_t source, size_t destination); + bool move_node(size_t id, int left, int top); + bool node_position(size_t id, int &left, int &top) const; + int handle(int event) override; +protected: + void draw() override; +private: + struct Position { size_t id = SIZE_MAX; int left = 0, top = 0; }; + std::array positions_{}; + size_t count_ = 0U; + const xtc_graph *graph_; + EditHandler handler_; + void *context_; + Fl_Box *feedback_; + size_t selected_node_ = SIZE_MAX; + xtc_connection_info selected_edge_{}; + bool edge_selected_ = false; + bool dragging_ = false, wiring_ = false; + size_t wire_node_ = SIZE_MAX, wire_port_ = 0U; + int drag_x_ = 0, drag_y_ = 0, mouse_x_ = 0, mouse_y_ = 0; + void feedback(xtc_result result, const char *success); + bool hit_port(int left, int top, size_t &node, size_t &port) const; + bool port_position(size_t id, size_t port, int &left, int &top) const; +}; +#endif diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 0d673b3..51b8f9a 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -1,15 +1,18 @@ #include "main_window.hpp" +#include "graph_widget.hpp" #include "xtc/xtc_version.h" #include #include #include #include +#include #include #include #include #include #include +#include namespace { void set_label(Fl_Box *box, const char *text) @@ -22,6 +25,12 @@ void set_label(Fl_Box *box, const char *text) MainWindow::MainWindow() : Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation") { + xtc_graph *initial_graph = nullptr; + xtc_result graph_result = xtc_graph_create(64U, &initial_graph); + if (graph_result != XTC_OK) throw std::runtime_error(xtc_result_string(graph_result)); + graph_.reset(initial_graph); + graph_result = xtc_graph_require_output_nodes(graph_.get(), 1); + if (graph_result != XTC_OK) throw std::runtime_error(xtc_result_string(graph_result)); begin(); auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28); menu->add("File/New (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE); @@ -45,7 +54,7 @@ MainWindow::MainWindow() indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); indicator_->labelfont(FL_BOLD); auto *hint = new Fl_Box(12, 78, 976, 24, - "Engine controls only. Playback content and recording are not implemented; no ports are connected automatically."); + "Edit signal flow in Modular Graph. No hardware ports are connected automatically; sequencer and recording are not implemented."); hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); hint->labelsize(12); @@ -61,7 +70,37 @@ MainWindow::MainWindow() mixer->end(); auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph"); graph->begin(); - new Fl_Box(24, 164, 940, 40, "Modular graph placeholder — visual graph editing is not implemented yet."); + graph_state_ = new Fl_Box(20, 182, 956, 24, "Graph: offline"); + graph_state_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + graph_state_->labelsize(12); + auto *feedback = new Fl_Box(20, 548, 956, 28, + "Add nodes. Drag output to input to connect; click a node or wire then Delete. Scroll to pan."); + feedback->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP); + feedback->labelsize(12); + auto *scroll = new Fl_Scroll(20, 212, 956, 328); + graph_widget_ = new GraphWidget(20, 212, 2000, 4000, graph_.get(), edit_graph, this, feedback); + scroll->end(); + auto *oscillator = new Fl_Button(20, 150, 130, 26, "Add oscillator"); + oscillator->callback([](Fl_Widget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_OSCILLATOR); + }, graph_widget_); + auto *gain = new Fl_Button(158, 150, 110, 26, "Add gain"); + gain->callback([](Fl_Widget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_GAIN); + }, graph_widget_); + auto *meter = new Fl_Button(276, 150, 110, 26, "Add meter"); + meter->callback([](Fl_Widget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_METER); + }, graph_widget_); + auto *output = new Fl_Button(394, 150, 110, 26, "Add output"); + output->callback([](Fl_Widget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_OUTPUT); + }, graph_widget_); + auto *remove = new Fl_Button(512, 150, 130, 26, "Delete selection"); + remove->callback([](Fl_Widget *, void *context) { + (void)static_cast(context)->delete_selected(); + }, graph_widget_); + graph->resizable(scroll); graph->end(); tabs_->end(); tabs_->value(sequencer); @@ -104,6 +143,22 @@ void MainWindow::report_error(const char *operation, xtc_result result) set_label(error_, text); } +xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, size_t *out_added_id) +{ + auto *window = static_cast(context); + xtc_graph *candidate = nullptr; + size_t added_id = SIZE_MAX; + xtc_result result = xtc_graph_apply_edit(window->graph_.get(), &edit, &candidate, &added_id); + if (result != XTC_OK) return result; + if (window->ready_) result = xtc_backend_submit(window->backend_, candidate); + if (result != XTC_OK) { xtc_graph_destroy(candidate); return result; } + window->graph_.reset(candidate); + window->graph_widget_->set_graph(candidate); + if (out_added_id != nullptr && edit.kind == XTC_EDIT_ADD_NODE) *out_added_id = added_id; + window->refresh_status(); + return XTC_OK; +} + void MainWindow::open_backend() { xtc_backend_config config = {XTC_BACKEND_JACK, XTC_PROJECT_NAME, 65536U, 1U}; @@ -111,7 +166,13 @@ void MainWindow::open_backend() ready_ = result == XTC_OK; running_ = false; if (result != XTC_OK) report_error("Open JACK failed", result); - else set_label(error_, ""); + else { + result = xtc_backend_submit(backend_, graph_.get()); + if (result != XTC_OK) { + ready_ = false; // Handle is retained for close/reconnect; graph was not installed. + report_error("Submit graph failed", result); + } else set_label(error_, ""); + } refresh_status(); } @@ -157,12 +218,14 @@ void MainWindow::refresh_status() set_label(indicator_, "Audio: disconnected"); set_label(status_, "JACK: not connected — use Reconnect after starting the server"); indicator_->labelcolor(fl_rgb_color(255, 155, 120)); + set_label(graph_state_, "Graph: offline — edits stored locally; Reconnect to play"); } else { xtc_backend_status status = {}; xtc_result result = xtc_backend_get_status(backend_, &status); if (result != XTC_OK) { report_error("Read engine status failed", result); set_label(indicator_, "Audio: status unavailable"); + set_label(graph_state_, "Graph: engine status unavailable"); set_label(status_, "Unable to read JACK status — use Reconnect"); indicator_->labelcolor(fl_rgb_color(255, 155, 120)); } else { @@ -172,7 +235,7 @@ void MainWindow::refresh_status() const char *state = status.server_lost ? "Audio: server lost — Reconnect required" : status.sample_rate_changed ? "Audio: sample rate changed — Reconnect required" : status.process_result != XTC_OK ? "Audio: processing error" : - running_ ? "Audio: running (silence — no playback content)" : "Audio: ready / stopped"; + running_ ? "Audio: running — modular graph" : "Audio: ready / stopped"; set_label(indicator_, state); indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120) : running_ ? fl_rgb_color(125, 225, 160) : fl_rgb_color(215, 215, 215)); @@ -181,6 +244,11 @@ void MainWindow::refresh_status() status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns, xtc_result_string(status.process_result)); set_label(status_, text); + if (fault) set_label(graph_state_, "Graph: engine unavailable — Reconnect required"); + else if (status.submitted_revision != status.active_revision) + set_label(graph_state_, running_ ? "Graph: queued — engine still processing previous graph" : + "Graph: prepared — starts on Play"); + else set_label(graph_state_, running_ ? "Graph: active in engine" : "Graph: stopped"); } xtc_backend_collect(backend_); } @@ -229,7 +297,7 @@ void MainWindow::view_callback(Fl_Widget *widget, void *view) } void MainWindow::about_callback(Fl_Widget *, void *) { - fl_message("%s %s\nModular audio workstation\nPhase 3: JACK engine controls; editors remain placeholders.", + fl_message("%s %s\nModular audio workstation\nPhase 4: modular graph editor; sequencer and mixer remain placeholders.", XTC_PROJECT_NAME, XTC_VERSION_STRING); } void MainWindow::timer_callback(void *context) diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 63f51d2..cb4f78b 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -2,8 +2,10 @@ #define XTC_MAIN_WINDOW_HPP #include +#include #include "xtc/xtc_backend.h" +class GraphWidget; class Fl_Box; class Fl_Button; class Fl_Tabs; @@ -18,6 +20,9 @@ public: int handle(int event) override; private: + std::unique_ptr graph_{nullptr, xtc_graph_destroy}; + GraphWidget *graph_widget_ = nullptr; + Fl_Box *graph_state_ = nullptr; xtc_backend *backend_ = nullptr; bool ready_ = false; bool running_ = false; @@ -29,6 +34,7 @@ private: Fl_Box *error_ = nullptr; Fl_Tabs *tabs_ = nullptr; + static xtc_result edit_graph(void *context, const xtc_graph_edit &edit, size_t *out_added_id); void open_backend(); void start_engine(); void stop_engine(); diff --git a/tests/integration/README.md b/tests/integration/README.md index 8458bed..374d5e4 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -63,3 +63,28 @@ CFLAGS/CXXFLAGS and LDFLAGS, using the baseline warning/include/standard flags from BUILD.md. Keep FLTK's `-I` and `-D` flags from `fltk-config --cxxflags`, but exclude the distribution's GCC-specific LTO/object-code flags for Clang. No suppressions are used for core/backend/realtime sanitizer tests. + +## Phase 4 graph editing coverage + +The same `make test-gui` target now adds nodes through toolbar callbacks, +connects the exact rendered graph, validates expected oscillator/gain samples +in the submitted backend plan, checks cycle/direction/duplicate rejection, +checks backend I/O/pending-capacity rollback, deletes wires and incident edges, +and verifies stable IDs, preserved positions, prepared/queued/active feedback +and empty-graph silence. The fake backend compiles submitted C snapshots rather +than accepting an unrelated GUI model. Move-only actions do not submit plans. + +`make test-gui-jack` exercises the same widget against real JACK: it builds a +graph while stopped, starts it, adds/deletes nodes during active processing, +waits for revision acknowledgement, and rejects cycles without changing the +model. Start/stop/reconnect and callback-safe active close remain covered. No +routes are created by this test. Core tests separately check known sample +changes after deleting/reconnecting nodes and meter lookup after ID compaction. + +A live xdotool mouse run under a 1280x800 Xvfb display created oscillator → gain +→ meter → output by dragging ports, observed a rejected feedback cycle, moved +a source with its wire attached, deleted a wire and node, and closed while +running. Visual inspection confirmed actual wires and active-state feedback, +with zero xruns reported during that smoke run. Zoom, multiselection, editable +parameters, project save/load, realtime meter visualization and hardware routing +controls are outside this phase. Node layout currently lasts only for a session. diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index ed94c4a..88bd729 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1,4 +1,5 @@ #include "../../src/gui/main_window.hpp" +#include "../../src/gui/graph_widget.hpp" #include #include #include @@ -8,15 +9,17 @@ #include #include #include +#include #include /* Fake only the public C backend boundary. Real FLTK widgets, callbacks and * timers run under Xvfb; no server/device is required or touched. */ -struct xtc_backend { int running = 0; }; +struct xtc_backend { int running = 0; xtc_plan *plan = nullptr; }; namespace { xtc_backend handle; xtc_result open_result = XTC_OK, start_result = XTC_OK, stop_result = XTC_OK, close_result = XTC_OK; -xtc_result status_result = XTC_OK; +xtc_result status_result = XTC_OK, submit_result = XTC_OK; +unsigned int submissions = 0U; xtc_backend_status engine_status = {}; bool partial_open = false; unsigned int opens = 0, starts = 0, stops = 0, closes = 0, collects = 0, alive = 0; @@ -55,6 +58,7 @@ void reset() { CHECK(alive == 0U); open_result = start_result = stop_result = close_result = status_result = XTC_OK; + submit_result = XTC_OK; engine_status = {}; engine_status.sample_rate = 48000U; engine_status.buffer_frames = 1024U; @@ -67,14 +71,14 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out) CHECK(config->kind == XTC_BACKEND_JACK && config->channel_count == 1U); ++opens; *out = open_result == XTC_OK || partial_open ? &handle : nullptr; - if (*out != nullptr) { ++alive; handle.running = 0; } + if (*out != nullptr) { ++alive; handle.running = 0; CHECK(handle.plan == nullptr); engine_status.active_revision = engine_status.submitted_revision = 0U; } return open_result; } xtc_result xtc_backend_close(xtc_backend *backend) { if (backend == nullptr) return XTC_OK; ++closes; - if (close_result == XTC_OK) { CHECK(alive == 1U); --alive; handle.running = 0; } + if (close_result == XTC_OK) { CHECK(alive == 1U); --alive; handle.running = 0; xtc_plan_destroy(handle.plan); handle.plan = nullptr; } return close_result; } xtc_result xtc_backend_start(xtc_backend *backend) @@ -88,6 +92,18 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status status->running = handle.running && !status->server_lost; return status_result; } +xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) +{ + CHECK(backend == &handle && graph != nullptr); + ++submissions; + if (submit_result != XTC_OK) return submit_result; + xtc_plan *plan = nullptr; + xtc_result result = xtc_graph_compile(graph, 48000.0, 32U, &plan); + if (result != XTC_OK) return result; + xtc_plan_destroy(handle.plan); handle.plan = plan; + ++engine_status.submitted_revision; + return XTC_OK; +} void xtc_backend_collect(xtc_backend *backend) { CHECK(backend == &handle && alive == 1U); ++collects; } } @@ -106,7 +122,7 @@ int main(int argc, char **argv) CHECK(contains(&window, "ready / stopped")); press(play); CHECK(starts == 1U && !play->active() && stop->active()); - CHECK(contains(&window, "running (silence")); + CHECK(contains(&window, "running — modular graph")); press(stop); CHECK(stops == 1U && play->active() && !stop->active()); start_result = XTC_ERR_IO; @@ -138,6 +154,68 @@ int main(int argc, char **argv) CHECK(std::strcmp(tabs->value()->label(), std::strchr(view, '/') + 1) == 0); } } + GraphWidget *canvas = nullptr; + auto *graph_group = dynamic_cast(find(&window, "Modular Graph")); + CHECK(graph_group != nullptr); + if (graph_group != nullptr) { + for (int i = 0; i < graph_group->children(); ++i) if (auto *scroll = dynamic_cast(graph_group->child(i))) + for (int j = 0; j < scroll->children(); ++j) + if (auto *found = dynamic_cast(scroll->child(j))) canvas = found; + } + CHECK(canvas != nullptr); + if (canvas == nullptr) return 1; + menu->picked(menu->find_item("View/Modular Graph")); + press(find(&window, "Add oscillator")); + press(find(&window, "Add gain")); + press(find(&window, "Add meter")); + press(find(&window, "Add output")); + size_t nodes, edges; + CHECK(xtc_graph_get_counts(canvas->graph(), &nodes, &edges) == XTC_OK && nodes == 4U && edges == 0U); + CHECK(canvas->connect_ports({0U, 1U, 1U, 0U}) == XTC_OK); + CHECK(canvas->connect_ports({1U, 1U, 2U, 0U}) == XTC_OK); + CHECK(canvas->connect_ports({2U, 1U, 3U, 0U}) == XTC_OK); + float rendered[32]; + CHECK(xtc_plan_process(handle.plan, nullptr, rendered, 32U) == XTC_OK); + CHECK(std::fabs(rendered[1] - static_cast(std::sin(6.283185307179586 * 440.0 / 48000.0) * 0.1)) < 0.00001f); + const xtc_graph *before_invalid = canvas->graph(); + unsigned int before_submit = submissions; + CHECK(canvas->connect_ports({2U, 1U, 1U, 0U}) == XTC_ERR_GRAPH_CYCLE); + CHECK(canvas->graph() == before_invalid && submissions == before_submit && contains(&window, "graph contains a cycle")); + CHECK(canvas->connect_ports({1U, 0U, 2U, 0U}) == XTC_ERR_INVALID_ARGUMENT); + CHECK(canvas->connect_ports({0U, 1U, 1U, 0U}) == XTC_ERR_INVALID_STATE); + submit_result = XTC_ERR_CAPACITY; + xtc_plan *before_plan = handle.plan; + CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_ERR_CAPACITY); + CHECK(canvas->graph() == before_invalid && handle.plan == before_plan && contains(&window, "Graph unchanged: capacity")); + submit_result = XTC_ERR_IO; + CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_ERR_IO && canvas->graph() == before_invalid); + submit_result = XTC_OK; + CHECK(canvas->select_node(0U) && canvas->move_node(0U, 65, 90)); + CHECK(submissions == before_submit + 2U); // Moving is GUI state, no compilation. + CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_OK); + int left, top; + CHECK(canvas->node_position(0U, left, top) && left == 65 && top == 90); + CHECK(canvas->delete_selected() == XTC_OK); // Newly added node. + CHECK(canvas->select_connection(1U, 2U) && canvas->delete_selected() == XTC_OK); + CHECK(xtc_plan_process(handle.plan, nullptr, rendered, 32U) == XTC_OK && rendered[1] == 0.0f); + CHECK(canvas->connect_ports({1U, 1U, 2U, 0U}) == XTC_OK); + CHECK(canvas->select_node(1U) && canvas->delete_selected() == XTC_OK); + CHECK(xtc_graph_get_counts(canvas->graph(), &nodes, &edges) == XTC_OK && nodes == 3U && edges == 1U); + xtc_node_info surviving{}; + CHECK(xtc_graph_get_node(canvas->graph(), 1U, &surviving) == XTC_OK && surviving.id == 2U); + engine_status.active_revision = engine_status.submitted_revision; + press(play); + pump(); + CHECK(contains(&window, "Graph: active in engine")); + CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_OK); + CHECK(contains(&window, "Graph: queued")); + engine_status.active_revision = engine_status.submitted_revision; + pump(); + CHECK(contains(&window, "Graph: active in engine")); + press(stop); + for (size_t id : {0U, 2U, 3U, 5U}) { CHECK(canvas->select_node(id)); CHECK(canvas->delete_selected() == XTC_OK); } + CHECK(xtc_graph_get_counts(canvas->graph(), &nodes, &edges) == XTC_OK && nodes == 0U && edges == 0U); + CHECK(xtc_plan_process(handle.plan, nullptr, rendered, 32U) == XTC_OK && rendered[1] == 0.0f); engine_status.xruns = 3U; engine_status.processed_blocks = 123U; pump(); diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp index eb9aee3..5099126 100644 --- a/tests/integration/test_gui_jack.cpp +++ b/tests/integration/test_gui_jack.cpp @@ -1,4 +1,5 @@ #include "../../src/gui/main_window.hpp" +#include "../../src/gui/graph_widget.hpp" #include #include #include @@ -15,6 +16,14 @@ Fl_Widget *find(Fl_Group &group, const char *label) } return nullptr; } +GraphWidget *canvas_in(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 = canvas_in(*nested)) return canvas; + } + return nullptr; +} void pump() { const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(400); @@ -33,11 +42,28 @@ int main(int argc, char **argv) auto *stop = find(window, "Stop"); auto *reconnect = find(window, "Reconnect"); CHECK(play != nullptr && stop != nullptr && reconnect != nullptr); + auto *canvas = canvas_in(window); + CHECK(canvas != nullptr); + CHECK(canvas->add_node(XTC_NODE_OSCILLATOR) == XTC_OK); + CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_OK); + CHECK(canvas->add_node(XTC_NODE_OUTPUT) == XTC_OK); + CHECK(canvas->connect_ports({0U, 1U, 1U, 0U}) == XTC_OK); + CHECK(canvas->connect_ports({1U, 1U, 2U, 0U}) == XTC_OK); for (int restart = 0; restart < 3; ++restart) { CHECK(play->active()); play->do_callback(); pump(); - CHECK(find(window, "running (silence") != nullptr); + CHECK(find(window, "running — modular graph") != nullptr); + CHECK(find(window, "Graph: active in engine") != nullptr); + const xtc_graph *unchanged = canvas->graph(); + CHECK(canvas->connect_ports({1U, 1U, 1U, 0U}) == XTC_ERR_GRAPH_CYCLE); + CHECK(canvas->graph() == unchanged); + CHECK(canvas->add_node(XTC_NODE_METER) == XTC_OK); + pump(); + CHECK(find(window, "Graph: active in engine") != nullptr); + CHECK(canvas->delete_selected() == XTC_OK); + pump(); + CHECK(find(window, "Graph: active in engine") != nullptr); auto *status = find(window, "JACK |"); CHECK(status != nullptr); unsigned int rate = 0U, frames = 0U, blocks = 0U; @@ -50,11 +76,11 @@ int main(int argc, char **argv) CHECK(find(window, "ready / stopped") != nullptr); play->do_callback(); pump(); - CHECK(find(window, "running (silence") != nullptr); + CHECK(find(window, "running — modular graph") != nullptr); window.do_callback(); // Close with an active client; synchronizes callbacks. CHECK(!window.shown()); pump(); // No timer may access the closed client. } - std::puts("Live GUI/JACK start/stop/reconnect/active-close tests passed."); + std::puts("Live GUI/JACK graph edits/revision acknowledgements/start/stop/reconnect/active-close tests passed."); return 0; } diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index eb38b13..658f45c 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -78,7 +78,7 @@ int main(void) xtc_backend_config config = {XTC_BACKEND_JACK, "test", 64U, 1U}; xtc_backend *backend = NULL; xtc_backend_status status; - xtc_graph *graph = NULL; + xtc_graph *graph = NULL, *invalid_graph = NULL; size_t node, i; const char *name; int visits = 0, iteration; @@ -110,7 +110,11 @@ int main(void) CHECK(xtc_graph_add_gain(graph, 0.5f, &node) == XTC_OK); CHECK(xtc_backend_open(&config, &backend) == XTC_OK); CHECK(xtc_backend_submit(backend, graph) == XTC_OK); - CHECK(xtc_backend_submit(backend, graph) == XTC_ERR_CAPACITY); + CHECK(xtc_backend_submit(backend, graph) == XTC_OK); /* Stopped edits replace pending plan. */ + CHECK(xtc_graph_create(1U, &invalid_graph) == XTC_OK); + CHECK(xtc_graph_add_oscillator(invalid_graph, 25000.0f, &node) == XTC_OK); + CHECK(xtc_backend_submit(backend, invalid_graph) == XTC_ERR_INVALID_ARGUMENT); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.submitted_revision == 1U && status.active_revision == 0U); CHECK(xtc_backend_port_name(backend, XTC_PORT_OUTPUT, &name) == XTC_OK && strcmp(name,"test:output") == 0); CHECK(xtc_backend_visit_ports(backend, XTC_PORT_INPUT, visit, &visits) == XTC_OK && visits == 1); CHECK(xtc_backend_connect(backend, XTC_PORT_OUTPUT, "fake:in") == XTC_ERR_INVALID_STATE); @@ -124,6 +128,11 @@ int main(void) CHECK(xtc_backend_disconnect(backend, XTC_PORT_OUTPUT, "fake:in") == XTC_OK); for (i = 0U; i < 128U; ++i) input[i] = 1.0f; CHECK(process_cb(32U, context) == 0 && output[0] == 0.5f); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.active_revision == status.submitted_revision); + CHECK(xtc_backend_submit(backend, invalid_graph) == XTC_ERR_INVALID_ARGUMENT); + CHECK(xtc_backend_submit(backend, graph) == XTC_OK); + CHECK(xtc_backend_submit(backend, graph) == XTC_ERR_CAPACITY); + CHECK(process_cb(32U, context) == 0 && output[0] == 0.5f); CHECK(size_cb(128U, context) == 0); CHECK(process_cb(128U, context) == 0 && output[127] == 0.0f); CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.process_result == XTC_ERR_CAPACITY); @@ -166,6 +175,7 @@ int main(void) } CHECK(closed >= 104); CHECK(xtc_backend_close(NULL) == XTC_OK); + xtc_graph_destroy(invalid_graph); xtc_graph_destroy(graph); puts("JACK backend fault-injection tests passed."); return 0; diff --git a/tests/unit/test_graph_edits.c b/tests/unit/test_graph_edits.c new file mode 100644 index 0000000..eb94fe8 --- /dev/null +++ b/tests/unit/test_graph_edits.c @@ -0,0 +1,83 @@ +#include "xtc/xtc_engine.h" +#include +#include +#define CHECK(c) do { if (!(c)) { fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); return 1; } } while (0) +static xtc_result apply(xtc_graph **graph, xtc_graph_edit *edit, size_t *id) +{ + xtc_graph *candidate = NULL; + xtc_result result = xtc_graph_apply_edit(*graph, edit, &candidate, id); + if (result == XTC_OK) { xtc_graph_destroy(*graph); *graph = candidate; } + return result; +} +int main(void) +{ + xtc_graph *graph = NULL, *candidate = NULL; + xtc_plan *old = NULL, *fresh = NULL; + xtc_graph_edit edit = {0}; + xtc_node_info info; + xtc_connection_info connection; + size_t osc, gain, meter, output, added = 77U, nodes, edges; + float samples[4] = {9,9,9,9}, peak; + CHECK(xtc_graph_create(8U, &graph) == XTC_OK); + CHECK(xtc_graph_require_output_nodes(graph, 2) == XTC_ERR_INVALID_ARGUMENT); + CHECK(xtc_graph_require_output_nodes(graph, 1) == XTC_OK); + CHECK(xtc_graph_compile(graph, 48000.0, 4U, &fresh) == XTC_OK); + CHECK(xtc_plan_process(fresh, NULL, samples, 4U) == XTC_OK && samples[0] == 0.0f && samples[3] == 0.0f); + xtc_plan_destroy(fresh); + edit.kind = XTC_EDIT_ADD_NODE; edit.data.add.type = XTC_NODE_OSCILLATOR; edit.data.add.value = 12000.0f; + CHECK(apply(&graph, &edit, &osc) == XTC_OK); + CHECK(xtc_graph_compile(graph, 48000.0, 4U, &fresh) == XTC_OK); + CHECK(xtc_plan_process(fresh, NULL, samples, 4U) == XTC_OK && samples[1] == 0.0f); /* No output. */ + xtc_plan_destroy(fresh); + edit.data.add.type = XTC_NODE_GAIN; edit.data.add.value = 0.25f; + CHECK(apply(&graph, &edit, &gain) == XTC_OK); + edit.data.add.type = XTC_NODE_METER; + CHECK(apply(&graph, &edit, &meter) == XTC_OK); + edit.data.add.type = XTC_NODE_OUTPUT; + CHECK(apply(&graph, &edit, &output) == XTC_OK); + edit.kind = XTC_EDIT_CONNECT; + edit.data.connection = (xtc_connection_info){osc, 1U, gain, 0U}; + CHECK(apply(&graph, &edit, NULL) == XTC_OK); + edit.data.connection = (xtc_connection_info){gain, 1U, meter, 0U}; + CHECK(apply(&graph, &edit, NULL) == XTC_OK); + edit.data.connection = (xtc_connection_info){meter, 1U, output, 0U}; + CHECK(apply(&graph, &edit, NULL) == XTC_OK); + CHECK(xtc_graph_get_connection(graph, 2U, &connection) == XTC_OK && connection.source_node == meter && connection.destination_node == output); + CHECK(xtc_graph_compile(graph, 48000.0, 4U, &old) == XTC_OK); + edit.data.connection = (xtc_connection_info){meter, 1U, gain, 0U}; + CHECK(xtc_graph_apply_edit(graph, &edit, &candidate, &added) == XTC_ERR_GRAPH_CYCLE && candidate == NULL && added == 77U); + CHECK(xtc_graph_get_counts(graph, &nodes, &edges) == XTC_OK && nodes == 4U && edges == 3U); + edit.data.connection = (xtc_connection_info){gain, 0U, meter, 0U}; + CHECK(apply(&graph, &edit, NULL) == XTC_ERR_INVALID_ARGUMENT); + edit.kind = XTC_EDIT_REMOVE_NODE; edit.data.node_id = gain; + CHECK(apply(&graph, &edit, NULL) == XTC_OK); + CHECK(xtc_graph_get_counts(graph, &nodes, &edges) == XTC_OK && nodes == 3U && edges == 1U); + CHECK(xtc_graph_get_node(graph, 1U, &info) == XTC_OK && info.id == meter && info.type == XTC_NODE_METER); + CHECK(xtc_graph_get_connection(graph, 0U, &connection) == XTC_OK && connection.source_node == meter); + edit.kind = XTC_EDIT_CONNECT; edit.data.connection = (xtc_connection_info){osc, 1U, meter, 0U}; + CHECK(apply(&graph, &edit, NULL) == XTC_OK); + CHECK(xtc_graph_compile(graph, 48000.0, 4U, &fresh) == XTC_OK); + CHECK(xtc_plan_process(old, NULL, samples, 4U) == XTC_OK && fabsf(samples[1] - 0.25f) < 0.00001f); + CHECK(xtc_plan_process(fresh, NULL, samples, 4U) == XTC_OK && fabsf(samples[1] - 1.0f) < 0.00001f); + CHECK(xtc_plan_meter_peak(fresh, meter, &peak) == XTC_OK && fabsf(peak - 1.0f) < 0.00001f); + xtc_plan_destroy(old); xtc_plan_destroy(fresh); + edit.kind = XTC_EDIT_DISCONNECT; + CHECK(apply(&graph, &edit, NULL) == XTC_OK); + CHECK(apply(&graph, &edit, NULL) == XTC_ERR_NOT_FOUND); + edit.kind = XTC_EDIT_ADD_NODE; edit.data.add.type = XTC_NODE_GAIN; edit.data.add.value = NAN; + CHECK(apply(&graph, &edit, &added) == XTC_ERR_INVALID_ARGUMENT && added == 77U); + edit.data.add.value = 2.0f; + CHECK(apply(&graph, &edit, &added) == XTC_OK && added > output); /* Never reuse gain's ID. */ + edit.kind = XTC_EDIT_REMOVE_NODE; edit.data.node_id = gain; + CHECK(apply(&graph, &edit, NULL) == XTC_ERR_NOT_FOUND); + edit.kind = (xtc_graph_edit_kind)999; + CHECK(apply(&graph, &edit, NULL) == XTC_ERR_INVALID_ARGUMENT); + edit.kind = XTC_EDIT_ADD_NODE; edit.data.add.type = (xtc_node_type)999; + CHECK(apply(&graph, &edit, NULL) == XTC_ERR_INVALID_ARGUMENT); + CHECK(xtc_graph_get_node(graph, SIZE_MAX, &info) == XTC_ERR_NOT_FOUND); + CHECK(xtc_graph_get_connection(graph, SIZE_MAX, &connection) == XTC_ERR_NOT_FOUND); + CHECK(xtc_graph_apply_edit(NULL, &edit, &candidate, NULL) == XTC_ERR_INVALID_ARGUMENT && candidate == NULL); + xtc_graph_destroy(graph); + puts("Transactional graph edits, stable IDs, snapshots and rendering passed."); + return 0; +} diff --git a/tests/unit/test_offline.c b/tests/unit/test_offline.c index 7a757ae..fd2ea87 100644 --- a/tests/unit/test_offline.c +++ b/tests/unit/test_offline.c @@ -89,7 +89,9 @@ static int validation(void) CHECK(xtc_graph_create(SIZE_MAX, &graph) == XTC_ERR_INVALID_ARGUMENT && graph == NULL); CHECK(xtc_graph_create(0U, &graph) == XTC_ERR_INVALID_ARGUMENT); CHECK(xtc_graph_create(4U, &graph) == XTC_OK); - CHECK(xtc_graph_compile(graph, 48000.0, 4U, &plan) == XTC_ERR_INVALID_STATE && plan == NULL); + CHECK(xtc_graph_compile(graph, 48000.0, 4U, &plan) == XTC_OK); + xtc_plan_destroy(plan); + plan = NULL; CHECK(xtc_graph_add_gain(graph, NAN, &unchanged) == XTC_ERR_INVALID_ARGUMENT && unchanged == 77U); CHECK(xtc_graph_add_oscillator(graph, -1.0f, &unchanged) == XTC_ERR_INVALID_ARGUMENT); CHECK(xtc_graph_add_gain(graph, 1.0f, &a) == XTC_OK); diff --git a/tests/unit/test_realtime.c b/tests/unit/test_realtime.c index aea0c5a..faaeeb8 100644 --- a/tests/unit/test_realtime.c +++ b/tests/unit/test_realtime.c @@ -16,6 +16,10 @@ static void *worker(void *context) while (!__atomic_load_n(&state->stop, __ATOMIC_ACQUIRE)) { if (xtc_realtime_process(state->engine, input, output, 32U) != XTC_OK) __atomic_store_n(&state->failed, 1U, __ATOMIC_RELAXED); + xtc_realtime_status status; + (void)xtc_realtime_get_status(state->engine, &status); + float expected = status.active_revision == 2U ? 2.0f : status.active_revision % 2U ? 2.0f : 1.0f; + if (output[0] != expected) __atomic_store_n(&state->failed, 1U, __ATOMIC_RELAXED); /* A whole block must come from one plan. No partially edited gains. */ for (i = 0U; i < 32U; ++i) if ((output[i] != 0.0f && output[i] != 1.0f && output[i] != 2.0f) || output[i] != output[0]) @@ -41,10 +45,12 @@ static int run(void) CHECK(xtc_graph_add_gain(two, 2.0f, &node) == XTC_OK); CHECK(xtc_realtime_submit(engine, one) == XTC_OK); CHECK(xtc_realtime_submit(engine, two) == XTC_ERR_CAPACITY); + CHECK(xtc_realtime_get_status(engine, &status) == XTC_OK && status.submitted_revision == 1U && status.active_revision == 0U); CHECK(xtc_realtime_process(engine, input, output, 4U) == XTC_OK && output[0] == 1.0f); CHECK(xtc_realtime_submit(engine, two) == XTC_OK); CHECK(xtc_realtime_process(engine, input, output, 4U) == XTC_OK && output[0] == 2.0f); xtc_realtime_collect(engine); + CHECK(xtc_realtime_get_status(engine, &status) == XTC_OK && status.active_revision == 2U && status.submitted_revision == 2U); CHECK(xtc_realtime_process(engine, input, output, 0U) == XTC_OK && output[0] == 2.0f); CHECK(xtc_realtime_get_status(engine, &status) == XTC_OK && status.processed_blocks == 3U); state.engine = engine; state.stop = 0U; state.failed = 0U;