Phase 7: Add MIDI clips, instrument and piano roll

This commit is contained in:
Johannes Findeisen 2026-10-27 00:53:41 +01:00
commit cacc086805
38 changed files with 1373 additions and 74 deletions

View file

@ -119,3 +119,19 @@ 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.
## Phase 7 verification
```sh
make all test gui debug test-gui test-gui-jack test-jack-audio test-jack-midi
make BUILD_DIR=build/nojack PKG_CONFIG=false all test gui test-gui
```
`test_midi` adds exact sample scheduling, trim/seek/panic, block independence,
voice capacities, stable notes and v3 malformed-file/roundtrip/v2 migration
coverage. Backend fault injection checks event offsets, dropped/malformed input,
disconnection, xruns, missing output buffers and stop/restart cleanup. GUI tests
draw/move/resize notes, save/reopen and render them. The live MIDI target connects
only software clients and checks on/off, panic, disconnect, and saved-note
playback/seek. It needs an existing JACK-compatible server. No hardware routes
or system changes are made. MIDI recording/SMF and physical devices are deferred.

View file

@ -1,6 +1,7 @@
#include "../../src/gui/main_window.hpp"
#include "../../src/gui/graph_widget.hpp"
#include "../../src/gui/sequencer_widget.hpp"
#include "../../src/gui/piano_roll.hpp"
#include <unistd.h>
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
@ -16,7 +17,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; xtc_audio_plan *audio = nullptr; xtc_recorder *recorder = nullptr; };
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 open_result = XTC_OK, start_result = XTC_OK, stop_result = XTC_OK, close_result = XTC_OK;
@ -59,7 +60,7 @@ SequencerWidget *timeline(Fl_Group *group)
}
return nullptr;
}
void gesture(SequencerWidget *s, int left, int top, int dx, int dy)
void gesture(Fl_Widget *s, int left, int top, int dx, int dy)
{
int old_x = Fl::e_x, old_y = Fl::e_y, old_key = Fl::e_keysym;
Fl::e_x = s->x() + left; Fl::e_y = s->y() + top; Fl::e_keysym = FL_Button + FL_LEFT_MOUSE;
@ -98,7 +99,7 @@ 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; xtc_audio_plan_destroy(handle.audio); handle.audio = 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; xtc_midi_plan_destroy(handle.midi); handle.midi = nullptr; }
return close_result;
}
xtc_result xtc_backend_start(xtc_backend *backend)
@ -126,13 +127,17 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph)
++engine_status.submitted_revision;
return XTC_OK;
}
xtc_result xtc_backend_panic(xtc_backend *) { xtc_midi_plan_panic(handle.midi, engine_status.sample_position); return XTC_OK; }
xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project)
{
xtc_audio_plan *plan = nullptr;
xtc_midi_plan *midi = nullptr;
xtc_result r = xtc_audio_plan_create(project, 48000, &plan);
if (r == XTC_OK) r = xtc_midi_plan_create(project, 48000, &midi);
if (r == XTC_OK) r = xtc_backend_submit(backend, xtc_project_graph(project));
if (r != XTC_OK) { xtc_audio_plan_destroy(plan); return r; }
if (r != XTC_OK) { xtc_audio_plan_destroy(plan); xtc_midi_plan_destroy(midi); return r; }
xtc_audio_plan_destroy(handle.audio); handle.audio = plan;
xtc_midi_plan_destroy(handle.midi); handle.midi = midi;
engine_status.asset_issues = static_cast<unsigned int>(xtc_audio_plan_issues(plan, nullptr));
return XTC_OK;
}
@ -300,6 +305,29 @@ int main(int argc, char **argv)
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);
press(find(&window, "Add MIDI clip"));
CHECK(window.open_piano_roll() == XTC_OK);
auto *roll = window.piano_roll(); CHECK(roll != nullptr);
int saved_x = Fl::e_x, saved_y = Fl::e_y, saved_key = Fl::e_keysym;
Fl::e_x = roll->x() + 112; Fl::e_y = roll->y() + 1110; Fl::e_keysym = FL_Button + FL_LEFT_MOUSE;
CHECK(roll->handle(FL_PUSH) == 1); (void)roll->handle(FL_RELEASE);
Fl::e_x = saved_x; Fl::e_y = saved_y; Fl::e_keysym = saved_key;
gesture(roll, 130, 1110, 24, -16); // Move one sixteenth and a semitone.
gesture(roll, 230, 1094, 48, 0); // Extend right edge by half a beat.
xtc_note_info drawn{}; size_t note_count;
CHECK(xtc_project_note_count(sequence->project(), &note_count) == XTC_OK && note_count == 1);
CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK &&
drawn.start_tick == 720 && drawn.length_tick == 1440 && drawn.pitch == 61);
CHECK(window.save_project(filename.c_str()) == XTC_OK);
CHECK(window.open_project(filename.c_str()) == XTC_OK);
CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && drawn.pitch == 61 && drawn.start_tick == 720);
float instrument[32], heard[32];
CHECK(xtc_midi_plan_render(handle.midi, 18000, nullptr, 0, instrument, 32) == XTC_OK && instrument[1] > 0);
CHECK(xtc_plan_process_sources(handle.plan, nullptr, nullptr, instrument, heard, 32) == XTC_OK && heard[1] == instrument[1]);
engine_status.sample_position = 18032;
press(find(&window, "Panic"));
CHECK(xtc_midi_plan_render(handle.midi, 18032, nullptr, 0, instrument, 32) == XTC_OK && instrument[0] == 0);
engine_status.sample_position = 0;
CHECK(unlink(filename.c_str()) == 0 && rmdir(directory) == 0);
engine_status.xruns = 3U;
engine_status.processed_blocks = 123U;

View file

