feat(project): persist bounded per-track effect chains (phase 81)

This commit is contained in:
Johannes Findeisen 2026-11-06 01:34:08 +01:00
commit 7489f86351
15 changed files with 159 additions and 12 deletions

View file

@ -574,3 +574,11 @@ test: test-sample
test-gui-sample: $(BUILD_DIR)/test_gui
XTC_TEST_SAMPLE_EDITOR_ONLY=1 xvfb-run -a -s "-screen 0 1280x900x24" ./$(BUILD_DIR)/test_gui
-include $(BUILD_DIR)/src/gui/sample_editor.d $(BUILD_DIR)/tests/unit/test_sample.d
$(BUILD_DIR)/test_track_fx: $(BUILD_DIR)/tests/unit/test_track_fx.o $(BUILD_DIR)/libxtc.a
$(CC) $(LDFLAGS) -o $@ $^ -lm -ldl
.PHONY: test-track-fx
test-track-fx: $(BUILD_DIR)/test_track_fx
./$(BUILD_DIR)/test_track_fx
test: test-track-fx
-include $(BUILD_DIR)/tests/unit/test_track_fx.d

View file

@ -1895,12 +1895,12 @@ Implemented: sequencer Create menu retains empty/audio/MIDI track creation, WAV
Add a bounded C99 per-track insert model with validation, clone/history/duplicate support and backward-compatible project persistence. Loading metadata must not load plugin binaries.
### 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: eight validated metadata-only insert slots per non-bus track, including enabled/wet/output values, format 17 persistence and reads of formats 1–16 with empty chains. Clone/history use copied state; duplicate/move/remove retain track association. Full warning-enabled core suite and dedicated metadata, persistence and invalid-value tests passed. See TRACK_INSERTS.md. Playback integration follows in phase 82.
---

16
docs/TRACK_INSERTS.md Normal file
View file

@ -0,0 +1,16 @@
# Track inserts
The sequencer's **Create** dropdown groups track creation, WAV import and
instrument loading. **View → Track inspector** opens the optional inspector;
it is hidden when the application starts to leave more room for editing.
Project format 17 stores up to eight effect slots per track, their order,
bypass, wet amount and chain output gain. Formats 1–16 read with empty chains.
Metadata load, clone and history never open executable plugin modules. Track
move, duplicate and removal retain the association with stable track IDs.
Nonempty bus chains are rejected. The model accepts mono effect specifications
for LADSPA, VST2, VST3, CLAP and internal effects; this alone does not establish
that a third-party binary is compatible with the host.
The chain API is declared in `xtc_plugin.h`. Setter errors preserve the old
chain; plugin identity and layout are checked when preparing playback.

View file

@ -31,6 +31,14 @@ typedef struct {
unsigned int tempo, point_count; /* milli-BPM, 20000..300000 */
xtc_plugin_point points[XTC_PLUGIN_POINTS];
} xtc_plugin_spec;
#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;
/* Metadata only; control thread. Empty chain has unity output. No module is
* opened here. Nonempty chains are mono effects on non-bus tracks. */
xtc_result xtc_track_chain_validate(const xtc_track_chain *chain);
xtc_result xtc_project_get_chain(const xtc_project *project,uint64_t track,xtc_track_chain *out);
xtc_result xtc_project_set_chain(xtc_project *project,uint64_t track,const xtc_track_chain *chain);
/* Copied discovery metadata, never descriptor/module pointers. Modern adapters
* currently expose the name only (zero controls). LADSPA provides
* no standardized units; unit is empty. Flags describe input control hints. */

View file

