diff --git a/docs/MODERN_EDITING.md b/docs/MODERN_EDITING.md index feaa8c7..d365642 100644 --- a/docs/MODERN_EDITING.md +++ b/docs/MODERN_EDITING.md @@ -23,3 +23,7 @@ Edit/Repeat selected phrase asks for 1..1024 additional copies. The bounding spa ## Phase 51 Edit/Align clip starts to cursor places every selected start at the cursor. Slip sources earlier/later changes source offsets by the snap step while retaining timeline positions and durations. Trimming must leave source material available; an invalid slip rejects the whole selection. No time stretching or destructive media edits are performed. + +## Phase 52 + +Select MIDI clips and use Edit/MIDI tools/Swing quantize. Grid is in source ticks; swing delays alternate subdivisions by 0..half a grid step, while strength blends the old start toward the nearest swung point. Durations stay fixed. All source notes are affected, including notes outside visible trims. Mixed audio/MIDI selections and notes that would exceed source bounds reject the entire edit. This is destructive note editing with undo, not a live groove engine. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8c1c2b3..82212e1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1393,12 +1393,12 @@ Implemented: selection-wide start alignment and source slip commands reuse atomi Apply bounded swing and quantize strength to source note starts with deterministic rounding and source-bound checks. ### Tasks and acceptance criteria -- [ ] Provide the control/model behavior and accessible GUI commands. -- [ ] Validate bounds and failure rollback; preserve existing project compatibility and callback safety. -- [ ] Test the new workflow and document scope and limitations. +- [x] Provide the control/model behavior and accessible GUI commands. +- [x] Validate bounds and failure rollback; preserve existing project compatibility and callback safety. +- [x] Test the new workflow and document scope and limitations. ### Implementation status -Planned. +Implemented: deterministic nearest swung-grid quantization with 0..100 strength and source-range rollback through a C99 API and MIDI tools dialog. Core timing, strength and late-note rollback tests plus focused GUI/piano refresh tests pass. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index 64005e4..74301ef 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -15,6 +15,19 @@ extern "C" { typedef enum { XTC_CLIPS_MOVE, XTC_CLIPS_DUPLICATE, XTC_CLIPS_DELETE, XTC_CLIPS_ALIGN, XTC_CLIPS_SLIP } xtc_clip_operation; xtc_result xtc_project_transform_clips(xtc_project *project,const uint64_t *ids,size_t count, xtc_clip_operation operation,int64_t amount,uint64_t *out_ids); +/* 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. No matching notes returns NOT_FOUND. Edits are atomic. + * Swing: grid 1..3840, swing 0..100 delays alternate grid points by up to half + * a grid; strength 0..100 blends toward the nearest swung point (ties later). + * Durations stay fixed. Out-of-source results reject the complete operation. */ +typedef enum { XTC_MIDI_TRANSFORM_SWING } xtc_midi_transform_type; +typedef struct { + xtc_midi_transform_type type; + uint64_t grid; + unsigned int swing,strength; +} xtc_midi_transform; +xtc_result xtc_project_transform_midi(xtc_project *project,const uint64_t *ids,size_t count,const xtc_midi_transform *edit); /* Repeat the bounding phrase span, including gaps, 1..1024 additional copies. * Originals remain selected by the caller; all copies publish together. */ xtc_result xtc_project_repeat_clips(xtc_project *project,const uint64_t *ids,size_t count,unsigned int copies); diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 0513a61..af8c2be 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -322,6 +322,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) Fl_Group::current(parent); menu->add("Edit/Nudge selection left",0,[](Fl_Widget *,void *c){static_cast(c)->nudge_selection(-1);},sequencer_); menu->add("Edit/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast(c)->nudge_selection(1);},sequencer_); + menu->add("Edit/MIDI tools/Swing quantize...",0,[](Fl_Widget *,void *c){static_cast(c)->swing_dialog();},sequencer_); menu->add("Edit/Align clip starts to cursor",0,[](Fl_Widget *,void *c){auto *s=static_cast(c);s->transform_selection(XTC_CLIPS_ALIGN,static_cast(s->position()));},sequencer_); menu->add("Edit/Slip sources earlier",0,[](Fl_Widget *,void *c){auto *s=static_cast(c);s->transform_selection(XTC_CLIPS_SLIP,-static_cast(s->grid()));},sequencer_); menu->add("Edit/Slip sources later",0,[](Fl_Widget *,void *c){auto *s=static_cast(c);s->transform_selection(XTC_CLIPS_SLIP,static_cast(s->grid()));},sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 1565750..9c6cbb8 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -1,3 +1,4 @@ +#include #include "sequencer_widget.hpp" #include "theme.hpp" #include "snap.hpp" @@ -555,6 +556,20 @@ xtc_result SequencerWidget::transform_selection(xtc_clip_operation op,int64_t am } changed(r);return r; } +xtc_result SequencerWidget::transform_midi(const xtc_midi_transform &edit) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_transform_midi(project_,ids.data(),ids.size(),&edit); + changed(r);return r; +} +void SequencerWidget::swing_dialog() +{ + const char *input=fl_input("Grid ticks (1..3840), swing (0..100), strength (0..100)","240 50 100");if(!input)return; + int grid=0,swing=-1,strength=-1;char extra; + std::istringstream values(input); + if(!(values>>grid>>swing>>strength) || (values>>extra) || grid<1 || grid>3840 || swing<0 || swing>100 || strength<0 || strength>100){fl_alert("Enter a valid grid, swing and strength.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SWING;edit.grid=static_cast(grid);edit.swing=static_cast(swing);edit.strength=static_cast(strength);transform_midi(edit); +} xtc_result SequencerWidget::repeat_selection(unsigned int copies) { auto ids=selected_clips();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 121b366..8737b3f 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -38,6 +38,8 @@ class SequencerWidget : public Fl_Group { bool clip_selected(uint64_t clip) const; std::vector selected_clips() const; xtc_result transform_selection(xtc_clip_operation operation,int64_t amount); + xtc_result transform_midi(const xtc_midi_transform &edit); + void swing_dialog(); xtc_result repeat_selection(unsigned int copies); void repeat_dialog(); xtc_result copy_selection(); diff --git a/src/project/editing.c b/src/project/editing.c index 7a8ce15..6c4244f 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -115,3 +115,36 @@ xtc_result xtc_project_repeat_clips(xtc_project *p,const uint64_t *ids,size_t co if(r==XTC_OK)publish(p,copy); xtc_project_destroy(copy);return r; } + +static uint64_t swung_start(uint64_t start,const xtc_midi_transform *edit) +{ + uint64_t base=start/edit->grid,best=0,distance=UINT64_MAX;int offset; + for(offset=-2;offset<=2;++offset) { + int64_t index=(int64_t)base+offset;uint64_t candidate,d; + if(index<0)continue; + candidate=(uint64_t)index*edit->grid; + if(index%2)candidate+=edit->grid*edit->swing/200; + d=start>candidate ? start-candidate : candidate-start; + if(dbest)){distance=d;best=candidate;} + } + return (start*(100-edit->strength)+best*edit->strength+50)/100; +} +xtc_result xtc_project_transform_midi(xtc_project *p,const uint64_t *ids,size_t count,const xtc_midi_transform *edit) +{ + xtc_project *copy=NULL;size_t i,j,matched=0;xtc_result r=selection_valid(p,ids,count); + if(r!=XTC_OK)return r; + if(!edit || edit->type!=XTC_MIDI_TRANSFORM_SWING || !edit->grid || edit->grid>3840 || edit->swing>100 || edit->strength>100)return XTC_ERR_INVALID_ARGUMENT; + for(i=0;inote_count;++i) { + xtc_note_info *n=copy->notes+i;xtc_clip_info c; + for(j=0;jclip_id==ids[j])break; + if(j==count)continue; + ++matched;xtc_project_find_clip(copy,n->clip_id,&c); + n->start_tick=swung_start(n->start_tick,edit); + if(n->start_tick>=c.source_length_tick || n->length_tick>c.source_length_tick-n->start_tick)r=XTC_ERR_INVALID_ARGUMENT; + } + if(r==XTC_OK && !matched)r=XTC_ERR_NOT_FOUND; + if(r==XTC_OK)publish(p,copy); + xtc_project_destroy(copy);return r; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 3657de4..93008b5 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1331,6 +1331,11 @@ int main(int argc, char **argv) s->select_all_clips();CHECK(s->transform_selection(XTC_CLIPS_ALIGN,3840)==XTC_OK); CHECK(xtc_project_find_clip(s->project(),first,&moved)==XTC_OK && moved.start_tick==3840); CHECK(s->transform_selection(XTC_CLIPS_SLIP,0)==XTC_OK); + CHECK(s->select(s->selected_track(),first));CHECK(window.open_piano_roll()==XTC_OK); + CHECK(window.piano_roll()->add_note(230,120,61)==XTC_OK); + xtc_midi_transform edit{};edit.grid=240;edit.swing=50;edit.strength=100; + CHECK(s->transform_midi(edit)==XTC_OK);xtc_note_info quantized{}; + CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.start_tick==300); window.do_callback(); } if (failures != 0) return 1; diff --git a/tests/unit/test_modern_editing.c b/tests/unit/test_modern_editing.c index c409514..5e56589 100644 --- a/tests/unit/test_modern_editing.c +++ b/tests/unit/test_modern_editing.c @@ -2,6 +2,25 @@ #include #include +static void test_swing(void) +{ + xtc_project *p=NULL;uint64_t t,c,n;xtc_note_info note;xtc_midi_transform edit={0}; + 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,1920,&c)==XTC_OK); + assert(xtc_project_add_note(p,c,230,120,60,100,0,&n)==XTC_OK); + edit.grid=240;edit.swing=50;edit.strength=100; + assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_OK); + assert(xtc_project_get_note(p,0,¬e)==XTC_OK && note.start_tick==300 && note.id==n && note.length_tick==120); + edit.strength=50;edit.swing=0; + assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_OK); + assert(xtc_project_get_note(p,0,¬e)==XTC_OK && note.start_tick==270); + assert(xtc_project_add_note(p,c,1850,70,64,100,0,&n)==XTC_OK); + edit.strength=100; + assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_get_note(p,0,¬e)==XTC_OK && note.start_tick==270); + edit.grid=0;assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_ERR_INVALID_ARGUMENT); + xtc_project_destroy(p); +} static void test_align_slip(void) { xtc_project *p=NULL;uint64_t t,ids[2];xtc_clip_info c; @@ -35,7 +54,7 @@ static void test_repeat(void) } int main(void) { - test_repeat();test_align_slip(); + test_repeat();test_align_slip();test_swing(); xtc_project *p=NULL;uint64_t track,a,b,n,ids[2],out[2]={999,999};xtc_clip_info c; size_t tracks,clips,notes; assert(xtc_project_create(&p)==XTC_OK);