diff --git a/Makefile b/Makefile index 5e30908..1b5e0bc 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/CONTROL.md b/docs/CONTROL.md new file mode 100644 index 0000000..f89cf18 --- /dev/null +++ b/docs/CONTROL.md @@ -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. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 964445b..51d94cb 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 907cf64..ce07c3f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/examples/processors.c b/examples/processors.c index d782d86..9aa3906 100644 --- a/examples/processors.c +++ b/examples/processors.c @@ -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); diff --git a/include/xtc/xtc_engine.h b/include/xtc/xtc_engine.h index 8181592..43a1082 100644 --- a/include/xtc/xtc_engine.h +++ b/include/xtc/xtc_engine.h @@ -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, diff --git a/include/xtc/xtc_midi.h b/include/xtc/xtc_midi.h index 86f6212..acd08b3 100644 --- a/include/xtc/xtc_midi.h +++ b/include/xtc/xtc_midi.h @@ -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 { diff --git a/include/xtc/xtc_plugin.h b/include/xtc/xtc_plugin.h index 96f72b0..a6b636c 100644 --- a/include/xtc/xtc_plugin.h +++ b/include/xtc/xtc_plugin.h @@ -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); diff --git a/include/xtc/xtc_realtime.h b/include/xtc/xtc_realtime.h index a7fd449..34e5c8b 100644 --- a/include/xtc/xtc_realtime.h +++ b/include/xtc/xtc_realtime.h @@ -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. */ diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 29a85ff..2f6ee4d 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -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; } diff --git a/src/engine/offline_engine.c b/src/engine/offline_engine.c index 326193d..35ccf86 100644 --- a/src/engine/offline_engine.c +++ b/src/engine/offline_engine.c @@ -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; } diff --git a/src/engine/realtime.c b/src/engine/realtime.c index 6a9dd24..740387d 100644 --- a/src/engine/realtime.c +++ b/src/engine/realtime.c @@ -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; } diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index c7a054c..b7799a7 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -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(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; diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 97cb643..e64a3e0 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -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; diff --git a/src/midi/instrument.c b/src/midi/instrument.c index 08a39bf..e2644ee 100644 --- a/src/midi/instrument.c +++ b/src/midi/instrument.c @@ -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; } diff --git a/src/native/native.c b/src/native/native.c index c509d7c..88cad47 100644 --- a/src/native/native.c +++ b/src/native/native.c @@ -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; } diff --git a/src/native/processor.c b/src/native/processor.c index b9025a4..3509d1b 100644 --- a/src/native/processor.c +++ b/src/native/processor.c @@ -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; diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index dbd9f64..4de685d 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -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) diff --git a/src/plugin/ladspa.c b/src/plugin/ladspa.c index 9f6a640..3ef5235 100644 --- a/src/plugin/ladspa.c +++ b/src/plugin/ladspa.c @@ -1,5 +1,6 @@ #include "xtc/xtc_plugin.h" #include "xtc/xtc_dsp.h" +#include "xtc/xtc_project.h" #include #include #include @@ -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 diff --git a/src/project/project.c b/src/project/project.c index a6a409b..2b8c2f4 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -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; } diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 60204c4..d01ce3c 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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); diff --git a/tests/integration/test_plugins.c b/tests/integration/test_plugins.c index d0e9a12..d2cae3e 100644 --- a/tests/integration/test_plugins.c +++ b/tests/integration/test_plugins.c @@ -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); diff --git a/tests/unit/test_control.c b/tests/unit/test_control.c new file mode 100644 index 0000000..3274cbf --- /dev/null +++ b/tests/unit/test_control.c @@ -0,0 +1,38 @@ +#include "xtc/xtc_midi.h" +#include +#include +#include +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; +}