diff --git a/docs/COMPOSITION_WORKFLOWS.md b/docs/COMPOSITION_WORKFLOWS.md index 405c1fe..fe97ac9 100644 --- a/docs/COMPOSITION_WORKFLOWS.md +++ b/docs/COMPOSITION_WORKFLOWS.md @@ -18,3 +18,7 @@ Edit/Time inserts a gap or removes a tick range beginning at the cursor. Clips c ## Phase 61 — Complete Track Duplication Edit/Duplicate complete track copies the selected non-bus track adjacent to its source. Clips, notes, instrument settings, static mix, bus routing and mixer automation are independent model copies with fresh IDs; WAV references remain shared. The modular graph and live-input target are unchanged. Bus duplication is rejected. Capacity errors leave the original document unchanged. + +## 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0f52bd1..3db7e6b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1554,15 +1554,15 @@ Implemented: complete non-bus track duplication with fresh IDs, adjacent orderin ## Phase 62 — Nondestructive MIDI Clip Splitting ### Goal -Split trimmed MIDI clips at the cursor while sharing source-relative note content and preserving exact rendered boundaries. +Split trimmed MIDI clips at the cursor while preserving source-relative note content and verifying boundary scheduling. ### 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: trimmed MIDI splitting with fresh right-side clip/note IDs and complete retained source notes. Original sine rendering is identical across the split in regression tests; MIDI instruments may retrigger at boundaries. Core range/render and GUI split/undo tests pass. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index 3b2c562..ca1f324 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -27,6 +27,11 @@ xtc_result xtc_project_ripple_time(xtc_project *project,uint64_t begin,uint64_t * instrument settings, mix, bus route and mixer automation; inserts adjacent to * source. Shared modular graph and live-input target are unchanged. Atomic. */ xtc_result xtc_project_duplicate_track(xtc_project *project,uint64_t track,const char *name,uint64_t *out); +/* MIDI split strictly inside the visible clip. Keeps left ID; right gets fresh + * clip/note IDs and copied complete source notes with an adjusted source offset. + * 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); /* 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 2c51625..a657c38 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -349,7 +349,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) menu->add("Edit/Markers/Remove at cursor",0,[](Fl_Widget *,void *c){static_cast(c)->marker(nullptr);},sequencer_); menu->add("Edit/Select all clips",0,[](Fl_Widget *,void *c){static_cast(c)->select_all_clips();},sequencer_); menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast(c)->duplicate_selection(); },sequencer_); - menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->split_selection(); }, sequencer_); + menu->add("Edit/Split clip at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->split_selection(); }, sequencer_); menu->add("Edit/Audio clip gain and fades", 0, [](Fl_Widget *, void *c) { static_cast(c)->audio_properties_dialog(); }, sequencer_); menu->add("Edit/Rename clip or track",FL_F+2,[](Fl_Widget *, void *c) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) (void)static_cast(c)->rename_selection(name); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 4882c89..7eec663 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -435,7 +435,7 @@ int SequencerWidget::handle(int event) mode_ = 0; Fl_Menu_Item items[6]{}; items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)"; - items[3].text = "Split audio at cursor (S)"; items[4].text = "Audio gain and fades (A)"; + items[3].text = "Split clip at cursor (S)"; items[4].text = "Audio gain and fades (A)"; const Fl_Menu_Item *picked = items->popup(Fl::event_x(),Fl::event_y()); if (picked == items) duplicate_selection(); else if (picked == items+1) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); } @@ -703,7 +703,10 @@ xtc_result SequencerWidget::split_selection() uint64_t right; const uint64_t split_tick = cursor_; // Stop/status refresh may move the playhead. xtc_result result = before_edit ? before_edit(context_) : XTC_OK; - if (result == XTC_OK) result = xtc_project_split_audio(project_, clip_, split_tick, &right); + if (result == XTC_OK) { + xtc_clip_info c{};result=xtc_project_find_clip(project_,clip_,&c); + if(result==XTC_OK)result=c.midi ? xtc_project_split_midi(project_,clip_,split_tick,&right) : xtc_project_split_audio(project_,clip_,split_tick,&right); + } if (result == XTC_OK) {clip_ = right;selection_.clear();} changed(result); return result; diff --git a/src/project/editing.c b/src/project/editing.c index 3641990..f49e5eb 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -219,3 +219,17 @@ xtc_result xtc_project_duplicate_track(xtc_project *p,uint64_t track,const char if(r==XTC_OK){publish(p,copy);*out=id;} xtc_project_destroy(copy);return r; } + +xtc_result xtc_project_split_midi(xtc_project *p,uint64_t clip,uint64_t tick,uint64_t *out) +{ + xtc_clip_info c;xtc_project *copy=NULL;uint64_t id=0,left;xtc_result r; + if(!p || !out)return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_find_clip(p,clip,&c);if(r!=XTC_OK)return r; + if(!c.midi || tick<=c.start_tick || tick>=c.start_tick+c.length_tick)return XTC_ERR_INVALID_ARGUMENT; + left=tick-c.start_tick;r=xtc_project_clone(p,©);if(r!=XTC_OK)return r; + r=xtc_project_duplicate_clip(copy,clip,c.track_id,c.start_tick,&id); + if(r==XTC_OK)r=xtc_project_edit_clip(copy,id,c.track_id,tick,c.length_tick-left,c.offset_tick+left); + if(r==XTC_OK)r=xtc_project_edit_clip(copy,clip,c.track_id,c.start_tick,left,c.offset_tick); + 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 5d3902e..0bd5a7d 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1379,6 +1379,9 @@ int main(int argc, char **argv) CHECK(s->select(0));CHECK(s->duplicate_track("Keys copy")==XTC_OK); size_t tracks=0,clips=0;xtc_project_get_counts(s->project(),&tracks,&clips);CHECK(tracks==2 && clips==2); CHECK(window.undo()==XTC_OK);xtc_project_get_counts(s->project(),&tracks,&clips);CHECK(tracks==1 && clips==1); + 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); 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 3f606fe..79bb60b 100644 --- a/tests/unit/test_next_daw.c +++ b/tests/unit/test_next_daw.c @@ -82,4 +82,17 @@ static void duplicate_track(void) assert(xtc_project_automation_count(p,&count)==XTC_OK && count==2); xtc_project_destroy(p); } -int main(void){markers();sections();ripple();duplicate_track();puts("Next DAW workflow tests passed.");return 0;} +static void split_midi(void) +{ + xtc_project *p=NULL;xtc_midi_plan *before=NULL,*after=NULL;uint64_t t,c,n,right=999;xtc_clip_info slice;float a[512],b[512]; + assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Track",&t)==XTC_OK); + assert(xtc_project_add_midi_clip(p,t,0,960,&c)==XTC_OK && xtc_project_add_note(p,c,0,960,60,90,0,&n)==XTC_OK); + assert(xtc_midi_plan_create(p,48000,&before)==XTC_OK); + assert(xtc_project_split_midi(p,c,0,&right)==XTC_ERR_INVALID_ARGUMENT && right==999); + assert(xtc_project_split_midi(p,c,480,&right)==XTC_OK && right!=c); + assert(xtc_project_find_clip(p,right,&slice)==XTC_OK && slice.offset_tick==480 && slice.length_tick==480); + assert(xtc_midi_plan_create(p,48000,&after)==XTC_OK); + 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;}