feat: trim audio clips from sample editor ranges with conservative tick alignment

This commit is contained in:
Johannes Findeisen 2026-11-06 01:02:08 +01:00
commit 3d031a0010
10 changed files with 59 additions and 6 deletions

View file

@ -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 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 ±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. 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.

View file

@ -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. 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 ### Tasks and acceptance criteria
- [ ] Implement bounded C99 analysis/processing and accessible FLTK controls. - [x] Implement bounded C99 analysis/processing and accessible FLTK controls.
- [ ] Preserve source files, atomic error handling, history and callback safety. - [x] Preserve source files, atomic error handling, history and callback safety.
- [ ] Pass meaningful core and GUI checks; document exact behavior and limits. - [x] Pass meaningful core and GUI checks; document exact behavior and limits.
### Implementation status ### 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.
--- ---

View file

@ -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_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_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); 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 #ifdef __cplusplus
} }
#endif #endif

View file

@ -25,3 +25,15 @@ xtc_result xtc_sample_analyze(const xtc_sample *s,size_t begin,size_t end,xtc_sa
for(i=begin;i<end;++i){float v=s->wave.samples[i];if(v<result.minimum)result.minimum=v;if(v>result.maximum)result.maximum=v;sum+=v;square+=(double)v*v;} for(i=begin;i<end;++i){float v=s->wave.samples[i];if(v<result.minimum)result.minimum=v;if(v>result.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; 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||first<c.offset_tick||last>c.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);
}

View file

@ -2597,6 +2597,15 @@ xtc_result MainWindow::collect_project(const char *directory)
BusyOperation busy(*this,status_,"Collecting project media…"); BusyOperation busy(*this,status_,"Collecting project media…");
return xtc_project_collect(project_.get(),directory); 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() xtc_result MainWindow::open_sample_editor()
{ {
if(recorder_)return XTC_ERR_INVALID_STATE; if(recorder_)return XTC_ERR_INVALID_STATE;
@ -2604,7 +2613,12 @@ xtc_result MainWindow::open_sample_editor()
if(r!=XTC_OK)return r; if(r!=XTC_OK)return r;
if(clip.midi||!clip.asset_path[0])return XTC_ERR_INVALID_STATE; 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; 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<size_t>(std::min(first,static_cast<uint64_t>(frames))),static_cast<size_t>(std::min(last,static_cast<uint64_t>(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() void MainWindow::show_commands()
{ {

View file

@ -61,6 +61,7 @@ class MainWindow : public Fl_Double_Window {
Fl_Widget *velocity_lane() const { return velocity_lane_; } Fl_Widget *velocity_lane() const { return velocity_lane_; }
/* Control-thread plugin commands, transactional model adoption. */ /* Control-thread plugin commands, transactional model adoption. */
xtc_result assign_instrument(uint64_t track, const char *path, unsigned int index); 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(); xtc_result open_sample_editor();
void export_audio(); void export_audio();
xtc_result load_sampler(uint64_t track, const char *path); xtc_result load_sampler(uint64_t track, const char *path);

View file

@ -5,6 +5,7 @@
#include <FL/Fl_Button.H> #include <FL/Fl_Button.H>
#include <FL/Fl_Box.H> #include <FL/Fl_Box.H>
#include <FL/fl_draw.H> #include <FL/fl_draw.H>
#include <FL/fl_ask.H>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
@ -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<SampleEditor *>(c);e->view(e->begin_,std::min(64U,e->zoom_*2));},this); auto *plus=new Fl_Button(750,58,100,28,"Zoom in");plus->callback([](Fl_Widget *,void *c){auto *e=static_cast<SampleEditor *>(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<SampleEditor *>(c);e->view(e->origin_,std::max(1U,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<SampleEditor *>(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); 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<SampleEditor *>(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<SampleEditor *>(c)->hide();},this);callback([](Fl_Widget *,void *c){static_cast<SampleEditor *>(c)->hide();},this); auto *close=new Fl_Button(970,604,110,30,"Close");close->callback([](Fl_Widget *,void *c){static_cast<SampleEditor *>(c)->hide();},this);callback([](Fl_Widget *,void *c){static_cast<SampleEditor *>(c)->hide();},this);
Fl_Group::end();xtc_ui::style(this);refresh(); 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;} 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<double>(begin_));end_input_->value(static_cast<double>(end_));origin_input_->value(static_cast<double>(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<double>(end_-begin_)/rate_,zoom_,s.minimum,s.maximum,s.rms,s.mean);stats_->copy_label(text);canvas_->redraw();} void SampleEditor::refresh(){begin_input_->value(static_cast<double>(begin_));end_input_->value(static_cast<double>(end_));origin_input_->value(static_cast<double>(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<double>(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();} void SampleEditor::run(){show();while(shown())Fl::wait();}
xtc_result SampleEditor::trim_clip(){return trim ? trim(rate_,begin_,end_) : XTC_ERR_INVALID_STATE;}

View file

@ -11,6 +11,8 @@ class SampleEditor : public Fl_Double_Window {
public: public:
/* Takes ownership of loaded snapshot. Modal edits never touch audio callbacks. */ /* Takes ownership of loaded snapshot. Modal edits never touch audio callbacks. */
explicit SampleEditor(xtc_sample *sample); explicit SampleEditor(xtc_sample *sample);
std::function<xtc_result(unsigned int,size_t,size_t)> trim;
xtc_result trim_clip();
void run(); void run();
xtc_result select(size_t begin,size_t end); xtc_result select(size_t begin,size_t end);
xtc_result view(size_t begin,unsigned int zoom); xtc_result view(size_t begin,unsigned int zoom);

View file

@ -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)); 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); CHECK(window.import_audio(path)==XTC_OK);
Fl::add_timeout(.1,[](void *){auto *e=dynamic_cast<SampleEditor *>(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); Fl::add_timeout(.1,[](void *){auto *e=dynamic_cast<SampleEditor *>(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) int main(int argc, char **argv)
{ {

View file

@ -18,5 +18,12 @@ int main(void)
assert(xtc_sample_read(s,2,&v)==XTC_OK && v==-.5f); 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_analyze(s,2,2,&stats)==XTC_ERR_INVALID_ARGUMENT);
assert(xtc_sample_load("/missing/xtc.wav",&sentinel)==XTC_ERR_IO && sentinel==NULL); 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; xtc_sample_destroy(s);assert(unlink(path)==0 && rmdir(root)==0);puts("Sample editor core tests passed.");return 0;
} }