1
0
Fork 0
forked from hanez/xtc

Phase 16: persist sample-timed effect automation and add expressive MIDI controls

This commit is contained in:
Johannes Findeisen 2026-10-27 06:25:30 +01:00
commit 7fac0a4f6a
23 changed files with 374 additions and 39 deletions

View file

@ -320,3 +320,11 @@ test-plugins-real: $(BUILD_DIR)/test_plugins
.PHONY: test-gui-plugins
test-gui-plugins: $(BUILD_DIR)/test_gui $(BUILD_DIR)/plugin-fixture.so
XTC_TEST_PLUGIN="$(abspath $(BUILD_DIR))/plugin-fixture.so" xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui
$(BUILD_DIR)/test_control: $(BUILD_DIR)/tests/unit/test_control.o $(BUILD_DIR)/libxtc.a
$(CC) $(LDFLAGS) -o $@ $^ -lm -ldl
.PHONY: test-control
test-control: $(BUILD_DIR)/test_control
./$(BUILD_DIR)/test_control
test: test-control
-include $(BUILD_DIR)/tests/unit/test_control.d

49
docs/CONTROL.md Normal file
View file

@ -0,0 +1,49 @@
# Automation and expressive control (Phase 16)
Track Gain/Pan lanes in the Mixer apply to audio and scheduled instrument voices.
They use absolute project ticks, linear interpolation, hold-first/last outside
points and the constant project tempo snapshot. A clip trim/move does not move
track or effect automation: lanes belong to the project timeline. Instrument
volume is controlled by its track Gain lane; the current sine has no timbre
parameter. Track gain ramps and boundary fades are described in AUDIO_QUALITY.md.
Select a LADSPA node in Modular Graph and use Edit/Selected plugin automation.
Enter `port tick mode value`: mode 0 interpolates linearly, 1 holds until the next
point, and -1 removes the point at that port/tick (value ignored). Port names,
ranges and current tick are displayed. There are at most 32 points per plugin;
setting an existing port/tick replaces it. Integer/toggle controls require step
mode. Invalid values, ticks, ports or duplicate persisted points reject the edit
or file. Edits are undoable and rebuild on the control thread while stopped.
Effect lanes use the project tempo, hold first/last outside their range and
apply before run at each exact sample. Distinct ticks rounding to one sample
resolve to the later tick. Linear curves avoid stepping continuous controls;
intentional step curves may click. The initial implementation calls run(1) while
any lane exists, with connected preallocated buffers. CPU cost can be significant;
only the documented LADSPA set is qualified. No worker, allocator, loader or
clock is called from the host audio path. Native ABI 2 callers continue to use
sample-offset parameter events, applied before equal-offset MIDI events.
Live MIDI input maps CC7 to per-channel volume (0..1), CC10 to pan (64 center),
and CC64 to sustain (>=64 down). Volume/pan ramp over 5 ms. These fixed mappings
apply to live sine voices, not track faders or prerecorded clips; controller
capture, configurable learn and plugin MIDI are not supported. CC120/123 and
Panic release all live voices and sustain and suppress already-started scheduled
notes. Note-on velocity zero decodes to note-off. Repeated live note-on on one
channel/pitch retriggers one voice; note-off releases it or marks it pending while
sustain is held. Sustain-up releases pending voices, never newer retriggers.
Seek, stop, teardown and disconnect clear live voices; plan reset returns controller
values to unity/center. Equal-offset live events execute in input order before
the sample; arrange note ends precede starts, and scheduled mixer values are
evaluated at that sample. Overlapping arranged notes are independent.
Format v6 preserves v5 data plus effect lanes. A v5 plugin migrates with no lane
and the project tempo. Repeated render/save/reopen, odd block partition, exact
endpoints, seek/plan replacement, sustain/retrigger and controller ramp tests:
```sh
make test test-control test-plugins test-gui-plugins test-plugins-real
```
Physical controller devices and listening remain unqualified; no tempo map,
latency compensation, MIDI capture, pitch bend or plugin crash isolation is added.

View file

@ -138,3 +138,13 @@ the module. Unknown nodes/versions fail without mutating the current project.
Missing modules remain preserved by metadata load/save; GUI installation rejects
them with a repair message. No transient DSP state, pointer or plugin binary is
stored. See PLUGIN_HOSTING.md and decision 0009 for supported processors/limits.
## Phase 16: version 6
Writer emits v6 and readers accept v1–v6. After each plugin's CONTROL records,
`PLUGIN_AUTOMATION count` is followed by exactly count (<=32) records:
`PARAM tick port mode float32_hex_bits`. Mode is 0 linear or 1 step. Duplicate
port/tick pairs, nonfinite values, unknown control ports and ticks beyond the
project limit are rejected. Loaded plugin tempo comes from TEMPO. Range and
discrete-mode compatibility are checked on control-thread compilation. Versions
1–5 migrate with empty plugin lanes. See CONTROL.md for timing/ordering.

View file

@ -324,6 +324,14 @@ unsupported; plugin realtime behavior and physical listening are unqualified.
- Held notes are released safely on stop, panic, seek, and relevant failure paths.
- Event ordering and interpolation behavior are documented and tested.
Implemented: persisted sample-evaluated LADSPA parameter lanes with linear/step
modes, range/discrete validation and public GUI editing; absolute automation
position through seek and plan handoff; fixed live CC7/10/64 mappings with volume/
pan ramps, sustain/retrigger safety, and exact block-partition regressions.
Existing track Gain/Pan lanes cover audio and scheduled instrument volume.
Format v6 migrates v1–v5. See CONTROL.md. MIDI capture/learn, pitch bend, new
instrument timbres and physical listening remain outside the supported scope.
## Phase 17 — Editing Workflow, Undo/Redo, and Usability
**Goal:** Make common editing tasks efficient and protect users from accidental changes.

View file

