@openmixer/plugin-qualify 0.20.1 → 0.20.4

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 (132) hide show
  1. package/bin/plugin-qualify-sync.mjs +1 -0
  2. package/catalogs/ca.json +251 -0
  3. package/catalogs/de.json +49 -0
  4. package/catalogs/es.json +49 -0
  5. package/catalogs/fr.json +49 -0
  6. package/catalogs/it.json +49 -0
  7. package/catalogs/pt.json +49 -0
  8. package/dist/clap-loader.js +2 -1
  9. package/dist/clap-loader.js.map +1 -1
  10. package/dist/clap-qualify.d.ts +11 -0
  11. package/dist/clap-qualify.js +16 -0
  12. package/dist/clap-qualify.js.map +1 -1
  13. package/dist/compare.d.ts +12 -11
  14. package/dist/compare.js +71 -38
  15. package/dist/compare.js.map +1 -1
  16. package/dist/host-admission.d.ts +25 -0
  17. package/dist/host-admission.js +41 -5
  18. package/dist/host-admission.js.map +1 -1
  19. package/dist/hostd-pin-files.d.ts +6 -2
  20. package/dist/hostd-pin-files.js +10 -6
  21. package/dist/hostd-pin-files.js.map +1 -1
  22. package/dist/hostd-search-path.d.ts +38 -0
  23. package/dist/hostd-search-path.js +58 -0
  24. package/dist/hostd-search-path.js.map +1 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.js +4 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/jack-registry.js +1 -1
  29. package/dist/lv2-loader.d.ts +33 -1
  30. package/dist/lv2-loader.js +82 -19
  31. package/dist/lv2-loader.js.map +1 -1
  32. package/dist/measurement/vocabulary.js +3 -3
  33. package/dist/measurement/vocabulary.js.map +1 -1
  34. package/dist/measurement-grid.d.ts +1 -1
  35. package/dist/measurement-grid.js +5 -3
  36. package/dist/measurement-grid.js.map +1 -1
  37. package/dist/pins-file.d.ts +3 -2
  38. package/dist/pins-file.js +3 -2
  39. package/dist/pins-file.js.map +1 -1
  40. package/dist/plugin-db-cli.d.ts +28 -0
  41. package/dist/plugin-db-cli.js +43 -0
  42. package/dist/plugin-db-cli.js.map +1 -0
  43. package/dist/plugin-db-pin.d.ts +8 -0
  44. package/dist/plugin-db-pin.js +29 -0
  45. package/dist/plugin-db-pin.js.map +1 -0
  46. package/dist/probe-judge.js +5 -0
  47. package/dist/probe-judge.js.map +1 -1
  48. package/dist/probe-output.d.ts +10 -0
  49. package/dist/probe-output.js +16 -0
  50. package/dist/probe-output.js.map +1 -1
  51. package/dist/probe-runner.js +24 -5
  52. package/dist/probe-runner.js.map +1 -1
  53. package/dist/qualification-record.d.ts +5 -0
  54. package/dist/qualification-record.js +13 -1
  55. package/dist/qualification-record.js.map +1 -1
  56. package/dist/qualify-adapter-pass.d.ts +1 -2
  57. package/dist/qualify-adapter-pass.js +6 -6
  58. package/dist/qualify-adapter-pass.js.map +1 -1
  59. package/dist/qualify-declarations.d.ts +23 -6
  60. package/dist/qualify-declarations.js +25 -6
  61. package/dist/qualify-declarations.js.map +1 -1
  62. package/dist/qualify-isolated.d.ts +9 -0
  63. package/dist/qualify-isolated.js +16 -5
  64. package/dist/qualify-isolated.js.map +1 -1
  65. package/dist/qualify-pool.js +4 -3
  66. package/dist/qualify-pool.js.map +1 -1
  67. package/dist/qualify-rejudge.js +3 -3
  68. package/dist/qualify-rejudge.js.map +1 -1
  69. package/dist/qualify-sync.d.ts +12 -5
  70. package/dist/qualify-sync.js +51 -30
  71. package/dist/qualify-sync.js.map +1 -1
  72. package/dist/record-figures.d.ts +52 -0
  73. package/dist/record-figures.js +58 -0
  74. package/dist/record-figures.js.map +1 -0
  75. package/dist/refused-stderr-kept.d.ts +12 -0
  76. package/dist/refused-stderr-kept.js +67 -0
  77. package/dist/refused-stderr-kept.js.map +1 -0
  78. package/dist/run-home.d.ts +4 -0
  79. package/dist/run-home.js +26 -0
  80. package/dist/run-home.js.map +1 -0
  81. package/dist/text-catalog.d.ts +41 -0
  82. package/dist/text-catalog.js +97 -0
  83. package/dist/text-catalog.js.map +1 -0
  84. package/dist/text-codes.d.ts +29 -0
  85. package/dist/text-codes.generated.d.ts +88 -0
  86. package/dist/text-codes.generated.js +96 -0
  87. package/dist/text-codes.generated.js.map +1 -0
  88. package/dist/text-codes.js +19 -0
  89. package/dist/text-codes.js.map +1 -0
  90. package/package.json +8 -6
  91. package/schema/codes.json +386 -0
  92. package/src/clap-loader.ts +2 -1
  93. package/src/clap-qualify.ts +25 -0
  94. package/src/compare.ts +85 -45
  95. package/src/host-admission.ts +52 -5
  96. package/src/hostd-pin-files.ts +13 -8
  97. package/src/hostd-search-path.ts +85 -0
  98. package/src/index.ts +4 -0
  99. package/src/jack-registry.ts +1 -1
  100. package/src/lv2-loader.ts +89 -17
  101. package/src/measurement/vocabulary.ts +3 -3
  102. package/src/measurement-grid.ts +5 -3
  103. package/src/pins-file.ts +3 -2
  104. package/src/plugin-db-cli.ts +78 -0
  105. package/src/plugin-db-pin.ts +33 -0
  106. package/src/probe-judge.ts +5 -0
  107. package/src/probe-output.ts +23 -0
  108. package/src/probe-runner.ts +25 -4
  109. package/src/qualification-record.ts +13 -1
  110. package/src/qualify-adapter-pass.ts +6 -5
  111. package/src/qualify-declarations.ts +27 -6
  112. package/src/qualify-isolated.ts +18 -5
  113. package/src/qualify-pool.ts +4 -3
  114. package/src/qualify-rejudge.ts +3 -3
  115. package/src/qualify-sync.ts +55 -33
  116. package/src/record-figures.ts +114 -0
  117. package/src/refused-stderr-kept.ts +84 -0
  118. package/src/run-home.ts +28 -0
  119. package/src/text-catalog.ts +116 -0
  120. package/src/text-codes.generated.ts +97 -0
  121. package/src/text-codes.ts +35 -0
  122. package/tools/_declarations.py +2 -1
  123. package/tools/catalogs-gate.sh +22 -0
  124. package/tools/compare-host.mjs +1 -1
  125. package/tools/compare-run.mjs +10 -6
  126. package/tools/descriptors.py +40 -3
  127. package/tools/gen-python-declarations.mjs +1 -0
  128. package/tools/gen-text-codes.d.mts +8 -0
  129. package/tools/gen-text-codes.mjs +77 -0
  130. package/tools/isolated-step.mjs +24 -3
  131. package/tools/mod-host-admit.mjs +57 -1
  132. package/tools/scan.py +29 -5
