xtc/src/gui/main_window.cpp

2633 lines
131 KiB
C++
Raw Normal View History

2026-10-26 22:51:25 +01:00
#include "main_window.hpp"
#include "graph_widget.hpp"
#include "sequencer_widget.hpp"
#include "piano_roll.hpp"
#include "mixer_widget.hpp"
#include "plugin_dialog.hpp"
#include "theme.hpp"
#include "snap.hpp"
#include "velocity_lane.hpp"
#include "preferences_dialog.hpp"
2026-10-28 17:16:01 +01:00
#include "command_palette.hpp"
#include <FL/Fl_Value_Input.H>
#include "plugin_library.hpp"
#include <FL/Fl_Check_Button.H>
#include <FL/Fl_Choice.H>
#include <FL/Fl_Value_Slider.H>
#include <FL/Fl_Native_File_Chooser.H>
#include "xtc/xtc_version.h"
2026-10-26 22:51:25 +01:00
#include <FL/Fl.H>
2026-10-26 22:51:25 +01:00
#include <FL/Fl_Box.H>
#include <FL/Fl_Button.H>
#include <FL/Fl_Group.H>
#include <FL/Fl_Scroll.H>
2026-10-26 22:51:25 +01:00
#include <FL/Fl_Menu_Bar.H>
#include <FL/Fl_Input_.H>
2026-10-26 22:51:25 +01:00
#include <FL/Fl_Tabs.H>
#include <FL/fl_ask.H>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <cstdlib>
#include <cmath>
#include <cerrno>
#include <algorithm>
2026-10-26 22:51:25 +01:00
namespace {
class PanelWindow : public Fl_Double_Window {
MainWindow *owner_;
public:
PanelWindow(int width, int height, const char *title, MainWindow *owner)
: Fl_Double_Window(width,height,title), owner_(owner) {}
void resize(int x,int y,int width,int height) override {
Fl_Double_Window::resize(x,y,width,height);
if(owner_ && owner_->transport_window()==this)owner_->layout_transport();
}
int handle(int event) override {
if (event == FL_SHORTCUT && owner_->panel_shortcut(event)) return 1;
return Fl_Double_Window::handle(event);
}
};
void restore_panel(Fl_Window *window,int x,int y,int width,int height,int min_width,int min_height)
{
int sx,sy,sw,sh;Fl::screen_work_area(sx,sy,sw,sh,Fl::screen_num(x,y,width,height));
width=std::min(std::max(width,min_width),sw);height=std::min(std::max(height,min_height),sh);
x=std::clamp(x,sx,sx+sw-width);y=std::clamp(y,sy,sy+sh-height);
window->resize(x,y,width,height);
}
2026-10-28 17:16:01 +01:00
bool editing_number(Fl_Value_Input *input)
{
return Fl::focus() == input || Fl::focus() == &input->input || Fl::pushed() == input ||
Fl::pushed() == &input->input;
}
/**
* @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.
*/
void set_label(Fl_Box *box, const char *text)
{
if (box->label() == nullptr || std::strcmp(box->label(), text) != 0)
box->copy_label(text);
}
/* Paint progress without dispatching input/reentering edit commands. */
class BusyOperation {
Fl_Window &window_;
public:
BusyOperation(Fl_Window &window, Fl_Box *status, const char *message) : window_(window) {
set_label(status,message); window_.cursor(FL_CURSOR_WAIT); Fl::flush();
}
~BusyOperation() { window_.cursor(FL_CURSOR_DEFAULT); }
};
2026-10-27 01:37:21 +01:00
} // namespace
2026-10-26 22:51:25 +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.
*/
MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
: Fl_Double_Window(1280, 800, XTC_PROJECT_NAME " — modular audio workstation"), prompt_unsaved_(prompt_unsaved)
2026-10-26 22:51:25 +01:00
{
xtc_ui::initialize();
xtc_settings_defaults(&settings_);
char config_path[4096];
if(configuration) configuration_=configuration;
else if(xtc_settings_path(config_path,sizeof(config_path))==XTC_OK) configuration_=config_path;
xtc_result config_result=configuration_.empty() ? XTC_ERR_NOT_FOUND : xtc_settings_load(&settings_,configuration_.c_str());
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));
project_defaults(initial);
project_.reset(initial);
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));
history_.reset(history);
2026-10-26 22:51:25 +01:00
begin();
auto *menu = new Fl_Menu_Bar(0, 0, 1280, 28);
menu_ = menu;
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/Export WAV",0,[](Fl_Widget *,void *context){static_cast<MainWindow *>(context)->export_audio();},this);
menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this);
menu->add("File/Import WAV", FL_CTRL + 'i', audio_file_callback, this);
menu->add("File/Relink selected audio", 0, audio_file_callback, this);
menu->add("Edit/Preferences",FL_CTRL+',',[](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->show_preferences();},this);
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);
menu->add("Audio/MIDI controller mapping", 0, control_map_callback, this);
menu->add("Audio/Panic", FL_F + 9, panic_callback, this);
menu->add("Edit/Mixer comparison/Capture A",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);auto r=w->mix_snapshot(0,true);if(r!=XTC_OK)w->report_error("Mixer capture failed",r);},this);
menu->add("Edit/Mixer comparison/Recall A",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);auto r=w->mix_snapshot(0,false);if(r!=XTC_OK)w->report_error("Mixer recall failed",r);},this);
menu->add("Edit/Mixer comparison/Capture B",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);auto r=w->mix_snapshot(1,true);if(r!=XTC_OK)w->report_error("Mixer capture failed",r);},this);
menu->add("Edit/Mixer comparison/Recall B",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);auto r=w->mix_snapshot(1,false);if(r!=XTC_OK)w->report_error("Mixer recall failed",r);},this);
menu->add("View/Previous marker",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);auto r=w->navigate_marker(-1);if(r!=XTC_OK)w->report_error("Marker navigation failed",r);},this);
menu->add("View/Next marker",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);auto r=w->navigate_marker(1);if(r!=XTC_OK)w->report_error("Marker navigation failed",r);},this);
menu->add("View/Loop current section",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);auto r=w->loop_section();if(r!=XTC_OK)w->report_error("Section loop unchanged",r);},this);
2026-10-28 17:16:01 +01:00
menu->add("View/Commands",FL_CTRL+'p',[](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->show_commands();},this);
menu->add("View/Fit song",FL_CTRL+'0',[](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->fit_song();},this);
menu->add("View/Piano roll", FL_CTRL + '4', piano_callback, this);
menu->add("Audio/Record WAV", FL_SHIFT + FL_F + 5, audio_file_callback, this);
menu->add("Track/Load instrument plugin", 0, instrument_callback, this);
menu->add("Track/Use built-in instrument", 0, instrument_callback, this);
menu->add("Track/Live MIDI to selected instrument", 0, instrument_callback, this);
menu->add("Track/Live MIDI to built-in sine", 0, instrument_callback, this);
menu->add("Edit/Selected instrument controls",0,instrument_callback,this);
menu->add("Edit/Selected instrument automation",0,instrument_callback,this);
menu->add("File/Load sampler WAV",0,instrument_callback,this);
menu->add("File/Save instrument preset",0,instrument_callback,this);
menu->add("File/Load instrument preset",0,instrument_callback,this);
2026-10-28 17:16:01 +01:00
menu->add("Edit/Insert internal delay on wire", 0, plugin_callback, this);
menu->add("Edit/Insert internal reverb on wire", 0, plugin_callback, this);
menu->add("Edit/Insert stereo delay on wire",0,plugin_callback,this);
menu->add("Edit/Insert stereo reverb on wire",0,plugin_callback,this);
menu->add("File/Add stereo delay",0,plugin_callback,this);
menu->add("File/Add stereo reverb",0,plugin_callback,this);
2026-10-28 17:16:01 +01:00
menu->add("File/Add internal delay", 0, plugin_callback, this);
menu->add("File/Add internal reverb", 0, plugin_callback, this);
menu->add("File/Load plugin", 0, plugin_callback, this);
menu->add("Edit/Replace selected plugin", 0, plugin_callback, this);
menu->add("Edit/Insert plugin on selected wire", 0, plugin_callback, this);
menu->add("Edit/Reset selected plugin", 0, plugin_callback, this);
menu->add("Edit/Selected plugin automation", 0, plugin_callback, this);
menu->add("Edit/Selected plugin control", 0, plugin_callback, this);
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
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);
menu->add("View/Detach transport",0,[](Fl_Widget *,void *c) {
auto *w=static_cast<MainWindow *>(c); w->detach_transport(!w->transport_detached_);
},this,FL_MENU_TOGGLE);
menu->add("View/Detach mixer",0,[](Fl_Widget *,void *c) {
auto *w=static_cast<MainWindow *>(c); w->detach_mixer(!w->mixer_detached_);
},this,FL_MENU_TOGGLE);
menu->add("View/Dock all panels",0,[](Fl_Widget *,void *c) {
auto *w=static_cast<MainWindow *>(c); w->detach_transport(false); w->detach_mixer(false);w->detach_editor(0,false);w->detach_editor(1,false);w->detach_piano(false);
},this);
menu->add("View/Detach sequencer",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);w->detach_editor(0,!w->editors_[0].detached);},this,FL_MENU_TOGGLE);
menu->add("View/Detach modular graph",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);w->detach_editor(1,!w->editors_[1].detached);},this,FL_MENU_TOGGLE);
menu->add("View/Float piano roll",0,[](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);w->detach_piano(!w->piano_detached_);},this,FL_MENU_TOGGLE|FL_MENU_VALUE);
menu->add("View/Reset workspace layout",0,[](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->reset_layout();},this);
transport_group_=new Fl_Group(0,28,1280,90);
transport_group_->begin();
play_ = new Fl_Button(12, 40, 80, 30, "Play");
play_->callback(play_callback, this);
play_->tooltip("Start the audio engine (F5 or Space). Play audio clips and the modular graph.");
stop_ = new Fl_Button(100, 40, 80, 30, "Stop");
stop_->callback(stop_callback, this);
stop_->tooltip("Stop playback at its current position; finalize recording (F8 or Space).");
auto *reconnect = new Fl_Button(188, 40, 110, 30, "Reconnect");
reconnect->callback(reconnect_callback, this);
reconnect->tooltip("Close and reopen JACK, leaving the engine stopped (F6).");
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.");
auto *panic = new Fl_Button(404, 40, 70, 30, "Panic");
panic->callback(panic_callback, this);
panic->tooltip("Silence held instrument notes (F9).");
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(1000, 40, 268, 30, "Audio: disconnected");
indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
indicator_->labelfont(FL_BOLD);
indicator_->labelsize(12);
transport_position_ = new Fl_Box(680,40,190,30,"001 : 1 : 000");
transport_position_->tooltip("Musical position: bar : beat : tick (4/4, 960 ticks per beat)");
2026-10-28 17:16:01 +01:00
tempo_display_ = new Fl_Value_Input(880, 40, 78, 30, "BPM");
tempo_display_->align(FL_ALIGN_RIGHT);
tempo_display_->bounds(20, 300);
tempo_display_->step(.1);
tempo_display_->when(FL_WHEN_RELEASE | FL_WHEN_ENTER_KEY);
tempo_display_->tooltip("Project tempo in BPM. Enter or release to apply; undoable. Stops playback.");
tempo_display_->callback(
[](Fl_Widget *, void *c) {
auto *w = static_cast<MainWindow *>(c);
double bpm = w->tempo_display_->value();
xtc_result r = (!std::isfinite(bpm) || bpm < 20 || bpm > 300)
? XTC_ERR_INVALID_ARGUMENT
: w->set_tempo(static_cast<unsigned int>(std::lround(bpm * 1000)));
if (r != XTC_OK)
w->report_error("Tempo unchanged", r);
w->tempo_display_->value(xtc_project_tempo(w->project_.get()) / 1000.0);
w->refresh_status();
},
this);
auto *hint = new Fl_Box(12, 82, 160, 28, "xtc / SESSION");
hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
session_info_ = new Fl_Box(180, 82, 280, 28);
session_info_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
auto *commands = new Fl_Button(480, 82, 120, 28, "Commands");
commands->tooltip("Search actions (Ctrl+P)");
commands->callback([](Fl_Widget *, void *c) { static_cast<MainWindow *>(c)->show_commands(); }, this);
follow_ = new Fl_Check_Button(620, 82, 154, 28, "Follow playhead");
follow_->tooltip("Keep playback visible in the arrangement; pauses while dragging.");
follow_->callback(
[](Fl_Widget *widget, void *c) {
static_cast<MainWindow *>(c)->settings_.follow_playhead = static_cast<Fl_Check_Button *>(widget)->value();
},
this);
auto *fit = new Fl_Button(790, 82, 100, 28, "Fit song");
fit->tooltip("Choose the largest supported zoom that fits the song (Ctrl+0)");
fit->callback([](Fl_Widget *, void *c) { static_cast<MainWindow *>(c)->fit_song(); }, this);
loop_button_ = new Fl_Check_Button(1000, 82, 100, 28, "Loop");
loop_button_->callback([](Fl_Widget *widget,void *c){auto *w=static_cast<MainWindow *>(c);
xtc_result r=w->set_loop(static_cast<uint64_t>(w->settings_.loop_start),static_cast<uint64_t>(w->settings_.loop_end),static_cast<Fl_Check_Button *>(widget)->value()!=0);
if(r!=XTC_OK) w->report_error("Loop unchanged",r);
w->refresh_status();},this);
auto *range=new Fl_Button(1110,82,150,28,"Loop range...");
range->tooltip("Shift-drag the sequencer ruler to select a loop, or enter start/end ticks here.");
range->callback([](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);char initial[100];
std::snprintf(initial,sizeof(initial),"%lld %lld",static_cast<long long>(w->settings_.loop_start),static_cast<long long>(w->settings_.loop_end));
const char *text=fl_input("Loop start and end ticks (end excluded)",initial);if(!text)return;
unsigned long long start,end;char extra;xtc_result r=XTC_ERR_INVALID_ARGUMENT;
if(std::sscanf(text,"%llu %llu %c",&start,&end,&extra)==2)r=w->set_loop(start,end,w->settings_.loop_enabled!=0);
if(r!=XTC_OK)w->report_error("Loop unchanged",r);},this);
2026-10-26 22:51:25 +01:00
transport_group_->end();
for(int i=0;i<transport_group_->children();++i) {
auto *item=transport_group_->child(i);
transport_items_.push_back({item,item->x(),item->y()-28,item->w(),item->h()});
}
tabs_ = new Fl_Tabs(216, 126, 1056, 578);
tabs_->begin();
auto *sequencer = new Fl_Group(216, 158, 1056, 546, "Sequencer");
2026-10-26 22:51:25 +01:00
sequencer->begin();
editors_[0].owner=this;editors_[0].index=0;editors_[0].page=sequencer;
editors_[0].content=new Fl_Group(216,158,1056,546);
editors_[0].content->begin();
auto *add_track = new Fl_Button(228, 168, 100, 26, "Add track");
auto *add_region = new Fl_Button(336, 168, 110, 26, "Add region");
auto *delete_region = new Fl_Button(454, 168, 130, 26, "Delete region/track");
auto *zoom_in = new Fl_Button(592, 168, 80, 26, "Zoom +");
auto *zoom_out = new Fl_Button(680, 168, 80, 26, "Zoom -");
auto *add_midi = new Fl_Button(768, 168, 110, 26, "Add MIDI clip");
auto *piano = new Fl_Button(886, 168, 100, 26, "Piano roll");
piano->callback(piano_callback, this);
2026-10-27 01:37:21 +01:00
auto *timeline_hint =
new Fl_Box(228, 666, 1032, 28,
"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);
auto *timeline_scroll = new Fl_Scroll(228, 206, 1032, 452);
2026-10-28 17:16:01 +01:00
timeline_scroll_=timeline_scroll;
sequencer_ = new SequencerWidget(228, 206, project_.get(), seek, this);
timeline_scroll->end();
sequencer_->before_edit = before_arrangement_edit;
sequencer_->loop_select=[](void *c,uint64_t start,uint64_t end){auto *w=static_cast<MainWindow *>(c);return w->set_loop(start,end,w->settings_.loop_enabled!=0);};
sequencer_->open_clip = [](void *c) { piano_callback(nullptr,c); };
sequencer_->instrument_select = [](void *c, uint64_t track, const xtc_plugin_spec *spec, bool browse) {
auto *w = static_cast<MainWindow *>(c);
w->sequencer_->select(track);
if (browse) { instrument_callback(nullptr,c); return; }
xtc_result r = w->assign_instrument(track,spec ? spec->path : nullptr,spec ? spec->index : 0);
if (r != XTC_OK) w->report_error("Track instrument unchanged",r);
};
// Keep this in the editor group rather than the scrollable timeline.
auto *parent = Fl_Group::current(); Fl_Group::current(editors_[0].content);
snap_ = new Fl_Choice(1040,168,210,26,"Snap");
for (size_t i = 0; i < xtc_ui::snap_count; ++i) { int item = snap_->add("Snap"); snap_->replace(item,xtc_ui::snap_labels[i]); }
snap_->value(xtc_ui::snap_index(sequencer_->grid()));
snap_->callback([](Fl_Widget *widget, void *c) {
auto *choice = static_cast<Fl_Choice *>(widget); int value = choice->value();
if (value >= 0 && static_cast<size_t>(value) < xtc_ui::snap_count)
(void)static_cast<MainWindow *>(c)->sequencer_->grid(xtc_ui::snap_ticks[value]);
},this);
Fl_Group::current(parent);
menu->add("Edit/Nudge selection left",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->nudge_selection(-1);},sequencer_);
menu->add("Edit/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->nudge_selection(1);},sequencer_);
menu->add("Edit/Audio tools/Set boundary fades...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->fades_dialog();},sequencer_);
menu->add("Edit/Audio tools/Remove boundary fades",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->transform_selection(XTC_CLIPS_FADE,0);},sequencer_);
menu->add("Edit/MIDI tools/Shape velocities...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->velocity_dialog();},sequencer_);
menu->add("Edit/MIDI tools/Correct to scale...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->scale_dialog();},sequencer_);
menu->add("Edit/MIDI tools/Note gate...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->gate_dialog();},sequencer_);
menu->add("Edit/MIDI tools/Legato",0,[](Fl_Widget *,void *c){xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_LEGATO;static_cast<SequencerWidget *>(c)->transform_midi(edit);},sequencer_);
menu->add("Edit/MIDI tools/Swing quantize...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->swing_dialog();},sequencer_);
menu->add("Edit/Align clip starts to cursor",0,[](Fl_Widget *,void *c){auto *s=static_cast<SequencerWidget *>(c);s->transform_selection(XTC_CLIPS_ALIGN,static_cast<int64_t>(s->position()));},sequencer_);
menu->add("Edit/Slip sources earlier",0,[](Fl_Widget *,void *c){auto *s=static_cast<SequencerWidget *>(c);s->transform_selection(XTC_CLIPS_SLIP,-static_cast<int64_t>(s->grid()));},sequencer_);
menu->add("Edit/Slip sources later",0,[](Fl_Widget *,void *c){auto *s=static_cast<SequencerWidget *>(c);s->transform_selection(XTC_CLIPS_SLIP,static_cast<int64_t>(s->grid()));},sequencer_);
menu->add("Edit/Repeat selected phrase...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->repeat_dialog();},sequencer_);
menu->add("Edit/Copy clips",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->copy_selection();},sequencer_);
menu->add("Edit/Paste clips at cursor",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->paste_selection();},sequencer_);
menu->add("Edit/Duplicate complete track...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->duplicate_track_dialog();},sequencer_);
menu->add("Edit/Time/Insert gap...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->ripple_dialog(false);},sequencer_);
menu->add("Edit/Time/Remove range...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->ripple_dialog(true);},sequencer_);
menu->add("Edit/Markers/Set at cursor...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->marker_dialog();},sequencer_);
menu->add("Edit/Markers/Remove at cursor",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->marker(nullptr);},sequencer_);
menu->add("Edit/Select all clips",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->select_all_clips();},sequencer_);
menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->duplicate_selection(); },sequencer_);
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_);
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/160 ticks","View/Snap/240 ticks","View/Snap/320 ticks","View/Snap/480 ticks","View/Snap/960 ticks","View/Snap/1920 ticks","View/Snap/3840 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_);
editors_[0].content->resizable(timeline_scroll);editors_[0].content->end();
sequencer->resizable(editors_[0].content);
2026-10-26 22:51:25 +01:00
sequencer->end();
auto *mixer = new Fl_Group(216, 158, 1056, 546, "Mixer");
mixer_page_=mixer;
2026-10-26 22:51:25 +01:00
mixer->begin();
mixer_ = new MixerWidget(228, 168, 1032, 522, 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);
mixer_placeholder_=new Fl_Group(228,210,1032,120);
auto *detached_hint=new Fl_Box(240,220,600,30,"The mixer is open in a separate window.");
detached_hint->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE);
auto *show_mixer=new Fl_Button(240,260,170,30,"Show mixer window");
show_mixer->callback([](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->mixer_window_->show();},this);
auto *dock_mixer=new Fl_Button(424,260,140,30,"Dock mixer");
dock_mixer->callback([](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->detach_mixer(false);},this);
mixer_placeholder_->end(); mixer_placeholder_->hide();
mixer->resizable(mixer_);
2026-10-26 22:51:25 +01:00
mixer->end();
auto *graph = new Fl_Group(216, 158, 1056, 546, "Modular Graph");
2026-10-26 22:51:25 +01:00
graph->begin();
editors_[1].owner=this;editors_[1].index=1;editors_[1].page=graph;
editors_[1].content=new Fl_Group(216,158,1056,546);editors_[1].content->begin();
graph_state_ = new Fl_Box(228, 200, 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(228, 666, 1032, 28,
2026-10-27 01:37:21 +01:00
"Add nodes. Drag output to input to connect; click a node or wire then Delete. Scroll to pan.");
feedback->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP);
feedback->labelsize(12);
auto *scroll = new Fl_Scroll(228, 230, 1032, 430);
graph_widget_ = new GraphWidget(228, 230, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback);
scroll->end();
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_);
auto *oscillator = new Fl_Button(228, 168, 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_);
auto *gain = new Fl_Button(366, 168, 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_);
auto *meter = new Fl_Button(484, 168, 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_);
auto *output = new Fl_Button(602, 168, 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_);
auto *audio = new Fl_Button(858, 168, 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_);
auto *instrument = new Fl_Button(996, 168, 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_);
auto *remove = new Fl_Button(720, 168, 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_);
editors_[1].content->resizable(scroll);editors_[1].content->end();
graph->resizable(editors_[1].content);graph->end();
piano_page_=new Fl_Group(216,158,1056,546,"Piano roll");
piano_placeholder_=new Fl_Group(228,210,1000,120);
auto *piano_hint=new Fl_Box(240,220,900,30,"Select a MIDI clip, then open its piano roll. Float or dock it from View.");piano_hint->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE);
auto *open_piano=new Fl_Button(240,260,190,30,"Open selected MIDI clip");open_piano->callback(piano_callback,this);
piano_placeholder_->end();piano_page_->end();
for(auto &panel:editors_) {
panel.page->begin();panel.placeholder=new Fl_Group(228,210,1000,120);
auto *hint=new Fl_Box(240,220,900,30,"This editor is open in a separate window.");hint->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE);
auto *show=new Fl_Button(240,260,170,30,"Show editor window");
show->callback([](Fl_Widget *,void *c){auto *p=static_cast<EditorPanel *>(c);if(p->window)p->window->show();},&panel);
auto *dock=new Fl_Button(424,260,140,30,"Dock editor");
dock->callback([](Fl_Widget *,void *c){auto *p=static_cast<EditorPanel *>(c);p->owner->detach_editor(p->index,false);},&panel);
panel.placeholder->end();panel.placeholder->hide();panel.page->end();
}
tabs_->end();
tabs_->value(sequencer);
menu->add("View/Sequencer", FL_CTRL + '1', view_callback, sequencer);
menu->add("View/Mixer", FL_CTRL + '2', view_callback, mixer);
menu->add("View/Modular Graph", FL_CTRL + '3', view_callback, graph);
menu->add("Help/About", 0, about_callback);
2026-10-26 22:51:25 +01:00
auto *sidebar = new Fl_Group(12,126,192,578);
sidebar_=sidebar;
sidebar->box(FL_BORDER_BOX);
auto *create_title = new Fl_Box(24,140,168,24,"CREATE TRACKS");
create_title->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
auto *midi_track = new Fl_Button(24,176,168,34,"New MIDI track");
midi_track->tooltip("Create a track and one-bar MIDI clip at the cursor; undo as one edit.");
midi_track->callback([](Fl_Widget *, void *c) { auto *w = static_cast<MainWindow *>(c);
xtc_result r = w->create_track(true); if (r != XTC_OK) w->report_error("Create MIDI track",r); },this);
auto *audio_track = new Fl_Button(24,218,168,34,"New audio track");
audio_track->tooltip("Create an empty audio track, ready for WAV import or recording.");
audio_track->callback([](Fl_Widget *, void *c) { auto *w = static_cast<MainWindow *>(c);
xtc_result r = w->create_track(false); if (r != XTC_OK) w->report_error("Create audio track",r); },this);
menu->add("Track/New MIDI track",FL_CTRL+FL_SHIFT+'m',midi_track->callback(),this);
menu->add("Track/New audio track",FL_CTRL+FL_SHIFT+'a',audio_track->callback(),this);
auto *import = new Fl_Button(24,260,168,34,"Import WAV");
import->callback(audio_file_callback,this);
auto *load = new Fl_Button(24,302,168,34,"Load instrument plugin");
load->callback(instrument_callback,this);
auto *editor = new Fl_Button(24,344,168,34,"Edit MIDI notes");
editor->callback(piano_callback,this);
2026-10-28 17:16:01 +01:00
auto *selection_title = new Fl_Box(24,404,168,24,"TRACK INSPECTOR");
selection_title->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
2026-10-28 17:16:01 +01:00
selection_info_ = new Fl_Box(24,438,168,82,"Select a track to inspect it.");
selection_info_->align(FL_ALIGN_TOP_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP);
2026-10-28 17:16:01 +01:00
inspector_gain_ = new Fl_Value_Input(78, 528, 114, 26, "Gain");
inspector_gain_->bounds(0, 4);
inspector_gain_->step(.01);
inspector_pan_ = new Fl_Value_Input(78, 562, 114, 26, "Pan");
inspector_pan_->bounds(-1, 1);
inspector_pan_->step(.01);
inspector_mute_ = new Fl_Check_Button(24, 596, 78, 24, "Mute");
inspector_solo_ = new Fl_Check_Button(108, 596, 84, 24, "Solo");
for (auto *control : {static_cast<Fl_Widget *>(inspector_gain_), static_cast<Fl_Widget *>(inspector_pan_),
static_cast<Fl_Widget *>(inspector_mute_), static_cast<Fl_Widget *>(inspector_solo_)}) {
control->when(FL_WHEN_RELEASE);
control->callback(inspector_callback, this);
}
inspector_gain_->tooltip(
"Selected track linear gain: 0 silence, 1 unity, 4 maximum. Automation overrides static gain.");
inspector_pan_->tooltip("Selected track pan: -1 left, 0 center, +1 right.");
auto *tip = new Fl_Box(24, 632, 168, 52, "Ctrl+P Commands\nCtrl+1/2/3 Views\nCtrl+0 Fit song");
tip->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
sidebar->end();
status_ = new Fl_Box(12,714,1256,26,"JACK: not connected");
status_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
error_ = new Fl_Box(12,744,1256,44,"");
error_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP);
xtc_ui::style(this);
color(xtc_ui::background());
create_title->labelcolor(xtc_ui::muted()); selection_title->labelcolor(xtc_ui::muted());
tip->labelcolor(xtc_ui::muted()); hint->labelcolor(xtc_ui::muted());
transport_position_->box(xtc_ui::flat); transport_position_->color(xtc_ui::background());
transport_position_->labelfont(FL_COURIER_BOLD); transport_position_->labelsize(18);
tempo_display_->labelcolor(xtc_ui::muted());
midi_track->color(fl_rgb_color(45,91,86));
play_->labelcolor(xtc_ui::accent()); record_->labelcolor(fl_rgb_color(245,132,142));
error_->labelcolor(fl_rgb_color(255,166,139));
2026-10-26 22:51:25 +01:00
end();
resizable(tabs_);
size_range(1220, 720);
callback(close_callback, this);
apply_ui_preferences();
open_backend();
if(config_result!=XTC_OK && config_result!=XTC_ERR_NOT_FOUND) report_error("Preferences file invalid; using defaults",config_result);
Fl::add_timeout(static_cast<double>(settings_.refresh_ms)/1000,timer_callback,this);
2026-10-26 22:51:25 +01:00
}
void MainWindow::detach_editor(unsigned int index,bool detached)
{
if(index>=2)return;
auto &panel=editors_[index];if(!panel.content || panel.detached==detached)return;
auto *scroll=index==0 ? timeline_scroll_ : dynamic_cast<Fl_Scroll *>(graph_widget_->parent());
int scroll_x=scroll ? scroll->xposition() : 0,scroll_y=scroll ? scroll->yposition() : 0;
if(detached && !panel.window) {
auto *current=Fl_Group::current();Fl_Group::current(nullptr);
panel.window.reset(new PanelWindow(1100,650,index==0 ? "xtc — Sequencer (close to dock)" : "xtc — Modular Graph (close to dock)",this));
auto *dock=new Fl_Button(12,6,150,28,"Dock editor");dock->callback([](Fl_Widget *,void *c){auto *p=static_cast<EditorPanel *>(c);p->owner->detach_editor(p->index,false);},&panel);
panel.window->end();panel.window->size_range(index==0 ? 1100 : 1000,420);xtc_ui::style(panel.window.get());
panel.window->callback([](Fl_Widget *,void *c){auto *p=static_cast<EditorPanel *>(c);p->owner->detach_editor(p->index,false);},&panel);
restore_panel(panel.window.get(),static_cast<int>(index==0 ? settings_.sequencer_x : settings_.graph_x),static_cast<int>(index==0 ? settings_.sequencer_y : settings_.graph_y),static_cast<int>(index==0 ? settings_.sequencer_width : settings_.graph_width),static_cast<int>(index==0 ? settings_.sequencer_height : settings_.graph_height),index==0 ? 1100 : 1000,420);
Fl_Group::current(current);
}
panel.content->parent()->remove(panel.content);panel.detached=detached;
if(detached) {
panel.page->resizable(nullptr);panel.window->add(panel.content);
panel.content->resize(12,44,panel.window->w()-24,panel.window->h()-56);panel.window->resizable(panel.content);panel.placeholder->show();
} else {
panel.window->resizable(nullptr);panel.page->add(panel.content);
panel.content->resize(panel.page->x(),panel.page->y(),panel.page->w(),panel.page->h());
panel.page->resizable(panel.content);panel.placeholder->hide();panel.window->hide();
}
if(scroll)scroll->scroll_to(scroll_x,scroll_y);
panel.content->show();panel.page->init_sizes();
if(index==0)settings_.sequencer_detached=detached;else settings_.graph_detached=detached;
auto *item=const_cast<Fl_Menu_Item *>(menu_->find_item(index==0 ? "View/Detach sequencer" : "View/Detach modular graph"));if(detached)item->set();else item->clear();
if(detached)panel.window->show();
redraw();
}
void MainWindow::detach_piano(bool detached)
{
settings_.piano_detached=detached;
auto *item=const_cast<Fl_Menu_Item *>(menu_->find_item("View/Float piano roll"));if(detached)item->set();else item->clear();
if(!piano_content_) {piano_detached_=detached;return;}
if(piano_content_->window()==piano_window_.get() && detached) {piano_detached_=true;return;}
if(piano_content_->parent()==piano_page_ && !detached) {piano_detached_=false;return;}
auto *scroll=dynamic_cast<Fl_Scroll *>(piano_->parent());int sx=scroll->xposition(),sy=scroll->yposition();
piano_content_->parent()->remove(piano_content_);piano_detached_=detached;
if(detached) {
piano_page_->resizable(nullptr);piano_placeholder_->show();piano_window_->add(piano_content_);
piano_content_->resize(0,0,piano_window_->w(),piano_window_->h());piano_window_->resizable(piano_content_);piano_window_->show();
} else {
piano_window_->resizable(nullptr);piano_placeholder_->hide();piano_page_->add(piano_content_);
piano_content_->resize(piano_page_->x(),piano_page_->y(),piano_page_->w(),piano_page_->h());
piano_page_->resizable(piano_content_);piano_window_->hide();tabs_->value(piano_page_);
}
scroll->scroll_to(sx,sy);piano_content_->show();piano_page_->init_sizes();
redraw();
}
void MainWindow::reset_layout()
{
detach_transport(false);detach_mixer(false);detach_editor(0,false);detach_editor(1,false);detach_piano(false);
settings_.transport_x=settings_.transport_y=settings_.sequencer_x=settings_.sequencer_y=settings_.graph_x=settings_.graph_y=80;
settings_.mixer_x=settings_.mixer_y=settings_.piano_x=settings_.piano_y=100;
settings_.transport_width=800;settings_.transport_height=300;settings_.mixer_width=1056;settings_.mixer_height=600;
settings_.sequencer_width=settings_.graph_width=1100;settings_.sequencer_height=settings_.graph_height=650;
settings_.piano_width=900;settings_.piano_height=620;settings_.track_header_width=180;
sequencer_->header_width(180);tabs_->value(editors_[0].page);apply_ui_preferences();
}
void MainWindow::resize(int x,int y,int width,int height)
{
Fl_Double_Window::resize(x,y,width,height);
if(transport_group_)layout_transport();
}
void MainWindow::layout_transport()
{
if(layout_transport_busy_ || !transport_group_ || transport_items_.empty())return;
layout_transport_busy_=true;
int width=transport_detached_ && transport_window_ ? transport_window_->w() : w();
int origin=transport_detached_ ? 32 : 28;
bool compact=transport_detached_ && width<1220;
int left=12,top=12;
transport_group_->resizable(nullptr);
for(const auto &item:transport_items_) {
int x,y;
if(compact) {
if(left+item.w+(item.widget==tempo_display_ ? 36 : 0)>width-12){left=12;top+=40;}
x=left;y=origin+top;left+=item.w+(item.widget==tempo_display_ ? 36 : 0)+8;
} else {x=item.x*width/1280;y=origin+item.y;}
item.widget->resize(x,y,item.w,item.h);
}
int height=compact ? top+48 : 90;
transport_group_->resize(0,origin,width,height);
// Group resize can transform children: restore exact layout after it.
left=12;top=12;
for(const auto &item:transport_items_) {
int x,y;if(compact){if(left+item.w+(item.widget==tempo_display_ ? 36 : 0)>width-12){left=12;top+=40;}x=left;y=origin+top;left+=item.w+(item.widget==tempo_display_ ? 36 : 0)+8;}
else{x=item.x*width/1280;y=origin+item.y;}
item.widget->resize(x,y,item.w,item.h);
}
transport_group_->init_sizes();
if(transport_detached_ && transport_window_ && transport_window_->h()<origin+height+8)
transport_window_->resize(transport_window_->x(),transport_window_->y(),width,origin+height+8);
layout_transport_busy_=false;
}
void MainWindow::detach_transport(bool detached)
{
if (detached==transport_detached_ || !transport_group_) return;
if (detached && !transport_window_) {
auto *current=Fl_Group::current(); Fl_Group::current(nullptr);
transport_window_.reset(new PanelWindow(static_cast<int>(settings_.transport_width),static_cast<int>(settings_.transport_height),"xtc — Transport (close to dock)",this));
auto *dock=new Fl_Button(12,4,140,24,"Dock transport");
dock->callback([](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->detach_transport(false);},this);
xtc_ui::style(transport_window_.get());
transport_window_->end(); transport_window_->size_range(400,130);
transport_window_->callback([](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->detach_transport(false);},this);
restore_panel(transport_window_.get(),static_cast<int>(settings_.transport_x),static_cast<int>(settings_.transport_y),static_cast<int>(settings_.transport_width),static_cast<int>(settings_.transport_height),400,130);
Fl_Group::current(current);
}
transport_detached_=detached;settings_.transport_detached=detached;
transport_group_->parent()->remove(transport_group_);
if (detached) {
transport_window_->add(transport_group_);
transport_group_->resize(0,32,transport_window_->w(),90);
transport_window_->resizable(transport_group_);
} else {
transport_window_->resizable(nullptr);
add(transport_group_); transport_group_->resize(0,28,w(),90);
transport_window_->hide();
}
int delta=detached ? -90 : 90;
tabs_->resize(tabs_->x(),tabs_->y()+delta,tabs_->w(),tabs_->h()-delta);
sidebar_->resize(sidebar_->x(),sidebar_->y()+delta,sidebar_->w(),sidebar_->h()-delta);
init_sizes();
auto *item=const_cast<Fl_Menu_Item *>(menu_->find_item("View/Detach transport"));
if (detached) item->set(); else item->clear();
layout_transport();
if (detached) transport_window_->show();
redraw();
}
void MainWindow::detach_mixer(bool detached)
{
if (detached==mixer_detached_ || !mixer_) return;
if (detached && !mixer_window_) {
auto *current=Fl_Group::current(); Fl_Group::current(nullptr);
mixer_window_.reset(new PanelWindow(1056,600,"xtc — Mixer (close to dock)",this));
auto *dock=new Fl_Button(12,6,140,28,"Dock mixer");
dock->callback([](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->detach_mixer(false);},this);
xtc_ui::style(mixer_window_.get());
mixer_window_->end(); mixer_window_->size_range(600,420);
mixer_window_->callback([](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->detach_mixer(false);},this);
restore_panel(mixer_window_.get(),static_cast<int>(settings_.mixer_x),static_cast<int>(settings_.mixer_y),static_cast<int>(settings_.mixer_width),static_cast<int>(settings_.mixer_height),600,420);
Fl_Group::current(current);
}
mixer_detached_=detached;settings_.mixer_detached=detached;
mixer_->parent()->remove(mixer_);
if (detached) {
mixer_page_->resizable(nullptr);
mixer_window_->add(mixer_); mixer_->resize(12,44,mixer_window_->w()-24,mixer_window_->h()-56);
mixer_window_->resizable(mixer_); mixer_placeholder_->show();
} else {
mixer_window_->resizable(nullptr);
mixer_page_->add(mixer_);
mixer_->resize(mixer_page_->x()+12,mixer_page_->y()+10,mixer_page_->w()-24,mixer_page_->h()-24);
mixer_page_->resizable(mixer_); mixer_placeholder_->hide(); mixer_window_->hide();
}
mixer_->show(); mixer_page_->init_sizes();
auto *item=const_cast<Fl_Menu_Item *>(menu_->find_item("View/Detach mixer"));
if (detached) item->set(); else item->clear();
if (detached) mixer_window_->show();
redraw();
}
void MainWindow::draw()
{
// FLTK may reload the platform scheme when a window first becomes visible.
// Keep built-in arrows, popups and choice fields flat as well as our boxes.
xtc_ui::configure_boxes();
Fl_Double_Window::draw();
}
/**
* @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.
*/
MainWindow::~MainWindow()
2026-10-26 22:51:25 +01:00
{
Fl::remove_timeout(timer_callback, this);
// Return ownership before member windows and the main widget tree destruct.
detach_transport(false);
detach_mixer(false);detach_editor(0,false);detach_editor(1,false);detach_piano(false);
// 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);
else if (recorder_) {
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_));
xtc_recorder_destroy(recorder_);
}
}
/**
* @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.
*/
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_OUT_OF_MEMORY ? "Insufficient memory; the command was not accepted."
: result == XTC_ERR_CAPACITY ? "A project capacity limit was reached."
: result == XTC_ERR_NOT_FOUND || result == XTC_ERR_INVALID_ARGUMENT ? "Check the selected target and entered values."
: result == XTC_ERR_UNSUPPORTED ? "Check optional build support and the requested format or plugin layout."
: "Check the operation details; for audio failures use Reconnect to retry.";
std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint);
set_label(error_, text);
}
/**
* @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.
*/
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;
xtc_graph *candidate = nullptr;
size_t added_id = SIZE_MAX;
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;
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);
xtc_graph_destroy(candidate);
if (result == XTC_OK) result = xtc_history_reserve(window->history_.get());
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;
}
window->project_.reset(replacement);
window->graph_widget_->set_graph(xtc_project_graph(replacement));
window->sequencer_->set_project(replacement, false);
window->mark_dirty();
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;
window->refresh_status();
return XTC_OK;
}
/**
* @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.
*/
void MainWindow::open_backend()
{
xtc_backend_config config = {XTC_BACKEND_JACK, settings_.client_name, static_cast<size_t>(settings_.max_frames), static_cast<size_t>(settings_.channels)};
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);
else {
xtc_backend_status format{};
result = xtc_backend_get_status(backend_, &format);
if (result == XTC_OK)
result = xtc_backend_set_transition_frames(backend_, static_cast<size_t>(format.sample_rate)*static_cast<size_t>(settings_.transition_ms)/1000U);
if (result == XTC_OK)
result = xtc_backend_submit(backend_, xtc_project_graph(project_.get()));
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_, "");
}
refresh_status();
}
/**
* @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.
*/
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;
xtc_result result = configure_loop(project_.get(),settings_);
if(result==XTC_OK) 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);
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. Use File/Relink selected audio or Load sampler WAV to repair.", issue);
set_label(error_, message);
}
}
refresh_status();
}
/**
* @brief Requests a graceful playback stop, or immediately finalizes recording.
*
* Does nothing when disconnected. Failures are reported in the window.
*/
void MainWindow::stop_engine()
{
2026-10-27 01:37:21 +01:00
if (!ready_)
return;
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);
else {
set_label(error_, "");
2026-10-27 01:37:21 +01:00
if (recorder_)
finish_recording();
}
refresh_status();
}
xtc_result MainWindow::pause_playback()
{
xtc_result result = recorder_ || !ready_ ? XTC_ERR_INVALID_STATE : xtc_backend_request_stop(backend_);
if (result != XTC_OK)
report_error("Pause failed; use Stop to finish recording or Reconnect audio", result);
else
set_label(error_, "");
refresh_status();
return result;
}
xtc_result MainWindow::return_to_start()
{
if (recorder_) {
report_error("Stop recording before returning to start", XTC_ERR_INVALID_STATE);
return XTC_ERR_INVALID_STATE;
}
xtc_result result = XTC_OK;
if (ready_) {
result = xtc_backend_stop(backend_);
if (result == XTC_OK)
result = xtc_backend_seek(backend_, 0);
}
if (result != XTC_OK)
report_error("Return to start failed", result);
else {
sequencer_->position(0);
set_label(error_, "");
}
refresh_status();
return result;
}
/**
* @brief Closes and reopens the JACK backend, leaving the engine stopped.
*
* Refuses to run while recording. If closing fails the existing handle is
* retained for a later retry and the failure is reported.
*/
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;
}
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();
}
/**
* @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.
*/
void MainWindow::update_history_controls()
{
size_t undo_count = 0, redo_count = 0;
if (!menu_ || xtc_history_counts(history_.get(),&undo_count,&redo_count) != XTC_OK) return;
for (const char *path : {"Edit/Undo","Edit/Redo","Edit/Redo alternate"}) {
int index = menu_->find_index(path);
if (index >= 0) menu_->mode(index,(!recorder_ && (std::strcmp(path,"Edit/Undo") == 0 ? undo_count : redo_count)) ? 0 : FL_MENU_INACTIVE);
}
}
void MainWindow::refresh_status()
{
refresh_workspace();
update_history_controls();
if (mixer_ && !ready_) mixer_->meters(xtc_meter_snapshot{});
bool can_play = false, can_record = 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));
set_label(graph_state_, "Graph: offline — edits stored locally; Reconnect to play");
} else {
xtc_backend_collect(backend_); // Completes an acknowledged graceful stop before reading status.
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(graph_state_, "Graph: engine status unavailable");
set_label(status_, "Unable to read JACK status — use Reconnect");
indicator_->labelcolor(fl_rgb_color(255, 155, 120));
} else {
uint64_t tick = 0;
if (xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &tick) == XTC_OK)
sequencer_->position(tick);
running_ = status.running != 0;
const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK;
can_play = !running_ && !fault;
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"
: 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";
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));
xtc_meter_snapshot meters{};
if (xtc_backend_read_meters(backend_, &meters) == XTC_OK) mixer_->meters(meters);
else { meters.master_left=status.peak_left;meters.master_right=status.peak_right;mixer_->meters(meters); }
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",
status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns,
xtc_result_string(status.process_result), status.asset_issues, status.midi_dropped);
set_label(status_, text);
2026-10-27 01:37:21 +01:00
if (fault)
set_label(graph_state_, "Graph: engine unavailable — Reconnect required");
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-27 01:37:21 +01:00
if (can_play)
play_->activate();
else
play_->deactivate();
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();
}
/**
* @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.
*/
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;
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);
for(auto &panel:editors_)if(panel.window)panel.window->hide();
2026-10-27 01:37:21 +01:00
if (piano_window_)
piano_window_->hide();
if (transport_window_) transport_window_->hide();
if (mixer_window_) mixer_window_->hide();
hide();
return true;
}
/**
* @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.
*/
int MainWindow::panel_shortcut(int event)
{
if (event == FL_SHORTCUT && dynamic_cast<Fl_Input_ *>(Fl::focus()) &&
(Fl::event_key() == ' ' || (Fl::event_state(FL_CTRL) &&
(Fl::event_key() == 'z' || Fl::event_key() == 'y' || Fl::event_key() == 'd')))) return 0;
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();
return 1;
}
if (event == FL_SHORTCUT) return menu_->test_shortcut() != nullptr;
return 0;
}
int MainWindow::handle(int event)
{
if (panel_shortcut(event)) return 1;
return Fl_Double_Window::handle(event);
}
2026-10-27 01:37:21 +01:00
/**
* @brief FLTK callback for the Play menu item and button.
*
* @param context MainWindow * whose engine is started.
*/
void MainWindow::play_callback(Fl_Widget *, void *context)
2026-10-27 01:37:21 +01:00
{
static_cast<MainWindow *>(context)->start_engine();
}
/**
* @brief FLTK callback for the Stop menu item and button.
*
* @param context MainWindow * whose engine is stopped.
*/
void MainWindow::stop_callback(Fl_Widget *, void *context)
2026-10-27 01:37:21 +01:00
{
static_cast<MainWindow *>(context)->stop_engine();
}
/**
* @brief FLTK callback for the Reconnect menu item and button.
*
* @param context MainWindow * whose backend is reopened.
*/
void MainWindow::reconnect_callback(Fl_Widget *, void *context)
2026-10-27 01:37:21 +01:00
{
static_cast<MainWindow *>(context)->reconnect_engine();
}
/**
* @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.
*/
void MainWindow::close_callback(Fl_Widget *, void *context)
2026-10-27 01:37:21 +01:00
{
(void)static_cast<MainWindow *>(context)->close_window();
}
/**
* @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.
*/
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();
for(auto &panel:window->editors_)if(view==panel.page && panel.detached)panel.window->show();
if(view==window->piano_page_) {if(window->open_piano_roll()!=XTC_OK)window->tabs_->value(window->editors_[0].page);}
if (view==window->mixer_page_ && window->mixer_detached_) window->mixer_window_->show();
}
2026-10-27 01:37:21 +01:00
/**
* @brief FLTK help callback that shows the application name and version.
*
*/
void MainWindow::about_callback(Fl_Widget *, void *)
{
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-27 01:37:21 +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().
*/
void MainWindow::timer_callback(void *context)
{
xtc_plugin_idle();
auto *window = static_cast<MainWindow *>(context);
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();
}
if(window->settings_.stop_after_tail && !window->settings_.loop_enabled && !window->recorder_) {
xtc_backend_status status{};uint64_t end=0;
if(xtc_backend_get_status(window->backend_,&status)==XTC_OK && status.running &&
xtc_project_render_length(window->project_.get(),status.sample_rate,static_cast<unsigned int>(window->settings_.tail_ms),&end)==XTC_OK && status.sample_position>=end)
window->stop_engine();
}
window->refresh_status();
2026-10-28 17:16:01 +01:00
window->follow_playhead();
2026-10-27 01:37:21 +01:00
if (!window->closing_)
Fl::repeat_timeout(static_cast<double>(window->settings_.refresh_ms)/1000,timer_callback,context);
2026-10-26 22:51:25 +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.
*/
void MainWindow::mark_dirty()
{
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);
mixer_->set_project(project_.get());
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;
update_history_controls();
2026-10-27 01:37:21 +01:00
copy_label("xtc * — unsaved changes");
}
2026-10-27 01:37:21 +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.
*/
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 01:37:21 +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.
*/
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;
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);
return r == XTC_OK ? xtc_backend_seek(window->backend_, sample) : r;
}
2026-10-27 01:37:21 +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.
*/
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;
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());
}
return r;
}
2026-10-27 01:37:21 +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.
*/
xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_history)
{
2026-10-27 01:37:21 +01:00
if (recorder_)
return XTC_ERR_INVALID_STATE;
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;
new_history.reset(h);
}
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=configure_loop(candidate,settings_);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_backend_prepare_project(backend_, candidate);
}
xtc_audio_plan *offline = nullptr;
2026-10-27 01:37:21 +01:00
if (r == XTC_OK && !ready_)
r = xtc_audio_plan_create(candidate, static_cast<unsigned int>(settings_.offline_rate), &offline);
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, static_cast<unsigned int>(settings_.offline_rate), &offline_midi);
size_t sample_issues=xtc_midi_plan_asset_issues(offline_midi);
xtc_midi_plan_destroy(offline_midi);
if (r == XTC_OK && !ready_) {
xtc_plan *graph = nullptr;
r = xtc_graph_compile(xtc_project_graph(candidate),static_cast<double>(settings_.offline_rate),256,&graph);
xtc_plan_destroy(graph);
}
2026-10-27 01:37:21 +01:00
if (r != XTC_OK) {
xtc_audio_plan_destroy(offline);
refresh_status(); // A failed install may have stopped playback.
2026-10-27 01:37:21 +01:00
return r;
}
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;
xtc_audio_plan_destroy(offline);
}
if(sample_issues)issue="Sampler WAV missing/invalid; File/Load sampler WAV relinks the selected track";
2026-10-27 01:37:21 +01:00
if (piano_window_)
piano_window_->hide();
if (piano_)
piano_->set_project(candidate, UINT64_MAX);
project_.reset(candidate);
mixer_->set_project(candidate);
if (reset_history) {
2026-10-27 01:37:21 +01:00
history_.swap(new_history);
for(auto &snapshot:mix_snapshots_)snapshot.reset();
}
graph_widget_->reset_layout();
graph_widget_->set_graph(xtc_project_graph(candidate));
sequencer_->set_project(candidate);
if (reset_history)
tabs_->value(tabs_->child(0));
dirty_ = false;
if (!issue.empty()) {
2026-10-27 01:37:21 +01:00
char text[640];
std::snprintf(text, sizeof(text), "Missing/unreadable audio (silent): %s. Use File/Relink selected audio or Load sampler WAV to repair.",
2026-10-27 01:37:21 +01:00
issue.c_str());
set_label(error_, text);
2026-10-27 01:37:21 +01:00
} else
set_label(error_, "");
refresh_status();
return XTC_OK;
}
2026-10-27 01:37:21 +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.
*/
xtc_result MainWindow::open_project(const char *path)
{
BusyOperation busy(*this,status_,"Loading and preparing project…");
2026-10-27 01:37:21 +01:00
if (path == nullptr)
return XTC_ERR_INVALID_ARGUMENT;
project_issue_.clear();
std::string loaded_path(path);
xtc_project *candidate = nullptr;
xtc_result r = xtc_project_create(&candidate);
xtc_project_diagnostic diagnostic{};
if (r == XTC_OK) {
r = xtc_project_load_diagnostic(candidate,path,&diagnostic);
if (r != XTC_OK) {
project_issue_ = "Project load line " + std::to_string(diagnostic.line) + " (" + diagnostic.record + "): " + diagnostic.message;
report_error(project_issue_.c_str(),r);
}
}
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = install_project(candidate);
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 = sample_rate(); 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);
} else {
2026-10-27 01:37:21 +01:00
path_ = loaded_path;
copy_label(path_.c_str());
}
return r;
}
2026-10-27 01:37:21 +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) project_defaults(candidate);
if (result == XTC_OK)
result = install_project(candidate);
if (result != XTC_OK)
xtc_project_destroy(candidate);
else {
path_.clear();
copy_label("xtc — Untitled");
}
return result;
}
/**
* @brief FLTK menu callback for the File/New, Open, Save, and Save As items.
*
* 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.
*/
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;
}
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
const char *action = menu->mvalue()->label();
xtc_result r = XTC_OK;
window->project_issue_.clear();
if (std::strcmp(action, "New") == 0) {
2026-10-27 01:37:21 +01:00
if (!window->discard_changes())
return;
r = window->new_project();
} else {
bool open = std::strcmp(action, "Open") == 0;
2026-10-27 01:37:21 +01:00
if (open && !window->discard_changes())
return;
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);
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 01:37:21 +01:00
if (r != XTC_OK)
window->report_error(window->project_issue_.empty() ? "Project operation failed" : window->project_issue_.c_str(), r);
}
/**
* @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.
*/
xtc_result MainWindow::before_arrangement_edit(void *context)
{
auto *window = static_cast<MainWindow *>(context);
if (window->recorder_) return XTC_ERR_INVALID_STATE;
xtc_result r = xtc_history_reserve(window->history_.get());
if (r == XTC_OK && window->ready_) r = xtc_backend_stop(window->backend_);
window->refresh_status();
return r;
}
2026-10-27 01:37:21 +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.
*/
xtc_result MainWindow::import_audio(const char *path)
{
BusyOperation busy(*this,status_,"Importing audio and preparing playback…");
xtc_result r = before_arrangement_edit(this);
2026-10-27 01:37:21 +01:00
if (r != XTC_OK)
return r;
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;
}
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 01:37:21 +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.
*/
xtc_result MainWindow::record_audio(const char *path)
{
if (!ready_ || recorder_ || settings_.loop_enabled)
2026-10-27 01:37:21 +01:00
return XTC_ERR_INVALID_STATE;
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) * static_cast<size_t>(settings_.recording_seconds), &recorder_);
2026-10-27 01:37:21 +01:00
if (r != XTC_OK)
return r;
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_);
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;
}
2026-10-27 01:37:21 +01:00
refresh_status();
return r;
}
2026-10-27 01:37:21 +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.
*/
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_);
std::string recovery_path = xtc_recorder_recovery_path(recorder_);
2026-10-27 01:37:21 +01:00
xtc_recorder_destroy(recorder_);
recorder_ = nullptr;
if (r != XTC_OK) {
2026-10-27 01:37:21 +01:00
char text[768];
std::snprintf(text, sizeof(text),
"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;
}
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_history_reserve(history_.get());
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_backend_prepare_project(backend_, candidate);
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 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 01:37:21 +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.
*/
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() :
(widget && widget->label() && !std::strcmp(widget->label(),"Import WAV") ? "Import WAV" : "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;
}
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");
// Recordings require a new destination; existing files are never replaced.
int chosen = chooser.show();
2026-10-27 01:37:21 +01:00
if (chosen == 1)
return;
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)
window->report_error(recording ? "Project recording failed; choose a new WAV path in a writable directory"
: "Project audio operation failed", r);
}
/**
* @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.
*/
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<PanelWindow>(900, 620, "xtc — Piano roll (close to dock)",this);
piano_window_->begin();
piano_content_=new Fl_Group(0,0,900,620);piano_content_->begin();
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);
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);
piano_snap_ = new Fl_Choice(60,46,150,26,"Snap");
for (size_t i = 0; i < xtc_ui::snap_count; ++i) { int item = piano_snap_->add("Snap"); piano_snap_->replace(item,xtc_ui::snap_labels[i]); }
piano_snap_->callback([](Fl_Widget *widget, void *c) {
auto *w = static_cast<MainWindow *>(c); int value = static_cast<Fl_Choice *>(widget)->value();
if (value >= 0 && static_cast<size_t>(value) < xtc_ui::snap_count) (void)w->piano_->set_grid(xtc_ui::snap_ticks[value]);
},this);
note_velocity_ = new Fl_Value_Slider(340,46,300,26,"Note velocity");
note_velocity_->align(FL_ALIGN_LEFT); note_velocity_->value(100);
note_velocity_->tooltip("Selected note MIDI velocity, 1..127. Instrument patches determine the response.");
note_velocity_->type(FL_HOR_FILL_SLIDER); note_velocity_->bounds(1,127); note_velocity_->step(1);
note_velocity_->when(FL_WHEN_RELEASE);
note_velocity_->callback([](Fl_Widget *widget, void *c) {
auto *w = static_cast<MainWindow *>(c);
(void)w->piano_->set_velocity(w->piano_->selected_note(),static_cast<unsigned int>(static_cast<Fl_Value_Slider *>(widget)->value()));
},this);
auto *scroll = new Fl_Scroll(12,84,876,404);
piano_ = new PianoRollWidget(12,84, 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();
velocity_lane_ = new VelocityLane(12,500,876,108,*piano_);
piano_content_->resizable(scroll);piano_content_->end();
2026-10-27 01:37:21 +01:00
piano_window_->end();
piano_window_->callback([](Fl_Widget *,void *c){static_cast<MainWindow *>(c)->detach_piano(false);},this);
piano_window_->size_range(800,520);
xtc_ui::style(piano_window_.get());
piano_window_->resizable(piano_content_);
scroll->scroll_to(0, 60 * 16);
auto px=settings_.piano_x,py=settings_.piano_y,pw=settings_.piano_width,ph=settings_.piano_height;
collect_preferences();settings_.piano_x=px;settings_.piano_y=py;settings_.piano_width=pw;settings_.piano_height=ph;
apply_ui_preferences();
2026-10-27 01:37:21 +01:00
} else
piano_->set_project(project_.get(), clip.id);
refresh_workspace();
detach_piano(settings_.piano_detached!=0);
if(piano_detached_)piano_window_->show();else {tabs_->value(piano_page_);tabs_->redraw();}
2026-10-27 01:37:21 +01:00
return XTC_OK;
}
2026-10-27 01:37:21 +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.
*/
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;
}
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 01:37:21 +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.
*/
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;
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);
}
/**
* @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.
*/
xtc_result MainWindow::undo(bool redo)
{
2026-10-27 01:37:21 +01:00
if (recorder_)
return XTC_ERR_INVALID_STATE;
const uint64_t track = sequencer_->selected_track(), clip = sequencer_->selected_clip();
const uint64_t mixer_track = mixer_->selected_track();
const size_t node = graph_widget_->selected_node();
const uint64_t piano_clip = piano_ ? piano_->clip() : UINT64_MAX;
const uint64_t note = piano_ ? piano_->selected_note() : UINT64_MAX;
const bool piano_visible = piano_window_ && piano_window_->shown();
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;
}
xtc_clip_info selected_clip{};
if (xtc_project_find_clip(project_.get(),clip,&selected_clip) == XTC_OK)
sequencer_->select(selected_clip.track_id,clip);
else sequencer_->select(track);
mixer_->select_track(mixer_track);
graph_widget_->select_node(node);
xtc_clip_info info{};
if (piano_ && xtc_project_find_clip(project_.get(),piano_clip,&info) == XTC_OK && info.midi) {
piano_->set_project(project_.get(),piano_clip); piano_->select_note(note);
if (piano_visible) piano_window_->show();
}
r = xtc_history_accept(history_.get(), redo ? 1 : 0);
2026-10-27 01:37:21 +01:00
dirty_ = true;
update_history_controls();
2026-10-27 01:37:21 +01:00
copy_label("xtc * — unsaved changes");
return r;
}
2026-10-27 01:37:21 +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.
*/
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);
}
xtc_result MainWindow::create_track(bool midi)
{
if (recorder_) return XTC_ERR_INVALID_STATE;
const uint64_t position = sequencer_->position();
xtc_project *candidate = nullptr;
uint64_t track = UINT64_MAX, clip = UINT64_MAX;
xtc_result r = xtc_project_clone(project_.get(),&candidate);
if (r == XTC_OK) r = xtc_project_add_track(candidate,midi ? "MIDI track" : "Audio track",&track);
if (r == XTC_OK && midi) r = xtc_project_add_midi_clip(candidate,track,position,static_cast<uint64_t>(settings_.clip_ticks),&clip);
if (r == XTC_OK) r = xtc_history_reserve(history_.get());
if (r == XTC_OK) r = install_project(candidate,false);
if (r != XTC_OK) xtc_project_destroy(candidate);
else {
sequencer_->select(track,clip);
mixer_->select_track(track); tabs_->value(tabs_->child(0)); mark_dirty();
}
return r;
}
void MainWindow::refresh_workspace()
{
2026-10-28 17:16:01 +01:00
if (loop_button_) {
loop_button_->value(settings_.loop_enabled != 0);
if (recorder_)
loop_button_->deactivate();
else
loop_button_->activate();
}
if (snap_)
snap_->value(xtc_ui::snap_index(sequencer_->grid()));
if (piano_) {
piano_snap_->value(xtc_ui::snap_index(piano_->grid()));
2026-10-28 17:16:01 +01:00
size_t count = 0;
bool selected = false;
xtc_project_note_count(project_.get(), &count);
for (size_t i = 0; i < count; ++i) {
2026-10-28 17:16:01 +01:00
xtc_note_info note{};
xtc_project_get_note(project_.get(), i, &note);
if (note.id == piano_->selected_note() && piano_->note_visible(note)) {
2026-10-28 17:16:01 +01:00
selected = true;
if (Fl::pushed() != note_velocity_)
note_velocity_->value(note.velocity);
break;
}
}
2026-10-28 17:16:01 +01:00
if (selected)
note_velocity_->activate();
else
note_velocity_->deactivate();
velocity_lane_->redraw();
}
2026-10-28 17:16:01 +01:00
char position[64], info[512];
uint64_t tick = sequencer_->position();
2026-10-28 17:16:01 +01:00
std::snprintf(position, sizeof(position), "%03llu : %u : %03u", static_cast<unsigned long long>(tick / 3840 + 1),
static_cast<unsigned int>((tick / 960) % 4 + 1), static_cast<unsigned int>(tick % 960));
set_label(transport_position_, position);
if (!editing_number(tempo_display_))
tempo_display_->value(xtc_project_tempo(project_.get()) / 1000.0);
if (recorder_)
tempo_display_->deactivate();
else
tempo_display_->activate();
size_t tracks = 0, clips = 0;
xtc_project_get_counts(project_.get(), &tracks, &clips);
uint64_t end = 0;
for (size_t i = 0; i < clips; ++i) {
xtc_clip_info clip{};
xtc_project_get_clip(project_.get(), i, &clip);
end = std::max(end, clip.start_tick + clip.length_tick);
}
2026-10-28 17:16:01 +01:00
std::snprintf(info, sizeof(info), "%zu tracks / %zu clips / %llu bars", tracks, clips,
static_cast<unsigned long long>((end + 3839) / 3840));
set_label(session_info_, info);
bool mixer_context = tabs_->value() == mixer_page_ ||
(mixer_detached_ && Fl::focus() && Fl::focus()->window() == mixer_window_.get());
uint64_t target = mixer_context ? mixer_->selected_track() : sequencer_->selected_track();
2026-10-28 17:16:01 +01:00
bool selection_changed = target != inspected_track_;
inspected_track_ = UINT64_MAX;
for (size_t i = 0; i < tracks; ++i) {
xtc_track_info track{};
xtc_project_get_track(project_.get(), i, &track);
if (track.id != target)
continue;
inspected_track_ = track.id;
for (auto *control : {static_cast<Fl_Widget *>(inspector_gain_), static_cast<Fl_Widget *>(inspector_pan_),
static_cast<Fl_Widget *>(inspector_mute_), static_cast<Fl_Widget *>(inspector_solo_)}) {
if (recorder_)
control->deactivate();
else
control->activate();
}
if (selection_changed || !editing_number(inspector_gain_))
inspector_gain_->value(track.gain);
if (selection_changed || !editing_number(inspector_pan_))
inspector_pan_->value(track.pan);
if (Fl::pushed() != inspector_mute_)
inspector_mute_->value(track.mute);
if (Fl::pushed() != inspector_solo_)
inspector_solo_->value(track.solo);
xtc_plugin_spec spec{};
bool plugin = xtc_project_get_instrument(project_.get(), track.id, &spec) == XTC_OK;
std::snprintf(info, sizeof(info), "%s\n%s %s%s\n%s", track.name, track.is_bus ? "BUS" : "TRACK",
track.mute ? "Muted " : "", track.solo ? "Solo" : "",
track.is_bus ? "Bus output"
: plugin ? spec.label
: "Built-in MIDI sine");
set_label(selection_info_, info);
return;
}
for (auto *control : {static_cast<Fl_Widget *>(inspector_gain_), static_cast<Fl_Widget *>(inspector_pan_),
static_cast<Fl_Widget *>(inspector_mute_), static_cast<Fl_Widget *>(inspector_solo_)})
control->deactivate();
std::snprintf(info, sizeof(info), "No track selected\nCreate a MIDI track or import a WAV to begin.");
set_label(selection_info_, info);
}
xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsigned int index)
{
uint64_t clip = sequencer_->selected_track() == track ? sequencer_->selected_clip() : UINT64_MAX;
if (recorder_) return XTC_ERR_INVALID_STATE;
xtc_project *candidate = nullptr;
xtc_plugin_spec spec{};
xtc_result r = path ? xtc_plugin_probe(path,index,sample_rate(),&spec) : XTC_OK;
if (r == XTC_OK && path && !spec.instrument) return XTC_ERR_UNSUPPORTED;
if (r == XTC_OK) r = xtc_project_clone(project_.get(),&candidate);
if (r == XTC_OK) r = xtc_project_set_instrument(candidate,track,path ? &spec : nullptr);
if (r == XTC_OK) r = xtc_history_reserve(history_.get());
if (r == XTC_OK) r = install_project(candidate,false);
if (r != XTC_OK) xtc_project_destroy(candidate); else { sequencer_->select(track,clip); mixer_->select_track(track); mark_dirty(); }
return r;
}
void MainWindow::export_audio()
{
if(recorder_){report_error("Stop recording before export",XTC_ERR_INVALID_STATE);return;}
const char *answer=fl_input("Export stereo WAV to a new absolute path (tail configured in Preferences)","");
if(!answer)return;
std::string path=answer;xtc_export *job=nullptr;
xtc_result r=xtc_export_create(project_.get(),path.c_str(),static_cast<unsigned int>(settings_.offline_rate),static_cast<unsigned int>(settings_.tail_ms),&job);
if(r==XTC_OK) {
Fl_Double_Window dialog(460,130,"Export WAV");dialog.set_modal();dialog.begin();
Fl_Box progress(12,12,436,45,"Preparing export");Fl_Button cancel(330,80,110,30,"Cancel export");
cancel.callback([](Fl_Widget *,void *c){static_cast<Fl_Window *>(c)->hide();},&dialog);
dialog.callback([](Fl_Widget *,void *c){static_cast<Fl_Window *>(c)->hide();},&dialog);dialog.end();xtc_ui::style(&dialog);dialog.show();
int done=0;
while(dialog.shown() && !done && r==XTC_OK) {
r=xtc_export_step(job,&done);uint64_t position=0,total=0;xtc_export_progress(job,&position,&total);
std::string text=std::to_string(position)+" / "+std::to_string(total)+" frames";progress.copy_label(text.c_str());Fl::check();
}
if(r==XTC_OK && !done)set_label(status_,"Export cancelled; destination retained");
else if(r==XTC_OK)set_label(status_,"Export complete");
dialog.hide();
}
xtc_export_destroy(job);
if(r!=XTC_OK)report_error("Export failed; destination must be new and media available",r);
}
xtc_result MainWindow::load_sampler(uint64_t track,const char *path)
{
if(!path || path[0]!='/' || std::strlen(path)>=sizeof(xtc_plugin_spec{}.sample_path))return XTC_ERR_INVALID_ARGUMENT;
xtc_plugin_spec spec{};xtc_result r=xtc_project_get_instrument(project_.get(),track,&spec);
if(r!=XTC_OK || spec.format!=XTC_PLUGIN_BUILTIN || spec.index!=4)r=xtc_plugin_probe(XTC_BUILTIN_PATH,4,sample_rate(),&spec);
if(r!=XTC_OK)return r;
std::strcpy(spec.sample_path,path);return set_instrument_spec(track,spec);
}
xtc_result MainWindow::set_instrument_spec(uint64_t track,const xtc_plugin_spec &spec)
{
if(recorder_)return XTC_ERR_INVALID_STATE;
uint64_t clip=sequencer_->selected_clip();xtc_project *candidate=nullptr;
xtc_result r=xtc_project_clone(project_.get(),&candidate);
if(r==XTC_OK)r=xtc_project_set_instrument(candidate,track,&spec);
if(r==XTC_OK)r=xtc_history_reserve(history_.get());
if(r==XTC_OK)r=install_project(candidate,false);
if(r!=XTC_OK)xtc_project_destroy(candidate);
else {sequencer_->select(track,clip);mixer_->select_track(track);mark_dirty();}
return r;
}
xtc_result MainWindow::select_live_instrument(uint64_t track)
{
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_live_instrument(candidate,track);
if (r == XTC_OK) r = xtc_history_reserve(history_.get());
if (r == XTC_OK) r = install_project(candidate,false);
if (r != XTC_OK) xtc_project_destroy(candidate); else mark_dirty();
return r;
}
void MainWindow::instrument_callback(Fl_Widget *widget, void *context)
{
auto *w = static_cast<MainWindow *>(context);
if (w->recorder_) { w->report_error("Stop recording before instrument changes",XTC_ERR_INVALID_STATE); return; }
auto *menu = dynamic_cast<Fl_Menu_Bar *>(widget);
const char *label = menu ? menu->mvalue()->label() : "Load instrument plugin";
uint64_t track = w->sequencer_->selected_track();
xtc_result r;
if(std::strcmp(label,"Load sampler WAV")==0) {
const char *path=fl_input("Sampler WAV path (absolute; replaces/relinks selected track sample)","");
if(path) {r=w->load_sampler(track,path);if(r!=XTC_OK)w->report_error("Sampler preparation failed",r);}
return;
}
if(std::strcmp(label,"Selected instrument controls")==0 || std::strcmp(label,"Selected instrument automation")==0 ||
std::strcmp(label,"Save instrument preset")==0 || std::strcmp(label,"Load instrument preset")==0) {
xtc_plugin_spec spec{};
if(std::strcmp(label,"Load instrument preset")==0) {
const char *path=fl_input("Instrument preset path (absolute)","");if(!path)return;
r=xtc_plugin_preset_load(path,&spec);
if(r==XTC_OK)r=w->set_instrument_spec(track,spec);
} else {
r=xtc_project_get_instrument(w->project_.get(),track,&spec);
if(r==XTC_OK && spec.format==XTC_PLUGIN_BUILTIN && !spec.control_count)
r=xtc_plugin_probe(XTC_BUILTIN_PATH,spec.index,w->sample_rate(),&spec);
if(r==XTC_OK && std::strcmp(label,"Save instrument preset")==0) {
const char *path=fl_input("Save instrument preset path (absolute)","");if(!path)return;
r=xtc_plugin_preset_save(&spec,path);
} else if(r==XTC_OK && std::strcmp(label,"Selected instrument controls")==0) {
xtc_plugin_details details{};xtc_plugin_spec edited{};
r=xtc_plugin_describe(&spec,w->sample_rate(),&details);
if(r==XTC_OK && xtc_edit_plugin(spec,details,edited))r=w->set_instrument_spec(track,edited);
} else if(r==XTC_OK) {
xtc_parameter_info info[XTC_PLUGIN_CONTROLS]{};r=xtc_plugin_parameters(&spec,w->sample_rate(),info);
std::string prompt="Instrument automation: port tick mode value (mode -1 removes)\n";
for(unsigned int i=0;i<spec.control_count;++i)prompt+=std::to_string(i)+": "+info[i].name+"\n";
if(r==XTC_OK) {
const char *answer=fl_input("%s","",prompt.c_str());if(!answer)return;
unsigned int 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 {
unsigned int i=0;for(;i<spec.point_count;++i)if(spec.points[i].port==port && spec.points[i].tick==tick)break;
if(mode== -1) {if(i==spec.point_count)r=XTC_ERR_NOT_FOUND;else {for(unsigned int j=i+1;j<spec.point_count;++j)spec.points[j-1]=spec.points[j];--spec.point_count;}}
else if(i==XTC_PLUGIN_POINTS)r=XTC_ERR_CAPACITY;
else {if(i==spec.point_count)++spec.point_count;spec.points[i]={static_cast<uint64_t>(tick),port,static_cast<unsigned int>(mode),value};}
if(r==XTC_OK)r=w->set_instrument_spec(track,spec);
}
}
}
}
if(r!=XTC_OK)w->report_error("Instrument edit failed; check track, preset and control ranges",r);
return;
}
if (std::strcmp(label,"Use built-in instrument") == 0) r = w->assign_instrument(track,nullptr,0);
else if (std::strcmp(label,"Live MIDI to selected instrument") == 0) r = w->select_live_instrument(track);
else if (std::strcmp(label,"Live MIDI to built-in sine") == 0) r = w->select_live_instrument(UINT64_MAX);
else {
std::string module; if(!xtc_choose_plugin_file(w->settings_,module)) return;
unsigned int index=0;
if(!xtc_choose_plugin(module.c_str(),w->sample_rate(),index,true))return;
r=w->assign_instrument(track,module.c_str(),index);
}
if (r != XTC_OK) w->report_error("Instrument assignment failed; select a non-bus track and compatible mono instrument",r);
}
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 = xtc_history_reserve(history_.get());
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);
}
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 = xtc_history_reserve(history_.get());
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)
{
BusyOperation busy(*this,status_,"Loading plugin and preparing playback…");
if (recorder_) return XTC_ERR_INVALID_STATE;
double rate = sample_rate();
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);
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 (r == XTC_OK && id) *id = added;
return r;
}
xtc_result MainWindow::replace_plugin(size_t id, const char *path, unsigned int index)
{
BusyOperation busy(*this,status_,"Replacing plugin and preparing playback…");
if (recorder_) return XTC_ERR_INVALID_STATE;
double rate = sample_rate(); 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 = sample_rate(); 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 = sample_rate(); 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;
}
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;
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;
double rate = sample_rate(); 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);
xtc_plugin_spec spec{};
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;
}
xtc_result MainWindow::apply_plugin_spec(size_t id, const xtc_plugin_spec &spec, bool replace)
{
xtc_graph *graph = nullptr;
xtc_result r = xtc_graph_clone(xtc_project_graph(project_.get()),&graph);
if (r == XTC_OK) r = replace ? xtc_graph_replace_plugin(graph,id,&spec) : xtc_graph_set_plugin(graph,id,&spec);
/* Compile before adoption even with a mocked/disconnected backend. */
xtc_plan *check = nullptr;
xtc_backend_status status{}; double rate = sample_rate();
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;
if(std::strcmp(label,"Insert stereo delay on wire")==0 || std::strcmp(label,"Insert stereo reverb on wire")==0) {
xtc_connection_info wire{};
r=w->graph_widget_->selected_connection(wire) ? w->insert_plugin(wire,XTC_BUILTIN_PATH,std::strcmp(label,"Insert stereo delay on wire")==0 ? 5U : 6U) : XTC_ERR_INVALID_STATE;
} else if(std::strcmp(label,"Add stereo delay")==0 || std::strcmp(label,"Add stereo reverb")==0) {
r=w->add_plugin(XTC_BUILTIN_PATH,std::strcmp(label,"Add stereo delay")==0 ? 5U : 6U);
} else if (std::strcmp(label,"Insert internal delay on wire")==0 || std::strcmp(label,"Insert internal reverb on wire")==0) {
2026-10-28 17:16:01 +01:00
xtc_connection_info wire{};
r=w->graph_widget_->selected_connection(wire) ? w->insert_plugin(wire,XTC_BUILTIN_PATH,std::strcmp(label,"Insert internal delay on wire")==0 ? 2U : 3U) : XTC_ERR_INVALID_STATE;
} else if (std::strcmp(label,"Add internal delay") == 0 || std::strcmp(label,"Add internal reverb") == 0) {
size_t id=0; r=w->add_plugin(XTC_BUILTIN_PATH, std::strcmp(label,"Add internal delay")==0 ? 2U : 3U, &id);
} else if (std::strcmp(label,"Reset selected plugin") == 0) {
r = w->reset_plugin(w->graph_widget_->selected_node());
} else if (std::strcmp(label,"Load plugin") == 0 ||
std::strcmp(label,"Replace selected plugin") == 0 ||
std::strcmp(label,"Insert plugin on selected wire") == 0) {
const size_t selected = w->graph_widget_->selected_node();
xtc_connection_info wire{};
const bool insert = std::strcmp(label,"Insert plugin 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 plugin node to replace",r); return; }
}
std::string module; if(!xtc_choose_plugin_file(w->settings_,module))return;
double rate = w->sample_rate(); xtc_backend_status status{};
if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate;
unsigned int index = 0;
xtc_plugin_spec probe{};
r = xtc_plugin_probe(module.c_str(),0,rate,&probe);
if (r != XTC_OK && r != XTC_ERR_UNSUPPORTED) {
w->report_error("Cannot discover plugin; check binary path, format, mono layout and dependencies",r); return;
}
if (!xtc_choose_plugin(module.c_str(),rate,index)) return;
if (replace) r = w->replace_plugin(selected,module.c_str(),index);
else if (insert) r = w->insert_plugin(wire,module.c_str(),index);
else r = w->add_plugin(module.c_str(),index);
} 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";
if (std::strcmp(label,"Selected plugin control") == 0) {
double rate = w->sample_rate(); 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;
}
xtc_parameter_info info[XTC_PLUGIN_CONTROLS]{};
double rate = w->sample_rate(); 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";
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;
}
}
}
if (r != XTC_OK) w->report_error("Plugin change failed: check module, mono layout, control port/range and JACK format",r);
}
void MainWindow::project_defaults(xtc_project *project)
{
(void)xtc_project_set_tempo(project,static_cast<unsigned int>(settings_.tempo));
unsigned int cc[3]={static_cast<unsigned int>(settings_.volume_cc),static_cast<unsigned int>(settings_.pan_cc),static_cast<unsigned int>(settings_.sustain_cc)};
for(unsigned int channel=0;channel<16;++channel)(void)xtc_project_set_control_map(project,channel,cc);
}
double MainWindow::sample_rate() const
{
xtc_backend_status status{};
return ready_ && xtc_backend_get_status(backend_,&status)==XTC_OK ? status.sample_rate : static_cast<double>(settings_.offline_rate);
}
xtc_result MainWindow::configure_loop(const xtc_project *project,const xtc_settings &settings)
{
if(!ready_)return XTC_OK;
uint64_t start,end;unsigned int rate=static_cast<unsigned int>(sample_rate());
xtc_result r=xtc_project_tick_to_sample(project,static_cast<uint64_t>(settings.loop_start),rate,&start);
if(r==XTC_OK)r=xtc_project_tick_to_sample(project,static_cast<uint64_t>(settings.loop_end),rate,&end);
if(r==XTC_OK)r=xtc_backend_set_loop(backend_,start,end,settings.loop_enabled!=0);
return r;
}
xtc_result MainWindow::set_loop(uint64_t start,uint64_t end,bool enabled)
{
if(recorder_)return XTC_ERR_INVALID_STATE;
if(start>=end || end>XTC_PROJECT_MAX_TICK)return XTC_ERR_INVALID_ARGUMENT;
xtc_settings candidate=settings_;candidate.loop_start=static_cast<int64_t>(start);candidate.loop_end=static_cast<int64_t>(end);candidate.loop_enabled=enabled;
bool resume=running_;xtc_result r=ready_ ? xtc_backend_stop(backend_) : XTC_OK;
if(r==XTC_OK)r=configure_loop(project_.get(),candidate);
if(r==XTC_OK) {settings_=candidate;sequencer_->loop_region(start,end,enabled);}
if(ready_ && resume && r==XTC_OK) {xtc_result restarted=xtc_backend_start(backend_);if(r==XTC_OK)r=restarted;}
refresh_status();return r;
}
void MainWindow::collect_preferences()
{
settings_.sequencer_detached=editors_[0].detached;settings_.graph_detached=editors_[1].detached;settings_.piano_detached=piano_detached_;
settings_.track_header_width=sequencer_->header_width();
for(unsigned int i=0;i<2;++i)if(editors_[i].window) {
auto *p=editors_[i].window.get();
if(i==0){settings_.sequencer_x=p->x();settings_.sequencer_y=p->y();settings_.sequencer_width=p->w();settings_.sequencer_height=p->h();}
else{settings_.graph_x=p->x();settings_.graph_y=p->y();settings_.graph_width=p->w();settings_.graph_height=p->h();}
}
if(piano_window_){settings_.piano_x=piano_window_->x();settings_.piano_y=piano_window_->y();}
settings_.transport_detached=transport_detached_;settings_.mixer_detached=mixer_detached_;
if(transport_window_) {settings_.transport_x=transport_window_->x();settings_.transport_y=transport_window_->y();settings_.transport_width=transport_window_->w();settings_.transport_height=transport_window_->h();}
if(mixer_window_) {settings_.mixer_x=mixer_window_->x();settings_.mixer_y=mixer_window_->y();settings_.mixer_width=mixer_window_->w();settings_.mixer_height=mixer_window_->h();}
settings_.width=w();settings_.height=h();settings_.sequencer_snap=static_cast<int64_t>(sequencer_->grid());settings_.sequencer_zoom=sequencer_->zoom_level();
if(piano_) {
settings_.piano_width=piano_window_->w();settings_.piano_height=piano_window_->h();settings_.piano_snap=static_cast<int64_t>(piano_->grid());
settings_.filter_channel=piano_->filter_channel();settings_.filter_low=piano_->filter_low();settings_.filter_high=piano_->filter_high();
}
}
void MainWindow::apply_ui_preferences()
{
bool seq=settings_.sequencer_detached!=0,graph=settings_.graph_detached!=0,piano=settings_.piano_detached!=0;
detach_editor(0,seq);detach_editor(1,graph);detach_piano(piano);
sequencer_->header_width(static_cast<int>(settings_.track_header_width));
for(unsigned int i=0;i<2;++i)if(editors_[i].window)restore_panel(editors_[i].window.get(),static_cast<int>(i==0 ? settings_.sequencer_x : settings_.graph_x),static_cast<int>(i==0 ? settings_.sequencer_y : settings_.graph_y),static_cast<int>(i==0 ? settings_.sequencer_width : settings_.graph_width),static_cast<int>(i==0 ? settings_.sequencer_height : settings_.graph_height),i==0 ? 1100 : 1000,420);
bool floating_transport=settings_.transport_detached!=0,floating_mixer=settings_.mixer_detached!=0;
detach_transport(floating_transport);detach_mixer(floating_mixer);
if(transport_window_)restore_panel(transport_window_.get(),static_cast<int>(settings_.transport_x),static_cast<int>(settings_.transport_y),static_cast<int>(settings_.transport_width),static_cast<int>(settings_.transport_height),400,130);
if(mixer_window_)restore_panel(mixer_window_.get(),static_cast<int>(settings_.mixer_x),static_cast<int>(settings_.mixer_y),static_cast<int>(settings_.mixer_width),static_cast<int>(settings_.mixer_height),600,420);
2026-10-28 17:16:01 +01:00
follow_->value(static_cast<int>(settings_.follow_playhead));
resize(x(),y(),static_cast<int>(settings_.width),static_cast<int>(settings_.height));
sequencer_->grid(static_cast<uint64_t>(settings_.sequencer_snap));sequencer_->set_zoom(static_cast<int>(settings_.sequencer_zoom));
sequencer_->new_clip_ticks=static_cast<uint64_t>(settings_.clip_ticks);
sequencer_->loop_region(static_cast<uint64_t>(settings_.loop_start),static_cast<uint64_t>(settings_.loop_end),settings_.loop_enabled!=0);
if(piano_) {
restore_panel(piano_window_.get(),static_cast<int>(settings_.piano_x),static_cast<int>(settings_.piano_y),static_cast<int>(settings_.piano_width),static_cast<int>(settings_.piano_height),800,520);
piano_->set_grid(static_cast<uint64_t>(settings_.piano_snap));piano_->set_filter(static_cast<int>(settings_.filter_channel),static_cast<unsigned int>(settings_.filter_low),static_cast<unsigned int>(settings_.filter_high));
piano_->new_note_ticks=static_cast<uint64_t>(settings_.note_ticks);piano_->new_note_velocity=static_cast<unsigned int>(settings_.note_velocity);piano_->new_note_channel=static_cast<unsigned int>(settings_.note_channel);
}
}
xtc_result MainWindow::apply_preferences(const xtc_settings &candidate)
{
if(recorder_)return XTC_ERR_INVALID_STATE;
xtc_result r=xtc_settings_validate(&candidate);if(r!=XTC_OK)return r;
if(configuration_.empty())return XTC_ERR_INVALID_STATE;
if(ready_)r=xtc_backend_stop(backend_);
if(r==XTC_OK)r=configure_loop(project_.get(),candidate);
if(r==XTC_OK)r=xtc_settings_save(&candidate,configuration_.c_str());
if(r!=XTC_OK) {if(ready_)(void)configure_loop(project_.get(),settings_);refresh_status();return r;}
settings_=candidate;apply_ui_preferences();refresh_status();return XTC_OK;
}
void MainWindow::show_preferences()
{
if(recorder_) {report_error("Stop recording before preferences",XTC_ERR_INVALID_STATE);return;}
collect_preferences();PreferencesDialog dialog(settings_,configuration_,[this](const xtc_settings &s){return apply_preferences(s);});dialog.run();
}
2026-10-28 17:16:01 +01:00
xtc_result MainWindow::set_tempo(unsigned int milli_bpm)
{
if (recorder_)
return XTC_ERR_INVALID_STATE;
if (milli_bpm < 20000 || milli_bpm > 300000)
return XTC_ERR_INVALID_ARGUMENT;
if (milli_bpm == xtc_project_tempo(project_.get()))
return XTC_OK;
uint64_t track = sequencer_->selected_track(), clip = sequencer_->selected_clip(), mix = mixer_->selected_track();
uint64_t piano_clip = piano_ ? piano_->clip() : UINT64_MAX, note = piano_ ? piano_->selected_note() : UINT64_MAX;
bool piano_visible = piano_window_ && piano_window_->shown();
xtc_project *candidate = nullptr;
xtc_result r = xtc_project_clone(project_.get(), &candidate);
if (r == XTC_OK)
r = xtc_project_set_tempo(candidate, milli_bpm);
if (r == XTC_OK)
r = xtc_history_reserve(history_.get());
if (r == XTC_OK)
r = install_project(candidate, false);
if (r != XTC_OK)
xtc_project_destroy(candidate);
else {
sequencer_->select(track, clip);
mixer_->select_track(mix);
xtc_clip_info info{};
if (piano_ && xtc_project_find_clip(project_.get(), piano_clip, &info) == XTC_OK) {
piano_->set_project(project_.get(), piano_clip);
piano_->select_note(note);
if (piano_visible)
piano_window_->show();
}
mark_dirty();
}
return r;
}
void MainWindow::inspector_callback(Fl_Widget *widget, void *context)
{
auto *w = static_cast<MainWindow *>(context);
const uint64_t id = w->inspected_track_;
float gain = static_cast<float>(w->inspector_gain_->value()), pan = static_cast<float>(w->inspector_pan_->value());
int mute = w->inspector_mute_->value(), solo = w->inspector_solo_->value();
// Editing a single field must not commit another field's unfinished input.
size_t count = 0, clips = 0;
xtc_project_get_counts(w->project_.get(), &count, &clips);
for (size_t i = 0; i < count; ++i) {
xtc_track_info track{};
xtc_project_get_track(w->project_.get(), i, &track);
if (track.id != id)
continue;
if (widget != w->inspector_gain_)
gain = track.gain;
if (widget != w->inspector_pan_)
pan = track.pan;
if (widget != w->inspector_mute_)
mute = track.mute;
if (widget != w->inspector_solo_)
solo = track.solo;
if (gain != track.gain || pan != track.pan || mute != track.mute || solo != track.solo)
if (w->mixer_->select_track(id))
w->mixer_->mix(gain, pan, mute, solo);
break;
}
w->inspected_track_ = UINT64_MAX;
w->refresh_status();
}
xtc_result MainWindow::mix_snapshot(unsigned int slot,bool capture)
{
if(slot>=2)return XTC_ERR_INVALID_ARGUMENT;
if(capture) {
xtc_mix_snapshot *snapshot=nullptr;xtc_result r=xtc_mix_snapshot_create(project_.get(),&snapshot);
if(r==XTC_OK)mix_snapshots_[slot].reset(snapshot);
return r;
}
if(!mix_snapshots_[slot])return XTC_ERR_NOT_FOUND;
xtc_result r=before_arrangement_edit(this);
if(r==XTC_OK)r=xtc_mix_snapshot_recall(mix_snapshots_[slot].get(),project_.get());
if(r==XTC_OK)mark_dirty();
return r;
}
xtc_result MainWindow::navigate_marker(int direction)
{
xtc_marker marker{};if(recorder_)return XTC_ERR_INVALID_STATE;
xtc_result r=xtc_project_adjacent_marker(project_.get(),sequencer_->position(),direction,&marker);
if(r==XTC_OK && ready_)r=xtc_backend_stop(backend_);
if(r==XTC_OK)r=seek(this,marker.tick);
if(r==XTC_OK)sequencer_->position(marker.tick);
return r;
}
xtc_result MainWindow::loop_section()
{
uint64_t begin=0,end=0;xtc_result r=xtc_project_section(project_.get(),sequencer_->position(),&begin,&end);
return r==XTC_OK ? set_loop(begin,end,true) : r;
}
2026-10-28 17:16:01 +01:00
void MainWindow::show_commands()
{
std::vector<std::string> commands;
for (int i = 0; i < menu_->size(); ++i) {
const auto &item = menu_->menu()[i];
if (!item.label() || item.submenu() || !item.active())
continue;
char path[512];
if (menu_->item_pathname(path, sizeof(path), &item) == 0)
commands.emplace_back(path);
}
std::string selected;
{
CommandPalette palette(std::move(commands));
selected = palette.run();
}
if (!selected.empty()) {
auto *item = menu_->find_item(selected.c_str());
if (item && item->active())
menu_->picked(item);
}
}
void MainWindow::fit_song()
{
size_t tracks = 0, clips = 0;
uint64_t end = 3840;
xtc_project_get_counts(project_.get(), &tracks, &clips);
for (size_t i = 0; i < clips; ++i) {
xtc_clip_info clip{};
xtc_project_get_clip(project_.get(), i, &clip);
end = std::max(end, clip.start_tick + clip.length_tick + 960);
}
int scale = 24;
for (int candidate : {24, 48, 96, 192, 384})
if (end * static_cast<unsigned int>(candidate) / 960 <=
static_cast<uint64_t>(std::max(1, timeline_scroll_->w() - 204)))
scale = candidate;
sequencer_->set_zoom(scale);
timeline_scroll_->scroll_to(0, 0);
timeline_scroll_->redraw();
}
void MainWindow::follow_playhead()
{
if (!running_ || !follow_->value() || tabs_->value() != tabs_->child(0) || Fl::pushed())
return;
int pixel = static_cast<int>(sequencer_->position() * static_cast<unsigned int>(sequencer_->zoom_level()) / 960);
int screen = sequencer_->x() + sequencer_->header_width() + pixel;
if (screen > timeline_scroll_->x() + timeline_scroll_->w() - 48 || screen < timeline_scroll_->x() + sequencer_->header_width())
2026-10-28 17:16:01 +01:00
timeline_scroll_->scroll_to(std::max(0, pixel - 160), timeline_scroll_->yposition());
}