From e9d0c62fd66ae2b253df5ff218b8b5008a31f028 Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 02:25:28 +0100 Subject: [PATCH] feat(preferences): choose MIDI devices and persistent channel defaults (phase 102) --- docs/MIDI_WORKFLOWS.md | 17 +++++++++++++++++ docs/ROADMAP.md | 8 ++++---- include/xtc/xtc_settings.h | 2 ++ src/gui/main_window.cpp | 27 ++++++++++++++++++++++----- src/gui/main_window.hpp | 2 ++ src/gui/preferences_dialog.hpp | 31 ++++++++++++++++++++++++++++--- src/settings/settings.c | 6 ++++++ tests/integration/test_gui.cpp | 21 ++++++++++++++++++--- tests/unit/test_settings.c | 4 ++-- 9 files changed, 101 insertions(+), 17 deletions(-) diff --git a/docs/MIDI_WORKFLOWS.md b/docs/MIDI_WORKFLOWS.md index a4fa2f0..cd6e744 100644 --- a/docs/MIDI_WORKFLOWS.md +++ b/docs/MIDI_WORKFLOWS.md @@ -32,3 +32,20 @@ the MIDI-drop counter. Panic and stop send CC123 on all channels. Immediate stop waits up to 100 ms for callback acknowledgement before deactivation; delivery cannot be guaranteed after server/device loss or to devices ignoring CC123. No device is connected unless a user chooses it or an external patchbay routes it. + +## MIDI preferences + +Preferences → midi has editable dropdowns for a default input source and output +destination, plus a Refresh MIDI devices button. Names are enumerated from the +current JACK/PipeWire server, excluding xtc's own ports. Select None or clear a +field for no automatic connection. Typed offline names remain saved. Hardware +ports are visible only when the host exposes them through its MIDI bridge. + +Input channel `-1` accepts all channels; `0..15` selects MIDI channel 1..16. +Output channel `-1` preserves note channels; `0..15` forces a channel. Thru defaults +off. Apply saves these choices; the next Play/Record restores the selected routes. +A missing port rejects that start and stops the backend, preserving the saved +choice for retry. Reconnect audio after server loss, then retry Play. Defaults +also apply when playback resumes after a loop edit. MIDI input/thru currently +requires active transport. Existing editing and project_defaults tabs contain +note length, velocity, channel, filters and new-project controller mappings. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1e36015..835897c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2256,12 +2256,12 @@ Implemented: public JACK MIDI discovery/naming/routing/options APIs, a prepared Provide device dropdowns and refresh in Preferences, persistent default input/output ports, live channel filters and MIDI thru; retain editing and controller defaults in ~/.xtc.conf. ### Tasks and acceptance criteria -- [ ] Implement and document the workflow with bounded validation and safe ownership. -- [ ] Pass meaningful core and GUI checks, including errors and persistence or undo. -- [ ] Commit this phase separately after validation. +- [x] Implement and document the workflow with bounded validation and safe ownership. +- [x] Pass meaningful core and GUI checks, including errors and persistence or undo. +- [x] Commit this phase separately after validation. ### Implementation status -Planned. +Implemented: a MIDI preferences tab provides editable source/destination dropdowns, device refresh, input filtering, output channel defaults and optional thru. All choices use the strict shared configuration schema and ~/.xtc.conf. Play/Record restores selected routes; missing devices reject startup without losing choices. Settings roundtrip/invalid-value, GUI discovery/refresh/persistence/missing-device checks and the full GUI suite passed. MIDI help explains host bridge exposure, channel numbering and existing note/CC defaults. --- diff --git a/include/xtc/xtc_settings.h b/include/xtc/xtc_settings.h index 8284a6c..b4f15af 100644 --- a/include/xtc/xtc_settings.h +++ b/include/xtc/xtc_settings.h @@ -10,6 +10,8 @@ extern "C" { typedef struct { char client_name[64], clap_paths[2048], vst2_paths[2048], vst3_paths[2048], ladspa_paths[2048]; int64_t max_frames, channels, transition_ms, offline_rate, recording_seconds; + char midi_input_port[256],midi_output_port[256]; + int64_t midi_input_channel,midi_output_channel,midi_thru; int64_t main_x, main_y, inspector_visible; int64_t width, height, piano_width, piano_height, refresh_ms; int64_t transport_detached,mixer_detached,sequencer_detached,graph_detached,piano_detached; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 9b426f6..78f0c95 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -905,9 +905,9 @@ void MainWindow::start_engine() 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_); + result = start_configured_backend(); if (result != XTC_OK) - report_error("Start engine failed", result); + report_error("Start failed; check audio and saved MIDI devices in Preferences", result); else { set_label(error_, ""); const char *issue = xtc_backend_asset_issue(backend_); @@ -1659,7 +1659,7 @@ xtc_result MainWindow::record_audio(const char *path) recording_track_ = sequencer_->selected_track(); r = xtc_backend_set_recorder(backend_, recorder_); if (r == XTC_OK) - r = xtc_backend_start(backend_); + r = start_configured_backend(); if (r != XTC_OK) { (void)xtc_backend_set_recorder(backend_, nullptr); xtc_recorder_destroy(recorder_); @@ -2462,7 +2462,7 @@ xtc_result MainWindow::set_loop(uint64_t start,uint64_t end,bool 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;} + if(ready_ && resume && r==XTC_OK) {xtc_result restarted=start_configured_backend();if(r==XTC_OK)r=restarted;} refresh_status();return r; } void MainWindow::collect_preferences() @@ -2524,10 +2524,27 @@ xtc_result MainWindow::save_preferences() if(configuration_.empty() || !configuration_valid_)return XTC_ERR_INVALID_STATE; collect_preferences();return xtc_settings_save(&settings_,configuration_.c_str()); } +std::vector MainWindow::midi_devices(bool input) +{ + std::vector ports; + if(ready_)(void)xtc_backend_visit_midi_ports(backend_,input ? XTC_PORT_OUTPUT : XTC_PORT_INPUT, + [](const char *name,void *c){static_cast *>(c)->emplace_back(name);return 0;},&ports); + return ports; +} +xtc_result MainWindow::start_configured_backend() +{ + xtc_result r=xtc_backend_set_midi_options(backend_,static_cast(settings_.midi_input_channel),static_cast(settings_.midi_output_channel),settings_.midi_thru!=0); + if(r==XTC_OK)r=xtc_backend_start(backend_); + if(r!=XTC_OK)return r; + if(settings_.midi_input_port[0])r=xtc_backend_route_midi(backend_,XTC_PORT_INPUT,settings_.midi_input_port,0); + if(settings_.midi_output_port[0]){auto out=xtc_backend_route_midi(backend_,XTC_PORT_OUTPUT,settings_.midi_output_port,0);if(r==XTC_OK)r=out;} + if(r!=XTC_OK){(void)xtc_backend_stop(backend_);return r;} + 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(); + collect_preferences();PreferencesDialog dialog(settings_,configuration_,[this](const xtc_settings &s){return apply_preferences(s);},[this](bool input){return midi_devices(input);});dialog.run(); } xtc_result MainWindow::set_tempo(unsigned int milli_bpm) diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 8d1dd56..d78e55b 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -66,6 +66,8 @@ class MainWindow : public Fl_Double_Window { const xtc_settings &settings() const { return settings_; } xtc_result apply_preferences(const xtc_settings &settings); void show_preferences(); + std::vector midi_devices(bool input); + xtc_result start_configured_backend(); xtc_result save_preferences(); xtc_result set_loop(uint64_t start,uint64_t end,bool enabled); Fl_Widget *velocity_lane() const { return velocity_lane_; } diff --git a/src/gui/preferences_dialog.hpp b/src/gui/preferences_dialog.hpp index 5ed757d..f3b2c15 100644 --- a/src/gui/preferences_dialog.hpp +++ b/src/gui/preferences_dialog.hpp @@ -4,6 +4,7 @@ #include "theme.hpp" #include #include +#include #include #include #include @@ -15,6 +16,18 @@ class PreferencesDialog : public Fl_Double_Window { std::function apply_; std::vector inputs_; Fl_Box *message_; + std::function(bool)> devices_; + Fl_Input_Choice *midi_input_=nullptr,*midi_output_=nullptr; + void refresh_devices() { + for(auto pair:{std::pair{midi_input_,true},{midi_output_,false}}){ + if(!pair.first)continue; + std::string value=pair.first->value();std::vector names=devices_ ? devices_(pair.second) : std::vector{}; + names.insert(names.begin(),"");std::vector items(names.size()+1); + for(size_t i=0;imenubutton()->copy(items.data());pair.first->value(value.c_str()); + } + } + static void apply_callback(Fl_Widget *,void *context) { auto *d=static_cast(context); xtc_settings candidate=d->original_; size_t count=0; @@ -36,8 +49,9 @@ class PreferencesDialog : public Fl_Double_Window { } public: PreferencesDialog(const xtc_settings &settings,const std::string &path, - std::function apply) - : Fl_Double_Window(1050,660,"xtc — Preferences"),original_(settings),apply_(std::move(apply)) { + std::function apply, + std::function(bool)> devices={}) + : Fl_Double_Window(1050,660,"xtc — Preferences"),original_(settings),apply_(std::move(apply)),devices_(std::move(devices)) { set_modal(); begin(); auto *location=new Fl_Box(16,12,1018,30); location->copy_label(path.c_str()); location->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE); auto *tabs=new Fl_Tabs(12,50,1026,510); tabs->begin(); @@ -50,12 +64,23 @@ class PreferencesDialog : public Fl_Double_Window { group=new Fl_Group(12,82,1026,478); group->copy_label(section.c_str()); group->begin(); scroll=new Fl_Scroll(22,94,1000,450); scroll->begin(); row=0; } - auto *input=new Fl_Input(510,108+row*48,480,30,schema[i].label); + Fl_Input *input; + if(!strcmp(schema[i].key,"midi_input_port") || !strcmp(schema[i].key,"midi_output_port")){ + auto *choice=new Fl_Input_Choice(510,108+row*48,480,30,schema[i].label); + choice->callback([](Fl_Widget *w,void *){auto *c=static_cast(w);auto *item=c->menubutton()->mvalue();if(item && !strcmp(item->label(),"(None)"))c->value("");}); + input=choice->input();if(!strcmp(schema[i].key,"midi_input_port"))midi_input_=choice;else midi_output_=choice; + }else input=new Fl_Input(510,108+row*48,480,30,schema[i].label); input->align(FL_ALIGN_LEFT); input->maximum_size(schema[i].capacity ? static_cast(schema[i].capacity-1) : 22); char value[4096]; xtc_settings_get(&settings,i,value,sizeof(value)); input->value(value); inputs_[i]=input; ++row; } if(scroll) { scroll->end(); group->end(); } tabs->end(); + tabs->begin();auto *midi_info=new Fl_Group(12,82,1026,478,"MIDI help"); + auto *help=new Fl_Box(42,110,950,190,"Choose JACK/PipeWire MIDI ports on the midi tab. Refresh to see newly connected devices.\nYou can also type a remembered port name while it is offline; blank means no automatic route.\nRouting is restored on Play/Record; missing ports are reported without deleting preferences.\nInput/output channels use 0..15 for MIDI channels 1..16. MIDI thru defaults off.\nNote length/velocity/channel defaults are on editing; CC defaults are on project_defaults.\nHardware MIDI devices must be exposed by your host JACK/PipeWire MIDI bridge."); + help->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE|FL_ALIGN_WRAP);midi_info->end();tabs->end(); + refresh_devices(); + auto *refresh=new Fl_Button(16,614,200,30,"Refresh MIDI devices"); + refresh->callback([](Fl_Widget *,void *c){static_cast(c)->refresh_devices();},this); message_=new Fl_Box(16,568,1018,36,"Apply saves every tab. Existing project contents are not overwritten by defaults."); message_->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE|FL_ALIGN_WRAP); auto *apply_button=new Fl_Button(812,614,100,30,"Apply"); apply_button->callback(apply_callback,this); diff --git a/src/settings/settings.c b/src/settings/settings.c index 014e7bc..ac90501 100644 --- a/src/settings/settings.c +++ b/src/settings/settings.c @@ -21,6 +21,11 @@ static const xtc_setting_info schema[] = { STR("plugins",vst2_paths,"VST2 search paths (colon-separated)"), STR("plugins",vst3_paths,"VST3 search paths (colon-separated)"), STR("plugins",ladspa_paths,"LADSPA search paths (colon-separated)"), + STR("midi",midi_input_port,"Default MIDI input source (next Play)"), + STR("midi",midi_output_port,"Default MIDI output destination (next Play)"), + NUM("midi",midi_input_channel,"Live input channel (-1 all, 0..15)",-1,15), + NUM("midi",midi_output_channel,"Output channel (-1 original, 0..15)",-1,15), + NUM("midi",midi_thru,"Forward live MIDI to output: 0 or 1",0,1), NUM("interface",main_x,"Main window X",-32768,32768), NUM("interface",main_y,"Main window Y",-32768,32768), NUM("interface",inspector_visible,"Track inspector visible: 0 or 1",0,1), NUM("interface",width,"Main window width",1220,3840), NUM("interface",height,"Main window height",720,2160), @@ -77,6 +82,7 @@ void xtc_settings_defaults(xtc_settings *s) s->mixer_x=s->mixer_y=100;s->mixer_width=1056;s->mixer_height=600; s->sequencer_snap=s->piano_snap=240; s->sequencer_zoom=48; s->clip_ticks=3840; s->note_ticks=960; s->note_velocity=100; s->filter_channel=-1; s->filter_high=127; s->tempo=120000; + s->midi_input_channel=s->midi_output_channel=-1; s->volume_cc=7; s->pan_cc=10; s->sustain_cc=64; s->loop_end=3840;s->tail_ms=3000; } static int snap(int64_t v) { return v==1 || v==120 || v==160 || v==240 || v==320 || v==480 || v==960 || v==1920 || v==3840; } diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index b223c43..8a1b918 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -165,6 +165,12 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) ++engine_status.submitted_revision; return XTC_OK; } +xtc_result xtc_backend_set_midi_options(xtc_backend *,int input,int output,int thru) +{CHECK(input>=-1 && input<16 && output>=-1 && output<16 && thru>=0 && thru<2);return handle.running ? XTC_ERR_INVALID_STATE : XTC_OK;} +xtc_result xtc_backend_visit_midi_ports(xtc_backend *,xtc_port_direction d,xtc_backend_port_visitor visit,void *c) +{visit(d==XTC_PORT_OUTPUT ? "Keyboard:out" : "Synth:in",c);return XTC_OK;} +xtc_result xtc_backend_route_midi(xtc_backend *,xtc_port_direction,const char *name,int) +{return !strcmp(name,"missing:port") ? XTC_ERR_NOT_FOUND : XTC_OK;} xtc_result xtc_backend_panic(xtc_backend *) { xtc_midi_plan_panic(handle.midi, engine_status.sample_position); return XTC_OK; } xtc_result xtc_backend_read_meters(xtc_backend *, xtc_meter_snapshot *out) { @@ -351,10 +357,19 @@ int midi_workflow_tests() char dir[]="/tmp/xtc-midi-settings-XXXXXX";CHECK(mkdtemp(dir));std::string config=std::string(dir)+"/.xtc.conf"; reset();{ MainWindow window(false,config.c_str());window.show();auto *s=timeline(&window); + auto settings=window.settings();strcpy(settings.midi_input_port,"Keyboard:out");strcpy(settings.midi_output_port,"Synth:in");settings.midi_input_channel=2;settings.midi_output_channel=4;settings.midi_thru=1; + CHECK(window.apply_preferences(settings)==XTC_OK); + CHECK(window.midi_devices(true)==std::vector{"Keyboard:out"} && window.midi_devices(false)==std::vector{"Synth:in"}); + CHECK(window.start_configured_backend()==XTC_OK && handle.running);CHECK(xtc_backend_stop(&handle)==XTC_OK); + PreferencesDialog dialog(settings,config,[&](const xtc_settings &v){return window.apply_preferences(v);},[&](bool input){return window.midi_devices(input);}); + CHECK(find(&dialog,"Refresh MIDI devices"));press(find(&dialog,"Refresh MIDI devices")); + CHECK(window.settings().midi_output_channel==4); + auto missing=settings;strcpy(missing.midi_input_port,"missing:port");CHECK(window.apply_preferences(missing)==XTC_OK); + CHECK(window.start_configured_backend()==XTC_ERR_NOT_FOUND && !handle.running);CHECK(window.apply_preferences(settings)==XTC_OK); s->grid(480);window.show_track_inspector(true);window.resize(20,30,1300,820); CHECK(window.set_loop(960,3840,true)==XTC_OK);window.do_callback();CHECK(!window.shown()); } - xtc_settings saved{};CHECK(xtc_settings_load(&saved,config.c_str())==XTC_OK && saved.sequencer_snap==480 && saved.inspector_visible && saved.loop_start==960 && saved.width==1300); + xtc_settings saved{};CHECK(xtc_settings_load(&saved,config.c_str())==XTC_OK && saved.sequencer_snap==480 && saved.inspector_visible && saved.loop_start==960 && saved.width==1300 && saved.midi_thru && !strcmp(saved.midi_input_port,"Keyboard:out")); reset();{ MainWindow window(false,config.c_str());CHECK(window.settings().sequencer_snap==480 && window.settings().inspector_visible && window.settings().loop_enabled); window.do_callback(); @@ -409,9 +424,9 @@ int main(int argc, char **argv) CHECK(engine_status.sample_position == 0 && timeline(&window)->position() == 0); start_result = XTC_ERR_IO; press(play); - CHECK(contains(&window, "Start engine failed: I/O error")); + CHECK(contains(&window, "Start failed; check audio and saved MIDI devices in Preferences: I/O error")); pump(); // A refresh must not erase a command error. - CHECK(contains(&window, "Start engine failed: I/O error")); + CHECK(contains(&window, "Start failed; check audio and saved MIDI devices in Preferences: I/O error")); start_result = XTC_OK; press(play); CHECK(!contains(&window, "Start engine failed")); diff --git a/tests/unit/test_settings.c b/tests/unit/test_settings.c index 5122ae4..b302fc3 100644 --- a/tests/unit/test_settings.c +++ b/tests/unit/test_settings.c @@ -13,13 +13,13 @@ int main(void) CHECK(fd>=0);close(fd);unlink(path); xtc_settings_defaults(&a);CHECK(xtc_settings_validate(&a)==XTC_OK); b=a;CHECK(xtc_settings_load(&b,path)==XTC_ERR_NOT_FOUND); - strcpy(a.clap_paths,"~/space folder:/tmp/quoted\"name\\path");a.loop_enabled=1;a.follow_playhead=1;a.transport_detached=1;a.mixer_detached=1;a.transport_x=-30000;a.transport_width=400;a.note_velocity=87; + strcpy(a.clap_paths,"~/space folder:/tmp/quoted\"name\\path");a.loop_enabled=1;a.follow_playhead=1;a.transport_detached=1;a.mixer_detached=1;a.transport_x=-30000;a.transport_width=400;a.note_velocity=87;strcpy(a.midi_input_port,"Keyboard:out");strcpy(a.midi_output_port,"Synth:in");a.midi_input_channel=2;a.midi_output_channel=4;a.midi_thru=1; CHECK(xtc_settings_save(&a,path)==XTC_OK);CHECK(stat(path,&st)==0 && (st.st_mode&0777)==0600); CHECK(xtc_settings_load(&b,path)==XTC_OK);CHECK(memcmp(&a,&b,sizeof(a))==0); xtc_settings_schema(&n); for(i=0;i=0);CHECK(fclose(f)==0);before=b;CHECK(xtc_settings_load(&b,path)==XTC_ERR_FORMAT);CHECK(memcmp(&before,&b,sizeof(b))==0);}} f=fopen(path,"wb");CHECK(f);CHECK(fwrite(";x\0hidden",1,9,f)==9);fclose(f);CHECK(xtc_settings_load(&b,path)==XTC_ERR_FORMAT); CHECK(xtc_settings_save(&a,path)==XTC_OK);b=a;b.loop_end=b.loop_start;CHECK(xtc_settings_save(&b,path)==XTC_ERR_INVALID_ARGUMENT);CHECK(xtc_settings_load(&b,path)==XTC_OK && b.loop_end==a.loop_end);