@ -32,7 +32,7 @@ static xtc_result instrument(void *state, const xtc_native_block *b)
if (m->type == XTC_MIDI_NOTE_ON) {
v->pitch = m->pitch; v->velocity = (float)m->velocity / 127.0f;
v->frequency = 440.0 * pow(2.0, ((double)m->pitch - 69.0) / 12.0);
} else if (m->type == XTC_MIDI_ALL_OFF || m->pitch == v->pitch) v->velocity = 0;
} else if (m->type == XTC_MIDI_ALL_OFF || (m->type == XTC_MIDI_NOTE_OFF && m->pitch == v->pitch)) v->velocity = 0;
}
b->output[i] = (float)sin(v->phase) * v->velocity * v->gain;
v->phase = fmod(v->phase + 6.283185307179586 * v->frequency / v->rate, 6.283185307179586);

View file

@ -29,6 +29,8 @@ typedef struct {
} xtc_audio_buffer;
typedef struct xtc_plan xtc_plan;
/* Single owner: set absolute automation/native-event position, no allocation. */
xtc_result xtc_plan_position(xtc_plan *plan, uint64_t position);
typedef enum {
XTC_NODE_GAIN = 1,

View file

@ -19,7 +19,8 @@ typedef struct {
typedef enum {
XTC_MIDI_NOTE_ON = 1,
XTC_MIDI_NOTE_OFF,
XTC_MIDI_ALL_OFF
XTC_MIDI_ALL_OFF,
XTC_MIDI_CONTROL /* pitch = CC number, velocity = 0..127 value */
} xtc_midi_type;
typedef struct {

View file

@ -5,6 +5,11 @@
extern "C" {
#endif
#define XTC_PLUGIN_CONTROLS 16U
#define XTC_PLUGIN_POINTS 32U
/* Absolute project ticks. Mode belongs to segment starting at this point:
* 0 linear, 1 hold/step. Outside a lane hold first/last; later ticks win when
* sample rounding collides. Integer/toggle controls require mode 1. */
typedef struct { uint64_t tick; unsigned int port, step; float value; } xtc_plugin_point;
/* Plain copied model: no library handles or callback pointers. Absolute trusted
* module path, descriptor index + unique ID + label checked again on compile.
* Controls are actual LADSPA port indices, in ascending order. */
@ -13,6 +18,8 @@ typedef struct {
unsigned int index, unique_id, inputs, control_count;
unsigned int ports[XTC_PLUGIN_CONTROLS];
float values[XTC_PLUGIN_CONTROLS];
unsigned int tempo, point_count; /* milli-BPM, 20000..300000 */
xtc_plugin_point points[XTC_PLUGIN_POINTS];
} xtc_plugin_spec;
typedef struct xtc_plugin xtc_plugin;
/* Control thread only. Probe explicitly enumerates a file by descriptor index;
@ -29,6 +36,7 @@ void xtc_plugin_destroy(xtc_plugin *plugin); /* control, after quiescence */
/* Single audio owner. No loading/allocation/I/O in host process; trusted run()
* must obey realtime rules. No MIDI; max one mono input and one mono output.
* Transport reset retains LADSPA delay history (no realtime reset contract). */
xtc_result xtc_plugin_process_at(xtc_plugin *plugin, const float *input, float *output, size_t frames, uint64_t position);
xtc_result xtc_plugin_process(xtc_plugin *plugin, const float *input, float *output, size_t frames);
xtc_result xtc_graph_add_plugin(xtc_graph *graph, const xtc_plugin_spec *spec, size_t *id);
xtc_result xtc_graph_get_plugin(const xtc_graph *graph, size_t id, xtc_plugin_spec *out);

View file

@ -8,6 +8,8 @@ extern "C" {
#endif
typedef struct xtc_realtime xtc_realtime;
/* Single audio owner, or stopped/joined control owner: position current/future plans. */
xtc_result xtc_realtime_position(xtc_realtime *engine, uint64_t position);
typedef struct {
unsigned int processed_blocks; /* Wraps modulo UINT_MAX + 1. */

View file

@ -124,6 +124,7 @@ static int process(jack_nframes_t frames, void *context)
} else {
const float *input = jack_port_get_buffer(backend->input, frames);
uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED);
(void)xtc_realtime_position(backend->engine,position);
if (backend->recorder)
xtc_recorder_push(backend->recorder, input, (size_t)frames);
if (backend->audio)
@ -434,6 +435,7 @@ xtc_result xtc_backend_seek(xtc_backend *backend, uint64_t sample)
__atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE))
return XTC_ERR_INVALID_STATE;
xtc_midi_plan_reset(backend->midi);
(void)xtc_realtime_position(backend->engine,sample);
__atomic_store_n(&backend->sample_position, sample, __ATOMIC_RELAXED);
return XTC_OK;
}
@ -467,6 +469,7 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph)
* Notifications never access engine. Preserve old engine until ready. */
xtc_realtime_destroy(backend->engine);
backend->engine = replacement;
(void)xtc_realtime_position(backend->engine,__atomic_load_n(&backend->sample_position,__ATOMIC_RELAXED));
__atomic_store_n(&backend->process_result, (unsigned int)XTC_OK, __ATOMIC_RELAXED);
return XTC_OK;
}

View file

