feat(audio): crossfade overlapping clips in one undoable edit (phase 98)

This commit is contained in:
Johannes Findeisen 2026-11-06 02:10:11 +01:00
commit 083ac05ae6
10 changed files with 82 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SequencerWidget *>(c)->select_all_clips();},sequencer_);
menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->duplicate_selection(); },sequencer_);
menu->add("Edit/Split clip at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->split_selection(); }, sequencer_);
menu->add("Edit/Audio tools/Crossfade two selected clips",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->crossfade_selection();},sequencer_);
menu->add("Edit/Audio clip gain and fades", 0, [](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(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<SequencerWidget *>(c)->rename_selection(name);

View file

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

View file

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

19
src/project/crossfade.c Normal file
View file

@ -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;i<p->clip_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;
}

View file

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

View file

@ -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 <stdlib.h>
#include <unistd.h>
#include <assert.h>
#include <stdio.h>
#include <string.h>
@ -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);