Phase 4: Add modular graph editor and transactional graph edits
This commit is contained in:
parent
2ecc03b482
commit
5c41d7f6da
27 changed files with 1023 additions and 64 deletions
16
Makefile
16
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
|
||||
|
|
|
|||
11
README.md
11
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
45
docs/GUI.md
45
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
260
src/gui/graph_widget.cpp
Normal 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
44
src/gui/graph_widget.hpp
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
83
tests/unit/test_graph_edits.c
Normal file
83
tests/unit/test_graph_edits.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue