diff --git a/Makefile b/Makefile index 1085045..5daa4fd 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/core/xtc.c src/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 +CORE_SOURCES := src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c 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) @@ -34,7 +34,7 @@ FLTK_LIBS := $(shell $(FLTK_CONFIG) --ldflags) GUI_AVAILABLE := yes endif -.PHONY: all clean test test-jack test-gui test-gui-jack debug gui install +.PHONY: all clean test test-jack-audio test-jack test-gui test-gui-jack debug gui install all: $(BUILD_DIR)/libxtc.a $(BUILD_DIR)/xtc-check $(BUILD_DIR)/xtc-check: $(BUILD_DIR)/src/main.o $(BUILD_DIR)/libxtc.a @@ -74,8 +74,12 @@ $(BUILD_DIR)/test_graph_edits: $(BUILD_DIR)/tests/unit/test_graph_edits.o $(BUIL $(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 +$(BUILD_DIR)/test_audio: $(BUILD_DIR)/tests/unit/test_audio.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -pthread + +test: $(BUILD_DIR)/test_audio $(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_audio ./$(BUILD_DIR)/test_project ./$(BUILD_DIR)/test_graph_edits ./$(BUILD_DIR)/test_realtime @@ -87,6 +91,13 @@ debug: $(MAKE) BUILD_DIR="$(BUILD_DIR)/debug" CFLAGS="$(CFLAGS) -O0 -g3" CXXFLAGS="$(CXXFLAGS) -O0 -g3" all test ifeq ($(JACK_AVAILABLE),yes) +$(BUILD_DIR)/tests/integration/test_jack_audio.o: CPPFLAGS += $(JACK_CFLAGS) +$(BUILD_DIR)/test_jack_audio: $(BUILD_DIR)/tests/integration/test_jack_audio.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ $(JACK_LIBS) -lm + +test-jack-audio: $(BUILD_DIR)/test_jack_audio + ./$(BUILD_DIR)/test_jack_audio + $(BUILD_DIR)/tests/integration/test_jack.o: CPPFLAGS += $(JACK_CFLAGS) $(BUILD_DIR)/test_jack: $(BUILD_DIR)/tests/integration/test_jack.o $(BUILD_DIR)/libxtc.a $(CC) $(LDFLAGS) -o $@ $^ $(JACK_LIBS) -lm @@ -94,7 +105,7 @@ $(BUILD_DIR)/test_jack: $(BUILD_DIR)/tests/integration/test_jack.o $(BUILD_DIR)/ test-jack: $(BUILD_DIR)/test_jack ./$(BUILD_DIR)/test_jack else -test-jack: +test-jack-audio test-jack: @echo "JACK development files are required for the live integration test." @exit 1 endif @@ -144,3 +155,7 @@ clean: $(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 + +-include $(BUILD_DIR)/tests/unit/test_audio.d + +-include $(BUILD_DIR)/tests/integration/test_jack_audio.d diff --git a/README.md b/README.md index 5aea9ad..401c38d 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,19 @@ This repository is a starter scaffold, not a production DAW. It currently provides: - a small C library/API; - a mono graph model with port validation, cycle rejection and topological ordering; -- compiled offline plans with preallocated buffers, sine oscillator, gain, meter and output nodes; +- compiled offline plans with preallocated buffers, sine oscillator, audio source, gain, meter and output nodes; - C tests for deterministic rendering, routing, validation and lifecycle; - an optional JACK-compatible mono backend with start/stop, routing and status; - safe control/audio plan handoff and concurrent replacement tests; - an optional FLTK shell with engine controls, view switching and backend status; - a modular graph editor with transactional edits and live plan adoption status; -- versioned project save/load and a timeline for tracks and silent regions; +- versioned project save/load and a timeline for tracks and clips; - sample-counted transport, selection, move/trim, timeline zoom and scrolling; +- WAV import/graph playback, bounded recording and missing-asset relinking; - architecture, format, GUI, roadmap, and decision documents. The JACK backend has passed software-loopback testing; physical-device playback -is unverified. Recording, audio-region playback, MIDI and plugin hosting are not implemented yet. +and capture are unverified. MIDI and plugin hosting are not implemented yet. ## Build @@ -55,10 +56,15 @@ 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 regions are currently silent; the mixer editor remains a placeholder. +moving and deleting oscillator/audio/gain/meter/output nodes and connecting ports. +Import WAV (Ctrl+I) creates playable clips; Add region creates silent placeholders. +Record captures JACK input to a chosen WAV; Stop finalizes and adds the take. +Save the project after recording. External asset paths must remain available; +select a missing clip and use File/Relink selected audio to repair it. +See [audio workflow and limits](docs/AUDIO_FILES.md). The mixer 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. +Arrangement edits stop playback; graph edits remain live. 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; @@ -66,7 +72,8 @@ select a node or wire and press Delete to remove it. Invalid edits leave the active graph intact; queued changes are labeled until the engine adopts them. See [GUI documentation](docs/GUI.md). With JACK development files and an existing JACK-compatible server, run the -isolated software-loopback test with `make test-jack`. It connects only test +isolated software-loopback test with `make test-jack` and the record/reopen/play +acceptance test with `make test-jack-audio`. It connects only test clients. See [engine documentation](docs/AUDIO_ENGINE.md) for configuration, routing, ownership and recovery. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cece19a..6108f76 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -85,3 +85,24 @@ 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. + + +## Phase 6 assets and recording + +Audio paths are C clip metadata. A stopped preparation copies range/offset +information, deduplicates paths, validates and preloads bounded WAV assets into +an independent C audio plan. The backend owns that plan and a preallocated +timeline buffer until callback quiescence. A graph audio-source node consumes +the buffer, so effects/output routing remain graph operations. All tracks share +the mono source in this phase; no audio summation bypasses the graph. + +Arrangement edits stop transport; subsequent Play prepares the new snapshot. +Graph-only edits retain live plan publication. Missing assets preserve metadata +and render silence, with repair through a C path setter and GUI relinking. +Version 2 persistence stores references and explicitly migrates version 1 loads. + +Recording has one callback producer and one control-thread consumer. A bounded +SPSC ring publishes complete payload blocks through lock-free indices. Only +the consumer writes WAV bytes; finalization follows successful callback stop. +No editable project pointer, GUI object or FILE is accessed by the producer. +See decision 0005 and AUDIO_FILES.md for format, limits and recovery contracts. diff --git a/docs/AUDIO_ENGINE.md b/docs/AUDIO_ENGINE.md index 4620b36..096777a 100644 --- a/docs/AUDIO_ENGINE.md +++ b/docs/AUDIO_ENGINE.md @@ -203,3 +203,38 @@ 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. + + +## Phase 6 audio source and recording + +xtc_audio.h provides control-thread import and immutable preloaded clip plans. +The backend prepares a project only while stopped: build audio candidate, +compile/submit its graph, then replace the old audio plan without a later +fallible step. Preparation failures retain both old plans. Missing/invalid +assets produce explicit issue metadata and silence; OOM/capacity fails. +Graph-only edits still use the existing pending/retired mechanism while running. + +Each callback renders the plan into a preallocated mono timeline buffer using +the authoritative sample position. xtc_realtime_process_audio passes that +buffer into the compiled graph; XTC_NODE_AUDIO (output port only) reads it, +independently from external JACK input. The old process APIs remain wrappers +with a silent timeline. Import creates a source/output route if necessary. +No samples or GUI/project arrays are shared with editable state. Clip mixing, +rate conversion and graph processing are bounded; see AUDIO_FILES.md for limits. + +A borrowed recorder can be attached/detached only while stopped. Raw JACK input +is copied to its SPSC ring before graph processing. Producer and consumer index +release/acquire operations protect payload ownership; all atomics are checked +always lock-free. The control thread drains to a temporary float32 WAV, then +finalizes only after successful stop/close has synchronized the callback. +An overflowing ring or unavailable input creates a sticky error; later input is +dropped, reported and never silently joined into a continuous-looking take. +A partial overflow take is finalized for manual recovery. File errors retain +the previous destination and remove unfinished temporaries. + +GUI recording uses a two-second ring and 100 ms control-thread draining, with +no worker thread. A GUI stall can overflow it. No callback allocation/free, file +I/O, logging, locks or FLTK calls were added. Backend stop failure preserves the +borrowed recorder; the caller must not free it before successful quiescence. +Live software source/capture acceptance and concurrent ring tests pass; physical +recording, latency compensation and disk endurance remain unverified. diff --git a/docs/AUDIO_FILES.md b/docs/AUDIO_FILES.md new file mode 100644 index 0000000..6bb3e03 --- /dev/null +++ b/docs/AUDIO_FILES.md @@ -0,0 +1,109 @@ +# Audio clips and recording (Phase 6) + +## Import and playback + +File/Import WAV (Ctrl+I) loads a WAV into the selected track at the playhead. +With no selected track it creates an Audio track. Add region still creates a +silent placeholder. Imported clips use the existing move/trim controls. +Import stops playback, validates a candidate project, and commits only after +successful backend preparation (or locally when disconnected). + +Supported input: little-endian RIFF/WAVE, PCM16/24/32 and IEEE float32, one or +two channels, 1..768000 Hz. Stereo is averaged to mono. Compressed WAV, RF64, +WAVE_FORMAT_EXTENSIBLE, PCM8, other containers and multichannel files are +unsupported. Nonfinite float data and malformed/truncated files are rejected. +There is no new dependency: the bounded C decoder/writer is covered by known +sample, truncation and invalid-codec tests. See decision 0005. + +Audio is preloaded before playback: maximum 64 distinct paths per plan, +16777216 mono frames per asset (64 MiB), 67108864 total frames (256 MiB). +Import rejects files over 256 MiB or over 4096 chunks. No streaming decoder, +waveform cache, peak drawing or background load worker exists yet. Preloading +can briefly block the GUI. Candidate compilation retains the old plan until +success; temporary build memory can include both old and candidate buffers. + +The callback renders only overlapping clip ranges from memory. Clips on all +tracks are summed at unity gain into the graph's **Audio clips** source node. +Import creates that source and connects it to an existing/new output if no +audio source exists. Subsequent routing is explicit: disconnect it or route it +through gain/meter nodes in Modular Graph. Removing the output silences editor +graphs. This source does not monitor JACK input; unconnected ordinary graph +inputs retain their earlier external-input behavior. Track faders/pan/mute +and individual track routing belong to the mixer phase. + +Linear interpolation converts source sample rate to backend rate. There is no +antialiasing filter, time stretching, looping or fades. Musical durations round +up to a tick on import; playback boundaries and trims use floor tick-to-sample +conversion. A rounded tail pads with silence past the source, and a recording's +placement can differ by less than one tick from the capture start sample. +Changing tempo changes the musical span, not the source playback rate. + +Arrange edits stop playback. Play preloads and adopts the current arrangement; +Stop pauses the sample clock. Graph-only edits can still be submitted during +playback. Graph recompilation can reset oscillator phase as before. Playback +does not stop automatically at the arrangement end. + +## Assets and recovery + +Version 2 projects store external absolute canonical paths, not sample data. +Save the project AND keep its referenced WAV files. Save As changes only the +metadata path; it does not copy assets. Moving/deleting a WAV makes its regions +silent, preserves their IDs/positions and reports the path and unique issue +count on Open/Play. An already loaded plan keeps playing even if its file is +removed; the next preparation detects it. Unreadable, malformed and unsupported +assets are also reported, rather than substituted with unrelated files. +Capacity/OOM is a preparation failure and retains the old plans. + +Select the affected clip, then File/Relink selected audio to choose a replacement. +Relinking retains its original timeline/source bounds. The replacement is +validated on the next Play; a shorter replacement pads beyond its end with +silence. Saving retains unresolved references for later repair. Missing assets +are also checked on Open without a working backend, using a temporary plan. +Automatic asset search, content hashes and portable project bundles are deferred. + +## Recording + +Record (or Audio/Record WAV, Shift+F5) opens a destination chooser, stops playback, +prepares the project, opens a temporary mono float32 WAV and starts JACK. +Connect a source to xtc's JACK input using an external patchbay; no physical or +software route is created automatically. Without a route the take contains +silence. Recording captures raw mono JACK input, not the output/effect mix. + +A preallocated two-second SPSC ring transfers complete callback blocks to the +GUI/control thread, which drains it every 100 ms. The producer uses verified +lock-free atomics and bounded copies only. It never allocates, logs, takes a +mutex, opens/writes files or calls FLTK. A slow disk/blocked GUI can overflow +the ring; capture then stops accepting input with a sticky, visible capacity +error rather than hiding gaps or compressing time. + +Stop first synchronizes the callback, detaches the recorder, drains remaining +samples, writes the final WAV header, flushes/fsyncs/closes and renames the +same-directory temporary over the destination. A successful nonempty take is +imported at its capture position on the selected track (or a new Recording +track), then the document becomes unsaved. Save the project after Stop. Seeking +back to the take and pressing Play auditions it. Recording continues existing +arrangement playback, but there is no automatic input monitoring, punch, loop, +latency compensation, multitrack arming or overdub alignment compensation. + +Graph/arrangement edits, project file actions, reconnect and close are blocked +until Stop finishes the take. Deactivation failure retains the recorder for a +safe retry. On overflow a finalized partial WAV is retained for manual recovery +and is not automatically added. On import/preparation failure the completed +take remains at its chosen path with an explicit recovery message. I/O errors +are reported; an unfinished temporary is removed on destruction. Recordings +are RIFF-limited to 1073741814 frames, but the smaller playback/import asset +limit still applies: longer takes may need external splitting before import. +There is no crash-recovery journal. The file is mode 0600; parent-directory +fsync and preservation of previous permissions are not implemented. + +## Verification + +make test includes deterministic WAV/graph samples, resampling/overlap/trim, +malformed inputs, missing/relinked assets, SPSC concurrency/overflow/cleanup, +and record -> save/reopen -> exact playback through the backend callback. +make test-gui covers the workflow and rollback with a fake backend. +make test-gui-jack records/reopens a silent take using real JACK callbacks. +make test-jack-audio routes only two software clients: known source -> recorded +WAV -> saved/reopened project -> graph playback -> capture assertion. +Physical input/output, device latency, slow-disk endurance and server loss +during a real recording remain unverified. diff --git a/docs/BUILD.md b/docs/BUILD.md index aad33ad..f3bf392 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -128,3 +128,29 @@ 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. + + +## Phase 6 verification + +```sh +make all test gui debug test-gui test-gui-jack test-jack-audio +make BUILD_DIR=build/nojack PKG_CONFIG=false all test gui test-gui +make BUILD_DIR=build/tsan CC=cc CFLAGS='-O1 -g -std=c99 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Iinclude -fsanitize=thread' LDFLAGS='-fsanitize=thread' build/tsan/test_audio build/tsan/test_realtime +./build/tsan/test_audio +./build/tsan/test_realtime +``` + +No new dependency is required for WAV import, recording or playback. New audio +sources remain C99; only GUI controls use C++/FLTK. test_audio covers decoder +samples, truncations/nonfinite/unsupported input, resampling, trim/overlap, +missing assets and concurrent recording. test_backend records known callback +input, saves/reopens and checks exact graph output. Project tests cover v1 +migration and 1024 maximum-length asset paths in a valid v2 file. + +The live test-jack-audio target connects only its software clients and checks +record -> WAV -> saved/reopened project -> graph playback. GUI/JACK tests also +record, finalize, save and reopen a take without hardware routing. Clang +ASan/UBSan passes the core and GUI tests (GUI retains the existing fontconfig-only +LSan suppression). ThreadSanitizer passes the concurrent recorder and existing +realtime handoff tests. Hardware capture/playback, disk endurance and real server +loss during recording remain unverified. See AUDIO_FILES.md. diff --git a/docs/GUI.md b/docs/GUI.md index 95ae1c1..cee876d 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -26,7 +26,7 @@ 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 advance/pause the sample-counted playhead; regions are currently silent. + These advance/pause the sample-counted playhead; imported audio clips play through their graph source. - **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. @@ -123,10 +123,35 @@ 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 +New and Open reset position. Add region creates silent metadata; imported WAV clips play through the +audio source node (Phase 6 below). MIDI content is not implemented. 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. + + +## Phase 6 audio workflow + +Use File/Import WAV (Ctrl+I) to add an audio clip at the playhead on the selected +track, or on a new Audio track. Record (Shift+F5) chooses a WAV destination and +captures JACK input until Stop finalizes and adds the take. No input routes are +created automatically. Save after Stop; the project references the external WAV. + +Import creates an Audio clips graph source and output route if the source is +absent. Modular Graph offers Add audio source for explicit routing through gain +or meter nodes. All tracks currently share one mono timeline mix. Arrangement +edits stop playback; graph edits remain live outside recording. During recording, +edits, file actions, reconnect and close require Stop first. + +Open/Play reports missing, unreadable or unsupported assets, preserving their +regions as silence. The backend status shows the unique issue count. Select a +clip and use File/Relink selected audio to choose another path; Play validates +the replacement. Playback uses preloaded immutable samples; imports and loads +can block the GUI while reading, but never the audio callback. + +See AUDIO_FILES.md for supported WAV formats, memory limits, musical placement, +recording overflow/partial-take recovery, external asset portability and the +software-only acceptance tests. Waveform drawing, input monitoring, recording +latency compensation and per-track mixing are not implemented. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 4393e85..1915c3e 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -1,9 +1,9 @@ -# Project format v1 +# Project format v2 (reads v1) -Phase 5 uses a bounded text format implemented in C99 without a parser library. +Phases 5/6 use 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. +The project owns its graph, tracks and silent/audio arrangement regions. Audio samples remain in external WAV +files; MIDI content is a subsequent phase. ## Schema @@ -12,7 +12,7 @@ Fields use ASCII spaces. Integers are unsigned decimal, without signs. Counts determine the number of subsequent records: ```text -XTC_PROJECT 1 +XTC_PROJECT 2 APP 0 1 0 TEMPO 120000 IDS 1 1 @@ -24,6 +24,7 @@ TRACKS 1 TRACK 0 547261636b CLIPS 1 CLIP 0 0 3840 2880 960 3840 526567696f6e +ASSET - END ``` @@ -34,28 +35,34 @@ END - `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 +- `NODE`: ID, type (1 gain, 2 oscillator, 3 meter, 4 output, 5 audio source), 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 + length tick, hex-encoded name bytes. 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. + length. Overlaps are allowed and audio clips mix through the audio source node. +- `ASSET`: required after each CLIP in v2; "-" means silent, otherwise + hex-encoded absolute path bytes, <=511 bytes after decoding, no embedded NUL + or ASCII control characters. Each clip directly references a WAV; no sample + data, pointers or DSP state are embedded. Paths can contain spaces. - 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 +4096 edges, 2 MiB input, 1535 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. +FORMAT. The reader explicitly accepts v1 records without ASSET lines, with +node types 1..4, migrating their clips to empty asset paths. Saving always writes +v2. Unknown versions are rejected without replacing the destination; older +applications cannot read v2. Migration and maximum-path roundtrips are tested. ## Ownership and failures @@ -71,5 +78,8 @@ 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. +oscillator phase are GUI/session state and are not saved. Asset references are +external absolute paths, not copied on Save As. Metadata loading preserves +missing references; audio-plan preparation reports unreadable/invalid assets +and renders them silently. Relinking replaces a selected clip's path without +changing its IDs or arrangement. See AUDIO_FILES.md for recovery and limitations. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8fa1588..5c9de4c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -66,6 +66,16 @@ no undo, asset playback, MIDI or persisted GUI layout are explicit limits. Audio import/playback, recording through bounded buffers, missing-asset handling, no file I/O in callbacks. +Implemented: bounded PCM16/24/32 and float32 WAV import (mono/stereo downmix), +preloaded overlap-summed clip plans with linear rate conversion, and an audio +source node using ordinary graph routing. A lock-free SPSC ring transfers JACK +input to a control-thread WAV writer; Stop finalizes and adds successful takes. +Version 2 metadata stores external asset references and reads v1. Missing assets +remain silent with reporting and manual relinking. Core, callback, GUI and live +software record/save/reopen/play acceptance tests pass. See AUDIO_FILES.md for +memory/format limits, overflow recovery and deferred physical-device validation. +No new dependency or callback I/O was introduced. + ## Phase 7 — MIDI and instrument MIDI input/events/clips, simple internal instrument, note-off/panic handling, then a basic piano roll. diff --git a/docs/decisions/0005-preloaded-audio-and-recording.md b/docs/decisions/0005-preloaded-audio-and-recording.md new file mode 100644 index 0000000..5e71b14 --- /dev/null +++ b/docs/decisions/0005-preloaded-audio-and-recording.md @@ -0,0 +1,26 @@ +# 0005: Preloaded WAV clips and control-thread recording consumer + +Status: accepted for Phase 6. + +Use a small bounded C99 WAV decoder/writer for PCM16/24/32 and IEEE float32, +mono/stereo, instead of adding libsndfile in this milestone. This avoids a new +build/runtime/license dependency, keeps the supported format surface explicit, +and enables malformed-input tests. It also limits format coverage; a future +codec library needs its own dependency and licensing review. + +Compile musical clip ranges and decoded, shared-per-path mono buffers while +stopped. The callback renders the timeline into a preallocated buffer consumed +by an explicit graph source node, preserving graph routing rather than mixing +outside the graph. The old snapshot remains until candidate preparation succeeds. +All track clips currently share one source; per-track routing/mixing is deferred. + +Use a bounded SPSC ring for callback input capture, with release/acquire index +publication and a GUI/control-thread consumer. This adds no worker-thread +dependency; a two-second ring tolerates normal 100 ms polling but exposes +overflow if the GUI or disk stalls. Sticky errors prevent silent time compression. +Finalization requires callback quiescence. Future long-session/background I/O +work can reuse the ring contract with a dedicated consumer. + +Store external absolute paths in version 2 metadata and retain version 1 loads. +Missing/unreadable assets are reported silence, with explicit relinking; portable +bundles and content-addressed assets are deferred. See AUDIO_FILES.md for limits. diff --git a/include/xtc/xtc_audio.h b/include/xtc/xtc_audio.h new file mode 100644 index 0000000..55e0e2d --- /dev/null +++ b/include/xtc/xtc_audio.h @@ -0,0 +1,46 @@ +#ifndef XTC_AUDIO_H +#define XTC_AUDIO_H +#include "xtc_project.h" +#ifdef __cplusplus +extern "C" { +#endif +typedef struct xtc_audio_plan xtc_audio_plan; +typedef struct xtc_recorder xtc_recorder; +/* Control-thread WAV import: PCM16/24/32 or float32, mono/stereo downmixed to + * mono, 1..768000 Hz. Absolute canonical references, <=511 bytes. + * Adds a timeline source -> output route if no timeline source exists. + * Transactional; track must exist. Duration rounds up to a musical tick. */ +xtc_result xtc_project_import_audio(xtc_project *project, uint64_t track, + const char *path, uint64_t start_tick, uint64_t *out_clip); +/* Empty path makes a region silent; nonempty path must be absolute. Does not + * open the file. Missing references survive save/load for later relinking. */ +xtc_result xtc_project_set_clip_asset(xtc_project *project, uint64_t clip, const char *path); +/* Immutable/preloaded plan. At most 64 distinct assets, 16M mono frames/asset, + * 64M total frames, 256 MiB file and 4096 chunk input limits. Missing/invalid assets become silence with explicit issues. + * OOM/capacity fails creation. No file access during render. */ +xtc_result xtc_audio_plan_create(const xtc_project *project, unsigned int rate, + xtc_audio_plan **out_plan); +void xtc_audio_plan_destroy(xtc_audio_plan *plan); +size_t xtc_audio_plan_issues(const xtc_audio_plan *plan, const char **out_first_path); +/* Single consumer; output replaced with overlap-summed mono at unity gain. + * Linear rate conversion, no time stretching. Output must have frames floats. */ +xtc_result xtc_audio_plan_render(const xtc_audio_plan *plan, uint64_t position, + float *output, size_t frames); +/* Control-thread creation opens a same-directory temporary float32 WAV. + * Ring capacity 1..2097152 frames. One callback producer, one control consumer. + * push accepts whole blocks; overflow is sticky and all later input is dropped. + * drain performs file I/O; call regularly. finish requires producer quiescence, + * drains, finalizes and atomically renames even on overflow (partial recovery). + * No source/recorder is freed until destroy, which also removes unfinished temp. + * Counts and sticky error can be observed while producing. */ +xtc_result xtc_recorder_create(const char *path, unsigned int rate, size_t capacity, + xtc_recorder **out_recorder); +void xtc_recorder_push(xtc_recorder *recorder, const float *input, size_t frames); +xtc_result xtc_recorder_drain(xtc_recorder *recorder); +xtc_result xtc_recorder_finish(xtc_recorder *recorder); +xtc_result xtc_recorder_status(const xtc_recorder *recorder, uint64_t *out_frames); +void xtc_recorder_destroy(xtc_recorder *recorder); +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index 8fb6406..25799d1 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -1,6 +1,7 @@ #ifndef XTC_BACKEND_H #define XTC_BACKEND_H #include "xtc_engine.h" +#include "xtc_audio.h" #ifdef __cplusplus extern "C" { #endif @@ -18,6 +19,7 @@ typedef struct { unsigned int buffer_frames; unsigned int xruns; /* Wraps modulo UINT_MAX + 1. */ unsigned int processed_blocks; + unsigned int asset_issues; /* Unique missing/unreadable/unsupported assets. */ uint64_t sample_position; /* Successful callback frames; retained across stop/submit. */ unsigned int submitted_revision, active_revision; int running; @@ -44,6 +46,13 @@ xtc_result xtc_backend_seek(xtc_backend *backend, uint64_t sample); * While stopped, transactionally replaces all retained plans (counters/revisions * restart); failed compilation leaves the previous engine intact. */ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph); +/* Stopped only. Preloads immutable audio and submits graph before committing. + * Asset issues are silent and reported in status/asset_issue. Other failures + * retain the previous plans. Arrangement edits are adopted on the next prepare. */ +xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project); +const char *xtc_backend_asset_issue(const xtc_backend *backend); +/* Borrowed recorder, set/detach only while stopped. Stop before finish/destroy. */ +xtc_result xtc_backend_set_recorder(xtc_backend *backend, xtc_recorder *recorder); void xtc_backend_collect(xtc_backend *backend); /* Snapshot fields are individually coherent, not a transactional snapshot. */ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *out_status); diff --git a/include/xtc/xtc_engine.h b/include/xtc/xtc_engine.h index 6a372c4..0123c76 100644 --- a/include/xtc/xtc_engine.h +++ b/include/xtc/xtc_engine.h @@ -32,7 +32,8 @@ typedef enum { XTC_NODE_GAIN = 1, XTC_NODE_OSCILLATOR, XTC_NODE_METER, - XTC_NODE_OUTPUT + XTC_NODE_OUTPUT, + XTC_NODE_AUDIO /* Timeline mix source, output only. */ } xtc_node_type; typedef struct { @@ -80,7 +81,7 @@ void xtc_graph_destroy(xtc_graph *graph); xtc_result xtc_graph_add_gain(xtc_graph *graph, float gain, size_t *out_node_id); xtc_result xtc_graph_connect(xtc_graph *graph, size_t source_node, size_t destination_node); -/* Port 0 is input; port 1 is output. Oscillators have only output 1; +/* Port 0 is input; port 1 is output. Oscillators and audio sources have only output 1; * output nodes have only input 0. All ports carry mono audio. */ xtc_result xtc_graph_get_port(const xtc_graph *graph, size_t node_id, size_t port_id, xtc_port_info *out_port); @@ -90,6 +91,7 @@ xtc_result xtc_graph_connect_ports(xtc_graph *graph, size_t source_node, xtc_result xtc_graph_add_oscillator(xtc_graph *graph, float frequency, size_t *out_node_id); xtc_result xtc_graph_add_meter(xtc_graph *graph, size_t *out_node_id); +xtc_result xtc_graph_add_audio(xtc_graph *graph, size_t *out_node_id); xtc_result xtc_graph_add_output(xtc_graph *graph, size_t *out_node_id); /* Compile copies graph state; caller owns the plan. Sample rate must be * finite and positive, oscillator frequencies <= Nyquist. max_frames: 1..65536. @@ -104,6 +106,9 @@ void xtc_plan_destroy(xtc_plan *plan); * Sums explicit output nodes; if absent, sums terminal nodes for compatibility * unless require_output_nodes is set. Empty graphs produce silence. * Unconnected gain/meter/output inputs receive external input. */ +/* timeline is a separate mono input for XTC_NODE_AUDIO, NULL means silence. */ +xtc_result xtc_plan_process_audio(xtc_plan *plan, const float *input, const float *timeline, + float *output, size_t frames); xtc_result xtc_plan_process(xtc_plan *plan, const float *input, float *output, size_t frame_count); /* Resets oscillator phases and meters, without allocation. */ diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index e823e43..88d4f48 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -7,15 +7,17 @@ extern "C" { #define XTC_PROJECT_MAX_TRACKS 64U #define XTC_PROJECT_MAX_CLIPS 1024U #define XTC_PROJECT_NAME_BYTES 64U +#define XTC_ASSET_PATH_BYTES 512U #define XTC_TICKS_PER_QUARTER 960U #define XTC_PROJECT_MAX_TICK UINT64_C(38400000) typedef struct xtc_project xtc_project; 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. +/* Arrangement region: empty asset_path means silent metadata. * 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]; + char asset_path[XTC_ASSET_PATH_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. */ diff --git a/include/xtc/xtc_realtime.h b/include/xtc/xtc_realtime.h index 97bb838..55037f6 100644 --- a/include/xtc/xtc_realtime.h +++ b/include/xtc/xtc_realtime.h @@ -31,6 +31,8 @@ xtc_result xtc_realtime_get_status(const xtc_realtime *engine, * frame_count must be <= configured max_frames and output must hold it. * Oversized buffers are silenced too; callers must bound their own frame count. * NULL input means silence. Zero frames is a no-op. */ +xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, const float *timeline, + float *output, size_t frames); xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, float *output, size_t frame_count); #ifdef __cplusplus diff --git a/src/audio/README.md b/src/audio/README.md new file mode 100644 index 0000000..c6a553b --- /dev/null +++ b/src/audio/README.md @@ -0,0 +1,10 @@ +# Audio files and capture + +C99/POSIX control code loads bounded WAV files and prepares immutable clip +plans. Callback rendering reads only preloaded samples. The audio source node +passes the mono timeline mix through the ordinary modular graph. + +The recorder uses a lock-free SPSC ring: callback push, control-thread drain, +and finish/destroy only after callback quiescence. WAV headers, file writing +and final rename occur on the control thread. See docs/AUDIO_FILES.md and +include/xtc/xtc_audio.h for ownership, limits and recovery behavior. diff --git a/src/audio/arrangement.c b/src/audio/arrangement.c new file mode 100644 index 0000000..0d05128 --- /dev/null +++ b/src/audio/arrangement.c @@ -0,0 +1,132 @@ +#define _XOPEN_SOURCE 700 +#include "wave_internal.h" +#include "../project/project_internal.h" +#include "../graph/graph_internal.h" +#include +#include +#include +typedef struct { uint64_t start, end, offset; size_t asset; } region; +struct xtc_audio_plan { + xtc_wave waves[64]; + char paths[64][XTC_ASSET_PATH_BYTES]; + region regions[XTC_PROJECT_MAX_CLIPS]; + size_t assets, count, issues; + char first_issue[XTC_ASSET_PATH_BYTES]; + unsigned int rate; +}; +xtc_result xtc_project_import_audio(xtc_project *project, uint64_t track, + const char *path, uint64_t start, uint64_t *out_clip) +{ + xtc_wave wave = {0}; + xtc_project *candidate = NULL; + char *absolute; + uint64_t length, id = 0; + size_t i, source = SIZE_MAX, output = SIZE_MAX; + xtc_result result; + if (!project || !path || !out_clip) return XTC_ERR_INVALID_ARGUMENT; + absolute = realpath(path, NULL); + if (!absolute) return XTC_ERR_IO; + if (strlen(absolute) >= XTC_ASSET_PATH_BYTES) { free(absolute); return XTC_ERR_CAPACITY; } + result = xtc_wave_load(absolute, &wave); + if (result != XTC_OK) { free(absolute); return result; } + length = ((uint64_t)wave.frames * project->tempo * XTC_TICKS_PER_QUARTER + + (uint64_t)wave.rate * 60000U - 1U) / ((uint64_t)wave.rate * 60000U); + free(wave.samples); + result = xtc_project_clone(project, &candidate); + if (result == XTC_OK) result = xtc_project_add_clip(candidate, track, "Audio", start, length, &id); + if (result == XTC_OK) result = xtc_project_set_clip_asset(candidate, id, absolute); + free(absolute); + if (result == XTC_OK) { + for (i = 0; i < candidate->graph->node_count; ++i) { + xtc_node *n = candidate->graph->nodes + i; + if (n->type == XTC_NODE_AUDIO) source = n->id; + if (n->type == XTC_NODE_OUTPUT) output = n->id; + } + if (source == SIZE_MAX) { + result = xtc_graph_add_audio(candidate->graph, &source); + if (result == XTC_OK && output == SIZE_MAX) result = xtc_graph_add_output(candidate->graph, &output); + if (result == XTC_OK) result = xtc_graph_connect(candidate->graph, source, output); + } + } + if (result == XTC_OK) { + xtc_project old = *project; *project = *candidate; *candidate = old; + *out_clip = id; + } + xtc_project_destroy(candidate); + return result; +} +void xtc_audio_plan_destroy(xtc_audio_plan *plan) +{ + size_t i; + if (!plan) return; + for (i = 0; i < plan->assets; ++i) free(plan->waves[i].samples); + free(plan); +} +xtc_result xtc_audio_plan_create(const xtc_project *project, unsigned int rate, xtc_audio_plan **out) +{ + xtc_audio_plan *plan; + size_t i, total = 0; + xtc_result result = XTC_OK; + if (!out) return XTC_ERR_INVALID_ARGUMENT; + *out = NULL; + if (!project || !rate || rate > 768000) return XTC_ERR_INVALID_ARGUMENT; + plan = calloc(1, sizeof(*plan)); + if (!plan) return XTC_ERR_OUT_OF_MEMORY; + plan->rate = rate; + for (i = 0; i < project->clip_count; ++i) { + const xtc_clip_info *clip = project->clips + i; + size_t a; region *r; + if (!clip->asset_path[0]) continue; + for (a = 0; a < plan->assets; ++a) if (!strcmp(plan->paths[a], clip->asset_path)) break; + if (a == plan->assets) { + if (a == 64) { result = XTC_ERR_CAPACITY; break; } + strcpy(plan->paths[a], clip->asset_path); ++plan->assets; + result = xtc_wave_load(clip->asset_path, plan->waves + a); + if (result == XTC_ERR_CAPACITY || result == XTC_ERR_OUT_OF_MEMORY) break; + if (result != XTC_OK) { + ++plan->issues; + if (!plan->first_issue[0]) strcpy(plan->first_issue, clip->asset_path); + } + result = XTC_OK; + total += plan->waves[a].frames; + if (total > 67108864U) { result = XTC_ERR_CAPACITY; break; } + } + r = plan->regions + plan->count++; + r->asset = a; + result = xtc_project_tick_to_sample(project, clip->start_tick, rate, &r->start); + if (result == XTC_OK) result = xtc_project_tick_to_sample(project, clip->start_tick + clip->length_tick, rate, &r->end); + if (result == XTC_OK) result = xtc_project_tick_to_sample(project, clip->offset_tick, rate, &r->offset); + if (result != XTC_OK) break; + } + if (result != XTC_OK) { xtc_audio_plan_destroy(plan); return result; } + *out = plan; return XTC_OK; +} +size_t xtc_audio_plan_issues(const xtc_audio_plan *plan, const char **path) +{ + if (path) *path = plan ? plan->first_issue : ""; + return plan ? plan->issues : 0; +} +xtc_result xtc_audio_plan_render(const xtc_audio_plan *plan, uint64_t position, float *output, size_t frames) +{ + size_t i, j; + if (!plan || !output || frames > 65536U || position > UINT64_MAX - frames) return XTC_ERR_INVALID_ARGUMENT; + memset(output, 0, frames * sizeof(*output)); + for (i = 0; i < plan->count; ++i) { + const region *r = plan->regions + i; + const xtc_wave *wave = plan->waves + r->asset; + uint64_t begin = position > r->start ? position : r->start; + uint64_t end = position + frames < r->end ? position + frames : r->end; + if (!wave->samples || end <= begin) continue; + for (j = (size_t)(begin - position); j < (size_t)(end - position); ++j) { + double source = (double)(position + j - r->start + r->offset) * wave->rate / plan->rate; + size_t index; + double fraction; + if (source >= (double)wave->frames) continue; + index = (size_t)source; fraction = source - (double)index; + output[j] += (float)(wave->samples[index] * (1.0 - fraction) + + (index + 1 < wave->frames ? wave->samples[index + 1] : wave->samples[index]) * fraction); + } + } + for (j = 0; j < frames; ++j) if (!isfinite(output[j])) output[j] = 0; + return XTC_OK; +} diff --git a/src/audio/recorder.c b/src/audio/recorder.c new file mode 100644 index 0000000..7a5d64b --- /dev/null +++ b/src/audio/recorder.c @@ -0,0 +1,105 @@ +#define _POSIX_C_SOURCE 200809L +#include "wave_internal.h" +#include +#include +#include +#include +struct xtc_recorder { + float *ring; + size_t capacity; + uint64_t write_position, read_position, written; + unsigned int error, rate; + FILE *file; + char *path, *temporary; + int finished; +}; +xtc_result xtc_recorder_create(const char *path, unsigned int rate, size_t capacity, xtc_recorder **out) +{ + xtc_recorder *r; + size_t length = 0; + int fd; + if (!out) return XTC_ERR_INVALID_ARGUMENT; + *out = NULL; + if (!path || !rate || rate > 768000 || !capacity || capacity > 2097152U) return XTC_ERR_INVALID_ARGUMENT; + while (length < 4096 && path[length]) ++length; + if (!length || length == 4096) return XTC_ERR_INVALID_ARGUMENT; + if (sizeof(float) != 4 || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128) return XTC_ERR_UNSUPPORTED; + if (!__atomic_always_lock_free(sizeof(uint64_t), 0) || + !__atomic_always_lock_free(sizeof(unsigned int), 0)) return XTC_ERR_UNSUPPORTED; + r = calloc(1, sizeof(*r)); + if (!r) return XTC_ERR_OUT_OF_MEMORY; + r->capacity = capacity; r->rate = rate; + r->ring = malloc(capacity * sizeof(float)); + r->path = malloc(length + 1); r->temporary = calloc(length + 12, 1); + if (!r->ring || !r->path || !r->temporary) { xtc_recorder_destroy(r); return XTC_ERR_OUT_OF_MEMORY; } + memcpy(r->path, path, length + 1); + memcpy(r->temporary, path, length); memcpy(r->temporary + length, ".tmp.XXXXXX", 12); + fd = mkstemp(r->temporary); + if (fd < 0) { xtc_recorder_destroy(r); return XTC_ERR_IO; } + r->file = fdopen(fd, "w+b"); + if (!r->file) { close(fd); xtc_recorder_destroy(r); return XTC_ERR_IO; } + if (!xtc_wave_header(r->file, rate, 0)) { xtc_recorder_destroy(r); return XTC_ERR_IO; } + *out = r; return XTC_OK; +} +void xtc_recorder_push(xtc_recorder *r, const float *input, size_t frames) +{ + uint64_t write, read; + size_t i; + if (!r || !frames || __atomic_load_n(&r->error, __ATOMIC_ACQUIRE)) return; + write = __atomic_load_n(&r->write_position, __ATOMIC_RELAXED); + read = __atomic_load_n(&r->read_position, __ATOMIC_ACQUIRE); + if (!input || frames > r->capacity || frames > r->capacity - (size_t)(write - read) || + write > UINT64_C(1073741814) - frames) { + __atomic_store_n(&r->error, (unsigned int)(input ? XTC_ERR_CAPACITY : XTC_ERR_IO), __ATOMIC_RELEASE); + return; + } + for (i = 0; i < frames; ++i) r->ring[(size_t)((write + i) % r->capacity)] = input[i]; + __atomic_store_n(&r->write_position, write + frames, __ATOMIC_RELEASE); +} +xtc_result xtc_recorder_drain(xtc_recorder *r) +{ + uint64_t read, write; + if (!r || !r->file || r->finished) return XTC_ERR_INVALID_STATE; + read = __atomic_load_n(&r->read_position, __ATOMIC_RELAXED); + write = __atomic_load_n(&r->write_position, __ATOMIC_ACQUIRE); + while (read < write) { + size_t offset = (size_t)(read % r->capacity); + size_t count = r->capacity - offset; + if (count > write - read) count = (size_t)(write - read); + if (!xtc_wave_write(r->file, r->ring + offset, count)) { + __atomic_store_n(&r->error, (unsigned int)XTC_ERR_IO, __ATOMIC_RELEASE); + return XTC_ERR_IO; + } + read += count; r->written += count; + __atomic_store_n(&r->read_position, read, __ATOMIC_RELEASE); + } + return (xtc_result)__atomic_load_n(&r->error, __ATOMIC_ACQUIRE); +} +xtc_result xtc_recorder_status(const xtc_recorder *r, uint64_t *frames) +{ + if (!r || !frames) return XTC_ERR_INVALID_ARGUMENT; + *frames = __atomic_load_n(&r->write_position, __ATOMIC_ACQUIRE); + return (xtc_result)__atomic_load_n(&r->error, __ATOMIC_ACQUIRE); +} +xtc_result xtc_recorder_finish(xtc_recorder *r) +{ + xtc_result result; + int failed = 0; + if (!r || !r->file || r->finished) return XTC_ERR_INVALID_STATE; + result = xtc_recorder_drain(r); + if (result == XTC_ERR_IO) failed = 1; + if (fseek(r->file, 0, SEEK_SET) || !xtc_wave_header(r->file, r->rate, (uint32_t)r->written) || + fflush(r->file) || fsync(fileno(r->file))) failed = 1; + if (fclose(r->file)) failed = 1; + r->file = NULL; + if (!failed && rename(r->temporary, r->path)) failed = 1; + r->finished = 1; + return failed ? XTC_ERR_IO : result; +} +void xtc_recorder_destroy(xtc_recorder *r) +{ + if (!r) return; + if (r->file) fclose(r->file); + if (r->temporary) unlink(r->temporary); + free(r->temporary); free(r->path); free(r->ring); free(r); +} diff --git a/src/audio/wave.c b/src/audio/wave.c new file mode 100644 index 0000000..d8a4a97 --- /dev/null +++ b/src/audio/wave.c @@ -0,0 +1,115 @@ +#include "wave_internal.h" +#include +#include +#include +#include +static uint32_t u32(const unsigned char *p) +{ return (uint32_t)p[0] | (uint32_t)p[1] << 8U | (uint32_t)p[2] << 16U | (uint32_t)p[3] << 24U; } +static unsigned int u16(const unsigned char *p) +{ return (unsigned int)p[0] | (unsigned int)p[1] << 8U; } +static void put32(unsigned char *p, uint32_t v) +{ size_t i; for (i = 0; i < 4; ++i) { p[i] = (unsigned char)(v & 255U); v >>= 8U; } } +int xtc_wave_header(FILE *file, unsigned int rate, uint32_t frames) +{ + unsigned char h[44] = {0}; + memcpy(h, "RIFF", 4); put32(h + 4, 36U + frames * 4U); + memcpy(h + 8, "WAVEfmt ", 8); put32(h + 16, 16); + h[20] = 3; h[22] = 1; put32(h + 24, rate); put32(h + 28, rate * 4U); + h[32] = 4; h[34] = 32; memcpy(h + 36, "data", 4); put32(h + 40, frames * 4U); + return fwrite(h, 1, sizeof(h), file) == sizeof(h); +} +int xtc_wave_write(FILE *file, const float *input, size_t frames) +{ + unsigned char bytes[4096]; + while (frames) { + size_t i, count = frames > 1024 ? 1024 : frames; + for (i = 0; i < count; ++i) { + uint32_t bits; float value = isfinite(input[i]) ? input[i] : 0.0f; + memcpy(&bits, &value, 4); put32(bytes + i * 4, bits); + } + if (fwrite(bytes, 4, count, file) != count) return 0; + input += count; frames -= count; + } + return 1; +} +xtc_result xtc_wave_load(const char *path, xtc_wave *out) +{ + FILE *f; + unsigned char h[40], bytes[8]; + uint64_t remaining; + uint32_t data_size = 0, rate = 0; + unsigned int format = 0, channels = 0, bits = 0, align = 0; + long data_offset = 0, file_size; + size_t i, frames, chunks = 0; + float *samples = NULL; + xtc_result result = XTC_ERR_FORMAT; + if (!path || !out) return XTC_ERR_INVALID_ARGUMENT; + memset(out, 0, sizeof(*out)); + if (sizeof(float) != 4 || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128) + return XTC_ERR_UNSUPPORTED; + f = fopen(path, "rb"); + if (!f) return XTC_ERR_IO; + if (fseek(f, 0, SEEK_END) || (file_size = ftell(f)) < 12 || fseek(f, 0, SEEK_SET)) goto done; + if (file_size > 268435456L) { result = XTC_ERR_CAPACITY; goto done; } + if (fread(h, 1, 12, f) != 12 || memcmp(h, "RIFF", 4) || memcmp(h + 8, "WAVE", 4)) goto done; + remaining = u32(h + 4); + if (remaining < 4 || remaining + 8U > (uint64_t)file_size) goto done; + remaining -= 4; + while (remaining) { + uint32_t size; uint64_t padded; + if (++chunks > 4096 || remaining < 8 || fread(h, 1, 8, f) != 8) goto done; + size = u32(h + 4); padded = (uint64_t)size + (size & 1U); + remaining -= 8; + if (padded > 0x7fffffffU || padded > remaining) goto done; + if (!memcmp(h, "fmt ", 4)) { + if (format || size < 16 || fread(h + 8, 1, 16, f) != 16) goto done; + format = u16(h + 8); channels = u16(h + 10); rate = u32(h + 12); + align = u16(h + 20); bits = u16(h + 22); + if (fseek(f, (long)(padded - 16), SEEK_CUR)) goto done; + } else { + if (!memcmp(h, "data", 4)) { + if (data_offset) goto done; + data_offset = ftell(f); data_size = size; + } + if (padded > 0x7fffffffU || fseek(f, (long)padded, SEEK_CUR)) goto done; + } + remaining -= padded; + } + if ((format != 1 && format != 3) || (channels != 1 && channels != 2) || + (bits != 16 && bits != 24 && bits != 32) || (format == 3 && bits != 32)) { + result = XTC_ERR_UNSUPPORTED; goto done; + } + if (!rate || rate > 768000 || !data_offset || !data_size || + align != channels * (bits / 8U) || data_size % align) goto done; + frames = data_size / align; + if (frames > 16777216U) { result = XTC_ERR_CAPACITY; goto done; } + samples = malloc(frames * sizeof(*samples)); + if (!samples) { result = XTC_ERR_OUT_OF_MEMORY; goto done; } + if (fseek(f, data_offset, SEEK_SET)) goto done; + for (i = 0; i < frames; ++i) { + unsigned int c; double sum = 0; + if (fread(bytes, 1, align, f) != align) goto done; + for (c = 0; c < channels; ++c) { + const unsigned char *p = bytes + c * (bits / 8U); + if (format == 3) { + uint32_t value = u32(p); float sample; memcpy(&sample, &value, 4); + if (!isfinite(sample)) goto done; + sum += sample; + } else { + uint32_t value = bits == 16 ? u16(p) : bits == 24 ? + (uint32_t)p[0] | (uint32_t)p[1] << 8U | (uint32_t)p[2] << 16U : u32(p); + int64_t signed_value = value; + if (value & (UINT32_C(1) << (bits - 1U))) signed_value -= INT64_C(1) << bits; + sum += (double)signed_value / (double)(INT64_C(1) << (bits - 1U)); + } + } + samples[i] = (float)(sum / channels); + } + if (ferror(f)) goto done; + result = XTC_OK; +done: + if (fclose(f) && result == XTC_OK) result = XTC_ERR_IO; + if (result == XTC_OK) { out->samples = samples; out->frames = frames; out->rate = rate; } + else free(samples); + return result; +} diff --git a/src/audio/wave_internal.h b/src/audio/wave_internal.h new file mode 100644 index 0000000..bcccfc8 --- /dev/null +++ b/src/audio/wave_internal.h @@ -0,0 +1,9 @@ +#ifndef XTC_WAVE_INTERNAL_H +#define XTC_WAVE_INTERNAL_H +#include "xtc/xtc_audio.h" +#include +typedef struct { float *samples; size_t frames; unsigned int rate; } xtc_wave; +xtc_result xtc_wave_load(const char *path, xtc_wave *out_wave); +int xtc_wave_header(FILE *file, unsigned int rate, uint32_t frames); +int xtc_wave_write(FILE *file, const float *input, size_t frames); +#endif diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 9411268..fffad99 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -11,6 +11,9 @@ struct xtc_backend { jack_client_t *client; jack_port_t *input, *output; xtc_realtime *engine; + xtc_audio_plan *audio; + xtc_recorder *recorder; /* Borrowed, installed while callbacks quiescent. */ + float *timeline; size_t max_frames; uint64_t sample_position; unsigned int expected_rate; @@ -25,6 +28,7 @@ static int process(jack_nframes_t frames, void *context) float *output = jack_port_get_buffer(backend->output, frames); xtc_result result = XTC_OK; if (output == NULL) { + if (backend->recorder) xtc_recorder_push(backend->recorder, NULL, 1U); __atomic_store_n(&backend->process_result, (unsigned int)XTC_ERR_IO, __ATOMIC_RELAXED); return 0; } @@ -35,14 +39,23 @@ static int process(jack_nframes_t frames, void *context) result = XTC_ERR_CAPACITY; } else { const float *input = jack_port_get_buffer(backend->input, frames); - result = xtc_realtime_process(backend->engine, input, output, (size_t)frames); + uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED); + if (backend->recorder) xtc_recorder_push(backend->recorder, input, (size_t)frames); + if (backend->audio) + result = xtc_audio_plan_render(backend->audio, position, backend->timeline, (size_t)frames); + if (result == XTC_OK) + result = xtc_realtime_process_audio(backend->engine, input, + backend->audio ? backend->timeline : NULL, 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)); + if (result != XTC_OK) { + if (backend->recorder) xtc_recorder_push(backend->recorder, NULL, 1U); + 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. */ } @@ -87,9 +100,11 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_ if (strlen(config->client_name) >= (size_t)jack_client_name_size()) return XTC_ERR_INVALID_ARGUMENT; backend = calloc(1U, sizeof(*backend)); if (backend == NULL) return XTC_ERR_OUT_OF_MEMORY; + backend->timeline = calloc(config->max_frames, sizeof(float)); + if (!backend->timeline) { free(backend); return XTC_ERR_OUT_OF_MEMORY; } backend->max_frames = config->max_frames; backend->client = jack_client_open(config->client_name, JackNoStartServer, &status); - if (backend->client == NULL) { free(backend); return XTC_ERR_IO; } + if (backend->client == NULL) { free(backend->timeline); free(backend); return XTC_ERR_IO; } backend->expected_rate = (unsigned int)jack_get_sample_rate(backend->client); backend->rate = backend->expected_rate; backend->frames = (unsigned int)jack_get_buffer_size(backend->client); @@ -118,6 +133,8 @@ fail: return XTC_ERR_IO; } xtc_realtime_destroy(backend->engine); + xtc_audio_plan_destroy(backend->audio); + free(backend->timeline); free(backend); return result; } @@ -128,6 +145,8 @@ xtc_result xtc_backend_close(xtc_backend *backend) /* JACK removes this client and joins callbacks before returning success. */ if (jack_client_close(backend->client) != 0) return XTC_ERR_IO; xtc_realtime_destroy(backend->engine); + xtc_audio_plan_destroy(backend->audio); + free(backend->timeline); free(backend); return XTC_OK; } @@ -182,6 +201,33 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) } return xtc_realtime_submit(backend->engine, graph); } +xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project) +{ + xtc_audio_plan *candidate = NULL; + xtc_result result; + if (!backend || !project) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready || backend->running) return XTC_ERR_INVALID_STATE; + result = xtc_audio_plan_create(project, backend->expected_rate, &candidate); + if (result == XTC_OK) result = xtc_backend_submit(backend, xtc_project_graph(project)); + if (result != XTC_OK) { xtc_audio_plan_destroy(candidate); return result; } + xtc_audio_plan_destroy(backend->audio); backend->audio = candidate; + return XTC_OK; +} +const char *xtc_backend_asset_issue(const xtc_backend *backend) +{ + const char *path = ""; + if (backend) xtc_audio_plan_issues(backend->audio, &path); + return path; +} +xtc_result xtc_backend_set_recorder(xtc_backend *backend, xtc_recorder *recorder) +{ + if (!backend) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready || backend->running) return XTC_ERR_INVALID_STATE; + if (recorder && (__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) || + __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE))) return XTC_ERR_INVALID_STATE; + backend->recorder = recorder; + return XTC_OK; +} void xtc_backend_collect(xtc_backend *backend) { if (backend != NULL) xtc_realtime_collect(backend->engine); } xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *out_status) @@ -190,6 +236,7 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status if (backend == NULL || out_status == NULL) return XTC_ERR_INVALID_ARGUMENT; if (!backend->ready) return XTC_ERR_INVALID_STATE; (void)xtc_realtime_get_status(backend->engine, &engine_status); + out_status->asset_issues = (unsigned int)xtc_audio_plan_issues(backend->audio, NULL); out_status->sample_rate = __atomic_load_n(&backend->rate, __ATOMIC_RELAXED); out_status->buffer_frames = __atomic_load_n(&backend->frames, __ATOMIC_RELAXED); out_status->xruns = __atomic_load_n(&backend->xruns, __ATOMIC_RELAXED); @@ -250,6 +297,12 @@ xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direc { return route(backend, direction, remote, 1); } #else +xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project) +{ (void)backend; (void)project; return XTC_ERR_UNSUPPORTED; } +const char *xtc_backend_asset_issue(const xtc_backend *backend) +{ (void)backend; return ""; } +xtc_result xtc_backend_set_recorder(xtc_backend *backend, xtc_recorder *recorder) +{ (void)backend; (void)recorder; return XTC_ERR_UNSUPPORTED; } 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. */ diff --git a/src/engine/offline_engine.c b/src/engine/offline_engine.c index 819cc2c..1b77384 100644 --- a/src/engine/offline_engine.c +++ b/src/engine/offline_engine.c @@ -109,7 +109,7 @@ xtc_result xtc_plan_meter_peak(const xtc_plan *plan, size_t node_id, float *out_ return XTC_OK; } -xtc_result xtc_plan_process(xtc_plan *plan, const float *input, float *output, +xtc_result xtc_plan_process_audio(xtc_plan *plan, const float *input, const float *timeline, float *output, size_t frame_count) { size_t index, frame, edge; @@ -126,7 +126,9 @@ xtc_result xtc_plan_process(xtc_plan *plan, const float *input, float *output, double phase = plan->phases[node]; for (frame = 0U; frame < frame_count; ++frame) { float sample = 0.0f; - if (spec.type == XTC_NODE_OSCILLATOR) { + if (spec.type == XTC_NODE_AUDIO) { + sample = timeline ? timeline[frame] : 0.0f; + } else if (spec.type == XTC_NODE_OSCILLATOR) { sample = (float)sin(XTC_TAU * phase); phase += (double)spec.value / plan->sample_rate; if (phase >= 1.0) phase -= 1.0; @@ -167,3 +169,6 @@ xtc_result xtc_graph_process_offline(xtc_graph *graph, const float *input, xtc_plan_destroy(plan); return result; } + +xtc_result xtc_plan_process(xtc_plan *plan, const float *input, float *output, size_t frames) +{ return xtc_plan_process_audio(plan, input, NULL, output, frames); } diff --git a/src/engine/realtime.c b/src/engine/realtime.c index 0ca4a20..c7f7433 100644 --- a/src/engine/realtime.c +++ b/src/engine/realtime.c @@ -80,7 +80,7 @@ xtc_result xtc_realtime_get_status(const xtc_realtime *engine, return XTC_OK; } -xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, +xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, const float *timeline, float *output, size_t frame_count) { xtc_result result = XTC_OK; @@ -107,7 +107,7 @@ xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, } } if (engine->active != NULL) - result = xtc_plan_process(engine->active, input, output, frame_count); + result = xtc_plan_process_audio(engine->active, input, timeline, output, frame_count); else memset(output, 0, frame_count * sizeof(*output)); } @@ -117,3 +117,6 @@ xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, __atomic_load_n(&engine->blocks, __ATOMIC_RELAXED) + 1U, __ATOMIC_RELAXED); return result; } + +xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, float *output, size_t frames) +{ return xtc_realtime_process_audio(engine, input, NULL, output, frames); } diff --git a/src/graph/graph.c b/src/graph/graph.c index 9262095..6ba5a32 100644 --- a/src/graph/graph.c +++ b/src/graph/graph.c @@ -59,6 +59,8 @@ xtc_result xtc_graph_add_oscillator(xtc_graph *graph, float frequency, size_t *o } xtc_result xtc_graph_add_meter(xtc_graph *graph, size_t *out_node_id) { return add_node(graph, XTC_NODE_METER, 0.0f, out_node_id); } +xtc_result xtc_graph_add_audio(xtc_graph *graph, size_t *out_node_id) +{ return add_node(graph, XTC_NODE_AUDIO, 0.0f, out_node_id); } xtc_result xtc_graph_add_output(xtc_graph *graph, size_t *out_node_id) { return add_node(graph, XTC_NODE_OUTPUT, 0.0f, out_node_id); } @@ -70,7 +72,7 @@ xtc_result xtc_graph_get_port(const xtc_graph *graph, size_t node_id, node_id = node_index(graph, node_id); if (node_id == SIZE_MAX) return XTC_ERR_NOT_FOUND; type = graph->nodes[node_id].type; - if (port_id > 1U || (port_id == 0U && type == XTC_NODE_OSCILLATOR) || + if (port_id > 1U || (port_id == 0U && (type == XTC_NODE_OSCILLATOR || type == XTC_NODE_AUDIO)) || (port_id == 1U && type == XTC_NODE_OUTPUT)) return XTC_ERR_NOT_FOUND; out_port->direction = port_id == 0U ? XTC_PORT_INPUT : XTC_PORT_OUTPUT; out_port->signal_type = XTC_SIGNAL_AUDIO; @@ -226,6 +228,7 @@ xtc_result xtc_graph_apply_edit(const xtc_graph *source, const xtc_graph_edit *e case XTC_NODE_GAIN: result = xtc_graph_add_gain(graph, edit->data.add.value, &added_id); break; case XTC_NODE_OSCILLATOR: result = xtc_graph_add_oscillator(graph, edit->data.add.value, &added_id); break; case XTC_NODE_METER: result = xtc_graph_add_meter(graph, &added_id); break; + case XTC_NODE_AUDIO: result = xtc_graph_add_audio(graph, &added_id); break; case XTC_NODE_OUTPUT: result = xtc_graph_add_output(graph, &added_id); break; default: result = XTC_ERR_INVALID_ARGUMENT; break; } diff --git a/src/gui/graph_widget.cpp b/src/gui/graph_widget.cpp index b5eb607..d6d63ed 100644 --- a/src/gui/graph_widget.cpp +++ b/src/gui/graph_widget.cpp @@ -14,6 +14,7 @@ const char *node_name(xtc_node_type type) case XTC_NODE_OSCILLATOR: return "Oscillator"; case XTC_NODE_GAIN: return "Gain"; case XTC_NODE_METER: return "Meter"; + case XTC_NODE_AUDIO: return "Audio clips"; case XTC_NODE_OUTPUT: return "Output"; } return "Unknown"; @@ -186,7 +187,7 @@ void GraphWidget::draw() fl_font(FL_HELVETICA, 12); if (node.type == XTC_NODE_OSCILLATOR) std::snprintf(text, sizeof(text), "%.0f Hz", static_cast(node.value)); else if (node.type == XTC_NODE_GAIN) std::snprintf(text, sizeof(text), "x %.2f", static_cast(node.value)); - else std::snprintf(text, sizeof(text), "%s", node.type == XTC_NODE_METER ? "Peak pass-through" : "Mono audio output"); + else std::snprintf(text, sizeof(text), "%s", node.type == XTC_NODE_METER ? "Peak pass-through" : node.type == XTC_NODE_AUDIO ? "Timeline mix source" : "Mono audio output"); fl_draw(text, left + 10, top + 37); for (size_t port = 0U; port < 2U; ++port) { int px, py; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 3a73105..28da98d 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -38,6 +38,9 @@ MainWindow::MainWindow(bool prompt_unsaved) 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/Import WAV", FL_CTRL + 'i', audio_file_callback, this); + menu->add("File/Relink selected audio", 0, audio_file_callback, this); + menu->add("Audio/Record WAV", FL_SHIFT + FL_F + 5, audio_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); @@ -45,18 +48,21 @@ MainWindow::MainWindow(bool prompt_unsaved) play_ = new Fl_Button(12, 40, 80, 30, "Play"); play_->callback(play_callback, this); - play_->tooltip("Start the audio engine (F5 or Space). Regions are silent until audio/MIDI phases."); + play_->tooltip("Start the audio engine (F5 or Space). Play audio clips and the modular graph."); stop_ = new Fl_Button(100, 40, 80, 30, "Stop"); stop_->callback(stop_callback, this); stop_->tooltip("Stop the audio engine (F8 or Space)."); auto *reconnect = new Fl_Button(188, 40, 110, 30, "Reconnect"); reconnect->callback(reconnect_callback, this); reconnect->tooltip("Close and reopen JACK, leaving the engine stopped (F6)."); - indicator_ = new Fl_Box(316, 40, 672, 30, "Audio: disconnected"); + record_ = new Fl_Button(306, 40, 90, 30, "Record"); + record_->callback(audio_file_callback, this); + record_->tooltip("Record JACK input to WAV; choose path, then Stop to finalize and add the take."); + indicator_ = new Fl_Box(404, 40, 584, 30, "Audio: disconnected"); 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; timeline regions are silent; recording is a future phase."); + "Edit signal flow in Modular Graph. No hardware ports are connected automatically; Import WAV clips or record JACK input; connect external ports explicitly."); hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); hint->labelsize(12); @@ -70,11 +76,12 @@ MainWindow::MainWindow(bool prompt_unsaved) 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."); + "Import WAV (Ctrl+I) or Record. 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_->before_edit = before_arrangement_edit; sequencer_->callback([](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); }, this); add_track->callback([](Fl_Widget *, void *c) { static_cast(c)->add_track(); }, sequencer_); add_region->callback([](Fl_Widget *, void *c) { static_cast(c)->add_region(); }, sequencer_); @@ -115,6 +122,10 @@ MainWindow::MainWindow(bool prompt_unsaved) output->callback([](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_OUTPUT); }, graph_widget_); + auto *audio = new Fl_Button(650, 150, 130, 26, "Add audio source"); + audio->callback([](Fl_Widget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_AUDIO); + }, graph_widget_); auto *remove = new Fl_Button(512, 150, 130, 26, "Delete selection"); remove->callback([](Fl_Widget *, void *context) { (void)static_cast(context)->delete_selected(); @@ -150,7 +161,11 @@ MainWindow::~MainWindow() // covers exceptional event-loop exit. A failed close must retain its C // callback context until process exit, rather than free active audio state. if (backend_ != nullptr && xtc_backend_close(backend_) != XTC_OK) - std::fputs("Failed to close JACK during GUI destruction; backend retained until process exit.\n", stderr); + std::fputs("Failed to close JACK during GUI destruction; backend and recording retained until process exit.\n", stderr); + else if (recorder_) { + (void)xtc_recorder_finish(recorder_); + xtc_recorder_destroy(recorder_); + } } void MainWindow::report_error(const char *operation, xtc_result result) @@ -166,6 +181,7 @@ void MainWindow::report_error(const char *operation, xtc_result result) xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, size_t *out_added_id) { auto *window = static_cast(context); + if (window->recorder_) return XTC_ERR_INVALID_STATE; xtc_graph *candidate = nullptr; size_t added_id = SIZE_MAX; xtc_result result = xtc_graph_apply_edit(xtc_project_graph(window->project_.get()), &edit, &candidate, &added_id); @@ -210,9 +226,17 @@ void MainWindow::start_engine() return; } if (running_) return; - xtc_result result = xtc_backend_start(backend_); + xtc_result result = xtc_backend_prepare_project(backend_, project_.get()); + if (result == XTC_OK) result = xtc_backend_start(backend_); if (result != XTC_OK) report_error("Start engine failed", result); - else set_label(error_, ""); + else { + set_label(error_, ""); + const char *issue = xtc_backend_asset_issue(backend_); + if (issue[0]) { + char message[640]; std::snprintf(message, sizeof(message), "Missing/unreadable audio (silent): %s. Select clip and Relink to repair.", issue); + set_label(error_, message); + } + } refresh_status(); } @@ -221,12 +245,16 @@ void MainWindow::stop_engine() if (!ready_) return; xtc_result result = xtc_backend_stop(backend_); if (result != XTC_OK) report_error("Stop engine failed", result); - else set_label(error_, ""); + else { + set_label(error_, ""); + if (recorder_) finish_recording(); + } refresh_status(); } void MainWindow::reconnect_engine() { + if (recorder_) { report_error("Stop recording before reconnecting", XTC_ERR_INVALID_STATE); return; } xtc_result result = xtc_backend_close(backend_); if (result != XTC_OK) { report_error("Reconnect could not close JACK", result); @@ -240,7 +268,7 @@ void MainWindow::reconnect_engine() void MainWindow::refresh_status() { - bool can_play = false; + bool can_play = false, can_record = false; if (!ready_) { set_label(indicator_, "Audio: disconnected"); set_label(status_, "JACK: not connected — use Reconnect after starting the server"); @@ -262,17 +290,18 @@ void MainWindow::refresh_status() running_ = status.running != 0; const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK; can_play = !running_ && !fault; + can_record = !fault && recorder_ == nullptr; const char *state = status.server_lost ? "Audio: server lost — Reconnect required" : status.sample_rate_changed ? "Audio: sample rate changed — Reconnect required" : status.process_result != XTC_OK ? "Audio: processing error" : - running_ ? "Audio: running — modular graph" : "Audio: ready / stopped"; + recorder_ ? "Audio: recording JACK input — Stop to finish" : running_ ? "Audio: running — modular graph" : "Audio: ready / stopped"; set_label(indicator_, state); indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120) : running_ ? fl_rgb_color(125, 225, 160) : fl_rgb_color(215, 215, 215)); char text[256]; - std::snprintf(text, sizeof(text), "JACK | %u Hz | %u frames | mono | %u blocks | %u xruns | %s", + std::snprintf(text, sizeof(text), "JACK | %u Hz | %u frames | mono | %u blocks | %u xruns | %s | %u asset issues", status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns, - xtc_result_string(status.process_result)); + xtc_result_string(status.process_result), status.asset_issues); set_label(status_, text); if (fault) set_label(graph_state_, "Graph: engine unavailable — Reconnect required"); else if (status.submitted_revision != status.active_revision) @@ -283,11 +312,13 @@ void MainWindow::refresh_status() xtc_backend_collect(backend_); } if (can_play) play_->activate(); else play_->deactivate(); - if (ready_ && running_) stop_->activate(); else stop_->deactivate(); + if (can_record) record_->activate(); else record_->deactivate(); + if (ready_ && (running_ || recorder_)) stop_->activate(); else stop_->deactivate(); } bool MainWindow::close_window() { + if (recorder_) { report_error("Stop recording before closing", XTC_ERR_INVALID_STATE); return false; } if (!discard_changes()) return false; xtc_result result = xtc_backend_close(backend_); if (result != XTC_OK) { @@ -328,12 +359,19 @@ void MainWindow::view_callback(Fl_Widget *widget, void *view) } void MainWindow::about_callback(Fl_Widget *, void *) { - fl_message("%s %s\nModular audio workstation\nPhase 5: projects and silent arrangement regions; audio/MIDI content follows.", + fl_message("%s %s\nModular audio workstation\nPhase 6: WAV import, graph clip playback and bounded recording; MIDI follows.", XTC_PROJECT_NAME, XTC_VERSION_STRING); } void MainWindow::timer_callback(void *context) { auto *window = static_cast(context); + if (window->recorder_) { + xtc_result r = xtc_recorder_drain(window->recorder_); + xtc_backend_status status{}; + xtc_backend_get_status(window->backend_, &status); + if (r != XTC_OK || status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK) + window->stop_engine(); + } window->refresh_status(); if (!window->closing_) Fl::repeat_timeout(0.1, timer_callback, context); } @@ -358,6 +396,7 @@ xtc_result MainWindow::seek(void *context, uint64_t tick) } xtc_result MainWindow::save_project(const char *path) { + if (recorder_) return XTC_ERR_INVALID_STATE; if (path == nullptr) return XTC_ERR_INVALID_ARGUMENT; std::string saved_path(path); xtc_result r = xtc_project_save(project_.get(), path); @@ -366,18 +405,31 @@ xtc_result MainWindow::save_project(const char *path) } xtc_result MainWindow::install_project(xtc_project *candidate) { + if (recorder_) return XTC_ERR_INVALID_STATE; 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) r = xtc_backend_prepare_project(backend_, candidate); } + xtc_audio_plan *offline = nullptr; + if (r == XTC_OK && !ready_) r = xtc_audio_plan_create(candidate, 48000, &offline); if (r != XTC_OK) return r; + std::string issue = ready_ ? xtc_backend_asset_issue(backend_) : ""; + if (offline) { + const char *path = ""; + xtc_audio_plan_issues(offline, &path); issue = path; + xtc_audio_plan_destroy(offline); + } project_.reset(candidate); graph_widget_->reset_layout(); graph_widget_->set_graph(xtc_project_graph(candidate)); sequencer_->set_project(candidate); dirty_ = false; + if (!issue.empty()) { + char text[640]; std::snprintf(text, sizeof(text), "Missing/unreadable audio (silent): %s. Select clip and Relink to repair.", issue.c_str()); + set_label(error_, text); + } else set_label(error_, ""); refresh_status(); return XTC_OK; } @@ -396,6 +448,7 @@ xtc_result MainWindow::open_project(const char *path) void MainWindow::file_callback(Fl_Widget *widget, void *context) { auto *window = static_cast(context); + if (window->recorder_) { window->report_error("Stop recording before file operations", XTC_ERR_INVALID_STATE); return; } auto *menu = static_cast(widget); const char *action = menu->mvalue()->label(); xtc_result r = XTC_OK; @@ -425,3 +478,102 @@ void MainWindow::file_callback(Fl_Widget *widget, void *context) } if (r != XTC_OK) window->report_error("Project operation failed", r); } + + +xtc_result MainWindow::before_arrangement_edit(void *context) +{ + auto *window = static_cast(context); + if (window->recorder_) return XTC_ERR_INVALID_STATE; + if (!window->ready_) return XTC_OK; + xtc_result r = xtc_backend_stop(window->backend_); + window->refresh_status(); + return r; +} +xtc_result MainWindow::import_audio(const char *path) +{ + xtc_result r = before_arrangement_edit(this); + if (r != XTC_OK) return r; + xtc_project *candidate = nullptr; + r = xtc_project_clone(project_.get(), &candidate); + uint64_t track = sequencer_->selected_track(), clip = 0; + if (r == XTC_OK && track == UINT64_MAX) r = xtc_project_add_track(candidate, "Audio", &track); + if (r == XTC_OK) r = xtc_project_import_audio(candidate, track, path, sequencer_->position(), &clip); + if (r == XTC_OK && ready_) r = xtc_backend_prepare_project(backend_, candidate); + if (r != XTC_OK) { xtc_project_destroy(candidate); return r; } + project_.reset(candidate); + sequencer_->set_project(candidate, false); + graph_widget_->set_graph(xtc_project_graph(candidate)); + mark_dirty(); refresh_status(); return XTC_OK; +} +xtc_result MainWindow::record_audio(const char *path) +{ + if (!ready_ || recorder_) return XTC_ERR_INVALID_STATE; + xtc_result r = xtc_backend_stop(backend_); + xtc_backend_status status{}; + if (r == XTC_OK) r = xtc_backend_get_status(backend_, &status); + if (r == XTC_OK) r = xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &recording_tick_); + if (r == XTC_OK) r = xtc_backend_prepare_project(backend_, project_.get()); + if (r == XTC_OK) r = xtc_recorder_create(path, status.sample_rate, static_cast(status.sample_rate) * 2, &recorder_); + if (r != XTC_OK) return r; + recording_path_ = path; + recording_track_ = sequencer_->selected_track(); + r = xtc_backend_set_recorder(backend_, recorder_); + if (r == XTC_OK) r = xtc_backend_start(backend_); + if (r != XTC_OK) { + (void)xtc_backend_set_recorder(backend_, nullptr); + xtc_recorder_destroy(recorder_); recorder_ = nullptr; + } else mark_dirty(); + refresh_status(); return r; +} +void MainWindow::finish_recording() +{ + // Called only after successful backend_stop: no producer can reference ring. + (void)xtc_backend_set_recorder(backend_, nullptr); + uint64_t frames = 0; + (void)xtc_recorder_status(recorder_, &frames); + xtc_result r = xtc_recorder_finish(recorder_); + xtc_recorder_destroy(recorder_); recorder_ = nullptr; + if (r != XTC_OK) { + char text[768]; std::snprintf(text, sizeof(text), "Recording failed (%s). Partial take, if finalized: %s. It was not added to the project.", + xtc_result_string(r), recording_path_.c_str()); + set_label(error_, text); return; + } + if (!frames) { set_label(error_, "Empty recording saved; no clip added."); return; } + xtc_project *candidate = nullptr; + r = xtc_project_clone(project_.get(), &candidate); + uint64_t clip = 0, track = recording_track_; + if (r == XTC_OK && track == UINT64_MAX) r = xtc_project_add_track(candidate, "Recording", &track); + if (r == XTC_OK) r = xtc_project_import_audio(candidate, track, recording_path_.c_str(), recording_tick_, &clip); + if (r == XTC_OK) r = xtc_backend_prepare_project(backend_, candidate); + if (r != XTC_OK) { + xtc_project_destroy(candidate); + char text[768]; std::snprintf(text, sizeof(text), "Take saved to %s; adding clip failed: %s. Import the WAV to recover.", + recording_path_.c_str(), xtc_result_string(r)); + set_label(error_, text); return; + } + project_.reset(candidate); sequencer_->set_project(candidate, false); + graph_widget_->set_graph(xtc_project_graph(candidate)); mark_dirty(); +} +void MainWindow::audio_file_callback(Fl_Widget *widget, void *context) +{ + auto *window = static_cast(context); + auto *menu = dynamic_cast(widget); + const char *action = menu ? menu->mvalue()->label() : "Record WAV"; + bool recording = std::strcmp(action, "Record WAV") == 0; + bool relink = std::strcmp(action, "Relink selected audio") == 0; + if (window->recorder_) { window->report_error("Stop recording first", XTC_ERR_INVALID_STATE); return; } + Fl_Native_File_Chooser chooser; + chooser.title(recording ? "Record JACK input to WAV" : relink ? "Relink selected clip" : "Import WAV"); + chooser.type(recording ? Fl_Native_File_Chooser::BROWSE_SAVE_FILE : Fl_Native_File_Chooser::BROWSE_FILE); + chooser.filter("WAV audio\t*.wav"); + if (recording) chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM); + int chosen = chooser.show(); + if (chosen == 1) return; + xtc_result r = chosen < 0 ? XTC_ERR_IO : XTC_OK; + if (r == XTC_OK && relink) { + r = before_arrangement_edit(window); + if (r == XTC_OK) r = xtc_project_set_clip_asset(window->project_.get(), window->sequencer_->selected_clip(), chooser.filename()); + if (r == XTC_OK) { window->mark_dirty(); window->sequencer_->redraw(); } + } else if (r == XTC_OK) r = recording ? window->record_audio(chooser.filename()) : window->import_audio(chooser.filename()); + if (r != XTC_OK) window->report_error("Project audio operation failed", r); +} diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index b2486e9..a037050 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -22,11 +22,19 @@ public: using Fl_Double_Window::show; int handle(int event) override; + xtc_result import_audio(const char *path); + xtc_result record_audio(const char *path); xtc_result save_project(const char *path); xtc_result open_project(const char *path); private: std::unique_ptr project_{nullptr, xtc_project_destroy}; SequencerWidget *sequencer_ = nullptr; + xtc_recorder *recorder_ = nullptr; + std::string recording_path_; + uint64_t recording_tick_ = 0, recording_track_ = UINT64_MAX; + void finish_recording(); + static xtc_result before_arrangement_edit(void *context); + static void audio_file_callback(Fl_Widget *, void *context); bool dirty_ = false, prompt_unsaved_ = true; std::string path_; bool discard_changes(); @@ -40,6 +48,7 @@ private: bool ready_ = false; bool running_ = false; bool closing_ = false; + Fl_Button *record_ = nullptr; Fl_Button *play_ = nullptr; Fl_Button *stop_ = nullptr; Fl_Box *indicator_ = nullptr; diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index d2c7a0e..2347cb2 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -39,11 +39,13 @@ void SequencerWidget::changed(xtc_result result) } xtc_result SequencerWidget::add_track() { + if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return r; } } 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() { + if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return r; } } 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; @@ -55,6 +57,7 @@ xtc_result SequencerWidget::add_region() } xtc_result SequencerWidget::delete_selection() { + if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return r; } } 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; @@ -96,7 +99,7 @@ void SequencerWidget::draw() 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(); + fl_draw(c.asset_path[0] ? "Audio" : c.name, std::max(a, left + 6), py + 31); fl_pop_clip(); } } int playhead = x() + 120 + static_cast(cursor_ * static_cast(scale_) / 960U); @@ -148,6 +151,7 @@ int SequencerWidget::handle(int event) } else if (mode == 2) { start += delta; offset += delta; length -= delta; } else length += delta; if (start < 0 || offset < 0 || length <= 0) return 1; + if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return 1; } } changed(xtc_project_edit_clip(project_, clip_, target, static_cast(start), static_cast(length), static_cast(offset))); return 1; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 5587e77..9411398 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -4,6 +4,10 @@ #include "xtc/xtc_project.h" class SequencerWidget : public Fl_Widget { public: + using BeforeEdit = xtc_result (*)(void *); + BeforeEdit before_edit = nullptr; + uint64_t selected_track() const { return track_; } + uint64_t selected_clip() const { return clip_; } 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); diff --git a/src/persistence/README.md b/src/persistence/README.md index c286ad8..0077da3 100644 --- a/src/persistence/README.md +++ b/src/persistence/README.md @@ -1,6 +1,6 @@ # Persistence -project_file.c implements bounded version-1 project metadata loading and atomic +project_file.c implements bounded version-2 project metadata loading (including explicit version-1 migration) and atomic temporary-file/fsync/rename saving in C. Loading validates a candidate before replacing the existing model. All I/O is control-thread only. See docs/PROJECT_FORMAT.md for the schema, limits and durability guarantees. diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index 06bcfe6..d0bc291 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -11,7 +11,7 @@ #include #include -typedef struct { FILE *file; size_t bytes; char line[512]; char *tokens[12]; size_t count; } reader; +typedef struct { FILE *file; size_t bytes; char line[1536]; char *tokens[12]; size_t count; } reader; static xtc_result line(reader *input) { size_t used = 0U; @@ -19,7 +19,7 @@ static xtc_result line(reader *input) char *cursor; input->count = 0U; while ((byte = fgetc(input->file)) != EOF) { - if (++input->bytes > 1048576U) return XTC_ERR_FORMAT; + if (++input->bytes > 2097152U) 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; @@ -85,11 +85,12 @@ static int decode_float(const char *text, float *value) #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; + uint64_t version, value, next_id, node_count, edge_count, format_version; size_t i, j; xtc_graph *graph = project->graph; READ("XTC_PROJECT", 2U); NUM(1U, version); - if (version != 1U) return XTC_ERR_UNSUPPORTED; + if (version != 1U && version != 2U) return XTC_ERR_UNSUPPORTED; + format_version = version; 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); @@ -103,11 +104,11 @@ static xtc_result read_project(reader *input, xtc_project *project) 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 || + if (value >= next_id || value >= SIZE_MAX || version < XTC_NODE_GAIN || version > (format_version == 1U ? XTC_NODE_OUTPUT : XTC_NODE_AUDIO) || !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; + if ((node.type == XTC_NODE_METER || node.type == XTC_NODE_OUTPUT || node.type == XTC_NODE_AUDIO) && 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; } @@ -133,6 +134,19 @@ static xtc_result read_project(reader *input, xtc_project *project) 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; + if (format_version == 2U) { + size_t k, length; + READ("ASSET", 2U); + if (!strcmp(input->tokens[1], "-")) continue; + length = strlen(input->tokens[1]); + if (!length || length % 2 || length >= XTC_ASSET_PATH_BYTES * 2U) return XTC_ERR_FORMAT; + for (k = 0; k < length / 2; ++k) { + int high = hex_digit(input->tokens[1][k * 2]), low = hex_digit(input->tokens[1][k * 2 + 1]); + if (high < 0 || low < 0 || (high == 0 && low == 0)) return XTC_ERR_FORMAT; + clip->asset_path[k] = (char)(high * 16 + low); + } + clip->asset_path[length / 2] = 0; + } } READ("END", 1U); if (fgetc(input->file) != EOF) return XTC_ERR_FORMAT; @@ -150,7 +164,7 @@ 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", + if (fprintf(file, "XTC_PROJECT 2\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; @@ -172,6 +186,10 @@ static xtc_result write_project(FILE *file, const xtc_project *project) 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; + if (fputs("ASSET ", file) == EOF) return XTC_ERR_IO; + if (clip->asset_path[0]) { + if (!write_name(file, clip->asset_path)) return XTC_ERR_IO; + } else if (fputs("-\n", file) == EOF) return XTC_ERR_IO; } return fputs("END\n", file) == EOF ? XTC_ERR_IO : XTC_OK; } diff --git a/src/project/README.md b/src/project/README.md index 6a6e428..8893122 100644 --- a/src/project/README.md +++ b/src/project/README.md @@ -1,6 +1,6 @@ # 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 +project.c owns the opaque C project: graph, bounded tracks, silent/audio arrangement +regions and external asset references, tempo and stable ID allocation. The GUI borrows handles and edits through xtc_project.h. Tick/sample conversion and source-preserving trim validation live here; no FLTK, file I/O or callback scheduling belongs in this module. diff --git a/src/project/project.c b/src/project/project.c index 82501f3..0f79f3d 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -1,4 +1,5 @@ #include "project_internal.h" +#include "xtc/xtc_audio.h" #include "../graph/graph_internal.h" #include #include @@ -214,9 +215,29 @@ xtc_result xtc_project_validate(const xtc_project *project) } for (i = 0U; i < project->clip_count; ++i) { const xtc_clip_info *clip = project->clips + i; + size_t path_length = 0U; + while (path_length < XTC_ASSET_PATH_BYTES && clip->asset_path[path_length]) { + unsigned char byte = (unsigned char)clip->asset_path[path_length++]; + if (byte < 32U || byte == 127U) return XTC_ERR_FORMAT; + } + if (path_length == XTC_ASSET_PATH_BYTES || (path_length && clip->asset_path[0] != '/')) return XTC_ERR_FORMAT; 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; } + +xtc_result xtc_project_set_clip_asset(xtc_project *project, uint64_t id, const char *path) +{ + size_t index, length = 0U, i; + if (!project || !path) return XTC_ERR_INVALID_ARGUMENT; + while (length < XTC_ASSET_PATH_BYTES && path[length]) ++length; + if (length == XTC_ASSET_PATH_BYTES || (length && path[0] != '/')) return XTC_ERR_INVALID_ARGUMENT; + for (i = 0; i < length; ++i) + if ((unsigned char)path[i] < 32U || path[i] == 127) return XTC_ERR_INVALID_ARGUMENT; + index = clip_index(project, id); + if (index == SIZE_MAX) return XTC_ERR_NOT_FOUND; + memmove(project->clips[index].asset_path, path, length + 1U); + return XTC_OK; +} diff --git a/tests/integration/README.md b/tests/integration/README.md index 87657be..6ea0428 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -102,3 +102,20 @@ 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. + + +## Phase 6 audio acceptance + +make test-jack-audio requires a running JACK-compatible server. It creates only +software routes between its probe and xtc backend: known 0.25 source, input +capture through the recorder ring, WAV finalization, project save/reopen, +then clip source graph playback observed at the probe input. All test ports, +files and clients are removed. It never connects hardware or changes server +configuration. + +test_gui covers record/Stop, exact take playback, saving/reopening, failed import, +missing assets, and blocked edits/file actions/close while recording. The live +test_gui_jack records a silent take, finalizes and saves/reopens it, then starts +playback before callback-safe close. Decoder and producer/consumer concurrency +checks are part of make test; run test_audio and test_realtime under TSan as in +BUILD.md. Physical input/output and latency compensation are not tested. diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index d318b4c..b59cf06 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -16,7 +16,7 @@ /* Fake only the public C backend boundary. Real FLTK widgets, callbacks and * timers run under Xvfb; no server/device is required or touched. */ -struct xtc_backend { int running = 0; xtc_plan *plan = nullptr; }; +struct xtc_backend { int running = 0; xtc_plan *plan = nullptr; xtc_audio_plan *audio = nullptr; xtc_recorder *recorder = nullptr; }; namespace { xtc_backend handle; xtc_result open_result = XTC_OK, start_result = XTC_OK, stop_result = XTC_OK, close_result = XTC_OK; @@ -98,11 +98,11 @@ xtc_result xtc_backend_close(xtc_backend *backend) { if (backend == nullptr) return XTC_OK; ++closes; - if (close_result == XTC_OK) { CHECK(alive == 1U); --alive; handle.running = 0; xtc_plan_destroy(handle.plan); handle.plan = nullptr; } + if (close_result == XTC_OK) { CHECK(alive == 1U); --alive; handle.running = 0; xtc_plan_destroy(handle.plan); handle.plan = nullptr; xtc_audio_plan_destroy(handle.audio); handle.audio = nullptr; } return close_result; } xtc_result xtc_backend_start(xtc_backend *backend) -{ CHECK(backend == &handle); ++starts; if (start_result == XTC_OK) handle.running = 1; return start_result; } +{ CHECK(backend == &handle); ++starts; if (start_result == XTC_OK) { handle.running = 1; engine_status.active_revision = engine_status.submitted_revision; } return start_result; } xtc_result xtc_backend_stop(xtc_backend *backend) { CHECK(backend == &handle); ++stops; if (stop_result == XTC_OK) handle.running = 0; return stop_result; } xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *status) @@ -126,6 +126,20 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) ++engine_status.submitted_revision; return XTC_OK; } +xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project) +{ + xtc_audio_plan *plan = nullptr; + xtc_result r = xtc_audio_plan_create(project, 48000, &plan); + if (r == XTC_OK) r = xtc_backend_submit(backend, xtc_project_graph(project)); + if (r != XTC_OK) { xtc_audio_plan_destroy(plan); return r; } + xtc_audio_plan_destroy(handle.audio); handle.audio = plan; + engine_status.asset_issues = static_cast(xtc_audio_plan_issues(plan, nullptr)); + return XTC_OK; +} +const char *xtc_backend_asset_issue(const xtc_backend *) +{ const char *path = ""; xtc_audio_plan_issues(handle.audio, &path); return path; } +xtc_result xtc_backend_set_recorder(xtc_backend *, xtc_recorder *recorder) +{ if (handle.running) return XTC_ERR_INVALID_STATE; handle.recorder = recorder; return XTC_OK; } void xtc_backend_collect(xtc_backend *backend) { CHECK(backend == &handle && alive == 1U); ++collects; } } @@ -268,6 +282,24 @@ int main(int argc, char **argv) CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_OK); CHECK(sequence->position() == 1920); // Graph edits preserve the transport. engine_status.sample_position = 0; + std::string take = std::string(directory) + "/take.wav"; + CHECK(window.record_audio(take.c_str()) == XTC_OK && handle.recorder != nullptr); + CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_ERR_INVALID_STATE); + CHECK(window.save_project(filename.c_str()) == XTC_ERR_INVALID_STATE); + window.do_callback(); + CHECK(window.shown() && contains(&window, "Stop recording before closing")); + float recorded[64]; for (float &value : recorded) value = 0.25f; + xtc_recorder_push(handle.recorder, recorded, 64); + pump(); + press(stop); + CHECK(handle.recorder == nullptr); + CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && clip_count == 2); + CHECK(window.save_project(filename.c_str()) == XTC_OK); + CHECK(window.open_project(filename.c_str()) == XTC_OK); + float mix[64]; CHECK(xtc_audio_plan_render(handle.audio, 0, mix, 64) == XTC_OK && mix[0] == 0.25f); + CHECK(unlink(take.c_str()) == 0); + CHECK(window.open_project(filename.c_str()) == XTC_OK && contains(&window, "Missing/unreadable audio")); + CHECK(window.import_audio("/missing/xtc-audio.wav") == XTC_ERR_IO); CHECK(unlink(filename.c_str()) == 0 && rmdir(directory) == 0); engine_status.xruns = 3U; engine_status.processed_blocks = 123U; diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp index d161752..05e7d8b 100644 --- a/tests/integration/test_gui_jack.cpp +++ b/tests/integration/test_gui_jack.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace { Fl_Widget *find(Fl_Group &group, const char *label) @@ -91,6 +92,21 @@ int main(int argc, char **argv) play->do_callback(); pump(); CHECK(find(window, "running — modular graph") != nullptr); + stop->do_callback(); + char directory[] = "/tmp/xtc-live-record-XXXXXX"; CHECK(mkdtemp(directory) != nullptr); + std::string take = std::string(directory) + "/take.wav"; + std::string song = std::string(directory) + "/song.xtc"; + CHECK(window.record_audio(take.c_str()) == XTC_OK); + pump(); + CHECK(find(window, "recording JACK input") != nullptr); + stop->do_callback(); + size_t tracks, clips; + CHECK(xtc_project_get_counts(timeline->project(), &tracks, &clips) == XTC_OK && clips == 1); + CHECK(window.save_project(song.c_str()) == XTC_OK); + CHECK(window.open_project(song.c_str()) == XTC_OK); + play->do_callback(); pump(); + CHECK(find(window, "running — modular graph") != nullptr); + CHECK(unlink(take.c_str()) == 0 && unlink(song.c_str()) == 0 && rmdir(directory) == 0); window.do_callback(); // Close with an active client; synchronizes callbacks. CHECK(!window.shown()); pump(); // No timer may access the closed client. diff --git a/tests/integration/test_jack_audio.c b/tests/integration/test_jack_audio.c new file mode 100644 index 0000000..7c8f807 --- /dev/null +++ b/tests/integration/test_jack_audio.c @@ -0,0 +1,90 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_backend.h" +#include +#include +#include +#include +#include +#include +typedef struct { jack_port_t *input, *output; unsigned int heard, bad; } probe; +static int process(jack_nframes_t frames, void *context) +{ + probe *p = context; + const float *input = jack_port_get_buffer(p->input, frames); + float *output = jack_port_get_buffer(p->output, frames); + jack_nframes_t i; + if (!input || !output) { __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED); return 0; } + for (i = 0; i < frames; ++i) { + output[i] = 0.25f; + if (!isfinite(input[i]) || fabsf(input[i]) > 0.251f) + __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED); + if (fabsf(input[i] - 0.25f) < 0.0001f) + __atomic_store_n(&p->heard, 1U, __ATOMIC_RELAXED); + } + return 0; +} +#define REQUIRE(c) do { if (!(c)) { fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); goto cleanup; } } while (0) +int main(void) +{ + char directory[] = "/tmp/xtc-live-audio-XXXXXX", take[256] = "", song[256] = "", name[64]; + xtc_backend_config config = {XTC_BACKEND_JACK, name, 65536, 1}; + xtc_backend *backend = NULL; + xtc_backend_status status; + xtc_recorder *recorder = NULL; + xtc_project *p = NULL, *q = NULL; + jack_client_t *client = NULL; + jack_status_t flags = 0; + probe state = {0}; + struct timespec pause = {0, 50000000L}; + uint64_t track, clip, frames; + int i, result = EXIT_FAILURE; + REQUIRE(mkdtemp(directory)); + snprintf(take, sizeof(take), "%s/take.wav", directory); + snprintf(song, sizeof(song), "%s/song.xtc", directory); + snprintf(name, sizeof(name), "xtc-audio-%ld", (long)getpid()); + REQUIRE(xtc_backend_open(&config, &backend) == XTC_OK); + REQUIRE(xtc_backend_get_status(backend, &status) == XTC_OK); + REQUIRE(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK); + REQUIRE(xtc_project_add_track(p, "Recording", &track) == XTC_OK); + REQUIRE(xtc_backend_prepare_project(backend, p) == XTC_OK); + client = jack_client_open("xtc-audio-probe", JackNoStartServer, &flags); + REQUIRE(client); + state.input = jack_port_register(client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0); + state.output = jack_port_register(client, "output", JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0); + REQUIRE(state.input && state.output); + REQUIRE(jack_set_process_callback(client, process, &state) == 0); + REQUIRE(jack_activate(client) == 0); + REQUIRE(xtc_recorder_create(take, status.sample_rate, (size_t)status.sample_rate * 2, &recorder) == XTC_OK); + REQUIRE(xtc_backend_set_recorder(backend, recorder) == XTC_OK); + REQUIRE(xtc_backend_start(backend) == XTC_OK); + REQUIRE(xtc_backend_connect(backend, XTC_PORT_INPUT, jack_port_name(state.output)) == XTC_OK); + for (i = 0; i < 20; ++i) { + nanosleep(&pause, NULL); + REQUIRE(xtc_recorder_drain(recorder) == XTC_OK); + REQUIRE(xtc_backend_get_status(backend, &status) == XTC_OK && status.process_result == XTC_OK); + } + REQUIRE(xtc_backend_stop(backend) == XTC_OK); + REQUIRE(xtc_backend_set_recorder(backend, NULL) == XTC_OK); + REQUIRE(xtc_recorder_finish(recorder) == XTC_OK); + REQUIRE(xtc_recorder_status(recorder, &frames) == XTC_OK && frames > status.sample_rate / 2); + xtc_recorder_destroy(recorder); recorder = NULL; + REQUIRE(xtc_project_import_audio(p, track, take, 0, &clip) == XTC_OK); + REQUIRE(xtc_project_save(p, song) == XTC_OK && xtc_project_load(q, song) == XTC_OK); + REQUIRE(xtc_backend_prepare_project(backend, q) == XTC_OK); + REQUIRE(xtc_backend_seek(backend, 0) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK); + for (i = 0; i < 10; ++i) nanosleep(&pause, NULL); + REQUIRE(__atomic_load_n(&state.heard, __ATOMIC_RELAXED) && !__atomic_load_n(&state.bad, __ATOMIC_RELAXED)); + puts("Live JACK software source -> recording -> save/reopen -> graph playback passed."); + result = EXIT_SUCCESS; +cleanup: + /* Close synchronizes producers before recorder/model cleanup. */ + if (backend && xtc_backend_close(backend) != XTC_OK) return EXIT_FAILURE; + if (client && jack_client_close(client) != 0) return EXIT_FAILURE; + xtc_recorder_destroy(recorder); + xtc_project_destroy(p); xtc_project_destroy(q); + if (take[0]) unlink(take); + if (song[0]) unlink(song); + rmdir(directory); + return result; +} diff --git a/tests/unit/test_audio.c b/tests/unit/test_audio.c new file mode 100644 index 0000000..66a0f3f --- /dev/null +++ b/tests/unit/test_audio.c @@ -0,0 +1,169 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_audio.h" +#include +#include +#include +#include +#include +#include +#include +static void le(unsigned char *p, uint32_t value, size_t count) +{ size_t i; for (i = 0; i < count; ++i) { p[i] = (unsigned char)(value & 255U); value >>= 8U; } } +static void fixture(const char *path, unsigned int bits, unsigned int channels, unsigned int rate) +{ + unsigned char header[44] = {0}, sample[8] = {0}; + size_t i; FILE *f = fopen(path, "wb"); + unsigned int align = channels * bits / 8; + assert(f); + memcpy(header, "RIFF", 4); le(header + 4, 36 + 480 * align, 4); + memcpy(header + 8, "WAVEfmt ", 8); le(header + 16, 16, 4); + le(header + 20, 1, 2); le(header + 22, channels, 2); + le(header + 24, rate, 4); le(header + 28, rate * align, 4); + le(header + 32, align, 2); le(header + 34, bits, 2); + memcpy(header + 36, "data", 4); le(header + 40, 480 * align, 4); + assert(fwrite(header, 1, 44, f) == 44); + le(sample, UINT32_C(1) << (bits - 3), bits / 8); /* +0.25 */ + if (channels == 2) le(sample + bits / 8, UINT32_C(3) << (bits - 3), bits / 8); /* +0.75 */ + for (i = 0; i < 480; ++i) assert(fwrite(sample, 1, align, f) == align); + assert(fclose(f) == 0); +} +typedef struct { xtc_recorder *r; unsigned int complete; } stress; +static void *producer(void *context) +{ + stress *s = context; + float samples[32]; size_t i, b; + for (i = 0; i < 32; ++i) samples[i] = (float)i / 32; + for (b = 0; b < 10000; ++b) xtc_recorder_push(s->r, samples, 32); + __atomic_store_n(&s->complete, 1, __ATOMIC_RELEASE); + return NULL; +} +int main(void) +{ + char directory[] = "/tmp/xtc-audio-XXXXXX", wave[256], take[256], project[256], moved[256]; + xtc_project *p = NULL, *q = NULL; + xtc_audio_plan *plan = NULL; + xtc_recorder *rec = NULL; + uint64_t track, clip, frames; + xtc_clip_info info; + float output[64], input[64], timeline[64]; + xtc_plan *graph_plan = NULL; + unsigned int bits; + size_t i; + assert(mkdtemp(directory)); + snprintf(wave, sizeof(wave), "%s/input.wav", directory); + snprintf(take, sizeof(take), "%s/take.wav", directory); + snprintf(project, sizeof(project), "%s/song.xtc", directory); + snprintf(moved, sizeof(moved), "%s/moved.wav", directory); + for (bits = 16; bits <= 32; bits += 8) { + fixture(wave, bits, 2, 48000); + assert(xtc_project_create(&p) == XTC_OK); + assert(xtc_project_add_track(p, "Audio", &track) == XTC_OK); + assert(xtc_project_import_audio(p, track, wave, 960, &clip) == XTC_OK); + assert(xtc_project_find_clip(p, clip, &info) == XTC_OK && info.length_tick == 20); + assert(xtc_audio_plan_create(p, 48000, &plan) == XTC_OK && xtc_audio_plan_issues(plan, NULL) == 0); + assert(xtc_audio_plan_render(plan, 23998, output, 8) == XTC_OK); + assert(output[0] == 0 && output[1] == 0 && output[2] == 0.5f && output[7] == 0.5f); + assert(xtc_graph_compile(xtc_project_graph(p), 48000, 64, &graph_plan) == XTC_OK); + assert(xtc_plan_process_audio(graph_plan, NULL, output, timeline, 8) == XTC_OK && timeline[2] == 0.5f); + xtc_plan_destroy(graph_plan); + xtc_audio_plan_destroy(plan); + assert(xtc_audio_plan_create(p, 24000, &plan) == XTC_OK); + assert(xtc_audio_plan_render(plan, 11999, output, 8) == XTC_OK && output[0] == 0 && output[1] == 0.5f); + xtc_audio_plan_destroy(plan); + /* Trim offset and overlap summing. */ + assert(xtc_project_edit_clip(p, clip, track, 0, 10, 10) == XTC_OK); + assert(xtc_project_import_audio(p, track, wave, 0, &clip) == XTC_OK); + assert(xtc_audio_plan_create(p, 48000, &plan) == XTC_OK); + assert(xtc_audio_plan_render(plan, 0, output, 64) == XTC_OK && output[0] == 1.0f); + assert(rename(wave, moved) == 0); + assert(xtc_audio_plan_render(plan, 0, output, 64) == XTC_OK && output[0] == 1.0f); /* no callback file access */ + xtc_audio_plan_destroy(plan); + assert(xtc_audio_plan_create(p, 48000, &plan) == XTC_OK && xtc_audio_plan_issues(plan, NULL) == 1); + assert(xtc_audio_plan_render(plan, 0, output, 64) == XTC_OK && output[0] == 0); + xtc_audio_plan_destroy(plan); + assert(xtc_project_set_clip_asset(p, clip, moved) == XTC_OK); + assert(xtc_audio_plan_create(p, 48000, &plan) == XTC_OK && xtc_audio_plan_issues(plan, NULL) == 1); + assert(xtc_audio_plan_render(plan, 0, output, 64) == XTC_OK && output[0] == 0.5f); + xtc_audio_plan_destroy(plan); + assert(unlink(moved) == 0); + xtc_project_destroy(p); + } + /* Recording ring wraps, exact samples survive WAV -> project -> reopen. */ + for (i = 0; i < 64; ++i) input[i] = i % 2 ? -0.5f : 0.25f; + assert(xtc_recorder_create(take, 48000, 128, &rec) == XTC_OK); + for (i = 0; i < 10; ++i) { + xtc_recorder_push(rec, input, 64); + assert(xtc_recorder_drain(rec) == XTC_OK); + } + assert(xtc_recorder_status(rec, &frames) == XTC_OK && frames == 640); + assert(xtc_recorder_finish(rec) == XTC_OK); xtc_recorder_destroy(rec); + assert(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK); + assert(xtc_project_add_track(p, "Take", &track) == XTC_OK); + assert(xtc_project_import_audio(p, track, take, 0, &clip) == XTC_OK); + assert(xtc_project_save(p, project) == XTC_OK && xtc_project_load(q, project) == XTC_OK); + assert(xtc_audio_plan_create(q, 48000, &plan) == XTC_OK); + assert(xtc_audio_plan_render(plan, 0, output, 64) == XTC_OK && memcmp(input, output, sizeof(input)) == 0); + assert(xtc_audio_plan_render(plan, 639, output, 8) == XTC_OK && output[0] == -0.5f && output[1] == 0); + xtc_audio_plan_destroy(plan); + /* Truncated/unsupported imports do not change project IDs/clip counts. */ + { + FILE *f = fopen(wave, "wb"); size_t tracks, clips; assert(f); fputs("RIFF", f); assert(fclose(f) == 0); + assert(xtc_project_import_audio(p, track, wave, 0, &clip) == XTC_ERR_FORMAT); + assert(xtc_project_get_counts(p, &tracks, &clips) == XTC_OK && clips == 1); + } + { + unsigned char bytes[2048]; + FILE *f; + size_t count, n, tracks, clips; + fixture(wave, 16, 2, 48000); + f = fopen(wave, "rb"); assert(f); + count = fread(bytes, 1, sizeof(bytes), f); assert(fclose(f) == 0); + for (n = 0; n < count; ++n) { + f = fopen(wave, "wb"); assert(f); + assert(fwrite(bytes, 1, n, f) == n && fclose(f) == 0); + assert(xtc_project_import_audio(p, track, wave, 0, &clip) != XTC_OK); + } + assert(xtc_project_get_counts(p, &tracks, &clips) == XTC_OK && clips == 1); + bytes[20] = 6; /* Unsupported codec. */ + f = fopen(wave, "wb"); assert(f); + assert(fwrite(bytes, 1, count, f) == count && fclose(f) == 0); + assert(xtc_project_import_audio(p, track, wave, 0, &clip) == XTC_ERR_UNSUPPORTED); + fixture(wave, 32, 1, 48000); + f = fopen(wave, "r+b"); assert(f); + assert(fseek(f, 20, SEEK_SET) == 0 && fputc(3, f) != EOF); + le(bytes, UINT32_C(0x7fc00000), 4); + assert(fseek(f, 44, SEEK_SET) == 0 && fwrite(bytes, 1, 4, f) == 4 && fclose(f) == 0); + assert(xtc_project_import_audio(p, track, wave, 0, &clip) == XTC_ERR_FORMAT); + } + xtc_project_destroy(p); xtc_project_destroy(q); + /* Overflow is sticky, partial file recoverable, destination unchanged until finish. */ + assert(xtc_recorder_create(take, 48000, 64, &rec) == XTC_OK); + xtc_recorder_push(rec, input, 64); xtc_recorder_push(rec, input, 1); + assert(xtc_recorder_status(rec, &frames) == XTC_ERR_CAPACITY && frames == 64); + assert(xtc_recorder_finish(rec) == XTC_ERR_CAPACITY); xtc_recorder_destroy(rec); + assert(xtc_recorder_create(take, 48000, 64, &rec) == XTC_OK); + xtc_recorder_push(rec, NULL, 1); + assert(xtc_recorder_status(rec, &frames) == XTC_ERR_IO); + xtc_recorder_destroy(rec); + /* Actual concurrent producer/control consumer; no GUI or JACK required. */ + { + stress s = {0}; pthread_t thread; + assert(xtc_recorder_create(take, 48000, 524288, &rec) == XTC_OK); s.r = rec; + assert(pthread_create(&thread, NULL, producer, &s) == 0); + while (!__atomic_load_n(&s.complete, __ATOMIC_ACQUIRE)) assert(xtc_recorder_drain(rec) == XTC_OK); + assert(pthread_join(thread, NULL) == 0); + assert(xtc_recorder_finish(rec) == XTC_OK); + assert(xtc_recorder_status(rec, &frames) == XTC_OK && frames == 320000); + xtc_recorder_destroy(rec); + assert(xtc_project_create(&p) == XTC_OK); + assert(xtc_project_add_track(p, "Concurrent take", &track) == XTC_OK); + assert(xtc_project_import_audio(p, track, take, 0, &clip) == XTC_OK); + assert(xtc_audio_plan_create(p, 48000, &plan) == XTC_OK); + assert(xtc_audio_plan_render(plan, 319936, output, 64) == XTC_OK); + for (i = 0; i < 64; ++i) assert(output[i] == (float)(i % 32) / 32); + xtc_audio_plan_destroy(plan); xtc_project_destroy(p); + } + assert(unlink(wave) == 0 && unlink(take) == 0 && unlink(project) == 0 && rmdir(directory) == 0); + puts("Audio import/render/trim/resample/missing assets and recording roundtrip/overflow/concurrency passed."); + return 0; +} diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index 58f471c..7750c09 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -1,3 +1,4 @@ +#define _POSIX_C_SOURCE 200809L /* JACK ABI fake: deterministic fault injection, linked without libjack. * Processing is driven synchronously here; realtime concurrency is tested * separately by test_realtime. No fake header replaces installed declarations. */ @@ -7,6 +8,7 @@ #include #include #include +#include #define CHECK(c) do { if (!(c)) { fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); return 1; } } while (0) static JackProcessCallback process_cb; static JackSampleRateCallback rate_cb; @@ -182,6 +184,58 @@ int main(void) close_fail = 0; CHECK(xtc_backend_close(backend) == XTC_OK); } + { + char directory[] = "/tmp/xtc-backend-audio-XXXXXX", take[256], song[256]; + xtc_project *p = NULL, *q = NULL; + xtc_recorder *recorder = NULL; + uint64_t track, clip, captured; + CHECK(mkdtemp(directory) != NULL); + snprintf(take, sizeof(take), "%s/take.wav", directory); + snprintf(song, sizeof(song), "%s/song.xtc", directory); + CHECK(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK); + CHECK(xtc_project_add_track(p, "Take", &track) == XTC_OK); + CHECK(xtc_backend_open(&config, &backend) == XTC_OK); + CHECK(xtc_backend_prepare_project(backend, p) == XTC_OK); + CHECK(xtc_recorder_create(take, 48000, 256, &recorder) == XTC_OK); + CHECK(xtc_backend_set_recorder(backend, recorder) == XTC_OK); + CHECK(xtc_backend_start(backend) == XTC_OK); + CHECK(xtc_backend_set_recorder(backend, NULL) == XTC_ERR_INVALID_STATE); + CHECK(xtc_backend_prepare_project(backend, p) == XTC_ERR_INVALID_STATE); + for (i = 0; i < 32; ++i) input[i] = i % 2 ? -0.5f : 0.25f; + CHECK(process_cb(32, context) == 0); + deactivate_fail = 1; + CHECK(xtc_backend_stop(backend) == XTC_ERR_IO); + CHECK(process_cb(32, context) == 0); + deactivate_fail = 0; + CHECK(xtc_backend_stop(backend) == XTC_OK); + CHECK(xtc_backend_set_recorder(backend, NULL) == XTC_OK); + CHECK(xtc_recorder_finish(recorder) == XTC_OK); + CHECK(xtc_recorder_status(recorder, &captured) == XTC_OK && captured == 64); + xtc_recorder_destroy(recorder); + CHECK(xtc_recorder_create(take, 48000, 256, &recorder) == XTC_OK); + CHECK(xtc_backend_set_recorder(backend, recorder) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + null_buffer = 1; CHECK(process_cb(32, context) == 0); null_buffer = 0; + CHECK(process_cb(32, context) == 0); /* Never silently resume after missing callback data. */ + CHECK(xtc_recorder_status(recorder, &captured) == XTC_ERR_IO && captured == 0); + CHECK(xtc_backend_stop(backend) == XTC_OK && xtc_backend_set_recorder(backend, NULL) == XTC_OK); + CHECK(xtc_recorder_finish(recorder) == XTC_ERR_IO); + xtc_recorder_destroy(recorder); /* Prior destination take must survive. */ + CHECK(xtc_project_import_audio(p, track, take, 0, &clip) == XTC_OK); + CHECK(xtc_project_save(p, song) == XTC_OK && xtc_project_load(q, song) == XTC_OK); + CHECK(xtc_backend_prepare_project(backend, q) == XTC_OK); + CHECK(xtc_backend_seek(backend, 0) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + CHECK(unlink(take) == 0); /* Immutable plan is already loaded. */ + CHECK(process_cb(32, context) == 0 && memcmp(input, output, 32 * sizeof(float)) == 0); + CHECK(xtc_backend_stop(backend) == XTC_OK); + CHECK(xtc_backend_prepare_project(backend, q) == XTC_OK); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.asset_issues == 1); + CHECK(strcmp(xtc_backend_asset_issue(backend), take) == 0); + CHECK(xtc_backend_seek(backend, 0) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + CHECK(process_cb(32, context) == 0 && output[0] == 0.0f); + CHECK(xtc_backend_close(backend) == XTC_OK); + xtc_project_destroy(p); xtc_project_destroy(q); + CHECK(unlink(song) == 0 && rmdir(directory) == 0); + } CHECK(closed >= 104); CHECK(xtc_backend_close(NULL) == XTC_OK); xtc_graph_destroy(invalid_graph); diff --git a/tests/unit/test_project.c b/tests/unit/test_project.c index f279816..e1d5981 100644 --- a/tests/unit/test_project.c +++ b/tests/unit/test_project.c @@ -1,5 +1,5 @@ #define _POSIX_C_SOURCE 200809L -#include "xtc/xtc_project.h" +#include "xtc/xtc_audio.h" #include #include #include @@ -110,9 +110,20 @@ int main(void) 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); + for (i = 0; i < XTC_PROJECT_MAX_CLIPS; ++i) { + char asset[XTC_ASSET_PATH_BYTES]; + memset(asset, 'a', sizeof(asset)); asset[0] = '/'; asset[sizeof(asset) - 1] = 0; + assert(xtc_project_add_clip(q, 2, "Region", 0, 960, &id) == XTC_OK); + assert(xtc_project_set_clip_asset(q, id, asset) == 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); + { + const char *v1 = "XTC_PROJECT 1\nAPP 0 1 0\nTEMPO 120000\nIDS 1 1\nGRAPH 1 0 0 0\nTRACKS 1\nTRACK 0 41\nCLIPS 1\nCLIP 0 0 0 960 0 960 42\nEND\n"; + write_bytes(bad, v1, strlen(v1)); + assert(xtc_project_load(p, bad) == XTC_OK); + assert(xtc_project_get_clip(p, 0, &info) == XTC_OK && info.asset_path[0] == 0); + } 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. */