From 68663f72d4e8e25a934f82db082ff3008534c1d0 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Fri, 16 Oct 2026 07:17:21 +0200 Subject: [PATCH] Play instruments when qualifying them, and add an omx-clap-host profile (#72) An instrument is quiet and cheap until a voice plays, so a synth that allocates on note on read suitable. The CLAP prober now plays every plugin with a note input, at every rate, under a count of its own: a chord, the pitch bent and a controller moved, pressure, the chord let go, a burst of 32 note ons in one block and their offs, and all notes off, in the dialect its note input reads (CLAP's, else MIDI's). The line gains a noteSweep block; an allocation, lock or syscall while played is unsuitable (hosting.rt-safety.violations-under-notes), an instrument that could not be played under the interposer is unknown (hosting.rt-safety.notes-unmeasured). The verdict document becomes plugin-qualify.rt-safety/2: /1 with measured.noteSweep and the note sweep's reasons in verdict. A plugin with no note input reads the same verdict as before. The omx-clap-host profile: the console's six CLAP host extensions plus the three omx-clap-host configures (preset-load, track-info, remote-controls), instruments fed, shared process, latency uncompensated. The prober offers those three with --offer, and a CLAP target is probed once per offer its profiles need, so each verdict is taken with its host's extensions. Its list is held to omx-clap-core's clap_host_extensions.h and configure_core. Fixtures: synth.clap (clean), synth-alloc.clap (allocates on note on) and synth-midi.clap (MIDI's dialect only). --- README.md | 20 ++- native/Makefile | 9 ++ native/clap-qualify.c | 272 +++++++++++++++++++++++++++++++- native/fixtures/synth.c | 259 ++++++++++++++++++++++++++++++ packaging/plugin-qualify.1 | 2 +- src/clap-qualify.test.ts | 155 +++++++++++++++++- src/clap-qualify.ts | 101 ++++++++++-- src/host-profiles.test.ts | 52 +++++- src/host-profiles.ts | 30 +++- src/hosting-codes.ts | 4 + src/hosting-suitability.test.ts | 12 +- src/hosting-suitability.ts | 53 +++++++ src/qualify-declarations.ts | 3 + 13 files changed, 941 insertions(+), 31 deletions(-) create mode 100644 native/fixtures/synth.c diff --git a/README.md b/README.md index 86be859..7416583 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,17 @@ The prober runs each plugin headless at every `--rates` rate, under an interpose allocations, locks and syscalls its `process()` makes while every parameter is swept, and prints the file's SHA-256 beside what it measured. The verdict is the real-time-safety one: **suitable** only on a clean count, **unsuitable** on any allocation, lock, syscall, main-thread host call from -`process()` or `CLAP_PROCESS_ERROR`, **unknown** when it could not be counted. `--out` writes one +`process()` or `CLAP_PROCESS_ERROR`, **unknown** when it could not be counted. An instrument is +quiet until it is played, so a plugin with a note input is also played, at every rate and under a +count of its own: a chord, the pitch bent and a controller moved, pressure, the chord let go, a +burst of 32 note ons in one block and their offs, all notes off, in the dialect its note input +reads (CLAP's, else MIDI's). An allocation, lock or syscall while it plays is **unsuitable** +(`hosting.rt-safety.violations-under-notes`); an instrument that could not be played under the +interposer is **unknown** (`hosting.rt-safety.notes-unmeasured`). `--out` writes one document per plugin binary and host profile, named `..rt-safety.json`, so a -verdict never outlives the bytes it was measured on. Each line also carries the plugin's pin, an +verdict never outlives the bytes it was measured on. Its schema is `plugin-qualify.rt-safety/2`: `/1` with +`measured.noteSweep` added and the note sweep's reasons in `verdict`; a plugin with no note input +reads the same verdict under both. Each line also carries the plugin's pin, an `integrity` block: the `.clap`'s SHA-256 by its file name and the parameter-layout fingerprint `omx-layout/1:`, both computed through plugin-hostd's `pin.h`, which is what a console hands plugin-hostd as `pin_set` before it adds the plugin. `OMX_CLAP_QUALIFY` and `OMX_RT_INTERPOSE` @@ -181,6 +189,7 @@ hosts. Two projects share the measurements and differ only in the profile they c ``` plugin-qualify --host-profile jalv ./my-synth.lv2 # zynthian: one jalv process per plugin plugin-qualify --host-profile mod-host ./my-fx.lv2 # MOD: one shared mod-host process +plugin-qualify --host-profile omx-clap-host ./my-synth.clap # omx-clap-host, alone or under plugin-hostd plugin-qualify --host-profile jalv,mod-host,openmixer-console # one verdict per profile plugin-qualify --host-profile ./my-host.json # your own host ``` @@ -190,8 +199,13 @@ plugin-qualify --host-profile ./my-host.json # your own ho | `openmixer-console` (default) | in-process (the console's RT thread) | no | the console's in-process table | 0.05 at 96000 / 1024 | | `jalv` | per-process | yes | jalv 1.6.8 `src/jalv.c` (zynthian's fork passes the same list) | 0.25 at 48000 / 256 | | `mod-host` | shared-process | yes | mod-host f14a230 `src/effects.c` | 0.05 at 48000 / 128 | +| `omx-clap-host` | shared-process | yes | the console's in-process table (the same LV2 feature provider) | 0.05 at 48000 / 128 | -The `jalv` and `mod-host` budgets are defaults, not a measurement of your board. State your own +`omx-clap-host` offers a CLAP plugin the console's six host extensions and three more, +`clap.preset-load/2`, `clap.track-info/1` and `clap.remote-controls/2`. A CLAP plugin judged +under it is probed again with those three offered, so its verdict holds for the host it runs in. + +The `jalv`, `mod-host` and `omx-clap-host` budgets are defaults, not a measurement of your board. State your own by writing a profile file. Every field is required; a missing or mistyped field is refused and never filled in with a default: diff --git a/native/Makefile b/native/Makefile index 35ebf74..b1fdfc1 100644 --- a/native/Makefile +++ b/native/Makefile @@ -6,6 +6,7 @@ # # make -C native build/clap-qualify, build/lv2_measure, build/lv2_host_probe, build/rt_interpose.so, # build/fixtures/{rt-safe,rt-unsafe,controls,params-2048,params-2049,info-fails,leaky-delay,undeclared-delay}.clap, +# build/fixtures/{synth,synth-alloc,synth-midi}.clap, # build/fixtures/fail-alloc.so, # build/fixtures/lv2/{rt-safe,rt-unsafe,host-faithfulness}.lv2 # build/lv2_measure_options_test the measurer's feature options oracle @@ -78,6 +79,7 @@ CFLAGS += -std=gnu11 -Wall -Wextra -Werror=implicit-function-declaration -Werror all: $(BUILD)/clap-qualify $(BUILD)/lv2_measure $(BUILD)/lv2_host_probe $(BUILD)/lv2_measure_options_test $(BUILD)/rt_interpose.so $(BUILD)/fixtures/rt-safe.clap $(BUILD)/fixtures/rt-unsafe.clap \ $(BUILD)/fixtures/controls.clap $(BUILD)/fixtures/params-2048.clap $(BUILD)/fixtures/params-2049.clap \ $(BUILD)/fixtures/info-fails.clap $(BUILD)/fixtures/leaky-delay.clap $(BUILD)/fixtures/undeclared-delay.clap \ + $(BUILD)/fixtures/synth.clap $(BUILD)/fixtures/synth-alloc.clap $(BUILD)/fixtures/synth-midi.clap \ $(BUILD)/fixtures/fail-alloc.so \ $(BUILD)/fixtures/lv2/rt-safe.lv2/gain.so $(BUILD)/fixtures/lv2/rt-unsafe.lv2/gain.so \ $(BUILD)/fixtures/lv2/host-faithfulness.lv2/host-faithfulness.so @@ -137,6 +139,13 @@ $(BUILD)/fixtures/leaky-delay.clap: fixtures/gain.c | $(BUILD)/fixtures $(BUILD)/fixtures/undeclared-delay.clap: fixtures/gain.c | $(BUILD)/fixtures $(CC) $(CPPFLAGS) $(CFLAGS) $(CLAP_CFLAGS) -DFIXTURE_DELAY=$(FIXTURE_DELAY) -DFIXTURE_UNDECLARED -shared -fPIC -fvisibility=hidden $(LDFLAGS) -o $@ $< +# the instrument the note sweep plays: clean, allocating on note on, and reading MIDI's dialect alone +$(BUILD)/fixtures/synth.clap: fixtures/synth.c | $(BUILD)/fixtures + $(CC) $(CPPFLAGS) $(CFLAGS) $(CLAP_CFLAGS) -shared -fPIC -fvisibility=hidden $(LDFLAGS) -o $@ $< -lm +$(BUILD)/fixtures/synth-alloc.clap: fixtures/synth.c | $(BUILD)/fixtures + $(CC) $(CPPFLAGS) $(CFLAGS) $(CLAP_CFLAGS) -DFIXTURE_ALLOCATES_ON_NOTE -shared -fPIC -fvisibility=hidden $(LDFLAGS) -o $@ $< -lm +$(BUILD)/fixtures/synth-midi.clap: fixtures/synth.c | $(BUILD)/fixtures + $(CC) $(CPPFLAGS) $(CFLAGS) $(CLAP_CFLAGS) -DFIXTURE_MIDI_ONLY -shared -fPIC -fvisibility=hidden $(LDFLAGS) -o $@ $< -lm # a preload failing one chosen allocation, for the prober's out-of-memory paths $(BUILD)/fixtures/fail-alloc.so: fixtures/fail-alloc.c | $(BUILD)/fixtures $(CC) $(CPPFLAGS) $(CFLAGS) -shared -fPIC -fvisibility=hidden $(LDFLAGS) -o $@ $< diff --git a/native/clap-qualify.c b/native/clap-qualify.c index 4886f97..7dc2c0c 100644 --- a/native/clap-qualify.c +++ b/native/clap-qualify.c @@ -5,7 +5,7 @@ * headless at every rate it is given and prints, per plugin, one JSON line of what it MEASURED. * It judges nothing: the verdict is plugin-qualify's (`src/clap-qualify.ts`), read from these facts. * - * clap-qualify --rates [--block ] [--blocks ] [] + * clap-qualify --rates [--block ] [--blocks ] [--offer ] [] * clap-qualify --json ... the scan (below), the document the CLAP loader reads * * Per rate the plugin is activated, its latency read, an impulse run through `process()` to see @@ -15,9 +15,16 @@ * nothing. The file's SHA-256 is printed beside the facts, so a verdict is bound to the bytes it * was measured on. * + * A plugin with a note input is then played, at every rate, under a count of its own (the note + * sweep): a chord, the pitch bent and a controller moved across a block, pressure, the chord let + * go, a burst of note ons in one block and their offs, all notes off, in the dialect its note + * port reads. An instrument is quiet and cheap until a voice plays, so what a note on does inside + * `process()` is only seen here. + * * The host offers exactly the extensions in HOST_EXTENSIONS and nothing else, so a plugin that - * needs more fails headless here rather than on the desk. A plugin calling a main-thread host - * function from inside `process()` is a thread violation. + * needs more fails headless here rather than on the desk; `--offer` adds those of OFFERABLE a + * host such as omx-clap-host offers too (their host functions answer nothing). A plugin calling a + * main-thread host function from inside `process()` is a thread violation. * * Exit status: 0 every plugin was measured, 2 usage, or the file or the plugin could not be loaded * (the line still prints with what was read). @@ -71,6 +78,23 @@ static const char *const HOST_EXTENSIONS[] = { }; #define HOST_EXTENSION_COUNT (sizeof HOST_EXTENSIONS / sizeof HOST_EXTENSIONS[0]) +/* what --offer may add, each by its id and the draft id a plugin may still ask for, in the order the document lists them */ +static const struct { const char *id, *compat; } OFFERABLE[] = { + { CLAP_EXT_PRESET_LOAD, CLAP_EXT_PRESET_LOAD_COMPAT }, + { CLAP_EXT_TRACK_INFO, CLAP_EXT_TRACK_INFO_COMPAT }, + { CLAP_EXT_REMOTE_CONTROLS, CLAP_EXT_REMOTE_CONTROLS_COMPAT }, +}; +#define OFFERABLE_COUNT (sizeof OFFERABLE / sizeof OFFERABLE[0]) +static int g_offered[OFFERABLE_COUNT]; + +/* the note sweep: a cycle of NOTE_CYCLE blocks (see note_block), a burst of NOTE_BURST note ons in one block */ +#define NOTE_CYCLE 8u +#define NOTE_BURST 32u +/* the chord the sweep holds, and the bend's reach in semitones (MIDI's default range) */ +static const int16_t CHORD[] = { 60, 64, 67 }; +#define CHORD_SIZE (sizeof CHORD / sizeof CHORD[0]) +#define BEND_SEMITONES 2.0 + static const char *const EVENT_KIND[] = { [OMX_RT_MALLOC] = "malloc", [OMX_RT_CALLOC] = "calloc", [OMX_RT_REALLOC] = "realloc", [OMX_RT_FREE] = "free", [OMX_RT_MUTEX_LOCK] = "pthread_mutex_lock", [OMX_RT_MUTEX_TRYLOCK] = "pthread_mutex_trylock", @@ -136,6 +160,27 @@ static const clap_host_audio_ports_t g_audio_ports = { host_ports_rescan_support static void host_state_mark_dirty(const clap_host_t *h) { (void)h; main_thread_only(); } static const clap_host_state_t g_state = { host_state_mark_dirty }; +/* the offerable extensions: main-thread host functions that answer nothing */ +static void host_preset_error(const clap_host_t *h, uint32_t kind, const char *location, const char *key, int32_t error, + const char *msg) { + (void)h; (void)kind; (void)location; (void)key; (void)error; (void)msg; + main_thread_only(); +} +static void host_preset_loaded(const clap_host_t *h, uint32_t kind, const char *location, const char *key) { + (void)h; (void)kind; (void)location; (void)key; + main_thread_only(); +} +static const clap_host_preset_load_t g_preset_load = { host_preset_error, host_preset_loaded }; + +static bool host_track_info_get(const clap_host_t *h, clap_track_info_t *info) { (void)h; (void)info; main_thread_only(); return false; } +static const clap_host_track_info_t g_track_info = { host_track_info_get }; + +static void host_remote_changed(const clap_host_t *h) { (void)h; main_thread_only(); } +static void host_remote_suggest(const clap_host_t *h, clap_id page) { (void)h; (void)page; main_thread_only(); } +static const clap_host_remote_controls_t g_remote_controls = { host_remote_changed, host_remote_suggest }; + +static const void *const OFFERED_OBJECT[OFFERABLE_COUNT] = { &g_preset_load, &g_track_info, &g_remote_controls }; + static const void *host_get_extension(const clap_host_t *h, const char *id) { (void)h; if (!id) return NULL; @@ -145,6 +190,8 @@ static const void *host_get_extension(const clap_host_t *h, const char *id) { if (!strcmp(id, CLAP_EXT_PARAMS)) return &g_params; if (!strcmp(id, CLAP_EXT_AUDIO_PORTS)) return &g_audio_ports; if (!strcmp(id, CLAP_EXT_STATE)) return &g_state; + for (size_t k = 0; k < OFFERABLE_COUNT; k++) + if (g_offered[k] && (!strcmp(id, OFFERABLE[k].id) || !strcmp(id, OFFERABLE[k].compat))) return OFFERED_OBJECT[k]; return NULL; } static void host_request(const clap_host_t *h) { (void)h; } @@ -164,8 +211,16 @@ static const clap_host_t g_clap_host = { /* ---- the events a sweep block carries ---- */ +union any_event { + clap_event_header_t header; + clap_event_param_value_t param; + clap_event_note_t note; + clap_event_note_expression_t expression; + clap_event_midi_t midi; +}; + static struct { - clap_event_param_value_t ev[EVENTS_PER_BLOCK]; + union any_event ev[EVENTS_PER_BLOCK]; uint32_t count; } g_in; @@ -201,6 +256,13 @@ struct facts { uint64_t allocations, locks, syscalls; struct omx_rt_event events[OMX_RT_EV_MAX]; int n_events; + /* the note sweep: the dialect it spoke (NULL: no note input, or none it speaks), and what it counted */ + const char *note_dialect; + uint32_t note_calls, note_events, note_errors; + int note_nonfinite; + uint64_t note_allocations, note_locks, note_syscalls; + struct omx_rt_event note_rt_events[OMX_RT_EV_MAX]; + int n_note_rt_events; }; static int ports_open(const clap_plugin_t *p, const clap_plugin_audio_ports_t *ext, bool input, uint32_t frames, @@ -355,7 +417,7 @@ static void measure_rate(const clap_plugin_t *p, double rate, uint32_t frames, u for (uint32_t b = 0; b < blocks; b++) { for (uint32_t k = 0; k < n_rows && g_in.count < EVENTS_PER_BLOCK; k++) { const double frac = (double)((b * 7u + k * 13u) % 101u) / 100.0; - clap_event_param_value_t *e = &g_in.ev[g_in.count++]; + clap_event_param_value_t *e = &g_in.ev[g_in.count++].param; memset(e, 0, sizeof *e); e->header.size = sizeof *e; e->header.time = 0; @@ -385,6 +447,145 @@ static void measure_rate(const clap_plugin_t *p, double rate, uint32_t frames, u ports_close(&outs); } +/* ---- the note sweep ---- */ + +/* one event of a note block, at frame `time`; the caller keeps the block's events in time order */ +static union any_event *note_event(uint32_t time, uint16_t type, uint32_t size) { + if (g_in.count >= EVENTS_PER_BLOCK) return NULL; + union any_event *e = &g_in.ev[g_in.count++]; + memset(e, 0, sizeof *e); + e->header.size = size; + e->header.time = time; + e->header.space_id = CLAP_CORE_EVENT_SPACE_ID; + e->header.type = type; + return e; +} + +static void note_midi(uint32_t time, uint8_t status, uint8_t a, uint8_t b) { + union any_event *e = note_event(time, CLAP_EVENT_MIDI, sizeof(clap_event_midi_t)); + if (!e) return; + e->midi.port_index = 0; + e->midi.data[0] = status; + e->midi.data[1] = a; + e->midi.data[2] = b; +} + +/* a note on (velocity above 0) or off, key -1 for every key, in the dialect */ +static void note_key(int clap_dialect, uint32_t time, int on, int16_t key, double velocity) { + if (clap_dialect) { + union any_event *e = note_event(time, on ? CLAP_EVENT_NOTE_ON : CLAP_EVENT_NOTE_OFF, sizeof(clap_event_note_t)); + if (!e) return; + e->note.note_id = -1; + e->note.port_index = 0; + e->note.channel = key < 0 ? -1 : 0; + e->note.key = key; + e->note.velocity = velocity; + } else if (key < 0) { + note_midi(time, 0xB0, 123, 0); + } else { + note_midi(time, on ? 0x90 : 0x80, (uint8_t)key, (uint8_t)(velocity * 127.0 + 0.5)); + } +} + +/* a channel-wide expression: the pitch bent by `amount` of its reach (-1 to 1), or pressure (0 to 1) */ +static void note_expression(int clap_dialect, uint32_t time, int bend, double amount) { + if (clap_dialect) { + union any_event *e = note_event(time, CLAP_EVENT_NOTE_EXPRESSION, sizeof(clap_event_note_expression_t)); + if (!e) return; + e->expression.expression_id = bend ? CLAP_NOTE_EXPRESSION_TUNING : CLAP_NOTE_EXPRESSION_PRESSURE; + e->expression.note_id = -1; + e->expression.port_index = 0; + e->expression.channel = 0; + e->expression.key = -1; + e->expression.value = bend ? amount * BEND_SEMITONES : amount; + } else if (bend) { + const unsigned v = (unsigned)((amount + 1.0) * 8191.5); + note_midi(time, 0xE0, (uint8_t)(v & 0x7f), (uint8_t)(v >> 7 & 0x7f)); + } else { + note_midi(time, 0xD0, (uint8_t)(amount * 127.0 + 0.5), 0); + } +} + +/* the events of block b of the note sweep, by b's place in the cycle; a controller only where the port reads MIDI */ +static void note_block(uint32_t b, uint32_t frames, int clap_dialect, int midi_too) { + const uint32_t step = frames / 8u ? frames / 8u : 1u; + switch (b % NOTE_CYCLE) { + case 0: + for (uint32_t k = 0; k < CHORD_SIZE; k++) note_key(clap_dialect, 0, 1, CHORD[k], 0.8); + break; + case 1: + for (uint32_t t = 0; t < 8u; t++) { + note_expression(clap_dialect, t * step, 1, (double)t / 4.0 - 1.0); + if (!clap_dialect || midi_too) note_midi(t * step, 0xB0, 1, (uint8_t)(t * 16u)); + } + break; + case 2: + for (uint32_t t = 0; t < 8u; t++) note_expression(clap_dialect, t * step, 0, (double)t / 7.0); + break; + case 3: + for (uint32_t k = 0; k < CHORD_SIZE; k++) note_key(clap_dialect, 0, 0, CHORD[k], 0.0); + break; + case 4: + case 5: + for (uint32_t k = 0; k < NOTE_BURST; k++) + note_key(clap_dialect, k * frames / NOTE_BURST, b % NOTE_CYCLE == 4, (int16_t)(36 + k), 0.25 + 0.5 * (double)k / NOTE_BURST); + break; + case 6: + note_key(clap_dialect, 0, 1, 72, 1.0); + note_key(clap_dialect, frames / 2u, 0, -1, 0.0); + break; + default: + break; /* the release tails */ + } +} + +/* the dialect the note sweep speaks to the plugin's first note input: CLAP's when it reads it, else MIDI's; NULL for none */ +static const char *note_dialect(const clap_plugin_t *p, int *clap_dialect, int *midi_too) { + const clap_plugin_note_ports_t *notes = p->get_extension(p, CLAP_EXT_NOTE_PORTS); + clap_note_port_info_t info; + memset(&info, 0, sizeof info); + if (!notes || notes->count(p, true) == 0 || !notes->get(p, 0, true, &info)) return NULL; + *midi_too = (info.supported_dialects & CLAP_NOTE_DIALECT_MIDI) != 0; + if (info.supported_dialects & CLAP_NOTE_DIALECT_CLAP) return *clap_dialect = 1, "clap"; + if (*midi_too) return *clap_dialect = 0, "midi"; + return NULL; +} + +static void note_sweep_rate(const clap_plugin_t *p, double rate, uint32_t frames, uint32_t blocks, int clap_dialect, int midi_too, + const struct interposer *ip, struct facts *f) { + const clap_plugin_audio_ports_t *ports = p->get_extension(p, CLAP_EXT_AUDIO_PORTS); + struct port_set ins, outs; + uint32_t main_in, main_out, others; + if (ports_open(p, ports, true, frames, &ins, &main_in, &others) != 0 || ports_open(p, ports, false, frames, &outs, &main_out, &others) != 0 || + !p->activate(p, rate, frames, frames)) { + f->headless_ok = 0; + ports_close(&ins); + ports_close(&outs); + return; + } + if (p->start_processing && !p->start_processing(p)) { + f->headless_ok = 0; + p->deactivate(p); + ports_close(&ins); + ports_close(&outs); + return; + } + if (p->reset) p->reset(p); + int64_t steady = 0; + ports_fill(&ins, frames, 0.0f); + for (uint32_t b = 0; b < blocks; b++) { + note_block(b, frames, clap_dialect, midi_too); + f->note_events += g_in.count; + if (run_block(p, &ins, &outs, frames, &steady, ip) == CLAP_PROCESS_ERROR) f->note_errors++; + if (!ports_finite(&outs, frames)) f->note_nonfinite = 1; + f->note_calls++; + } + if (p->stop_processing) p->stop_processing(p); + p->deactivate(p); + ports_close(&ins); + ports_close(&outs); +} + /* ---- JSON ---- */ /* where the JSON goes: stdout, or a scan child's pipe */ @@ -452,7 +653,35 @@ static void put_head(const char *id, const char *path, const char *sha256) { fprintf(g_out, ",\"sha256\":"); put_string(sha256); fprintf(g_out, "},\"hostExtensions\":"); - put_strings(HOST_EXTENSIONS, HOST_EXTENSION_COUNT); + const char *offered[HOST_EXTENSION_COUNT + OFFERABLE_COUNT + 1]; + size_t n = 0; + for (size_t k = 0; k < HOST_EXTENSION_COUNT; k++) offered[n++] = HOST_EXTENSIONS[k]; + for (size_t k = 0; k < OFFERABLE_COUNT; k++) + if (g_offered[k]) offered[n++] = OFFERABLE[k].id; + offered[n] = NULL; + put_strings(offered, n); +} + +static const char *event_kind(int k) { + return k > 0 && k < (int)(sizeof EVENT_KIND / sizeof EVENT_KIND[0]) ? EVENT_KIND[k] : "unknown"; +} + +/* the note sweep's block: not applicable without a note input it can speak to; its counts only under the interposer */ +static void put_note_sweep(const struct facts *f, uint32_t frames) { + if (!f->note_dialect) { + fprintf(g_out, ",\"noteSweep\":{\"applicable\":false}"); + return; + } + fprintf(g_out, ",\"noteSweep\":{\"applicable\":true,\"dialect\":\"%s\",\"blocks\":%u,\"blockFrames\":%u,\"noteEvents\":%u," + "\"processErrors\":%u,\"nonFinite\":%s,\"interposer\":%s", f->note_dialect, f->note_calls, frames, f->note_events, + f->note_errors, f->note_nonfinite ? "true" : "false", f->interposer ? "true" : "false"); + if (f->interposer) { + fprintf(g_out, ",\"allocationsInRun\":%llu,\"locksInRun\":%llu,\"syscallsInRun\":%llu,\"events\":[", + (unsigned long long)f->note_allocations, (unsigned long long)f->note_locks, (unsigned long long)f->note_syscalls); + for (int i = 0; i < f->n_note_rt_events; i++) fprintf(g_out, "%s\"%s\"", i ? "," : "", event_kind(f->note_rt_events[i].kind)); + fputc_out(']'); + } + fputc_out('}'); } static void put_facts(const clap_plugin_descriptor_t *d, const struct facts *f, uint32_t frames) { @@ -468,6 +697,7 @@ static void put_facts(const clap_plugin_descriptor_t *d, const struct facts *f, f->process_errors, f->process_calls, f->nonfinite ? "true" : "false"); if (!f->interposer) { fprintf(g_out, ",\"rtSafety\":{\"interposer\":false}"); + put_note_sweep(f, frames); return; } fprintf(g_out, ",\"rtSafety\":{\"interposer\":true,\"swept\":%s,\"sweepApplicable\":%s,\"blocks\":%u,\"blockFrames\":%u," @@ -479,6 +709,7 @@ static void put_facts(const clap_plugin_descriptor_t *d, const struct facts *f, fprintf(g_out, "%s\"%s\"", i ? "," : "", k > 0 && k < (int)(sizeof EVENT_KIND / sizeof EVENT_KIND[0]) ? EVENT_KIND[k] : "unknown"); } fprintf(g_out, "]}"); + put_note_sweep(f, frames); } /* ---- the pin (plugin-qualify spec §11) ---- */ @@ -957,6 +1188,17 @@ static int qualify_plugin(const clap_plugin_factory_t *factory, const clap_plugi ip.counts(&f.allocations, &f.locks, &f.syscalls); f.n_events = ip.events(f.events, OMX_RT_EV_MAX); } + int clap_dialect = 0, midi_too = 0; + f.note_dialect = f.headless_ok ? note_dialect(p, &clap_dialect, &midi_too) : NULL; + if (f.note_dialect) { + if (f.interposer) ip.reset(); + for (uint32_t r = 0; r < o->n_rates && f.headless_ok; r++) + note_sweep_rate(p, o->rates[r], o->frames, o->blocks, clap_dialect, midi_too, f.interposer ? &ip : NULL, &f); + if (f.interposer) { + ip.counts(&f.note_allocations, &f.note_locks, &f.note_syscalls); + f.n_note_rt_events = ip.events(f.note_rt_events, OMX_RT_EV_MAX); + } + } p->destroy(p); put_head(d->id, path, sha256); put_facts(d, &f, o->frames); @@ -988,9 +1230,23 @@ static int parse_rates(const char *s, struct options *o) { return o->n_rates ? 0 : -1; } +/* --offer: a comma list of OFFERABLE ids; any other word is a usage error */ +static int parse_offer(const char *s) { + char *copy = strdup(s), *save = NULL; + int rc = copy ? 0 : -1; + for (char *tok = copy ? strtok_r(copy, ",", &save) : NULL; tok && rc == 0; tok = strtok_r(NULL, ",", &save)) { + size_t k = 0; + while (k < OFFERABLE_COUNT && strcmp(tok, OFFERABLE[k].id)) k++; + if (k == OFFERABLE_COUNT) rc = -1; + else g_offered[k] = 1; + } + free(copy); + return rc; +} + static int usage(const char *name) { fprintf(stderr, - "usage: %s --rates [--block ] [--blocks ] []\n" + "usage: %s --rates [--block ] [--blocks ] [--offer ] []\n" " %s --json ... (the scan: a .clap file, or a directory searched for them)\n", name, name); return 2; @@ -1009,6 +1265,8 @@ int main(int argc, char **argv) { if (parse_u32(argv[a + 1], &o.frames) != 0) return usage(argv[0]); } else if (!strcmp(argv[a], "--blocks")) { if (parse_u32(argv[a + 1], &o.blocks) != 0) return usage(argv[0]); + } else if (!strcmp(argv[a], "--offer")) { + if (parse_offer(argv[a + 1]) != 0) return usage(argv[0]); } else { return usage(argv[0]); } diff --git a/native/fixtures/synth.c b/native/fixtures/synth.c new file mode 100644 index 0000000..53d80c9 --- /dev/null +++ b/native/fixtures/synth.c @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Pau Aliagas +/* + * The qualifier's fixture instrument: no audio input, a stereo output, one note input and one + * gain parameter. Each key plays a sine from a fixed bank of voices; a note off, or a note off on + * every key, lets it go. Built three times: + * + * synth.clap reads CLAP's note dialect (and MIDI's): the known RT-safe instrument. + * synth-alloc.clap built with -DFIXTURE_ALLOCATES_ON_NOTE, the same, which also allocates a + * scratch buffer on every note on and frees it: an instrument that is quiet + * and clean until it is played, the known RT-unsafe one. + * synth-midi.clap built with -DFIXTURE_MIDI_ONLY, the safe one reading MIDI's dialect alone. + * + * Nothing else differs, so a verdict that tells them apart is what the note sweep counted. + */ +#include +#include +#include + +#include + +#if defined FIXTURE_ALLOCATES_ON_NOTE +#define FIXTURE_ID "org.freemixer.plugin-qualify.fixture.synth-alloc" +#define FIXTURE_NAME "synth that allocates on note on" +/* through a volatile pointer, so the compiler cannot pair the malloc with the free and drop both */ +static void *(*volatile fixture_malloc)(size_t) = malloc; +static void (*volatile fixture_free)(void *) = free; +#elif defined FIXTURE_MIDI_ONLY +#define FIXTURE_ID "org.freemixer.plugin-qualify.fixture.synth-midi" +#define FIXTURE_NAME "synth reading MIDI" +#else +#define FIXTURE_ID "org.freemixer.plugin-qualify.fixture.synth" +#define FIXTURE_NAME "synth" +#endif + +#define VOICES 16 +#define GAIN_ID 0u + +static const char *const features[] = { CLAP_PLUGIN_FEATURE_INSTRUMENT, CLAP_PLUGIN_FEATURE_STEREO, NULL }; + +static const clap_plugin_descriptor_t descriptor = { + .clap_version = CLAP_VERSION_INIT, + .id = FIXTURE_ID, + .name = FIXTURE_NAME, + .vendor = "plugin-qualify", + .url = "", + .manual_url = "", + .support_url = "", + .version = "1", + .description = "plugin-qualify fixture", + .features = features, +}; + +struct voice { int key; double phase, step; }; + +struct synth { + clap_plugin_t plugin; + double rate, gain; + struct voice voice[VOICES]; +}; + +static bool synth_init(const clap_plugin_t *p) { (void)p; return true; } +static void synth_destroy(const clap_plugin_t *p) { free(p->plugin_data); } +static bool synth_activate(const clap_plugin_t *p, double rate, uint32_t min, uint32_t max) { + (void)min; (void)max; + ((struct synth *)p->plugin_data)->rate = rate; + return true; +} +static void synth_deactivate(const clap_plugin_t *p) { (void)p; } +static bool synth_start(const clap_plugin_t *p) { (void)p; return true; } +static void synth_stop(const clap_plugin_t *p) { (void)p; } +static void synth_reset(const clap_plugin_t *p) { + struct synth *s = p->plugin_data; + for (int v = 0; v < VOICES; v++) s->voice[v].key = -1; +} + +static void note_on(struct synth *s, int key) { +#ifdef FIXTURE_ALLOCATES_ON_NOTE + double *scratch = fixture_malloc(256 * sizeof(double)); + if (scratch) { + scratch[0] = (double)key; + fixture_free(scratch); + } +#endif + for (int v = 0; v < VOICES; v++) + if (s->voice[v].key < 0) { + s->voice[v].key = key; + s->voice[v].phase = 0.0; + s->voice[v].step = 2.0 * M_PI * 440.0 * pow(2.0, (key - 69) / 12.0) / s->rate; + return; + } +} + +static void note_off(struct synth *s, int key) { + for (int v = 0; v < VOICES; v++) + if (key < 0 || s->voice[v].key == key) s->voice[v].key = -1; +} + +static void synth_event(struct synth *s, const clap_event_header_t *e) { + if (e->space_id != CLAP_CORE_EVENT_SPACE_ID) return; + switch (e->type) { + case CLAP_EVENT_PARAM_VALUE: { + const clap_event_param_value_t *v = (const clap_event_param_value_t *)e; + if (v->param_id == GAIN_ID) s->gain = v->value; + break; + } +#ifndef FIXTURE_MIDI_ONLY + case CLAP_EVENT_NOTE_ON: + note_on(s, ((const clap_event_note_t *)e)->key); + break; + case CLAP_EVENT_NOTE_OFF: + note_off(s, ((const clap_event_note_t *)e)->key); + break; +#endif + case CLAP_EVENT_MIDI: { + const uint8_t *m = ((const clap_event_midi_t *)e)->data; + if ((m[0] & 0xf0) == 0x90 && m[2] > 0) note_on(s, m[1]); + else if ((m[0] & 0xf0) == 0x80 || (m[0] & 0xf0) == 0x90) note_off(s, m[1]); + else if ((m[0] & 0xf0) == 0xb0 && m[1] == 123) note_off(s, -1); + break; + } + default: + break; + } +} + +static clap_process_status synth_process(const clap_plugin_t *p, const clap_process_t *proc) { + struct synth *s = p->plugin_data; + const uint32_t n = proc->in_events->size(proc->in_events); + uint32_t next = 0; + if (proc->audio_outputs_count < 1) return CLAP_PROCESS_ERROR; + const clap_audio_buffer_t *out = &proc->audio_outputs[0]; + for (uint32_t i = 0; i < proc->frames_count; i++) { + while (next < n) { + const clap_event_header_t *e = proc->in_events->get(proc->in_events, next); + if (e->time > i) break; + synth_event(s, e); + next++; + } + double y = 0.0; + for (int v = 0; v < VOICES; v++) + if (s->voice[v].key >= 0) { + y += sin(s->voice[v].phase) * 0.1; + s->voice[v].phase += s->voice[v].step; + } + for (uint32_t c = 0; c < out->channel_count; c++) out->data32[c][i] = (float)(y * s->gain); + } + for (; next < n; next++) synth_event(s, proc->in_events->get(proc->in_events, next)); + return CLAP_PROCESS_CONTINUE; +} + +static uint32_t ports_count(const clap_plugin_t *p, bool is_input) { (void)p; return is_input ? 0 : 1; } +static bool ports_get(const clap_plugin_t *p, uint32_t index, bool is_input, clap_audio_port_info_t *info) { + (void)p; + if (index != 0 || is_input) return false; + memset(info, 0, sizeof *info); + strcpy(info->name, "out"); + info->flags = CLAP_AUDIO_PORT_IS_MAIN; + info->channel_count = 2; + info->port_type = CLAP_PORT_STEREO; + info->in_place_pair = CLAP_INVALID_ID; + return true; +} +static const clap_plugin_audio_ports_t audio_ports = { ports_count, ports_get }; + +static uint32_t notes_count(const clap_plugin_t *p, bool is_input) { (void)p; return is_input ? 1 : 0; } +static bool notes_get(const clap_plugin_t *p, uint32_t index, bool is_input, clap_note_port_info_t *info) { + (void)p; + if (index != 0 || !is_input) return false; + memset(info, 0, sizeof *info); + strcpy(info->name, "notes"); +#ifdef FIXTURE_MIDI_ONLY + info->supported_dialects = CLAP_NOTE_DIALECT_MIDI; + info->preferred_dialect = CLAP_NOTE_DIALECT_MIDI; +#else + info->supported_dialects = CLAP_NOTE_DIALECT_CLAP | CLAP_NOTE_DIALECT_MIDI; + info->preferred_dialect = CLAP_NOTE_DIALECT_CLAP; +#endif + return true; +} +static const clap_plugin_note_ports_t note_ports = { notes_count, notes_get }; + +static uint32_t params_count(const clap_plugin_t *p) { (void)p; return 1; } +static bool params_info(const clap_plugin_t *p, uint32_t index, clap_param_info_t *info) { + (void)p; + if (index != 0) return false; + memset(info, 0, sizeof *info); + info->id = GAIN_ID; + info->flags = CLAP_PARAM_IS_AUTOMATABLE; + strcpy(info->name, "gain"); + info->max_value = 2.0; + info->default_value = 1.0; + return true; +} +static bool params_value(const clap_plugin_t *p, clap_id id, double *value) { + if (id != GAIN_ID) return false; + *value = ((struct synth *)p->plugin_data)->gain; + return true; +} +static bool params_to_text(const clap_plugin_t *p, clap_id id, double value, char *out, uint32_t size) { + (void)p; (void)id; (void)value; (void)out; (void)size; + return false; +} +static bool params_from_text(const clap_plugin_t *p, clap_id id, const char *text, double *value) { + (void)p; (void)id; (void)text; (void)value; + return false; +} +static void params_flush(const clap_plugin_t *p, const clap_input_events_t *in, const clap_output_events_t *out) { + (void)out; + const uint32_t n = in->size(in); + for (uint32_t i = 0; i < n; i++) synth_event(p->plugin_data, in->get(in, i)); +} +static const clap_plugin_params_t params = { params_count, params_info, params_value, params_to_text, params_from_text, params_flush }; + +static const void *synth_extension(const clap_plugin_t *p, const char *id) { + (void)p; + if (!strcmp(id, CLAP_EXT_AUDIO_PORTS)) return &audio_ports; + if (!strcmp(id, CLAP_EXT_NOTE_PORTS)) return ¬e_ports; + if (!strcmp(id, CLAP_EXT_PARAMS)) return ¶ms; + return NULL; +} +static void synth_main_thread(const clap_plugin_t *p) { (void)p; } + +static uint32_t factory_count(const clap_plugin_factory_t *f) { (void)f; return 1; } +static const clap_plugin_descriptor_t *factory_descriptor(const clap_plugin_factory_t *f, uint32_t index) { + (void)f; + return index == 0 ? &descriptor : NULL; +} +static const clap_plugin_t *factory_create(const clap_plugin_factory_t *f, const clap_host_t *host, const char *id) { + (void)f; (void)host; + if (strcmp(id, descriptor.id)) return NULL; + struct synth *s = calloc(1, sizeof *s); + if (!s) return NULL; + s->gain = 1.0; + s->rate = 48000.0; + for (int v = 0; v < VOICES; v++) s->voice[v].key = -1; + s->plugin = (clap_plugin_t){ + .desc = &descriptor, + .plugin_data = s, + .init = synth_init, + .destroy = synth_destroy, + .activate = synth_activate, + .deactivate = synth_deactivate, + .start_processing = synth_start, + .stop_processing = synth_stop, + .reset = synth_reset, + .process = synth_process, + .get_extension = synth_extension, + .on_main_thread = synth_main_thread, + }; + return &s->plugin; +} +static const clap_plugin_factory_t factory = { factory_count, factory_descriptor, factory_create }; + +static bool entry_init(const char *path) { (void)path; return true; } +static void entry_deinit(void) {} +static const void *entry_factory(const char *id) { return strcmp(id, CLAP_PLUGIN_FACTORY_ID) ? NULL : &factory; } + +CLAP_EXPORT const clap_plugin_entry_t clap_entry = { CLAP_VERSION_INIT, entry_init, entry_deinit, entry_factory }; diff --git a/packaging/plugin-qualify.1 b/packaging/plugin-qualify.1 index 2eddc4f..6a8bb18 100644 --- a/packaging/plugin-qualify.1 +++ b/packaging/plugin-qualify.1 @@ -38,7 +38,7 @@ Measure each plugin in a fresh worker, so a crash or hang takes only that plugin Offline measurement only, without mod-host. .TP .BI \-\-host\-profile " file|id" -Judge against this host: openmixer-console (default), jalv, mod-host, or a profile JSON file. +Judge against this host: openmixer-console (default), jalv, mod-host, omx-clap-host, or a profile JSON file. Repeat or comma-separate for one verdict per profile. .TP .B \-\-help diff --git a/src/clap-qualify.test.ts b/src/clap-qualify.test.ts index 7345d2d..1b4880b 100644 --- a/src/clap-qualify.test.ts +++ b/src/clap-qualify.test.ts @@ -19,6 +19,7 @@ import { ClapQualifyError, RT_SAFETY_DOCUMENT_SCHEMA, clapQualifyTools, + proberOffer, parseClapQualifyLine, parseClapQualifyOutput, qualifyClap, @@ -29,7 +30,13 @@ import { type ClapQualifyRun, } from "./clap-qualify.js"; import { HOSTING_CODE } from "./hosting-codes.js"; -import { OMX_CLAP_HOST_EXTENSIONS, OPENMIXER_CONSOLE_PROFILE, shippedHostProfile } from "./host-profiles.js"; +import { + OMX_CLAP_HOST_CONFIGURED_EXTENSIONS, + OMX_CLAP_HOST_EXTENSIONS, + OMX_CLAP_HOST_PROFILE, + OPENMIXER_CONSOLE_PROFILE, + shippedHostProfile, +} from "./host-profiles.js"; import { CLAP_QUALIFY_TOOL_ENV, CLAP_QUALIFY_TOOL_NAME, @@ -47,9 +54,12 @@ const FIXTURES = join(PKG, "native", "build", "fixtures"); const SAFE = join(FIXTURES, "rt-safe.clap"); const UNSAFE = join(FIXTURES, "rt-unsafe.clap"); const CONTROLS = join(FIXTURES, "controls.clap"); +const SYNTH = join(FIXTURES, "synth.clap"); +const SYNTH_ALLOC = join(FIXTURES, "synth-alloc.clap"); +const SYNTH_MIDI = join(FIXTURES, "synth-midi.clap"); const SAFE_ID = "org.freemixer.plugin-qualify.fixture.rt-safe"; const UNSAFE_ID = "org.freemixer.plugin-qualify.fixture.rt-unsafe"; -const BUILT = [clapQualifyToolPath(), rtInterposePath(), SAFE, UNSAFE].every((p) => existsSync(p)); +const BUILT = [clapQualifyToolPath(), rtInterposePath(), SAFE, UNSAFE, SYNTH, SYNTH_ALLOC, SYNTH_MIDI].every((p) => existsSync(p)); const scratch = mkdtempSync(join(tmpdir(), "clap-qualify-")); afterAll(() => rmSync(scratch, { recursive: true, force: true })); @@ -68,7 +78,9 @@ function live(name: string, body: () => void): void { const sha256 = (path: string): string => createHash("sha256").update(readFileSync(path)).digest("hex"); /** A prober line as the tool prints it, with `patch` merged over the facts. */ -function line(patch: { rtSafety?: object; clap?: object; hostExtensions?: string[]; integrity?: object | null } = {}): string { +function line( + patch: { rtSafety?: object; clap?: object; hostExtensions?: string[]; integrity?: object | null; noteSweep?: object } = {}, +): string { return JSON.stringify({ uri: `urn:clap:${SAFE_ID}`, format: "clap", @@ -101,6 +113,7 @@ function line(patch: { rtSafety?: object; clap?: object; hostExtensions?: string events: [], ...patch.rtSafety, }, + noteSweep: patch.noteSweep ?? { applicable: false }, ...(patch.integrity === null ? {} : { @@ -260,6 +273,70 @@ describe("clap-qualify, live — the known RT-safe and RT-unsafe fixture plugins }); }); +describe("clap-qualify, live — an instrument is played: the note sweep", () => { + live("a clean synth is suitable: the parameter sweep and the note sweep both count nothing", () => { + const [l] = qualifyClap(SYNTH, undefined, { rates: QUALIFY_RATES }); + expect(l!.clap.noteInputs).toBe(1); + expect(l!.noteSweep).toMatchObject({ applicable: true, dialect: "clap", interposer: true, allocationsInRun: 0, locksInRun: 0, syscallsInRun: 0 }); + expect(l!.noteSweep.blocks).toBe(CLAP_SWEEP_BLOCKS * QUALIFY_RATES.length); + expect(l!.noteSweep.noteEvents).toBeGreaterThan(l!.noteSweep.blocks!); + const doc = rtSafetyDocument(l!); + expect(doc.verdict).toMatchObject({ rating: "suitable", rtSafe: true }); + expect(doc.verdict.reasons.map((r) => r.code)).toEqual([HOSTING_CODE.rtSafetyClean, HOSTING_CODE.rtSafetyNotesClean]); + expect(doc.measured.noteSweep).toEqual(l!.noteSweep); + }); + + live("a synth that allocates on note on is clean at rest and unsuitable once played", () => { + const [l] = qualifyClap(SYNTH_ALLOC, undefined, { rates: QUALIFY_RATES }); + // the parameter sweep plays no note: it sees nothing + expect(l!.rtSafety).toMatchObject({ allocationsInRun: 0, locksInRun: 0, syscallsInRun: 0 }); + expect(l!.noteSweep.allocationsInRun).toBeGreaterThan(0); + expect(l!.noteSweep.events).toContain("malloc"); + const doc = rtSafetyDocument(l!); + expect(doc.verdict).toMatchObject({ rating: "unsuitable", rtSafe: false }); + expect(doc.verdict.deciding?.code).toBe(HOSTING_CODE.rtSafetyNotesViolations); + }); + + live("a synth that reads only MIDI is played in MIDI's dialect", () => { + const [l] = qualifyClap(SYNTH_MIDI, undefined, { rates: [QUALIFY_RATES[0]!] }); + expect(l!.noteSweep).toMatchObject({ applicable: true, dialect: "midi", allocationsInRun: 0 }); + expect(rtSafetyDocument(l!).verdict.rating).toBe("suitable"); + }); + + live("an effect is not played: its note sweep is not applicable and adds no reason", () => { + const [l] = qualifyClap(SAFE, SAFE_ID, { rates: [QUALIFY_RATES[0]!] }); + expect(l!.noteSweep).toEqual({ applicable: false }); + expect(rtSafetyDocument(l!).verdict.reasons.map((r) => r.code)).toEqual([HOSTING_CODE.rtSafetyClean]); + }); + + live("without the interposer the note sweep counts nothing, and an instrument's verdict is unknown", () => { + const r = spawnSync(clapQualifyToolPath(), ["--rates", String(QUALIFY_RATES[0]), "--blocks", "16", SYNTH_ALLOC], { encoding: "utf8" }); + expect(r.status).toBe(0); + const [l] = parseClapQualifyOutput(r.stdout); + expect(l!.noteSweep).toMatchObject({ applicable: true, interposer: false }); + expect(l!.noteSweep.allocationsInRun).toBeUndefined(); + expect(rtSafetyDocument(l!).verdict.rating).toBe("unknown"); + }); + + live("--offer adds omx-clap-host's three extensions, and the synth is suitable under its profile", () => { + const offer = proberOffer(OMX_CLAP_HOST_PROFILE)!; + expect(offer).toEqual(OMX_CLAP_HOST_CONFIGURED_EXTENSIONS); + const [l] = qualifyClap(SYNTH, undefined, { rates: [QUALIFY_RATES[0]!], offer }); + expect(l!.hostExtensions).toEqual([...OMX_CLAP_HOST_EXTENSIONS, ...OMX_CLAP_HOST_CONFIGURED_EXTENSIONS]); + expect(rtSafetyDocument(l!, OMX_CLAP_HOST_PROFILE).verdict.rating).toBe("suitable"); + // the console's run is not omx-clap-host's measurement, nor the other way round + const [base] = qualifyClap(SYNTH, undefined, { rates: [QUALIFY_RATES[0]!] }); + expect(rtSafetyDocument(base!, OMX_CLAP_HOST_PROFILE).verdict.deciding?.code).toBe(HOSTING_CODE.clapExtensionsNotCovered); + expect(rtSafetyDocument(l!, OPENMIXER_CONSOLE_PROFILE).verdict.deciding?.code).toBe(HOSTING_CODE.clapExtensionsNotCovered); + }); + + live("an extension the prober cannot offer is a usage error, not an offer", () => { + const r = spawnSync(clapQualifyToolPath(), ["--rates", "48000", "--offer", "clap.gui", SYNTH], { encoding: "utf8" }); + expect(r.status).toBe(2); + expect(r.stdout).toBe(""); + }); +}); + describe("plugin-qualify --all, live — the standard CLAP search path", () => { live("a .clap only on CLAP_PATH is found by --all and qualified, with ~/.clap empty", () => { const onPath = join(scratch, "clap-path"); @@ -368,6 +445,78 @@ describe("rtSafetyDocument — the rtSafety dimension's verdict, and nothing els }); }); +describe("the note sweep in the line and the document", () => { + const played = { applicable: true, dialect: "clap", blocks: 512, blockFrames: 512, noteEvents: 6000, processErrors: 0, nonFinite: false, + interposer: true, allocationsInRun: 0, locksInRun: 0, syscallsInRun: 0, events: [] }; + const instrument = { noteInputs: 1, mainInputChannels: 0, features: ["instrument"] }; + + it("reads a played sweep, and refuses one that is not the prober's", () => { + expect(parseClapQualifyLine(line({ noteSweep: played, clap: instrument })).noteSweep).toEqual(played); + expect(() => parseClapQualifyLine(line({ noteSweep: { ...played, dialect: "midi2" } }))).toThrow(/dialect/); + expect(() => parseClapQualifyLine(line({ noteSweep: { ...played, allocationsInRun: -1 } }))).toThrow(/allocationsInRun/); + expect(() => parseClapQualifyLine(JSON.stringify({ ...JSON.parse(line()), noteSweep: undefined }))).toThrow(/noteSweep/); + }); + + it("an allocation, a lock or a syscall while played is unsuitable, under its own code", () => { + for (const patch of [{ allocationsInRun: 2 }, { locksInRun: 1 }, { syscallsInRun: 1 }]) { + const doc = rtSafetyDocument(parseClapQualifyLine(line({ noteSweep: { ...played, ...patch }, clap: instrument }))); + expect(doc.verdict.rating).toBe("unsuitable"); + expect(doc.verdict.deciding?.code).toBe(HOSTING_CODE.rtSafetyNotesViolations); + } + const errors = rtSafetyDocument(parseClapQualifyLine(line({ noteSweep: { ...played, processErrors: 1 }, clap: instrument }))); + expect(errors.verdict.deciding?.code).toBe(HOSTING_CODE.clapProcessError); + }); + + it("an instrument the prober could not play, or played with no interposer, is unknown, never clean", () => { + for (const sweep of [{ applicable: false }, { ...played, interposer: false, allocationsInRun: undefined, locksInRun: undefined, syscallsInRun: undefined, events: undefined }]) { + const doc = rtSafetyDocument(parseClapQualifyLine(line({ noteSweep: sweep, clap: instrument }))); + expect(doc.verdict.rating).toBe("unknown"); + expect(doc.verdict.deciding?.code).toBe(HOSTING_CODE.rtSafetyNotesUnmeasured); + } + }); + + it("a plugin with no note input gets no note reason: its verdict is the one /1 gave", () => { + const doc = rtSafetyDocument(parseClapQualifyLine(line())); + expect(doc.schema).toBe("plugin-qualify.rt-safety/2"); + expect(doc.verdict.reasons.map((r) => r.code)).toEqual([HOSTING_CODE.rtSafetyClean]); + expect(doc.measured.noteSweep).toEqual({ applicable: false }); + }); +}); + +describe("proberOffer — the run that stands in for a profile", () => { + it("the console needs no offer, omx-clap-host its three, and a host the prober cannot stand in for none", () => { + expect(proberOffer(OPENMIXER_CONSOLE_PROFILE)).toEqual([]); + expect(proberOffer(OMX_CLAP_HOST_PROFILE)).toEqual(OMX_CLAP_HOST_CONFIGURED_EXTENSIONS); + expect(proberOffer(shippedHostProfile("jalv")!)).toBeUndefined(); + expect(proberOffer({ ...OPENMIXER_CONSOLE_PROFILE, clapExtensions: [...OMX_CLAP_HOST_EXTENSIONS, "clap.gui"] })).toBeUndefined(); + expect(proberOffer({ ...OPENMIXER_CONSOLE_PROFILE, clapExtensions: ["clap.log"] })).toBeUndefined(); + }); + + it("qualifyClapTargets runs the prober once per offer, and judges each profile from its own run", () => { + const seen: (readonly string[])[] = []; + const run: ClapQualifyRun = (_tool, args) => { + seen.push(args); + const at = args.indexOf("--offer"); + const offered = at < 0 ? [] : args[at + 1]!.split(","); + return { status: 0, signal: null, stdout: line({ hostExtensions: [...OMX_CLAP_HOST_EXTENSIONS, ...offered] }) }; + }; + const env = { [CLAP_QUALIFY_TOOL_ENV]: "/t/clap-qualify", [RT_INTERPOSE_ENV]: "/t/rt.so" }; + const pass = qualifyClapTargets([{ format: "clap", uri: `urn:clap:${SAFE_ID}`, binaryPath: "/p/x.clap" }], { + rates: [48000], + run, + env, + exists: () => true, + profiles: [OPENMIXER_CONSOLE_PROFILE, OMX_CLAP_HOST_PROFILE, shippedHostProfile("jalv")!], + }); + expect(seen.map((a) => a.includes("--offer"))).toEqual([false, true]); + expect(pass.verdicts.map((v) => [v.host, v.rating])).toEqual([ + ["openmixer-console", "suitable"], + ["omx-clap-host", "suitable"], + ["jalv", "unknown"], + ]); + }); +}); + describe("qualifyClap — the run, fail closed", () => { const answering = (stdout: string, extra: { status?: number; signal?: string } = {}): { run: ClapQualifyRun; calls: unknown[] } => { const calls: unknown[] = []; diff --git a/src/clap-qualify.ts b/src/clap-qualify.ts index dda4498..aee3a4f 100644 --- a/src/clap-qualify.ts +++ b/src/clap-qualify.ts @@ -24,12 +24,19 @@ import { HOSTING_CODE, clapRtReasons, contributedHostingRating, + noteSweepReasons, rtSafetyReasons, type HostingReason, type PluginClapMeasurement, + type PluginNoteSweepMeasurement, type PluginRtSafetyMeasurement, } from "./hosting-suitability.js"; -import { OPENMIXER_CONSOLE_PROFILE, type HostProfile } from "./host-profiles.js"; +import { + OMX_CLAP_HOST_CONFIGURED_EXTENSIONS, + OMX_CLAP_HOST_EXTENSIONS, + OPENMIXER_CONSOLE_PROFILE, + type HostProfile, +} from "./host-profiles.js"; import type { PluginTarget } from "./loader.js"; import type { QualifyVerdict } from "./qualify-cli.js"; import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js"; @@ -51,6 +58,12 @@ export interface ClapRtSafetyReading extends PluginRtSafetyMeasurement { readonly events?: readonly string[]; } +/** What the note sweep counted; `applicable: false` is a plugin the prober did not play. */ +export interface ClapNoteSweepReading extends PluginNoteSweepMeasurement { + /** The first events the interposer recorded inside `process()` while the plugin was played. */ + readonly events?: readonly string[]; +} + /** The CLAP facts as the prober printed them: the measurement, and whether any output was not finite. */ export interface ClapProbedFacts extends PluginClapMeasurement { readonly nonFinite: boolean; @@ -65,6 +78,8 @@ export interface ClapQualifyLine { readonly hostExtensions: readonly string[]; readonly clap: ClapProbedFacts; readonly rtSafety: ClapRtSafetyReading; + /** The note sweep: the plugin played through its note input, counted apart. */ + readonly noteSweep: ClapNoteSweepReading; /** * The pin (spec §11): the `.clap`'s digest by its name in its directory and the layout fingerprint * the prober computed through plugin-hostd's `pin.h`, or the reason it took none. @@ -143,6 +158,31 @@ function readRtSafety(v: unknown): ClapRtSafetyReading { }; } +function readNoteSweep(v: unknown): ClapNoteSweepReading { + if (!isRecord(v)) return bad("no noteSweep"); + if (!bool(v, "applicable", "noteSweep")) return { applicable: false }; + const dialect = str(v, "dialect", "noteSweep"); + if (dialect !== "clap" && dialect !== "midi") return bad(`noteSweep dialect ${dialect}`); + const played = { + applicable: true, + dialect, + blocks: count(v, "blocks", "noteSweep"), + blockFrames: count(v, "blockFrames", "noteSweep"), + noteEvents: count(v, "noteEvents", "noteSweep"), + processErrors: count(v, "processErrors", "noteSweep"), + nonFinite: bool(v, "nonFinite", "noteSweep"), + } as const; + if (!bool(v, "interposer", "noteSweep")) return { ...played, interposer: false }; + return { + ...played, + interposer: true, + allocationsInRun: count(v, "allocationsInRun", "noteSweep"), + locksInRun: count(v, "locksInRun", "noteSweep"), + syscallsInRun: count(v, "syscallsInRun", "noteSweep"), + events: strings(v, "events", "noteSweep"), + }; +} + /** One line of the prober's stdout, or a {@link ClapQualifyError} saying how it differs. */ export function parseClapQualifyLine(text: string): ClapQualifyLine { let v: unknown; @@ -167,6 +207,7 @@ export function parseClapQualifyLine(text: string): ClapQualifyLine { hostExtensions: strings(v, "hostExtensions", "the line"), clap: readClap(v["clap"]), rtSafety: readRtSafety(v["rtSafety"]), + noteSweep: readNoteSweep(v["noteSweep"]), integrity: readIntegrity(v["integrity"]) ?? bad("no integrity"), }; } @@ -178,8 +219,13 @@ export function parseClapQualifyOutput(stdout: string): ClapQualifyLine[] { // --- the verdict document ------------------------------------------------------------------------ -/** The document's schema name; a reader refuses any other. */ -export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/1"; +/** + * The document's schema name; a reader refuses any other it does not know. `/2` is `/1` with + * `measured.noteSweep` added and the note sweep's reasons folded into `verdict` (a plugin with no + * note input gets none, so its `/2` verdict is its `/1` verdict); every `/1` field is unchanged, so + * a `/1` reader of `verdict.rating` and `verdict.rtSafe` reads a `/2` document right. + */ +export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/2"; /** * The RT-safety verdict of one plugin binary for one console: keyed by the binary's SHA-256 and @@ -207,6 +253,7 @@ export interface RtSafetyDocument { readonly hostExtensions: readonly string[]; readonly clap: ClapProbedFacts; readonly rtSafety: ClapRtSafetyReading; + readonly noteSweep: ClapNoteSweepReading; readonly integrity: IntegrityMeasurement; }; } @@ -225,7 +272,7 @@ export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = O offered === null ? [{ dimension: "rtSafety", kind: "objective", rating: "unknown", code: HOSTING_CODE.clapFormatNotHosted, params: {} }] : offeredAsProfile - ? [...rtSafetyReasons(line.rtSafety), ...clapRtReasons(line.clap)] + ? [...rtSafetyReasons(line.rtSafety), ...clapRtReasons(line.clap), ...noteSweepReasons(line.noteSweep, line.clap.noteInputs)] : [ { dimension: "rtSafety", @@ -244,7 +291,13 @@ export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = O plugin: { uri: line.uri, format: "clap", binary: line.binary }, console: profile.id, verdict: { rating, rtSafe: rating === "suitable", ...(rating === "suitable" || deciding === undefined ? {} : { deciding }), reasons }, - measured: { hostExtensions: line.hostExtensions, clap: line.clap, rtSafety: line.rtSafety, integrity: line.integrity }, + measured: { + hostExtensions: line.hostExtensions, + clap: line.clap, + rtSafety: line.rtSafety, + noteSweep: line.noteSweep, + integrity: line.integrity, + }, }; } @@ -303,6 +356,8 @@ export interface ClapQualifyOptions { readonly env?: Readonly>; /** Whether a file exists; the default asks the filesystem. */ readonly exists?: (path: string) => boolean; + /** The host extensions the prober offers beyond the console's, each one of {@link OMX_CLAP_HOST_CONFIGURED_EXTENSIONS}. */ + readonly offer?: readonly string[]; } /** @@ -341,6 +396,7 @@ export function qualifyClap(binaryPath: string, id: string | undefined, options: String(CLAP_SWEEP_FRAMES), "--blocks", String(CLAP_SWEEP_BLOCKS), + ...(options.offer === undefined || options.offer.length === 0 ? [] : ["--offer", options.offer.join(",")]), binaryPath, ...(id === undefined ? [] : [id]), ]; @@ -364,9 +420,24 @@ export interface ClapQualifyPass { } /** - * Qualify every CLAP target, one prober run per target (a crash takes only that plugin), and judge - * each line under every host profile. A target without a binary path (a bare `urn:clap:` id) has - * nothing to load and is a failure, never a pass. + * What the prober must offer beyond the console's extensions to measure for `profile`: the extra + * extensions of its list, when the prober can offer each and the list holds the console's; `undefined` + * for a profile the prober cannot stand in for (no CLAP path, or an extension it cannot offer), which + * is judged from the console's run and reads `unknown` there. + */ +export function proberOffer(profile: HostProfile): readonly string[] | undefined { + const wanted = profile.clapExtensions; + if (wanted === null || !OMX_CLAP_HOST_EXTENSIONS.every((e) => wanted.includes(e))) return undefined; + const extra = wanted.filter((e) => !OMX_CLAP_HOST_EXTENSIONS.includes(e)); + if (!extra.every((e) => OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.includes(e))) return undefined; + return OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.filter((e) => extra.includes(e)); +} + +/** + * Qualify every CLAP target, one prober run per target and set of offered extensions (a crash takes + * only that plugin), and judge each line under every host profile, from the run that offered that + * profile's extensions. A target without a binary path (a bare `urn:clap:` id) has nothing to load + * and is a failure, never a pass. */ export function qualifyClapTargets( targets: readonly PluginTarget[], @@ -378,10 +449,16 @@ export function qualifyClapTargets( const failures: { uri: string; problem: string }[] = []; for (const target of targets) { const id = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : undefined; - let lines: ClapQualifyLine[]; + // one run per distinct offer, the console's (none) for a profile the prober cannot stand in for + const offers = new Map(); + for (const profile of profiles) { + const offer = proberOffer(profile) ?? []; + offers.set(offer.join(","), offer); + } + const runs = new Map(); try { if (target.binaryPath === undefined) throw new ClapQualifyError(`${target.uri}: no .clap to load; name the file or a directory holding it`); - lines = qualifyClap(target.binaryPath, id, options); + for (const [key, offer] of offers) runs.set(key, qualifyClap(target.binaryPath, id, { ...options, offer })); } catch (e) { if (!(e instanceof ClapQualifyError)) throw e; failures.push({ uri: target.uri, problem: e.message }); @@ -390,8 +467,8 @@ export function qualifyClapTargets( } continue; } - for (const line of lines) { - for (const profile of profiles) { + for (const profile of profiles) { + for (const line of runs.get((proberOffer(profile) ?? []).join(","))!) { const doc = rtSafetyDocument(line, profile); documents.push(doc); verdicts.push(rtSafetyVerdict(doc)); diff --git a/src/host-profiles.test.ts b/src/host-profiles.test.ts index 54028ae..9219406 100644 --- a/src/host-profiles.test.ts +++ b/src/host-profiles.test.ts @@ -2,16 +2,21 @@ // Copyright (C) 2026 Pau Aliagas /** * The host profiles as declared: the - * shipped three, the lists they cite, and the reader a project's own profile file goes through — + * shipped four, the lists they cite, and the reader a project's own profile file goes through — * which refuses a missing or mistyped field rather than filling in a host it was not told about. */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { HOST_PROFILES, JALV_LV2_FEATURES, JALV_PROFILE, MOD_HOST_LV2_FEATURES, + OMX_CLAP_HOST_CONFIGURED_EXTENSIONS, OMX_CLAP_HOST_EXTENSIONS, + OMX_CLAP_HOST_PROFILE, OMX_INPROCESS_FEATURES, OPENMIXER_CONSOLE_PROFILE, readHostProfile, @@ -21,8 +26,8 @@ import * as hosting from "./hosting-suitability.js"; import { HOST_BUDGETS } from "./qualify-declarations.js"; describe("the shipped profiles", () => { - it("are three, by unique id, openmixer-console first", () => { - expect(HOST_PROFILES.map((p) => p.id)).toEqual(["openmixer-console", "jalv", "mod-host"]); + it("are four, by unique id, openmixer-console first", () => { + expect(HOST_PROFILES.map((p) => p.id)).toEqual(["openmixer-console", "jalv", "mod-host", "omx-clap-host"]); for (const p of HOST_PROFILES) expect(shippedHostProfile(p.id)).toBe(p); expect(shippedHostProfile("ardour")).toBeUndefined(); }); @@ -48,6 +53,47 @@ describe("the shipped profiles", () => { }); }); +describe("omx-clap-host's profile", () => { + // omx-clap-core's own tree: the deps checkout `make -C native deps` fetches (CI), else the installed header + const PKG = join(dirname(fileURLToPath(import.meta.url)), ".."); + const DEPS = join(PKG, "native", "deps", "omx-clap-core", "src"); + const HEADER = [join(DEPS, "clap_host_extensions.h"), "/usr/include/omx-clap-host/clap_host_extensions.h"].find((p) => existsSync(p)); + const EFFECTS = join(DEPS, "effects.c"); + + it("is the console's extensions plus the three omx-clap-host configures, instruments fed", () => { + expect(OMX_CLAP_HOST_PROFILE.clapExtensions).toEqual([...OMX_CLAP_HOST_EXTENSIONS, ...OMX_CLAP_HOST_CONFIGURED_EXTENSIONS]); + expect(OMX_CLAP_HOST_PROFILE).toMatchObject({ instruments: true, latency: "uncompensated", isolation: "shared-process" }); + expect(OMX_CLAP_HOST_PROFILE.cost).toBe(HOST_BUDGETS["omx-clap-host"]); + }); + + it("holds the library's host extensions as omx-clap-core's clap_host_extensions.h lists them", (ctx) => { + if (HEADER === undefined) { + if (process.env["CI"]) throw new Error("no clap_host_extensions.h: CI fetches it with make -C native deps"); + return ctx.skip("no clap_host_extensions.h: make -C native deps, or install omx-clap-core-devel"); + } + const init = /#define\s+CLAP_HOST_EXTENSIONS_INIT\s*\{([^}]*)\}/.exec(readFileSync(HEADER, "utf8")); + expect(init, HEADER).not.toBeNull(); + const listed = [...init![1]!.matchAll(/"([^"]+)"/g)].map((m) => m[1]); + expect(listed).toEqual(OMX_CLAP_HOST_EXTENSIONS); + }); + + it("holds the three configured ones as omx-clap-host's configure_core turns them on", (ctx) => { + if (!existsSync(EFFECTS)) { + if (process.env["CI"]) throw new Error("no omx-clap-core checkout: CI fetches it with make -C native deps"); + return ctx.skip("no omx-clap-core checkout: make -C native deps"); + } + const configure = /static int configure_core\(void\)\s*\{([\s\S]*?)\n\}/.exec(readFileSync(EFFECTS, "utf8")); + expect(configure).not.toBeNull(); + const on = [...configure![1]!.matchAll(/config\.(preset_load|track_info|remote_controls) = 1;/g)].map((m) => m[1]); + const ids: Record = { + preset_load: "clap.preset-load/2", + track_info: "clap.track-info/1", + remote_controls: "clap.remote-controls/2", + }; + expect(on.map((k) => ids[k!])).toEqual(OMX_CLAP_HOST_CONFIGURED_EXTENSIONS); + }); +}); + describe("readHostProfile — a project's own profile file", () => { it("reads every shipped profile back from its JSON unchanged", () => { for (const p of HOST_PROFILES) { diff --git a/src/host-profiles.ts b/src/host-profiles.ts index a746e3c..c8f90c9 100644 --- a/src/host-profiles.ts +++ b/src/host-profiles.ts @@ -54,6 +54,17 @@ export const OMX_CLAP_HOST_EXTENSIONS: readonly string[] = [ "clap.state", ]; +/** + * The three host extensions omx-clap-host turns on beyond the library's own (omx-clap-core + * `src/effects.c` `configure_core`: `preset_load`, `track_info`, `remote_controls`), by the ids + * its host object answers (`src/clap_host.c`); the prober offers them with `--offer`. + */ +export const OMX_CLAP_HOST_CONFIGURED_EXTENSIONS: readonly string[] = [ + "clap.preset-load/2", + "clap.track-info/1", + "clap.remote-controls/2", +]; + /** The LV2 extension namespace the jalv and mod-host lists are spelled in. */ const LV2_EXT = "http://lv2plug.in/ns/ext/"; const BUF_SIZE_ALL = ["powerOf2BlockLength", "fixedBlockLength", "boundedBlockLength"].map((f) => `${LV2_EXT}buf-size#${f}`); @@ -113,8 +124,25 @@ export const MOD_HOST_PROFILE: HostProfile = { isolation: "shared-process", }; +/** + * omx-clap-host, alone or as plugin-hostd's CLAP worker: one JACK client per plugin in a host + * process that several plugins may share (a plugin-hostd pool), instruments fed through each + * client's `midi_in`, latency published on the JACK ports. Its CLAP host object is the library's + * plus the three it configures; its LV2 plugins run through the same feature provider as the + * console's in-process stage. + */ +export const OMX_CLAP_HOST_PROFILE: HostProfile = { + id: "omx-clap-host", + lv2Features: OMX_INPROCESS_FEATURES, + clapExtensions: [...OMX_CLAP_HOST_EXTENSIONS, ...OMX_CLAP_HOST_CONFIGURED_EXTENSIONS], + instruments: true, + cost: HOST_BUDGETS["omx-clap-host"], + latency: "uncompensated", + isolation: "shared-process", +}; + /** Every profile the tool ships, by id. */ -export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE]; +export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE, OMX_CLAP_HOST_PROFILE]; /** A shipped profile by id, or `undefined`. */ export function shippedHostProfile(id: string): HostProfile | undefined { diff --git a/src/hosting-codes.ts b/src/hosting-codes.ts index 3121085..beeed2b 100644 --- a/src/hosting-codes.ts +++ b/src/hosting-codes.ts @@ -24,6 +24,10 @@ export const HOSTING_CODE = { rtSafetyViolations: 'hosting.rt-safety.violations-in-run', rtSafetyUnmeasured: 'hosting.rt-safety.unmeasured', rtSafetyNotReproducible: 'hosting.rt-safety.not-reproducible', + // The note sweep: an instrument played under the interposer, a count of its own. + rtSafetyNotesClean: 'hosting.rt-safety.clean-under-notes', + rtSafetyNotesViolations: 'hosting.rt-safety.violations-under-notes', + rtSafetyNotesUnmeasured: 'hosting.rt-safety.notes-unmeasured', featuresProvided: 'hosting.features.all-provided', featuresMissing: 'hosting.features.required-not-provided', featuresDisqualifying: 'hosting.features.host-not-provided', diff --git a/src/hosting-suitability.test.ts b/src/hosting-suitability.test.ts index 76ba4f7..fcf9ae0 100644 --- a/src/hosting-suitability.test.ts +++ b/src/hosting-suitability.test.ts @@ -34,7 +34,7 @@ import { type PluginStabilityMeasurement, } from "./hosting-suitability.js"; import type { SoakAtRate, SoakReading } from "./measurement/format.js"; -import { JALV_PROFILE, MOD_HOST_PROFILE, OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js"; +import { JALV_PROFILE, MOD_HOST_PROFILE, OMX_CLAP_HOST_PROFILE, OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js"; import { HOST_BUDGETS } from "./qualify-declarations.js"; /** The console's per-instance ceiling, read from the declaration it lives in. */ @@ -885,6 +885,16 @@ describe("host profiles — the CLAP arm", () => { expect(v.rating).toBe("suitable"); expect(v.reasons.map((r) => r.code)).toContain(HOSTING_CODE.topologyInstrument); }); + + it("a CLAP instrument is suitable under omx-clap-host and unsuitable under openmixer-console", () => { + const synth = clapPlugin({ mainInputChannels: 0, noteInputs: 1, features: ["instrument"] }); + const asHost = classify(synth, { profile: OMX_CLAP_HOST_PROFILE, quantum: OMX_CLAP_HOST_PROFILE.cost.quantum }); + expect(asHost.rating).toBe("suitable"); + expect(asHost.reasons.map((r) => r.code)).toContain(HOSTING_CODE.topologyInstrument); + const asConsole = classify(synth, { profile: OPENMIXER_CONSOLE_PROFILE }); + expect(asConsole.rating).toBe("unsuitable"); + expect(asConsole.reasons.map((r) => r.code)).toContain(HOSTING_CODE.clapNoteInput); + }); }); describe("each core refusal declares its one-line description beside it", () => { diff --git a/src/hosting-suitability.ts b/src/hosting-suitability.ts index 0c2fad2..b310e1c 100644 --- a/src/hosting-suitability.ts +++ b/src/hosting-suitability.ts @@ -342,6 +342,28 @@ export interface PluginClapMeasurement { readonly processCalls: number; } +/** + * The note sweep of a CLAP plugin with a note input (plugin-qualify's prober): the plugin played at + * every rate — a chord, bend and a controller, pressure, the chord let go, a burst of note ons and + * their offs, all notes off — in the dialect its first note input reads, counted under the + * interposer apart from the parameter sweep. `applicable: false` is a plugin the prober could not + * play: no note input, or none in a dialect it speaks. + */ +export interface PluginNoteSweepMeasurement { + readonly applicable: boolean; + readonly dialect?: "clap" | "midi"; + readonly blocks?: number; + readonly blockFrames?: number; + /** Note, expression and MIDI events the sweep sent. */ + readonly noteEvents?: number; + readonly processErrors?: number; + readonly nonFinite?: boolean; + readonly interposer?: boolean; + readonly allocationsInRun?: number; + readonly locksInRun?: number; + readonly syscallsInRun?: number; +} + /** What the classifier is told about the console asking. */ export interface HostingClassifyOptions { /** The LIVE rate. Cost is read here and nowhere else — never projected. */ @@ -888,6 +910,37 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly return out; } +/** + * The rtSafety reasons of the note sweep, for a plugin with `noteInputs` note inputs: none for a + * plugin that takes no notes; `unknown` when it takes notes and was not played under the + * interposer (an instrument is cheap and clean at rest, so the parameter sweep alone says nothing + * about a voice); `unsuitable` on any allocation, lock or syscall while it was played, or a process + * error; `suitable` on a clean count. + */ +export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined, noteInputs: number): readonly HostingReason[] { + if (noteInputs === 0) return []; + if (sweep === undefined || !sweep.applicable || sweep.interposer !== true || !sweep.blocks || + sweep.allocationsInRun === undefined || sweep.locksInRun === undefined || sweep.syscallsInRun === undefined) { + return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotesUnmeasured)]; + } + const out: HostingReason[] = []; + if (sweep.allocationsInRun + sweep.locksInRun + sweep.syscallsInRun > 0) { + out.push( + reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyNotesViolations, { + allocations: sweep.allocationsInRun, + syscalls: sweep.syscallsInRun, + locks: sweep.locksInRun, + }), + ); + } + if ((sweep.processErrors ?? 0) > 0) { + out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: sweep.processErrors ?? 0 })); + } + return out.length > 0 + ? out + : [reason("rtSafety", "objective", "suitable", HOSTING_CODE.rtSafetyNotesClean, { blocks: sweep.blocks, events: sweep.noteEvents ?? 0 })]; +} + /** * The verdict. Worst-wins across every dimension, exactly as `classifyForRole` folds — with * three gates that can poison the whole result before the fold is even read: a curator diff --git a/src/qualify-declarations.ts b/src/qualify-declarations.ts index c9e2123..744e26a 100644 --- a/src/qualify-declarations.ts +++ b/src/qualify-declarations.ts @@ -178,10 +178,13 @@ export type { HostBudget }; * so it gets the same per-instance share, at 48 kHz / 128 frames. `jalv` gives each plugin its own * process and client, so a quarter core per instance, at 48 kHz / 256 frames. The last two are * declared defaults, not a measurement of anyone's board — a project brings its own profile file. + * `omx-clap-host` runs its plugins one JACK client each, called in turn on the server's one cycle, + * so it gets mod-host's share and pair. */ export const HOST_BUDGETS = { "openmixer-console": { coreFractionCeiling: 0.05, rate: COST_REFERENCE_RATE, quantum: QUANTUM_FRAMES }, jalv: { coreFractionCeiling: 0.25, rate: MEASURE_BASE_RATE, quantum: 256 }, "mod-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 }, + "omx-clap-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 }, } as const satisfies Readonly>;