feat: shape MIDI phrase dynamics with bounded velocity scaling

This commit is contained in:
Johannes Findeisen 2026-11-06 00:19:32 +01:00
commit 4af18fc33d
9 changed files with 44 additions and 7 deletions

View file

@ -35,3 +35,7 @@ Edit/MIDI tools/Note gate scales lengths from 1 to 400 percent, rounded to at le
## Phase 54 ## Phase 54
Edit/MIDI tools/Correct to scale takes a root (0=C through 11=B) and chromatic, major or natural minor. Each pitch moves to the nearest permitted MIDI note; ties choose the lower pitch. Timing, channel and velocity remain unchanged. Corrections can produce unison notes; notes are not merged. This changes MIDI notes, not audio pitch or a live scale filter. Edit/MIDI tools/Correct to scale takes a root (0=C through 11=B) and chromatic, major or natural minor. Each pitch moves to the nearest permitted MIDI note; ties choose the lower pitch. Timing, channel and velocity remain unchanged. Corrections can produce unison notes; notes are not merged. This changes MIDI notes, not audio pitch or a live scale filter.
## Phase 55
Edit/MIDI tools/Shape velocities scales from 0 to 400 percent, rounds, then applies an offset from -127 to 127. Results clamp to 1..127, keeping every note audible rather than introducing velocity-zero note-offs. Use 0 percent with a positive offset for a fixed velocity. No randomization is applied.

View file

@ -1438,12 +1438,12 @@ Implemented: root-aware nearest-pitch correction to chromatic, major or natural
Apply bounded velocity scaling and offsets to selected MIDI clips, with explicit clipping to valid MIDI velocities. Apply bounded velocity scaling and offsets to selected MIDI clips, with explicit clipping to valid MIDI velocities.
### Tasks and acceptance criteria ### Tasks and acceptance criteria
- [ ] Provide the control/model behavior and accessible GUI commands. - [x] Provide the control/model behavior and accessible GUI commands.
- [ ] Validate bounds and failure rollback; preserve existing project compatibility and callback safety. - [x] Validate bounds and failure rollback; preserve existing project compatibility and callback safety.
- [ ] Test the new workflow and document scope and limitations. - [x] Test the new workflow and document scope and limitations.
### Implementation status ### Implementation status
Planned. Implemented: rounded velocity percentage and offset transforms with explicit 1..127 saturation. Core scale/offset/extreme/invalid-input and GUI velocity-refresh tests pass; timing, IDs, pitch and channel remain unchanged.
--- ---

View file

@ -22,11 +22,13 @@ xtc_result xtc_project_transform_clips(xtc_project *project,const uint64_t *ids,
* a grid; strength 0..100 blends toward the nearest swung point (ties later). * a grid; strength 0..100 blends toward the nearest swung point (ties later).
* 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. Out-of-source results reject the complete operation. */ * 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_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_type;
typedef struct { typedef struct {
xtc_midi_transform_type type; xtc_midi_transform_type type;
uint64_t grid; uint64_t grid;
unsigned int swing,strength; unsigned int swing,strength;
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 root,scale; /* SCALE: root 0..11 (C..B), scale 0 chromatic, 1 major, 2 natural minor; nearest ties lower. */
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;

View file

@ -322,6 +322,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
Fl_Group::current(parent); Fl_Group::current(parent);
menu->add("Edit/Nudge selection left",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->nudge_selection(-1);},sequencer_); menu->add("Edit/Nudge selection left",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/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->nudge_selection(1);},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_);
menu->add("Edit/MIDI tools/Legato",0,[](Fl_Widget *,void *c){xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_LEGATO;static_cast<SequencerWidget *>(c)->transform_midi(edit);},sequencer_); menu->add("Edit/MIDI tools/Legato",0,[](Fl_Widget *,void *c){xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_LEGATO;static_cast<SequencerWidget *>(c)->transform_midi(edit);},sequencer_);

View file

@ -562,6 +562,13 @@ xtc_result SequencerWidget::transform_midi(const xtc_midi_transform &edit)
if(r==XTC_OK)r=xtc_project_transform_midi(project_,ids.data(),ids.size(),&edit); if(r==XTC_OK)r=xtc_project_transform_midi(project_,ids.data(),ids.size(),&edit);
changed(r);return r; changed(r);return r;
} }
void SequencerWidget::velocity_dialog()
{
const char *input=fl_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return;
unsigned int percent=0;int offset=0;char extra;std::istringstream values(input);
if(!(values>>percent>>offset) || (values>>extra) || percent>400 || offset < -127 || offset>127){fl_alert("Enter a valid percentage and velocity offset.");return;}
xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_VELOCITY;edit.velocity_percent=percent;edit.velocity_offset=offset;transform_midi(edit);
}
void SequencerWidget::scale_dialog() void SequencerWidget::scale_dialog()
{ {
const char *input=fl_input("Root (0=C..11=B), scale (0=chromatic, 1=major, 2=natural minor)","0 1");if(!input)return; const char *input=fl_input("Root (0=C..11=B), scale (0=chromatic, 1=major, 2=natural minor)","0 1");if(!input)return;

View file

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

View file

@ -133,9 +133,10 @@ 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_SCALE)return XTC_ERR_INVALID_ARGUMENT; if(!edit || edit->type<XTC_MIDI_TRANSFORM_SWING || edit->type>XTC_MIDI_TRANSFORM_VELOCITY)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_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_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;
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;} 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;}
r=xtc_project_clone(p,&copy);if(r!=XTC_OK)return r; r=xtc_project_clone(p,&copy);if(r!=XTC_OK)return r;
@ -163,6 +164,9 @@ xtc_result xtc_project_transform_midi(xtc_project *p,const uint64_t *ids,size_t
if(d<distance){distance=d;best=pitch;} if(d<distance){distance=d;best=pitch;}
} }
n->pitch=best; n->pitch=best;
} else if(edit->type==XTC_MIDI_TRANSFORM_VELOCITY) {
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(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;
} }

