diff --git a/Makefile b/Makefile index cb7ef6d..9aa5817 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/mixer/track_fx.c src/audio/sample.c src/persistence/collect.c src/audio/normalize.c src/project/sections.c src/project/editing.c src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c +CORE_SOURCES := src/project/chain_preset.c src/mixer/track_fx.c src/audio/sample.c src/persistence/collect.c src/audio/normalize.c src/project/sections.c src/project/editing.c src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes) ifeq ($(JACK_AVAILABLE),yes) JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8d1c75e..83028be 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1985,12 +1985,12 @@ Implemented: selected insert Inspect/edit verifies identity and shows generic co Save and load portable chain metadata with bounded validation, transactional replacement and explicit missing-plugin errors at preparation. ### Tasks and acceptance criteria -- [ ] Implement the described workflow with validated errors and safe ownership. -- [ ] Pass relevant core and/or GUI tests. -- [ ] Document behavior and limitations; commit this phase separately. +- [x] Implement the described workflow with validated errors and safe ownership. +- [x] Pass relevant core and/or GUI tests. +- [x] Document behavior and limitations; commit this phase separately. ### Implementation status -Planned. +Implemented: atomic chain preset save/load reuses the versioned project container with exactly one non-bus track and no clips/instrument. Metadata load never opens modules and preserves output on failure; GUI adoption prepares all effects before replacement. Core roundtrip/malformed tests, full core suite and GUI replacement/missing-file/reload checks passed. Presets reference installed binaries; opaque plugin states are not captured. --- diff --git a/docs/TRACK_INSERTS.md b/docs/TRACK_INSERTS.md index 2f152df..3889c07 100644 --- a/docs/TRACK_INSERTS.md +++ b/docs/TRACK_INSERTS.md @@ -50,3 +50,11 @@ controls. Reset defaults clears parameter automation on Apply. Cancel leaves the project unchanged. Internal effects and LADSPA expose controls; modern adapters currently expose none and run default configurations. Native plugin editor windows and opaque plugin-state chunks are not implemented. + +**Preset → Save chain / Load chain** reuses the atomic versioned project file +container with one track. Order, bypass, wet amounts, output gain and exposed +controls/automation are retained. Loading metadata executes no plugin code; +applying it verifies all enabled plugins before replacing the current chain. +Preset files reference absolute installed binary paths, so another machine must +provide compatible binaries at those paths. No plugin binaries or opaque state +chunks are embedded. diff --git a/include/xtc/xtc_plugin.h b/include/xtc/xtc_plugin.h index d3ec23a..b306014 100644 --- a/include/xtc/xtc_plugin.h +++ b/include/xtc/xtc_plugin.h @@ -31,9 +31,13 @@ typedef struct { unsigned int tempo, point_count; /* milli-BPM, 20000..300000 */ xtc_plugin_point points[XTC_PLUGIN_POINTS]; } xtc_plugin_spec; +/* Atomic versioned metadata presets; load never executes plugin code and + * preserves out on error. Modules are verified only when adopting playback. */ #define XTC_TRACK_INSERTS 8U typedef struct { xtc_plugin_spec plugin; unsigned int enabled; float wet; } xtc_track_insert; typedef struct { size_t count; float output_gain; xtc_track_insert slots[XTC_TRACK_INSERTS]; } xtc_track_chain; +xtc_result xtc_track_chain_preset_save(const xtc_track_chain *chain,const char *path); +xtc_result xtc_track_chain_preset_load(const char *path,xtc_track_chain *out); /* Metadata only; control thread. Empty chain has unity output. No module is * opened here. Nonempty chains are mono effects on non-bus tracks. */ /* Allocates the exact serial/parallel graph used by playback. Caller owns it. */ diff --git a/src/gui/track_inserts.hpp b/src/gui/track_inserts.hpp index 7ce195a..b5b7824 100644 --- a/src/gui/track_inserts.hpp +++ b/src/gui/track_inserts.hpp @@ -37,6 +37,9 @@ class TrackInsertsPanel : public Fl_Group { } auto *inspect=new Fl_Button(x+850,y+12,120,28,"Inspect / edit"); inspect->callback([](Fl_Widget *,void *c){auto *p=static_cast(c);p->result(p->inspect_slot());},this); + auto *presets=new Fl_Menu_Button(x+978,y+12,68,28,"Preset"); + presets->add("Save chain...",0,[](Fl_Widget *,void *c){static_cast(c)->preset(true);},this); + presets->add("Load chain...",0,[](Fl_Widget *,void *c){static_cast(c)->preset(false);},this); message_=new Fl_Box(x+16,y+50,w-32,32,"Track input > inserts > output gain > track fader > bus/master");message_->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE); slots_=new Fl_Hold_Browser(x+16,y+94,230,h-110);slots_->format_char(0); graph_=new GraphWidget(x+260,y+94,w-276,h-110,nullptr,[](void *,const xtc_graph_edit &,size_t *){return XTC_ERR_UNSUPPORTED;},nullptr,message_); @@ -48,6 +51,21 @@ class TrackInsertsPanel : public Fl_Group { void select_track(uint64_t id) {track_=id;refresh(true);} const xtc_graph *graph() const {return model_;} void add(const char *file,unsigned int index) { result(owner_->add_track_effect(track_,file,index)); } + xtc_result save_preset(const char *file) { + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain); + return r==XTC_OK ? xtc_track_chain_preset_save(&chain,file) : r; + } + xtc_result load_preset(const char *file) { + xtc_track_chain chain{};auto r=xtc_track_chain_preset_load(file,&chain); + return r==XTC_OK ? owner_->set_track_chain(track_,chain) : r; + } + void preset(bool save) { + Fl_Native_File_Chooser chooser;chooser.title(save ? "Save effect chain" : "Load effect chain"); + chooser.type(save ? Fl_Native_File_Chooser::BROWSE_SAVE_FILE : Fl_Native_File_Chooser::BROWSE_FILE); + chooser.filter("Effect chain project\t*.xtc");if(save)chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM); + if(chooser.show()!=0 || !chooser.filename())return; + result(save ? save_preset(chooser.filename()) : load_preset(chooser.filename())); + } xtc_result inspect_slot() { xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain);size_t index=selected_slot(); if(r!=XTC_OK)return r; diff --git a/src/project/chain_preset.c b/src/project/chain_preset.c new file mode 100644 index 0000000..8b3ab45 --- /dev/null +++ b/src/project/chain_preset.c @@ -0,0 +1,23 @@ +#include "project_internal.h" +/* Reuse the versioned atomic project container. No module loading occurs here. */ +xtc_result xtc_track_chain_preset_save(const xtc_track_chain *chain,const char *path) +{ + xtc_project *p=NULL;uint64_t id;xtc_result r; + if(!path || xtc_track_chain_validate(chain)!=XTC_OK)return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_create(&p); + if(r==XTC_OK)r=xtc_project_add_track(p,"Effect chain preset",&id); + if(r==XTC_OK)r=xtc_project_set_chain(p,id,chain); + if(r==XTC_OK)r=xtc_project_save(p,path); + xtc_project_destroy(p);return r; +} +xtc_result xtc_track_chain_preset_load(const char *path,xtc_track_chain *out) +{ + xtc_project *p=NULL;xtc_track_chain chain;xtc_result r; + if(!path || !out)return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_create(&p); + if(r==XTC_OK)r=xtc_project_load(p,path); + if(r==XTC_OK && (p->track_count!=1 || p->clip_count || p->tracks[0].is_bus || p->instruments[0].instrument))r=XTC_ERR_FORMAT; + if(r==XTC_OK)r=xtc_project_get_chain(p,p->tracks[0].id,&chain); + if(r==XTC_OK)*out=chain; + xtc_project_destroy(p);return r; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 8a5f6f3..871c0f2 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -249,6 +249,11 @@ int track_insert_tests() CHECK(xtc_project_get_chain(s->project(),track,&chain)==XTC_OK && chain.slots[0].plugin.values[0]!=10); const auto *cancel_before=s->project(); Fl::add_timeout(.05,[](void *){press(find(Fl::modal(),"Cancel"));});CHECK(panel->inspect_slot()==XTC_OK && s->project()==cancel_before); + CHECK(panel->save_preset("/tmp/xtc-inserts-preset.xtc")==XTC_OK); + CHECK(window.add_track_effect(track,XTC_BUILTIN_PATH,3)==XTC_OK && panel->load_preset("/tmp/xtc-inserts-preset.xtc")==XTC_OK); + CHECK(xtc_project_get_chain(s->project(),track,&chain)==XTC_OK && chain.count==1); + auto *preset_before=s->project();CHECK(panel->load_preset("/missing/preset.xtc")!=XTC_OK && s->project()==preset_before); + unlink("/tmp/xtc-inserts-preset.xtc"); CHECK(window.save_project("/tmp/xtc-inserts-gui.xtc")==XTC_OK && window.new_project()==XTC_OK && window.open_project("/tmp/xtc-inserts-gui.xtc")==XTC_OK); CHECK(xtc_project_get_chain(s->project(),track,&chain)==XTC_OK && chain.count==1);unlink("/tmp/xtc-inserts-gui.xtc"); window.do_callback();puts("Track inserts GUI adoption/undo/rollback/reload passed.");return failures ? 1 : 0; diff --git a/tests/unit/test_track_fx.c b/tests/unit/test_track_fx.c index 8b38820..71b3611 100644 --- a/tests/unit/test_track_fx.c +++ b/tests/unit/test_track_fx.c @@ -59,5 +59,9 @@ int main(void) assert(xtc_project_get_chain(q,a,&got)==XTC_OK && got.output_gain==.75f); c.output_gain=1;c.slots[0].wet=2;assert(xtc_project_set_chain(q,a,&c)==XTC_ERR_INVALID_ARGUMENT); c.slots[0].wet=1;c.count=XTC_TRACK_INSERTS+1;assert(xtc_track_chain_validate(&c)==XTC_ERR_INVALID_ARGUMENT); + assert(xtc_track_chain_preset_save(&got,file)==XTC_OK); + memset(&c,0,sizeof(c));assert(xtc_track_chain_preset_load(file,&c)==XTC_OK && c.count==1 && c.output_gain==.75f); + {FILE *broken=fopen(file,"w");assert(broken);fputs("bad preset\n",broken);fclose(broken);} + assert(xtc_track_chain_preset_load(file,&c)==XTC_ERR_FORMAT && c.count==1 && c.output_gain==.75f); xtc_project_destroy(p);xtc_project_destroy(q);unlink(file);puts("Track insert metadata/persistence passed.");return 0; }