From 836cdedc6b69b07cbde2a22b1798f36067afe60e Mon Sep 17 00:00:00 2001 From: hanez Date: Thu, 5 Nov 2026 23:55:56 +0100 Subject: [PATCH] feat: render bounded effect tails and reset internal history on transport changes --- Makefile | 2 +- docs/BUILTIN_DEVICES.md | 36 ++++++++++++ docs/PREFERENCES.md | 5 ++ docs/ROADMAP.md | 18 +++--- include/xtc/xtc_audio.h | 10 ++++ include/xtc/xtc_engine.h | 1 + include/xtc/xtc_plugin.h | 3 + include/xtc/xtc_realtime.h | 1 + include/xtc/xtc_settings.h | 2 +- src/audio/export.c | 88 ++++++++++++++++++++++++++++ src/backend/jack_backend.c | 3 + src/engine/offline_engine.c | 4 +- src/engine/realtime.c | 7 +++ src/gui/main_window.cpp | 31 ++++++++++ src/gui/main_window.hpp | 1 + src/plugin/builtin.c | 22 +++++-- src/plugin/builtin.h | 1 + src/plugin/host.c | 1 + src/settings/settings.c | 4 +- tests/integration/test_gui.cpp | 5 ++ tests/integration/test_reliability.c | 1 + tests/unit/test_builtin.c | 56 +++++++++++++++++- 22 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 src/audio/export.c diff --git a/Makefile b/Makefile index 4668c62..9780124 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c +CORE_SOURCES := src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes) ifeq ($(JACK_AVAILABLE),yes) JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack) diff --git a/docs/BUILTIN_DEVICES.md b/docs/BUILTIN_DEVICES.md index 8589df1..2ad0164 100644 --- a/docs/BUILTIN_DEVICES.md +++ b/docs/BUILTIN_DEVICES.md @@ -152,3 +152,39 @@ energy, bounded extreme feedback/diffusion and graph/direct stereo output (including the engine's subnormal-to-zero policy). GUI tests save/reopen both identities; callback guards and ASan/UBSan cover processing. `make builtin-preview` also writes files 5/6 for repeatable listening; human listening remains pending. + +## Phase 44 history and tails + +Internal effects reset on a transport discontinuity (seek/loop wrap), stopped +backend, or newly prepared plan. Pause uses backend Stop and therefore also +resets history. Continuous playback retains tails. Reset invalidates delay +history with a valid-frame counter and clears only fixed-size filter/voice state; +it neither allocates nor clears a large delay buffer in the callback. External +plugins retain their existing history contract. MIDI discontinuities continue +to chase notes with fresh envelopes. + +**File → Export WAV** renders the song plus the Preferences / transport +`tail_ms` duration (0..60000, default 3000). Output is stereo float32 WAV at the +offline sample rate. The destination must be a new absolute path. Rendering +prepares an independent snapshot, then advances in 256-frame control-thread +steps with a Cancel button; the project and playback instance stay independent. +Only a complete synced file is published, without replacing an existing file. +Cancellation/errors remove the temporary. Missing media rejects export. +RIFF's 32-bit size limit is enforced before rendering. Rendering does not apply +loop preferences, normalize, automatically detect silence or stop infinite +oscillator/feedback sources early: the configured endpoint always bounds work. + +The public `xtc_export_create/step/progress/destroy` API supports incremental +rendering and cancellation by destruction. A failed step must be followed by +destruction. `xtc_project_render_length` returns the exact sample endpoint. + +Set `stop_after_tail=1` in Preferences to stop ordinary playback after the song +and configured tail. Looping and recording disable this automatic stop. Playback +stop is serviced by the GUI refresh timer and normal stop fade, so it can exceed +the endpoint by a refresh interval plus fade; export has the exact sample length. +Uncheck the setting to retain indefinite playback. Stop always remains available. + +Tests compare stereo export against direct rendering in 31-frame blocks, verify +nonzero tails, exact length, cancellation, existing-file retention, tail bounds, +history reset and callback-safe reset. Preview generation and manual listening +procedures remain available; no human listening result is claimed. diff --git a/docs/PREFERENCES.md b/docs/PREFERENCES.md index 74854cd..4d4a30c 100644 --- a/docs/PREFERENCES.md +++ b/docs/PREFERENCES.md @@ -66,3 +66,8 @@ Validation covers settings roundtrips, malformed/duplicate/overflow/NUL input, private permissions, save failure, GUI Apply/Close/reload and plugin listing, plus JACK callback wrap positions and existing GUI/core regressions. Tests use temporary configuration paths and do not write the user's configuration. + +Phase 44 adds `[transport] tail_ms=3000` (0..60000) and +`stop_after_tail=0` (0/1). Export always includes the configured tail; optional +playback stopping uses the GUI timer and is disabled during looping/recording. +See BUILTIN_DEVICES.md for endpoint, cancellation and reset semantics. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 58abdff..6614074 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1239,15 +1239,19 @@ documented; human listening is unverified. See BUILTIN_DEVICES.md. Make discontinuities and effect tails explicit in playback and export. ### Tasks and acceptance criteria -- [ ] Define retained/reset effect history for seek, stop, loop wrap and project/plan replacement. -- [ ] Implement the chosen history policy without callback allocation or unsafe state sharing. -- [ ] Add bounded automatic or user-configurable tail rendering after the arrangement/export endpoint. -- [ ] Expose tail limits and cancellation, including silent input and non-decaying/high-feedback cases. -- [ ] Test seek/loop reproducibility and complete delay/reverb tails across processing block sizes. +- [x] Define retained/reset effect history for seek, stop, loop wrap and project/plan replacement. +- [x] Implement the chosen history policy without callback allocation or unsafe state sharing. +- [x] Add bounded automatic or user-configurable tail rendering after the arrangement/export endpoint. +- [x] Expose tail limits and cancellation, including silent input and non-decaying/high-feedback cases. +- [x] Test seek/loop reproducibility and complete delay/reverb tails across processing block sizes. ### Implementation status -Planned. Seeks currently retain effect history; tails need continued rendering -and do not automatically extend playback/export. +Implemented: constant-cost internal history invalidation on discontinuity/stop, +plus incremental stereo WAV export with 0..60-second tails, progress/cancel and +no-clobber publication. Preferences optionally stop playback after the tail +(with GUI-timer/fade precision); loops/recording retain their existing behavior. +Core, reliability, export/direct partition, GUI and sanitizer checks pass. +External-plugin history remains retained. See BUILTIN_DEVICES.md for limits. --- diff --git a/include/xtc/xtc_audio.h b/include/xtc/xtc_audio.h index 6236aec..b087708 100644 --- a/include/xtc/xtc_audio.h +++ b/include/xtc/xtc_audio.h @@ -8,6 +8,16 @@ extern "C" { #endif typedef struct xtc_audio_plan xtc_audio_plan; +typedef struct xtc_export xtc_export; +/* Control thread only: bounded 256-frame incremental stereo float32 WAV export. + * tail_ms 0..60000, rate 8000..192000. Destination must be absolute and absent. + * No destination is published on error/cancel; destroy cancels/removes temporary. + * Snapshot preparation may execute trusted plugins. Missing media rejects export. */ +xtc_result xtc_project_render_length(const xtc_project *project,unsigned int rate,unsigned int tail_ms,uint64_t *out_frames); +xtc_result xtc_export_create(const xtc_project *project,const char *path,unsigned int rate,unsigned int tail_ms,xtc_export **out); +xtc_result xtc_export_step(xtc_export *job,int *done); +xtc_result xtc_export_progress(const xtc_export *job,uint64_t *position,uint64_t *total); +void xtc_export_destroy(xtc_export *job); typedef struct xtc_recorder xtc_recorder; /* Control-thread metadata edits; audio/silent clips only. No source-file writes. diff --git a/include/xtc/xtc_engine.h b/include/xtc/xtc_engine.h index 79de4d0..664d234 100644 --- a/include/xtc/xtc_engine.h +++ b/include/xtc/xtc_engine.h @@ -30,6 +30,7 @@ typedef struct { typedef struct xtc_plan xtc_plan; /* Single owner: set absolute automation/native-event position, no allocation. */ +void xtc_plan_reset_history(xtc_plan *plan); /* Single owner; internal effects only. */ xtc_result xtc_plan_position(xtc_plan *plan, uint64_t position); typedef enum { diff --git a/include/xtc/xtc_plugin.h b/include/xtc/xtc_plugin.h index 69980c9..f9b108a 100644 --- a/include/xtc/xtc_plugin.h +++ b/include/xtc/xtc_plugin.h @@ -69,6 +69,9 @@ xtc_result xtc_plugin_parameters(const xtc_plugin_spec *spec, double rate, xtc_p /* Metadata-only validation: never opens libraries. */ xtc_result xtc_plugin_spec_validate(const xtc_plugin_spec *spec); xtc_result xtc_plugin_create(const xtc_plugin_spec *spec, double rate, size_t frames, xtc_plugin **out); +/* Single audio owner or quiescent control owner. Internal state resets in bounded + * constant work; external-plugin history remains retained. No allocation/I/O. */ +void xtc_plugin_reset_history(xtc_plugin *plugin); size_t xtc_plugin_sample_frames(const xtc_plugin *plugin); size_t xtc_plugin_asset_issues(const xtc_plugin *plugin); void xtc_plugin_destroy(xtc_plugin *plugin); /* control, after quiescence */ diff --git a/include/xtc/xtc_realtime.h b/include/xtc/xtc_realtime.h index 34e5c8b..0b8288d 100644 --- a/include/xtc/xtc_realtime.h +++ b/include/xtc/xtc_realtime.h @@ -9,6 +9,7 @@ extern "C" { typedef struct xtc_realtime xtc_realtime; /* Single audio owner, or stopped/joined control owner: position current/future plans. */ +void xtc_realtime_reset_history(xtc_realtime *engine); /* Quiescent control only. */ xtc_result xtc_realtime_position(xtc_realtime *engine, uint64_t position); typedef struct { diff --git a/include/xtc/xtc_settings.h b/include/xtc/xtc_settings.h index 431f372..6d23ed9 100644 --- a/include/xtc/xtc_settings.h +++ b/include/xtc/xtc_settings.h @@ -14,7 +14,7 @@ typedef struct { int64_t sequencer_snap, piano_snap, sequencer_zoom; int64_t clip_ticks, note_ticks, note_velocity, note_channel, filter_channel, filter_low, filter_high; int64_t tempo, volume_cc, pan_cc, sustain_cc; - int64_t loop_start, loop_end, loop_enabled, follow_playhead; + int64_t loop_start, loop_end, loop_enabled, follow_playhead, tail_ms, stop_after_tail; } xtc_settings; typedef struct { const char *section, *key, *label; diff --git a/src/audio/export.c b/src/audio/export.c new file mode 100644 index 0000000..f389d84 --- /dev/null +++ b/src/audio/export.c @@ -0,0 +1,88 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_audio.h" +#include "xtc/xtc_midi.h" +#include +#include +#include +#include +#include +struct xtc_export { + xtc_audio_plan *audio;xtc_midi_plan *midi;xtc_plan *graph; + FILE *file;char *temporary,*destination; + uint64_t position,total;unsigned int rate;int complete; + float buffers[6][256]; +}; +static void put32(unsigned char *p,uint32_t v) +{unsigned int i;for(i=0;i<4;++i){p[i]=(unsigned char)(v&255);v>>=8;}} +static int header(FILE *file,unsigned int rate,uint32_t frames) +{ + unsigned char h[44]={0};memcpy(h,"RIFF",4);put32(h+4,36+frames*8);memcpy(h+8,"WAVEfmt ",8); + put32(h+16,16);h[20]=3;h[22]=2;put32(h+24,rate);put32(h+28,rate*8);h[32]=8;h[34]=32; + memcpy(h+36,"data",4);put32(h+40,frames*8);return fwrite(h,1,sizeof(h),file)==sizeof(h); +} +void xtc_export_destroy(xtc_export *job) +{ + if(!job)return; + if(job->file)fclose(job->file); + if(job->temporary)unlink(job->temporary); + free(job->temporary);free(job->destination);xtc_audio_plan_destroy(job->audio); + xtc_midi_plan_destroy(job->midi);xtc_plan_destroy(job->graph);free(job); +} +xtc_result xtc_project_render_length(const xtc_project *project,unsigned int rate,unsigned int tail_ms,uint64_t *out) +{ + size_t tracks,clips,i;uint64_t end=0,samples;xtc_result r; + if(!out || rate<8000 || rate>192000 || tail_ms>60000)return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_get_counts(project,&tracks,&clips);if(r!=XTC_OK)return r; + for(i=0;iend)end=clip.start_tick+clip.length_tick;} + r=xtc_project_tick_to_sample(project,end,rate,&samples);if(r!=XTC_OK)return r; + *out=samples+((uint64_t)rate*tail_ms+999)/1000;return XTC_OK; +} +xtc_result xtc_export_create(const xtc_project *project,const char *path,unsigned int rate,unsigned int tail_ms,xtc_export **out) +{ + xtc_export *job;xtc_result r;int fd;uint64_t total; + if(!out)return XTC_ERR_INVALID_ARGUMENT; + *out=NULL; + if(!path || path[0]!='/' || strlen(path)>4000)return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_render_length(project,rate,tail_ms,&total);if(r!=XTC_OK)return r; + if(total>(UINT32_MAX-36U)/8U)return XTC_ERR_CAPACITY; + job=calloc(1,sizeof(*job));if(!job)return XTC_ERR_OUT_OF_MEMORY; + job->total=total;job->rate=rate; + r=xtc_audio_plan_create(project,rate,&job->audio); + if(r==XTC_OK)r=xtc_midi_plan_create(project,rate,&job->midi); + if(r==XTC_OK && (xtc_audio_plan_issues(job->audio,NULL) || xtc_midi_plan_asset_issues(job->midi)))r=XTC_ERR_NOT_FOUND; + if(r==XTC_OK)r=xtc_graph_compile(xtc_project_graph(project),rate,256,&job->graph); + if(r!=XTC_OK){xtc_export_destroy(job);return r;} + job->destination=strdup(path);job->temporary=malloc(strlen(path)+16); + if(!job->destination || !job->temporary){xtc_export_destroy(job);return XTC_ERR_OUT_OF_MEMORY;} + snprintf(job->temporary,strlen(path)+16,"%s.tmp.XXXXXX",path); + fd=mkstemp(job->temporary);if(fd<0){xtc_export_destroy(job);return XTC_ERR_IO;} + job->file=fdopen(fd,"wb");if(!job->file){close(fd);xtc_export_destroy(job);return XTC_ERR_IO;} + if(!header(job->file,rate,(uint32_t)total)){xtc_export_destroy(job);return XTC_ERR_IO;} + *out=job;return XTC_OK; +} +xtc_result xtc_export_step(xtc_export *job,int *done) +{ + size_t n,i;xtc_result r;unsigned char bytes[256*8]; + if(!job || !done)return XTC_ERR_INVALID_ARGUMENT; + if(job->complete){*done=1;return XTC_OK;} + if(!job->file)return XTC_ERR_INVALID_STATE; + n=job->total-job->position<256 ? (size_t)(job->total-job->position) : 256; + r=xtc_audio_plan_render_stereo(job->audio,job->position,job->buffers[0],job->buffers[1],n); + if(r==XTC_OK)r=xtc_midi_plan_render_stereo(job->midi,job->position,NULL,0,job->buffers[2],job->buffers[3],n); + if(r==XTC_OK)r=xtc_plan_process_stereo(job->graph,NULL,job->buffers[0],job->buffers[1],job->buffers[2],job->buffers[3],job->buffers[4],job->buffers[5],n); + if(r!=XTC_OK)return r; + for(i=0;ibuffers[4]+i,4);memcpy(&b,job->buffers[5]+i,4);put32(bytes+i*8,a);put32(bytes+i*8+4,b);} + if(n && fwrite(bytes,8,n,job->file)!=n)return XTC_ERR_IO; + job->position+=n;*done=0; + if(job->position==job->total) { + if(fflush(job->file) || fsync(fileno(job->file)))return XTC_ERR_IO; + if(fclose(job->file)){job->file=NULL;return XTC_ERR_IO;}job->file=NULL; + /* No clobber: destination appears only after a complete, synced render. */ + if(link(job->temporary,job->destination))return errno==EEXIST ? XTC_ERR_INVALID_STATE : XTC_ERR_IO; + unlink(job->temporary);job->complete=1;*done=1; + } + return XTC_OK; +} +xtc_result xtc_export_progress(const xtc_export *job,uint64_t *position,uint64_t *total) +{if(!job || !position || !total)return XTC_ERR_INVALID_ARGUMENT;*position=job->position;*total=job->total;return XTC_OK;} diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 1fab1d3..84ddc00 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -447,6 +447,7 @@ xtc_result xtc_backend_stop(xtc_backend *backend) if (!backend->ready) return XTC_ERR_INVALID_STATE; if (!backend->running) { + xtc_realtime_reset_history(backend->engine); xtc_midi_plan_reset(backend->midi); return XTC_OK; } @@ -456,6 +457,7 @@ xtc_result xtc_backend_stop(xtc_backend *backend) __atomic_store_n(&backend->stop_requested, 0U, __ATOMIC_RELAXED); __atomic_store_n(&backend->stop_done, 0U, __ATOMIC_RELAXED); xtc_midi_plan_reset(backend->midi); + xtc_realtime_reset_history(backend->engine); xtc_realtime_collect(backend->engine); return XTC_OK; } @@ -475,6 +477,7 @@ xtc_result xtc_backend_seek(xtc_backend *backend, uint64_t sample) __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE; xtc_midi_plan_reset(backend->midi); + xtc_realtime_reset_history(backend->engine); (void)xtc_realtime_position(backend->engine,sample); __atomic_store_n(&backend->sample_position, sample, __ATOMIC_RELAXED); return XTC_OK; diff --git a/src/engine/offline_engine.c b/src/engine/offline_engine.c index bf7a6c1..865dadf 100644 --- a/src/engine/offline_engine.c +++ b/src/engine/offline_engine.c @@ -380,5 +380,7 @@ xtc_result xtc_plan_process_sources(xtc_plan *p, const float *in, const float *a return xtc_plan_process_stereo(p, in, audio, NULL, midi, NULL, out, NULL, frames); } +void xtc_plan_reset_history(xtc_plan *p) +{size_t i;if(p)for(i=0;inode_count*2;++i)xtc_plugin_reset_history(p->plugins[i]);} xtc_result xtc_plan_position(xtc_plan *p, uint64_t position) -{ if (!p || position > UINT64_MAX-65536U) return XTC_ERR_INVALID_ARGUMENT; p->position = position; return XTC_OK; } +{ if (!p || position > UINT64_MAX-65536U) return XTC_ERR_INVALID_ARGUMENT; if(p->position!=position)xtc_plan_reset_history(p); p->position = position; return XTC_OK; } diff --git a/src/engine/realtime.c b/src/engine/realtime.c index 740387d..e213701 100644 --- a/src/engine/realtime.c +++ b/src/engine/realtime.c @@ -240,3 +240,10 @@ xtc_result xtc_realtime_process_sources(xtc_realtime *p, const float *in, const xtc_result xtc_realtime_position(xtc_realtime *p, uint64_t position) { if (!p || position > UINT64_MAX-65536U) return XTC_ERR_INVALID_ARGUMENT; p->position = position; return XTC_OK; } + +void xtc_realtime_reset_history(xtc_realtime *p) +{ + if(!p)return; + xtc_plan_reset_history(p->active); + xtc_plan_reset_history(__atomic_load_n(&p->pending,__ATOMIC_ACQUIRE)); +} diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 48af1a8..962e266 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -111,6 +111,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) menu->add("File/New", FL_CTRL + 'n', file_callback, this); menu->add("File/Open", FL_CTRL + 'o', file_callback, this); menu->add("File/Save", FL_CTRL + 's', file_callback, this); + menu->add("File/Export WAV",0,[](Fl_Widget *,void *context){static_cast(context)->export_audio();},this); menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this); menu->add("File/Import WAV", FL_CTRL + 'i', audio_file_callback, this); menu->add("File/Relink selected audio", 0, audio_file_callback, this); @@ -1053,6 +1054,12 @@ void MainWindow::timer_callback(void *context) if (r != XTC_OK || status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK) window->stop_engine(); } + if(window->settings_.stop_after_tail && !window->settings_.loop_enabled && !window->recorder_) { + xtc_backend_status status{};uint64_t end=0; + if(xtc_backend_get_status(window->backend_,&status)==XTC_OK && status.running && + xtc_project_render_length(window->project_.get(),status.sample_rate,static_cast(window->settings_.tail_ms),&end)==XTC_OK && status.sample_position>=end) + window->stop_engine(); + } window->refresh_status(); window->follow_playhead(); if (!window->closing_) @@ -1861,6 +1868,30 @@ xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsig if (r != XTC_OK) xtc_project_destroy(candidate); else { sequencer_->select(track,clip); mixer_->select_track(track); mark_dirty(); } return r; } +void MainWindow::export_audio() +{ + if(recorder_){report_error("Stop recording before export",XTC_ERR_INVALID_STATE);return;} + const char *answer=fl_input("Export stereo WAV to a new absolute path (tail configured in Preferences)",""); + if(!answer)return; + std::string path=answer;xtc_export *job=nullptr; + xtc_result r=xtc_export_create(project_.get(),path.c_str(),static_cast(settings_.offline_rate),static_cast(settings_.tail_ms),&job); + if(r==XTC_OK) { + Fl_Double_Window dialog(460,130,"Export WAV");dialog.set_modal();dialog.begin(); + Fl_Box progress(12,12,436,45,"Preparing export");Fl_Button cancel(330,80,110,30,"Cancel export"); + cancel.callback([](Fl_Widget *,void *c){static_cast(c)->hide();},&dialog); + dialog.callback([](Fl_Widget *,void *c){static_cast(c)->hide();},&dialog);dialog.end();xtc_ui::style(&dialog);dialog.show(); + int done=0; + while(dialog.shown() && !done && r==XTC_OK) { + r=xtc_export_step(job,&done);uint64_t position=0,total=0;xtc_export_progress(job,&position,&total); + std::string text=std::to_string(position)+" / "+std::to_string(total)+" frames";progress.copy_label(text.c_str());Fl::check(); + } + if(r==XTC_OK && !done)set_label(status_,"Export cancelled; destination retained"); + else if(r==XTC_OK)set_label(status_,"Export complete"); + dialog.hide(); + } + xtc_export_destroy(job); + if(r!=XTC_OK)report_error("Export failed; destination must be new and media available",r); +} xtc_result MainWindow::load_sampler(uint64_t track,const char *path) { if(!path || path[0]!='/' || std::strlen(path)>=sizeof(xtc_plugin_spec{}.sample_path))return XTC_ERR_INVALID_ARGUMENT; diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index e4cda43..7a02f4a 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -49,6 +49,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); + void export_audio(); xtc_result load_sampler(uint64_t track, const char *path); xtc_result set_instrument_spec(uint64_t track, const xtc_plugin_spec &spec); xtc_result select_live_instrument(uint64_t track); diff --git a/src/plugin/builtin.c b/src/plugin/builtin.c index a086a02..45428c9 100644 --- a/src/plugin/builtin.c +++ b/src/plugin/builtin.c @@ -29,7 +29,7 @@ struct xtc_builtin { xtc_plugin_spec spec; double rate; float attack, release; - size_t max_frames, length, cursor; + size_t max_frames, length, cursor,history_frames; float *line, damp[4]; size_t comb[4]; voice voices[32]; @@ -107,6 +107,14 @@ xtc_result xtc_builtin_create(const xtc_plugin_spec *s, double rate, size_t fram } size_t xtc_builtin_sample_frames(const xtc_builtin *p) {return p ? p->sample.frames : 0;} size_t xtc_builtin_asset_issues(const xtc_builtin *p) {return p ? p->sample_issue : 0;} +/* Lazy invalidation: reset cost is independent of delay-buffer capacity. */ +void xtc_builtin_reset(xtc_builtin *p) +{ + if(!p)return; + p->cursor=p->history_frames=0;p->delay_started=0;p->delay_remaining=0; + memset(p->damp,0,sizeof(p->damp));memset(p->previous,0,sizeof(p->previous)); + memset(p->voices,0,sizeof(p->voices));memset(p->sustain,0,sizeof(p->sustain));memset(p->bend,0,sizeof(p->bend)); +} void xtc_builtin_destroy(xtc_builtin *p) { if(p) {free(p->sample.samples);free(p->line);free(p);} } static float parameter(const xtc_builtin *p, unsigned int port, uint64_t position) { @@ -131,17 +139,17 @@ xtc_result xtc_builtin_process(xtc_builtin *p, const float *in, float *out, size float a=parameter(p,0,position+i), b=parameter(p,1,position+i), mix=parameter(p,2,position+i); if(p->spec.index==2) { size_t delay=(size_t)(a*p->rate/1000); if(!delay) delay=1; - wet=p->line[(p->cursor+p->length-delay)%p->length]; + wet=p->history_frames>=delay ? p->line[(p->cursor+p->length-delay)%p->length] : 0; p->line[p->cursor]=x+wet*b; } else { for(j=0;j<4;++j) { float *line=p->line+j*p->length; - float y=line[(p->cursor+p->length-p->comb[j])%p->length]; + float y=p->history_frames>=p->comb[j] ? line[(p->cursor+p->length-p->comb[j])%p->length] : 0; p->damp[j]=(1-b)*y+b*p->damp[j]; line[p->cursor]=x+a*p->damp[j]; wet+=y*.25f; } } - out[i]=(1-mix)*x+mix*wet; p->cursor=(p->cursor+1)%p->length; + out[i]=(1-mix)*x+mix*wet; if(p->history_frameslength)++p->history_frames; p->cursor=(p->cursor+1)%p->length; } return XTC_OK; } @@ -170,7 +178,8 @@ xtc_result xtc_builtin_stereo(xtc_builtin *p,const float *left,const float *righ for(head=0;head<2;++head) { size_t delay=(size_t)delays[head]; size_t n=(p->cursor+p->length-delay)%p->length,m=(n+p->length-1)%p->length; - values[head]=line[n]+(line[m]-line[n])*(float)(delays[head]-(double)delay); + {float near=p->history_frames>=delay ? line[n] : 0,far=p->history_frames>delay ? line[m] : 0; + values[head]=near+(far-near)*(float)(delays[head]-(double)delay);} } {float blend=p->delay_remaining ? (float)(p->delay_transition-p->delay_remaining+1)/(float)p->delay_transition : 0; wet[side]=values[0]+(values[1]-values[0])*blend;} @@ -183,7 +192,7 @@ xtc_result xtc_builtin_stereo(xtc_builtin *p,const float *left,const float *righ for(side=0;side<2;++side)for(j=0;j<4;++j) { float *line=p->line+(side*4+j)*p->length; size_t delay=(size_t)((double)p->comb[j]*(.5+room*1.5)*(side ? 1.071 : 1)); - float y=line[(p->cursor+p->length-delay)%p->length]; + float y=p->history_frames>=delay ? line[(p->cursor+p->length-delay)%p->length] : 0; next[side][j]=(1-b)*y+b*p->previous[side][j]; line[p->cursor]=input[side]+a*((1-.5f*diffusion)*next[side][j]+.5f*diffusion*p->previous[1-side][j]); wet[side]+=y*.25f; @@ -192,6 +201,7 @@ xtc_result xtc_builtin_stereo(xtc_builtin *p,const float *left,const float *righ } if(out_right) {out[i]=(1-mix)*input[0]+mix*wet[0];out_right[i]=(1-mix)*input[1]+mix*wet[1];} else out[i]=(1-mix)*input[0]+mix*(wet[0]+wet[1])*.5f; + if(p->history_frameslength)++p->history_frames; p->cursor=(p->cursor+1)%p->length; } return XTC_OK; diff --git a/src/plugin/builtin.h b/src/plugin/builtin.h index 7a2d86c..bceefe8 100644 --- a/src/plugin/builtin.h +++ b/src/plugin/builtin.h @@ -8,6 +8,7 @@ xtc_result xtc_builtin_describe(const xtc_plugin_spec *s, xtc_plugin_details *ou xtc_result xtc_builtin_create(const xtc_plugin_spec *s, double rate, size_t frames, xtc_builtin **out); size_t xtc_builtin_sample_frames(const xtc_builtin *p); size_t xtc_builtin_asset_issues(const xtc_builtin *p); +void xtc_builtin_reset(xtc_builtin *p); void xtc_builtin_destroy(xtc_builtin *p); xtc_result xtc_builtin_process(xtc_builtin *p, const float *in, float *out, size_t frames, uint64_t position); xtc_result xtc_builtin_stereo(xtc_builtin *p,const float *left,const float *right,float *out,float *out_right,size_t frames,uint64_t position); diff --git a/src/plugin/host.c b/src/plugin/host.c index e246ab8..ab82239 100644 --- a/src/plugin/host.c +++ b/src/plugin/host.c @@ -53,6 +53,7 @@ xtc_result xtc_plugin_describe(const xtc_plugin_spec *s, double rate, xtc_plugin snprintf(out->copyright,sizeof(out->copyright),"See plugin license"); return XTC_OK; } +void xtc_plugin_reset_history(xtc_plugin *p) {if(p){xtc_builtin_reset(p->builtin);p->next=0;}} size_t xtc_plugin_sample_frames(const xtc_plugin *p) {return p ? xtc_builtin_sample_frames(p->builtin) : 0;} size_t xtc_plugin_asset_issues(const xtc_plugin *p) {return p ? xtc_builtin_asset_issues(p->builtin) : 0;} void xtc_plugin_destroy(xtc_plugin *p) diff --git a/src/settings/settings.c b/src/settings/settings.c index a8b5a54..1463dcd 100644 --- a/src/settings/settings.c +++ b/src/settings/settings.c @@ -38,6 +38,8 @@ static const xtc_setting_info schema[] = { NUM("project_defaults",sustain_cc,"New project sustain CC (128 disables)",0,128), NUM("transport",loop_start,"Loop start, ticks",0,38399999), NUM("transport",loop_end,"Loop end, ticks",1,38400000), NUM("transport",loop_enabled,"Loop enabled: 0 or 1",0,1), + NUM("transport",tail_ms,"Playback/export tail, ms",0,60000), + NUM("transport",stop_after_tail,"Stop playback after song and tail: 0 or 1",0,1), NUM("transport",follow_playhead,"Follow playhead: 0 or 1",0,1) }; #define COUNT (sizeof(schema)/sizeof(schema[0])) @@ -54,7 +56,7 @@ void xtc_settings_defaults(xtc_settings *s) s->width=1280; s->height=800; s->piano_width=900; s->piano_height=620; s->refresh_ms=100; s->sequencer_snap=s->piano_snap=240; s->sequencer_zoom=48; s->clip_ticks=3840; s->note_ticks=960; s->note_velocity=100; s->filter_channel=-1; s->filter_high=127; s->tempo=120000; - s->volume_cc=7; s->pan_cc=10; s->sustain_cc=64; s->loop_end=3840; + s->volume_cc=7; s->pan_cc=10; s->sustain_cc=64; s->loop_end=3840;s->tail_ms=3000; } static int snap(int64_t v) { return v==1 || v==120 || v==160 || v==240 || v==320 || v==480 || v==960 || v==1920 || v==3840; } xtc_result xtc_settings_validate(const xtc_settings *s) diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index ff9aeef..b6ecd18 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1046,6 +1046,11 @@ int main(int argc, char **argv) CHECK(window.settings().loop_enabled==1 && window.settings().loop_start==960); CHECK(window.set_loop(4000,2000,true)==XTC_ERR_INVALID_ARGUMENT); settings=window.settings();CHECK(window.apply_preferences(settings)==XTC_OK); + settings=window.settings();settings.loop_enabled=0;settings.stop_after_tail=1;settings.tail_ms=0; + CHECK(window.apply_preferences(settings)==XTC_OK); + CHECK(window.create_track(true)==XTC_OK);press(find(&window,"Play")); + engine_status.sample_position=1000000;pump();CHECK(!handle.running); + settings.loop_enabled=1;settings.stop_after_tail=0;CHECK(window.apply_preferences(settings)==XTC_OK); settings.note_velocity=128;CHECK(window.apply_preferences(settings)==XTC_ERR_INVALID_ARGUMENT); PreferencesDialog dialog(window.settings(),path,[&](const xtc_settings &v){return window.apply_preferences(v);}); auto *input=dynamic_cast(find(&dialog,"New note velocity"));CHECK(input); diff --git a/tests/integration/test_reliability.c b/tests/integration/test_reliability.c index 71c3907..fe63d4a 100644 --- a/tests/integration/test_reliability.c +++ b/tests/integration/test_reliability.c @@ -170,6 +170,7 @@ static void builtin_failures(void) assert(xtc_plugin_process_midi(plugin,&event,1,out,256)==XTC_OK); } else if(id>=5) {float right[256];assert(xtc_plugin_process_stereo_at(plugin,in,in,out,right,256,0)==XTC_OK);} else assert(xtc_plugin_process(plugin,in,out,256)==XTC_OK); + xtc_plugin_reset_history(plugin); processing=0;xtc_plugin_destroy(plugin);succeeded=1;break; } assert(succeeded); diff --git a/tests/unit/test_builtin.c b/tests/unit/test_builtin.c index 28a261b..bb721ec 100644 --- a/tests/unit/test_builtin.c +++ b/tests/unit/test_builtin.c @@ -266,7 +266,61 @@ static void stereo_effect(unsigned int id) }xtc_plugin_destroy(a); } } +static void histories(void) +{ + unsigned int ids[]={2,3,5,6},j;size_t k,i; + for(j=0;j<4;++j) { + xtc_plugin_spec s;xtc_plugin *p=NULL;float in[256]={0},out[256]; + assert(xtc_plugin_probe(XTC_BUILTIN_PATH,ids[j],48000,&s)==XTC_OK); + assert(xtc_plugin_create(&s,48000,256,&p)==XTC_OK); + in[0]=1;assert(xtc_plugin_process(p,in,out,256)==XTC_OK);in[0]=0; + for(k=0;k<200;++k)assert(xtc_plugin_process(p,in,out,256)==XTC_OK); + xtc_plugin_reset_history(p); + for(k=0;k<600;++k) { + assert(xtc_plugin_process(p,in,out,256)==XTC_OK); + for(i=0;i<256;++i)assert(out[i]==0); + } + xtc_plugin_destroy(p); + } +} +static void exporting(void) +{ + xtc_project *project=NULL;xtc_plugin_spec spec;xtc_graph *graph=NULL;xtc_plan *plan=NULL; + xtc_midi_plan *midi=NULL;xtc_export *job=NULL;xtc_wave wave={0}; + uint64_t track,clip,note,total,position;size_t source,node,output,i;int done=0; + char path[]="/tmp/xtc-export-XXXXXX";int fd=mkstemp(path);assert(fd>=0);close(fd);unlink(path); + assert(xtc_project_create(&project)==XTC_OK && xtc_project_add_track(project,"Export",&track)==XTC_OK); + assert(xtc_project_add_midi_clip(project,track,0,120,&clip)==XTC_OK); + assert(xtc_project_add_note(project,clip,0,100,60,100,0,¬e)==XTC_OK); + assert(xtc_graph_create(3,&graph)==XTC_OK && xtc_graph_add_instrument(graph,&source)==XTC_OK); + assert(xtc_plugin_probe(XTC_BUILTIN_PATH,5,48000,&spec)==XTC_OK);spec.values[0]=100; + assert(xtc_graph_add_plugin(graph,&spec,&node)==XTC_OK && xtc_graph_add_output(graph,&output)==XTC_OK); + assert(xtc_graph_connect(graph,source,node)==XTC_OK && xtc_graph_connect(graph,node,output)==XTC_OK); + assert(xtc_project_set_graph(project,graph)==XTC_OK); + assert(xtc_export_create(project,path,48000,500,&job)==XTC_OK); + assert(xtc_export_step(job,&done)==XTC_OK && !done && access(path,F_OK)!=0); + xtc_export_destroy(job);job=NULL;assert(access(path,F_OK)!=0); + assert(xtc_export_create(project,path,48000,500,&job)==XTC_OK); + while(!done)assert(xtc_export_step(job,&done)==XTC_OK); + assert(xtc_export_progress(job,&position,&total)==XTC_OK && position==total && total==27000); + xtc_export_destroy(job);job=NULL; + assert(xtc_wave_load(path,&wave)==XTC_OK && wave.frames==27000); + {double energy=0;for(i=3000;i1); } + assert(xtc_graph_compile(graph,48000,31,&plan)==XTC_OK && xtc_midi_plan_create(project,48000,&midi)==XTC_OK); + for(position=0;position