feat(preferences): choose MIDI devices and persistent channel defaults (phase 102)

This commit is contained in:
Johannes Findeisen 2026-11-06 02:25:28 +01:00
commit e9d0c62fd6
9 changed files with 101 additions and 17 deletions

View file

@ -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.

View file

@ -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.
---

View file

@ -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;

View file

@ -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<std::string> MainWindow::midi_devices(bool input)
{
std::vector<std::string> ports;
if(ready_)(void)xtc_backend_visit_midi_ports(backend_,input ? XTC_PORT_OUTPUT : XTC_PORT_INPUT,
[](const char *name,void *c){static_cast<std::vector<std::string> *>(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<int>(settings_.midi_input_channel),static_cast<int>(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)

View file

@ -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<std::string> 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_; }

View file

@ -4,6 +4,7 @@
#include "theme.hpp"
#include <FL/Fl_Double_Window.H>
#include <FL/Fl_Input.H>
#include <FL/Fl_Input_Choice.H>
#include <FL/Fl_Box.H>
#include <FL/Fl_Button.H>
#include <functional>
@ -15,6 +16,18 @@ class PreferencesDialog : public Fl_Double_Window {
std::function<xtc_result(const xtc_settings &)> apply_;
std::vector<Fl_Input *> inputs_;
Fl_Box *message_;
std::function<std::vector<std::string>(bool)> devices_;
Fl_Input_Choice *midi_input_=nullptr,*midi_output_=nullptr;
void refresh_devices() {
for(auto pair:{std::pair<Fl_Input_Choice *,bool>{midi_input_,true},{midi_output_,false}}){
if(!pair.first)continue;
std::string value=pair.first->value();std::vector<std::string> names=devices_ ? devices_(pair.second) : std::vector<std::string>{};
names.insert(names.begin(),"");std::vector<Fl_Menu_Item> items(names.size()+1);
for(size_t i=0;i<names.size();++i)items[i].text=i ? names[i].c_str() : "(None)";
pair.first->menubutton()->copy(items.data());pair.first->value(value.c_str());
}
}
static void apply_callback(Fl_Widget *,void *context) {
auto *d=static_cast<PreferencesDialog *>(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<xtc_result(const xtc_settings &)> apply)
: Fl_Double_Window(1050,660,"xtc — Preferences"),original_(settings),apply_(std::move(apply)) {
std::function<xtc_result(const xtc_settings &)> apply,
std::function<std::vector<std::string>(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<Fl_Input_Choice *>(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<int>(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<PreferencesDialog *>(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);

View file

@ -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; }

View file

@ -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<std::string>{"Keyboard:out"} && window.midi_devices(false)==std::vector<std::string>{"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"));

View file

@ -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<n;++i){CHECK(xtc_settings_get(&a,i,value,sizeof(value))==XTC_OK);CHECK(xtc_settings_set(&b,i,value)==XTC_OK);}
CHECK(memcmp(&a,&b,sizeof(a))==0);
{const char *bad[]={"[unknown]\n","[interface]\ntransport_width=399\n","[interface]\nmixer_detached=2\n","[audio]\nchannels=3\n","[audio]\nchannels=1\nchannels=2\n","[audio]\nchannels=9999999999999999999999\n","[plugins]\nclap_paths=\"unterminated\n","[transport]\nloop_start=4000\nloop_end=2\n"};
{const char *bad[]={"[midi]\nmidi_input_channel=16\n","[midi]\nmidi_thru=2\n","[unknown]\n","[interface]\ntransport_width=399\n","[interface]\nmixer_detached=2\n","[audio]\nchannels=3\n","[audio]\nchannels=1\nchannels=2\n","[audio]\nchannels=9999999999999999999999\n","[plugins]\nclap_paths=\"unterminated\n","[transport]\nloop_start=4000\nloop_end=2\n"};
for(i=0;i<sizeof(bad)/sizeof(bad[0]);++i){f=fopen(path,"wb");CHECK(f);CHECK(fputs(bad[i],f)>=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);