@ -7,7 +7,7 @@
extern "C" {
#endif
#define XTC_PROJECT_FORMAT_VERSION 16U
#define XTC_PROJECT_FORMAT_VERSION 17U
#define XTC_PROJECT_MAX_TRACKS 64U
#define XTC_PROJECT_MAX_CLIPS 1024U
#define XTC_PROJECT_NAME_BYTES 64U

View file

@ -502,6 +502,40 @@ static xtc_result read_project(reader *input, xtc_project *project, const char *
if(!decode_name(input->tokens[2],project->markers[i].name))return XTC_ERR_FORMAT;
}
}
for(i=0;i<project->track_count;++i)project->chains[i].output_gain=1;
if(format_version>=17U) {
READ("CHAINS",2U);NUM(1U,value);if(value!=project->track_count)return XTC_ERR_FORMAT;
for(i=0;i<project->track_count;++i) {
xtc_track_chain *c=project->chains+i;size_t k;
READ("CHAIN",4U);NUM(1U,value);if(value!=project->tracks[i].id)return XTC_ERR_FORMAT;
NUM(2U,value);if(value>XTC_TRACK_INSERTS)return XTC_ERR_FORMAT;c->count=(size_t)value;
if(!decode_float(input->tokens[3],&c->output_gain))return XTC_ERR_FORMAT;
for(j=0;j<c->count;++j) {
xtc_track_insert *slot=c->slots+j;xtc_plugin_spec *p=&slot->plugin;
READ("INSERT",9U);NUM(1U,value);if(value>1)return XTC_ERR_FORMAT;slot->enabled=(unsigned int)value;
if(!decode_float(input->tokens[2],&slot->wet))return XTC_ERR_FORMAT;
NUM(3U,value);if(value>XTC_PLUGIN_BUILTIN)return XTC_ERR_FORMAT;p->format=(xtc_plugin_format)value;
NUM(4U,value);if(value>=1024)return XTC_ERR_FORMAT;p->index=(unsigned int)value;
NUM(5U,value);if(value>UINT32_MAX)return XTC_ERR_FORMAT;p->unique_id=(unsigned int)value;
NUM(6U,value);if(value!=1)return XTC_ERR_FORMAT;p->inputs=1;
NUM(7U,value);if(value<20000 || value>300000)return XTC_ERR_FORMAT;p->tempo=(unsigned int)value;
if(!decode_name(input->tokens[8],p->label))return XTC_ERR_FORMAT;
READ("MODULE",2U);if(!decode_bytes(input->tokens[1],p->path,sizeof(p->path)))return XTC_ERR_FORMAT;
READ("IDENTITY",2U);if(strcmp(input->tokens[1],"-") && !decode_bytes(input->tokens[1],p->identifier,sizeof(p->identifier)))return XTC_ERR_FORMAT;
READ("CONTROLS",2U);NUM(1U,value);if(value>XTC_PLUGIN_CONTROLS)return XTC_ERR_FORMAT;p->control_count=(unsigned int)value;
for(k=0;k<p->control_count;++k) {
READ("CONTROL",3U);NUM(1U,value);if(value>=64)return XTC_ERR_FORMAT;p->ports[k]=(unsigned int)value;
if(!decode_float(input->tokens[2],p->values+k))return XTC_ERR_FORMAT;
}
READ("PARAMETERS",2U);NUM(1U,value);if(value>XTC_PLUGIN_POINTS)return XTC_ERR_FORMAT;p->point_count=(unsigned int)value;
for(k=0;k<p->point_count;++k) {
xtc_plugin_point *a=p->points+k;READ("PARAM",5U);NUM(1U,a->tick);NUM(2U,value);if(value>=64)return XTC_ERR_FORMAT;a->port=(unsigned int)value;
NUM(3U,value);if(value>1)return XTC_ERR_FORMAT;a->step=(unsigned int)value;
if(!decode_float(input->tokens[4],&a->value))return XTC_ERR_FORMAT;
}
}
}
}
READ("END", 1U);
if (fgetc(input->file) != EOF)
return XTC_ERR_FORMAT;
@ -681,6 +715,22 @@ static xtc_result write_project(FILE *file, const xtc_project *project, const ch
}
if(fprintf(file,"MARKERS %zu\n",project->marker_count)<0)return XTC_ERR_IO;
for(i=0;i<project->marker_count;++i)if(fprintf(file,"MARKER %" PRIu64 " ",project->markers[i].tick)<0 || !write_name(file,project->markers[i].name))return XTC_ERR_IO;
if(fprintf(file,"CHAINS %zu\n",project->track_count)<0)return XTC_ERR_IO;
for(i=0;i<project->track_count;++i) {
const xtc_track_chain *c=project->chains+i;size_t j,k;uint32_t bits;
memcpy(&bits,&c->output_gain,4);
if(fprintf(file,"CHAIN %" PRIu64 " %zu %08" PRIx32 "\n",project->tracks[i].id,c->count,bits)<0)return XTC_ERR_IO;
for(j=0;j<c->count;++j) {
const xtc_track_insert *slot=c->slots+j;const xtc_plugin_spec *p=&slot->plugin;memcpy(&bits,&slot->wet,4);
if(fprintf(file,"INSERT %u %08" PRIx32 " %u %u %u %u %u ",slot->enabled,bits,(unsigned int)p->format,p->index,p->unique_id,p->inputs,p->tempo)<0 || !write_name(file,p->label))return XTC_ERR_IO;
if(fputs("MODULE ",file)==EOF || !write_name(file,p->path) || fputs("IDENTITY ",file)==EOF)return XTC_ERR_IO;
if(p->identifier[0]) {if(!write_name(file,p->identifier))return XTC_ERR_IO;}else if(fputs("-\n",file)==EOF)return XTC_ERR_IO;
if(fprintf(file,"CONTROLS %u\n",p->control_count)<0)return XTC_ERR_IO;
for(k=0;k<p->control_count;++k) {memcpy(&bits,p->values+k,4);if(fprintf(file,"CONTROL %u %08" PRIx32 "\n",p->ports[k],bits)<0)return XTC_ERR_IO;}
if(fprintf(file,"PARAMETERS %u\n",p->point_count)<0)return XTC_ERR_IO;
for(k=0;k<p->point_count;++k) {const xtc_plugin_point *a=p->points+k;memcpy(&bits,&a->value,4);if(fprintf(file,"PARAM %" PRIu64 " %u %u %08" PRIx32 "\n",a->tick,a->port,a->step,bits)<0)return XTC_ERR_IO;}
}
}
return fputs("END\n", file) == EOF ? XTC_ERR_IO : XTC_OK;
}

