@openmixer/plugin-qualify 0.0.0-stage → 0.4.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/LICENSE +674 -0
- package/README.md +203 -2
- package/bin/plugin-qualify.mjs +77 -0
- package/dist/clap-loader.d.ts +147 -0
- package/dist/clap-loader.js +252 -0
- package/dist/clap-loader.js.map +1 -0
- package/dist/clap-qualify.d.ts +126 -0
- package/dist/clap-qualify.js +272 -0
- package/dist/clap-qualify.js.map +1 -0
- package/dist/host-profiles.d.ts +42 -0
- package/dist/host-profiles.js +106 -0
- package/dist/host-profiles.js.map +1 -0
- package/dist/hosting-codes.d.ts +62 -0
- package/dist/hosting-codes.js +72 -0
- package/dist/hosting-codes.js.map +1 -0
- package/dist/hosting-suitability.d.ts +362 -0
- package/dist/hosting-suitability.js +669 -0
- package/dist/hosting-suitability.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +26 -0
- package/dist/loader.js +9 -0
- package/dist/loader.js.map +1 -0
- package/dist/lv2-loader.d.ts +7 -0
- package/dist/lv2-loader.js +50 -0
- package/dist/lv2-loader.js.map +1 -0
- package/dist/measurement/cli.d.ts +168 -0
- package/dist/measurement/cli.js +353 -0
- package/dist/measurement/cli.js.map +1 -0
- package/dist/measurement/fixtures.d.ts +23 -0
- package/dist/measurement/fixtures.js +129 -0
- package/dist/measurement/fixtures.js.map +1 -0
- package/dist/measurement/format.d.ts +483 -0
- package/dist/measurement/format.js +627 -0
- package/dist/measurement/format.js.map +1 -0
- package/dist/measurement/guards.d.ts +33 -0
- package/dist/measurement/guards.js +61 -0
- package/dist/measurement/guards.js.map +1 -0
- package/dist/measurement/index.d.ts +36 -0
- package/dist/measurement/index.js +39 -0
- package/dist/measurement/index.js.map +1 -0
- package/dist/measurement/merge.d.ts +143 -0
- package/dist/measurement/merge.js +315 -0
- package/dist/measurement/merge.js.map +1 -0
- package/dist/measurement/plan.d.ts +127 -0
- package/dist/measurement/plan.js +141 -0
- package/dist/measurement/plan.js.map +1 -0
- package/dist/measurement/probe-json.d.ts +61 -0
- package/dist/measurement/probe-json.js +253 -0
- package/dist/measurement/probe-json.js.map +1 -0
- package/dist/measurement/provenance.d.ts +91 -0
- package/dist/measurement/provenance.js +173 -0
- package/dist/measurement/provenance.js.map +1 -0
- package/dist/measurement/sweep-jsonl.d.ts +40 -0
- package/dist/measurement/sweep-jsonl.js +154 -0
- package/dist/measurement/sweep-jsonl.js.map +1 -0
- package/dist/measurement/vocabulary.d.ts +125 -0
- package/dist/measurement/vocabulary.js +217 -0
- package/dist/measurement/vocabulary.js.map +1 -0
- package/dist/qualify-cli.d.ts +83 -0
- package/dist/qualify-cli.js +224 -0
- package/dist/qualify-cli.js.map +1 -0
- package/dist/qualify-declarations.d.ts +98 -0
- package/dist/qualify-declarations.js +76 -0
- package/dist/qualify-declarations.js.map +1 -0
- package/dist/qualify-options.d.ts +22 -0
- package/dist/qualify-options.js +36 -0
- package/dist/qualify-options.js.map +1 -0
- package/dist/rating.d.ts +32 -0
- package/dist/rating.js +30 -0
- package/dist/rating.js.map +1 -0
- package/dist/tool-paths.d.ts +12 -0
- package/dist/tool-paths.js +29 -0
- package/dist/tool-paths.js.map +1 -0
- package/fixtures/clap-scan/controls.json +8 -0
- package/fixtures/clap-scan/fake.json +16 -0
- package/fixtures/clap-scan/mixed.json +18 -0
- package/fixtures/zero-path.lv2/manifest.ttl +3 -0
- package/fixtures/zero-path.lv2/zero-path.c +85 -0
- package/fixtures/zero-path.lv2/zero-path.ttl +21 -0
- package/package.json +94 -4
- package/src/clap-loader.ts +393 -0
- package/src/clap-qualify.ts +387 -0
- package/src/host-profiles.ts +122 -0
- package/src/hosting-codes.ts +71 -0
- package/src/hosting-suitability.ts +998 -0
- package/src/index.ts +19 -0
- package/src/loader.ts +30 -0
- package/src/lv2-loader.ts +50 -0
- package/src/measurement/cli.ts +458 -0
- package/src/measurement/fixtures.ts +141 -0
- package/src/measurement/format.ts +1064 -0
- package/src/measurement/guards.ts +64 -0
- package/src/measurement/index.ts +39 -0
- package/src/measurement/merge.ts +434 -0
- package/src/measurement/plan.ts +219 -0
- package/src/measurement/probe-json.ts +271 -0
- package/src/measurement/provenance.ts +220 -0
- package/src/measurement/sweep-jsonl.ts +173 -0
- package/src/measurement/vocabulary.ts +298 -0
- package/src/qualify-cli.ts +277 -0
- package/src/qualify-declarations.ts +117 -0
- package/src/qualify-options.ts +49 -0
- package/src/rating.ts +53 -0
- package/src/tool-paths.ts +32 -0
- package/tools/_declarations.py +21 -0
- package/tools/benchmark.py +2462 -0
- package/tools/gen-python-declarations.d.mts +4 -0
- package/tools/gen-python-declarations.mjs +92 -0
- package/tools/hosting-sweep.mjs +51 -0
- package/tools/lv2-measure.mjs +204 -0
- package/tools/lv2_presets.py +132 -0
- package/tools/scan.py +432 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The CLAP arm of the one qualifier. `native/clap-qualify` loads a `.clap` under the interposer
|
|
5
|
+
* (`native/rt_interpose.so`), sweeps each plugin at every rate and prints what it MEASURED, one
|
|
6
|
+
* JSON line per plugin; this module runs it, reads those lines strictly, and turns each into the
|
|
7
|
+
* RT-safety verdict document: the binary's SHA-256, the console it was judged for, and the verdict
|
|
8
|
+
* of the `rtSafety` dimension from the same rules `classifyPluginHosting` folds
|
|
9
|
+
* ({@link rtSafetyReasons}, {@link clapRtReasons}) — never a second judge. A line that cannot be
|
|
10
|
+
* read, a prober that is absent, crashed or timed out, is a {@link ClapQualifyError}: nothing was
|
|
11
|
+
* measured, and nothing is rated.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
CLAP_QUALIFY_TIMEOUT_S,
|
|
18
|
+
CLAP_QUALIFY_TOOL_ENV,
|
|
19
|
+
CLAP_SWEEP_BLOCKS,
|
|
20
|
+
CLAP_SWEEP_FRAMES,
|
|
21
|
+
RT_INTERPOSE_ENV,
|
|
22
|
+
} from "./qualify-declarations.js";
|
|
23
|
+
import {
|
|
24
|
+
HOSTING_CODE,
|
|
25
|
+
clapRtReasons,
|
|
26
|
+
contributedHostingRating,
|
|
27
|
+
rtSafetyReasons,
|
|
28
|
+
type HostingReason,
|
|
29
|
+
type PluginClapMeasurement,
|
|
30
|
+
type PluginRtSafetyMeasurement,
|
|
31
|
+
} from "./hosting-suitability.js";
|
|
32
|
+
import { OPENMIXER_CONSOLE_PROFILE, type HostProfile } from "./host-profiles.js";
|
|
33
|
+
import type { PluginTarget } from "./loader.js";
|
|
34
|
+
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
35
|
+
import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
|
|
36
|
+
import { clapQualifyToolPath, rtInterposePath } from "./tool-paths.js";
|
|
37
|
+
|
|
38
|
+
export class ClapQualifyError extends Error {
|
|
39
|
+
constructor(message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "ClapQualifyError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- the prober's line ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** What the interposer counted; `interposer: false` is a run without it, which counts nothing. */
|
|
48
|
+
export interface ClapRtSafetyReading extends PluginRtSafetyMeasurement {
|
|
49
|
+
/** The first events the interposer recorded inside `process()`, by libc call (`malloc`, `pthread_mutex_lock`, ...). */
|
|
50
|
+
readonly events?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The CLAP facts as the prober printed them: the measurement, and whether any output was not finite. */
|
|
54
|
+
export interface ClapProbedFacts extends PluginClapMeasurement {
|
|
55
|
+
readonly nonFinite: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One plugin's line from `clap-qualify`. */
|
|
59
|
+
export interface ClapQualifyLine {
|
|
60
|
+
readonly uri: string;
|
|
61
|
+
readonly id: string;
|
|
62
|
+
readonly binary: { readonly path: string; readonly sha256: string };
|
|
63
|
+
/** The host extensions the prober offered; the verdict's headless pass holds only for these. */
|
|
64
|
+
readonly hostExtensions: readonly string[];
|
|
65
|
+
readonly clap: ClapProbedFacts;
|
|
66
|
+
readonly rtSafety: ClapRtSafetyReading;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
70
|
+
|
|
71
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
72
|
+
return Object.prototype.toString.call(v) === "[object Object]";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function bad(what: string): never {
|
|
76
|
+
throw new ClapQualifyError(`clap-qualify printed a line that is not its document: ${what}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function str(o: Record<string, unknown>, key: string, where: string): string {
|
|
80
|
+
const v = o[key];
|
|
81
|
+
return typeof v === "string" ? v : bad(`${where} has no string ${key}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function count(o: Record<string, unknown>, key: string, where: string): number {
|
|
85
|
+
const v = o[key];
|
|
86
|
+
return typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : bad(`${where} has no count ${key}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bool(o: Record<string, unknown>, key: string, where: string): boolean {
|
|
90
|
+
const v = o[key];
|
|
91
|
+
return typeof v === "boolean" ? v : bad(`${where} has no boolean ${key}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function strings(o: Record<string, unknown>, key: string, where: string): string[] {
|
|
95
|
+
const v = o[key];
|
|
96
|
+
if (!Array.isArray(v)) return bad(`${where} has no list ${key}`);
|
|
97
|
+
return v.map((s) => (typeof s === "string" ? s : bad(`${where} ${key} holds something that is not a string`)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readClap(v: unknown): ClapProbedFacts {
|
|
101
|
+
if (!isRecord(v)) return bad("no clap facts");
|
|
102
|
+
const latencyIn = v["latency"];
|
|
103
|
+
if (!isRecord(latencyIn)) return bad("clap has no latency map");
|
|
104
|
+
const latency: Record<string, { declaredFrames: number; measuredFrames: number }> = {};
|
|
105
|
+
for (const [rate, at] of Object.entries(latencyIn)) {
|
|
106
|
+
if (!/^[1-9]\d*$/.test(rate) || !isRecord(at)) return bad(`clap latency at ${rate}`);
|
|
107
|
+
latency[rate] = { declaredFrames: count(at, "declaredFrames", `latency ${rate}`), measuredFrames: count(at, "measuredFrames", `latency ${rate}`) };
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
mainInputChannels: count(v, "mainInputChannels", "clap"),
|
|
111
|
+
mainOutputChannels: count(v, "mainOutputChannels", "clap"),
|
|
112
|
+
sidechainInputs: count(v, "sidechainInputs", "clap"),
|
|
113
|
+
features: strings(v, "features", "clap"),
|
|
114
|
+
headlessOk: bool(v, "headlessOk", "clap"),
|
|
115
|
+
noteInputs: count(v, "noteInputs", "clap"),
|
|
116
|
+
latency,
|
|
117
|
+
threadViolations: count(v, "threadViolations", "clap"),
|
|
118
|
+
processErrors: count(v, "processErrors", "clap"),
|
|
119
|
+
processCalls: count(v, "processCalls", "clap"),
|
|
120
|
+
nonFinite: bool(v, "nonFinite", "clap"),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readRtSafety(v: unknown): ClapRtSafetyReading {
|
|
125
|
+
if (!isRecord(v)) return bad("no rtSafety");
|
|
126
|
+
if (!bool(v, "interposer", "rtSafety")) return { interposer: false };
|
|
127
|
+
return {
|
|
128
|
+
interposer: true,
|
|
129
|
+
swept: bool(v, "swept", "rtSafety"),
|
|
130
|
+
sweepApplicable: bool(v, "sweepApplicable", "rtSafety"),
|
|
131
|
+
blocks: count(v, "blocks", "rtSafety"),
|
|
132
|
+
blockFrames: count(v, "blockFrames", "rtSafety"),
|
|
133
|
+
allocationsInRun: count(v, "allocationsInRun", "rtSafety"),
|
|
134
|
+
locksInRun: count(v, "locksInRun", "rtSafety"),
|
|
135
|
+
syscallsInRun: count(v, "syscallsInRun", "rtSafety"),
|
|
136
|
+
events: strings(v, "events", "rtSafety"),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One line of the prober's stdout, or a {@link ClapQualifyError} saying how it differs. */
|
|
141
|
+
export function parseClapQualifyLine(text: string): ClapQualifyLine {
|
|
142
|
+
let v: unknown;
|
|
143
|
+
try {
|
|
144
|
+
v = JSON.parse(text);
|
|
145
|
+
} catch {
|
|
146
|
+
return bad("not JSON");
|
|
147
|
+
}
|
|
148
|
+
if (!isRecord(v)) return bad("not an object");
|
|
149
|
+
if (v["format"] !== "clap") return bad(`format is ${JSON.stringify(v["format"])}`);
|
|
150
|
+
const id = str(v, "id", "the line");
|
|
151
|
+
const uri = str(v, "uri", "the line");
|
|
152
|
+
if (uri !== `urn:clap:${id}`) return bad(`uri ${uri} is not urn:clap:${id}`);
|
|
153
|
+
const binary = v["binary"];
|
|
154
|
+
if (!isRecord(binary)) return bad("no binary");
|
|
155
|
+
const sha256 = str(binary, "sha256", "binary");
|
|
156
|
+
if (!SHA256.test(sha256)) return bad(`binary sha256 ${sha256} is not 64 lowercase hex digits`);
|
|
157
|
+
return {
|
|
158
|
+
uri,
|
|
159
|
+
id,
|
|
160
|
+
binary: { path: str(binary, "path", "binary"), sha256 },
|
|
161
|
+
hostExtensions: strings(v, "hostExtensions", "the line"),
|
|
162
|
+
clap: readClap(v["clap"]),
|
|
163
|
+
rtSafety: readRtSafety(v["rtSafety"]),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Every non-empty line of the prober's stdout. */
|
|
168
|
+
export function parseClapQualifyOutput(stdout: string): ClapQualifyLine[] {
|
|
169
|
+
return stdout.split("\n").filter((l) => l.trim() !== "").map(parseClapQualifyLine);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- the verdict document ------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/** The document's schema name; a reader refuses any other. */
|
|
175
|
+
export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/1";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The RT-safety verdict of one plugin binary for one console: keyed by the binary's SHA-256 and
|
|
179
|
+
* the host profile id, so a verdict never outlives the bytes it was measured on. `measured` is what
|
|
180
|
+
* the prober printed, raw; `verdict` is the `rtSafety` dimension's worst-wins fold of it.
|
|
181
|
+
*/
|
|
182
|
+
export interface RtSafetyDocument {
|
|
183
|
+
readonly schema: typeof RT_SAFETY_DOCUMENT_SCHEMA;
|
|
184
|
+
readonly plugin: {
|
|
185
|
+
readonly uri: string;
|
|
186
|
+
readonly format: "clap";
|
|
187
|
+
readonly binary: { readonly path: string; readonly sha256: string };
|
|
188
|
+
};
|
|
189
|
+
/** The host profile the verdict is for. */
|
|
190
|
+
readonly console: string;
|
|
191
|
+
readonly verdict: {
|
|
192
|
+
readonly rating: SuitabilityRating;
|
|
193
|
+
/** `rating === "suitable"` — in-process only with it. */
|
|
194
|
+
readonly rtSafe: boolean;
|
|
195
|
+
/** The ONE reason that decided it; absent when suitable. */
|
|
196
|
+
readonly deciding?: HostingReason;
|
|
197
|
+
readonly reasons: readonly HostingReason[];
|
|
198
|
+
};
|
|
199
|
+
readonly measured: {
|
|
200
|
+
readonly hostExtensions: readonly string[];
|
|
201
|
+
readonly clap: ClapProbedFacts;
|
|
202
|
+
readonly rtSafety: ClapRtSafetyReading;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The verdict document for one prober line under one host profile. The interposer's counts and the
|
|
208
|
+
* two violations only a CLAP commits decide it. The line is judged against the profile's own CLAP
|
|
209
|
+
* host extensions: a line from a prober that offered others is not that host's measurement, and a
|
|
210
|
+
* host with no CLAP path has none; both read `unknown`.
|
|
211
|
+
*/
|
|
212
|
+
export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = OPENMIXER_CONSOLE_PROFILE): RtSafetyDocument {
|
|
213
|
+
const offered = profile.clapExtensions;
|
|
214
|
+
const offeredAsProfile =
|
|
215
|
+
offered !== null && line.hostExtensions.length === offered.length && offered.every((e) => line.hostExtensions.includes(e));
|
|
216
|
+
const reasons: HostingReason[] =
|
|
217
|
+
offered === null
|
|
218
|
+
? [{ dimension: "rtSafety", kind: "objective", rating: "unknown", code: HOSTING_CODE.clapFormatNotHosted, params: {} }]
|
|
219
|
+
: offeredAsProfile
|
|
220
|
+
? [...rtSafetyReasons(line.rtSafety), ...clapRtReasons(line.clap)]
|
|
221
|
+
: [
|
|
222
|
+
{
|
|
223
|
+
dimension: "rtSafety",
|
|
224
|
+
kind: "objective",
|
|
225
|
+
rating: "unknown",
|
|
226
|
+
code: HOSTING_CODE.clapExtensionsNotCovered,
|
|
227
|
+
params: { extensions: line.hostExtensions.join(" ") },
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
const rating = worstRating(reasons.map(contributedHostingRating));
|
|
231
|
+
const deciding = [...reasons]
|
|
232
|
+
.sort((a, b) => RATING_SEVERITY[contributedHostingRating(b)] - RATING_SEVERITY[contributedHostingRating(a)])
|
|
233
|
+
.find((r) => contributedHostingRating(r) === rating);
|
|
234
|
+
return {
|
|
235
|
+
schema: RT_SAFETY_DOCUMENT_SCHEMA,
|
|
236
|
+
plugin: { uri: line.uri, format: "clap", binary: line.binary },
|
|
237
|
+
console: profile.id,
|
|
238
|
+
verdict: { rating, rtSafe: rating === "suitable", ...(rating === "suitable" || deciding === undefined ? {} : { deciding }), reasons },
|
|
239
|
+
measured: { hostExtensions: line.hostExtensions, clap: line.clap, rtSafety: line.rtSafety },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The document as the CLI's verdict row. */
|
|
244
|
+
export function rtSafetyVerdict(doc: RtSafetyDocument): QualifyVerdict {
|
|
245
|
+
return {
|
|
246
|
+
uri: doc.plugin.uri,
|
|
247
|
+
host: doc.console,
|
|
248
|
+
measured: true,
|
|
249
|
+
rating: doc.verdict.rating,
|
|
250
|
+
qualified: doc.verdict.rtSafe,
|
|
251
|
+
...(doc.verdict.deciding === undefined ? {} : { deciding: doc.verdict.deciding.code }),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Where a document is written in `--out`: one file per binary and console. */
|
|
256
|
+
export function rtSafetyDocumentName(doc: RtSafetyDocument): string {
|
|
257
|
+
return `${doc.plugin.binary.sha256}.${doc.console}.rt-safety.json`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- running the prober --------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/** What a run of the prober gave back; `error` is a spawn failure, `signal` a death. */
|
|
263
|
+
export interface ClapQualifyRunResult {
|
|
264
|
+
readonly status: number | null;
|
|
265
|
+
readonly signal?: string | null;
|
|
266
|
+
readonly stdout: string;
|
|
267
|
+
readonly stderr?: string;
|
|
268
|
+
readonly error?: Error;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export type ClapQualifyRun = (
|
|
272
|
+
tool: string,
|
|
273
|
+
args: readonly string[],
|
|
274
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
275
|
+
timeoutMs: number,
|
|
276
|
+
) => ClapQualifyRunResult;
|
|
277
|
+
|
|
278
|
+
const spawnQualify: ClapQualifyRun = (tool, args, env, timeoutMs) => {
|
|
279
|
+
const r = spawnSync(tool, [...args], { encoding: "utf8", env: { ...env }, timeout: timeoutMs, maxBuffer: 64 << 20 });
|
|
280
|
+
return {
|
|
281
|
+
status: r.status,
|
|
282
|
+
signal: r.signal,
|
|
283
|
+
stdout: r.stdout ?? "",
|
|
284
|
+
stderr: r.stderr ?? "",
|
|
285
|
+
...(r.error === undefined ? {} : { error: r.error }),
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export interface ClapQualifyOptions {
|
|
290
|
+
/** The rates the plugin is activated and swept at. */
|
|
291
|
+
readonly rates: readonly number[];
|
|
292
|
+
/** Runs the prober; the default spawns it. */
|
|
293
|
+
readonly run?: ClapQualifyRun;
|
|
294
|
+
/** Where the two settings are read from; the default is `process.env`. */
|
|
295
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
296
|
+
/** Whether a file exists; the default asks the filesystem. */
|
|
297
|
+
readonly exists?: (path: string) => boolean;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function setting(env: Readonly<Record<string, string | undefined>>, name: string, fallback: string): string {
|
|
301
|
+
const v = env[name];
|
|
302
|
+
return v === undefined || v === "" ? fallback : v;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The prober and the interposer this run uses: each its setting, else the package's own build. */
|
|
306
|
+
export function clapQualifyTools(env: Readonly<Record<string, string | undefined>>): { tool: string; interposer: string } {
|
|
307
|
+
return { tool: setting(env, CLAP_QUALIFY_TOOL_ENV, clapQualifyToolPath()), interposer: setting(env, RT_INTERPOSE_ENV, rtInterposePath()) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Qualify the plugins of one `.clap` (all of them, or the one `id` names) under the interposer.
|
|
312
|
+
* Absent tools, a crash, a timeout, a load failure or an unreadable line throw: none of them is a
|
|
313
|
+
* measurement.
|
|
314
|
+
*/
|
|
315
|
+
export function qualifyClap(binaryPath: string, id: string | undefined, options: ClapQualifyOptions): ClapQualifyLine[] {
|
|
316
|
+
const env = options.env ?? process.env;
|
|
317
|
+
const exists = options.exists ?? existsSync;
|
|
318
|
+
const { tool, interposer } = clapQualifyTools(env);
|
|
319
|
+
for (const [what, path, name] of [["prober", tool, CLAP_QUALIFY_TOOL_ENV], ["interposer", interposer, RT_INTERPOSE_ENV]] as const) {
|
|
320
|
+
if (!exists(path)) throw new ClapQualifyError(`no CLAP ${what} at ${path}: build it with make -C native, or set ${name}`);
|
|
321
|
+
}
|
|
322
|
+
const args = [
|
|
323
|
+
"--rates",
|
|
324
|
+
options.rates.join(","),
|
|
325
|
+
"--block",
|
|
326
|
+
String(CLAP_SWEEP_FRAMES),
|
|
327
|
+
"--blocks",
|
|
328
|
+
String(CLAP_SWEEP_BLOCKS),
|
|
329
|
+
binaryPath,
|
|
330
|
+
...(id === undefined ? [] : [id]),
|
|
331
|
+
];
|
|
332
|
+
const r = (options.run ?? spawnQualify)(tool, args, { ...env, LD_PRELOAD: interposer }, CLAP_QUALIFY_TIMEOUT_S * 1000);
|
|
333
|
+
if (r.error !== undefined) throw new ClapQualifyError(`cannot run ${tool}: ${r.error.message}`);
|
|
334
|
+
if (r.signal) throw new ClapQualifyError(`${binaryPath} killed the prober (${r.signal})`);
|
|
335
|
+
if (r.status !== 0) throw new ClapQualifyError(`${tool} exited ${String(r.status)} on ${binaryPath}: ${(r.stderr ?? "").trim()}`);
|
|
336
|
+
const lines = parseClapQualifyOutput(r.stdout);
|
|
337
|
+
if (lines.length === 0) throw new ClapQualifyError(`${tool} printed nothing for ${binaryPath}`);
|
|
338
|
+
return lines;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// --- the CLI's CLAP pass -------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/** What the CLAP pass of one run produced: a document per plugin and console, and a row per verdict. */
|
|
344
|
+
export interface ClapQualifyPass {
|
|
345
|
+
readonly documents: readonly RtSafetyDocument[];
|
|
346
|
+
readonly verdicts: readonly QualifyVerdict[];
|
|
347
|
+
/** Each target that could not be measured, with why; its verdict rows read `unknown`, unmeasured. */
|
|
348
|
+
readonly failures: readonly { readonly uri: string; readonly problem: string }[];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Qualify every CLAP target, one prober run per target (a crash takes only that plugin), and judge
|
|
353
|
+
* each line under every host profile. A target without a binary path (a bare `urn:clap:` id) has
|
|
354
|
+
* nothing to load and is a failure, never a pass.
|
|
355
|
+
*/
|
|
356
|
+
export function qualifyClapTargets(
|
|
357
|
+
targets: readonly PluginTarget[],
|
|
358
|
+
options: ClapQualifyOptions & { readonly profiles?: readonly HostProfile[] },
|
|
359
|
+
): ClapQualifyPass {
|
|
360
|
+
const profiles = options.profiles ?? [OPENMIXER_CONSOLE_PROFILE];
|
|
361
|
+
const documents: RtSafetyDocument[] = [];
|
|
362
|
+
const verdicts: QualifyVerdict[] = [];
|
|
363
|
+
const failures: { uri: string; problem: string }[] = [];
|
|
364
|
+
for (const target of targets) {
|
|
365
|
+
const id = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : undefined;
|
|
366
|
+
let lines: ClapQualifyLine[];
|
|
367
|
+
try {
|
|
368
|
+
if (target.binaryPath === undefined) throw new ClapQualifyError(`${target.uri}: no .clap to load; name the file or a directory holding it`);
|
|
369
|
+
lines = qualifyClap(target.binaryPath, id, options);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
if (!(e instanceof ClapQualifyError)) throw e;
|
|
372
|
+
failures.push({ uri: target.uri, problem: e.message });
|
|
373
|
+
for (const profile of profiles) {
|
|
374
|
+
verdicts.push({ uri: target.uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: "not-measured" });
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
for (const line of lines) {
|
|
379
|
+
for (const profile of profiles) {
|
|
380
|
+
const doc = rtSafetyDocument(line, profile);
|
|
381
|
+
documents.push(doc);
|
|
382
|
+
verdicts.push(rtSafetyVerdict(doc));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { documents, verdicts, failures };
|
|
387
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* HOST PROFILES — the host a verdict is judged against, declared. The measurements are profile-free; `classifyPluginHosting` reads one of these to say
|
|
5
|
+
* whether THAT host can run the plugin. A profile names only what differs between hosts; every
|
|
6
|
+
* other rule is the one classifier's.
|
|
7
|
+
*/
|
|
8
|
+
import { HOST_BUDGETS } from "./qualify-declarations.js";
|
|
9
|
+
|
|
10
|
+
// The profile's shape and its reader belong to the interchange (a document carries its
|
|
11
|
+
// profiles, §3b), whose directory imports nothing from outside itself; they are re-exported here.
|
|
12
|
+
export {
|
|
13
|
+
HOST_ISOLATIONS,
|
|
14
|
+
HOST_LATENCY_HANDLING,
|
|
15
|
+
type HostIsolation,
|
|
16
|
+
type HostLatencyHandling,
|
|
17
|
+
} from "./measurement/vocabulary.js";
|
|
18
|
+
export { readHostProfile, type HostProfile, type HostProfileRead } from "./measurement/format.js";
|
|
19
|
+
import type { HostProfile } from "./measurement/format.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The LV2 features the in-process stage provides — the ONE list (the console's host-backend contract).
|
|
23
|
+
* The verdict judges `required ⊆ provided` against it (as the `openmixer-console` profile), and
|
|
24
|
+
* the console hands the same array to the in-process LV2 table at `omx_host_backend_configure`,
|
|
25
|
+
* which builds one provider per URI and refuses a URI it cannot provide; there is no C copy (the
|
|
26
|
+
* console's native test reads this export).
|
|
27
|
+
*/
|
|
28
|
+
export const OMX_INPROCESS_FEATURES: readonly string[] = [
|
|
29
|
+
"http://lv2plug.in/ns/ext/urid#map",
|
|
30
|
+
"http://lv2plug.in/ns/ext/urid#unmap",
|
|
31
|
+
"http://lv2plug.in/ns/ext/options#options",
|
|
32
|
+
"http://lv2plug.in/ns/ext/buf-size#boundedBlockLength",
|
|
33
|
+
"http://lv2plug.in/ns/ext/worker#schedule",
|
|
34
|
+
"http://lv2plug.in/ns/ext/state#loadDefaultState",
|
|
35
|
+
"http://lv2plug.in/ns/ext/log#log",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The CLAP host object's extensions — what the console's control-thread host provides to every
|
|
40
|
+
* instance it hosts (CLAP hosting spec §5), and ALL the qualifier offers, so a plugin that needs
|
|
41
|
+
* more fails `init` headless in qualification and reads `hosting.clap.headless-failed` rather
|
|
42
|
+
* than on the desk. The list's home is the CLAP library — libomx-clap-core's own
|
|
43
|
+
* `clap_host_extensions.h`, what its host object offers — and this is its mirror, held equal to the
|
|
44
|
+
* header at the pinned omx-clap-host (by the console's conformance test), so the verdict never
|
|
45
|
+
* promises more or less than the host provides. Nothing else: no `gui`, no `timer-support`, no
|
|
46
|
+
* `posix-fd-support`, no `thread-pool`.
|
|
47
|
+
*/
|
|
48
|
+
export const OMX_CLAP_HOST_EXTENSIONS: readonly string[] = [
|
|
49
|
+
"clap.log",
|
|
50
|
+
"clap.thread-check",
|
|
51
|
+
"clap.latency",
|
|
52
|
+
"clap.params",
|
|
53
|
+
"clap.audio-ports",
|
|
54
|
+
"clap.state",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** The LV2 extension namespace the jalv and mod-host lists are spelled in. */
|
|
58
|
+
const LV2_EXT = "http://lv2plug.in/ns/ext/";
|
|
59
|
+
const BUF_SIZE_ALL = ["powerOf2BlockLength", "fixedBlockLength", "boundedBlockLength"].map((f) => `${LV2_EXT}buf-size#${f}`);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* jalv 1.6.8 `src/jalv.c` 123–127 + 1316–1325: the list passed to `lilv_plugin_instantiate`
|
|
63
|
+
* (the zynthian fork, 1.6.9, `src/jalv.c` 1371–1380, passes the identical list).
|
|
64
|
+
*/
|
|
65
|
+
export const JALV_LV2_FEATURES: readonly string[] = [
|
|
66
|
+
...["urid#map", "urid#unmap", "worker#schedule", "log#log", "options#options", "state#loadDefaultState"].map((f) => LV2_EXT + f),
|
|
67
|
+
...BUF_SIZE_ALL,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* mod-host f14a230 `src/effects.c` 254–275 + 2980–2995, without the `__MOD_DEVICES__`-only HMI
|
|
72
|
+
* feature: every feature `effects_add` hands a plugin, in its enum order.
|
|
73
|
+
*/
|
|
74
|
+
export const MOD_HOST_LV2_FEATURES: readonly string[] = [
|
|
75
|
+
...["uri-map", "urid#map", "urid#unmap", "options#options"].map((f) => LV2_EXT + f),
|
|
76
|
+
"http://moddevices.com/ns/ext/license#feature",
|
|
77
|
+
...BUF_SIZE_ALL,
|
|
78
|
+
...["log#log", "state#freePath", "state#makePath"].map((f) => LV2_EXT + f),
|
|
79
|
+
"http://kx.studio/ns/lv2ext/control-input-port-change-request",
|
|
80
|
+
`${LV2_EXT}worker#schedule`,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** The console's RT thread — exactly the verdict before profiles existed, and the default. */
|
|
84
|
+
export const OPENMIXER_CONSOLE_PROFILE: HostProfile = {
|
|
85
|
+
id: "openmixer-console",
|
|
86
|
+
lv2Features: OMX_INPROCESS_FEATURES,
|
|
87
|
+
clapExtensions: OMX_CLAP_HOST_EXTENSIONS,
|
|
88
|
+
instruments: false,
|
|
89
|
+
cost: HOST_BUDGETS["openmixer-console"],
|
|
90
|
+
latency: "compensated",
|
|
91
|
+
isolation: "in-process",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** zynthian's host: one jalv process per plugin, instruments fed, latency published to JACK. */
|
|
95
|
+
export const JALV_PROFILE: HostProfile = {
|
|
96
|
+
id: "jalv",
|
|
97
|
+
lv2Features: JALV_LV2_FEATURES,
|
|
98
|
+
clapExtensions: null,
|
|
99
|
+
instruments: true,
|
|
100
|
+
cost: HOST_BUDGETS.jalv,
|
|
101
|
+
latency: "uncompensated",
|
|
102
|
+
isolation: "per-process",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** MOD's host: every plugin of a pedalboard in one shared mod-host process. */
|
|
106
|
+
export const MOD_HOST_PROFILE: HostProfile = {
|
|
107
|
+
id: "mod-host",
|
|
108
|
+
lv2Features: MOD_HOST_LV2_FEATURES,
|
|
109
|
+
clapExtensions: null,
|
|
110
|
+
instruments: true,
|
|
111
|
+
cost: HOST_BUDGETS["mod-host"],
|
|
112
|
+
latency: "uncompensated",
|
|
113
|
+
isolation: "shared-process",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Every profile the tool ships, by id. */
|
|
117
|
+
export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE];
|
|
118
|
+
|
|
119
|
+
/** A shipped profile by id, or `undefined`. */
|
|
120
|
+
export function shippedHostProfile(id: string): HostProfile | undefined {
|
|
121
|
+
return HOST_PROFILES.find((p) => p.id === id);
|
|
122
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The hosting verdict's message codes (2026-09-04-lv2-hosting-path.md). Codes, never prose: the
|
|
5
|
+
* surface renders the operator's locale.
|
|
6
|
+
*
|
|
7
|
+
* A UNION list file (2026-09-15-no-stored-derivations.md §9): `.gitattributes` marks it
|
|
8
|
+
* `merge=union`, so two lanes that each add a code merge with both kept. Only the table lives
|
|
9
|
+
* here, one `key: 'code',` per line, with keys and codes unique; the registry-hotspots gate
|
|
10
|
+
* refuses anything else.
|
|
11
|
+
*/
|
|
12
|
+
export const HOSTING_CODE = {
|
|
13
|
+
stabilitySoaked: 'hosting.stability.soaked',
|
|
14
|
+
stabilityCrashedAttributed: 'hosting.stability.crashed-attributed-plugin',
|
|
15
|
+
stabilityCrashedLive: 'hosting.stability.crashed-live-on-this-rig',
|
|
16
|
+
stabilityBelowFloor: 'hosting.stability.cycles-below-floor',
|
|
17
|
+
stabilityUnsoaked: 'hosting.stability.unsoaked',
|
|
18
|
+
stabilityUnmeasured: 'hosting.stability.unmeasured',
|
|
19
|
+
stabilityCrashUnattributed: 'hosting.stability.crash-unattributed',
|
|
20
|
+
stabilitySpawnsThreads: 'hosting.stability.spawns-threads',
|
|
21
|
+
stabilityLinksGuiToolkit: 'hosting.stability.links-gui-toolkit',
|
|
22
|
+
rtSafetyClean: 'hosting.rt-safety.clean-under-sweep',
|
|
23
|
+
rtSafetyViolations: 'hosting.rt-safety.violations-in-run',
|
|
24
|
+
rtSafetyUnmeasured: 'hosting.rt-safety.unmeasured',
|
|
25
|
+
rtSafetyNotReproducible: 'hosting.rt-safety.not-reproducible',
|
|
26
|
+
featuresProvided: 'hosting.features.all-provided',
|
|
27
|
+
featuresMissing: 'hosting.features.required-not-provided',
|
|
28
|
+
featuresDisqualifying: 'hosting.features.host-not-provided',
|
|
29
|
+
featuresCv: 'hosting.features.cv-ports',
|
|
30
|
+
featuresMidiIn: 'hosting.features.midi-in-fed-empty',
|
|
31
|
+
featuresUnscanned: 'hosting.features.scan-predates-field',
|
|
32
|
+
costWithinCeiling: 'hosting.cost.within-ceiling',
|
|
33
|
+
costAboveCeiling: 'hosting.cost.above-ceiling',
|
|
34
|
+
costSpiky: 'hosting.cost.spiky-tail',
|
|
35
|
+
costUnmeasured: 'hosting.cost.unmeasured-at-rate-and-quantum',
|
|
36
|
+
latencyResolved: 'hosting.latency.resolved',
|
|
37
|
+
latencyUnmeasured: 'hosting.latency.unmeasured',
|
|
38
|
+
latencyNonconforming: 'hosting.latency.nonconforming',
|
|
39
|
+
latencyParameterDependent: 'hosting.latency.parameter-dependent',
|
|
40
|
+
topologyMatched: 'hosting.topology.matched',
|
|
41
|
+
topologyNoAudioInput: 'hosting.topology.no-audio-input',
|
|
42
|
+
topologyNoAudioOutput: 'hosting.topology.no-audio-output',
|
|
43
|
+
topologyExtraInputs: 'hosting.topology.extra-inputs-fed-silence',
|
|
44
|
+
topologyInstrument: 'hosting.topology.instrument',
|
|
45
|
+
// The CLAP arm (2026-09-26-clap-hosting-path.md §3): the SAME dimensions fed from CLAP facts,
|
|
46
|
+
// minted here where the LV2 table has no code for the fact.
|
|
47
|
+
topologyWiderThanStrip: 'hosting.topology.wider-than-strip',
|
|
48
|
+
clapLatencyMismatch: 'hosting.clap.latency-mismatch',
|
|
49
|
+
clapNotAudioEffect: 'hosting.clap.not-audio-effect',
|
|
50
|
+
clapHeadlessFailed: 'hosting.clap.headless-failed',
|
|
51
|
+
clapNoteInput: 'hosting.clap.note-input',
|
|
52
|
+
clapThreadViolation: 'hosting.clap.thread-violation',
|
|
53
|
+
clapProcessError: 'hosting.clap.process-error',
|
|
54
|
+
clapUnqualified: 'hosting.clap.unqualified',
|
|
55
|
+
clapFormatNotHosted: 'hosting.clap.format-not-hosted',
|
|
56
|
+
clapExtensionsNotCovered: 'hosting.clap.extensions-not-covered',
|
|
57
|
+
outputNonFinite: 'hosting.output.non-finite',
|
|
58
|
+
overrideIsolated: 'hosting.override.curator-isolated',
|
|
59
|
+
hostProvenanceDiffers: 'hosting.provenance.host-differs',
|
|
60
|
+
sweepControlDidNotFire: 'hosting.provenance.sweep-control-did-not-fire',
|
|
61
|
+
// The five REALISED reasons. Everything above answers what a plugin EARNED; these answer
|
|
62
|
+
// what the console DID with that, and the difference between the two is the operator's
|
|
63
|
+
// whole question when a slot is not on the fast path.
|
|
64
|
+
disabled: 'hosting.disabled',
|
|
65
|
+
unjudged: 'hosting.unjudged',
|
|
66
|
+
noRealisation: 'hosting.no-realisation',
|
|
67
|
+
realisedInProcess: 'hosting.realised.in-process',
|
|
68
|
+
// A plugin LINKED into the engine (the static-link registry) runs in-process without a
|
|
69
|
+
// verdict: the library's oracles are its proof (`2026-09-26-clap-hosting-path.md` §7).
|
|
70
|
+
linked: 'hosting.linked',
|
|
71
|
+
} as const;
|