From 392e437d32f3061c8904bfba44eb2d5c90296d37 Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 01:08:01 +0100 Subject: [PATCH] feat: select audible sample boundaries with threshold and padding controls --- docs/AUDIO_EDITOR.md | 4 ++++ docs/ROADMAP.md | 8 ++++---- include/xtc/xtc_sample.h | 4 ++++ src/audio/sample.c | 11 +++++++++++ src/gui/sample_editor.cpp | 3 +++ src/gui/sample_editor.hpp | 1 + tests/integration/test_gui.cpp | 2 +- tests/unit/test_sample.c | 4 ++++ 8 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/AUDIO_EDITOR.md b/docs/AUDIO_EDITOR.md index 145d974..d99c761 100644 --- a/docs/AUDIO_EDITOR.md +++ b/docs/AUDIO_EDITOR.md @@ -41,3 +41,7 @@ Sample fades offers four offline curves: linear in/out and equal-power in/out (s ## Phase 76 Gain / normalize processes only selected samples. Mode 0 accepts linear gain 0..16; mode 1 accepts a linear peak target above 0 and at most 1, rejecting silent selections or required gain above 16. Finite values above unity are not clipped; float overflow rejects atomically. This changes sample data in the local snapshot, unlike the existing clip-gain normalization command; it provides neither true-peak nor LUFS processing. + +## Phase 77 + +Select audible bounds finds the first and last sample strictly above the chosen dBFS threshold and pads within the existing selection. It only changes selection; use Trim clip to selection to apply a nondestructive timeline trim, or export the selection. Entirely silent/below-threshold ranges leave selection unchanged. This is boundary detection, not automatic removal of internal gaps or a noise gate. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8273436..af56720 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1807,12 +1807,12 @@ Implemented: selected sample gain (0..16) and peak normalization (target >0..1, Find audible selected-range bounds using a user threshold and padding; select detected bounds for export or nondestructive clip trimming. Silence is an explicit no-result error. ### 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: threshold-based audible-range detection with bounded padding and atomic selection updates. Exact threshold/padding, silent no-result, NaN and GUI unchanged/successful selection checks pass. --- diff --git a/include/xtc/xtc_sample.h b/include/xtc/xtc_sample.h index f3491e9..4a3e7d9 100644 --- a/include/xtc/xtc_sample.h +++ b/include/xtc/xtc_sample.h @@ -22,6 +22,10 @@ typedef enum { XTC_SAMPLE_REVERSE, XTC_SAMPLE_INVERT, XTC_SAMPLE_REMOVE_DC, XTC_ * One-frame fades reject; fade endpoints are exactly 0 and 1. Failure preserves *out. */ xtc_result xtc_sample_process(const xtc_sample *sample,size_t begin,size_t end, xtc_sample_operation operation,double amount,xtc_sample **out); +/* First/last sample strictly above linear threshold 0..1, padded and clamped + * to the input range. All-silent returns NOT_FOUND; outputs unchanged on error. */ +xtc_result xtc_sample_audible_bounds(const xtc_sample *sample,size_t begin,size_t end, + double threshold,size_t padding,size_t *first,size_t *last); /* Control-thread bounded mono float32 WAV save. Absolute path <512 bytes, * NEW destination only. Failure/cancel publishes nothing and removes temporary. */ xtc_result xtc_sample_write(const xtc_sample *sample,size_t begin,size_t end,const char *path); diff --git a/src/audio/sample.c b/src/audio/sample.c index a5b8fbd..3807f81 100644 --- a/src/audio/sample.c +++ b/src/audio/sample.c @@ -76,3 +76,14 @@ xtc_result xtc_sample_process(const xtc_sample *s,size_t begin,size_t end, } if(r!=XTC_OK){xtc_sample_destroy(copy);return r;}*out=copy;return XTC_OK; } + +xtc_result xtc_sample_audible_bounds(const xtc_sample *s,size_t begin,size_t end, + double threshold,size_t padding,size_t *first,size_t *last) +{ + size_t b=begin,e=end; + if(!range(s,begin,end)||!first||!last||first==last||!isfinite(threshold)||threshold<0||threshold>1||padding>XTC_SAMPLE_MAX_FRAMES)return XTC_ERR_INVALID_ARGUMENT; + while(bwave.samples[b])<=threshold)++b; + if(b==end)return XTC_ERR_NOT_FOUND; + while(e>b && fabs(s->wave.samples[e-1])<=threshold)--e; + *first=b-begin>padding ? b-padding : begin;*last=end-e>padding ? e+padding : end;return XTC_OK; +} diff --git a/src/gui/sample_editor.cpp b/src/gui/sample_editor.cpp index 320342a..253392e 100644 --- a/src/gui/sample_editor.cpp +++ b/src/gui/sample_editor.cpp @@ -46,6 +46,7 @@ SampleEditor::SampleEditor(xtc_sample *sample):Fl_Double_Window(1100,650,"xtc auto *dc=new Fl_Button(360,562,160,30,"Remove DC offset");dc->callback([](Fl_Widget *,void *c){auto r=static_cast(c)->process(XTC_SAMPLE_REMOVE_DC);if(r!=XTC_OK)fl_alert("DC removal failed: %s",xtc_result_string(r));},this); auto *fade=new Fl_Button(532,562,160,30,"Sample fades...");fade->callback([](Fl_Widget *,void *c){auto *e=static_cast(c);const char *text=fl_input("Fade: 0 linear in, 1 linear out, 2 equal-power in, 3 equal-power out","0");if(!text)return;unsigned int mode;char extra;if(std::sscanf(text,"%u %c",&mode,&extra)!=1||mode>3){fl_alert("Enter a fade mode 0..3.");return;}auto r=e->process(static_cast(XTC_SAMPLE_FADE_IN+mode));if(r!=XTC_OK)fl_alert("Fade failed: %s",xtc_result_string(r));},this); auto *gain=new Fl_Button(704,562,160,30,"Gain / normalize...");gain->callback([](Fl_Widget *,void *c){auto *e=static_cast(c);const char *text=fl_input("Mode (0 gain, 1 normalize), linear gain 0..16 or target peak >0..1","1 0.9");if(!text)return;unsigned int mode;double value;char extra;if(std::sscanf(text,"%u %lf %c",&mode,&value,&extra)!=2||mode>1){fl_alert("Enter mode and linear value.");return;}auto r=e->process(mode ? XTC_SAMPLE_NORMALIZE : XTC_SAMPLE_GAIN,value);if(r!=XTC_OK)fl_alert("Gain edit failed: %s",xtc_result_string(r));},this); + auto *silence=new Fl_Button(876,562,200,30,"Select audible bounds...");silence->callback([](Fl_Widget *,void *c){auto *e=static_cast(c);const char *text=fl_input("Silence threshold dBFS (-120..0), padding ms (0..10000)","-60 2");if(!text)return;double db,ms;char extra;if(std::sscanf(text,"%lf %lf %c",&db,&ms,&extra)!=2||!std::isfinite(db)||!std::isfinite(ms)||db< -120||db>0||ms<0||ms>10000){fl_alert("Enter a threshold and padding within the shown bounds.");return;}auto r=e->select_audible(std::pow(10.,db/20),static_cast(std::min(static_cast(e->frames_),ms*e->rate_/1000)));if(r!=XTC_OK)fl_alert("Audible bounds not found: %s",xtc_result_string(r));},this); auto *undo=new Fl_Button(850,604,110,30,"Undo / redo");undo->callback([](Fl_Widget *,void *c){auto r=static_cast(c)->undo_sample();if(r!=XTC_OK)fl_alert("No local sample edit to undo.");},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(); @@ -66,3 +67,5 @@ xtc_result SampleEditor::process(xtc_sample_operation operation,double amount) previous_=std::move(sample_);sample_.reset(next);copy_label("xtc — Sample editor / local sample changes; save a copy to keep");refresh();return XTC_OK; } xtc_result SampleEditor::undo_sample(){if(!previous_)return XTC_ERR_NOT_FOUND;sample_.swap(previous_);refresh();return XTC_OK;} + +xtc_result SampleEditor::select_audible(double threshold,size_t padding){size_t b=0,e=0;auto r=xtc_sample_audible_bounds(sample_.get(),begin_,end_,threshold,padding,&b,&e);return r==XTC_OK ? select(b,e) : r;} diff --git a/src/gui/sample_editor.hpp b/src/gui/sample_editor.hpp index d5f2a38..85a8585 100644 --- a/src/gui/sample_editor.hpp +++ b/src/gui/sample_editor.hpp @@ -18,6 +18,7 @@ public: xtc_result export_selection(const char *path); xtc_result process(xtc_sample_operation operation,double amount=0); xtc_result undo_sample(); + xtc_result select_audible(double threshold,size_t padding); 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 9caeab3..a5ca247 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -199,7 +199,7 @@ static int sample_editor_tests() CHECK(e->process(XTC_SAMPLE_FADE_IN_POWER)==XTC_OK);CHECK(e->undo_sample()==XTC_OK); CHECK(e->process(XTC_SAMPLE_REMOVE_DC)==XTC_OK);CHECK(e->undo_sample()==XTC_OK); CHECK(e->process(XTC_SAMPLE_INVERT)==XTC_OK);CHECK(e->undo_sample()==XTC_OK); - CHECK(e->process(XTC_SAMPLE_REVERSE)==XTC_OK);CHECK(e->undo_sample()==XTC_OK);CHECK(xtc_sample_read(e->sample(),10,&after)==XTC_OK && before==after);CHECK(e->undo_sample()==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(e->process(XTC_SAMPLE_REVERSE)==XTC_OK);CHECK(e->undo_sample()==XTC_OK);CHECK(xtc_sample_read(e->sample(),10,&after)==XTC_OK && before==after);CHECK(e->undo_sample()==XTC_OK);CHECK(e->select(100,10)==XTC_ERR_INVALID_ARGUMENT);CHECK(e->view(10,8)==XTC_OK);size_t b=e->begin(),z=e->end();CHECK(e->select_audible(1,0)==XTC_ERR_NOT_FOUND && e->begin()==b && e->end()==z);CHECK(e->select(0,e->frames())==XTC_OK);CHECK(e->select_audible(.01,2)==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); 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); diff --git a/tests/unit/test_sample.c b/tests/unit/test_sample.c index 54e84a6..f531d5f 100644 --- a/tests/unit/test_sample.c +++ b/tests/unit/test_sample.c @@ -55,5 +55,9 @@ int main(void) assert(xtc_sample_process(s,4,6,XTC_SAMPLE_NORMALIZE,1,&q)==XTC_ERR_INVALID_STATE && q==NULL); assert(xtc_sample_process(s,1,7,XTC_SAMPLE_GAIN,NAN,&q)==XTC_ERR_INVALID_ARGUMENT && q==NULL); assert(xtc_sample_process(s,1,7,XTC_SAMPLE_GAIN,17,&q)==XTC_ERR_INVALID_ARGUMENT && q==NULL);} + {size_t b=999,e=999;assert(xtc_sample_audible_bounds(s,0,8,.25,0,&b,&e)==XTC_OK && b==2 && e==7); + assert(xtc_sample_audible_bounds(s,0,8,.25,2,&b,&e)==XTC_OK && b==0 && e==8); + b=e=999;assert(xtc_sample_audible_bounds(s,4,6,0,0,&b,&e)==XTC_ERR_NOT_FOUND && b==999 && e==999); + assert(xtc_sample_audible_bounds(s,0,8,NAN,0,&b,&e)==XTC_ERR_INVALID_ARGUMENT && b==999);} xtc_sample_destroy(s);assert(unlink(path)==0 && rmdir(root)==0);puts("Sample editor core tests passed.");return 0; }