@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.
Files changed (114) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +203 -2
  3. package/bin/plugin-qualify.mjs +77 -0
  4. package/dist/clap-loader.d.ts +147 -0
  5. package/dist/clap-loader.js +252 -0
  6. package/dist/clap-loader.js.map +1 -0
  7. package/dist/clap-qualify.d.ts +126 -0
  8. package/dist/clap-qualify.js +272 -0
  9. package/dist/clap-qualify.js.map +1 -0
  10. package/dist/host-profiles.d.ts +42 -0
  11. package/dist/host-profiles.js +106 -0
  12. package/dist/host-profiles.js.map +1 -0
  13. package/dist/hosting-codes.d.ts +62 -0
  14. package/dist/hosting-codes.js +72 -0
  15. package/dist/hosting-codes.js.map +1 -0
  16. package/dist/hosting-suitability.d.ts +354 -0
  17. package/dist/hosting-suitability.js +654 -0
  18. package/dist/hosting-suitability.js.map +1 -0
  19. package/dist/index.d.ts +17 -0
  20. package/dist/index.js +20 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/loader.d.ts +26 -0
  23. package/dist/loader.js +9 -0
  24. package/dist/loader.js.map +1 -0
  25. package/dist/lv2-loader.d.ts +7 -0
  26. package/dist/lv2-loader.js +50 -0
  27. package/dist/lv2-loader.js.map +1 -0
  28. package/dist/measurement/cli.d.ts +168 -0
  29. package/dist/measurement/cli.js +353 -0
  30. package/dist/measurement/cli.js.map +1 -0
  31. package/dist/measurement/fixtures.d.ts +23 -0
  32. package/dist/measurement/fixtures.js +129 -0
  33. package/dist/measurement/fixtures.js.map +1 -0
  34. package/dist/measurement/format.d.ts +483 -0
  35. package/dist/measurement/format.js +627 -0
  36. package/dist/measurement/format.js.map +1 -0
  37. package/dist/measurement/guards.d.ts +33 -0
  38. package/dist/measurement/guards.js +61 -0
  39. package/dist/measurement/guards.js.map +1 -0
  40. package/dist/measurement/index.d.ts +36 -0
  41. package/dist/measurement/index.js +39 -0
  42. package/dist/measurement/index.js.map +1 -0
  43. package/dist/measurement/merge.d.ts +143 -0
  44. package/dist/measurement/merge.js +315 -0
  45. package/dist/measurement/merge.js.map +1 -0
  46. package/dist/measurement/plan.d.ts +127 -0
  47. package/dist/measurement/plan.js +141 -0
  48. package/dist/measurement/plan.js.map +1 -0
  49. package/dist/measurement/probe-json.d.ts +61 -0
  50. package/dist/measurement/probe-json.js +253 -0
  51. package/dist/measurement/probe-json.js.map +1 -0
  52. package/dist/measurement/provenance.d.ts +91 -0
  53. package/dist/measurement/provenance.js +173 -0
  54. package/dist/measurement/provenance.js.map +1 -0
  55. package/dist/measurement/sweep-jsonl.d.ts +40 -0
  56. package/dist/measurement/sweep-jsonl.js +154 -0
  57. package/dist/measurement/sweep-jsonl.js.map +1 -0
  58. package/dist/measurement/vocabulary.d.ts +125 -0
  59. package/dist/measurement/vocabulary.js +217 -0
  60. package/dist/measurement/vocabulary.js.map +1 -0
  61. package/dist/qualify-cli.d.ts +83 -0
  62. package/dist/qualify-cli.js +224 -0
  63. package/dist/qualify-cli.js.map +1 -0
  64. package/dist/qualify-declarations.d.ts +98 -0
  65. package/dist/qualify-declarations.js +76 -0
  66. package/dist/qualify-declarations.js.map +1 -0
  67. package/dist/qualify-options.d.ts +22 -0
  68. package/dist/qualify-options.js +36 -0
  69. package/dist/qualify-options.js.map +1 -0
  70. package/dist/rating.d.ts +32 -0
  71. package/dist/rating.js +30 -0
  72. package/dist/rating.js.map +1 -0
  73. package/dist/tool-paths.d.ts +12 -0
  74. package/dist/tool-paths.js +29 -0
  75. package/dist/tool-paths.js.map +1 -0
  76. package/fixtures/clap-scan/controls.json +8 -0
  77. package/fixtures/clap-scan/fake.json +16 -0
  78. package/fixtures/clap-scan/mixed.json +18 -0
  79. package/fixtures/zero-path.lv2/manifest.ttl +3 -0
  80. package/fixtures/zero-path.lv2/zero-path.c +85 -0
  81. package/fixtures/zero-path.lv2/zero-path.ttl +21 -0
  82. package/package.json +91 -3
  83. package/src/clap-loader.ts +393 -0
  84. package/src/clap-qualify.ts +387 -0
  85. package/src/host-profiles.ts +122 -0
  86. package/src/hosting-codes.ts +71 -0
  87. package/src/hosting-suitability.ts +982 -0
  88. package/src/index.ts +19 -0
  89. package/src/loader.ts +30 -0
  90. package/src/lv2-loader.ts +50 -0
  91. package/src/measurement/cli.ts +458 -0
  92. package/src/measurement/fixtures.ts +141 -0
  93. package/src/measurement/format.ts +1064 -0
  94. package/src/measurement/guards.ts +64 -0
  95. package/src/measurement/index.ts +39 -0
  96. package/src/measurement/merge.ts +434 -0
  97. package/src/measurement/plan.ts +219 -0
  98. package/src/measurement/probe-json.ts +271 -0
  99. package/src/measurement/provenance.ts +220 -0
  100. package/src/measurement/sweep-jsonl.ts +173 -0
  101. package/src/measurement/vocabulary.ts +298 -0
  102. package/src/qualify-cli.ts +277 -0
  103. package/src/qualify-declarations.ts +117 -0
  104. package/src/qualify-options.ts +49 -0
  105. package/src/rating.ts +53 -0
  106. package/src/tool-paths.ts +32 -0
  107. package/tools/_declarations.py +21 -0
  108. package/tools/benchmark.py +2462 -0
  109. package/tools/gen-python-declarations.d.mts +4 -0
  110. package/tools/gen-python-declarations.mjs +92 -0
  111. package/tools/hosting-sweep.mjs +51 -0
  112. package/tools/lv2-measure.mjs +204 -0
  113. package/tools/lv2_presets.py +132 -0
  114. 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