#include "main_window.hpp" #include "graph_widget.hpp" #include "sequencer_widget.hpp" #include "piano_roll.hpp" #include "mixer_widget.hpp" #include #include "xtc/xtc_version.h" #include #include #include #include #include #include #include #include #include #include #include namespace { /** * @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); } } // 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) : Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation"), prompt_unsaved_(prompt_unsaved) { xtc_project *initial = nullptr; xtc_result result = xtc_project_create(&initial); if (result != XTC_OK) throw std::runtime_error(xtc_result_string(result)); 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, 1000, 28); 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("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("Audio/Panic", FL_F + 9, panic_callback, 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("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); 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(668, 40, 320, 30, "Audio: disconnected"); indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); indicator_->labelfont(FL_BOLD); indicator_->labelsize(12); auto *hint = new Fl_Box(12, 76, 976, 34, "Start: Add track, Add MIDI clip, then Piano roll to draw notes; or Import WAV (Ctrl+I).\n" "Play with F5. Connect JACK output/output_right to playback ports to hear audio."); hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); hint->labelsize(12); tabs_ = new Fl_Tabs(8, 114, 984, 470); tabs_->begin(); auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer"); sequencer->begin(); auto *add_track = new Fl_Button(20, 150, 100, 26, "Add track"); auto *add_region = new Fl_Button(128, 150, 110, 26, "Add region"); auto *delete_region = new Fl_Button(246, 150, 130, 26, "Delete region/track"); auto *zoom_in = new Fl_Button(384, 150, 80, 26, "Zoom +"); auto *zoom_out = new Fl_Button(472, 150, 80, 26, "Zoom -"); auto *add_midi = new Fl_Button(560, 150, 110, 26, "Add MIDI clip"); auto *piano = new Fl_Button(678, 150, 100, 26, "Piano roll"); piano->callback(piano_callback, this); auto *timeline_hint = new Fl_Box(20, 548, 956, 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(20, 188, 956, 350); sequencer_ = new SequencerWidget(20, 188, project_.get(), seek, this); timeline_scroll->end(); sequencer_->before_edit = before_arrangement_edit; 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(8, 140, 984, 444, "Mixer"); mixer->begin(); mixer_ = new MixerWidget(20, 150, 956, 420, project_.get(), before_arrangement_edit, this); mixer_->callback( [](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); }, this); mixer->resizable(mixer_); mixer->end(); auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph"); graph->begin(); graph_state_ = new Fl_Box(20, 182, 956, 24, "Graph: offline"); graph_state_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); graph_state_->labelsize(12); auto *feedback = new Fl_Box(20, 548, 956, 28, "Add nodes. Drag output to input to connect; click a node or wire then Delete. Scroll to pan."); feedback->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP); feedback->labelsize(12); auto *scroll = new Fl_Scroll(20, 212, 956, 328); graph_widget_ = new GraphWidget(20, 212, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback); scroll->end(); auto *oscillator = new Fl_Button(20, 150, 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(158, 150, 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(276, 150, 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(394, 150, 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(650, 150, 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(788, 150, 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(512, 150, 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); status_ = new Fl_Box(12, 594, 976, 26, "JACK: not connected"); status_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); status_->labelsize(12); error_ = new Fl_Box(12, 624, 976, 44, ""); error_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP); error_->labelcolor(fl_rgb_color(255, 155, 120)); error_->labelsize(12); end(); resizable(tabs_); size_range(800, 540); callback(close_callback, this); open_backend(); Fl::add_timeout(0.1, timer_callback, this); } /** * @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); // 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_) { (void)xtc_recorder_finish(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_UNSUPPORTED ? "Rebuild with JACK development support." : "Check the JACK-compatible server and use Reconnect to retry."; std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint); set_label(error_, text); } /** * @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 && 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) *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, XTC_PROJECT_NAME, 65536U, 2U}; 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_, format.sample_rate / 200U); 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 = 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. Select clip and Relink 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::refresh_status() { if (mixer_) mixer_->peaks(0, 0); 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)); mixer_->peaks(status.peak_left, status.peak_right); 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(); 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::handle(int event) { if (event == FL_SHORTCUT && Fl::event_key() == ' ' && (Fl::event_state() & (FL_CTRL | FL_ALT | FL_META)) == 0) { if (running_) stop_engine(); else start_engine(); return 1; } return Fl_Double_Window::handle(event); } /** * @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(); } /** * @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) { 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(); if (!window->closing_) Fl::repeat_timeout(0.1, 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; 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 = xtc_backend_prepare_project(backend_, candidate); } xtc_audio_plan *offline = nullptr; if (r == XTC_OK && !ready_) r = xtc_audio_plan_create(candidate, 48000, &offline); xtc_midi_plan *offline_midi = nullptr; if (r == XTC_OK && !ready_) r = xtc_midi_plan_create(candidate, 48000, &offline_midi); xtc_midi_plan_destroy(offline_midi); 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 (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. Select clip and Relink 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) { if (path == nullptr) return XTC_ERR_INVALID_ARGUMENT; std::string loaded_path(path); xtc_project *candidate = nullptr; xtc_result r = xtc_project_create(&candidate); if (r == XTC_OK) r = xtc_project_load(candidate, path); if (r == XTC_OK) r = install_project(candidate); if (r != XTC_OK) 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) 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; 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("Project operation failed", 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; if (!window->ready_) return XTC_OK; xtc_result 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) { 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_) 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) * 2, &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; } else mark_dirty(); 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_); xtc_recorder_destroy(recorder_); recorder_ = nullptr; if (r != XTC_OK) { char text[768]; std::snprintf(text, sizeof(text), "Recording failed (%s). Partial take, if finalized: %s. It was not added to the project.", xtc_result_string(r), recording_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_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() : "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"); if (recording) chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM); 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("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 *hint = new Fl_Box(12, 8, 876, 32, "Source ticks: click draws; drag body moves/pitches, right edge resizes; " "right-click/Delete removes. Snap 1/16."); hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); hint->labelsize(12); auto *scroll = new Fl_Scroll(12, 48, 876, 560); piano_ = new PianoRollWidget(12, 48, project_.get(), clip.id, before_arrangement_edit, this); piano_->callback( [](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); }, this); scroll->end(); piano_window_->end(); piano_window_->resizable(scroll); scroll->scroll_to(0, 60 * 16); } else piano_->set_project(project_.get(), clip.id); 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; 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; } r = xtc_history_accept(history_.get(), redo ? 1 : 0); dirty_ = true; 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); }