Phase 22: add non-destructive audio splitting, clip gain and fades, and cached waveforms
This commit is contained in:
parent
73fc5eea8d
commit
01d8c1d661
23 changed files with 650 additions and 18 deletions
10
Makefile
10
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
53
src/audio/waveform.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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_);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
5
tests/fixtures/README.md
vendored
5
tests/fixtures/README.md
vendored
|
|
@ -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
20
tests/fixtures/project-v7.xtc
vendored
Normal 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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
159
tests/unit/test_audio_editing.c
Normal file
159
tests/unit/test_audio_editing.c
Normal 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, ©) == 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue