feat(midi): strum chords with preserved note ends and undo (phase 99)

This commit is contained in:
Johannes Findeisen 2026-11-06 02:16:46 +01:00
commit bd0a63c06d
17 changed files with 172 additions and 5 deletions

View file

@ -179,3 +179,8 @@ internal devices. Chains support undo, presets, audio/MIDI rendering and export.
Projects save format 17 and read formats 1–17. See
[track insert workflows and limits](docs/TRACK_INSERTS.md) and the
[screenshot gallery](screenshots/README.md).
Phases 90–99 add direct selected-track inserts, effect duplication, chain copy/
paste, A/B snapshots, three internal effect templates, bulk rack actions, insert
peak meters, tap tempo, audio crossfades and MIDI chord strumming. See
[creative workflows and limits](docs/CREATIVE_WORKFLOWS.md).

View file

@ -82,3 +82,14 @@ adjacency, cross-track selections and MIDI clips reject without partial edits.
This is a linear tick-based crossfade using existing clip envelopes; it does
not move clips, create overlaps or offer equal-power curves. Discrete sample
endpoints retain the engine's existing one-sample boundary convention.
## Chord strumming
Select MIDI clips and use **Edit → MIDI tools → Strum chords...**. Enter spacing
in ticks (1–960) and direction (0 ascending, 1 descending). Notes sharing a source
onset and MIDI channel within each clip form a chord. Equal pitches use stable
note-ID order. Each successive note starts one spacing later, while its end,
pitch, velocity and channel remain unchanged. Single notes remain unchanged.
Any resulting zero/negative duration rejects the entire edit; Undo restores the
whole selection. This edits source notes, including notes hidden by clip trims.
It does not generate an arpeggiator or schedule live input.

View file

@ -2193,15 +2193,33 @@ Implemented: allocation-free C99 validation applies paired linear overlap fades
Spread simultaneous chord notes in pitch order with a user-selected tick spacing and direction, preserving original ends and rejecting source overflow atomically. Expose a musical editing command with undo.
### Tasks and acceptance criteria
- [ ] Implement the workflow with bounded validation, safe preparation and documented limits.
- [ ] Pass meaningful core and/or GUI checks, including error and undo behavior.
- [ ] Document results and commit this phase separately.
- [x] Implement the workflow with bounded validation, safe preparation and documented limits.
- [x] Pass meaningful core and/or GUI checks, including error and undo behavior.
- [x] Document results and commit this phase separately.
### Implementation status
Planned.
Implemented: transactional C99 chord strumming groups source onsets by clip/channel, orders by pitch with stable-ID ties, preserves original note ends and rejects invalid durations atomically. Core ascending/descending/channel/singleton/mixed-selection/rollback checks, allocation-failure injection and real GUI editing/undo passed. A bounded spacing/direction dialog exposes the operation; source notes hidden by trims remain editable.
---
## Integrated Validation for Phases 90–99
Completed on 2026-11-06 with `BUILD_DIR=build/audio-editor`:
warning-enabled `all test test-reliability`, `test-gui-creative`,
`test-gui-track-fx`, full `test-gui`, modern-plugin/instrument/LADSPA GUI fixtures,
and `test-gui-jack`. Clang static analysis and both staged install layouts passed,
including C/C++ pkg-config consumers. ASan/UBSan creative core, allocation-failure
and focused/full GUI checks passed using only the existing Fontconfig leak
suppression; Clang/FLTK retains its existing instrumentation warning.
Four additional real screenshots show the Dub Echo rack, actions menu, strum
dialog and piano-roll result. Earlier gallery captures remain labeled Phase 89.
See [creative workflows](CREATIVE_WORKFLOWS.md) and
[the gallery](../screenshots/README.md) for reproduction and limits. Chains and
A/B snapshots copy supported metadata, not opaque plugin states or DSP histories.
Crossfades use existing linear envelopes; strumming is a source-note edit.
Project format remains 17, readable from versions 1–17; no dependencies were added.
## Recommended Execution Order
Reorder the phases when the audit reveals a blocking dependency. Do not mark a phase complete merely because code exists: its acceptance criteria must be demonstrated by builds, tests, or documented manual verification.

View file

