1
0
Fork 0
forked from hanez/xtc
xtc/src/gui/main_window.cpp

924 lines
34 KiB
C++
Raw Normal View History

2026-10-26 22:51:25 +01:00
#include "main_window.hpp"
#include "graph_widget.hpp"
#include "sequencer_widget.hpp"
#include "piano_roll.hpp"
#include "mixer_widget.hpp"
#include <FL/Fl_Native_File_Chooser.H>
#include "xtc/xtc_version.h"
2026-10-26 22:51:25 +01:00
#include <FL/Fl.H>
2026-10-26 22:51:25 +01:00
#include <FL/Fl_Box.H>
#include <FL/Fl_Button.H>
#include <FL/Fl_Group.H>
#include <FL/Fl_Scroll.H>
2026-10-26 22:51:25 +01:00
#include <FL/Fl_Menu_Bar.H>
#include <FL/Fl_Tabs.H>
#include <FL/fl_ask.H>
#include <cstdio>
#include <cstring>
#include <stdexcept>
2026-10-26 22:51:25 +01:00
namespace {
void set_label(Fl_Box *box, const char *text)
{
if (box->label() == nullptr || std::strcmp(box->label(), text) != 0)
box->copy_label(text);
}
2026-10-27 01:37:21 +01:00
} // namespace
2026-10-26 22:51:25 +01:00
MainWindow::MainWindow(bool prompt_unsaved)
2026-10-27 01:37:21 +01:00
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation"), prompt_unsaved_(prompt_unsaved)
2026-10-26 22:51:25 +01:00
{
xtc_project *initial = nullptr;
xtc_result result = xtc_project_create(&initial);
2026-10-27 01:37:21 +01:00
if (result != XTC_OK)
throw std::runtime_error(xtc_result_string(result));
project_.reset(initial);
xtc_history *history = nullptr;
result = xtc_history_create(initial, &history);
2026-10-27 01:37:21 +01:00
if (result != XTC_OK)
throw std::runtime_error(xtc_result_string(result));
history_.reset(history);
2026-10-26 22:51:25 +01:00
begin();
auto *menu = new Fl_Menu_Bar(0, 0, 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);
2026-10-26 22:51:25 +01:00
play_ = new Fl_Button(12, 40, 80, 30, "Play");
play_->callback(play_callback, this);
play_->tooltip("Start the audio engine (F5 or Space). Play audio clips and the modular graph.");
stop_ = new Fl_Button(100, 40, 80, 30, "Stop");
stop_->callback(stop_callback, this);
stop_->tooltip("Stop the audio engine (F8 or Space).");
auto *reconnect = new Fl_Button(188, 40, 110, 30, "Reconnect");
reconnect->callback(reconnect_callback, this);
reconnect->tooltip("Close and reopen JACK, leaving the engine stopped (F6).");
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).");
indicator_ = new Fl_Box(482, 40, 506, 30, "Audio: disconnected");
indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
indicator_->labelfont(FL_BOLD);
auto *hint = new Fl_Box(12, 78, 976, 24,
2026-10-27 01:37:21 +01:00
"Edit signal flow in Modular Graph. No hardware ports are connected automatically; Import "
"WAV clips or record JACK input; connect external ports explicitly.");
hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
hint->labelsize(12);
2026-10-26 22:51:25 +01:00
tabs_ = new Fl_Tabs(8, 114, 984, 470);
tabs_->begin();
auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer");
2026-10-26 22:51:25 +01:00
sequencer->begin();
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);
2026-10-27 01:37:21 +01:00
auto *timeline_hint =
new Fl_Box(20, 548, 956, 28,
"Import WAV (Ctrl+I) or Record. Drag body to move, edges to trim; ruler seeks while stopped.");
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;
2026-10-27 01:37:21 +01:00
sequencer_->callback(
[](Fl_Widget *, void *c) {
static_cast<MainWindow *>(c)->mark_dirty();
},
this);
add_track->callback(
[](Fl_Widget *, void *c) {
static_cast<SequencerWidget *>(c)->add_track();
},
sequencer_);
add_midi->callback(
[](Fl_Widget *, void *c) {
static_cast<SequencerWidget *>(c)->add_midi_clip();
},
sequencer_);
add_region->callback(
[](Fl_Widget *, void *c) {
static_cast<SequencerWidget *>(c)->add_region();
},
sequencer_);
delete_region->callback(
[](Fl_Widget *, void *c) {
static_cast<SequencerWidget *>(c)->delete_selection();
},
sequencer_);
zoom_in->callback(
[](Fl_Widget *, void *c) {
static_cast<SequencerWidget *>(c)->zoom(1);
},
sequencer_);
zoom_out->callback(
[](Fl_Widget *, void *c) {
static_cast<SequencerWidget *>(c)->zoom(-1);
},
sequencer_);
sequencer->resizable(timeline_scroll);
2026-10-26 22:51:25 +01:00
sequencer->end();
auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer");
2026-10-26 22:51:25 +01:00
mixer->begin();
mixer_ = new MixerWidget(20, 150, 956, 420, project_.get(), before_arrangement_edit, this);
2026-10-27 01:37:21 +01:00
mixer_->callback(
[](Fl_Widget *, void *c) {
static_cast<MainWindow *>(c)->mark_dirty();
},
this);
mixer->resizable(mixer_);
2026-10-26 22:51:25 +01:00
mixer->end();
auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph");
2026-10-26 22:51:25 +01:00
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);
2026-10-27 01:37:21 +01:00
auto *feedback =
new Fl_Box(20, 548, 956, 28,
"Add nodes. Drag output to input to connect; click a node or wire then Delete. Scroll to pan.");
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");
2026-10-27 01:37:21 +01:00
oscillator->callback(
[](Fl_Widget *, void *context) {
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_OSCILLATOR);
},
graph_widget_);
auto *gain = new Fl_Button(158, 150, 110, 26, "Add gain");
2026-10-27 01:37:21 +01:00
gain->callback(
[](Fl_Widget *, void *context) {
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_GAIN);
},
graph_widget_);
auto *meter = new Fl_Button(276, 150, 110, 26, "Add meter");
2026-10-27 01:37:21 +01:00
meter->callback(
[](Fl_Widget *, void *context) {
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_METER);
},
graph_widget_);
auto *output = new Fl_Button(394, 150, 110, 26, "Add output");
2026-10-27 01:37:21 +01:00
output->callback(
[](Fl_Widget *, void *context) {
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_OUTPUT);
},
graph_widget_);
auto *audio = new Fl_Button(650, 150, 130, 26, "Add audio source");
2026-10-27 01:37:21 +01:00
audio->callback(
[](Fl_Widget *, void *context) {
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_AUDIO);
},
graph_widget_);
auto *instrument = new Fl_Button(788, 150, 130, 26, "Add instrument");
2026-10-27 01:37:21 +01:00
instrument->callback(
[](Fl_Widget *, void *c) {
(void)static_cast<GraphWidget *>(c)->add_node(XTC_NODE_INSTRUMENT);
},
graph_widget_);
auto *remove = new Fl_Button(512, 150, 130, 26, "Delete selection");
2026-10-27 01:37:21 +01:00
remove->callback(
[](Fl_Widget *, void *context) {
(void)static_cast<GraphWidget *>(context)->delete_selected();
},
graph_widget_);
graph->resizable(scroll);
2026-10-26 22:51:25 +01:00
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);
2026-10-26 22:51:25 +01:00
status_ = new Fl_Box(12, 594, 976, 26, "JACK: not connected");
status_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
status_->labelsize(12);
error_ = new Fl_Box(12, 624, 976, 44, "");
error_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP);
error_->labelcolor(fl_rgb_color(255, 155, 120));
error_->labelsize(12);
2026-10-26 22:51:25 +01:00
end();
resizable(tabs_);
size_range(800, 540);
callback(close_callback, this);
open_backend();
Fl::add_timeout(0.1, timer_callback, this);
2026-10-26 22:51:25 +01:00
}
MainWindow::~MainWindow()
2026-10-26 22:51:25 +01:00
{
Fl::remove_timeout(timer_callback, this);
// Normal window/menu close already synchronized callbacks. This fallback
// covers exceptional event-loop exit. A failed close must retain its C
// callback context until process exit, rather than free active audio state.
if (backend_ != nullptr && xtc_backend_close(backend_) != XTC_OK)
2026-10-27 01:37:21 +01:00
std::fputs("Failed to close JACK during GUI destruction; backend and recording retained until process exit.\n",
stderr);
else if (recorder_) {
(void)xtc_recorder_finish(recorder_);
xtc_recorder_destroy(recorder_);
}
}
void MainWindow::report_error(const char *operation, xtc_result result)
{
char text[384];
2026-10-27 01:37:21 +01:00
const char *hint = std::strstr(operation, "Project") != nullptr
? "Check the project path, file format and write permissions."
: result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support."
: "Check the JACK-compatible server and use Reconnect to retry.";
std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint);
set_label(error_, text);
}
xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, size_t *out_added_id)
{
auto *window = static_cast<MainWindow *>(context);
2026-10-27 01:37:21 +01:00
if (window->recorder_)
return XTC_ERR_INVALID_STATE;
xtc_graph *candidate = nullptr;
size_t added_id = SIZE_MAX;
xtc_result result = xtc_graph_apply_edit(xtc_project_graph(window->project_.get()), &edit, &candidate, &added_id);
2026-10-27 01:37:21 +01:00
if (result != XTC_OK)
return result;
xtc_project *replacement = nullptr;
result = xtc_project_clone(window->project_.get(), &replacement);
2026-10-27 01:37:21 +01:00
if (result == XTC_OK)
result = xtc_project_set_graph(replacement, candidate);
xtc_graph_destroy(candidate);
if (result == XTC_OK && window->ready_)
result = xtc_backend_submit(window->backend_, xtc_project_graph(replacement));
2026-10-27 01:37:21 +01:00
if (result != XTC_OK) {
xtc_project_destroy(replacement);
return result;
}
window->project_.reset(replacement);
window->graph_widget_->set_graph(xtc_project_graph(replacement));
window->sequencer_->set_project(replacement, false);
window->mark_dirty();
2026-10-27 01:37:21 +01:00
if (out_added_id != nullptr && edit.kind == XTC_EDIT_ADD_NODE)
*out_added_id = added_id;
window->refresh_status();
return XTC_OK;
}
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;
2026-10-27 01:37:21 +01:00
if (result != XTC_OK)
report_error("Open JACK failed", result);
else {
result = xtc_backend_submit(backend_, xtc_project_graph(project_.get()));
if (result != XTC_OK) {
ready_ = false; // Handle is retained for close/reconnect; graph was not installed.
report_error("Submit graph failed", result);
2026-10-27 01:37:21 +01:00
} else
set_label(error_, "");
}
refresh_status();
}
void MainWindow::start_engine()
{
if (!ready_) {
report_error("Audio is disconnected; use Reconnect", XTC_ERR_INVALID_STATE);
return;
}
2026-10-27 01:37:21 +01:00
if (running_)
return;
xtc_result result = xtc_backend_prepare_project(backend_, project_.get());
2026-10-27 01:37:21 +01:00
if (result == XTC_OK)
result = xtc_backend_start(backend_);
if (result != XTC_OK)
report_error("Start engine failed", result);
else {
set_label(error_, "");
const char *issue = xtc_backend_asset_issue(backend_);
if (issue[0]) {
2026-10-27 01:37:21 +01:00
char message[640];
std::snprintf(message, sizeof(message),
"Missing/unreadable audio (silent): %s. Select clip and Relink to repair.", issue);
set_label(error_, message);
}
}
refresh_status();
}
void MainWindow::stop_engine()
{
2026-10-27 01:37:21 +01:00
if (!ready_)
return;
xtc_result result = xtc_backend_stop(backend_);
2026-10-27 01:37:21 +01:00
if (result != XTC_OK)
report_error("Stop engine failed", result);
else {
set_label(error_, "");
2026-10-27 01:37:21 +01:00
if (recorder_)
finish_recording();
}
refresh_status();
}
void MainWindow::reconnect_engine()
{
2026-10-27 01:37:21 +01:00
if (recorder_) {
report_error("Stop recording before reconnecting", XTC_ERR_INVALID_STATE);
return;
}
xtc_result result = xtc_backend_close(backend_);
if (result != XTC_OK) {
report_error("Reconnect could not close JACK", result);
return; // Preserve the existing handle for a later retry.
}
backend_ = nullptr;
ready_ = false;
running_ = false;
open_backend();
}
void MainWindow::refresh_status()
{
2026-10-27 01:37:21 +01:00
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_status status = {};
xtc_result result = xtc_backend_get_status(backend_, &status);
if (result != XTC_OK) {
report_error("Read engine status failed", result);
set_label(indicator_, "Audio: status unavailable");
set_label(graph_state_, "Graph: engine status unavailable");
set_label(status_, "Unable to read JACK status — use Reconnect");
indicator_->labelcolor(fl_rgb_color(255, 155, 120));
} else {
uint64_t tick = 0;
if (xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &tick) == XTC_OK)
sequencer_->position(tick);
running_ = status.running != 0;
const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK;
can_play = !running_ && !fault;
can_record = !fault && recorder_ == nullptr;
2026-10-27 01:37:21 +01:00
const char *state = status.server_lost ? "Audio: server lost — Reconnect required"
: status.sample_rate_changed ? "Audio: sample rate changed — Reconnect required"
: status.process_result != XTC_OK ? "Audio: processing error"
: recorder_ ? "Audio: recording JACK input — Stop to finish"
: running_ ? "Audio: running — modular graph"
: "Audio: ready / stopped";
set_label(indicator_, state);
2026-10-27 01:37:21 +01:00
indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120)
: running_ ? fl_rgb_color(125, 225, 160)
: fl_rgb_color(215, 215, 215));
mixer_->peaks(status.peak_left, status.peak_right);
char text[256];
2026-10-27 01:37:21 +01:00
std::snprintf(
text, sizeof(text),
"JACK | %u Hz | %u frames | stereo | %u blocks | %u xruns | %s | %u asset issues | %u MIDI drops",
status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns,
xtc_result_string(status.process_result), status.asset_issues, status.midi_dropped);
set_label(status_, text);
2026-10-27 01:37:21 +01:00
if (fault)
set_label(graph_state_, "Graph: engine unavailable — Reconnect required");
else if (status.submitted_revision != status.active_revision)
2026-10-27 01:37:21 +01:00
set_label(graph_state_, running_ ? "Graph: queued — engine still processing previous graph"
: "Graph: prepared — starts on Play");
else
set_label(graph_state_, running_ ? "Graph: active in engine" : "Graph: stopped");
}
xtc_backend_collect(backend_);
}
2026-10-27 01:37:21 +01:00
if (can_play)
play_->activate();
else
play_->deactivate();
if (can_record)
record_->activate();
else
record_->deactivate();
if (ready_ && (running_ || recorder_))
stop_->activate();
else
stop_->deactivate();
}
bool MainWindow::close_window()
{
2026-10-27 01:37:21 +01:00
if (recorder_) {
report_error("Stop recording before closing", XTC_ERR_INVALID_STATE);
return false;
}
if (!discard_changes())
return false;
xtc_result result = xtc_backend_close(backend_);
if (result != XTC_OK) {
report_error("Close JACK failed; window kept open for retry", result);
return false;
}
backend_ = nullptr;
ready_ = false;
running_ = false;
closing_ = true;
Fl::remove_timeout(timer_callback, this);
2026-10-27 01:37:21 +01:00
if (piano_window_)
piano_window_->hide();
hide();
return true;
}
int MainWindow::handle(int event)
{
2026-10-27 01:37:21 +01:00
if (event == FL_SHORTCUT && Fl::event_key() == ' ' && (Fl::event_state() & (FL_CTRL | FL_ALT | FL_META)) == 0) {
if (running_)
stop_engine();
else
start_engine();
return 1;
}
return Fl_Double_Window::handle(event);
}
2026-10-27 01:37:21 +01:00
void MainWindow::play_callback(Fl_Widget *, void *context)
2026-10-27 01:37:21 +01:00
{
static_cast<MainWindow *>(context)->start_engine();
}
void MainWindow::stop_callback(Fl_Widget *, void *context)
2026-10-27 01:37:21 +01:00
{
static_cast<MainWindow *>(context)->stop_engine();
}
void MainWindow::reconnect_callback(Fl_Widget *, void *context)
2026-10-27 01:37:21 +01:00
{
static_cast<MainWindow *>(context)->reconnect_engine();
}
void MainWindow::close_callback(Fl_Widget *, void *context)
2026-10-27 01:37:21 +01:00
{
(void)static_cast<MainWindow *>(context)->close_window();
}
void MainWindow::view_callback(Fl_Widget *widget, void *view)
{
auto *window = static_cast<MainWindow *>(widget->window());
window->tabs_->value(static_cast<Fl_Widget *>(view));
window->tabs_->redraw();
}
2026-10-27 01:37:21 +01:00
void MainWindow::about_callback(Fl_Widget *, void *)
{
fl_message("%s %s\nModular audio workstation\nPhase 9: native C processors and Linux release preparation.",
2026-10-27 01:37:21 +01:00
XTC_PROJECT_NAME, XTC_VERSION_STRING);
}
2026-10-27 01:37:21 +01:00
void MainWindow::timer_callback(void *context)
{
auto *window = static_cast<MainWindow *>(context);
if (window->recorder_) {
xtc_result r = xtc_recorder_drain(window->recorder_);
xtc_backend_status status{};
xtc_backend_get_status(window->backend_, &status);
if (r != XTC_OK || status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK)
window->stop_engine();
}
window->refresh_status();
2026-10-27 01:37:21 +01:00
if (!window->closing_)
Fl::repeat_timeout(0.1, timer_callback, context);
2026-10-26 22:51:25 +01:00
}
void MainWindow::mark_dirty()
{
xtc_result history_result = xtc_history_record(history_.get(), project_.get());
2026-10-27 01:37:21 +01:00
if (history_result != XTC_OK)
report_error("Undo history could not retain edit", history_result);
mixer_->set_project(project_.get());
graph_widget_->set_graph(xtc_project_graph(project_.get()));
2026-10-27 01:37:21 +01:00
if (piano_)
piano_->set_project(project_.get(), piano_->clip());
dirty_ = true;
copy_label("xtc * — unsaved changes");
}
2026-10-27 01:37:21 +01:00
bool MainWindow::discard_changes()
{
return !dirty_ || !prompt_unsaved_ ||
2026-10-27 01:37:21 +01:00
fl_choice("Discard unsaved project changes?", "Cancel", "Discard", nullptr) == 1;
}
2026-10-27 01:37:21 +01:00
xtc_result MainWindow::seek(void *context, uint64_t tick)
{
auto *window = static_cast<MainWindow *>(context);
2026-10-27 01:37:21 +01:00
if (!window->ready_)
return XTC_OK;
xtc_backend_status status{};
xtc_result r = xtc_backend_get_status(window->backend_, &status);
uint64_t sample = 0;
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_project_tick_to_sample(window->project_.get(), tick, status.sample_rate, &sample);
return r == XTC_OK ? xtc_backend_seek(window->backend_, sample) : r;
}
2026-10-27 01:37:21 +01:00
xtc_result MainWindow::save_project(const char *path)
{
2026-10-27 01:37:21 +01:00
if (recorder_)
return XTC_ERR_INVALID_STATE;
if (path == nullptr)
return XTC_ERR_INVALID_ARGUMENT;
std::string saved_path(path);
xtc_result r = xtc_project_save(project_.get(), path);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK) {
path_ = saved_path;
dirty_ = false;
copy_label(path_.c_str());
}
return r;
}
2026-10-27 01:37:21 +01:00
xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_history)
{
2026-10-27 01:37:21 +01:00
if (recorder_)
return XTC_ERR_INVALID_STATE;
std::unique_ptr<xtc_history, decltype(&xtc_history_destroy)> new_history(nullptr, xtc_history_destroy);
if (reset_history) {
xtc_history *h = nullptr;
xtc_result result = xtc_history_create(candidate, &h);
2026-10-27 01:37:21 +01:00
if (result != XTC_OK)
return result;
new_history.reset(h);
}
xtc_result r = XTC_OK;
if (ready_) {
r = xtc_backend_stop(backend_);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_backend_seek(backend_, 0);
if (r == XTC_OK)
r = xtc_backend_prepare_project(backend_, candidate);
}
xtc_audio_plan *offline = nullptr;
2026-10-27 01:37:21 +01:00
if (r == XTC_OK && !ready_)
r = xtc_audio_plan_create(candidate, 48000, &offline);
xtc_midi_plan *offline_midi = nullptr;
2026-10-27 01:37:21 +01:00
if (r == XTC_OK && !ready_)
r = xtc_midi_plan_create(candidate, 48000, &offline_midi);
xtc_midi_plan_destroy(offline_midi);
2026-10-27 01:37:21 +01:00
if (r != XTC_OK) {
xtc_audio_plan_destroy(offline);
return r;
}
std::string issue = ready_ ? xtc_backend_asset_issue(backend_) : "";
if (offline) {
const char *path = "";
2026-10-27 01:37:21 +01:00
xtc_audio_plan_issues(offline, &path);
issue = path;
xtc_audio_plan_destroy(offline);
}
2026-10-27 01:37:21 +01:00
if (piano_window_)
piano_window_->hide();
if (piano_)
piano_->set_project(candidate, UINT64_MAX);
project_.reset(candidate);
mixer_->set_project(candidate);
2026-10-27 01:37:21 +01:00
if (reset_history)
history_.swap(new_history);
graph_widget_->reset_layout();
graph_widget_->set_graph(xtc_project_graph(candidate));
sequencer_->set_project(candidate);
dirty_ = false;
if (!issue.empty()) {
2026-10-27 01:37:21 +01:00
char text[640];
std::snprintf(text, sizeof(text), "Missing/unreadable audio (silent): %s. Select clip and Relink to repair.",
issue.c_str());
set_label(error_, text);
2026-10-27 01:37:21 +01:00
} else
set_label(error_, "");
refresh_status();
return XTC_OK;
}
2026-10-27 01:37:21 +01:00
xtc_result MainWindow::open_project(const char *path)
{
2026-10-27 01:37:21 +01:00
if (path == nullptr)
return XTC_ERR_INVALID_ARGUMENT;
std::string loaded_path(path);
xtc_project *candidate = nullptr;
xtc_result r = xtc_project_create(&candidate);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_project_load(candidate, path);
if (r == XTC_OK)
r = install_project(candidate);
if (r != XTC_OK)
xtc_project_destroy(candidate);
else {
path_ = loaded_path;
copy_label(path_.c_str());
}
return r;
}
2026-10-27 01:37:21 +01:00
void MainWindow::file_callback(Fl_Widget *widget, void *context)
{
auto *window = static_cast<MainWindow *>(context);
2026-10-27 01:37:21 +01:00
if (window->recorder_) {
window->report_error("Stop recording before file operations", XTC_ERR_INVALID_STATE);
return;
}
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
const char *action = menu->mvalue()->label();
xtc_result r = XTC_OK;
if (std::strcmp(action, "New") == 0) {
2026-10-27 01:37:21 +01:00
if (!window->discard_changes())
return;
xtc_project *candidate = nullptr;
r = xtc_project_create(&candidate);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = window->install_project(candidate);
if (r != XTC_OK)
xtc_project_destroy(candidate);
else {
window->path_.clear();
window->copy_label("xtc — Untitled");
}
} else {
bool open = std::strcmp(action, "Open") == 0;
2026-10-27 01:37:21 +01:00
if (open && !window->discard_changes())
return;
if (!open && std::strcmp(action, "Save") == 0 && !window->path_.empty())
r = window->save_project(window->path_.c_str());
else {
Fl_Native_File_Chooser chooser;
chooser.title(open ? "Open project" : "Save project");
chooser.type(open ? Fl_Native_File_Chooser::BROWSE_FILE : Fl_Native_File_Chooser::BROWSE_SAVE_FILE);
chooser.filter("xtc projects *.xtc");
2026-10-27 01:37:21 +01:00
if (!open)
chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM);
int result = chooser.show();
2026-10-27 01:37:21 +01:00
if (result == 1)
return;
if (result < 0)
r = XTC_ERR_IO;
else
r = open ? window->open_project(chooser.filename()) : window->save_project(chooser.filename());
}
}
2026-10-27 01:37:21 +01:00
if (r != XTC_OK)
window->report_error("Project operation failed", r);
}
xtc_result MainWindow::before_arrangement_edit(void *context)
{
auto *window = static_cast<MainWindow *>(context);
2026-10-27 01:37:21 +01:00
if (window->recorder_)
return XTC_ERR_INVALID_STATE;
if (!window->ready_)
return XTC_OK;
xtc_result r = xtc_backend_stop(window->backend_);
window->refresh_status();
return r;
}
2026-10-27 01:37:21 +01:00
xtc_result MainWindow::import_audio(const char *path)
{
xtc_result r = before_arrangement_edit(this);
2026-10-27 01:37:21 +01:00
if (r != XTC_OK)
return r;
xtc_project *candidate = nullptr;
r = xtc_project_clone(project_.get(), &candidate);
uint64_t track = sequencer_->selected_track(), clip = 0;
2026-10-27 01:37:21 +01:00
if (r == XTC_OK && track == UINT64_MAX)
r = xtc_project_add_track(candidate, "Audio", &track);
if (r == XTC_OK)
r = xtc_project_import_audio(candidate, track, path, sequencer_->position(), &clip);
if (r == XTC_OK && ready_)
r = xtc_backend_prepare_project(backend_, candidate);
if (r != XTC_OK) {
xtc_project_destroy(candidate);
return r;
}
project_.reset(candidate);
sequencer_->set_project(candidate, false);
graph_widget_->set_graph(xtc_project_graph(candidate));
2026-10-27 01:37:21 +01:00
mark_dirty();
refresh_status();
return XTC_OK;
}
2026-10-27 01:37:21 +01:00
xtc_result MainWindow::record_audio(const char *path)
{
2026-10-27 01:37:21 +01:00
if (!ready_ || recorder_)
return XTC_ERR_INVALID_STATE;
xtc_result r = xtc_backend_stop(backend_);
xtc_backend_status status{};
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_backend_get_status(backend_, &status);
if (r == XTC_OK)
r = xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &recording_tick_);
if (r == XTC_OK)
r = xtc_backend_prepare_project(backend_, project_.get());
if (r == XTC_OK)
r = xtc_recorder_create(path, status.sample_rate, static_cast<size_t>(status.sample_rate) * 2, &recorder_);
if (r != XTC_OK)
return r;
recording_path_ = path;
recording_track_ = sequencer_->selected_track();
r = xtc_backend_set_recorder(backend_, recorder_);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_backend_start(backend_);
if (r != XTC_OK) {
(void)xtc_backend_set_recorder(backend_, nullptr);
2026-10-27 01:37:21 +01:00
xtc_recorder_destroy(recorder_);
recorder_ = nullptr;
} else
mark_dirty();
refresh_status();
return r;
}
2026-10-27 01:37:21 +01:00
void MainWindow::finish_recording()
{
// Called only after successful backend_stop: no producer can reference ring.
(void)xtc_backend_set_recorder(backend_, nullptr);
uint64_t frames = 0;
(void)xtc_recorder_status(recorder_, &frames);
xtc_result r = xtc_recorder_finish(recorder_);
2026-10-27 01:37:21 +01:00
xtc_recorder_destroy(recorder_);
recorder_ = nullptr;
if (r != XTC_OK) {
2026-10-27 01:37:21 +01:00
char text[768];
std::snprintf(text, sizeof(text),
"Recording failed (%s). 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_;
2026-10-27 01:37:21 +01:00
if (r == XTC_OK && track == UINT64_MAX)
r = xtc_project_add_track(candidate, "Recording", &track);
if (r == XTC_OK)
r = xtc_project_import_audio(candidate, track, recording_path_.c_str(), recording_tick_, &clip);
if (r == XTC_OK)
r = xtc_backend_prepare_project(backend_, candidate);
if (r != XTC_OK) {
xtc_project_destroy(candidate);
2026-10-27 01:37:21 +01:00
char text[768];
std::snprintf(text, sizeof(text), "Take saved to %s; adding clip failed: %s. Import the WAV to recover.",
recording_path_.c_str(), xtc_result_string(r));
set_label(error_, text);
return;
}
2026-10-27 01:37:21 +01:00
project_.reset(candidate);
sequencer_->set_project(candidate, false);
graph_widget_->set_graph(xtc_project_graph(candidate));
mark_dirty();
}
2026-10-27 01:37:21 +01:00
void MainWindow::audio_file_callback(Fl_Widget *widget, void *context)
{
auto *window = static_cast<MainWindow *>(context);
auto *menu = dynamic_cast<Fl_Menu_Bar *>(widget);
const char *action = menu ? menu->mvalue()->label() : "Record WAV";
bool recording = std::strcmp(action, "Record WAV") == 0;
bool relink = std::strcmp(action, "Relink selected audio") == 0;
2026-10-27 01:37:21 +01:00
if (window->recorder_) {
window->report_error("Stop recording first", XTC_ERR_INVALID_STATE);
return;
}
Fl_Native_File_Chooser chooser;
chooser.title(recording ? "Record JACK input to WAV" : relink ? "Relink selected clip" : "Import WAV");
chooser.type(recording ? Fl_Native_File_Chooser::BROWSE_SAVE_FILE : Fl_Native_File_Chooser::BROWSE_FILE);
chooser.filter("WAV audio\t*.wav");
2026-10-27 01:37:21 +01:00
if (recording)
chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM);
int chosen = chooser.show();
2026-10-27 01:37:21 +01:00
if (chosen == 1)
return;
xtc_result r = chosen < 0 ? XTC_ERR_IO : XTC_OK;
if (r == XTC_OK && relink) {
r = before_arrangement_edit(window);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = xtc_project_set_clip_asset(window->project_.get(), window->sequencer_->selected_clip(),
chooser.filename());
if (r == XTC_OK) {
window->mark_dirty();
window->sequencer_->redraw();
}
} else if (r == XTC_OK)
r = recording ? window->record_audio(chooser.filename()) : window->import_audio(chooser.filename());
if (r != XTC_OK)
window->report_error("Project audio operation failed", r);
}
xtc_result MainWindow::open_piano_roll()
{
xtc_clip_info clip{};
if (xtc_project_find_clip(project_.get(), sequencer_->selected_clip(), &clip) != XTC_OK || !clip.midi)
return XTC_ERR_INVALID_STATE;
if (!piano_window_) {
piano_window_ = std::make_unique<Fl_Double_Window>(900, 620, "xtc — Piano roll");
piano_window_->begin();
auto *hint = new Fl_Box(12, 8, 876, 32,
2026-10-27 01:37:21 +01:00
"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);
2026-10-27 01:37:21 +01:00
piano_->callback(
[](Fl_Widget *, void *c) {
static_cast<MainWindow *>(c)->mark_dirty();
},
this);
scroll->end();
piano_window_->end();
piano_window_->resizable(scroll);
scroll->scroll_to(0, 60 * 16);
2026-10-27 01:37:21 +01:00
} else
piano_->set_project(project_.get(), clip.id);
piano_window_->show();
return XTC_OK;
}
2026-10-27 01:37:21 +01:00
void MainWindow::piano_callback(Fl_Widget *, void *context)
{
auto *window = static_cast<MainWindow *>(context);
2026-10-27 01:37:21 +01:00
if (window->recorder_) {
window->report_error("Stop recording before editing MIDI", XTC_ERR_INVALID_STATE);
return;
}
xtc_result r = window->open_piano_roll();
2026-10-27 01:37:21 +01:00
if (r != XTC_OK)
set_label(window->error_, "Select a MIDI clip, then open Piano roll.");
}
2026-10-27 01:37:21 +01:00
void MainWindow::panic_callback(Fl_Widget *, void *context)
{
auto *window = static_cast<MainWindow *>(context);
2026-10-27 01:37:21 +01:00
if (!window->ready_)
return;
xtc_result r = xtc_backend_panic(window->backend_);
2026-10-27 01:37:21 +01:00
if (r != XTC_OK)
window->report_error("Panic failed", r);
}
xtc_result MainWindow::undo(bool redo)
{
2026-10-27 01:37:21 +01:00
if (recorder_)
return XTC_ERR_INVALID_STATE;
xtc_project *candidate = nullptr;
xtc_result r = xtc_history_checkout(history_.get(), redo ? 1 : 0, &candidate);
2026-10-27 01:37:21 +01:00
if (r == XTC_OK)
r = install_project(candidate, false);
if (r != XTC_OK) {
xtc_project_destroy(candidate);
return r;
}
r = xtc_history_accept(history_.get(), redo ? 1 : 0);
2026-10-27 01:37:21 +01:00
dirty_ = true;
copy_label("xtc * — unsaved changes");
return r;
}
2026-10-27 01:37:21 +01:00
void MainWindow::history_callback(Fl_Widget *widget, void *context)
{
auto *window = static_cast<MainWindow *>(context);
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
bool redo = std::strncmp(menu->mvalue()->label(), "Redo", 4) == 0;
xtc_result r = window->undo(redo);
2026-10-27 01:37:21 +01:00
if (r != XTC_OK && r != XTC_ERR_NOT_FOUND)
window->report_error("Undo/redo failed", r);
}