2026-10-26 22:51:25 +01:00
|
|
|
#include "main_window.hpp"
|
2026-10-26 23:55:35 +01:00
|
|
|
#include "graph_widget.hpp"
|
2026-10-27 00:10:45 +01:00
|
|
|
#include "sequencer_widget.hpp"
|
2026-10-27 00:53:41 +01:00
|
|
|
#include "piano_roll.hpp"
|
2026-10-27 01:06:57 +01:00
|
|
|
#include "mixer_widget.hpp"
|
2026-10-27 16:24:21 +01:00
|
|
|
#include "plugin_dialog.hpp"
|
2026-10-27 00:10:45 +01:00
|
|
|
#include <FL/Fl_Native_File_Chooser.H>
|
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 23:55:35 +01:00
|
|
|
#include <FL/Fl_Scroll.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 23:55:35 +01:00
|
|
|
#include <stdexcept>
|
2026-10-27 04:00:27 +01:00
|
|
|
#include <cstdlib>
|
|
|
|
|
#include <cmath>
|
|
|
|
|
#include <cerrno>
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
namespace {
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Updates a box label only when the text differs.
|
|
|
|
|
*
|
|
|
|
|
* Avoids redundant relayout and redraw when status text is refreshed
|
|
|
|
|
* repeatedly. The box takes a copy of the text.
|
|
|
|
|
*
|
|
|
|
|
* @param box Box whose label is updated; must not be NULL.
|
|
|
|
|
* @param text New label text; must not be NULL.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
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-27 01:37:21 +01:00
|
|
|
} // namespace
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Constructs the window, its widgets, and the initial project.
|
|
|
|
|
*
|
|
|
|
|
* Creates an empty project and undo history, opens the audio backend, and
|
|
|
|
|
* schedules the periodic status timer. Throws std::runtime_error when the
|
|
|
|
|
* initial project or history cannot be created.
|
|
|
|
|
*
|
|
|
|
|
* @param prompt_unsaved Whether open, new, and close actions ask before
|
|
|
|
|
* discarding unsaved changes.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
MainWindow::MainWindow(bool prompt_unsaved)
|
2026-10-27 01:37:21 +01:00
|
|
|
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation"), prompt_unsaved_(prompt_unsaved)
|
2026-10-26 22:51:25 +01:00
|
|
|
{
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_project *initial = nullptr;
|
|
|
|
|
xtc_result result = xtc_project_create(&initial);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result != XTC_OK)
|
|
|
|
|
throw std::runtime_error(xtc_result_string(result));
|
2026-10-27 00:10:45 +01:00
|
|
|
project_.reset(initial);
|
2026-10-27 01:06:57 +01:00
|
|
|
xtc_history *history = nullptr;
|
|
|
|
|
result = xtc_history_create(initial, &history);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result != XTC_OK)
|
|
|
|
|
throw std::runtime_error(xtc_result_string(result));
|
2026-10-27 01:06:57 +01:00
|
|
|
history_.reset(history);
|
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);
|
2026-10-27 01:06:57 +01:00
|
|
|
menu->add("Edit/Undo", FL_CTRL + 'z', history_callback, this);
|
|
|
|
|
menu->add("Edit/Redo", FL_CTRL + FL_SHIFT + 'z', history_callback, this);
|
|
|
|
|
menu->add("Edit/Redo alternate", FL_CTRL + 'y', history_callback, this);
|
2026-10-27 00:10:45 +01:00
|
|
|
menu->add("File/New", FL_CTRL + 'n', file_callback, this);
|
|
|
|
|
menu->add("File/Open", FL_CTRL + 'o', file_callback, this);
|
|
|
|
|
menu->add("File/Save", FL_CTRL + 's', file_callback, this);
|
|
|
|
|
menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this);
|
2026-10-27 00:31:03 +01:00
|
|
|
menu->add("File/Import WAV", FL_CTRL + 'i', audio_file_callback, this);
|
|
|
|
|
menu->add("File/Relink selected audio", 0, audio_file_callback, this);
|
2026-10-27 16:37:59 +01:00
|
|
|
menu->add("Audio/MIDI controller mapping", 0, control_map_callback, this);
|
2026-10-27 00:53:41 +01:00
|
|
|
menu->add("Audio/Panic", FL_F + 9, panic_callback, this);
|
|
|
|
|
menu->add("View/Piano roll", FL_CTRL + '4', piano_callback, this);
|
2026-10-27 00:31:03 +01:00
|
|
|
menu->add("Audio/Record WAV", FL_SHIFT + FL_F + 5, audio_file_callback, this);
|
2026-10-27 04:00:27 +01:00
|
|
|
menu->add("File/Load LADSPA plugin", 0, plugin_callback, this);
|
2026-10-27 16:24:21 +01:00
|
|
|
menu->add("Edit/Replace selected plugin", 0, plugin_callback, this);
|
|
|
|
|
menu->add("Edit/Insert LADSPA on selected wire", 0, plugin_callback, this);
|
|
|
|
|
menu->add("Edit/Reset selected plugin", 0, plugin_callback, this);
|
2026-10-27 06:25:30 +01:00
|
|
|
menu->add("Edit/Selected plugin automation", 0, plugin_callback, this);
|
2026-10-27 04:00:27 +01:00
|
|
|
menu->add("Edit/Selected plugin control", 0, plugin_callback, this);
|
2026-10-26 23:40:28 +01:00
|
|
|
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-27 03:25:04 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
play_ = new Fl_Button(12, 40, 80, 30, "Play");
|
|
|
|
|
play_->callback(play_callback, this);
|
2026-10-27 00:31:03 +01:00
|
|
|
play_->tooltip("Start the audio engine (F5 or Space). Play audio clips and the modular graph.");
|
2026-10-26 23:40:28 +01:00
|
|
|
stop_ = new Fl_Button(100, 40, 80, 30, "Stop");
|
|
|
|
|
stop_->callback(stop_callback, this);
|
2026-10-27 03:25:04 +01:00
|
|
|
stop_->tooltip("Stop playback at its current position; finalize recording (F8 or Space).");
|
2026-10-26 23:40:28 +01:00
|
|
|
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).");
|
2026-10-27 00:31:03 +01:00
|
|
|
record_ = new Fl_Button(306, 40, 90, 30, "Record");
|
|
|
|
|
record_->callback(audio_file_callback, this);
|
|
|
|
|
record_->tooltip("Record JACK input to WAV; choose path, then Stop to finalize and add the take.");
|
2026-10-27 00:53:41 +01:00
|
|
|
auto *panic = new Fl_Button(404, 40, 70, 30, "Panic");
|
|
|
|
|
panic->callback(panic_callback, this);
|
|
|
|
|
panic->tooltip("Silence held instrument notes (F9).");
|
2026-10-27 03:25:04 +01:00
|
|
|
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");
|
2026-10-26 23:40:28 +01:00
|
|
|
indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
|
|
|
|
indicator_->labelfont(FL_BOLD);
|
2026-10-27 03:25:04 +01:00
|
|
|
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.");
|
2026-10-26 23:40:28 +01:00
|
|
|
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-27 00:10:45 +01:00
|
|
|
auto *add_track = new Fl_Button(20, 150, 100, 26, "Add track");
|
|
|
|
|
auto *add_region = new Fl_Button(128, 150, 110, 26, "Add region");
|
|
|
|
|
auto *delete_region = new Fl_Button(246, 150, 130, 26, "Delete region/track");
|
|
|
|
|
auto *zoom_in = new Fl_Button(384, 150, 80, 26, "Zoom +");
|
|
|
|
|
auto *zoom_out = new Fl_Button(472, 150, 80, 26, "Zoom -");
|
2026-10-27 00:53:41 +01:00
|
|
|
auto *add_midi = new Fl_Button(560, 150, 110, 26, "Add MIDI clip");
|
|
|
|
|
auto *piano = new Fl_Button(678, 150, 100, 26, "Piano roll");
|
|
|
|
|
piano->callback(piano_callback, this);
|
2026-10-27 01:37:21 +01:00
|
|
|
auto *timeline_hint =
|
|
|
|
|
new Fl_Box(20, 548, 956, 28,
|
2026-10-27 03:25:04 +01:00
|
|
|
"Drag clips to move, edges to trim. Click the ruler while stopped to seek; Home returns to start.");
|
2026-10-27 01:37:21 +01:00
|
|
|
timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
|
|
|
|
timeline_hint->labelsize(12);
|
2026-10-27 00:10:45 +01:00
|
|
|
auto *timeline_scroll = new Fl_Scroll(20, 188, 956, 350);
|
|
|
|
|
sequencer_ = new SequencerWidget(20, 188, project_.get(), seek, this);
|
|
|
|
|
timeline_scroll->end();
|
2026-10-27 00:31:03 +01:00
|
|
|
sequencer_->before_edit = before_arrangement_edit;
|
2026-10-27 06:28:25 +01:00
|
|
|
menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->duplicate_selection(); },sequencer_);
|
2026-10-27 15:46:41 +01:00
|
|
|
menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->split_selection(); }, sequencer_);
|
|
|
|
|
menu->add("Edit/Audio clip gain and fades", 0, [](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->audio_properties_dialog(); }, sequencer_);
|
2026-10-27 06:28:25 +01:00
|
|
|
menu->add("Edit/Rename clip or track",FL_F+2,[](Fl_Widget *, void *c) {
|
|
|
|
|
const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) (void)static_cast<SequencerWidget *>(c)->rename_selection(name);
|
|
|
|
|
},sequencer_);
|
|
|
|
|
for (const char *label : {"View/Snap/1 tick","View/Snap/120 ticks","View/Snap/240 ticks","View/Snap/480 ticks","View/Snap/960 ticks"})
|
|
|
|
|
menu->add(label,0,[](Fl_Widget *widget,void *c) {
|
|
|
|
|
auto *m = static_cast<Fl_Menu_Bar *>(widget);
|
|
|
|
|
(void)static_cast<SequencerWidget *>(c)->grid(std::strtoull(m->mvalue()->label(),nullptr,10));
|
|
|
|
|
},sequencer_);
|
2026-10-27 01:37:21 +01:00
|
|
|
sequencer_->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<MainWindow *>(c)->mark_dirty();
|
|
|
|
|
},
|
|
|
|
|
this);
|
|
|
|
|
add_track->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<SequencerWidget *>(c)->add_track();
|
|
|
|
|
},
|
|
|
|
|
sequencer_);
|
|
|
|
|
add_midi->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<SequencerWidget *>(c)->add_midi_clip();
|
|
|
|
|
},
|
|
|
|
|
sequencer_);
|
|
|
|
|
add_region->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<SequencerWidget *>(c)->add_region();
|
|
|
|
|
},
|
|
|
|
|
sequencer_);
|
|
|
|
|
delete_region->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<SequencerWidget *>(c)->delete_selection();
|
|
|
|
|
},
|
|
|
|
|
sequencer_);
|
|
|
|
|
zoom_in->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<SequencerWidget *>(c)->zoom(1);
|
|
|
|
|
},
|
|
|
|
|
sequencer_);
|
|
|
|
|
zoom_out->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<SequencerWidget *>(c)->zoom(-1);
|
|
|
|
|
},
|
|
|
|
|
sequencer_);
|
2026-10-27 00:10:45 +01:00
|
|
|
sequencer->resizable(timeline_scroll);
|
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-27 01:06:57 +01:00
|
|
|
mixer_ = new MixerWidget(20, 150, 956, 420, project_.get(), before_arrangement_edit, this);
|
2026-10-27 01:37:21 +01:00
|
|
|
mixer_->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<MainWindow *>(c)->mark_dirty();
|
|
|
|
|
},
|
|
|
|
|
this);
|
2026-10-27 01:06:57 +01:00
|
|
|
mixer->resizable(mixer_);
|
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:55:35 +01:00
|
|
|
graph_state_ = new Fl_Box(20, 182, 956, 24, "Graph: offline");
|
|
|
|
|
graph_state_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
|
|
|
|
graph_state_->labelsize(12);
|
2026-10-27 01:37:21 +01:00
|
|
|
auto *feedback =
|
|
|
|
|
new Fl_Box(20, 548, 956, 28,
|
|
|
|
|
"Add nodes. Drag output to input to connect; click a node or wire then Delete. Scroll to pan.");
|
2026-10-26 23:55:35 +01:00
|
|
|
feedback->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP);
|
|
|
|
|
feedback->labelsize(12);
|
|
|
|
|
auto *scroll = new Fl_Scroll(20, 212, 956, 328);
|
2026-10-27 00:10:45 +01:00
|
|
|
graph_widget_ = new GraphWidget(20, 212, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback);
|
2026-10-26 23:55:35 +01:00
|
|
|
scroll->end();
|
2026-10-27 16:00:29 +01:00
|
|
|
menu->add("View/Arrange graph by signal flow", 0, [](Fl_Widget *, void *c) { (void)static_cast<GraphWidget *>(c)->auto_layout(); }, graph_widget_);
|
|
|
|
|
menu->add("Edit/Insert unity gain in selected wire", 0, [](Fl_Widget *, void *c) { (void)static_cast<GraphWidget *>(c)->insert_gain(); }, graph_widget_);
|
2026-10-26 23:55:35 +01:00
|
|
|
auto *oscillator = new Fl_Button(20, 150, 130, 26, "Add oscillator");
|
2026-10-27 01:37:21 +01:00
|
|
|
oscillator->callback(
|
|
|
|
|
[](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_OSCILLATOR);
|
|
|
|
|
},
|
|
|
|
|
graph_widget_);
|
2026-10-26 23:55:35 +01:00
|
|
|
auto *gain = new Fl_Button(158, 150, 110, 26, "Add gain");
|
2026-10-27 01:37:21 +01:00
|
|
|
gain->callback(
|
|
|
|
|
[](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_GAIN);
|
|
|
|
|
},
|
|
|
|
|
graph_widget_);
|
2026-10-26 23:55:35 +01:00
|
|
|
auto *meter = new Fl_Button(276, 150, 110, 26, "Add meter");
|
2026-10-27 01:37:21 +01:00
|
|
|
meter->callback(
|
|
|
|
|
[](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_METER);
|
|
|
|
|
},
|
|
|
|
|
graph_widget_);
|
2026-10-26 23:55:35 +01:00
|
|
|
auto *output = new Fl_Button(394, 150, 110, 26, "Add output");
|
2026-10-27 01:37:21 +01:00
|
|
|
output->callback(
|
|
|
|
|
[](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_OUTPUT);
|
|
|
|
|
},
|
|
|
|
|
graph_widget_);
|
2026-10-27 00:31:03 +01:00
|
|
|
auto *audio = new Fl_Button(650, 150, 130, 26, "Add audio source");
|
2026-10-27 01:37:21 +01:00
|
|
|
audio->callback(
|
|
|
|
|
[](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_AUDIO);
|
|
|
|
|
},
|
|
|
|
|
graph_widget_);
|
2026-10-27 00:53:41 +01:00
|
|
|
auto *instrument = new Fl_Button(788, 150, 130, 26, "Add instrument");
|
2026-10-27 01:37:21 +01:00
|
|
|
instrument->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(c)->add_node(XTC_NODE_INSTRUMENT);
|
|
|
|
|
},
|
|
|
|
|
graph_widget_);
|
2026-10-26 23:55:35 +01:00
|
|
|
auto *remove = new Fl_Button(512, 150, 130, 26, "Delete selection");
|
2026-10-27 01:37:21 +01:00
|
|
|
remove->callback(
|
|
|
|
|
[](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->delete_selected();
|
|
|
|
|
},
|
|
|
|
|
graph_widget_);
|
2026-10-26 23:55:35 +01:00
|
|
|
graph->resizable(scroll);
|
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-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Removes the status timer and releases backend and recording state.
|
|
|
|
|
*
|
|
|
|
|
* A backend that fails to close is intentionally retained with its recorder
|
|
|
|
|
* until process exit so active callbacks never observe freed state.
|
|
|
|
|
*/
|
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)
|
2026-10-27 01:37:21 +01:00
|
|
|
std::fputs("Failed to close JACK during GUI destruction; backend and recording retained until process exit.\n",
|
|
|
|
|
stderr);
|
2026-10-27 00:31:03 +01:00
|
|
|
else if (recorder_) {
|
2026-10-27 03:46:09 +01:00
|
|
|
xtc_result result = xtc_recorder_finish(recorder_);
|
|
|
|
|
std::fprintf(stderr, "Recording finalized during shutdown (%s): %s\n", xtc_result_string(result),
|
|
|
|
|
xtc_recorder_recovery_path(recorder_));
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_recorder_destroy(recorder_);
|
|
|
|
|
}
|
2026-10-26 23:40:28 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Shows an operation failure and a context-sensitive hint.
|
|
|
|
|
*
|
|
|
|
|
* @param operation Human-readable operation name; names containing
|
|
|
|
|
* "Project" select a file and permission hint.
|
|
|
|
|
* @param result Failing result code whose name is included in the message.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::report_error(const char *operation, xtc_result result)
|
|
|
|
|
{
|
|
|
|
|
char text[384];
|
2026-10-27 01:37:21 +01:00
|
|
|
const char *hint = std::strstr(operation, "Project") != nullptr
|
|
|
|
|
? "Check the project path, file format and write permissions."
|
|
|
|
|
: result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support."
|
|
|
|
|
: "Check the JACK-compatible server and use Reconnect to retry.";
|
2026-10-26 23:40:28 +01:00
|
|
|
std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint);
|
|
|
|
|
set_label(error_, text);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Edit handler passed to the modular graph widget.
|
|
|
|
|
*
|
|
|
|
|
* Applies the edit to a candidate graph, clones the project around it, and
|
|
|
|
|
* submits the new graph to the engine when connected. The live project is
|
|
|
|
|
* replaced only after every step succeeds.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * passed as the graph widget owner context.
|
|
|
|
|
* @param edit Graph edit to apply.
|
|
|
|
|
* @param out_added_id Optional output receiving the new node ID when the edit
|
|
|
|
|
* adds a node; untouched otherwise or when nullptr.
|
|
|
|
|
* @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording, or an
|
|
|
|
|
* error from the graph, project, or backend operation.
|
|
|
|
|
*/
|
2026-10-26 23:55:35 +01:00
|
|
|
xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, size_t *out_added_id)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (window->recorder_)
|
|
|
|
|
return XTC_ERR_INVALID_STATE;
|
2026-10-26 23:55:35 +01:00
|
|
|
xtc_graph *candidate = nullptr;
|
|
|
|
|
size_t added_id = SIZE_MAX;
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result result = xtc_graph_apply_edit(xtc_project_graph(window->project_.get()), &edit, &candidate, &added_id);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result != XTC_OK)
|
|
|
|
|
return result;
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_project *replacement = nullptr;
|
|
|
|
|
result = xtc_project_clone(window->project_.get(), &replacement);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result == XTC_OK)
|
|
|
|
|
result = xtc_project_set_graph(replacement, candidate);
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_graph_destroy(candidate);
|
|
|
|
|
if (result == XTC_OK && window->ready_)
|
|
|
|
|
result = xtc_backend_submit(window->backend_, xtc_project_graph(replacement));
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result != XTC_OK) {
|
|
|
|
|
xtc_project_destroy(replacement);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
window->project_.reset(replacement);
|
|
|
|
|
window->graph_widget_->set_graph(xtc_project_graph(replacement));
|
|
|
|
|
window->sequencer_->set_project(replacement, false);
|
|
|
|
|
window->mark_dirty();
|
2026-10-27 16:00:29 +01:00
|
|
|
if (out_added_id != nullptr && (edit.kind == XTC_EDIT_ADD_NODE || edit.kind == XTC_EDIT_INSERT_GAIN))
|
2026-10-27 01:37:21 +01:00
|
|
|
*out_added_id = added_id;
|
2026-10-26 23:55:35 +01:00
|
|
|
window->refresh_status();
|
|
|
|
|
return XTC_OK;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Opens the JACK backend and submits the current graph.
|
|
|
|
|
*
|
|
|
|
|
* Uses the default backend configuration and leaves the engine stopped.
|
|
|
|
|
* Failure is reported and leaves the backend disconnected; a handle that
|
|
|
|
|
* opened but failed to accept the graph is retained for reconnect.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::open_backend()
|
|
|
|
|
{
|
2026-10-27 01:06:57 +01:00
|
|
|
xtc_backend_config config = {XTC_BACKEND_JACK, XTC_PROJECT_NAME, 65536U, 2U};
|
2026-10-26 23:40:28 +01:00
|
|
|
xtc_result result = xtc_backend_open(&config, &backend_);
|
|
|
|
|
ready_ = result == XTC_OK;
|
|
|
|
|
running_ = false;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result != XTC_OK)
|
|
|
|
|
report_error("Open JACK failed", result);
|
2026-10-26 23:55:35 +01:00
|
|
|
else {
|
2026-10-27 03:37:19 +01:00
|
|
|
xtc_backend_status format{};
|
|
|
|
|
result = xtc_backend_get_status(backend_, &format);
|
|
|
|
|
if (result == XTC_OK)
|
|
|
|
|
result = xtc_backend_set_transition_frames(backend_, format.sample_rate / 200U);
|
|
|
|
|
if (result == XTC_OK)
|
|
|
|
|
result = xtc_backend_submit(backend_, xtc_project_graph(project_.get()));
|
2026-10-26 23:55:35 +01:00
|
|
|
if (result != XTC_OK) {
|
|
|
|
|
ready_ = false; // Handle is retained for close/reconnect; graph was not installed.
|
|
|
|
|
report_error("Submit graph failed", result);
|
2026-10-27 01:37:21 +01:00
|
|
|
} else
|
|
|
|
|
set_label(error_, "");
|
2026-10-26 23:55:35 +01:00
|
|
|
}
|
2026-10-26 23:40:28 +01:00
|
|
|
refresh_status();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Starts the audio engine with the current project.
|
|
|
|
|
*
|
|
|
|
|
* Does nothing when already running. Reports an error when disconnected.
|
|
|
|
|
* Otherwise prepares the project and starts the engine. Missing or
|
|
|
|
|
* unreadable audio is reported as a warning after a successful start.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::start_engine()
|
|
|
|
|
{
|
|
|
|
|
if (!ready_) {
|
|
|
|
|
report_error("Audio is disconnected; use Reconnect", XTC_ERR_INVALID_STATE);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
if (running_)
|
|
|
|
|
return;
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_result result = xtc_backend_prepare_project(backend_, project_.get());
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result == XTC_OK)
|
|
|
|
|
result = xtc_backend_start(backend_);
|
|
|
|
|
if (result != XTC_OK)
|
|
|
|
|
report_error("Start engine failed", result);
|
2026-10-27 00:31:03 +01:00
|
|
|
else {
|
|
|
|
|
set_label(error_, "");
|
|
|
|
|
const char *issue = xtc_backend_asset_issue(backend_);
|
|
|
|
|
if (issue[0]) {
|
2026-10-27 01:37:21 +01:00
|
|
|
char message[640];
|
|
|
|
|
std::snprintf(message, sizeof(message),
|
|
|
|
|
"Missing/unreadable audio (silent): %s. Select clip and Relink to repair.", issue);
|
2026-10-27 00:31:03 +01:00
|
|
|
set_label(error_, message);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-26 23:40:28 +01:00
|
|
|
refresh_status();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
2026-10-27 03:37:19 +01:00
|
|
|
* @brief Requests a graceful playback stop, or immediately finalizes recording.
|
2026-10-27 01:58:27 +01:00
|
|
|
*
|
|
|
|
|
* Does nothing when disconnected. Failures are reported in the window.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::stop_engine()
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (!ready_)
|
|
|
|
|
return;
|
2026-10-27 03:37:19 +01:00
|
|
|
xtc_result result = recorder_ ? xtc_backend_stop(backend_) : xtc_backend_request_stop(backend_);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result != XTC_OK)
|
|
|
|
|
report_error("Stop engine failed", result);
|
2026-10-27 00:31:03 +01:00
|
|
|
else {
|
|
|
|
|
set_label(error_, "");
|
2026-10-27 01:37:21 +01:00
|
|
|
if (recorder_)
|
|
|
|
|
finish_recording();
|
2026-10-27 00:31:03 +01:00
|
|
|
}
|
2026-10-26 23:40:28 +01:00
|
|
|
refresh_status();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 03:25:04 +01:00
|
|
|
xtc_result MainWindow::pause_playback()
|
|
|
|
|
{
|
2026-10-27 03:37:19 +01:00
|
|
|
xtc_result result = recorder_ || !ready_ ? XTC_ERR_INVALID_STATE : xtc_backend_request_stop(backend_);
|
2026-10-27 03:25:04 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Closes and reopens the JACK backend, leaving the engine stopped.
|
|
|
|
|
*
|
|
|
|
|
* Refuses to run while recording. If closing fails the existing handle is
|
|
|
|
|
* retained for a later retry and the failure is reported.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::reconnect_engine()
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (recorder_) {
|
|
|
|
|
report_error("Stop recording before reconnecting", XTC_ERR_INVALID_STATE);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-26 23:40:28 +01:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Polls the backend and updates labels, meters, and button states.
|
|
|
|
|
*
|
|
|
|
|
* When disconnected, shows offline text and disables transport controls.
|
|
|
|
|
* Otherwise updates the sequencer position, mixer peaks, status line, and
|
|
|
|
|
* error indicators from the latest engine status, then collects backend
|
|
|
|
|
* work.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::refresh_status()
|
|
|
|
|
{
|
2026-10-27 16:15:34 +01:00
|
|
|
if (mixer_ && !ready_) { mixer_->peaks(0, 0); mixer_->track_peaks(0, 0); }
|
2026-10-27 00:31:03 +01:00
|
|
|
bool can_play = false, can_record = false;
|
2026-10-26 23:40:28 +01:00
|
|
|
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));
|
2026-10-26 23:55:35 +01:00
|
|
|
set_label(graph_state_, "Graph: offline — edits stored locally; Reconnect to play");
|
2026-10-26 23:40:28 +01:00
|
|
|
} else {
|
2026-10-27 03:37:19 +01:00
|
|
|
xtc_backend_collect(backend_); // Completes an acknowledged graceful stop before reading status.
|
2026-10-26 23:40:28 +01:00
|
|
|
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");
|
2026-10-26 23:55:35 +01:00
|
|
|
set_label(graph_state_, "Graph: engine status unavailable");
|
2026-10-26 23:40:28 +01:00
|
|
|
set_label(status_, "Unable to read JACK status — use Reconnect");
|
|
|
|
|
indicator_->labelcolor(fl_rgb_color(255, 155, 120));
|
|
|
|
|
} else {
|
2026-10-27 00:10:45 +01:00
|
|
|
uint64_t tick = 0;
|
|
|
|
|
if (xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &tick) == XTC_OK)
|
|
|
|
|
sequencer_->position(tick);
|
2026-10-26 23:40:28 +01:00
|
|
|
running_ = status.running != 0;
|
|
|
|
|
const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK;
|
|
|
|
|
can_play = !running_ && !fault;
|
2026-10-27 00:31:03 +01:00
|
|
|
can_record = !fault && recorder_ == nullptr;
|
2026-10-27 01:37:21 +01:00
|
|
|
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"
|
2026-10-27 03:37:19 +01:00
|
|
|
: status.stopping ? "Audio: stopping — fading to silence"
|
2026-10-27 01:37:21 +01:00
|
|
|
: recorder_ ? "Audio: recording JACK input — Stop to finish"
|
|
|
|
|
: running_ ? "Audio: running — modular graph"
|
|
|
|
|
: "Audio: ready / stopped";
|
2026-10-26 23:40:28 +01:00
|
|
|
set_label(indicator_, state);
|
2026-10-27 01:37:21 +01:00
|
|
|
indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120)
|
|
|
|
|
: running_ ? fl_rgb_color(125, 225, 160)
|
|
|
|
|
: fl_rgb_color(215, 215, 215));
|
2026-10-27 16:15:34 +01:00
|
|
|
xtc_meter_snapshot meters{};
|
|
|
|
|
float track_left = 0, track_right = 0;
|
|
|
|
|
if (xtc_backend_read_meters(backend_, &meters) == XTC_OK) {
|
|
|
|
|
mixer_->peaks(meters.master_left, meters.master_right);
|
|
|
|
|
for (size_t i = 0; i < meters.count; ++i) if (meters.tracks[i].id == mixer_->selected_track()) {
|
|
|
|
|
track_left = meters.tracks[i].left; track_right = meters.tracks[i].right; break;
|
|
|
|
|
}
|
|
|
|
|
} else mixer_->peaks(status.peak_left, status.peak_right);
|
|
|
|
|
mixer_->track_peaks(track_left, track_right);
|
2026-10-26 23:40:28 +01:00
|
|
|
char text[256];
|
2026-10-27 01:37:21 +01:00
|
|
|
std::snprintf(
|
|
|
|
|
text, sizeof(text),
|
|
|
|
|
"JACK | %u Hz | %u frames | stereo | %u blocks | %u xruns | %s | %u asset issues | %u MIDI drops",
|
2026-10-26 23:40:28 +01:00
|
|
|
status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns,
|
2026-10-27 00:53:41 +01:00
|
|
|
xtc_result_string(status.process_result), status.asset_issues, status.midi_dropped);
|
2026-10-26 23:40:28 +01:00
|
|
|
set_label(status_, text);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (fault)
|
|
|
|
|
set_label(graph_state_, "Graph: engine unavailable — Reconnect required");
|
2026-10-26 23:55:35 +01:00
|
|
|
else if (status.submitted_revision != status.active_revision)
|
2026-10-27 01:37:21 +01:00
|
|
|
set_label(graph_state_, running_ ? "Graph: queued — engine still processing previous graph"
|
|
|
|
|
: "Graph: prepared — starts on Play");
|
|
|
|
|
else
|
|
|
|
|
set_label(graph_state_, running_ ? "Graph: active in engine" : "Graph: stopped");
|
2026-10-26 23:40:28 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
if (can_play)
|
|
|
|
|
play_->activate();
|
|
|
|
|
else
|
|
|
|
|
play_->deactivate();
|
2026-10-27 03:25:04 +01:00
|
|
|
if (ready_ && running_ && !recorder_)
|
|
|
|
|
pause_->activate();
|
|
|
|
|
else
|
|
|
|
|
pause_->deactivate();
|
|
|
|
|
if (!recorder_)
|
|
|
|
|
home_->activate();
|
|
|
|
|
else
|
|
|
|
|
home_->deactivate();
|
2026-10-27 01:37:21 +01:00
|
|
|
if (can_record)
|
|
|
|
|
record_->activate();
|
|
|
|
|
else
|
|
|
|
|
record_->deactivate();
|
|
|
|
|
if (ready_ && (running_ || recorder_))
|
|
|
|
|
stop_->activate();
|
|
|
|
|
else
|
|
|
|
|
stop_->deactivate();
|
2026-10-26 23:40:28 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Attempts to close the window after discarding unsaved changes.
|
|
|
|
|
*
|
|
|
|
|
* Refuses while recording. On success closes the backend, removes the status
|
|
|
|
|
* timer, hides the piano-roll window, and hides this window.
|
|
|
|
|
*
|
|
|
|
|
* @return True when the window closed; false when recording is active, the
|
|
|
|
|
* user cancels discarding, or the backend cannot close.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
bool MainWindow::close_window()
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (recorder_) {
|
|
|
|
|
report_error("Stop recording before closing", XTC_ERR_INVALID_STATE);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!discard_changes())
|
|
|
|
|
return false;
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (piano_window_)
|
|
|
|
|
piano_window_->hide();
|
2026-10-26 23:40:28 +01:00
|
|
|
hide();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Handles FLTK events for the window.
|
|
|
|
|
*
|
|
|
|
|
* Space without Ctrl/Alt/Meta toggles the audio engine and is consumed.
|
|
|
|
|
*
|
|
|
|
|
* @param event FLTK event code, such as FL_SHORTCUT.
|
|
|
|
|
* @return 1 when Space was consumed; otherwise the Fl_Double_Window result.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
int MainWindow::handle(int event)
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (event == FL_SHORTCUT && Fl::event_key() == ' ' && (Fl::event_state() & (FL_CTRL | FL_ALT | FL_META)) == 0) {
|
|
|
|
|
if (running_)
|
|
|
|
|
stop_engine();
|
|
|
|
|
else
|
|
|
|
|
start_engine();
|
2026-10-26 23:40:28 +01:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
return Fl_Double_Window::handle(event);
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK callback for the Play menu item and button.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * whose engine is started.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::play_callback(Fl_Widget *, void *context)
|
2026-10-27 01:37:21 +01:00
|
|
|
{
|
|
|
|
|
static_cast<MainWindow *>(context)->start_engine();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK callback for the Stop menu item and button.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * whose engine is stopped.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::stop_callback(Fl_Widget *, void *context)
|
2026-10-27 01:37:21 +01:00
|
|
|
{
|
|
|
|
|
static_cast<MainWindow *>(context)->stop_engine();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK callback for the Reconnect menu item and button.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * whose backend is reopened.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::reconnect_callback(Fl_Widget *, void *context)
|
2026-10-27 01:37:21 +01:00
|
|
|
{
|
|
|
|
|
static_cast<MainWindow *>(context)->reconnect_engine();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK callback for the window close box, File/Quit, and close events.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * whose close is requested; a refused close
|
|
|
|
|
* simply leaves the window open.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::close_callback(Fl_Widget *, void *context)
|
2026-10-27 01:37:21 +01:00
|
|
|
{
|
|
|
|
|
(void)static_cast<MainWindow *>(context)->close_window();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK menu callback that selects a tab page in the main window.
|
|
|
|
|
*
|
|
|
|
|
* @param widget Triggering menu item; window() must return the MainWindow.
|
|
|
|
|
* @param view Fl_Widget * tab page to make current, not the MainWindow.
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
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();
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK help callback that shows the application name and version.
|
|
|
|
|
*
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::about_callback(Fl_Widget *, void *)
|
|
|
|
|
{
|
2026-10-27 03:37:19 +01:00
|
|
|
fl_message("%s %s\nModular audio workstation\nPhase 12: timing, transitions and audio quality.",
|
2026-10-27 01:37:21 +01:00
|
|
|
XTC_PROJECT_NAME, XTC_VERSION_STRING);
|
2026-10-26 23:40:28 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK timeout callback that refreshes status and supervises recording.
|
|
|
|
|
*
|
|
|
|
|
* While recording, drains the recorder and stops the engine if draining
|
|
|
|
|
* fails or the backend reports a lost server, sample-rate change, or
|
|
|
|
|
* processing error. Reschedules itself every 0.1 seconds until closing.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * registered with Fl::add_timeout().
|
|
|
|
|
*/
|
2026-10-26 23:40:28 +01:00
|
|
|
void MainWindow::timer_callback(void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
2026-10-27 00:31:03 +01:00
|
|
|
if (window->recorder_) {
|
|
|
|
|
xtc_result r = xtc_recorder_drain(window->recorder_);
|
|
|
|
|
xtc_backend_status status{};
|
|
|
|
|
xtc_backend_get_status(window->backend_, &status);
|
|
|
|
|
if (r != XTC_OK || status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK)
|
|
|
|
|
window->stop_engine();
|
|
|
|
|
}
|
2026-10-26 23:40:28 +01:00
|
|
|
window->refresh_status();
|
2026-10-27 01:37:21 +01:00
|
|
|
if (!window->closing_)
|
|
|
|
|
Fl::repeat_timeout(0.1, timer_callback, context);
|
2026-10-26 22:51:25 +01:00
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Records the current project in history and marks the window dirty.
|
|
|
|
|
*
|
|
|
|
|
* Dependent widgets are refreshed with the current project and graph, and
|
|
|
|
|
* the title gains the unsaved marker. A history failure is reported.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
void MainWindow::mark_dirty()
|
2026-10-27 00:53:41 +01:00
|
|
|
{
|
2026-10-27 01:06:57 +01:00
|
|
|
xtc_result history_result = xtc_history_record(history_.get(), project_.get());
|
2026-10-27 01:37:21 +01:00
|
|
|
if (history_result != XTC_OK)
|
|
|
|
|
report_error("Undo history could not retain edit", history_result);
|
2026-10-27 01:06:57 +01:00
|
|
|
mixer_->set_project(project_.get());
|
2026-10-27 00:53:41 +01:00
|
|
|
graph_widget_->set_graph(xtc_project_graph(project_.get()));
|
2026-10-27 01:37:21 +01:00
|
|
|
if (piano_)
|
|
|
|
|
piano_->set_project(project_.get(), piano_->clip());
|
|
|
|
|
dirty_ = true;
|
|
|
|
|
copy_label("xtc * — unsaved changes");
|
2026-10-27 00:53:41 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Asks whether unsaved changes may be discarded.
|
|
|
|
|
*
|
|
|
|
|
* @return True when the project is clean, prompting is disabled, or the user
|
|
|
|
|
* chose Discard; false when the user cancels.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
bool MainWindow::discard_changes()
|
|
|
|
|
{
|
|
|
|
|
return !dirty_ || !prompt_unsaved_ ||
|
2026-10-27 01:37:21 +01:00
|
|
|
fl_choice("Discard unsaved project changes?", "Cancel", "Discard", nullptr) == 1;
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Hook used by the sequencer to seek the audio engine.
|
|
|
|
|
*
|
|
|
|
|
* Does nothing when the backend is disconnected. Otherwise reads the engine
|
|
|
|
|
* sample rate, converts the tick position to samples, and seeks the backend.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * passed as the sequencer owner context.
|
|
|
|
|
* @param tick Target timeline position in project ticks.
|
|
|
|
|
* @return XTC_OK when disconnected or the seek succeeded, or the status,
|
|
|
|
|
* conversion, or backend seek error.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result MainWindow::seek(void *context, uint64_t tick)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (!window->ready_)
|
|
|
|
|
return XTC_OK;
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_backend_status status{};
|
|
|
|
|
xtc_result r = xtc_backend_get_status(window->backend_, &status);
|
|
|
|
|
uint64_t sample = 0;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_project_tick_to_sample(window->project_.get(), tick, status.sample_rate, &sample);
|
2026-10-27 00:10:45 +01:00
|
|
|
return r == XTC_OK ? xtc_backend_seek(window->backend_, sample) : r;
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Saves the current project to a path and updates the window title.
|
|
|
|
|
*
|
|
|
|
|
* On success the path is remembered and the unsaved marker is cleared.
|
|
|
|
|
*
|
|
|
|
|
* @param path Destination project path; nullptr is invalid.
|
|
|
|
|
* @return XTC_OK on success, XTC_ERR_INVALID_ARGUMENT for nullptr,
|
|
|
|
|
* XTC_ERR_INVALID_STATE while recording, or the save error.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result MainWindow::save_project(const char *path)
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (recorder_)
|
|
|
|
|
return XTC_ERR_INVALID_STATE;
|
|
|
|
|
if (path == nullptr)
|
|
|
|
|
return XTC_ERR_INVALID_ARGUMENT;
|
2026-10-27 00:10:45 +01:00
|
|
|
std::string saved_path(path);
|
|
|
|
|
xtc_result r = xtc_project_save(project_.get(), path);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK) {
|
|
|
|
|
path_ = saved_path;
|
|
|
|
|
dirty_ = false;
|
|
|
|
|
copy_label(path_.c_str());
|
|
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Installs a candidate project as the active project.
|
|
|
|
|
*
|
|
|
|
|
* Rejected while recording. Optionally builds fresh undo history, stops and
|
|
|
|
|
* prepares the connected backend, or validates offline audio and MIDI plans
|
|
|
|
|
* when disconnected. On success the window takes ownership of candidate,
|
|
|
|
|
* rebinds widgets, resets the graph layout, clears the dirty flag, and shows
|
|
|
|
|
* any missing-asset warning. On failure ownership stays with the caller.
|
|
|
|
|
*
|
|
|
|
|
* @param candidate Project to install; must be non-null.
|
|
|
|
|
* @param reset_history True to discard existing undo/redo history and start
|
|
|
|
|
* from candidate.
|
|
|
|
|
* @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording, or an
|
|
|
|
|
* error from history creation, the backend, or offline planning.
|
|
|
|
|
*/
|
2026-10-27 01:06:57 +01:00
|
|
|
xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_history)
|
2026-10-27 00:10:45 +01:00
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (recorder_)
|
|
|
|
|
return XTC_ERR_INVALID_STATE;
|
2026-10-27 01:06:57 +01:00
|
|
|
std::unique_ptr<xtc_history, decltype(&xtc_history_destroy)> new_history(nullptr, xtc_history_destroy);
|
|
|
|
|
if (reset_history) {
|
|
|
|
|
xtc_history *h = nullptr;
|
|
|
|
|
xtc_result result = xtc_history_create(candidate, &h);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result != XTC_OK)
|
|
|
|
|
return result;
|
2026-10-27 01:06:57 +01:00
|
|
|
new_history.reset(h);
|
|
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result r = XTC_OK;
|
|
|
|
|
if (ready_) {
|
|
|
|
|
r = xtc_backend_stop(backend_);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_backend_seek(backend_, 0);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_backend_prepare_project(backend_, candidate);
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_audio_plan *offline = nullptr;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK && !ready_)
|
|
|
|
|
r = xtc_audio_plan_create(candidate, 48000, &offline);
|
2026-10-27 00:53:41 +01:00
|
|
|
xtc_midi_plan *offline_midi = nullptr;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK && !ready_)
|
|
|
|
|
r = xtc_midi_plan_create(candidate, 48000, &offline_midi);
|
2026-10-27 00:53:41 +01:00
|
|
|
xtc_midi_plan_destroy(offline_midi);
|
2026-10-27 04:00:27 +01:00
|
|
|
if (r == XTC_OK && !ready_) {
|
|
|
|
|
xtc_plan *graph = nullptr;
|
|
|
|
|
r = xtc_graph_compile(xtc_project_graph(candidate),48000,256,&graph);
|
|
|
|
|
xtc_plan_destroy(graph);
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
xtc_audio_plan_destroy(offline);
|
2026-10-27 03:25:04 +01:00
|
|
|
refresh_status(); // A failed install may have stopped playback.
|
2026-10-27 01:37:21 +01:00
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 00:31:03 +01:00
|
|
|
std::string issue = ready_ ? xtc_backend_asset_issue(backend_) : "";
|
|
|
|
|
if (offline) {
|
|
|
|
|
const char *path = "";
|
2026-10-27 01:37:21 +01:00
|
|
|
xtc_audio_plan_issues(offline, &path);
|
|
|
|
|
issue = path;
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_audio_plan_destroy(offline);
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
if (piano_window_)
|
|
|
|
|
piano_window_->hide();
|
|
|
|
|
if (piano_)
|
|
|
|
|
piano_->set_project(candidate, UINT64_MAX);
|
2026-10-27 00:10:45 +01:00
|
|
|
project_.reset(candidate);
|
2026-10-27 01:06:57 +01:00
|
|
|
mixer_->set_project(candidate);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (reset_history)
|
|
|
|
|
history_.swap(new_history);
|
2026-10-27 00:10:45 +01:00
|
|
|
graph_widget_->reset_layout();
|
|
|
|
|
graph_widget_->set_graph(xtc_project_graph(candidate));
|
|
|
|
|
sequencer_->set_project(candidate);
|
2026-10-27 03:25:04 +01:00
|
|
|
if (reset_history)
|
|
|
|
|
tabs_->value(tabs_->child(0));
|
2026-10-27 00:10:45 +01:00
|
|
|
dirty_ = false;
|
2026-10-27 00:31:03 +01:00
|
|
|
if (!issue.empty()) {
|
2026-10-27 01:37:21 +01:00
|
|
|
char text[640];
|
|
|
|
|
std::snprintf(text, sizeof(text), "Missing/unreadable audio (silent): %s. Select clip and Relink to repair.",
|
|
|
|
|
issue.c_str());
|
2026-10-27 00:31:03 +01:00
|
|
|
set_label(error_, text);
|
2026-10-27 01:37:21 +01:00
|
|
|
} else
|
|
|
|
|
set_label(error_, "");
|
2026-10-27 00:10:45 +01:00
|
|
|
refresh_status();
|
|
|
|
|
return XTC_OK;
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Loads a project from a path and makes it the current project.
|
|
|
|
|
*
|
|
|
|
|
* The file is loaded into a candidate project and installed only on success.
|
|
|
|
|
*
|
|
|
|
|
* @param path Source project path; nullptr is invalid.
|
|
|
|
|
* @return XTC_OK on success, XTC_ERR_INVALID_ARGUMENT for nullptr, or an
|
|
|
|
|
* error from loading or installing the project.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result MainWindow::open_project(const char *path)
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (path == nullptr)
|
|
|
|
|
return XTC_ERR_INVALID_ARGUMENT;
|
2026-10-27 04:00:27 +01:00
|
|
|
project_issue_.clear();
|
2026-10-27 00:10:45 +01:00
|
|
|
std::string loaded_path(path);
|
|
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
xtc_result r = xtc_project_create(&candidate);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_project_load(candidate, path);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = install_project(candidate);
|
2026-10-27 04:00:27 +01:00
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
if (candidate) {
|
|
|
|
|
const xtc_graph *graph = xtc_project_graph(candidate);
|
|
|
|
|
size_t nodes = 0, edges = 0;
|
|
|
|
|
xtc_graph_get_counts(graph,&nodes,&edges);
|
|
|
|
|
for (size_t i = 0; i < nodes; ++i) {
|
|
|
|
|
xtc_node_info node{}; xtc_plugin_spec spec{};
|
|
|
|
|
if (xtc_graph_get_node(graph,i,&node) != XTC_OK || node.type != XTC_NODE_PLUGIN ||
|
|
|
|
|
xtc_graph_get_plugin(graph,node.id,&spec) != XTC_OK) continue;
|
|
|
|
|
double rate = 48000; xtc_backend_status status{};
|
|
|
|
|
if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
xtc_plugin *check = nullptr;
|
|
|
|
|
xtc_result plugin_result = xtc_plugin_create(&spec,rate,256,&check);
|
|
|
|
|
xtc_plugin_destroy(check);
|
|
|
|
|
if (plugin_result != XTC_OK) {
|
|
|
|
|
project_issue_ = std::string("Missing/incompatible plugin ") + spec.label + " at " + spec.path +
|
|
|
|
|
"; restore a compatible module and retry Open. Saved document retained";
|
|
|
|
|
report_error(project_issue_.c_str(),plugin_result);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
xtc_project_destroy(candidate);
|
2026-10-27 04:00:27 +01:00
|
|
|
} else {
|
2026-10-27 01:37:21 +01:00
|
|
|
path_ = loaded_path;
|
|
|
|
|
copy_label(path_.c_str());
|
|
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 03:25:04 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK menu callback for the File/New, Open, Save, and Save As items.
|
|
|
|
|
*
|
|
|
|
|
* Reads the action from the selected menu label, confirms discarding unsaved
|
|
|
|
|
* changes for New and Open, and uses a native file chooser unless Save can
|
|
|
|
|
* reuse the remembered path. Refuses file operations while recording and
|
|
|
|
|
* reports failures in the window.
|
|
|
|
|
*
|
|
|
|
|
* @param widget Triggering Fl_Menu_Bar whose selected label names the action.
|
|
|
|
|
* @param context MainWindow * owning the project and file path.
|
|
|
|
|
*/
|
2026-10-27 00:10:45 +01:00
|
|
|
void MainWindow::file_callback(Fl_Widget *widget, void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (window->recorder_) {
|
|
|
|
|
window->report_error("Stop recording before file operations", XTC_ERR_INVALID_STATE);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
|
|
|
|
|
const char *action = menu->mvalue()->label();
|
|
|
|
|
xtc_result r = XTC_OK;
|
2026-10-27 04:00:27 +01:00
|
|
|
window->project_issue_.clear();
|
2026-10-27 00:10:45 +01:00
|
|
|
if (std::strcmp(action, "New") == 0) {
|
2026-10-27 01:37:21 +01:00
|
|
|
if (!window->discard_changes())
|
|
|
|
|
return;
|
2026-10-27 03:25:04 +01:00
|
|
|
r = window->new_project();
|
2026-10-27 00:10:45 +01:00
|
|
|
} else {
|
|
|
|
|
bool open = std::strcmp(action, "Open") == 0;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (open && !window->discard_changes())
|
|
|
|
|
return;
|
2026-10-27 00:10:45 +01:00
|
|
|
if (!open && std::strcmp(action, "Save") == 0 && !window->path_.empty())
|
|
|
|
|
r = window->save_project(window->path_.c_str());
|
|
|
|
|
else {
|
|
|
|
|
Fl_Native_File_Chooser chooser;
|
|
|
|
|
chooser.title(open ? "Open project" : "Save project");
|
|
|
|
|
chooser.type(open ? Fl_Native_File_Chooser::BROWSE_FILE : Fl_Native_File_Chooser::BROWSE_SAVE_FILE);
|
|
|
|
|
chooser.filter("xtc projects *.xtc");
|
2026-10-27 01:37:21 +01:00
|
|
|
if (!open)
|
|
|
|
|
chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM);
|
2026-10-27 00:10:45 +01:00
|
|
|
int result = chooser.show();
|
2026-10-27 01:37:21 +01:00
|
|
|
if (result == 1)
|
|
|
|
|
return;
|
|
|
|
|
if (result < 0)
|
|
|
|
|
r = XTC_ERR_IO;
|
|
|
|
|
else
|
|
|
|
|
r = open ? window->open_project(chooser.filename()) : window->save_project(chooser.filename());
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r != XTC_OK)
|
2026-10-27 04:00:27 +01:00
|
|
|
window->report_error(window->project_issue_.empty() ? "Project operation failed" : window->project_issue_.c_str(), r);
|
2026-10-27 00:10:45 +01:00
|
|
|
}
|
2026-10-27 00:31:03 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Hook called by arrangement widgets before mutating the project.
|
|
|
|
|
*
|
|
|
|
|
* Refuses edits while recording. When connected, stops the engine so the
|
|
|
|
|
* mutation cannot race the audio thread, then refreshes the status display.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * passed as the widget owner context.
|
|
|
|
|
* @return XTC_OK when disconnected or the stop succeeded,
|
|
|
|
|
* XTC_ERR_INVALID_STATE while recording, or the backend stop error.
|
|
|
|
|
*/
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_result MainWindow::before_arrangement_edit(void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (window->recorder_)
|
|
|
|
|
return XTC_ERR_INVALID_STATE;
|
|
|
|
|
if (!window->ready_)
|
|
|
|
|
return XTC_OK;
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_result r = xtc_backend_stop(window->backend_);
|
|
|
|
|
window->refresh_status();
|
|
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Imports a WAV file as an audio clip in a cloned project.
|
|
|
|
|
*
|
|
|
|
|
* Stops the engine first, creates an "Audio" track when none is selected,
|
|
|
|
|
* and places the clip at the sequencer cursor. The clone replaces the live
|
|
|
|
|
* project only after every step succeeds.
|
|
|
|
|
*
|
|
|
|
|
* @param path Path to the WAV file to import.
|
|
|
|
|
* @return XTC_OK on success, or an error from the engine stop, clone, import,
|
|
|
|
|
* or backend preparation.
|
|
|
|
|
*/
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_result MainWindow::import_audio(const char *path)
|
|
|
|
|
{
|
|
|
|
|
xtc_result r = before_arrangement_edit(this);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r != XTC_OK)
|
|
|
|
|
return r;
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
r = xtc_project_clone(project_.get(), &candidate);
|
|
|
|
|
uint64_t track = sequencer_->selected_track(), clip = 0;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK && track == UINT64_MAX)
|
|
|
|
|
r = xtc_project_add_track(candidate, "Audio", &track);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_project_import_audio(candidate, track, path, sequencer_->position(), &clip);
|
|
|
|
|
if (r == XTC_OK && ready_)
|
|
|
|
|
r = xtc_backend_prepare_project(backend_, candidate);
|
|
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
xtc_project_destroy(candidate);
|
|
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 00:31:03 +01:00
|
|
|
project_.reset(candidate);
|
|
|
|
|
sequencer_->set_project(candidate, false);
|
|
|
|
|
graph_widget_->set_graph(xtc_project_graph(candidate));
|
2026-10-27 01:37:21 +01:00
|
|
|
mark_dirty();
|
|
|
|
|
refresh_status();
|
|
|
|
|
return XTC_OK;
|
2026-10-27 00:31:03 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Starts recording JACK input to a WAV file.
|
|
|
|
|
*
|
|
|
|
|
* Requires a connected backend and no active recording. The engine is
|
|
|
|
|
* stopped, the current position and selected track are remembered, and a
|
|
|
|
|
* stereo recorder at the engine sample rate is attached before starting.
|
|
|
|
|
* Failure leaves the window without a recorder.
|
|
|
|
|
*
|
|
|
|
|
* @param path Destination WAV path chosen by the user.
|
|
|
|
|
* @return XTC_OK when recording started, XTC_ERR_INVALID_STATE when
|
|
|
|
|
* disconnected or already recording, or another backend or recorder
|
|
|
|
|
* error.
|
|
|
|
|
*/
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_result MainWindow::record_audio(const char *path)
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (!ready_ || recorder_)
|
|
|
|
|
return XTC_ERR_INVALID_STATE;
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_result r = xtc_backend_stop(backend_);
|
|
|
|
|
xtc_backend_status status{};
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_backend_get_status(backend_, &status);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &recording_tick_);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_backend_prepare_project(backend_, project_.get());
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_recorder_create(path, status.sample_rate, static_cast<size_t>(status.sample_rate) * 2, &recorder_);
|
|
|
|
|
if (r != XTC_OK)
|
|
|
|
|
return r;
|
2026-10-27 00:31:03 +01:00
|
|
|
recording_path_ = path;
|
|
|
|
|
recording_track_ = sequencer_->selected_track();
|
|
|
|
|
r = xtc_backend_set_recorder(backend_, recorder_);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_backend_start(backend_);
|
2026-10-27 00:31:03 +01:00
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
(void)xtc_backend_set_recorder(backend_, nullptr);
|
2026-10-27 01:37:21 +01:00
|
|
|
xtc_recorder_destroy(recorder_);
|
|
|
|
|
recorder_ = nullptr;
|
|
|
|
|
} else
|
|
|
|
|
mark_dirty();
|
|
|
|
|
refresh_status();
|
|
|
|
|
return r;
|
2026-10-27 00:31:03 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Finalizes the active recording and adds the take to the project.
|
|
|
|
|
*
|
|
|
|
|
* Must run only after the engine has stopped, so no audio callback can
|
|
|
|
|
* reference the recorder ring. The recorder is detached, finished, and
|
|
|
|
|
* destroyed. A failed finalize or empty take is reported and not added. If
|
|
|
|
|
* the subsequent import fails, the saved WAV path is reported for recovery.
|
|
|
|
|
*/
|
2026-10-27 00:31:03 +01:00
|
|
|
void MainWindow::finish_recording()
|
|
|
|
|
{
|
|
|
|
|
// Called only after successful backend_stop: no producer can reference ring.
|
|
|
|
|
(void)xtc_backend_set_recorder(backend_, nullptr);
|
|
|
|
|
uint64_t frames = 0;
|
|
|
|
|
(void)xtc_recorder_status(recorder_, &frames);
|
|
|
|
|
xtc_result r = xtc_recorder_finish(recorder_);
|
2026-10-27 03:46:09 +01:00
|
|
|
std::string recovery_path = xtc_recorder_recovery_path(recorder_);
|
2026-10-27 01:37:21 +01:00
|
|
|
xtc_recorder_destroy(recorder_);
|
|
|
|
|
recorder_ = nullptr;
|
2026-10-27 00:31:03 +01:00
|
|
|
if (r != XTC_OK) {
|
2026-10-27 01:37:21 +01:00
|
|
|
char text[768];
|
|
|
|
|
std::snprintf(text, sizeof(text),
|
2026-10-27 03:46:09 +01:00
|
|
|
"Recording failed (%s). Retained take/temporary: %s. Recover with xtc-check --recover-recording. Not added.",
|
|
|
|
|
xtc_result_string(r), recovery_path.c_str());
|
2026-10-27 01:37:21 +01:00
|
|
|
set_label(error_, text);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!frames) {
|
|
|
|
|
set_label(error_, "Empty recording saved; no clip added.");
|
|
|
|
|
return;
|
2026-10-27 00:31:03 +01:00
|
|
|
}
|
|
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
r = xtc_project_clone(project_.get(), &candidate);
|
|
|
|
|
uint64_t clip = 0, track = recording_track_;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK && track == UINT64_MAX)
|
|
|
|
|
r = xtc_project_add_track(candidate, "Recording", &track);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_project_import_audio(candidate, track, recording_path_.c_str(), recording_tick_, &clip);
|
|
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_backend_prepare_project(backend_, candidate);
|
2026-10-27 00:31:03 +01:00
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
xtc_project_destroy(candidate);
|
2026-10-27 01:37:21 +01:00
|
|
|
char text[768];
|
|
|
|
|
std::snprintf(text, sizeof(text), "Take saved to %s; adding clip failed: %s. Import the WAV to recover.",
|
|
|
|
|
recording_path_.c_str(), xtc_result_string(r));
|
|
|
|
|
set_label(error_, text);
|
|
|
|
|
return;
|
2026-10-27 00:31:03 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
project_.reset(candidate);
|
|
|
|
|
sequencer_->set_project(candidate, false);
|
|
|
|
|
graph_widget_->set_graph(xtc_project_graph(candidate));
|
|
|
|
|
mark_dirty();
|
2026-10-27 00:31:03 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK callback for the audio import, record, and relink commands.
|
|
|
|
|
*
|
|
|
|
|
* The triggering widget supplies the menu label; the Record button uses the
|
|
|
|
|
* "Record WAV" default. A native file chooser selects the file, after which
|
|
|
|
|
* the selected clip is relinked, recording starts, or a WAV is imported.
|
|
|
|
|
* Failures are reported in the window.
|
|
|
|
|
*
|
|
|
|
|
* @param widget Triggering Fl_Menu_Bar or Fl_Button; menu labels select the
|
|
|
|
|
* action.
|
|
|
|
|
* @param context MainWindow * owning the project and sequencer.
|
|
|
|
|
*/
|
2026-10-27 00:31:03 +01:00
|
|
|
void MainWindow::audio_file_callback(Fl_Widget *widget, void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
|
|
|
|
auto *menu = dynamic_cast<Fl_Menu_Bar *>(widget);
|
|
|
|
|
const char *action = menu ? menu->mvalue()->label() : "Record WAV";
|
|
|
|
|
bool recording = std::strcmp(action, "Record WAV") == 0;
|
|
|
|
|
bool relink = std::strcmp(action, "Relink selected audio") == 0;
|
2026-10-27 01:37:21 +01:00
|
|
|
if (window->recorder_) {
|
|
|
|
|
window->report_error("Stop recording first", XTC_ERR_INVALID_STATE);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-27 00:31:03 +01:00
|
|
|
Fl_Native_File_Chooser chooser;
|
|
|
|
|
chooser.title(recording ? "Record JACK input to WAV" : relink ? "Relink selected clip" : "Import WAV");
|
|
|
|
|
chooser.type(recording ? Fl_Native_File_Chooser::BROWSE_SAVE_FILE : Fl_Native_File_Chooser::BROWSE_FILE);
|
|
|
|
|
chooser.filter("WAV audio\t*.wav");
|
2026-10-27 03:46:09 +01:00
|
|
|
// Recordings require a new destination; existing files are never replaced.
|
2026-10-27 00:31:03 +01:00
|
|
|
int chosen = chooser.show();
|
2026-10-27 01:37:21 +01:00
|
|
|
if (chosen == 1)
|
|
|
|
|
return;
|
2026-10-27 00:31:03 +01:00
|
|
|
xtc_result r = chosen < 0 ? XTC_ERR_IO : XTC_OK;
|
|
|
|
|
if (r == XTC_OK && relink) {
|
|
|
|
|
r = before_arrangement_edit(window);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = xtc_project_set_clip_asset(window->project_.get(), window->sequencer_->selected_clip(),
|
|
|
|
|
chooser.filename());
|
|
|
|
|
if (r == XTC_OK) {
|
|
|
|
|
window->mark_dirty();
|
|
|
|
|
window->sequencer_->redraw();
|
|
|
|
|
}
|
|
|
|
|
} else if (r == XTC_OK)
|
|
|
|
|
r = recording ? window->record_audio(chooser.filename()) : window->import_audio(chooser.filename());
|
|
|
|
|
if (r != XTC_OK)
|
2026-10-27 03:46:09 +01:00
|
|
|
window->report_error(recording ? "Project recording failed; choose a new WAV path in a writable directory"
|
|
|
|
|
: "Project audio operation failed", r);
|
2026-10-27 00:31:03 +01:00
|
|
|
}
|
2026-10-27 00:53:41 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Shows the piano roll for the selected MIDI clip.
|
|
|
|
|
*
|
|
|
|
|
* The piano-roll window is created on first use and reused afterwards.
|
|
|
|
|
*
|
|
|
|
|
* @return XTC_OK on success, or XTC_ERR_INVALID_STATE when the selected clip
|
|
|
|
|
* does not exist or is not MIDI.
|
|
|
|
|
*/
|
2026-10-27 00:53:41 +01:00
|
|
|
xtc_result MainWindow::open_piano_roll()
|
|
|
|
|
{
|
|
|
|
|
xtc_clip_info clip{};
|
|
|
|
|
if (xtc_project_find_clip(project_.get(), sequencer_->selected_clip(), &clip) != XTC_OK || !clip.midi)
|
|
|
|
|
return XTC_ERR_INVALID_STATE;
|
|
|
|
|
if (!piano_window_) {
|
|
|
|
|
piano_window_ = std::make_unique<Fl_Double_Window>(900, 620, "xtc — Piano roll");
|
|
|
|
|
piano_window_->begin();
|
2026-10-27 15:34:54 +01:00
|
|
|
auto *menu = new Fl_Menu_Bar(12, 8, 876, 30);
|
|
|
|
|
const char *commands[] = {"Notes/Grid (G)", "Notes/Filter (F)", "Notes/Velocity and channel (V)",
|
|
|
|
|
"Notes/Quantize (Q)", "Notes/Transpose (T)", "Notes/Repeat (Ctrl+D)",
|
|
|
|
|
"Notes/Select filtered (Ctrl+A)", "Notes/Delete"};
|
|
|
|
|
for (int i = 0; i < 8; ++i)
|
|
|
|
|
menu->add(commands[i], 0, [](Fl_Widget *widget, void *data) {
|
|
|
|
|
auto *owner = static_cast<MainWindow *>(data);
|
|
|
|
|
auto *bar = static_cast<Fl_Menu_Bar *>(widget);
|
|
|
|
|
owner->piano_->command(static_cast<int>(bar->mvalue() - bar->menu()) - 1);
|
|
|
|
|
}, this);
|
2026-10-27 00:53:41 +01:00
|
|
|
auto *scroll = new Fl_Scroll(12, 48, 876, 560);
|
|
|
|
|
piano_ = new PianoRollWidget(12, 48, project_.get(), clip.id, before_arrangement_edit, this);
|
2026-10-27 01:37:21 +01:00
|
|
|
piano_->callback(
|
|
|
|
|
[](Fl_Widget *, void *c) {
|
|
|
|
|
static_cast<MainWindow *>(c)->mark_dirty();
|
|
|
|
|
},
|
|
|
|
|
this);
|
|
|
|
|
scroll->end();
|
|
|
|
|
piano_window_->end();
|
|
|
|
|
piano_window_->resizable(scroll);
|
2026-10-27 00:53:41 +01:00
|
|
|
scroll->scroll_to(0, 60 * 16);
|
2026-10-27 01:37:21 +01:00
|
|
|
} else
|
|
|
|
|
piano_->set_project(project_.get(), clip.id);
|
|
|
|
|
piano_window_->show();
|
|
|
|
|
return XTC_OK;
|
2026-10-27 00:53:41 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK callback for the Piano roll menu item and button.
|
|
|
|
|
*
|
|
|
|
|
* Refuses to open the editor while recording and asks the user to select a
|
|
|
|
|
* MIDI clip when open_piano_roll() fails.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * owning the piano roll.
|
|
|
|
|
*/
|
2026-10-27 00:53:41 +01:00
|
|
|
void MainWindow::piano_callback(Fl_Widget *, void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (window->recorder_) {
|
|
|
|
|
window->report_error("Stop recording before editing MIDI", XTC_ERR_INVALID_STATE);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-27 00:53:41 +01:00
|
|
|
xtc_result r = window->open_piano_roll();
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r != XTC_OK)
|
|
|
|
|
set_label(window->error_, "Select a MIDI clip, then open Piano roll.");
|
2026-10-27 00:53:41 +01:00
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK callback for the Panic menu item and button.
|
|
|
|
|
*
|
|
|
|
|
* When the backend is connected, sends a panic request that silences held
|
|
|
|
|
* instrument notes and reports failures.
|
|
|
|
|
*
|
|
|
|
|
* @param context MainWindow * owning the backend.
|
|
|
|
|
*/
|
2026-10-27 00:53:41 +01:00
|
|
|
void MainWindow::panic_callback(Fl_Widget *, void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (!window->ready_)
|
|
|
|
|
return;
|
2026-10-27 00:53:41 +01:00
|
|
|
xtc_result r = xtc_backend_panic(window->backend_);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r != XTC_OK)
|
|
|
|
|
window->report_error("Panic failed", r);
|
2026-10-27 00:53:41 +01:00
|
|
|
}
|
2026-10-27 01:06:57 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief Applies one undo or redo step from the project history.
|
|
|
|
|
*
|
|
|
|
|
* The selected snapshot is installed without resetting history and then
|
|
|
|
|
* accepted, leaving the window marked dirty.
|
|
|
|
|
*
|
|
|
|
|
* @param redo True to redo, false to undo.
|
|
|
|
|
* @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording,
|
|
|
|
|
* XTC_ERR_NOT_FOUND when no step is available, or another error from
|
|
|
|
|
* checkout, install, or accept.
|
|
|
|
|
*/
|
2026-10-27 01:06:57 +01:00
|
|
|
xtc_result MainWindow::undo(bool redo)
|
|
|
|
|
{
|
2026-10-27 01:37:21 +01:00
|
|
|
if (recorder_)
|
|
|
|
|
return XTC_ERR_INVALID_STATE;
|
2026-10-27 01:06:57 +01:00
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
xtc_result r = xtc_history_checkout(history_.get(), redo ? 1 : 0, &candidate);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r == XTC_OK)
|
|
|
|
|
r = install_project(candidate, false);
|
|
|
|
|
if (r != XTC_OK) {
|
|
|
|
|
xtc_project_destroy(candidate);
|
|
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 01:06:57 +01:00
|
|
|
r = xtc_history_accept(history_.get(), redo ? 1 : 0);
|
2026-10-27 01:37:21 +01:00
|
|
|
dirty_ = true;
|
|
|
|
|
copy_label("xtc * — unsaved changes");
|
2026-10-27 01:06:57 +01:00
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 01:37:21 +01:00
|
|
|
|
2026-10-27 01:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* @brief FLTK menu callback for the Undo and Redo menu items.
|
|
|
|
|
*
|
|
|
|
|
* The selected menu label starting with "Redo" chooses redo; all other
|
|
|
|
|
* labels undo. Failures other than XTC_ERR_NOT_FOUND are shown in the window.
|
|
|
|
|
*
|
|
|
|
|
* @param widget Fl_Menu_Bar whose selected item triggered the callback.
|
|
|
|
|
* @param context MainWindow * owning the menu and history.
|
|
|
|
|
*/
|
2026-10-27 01:06:57 +01:00
|
|
|
void MainWindow::history_callback(Fl_Widget *widget, void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
|
|
|
|
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
|
|
|
|
|
bool redo = std::strncmp(menu->mvalue()->label(), "Redo", 4) == 0;
|
|
|
|
|
xtc_result r = window->undo(redo);
|
2026-10-27 01:37:21 +01:00
|
|
|
if (r != XTC_OK && r != XTC_ERR_NOT_FOUND)
|
|
|
|
|
window->report_error("Undo/redo failed", r);
|
2026-10-27 01:06:57 +01:00
|
|
|
}
|
2026-10-27 04:00:27 +01:00
|
|
|
|
2026-10-27 16:37:59 +01:00
|
|
|
xtc_result MainWindow::set_control_map(unsigned int channel, const unsigned int controllers[3])
|
|
|
|
|
{
|
|
|
|
|
if (recorder_) return XTC_ERR_INVALID_STATE;
|
|
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
xtc_result r = xtc_project_clone(project_.get(),&candidate);
|
|
|
|
|
if (r == XTC_OK) r = xtc_project_set_control_map(candidate,channel,controllers);
|
|
|
|
|
if (r == XTC_OK) r = install_project(candidate,false);
|
|
|
|
|
if (r != XTC_OK) xtc_project_destroy(candidate); else mark_dirty();
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
void MainWindow::control_map_callback(Fl_Widget *, void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *w = static_cast<MainWindow *>(context);
|
|
|
|
|
if (w->recorder_) { w->report_error("Stop recording before controller mapping",XTC_ERR_INVALID_STATE); return; }
|
|
|
|
|
unsigned int controllers[3]{};
|
|
|
|
|
std::string prompt = "Live sine MIDI bindings (channels 0..15; 128 disables). CC120/123 reserved.\n";
|
|
|
|
|
for (unsigned int channel = 0; channel < 16; ++channel) {
|
|
|
|
|
xtc_project_get_control_map(w->project_.get(),channel,controllers);
|
|
|
|
|
prompt += std::to_string(channel) + ": volume " + std::to_string(controllers[0]) +
|
|
|
|
|
", pan " + std::to_string(controllers[1]) + ", sustain " + std::to_string(controllers[2]) + "\n";
|
|
|
|
|
}
|
|
|
|
|
prompt += "Enter channel volume_CC pan_CC sustain_CC (distinct enabled CCs):";
|
|
|
|
|
const char *answer = fl_input("%s","0 7 10 64",prompt.c_str());
|
|
|
|
|
if (!answer) return;
|
|
|
|
|
unsigned int channel; char extra;
|
|
|
|
|
xtc_result r = std::sscanf(answer,"%u %u %u %u %c",&channel,controllers,controllers+1,controllers+2,&extra) == 4 ?
|
|
|
|
|
w->set_control_map(channel,controllers) : XTC_ERR_INVALID_ARGUMENT;
|
|
|
|
|
if (r != XTC_OK) w->report_error("Controller mapping rejected: check channel, unique CCs and reserved panic controllers",r);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 04:00:27 +01:00
|
|
|
xtc_result MainWindow::install_plugin_graph(xtc_graph *graph)
|
|
|
|
|
{
|
|
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
xtc_result r = xtc_project_clone(project_.get(),&candidate);
|
|
|
|
|
if (r == XTC_OK) r = xtc_project_set_graph(candidate,graph);
|
|
|
|
|
if (r == XTC_OK) r = install_project(candidate,false);
|
|
|
|
|
if (r != XTC_OK) xtc_project_destroy(candidate);
|
|
|
|
|
else mark_dirty();
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
xtc_result MainWindow::add_plugin(const char *path, unsigned int index, size_t *id)
|
|
|
|
|
{
|
|
|
|
|
if (recorder_) return XTC_ERR_INVALID_STATE;
|
|
|
|
|
double rate = 48000;
|
|
|
|
|
xtc_backend_status status{};
|
|
|
|
|
if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
xtc_plugin_spec spec{};
|
|
|
|
|
xtc_result r = xtc_plugin_probe(path,index,rate,&spec);
|
|
|
|
|
xtc_graph *graph = nullptr; size_t added = SIZE_MAX;
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_clone(xtc_project_graph(project_.get()),&graph);
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_add_plugin(graph,&spec,&added);
|
2026-10-27 16:24:21 +01:00
|
|
|
xtc_plan *check = nullptr;
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_compile(graph,rate,256,&check);
|
|
|
|
|
xtc_plan_destroy(check);
|
2026-10-27 04:00:27 +01:00
|
|
|
if (r == XTC_OK) r = install_plugin_graph(graph);
|
|
|
|
|
xtc_graph_destroy(graph);
|
2026-10-27 16:24:21 +01:00
|
|
|
if (r == XTC_OK) graph_widget_->select_node(added);
|
2026-10-27 04:00:27 +01:00
|
|
|
if (r == XTC_OK && id) *id = added;
|
|
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 16:24:21 +01:00
|
|
|
xtc_result MainWindow::replace_plugin(size_t id, const char *path, unsigned int index)
|
|
|
|
|
{
|
|
|
|
|
if (recorder_) return XTC_ERR_INVALID_STATE;
|
|
|
|
|
double rate = 48000; xtc_backend_status status{};
|
|
|
|
|
if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
xtc_plugin_spec spec{};
|
|
|
|
|
xtc_result r = xtc_plugin_probe(path,index,rate,&spec);
|
|
|
|
|
if (r == XTC_OK) r = apply_plugin_spec(id,spec,true);
|
|
|
|
|
if (r == XTC_OK) graph_widget_->select_node(id);
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
xtc_result MainWindow::reset_plugin(size_t id, unsigned int port)
|
|
|
|
|
{
|
|
|
|
|
if (recorder_) return XTC_ERR_INVALID_STATE;
|
|
|
|
|
double rate = 48000; xtc_backend_status status{};
|
|
|
|
|
if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
xtc_plugin_spec spec{};
|
|
|
|
|
xtc_result r = xtc_graph_get_plugin(xtc_project_graph(project_.get()),id,&spec);
|
|
|
|
|
if (r == XTC_OK) r = xtc_plugin_reset(&spec,rate,port);
|
|
|
|
|
return r == XTC_OK ? apply_plugin_spec(id,spec) : r;
|
|
|
|
|
}
|
|
|
|
|
xtc_result MainWindow::insert_plugin(const xtc_connection_info &wire, const char *path, unsigned int index, size_t *id)
|
|
|
|
|
{
|
|
|
|
|
if (recorder_) return XTC_ERR_INVALID_STATE;
|
|
|
|
|
double rate = 48000; xtc_backend_status status{};
|
|
|
|
|
if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
xtc_plugin_spec spec{};
|
|
|
|
|
xtc_result r = xtc_plugin_probe(path,index,rate,&spec);
|
|
|
|
|
if (r == XTC_OK && !spec.inputs) r = XTC_ERR_UNSUPPORTED;
|
|
|
|
|
xtc_graph *graph = nullptr; size_t added = SIZE_MAX;
|
|
|
|
|
xtc_graph_edit edit{}; edit.kind = XTC_EDIT_DISCONNECT; edit.data.connection = wire;
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_apply_edit(xtc_project_graph(project_.get()),&edit,&graph,nullptr);
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_add_plugin(graph,&spec,&added);
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_connect_ports(graph,wire.source_node,wire.source_port,added,0);
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_connect_ports(graph,added,1,wire.destination_node,wire.destination_port);
|
|
|
|
|
xtc_plan *check = nullptr;
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_compile(graph,rate,256,&check);
|
|
|
|
|
xtc_plan_destroy(check);
|
|
|
|
|
if (r == XTC_OK) r = install_plugin_graph(graph);
|
|
|
|
|
xtc_graph_destroy(graph);
|
|
|
|
|
if (r == XTC_OK) { graph_widget_->select_node(added); if (id) *id = added; }
|
|
|
|
|
return r;
|
|
|
|
|
}
|
2026-10-27 04:00:27 +01:00
|
|
|
xtc_result MainWindow::set_plugin_control(size_t id, unsigned int port, float value)
|
|
|
|
|
{
|
|
|
|
|
if (recorder_) return XTC_ERR_INVALID_STATE;
|
|
|
|
|
xtc_plugin_spec spec{};
|
|
|
|
|
xtc_result r = xtc_graph_get_plugin(xtc_project_graph(project_.get()),id,&spec);
|
|
|
|
|
if (r != XTC_OK) return r;
|
|
|
|
|
size_t i;
|
|
|
|
|
for (i = 0; i < spec.control_count && spec.ports[i] != port; ++i) {}
|
|
|
|
|
if (i == spec.control_count) return XTC_ERR_NOT_FOUND;
|
|
|
|
|
spec.values[i] = value;
|
2026-10-27 06:25:30 +01:00
|
|
|
return apply_plugin_spec(id,spec);
|
|
|
|
|
}
|
|
|
|
|
xtc_result MainWindow::set_plugin_automation(size_t id, const xtc_plugin_point &point, bool remove)
|
|
|
|
|
{
|
|
|
|
|
if (recorder_) return XTC_ERR_INVALID_STATE;
|
2026-10-27 16:37:59 +01:00
|
|
|
double rate = 48000; xtc_backend_status status{};
|
|
|
|
|
if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
xtc_result r = xtc_project_clone(project_.get(),&candidate);
|
|
|
|
|
xtc_parameter_key key{XTC_PARAMETER_PLUGIN,static_cast<uint64_t>(id),point.port};
|
|
|
|
|
if (r == XTC_OK) r = xtc_project_automate(candidate,key,rate,point.tick,point.step,point.value,remove ? 1 : 0);
|
2026-10-27 06:25:30 +01:00
|
|
|
xtc_plugin_spec spec{};
|
2026-10-27 16:37:59 +01:00
|
|
|
if (r == XTC_OK) r = xtc_graph_get_plugin(xtc_project_graph(candidate),id,&spec);
|
|
|
|
|
xtc_project_destroy(candidate);
|
|
|
|
|
return r == XTC_OK ? apply_plugin_spec(id,spec) : r;
|
2026-10-27 06:25:30 +01:00
|
|
|
}
|
2026-10-27 16:24:21 +01:00
|
|
|
xtc_result MainWindow::apply_plugin_spec(size_t id, const xtc_plugin_spec &spec, bool replace)
|
2026-10-27 06:25:30 +01:00
|
|
|
{
|
2026-10-27 04:00:27 +01:00
|
|
|
xtc_graph *graph = nullptr;
|
2026-10-27 06:25:30 +01:00
|
|
|
xtc_result r = xtc_graph_clone(xtc_project_graph(project_.get()),&graph);
|
2026-10-27 16:24:21 +01:00
|
|
|
if (r == XTC_OK) r = replace ? xtc_graph_replace_plugin(graph,id,&spec) : xtc_graph_set_plugin(graph,id,&spec);
|
2026-10-27 04:00:27 +01:00
|
|
|
/* Compile before adoption even with a mocked/disconnected backend. */
|
|
|
|
|
xtc_plan *check = nullptr;
|
|
|
|
|
xtc_backend_status status{}; double rate = 48000;
|
|
|
|
|
if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
if (r == XTC_OK) r = xtc_graph_compile(graph,rate,256,&check);
|
|
|
|
|
xtc_plan_destroy(check);
|
|
|
|
|
if (r == XTC_OK) r = install_plugin_graph(graph);
|
|
|
|
|
xtc_graph_destroy(graph); return r;
|
|
|
|
|
}
|
|
|
|
|
void MainWindow::plugin_callback(Fl_Widget *widget, void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *w = static_cast<MainWindow *>(context);
|
|
|
|
|
if (w->recorder_) { w->report_error("Stop recording before plugin changes",XTC_ERR_INVALID_STATE); return; }
|
|
|
|
|
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
|
|
|
|
|
const char *label = menu->mvalue()->label();
|
|
|
|
|
xtc_result r;
|
2026-10-27 16:24:21 +01:00
|
|
|
if (std::strcmp(label,"Reset selected plugin") == 0) {
|
|
|
|
|
r = w->reset_plugin(w->graph_widget_->selected_node());
|
|
|
|
|
} else if (std::strcmp(label,"Load LADSPA plugin") == 0 ||
|
|
|
|
|
std::strcmp(label,"Replace selected plugin") == 0 ||
|
|
|
|
|
std::strcmp(label,"Insert LADSPA on selected wire") == 0) {
|
|
|
|
|
const size_t selected = w->graph_widget_->selected_node();
|
|
|
|
|
xtc_connection_info wire{};
|
|
|
|
|
const bool insert = std::strcmp(label,"Insert LADSPA on selected wire") == 0;
|
|
|
|
|
const bool replace = std::strcmp(label,"Replace selected plugin") == 0;
|
|
|
|
|
if (insert && !w->graph_widget_->selected_connection(wire)) {
|
|
|
|
|
w->report_error("Select an existing wire before inserting an effect",XTC_ERR_INVALID_STATE); return;
|
|
|
|
|
}
|
|
|
|
|
if (replace) {
|
|
|
|
|
xtc_plugin_spec old{};
|
|
|
|
|
r = xtc_graph_get_plugin(xtc_project_graph(w->project_.get()),selected,&old);
|
|
|
|
|
if (r != XTC_OK) { w->report_error("Select a LADSPA node to replace",r); return; }
|
|
|
|
|
}
|
2026-10-27 04:00:27 +01:00
|
|
|
Fl_Native_File_Chooser chooser;
|
2026-10-27 16:24:21 +01:00
|
|
|
chooser.title("Choose trusted LADSPA module (executes native code)"); chooser.filter("LADSPA\t*.so");
|
2026-10-27 04:00:27 +01:00
|
|
|
if (chooser.show() != 0 || !chooser.filename()) return;
|
|
|
|
|
double rate = 48000; xtc_backend_status status{};
|
|
|
|
|
if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate;
|
2026-10-27 16:24:21 +01:00
|
|
|
unsigned int index = 0;
|
|
|
|
|
xtc_plugin_spec probe{};
|
|
|
|
|
r = xtc_plugin_probe(chooser.filename(),0,rate,&probe);
|
|
|
|
|
if (r != XTC_OK && r != XTC_ERR_UNSUPPORTED) {
|
|
|
|
|
w->report_error("Cannot discover LADSPA module; check path, dependencies and ladspa_descriptor export",r); return;
|
2026-10-27 04:00:27 +01:00
|
|
|
}
|
2026-10-27 16:24:21 +01:00
|
|
|
if (!xtc_choose_plugin(chooser.filename(),rate,index)) return;
|
|
|
|
|
if (replace) r = w->replace_plugin(selected,chooser.filename(),index);
|
|
|
|
|
else if (insert) r = w->insert_plugin(wire,chooser.filename(),index);
|
|
|
|
|
else r = w->add_plugin(chooser.filename(),index);
|
2026-10-27 04:00:27 +01:00
|
|
|
} else {
|
|
|
|
|
size_t id = w->graph_widget_->selected_node(); xtc_plugin_spec spec{};
|
|
|
|
|
r = xtc_graph_get_plugin(xtc_project_graph(w->project_.get()),id,&spec);
|
|
|
|
|
if (r == XTC_OK) {
|
|
|
|
|
std::string prompt = std::string(spec.label) + " control ports and values:\n";
|
2026-10-27 16:24:21 +01:00
|
|
|
if (std::strcmp(label,"Selected plugin control") == 0) {
|
|
|
|
|
double rate = 48000; xtc_backend_status status{};
|
|
|
|
|
if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
xtc_plugin_details details{}; xtc_plugin_spec edited{};
|
|
|
|
|
r = xtc_plugin_describe(&spec,rate,&details);
|
|
|
|
|
if (r == XTC_OK && xtc_edit_plugin(spec,details,edited)) r = w->apply_plugin_spec(id,edited);
|
|
|
|
|
if (r != XTC_OK) w->report_error("Plugin editor failed; configuration retained. Check module identity and control ranges",r);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-27 04:00:27 +01:00
|
|
|
xtc_parameter_info info[XTC_PLUGIN_CONTROLS]{};
|
|
|
|
|
double rate = 48000; xtc_backend_status status{};
|
|
|
|
|
if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate;
|
|
|
|
|
r = xtc_plugin_parameters(&spec,rate,info);
|
|
|
|
|
if (r != XTC_OK) { w->report_error("Read plugin controls failed",r); return; }
|
|
|
|
|
for (unsigned i = 0; i < spec.control_count; ++i)
|
|
|
|
|
prompt += std::to_string(spec.ports[i]) + ": " + info[i].name + " = " + std::to_string(spec.values[i]) +
|
|
|
|
|
" [" + std::to_string(info[i].minimum) + ", " + std::to_string(info[i].maximum) + "]\n";
|
2026-10-27 06:25:30 +01:00
|
|
|
if (std::strcmp(label,"Selected plugin automation") == 0) {
|
|
|
|
|
prompt += "Enter port tick mode value; mode 0 linear, 1 step, -1 remove.\nCurrent tick: " +
|
|
|
|
|
std::to_string(w->sequencer_->position());
|
|
|
|
|
const char *answer = fl_input("%s","",prompt.c_str());
|
|
|
|
|
if (!answer) return;
|
|
|
|
|
unsigned port; unsigned long long tick; int mode; float value; char extra;
|
|
|
|
|
if (std::sscanf(answer,"%u %llu %d %f %c",&port,&tick,&mode,&value,&extra) != 4 || mode < -1 || mode > 1)
|
|
|
|
|
r = XTC_ERR_INVALID_ARGUMENT;
|
|
|
|
|
else {
|
|
|
|
|
xtc_plugin_point point{static_cast<uint64_t>(tick),port,mode == 1 ? 1U : 0U,value};
|
|
|
|
|
r = w->set_plugin_automation(id,point,mode == -1);
|
|
|
|
|
}
|
|
|
|
|
if (r != XTC_OK) w->report_error("Plugin automation rejected: check tick, port, range and discrete mode",r);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-27 04:00:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (r != XTC_OK) w->report_error("Plugin change failed: check module, mono layout, control port/range and JACK format",r);
|
|
|
|
|
}
|