package/src/lv2-loader.ts CHANGED
@@ -64,27 +64,52 @@ function turtleTokens(ttl: string): Token[] {
64
64
  return out;
65
65
  }
66
66
 
67
+ /**
68
+ * The path a file IRI names, as lilv and serd read it: a `file:` URI loses its scheme and authority, and the percent
69
+ * escapes of its path are decoded (`libSurge%20XT.so` is `libSurge XT.so` on the disk). A relative IRI is decoded the
70
+ * same way. A malformed escape leaves the text as written: the name is then looked up as it stands and not found, which
71
+ * is its own, named failure.
72
+ */
73
+ export function iriPath(iri: string): string {
74
+ let path = iri;
75
+ const scheme = /^file:(?:\/\/[^/]*)?(?=\/)/i.exec(iri);
76
+ if (scheme !== null) path = iri.slice(scheme[0].length);
77
+ try {
78
+ return decodeURIComponent(path);
79
+ } catch {
80
+ return path;
81
+ }
82
+ }
83
+
67
84
  /** A plugin a manifest declares, with the files it names for it, each relative to the bundle, in the order named. */
68
85
  export interface ManifestPlugin {
69
86
  readonly uri: string;
70
87
  readonly files: readonly string[];
71
- /** The basename of its `lv2:binary`, when it names one. */
88
+ /** The basename of its `lv2:binary` as it is on the disk (the IRI's escapes decoded), when it names one. `files` keep the IRI's word, which is what a pin holds. */
72
89
  readonly binary?: string;
73
90
  }
74
91
 
92
+ /** A statement of a Turtle text: the subject and predicate expanded, the object as written and, when it names one, expanded. */
93
+ export interface TurtleTriple {
94
+ readonly subject: string;
95
+ readonly predicate: string;
96
+ /** The object's token: an `iri` is as written (a relative one stays relative), a `literal` carries no text. */
97
+ readonly object: { readonly kind: "iri" | "name" | "literal"; readonly text: string };
98
+ /** The object expanded: an absolute or relative IRI, or a prefixed name with a known prefix. */
99
+ readonly objectIri?: string;
100
+ }
101
+
75
102
  /**
76
- * Every plugin a bundle manifest declares and the files it names for it with `lv2:binary` or `rdfs:seeAlso`: the
77
- * files plugin-hostd's pin holds besides `manifest.ttl` itself. A file named outside the bundle (an absolute IRI) is
78
- * not one of them.
103
+ * The statements of a Turtle text, as plugin-hostd reads a manifest to pin a plugin: statements of IRIs and prefixed
104
+ * names, with blank nodes and collections stepped over, not a whole Turtle parser. `lv2`, `rdfs` and `rdf` read even
105
+ * when a file leaves them undeclared.
79
106
  */
