diff --git a/docs/COMPOSITION_WORKFLOWS.md b/docs/COMPOSITION_WORKFLOWS.md index 04db589..0fce48b 100644 --- a/docs/COMPOSITION_WORKFLOWS.md +++ b/docs/COMPOSITION_WORKFLOWS.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 92bde02..bec5001 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index e210d26..0df5340 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -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 diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 9aac5cd..222258e 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/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_); menu->add("Edit/MIDI tools/Correct to scale...",0,[](Fl_Widget *,void *c){static_cast(c)->scale_dialog();},sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 67c5a2c..6887acf 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -618,6 +618,20 @@ void SequencerWidget::fades_dialog() if(!(values>>ticks) || (values>>extra) || ticks<0 || ticks>static_cast(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 &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 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; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index ad605cc..06df695 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -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 &intervals); + void chord_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 ecbf533..45b69b8 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -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;i8)return XTC_ERR_INVALID_ARGUMENT; + for(i=0;i127)return XTC_ERR_INVALID_ARGUMENT;for(j=0;jnote_count;++i) { + const xtc_note_info *n=p->notes+i; + for(j=0;jclip_id==ids[j])break; + if(j==count)continue; + ++matched; + for(k=0;r==XTC_OK && kpitch+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; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 603a5bc..17349f4 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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(),¬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 83c5828..cd5c00a 100644 --- a/tests/unit/test_next_daw.c +++ b/tests/unit/test_next_daw.c @@ -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,¬e)==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