From ea0b4ca3da92f9a0c330c1ef5f3d3afac5d36f6f Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 00:42:33 +0100 Subject: [PATCH] feat: generate bounded ascending and descending MIDI arpeggio patterns --- docs/COMPOSITION_WORKFLOWS.md | 4 ++++ docs/ROADMAP.md | 8 ++++---- include/xtc/xtc_editing.h | 5 +++++ src/gui/main_window.cpp | 1 + src/gui/sequencer_widget.cpp | 13 ++++++++++++ src/gui/sequencer_widget.hpp | 2 ++ src/project/editing.c | 36 ++++++++++++++++++++++++++++++++++ tests/integration/test_gui.cpp | 2 ++ tests/unit/test_next_daw.c | 23 +++++++++++++++++++++- 9 files changed, 89 insertions(+), 5 deletions(-) diff --git a/docs/COMPOSITION_WORKFLOWS.md b/docs/COMPOSITION_WORKFLOWS.md index 0fce48b..3035d55 100644 --- a/docs/COMPOSITION_WORKFLOWS.md +++ b/docs/COMPOSITION_WORKFLOWS.md @@ -34,3 +34,7 @@ Edit/MIDI tools/Humanize applies timing variation up to 960 ticks and velocity v ## Phase 65 — Chord Construction Edit/MIDI tools/Build chords accepts 1..8 unique nonzero semitone intervals, such as 4 7 for a major triad or 3 7 for minor. Originals remain; added notes copy timing, velocity and channel. Negative intervals support lower voicings. All source notes are processed, including hidden trimmed notes. Pitch overflow or capacity rejects the whole operation; existing unisons are not deduplicated. + +## Phase 66 — Offline Arpeggio Generation + +Edit/MIDI tools/Generate arpeggio replaces source notes with a written pattern. Equal-onset notes on the same channel form a chord lasting until its longest note end. The pattern cycles sorted pitches upward or downward, with the selected step and gate. Each step inherits its chosen note velocity/channel. All source notes, including hidden trimmed notes, participate. This is offline generation with undo, not a live arpeggiator or tempo-synced MIDI effect. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bec5001..7b6a4e0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1617,12 +1617,12 @@ Implemented: custom bounded chord intervals add independent notes with fresh IDs Convert source note chords into bounded ascending/descending stepped patterns with gate controls, deterministic ordering and undo. ### 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: bounded offline ascending/descending arpeggio generation with equal-onset/channel chord grouping, step/gate controls and fresh note IDs. Core ordering/gate/capacity rollback and GUI generation/undo tests pass. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index 0df5340..302608c 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -41,6 +41,11 @@ xtc_result xtc_project_merge_midi(xtc_project *project,const uint64_t *ids,size_ * in selected MIDI clips, retaining originals/articulation and allocating fresh * note IDs. Mixed selections, pitch overflow and capacity reject atomically. */ xtc_result xtc_project_chordify(xtc_project *project,const uint64_t *ids,size_t count,const int *intervals,size_t interval_count); +/* Offline arpeggio: replaces all source notes in selected MIDI clips with fresh + * IDs. Equal-onset/channel notes form a chord lasting to its longest note end. + * step 1..3840, gate 1..100 percent, descending 0/1. Cycles sorted pitches (ties + * by original ID); each step inherits its chosen note velocity/channel. Atomic. */ +xtc_result xtc_project_arpeggiate(xtc_project *project,const uint64_t *ids,size_t count,uint64_t step,unsigned int gate,int descending); /* 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 222258e..f0d7f19 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -331,6 +331,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) menu->add("Edit/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast(c)->nudge_selection(1);},sequencer_); menu->add("Edit/Audio tools/Set boundary fades...",0,[](Fl_Widget *,void *c){static_cast(c)->fades_dialog();},sequencer_); menu->add("Edit/Audio tools/Remove boundary fades",0,[](Fl_Widget *,void *c){static_cast(c)->transform_selection(XTC_CLIPS_FADE,0);},sequencer_); + menu->add("Edit/MIDI tools/Generate arpeggio...",0,[](Fl_Widget *,void *c){static_cast(c)->arpeggio_dialog();},sequencer_); menu->add("Edit/MIDI tools/Build chords...",0,[](Fl_Widget *,void *c){static_cast(c)->chord_dialog();},sequencer_); menu->add("Edit/MIDI tools/Humanize...",0,[](Fl_Widget *,void *c){static_cast(c)->humanize_dialog();},sequencer_); menu->add("Edit/MIDI tools/Shape velocities...",0,[](Fl_Widget *,void *c){static_cast(c)->velocity_dialog();},sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 6887acf..4cf0112 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -624,6 +624,19 @@ xtc_result SequencerWidget::make_chord(const std::vector &intervals) if(r==XTC_OK)r=xtc_project_chordify(project_,ids.data(),ids.size(),intervals.data(),intervals.size()); changed(r);return r; } +xtc_result SequencerWidget::arpeggiate(uint64_t step,unsigned int gate,bool descending) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_arpeggiate(project_,ids.data(),ids.size(),step,gate,descending); + changed(r);return r; +} +void SequencerWidget::arpeggio_dialog() +{ + const char *input=fl_input("Step ticks (1..3840), gate percent (1..100), direction (0=up, 1=down)","240 80 0");if(!input)return; + uint64_t step=0;unsigned int gate=0;int down=0;char extra;std::istringstream values(input); + if(!(values>>step>>gate>>down) || (values>>extra) || !step || step>3840 || !gate || gate>100 || down<0 || down>1){fl_alert("Enter a valid step, gate and direction.");return;} + arpeggiate(step,gate,down!=0); +} void SequencerWidget::chord_dialog() { const char *input=fl_input("Additional semitone intervals: 1..8 unique nonzero values (-127..127)","4 7");if(!input)return; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 06df695..2d0e8eb 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -53,6 +53,8 @@ class SequencerWidget : public Fl_Group { void humanize_dialog(); xtc_result make_chord(const std::vector &intervals); void chord_dialog(); + xtc_result arpeggiate(uint64_t step,unsigned int gate,bool descending); + void arpeggio_dialog(); void fades_dialog(); xtc_result repeat_selection(unsigned int copies); void repeat_dialog(); diff --git a/src/project/editing.c b/src/project/editing.c index 45b69b8..3500b89 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -298,3 +298,39 @@ xtc_result xtc_project_chordify(xtc_project *p,const uint64_t *ids,size_t count, if(r==XTC_OK)publish(p,copy); xtc_project_destroy(copy);return r; } + +static int chord_order(const void *a,const void *b) +{ + const xtc_note_info *x=*(const xtc_note_info *const *)a,*y=*(const xtc_note_info *const *)b; + if(x->pitch!=y->pitch)return x->pitchpitch ? -1 : 1; + return x->id==y->id ? 0 : x->idid ? -1 : 1; +} +xtc_result xtc_project_arpeggiate(xtc_project *p,const uint64_t *ids,size_t count,uint64_t step,unsigned int gate,int descending) +{ + xtc_project *copy=NULL;size_t i,j,k,matched=0;xtc_result r=midi_selection(p,ids,count); + if(r!=XTC_OK)return r; + if(!step || step>3840 || !gate || gate>100 || (descending!=0 && descending!=1))return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_clone(p,©);if(r!=XTC_OK)return r; + for(i=copy->note_count;i>0;--i){for(j=0;jnotes[i-1].clip_id==ids[j])break;if(jnotes[i-1].id);} + for(i=0;r==XTC_OK && inote_count;++i) { + const xtc_note_info *first=p->notes+i,*chord[XTC_PROJECT_MAX_NOTES];size_t voices=0,index=0;uint64_t end=0,at; + for(j=0;jclip_id==ids[j])break; + if(j==count)continue; + for(j=0;jnotes[j].clip_id==first->clip_id && p->notes[j].start_tick==first->start_tick && p->notes[j].channel==first->channel)break; + if(jnote_count;++j){const xtc_note_info *n=p->notes+j; + if(n->clip_id==first->clip_id && n->start_tick==first->start_tick && n->channel==first->channel){chord[voices++]=n;if(n->start_tick+n->length_tick>end)end=n->start_tick+n->length_tick;} + } + if(!voices){r=XTC_ERR_INVALID_STATE;break;} + qsort(chord,voices,sizeof(*chord),chord_order); + for(at=first->start_tick;r==XTC_OK && atend-at)length=end-at; + k=index%voices;if(descending)k=voices-1-k; + r=xtc_project_add_note(copy,first->clip_id,at,length,chord[k]->pitch,chord[k]->velocity,chord[k]->channel,¬e); + } + } + 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 17349f4..5a62955 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1390,6 +1390,8 @@ int main(int argc, char **argv) CHECK(s->transform_midi(human)==XTC_OK);CHECK(window.undo()==XTC_OK); CHECK(s->select(0,0));CHECK(s->make_chord({4,7})==XTC_OK); size_t notes=0;xtc_project_note_count(s->project(),¬es);CHECK(notes==3); + CHECK(s->arpeggiate(80,50,false)==XTC_OK);xtc_project_note_count(s->project(),¬es);CHECK(notes==3); + CHECK(window.undo()==XTC_OK);xtc_project_note_count(s->project(),¬es);CHECK(notes==3); 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 cd5c00a..94ea047 100644 --- a/tests/unit/test_next_daw.c +++ b/tests/unit/test_next_daw.c @@ -142,4 +142,25 @@ static void chords(void) assert(xtc_project_note_count(p,&count)==XTC_OK && count==XTC_PROJECT_MAX_NOTES); xtc_project_destroy(p); } -int main(void){markers();sections();ripple();duplicate_track();split_midi();merge_midi();humanize();chords();puts("Next DAW workflow tests passed.");return 0;} +static void arpeggio(void) +{ + xtc_project *p=NULL,*q=NULL;uint64_t t,c,n;size_t count;xtc_note_info note;int intervals[2]={4,7}; + 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,100,0,&n)==XTC_OK); + assert(xtc_project_chordify(p,&c,1,intervals,2)==XTC_OK); + assert(xtc_project_clone(p,&q)==XTC_OK && xtc_project_arpeggiate(q,&c,1,240,50,1)==XTC_OK); + assert(xtc_project_get_note(q,0,¬e)==XTC_OK && note.pitch==67);xtc_project_destroy(q); + assert(xtc_project_arpeggiate(p,&c,1,240,50,0)==XTC_OK); + assert(xtc_project_note_count(p,&count)==XTC_OK && count==4); + assert(xtc_project_get_note(p,1,¬e)==XTC_OK && note.start_tick==240 && note.pitch==64 && note.length_tick==120); + assert(xtc_project_get_note(p,3,¬e)==XTC_OK && note.start_tick==720 && note.pitch==60); + assert(xtc_project_arpeggiate(p,&c,1,1,100,0)==XTC_OK); + assert(xtc_project_note_count(p,&count)==XTC_OK && count==480); + assert(xtc_project_arpeggiate(p,&c,1,0,100,0)==XTC_ERR_INVALID_ARGUMENT); + xtc_project_destroy(p);p=NULL; + 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,XTC_PROJECT_MAX_TICK,&c)==XTC_OK && xtc_project_add_note(p,c,0,XTC_PROJECT_MAX_TICK,60,100,0,&n)==XTC_OK); + assert(xtc_project_arpeggiate(p,&c,1,1,100,0)==XTC_ERR_CAPACITY); + assert(xtc_project_note_count(p,&count)==XTC_OK && count==1);xtc_project_destroy(p); +} +int main(void){markers();sections();ripple();duplicate_track();split_midi();merge_midi();humanize();chords();arpeggio();puts("Next DAW workflow tests passed.");return 0;}