View file

@ -223,7 +223,7 @@ xtc_result xtc_project_duplicate_track(xtc_project *p,uint64_t track,const char
if(p->tracks[index].is_bus)return XTC_ERR_INVALID_STATE;
r=xtc_project_clone(p,&copy);if(r!=XTC_OK)return r;
r=xtc_project_add_track(copy,name,&id);
if(r==XTC_OK){xtc_track_info *target=copy->tracks+copy->track_count-1;*target=p->tracks[index];target->id=id;memcpy(target->name,name,strlen(name)+1);copy->instruments[copy->track_count-1]=p->instruments[index];}
if(r==XTC_OK){xtc_track_info *target=copy->tracks+copy->track_count-1;*target=p->tracks[index];target->id=id;memcpy(target->name,name,strlen(name)+1);copy->instruments[copy->track_count-1]=p->instruments[index];copy->chains[copy->track_count-1]=p->chains[index];}
for(i=0;r==XTC_OK && i<p->clip_count;++i)if(p->clips[i].track_id==track){uint64_t clip;r=xtc_project_duplicate_clip(copy,p->clips[i].id,id,p->clips[i].start_tick,&clip);}
for(i=0;r==XTC_OK && i<p->automation_count;++i)if(p->automation[i].track_id==track){xtc_automation_point point=p->automation[i];point.track_id=id;r=xtc_project_set_automation(copy,&point);}
if(r==XTC_OK)r=xtc_project_move_track(copy,id,index+1);

View file

@ -306,6 +306,8 @@ xtc_result xtc_project_add_track(xtc_project *project, const char *name, uint64_
track.gain = 1.0f;
track.bus_id = UINT64_MAX;
memcpy(track.name, name, strlen(name) + 1U);
memset(project->chains+project->track_count,0,sizeof(*project->chains));
project->chains[project->track_count].output_gain=1;
project->tracks[project->track_count++] = track;
*id = track.id;
return XTC_OK;
@ -333,6 +335,9 @@ xtc_result xtc_project_remove_track(xtc_project *project, uint64_t id)
for (i = 0; i < project->track_count; ++i)
if (project->tracks[i].bus_id == id) project->tracks[i].bus_id = UINT64_MAX;
if (project->live_instrument == id) project->live_instrument = UINT64_MAX;
memmove(project->chains+index,project->chains+index+1,
(project->track_count-index-1)*sizeof(*project->chains));
memset(project->chains+project->track_count-1,0,sizeof(*project->chains));
memmove(project->instruments+index,project->instruments+index+1,
(project->track_count-index-1)*sizeof(*project->instruments));
memset(project->instruments+project->track_count-1,0,sizeof(*project->instruments));
@ -544,6 +549,8 @@ xtc_result xtc_project_validate(const xtc_project *project)
if (target == SIZE_MAX || !project->instruments[target].instrument) return XTC_ERR_FORMAT;
}
for (i = 0U; i < project->track_count; ++i) {
if(xtc_track_chain_validate(project->chains+i)!=XTC_OK ||
(project->tracks[i].is_bus && project->chains[i].count))return XTC_ERR_FORMAT;
if (project->instruments[i].instrument && (project->tracks[i].is_bus ||
xtc_plugin_spec_validate(project->instruments+i) != XTC_OK)) return XTC_ERR_FORMAT;
if (project->tracks[i].id >= project->next_track_id || !xtc_project_valid_name(project->tracks[i].name))
@ -689,6 +696,10 @@ xtc_result xtc_project_move_track(xtc_project *p, uint64_t id, size_t destinatio
size_t i; xtc_track_info track;
if (!p || destination >= p->track_count) return XTC_ERR_INVALID_ARGUMENT;
i = track_index(p,id); if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND;
{ xtc_track_chain chain=p->chains[i];
if(i<destination)memmove(p->chains+i,p->chains+i+1,(destination-i)*sizeof(chain));
else if(i>destination)memmove(p->chains+destination+1,p->chains+destination,(i-destination)*sizeof(chain));
p->chains[destination]=chain; }
{ xtc_plugin_spec instrument = p->instruments[i];
if (i < destination) memmove(p->instruments+i,p->instruments+i+1,(destination-i)*sizeof(instrument));
else if (i > destination) memmove(p->instruments+destination+1,p->instruments+destination,(i-destination)*sizeof(instrument));
@ -772,3 +783,28 @@ xtc_result xtc_project_set_live_instrument(xtc_project *p, uint64_t track)
}
xtc_result xtc_project_get_live_instrument(const xtc_project *p, uint64_t *track)
{ if (!p || !track) return XTC_ERR_INVALID_ARGUMENT; *track = p->live_instrument; return XTC_OK; }
xtc_result xtc_track_chain_validate(const xtc_track_chain *c)
{
size_t i;
if(!c || c->count>XTC_TRACK_INSERTS || !isfinite(c->output_gain) || c->output_gain<0 || c->output_gain>4)return XTC_ERR_INVALID_ARGUMENT;
for(i=0;i<c->count;++i) {
const xtc_track_insert *s=c->slots+i;
if(s->enabled>1 || !isfinite(s->wet) || s->wet<0 || s->wet>1 || s->plugin.instrument ||
s->plugin.inputs!=1 || s->plugin.sample_path[0] || xtc_plugin_spec_validate(&s->plugin)!=XTC_OK)return XTC_ERR_INVALID_ARGUMENT;
}
return XTC_OK;
}
xtc_result xtc_project_get_chain(const xtc_project *p,uint64_t track,xtc_track_chain *out)
{
size_t i;if(!p || !out)return XTC_ERR_INVALID_ARGUMENT;
i=track_index(p,track);if(i==SIZE_MAX)return XTC_ERR_NOT_FOUND;
*out=p->chains[i];return XTC_OK;
}
xtc_result xtc_project_set_chain(xtc_project *p,uint64_t track,const xtc_track_chain *c)
{
size_t i;if(!p || xtc_track_chain_validate(c)!=XTC_OK)return XTC_ERR_INVALID_ARGUMENT;
i=track_index(p,track);if(i==SIZE_MAX)return XTC_ERR_NOT_FOUND;
if(p->tracks[i].is_bus && c->count)return XTC_ERR_UNSUPPORTED;
p->chains[i]=*c;return XTC_OK;
}

View file

@ -12,6 +12,7 @@ struct xtc_project {
xtc_marker markers[XTC_PROJECT_MAX_MARKERS];
uint64_t live_instrument;
xtc_plugin_spec instruments[XTC_PROJECT_MAX_TRACKS];
xtc_track_chain chains[XTC_PROJECT_MAX_TRACKS];
uint64_t next_track_id, next_clip_id;
unsigned int tempo;
unsigned char control_map[16][3];

View file

@ -56,7 +56,7 @@ def main():
run([str(binaries / 'xtc-check'), '--inspect', str(resources / 'xtc/examples/modern-session.xtc')])
assert (docs / 'WORKSPACE.md').is_file()
diagnose = subprocess.check_output([str(binaries / 'xtc-check'), '--diagnose'], text=True)
assert 'Project formats: 1..16' in diagnose and 'JACK compiled:' in diagnose
assert 'Project formats: 1..17' in diagnose and 'JACK compiled:' in diagnose
# Replacing a file symlink must not overwrite an unrelated target.
protected = stage / 'unrelated-target'
protected.write_text('keep unrelated target\n')

View file

@ -84,7 +84,7 @@ with tempfile.TemporaryDirectory(prefix="xtc-inspect-") as directory:
project.write_text(malformed)
check("--validate", project, 1)
for text, messages in [
(base.replace("XTC_PROJECT 10", "XTC_PROJECT 999"), ("line 1", "999", "1..16")),
(base.replace("XTC_PROJECT 10", "XTC_PROJECT 999"), ("line 1", "999", "1..17")),
("XTC_PROJECT 10\n", ("line 2", "APP")),
(base.replace("TEMPO 120000", "TEMPO 0"), ("line 3", "TEMPO")),
(base + "OPTIONAL_FUTURE 1\n", ("Unknown fields",)),

View file

@ -167,8 +167,8 @@ static void editable(void)
assert(xtc_plugin_preset_save(&legacy,path)==XTC_OK);
{FILE *f=fopen(path,"r");char lines[32768],line[2048];size_t used=0;assert(f);
while(fgets(line,sizeof(line),f)) {
if(!strncmp(line,"MARKERS ",8) || !strncmp(line,"SAMPLE ",7) || !strncmp(line,"INSTRUMENT_CONTROLS",19) || !strncmp(line,"INSTRUMENT_AUTOMATION",21))continue;
if(!strncmp(line,"XTC_PROJECT 16",14))strcpy(line,"XTC_PROJECT 13\n");
if(!strncmp(line,"CHAINS ",7) || !strncmp(line,"CHAIN ",6) || !strncmp(line,"MARKERS ",8) || !strncmp(line,"SAMPLE ",7) || !strncmp(line,"INSTRUMENT_CONTROLS",19) || !strncmp(line,"INSTRUMENT_AUTOMATION",21))continue;
if(!strncmp(line,"XTC_PROJECT 17",14))strcpy(line,"XTC_PROJECT 13\n");
assert(used+strlen(line)<sizeof(lines));strcpy(lines+used,line);used+=strlen(line);
}fclose(f);f=fopen(path,"w");assert(f && fwrite(lines,1,used,f)==used);fclose(f);}
assert(xtc_plugin_preset_load(path,&t)==XTC_OK && !t.control_count && t.index==0);

View file

@ -197,7 +197,7 @@ int main(void)
fputc('\n',legacy); continue;
}
if (strncmp(line, "MIX ", 4) && strncmp(line, "AUTOMATION ", 11) &&
strncmp(line, "POINT ", 6) && strncmp(line, "AUDIO ", 6) && strncmp(line, "ROUTE ", 6) && strncmp(line,"CONTROL_MAPS ",13) && strncmp(line,"MAP ",4) && strncmp(line,"TRACK_INSTRUMENTS ",18) && strncmp(line,"LIVE_INSTRUMENT ",16) && strncmp(line,"MARKERS ",8) && strncmp(line,"MARKER ",7))
strncmp(line, "POINT ", 6) && strncmp(line, "AUDIO ", 6) && strncmp(line, "ROUTE ", 6) && strncmp(line,"CONTROL_MAPS ",13) && strncmp(line,"MAP ",4) && strncmp(line,"TRACK_INSTRUMENTS ",18) && strncmp(line,"LIVE_INSTRUMENT ",16) && strncmp(line,"CHAINS ",7) && strncmp(line,"CHAIN ",6) && strncmp(line,"MARKERS ",8) && strncmp(line,"MARKER ",7))
assert(fputs(line, legacy) >= 0);
}
assert(fclose(f) == 0 && fclose(legacy) == 0);

View file

@ -81,7 +81,7 @@ int main(void)
}
write_text(bad,"XTC_PROJECT 999\n");
assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_UNSUPPORTED && diagnostic.line == 1 &&
diagnostic.format_version == 999 && !strcmp(diagnostic.record,"XTC_PROJECT") && strstr(diagnostic.message,"1..16"));
diagnostic.format_version == 999 && !strcmp(diagnostic.record,"XTC_PROJECT") && strstr(diagnostic.message,"1..17"));
write_text(bad,"XTC_PROJECT 10\nAPP 0 1 0\nTEMPO nope\n");
assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_FORMAT && diagnostic.line == 3 &&
!strcmp(diagnostic.record,"TEMPO"));

