diff --git a/docs/COMPOSITION_WORKFLOWS.md b/docs/COMPOSITION_WORKFLOWS.md index fe97ac9..6d00501 100644 --- a/docs/COMPOSITION_WORKFLOWS.md +++ b/docs/COMPOSITION_WORKFLOWS.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3db7e6b..62858aa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index ca1f324..29c33c6 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -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 diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index a657c38..feef0f7 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -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(c)->repeat_dialog();},sequencer_); menu->add("Edit/Copy clips",0,[](Fl_Widget *,void *c){static_cast(c)->copy_selection();},sequencer_); menu->add("Edit/Paste clips at cursor",0,[](Fl_Widget *,void *c){static_cast(c)->paste_selection();},sequencer_); + menu->add("Edit/Consolidate MIDI clips",0,[](Fl_Widget *,void *c){static_cast(c)->merge_midi();},sequencer_); menu->add("Edit/Duplicate complete track...",0,[](Fl_Widget *,void *c){static_cast(c)->duplicate_track_dialog();},sequencer_); menu->add("Edit/Time/Insert gap...",0,[](Fl_Widget *,void *c){static_cast(c)->ripple_dialog(false);},sequencer_); menu->add("Edit/Time/Remove range...",0,[](Fl_Widget *,void *c){static_cast(c)->ripple_dialog(true);},sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 7eec663..163fca3 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -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; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 3d11426..0b5db42 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -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); diff --git a/src/project/editing.c b/src/project/editing.c index f49e5eb..faf52f7 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -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;iend)end=c.start_tick+c.length_tick; + } + r=xtc_project_clone(p,©);if(r!=XTC_OK)return r; + for(i=0;r==XTC_OK && inote_count;++i) { + const xtc_note_info *n=p->notes+i;xtc_clip_info c;uint64_t a,b,note; + for(j=0;jclip_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_tickstart_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,¬e); + } + if(r==XTC_OK){publish(p,copy);*out=id;} + xtc_project_destroy(copy);return r; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 0bd5a7d..565afd7 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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; diff --git a/tests/unit/test_next_daw.c b/tests/unit/test_next_daw.c index 79bb60b..924654d 100644 --- a/tests/unit/test_next_daw.c +++ b/tests/unit/test_next_daw.c @@ -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,¬e)==XTC_OK && note.start_tick==360 && note.length_tick==120 && note.pitch==64 && note.channel==1); + assert(xtc_project_get_note(p,1,¬e)==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;}