Phase 11: Complete and verify the minimal music-making workflow
This commit is contained in:
parent
8dd96d3cc5
commit
461689dd44
12 changed files with 463 additions and 25 deletions
15
Makefile
15
Makefile
|
|
@ -257,3 +257,18 @@ analyze:
|
|||
$(PYTHON) tools/analyze.py $(CORE_SOURCES)
|
||||
|
||||
-include $(BUILD_DIR)/tests/integration/test_reliability.d
|
||||
|
||||
.PHONY: test-workflow
|
||||
ifeq ($(GUI_AVAILABLE)$(JACK_AVAILABLE),yesyes)
|
||||
$(BUILD_DIR)/test_workflow: $(BUILD_DIR)/tests/integration/test_workflow.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/src/gui/mixer_widget.o $(BUILD_DIR)/libxtc.a
|
||||
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm
|
||||
|
||||
test-workflow: $(BUILD_DIR)/test_workflow
|
||||
xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_workflow
|
||||
else
|
||||
test-workflow:
|
||||
@echo "FLTK and JACK development files are required for the workflow test."
|
||||
@exit 1
|
||||
endif
|
||||
|
||||
-include $(BUILD_DIR)/tests/integration/test_workflow.d
|
||||
|
|
|
|||
|
|
@ -58,7 +58,10 @@ Run the GUI:
|
|||
./build/xtc
|
||||
```
|
||||
|
||||
The GUI opens JACK and offers Play/Stop/Reconnect, backend status and view
|
||||
See [the minimal music-making workflow](docs/WORKFLOW.md) for creating, saving,
|
||||
reopening and playing a project.
|
||||
|
||||
The GUI opens JACK and offers Play/Pause/Stop/To start/Reconnect, backend status and view
|
||||
switching. The initial empty graph is silent. Modular Graph supports adding,
|
||||
moving and deleting oscillator/audio/instrument/gain/meter/output nodes and connecting ports.
|
||||
Import WAV (Ctrl+I) creates playable clips; Add region creates silent placeholders.
|
||||
|
|
|
|||
|
|
@ -232,3 +232,10 @@ Clang static analysis of all core sources. These need installed development
|
|||
tools; the allocation guard uses a GNU-compatible linker. See
|
||||
[RELIABILITY.md](RELIABILITY.md) for ownership, audit findings,
|
||||
full verification commands and remaining limits.
|
||||
|
||||
## Phase 11 workflow acceptance
|
||||
|
||||
`make test-workflow` requires FLTK/JACK development files, Xvfb and an existing
|
||||
JACK-compatible server. It does not start/configure the server or connect
|
||||
physical devices. See [WORKFLOW.md](WORKFLOW.md) for the full GUI composition
|
||||
acceptance sequence and transport semantics.
|
||||
|
|
|
|||
18
docs/GUI.md
18
docs/GUI.md
|
|
@ -25,7 +25,8 @@ data is owned by opaque C objects, manipulated only through the C API. All backe
|
|||
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.
|
||||
- **Play / F5:** start the engine. **Stop / F8:** deactivate it. **Pause / F7:** hold playback. **Space:** toggle Play/Stop.
|
||||
**To start / Home:** stop and seek to zero. Pause/Home are disabled while recording.
|
||||
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
|
||||
|
|
@ -177,3 +178,18 @@ remove, and change First tick to navigate. Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y undo
|
|||
and redo up to 32 project edits; New/Open resets history. M/S and arrow keys
|
||||
provide mixer shortcuts. See MIXER.md for exact controls, history rollback,
|
||||
stereo port routing, automation semantics and prototype limits.
|
||||
|
||||
## Phase 11 minimal composition workflow
|
||||
|
||||
[WORKFLOW.md](WORKFLOW.md) walks through creating, arranging, saving, closing,
|
||||
reopening and playing MIDI/audio with the GUI. New/Open select Sequencer;
|
||||
Ctrl+3 still opens the modular graph. Stop retains its established position-hold
|
||||
behavior and finalizes recording; explicit Pause retains position without
|
||||
finalizing a take (and is refused during recording). Home stops and returns to
|
||||
zero. Failed candidate installation now refreshes the actual backend state
|
||||
even when the previous document is retained.
|
||||
|
||||
`make test-workflow` uses a live JACK-compatible backend and Xvfb, with exact
|
||||
stereo render comparisons across destruction/recreation of the GUI and live
|
||||
playhead/meter/transport checks. `test-gui` covers stop/seek failure and recording
|
||||
guards. No project files need manual editing for this workflow.
|
||||
|
|
|
|||
|
|
@ -166,6 +166,17 @@ within the documented contracts.
|
|||
- Common operations do not require manual project-file edits.
|
||||
- Transport and GUI state remain consistent during normal playback and error conditions.
|
||||
|
||||
Implemented: explicit Pause/F7 and To start/Home controls, position-preserving
|
||||
Stop compatibility, recording guards, sequencer selection on New/Open, first-use
|
||||
composition/routing guidance, and GUI status refresh after failed installation.
|
||||
A real GUI/backend workflow test creates MIDI and audio, arranges/routes, saves,
|
||||
closes/destroys the window, reopens in a fresh window, compares exact stereo
|
||||
rendering and stable clip/routing metadata, then plays/seeks/pauses/resumes,
|
||||
continues editing with undo/redo and saves/reopens again. Mock failure tests and
|
||||
live JACK/GUI/software MIDI tests pass. See WORKFLOW.md for instructions,
|
||||
transport semantics, test commands and limits. External playback routing remains
|
||||
explicit; physical-device listening and endurance remain unqualified.
|
||||
|
||||
## Phase 12 — Audio Quality and Transport Correctness
|
||||
|
||||
**Goal:** Make timing and audio output predictable and musically reliable.
|
||||
|
|
|
|||
98
docs/WORKFLOW.md
Normal file
98
docs/WORKFLOW.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Make and reopen a small project
|
||||
|
||||
The default workspace is Sequencer. New and Open return to this workspace;
|
||||
Mixer and Modular Graph remain available with Ctrl+2 and Ctrl+3. Start with an
|
||||
empty project at 120 BPM, 4/4. Clips default to one bar and piano-roll notes use
|
||||
velocity 100; no notes or test oscillators are inserted automatically.
|
||||
|
||||
## Create music
|
||||
|
||||
1. Start your existing JACK-compatible server, launch `xtc`, and check that audio
|
||||
says ready/stopped. If disconnected, start the server and use Reconnect/F6.
|
||||
2. Click Add track, then Add MIDI clip. Click Piano roll (Ctrl+4) and click the
|
||||
grid to draw notes. Drag notes to move them or their right edges to resize.
|
||||
Close the piano-roll window to return to the arrangement.
|
||||
3. Drag the clip body to arrange it; drag its edges to trim. Edits snap to
|
||||
sixteenth notes. Add further tracks/clips as needed. Add region creates a
|
||||
**silent placeholder**, rather than audio or MIDI.
|
||||
4. Optionally use File/Import WAV (Ctrl+I). The file is placed at the current
|
||||
cursor on the selected track; without a selected track an Audio track is
|
||||
created. Keep the WAV available at its stored path.
|
||||
5. MIDI creation supplies an instrument-to-output route, and audio import
|
||||
supplies an audio-to-output route for a new project. No graph editing or
|
||||
project-file editing is required. Ctrl+3 shows these routes if you want to
|
||||
inspect or change them. Existing custom routes are respected; see MIDI.md
|
||||
and AUDIO_FILES.md for routing limits.
|
||||
6. In your existing JACK routing tool, connect xtc's `output` and `output_right`
|
||||
ports to left/right playback inputs. Press Play/F5. Mixer/Ctrl+2 shows output
|
||||
meters and track gain/pan/mute/solo. If meters move but nothing is audible,
|
||||
check external playback routing and device levels. xtc does not connect
|
||||
physical ports automatically or start/configure the server.
|
||||
|
||||
## Transport
|
||||
|
||||
| Command | Behavior |
|
||||
| --- | --- |
|
||||
| Play / F5 | Prepare the current arrangement and resume at the retained sample position. Already playing is a no-op. |
|
||||
| Pause / F7 | Deactivate playback and hold the sample position. Play resumes from there. Disabled during recording. |
|
||||
| Stop / F8 | Hold the sample position, preserving the established Stop behavior. During recording, finalize and add the take. |
|
||||
| Space | Toggle Play/Stop; while recording it finishes the take. |
|
||||
| To start / Home | Stop and seek to sample zero; remain stopped until Play. Disabled during recording. Also resets an offline cursor. |
|
||||
| Click timeline ruler | Seek on the sixteenth-note grid while stopped; during playback the seek is refused. |
|
||||
| New / Open / Reconnect | Stop and reset the position. New/Open show Sequencer; Reconnect leaves the document intact. |
|
||||
|
||||
The backend sample count drives the playhead; its display refreshes every 100 ms.
|
||||
Pause/Stop retain the exact sample position even though the ruler displays ticks.
|
||||
Arrangement, note and mixer edits stop playback; graph edits can be adopted live.
|
||||
A failed stop leaves playback running and the controls reflect that. A failed
|
||||
return-to-start seek does not set the cursor to zero. If candidate project
|
||||
preparation fails after stopping/seeking, the old document stays loaded and the
|
||||
GUI refreshes to its actual stopped position (which may be zero). A parse failure
|
||||
occurs before installation and leaves playback alone.
|
||||
|
||||
## Save, close and continue
|
||||
|
||||
Use File/Save (Ctrl+S), choose a `.xtc` path and retain external WAV files. The
|
||||
unsaved title marker clears on success. File/Save As confirms overwrites.
|
||||
Close the window, launch xtc again and use File/Open (Ctrl+O), or run
|
||||
`xtc /path/to/song.xtc`. The arrangement, stable IDs, routing, notes, mixing and
|
||||
automation are restored; the transport starts stopped at zero. Continue drawing,
|
||||
importing or moving clips, use Ctrl+Z/Ctrl+Shift+Z for undo/redo, and save again.
|
||||
|
||||
New/Open/Quit ask before discarding unsaved edits. They are refused while
|
||||
recording: use Stop to finalize the take, then save. Missing media stays silent
|
||||
with a warning naming the first bad path; select its clip and use File/Relink
|
||||
selected audio. Failed file operations retain the document and show an error;
|
||||
check the path, format and permissions. Server loss or rate changes require
|
||||
Reconnect; this resets position, so seek again before resuming.
|
||||
|
||||
## Repeatable acceptance test
|
||||
|
||||
With installed FLTK/JACK development files, Xvfb and an already running
|
||||
JACK-compatible server:
|
||||
|
||||
```sh
|
||||
make all test gui test-gui test-workflow test-gui-jack test-jack-midi
|
||||
```
|
||||
|
||||
`test-workflow` drives real GUI commands and timeline gestures under Xvfb. It
|
||||
creates a track, draws two notes, arranges the MIDI clip two beats later, imports
|
||||
a generated WAV and verifies both automatic source routes. It saves and closes,
|
||||
destroys the first window, reopens in a fresh window and compares the complete
|
||||
stereo offline render exactly (including silence, imported audio and notes).
|
||||
It checks stable clip IDs/positions and graph counts, seeks through the ruler,
|
||||
plays on the real backend, observes nonzero stereo output meters and advancing
|
||||
playhead, pauses/resumes, stops, returns to start, edits/undoes/redoes, saves and
|
||||
reopens again. It uses temporary files only and cleans them on success.
|
||||
|
||||
`test-gui` injects stop/seek/prepare failures to verify controls, position and
|
||||
retained document state, plus recording guards. `test-gui-jack` exercises live
|
||||
graph changes, recording and restart/close; `test-jack-midi` checks software
|
||||
loopback output. The Phase 11 tests also pass ASan/UBSan with the existing
|
||||
fontconfig-only GUI leak suppression, and the GUI/mock suite builds/runs without
|
||||
JACK support. Core tests use no GUI leak suppression.
|
||||
|
||||
These checks cover a minimal composition workflow, not physical listening or
|
||||
endurance qualification. No new dependency, project-format change, plugin host,
|
||||
MIDI capture, tempo editor, loop playback, device-routing GUI, or persisted editor
|
||||
layout was introduced. See GUI.md, MIDI.md, MIXER.md and LINUX.md for details.
|
||||
|
|
@ -77,12 +77,19 @@ MainWindow::MainWindow(bool prompt_unsaved)
|
|||
menu->add("Audio/Stop engine", FL_F + 8, stop_callback, this);
|
||||
menu->add("Audio/Reconnect", FL_F + 6, reconnect_callback, this);
|
||||
|
||||
menu->add("Audio/Pause", FL_F + 7, [](Fl_Widget *, void *c) {
|
||||
(void)static_cast<MainWindow *>(c)->pause_playback();
|
||||
}, this);
|
||||
menu->add("Audio/Return to start", FL_Home, [](Fl_Widget *, void *c) {
|
||||
(void)static_cast<MainWindow *>(c)->return_to_start();
|
||||
}, this);
|
||||
|
||||
play_ = new Fl_Button(12, 40, 80, 30, "Play");
|
||||
play_->callback(play_callback, this);
|
||||
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).");
|
||||
stop_->tooltip("Stop playback at its current position; finalize recording (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).");
|
||||
|
|
@ -92,12 +99,23 @@ MainWindow::MainWindow(bool prompt_unsaved)
|
|||
auto *panic = new Fl_Button(404, 40, 70, 30, "Panic");
|
||||
panic->callback(panic_callback, this);
|
||||
panic->tooltip("Silence held instrument notes (F9).");
|
||||
indicator_ = new Fl_Box(482, 40, 506, 30, "Audio: disconnected");
|
||||
pause_ = new Fl_Button(482, 40, 70, 30, "Pause");
|
||||
pause_->callback([](Fl_Widget *, void *c) {
|
||||
(void)static_cast<MainWindow *>(c)->pause_playback();
|
||||
}, this);
|
||||
pause_->tooltip("Pause at the current sample position (F7). Play resumes; recording uses Stop.");
|
||||
home_ = new Fl_Button(560, 40, 100, 30, "To start");
|
||||
home_->callback([](Fl_Widget *, void *c) {
|
||||
(void)static_cast<MainWindow *>(c)->return_to_start();
|
||||
}, this);
|
||||
home_->tooltip("Stop and return to the project beginning (Home). Unavailable while recording.");
|
||||
indicator_ = new Fl_Box(668, 40, 320, 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; Import "
|
||||
"WAV clips or record JACK input; connect external ports explicitly.");
|
||||
indicator_->labelsize(12);
|
||||
auto *hint = new Fl_Box(12, 76, 976, 34,
|
||||
"Start: Add track, Add MIDI clip, then Piano roll to draw notes; or Import WAV (Ctrl+I).\n"
|
||||
"Play with F5. Connect JACK output/output_right to playback ports to hear audio.");
|
||||
hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
||||
hint->labelsize(12);
|
||||
|
||||
|
|
@ -115,7 +133,7 @@ MainWindow::MainWindow(bool prompt_unsaved)
|
|||
piano->callback(piano_callback, this);
|
||||
auto *timeline_hint =
|
||||
new Fl_Box(20, 548, 956, 28,
|
||||
"Import WAV (Ctrl+I) or Record. Drag body to move, edges to trim; ruler seeks while stopped.");
|
||||
"Drag clips to move, edges to trim. Click the ruler while stopped to seek; Home returns to start.");
|
||||
timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
||||
timeline_hint->labelsize(12);
|
||||
auto *timeline_scroll = new Fl_Scroll(20, 188, 956, 350);
|
||||
|
|
@ -411,6 +429,39 @@ void MainWindow::stop_engine()
|
|||
refresh_status();
|
||||
}
|
||||
|
||||
xtc_result MainWindow::pause_playback()
|
||||
{
|
||||
xtc_result result = recorder_ || !ready_ ? XTC_ERR_INVALID_STATE : xtc_backend_stop(backend_);
|
||||
if (result != XTC_OK)
|
||||
report_error("Pause failed; use Stop to finish recording or Reconnect audio", result);
|
||||
else
|
||||
set_label(error_, "");
|
||||
refresh_status();
|
||||
return result;
|
||||
}
|
||||
|
||||
xtc_result MainWindow::return_to_start()
|
||||
{
|
||||
if (recorder_) {
|
||||
report_error("Stop recording before returning to start", XTC_ERR_INVALID_STATE);
|
||||
return XTC_ERR_INVALID_STATE;
|
||||
}
|
||||
xtc_result result = XTC_OK;
|
||||
if (ready_) {
|
||||
result = xtc_backend_stop(backend_);
|
||||
if (result == XTC_OK)
|
||||
result = xtc_backend_seek(backend_, 0);
|
||||
}
|
||||
if (result != XTC_OK)
|
||||
report_error("Return to start failed", result);
|
||||
else {
|
||||
sequencer_->position(0);
|
||||
set_label(error_, "");
|
||||
}
|
||||
refresh_status();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Closes and reopens the JACK backend, leaving the engine stopped.
|
||||
*
|
||||
|
|
@ -501,6 +552,14 @@ void MainWindow::refresh_status()
|
|||
play_->activate();
|
||||
else
|
||||
play_->deactivate();
|
||||
if (ready_ && running_ && !recorder_)
|
||||
pause_->activate();
|
||||
else
|
||||
pause_->deactivate();
|
||||
if (!recorder_)
|
||||
home_->activate();
|
||||
else
|
||||
home_->deactivate();
|
||||
if (can_record)
|
||||
record_->activate();
|
||||
else
|
||||
|
|
@ -624,7 +683,7 @@ void MainWindow::view_callback(Fl_Widget *widget, void *view)
|
|||
*/
|
||||
void MainWindow::about_callback(Fl_Widget *, void *)
|
||||
{
|
||||
fl_message("%s %s\nModular audio workstation\nPhase 9: native C processors and Linux release preparation.",
|
||||
fl_message("%s %s\nModular audio workstation\nPhase 11: create, arrange, save, reopen and play.",
|
||||
XTC_PROJECT_NAME, XTC_VERSION_STRING);
|
||||
}
|
||||
|
||||
|
|
@ -776,6 +835,7 @@ xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_histor
|
|||
xtc_midi_plan_destroy(offline_midi);
|
||||
if (r != XTC_OK) {
|
||||
xtc_audio_plan_destroy(offline);
|
||||
refresh_status(); // A failed install may have stopped playback.
|
||||
return r;
|
||||
}
|
||||
std::string issue = ready_ ? xtc_backend_asset_issue(backend_) : "";
|
||||
|
|
@ -796,6 +856,8 @@ xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_histor
|
|||
graph_widget_->reset_layout();
|
||||
graph_widget_->set_graph(xtc_project_graph(candidate));
|
||||
sequencer_->set_project(candidate);
|
||||
if (reset_history)
|
||||
tabs_->value(tabs_->child(0));
|
||||
dirty_ = false;
|
||||
if (!issue.empty()) {
|
||||
char text[640];
|
||||
|
|
@ -837,6 +899,23 @@ xtc_result MainWindow::open_project(const char *path)
|
|||
return r;
|
||||
}
|
||||
|
||||
xtc_result MainWindow::new_project()
|
||||
{
|
||||
if (recorder_)
|
||||
return XTC_ERR_INVALID_STATE;
|
||||
xtc_project *candidate = nullptr;
|
||||
xtc_result result = xtc_project_create(&candidate);
|
||||
if (result == XTC_OK)
|
||||
result = install_project(candidate);
|
||||
if (result != XTC_OK)
|
||||
xtc_project_destroy(candidate);
|
||||
else {
|
||||
path_.clear();
|
||||
copy_label("xtc — Untitled");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief FLTK menu callback for the File/New, Open, Save, and Save As items.
|
||||
*
|
||||
|
|
@ -861,16 +940,7 @@ void MainWindow::file_callback(Fl_Widget *widget, void *context)
|
|||
if (std::strcmp(action, "New") == 0) {
|
||||
if (!window->discard_changes())
|
||||
return;
|
||||
xtc_project *candidate = nullptr;
|
||||
r = xtc_project_create(&candidate);
|
||||
if (r == XTC_OK)
|
||||
r = window->install_project(candidate);
|
||||
if (r != XTC_OK)
|
||||
xtc_project_destroy(candidate);
|
||||
else {
|
||||
window->path_.clear();
|
||||
window->copy_label("xtc — Untitled");
|
||||
}
|
||||
r = window->new_project();
|
||||
} else {
|
||||
bool open = std::strcmp(action, "Open") == 0;
|
||||
if (open && !window->discard_changes())
|
||||
|
|
|
|||
|
|
@ -145,6 +145,16 @@ class MainWindow : public Fl_Double_Window {
|
|||
*/
|
||||
xtc_result open_project(const char *path);
|
||||
|
||||
/** Creates a fresh untitled project; the File menu checks unsaved changes first.
|
||||
* On failure the current document remains owned by the window. */
|
||||
xtc_result new_project();
|
||||
/** Stops playback, retaining its exact sample position. Recording must be
|
||||
* finalized with Stop instead. Returns the backend failure, if any. */
|
||||
xtc_result pause_playback();
|
||||
/** Stops playback and seeks to zero. Refuses during recording. A failed
|
||||
* stop/seek is reported without moving the displayed cursor to zero. */
|
||||
xtc_result return_to_start();
|
||||
|
||||
private:
|
||||
MixerWidget *mixer_ = nullptr;
|
||||
std::unique_ptr<xtc_history, decltype(&xtc_history_destroy)> history_{nullptr, xtc_history_destroy};
|
||||
|
|
@ -283,6 +293,8 @@ class MainWindow : public Fl_Double_Window {
|
|||
Fl_Button *record_ = nullptr;
|
||||
Fl_Button *play_ = nullptr;
|
||||
Fl_Button *stop_ = nullptr;
|
||||
Fl_Button *pause_ = nullptr;
|
||||
Fl_Button *home_ = nullptr;
|
||||
Fl_Box *indicator_ = nullptr;
|
||||
Fl_Box *status_ = nullptr;
|
||||
Fl_Box *error_ = nullptr;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
169
tests/integration/test_workflow.cpp
Normal file
169
tests/integration/test_workflow.cpp
Normal 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(), ¬es) == 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.");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue