diff --git a/Makefile b/Makefile index f5b63f2..39c4457 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/control/tempo.c src/project/chain_tools.c src/project/chain_preset.c src/mixer/track_fx.c src/audio/sample.c src/persistence/collect.c src/audio/normalize.c src/project/sections.c src/project/editing.c 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 +CORE_SOURCES := src/project/crossfade.c src/control/tempo.c src/project/chain_tools.c src/project/chain_preset.c src/mixer/track_fx.c src/audio/sample.c src/persistence/collect.c src/audio/normalize.c src/project/sections.c src/project/editing.c 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/CREATIVE_WORKFLOWS.md b/docs/CREATIVE_WORKFLOWS.md index 489394f..d6b3202 100644 --- a/docs/CREATIVE_WORKFLOWS.md +++ b/docs/CREATIVE_WORKFLOWS.md @@ -71,3 +71,14 @@ are ignored. Each accepted tempo is an undoable edit and stops playback before preparation; tapping is unavailable while recording. New/Open resets tap history. The estimator uses monotonic control-thread time, with no audio-callback clock access or allocation. Tap timing is not a beat detector or MIDI clock sync. + +## Audio crossfades + +Select two partially overlapping audio clips on the same track and choose +**Edit → Audio tools → Crossfade two selected clips**. The earlier clip fades +out across the overlap while the later clip fades in. Gains, positions, source +files and the opposite fades are preserved. Equal boundaries, containment, +adjacency, cross-track selections and MIDI clips reject without partial edits. +This is a linear tick-based crossfade using existing clip envelopes; it does +not move clips, create overlaps or offer equal-power curves. Discrete sample +endpoints retain the engine's existing one-sample boundary convention. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb20ee5..b329fa0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2178,12 +2178,12 @@ Implemented: transport Tap uses a bounded C99 four-interval estimator, 20–300 Apply matching linear boundary fades to two partially overlapping audio clips on one track, preserving source files and opposite fades. Reject ambiguous layouts transactionally and provide one undo step. ### Tasks and acceptance criteria -- [ ] Implement the workflow with bounded validation, safe preparation and documented limits. -- [ ] Pass meaningful core and/or GUI checks, including error and undo behavior. -- [ ] Document results and commit this phase separately. +- [x] Implement the workflow with bounded validation, safe preparation and documented limits. +- [x] Pass meaningful core and/or GUI checks, including error and undo behavior. +- [x] Document results and commit this phase separately. ### Implementation status -Planned. +Implemented: allocation-free C99 validation applies paired linear overlap fades atomically, retaining opposite fades, media, gains and positions. Ambiguous layouts reject. Exact overlapping-wave render and invalid-selection preservation checks passed, as did GUI two-clip crossfade/undo and warning-enabled builds. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index d1e271f..a62ca39 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -68,6 +68,11 @@ typedef struct { unsigned int timing_variation,velocity_variation; /* 0..960 ticks, 0..127 velocity; source overflow rejects, velocity clamps. */ } xtc_midi_transform; xtc_result xtc_project_transform_midi(xtc_project *project,const uint64_t *ids,size_t count,const xtc_midi_transform *edit); +/* Matching linear boundary fades for two partially overlapping non-MIDI clips + * on one track. Order is inferred. Equal starts/ends, containment and adjacency + * reject; opposite fades, media, gains and positions remain unchanged. + * No allocation or I/O; control-thread only, errors leave both clips intact. */ +xtc_result xtc_project_crossfade(xtc_project *project,uint64_t first,uint64_t second); /* Session static mixer comparison. Capture owns an independent snapshot of * gain/pan/mute/solo for all tracks/buses. Recall requires the exact same stable * track ID set (order may change), and publishes atomically. Routing, automation, diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 84db1ff..b16e490 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -365,6 +365,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) menu->add("Edit/Select all clips",0,[](Fl_Widget *,void *c){static_cast(c)->select_all_clips();},sequencer_); menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast(c)->duplicate_selection(); },sequencer_); menu->add("Edit/Split clip at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->split_selection(); }, sequencer_); + menu->add("Edit/Audio tools/Crossfade two selected clips",0,[](Fl_Widget *,void *c){static_cast(c)->crossfade_selection();},sequencer_); menu->add("Edit/Audio clip gain and fades", 0, [](Fl_Widget *, void *c) { static_cast(c)->audio_properties_dialog(); }, sequencer_); menu->add("Edit/Rename clip or track",FL_F+2,[](Fl_Widget *, void *c) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) (void)static_cast(c)->rename_selection(name); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index e03ba5c..af7ebd3 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -931,3 +931,11 @@ xtc_result SequencerWidget::set_zoom(int scale) if(scale!=24 && scale!=48 && scale!=96 && scale!=192 && scale!=384) return XTC_ERR_INVALID_ARGUMENT; scale_=scale;extent();redraw();return XTC_OK; } + +xtc_result SequencerWidget::crossfade_selection() +{ + auto ids=selected_clips();xtc_result r=ids.size()==2 ? XTC_OK : XTC_ERR_INVALID_ARGUMENT; + if(r==XTC_OK && before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_project_crossfade(project_,ids[0],ids[1]); + changed(r);return r; +} diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 2b4a205..fd98928 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -58,6 +58,7 @@ class SequencerWidget : public Fl_Group { xtc_result normalize_audio(float target); void normalize_dialog(); void fades_dialog(); + xtc_result crossfade_selection(); xtc_result repeat_selection(unsigned int copies); void repeat_dialog(); xtc_result copy_selection(); diff --git a/src/project/crossfade.c b/src/project/crossfade.c new file mode 100644 index 0000000..2f6fc39 --- /dev/null +++ b/src/project/crossfade.c @@ -0,0 +1,19 @@ +#include "project_internal.h" +#include "xtc/xtc_editing.h" +/* Validate both sides before either write; no allocation or source-file access. */ +xtc_result xtc_project_crossfade(xtc_project *p,uint64_t first,uint64_t second) +{ + size_t i,a=SIZE_MAX,b=SIZE_MAX;xtc_clip_info left,right;uint64_t left_end,right_end,overlap; + if(!p || first==second)return XTC_ERR_INVALID_ARGUMENT; + for(i=0;iclip_count;++i){if(p->clips[i].id==first)a=i;if(p->clips[i].id==second)b=i;} + if(a==SIZE_MAX || b==SIZE_MAX)return XTC_ERR_NOT_FOUND; + if(p->clips[a].start_tick>p->clips[b].start_tick){i=a;a=b;b=i;} + left=p->clips[a];right=p->clips[b]; + if(left.midi || right.midi)return XTC_ERR_INVALID_STATE; + left_end=left.start_tick+left.length_tick;right_end=right.start_tick+right.length_tick; + if(left.track_id!=right.track_id || left.start_tick>=right.start_tick || right.start_tick>=left_end || left_end>=right_end)return XTC_ERR_INVALID_ARGUMENT; + overlap=left_end-right.start_tick; + if(overlap>left.source_length_tick || overlap>right.source_length_tick)return XTC_ERR_INVALID_ARGUMENT; + left.fade_out_tick=overlap;right.fade_in_tick=overlap; + p->clips[a]=left;p->clips[b]=right;return XTC_OK; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 5e47a43..6d43d2e 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -266,6 +266,10 @@ int creative_workflow_tests() CHECK(find(&window,"Tap"));CHECK(window.tap_tempo(0)==XTC_ERR_NOT_FOUND && window.tap_tempo(.4)==XTC_OK); CHECK(xtc_project_tempo(s->project())==150000 && window.undo()==XTC_OK && xtc_project_tempo(s->project())==120000); CHECK(window.new_project()==XTC_OK && window.tap_tempo(.9)==XTC_ERR_NOT_FOUND); + CHECK(window.create_track(false)==XTC_OK);auto audio_track=s->selected_track();s->position(0);CHECK(s->add_region()==XTC_OK);auto left_clip=s->selected_clip(); + engine_status.sample_position=48000;s->position(1920);CHECK(s->add_region()==XTC_OK);auto right_clip=s->selected_clip();CHECK(s->select(audio_track,left_clip) && s->toggle_clip(right_clip)); + CHECK(s->crossfade_selection()==XTC_OK);xtc_clip_info audio{};CHECK(xtc_project_find_clip(s->project(),left_clip,&audio)==XTC_OK && audio.fade_out_tick==1920); + CHECK(window.undo()==XTC_OK && xtc_project_find_clip(s->project(),left_clip,&audio)==XTC_OK && !audio.fade_out_tick); CHECK(!panel->comparison_ready(0) && !panel->comparison_ready(1)); window.do_callback();puts("Creative workflow GUI checks passed.");return failures ? 1 : 0; } diff --git a/tests/unit/test_creative.c b/tests/unit/test_creative.c index c8ae222..c8cf5ca 100644 --- a/tests/unit/test_creative.c +++ b/tests/unit/test_creative.c @@ -1,5 +1,10 @@ +#define _POSIX_C_SOURCE 200809L #include "xtc/xtc_plugin.h" #include "xtc/xtc_tempo.h" +#include "xtc/xtc_editing.h" +#include "xtc/xtc_audio.h" +#include +#include #include #include #include @@ -18,9 +23,31 @@ static void tap_tests(void) assert(xtc_tap_tempo_push(&s,10.25,&tempo)==XTC_OK && tempo==240000); xtc_tap_tempo_reset(&s);assert(!s.started && !s.count); } +static void crossfade_tests(void) +{ + xtc_project *p=NULL;xtc_recorder *rec=NULL;xtc_audio_plan *plan=NULL;uint64_t track,a,b;xtc_clip_info left,right,before; + char wave[]="/tmp/xtc-crossfade-XXXXXX";int fd=mkstemp(wave);float ones[1000],output[32];assert(fd>=0);close(fd);unlink(wave); + for(size_t i=0;i<1000;++i)ones[i]=1; + assert(xtc_recorder_create(wave,8000,8000,&rec)==XTC_OK); + for(size_t i=0;i<8;++i)xtc_recorder_push(rec,ones,1000); + assert(xtc_recorder_finish(rec)==XTC_OK);xtc_recorder_destroy(rec); + assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Audio",&track)==XTC_OK); + assert(xtc_project_add_clip(p,track,"Left",0,1920,&a)==XTC_OK && xtc_project_add_clip(p,track,"Right",960,1920,&b)==XTC_OK); + assert(xtc_project_set_clip_asset(p,a,wave)==XTC_OK && xtc_project_set_clip_asset(p,b,wave)==XTC_OK); + assert(xtc_project_set_clip_audio(p,a,1,16,0)==XTC_OK && xtc_project_set_clip_audio(p,b,1,0,16)==XTC_OK); + assert(xtc_project_crossfade(p,b,a)==XTC_OK && xtc_project_find_clip(p,a,&left)==XTC_OK && xtc_project_find_clip(p,b,&right)==XTC_OK); + assert(left.fade_in_tick==16 && left.fade_out_tick==960 && right.fade_in_tick==960 && right.fade_out_tick==16); + assert(xtc_audio_plan_create(p,8000,&plan)==XTC_OK && xtc_audio_plan_set_fade_frames(plan,0)==XTC_OK && xtc_audio_plan_render(plan,4000,output,32)==XTC_OK); + for(size_t i=0;i<32;++i)assert(fabsf(output[i]-.99975f)<.00001f); + xtc_audio_plan_destroy(plan);before=left; + assert(xtc_project_crossfade(p,a,a)==XTC_ERR_INVALID_ARGUMENT && xtc_project_find_clip(p,a,&left)==XTC_OK && !memcmp(&before,&left,sizeof(left))); + assert(xtc_project_edit_clip(p,b,track,1920,1920,0)==XTC_OK && xtc_project_crossfade(p,a,b)==XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_find_clip(p,a,&left)==XTC_OK && !memcmp(&before,&left,sizeof(left))); + xtc_project_destroy(p);unlink(wave); +} int main(void) { - tap_tests(); + tap_tests();crossfade_tests(); xtc_track_chain c={0},out={0}; c.output_gain=.8f;c.count=1;c.slots[0].enabled=1;c.slots[0].wet=.4f; assert(xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,XTC_BUILTIN_PATH,2,48000,&c.slots[0].plugin)==XTC_OK);