feat: consolidate trimmed MIDI clips with explicit note boundary cropping

This commit is contained in:
Johannes Findeisen 2026-11-06 00:38:13 +01:00
commit fc76bf6803
9 changed files with 68 additions and 5 deletions

View file

@ -22,3 +22,7 @@ Edit/Duplicate complete track copies the selected non-bus track adjacent to its
## Phase 62 — Nondestructive MIDI Clip Splitting
The existing Split clip at cursor command (S) now handles MIDI as well as audio. Left retains its ID; right gets fresh IDs, the same source content and an adjusted source offset. Hidden notes remain editable. Split is nondestructive, but instrument note envelopes may retrigger at the new boundary; original sine rendering retains source phase. Capacity failures preserve the project.
## Phase 63 — MIDI Clip Consolidation
Edit/Consolidate MIDI clips merges two or more selected MIDI clips on one track. The new clip spans their full bounding range; gaps and overlapping notes remain. Notes outside visible trims are discarded and crossing notes are cropped to audible boundaries. IDs are fresh. Cropped notes start anew, so envelope/phase behavior can change. Audio or cross-track selections reject atomically.

View file

@ -1572,12 +1572,12 @@ Implemented: trimmed MIDI splitting with fresh right-side clip/note IDs and comp
Merge selected MIDI clips on one track into a fresh source, retaining audible notes and gaps with explicit boundary clipping and rollback.
### Tasks and acceptance criteria
- [ ] Implement bounded C99 model/control APIs and accessible GUI commands.
- [ ] Preserve transactional errors, history, compatibility and callback safety.
- [ ] Verify the feature with automated model/GUI tests and document limitations.
- [x] Implement bounded C99 model/control APIs and accessible GUI commands.
- [x] Preserve transactional errors, history, compatibility and callback safety.
- [x] Verify the feature with automated model/GUI tests and document limitations.
### Implementation status
Planned.
Implemented: transactional consolidation of selected same-track MIDI clips into a fresh source. Visible note intersections are cropped, hidden notes dropped and gaps/overlaps retained. Core trim/timing/ID checks and GUI merge/undo tests pass.
---

View file