@ -271,8 +271,8 @@ xtc_result xtc_plan_process_stereo(xtc_plan *plan, const float *input, const flo
}
}
if (spec->type == XTC_NODE_PLUGIN) {
xtc_result result = xtc_plugin_process(plan->plugins[channel * plan->node_count + node],
spec->plugin.inputs ? buffer : NULL,buffer,frame_count);
xtc_result result = xtc_plugin_process_at(plan->plugins[channel * plan->node_count + node],
spec->plugin.inputs ? buffer : NULL,buffer,frame_count,plan->position);
if (result != XTC_OK) {
memset(output,0,frame_count*sizeof(*output));
if (right) memset(right,0,frame_count*sizeof(*right));
@ -372,3 +372,6 @@ xtc_result xtc_plan_process_sources(xtc_plan *p, const float *in, const float *a
{
return xtc_plan_process_stereo(p, in, audio, NULL, midi, NULL, out, NULL, frames);
}
xtc_result xtc_plan_position(xtc_plan *p, uint64_t position)
{ if (!p || position > UINT64_MAX-65536U) return XTC_ERR_INVALID_ARGUMENT; p->position = position; return XTC_OK; }

View file

@ -5,6 +5,7 @@
struct xtc_realtime {
double sample_rate;
uint64_t position;
size_t max_frames;
xtc_plan *pending;
xtc_plan *retired;
@ -166,15 +167,20 @@ xtc_result xtc_realtime_process_stereo(xtc_realtime *engine, const float *input,
__atomic_store_n(&engine->retired, old, __ATOMIC_RELEASE);
}
}
if (engine->active != NULL)
result = xtc_plan_process_stereo(engine->active, input, timeline, timeline_right, instrument,
if (engine->active != NULL) {
result = xtc_plan_position(engine->active,engine->position);
if (result == XTC_OK) result = xtc_plan_process_stereo(engine->active, input, timeline, timeline_right, instrument,
instrument_right, output, right, frame_count);
else {
} else {
memset(output, 0, frame_count * sizeof(*output));
if (right)
memset(right, 0, frame_count * sizeof(*right));
}
}
if (result == XTC_OK) {
if (engine->position <= UINT64_MAX-frame_count) engine->position += frame_count;
else result = XTC_ERR_CAPACITY;
}
if (result != XTC_OK) {
memset(output, 0, frame_count * sizeof(*output));
if (right)
@ -231,3 +237,6 @@ xtc_result xtc_realtime_process_sources(xtc_realtime *p, const float *in, const
{
return xtc_realtime_process_stereo(p, in, audio, NULL, midi, NULL, out, NULL, frames);
}
xtc_result xtc_realtime_position(xtc_realtime *p, uint64_t position)
{ if (!p || position > UINT64_MAX-65536U) return XTC_ERR_INVALID_ARGUMENT; p->position = position; return XTC_OK; }

View file

@ -76,6 +76,7 @@ MainWindow::MainWindow(bool prompt_unsaved)
menu->add("View/Piano roll", FL_CTRL + '4', piano_callback, this);
menu->add("Audio/Record WAV", FL_SHIFT + FL_F + 5, audio_file_callback, this);
menu->add("File/Load LADSPA plugin", 0, plugin_callback, this);
menu->add("Edit/Selected plugin automation", 0, plugin_callback, this);
menu->add("Edit/Selected plugin control", 0, plugin_callback, this);
menu->add("File/Quit", FL_CTRL + 'q', close_callback, this);
menu->add("Audio/Start engine", FL_F + 5, play_callback, this);
@ -1372,8 +1373,31 @@ xtc_result MainWindow::set_plugin_control(size_t id, unsigned int port, float va
for (i = 0; i < spec.control_count && spec.ports[i] != port; ++i) {}
if (i == spec.control_count) return XTC_ERR_NOT_FOUND;
spec.values[i] = value;
return apply_plugin_spec(id,spec);
}
xtc_result MainWindow::set_plugin_automation(size_t id, const xtc_plugin_point &point, bool remove)
{
if (recorder_) return XTC_ERR_INVALID_STATE;
xtc_plugin_spec spec{};
xtc_result r = xtc_graph_get_plugin(xtc_project_graph(project_.get()),id,&spec);
if (r != XTC_OK) return r;
unsigned i;
for (i = 0; i < spec.point_count; ++i) if (spec.points[i].port == point.port && spec.points[i].tick == point.tick) break;
if (remove) {
if (i == spec.point_count) return XTC_ERR_NOT_FOUND;
for (unsigned j = i+1; j < spec.point_count; ++j) spec.points[j-1] = spec.points[j];
--spec.point_count;
} else {
if (i == XTC_PLUGIN_POINTS) return XTC_ERR_CAPACITY;
spec.points[i] = point;
if (i == spec.point_count) ++spec.point_count;
}
return apply_plugin_spec(id,spec);
}
xtc_result MainWindow::apply_plugin_spec(size_t id, const xtc_plugin_spec &spec)
{
xtc_graph *graph = nullptr;
r = xtc_graph_clone(xtc_project_graph(project_.get()),&graph);
xtc_result r = xtc_graph_clone(xtc_project_graph(project_.get()),&graph);
if (r == XTC_OK) r = xtc_graph_set_plugin(graph,id,&spec);
/* Compile before adoption even with a mocked/disconnected backend. */
xtc_plan *check = nullptr;
@ -1423,6 +1447,21 @@ void MainWindow::plugin_callback(Fl_Widget *widget, void *context)
for (unsigned i = 0; i < spec.control_count; ++i)
prompt += std::to_string(spec.ports[i]) + ": " + info[i].name + " = " + std::to_string(spec.values[i]) +
" [" + std::to_string(info[i].minimum) + ", " + std::to_string(info[i].maximum) + "]\n";
if (std::strcmp(label,"Selected plugin automation") == 0) {
prompt += "Enter port tick mode value; mode 0 linear, 1 step, -1 remove.\nCurrent tick: " +
std::to_string(w->sequencer_->position());
const char *answer = fl_input("%s","",prompt.c_str());
if (!answer) return;
unsigned port; unsigned long long tick; int mode; float value; char extra;
if (std::sscanf(answer,"%u %llu %d %f %c",&port,&tick,&mode,&value,&extra) != 4 || mode < -1 || mode > 1)
r = XTC_ERR_INVALID_ARGUMENT;
else {
xtc_plugin_point point{static_cast<uint64_t>(tick),port,mode == 1 ? 1U : 0U,value};
r = w->set_plugin_automation(id,point,mode == -1);
}
if (r != XTC_OK) w->report_error("Plugin automation rejected: check tick, port, range and discrete mode",r);
return;
}
prompt += "Enter port and value (for example: 2 0.5):";
const char *answer = fl_input("%s","",prompt.c_str());
if (!answer) return;

View file

@ -21,6 +21,7 @@ class MainWindow : public Fl_Double_Window {
public:
/* Control-thread plugin commands, transactional model adoption. */
xtc_result add_plugin(const char *path, unsigned int index, size_t *id = nullptr);
xtc_result set_plugin_automation(size_t id, const xtc_plugin_point &point, bool remove = false);
xtc_result set_plugin_control(size_t id, unsigned int port, float value);
/**
* @brief Constructs the window, its widgets, and the initial project.
@ -289,6 +290,7 @@ class MainWindow : public Fl_Double_Window {
* @param context MainWindow * owning the project and file path.
*/
static void plugin_callback(Fl_Widget *widget, void *context);
xtc_result apply_plugin_spec(size_t id, const xtc_plugin_spec &spec);
xtc_result install_plugin_graph(xtc_graph *graph);
static void file_callback(Fl_Widget * widget, void *context);
GraphWidget *graph_widget_ = nullptr;

View file

@ -19,13 +19,15 @@ typedef struct {
} endpoint;
typedef struct {
unsigned int active, channel, pitch, velocity;
unsigned int active, channel, pitch, velocity, released;
uint64_t age;
double phase;
} voice;
struct xtc_midi_plan {
size_t fade_frames;
size_t fade_frames, controller_frames;
xtc_gain_ramp volume[16], pan[16];
unsigned int sustain[16];
xtc_mix_snapshot mix;
span notes[XTC_PROJECT_MAX_NOTES];
size_t count;
@ -54,13 +56,13 @@ xtc_result xtc_midi_decode(const unsigned char *bytes, size_t size, unsigned int
return XTC_ERR_UNSUPPORTED;
if (size != 3 || bytes[1] > 127 || bytes[2] > 127)
return XTC_ERR_INVALID_ARGUMENT;
if (command == 176 && bytes[1] != 120 && bytes[1] != 123)
if (command == 176 && bytes[1] != 120 && bytes[1] != 123 && bytes[1] != 7 && bytes[1] != 10 && bytes[1] != 64)
return XTC_ERR_UNSUPPORTED;
e.offset = offset;
e.channel = bytes[0] & 15U;
e.pitch = bytes[1];
e.velocity = bytes[2];
e.type = command == 176 ? XTC_MIDI_ALL_OFF : command == 128 || !e.velocity ? XTC_MIDI_NOTE_OFF : XTC_MIDI_NOTE_ON;
e.type = command == 176 ? (bytes[1] == 120 || bytes[1] == 123 ? XTC_MIDI_ALL_OFF : XTC_MIDI_CONTROL) : command == 128 || !e.velocity ? XTC_MIDI_NOTE_OFF : XTC_MIDI_NOTE_ON;
*out = e;
return XTC_OK;
}
@ -93,6 +95,11 @@ void xtc_midi_plan_reset(xtc_midi_plan *p)
memset(p->cutoff, 0, sizeof(p->cutoff));
p->next_position = UINT64_MAX;
p->age = 0;
{ unsigned int c; for (c = 0; c < 16; ++c) {
p->sustain[c] = 0;
(void)xtc_gain_ramp_reset(p->volume+c,1);
(void)xtc_gain_ramp_reset(p->pan+c,0);
} }
}
/**
@ -181,6 +188,8 @@ xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int rate, x
}
}
free(points);
p->controller_frames = rate / 200U;
if (!p->controller_frames) p->controller_frames = 1;
xtc_midi_plan_reset(p);
*out = p;
return XTC_OK;
@ -209,6 +218,7 @@ static void channel_off(xtc_midi_plan *p, unsigned int channel, uint64_t positio
for (i = 0; i < XTC_MIDI_VOICES; ++i)
if (p->voices[i].channel == channel)
p->voices[i].active = 0;
p->sustain[channel] = 0;
p->cutoff[channel] = position == UINT64_MAX ? position : position + 1;
}
@ -242,6 +252,17 @@ static void event(xtc_midi_plan *p, const xtc_midi_event *e, uint64_t position)
channel_off(p, e->channel, position);
return;
}
if (e->type == XTC_MIDI_CONTROL) {
if (e->pitch == 7) (void)xtc_gain_ramp_target(p->volume+e->channel,(float)e->velocity/127.0f,p->controller_frames);
else if (e->pitch == 10) (void)xtc_gain_ramp_target(p->pan+e->channel,
e->velocity >= 64 ? (float)(e->velocity-64)/63.0f : ((float)e->velocity-64)/64.0f,p->controller_frames);
else if (e->pitch == 64) {
p->sustain[e->channel] = e->velocity >= 64;
if (!p->sustain[e->channel]) for (i = 0; i < XTC_MIDI_VOICES; ++i)
if (p->voices[i].channel == e->channel && p->voices[i].released) p->voices[i].active = 0;
}
return;
}
for (i = 0; i < XTC_MIDI_VOICES; ++i) {
if (p->voices[i].active && p->voices[i].channel == e->channel && p->voices[i].pitch == e->pitch) {
slot = i;
@ -249,8 +270,10 @@ static void event(xtc_midi_plan *p, const xtc_midi_event *e, uint64_t position)
}
}
if (e->type == XTC_MIDI_NOTE_OFF) {
if (slot != SIZE_MAX)
p->voices[slot].active = 0;
if (slot != SIZE_MAX) {
if (p->sustain[e->channel]) p->voices[slot].released = 1;
else p->voices[slot].active = 0;
}
return;
}
if (slot == SIZE_MAX) {
@ -267,6 +290,7 @@ static void event(xtc_midi_plan *p, const xtc_midi_event *e, uint64_t position)
slot = i;
}
p->voices[slot].active = 1;
p->voices[slot].released = 0;
p->voices[slot].channel = e->channel;
p->voices[slot].pitch = e->pitch;
p->voices[slot].velocity = e->velocity;
@ -315,19 +339,24 @@ static void segment(xtc_midi_plan *p, uint64_t position, float *out, float *righ
right[j] += sample * xtc_mix_level(&p->mix, s->track, position + j, 1);
}
}
for (i = 0; i < XTC_MIDI_VOICES; ++i) {
voice *v = p->voices + i;
double step = p->step[v->pitch];
if (!v->active || step == 0.0)
continue;
for (j = 0; j < frames; ++j) {
float sample = (float)(sin(TAU * v->phase) * 0.1 * v->velocity / 127.0);
out[j] += sample;
if (right)
right[j] += sample;
for (j = 0; j < frames; ++j) {
float levels[16], pans[16]; unsigned int c;
for (c = 0; c < 16; ++c) {
levels[c] = pans[c] = 1;
(void)xtc_gain_ramp_process(p->volume+c,levels+c,NULL,1);
(void)xtc_gain_ramp_process(p->pan+c,pans+c,NULL,1);
}
for (i = 0; i < XTC_MIDI_VOICES; ++i) {
voice *v = p->voices+i;
double step = p->step[v->pitch];
float sample, pan;
if (!v->active || step == 0) continue;
sample = (float)(sin(TAU*v->phase)*.1*v->velocity/127.0) * levels[v->channel];
pan = pans[v->channel];
out[j] += sample * (right && pan > 0 ? 1-pan : 1);
if (right) right[j] += sample * (pan < 0 ? 1+pan : 1);
v->phase += step;
if (v->phase >= 1)
v->phase -= 1;
if (v->phase >= 1) v->phase -= 1;
}
}
}
@ -354,7 +383,7 @@ xtc_result xtc_midi_plan_render_stereo(xtc_midi_plan *p, uint64_t position, cons
for (i = 0; i < count; ++i) {
const xtc_midi_event *e = events + i;
if (e->offset >= frames || (i && e->offset < events[i - 1].offset) || e->type < XTC_MIDI_NOTE_ON ||
e->type > XTC_MIDI_ALL_OFF || e->pitch > 127 || e->velocity > 127 || e->channel > 15 ||
e->type > XTC_MIDI_CONTROL || e->pitch > 127 || e->velocity > 127 || e->channel > 15 ||
(e->type == XTC_MIDI_NOTE_ON && !e->velocity))
return XTC_ERR_INVALID_ARGUMENT;
}

View file

@ -124,7 +124,7 @@ xtc_result xtc_native_process(xtc_native *p, const xtc_native_block *b)
for (i = 0; i < b->event_count; ++i) {
const xtc_midi_event *e = b->events + i;
if (e->offset >= b->frames || (i && e->offset < b->events[i - 1].offset) || e->type < XTC_MIDI_NOTE_ON ||
e->type > XTC_MIDI_ALL_OFF || e->channel > 15 || e->pitch > 127 || e->velocity > 127 ||
e->type > XTC_MIDI_CONTROL || e->channel > 15 || e->pitch > 127 || e->velocity > 127 ||
(e->type == XTC_MIDI_NOTE_ON && !e->velocity))
return XTC_ERR_INVALID_ARGUMENT;
}

View file

@ -88,7 +88,7 @@ xtc_result xtc_processor_process(xtc_processor *p, const xtc_native_block *b, co
for (i = 0; i < b->event_count; ++i) {
const xtc_midi_event *e = b->events + i;
if (e->offset >= b->frames || (i && e->offset < b->events[i-1].offset) || e->type < XTC_MIDI_NOTE_ON ||
e->type > XTC_MIDI_ALL_OFF || e->pitch > 127 || e->velocity > 127 || e->channel > 15 ||
e->type > XTC_MIDI_CONTROL || e->pitch > 127 || e->velocity > 127 || e->channel > 15 ||
(e->type == XTC_MIDI_NOTE_ON && !e->velocity)) return XTC_ERR_INVALID_ARGUMENT;
}
if (!b->frames) return XTC_OK;

View file

@ -191,7 +191,7 @@ static xtc_result read_project(reader *input, xtc_project *project)
xtc_graph *graph = project->graph;
READ("XTC_PROJECT", 2U);
NUM(1U, version);
if (version < 1U || version > 5U)
if (version < 1U || version > 6U)
return XTC_ERR_UNSUPPORTED;
format_version = version;
READ("APP", 4U);
@ -255,6 +255,20 @@ static xtc_result read_project(reader *input, xtc_project *project)
if (value >= 64 || !decode_float(input->tokens[2],p->values+k)) return XTC_ERR_FORMAT;
p->ports[k] = (unsigned int)value;
}
p->tempo = project->tempo;
if (format_version >= 6) {
READ("PLUGIN_AUTOMATION",2U); NUM(1U,value);
if (value > XTC_PLUGIN_POINTS) return XTC_ERR_FORMAT;
p->point_count = (unsigned int)value;
for (k = 0; k < p->point_count; ++k) {
xtc_plugin_point *a = p->points+k;
READ("PARAM",5U); NUM(1U,a->tick); NUM(2U,value);
if (value >= 64) return XTC_ERR_FORMAT;
a->port = (unsigned int)value;
NUM(3U,value); if (value > 1) return XTC_ERR_FORMAT; a->step = (unsigned int)value;
if (!decode_float(input->tokens[4],&a->value)) return XTC_ERR_FORMAT;
}
}
if (xtc_plugin_spec_validate(p) != XTC_OK) return XTC_ERR_FORMAT;
}
for (j = 0U; j < i; ++j)
@ -423,7 +437,7 @@ static xtc_result write_project(FILE *file, const xtc_project *project)
return XTC_ERR_UNSUPPORTED;
if (sizeof(float) != sizeof(uint32_t) || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128)
return XTC_ERR_UNSUPPORTED;
if (fprintf(file, "XTC_PROJECT 5\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n",
if (fprintf(file, "XTC_PROJECT 6\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n",
XTC_VERSION_MAJOR, XTC_VERSION_MINOR, XTC_VERSION_PATCH, project->tempo, project->next_track_id,
project->next_clip_id, graph->require_output_nodes, graph->next_id, graph->node_count,
graph->edge_count) < 0)
@ -444,6 +458,12 @@ static xtc_result write_project(FILE *file, const xtc_project *project)
uint32_t value; memcpy(&value,p->values+k,4);
if (fprintf(file,"CONTROL %u %08" PRIx32 "\n",p->ports[k],value) < 0) return XTC_ERR_IO;
}
if (fprintf(file,"PLUGIN_AUTOMATION %u\n",p->point_count) < 0) return XTC_ERR_IO;
for (k = 0; k < p->point_count; ++k) {
uint32_t value; memcpy(&value,&p->points[k].value,4);
if (fprintf(file,"PARAM %" PRIu64 " %u %u %08" PRIx32 "\n",p->points[k].tick,p->points[k].port,
p->points[k].step,value) < 0) return XTC_ERR_IO;
}
}
}
for (i = 0U; i < graph->edge_count; ++i)

View file

@ -1,5 +1,6 @@
#include "xtc/xtc_plugin.h"
#include "xtc/xtc_dsp.h"
#include "xtc/xtc_project.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
@ -18,10 +19,20 @@ xtc_result xtc_plugin_spec_validate(const xtc_plugin_spec *s)
size_t i;
if (!s || !terminated(s->path,sizeof(s->path)) || s->path[0] != '/' ||
!terminated(s->label,sizeof(s->label)) || !s->label[0] || s->index >= 1024 ||
s->inputs > 1 || s->control_count > XTC_PLUGIN_CONTROLS) return XTC_ERR_INVALID_ARGUMENT;
s->inputs > 1 || s->control_count > XTC_PLUGIN_CONTROLS ||
s->tempo < 20000 || s->tempo > 300000 || s->point_count > XTC_PLUGIN_POINTS) return XTC_ERR_INVALID_ARGUMENT;
for (i = 0; i < s->control_count; ++i)
if (s->ports[i] >= 64 || (i && s->ports[i] <= s->ports[i-1]) || !isfinite(s->values[i]))
return XTC_ERR_INVALID_ARGUMENT;
for (i = 0; i < s->point_count; ++i) {
size_t j;
const xtc_plugin_point *a = s->points+i;
if (a->tick > XTC_PROJECT_MAX_TICK || a->step > 1 || !isfinite(a->value)) return XTC_ERR_INVALID_ARGUMENT;
for (j = 0; j < s->control_count && s->ports[j] != a->port; ++j) {}
if (j == s->control_count) return XTC_ERR_INVALID_ARGUMENT;
for (j = 0; j < i; ++j) if (s->points[j].port == a->port && s->points[j].tick == a->tick)
return XTC_ERR_INVALID_ARGUMENT;
}
return XTC_OK;
}
#ifdef XTC_HAVE_LADSPA
@ -36,6 +47,9 @@ struct xtc_plugin {
float controls[64];
size_t frames;
int activated;
xtc_plugin_point points[XTC_PLUGIN_POINTS];
uint64_t samples[XTC_PLUGIN_POINTS], next;
unsigned int point_count;
};
static xtc_result load(const char *path, unsigned int index, void **module, const LADSPA_Descriptor **descriptor)
{
@ -91,6 +105,7 @@ static xtc_result inspect(const LADSPA_Descriptor *d, double rate, xtc_plugin_sp
if (!d->Label || !d->Label[0] || strlen(d->Label) >= sizeof(s->label) || d->UniqueID > UINT32_MAX ||
!d->PortDescriptors || !d->PortRangeHints || !d->PortCount || d->PortCount > 64 ||
!d->instantiate || !d->connect_port || !d->run || !d->cleanup) return XTC_ERR_UNSUPPORTED;
s->tempo = 120000;
s->unique_id = (unsigned int)d->UniqueID; strcpy(s->label,d->Label);
for (i = 0; i < d->PortCount; ++i) {
LADSPA_PortDescriptor p = d->PortDescriptors[i];
@ -173,6 +188,15 @@ xtc_result xtc_plugin_create(const xtc_plugin_spec *s, double rate, size_t frame
if (s->ports[c] != actual.ports[c] || !bounds(p->descriptor->PortRangeHints+s->ports[c],rate,s->values[c])) goto fail;
p->controls[s->ports[c]] = s->values[c];
}
for (c = 0; c < s->point_count; ++c) {
const xtc_plugin_point *a = s->points+c;
const LADSPA_PortRangeHint *h = p->descriptor->PortRangeHints+a->port;
if (!bounds(h,rate,a->value) || (!a->step &&
(LADSPA_IS_HINT_INTEGER(h->HintDescriptor) || LADSPA_IS_HINT_TOGGLED(h->HintDescriptor)))) goto fail;
p->points[c] = *a;
p->samples[c] = a->tick * (uint64_t)rate * UINT64_C(60000) / ((uint64_t)s->tempo * XTC_TICKS_PER_QUARTER);
}
p->point_count = s->point_count;
p->frames = frames;
p->input = calloc(frames,sizeof(float)); p->output = calloc(frames,sizeof(float));
r = XTC_ERR_OUT_OF_MEMORY; if (!p->input || !p->output) goto fail;
@ -188,17 +212,51 @@ xtc_result xtc_plugin_create(const xtc_plugin_spec *s, double rate, size_t frame
fail:
xtc_plugin_destroy(p); return r;
}
xtc_result xtc_plugin_process(xtc_plugin *p, const float *input, float *output, size_t frames)
static void automate(xtc_plugin *p, uint64_t position)
{
unsigned long port;
for (port = 0; port < p->descriptor->PortCount; ++port) {
size_t i, before = SIZE_MAX, after = SIZE_MAX;
for (i = 0; i < p->point_count; ++i) if (p->points[i].port == port) {
if (p->samples[i] <= position) {
if (before == SIZE_MAX || p->points[i].tick > p->points[before].tick) before = i;
} else if (after == SIZE_MAX || p->points[i].tick < p->points[after].tick) after = i;
}
if (before == SIZE_MAX) { if (after != SIZE_MAX) p->controls[port] = p->points[after].value; }
else {
double value = p->points[before].value;
if (after != SIZE_MAX && !p->points[before].step)
value += (double)(position-p->samples[before]) / (double)(p->samples[after]-p->samples[before]) *
((double)p->points[after].value-p->points[before].value);
p->controls[port] = (float)value;
}
}
}
xtc_result xtc_plugin_process_at(xtc_plugin *p, const float *input, float *output, size_t frames, uint64_t position)
{
size_t i;
if (!p || !output || frames > p->frames) return XTC_ERR_INVALID_ARGUMENT;
if (!p || !output || frames > p->frames || position > UINT64_MAX-frames) return XTC_ERR_INVALID_ARGUMENT;
if (!frames) return XTC_OK;
for (i = 0; i < frames; ++i) p->input[i] = input ? xtc_dsp_finite(input[i]) : 0;
memset(p->output,0,frames*sizeof(float));
p->descriptor->run(p->handle,(unsigned long)frames);
for (i = 0; i < frames; ++i) output[i] = xtc_dsp_finite(p->output[i]);
return XTC_OK;
if (!p->point_count) {
for (i = 0; i < frames; ++i) p->input[i] = input ? xtc_dsp_finite(input[i]) : 0;
memset(p->output,0,frames*sizeof(float));
p->descriptor->run(p->handle,(unsigned long)frames);
for (i = 0; i < frames; ++i) output[i] = xtc_dsp_finite(p->output[i]);
} else {
/* Buffers stay connected: run(1) reads/writes slot zero, one control
* evaluation per absolute sample. Exact aliasing is safe. */
for (i = 0; i < frames; ++i) {
automate(p,position+i);
p->input[0] = input ? xtc_dsp_finite(input[i]) : 0; p->output[0] = 0;
p->descriptor->run(p->handle,1);
output[i] = xtc_dsp_finite(p->output[0]);
}
}
p->next = position+frames; return XTC_OK;
}
xtc_result xtc_plugin_process(xtc_plugin *p, const float *input, float *output, size_t frames)
{ return xtc_plugin_process_at(p,input,output,frames,p ? p->next : 0); }
#else
xtc_result xtc_plugin_parameters(const xtc_plugin_spec *s, double r, xtc_parameter_info out[XTC_PLUGIN_CONTROLS])
{ (void)s; (void)r; (void)out; return XTC_ERR_UNSUPPORTED; }
@ -207,6 +265,8 @@ xtc_result xtc_plugin_probe(const char *p, unsigned int i, double r, xtc_plugin_
xtc_result xtc_plugin_create(const xtc_plugin_spec *s, double r, size_t f, xtc_plugin **out)
{ (void)s; (void)r; (void)f; if (!out) return XTC_ERR_INVALID_ARGUMENT; *out = NULL; return XTC_ERR_UNSUPPORTED; }
void xtc_plugin_destroy(xtc_plugin *p) { (void)p; }
xtc_result xtc_plugin_process_at(xtc_plugin *p, const float *i, float *o, size_t f, uint64_t pos)
{ (void)p; (void)i; (void)o; (void)f; (void)pos; return XTC_ERR_UNSUPPORTED; }
xtc_result xtc_plugin_process(xtc_plugin *p, const float *i, float *o, size_t f)
{ (void)p; (void)i; (void)o; (void)f; return XTC_ERR_UNSUPPORTED; }
#endif

View file

@ -174,6 +174,8 @@ xtc_result xtc_project_set_graph(xtc_project *project, const xtc_graph *source)
copy->require_output_nodes = source->require_output_nodes;
memcpy(copy->nodes, source->nodes, source->node_count * sizeof(*copy->nodes));
memcpy(copy->edges, source->edges, source->edge_count * sizeof(*copy->edges));
{ size_t i; for (i = 0; i < copy->node_count; ++i)
if (copy->nodes[i].type == XTC_NODE_PLUGIN) copy->nodes[i].plugin.tempo = project->tempo; }
xtc_graph_destroy(project->graph);
project->graph = copy;
return XTC_OK;
@ -446,6 +448,8 @@ xtc_result xtc_project_set_tempo(xtc_project *project, unsigned int tempo)
if (project == NULL || tempo < 20000U || tempo > 300000U)
return XTC_ERR_INVALID_ARGUMENT;
project->tempo = tempo;
{ size_t i; for (i = 0; i < project->graph->node_count; ++i)
if (project->graph->nodes[i].type == XTC_NODE_PLUGIN) project->graph->nodes[i].plugin.tempo = tempo; }
return XTC_OK;
}

View file

@ -476,10 +476,15 @@ int main(int argc, char **argv)
const xtc_project *before = sequence->project();
CHECK(window.add_plugin("/absent/plugin.so",0) == XTC_ERR_NOT_FOUND && sequence->project() == before);
CHECK(window.add_plugin(module,2) == XTC_ERR_UNSUPPORTED && sequence->project() == before);
xtc_plugin_point point{960,spec.ports[0],0,.25f};
CHECK(window.set_plugin_automation(id,point) == XTC_OK);
CHECK(window.set_plugin_automation(id,point,true) == XTC_OK);
CHECK(window.undo(false) == XTC_OK);
CHECK(window.save_project("/tmp/xtc-gui-plugin-test.xtc") == XTC_OK);
CHECK(window.new_project() == XTC_OK);
CHECK(window.open_project("/tmp/xtc-gui-plugin-test.xtc") == XTC_OK);
CHECK(xtc_graph_get_plugin(xtc_project_graph(sequence->project()),id,&spec) == XTC_OK && spec.values[0] == .5f);
CHECK(spec.point_count == 1 && spec.points[0].tick == 960 && spec.points[0].value == .25f);
xtc_project *missing = nullptr; xtc_graph *graph = nullptr; size_t absent;
CHECK(xtc_project_create(&missing) == XTC_OK);
CHECK(xtc_graph_create(8,&graph) == XTC_OK);

View file

@ -133,6 +133,41 @@ int main(int argc, char **argv)
for (i = 0; i < 17; ++i) assert(data[i] == 1);
xtc_plugin_destroy(p); p = NULL;
graph_roundtrip(&s);
s.point_count = 2;
s.points[0] = (xtc_plugin_point){0,s.ports[0],0,.5f};
s.points[1] = (xtc_plugin_point){1,s.ports[0],0,2};
graph_roundtrip(&s);
{
xtc_graph *g = NULL; xtc_realtime *rt = NULL; size_t id;
float in[1] = {1}, out[1];
assert(xtc_graph_create(8,&g) == XTC_OK && xtc_graph_add_plugin(g,&s,&id) == XTC_OK);
assert(xtc_realtime_create(48000,31,&rt) == XTC_OK);
assert(xtc_realtime_position(rt,25) == XTC_OK && xtc_realtime_submit(rt,g) == XTC_OK);
audio = 1; assert(xtc_realtime_process(rt,in,out,1) == XTC_OK && out[0] == 2); audio = 0;
xtc_realtime_collect(rt);
assert(xtc_realtime_submit(rt,g) == XTC_OK);
audio = 1; assert(xtc_realtime_process(rt,in,out,1) == XTC_OK && out[0] == 2); audio = 0;
xtc_realtime_collect(rt);
xtc_realtime_destroy(rt); xtc_graph_destroy(g);
}
{
float whole[31], split[31], in[31]; xtc_plugin *other = NULL;
for (i = 0; i < 31; ++i) in[i] = 1;
assert(xtc_plugin_create(&s,48000,31,&p) == XTC_OK);
assert(xtc_plugin_create(&s,48000,31,&other) == XTC_OK);
audio = 1;
assert(xtc_plugin_process_at(p,in,whole,31,0) == XTC_OK);
assert(xtc_plugin_process_at(other,in,split,7,0) == XTC_OK);
assert(xtc_plugin_process_at(other,in+7,split+7,24,7) == XTC_OK);
audio = 0;
for (i = 0; i < 31; ++i) {
assert(whole[i] == split[i]);
assert(fabsf(whole[i]-(i < 25 ? .5f+1.5f*(float)i/25 : 2)) < .000001f);
}
assert(xtc_plugin_process_at(other,in,split,1,25) == XTC_OK && split[0] == 2);
xtc_plugin_destroy(p); xtc_plugin_destroy(other); p = NULL;
}
s.point_count = 0;
s.values[0] = 3; assert(xtc_plugin_create(&s,48000,17,&p) == XTC_ERR_UNSUPPORTED && !p);
assert(xtc_plugin_probe(argv[1],1,48000,&s) == XTC_OK && !s.inputs);
graph_roundtrip(&s);

38
tests/unit/test_control.c Normal file
View file

@ -0,0 +1,38 @@
#include "xtc/xtc_midi.h"
#include <assert.h>
#include <math.h>
#include <stdio.h>
int main(void)
{
xtc_project *project = NULL; xtc_midi_plan *a = NULL, *b = NULL;
float whole[400], split[400]; size_t i, j, cursor = 0;
xtc_midi_event decoded;
const unsigned char cc[] = {0xb2,7,0};
xtc_midi_event events[] = {
{0,XTC_MIDI_NOTE_ON,2,69,127}, {3,XTC_MIDI_CONTROL,2,64,127},
{5,XTC_MIDI_NOTE_OFF,2,69,0}, {9,XTC_MIDI_NOTE_ON,2,69,127},
{10,XTC_MIDI_CONTROL,2,7,0}, {11,XTC_MIDI_CONTROL,2,10,127},
{260,XTC_MIDI_NOTE_OFF,2,69,0}, {270,XTC_MIDI_CONTROL,2,64,0}};
assert(xtc_midi_decode(cc,3,10,&decoded) == XTC_OK && decoded.type == XTC_MIDI_CONTROL && decoded.pitch == 7);
assert(xtc_project_create(&project) == XTC_OK);
assert(xtc_midi_plan_create(project,48000,&a) == XTC_OK);
assert(xtc_midi_plan_create(project,48000,&b) == XTC_OK);
assert(xtc_midi_plan_render(a,0,events,8,whole,400) == XTC_OK);
while (cursor < 400) {
xtc_midi_event local[8]; size_t count = 0, n = 13;
if (n > 400-cursor) n = 400-cursor;
for (j = 0; j < 8; ++j) if (events[j].offset >= cursor && events[j].offset < cursor+n) {
local[count] = events[j]; local[count++].offset -= (unsigned int)cursor;
}
assert(xtc_midi_plan_render(b,cursor,local,count,split+cursor,n) == XTC_OK); cursor += n;
}
for (i = 0; i < 400; ++i) assert(whole[i] == split[i]);
assert(whole[6] != 0); /* Sustain retains the released note. */
for (i = 250; i < 400; ++i) assert(whole[i] == 0); /* CC7 ramp reaches zero in 240 samples. */
xtc_midi_plan_reset(a);
assert(xtc_midi_plan_render(a,400,NULL,0,whole,400) == XTC_OK);
for (i = 0; i < 400; ++i) assert(whole[i] == 0);
xtc_midi_plan_destroy(a); xtc_midi_plan_destroy(b); xtc_project_destroy(project);
puts("Sample-timed CC7/CC10/sustain, retrigger, note-off and block partition checks passed.");
return 0;
}