Connect FLTK shell to JACK engine controls and status
This commit is contained in:
parent
577d80f018
commit
2ecc03b482
14 changed files with 691 additions and 45 deletions
28
Makefile
28
Makefile
|
|
@ -34,7 +34,7 @@ FLTK_LIBS := $(shell $(FLTK_CONFIG) --ldflags)
|
|||
GUI_AVAILABLE := yes
|
||||
endif
|
||||
|
||||
.PHONY: all clean test test-jack debug gui install
|
||||
.PHONY: all clean test 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
|
||||
|
|
@ -94,9 +94,31 @@ endif
|
|||
ifeq ($(GUI_AVAILABLE),yes)
|
||||
gui: $(BUILD_DIR)/xtc
|
||||
|
||||
$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a
|
||||
$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a
|
||||
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) -lm
|
||||
|
||||
test-gui: $(BUILD_DIR)/test_gui
|
||||
xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui
|
||||
|
||||
ifeq ($(JACK_AVAILABLE),yes)
|
||||
$(BUILD_DIR)/test_gui_jack: $(BUILD_DIR)/tests/integration/test_gui_jack.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a
|
||||
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm
|
||||
|
||||
test-gui-jack: $(BUILD_DIR)/test_gui_jack
|
||||
xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui_jack
|
||||
else
|
||||
test-gui-jack:
|
||||
@echo "JACK development files are required for the live GUI test."
|
||||
@exit 1
|
||||
endif
|
||||
|
||||
$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a
|
||||
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm
|
||||
else
|
||||
test-gui-jack test-gui:
|
||||
@echo "FLTK development files are required for GUI tests."
|
||||
@exit 1
|
||||
|
||||
gui:
|
||||
@echo "FLTK was not found by pkg-config or fltk-config. GUI requires FLTK development files."
|
||||
@exit 1
|
||||
|
|
@ -111,4 +133,4 @@ clean:
|
|||
rm -rf "$(BUILD_DIR)"
|
||||
|
||||
-include $(CORE_OBJECTS:.o=.d) $(TEST_OBJECT:.o=.d) $(BUILD_DIR)/src/main.d $(BUILD_DIR)/tests/unit/test_offline.d $(BUILD_DIR)/tests/unit/test_realtime.d $(BUILD_DIR)/tests/unit/test_backend.d \
|
||||
$(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/tests/integration/test_jack.d
|
||||
$(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/tests/integration/test_jack.d $(BUILD_DIR)/tests/integration/test_gui.d $(BUILD_DIR)/tests/integration/test_gui_jack.d
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ This repository is a starter scaffold, not a production DAW. It currently provid
|
|||
- 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 GUI shell;
|
||||
- an optional FLTK shell with engine controls, view switching and backend status;
|
||||
- architecture, format, GUI, roadmap, and decision documents.
|
||||
|
||||
The JACK backend has passed software-loopback testing; physical-device playback
|
||||
|
|
@ -51,7 +51,11 @@ Run the GUI:
|
|||
./build/xtc
|
||||
```
|
||||
|
||||
The current GUI is only a shell and is not connected to the backend yet.
|
||||
The GUI opens JACK and offers Play/Stop/Reconnect, backend status and view
|
||||
switching. Play starts the engine in silence; playback content and editors are
|
||||
not implemented. 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. 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
|
||||
clients. See [engine documentation](docs/AUDIO_ENGINE.md) for configuration,
|
||||
|
|
|
|||
|
|
@ -43,3 +43,12 @@ the optional JACK implementation drives that same realtime engine.
|
|||
Only acknowledged retired plans are reclaimed during processing. Backend close
|
||||
synchronizes callbacks before reclaiming remaining plans. See decision 0004
|
||||
for the release/acquire protocol, bounded resource retention and recovery.
|
||||
|
||||
## GUI backend ownership
|
||||
|
||||
The Phase 3 window owns one C backend handle and serializes all control calls
|
||||
on the GUI thread. A GUI timer reads snapshots and collects retired resources;
|
||||
no audio callback calls FLTK. Successful window close first closes the JACK
|
||||
client (synchronizing callbacks), then removes the GUI timer and hides the
|
||||
window. Failed close leaves both handle and window available for retry. View
|
||||
switching is GUI state only; editors have no domain mutations yet. See GUI.md.
|
||||
|
|
|
|||
|
|
@ -131,5 +131,6 @@ internal realtime guarantees depend on the installed implementation/server.
|
|||
Validation commands and results are recorded in BUILD.md and the integration
|
||||
test README. Current limitations: mono input/output, no parameter ramping or
|
||||
crossfaded graph changes, no realtime meter bridge, no automatic reconnect,
|
||||
no physical-device or long-duration endurance qualification. GUI controls and
|
||||
status display are Phase 3; the GUI remains a placeholder shell.
|
||||
no physical-device or long-duration endurance qualification. Phase 3 connects
|
||||
the FLTK shell to backend controls and status; see GUI.md. Its editors remain
|
||||
placeholders and the GUI submits no playback graph yet.
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ make gui
|
|||
`all` builds the static C library and startup check. `test` runs the existing
|
||||
core/offline tests, concurrent plan-handoff stress, JACK fault injection
|
||||
(when JACK headers are available), and the startup check. `debug` builds and tests separately in
|
||||
`build/debug`. `gui` builds the existing placeholder shell; it does not produce
|
||||
audio. Generated files remain under `build` and header changes trigger rebuilds.
|
||||
`build/debug`. `gui` builds the FLTK shell with backend controls and status.
|
||||
The shell starts a silent engine and never connects physical ports. Generated files remain under `build` and header changes trigger rebuilds.
|
||||
Compiler, flags, `BUILD_DIR`, `PKG_CONFIG`, `FLTK_CONFIG`, and installation
|
||||
`PREFIX` can be overridden through Make variables. C uses C99; GUI uses C++17.
|
||||
|
||||
|
|
@ -76,8 +76,24 @@ fault-injection tests cover those notification paths. No packages installed
|
|||
or system/device configuration changed. Sanitizers are not enabled in the live
|
||||
callback qualification run.
|
||||
|
||||
## Phase 3 verification
|
||||
|
||||
```sh
|
||||
make clean all test gui debug test-gui test-gui-jack
|
||||
```
|
||||
|
||||
`test-gui` needs Xvfb and `xvfb-run`, uses a fake C backend and requires no JACK
|
||||
server. `test-gui-jack` needs a running server; it creates no physical routes.
|
||||
Both passed, including repeated active-client close and reconnect. The GUI also
|
||||
builds without JACK metadata and reports unsupported backend support at startup.
|
||||
A live keyboard/visual smoke run passed under Xvfb. Clang ASan/UBSan GUI tests
|
||||
passed with a suppression limited to the installed fontconfig library's
|
||||
exit-time font allocations; see tests/integration/README.md for the distinction
|
||||
and command. No compiler warnings remain with GCC or Clang. Core/backend tests
|
||||
pass with unsuppressed ASan/UBSan. No packages or system configuration changed.
|
||||
|
||||
## Next milestone
|
||||
|
||||
Phase 3 connects the FLTK shell to the backend C API for engine controls,
|
||||
status/error reporting and clean shutdown. Physical playback and endurance,
|
||||
project persistence and sequencing remain unverified/unimplemented as applicable.
|
||||
Phase 4 implements the modular graph editor and submits validated graph edits
|
||||
through the C API. Physical playback/endurance, project persistence and
|
||||
sequencing remain unverified/unimplemented as applicable.
|
||||
|
|
|
|||
46
docs/GUI.md
46
docs/GUI.md
|
|
@ -16,3 +16,49 @@ from project mutation. Never call FLTK from the audio thread.
|
|||
Visual direction: compact, high-contrast, restrained dark interface. Avoid
|
||||
excessive animation and modal configuration. Recreate broad usability ideas,
|
||||
not energyXT's exact artwork or proprietary resources.
|
||||
|
||||
## Phase 3 shell
|
||||
|
||||
The shell opens JACK on startup through `xtc_backend.h`. It never accesses
|
||||
backend structures, processes samples, creates domain objects, or calls JACK
|
||||
itself. All backend control calls run on the GUI thread; FLTK is never called
|
||||
from an audio callback. No server is started and no audio ports are connected
|
||||
by the GUI. With no graph submitted, the running backend produces silence.
|
||||
|
||||
- **Play / F5:** start the engine. **Stop / F8:** deactivate it. **Space:** toggle.
|
||||
These are engine controls, not timeline playback or transport positioning.
|
||||
- **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.
|
||||
- **View menu / Ctrl+1, Ctrl+2, Ctrl+3:** switch between sequencer, mixer and
|
||||
modular graph. Tabs provide the same view switching. Editors remain clearly
|
||||
labeled placeholders; no project editing is implied.
|
||||
- **File / Quit / Ctrl+Q** or the window close button: close the backend and
|
||||
synchronize audio callbacks before hiding/destroying widgets. A failed close
|
||||
leaves the window open for a retry. Unsupported New/Open/Save actions are
|
||||
visibly disabled. About identifies the development milestone and version.
|
||||
|
||||
The indicator shows disconnected, ready, running, server lost, format changed
|
||||
or process-error states. The status line shows rate, actual block size, mono
|
||||
layout, processed block count, xruns and process result. Command errors remain
|
||||
visible until a successful control action; periodic refresh does not erase them.
|
||||
A GUI-owned 100 ms timer reads atomic snapshots and collects retired plans.
|
||||
Label text changes only when needed. The timer is removed before successful
|
||||
close and again by the window destructor. No GUI locks are held across control
|
||||
calls. Operations such as opening JACK can briefly block the GUI; there is no
|
||||
worker/control-thread framework in this initial shell.
|
||||
|
||||
MainWindow owns the backend handle and FLTK owns the window's children. The
|
||||
destructor also attempts cleanup on exceptional event-loop exit; if JACK close
|
||||
itself fails then, it reports to stderr and retains the callback context until
|
||||
process exit. Normal close/reconnect paths permit retry and release ownership
|
||||
only after successful close. The entry point catches C++ exceptions before
|
||||
process exit. Backend operations return C error codes throughout.
|
||||
|
||||
Tests: `make test-gui` runs real widgets/callbacks/timers against a fake C backend
|
||||
under Xvfb, including control errors, startup without JACK, partially initialized
|
||||
handles, server/format notifications, view switching, retrying close, and timer
|
||||
removal. `make test-gui-jack` additionally requires a running JACK-compatible
|
||||
server, tests repeated start/stop, reconnect and active-client close, and creates
|
||||
no routes. Physical playback is outside this shell. See BUILD.md and the
|
||||
integration test README for sanitizer limitations and keyboard smoke results.
|
||||
|
|
|
|||
|
|
@ -26,12 +26,18 @@ format/xrun/loss status, start/stop/reopen, and bounded pending/retired plan
|
|||
handoff using lock-free atomics. Hardware-independent failure/concurrency tests
|
||||
and a 30-second software-loopback run pass. See decision 0004 and AUDIO_ENGINE.md.
|
||||
Physical hardware playback/endurance and live server-loss recovery remain
|
||||
unverified; no GUI engine integration until Phase 3.
|
||||
unverified; GUI engine controls are implemented in Phase 3.
|
||||
|
||||
## Phase 3 — FLTK shell
|
||||
Main window, transport controls, view switching, backend status, clean startup
|
||||
and shutdown through the C API.
|
||||
|
||||
Implemented: dark FLTK shell, Play/Stop/Reconnect, view/menu shortcuts, backend
|
||||
state/format/xrun/process status, persistent command errors, timer-based refresh
|
||||
and retirement collection, and callback-safe close/retry. Mock-backend GUI tests
|
||||
and live GUI/JACK start/stop/reconnect/close tests pass under Xvfb. Editors and
|
||||
project-file actions remain placeholders; engine runs in silence. See GUI.md.
|
||||
|
||||
## Phase 4 — Modular editor
|
||||
Draw nodes/ports/edges; create, move and connect nodes; reject invalid edges;
|
||||
safely submit edits to the C core.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
#include <FL/Fl.H>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <exception>
|
||||
#include "main_window.hpp"
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
MainWindow window;
|
||||
window.show(argc, argv);
|
||||
return Fl::run();
|
||||
try {
|
||||
Fl::scheme("flat");
|
||||
Fl::background(34, 36, 40);
|
||||
Fl::background2(24, 26, 30);
|
||||
Fl::foreground(230, 232, 236);
|
||||
MainWindow window;
|
||||
window.show(argc, argv);
|
||||
return Fl::run();
|
||||
} catch (const std::exception &error) {
|
||||
std::fprintf(stderr, "GUI startup/runtime failure: %s\n", error.what());
|
||||
} catch (...) {
|
||||
std::fputs("Unexpected GUI startup/runtime failure.\n", stderr);
|
||||
}
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,240 @@
|
|||
#include "main_window.hpp"
|
||||
#include "xtc/xtc_version.h"
|
||||
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/Fl_Box.H>
|
||||
#include <FL/Fl_Button.H>
|
||||
#include <FL/Fl_Group.H>
|
||||
#include <FL/Fl_Menu_Bar.H>
|
||||
#include <FL/Fl_Tabs.H>
|
||||
#include <FL/Fl_Group.H>
|
||||
#include <FL/fl_ask.H>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
extern "C" {
|
||||
#include "xtc/xtc.h"
|
||||
namespace {
|
||||
void set_label(Fl_Box *box, const char *text)
|
||||
{
|
||||
if (box->label() == nullptr || std::strcmp(box->label(), text) != 0)
|
||||
box->copy_label(text);
|
||||
}
|
||||
}
|
||||
|
||||
MainWindow::MainWindow()
|
||||
: Fl_Double_Window(1000, 680, "xtc — modular audio workstation")
|
||||
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation")
|
||||
{
|
||||
begin();
|
||||
auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28);
|
||||
menu->add("File/New (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
|
||||
menu->add("File/Open (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
|
||||
menu->add("File/Save (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE);
|
||||
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);
|
||||
menu->add("Audio/Reconnect", FL_F + 6, reconnect_callback, this);
|
||||
|
||||
Fl_Menu_Bar *menu = new Fl_Menu_Bar(0, 0, 1000, 28);
|
||||
menu->add("File/New", 0, nullptr);
|
||||
menu->add("File/Open...", 0, nullptr);
|
||||
menu->add("File/Save", 0, nullptr);
|
||||
menu->add("Help/About", 0, nullptr);
|
||||
play_ = new Fl_Button(12, 40, 80, 30, "Play");
|
||||
play_->callback(play_callback, this);
|
||||
play_->tooltip("Start the audio engine (F5 or Space). No sequencer content yet.");
|
||||
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");
|
||||
indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
||||
indicator_->labelfont(FL_BOLD);
|
||||
auto *hint = new Fl_Box(12, 78, 976, 24,
|
||||
"Engine controls only. Playback content and recording are not implemented; no ports are connected automatically.");
|
||||
hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
||||
hint->labelsize(12);
|
||||
|
||||
Fl_Tabs *tabs = new Fl_Tabs(8, 36, 984, 600);
|
||||
tabs->begin();
|
||||
|
||||
Fl_Group *sequencer = new Fl_Group(8, 60, 984, 576, "Sequencer");
|
||||
tabs_ = new Fl_Tabs(8, 114, 984, 470);
|
||||
tabs_->begin();
|
||||
auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer");
|
||||
sequencer->begin();
|
||||
new Fl_Box(24, 84, 940, 40,
|
||||
"Sequencer placeholder — timeline and clips are not implemented yet.");
|
||||
new Fl_Box(24, 164, 940, 40, "Sequencer placeholder — timeline and clips are not implemented yet.");
|
||||
sequencer->end();
|
||||
|
||||
Fl_Group *mixer = new Fl_Group(8, 60, 984, 576, "Mixer");
|
||||
auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer");
|
||||
mixer->begin();
|
||||
new Fl_Box(24, 84, 940, 40,
|
||||
"Mixer placeholder — channels and meters are not implemented yet.");
|
||||
new Fl_Box(24, 164, 940, 40, "Mixer placeholder — channels and meters are not implemented yet.");
|
||||
mixer->end();
|
||||
|
||||
Fl_Group *graph = new Fl_Group(8, 60, 984, 576, "Modular Graph");
|
||||
auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph");
|
||||
graph->begin();
|
||||
new Fl_Box(24, 84, 940, 40,
|
||||
"Modular graph placeholder — the C graph API is the current prototype.");
|
||||
new Fl_Box(24, 164, 940, 40, "Modular graph placeholder — visual graph editing is not implemented yet.");
|
||||
graph->end();
|
||||
tabs_->end();
|
||||
tabs_->value(sequencer);
|
||||
menu->add("View/Sequencer", FL_CTRL + '1', view_callback, sequencer);
|
||||
menu->add("View/Mixer", FL_CTRL + '2', view_callback, mixer);
|
||||
menu->add("View/Modular Graph", FL_CTRL + '3', view_callback, graph);
|
||||
menu->add("Help/About", 0, about_callback);
|
||||
|
||||
tabs->end();
|
||||
status_ = new Fl_Box(12, 594, 976, 26, "JACK: not connected");
|
||||
status_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
||||
status_->labelsize(12);
|
||||
error_ = new Fl_Box(12, 624, 976, 44, "");
|
||||
error_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP);
|
||||
error_->labelcolor(fl_rgb_color(255, 155, 120));
|
||||
error_->labelsize(12);
|
||||
end();
|
||||
resizable(tabs);
|
||||
resizable(tabs_);
|
||||
size_range(800, 540);
|
||||
callback(close_callback, this);
|
||||
open_backend();
|
||||
Fl::add_timeout(0.1, timer_callback, this);
|
||||
}
|
||||
|
||||
void MainWindow::show(int argc, char **argv)
|
||||
MainWindow::~MainWindow()
|
||||
{
|
||||
Fl_Double_Window::show(argc, argv);
|
||||
Fl::remove_timeout(timer_callback, this);
|
||||
// Normal window/menu close already synchronized callbacks. This fallback
|
||||
// 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);
|
||||
}
|
||||
|
||||
void MainWindow::report_error(const char *operation, xtc_result result)
|
||||
{
|
||||
char text[384];
|
||||
const char *hint = result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support." :
|
||||
"Check the JACK-compatible server and use Reconnect to retry.";
|
||||
std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint);
|
||||
set_label(error_, text);
|
||||
}
|
||||
|
||||
void MainWindow::open_backend()
|
||||
{
|
||||
xtc_backend_config config = {XTC_BACKEND_JACK, XTC_PROJECT_NAME, 65536U, 1U};
|
||||
xtc_result result = xtc_backend_open(&config, &backend_);
|
||||
ready_ = result == XTC_OK;
|
||||
running_ = false;
|
||||
if (result != XTC_OK) report_error("Open JACK failed", result);
|
||||
else set_label(error_, "");
|
||||
refresh_status();
|
||||
}
|
||||
|
||||
void MainWindow::start_engine()
|
||||
{
|
||||
if (!ready_) {
|
||||
report_error("Audio is disconnected; use Reconnect", XTC_ERR_INVALID_STATE);
|
||||
return;
|
||||
}
|
||||
if (running_) return;
|
||||
xtc_result result = xtc_backend_start(backend_);
|
||||
if (result != XTC_OK) report_error("Start engine failed", result);
|
||||
else set_label(error_, "");
|
||||
refresh_status();
|
||||
}
|
||||
|
||||
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_, "");
|
||||
refresh_status();
|
||||
}
|
||||
|
||||
void MainWindow::reconnect_engine()
|
||||
{
|
||||
xtc_result result = xtc_backend_close(backend_);
|
||||
if (result != XTC_OK) {
|
||||
report_error("Reconnect could not close JACK", result);
|
||||
return; // Preserve the existing handle for a later retry.
|
||||
}
|
||||
backend_ = nullptr;
|
||||
ready_ = false;
|
||||
running_ = false;
|
||||
open_backend();
|
||||
}
|
||||
|
||||
void MainWindow::refresh_status()
|
||||
{
|
||||
bool can_play = false;
|
||||
if (!ready_) {
|
||||
set_label(indicator_, "Audio: disconnected");
|
||||
set_label(status_, "JACK: not connected — use Reconnect after starting the server");
|
||||
indicator_->labelcolor(fl_rgb_color(255, 155, 120));
|
||||
} else {
|
||||
xtc_backend_status status = {};
|
||||
xtc_result result = xtc_backend_get_status(backend_, &status);
|
||||
if (result != XTC_OK) {
|
||||
report_error("Read engine status failed", result);
|
||||
set_label(indicator_, "Audio: status unavailable");
|
||||
set_label(status_, "Unable to read JACK status — use Reconnect");
|
||||
indicator_->labelcolor(fl_rgb_color(255, 155, 120));
|
||||
} else {
|
||||
running_ = status.running != 0;
|
||||
const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK;
|
||||
can_play = !running_ && !fault;
|
||||
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 (silence — no playback content)" : "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",
|
||||
status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns,
|
||||
xtc_result_string(status.process_result));
|
||||
set_label(status_, text);
|
||||
}
|
||||
xtc_backend_collect(backend_);
|
||||
}
|
||||
if (can_play) play_->activate(); else play_->deactivate();
|
||||
if (ready_ && running_) stop_->activate(); else stop_->deactivate();
|
||||
}
|
||||
|
||||
bool MainWindow::close_window()
|
||||
{
|
||||
xtc_result result = xtc_backend_close(backend_);
|
||||
if (result != XTC_OK) {
|
||||
report_error("Close JACK failed; window kept open for retry", result);
|
||||
return false;
|
||||
}
|
||||
backend_ = nullptr;
|
||||
ready_ = false;
|
||||
running_ = false;
|
||||
closing_ = true;
|
||||
Fl::remove_timeout(timer_callback, this);
|
||||
hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
int MainWindow::handle(int event)
|
||||
{
|
||||
if (event == FL_SHORTCUT && Fl::event_key() == ' ' &&
|
||||
(Fl::event_state() & (FL_CTRL | FL_ALT | FL_META)) == 0) {
|
||||
if (running_) stop_engine(); else start_engine();
|
||||
return 1;
|
||||
}
|
||||
return Fl_Double_Window::handle(event);
|
||||
}
|
||||
void MainWindow::play_callback(Fl_Widget *, void *context)
|
||||
{ static_cast<MainWindow *>(context)->start_engine(); }
|
||||
void MainWindow::stop_callback(Fl_Widget *, void *context)
|
||||
{ static_cast<MainWindow *>(context)->stop_engine(); }
|
||||
void MainWindow::reconnect_callback(Fl_Widget *, void *context)
|
||||
{ static_cast<MainWindow *>(context)->reconnect_engine(); }
|
||||
void MainWindow::close_callback(Fl_Widget *, void *context)
|
||||
{ (void)static_cast<MainWindow *>(context)->close_window(); }
|
||||
void MainWindow::view_callback(Fl_Widget *widget, void *view)
|
||||
{
|
||||
auto *window = static_cast<MainWindow *>(widget->window());
|
||||
window->tabs_->value(static_cast<Fl_Widget *>(view));
|
||||
window->tabs_->redraw();
|
||||
}
|
||||
void MainWindow::about_callback(Fl_Widget *, void *)
|
||||
{
|
||||
fl_message("%s %s\nModular audio workstation\nPhase 3: JACK engine controls; editors remain placeholders.",
|
||||
XTC_PROJECT_NAME, XTC_VERSION_STRING);
|
||||
}
|
||||
void MainWindow::timer_callback(void *context)
|
||||
{
|
||||
auto *window = static_cast<MainWindow *>(context);
|
||||
window->refresh_status();
|
||||
if (!window->closing_) Fl::repeat_timeout(0.1, timer_callback, context);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,47 @@
|
|||
#define XTC_MAIN_WINDOW_HPP
|
||||
|
||||
#include <FL/Fl_Double_Window.H>
|
||||
#include "xtc/xtc_backend.h"
|
||||
|
||||
class Fl_Box;
|
||||
class Fl_Button;
|
||||
class Fl_Tabs;
|
||||
|
||||
class MainWindow : public Fl_Double_Window {
|
||||
public:
|
||||
MainWindow();
|
||||
void show(int argc, char **argv);
|
||||
~MainWindow() override;
|
||||
MainWindow(const MainWindow &) = delete;
|
||||
MainWindow &operator=(const MainWindow &) = delete;
|
||||
using Fl_Double_Window::show;
|
||||
int handle(int event) override;
|
||||
|
||||
private:
|
||||
xtc_backend *backend_ = nullptr;
|
||||
bool ready_ = false;
|
||||
bool running_ = false;
|
||||
bool closing_ = false;
|
||||
Fl_Button *play_ = nullptr;
|
||||
Fl_Button *stop_ = nullptr;
|
||||
Fl_Box *indicator_ = nullptr;
|
||||
Fl_Box *status_ = nullptr;
|
||||
Fl_Box *error_ = nullptr;
|
||||
Fl_Tabs *tabs_ = nullptr;
|
||||
|
||||
void open_backend();
|
||||
void start_engine();
|
||||
void stop_engine();
|
||||
void reconnect_engine();
|
||||
void refresh_status();
|
||||
bool close_window();
|
||||
void report_error(const char *operation, xtc_result result);
|
||||
static void play_callback(Fl_Widget *, void *context);
|
||||
static void stop_callback(Fl_Widget *, void *context);
|
||||
static void reconnect_callback(Fl_Widget *, void *context);
|
||||
static void close_callback(Fl_Widget *, void *context);
|
||||
static void view_callback(Fl_Widget *widget, void *view);
|
||||
static void about_callback(Fl_Widget *, void *);
|
||||
static void timer_callback(void *context);
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -26,3 +26,40 @@ format change is performed on the shared server. Those paths, missing server,
|
|||
port/callback setup failure, activation/deactivation/close failure, null buffers,
|
||||
invalid configuration, oversize buffers, and reopen are tested with injected
|
||||
JACK callbacks/results in `tests/unit/test_backend.c`.
|
||||
|
||||
## FLTK shell tests
|
||||
|
||||
```sh
|
||||
make gui test-gui
|
||||
# Optional, requires a running JACK-compatible server:
|
||||
make test-gui-jack
|
||||
```
|
||||
|
||||
These targets require FLTK development files, `xvfb-run` and Xvfb. No additional
|
||||
GUI test library is needed. `test_gui.cpp` uses real widgets, menus and timers
|
||||
with a fake public C backend. It covers Play/Stop/Reconnect, view switching,
|
||||
persistent startup/control errors, server loss/rate changes, partial handles,
|
||||
close-failure retry and timer removal. All 18 opened handles are closed.
|
||||
`test_gui_jack.cpp` uses the real backend, repeats start/stop nine times across
|
||||
three windows, reconnects, and closes each window while running. No audio ports
|
||||
are connected. Both tests passed on the inspected host.
|
||||
|
||||
A separate xdotool smoke run against `build/xtc` under a 1280x800 Xvfb display
|
||||
exercised F5/F8/F6, Space, Ctrl+1/2/3 and Ctrl+Q. The app exited successfully;
|
||||
visual inspection confirmed running status and mixer view switching.
|
||||
|
||||
Clang ASan/UBSan found no application errors in the GUI tests. The unsuppressed
|
||||
run reports exit-time allocations originating in the installed fontconfig
|
||||
library used by FLTK/Pango. `lsan.supp` narrowly suppresses that library; leak
|
||||
checking stays enabled for application allocations. This is not a claim that
|
||||
the third-party font stack is leak-free. Apply only to GUI sanitizer tests:
|
||||
|
||||
```sh
|
||||
LSAN_OPTIONS=suppressions=tests/integration/lsan.supp xvfb-run -a -s '-screen 0 1280x800x24' ./build/gui-sanitize/test_gui
|
||||
```
|
||||
|
||||
Build that binary with Clang/Clang++, `-fsanitize=address,undefined` in both
|
||||
CFLAGS/CXXFLAGS and LDFLAGS, using the baseline warning/include/standard flags
|
||||
from BUILD.md. Keep FLTK's `-I` and `-D` flags from `fltk-config --cxxflags`,
|
||||
but exclude the distribution's GCC-specific LTO/object-code flags for Clang.
|
||||
No suppressions are used for core/backend/realtime sanitizer tests.
|
||||
|
|
|
|||
4
tests/integration/lsan.supp
Normal file
4
tests/integration/lsan.supp
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Installed FLTK/Pango/fontconfig retain font caches at process exit.
|
||||
# Keep leak detection active for application allocations and backend handles.
|
||||
# The unsuppressed GUI run reports allocation stacks in libfontconfig.so.1.
|
||||
leak:libfontconfig.so
|
||||
204
tests/integration/test_gui.cpp
Normal file
204
tests/integration/test_gui.cpp
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
#include "../../src/gui/main_window.hpp"
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/Fl_Box.H>
|
||||
#include <FL/Fl_Button.H>
|
||||
#include <FL/Fl_Group.H>
|
||||
#include <FL/Fl_Menu_Bar.H>
|
||||
#include <FL/Fl_Tabs.H>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
|
||||
/* 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; };
|
||||
namespace {
|
||||
xtc_backend handle;
|
||||
xtc_result open_result = XTC_OK, start_result = XTC_OK, stop_result = XTC_OK, close_result = XTC_OK;
|
||||
xtc_result status_result = XTC_OK;
|
||||
xtc_backend_status engine_status = {};
|
||||
bool partial_open = false;
|
||||
unsigned int opens = 0, starts = 0, stops = 0, closes = 0, collects = 0, alive = 0;
|
||||
int failures = 0;
|
||||
#define CHECK(c) do { if (!(c)) { std::fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); ++failures; } } while (0)
|
||||
void pump()
|
||||
{
|
||||
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(180);
|
||||
while (std::chrono::steady_clock::now() < end) Fl::wait(0.02);
|
||||
}
|
||||
Fl_Widget *find(Fl_Group *group, const char *label)
|
||||
{
|
||||
for (int i = 0; i < group->children(); ++i) {
|
||||
Fl_Widget *child = group->child(i);
|
||||
if (child->label() != nullptr && std::strcmp(child->label(), label) == 0) return child;
|
||||
auto *nested = dynamic_cast<Fl_Group *>(child);
|
||||
if (nested != nullptr) if (auto *match = find(nested, label)) return match;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
bool contains(Fl_Group *group, const char *text)
|
||||
{
|
||||
for (int i = 0; i < group->children(); ++i) {
|
||||
Fl_Widget *child = group->child(i);
|
||||
if (child->label() != nullptr && std::strstr(child->label(), text) != nullptr) return true;
|
||||
if (auto *nested = dynamic_cast<Fl_Group *>(child)) if (contains(nested, text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void press(Fl_Widget *widget)
|
||||
{
|
||||
CHECK(widget != nullptr);
|
||||
if (widget != nullptr) widget->do_callback();
|
||||
}
|
||||
void reset()
|
||||
{
|
||||
CHECK(alive == 0U);
|
||||
open_result = start_result = stop_result = close_result = status_result = XTC_OK;
|
||||
engine_status = {};
|
||||
engine_status.sample_rate = 48000U;
|
||||
engine_status.buffer_frames = 1024U;
|
||||
partial_open = false;
|
||||
}
|
||||
}
|
||||
extern "C" {
|
||||
xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out)
|
||||
{
|
||||
CHECK(config->kind == XTC_BACKEND_JACK && config->channel_count == 1U);
|
||||
++opens;
|
||||
*out = open_result == XTC_OK || partial_open ? &handle : nullptr;
|
||||
if (*out != nullptr) { ++alive; handle.running = 0; }
|
||||
return open_result;
|
||||
}
|
||||
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; }
|
||||
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; }
|
||||
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)
|
||||
{
|
||||
CHECK(backend == &handle && alive == 1U);
|
||||
*status = engine_status;
|
||||
status->running = handle.running && !status->server_lost;
|
||||
return status_result;
|
||||
}
|
||||
void xtc_backend_collect(xtc_backend *backend)
|
||||
{ CHECK(backend == &handle && alive == 1U); ++collects; }
|
||||
}
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
reset();
|
||||
{
|
||||
MainWindow window;
|
||||
window.show(argc, argv);
|
||||
auto *play = find(&window, "Play");
|
||||
auto *stop = find(&window, "Stop");
|
||||
auto *reconnect = find(&window, "Reconnect");
|
||||
CHECK(play != nullptr && stop != nullptr && reconnect != nullptr);
|
||||
if (play == nullptr || stop == nullptr || reconnect == nullptr) return 1;
|
||||
CHECK(play->active() && !stop->active());
|
||||
CHECK(contains(&window, "ready / stopped"));
|
||||
press(play);
|
||||
CHECK(starts == 1U && !play->active() && stop->active());
|
||||
CHECK(contains(&window, "running (silence"));
|
||||
press(stop);
|
||||
CHECK(stops == 1U && play->active() && !stop->active());
|
||||
start_result = XTC_ERR_IO;
|
||||
press(play);
|
||||
CHECK(contains(&window, "Start engine failed: I/O error"));
|
||||
pump(); // A refresh must not erase a command error.
|
||||
CHECK(contains(&window, "Start engine failed: I/O error"));
|
||||
start_result = XTC_OK;
|
||||
press(play);
|
||||
CHECK(!contains(&window, "Start engine failed"));
|
||||
stop_result = XTC_ERR_IO;
|
||||
press(stop);
|
||||
CHECK(contains(&window, "Stop engine failed") && stop->active());
|
||||
stop_result = XTC_OK;
|
||||
press(stop);
|
||||
Fl_Menu_Bar *menu = nullptr;
|
||||
Fl_Tabs *tabs = nullptr;
|
||||
for (int i = 0; i < window.children(); ++i) {
|
||||
if (auto *found = dynamic_cast<Fl_Menu_Bar *>(window.child(i))) menu = found;
|
||||
if (auto *found = dynamic_cast<Fl_Tabs *>(window.child(i))) tabs = found;
|
||||
}
|
||||
CHECK(menu != nullptr && tabs != nullptr);
|
||||
if (menu == nullptr || tabs == nullptr) return 1;
|
||||
for (const char *view : {"View/Mixer", "View/Modular Graph", "View/Sequencer"}) {
|
||||
const Fl_Menu_Item *item = menu->find_item(view);
|
||||
CHECK(item != nullptr);
|
||||
if (item != nullptr) {
|
||||
menu->picked(item);
|
||||
CHECK(std::strcmp(tabs->value()->label(), std::strchr(view, '/') + 1) == 0);
|
||||
}
|
||||
}
|
||||
engine_status.xruns = 3U;
|
||||
engine_status.processed_blocks = 123U;
|
||||
pump();
|
||||
CHECK(contains(&window, "123 blocks | 3 xruns"));
|
||||
engine_status.sample_rate_changed = 1;
|
||||
pump();
|
||||
CHECK(contains(&window, "sample rate changed") && !play->active());
|
||||
engine_status.sample_rate_changed = 0;
|
||||
engine_status.server_lost = 1;
|
||||
pump();
|
||||
CHECK(contains(&window, "server lost") && !play->active());
|
||||
engine_status.server_lost = 0;
|
||||
press(reconnect);
|
||||
CHECK(contains(&window, "ready / stopped") && play->active());
|
||||
status_result = XTC_ERR_IO;
|
||||
pump();
|
||||
CHECK(contains(&window, "status unavailable") && !play->active());
|
||||
status_result = XTC_OK;
|
||||
press(reconnect);
|
||||
close_result = XTC_ERR_IO;
|
||||
press(reconnect);
|
||||
CHECK(contains(&window, "Reconnect could not close JACK") && alive == 1U);
|
||||
window.do_callback();
|
||||
CHECK(window.shown() && alive == 1U && contains(&window, "window kept open for retry"));
|
||||
close_result = XTC_OK;
|
||||
menu->picked(menu->find_item("File/Quit"));
|
||||
CHECK(!window.shown() && alive == 0U);
|
||||
unsigned int before = collects;
|
||||
pump();
|
||||
CHECK(collects == before); // Removed timer cannot call a closed backend.
|
||||
}
|
||||
for (xtc_result failure : {XTC_ERR_IO, XTC_ERR_UNSUPPORTED}) {
|
||||
reset();
|
||||
open_result = failure;
|
||||
MainWindow window;
|
||||
window.show(argc, argv);
|
||||
CHECK(contains(&window, "Open JACK failed") && !find(&window, "Play")->active());
|
||||
open_result = XTC_OK;
|
||||
press(find(&window, "Reconnect"));
|
||||
CHECK(find(&window, "Play")->active());
|
||||
press(find(&window, "Play"));
|
||||
window.do_callback(); // Close while running, without a preceding stop.
|
||||
CHECK(!window.shown() && alive == 0U);
|
||||
}
|
||||
reset();
|
||||
partial_open = true;
|
||||
open_result = XTC_ERR_IO;
|
||||
{
|
||||
MainWindow window;
|
||||
CHECK(alive == 1U && !find(&window, "Play")->active());
|
||||
window.do_callback(); // Partially initialized handles are closed only.
|
||||
CHECK(alive == 0U);
|
||||
}
|
||||
reset();
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
MainWindow window;
|
||||
press(find(&window, "Play"));
|
||||
window.do_callback();
|
||||
}
|
||||
CHECK(alive == 0U);
|
||||
if (failures != 0) return 1;
|
||||
std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes);
|
||||
return 0;
|
||||
}
|
||||
60
tests/integration/test_gui_jack.cpp
Normal file
60
tests/integration/test_gui_jack.cpp
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#include "../../src/gui/main_window.hpp"
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/Fl_Group.H>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
Fl_Widget *find(Fl_Group &group, const char *label)
|
||||
{
|
||||
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;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
void pump()
|
||||
{
|
||||
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(400);
|
||||
while (std::chrono::steady_clock::now() < end) Fl::wait(0.02);
|
||||
}
|
||||
}
|
||||
#define CHECK(c) do { if (!(c)) { std::fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); return 1; } } while (0)
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
// Real JACK backend and real FLTK event loop. No routes, no playback data.
|
||||
for (int iteration = 0; iteration < 3; ++iteration) {
|
||||
MainWindow window;
|
||||
window.show(argc, argv);
|
||||
CHECK(find(window, "ready / stopped") != nullptr);
|
||||
auto *play = find(window, "Play");
|
||||
auto *stop = find(window, "Stop");
|
||||
auto *reconnect = find(window, "Reconnect");
|
||||
CHECK(play != nullptr && stop != nullptr && reconnect != nullptr);
|
||||
for (int restart = 0; restart < 3; ++restart) {
|
||||
CHECK(play->active());
|
||||
play->do_callback();
|
||||
pump();
|
||||
CHECK(find(window, "running (silence") != nullptr);
|
||||
auto *status = find(window, "JACK |");
|
||||
CHECK(status != nullptr);
|
||||
unsigned int rate = 0U, frames = 0U, blocks = 0U;
|
||||
CHECK(std::sscanf(status->label(), "JACK | %u Hz | %u frames | mono | %u blocks", &rate, &frames, &blocks) == 3);
|
||||
CHECK(rate > 0U && frames > 0U && blocks > 0U);
|
||||
stop->do_callback();
|
||||
CHECK(find(window, "ready / stopped") != nullptr);
|
||||
}
|
||||
reconnect->do_callback();
|
||||
CHECK(find(window, "ready / stopped") != nullptr);
|
||||
play->do_callback();
|
||||
pump();
|
||||
CHECK(find(window, "running (silence") != nullptr);
|
||||
window.do_callback(); // Close with an active client; synchronizes callbacks.
|
||||
CHECK(!window.shown());
|
||||
pump(); // No timer may access the closed client.
|
||||
}
|
||||
std::puts("Live GUI/JACK start/stop/reconnect/active-close tests passed.");
|
||||
return 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue