Phase 4: Add modular graph editor and transactional graph edits

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

View file

@ -28,7 +28,7 @@ stable node IDs, topological order, precomputed input adjacency, per-node mono
buffers and oscillator/meter state. Each plan has one processing owner. Graph
edits and destruction cannot affect an existing plan. All nodes are scheduled,
including disconnected nodes; only explicit output nodes are rendered (or
terminal nodes when no output node exists). See AUDIO_ENGINE.md for contracts.
terminal nodes for legacy callers that do not require explicit outputs). See AUDIO_ENGINE.md for contracts.
`xtc_realtime` now provides a one-pending/one-retired handoff around an
exclusively audio-owned active plan. `xtc_backend` is the C backend boundary;
the optional JACK implementation drives that same realtime engine.
@ -51,4 +51,20 @@ on the GUI thread. A GUI timer reads snapshots and collects retired resources;
no audio callback calls FLTK. Successful window close first closes the JACK
client (synchronizing callbacks), then removes the GUI timer and hides the
window. Failed close leaves both handle and window available for retry. View
switching is GUI state only; editors have no domain mutations yet. See GUI.md.
switching and node positions are GUI state only. Semantic edits use transactional
C commands and publish independent compiled snapshots. See GUI.md.
## Editable graph and canvas
Phase 4 adds read-only C snapshots, monotonic node IDs, and a small set of C
edit commands. The GUI owns an opaque graph handle, never private arrays.
An edit produces a candidate, and the GUI commits it only after backend submit
succeeds (or locally while offline). A busy pending slot or invalid edit leaves
the prior model/plan unchanged. Active/submitted revisions distinguish the
queued drawing from the engine's acknowledged plan. Empty/output-less editor
graphs render silence; legacy offline terminal fallback remains available.
GraphWidget owns only selection, node positions, pointer gestures and drawing.
Its fixed canvas is hosted by FLTK scrolling. C owns nodes, ports, edges,
validation, ID assignment, compilation and rendering. No audio thread accesses
GUI-owned state. Reconnect republishes the same retained editable graph.

View file

@ -28,8 +28,8 @@ tempo automation.
## Phase 1 offline API
`xtc_graph_create` accepts 1..256 nodes; IDs are insertion indexes, stable for
that graph's lifetime. Supported nodes are gain (any finite multiplier), sine
`xtc_graph_create` accepts 1..256 nodes; IDs are monotonic stable identifiers, never reused
within a graph's lifetime. Enumeration indexes are dense and distinct from IDs. Supported nodes are gain (any finite multiplier), sine
oscillator (finite nonnegative Hz), pass-through peak meter and audio output.
Ports carry mono float audio: input 0, output 1. Oscillators have no input and
output nodes have no output. Port lookup reports direction, signal type and
@ -39,7 +39,7 @@ edit becomes visible. Fan-out copies a source to consumers; fan-in sums inputs
without normalization. Failed additions leave IDs and graph contents unchanged.
`xtc_graph_compile(graph, sample_rate, max_frames, &plan)` runs on the control
thread. Empty graphs are rejected. Sample rate must be finite and positive,
thread. Empty graphs compile to silence. Sample rate must be finite and positive,
oscillators must be at or below Nyquist, and max_frames is 1..65536. Compilation
allocates and copies topology/parameters, builds a topological schedule and
input adjacency, and allocates one max_frames float buffer per node. The
@ -56,7 +56,9 @@ with processing, including reset, meter reads or destruction.
Unconnected non-oscillator nodes receive external input. Connected inputs
receive only the sum of their upstream buffers. Explicit output nodes are
summed to the caller's output. If none exist, terminal nodes are summed for
compatibility with the original gain-chain API. Disconnected components cannot
compatibility with the original gain-chain API, unless
`xtc_graph_require_output_nodes(graph, 1)` is set. The GUI uses that policy;
no output nodes means silence. Disconnected components cannot
contribute when explicit outputs exist, but they are still processed. Floating
point overflow and nonfinite input become silence per sample. No clipping or
normalization is applied to finite samples.
@ -103,7 +105,10 @@ Use `xtc_backend.h` on one serialized control thread:
between enumeration and connection; route failures are returned as I/O errors.
4. During playback, submit edited graphs and periodically collect retired
resources. Capacity means one pending graph is still awaiting adoption;
keep the current graph and retry later. get_status is a nonblocking snapshot
keep the current graph and retry later. Stopped submissions instead compile
a replacement engine off-thread and swap only after success, while callbacks
are quiescent. Its block/revision counters restart and oscillator phase resets;
no edit is blocked behind a plan that cannot be adopted until playback. get_status is a nonblocking snapshot
of rate, frames, running/server-loss/rate-change flags, xruns, processed
blocks and last process result. No active DSP memory is exposed.
5. Stop pauses the retained DSP state after deactivation. Restart resumes it.
@ -133,4 +138,46 @@ test README. Current limitations: mono input/output, no parameter ramping or
crossfaded graph changes, no realtime meter bridge, no automatic reconnect,
no physical-device or long-duration endurance qualification. Phase 3 connects
the FLTK shell to backend controls and status; see GUI.md. Its editors remain
placeholders and the GUI submits no playback graph yet.
placeholders except the Phase 4 modular editor described below.
## Phase 4 graph snapshots and edits
The control-thread API exposes copies through get_counts, get_node(index) and
get_connection(index), including node IDs/types/values and port endpoints. No
mutable graph/engine storage is exposed. Node IDs increase monotonically;
removing a node compacts private storage and adjusts private edge indexes but
leaves surviving IDs unchanged. Exhausted ID space returns capacity. Compiled
meter lookup resolves stable IDs too.
`xtc_graph_apply_edit(source, edit, &candidate, &added_id)` accepts ADD_NODE,
REMOVE_NODE, CONNECT or DISCONNECT. It copies the source, validates the edit,
and returns a caller-owned candidate on success. On failure candidate is NULL,
source and added_id are unchanged. added_id is optional and written only for
ADD_NODE. ADD_NODE validates type and the used parameter (finite gain, or finite
nonnegative oscillator Hz). Meter/output do not use the value field. Compile
additionally checks the oscillator against the actual sample rate. Removing
a node removes its incident connections; disconnecting a nonexistent edge
returns not-found. Missing nodes/ports, reversed directions, duplicate edges,
cycles and unknown command/type values are rejected without visible edits.
The GUI keeps a 64-node graph requiring explicit output nodes. It creates a
candidate, submits that exact candidate to the backend, then swaps its owned
graph and refreshes the canvas only if submission succeeds. Failed compile or
pending-slot pressure destroys the candidate and retains both the displayed
graph and previously submitted engine plan. When disconnected at startup,
edits can be stored locally and are submitted on reconnect. Lost/changed active
backends reject edits until reconnect. Reconnect submits the retained graph.
Submitted/active revision counters expose adoption without exposing plan
pointers. A submission increments the submitted revision; only the audio
consumer acknowledges the revision when adopting the plan at a block boundary.
The editor shows prepared while stopped, queued while an older plan is active,
and active when those revisions agree. Atomic fields remain independent
snapshots and wrap modulo UINT_MAX + 1. Rebuilding a stopped engine or reopening
a backend starts a new counter epoch. See decision 0004 for slot ordering.
Movement, selection and scroll position are GUI-only state; they never compile
or publish audio plans. Empty graphs and graphs without explicit outputs are
silent under the GUI policy. Terminal fallback remains the default for earlier
offline/standalone callers. Input on unconnected nodes retains the existing
external-input contract; the GUI does not connect external JACK ports.

View file

