feat(presets): save and adopt reusable effect chains (phase 87)

This commit is contained in:
Johannes Findeisen 2026-11-06 01:40:13 +01:00
commit b6d0771b4f
8 changed files with 67 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<TrackInsertsPanel *>(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<TrackInsertsPanel *>(c)->preset(true);},this);
presets->add("Load chain...",0,[](Fl_Widget *,void *c){static_cast<TrackInsertsPanel *>(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;

View file

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

View file

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

View file

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