@ -32,6 +32,11 @@ xtc_result xtc_project_duplicate_track(xtc_project *project,uint64_t track,const
* Hidden source notes remain editable. MIDI instruments may retrigger at the new
* clip boundary; the original sine preserves source phase. Atomic. */
xtc_result xtc_project_split_midi(xtc_project *project,uint64_t clip,uint64_t tick,uint64_t *out_right);
/* Consolidate >=2 MIDI clips on one track into a fresh clip spanning their
* bounding range. Copies only audible note intersections, crops at visible
* boundaries, drops hidden notes, retains overlaps and gaps. Fresh IDs; atomic.
* Cropped notes start anew; instrument envelopes/phase can change. */
xtc_result xtc_project_merge_midi(xtc_project *project,const uint64_t *ids,size_t count,uint64_t *out);
/* MIDI transforms affect ALL source notes in selected MIDI clips, including
* notes hidden by trims. Mixed audio/MIDI selections reject; IDs and unaffected
* fields persist. Initialize the edit struct to zero and set the fields for its

View file

@ -342,6 +342,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
menu->add("Edit/Repeat selected phrase...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->repeat_dialog();},sequencer_);
menu->add("Edit/Copy clips",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->copy_selection();},sequencer_);
menu->add("Edit/Paste clips at cursor",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->paste_selection();},sequencer_);
menu->add("Edit/Consolidate MIDI clips",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->merge_midi();},sequencer_);
menu->add("Edit/Duplicate complete track...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->duplicate_track_dialog();},sequencer_);
menu->add("Edit/Time/Insert gap...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->ripple_dialog(false);},sequencer_);
menu->add("Edit/Time/Remove range...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->ripple_dialog(true);},sequencer_);

View file

@ -519,6 +519,13 @@ int SequencerWidget::handle(int event)
return Fl_Group::handle(event);
}
xtc_result SequencerWidget::merge_midi()
{
auto ids=selected_clips();uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_merge_midi(project_,ids.data(),ids.size(),&id);
if(r==XTC_OK){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);select(c.track_id,id);}
changed(r);return r;
}
xtc_result SequencerWidget::duplicate_track(const char *name)
{
const uint64_t source=track_;uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK;

View file

@ -33,6 +33,7 @@ class SequencerWidget : public Fl_Group {
Fl_Choice *instrument_choice(uint64_t track) const;
void refresh_instruments();
bool select(uint64_t track, uint64_t clip = UINT64_MAX);
xtc_result merge_midi();
xtc_result duplicate_track(const char *name);
void duplicate_track_dialog();
xtc_result ripple_time(uint64_t length,bool remove);

View file

@ -233,3 +233,29 @@ xtc_result xtc_project_split_midi(xtc_project *p,uint64_t clip,uint64_t tick,uin
if(r==XTC_OK){publish(p,copy);*out=id;}
xtc_project_destroy(copy);return r;
}
xtc_result xtc_project_merge_midi(xtc_project *p,const uint64_t *ids,size_t count,uint64_t *out)
{
xtc_project *copy=NULL;size_t i,j;uint64_t begin=XTC_PROJECT_MAX_TICK,end=0,track=UINT64_MAX,id=0;
xtc_result r=selection_valid(p,ids,count);if(r!=XTC_OK)return r;
if(count<2 || !out)return XTC_ERR_INVALID_ARGUMENT;
for(i=0;i<count;++i){xtc_clip_info c;xtc_project_find_clip(p,ids[i],&c);
if(!c.midi || (i && c.track_id!=track))return XTC_ERR_INVALID_STATE;
track=c.track_id;if(c.start_tick<begin)begin=c.start_tick;if(c.start_tick+c.length_tick>end)end=c.start_tick+c.length_tick;
}
r=xtc_project_clone(p,&copy);if(r!=XTC_OK)return r;
for(i=0;r==XTC_OK && i<count;++i)r=xtc_project_remove_clip(copy,ids[i]);
if(r==XTC_OK)r=xtc_project_add_midi_clip(copy,track,begin,end-begin,&id);
if(r==XTC_OK)r=xtc_project_rename_clip(copy,id,"Consolidated MIDI");
for(i=0;r==XTC_OK && i<p->note_count;++i) {
const xtc_note_info *n=p->notes+i;xtc_clip_info c;uint64_t a,b,note;
for(j=0;j<count;++j)if(n->clip_id==ids[j])break;
if(j==count)continue;
xtc_project_find_clip(p,n->clip_id,&c);
a=n->start_tick>c.offset_tick ? n->start_tick : c.offset_tick;
b=n->start_tick+n->length_tick<c.offset_tick+c.length_tick ? n->start_tick+n->length_tick : c.offset_tick+c.length_tick;
if(b>a)r=xtc_project_add_note(copy,id,c.start_tick-begin+a-c.offset_tick,b-a,n->pitch,n->velocity,n->channel,&note);
}
if(r==XTC_OK){publish(p,copy);*out=id;}
xtc_project_destroy(copy);return r;
}

View file

@ -1382,6 +1382,9 @@ int main(int argc, char **argv)
CHECK(s->select(0,0));s->position(1920);CHECK(s->split_selection()==XTC_OK);
xtc_project_get_counts(s->project(),&tracks,&clips);CHECK(clips==2);
CHECK(window.undo()==XTC_OK);xtc_project_get_counts(s->project(),&tracks,&clips);CHECK(clips==1);
CHECK(s->select(0,0));CHECK(s->duplicate_selection()==XTC_OK);s->select_all_clips();CHECK(s->merge_midi()==XTC_OK);
xtc_project_get_counts(s->project(),&tracks,&clips);CHECK(clips==1);
CHECK(window.undo()==XTC_OK);xtc_project_get_counts(s->project(),&tracks,&clips);CHECK(clips==2);
window.do_callback();
}
if (failures != 0) return 1;

View file

@ -95,4 +95,20 @@ static void split_midi(void)
assert(xtc_midi_plan_render(before,11800,NULL,0,a,512)==XTC_OK && xtc_midi_plan_render(after,11800,NULL,0,b,512)==XTC_OK);
assert(!memcmp(a,b,sizeof(a)));xtc_midi_plan_destroy(before);xtc_midi_plan_destroy(after);xtc_project_destroy(p);
}
int main(void){markers();sections();ripple();duplicate_track();split_midi();puts("Next DAW workflow tests passed.");return 0;}
static void merge_midi(void)
{
xtc_project *p=NULL;uint64_t t,ids[2],n,out=999;xtc_note_info note;size_t count;
assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Track",&t)==XTC_OK);
assert(xtc_project_add_midi_clip(p,t,960,960,&ids[0])==XTC_OK && xtc_project_add_midi_clip(p,t,1920,960,&ids[1])==XTC_OK);
assert(xtc_project_add_note(p,ids[0],0,240,60,90,0,&n)==XTC_OK);
assert(xtc_project_add_note(p,ids[0],600,240,64,100,1,&n)==XTC_OK);
assert(xtc_project_add_note(p,ids[1],0,480,67,110,2,&n)==XTC_OK);
assert(xtc_project_edit_clip(p,ids[0],t,960,480,240)==XTC_OK && xtc_project_edit_clip(p,ids[1],t,1920,480,240)==XTC_OK);
assert(xtc_project_merge_midi(p,ids,1,&out)==XTC_ERR_INVALID_ARGUMENT && out==999);
assert(xtc_project_merge_midi(p,ids,2,&out)==XTC_OK);
assert(xtc_project_note_count(p,&count)==XTC_OK && count==2);
assert(xtc_project_get_note(p,0,&note)==XTC_OK && note.start_tick==360 && note.length_tick==120 && note.pitch==64 && note.channel==1);
assert(xtc_project_get_note(p,1,&note)==XTC_OK && note.start_tick==960 && note.length_tick==240 && note.pitch==67);
xtc_project_destroy(p);
}
int main(void){markers();sections();ripple();duplicate_track();split_midi();merge_midi();puts("Next DAW workflow tests passed.");return 0;}