feat: humanize MIDI timing and dynamics with reproducible seeded edits

This commit is contained in:
Johannes Findeisen 2026-11-06 00:39:19 +01:00
commit d034417c8c
9 changed files with 51 additions and 7 deletions

View file

@ -26,3 +26,7 @@ The existing Split clip at cursor command (S) now handles MIDI as well as audio.
## Phase 63 — MIDI Clip Consolidation ## 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. 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.

View file

@ -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. Apply seeded timing and velocity variation to MIDI clip sources with reproducible results, bounds checks and undo.
### Tasks and acceptance criteria ### Tasks and acceptance criteria
- [ ] Implement bounded C99 model/control APIs and accessible GUI commands. - [x] Implement bounded C99 model/control APIs and accessible GUI commands.
- [ ] Preserve transactional errors, history, compatibility and callback safety. - [x] Preserve transactional errors, history, compatibility and callback safety.
- [ ] Verify the feature with automated model/GUI tests and document limitations. - [x] Verify the feature with automated model/GUI tests and document limitations.
### Implementation status ### 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.
--- ---

View file

@ -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 * 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. * the same clip/channel (chords stay together), or source end for the last onset.
* Out-of-source results reject the complete operation. */ * 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 { typedef struct {
xtc_midi_transform_type type; xtc_midi_transform_type type;
uint64_t grid; uint64_t grid;
@ -54,6 +54,8 @@ typedef struct {
unsigned int velocity_percent; /* VELOCITY: 0..400, rounded scaling then offset, clamp to 1..127. */ unsigned int velocity_percent; /* VELOCITY: 0..400, rounded scaling then offset, clamp to 1..127. */
int velocity_offset; /* -127..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 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. */ unsigned int gate_percent; /* GATE: 1..400, rounded length, minimum 1 tick. */
} xtc_midi_transform; } xtc_midi_transform;
xtc_result xtc_project_transform_midi(xtc_project *project,const uint64_t *ids,size_t count,const xtc_midi_transform *edit); xtc_result xtc_project_transform_midi(xtc_project *project,const uint64_t *ids,size_t count,const xtc_midi_transform *edit);

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/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/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/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/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/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_); menu->add("Edit/MIDI tools/Correct to scale...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->scale_dialog();},sequencer_);
menu->add("Edit/MIDI tools/Note gate...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->gate_dialog();},sequencer_); menu->add("Edit/MIDI tools/Note gate...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->gate_dialog();},sequencer_);

View file

@ -618,6 +618,13 @@ 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;} 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); 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() void SequencerWidget::velocity_dialog()
{ {
const char *input=fl_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return; const char *input=fl_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return;

View file

@ -50,6 +50,7 @@ class SequencerWidget : public Fl_Group {
void gate_dialog(); void gate_dialog();
void scale_dialog(); void scale_dialog();
void velocity_dialog(); void velocity_dialog();
void humanize_dialog();
void fades_dialog(); void fades_dialog();
xtc_result repeat_selection(unsigned int copies); xtc_result repeat_selection(unsigned int copies);
void repeat_dialog(); void repeat_dialog();

View file

@ -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); 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(r!=XTC_OK)return r;
if(!edit || edit->type<XTC_MIDI_TRANSFORM_SWING || edit->type>XTC_MIDI_TRANSFORM_VELOCITY)return XTC_ERR_INVALID_ARGUMENT; if(!edit || edit->type<XTC_MIDI_TRANSFORM_SWING || edit->type>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_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_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_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; 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; int velocity=(int)((n->velocity*edit->velocity_percent+50)/100)+edit->velocity_offset;
n->velocity=(unsigned int)(velocity<1 ? 1 : velocity>127 ? 127 : velocity); 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(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; if(r==XTC_OK && !matched)r=XTC_ERR_NOT_FOUND;

View file

@ -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); 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); 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(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(); window.do_callback();
} }
if (failures != 0) return 1; if (failures != 0) return 1;

View file

@ -111,4 +111,19 @@ static void merge_midi(void)
assert(xtc_project_get_note(p,1,&note)==XTC_OK && note.start_tick==960 && note.length_tick==240 && note.pitch==67); assert(xtc_project_get_note(p,1,&note)==XTC_OK && note.start_tick==960 && note.length_tick==240 && note.pitch==67);
xtc_project_destroy(p); 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;}