feat(midi): strum chords with preserved note ends and undo (phase 99)
This commit is contained in:
parent
083ac05ae6
commit
bd0a63c06d
17 changed files with 172 additions and 5 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
screenshots/creative-dub-rack.png
Normal file
BIN
screenshots/creative-dub-rack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
screenshots/creative-rack-actions.png
Normal file
BIN
screenshots/creative-rack-actions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
screenshots/creative-strum-dialog.png
Normal file
BIN
screenshots/creative-strum-dialog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
screenshots/creative-strum-result.png
Normal file
BIN
screenshots/creative-strum-result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -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_);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,©);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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),¬e_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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,©)==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,¬e)==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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue