diff --git a/README.md b/README.md index 2961e22..7cd82c8 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/CREATIVE_WORKFLOWS.md b/docs/CREATIVE_WORKFLOWS.md index d6b3202..2fc31d9 100644 --- a/docs/CREATIVE_WORKFLOWS.md +++ b/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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b329fa0..aa77c31 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index a62ca39..529b330 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -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 diff --git a/screenshots/README.md b/screenshots/README.md index fb19f6b..c580eba 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -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) diff --git a/screenshots/creative-dub-rack.png b/screenshots/creative-dub-rack.png new file mode 100644 index 0000000..73ae052 Binary files /dev/null and b/screenshots/creative-dub-rack.png differ diff --git a/screenshots/creative-rack-actions.png b/screenshots/creative-rack-actions.png new file mode 100644 index 0000000..781ae6a Binary files /dev/null and b/screenshots/creative-rack-actions.png differ diff --git a/screenshots/creative-strum-dialog.png b/screenshots/creative-strum-dialog.png new file mode 100644 index 0000000..80622f8 Binary files /dev/null and b/screenshots/creative-strum-dialog.png differ diff --git a/screenshots/creative-strum-result.png b/screenshots/creative-strum-result.png new file mode 100644 index 0000000..fba2b4b Binary files /dev/null and b/screenshots/creative-strum-result.png differ diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index b16e490..c787b63 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -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(c)->transform_selection(XTC_CLIPS_FADE,0);},sequencer_); menu->add("Edit/MIDI tools/Generate arpeggio...",0,[](Fl_Widget *,void *c){static_cast(c)->arpeggio_dialog();},sequencer_); menu->add("Edit/MIDI tools/Build chords...",0,[](Fl_Widget *,void *c){static_cast(c)->chord_dialog();},sequencer_); + menu->add("Edit/MIDI tools/Strum chords...",0,[](Fl_Widget *,void *c){static_cast(c)->strum_dialog();},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_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index af7ebd3..3e7bcf4 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -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); +} diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index fd98928..a48453b 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -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 &intervals); void chord_dialog(); xtc_result arpeggiate(uint64_t step,unsigned int gate,bool descending); diff --git a/src/project/editing.c b/src/project/editing.c index 3b9430f..b02cec8 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -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;inote_count;++i) { + const xtc_note_info *n=p->notes+i;size_t rank=0;uint64_t offset; + for(k=0;kclip_id==ids[k])break; + if(k==count)continue; + for(j=0;jnote_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->pitchpitch) || + (other->pitch==n->pitch && other->idid))++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; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 6d43d2e..bb8f21b 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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; } diff --git a/tests/integration/test_reliability.c b/tests/integration/test_reliability.c index 81d963a..c76984f 100644 --- a/tests/integration/test_reliability.c +++ b/tests/integration/test_reliability.c @@ -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(); diff --git a/tests/unit/test_creative.c b/tests/unit/test_creative.c index c8cf5ca..6988b1e 100644 --- a/tests/unit/test_creative.c +++ b/tests/unit/test_creative.c @@ -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); diff --git a/tools/capture_screenshots.cpp b/tools/capture_screenshots.cpp index 7a9c9c3..4aa0921 100644 --- a/tools/capture_screenshots.cpp +++ b/tools/capture_screenshots.cpp @@ -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(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(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(named(window,"Create"));if(!create)throw std::runtime_error("Missing Create dropdown");