@ -18,7 +18,7 @@ make gui
core/offline tests, concurrent plan-handoff stress, JACK fault injection
(when JACK headers are available), and the startup check. `debug` builds and tests separately in
`build/debug`. `gui` builds the FLTK shell with backend controls and status.
The shell starts a silent engine and never connects physical ports. Generated files remain under `build` and header changes trigger rebuilds.
The shell starts with a silent graph and never connects physical ports. Generated files remain under `build` and header changes trigger rebuilds.
Compiler, flags, `BUILD_DIR`, `PKG_CONFIG`, `FLTK_CONFIG`, and installation
`PREFIX` can be overridden through Make variables. C uses C99; GUI uses C++17.
@ -92,8 +92,25 @@ exit-time font allocations; see tests/integration/README.md for the distinction
and command. No compiler warnings remain with GCC or Clang. Core/backend tests
pass with unsuppressed ASan/UBSan. No packages or system configuration changed.
## Phase 4 verification
```sh
make clean all test gui debug test-gui test-gui-jack
make BUILD_DIR=build/nojack PKG_CONFIG=false all test gui test-gui
```
The normal suite adds test_graph_edits for stable IDs, dense snapshots,
transactional rejection/deletion/disconnect and known rendered samples before
and after edits. Core/backend/realtime tests pass under unsuppressed Clang
ASan/UBSan; ThreadSanitizer passes the concurrent handoff test with revision-to-
gain checks. GUI ASan/UBSan tests pass with the existing fontconfig-only leak
suppression. Live GUI tests build a connected graph, adopt accepted edits during
playback, reject cycles, reconnect and close active clients. Mouse/visual smoke
checks cover node creation, wiring, cycle rejection, dragging and deletion.
No physical routes, new dependencies or system changes are introduced.
## Next milestone
Phase 4 implements the modular graph editor and submits validated graph edits
through the C API. Physical playback/endurance, project persistence and
sequencing remain unverified/unimplemented as applicable.
Phase 5 adds versioned project persistence and the sequencer foundation, stable
track/clip IDs and timeline editing. Physical-device playback/endurance remains
unverified; graph parameters are fixed GUI presets and undo/zoom are not present.

View file

@ -20,10 +20,10 @@ not energyXT's exact artwork or proprietary resources.
## Phase 3 shell
The shell opens JACK on startup through `xtc_backend.h`. It never accesses
backend structures, processes samples, creates domain objects, or calls JACK
itself. All backend control calls run on the GUI thread; FLTK is never called
backend structures, processes samples, or calls JACK itself. Graph domain
data is owned by opaque C objects, manipulated only through the C API. All backend control calls run on the GUI thread; FLTK is never called
from an audio callback. No server is started and no audio ports are connected
by the GUI. With no graph submitted, the running backend produces silence.
by the GUI. The initially empty graph produces silence.
- **Play / F5:** start the engine. **Stop / F8:** deactivate it. **Space:** toggle.
These are engine controls, not timeline playback or transport positioning.
@ -32,7 +32,7 @@ by the GUI. With no graph submitted, the running backend produces silence.
the handle and reports the failure instead of opening another client.
- **View menu / Ctrl+1, Ctrl+2, Ctrl+3:** switch between sequencer, mixer and
modular graph. Tabs provide the same view switching. Editors remain clearly
labeled placeholders; no project editing is implied.
labeled placeholders for sequencer/mixer; Modular Graph is implemented below.
- **File / Quit / Ctrl+Q** or the window close button: close the backend and
synchronize audio callbacks before hiding/destroying widgets. A failed close
leaves the window open for a retry. Unsupported New/Open/Save actions are
@ -62,3 +62,40 @@ removal. `make test-gui-jack` additionally requires a running JACK-compatible
server, tests repeated start/stop, reconnect and active-client close, and creates
no routes. Physical playback is outside this shell. See BUILD.md and the
integration test README for sanitizer limitations and keyboard smoke results.
## Phase 4 modular editor
Open **Modular Graph** or press **Ctrl+3**. Add oscillator (440 Hz), gain (0.10),
pass-through meter or output nodes with the toolbar. Their parameters are fixed
presets in this milestone and are drawn on each node; parameter editing and
live meter display are not implemented. Connect oscillator → gain → meter →
output for a simple test chain. No sound reaches hardware until ports are
explicitly connected outside the GUI. Output nodes are required to render;
isolated sources and an empty graph produce silence.
Drag a node body/title to move it. Drag a circular output port to an input port
to connect. Click a node or wire to select it (yellow outline/wire), then press
Delete/Backspace or **Delete selection**. Deleting a node deletes its incident
wires. Escape cancels a drag preview. Canvas scrollbars pan the view; positions
are retained across edits/reconnect but not across application restart. There
is no zoom, multiselection, undo, project persistence or external routing UI yet.
The editor supports 64 nodes on a fixed 2000x4000 canvas.
Every semantic action becomes a C graph command. The window owns the graph
handle and publishes the C-created candidate through the backend. Cycle,
invalid-direction, duplicate connection, missing-port, capacity and backend
failures are shown below the canvas; failed edits leave the graph unchanged.
Capacity feedback distinguishes the possible node limit/pending edit condition
and allows retry. Editing while stopped replaces the prepared plan without
starting playback. Editing with a disconnected startup backend stays local
until reconnect. A lost or changed live backend requires reconnect before
further semantic edits. Moving/selecting nodes does not touch the engine.
The graph state line distinguishes offline, prepared, queued and active states.
Queued explicitly means the drawing represents the next submitted graph while
the engine finishes the previous plan. The 100 ms GUI timer observes audio
adoption revisions and then shows active. Graphs are not edited in place on the
audio thread. There is no automatic retry/hidden edit queue; users retry a
rejected capacity edit. Plan replacement can reset oscillator phase and is not
crossfaded. Render validation and active replacement tests are described in
tests/integration/README.md.