80
- export function manifestPlugins(ttl: string): ManifestPlugin[] {
81
- // the three vocabularies every manifest uses, so a manifest that leaves a prefix undeclared still reads
107
+ export function turtleTriples(ttl: string): TurtleTriple[] {
82
108
  const prefixes = new Map<string, string>([
83
109
  ["lv2", "http://lv2plug.in/ns/lv2core#"],
84
110
  ["rdfs", "http://www.w3.org/2000/01/rdf-schema#"],
85
111
  ["rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"],
86
112
  ]);
87
- const subjects = new Map<string, { typed: boolean; files: string[]; binary?: string }>();
88
113
  const expand = (t: Token): string | undefined => {
89
114
  if (t.kind === "iri") return t.text;
90
115
  if (t.kind !== "name") return undefined;
@@ -93,6 +118,7 @@ export function manifestPlugins(ttl: string): ManifestPlugin[] {
93
118
  const base = colon < 0 ? undefined : prefixes.get(t.text.slice(0, colon));
94
119
  return base === undefined ? undefined : base + t.text.slice(colon + 1);
95
120
  };
121
+ const out: TurtleTriple[] = [];
96
122
  const tokens = turtleTokens(ttl);
97
123
  let state: "subject" | "predicate" | "object" | "after" = "subject";
98
124
  let depth = 0;
@@ -134,16 +160,29 @@ export function manifestPlugins(ttl: string): ManifestPlugin[] {
134
160
  state = "object";
135
161
  } else if (state === "object") {
136
162
  state = "after";
137
- if (subject === undefined) continue;
138
- const entry = subjects.get(subject) ?? { typed: false, files: [] };
139
- subjects.set(subject, entry);
140
- const object = expand(t);
141
- if (predicate === RDF_TYPE && object === LV2_PLUGIN) entry.typed = true;
142
- if ((predicate === LV2_BINARY || predicate === RDFS_SEE_ALSO) && t.kind === "iri" && !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(t.text) && !t.text.startsWith("/")) {
143
- const file = t.text.replace(/^(\.\/)+/, "");
144
- if (!entry.files.includes(file)) entry.files.push(file);
145
- if (predicate === LV2_BINARY && entry.binary === undefined) entry.binary = basename(file);
146
- }
163
+ if (subject === undefined || predicate === undefined) continue;
164
+ const objectIri = expand(t);
165
+ out.push({ subject, predicate, object: { kind: t.kind as "iri" | "name" | "literal", text: t.text }, ...(objectIri === undefined ? {} : { objectIri }) });
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * Every plugin a bundle manifest declares and the files it names for it with `lv2:binary` or `rdfs:seeAlso`: the
173
+ * files plugin-hostd's pin holds besides `manifest.ttl` itself. A file named outside the bundle (an absolute IRI) is
174
+ * not one of them.
175
+ */
176
+ export function manifestPlugins(ttl: string): ManifestPlugin[] {
177
+ const subjects = new Map<string, { typed: boolean; files: string[]; binary?: string }>();
178
+ for (const { subject, predicate, object, objectIri } of turtleTriples(ttl)) {
179
+ const entry = subjects.get(subject) ?? { typed: false, files: [] };
180
+ subjects.set(subject, entry);
181
+ if (predicate === RDF_TYPE && objectIri === LV2_PLUGIN) entry.typed = true;
182
+ if ((predicate === LV2_BINARY || predicate === RDFS_SEE_ALSO) && object.kind === "iri" && !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(object.text) && !object.text.startsWith("/")) {
183
+ const file = object.text.replace(/^(\.\/)+/, "");
184
+ if (!entry.files.includes(file)) entry.files.push(file);
185
+ if (predicate === LV2_BINARY && entry.binary === undefined) entry.binary = basename(iriPath(file));
147
186
  }
148
187
  }
149
188
  return [...subjects.entries()]
@@ -151,6 +190,39 @@ export function manifestPlugins(ttl: string): ManifestPlugin[] {
151
190
  .map(([uri, e]) => ({ uri, files: e.files, ...(e.binary === undefined ? {} : { binary: e.binary }) }));
152
191
  }
153
192
 
193
+ const PATCH_WRITABLE = "http://lv2plug.in/ns/ext/patch#writable";
194
+ const RDFS_RANGE = "http://www.w3.org/2000/01/rdf-schema#range";
195
+ const ATOM_PATH = "http://lv2plug.in/ns/ext/atom#Path";
196
+
197
+ /**
198
+ * The content an LV2 plugin declares it plays from: the parameters it lists as `patch:writable` whose `rdfs:range` is
199
+ * `atom:Path` (a sampler's sample, a drum machine's kit), sorted. Read from the bundle on the search path whose
200
+ * manifest declares `uri`, in the manifest and every file it names for the plugin. Empty when the plugin declares none
201
+ * or no bundle on the path holds it (#318).
202
+ */
203
+ export function lv2ContentParams(uri: string, env: Readonly<Record<string, string | undefined>>, home: string = homedir()): string[] {
204
+ for (const bundle of lv2BundlesOnPath(env, home)) {
205
+ let manifest: string;
206
+ try {
207
+ manifest = readFileSync(join(bundle, "manifest.ttl"), "utf8");
208
+ } catch {
209
+ continue;
210
+ }
211
+ const plugin = manifestPlugins(manifest).find((p) => p.uri === uri);
212
+ if (plugin === undefined) continue;
213
+ const triples = [manifest, ...plugin.files.filter((f) => f.endsWith(".ttl")).map((f) => {
214
+ try {
215
+ return readFileSync(join(bundle, f), "utf8");
216
+ } catch {
217
+ return "";
218
+ }
219
+ })].flatMap(turtleTriples);
220
+ const paths = new Set(triples.filter((t) => t.predicate === RDFS_RANGE && t.objectIri === ATOM_PATH).map((t) => t.subject));
221
+ return [...new Set(triples.filter((t) => t.subject === uri && t.predicate === PATCH_WRITABLE && t.objectIri !== undefined && paths.has(t.objectIri)).map((t) => t.objectIri!))].sort();
222
+ }
223
+ return [];
224
+ }
225
+
154
226
  /** The plugins a bundle manifest declares, each with its `lv2:binary` basename. */
155
227
  export function pluginsInManifest(ttl: string): { uri: string; binary?: string }[] {
156
228
  return manifestPlugins(ttl).map((p) => (p.binary === undefined ? { uri: p.uri } : { uri: p.uri, binary: p.binary }));
@@ -94,9 +94,9 @@ const UNMEASURED_DESCRIPTIONS: Readonly<Record<UnmeasuredCode, string>> = {
94
94
  "needs-impulse-response":
95
95
  "A convolver requiring an IR file the prober did not load.",
96
96
  "unpinnable":
97
- "The files the host loads cannot be pinned (plugin-qualify spec §11.1): a path holding ',', '=' or " +
98
- "whitespace, a manifest using @base or a percent-encoded IRI, or a CLAP plugin with more parameters than the " +
99
- "host accepts. The plugin stays unpinned, never partly pinned.",
97
+ "The files the host loads cannot be pinned (plugin-qualify spec §11.1): a manifest using @base, a file name " +
98
+ "longer than a pin path, or a CLAP plugin with more parameters than the host accepts. A name with a space or a " +
99
+ "separator of pin_set is pinned percent-encoded. The plugin stays unpinned, never partly pinned.",
100
100
  "no-voices":
101
101
  "An instrument whose voice-info declares a voice capacity of 0: there is no voice to hold down, so its " +
102
102
  "cost with notes held cannot be taken.",
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Pure: no file, no process. `probe-runner.ts` runs what `runnerInvocations` plans.
9
9
  */
10
- import { GRID_CONTINUITY_QUANTUM } from "./qualify-declarations.js";
10
+ import { COST_REFERENCE_RATE, GRID_CONTINUITY_QUANTUM, LONG_QUANTUM_FRAMES } from "./qualify-declarations.js";
11
11
 
12
12
  /** One cell of the grid. */
13
13
  export interface GridCell {
@@ -55,7 +55,7 @@ const byRateThenQuantum = (a: GridCell, b: GridCell): number => a.rate - b.rate
55
55
 
56
56
  /**
57
57
  * The cells of a grid, in the order they are taken: for `hosts`, each host's own cell in the order the hosts are given,
58
- * then `GRID_CONTINUITY_QUANTUM` at every rate; for `full`, those cells first as well, then the rest of the product by
58
+ * then `GRID_CONTINUITY_QUANTUM` at every rate, then the long quantum at the reference rate when the run measures that rate; for `full`, those cells first as well, then the rest of the product by
59
59
  * rate and quantum; for named cells, as named. A cell is listed once.
60
60
  */
61
61
  export function gridCells(
@@ -64,7 +64,9 @@ export function gridCells(
64
64
  ): GridCell[] {
65
65
  if (spec.kind === "cells") return unique(spec.cells);
66
66
  const continuity = [...context.rates].sort((a, b) => a - b).map((rate) => ({ rate, quantum: GRID_CONTINUITY_QUANTUM }));
67
- const lead = unique([...context.hosts, ...continuity]);
67
+ // the console was judged at the long quantum until its basis moved to the reference one: that cell stays measured
68
+ const long = context.rates.includes(COST_REFERENCE_RATE) ? [{ rate: COST_REFERENCE_RATE, quantum: LONG_QUANTUM_FRAMES }] : [];
69
+ const lead = unique([...context.hosts, ...continuity, ...long]);
68
70
  if (spec.kind === "hosts") return lead;
69
71
  const product = context.rates.flatMap((rate) => context.quanta.map((quantum) => ({ rate, quantum }))).sort(byRateThenQuantum);
70
72
  const inProduct = new Set(product.map(cellKey));
package/src/pins-file.ts CHANGED
@@ -127,8 +127,9 @@ export function pinClearLine(uri: string): string {
127
127
 
128
128
  /**
129
129
  * The line of one pin, or `undefined` when the integrity block is not a whole pin. The uri is one word, quoted when it
130
- * holds a space. A file path inside the bundle cannot hold whitespace, a comma or an equals sign (the protocol's PATH
131
- * limit: they are the separators of the words of a pin), so a plugin with such a file has no pin, refused by name.
130
+ * holds a space. A file path is the prober's, percent-encoded as plugin-hostd-protocol writes a pin path (Surge XT's
131
+ * "Surge XT.clap" is Surge%20XT.clap), so it holds no whitespace, comma or equals sign; one that does was not encoded,
132
+ * and the plugin has no pin, refused by name.
132
133
  */
133
134
  export function pinLine(entry: PinEntry): string | undefined {
134
135
  const { files, layout } = entry.integrity;
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * plugin-db is the one writer's call into plugin-db's own store, as plugin-db's records documentation describes it:
5
+ * `plugin-db pin <address> --files <sha256> [--layout <scheme>:<sha256>]` writes the pinned layer, `plugin-db put
6
+ * <address> judged --writer plugin-qualify` writes the judged layer plugin-qualify is the one writer of, its body on
7
+ * stdin. `--system` names the service's store (plugin-db's own default otherwise, for an interactive caller). Nothing
8
+ * here computes a digest or a body; `plugin-db-pin.ts` and `judged-layer.ts` do.
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ import type { JudgedBody } from "./judged-layer.js";
13
+ import type { LayerPin } from "./judged-layer.js";
14
+
15
+ /** The binary, else `plugin-db` on the PATH. */
16
+ export const PLUGIN_DB_ENV = "PLUGIN_DB";
17
+
18
+ export interface PluginDbRunResult {
19
+ readonly status: number | null;
20
+ readonly stdout: string;
21
+ readonly stderr: string;
22
+ /** plugin-db is not installed. */
23
+ readonly missing?: boolean;
24
+ }
25
+
26
+ /** One run of `plugin-db <args...>`, the body on stdin when one is given. */
27
+ export type PluginDbRun = (args: readonly string[], input: string | undefined, env: Readonly<Record<string, string | undefined>>) => PluginDbRunResult;
28
+
29
+ export const SPAWNED_PLUGIN_DB: PluginDbRun = (args, input, env) => {
30
+ const r = spawnSync(env[PLUGIN_DB_ENV] ?? "plugin-db", args, { encoding: "utf8", timeout: 30000, env: { ...env }, ...(input === undefined ? {} : { input }) });
31
+ const missing = (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
32
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", ...(missing ? { missing } : {}) };
33
+ };
34
+
35
+ export interface PluginDbCallOptions {
36
+ /** The service's store (`--system`), plugin-db's own default otherwise. */
37
+ readonly system: boolean;
38
+ }
39
+
40
+ export type PluginDbCallResult = { readonly ok: true } | { readonly ok: false; readonly detail: string };
41
+
42
+ function systemFlag(args: readonly string[], opts: PluginDbCallOptions): string[] {
43
+ return opts.system ? [...args, "--system"] : [...args];
44
+ }
45
+
46
+ function refusal(r: PluginDbRunResult, verb: string): PluginDbCallResult {
47
+ if (r.missing) return { ok: false, detail: `plugin-db is not installed: the store is plugin-db's to ${verb}` };
48
+ const detail = r.stderr.trim().split("\n").pop();
49
+ return { ok: false, detail: detail || `plugin-db ${verb} exited ${String(r.status)}` };
50
+ }
51
+
52
+ /** `plugin-db pin <address> --files <sha256> [--layout <scheme>:<sha256>]`: the files of a plugin's bytes, pinned. */
53
+ export function pluginDbPin(
54
+ address: string,
55
+ pin: LayerPin,
56
+ opts: PluginDbCallOptions,
57
+ env: Readonly<Record<string, string | undefined>> = process.env,
58
+ run: PluginDbRun = SPAWNED_PLUGIN_DB,
59
+ ): PluginDbCallResult {
60
+ const args = systemFlag(["pin", address, "--files", pin.files, ...(pin.layout === null ? [] : ["--layout", pin.layout])], opts);
61
+ const r = run(args, undefined, env);
62
+ if (r.status !== 0 || r.missing) return refusal(r, "pin");
63
+ return { ok: true };
64
+ }
65
+
66
+ /** `plugin-db put <address> judged --writer plugin-qualify`, the judged layer's body on stdin. */
67
+ export function pluginDbPutJudged(
68
+ address: string,
69
+ body: JudgedBody,
70
+ opts: PluginDbCallOptions,
71
+ env: Readonly<Record<string, string | undefined>> = process.env,
72
+ run: PluginDbRun = SPAWNED_PLUGIN_DB,
73
+ ): PluginDbCallResult {
74
+ const args = systemFlag(["put", address, "judged", "--writer", "plugin-qualify"], opts);
75
+ const r = run(args, JSON.stringify(body), env);
76
+ if (r.status !== 0 || r.missing) return refusal(r, "put");
77
+ return { ok: true };
78
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The pin plugin-db holds for an address: `files`, the combined digest of every file a plugin's bytes are
5
+ * (`omx-files/1`, plugin-db's `plugin_db_tables.h`: the SHA-256 of the line `omx-files/1`, then one line
6
+ * `<path>\t<sha256>` per file in byte order of the paths, each ending in one `\n`), and `layout`, the parameter-layout
7
+ * fingerprint plugin-hostd's prober took (`omx-layout/1:<sha256>`), or none. Computed here from the raw per-file
8
+ * digests the prober wrote (`IntegrityMeasurement`): nothing here opens a file or hashes bytes plugin-hostd did not
9
+ * already hash.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+
13
+ import type { IntegrityMeasurement, PinnedFile } from "./measurement/format.js";
14
+ import type { LayerPin } from "./judged-layer.js";
15
+
16
+ /** The scheme name the combined files digest states, plugin-db's `PDB_STORE_FILES_PIN_SCHEME`. */
17
+ export const OMX_FILES_SCHEME = "omx-files/1";
18
+
19
+ /** The combined `omx-files/1` digest of a pin's files, plugin-db's one way to fold several file hashes into one. */
20
+ export function omxFilesDigest(files: readonly PinnedFile[]): string {
21
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
22
+ const text = [`${OMX_FILES_SCHEME}\n`, ...sorted.map((f) => `${f.path}\t${f.sha256}\n`)].join("");
23
+ return createHash("sha256").update(text).digest("hex");
24
+ }
25
+
26
+ /** The pin plugin-db holds for an integrity measurement, or `undefined` when it took no whole pin (no files). */
27
+ export function layerPinOf(integrity: Pick<IntegrityMeasurement, "files" | "layout">): LayerPin | undefined {
28
+ if (integrity.files === undefined || integrity.files.length === 0) return undefined;
29
+ return {
30
+ files: omxFilesDigest(integrity.files),
31
+ layout: integrity.layout === undefined ? null : `${integrity.layout.scheme}:${integrity.layout.sha256}`,
32
+ };
33
+ }
@@ -320,6 +320,11 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
320
320
  };
321
321
  }
322
322
  }
323
+ // a run that admitted the plugin and says why its note test could not be observed (a sampler with nothing to play): shown,
324
+ // never deciding (#318)
325
+ if (ran && admission !== undefined && admission.admitted && admission.code !== undefined) {
326
+ reasons = [{ dimension: "hostAdmission", rating: "suitable", code: admission.code, params: admission.params }, ...reasons];
327
+ }
323
328
  const owed = facts.owed?.[profile.id];
324
329
  if (owed !== undefined) reasons = [owed, ...reasons];
325
330
  const rating = worstRating(reasons.map((r) => r.rating));
@@ -63,6 +63,14 @@ export interface ProbeCost {
63
63
  readonly warmupBlocks: number;
64
64
  /** For an instrument: the notes it held while it was timed, how many and why that many. */
65
65
  readonly voices?: VoicesHeld;
66
+ /** For an instrument: its cost at each count of notes held (probe output 1.4), in the cost's own unit. */
67
+ readonly voiceCurve?: readonly VoiceCurvePoint[];
68
+ }
69
+
70
+ export interface VoiceCurvePoint {
71
+ readonly held: number;
72
+ readonly median: number;
73
+ readonly percentile: { readonly p: number; readonly value: number };
66
74
  }
67
75
 
68
76
  export interface ProbeRtSafety {
@@ -255,15 +263,30 @@ function readCost(v: unknown, where: string): ProbeCost {
255
263
  if (median < 0 || value < 0) invalid(`${where} holds a negative cost`);
256
264
  const voices = o["voices"] === undefined ? undefined : readVoicesHeld(o["voices"]);
257
265
  if (o["voices"] !== undefined && voices === undefined) invalid(`${where} voices is not a count of held notes with its basis`);
266
+ const voiceCurve = o["voiceCurve"] === undefined ? undefined : readVoiceCurve(o["voiceCurve"], `${where} voiceCurve`);
258
267
  return {
259
268
  median,
260
269
  percentile: { p, value },
261
270
  blocks: posInt(o, "blocks", where),
262
271
  warmupBlocks: count(o, "warmupBlocks", where),
263
272
  ...(voices === undefined ? {} : { voices }),
273
+ ...(voiceCurve === undefined ? {} : { voiceCurve }),
264
274
  };
265
275
  }
266
276
 
277
+ function readVoiceCurve(v: unknown, where: string): VoiceCurvePoint[] {
278
+ if (!Array.isArray(v)) invalid(`${where} is not a list`);
279
+ return (v as unknown[]).map((e, i) => {
280
+ const at = `${where} entry ${i}`;
281
+ const o = record(e, at);
282
+ const pct = record(o["percentile"], `${at} percentile`);
283
+ const median = num(o, "median", at);
284
+ const value = num(pct, "value", `${at} percentile`);
285
+ if (median < 0 || value < 0) invalid(`${at} holds a negative cost`);
286
+ return { held: posInt(o, "held", at), median, percentile: { p: num(pct, "p", `${at} percentile`), value } };
287
+ });
288
+ }
289
+
267
290
  function readRtSafety(v: unknown, where: string): ProbeRtSafety {
268
291
  const o = record(v, where);
269
292
  const events = o["events"] === undefined ? undefined : (stringList(o["events"]) ?? invalid(`${where} events is not a list of strings`));
@@ -12,7 +12,7 @@
12
12
  * unavailable, an output that could not be written), 3 is no such plugin.
13
13
  */
14
14
  import { spawnSync } from "node:child_process";
15
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
16
16
  import { availableParallelism, loadavg, tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
 
@@ -22,7 +22,7 @@ import type { HostProfile } from "./host-profiles.js";
22
22
  import { OPENMIXER_CONSOLE_PROFILE, shippedHostProfile } from "./host-profiles.js";
23
23
  import { IN_PROCESS_TIER_HOST, ISOLATED_TIER_HOST } from "./qualification-record.js";
24
24
  import type { PluginTarget } from "./loader.js";
25
- import { ADAPTER_RUN_HOSTS, SPAWNED_ADMISSIONS, admitPlugin, admitUnderAdapter, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
25
+ import { ADAPTER_RUN_HOSTS, SPAWNED_ADMISSIONS, noJackServerProblem, admitPlugin, admitUnderAdapter, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
26
26
  import { judgeProbeOutput, judgmentVerdicts, type Judgment } from "./probe-judge.js";
27
27
  import { readProbeOutput, type ProbeOutput } from "./probe-output.js";
28
28
  import { budgetCells, gridCells, hostCells, runnerInvocations, type GridCell, type GridSpec, type RunnerInvocation } from "./measurement-grid.js";
@@ -30,6 +30,10 @@ import type { MemoryBound } from "./memory-bound.js";
30
30
  import { mergeProbeRuns, ProbeMergeError } from "./probe-merge.js";
31
31
  import type { QualifyVerdict } from "./qualify-cli.js";
32
32
  import {
33
+ MEMORY_PASS_PROBE_NOTES,
34
+ PROBE_NOTES_CURVE,
35
+ PROBE_NOTES_CURVE_ENV,
36
+ PROBE_NOTES_ENV,
33
37
  CLAP_QUALIFY_TIMEOUT_S,
34
38
  CLAP_SWEEP_BLOCKS,
35
39
  CLAP_SWEEP_FRAMES,
@@ -182,7 +186,11 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
182
186
  const log = join(work, "child.stderr.log");
183
187
  try {
184
188
  let args: string[];
185
- let runEnv: Record<string, string | undefined> = { ...env };
189
+ // a HOME of the run's own, under its scratch directory: a plugin that reads its user data from HOME (Surge XT) dies
190
+ // or stays silent without one, and a probe must never write into the caller's real home
191
+ const home = join(work, "home");
192
+ mkdirSync(home);
193
+ let runEnv: Record<string, string | undefined> = { ...env, HOME: home };
186
194
  let expectedId: string;
187
195
  const wall = options.wallS === undefined ? {} : { wallS: options.wallS };
188
196
  const bound = options.memory === undefined ? {} : { dataBytes: options.memory.bytes };
@@ -196,7 +204,7 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
196
204
  target.binaryPath,
197
205
  expectedId,
198
206
  );
199
- runEnv = { ...env, LD_PRELOAD: interposer };
207
+ runEnv = { ...runEnv, LD_PRELOAD: interposer, [PROBE_NOTES_CURVE_ENV]: env[PROBE_NOTES_CURVE_ENV] ?? PROBE_NOTES_CURVE.join(",") };
200
208
  } else {
201
209
  expectedId = target.uri;
202
210
  const child = locateNativeTool(LV2_MEASURE_TOOL_NAME, env, options.exists ?? existsSync).path;
@@ -204,6 +212,14 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
204
212
  { ...at, ...wall, out, log, ...(child === undefined ? {} : { child }) },
205
213
  target.uri,
206
214
  );
215
+ // an instrument that declares no voice capacity is played with this many notes held, so its cost and its memory
216
+ // under notes are measured, not left voices-unknown; a count the caller set wins
217
+ runEnv = {
218
+ ...runEnv,
219
+ [PROBE_NOTES_ENV]: env[PROBE_NOTES_ENV] ?? String(MEMORY_PASS_PROBE_NOTES),
220
+ // and timed at each count of the curve, the cost against the notes held
221
+ [PROBE_NOTES_CURVE_ENV]: env[PROBE_NOTES_CURVE_ENV] ?? PROBE_NOTES_CURVE.join(","),
222
+ };
207
223
  }
208
224
  // under a bound, the interposer ends the child the moment an allocation is refused, with the status that names it
209
225
  if (options.memory !== undefined) runEnv = { ...runEnv, [MEMORY_BOUND_EXIT_ENV]: "1" };
@@ -322,12 +338,17 @@ export function qualifyTargets(
322
338
  const admissions: Record<string, HostAdmission> = {};
323
339
  // an LV2 plugin is also run once under plugin-hostd's LV2 adapter, recorded beside the verdicts (#223)
324
340
  const adapter = target.format === "lv2" ? admitUnderAdapter(target.uri, admissionRuns, admissionEnv) : undefined;
341
+ // no JACK server is the machine's, not the plugin's: the run stops by name instead of recording resp -201 in every record
342
+ const noJack = noJackServerProblem(adapter);
343
+ if (noJack !== undefined) throw new ProbeRunnerError(noJack);
325
344
  // and that run decides its omx-clap-host verdict, the isolated tier
326
345
  if (adapter !== undefined) for (const host of ADAPTER_RUN_HOSTS) admissions[host] = adapter;
327
346
  if (target.format === "lv2") {
328
347
  for (const profile of judgedProfiles) {
329
348
  const host = admittedHostOf(profile);
330
349
  if (host !== undefined) admissions[profile.id] = admitPlugin(host, target.uri, admissionRuns, admissionEnv);
350
+ const noJackHost = noJackServerProblem(admissions[profile.id]);
351
+ if (noJackHost !== undefined) throw new ProbeRunnerError(noJackHost);
331
352
  }
332
353
  }
333
354
  for (const profile of judgedProfiles) {
@@ -434,7 +434,19 @@ export interface NotMeasured {
434
434
  readonly code: string;
435
435
  }
436
436
 
437
- const safe = (text: string): string => text.replace(/[^A-Za-z0-9._-]/g, "_");
437
+ /**
438
+ * A name part as plugin-hostd-protocol's descriptor name rule writes it (PHD_DESCRIPTOR_SAFE_CHAR): each byte of the
439
+ * UTF-8 text outside [A-Za-z0-9._-] is one "_", as plugin-hostd's descriptors.c reads the name. "é" is two bytes: "__".
440
+ */
441
+ export function descriptorNamePart(text: string): string {
442
+ let out = "";
443
+ for (const byte of Buffer.from(text, "utf8")) {
444
+ const c = String.fromCharCode(byte);
445
+ out += /[A-Za-z0-9._-]/.test(c) && byte < 0x80 ? c : "_";
446
+ }
447
+ return out;
448
+ }
449
+ const safe = descriptorNamePart;
438
450
 
439
451
  /** `<id>@<version>-<sha256 first 12>.<offer>`: one binary and one offer, whatever the run. */
440
452
  export function qualificationBaseName(output: ProbeOutput): string {
@@ -22,7 +22,6 @@ import { HOSTD_ENV } from "./hostd-pin-files.js";
22
22
  import { freeDeadJackSlots } from "./jack-registry.js";
23
23
  import { cellKey, type GridCell } from "./measurement-grid.js";
24
24
  import { defaultAddressSpaceLimit } from "./direct-limit.js";
25
- import { PROBE_OK } from "./probe-output.js";
26
25
  import { QUALIFICATION_RECORD_SCHEMA, type AdapterBlock, type QualificationRecord } from "./qualification-record.js";
27
26
  import { ADMIT_ADDRESS_KB_ENV, ADMIT_STATE_DIR_ENV, ADMIT_STEP_MS_ENV, PI_CELLS } from "./qualify-declarations.js";
28
27
  import { digestFiles, type SyncContext, type SyncState } from "./qualify-sync.js";
@@ -90,7 +89,8 @@ const REAPING_JACKD = `trap 'kill -TERM $p 2>/dev/null' TERM; trap 'kill -KILL $
90
89
 
91
90
  /** `jackd` on a name of its own and the dummy driver, at the cell's rate and quantum; `undefined` when none came up. */
92
91
  export const startPrivateJack: JackStarter = (cell) => {
93
- const name = `omx-qualify-${String(process.pid)}-${String(cell.quantum)}`;
92
+ // the rate is in the name: the cells of one pass run at the same quantum at two rates, on servers alive together
93
+ const name = `omx-qualify-${String(process.pid)}-${String(cell.rate)}-${String(cell.quantum)}`;
94
94
  // the rows of servers of ours that were killed, freed before this one asks for a row
95
95
  freeDeadJackSlots();
96
96
  let child: ChildProcess;
@@ -238,8 +238,7 @@ const readRecord = (path: string): QualificationRecord | undefined => {
238
238
  };
239
239
 
240
240
  /**
241
- * The run of each LV2 plugin of the state whose probe ended well (one that did not has no verdict a run could decide), at
242
- * each cell. Each record is written whole as its plugin ends.
241
+ * The run of each LV2 plugin of the state, whatever its probe ended with, at each cell. Each record is written whole as its plugin ends.
243
242
  */
244
243
  export function adapterPass(ctx: SyncContext, state: SyncState, options: AdapterPassOptions = {}): AdapterPassResult {
245
244
  const read = hostVersions(ctx.env, options.versions);
@@ -279,7 +278,9 @@ export function adapterPass(ctx: SyncContext, state: SyncState, options: Adapter
279
278
  count.failed++;
280
279
  continue;
281
280
  }
282
- if (record.probe.outcome !== PROBE_OK) continue;
281
+ // a probe that crashed, hung or measured nothing is run too: the adapter's own process is not the probe's, and its
282
+ // answer replaces whatever the main run recorded there (a missing JACK server's no-jack-client), so no record keeps a
283
+ // stale code. The probe's outcome stays in the record beside it.
283
284
  if (options.force !== true && adapterFinished(record, versions)) {
284
285
  count.already++;
285
286
  continue;
@@ -14,9 +14,16 @@ import { ORACLE_FLOOR_RATES, RT_HARD_TARGET } from "@openmixer/omx-contract";
14
14
  export const COST_REFERENCE_RATE = 96000;
15
15
 
16
16
  /**
17
- * The reference quantum in frames: the rig's buffer, and the block `benchmark.py` runs.
17
+ * The reference quantum in frames: the buffer the console aims at (96000/256), the quantum its budget is judged at,
18
+ * and the block `benchmark.py` runs.
18
19
  */
19
- export const QUANTUM_FRAMES = 1024;
20
+ export const QUANTUM_FRAMES = 256;
21
+
22
+ /**
23
+ * The long quantum, in frames: the console's basis until 0.20.2, kept as a measured cell so a plugin's cost at a large
24
+ * buffer stays on record beside its cost at the console's.
25
+ */
26
+ export const LONG_QUANTUM_FRAMES = 1024;
20
27
 
21
28
  /** The rate the offline capture window is sized at; other rates scale from it. */
22
29
  export const MEASURE_BASE_RATE = 48000;
@@ -31,8 +38,8 @@ export const OPERATIONAL_RATE_FLOOR = 48000;
31
38
  /** The rates a run measures when `--rates` is not given — recorded in `run.rates`: omx-contract's ORACLE_FLOOR_RATES. */
32
39
  export const QUALIFY_RATES: readonly number[] = ORACLE_FLOOR_RATES;
33
40
 
34
- /** The quanta (frames) a run rates cost at when `--quanta` is not given: the contract's hard-target quantum, then 256, 512 and the reference. */
35
- export const QUALIFY_QUANTA: readonly number[] = [RT_HARD_TARGET.quantum, 256, 512, QUANTUM_FRAMES];
41
+ /** The quanta (frames) a run rates cost at when `--quanta` is not given: the contract's hard-target quantum, the reference, 512 and the long quantum. */
42
+ export const QUALIFY_QUANTA: readonly number[] = [RT_HARD_TARGET.quantum, QUANTUM_FRAMES, 512, LONG_QUANTUM_FRAMES];
36
43
 
37
44
  /** Seconds one plugin's offline pass may take before its worker is killed and it reads as a timeout. */
38
45
  export const PLUGIN_TIMEOUT_S = 15;
@@ -174,13 +181,22 @@ export const MEMORY_BOUND_EXIT_STATUS = 121;
174
181
  /**
175
182
  * The memory-only pass (`plugin-qualify-sync --memory-only`). With `PROBE_MEMORY_ONLY_ENV` set the probers take the
176
183
  * footprint and nothing else; the probe runner passes its environment on, so its limits hold unchanged.
177
- * `PROBE_NOTES_ENV` is the count of notes an instrument that declares no voice capacity holds, basis `probe`:
184
+ * `PROBE_NOTES_ENV` is the count of notes an instrument that declares no voice capacity holds, basis `probe`, in every
185
+ * LV2 run (probe-runner) and in the memory-only pass:
178
186
  * `MEMORY_PASS_PROBE_NOTES` unless `memory_notes` says otherwise.
179
187
  */
180
188
  export const PROBE_MEMORY_ONLY_ENV = "OMX_PROBE_MEMORY_ONLY";
181
189
  export const PROBE_NOTES_ENV = "OMX_PROBE_NOTES";
182
190
  export const MEMORY_PASS_PROBE_NOTES = 16;
183
191
 
192
+ /**
193
+ * The counts of notes an LV2 instrument is timed at, besides its probe count, for its cost against the notes held
194
+ * (the rate's `voiceCurve`, probe output 1.4): the counts the Pi measurements held Surge XT at
195
+ * (openmixer.org/notes/instruments-on-a-pi-5), so a voice limit shows as the count past which the cost stops growing.
196
+ */
197
+ export const PROBE_NOTES_CURVE_ENV = "OMX_PROBE_NOTES_CURVE";
198
+ export const PROBE_NOTES_CURVE = [1, 8, 16, 32, 48, 64] as const;
199
+
184
200
  /**
185
201
  * What `tools/mod-host-admit.mjs` is told in an adapter-only pass: how long the host has for one command, its address
186
202
  * space (`ulimit -v`, in KB) and the directory for the state files of the plugin it loads.
@@ -239,6 +255,8 @@ export const DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing
239
255
  export const DESCRIPTOR_CODE_SCHEMA_FLOOR = "qualify.descriptor.schema-floor";
240
256
  export const DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed";
241
257
  export const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable";
258
+ /** Two ports of one plugin carry the same symbol: neither can be addressed by it, so the plugin is not described. Args: `symbol`. */
259
+ export const DESCRIPTOR_CODE_DUPLICATE_SYMBOL = "qualify.descriptor.duplicate-symbol";
242
260
 
243
261
 
244
262
 
@@ -317,11 +335,14 @@ const piMemory = (ramGib: number): NonNullable<HostBudget["memory"]> => ({
317
335
  /**
318
336
  * The cells a Raspberry Pi runs: zynthian-sys's JACK settings, 48000/128 on a Pi 5 with the V5 kit and 48000/256 on a Pi 4 and
319
337
  * the other kits, the one a Pi 5 with no kit card also ran its stress and first boot at. The Zynthian benchmark's matrix sweeps
320
- * more cells; these two are what a Pi is set to.
338
+ * more cells; these two are what a Pi is set to. Every Pi matrix carries 96000 beside 48000, so each of the two quanta
339
+ * is also run at twice the base rate: the passes that follow these cells (adapter, isolated, memory, pool) cost twice as long.
321
340
  */
322
341
  export const PI_CELLS = [
323
342
  { rate: MEASURE_BASE_RATE, quantum: 128 },
324
343
  { rate: MEASURE_BASE_RATE, quantum: 256 },
344
+ { rate: 2 * MEASURE_BASE_RATE, quantum: 128 },
345
+ { rate: 2 * MEASURE_BASE_RATE, quantum: 256 },
325
346
  ];
326
347
 
327
348
  export const HOST_BUDGETS = {