feat: snap sample selection boundaries to deterministic nearby zero crossings

This commit is contained in:
Johannes Findeisen 2026-11-06 01:08:30 +01:00
commit edeeb8e8bb
8 changed files with 37 additions and 5 deletions

View file

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

View file

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

View file

@ -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);

View file

@ -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(d<distance){best=i;distance=d;}}
}
if(best==SIZE_MAX)return XTC_ERR_NOT_FOUND;
*out=best;return XTC_OK;
}

View file

@ -47,6 +47,7 @@ SampleEditor::SampleEditor(xtc_sample *sample):Fl_Double_Window(1100,650,"xtc
auto *fade=new Fl_Button(532,562,160,30,"Sample fades...");fade->callback([](Fl_Widget *,void *c){auto *e=static_cast<SampleEditor *>(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_operation>(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<SampleEditor *>(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<SampleEditor *>(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<size_t>(std::min(static_cast<double>(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<SampleEditor *>(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<SampleEditor *>(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<SampleEditor *>(c)->hide();},this);callback([](Fl_Widget *,void *c){static_cast<SampleEditor *>(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;}

View file

@ -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);

View file

@ -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);

View file

@ -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;
}