@openmixer/plugin-qualify 0.8.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -4
- package/bin/plugin-qualify.mjs +60 -25
- package/dist/clap-loader.d.ts +25 -1
- package/dist/clap-loader.js +88 -7
- package/dist/clap-loader.js.map +1 -1
- package/dist/clap-qualify.d.ts +29 -6
- package/dist/clap-qualify.js +77 -13
- package/dist/clap-qualify.js.map +1 -1
- package/dist/host-profiles.d.ts +14 -0
- package/dist/host-profiles.js +27 -1
- package/dist/host-profiles.js.map +1 -1
- package/dist/hosting-codes.d.ts +5 -2
- package/dist/hosting-codes.js +8 -4
- package/dist/hosting-codes.js.map +1 -1
- package/dist/hosting-suitability.d.ts +32 -3
- package/dist/hosting-suitability.js +35 -6
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lv2-loader.d.ts +5 -0
- package/dist/lv2-loader.js +30 -2
- package/dist/lv2-loader.js.map +1 -1
- package/dist/measure-host.js +2 -2
- package/dist/measurement/cli.d.ts +13 -0
- package/dist/measurement/cli.js +68 -2
- package/dist/measurement/cli.js.map +1 -1
- package/dist/measurement/format.d.ts +1 -1
- package/dist/measurement/vocabulary.d.ts +1 -1
- package/dist/measurement/vocabulary.js +1 -1
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/pins-file.d.ts +76 -0
- package/dist/pins-file.js +165 -0
- package/dist/pins-file.js.map +1 -0
- package/dist/qualify-cli.d.ts +2 -0
- package/dist/qualify-cli.js +7 -1
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +23 -4
- package/dist/qualify-declarations.js +19 -3
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-options.js +6 -1
- package/dist/qualify-options.js.map +1 -1
- package/package.json +3 -2
- package/src/clap-loader.ts +99 -7
- package/src/clap-qualify.ts +89 -12
- package/src/host-profiles.ts +29 -1
- package/src/hosting-codes.ts +8 -4
- package/src/hosting-suitability.ts +61 -8
- package/src/index.ts +1 -0
- package/src/lv2-loader.ts +28 -2
- package/src/measure-host.ts +2 -2
- package/src/measurement/cli.ts +77 -2
- package/src/measurement/format.ts +1 -1
- package/src/measurement/vocabulary.ts +1 -1
- package/src/pins-file.ts +200 -0
- package/src/qualify-cli.ts +8 -1
- package/src/qualify-declarations.ts +21 -4
- package/src/qualify-options.ts +7 -1
- package/tools/ai-trace-commit-check.sh +114 -0
- package/tools/ai-trace-patterns.json +33 -0
- package/tools/benchmark.py +1 -1
- package/tools/lv2-measure.mjs +16 -0
- package/tools/private-refs-check.sh +90 -0
package/src/clap-qualify.ts
CHANGED
|
@@ -24,12 +24,19 @@ import {
|
|
|
24
24
|
HOSTING_CODE,
|
|
25
25
|
clapRtReasons,
|
|
26
26
|
contributedHostingRating,
|
|
27
|
+
noteSweepReasons,
|
|
27
28
|
rtSafetyReasons,
|
|
28
29
|
type HostingReason,
|
|
29
30
|
type PluginClapMeasurement,
|
|
31
|
+
type PluginNoteSweepMeasurement,
|
|
30
32
|
type PluginRtSafetyMeasurement,
|
|
31
33
|
} from "./hosting-suitability.js";
|
|
32
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
OMX_CLAP_HOST_CONFIGURED_EXTENSIONS,
|
|
36
|
+
OMX_CLAP_HOST_EXTENSIONS,
|
|
37
|
+
OPENMIXER_CONSOLE_PROFILE,
|
|
38
|
+
type HostProfile,
|
|
39
|
+
} from "./host-profiles.js";
|
|
33
40
|
import type { PluginTarget } from "./loader.js";
|
|
34
41
|
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
35
42
|
import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
|
|
@@ -51,6 +58,12 @@ export interface ClapRtSafetyReading extends PluginRtSafetyMeasurement {
|
|
|
51
58
|
readonly events?: readonly string[];
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
/** What the note sweep counted; `applicable: false` is a plugin the prober did not play. */
|
|
62
|
+
export interface ClapNoteSweepReading extends PluginNoteSweepMeasurement {
|
|
63
|
+
/** The first events the interposer recorded inside `process()` while the plugin was played. */
|
|
64
|
+
readonly events?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
/** The CLAP facts as the prober printed them: the measurement, and whether any output was not finite. */
|
|
55
68
|
export interface ClapProbedFacts extends PluginClapMeasurement {
|
|
56
69
|
readonly nonFinite: boolean;
|
|
@@ -65,6 +78,8 @@ export interface ClapQualifyLine {
|
|
|
65
78
|
readonly hostExtensions: readonly string[];
|
|
66
79
|
readonly clap: ClapProbedFacts;
|
|
67
80
|
readonly rtSafety: ClapRtSafetyReading;
|
|
81
|
+
/** The note sweep: the plugin played through its note input, counted apart. */
|
|
82
|
+
readonly noteSweep: ClapNoteSweepReading;
|
|
68
83
|
/**
|
|
69
84
|
* The pin (spec §11): the `.clap`'s digest by its name in its directory and the layout fingerprint
|
|
70
85
|
* the prober computed through plugin-hostd's `pin.h`, or the reason it took none.
|
|
@@ -143,6 +158,31 @@ function readRtSafety(v: unknown): ClapRtSafetyReading {
|
|
|
143
158
|
};
|
|
144
159
|
}
|
|
145
160
|
|
|
161
|
+
function readNoteSweep(v: unknown): ClapNoteSweepReading {
|
|
162
|
+
if (!isRecord(v)) return bad("no noteSweep");
|
|
163
|
+
if (!bool(v, "applicable", "noteSweep")) return { applicable: false };
|
|
164
|
+
const dialect = str(v, "dialect", "noteSweep");
|
|
165
|
+
if (dialect !== "clap" && dialect !== "midi") return bad(`noteSweep dialect ${dialect}`);
|
|
166
|
+
const played = {
|
|
167
|
+
applicable: true,
|
|
168
|
+
dialect,
|
|
169
|
+
blocks: count(v, "blocks", "noteSweep"),
|
|
170
|
+
blockFrames: count(v, "blockFrames", "noteSweep"),
|
|
171
|
+
noteEvents: count(v, "noteEvents", "noteSweep"),
|
|
172
|
+
processErrors: count(v, "processErrors", "noteSweep"),
|
|
173
|
+
nonFinite: bool(v, "nonFinite", "noteSweep"),
|
|
174
|
+
} as const;
|
|
175
|
+
if (!bool(v, "interposer", "noteSweep")) return { ...played, interposer: false };
|
|
176
|
+
return {
|
|
177
|
+
...played,
|
|
178
|
+
interposer: true,
|
|
179
|
+
allocationsInRun: count(v, "allocationsInRun", "noteSweep"),
|
|
180
|
+
locksInRun: count(v, "locksInRun", "noteSweep"),
|
|
181
|
+
syscallsInRun: count(v, "syscallsInRun", "noteSweep"),
|
|
182
|
+
events: strings(v, "events", "noteSweep"),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
146
186
|
/** One line of the prober's stdout, or a {@link ClapQualifyError} saying how it differs. */
|
|
147
187
|
export function parseClapQualifyLine(text: string): ClapQualifyLine {
|
|
148
188
|
let v: unknown;
|
|
@@ -167,6 +207,7 @@ export function parseClapQualifyLine(text: string): ClapQualifyLine {
|
|
|
167
207
|
hostExtensions: strings(v, "hostExtensions", "the line"),
|
|
168
208
|
clap: readClap(v["clap"]),
|
|
169
209
|
rtSafety: readRtSafety(v["rtSafety"]),
|
|
210
|
+
noteSweep: readNoteSweep(v["noteSweep"]),
|
|
170
211
|
integrity: readIntegrity(v["integrity"]) ?? bad("no integrity"),
|
|
171
212
|
};
|
|
172
213
|
}
|
|
@@ -178,8 +219,13 @@ export function parseClapQualifyOutput(stdout: string): ClapQualifyLine[] {
|
|
|
178
219
|
|
|
179
220
|
// --- the verdict document ------------------------------------------------------------------------
|
|
180
221
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
222
|
+
/**
|
|
223
|
+
* The document's schema name; a reader refuses any other it does not know. `/2` is `/1` with
|
|
224
|
+
* `measured.noteSweep` added and the note sweep's reasons folded into `verdict` (a plugin with no
|
|
225
|
+
* note input gets none, so its `/2` verdict is its `/1` verdict); every `/1` field is unchanged, so
|
|
226
|
+
* a `/1` reader of `verdict.rating` and `verdict.rtSafe` reads a `/2` document right.
|
|
227
|
+
*/
|
|
228
|
+
export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/2";
|
|
183
229
|
|
|
184
230
|
/**
|
|
185
231
|
* 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 {
|
|
|
207
253
|
readonly hostExtensions: readonly string[];
|
|
208
254
|
readonly clap: ClapProbedFacts;
|
|
209
255
|
readonly rtSafety: ClapRtSafetyReading;
|
|
256
|
+
readonly noteSweep: ClapNoteSweepReading;
|
|
210
257
|
readonly integrity: IntegrityMeasurement;
|
|
211
258
|
};
|
|
212
259
|
}
|
|
@@ -225,7 +272,7 @@ export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = O
|
|
|
225
272
|
offered === null
|
|
226
273
|
? [{ dimension: "rtSafety", kind: "objective", rating: "unknown", code: HOSTING_CODE.clapFormatNotHosted, params: {} }]
|
|
227
274
|
: offeredAsProfile
|
|
228
|
-
? [...rtSafetyReasons(line.rtSafety), ...clapRtReasons(line.clap)]
|
|
275
|
+
? [...rtSafetyReasons(line.rtSafety), ...clapRtReasons(line.clap), ...noteSweepReasons(line.noteSweep, line.clap.noteInputs)]
|
|
229
276
|
: [
|
|
230
277
|
{
|
|
231
278
|
dimension: "rtSafety",
|
|
@@ -244,7 +291,13 @@ export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = O
|
|
|
244
291
|
plugin: { uri: line.uri, format: "clap", binary: line.binary },
|
|
245
292
|
console: profile.id,
|
|
246
293
|
verdict: { rating, rtSafe: rating === "suitable", ...(rating === "suitable" || deciding === undefined ? {} : { deciding }), reasons },
|
|
247
|
-
measured: {
|
|
294
|
+
measured: {
|
|
295
|
+
hostExtensions: line.hostExtensions,
|
|
296
|
+
clap: line.clap,
|
|
297
|
+
rtSafety: line.rtSafety,
|
|
298
|
+
noteSweep: line.noteSweep,
|
|
299
|
+
integrity: line.integrity,
|
|
300
|
+
},
|
|
248
301
|
};
|
|
249
302
|
}
|
|
250
303
|
|
|
@@ -303,6 +356,8 @@ export interface ClapQualifyOptions {
|
|
|
303
356
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
304
357
|
/** Whether a file exists; the default asks the filesystem. */
|
|
305
358
|
readonly exists?: (path: string) => boolean;
|
|
359
|
+
/** The host extensions the prober offers beyond the console's, each one of {@link OMX_CLAP_HOST_CONFIGURED_EXTENSIONS}. */
|
|
360
|
+
readonly offer?: readonly string[];
|
|
306
361
|
}
|
|
307
362
|
|
|
308
363
|
/**
|
|
@@ -341,6 +396,7 @@ export function qualifyClap(binaryPath: string, id: string | undefined, options:
|
|
|
341
396
|
String(CLAP_SWEEP_FRAMES),
|
|
342
397
|
"--blocks",
|
|
343
398
|
String(CLAP_SWEEP_BLOCKS),
|
|
399
|
+
...(options.offer === undefined || options.offer.length === 0 ? [] : ["--offer", options.offer.join(",")]),
|
|
344
400
|
binaryPath,
|
|
345
401
|
...(id === undefined ? [] : [id]),
|
|
346
402
|
];
|
|
@@ -364,9 +420,24 @@ export interface ClapQualifyPass {
|
|
|
364
420
|
}
|
|
365
421
|
|
|
366
422
|
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
423
|
+
* What the prober must offer beyond the console's extensions to measure for `profile`: the extra
|
|
424
|
+
* extensions of its list, when the prober can offer each and the list holds the console's; `undefined`
|
|
425
|
+
* for a profile the prober cannot stand in for (no CLAP path, or an extension it cannot offer), which
|
|
426
|
+
* is judged from the console's run and reads `unknown` there.
|
|
427
|
+
*/
|
|
428
|
+
export function proberOffer(profile: HostProfile): readonly string[] | undefined {
|
|
429
|
+
const wanted = profile.clapExtensions;
|
|
430
|
+
if (wanted === null || !OMX_CLAP_HOST_EXTENSIONS.every((e) => wanted.includes(e))) return undefined;
|
|
431
|
+
const extra = wanted.filter((e) => !OMX_CLAP_HOST_EXTENSIONS.includes(e));
|
|
432
|
+
if (!extra.every((e) => OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.includes(e))) return undefined;
|
|
433
|
+
return OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.filter((e) => extra.includes(e));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Qualify every CLAP target, one prober run per target and set of offered extensions (a crash takes
|
|
438
|
+
* only that plugin), and judge each line under every host profile, from the run that offered that
|
|
439
|
+
* profile's extensions. A target without a binary path (a bare `urn:clap:` id) has nothing to load
|
|
440
|
+
* and is a failure, never a pass.
|
|
370
441
|
*/
|
|
371
442
|
export function qualifyClapTargets(
|
|
372
443
|
targets: readonly PluginTarget[],
|
|
@@ -378,10 +449,16 @@ export function qualifyClapTargets(
|
|
|
378
449
|
const failures: { uri: string; problem: string }[] = [];
|
|
379
450
|
for (const target of targets) {
|
|
380
451
|
const id = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : undefined;
|
|
381
|
-
|
|
452
|
+
// one run per distinct offer, the console's (none) for a profile the prober cannot stand in for
|
|
453
|
+
const offers = new Map<string, readonly string[]>();
|
|
454
|
+
for (const profile of profiles) {
|
|
455
|
+
const offer = proberOffer(profile) ?? [];
|
|
456
|
+
offers.set(offer.join(","), offer);
|
|
457
|
+
}
|
|
458
|
+
const runs = new Map<string, ClapQualifyLine[]>();
|
|
382
459
|
try {
|
|
383
460
|
if (target.binaryPath === undefined) throw new ClapQualifyError(`${target.uri}: no .clap to load; name the file or a directory holding it`);
|
|
384
|
-
|
|
461
|
+
for (const [key, offer] of offers) runs.set(key, qualifyClap(target.binaryPath, id, { ...options, offer }));
|
|
385
462
|
} catch (e) {
|
|
386
463
|
if (!(e instanceof ClapQualifyError)) throw e;
|
|
387
464
|
failures.push({ uri: target.uri, problem: e.message });
|
|
@@ -390,8 +467,8 @@ export function qualifyClapTargets(
|
|
|
390
467
|
}
|
|
391
468
|
continue;
|
|
392
469
|
}
|
|
393
|
-
for (const
|
|
394
|
-
for (const
|
|
470
|
+
for (const profile of profiles) {
|
|
471
|
+
for (const line of runs.get((proberOffer(profile) ?? []).join(","))!) {
|
|
395
472
|
const doc = rtSafetyDocument(line, profile);
|
|
396
473
|
documents.push(doc);
|
|
397
474
|
verdicts.push(rtSafetyVerdict(doc));
|
package/src/host-profiles.ts
CHANGED
|
@@ -54,6 +54,17 @@ export const OMX_CLAP_HOST_EXTENSIONS: readonly string[] = [
|
|
|
54
54
|
"clap.state",
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The three host extensions omx-clap-host turns on beyond the library's own (omx-clap-core
|
|
59
|
+
* `src/effects.c` `configure_core`: `preset_load`, `track_info`, `remote_controls`), by the ids
|
|
60
|
+
* its host object answers (`src/clap_host.c`); the prober offers them with `--offer`.
|
|
61
|
+
*/
|
|
62
|
+
export const OMX_CLAP_HOST_CONFIGURED_EXTENSIONS: readonly string[] = [
|
|
63
|
+
"clap.preset-load/2",
|
|
64
|
+
"clap.track-info/1",
|
|
65
|
+
"clap.remote-controls/2",
|
|
66
|
+
];
|
|
67
|
+
|
|
57
68
|
/** The LV2 extension namespace the jalv and mod-host lists are spelled in. */
|
|
58
69
|
const LV2_EXT = "http://lv2plug.in/ns/ext/";
|
|
59
70
|
const BUF_SIZE_ALL = ["powerOf2BlockLength", "fixedBlockLength", "boundedBlockLength"].map((f) => `${LV2_EXT}buf-size#${f}`);
|
|
@@ -113,8 +124,25 @@ export const MOD_HOST_PROFILE: HostProfile = {
|
|
|
113
124
|
isolation: "shared-process",
|
|
114
125
|
};
|
|
115
126
|
|
|
127
|
+
/**
|
|
128
|
+
* omx-clap-host, alone or as plugin-hostd's CLAP worker: one JACK client per plugin in a host
|
|
129
|
+
* process that several plugins may share (a plugin-hostd pool), instruments fed through each
|
|
130
|
+
* client's `midi_in`, latency published on the JACK ports. Its CLAP host object is the library's
|
|
131
|
+
* plus the three it configures; its LV2 plugins run through the same feature provider as the
|
|
132
|
+
* console's in-process stage.
|
|
133
|
+
*/
|
|
134
|
+
export const OMX_CLAP_HOST_PROFILE: HostProfile = {
|
|
135
|
+
id: "omx-clap-host",
|
|
136
|
+
lv2Features: OMX_INPROCESS_FEATURES,
|
|
137
|
+
clapExtensions: [...OMX_CLAP_HOST_EXTENSIONS, ...OMX_CLAP_HOST_CONFIGURED_EXTENSIONS],
|
|
138
|
+
instruments: true,
|
|
139
|
+
cost: HOST_BUDGETS["omx-clap-host"],
|
|
140
|
+
latency: "uncompensated",
|
|
141
|
+
isolation: "shared-process",
|
|
142
|
+
};
|
|
143
|
+
|
|
116
144
|
/** Every profile the tool ships, by id. */
|
|
117
|
-
export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE];
|
|
145
|
+
export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE, OMX_CLAP_HOST_PROFILE];
|
|
118
146
|
|
|
119
147
|
/** A shipped profile by id, or `undefined`. */
|
|
120
148
|
export function shippedHostProfile(id: string): HostProfile | undefined {
|
package/src/hosting-codes.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
3
|
/**
|
|
4
|
-
* The hosting verdict's message codes
|
|
4
|
+
* The hosting verdict's message codes. Codes, never prose: the
|
|
5
5
|
* surface renders the operator's locale.
|
|
6
6
|
*
|
|
7
|
-
* A UNION list file
|
|
7
|
+
* A UNION list file: `.gitattributes` marks it
|
|
8
8
|
* `merge=union`, so two lanes that each add a code merge with both kept. Only the table lives
|
|
9
9
|
* here, one `key: 'code',` per line, with keys and codes unique; the registry-hotspots gate
|
|
10
10
|
* refuses anything else.
|
|
@@ -24,6 +24,10 @@ export const HOSTING_CODE = {
|
|
|
24
24
|
rtSafetyViolations: 'hosting.rt-safety.violations-in-run',
|
|
25
25
|
rtSafetyUnmeasured: 'hosting.rt-safety.unmeasured',
|
|
26
26
|
rtSafetyNotReproducible: 'hosting.rt-safety.not-reproducible',
|
|
27
|
+
// The note sweep: an instrument played under the interposer, a count of its own.
|
|
28
|
+
rtSafetyNotesClean: 'hosting.rt-safety.clean-under-notes',
|
|
29
|
+
rtSafetyNotesViolations: 'hosting.rt-safety.violations-under-notes',
|
|
30
|
+
rtSafetyNotesUnmeasured: 'hosting.rt-safety.notes-unmeasured',
|
|
27
31
|
featuresProvided: 'hosting.features.all-provided',
|
|
28
32
|
featuresMissing: 'hosting.features.required-not-provided',
|
|
29
33
|
featuresDisqualifying: 'hosting.features.host-not-provided',
|
|
@@ -44,7 +48,7 @@ export const HOSTING_CODE = {
|
|
|
44
48
|
topologyNoAudioOutput: 'hosting.topology.no-audio-output',
|
|
45
49
|
topologyExtraInputs: 'hosting.topology.extra-inputs-fed-silence',
|
|
46
50
|
topologyInstrument: 'hosting.topology.instrument',
|
|
47
|
-
// The CLAP arm
|
|
51
|
+
// The CLAP arm: the SAME dimensions fed from CLAP facts,
|
|
48
52
|
// minted here where the LV2 table has no code for the fact.
|
|
49
53
|
topologyWiderThanStrip: 'hosting.topology.wider-than-strip',
|
|
50
54
|
clapLatencyMismatch: 'hosting.clap.latency-mismatch',
|
|
@@ -72,6 +76,6 @@ export const HOSTING_CODE = {
|
|
|
72
76
|
noRealisation: 'hosting.no-realisation',
|
|
73
77
|
realisedInProcess: 'hosting.realised.in-process',
|
|
74
78
|
// A plugin LINKED into the engine (the static-link registry) runs in-process without a
|
|
75
|
-
// verdict: the library's oracles are its proof
|
|
79
|
+
// verdict: the library's oracles are its proof.
|
|
76
80
|
linked: 'hosting.linked',
|
|
77
81
|
} as const;
|
|
@@ -199,12 +199,12 @@ export interface CrashSignature {
|
|
|
199
199
|
/** The top plugin-owned stack frame, or a host frame that exonerates the plugin. */
|
|
200
200
|
readonly topFrame: string;
|
|
201
201
|
readonly signal?: "SIGSEGV" | "SIGABRT" | "SIGBUS";
|
|
202
|
-
/**
|
|
202
|
+
/** What was found about this cluster. Provenance, so a row is never folklore. */
|
|
203
203
|
readonly note: string;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
|
-
* Seeded from the
|
|
207
|
+
* Seeded from the crashes investigated on 2026-09-03/04. A raw crash matching NO row is
|
|
208
208
|
* `unknown` — the table blames a plugin only where a named investigation did.
|
|
209
209
|
*/
|
|
210
210
|
export const CRASH_SIGNATURES: readonly CrashSignature[] = [
|
|
@@ -213,26 +213,26 @@ export const CRASH_SIGNATURES: readonly CrashSignature[] = [
|
|
|
213
213
|
attribution: "host",
|
|
214
214
|
topFrame: "pw_memmap_free",
|
|
215
215
|
signal: "SIGSEGV",
|
|
216
|
-
note: "
|
|
216
|
+
note: "closing a PipeWire JACK client frees a memory map twice; the host crashes, not the plugin",
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
id: "pipewire-jack-close-double-free-abort",
|
|
220
220
|
attribution: "host",
|
|
221
221
|
topFrame: "tcache_double_free_verify",
|
|
222
222
|
signal: "SIGABRT",
|
|
223
|
-
note: "
|
|
223
|
+
note: "the same double free in PipeWire's JACK client close, caught by glibc's tcache check as an abort",
|
|
224
224
|
},
|
|
225
225
|
{
|
|
226
226
|
id: "mod-host-effects-remove",
|
|
227
227
|
attribution: "host",
|
|
228
228
|
topFrame: "effects_remove",
|
|
229
|
-
note: "
|
|
229
|
+
note: "mod-host crashes in effects_remove while removing an effect; the host is at fault",
|
|
230
230
|
},
|
|
231
231
|
{
|
|
232
232
|
id: "plugin-instantiate-layout",
|
|
233
233
|
attribution: "plugin",
|
|
234
234
|
topFrame: "lilv_instance_instantiate",
|
|
235
|
-
note: "
|
|
235
|
+
note: "the plugin crashes while lilv instantiates it, on a port layout it cannot host",
|
|
236
236
|
},
|
|
237
237
|
];
|
|
238
238
|
|
|
@@ -342,6 +342,28 @@ export interface PluginClapMeasurement {
|
|
|
342
342
|
readonly processCalls: number;
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* The note sweep of a CLAP plugin with a note input (plugin-qualify's prober): the plugin played at
|
|
347
|
+
* every rate — a chord, bend and a controller, pressure, the chord let go, a burst of note ons and
|
|
348
|
+
* their offs, all notes off — in the dialect its first note input reads, counted under the
|
|
349
|
+
* interposer apart from the parameter sweep. `applicable: false` is a plugin the prober could not
|
|
350
|
+
* play: no note input, or none in a dialect it speaks.
|
|
351
|
+
*/
|
|
352
|
+
export interface PluginNoteSweepMeasurement {
|
|
353
|
+
readonly applicable: boolean;
|
|
354
|
+
readonly dialect?: "clap" | "midi";
|
|
355
|
+
readonly blocks?: number;
|
|
356
|
+
readonly blockFrames?: number;
|
|
357
|
+
/** Note, expression and MIDI events the sweep sent. */
|
|
358
|
+
readonly noteEvents?: number;
|
|
359
|
+
readonly processErrors?: number;
|
|
360
|
+
readonly nonFinite?: boolean;
|
|
361
|
+
readonly interposer?: boolean;
|
|
362
|
+
readonly allocationsInRun?: number;
|
|
363
|
+
readonly locksInRun?: number;
|
|
364
|
+
readonly syscallsInRun?: number;
|
|
365
|
+
}
|
|
366
|
+
|
|
345
367
|
/** What the classifier is told about the console asking. */
|
|
346
368
|
export interface HostingClassifyOptions {
|
|
347
369
|
/** The LIVE rate. Cost is read here and nowhere else — never projected. */
|
|
@@ -888,6 +910,37 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
|
|
|
888
910
|
return out;
|
|
889
911
|
}
|
|
890
912
|
|
|
913
|
+
/**
|
|
914
|
+
* The rtSafety reasons of the note sweep, for a plugin with `noteInputs` note inputs: none for a
|
|
915
|
+
* plugin that takes no notes; `unknown` when it takes notes and was not played under the
|
|
916
|
+
* interposer (an instrument is cheap and clean at rest, so the parameter sweep alone says nothing
|
|
917
|
+
* about a voice); `unsuitable` on any allocation, lock or syscall while it was played, or a process
|
|
918
|
+
* error; `suitable` on a clean count.
|
|
919
|
+
*/
|
|
920
|
+
export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined, noteInputs: number): readonly HostingReason[] {
|
|
921
|
+
if (noteInputs === 0) return [];
|
|
922
|
+
if (sweep === undefined || !sweep.applicable || sweep.interposer !== true || !sweep.blocks ||
|
|
923
|
+
sweep.allocationsInRun === undefined || sweep.locksInRun === undefined || sweep.syscallsInRun === undefined) {
|
|
924
|
+
return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotesUnmeasured)];
|
|
925
|
+
}
|
|
926
|
+
const out: HostingReason[] = [];
|
|
927
|
+
if (sweep.allocationsInRun + sweep.locksInRun + sweep.syscallsInRun > 0) {
|
|
928
|
+
out.push(
|
|
929
|
+
reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyNotesViolations, {
|
|
930
|
+
allocations: sweep.allocationsInRun,
|
|
931
|
+
syscalls: sweep.syscallsInRun,
|
|
932
|
+
locks: sweep.locksInRun,
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
if ((sweep.processErrors ?? 0) > 0) {
|
|
937
|
+
out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: sweep.processErrors ?? 0 }));
|
|
938
|
+
}
|
|
939
|
+
return out.length > 0
|
|
940
|
+
? out
|
|
941
|
+
: [reason("rtSafety", "objective", "suitable", HOSTING_CODE.rtSafetyNotesClean, { blocks: sweep.blocks, events: sweep.noteEvents ?? 0 })];
|
|
942
|
+
}
|
|
943
|
+
|
|
891
944
|
/**
|
|
892
945
|
* The verdict. Worst-wins across every dimension, exactly as `classifyForRole` folds — with
|
|
893
946
|
* three gates that can poison the whole result before the fold is even read: a curator
|
|
@@ -905,7 +958,7 @@ export function classifyPluginHosting(
|
|
|
905
958
|
// interchange's one door (`measurement-catalog.ts`), whose per-dimension precedence is the
|
|
906
959
|
// only precedence there is. A second "passed measurements win" seam here was how an offline
|
|
907
960
|
// report could agree with itself while every served descriptor read unknown.
|
|
908
|
-
// The CLAP arm
|
|
961
|
+
// The CLAP arm: the SAME six dimensions, fed from the
|
|
909
962
|
// qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
|
|
910
963
|
// the interposer's counts and the cost are format-blind and read unchanged.
|
|
911
964
|
const reasons: HostingReason[] = descriptor.format === "clap"
|
|
@@ -988,7 +1041,7 @@ export interface HostingRealisation {
|
|
|
988
1041
|
readonly inProcessAvailable: boolean;
|
|
989
1042
|
/**
|
|
990
1043
|
* Whether the plugin is LINKED into the engine (its static-link registry): it needs no verdict
|
|
991
|
-
* to run in-process
|
|
1044
|
+
* to run in-process. Absent reads as not linked.
|
|
992
1045
|
*/
|
|
993
1046
|
readonly linked?: boolean;
|
|
994
1047
|
}
|
package/src/index.ts
CHANGED
package/src/lv2-loader.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* The manifest read is the subset every LV2 bundle's manifest uses (subject, `a lv2:Plugin`,
|
|
10
10
|
* `lv2:binary <x.so>`), not a Turtle parser; `--all` goes through lilv itself.
|
|
11
11
|
*/
|
|
12
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
-
import {
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { basename, join, resolve } from "node:path";
|
|
14
15
|
|
|
15
16
|
import type { PluginLoader, PluginTarget } from "./loader.js";
|
|
16
17
|
|
|
@@ -48,3 +49,28 @@ export const lv2Loader: PluginLoader = {
|
|
|
48
49
|
return URI_SHAPE.test(target) ? [{ format: "lv2", uri: target }] : undefined;
|
|
49
50
|
},
|
|
50
51
|
};
|
|
52
|
+
|
|
53
|
+
/** lilv's default LV2 search path, used when `LV2_PATH` is unset. */
|
|
54
|
+
const LV2_DEFAULT_DIRS = [".lv2", "/usr/lib/lv2", "/usr/lib64/lv2", "/usr/local/lib/lv2", "/usr/local/lib64/lv2"];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every `.lv2` bundle directly under the LV2 search path (`LV2_PATH`, else lilv's defaults). An
|
|
58
|
+
* absent directory holds none; any other fs error propagates for the caller to name.
|
|
59
|
+
*/
|
|
60
|
+
export function lv2BundlesOnPath(env: Readonly<Record<string, string | undefined>>, home: string = homedir()): string[] {
|
|
61
|
+
const named = (env["LV2_PATH"] ?? "").split(":").filter((d) => d !== "");
|
|
62
|
+
const dirs = named.length > 0 ? named : LV2_DEFAULT_DIRS.map((d) => (d.startsWith("/") ? d : join(home, d)));
|
|
63
|
+
const bundles: string[] = [];
|
|
64
|
+
for (const dir of new Set(dirs.map((d) => resolve(d)))) {
|
|
65
|
+
let names: string[];
|
|
66
|
+
try {
|
|
67
|
+
names = readdirSync(dir);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
70
|
+
if (code === "ENOENT" || code === "ENOTDIR") continue;
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
for (const n of names.sort()) if (n.endsWith(".lv2")) bundles.push(join(dir, n));
|
|
74
|
+
}
|
|
75
|
+
return bundles;
|
|
76
|
+
}
|
package/src/measure-host.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
3
|
/**
|
|
4
|
-
* Reading what the measuring host (`lv2_measure`) prints and supervising how long it may take
|
|
5
|
-
*
|
|
4
|
+
* Reading what the measuring host (`lv2_measure`) prints and supervising how long it may take.
|
|
5
|
+
* Pure functions: the runner spawns, these decide.
|
|
6
6
|
*/
|
|
7
7
|
import { isRecord } from "./measurement/guards.js";
|
|
8
8
|
import { SOAK_SWEEP_DB, SOAK_SWEEP_RATIO, SOAK_TIMEOUT_FLOOR_S, SOAK_TIMEOUT_MARGIN } from "./qualify-declarations.js";
|
package/src/measurement/cli.ts
CHANGED
|
@@ -79,8 +79,22 @@ export interface MeasureOptions {
|
|
|
79
79
|
readonly run: boolean;
|
|
80
80
|
/** Print the plan and probe nothing. */
|
|
81
81
|
readonly dryRun: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The LV2 measurer's feature-option flags (`--features`, `--strict`, ...), in the order given, passed
|
|
84
|
+
* to every run of the hosting pass. Absent or empty: the measurer's defaults.
|
|
85
|
+
*/
|
|
86
|
+
readonly featureFlags?: readonly string[];
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The words the LV2 measurer's feature-option flags take, as `native/lv2-measure-options.c` names
|
|
91
|
+
* them: the provider's kinds (`--features`), its buf-size declarations (`--block-declares`) and its log
|
|
92
|
+
* modes (`--log`). A conformance test reads both.
|
|
93
|
+
*/
|
|
94
|
+
export const LV2_FEATURE_KINDS = ["map", "unmap", "options", "bounded", "worker", "default-state", "log"] as const;
|
|
95
|
+
export const LV2_BLOCK_DECLARATIONS = ["bounded", "fixed", "pow2"] as const;
|
|
96
|
+
export const LV2_LOG_MODES = ["silent", "route", "capture"] as const;
|
|
97
|
+
|
|
84
98
|
/** One subprocess the pipeline runs, and what it leaves behind. */
|
|
85
99
|
export interface PipelineStep {
|
|
86
100
|
/** Stable id: `scan` / `latency-declared` / `latency-measured` / `cost` / `hosting`. */
|
|
@@ -205,6 +219,7 @@ export function buildPipeline(options: MeasureOptions, native?: HostingTools): P
|
|
|
205
219
|
String(LV2_HOSTING_BLOCK_FRAMES),
|
|
206
220
|
String(LV2_HOSTING_LIFECYCLES),
|
|
207
221
|
String(LV2_HOSTING_REPEATS),
|
|
222
|
+
...(options.featureFlags ?? []),
|
|
208
223
|
],
|
|
209
224
|
perPlugin: true,
|
|
210
225
|
env: { LD_PRELOAD: native.interposer, OMX_STIM: "noise" },
|
|
@@ -287,6 +302,32 @@ function parseRates(text: string): number[] | undefined {
|
|
|
287
302
|
/** What `--dimensions` takes: the prober's, and the hosting pass's. */
|
|
288
303
|
export const MEASURED_DIMENSIONS: readonly MeasurementDimension[] = [...PROBED_DIMENSIONS, ...HOSTING_PASS_DIMENSIONS];
|
|
289
304
|
|
|
305
|
+
/** Whether `text` is a comma-separated list of `words` (empty: none of them). */
|
|
306
|
+
function wordList(text: string, words: readonly string[]): boolean {
|
|
307
|
+
return text === "" || text.split(",").every((w) => words.includes(w));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A block length or a buffer size: a whole number of at least `min`. */
|
|
311
|
+
function wholeNumber(text: string, min: number): boolean {
|
|
312
|
+
return /^\d+$/.test(text) && Number(text) >= min && Number(text) <= 2 ** 31 - 1;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* What each feature-option flag takes: a check of its value, or `undefined` for a switch. The
|
|
317
|
+
* measurer checks the values again; these refuse a bad one before any plugin is run.
|
|
318
|
+
*/
|
|
319
|
+
const FEATURE_FLAGS: Readonly<Record<string, ((value: string) => boolean) | undefined>> = {
|
|
320
|
+
"--features": (v) => wordList(v, LV2_FEATURE_KINDS),
|
|
321
|
+
"--block-declares": (v) => wordList(v, LV2_BLOCK_DECLARATIONS),
|
|
322
|
+
"--block-min": (v) => wholeNumber(v, 0),
|
|
323
|
+
"--block-max": (v) => wholeNumber(v, 0),
|
|
324
|
+
"--block-nominal": (v) => wholeNumber(v, 0),
|
|
325
|
+
"--log": (v) => (LV2_LOG_MODES as readonly string[]).includes(v),
|
|
326
|
+
"--log-capture-bytes": (v) => wholeNumber(v, 2),
|
|
327
|
+
"--strict": undefined,
|
|
328
|
+
"--no-strict": undefined,
|
|
329
|
+
};
|
|
330
|
+
|
|
290
331
|
function parseDimensions(text: string): MeasurementDimension[] | undefined {
|
|
291
332
|
const wanted = text.split(",").map((part) => part.trim());
|
|
292
333
|
const dimensions = MEASURED_DIMENSIONS.filter((d) => wanted.includes(d));
|
|
@@ -320,6 +361,7 @@ export function parseMeasureArgs(
|
|
|
320
361
|
let python = env.PYTHON ?? "python3";
|
|
321
362
|
let run = env.LV2_MEASURE_RUN === "1";
|
|
322
363
|
let dryRun = false;
|
|
364
|
+
const featureFlags: string[] = [];
|
|
323
365
|
|
|
324
366
|
for (let i = 0; i < argv.length; i += 1) {
|
|
325
367
|
const flag = argv[i];
|
|
@@ -403,8 +445,19 @@ export function parseMeasureArgs(
|
|
|
403
445
|
case "--dry-run":
|
|
404
446
|
dryRun = true;
|
|
405
447
|
break;
|
|
406
|
-
default:
|
|
407
|
-
|
|
448
|
+
default: {
|
|
449
|
+
if (flag === undefined || !Object.hasOwn(FEATURE_FLAGS, flag)) {
|
|
450
|
+
return { ok: false, error: `unknown argument: ${String(flag)}` };
|
|
451
|
+
}
|
|
452
|
+
const check = FEATURE_FLAGS[flag];
|
|
453
|
+
if (check === undefined) {
|
|
454
|
+
featureFlags.push(flag);
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
const value = next();
|
|
458
|
+
if (value === undefined || !check(value)) return { ok: false, error: `${flag}: ${featureFlagUse(flag)}` };
|
|
459
|
+
featureFlags.push(flag, value);
|
|
460
|
+
}
|
|
408
461
|
}
|
|
409
462
|
}
|
|
410
463
|
|
|
@@ -414,6 +467,12 @@ export function parseMeasureArgs(
|
|
|
414
467
|
error: "--rates is required: a measurement with no stated rate is not a measurement",
|
|
415
468
|
};
|
|
416
469
|
}
|
|
470
|
+
if (featureFlags.length > 0 && !dimensions.some((d) => (HOSTING_PASS_DIMENSIONS as readonly string[]).includes(d))) {
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
error: `the feature options are the hosting pass's: ${featureFlags[0]} needs one of ${HOSTING_PASS_DIMENSIONS.join(",")}`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
417
476
|
if (plugins !== undefined && dimensions.some((d) => (PROBED_DIMENSIONS as readonly string[]).includes(d))) {
|
|
418
477
|
return {
|
|
419
478
|
ok: false,
|
|
@@ -436,10 +495,26 @@ export function parseMeasureArgs(
|
|
|
436
495
|
...(plugins === undefined ? {} : { plugins }),
|
|
437
496
|
...(costCpu === undefined ? {} : { costCpu }),
|
|
438
497
|
...(label === undefined ? {} : { label }),
|
|
498
|
+
...(featureFlags.length === 0 ? {} : { featureFlags }),
|
|
439
499
|
},
|
|
440
500
|
};
|
|
441
501
|
}
|
|
442
502
|
|
|
503
|
+
function featureFlagUse(flag: string): string {
|
|
504
|
+
switch (flag) {
|
|
505
|
+
case "--features":
|
|
506
|
+
return `a comma-separated list of ${LV2_FEATURE_KINDS.join(",")} (empty: none)`;
|
|
507
|
+
case "--block-declares":
|
|
508
|
+
return `a comma-separated list of ${LV2_BLOCK_DECLARATIONS.join(",")} (empty: none)`;
|
|
509
|
+
case "--log":
|
|
510
|
+
return `one of ${LV2_LOG_MODES.join(", ")}`;
|
|
511
|
+
case "--log-capture-bytes":
|
|
512
|
+
return "a size in bytes of at least 2";
|
|
513
|
+
default:
|
|
514
|
+
return "a number of frames, 0 for the run's block";
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
443
518
|
// ---------------------------------------------------------------------------
|
|
444
519
|
// Progress
|
|
445
520
|
// ---------------------------------------------------------------------------
|
|
@@ -464,7 +464,7 @@ export interface HostBudget {
|
|
|
464
464
|
}
|
|
465
465
|
|
|
466
466
|
/**
|
|
467
|
-
* A HOST PROFILE
|
|
467
|
+
* A HOST PROFILE: the host a verdict was judged
|
|
468
468
|
* against — only what differs between hosts. A document carries every profile its verdicts name,
|
|
469
469
|
* in full, so a verdict against a project's own profile is reproducible from the document alone.
|
|
470
470
|
*/
|
|
@@ -302,7 +302,7 @@ export function readDimension(value: unknown): MeasurementDimension | undefined
|
|
|
302
302
|
export const VERDICT_RATINGS = ["suitable", "conditional", "unknown", "unsuitable"] as const;
|
|
303
303
|
export type VerdictRating = (typeof VERDICT_RATINGS)[number];
|
|
304
304
|
|
|
305
|
-
/** How a host runs its plugins
|
|
305
|
+
/** How a host runs its plugins. */
|
|
306
306
|
export const HOST_ISOLATIONS = ["in-process", "per-process", "shared-process"] as const;
|
|
307
307
|
export type HostIsolation = (typeof HOST_ISOLATIONS)[number];
|
|
308
308
|
|