@openmixer/plugin-qualify 0.8.0 → 0.9.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 +41 -1
- package/bin/plugin-qualify.mjs +59 -24
- 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/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/measurement/cli.d.ts +13 -0
- package/dist/measurement/cli.js +68 -2
- package/dist/measurement/cli.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 +8 -0
- package/dist/qualify-declarations.js +8 -0
- 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 +1 -1
- package/src/clap-loader.ts +99 -7
- package/src/index.ts +1 -0
- package/src/lv2-loader.ts +28 -2
- package/src/measurement/cli.ts +77 -2
- package/src/pins-file.ts +200 -0
- package/src/qualify-cli.ts +8 -1
- package/src/qualify-declarations.ts +9 -0
- package/src/qualify-options.ts +7 -1
- package/tools/lv2-measure.mjs +16 -0
package/src/clap-loader.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The CLAP implementation of the one discovery seam (`loader.ts`). A target that is a `.clap` file, or a
|
|
5
5
|
* directory holding one, is scanned by `omx-clap-scan --json` (the scanner of omx-clap-host),
|
|
6
|
-
* located by the ONE declared setting; `urn:clap:<id>` is one plugin without a scan.
|
|
6
|
+
* located by the ONE declared setting; `urn:clap:<id>` is one plugin without a scan. `--all` walks
|
|
7
|
+
* the standard CLAP search path ({@link clapSearchPath}) through the same loader. The
|
|
7
8
|
* scanner's JSON is the input: {@link clapScanDescriptors} normalises it into the interchange
|
|
8
9
|
* rows. A scan that cannot be read is a {@link ClapScanError}, never an empty result.
|
|
9
10
|
*
|
|
@@ -12,11 +13,20 @@
|
|
|
12
13
|
* remote-control pages; a row keeps them, and each parameter's module, wherever the scan read them.
|
|
13
14
|
*/
|
|
14
15
|
import { spawnSync } from "node:child_process";
|
|
15
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
16
|
+
import { type Dirent, existsSync, readdirSync, statSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
16
18
|
import { basename, dirname, join, resolve } from "node:path";
|
|
17
19
|
|
|
18
20
|
import type { PluginLoader, PluginTarget } from "./loader.js";
|
|
19
|
-
import {
|
|
21
|
+
import { lv2BundlesOnPath } from "./lv2-loader.js";
|
|
22
|
+
import {
|
|
23
|
+
CLAP_PATH_ENV,
|
|
24
|
+
CLAP_SCAN_TIMEOUT_S,
|
|
25
|
+
CLAP_SCAN_TOOL_DEFAULT,
|
|
26
|
+
CLAP_SCAN_TOOL_ENV,
|
|
27
|
+
CLAP_SYSTEM_DIR,
|
|
28
|
+
CLAP_USER_DIR,
|
|
29
|
+
} from "./qualify-declarations.js";
|
|
20
30
|
|
|
21
31
|
/** The scanner's own name in its document — anything else is not its output. */
|
|
22
32
|
const SCANNER_NAME = "omx-clap-scan";
|
|
@@ -344,11 +354,33 @@ export function clapScanTool(env: Readonly<Record<string, string | undefined>>):
|
|
|
344
354
|
return named === undefined || named === "" ? CLAP_SCAN_TOOL_DEFAULT : named;
|
|
345
355
|
}
|
|
346
356
|
|
|
357
|
+
/** An fs error met while walking a search directory, as the scan refusal that names the path. */
|
|
358
|
+
function unreadable(path: string, e: unknown): ClapScanError {
|
|
359
|
+
if (e instanceof ClapScanError) return e;
|
|
360
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
361
|
+
return new ClapScanError(`cannot read ${path}: ${typeof code === "string" ? code : String(e)}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A directory entry that is a directory, or a symlink to one; a dangling link is not, any other fs error (a loop) is thrown. */
|
|
365
|
+
function isDirectory(e: Dirent, p: string): boolean {
|
|
366
|
+
if (e.isDirectory()) return true;
|
|
367
|
+
if (!e.isSymbolicLink()) return false;
|
|
368
|
+
try {
|
|
369
|
+
return statSync(p).isDirectory();
|
|
370
|
+
} catch (err) {
|
|
371
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
347
376
|
function holdsClap(dir: string): boolean {
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
377
|
+
try {
|
|
378
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
379
|
+
const p = join(dir, e.name);
|
|
380
|
+
if (isDirectory(e, p) ? holdsClap(p) : e.name.endsWith(CLAP_SUFFIX)) return true;
|
|
381
|
+
}
|
|
382
|
+
} catch (e) {
|
|
383
|
+
throw unreadable(dir, e);
|
|
352
384
|
}
|
|
353
385
|
return false;
|
|
354
386
|
}
|
|
@@ -391,3 +423,63 @@ export function clapLoader(options: ClapLoaderOptions = {}): PluginLoader {
|
|
|
391
423
|
},
|
|
392
424
|
};
|
|
393
425
|
}
|
|
426
|
+
|
|
427
|
+
// --- the standard search path (`--all`) ----------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The directories `--all` walks, in order and once each: every non-empty entry of `CLAP_PATH`, then
|
|
431
|
+
* `~/.clap`, then `/usr/lib/clap`.
|
|
432
|
+
*/
|
|
433
|
+
export function clapSearchPath(env: Readonly<Record<string, string | undefined>>, home: string): string[] {
|
|
434
|
+
const named = (env[CLAP_PATH_ENV] ?? "").split(":").filter((d) => d !== "");
|
|
435
|
+
return [...new Set([...named, join(home, CLAP_USER_DIR), CLAP_SYSTEM_DIR].map((d) => resolve(d)))];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface ClapDiscoverOptions extends ClapLoaderOptions {
|
|
439
|
+
/** The home directory `~/.clap` is read under; the default is the user's. */
|
|
440
|
+
readonly home?: string;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Every CLAP plugin on the standard search path, each directory resolved through {@link clapLoader}
|
|
445
|
+
* exactly as a named directory is. A directory that is absent or holds no `.clap` is skipped; a
|
|
446
|
+
* plugin id found twice keeps its first place on the path. A directory that cannot be read (a
|
|
447
|
+
* permission error, a symlink loop) is a {@link ClapScanError} naming it, never a partial list.
|
|
448
|
+
*/
|
|
449
|
+
export function discoverClapTargets(options: ClapDiscoverOptions = {}): PluginTarget[] {
|
|
450
|
+
const loader = clapLoader(options);
|
|
451
|
+
const found = new Map<string, PluginTarget>();
|
|
452
|
+
for (const dir of clapSearchPath(options.env ?? process.env, options.home ?? homedir())) {
|
|
453
|
+
try {
|
|
454
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
|
|
455
|
+
for (const t of loader.resolve(dir) ?? []) if (!found.has(t.uri)) found.set(t.uri, t);
|
|
456
|
+
} catch (e) {
|
|
457
|
+
throw unreadable(dir, e);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return [...found.values()];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export interface DiscoverAllOptions extends ClapDiscoverOptions {}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The targets of `--all`. The LV2 plugins lilv finds cannot be measured from this entry point, so
|
|
467
|
+
* when the LV2 search path holds any bundle the run refuses (exit 2) and names how many, rather
|
|
468
|
+
* than qualifying only the CLAP side and reporting everything qualified.
|
|
469
|
+
*/
|
|
470
|
+
export function discoverAllTargets(options: DiscoverAllOptions = {}): PluginTarget[] {
|
|
471
|
+
const env = options.env ?? process.env;
|
|
472
|
+
let bundles: string[];
|
|
473
|
+
try {
|
|
474
|
+
bundles = lv2BundlesOnPath(env, options.home ?? homedir());
|
|
475
|
+
} catch (e) {
|
|
476
|
+
throw unreadable("the LV2 search path", e);
|
|
477
|
+
}
|
|
478
|
+
if (bundles.length > 0) {
|
|
479
|
+
throw new ClapScanError(
|
|
480
|
+
`--all cannot measure LV2 from this entry point and ${String(bundles.length)} LV2 bundle(s) are on the LV2 path ` +
|
|
481
|
+
`(first: ${bundles[0]}); name the plugins or bundles to qualify instead, or unset LV2_PATH to an empty directory`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return discoverClapTargets(options);
|
|
485
|
+
}
|
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/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
|
// ---------------------------------------------------------------------------
|
package/src/pins-file.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The pins file `plugin-qualify --pins <file>` writes and plugin-hostd loads (`pin_file` in its settings, or its
|
|
5
|
+
* pin store's import): one line per qualified plugin, the SAME line as hostd's `pin_set` verb, with the
|
|
6
|
+
* qualification verdict as a last word.
|
|
7
|
+
*
|
|
8
|
+
* pin_set <uri> <path>=<sha256>[,<path>=<sha256>...] <scheme>:<sha256> [verdict=<word>]
|
|
9
|
+
*
|
|
10
|
+
* The digests and the layout are the prober's, raw (`IntegrityMeasurement`); nothing here computes a hash. A plugin
|
|
11
|
+
* whose run took no whole pin has no line: a pins file never holds a partial pin. The grammar is plugin-hostd's
|
|
12
|
+
* (`include/plugin-hostd/protocol.h`, PHD_PIN_*); `parsePinLine` reads a line back with the daemon's rules, so a
|
|
13
|
+
* writer that drifts from them is a red test and not a refused add.
|
|
14
|
+
*/
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
import type { RtSafetyDocument } from "./clap-qualify.js";
|
|
18
|
+
import type { IntegrityMeasurement, MeasuredPlugin } from "./measurement/format.js";
|
|
19
|
+
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
20
|
+
import type { SuitabilityRating } from "./rating.js";
|
|
21
|
+
|
|
22
|
+
/** The verb the line starts with in a pins file, hostd's `PHD_VERB_PIN_SET`. */
|
|
23
|
+
export const PIN_VERB = "pin_set";
|
|
24
|
+
|
|
25
|
+
/** The key of the verdict word, hostd's `PHD_PIN_VERDICT_KEY`. */
|
|
26
|
+
export const PIN_VERDICT_KEY = "verdict=";
|
|
27
|
+
|
|
28
|
+
/** The verdict words hostd knows (`PHD_PIN_VERDICTS`). */
|
|
29
|
+
export const PIN_VERDICTS = ["in-process", "isolated", "refused"] as const;
|
|
30
|
+
export type PinVerdict = (typeof PIN_VERDICTS)[number];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a rating says about where the plugin may run, in hostd's words. `suitable` is qualified for a worker that other
|
|
34
|
+
* plugins share. `conditional` loads and is isolated. `unsuitable` is measured to fail and is `refused`. `unknown` is no
|
|
35
|
+
* opinion: no verdict word, and hostd treats the plugin as not qualified.
|
|
36
|
+
*/
|
|
37
|
+
export function pinVerdictOf(rating: SuitabilityRating): PinVerdict | undefined {
|
|
38
|
+
switch (rating) {
|
|
39
|
+
case "suitable":
|
|
40
|
+
return "in-process";
|
|
41
|
+
case "conditional":
|
|
42
|
+
return "isolated";
|
|
43
|
+
case "unsuitable":
|
|
44
|
+
return "refused";
|
|
45
|
+
case "unknown":
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One pin: what hostd's `pin_set` takes, and the verdict of the run that took it. */
|
|
51
|
+
export interface PinEntry {
|
|
52
|
+
/** hostd's uri: `clap:<path of the .clap>#<id>`, or an LV2 plugin's own URI. */
|
|
53
|
+
readonly uri: string;
|
|
54
|
+
readonly integrity: IntegrityMeasurement;
|
|
55
|
+
readonly verdict?: PinVerdict;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The line of one pin, or `undefined` when the integrity block is not a whole pin. A uri with whitespace in it would be read back as more words: it is refused, by name. */
|
|
59
|
+
export function pinLine(entry: PinEntry): string | undefined {
|
|
60
|
+
const spaced = [entry.uri, ...(entry.integrity.files ?? []).map((f) => f.path)].find((w) => /\s/.test(w));
|
|
61
|
+
if (spaced !== undefined) {
|
|
62
|
+
throw new Error(`${entry.uri}: ${JSON.stringify(spaced)} has whitespace in it and cannot be a word of a pin line`);
|
|
63
|
+
}
|
|
64
|
+
const { files, layout } = entry.integrity;
|
|
65
|
+
if (files === undefined || files.length === 0 || layout === undefined) return undefined;
|
|
66
|
+
const list = files.map((f) => `${f.path}=${f.sha256}`).join(",");
|
|
67
|
+
const verdict = entry.verdict === undefined ? "" : ` ${PIN_VERDICT_KEY}${entry.verdict}`;
|
|
68
|
+
return `${PIN_VERB} ${entry.uri} ${list} ${layout.scheme}:${layout.sha256}${verdict}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** hostd's uri of a CLAP plugin the prober loaded: its file as it was handed, made absolute, and its id. */
|
|
72
|
+
export function clapPinUri(binaryPath: string, pluginUri: string): string {
|
|
73
|
+
const id = pluginUri.startsWith("urn:clap:") ? pluginUri.slice("urn:clap:".length) : pluginUri;
|
|
74
|
+
return `clap:${resolve(binaryPath)}#${id}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What went into a pins file and what did not. */
|
|
78
|
+
export interface PinsFile {
|
|
79
|
+
readonly text: string;
|
|
80
|
+
/** The uris written. */
|
|
81
|
+
readonly written: readonly string[];
|
|
82
|
+
/** The uris of plugins that took no whole pin: they have no line. */
|
|
83
|
+
readonly skipped: readonly string[];
|
|
84
|
+
/** The uris refused because a word of their line has whitespace in it, each with the reason: they have no line. */
|
|
85
|
+
readonly refused: readonly { readonly uri: string; readonly problem: string }[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The file: a comment that says where it came from, then one line per plugin, sorted by uri. A uri given twice keeps
|
|
90
|
+
* the entry whose verdict is the worse one (`refused` over `isolated` over `in-process`, no word over all), so a
|
|
91
|
+
* plugin found twice is never pinned more kindly than its worst measurement.
|
|
92
|
+
*/
|
|
93
|
+
export function pinsFile(entries: readonly PinEntry[]): PinsFile {
|
|
94
|
+
const byUri = new Map<string, string>();
|
|
95
|
+
const verdictOf = new Map<string, PinVerdict | undefined>();
|
|
96
|
+
const skipped: string[] = [];
|
|
97
|
+
const refused: { uri: string; problem: string }[] = [];
|
|
98
|
+
const rank = (v: PinVerdict | undefined): number => (v === undefined ? 4 : { "in-process": 1, isolated: 2, refused: 3 }[v]);
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
let line: string | undefined;
|
|
101
|
+
try {
|
|
102
|
+
line = pinLine(entry);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
refused.push({ uri: entry.uri, problem: (e as Error).message });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (line === undefined) {
|
|
108
|
+
skipped.push(entry.uri);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (byUri.has(entry.uri) && rank(verdictOf.get(entry.uri)) >= rank(entry.verdict)) continue;
|
|
112
|
+
byUri.set(entry.uri, line);
|
|
113
|
+
verdictOf.set(entry.uri, entry.verdict);
|
|
114
|
+
}
|
|
115
|
+
const uris = [...byUri.keys()].sort();
|
|
116
|
+
const text = [
|
|
117
|
+
"# written by plugin-qualify --pins; plugin-hostd reads it with pin_file",
|
|
118
|
+
...uris.map((u) => byUri.get(u)!),
|
|
119
|
+
"",
|
|
120
|
+
].join("\n");
|
|
121
|
+
return { text, written: uris, skipped: skipped.filter((u) => !byUri.has(u)), refused };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The entries of a CLAP pass for one host: each document of that host's profile with its pin and the verdict its
|
|
126
|
+
* rating says. The documents of other profiles are ignored; a pins file is for one host.
|
|
127
|
+
*/
|
|
128
|
+
export function clapPinEntries(documents: readonly RtSafetyDocument[], host: string): PinEntry[] {
|
|
129
|
+
return documents
|
|
130
|
+
.filter((d) => d.console === host)
|
|
131
|
+
.map((d) => {
|
|
132
|
+
const verdict = pinVerdictOf(d.verdict.rating);
|
|
133
|
+
return {
|
|
134
|
+
uri: clapPinUri(d.plugin.binary.path, d.plugin.uri),
|
|
135
|
+
integrity: d.measured.integrity,
|
|
136
|
+
...(verdict === undefined ? {} : { verdict }),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The entries of an LV2 run: each measured plugin with a pin and the verdict row of that host. A plugin with no verdict
|
|
143
|
+
* row for the host is pinned without a verdict word.
|
|
144
|
+
*/
|
|
145
|
+
export function measuredPinEntries(
|
|
146
|
+
plugins: readonly MeasuredPlugin[],
|
|
147
|
+
verdicts: readonly QualifyVerdict[],
|
|
148
|
+
host: string,
|
|
149
|
+
): PinEntry[] {
|
|
150
|
+
return plugins.flatMap((p) => {
|
|
151
|
+
if (p.integrity === undefined) return [];
|
|
152
|
+
const row = verdicts.find((v) => v.uri === p.uri && v.host === host);
|
|
153
|
+
const verdict = row === undefined || !row.measured ? undefined : pinVerdictOf(row.rating);
|
|
154
|
+
return [{ uri: p.uri, integrity: p.integrity, ...(verdict === undefined ? {} : { verdict }) }];
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A line read back with hostd's rules. */
|
|
159
|
+
export interface ParsedPin {
|
|
160
|
+
readonly uri: string;
|
|
161
|
+
readonly files: readonly { readonly path: string; readonly sha256: string }[];
|
|
162
|
+
readonly layout: { readonly scheme: string; readonly sha256: string };
|
|
163
|
+
readonly verdict?: PinVerdict;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const HEX = /^[0-9a-f]{64}$/;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* hostd's grammar for a pin line, the verb optional; `undefined` for a blank or comment line, and a thrown Error for a
|
|
170
|
+
* line the daemon would refuse (a path holding a separator, a path that climbs out of the bundle, a digest that is not
|
|
171
|
+
* 64 lowercase hex digits, a layout without a scheme, a verdict outside the vocabulary, a path named twice).
|
|
172
|
+
*/
|
|
173
|
+
export function parsePinLine(line: string): ParsedPin | undefined {
|
|
174
|
+
const words = line.trim().split(/\s+/).filter((w) => w !== "");
|
|
175
|
+
if (words.length === 0 || words[0]!.startsWith("#")) return undefined;
|
|
176
|
+
if (words[0] === PIN_VERB) words.shift();
|
|
177
|
+
if (words.length !== 3 && words.length !== 4) throw new Error(`a pin line has 3 or 4 words, not ${words.length}`);
|
|
178
|
+
const [uri, list, layout, verdictWord] = words as [string, string, string, string | undefined];
|
|
179
|
+
const files = list.split(",").map((item) => {
|
|
180
|
+
const eq = item.lastIndexOf("=");
|
|
181
|
+
if (eq < 0) throw new Error(`${item}: no =`);
|
|
182
|
+
const path = item.slice(0, eq);
|
|
183
|
+
const sha256 = item.slice(eq + 1);
|
|
184
|
+
if (path === "" || path.startsWith("/") || path.split("/").some((part) => part === "" || part === "..")) {
|
|
185
|
+
throw new Error(`${path}: not a path inside the bundle`);
|
|
186
|
+
}
|
|
187
|
+
if (!HEX.test(sha256)) throw new Error(`${path}: not a sha256`);
|
|
188
|
+
return { path, sha256 };
|
|
189
|
+
});
|
|
190
|
+
if (new Set(files.map((f) => f.path)).size !== files.length) throw new Error("a path is named twice");
|
|
191
|
+
const colon = layout.lastIndexOf(":");
|
|
192
|
+
if (colon <= 0 || !HEX.test(layout.slice(colon + 1))) throw new Error(`${layout}: not a layout pin`);
|
|
193
|
+
let verdict: PinVerdict | undefined;
|
|
194
|
+
if (verdictWord !== undefined) {
|
|
195
|
+
const word = verdictWord.startsWith(PIN_VERDICT_KEY) ? verdictWord.slice(PIN_VERDICT_KEY.length) : "";
|
|
196
|
+
if (!(PIN_VERDICTS as readonly string[]).includes(word)) throw new Error(`${verdictWord}: not a verdict`);
|
|
197
|
+
verdict = word as PinVerdict;
|
|
198
|
+
}
|
|
199
|
+
return { uri, files, layout: { scheme: layout.slice(0, colon), sha256: layout.slice(colon + 1) }, ...(verdict === undefined ? {} : { verdict }) };
|
|
200
|
+
}
|
package/src/qualify-cli.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface QualifyArgs {
|
|
|
27
27
|
readonly rates: readonly number[];
|
|
28
28
|
readonly quanta: readonly number[];
|
|
29
29
|
readonly out?: string;
|
|
30
|
+
/** `--pins`: where the pins file is written. */
|
|
31
|
+
readonly pins?: string;
|
|
30
32
|
readonly format: OutputFormat;
|
|
31
33
|
readonly isolate: boolean;
|
|
32
34
|
readonly hostSweep: boolean;
|
|
@@ -83,6 +85,8 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
|
|
|
83
85
|
return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
|
|
84
86
|
}
|
|
85
87
|
const out = seen.get("--out") as string | undefined;
|
|
88
|
+
const pins = seen.get("--pins") as string | undefined;
|
|
89
|
+
if (pins !== undefined && pins.startsWith("--")) return { ok: false, error: "--pins needs a file name" };
|
|
86
90
|
const hostProfiles = [...new Set(profileSpecs.length > 0 ? profileSpecs : [defaultOf("--host-profile")])];
|
|
87
91
|
return {
|
|
88
92
|
ok: true,
|
|
@@ -92,6 +96,7 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
|
|
|
92
96
|
rates,
|
|
93
97
|
quanta,
|
|
94
98
|
...(out === undefined ? {} : { out }),
|
|
99
|
+
...(pins === undefined ? {} : { pins }),
|
|
95
100
|
format: format as OutputFormat,
|
|
96
101
|
isolate: seen.has("--isolate"),
|
|
97
102
|
hostSweep: !seen.has("--no-host-sweep"),
|
|
@@ -111,7 +116,9 @@ export function helpText(): string {
|
|
|
111
116
|
"plugin-qualify — qualify audio plugins for real-time hosting",
|
|
112
117
|
"",
|
|
113
118
|
" plugin-qualify <uri|bundle-path>... exactly the named plugins; a .lv2 bundle = every plugin in it",
|
|
114
|
-
" plugin-qualify --all every plugin
|
|
119
|
+
" plugin-qualify --all every CLAP plugin on CLAP_PATH, then ~/.clap, then /usr/lib/clap;",
|
|
120
|
+
" refuses (exit 2) when LV2 bundles are on the LV2 path (LV2_PATH),",
|
|
121
|
+
" which this entry point cannot measure",
|
|
115
122
|
"",
|
|
116
123
|
...rows,
|
|
117
124
|
"",
|
|
@@ -69,6 +69,15 @@ export const SOAK_CALIBRATION_S = 30;
|
|
|
69
69
|
export const CLAP_SCAN_TOOL_ENV = "OMX_CLAP_SCAN";
|
|
70
70
|
export const CLAP_SCAN_TOOL_DEFAULT = "omx-clap-scan";
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The standard CLAP search path `--all` walks, in this order: every directory `CLAP_PATH` names
|
|
74
|
+
* (colon-separated), then `~/.clap`, then `/usr/lib/clap`. The system directory is the same on
|
|
75
|
+
* every distribution and architecture; it is never the library directory (`/usr/lib64`).
|
|
76
|
+
*/
|
|
77
|
+
export const CLAP_PATH_ENV = "CLAP_PATH";
|
|
78
|
+
export const CLAP_USER_DIR = ".clap";
|
|
79
|
+
export const CLAP_SYSTEM_DIR = "/usr/lib/clap";
|
|
80
|
+
|
|
72
81
|
/** Seconds one run of the CLAP scanner may take over everything it was pointed at. */
|
|
73
82
|
export const CLAP_SCAN_TIMEOUT_S = 300;
|
|
74
83
|
|
package/src/qualify-options.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface QualifyOption {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
|
|
35
|
-
{ flag: "--all", describe: "every plugin
|
|
35
|
+
{ flag: "--all", describe: "every CLAP plugin on the CLAP search path; refuses when LV2 bundles are on the LV2 path" },
|
|
36
36
|
{ flag: "--rates", value: "list", describe: "sample rates to measure at, comma-separated", default: QUALIFY_RATES.join(",") },
|
|
37
37
|
{ flag: "--quanta", value: "list", describe: "quanta (frames) to rate cost at, comma-separated", default: QUALIFY_QUANTA.join(",") },
|
|
38
38
|
{ flag: "--out", value: "dir", describe: "write one interchange JSON document per run into this directory" },
|
|
@@ -45,5 +45,11 @@ export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
|
|
|
45
45
|
describe: `judge against this host (${HOST_PROFILES.map((p) => p.id).join(", ")}, or a profile JSON file); repeat or comma-separate for one verdict per profile`,
|
|
46
46
|
default: OPENMIXER_CONSOLE_PROFILE.id,
|
|
47
47
|
},
|
|
48
|
+
{
|
|
49
|
+
flag: "--pins",
|
|
50
|
+
value: "file",
|
|
51
|
+
describe:
|
|
52
|
+
"write one pin per qualified plugin to this file, with its verdict, for plugin-hostd's pin_file (the verdict is the first host profile's)",
|
|
53
|
+
},
|
|
48
54
|
{ flag: "--help", describe: "this text" },
|
|
49
55
|
];
|
package/tools/lv2-measure.mjs
CHANGED
|
@@ -85,6 +85,22 @@ const HELP = `${TOOL_NAME} ${TOOL_VERSION} — measure LV2 plugin latency and CP
|
|
|
85
85
|
--dry-run Print the plan and probe nothing.
|
|
86
86
|
--progress-json Emit machine-readable progress on stderr, one '@progress {...}' per line.
|
|
87
87
|
--help This text.
|
|
88
|
+
|
|
89
|
+
The hosting pass's host features are omx-clap-core's LV2 feature provider; each of its options is
|
|
90
|
+
a flag, passed to every run of lv2_measure and named in each plugin's record (featureOptions):
|
|
91
|
+
|
|
92
|
+
--features LIST Kinds offered: map,unmap,options,bounded,worker,default-state,log
|
|
93
|
+
(default: all but default-state; empty: none).
|
|
94
|
+
--strict Refuse a plugin requiring a feature not offered, naming it (refusedFeature).
|
|
95
|
+
--no-strict Hand it to its own instantiate without it (default).
|
|
96
|
+
--block-declares LIST Buf-size declarations when bounded is offered: bounded,fixed,pow2
|
|
97
|
+
(default: bounded; empty: none).
|
|
98
|
+
--block-min N bufsz:minBlockLength (default: 1; 0: the run's block).
|
|
99
|
+
--block-max N bufsz:maxBlockLength (default: 0, the run's block).
|
|
100
|
+
--block-nominal N bufsz:nominalBlockLength (default: 0, the maximum).
|
|
101
|
+
--log MODE log:log lines: silent, route (to stderr) or capture (into the record)
|
|
102
|
+
(default: silent).
|
|
103
|
+
--log-capture-bytes N The capture buffer, its NUL included (default: 4096).
|
|
88
104
|
`;
|
|
89
105
|
|
|
90
106
|
function fail(message, code = 2) {
|