diff --git a/docs/COMPOSITION_WORKFLOWS.md b/docs/COMPOSITION_WORKFLOWS.md index 6d00501..04db589 100644 --- a/docs/COMPOSITION_WORKFLOWS.md +++ b/docs/COMPOSITION_WORKFLOWS.md @@ -26,3 +26,7 @@ The existing Split clip at cursor command (S) now handles MIDI as well as audio. ## Phase 63 — MIDI Clip Consolidation Edit/Consolidate MIDI clips merges two or more selected MIDI clips on one track. The new clip spans their full bounding range; gaps and overlapping notes remain. Notes outside visible trims are discarded and crossing notes are cropped to audible boundaries. IDs are fresh. Cropped notes start anew, so envelope/phase behavior can change. Audio or cross-track selections reject atomically. + +## 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 62858aa..92bde02 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1587,12 +1587,12 @@ Implemented: transactional consolidation of selected same-track MIDI clips into Apply seeded timing and velocity variation to MIDI clip sources with reproducible results, bounds checks 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: seeded note-ID keyed timing/velocity humanization, independent of selection order. Timing overflow rejects atomically; velocity saturates to MIDI bounds. Core reproducibility/range/rollback and GUI undo tests pass. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index 29c33c6..e210d26 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -46,7 +46,7 @@ xtc_result xtc_project_merge_midi(xtc_project *project,const uint64_t *ids,size_ * 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_SCALE, XTC_MIDI_TRANSFORM_VELOCITY } 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_VELOCITY, XTC_MIDI_TRANSFORM_HUMANIZE } xtc_midi_transform_type; typedef struct { xtc_midi_transform_type type; uint64_t grid; @@ -54,6 +54,8 @@ typedef struct { unsigned int velocity_percent; /* VELOCITY: 0..400, rounded scaling then offset, clamp to 1..127. */ int velocity_offset; /* -127..127 */ unsigned int root,scale; /* SCALE: root 0..11 (C..B), scale 0 chromatic, 1 major, 2 natural minor; nearest ties lower. */ + unsigned int seed; /* HUMANIZE: stable note-ID keyed 32-bit PRNG seed. */ + unsigned int timing_variation,velocity_variation; /* 0..960 ticks, 0..127 velocity; source overflow rejects, velocity clamps. */ 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 feef0f7..9aac5cd 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/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_); menu->add("Edit/MIDI tools/Note gate...",0,[](Fl_Widget *,void *c){static_cast(c)->gate_dialog();},sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 163fca3..67c5a2c 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -618,6 +618,13 @@ 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); } +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; + unsigned int timing=0,velocity=0,seed=0;char extra;std::istringstream values(input); + if(!(values>>timing>>velocity>>seed) || (values>>extra) || timing>960 || velocity>127){fl_alert("Enter bounded timing/velocity variation and a 32-bit seed.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_HUMANIZE;edit.timing_variation=timing;edit.velocity_variation=velocity;edit.seed=seed;transform_midi(edit); +} void SequencerWidget::velocity_dialog() { const char *input=fl_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 0b5db42..ad605cc 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -50,6 +50,7 @@ class SequencerWidget : public Fl_Group { void gate_dialog(); void scale_dialog(); void velocity_dialog(); + void humanize_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 faf52f7..ecbf533 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -137,8 +137,9 @@ 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_VELOCITY)return XTC_ERR_INVALID_ARGUMENT; + if(!edit || edit->typetype>XTC_MIDI_TRANSFORM_HUMANIZE)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_HUMANIZE && (edit->timing_variation>960 || edit->velocity_variation>127))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_VELOCITY && (edit->velocity_percent>400 || edit->velocity_offset < -127 || edit->velocity_offset>127))return XTC_ERR_INVALID_ARGUMENT; if(edit->type==XTC_MIDI_TRANSFORM_SCALE && (edit->root>11 || edit->scale>2))return XTC_ERR_INVALID_ARGUMENT; @@ -172,6 +173,16 @@ xtc_result xtc_project_transform_midi(xtc_project *p,const uint64_t *ids,size_t int velocity=(int)((n->velocity*edit->velocity_percent+50)/100)+edit->velocity_offset; n->velocity=(unsigned int)(velocity<1 ? 1 : velocity>127 ? 127 : velocity); } + if(edit->type==XTC_MIDI_TRANSFORM_HUMANIZE) { + uint32_t random=(uint32_t)(n->id^(n->id>>32))^(uint32_t)edit->seed^UINT32_C(0x9e3779b9); + int64_t start;int velocity; + random^=random<<13;random^=random>>17;random^=random<<5; + start=(int64_t)n->start_tick+(int64_t)(random%(2*edit->timing_variation+1))-(int64_t)edit->timing_variation; + random^=random<<13;random^=random>>17;random^=random<<5; + velocity=(int)n->velocity+(int)(random%(2*edit->velocity_variation+1))-(int)edit->velocity_variation; + if(start<0){r=XTC_ERR_INVALID_ARGUMENT;break;} + n->start_tick=(uint64_t)start;n->velocity=(unsigned int)(velocity<1 ? 1 : velocity>127 ? 127 : velocity); + } if(n->start_tick>=c.source_length_tick || n->length_tick>c.source_length_tick-n->start_tick)r=XTC_ERR_INVALID_ARGUMENT; } if(r==XTC_OK && !matched)r=XTC_ERR_NOT_FOUND; diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 565afd7..603a5bc 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1385,6 +1385,9 @@ int main(int argc, char **argv) CHECK(s->select(0,0));CHECK(s->duplicate_selection()==XTC_OK);s->select_all_clips();CHECK(s->merge_midi()==XTC_OK); xtc_project_get_counts(s->project(),&tracks,&clips);CHECK(clips==1); CHECK(window.undo()==XTC_OK);xtc_project_get_counts(s->project(),&tracks,&clips);CHECK(clips==2); + 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); 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 924654d..83c5828 100644 --- a/tests/unit/test_next_daw.c +++ b/tests/unit/test_next_daw.c @@ -111,4 +111,19 @@ static void merge_midi(void) assert(xtc_project_get_note(p,1,¬e)==XTC_OK && note.start_tick==960 && note.length_tick==240 && note.pitch==67); xtc_project_destroy(p); } -int main(void){markers();sections();ripple();duplicate_track();split_midi();merge_midi();puts("Next DAW workflow tests passed.");return 0;} +static void humanize(void) +{ + xtc_project *p=NULL,*q=NULL;uint64_t t,c,n;xtc_note_info a,b;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,1920,&c)==XTC_OK && xtc_project_add_note(p,c,480,120,60,100,0,&n)==XTC_OK); + assert(xtc_project_clone(p,&q)==XTC_OK); + edit.type=XTC_MIDI_TRANSFORM_HUMANIZE;edit.seed=42;edit.timing_variation=30;edit.velocity_variation=12; + assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_OK && xtc_project_transform_midi(q,&c,1,&edit)==XTC_OK); + assert(xtc_project_get_note(p,0,&a)==XTC_OK && xtc_project_get_note(q,0,&b)==XTC_OK && !memcmp(&a,&b,sizeof(a))); + assert(a.start_tick>=450 && a.start_tick<=510 && a.velocity>=88 && a.velocity<=112 && a.id==n); + assert(xtc_project_edit_note(p,n,0,1920,60,100,0)==XTC_OK);edit.timing_variation=960; + assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_ERR_INVALID_ARGUMENT); + 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;}