Phase 5: Add project persistence and sequencer foundation
This commit is contained in:
parent
5c41d7f6da
commit
65640c4aad
28 changed files with 1307 additions and 84 deletions
18
Makefile
18
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
|
||||
|
|
|
|||
10
README.md
10
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
45
docs/GUI.md
45
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "main_window.hpp"
|
||||
#include "graph_widget.hpp"
|
||||
#include "sequencer_widget.hpp"
|
||||
#include <FL/Fl_Native_File_Chooser.H>
|
||||
#include "xtc/xtc_version.h"
|
||||
|
||||
#include <FL/Fl.H>
|
||||
|
|
@ -22,20 +24,20 @@ void set_label(Fl_Box *box, const char *text)
|
|||
}
|
||||
}
|
||||
|
||||
MainWindow::MainWindow()
|
||||
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation")
|
||||
MainWindow::MainWindow(bool prompt_unsaved)
|
||||
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation"),
|
||||
prompt_unsaved_(prompt_unsaved)
|
||||
{
|
||||
xtc_graph *initial_graph = nullptr;
|
||||
xtc_result graph_result = xtc_graph_create(64U, &initial_graph);
|
||||
if (graph_result != XTC_OK) throw std::runtime_error(xtc_result_string(graph_result));
|
||||
graph_.reset(initial_graph);
|
||||
graph_result = xtc_graph_require_output_nodes(graph_.get(), 1);
|
||||
if (graph_result != XTC_OK) throw std::runtime_error(xtc_result_string(graph_result));
|
||||
xtc_project *initial = nullptr;
|
||||
xtc_result result = xtc_project_create(&initial);
|
||||
if (result != XTC_OK) throw std::runtime_error(xtc_result_string(result));
|
||||
project_.reset(initial);
|
||||
begin();
|
||||
auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28);
|
||||
menu->add("File/New (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
|
||||
menu->add("File/Open (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
|
||||
menu->add("File/Save (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
|
||||
menu->add("File/New", FL_CTRL + 'n', file_callback, this);
|
||||
menu->add("File/Open", FL_CTRL + 'o', file_callback, this);
|
||||
menu->add("File/Save", FL_CTRL + 's', file_callback, this);
|
||||
menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this);
|
||||
menu->add("File/Quit", FL_CTRL + 'q', close_callback, this);
|
||||
menu->add("Audio/Start engine", FL_F + 5, play_callback, this);
|
||||
menu->add("Audio/Stop engine", FL_F + 8, stop_callback, this);
|
||||
|
|
@ -43,7 +45,7 @@ MainWindow::MainWindow()
|
|||
|
||||
play_ = new Fl_Button(12, 40, 80, 30, "Play");
|
||||
play_->callback(play_callback, this);
|
||||
play_->tooltip("Start the audio engine (F5 or Space). No sequencer content yet.");
|
||||
play_->tooltip("Start the audio engine (F5 or Space). Regions are silent until audio/MIDI phases.");
|
||||
stop_ = new Fl_Button(100, 40, 80, 30, "Stop");
|
||||
stop_->callback(stop_callback, this);
|
||||
stop_->tooltip("Stop the audio engine (F8 or Space).");
|
||||
|
|
@ -54,7 +56,7 @@ MainWindow::MainWindow()
|
|||
indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
||||
indicator_->labelfont(FL_BOLD);
|
||||
auto *hint = new Fl_Box(12, 78, 976, 24,
|
||||
"Edit signal flow in Modular Graph. No hardware ports are connected automatically; sequencer and recording are not implemented.");
|
||||
"Edit signal flow in Modular Graph. No hardware ports are connected automatically; timeline regions are silent; recording is a future phase.");
|
||||
hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
||||
hint->labelsize(12);
|
||||
|
||||
|
|
@ -62,7 +64,24 @@ MainWindow::MainWindow()
|
|||
tabs_->begin();
|
||||
auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer");
|
||||
sequencer->begin();
|
||||
new Fl_Box(24, 164, 940, 40, "Sequencer placeholder — timeline and clips are not implemented yet.");
|
||||
auto *add_track = new Fl_Button(20, 150, 100, 26, "Add track");
|
||||
auto *add_region = new Fl_Button(128, 150, 110, 26, "Add region");
|
||||
auto *delete_region = new Fl_Button(246, 150, 130, 26, "Delete region/track");
|
||||
auto *zoom_in = new Fl_Button(384, 150, 80, 26, "Zoom +");
|
||||
auto *zoom_out = new Fl_Button(472, 150, 80, 26, "Zoom -");
|
||||
auto *timeline_hint = new Fl_Box(20, 548, 956, 28,
|
||||
"Silent regions. Drag body to move, edges to trim; ruler seeks while stopped.");
|
||||
timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); timeline_hint->labelsize(12);
|
||||
auto *timeline_scroll = new Fl_Scroll(20, 188, 956, 350);
|
||||
sequencer_ = new SequencerWidget(20, 188, project_.get(), seek, this);
|
||||
timeline_scroll->end();
|
||||
sequencer_->callback([](Fl_Widget *, void *c) { static_cast<MainWindow *>(c)->mark_dirty(); }, this);
|
||||
add_track->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_track(); }, sequencer_);
|
||||
add_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_region(); }, sequencer_);
|
||||
delete_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->delete_selection(); }, sequencer_);
|
||||
zoom_in->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->zoom(1); }, sequencer_);
|
||||
zoom_out->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->zoom(-1); }, sequencer_);
|
||||
sequencer->resizable(timeline_scroll);
|
||||
sequencer->end();
|
||||
auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer");
|
||||
mixer->begin();
|
||||
|
|
@ -78,7 +97,7 @@ MainWindow::MainWindow()
|
|||
feedback->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP);
|
||||
feedback->labelsize(12);
|
||||
auto *scroll = new Fl_Scroll(20, 212, 956, 328);
|
||||
graph_widget_ = new GraphWidget(20, 212, 2000, 4000, graph_.get(), edit_graph, this, feedback);
|
||||
graph_widget_ = new GraphWidget(20, 212, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback);
|
||||
scroll->end();
|
||||
auto *oscillator = new Fl_Button(20, 150, 130, 26, "Add oscillator");
|
||||
oscillator->callback([](Fl_Widget *, void *context) {
|
||||
|
|
@ -137,7 +156,8 @@ MainWindow::~MainWindow()
|
|||
void MainWindow::report_error(const char *operation, xtc_result result)
|
||||
{
|
||||
char text[384];
|
||||
const char *hint = result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support." :
|
||||
const char *hint = std::strstr(operation, "Project") != nullptr ? "Check the project path, file format and write permissions." :
|
||||
result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support." :
|
||||
"Check the JACK-compatible server and use Reconnect to retry.";
|
||||
std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint);
|
||||
set_label(error_, text);
|
||||
|
|
@ -148,12 +168,19 @@ xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, siz
|
|||
auto *window = static_cast<MainWindow *>(context);
|
||||
xtc_graph *candidate = nullptr;
|
||||
size_t added_id = SIZE_MAX;
|
||||
xtc_result result = xtc_graph_apply_edit(window->graph_.get(), &edit, &candidate, &added_id);
|
||||
xtc_result result = xtc_graph_apply_edit(xtc_project_graph(window->project_.get()), &edit, &candidate, &added_id);
|
||||
if (result != XTC_OK) return result;
|
||||
if (window->ready_) result = xtc_backend_submit(window->backend_, candidate);
|
||||
if (result != XTC_OK) { xtc_graph_destroy(candidate); return result; }
|
||||
window->graph_.reset(candidate);
|
||||
window->graph_widget_->set_graph(candidate);
|
||||
xtc_project *replacement = nullptr;
|
||||
result = xtc_project_clone(window->project_.get(), &replacement);
|
||||
if (result == XTC_OK) result = xtc_project_set_graph(replacement, candidate);
|
||||
xtc_graph_destroy(candidate);
|
||||
if (result == XTC_OK && window->ready_)
|
||||
result = xtc_backend_submit(window->backend_, xtc_project_graph(replacement));
|
||||
if (result != XTC_OK) { xtc_project_destroy(replacement); return result; }
|
||||
window->project_.reset(replacement);
|
||||
window->graph_widget_->set_graph(xtc_project_graph(replacement));
|
||||
window->sequencer_->set_project(replacement, false);
|
||||
window->mark_dirty();
|
||||
if (out_added_id != nullptr && edit.kind == XTC_EDIT_ADD_NODE) *out_added_id = added_id;
|
||||
window->refresh_status();
|
||||
return XTC_OK;
|
||||
|
|
@ -167,7 +194,7 @@ void MainWindow::open_backend()
|
|||
running_ = false;
|
||||
if (result != XTC_OK) report_error("Open JACK failed", result);
|
||||
else {
|
||||
result = xtc_backend_submit(backend_, graph_.get());
|
||||
result = xtc_backend_submit(backend_, xtc_project_graph(project_.get()));
|
||||
if (result != XTC_OK) {
|
||||
ready_ = false; // Handle is retained for close/reconnect; graph was not installed.
|
||||
report_error("Submit graph failed", result);
|
||||
|
|
@ -229,6 +256,9 @@ void MainWindow::refresh_status()
|
|||
set_label(status_, "Unable to read JACK status — use Reconnect");
|
||||
indicator_->labelcolor(fl_rgb_color(255, 155, 120));
|
||||
} else {
|
||||
uint64_t tick = 0;
|
||||
if (xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &tick) == XTC_OK)
|
||||
sequencer_->position(tick);
|
||||
running_ = status.running != 0;
|
||||
const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK;
|
||||
can_play = !running_ && !fault;
|
||||
|
|
@ -258,6 +288,7 @@ void MainWindow::refresh_status()
|
|||
|
||||
bool MainWindow::close_window()
|
||||
{
|
||||
if (!discard_changes()) return false;
|
||||
xtc_result result = xtc_backend_close(backend_);
|
||||
if (result != XTC_OK) {
|
||||
report_error("Close JACK failed; window kept open for retry", result);
|
||||
|
|
@ -297,7 +328,7 @@ void MainWindow::view_callback(Fl_Widget *widget, void *view)
|
|||
}
|
||||
void MainWindow::about_callback(Fl_Widget *, void *)
|
||||
{
|
||||
fl_message("%s %s\nModular audio workstation\nPhase 4: modular graph editor; sequencer and mixer remain placeholders.",
|
||||
fl_message("%s %s\nModular audio workstation\nPhase 5: projects and silent arrangement regions; audio/MIDI content follows.",
|
||||
XTC_PROJECT_NAME, XTC_VERSION_STRING);
|
||||
}
|
||||
void MainWindow::timer_callback(void *context)
|
||||
|
|
@ -306,3 +337,91 @@ void MainWindow::timer_callback(void *context)
|
|||
window->refresh_status();
|
||||
if (!window->closing_) Fl::repeat_timeout(0.1, timer_callback, context);
|
||||
}
|
||||
|
||||
|
||||
void MainWindow::mark_dirty()
|
||||
{ dirty_ = true; copy_label("xtc * — unsaved changes"); }
|
||||
bool MainWindow::discard_changes()
|
||||
{
|
||||
return !dirty_ || !prompt_unsaved_ ||
|
||||
fl_choice("Discard unsaved project changes?", "Cancel", "Discard", nullptr) == 1;
|
||||
}
|
||||
xtc_result MainWindow::seek(void *context, uint64_t tick)
|
||||
{
|
||||
auto *window = static_cast<MainWindow *>(context);
|
||||
if (!window->ready_) return XTC_OK;
|
||||
xtc_backend_status status{};
|
||||
xtc_result r = xtc_backend_get_status(window->backend_, &status);
|
||||
uint64_t sample = 0;
|
||||
if (r == XTC_OK) r = xtc_project_tick_to_sample(window->project_.get(), tick, status.sample_rate, &sample);
|
||||
return r == XTC_OK ? xtc_backend_seek(window->backend_, sample) : r;
|
||||
}
|
||||
xtc_result MainWindow::save_project(const char *path)
|
||||
{
|
||||
if (path == nullptr) return XTC_ERR_INVALID_ARGUMENT;
|
||||
std::string saved_path(path);
|
||||
xtc_result r = xtc_project_save(project_.get(), path);
|
||||
if (r == XTC_OK) { path_ = saved_path; dirty_ = false; copy_label(path_.c_str()); }
|
||||
return r;
|
||||
}
|
||||
xtc_result MainWindow::install_project(xtc_project *candidate)
|
||||
{
|
||||
xtc_result r = XTC_OK;
|
||||
if (ready_) {
|
||||
r = xtc_backend_stop(backend_);
|
||||
if (r == XTC_OK) r = xtc_backend_seek(backend_, 0);
|
||||
if (r == XTC_OK) r = xtc_backend_submit(backend_, xtc_project_graph(candidate));
|
||||
}
|
||||
if (r != XTC_OK) return r;
|
||||
project_.reset(candidate);
|
||||
graph_widget_->reset_layout();
|
||||
graph_widget_->set_graph(xtc_project_graph(candidate));
|
||||
sequencer_->set_project(candidate);
|
||||
dirty_ = false;
|
||||
refresh_status();
|
||||
return XTC_OK;
|
||||
}
|
||||
xtc_result MainWindow::open_project(const char *path)
|
||||
{
|
||||
if (path == nullptr) return XTC_ERR_INVALID_ARGUMENT;
|
||||
std::string loaded_path(path);
|
||||
xtc_project *candidate = nullptr;
|
||||
xtc_result r = xtc_project_create(&candidate);
|
||||
if (r == XTC_OK) r = xtc_project_load(candidate, path);
|
||||
if (r == XTC_OK) r = install_project(candidate);
|
||||
if (r != XTC_OK) xtc_project_destroy(candidate);
|
||||
else { path_ = loaded_path; copy_label(path_.c_str()); }
|
||||
return r;
|
||||
}
|
||||
void MainWindow::file_callback(Fl_Widget *widget, void *context)
|
||||
{
|
||||
auto *window = static_cast<MainWindow *>(context);
|
||||
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
|
||||
const char *action = menu->mvalue()->label();
|
||||
xtc_result r = XTC_OK;
|
||||
if (std::strcmp(action, "New") == 0) {
|
||||
if (!window->discard_changes()) return;
|
||||
xtc_project *candidate = nullptr;
|
||||
r = xtc_project_create(&candidate);
|
||||
if (r == XTC_OK) r = window->install_project(candidate);
|
||||
if (r != XTC_OK) xtc_project_destroy(candidate);
|
||||
else { window->path_.clear(); window->copy_label("xtc — Untitled"); }
|
||||
} else {
|
||||
bool open = std::strcmp(action, "Open") == 0;
|
||||
if (open && !window->discard_changes()) return;
|
||||
if (!open && std::strcmp(action, "Save") == 0 && !window->path_.empty())
|
||||
r = window->save_project(window->path_.c_str());
|
||||
else {
|
||||
Fl_Native_File_Chooser chooser;
|
||||
chooser.title(open ? "Open project" : "Save project");
|
||||
chooser.type(open ? Fl_Native_File_Chooser::BROWSE_FILE : Fl_Native_File_Chooser::BROWSE_SAVE_FILE);
|
||||
chooser.filter("xtc projects *.xtc");
|
||||
if (!open) chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM);
|
||||
int result = chooser.show();
|
||||
if (result == 1) return;
|
||||
if (result < 0) r = XTC_ERR_IO;
|
||||
else r = open ? window->open_project(chooser.filename()) : window->save_project(chooser.filename());
|
||||
}
|
||||
}
|
||||
if (r != XTC_OK) window->report_error("Project operation failed", r);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,24 +3,37 @@
|
|||
|
||||
#include <FL/Fl_Double_Window.H>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include "xtc/xtc_project.h"
|
||||
#include "xtc/xtc_backend.h"
|
||||
|
||||
class GraphWidget;
|
||||
class SequencerWidget;
|
||||
class Fl_Box;
|
||||
class Fl_Button;
|
||||
class Fl_Tabs;
|
||||
|
||||
class MainWindow : public Fl_Double_Window {
|
||||
public:
|
||||
MainWindow();
|
||||
explicit MainWindow(bool prompt_unsaved = true);
|
||||
~MainWindow() override;
|
||||
MainWindow(const MainWindow &) = delete;
|
||||
MainWindow &operator=(const MainWindow &) = delete;
|
||||
using Fl_Double_Window::show;
|
||||
int handle(int event) override;
|
||||
|
||||
xtc_result save_project(const char *path);
|
||||
xtc_result open_project(const char *path);
|
||||
private:
|
||||
std::unique_ptr<xtc_graph, decltype(&xtc_graph_destroy)> graph_{nullptr, xtc_graph_destroy};
|
||||
std::unique_ptr<xtc_project, decltype(&xtc_project_destroy)> project_{nullptr, xtc_project_destroy};
|
||||
SequencerWidget *sequencer_ = nullptr;
|
||||
bool dirty_ = false, prompt_unsaved_ = true;
|
||||
std::string path_;
|
||||
bool discard_changes();
|
||||
void mark_dirty();
|
||||
xtc_result install_project(xtc_project *candidate);
|
||||
static xtc_result seek(void *context, uint64_t tick);
|
||||
static void file_callback(Fl_Widget *, void *context);
|
||||
GraphWidget *graph_widget_ = nullptr;
|
||||
Fl_Box *graph_state_ = nullptr;
|
||||
xtc_backend *backend_ = nullptr;
|
||||
|
|
|
|||
159
src/gui/sequencer_widget.cpp
Normal file
159
src/gui/sequencer_widget.cpp
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
#include "sequencer_widget.hpp"
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/Fl_Scroll.H>
|
||||
#include <FL/fl_draw.H>
|
||||
#include <FL/fl_ask.H>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
SequencerWidget::SequencerWidget(int left, int top, xtc_project *project, Seek seek, void *context)
|
||||
: Fl_Widget(left, top, 6300, 3700), project_(project), seek_(seek), context_(context) { extent(); }
|
||||
void SequencerWidget::set_project(xtc_project *project, bool reset)
|
||||
{
|
||||
project_ = project;
|
||||
if (reset) { track_ = clip_ = UINT64_MAX; cursor_ = 0; mode_ = 0; }
|
||||
extent(); redraw();
|
||||
}
|
||||
uint64_t SequencerWidget::tick(int px) const
|
||||
{
|
||||
return std::min(XTC_PROJECT_MAX_TICK, static_cast<uint64_t>(std::max(0, px - x() - 120)) * 960U /
|
||||
static_cast<unsigned int>(scale_) / 240U * 240U);
|
||||
}
|
||||
void SequencerWidget::extent()
|
||||
{
|
||||
size_t tracks, clips;
|
||||
xtc_project_get_counts(project_, &tracks, &clips);
|
||||
uint64_t end = 32U * 3840U;
|
||||
for (size_t i = 0; i < clips; ++i) {
|
||||
xtc_clip_info c{}; xtc_project_get_clip(project_, i, &c);
|
||||
end = std::max(end, c.start_tick + c.length_tick + 3840U);
|
||||
}
|
||||
size(static_cast<int>(end * static_cast<unsigned int>(scale_) / 960U + 140U),
|
||||
std::max(400, static_cast<int>(tracks) * 56 + 100));
|
||||
if (parent()) parent()->redraw();
|
||||
}
|
||||
void SequencerWidget::changed(xtc_result result)
|
||||
{
|
||||
if (result != XTC_OK) fl_alert("%s", xtc_result_string(result));
|
||||
else { extent(); do_callback(); }
|
||||
redraw();
|
||||
}
|
||||
xtc_result SequencerWidget::add_track()
|
||||
{
|
||||
uint64_t id; xtc_result r = xtc_project_add_track(project_, "Track", &id);
|
||||
if (r == XTC_OK) { track_ = id; clip_ = UINT64_MAX; } changed(r); return r;
|
||||
}
|
||||
xtc_result SequencerWidget::add_region()
|
||||
{
|
||||
size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips);
|
||||
if (track_ == UINT64_MAX && tracks) {
|
||||
xtc_track_info t{}; xtc_project_get_track(project_, 0, &t); track_ = t.id;
|
||||
}
|
||||
uint64_t id;
|
||||
xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, 3840U, &id);
|
||||
if (r == XTC_OK) clip_ = id;
|
||||
changed(r); return r;
|
||||
}
|
||||
xtc_result SequencerWidget::delete_selection()
|
||||
{
|
||||
xtc_result r = clip_ != UINT64_MAX ? xtc_project_remove_clip(project_, clip_) :
|
||||
xtc_project_remove_track(project_, track_);
|
||||
if (r == XTC_OK) clip_ = track_ = UINT64_MAX;
|
||||
changed(r); return r;
|
||||
}
|
||||
void SequencerWidget::zoom(int direction)
|
||||
{ scale_ = std::clamp(direction > 0 ? scale_ * 2 : scale_ / 2, 24, 384); extent(); redraw(); }
|
||||
void SequencerWidget::draw()
|
||||
{
|
||||
fl_push_clip(x(), y(), w(), h());
|
||||
fl_color(fl_rgb_color(35, 38, 43)); fl_rectf(x(), y(), w(), h());
|
||||
int cx, cy, cw, ch; fl_clip_box(x(), y(), w(), h(), cx, cy, cw, ch);
|
||||
fl_font(FL_HELVETICA, 12);
|
||||
int first = std::max(0, (cx - x() - 120) / (scale_ * 4));
|
||||
int last = (cx + cw - x() - 120) / (scale_ * 4) + 1;
|
||||
for (int bar = first; bar <= last; ++bar) {
|
||||
int px = x() + 120 + bar * scale_ * 4;
|
||||
fl_color(fl_rgb_color(65, 68, 74)); fl_line(px, y(), px, y() + h());
|
||||
char label[32]; std::snprintf(label, sizeof(label), "%d", bar + 1);
|
||||
fl_color(FL_WHITE); fl_draw(label, px + 4, y() + 19);
|
||||
}
|
||||
char tempo[32];
|
||||
std::snprintf(tempo, sizeof(tempo), "%.3g BPM | 4/4", xtc_project_tempo(project_) / 1000.0);
|
||||
fl_color(FL_WHITE); fl_draw(tempo, x() + 4, y() + 19);
|
||||
size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips);
|
||||
for (size_t i = 0; i < tracks; ++i) {
|
||||
xtc_track_info t{}; xtc_project_get_track(project_, i, &t);
|
||||
int py = y() + 28 + static_cast<int>(i) * 56;
|
||||
fl_color(t.id == track_ ? fl_rgb_color(70, 90, 110) : fl_rgb_color(48, 51, 57));
|
||||
fl_rectf(x(), py, 118, 55);
|
||||
fl_color(FL_WHITE); fl_draw(t.name, x() + 6, py + 32);
|
||||
for (size_t j = 0; j < clips; ++j) {
|
||||
xtc_clip_info c{}; xtc_project_get_clip(project_, j, &c);
|
||||
if (c.track_id != t.id) continue;
|
||||
int left = x() + 120 + static_cast<int>(c.start_tick * static_cast<unsigned int>(scale_) / 960U);
|
||||
int right = left + std::max(4, static_cast<int>(c.length_tick * static_cast<unsigned int>(scale_) / 960U));
|
||||
int a = std::max(left, cx), b = std::min(right, cx + cw);
|
||||
if (b <= a || py + 50 < cy || py > cy + ch) continue;
|
||||
fl_color(c.id == clip_ ? fl_rgb_color(100, 170, 205) : fl_rgb_color(65, 120, 150));
|
||||
fl_rectf(a, py + 5, b - a, 44);
|
||||
fl_push_clip(a, py + 5, b - a, 44); fl_color(FL_WHITE);
|
||||
fl_draw(c.name, std::max(a, left + 6), py + 31); fl_pop_clip();
|
||||
}
|
||||
}
|
||||
int playhead = x() + 120 + static_cast<int>(cursor_ * static_cast<unsigned int>(scale_) / 960U);
|
||||
if (playhead >= cx && playhead <= cx + cw) {
|
||||
fl_color(FL_RED); fl_line(playhead, y(), playhead, y() + h());
|
||||
}
|
||||
fl_pop_clip();
|
||||
}
|
||||
int SequencerWidget::handle(int event)
|
||||
{
|
||||
if (event == FL_PUSH && Fl::event_button() == FL_LEFT_MOUSE) {
|
||||
take_focus(); mode_ = 0;
|
||||
if (Fl::event_y() < y() + 28) {
|
||||
uint64_t target = tick(Fl::event_x());
|
||||
xtc_result r = seek_(context_, target);
|
||||
if (r == XTC_OK) cursor_ = target;
|
||||
else fl_alert("Stop playback before seeking: %s", xtc_result_string(r));
|
||||
redraw(); return 1;
|
||||
}
|
||||
size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips);
|
||||
int row = (Fl::event_y() - y() - 28) / 56;
|
||||
if (row < 0 || static_cast<size_t>(row) >= tracks) return 1;
|
||||
xtc_track_info t{}; xtc_project_get_track(project_, static_cast<size_t>(row), &t); track_ = t.id;
|
||||
clip_ = UINT64_MAX;
|
||||
for (size_t j = clips; j > 0; --j) {
|
||||
xtc_clip_info c{}; xtc_project_get_clip(project_, j - 1, &c);
|
||||
int left = x() + 120 + static_cast<int>(c.start_tick * static_cast<unsigned int>(scale_) / 960U);
|
||||
int right = left + static_cast<int>(c.length_tick * static_cast<unsigned int>(scale_) / 960U);
|
||||
if (c.track_id == track_ && Fl::event_x() >= left && Fl::event_x() < right) {
|
||||
clip_ = c.id; original_ = c; drag_x_ = Fl::event_x();
|
||||
mode_ = Fl::event_x() - left < 7 ? 2 : right - Fl::event_x() < 7 ? 3 : 1; break;
|
||||
}
|
||||
}
|
||||
redraw(); return 1;
|
||||
}
|
||||
if (event == FL_DRAG) return mode_ != 0;
|
||||
if (event == FL_RELEASE && mode_) {
|
||||
int mode = mode_; mode_ = 0;
|
||||
int64_t delta = static_cast<int64_t>(Fl::event_x() - drag_x_) * 960 / scale_ / 240 * 240;
|
||||
int64_t start = static_cast<int64_t>(original_.start_tick);
|
||||
int64_t length = static_cast<int64_t>(original_.length_tick);
|
||||
int64_t offset = static_cast<int64_t>(original_.offset_tick);
|
||||
uint64_t target = original_.track_id;
|
||||
if (mode == 1) {
|
||||
start = std::max<int64_t>(0, start + delta);
|
||||
int row = (Fl::event_y() - y() - 28) / 56;
|
||||
xtc_track_info t{};
|
||||
if (row >= 0 && xtc_project_get_track(project_, static_cast<size_t>(row), &t) == XTC_OK) target = t.id;
|
||||
} else if (mode == 2) { start += delta; offset += delta; length -= delta; }
|
||||
else length += delta;
|
||||
if (start < 0 || offset < 0 || length <= 0) return 1;
|
||||
changed(xtc_project_edit_clip(project_, clip_, target, static_cast<uint64_t>(start),
|
||||
static_cast<uint64_t>(length), static_cast<uint64_t>(offset)));
|
||||
return 1;
|
||||
}
|
||||
if (event == FL_KEYDOWN && Fl::event_key() == FL_Escape) { mode_ = 0; return 1; }
|
||||
if (event == FL_KEYDOWN && Fl::event_key() == FL_Delete) { delete_selection(); return 1; }
|
||||
if (event == FL_FOCUS || event == FL_UNFOCUS) return 1;
|
||||
return Fl_Widget::handle(event);
|
||||
}
|
||||
31
src/gui/sequencer_widget.hpp
Normal file
31
src/gui/sequencer_widget.hpp
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#ifndef XTC_SEQUENCER_WIDGET_HPP
|
||||
#define XTC_SEQUENCER_WIDGET_HPP
|
||||
#include <FL/Fl_Widget.H>
|
||||
#include "xtc/xtc_project.h"
|
||||
class SequencerWidget : public Fl_Widget {
|
||||
public:
|
||||
using Seek = xtc_result (*)(void *, uint64_t);
|
||||
SequencerWidget(int x, int y, xtc_project *project, Seek seek, void *context);
|
||||
void set_project(xtc_project *project, bool reset = true);
|
||||
uint64_t position() const { return cursor_; }
|
||||
void position(uint64_t tick) { cursor_ = tick; redraw(); }
|
||||
xtc_result add_track();
|
||||
xtc_result add_region();
|
||||
xtc_result delete_selection();
|
||||
void zoom(int direction);
|
||||
xtc_project *project() const { return project_; }
|
||||
int handle(int event) override;
|
||||
protected:
|
||||
void draw() override;
|
||||
private:
|
||||
xtc_project *project_;
|
||||
Seek seek_;
|
||||
void *context_;
|
||||
uint64_t track_ = UINT64_MAX, clip_ = UINT64_MAX, cursor_ = 0;
|
||||
int scale_ = 48, drag_x_ = 0, mode_ = 0;
|
||||
xtc_clip_info original_{};
|
||||
uint64_t tick(int px) const;
|
||||
void changed(xtc_result result);
|
||||
void extent();
|
||||
};
|
||||
#endif
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
227
src/persistence/project_file.c
Normal file
227
src/persistence/project_file.c
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "../project/project_internal.h"
|
||||
#include "../graph/graph_internal.h"
|
||||
#include "xtc/xtc.h"
|
||||
#include <float.h>
|
||||
#include <inttypes.h>
|
||||
#include <limits.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef struct { FILE *file; size_t bytes; char line[512]; char *tokens[12]; size_t count; } reader;
|
||||
static xtc_result line(reader *input)
|
||||
{
|
||||
size_t used = 0U;
|
||||
int byte;
|
||||
char *cursor;
|
||||
input->count = 0U;
|
||||
while ((byte = fgetc(input->file)) != EOF) {
|
||||
if (++input->bytes > 1048576U) return XTC_ERR_FORMAT;
|
||||
if (byte == '\n') break;
|
||||
if (byte < 32 || byte > 126 || used == sizeof(input->line) - 1U) return XTC_ERR_FORMAT;
|
||||
input->line[used++] = (char)byte;
|
||||
}
|
||||
if (byte == EOF) return ferror(input->file) ? XTC_ERR_IO : XTC_ERR_FORMAT;
|
||||
input->line[used] = '\0'; cursor = input->line;
|
||||
while (*cursor != '\0') {
|
||||
while (*cursor == ' ') ++cursor;
|
||||
if (*cursor == '\0') break;
|
||||
if (input->count == 12U) return XTC_ERR_FORMAT;
|
||||
input->tokens[input->count++] = cursor;
|
||||
while (*cursor != '\0' && *cursor != ' ') ++cursor;
|
||||
if (*cursor == ' ') *cursor++ = '\0';
|
||||
}
|
||||
return input->count == 0U ? XTC_ERR_FORMAT : XTC_OK;
|
||||
}
|
||||
static int number(const char *text, uint64_t *value)
|
||||
{
|
||||
uint64_t result = 0U;
|
||||
const unsigned char *cursor = (const unsigned char *)text;
|
||||
if (*cursor == '\0') return 0;
|
||||
while (*cursor != '\0') {
|
||||
unsigned int digit;
|
||||
if (*cursor < '0' || *cursor > '9') return 0;
|
||||
digit = (unsigned int)(*cursor++ - '0');
|
||||
if (result > (UINT64_MAX - digit) / 10U) return 0;
|
||||
result = result * 10U + digit;
|
||||
}
|
||||
*value = result; return 1;
|
||||
}
|
||||
static int hex_digit(char byte)
|
||||
{
|
||||
if (byte >= '0' && byte <= '9') return byte - '0';
|
||||
if (byte >= 'a' && byte <= 'f') return byte - 'a' + 10;
|
||||
if (byte >= 'A' && byte <= 'F') return byte - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
static int decode_name(const char *text, char *name)
|
||||
{
|
||||
size_t length = strlen(text), i;
|
||||
if (length == 0U || length % 2U != 0U || length > 126U) return 0;
|
||||
for (i = 0U; i < length / 2U; ++i) {
|
||||
int high = hex_digit(text[i * 2U]), low = hex_digit(text[i * 2U + 1U]);
|
||||
if (high < 0 || low < 0 || (high == 0 && low == 0)) return 0;
|
||||
name[i] = (char)(high * 16 + low);
|
||||
}
|
||||
name[length / 2U] = '\0'; return xtc_project_valid_name(name);
|
||||
}
|
||||
static int decode_float(const char *text, float *value)
|
||||
{
|
||||
uint32_t bits = 0U;
|
||||
size_t i;
|
||||
if (strlen(text) != 8U || (sizeof(*value) != sizeof(bits) || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128)) return 0;
|
||||
for (i = 0U; i < 8U; ++i) {
|
||||
int digit = hex_digit(text[i]);
|
||||
if (digit < 0) return 0;
|
||||
bits = (bits << 4U) | (uint32_t)digit;
|
||||
}
|
||||
memcpy(value, &bits, sizeof(bits)); return isfinite(*value);
|
||||
}
|
||||
#define READ(tag, total) do { xtc_result read_result = line(input); if (read_result != XTC_OK) return read_result; \
|
||||
if (input->count != (total) || strcmp(input->tokens[0], (tag)) != 0) return XTC_ERR_FORMAT; } while (0)
|
||||
#define NUM(index, variable) do { if (!number(input->tokens[(index)], &(variable))) return XTC_ERR_FORMAT; } while (0)
|
||||
static xtc_result read_project(reader *input, xtc_project *project)
|
||||
{
|
||||
uint64_t version, value, next_id, node_count, edge_count;
|
||||
size_t i, j;
|
||||
xtc_graph *graph = project->graph;
|
||||
READ("XTC_PROJECT", 2U); NUM(1U, version);
|
||||
if (version != 1U) return XTC_ERR_UNSUPPORTED;
|
||||
READ("APP", 4U);
|
||||
for (i = 1U; i < 4U; ++i) { NUM(i, value); if (value > UINT_MAX) return XTC_ERR_FORMAT; }
|
||||
READ("TEMPO", 2U); NUM(1U, value);
|
||||
if (value < 20000U || value > 300000U) return XTC_ERR_FORMAT;
|
||||
project->tempo = (unsigned int)value;
|
||||
READ("IDS", 3U); NUM(1U, project->next_track_id); NUM(2U, project->next_clip_id);
|
||||
READ("GRAPH", 5U);
|
||||
NUM(1U, value); NUM(2U, next_id); NUM(3U, node_count); NUM(4U, edge_count);
|
||||
if (value > 1U || next_id > SIZE_MAX || node_count > 64U || edge_count > node_count * node_count) return XTC_ERR_FORMAT;
|
||||
graph->require_output_nodes = (int)value; graph->next_id = (size_t)next_id;
|
||||
for (i = 0U; i < (size_t)node_count; ++i) {
|
||||
xtc_node node;
|
||||
READ("NODE", 4U); NUM(1U, value); NUM(2U, version);
|
||||
if (value >= next_id || value >= SIZE_MAX || version < XTC_NODE_GAIN || version > XTC_NODE_OUTPUT ||
|
||||
!decode_float(input->tokens[3], &node.value)) return XTC_ERR_FORMAT;
|
||||
node.id = (size_t)value; node.type = (xtc_node_type)version;
|
||||
if (node.type == XTC_NODE_OSCILLATOR && node.value < 0.0f) return XTC_ERR_FORMAT;
|
||||
if ((node.type == XTC_NODE_METER || node.type == XTC_NODE_OUTPUT) && node.value != 0.0f) return XTC_ERR_FORMAT;
|
||||
for (j = 0U; j < i; ++j) if (graph->nodes[j].id == node.id) return XTC_ERR_FORMAT;
|
||||
graph->nodes[graph->node_count++] = node;
|
||||
}
|
||||
for (i = 0U; i < (size_t)edge_count; ++i) {
|
||||
uint64_t source, dest;
|
||||
READ("EDGE", 3U); NUM(1U, source); NUM(2U, dest);
|
||||
if (source >= SIZE_MAX || dest >= SIZE_MAX ||
|
||||
xtc_graph_connect(graph, (size_t)source, (size_t)dest) != XTC_OK) return XTC_ERR_FORMAT;
|
||||
}
|
||||
READ("TRACKS", 2U); NUM(1U, value);
|
||||
if (value > XTC_PROJECT_MAX_TRACKS) return XTC_ERR_FORMAT;
|
||||
project->track_count = (size_t)value;
|
||||
for (i = 0U; i < project->track_count; ++i) {
|
||||
READ("TRACK", 3U); NUM(1U, project->tracks[i].id);
|
||||
if (!decode_name(input->tokens[2], project->tracks[i].name)) return XTC_ERR_FORMAT;
|
||||
}
|
||||
READ("CLIPS", 2U); NUM(1U, value);
|
||||
if (value > XTC_PROJECT_MAX_CLIPS) return XTC_ERR_FORMAT;
|
||||
project->clip_count = (size_t)value;
|
||||
for (i = 0U; i < project->clip_count; ++i) {
|
||||
xtc_clip_info *clip = project->clips + i;
|
||||
READ("CLIP", 8U);
|
||||
NUM(1U, clip->id); NUM(2U, clip->track_id); NUM(3U, clip->start_tick);
|
||||
NUM(4U, clip->length_tick); NUM(5U, clip->offset_tick); NUM(6U, clip->source_length_tick);
|
||||
if (!decode_name(input->tokens[7], clip->name)) return XTC_ERR_FORMAT;
|
||||
}
|
||||
READ("END", 1U);
|
||||
if (fgetc(input->file) != EOF) return XTC_ERR_FORMAT;
|
||||
if (ferror(input->file)) return XTC_ERR_IO;
|
||||
return xtc_project_validate(project);
|
||||
}
|
||||
static int write_name(FILE *file, const char *name)
|
||||
{
|
||||
const unsigned char *cursor = (const unsigned char *)name;
|
||||
while (*cursor != '\0') if (fprintf(file, "%02x", (unsigned int)*cursor++) < 0) return 0;
|
||||
return fputc('\n', file) != EOF;
|
||||
}
|
||||
static xtc_result write_project(FILE *file, const xtc_project *project)
|
||||
{
|
||||
const xtc_graph *graph = project->graph;
|
||||
size_t i;
|
||||
if (sizeof(float) != sizeof(uint32_t) || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128) return XTC_ERR_UNSUPPORTED;
|
||||
if (fprintf(file, "XTC_PROJECT 1\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n",
|
||||
XTC_VERSION_MAJOR, XTC_VERSION_MINOR, XTC_VERSION_PATCH, project->tempo,
|
||||
project->next_track_id, project->next_clip_id, graph->require_output_nodes,
|
||||
graph->next_id, graph->node_count, graph->edge_count) < 0) return XTC_ERR_IO;
|
||||
for (i = 0U; i < graph->node_count; ++i) {
|
||||
uint32_t bits;
|
||||
memcpy(&bits, &graph->nodes[i].value, sizeof(bits));
|
||||
if (fprintf(file, "NODE %zu %u %08" PRIx32 "\n", graph->nodes[i].id,
|
||||
(unsigned int)graph->nodes[i].type, bits) < 0) return XTC_ERR_IO;
|
||||
}
|
||||
for (i = 0U; i < graph->edge_count; ++i)
|
||||
if (fprintf(file, "EDGE %zu %zu\n", graph->nodes[graph->edges[i].source].id,
|
||||
graph->nodes[graph->edges[i].destination].id) < 0) return XTC_ERR_IO;
|
||||
if (fprintf(file, "TRACKS %zu\n", project->track_count) < 0) return XTC_ERR_IO;
|
||||
for (i = 0U; i < project->track_count; ++i)
|
||||
if (fprintf(file, "TRACK %" PRIu64 " ", project->tracks[i].id) < 0 || !write_name(file, project->tracks[i].name)) return XTC_ERR_IO;
|
||||
if (fprintf(file, "CLIPS %zu\n", project->clip_count) < 0) return XTC_ERR_IO;
|
||||
for (i = 0U; i < project->clip_count; ++i) {
|
||||
const xtc_clip_info *clip = project->clips + i;
|
||||
if (fprintf(file, "CLIP %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 " ",
|
||||
clip->id, clip->track_id, clip->start_tick, clip->length_tick, clip->offset_tick, clip->source_length_tick) < 0 ||
|
||||
!write_name(file, clip->name)) return XTC_ERR_IO;
|
||||
}
|
||||
return fputs("END\n", file) == EOF ? XTC_ERR_IO : XTC_OK;
|
||||
}
|
||||
static size_t path_length(const char *path)
|
||||
{
|
||||
size_t size = 0U;
|
||||
if (path == NULL) return 0U;
|
||||
while (size < 4096U && path[size] != '\0') ++size;
|
||||
return size == 4096U ? 0U : size;
|
||||
}
|
||||
xtc_result xtc_project_load(xtc_project *project, const char *path)
|
||||
{
|
||||
xtc_project *candidate = NULL;
|
||||
reader input = {0};
|
||||
xtc_result result;
|
||||
if (project == NULL || path_length(path) == 0U) return XTC_ERR_INVALID_ARGUMENT;
|
||||
input.file = fopen(path, "rb");
|
||||
if (input.file == NULL) return XTC_ERR_IO;
|
||||
result = xtc_project_create(&candidate);
|
||||
if (result == XTC_OK) result = read_project(&input, candidate);
|
||||
if (fclose(input.file) != 0 && result == XTC_OK) result = XTC_ERR_IO;
|
||||
if (result == XTC_OK) {
|
||||
xtc_graph *old = project->graph;
|
||||
*project = *candidate;
|
||||
candidate->graph = old;
|
||||
}
|
||||
xtc_project_destroy(candidate); return result;
|
||||
}
|
||||
xtc_result xtc_project_save(const xtc_project *project, const char *path)
|
||||
{
|
||||
size_t length = path_length(path);
|
||||
char *temporary;
|
||||
FILE *file;
|
||||
int descriptor;
|
||||
xtc_result result;
|
||||
if (project == NULL || length == 0U) return XTC_ERR_INVALID_ARGUMENT;
|
||||
result = xtc_project_validate(project);
|
||||
if (result != XTC_OK) return result;
|
||||
temporary = malloc(length + 12U);
|
||||
if (temporary == NULL) return XTC_ERR_OUT_OF_MEMORY;
|
||||
memcpy(temporary, path, length); memcpy(temporary + length, ".tmp.XXXXXX", 12U);
|
||||
descriptor = mkstemp(temporary);
|
||||
if (descriptor < 0) { free(temporary); return XTC_ERR_IO; }
|
||||
file = fdopen(descriptor, "wb");
|
||||
if (file == NULL) { (void)close(descriptor); (void)unlink(temporary); free(temporary); return XTC_ERR_IO; }
|
||||
result = write_project(file, project);
|
||||
if (result == XTC_OK && fflush(file) != 0) result = XTC_ERR_IO;
|
||||
if (result == XTC_OK && fsync(descriptor) != 0) result = XTC_ERR_IO;
|
||||
if (fclose(file) != 0 && result == XTC_OK) result = XTC_ERR_IO;
|
||||
if (result == XTC_OK && rename(temporary, path) != 0) result = XTC_ERR_IO;
|
||||
if (result != XTC_OK) (void)unlink(temporary);
|
||||
free(temporary); return result;
|
||||
}
|
||||
|
|
@ -1 +1,6 @@
|
|||
Project domain model will live here. Keep it in C and separate from FLTK widgets.
|
||||
# Project model
|
||||
|
||||
project.c owns the opaque C project: graph, bounded tracks, silent arrangement
|
||||
regions, tempo and stable ID allocation. The GUI borrows handles and edits through
|
||||
xtc_project.h. Tick/sample conversion and source-preserving trim validation live
|
||||
here; no FLTK, file I/O or callback scheduling belongs in this module.
|
||||
|
|
|
|||
222
src/project/project.c
Normal file
222
src/project/project.c
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
#include "project_internal.h"
|
||||
#include "../graph/graph_internal.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
int xtc_project_valid_name(const char *name)
|
||||
{
|
||||
size_t length = 0U, i = 0U;
|
||||
if (name == NULL) return 0;
|
||||
while (length < XTC_PROJECT_NAME_BYTES && name[length] != '\0') ++length;
|
||||
if (length == 0U || length == XTC_PROJECT_NAME_BYTES) return 0;
|
||||
while (i < length) {
|
||||
unsigned char byte = (unsigned char)name[i++];
|
||||
size_t extra, j;
|
||||
if (byte < 32U || byte == 127U) return 0;
|
||||
if (byte < 128U) continue;
|
||||
if (byte >= 194U && byte <= 223U) extra = 1U;
|
||||
else if (byte >= 224U && byte <= 239U) extra = 2U;
|
||||
else if (byte >= 240U && byte <= 244U) extra = 3U;
|
||||
else return 0;
|
||||
if (extra > length - i) return 0;
|
||||
if ((byte == 224U && (unsigned char)name[i] < 160U) ||
|
||||
(byte == 237U && (unsigned char)name[i] >= 160U) ||
|
||||
(byte == 240U && (unsigned char)name[i] < 144U) ||
|
||||
(byte == 244U && (unsigned char)name[i] >= 144U)) return 0;
|
||||
for (j = 0U; j < extra; ++j) {
|
||||
unsigned char tail = (unsigned char)name[i++];
|
||||
if (tail < 128U || tail > 191U) return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
static size_t track_index(const xtc_project *project, uint64_t id)
|
||||
{
|
||||
size_t i;
|
||||
for (i = 0U; i < project->track_count; ++i) if (project->tracks[i].id == id) return i;
|
||||
return SIZE_MAX;
|
||||
}
|
||||
static size_t clip_index(const xtc_project *project, uint64_t id)
|
||||
{
|
||||
size_t i;
|
||||
for (i = 0U; i < project->clip_count; ++i) if (project->clips[i].id == id) return i;
|
||||
return SIZE_MAX;
|
||||
}
|
||||
static int valid_range(uint64_t start, uint64_t length, uint64_t offset, uint64_t source)
|
||||
{
|
||||
return start <= XTC_PROJECT_MAX_TICK && length != 0U &&
|
||||
length <= XTC_PROJECT_MAX_TICK - start && source <= XTC_PROJECT_MAX_TICK &&
|
||||
offset <= source && length <= source - offset;
|
||||
}
|
||||
xtc_result xtc_project_create(xtc_project **out_project)
|
||||
{
|
||||
xtc_project *project;
|
||||
xtc_result result;
|
||||
if (out_project == NULL) return XTC_ERR_INVALID_ARGUMENT;
|
||||
*out_project = NULL;
|
||||
project = calloc(1U, sizeof(*project));
|
||||
if (project == NULL) return XTC_ERR_OUT_OF_MEMORY;
|
||||
result = xtc_graph_create(64U, &project->graph);
|
||||
if (result != XTC_OK) { free(project); return result; }
|
||||
project->graph->require_output_nodes = 1;
|
||||
project->tempo = 120000U;
|
||||
*out_project = project;
|
||||
return XTC_OK;
|
||||
}
|
||||
void xtc_project_destroy(xtc_project *project)
|
||||
{ if (project != NULL) { xtc_graph_destroy(project->graph); free(project); } }
|
||||
const xtc_graph *xtc_project_graph(const xtc_project *project)
|
||||
{ return project == NULL ? NULL : project->graph; }
|
||||
xtc_result xtc_project_set_graph(xtc_project *project, const xtc_graph *source)
|
||||
{
|
||||
xtc_graph *copy;
|
||||
xtc_result result;
|
||||
if (project == NULL || source == NULL) return XTC_ERR_INVALID_ARGUMENT;
|
||||
if (source->node_count > 64U || source->edge_count > 4096U) return XTC_ERR_CAPACITY;
|
||||
result = xtc_graph_validate(source);
|
||||
if (result != XTC_OK) return result;
|
||||
result = xtc_graph_create(64U, ©);
|
||||
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;
|
||||
}
|
||||
14
src/project/project_internal.h
Normal file
14
src/project/project_internal.h
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#ifndef XTC_PROJECT_INTERNAL_H
|
||||
#define XTC_PROJECT_INTERNAL_H
|
||||
#include "xtc/xtc_project.h"
|
||||
struct xtc_project {
|
||||
xtc_graph *graph;
|
||||
uint64_t next_track_id, next_clip_id;
|
||||
unsigned int tempo;
|
||||
size_t track_count, clip_count;
|
||||
xtc_track_info tracks[XTC_PROJECT_MAX_TRACKS];
|
||||
xtc_clip_info clips[XTC_PROJECT_MAX_CLIPS];
|
||||
};
|
||||
int xtc_project_valid_name(const char *name);
|
||||
xtc_result xtc_project_validate(const xtc_project *project);
|
||||
#endif
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "../../src/gui/main_window.hpp"
|
||||
#include "../../src/gui/graph_widget.hpp"
|
||||
#include "../../src/gui/sequencer_widget.hpp"
|
||||
#include <unistd.h>
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/Fl_Box.H>
|
||||
#include <FL/Fl_Button.H>
|
||||
|
|
@ -49,6 +51,24 @@ bool contains(Fl_Group *group, const char *text)
|
|||
}
|
||||
return false;
|
||||
}
|
||||
SequencerWidget *timeline(Fl_Group *group)
|
||||
{
|
||||
for (int i = 0; i < group->children(); ++i) {
|
||||
if (auto *s = dynamic_cast<SequencerWidget *>(group->child(i))) return s;
|
||||
if (auto *g = dynamic_cast<Fl_Group *>(group->child(i))) if (auto *s = timeline(g)) return s;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
void gesture(SequencerWidget *s, int left, int top, int dx, int dy)
|
||||
{
|
||||
int old_x = Fl::e_x, old_y = Fl::e_y, old_key = Fl::e_keysym;
|
||||
Fl::e_x = s->x() + left; Fl::e_y = s->y() + top; Fl::e_keysym = FL_Button + FL_LEFT_MOUSE;
|
||||
CHECK(s->handle(FL_PUSH) == 1);
|
||||
Fl::e_x += dx; Fl::e_y += dy;
|
||||
CHECK(s->handle(FL_DRAG) == 1);
|
||||
CHECK(s->handle(FL_RELEASE) == 1);
|
||||
Fl::e_x = old_x; Fl::e_y = old_y; Fl::e_keysym = old_key;
|
||||
}
|
||||
void press(Fl_Widget *widget)
|
||||
{
|
||||
CHECK(widget != nullptr);
|
||||
|
|
@ -92,6 +112,8 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status
|
|||
status->running = handle.running && !status->server_lost;
|
||||
return status_result;
|
||||
}
|
||||
xtc_result xtc_backend_seek(xtc_backend *, uint64_t sample)
|
||||
{ engine_status.sample_position = sample; return handle.running ? XTC_ERR_INVALID_STATE : XTC_OK; }
|
||||
xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph)
|
||||
{
|
||||
CHECK(backend == &handle && graph != nullptr);
|
||||
|
|
@ -111,7 +133,7 @@ int main(int argc, char **argv)
|
|||
{
|
||||
reset();
|
||||
{
|
||||
MainWindow window;
|
||||
MainWindow window(false);
|
||||
window.show(argc, argv);
|
||||
auto *play = find(&window, "Play");
|
||||
auto *stop = find(&window, "Stop");
|
||||
|
|
@ -216,6 +238,37 @@ int main(int argc, char **argv)
|
|||
for (size_t id : {0U, 2U, 3U, 5U}) { CHECK(canvas->select_node(id)); CHECK(canvas->delete_selected() == XTC_OK); }
|
||||
CHECK(xtc_graph_get_counts(canvas->graph(), &nodes, &edges) == XTC_OK && nodes == 0U && edges == 0U);
|
||||
CHECK(xtc_plan_process(handle.plan, nullptr, rendered, 32U) == XTC_OK && rendered[1] == 0.0f);
|
||||
auto *sequence = timeline(&window);
|
||||
CHECK(sequence != nullptr);
|
||||
char directory[] = "/tmp/xtc-gui-XXXXXX"; CHECK(mkdtemp(directory) != nullptr);
|
||||
std::string filename = std::string(directory) + "/song.xtc";
|
||||
press(find(&window, "Add track")); press(find(&window, "Add region"));
|
||||
size_t track_count, clip_count;
|
||||
CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && track_count == 1 && clip_count == 1);
|
||||
xtc_clip_info region{};
|
||||
CHECK(xtc_project_get_clip(sequence->project(), 0, ®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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "../../src/gui/main_window.hpp"
|
||||
#include "../../src/gui/graph_widget.hpp"
|
||||
#include "../../src/gui/sequencer_widget.hpp"
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/Fl_Group.H>
|
||||
#include <chrono>
|
||||
|
|
@ -24,6 +25,14 @@ GraphWidget *canvas_in(Fl_Group &group)
|
|||
}
|
||||
return nullptr;
|
||||
}
|
||||
SequencerWidget *timeline_in(Fl_Group &group)
|
||||
{
|
||||
for (int i = 0; i < group.children(); ++i) {
|
||||
if (auto *s = dynamic_cast<SequencerWidget *>(group.child(i))) return s;
|
||||
if (auto *g = dynamic_cast<Fl_Group *>(group.child(i))) if (auto *s = timeline_in(*g)) return s;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
void pump()
|
||||
{
|
||||
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(400);
|
||||
|
|
@ -35,7 +44,7 @@ int main(int argc, char **argv)
|
|||
{
|
||||
// Real JACK backend and real FLTK event loop. No routes, no playback data.
|
||||
for (int iteration = 0; iteration < 3; ++iteration) {
|
||||
MainWindow window;
|
||||
MainWindow window(false);
|
||||
window.show(argc, argv);
|
||||
CHECK(find(window, "ready / stopped") != nullptr);
|
||||
auto *play = find(window, "Play");
|
||||
|
|
@ -44,16 +53,19 @@ int main(int argc, char **argv)
|
|||
CHECK(play != nullptr && stop != nullptr && reconnect != nullptr);
|
||||
auto *canvas = canvas_in(window);
|
||||
CHECK(canvas != nullptr);
|
||||
auto *timeline = timeline_in(window); CHECK(timeline != nullptr);
|
||||
CHECK(canvas->add_node(XTC_NODE_OSCILLATOR) == XTC_OK);
|
||||
CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_OK);
|
||||
CHECK(canvas->add_node(XTC_NODE_OUTPUT) == XTC_OK);
|
||||
CHECK(canvas->connect_ports({0U, 1U, 1U, 0U}) == XTC_OK);
|
||||
CHECK(canvas->connect_ports({1U, 1U, 2U, 0U}) == XTC_OK);
|
||||
for (int restart = 0; restart < 3; ++restart) {
|
||||
uint64_t previous = timeline->position();
|
||||
CHECK(play->active());
|
||||
play->do_callback();
|
||||
pump();
|
||||
CHECK(find(window, "running — modular graph") != nullptr);
|
||||
CHECK(timeline->position() > previous);
|
||||
CHECK(find(window, "Graph: active in engine") != nullptr);
|
||||
const xtc_graph *unchanged = canvas->graph();
|
||||
CHECK(canvas->connect_ports({1U, 1U, 1U, 0U}) == XTC_ERR_GRAPH_CYCLE);
|
||||
|
|
@ -71,6 +83,8 @@ int main(int argc, char **argv)
|
|||
CHECK(rate > 0U && frames > 0U && blocks > 0U);
|
||||
stop->do_callback();
|
||||
CHECK(find(window, "ready / stopped") != nullptr);
|
||||
uint64_t paused = timeline->position(); pump();
|
||||
CHECK(timeline->position() == paused);
|
||||
}
|
||||
reconnect->do_callback();
|
||||
CHECK(find(window, "ready / stopped") != nullptr);
|
||||
|
|
|
|||
|
|
@ -121,25 +121,29 @@ int main(void)
|
|||
activate_fail = 1;
|
||||
CHECK(xtc_backend_start(backend) == XTC_ERR_IO);
|
||||
activate_fail = 0;
|
||||
CHECK(xtc_backend_seek(backend, 1000U) == XTC_OK);
|
||||
CHECK(xtc_backend_submit(backend, graph) == XTC_OK);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 1000U);
|
||||
CHECK(xtc_backend_start(backend) == XTC_OK);
|
||||
CHECK(xtc_backend_seek(backend, 0) == XTC_ERR_INVALID_STATE);
|
||||
CHECK(xtc_backend_start(backend) == XTC_ERR_INVALID_STATE);
|
||||
CHECK(xtc_backend_connect(backend, XTC_PORT_OUTPUT, "fake:in") == XTC_OK);
|
||||
CHECK(xtc_backend_connect(backend, XTC_PORT_INPUT, "missing:out") == XTC_ERR_IO);
|
||||
CHECK(xtc_backend_disconnect(backend, XTC_PORT_OUTPUT, "fake:in") == XTC_OK);
|
||||
for (i = 0U; i < 128U; ++i) input[i] = 1.0f;
|
||||
CHECK(process_cb(32U, context) == 0 && output[0] == 0.5f);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.active_revision == status.submitted_revision);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.active_revision == status.submitted_revision && status.sample_position == 1032U);
|
||||
CHECK(xtc_backend_submit(backend, invalid_graph) == XTC_ERR_INVALID_ARGUMENT);
|
||||
CHECK(xtc_backend_submit(backend, graph) == XTC_OK);
|
||||
CHECK(xtc_backend_submit(backend, graph) == XTC_ERR_CAPACITY);
|
||||
CHECK(process_cb(32U, context) == 0 && output[0] == 0.5f);
|
||||
CHECK(size_cb(128U, context) == 0);
|
||||
CHECK(process_cb(128U, context) == 0 && output[127] == 0.0f);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.process_result == XTC_ERR_CAPACITY);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.process_result == XTC_ERR_CAPACITY && status.sample_position == 1064U);
|
||||
CHECK(size_cb(16U, context) == 0);
|
||||
CHECK(process_cb(16U, context) == 0 && output[15] == 0.5f);
|
||||
CHECK(xrun_cb(context) == 0);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.xruns == 1U && status.buffer_frames == 16U);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.xruns == 1U && status.buffer_frames == 16U && status.sample_position == 1080U);
|
||||
null_buffer = 1;
|
||||
CHECK(process_cb(16U, context) == 0);
|
||||
null_buffer = 0;
|
||||
|
|
@ -165,7 +169,12 @@ int main(void)
|
|||
CHECK(xtc_backend_start(backend) == XTC_ERR_INVALID_STATE);
|
||||
} else {
|
||||
CHECK(xtc_backend_stop(backend) == XTC_OK);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 32U);
|
||||
CHECK(xtc_backend_seek(backend, 24000U) == XTC_OK);
|
||||
CHECK(xtc_backend_submit(backend, graph) == XTC_OK);
|
||||
CHECK(xtc_backend_start(backend) == XTC_OK);
|
||||
CHECK(process_cb(32U, context) == 0);
|
||||
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 24032U);
|
||||
}
|
||||
CHECK(xtc_backend_submit(backend, graph) == (iteration % 2 == 0 ? XTC_ERR_INVALID_STATE : XTC_OK));
|
||||
close_fail = 1;
|
||||
|
|
|
|||
121
tests/unit/test_project.c
Normal file
121
tests/unit/test_project.c
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "xtc/xtc_project.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
static void write_bytes(const char *path, const char *bytes, size_t count)
|
||||
{
|
||||
FILE *f = fopen(path, "wb"); assert(f);
|
||||
assert(fwrite(bytes, 1, count, f) == count); assert(fclose(f) == 0);
|
||||
}
|
||||
static size_t read_bytes(const char *path, char *bytes)
|
||||
{
|
||||
FILE *f = fopen(path, "rb"); size_t n; assert(f);
|
||||
n = fread(bytes, 1, 8191, f); assert(!ferror(f)); assert(fclose(f) == 0); bytes[n] = 0; return n;
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
xtc_project *p = NULL, *q = NULL;
|
||||
uint64_t a, b, c, id, sample, tick;
|
||||
size_t tracks, clips, nodes, edges;
|
||||
xtc_clip_info info, before;
|
||||
xtc_graph *graph = NULL;
|
||||
size_t gain, output, discarded;
|
||||
xtc_graph *edited = NULL;
|
||||
xtc_graph_edit edit = {0};
|
||||
xtc_plan *plan = NULL;
|
||||
float input = 1.0f, rendered = 0.0f;
|
||||
char dir[] = "/tmp/xtc-project-XXXXXX", path[256], other[256], bad[256];
|
||||
char data[8192], again[8192], damaged[8192];
|
||||
size_t length, i;
|
||||
assert(mkdtemp(dir));
|
||||
snprintf(path, sizeof(path), "%s/song.xtc", dir);
|
||||
snprintf(other, sizeof(other), "%s/copy.xtc", dir);
|
||||
snprintf(bad, sizeof(bad), "%s/bad.xtc", dir);
|
||||
assert(xtc_project_create(&p) == XTC_OK);
|
||||
assert(xtc_project_create(&q) == XTC_OK);
|
||||
assert(xtc_project_add_track(p, "Drums ä", &a) == XTC_OK && a == 0);
|
||||
assert(xtc_project_add_track(p, "Bass", &b) == XTC_OK && b == 1);
|
||||
assert(xtc_project_add_track(p, "\300\200", &id) == XTC_ERR_INVALID_ARGUMENT);
|
||||
assert(xtc_project_add_track(p, "Bad\n", &id) == XTC_ERR_INVALID_ARGUMENT);
|
||||
assert(xtc_project_add_clip(p, a, "Verse", 3840, 3840, &c) == XTC_OK);
|
||||
assert(xtc_project_edit_clip(p, c, b, 4800, 2880, 960) == XTC_OK);
|
||||
assert(xtc_project_find_clip(p, c, &before) == XTC_OK);
|
||||
assert(xtc_project_edit_clip(p, c, b, 0, 3840, 960) == XTC_ERR_INVALID_ARGUMENT);
|
||||
assert(xtc_project_find_clip(p, c, &info) == XTC_OK && memcmp(&info, &before, sizeof(info)) == 0);
|
||||
assert(xtc_project_add_clip(p, 99, "Bad", 0, 960, &id) == XTC_ERR_NOT_FOUND);
|
||||
assert(xtc_project_add_clip(p, a, "Bad", XTC_PROJECT_MAX_TICK, 1, &id) == XTC_ERR_INVALID_ARGUMENT);
|
||||
assert(xtc_project_tick_to_sample(p, 960, 48000, &sample) == XTC_OK && sample == 24000);
|
||||
assert(xtc_project_sample_to_tick(p, sample, 48000, &tick) == XTC_OK && tick == 960);
|
||||
assert(xtc_project_set_tempo(p, 20000) == XTC_OK);
|
||||
assert(xtc_project_tick_to_sample(p, XTC_PROJECT_MAX_TICK, 768000, &sample) == XTC_OK);
|
||||
assert(xtc_project_sample_to_tick(p, sample, 768000, &tick) == XTC_OK && tick == XTC_PROJECT_MAX_TICK);
|
||||
assert(xtc_project_sample_to_tick(p, UINT64_MAX, 1, &tick) == XTC_ERR_CAPACITY);
|
||||
assert(xtc_project_set_tempo(p, 120000) == XTC_OK);
|
||||
assert(xtc_graph_create(64, &graph) == XTC_OK);
|
||||
assert(xtc_graph_add_gain(graph, 0.25f, &gain) == XTC_OK);
|
||||
assert(xtc_graph_add_output(graph, &output) == XTC_OK);
|
||||
assert(xtc_graph_connect(graph, gain, output) == XTC_OK);
|
||||
assert(xtc_graph_require_output_nodes(graph, 1) == XTC_OK);
|
||||
assert(xtc_graph_add_gain(graph, 0.5f, &discarded) == XTC_OK);
|
||||
edit.kind = XTC_EDIT_REMOVE_NODE; edit.data.node_id = discarded;
|
||||
assert(xtc_graph_apply_edit(graph, &edit, &edited, NULL) == XTC_OK);
|
||||
xtc_graph_destroy(graph); graph = edited; edited = NULL;
|
||||
assert(xtc_project_set_graph(p, graph) == XTC_OK);
|
||||
xtc_graph_destroy(graph);
|
||||
assert(xtc_project_remove_track(p, a) == XTC_OK);
|
||||
assert(xtc_project_save(p, path) == XTC_OK);
|
||||
length = read_bytes(path, data);
|
||||
assert(xtc_project_load(q, path) == XTC_OK);
|
||||
assert(xtc_project_find_clip(q, c, &info) == XTC_OK && memcmp(&info, &before, sizeof(info)) == 0);
|
||||
assert(xtc_graph_get_counts(xtc_project_graph(q), &nodes, &edges) == XTC_OK && nodes == 2 && edges == 1);
|
||||
assert(xtc_graph_compile(xtc_project_graph(q), 48000.0, 1, &plan) == XTC_OK);
|
||||
assert(xtc_plan_process(plan, &input, &rendered, 1) == XTC_OK && rendered == 0.25f);
|
||||
xtc_plan_destroy(plan);
|
||||
edit.kind = XTC_EDIT_ADD_NODE; edit.data.add.type = XTC_NODE_GAIN; edit.data.add.value = 1.0f;
|
||||
assert(xtc_graph_apply_edit(xtc_project_graph(q), &edit, &edited, &gain) == XTC_OK && gain == 3);
|
||||
xtc_graph_destroy(edited); edited = NULL;
|
||||
assert(xtc_project_save(q, other) == XTC_OK);
|
||||
assert(read_bytes(other, again) == length && memcmp(data, again, length) == 0);
|
||||
/* Every truncation is rejected without altering the existing arrangement. */
|
||||
for (i = 0; i < length; ++i) {
|
||||
write_bytes(bad, data, i);
|
||||
assert(xtc_project_load(q, bad) != XTC_OK);
|
||||
assert(xtc_project_find_clip(q, c, &info) == XTC_OK && memcmp(&info, &before, sizeof(info)) == 0);
|
||||
}
|
||||
memcpy(damaged, data, length); damaged[12] = '9';
|
||||
write_bytes(bad, damaged, length);
|
||||
assert(xtc_project_load(q, bad) == XTC_ERR_UNSUPPORTED);
|
||||
memcpy(damaged, data, length); damaged[length] = '\n';
|
||||
write_bytes(bad, damaged, length + 1);
|
||||
assert(xtc_project_load(q, bad) == XTC_ERR_FORMAT);
|
||||
memcpy(damaged, data, length); damaged[0] = 0;
|
||||
write_bytes(bad, damaged, length);
|
||||
assert(xtc_project_load(q, bad) == XTC_ERR_FORMAT);
|
||||
/* Mutate every byte: accepted variants must still be serializable. */
|
||||
for (i = 0; i < length; ++i) {
|
||||
memcpy(damaged, data, length); damaged[i] = '9';
|
||||
write_bytes(bad, damaged, length);
|
||||
if (xtc_project_load(q, bad) == XTC_OK) assert(xtc_project_save(q, other) == XTC_OK);
|
||||
}
|
||||
assert(xtc_project_load(q, path) == XTC_OK);
|
||||
assert(xtc_project_add_track(q, "New", &id) == XTC_OK && id == 2);
|
||||
assert(xtc_project_remove_track(q, b) == XTC_OK);
|
||||
assert(xtc_project_get_counts(q, &tracks, &clips) == XTC_OK && tracks == 1 && clips == 0);
|
||||
assert(xtc_project_save(p, dir) == XTC_ERR_IO); /* Rename onto directory fails, temp removed. */
|
||||
assert(read_bytes(path, again) == length && memcmp(data, again, length) == 0);
|
||||
for (i = 1; i < XTC_PROJECT_MAX_TRACKS; ++i) assert(xtc_project_add_track(q, "Track", &id) == XTC_OK);
|
||||
assert(xtc_project_add_track(q, "Full", &id) == XTC_ERR_CAPACITY);
|
||||
assert(xtc_project_get_track(q, 0, &(xtc_track_info){0}) == XTC_OK);
|
||||
for (i = 0; i < XTC_PROJECT_MAX_CLIPS; ++i) assert(xtc_project_add_clip(q, 2, "Region", 0, 960, &id) == XTC_OK);
|
||||
assert(xtc_project_add_clip(q, 2, "Full", 0, 960, &id) == XTC_ERR_CAPACITY);
|
||||
assert(xtc_project_save(q, other) == XTC_OK && xtc_project_load(p, other) == XTC_OK);
|
||||
xtc_project_destroy(p); xtc_project_destroy(q);
|
||||
assert(unlink(path) == 0 && unlink(other) == 0 && unlink(bad) == 0);
|
||||
assert(rmdir(dir) == 0); /* Also verifies no abandoned save temporaries. */
|
||||
puts("Project model, persistence roundtrip, malformed input and cleanup passed.");
|
||||
return 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue