diff --git a/Makefile b/Makefile index 91b405c..c6f1a30 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/plugin/ladspa.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/waveform.c src/plugin/ladspa.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) @@ -383,3 +383,11 @@ test-composition: $(BUILD_DIR)/test_composition ./$(BUILD_DIR)/test_composition test: test-composition -include $(BUILD_DIR)/tests/unit/test_composition.d + +$(BUILD_DIR)/test_audio_editing: $(BUILD_DIR)/tests/unit/test_audio_editing.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl +.PHONY: test-audio-editing +test-audio-editing: $(BUILD_DIR)/test_audio_editing + ./$(BUILD_DIR)/test_audio_editing +test: test-audio-editing +-include $(BUILD_DIR)/tests/unit/test_audio_editing.d diff --git a/README.md b/README.md index 68be352..7c15c51 100644 --- a/README.md +++ b/README.md @@ -128,3 +128,9 @@ For everyday use see [USER_MANUAL.md](docs/USER_MANUAL.md), `make test-package` checks a local system-linked Linux binary archive. Phases 16–20 add effect automation/controllers, editing commands, v7 project-folder media paths, performance baselines and release/installation checks. + +Phase 22 adds non-destructive audio splitting, clip gain/fades and cached timeline +waveforms, with undo and v8 persistence (reading v1–v7). See +[AUDIO_FILES.md](docs/AUDIO_FILES.md#phase-22-non-destructive-clip-editing). +`make test-audio-editing` checks source preservation and exact boundary output. +Time stretching and independent pitch shifting remain deferred. diff --git a/docs/AUDIO_FILES.md b/docs/AUDIO_FILES.md index f3f7e1a..605b7d8 100644 --- a/docs/AUDIO_FILES.md +++ b/docs/AUDIO_FILES.md @@ -137,3 +137,82 @@ Recordings remain RIFF mono float32, bounded to roughly 4 GiB; the preloaded player still limits each asset to 16M frames. Longer valid takes may require external splitting before import. No directory fsync/power-loss guarantee is claimed. WAV destinations are chosen explicitly by the user. + +## Phase 22: non-destructive clip editing + +Move clips by dragging their body; drag the left/right edge to trim within the +existing source length. Ctrl+D duplicates adjacent to the selected clip. Delete +removes the reference. With an audio clip selected, click the ruler while stopped +to position the cursor strictly inside it, then choose **Edit / Split audio at +cursor**, press S with timeline focus, or use the clip context menu. The right +piece becomes selected. The four-pixel minimum draw width is also selectable for +very short clips; zoom in for precise edge gestures. + +**Edit / Audio clip gain and fades** (A with timeline focus, also in the context +menu) takes three numbers: linear gain 0..4, fade-in ticks, fade-out ticks. +Defaults are `1 0 0`; 0 fade disables that explicit fade. Each duration can be up +to the original source length, even after trimming; overlapping long fades +attenuate a short clip without extending it. MIDI clips reject these operations; +silent metadata regions support them for later audio relinking. + +These operations are one undo step each, stop playback before model edits and +are refused while recording. Main Edit / Undo and Redo restore the model and +prepare its matching stopped engine plan; Play uses the latest settings. +Splitting and duplication allocate metadata IDs only: no media copies. All +operations preserve original WAV bytes, including Delete and undoing recording. +No destructive audio processing or file deletion command is supplied. + +A split retains the original source length/path/name/gain on both pieces. Its +right offset advances by the left length, keeping trims reversible within the +same source. Left keeps its ID and fade-in; right gets a fresh ID and fade-out. +Inner explicit fades are cleared. Gain/fades are retained on duplication. +Moving does not alter offsets; trimming changes only timeline/source bounds and +makes explicit fades follow the new audible boundaries. + +The renderer maps tick boundaries by the existing absolute floor conversion, +linearly resamples the source and never reads beyond its samples. Its audible +end is the earlier of the trimmed end and actual WAV end (imports round metadata +length up to a tick). A fade uses `age / fade_in_frames` at the start and +`remaining_frames / fade_out_frames` at the end, capped at 1; overlapping fades +use the smaller value. The first/last audible samples are zero when their +respective fade has a positive sample duration. Durations rounding to zero +samples are disabled. A one-sample clip with any active fade is silent. Explicit +fade gain multiplies the existing optional safety envelope, then clip gain, +then track gain/pan/automation. No limiter is added; gains/overlaps may clip. + +The GUI retains its 5 ms boundary safety fades, including new split edges, so +cuts can introduce a brief dip rather than an abrupt discontinuity. The raw C +plan still defaults to zero safety fade for exact-sample/offline consumers; +callers requesting raw cuts accept possible clicks. Tick quantization can place +an edit up to one output sample away from a separately rounded boundary; this +is not sample-resolution audio editing. Manual listening remains pending. +Time-stretching, independent pitch shifting and crossfade tools are future work; +no suitable algorithm/quality target or new dependency has been selected. + +### Cached waveform display + +Timeline audio clips show mono min/max peaks from an immutable C summary, +shared by reference path across split/duplicated clips. Up to 64 paths each +cache at most 1024 bins (fewer for short files). It uses playback's WAV parser, +downmix and file/frame bounds; creation temporarily loads one WAV then frees +its sample storage. Bins cover every source sample, including a final partial +bin. The display follows source offsets and current tempo; it shows **raw source +peaks before clip/track gain and fades**, clamped visually to +/-1. It is an +arrangement aid, not a sample editor or loudness meter. + +Loading/rebuilding is synchronous on the GUI/control thread during project +binding or edits that introduce media paths. Large files/slow disks can delay +that UI operation. Draw/redraw, scrolling and playback never load a WAV or build +peaks; the audio callback does not use this cache at all. Missing/invalid assets +show no waveform and keep the existing playback issue reporting. Failures are +cached, removed paths are evicted, and Open rebuilds caches, including changed +external files. No persistent disk cache, file watcher or background loader is +claimed. Source files should remain unchanged during a session; reopen after +external replacement. Cache allocation failure omits previews without changing +music metadata. + +`make test-audio-editing` verifies exact split/gain/fade playback, odd blocks, +seeks, shortened media tails, one-frame files, source/project limits, capacity +rollback, duplication/fade inheritance, persistence/invalid metadata, undo, peak +bins after source removal and byte-identical source preservation. GUI tests +cover short-clip selection, shared previews, split/duplicate/delete and undo. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 8793060..9fe66ff 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -21,7 +21,7 @@ zoom/selection and graph positions are session UI state. Undo retains 32 edits plus baseline; allocation failures are reported, not silently hidden. Media is never removed by undo/delete. Broad screen-reader/accessibility testing is pending. -- Format v7 reads v1–v6 and uses relative local media references. External absolute +- Format v8 reads v1–v7 and uses relative local media references. External absolute media/module paths need manual repair after relocation. Saving does not bundle media; missing media stays referenced, missing plugins reject GUI installation. Unknown data is rejected rather than stripped. Post-rename directory-sync errors @@ -33,3 +33,8 @@ See ROADMAP.md, AUDIO_QUALITY.md, CONTROL.md, WORKFLOW.md, PROJECT_FORMAT.md, PERFORMANCE.md, PLUGIN_HOSTING.md and RECOVERY.md for tested contracts/evidence. + +- Waveform previews load synchronously on the control thread and cache at most + 64 paths/1024 bins each. Reopen after external media replacement. Audio cuts + use tick resolution; GUI safety fades can create a short dip at split edges. + Time stretching, independent pitch shifting and crossfades are deferred. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index f62a2aa..3604a07 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -1,4 +1,4 @@ -# Project format v7 (reads v1–v6) +# Project format v8 (reads v1–v7) Phases 5–15 use a bounded text format implemented in C99 without a parser library. The extension is `.xtc`. File operations run exclusively on the control thread. @@ -151,7 +151,7 @@ discrete-mode compatibility are checked on control-thread compilation. Versions ## Phase 18: version 7 and project folders -Current writer emits v7; readers migrate v1–v6. Record ordering and field bounds +Phase 18 introduced v7; its readers migrated v1–v6. Record ordering and field bounds are otherwise unchanged from v6. v7 ASSET may contain a relative path, interpreted against the actual containing directory of the named project file (resolved on the control thread), never the launch working directory. Opening a symlink to a project uses the @@ -189,3 +189,29 @@ truncation and deterministic byte mutations, malformed plugin metadata, interrup writers and write-limit failures. ASan/UBSan/static analysis additionally audit the bounded parser. This is regression/fault-injection coverage, not exhaustive fuzzing or a claim of power-cut qualification. + +## Phase 22: version 8 audio clip settings + +Current writer emits v8 and reads v1–v7 with unity clip gain and no explicit +clip fades. After **every** clip's `MIDI` record (including silent and MIDI +clips), v8 requires: + +```text +AUDIO gain_float32_hex_bits fade_in_ticks fade_out_ticks +``` + +Gain is finite, linear and within 0..4. Fade durations are unsigned decimal, +0..source_length_tick; zero disables that explicit fade. MIDI clips require +gain exactly 1 and both fades zero. Invalid or missing AUDIO records reject the +whole candidate. All v7 media-folder rules and atomic-save guarantees remain. +Older applications reject v8 rather than silently stripping clip settings. +Rebuild C/C++ consumers against the extended `xtc_clip_info` public struct; +this development release does not promise an unchanged struct binary layout. + +Fades follow the audible trimmed clip boundaries, in current-tempo ticks, with +sample endpoints defined in AUDIO_FILES.md. Duplicating retains settings and +source references; splitting keeps the original ID on the left, gives the right +a fresh ID/source offset, retains outer explicit fades and clears inner ones. +No waveform cache or source audio is serialized. Checked-in v1–v7 migrations, +v8 exact roundtrips, malformed new metadata and byte-mutation tests cover the +format change (`make test test-portability test-audio-editing`). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 541d759..d1e0e35 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -516,6 +516,23 @@ Provide focused, non-destructive audio editing tools for arranging and preparing - Clip boundaries do not produce unintended clicks from invalid offsets or discontinuities. - Editing and waveform generation never perform blocking work in the real-time audio callback. +Implemented: transactional audio/silent-clip splitting with stable IDs and source +offsets; persisted linear clip gain and explicit in/out fades in v8 (v1–v7 migrate +to unity/no explicit fades); undoable sequencer menu/context/keyboard controls; +immutable bounded source-peak summaries shared across clips and drawn from +cached memory. Existing move/trim/duplicate/delete remain supported; very short +clip hit testing now matches drawing. See AUDIO_FILES.md and PROJECT_FORMAT.md. +Stretch/pitch processing and crossfades remain future work; waveform loading is +synchronous on the control thread, and physical listening remains pending. + +Acceptance evidence: +- [x] Core/GUI regressions cover split/duplicate/delete and snapshot undo. +- [x] Exact byte comparison verifies source files survive all metadata edits. +- [x] Boundary tests cover invalid offsets, short tails, one-frame media, explicit + fade endpoints, gain, block partitions, seeks and capacity rollback. +- [x] Waveform construction stays on control-thread paths; redraw reads cached + peaks and audio rendering retains immutable/preloaded callback storage. + ## Phase 23 — Modular Workflow and Routing Refinement ### Goal diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 3f34ed7..ec3976d 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -105,3 +105,14 @@ Click selects a single note; Ctrl+A selects all filtered notes. Ctrl+D repeats that selection at a chosen spacing within the existing clip source. Up/Down transposes by a semitone. Commands are undoable through the main Edit menu. See [MIDI.md](MIDI.md#phase-21-composition-commands) for shortcuts, timing and limits. + +## Audio clip tools + +Select an audio clip, click the ruler at the desired cut while stopped, then +choose Edit / Split audio at cursor (S with timeline focus). Ctrl+D duplicates; +Delete removes the clip reference. Drag the body to move and edges to trim. +Edit / Audio clip gain and fades (A) sets linear gain and fade durations in ticks. +All are undoable and preserve the WAV file. Waveforms show cached raw source +peaks, before gain/fades; large media can take time to load on Open/import. +See [AUDIO_FILES.md](AUDIO_FILES.md#phase-22-non-destructive-clip-editing) for +boundary behavior, preview limits and the deferred stretch/pitch tools. diff --git a/include/xtc/xtc_audio.h b/include/xtc/xtc_audio.h index 1e07ef3..6236aec 100644 --- a/include/xtc/xtc_audio.h +++ b/include/xtc/xtc_audio.h @@ -10,6 +10,26 @@ extern "C" { typedef struct xtc_audio_plan xtc_audio_plan; typedef struct xtc_recorder xtc_recorder; +/* Control-thread metadata edits; audio/silent clips only. No source-file writes. + * Gain 0..4, fade durations 0..source_length_tick, measured at current tempo. + * Fades follow audible trimmed boundaries; 0 disables the explicit fade. */ +xtc_result xtc_project_set_clip_audio(xtc_project *project, uint64_t clip, float gain, + uint64_t fade_in_tick, uint64_t fade_out_tick); +/* Split strictly inside the timeline range. Left keeps ID; right receives a + * fresh ID and adjusted source offset. Gain/source/name retained. Inner explicit + * fades cleared; outer fades retained. Transactional; output unchanged on error. */ +xtc_result xtc_project_split_audio(xtc_project *project, uint64_t clip, uint64_t tick, uint64_t *out_right); + +/* Immutable waveform summary, created/destroyed on control thread only. Uses + * the same bounded WAV reader/downmix as playback; no media writes. Up to 1024 + * equal frame bins, fewer for short media. Peaks are raw source, before gain or + * fades. Accessors read cached memory only. Load errors are explicit. */ +typedef struct xtc_waveform xtc_waveform; +xtc_result xtc_waveform_create(const char *path, xtc_waveform **out); +void xtc_waveform_destroy(xtc_waveform *waveform); +xtc_result xtc_waveform_info(const xtc_waveform *waveform, unsigned int *rate, size_t *frames, size_t *bins); +xtc_result xtc_waveform_peak(const xtc_waveform *waveform, size_t bin, float *minimum, float *maximum); + /* Control-thread WAV import: PCM16/24/32 or float32, mono/stereo downmixed to * mono, 1..768000 Hz. Absolute canonical references, <=511 bytes. * Adds a timeline source -> output route if no timeline source exists. @@ -67,11 +87,11 @@ xtc_result xtc_audio_plan_set_fade_frames(xtc_audio_plan *plan, size_t frames); * @return Number of unique asset issues; zero for a NULL plan. */ size_t xtc_audio_plan_issues(const xtc_audio_plan *plan, const char **out_first_path); -/* Single consumer; output replaced with overlap-summed mono at unity gain. +/* Single consumer; output replaced with overlap-summed mono, clip/track gain and fades. * Linear rate conversion, no time stretching. Output must have frames floats. */ /* Stereo outputs must be distinct; NULL right selects mono gain-only rendering. */ /** - * @brief Replaces output with the overlap-summed timeline mix, applying track gain and pan. + * @brief Replaces output with the overlap-summed timeline mix, applying clip gain/fades and track gain/pan. * * @param plan Preloaded arrangement plan. * @param position Absolute sample position at the start of the block. diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index a73461f..abc065f 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -7,7 +7,7 @@ extern "C" { #endif -#define XTC_PROJECT_FORMAT_VERSION 7U +#define XTC_PROJECT_FORMAT_VERSION 8U #define XTC_PROJECT_MAX_TRACKS 64U #define XTC_PROJECT_MAX_CLIPS 1024U #define XTC_PROJECT_NAME_BYTES 64U @@ -35,6 +35,8 @@ typedef struct { uint64_t id, track_id, start_tick, length_tick, offset_tick, source_length_tick; char name[XTC_PROJECT_NAME_BYTES]; char asset_path[XTC_ASSET_PATH_BYTES]; + float gain; /* Audio-only, linear 0..4; default 1. */ + uint64_t fade_in_tick, fade_out_tick; /* Audio-only linear boundary fades. */ int midi; /* 0 silent/audio region, 1 MIDI clip; no asset path in MIDI. */ } xtc_clip_info; @@ -209,7 +211,7 @@ xtc_result xtc_project_sample_to_tick(const xtc_project *project, uint64_t sampl uint64_t *out_tick); /* Control-thread I/O. Versioned, bounded format; see docs/PROJECT_FORMAT.md. * Load is transactional. Save fsyncs a same-directory temporary and renames it; - * failures before rename preserve the previous destination. No asset I/O. v7 saves local media paths relative to the project folder; load + * failures before rename preserve the previous destination. No asset I/O. v7+ saves local media paths relative to the project folder; load * resolves them to absolute references. Post-rename directory-sync failure can * report IO with the complete new document already published. */ /** diff --git a/src/audio/arrangement.c b/src/audio/arrangement.c index 35892c4..8bb527c 100644 --- a/src/audio/arrangement.c +++ b/src/audio/arrangement.c @@ -8,7 +8,8 @@ #include typedef struct { - uint64_t start, end, offset; + uint64_t start, end, offset, fade_in, fade_out; + float gain; size_t asset, track; } region; @@ -185,6 +186,9 @@ xtc_result xtc_audio_plan_create(const xtc_project *project, unsigned int rate, result = xtc_project_tick_to_sample(project, clip->start_tick + clip->length_tick, rate, &r->end); if (result == XTC_OK) result = xtc_project_tick_to_sample(project, clip->offset_tick, rate, &r->offset); + r->gain = clip->gain; + if (result == XTC_OK) result = xtc_project_tick_to_sample(project, clip->fade_in_tick, rate, &r->fade_in); + if (result == XTC_OK) result = xtc_project_tick_to_sample(project, clip->fade_out_tick, rate, &r->fade_out); if (result != XTC_OK) break; } @@ -219,7 +223,7 @@ xtc_result xtc_audio_plan_set_fade_frames(xtc_audio_plan *plan, size_t frames) } /** - * @brief Renders a block of the plan at unity gain with linear rate conversion. + * @brief Renders a block with clip/track gain, boundary fades and linear rate conversion. * * Both buffers are replaced, not mixed into. Rendering performs no allocation, * file access, or locking. Non-finite output samples are replaced with zero. @@ -247,7 +251,7 @@ xtc_result xtc_audio_plan_render_stereo(const xtc_audio_plan *plan, uint64_t pos uint64_t audible_end = r->end; uint64_t begin = position > r->start ? position : r->start; uint64_t end; - if (plan->fade_frames && wave->samples) { + if (wave->samples) { uint64_t duration = ((uint64_t)wave->frames * plan->rate + wave->rate - 1U) / wave->rate; if (duration <= r->offset) continue; if (duration - r->offset < audible_end - r->start) @@ -274,6 +278,16 @@ xtc_result xtc_audio_plan_render_stereo(const xtc_audio_plan *plan, uint64_t pos if (distance < plan->fade_frames) sample *= (float)distance / (float)plan->fade_frames; } + { + uint64_t age = position + j - r->start, tail = audible_end - 1U - (position + j); + float envelope = 1.0f; + if (r->fade_in && age < r->fade_in) envelope = (float)age / (float)r->fade_in; + if (r->fade_out && tail < r->fade_out) { + float fade = (float)tail / (float)r->fade_out; + if (fade < envelope) envelope = fade; + } + sample *= r->gain * envelope; + } output[j] += sample * xtc_mix_level(&plan->mix, r->track, position + j, right ? 0U : 2U); if (right) right[j] += sample * xtc_mix_level(&plan->mix, r->track, position + j, 1); diff --git a/src/audio/waveform.c b/src/audio/waveform.c new file mode 100644 index 0000000..011726e --- /dev/null +++ b/src/audio/waveform.c @@ -0,0 +1,53 @@ +#include "wave_internal.h" +#include + +struct xtc_waveform { + unsigned int rate; + size_t frames, bins; + float minimum[1024], maximum[1024]; +}; + +xtc_result xtc_waveform_create(const char *path, xtc_waveform **out) +{ + xtc_wave wave = {0}; + xtc_waveform *summary; + xtc_result result; + size_t i, j; + if (!out) return XTC_ERR_INVALID_ARGUMENT; + *out = NULL; + if (!path) return XTC_ERR_INVALID_ARGUMENT; + result = xtc_wave_load(path, &wave); + if (result != XTC_OK) return result; + summary = calloc(1, sizeof(*summary)); + if (!summary) { free(wave.samples); return XTC_ERR_OUT_OF_MEMORY; } + summary->rate = wave.rate; + summary->frames = wave.frames; + summary->bins = wave.frames < 1024 ? wave.frames : 1024; + for (i = 0; i < summary->bins; ++i) { + size_t first = (size_t)((uint64_t)i * wave.frames / summary->bins); + size_t end = (size_t)((uint64_t)(i+1) * wave.frames / summary->bins); + float low = wave.samples[first], high = low; + for (j = first+1; j < end; ++j) { + if (wave.samples[j] < low) low = wave.samples[j]; + if (wave.samples[j] > high) high = wave.samples[j]; + } + summary->minimum[i] = low; + summary->maximum[i] = high; + } + free(wave.samples); + *out = summary; + return XTC_OK; +} +void xtc_waveform_destroy(xtc_waveform *p) { free(p); } +xtc_result xtc_waveform_info(const xtc_waveform *p, unsigned int *rate, size_t *frames, size_t *bins) +{ + if (!p || !rate || !frames || !bins) return XTC_ERR_INVALID_ARGUMENT; + *rate = p->rate; *frames = p->frames; *bins = p->bins; + return XTC_OK; +} +xtc_result xtc_waveform_peak(const xtc_waveform *p, size_t bin, float *low, float *high) +{ + if (!p || !low || !high || bin >= p->bins) return XTC_ERR_INVALID_ARGUMENT; + *low = p->minimum[bin]; *high = p->maximum[bin]; + return XTC_OK; +} diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index f56ba86..d29dbf6 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -147,6 +147,8 @@ MainWindow::MainWindow(bool prompt_unsaved) timeline_scroll->end(); sequencer_->before_edit = before_arrangement_edit; menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast(c)->duplicate_selection(); },sequencer_); + menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->split_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); },sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 5beea6a..9b70ee7 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include /** * @brief Constructs the timeline widget with a borrowed project and seek callback. @@ -33,10 +35,12 @@ void SequencerWidget::set_project(xtc_project *project, bool reset) { project_ = project; if (reset) { + waves_.clear(); track_ = clip_ = UINT64_MAX; cursor_ = 0; mode_ = 0; } + refresh_waveforms(); extent(); redraw(); } @@ -82,6 +86,7 @@ void SequencerWidget::changed(xtc_result result) if (result != XTC_OK) fl_alert("%s", xtc_result_string(result)); else { + refresh_waveforms(); extent(); do_callback(); } @@ -244,6 +249,7 @@ void SequencerWidget::draw() fl_color(c.id == clip_ ? fl_rgb_color(100, 170, 205) : fl_rgb_color(65, 120, 150)); fl_rectf(a, py + 5, b - a, 44); fl_push_clip(a, py + 5, b - a, 44); + draw_waveform(c, left, py); fl_color(FL_WHITE); char label[96]; std::snprintf(label,sizeof(label),"%s: %s",c.midi ? "MIDI" : c.asset_path[0] ? "Audio" : "Region",c.name); @@ -293,7 +299,7 @@ int SequencerWidget::handle(int event) xtc_clip_info c{}; xtc_project_get_clip(project_, j - 1, &c); int left = x() + 120 + static_cast(c.start_tick * static_cast(scale_) / 960U); - int right = left + static_cast(c.length_tick * static_cast(scale_) / 960U); + int right = left + std::max(4, static_cast(c.length_tick * static_cast(scale_) / 960U)); if (c.track_id == track_ && Fl::event_x() >= left && Fl::event_x() < right) { clip_ = c.id; original_ = c; @@ -304,12 +310,15 @@ int SequencerWidget::handle(int event) } if (Fl::event_button() == FL_RIGHT_MOUSE) { mode_ = 0; - Fl_Menu_Item items[4]{}; + Fl_Menu_Item items[6]{}; items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)"; + items[3].text = "Split audio at cursor (S)"; items[4].text = "Audio gain and fades (A)"; const Fl_Menu_Item *picked = items->popup(Fl::event_x(),Fl::event_y()); if (picked == items) duplicate_selection(); else if (picked == items+1) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); } else if (picked == items+2) delete_selection(); + else if (picked == items+3) split_selection(); + else if (picked == items+4) audio_properties_dialog(); } redraw(); return 1; @@ -349,6 +358,10 @@ int SequencerWidget::handle(int event) static_cast(length), static_cast(offset))); return 1; } + if (event == FL_KEYDOWN && !Fl::event_state(FL_CTRL | FL_ALT)) { + if (Fl::event_key() == 's') { split_selection(); return 1; } + if (Fl::event_key() == 'a') { audio_properties_dialog(); return 1; } + } if (event == FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key() == 'd') { duplicate_selection(); return 1; } if (event == FL_KEYDOWN && Fl::event_key() == FL_F+2) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); return 1; @@ -398,3 +411,97 @@ xtc_result SequencerWidget::move_track(int direction) if (r == XTC_OK) r = xtc_project_move_track(project_,track_,direction < 0 ? i-1 : i+1); changed(r); return r; } + +xtc_result SequencerWidget::split_selection() +{ + uint64_t right; + const uint64_t split_tick = cursor_; // Stop/status refresh may move the playhead. + xtc_result result = before_edit ? before_edit(context_) : XTC_OK; + if (result == XTC_OK) result = xtc_project_split_audio(project_, clip_, split_tick, &right); + if (result == XTC_OK) clip_ = right; + changed(result); + return result; +} + +xtc_result SequencerWidget::audio_properties(float gain, uint64_t in, uint64_t out) +{ + xtc_result result = before_edit ? before_edit(context_) : XTC_OK; + if (result == XTC_OK) result = xtc_project_set_clip_audio(project_, clip_, gain, in, out); + changed(result); + return result; +} + +void SequencerWidget::audio_properties_dialog() +{ + xtc_clip_info clip{}; + if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || clip.midi) { + fl_alert("Select an audio clip or silent region first."); + return; + } + char defaults[128]; + std::snprintf(defaults, sizeof(defaults), "%.9g %llu %llu", static_cast(clip.gain), + static_cast(clip.fade_in_tick), static_cast(clip.fade_out_tick)); + const char *text = fl_input("Linear gain (0..4), fade-in ticks, fade-out ticks (0 disables)", defaults); + if (!text) return; + std::istringstream input(text); + float gain; uint64_t in, out; + if (!(input >> gain >> in >> out) || !(input >> std::ws).eof()) { + fl_alert("Enter gain and two fade durations in ticks."); return; + } + audio_properties(gain, in, out); +} + +void SequencerWidget::refresh_waveforms() try +{ + // Control-thread loading only. Keep cached failures as well as successes; + // removed/relinked paths are evicted. Reopening rebuilds externally changed files. + size_t tracks, clips; + xtc_project_get_counts(project_, &tracks, &clips); + std::vector next; + for (size_t i = 0; i < clips && next.size() < 64; ++i) { + xtc_clip_info c{}; + xtc_project_get_clip(project_, i, &c); + if (!c.asset_path[0]) continue; + bool present = false; + for (const auto &wave : next) if (wave.path == c.asset_path) { present = true; break; } + if (present) continue; + Wave entry; + entry.path = c.asset_path; + for (auto &wave : waves_) if (wave.path == entry.path) { + entry.data = std::move(wave.data); present = true; break; + } + if (!present) { + xtc_waveform *summary = nullptr; + (void)xtc_waveform_create(c.asset_path, &summary); + entry.data.reset(summary); + } + next.push_back(std::move(entry)); + } + waves_ = std::move(next); +} catch (const std::bad_alloc &) { + waves_.clear(); // Preview failure must not interrupt the model/history edit. +} + +void SequencerWidget::draw_waveform(const xtc_clip_info &c, int left, int top) +{ + const xtc_waveform *summary = nullptr; + for (const auto &wave : waves_) if (wave.path == c.asset_path) { summary = wave.data.get(); break; } + if (!summary) return; + unsigned int rate; size_t frames, bins; + if (xtc_waveform_info(summary, &rate, &frames, &bins) != XTC_OK) return; + double ticks_per_frame = static_cast(xtc_project_tempo(project_)) * 960 / (rate * 60000.0); + fl_color(fl_rgb_color(30, 65, 80)); + for (size_t bin = 0; bin < bins; ++bin) { + double start = static_cast(bin * frames / bins) * ticks_per_frame; + double end = static_cast((bin+1) * frames / bins) * ticks_per_frame; + double offset = static_cast(c.offset_tick); + if (end <= offset || start >= offset + static_cast(c.length_tick)) continue; + int px = left + static_cast((std::max(start, offset) - offset) * scale_ / 960); + int width = std::max(1, static_cast((end - std::max(start, offset)) * scale_ / 960)); + float low, high; + if (xtc_waveform_peak(summary, bin, &low, &high) != XTC_OK) continue; + int y1 = top + 28 - static_cast(std::clamp(high, -1.0f, 1.0f) * 19); + int y2 = top + 28 - static_cast(std::clamp(low, -1.0f, 1.0f) * 19); + fl_rectf(px, y1, width, std::max(1, y2-y1+1)); + } +} diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index db6385c..42b280f 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -1,7 +1,10 @@ #ifndef XTC_SEQUENCER_WIDGET_HPP #define XTC_SEQUENCER_WIDGET_HPP #include -#include "xtc/xtc_project.h" +#include "xtc/xtc_audio.h" +#include +#include +#include class SequencerWidget : public Fl_Widget { public: @@ -11,6 +14,12 @@ class SequencerWidget : public Fl_Widget { * @return XTC_OK to allow the edit, or an error to cancel it. */ xtc_result duplicate_selection(); + xtc_result split_selection(); + xtc_result audio_properties(float gain, uint64_t fade_in, uint64_t fade_out); + void audio_properties_dialog(); + void refresh_waveforms(); + size_t waveform_count() const { return waves_.size(); } + xtc_result rename_selection(const char *name); xtc_result move_track(int direction); xtc_result grid(uint64_t ticks); @@ -131,6 +140,12 @@ class SequencerWidget : public Fl_Widget { void draw() override; private: + struct Wave { + std::string path; + std::unique_ptr data{nullptr, xtc_waveform_destroy}; + }; + std::vector waves_; + void draw_waveform(const xtc_clip_info &clip, int left, int top); xtc_project *project_; Seek seek_; void *context_; diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index 29d3303..748f90a 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -351,6 +351,13 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * return XTC_ERR_FORMAT; clip->midi = (int)value; } + clip->gain = 1.0f; + if (format_version >= 8U) { + READ("AUDIO", 4U); + if (!decode_float(input->tokens[1], &clip->gain)) return XTC_ERR_FORMAT; + NUM(2U, clip->fade_in_tick); + NUM(3U, clip->fade_out_tick); + } } if (format_version >= 3U) { READ("NOTES", 3U); @@ -513,6 +520,12 @@ static xtc_result write_project(FILE *file, const xtc_project *project, const ch return XTC_ERR_IO; if (fprintf(file, "MIDI %d\n", clip->midi) < 0) return XTC_ERR_IO; + { + uint32_t bits; + memcpy(&bits, &clip->gain, sizeof(bits)); + if (fprintf(file, "AUDIO %08" PRIx32 " %" PRIu64 " %" PRIu64 "\n", bits, + clip->fade_in_tick, clip->fade_out_tick) < 0) return XTC_ERR_IO; + } } if (fprintf(file, "NOTES %" PRIu64 " %zu\n", project->next_note_id, project->note_count) < 0) return XTC_ERR_IO; diff --git a/src/project/project.c b/src/project/project.c index 85a38a0..3347d11 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -358,6 +358,7 @@ xtc_result xtc_project_add_clip(xtc_project *project, uint64_t track, const char return XTC_ERR_NOT_FOUND; if (project->clip_count == XTC_PROJECT_MAX_CLIPS || project->next_clip_id == UINT64_MAX) return XTC_ERR_CAPACITY; + clip.gain = 1.0f; clip.id = project->next_clip_id++; clip.track_id = track; clip.start_tick = start; @@ -527,6 +528,10 @@ xtc_result xtc_project_validate(const xtc_project *project) } if (path_length == XTC_ASSET_PATH_BYTES || (path_length && clip->asset_path[0] != '/')) return XTC_ERR_FORMAT; + if (!isfinite(clip->gain) || clip->gain < 0 || clip->gain > 4 || + clip->fade_in_tick > clip->source_length_tick || clip->fade_out_tick > clip->source_length_tick || + (clip->midi && (clip->gain != 1 || clip->fade_in_tick || clip->fade_out_tick))) + return XTC_ERR_FORMAT; if ((clip->midi != 0 && clip->midi != 1) || (clip->midi && path_length)) return XTC_ERR_FORMAT; if (clip->id >= project->next_clip_id || track_index(project, clip->track_id) == SIZE_MAX || @@ -646,3 +651,42 @@ xtc_result xtc_project_move_track(xtc_project *p, uint64_t id, size_t destinatio else if (i > destination) memmove(p->tracks+destination+1,p->tracks+destination,(i-destination)*sizeof(track)); p->tracks[destination] = track; return XTC_OK; } + +xtc_result xtc_project_set_clip_audio(xtc_project *p, uint64_t id, float gain, uint64_t in, uint64_t out) +{ + size_t i; + if (!p || !isfinite(gain) || gain < 0 || gain > 4) return XTC_ERR_INVALID_ARGUMENT; + i = clip_index(p, id); + if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; + if (p->clips[i].midi) return XTC_ERR_INVALID_STATE; + if (in > p->clips[i].source_length_tick || out > p->clips[i].source_length_tick) return XTC_ERR_INVALID_ARGUMENT; + p->clips[i].gain = gain; + p->clips[i].fade_in_tick = in; + p->clips[i].fade_out_tick = out; + return XTC_OK; +} + +xtc_result xtc_project_split_audio(xtc_project *p, uint64_t id, uint64_t tick, uint64_t *out) +{ + size_t i; + xtc_clip_info right; + uint64_t left_length; + if (!p || !out) return XTC_ERR_INVALID_ARGUMENT; + i = clip_index(p, id); + if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; + right = p->clips[i]; + if (right.midi) return XTC_ERR_INVALID_STATE; + if (tick <= right.start_tick || tick >= right.start_tick + right.length_tick) return XTC_ERR_INVALID_ARGUMENT; + if (p->clip_count == XTC_PROJECT_MAX_CLIPS || p->next_clip_id == UINT64_MAX) return XTC_ERR_CAPACITY; + left_length = tick - right.start_tick; + right.id = p->next_clip_id++; + right.start_tick = tick; + right.offset_tick += left_length; + right.length_tick -= left_length; + right.fade_in_tick = 0; + p->clips[i].length_tick = left_length; + p->clips[i].fade_out_tick = 0; + p->clips[p->clip_count++] = right; + *out = right.id; + return XTC_OK; +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 07f600d..0337b43 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -2,3 +2,8 @@ Store small, redistributable fixtures here. Document their origin and license. Do not commit large recordings or assets with unclear licensing. + +`project-v1.xtc` through `project-v7.xtc` are hand-authored xtc metadata fixtures +created for this repository, under the project license. They contain no audio +or third-party resources. The v5/v6 plugin fixtures intentionally reference a +missing module to exercise metadata migration without loading code. diff --git a/tests/fixtures/project-v7.xtc b/tests/fixtures/project-v7.xtc new file mode 100644 index 0000000..f897cc1 --- /dev/null +++ b/tests/fixtures/project-v7.xtc @@ -0,0 +1,20 @@ +XTC_PROJECT 7 +APP 0 1 0 +TEMPO 120000 +IDS 1 1 +GRAPH 1 2 2 1 +NODE 0 1 3f000000 +NODE 1 4 00000000 +EDGE 0 1 +TRACKS 1 +TRACK 0 4b657973 +MIX 3f800000 00000000 0 0 +CLIPS 1 +CLIP 0 0 0 960 0 960 5665727365 +ASSET - +MIDI 1 +NOTES 1 1 +NOTE 0 0 0 480 69 100 0 +AUTOMATION 1 +POINT 0 0 0 3f800000 +END diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 603453c..221d0ff 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -332,6 +332,31 @@ int main(int argc, char **argv) press(stop); CHECK(handle.recorder == nullptr); CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && clip_count == 2); + CHECK(sequence->waveform_count() == 1); + // Short clips use their four-pixel draw width for hit testing too. + xtc_clip_info audio_clip{}; + CHECK(xtc_project_get_clip(sequence->project(), 1, &audio_clip) == XTC_OK && audio_clip.asset_path[0]); + size_t audio_row = 0; + for (; audio_row < track_count; ++audio_row) { + xtc_track_info t{}; xtc_project_get_track(sequence->project(), audio_row, &t); + if (t.id == audio_clip.track_id) break; + } + gesture(sequence, 121 + static_cast(audio_clip.start_tick*48/960), 50 + static_cast(audio_row)*56, 0, 0); + CHECK(sequence->selected_clip() == audio_clip.id); + CHECK(sequence->audio_properties(0.5f, 1, 1) == XTC_OK); + sequence->position(audio_clip.start_tick+1); + int split_key = Fl::e_keysym, split_state = Fl::e_state; + Fl::e_keysym = 's'; Fl::e_state = 0; + CHECK(sequence->handle(FL_KEYDOWN) == 1); + Fl::e_keysym = split_key; Fl::e_state = split_state; + CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && clip_count == 3); + CHECK(sequence->duplicate_selection() == XTC_OK && sequence->waveform_count() == 1); + CHECK(sequence->delete_selection() == XTC_OK); + CHECK(window.undo() == XTC_OK); + CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && clip_count == 4); + CHECK(window.undo() == XTC_OK && window.undo() == XTC_OK && window.undo() == XTC_OK); + CHECK(xtc_project_find_clip(sequence->project(), audio_clip.id, &audio_clip) == XTC_OK && audio_clip.gain == 1 && audio_clip.fade_in_tick == 0 && audio_clip.length_tick == 3); + CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && clip_count == 2); CHECK(window.save_project(filename.c_str()) == XTC_OK); CHECK(window.open_project(filename.c_str()) == XTC_OK); float mix[64]; CHECK(xtc_audio_plan_render(handle.audio, 0, mix, 64) == XTC_OK && mix[0] == 0.25f); diff --git a/tests/integration/test_install.py b/tests/integration/test_install.py index de295b3..f67c66f 100644 --- a/tests/integration/test_install.py +++ b/tests/integration/test_install.py @@ -37,7 +37,7 @@ def main(): assert os.access(root / 'bin/xtc-check', os.X_OK) run([str(root / 'bin/xtc-check')]) diagnose = subprocess.check_output([str(root / 'bin/xtc-check'), '--diagnose'], text=True) - assert 'Project formats: 1..7' in diagnose and 'JACK compiled:' in diagnose + assert 'Project formats: 1..8' in diagnose and 'JACK compiled:' in diagnose # Upgrade/repair repeats the same owned-file install and keeps unrelated data. (root / 'include/xtc/xtc.h').write_text('old installation marker\n') run(make + ['install'] + variables, env=env) diff --git a/tests/integration/test_portability.c b/tests/integration/test_portability.c index c97121b..d724e0e 100644 --- a/tests/integration/test_portability.c +++ b/tests/integration/test_portability.c @@ -18,12 +18,13 @@ int main(void) assert(mkdtemp(root)); assert(xtc_project_create(&p) == XTC_OK); snprintf(file,sizeof(file),"%s/migration.xtc",root); - for (version = 1; version <= 6; ++version) { + for (version = 1; version <= 7; ++version) { xtc_track_info t; xtc_clip_info c; size_t notes, points; snprintf(old,sizeof(old),"tests/fixtures/project-v%u.xtc",version); assert(xtc_project_load(p,old) == XTC_OK); assert(xtc_project_get_track(p,0,&t) == XTC_OK && !strcmp(t.name,"Keys") && t.gain == 1); assert(xtc_project_get_clip(p,0,&c) == XTC_OK && c.midi == (version >= 3)); + assert(c.gain == 1 && c.fade_in_tick == 0 && c.fade_out_tick == 0); assert(xtc_project_note_count(p,¬es) == XTC_OK && notes == (size_t)(version >= 3)); assert(xtc_project_automation_count(p,&points) == XTC_OK && points == (size_t)(version >= 4)); assert(xtc_project_save(p,file) == XTC_OK && xtc_project_load(p,file) == XTC_OK); @@ -83,6 +84,6 @@ int main(void) assert(xtc_project_load(p,file) == XTC_ERR_FORMAT); { xtc_clip_info c; assert(xtc_project_find_clip(p,clip,&c) == XTC_OK && c.asset_path[0]); } } xtc_project_destroy(p); unlink(file); rmdir(moved); - puts("v1-v6 migration, v7 folder relocation, cwd independence and missing-media preservation passed."); + puts("v1-v7 migration, current folder relocation, cwd independence and missing-media preservation passed."); return 0; } diff --git a/tests/unit/test_audio_editing.c b/tests/unit/test_audio_editing.c new file mode 100644 index 0000000..8f557d1 --- /dev/null +++ b/tests/unit/test_audio_editing.c @@ -0,0 +1,159 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_audio.h" +#include "xtc/xtc_midi.h" +#include "xtc/xtc_mixer.h" +#include +#include +#include +#include +#include +#include + +int main(void) +{ + char directory[] = "/tmp/xtc-audio-edits-XXXXXX", path[256], song[256], missing[256]; + unsigned char original[5000], final[5000]; size_t bytes; + xtc_project *p = NULL, *q = NULL, *undo = NULL; + xtc_history *history = NULL; + xtc_audio_plan *a = NULL, *b = NULL; + xtc_recorder *rec = NULL; + xtc_waveform *wave = NULL; + xtc_clip_info left, right, copy; + uint64_t track, clip, split, duplicate, midi, unchanged = UINT64_MAX; + size_t i, frames, bins, tracks, clips; + unsigned int rate; + float source[1003], before[1100], after[1100], blocks[1100], low, high; + FILE *file; + assert(mkdtemp(directory)); + snprintf(path, sizeof(path), "%s/source.wav", directory); + snprintf(song, sizeof(song), "%s/song.xtc", directory); + snprintf(missing, sizeof(missing), "%s/missing.wav", directory); + for (i = 0; i < 1003; ++i) source[i] = (float)(i % 31) / 32; + assert(xtc_recorder_create(path, 48000, 1003, &rec) == XTC_OK); + xtc_recorder_push(rec, source, 1003); + assert(xtc_recorder_finish(rec) == XTC_OK); xtc_recorder_destroy(rec); + file = fopen(path, "rb"); assert(file); bytes = fread(original, 1, sizeof(original), file); assert(fclose(file) == 0); + assert(bytes == 4056); + assert(xtc_waveform_create(path, &wave) == XTC_OK); + assert(xtc_waveform_info(wave, &rate, &frames, &bins) == XTC_OK && rate == 48000 && frames == 1003 && bins == 1003); + for (i = 0; i < bins; ++i) { + assert(xtc_waveform_peak(wave, i, &low, &high) == XTC_OK && low == source[i] && high == source[i]); + } + low = 123; + assert(xtc_waveform_peak(wave, bins, &low, &high) == XTC_ERR_INVALID_ARGUMENT && low == 123); + assert(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK); + assert(xtc_project_add_track(p, "Audio", &track) == XTC_OK); + assert(xtc_project_import_audio(p, track, path, 2, &clip) == XTC_OK); + assert(xtc_project_find_clip(p, clip, &left) == XTC_OK && left.gain == 1 && left.length_tick == 41); + assert(xtc_audio_plan_create(p, 48000, &a) == XTC_OK); + assert(xtc_audio_plan_render(a, 0, before, 1100) == XTC_OK); + assert(xtc_history_create(p, &history) == XTC_OK); + assert(xtc_project_split_audio(p, clip, 2, &unchanged) == XTC_ERR_INVALID_ARGUMENT && unchanged == UINT64_MAX); + assert(xtc_project_split_audio(p, clip, 43, &unchanged) == XTC_ERR_INVALID_ARGUMENT && unchanged == UINT64_MAX); + assert(xtc_project_split_audio(p, clip, 22, &split) == XTC_OK); + assert(xtc_project_find_clip(p, clip, &left) == XTC_OK && left.length_tick == 20 && left.offset_tick == 0); + assert(xtc_project_find_clip(p, split, &right) == XTC_OK && right.offset_tick == 20 && right.length_tick == 21 && right.source_length_tick == 41); + assert(!strcmp(left.asset_path, right.asset_path) && split != clip); + assert(xtc_audio_plan_create(p, 48000, &b) == XTC_OK); + assert(xtc_audio_plan_render(b, 0, after, 1100) == XTC_OK && !memcmp(before, after, sizeof(before))); + xtc_audio_plan_destroy(b); + assert(xtc_history_record(history, p) == XTC_OK && xtc_history_checkout(history, 0, &undo) == XTC_OK); + assert(xtc_project_find_clip(undo, split, ©) == XTC_ERR_NOT_FOUND); + assert(xtc_project_find_clip(undo, clip, ©) == XTC_OK && copy.length_tick == 41); + xtc_project_destroy(undo); + assert(xtc_project_set_clip_audio(p, split, 0.5f, 2, 3) == XTC_OK); + assert(xtc_audio_plan_create(p, 48000, &b) == XTC_OK); + assert(xtc_audio_plan_render(b, 0, after, 1100) == XTC_OK); + /* Start 550, media end 1053; fade-in 50 frames, fade-out 75. */ + assert(after[550] == 0 && after[1052] == 0 && after[1053] == 0); + assert(after[600] == source[550] * 0.5f); + assert(fabsf(after[551] - source[501]*0.5f/50) < 0.000001f); + assert(fabsf(after[1051] - source[1001]*0.5f/75) < 0.000001f); + for (i = 0; i < 1100;) { + size_t n = i % 61 + 1; if (n > 1100-i) n = 1100-i; + assert(xtc_audio_plan_render(b, i, blocks+i, n) == XTC_OK); i += n; + } + assert(!memcmp(after, blocks, sizeof(after))); + assert(xtc_audio_plan_render(b, 551, blocks, 17) == XTC_OK && !memcmp(after+551, blocks, 17*sizeof(float))); + xtc_audio_plan_destroy(b); + assert(xtc_project_set_clip_audio(p, split, NAN, 0, 0) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_set_clip_audio(p, split, 5, 0, 0) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_set_clip_audio(p, split, 1, 42, 0) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_duplicate_clip(p, split, track, 100, &duplicate) == XTC_OK); + assert(xtc_project_find_clip(p, duplicate, ©) == XTC_OK && copy.gain == 0.5f && copy.fade_in_tick == 2 && copy.fade_out_tick == 3 && copy.offset_tick == 20); + /* Splits retain outer fades and clear only inner explicit fades. */ + assert(xtc_project_split_audio(p, duplicate, 110, &unchanged) == XTC_OK); + assert(xtc_project_find_clip(p, duplicate, ©) == XTC_OK && copy.fade_in_tick == 2 && copy.fade_out_tick == 0); + assert(xtc_project_find_clip(p, unchanged, ©) == XTC_OK && copy.fade_in_tick == 0 && copy.fade_out_tick == 3); + assert(xtc_project_save(p, song) == XTC_OK && xtc_project_load(q, song) == XTC_OK); + assert(xtc_project_find_clip(q, split, ©) == XTC_OK && copy.gain == 0.5f && copy.fade_in_tick == 2 && copy.fade_out_tick == 3); + /* A bad v8 AUDIO record rejects the entire load. */ + { + char text[10000], *record; size_t length; + file = fopen(song, "rb"); assert(file); length = fread(text, 1, sizeof(text)-1, file); assert(fclose(file) == 0); + text[length] = 0; record = strstr(text, "AUDIO "); assert(record); + memcpy(record+6, "7fc00000", 8); /* NaN */ + file = fopen(song, "wb"); assert(file); assert(fwrite(text, 1, length, file) == length && fclose(file) == 0); + assert(xtc_project_load(q, song) == XTC_ERR_FORMAT); + assert(xtc_project_find_clip(q, split, ©) == XTC_OK && copy.gain == 0.5f); + } + /* Last source tick, very short audible tail and oversized overlapping fades. */ + assert(xtc_project_edit_clip(q, split, track, 0, 1, 40) == XTC_OK); + assert(xtc_project_set_clip_audio(q, split, 1, 41, 41) == XTC_OK); + assert(xtc_audio_plan_create(q, 48000, &b) == XTC_OK); + assert(xtc_audio_plan_render(b, 0, blocks, 25) == XTC_OK && blocks[0] == 0 && blocks[2] == 0 && blocks[3] == 0); + xtc_audio_plan_destroy(b); + assert(xtc_project_edit_clip(q, split, track, 0, 2, 40) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_edit_clip(q, split, track, XTC_PROJECT_MAX_TICK, 1, 40) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_add_midi_clip(p, track, 1000, 960, &midi) == XTC_OK); + assert(xtc_project_split_audio(p, midi, 1001, &unchanged) == XTC_ERR_INVALID_STATE); + assert(xtc_project_set_clip_audio(p, midi, 1, 0, 0) == XTC_ERR_INVALID_STATE); + assert(xtc_project_get_counts(p, &tracks, &clips) == XTC_OK); + for (i = clips; i < XTC_PROJECT_MAX_CLIPS; ++i) assert(xtc_project_add_clip(p, track, "Full", 0, 1, &midi) == XTC_OK); + unchanged = UINT64_MAX; + assert(xtc_project_split_audio(p, clip, 3, &unchanged) == XTC_ERR_CAPACITY && unchanged == UINT64_MAX); + assert(xtc_project_find_clip(p, clip, ©) == XTC_OK && copy.length_tick == 20); + file = fopen(path, "rb"); assert(file); assert(fread(final, 1, sizeof(final), file) == bytes && fclose(file) == 0); + assert(!memcmp(original, final, bytes)); /* All editing leaves media byte-identical. */ + assert(unlink(path) == 0); + assert(xtc_waveform_peak(wave, 7, &low, &high) == XTC_OK && low == source[7]); + assert(xtc_audio_plan_render(a, 0, blocks, 1100) == XTC_OK && !memcmp(before, blocks, sizeof(before))); + xtc_waveform_destroy(wave); + /* Grouped bins retain extrema, including the final odd-sized bin. */ + { + float long_source[2049]; + for (i = 0; i < 2049; ++i) long_source[i] = (float)((int)(i % 17)-8) / 8; + assert(xtc_recorder_create(path, 48000, 2049, &rec) == XTC_OK); + xtc_recorder_push(rec, long_source, 2049); + assert(xtc_recorder_finish(rec) == XTC_OK); xtc_recorder_destroy(rec); + assert(xtc_waveform_create(path, &wave) == XTC_OK); + assert(xtc_waveform_info(wave, &rate, &frames, &bins) == XTC_OK && bins == 1024); + for (i = 0; i < bins; ++i) { + size_t j, first = i*2049/bins, end = (i+1)*2049/bins; + float expected_low = long_source[first], expected_high = expected_low; + for (j = first+1; j < end; ++j) { + if (long_source[j] < expected_low) expected_low = long_source[j]; + if (long_source[j] > expected_high) expected_high = long_source[j]; + } + assert(xtc_waveform_peak(wave, i, &low, &high) == XTC_OK && low == expected_low && high == expected_high); + } + xtc_waveform_destroy(wave); assert(unlink(path) == 0); + assert(xtc_recorder_create(path, 48000, 1, &rec) == XTC_OK); + source[0] = 1; + xtc_recorder_push(rec, source, 1); + assert(xtc_recorder_finish(rec) == XTC_OK); xtc_recorder_destroy(rec); + assert(xtc_waveform_create(path, &wave) == XTC_OK); + assert(xtc_waveform_info(wave, &rate, &frames, &bins) == XTC_OK && bins == 1 && frames == 1); + xtc_waveform_destroy(wave); + assert(xtc_project_set_clip_asset(q, split, path) == XTC_OK); + assert(xtc_project_edit_clip(q, split, track, 0, 1, 0) == XTC_OK); + assert(xtc_audio_plan_create(q, 48000, &b) == XTC_OK); + assert(xtc_audio_plan_render(b, 0, blocks, 25) == XTC_OK && blocks[0] == 0 && blocks[1] == 0); + xtc_audio_plan_destroy(b); assert(unlink(path) == 0); + } + assert(xtc_waveform_create(missing, &wave) != XTC_OK && wave == NULL); + xtc_audio_plan_destroy(a); xtc_history_destroy(history); xtc_project_destroy(p); xtc_project_destroy(q); + assert(unlink(song) == 0 && rmdir(directory) == 0); + puts("Non-destructive audio split/gain/fades/cache, bounds, persistence and undo passed."); + return 0; +} diff --git a/tests/unit/test_mixer.c b/tests/unit/test_mixer.c index f525ada..b288f33 100644 --- a/tests/unit/test_mixer.c +++ b/tests/unit/test_mixer.c @@ -197,7 +197,7 @@ int main(void) fputc('\n',legacy); continue; } if (strncmp(line, "MIX ", 4) && strncmp(line, "AUTOMATION ", 11) && - strncmp(line, "POINT ", 6)) + strncmp(line, "POINT ", 6) && strncmp(line, "AUDIO ", 6)) assert(fputs(line, legacy) >= 0); } assert(fclose(f) == 0 && fclose(legacy) == 0);