Phase 22: add non-destructive audio splitting, clip gain and fades, and cached waveforms

This commit is contained in:
Johannes Findeisen 2026-10-27 15:46:41 +01:00
commit 01d8c1d661
23 changed files with 650 additions and 18 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,8 @@
#include <string.h>
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);

53
src/audio/waveform.c Normal file
View file

@ -0,0 +1,53 @@
#include "wave_internal.h"
#include <stdlib.h>
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;
}

View file

@ -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<SequencerWidget *>(c)->duplicate_selection(); },sequencer_);
menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->split_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);
},sequencer_);

View file

@ -7,6 +7,8 @@
#include <FL/Fl_Menu_Item.H>
#include <algorithm>
#include <cstdio>
#include <sstream>
#include <new>
/**
* @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<int>(c.start_tick * static_cast<unsigned int>(scale_) / 960U);
int right = left + static_cast<int>(c.length_tick * static_cast<unsigned int>(scale_) / 960U);
int right = left + std::max(4, static_cast<int>(c.length_tick * static_cast<unsigned int>(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<uint64_t>(length), static_cast<uint64_t>(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<double>(clip.gain),
static_cast<unsigned long long>(clip.fade_in_tick), static_cast<unsigned long long>(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<Wave> 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<double>(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<double>(bin * frames / bins) * ticks_per_frame;
double end = static_cast<double>((bin+1) * frames / bins) * ticks_per_frame;
double offset = static_cast<double>(c.offset_tick);
if (end <= offset || start >= offset + static_cast<double>(c.length_tick)) continue;
int px = left + static_cast<int>((std::max(start, offset) - offset) * scale_ / 960);
int width = std::max(1, static_cast<int>((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<int>(std::clamp(high, -1.0f, 1.0f) * 19);
int y2 = top + 28 - static_cast<int>(std::clamp(low, -1.0f, 1.0f) * 19);
fl_rectf(px, y1, width, std::max(1, y2-y1+1));
}
}

View file

@ -1,7 +1,10 @@
#ifndef XTC_SEQUENCER_WIDGET_HPP
#define XTC_SEQUENCER_WIDGET_HPP
#include <FL/Fl_Widget.H>
#include "xtc/xtc_project.h"
#include "xtc/xtc_audio.h"
#include <memory>
#include <string>
#include <vector>
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<xtc_waveform, decltype(&xtc_waveform_destroy)> data{nullptr, xtc_waveform_destroy};
};
std::vector<Wave> waves_;
void draw_waveform(const xtc_clip_info &clip, int left, int top);
xtc_project *project_;
Seek seek_;
void *context_;

View file

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

View file

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

View file

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

20
tests/fixtures/project-v7.xtc vendored Normal file
View file

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

View file

@ -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<int>(audio_clip.start_tick*48/960), 50 + static_cast<int>(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);

View file

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

View file

@ -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,&notes) == 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;
}

View file

@ -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 <assert.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
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, &copy) == XTC_ERR_NOT_FOUND);
assert(xtc_project_find_clip(undo, clip, &copy) == 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, &copy) == 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, &copy) == XTC_OK && copy.fade_in_tick == 2 && copy.fade_out_tick == 0);
assert(xtc_project_find_clip(p, unchanged, &copy) == 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, &copy) == 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, &copy) == 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, &copy) == 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;
}

View file

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