@ -17,6 +17,14 @@ extern "C" {
typedef enum { XTC_CLIPS_MOVE, XTC_CLIPS_DUPLICATE, XTC_CLIPS_DELETE, XTC_CLIPS_ALIGN, XTC_CLIPS_SLIP, XTC_CLIPS_FADE } xtc_clip_operation;
xtc_result xtc_project_transform_clips(xtc_project *project,const uint64_t *ids,size_t count,
xtc_clip_operation operation,int64_t amount,uint64_t *out_ids);
/* Control-thread chord strumming in selected MIDI clips, including hidden source
* notes. Groups share source onset and channel within one clip. Pitch order is
* ascending (descending=0) or descending (1), equal pitches ordered by stable ID.
* Spacing is 1..960 ticks; original note ends and all other properties remain.
* Any nonpositive resulting duration rejects the entire edit. No chords returns
* NOT_FOUND. Allocation/validation errors preserve the project and IDs. */
xtc_result xtc_project_strum(xtc_project *project,const uint64_t *ids,size_t count,
uint64_t spacing,int descending);
/* Insert a gap or delete [begin,begin+length), removing contained clips/notes
* and shifting later clips, markers, mixer/instrument/graph plugin automation.
* Crossing clips reject (split them first); source notes and static tempo stay

View file

@ -14,6 +14,21 @@ APIs and GUI history/preparation paths. Original projects, media and user
configuration are not overwritten. Black margins are the virtual desktop;
floating views show the actual detached windows together.
## Creative workflows (Phases 90–99)
Four additional real captures show the current transport Tap button, insert
meters and new creative controls. Earlier gallery images retain Phase 89 UI.
- [Dub Echo template in the insert graph](creative-dub-rack.png)
- [Rack actions: bulk edits, comparison and chain clipboard](creative-rack-actions.png)
- [Chord strumming controls](creative-strum-dialog.png)
- [Strummed chord notes in the piano roll](creative-strum-result.png)
Reproduce only these captures with
`XTC_CAPTURE_CREATIVE_ONLY=1 make BUILD_DIR=build/audio-editor screenshots`.
The rack shows the actual template and idle meter; no live peak readings are
fabricated. See [creative workflow limits](../docs/CREATIVE_WORKFLOWS.md).
## Compact workspace and installed VST3 effects (Phases 80–89)
- [Compact sequencer workspace](compact-workspace.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -344,6 +344,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
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/Generate arpeggio...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->arpeggio_dialog();},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/Strum chords...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->strum_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

@ -939,3 +939,18 @@ xtc_result SequencerWidget::crossfade_selection()
if(r==XTC_OK)r=xtc_project_crossfade(project_,ids[0],ids[1]);
changed(r);return r;
}
xtc_result SequencerWidget::strum_chords(uint64_t spacing,bool descending)
{
auto ids=selected_clips();xtc_result r=XTC_OK;
if(before_edit)r=before_edit(context_);
if(r==XTC_OK)r=xtc_project_strum(project_,ids.data(),ids.size(),spacing,descending ? 1 : 0);
changed(r);return r;
}
void SequencerWidget::strum_dialog()
{
const char *input=fl_input("Spacing ticks (1..960), direction (0=up, 1=down)","30 0");if(!input)return;
uint64_t spacing=0;int down=0;char extra;std::istringstream values(input);
if(!(values>>spacing>>down) || (values>>extra) || !spacing || spacing>960 || down<0 || down>1){fl_alert("Enter a valid spacing and direction.");return;}
strum_chords(spacing,down!=0);
}

View file

@ -51,6 +51,8 @@ class SequencerWidget : public Fl_Group {
void scale_dialog();
void velocity_dialog();
void humanize_dialog();
void strum_dialog();
xtc_result strum_chords(uint64_t spacing,bool descending);
xtc_result make_chord(const std::vector<int> &intervals);
void chord_dialog();
xtc_result arpeggiate(uint64_t step,unsigned int gate,bool descending);

View file

@ -334,3 +334,32 @@ xtc_result xtc_project_arpeggiate(xtc_project *p,const uint64_t *ids,size_t coun
if(r==XTC_OK)publish(p,copy);
xtc_project_destroy(copy);return r;
}
/* Rank against the original project so edits cannot alter later chord groups. */
xtc_result xtc_project_strum(xtc_project *p,const uint64_t *ids,size_t count,
uint64_t spacing,int descending)
{
xtc_project *copy=NULL;size_t i,j,k,changed=0;
xtc_result r=selection_valid(p,ids,count);
if(r!=XTC_OK)return r;
if(!spacing || spacing>960 || (descending!=0 && descending!=1))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;}
r=xtc_project_clone(p,&copy);if(r!=XTC_OK)return r;
for(i=0;i<p->note_count;++i) {
const xtc_note_info *n=p->notes+i;size_t rank=0;uint64_t offset;
for(k=0;k<count;++k)if(n->clip_id==ids[k])break;
if(k==count)continue;
for(j=0;j<p->note_count;++j) {
const xtc_note_info *other=p->notes+j;
if(other->clip_id!=n->clip_id || other->start_tick!=n->start_tick || other->channel!=n->channel)continue;
if((descending ? other->pitch>n->pitch : other->pitch<n->pitch) ||
(other->pitch==n->pitch && other->id<n->id))++rank;
}
if(!rank)continue;
offset=(uint64_t)rank*spacing;
if(offset>=n->length_tick){xtc_project_destroy(copy);return XTC_ERR_INVALID_ARGUMENT;}
copy->notes[i].start_tick+=offset;copy->notes[i].length_tick-=offset;++changed;
}
if(changed)publish(p,copy);
xtc_project_destroy(copy);return changed ? XTC_OK : XTC_ERR_NOT_FOUND;
}

