diff --git a/docs/AUDIO_EDITOR.md b/docs/AUDIO_EDITOR.md index d99c761..ef220cc 100644 --- a/docs/AUDIO_EDITOR.md +++ b/docs/AUDIO_EDITOR.md @@ -45,3 +45,7 @@ Gain / normalize processes only selected samples. Mode 0 accepts linear gain 0.. ## 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. + +## Phase 78 + +Snap to zero crossings searches each selected boundary within a source-frame radius up to 65,536. It uses the mono source, chooses earlier positions on equal distance and changes both endpoints only if both searches yield a nonempty range. Source edges qualify only when their adjacent sample is zero. This can reduce boundary discontinuities but does not guarantee click-free output; musical-tick trimming/slicing can move a boundary away from the exact crossing. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index af56720..b34ea52 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1822,12 +1822,12 @@ Implemented: threshold-based audible-range detection with bounded padding and at Find nearest bounded mono zero crossings around selection boundaries with deterministic ties; adjust selection without crossing or inventing samples. ### 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: deterministic nearest zero-sample/sign-crossing boundary search with bounded radius, earlier ties and atomic two-endpoint snapping. Core edge/radius/no-result/tie cases and GUI selection snapping pass. --- diff --git a/include/xtc/xtc_sample.h b/include/xtc/xtc_sample.h index 4a3e7d9..5b6ec09 100644 --- a/include/xtc/xtc_sample.h +++ b/include/xtc/xtc_sample.h @@ -26,6 +26,10 @@ xtc_result xtc_sample_process(const xtc_sample *sample,size_t begin,size_t end, * 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); +/* Nearest zero sample/sign crossing at a frame boundary; ties choose earlier. + * center 0..frames, radius <=65536 frames. Edge boundaries qualify only when + * the adjacent sample is zero. NOT_FOUND preserves output. */ +xtc_result xtc_sample_zero_crossing(const xtc_sample *sample,size_t center,size_t radius,size_t *out); /* 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 3807f81..bd492ee 100644 --- a/src/audio/sample.c +++ b/src/audio/sample.c @@ -87,3 +87,18 @@ xtc_result xtc_sample_audible_bounds(const xtc_sample *s,size_t begin,size_t end 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; } + +xtc_result xtc_sample_zero_crossing(const xtc_sample *s,size_t center,size_t radius,size_t *out) +{ + size_t i,lo,hi,best=SIZE_MAX,distance=SIZE_MAX; + if(!s||!out||center>s->wave.frames||radius>65536)return XTC_ERR_INVALID_ARGUMENT; + lo=center>radius ? center-radius : 0;hi=center+radius;if(hi>s->wave.frames)hi=s->wave.frames; + for(i=lo;i<=hi;++i){int crossing; + if(i==0)crossing=s->wave.samples[0]==0; + else if(i==s->wave.frames)crossing=s->wave.samples[i-1]==0; + else {float a=s->wave.samples[i-1],b=s->wave.samples[i];crossing=b==0||(a<0&&b>0)||(a>0&&b<0);} + if(crossing){size_t d=i>center ? i-center : center-i;if(dcallback([](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 *zero=new Fl_Button(16,604,180,30,"Snap to zero crossings...");zero->callback([](Fl_Widget *,void *c){auto *e=static_cast(c);const char *text=fl_input("Search radius in source frames (0..65536)","256");if(!text)return;unsigned int radius;char extra;if(std::sscanf(text,"%u %c",&radius,&extra)!=1||radius>65536){fl_alert("Enter a radius 0..65536.");return;}auto r=e->snap_zero_crossings(radius);if(r!=XTC_OK)fl_alert("Selection unchanged: %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(); @@ -69,3 +70,5 @@ xtc_result SampleEditor::process(xtc_sample_operation operation,double amount) 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;} + +xtc_result SampleEditor::snap_zero_crossings(size_t radius){size_t b=0,e=0;auto r=xtc_sample_zero_crossing(sample_.get(),begin_,radius,&b);if(r==XTC_OK)r=xtc_sample_zero_crossing(sample_.get(),end_,radius,&e);return r==XTC_OK ? select(b,e) : r;} diff --git a/src/gui/sample_editor.hpp b/src/gui/sample_editor.hpp index 85a8585..c52dc3b 100644 --- a/src/gui/sample_editor.hpp +++ b/src/gui/sample_editor.hpp @@ -19,6 +19,7 @@ public: xtc_result process(xtc_sample_operation operation,double amount=0); xtc_result undo_sample(); xtc_result select_audible(double threshold,size_t padding); + xtc_result snap_zero_crossings(size_t radius); 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 a5ca247..5b0591b 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);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(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->select(0,e->frames())==XTC_OK);CHECK(e->snap_zero_crossings(65536)==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 f531d5f..af91b92 100644 --- a/tests/unit/test_sample.c +++ b/tests/unit/test_sample.c @@ -59,5 +59,10 @@ int main(void) 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);} + {size_t crossing=999;assert(xtc_sample_zero_crossing(s,1,1,&crossing)==XTC_OK && crossing==0); + assert(xtc_sample_zero_crossing(s,6,0,&crossing)==XTC_ERR_NOT_FOUND && crossing==0); + assert(xtc_sample_zero_crossing(s,6,1,&crossing)==XTC_OK && crossing==5); + assert(xtc_sample_zero_crossing(s,8,1,&crossing)==XTC_OK && crossing==7); + assert(xtc_sample_zero_crossing(s,0,65537,&crossing)==XTC_ERR_INVALID_ARGUMENT && crossing==7);} xtc_sample_destroy(s);assert(unlink(path)==0 && rmdir(root)==0);puts("Sample editor core tests passed.");return 0; }