View file

@ -0,0 +1,28 @@
#define _POSIX_C_SOURCE 200809L
#include "xtc/xtc_plugin.h"
#include "xtc/xtc_editing.h"
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
xtc_project *p=NULL,*q=NULL;uint64_t a,b,duplicate;xtc_track_chain c={0},got={0};
char file[]="/tmp/xtc-chain-XXXXXX";int fd=mkstemp(file);assert(fd>=0);close(fd);
assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Audio",&a)==XTC_OK && xtc_project_add_track(p,"Other",&b)==XTC_OK);
assert(xtc_project_get_chain(p,a,&got)==XTC_OK && got.count==0 && got.output_gain==1);
assert(xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,XTC_BUILTIN_PATH,2,48000,&c.slots[0].plugin)==XTC_OK);
c.count=1;c.output_gain=.75f;c.slots[0].enabled=1;c.slots[0].wet=.5f;
assert(xtc_project_set_chain(p,a,&c)==XTC_OK);
assert(xtc_project_save(p,file)==XTC_OK && xtc_project_create(&q)==XTC_OK && xtc_project_load(q,file)==XTC_OK);
assert(xtc_project_get_chain(q,a,&got)==XTC_OK && got.count==1 && got.output_gain==.75f && got.slots[0].wet==.5f && !strcmp(got.slots[0].plugin.label,c.slots[0].plugin.label));
assert(xtc_project_duplicate_track(q,a,"Copy",&duplicate)==XTC_OK && xtc_project_get_chain(q,duplicate,&got)==XTC_OK && got.count==1);
assert(xtc_project_move_track(q,a,2)==XTC_OK && xtc_project_remove_track(q,b)==XTC_OK && xtc_project_get_chain(q,a,&got)==XTC_OK && got.count==1);
c.output_gain=NAN;assert(xtc_project_set_chain(q,a,&c)==XTC_ERR_INVALID_ARGUMENT);
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);
xtc_project_destroy(p);xtc_project_destroy(q);unlink(file);puts("Track insert metadata/persistence passed.");return 0;
}