feat: build custom MIDI chord voicings with atomic pitch and capacity checks

This commit is contained in:
Johannes Findeisen 2026-11-06 00:40:48 +01:00
commit 6be4f9803a
9 changed files with 76 additions and 5 deletions

View file

@ -30,3 +30,7 @@ Edit/Consolidate MIDI clips merges two or more selected MIDI clips on one track.
## Phase 64 — Deterministic MIDI Humanization
Edit/MIDI tools/Humanize applies timing variation up to 960 ticks and velocity variation up to 127, using a 32-bit seed and stable note IDs. Repeating from the same original state reproduces results; applying repeatedly accumulates edits. All source notes are affected, including hidden trimmed notes. Timing outside source bounds rejects the entire edit; velocities clamp to 1..127. This is offline editing, not live randomness.
## 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.

View file

@ -1602,12 +1602,12 @@ Implemented: seeded note-ID keyed timing/velocity humanization, independent of s
Generate bounded custom chord intervals from source notes with fresh IDs, preserved articulation and transactional pitch/capacity checks.
### 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: custom bounded chord intervals add independent notes with fresh IDs while retaining originals and articulation. Core pitch/duplicate-interval/capacity-bound rollback and GUI chord/history tests pass.
---

View file

@ -37,6 +37,10 @@ xtc_result xtc_project_split_midi(xtc_project *project,uint64_t clip,uint64_t ti
* boundaries, drops hidden notes, retains overlaps and gaps. Fresh IDs; atomic.
* Cropped notes start anew; instrument envelopes/phase can change. */
xtc_result xtc_project_merge_midi(xtc_project *project,const uint64_t *ids,size_t count,uint64_t *out);
/* Adds 1..8 unique nonzero semitone intervals (-127..127) to EVERY source note
* 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);
/* 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/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_);
menu->add("Edit/MIDI tools/Correct to scale...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->scale_dialog();},sequencer_);

View file

@ -618,6 +618,20 @@ void SequencerWidget::fades_dialog()
if(!(values>>ticks) || (values>>extra) || ticks<0 || ticks>static_cast<int64_t>(XTC_PROJECT_MAX_TICK)){fl_alert("Enter a valid nonnegative tick length.");return;}
transform_selection(XTC_CLIPS_FADE,ticks);
}
xtc_result SequencerWidget::make_chord(const std::vector<int> &intervals)
{
auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_chordify(project_,ids.data(),ids.size(),intervals.data(),intervals.size());
changed(r);return r;
}
void SequencerWidget::chord_dialog()
{
const char *input=fl_input("Additional semitone intervals: 1..8 unique nonzero values (-127..127)","4 7");if(!input)return;
std::istringstream values(input);std::vector<int> intervals;int interval;
while(values>>interval){intervals.push_back(interval);if(intervals.size()>8)break;}
if(!values.eof() || intervals.empty() || intervals.size()>8){fl_alert("Enter 1 to 8 semitone intervals.");return;}
make_chord(intervals);
}
void SequencerWidget::humanize_dialog()
{
const char *input=fl_input("Timing variation ticks (0..960), velocity variation (0..127), seed","12 8 1");if(!input)return;

View file

@ -51,6 +51,8 @@ class SequencerWidget : public Fl_Group {
void scale_dialog();
void velocity_dialog();
void humanize_dialog();
xtc_result make_chord(const std::vector<int> &intervals);
void chord_dialog();
void fades_dialog();
xtc_result repeat_selection(unsigned int copies);
void repeat_dialog();

View file

@ -270,3 +270,31 @@ xtc_result xtc_project_merge_midi(xtc_project *p,const uint64_t *ids,size_t coun
if(r==XTC_OK){publish(p,copy);*out=id;}
xtc_project_destroy(copy);return r;
}
static xtc_result midi_selection(const xtc_project *p,const uint64_t *ids,size_t count)
{
size_t i;xtc_result r=selection_valid(p,ids,count);if(r!=XTC_OK)return r;
for(i=0;i<count;++i){xtc_clip_info c;xtc_project_find_clip(p,ids[i],&c);if(!c.midi)return XTC_ERR_INVALID_STATE;}
return XTC_OK;
}
xtc_result xtc_project_chordify(xtc_project *p,const uint64_t *ids,size_t count,const int *intervals,size_t interval_count)
{
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(!intervals || !interval_count || interval_count>8)return XTC_ERR_INVALID_ARGUMENT;
for(i=0;i<interval_count;++i){if(!intervals[i] || intervals[i]<-127 || intervals[i]>127)return XTC_ERR_INVALID_ARGUMENT;for(j=0;j<i;++j)if(intervals[j]==intervals[i])return XTC_ERR_INVALID_ARGUMENT;}
r=xtc_project_clone(p,&copy);if(r!=XTC_OK)return r;
for(i=0;r==XTC_OK && i<p->note_count;++i) {
const xtc_note_info *n=p->notes+i;
for(j=0;j<count;++j)if(n->clip_id==ids[j])break;
if(j==count)continue;
++matched;
for(k=0;r==XTC_OK && k<interval_count;++k){int pitch=(int)n->pitch+intervals[k];uint64_t id;
if(pitch<0 || pitch>127){r=XTC_ERR_INVALID_ARGUMENT;break;}
r=xtc_project_add_note(copy,n->clip_id,n->start_tick,n->length_tick,(unsigned int)pitch,n->velocity,n->channel,&id);
}
}
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

@ -1388,6 +1388,8 @@ int main(int argc, char **argv)
CHECK(s->select(0,0));CHECK(window.open_piano_roll()==XTC_OK);CHECK(window.piano_roll()->add_note(480,240,60)==XTC_OK);
xtc_midi_transform human{};human.type=XTC_MIDI_TRANSFORM_HUMANIZE;human.seed=42;human.timing_variation=12;human.velocity_variation=8;
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);
window.do_callback();
}
if (failures != 0) return 1;

View file

@ -126,4 +126,20 @@ static void humanize(void)
assert(xtc_project_get_note(p,0,&a)==XTC_OK && a.start_tick==0 && a.velocity==100);
xtc_project_destroy(p);xtc_project_destroy(q);
}
int main(void){markers();sections();ripple();duplicate_track();split_midi();merge_midi();humanize();puts("Next DAW workflow tests passed.");return 0;}
static void chords(void)
{
xtc_project *p=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,1920,&c)==XTC_OK && xtc_project_add_note(p,c,480,120,60,100,2,&n)==XTC_OK);
assert(xtc_project_chordify(p,&c,1,intervals,2)==XTC_OK && xtc_project_note_count(p,&count)==XTC_OK && count==3);
assert(xtc_project_get_note(p,2,&note)==XTC_OK && note.pitch==67 && note.start_tick==480 && note.channel==2 && note.id!=n);
assert(xtc_project_add_note(p,c,0,120,125,100,0,&n)==XTC_OK);
assert(xtc_project_chordify(p,&c,1,intervals,2)==XTC_ERR_INVALID_ARGUMENT);
assert(xtc_project_note_count(p,&count)==XTC_OK && count==4);
intervals[1]=4;assert(xtc_project_chordify(p,&c,1,intervals,2)==XTC_ERR_INVALID_ARGUMENT);
while(count<XTC_PROJECT_MAX_NOTES){assert(xtc_project_add_note(p,c,0,1,60,100,0,&n)==XTC_OK);++count;}
intervals[0]=1;assert(xtc_project_chordify(p,&c,1,intervals,1)==XTC_ERR_CAPACITY);
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;}