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

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