@openmixer/plugin-qualify 0.10.1 → 0.12.0
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 +52 -10
- package/bin/plugin-qualify.mjs +61 -28
- package/dist/clap-loader.d.ts +1 -1
- package/dist/clap-loader.js +3 -3
- package/dist/clap-loader.js.map +1 -1
- package/dist/clap-qualify.d.ts +60 -51
- package/dist/clap-qualify.js +143 -91
- package/dist/clap-qualify.js.map +1 -1
- package/dist/cost-fields.d.ts +26 -0
- package/dist/cost-fields.js +30 -0
- package/dist/cost-fields.js.map +1 -1
- package/dist/hosting-codes.d.ts +1 -0
- package/dist/hosting-codes.js +1 -0
- package/dist/hosting-codes.js.map +1 -1
- package/dist/hosting-suitability.d.ts +22 -3
- package/dist/hosting-suitability.js +25 -5
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/judge-cli.d.ts +41 -0
- package/dist/judge-cli.js +114 -0
- package/dist/judge-cli.js.map +1 -0
- package/dist/measurement/cli.d.ts +7 -0
- package/dist/measurement/cli.js +14 -0
- package/dist/measurement/cli.js.map +1 -1
- package/dist/measurement/format.d.ts +44 -4
- package/dist/measurement/format.js +46 -3
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/probe-json.js +4 -0
- package/dist/measurement/probe-json.js.map +1 -1
- package/dist/measurement/sweep-jsonl.js +2 -1
- package/dist/measurement/sweep-jsonl.js.map +1 -1
- package/dist/measurement/vocabulary.d.ts +9 -0
- package/dist/measurement/vocabulary.js +33 -0
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/probe-judge.d.ts +65 -0
- package/dist/probe-judge.js +217 -0
- package/dist/probe-judge.js.map +1 -0
- package/dist/probe-output-fixtures.d.ts +25 -0
- package/dist/probe-output-fixtures.js +134 -0
- package/dist/probe-output-fixtures.js.map +1 -0
- package/dist/probe-output.d.ts +153 -0
- package/dist/probe-output.js +374 -0
- package/dist/probe-output.js.map +1 -0
- package/dist/probe-runner.d.ts +79 -0
- package/dist/probe-runner.js +191 -0
- package/dist/probe-runner.js.map +1 -0
- package/dist/qualification-record.d.ts +82 -0
- package/dist/qualification-record.js +85 -0
- package/dist/qualification-record.js.map +1 -0
- package/dist/qualify-cli.js +1 -1
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +11 -2
- package/dist/qualify-declarations.js +14 -4
- package/dist/qualify-declarations.js.map +1 -1
- package/fixtures/clap-scan/fake.json +30 -6
- package/fixtures/probe-output/memory-limit-refused.json +190 -0
- package/fixtures/probe-output/plugin-crash.json +236 -0
- package/fixtures/probe-output/success-msi.json +363 -0
- package/package.json +5 -1
- package/src/clap-loader.ts +3 -2
- package/src/clap-qualify.ts +179 -138
- package/src/cost-fields.ts +39 -0
- package/src/hosting-codes.ts +1 -0
- package/src/hosting-suitability.ts +42 -7
- package/src/index.ts +5 -0
- package/src/judge-cli.ts +133 -0
- package/src/measurement/cli.ts +20 -0
- package/src/measurement/format.ts +80 -2
- package/src/measurement/probe-json.ts +4 -0
- package/src/measurement/sweep-jsonl.ts +2 -1
- package/src/measurement/vocabulary.ts +38 -0
- package/src/probe-judge.ts +272 -0
- package/src/probe-output-fixtures.ts +146 -0
- package/src/probe-output.ts +511 -0
- package/src/probe-runner.ts +244 -0
- package/src/qualification-record.ts +149 -0
- package/src/qualify-cli.ts +1 -1
- package/src/qualify-declarations.ts +16 -4
- package/tools/benchmark.py +116 -13
- package/tools/lv2-measure.mjs +2 -0
package/src/clap-qualify.ts
CHANGED
|
@@ -10,26 +10,22 @@
|
|
|
10
10
|
* read, a prober that is absent, crashed or timed out, is a {@link ClapQualifyError}: nothing was
|
|
11
11
|
* measured, and nothing is rated.
|
|
12
12
|
*/
|
|
13
|
-
import { spawnSync } from "node:child_process";
|
|
14
13
|
import { existsSync } from "node:fs";
|
|
15
14
|
|
|
16
|
-
import {
|
|
17
|
-
CLAP_QUALIFY_TIMEOUT_S,
|
|
18
|
-
CLAP_QUALIFY_TOOL_NAME,
|
|
19
|
-
CLAP_SWEEP_BLOCKS,
|
|
20
|
-
CLAP_SWEEP_FRAMES,
|
|
21
|
-
RT_INTERPOSE_NAME,
|
|
22
|
-
} from "./qualify-declarations.js";
|
|
15
|
+
import { CLAP_QUALIFY_TOOL_NAME, RT_INTERPOSE_NAME } from "./qualify-declarations.js";
|
|
23
16
|
import {
|
|
24
17
|
HOSTING_CODE,
|
|
25
18
|
clapRtReasons,
|
|
26
19
|
contributedHostingRating,
|
|
27
20
|
noteSweepReasons,
|
|
28
21
|
rtSafetyReasons,
|
|
22
|
+
type ClapLatencyAtRate,
|
|
29
23
|
type HostingReason,
|
|
30
24
|
type PluginClapMeasurement,
|
|
31
25
|
type PluginNoteSweepMeasurement,
|
|
32
26
|
type PluginRtSafetyMeasurement,
|
|
27
|
+
type PluginStabilityMeasurement,
|
|
28
|
+
type QualifiedPlugin,
|
|
33
29
|
} from "./hosting-suitability.js";
|
|
34
30
|
import {
|
|
35
31
|
OMX_CLAP_HOST_CONFIGURED_EXTENSIONS,
|
|
@@ -37,11 +33,12 @@ import {
|
|
|
37
33
|
OPENMIXER_CONSOLE_PROFILE,
|
|
38
34
|
type HostProfile,
|
|
39
35
|
} from "./host-profiles.js";
|
|
40
|
-
import type { PluginTarget } from "./loader.js";
|
|
41
36
|
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
42
37
|
import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
|
|
43
38
|
import { halfPopulatedRefusal, locateNativeTools } from "./tool-paths.js";
|
|
44
|
-
import { readIntegrity, type IntegrityMeasurement } from "./measurement/format.js";
|
|
39
|
+
import { readIntegrity, type IntegrityMeasurement, type VoicesHeld } from "./measurement/format.js";
|
|
40
|
+
import { isVoiceBasis } from "./measurement/vocabulary.js";
|
|
41
|
+
import { coreFraction } from "./cost-fields.js";
|
|
45
42
|
|
|
46
43
|
export class ClapQualifyError extends Error {
|
|
47
44
|
constructor(message: string) {
|
|
@@ -69,6 +66,45 @@ export interface ClapProbedFacts extends PluginClapMeasurement {
|
|
|
69
66
|
readonly nonFinite: boolean;
|
|
70
67
|
}
|
|
71
68
|
|
|
69
|
+
/** One rate's cost: the block times in nanoseconds per sample, warm-up dropped, and the notes held for an instrument. */
|
|
70
|
+
export interface ClapCostFigure {
|
|
71
|
+
readonly nsPerSampleMedian: number;
|
|
72
|
+
readonly nsPerSampleP95: number;
|
|
73
|
+
readonly nsPerSampleMin: number;
|
|
74
|
+
readonly nsPerSampleMax: number;
|
|
75
|
+
/** The median of the dropped warm-up blocks: the evidence that dropping them was warranted. */
|
|
76
|
+
readonly warmupNsPerSampleMedian: number;
|
|
77
|
+
readonly blocks: number;
|
|
78
|
+
/** For an instrument: the notes held while it was timed. Absent for an effect. */
|
|
79
|
+
readonly voices?: VoicesHeld;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One rate the cost arm did not time, and why; an instrument's declared capacity is kept when it has one. */
|
|
83
|
+
export interface ClapCostUnmeasured {
|
|
84
|
+
readonly unmeasuredReason: string;
|
|
85
|
+
readonly unmeasuredDetail: string;
|
|
86
|
+
readonly voices?: { readonly capacity: number };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type ClapCostAtRate = ClapCostFigure | ClapCostUnmeasured;
|
|
90
|
+
|
|
91
|
+
export function isClapCostFigure(at: ClapCostAtRate): at is ClapCostFigure {
|
|
92
|
+
return !("unmeasuredReason" in at);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The cost arm: every rate timed with every parameter swept, an effect fed noise and an instrument
|
|
97
|
+
* holding notes (`notes-held`), each rate's figures or the reason it has none.
|
|
98
|
+
*/
|
|
99
|
+
export interface ClapCostReading {
|
|
100
|
+
readonly stimulus: "notes-held" | "noise";
|
|
101
|
+
readonly blockFrames: number;
|
|
102
|
+
readonly warmupBlocks: number;
|
|
103
|
+
/** The percentile `nsPerSampleP95` is taken at. */
|
|
104
|
+
readonly percentile: number;
|
|
105
|
+
readonly perRate: Readonly<Record<string, ClapCostAtRate>>;
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
/** One plugin's line from `clap-qualify`. */
|
|
73
109
|
export interface ClapQualifyLine {
|
|
74
110
|
readonly uri: string;
|
|
@@ -80,6 +116,8 @@ export interface ClapQualifyLine {
|
|
|
80
116
|
readonly rtSafety: ClapRtSafetyReading;
|
|
81
117
|
/** The note sweep: the plugin played through its note input, counted apart. */
|
|
82
118
|
readonly noteSweep: ClapNoteSweepReading;
|
|
119
|
+
/** The cost arm; absent when the plugin did not initialise and activate headless. */
|
|
120
|
+
readonly cost?: ClapCostReading;
|
|
83
121
|
/**
|
|
84
122
|
* The pin (spec §11): the `.clap`'s digest by its name in its directory and the layout fingerprint
|
|
85
123
|
* the prober computed through plugin-hostd's `pin.h`, or the reason it took none.
|
|
@@ -122,10 +160,17 @@ function readClap(v: unknown): ClapProbedFacts {
|
|
|
122
160
|
if (!isRecord(v)) return bad("no clap facts");
|
|
123
161
|
const latencyIn = v["latency"];
|
|
124
162
|
if (!isRecord(latencyIn)) return bad("clap has no latency map");
|
|
125
|
-
const latency: Record<string,
|
|
163
|
+
const latency: Record<string, ClapLatencyAtRate> = {};
|
|
126
164
|
for (const [rate, at] of Object.entries(latencyIn)) {
|
|
127
165
|
if (!/^[1-9]\d*$/.test(rate) || !isRecord(at)) return bad(`clap latency at ${rate}`);
|
|
128
|
-
|
|
166
|
+
if (at["from"] !== undefined && at["from"] !== "note") return bad(`clap latency at ${rate} from ${JSON.stringify(at["from"])}`);
|
|
167
|
+
if (at["heard"] !== undefined && (at["from"] !== "note" || at["heard"] !== false)) return bad(`clap latency at ${rate} heard`);
|
|
168
|
+
latency[rate] = {
|
|
169
|
+
declaredFrames: count(at, "declaredFrames", `latency ${rate}`),
|
|
170
|
+
measuredFrames: count(at, "measuredFrames", `latency ${rate}`),
|
|
171
|
+
...(at["from"] === "note" ? { from: "note" as const } : {}),
|
|
172
|
+
...(at["heard"] === false ? { heard: false } : {}),
|
|
173
|
+
};
|
|
129
174
|
}
|
|
130
175
|
return {
|
|
131
176
|
mainInputChannels: count(v, "mainInputChannels", "clap"),
|
|
@@ -183,6 +228,120 @@ function readNoteSweep(v: unknown): ClapNoteSweepReading {
|
|
|
183
228
|
};
|
|
184
229
|
}
|
|
185
230
|
|
|
231
|
+
function figure(o: Record<string, unknown>, key: string, where: string): number {
|
|
232
|
+
const v = o[key];
|
|
233
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : bad(`${where} has no figure ${key}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function readVoices(v: unknown, where: string): { held?: number; basis?: VoicesHeld["basis"]; capacity?: number } {
|
|
237
|
+
if (!isRecord(v)) return bad(`${where} voices is not an object`);
|
|
238
|
+
const out: { held?: number; basis?: VoicesHeld["basis"]; capacity?: number } = {};
|
|
239
|
+
if ("held" in v || "basis" in v) {
|
|
240
|
+
const basis = v["basis"];
|
|
241
|
+
if (!isVoiceBasis(basis)) return bad(`${where} voices basis ${JSON.stringify(basis)}`);
|
|
242
|
+
const held = count(v, "held", `${where} voices`);
|
|
243
|
+
if (held === 0) return bad(`${where} holds no note`);
|
|
244
|
+
out.held = held;
|
|
245
|
+
out.basis = basis;
|
|
246
|
+
}
|
|
247
|
+
if ("capacity" in v) out.capacity = count(v, "capacity", `${where} voices`);
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readCostAtRate(v: unknown, where: string, notes: boolean): ClapCostAtRate {
|
|
252
|
+
if (!isRecord(v)) return bad(`${where} is not an object`);
|
|
253
|
+
const voices = v["voices"] === undefined ? undefined : readVoices(v["voices"], where);
|
|
254
|
+
if (voices !== undefined && !notes) return bad(`${where} holds notes for a plugin fed noise`);
|
|
255
|
+
if (v["unmeasuredReason"] !== undefined) {
|
|
256
|
+
if (voices?.held !== undefined) return bad(`${where} held notes and timed nothing`);
|
|
257
|
+
return {
|
|
258
|
+
unmeasuredReason: str(v, "unmeasuredReason", where),
|
|
259
|
+
unmeasuredDetail: str(v, "unmeasuredDetail", where),
|
|
260
|
+
...(voices?.capacity === undefined ? {} : { voices: { capacity: voices.capacity } }),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (notes && (voices === undefined || voices.held === undefined || voices.basis === undefined)) {
|
|
264
|
+
return bad(`${where} timed an instrument without saying what it held`);
|
|
265
|
+
}
|
|
266
|
+
const blocks = count(v, "blocks", where);
|
|
267
|
+
if (blocks === 0) return bad(`${where} timed no block`);
|
|
268
|
+
return {
|
|
269
|
+
nsPerSampleMedian: figure(v, "nsPerSampleMedian", where),
|
|
270
|
+
nsPerSampleP95: figure(v, "nsPerSampleP95", where),
|
|
271
|
+
nsPerSampleMin: figure(v, "nsPerSampleMin", where),
|
|
272
|
+
nsPerSampleMax: figure(v, "nsPerSampleMax", where),
|
|
273
|
+
warmupNsPerSampleMedian: figure(v, "warmupNsPerSampleMedian", where),
|
|
274
|
+
blocks,
|
|
275
|
+
...(voices?.held === undefined || voices.basis === undefined
|
|
276
|
+
? {}
|
|
277
|
+
: { voices: { held: voices.held, basis: voices.basis, ...(voices.capacity === undefined ? {} : { capacity: voices.capacity }) } }),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readCost(v: unknown): ClapCostReading {
|
|
282
|
+
if (!isRecord(v)) return bad("cost is not an object");
|
|
283
|
+
const stimulus = str(v, "stimulus", "cost");
|
|
284
|
+
if (stimulus !== "notes-held" && stimulus !== "noise") return bad(`cost stimulus ${stimulus}`);
|
|
285
|
+
const perRateIn = v["perRate"];
|
|
286
|
+
if (!isRecord(perRateIn)) return bad("cost has no perRate map");
|
|
287
|
+
const perRate: Record<string, ClapCostAtRate> = {};
|
|
288
|
+
for (const [rate, at] of Object.entries(perRateIn)) {
|
|
289
|
+
if (!/^[1-9]\d*$/.test(rate)) return bad(`cost rate ${rate}`);
|
|
290
|
+
perRate[rate] = readCostAtRate(at, `cost at ${rate}`, stimulus === "notes-held");
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
stimulus,
|
|
294
|
+
blockFrames: count(v, "blockFrames", "cost"),
|
|
295
|
+
warmupBlocks: count(v, "warmupBlocks", "cost"),
|
|
296
|
+
percentile: figure(v, "percentile", "cost"),
|
|
297
|
+
perRate,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The cost arm's figures as the classifier's cost gate reads them, per rate: the median and the P95
|
|
303
|
+
* per sample, and the P95 as a fraction of one core at that rate. A rate the arm did not time is absent.
|
|
304
|
+
*/
|
|
305
|
+
export function clapCostPerRate(
|
|
306
|
+
cost: ClapCostReading | undefined,
|
|
307
|
+
): Record<string, { coreFractionP95: number; nsPerSampleMedian: number; nsPerSampleP95: number; voices?: VoicesHeld }> {
|
|
308
|
+
const out: Record<string, { coreFractionP95: number; nsPerSampleMedian: number; nsPerSampleP95: number; voices?: VoicesHeld }> = {};
|
|
309
|
+
for (const [rate, at] of Object.entries(cost?.perRate ?? {})) {
|
|
310
|
+
if (!isClapCostFigure(at)) continue;
|
|
311
|
+
const fraction = coreFraction(at.nsPerSampleP95, Number(rate));
|
|
312
|
+
if (fraction === undefined) continue;
|
|
313
|
+
out[rate] = {
|
|
314
|
+
coreFractionP95: fraction,
|
|
315
|
+
nsPerSampleMedian: at.nsPerSampleMedian,
|
|
316
|
+
nsPerSampleP95: at.nsPerSampleP95,
|
|
317
|
+
...(at.voices === undefined ? {} : { voices: at.voices }),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A prober line as the plugin the full classifier (`classifyPluginHosting`) reads: the CLAP facts, the
|
|
325
|
+
* interposer's counts with the note sweep beside them, and the cost arm's figures per rate. The prober
|
|
326
|
+
* measures no stability (that is the soak's), so the caller passes what it has; without it the verdict
|
|
327
|
+
* is `unknown` on stability, never a pass.
|
|
328
|
+
*/
|
|
329
|
+
export function qualifiedPluginOfClapLine(line: ClapQualifyLine, stability?: PluginStabilityMeasurement): QualifiedPlugin {
|
|
330
|
+
const { events: _events, ...rtSafety } = line.rtSafety;
|
|
331
|
+
const { events: _noteEvents, ...noteSweep } = line.noteSweep;
|
|
332
|
+
const { nonFinite: _nonFinite, ...clap } = line.clap;
|
|
333
|
+
return {
|
|
334
|
+
format: "clap",
|
|
335
|
+
audioInputs: line.clap.mainInputChannels,
|
|
336
|
+
audioOutputs: line.clap.mainOutputChannels,
|
|
337
|
+
hasMidiIn: line.clap.noteInputs > 0,
|
|
338
|
+
clap,
|
|
339
|
+
rtSafety: { ...rtSafety, noteSweep },
|
|
340
|
+
cpuCost: { perRate: clapCostPerRate(line.cost) },
|
|
341
|
+
...(stability === undefined ? {} : { stability }),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
186
345
|
/** One line of the prober's stdout, or a {@link ClapQualifyError} saying how it differs. */
|
|
187
346
|
export function parseClapQualifyLine(text: string): ClapQualifyLine {
|
|
188
347
|
let v: unknown;
|
|
@@ -208,6 +367,7 @@ export function parseClapQualifyLine(text: string): ClapQualifyLine {
|
|
|
208
367
|
clap: readClap(v["clap"]),
|
|
209
368
|
rtSafety: readRtSafety(v["rtSafety"]),
|
|
210
369
|
noteSweep: readNoteSweep(v["noteSweep"]),
|
|
370
|
+
...(v["cost"] === undefined ? {} : { cost: readCost(v["cost"]) }),
|
|
211
371
|
integrity: readIntegrity(v["integrity"]) ?? bad("no integrity"),
|
|
212
372
|
};
|
|
213
373
|
}
|
|
@@ -223,9 +383,11 @@ export function parseClapQualifyOutput(stdout: string): ClapQualifyLine[] {
|
|
|
223
383
|
* The document's schema name; a reader refuses any other it does not know. `/2` is `/1` with
|
|
224
384
|
* `measured.noteSweep` added and the note sweep's reasons folded into `verdict` (a plugin with no
|
|
225
385
|
* 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.
|
|
386
|
+
* a `/1` reader of `verdict.rating` and `verdict.rtSafe` reads a `/2` document right. `/3` is `/2`
|
|
387
|
+
* with `measured.cost` added (the cost arm, an instrument's with the notes it held), which no
|
|
388
|
+
* verdict reads.
|
|
227
389
|
*/
|
|
228
|
-
export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/
|
|
390
|
+
export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/3";
|
|
229
391
|
|
|
230
392
|
/**
|
|
231
393
|
* The RT-safety verdict of one plugin binary for one console: keyed by the binary's SHA-256 and
|
|
@@ -254,6 +416,7 @@ export interface RtSafetyDocument {
|
|
|
254
416
|
readonly clap: ClapProbedFacts;
|
|
255
417
|
readonly rtSafety: ClapRtSafetyReading;
|
|
256
418
|
readonly noteSweep: ClapNoteSweepReading;
|
|
419
|
+
readonly cost?: ClapCostReading;
|
|
257
420
|
readonly integrity: IntegrityMeasurement;
|
|
258
421
|
};
|
|
259
422
|
}
|
|
@@ -296,6 +459,7 @@ export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = O
|
|
|
296
459
|
clap: line.clap,
|
|
297
460
|
rtSafety: line.rtSafety,
|
|
298
461
|
noteSweep: line.noteSweep,
|
|
462
|
+
...(line.cost === undefined ? {} : { cost: line.cost }),
|
|
299
463
|
integrity: line.integrity,
|
|
300
464
|
},
|
|
301
465
|
};
|
|
@@ -318,47 +482,7 @@ export function rtSafetyDocumentName(doc: RtSafetyDocument): string {
|
|
|
318
482
|
return `${doc.plugin.binary.sha256}.${doc.console}.rt-safety.json`;
|
|
319
483
|
}
|
|
320
484
|
|
|
321
|
-
// ---
|
|
322
|
-
|
|
323
|
-
/** What a run of the prober gave back; `error` is a spawn failure, `signal` a death. */
|
|
324
|
-
export interface ClapQualifyRunResult {
|
|
325
|
-
readonly status: number | null;
|
|
326
|
-
readonly signal?: string | null;
|
|
327
|
-
readonly stdout: string;
|
|
328
|
-
readonly stderr?: string;
|
|
329
|
-
readonly error?: Error;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
export type ClapQualifyRun = (
|
|
333
|
-
tool: string,
|
|
334
|
-
args: readonly string[],
|
|
335
|
-
env: Readonly<Record<string, string | undefined>>,
|
|
336
|
-
timeoutMs: number,
|
|
337
|
-
) => ClapQualifyRunResult;
|
|
338
|
-
|
|
339
|
-
const spawnQualify: ClapQualifyRun = (tool, args, env, timeoutMs) => {
|
|
340
|
-
const r = spawnSync(tool, [...args], { encoding: "utf8", env: { ...env }, timeout: timeoutMs, maxBuffer: 64 << 20 });
|
|
341
|
-
return {
|
|
342
|
-
status: r.status,
|
|
343
|
-
signal: r.signal,
|
|
344
|
-
stdout: r.stdout ?? "",
|
|
345
|
-
stderr: r.stderr ?? "",
|
|
346
|
-
...(r.error === undefined ? {} : { error: r.error }),
|
|
347
|
-
};
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
export interface ClapQualifyOptions {
|
|
351
|
-
/** The rates the plugin is activated and swept at. */
|
|
352
|
-
readonly rates: readonly number[];
|
|
353
|
-
/** Runs the prober; the default spawns it. */
|
|
354
|
-
readonly run?: ClapQualifyRun;
|
|
355
|
-
/** Where the two settings are read from; the default is `process.env`. */
|
|
356
|
-
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
357
|
-
/** Whether a file exists; the default asks the filesystem. */
|
|
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[];
|
|
361
|
-
}
|
|
485
|
+
// --- the prober and the interposer a run uses ---------------------------------------------------
|
|
362
486
|
|
|
363
487
|
/**
|
|
364
488
|
* The prober and the interposer this run uses: each its setting, else both from one place, the
|
|
@@ -381,44 +505,6 @@ export function clapQualifyTools(
|
|
|
381
505
|
return { tool: tool!, interposer: interposer! };
|
|
382
506
|
}
|
|
383
507
|
|
|
384
|
-
/**
|
|
385
|
-
* Qualify the plugins of one `.clap` (all of them, or the one `id` names) under the interposer.
|
|
386
|
-
* Absent tools, a crash, a timeout, a load failure or an unreadable line throw: none of them is a
|
|
387
|
-
* measurement.
|
|
388
|
-
*/
|
|
389
|
-
export function qualifyClap(binaryPath: string, id: string | undefined, options: ClapQualifyOptions): ClapQualifyLine[] {
|
|
390
|
-
const env = options.env ?? process.env;
|
|
391
|
-
const { tool, interposer } = clapQualifyTools(env, options.exists ?? existsSync);
|
|
392
|
-
const args = [
|
|
393
|
-
"--rates",
|
|
394
|
-
options.rates.join(","),
|
|
395
|
-
"--block",
|
|
396
|
-
String(CLAP_SWEEP_FRAMES),
|
|
397
|
-
"--blocks",
|
|
398
|
-
String(CLAP_SWEEP_BLOCKS),
|
|
399
|
-
...(options.offer === undefined || options.offer.length === 0 ? [] : ["--offer", options.offer.join(",")]),
|
|
400
|
-
binaryPath,
|
|
401
|
-
...(id === undefined ? [] : [id]),
|
|
402
|
-
];
|
|
403
|
-
const r = (options.run ?? spawnQualify)(tool, args, { ...env, LD_PRELOAD: interposer }, CLAP_QUALIFY_TIMEOUT_S * 1000);
|
|
404
|
-
if (r.error !== undefined) throw new ClapQualifyError(`cannot run ${tool}: ${r.error.message}`);
|
|
405
|
-
if (r.signal) throw new ClapQualifyError(`${binaryPath} killed the prober (${r.signal})`);
|
|
406
|
-
if (r.status !== 0) throw new ClapQualifyError(`${tool} exited ${String(r.status)} on ${binaryPath}: ${(r.stderr ?? "").trim()}`);
|
|
407
|
-
const lines = parseClapQualifyOutput(r.stdout);
|
|
408
|
-
if (lines.length === 0) throw new ClapQualifyError(`${tool} printed nothing for ${binaryPath}`);
|
|
409
|
-
return lines;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// --- the CLI's CLAP pass -------------------------------------------------------------------------
|
|
413
|
-
|
|
414
|
-
/** What the CLAP pass of one run produced: a document per plugin and console, and a row per verdict. */
|
|
415
|
-
export interface ClapQualifyPass {
|
|
416
|
-
readonly documents: readonly RtSafetyDocument[];
|
|
417
|
-
readonly verdicts: readonly QualifyVerdict[];
|
|
418
|
-
/** Each target that could not be measured, with why; its verdict rows read `unknown`, unmeasured. */
|
|
419
|
-
readonly failures: readonly { readonly uri: string; readonly problem: string }[];
|
|
420
|
-
}
|
|
421
|
-
|
|
422
508
|
/**
|
|
423
509
|
* What the prober must offer beyond the console's extensions to measure for `profile`: the extra
|
|
424
510
|
* extensions of its list, when the prober can offer each and the list holds the console's; `undefined`
|
|
@@ -432,48 +518,3 @@ export function proberOffer(profile: HostProfile): readonly string[] | undefined
|
|
|
432
518
|
if (!extra.every((e) => OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.includes(e))) return undefined;
|
|
433
519
|
return OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.filter((e) => extra.includes(e));
|
|
434
520
|
}
|
|
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.
|
|
441
|
-
*/
|
|
442
|
-
export function qualifyClapTargets(
|
|
443
|
-
targets: readonly PluginTarget[],
|
|
444
|
-
options: ClapQualifyOptions & { readonly profiles?: readonly HostProfile[] },
|
|
445
|
-
): ClapQualifyPass {
|
|
446
|
-
const profiles = options.profiles ?? [OPENMIXER_CONSOLE_PROFILE];
|
|
447
|
-
const documents: RtSafetyDocument[] = [];
|
|
448
|
-
const verdicts: QualifyVerdict[] = [];
|
|
449
|
-
const failures: { uri: string; problem: string }[] = [];
|
|
450
|
-
for (const target of targets) {
|
|
451
|
-
const id = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : undefined;
|
|
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[]>();
|
|
459
|
-
try {
|
|
460
|
-
if (target.binaryPath === undefined) throw new ClapQualifyError(`${target.uri}: no .clap to load; name the file or a directory holding it`);
|
|
461
|
-
for (const [key, offer] of offers) runs.set(key, qualifyClap(target.binaryPath, id, { ...options, offer }));
|
|
462
|
-
} catch (e) {
|
|
463
|
-
if (!(e instanceof ClapQualifyError)) throw e;
|
|
464
|
-
failures.push({ uri: target.uri, problem: e.message });
|
|
465
|
-
for (const profile of profiles) {
|
|
466
|
-
verdicts.push({ uri: target.uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: "not-measured" });
|
|
467
|
-
}
|
|
468
|
-
continue;
|
|
469
|
-
}
|
|
470
|
-
for (const profile of profiles) {
|
|
471
|
-
for (const line of runs.get((proberOffer(profile) ?? []).join(","))!) {
|
|
472
|
-
const doc = rtSafetyDocument(line, profile);
|
|
473
|
-
documents.push(doc);
|
|
474
|
-
verdicts.push(rtSafetyVerdict(doc));
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
return { documents, verdicts, failures };
|
|
479
|
-
}
|
package/src/cost-fields.ts
CHANGED
|
@@ -70,3 +70,42 @@ export function classifierCostFromDocument(reading: CostAtRate, rate: number): C
|
|
|
70
70
|
[COST_FIELDS.classifier.coreFraction]: fraction,
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The key of one point of a cost grid: the rate and the quantum it was measured at. One place spells it,
|
|
76
|
+
* so the reader that fills a grid and the gate that looks a point up cannot disagree.
|
|
77
|
+
*/
|
|
78
|
+
export function costPointKey(rate: number, quantum: number): string {
|
|
79
|
+
return `${rate}/${quantum}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The percentile the classifier's `nsPerSampleP95` is. A figure taken at another percentile is not that field. */
|
|
83
|
+
export const CLASSIFIER_PERCENTILE = 95;
|
|
84
|
+
|
|
85
|
+
/** One point of a grid as it is measured: the figures {@link gridCostReadings} turns into the classifier's. */
|
|
86
|
+
export interface CostGridPoint {
|
|
87
|
+
readonly rate: number;
|
|
88
|
+
readonly quantum: number;
|
|
89
|
+
readonly outcome: string;
|
|
90
|
+
readonly cost?: { readonly median: number; readonly percentile: { readonly p: number; readonly value: number } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The classifier's cost readings of a grid, by {@link costPointKey}. A point that did not end `probe.ok`, has no
|
|
95
|
+
* cost, or whose percentile is not {@link CLASSIFIER_PERCENTILE} has no reading: the gate then reads the pair as
|
|
96
|
+
* unmeasured, which is the truth, where a figure under the wrong name would be judged as if it were the P95.
|
|
97
|
+
*/
|
|
98
|
+
export function gridCostReadings(points: readonly CostGridPoint[], okOutcome: string): Record<string, ClassifierCostReading> {
|
|
99
|
+
const out: Record<string, ClassifierCostReading> = {};
|
|
100
|
+
for (const p of points) {
|
|
101
|
+
if (p.outcome !== okOutcome || p.cost === undefined || p.cost.percentile.p !== CLASSIFIER_PERCENTILE) continue;
|
|
102
|
+
const fraction = coreFraction(p.cost.percentile.value, p.rate);
|
|
103
|
+
if (fraction === undefined) continue;
|
|
104
|
+
out[costPointKey(p.rate, p.quantum)] = {
|
|
105
|
+
[COST_FIELDS.classifier.median]: p.cost.median,
|
|
106
|
+
[COST_FIELDS.classifier.percentile]: p.cost.percentile.value,
|
|
107
|
+
[COST_FIELDS.classifier.coreFraction]: fraction,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
package/src/hosting-codes.ts
CHANGED
|
@@ -61,6 +61,7 @@ export const HOSTING_CODE = {
|
|
|
61
61
|
clapFormatNotHosted: 'hosting.clap.format-not-hosted',
|
|
62
62
|
clapExtensionsNotCovered: 'hosting.clap.extensions-not-covered',
|
|
63
63
|
outputNonFinite: 'hosting.output.non-finite',
|
|
64
|
+
workerNonconforming: 'hosting.worker.nonconforming',
|
|
64
65
|
// The pin (spec §11; one-contract §10.3): a host's refusal at add, by the daemon's code.
|
|
65
66
|
integrityUnpinned: 'hosting.integrity.unpinned',
|
|
66
67
|
integrityBinaryMismatch: 'hosting.integrity.binary-mismatch',
|
|
@@ -52,11 +52,19 @@ export interface QualifiedPlugin {
|
|
|
52
52
|
readonly scalingClass?: string;
|
|
53
53
|
readonly paramSweep?: unknown;
|
|
54
54
|
};
|
|
55
|
-
/** Measured cost per rate (Hz as a string key). */
|
|
55
|
+
/** Measured cost: per rate (Hz as a string key), or per rate AND quantum. */
|
|
56
56
|
readonly cpuCost?: {
|
|
57
|
+
/** One figure per rate, at the block the prober ran: the old format. Read only when `perRateQuantum` is absent. */
|
|
57
58
|
readonly perRate?: Readonly<
|
|
58
59
|
Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
|
|
59
60
|
>;
|
|
61
|
+
/**
|
|
62
|
+
* One figure per rate and quantum, keyed by {@link costPointKey}: a probe output's grid. When this is
|
|
63
|
+
* present it is the only cost there is, so a pair it lacks is unmeasured, never read from `perRate`.
|
|
64
|
+
*/
|
|
65
|
+
readonly perRateQuantum?: Readonly<
|
|
66
|
+
Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
|
|
67
|
+
>;
|
|
60
68
|
};
|
|
61
69
|
readonly stability?: PluginStabilityMeasurement;
|
|
62
70
|
readonly rtSafety?: PluginRtSafetyMeasurement;
|
|
@@ -306,14 +314,20 @@ export type PluginLv2Features = Omit<FeaturesMeasurement, "unmeasuredReason" | "
|
|
|
306
314
|
|
|
307
315
|
export { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
|
|
308
316
|
import { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
|
|
309
|
-
import { costFieldsMissing } from "./cost-fields.js";
|
|
317
|
+
import { costFieldsMissing, costPointKey } from "./cost-fields.js";
|
|
310
318
|
|
|
311
319
|
/** One declared rate's latency reading from the CLAP qualifier (CLAP spec §3, latency). */
|
|
312
320
|
export interface ClapLatencyAtRate {
|
|
313
321
|
/** `latency.get()` after `activate` at this rate; a plugin without the ext declares 0. */
|
|
314
322
|
readonly declaredFrames: number;
|
|
315
|
-
/** Where the qualifier's impulse landed through `process()`, in frames
|
|
323
|
+
/** Where the qualifier's impulse landed through `process()`, in frames; for an instrument, where a note's
|
|
324
|
+
* sound began. */
|
|
316
325
|
readonly measuredFrames: number;
|
|
326
|
+
/** `note`: a plugin with no main audio input, measured from a note struck at frame 0 (the onset of its
|
|
327
|
+
* sound, which an attack envelope may put later than its latency). Absent: from an impulse. */
|
|
328
|
+
readonly from?: "note";
|
|
329
|
+
/** `false`: the note was struck and nothing was heard, so there is no onset to read. */
|
|
330
|
+
readonly heard?: boolean;
|
|
317
331
|
}
|
|
318
332
|
|
|
319
333
|
/**
|
|
@@ -700,7 +714,13 @@ export function hostingCostReasons(
|
|
|
700
714
|
options: HostingClassifyOptions,
|
|
701
715
|
policy: HostingPolicy,
|
|
702
716
|
): readonly HostingReason[] {
|
|
703
|
-
const
|
|
717
|
+
const grid = descriptor.cpuCost?.perRateQuantum;
|
|
718
|
+
const perRate =
|
|
719
|
+
grid === undefined
|
|
720
|
+
? descriptor.cpuCost?.perRate?.[String(options.rate)]
|
|
721
|
+
: options.quantum === undefined
|
|
722
|
+
? undefined
|
|
723
|
+
: grid[costPointKey(options.rate, options.quantum)];
|
|
704
724
|
if (perRate === undefined) {
|
|
705
725
|
return [
|
|
706
726
|
reason("cost", "objective", "unknown", HOSTING_CODE.costUnmeasured, {
|
|
@@ -761,6 +781,10 @@ export function hostingLatencyReasons(
|
|
|
761
781
|
// one the benchmark declared unmeasurable, has not been judged and does not pass by
|
|
762
782
|
// default. The oracle's first fixture omitted latency and still expected `suitable`,
|
|
763
783
|
// which is how this branch was missing — a self-consistent suite proving nothing.
|
|
784
|
+
// A plugin whose worker interface was refused is not unmeasured by accident: it is not hostable.
|
|
785
|
+
if (typeof latency?.unmeasurable === "string" && latency.unmeasurable.includes(HOSTING_CODE.workerNonconforming)) {
|
|
786
|
+
return [reason("latency", "objective", "unsuitable", HOSTING_CODE.workerNonconforming)];
|
|
787
|
+
}
|
|
764
788
|
if (latency === undefined || latency.unmeasurable !== undefined) {
|
|
765
789
|
return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
|
|
766
790
|
}
|
|
@@ -829,7 +853,10 @@ export function clapTopologyReasons(clap: PluginClapMeasurement | undefined, ins
|
|
|
829
853
|
/**
|
|
830
854
|
* The CLAP arm of `latency` (CLAP spec §3): declared = measured at EVERY declared rate earns;
|
|
831
855
|
* one rate that lies refuses (`unsuitable` — compensation reads the figure as truth); no rate
|
|
832
|
-
* measured is `unknown`.
|
|
856
|
+
* measured is `unknown`. An instrument has no input to put an impulse in, so its figure is the onset
|
|
857
|
+
* of a note struck at frame 0: an onset before the declared latency is the same lie, one at or after
|
|
858
|
+
* it is resolved (an attack envelope sounds later than the plugin's delay, and that is no latency),
|
|
859
|
+
* and a note that was never heard is unmeasured.
|
|
833
860
|
*/
|
|
834
861
|
export function clapLatencyReasons(clap: PluginClapMeasurement | undefined): readonly HostingReason[] {
|
|
835
862
|
const rates = clap ? Object.keys(clap.latency) : [];
|
|
@@ -838,7 +865,11 @@ export function clapLatencyReasons(clap: PluginClapMeasurement | undefined): rea
|
|
|
838
865
|
}
|
|
839
866
|
for (const rate of rates) {
|
|
840
867
|
const at = clap.latency[rate]!;
|
|
841
|
-
if (at.
|
|
868
|
+
if (at.from === "note" && at.heard === false) {
|
|
869
|
+
return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured, { rate: Number(rate) })];
|
|
870
|
+
}
|
|
871
|
+
const lies = at.from === "note" ? at.measuredFrames < at.declaredFrames : at.declaredFrames !== at.measuredFrames;
|
|
872
|
+
if (lies) {
|
|
842
873
|
return [
|
|
843
874
|
reason("latency", "objective", "unsuitable", HOSTING_CODE.clapLatencyMismatch, {
|
|
844
875
|
rate: Number(rate), declared: at.declaredFrames, measured: at.measuredFrames,
|
|
@@ -960,12 +991,15 @@ export function classifyPluginHosting(
|
|
|
960
991
|
// report could agree with itself while every served descriptor read unknown.
|
|
961
992
|
// The CLAP arm: the SAME six dimensions, fed from the
|
|
962
993
|
// qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
|
|
963
|
-
// the interposer's counts and the cost are format-blind and read unchanged.
|
|
994
|
+
// the interposer's counts and the cost are format-blind and read unchanged. A host that plays
|
|
995
|
+
// notes (an instrument host) also reads the note sweep: an instrument is clean at rest, so the
|
|
996
|
+
// parameter sweep alone says nothing about what a voice does.
|
|
964
997
|
const reasons: HostingReason[] = descriptor.format === "clap"
|
|
965
998
|
? [
|
|
966
999
|
...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess, options.rate),
|
|
967
1000
|
...rtSafetyReasons(descriptor.rtSafety),
|
|
968
1001
|
...clapRtReasons(descriptor.clap),
|
|
1002
|
+
...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.clap?.noteInputs ?? (descriptor.hasMidiIn ? 1 : 0)) : []),
|
|
969
1003
|
...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
|
|
970
1004
|
...hostingCostReasons(descriptor, options, policy),
|
|
971
1005
|
...clapLatencyReasons(descriptor.clap),
|
|
@@ -974,6 +1008,7 @@ export function classifyPluginHosting(
|
|
|
974
1008
|
: [
|
|
975
1009
|
...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess, options.rate),
|
|
976
1010
|
...rtSafetyReasons(descriptor.rtSafety),
|
|
1011
|
+
...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.hasMidiIn ? 1 : 0) : []),
|
|
977
1012
|
...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
|
|
978
1013
|
...hostingCostReasons(descriptor, options, policy),
|
|
979
1014
|
...hostingLatencyReasons(descriptor, profile.latency),
|
package/src/index.ts
CHANGED
|
@@ -20,3 +20,8 @@ export * from "./qualify-cli.js";
|
|
|
20
20
|
export * from "./pins-file.js";
|
|
21
21
|
export * from "./measure-host.js";
|
|
22
22
|
export * from "./cost-fields.js";
|
|
23
|
+
export * from "./probe-output.js";
|
|
24
|
+
export * from "./probe-judge.js";
|
|
25
|
+
export * from "./qualification-record.js";
|
|
26
|
+
export * from "./probe-runner.js";
|
|
27
|
+
export * from "./judge-cli.js";
|