feat: generate bounded ascending and descending MIDI arpeggio patterns

This commit is contained in:
Johannes Findeisen 2026-11-06 00:42:33 +01:00
commit ea0b4ca3da
9 changed files with 89 additions and 5 deletions

View file

@ -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.

View file

@ -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.
---

View file

@ -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

View file

@ -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<SequencerWidget *>(c)->nudge_selection(1);},sequencer_);
menu->add("Edit/Audio tools/Set boundary fades...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->fades_dialog();},sequencer_);
menu->add("Edit/Audio tools/Remove boundary fades",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->transform_selection(XTC_CLIPS_FADE,0);},sequencer_);
menu->add("Edit/MIDI tools/Generate arpeggio...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->arpeggio_dialog();},sequencer_);
menu->add("Edit/MIDI tools/Build chords...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->chord_dialog();},sequencer_);
menu->add("Edit/MIDI tools/Humanize...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->humanize_dialog();},sequencer_);
menu->add("Edit/MIDI tools/Shape velocities...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->velocity_dialog();},sequencer_);

View file

@ -624,6 +624,19 @@ xtc_result SequencerWidget::make_chord(const std::vector<int> &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;

View file

@ -53,6 +53,8 @@ class SequencerWidget : public Fl_Group {
void humanize_dialog();
xtc_result make_chord(const std::vector<int> &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();

View file

@ -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->pitch<y->pitch ? -1 : 1;
return x->id==y->id ? 0 : x->id<y->id ? -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,&copy);if(r!=XTC_OK)return r;
for(i=copy->note_count;i>0;--i){for(j=0;j<count;++j)if(copy->notes[i-1].clip_id==ids[j])break;if(j<count)r=xtc_project_remove_note(copy,copy->notes[i-1].id);}
for(i=0;r==XTC_OK && i<p->note_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;j<count;++j)if(first->clip_id==ids[j])break;
if(j==count)continue;
for(j=0;j<i;++j)if(p->notes[j].clip_id==first->clip_id && p->notes[j].start_tick==first->start_tick && p->notes[j].channel==first->channel)break;
if(j<i)continue;
++matched;
for(j=i;j<p->note_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 && at<end;at+=step,++index) {
uint64_t length=(step*gate+50)/100,note;if(!length)length=1;if(length>end-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,&note);
}
}
if(r==XTC_OK && !matched)r=XTC_ERR_NOT_FOUND;
if(r==XTC_OK)publish(p,copy);
xtc_project_destroy(copy);return r;
}

View file

@ -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(),&notes);CHECK(notes==3);
CHECK(s->arpeggiate(80,50,false)==XTC_OK);xtc_project_note_count(s->project(),&notes);CHECK(notes==3);
CHECK(window.undo()==XTC_OK);xtc_project_note_count(s->project(),&notes);CHECK(notes==3);
window.do_callback();
}
if (failures != 0) return 1;

View file

@ -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,&note)==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,&note)==XTC_OK && note.start_tick==240 && note.pitch==64 && note.length_tick==120);
assert(xtc_project_get_note(p,3,&note)==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;}