Phase 6: Add WAV clips, graph playback and bounded recording
This commit is contained in:
parent
65640c4aad
commit
8a37090b81
40 changed files with 1450 additions and 69 deletions
23
Makefile
23
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
|
||||
|
|
|
|||
19
README.md
19
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
109
docs/AUDIO_FILES.md
Normal file
109
docs/AUDIO_FILES.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
31
docs/GUI.md
31
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
26
docs/decisions/0005-preloaded-audio-and-recording.md
Normal file
26
docs/decisions/0005-preloaded-audio-and-recording.md
Normal file
|
|
@ -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.
|
||||
46
include/xtc/xtc_audio.h
Normal file
46
include/xtc/xtc_audio.h
Normal file
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
src/audio/README.md
Normal file
10
src/audio/README.md
Normal file
|
|
@ -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.
|
||||
132
src/audio/arrangement.c
Normal file
132
src/audio/arrangement.c
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#define _XOPEN_SOURCE 700
|
||||
#include "wave_internal.h"
|
||||
#include "../project/project_internal.h"
|
||||
#include "../graph/graph_internal.h"
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
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;
|
||||
}
|
||||
105
src/audio/recorder.c
Normal file
105
src/audio/recorder.c
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "wave_internal.h"
|
||||
#include <stdlib.h>
|
||||
#include <float.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
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);
|
||||
}
|
||||
115
src/audio/wave.c
Normal file
115
src/audio/wave.c
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#include "wave_internal.h"
|
||||
#include <float.h>
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
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;
|
||||
}
|
||||
9
src/audio/wave_internal.h
Normal file
9
src/audio/wave_internal.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#ifndef XTC_WAVE_INTERNAL_H
|
||||
#define XTC_WAVE_INTERNAL_H
|
||||
#include "xtc/xtc_audio.h"
|
||||
#include <stdio.h>
|
||||
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
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<double>(node.value));
|
||||
else if (node.type == XTC_NODE_GAIN) std::snprintf(text, sizeof(text), "x %.2f", static_cast<double>(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;
|
||||
|
|
|
|||
|
|
@ -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<MainWindow *>(c)->mark_dirty(); }, this);
|
||||
add_track->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_track(); }, sequencer_);
|
||||
add_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_region(); }, sequencer_);
|
||||
|
|
@ -115,6 +122,10 @@ MainWindow::MainWindow(bool prompt_unsaved)
|
|||
output->callback([](Fl_Widget *, void *context) {
|
||||
(void)static_cast<GraphWidget *>(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<GraphWidget *>(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<GraphWidget *>(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<MainWindow *>(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<MainWindow *>(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<MainWindow *>(context);
|
||||
if (window->recorder_) { window->report_error("Stop recording before file operations", XTC_ERR_INVALID_STATE); return; }
|
||||
auto *menu = static_cast<Fl_Menu_Bar *>(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<MainWindow *>(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<size_t>(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<MainWindow *>(context);
|
||||
auto *menu = dynamic_cast<Fl_Menu_Bar *>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<xtc_project, decltype(&xtc_project_destroy)> 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;
|
||||
|
|
|
|||
|
|
@ -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<int>(cursor_ * static_cast<unsigned int>(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<uint64_t>(start),
|
||||
static_cast<uint64_t>(length), static_cast<uint64_t>(offset)));
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#include "project_internal.h"
|
||||
#include "xtc/xtc_audio.h"
|
||||
#include "../graph/graph_internal.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<unsigned int>(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;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <unistd.h>
|
||||
|
||||
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.
|
||||
|
|
|
|||
90
tests/integration/test_jack_audio.c
Normal file
90
tests/integration/test_jack_audio.c
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "xtc/xtc_backend.h"
|
||||
#include <jack/jack.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
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;
|
||||
}
|
||||
169
tests/unit/test_audio.c
Normal file
169
tests/unit/test_audio.c
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "xtc/xtc_audio.h"
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#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);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "xtc/xtc_project.h"
|
||||
#include "xtc/xtc_audio.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue