1
0
Fork 0
forked from hanez/xtc

Phase 5: Add project persistence and sequencer foundation

This commit is contained in:
Johannes Findeisen 2026-10-27 00:10:45 +01:00
commit 65640c4aad
28 changed files with 1307 additions and 84 deletions

View file

@ -13,7 +13,7 @@ CXXFLAGS ?= -O2 -g
CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude
LDFLAGS ?=
CORE_SOURCES := src/core/xtc.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
CORE_SOURCES := src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes)
ifeq ($(JACK_AVAILABLE),yes)
JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack)
@ -71,8 +71,12 @@ $(BUILD_DIR)/test_backend: $(BUILD_DIR)/tests/unit/test_backend.o $(BUILD_DIR)/l
$(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
$(BUILD_DIR)/test_project: $(BUILD_DIR)/tests/unit/test_project.o $(BUILD_DIR)/libxtc.a
$(CC) $(LDFLAGS) -o $@ $^ -lm
test: $(BUILD_DIR)/test_project $(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_project
./$(BUILD_DIR)/test_graph_edits
./$(BUILD_DIR)/test_realtime
./$(BUILD_DIR)/test_core
@ -98,14 +102,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)/src/gui/graph_widget.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)/src/gui/sequencer_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)/src/gui/graph_widget.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)/src/gui/sequencer_widget.o $(BUILD_DIR)/libxtc.a
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm
test-gui-jack: $(BUILD_DIR)/test_gui_jack
@ -116,7 +120,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)/src/gui/graph_widget.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)/src/gui/sequencer_widget.o $(BUILD_DIR)/libxtc.a
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm
else
test-gui-jack test-gui:
@ -137,4 +141,6 @@ 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)/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
$(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/src/gui/graph_widget.d $(BUILD_DIR)/src/gui/sequencer_widget.d $(BUILD_DIR)/tests/integration/test_jack.d $(BUILD_DIR)/tests/integration/test_gui.d $(BUILD_DIR)/tests/integration/test_gui_jack.d
-include $(BUILD_DIR)/tests/unit/test_project.d

View file

@ -21,11 +21,12 @@ This repository is a starter scaffold, not a production DAW. It currently provid
- 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;
- versioned project save/load and a timeline for tracks and silent regions;
- sample-counted transport, selection, move/trim, timeline zoom and scrolling;
- architecture, format, GUI, roadmap, and decision documents.
The JACK backend has passed software-loopback testing; physical-device playback
is unverified. Recording, MIDI, plugin hosting, project persistence, and the
actual sequencer UI are not implemented yet.
is unverified. Recording, audio-region playback, MIDI and plugin hosting are not implemented yet.
## Build
@ -55,7 +56,10 @@ Run the GUI:
The GUI opens JACK and offers Play/Stop/Reconnect, backend status and view
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
Sequencer regions are currently silent; the mixer editor remains a placeholder.
Use Add track/Add region, drag regions or their edges, and Zoom +/-; scrollbars pan.
Ctrl+N/O/S creates, opens or saves a project; Save As chooses another path.
The ruler seeks while stopped. Stop pauses; Reconnect and project load reset position. 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. In Modular Graph, drag output to input to connect;
select a node or wire and press Delete to remove it. Invalid edits leave the

View file

@ -68,3 +68,20 @@ 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.
## Project ownership and persistence
Phase 5 introduces a C-owned project containing the graph, bounded track/clip
arrays, stable ID allocators and constant tempo. Read APIs return copies;
GUI canvases borrow the opaque project/graph on the control thread. A graph
edit prepares a candidate project before submitting its graph, then commits
ownership only after acceptance. Arrangement edits currently change silent
metadata and require no audio publication.
Persistence is private C/POSIX code; loading validates an isolated candidate
and swaps ownership only after a successful close. The GUI similarly prepares
a loaded document before stopping/submitting audio. File operations never run
in the callback. See PROJECT_FORMAT.md for schema, bounds and durability.
SequencerWidget owns selection, zoom and gestures only; musical range checks,
source-preserving trims and ID allocation remain C responsibilities.

View file

@ -181,3 +181,25 @@ 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.
## Phase 5 transport
Backend status includes a uint64 sample_position. Each successful processing
callback advances it by its exact frame count (including silence); errors do
not advance it. The counter uses compiler atomics, verified always lock-free
before backend setup, with overflow detected rather than wrapped. No extra
allocation, I/O, mutex or GUI work occurs in callbacks. Fields remain individually
coherent snapshots.
Stop pauses the clock. Start resumes; stopped and running graph submissions
preserve position even when plan/revision counters reset. Reopen starts at zero.
xtc_backend_seek changes position only on a healthy stopped backend; it does
not rewind oscillator phase. Project tick/sample conversions use integer floor
arithmetic at constant tempo, with explicit capacity errors and rates 1..768000.
The GUI observes the counter every 100 ms, so visual updates are block-quantized
and delayed by polling. No external JACK transport synchronization is provided.
Silent clip metadata is control-thread data, never traversed by the callback.
Audio/MIDI scheduling will be added in later phases through compiled event/clip
plans rather than direct callback access to the editable project.

View file

@ -109,8 +109,22 @@ 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 5 verification
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.
```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 suite adds test_project: track/clip limits, stable IDs, trims, integer
transport conversion, exact file roundtrip with graph rendering, exhaustive
fixture truncations, byte mutations, failed saves and temporary cleanup.
GUI tests cover region gestures, zoom, save/load and backend rollback.
Live JACK tests verify playhead advance, pause and resume without creating routes.
The updated core and GUI tests pass Clang ASan/UBSan (the GUI uses the existing
fontconfig-only LSan suppression). An Xvfb/xdotool visual check created a track
and region, moved it one bar and zoomed the canvas. No dependencies were added.
Phase 6 will add audio assets and recording. Silent arrangement regions, fixed
tempo/4/4, no undo, no persisted GUI layout, and unverified physical-device
playback/endurance remain explicit limits.

View file

@ -26,17 +26,15 @@ from an audio callback. No server is started and no audio ports are connected
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.
These advance/pause the sample-counted playhead; regions are currently silent.
- **Reconnect / F6:** close the old client and open a fresh stopped client.
Use this after server loss or sample-rate changes. A failed close preserves
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 for sequencer/mixer; Modular Graph is implemented below.
modular graph. Tabs provide the same view switching. The mixer remains a placeholder; graph and sequencer editors are described 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
visibly disabled. About identifies the development milestone and version.
leaves the window open for a retry. New/Open/Save actions are described below. About identifies the development milestone and version.
The indicator shows disconnected, ready, running, server lost, format changed
or process-error states. The status line shows rate, actual block size, mono
@ -78,10 +76,10 @@ 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.
is no graph zoom, multiselection, undo 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
Every semantic action becomes a C graph command. The window owns a C project containing the graph
handle and publishes the C-created candidate through the backend. Cycle,
invalid-direction, duplicate connection, missing-port, capacity and backend
failures are shown below the canvas; failed edits leave the graph unchanged.
@ -99,3 +97,36 @@ 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.
## Phase 5 projects and sequencer
File/New (Ctrl+N), Open (Ctrl+O), Save (Ctrl+S), and Save As
(Ctrl+Shift+S) use native file dialogs and the C persistence API. New/Open/Quit
offer Cancel/Discard when semantic edits are unsaved; save first to retain them.
Save As confirms overwrites. The title marks unsaved changes. Failed loads and
backend submissions retain the old document. Loading parses before stopping
audio; installing a valid candidate stops and resets transport before compiling
and submitting. A later failure can leave the retained document stopped at zero.
Opening/New resets graph layout; graph positions are not saved. See PROJECT_FORMAT.md.
Sequencer offers Add track, Add region, Delete region/track, Zoom +/-. Select a
track header to add a one-bar region there at the cursor. Select a region to
delete it; deleting a selected track cascades to its regions. Delete also works
with canvas focus. Drag a region body to move it in time or between tracks;
drag either edge to trim within its original source bounds. Edits commit on
release with 1/16-note snapping; Escape cancels. There is no drag preview, undo,
multiselection, rename or tempo editor yet. Overlapping regions are permitted.
The scrollable canvas expands to cover the arrangement plus a margin; zoom
ranges from 24 to 384 pixels/quarter. Names, tempo and 4/4 meter are drawn.
A ruler click seeks while stopped (or positions the cursor when disconnected).
The 100 ms GUI refresh converts the backend's actual sample counter to ticks;
it never integrates wall-clock time. Play resumes and Stop pauses. Reconnect,
New and Open reset position. Regions contain no audio or MIDI and cannot
produce sound yet; modular graph processing continues as before. The C model
supports a constant tempo; a tempo map and scheduler are future work.
Tests cover C-model/persistence boundaries, GUI move/trim gestures and zoom,
save/reopen, failed load/submission rollback, sample-derived cursor updates,
and live JACK playhead advance/pause.

View file

@ -1,18 +1,75 @@
# Project format
# Project format v1
Project persistence is not implemented yet. Before implementation, define a
versioned format with stable IDs for tracks, nodes, clips, connections, and
parameters.
Phase 5 uses a bounded text format implemented in C99 without a parser library.
The extension is `.xtc`. File operations run exclusively on the control thread.
The project owns its graph, tracks and silent arrangement regions; audio files,
recording and MIDI content arrive in subsequent phases.
Requirements:
- reject malformed, truncated, unsupported, or unreasonable input safely;
- validate sizes before allocation;
- store external audio assets separately from project metadata;
- define missing-asset behavior;
- never execute code during loading;
- perform file I/O only off the audio thread;
- save atomically using a temporary file and a safe rename;
- add explicit migration code when the format changes.
## Schema
Choose a simple format after evaluating dependency, robustness, and human
inspectability trade-offs. Document the schema before treating it as stable.
Records occur in this exact order, separated by LF, including the final record.
Fields use ASCII spaces. Integers are unsigned decimal, without signs.
Counts determine the number of subsequent records:
```text
XTC_PROJECT 1
APP 0 1 0
TEMPO 120000
IDS 1 1
GRAPH 1 2 2 1
NODE 0 1 3e800000
NODE 1 4 00000000
EDGE 0 1
TRACKS 1
TRACK 0 547261636b
CLIPS 1
CLIP 0 0 3840 2880 960 3840 526567696f6e
END
```
- `APP`: producer major/minor/patch; informational, never executed.
- `TEMPO`: constant milli-BPM (20000..300000, default 120000), fixed 4/4.
- `IDS`: next track ID and next clip ID. IDs are uint64, monotonic and never
reused; UINT64_MAX is reserved. Track and clip ID namespaces are separate.
- `GRAPH`: output-node policy (0/1), next node ID, node count, edge count.
Nodes retain stable IDs (limited to the host's size_t range). No pointers or
compiled state are serialized.
- `NODE`: ID, type (1 gain, 2 oscillator, 3 meter, 4 output), and exactly eight
hexadecimal digits encoding the IEEE binary32 parameter bits, independent
of locale and host byte order. Nonfinite values are rejected.
- `EDGE`: source and destination node IDs. Mono port directions are implied
by node type; endpoints, duplicates and cycles are validated by the graph API.
- `TRACK`: ID and hex-encoded name bytes.
- `CLIP`: ID, track ID, start tick, length tick, source offset tick, source
length tick, hex-encoded name bytes. Regions are silent metadata. Trimming
preserves source bounds, so a region can re-extend only within its original
length. Overlaps are allowed; no audio mixing or scheduling is implied.
- Names decode to nonempty valid UTF-8 of at most 63 bytes; ASCII control
characters and embedded NULs are forbidden. Hex avoids quoting ambiguity.
Ticks use 960 per quarter note, 3840 per bar. All region endpoints are bounded
by 38400000 ticks (10000 bars). Limits: 64 tracks, 1024 clips, 64 graph nodes,
4096 edges, 1 MiB input, 511 bytes per record, 12 tokens per record. The reader
rejects invalid references, duplicate IDs, inconsistent next IDs, ranges,
truncated records, raw control/non-ASCII bytes, and trailing data.
Unsupported format versions return UNSUPPORTED. Other malformed data returns
FORMAT. No implicit migration is attempted; future revisions require explicit
migration code and roundtrip tests.
## Ownership and failures
Load builds and validates a separate candidate, closes the input, then replaces
the destination. Any failure retains the complete previous project. Saving
validates the project, creates a mode-0600 temporary in the destination
directory, writes, flushes, fsyncs, closes and renames over the destination.
Failures before rename retain the previous destination and remove the temporary.
The parent directory is not fsynced: survival of the rename through sudden power
loss is not guaranteed. Save replaces symlinks themselves and does not preserve
existing file permissions. POSIX I/O is private to the Linux-first persistence
module; public headers contain no POSIX types.
Saved data reproduces the arrangement and graph, including ID allocation state.
Graph node positions, timeline zoom/scroll/selection, transport position and DSP
oscillator phase are GUI/session state and are not saved. There are no external
asset references yet; missing-asset recovery belongs to Phase 6.

View file

@ -54,6 +54,14 @@ mono-only processing, fixed canvas and no persistence/undo/zoom remain limits.
Versioned persistence, stable IDs, tracks, clips, timeline, selection, zoom,
scrolling, and transport synchronization.
Implemented: bounded version-1 project persistence with transactional loads and
temporary-file/fsync/rename saves; stable track/clip/node IDs; silent regions;
track and region creation/deletion, selection, move/trim, timeline zoom/scroll;
sample-counted playhead, pause/resume and stopped seek. Project/graph arrangement
roundtrips, malformed-input, GUI rollback/gesture and live transport tests pass.
See PROJECT_FORMAT.md and GUI.md. Constant tempo/4/4, silent metadata regions,
no undo, asset playback, MIDI or persisted GUI layout are explicit limits.
## Phase 6 — Audio clips and recording
Audio import/playback, recording through bounded buffers, missing-asset
handling, no file I/O in callbacks.

View file

@ -18,6 +18,7 @@ typedef struct {
unsigned int buffer_frames;
unsigned int xruns; /* Wraps modulo UINT_MAX + 1. */
unsigned int processed_blocks;
uint64_t sample_position; /* Successful callback frames; retained across stop/submit. */
unsigned int submitted_revision, active_revision;
int running;
int server_lost;
@ -37,6 +38,8 @@ 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);
/* Stopped only; changes transport clock, not oscillator phase. */
xtc_result xtc_backend_seek(xtc_backend *backend, uint64_t sample);
/* 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. */

View file

@ -1,22 +1,59 @@
#ifndef XTC_PROJECT_H
#define XTC_PROJECT_H
#include "xtc_types.h"
#include "xtc_engine.h"
#ifdef __cplusplus
extern "C" {
#endif
#define XTC_PROJECT_MAX_TRACKS 64U
#define XTC_PROJECT_MAX_CLIPS 1024U
#define XTC_PROJECT_NAME_BYTES 64U
#define XTC_TICKS_PER_QUARTER 960U
#define XTC_PROJECT_MAX_TICK UINT64_C(38400000)
typedef struct xtc_project xtc_project;
/* Project persistence is not implemented in this scaffold yet. */
typedef struct { uint64_t id; char name[XTC_PROJECT_NAME_BYTES]; } xtc_track_info;
/* Silent arrangement region: audio assets and MIDI content are future phases.
* All musical positions are integer ticks, constant tempo and 4/4 meter. */
typedef struct {
uint64_t id, track_id, start_tick, length_tick, offset_tick, source_length_tick;
char name[XTC_PROJECT_NAME_BYTES];
} xtc_clip_info;
/* Single control-thread ownership. Names: nonempty UTF-8, <=63 bytes, no ASCII controls.
* IDs are monotonic, never reused, UINT64_MAX reserved. Snapshots are copies. */
xtc_result xtc_project_create(xtc_project **out_project);
void xtc_project_destroy(xtc_project *project);
xtc_result xtc_project_clone(const xtc_project *project, xtc_project **out_project);
xtc_result xtc_project_get_counts(const xtc_project *project, size_t *out_tracks, size_t *out_clips);
xtc_result xtc_project_get_track(const xtc_project *project, size_t index, xtc_track_info *out_track);
xtc_result xtc_project_get_clip(const xtc_project *project, size_t index, xtc_clip_info *out_clip);
xtc_result xtc_project_find_clip(const xtc_project *project, uint64_t id, xtc_clip_info *out_clip);
xtc_result xtc_project_add_track(xtc_project *project, const char *name, uint64_t *out_id);
/* Removing a track also removes its clips. */
xtc_result xtc_project_remove_track(xtc_project *project, uint64_t id);
xtc_result xtc_project_add_clip(xtc_project *project, uint64_t track_id, const char *name,
uint64_t start_tick, uint64_t length_tick, uint64_t *out_id);
xtc_result xtc_project_remove_clip(xtc_project *project, uint64_t id);
/* Atomic move/trim: source bounds remain unchanged, no destructive edits. */
xtc_result xtc_project_edit_clip(xtc_project *project, uint64_t id, uint64_t track_id,
uint64_t start_tick, uint64_t length_tick, uint64_t offset_tick);
/* Borrowed opaque graph valid until project mutation/destruction. set_graph
* copies before replacing; max 64 nodes, preserving IDs, edges and output policy. */
const xtc_graph *xtc_project_graph(const xtc_project *project);
xtc_result xtc_project_set_graph(xtc_project *project, const xtc_graph *graph);
/* Constant tempo in milli-BPM: 20000..300000, default 120000. */
unsigned int xtc_project_tempo(const xtc_project *project);
xtc_result xtc_project_set_tempo(xtc_project *project, unsigned int milli_bpm);
/* Floor conversion, no accumulated drift. Rate: 1..768000. Tick <= MAX_TICK.
* Excess sample/tick positions return capacity; outputs unchanged on error. */
xtc_result xtc_project_tick_to_sample(const xtc_project *project, uint64_t tick,
unsigned int sample_rate, uint64_t *out_sample);
xtc_result xtc_project_sample_to_tick(const xtc_project *project, uint64_t sample,
unsigned int sample_rate, uint64_t *out_tick);
/* Control-thread I/O. Versioned, bounded format; see docs/PROJECT_FORMAT.md.
* Load is transactional. Save fsyncs a same-directory temporary and renames it;
* failures before rename preserve the previous destination. No asset I/O. */
xtc_result xtc_project_save(const xtc_project *project, const char *path);
xtc_result xtc_project_load(xtc_project *project, const char *path);
#ifdef __cplusplus
}
#endif
#endif

View file

@ -17,7 +17,8 @@ typedef enum {
XTC_ERR_IO,
XTC_ERR_UNSUPPORTED,
XTC_ERR_CAPACITY,
XTC_ERR_GRAPH_CYCLE
XTC_ERR_GRAPH_CYCLE,
XTC_ERR_FORMAT
} xtc_result;
const char *xtc_result_string(xtc_result result);

View file

@ -12,6 +12,7 @@ struct xtc_backend {
jack_port_t *input, *output;
xtc_realtime *engine;
size_t max_frames;
uint64_t sample_position;
unsigned int expected_rate;
unsigned int rate, frames, lost, rate_changed, xruns;
unsigned int process_result;
@ -36,6 +37,11 @@ static int process(jack_nframes_t frames, void *context)
const float *input = jack_port_get_buffer(backend->input, frames);
result = xtc_realtime_process(backend->engine, input, output, (size_t)frames);
}
if (result == XTC_OK) {
uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED);
if (position > UINT64_MAX - frames) result = XTC_ERR_CAPACITY;
else __atomic_store_n(&backend->sample_position, position + frames, __ATOMIC_RELAXED);
}
if (result != XTC_OK) memset(output, 0, (size_t)frames * sizeof(*output));
__atomic_store_n(&backend->process_result, (unsigned int)result, __ATOMIC_RELAXED);
return 0; /* Keep connected and silent on recoverable processing errors. */
@ -76,6 +82,7 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_
if (config == NULL || config->client_name == NULL || config->client_name[0] == '\0' ||
config->max_frames == 0U || config->max_frames > 65536U || config->channel_count != 1U)
return XTC_ERR_INVALID_ARGUMENT;
if (!__atomic_always_lock_free(sizeof(uint64_t), 0)) return XTC_ERR_UNSUPPORTED;
if (config->kind != XTC_BACKEND_JACK) return XTC_ERR_UNSUPPORTED;
if (strlen(config->client_name) >= (size_t)jack_client_name_size()) return XTC_ERR_INVALID_ARGUMENT;
backend = calloc(1U, sizeof(*backend));
@ -145,6 +152,15 @@ xtc_result xtc_backend_stop(xtc_backend *backend)
xtc_realtime_collect(backend->engine);
return XTC_OK;
}
xtc_result xtc_backend_seek(xtc_backend *backend, uint64_t sample)
{
if (backend == NULL || sample > UINT64_MAX - 65536U) return XTC_ERR_INVALID_ARGUMENT;
if (!backend->ready || backend->running ||
__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) ||
__atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE;
__atomic_store_n(&backend->sample_position, sample, __ATOMIC_RELAXED);
return XTC_OK;
}
xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph)
{
if (backend == NULL) return XTC_ERR_INVALID_ARGUMENT;
@ -180,6 +196,7 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status
out_status->server_lost = (int)__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE);
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->sample_position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED);
out_status->processed_blocks = engine_status.processed_blocks;
out_status->submitted_revision = engine_status.submitted_revision;
out_status->active_revision = engine_status.active_revision;
@ -233,6 +250,8 @@ xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direc
{ return route(backend, direction, remote, 1); }
#else
xtc_result xtc_backend_seek(xtc_backend *backend, uint64_t sample)
{ (void)backend; (void)sample; return XTC_ERR_UNSUPPORTED; }
/* Keep core/offline builds available on machines without JACK headers. */
xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_backend)
{

View file

@ -17,6 +17,7 @@ const char *xtc_result_string(xtc_result result)
case XTC_ERR_IO: return "I/O error";
case XTC_ERR_UNSUPPORTED: return "unsupported operation";
case XTC_ERR_CAPACITY: return "capacity exceeded";
case XTC_ERR_FORMAT: return "malformed project data";
case XTC_ERR_GRAPH_CYCLE: return "graph contains a cycle";
default: return "unknown error";
}

View file

@ -11,6 +11,7 @@ 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 reset_layout() { count_ = 0; selected_node_ = SIZE_MAX; edge_selected_ = dragging_ = wiring_ = false; }
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);

View file

@ -1,5 +1,7 @@
#include "main_window.hpp"
#include "graph_widget.hpp"
#include "sequencer_widget.hpp"
#include <FL/Fl_Native_File_Chooser.H>
#include "xtc/xtc_version.h"
#include <FL/Fl.H>
@ -22,20 +24,20 @@ void set_label(Fl_Box *box, const char *text)
}
}
MainWindow::MainWindow()
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation")
MainWindow::MainWindow(bool prompt_unsaved)
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation"),
prompt_unsaved_(prompt_unsaved)
{
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));
xtc_project *initial = nullptr;
xtc_result result = xtc_project_create(&initial);
if (result != XTC_OK) throw std::runtime_error(xtc_result_string(result));
project_.reset(initial);
begin();
auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28);
menu->add("File/New (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
menu->add("File/Open (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
menu->add("File/Save (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
menu->add("File/New", FL_CTRL + 'n', file_callback, this);
menu->add("File/Open", FL_CTRL + 'o', file_callback, this);
menu->add("File/Save", FL_CTRL + 's', file_callback, this);
menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this);
menu->add("File/Quit", FL_CTRL + 'q', close_callback, this);
menu->add("Audio/Start engine", FL_F + 5, play_callback, this);
menu->add("Audio/Stop engine", FL_F + 8, stop_callback, this);
@ -43,7 +45,7 @@ MainWindow::MainWindow()
play_ = new Fl_Button(12, 40, 80, 30, "Play");
play_->callback(play_callback, this);
play_->tooltip("Start the audio engine (F5 or Space). No sequencer content yet.");
play_->tooltip("Start the audio engine (F5 or Space). Regions are silent until audio/MIDI phases.");
stop_ = new Fl_Button(100, 40, 80, 30, "Stop");
stop_->callback(stop_callback, this);
stop_->tooltip("Stop the audio engine (F8 or Space).");
@ -54,7 +56,7 @@ MainWindow::MainWindow()
indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
indicator_->labelfont(FL_BOLD);
auto *hint = new Fl_Box(12, 78, 976, 24,
"Edit signal flow in Modular Graph. No hardware ports are connected automatically; sequencer and recording are not implemented.");
"Edit signal flow in Modular Graph. No hardware ports are connected automatically; timeline regions are silent; recording is a future phase.");
hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
hint->labelsize(12);
@ -62,7 +64,24 @@ MainWindow::MainWindow()
tabs_->begin();
auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer");
sequencer->begin();
new Fl_Box(24, 164, 940, 40, "Sequencer placeholder — timeline and clips are not implemented yet.");
auto *add_track = new Fl_Button(20, 150, 100, 26, "Add track");
auto *add_region = new Fl_Button(128, 150, 110, 26, "Add region");
auto *delete_region = new Fl_Button(246, 150, 130, 26, "Delete region/track");
auto *zoom_in = new Fl_Button(384, 150, 80, 26, "Zoom +");
auto *zoom_out = new Fl_Button(472, 150, 80, 26, "Zoom -");
auto *timeline_hint = new Fl_Box(20, 548, 956, 28,
"Silent regions. Drag body to move, edges to trim; ruler seeks while stopped.");
timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); timeline_hint->labelsize(12);
auto *timeline_scroll = new Fl_Scroll(20, 188, 956, 350);
sequencer_ = new SequencerWidget(20, 188, project_.get(), seek, this);
timeline_scroll->end();
sequencer_->callback([](Fl_Widget *, void *c) { static_cast<MainWindow *>(c)->mark_dirty(); }, this);
add_track->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_track(); }, sequencer_);
add_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_region(); }, sequencer_);
delete_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->delete_selection(); }, sequencer_);
zoom_in->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->zoom(1); }, sequencer_);
zoom_out->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->zoom(-1); }, sequencer_);
sequencer->resizable(timeline_scroll);
sequencer->end();
auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer");
mixer->begin();
@ -78,7 +97,7 @@ MainWindow::MainWindow()
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);
graph_widget_ = new GraphWidget(20, 212, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback);
scroll->end();
auto *oscillator = new Fl_Button(20, 150, 130, 26, "Add oscillator");
oscillator->callback([](Fl_Widget *, void *context) {
@ -137,7 +156,8 @@ MainWindow::~MainWindow()
void MainWindow::report_error(const char *operation, xtc_result result)
{
char text[384];
const char *hint = result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support." :
const char *hint = std::strstr(operation, "Project") != nullptr ? "Check the project path, file format and write permissions." :
result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support." :
"Check the JACK-compatible server and use Reconnect to retry.";
std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint);
set_label(error_, text);
@ -148,12 +168,19 @@ xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, siz
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);
xtc_result result = xtc_graph_apply_edit(xtc_project_graph(window->project_.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);
xtc_project *replacement = nullptr;
result = xtc_project_clone(window->project_.get(), &replacement);
if (result == XTC_OK) result = xtc_project_set_graph(replacement, candidate);
xtc_graph_destroy(candidate);
if (result == XTC_OK && window->ready_)
result = xtc_backend_submit(window->backend_, xtc_project_graph(replacement));
if (result != XTC_OK) { xtc_project_destroy(replacement); return result; }
window->project_.reset(replacement);
window->graph_widget_->set_graph(xtc_project_graph(replacement));
window->sequencer_->set_project(replacement, false);
window->mark_dirty();
if (out_added_id != nullptr && edit.kind == XTC_EDIT_ADD_NODE) *out_added_id = added_id;
window->refresh_status();
return XTC_OK;
@ -167,7 +194,7 @@ void MainWindow::open_backend()
running_ = false;
if (result != XTC_OK) report_error("Open JACK failed", result);
else {
result = xtc_backend_submit(backend_, graph_.get());
result = xtc_backend_submit(backend_, xtc_project_graph(project_.get()));
if (result != XTC_OK) {
ready_ = false; // Handle is retained for close/reconnect; graph was not installed.
report_error("Submit graph failed", result);
@ -229,6 +256,9 @@ void MainWindow::refresh_status()
set_label(status_, "Unable to read JACK status — use Reconnect");
indicator_->labelcolor(fl_rgb_color(255, 155, 120));
} else {
uint64_t tick = 0;
if (xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &tick) == XTC_OK)
sequencer_->position(tick);
running_ = status.running != 0;
const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK;
can_play = !running_ && !fault;
@ -258,6 +288,7 @@ void MainWindow::refresh_status()
bool MainWindow::close_window()
{
if (!discard_changes()) return false;
xtc_result result = xtc_backend_close(backend_);
if (result != XTC_OK) {
report_error("Close JACK failed; window kept open for retry", result);
@ -297,7 +328,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 4: modular graph editor; sequencer and mixer remain placeholders.",
fl_message("%s %s\nModular audio workstation\nPhase 5: projects and silent arrangement regions; audio/MIDI content follows.",
XTC_PROJECT_NAME, XTC_VERSION_STRING);
}
void MainWindow::timer_callback(void *context)
@ -306,3 +337,91 @@ void MainWindow::timer_callback(void *context)
window->refresh_status();
if (!window->closing_) Fl::repeat_timeout(0.1, timer_callback, context);
}
void MainWindow::mark_dirty()
{ dirty_ = true; copy_label("xtc * — unsaved changes"); }
bool MainWindow::discard_changes()
{
return !dirty_ || !prompt_unsaved_ ||
fl_choice("Discard unsaved project changes?", "Cancel", "Discard", nullptr) == 1;
}
xtc_result MainWindow::seek(void *context, uint64_t tick)
{
auto *window = static_cast<MainWindow *>(context);
if (!window->ready_) return XTC_OK;
xtc_backend_status status{};
xtc_result r = xtc_backend_get_status(window->backend_, &status);
uint64_t sample = 0;
if (r == XTC_OK) r = xtc_project_tick_to_sample(window->project_.get(), tick, status.sample_rate, &sample);
return r == XTC_OK ? xtc_backend_seek(window->backend_, sample) : r;
}
xtc_result MainWindow::save_project(const char *path)
{
if (path == nullptr) return XTC_ERR_INVALID_ARGUMENT;
std::string saved_path(path);
xtc_result r = xtc_project_save(project_.get(), path);
if (r == XTC_OK) { path_ = saved_path; dirty_ = false; copy_label(path_.c_str()); }
return r;
}
xtc_result MainWindow::install_project(xtc_project *candidate)
{
xtc_result r = XTC_OK;
if (ready_) {
r = xtc_backend_stop(backend_);
if (r == XTC_OK) r = xtc_backend_seek(backend_, 0);
if (r == XTC_OK) r = xtc_backend_submit(backend_, xtc_project_graph(candidate));
}
if (r != XTC_OK) return r;
project_.reset(candidate);
graph_widget_->reset_layout();
graph_widget_->set_graph(xtc_project_graph(candidate));
sequencer_->set_project(candidate);
dirty_ = false;
refresh_status();
return XTC_OK;
}
xtc_result MainWindow::open_project(const char *path)
{
if (path == nullptr) return XTC_ERR_INVALID_ARGUMENT;
std::string loaded_path(path);
xtc_project *candidate = nullptr;
xtc_result r = xtc_project_create(&candidate);
if (r == XTC_OK) r = xtc_project_load(candidate, path);
if (r == XTC_OK) r = install_project(candidate);
if (r != XTC_OK) xtc_project_destroy(candidate);
else { path_ = loaded_path; copy_label(path_.c_str()); }
return r;
}
void MainWindow::file_callback(Fl_Widget *widget, void *context)
{
auto *window = static_cast<MainWindow *>(context);
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
const char *action = menu->mvalue()->label();
xtc_result r = XTC_OK;
if (std::strcmp(action, "New") == 0) {
if (!window->discard_changes()) return;
xtc_project *candidate = nullptr;
r = xtc_project_create(&candidate);
if (r == XTC_OK) r = window->install_project(candidate);
if (r != XTC_OK) xtc_project_destroy(candidate);
else { window->path_.clear(); window->copy_label("xtc — Untitled"); }
} else {
bool open = std::strcmp(action, "Open") == 0;
if (open && !window->discard_changes()) return;
if (!open && std::strcmp(action, "Save") == 0 && !window->path_.empty())
r = window->save_project(window->path_.c_str());
else {
Fl_Native_File_Chooser chooser;
chooser.title(open ? "Open project" : "Save project");
chooser.type(open ? Fl_Native_File_Chooser::BROWSE_FILE : Fl_Native_File_Chooser::BROWSE_SAVE_FILE);
chooser.filter("xtc projects *.xtc");
if (!open) chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM);
int result = chooser.show();
if (result == 1) return;
if (result < 0) r = XTC_ERR_IO;
else r = open ? window->open_project(chooser.filename()) : window->save_project(chooser.filename());
}
}
if (r != XTC_OK) window->report_error("Project operation failed", r);
}

View file

@ -3,24 +3,37 @@
#include <FL/Fl_Double_Window.H>
#include <memory>
#include <string>
#include "xtc/xtc_project.h"
#include "xtc/xtc_backend.h"
class GraphWidget;
class SequencerWidget;
class Fl_Box;
class Fl_Button;
class Fl_Tabs;
class MainWindow : public Fl_Double_Window {
public:
MainWindow();
explicit MainWindow(bool prompt_unsaved = true);
~MainWindow() override;
MainWindow(const MainWindow &) = delete;
MainWindow &operator=(const MainWindow &) = delete;
using Fl_Double_Window::show;
int handle(int event) override;
xtc_result save_project(const char *path);
xtc_result open_project(const char *path);
private:
std::unique_ptr<xtc_graph, decltype(&xtc_graph_destroy)> graph_{nullptr, xtc_graph_destroy};
std::unique_ptr<xtc_project, decltype(&xtc_project_destroy)> project_{nullptr, xtc_project_destroy};
SequencerWidget *sequencer_ = nullptr;
bool dirty_ = false, prompt_unsaved_ = true;
std::string path_;
bool discard_changes();
void mark_dirty();
xtc_result install_project(xtc_project *candidate);
static xtc_result seek(void *context, uint64_t tick);
static void file_callback(Fl_Widget *, void *context);
GraphWidget *graph_widget_ = nullptr;
Fl_Box *graph_state_ = nullptr;
xtc_backend *backend_ = nullptr;

View file

@ -0,0 +1,159 @@
#include "sequencer_widget.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Scroll.H>
#include <FL/fl_draw.H>
#include <FL/fl_ask.H>
#include <algorithm>
#include <cstdio>
SequencerWidget::SequencerWidget(int left, int top, xtc_project *project, Seek seek, void *context)
: Fl_Widget(left, top, 6300, 3700), project_(project), seek_(seek), context_(context) { extent(); }
void SequencerWidget::set_project(xtc_project *project, bool reset)
{
project_ = project;
if (reset) { track_ = clip_ = UINT64_MAX; cursor_ = 0; mode_ = 0; }
extent(); redraw();
}
uint64_t SequencerWidget::tick(int px) const
{
return std::min(XTC_PROJECT_MAX_TICK, static_cast<uint64_t>(std::max(0, px - x() - 120)) * 960U /
static_cast<unsigned int>(scale_) / 240U * 240U);
}
void SequencerWidget::extent()
{
size_t tracks, clips;
xtc_project_get_counts(project_, &tracks, &clips);
uint64_t end = 32U * 3840U;
for (size_t i = 0; i < clips; ++i) {
xtc_clip_info c{}; xtc_project_get_clip(project_, i, &c);
end = std::max(end, c.start_tick + c.length_tick + 3840U);
}
size(static_cast<int>(end * static_cast<unsigned int>(scale_) / 960U + 140U),
std::max(400, static_cast<int>(tracks) * 56 + 100));
if (parent()) parent()->redraw();
}
void SequencerWidget::changed(xtc_result result)
{
if (result != XTC_OK) fl_alert("%s", xtc_result_string(result));
else { extent(); do_callback(); }
redraw();
}
xtc_result SequencerWidget::add_track()
{
uint64_t id; xtc_result r = xtc_project_add_track(project_, "Track", &id);
if (r == XTC_OK) { track_ = id; clip_ = UINT64_MAX; } changed(r); return r;
}
xtc_result SequencerWidget::add_region()
{
size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips);
if (track_ == UINT64_MAX && tracks) {
xtc_track_info t{}; xtc_project_get_track(project_, 0, &t); track_ = t.id;
}
uint64_t id;
xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, 3840U, &id);
if (r == XTC_OK) clip_ = id;
changed(r); return r;
}
xtc_result SequencerWidget::delete_selection()
{
xtc_result r = clip_ != UINT64_MAX ? xtc_project_remove_clip(project_, clip_) :
xtc_project_remove_track(project_, track_);
if (r == XTC_OK) clip_ = track_ = UINT64_MAX;
changed(r); return r;
}
void SequencerWidget::zoom(int direction)
{ scale_ = std::clamp(direction > 0 ? scale_ * 2 : scale_ / 2, 24, 384); extent(); redraw(); }
void SequencerWidget::draw()
{
fl_push_clip(x(), y(), w(), h());
fl_color(fl_rgb_color(35, 38, 43)); fl_rectf(x(), y(), w(), h());
int cx, cy, cw, ch; fl_clip_box(x(), y(), w(), h(), cx, cy, cw, ch);
fl_font(FL_HELVETICA, 12);
int first = std::max(0, (cx - x() - 120) / (scale_ * 4));
int last = (cx + cw - x() - 120) / (scale_ * 4) + 1;
for (int bar = first; bar <= last; ++bar) {
int px = x() + 120 + bar * scale_ * 4;
fl_color(fl_rgb_color(65, 68, 74)); fl_line(px, y(), px, y() + h());
char label[32]; std::snprintf(label, sizeof(label), "%d", bar + 1);
fl_color(FL_WHITE); fl_draw(label, px + 4, y() + 19);
}
char tempo[32];
std::snprintf(tempo, sizeof(tempo), "%.3g BPM | 4/4", xtc_project_tempo(project_) / 1000.0);
fl_color(FL_WHITE); fl_draw(tempo, x() + 4, y() + 19);
size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips);
for (size_t i = 0; i < tracks; ++i) {
xtc_track_info t{}; xtc_project_get_track(project_, i, &t);
int py = y() + 28 + static_cast<int>(i) * 56;
fl_color(t.id == track_ ? fl_rgb_color(70, 90, 110) : fl_rgb_color(48, 51, 57));
fl_rectf(x(), py, 118, 55);
fl_color(FL_WHITE); fl_draw(t.name, x() + 6, py + 32);
for (size_t j = 0; j < clips; ++j) {
xtc_clip_info c{}; xtc_project_get_clip(project_, j, &c);
if (c.track_id != t.id) continue;
int left = x() + 120 + static_cast<int>(c.start_tick * static_cast<unsigned int>(scale_) / 960U);
int right = left + std::max(4, static_cast<int>(c.length_tick * static_cast<unsigned int>(scale_) / 960U));
int a = std::max(left, cx), b = std::min(right, cx + cw);
if (b <= a || py + 50 < cy || py > cy + ch) continue;
fl_color(c.id == clip_ ? fl_rgb_color(100, 170, 205) : fl_rgb_color(65, 120, 150));
fl_rectf(a, py + 5, b - a, 44);
fl_push_clip(a, py + 5, b - a, 44); fl_color(FL_WHITE);
fl_draw(c.name, std::max(a, left + 6), py + 31); fl_pop_clip();
}
}
int playhead = x() + 120 + static_cast<int>(cursor_ * static_cast<unsigned int>(scale_) / 960U);
if (playhead >= cx && playhead <= cx + cw) {
fl_color(FL_RED); fl_line(playhead, y(), playhead, y() + h());
}
fl_pop_clip();
}
int SequencerWidget::handle(int event)
{
if (event == FL_PUSH && Fl::event_button() == FL_LEFT_MOUSE) {
take_focus(); mode_ = 0;
if (Fl::event_y() < y() + 28) {
uint64_t target = tick(Fl::event_x());
xtc_result r = seek_(context_, target);
if (r == XTC_OK) cursor_ = target;
else fl_alert("Stop playback before seeking: %s", xtc_result_string(r));
redraw(); return 1;
}
size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips);
int row = (Fl::event_y() - y() - 28) / 56;
if (row < 0 || static_cast<size_t>(row) >= tracks) return 1;
xtc_track_info t{}; xtc_project_get_track(project_, static_cast<size_t>(row), &t); track_ = t.id;
clip_ = UINT64_MAX;
for (size_t j = clips; j > 0; --j) {
xtc_clip_info c{}; xtc_project_get_clip(project_, j - 1, &c);
int left = x() + 120 + static_cast<int>(c.start_tick * static_cast<unsigned int>(scale_) / 960U);
int right = left + static_cast<int>(c.length_tick * static_cast<unsigned int>(scale_) / 960U);
if (c.track_id == track_ && Fl::event_x() >= left && Fl::event_x() < right) {
clip_ = c.id; original_ = c; drag_x_ = Fl::event_x();
mode_ = Fl::event_x() - left < 7 ? 2 : right - Fl::event_x() < 7 ? 3 : 1; break;
}
}
redraw(); return 1;
}
if (event == FL_DRAG) return mode_ != 0;
if (event == FL_RELEASE && mode_) {
int mode = mode_; mode_ = 0;
int64_t delta = static_cast<int64_t>(Fl::event_x() - drag_x_) * 960 / scale_ / 240 * 240;
int64_t start = static_cast<int64_t>(original_.start_tick);
int64_t length = static_cast<int64_t>(original_.length_tick);
int64_t offset = static_cast<int64_t>(original_.offset_tick);
uint64_t target = original_.track_id;
if (mode == 1) {
start = std::max<int64_t>(0, start + delta);
int row = (Fl::event_y() - y() - 28) / 56;
xtc_track_info t{};
if (row >= 0 && xtc_project_get_track(project_, static_cast<size_t>(row), &t) == XTC_OK) target = t.id;
} else if (mode == 2) { start += delta; offset += delta; length -= delta; }
else length += delta;
if (start < 0 || offset < 0 || length <= 0) return 1;
changed(xtc_project_edit_clip(project_, clip_, target, static_cast<uint64_t>(start),
static_cast<uint64_t>(length), static_cast<uint64_t>(offset)));
return 1;
}
if (event == FL_KEYDOWN && Fl::event_key() == FL_Escape) { mode_ = 0; return 1; }
if (event == FL_KEYDOWN && Fl::event_key() == FL_Delete) { delete_selection(); return 1; }
if (event == FL_FOCUS || event == FL_UNFOCUS) return 1;
return Fl_Widget::handle(event);
}

View file

@ -0,0 +1,31 @@
#ifndef XTC_SEQUENCER_WIDGET_HPP
#define XTC_SEQUENCER_WIDGET_HPP
#include <FL/Fl_Widget.H>
#include "xtc/xtc_project.h"
class SequencerWidget : public Fl_Widget {
public:
using Seek = xtc_result (*)(void *, uint64_t);
SequencerWidget(int x, int y, xtc_project *project, Seek seek, void *context);
void set_project(xtc_project *project, bool reset = true);
uint64_t position() const { return cursor_; }
void position(uint64_t tick) { cursor_ = tick; redraw(); }
xtc_result add_track();
xtc_result add_region();
xtc_result delete_selection();
void zoom(int direction);
xtc_project *project() const { return project_; }
int handle(int event) override;
protected:
void draw() override;
private:
xtc_project *project_;
Seek seek_;
void *context_;
uint64_t track_ = UINT64_MAX, clip_ = UINT64_MAX, cursor_ = 0;
int scale_ = 48, drag_x_ = 0, mode_ = 0;
xtc_clip_info original_{};
uint64_t tick(int px) const;
void changed(xtc_result result);
void extent();
};
#endif

View file

@ -1,5 +1,6 @@
# Persistence
Project save/load is intentionally unimplemented in this scaffold. The project
format must be versioned, validated, and saved atomically. File I/O must never
occur in the audio callback. See `docs/PROJECT_FORMAT.md`.
project_file.c implements bounded version-1 project metadata loading and atomic
temporary-file/fsync/rename saving in C. Loading validates a candidate before
replacing the existing model. All I/O is control-thread only.
See docs/PROJECT_FORMAT.md for the schema, limits and durability guarantees.

View file

@ -0,0 +1,227 @@
#define _POSIX_C_SOURCE 200809L
#include "../project/project_internal.h"
#include "../graph/graph_internal.h"
#include "xtc/xtc.h"
#include <float.h>
#include <inttypes.h>
#include <limits.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
typedef struct { FILE *file; size_t bytes; char line[512]; char *tokens[12]; size_t count; } reader;
static xtc_result line(reader *input)
{
size_t used = 0U;
int byte;
char *cursor;
input->count = 0U;
while ((byte = fgetc(input->file)) != EOF) {
if (++input->bytes > 1048576U) return XTC_ERR_FORMAT;
if (byte == '\n') break;
if (byte < 32 || byte > 126 || used == sizeof(input->line) - 1U) return XTC_ERR_FORMAT;
input->line[used++] = (char)byte;
}
if (byte == EOF) return ferror(input->file) ? XTC_ERR_IO : XTC_ERR_FORMAT;
input->line[used] = '\0'; cursor = input->line;
while (*cursor != '\0') {
while (*cursor == ' ') ++cursor;
if (*cursor == '\0') break;
if (input->count == 12U) return XTC_ERR_FORMAT;
input->tokens[input->count++] = cursor;
while (*cursor != '\0' && *cursor != ' ') ++cursor;
if (*cursor == ' ') *cursor++ = '\0';
}
return input->count == 0U ? XTC_ERR_FORMAT : XTC_OK;
}
static int number(const char *text, uint64_t *value)
{
uint64_t result = 0U;
const unsigned char *cursor = (const unsigned char *)text;
if (*cursor == '\0') return 0;
while (*cursor != '\0') {
unsigned int digit;
if (*cursor < '0' || *cursor > '9') return 0;
digit = (unsigned int)(*cursor++ - '0');
if (result > (UINT64_MAX - digit) / 10U) return 0;
result = result * 10U + digit;
}
*value = result; return 1;
}
static int hex_digit(char byte)
{
if (byte >= '0' && byte <= '9') return byte - '0';
if (byte >= 'a' && byte <= 'f') return byte - 'a' + 10;
if (byte >= 'A' && byte <= 'F') return byte - 'A' + 10;
return -1;
}
static int decode_name(const char *text, char *name)
{
size_t length = strlen(text), i;
if (length == 0U || length % 2U != 0U || length > 126U) return 0;
for (i = 0U; i < length / 2U; ++i) {
int high = hex_digit(text[i * 2U]), low = hex_digit(text[i * 2U + 1U]);
if (high < 0 || low < 0 || (high == 0 && low == 0)) return 0;
name[i] = (char)(high * 16 + low);
}
name[length / 2U] = '\0'; return xtc_project_valid_name(name);
}
static int decode_float(const char *text, float *value)
{
uint32_t bits = 0U;
size_t i;
if (strlen(text) != 8U || (sizeof(*value) != sizeof(bits) || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128)) return 0;
for (i = 0U; i < 8U; ++i) {
int digit = hex_digit(text[i]);
if (digit < 0) return 0;
bits = (bits << 4U) | (uint32_t)digit;
}
memcpy(value, &bits, sizeof(bits)); return isfinite(*value);
}
#define READ(tag, total) do { xtc_result read_result = line(input); if (read_result != XTC_OK) return read_result; \
if (input->count != (total) || strcmp(input->tokens[0], (tag)) != 0) return XTC_ERR_FORMAT; } while (0)
#define NUM(index, variable) do { if (!number(input->tokens[(index)], &(variable))) return XTC_ERR_FORMAT; } while (0)
static xtc_result read_project(reader *input, xtc_project *project)
{
uint64_t version, value, next_id, node_count, edge_count;
size_t i, j;
xtc_graph *graph = project->graph;
READ("XTC_PROJECT", 2U); NUM(1U, version);
if (version != 1U) return XTC_ERR_UNSUPPORTED;
READ("APP", 4U);
for (i = 1U; i < 4U; ++i) { NUM(i, value); if (value > UINT_MAX) return XTC_ERR_FORMAT; }
READ("TEMPO", 2U); NUM(1U, value);
if (value < 20000U || value > 300000U) return XTC_ERR_FORMAT;
project->tempo = (unsigned int)value;
READ("IDS", 3U); NUM(1U, project->next_track_id); NUM(2U, project->next_clip_id);
READ("GRAPH", 5U);
NUM(1U, value); NUM(2U, next_id); NUM(3U, node_count); NUM(4U, edge_count);
if (value > 1U || next_id > SIZE_MAX || node_count > 64U || edge_count > node_count * node_count) return XTC_ERR_FORMAT;
graph->require_output_nodes = (int)value; graph->next_id = (size_t)next_id;
for (i = 0U; i < (size_t)node_count; ++i) {
xtc_node node;
READ("NODE", 4U); NUM(1U, value); NUM(2U, version);
if (value >= next_id || value >= SIZE_MAX || version < XTC_NODE_GAIN || version > XTC_NODE_OUTPUT ||
!decode_float(input->tokens[3], &node.value)) return XTC_ERR_FORMAT;
node.id = (size_t)value; node.type = (xtc_node_type)version;
if (node.type == XTC_NODE_OSCILLATOR && node.value < 0.0f) return XTC_ERR_FORMAT;
if ((node.type == XTC_NODE_METER || node.type == XTC_NODE_OUTPUT) && node.value != 0.0f) return XTC_ERR_FORMAT;
for (j = 0U; j < i; ++j) if (graph->nodes[j].id == node.id) return XTC_ERR_FORMAT;
graph->nodes[graph->node_count++] = node;
}
for (i = 0U; i < (size_t)edge_count; ++i) {
uint64_t source, dest;
READ("EDGE", 3U); NUM(1U, source); NUM(2U, dest);
if (source >= SIZE_MAX || dest >= SIZE_MAX ||
xtc_graph_connect(graph, (size_t)source, (size_t)dest) != XTC_OK) return XTC_ERR_FORMAT;
}
READ("TRACKS", 2U); NUM(1U, value);
if (value > XTC_PROJECT_MAX_TRACKS) return XTC_ERR_FORMAT;
project->track_count = (size_t)value;
for (i = 0U; i < project->track_count; ++i) {
READ("TRACK", 3U); NUM(1U, project->tracks[i].id);
if (!decode_name(input->tokens[2], project->tracks[i].name)) return XTC_ERR_FORMAT;
}
READ("CLIPS", 2U); NUM(1U, value);
if (value > XTC_PROJECT_MAX_CLIPS) return XTC_ERR_FORMAT;
project->clip_count = (size_t)value;
for (i = 0U; i < project->clip_count; ++i) {
xtc_clip_info *clip = project->clips + i;
READ("CLIP", 8U);
NUM(1U, clip->id); NUM(2U, clip->track_id); NUM(3U, clip->start_tick);
NUM(4U, clip->length_tick); NUM(5U, clip->offset_tick); NUM(6U, clip->source_length_tick);
if (!decode_name(input->tokens[7], clip->name)) return XTC_ERR_FORMAT;
}
READ("END", 1U);
if (fgetc(input->file) != EOF) return XTC_ERR_FORMAT;
if (ferror(input->file)) return XTC_ERR_IO;
return xtc_project_validate(project);
}
static int write_name(FILE *file, const char *name)
{
const unsigned char *cursor = (const unsigned char *)name;
while (*cursor != '\0') if (fprintf(file, "%02x", (unsigned int)*cursor++) < 0) return 0;
return fputc('\n', file) != EOF;
}
static xtc_result write_project(FILE *file, const xtc_project *project)
{
const xtc_graph *graph = project->graph;
size_t i;
if (sizeof(float) != sizeof(uint32_t) || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128) return XTC_ERR_UNSUPPORTED;
if (fprintf(file, "XTC_PROJECT 1\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n",
XTC_VERSION_MAJOR, XTC_VERSION_MINOR, XTC_VERSION_PATCH, project->tempo,
project->next_track_id, project->next_clip_id, graph->require_output_nodes,
graph->next_id, graph->node_count, graph->edge_count) < 0) return XTC_ERR_IO;
for (i = 0U; i < graph->node_count; ++i) {
uint32_t bits;
memcpy(&bits, &graph->nodes[i].value, sizeof(bits));
if (fprintf(file, "NODE %zu %u %08" PRIx32 "\n", graph->nodes[i].id,
(unsigned int)graph->nodes[i].type, bits) < 0) return XTC_ERR_IO;
}
for (i = 0U; i < graph->edge_count; ++i)
if (fprintf(file, "EDGE %zu %zu\n", graph->nodes[graph->edges[i].source].id,
graph->nodes[graph->edges[i].destination].id) < 0) return XTC_ERR_IO;
if (fprintf(file, "TRACKS %zu\n", project->track_count) < 0) return XTC_ERR_IO;
for (i = 0U; i < project->track_count; ++i)
if (fprintf(file, "TRACK %" PRIu64 " ", project->tracks[i].id) < 0 || !write_name(file, project->tracks[i].name)) return XTC_ERR_IO;
if (fprintf(file, "CLIPS %zu\n", project->clip_count) < 0) return XTC_ERR_IO;
for (i = 0U; i < project->clip_count; ++i) {
const xtc_clip_info *clip = project->clips + i;
if (fprintf(file, "CLIP %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 " ",
clip->id, clip->track_id, clip->start_tick, clip->length_tick, clip->offset_tick, clip->source_length_tick) < 0 ||
!write_name(file, clip->name)) return XTC_ERR_IO;
}
return fputs("END\n", file) == EOF ? XTC_ERR_IO : XTC_OK;
}
static size_t path_length(const char *path)
{
size_t size = 0U;
if (path == NULL) return 0U;
while (size < 4096U && path[size] != '\0') ++size;
return size == 4096U ? 0U : size;
}
xtc_result xtc_project_load(xtc_project *project, const char *path)
{
xtc_project *candidate = NULL;
reader input = {0};
xtc_result result;
if (project == NULL || path_length(path) == 0U) return XTC_ERR_INVALID_ARGUMENT;
input.file = fopen(path, "rb");
if (input.file == NULL) return XTC_ERR_IO;
result = xtc_project_create(&candidate);
if (result == XTC_OK) result = read_project(&input, candidate);
if (fclose(input.file) != 0 && result == XTC_OK) result = XTC_ERR_IO;
if (result == XTC_OK) {
xtc_graph *old = project->graph;
*project = *candidate;
candidate->graph = old;
}
xtc_project_destroy(candidate); return result;
}
xtc_result xtc_project_save(const xtc_project *project, const char *path)
{
size_t length = path_length(path);
char *temporary;
FILE *file;
int descriptor;
xtc_result result;
if (project == NULL || length == 0U) return XTC_ERR_INVALID_ARGUMENT;
result = xtc_project_validate(project);
if (result != XTC_OK) return result;
temporary = malloc(length + 12U);
if (temporary == NULL) return XTC_ERR_OUT_OF_MEMORY;
memcpy(temporary, path, length); memcpy(temporary + length, ".tmp.XXXXXX", 12U);
descriptor = mkstemp(temporary);
if (descriptor < 0) { free(temporary); return XTC_ERR_IO; }
file = fdopen(descriptor, "wb");
if (file == NULL) { (void)close(descriptor); (void)unlink(temporary); free(temporary); return XTC_ERR_IO; }
result = write_project(file, project);
if (result == XTC_OK && fflush(file) != 0) result = XTC_ERR_IO;
if (result == XTC_OK && fsync(descriptor) != 0) result = XTC_ERR_IO;
if (fclose(file) != 0 && result == XTC_OK) result = XTC_ERR_IO;
if (result == XTC_OK && rename(temporary, path) != 0) result = XTC_ERR_IO;
if (result != XTC_OK) (void)unlink(temporary);
free(temporary); return result;
}

View file

@ -1 +1,6 @@
Project domain model will live here. Keep it in C and separate from FLTK widgets.
# Project model
project.c owns the opaque C project: graph, bounded tracks, silent arrangement
regions, tempo and stable ID allocation. The GUI borrows handles and edits through
xtc_project.h. Tick/sample conversion and source-preserving trim validation live
here; no FLTK, file I/O or callback scheduling belongs in this module.

222
src/project/project.c Normal file
View file

@ -0,0 +1,222 @@
#include "project_internal.h"
#include "../graph/graph_internal.h"
#include <stdlib.h>
#include <string.h>
int xtc_project_valid_name(const char *name)
{
size_t length = 0U, i = 0U;
if (name == NULL) return 0;
while (length < XTC_PROJECT_NAME_BYTES && name[length] != '\0') ++length;
if (length == 0U || length == XTC_PROJECT_NAME_BYTES) return 0;
while (i < length) {
unsigned char byte = (unsigned char)name[i++];
size_t extra, j;
if (byte < 32U || byte == 127U) return 0;
if (byte < 128U) continue;
if (byte >= 194U && byte <= 223U) extra = 1U;
else if (byte >= 224U && byte <= 239U) extra = 2U;
else if (byte >= 240U && byte <= 244U) extra = 3U;
else return 0;
if (extra > length - i) return 0;
if ((byte == 224U && (unsigned char)name[i] < 160U) ||
(byte == 237U && (unsigned char)name[i] >= 160U) ||
(byte == 240U && (unsigned char)name[i] < 144U) ||
(byte == 244U && (unsigned char)name[i] >= 144U)) return 0;
for (j = 0U; j < extra; ++j) {
unsigned char tail = (unsigned char)name[i++];
if (tail < 128U || tail > 191U) return 0;
}
}
return 1;
}
static size_t track_index(const xtc_project *project, uint64_t id)
{
size_t i;
for (i = 0U; i < project->track_count; ++i) if (project->tracks[i].id == id) return i;
return SIZE_MAX;
}
static size_t clip_index(const xtc_project *project, uint64_t id)
{
size_t i;
for (i = 0U; i < project->clip_count; ++i) if (project->clips[i].id == id) return i;
return SIZE_MAX;
}
static int valid_range(uint64_t start, uint64_t length, uint64_t offset, uint64_t source)
{
return start <= XTC_PROJECT_MAX_TICK && length != 0U &&
length <= XTC_PROJECT_MAX_TICK - start && source <= XTC_PROJECT_MAX_TICK &&
offset <= source && length <= source - offset;
}
xtc_result xtc_project_create(xtc_project **out_project)
{
xtc_project *project;
xtc_result result;
if (out_project == NULL) return XTC_ERR_INVALID_ARGUMENT;
*out_project = NULL;
project = calloc(1U, sizeof(*project));
if (project == NULL) return XTC_ERR_OUT_OF_MEMORY;
result = xtc_graph_create(64U, &project->graph);
if (result != XTC_OK) { free(project); return result; }
project->graph->require_output_nodes = 1;
project->tempo = 120000U;
*out_project = project;
return XTC_OK;
}
void xtc_project_destroy(xtc_project *project)
{ if (project != NULL) { xtc_graph_destroy(project->graph); free(project); } }
const xtc_graph *xtc_project_graph(const xtc_project *project)
{ return project == NULL ? NULL : project->graph; }
xtc_result xtc_project_set_graph(xtc_project *project, const xtc_graph *source)
{
xtc_graph *copy;
xtc_result result;
if (project == NULL || source == NULL) return XTC_ERR_INVALID_ARGUMENT;
if (source->node_count > 64U || source->edge_count > 4096U) return XTC_ERR_CAPACITY;
result = xtc_graph_validate(source);
if (result != XTC_OK) return result;
result = xtc_graph_create(64U, &copy);
if (result != XTC_OK) return result;
copy->node_count = source->node_count; copy->edge_count = source->edge_count;
copy->next_id = source->next_id; copy->require_output_nodes = source->require_output_nodes;
memcpy(copy->nodes, source->nodes, source->node_count * sizeof(*copy->nodes));
memcpy(copy->edges, source->edges, source->edge_count * sizeof(*copy->edges));
xtc_graph_destroy(project->graph); project->graph = copy;
return XTC_OK;
}
xtc_result xtc_project_clone(const xtc_project *source, xtc_project **out_project)
{
xtc_project *copy;
xtc_result result;
if (out_project == NULL) return XTC_ERR_INVALID_ARGUMENT;
*out_project = NULL;
if (source == NULL) return XTC_ERR_INVALID_ARGUMENT;
copy = malloc(sizeof(*copy));
if (copy == NULL) return XTC_ERR_OUT_OF_MEMORY;
*copy = *source; copy->graph = NULL;
result = xtc_project_set_graph(copy, source->graph);
if (result != XTC_OK) { free(copy); return result; }
*out_project = copy;
return XTC_OK;
}
xtc_result xtc_project_get_counts(const xtc_project *project, size_t *tracks, size_t *clips)
{
if (project == NULL || tracks == NULL || clips == NULL) return XTC_ERR_INVALID_ARGUMENT;
*tracks = project->track_count; *clips = project->clip_count; return XTC_OK;
}
xtc_result xtc_project_get_track(const xtc_project *project, size_t index, xtc_track_info *track)
{
if (project == NULL || track == NULL) return XTC_ERR_INVALID_ARGUMENT;
if (index >= project->track_count) return XTC_ERR_NOT_FOUND;
*track = project->tracks[index]; return XTC_OK;
}
xtc_result xtc_project_get_clip(const xtc_project *project, size_t index, xtc_clip_info *clip)
{
if (project == NULL || clip == NULL) return XTC_ERR_INVALID_ARGUMENT;
if (index >= project->clip_count) return XTC_ERR_NOT_FOUND;
*clip = project->clips[index]; return XTC_OK;
}
xtc_result xtc_project_find_clip(const xtc_project *project, uint64_t id, xtc_clip_info *clip)
{
if (project == NULL) return XTC_ERR_INVALID_ARGUMENT;
return xtc_project_get_clip(project, clip_index(project, id), clip);
}
xtc_result xtc_project_add_track(xtc_project *project, const char *name, uint64_t *id)
{
xtc_track_info track = {0};
if (project == NULL || id == NULL || !xtc_project_valid_name(name)) return XTC_ERR_INVALID_ARGUMENT;
if (project->track_count == XTC_PROJECT_MAX_TRACKS || project->next_track_id == UINT64_MAX) return XTC_ERR_CAPACITY;
track.id = project->next_track_id++;
memcpy(track.name, name, strlen(name) + 1U);
project->tracks[project->track_count++] = track; *id = track.id; return XTC_OK;
}
xtc_result xtc_project_remove_track(xtc_project *project, uint64_t id)
{
size_t index, i, count = 0U;
if (project == NULL) return XTC_ERR_INVALID_ARGUMENT;
index = track_index(project, id);
if (index == SIZE_MAX) return XTC_ERR_NOT_FOUND;
for (i = 0U; i < project->clip_count; ++i)
if (project->clips[i].track_id != id) project->clips[count++] = project->clips[i];
project->clip_count = count;
memmove(project->tracks + index, project->tracks + index + 1U,
(project->track_count - index - 1U) * sizeof(*project->tracks));
--project->track_count; return XTC_OK;
}
xtc_result xtc_project_add_clip(xtc_project *project, uint64_t track, const char *name,
uint64_t start, uint64_t length, uint64_t *id)
{
xtc_clip_info clip = {0};
if (project == NULL || id == NULL || !xtc_project_valid_name(name) || !valid_range(start, length, 0U, length))
return XTC_ERR_INVALID_ARGUMENT;
if (track_index(project, track) == SIZE_MAX) return XTC_ERR_NOT_FOUND;
if (project->clip_count == XTC_PROJECT_MAX_CLIPS || project->next_clip_id == UINT64_MAX) return XTC_ERR_CAPACITY;
clip.id = project->next_clip_id++; clip.track_id = track;
clip.start_tick = start; clip.length_tick = clip.source_length_tick = length;
memcpy(clip.name, name, strlen(name) + 1U);
project->clips[project->clip_count++] = clip; *id = clip.id; return XTC_OK;
}
xtc_result xtc_project_remove_clip(xtc_project *project, uint64_t id)
{
size_t index;
if (project == NULL) return XTC_ERR_INVALID_ARGUMENT;
index = clip_index(project, id);
if (index == SIZE_MAX) return XTC_ERR_NOT_FOUND;
memmove(project->clips + index, project->clips + index + 1U,
(project->clip_count - index - 1U) * sizeof(*project->clips));
--project->clip_count; return XTC_OK;
}
xtc_result xtc_project_edit_clip(xtc_project *project, uint64_t id, uint64_t track,
uint64_t start, uint64_t length, uint64_t offset)
{
size_t index;
if (project == NULL) return XTC_ERR_INVALID_ARGUMENT;
index = clip_index(project, id);
if (index == SIZE_MAX || track_index(project, track) == SIZE_MAX) return XTC_ERR_NOT_FOUND;
if (!valid_range(start, length, offset, project->clips[index].source_length_tick)) return XTC_ERR_INVALID_ARGUMENT;
project->clips[index].track_id = track; project->clips[index].start_tick = start;
project->clips[index].length_tick = length; project->clips[index].offset_tick = offset;
return XTC_OK;
}
unsigned int xtc_project_tempo(const xtc_project *project) { return project == NULL ? 0U : project->tempo; }
xtc_result xtc_project_set_tempo(xtc_project *project, unsigned int tempo)
{
if (project == NULL || tempo < 20000U || tempo > 300000U) return XTC_ERR_INVALID_ARGUMENT;
project->tempo = tempo; return XTC_OK;
}
xtc_result xtc_project_tick_to_sample(const xtc_project *project, uint64_t tick, unsigned int rate, uint64_t *sample)
{
if (project == NULL || sample == NULL || rate == 0U || rate > 768000U) return XTC_ERR_INVALID_ARGUMENT;
if (tick > XTC_PROJECT_MAX_TICK) return XTC_ERR_CAPACITY;
*sample = tick * rate * UINT64_C(60000) / ((uint64_t)project->tempo * XTC_TICKS_PER_QUARTER);
return XTC_OK;
}
xtc_result xtc_project_sample_to_tick(const xtc_project *project, uint64_t sample, unsigned int rate, uint64_t *tick)
{
uint64_t scale, value, divisor, whole;
if (project == NULL || tick == NULL || rate == 0U || rate > 768000U) return XTC_ERR_INVALID_ARGUMENT;
scale = (uint64_t)project->tempo * XTC_TICKS_PER_QUARTER;
divisor = (uint64_t)rate * UINT64_C(60000);
whole = sample / divisor;
if (whole > XTC_PROJECT_MAX_TICK / scale) return XTC_ERR_CAPACITY;
value = whole * scale + (sample % divisor) * scale / divisor;
if (value > XTC_PROJECT_MAX_TICK) return XTC_ERR_CAPACITY;
*tick = value; return XTC_OK;
}
xtc_result xtc_project_validate(const xtc_project *project)
{
size_t i, j;
if (project->tempo < 20000U || project->tempo > 300000U ||
project->track_count > XTC_PROJECT_MAX_TRACKS || project->clip_count > XTC_PROJECT_MAX_CLIPS) return XTC_ERR_FORMAT;
for (i = 0U; i < project->track_count; ++i) {
if (project->tracks[i].id >= project->next_track_id || !xtc_project_valid_name(project->tracks[i].name)) return XTC_ERR_FORMAT;
for (j = 0U; j < i; ++j) if (project->tracks[j].id == project->tracks[i].id) return XTC_ERR_FORMAT;
}
for (i = 0U; i < project->clip_count; ++i) {
const xtc_clip_info *clip = project->clips + i;
if (clip->id >= project->next_clip_id || track_index(project, clip->track_id) == SIZE_MAX ||
!xtc_project_valid_name(clip->name) || !valid_range(clip->start_tick, clip->length_tick, clip->offset_tick, clip->source_length_tick)) return XTC_ERR_FORMAT;
for (j = 0U; j < i; ++j) if (project->clips[j].id == clip->id) return XTC_ERR_FORMAT;
}
return XTC_OK;
}

View file

@ -0,0 +1,14 @@
#ifndef XTC_PROJECT_INTERNAL_H
#define XTC_PROJECT_INTERNAL_H
#include "xtc/xtc_project.h"
struct xtc_project {
xtc_graph *graph;
uint64_t next_track_id, next_clip_id;
unsigned int tempo;
size_t track_count, clip_count;
xtc_track_info tracks[XTC_PROJECT_MAX_TRACKS];
xtc_clip_info clips[XTC_PROJECT_MAX_CLIPS];
};
int xtc_project_valid_name(const char *name);
xtc_result xtc_project_validate(const xtc_project *project);
#endif

View file

@ -88,3 +88,17 @@ 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.
## Phase 5 project and transport coverage
test_gui adds a track and region, injects FLTK mouse gestures for movement and
left trimming, checks zoom, saves/reopens the arrangement, and rejects missing
files and simulated backend failures without replacing the document.
A sample-status update drives the cursor to an exact tick; a graph edit retains
that position. test_gui_jack checks actual playhead advancement and a stable
paused cursor across repeated starts/stops. No routes are created.
test_project additionally covers serialization, exact graph samples/IDs,
truncation/mutation input, capacity, save failures and cleanup (part of make test).
The core/backend tests remain unsuppressed under ASan/UBSan; GUI checks retain
the narrowly scoped fontconfig-only suppression described above.

View file

@ -1,5 +1,7 @@
#include "../../src/gui/main_window.hpp"
#include "../../src/gui/graph_widget.hpp"
#include "../../src/gui/sequencer_widget.hpp"
#include <unistd.h>
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
#include <FL/Fl_Button.H>
@ -49,6 +51,24 @@ bool contains(Fl_Group *group, const char *text)
}
return false;
}
SequencerWidget *timeline(Fl_Group *group)
{
for (int i = 0; i < group->children(); ++i) {
if (auto *s = dynamic_cast<SequencerWidget *>(group->child(i))) return s;
if (auto *g = dynamic_cast<Fl_Group *>(group->child(i))) if (auto *s = timeline(g)) return s;
}
return nullptr;
}
void gesture(SequencerWidget *s, int left, int top, int dx, int dy)
{
int old_x = Fl::e_x, old_y = Fl::e_y, old_key = Fl::e_keysym;
Fl::e_x = s->x() + left; Fl::e_y = s->y() + top; Fl::e_keysym = FL_Button + FL_LEFT_MOUSE;
CHECK(s->handle(FL_PUSH) == 1);
Fl::e_x += dx; Fl::e_y += dy;
CHECK(s->handle(FL_DRAG) == 1);
CHECK(s->handle(FL_RELEASE) == 1);
Fl::e_x = old_x; Fl::e_y = old_y; Fl::e_keysym = old_key;
}
void press(Fl_Widget *widget)
{
CHECK(widget != nullptr);
@ -92,6 +112,8 @@ 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_seek(xtc_backend *, uint64_t sample)
{ engine_status.sample_position = sample; return handle.running ? XTC_ERR_INVALID_STATE : XTC_OK; }
xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph)
{
CHECK(backend == &handle && graph != nullptr);
@ -111,7 +133,7 @@ int main(int argc, char **argv)
{
reset();
{
MainWindow window;
MainWindow window(false);
window.show(argc, argv);
auto *play = find(&window, "Play");
auto *stop = find(&window, "Stop");
@ -216,6 +238,37 @@ int main(int argc, char **argv)
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);
auto *sequence = timeline(&window);
CHECK(sequence != nullptr);
char directory[] = "/tmp/xtc-gui-XXXXXX"; CHECK(mkdtemp(directory) != nullptr);
std::string filename = std::string(directory) + "/song.xtc";
press(find(&window, "Add track")); press(find(&window, "Add region"));
size_t track_count, clip_count;
CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && track_count == 1 && clip_count == 1);
xtc_clip_info region{};
CHECK(xtc_project_get_clip(sequence->project(), 0, &region) == XTC_OK);
gesture(sequence, 150, 50, 144, 0); // Move by three beats.
gesture(sequence, 265, 50, 48, 0); // Trim left edge by one beat.
CHECK(xtc_project_get_clip(sequence->project(), 0, &region) == XTC_OK &&
region.start_tick == 3840 && region.length_tick == 2880 && region.offset_tick == 960);
int width = sequence->w();
press(find(&window, "Zoom +")); CHECK(sequence->w() > width);
press(find(&window, "Zoom -")); CHECK(sequence->w() == width);
CHECK(window.save_project(filename.c_str()) == XTC_OK);
const xtc_project *retained = sequence->project();
CHECK(window.open_project("/missing/xtc-project") == XTC_ERR_IO && sequence->project() == retained);
submit_result = XTC_ERR_CAPACITY;
CHECK(window.open_project(filename.c_str()) == XTC_ERR_CAPACITY && sequence->project() == retained);
submit_result = XTC_OK;
press(find(&window, "Delete region/track"));
CHECK(window.open_project(filename.c_str()) == XTC_OK);
CHECK(xtc_project_get_clip(sequence->project(), 0, &region) == XTC_OK && region.start_tick == 3840 && region.offset_tick == 960);
engine_status.sample_position = 48000; pump();
CHECK(sequence->position() == 1920);
CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_OK);
CHECK(sequence->position() == 1920); // Graph edits preserve the transport.
engine_status.sample_position = 0;
CHECK(unlink(filename.c_str()) == 0 && rmdir(directory) == 0);
engine_status.xruns = 3U;
engine_status.processed_blocks = 123U;
pump();
@ -250,7 +303,7 @@ int main(int argc, char **argv)
for (xtc_result failure : {XTC_ERR_IO, XTC_ERR_UNSUPPORTED}) {
reset();
open_result = failure;
MainWindow window;
MainWindow window(false);
window.show(argc, argv);
CHECK(contains(&window, "Open JACK failed") && !find(&window, "Play")->active());
open_result = XTC_OK;
@ -264,14 +317,14 @@ int main(int argc, char **argv)
partial_open = true;
open_result = XTC_ERR_IO;
{
MainWindow window;
MainWindow window(false);
CHECK(alive == 1U && !find(&window, "Play")->active());
window.do_callback(); // Partially initialized handles are closed only.
CHECK(alive == 0U);
}
reset();
for (int i = 0; i < 10; ++i) {
MainWindow window;
MainWindow window(false);
press(find(&window, "Play"));
window.do_callback();
}

View file

@ -1,5 +1,6 @@
#include "../../src/gui/main_window.hpp"
#include "../../src/gui/graph_widget.hpp"
#include "../../src/gui/sequencer_widget.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Group.H>
#include <chrono>
@ -24,6 +25,14 @@ GraphWidget *canvas_in(Fl_Group &group)
}
return nullptr;
}
SequencerWidget *timeline_in(Fl_Group &group)
{
for (int i = 0; i < group.children(); ++i) {
if (auto *s = dynamic_cast<SequencerWidget *>(group.child(i))) return s;
if (auto *g = dynamic_cast<Fl_Group *>(group.child(i))) if (auto *s = timeline_in(*g)) return s;
}
return nullptr;
}
void pump()
{
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(400);
@ -35,7 +44,7 @@ int main(int argc, char **argv)
{
// Real JACK backend and real FLTK event loop. No routes, no playback data.
for (int iteration = 0; iteration < 3; ++iteration) {
MainWindow window;
MainWindow window(false);
window.show(argc, argv);
CHECK(find(window, "ready / stopped") != nullptr);
auto *play = find(window, "Play");
@ -44,16 +53,19 @@ int main(int argc, char **argv)
CHECK(play != nullptr && stop != nullptr && reconnect != nullptr);
auto *canvas = canvas_in(window);
CHECK(canvas != nullptr);
auto *timeline = timeline_in(window); CHECK(timeline != 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) {
uint64_t previous = timeline->position();
CHECK(play->active());
play->do_callback();
pump();
CHECK(find(window, "running — modular graph") != nullptr);
CHECK(timeline->position() > previous);
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);
@ -71,6 +83,8 @@ int main(int argc, char **argv)
CHECK(rate > 0U && frames > 0U && blocks > 0U);
stop->do_callback();
CHECK(find(window, "ready / stopped") != nullptr);
uint64_t paused = timeline->position(); pump();
CHECK(timeline->position() == paused);
}
reconnect->do_callback();
CHECK(find(window, "ready / stopped") != nullptr);

View file

@ -121,25 +121,29 @@ int main(void)
activate_fail = 1;
CHECK(xtc_backend_start(backend) == XTC_ERR_IO);
activate_fail = 0;
CHECK(xtc_backend_seek(backend, 1000U) == XTC_OK);
CHECK(xtc_backend_submit(backend, graph) == XTC_OK);
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 1000U);
CHECK(xtc_backend_start(backend) == XTC_OK);
CHECK(xtc_backend_seek(backend, 0) == XTC_ERR_INVALID_STATE);
CHECK(xtc_backend_start(backend) == XTC_ERR_INVALID_STATE);
CHECK(xtc_backend_connect(backend, XTC_PORT_OUTPUT, "fake:in") == XTC_OK);
CHECK(xtc_backend_connect(backend, XTC_PORT_INPUT, "missing:out") == XTC_ERR_IO);
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_get_status(backend, &status) == XTC_OK && status.active_revision == status.submitted_revision && status.sample_position == 1032U);
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);
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.process_result == XTC_ERR_CAPACITY && status.sample_position == 1064U);
CHECK(size_cb(16U, context) == 0);
CHECK(process_cb(16U, context) == 0 && output[15] == 0.5f);
CHECK(xrun_cb(context) == 0);
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.xruns == 1U && status.buffer_frames == 16U);
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.xruns == 1U && status.buffer_frames == 16U && status.sample_position == 1080U);
null_buffer = 1;
CHECK(process_cb(16U, context) == 0);
null_buffer = 0;
@ -165,7 +169,12 @@ int main(void)
CHECK(xtc_backend_start(backend) == XTC_ERR_INVALID_STATE);
} else {
CHECK(xtc_backend_stop(backend) == XTC_OK);
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 32U);
CHECK(xtc_backend_seek(backend, 24000U) == XTC_OK);
CHECK(xtc_backend_submit(backend, graph) == XTC_OK);
CHECK(xtc_backend_start(backend) == XTC_OK);
CHECK(process_cb(32U, context) == 0);
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 24032U);
}
CHECK(xtc_backend_submit(backend, graph) == (iteration % 2 == 0 ? XTC_ERR_INVALID_STATE : XTC_OK));
close_fail = 1;

121
tests/unit/test_project.c Normal file
View file

@ -0,0 +1,121 @@
#define _POSIX_C_SOURCE 200809L
#include "xtc/xtc_project.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dirent.h>
static void write_bytes(const char *path, const char *bytes, size_t count)
{
FILE *f = fopen(path, "wb"); assert(f);
assert(fwrite(bytes, 1, count, f) == count); assert(fclose(f) == 0);
}
static size_t read_bytes(const char *path, char *bytes)
{
FILE *f = fopen(path, "rb"); size_t n; assert(f);
n = fread(bytes, 1, 8191, f); assert(!ferror(f)); assert(fclose(f) == 0); bytes[n] = 0; return n;
}
int main(void)
{
xtc_project *p = NULL, *q = NULL;
uint64_t a, b, c, id, sample, tick;
size_t tracks, clips, nodes, edges;
xtc_clip_info info, before;
xtc_graph *graph = NULL;
size_t gain, output, discarded;
xtc_graph *edited = NULL;
xtc_graph_edit edit = {0};
xtc_plan *plan = NULL;
float input = 1.0f, rendered = 0.0f;
char dir[] = "/tmp/xtc-project-XXXXXX", path[256], other[256], bad[256];
char data[8192], again[8192], damaged[8192];
size_t length, i;
assert(mkdtemp(dir));
snprintf(path, sizeof(path), "%s/song.xtc", dir);
snprintf(other, sizeof(other), "%s/copy.xtc", dir);
snprintf(bad, sizeof(bad), "%s/bad.xtc", dir);
assert(xtc_project_create(&p) == XTC_OK);
assert(xtc_project_create(&q) == XTC_OK);
assert(xtc_project_add_track(p, "Drums ä", &a) == XTC_OK && a == 0);
assert(xtc_project_add_track(p, "Bass", &b) == XTC_OK && b == 1);
assert(xtc_project_add_track(p, "\300\200", &id) == XTC_ERR_INVALID_ARGUMENT);
assert(xtc_project_add_track(p, "Bad\n", &id) == XTC_ERR_INVALID_ARGUMENT);
assert(xtc_project_add_clip(p, a, "Verse", 3840, 3840, &c) == XTC_OK);
assert(xtc_project_edit_clip(p, c, b, 4800, 2880, 960) == XTC_OK);
assert(xtc_project_find_clip(p, c, &before) == XTC_OK);
assert(xtc_project_edit_clip(p, c, b, 0, 3840, 960) == XTC_ERR_INVALID_ARGUMENT);
assert(xtc_project_find_clip(p, c, &info) == XTC_OK && memcmp(&info, &before, sizeof(info)) == 0);
assert(xtc_project_add_clip(p, 99, "Bad", 0, 960, &id) == XTC_ERR_NOT_FOUND);
assert(xtc_project_add_clip(p, a, "Bad", XTC_PROJECT_MAX_TICK, 1, &id) == XTC_ERR_INVALID_ARGUMENT);
assert(xtc_project_tick_to_sample(p, 960, 48000, &sample) == XTC_OK && sample == 24000);
assert(xtc_project_sample_to_tick(p, sample, 48000, &tick) == XTC_OK && tick == 960);
assert(xtc_project_set_tempo(p, 20000) == XTC_OK);
assert(xtc_project_tick_to_sample(p, XTC_PROJECT_MAX_TICK, 768000, &sample) == XTC_OK);
assert(xtc_project_sample_to_tick(p, sample, 768000, &tick) == XTC_OK && tick == XTC_PROJECT_MAX_TICK);
assert(xtc_project_sample_to_tick(p, UINT64_MAX, 1, &tick) == XTC_ERR_CAPACITY);
assert(xtc_project_set_tempo(p, 120000) == XTC_OK);
assert(xtc_graph_create(64, &graph) == XTC_OK);
assert(xtc_graph_add_gain(graph, 0.25f, &gain) == XTC_OK);
assert(xtc_graph_add_output(graph, &output) == XTC_OK);
assert(xtc_graph_connect(graph, gain, output) == XTC_OK);
assert(xtc_graph_require_output_nodes(graph, 1) == XTC_OK);
assert(xtc_graph_add_gain(graph, 0.5f, &discarded) == XTC_OK);
edit.kind = XTC_EDIT_REMOVE_NODE; edit.data.node_id = discarded;
assert(xtc_graph_apply_edit(graph, &edit, &edited, NULL) == XTC_OK);
xtc_graph_destroy(graph); graph = edited; edited = NULL;
assert(xtc_project_set_graph(p, graph) == XTC_OK);
xtc_graph_destroy(graph);
assert(xtc_project_remove_track(p, a) == XTC_OK);
assert(xtc_project_save(p, path) == XTC_OK);
length = read_bytes(path, data);
assert(xtc_project_load(q, path) == XTC_OK);
assert(xtc_project_find_clip(q, c, &info) == XTC_OK && memcmp(&info, &before, sizeof(info)) == 0);
assert(xtc_graph_get_counts(xtc_project_graph(q), &nodes, &edges) == XTC_OK && nodes == 2 && edges == 1);
assert(xtc_graph_compile(xtc_project_graph(q), 48000.0, 1, &plan) == XTC_OK);
assert(xtc_plan_process(plan, &input, &rendered, 1) == XTC_OK && rendered == 0.25f);
xtc_plan_destroy(plan);
edit.kind = XTC_EDIT_ADD_NODE; edit.data.add.type = XTC_NODE_GAIN; edit.data.add.value = 1.0f;
assert(xtc_graph_apply_edit(xtc_project_graph(q), &edit, &edited, &gain) == XTC_OK && gain == 3);
xtc_graph_destroy(edited); edited = NULL;
assert(xtc_project_save(q, other) == XTC_OK);
assert(read_bytes(other, again) == length && memcmp(data, again, length) == 0);
/* Every truncation is rejected without altering the existing arrangement. */
for (i = 0; i < length; ++i) {
write_bytes(bad, data, i);
assert(xtc_project_load(q, bad) != XTC_OK);
assert(xtc_project_find_clip(q, c, &info) == XTC_OK && memcmp(&info, &before, sizeof(info)) == 0);
}
memcpy(damaged, data, length); damaged[12] = '9';
write_bytes(bad, damaged, length);
assert(xtc_project_load(q, bad) == XTC_ERR_UNSUPPORTED);
memcpy(damaged, data, length); damaged[length] = '\n';
write_bytes(bad, damaged, length + 1);
assert(xtc_project_load(q, bad) == XTC_ERR_FORMAT);
memcpy(damaged, data, length); damaged[0] = 0;
write_bytes(bad, damaged, length);
assert(xtc_project_load(q, bad) == XTC_ERR_FORMAT);
/* Mutate every byte: accepted variants must still be serializable. */
for (i = 0; i < length; ++i) {
memcpy(damaged, data, length); damaged[i] = '9';
write_bytes(bad, damaged, length);
if (xtc_project_load(q, bad) == XTC_OK) assert(xtc_project_save(q, other) == XTC_OK);
}
assert(xtc_project_load(q, path) == XTC_OK);
assert(xtc_project_add_track(q, "New", &id) == XTC_OK && id == 2);
assert(xtc_project_remove_track(q, b) == XTC_OK);
assert(xtc_project_get_counts(q, &tracks, &clips) == XTC_OK && tracks == 1 && clips == 0);
assert(xtc_project_save(p, dir) == XTC_ERR_IO); /* Rename onto directory fails, temp removed. */
assert(read_bytes(path, again) == length && memcmp(data, again, length) == 0);
for (i = 1; i < XTC_PROJECT_MAX_TRACKS; ++i) assert(xtc_project_add_track(q, "Track", &id) == XTC_OK);
assert(xtc_project_add_track(q, "Full", &id) == XTC_ERR_CAPACITY);
assert(xtc_project_get_track(q, 0, &(xtc_track_info){0}) == XTC_OK);
for (i = 0; i < XTC_PROJECT_MAX_CLIPS; ++i) assert(xtc_project_add_clip(q, 2, "Region", 0, 960, &id) == XTC_OK);
assert(xtc_project_add_clip(q, 2, "Full", 0, 960, &id) == XTC_ERR_CAPACITY);
assert(xtc_project_save(q, other) == XTC_OK && xtc_project_load(p, other) == XTC_OK);
xtc_project_destroy(p); xtc_project_destroy(q);
assert(unlink(path) == 0 && unlink(other) == 0 && unlink(bad) == 0);
assert(rmdir(dir) == 0); /* Also verifies no abandoned save temporaries. */
puts("Project model, persistence roundtrip, malformed input and cleanup passed.");
return 0;
}