@openmixer/plugin-qualify 0.0.0-stage → 0.3.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/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 +354 -0
- package/dist/hosting-suitability.js +654 -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 +91 -3
- 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 +982 -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,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/** The generated `tools/_declarations.py` body, from `src/qualify-declarations.ts`. */
|
|
4
|
+
export function declarationsFile(decl?: Readonly<Record<string, unknown>>): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
4
|
+
/*
|
|
5
|
+
* gen-python-declarations.mjs — `tools/_declarations.py`, generated from the package's ONE
|
|
6
|
+
* declarations module, `src/qualify-declarations.ts`.
|
|
7
|
+
* `benchmark.py`'s `DEFAULT_BLOCK`, `DEFAULT_RATES` and timeouts are these facts under its own names.
|
|
8
|
+
*
|
|
9
|
+
* `benchmark.py` cannot import a TS module — that scan's own comment already names the mirror as
|
|
10
|
+
* "known" — so this is the `omx_contract_limits.h` shape in its cheapest form: a file emitted on
|
|
11
|
+
* demand, committed, and checked byte-identical rather than hand-typed twice.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node tools/gen-python-declarations.mjs --check # exit 1 if the committed file is stale
|
|
15
|
+
* node tools/gen-python-declarations.mjs --write # (re)write the committed file
|
|
16
|
+
* node tools/gen-python-declarations.mjs --emit-stdout # print it (R-094's cross-language arm)
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join, resolve, dirname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const PKG = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const OUT = join(PKG, 'tools/_declarations.py');
|
|
24
|
+
|
|
25
|
+
const D = await import(join(PKG, 'dist/qualify-declarations.js'));
|
|
26
|
+
|
|
27
|
+
/** The declarations the Python tools read, in the order the file states them. */
|
|
28
|
+
const PY_NAMES = [
|
|
29
|
+
'COST_REFERENCE_RATE',
|
|
30
|
+
'QUANTUM_FRAMES',
|
|
31
|
+
'MEASURE_BASE_RATE',
|
|
32
|
+
'OPERATIONAL_RATE_FLOOR',
|
|
33
|
+
'QUALIFY_RATES',
|
|
34
|
+
'QUALIFY_QUANTA',
|
|
35
|
+
'PLUGIN_TIMEOUT_S',
|
|
36
|
+
'BOOT_TIMEOUT_S',
|
|
37
|
+
'COST_TIMEOUT_S',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const py = (v) => (Array.isArray(v) ? `(${v.join(', ')})` : String(v));
|
|
41
|
+
|
|
42
|
+
/** `decl` defaults to the built declarations; a test passes moved ones to prove the Python side follows. */
|
|
43
|
+
export function declarationsFile(decl = D) {
|
|
44
|
+
return (
|
|
45
|
+
`# SPDX-License-Identifier: GPL-3.0-or-later\n` +
|
|
46
|
+
`# Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>\n` +
|
|
47
|
+
`#\n` +
|
|
48
|
+
`# GENERATED by tools/gen-python-declarations.mjs — do not hand-edit.\n` +
|
|
49
|
+
`#\n` +
|
|
50
|
+
`# Source: src/qualify-declarations.ts. The Python tools cannot import a TS module, so this file\n` +
|
|
51
|
+
`# stands in for that import: regenerated and checked byte-identical instead of a second,\n` +
|
|
52
|
+
`# hand-typed copy of the figures (2026-09-17 declare-derive audit, F8).\n` +
|
|
53
|
+
`#\n` +
|
|
54
|
+
`# Regenerate: node tools/gen-python-declarations.mjs --write\n` +
|
|
55
|
+
`# Verify: node tools/gen-python-declarations.mjs --check\n` +
|
|
56
|
+
`\n` +
|
|
57
|
+
PY_NAMES.map((n) => `${n} = ${py(decl[n])}\n`).join('')
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function main() {
|
|
62
|
+
const mode = process.argv[2];
|
|
63
|
+
const content = declarationsFile();
|
|
64
|
+
if (mode === '--emit-stdout') {
|
|
65
|
+
process.stdout.write(content);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (mode === '--check') {
|
|
69
|
+
let onDisk;
|
|
70
|
+
try {
|
|
71
|
+
onDisk = readFileSync(OUT, 'utf8');
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(`gen-python-declarations: cannot read ${OUT}: ${err.message}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
if (onDisk !== content) {
|
|
77
|
+
console.error(`gen-python-declarations: ${OUT} is stale — run with --write`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
console.log('gen-python-declarations: up to date');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (mode === '--write') {
|
|
84
|
+
writeFileSync(OUT, content);
|
|
85
|
+
console.log(`gen-python-declarations: wrote ${OUT}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.error('usage: node tools/gen-python-declarations.mjs --check|--write|--emit-stdout');
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The hosting sweep directory → ONE interchange document. Shared by `curate.mjs --hosting-sweep`
|
|
5
|
+
* (which writes it into `data/hosting-measurements.json`) and `hosting-report.mjs` (which lands it
|
|
6
|
+
* in memory before it classifies), so the report and the served catalog read the sweep through
|
|
7
|
+
* the same reader and the same door.
|
|
8
|
+
*
|
|
9
|
+
* The directory carries its own run record, `sweep-run.json`: who measured, on what, when, and
|
|
10
|
+
* which JSONL file is which pass. A figure without its conditions is not a measurement.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
buildMeasurementDocument,
|
|
17
|
+
buildRun,
|
|
18
|
+
measuredPluginsFromSweep,
|
|
19
|
+
parseJsonl,
|
|
20
|
+
} from '../dist/measurement/index.js';
|
|
21
|
+
|
|
22
|
+
/** The dimensions a hosting sweep covers. */
|
|
23
|
+
export const HOSTING_DIMENSIONS = ['stability', 'rtSafety', 'features'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read `<dir>/sweep-run.json` and the passes it names, and build the document.
|
|
27
|
+
* Throws when the run record or the measurements pass is missing — no default run, no guessed file.
|
|
28
|
+
*/
|
|
29
|
+
export function hostingSweepDocument(dir) {
|
|
30
|
+
const run = JSON.parse(readFileSync(join(dir, 'sweep-run.json'), 'utf8'));
|
|
31
|
+
const passes = run.passes ?? {};
|
|
32
|
+
if (!passes.measurements) throw new Error(`${dir}/sweep-run.json names no measurements pass`);
|
|
33
|
+
const read = (name) => (name ? parseJsonl(readFileSync(join(dir, name), 'utf8')) : undefined);
|
|
34
|
+
const plugins = measuredPluginsFromSweep({
|
|
35
|
+
measurements: read(passes.measurements),
|
|
36
|
+
lifecycle: read(passes.lifecycle),
|
|
37
|
+
soak: read(passes.soak),
|
|
38
|
+
});
|
|
39
|
+
return buildMeasurementDocument(
|
|
40
|
+
buildRun({
|
|
41
|
+
id: run.id,
|
|
42
|
+
measuredAt: run.measuredAt,
|
|
43
|
+
dimensions: HOSTING_DIMENSIONS,
|
|
44
|
+
rates: run.rates,
|
|
45
|
+
tool: run.tool,
|
|
46
|
+
host: run.host,
|
|
47
|
+
...(run.label ? { label: run.label } : {}),
|
|
48
|
+
}),
|
|
49
|
+
plugins,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
4
|
+
/**
|
|
5
|
+
* `lv2-measure` — measure the LV2 plugins on THIS machine and write a versioned
|
|
6
|
+
* measurement document.
|
|
7
|
+
*
|
|
8
|
+
* Standalone by construction: it needs `python3` with the `lilv` bindings, the two probers
|
|
9
|
+
* next to this file (`scan.py`, `benchmark.py`), and an LV2 world to look at. It does not
|
|
10
|
+
* need the OpenMixer engine, its server, PipeWire, JACK, an audio interface, or root. Nothing
|
|
11
|
+
* it writes is openmixer-shaped — the output is the interchange format described in
|
|
12
|
+
* `src/measurement/format.ts`, which any LV2 host can read.
|
|
13
|
+
*
|
|
14
|
+
* This file is deliberately thin. Every decision — argument parsing, the pass order, the
|
|
15
|
+
* progress heuristic, the assembly of the document — lives in `src/measurement/cli.ts` and
|
|
16
|
+
* is unit-tested there without probing anything. What is left here is spawning processes and
|
|
17
|
+
* moving bytes, which is the part that cannot be tested without 958 real plugins.
|
|
18
|
+
*
|
|
19
|
+
* ## Usage
|
|
20
|
+
*
|
|
21
|
+
* ```sh
|
|
22
|
+
* pnpm build # this shell reads the compiled dist/
|
|
23
|
+
* node tools/lv2-measure.mjs --rates 48000,96000 --out measurements.json --run
|
|
24
|
+
* node tools/lv2-measure.mjs --rates 48000 --dimensions latency --dry-run
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* `--run` (or `LV2_MEASURE_RUN=1`) is required to probe: the passes instantiate and execute
|
|
28
|
+
* every plugin on the machine, which is CPU-heavy and occasionally fatal to the probe worker.
|
|
29
|
+
* `--dry-run` prints the plan and touches nothing.
|
|
30
|
+
*
|
|
31
|
+
* ## Machine-readable progress
|
|
32
|
+
*
|
|
33
|
+
* With `--progress-json`, one JSON object per line is written to stderr, each prefixed
|
|
34
|
+
* `@progress `. A supervising process (the OpenMixer console's Setup screen) parses those and
|
|
35
|
+
* ignores everything else, so the human log stays readable.
|
|
36
|
+
*/
|
|
37
|
+
import { spawn } from "node:child_process";
|
|
38
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { hostname } from "node:os";
|
|
40
|
+
import { dirname, join, resolve } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
advanceProgress,
|
|
45
|
+
assembleDocument,
|
|
46
|
+
buildPipeline,
|
|
47
|
+
collectHostFacts,
|
|
48
|
+
INITIAL_PROGRESS,
|
|
49
|
+
parseMeasureArgs,
|
|
50
|
+
parseProbeProgress,
|
|
51
|
+
serialiseMeasurementDocument,
|
|
52
|
+
TOOL_NAME,
|
|
53
|
+
TOOL_VERSION,
|
|
54
|
+
} from "../dist/measurement/index.js";
|
|
55
|
+
|
|
56
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
57
|
+
|
|
58
|
+
const HELP = `${TOOL_NAME} ${TOOL_VERSION} — measure LV2 plugin latency and CPU cost on this machine.
|
|
59
|
+
|
|
60
|
+
--rates 48000,96000 REQUIRED. Sample rates to measure at. There is no default:
|
|
61
|
+
a figure without a stated rate is not a measurement.
|
|
62
|
+
--dimensions latency,cost
|
|
63
|
+
What to measure (default: both).
|
|
64
|
+
--out PATH Where the document goes (default: stdout).
|
|
65
|
+
--scan PATH Reuse an existing prober scan instead of scanning.
|
|
66
|
+
--work-dir PATH Where intermediates go (default: alongside --out, or a temp dir).
|
|
67
|
+
--tool-dir PATH Where scan.py / benchmark.py live (default: next to this file).
|
|
68
|
+
--python BIN Python interpreter (default: $PYTHON or python3).
|
|
69
|
+
--sweep Also sweep latency-bearing controls (finds parameter-dependent latency).
|
|
70
|
+
--cost-cpu N Pin the cost pass to core N.
|
|
71
|
+
--label TEXT A note recorded with the run.
|
|
72
|
+
--run Required to probe. Instantiates and runs every plugin.
|
|
73
|
+
--dry-run Print the plan and probe nothing.
|
|
74
|
+
--progress-json Emit machine-readable progress on stderr, one '@progress {...}' per line.
|
|
75
|
+
--help This text.
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
function fail(message, code = 2) {
|
|
79
|
+
process.stderr.write(`${TOOL_NAME}: ${message}\n`);
|
|
80
|
+
process.exit(code);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readJson(path) {
|
|
84
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Run one pipeline step, forwarding its stderr and counting the plugins it reports. */
|
|
88
|
+
function runStep(step, onProgress) {
|
|
89
|
+
return new Promise((settle, reject) => {
|
|
90
|
+
const child = spawn(step.command, [...step.args], { stdio: ["ignore", "inherit", "pipe"] });
|
|
91
|
+
let pending = "";
|
|
92
|
+
child.stderr.setEncoding("utf8");
|
|
93
|
+
child.stderr.on("data", (chunk) => {
|
|
94
|
+
pending += chunk;
|
|
95
|
+
const lines = pending.split("\n");
|
|
96
|
+
pending = lines.pop() ?? "";
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
process.stderr.write(`${line}\n`);
|
|
99
|
+
const progress = parseProbeProgress(line);
|
|
100
|
+
if (progress !== undefined) onProgress(step, progress);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
child.on("error", reject);
|
|
104
|
+
child.on("close", (code) => {
|
|
105
|
+
if (pending.length > 0) process.stderr.write(`${pending}\n`);
|
|
106
|
+
if (code === 0) settle();
|
|
107
|
+
else reject(new Error(`${step.id} exited ${code}`));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function main() {
|
|
113
|
+
const argv = process.argv.slice(2);
|
|
114
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
115
|
+
process.stdout.write(HELP);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const progressJson = argv.includes("--progress-json");
|
|
119
|
+
const parsed = parseMeasureArgs(
|
|
120
|
+
argv.filter((a) => a !== "--progress-json"),
|
|
121
|
+
process.env,
|
|
122
|
+
{ toolDir: HERE, workDir: join(process.cwd(), ".lv2-measure") },
|
|
123
|
+
);
|
|
124
|
+
if (!parsed.ok) fail(`${parsed.error}\n\n${HELP}`);
|
|
125
|
+
const options = parsed.options;
|
|
126
|
+
|
|
127
|
+
const pipeline = buildPipeline(options);
|
|
128
|
+
|
|
129
|
+
if (options.dryRun) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`${TOOL_NAME} would measure ${options.dimensions.join(" + ")} at ${options.rates.join(", ")} Hz\n`,
|
|
132
|
+
);
|
|
133
|
+
for (const step of pipeline.steps) {
|
|
134
|
+
process.stderr.write(` ${step.id}: ${step.command} ${step.args.join(" ")}\n`);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!options.run) {
|
|
139
|
+
fail("refusing to probe without --run (or LV2_MEASURE_RUN=1): this instantiates and runs every LV2 plugin on this machine");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
mkdirSync(options.workDir, { recursive: true });
|
|
143
|
+
// Only the FIRST step's input can be checked up front — every later step reads what the
|
|
144
|
+
// one before it wrote, and a missing intermediate is a failed step, reported as one.
|
|
145
|
+
if (options.scanPath !== undefined && !existsSync(options.scanPath)) {
|
|
146
|
+
fail(`--scan: no such file: ${options.scanPath}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Progress is advisory: the phase banner supplies the denominator, the indented result
|
|
150
|
+
// lines supply the numerator. A prober whose log we cannot parse simply stalls the bar.
|
|
151
|
+
let phase = INITIAL_PROGRESS;
|
|
152
|
+
const emit = (event) => {
|
|
153
|
+
if (progressJson) process.stderr.write(`@progress ${JSON.stringify(event)}\n`);
|
|
154
|
+
};
|
|
155
|
+
const onProgress = (step, progress) => {
|
|
156
|
+
const next = advanceProgress(phase, progress);
|
|
157
|
+
if (next === phase) return; // clamped: the phase's own summary lines are not plugins
|
|
158
|
+
phase = next;
|
|
159
|
+
emit({ step: step.id, dimension: step.dimension ?? null, ...phase });
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const startedAt = Date.now();
|
|
163
|
+
const measuredAt = new Date().toISOString();
|
|
164
|
+
emit({ step: "start", steps: pipeline.steps.map((s) => s.id) });
|
|
165
|
+
for (const step of pipeline.steps) {
|
|
166
|
+
process.stderr.write(`== ${step.id}\n`);
|
|
167
|
+
await runStep(step, onProgress);
|
|
168
|
+
}
|
|
169
|
+
const elapsedSeconds = (Date.now() - startedAt) / 1000;
|
|
170
|
+
|
|
171
|
+
const document = assembleDocument({
|
|
172
|
+
options,
|
|
173
|
+
catalog: readJson(pipeline.finalCatalog),
|
|
174
|
+
costProvenance:
|
|
175
|
+
pipeline.costProvenance !== undefined && existsSync(pipeline.costProvenance)
|
|
176
|
+
? readJson(pipeline.costProvenance)
|
|
177
|
+
: undefined,
|
|
178
|
+
host: collectHostFacts({
|
|
179
|
+
// Both false and recorded as such: this tool deliberately requires no privileges, so
|
|
180
|
+
// claiming otherwise would make its figures look better conditioned than they are.
|
|
181
|
+
realtimePriority: false,
|
|
182
|
+
memoryLocked: false,
|
|
183
|
+
...(options.costCpu === undefined ? {} : { pinnedCpu: options.costCpu }),
|
|
184
|
+
}),
|
|
185
|
+
runId: `${hostname()}-${measuredAt}`,
|
|
186
|
+
measuredAt,
|
|
187
|
+
elapsedSeconds,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const serialised = serialiseMeasurementDocument(document);
|
|
191
|
+
if (options.outPath === undefined) process.stdout.write(serialised);
|
|
192
|
+
else writeFileSync(resolve(options.outPath), serialised);
|
|
193
|
+
|
|
194
|
+
const measured = document.plugins.filter(
|
|
195
|
+
(p) => Object.keys(p.latency?.perRate ?? {}).length > 0 || Object.keys(p.cost?.perRate ?? {}).length > 0,
|
|
196
|
+
).length;
|
|
197
|
+
process.stderr.write(
|
|
198
|
+
`${TOOL_NAME}: ${document.plugins.length} plugin(s), ${measured} with at least one figure, ` +
|
|
199
|
+
`${elapsedSeconds.toFixed(1)} s -> ${options.outPath ?? "stdout"}\n`,
|
|
200
|
+
);
|
|
201
|
+
emit({ step: "done", plugins: document.plugins.length, measured, elapsedSeconds });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err), 1));
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
"""
|
|
4
|
+
The presets an LV2 plugin SHIPS, read off its bundle with lilv.
|
|
5
|
+
|
|
6
|
+
A preset is a `pset:Preset` resource related to the plugin (`plugin.get_related`). For each one
|
|
7
|
+
this reads its label, its bank, the port values it sets and whether it carries a `state:state`
|
|
8
|
+
body, and judges it against the plugin's own descriptor params:
|
|
9
|
+
|
|
10
|
+
- `carries`: "params" (port values, no state body), "params+state" (both), "state" (a body only);
|
|
11
|
+
- `values`: the values for the plugin's PARAMETERS. A value for a port the plugin has but no
|
|
12
|
+
host sets — an output meter, a hidden (`notOnGUI`) or designated port, which hosts that save
|
|
13
|
+
presets often record — is left out: it is not a parameter, and it is not an error either;
|
|
14
|
+
- `invalid`: "UNKNOWN_SYMBOL" when a value names a symbol that is no port of the plugin at all,
|
|
15
|
+
"OUT_OF_TRAVEL" when a value lies outside that param's [min, max]. An invalid preset stays
|
|
16
|
+
listed — refuse or report, never drop.
|
|
17
|
+
|
|
18
|
+
Only the bundles lilv loads from LV2_PATH are read, which is what the scan reads: the presets a
|
|
19
|
+
plugin ships, never one another host saved in a user's home (ruling 2). The output is sorted by
|
|
20
|
+
preset URI and the values by symbol, so the same install yields the same bytes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import struct
|
|
24
|
+
|
|
25
|
+
PSET = "http://lv2plug.in/ns/ext/presets#"
|
|
26
|
+
STATE = "http://lv2plug.in/ns/ext/state#"
|
|
27
|
+
LV2 = "http://lv2plug.in/ns/lv2core#"
|
|
28
|
+
RDFS = "http://www.w3.org/2000/01/rdf-schema#"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def as_float(node):
|
|
32
|
+
"""A preset value node as a float — the TTL LITERAL, or None when it is not a number.
|
|
33
|
+
|
|
34
|
+
The literal first: lilv's float() goes through a C float, so "0.478000" comes back as
|
|
35
|
+
0.4779999852..., and a host's param_get ("%f", 0.478000) would then read as an edit nobody
|
|
36
|
+
made the moment the preset loaded.
|
|
37
|
+
"""
|
|
38
|
+
if node is None:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return float(str(node))
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
try:
|
|
44
|
+
return float(node)
|
|
45
|
+
except (ValueError, TypeError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def carries_of(values, has_state):
|
|
50
|
+
"""What a preset sets — the three words of the spec, from what the TTL holds."""
|
|
51
|
+
if has_state and values:
|
|
52
|
+
return "params+state"
|
|
53
|
+
if has_state:
|
|
54
|
+
return "state"
|
|
55
|
+
return "params"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def invalid_of(values, params):
|
|
59
|
+
"""The first reason a preset can never be loaded against `params`, or None.
|
|
60
|
+
|
|
61
|
+
`params` are the descriptor's CONTROL params (symbol, min, max). A symbol the descriptor does
|
|
62
|
+
not carry is checked before the travel: a value for a port that does not exist has no travel.
|
|
63
|
+
"""
|
|
64
|
+
by_symbol = {p["symbol"]: p for p in params if p.get("kind", "control") == "control"}
|
|
65
|
+
for v in values:
|
|
66
|
+
if v["symbol"] not in by_symbol:
|
|
67
|
+
return "UNKNOWN_SYMBOL"
|
|
68
|
+
for v in values:
|
|
69
|
+
p = by_symbol[v["symbol"]]
|
|
70
|
+
lo, hi = p.get("min"), p.get("max")
|
|
71
|
+
# The travel's ends were scanned through a C float (lilv), the value is the TTL literal:
|
|
72
|
+
# compare at the port's own precision, or "0.707" sits below its own min of "0.707".
|
|
73
|
+
value = as_port_float(v["value"])
|
|
74
|
+
if lo is not None and value < lo:
|
|
75
|
+
return "OUT_OF_TRAVEL"
|
|
76
|
+
if hi is not None and value > hi:
|
|
77
|
+
return "OUT_OF_TRAVEL"
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def as_port_float(value):
|
|
82
|
+
"""`value` rounded to the C float an LV2 control port holds."""
|
|
83
|
+
return struct.unpack("f", struct.pack("f", value))[0]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def preset_entry(uri, name, bank, values, has_state, params, port_symbols=frozenset()):
|
|
87
|
+
"""One descriptor `presets[]` entry, with absent fields left out rather than null.
|
|
88
|
+
|
|
89
|
+
`port_symbols` are every port of the plugin; a value for one of them that is not a control
|
|
90
|
+
PARAM is dropped (see the module header), any other unknown symbol keeps the preset invalid.
|
|
91
|
+
"""
|
|
92
|
+
controls = {p["symbol"] for p in params if p.get("kind", "control") == "control"}
|
|
93
|
+
values = sorted((v for v in values if v["symbol"] in controls or v["symbol"] not in port_symbols),
|
|
94
|
+
key=lambda v: v["symbol"])
|
|
95
|
+
out = {"uri": uri, "name": name}
|
|
96
|
+
if bank:
|
|
97
|
+
out["bank"] = bank
|
|
98
|
+
out["carries"] = carries_of(values, has_state)
|
|
99
|
+
if values:
|
|
100
|
+
out["values"] = values
|
|
101
|
+
reason = invalid_of(values, params)
|
|
102
|
+
if reason:
|
|
103
|
+
out["invalid"] = reason
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_presets(world, plugin, params):
|
|
108
|
+
"""Every preset `plugin` ships, as descriptor entries sorted by URI."""
|
|
109
|
+
uri = world.new_uri
|
|
110
|
+
port_symbols = frozenset(str(plugin.get_port_by_index(i).get_symbol())
|
|
111
|
+
for i in range(plugin.get_num_ports()))
|
|
112
|
+
related = plugin.get_related(uri(PSET + "Preset"))
|
|
113
|
+
out = []
|
|
114
|
+
for node in (related if related is not None else []):
|
|
115
|
+
world.load_resource(node)
|
|
116
|
+
label = world.get(node, uri(RDFS + "label"), None)
|
|
117
|
+
bank_node = world.get(node, uri(PSET + "bank"), None)
|
|
118
|
+
bank = None
|
|
119
|
+
if bank_node is not None:
|
|
120
|
+
bank_label = world.get(bank_node, uri(RDFS + "label"), None)
|
|
121
|
+
bank = str(bank_label) if bank_label is not None else None
|
|
122
|
+
values = []
|
|
123
|
+
for port in (world.find_nodes(node, uri(LV2 + "port"), None) or []):
|
|
124
|
+
symbol = world.get(port, uri(LV2 + "symbol"), None)
|
|
125
|
+
value = as_float(world.get(port, uri(PSET + "value"), None))
|
|
126
|
+
if symbol is not None and value is not None:
|
|
127
|
+
values.append({"symbol": str(symbol), "value": value})
|
|
128
|
+
has_state = world.get(node, uri(STATE + "state"), None) is not None
|
|
129
|
+
name = str(label) if label is not None else str(node)
|
|
130
|
+
out.append(preset_entry(str(node), name, bank, values, has_state, params, port_symbols))
|
|
131
|
+
out.sort(key=lambda p: p["uri"])
|
|
132
|
+
return out
|