diff --git a/Makefile b/Makefile index 9f3e5c4..1085045 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 1d85a48..5aea9ad 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e96038..cece19a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/AUDIO_ENGINE.md b/docs/AUDIO_ENGINE.md index 6a3b931..4620b36 100644 --- a/docs/AUDIO_ENGINE.md +++ b/docs/AUDIO_ENGINE.md @@ -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. diff --git a/docs/BUILD.md b/docs/BUILD.md index cd7da25..aad33ad 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -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. diff --git a/docs/GUI.md b/docs/GUI.md index 764b395..95ae1c1 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -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. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 893cb16..4393e85 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a299799..8fa1588 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index cbf2964..8fb6406 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -18,6 +18,7 @@ typedef struct { unsigned int buffer_frames; unsigned int xruns; /* Wraps modulo UINT_MAX + 1. */ unsigned int processed_blocks; + 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. */ diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index fdcd0d4..e823e43 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -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 diff --git a/include/xtc/xtc_types.h b/include/xtc/xtc_types.h index fdf828a..9759e7f 100644 --- a/include/xtc/xtc_types.h +++ b/include/xtc/xtc_types.h @@ -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); diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 652e5ac..9411268 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -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) { diff --git a/src/core/xtc.c b/src/core/xtc.c index 0f1b579..7af5c3a 100644 --- a/src/core/xtc.c +++ b/src/core/xtc.c @@ -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"; } diff --git a/src/gui/graph_widget.hpp b/src/gui/graph_widget.hpp index b4a2ee2..84346eb 100644 --- a/src/gui/graph_widget.hpp +++ b/src/gui/graph_widget.hpp @@ -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); diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 51b8f9a..3a73105 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -1,5 +1,7 @@ #include "main_window.hpp" #include "graph_widget.hpp" +#include "sequencer_widget.hpp" +#include #include "xtc/xtc_version.h" #include @@ -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(c)->mark_dirty(); }, this); + add_track->callback([](Fl_Widget *, void *c) { static_cast(c)->add_track(); }, sequencer_); + add_region->callback([](Fl_Widget *, void *c) { static_cast(c)->add_region(); }, sequencer_); + delete_region->callback([](Fl_Widget *, void *c) { static_cast(c)->delete_selection(); }, sequencer_); + zoom_in->callback([](Fl_Widget *, void *c) { static_cast(c)->zoom(1); }, sequencer_); + zoom_out->callback([](Fl_Widget *, void *c) { static_cast(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(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(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(context); + auto *menu = static_cast(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); +} diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index cb4f78b..b2486e9 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -3,24 +3,37 @@ #include #include +#include +#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 graph_{nullptr, xtc_graph_destroy}; + std::unique_ptr 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; diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp new file mode 100644 index 0000000..d2c7a0e --- /dev/null +++ b/src/gui/sequencer_widget.cpp @@ -0,0 +1,159 @@ +#include "sequencer_widget.hpp" +#include +#include +#include +#include +#include +#include +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(std::max(0, px - x() - 120)) * 960U / + static_cast(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(end * static_cast(scale_) / 960U + 140U), + std::max(400, static_cast(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(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(c.start_tick * static_cast(scale_) / 960U); + int right = left + std::max(4, static_cast(c.length_tick * static_cast(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(cursor_ * static_cast(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(row) >= tracks) return 1; + xtc_track_info t{}; xtc_project_get_track(project_, static_cast(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(c.start_tick * static_cast(scale_) / 960U); + int right = left + static_cast(c.length_tick * static_cast(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(Fl::event_x() - drag_x_) * 960 / scale_ / 240 * 240; + int64_t start = static_cast(original_.start_tick); + int64_t length = static_cast(original_.length_tick); + int64_t offset = static_cast(original_.offset_tick); + uint64_t target = original_.track_id; + if (mode == 1) { + start = std::max(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(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(start), + static_cast(length), static_cast(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); +} diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp new file mode 100644 index 0000000..5587e77 --- /dev/null +++ b/src/gui/sequencer_widget.hpp @@ -0,0 +1,31 @@ +#ifndef XTC_SEQUENCER_WIDGET_HPP +#define XTC_SEQUENCER_WIDGET_HPP +#include +#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 diff --git a/src/persistence/README.md b/src/persistence/README.md index 1ccc1b9..c286ad8 100644 --- a/src/persistence/README.md +++ b/src/persistence/README.md @@ -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. diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c new file mode 100644 index 0000000..06bcfe6 --- /dev/null +++ b/src/persistence/project_file.c @@ -0,0 +1,227 @@ +#define _POSIX_C_SOURCE 200809L +#include "../project/project_internal.h" +#include "../graph/graph_internal.h" +#include "xtc/xtc.h" +#include +#include +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/src/project/README.md b/src/project/README.md index fbb0d9a..6a6e428 100644 --- a/src/project/README.md +++ b/src/project/README.md @@ -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. diff --git a/src/project/project.c b/src/project/project.c new file mode 100644 index 0000000..82501f3 --- /dev/null +++ b/src/project/project.c @@ -0,0 +1,222 @@ +#include "project_internal.h" +#include "../graph/graph_internal.h" +#include +#include + +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, ©); + 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; +} diff --git a/src/project/project_internal.h b/src/project/project_internal.h new file mode 100644 index 0000000..3ba8ace --- /dev/null +++ b/src/project/project_internal.h @@ -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 diff --git a/tests/integration/README.md b/tests/integration/README.md index 374d5e4..87657be 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -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. diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 88bd729..d318b4c 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1,5 +1,7 @@ #include "../../src/gui/main_window.hpp" #include "../../src/gui/graph_widget.hpp" +#include "../../src/gui/sequencer_widget.hpp" +#include #include #include #include @@ -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(group->child(i))) return s; + if (auto *g = dynamic_cast(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, ®ion) == 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, ®ion) == 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, ®ion) == 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(); } diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp index 5099126..d161752 100644 --- a/tests/integration/test_gui_jack.cpp +++ b/tests/integration/test_gui_jack.cpp @@ -1,5 +1,6 @@ #include "../../src/gui/main_window.hpp" #include "../../src/gui/graph_widget.hpp" +#include "../../src/gui/sequencer_widget.hpp" #include #include #include @@ -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(group.child(i))) return s; + if (auto *g = dynamic_cast(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); diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index 658f45c..58f471c 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -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; diff --git a/tests/unit/test_project.c b/tests/unit/test_project.c new file mode 100644 index 0000000..f279816 --- /dev/null +++ b/tests/unit/test_project.c @@ -0,0 +1,121 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_project.h" +#include +#include +#include +#include +#include +#include +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; +}