From 3d031a0010ca1c89db908ccb360b8624557eb085 Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 01:02:08 +0100 Subject: [PATCH] feat: trim audio clips from sample editor ranges with conservative tick alignment --- docs/AUDIO_EDITOR.md | 4 ++++ docs/ROADMAP.md | 8 ++++---- include/xtc/xtc_sample.h | 5 +++++ src/audio/sample.c | 12 ++++++++++++ src/gui/main_window.cpp | 16 +++++++++++++++- src/gui/main_window.hpp | 1 + src/gui/sample_editor.cpp | 4 ++++ src/gui/sample_editor.hpp | 2 ++ tests/integration/test_gui.cpp | 6 +++++- tests/unit/test_sample.c | 7 +++++++ 10 files changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/AUDIO_EDITOR.md b/docs/AUDIO_EDITOR.md index fc0fe7c..b103101 100644 --- a/docs/AUDIO_EDITOR.md +++ b/docs/AUDIO_EDITOR.md @@ -13,3 +13,7 @@ editor loads an independent snapshot of the complete source, up to 4,194,304 fra RMS and mean/DC before clip gain, fades or mixer processing. Display clamps to ±1; sample statistics retain finite over-range values. Editing project MIDI still opens the piano roll. This editor does not audition or stream long files. + +## Phase 70 + +Trim clip to selection applies source-frame endpoints within the current audible clip. Start rounds up and end rounds down to musical ticks; empty sub-tick ranges reject. Leading removal moves the timeline start by the same amount, preserving alignment. Source content, reference and clip gain/fade settings remain intact; project Undo restores the prior trim. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index effe385..be3be12 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1702,12 +1702,12 @@ Implemented: bounded C99 source snapshots, per-range min/max/RMS/DC analysis and Apply sample-editor selection as audible clip trims with conservative frame-to-musical-tick rounding, preserved source content and one project undo step. ### Tasks and acceptance criteria -- [ ] Implement bounded C99 analysis/processing and accessible FLTK controls. -- [ ] Preserve source files, atomic error handling, history and callback safety. -- [ ] Pass meaningful core and GUI checks; document exact behavior and limits. +- [x] Implement bounded C99 analysis/processing and accessible FLTK controls. +- [x] Preserve source files, atomic error handling, history and callback safety. +- [x] Pass meaningful core and GUI checks; document exact behavior and limits. ### Implementation status -Planned. +Implemented: native frame bounds trim audio inside its current audible region with conservative tick rounding and preserved timeline/source alignment. Core rounding/rejection/unchanged-state checks and GUI trim/project undo pass. --- diff --git a/include/xtc/xtc_sample.h b/include/xtc/xtc_sample.h index 422e63b..cadc5d1 100644 --- a/include/xtc/xtc_sample.h +++ b/include/xtc/xtc_sample.h @@ -15,6 +15,11 @@ void xtc_sample_destroy(xtc_sample *sample); xtc_result xtc_sample_info(const xtc_sample *sample,unsigned int *rate,size_t *frames); xtc_result xtc_sample_read(const xtc_sample *sample,size_t frame,float *out); xtc_result xtc_sample_analyze(const xtc_sample *sample,size_t begin,size_t end,xtc_sample_stats *out); +/* Select native source frame bounds inside the clip's current audible trim. + * Start rounds up to a tick; end rounds down. Sub-tick empty ranges reject. + * Timeline placement follows the removed leading source, preserving alignment. + * No media I/O, source content or fade changes; caller supplies source rate. */ +xtc_result xtc_project_trim_source_frames(xtc_project *project,uint64_t clip,unsigned int rate,size_t begin,size_t end); #ifdef __cplusplus } #endif diff --git a/src/audio/sample.c b/src/audio/sample.c index 03144c6..fe45a51 100644 --- a/src/audio/sample.c +++ b/src/audio/sample.c @@ -25,3 +25,15 @@ xtc_result xtc_sample_analyze(const xtc_sample *s,size_t begin,size_t end,xtc_sa for(i=begin;iwave.samples[i];if(vresult.maximum)result.maximum=v;sum+=v;square+=(double)v*v;} result.mean=sum/(double)(end-begin);result.rms=sqrt(square/(double)(end-begin));*out=result;return XTC_OK; } + +xtc_result xtc_project_trim_source_frames(xtc_project *p,uint64_t id,unsigned int rate,size_t begin,size_t end) +{ + xtc_clip_info c;uint64_t first,last,denom,factor;xtc_result r; + if(!p||!rate||rate>768000||begin>=end||end>XTC_SAMPLE_MAX_FRAMES)return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_find_clip(p,id,&c);if(r!=XTC_OK)return r; + if(c.midi||!c.asset_path[0])return XTC_ERR_INVALID_STATE; + factor=(uint64_t)xtc_project_tempo(p)*XTC_TICKS_PER_QUARTER;denom=(uint64_t)rate*60000U; + first=((uint64_t)begin*factor+denom-1)/denom;last=(uint64_t)end*factor/denom; + if(first>=last||firstc.offset_tick+c.length_tick)return XTC_ERR_INVALID_ARGUMENT; + return xtc_project_edit_clip(p,id,c.track_id,c.start_tick+first-c.offset_tick,last-first,first); +} diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index ba8b520..3448f69 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -2597,6 +2597,15 @@ xtc_result MainWindow::collect_project(const char *directory) BusyOperation busy(*this,status_,"Collecting project media…"); return xtc_project_collect(project_.get(),directory); } +xtc_result MainWindow::trim_sample_clip(uint64_t id,unsigned int rate,size_t begin,size_t end) +{ + auto r=before_arrangement_edit(this);if(r!=XTC_OK)return r; + xtc_project *candidate=nullptr;r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK)r=xtc_project_trim_source_frames(candidate,id,rate,begin,end); + if(r==XTC_OK)r=install_project(candidate,false); + if(r!=XTC_OK){xtc_project_destroy(candidate);return r;} + xtc_clip_info clip{};xtc_project_find_clip(project_.get(),id,&clip);sequencer_->select(clip.track_id,id);mark_dirty();return XTC_OK; +} xtc_result MainWindow::open_sample_editor() { if(recorder_)return XTC_ERR_INVALID_STATE; @@ -2604,7 +2613,12 @@ xtc_result MainWindow::open_sample_editor() if(r!=XTC_OK)return r; if(clip.midi||!clip.asset_path[0])return XTC_ERR_INVALID_STATE; xtc_sample *sample=nullptr;r=xtc_sample_load(clip.asset_path,&sample);if(r!=XTC_OK)return r; - SampleEditor editor(sample);editor.run();return XTC_OK; + SampleEditor editor(sample); + uint64_t first=0,last=0;unsigned int rate;size_t frames;xtc_sample_info(sample,&rate,&frames); + if(xtc_project_tick_to_sample(project_.get(),clip.offset_tick,rate,&first)==XTC_OK && xtc_project_tick_to_sample(project_.get(),clip.offset_tick+clip.length_tick,rate,&last)==XTC_OK) + editor.select(static_cast(std::min(first,static_cast(frames))),static_cast(std::min(last,static_cast(frames)))); + editor.trim=[this,id=clip.id](unsigned int sr,size_t b,size_t e){return trim_sample_clip(id,sr,b,e);}; + editor.run();return XTC_OK; } void MainWindow::show_commands() { diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index e987f9f..8ab9e16 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -61,6 +61,7 @@ class MainWindow : public Fl_Double_Window { Fl_Widget *velocity_lane() const { return velocity_lane_; } /* Control-thread plugin commands, transactional model adoption. */ xtc_result assign_instrument(uint64_t track, const char *path, unsigned int index); + xtc_result trim_sample_clip(uint64_t clip,unsigned int rate,size_t begin,size_t end); xtc_result open_sample_editor(); void export_audio(); xtc_result load_sampler(uint64_t track, const char *path); diff --git a/src/gui/sample_editor.cpp b/src/gui/sample_editor.cpp index 69c2f84..3adf8e4 100644 --- a/src/gui/sample_editor.cpp +++ b/src/gui/sample_editor.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,7 @@ SampleEditor::SampleEditor(xtc_sample *sample):Fl_Double_Window(1100,650,"xtc auto *plus=new Fl_Button(750,58,100,28,"Zoom in");plus->callback([](Fl_Widget *,void *c){auto *e=static_cast(c);e->view(e->begin_,std::min(64U,e->zoom_*2));},this); auto *minus=new Fl_Button(860,58,100,28,"Zoom out");minus->callback([](Fl_Widget *,void *c){auto *e=static_cast(c);e->view(e->origin_,std::max(1U,e->zoom_/2));},this); canvas_=new SampleCanvas(16,104,1068,340,*this);stats_=new Fl_Box(16,452,1068,50);stats_->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE); + auto *trim_button=new Fl_Button(16,520,180,30,"Trim clip to selection");trim_button->callback([](Fl_Widget *,void *c){auto r=static_cast(c)->trim_clip();if(r!=XTC_OK)fl_alert("Clip trim unchanged: %s",xtc_result_string(r));},this); auto *close=new Fl_Button(970,604,110,30,"Close");close->callback([](Fl_Widget *,void *c){static_cast(c)->hide();},this);callback([](Fl_Widget *,void *c){static_cast(c)->hide();},this); Fl_Group::end();xtc_ui::style(this);refresh(); } @@ -44,3 +46,5 @@ xtc_result SampleEditor::select(size_t b,size_t e){if(b>=e||e>frames_)return XTC xtc_result SampleEditor::view(size_t b,unsigned int zoom){if(b>=frames_||!zoom||zoom>64)return XTC_ERR_INVALID_ARGUMENT;origin_=b;zoom_=zoom;refresh();return XTC_OK;} void SampleEditor::refresh(){begin_input_->value(static_cast(begin_));end_input_->value(static_cast(end_));origin_input_->value(static_cast(origin_));xtc_sample_stats s{};xtc_sample_analyze(sample_.get(),begin_,end_,&s);char text[256];std::snprintf(text,sizeof(text),"%u Hz / %zu source frames / selection %zu..%zu (%0.6f s) / zoom %ux\nMin %.6f Max %.6f RMS %.6f DC %.6f",rate_,frames_,begin_,end_,static_cast(end_-begin_)/rate_,zoom_,s.minimum,s.maximum,s.rms,s.mean);stats_->copy_label(text);canvas_->redraw();} void SampleEditor::run(){show();while(shown())Fl::wait();} + +xtc_result SampleEditor::trim_clip(){return trim ? trim(rate_,begin_,end_) : XTC_ERR_INVALID_STATE;} diff --git a/src/gui/sample_editor.hpp b/src/gui/sample_editor.hpp index 2221ae6..4092ee8 100644 --- a/src/gui/sample_editor.hpp +++ b/src/gui/sample_editor.hpp @@ -11,6 +11,8 @@ class SampleEditor : public Fl_Double_Window { public: /* Takes ownership of loaded snapshot. Modal edits never touch audio callbacks. */ explicit SampleEditor(xtc_sample *sample); + std::function trim; + xtc_result trim_clip(); void run(); xtc_result select(size_t begin,size_t end); xtc_result view(size_t begin,unsigned int zoom); diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 8e2599e..1ea1403 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -195,7 +195,11 @@ static int sample_editor_tests() MainWindow window(false,"");window.show();auto *s=timeline(&window);char path[4096];CHECK(realpath("examples/projects/original-pluck.wav",path)); CHECK(window.import_audio(path)==XTC_OK); Fl::add_timeout(.1,[](void *){auto *e=dynamic_cast(Fl::modal());CHECK(e!=nullptr);if(e){CHECK(e->select(10,100)==XTC_OK);CHECK(e->select(100,10)==XTC_ERR_INVALID_ARGUMENT);CHECK(e->view(10,8)==XTC_OK);CHECK(e->view(e->frames(),1)==XTC_ERR_INVALID_ARGUMENT);e->hide();}},nullptr); - CHECK(window.open_sample_editor()==XTC_OK);CHECK(s->selected_clip()!=UINT64_MAX);window.do_callback();return failures ? 1 : 0; + CHECK(window.open_sample_editor()==XTC_OK);CHECK(s->selected_clip()!=UINT64_MAX); + uint64_t id=s->selected_clip();CHECK(window.trim_sample_clip(id,48000,2400,12000)==XTC_OK); + xtc_clip_info clip{};CHECK(xtc_project_find_clip(s->project(),id,&clip)==XTC_OK && clip.offset_tick==96 && clip.length_tick==384); + CHECK(window.undo()==XTC_OK);CHECK(xtc_project_find_clip(s->project(),id,&clip)==XTC_OK && clip.offset_tick==0); + window.do_callback();return failures ? 1 : 0; } int main(int argc, char **argv) { diff --git a/tests/unit/test_sample.c b/tests/unit/test_sample.c index dc7a581..386fcb0 100644 --- a/tests/unit/test_sample.c +++ b/tests/unit/test_sample.c @@ -18,5 +18,12 @@ int main(void) assert(xtc_sample_read(s,2,&v)==XTC_OK && v==-.5f); assert(xtc_sample_analyze(s,2,2,&stats)==XTC_ERR_INVALID_ARGUMENT); assert(xtc_sample_load("/missing/xtc.wav",&sentinel)==XTC_ERR_IO && sentinel==NULL); + {xtc_project *p=NULL;uint64_t t,c;xtc_clip_info clip;assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Audio",&t)==XTC_OK); + assert(xtc_project_add_clip(p,t,"Audio",10,960,&c)==XTC_OK && xtc_project_set_clip_asset(p,c,path)==XTC_OK); + assert(xtc_project_trim_source_frames(p,c,48000,1000,2000)==XTC_OK); + assert(xtc_project_find_clip(p,c,&clip)==XTC_OK && clip.start_tick==50 && clip.offset_tick==40 && clip.length_tick==40); + assert(xtc_project_trim_source_frames(p,c,48000,1001,1002)==XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_find_clip(p,c,&clip)==XTC_OK && clip.offset_tick==40 && clip.length_tick==40); + xtc_project_destroy(p);} xtc_sample_destroy(s);assert(unlink(path)==0 && rmdir(root)==0);puts("Sample editor core tests passed.");return 0; }