Phase 6: Add WAV clips, graph playback and bounded recording

This commit is contained in:
Johannes Findeisen 2026-10-27 00:31:03 +01:00
commit 8a37090b81
40 changed files with 1450 additions and 69 deletions

View file

@ -13,7 +13,7 @@ CXXFLAGS ?= -O2 -g
CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude
LDFLAGS ?=
CORE_SOURCES := src/core/xtc.c src/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

View file

@ -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.

View file

@ -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.

View file

@ -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
View 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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View 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
View 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

View file

@ -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);

View file

@ -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. */

View file

@ -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. */

View file

@ -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
View 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
View 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
View 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
View 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;
}

View 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

View file

@ -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. */

View file

@ -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); }

View file

@ -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); }

View file

@ -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;
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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.

View file

@ -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;
}

View file

@ -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.

View file

@ -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;
}

View file

@ -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.

View file

@ -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;

View file

@ -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.

View 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
View 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;
}

View file

@ -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);

View file

@ -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. */