Phase 24: add mix buses, coherent peak meters and master clipping indicators

This commit is contained in:
Johannes Findeisen 2026-10-27 16:15:34 +01:00
commit 2fd7724065
32 changed files with 632 additions and 61 deletions

View file

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

View file

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

View file

@ -21,7 +21,7 @@
zoom/selection and graph positions are session UI state. Undo retains 32 edits
plus baseline; allocation failures are reported, not silently hidden. Media is
never removed by undo/delete. Broad screen-reader/accessibility testing is pending.
- Format 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <stdlib.h>
#include <string.h>
@ -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

View file

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

View file

@ -2,6 +2,7 @@
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
#include <FL/Fl_Check_Button.H>
#include <FL/Fl_Button.H>
#include <FL/Fl_Choice.H>
#include <FL/Fl_Progress.H>
#include <FL/Fl_Value_Input.H>
@ -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<MixerWidget *>(c);
int index = m->routes_->value();
if (index >= 0 && static_cast<size_t>(index) < m->route_ids_.size()) m->route(m->route_ids_[static_cast<size_t>(index)]);
}, this);
auto *bus = new Fl_Button(x + 820, y + 8, 110, 26, "Add bus");
bus->callback([](Fl_Widget *, void *c) { static_cast<MixerWidget *>(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<MixerWidget *>(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<int>(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<int>(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<double>(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<double>(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<size_t>(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<int>(i)); selection(); return true; }
}
return false;
}

View file

@ -2,6 +2,8 @@
#define XTC_MIXER_WIDGET_HPP
#include "xtc/xtc_mixer.h"
#include <FL/Fl_Group.H>
#include <chrono>
#include <vector>
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<uint64_t> 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_;
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

21
tests/fixtures/project-v8.xtc vendored Normal file
View file

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

View file

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

View file

@ -37,7 +37,7 @@ def main():
assert os.access(root / 'bin/xtc-check', os.X_OK)
run([str(root / 'bin/xtc-check')])
diagnose = subprocess.check_output([str(root / 'bin/xtc-check'), '--diagnose'], text=True)
assert 'Project formats: 1..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)

View file

@ -18,12 +18,13 @@ int main(void)
assert(mkdtemp(root));
assert(xtc_project_create(&p) == XTC_OK);
snprintf(file,sizeof(file),"%s/migration.xtc",root);
for (version = 1; version <= 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,&notes) == 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;
}

View file

@ -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<Fl_Group *>(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<Fl_Progress *>(button(window, "Output L"));
auto *right_meter = dynamic_cast<Fl_Progress *>(button(window, "Output R"));
CHECK(left_meter && right_meter && left_meter->value() > 0 && right_meter->value() > 0);
auto *left_meter = dynamic_cast<Fl_Progress *>(button(window, "Master L"));
auto *right_meter = dynamic_cast<Fl_Progress *>(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();

View file

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

115
tests/unit/test_buses.c Normal file
View file

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

View file

@ -197,7 +197,7 @@ int main(void)
fputc('\n',legacy); continue;
}
if (strncmp(line, "MIX ", 4) && strncmp(line, "AUTOMATION ", 11) &&
strncmp(line, "POINT ", 6) && strncmp(line, "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);

View file

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