diff --git a/docs/MODERN_EDITING.md b/docs/MODERN_EDITING.md index 502a555..14346e3 100644 --- a/docs/MODERN_EDITING.md +++ b/docs/MODERN_EDITING.md @@ -31,3 +31,7 @@ Select MIDI clips and use Edit/MIDI tools/Swing quantize. Grid is in source tick ## Phase 53 Edit/MIDI tools/Note gate scales lengths from 1 to 400 percent, rounded to at least one tick. Legato ends notes at the next strictly later onset in the same clip and channel; notes in a chord end together. The final onset extends to source end. Gate expansion beyond the source rejects the whole edit. Undo restores original durations. + +## Phase 54 + +Edit/MIDI tools/Correct to scale takes a root (0=C through 11=B) and chromatic, major or natural minor. Each pitch moves to the nearest permitted MIDI note; ties choose the lower pitch. Timing, channel and velocity remain unchanged. Corrections can produce unison notes; notes are not merged. This changes MIDI notes, not audio pitch or a live scale filter. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9f1ea47..ef1b35c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1423,12 +1423,12 @@ Implemented: atomic gate scaling and same-channel legato over selected MIDI clip Snap notes to a chosen root and chromatic, major or minor scale with deterministic nearest-pitch ties. ### 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: root-aware nearest-pitch correction to chromatic, major or natural minor scales, with lower-pitch ties and MIDI endpoint bounds. Core root/tie/endpoint/invalid-input and GUI pitch-refresh tests pass; note IDs and timing persist. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index 17227ca..7795368 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -22,11 +22,12 @@ xtc_result xtc_project_transform_clips(xtc_project *project,const uint64_t *ids, * a grid; strength 0..100 blends toward the nearest swung point (ties later). * Swing durations stay fixed. LEGATO ends at the next strictly later onset in * the same clip/channel (chords stay together), or source end for the last onset. Out-of-source results reject the complete operation. */ -typedef enum { XTC_MIDI_TRANSFORM_SWING, XTC_MIDI_TRANSFORM_GATE, XTC_MIDI_TRANSFORM_LEGATO } xtc_midi_transform_type; +typedef enum { XTC_MIDI_TRANSFORM_SWING, XTC_MIDI_TRANSFORM_GATE, XTC_MIDI_TRANSFORM_LEGATO, XTC_MIDI_TRANSFORM_SCALE } xtc_midi_transform_type; typedef struct { xtc_midi_transform_type type; uint64_t grid; unsigned int swing,strength; + unsigned int root,scale; /* SCALE: root 0..11 (C..B), scale 0 chromatic, 1 major, 2 natural minor; nearest ties lower. */ unsigned int gate_percent; /* GATE: 1..400, rounded length, minimum 1 tick. */ } xtc_midi_transform; xtc_result xtc_project_transform_midi(xtc_project *project,const uint64_t *ids,size_t count,const xtc_midi_transform *edit); diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index caa2b0d..0c4114c 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/Correct to scale...",0,[](Fl_Widget *,void *c){static_cast(c)->scale_dialog();},sequencer_); menu->add("Edit/MIDI tools/Note gate...",0,[](Fl_Widget *,void *c){static_cast(c)->gate_dialog();},sequencer_); menu->add("Edit/MIDI tools/Legato",0,[](Fl_Widget *,void *c){xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_LEGATO;static_cast(c)->transform_midi(edit);},sequencer_); menu->add("Edit/MIDI tools/Swing quantize...",0,[](Fl_Widget *,void *c){static_cast(c)->swing_dialog();},sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 350eacd..bbc8b64 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -562,6 +562,13 @@ xtc_result SequencerWidget::transform_midi(const xtc_midi_transform &edit) if(r==XTC_OK)r=xtc_project_transform_midi(project_,ids.data(),ids.size(),&edit); changed(r);return r; } +void SequencerWidget::scale_dialog() +{ + const char *input=fl_input("Root (0=C..11=B), scale (0=chromatic, 1=major, 2=natural minor)","0 1");if(!input)return; + unsigned int root=0,scale=0;char extra;std::istringstream values(input); + if(!(values>>root>>scale) || (values>>extra) || root>11 || scale>2){fl_alert("Enter a root from 0 to 11 and scale from 0 to 2.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.root=root;edit.scale=scale;transform_midi(edit); +} void SequencerWidget::gate_dialog() { const char *input=fl_input("Note length percentage (1..400)","50");if(!input)return; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 3edd3c8..572a055 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -41,6 +41,7 @@ class SequencerWidget : public Fl_Group { xtc_result transform_midi(const xtc_midi_transform &edit); void swing_dialog(); void gate_dialog(); + void scale_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 94fd838..69d6b02 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -133,9 +133,10 @@ xtc_result xtc_project_transform_midi(xtc_project *p,const uint64_t *ids,size_t { 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->typetype>XTC_MIDI_TRANSFORM_LEGATO)return XTC_ERR_INVALID_ARGUMENT; + if(!edit || edit->typetype>XTC_MIDI_TRANSFORM_SCALE)return XTC_ERR_INVALID_ARGUMENT; if(edit->type==XTC_MIDI_TRANSFORM_SWING && (!edit->grid || edit->grid>3840 || edit->swing>100 || edit->strength>100))return XTC_ERR_INVALID_ARGUMENT; if(edit->type==XTC_MIDI_TRANSFORM_GATE && (!edit->gate_percent || edit->gate_percent>400))return XTC_ERR_INVALID_ARGUMENT; + if(edit->type==XTC_MIDI_TRANSFORM_SCALE && (edit->root>11 || edit->scale>2))return XTC_ERR_INVALID_ARGUMENT; for(i=0;inote_count;++i) { @@ -147,13 +148,21 @@ xtc_result xtc_project_transform_midi(xtc_project *p,const uint64_t *ids,size_t else if(edit->type==XTC_MIDI_TRANSFORM_GATE) { n->length_tick=(n->length_tick*edit->gate_percent+50)/100; if(!n->length_tick)n->length_tick=1; - } else { + } else if(edit->type==XTC_MIDI_TRANSFORM_LEGATO) { uint64_t next=c.source_length_tick; for(j=0;jnote_count;++j) { const xtc_note_info *other=p->notes+j; if(other->clip_id==n->clip_id && other->channel==n->channel && other->start_tick>n->start_tick && other->start_tickstart_tick; } n->length_tick=next-n->start_tick; + } else if(edit->type==XTC_MIDI_TRANSFORM_SCALE) { + static const unsigned int masks[]={4095,2741,1453}; + unsigned int pitch,best=n->pitch,distance=128; + for(pitch=0;pitch<128;++pitch)if(masks[edit->scale] & (1U<<((pitch+12-edit->root)%12))) { + unsigned int d=pitch>n->pitch ? pitch-n->pitch : n->pitch-pitch; + if(dpitch=best; } if(n->start_tick>=c.source_length_tick || n->length_tick>c.source_length_tick-n->start_tick)r=XTC_ERR_INVALID_ARGUMENT; } diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 1e280fd..0617d72 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1340,6 +1340,8 @@ int main(int argc, char **argv) CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.length_tick==60); edit.type=XTC_MIDI_TRANSFORM_LEGATO;CHECK(s->transform_midi(edit)==XTC_OK); CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.length_tick==3540); + edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.root=0;edit.scale=1;CHECK(s->transform_midi(edit)==XTC_OK); + CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.pitch==60); 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 64df19e..4853cca 100644 --- a/tests/unit/test_modern_editing.c +++ b/tests/unit/test_modern_editing.c @@ -2,6 +2,21 @@ #include #include +static void test_scale(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,960,&c)==XTC_OK); + assert(xtc_project_add_note(p,c,0,120,61,100,0,&n)==XTC_OK); + edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.scale=1; + assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_OK); + assert(xtc_project_get_note(p,0,¬e)==XTC_OK && note.pitch==60 && note.id==n); + assert(xtc_project_edit_note(p,n,0,120,0,100,0)==XTC_OK);edit.root=11; + assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_OK); + assert(xtc_project_get_note(p,0,¬e)==XTC_OK && note.pitch==1); + edit.root=12;assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_ERR_INVALID_ARGUMENT); + xtc_project_destroy(p); +} static void test_gate_legato(void) { xtc_project *p=NULL;uint64_t t,c,n;xtc_note_info note;xtc_midi_transform edit={0}; @@ -75,7 +90,7 @@ static void test_repeat(void) } int main(void) { - test_repeat();test_align_slip();test_swing();test_gate_legato(); + test_repeat();test_align_slip();test_swing();test_gate_legato();test_scale(); 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);