From 310b81ec4deb8ccd1f1bb92826908d48025d4c48 Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 00:20:37 +0100 Subject: [PATCH] feat: apply reversible boundary fades across audio clip selections --- docs/MODERN_EDITING.md | 4 ++++ docs/ROADMAP.md | 8 ++++---- include/xtc/xtc_editing.h | 4 +++- src/gui/main_window.cpp | 2 ++ src/gui/sequencer_widget.cpp | 7 +++++++ src/gui/sequencer_widget.hpp | 1 + src/project/editing.c | 6 +++++- tests/integration/test_gui.cpp | 4 ++++ tests/unit/test_modern_editing.c | 18 +++++++++++++++++- 9 files changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/MODERN_EDITING.md b/docs/MODERN_EDITING.md index 15b2181..84843ff 100644 --- a/docs/MODERN_EDITING.md +++ b/docs/MODERN_EDITING.md @@ -39,3 +39,7 @@ Edit/MIDI tools/Correct to scale takes a root (0=C through 11=B) and chromatic, ## 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. + +## Phase 56 + +Edit/Audio tools/Set boundary fades sets linear fade-in and fade-out lengths in ticks; 0 or Remove boundary fades clears them. Each fade must fit within half of every selected clip duration. Gain and source offsets stay unchanged. These are boundary envelopes, not overlap-aware crossfades, equal-power curves or destructive WAV processing. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fb804e8..5282088 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1453,12 +1453,12 @@ Implemented: rounded velocity percentage and offset transforms with explicit 1.. Apply symmetric boundary fades or remove fades on selected audio clips, preserving clip gain, media and timing. ### Tasks and acceptance criteria -- [ ] Provide the control/model behavior and accessible GUI commands. -- [ ] Validate bounds and failure rollback; preserve existing project compatibility and callback safety. -- [ ] Test the new workflow and document scope and limitations. +- [x] Provide the control/model behavior and accessible GUI commands. +- [x] Validate bounds and failure rollback; preserve existing project compatibility and callback safety. +- [x] Test the new workflow and document scope and limitations. ### Implementation status -Planned. +Implemented: selection-wide symmetric boundary fade and remove-fade tools preserve gain, references and timing. Fade lengths must fit half every current clip duration; mixed MIDI selections reject atomically. Core late-failure/property tests, existing audio rendering tests and focused GUI fade workflows pass. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index 48a15f3..3b0dacf 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -11,8 +11,10 @@ extern "C" { * DUPLICATE retains tracks, source ranges, notes and media with fresh IDs. * ALIGN amount is an absolute timeline start; SLIP amount is a signed source * offset change (audio or MIDI), retaining timeline start/length. + * FADE sets both audio boundary fades to amount ticks (0 removes); each must + * fit within half the current clip duration. MIDI clips reject this operation. * out_ids receives resulting IDs (removed IDs for DELETE). May be NULL. */ -typedef enum { XTC_CLIPS_MOVE, XTC_CLIPS_DUPLICATE, XTC_CLIPS_DELETE, XTC_CLIPS_ALIGN, XTC_CLIPS_SLIP } xtc_clip_operation; +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); /* MIDI transforms affect ALL source notes in selected MIDI clips, including diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 9bacd90..9b31f38 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -322,6 +322,8 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) Fl_Group::current(parent); menu->add("Edit/Nudge selection left",0,[](Fl_Widget *,void *c){static_cast(c)->nudge_selection(-1);},sequencer_); menu->add("Edit/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast(c)->nudge_selection(1);},sequencer_); + menu->add("Edit/Audio tools/Set boundary fades...",0,[](Fl_Widget *,void *c){static_cast(c)->fades_dialog();},sequencer_); + 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/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_); menu->add("Edit/MIDI tools/Note gate...",0,[](Fl_Widget *,void *c){static_cast(c)->gate_dialog();},sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 3e3eb50..71bdd36 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -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); changed(r);return r; } +void SequencerWidget::fades_dialog() +{ + const char *input=fl_input("Symmetric fade length in ticks (0 removes; at most half each clip)","10");if(!input)return; + int64_t ticks=0;char extra;std::istringstream values(input); + if(!(values>>ticks) || (values>>extra) || ticks<0 || ticks>static_cast(XTC_PROJECT_MAX_TICK)){fl_alert("Enter a valid nonnegative tick length.");return;} + transform_selection(XTC_CLIPS_FADE,ticks); +} void SequencerWidget::velocity_dialog() { const char *input=fl_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 03c6ed5..cfa9090 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -43,6 +43,7 @@ class SequencerWidget : public Fl_Group { void gate_dialog(); void scale_dialog(); void velocity_dialog(); + void fades_dialog(); xtc_result repeat_selection(unsigned int copies); void repeat_dialog(); xtc_result copy_selection(); diff --git a/src/project/editing.c b/src/project/editing.c index cc58738..7f292fc 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -1,5 +1,6 @@ #include "project_internal.h" #include "xtc/xtc_editing.h" +#include "xtc/xtc_audio.h" #include #include @@ -23,7 +24,7 @@ xtc_result xtc_project_transform_clips(xtc_project *p,const uint64_t *ids,size_t xtc_project *copy=NULL;size_t i;uint64_t output[XTC_PROJECT_MAX_CLIPS]; xtc_result r=selection_valid(p,ids,count); if(r!=XTC_OK)return r; - if(operationXTC_CLIPS_SLIP || + if(operationXTC_CLIPS_FADE || amount<-(int64_t)XTC_PROJECT_MAX_TICK || amount>(int64_t)XTC_PROJECT_MAX_TICK)return XTC_ERR_INVALID_ARGUMENT; r=xtc_project_clone(p,©); for(i=0;r==XTC_OK && ic.length_tick/2 ? XTC_ERR_INVALID_ARGUMENT : xtc_project_set_clip_audio(copy,ids[i],c.gain,(uint64_t)amount,(uint64_t)amount); + } else if(operation==XTC_CLIPS_ALIGN) { r=amount<0 ? XTC_ERR_INVALID_ARGUMENT : xtc_project_edit_clip(copy,ids[i],c.track_id,(uint64_t)amount,c.length_tick,c.offset_tick); } else if(operation==XTC_CLIPS_SLIP) { diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 1c44742..9752699 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1344,6 +1344,10 @@ int main(int argc, char **argv) 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); + CHECK(window.create_track(false)==XTC_OK && s->add_region()==XTC_OK); + CHECK(s->transform_selection(XTC_CLIPS_FADE,10)==XTC_OK); + CHECK(xtc_project_find_clip(s->project(),s->selected_clip(),&moved)==XTC_OK && moved.fade_in_tick==10 && moved.fade_out_tick==10); + CHECK(s->transform_selection(XTC_CLIPS_FADE,0)==XTC_OK); window.do_callback(); } if (failures != 0) return 1; diff --git a/tests/unit/test_modern_editing.c b/tests/unit/test_modern_editing.c index a063b2b..f4caabd 100644 --- a/tests/unit/test_modern_editing.c +++ b/tests/unit/test_modern_editing.c @@ -1,7 +1,23 @@ #include "xtc/xtc_editing.h" +#include "xtc/xtc_audio.h" #include #include +static void test_fades(void) +{ + xtc_project *p=NULL;uint64_t t,ids[2];xtc_clip_info c; + 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,&ids[0])==XTC_OK); + assert(xtc_project_add_clip(p,t,"B",960,240,&ids[1])==XTC_OK); + assert(xtc_project_set_clip_audio(p,ids[0],0.5f,0,0)==XTC_OK); + assert(xtc_project_transform_clips(p,ids,2,XTC_CLIPS_FADE,240,NULL)==XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_find_clip(p,ids[0],&c)==XTC_OK && c.fade_in_tick==0); + assert(xtc_project_transform_clips(p,ids,2,XTC_CLIPS_FADE,120,NULL)==XTC_OK); + assert(xtc_project_find_clip(p,ids[0],&c)==XTC_OK && c.gain==0.5f && c.fade_in_tick==120 && c.fade_out_tick==120 && c.start_tick==0); + assert(xtc_project_transform_clips(p,ids,2,XTC_CLIPS_FADE,0,NULL)==XTC_OK); + assert(xtc_project_find_clip(p,ids[1],&c)==XTC_OK && c.fade_in_tick==0 && c.fade_out_tick==0); + xtc_project_destroy(p); +} static void test_velocity(void) { xtc_project *p=NULL;uint64_t t,c,n;xtc_note_info note;xtc_midi_transform edit={0}; @@ -106,7 +122,7 @@ static void test_repeat(void) } int main(void) { - test_repeat();test_align_slip();test_swing();test_gate_legato();test_scale();test_velocity(); + test_repeat();test_align_slip();test_swing();test_gate_legato();test_scale();test_velocity();test_fades(); 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; assert(xtc_project_create(&p)==XTC_OK);