From 2fd77240656854856822bad0d0d2d84634644587 Mon Sep 17 00:00:00 2001 From: hanez Date: Tue, 27 Oct 2026 16:15:34 +0100 Subject: [PATCH] Phase 24: add mix buses, coherent peak meters and master clipping indicators --- Makefile | 8 ++ README.md | 5 +- docs/KNOWN_LIMITATIONS.md | 7 +- docs/MIXER.md | 57 +++++++++++-- docs/PROJECT_FORMAT.md | 16 +++- docs/ROADMAP.md | 24 +++++- docs/ROUTING.md | 6 +- docs/USER_MANUAL.md | 5 ++ include/xtc/xtc_backend.h | 13 +++ include/xtc/xtc_mixer.h | 12 +++ include/xtc/xtc_project.h | 4 +- src/audio/arrangement.c | 7 +- src/backend/jack_backend.c | 83 ++++++++++++++++++- src/gui/main_window.cpp | 13 ++- src/gui/mixer_widget.cpp | 100 ++++++++++++++++++++--- src/gui/mixer_widget.hpp | 19 ++++- src/midi/instrument.c | 10 ++- src/mixer/mix_internal.h | 9 +++ src/mixer/model.c | 35 ++++++++ src/mixer/render.c | 40 ++++++++-- src/persistence/project_file.c | 10 ++- src/project/project.c | 14 ++++ tests/fixtures/README.md | 2 +- tests/fixtures/project-v8.xtc | 21 +++++ tests/integration/test_gui.cpp | 26 ++++++ tests/integration/test_install.py | 2 +- tests/integration/test_portability.c | 5 +- tests/integration/test_workflow.cpp | 11 +-- tests/unit/test_backend.c | 9 +++ tests/unit/test_buses.c | 115 +++++++++++++++++++++++++++ tests/unit/test_mixer.c | 2 +- tests/unit/test_project.c | 3 +- 32 files changed, 632 insertions(+), 61 deletions(-) create mode 100644 tests/fixtures/project-v8.xtc create mode 100644 tests/unit/test_buses.c diff --git a/Makefile b/Makefile index 7980f03..d4cb617 100644 --- a/Makefile +++ b/Makefile @@ -399,3 +399,11 @@ test-routing: $(BUILD_DIR)/test_routing ./$(BUILD_DIR)/test_routing test: test-routing -include $(BUILD_DIR)/tests/unit/test_routing.d + +$(BUILD_DIR)/test_buses: $(BUILD_DIR)/tests/unit/test_buses.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl +.PHONY: test-buses +test-buses: $(BUILD_DIR)/test_buses + ./$(BUILD_DIR)/test_buses +test: test-buses +-include $(BUILD_DIR)/tests/unit/test_buses.d diff --git a/README.md b/README.md index 26337e2..5d7054f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ This repository is a starter scaffold, not a production DAW. It currently provid - sample-counted transport, selection, move/trim, timeline zoom and scrolling; - WAV import/graph playback, bounded recording and missing-asset relinking; - MIDI clips, a sine instrument, JACK MIDI input and a basic piano roll; -- stereo track gain/pan/mute/solo, output meters and gain/pan automation lanes; +- stereo track/bus gain/pan/mute/solo, dBFS peak meters and gain/pan automation lanes; - bounded undo/redo with keyboard shortcuts; - configurable boundary fades, gain ramps and graceful GUI playback transitions; - native C processors, deterministic source packaging and staged install/uninstall; @@ -138,3 +138,6 @@ Time stretching and independent pitch shifting remain deferred. Phase 23 adds signal-flow graph layout, routing diagnostics, direction/role indicators and atomic unity-gain insertion into a wire. See [ROUTING.md](docs/ROUTING.md); `make test-routing` verifies the C routing contract. + +Phase 24 adds one-level mix buses, routing, coherent track/bus peaks and master +clipping indicators, persisted in v9 (reading v1–v8). See [MIXER.md](docs/MIXER.md). diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index d3c684c..4db333a 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -21,7 +21,7 @@ zoom/selection and graph positions are session UI state. Undo retains 32 edits plus baseline; allocation failures are reported, not silently hidden. Media is never removed by undo/delete. Broad screen-reader/accessibility testing is pending. -- Format v8 reads v1–v7 and uses relative local media references. External absolute +- Format v9 reads v1–v8 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 @@ -42,3 +42,8 @@ PERFORMANCE.md, PLUGIN_HOSTING.md and RECOVERY.md for tested contracts/evidence. - Graph folders, per-track graph routing and track templates remain deferred: timeline and MIDI sources expose shared mixes. Layout is session-only; no feedback cycles, plugin isolation or latency compensation are supplied. + +- Stereo one-level mix buses share the 64-track limit. Nested buses, independent + bus plugin inserts, per-track aux sends and sidechains remain deferred. Track + meters are pre-bus; bus meters are pre-graph; master is post-graph. Sample peaks + and sticky clipping do not provide loudness, true-peak analysis or limiting. diff --git a/docs/MIXER.md b/docs/MIXER.md index 1460ee2..472359b 100644 --- a/docs/MIXER.md +++ b/docs/MIXER.md @@ -9,8 +9,7 @@ recording. Graph edits retain their existing live publication behavior. Gain is a linear multiplier from 0 to 4. Pan ranges from -1 (left) to +1 (right): center has unity on both sides and moving away attenuates the opposite side linearly. This preserves existing centered mono levels; it is not an equal-power -pan law. Mute wins over Solo. If any track is soloed, only soloed, unmuted tracks -play. WAV clips and scheduled MIDI use their containing track's settings before +pan law. Mute wins over Solo. Solo behavior across tracks and buses is defined below. WAV clips and scheduled MIDI use their containing track's settings before summing into the audio/instrument graph sources. Live MIDI input and independent graph oscillators/external input are unassigned sources and bypass track mixing. Stereo WAV files are still downmixed at import; stereo output comes from panning @@ -52,12 +51,50 @@ uses explicit paired buffers, not new port IDs. Output buffers must be distinct; stereo callers must not alias them with source buffers. Graph-node meter reads report the maximum of both sides on the processing owner only. -Mixer Output L/R bars show last-block post-graph absolute peaks, polled at 100 ms. -Peaks above unity turn red. Independent lock-free integer snapshots avoid sharing -DSP pointers or float state between callback and GUI; stopped meters show zero. -They are output meters, without per-track RMS, peak hold or loudness analysis. -There is no limiter: finite summed output can exceed unity. GUI polling may miss -brief peaks between refreshes; these bars are not overload history. +Track and bus meters measure coherent audio plus scheduled MIDI sums, after their +own gain/pan/mute/solo controls, before the shared modular graph. A track tap is +before its parent bus: a muted bus can have active member-track meters. Bus taps +are after bus controls. Master L/R measure the final graph output after transport +fades; downstream effects and graph routing can change its level independently. + +The backend retains interval sample-peak maxima until one serialized control +reader consumes them with `xtc_backend_read_meters`. The GUI polls about every +100 ms. Atomic channels are independent (not a simultaneous waveform snapshot); +peaks are quantized to 0.000001 linear and capped at 1000. Legacy backend status +peaks remain last-block linear values. GUI bars display 20*log10(peak) dBFS, floor +-90, instantaneous attack and 20 dB/second release, with a +6 display ceiling. +Each channel latches red CLIP at linear peak >=1 (0 dBFS) until Reset peaks. +Reset is session state, not an audio edit. Selection changes reset selected-strip +history. These are sample-peak meters, not RMS, LUFS or true-peak measurements. +There is no limiter; summed levels can exceed unity. + +## Mix buses and gain staging (Phase 24) + +Use Add bus in Mixer, select an ordinary track, then choose its Route: Master or +an existing bus. Buses share the 64-track capacity and gain/pan automation APIs. +They contain no clips and feed master directly; nesting is rejected. Deleting a +bus reroutes its members to master without deleting their clips; undo restores +routing. Editing uses the existing stopped-transport preparation workflow. + +Track controls apply first, then parent bus controls, then the shared graph. +Gain multiplies (track 2 times bus 2 gives 4); pan uses the same center-unity +balance law at both stages. There is no normalization or automatic attenuation. +Solo is one inclusive domain: a soloed track passes through its bus, a soloed bus +admits all its members, and unrelated tracks are silent. Track mute silences +that member; bus mute silences its entire output even when a member is soloed. +Live MIDI, external audio inputs and independent graph oscillators remain +unassigned and outside this solo domain. + +Master here means the source mix feeding the shared graph, not independent +per-bus effect chains. Graph fan-out and gain branches permit parallel processing +of shared sources. Independent per-track aux sends, sidechains, bus plugin +inserts and nested buses are not represented and remain deferred. + +Preparation allocates shared stereo monitor scratch on the control thread: +2 * track_count * max_frames * sizeof(float), at most 32 MiB at current limits. +The callback clears, accumulates and scans bounded buffers, with no allocation, +I/O, locks or GUI calls. Immutable audio/MIDI snapshots borrow this scratch until +stopped replacement or backend destruction. ## Undo and keyboard workflow @@ -96,3 +133,7 @@ saved/reopened hard-right playback and stereo peaks using only software clients. A native Xvfb mouse smoke check covers sliders and automation drawing. Physical stereo devices, listening quality, realtime endurance and high-density project performance remain unverified; no new dependency was introduced. + +`test_buses` covers bus routing, exact taps, audio/MIDI cancellation, controls, +automation, persistence, invalid routes, deletion/undo and dBFS ballistics. Backend +tests cover consuming interval peaks; GUI tests cover routing undo and clipping. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 3604a07..0835787 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -1,4 +1,4 @@ -# Project format v8 (reads v1–v7) +# Project format v9 (reads v1–v8) 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. @@ -192,7 +192,7 @@ 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 +Phase 22 introduced 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: @@ -215,3 +215,15 @@ 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`). + +## Phase 24: version 9 mix routing + +The writer emits v9. Immediately after each TRACK's MIX record it writes +`ROUTE is_bus bus_id`. `is_bus` is 0 or 1; `bus_id` is an existing bus track ID, +or decimal UINT64_MAX (18446744073709551615) for master. Buses must route to +master and cannot contain clips. Invalid destinations, flags and nested routing +are rejected. v1–v8 migrate all tracks to ordinary direct-to-master tracks. +Bus controls and automation use existing MIX and POINT records. Older readers +reject v9 rather than discard routing. The public `xtc_track_info` grew fields; +API consumers must rebuild. Fixtures cover v1–v8 migration and tests exercise +v9 exact roundtrips and malformed routing. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ce42e0a..07449aa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -587,10 +587,26 @@ Provide the essential mixing controls needed to finish a small or medium-sized p - Test mute, solo, pan, gain, and routing interactions. ### Acceptance criteria -- Mixer controls behave consistently across tracks, buses, and master output. -- Meter readings correspond to documented signal points and units. -- Solo and mute behavior is covered by automated tests. -- No mixer operation introduces allocations, locks, or other prohibited work in the audio callback. +- [x] Mixer controls behave consistently across tracks, buses, and master output. +- [x] Meter readings correspond to documented signal points and units. +- [x] Solo and mute behavior is covered by automated tests. +- [x] No mixer operation introduces allocations, locks, or other prohibited work in the audio callback. + +Implemented: one-level stereo mix buses with routing, shared gain/pan automation, +inclusive solo and mute precedence; coherent audio/MIDI track and bus peaks; +interval master peaks, dBFS ballistics and sticky clipping; v9 persistence reading +v1–v8. See MIXER.md for exact taps, gain staging and callback scratch bounds. +Independent per-track aux sends/sidechains and per-bus graph effects remain +conditional/deferred because graph sources still expose shared mixes. + +Validation: warning-enabled core/GUI builds and unit/integration tests, bus and +backend fault tests, GUI plugin/workflow tests, software JACK GUI/audio/MIDI, +allocation-failure/callback-allocation guard, static analysis, installation and +package relocation, optional-dependency-free build, and core/GUI ASan+UBSan. +The live MIDI connection test required one retry after a JACK connection failure; +its full playback assertions passed on retry. GUI leak checks use the existing +fontconfig suppression; core checks are unsuppressed. Physical hardware, +human listening and extended high-density endurance remain unverified. ## Phase 25 — Plugin and Instrument Usability diff --git a/docs/ROUTING.md b/docs/ROUTING.md index e9d26a3..3d616d4 100644 --- a/docs/ROUTING.md +++ b/docs/ROUTING.md @@ -58,9 +58,9 @@ allocates a fresh stable node ID and is **one** semantic edit/undo step. It preserves all other edges. Failure (including capacity, a missing/stale wire or backend refusal) keeps the previous graph and plan. It introduces no new node type or project record: v8 persistence already represents the resulting graph. -There are no track templates, folders or per-track graph buses yet. The flat -track model and shared source buffers cannot represent those relationships -faithfully; these conditional roadmap tasks remain deferred. +Phase 24 adds one-level stereo mix buses before shared graph sources (see +MIXER.md). Track templates, folders and independent per-track/bus graph inserts +remain deferred because graph sources still expose shared mixes. ## Transactions, cycles and realtime ownership diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 4e2641a..baece1b 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -125,3 +125,8 @@ branches. L arranges nodes by signal flow. Select a wire and press I to insert a unity gain/bus node as one undoable edit. These commands are also in View/Edit and the graph context menu. Unwired effect inputs use external audio, as shown by “ext in”; they do not imply silence. See [ROUTING.md](ROUTING.md). + +Phase 24: Mixer also offers Add bus and a Route choice for ordinary tracks. +Select Track/Bus strips to edit their controls and automation. Master and selected +strip meters show dBFS with 20 dB/s release; Reset peaks clears latched clipping. +See MIXER.md for solo rules, gain staging and meter taps. diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index 619e23c..a0a2a4f 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -9,6 +9,17 @@ extern "C" { #endif +/* Control-consumed interval maxima; quantized to 1e-6 linear, capped at 1000. + * Track tap is post-track controls/solo, pre-bus; bus tap is post-bus controls, + * both before the modular graph. Master is post-graph and transport envelope. + * Readers consume maxima, so use one control-thread reader at UI refresh rate. */ +typedef struct { uint64_t id; float left, right; } xtc_track_peak; +typedef struct { + size_t count; + xtc_track_peak tracks[XTC_PROJECT_MAX_TRACKS]; + float master_left, master_right; +} xtc_meter_snapshot; + typedef struct xtc_backend xtc_backend; typedef enum { @@ -229,6 +240,8 @@ xtc_result xtc_backend_connect(xtc_backend *backend, xtc_port_direction own_dire */ xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction own_direction, const char *remote_name); +xtc_result xtc_backend_read_meters(xtc_backend *backend, xtc_meter_snapshot *out); + #ifdef __cplusplus } #endif diff --git a/include/xtc/xtc_mixer.h b/include/xtc/xtc_mixer.h index d3b27f8..1de3ad5 100644 --- a/include/xtc/xtc_mixer.h +++ b/include/xtc/xtc_mixer.h @@ -21,6 +21,18 @@ typedef struct { float value; } xtc_automation_point; +/* Buses share the 64 track/automation capacities. A bus cannot contain clips + * or feed another bus. Deleting a bus returns its member tracks to master. + * Solo is inclusive: a solo bus admits all its members, a solo track admits + * itself through its bus; bus mute always wins at the bus output. */ +xtc_result xtc_project_add_bus(xtc_project *project, const char *name, uint64_t *out_id); +xtc_result xtc_project_route_track(xtc_project *project, uint64_t track, uint64_t bus_or_master); +/* UI/control-thread sample peak display, dBFS floor -90; instant attack, + * 20 dB/s release, sticky >=1 clip indication. No loudness/true-peak estimate. */ +typedef struct { float db; int clipped; } xtc_peak_meter; +void xtc_peak_meter_reset(xtc_peak_meter *meter); +xtc_result xtc_peak_meter_update(xtc_peak_meter *meter, float linear_peak, double seconds); + /* Gain 0..4, pan -1..1, mute/solo 0 or 1. Track audio and scheduled MIDI only. * Linear pan: center unity on both outputs; hard pan attenuates opposite side. * Automation replaces the corresponding static value, linear between points, diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index abc065f..cc55cea 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -7,7 +7,7 @@ extern "C" { #endif -#define XTC_PROJECT_FORMAT_VERSION 8U +#define XTC_PROJECT_FORMAT_VERSION 9U #define XTC_PROJECT_MAX_TRACKS 64U #define XTC_PROJECT_MAX_CLIPS 1024U #define XTC_PROJECT_NAME_BYTES 64U @@ -27,6 +27,8 @@ typedef struct { char name[XTC_PROJECT_NAME_BYTES]; float gain, pan; int mute, solo; + int is_bus; /* One-level summing bus; no clips, routes only to master. */ + uint64_t bus_id; /* UINT64_MAX = master, otherwise an existing bus ID. */ } xtc_track_info; /* Arrangement region: empty asset_path means silent metadata. diff --git a/src/audio/arrangement.c b/src/audio/arrangement.c index 8bb527c..0c56fb5 100644 --- a/src/audio/arrangement.c +++ b/src/audio/arrangement.c @@ -14,7 +14,8 @@ typedef struct { } region; struct xtc_audio_plan { - size_t fade_frames; + size_t fade_frames, monitor_stride; + float *monitor; xtc_mix_snapshot mix; xtc_wave waves[64]; char paths[64][XTC_ASSET_PATH_BYTES]; @@ -288,6 +289,7 @@ xtc_result xtc_audio_plan_render_stereo(const xtc_audio_plan *plan, uint64_t pos } sample *= r->gain * envelope; } + if (plan->monitor) xtc_mix_monitor(&plan->mix, plan->monitor, plan->monitor_stride, r->track, position+j, j, sample, right != NULL); 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); @@ -315,3 +317,6 @@ xtc_result xtc_audio_plan_render(const xtc_audio_plan *p, uint64_t position, flo { return xtc_audio_plan_render_stereo(p, position, out, NULL, frames); } + +void xtc_audio_plan_monitor(xtc_audio_plan *p, float *samples, size_t stride) +{ if (p) { p->monitor = samples; p->monitor_stride = stride; } } diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 2f6ee4d..eea8cec 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -1,6 +1,7 @@ #include "xtc/xtc_backend.h" #include "xtc/xtc_realtime.h" #include "xtc/xtc_dsp.h" +#include "../mixer/mix_internal.h" #include #include @@ -18,7 +19,10 @@ struct xtc_backend { xtc_midi_plan *midi; unsigned int panic_requested, panic_seen, midi_dropped, peak_left, peak_right; xtc_recorder *recorder; /* Borrowed, installed while callbacks quiescent. */ - float *timeline, *instrument; + float *timeline, *instrument, *monitor; + size_t meter_count; + uint64_t meter_ids[XTC_PROJECT_MAX_TRACKS]; + unsigned int meter_peaks[XTC_PROJECT_MAX_TRACKS][2], master_peaks[2]; size_t max_frames; size_t transition_frames; /* Control owner while stopped. */ xtc_gain_ramp ramp; /* Audio owner while active. */ @@ -31,6 +35,15 @@ struct xtc_backend { int ready, running; /* Control thread only. */ }; +/* One producer, one exchanging reader; at most one CAS, never a retry loop. */ +static void publish_peak(unsigned int *slot, float peak) +{ + unsigned int value = (unsigned int)(fminf(isfinite(peak) ? peak : 0, 1000) * 1000000); + unsigned int old = __atomic_load_n(slot, __ATOMIC_RELAXED); + if (value > old && !__atomic_compare_exchange_n(slot, &old, value, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) + __atomic_store_n(slot, value, __ATOMIC_RELAXED); +} + /** * @brief Decodes queued JACK MIDI and renders the instrument for the current audio block. * @@ -127,6 +140,11 @@ static int process(jack_nframes_t frames, void *context) (void)xtc_realtime_position(backend->engine,position); if (backend->recorder) xtc_recorder_push(backend->recorder, input, (size_t)frames); + { + size_t channel; + for (channel = 0; channel < backend->meter_count*2; ++channel) + memset(backend->monitor + channel*backend->max_frames, 0, (size_t)frames*sizeof(float)); + } if (backend->audio) result = xtc_audio_plan_render_stereo(backend->audio, position, backend->timeline, @@ -175,6 +193,20 @@ static int process(jack_nframes_t frames, void *context) if (r > right_peak) right_peak = r; } + publish_peak(backend->master_peaks, left_peak); + publish_peak(backend->master_peaks+1, right_peak); + if (result == XTC_OK) { + size_t track, side, frame; + for (track = 0; track < backend->meter_count; ++track) + for (side = 0; side < 2; ++side) { + float peak = 0; + for (frame = 0; frame < frames; ++frame) { + float value = fabsf(backend->monitor[(track*2+side)*backend->max_frames+frame]); + if (isfinite(value) && value > peak) peak = value; + } + publish_peak(&backend->meter_peaks[track][side], peak); + } + } __atomic_store_n(&backend->peak_left, (unsigned int)(fminf(left_peak, 1000) * 1000000), __ATOMIC_RELAXED); __atomic_store_n(&backend->peak_right, (unsigned int)(fminf(right_peak, 1000) * 1000000), __ATOMIC_RELAXED); } @@ -284,14 +316,16 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_ backend->instrument = calloc(config->max_frames * config->channel_count, sizeof(float)); if (!backend->timeline || !backend->instrument) { free(backend->timeline); - free(backend->instrument); + free(backend->monitor); + free(backend->instrument); free(backend); return XTC_ERR_OUT_OF_MEMORY; } backend->max_frames = config->max_frames; backend->client = jack_client_open(config->client_name, JackNoStartServer, &status); if (backend->client == NULL) { - free(backend->instrument); + free(backend->monitor); + free(backend->instrument); free(backend->timeline); free(backend); return XTC_ERR_IO; @@ -337,6 +371,7 @@ fail: xtc_realtime_destroy(backend->engine); xtc_audio_plan_destroy(backend->audio); xtc_midi_plan_destroy(backend->midi); + free(backend->monitor); free(backend->instrument); free(backend->timeline); free(backend); @@ -359,6 +394,7 @@ xtc_result xtc_backend_close(xtc_backend *backend) xtc_realtime_destroy(backend->engine); xtc_audio_plan_destroy(backend->audio); xtc_midi_plan_destroy(backend->midi); + free(backend->monitor); free(backend->instrument); free(backend->timeline); free(backend); @@ -487,12 +523,19 @@ xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project * { xtc_audio_plan *candidate = NULL; xtc_midi_plan *midi = NULL; + float *monitor = NULL; + size_t tracks = 0, clips = 0, i; xtc_result result; if (!backend || !project) return XTC_ERR_INVALID_ARGUMENT; if (!backend->ready || backend->running) return XTC_ERR_INVALID_STATE; - result = xtc_audio_plan_create(project, backend->expected_rate, &candidate); + result = xtc_project_get_counts(project, &tracks, &clips); + if (result == XTC_OK && tracks) { + monitor = calloc(tracks*2*backend->max_frames, sizeof(float)); + if (!monitor) result = XTC_ERR_OUT_OF_MEMORY; + } + if (result == XTC_OK) result = xtc_audio_plan_create(project, backend->expected_rate, &candidate); if (result == XTC_OK) result = xtc_audio_plan_set_fade_frames(candidate, backend->transition_frames); if (result == XTC_OK) @@ -504,8 +547,21 @@ xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project * if (result != XTC_OK) { xtc_audio_plan_destroy(candidate); xtc_midi_plan_destroy(midi); + free(monitor); return result; } + free(backend->monitor); + backend->monitor = monitor; + backend->meter_count = tracks; + memset(backend->meter_peaks, 0, sizeof(backend->meter_peaks)); + memset(backend->master_peaks, 0, sizeof(backend->master_peaks)); + for (i = 0; i < tracks; ++i) { + xtc_track_info track; + (void)xtc_project_get_track(project, i, &track); + backend->meter_ids[i] = track.id; + } + xtc_audio_plan_monitor(candidate, monitor, backend->max_frames); + xtc_midi_plan_monitor(midi, monitor, backend->max_frames); xtc_audio_plan_destroy(backend->audio); backend->audio = candidate; xtc_midi_plan_destroy(backend->midi); @@ -773,6 +829,22 @@ xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direc return route(backend, direction, remote, 1); } +xtc_result xtc_backend_read_meters(xtc_backend *backend, xtc_meter_snapshot *out) +{ + size_t i; + if (!backend || !out) return XTC_ERR_INVALID_ARGUMENT; + memset(out, 0, sizeof(*out)); + out->count = backend->meter_count; + for (i = 0; i < out->count; ++i) { + out->tracks[i].id = backend->meter_ids[i]; + out->tracks[i].left = (float)__atomic_exchange_n(&backend->meter_peaks[i][0], 0U, __ATOMIC_RELAXED)/1000000; + out->tracks[i].right = (float)__atomic_exchange_n(&backend->meter_peaks[i][1], 0U, __ATOMIC_RELAXED)/1000000; + } + out->master_left = (float)__atomic_exchange_n(backend->master_peaks, 0U, __ATOMIC_RELAXED)/1000000; + out->master_right = (float)__atomic_exchange_n(backend->master_peaks+1, 0U, __ATOMIC_RELAXED)/1000000; + return XTC_OK; +} + #else /** * @brief Reports that output name is unavailable without JACK support. @@ -1038,4 +1110,7 @@ xtc_result xtc_backend_request_stop(xtc_backend *backend) return XTC_ERR_UNSUPPORTED; } + +xtc_result xtc_backend_read_meters(xtc_backend *backend, xtc_meter_snapshot *out) +{ (void)backend; (void)out; return XTC_ERR_UNSUPPORTED; } #endif diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 9329feb..cf7f26a 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -521,8 +521,7 @@ void MainWindow::reconnect_engine() */ void MainWindow::refresh_status() { - if (mixer_) - mixer_->peaks(0, 0); + if (mixer_ && !ready_) { mixer_->peaks(0, 0); mixer_->track_peaks(0, 0); } bool can_play = false, can_record = false; if (!ready_) { set_label(indicator_, "Audio: disconnected"); @@ -558,7 +557,15 @@ void MainWindow::refresh_status() indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120) : running_ ? fl_rgb_color(125, 225, 160) : fl_rgb_color(215, 215, 215)); - mixer_->peaks(status.peak_left, status.peak_right); + xtc_meter_snapshot meters{}; + float track_left = 0, track_right = 0; + if (xtc_backend_read_meters(backend_, &meters) == XTC_OK) { + mixer_->peaks(meters.master_left, meters.master_right); + for (size_t i = 0; i < meters.count; ++i) if (meters.tracks[i].id == mixer_->selected_track()) { + track_left = meters.tracks[i].left; track_right = meters.tracks[i].right; break; + } + } else mixer_->peaks(status.peak_left, status.peak_right); + mixer_->track_peaks(track_left, track_right); char text[256]; std::snprintf( text, sizeof(text), diff --git a/src/gui/mixer_widget.cpp b/src/gui/mixer_widget.cpp index 15ab850..bb6e9ea 100644 --- a/src/gui/mixer_widget.cpp +++ b/src/gui/mixer_widget.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -193,26 +194,39 @@ MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit solo_ = new Fl_Check_Button(x + 440, y + 8, 90, 26, "Solo"); mute_->callback(control, this); solo_->callback(control, this); + routes_ = new Fl_Choice(x + 600, y + 8, 180, 26, "Route"); + routes_->callback([](Fl_Widget *, void *c) { + auto *m = static_cast(c); + int index = m->routes_->value(); + if (index >= 0 && static_cast(index) < m->route_ids_.size()) m->route(m->route_ids_[static_cast(index)]); + }, this); + auto *bus = new Fl_Button(x + 820, y + 8, 110, 26, "Add bus"); + bus->callback([](Fl_Widget *, void *c) { static_cast(c)->add_bus(); }, this); left_ = new Fl_Progress(x + 70, y + 90, 320, 20, "Output L"); right_ = new Fl_Progress(x + 480, y + 90, 320, 20, "Output R"); - for (auto *meter : {left_, right_}) { - meter->minimum(0); - meter->maximum(1); + track_left_ = new Fl_Progress(x + 70, y + 116, 320, 20, "Track L"); + track_right_ = new Fl_Progress(x + 480, y + 116, 320, 20, "Track R"); + for (auto *meter : {left_, right_, track_left_, track_right_}) { + meter->minimum(-90); + meter->maximum(6); meter->selection_color(fl_rgb_color(80, 180, 120)); } - parameter_ = new Fl_Choice(x + 110, y + 126, 150, 26, "Automation"); + auto *reset = new Fl_Button(x + 820, y + 90, 110, 46, "Reset peaks"); + reset->callback([](Fl_Widget *, void *c) { static_cast(c)->reset_meters(); }, this); + reset_meters(); + parameter_ = new Fl_Choice(x + 110, y + 146, 150, 26, "Automation"); parameter_->add("Gain|Pan"); parameter_->value(0); parameter_->callback(select, this); - start_ = new Fl_Value_Input(x + 420, y + 126, 140, 26, "First tick"); + start_ = new Fl_Value_Input(x + 420, y + 146, 140, 26, "First tick"); start_->bounds(0, XTC_PROJECT_MAX_TICK - lane_ticks); start_->step(3840); start_->callback(select, this); - auto *hint = new Fl_Box(x + 10, y + 160, w - 20, 24, + auto *hint = new Fl_Box(x + 10, y + 180, w - 20, 24, "Gain 0..4; pan -1 left / +1 right. M/S toggles; arrows change gain, " "Shift+arrows pan; Up/Down selects track."); hint->labelsize(12); - lane_ = new AutomationLane(x + 10, y + 192, w - 20, h - 222, *this); + lane_ = new AutomationLane(x + 10, y + 212, w - 20, h - 242, *this); end(); set_project(p); } @@ -267,6 +281,8 @@ void MixerWidget::set_project(xtc_project *p) uint64_t selected = project_ == p ? selected_track() : UINT64_MAX; project_ = p; tracks_->clear(); + routes_->clear(); routes_->add("Master"); + route_ids_.clear(); route_ids_.push_back(UINT64_MAX); size_t n = 0, c = 0; xtc_project_get_counts(p, &n, &c); int index = 0; @@ -279,7 +295,8 @@ void MixerWidget::set_project(xtc_project *p) label += '\\'; label += ch; } - tracks_->add(label.c_str()); + tracks_->add((std::string(t.is_bus ? "Bus: " : "Track: ") + label).c_str()); + if (t.is_bus) { routes_->add(label.c_str()); route_ids_.push_back(t.id); } if (t.id == selected) index = static_cast(i); } @@ -311,6 +328,13 @@ void MixerWidget::selection() pan_->value(t.pan); mute_->value(t.mute); solo_->value(t.solo); + if (valid && !t.is_bus) routes_->activate(); else routes_->deactivate(); + routes_->value(0); + for (size_t i = 0; i < route_ids_.size(); ++i) if (route_ids_[i] == t.bus_id) routes_->value(static_cast(i)); + if (meter_track_ != selected_track()) { + meter_track_ = selected_track(); + xtc_peak_meter_reset(track_meter_); xtc_peak_meter_reset(track_meter_+1); + } lane_->redraw(); } @@ -325,10 +349,12 @@ void MixerWidget::selection() */ void MixerWidget::peaks(float left, float right) { - left_->value(std::min(left, 1.0f)); - right_->value(std::min(right, 1.0f)); - left_->selection_color(left > 1 ? FL_RED : FL_GREEN); - right_->selection_color(right > 1 ? FL_RED : FL_GREEN); + auto now = std::chrono::steady_clock::now(); + meter_elapsed_ = std::clamp(std::chrono::duration(now-peak_time_).count(), 0.0, 60.0); + peak_time_ = now; + (void)xtc_peak_meter_update(master_, left, meter_elapsed_); + (void)xtc_peak_meter_update(master_+1, right, meter_elapsed_); + display_peaks(left_, right_, master_, "Master"); } /** @@ -456,3 +482,53 @@ int MixerWidget::handle(int e) } return Fl_Group::handle(e); } + +void MixerWidget::display_peaks(Fl_Progress *left, Fl_Progress *right, xtc_peak_meter *meters, const char *prefix) +{ + for (unsigned int side = 0; side < 2; ++side) { + auto *bar = side ? right : left; + char label[80]; + std::snprintf(label, sizeof(label), "%s %c %.1f dBFS%s", prefix, side ? 'R' : 'L', + static_cast(meters[side].db), meters[side].clipped ? " CLIP" : ""); + bar->copy_label(label); + bar->value(std::clamp(meters[side].db, -90.0f, 6.0f)); + bar->selection_color(meters[side].clipped ? FL_RED : FL_GREEN); + } +} +void MixerWidget::track_peaks(float left, float right) +{ + (void)xtc_peak_meter_update(track_meter_, left, meter_elapsed_); + (void)xtc_peak_meter_update(track_meter_+1, right, meter_elapsed_); + xtc_track_info track{}; + bool bus = xtc_project_get_track(project_, static_cast(tracks_->value()), &track) == XTC_OK && track.is_bus; + display_peaks(track_left_, track_right_, track_meter_, bus ? "Bus" : "Track"); +} +void MixerWidget::reset_meters() +{ + for (unsigned int i = 0; i < 2; ++i) { xtc_peak_meter_reset(master_+i); xtc_peak_meter_reset(track_meter_+i); } + display_peaks(left_, right_, master_, "Master"); + display_peaks(track_left_, track_right_, track_meter_, "Track"); +} +xtc_result MixerWidget::add_bus() +{ + uint64_t id; + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_add_bus(project_, "Bus", &id); + if (r == XTC_OK) { changed(r); select_track(id); return r; } + return changed(r); +} +xtc_result MixerWidget::route(uint64_t bus) +{ + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_route_track(project_, selected_track(), bus); + return changed(r); +} +bool MixerWidget::select_track(uint64_t id) +{ + size_t n = 0, clips = 0; xtc_project_get_counts(project_, &n, &clips); + for (size_t i = 0; i < n; ++i) { + xtc_track_info track{}; xtc_project_get_track(project_, i, &track); + if (track.id == id) { tracks_->value(static_cast(i)); selection(); return true; } + } + return false; +} diff --git a/src/gui/mixer_widget.hpp b/src/gui/mixer_widget.hpp index 84b81d6..43e5c5b 100644 --- a/src/gui/mixer_widget.hpp +++ b/src/gui/mixer_widget.hpp @@ -2,6 +2,8 @@ #define XTC_MIXER_WIDGET_HPP #include "xtc/xtc_mixer.h" #include +#include +#include class Fl_Choice; class Fl_Value_Slider; @@ -118,6 +120,12 @@ class MixerWidget : public Fl_Group { * @param solo Solo state: 0 or 1. * @return XTC_OK on success, or the before-edit or project mutation error. */ + xtc_result add_bus(); + xtc_result route(uint64_t bus); + bool select_track(uint64_t id); + void track_peaks(float left, float right); + void reset_meters(); + bool master_clipped() const { return master_[0].clipped || master_[1].clipped; } xtc_result mix(float gain, float pan, int mute, int solo); /** @@ -135,10 +143,17 @@ class MixerWidget : public Fl_Group { xtc_project *project_; BeforeEdit before_; void *context_; - Fl_Choice *tracks_, *parameter_; + Fl_Choice *tracks_, *parameter_, *routes_; + std::vector route_ids_; Fl_Value_Slider *gain_, *pan_; Fl_Check_Button *mute_, *solo_; - Fl_Progress *left_, *right_; + Fl_Progress *left_, *right_, *track_left_, *track_right_; + xtc_peak_meter master_[2]{}, track_meter_[2]{}; + uint64_t meter_track_ = UINT64_MAX; + std::chrono::steady_clock::time_point peak_time_ = std::chrono::steady_clock::now(); + double meter_elapsed_ = 0.1; + void display_peaks(Fl_Progress *left, Fl_Progress *right, xtc_peak_meter *meters, const char *prefix); + Fl_Value_Input *start_; AutomationLane *lane_; /** diff --git a/src/midi/instrument.c b/src/midi/instrument.c index 8946824..d28aaf7 100644 --- a/src/midi/instrument.c +++ b/src/midi/instrument.c @@ -25,7 +25,9 @@ typedef struct { } voice; struct xtc_midi_plan { - size_t fade_frames, controller_frames; + size_t fade_frames, controller_frames, monitor_stride; + float *monitor; + uint64_t monitor_position; xtc_gain_ramp volume[16], pan[16]; unsigned int sustain[16]; xtc_mix_snapshot mix; @@ -334,6 +336,8 @@ static void segment(xtc_midi_plan *p, uint64_t position, float *out, float *righ if (distance < p->fade_frames) sample *= (float)distance / (float)p->fade_frames; } + if (p->monitor) xtc_mix_monitor(&p->mix, p->monitor, p->monitor_stride, s->track, position+j, + (size_t)(position-p->monitor_position)+j, sample, right != NULL); out[j] += sample * xtc_mix_level(&p->mix, s->track, position + j, right ? 0U : 2U); if (right) right[j] += sample * xtc_mix_level(&p->mix, s->track, position + j, 1); @@ -393,6 +397,7 @@ xtc_result xtc_midi_plan_render_stereo(xtc_midi_plan *p, uint64_t position, cons (e->type == XTC_MIDI_NOTE_ON && !e->velocity)) return XTC_ERR_INVALID_ARGUMENT; } + p->monitor_position = position; if (!frames) return XTC_OK; if (p->next_position != position) @@ -431,3 +436,6 @@ xtc_result xtc_midi_plan_render(xtc_midi_plan *p, uint64_t position, const xtc_m { return xtc_midi_plan_render_stereo(p, position, events, count, out, NULL, frames); } + +void xtc_midi_plan_monitor(xtc_midi_plan *p, float *samples, size_t stride) +{ if (p) { p->monitor = samples; p->monitor_stride = stride; } } diff --git a/src/mixer/mix_internal.h b/src/mixer/mix_internal.h index cfd7a30..8378184 100644 --- a/src/mixer/mix_internal.h +++ b/src/mixer/mix_internal.h @@ -2,6 +2,7 @@ #define XTC_MIX_INTERNAL_H #include "../project/project_internal.h" +#include "xtc/xtc_audio.h" typedef struct { uint64_t sample, tick; @@ -15,6 +16,7 @@ typedef struct { size_t count, offsets[XTC_PROJECT_MAX_TRACKS][2], lengths[XTC_PROJECT_MAX_TRACKS][2]; xtc_mix_point points[XTC_MAX_AUTOMATION_POINTS]; int any_solo; + size_t bus[XTC_PROJECT_MAX_TRACKS]; } xtc_mix_snapshot; /** @@ -45,4 +47,11 @@ size_t xtc_mix_track(const xtc_mix_snapshot * m, uint64_t id); * @return Linear level for the chosen side, or zero for an absent or inaudible track. */ float xtc_mix_level(const xtc_mix_snapshot * m, size_t track, uint64_t sample, unsigned int side); +float xtc_mix_leaf_level(const xtc_mix_snapshot *m, size_t track, uint64_t sample, unsigned int side); +/* Borrowed control-installed scratch: 2*track_count channels, stride frames. + * Clear once per callback before audio+MIDI rendering. No pointer changes active. */ +void xtc_audio_plan_monitor(xtc_audio_plan *plan, float *samples, size_t stride); +void xtc_midi_plan_monitor(xtc_midi_plan *plan, float *samples, size_t stride); +void xtc_mix_monitor(const xtc_mix_snapshot *mix, float *samples, size_t stride, size_t track, + uint64_t position, size_t frame, float sample, int stereo); #endif diff --git a/src/mixer/model.c b/src/mixer/model.c index 29262d6..e5e0a21 100644 --- a/src/mixer/model.c +++ b/src/mixer/model.c @@ -129,3 +129,38 @@ xtc_result xtc_project_get_automation(const xtc_project *p, size_t i, xtc_automa *a = p->automation[i]; return XTC_OK; } + +xtc_result xtc_project_add_bus(xtc_project *p, const char *name, uint64_t *out) +{ + xtc_result r = xtc_project_add_track(p, name, out); + if (r == XTC_OK) p->tracks[p->track_count-1].is_bus = 1; + return r; +} +xtc_result xtc_project_route_track(xtc_project *p, uint64_t id, uint64_t bus) +{ + size_t i, destination; + if (!p) return XTC_ERR_INVALID_ARGUMENT; + i = find_track(p, id); + if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; + if (p->tracks[i].is_bus) return XTC_ERR_INVALID_STATE; + destination = find_track(p, bus); + if (bus != UINT64_MAX && (destination == SIZE_MAX || !p->tracks[destination].is_bus)) return XTC_ERR_INVALID_ARGUMENT; + p->tracks[i].bus_id = bus; + return XTC_OK; +} +void xtc_peak_meter_reset(xtc_peak_meter *m) +{ + if (m) { m->db = -90; m->clipped = 0; } +} +xtc_result xtc_peak_meter_update(xtc_peak_meter *m, float peak, double seconds) +{ + float db, released; + if (!m || !isfinite(peak) || peak < 0 || !isfinite(seconds) || seconds < 0 || seconds > 60) + return XTC_ERR_INVALID_ARGUMENT; + db = peak > 0 ? 20.0f * log10f(peak) : -90; + if (db < -90) db = -90; + released = m->db - (float)(20 * seconds); + m->db = db > released ? db : released; + if (peak >= 1) m->clipped = 1; + return XTC_OK; +} diff --git a/src/mixer/render.c b/src/mixer/render.c index 1a1dcf6..9251ff5 100644 --- a/src/mixer/render.c +++ b/src/mixer/render.c @@ -50,9 +50,10 @@ xtc_result xtc_mix_prepare(const xtc_project *p, unsigned int rate, xtc_mix_snap memset(m, 0, sizeof(*m)); m->count = p->track_count; memcpy(m->tracks, p->tracks, p->track_count * sizeof(*m->tracks)); - for (i = 0; i < m->count; ++i) - if (m->tracks[i].solo) - m->any_solo = 1; + for (i = 0; i < m->count; ++i) { + if (m->tracks[i].solo) m->any_solo = 1; + m->bus[i] = xtc_mix_track(m, m->tracks[i].bus_id); + } for (i = 0; i < p->automation_count; ++i) { const xtc_automation_point *a = p->automation + i; xtc_mix_point *b = m->points + i; @@ -117,12 +118,13 @@ static float value(const xtc_mix_snapshot *m, size_t track, unsigned int param, * @param side Output side: 0 for left, 1 for right, 2 for mono gain only. * @return Linear level for the chosen side, or zero for an absent or inaudible track. */ -float xtc_mix_level(const xtc_mix_snapshot *m, size_t track, uint64_t sample, unsigned int side) +float xtc_mix_leaf_level(const xtc_mix_snapshot *m, size_t track, uint64_t sample, unsigned int side) { float gain, pan; if (track >= m->count) return 0; - if (m->tracks[track].mute || (m->any_solo && !m->tracks[track].solo)) + if (m->tracks[track].mute || (m->any_solo && !m->tracks[track].solo && + !(m->bus[track] < m->count && m->tracks[m->bus[track]].solo))) return 0; gain = value(m, track, 0, sample); if (side == 2) @@ -130,3 +132,31 @@ float xtc_mix_level(const xtc_mix_snapshot *m, size_t track, uint64_t sample, un pan = value(m, track, 1, sample); return gain * (side == 0 ? (pan > 0 ? 1 - pan : 1) : (pan < 0 ? 1 + pan : 1)); } + +float xtc_mix_level(const xtc_mix_snapshot *m, size_t track, uint64_t sample, unsigned int side) +{ + float level = xtc_mix_leaf_level(m, track, sample, side); + size_t bus; + if (track >= m->count) return 0; + bus = m->bus[track]; + if (bus < m->count) { + float gain, pan; + if (m->tracks[bus].mute) return 0; + gain = value(m, bus, 0, sample); + pan = side == 2 ? 0 : value(m, bus, 1, sample); + level *= gain * (side == 0 ? (pan > 0 ? 1-pan : 1) : side == 1 ? (pan < 0 ? 1+pan : 1) : 1); + } + return level; +} +void xtc_mix_monitor(const xtc_mix_snapshot *m, float *samples, size_t stride, size_t track, + uint64_t position, size_t frame, float sample, int stereo) +{ + unsigned int side; + if (!samples || track >= m->count || frame >= stride) return; + for (side = 0; side < 2; ++side) { + unsigned int channel = stereo ? side : 2; + samples[(track*2+side)*stride+frame] += sample * xtc_mix_leaf_level(m, track, position, channel); + if (m->bus[track] < m->count) + samples[(m->bus[track]*2+side)*stride+frame] += sample * xtc_mix_level(m, track, position, channel); + } +} diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index 748f90a..19e433a 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -311,6 +311,14 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * return XTC_ERR_FORMAT; project->tracks[i].solo = (int)value; } + project->tracks[i].bus_id = UINT64_MAX; + if (format_version >= 9U) { + READ("ROUTE", 3U); + NUM(1U, value); + if (value > 1) return XTC_ERR_FORMAT; + project->tracks[i].is_bus = (int)value; + NUM(2U, project->tracks[i].bus_id); + } } READ("CLIPS", 2U); NUM(1U, value); @@ -496,7 +504,7 @@ static xtc_result write_project(FILE *file, const xtc_project *project, const ch memcpy(&gain, &t->gain, sizeof(gain)); memcpy(&pan, &t->pan, sizeof(pan)); if (fprintf(file, "TRACK %" PRIu64 " ", t->id) < 0 || !write_name(file, t->name) || - fprintf(file, "MIX %08" PRIx32 " %08" PRIx32 " %d %d\n", gain, pan, t->mute, t->solo) < 0) + fprintf(file, "MIX %08" PRIx32 " %08" PRIx32 " %d %d\nROUTE %d %" PRIu64 "\n", gain, pan, t->mute, t->solo, t->is_bus, t->bus_id) < 0) return XTC_ERR_IO; } if (fprintf(file, "CLIPS %zu\n", project->clip_count) < 0) diff --git a/src/project/project.c b/src/project/project.c index 3347d11..826bd03 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -296,6 +296,7 @@ xtc_result xtc_project_add_track(xtc_project *project, const char *name, uint64_ return XTC_ERR_CAPACITY; track.id = project->next_track_id++; track.gain = 1.0f; + track.bus_id = UINT64_MAX; memcpy(track.name, name, strlen(name) + 1U); project->tracks[project->track_count++] = track; *id = track.id; @@ -321,6 +322,8 @@ xtc_result xtc_project_remove_track(xtc_project *project, uint64_t id) if (project->clips[i].track_id != id) project->clips[count++] = project->clips[i]; project->clip_count = count; + for (i = 0; i < project->track_count; ++i) + if (project->tracks[i].bus_id == id) project->tracks[i].bus_id = UINT64_MAX; memmove(project->tracks + index, project->tracks + index + 1U, (project->track_count - index - 1U) * sizeof(*project->tracks)); --project->track_count; @@ -356,6 +359,7 @@ xtc_result xtc_project_add_clip(xtc_project *project, uint64_t track, const char return XTC_ERR_INVALID_ARGUMENT; if (track_index(project, track) == SIZE_MAX) return XTC_ERR_NOT_FOUND; + if (project->tracks[track_index(project, track)].is_bus) return XTC_ERR_INVALID_STATE; if (project->clip_count == XTC_PROJECT_MAX_CLIPS || project->next_clip_id == UINT64_MAX) return XTC_ERR_CAPACITY; clip.gain = 1.0f; @@ -417,6 +421,7 @@ xtc_result xtc_project_edit_clip(xtc_project *project, uint64_t id, uint64_t tra index = clip_index(project, id); if (index == SIZE_MAX || track_index(project, track) == SIZE_MAX) return XTC_ERR_NOT_FOUND; + if (project->tracks[track_index(project, track)].is_bus) return XTC_ERR_INVALID_STATE; if (!valid_range(start, length, offset, project->clips[index].source_length_tick)) return XTC_ERR_INVALID_ARGUMENT; project->clips[index].track_id = track; @@ -514,6 +519,13 @@ xtc_result xtc_project_validate(const xtc_project *project) for (i = 0U; i < project->track_count; ++i) { if (project->tracks[i].id >= project->next_track_id || !xtc_project_valid_name(project->tracks[i].name)) return XTC_ERR_FORMAT; + { + const xtc_track_info *t = project->tracks+i; + size_t bus = track_index(project, t->bus_id); + if ((t->is_bus != 0 && t->is_bus != 1) || + (t->bus_id != UINT64_MAX && (t->is_bus || bus == SIZE_MAX || !project->tracks[bus].is_bus))) + return XTC_ERR_FORMAT; + } for (j = 0U; j < i; ++j) if (project->tracks[j].id == project->tracks[i].id) return XTC_ERR_FORMAT; @@ -538,6 +550,7 @@ xtc_result xtc_project_validate(const xtc_project *project) !xtc_project_valid_name(clip->name) || !valid_range(clip->start_tick, clip->length_tick, clip->offset_tick, clip->source_length_tick)) return XTC_ERR_FORMAT; + if (project->tracks[track_index(project, clip->track_id)].is_bus) return XTC_ERR_FORMAT; for (j = 0U; j < i; ++j) if (project->clips[j].id == clip->id) return XTC_ERR_FORMAT; @@ -613,6 +626,7 @@ xtc_result xtc_project_duplicate_clip(xtc_project *p, uint64_t id, uint64_t trac if (!p || !out) return XTC_ERR_INVALID_ARGUMENT; index = clip_index(p,id); if (index == SIZE_MAX || track_index(p,track) == SIZE_MAX) return XTC_ERR_NOT_FOUND; + if (p->tracks[track_index(p,track)].is_bus) return XTC_ERR_INVALID_STATE; c = p->clips[index]; if (!valid_range(start,c.length_tick,c.offset_tick,c.source_length_tick)) return XTC_ERR_INVALID_ARGUMENT; for (i = 0; i < p->note_count; ++i) if (p->notes[i].clip_id == id) ++count; diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 0337b43..c59ebd5 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -3,7 +3,7 @@ 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 +`project-v1.xtc` through `project-v8.xtc` are hand-authored xtc metadata fixtures created for this repository, under the project license. They contain no audio or third-party resources. The v5/v6 plugin fixtures intentionally reference a missing module to exercise metadata migration without loading code. diff --git a/tests/fixtures/project-v8.xtc b/tests/fixtures/project-v8.xtc new file mode 100644 index 0000000..9c2ac99 --- /dev/null +++ b/tests/fixtures/project-v8.xtc @@ -0,0 +1,21 @@ +XTC_PROJECT 8 +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 +AUDIO 3f800000 0 0 +NOTES 1 1 +NOTE 0 0 0 480 69 100 0 +AUTOMATION 1 +POINT 0 0 0 3f800000 +END diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 7e63a5d..3ca5933 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -144,6 +144,11 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) return XTC_OK; } xtc_result xtc_backend_panic(xtc_backend *) { xtc_midi_plan_panic(handle.midi, engine_status.sample_position); return XTC_OK; } +xtc_result xtc_backend_read_meters(xtc_backend *, xtc_meter_snapshot *out) +{ + *out = {}; out->master_left = engine_status.peak_left; out->master_right = engine_status.peak_right; + return XTC_OK; +} xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project) { xtc_audio_plan *plan = nullptr; @@ -602,6 +607,27 @@ int main(int argc, char **argv) CHECK((sx != gx || sy != gy) && (sx != ox || sy != oy) && (gx != ox || gy != oy)); window.do_callback(); } + { + reset(); MainWindow window(false); + auto *sequence = timeline(&window); auto *mixer = window.mixer(); + CHECK(sequence->add_track() == XTC_OK); + CHECK(mixer->add_bus() == XTC_OK); + uint64_t bus = mixer->selected_track(); + xtc_track_info track{}; + CHECK(xtc_project_get_track(sequence->project(),1,&track) == XTC_OK && track.is_bus); + CHECK(mixer->mix(.5f,1,0,1) == XTC_OK); + CHECK(mixer->select_track(0) && mixer->route(bus) == XTC_OK); + CHECK(xtc_project_get_track(sequence->project(),0,&track) == XTC_OK && track.bus_id == bus); + CHECK(window.undo() == XTC_OK); + CHECK(xtc_project_get_track(sequence->project(),0,&track) == XTC_OK && track.bus_id == UINT64_MAX); + CHECK(window.undo(true) == XTC_OK); + CHECK(xtc_project_get_track(sequence->project(),0,&track) == XTC_OK && track.bus_id == bus); + mixer->peaks(1.25f,.5f); mixer->track_peaks(.5f,.25f); + CHECK(mixer->master_clipped() && contains(&window,"dBFS CLIP")); + mixer->peaks(0,0); CHECK(mixer->master_clipped()); + mixer->reset_meters(); CHECK(!mixer->master_clipped()); + window.do_callback(); + } if (const char *module = std::getenv("XTC_TEST_PLUGIN")) { reset(); MainWindow window(false); diff --git a/tests/integration/test_install.py b/tests/integration/test_install.py index f67c66f..4d76220 100644 --- a/tests/integration/test_install.py +++ b/tests/integration/test_install.py @@ -37,7 +37,7 @@ def main(): assert os.access(root / 'bin/xtc-check', os.X_OK) run([str(root / 'bin/xtc-check')]) diagnose = subprocess.check_output([str(root / 'bin/xtc-check'), '--diagnose'], text=True) - assert 'Project formats: 1..8' in diagnose and 'JACK compiled:' in diagnose + assert 'Project formats: 1..9' in diagnose and 'JACK compiled:' in diagnose # Upgrade/repair repeats the same owned-file install and keeps unrelated data. (root / 'include/xtc/xtc.h').write_text('old installation marker\n') run(make + ['install'] + variables, env=env) diff --git a/tests/integration/test_portability.c b/tests/integration/test_portability.c index d724e0e..a6aa5d9 100644 --- a/tests/integration/test_portability.c +++ b/tests/integration/test_portability.c @@ -18,12 +18,13 @@ int main(void) assert(mkdtemp(root)); assert(xtc_project_create(&p) == XTC_OK); snprintf(file,sizeof(file),"%s/migration.xtc",root); - for (version = 1; version <= 7; ++version) { + for (version = 1; version <= 8; ++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(!t.is_bus && t.bus_id == UINT64_MAX); 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)); @@ -84,6 +85,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-v7 migration, current folder relocation, cwd independence and missing-media preservation passed."); + puts("v1-v8 migration, current folder relocation, cwd independence and missing-media preservation passed."); return 0; } diff --git a/tests/integration/test_workflow.cpp b/tests/integration/test_workflow.cpp index 1699d36..2c63720 100644 --- a/tests/integration/test_workflow.cpp +++ b/tests/integration/test_workflow.cpp @@ -30,7 +30,7 @@ static Fl_Widget *button(Fl_Group &group, const std::string &label) { for (int i = 0; i < group.children(); ++i) { auto *child = group.child(i); - if (child->label() && label == child->label()) return child; + if (child->label() && (label == child->label() || (label.compare(0, 7, "Master ") == 0 && std::string(child->label()).compare(0, label.size(), label) == 0))) return child; if (auto *nested = dynamic_cast(child)) if (auto *match = button(*nested, label)) return match; } @@ -142,12 +142,13 @@ int main(int argc, char **argv) press(window, "Play"); pump(); CHECK(!button(window, "Play")->active() && button(window, "Pause")->active()); CHECK(timeline->position() > 1920); - auto *left_meter = dynamic_cast(button(window, "Output L")); - auto *right_meter = dynamic_cast(button(window, "Output R")); - CHECK(left_meter && right_meter && left_meter->value() > 0 && right_meter->value() > 0); + auto *left_meter = dynamic_cast(button(window, "Master L")); + auto *right_meter = dynamic_cast(button(window, "Master R")); + CHECK(left_meter && right_meter && left_meter->value() > -90 && right_meter->value() > -90); + const double playing_left = left_meter->value(), playing_right = right_meter->value(); CHECK(window.pause_playback() == XTC_OK); pump(); // Fade completion is acknowledged by the GUI timer. - CHECK(left_meter->value() == 0 && right_meter->value() == 0); + CHECK(left_meter->value() < playing_left && right_meter->value() < playing_right); uint64_t paused = timeline->position(); pump(); CHECK(timeline->position() == paused); press(window, "Play"); pump(); diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index b6b3621..8e97035 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -312,9 +312,18 @@ int main(void) CHECK(xtc_project_add_midi_clip(p, track, 0, 3840, &clip) == XTC_OK); CHECK(xtc_project_add_note(p, clip, 0, 960, 69, 127, 0, ¬e) == XTC_OK); CHECK(xtc_project_set_mix(p, track, 0.5f, 1, 0, 0) == XTC_OK); + uint64_t bus; + CHECK(xtc_project_add_bus(p, "Bus", &bus) == XTC_OK && xtc_project_route_track(p, track, bus) == XTC_OK); + CHECK(xtc_project_set_mix(p, bus, 2, 0, 0, 0) == XTC_OK); CHECK(xtc_backend_prepare_project(backend, p) == XTC_OK && xtc_backend_start(backend) == XTC_OK); CHECK(process_cb(32, context) == 0 && output[1] == 0 && output_right[1] > 0); CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.peak_left == 0 && status.peak_right > 0); + xtc_meter_snapshot meters; + CHECK(xtc_backend_read_meters(backend, &meters) == XTC_OK && meters.count == 2); + CHECK(meters.tracks[0].id == track && meters.tracks[0].left == 0 && meters.tracks[0].right > 0); + CHECK(meters.tracks[1].id == bus && meters.tracks[1].left == 0 && meters.tracks[1].right > meters.tracks[0].right); + CHECK(meters.master_left == 0 && meters.master_right == meters.tracks[1].right); + CHECK(xtc_backend_read_meters(backend, &meters) == XTC_OK && meters.master_right == 0 && meters.tracks[0].right == 0); CHECK(xtc_backend_stop(backend) == XTC_OK); CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.peak_right == 0); CHECK(xtc_backend_close(backend) == XTC_OK); xtc_project_destroy(p); diff --git a/tests/unit/test_buses.c b/tests/unit/test_buses.c new file mode 100644 index 0000000..ca61931 --- /dev/null +++ b/tests/unit/test_buses.c @@ -0,0 +1,115 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_audio.h" +#include "xtc/xtc_midi.h" +#include "xtc/xtc_mixer.h" +#include "../../src/mixer/mix_internal.h" +#include +#include +#include +#include +#include +#include +#define TAU 6.28318530717958647692 +static void render(xtc_project *p, float *left, float *right, float *meters) +{ + xtc_audio_plan *a = NULL; + memset(meters, 0, 8*2*64*sizeof(float)); + assert(xtc_audio_plan_create(p, 48000, &a) == XTC_OK); + xtc_audio_plan_monitor(a, meters, 64); + assert(xtc_audio_plan_render_stereo(a, 0, left, right, 64) == XTC_OK); + xtc_audio_plan_destroy(a); +} +int main(void) +{ + xtc_project *p = NULL, *q = NULL, *undo = NULL; + xtc_recorder *rec = NULL; + xtc_history *history = NULL; + xtc_audio_plan *audio = NULL; + xtc_midi_plan *midi = NULL; + xtc_track_info info; + xtc_peak_meter meter; + xtc_automation_point point; + uint64_t a,b,c,bus,clip,id; + float in[64], left[64], right[64], meters[8*2*64], ml[64], mr[64]; + char directory[] = "/tmp/xtc-buses-XXXXXX", wave[256], song[256]; + size_t i; + assert(mkdtemp(directory)); + snprintf(wave,sizeof(wave),"%s/source.wav",directory); + snprintf(song,sizeof(song),"%s/song.xtc",directory); + for(i=0;i<64;++i) in[i]=1; + assert(xtc_recorder_create(wave,48000,64,&rec)==XTC_OK); + xtc_recorder_push(rec,in,64); assert(xtc_recorder_finish(rec)==XTC_OK); xtc_recorder_destroy(rec); + assert(xtc_project_create(&p)==XTC_OK && xtc_project_create(&q)==XTC_OK); + assert(xtc_project_add_track(p,"A",&a)==XTC_OK && xtc_project_add_track(p,"B",&b)==XTC_OK); + assert(xtc_project_add_track(p,"Direct",&c)==XTC_OK && xtc_project_add_bus(p,"Group",&bus)==XTC_OK); + assert(xtc_project_route_track(p,a,bus)==XTC_OK && xtc_project_route_track(p,b,bus)==XTC_OK); + assert(xtc_project_route_track(p,bus,bus)==XTC_ERR_INVALID_STATE); + assert(xtc_project_route_track(p,a,b)==XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_import_audio(p,bus,wave,0,&clip)==XTC_ERR_INVALID_STATE); + assert(xtc_project_import_audio(p,a,wave,0,&clip)==XTC_OK); + assert(xtc_project_duplicate_clip(p,clip,bus,0,&id)==XTC_ERR_INVALID_STATE); + assert(xtc_project_edit_clip(p,clip,bus,0,3,0)==XTC_ERR_INVALID_STATE); + assert(xtc_project_import_audio(p,b,wave,0,&clip)==XTC_OK && xtc_project_import_audio(p,c,wave,0,&clip)==XTC_OK); + assert(xtc_project_set_mix(p,a,.5f,0,0,0)==XTC_OK && xtc_project_set_mix(p,b,.5f,0,0,0)==XTC_OK); + assert(xtc_project_set_mix(p,c,.25f,0,0,0)==XTC_OK && xtc_project_set_mix(p,bus,.5f,1,0,0)==XTC_OK); + render(p,left,right,meters); + assert(left[0]==.25f && right[0]==.75f); + assert(meters[0]==.5f && meters[64]==.5f && meters[(3*2)*64]==0 && meters[(3*2+1)*64]==.5f); + assert(xtc_project_set_mix(p,a,.5f,0,0,1)==XTC_OK); + render(p,left,right,meters); assert(left[0]==0 && right[0]==.25f && meters[2*64]==0); + assert(xtc_project_set_mix(p,a,.5f,0,0,0)==XTC_OK && xtc_project_set_mix(p,bus,.5f,1,0,1)==XTC_OK); + render(p,left,right,meters); assert(left[0]==0 && right[0]==.5f && meters[4*64]==0); + assert(xtc_project_set_mix(p,bus,.5f,1,1,1)==XTC_OK); + render(p,left,right,meters); assert(right[0]==0 && meters[0]==.5f && meters[7*64]==0); + assert(xtc_project_set_mix(p,bus,.5f,0,0,0)==XTC_OK); + point=(xtc_automation_point){bus,0,XTC_AUTOMATION_GAIN,.25f}; + assert(xtc_project_set_automation(p,&point)==XTC_OK); + render(p,left,right,meters); assert(left[0]==.5f && right[0]==.5f); /* .25 bus + .25 direct */ + assert(xtc_project_save(p,song)==XTC_OK && xtc_project_load(q,song)==XTC_OK); + assert(xtc_project_get_track(q,3,&info)==XTC_OK && info.is_bus && info.bus_id==UINT64_MAX); + assert(xtc_project_get_track(q,0,&info)==XTC_OK && info.bus_id==bus); + assert(xtc_history_create(q,&history)==XTC_OK && xtc_project_remove_track(q,bus)==XTC_OK); + assert(xtc_project_get_track(q,0,&info)==XTC_OK && info.bus_id==UINT64_MAX); + assert(xtc_history_record(history,q)==XTC_OK && xtc_history_checkout(history,0,&undo)==XTC_OK); + assert(xtc_project_get_track(undo,0,&info)==XTC_OK && info.bus_id==bus); + xtc_project_destroy(undo); xtc_history_destroy(history); + /* Invalid destination on load is transactional. */ + { + char text[10000], *route; size_t length; FILE *file=fopen(song,"rb"); assert(file); + length=fread(text,1,sizeof(text)-1,file); assert(fclose(file)==0); text[length]=0; + route=strstr(text,"ROUTE 0 3\n"); assert(route); route[8]='9'; + 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); + } + xtc_project_destroy(p); xtc_project_destroy(q); + /* Audio+MIDI on one track cancel before their shared bus/meter tap. */ + assert(unlink(wave)==0); + for(i=0;i<64;++i) in[i]=-(float)(sin(TAU*fmod((double)i*(440.0/48000),1.0))*.1); + assert(xtc_recorder_create(wave,48000,64,&rec)==XTC_OK); + xtc_recorder_push(rec,in,64); assert(xtc_recorder_finish(rec)==XTC_OK); xtc_recorder_destroy(rec); + assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Cancel",&a)==XTC_OK); + assert(xtc_project_add_bus(p,"Sum",&bus)==XTC_OK && xtc_project_route_track(p,a,bus)==XTC_OK); + assert(xtc_project_import_audio(p,a,wave,0,&clip)==XTC_OK && xtc_project_add_midi_clip(p,a,0,960,&clip)==XTC_OK); + assert(xtc_project_add_note(p,clip,0,960,69,127,0,&id)==XTC_OK); + assert(xtc_audio_plan_create(p,48000,&audio)==XTC_OK && xtc_midi_plan_create(p,48000,&midi)==XTC_OK); + memset(meters,0,sizeof(meters)); xtc_audio_plan_monitor(audio,meters,64); xtc_midi_plan_monitor(midi,meters,64); + assert(xtc_audio_plan_render_stereo(audio,0,left,right,64)==XTC_OK); + assert(xtc_midi_plan_render_stereo(midi,0,NULL,0,ml,mr,17)==XTC_OK); + assert(xtc_midi_plan_render_stereo(midi,17,NULL,0,ml+17,mr+17,47)==XTC_OK); + /* Standalone callers clear/offset scratch per render; backend renders one MIDI call per block. */ + memset(meters,0,sizeof(meters)); + assert(xtc_audio_plan_render_stereo(audio,0,left,right,64)==XTC_OK); + assert(xtc_midi_plan_render_stereo(midi,0,NULL,0,ml,mr,64)==XTC_OK); + for(i=0;i<64;++i) { assert(fabsf(meters[i])<.000001f); assert(fabsf(meters[2*64+i])<.000001f); } + xtc_audio_plan_destroy(audio); xtc_midi_plan_destroy(midi); xtc_project_destroy(p); + xtc_peak_meter_reset(&meter); assert(meter.db==-90 && !meter.clipped); + assert(xtc_peak_meter_update(&meter,.5f,.1)==XTC_OK && fabsf(meter.db+6.0206f)<.0001f); + assert(xtc_peak_meter_update(&meter,0,.1)==XTC_OK && fabsf(meter.db+8.0206f)<.0001f); + assert(xtc_peak_meter_update(&meter,1,0)==XTC_OK && meter.db==0 && meter.clipped); + assert(xtc_peak_meter_update(&meter,0,1)==XTC_OK && meter.db==-20 && meter.clipped); + assert(xtc_peak_meter_update(&meter,NAN,.1)==XTC_ERR_INVALID_ARGUMENT && meter.db==-20); + xtc_peak_meter_reset(&meter); assert(!meter.clipped); + assert(unlink(wave)==0 && unlink(song)==0 && rmdir(directory)==0); + puts("Bus routing/gain/pan/mute/solo/automation, coherent meters, persistence and undo passed."); + return 0; +} diff --git a/tests/unit/test_mixer.c b/tests/unit/test_mixer.c index b288f33..3d8b363 100644 --- a/tests/unit/test_mixer.c +++ b/tests/unit/test_mixer.c @@ -197,7 +197,7 @@ int main(void) fputc('\n',legacy); continue; } if (strncmp(line, "MIX ", 4) && strncmp(line, "AUTOMATION ", 11) && - strncmp(line, "POINT ", 6) && strncmp(line, "AUDIO ", 6)) + strncmp(line, "POINT ", 6) && strncmp(line, "AUDIO ", 6) && strncmp(line, "ROUTE ", 6)) assert(fputs(line, legacy) >= 0); } assert(fclose(f) == 0 && fclose(legacy) == 0); diff --git a/tests/unit/test_project.c b/tests/unit/test_project.c index e1d5981..15a5b1b 100644 --- a/tests/unit/test_project.c +++ b/tests/unit/test_project.c @@ -86,8 +86,7 @@ int main(void) assert(xtc_project_load(q, bad) != XTC_OK); assert(xtc_project_find_clip(q, c, &info) == XTC_OK && memcmp(&info, &before, sizeof(info)) == 0); } - memcpy(damaged, data, length); damaged[12] = '9'; - write_bytes(bad, damaged, length); + write_bytes(bad, "XTC_PROJECT 999\n", strlen("XTC_PROJECT 999\n")); assert(xtc_project_load(q, bad) == XTC_ERR_UNSUPPORTED); memcpy(damaged, data, length); damaged[length] = '\n'; write_bytes(bad, damaged, length + 1);