feat: export sample ranges and adopt new source copies with rollback and project undo

This commit is contained in:
Johannes Findeisen 2026-11-06 01:03:31 +01:00
commit 342a93385c
10 changed files with 62 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
#include "xtc/xtc_sample.h"
#include "wave_internal.h"
#include <stdlib.h>
#include <string.h>
#include <math.h>
struct xtc_sample {xtc_wave wave;};
static int range(const xtc_sample *s,size_t begin,size_t end){return s && begin<end && end<=s->wave.frames;}
@ -37,3 +38,14 @@ xtc_result xtc_project_trim_source_frames(xtc_project *p,uint64_t id,unsigned in
if(first>=last||first<c.offset_tick||last>c.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 && i<end;){size_t n=end-i;if(n>256)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;
}

View file

@ -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<size_t>(std::min(first,static_cast<uint64_t>(frames))),static_cast<size_t>(std::min(last,static_cast<uint64_t>(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;
}

View file

@ -6,6 +6,7 @@
#include <vector>
#include <string>
#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();

View file

@ -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<SampleEditor *>(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<SampleEditor *>(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<SampleEditor *>(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<SampleEditor *>(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<SampleEditor *>(c)->hide();},this);callback([](Fl_Widget *,void *c){static_cast<SampleEditor *>(c)->hide();},this);
Fl_Group::end();xtc_ui::style(this);refresh();
}
@ -48,3 +50,6 @@ void SampleEditor::refresh(){begin_input_->value(static_cast<double>(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);}

View file

@ -13,6 +13,9 @@ public:
explicit SampleEditor(xtc_sample *sample);
std::function<xtc_result(unsigned int,size_t,size_t)> trim;
xtc_result trim_clip();
std::function<xtc_result(const xtc_sample *,const char *)> 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);

View file

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

View file

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