View file

@ -1342,6 +1342,8 @@ int main(int argc, char **argv)
CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.length_tick==3540); CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.length_tick==3540);
edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.root=0;edit.scale=1;CHECK(s->transform_midi(edit)==XTC_OK); edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.root=0;edit.scale=1;CHECK(s->transform_midi(edit)==XTC_OK);
CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.pitch==60); CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.pitch==60);
edit.type=XTC_MIDI_TRANSFORM_VELOCITY;edit.velocity_percent=50;edit.velocity_offset=10;CHECK(s->transform_midi(edit)==XTC_OK);
CHECK(xtc_project_get_note(s->project(),0,&quantized)==XTC_OK && quantized.velocity==60);
window.do_callback(); window.do_callback();
} }
if (failures != 0) return 1; if (failures != 0) return 1;

View file

@ -2,6 +2,22 @@
#include <assert.h> #include <assert.h>
#include <stdio.h> #include <stdio.h>
static void test_velocity(void)
{
xtc_project *p=NULL;uint64_t t,c,n;xtc_note_info note;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,960,&c)==XTC_OK);
assert(xtc_project_add_note(p,c,0,120,61,100,0,&n)==XTC_OK);
edit.type=XTC_MIDI_TRANSFORM_VELOCITY;edit.velocity_percent=50;edit.velocity_offset=10;
assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_OK);
assert(xtc_project_get_note(p,0,&note)==XTC_OK && note.velocity==60 && note.pitch==61);
edit.velocity_percent=400;assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_OK);
assert(xtc_project_get_note(p,0,&note)==XTC_OK && note.velocity==127);
edit.velocity_percent=0;edit.velocity_offset=-127;assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_OK);
assert(xtc_project_get_note(p,0,&note)==XTC_OK && note.velocity==1 && note.id==n);
edit.velocity_percent=401;assert(xtc_project_transform_midi(p,&c,1,&edit)==XTC_ERR_INVALID_ARGUMENT);
xtc_project_destroy(p);
}
static void test_scale(void) static void test_scale(void)
{ {
xtc_project *p=NULL;uint64_t t,c,n;xtc_note_info note;xtc_midi_transform edit={0}; xtc_project *p=NULL;uint64_t t,c,n;xtc_note_info note;xtc_midi_transform edit={0};
@ -90,7 +106,7 @@ static void test_repeat(void)
} }
int main(void) int main(void)
{ {
test_repeat();test_align_slip();test_swing();test_gate_legato();test_scale(); test_repeat();test_align_slip();test_swing();test_gate_legato();test_scale();test_velocity();
xtc_project *p=NULL;uint64_t track,a,b,n,ids[2],out[2]={999,999};xtc_clip_info c; xtc_project *p=NULL;uint64_t track,a,b,n,ids[2],out[2]={999,999};xtc_clip_info c;
size_t tracks,clips,notes; size_t tracks,clips,notes;
assert(xtc_project_create(&p)==XTC_OK); assert(xtc_project_create(&p)==XTC_OK);