Phase 4: Add modular graph editor and transactional graph edits

This commit is contained in:
Johannes Findeisen 2026-10-26 23:55:35 +01:00
commit 5c41d7f6da
27 changed files with 1023 additions and 64 deletions

View file

@ -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

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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. */

View file

@ -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);

View file

@ -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.

View file

@ -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;
}

View file

@ -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;

View file

@ -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);
}

View file

@ -1,6 +1,7 @@
#include "graph_internal.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
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;
}

View file

@ -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;
};

260
src/gui/graph_widget.cpp Normal file
View file

@ -0,0 +1,260 @@
#include "graph_widget.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
#include <FL/fl_draw.H>
#include <algorithm>
#include <cmath>
#include <cstdio>
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<int>(slot % 8U) * 210;
position.top = 40 + static_cast<int>(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<double>(node.value));
else if (node.type == XTC_NODE_GAIN) std::snprintf(text, sizeof(text), "x %.2f", static_cast<double>(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);
}

44
src/gui/graph_widget.hpp Normal file
View file

@ -0,0 +1,44 @@
#ifndef XTC_GRAPH_WIDGET_HPP
#define XTC_GRAPH_WIDGET_HPP
#include <FL/Fl_Widget.H>
#include <array>
#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<Position, 256> 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

View file

@ -1,15 +1,18 @@
#include "main_window.hpp"
#include "graph_widget.hpp"
#include "xtc/xtc_version.h"
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
#include <FL/Fl_Button.H>
#include <FL/Fl_Group.H>
#include <FL/Fl_Scroll.H>
#include <FL/Fl_Menu_Bar.H>
#include <FL/Fl_Tabs.H>
#include <FL/fl_ask.H>
#include <cstdio>
#include <cstring>
#include <stdexcept>
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<GraphWidget *>(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<GraphWidget *>(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<GraphWidget *>(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<GraphWidget *>(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<GraphWidget *>(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<MainWindow *>(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)

View file

@ -2,8 +2,10 @@
#define XTC_MAIN_WINDOW_HPP
#include <FL/Fl_Double_Window.H>
#include <memory>
#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<xtc_graph, decltype(&xtc_graph_destroy)> 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();

View file

@ -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.

View file

@ -1,4 +1,5 @@
#include "../../src/gui/main_window.hpp"
#include "../../src/gui/graph_widget.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
#include <FL/Fl_Button.H>
@ -8,15 +9,17 @@
#include <chrono>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <initializer_list>
/* 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<Fl_Group *>(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<Fl_Group *>(graph_group->child(i)))
for (int j = 0; j < scroll->children(); ++j)
if (auto *found = dynamic_cast<GraphWidget *>(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<float>(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();

View file

@ -1,4 +1,5 @@
#include "../../src/gui/main_window.hpp"
#include "../../src/gui/graph_widget.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Group.H>
#include <chrono>
@ -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<GraphWidget *>(group.child(i))) return canvas;
if (auto *nested = dynamic_cast<Fl_Group *>(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;
}

View file

@ -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;

View file

@ -0,0 +1,83 @@
#include "xtc/xtc_engine.h"
#include <math.h>
#include <stdio.h>
#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;
}

View file

@ -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);

View file

@ -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;