From ddef6cd73d113c33b501e418669568c09f2d7a9a Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 01:07:23 +0100 Subject: [PATCH] feat: process selected sample gain and peak normalization with atomic bounds checks --- docs/AUDIO_EDITOR.md | 4 ++++ docs/ROADMAP.md | 8 ++++---- include/xtc/xtc_sample.h | 6 ++++-- src/audio/sample.c | 9 ++++++--- src/gui/sample_editor.cpp | 1 + tests/integration/test_gui.cpp | 1 + tests/unit/test_sample.c | 6 ++++++ 7 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/AUDIO_EDITOR.md b/docs/AUDIO_EDITOR.md index e60071b..145d974 100644 --- a/docs/AUDIO_EDITOR.md +++ b/docs/AUDIO_EDITOR.md @@ -37,3 +37,7 @@ Remove DC offset subtracts the mean of the selected range from only that range. ## Phase 75 Sample fades offers four offline curves: linear in/out and equal-power in/out (sinusoidal amplitude). The entire selection is the fade duration; both endpoints are included in the curve. Fewer than two frames reject. These curves change copied sample data and stack with existing playback clip fades after adoption; they do not form a linked clip crossfade. + +## 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fe9f38e..8273436 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1792,12 +1792,12 @@ Implemented: selected-range linear/equal-power fade-in/out processing with expli Process selected sample gain or peak normalization with finite bounded parameters, whole-result validation, silence rejection and reversible edits. ### 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: selected sample gain (0..16) and peak normalization (target >0..1, gain cap 16) with silence/NaN/parameter rejection and finite result validation. Exact selected/unselected samples, retained values above unity and GUI processing/undo pass. --- diff --git a/include/xtc/xtc_sample.h b/include/xtc/xtc_sample.h index c2fac8e..f3491e9 100644 --- a/include/xtc/xtc_sample.h +++ b/include/xtc/xtc_sample.h @@ -15,9 +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); -typedef enum { XTC_SAMPLE_REVERSE, XTC_SAMPLE_INVERT, XTC_SAMPLE_REMOVE_DC, XTC_SAMPLE_FADE_IN, XTC_SAMPLE_FADE_OUT, XTC_SAMPLE_FADE_IN_POWER, XTC_SAMPLE_FADE_OUT_POWER } xtc_sample_operation; +typedef enum { XTC_SAMPLE_REVERSE, XTC_SAMPLE_INVERT, XTC_SAMPLE_REMOVE_DC, XTC_SAMPLE_FADE_IN, XTC_SAMPLE_FADE_OUT, XTC_SAMPLE_FADE_IN_POWER, XTC_SAMPLE_FADE_OUT_POWER, XTC_SAMPLE_GAIN, XTC_SAMPLE_NORMALIZE } xtc_sample_operation; /* Independent full-source result; only [begin,end) changes. Never mutates input. - * amount is reserved/ignored for reversal. Failure preserves *out. */ + * amount: GAIN 0..16, NORMALIZE target 0XTC_SAMPLE_FADE_OUT_POWER)||!isfinite(amount))return XTC_ERR_INVALID_ARGUMENT; - if(operation>=XTC_SAMPLE_FADE_IN && end-begin<2)return XTC_ERR_INVALID_ARGUMENT; + xtc_sample *copy;size_t i;xtc_result r=XTC_OK;xtc_sample_stats stats;double gain=1; + if(!range(s,begin,end)||!out||(operationXTC_SAMPLE_NORMALIZE)||!isfinite(amount))return XTC_ERR_INVALID_ARGUMENT; + if(operation>=XTC_SAMPLE_FADE_IN && operation<=XTC_SAMPLE_FADE_OUT_POWER && end-begin<2)return XTC_ERR_INVALID_ARGUMENT; r=xtc_sample_analyze(s,begin,end,&stats);if(r!=XTC_OK)return r; + if(operation==XTC_SAMPLE_GAIN){if(amount<0||amount>16)return XTC_ERR_INVALID_ARGUMENT;gain=amount;} + if(operation==XTC_SAMPLE_NORMALIZE){double peak=fmax(fabs(stats.minimum),fabs(stats.maximum));if(amount<=0||amount>1)return XTC_ERR_INVALID_ARGUMENT;if(peak<=0)return XTC_ERR_INVALID_STATE;gain=amount/peak;if(gain>16)return XTC_ERR_CAPACITY;} copy=calloc(1,sizeof(*copy));if(!copy)return XTC_ERR_OUT_OF_MEMORY; copy->wave=s->wave;copy->wave.samples=malloc(s->wave.frames*sizeof(float)); if(!copy->wave.samples){free(copy);return XTC_ERR_OUT_OF_MEMORY;} for(i=0;iwave.frames;++i)copy->wave.samples[i]=s->wave.samples[i]; for(i=begin;iwave.samples[i]*gain;break; case XTC_SAMPLE_FADE_IN:case XTC_SAMPLE_FADE_OUT:case XTC_SAMPLE_FADE_IN_POWER:case XTC_SAMPLE_FADE_OUT_POWER:{ double fraction=(double)(i-begin)/(double)(end-begin-1);if(operation==XTC_SAMPLE_FADE_OUT||operation==XTC_SAMPLE_FADE_OUT_POWER)fraction=1-fraction; if(operation==XTC_SAMPLE_FADE_IN_POWER||operation==XTC_SAMPLE_FADE_OUT_POWER)fraction=sin(fraction*1.57079632679489661923); diff --git a/src/gui/sample_editor.cpp b/src/gui/sample_editor.cpp index c6e3694..320342a 100644 --- a/src/gui/sample_editor.cpp +++ b/src/gui/sample_editor.cpp @@ -45,6 +45,7 @@ SampleEditor::SampleEditor(xtc_sample *sample):Fl_Double_Window(1100,650,"xtc auto *invert=new Fl_Button(188,562,160,30,"Invert polarity");invert->callback([](Fl_Widget *,void *c){auto r=static_cast(c)->process(XTC_SAMPLE_INVERT);if(r!=XTC_OK)fl_alert("Polarity edit failed: %s",xtc_result_string(r));},this); 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 *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(); diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 69e050d..9caeab3 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -195,6 +195,7 @@ 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);float before=0,after=0;CHECK(xtc_sample_read(e->sample(),10,&before)==XTC_OK); + CHECK(e->process(XTC_SAMPLE_NORMALIZE,.8)==XTC_OK);CHECK(e->undo_sample()==XTC_OK); 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); diff --git a/tests/unit/test_sample.c b/tests/unit/test_sample.c index 5eb257c..54e84a6 100644 --- a/tests/unit/test_sample.c +++ b/tests/unit/test_sample.c @@ -49,5 +49,11 @@ int main(void) for(size_t j=0;j<8;++j){double scale=1;if(j>=1&&j<7){scale=(double)(j-1)/5;if(mode==1||mode==3)scale=1-scale;if(mode>=2)scale=sin(scale*1.57079632679489661923);}assert(xtc_sample_read(q,j,&v)==XTC_OK && fabs(v-values[j]*scale)<.000001);} xtc_sample_destroy(q);} {xtc_sample *q=NULL;assert(xtc_sample_process(s,1,2,XTC_SAMPLE_FADE_IN,0,&q)==XTC_ERR_INVALID_ARGUMENT && q==NULL);} + {xtc_sample *q=NULL;assert(xtc_sample_process(s,1,7,XTC_SAMPLE_NORMALIZE,.375,&q)==XTC_OK); + for(size_t j=0;j<8;++j){assert(xtc_sample_read(q,j,&v)==XTC_OK && v==values[j]*(j>=1&&j<7 ? .5f : 1));}xtc_sample_destroy(q);q=NULL; + assert(xtc_sample_process(s,1,7,XTC_SAMPLE_GAIN,2,&q)==XTC_OK);assert(xtc_sample_read(q,3,&v)==XTC_OK && v==1.5f);xtc_sample_destroy(q);q=NULL; + 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);} xtc_sample_destroy(s);assert(unlink(path)==0 && rmdir(root)==0);puts("Sample editor core tests passed.");return 0; }