From 7084e660fefc3231591c371e36a3168664be6018 Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 00:32:33 +0100 Subject: [PATCH] feat: ripple timeline ranges with markers and automation atomically --- docs/COMPOSITION_WORKFLOWS.md | 4 ++++ docs/ROADMAP.md | 8 +++---- include/xtc/xtc_editing.h | 6 +++++ src/gui/main_window.cpp | 2 ++ src/gui/sequencer_widget.cpp | 13 +++++++++++ src/gui/sequencer_widget.hpp | 2 ++ src/project/sections.c | 42 ++++++++++++++++++++++++++++++++++ tests/integration/test_gui.cpp | 3 +++ tests/unit/test_next_daw.c | 20 +++++++++++++++- 9 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/COMPOSITION_WORKFLOWS.md b/docs/COMPOSITION_WORKFLOWS.md index d0946e7..51c4bc4 100644 --- a/docs/COMPOSITION_WORKFLOWS.md +++ b/docs/COMPOSITION_WORKFLOWS.md @@ -10,3 +10,7 @@ Edit/Markers/Set at cursor adds or renames a marker; Remove at cursor removes th ## Phase 59 — Section Navigation and Looping View/Previous marker and Next marker navigate without wrapping and stop playback before seeking. Loop current section uses the last marker at or before the cursor through its successor; the final section ends at the final clip endpoint. Empty final sections reject. Loop ranges remain existing UI preferences rather than marker metadata. + +## Phase 60 — Ripple Time Editing + +Edit/Time inserts a gap or removes a tick range beginning at the cursor. Clips crossing either edit boundary reject the entire operation; split them first. Contained clips and their notes are removed; later clips, markers and absolute automation points shift together. Source notes within retained clips do not move. No interpolation guard points are generated, so automation ramps across the boundary may change slope. Loop preferences are unchanged. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index efcb5b6..9a9b955 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1527,12 +1527,12 @@ Implemented: strict previous/next navigation and section queries, exposed throug Insert or remove bounded timeline ranges atomically, shifting clips, markers and automation; reject clips crossing edit boundaries explicitly. ### Tasks and acceptance criteria -- [ ] Implement bounded C99 model/control APIs and accessible GUI commands. -- [ ] Preserve transactional errors, history, compatibility and callback safety. -- [ ] Verify the feature with automated model/GUI tests and document limitations. +- [x] Implement bounded C99 model/control APIs and accessible GUI commands. +- [x] Preserve transactional errors, history, compatibility and callback safety. +- [x] Verify the feature with automated model/GUI tests and document limitations. ### Implementation status -Planned. +Implemented: atomic gap insertion/range removal shifts clips, markers and mixer/instrument/graph plugin automation. Crossing clips reject explicitly. Core position/automation/removal rollback tests and GUI edit/history checks pass. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index 9187a25..4a4018e 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -17,6 +17,12 @@ 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); +/* 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 + * unchanged. No interpolated automation guard points are synthesized. Atomic. + * remove must be 0 or 1. GUI loop preferences are outside the project. */ +xtc_result xtc_project_ripple_time(xtc_project *project,uint64_t begin,uint64_t length,int remove); /* MIDI transforms affect ALL source notes in selected MIDI clips, including * notes hidden by trims. Mixed audio/MIDI selections reject; IDs and unaffected * fields persist. Initialize the edit struct to zero and set the fields for its diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index c154ffd..a729d7c 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -342,6 +342,8 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) menu->add("Edit/Repeat selected phrase...",0,[](Fl_Widget *,void *c){static_cast(c)->repeat_dialog();},sequencer_); menu->add("Edit/Copy clips",0,[](Fl_Widget *,void *c){static_cast(c)->copy_selection();},sequencer_); menu->add("Edit/Paste clips at cursor",0,[](Fl_Widget *,void *c){static_cast(c)->paste_selection();},sequencer_); + menu->add("Edit/Time/Insert gap...",0,[](Fl_Widget *,void *c){static_cast(c)->ripple_dialog(false);},sequencer_); + menu->add("Edit/Time/Remove range...",0,[](Fl_Widget *,void *c){static_cast(c)->ripple_dialog(true);},sequencer_); menu->add("Edit/Markers/Set at cursor...",0,[](Fl_Widget *,void *c){static_cast(c)->marker_dialog();},sequencer_); menu->add("Edit/Markers/Remove at cursor",0,[](Fl_Widget *,void *c){static_cast(c)->marker(nullptr);},sequencer_); menu->add("Edit/Select all clips",0,[](Fl_Widget *,void *c){static_cast(c)->select_all_clips();},sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index f4f2462..b149ffe 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -519,6 +519,19 @@ int SequencerWidget::handle(int event) return Fl_Group::handle(event); } +xtc_result SequencerWidget::ripple_time(uint64_t length,bool remove) +{ + const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_ripple_time(project_,at,length,remove); + changed(r);return r; +} +void SequencerWidget::ripple_dialog(bool remove) +{ + const char *input=fl_input(remove ? "Remove time from cursor: ticks (split crossing clips first)" : "Insert time at cursor: ticks (split crossing clips first)","3840");if(!input)return; + uint64_t length=0;char extra;std::istringstream values(input); + if(!(values>>length) || (values>>extra) || !length || length>XTC_PROJECT_MAX_TICK){fl_alert("Enter a positive bounded tick duration.");return;} + ripple_time(length,remove); +} xtc_result SequencerWidget::marker(const char *name) { const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 3ef4e3e..fc2bbfe 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -33,6 +33,8 @@ class SequencerWidget : public Fl_Group { Fl_Choice *instrument_choice(uint64_t track) const; void refresh_instruments(); bool select(uint64_t track, uint64_t clip = UINT64_MAX); + xtc_result ripple_time(uint64_t length,bool remove); + void ripple_dialog(bool remove); xtc_result marker(const char *name); /* NULL removes at cursor. */ void marker_dialog(); bool toggle_clip(uint64_t clip); diff --git a/src/project/sections.c b/src/project/sections.c index df4909b..0ffcd85 100644 --- a/src/project/sections.c +++ b/src/project/sections.c @@ -1,4 +1,6 @@ #include "project_internal.h" +#include "../graph/graph_internal.h" +#include "xtc/xtc_editing.h" #include xtc_result xtc_project_marker_count(const xtc_project *p,size_t *out) @@ -50,3 +52,43 @@ xtc_result xtc_project_section(const xtc_project *p,uint64_t tick,uint64_t *begi if(finish<=p->markers[i-1].tick)return XTC_ERR_INVALID_STATE; *begin=p->markers[i-1].tick;*end=finish;return XTC_OK; } + +static xtc_result ripple_tick(uint64_t *tick,uint64_t begin,uint64_t length,int remove,int *keep) +{ + *keep=1; + if(*tickXTC_PROJECT_MAX_TICK-length)return XTC_ERR_INVALID_ARGUMENT;*tick+=length;} + return XTC_OK; +} +static xtc_result ripple_plugin(xtc_plugin_spec *spec,uint64_t begin,uint64_t length,int remove) +{ + unsigned int i,n=0;int keep;xtc_result r; + for(i=0;ipoint_count;++i) { + xtc_plugin_point point=spec->points[i];r=ripple_tick(&point.tick,begin,length,remove,&keep); + if(r!=XTC_OK)return r; + if(keep)spec->points[n++]=point; + } + spec->point_count=n;return XTC_OK; +} +xtc_result xtc_project_ripple_time(xtc_project *p,uint64_t begin,uint64_t length,int remove) +{ + xtc_project *copy=NULL;size_t i,n;int keep;xtc_result r; + if(!p || !length || begin>XTC_PROJECT_MAX_TICK || length>XTC_PROJECT_MAX_TICK-begin || (remove!=0 && remove!=1))return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_clone(p,©);if(r!=XTC_OK)return r; + for(i=copy->clip_count;r==XTC_OK && i>0;--i) { + xtc_clip_info c=copy->clips[i-1];uint64_t end=c.start_tick+c.length_tick; + if((c.start_tickbegin) || (remove && c.start_tickbegin+length)){r=XTC_ERR_INVALID_STATE;break;} + r=ripple_tick(&c.start_tick,begin,length,remove,&keep); + if(r==XTC_OK)r=keep ? xtc_project_edit_clip(copy,c.id,c.track_id,c.start_tick,c.length_tick,c.offset_tick) : xtc_project_remove_clip(copy,c.id); + } + n=0; + for(i=0;r==XTC_OK && imarker_count;++i){xtc_marker m=copy->markers[i];r=ripple_tick(&m.tick,begin,length,remove,&keep);if(r==XTC_OK && keep)copy->markers[n++]=m;} + copy->marker_count=n;n=0; + for(i=0;r==XTC_OK && iautomation_count;++i){xtc_automation_point a=copy->automation[i];r=ripple_tick(&a.tick,begin,length,remove,&keep);if(r==XTC_OK && keep)copy->automation[n++]=a;} + copy->automation_count=n; + for(i=0;r==XTC_OK && itrack_count;++i)r=ripple_plugin(copy->instruments+i,begin,length,remove); + for(i=0;r==XTC_OK && igraph->node_count;++i)if(copy->graph->nodes[i].type==XTC_NODE_PLUGIN)r=ripple_plugin(©->graph->nodes[i].plugin,begin,length,remove); + if(r==XTC_OK){xtc_graph *old=p->graph;*p=*copy;copy->graph=old;} + xtc_project_destroy(copy);return r; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 7dfdaa0..2aad622 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1373,6 +1373,9 @@ int main(int argc, char **argv) CHECK(window.navigate_marker(1)==XTC_OK && s->position()==960); CHECK(window.loop_section()==XTC_OK);CHECK(window.navigate_marker(1)==XTC_OK && s->position()==1920); CHECK(window.navigate_marker(-1)==XTC_OK && s->position()==960); + s->position(3840);CHECK(s->ripple_time(960,false)==XTC_OK); + xtc_marker moved{};xtc_project_get_marker(s->project(),1,&moved);CHECK(moved.tick==1920); + CHECK(window.undo()==XTC_OK); window.do_callback(); } if (failures != 0) return 1; diff --git a/tests/unit/test_next_daw.c b/tests/unit/test_next_daw.c index 89a36dd..1f35a2f 100644 --- a/tests/unit/test_next_daw.c +++ b/tests/unit/test_next_daw.c @@ -46,4 +46,22 @@ static void sections(void) assert(xtc_project_set_marker(p,9000,"Empty")==XTC_OK && xtc_project_section(p,9000,&begin,&end)==XTC_ERR_INVALID_STATE); xtc_project_destroy(p); } -int main(void){markers();sections();puts("Next DAW workflow tests passed.");return 0;} +static void ripple(void) +{ + xtc_project *p=NULL;uint64_t t,c,a;xtc_clip_info clip;xtc_marker m;xtc_automation_point point; + assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Track",&t)==XTC_OK); + assert(xtc_project_add_clip(p,t,"A",0,960,&a)==XTC_OK && xtc_project_add_clip(p,t,"B",1920,960,&c)==XTC_OK); + point=(xtc_automation_point){t,1920,XTC_AUTOMATION_GAIN,.5f,0};assert(xtc_project_set_automation(p,&point)==XTC_OK); + assert(xtc_project_set_marker(p,1920,"B")==XTC_OK); + assert(xtc_project_ripple_time(p,480,960,0)==XTC_ERR_INVALID_STATE); + assert(xtc_project_ripple_time(p,960,960,0)==XTC_OK); + assert(xtc_project_find_clip(p,c,&clip)==XTC_OK && clip.start_tick==2880); + assert(xtc_project_get_marker(p,0,&m)==XTC_OK && m.tick==2880); + assert(xtc_project_get_automation(p,0,&point)==XTC_OK && point.tick==2880); + assert(xtc_project_ripple_time(p,960,1920,1)==XTC_OK); + assert(xtc_project_find_clip(p,c,&clip)==XTC_OK && clip.start_tick==960); + assert(xtc_project_ripple_time(p,0,960,1)==XTC_OK && xtc_project_find_clip(p,a,&clip)==XTC_ERR_NOT_FOUND); + assert(xtc_project_find_clip(p,c,&clip)==XTC_OK && clip.start_tick==0); + xtc_project_destroy(p); +} +int main(void){markers();sections();ripple();puts("Next DAW workflow tests passed.");return 0;}