@openmixer/plugin-qualify 0.20.0 → 0.20.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/plugin-qualify-records-package.mjs +9 -7
- package/bin/plugin-qualify-sync.mjs +1 -0
- package/bin/plugin-qualify.mjs +34 -0
- package/catalogs/ca.json +8 -0
- package/dist/clap-loader.js +2 -1
- package/dist/clap-loader.js.map +1 -1
- package/dist/clap-qualify.d.ts +11 -0
- package/dist/clap-qualify.js +16 -0
- package/dist/clap-qualify.js.map +1 -1
- package/dist/distributed-records.d.ts +16 -3
- package/dist/distributed-records.js +38 -4
- package/dist/distributed-records.js.map +1 -1
- package/dist/host-admission.d.ts +25 -0
- package/dist/host-admission.js +41 -5
- package/dist/host-admission.js.map +1 -1
- package/dist/hostd-search-path.d.ts +38 -0
- package/dist/hostd-search-path.js +58 -0
- package/dist/hostd-search-path.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/jack-registry.js +1 -1
- package/dist/judged-layer.d.ts +91 -0
- package/dist/judged-layer.js +144 -0
- package/dist/judged-layer.js.map +1 -0
- package/dist/judgement-line.d.ts +6 -0
- package/dist/judgement-line.js +20 -0
- package/dist/judgement-line.js.map +1 -0
- package/dist/lv2-loader.d.ts +33 -1
- package/dist/lv2-loader.js +82 -19
- package/dist/lv2-loader.js.map +1 -1
- package/dist/measurement/cli.d.ts +2 -0
- package/dist/measurement/cli.js +25 -2
- package/dist/measurement/cli.js.map +1 -1
- package/dist/measurement/format.d.ts +2 -0
- package/dist/measurement/format.js +5 -0
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/vocabulary.js +3 -3
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/measurement-grid.d.ts +1 -1
- package/dist/measurement-grid.js +5 -3
- package/dist/measurement-grid.js.map +1 -1
- package/dist/pins-file.d.ts +3 -2
- package/dist/pins-file.js +3 -2
- package/dist/pins-file.js.map +1 -1
- package/dist/probe-judge.js +5 -0
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-output.d.ts +10 -0
- package/dist/probe-output.js +16 -0
- package/dist/probe-output.js.map +1 -1
- package/dist/probe-runner.js +24 -5
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +13 -0
- package/dist/qualification-record.js +25 -3
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-adapter-pass.d.ts +1 -2
- package/dist/qualify-adapter-pass.js +6 -6
- package/dist/qualify-adapter-pass.js.map +1 -1
- package/dist/qualify-declarations.d.ts +23 -6
- package/dist/qualify-declarations.js +25 -6
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-isolated.d.ts +14 -3
- package/dist/qualify-isolated.js +22 -9
- package/dist/qualify-isolated.js.map +1 -1
- package/dist/qualify-pool.js +4 -3
- package/dist/qualify-pool.js.map +1 -1
- package/dist/qualify-rejudge.js +3 -3
- package/dist/qualify-rejudge.js.map +1 -1
- package/dist/qualify-sync.d.ts +18 -6
- package/dist/qualify-sync.js +68 -38
- package/dist/qualify-sync.js.map +1 -1
- package/dist/records-package.d.ts +31 -4
- package/dist/records-package.js +149 -24
- package/dist/records-package.js.map +1 -1
- package/dist/run-home.d.ts +4 -0
- package/dist/run-home.js +26 -0
- package/dist/run-home.js.map +1 -0
- package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.probe.json +58 -0
- package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.qualification.json +305 -0
- package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.probe.json +58 -0
- package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.qualification.json +301 -0
- package/package.json +7 -5
- package/schema/codes.json +15 -0
- package/src/clap-loader.ts +2 -1
- package/src/clap-qualify.ts +25 -0
- package/src/distributed-records.ts +41 -4
- package/src/host-admission.ts +52 -5
- package/src/hostd-search-path.ts +85 -0
- package/src/index.ts +2 -0
- package/src/jack-registry.ts +1 -1
- package/src/judged-layer.ts +197 -0
- package/src/judgement-line.ts +22 -0
- package/src/lv2-loader.ts +89 -17
- package/src/measurement/cli.ts +26 -2
- package/src/measurement/format.ts +8 -0
- package/src/measurement/vocabulary.ts +3 -3
- package/src/measurement-grid.ts +5 -3
- package/src/pins-file.ts +3 -2
- package/src/probe-judge.ts +5 -0
- package/src/probe-output.ts +23 -0
- package/src/probe-runner.ts +25 -4
- package/src/qualification-record.ts +32 -3
- package/src/qualify-adapter-pass.ts +6 -5
- package/src/qualify-declarations.ts +27 -6
- package/src/qualify-isolated.ts +24 -9
- package/src/qualify-pool.ts +4 -3
- package/src/qualify-rejudge.ts +3 -3
- package/src/qualify-sync.ts +74 -41
- package/src/records-package.ts +156 -25
- package/src/run-home.ts +28 -0
- package/tools/_declarations.py +2 -1
- package/tools/descriptors.py +40 -3
- package/tools/gen-python-declarations.mjs +1 -0
- package/tools/isolated-step.mjs +24 -3
- package/tools/lv2-measure.mjs +21 -2
- package/tools/mod-host-admit.mjs +57 -1
- package/tools/scan.py +29 -5
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmixer/plugin-qualify",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.3",
|
|
4
4
|
"description": "Qualify audio plugins for real-time hosting: scan, offline latency and cost measurement, and a fail-closed rating.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/
|
|
9
|
+
"url": "git+https://github.com/Open-Mixer/plugin-qualify.git"
|
|
10
10
|
},
|
|
11
11
|
"author": "Pau Aliagas <linuxnow@gmail.com>",
|
|
12
|
-
"homepage": "https://github.com/
|
|
12
|
+
"homepage": "https://github.com/Open-Mixer/plugin-qualify#readme",
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/
|
|
14
|
+
"url": "https://github.com/Open-Mixer/plugin-qualify/issues"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=18"
|
|
@@ -75,6 +75,8 @@
|
|
|
75
75
|
"!**/__pycache__",
|
|
76
76
|
"!**/*.pyc",
|
|
77
77
|
"fixtures",
|
|
78
|
+
"schema/codes.json",
|
|
79
|
+
"catalogs",
|
|
78
80
|
"README.md",
|
|
79
81
|
"LICENSE"
|
|
80
82
|
],
|
|
@@ -98,6 +100,6 @@
|
|
|
98
100
|
"provenance": true
|
|
99
101
|
},
|
|
100
102
|
"dependencies": {
|
|
101
|
-
"@openmixer/omx-contract": "^3.
|
|
103
|
+
"@openmixer/omx-contract": "^3.12.0"
|
|
102
104
|
}
|
|
103
105
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "codes",
|
|
3
|
+
"doc": "The codes plugin-qualify's scan refuses a plugin by, each with its source sentence in English and the args it names (omx-contract's codes shape; translations are not here). Each language's sentences are in catalogs/<lang>.json, keyed by the code. tools/descriptors.py renders the English sentence from this file; no module repeats it. A code is never reworded.",
|
|
4
|
+
"codes": {
|
|
5
|
+
"descriptorDuplicateSymbol": {
|
|
6
|
+
"code": "qualify.descriptor.duplicate-symbol",
|
|
7
|
+
"text": {
|
|
8
|
+
"en": "Two ports of this plugin share the symbol {symbol}, so neither can be addressed by it; the plugin is not described."
|
|
9
|
+
},
|
|
10
|
+
"args": [
|
|
11
|
+
"symbol"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/clap-loader.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { type Dirent, existsSync, readdirSync, statSync } from "node:fs";
|
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { basename, dirname, join, resolve } from "node:path";
|
|
19
19
|
|
|
20
|
+
import { withRunHome } from "./run-home.js";
|
|
20
21
|
import type { PluginLoader, PluginTarget } from "./loader.js";
|
|
21
22
|
import { lv2BundlesOnPath, lv2Loader } from "./lv2-loader.js";
|
|
22
23
|
import {
|
|
@@ -345,7 +346,7 @@ export interface ClapScanRunResult {
|
|
|
345
346
|
export type ClapScanRun = (tool: string, args: readonly string[], timeoutMs: number) => ClapScanRunResult;
|
|
346
347
|
|
|
347
348
|
const spawnScan: ClapScanRun = (tool, args, timeoutMs) => {
|
|
348
|
-
const r = spawnSync(tool, [...args], { encoding: "utf8", timeout: timeoutMs, maxBuffer: MAX_SCAN_BYTES });
|
|
349
|
+
const r = spawnSync(tool, [...args], { encoding: "utf8", timeout: timeoutMs, maxBuffer: MAX_SCAN_BYTES, env: withRunHome(process.env) });
|
|
349
350
|
return { status: r.status, stdout: r.stdout ?? "", ...(r.error === undefined ? {} : { error: r.error }) };
|
|
350
351
|
};
|
|
351
352
|
|
package/src/clap-qualify.ts
CHANGED
|
@@ -79,6 +79,18 @@ export interface ClapCostFigure {
|
|
|
79
79
|
readonly voices?: VoicesHeld;
|
|
80
80
|
/** What these blocks were timed under. */
|
|
81
81
|
readonly scheduling?: TimingScheduling;
|
|
82
|
+
/**
|
|
83
|
+
* For an instrument: its cost at each count of notes held with every parameter at its default (`--curve`), the
|
|
84
|
+
* rate's voiceCurve that plugin-hostd's runner carries into probe output 1.4; an entry it could not time has a reason.
|
|
85
|
+
*/
|
|
86
|
+
readonly voiceCurve?: readonly ClapVoiceCurveEntry[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ClapVoiceCurveEntry {
|
|
90
|
+
readonly held: number;
|
|
91
|
+
readonly nsPerSampleMedian?: number;
|
|
92
|
+
readonly nsPerSampleP95?: number;
|
|
93
|
+
readonly unmeasuredReason?: string;
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
/** One rate the cost arm did not time, and why; an instrument's declared capacity is kept when it has one. */
|
|
@@ -281,9 +293,22 @@ function readCostAtRate(v: unknown, where: string, notes: boolean): ClapCostAtRa
|
|
|
281
293
|
? {}
|
|
282
294
|
: { voices: { held: voices.held, basis: voices.basis, ...(voices.capacity === undefined ? {} : { capacity: voices.capacity }) } }),
|
|
283
295
|
...(scheduling === undefined ? {} : { scheduling }),
|
|
296
|
+
...(v["voiceCurve"] === undefined ? {} : { voiceCurve: readVoiceCurve(v["voiceCurve"], `${where} voiceCurve`) }),
|
|
284
297
|
};
|
|
285
298
|
}
|
|
286
299
|
|
|
300
|
+
function readVoiceCurve(v: unknown, where: string): ClapVoiceCurveEntry[] {
|
|
301
|
+
if (!Array.isArray(v)) return bad(`${where} is not a list`);
|
|
302
|
+
return v.map((e: unknown, i: number) => {
|
|
303
|
+
const at = `${where} entry ${i}`;
|
|
304
|
+
if (!isRecord(e)) return bad(`${at} is not an object`);
|
|
305
|
+
const held = count(e, "held", at);
|
|
306
|
+
if (held === 0) return bad(`${at} holds no note`);
|
|
307
|
+
if (e["unmeasuredReason"] !== undefined) return { held, unmeasuredReason: str(e, "unmeasuredReason", at) };
|
|
308
|
+
return { held, nsPerSampleMedian: figure(e, "nsPerSampleMedian", at), nsPerSampleP95: figure(e, "nsPerSampleP95", at) };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
287
312
|
function readCost(v: unknown): ClapCostReading {
|
|
288
313
|
if (!isRecord(v)) return bad("cost is not an object");
|
|
289
314
|
const stimulus = str(v, "stimulus", "cost");
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* treats the plugin as one it measured.
|
|
13
13
|
*/
|
|
14
14
|
import { readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { machine } from "node:os";
|
|
15
16
|
import { join, resolve } from "node:path";
|
|
16
17
|
|
|
18
|
+
import { cellKey, type GridCell } from "./measurement-grid.js";
|
|
17
19
|
import { clapPinUri, pinVerdictOf, type PinEntry } from "./pins-file.js";
|
|
18
20
|
import { integrityOfProbeOutput } from "./probe-judge.js";
|
|
19
21
|
import { readProbeOutput, sha256Hex } from "./probe-output.js";
|
|
@@ -60,6 +62,20 @@ export function hostClassOfBoard(model: string | undefined): string | undefined
|
|
|
60
62
|
return m === null ? undefined : `rpi${m[1]!}`;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/** The architecture `uname -m` names, or `undefined` when it cannot be read. */
|
|
66
|
+
export function readMachine(): string | undefined {
|
|
67
|
+
try {
|
|
68
|
+
return machine();
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The host class of a machine with no board name, by `uname -m`: `x86-64` for an x86_64 one; none for an architecture without a class. */
|
|
75
|
+
export function hostClassOfMachine(arch: string | undefined): string | undefined {
|
|
76
|
+
return arch?.trim() === "x86_64" ? "x86-64" : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
/** One distributed record, read: where it is, what it says, and the set it came with. */
|
|
64
80
|
export interface DistributedRecord {
|
|
65
81
|
readonly path: string;
|
|
@@ -156,26 +172,38 @@ export function outcomeOfVerdict(v: Pick<RecordVerdict, "measured" | "rating" |
|
|
|
156
172
|
return v.rating === "unsuitable" ? "refused" : "unmeasured";
|
|
157
173
|
}
|
|
158
174
|
|
|
175
|
+
/** The cells a record's grid holds, by rate then quantum: where the set it came in was measured. */
|
|
176
|
+
export function recordCells(record: Pick<QualificationRecord, "measurements">): GridCell[] {
|
|
177
|
+
const seen = new Map<string, GridCell>();
|
|
178
|
+
for (const p of record.measurements?.grid ?? []) seen.set(cellKey(p), { rate: p.rate, quantum: p.quantum });
|
|
179
|
+
return [...seen.values()].sort((a, b) => a.rate - b.rate || a.quantum - b.quantum);
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
/** What the layer makes of one item. */
|
|
160
183
|
export type DistributedMatch =
|
|
161
184
|
/** No distributed record names the plugin: it is measured, and nothing is said. */
|
|
162
185
|
| { readonly status: "none" }
|
|
163
186
|
/** Every reason a record found was not taken, one line each; the plugin is measured. */
|
|
164
187
|
| { readonly status: "ignored"; readonly lines: readonly string[] }
|
|
165
|
-
|
|
188
|
+
/** A set applies; `lines` names every set tried before it and why it was passed over. */
|
|
189
|
+
| { readonly status: "applies"; readonly set: string; readonly records: readonly DistributedRecord[]; readonly files: readonly string[]; readonly lines: readonly string[] };
|
|
166
190
|
|
|
167
191
|
/**
|
|
168
192
|
* Whether the item is taken from the layer. The records that find it are tried one set at a time, in the order the
|
|
169
193
|
* layer read them. Of a set, only the records whose every file is installed with the SHA-256 it recorded (computed
|
|
170
194
|
* here by `digest`) count: a record of other bytes is another build of the plugin and is passed over. The set applies
|
|
171
195
|
* when at least one record of these bytes holds a verdict for `host`, and none of those verdicts is one the source
|
|
172
|
-
* could not measure; a set with no record of these bytes is a binary that differs.
|
|
196
|
+
* could not measure; a set with no record of these bytes is a binary that differs. A set is per machine class, rate
|
|
197
|
+
* and quantum: of these records, only the ones whose grid holds every cell in `cells` (the cells the sync runs at)
|
|
198
|
+
* count, so a set measured at another cell is passed over, naming the cells it was measured at, and never answers a
|
|
199
|
+
* sync at this one by the order its name sorts in (#317). No cells given asks nothing of the grid.
|
|
173
200
|
*/
|
|
174
201
|
export function matchDistributed(
|
|
175
202
|
layer: DistributedLayer,
|
|
176
203
|
item: SyncItem,
|
|
177
204
|
host: string,
|
|
178
205
|
digest: (files: readonly string[]) => readonly FileDigest[] | undefined,
|
|
206
|
+
cells: readonly GridCell[] = [],
|
|
179
207
|
): DistributedMatch {
|
|
180
208
|
const found = item.format === "clap" ? layer.clap.get(resolve(join(item.root, item.files[0]!))) : layer.lv2.get(item.uri ?? "");
|
|
181
209
|
if (found === undefined || found.length === 0) return { status: "none" };
|
|
@@ -195,7 +223,16 @@ export function matchDistributed(
|
|
|
195
223
|
say(`distributed: ${distributedUri(item, found.find((r) => r.set === set)!)}: binary differs`);
|
|
196
224
|
continue;
|
|
197
225
|
}
|
|
198
|
-
const
|
|
226
|
+
const atCell = records.filter((r) => {
|
|
227
|
+
const held = new Set(recordCells(r.record).map(cellKey));
|
|
228
|
+
return cells.every((c) => held.has(cellKey(c)));
|
|
229
|
+
});
|
|
230
|
+
if (atCell.length === 0) {
|
|
231
|
+
const measuredAt = [...new Set(records.flatMap((r) => recordCells(r.record).map(cellKey)))];
|
|
232
|
+
say(`distributed: ${distributedUri(item, records[0]!)}: set ${set} measured at ${measuredAt.length === 0 ? "no cell" : measuredAt.join(", ")}`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const judged = atCell.filter((r) => r.record.verdicts.some((v) => v.host === host));
|
|
199
236
|
if (judged.length === 0) {
|
|
200
237
|
say(`distributed: ${distributedUri(item, records[0]!)}: no verdict for ${host} in ${set}`);
|
|
201
238
|
continue;
|
|
@@ -205,7 +242,7 @@ export function matchDistributed(
|
|
|
205
242
|
say(`distributed: ${distributedUri(item, unmeasured)}: unmeasured in ${set}, measured here`);
|
|
206
243
|
continue;
|
|
207
244
|
}
|
|
208
|
-
return { status: "applies", set, records, files: [...new Set(
|
|
245
|
+
return { status: "applies", set, records: atCell, files: [...new Set(atCell.flatMap((r) => r.record.binary.files.map((f) => f.path)))], lines };
|
|
209
246
|
}
|
|
210
247
|
return { status: "ignored", lines };
|
|
211
248
|
}
|
package/src/host-admission.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
14
|
+
import { withRunHome } from "./run-home.js";
|
|
14
15
|
import type { HostProfile } from "./host-profiles.js";
|
|
15
16
|
import { ADMIT_STEP_MS_ENV } from "./qualify-declarations.js";
|
|
16
17
|
|
|
@@ -83,7 +84,17 @@ export interface ModHostRun {
|
|
|
83
84
|
* not be read. Absent for a plugin that is no instrument, and for any plugin under mod-host.
|
|
84
85
|
*/
|
|
85
86
|
/** `notes`: how many keys were played; a run from when one key was played has none. */
|
|
86
|
-
readonly sound?: { readonly peak?: number; readonly notes?: number; readonly problem?: string };
|
|
87
|
+
readonly sound?: { readonly peak?: number; readonly notes?: number; readonly problem?: string; readonly content?: AdapterContent };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The content an instrument declares it plays from (its `patch:writable` parameters of range `atom:Path`, `lv2ContentParams`)
|
|
92
|
+
* and what came of giving each a short sample before the notes: `loaded` the host took, `refused` it did not (with its answer).
|
|
93
|
+
*/
|
|
94
|
+
export interface AdapterContent {
|
|
95
|
+
readonly params: readonly string[];
|
|
96
|
+
readonly loaded: readonly string[];
|
|
97
|
+
readonly refused?: readonly string[];
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
/** At or under this magnitude (-120 dBFS) a held note is taken as digital silence: clap-qualify's own SILENCE. */
|
|
@@ -97,6 +108,22 @@ export const SILENT_PEAK = 1e-6;
|
|
|
97
108
|
export const ADAPTER_SILENT_CODE = `${ADAPTER_HOST}.silent-under-note`;
|
|
98
109
|
export const ADAPTER_LEVEL_UNMEASURED_CODE = `${ADAPTER_HOST}.level-unmeasured`;
|
|
99
110
|
const UNPROVEN_SOUND: readonly string[] = [ADAPTER_SILENT_CODE, ADAPTER_LEVEL_UNMEASURED_CODE];
|
|
111
|
+
/**
|
|
112
|
+
* An instrument that declares content it plays from and stayed silent under the notes even so: the content could not be
|
|
113
|
+
* given (the host refused the sample) or the sample did not reach the keys played. Its silence says nothing of the plugin,
|
|
114
|
+
* so the note test is not observable for it: the run admits it, and the record names this code and the content (#318).
|
|
115
|
+
* No other host is asked instead.
|
|
116
|
+
*/
|
|
117
|
+
export const ADAPTER_CONTENT_UNOBSERVED_CODE = `${ADAPTER_HOST}.silent-needs-content`;
|
|
118
|
+
|
|
119
|
+
/** The run's content, as a reason's params carry it: the declared parameters, the ones loaded, the ones refused. */
|
|
120
|
+
function contentParams(content: AdapterContent): Record<string, string> {
|
|
121
|
+
return {
|
|
122
|
+
content: content.params.join(" "),
|
|
123
|
+
loaded: content.loaded.join(" "),
|
|
124
|
+
...(content.refused === undefined || content.refused.length === 0 ? {} : { refused: content.refused.join("; ") }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
100
127
|
|
|
101
128
|
/** The rating a run that did not admit the plugin gives its host's verdict. */
|
|
102
129
|
export function admissionRating(admission: HostAdmission): "unsuitable" | "unknown" {
|
|
@@ -109,6 +136,19 @@ export function admissionRating(admission: HostAdmission): "unsuitable" | "unkno
|
|
|
109
136
|
*/
|
|
110
137
|
const JACK_CLIENT_RESPS: readonly number[] = [-201, -202];
|
|
111
138
|
|
|
139
|
+
/** The name of the reason a run stops on when no JACK server answers: the admission cannot say anything of any plugin. */
|
|
140
|
+
export const NO_JACK_SERVER = "no JACK server";
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The refusal to go on when an admission found no JACK client to have (`<host>.no-jack-client`): the server the host is
|
|
144
|
+
* pointed at (the default one, or `JACK_DEFAULT_SERVER`) is not there, and every plugin would be recorded with the same
|
|
145
|
+
* resp -201 as if it were the plugin's. `undefined` for any other run.
|
|
146
|
+
*/
|
|
147
|
+
export function noJackServerProblem(admission: HostAdmission | undefined): string | undefined {
|
|
148
|
+
if (admission === undefined || admission.measured || !admission.code?.endsWith(".no-jack-client")) return undefined;
|
|
149
|
+
return `${NO_JACK_SERVER}: ${admission.host} could not open a JACK client (resp ${String(admission.params["resp"] ?? "-201")}), so nothing is learned of any plugin; start the JACK server it is pointed at (JACK_DEFAULT_SERVER names it) and run again`;
|
|
150
|
+
}
|
|
151
|
+
|
|
112
152
|
/** The verdict of a run of a host that speaks mod-host's protocol: the first step that did not answer `resp 0` decides. */
|
|
113
153
|
export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAdmission {
|
|
114
154
|
if (!run.installed) return notInstalled(host);
|
|
@@ -128,7 +168,14 @@ export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAd
|
|
|
128
168
|
if (run.sound !== undefined) {
|
|
129
169
|
const { peak, problem } = run.sound;
|
|
130
170
|
if (peak === undefined) return { host, measured: true, admitted: false, code: ADAPTER_LEVEL_UNMEASURED_CODE, params: { problem: problem ?? "no figure" } };
|
|
131
|
-
|
|
171
|
+
const notes: Record<string, number> = run.sound.notes === undefined ? {} : { notes: run.sound.notes };
|
|
172
|
+
const content = run.sound.content;
|
|
173
|
+
if (!(peak > SILENT_PEAK)) {
|
|
174
|
+
if (content !== undefined && content.params.length > 0) return { host, measured: true, admitted: true, code: ADAPTER_CONTENT_UNOBSERVED_CODE, params: { peak, ...notes, ...contentParams(content) } };
|
|
175
|
+
return { host, measured: true, admitted: false, code: ADAPTER_SILENT_CODE, params: { peak, ...notes } };
|
|
176
|
+
}
|
|
177
|
+
// it sounded on the content it was given: the record says which
|
|
178
|
+
if (content !== undefined && content.loaded.length > 0) return { ...admitted(host), params: { peak, ...notes, ...contentParams(content) } };
|
|
132
179
|
}
|
|
133
180
|
return admitted(host);
|
|
134
181
|
}
|
|
@@ -186,14 +233,14 @@ export const SPAWNED_ADMISSIONS: AdmissionRuns = {
|
|
|
186
233
|
// two commands at the limit the pass gives each, and the tool's own start and end: it kills the host itself first
|
|
187
234
|
const stepMs = Number(env?.[ADMIT_STEP_MS_ENV] ?? 0);
|
|
188
235
|
const timeout = stepMs > 0 ? 2 * stepMs + 15000 : MOD_HOST_ADMISSION_MS;
|
|
189
|
-
const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri, ADAPTER_HOST], { encoding: "utf8", timeout, killSignal: "SIGKILL",
|
|
236
|
+
const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri, ADAPTER_HOST], { encoding: "utf8", timeout, killSignal: "SIGKILL", env: withRunHome(env ?? process.env) });
|
|
190
237
|
if (r.error !== undefined || r.status !== 0) {
|
|
191
238
|
throw new Error(`${ADAPTER_HOST} admission of ${uri} gave no answer (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim()}`);
|
|
192
239
|
}
|
|
193
240
|
return JSON.parse(r.stdout) as ModHostRun;
|
|
194
241
|
},
|
|
195
242
|
modHost(uri, env) {
|
|
196
|
-
const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri], { encoding: "utf8", timeout: MOD_HOST_ADMISSION_MS,
|
|
243
|
+
const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri], { encoding: "utf8", timeout: MOD_HOST_ADMISSION_MS, env: withRunHome(env ?? process.env) });
|
|
197
244
|
if (r.error !== undefined || r.status !== 0) {
|
|
198
245
|
throw new Error(`mod-host admission of ${uri} gave no answer (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim()}`);
|
|
199
246
|
}
|
|
@@ -203,7 +250,7 @@ export const SPAWNED_ADMISSIONS: AdmissionRuns = {
|
|
|
203
250
|
// SIGKILL at the deadline: jalv hosting some plugins ignores SIGTERM, and one stuck in jack_client_open never reaches
|
|
204
251
|
// the loop that reads the flag TERM sets, so a TERM left spawnSync waiting with it (#187). Alive at the deadline is
|
|
205
252
|
// all the verdict asks, so nothing is lost by not letting it exit cleanly.
|
|
206
|
-
const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, killSignal: "SIGKILL", input: "",
|
|
253
|
+
const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, killSignal: "SIGKILL", input: "", env: withRunHome(env ?? process.env) });
|
|
207
254
|
const code = (r.error as NodeJS.ErrnoException | undefined)?.code;
|
|
208
255
|
if (code === "ENOENT") return { installed: false, stillRunning: false, status: null, signal: null, stderr: "" };
|
|
209
256
|
return { installed: true, stillRunning: code === "ETIMEDOUT", status: r.status, signal: code === "ETIMEDOUT" ? null : r.signal, stderr: r.stderr ?? "" };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The directories the sync scans, as plugin-hostd searches them (plugin-qualify#9). One definition of the search path:
|
|
5
|
+
* the daemon's, which `plugin-hostd --search-path [-c <file>]` prints from the code it searches with (plugin-hostd 0.20.3
|
|
6
|
+
* and later). This module never works the lists out itself: it runs the daemon on its settings file and reads the
|
|
7
|
+
* `lv2` and `clap` lines, and where each list came from (`file`, `env` or `default`).
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { HOSTD_ENV } from "./hostd-pin-files.js";
|
|
11
|
+
|
|
12
|
+
/** Where plugin-hostd took a list from: its settings file, its environment (`LV2_PATH`, `CLAP_PATH`) or its default. */
|
|
13
|
+
export type SearchPathSource = "file" | "env" | "default";
|
|
14
|
+
|
|
15
|
+
export interface HostdSearchPath {
|
|
16
|
+
/** The LV2 directories, in the order the daemon searches them: `lv2_path` then `lv2_extra`. */
|
|
17
|
+
readonly lv2: readonly string[];
|
|
18
|
+
/** The CLAP directories, in the order the daemon searches them. */
|
|
19
|
+
readonly clap: readonly string[];
|
|
20
|
+
/** Where each of `lv2_path`, `lv2_extra` and `clap_path` came from. */
|
|
21
|
+
readonly sources: Readonly<Record<"lv2_path" | "lv2_extra" | "clap_path", SearchPathSource>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SearchPathRunResult {
|
|
25
|
+
readonly status: number | null;
|
|
26
|
+
readonly stdout: string;
|
|
27
|
+
readonly stderr: string;
|
|
28
|
+
/** The daemon is not installed. */
|
|
29
|
+
readonly missing?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One run of `plugin-hostd --search-path`, with `-c <conf>` when a settings file is named. */
|
|
33
|
+
export type SearchPathRun = (conf: string | undefined, env: Readonly<Record<string, string | undefined>>) => SearchPathRunResult;
|
|
34
|
+
|
|
35
|
+
export const SPAWNED_SEARCH_PATH: SearchPathRun = (conf, env) => {
|
|
36
|
+
const r = spawnSync(env[HOSTD_ENV] ?? "plugin-hostd", [...(conf === undefined ? [] : ["-c", conf]), "--search-path"], { encoding: "utf8", timeout: 30000, env: { ...env } });
|
|
37
|
+
const missing = (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
38
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", ...(missing ? { missing } : {}) };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SOURCES: readonly string[] = ["file", "env", "default"];
|
|
42
|
+
const dirList = (text: string): string[] => text.split(":").filter((d) => d !== "");
|
|
43
|
+
|
|
44
|
+
/** The lines `plugin-hostd --search-path` prints, read: `<key> TAB <source> TAB <value>`; why not, when one is missing. */
|
|
45
|
+
export function parseSearchPath(stdout: string): { ok: true; path: HostdSearchPath } | { ok: false; error: string } {
|
|
46
|
+
const rows = new Map<string, { source: string; value: string }>();
|
|
47
|
+
for (const line of stdout.split("\n")) {
|
|
48
|
+
const f = line.split("\t");
|
|
49
|
+
if (f.length >= 2 && f[0] !== undefined && f[1] !== undefined) rows.set(f[0], { source: f[1], value: f.slice(2).join("\t") });
|
|
50
|
+
}
|
|
51
|
+
for (const key of ["lv2_path", "lv2_extra", "clap_path"]) {
|
|
52
|
+
const r = rows.get(key);
|
|
53
|
+
if (r === undefined || !SOURCES.includes(r.source)) return { ok: false, error: `plugin-hostd --search-path printed no ${key} with its source` };
|
|
54
|
+
}
|
|
55
|
+
const lv2 = rows.get("lv2");
|
|
56
|
+
const clap = rows.get("clap");
|
|
57
|
+
if (lv2?.source !== "effective" || clap?.source !== "effective") return { ok: false, error: "plugin-hostd --search-path printed no effective lv2 and clap lists" };
|
|
58
|
+
const src = (key: string) => rows.get(key)!.source as SearchPathSource;
|
|
59
|
+
return { ok: true, path: { lv2: dirList(lv2.value), clap: dirList(clap.value), sources: { lv2_path: src("lv2_path"), lv2_extra: src("lv2_extra"), clap_path: src("clap_path") } } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The directories plugin-hostd searches with the settings file `conf` (its own default when undefined); why not, when it cannot say. */
|
|
63
|
+
export function hostdSearchPath(
|
|
64
|
+
conf: string | undefined,
|
|
65
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
66
|
+
run: SearchPathRun = SPAWNED_SEARCH_PATH,
|
|
67
|
+
): { ok: true; path: HostdSearchPath } | { ok: false; error: string } {
|
|
68
|
+
const r = run(conf, env);
|
|
69
|
+
if (r.missing) return { ok: false, error: "plugin-hostd is not installed: the directories scanned are the ones plugin-hostd searches" };
|
|
70
|
+
if (r.status !== 0) {
|
|
71
|
+
const why = /unrecognized option|^Usage:/m.test(r.stdout + r.stderr)
|
|
72
|
+
? "this plugin-hostd has no --search-path (plugin-hostd 0.20.3 or later names the directories it searches)"
|
|
73
|
+
: r.stderr.trim().split("\n").pop() || `plugin-hostd --search-path exited ${String(r.status)}`;
|
|
74
|
+
return { ok: false, error: why };
|
|
75
|
+
}
|
|
76
|
+
return parseSearchPath(r.stdout);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What the run says when a list is not the settings file's: the running daemon, started with another environment, may search other directories. */
|
|
80
|
+
export function searchPathWarnings(path: HostdSearchPath, conf: string | undefined): string[] {
|
|
81
|
+
const where = conf ?? "plugin-hostd's own settings file";
|
|
82
|
+
return (["lv2_path", "clap_path"] as const)
|
|
83
|
+
.filter((k) => path.sources[k] !== "file")
|
|
84
|
+
.map((k) => `${k} is plugin-hostd's ${path.sources[k] === "env" ? `environment (${k === "lv2_path" ? "LV2_PATH" : "CLAP_PATH"})` : "default"}, not ${where}: the running daemon may search other directories; write ${k} in ${where}`);
|
|
85
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ export * from "./cost-fields.js";
|
|
|
24
24
|
export * from "./probe-output.js";
|
|
25
25
|
export * from "./probe-judge.js";
|
|
26
26
|
export * from "./qualification-record.js";
|
|
27
|
+
export * from "./judgement-line.js";
|
|
28
|
+
export * from "./judged-layer.js";
|
|
27
29
|
export * from "./probe-runner.js";
|
|
28
30
|
export * from "./measurement-grid.js";
|
|
29
31
|
export * from "./memory-bound.js";
|
package/src/jack-registry.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* JACK's table of its servers, /dev/shm/jack-shm-registry: a header, then eight rows of a pid and a name
|
|
5
5
|
* (`jack-<uid>:<server>:`). A server that is killed never clears its row, and once the eight are taken no server starts.
|
|
6
|
-
* The adapter pass starts and stops servers of its own, `omx-qualify-<pid>-<quantum>`; a row of one of those whose
|
|
6
|
+
* The adapter pass starts and stops servers of its own, `omx-qualify-<pid>-<rate>-<quantum>`; a row of one of those whose
|
|
7
7
|
* process is gone is freed (pid 0, name empty) before the next start, as jackd frees its own row when it ends well.
|
|
8
8
|
* Rows of any other server are never touched, and a file of another layout is left as it is.
|
|
9
9
|
*/
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The judged layer of a plugin-db entry, from a qualification record: plugin-qualify is that layer's one writer
|
|
5
|
+
* (plugin-db's records design note, FreeMixer/plugin-db#10). The reader never parses a record; it reads the layer this writes, so every field
|
|
6
|
+
* the state function reads is set here, from the record and from what plugin-qualify declares once:
|
|
7
|
+
*
|
|
8
|
+
* - the latency line (`LATENCY_LINE_MS`), written beside each cell's figure (`latencyMs`), so a surface shows the
|
|
9
|
+
* figure within or over the line and keeps no tier word (openmixer plugin-system design 7.15);
|
|
10
|
+
* - the rules the record was judged by (`judgementRules`), the minor of the plugin-qualify that judged it: a rule
|
|
11
|
+
* changes only in a minor, so a patch release leaves every record current;
|
|
12
|
+
* - the hosting epoch of the host the verdict is for (`HOSTING_EPOCH`).
|
|
13
|
+
*
|
|
14
|
+
* One layer is one verdict under one host: the host plugin-hostd runs a plugin with. Its verdict is the one the pins
|
|
15
|
+
* file gives the same bytes (`pinVerdictOf` of the isolated tier's host, omx-clap-host, which is plugin-hostd's
|
|
16
|
+
* worker). A record whose verdict for that host was not measured has no judged layer: the reader then answers
|
|
17
|
+
* unqualified, with its reason.
|
|
18
|
+
*/
|
|
19
|
+
import { judgementRules, latencyMsOf, LATENCY_LINE_MS } from "./judgement-line.js";
|
|
20
|
+
import { pinVerdictOf, type PinVerdict } from "./pins-file.js";
|
|
21
|
+
import { QUALIFICATION_RECORD_SCHEMA, ISOLATED_TIER_HOST, type QualificationRecord } from "./qualification-record.js";
|
|
22
|
+
import type { SuitabilityRating } from "./rating.js";
|
|
23
|
+
|
|
24
|
+
/** The host a judged layer is written for: the one that runs plugins, in workers of its own (PLUGIN_DB_ISOLATION_HOSTS). */
|
|
25
|
+
export const JUDGED_HOST = "plugin-hostd";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The hosting epoch of each host a layer is written for. The epoch is the host owner's to move, when a release of it
|
|
29
|
+
* changes how it hosts; plugin-hostd has declared no hosting change since records were written, so its epoch is 0.
|
|
30
|
+
*/
|
|
31
|
+
export const HOSTING_EPOCH = { [JUDGED_HOST]: 0 } as const satisfies Readonly<Record<string, number>>;
|
|
32
|
+
|
|
33
|
+
/** The plugin-db envelope version this writes (plugin-db data/store.schema.json). */
|
|
34
|
+
export const PLUGIN_DB_ENVELOPE = 1;
|
|
35
|
+
|
|
36
|
+
/** The pin a layer is written for, as plugin-db's pinned layer holds it. */
|
|
37
|
+
export interface LayerPin {
|
|
38
|
+
/** The `omx-files/1` SHA-256 of the plugin's files. */
|
|
39
|
+
readonly files: string;
|
|
40
|
+
/** `<scheme>:<sha256>` of the parameter layout, or null when the pin holds none. */
|
|
41
|
+
readonly layout: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface JudgedCell {
|
|
45
|
+
readonly rate: number;
|
|
46
|
+
readonly quantum: number;
|
|
47
|
+
readonly latencyMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface JudgedBody {
|
|
51
|
+
readonly verdict: PinVerdict;
|
|
52
|
+
readonly machineClass: string;
|
|
53
|
+
readonly host: { readonly name: string; readonly version: string; readonly hostingEpoch: number };
|
|
54
|
+
readonly rules: string;
|
|
55
|
+
readonly recordSchema: string;
|
|
56
|
+
readonly cells: readonly JudgedCell[];
|
|
57
|
+
readonly conditions: Readonly<Record<string, string>>;
|
|
58
|
+
readonly latencyLineMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface JudgedLayer {
|
|
62
|
+
readonly envelope: typeof PLUGIN_DB_ENVELOPE;
|
|
63
|
+
readonly layer: "judged";
|
|
64
|
+
readonly address: string;
|
|
65
|
+
readonly pin: LayerPin;
|
|
66
|
+
readonly writer: "plugin-qualify";
|
|
67
|
+
readonly body: JudgedBody;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the converter is given beside the record: the class word, the pin, and the rules when the record states none. */
|
|
71
|
+
export interface JudgedLayerInput {
|
|
72
|
+
/** The machine class word the record was measured on (`rpi5`, `x86-64`): the set's host class. */
|
|
73
|
+
readonly machineClass: string;
|
|
74
|
+
readonly pin: LayerPin;
|
|
75
|
+
/** plugin-db's address; by default `<format>:<id>`. */
|
|
76
|
+
readonly address?: string;
|
|
77
|
+
/** The rules of a record that states none: the plugin-qualify version that judged its set (the set's provenance). */
|
|
78
|
+
readonly judgedBy?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Why a record has no judged layer, when it has none. */
|
|
82
|
+
export type NoLayer = { readonly layer: null; readonly reason: string };
|
|
83
|
+
|
|
84
|
+
/** The version of a tool named `<name> <version>` in the record's `tool`. */
|
|
85
|
+
function versionOf(record: QualificationRecord, name: string): string | undefined {
|
|
86
|
+
for (const v of Object.values(record.tool)) {
|
|
87
|
+
const m = new RegExp(`^${name.replace(/[-.]/g, "\\$&")} (\\S+)$`).exec(v);
|
|
88
|
+
if (m) return m[1];
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The cells of the record: each measured rate and quantum, with its latency in milliseconds when one was measured. */
|
|
94
|
+
export function judgedCells(record: QualificationRecord): JudgedCell[] {
|
|
95
|
+
return record.measurements.grid
|
|
96
|
+
.filter((p) => p.outcome === "probe.ok")
|
|
97
|
+
.map((p) => {
|
|
98
|
+
const frames = (p.latency as { measuredFrames?: number }).measuredFrames;
|
|
99
|
+
return { rate: p.rate, quantum: p.quantum, ...(frames === undefined ? {} : { latencyMs: latencyMsOf(frames, p.rate) }) };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The judged layer of a record, or why there is none. The verdict is the isolated tier host's, as the pins file gives
|
|
105
|
+
* it; the host is plugin-hostd at the probe runner's version, which is plugin-hostd's.
|
|
106
|
+
*/
|
|
107
|
+
export function judgedLayerOf(record: QualificationRecord, input: JudgedLayerInput): JudgedLayer | NoLayer {
|
|
108
|
+
if (record.schema !== QUALIFICATION_RECORD_SCHEMA) return { layer: null, reason: `record schema ${JSON.stringify(record.schema)} is not ${QUALIFICATION_RECORD_SCHEMA}` };
|
|
109
|
+
const tierHost = ISOLATED_TIER_HOST[record.plugin.format];
|
|
110
|
+
const v = record.verdicts.find((r) => r.host === tierHost);
|
|
111
|
+
if (v === undefined) return { layer: null, reason: `no verdict under ${tierHost ?? `a host for ${record.plugin.format}`}` };
|
|
112
|
+
const verdict = v.measured ? pinVerdictOf(v.rating as SuitabilityRating) : undefined;
|
|
113
|
+
if (verdict === undefined) return { layer: null, reason: `${tierHost}: not measured (${v.deciding ?? v.rating})` };
|
|
114
|
+
const hostVersion = versionOf(record, "plugin-hostd-probe");
|
|
115
|
+
if (hostVersion === undefined) return { layer: null, reason: "the record names no plugin-hostd-probe version" };
|
|
116
|
+
const rules = record.rules ?? (input.judgedBy === undefined ? undefined : judgementRules(input.judgedBy));
|
|
117
|
+
if (rules === undefined) return { layer: null, reason: "the record states no rules, and no judgedBy was given" };
|
|
118
|
+
return {
|
|
119
|
+
envelope: PLUGIN_DB_ENVELOPE,
|
|
120
|
+
layer: "judged",
|
|
121
|
+
address: input.address ?? `${record.plugin.format}:${record.plugin.id}`,
|
|
122
|
+
pin: input.pin,
|
|
123
|
+
writer: "plugin-qualify",
|
|
124
|
+
body: {
|
|
125
|
+
verdict,
|
|
126
|
+
machineClass: input.machineClass,
|
|
127
|
+
host: { name: JUDGED_HOST, version: hostVersion, hostingEpoch: HOSTING_EPOCH[JUDGED_HOST] },
|
|
128
|
+
rules,
|
|
129
|
+
recordSchema: record.schema,
|
|
130
|
+
cells: judgedCells(record),
|
|
131
|
+
conditions: {},
|
|
132
|
+
latencyLineMs: record.latencyLineMs ?? LATENCY_LINE_MS,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface JudgedLayerArgs {
|
|
138
|
+
readonly record: string;
|
|
139
|
+
readonly machineClass: string;
|
|
140
|
+
readonly files: string;
|
|
141
|
+
readonly layout: string | null;
|
|
142
|
+
readonly judgedBy?: string;
|
|
143
|
+
readonly address?: string;
|
|
144
|
+
/** Print the body only, as `plugin-db put <address> judged --writer plugin-qualify` reads it from stdin. */
|
|
145
|
+
readonly body: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function judgedLayerHelpText(): string {
|
|
149
|
+
return [
|
|
150
|
+
"usage: plugin-qualify judged-layer <record.qualification.json> --machine-class <word> --files <sha256>",
|
|
151
|
+
" [--layout <scheme>:<sha256>] [--judged-by <version>] [--address <address>] [--body]",
|
|
152
|
+
"",
|
|
153
|
+
"Print the plugin-db judged layer of a qualification record, for the pin plugin-db holds (its files digest and",
|
|
154
|
+
"layout). --judged-by names the plugin-qualify that judged a record stating no rules (the set's provenance). --body",
|
|
155
|
+
"prints the body only, for `plugin-db put <address> judged --writer plugin-qualify`. A record whose verdict under",
|
|
156
|
+
"plugin-hostd's worker was not measured has no layer: the reason is printed and the exit status is 1.",
|
|
157
|
+
"",
|
|
158
|
+
].join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type ParsedJudgedLayerArgs = { ok: true; args: JudgedLayerArgs } | { ok: true; help: true } | { ok: false; error: string };
|
|
162
|
+
|
|
163
|
+
export function parseJudgedLayerArgs(argv: readonly string[]): ParsedJudgedLayerArgs {
|
|
164
|
+
const opts: Record<string, string> = {};
|
|
165
|
+
const files: string[] = [];
|
|
166
|
+
let body = false;
|
|
167
|
+
for (let i = 0; i < argv.length; i++) {
|
|
168
|
+
const a = argv[i]!;
|
|
169
|
+
if (a === "--help" || a === "-h") return { ok: true, help: true };
|
|
170
|
+
if (a === "--body") body = true;
|
|
171
|
+
else if (["--machine-class", "--files", "--layout", "--judged-by", "--address"].includes(a)) {
|
|
172
|
+
const v = argv[++i];
|
|
173
|
+
if (v === undefined || v.startsWith("--")) return { ok: false, error: `${a} needs a value` };
|
|
174
|
+
opts[a] = v;
|
|
175
|
+
} else if (a.startsWith("--")) return { ok: false, error: `unknown option ${a}` };
|
|
176
|
+
else files.push(a);
|
|
177
|
+
}
|
|
178
|
+
if (files.length !== 1) return { ok: false, error: "name one record" };
|
|
179
|
+
const machineClass = opts["--machine-class"];
|
|
180
|
+
const pinFiles = opts["--files"];
|
|
181
|
+
if (machineClass === undefined) return { ok: false, error: "--machine-class is required" };
|
|
182
|
+
if (pinFiles === undefined || !/^[0-9a-f]{64}$/.test(pinFiles)) return { ok: false, error: "--files must be the 64 hex digits of the files pin" };
|
|
183
|
+
const layout = opts["--layout"] ?? null;
|
|
184
|
+
if (layout !== null && !/^[^:]+:[0-9a-f]{64}$/.test(layout)) return { ok: false, error: "--layout must be <scheme>:<sha256>" };
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
args: {
|
|
188
|
+
record: files[0]!,
|
|
189
|
+
machineClass,
|
|
190
|
+
files: pinFiles,
|
|
191
|
+
layout,
|
|
192
|
+
body,
|
|
193
|
+
...(opts["--judged-by"] === undefined ? {} : { judgedBy: opts["--judged-by"] }),
|
|
194
|
+
...(opts["--address"] === undefined ? {} : { address: opts["--address"] }),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* What plugin-qualify's judgement declares once and writes into every record (and so into every judged layer): the
|
|
5
|
+
* latency line and the rules a record was judged by (openmixer plugin-system design 7.15, plugin-db records.md).
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
/** The latency line, in milliseconds: declared once, here, and written into every record and judged layer. */
|
|
10
|
+
export const LATENCY_LINE_MS = 5;
|
|
11
|
+
|
|
12
|
+
/** A measured latency in milliseconds at its rate, rounded to the microsecond. */
|
|
13
|
+
export const latencyMsOf = (frames: number, rate: number): number => Math.round((frames * 1e9) / rate) / 1e6;
|
|
14
|
+
|
|
15
|
+
const VERSION: string = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
16
|
+
|
|
17
|
+
/** The rules a plugin-qualify of this version judges by: `plugin-qualify-rules/<major>.<minor>`. */
|
|
18
|
+
export function judgementRules(version: string = VERSION): string {
|
|
19
|
+
const m = /^(\d+)\.(\d+)\.\d+/.exec(version);
|
|
20
|
+
if (m === null) throw new Error(`judgementRules: ${JSON.stringify(version)} is no version`);
|
|
21
|
+
return `plugin-qualify-rules/${m[1]}.${m[2]}`;
|
|
22
|
+
}
|