View file

@ -270,6 +270,16 @@ int creative_workflow_tests()
engine_status.sample_position=48000;s->position(1920);CHECK(s->add_region()==XTC_OK);auto right_clip=s->selected_clip();CHECK(s->select(audio_track,left_clip) && s->toggle_clip(right_clip));
CHECK(s->crossfade_selection()==XTC_OK);xtc_clip_info audio{};CHECK(xtc_project_find_clip(s->project(),left_clip,&audio)==XTC_OK && audio.fade_out_tick==1920);
CHECK(window.undo()==XTC_OK && xtc_project_find_clip(s->project(),left_clip,&audio)==XTC_OK && !audio.fade_out_tick);
engine_status.sample_position=0;
CHECK(window.create_track(true)==XTC_OK);auto midi_track=s->selected_track();
CHECK(s->add_midi_clip()==XTC_OK);auto chord_clip=s->selected_clip();
CHECK(window.open_piano_roll()==XTC_OK);
CHECK(window.piano_roll()->add_note(0,200,60,100,0)==XTC_OK);
CHECK(window.piano_roll()->add_note(0,200,67,100,0)==XTC_OK);
CHECK(s->select(midi_track,chord_clip) && s->strum_chords(30,false)==XTC_OK);
xtc_note_info strummed{};size_t note_count=0;CHECK(xtc_project_note_count(s->project(),&note_count)==XTC_OK);
CHECK(xtc_project_get_note(s->project(),note_count-1,&strummed)==XTC_OK && strummed.start_tick==30 && strummed.length_tick==170);
CHECK(window.undo()==XTC_OK && xtc_project_get_note(s->project(),note_count-1,&strummed)==XTC_OK && strummed.start_tick==0 && strummed.length_tick==200);
CHECK(!panel->comparison_ready(0) && !panel->comparison_ready(1));
window.do_callback();puts("Creative workflow GUI checks passed.");return failures ? 1 : 0;
}

View file

