1
0
Fork 0
forked from hanez/xtc

Phase 11: Complete and verify the minimal music-making workflow

This commit is contained in:
Johannes Findeisen 2026-10-27 03:25:04 +01:00
commit 461689dd44
12 changed files with 463 additions and 25 deletions

View file

@ -192,3 +192,13 @@ Clang static analysis of all core sources. These need installed development
tools; the allocation guard uses a GNU-compatible linker. See
[RELIABILITY.md](../../docs/RELIABILITY.md) for ownership, audit findings,
full verification commands and remaining limits.
## Phase 11 composition workflow
`make test-workflow` builds and runs `test_workflow.cpp` under Xvfb with the real
JACK-compatible backend. It requires an already running server. It creates and
arranges MIDI and imported audio through GUI commands, saves/closes/destroys the
window, reopens in another window, compares stereo rendering exactly, verifies
playhead/meters and pause/resume/seek/home, and continues editing/saving.
Mock GUI tests inject failed stop/seek/project preparation and recording guards.
See [WORKFLOW.md](../../docs/WORKFLOW.md) for acceptance details and limitations.

View file

@ -21,6 +21,7 @@
struct xtc_backend { int running = 0; xtc_plan *plan = nullptr; xtc_audio_plan *audio = nullptr; xtc_midi_plan *midi = nullptr; xtc_recorder *recorder = nullptr; };
namespace {
xtc_backend handle;
xtc_result seek_result = XTC_OK;
xtc_result open_result = XTC_OK, start_result = XTC_OK, stop_result = XTC_OK, close_result = XTC_OK;
xtc_result status_result = XTC_OK, submit_result = XTC_OK;
unsigned int submissions = 0U;
@ -80,7 +81,7 @@ void reset()
{
CHECK(alive == 0U);
open_result = start_result = stop_result = close_result = status_result = XTC_OK;
submit_result = XTC_OK;
submit_result = seek_result = XTC_OK;
engine_status = {};
engine_status.sample_rate = 48000U;
engine_status.buffer_frames = 1024U;
@ -115,7 +116,7 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status
return status_result;
}
xtc_result xtc_backend_seek(xtc_backend *, uint64_t sample)
{ engine_status.sample_position = sample; return handle.running ? XTC_ERR_INVALID_STATE : XTC_OK; }
{ if (handle.running) return XTC_ERR_INVALID_STATE; if (seek_result != XTC_OK) return seek_result; engine_status.sample_position = sample; return XTC_OK; }
xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph)
{
CHECK(backend == &handle && graph != nullptr);
@ -167,6 +168,26 @@ int main(int argc, char **argv)
CHECK(contains(&window, "running — modular graph"));
press(stop);
CHECK(stops == 1U && play->active() && !stop->active());
auto *pause = find(&window, "Pause");
auto *home = find(&window, "To start");
CHECK(pause && home && !pause->active() && home->active());
engine_status.sample_position = 48000;
press(play);
CHECK(pause->active());
CHECK(window.pause_playback() == XTC_OK && engine_status.sample_position == 48000);
CHECK(timeline(&window)->position() == 1920 && play->active() && !pause->active());
press(play);
CHECK(engine_status.sample_position == 48000); // Resume does not rewind.
stop_result = XTC_ERR_IO;
CHECK(window.return_to_start() == XTC_ERR_IO && engine_status.sample_position == 48000);
CHECK(!play->active() && stop->active());
stop_result = XTC_OK;
seek_result = XTC_ERR_IO;
CHECK(window.return_to_start() == XTC_ERR_IO && engine_status.sample_position == 48000);
CHECK(play->active() && !stop->active());
seek_result = XTC_OK;
press(home);
CHECK(engine_status.sample_position == 0 && timeline(&window)->position() == 0);
start_result = XTC_ERR_IO;
press(play);
CHECK(contains(&window, "Start engine failed: I/O error"));
@ -277,8 +298,10 @@ int main(int argc, char **argv)
CHECK(window.save_project(filename.c_str()) == XTC_OK);
const xtc_project *retained = sequence->project();
CHECK(window.open_project("/missing/xtc-project") == XTC_ERR_IO && sequence->project() == retained);
press(play);
submit_result = XTC_ERR_CAPACITY;
CHECK(window.open_project(filename.c_str()) == XTC_ERR_CAPACITY && sequence->project() == retained);
CHECK(play->active() && !stop->active()); // Failed install refreshed stopped state.
submit_result = XTC_OK;
press(find(&window, "Delete region/track"));
CHECK(window.open_project(filename.c_str()) == XTC_OK);
@ -292,6 +315,10 @@ int main(int argc, char **argv)
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);
CHECK(!pause->active() && !home->active());
CHECK(window.pause_playback() == XTC_ERR_INVALID_STATE);
CHECK(window.return_to_start() == XTC_ERR_INVALID_STATE);
CHECK(window.new_project() == 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;

View file

@ -10,12 +10,12 @@
#include <unistd.h>
namespace {
Fl_Widget *find(Fl_Group &group, const char *label)
Fl_Widget *find(Fl_Group &group, const char *label, bool exact = false)
{
for (int i = 0; i < group.children(); ++i) {
Fl_Widget *child = group.child(i);
if (child->label() != nullptr && std::strstr(child->label(), label) != nullptr) return child;
if (auto *nested = dynamic_cast<Fl_Group *>(child)) if (auto *match = find(*nested, label)) return match;
if (child->label() != nullptr && (exact ? std::strcmp(child->label(), label) == 0 : std::strstr(child->label(), label) != nullptr)) return child;
if (auto *nested = dynamic_cast<Fl_Group *>(child)) if (auto *match = find(*nested, label, exact)) return match;
}
return nullptr;
}
@ -107,7 +107,7 @@ int main(int argc, char **argv)
CHECK(window.open_project(song.c_str()) == XTC_OK);
play->do_callback(); pump();
CHECK(find(window, "running — modular graph") != nullptr);
auto *add_midi = find(window, "Add MIDI clip"); CHECK(add_midi != nullptr);
auto *add_midi = find(window, "Add MIDI clip", true); CHECK(add_midi != nullptr);
add_midi->do_callback(); CHECK(window.open_piano_roll() == XTC_OK);
CHECK(window.piano_roll()->add_note(0, 960, 69) == XTC_OK);
CHECK(window.save_project(song.c_str()) == XTC_OK);

View file

@ -0,0 +1,169 @@
/* Full GUI workflow against a running JACK-compatible server. All edits use
* widget commands/gestures; public C render APIs compare saved musical output. */
#include "../../src/gui/main_window.hpp"
#include "../../src/gui/sequencer_widget.hpp"
#include "../../src/gui/piano_roll.hpp"
#include "../../src/gui/mixer_widget.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Group.H>
#include <FL/Fl_Tabs.H>
#include <FL/Fl_Progress.H>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
#include <unistd.h>
#define CHECK(c) do { if (!(c)) { std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); std::exit(1); } } while (0)
template<class T> static T *widget(Fl_Group &group)
{
for (int i = 0; i < group.children(); ++i) {
if (auto *match = dynamic_cast<T *>(group.child(i))) return match;
if (auto *nested = dynamic_cast<Fl_Group *>(group.child(i)))
if (auto *match = widget<T>(*nested)) return match;
}
return nullptr;
}
static Fl_Widget *button(Fl_Group &group, const std::string &label)
{
for (int i = 0; i < group.children(); ++i) {
auto *child = group.child(i);
if (child->label() && label == child->label()) return child;
if (auto *nested = dynamic_cast<Fl_Group *>(child))
if (auto *match = button(*nested, label)) return match;
}
return nullptr;
}
static void press(MainWindow &window, const char *label)
{
auto *control = button(window, label);
CHECK(control && control->active());
control->do_callback();
}
static void pump()
{
auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(400);
while (std::chrono::steady_clock::now() < end) Fl::wait(0.02);
}
static void gesture(SequencerWidget &timeline, int x, int y, int delta)
{
int saved_x = Fl::e_x, saved_y = Fl::e_y, saved_key = Fl::e_keysym;
Fl::e_x = timeline.x() + x; Fl::e_y = timeline.y() + y;
Fl::e_keysym = FL_Button + FL_LEFT_MOUSE;
CHECK(timeline.handle(FL_PUSH) == 1);
Fl::e_x += delta;
(void)timeline.handle(FL_DRAG);
(void)timeline.handle(FL_RELEASE);
Fl::e_x = saved_x; Fl::e_y = saved_y; Fl::e_keysym = saved_key;
}
static std::vector<float> render(const xtc_project *project)
{
xtc_plan *graph = nullptr;
xtc_audio_plan *audio = nullptr;
xtc_midi_plan *midi = nullptr;
CHECK(xtc_graph_compile(xtc_project_graph(project), 48000, 256, &graph) == XTC_OK);
CHECK(xtc_audio_plan_create(project, 48000, &audio) == XTC_OK);
CHECK(xtc_midi_plan_create(project, 48000, &midi) == XTC_OK);
std::vector<float> result;
for (uint64_t pos = 0; pos < 96000; pos += 256) {
float al[256], ar[256], ml[256], mr[256], left[256], right[256];
CHECK(xtc_audio_plan_render_stereo(audio, pos, al, ar, 256) == XTC_OK);
CHECK(xtc_midi_plan_render_stereo(midi, pos, nullptr, 0, ml, mr, 256) == XTC_OK);
CHECK(xtc_plan_process_stereo(graph, nullptr, al, ar, ml, mr, left, right, 256) == XTC_OK);
for (size_t i = 0; i < 256; ++i) {
CHECK(std::isfinite(left[i]) && std::isfinite(right[i]));
result.push_back(left[i]); result.push_back(right[i]);
}
}
xtc_plan_destroy(graph); xtc_audio_plan_destroy(audio); xtc_midi_plan_destroy(midi);
return result;
}
int main(int argc, char **argv)
{
char directory[] = "/tmp/xtc-workflow-XXXXXX";
CHECK(mkdtemp(directory));
std::string path = std::string(directory) + "/song.xtc";
std::string wave = std::string(directory) + "/source.wav";
xtc_recorder *recorder = nullptr;
CHECK(xtc_recorder_create(wave.c_str(), 48000, 256, &recorder) == XTC_OK);
float source[256]; for (float &sample : source) sample = 0.125f;
xtc_recorder_push(recorder, source, 256);
CHECK(xtc_recorder_finish(recorder) == XTC_OK);
xtc_recorder_destroy(recorder);
std::vector<float> expected;
xtc_clip_info arranged{};
size_t nodes, edges;
{
MainWindow window(false);
window.show(argc, argv);
auto *timeline = widget<SequencerWidget>(window);
auto *tabs = widget<Fl_Tabs>(window);
CHECK(timeline && tabs && std::string(tabs->value()->label()) == "Sequencer");
CHECK(window.new_project() == XTC_OK);
press(window, "Add track");
press(window, "Add MIDI clip");
press(window, "Piano roll");
CHECK(window.piano_roll());
CHECK(window.piano_roll()->add_note(0, 960, 60) == XTC_OK);
CHECK(window.piano_roll()->add_note(960, 960, 64) == XTC_OK);
gesture(*timeline, 150, 50, 96); // Move the clip two beats using its body.
CHECK(xtc_project_get_clip(timeline->project(), 0, &arranged) == XTC_OK);
CHECK(arranged.start_tick == 1920 && arranged.length_tick == 3840);
CHECK(window.import_audio(wave.c_str()) == XTC_OK);
expected = render(timeline->project());
CHECK(expected[0] > 0); // Imported audio is routed without graph-file edits.
CHECK(expected[2 * 48001] != 0); // Arranged notes also reach the output.
CHECK(xtc_graph_get_counts(xtc_project_graph(timeline->project()), &nodes, &edges) == XTC_OK);
CHECK(nodes == 3 && edges == 2); // Audio and instrument -> output.
CHECK(window.save_project(path.c_str()) == XTC_OK);
window.do_callback(); CHECK(!window.shown());
} // Destroy all widgets/engine state before opening a fresh window.
{
MainWindow window(false);
window.show(argc, argv);
auto *timeline = widget<SequencerWidget>(window);
widget<Fl_Tabs>(window)->value(widget<Fl_Tabs>(window)->child(2));
CHECK(window.open_project(path.c_str()) == XTC_OK);
CHECK(widget<Fl_Tabs>(window)->value() == widget<Fl_Tabs>(window)->child(0));
CHECK(render(timeline->project()) == expected);
xtc_clip_info reopened{};
CHECK(xtc_project_get_clip(timeline->project(), 0, &reopened) == XTC_OK);
CHECK(reopened.id == arranged.id && reopened.track_id == arranged.track_id &&
reopened.start_tick == arranged.start_tick && reopened.length_tick == arranged.length_tick);
size_t actual_nodes, actual_edges;
CHECK(xtc_graph_get_counts(xtc_project_graph(timeline->project()), &actual_nodes, &actual_edges) == XTC_OK);
CHECK(actual_nodes == nodes && actual_edges == edges);
gesture(*timeline, 216, 10, 0); // Ruler seeks two beats while stopped.
CHECK(timeline->position() == 1920);
press(window, "Play"); pump();
CHECK(!button(window, "Play")->active() && button(window, "Pause")->active());
CHECK(timeline->position() > 1920);
auto *left_meter = dynamic_cast<Fl_Progress *>(button(window, "Output L"));
auto *right_meter = dynamic_cast<Fl_Progress *>(button(window, "Output R"));
CHECK(left_meter && right_meter && left_meter->value() > 0 && right_meter->value() > 0);
CHECK(window.pause_playback() == XTC_OK);
CHECK(left_meter->value() == 0 && right_meter->value() == 0);
uint64_t paused = timeline->position(); pump();
CHECK(timeline->position() == paused);
press(window, "Play"); pump();
CHECK(timeline->position() > paused);
press(window, "Stop");
paused = timeline->position(); pump(); CHECK(timeline->position() == paused);
press(window, "To start"); CHECK(timeline->position() == 0);
press(window, "Add MIDI clip");
press(window, "Piano roll");
CHECK(window.piano_roll()->add_note(0, 480, 67) == XTC_OK);
CHECK(window.undo() == XTC_OK && window.undo(true) == XTC_OK);
CHECK(window.save_project(path.c_str()) == XTC_OK);
CHECK(window.new_project() == XTC_OK);
CHECK(window.open_project(path.c_str()) == XTC_OK);
size_t notes;
CHECK(xtc_project_note_count(timeline->project(), &notes) == XTC_OK && notes == 3);
CHECK(widget<Fl_Tabs>(window)->value() == widget<Fl_Tabs>(window)->child(0));
window.do_callback(); CHECK(!window.shown());
}
CHECK(unlink(path.c_str()) == 0 && unlink(wave.c_str()) == 0 && rmdir(directory) == 0);
std::puts("GUI create/arrange/route/save/close/reopen/render/play/pause/seek/edit workflow passed.");
}