From 342a93385c0907bde6cf4fa5eaf043cea55ef412 Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 01:03:31 +0100 Subject: [PATCH] feat: export sample ranges and adopt new source copies with rollback and project undo --- docs/AUDIO_EDITOR.md | 4 ++++ docs/ROADMAP.md | 8 ++++---- include/xtc/xtc_sample.h | 3 +++ src/audio/sample.c | 12 ++++++++++++ src/gui/main_window.cpp | 19 +++++++++++++++++++ src/gui/main_window.hpp | 2 ++ src/gui/sample_editor.cpp | 5 +++++ src/gui/sample_editor.hpp | 3 +++ tests/integration/test_gui.cpp | 5 +++++ tests/unit/test_sample.c | 5 +++++ 10 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/AUDIO_EDITOR.md b/docs/AUDIO_EDITOR.md index b103101..5d86fcc 100644 --- a/docs/AUDIO_EDITOR.md +++ b/docs/AUDIO_EDITOR.md @@ -17,3 +17,7 @@ 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. + +## Phase 71 + +Export selected frames writes a new mono float32 WAV of the half-open selection at native rate. Save full copy and relink writes the complete source and relinks only the selected clip after successful preparation. The original source is never overwritten. Full-copy adoption requires matching source frame count/rate. Project undo restores the reference but retains the new file, which may be referenced by redo/history or other projects. Failed adoption removes its owned new file. Export/copy runs synchronously outside audio callbacks. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index be3be12..6d27c9d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1717,12 +1717,12 @@ Implemented: native frame bounds trim audio inside its current audible region wi Export selected frames or save the complete edited source into a new mono float32 WAV; optionally relink the selected clip with project undo and cleanup on failed preparation. Never overwrite source/destination files. ### 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: no-clobber mono float32 selection export and full-source copy adoption, preserving frame/rate alignment. Preparation failures remove the newly written copy and retain the original project; successful adoption is project-undoable. Exact exported frames, existing destination protection and GUI failed/successful adoption/undo pass. --- diff --git a/include/xtc/xtc_sample.h b/include/xtc/xtc_sample.h index cadc5d1..c97509a 100644 --- a/include/xtc/xtc_sample.h +++ b/include/xtc/xtc_sample.h @@ -15,6 +15,9 @@ 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); +/* 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); /* 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. diff --git a/src/audio/sample.c b/src/audio/sample.c index fe45a51..2405c76 100644 --- a/src/audio/sample.c +++ b/src/audio/sample.c @@ -1,6 +1,7 @@ #include "xtc/xtc_sample.h" #include "wave_internal.h" #include +#include #include struct xtc_sample {xtc_wave wave;}; static int range(const xtc_sample *s,size_t begin,size_t end){return s && beginwave.frames;} @@ -37,3 +38,14 @@ xtc_result xtc_project_trim_source_frames(xtc_project *p,uint64_t id,unsigned in if(first>=last||firstc.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); } + +xtc_result xtc_sample_write(const xtc_sample *s,size_t begin,size_t end,const char *path) +{ + xtc_recorder *rec=NULL;xtc_result r;size_t i; + if(!range(s,begin,end)||!path||path[0]!='/'||strlen(path)>=512)return XTC_ERR_INVALID_ARGUMENT; + r=xtc_recorder_create(path,s->wave.rate,256,&rec); + for(i=begin;r==XTC_OK && i256)n=256;xtc_recorder_push(rec,s->wave.samples+i,n);r=xtc_recorder_drain(rec);i+=n;} + if(r==XTC_OK)r=xtc_recorder_finish(rec); + if(r!=XTC_OK)xtc_recorder_abort(rec); + xtc_recorder_destroy(rec);return r; +} diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 3448f69..b70b485 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -2597,6 +2597,24 @@ xtc_result MainWindow::collect_project(const char *directory) BusyOperation busy(*this,status_,"Collecting project media…"); return xtc_project_collect(project_.get(),directory); } +xtc_result MainWindow::adopt_sample_copy(uint64_t id,const xtc_sample *sample,const char *path) +{ + if(!sample||!path)return XTC_ERR_INVALID_ARGUMENT; + auto r=before_arrangement_edit(this);if(r!=XTC_OK)return r; + xtc_clip_info clip{};r=xtc_project_find_clip(project_.get(),id,&clip);if(r!=XTC_OK)return r; + if(clip.midi||!clip.asset_path[0])return XTC_ERR_INVALID_STATE; + xtc_sample *original=nullptr;r=xtc_sample_load(clip.asset_path,&original);if(r!=XTC_OK)return r; + unsigned int old_rate=0,new_rate=0;size_t old_frames=0,new_frames=0; + xtc_sample_info(original,&old_rate,&old_frames);xtc_sample_info(sample,&new_rate,&new_frames);xtc_sample_destroy(original); + if(old_rate!=new_rate||old_frames!=new_frames)return XTC_ERR_INVALID_ARGUMENT; + xtc_project *candidate=nullptr;r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK)r=xtc_project_set_clip_asset(candidate,id,path); + bool written=false; + if(r==XTC_OK){r=xtc_sample_write(sample,0,new_frames,path);written=r==XTC_OK;} + if(r==XTC_OK)r=install_project(candidate,false); + if(r!=XTC_OK){if(written)std::remove(path);xtc_project_destroy(candidate);return r;} + sequencer_->select(clip.track_id,id);mark_dirty();return XTC_OK; +} 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; @@ -2617,6 +2635,7 @@ xtc_result MainWindow::open_sample_editor() 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(std::min(first,static_cast(frames))),static_cast(std::min(last,static_cast(frames)))); + editor.adopt=[this,id=clip.id](const xtc_sample *data,const char *path){return adopt_sample_copy(id,data,path);}; 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; } diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 8ab9e16..8f232be 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -6,6 +6,7 @@ #include #include #include "xtc/xtc_project.h" +#include "xtc/xtc_sample.h" #include "xtc/xtc_editing.h" #include "xtc/xtc_backend.h" #include "xtc/xtc_mixer.h" @@ -61,6 +62,7 @@ class MainWindow : public Fl_Double_Window { Fl_Widget *velocity_lane() const { return velocity_lane_; } /* Control-thread plugin commands, transactional model adoption. */ xtc_result assign_instrument(uint64_t track, const char *path, unsigned int index); + xtc_result adopt_sample_copy(uint64_t clip,const xtc_sample *sample,const char *path); xtc_result trim_sample_clip(uint64_t clip,unsigned int rate,size_t begin,size_t end); xtc_result open_sample_editor(); void export_audio(); diff --git a/src/gui/sample_editor.cpp b/src/gui/sample_editor.cpp index 3adf8e4..24b23de 100644 --- a/src/gui/sample_editor.cpp +++ b/src/gui/sample_editor.cpp @@ -39,6 +39,8 @@ SampleEditor::SampleEditor(xtc_sample *sample):Fl_Double_Window(1100,650,"xtc auto *minus=new Fl_Button(860,58,100,28,"Zoom out");minus->callback([](Fl_Widget *,void *c){auto *e=static_cast(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); 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(c)->trim_clip();if(r!=XTC_OK)fl_alert("Clip trim unchanged: %s",xtc_result_string(r));},this); + auto *save=new Fl_Button(210,520,210,30,"Save full copy and relink...");save->callback([](Fl_Widget *,void *c){auto *e=static_cast(c);const char *path=fl_input("NEW absolute WAV path (full source; project undo keeps the new file)","");if(path){auto r=e->save_copy(path,true);if(r!=XTC_OK)fl_alert("Copy not adopted: %s",xtc_result_string(r));}},this); + auto *export_button=new Fl_Button(434,520,200,30,"Export selected frames...");export_button->callback([](Fl_Widget *,void *c){auto *e=static_cast(c);const char *path=fl_input("NEW absolute WAV path (selection only)","");if(path){auto r=e->export_selection(path);if(r!=XTC_OK)fl_alert("Export failed: %s",xtc_result_string(r));}},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(); } @@ -48,3 +50,6 @@ void SampleEditor::refresh(){begin_input_->value(static_cast(begin_));en void SampleEditor::run(){show();while(shown())Fl::wait();} xtc_result SampleEditor::trim_clip(){return trim ? trim(rate_,begin_,end_) : XTC_ERR_INVALID_STATE;} + +xtc_result SampleEditor::save_copy(const char *path,bool use_in_project){if(use_in_project)return adopt ? adopt(sample_.get(),path) : XTC_ERR_INVALID_STATE;return xtc_sample_write(sample_.get(),0,frames_,path);} +xtc_result SampleEditor::export_selection(const char *path){return xtc_sample_write(sample_.get(),begin_,end_,path);} diff --git a/src/gui/sample_editor.hpp b/src/gui/sample_editor.hpp index 4092ee8..65259c5 100644 --- a/src/gui/sample_editor.hpp +++ b/src/gui/sample_editor.hpp @@ -13,6 +13,9 @@ public: explicit SampleEditor(xtc_sample *sample); std::function trim; xtc_result trim_clip(); + std::function adopt; + xtc_result save_copy(const char *path,bool use_in_project); + xtc_result export_selection(const char *path); 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 1ea1403..3d1c742 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -199,6 +199,11 @@ static int sample_editor_tests() 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); + {xtc_sample *data=nullptr;CHECK(xtc_sample_load(path,&data)==XTC_OK);std::string copy="/tmp/xtc-sample-gui-"+std::to_string(getpid())+".wav"; + submit_result=XTC_ERR_OUT_OF_MEMORY;CHECK(window.adopt_sample_copy(id,data,copy.c_str())==XTC_ERR_OUT_OF_MEMORY && access(copy.c_str(),F_OK)!=0);submit_result=XTC_OK; + CHECK(window.adopt_sample_copy(id,data,copy.c_str())==XTC_OK);CHECK(xtc_project_find_clip(s->project(),id,&clip)==XTC_OK && !strcmp(clip.asset_path,copy.c_str())); + CHECK(window.undo()==XTC_OK);CHECK(xtc_project_find_clip(s->project(),id,&clip)==XTC_OK && !strcmp(clip.asset_path,path)); + xtc_sample_destroy(data);CHECK(unlink(copy.c_str())==0);} window.do_callback();return failures ? 1 : 0; } int main(int argc, char **argv) diff --git a/tests/unit/test_sample.c b/tests/unit/test_sample.c index 386fcb0..6332a1c 100644 --- a/tests/unit/test_sample.c +++ b/tests/unit/test_sample.c @@ -25,5 +25,10 @@ int main(void) 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);} + {char copy[256];xtc_sample *q=NULL;snprintf(copy,sizeof(copy),"%s/copy.wav",root); + assert(xtc_sample_write(s,1,7,copy)==XTC_OK && xtc_sample_write(s,1,7,copy)==XTC_ERR_IO); + assert(xtc_sample_load(copy,&q)==XTC_OK && xtc_sample_info(q,&rate,&frames)==XTC_OK && frames==6 && rate==8000); + for(size_t j=0;j<6;++j){assert(xtc_sample_read(q,j,&v)==XTC_OK && v==values[j+1]);} + xtc_sample_destroy(q);assert(unlink(copy)==0);assert(xtc_sample_write(s,0,8,"relative.wav")==XTC_ERR_INVALID_ARGUMENT);} xtc_sample_destroy(s);assert(unlink(path)==0 && rmdir(root)==0);puts("Sample editor core tests passed.");return 0; }