@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,298 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The controlled vocabularies the interchange format speaks.
5
+ *
6
+ * This is the boring file that decides whether two projects' measurements can be merged
7
+ * Everyone who has written an LV2 prober has invented their own
8
+ * words for "there was nothing to measure here", so nobody's results combine. The words
9
+ * are fixed here, each with the ONE property a merger actually needs to branch on —
10
+ * whether the plugin has nothing to measure or whether OUR PROBE failed — and each with
11
+ * a plain description a UI can render.
12
+ *
13
+ * Everything is a data table, not a switch. Adding a term is one row; a consumer that
14
+ * meets a term it does not know must be able to fall back on the `kind`, which is why the
15
+ * readers below return a term record rather than a bare string.
16
+ */
17
+
18
+ import { nonEmptyString } from "./guards.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Why a figure is absent
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * What an absent figure means for a MERGE, and it is the only distinction that changes
26
+ * behaviour:
27
+ *
28
+ * - `nothing-to-measure` — a property of the PLUGIN. An instrument has no audio input to
29
+ * drive; a meter has no audio output to read. Any prober on any machine reaches the
30
+ * same conclusion, so this outcome is as portable as a measurement and may override one.
31
+ * - `probe-failed` — a property of THIS RUN. The plugin crashed the worker, timed out,
32
+ * refused to instantiate. It says nothing about the plugin's latency or cost, so it
33
+ * must NOT override a figure someone else managed to take.
34
+ * - `not-attempted` — the run did not cover this plugin or this dimension at all. Neither
35
+ * a finding nor a failure; it is the shape a PARTIAL run has, and partial runs are
36
+ * first-class here.
37
+ */
38
+ export type UnmeasuredKind = "nothing-to-measure" | "probe-failed" | "not-attempted";
39
+
40
+ /** One term of the "why is there no figure" vocabulary. */
41
+ export interface UnmeasuredTerm {
42
+ readonly code: string;
43
+ readonly kind: UnmeasuredKind;
44
+ /** Plain description, English. A UI localises from `code`; this is for logs and specs. */
45
+ readonly description: string;
46
+ }
47
+
48
+ /**
49
+ * Every reason a figure may be absent. These were derived from a sweep of 958 plugins — each
50
+ * one is an outcome that sweep actually produced, not a hypothetical.
51
+ */
52
+ export const UNMEASURED_TERMS: readonly UnmeasuredTerm[] = [
53
+ {
54
+ code: "no-audio-in",
55
+ kind: "nothing-to-measure",
56
+ description: "No audio input port: an instrument, a generator or an analyser. Nothing to drive.",
57
+ },
58
+ {
59
+ code: "no-audio-out",
60
+ kind: "nothing-to-measure",
61
+ description: "No audio output port: a meter or a sink. Nothing to read.",
62
+ },
63
+ {
64
+ code: "silent-output",
65
+ kind: "nothing-to-measure",
66
+ description:
67
+ "Instantiated and driven, but emitted nothing under a stimulus that should have passed. " +
68
+ "A convolver with no impulse response, a vocoder with no carrier, a gate that never opened.",
69
+ },
70
+ {
71
+ code: "needs-carrier",
72
+ kind: "nothing-to-measure",
73
+ description: "Requires a second audio input (a carrier or side-chain) the prober did not supply.",
74
+ },
75
+ {
76
+ code: "needs-impulse-response",
77
+ kind: "nothing-to-measure",
78
+ description: "A convolver requiring an IR file the prober did not load.",
79
+ },
80
+ {
81
+ code: "refused-instantiation",
82
+ kind: "probe-failed",
83
+ description: "The plugin would not instantiate at this rate or block size.",
84
+ },
85
+ {
86
+ code: "probe-crashed",
87
+ kind: "probe-failed",
88
+ description: "The probe worker died (signal or abort) while hosting this plugin.",
89
+ },
90
+ {
91
+ code: "probe-timeout",
92
+ kind: "probe-failed",
93
+ description: "The probe exceeded its per-plugin time limit and was abandoned.",
94
+ },
95
+ {
96
+ code: "non-finite-output",
97
+ kind: "probe-failed",
98
+ description: "Output contained NaN or infinity, so no onset or block time could be trusted.",
99
+ },
100
+ {
101
+ code: "not-installed",
102
+ kind: "probe-failed",
103
+ description:
104
+ "The URI was not present in this machine's LV2 world. A fact about this installation, " +
105
+ "not about the plugin — another host may well have it.",
106
+ },
107
+ {
108
+ code: "probe-error",
109
+ kind: "probe-failed",
110
+ description:
111
+ "The probe reported a failure with no more specific term. Its own words are kept verbatim " +
112
+ "in the measurement's `unmeasuredDetail`, so a coarse code never destroys the detail.",
113
+ },
114
+ {
115
+ code: "not-attempted",
116
+ kind: "not-attempted",
117
+ description: "This run did not cover this plugin for this dimension. A partial run, not a finding.",
118
+ },
119
+ ];
120
+
121
+ const UNMEASURED_BY_CODE: ReadonlyMap<string, UnmeasuredTerm> = new Map(
122
+ UNMEASURED_TERMS.map((term) => [term.code, term]),
123
+ );
124
+
125
+ /**
126
+ * The vocabulary term for a code, or `undefined` when the code is not one of ours.
127
+ *
128
+ * A third-party document carrying an unknown reason is not an error — the format is meant
129
+ * to be extended by people we will never meet. {@link unmeasuredKind} is what a merger
130
+ * should consult, and it is deliberately conservative about strangers.
131
+ */
132
+ export function unmeasuredTerm(code: string | undefined): UnmeasuredTerm | undefined {
133
+ return code === undefined ? undefined : UNMEASURED_BY_CODE.get(code);
134
+ }
135
+
136
+ /**
137
+ * How a merger must treat an absence reason. An UNKNOWN code resolves to `probe-failed`,
138
+ * the conservative answer: a reason we cannot interpret must never be allowed to delete
139
+ * somebody else's real measurement, and treating it as a plugin property would do exactly
140
+ * that.
141
+ */
142
+ export function unmeasuredKind(code: string | undefined): UnmeasuredKind {
143
+ if (code === undefined) return "not-attempted";
144
+ return UNMEASURED_BY_CODE.get(code)?.kind ?? "probe-failed";
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // How latency moves with the sample rate
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * The rate-scaling laws, DERIVED from readings at several rates and never assumed from
153
+ * what a plugin is called. The population behind each term is in
154
+ * `CATALOG_PROVENANCE.md`'s 2026-07-28 rows.
155
+ */
156
+ export interface ScalingTerm {
157
+ readonly code: string;
158
+ readonly description: string;
159
+ }
160
+
161
+ export const SCALING_TERMS: readonly ScalingTerm[] = [
162
+ {
163
+ code: "fixed-frame",
164
+ description: "Constant frame count at every rate — an FFT window. Milliseconds halve when the rate doubles.",
165
+ },
166
+ {
167
+ code: "fixed-time",
168
+ description: "Constant milliseconds at every rate — a look-ahead. Frames double when the rate doubles.",
169
+ },
170
+ { code: "zero", description: "No latency a mix can notice at any measured rate." },
171
+ {
172
+ code: "nonconforming",
173
+ description:
174
+ "Fits neither law at a magnitude that reaches a mix: the plugin changes what it does with " +
175
+ "the rate. Flagged rather than averaged; nothing may be projected from it.",
176
+ },
177
+ { code: "single-rate", description: "One usable reading, so no law can be fitted." },
178
+ { code: "unmeasured", description: "No rate produced a figure." },
179
+ ];
180
+
181
+ const SCALING_CODES: ReadonlySet<string> = new Set(SCALING_TERMS.map((t) => t.code));
182
+
183
+ /** True when `code` is a scaling term this version of the format defines. */
184
+ export function isKnownScalingCode(code: string): boolean {
185
+ return SCALING_CODES.has(code);
186
+ }
187
+
188
+ /**
189
+ * Why a derived scaling class is not the naive fit over every reading — `below-budget`,
190
+ * where the readings fit no law but are all small enough that the disagreement is the onset
191
+ * detector's rather than the plugin's, and `informational-rate`, where only a reading below
192
+ * the operational rates breaks the law (`lv2-measurement-interchange.md` §1a).
193
+ */
194
+ export const SCALING_NOTE_TERMS = [
195
+ {
196
+ code: "below-budget",
197
+ description:
198
+ "The readings fit no law, but every one of them is inside the per-channel latency budget, " +
199
+ "so the disagreement is measurement noise rather than plugin behaviour.",
200
+ },
201
+ {
202
+ code: "informational-rate",
203
+ description:
204
+ "The readings together fit no law, but those at the operational rates (48 kHz and up) do; " +
205
+ "the reading below them stays in perRate as information and does not flag the plugin.",
206
+ },
207
+ ] as const satisfies readonly ScalingTerm[];
208
+
209
+ /** One of the {@link SCALING_NOTE_TERMS} codes — the words `latency.scalingNote` may carry. */
210
+ export type ScalingNoteCode = (typeof SCALING_NOTE_TERMS)[number]["code"];
211
+
212
+ /**
213
+ * Why one rate's reading was excluded from the fit and from every rate resolution. A
214
+ * failed reading served as an exact answer is worse than no reading, because a zero reads
215
+ * as confidence.
216
+ */
217
+ export const EXCLUSION_TERMS: readonly ScalingTerm[] = [
218
+ {
219
+ code: "impossible-zero",
220
+ description:
221
+ "Zero frames at this rate while another rate measured non-zero. Both scaling laws map zero " +
222
+ "to zero, so no single latency produces this; the onset detector was defeated.",
223
+ },
224
+ ];
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // How a figure was taken
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /** The measurement methods the format has names for. */
231
+ export const METHOD_TERMS: readonly ScalingTerm[] = [
232
+ {
233
+ code: "impulse-onset",
234
+ description:
235
+ "Latency as the round-trip delay from a unit impulse to the first output sample above the " +
236
+ "residual floor. Measures the plugin, not what it declares.",
237
+ },
238
+ {
239
+ code: "declared-port",
240
+ description: "Latency read from the plugin's own lv2:latency output port. What it CLAIMS.",
241
+ },
242
+ {
243
+ code: "block-time-percentile",
244
+ description:
245
+ "Cost as a percentile of per-block wall time under a sustained stimulus, warm-up blocks " +
246
+ "discarded, normalised to nanoseconds per sample.",
247
+ },
248
+ ];
249
+
250
+ const METHOD_CODES: ReadonlySet<string> = new Set(METHOD_TERMS.map((t) => t.code));
251
+
252
+ /** True when `code` is a method term this version of the format defines. */
253
+ export function isKnownMethodCode(code: string): boolean {
254
+ return METHOD_CODES.has(code);
255
+ }
256
+
257
+ /**
258
+ * The measurable dimensions. A document declares which of them it covers.
259
+ *
260
+ * `latency` and `cost` are 1.0's. `stability`, `rtSafety` and `features` joined in 1.1 for the
261
+ * hosting verdict — whether a plugin survives, whether its `run()` is RT-clean, what it
262
+ * demands of a host — and merge under exactly the same per-plugin, per-DIMENSION precedence.
263
+ */
264
+ export const MEASUREMENT_DIMENSIONS = ["latency", "cost", "stability", "rtSafety", "features"] as const;
265
+
266
+ /**
267
+ * The dimensions the standalone prober (`scan.py` + `benchmark.py`, driven by `lv2-measure.mjs`
268
+ * and the console's Setup analysis) can measure. The three hosting dimensions come from a
269
+ * different tool — the `lv2-measure.c` sweep, read by `sweep-jsonl.ts` — so a run asked of the
270
+ * prober for `stability` would be asking it for something it cannot take.
271
+ */
272
+ export const PROBED_DIMENSIONS = ["latency", "cost"] as const satisfies readonly (typeof MEASUREMENT_DIMENSIONS)[number][];
273
+
274
+ /** One measurable dimension. */
275
+ export type MeasurementDimension = (typeof MEASUREMENT_DIMENSIONS)[number];
276
+
277
+ /** `value` as a dimension, or `undefined` when it is not one. */
278
+ export function readDimension(value: unknown): MeasurementDimension | undefined {
279
+ const text = nonEmptyString(value);
280
+ return MEASUREMENT_DIMENSIONS.find((d) => d === text);
281
+ }
282
+
283
+ /**
284
+ * The four ratings a verdict speaks (`lv2-measurement-interchange.md` §3b) — the ONE list; the
285
+ * rating vocabulary (`rating.ts`) derives its type from it, so the document and the classifier
286
+ * can never disagree on a word.
287
+ */
288
+ export const VERDICT_RATINGS = ["suitable", "conditional", "unknown", "unsuitable"] as const;
289
+ export type VerdictRating = (typeof VERDICT_RATINGS)[number];
290
+
291
+ /** How a host runs its plugins (`2026-09-25-plugin-qualify.md` §3a). */
292
+ export const HOST_ISOLATIONS = ["in-process", "per-process", "shared-process"] as const;
293
+ export type HostIsolation = (typeof HOST_ISOLATIONS)[number];
294
+
295
+ /** Whether a host aligns on a plugin's reported latency (`compensated`) or only passes it on. */
296
+ export const HOST_LATENCY_HANDLING = ["compensated", "uncompensated"] as const;
297
+ export type HostLatencyHandling = (typeof HOST_LATENCY_HANDLING)[number];
298
+
@@ -0,0 +1,277 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The `plugin-qualify` CLI's decisions, as pure functions: parse the arguments against `QUALIFY_OPTIONS`, print `--help` from the same table, resolve
5
+ * the named targets through the loader seam, turn each plugin's measurements into
6
+ * `classifyPluginHosting`'s verdict (never a second one), and pick the exit code.
7
+ * `bin/plugin-qualify.mjs` only spawns the measuring tools and calls these.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
11
+ import { OPENMIXER_CONSOLE_PROFILE, readHostProfile, shippedHostProfile, type HostProfile } from "./host-profiles.js";
12
+ import {
13
+ buildMeasurementDocument,
14
+ type DocumentVerdict,
15
+ type MeasuredPlugin,
16
+ type MeasurementDocument,
17
+ type MeasurementRun,
18
+ } from "./measurement/format.js";
19
+ import { classifyPluginHosting, type QualifiedPlugin } from "./hosting-suitability.js";
20
+ import type { SuitabilityRating } from "./rating.js";
21
+ import type { PluginLoader, PluginTarget } from "./loader.js";
22
+ import { EXIT_CODE, OUTPUT_FORMATS, QUALIFY_OPTIONS, type OutputFormat } from "./qualify-options.js";
23
+
24
+ export interface QualifyArgs {
25
+ readonly all: boolean;
26
+ readonly targets: readonly string[];
27
+ readonly rates: readonly number[];
28
+ readonly quanta: readonly number[];
29
+ readonly out?: string;
30
+ readonly format: OutputFormat;
31
+ readonly isolate: boolean;
32
+ readonly hostSweep: boolean;
33
+ /** `--host-profile` specs (a shipped id or a profile file), in order, once each. */
34
+ readonly hostProfiles: readonly string[];
35
+ readonly help: boolean;
36
+ }
37
+
38
+ export type ParseResult = { ok: true; args: QualifyArgs } | { ok: false; error: string };
39
+
40
+ function defaultOf(flag: string): string {
41
+ const d = QUALIFY_OPTIONS.find((o) => o.flag === flag)?.default;
42
+ if (d === undefined) throw new Error(`${flag} declares no default`);
43
+ return d;
44
+ }
45
+
46
+ function numberList(text: string | undefined): number[] | undefined {
47
+ if (text === undefined || text === "") return undefined;
48
+ const out = [...new Set(text.split(",").map((t) => Number(t.trim())))];
49
+ return out.every((n) => Number.isInteger(n) && n > 0) ? out.sort((a, b) => a - b) : undefined;
50
+ }
51
+
52
+ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
53
+ const known = new Set(QUALIFY_OPTIONS.map((o) => o.flag));
54
+ const takesValue = new Set(QUALIFY_OPTIONS.filter((o) => o.value !== undefined).map((o) => o.flag));
55
+ const seen = new Map<string, string | true>();
56
+ const targets: string[] = [];
57
+ // The one option that repeats: every host asked about is a verdict of its own.
58
+ const profileSpecs: string[] = [];
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const a = argv[i]!;
61
+ if (!a.startsWith("--")) {
62
+ targets.push(a);
63
+ continue;
64
+ }
65
+ if (!known.has(a)) return { ok: false, error: `unknown option ${a}` };
66
+ if (takesValue.has(a)) {
67
+ const v = argv[++i];
68
+ if (v === undefined) return { ok: false, error: `${a} needs a value` };
69
+ if (a === "--host-profile") profileSpecs.push(...v.split(",").map((t) => t.trim()).filter((t) => t !== ""));
70
+ seen.set(a, v);
71
+ } else seen.set(a, true);
72
+ }
73
+ const help = seen.has("--help");
74
+ const all = seen.has("--all");
75
+ if (!help && all && targets.length > 0) return { ok: false, error: "--all takes no plugin targets" };
76
+ if (!help && !all && targets.length === 0) return { ok: false, error: "name a plugin (URI or .lv2 bundle) or pass --all" };
77
+ const rates = numberList((seen.get("--rates") as string | undefined) ?? defaultOf("--rates"));
78
+ if (rates === undefined) return { ok: false, error: "--rates must be positive integers, comma-separated" };
79
+ const quanta = numberList((seen.get("--quanta") as string | undefined) ?? defaultOf("--quanta"));
80
+ if (quanta === undefined) return { ok: false, error: "--quanta must be positive integers, comma-separated" };
81
+ const format = (seen.get("--format") as string | undefined) ?? defaultOf("--format");
82
+ if (!(OUTPUT_FORMATS as readonly string[]).includes(format)) {
83
+ return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
84
+ }
85
+ const out = seen.get("--out") as string | undefined;
86
+ const hostProfiles = [...new Set(profileSpecs.length > 0 ? profileSpecs : [defaultOf("--host-profile")])];
87
+ return {
88
+ ok: true,
89
+ args: {
90
+ all,
91
+ targets,
92
+ rates,
93
+ quanta,
94
+ ...(out === undefined ? {} : { out }),
95
+ format: format as OutputFormat,
96
+ isolate: seen.has("--isolate"),
97
+ hostSweep: !seen.has("--no-host-sweep"),
98
+ hostProfiles,
99
+ help,
100
+ },
101
+ };
102
+ }
103
+
104
+ export function helpText(): string {
105
+ const rows = QUALIFY_OPTIONS.map((o) => {
106
+ const left = o.value === undefined ? o.flag : `${o.flag} <${o.value}>`;
107
+ const def = o.default === undefined ? "" : ` (default: ${o.default})`;
108
+ return ` ${left.padEnd(28)}${o.describe}${def}`;
109
+ });
110
+ return [
111
+ "plugin-qualify — qualify audio plugins for real-time hosting",
112
+ "",
113
+ " plugin-qualify <uri|bundle-path>... exactly the named plugins; a .lv2 bundle = every plugin in it",
114
+ " plugin-qualify --all every plugin lilv finds (LV2_PATH honoured)",
115
+ "",
116
+ ...rows,
117
+ "",
118
+ `exit ${EXIT_CODE.allQualified} all qualified / ${EXIT_CODE.someNotQualified} some not / ${EXIT_CODE.couldNotMeasure} could not measure`,
119
+ `defaults are declarations: rates ${QUALIFY_RATES.join(",")}, quanta ${QUALIFY_QUANTA.join(",")}`,
120
+ "",
121
+ ].join("\n");
122
+ }
123
+
124
+ export type ResolveResult =
125
+ | { ok: true; plugins: readonly PluginTarget[] }
126
+ | { ok: false; refused: readonly string[] };
127
+
128
+ /** Every target through the loaders; a target no loader claims, or one naming no plugin, is refused. */
129
+ export function resolveTargets(targets: readonly string[], loaders: readonly PluginLoader[]): ResolveResult {
130
+ const plugins = new Map<string, PluginTarget>();
131
+ const refused: string[] = [];
132
+ for (const t of targets) {
133
+ let found: readonly PluginTarget[] | undefined;
134
+ for (const l of loaders) {
135
+ found = l.resolve(t);
136
+ if (found !== undefined) break;
137
+ }
138
+ if (found === undefined || found.length === 0) {
139
+ refused.push(t);
140
+ continue;
141
+ }
142
+ for (const p of found) {
143
+ const prior = plugins.get(p.uri);
144
+ // A URI and its bundle name the same plugin: keep the richer record (bundle, binary).
145
+ if (prior === undefined || (prior.binary === undefined && p.binary !== undefined)) plugins.set(p.uri, p);
146
+ }
147
+ }
148
+ return refused.length > 0 ? { ok: false, refused } : { ok: true, plugins: [...plugins.values()] };
149
+ }
150
+
151
+ export type HostProfilesResult =
152
+ | { ok: true; profiles: readonly HostProfile[] }
153
+ | { ok: false; refused: readonly { spec: string; problem: string }[] };
154
+
155
+ /** Read a profile file as JSON; `undefined` when it cannot be read or parsed. */
156
+ function readJsonFile(path: string): unknown {
157
+ try {
158
+ return JSON.parse(readFileSync(path, "utf8"));
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Every `--host-profile` spec to a profile: a shipped id, else a profile file read through
166
+ * `readHostProfile`. An id nobody ships and no readable file names, a file the reader refuses, or
167
+ * two specs naming one id are refused — never judged against a default.
168
+ */
169
+ export function resolveHostProfiles(
170
+ specs: readonly string[],
171
+ readJson: (path: string) => unknown = readJsonFile,
172
+ ): HostProfilesResult {
173
+ const profiles: HostProfile[] = [];
174
+ const refused: { spec: string; problem: string }[] = [];
175
+ for (const spec of specs) {
176
+ const shipped = shippedHostProfile(spec);
177
+ let profile: HostProfile | undefined = shipped;
178
+ if (profile === undefined) {
179
+ const json = readJson(spec);
180
+ if (json === undefined) {
181
+ refused.push({ spec, problem: "no shipped profile has this id, and no readable JSON file is at this path" });
182
+ continue;
183
+ }
184
+ const r = readHostProfile(json);
185
+ if (!r.ok) {
186
+ refused.push({ spec, problem: r.problem });
187
+ continue;
188
+ }
189
+ profile = r.profile;
190
+ }
191
+ if (profiles.some((p) => p.id === profile.id)) {
192
+ refused.push({ spec, problem: `a second profile with id ${profile.id}` });
193
+ continue;
194
+ }
195
+ profiles.push(profile);
196
+ }
197
+ return refused.length > 0 ? { ok: false, refused } : { ok: true, profiles };
198
+ }
199
+
200
+ export interface QualifyVerdict {
201
+ readonly uri: string;
202
+ /** The id of the host profile this verdict was judged against. */
203
+ readonly host: string;
204
+ /** False when nothing about the plugin was measured at all. */
205
+ readonly measured: boolean;
206
+ readonly rating: SuitabilityRating;
207
+ /** `rating === "suitable"` — the only meaning `qualified` has. */
208
+ readonly qualified: boolean;
209
+ /** The code of the reason that decided it (absent for `suitable`). */
210
+ readonly deciding?: string;
211
+ }
212
+
213
+ /**
214
+ * The verdict of each plugin under each host profile: `classifyPluginHosting`'s rating and
215
+ * deciding reason, nothing more. Each profile is judged at the live `rate`/`quantum` when the
216
+ * caller names them, else at the profile's own declared budget pair.
217
+ */
218
+ export function qualifyPlugins(
219
+ measured: readonly { target: PluginTarget; plugin?: QualifiedPlugin }[],
220
+ options: { rate?: number; quantum?: number; profiles?: readonly HostProfile[] },
221
+ ): QualifyVerdict[] {
222
+ const profiles = options.profiles ?? [OPENMIXER_CONSOLE_PROFILE];
223
+ return measured.flatMap(({ target, plugin }) =>
224
+ profiles.map((profile): QualifyVerdict => {
225
+ if (plugin === undefined) {
226
+ return { uri: target.uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: "not-measured" };
227
+ }
228
+ const live = options.rate !== undefined;
229
+ const quantum = live ? options.quantum : profile.cost.quantum;
230
+ const v = classifyPluginHosting(plugin, {
231
+ rate: options.rate ?? profile.cost.rate,
232
+ ...(quantum === undefined ? {} : { quantum }),
233
+ ...(target.binary === undefined ? {} : { pluginBinary: target.binary }),
234
+ profile,
235
+ });
236
+ return {
237
+ uri: target.uri,
238
+ host: profile.id,
239
+ measured: true,
240
+ rating: v.rating,
241
+ qualified: v.rating === "suitable",
242
+ ...(v.deciding === undefined ? {} : { deciding: v.deciding.code }),
243
+ };
244
+ }),
245
+ );
246
+ }
247
+
248
+ /** The run's interchange document (v1.2, §6): the raw plugin blocks, the profiles judged against, and the verdicts. */
249
+ export function judgedDocument(
250
+ run: MeasurementRun,
251
+ plugins: readonly MeasuredPlugin[],
252
+ profiles: readonly HostProfile[],
253
+ verdicts: readonly QualifyVerdict[],
254
+ ): MeasurementDocument {
255
+ const written: DocumentVerdict[] = verdicts.map((v) => ({
256
+ uri: v.uri,
257
+ host: v.host,
258
+ rating: v.rating,
259
+ qualified: v.qualified,
260
+ ...(v.deciding === undefined ? {} : { deciding: v.deciding }),
261
+ }));
262
+ return buildMeasurementDocument(run, plugins, { hostProfiles: profiles, verdicts: written });
263
+ }
264
+
265
+ export function exitCodeFor(verdicts: readonly QualifyVerdict[]): number {
266
+ if (verdicts.length === 0 || verdicts.some((v) => !v.measured)) return EXIT_CODE.couldNotMeasure;
267
+ return verdicts.every((v) => v.qualified) ? EXIT_CODE.allQualified : EXIT_CODE.someNotQualified;
268
+ }
269
+
270
+ /** Plain words: plugin, verdict, the reason that decided it, the host it was judged for. */
271
+ export function textTable(verdicts: readonly QualifyVerdict[]): string {
272
+ const w = Math.max("plugin".length, ...verdicts.map((v) => v.uri.length));
273
+ const r = Math.max("reason".length, ...verdicts.map((v) => (v.deciding ?? "-").length));
274
+ const head = `${"plugin".padEnd(w)} ${"verdict".padEnd(11)} ${"reason".padEnd(r)} host`;
275
+ const rows = verdicts.map((v) => `${v.uri.padEnd(w)} ${v.rating.padEnd(11)} ${(v.deciding ?? "-").padEnd(r)} ${v.host}`);
276
+ return [head, ...rows, ""].join("\n");
277
+ }
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * EVERY NUMBER plugin-qualify uses, declared once. The CLI's defaults and `--help`, the rating, and the Python tools (through the generated
5
+ * `tools/_declarations.py`) all READ these; a consumer holding its own copy is what the package's
6
+ * perturbation test catches (R-094).
7
+ */
8
+
9
+ /**
10
+ * The reference rate cost is quoted at: the rig's rate, 96 kHz.
11
+ */
12
+ export const COST_REFERENCE_RATE = 96000;
13
+
14
+ /**
15
+ * The reference quantum in frames: the rig's buffer, and the block `benchmark.py` runs.
16
+ */
17
+ export const QUANTUM_FRAMES = 1024;
18
+
19
+ /** The rate the offline capture window is sized at; other rates scale from it. */
20
+ export const MEASURE_BASE_RATE = 48000;
21
+
22
+ /**
23
+ * The lowest rate the console runs a show at. A reading below it is informational for the scaling
24
+ * class: kept in `perRate` and served at its own rate, but never alone enough to flag a plugin
25
+ * `nonconforming` (`lv2-measurement-interchange.md` §1a).
26
+ */
27
+ export const OPERATIONAL_RATE_FLOOR = 48000;
28
+
29
+ /** The rates a run measures when `--rates` is not given — recorded in `run.rates`. */
30
+ export const QUALIFY_RATES: readonly number[] = [44100, MEASURE_BASE_RATE, COST_REFERENCE_RATE, 192000];
31
+
32
+ /** The quanta (frames) a run rates cost at when `--quanta` is not given. */
33
+ export const QUALIFY_QUANTA: readonly number[] = [128, 256, 512, QUANTUM_FRAMES];
34
+
35
+ /** Seconds one plugin's offline pass may take before its worker is killed and it reads as a timeout. */
36
+ export const PLUGIN_TIMEOUT_S = 15;
37
+
38
+ /** Seconds the offline worker may take to start (lilv world load on a large LV2_PATH). */
39
+ export const BOOT_TIMEOUT_S = 120;
40
+
41
+ /** Seconds one plugin's cost pass may take (a sustained tone runs longer than an impulse). */
42
+ export const COST_TIMEOUT_S = 60;
43
+
44
+ /**
45
+ * The ONE setting that locates the CLAP scanner (spec §7): the environment variable that names
46
+ * the `omx-clap-scan` binary, and what is run when it is unset (looked up on `PATH`).
47
+ */
48
+ export const CLAP_SCAN_TOOL_ENV = "OMX_CLAP_SCAN";
49
+ export const CLAP_SCAN_TOOL_DEFAULT = "omx-clap-scan";
50
+
51
+ /** Seconds one run of the CLAP scanner may take over everything it was pointed at. */
52
+ export const CLAP_SCAN_TIMEOUT_S = 300;
53
+
54
+ /**
55
+ * The CLAP prober (`native/clap-qualify.c`) and the interposer it runs under (`native/rt-interpose.c`):
56
+ * each located by one environment variable, else the package's own build in `native/build/`.
57
+ */
58
+ export const CLAP_QUALIFY_TOOL_ENV = "OMX_CLAP_QUALIFY";
59
+ export const RT_INTERPOSE_ENV = "OMX_RT_INTERPOSE";
60
+
61
+ /** The CLAP sweep: blocks of `CLAP_SWEEP_FRAMES` frames per rate, every parameter moved through events. */
62
+ export const CLAP_SWEEP_BLOCKS = 512;
63
+ export const CLAP_SWEEP_FRAMES = 512;
64
+
65
+ /** Seconds one `.clap` may take to qualify at every rate before the prober is killed. */
66
+ export const CLAP_QUALIFY_TIMEOUT_S = 120;
67
+
68
+
69
+
70
+
71
+ // The rating's thresholds.
72
+
73
+ /** Every threshold, so none is baked into the code. */
74
+ export interface HostingPolicy {
75
+ /** Minimum instantiate/destroy lifecycles. The operator's §14.4 ruling: 1000. */
76
+ readonly cycleFloor: number;
77
+ /** Minimum continuous soak, seconds. Default 3 h — the operator's longest show. */
78
+ readonly soakSeconds: number;
79
+ /** Above this ratio a plugin is spiky: in-process a spike is the whole desk's xrun. */
80
+ readonly costTailRatioCeiling: number;
81
+ /** A soak rate whose settled output sits this far under the plugin's own best rate is
82
+ * dead there. A ratio against the plugin itself, never an absolute floor. */
83
+ readonly soakDeadDropDb: number;
84
+ /** The host string the shipped figures were taken on, when the console knows its own. */
85
+ readonly hostProvenance?: string;
86
+ }
87
+
88
+ /**
89
+ * Defaults. The cycle floor is the operator's ruling of 2026-09-05 (§14.4, option c) and the
90
+ * soak is §3's default show length. The per-instance cost ceiling is the HOST's budget and
91
+ * lives in {@link HOST_BUDGETS} (plugin-qualify §3a), not here.
92
+ */
93
+ export const DEFAULT_HOSTING_POLICY: HostingPolicy = {
94
+ cycleFloor: 1000,
95
+ soakSeconds: 3 * 60 * 60,
96
+ costTailRatioCeiling: 4,
97
+ soakDeadDropDb: 40,
98
+ };
99
+
100
+ /** What a host spends on one plugin instance (`2026-09-25-plugin-qualify.md` §3a) — the
101
+ * interchange's shape, since a document carries each profile's budget. */
102
+ import type { HostBudget } from "./measurement/format.js";
103
+ export type { HostBudget };
104
+
105
+ /**
106
+ * Each shipped host profile's budget, by profile id. `openmixer-console` is §14.2's 5 % per
107
+ * instance at the rig's rate and buffer. `mod-host` runs a whole pedalboard on one JACK thread,
108
+ * so it gets the same per-instance share, at 48 kHz / 128 frames. `jalv` gives each plugin its own
109
+ * process and client, so a quarter core per instance, at 48 kHz / 256 frames. The last two are
110
+ * declared defaults, not a measurement of anyone's board — a project brings its own profile file.
111
+ */
112
+ export const HOST_BUDGETS = {
113
+ "openmixer-console": { coreFractionCeiling: 0.05, rate: COST_REFERENCE_RATE, quantum: QUANTUM_FRAMES },
114
+ jalv: { coreFractionCeiling: 0.25, rate: MEASURE_BASE_RATE, quantum: 256 },
115
+ "mod-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 },
116
+ } as const satisfies Readonly<Record<string, HostBudget>>;
117
+