@ -294,6 +294,22 @@ static void sample_failures(void)
}
xtc_sample_destroy(source);xtc_project_destroy(p);assert(rmdir(root)==0);
}
static void strum_failures(void)
{
xtc_project *p=NULL;uint64_t t,c,n;int succeeded=0;
assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Chord",&t)==XTC_OK);
assert(xtc_project_add_midi_clip(p,t,0,1920,&c)==XTC_OK);
assert(xtc_project_add_note(p,c,0,200,60,100,0,&n)==XTC_OK && xtc_project_add_note(p,c,0,200,67,100,0,&n)==XTC_OK);
for(int failure=0;failure<128;++failure){
xtc_project *copy=NULL;xtc_note_info note;xtc_result r;
assert(xtc_project_clone(p,&copy)==XTC_OK);fail_after=failure;
r=xtc_project_strum(copy,&c,1,30,0);fail_after=-1;
if(r==XTC_OK)succeeded=1;
else {assert(r==XTC_ERR_OUT_OF_MEMORY);assert(xtc_project_get_note(copy,1,&note)==XTC_OK && note.id==n && note.start_tick==0 && note.length_tick==200);}
xtc_project_destroy(copy);if(succeeded)break;
}
assert(succeeded);xtc_project_destroy(p);
}
static void track_insert_failures(void)
{
xtc_project *p=NULL;xtc_track_chain c={0};uint64_t t,clip;int mode,failure;
@ -324,6 +340,7 @@ static void track_insert_failures(void)
}
int main(void)
{
strum_failures();
track_insert_failures();
sample_failures();
composition_failures();

View file

@ -45,9 +45,35 @@ static void crossfade_tests(void)
assert(xtc_project_find_clip(p,a,&left)==XTC_OK && !memcmp(&before,&left,sizeof(left)));
xtc_project_destroy(p);unlink(wave);
}
static void strum_tests(void)
{
xtc_project *p=NULL,*down=NULL;uint64_t t,c,n,solo,audio;xtc_note_info before[5],after;
unsigned int pitches[]={67,60,64,72,48};
assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Chords",&t)==XTC_OK);
assert(xtc_project_add_midi_clip(p,t,0,1920,&c)==XTC_OK);
for(size_t i=0;i<5;++i){
assert(xtc_project_add_note(p,c,i==3 ? 480 : 0,200,pitches[i],100,i==4 ? 1 : 0,&n)==XTC_OK);
assert(xtc_project_get_note(p,i,before+i)==XTC_OK);
}
assert(xtc_project_clone(p,&down)==XTC_OK);
assert(xtc_project_strum(p,&c,1,200,0)==XTC_ERR_INVALID_ARGUMENT);
for(size_t i=0;i<5;++i){assert(xtc_project_get_note(p,i,&after)==XTC_OK && !memcmp(&after,before+i,sizeof(after)));}
assert(xtc_project_strum(p,&c,1,0,0)==XTC_ERR_INVALID_ARGUMENT && xtc_project_strum(p,&c,1,20,2)==XTC_ERR_INVALID_ARGUMENT);
assert(xtc_project_strum(p,&c,1,20,0)==XTC_OK && xtc_project_strum(down,&c,1,20,1)==XTC_OK);
for(size_t i=0;i<5;++i){
uint64_t up[]={40,0,20,480,0},descending[]={0,40,20,480,0};
assert(xtc_project_get_note(p,i,&after)==XTC_OK && after.start_tick==up[i] && after.start_tick+after.length_tick==before[i].start_tick+200 && after.id==before[i].id && after.pitch==pitches[i] && after.velocity==100 && after.channel==before[i].channel);
assert(xtc_project_get_note(down,i,&after)==XTC_OK && after.start_tick==descending[i]);
}
assert(xtc_project_add_midi_clip(p,t,3840,1920,&solo)==XTC_OK && xtc_project_add_note(p,solo,0,100,60,100,0,&n)==XTC_OK);
assert(xtc_project_strum(p,&solo,1,20,0)==XTC_ERR_NOT_FOUND);
assert(xtc_project_add_clip(p,t,"Audio",7680,1920,&audio)==XTC_OK);
{uint64_t ids[]={c,audio};assert(xtc_project_strum(p,ids,2,20,0)==XTC_ERR_INVALID_STATE);}
xtc_project_destroy(p);xtc_project_destroy(down);
}
int main(void)
{
tap_tests();crossfade_tests();
tap_tests();crossfade_tests();strum_tests();
xtc_track_chain c={0},out={0};
c.output_gain=.8f;c.count=1;c.slots[0].enabled=1;c.slots[0].wet=.4f;
assert(xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,XTC_BUILTIN_PATH,2,48000,&c.slots[0].plugin)==XTC_OK);

View file

@ -63,6 +63,16 @@ int main(int argc,char **argv) {
MainWindow window(false,"");main_window=&window;window.resize(0,0,1400,900);window.show();check(window.open_project(project.c_str()));pump();
auto *s=first<SequencerWidget>(window);if(!s)throw std::runtime_error("Sequencer missing");
auto reset=[&](){window.reset_layout();check(window.open_project(project.c_str()));window.resize(0,20,1400,900);tab("Sequencer");s->select(0,0);s->position(0);check(window.set_loop(3840,7680,true));window.fit_song();pump();};
if(std::getenv("XTC_CAPTURE_CREATIVE_ONLY")) {
reset();check(window.open_track_inserts(0));auto *inserts=window.track_inserts();
check(inserts->apply_template(1));tab("Track inserts");capture("creative-dub-rack");
Fl::add_timeout(.3,[](void*){capture("creative-rack-actions");if(system("xdotool key Escape"))std::abort();});
auto *actions=dynamic_cast<Fl_Menu_Button *>(named(window,"Rack actions"));if(!actions)throw std::runtime_error("Missing rack actions");
actions->menu()->pulldown(actions->x(),actions->y(),actions->w(),actions->h(),nullptr,actions);
reset();dialog("creative-strum-dialog",[&]{s->strum_dialog();});
check(s->make_chord({4,7}));check(s->strum_chords(30,false));piano_view();capture("creative-strum-result");
window.do_callback();std::puts("Creative workflow captures completed.");return 0;
}
if(std::getenv("XTC_CAPTURE_HOST_VST3")) {
reset();capture("compact-workspace");
auto *create=dynamic_cast<Fl_Menu_Button *>(named(window,"Create"));if(!create)throw std::runtime_error("Missing Create dropdown");