@ -1,6 +1,7 @@
#include "../../src/gui/main_window.hpp"
#include "../../src/gui/graph_widget.hpp"
#include "../../src/gui/sequencer_widget.hpp"
#include "../../src/gui/piano_roll.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Group.H>
#include <chrono>
@ -106,6 +107,15 @@ 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);
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);
CHECK(window.open_project(song.c_str()) == XTC_OK);
size_t notes;
CHECK(xtc_project_note_count(timeline->project(), &notes) == XTC_OK && notes == 1);
play->do_callback(); pump();
CHECK(find(window, "Graph: active in engine") != 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());

View file

@ -0,0 +1,108 @@
#define _POSIX_C_SOURCE 200809L
#include "xtc/xtc_backend.h"
#include <jack/jack.h>
#include <jack/midiport.h>
#include <errno.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
typedef struct {
jack_port_t *input, *midi;
unsigned int command, seen, loud, 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);
void *midi = jack_port_get_buffer(p->midi, frames);
unsigned int command = __atomic_load_n(&p->command, __ATOMIC_ACQUIRE), loud = 0;
jack_nframes_t i;
if (!input || !midi) { __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED); return 0; }
jack_midi_clear_buffer(midi);
if (command && command != p->seen) {
unsigned char bytes[3] = {0x90, 69, command % 2 ? 127 : 0};
if (jack_midi_event_write(midi, frames > 8 ? 8 : 0, bytes, 3) != 0)
__atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED);
p->seen = command;
}
for (i = 0; i < frames; ++i) {
if (!isfinite(input[i]) || fabsf(input[i]) > 0.101f) __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED);
if (fabsf(input[i]) > 0.01f) loud = 1;
}
__atomic_store_n(&p->loud, loud, __ATOMIC_RELAXED);
if (loud) __atomic_store_n(&p->heard, 1U, __ATOMIC_RELAXED);
return 0;
}
static void wait_blocks(void)
{ struct timespec pause = {0, 200000000L}; nanosleep(&pause, NULL); }
#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-midi-XXXXXX", song[256] = "", name[64];
xtc_backend_config config = {XTC_BACKEND_JACK, name, 65536, 1};
xtc_backend *backend = NULL;
xtc_project *p = NULL, *q = NULL;
jack_client_t *client = NULL;
jack_status_t flags = 0;
probe state = {0};
const char *destination;
uint64_t track, clip, note;
int result = EXIT_FAILURE;
REQUIRE(mkdtemp(directory));
snprintf(song, sizeof(song), "%s/song.xtc", directory);
snprintf(name, sizeof(name), "xtc-midi-%ld", (long)getpid());
REQUIRE(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK);
REQUIRE(xtc_project_add_track(p, "Keys", &track) == XTC_OK);
REQUIRE(xtc_project_add_midi_clip(p, track, 0, 3840, &clip) == XTC_OK);
REQUIRE(xtc_backend_open(&config, &backend) == XTC_OK);
REQUIRE(xtc_backend_prepare_project(backend, p) == XTC_OK);
client = jack_client_open("xtc-midi-probe", JackNoStartServer, &flags); REQUIRE(client);
state.input = jack_port_register(client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
state.midi = jack_port_register(client, "midi", JACK_DEFAULT_MIDI_TYPE, JackPortIsOutput, 0);
REQUIRE(state.input && state.midi);
REQUIRE(jack_set_process_callback(client, process, &state) == 0 && jack_activate(client) == 0);
REQUIRE(xtc_backend_start(backend) == XTC_OK);
REQUIRE(xtc_backend_midi_port_name(backend, &destination) == XTC_OK);
wait_blocks(); /* PipeWire JACK publishes newly activated ports asynchronously. */
{
int connected = jack_connect(client, jack_port_name(state.midi), destination);
if (connected && connected != EEXIST) fprintf(stderr, "MIDI connection returned %d: %s -> %s\n", connected, jack_port_name(state.midi), destination);
REQUIRE(connected == 0 || connected == EEXIST);
}
REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK);
__atomic_store_n(&state.command, 1U, __ATOMIC_RELEASE); wait_blocks();
REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
__atomic_store_n(&state.command, 2U, __ATOMIC_RELEASE); wait_blocks();
REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
__atomic_store_n(&state.command, 3U, __ATOMIC_RELEASE); wait_blocks();
REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
REQUIRE(xtc_backend_panic(backend) == XTC_OK); wait_blocks();
REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
__atomic_store_n(&state.command, 5U, __ATOMIC_RELEASE); wait_blocks();
REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
REQUIRE(jack_disconnect(client, jack_port_name(state.midi), destination) == 0);
wait_blocks(); REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
REQUIRE(xtc_backend_stop(backend) == XTC_OK);
REQUIRE(xtc_project_add_note(p, clip, 0, 960, 69, 100, 0, &note) == 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);
wait_blocks(); REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
REQUIRE(xtc_backend_stop(backend) == XTC_OK);
REQUIRE(xtc_backend_seek(backend, 7680000) == XTC_OK && xtc_backend_start(backend) == XTC_OK);
REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK);
wait_blocks(); REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
REQUIRE(__atomic_load_n(&state.heard, __ATOMIC_RELAXED) && !__atomic_load_n(&state.bad, __ATOMIC_RELAXED));
puts("Live JACK MIDI input/note-off/panic and saved/reopened scheduled instrument playback passed.");
result = EXIT_SUCCESS;
cleanup:
if (backend && xtc_backend_close(backend) != XTC_OK) return EXIT_FAILURE;
if (client && jack_client_close(client) != 0) return EXIT_FAILURE;
xtc_project_destroy(p); xtc_project_destroy(q);
if (song[0]) unlink(song);
rmdir(directory);
return result;
}