#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" #include "command_palette.hpp" #include #include "plugin_library.hpp" #include #include #include #include #include "xtc/xtc_version.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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) {} int handle(int event) override { if (event == FL_SHORTCUT && owner_->panel_shortcut(event)) return 1; return Fl_Double_Window::handle(event); } }; 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); } }; } // namespace /** * @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) { 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); 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); if (result != XTC_OK) throw std::runtime_error(xtc_result_string(result)); history_.reset(history); 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/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(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("View/Commands",FL_CTRL+'p',[](Fl_Widget *,void *c){static_cast(c)->show_commands();},this); menu->add("View/Fit song",FL_CTRL+'0',[](Fl_Widget *,void *c){static_cast(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); 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); 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); menu->add("Audio/Pause", FL_F + 7, [](Fl_Widget *, void *c) { (void)static_cast(c)->pause_playback(); }, this); menu->add("Audio/Return to start", FL_Home, [](Fl_Widget *, void *c) { (void)static_cast(c)->return_to_start(); }, this); menu->add("View/Detach transport",0,[](Fl_Widget *,void *c) { auto *w=static_cast(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(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(c); w->detach_transport(false); w->detach_mixer(false); },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(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(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)"); 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(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(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(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(c)->settings_.follow_playhead = static_cast(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(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(c); xtc_result r=w->set_loop(static_cast(w->settings_.loop_start),static_cast(w->settings_.loop_end),static_cast(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(c);char initial[100]; std::snprintf(initial,sizeof(initial),"%lld %lld",static_cast(w->settings_.loop_start),static_cast(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); transport_group_->end(); tabs_ = new Fl_Tabs(216, 126, 1056, 578); tabs_->begin(); auto *sequencer = new Fl_Group(216, 158, 1056, 546, "Sequencer"); sequencer->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); 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."); timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); timeline_hint->labelsize(12); auto *timeline_scroll = new Fl_Scroll(228, 206, 1032, 452); 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(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(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(sequencer); 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(widget); int value = choice->value(); if (value >= 0 && static_cast(value) < xtc_ui::snap_count) (void)static_cast(c)->sequencer_->grid(xtc_ui::snap_ticks[value]); },this); Fl_Group::current(parent); menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast(c)->duplicate_selection(); },sequencer_); menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->split_selection(); }, sequencer_); menu->add("Edit/Audio clip gain and fades", 0, [](Fl_Widget *, void *c) { static_cast(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(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(widget); (void)static_cast(c)->grid(std::strtoull(m->mvalue()->label(),nullptr,10)); },sequencer_); sequencer_->callback( [](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); }, this); add_track->callback( [](Fl_Widget *, void *c) { static_cast(c)->add_track(); }, sequencer_); add_midi->callback( [](Fl_Widget *, void *c) { static_cast(c)->add_midi_clip(); }, sequencer_); add_region->callback( [](Fl_Widget *, void *c) { static_cast(c)->add_region(); }, sequencer_); delete_region->callback( [](Fl_Widget *, void *c) { static_cast(c)->delete_selection(); }, sequencer_); zoom_in->callback( [](Fl_Widget *, void *c) { static_cast(c)->zoom(1); }, sequencer_); zoom_out->callback( [](Fl_Widget *, void *c) { static_cast(c)->zoom(-1); }, sequencer_); sequencer->resizable(timeline_scroll); sequencer->end(); auto *mixer = new Fl_Group(216, 158, 1056, 546, "Mixer"); mixer_page_=mixer; mixer->begin(); mixer_ = new MixerWidget(228, 168, 1032, 522, project_.get(), before_arrangement_edit, this); mixer_->callback( [](Fl_Widget *, void *c) { static_cast(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(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(c)->detach_mixer(false);},this); mixer_placeholder_->end(); mixer_placeholder_->hide(); mixer->resizable(mixer_); mixer->end(); auto *graph = new Fl_Group(216, 158, 1056, 546, "Modular Graph"); graph->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); auto *feedback = new Fl_Box(228, 666, 1032, 28, "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(c)->auto_layout(); }, graph_widget_); menu->add("Edit/Insert unity gain in selected wire", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->insert_gain(); }, graph_widget_); auto *oscillator = new Fl_Button(228, 168, 130, 26, "Add oscillator"); oscillator->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_OSCILLATOR); }, graph_widget_); auto *gain = new Fl_Button(366, 168, 110, 26, "Add gain"); gain->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_GAIN); }, graph_widget_); auto *meter = new Fl_Button(484, 168, 110, 26, "Add meter"); meter->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_METER); }, graph_widget_); auto *output = new Fl_Button(602, 168, 110, 26, "Add output"); output->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_OUTPUT); }, graph_widget_); auto *audio = new Fl_Button(858, 168, 130, 26, "Add audio source"); audio->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_AUDIO); }, graph_widget_); auto *instrument = new Fl_Button(996, 168, 130, 26, "Add instrument"); instrument->callback( [](Fl_Widget *, void *c) { (void)static_cast(c)->add_node(XTC_NODE_INSTRUMENT); }, graph_widget_); auto *remove = new Fl_Button(720, 168, 130, 26, "Delete selection"); remove->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->delete_selected(); }, graph_widget_); graph->resizable(scroll); graph->end(); tabs_->end(); tabs_->value(sequencer); menu->add("View/Sequencer", FL_CTRL + '1', view_callback, sequencer); menu->add("View/Mixer", FL_CTRL + '2', view_callback, mixer); menu->add("View/Modular Graph", FL_CTRL + '3', view_callback, graph); menu->add("Help/About", 0, about_callback); 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(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(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); auto *selection_title = new Fl_Box(24,404,168,24,"TRACK INSPECTOR"); selection_title->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); 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); 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(inspector_gain_), static_cast(inspector_pan_), static_cast(inspector_mute_), static_cast(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)); 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(settings_.refresh_ms)/1000,timer_callback,this); } 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(w(),130,"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(c)->detach_transport(false);},this); xtc_ui::style(transport_window_.get()); transport_window_->end(); transport_window_->size_range(1220,130,0,130); transport_window_->callback([](Fl_Widget *,void *c){static_cast(c)->detach_transport(false);},this); Fl_Group::current(current); } 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(menu_->find_item("View/Detach transport")); if (detached) item->set(); else item->clear(); 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(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(c)->detach_mixer(false);},this); Fl_Group::current(current); } 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(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() { Fl::remove_timeout(timer_callback, this); // Return ownership before member windows and the main widget tree destruct. detach_transport(false); detach_mixer(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) 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]; 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(context); 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); if (result != XTC_OK) return result; xtc_project *replacement = nullptr; result = xtc_project_clone(window->project_.get(), &replacement); 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)); 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)) *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(settings_.max_frames), static_cast(settings_.channels)}; xtc_result result = xtc_backend_open(&config, &backend_); ready_ = result == XTC_OK; running_ = false; if (result != XTC_OK) report_error("Open JACK failed", result); else { xtc_backend_status format{}; result = xtc_backend_get_status(backend_, &format); if (result == XTC_OK) result = xtc_backend_set_transition_frames(backend_, static_cast(format.sample_rate)*static_cast(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); } 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; } if (running_) return; xtc_result result = configure_loop(project_.get(),settings_); if(result==XTC_OK) result = xtc_backend_prepare_project(backend_, project_.get()); 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]) { 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() { if (!ready_) return; xtc_result result = recorder_ ? xtc_backend_stop(backend_) : xtc_backend_request_stop(backend_); if (result != XTC_OK) report_error("Stop engine failed", result); else { set_label(error_, ""); 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() { 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; 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" : recorder_ ? "Audio: recording JACK input — Stop to finish" : running_ ? "Audio: running — modular graph" : "Audio: ready / stopped"; set_label(indicator_, state); indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120) : running_ ? fl_rgb_color(125, 225, 160) : fl_rgb_color(215, 215, 215)); 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]; 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); if (fault) set_label(graph_state_, "Graph: engine unavailable — Reconnect required"); else if (status.submitted_revision != status.active_revision) 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"); } } if (can_play) play_->activate(); else play_->deactivate(); if (ready_ && running_ && !recorder_) pause_->activate(); else pause_->deactivate(); if (!recorder_) home_->activate(); else home_->deactivate(); if (can_record) record_->activate(); else 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() { 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); 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::focus()) && (Fl::event_key() == ' ' || (Fl::event_state(FL_CTRL) && (Fl::event_key() == 'z' || Fl::event_key() == 'y' || Fl::event_key() == 'd')))) return 0; 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); } /** * @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) { static_cast(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) { static_cast(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) { static_cast(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) { (void)static_cast(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(widget->window()); window->tabs_->value(static_cast(view)); window->tabs_->redraw(); if (view==window->mixer_page_ && window->mixer_detached_) window->mixer_window_->show(); } /** * @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.", XTC_PROJECT_NAME, XTC_VERSION_STRING); } /** * @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(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(); } window->refresh_status(); window->follow_playhead(); if (!window->closing_) Fl::repeat_timeout(static_cast(window->settings_.refresh_ms)/1000,timer_callback,context); } /** * @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()); 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())); if (piano_) piano_->set_project(project_.get(), piano_->clip()); dirty_ = true; update_history_controls(); copy_label("xtc * — unsaved changes"); } /** * @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_ || fl_choice("Discard unsaved project changes?", "Cancel", "Discard", nullptr) == 1; } /** * @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(context); if (!window->ready_) return XTC_OK; xtc_backend_status status{}; xtc_result r = xtc_backend_get_status(window->backend_, &status); uint64_t sample = 0; 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; } /** * @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) { 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); if (r == XTC_OK) { path_ = saved_path; dirty_ = false; copy_label(path_.c_str()); } return r; } /** * @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) { if (recorder_) return XTC_ERR_INVALID_STATE; std::unique_ptr new_history(nullptr, xtc_history_destroy); if (reset_history) { xtc_history *h = nullptr; xtc_result result = xtc_history_create(candidate, &h); if (result != XTC_OK) return result; new_history.reset(h); } xtc_result r = XTC_OK; if (ready_) { r = xtc_backend_stop(backend_); if (r == XTC_OK) r = xtc_backend_seek(backend_, 0); if(r==XTC_OK) r=configure_loop(candidate,settings_); if (r == XTC_OK) r = xtc_backend_prepare_project(backend_, candidate); } xtc_audio_plan *offline = nullptr; if (r == XTC_OK && !ready_) r = xtc_audio_plan_create(candidate, static_cast(settings_.offline_rate), &offline); xtc_midi_plan *offline_midi = nullptr; if (r == XTC_OK && !ready_) r = xtc_midi_plan_create(candidate, static_cast(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(settings_.offline_rate),256,&graph); xtc_plan_destroy(graph); } if (r != XTC_OK) { xtc_audio_plan_destroy(offline); refresh_status(); // A failed install may have stopped playback. return r; } std::string issue = ready_ ? xtc_backend_asset_issue(backend_) : ""; if (offline) { const char *path = ""; 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"; if (piano_window_) piano_window_->hide(); if (piano_) piano_->set_project(candidate, UINT64_MAX); project_.reset(candidate); mixer_->set_project(candidate); if (reset_history) history_.swap(new_history); graph_widget_->reset_layout(); graph_widget_->set_graph(xtc_project_graph(candidate)); sequencer_->set_project(candidate); if (reset_history) tabs_->value(tabs_->child(0)); dirty_ = false; if (!issue.empty()) { char text[640]; std::snprintf(text, sizeof(text), "Missing/unreadable audio (silent): %s. Use File/Relink selected audio or Load sampler WAV to repair.", issue.c_str()); set_label(error_, text); } else set_label(error_, ""); refresh_status(); return XTC_OK; } /** * @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…"); 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); } } 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; } } } xtc_project_destroy(candidate); } else { path_ = loaded_path; copy_label(path_.c_str()); } return r; } xtc_result MainWindow::new_project() { if (recorder_) return XTC_ERR_INVALID_STATE; xtc_project *candidate = nullptr; xtc_result result = xtc_project_create(&candidate); if(result==XTC_OK) 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(context); if (window->recorder_) { window->report_error("Stop recording before file operations", XTC_ERR_INVALID_STATE); return; } auto *menu = static_cast(widget); const char *action = menu->mvalue()->label(); xtc_result r = XTC_OK; window->project_issue_.clear(); if (std::strcmp(action, "New") == 0) { if (!window->discard_changes()) return; r = window->new_project(); } else { bool open = std::strcmp(action, "Open") == 0; 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"); if (!open) chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM); int result = chooser.show(); if (result == 1) return; if (result < 0) r = XTC_ERR_IO; else r = open ? window->open_project(chooser.filename()) : window->save_project(chooser.filename()); } } 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(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; } /** * @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); 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; 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)); mark_dirty(); refresh_status(); return XTC_OK; } /** * @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) return XTC_ERR_INVALID_STATE; xtc_result r = xtc_backend_stop(backend_); xtc_backend_status status{}; 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(status.sample_rate) * static_cast(settings_.recording_seconds), &recorder_); if (r != XTC_OK) return r; recording_path_ = path; recording_track_ = sequencer_->selected_track(); r = xtc_backend_set_recorder(backend_, recorder_); if (r == XTC_OK) r = xtc_backend_start(backend_); if (r != XTC_OK) { (void)xtc_backend_set_recorder(backend_, nullptr); xtc_recorder_destroy(recorder_); recorder_ = nullptr; } refresh_status(); return r; } /** * @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_); xtc_recorder_destroy(recorder_); recorder_ = nullptr; if (r != XTC_OK) { 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()); 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_; 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()); if (r == XTC_OK) r = xtc_backend_prepare_project(backend_, candidate); if (r != XTC_OK) { xtc_project_destroy(candidate); 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; } project_.reset(candidate); sequencer_->set_project(candidate, false); graph_widget_->set_graph(xtc_project_graph(candidate)); mark_dirty(); } /** * @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(context); auto *menu = dynamic_cast(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; 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(); if (chosen == 1) return; xtc_result r = chosen < 0 ? XTC_ERR_IO : XTC_OK; if (r == XTC_OK && relink) { r = before_arrangement_edit(window); 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(900, 620, "xtc — Piano roll"); piano_window_->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(data); auto *bar = static_cast(widget); owner->piano_->command(static_cast(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(c); int value = static_cast(widget)->value(); if (value >= 0 && static_cast(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(c); (void)w->piano_->set_velocity(w->piano_->selected_note(),static_cast(static_cast(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); piano_->callback( [](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); }, this); scroll->end(); velocity_lane_ = new VelocityLane(12,500,876,108,*piano_); piano_window_->end(); piano_window_->size_range(800,520); xtc_ui::style(piano_window_.get()); piano_window_->resizable(scroll); scroll->scroll_to(0, 60 * 16); settings_.width=w();settings_.height=h(); settings_.sequencer_snap=static_cast(sequencer_->grid());settings_.sequencer_zoom=sequencer_->zoom_level(); apply_ui_preferences(); } else piano_->set_project(project_.get(), clip.id); refresh_workspace(); piano_window_->show(); return XTC_OK; } /** * @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(context); if (window->recorder_) { window->report_error("Stop recording before editing MIDI", XTC_ERR_INVALID_STATE); return; } xtc_result r = window->open_piano_roll(); if (r != XTC_OK) set_label(window->error_, "Select a MIDI clip, then open Piano roll."); } /** * @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(context); if (!window->ready_) return; xtc_result r = xtc_backend_panic(window->backend_); 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) { 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); 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); dirty_ = true; update_history_controls(); copy_label("xtc * — unsaved changes"); return r; } /** * @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(context); auto *menu = static_cast(widget); bool redo = std::strncmp(menu->mvalue()->label(), "Redo", 4) == 0; xtc_result r = window->undo(redo); 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(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() { 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())); size_t count = 0; bool selected = false; xtc_project_note_count(project_.get(), &count); for (size_t i = 0; i < count; ++i) { xtc_note_info note{}; xtc_project_get_note(project_.get(), i, ¬e); if (note.id == piano_->selected_note() && piano_->note_visible(note)) { selected = true; if (Fl::pushed() != note_velocity_) note_velocity_->value(note.velocity); break; } } if (selected) note_velocity_->activate(); else note_velocity_->deactivate(); velocity_lane_->redraw(); } char position[64], info[512]; uint64_t tick = sequencer_->position(); std::snprintf(position, sizeof(position), "%03llu : %u : %03u", static_cast(tick / 3840 + 1), static_cast((tick / 960) % 4 + 1), static_cast(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); } std::snprintf(info, sizeof(info), "%zu tracks / %zu clips / %llu bars", tracks, clips, static_cast((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(); 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(inspector_gain_), static_cast(inspector_pan_), static_cast(inspector_mute_), static_cast(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(inspector_gain_), static_cast(inspector_pan_), static_cast(inspector_mute_), static_cast(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; } 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(context); if (w->recorder_) { w->report_error("Stop recording before instrument changes",XTC_ERR_INVALID_STATE); return; } auto *menu = dynamic_cast(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;i1)r=XTC_ERR_INVALID_ARGUMENT; else { unsigned int i=0;for(;i(tick),port,static_cast(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(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(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(context); if (w->recorder_) { w->report_error("Stop recording before plugin changes",XTC_ERR_INVALID_STATE); return; } auto *menu = static_cast(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) { 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(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(settings_.tempo)); unsigned int cc[3]={static_cast(settings_.volume_cc),static_cast(settings_.pan_cc),static_cast(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(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(sample_rate()); xtc_result r=xtc_project_tick_to_sample(project,static_cast(settings.loop_start),rate,&start); if(r==XTC_OK)r=xtc_project_tick_to_sample(project,static_cast(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(start);candidate.loop_end=static_cast(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_.width=w();settings_.height=h();settings_.sequencer_snap=static_cast(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(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() { follow_->value(static_cast(settings_.follow_playhead)); resize(x(),y(),static_cast(settings_.width),static_cast(settings_.height)); sequencer_->grid(static_cast(settings_.sequencer_snap));sequencer_->set_zoom(static_cast(settings_.sequencer_zoom)); sequencer_->new_clip_ticks=static_cast(settings_.clip_ticks); sequencer_->loop_region(static_cast(settings_.loop_start),static_cast(settings_.loop_end),settings_.loop_enabled!=0); if(piano_) { piano_window_->resize(piano_window_->x(),piano_window_->y(),static_cast(settings_.piano_width),static_cast(settings_.piano_height)); piano_->set_grid(static_cast(settings_.piano_snap));piano_->set_filter(static_cast(settings_.filter_channel),static_cast(settings_.filter_low),static_cast(settings_.filter_high)); piano_->new_note_ticks=static_cast(settings_.note_ticks);piano_->new_note_velocity=static_cast(settings_.note_velocity);piano_->new_note_channel=static_cast(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(); } 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(context); const uint64_t id = w->inspected_track_; float gain = static_cast(w->inspector_gain_->value()), pan = static_cast(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(); } void MainWindow::show_commands() { std::vector 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(candidate) / 960 <= static_cast(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(sequencer_->position() * static_cast(sequencer_->zoom_level()) / 960); int screen = sequencer_->x() + 180 + pixel; if (screen > timeline_scroll_->x() + timeline_scroll_->w() - 48 || screen < timeline_scroll_->x() + 180) timeline_scroll_->scroll_to(std::max(0, pixel - 160), timeline_scroll_->yposition()); }