View file

@ -35,13 +35,21 @@ and shutdown through the C API.
Implemented: dark FLTK shell, Play/Stop/Reconnect, view/menu shortcuts, backend
state/format/xrun/process status, persistent command errors, timer-based refresh
and retirement collection, and callback-safe close/retry. Mock-backend GUI tests
and live GUI/JACK start/stop/reconnect/close tests pass under Xvfb. Editors and
project-file actions remain placeholders; engine runs in silence. See GUI.md.
and live GUI/JACK start/stop/reconnect/close tests pass under Xvfb. Sequencer,
mixer and project-file actions remain placeholders; initial graph is silent. See GUI.md.
## Phase 4 — Modular editor
Draw nodes/ports/edges; create, move and connect nodes; reject invalid edges;
safely submit edits to the C core.
Implemented: scrollable node/port/wire canvas; add/select/move/delete nodes;
drag connections, select/delete wires, and show invalid-edit feedback. C owns
stable IDs, read-only snapshots and transactional edits. Exact accepted graph
snapshots are submitted to the backend; prepared/queued/active state is exposed
through revision acknowledgements. Core/render, GUI rollback, mouse smoke and
live adoption tests pass. See GUI.md and AUDIO_ENGINE.md. Preset parameters,
mono-only processing, fixed canvas and no persistence/undo/zoom remain limits.
## Phase 5 — Project and sequencer
Versioned persistence, stable IDs, tracks, clips, timeline, selection, zoom,
scrolling, and transport synchronization.

View file

@ -39,7 +39,7 @@ objects. There is no mutex or libatomic fallback in the callback path.
One serialized control producer and one audio consumer share two pointer slots:
- `pending`: producer publishes a fully compiled plan with release; consumer
exchanges it for NULL with acquire at the next eligible block boundary.
exchanges it for NULL with acquire/release at the next eligible block boundary.
- `retired`: consumer releases its previous active plan into this slot only
after its final access. Producer exchanges with NULL using acquire/release,
then destroys that acknowledged plan on the control thread.
@ -82,3 +82,27 @@ crossfade. Smooth parameter ramps and click-free graph transitions are future wo
No stereo, recording, MIDI, native PipeWire API, GUI engine controls or hardware
configuration dialogs are part of this phase.
## Phase 4 adoption acknowledgement and stopped editing
The pending pointer has an associated atomic revision. Producer stores the
revision before release-publishing the pointer. Consumer acquire-loads a
non-NULL pending pointer, reads its revision, and only then releases that slot
with an acquire/release exchange to NULL. Thus producer cannot reuse the
revision field until the previous consumer read is complete. Consumer publishes
active_revision when selecting that plan; the GUI compares it with the
producer's submitted_revision. These are wrapping counters and individual
snapshots, not plan pointers or a multi-field transactional snapshot.
While JACK is stopped, a backend submission builds a separate realtime engine,
submits/compiles the graph there, and replaces the retained engine only on
success. Process callbacks cannot run on an inactive client or after successful
deactivation, and notification callbacks do not access the engine pointer.
The control thread can therefore reclaim all old state safely. Counters and
oscillator phases restart for that new engine epoch. The standalone realtime
API keeps its one-pending-slot contract regardless of external stop state.
The callback still adopts at most one plan and never allocates/frees. Revision
ordering is tested alongside the plan's rendered gain under 2,000 concurrent
submissions and ThreadSanitizer. Stopped compile failure and active pending
capacity are checked with backend fault injection.