2026-10-26 22:51:25 +01:00
|
|
|
#include "main_window.hpp"
|
2026-10-26 23:40:28 +01:00
|
|
|
#include "xtc/xtc_version.h"
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
#include <FL/Fl.H>
|
2026-10-26 22:51:25 +01:00
|
|
|
#include <FL/Fl_Box.H>
|
2026-10-26 23:40:28 +01:00
|
|
|
#include <FL/Fl_Button.H>
|
|
|
|
|
#include <FL/Fl_Group.H>
|
2026-10-26 22:51:25 +01:00
|
|
|
#include <FL/Fl_Menu_Bar.H>
|
|
|
|
|
#include <FL/Fl_Tabs.H>
|
2026-10-26 23:40:28 +01:00
|
|
|
#include <FL/fl_ask.H>
|
|
|
|
|
#include <cstdio>
|
|
|
|
|
#include <cstring>
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
namespace {
|
|
|
|
|
void set_label(Fl_Box *box, const char *text)
|
|
|
|
|
{
|
|
|
|
|
if (box->label() == nullptr || std::strcmp(box->label(), text) != 0)
|
|
|
|
|
box->copy_label(text);
|
|
|
|
|
}
|
2026-10-26 22:51:25 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
MainWindow::MainWindow()
|
2026-10-26 23:40:28 +01:00
|
|
|
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation")
|
2026-10-26 22:51:25 +01:00
|
|
|
{
|
|
|
|
|
begin();
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
tabs_ = new Fl_Tabs(8, 114, 984, 470);
|
|
|
|
|
tabs_->begin();
|
|
|
|
|
auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer");
|
2026-10-26 22:51:25 +01:00
|
|
|
sequencer->begin();
|
2026-10-26 23:40:28 +01:00
|
|
|
new Fl_Box(24, 164, 940, 40, "Sequencer placeholder — timeline and clips are not implemented yet.");
|
2026-10-26 22:51:25 +01:00
|
|
|
sequencer->end();
|
2026-10-26 23:40:28 +01:00
|
|
|
auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer");
|
2026-10-26 22:51:25 +01:00
|
|
|
mixer->begin();
|
2026-10-26 23:40:28 +01:00
|
|
|
new Fl_Box(24, 164, 940, 40, "Mixer placeholder — channels and meters are not implemented yet.");
|
2026-10-26 22:51:25 +01:00
|
|
|
mixer->end();
|
2026-10-26 23:40:28 +01:00
|
|
|
auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph");
|
2026-10-26 22:51:25 +01:00
|
|
|
graph->begin();
|
2026-10-26 23:40:28 +01:00
|
|
|
new Fl_Box(24, 164, 940, 40, "Modular graph placeholder — visual graph editing is not implemented yet.");
|
2026-10-26 22:51:25 +01:00
|
|
|
graph->end();
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 22:51:25 +01:00
|
|
|
end();
|
2026-10-26 23:40:28 +01:00
|
|
|
resizable(tabs_);
|
|
|
|
|
size_range(800, 540);
|
|
|
|
|
callback(close_callback, this);
|
|
|
|
|
open_backend();
|
|
|
|
|
Fl::add_timeout(0.1, timer_callback, this);
|
2026-10-26 22:51:25 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
MainWindow::~MainWindow()
|
2026-10-26 22:51:25 +01:00
|
|
|
{
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 22:51:25 +01:00
|
|
|
}
|