@openmixer/plugin-qualify 0.18.4 → 0.19.1

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 (102) hide show
  1. package/README.md +19 -730
  2. package/bin/plugin-qualify-sync.mjs +4 -2
  3. package/bin/plugin-qualify.mjs +7 -0
  4. package/dist/clap-qualify.d.ts +1 -1
  5. package/dist/compare.d.ts +311 -0
  6. package/dist/compare.js +502 -0
  7. package/dist/compare.js.map +1 -0
  8. package/dist/distributed-records.d.ts +0 -2
  9. package/dist/distributed-records.js +0 -2
  10. package/dist/distributed-records.js.map +1 -1
  11. package/dist/host-admission.d.ts +2 -2
  12. package/dist/host-admission.js +7 -0
  13. package/dist/host-admission.js.map +1 -1
  14. package/dist/hosting-codes.generated.d.ts +1 -0
  15. package/dist/hosting-codes.generated.js +1 -0
  16. package/dist/hosting-codes.generated.js.map +1 -1
  17. package/dist/hosting-suitability.d.ts +3 -1
  18. package/dist/hosting-suitability.js +9 -0
  19. package/dist/hosting-suitability.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/jack-registry.d.ts +6 -0
  24. package/dist/jack-registry.js +52 -0
  25. package/dist/jack-registry.js.map +1 -0
  26. package/dist/lv2-loader.js +6 -5
  27. package/dist/lv2-loader.js.map +1 -1
  28. package/dist/measurement/format.d.ts +15 -0
  29. package/dist/measurement/format.js +19 -0
  30. package/dist/measurement/format.js.map +1 -1
  31. package/dist/measurement/sweep-jsonl.js +5 -1
  32. package/dist/measurement/sweep-jsonl.js.map +1 -1
  33. package/dist/measurement-grid.js +1 -1
  34. package/dist/pins-file.d.ts +22 -2
  35. package/dist/pins-file.js +118 -17
  36. package/dist/pins-file.js.map +1 -1
  37. package/dist/probe-judge.d.ts +5 -0
  38. package/dist/probe-judge.js.map +1 -1
  39. package/dist/probe-runner.d.ts +8 -0
  40. package/dist/probe-runner.js +8 -4
  41. package/dist/probe-runner.js.map +1 -1
  42. package/dist/qualification-record.d.ts +45 -0
  43. package/dist/qualification-record.js +36 -0
  44. package/dist/qualification-record.js.map +1 -1
  45. package/dist/qualify-adapter-pass.js +20 -5
  46. package/dist/qualify-adapter-pass.js.map +1 -1
  47. package/dist/qualify-declarations.d.ts +6 -0
  48. package/dist/qualify-declarations.js +8 -0
  49. package/dist/qualify-declarations.js.map +1 -1
  50. package/dist/qualify-isolated.d.ts +16 -3
  51. package/dist/qualify-isolated.js +31 -3
  52. package/dist/qualify-isolated.js.map +1 -1
  53. package/dist/qualify-pool.d.ts +129 -0
  54. package/dist/qualify-pool.js +290 -0
  55. package/dist/qualify-pool.js.map +1 -0
  56. package/dist/qualify-rejudge.js +7 -0
  57. package/dist/qualify-rejudge.js.map +1 -1
  58. package/dist/qualify-sync.d.ts +46 -1
  59. package/dist/qualify-sync.js +184 -20
  60. package/dist/qualify-sync.js.map +1 -1
  61. package/dist/records-package.d.ts +5 -0
  62. package/dist/records-package.js +25 -8
  63. package/dist/records-package.js.map +1 -1
  64. package/fixtures/clap-scan/fake.json +8 -2
  65. package/fixtures/probe-output/success-msi.json +2 -2
  66. package/package.json +3 -3
  67. package/src/clap-qualify.ts +1 -1
  68. package/src/compare.ts +699 -0
  69. package/src/distributed-records.ts +0 -3
  70. package/src/host-admission.ts +9 -2
  71. package/src/hosting-codes.generated.ts +1 -0
  72. package/src/hosting-suitability.ts +14 -0
  73. package/src/index.ts +1 -0
  74. package/src/jack-registry.ts +49 -0
  75. package/src/lv2-loader.ts +5 -4
  76. package/src/measurement/format.ts +32 -0
  77. package/src/measurement/sweep-jsonl.ts +5 -1
  78. package/src/measurement-grid.ts +1 -1
  79. package/src/pins-file.ts +109 -16
  80. package/src/probe-judge.ts +2 -0
  81. package/src/probe-runner.ts +18 -4
  82. package/src/qualification-record.ts +69 -0
  83. package/src/qualify-adapter-pass.ts +18 -5
  84. package/src/qualify-declarations.ts +8 -0
  85. package/src/qualify-isolated.ts +31 -5
  86. package/src/qualify-pool.ts +318 -0
  87. package/src/qualify-rejudge.ts +8 -0
  88. package/src/qualify-sync.ts +187 -19
  89. package/src/records-package.ts +25 -8
  90. package/tools/benchmark.py +9 -3
  91. package/tools/compare-host.mjs +432 -0
  92. package/tools/compare-run.mjs +355 -0
  93. package/tools/descriptors.py +27 -0
  94. package/tools/isolated-step.mjs +3 -1
  95. package/tools/lilv_load.py +25 -0
  96. package/tools/lv2_resolve.py +3 -1
  97. package/tools/pool-step.mjs +186 -0
  98. package/tools/scan.py +16 -20
  99. package/tools/audit-overlap.sh +0 -250
  100. package/tools/commit-message-check.sh +0 -128
  101. package/tools/commit-message-patterns.json +0 -41
  102. package/tools/private-refs-check.sh +0 -90
package/src/compare.ts ADDED
@@ -0,0 +1,699 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * `plugin-qualify compare`: every plugin through every host with the same checks, one row per plugin per host.
5
+ *
6
+ * The hosts are mod-host and jalv, the two LV2 hosts a Zynthian or a MOD device runs, and ours: omx-clap-host (one
7
+ * process, the LV2 adapter for LV2, CLAP as itself) and plugin-hostd (a worker per plugin). Every host runs on one
8
+ * private JACK server at one rate and quantum, with the same timeouts, the same stimulus (a tone into an effect, notes
9
+ * into an instrument), the same number of load cycles, the same soak and the same real-time watch.
10
+ *
11
+ * This file holds every decision and no process: the arguments, which host a plugin goes to, how one host's raw
12
+ * observation (`tools/compare-host.mjs` prints it) becomes the row's checks, the comparison record of a plugin, and the
13
+ * summary of a directory of them. A check a host does not let us observe is `not-observable`, never `fail`; a host that
14
+ * is not installed is `skipped` with the reason, never left out.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+
18
+ export const COMPARISON_RECORD_SCHEMA = "plugin-qualify.comparison-record/1";
19
+ export const COMPARISON_SUMMARY_SCHEMA = "plugin-qualify.comparison-summary/1";
20
+ export const COMPARISON_RECORD_SUFFIX = ".comparison.json";
21
+ export const COMPARISON_SUMMARY_FILE = "comparison-summary.json";
22
+
23
+ /** The hosts, in the order their rows are written. */
24
+ export const COMPARE_HOSTS = ["omx-clap-host", "plugin-hostd", "mod-host", "jalv"] as const;
25
+ export type CompareHost = (typeof COMPARE_HOSTS)[number];
26
+ /** The references a published comparison is measured against: without both, a summary is not publishable. */
27
+ export const REFERENCE_HOSTS: readonly CompareHost[] = ["mod-host", "jalv"];
28
+
29
+ /** How each host is driven, and what of it can be observed. The page and the summary print these, so they are data. */
30
+ export interface HostTraits {
31
+ readonly program: string;
32
+ /** `protocol`: the mod-host socket protocol (`add`, `remove`); `process`: one process per plugin (jalv). */
33
+ readonly drive: "protocol" | "process";
34
+ readonly formats: readonly ("lv2" | "clap")[];
35
+ /** Why the plugin latency it reports is or is not observable. */
36
+ readonly latency: { readonly observable: boolean; readonly how: string };
37
+ readonly rt: string;
38
+ }
39
+
40
+ export const HOST_TRAITS: Readonly<Record<CompareHost, HostTraits>> = {
41
+ "omx-clap-host": {
42
+ program: "omx-clap-host",
43
+ drive: "protocol",
44
+ formats: ["lv2", "clap"],
45
+ latency: { observable: true, how: "the host sets a JACK latency callback: the plugin's latency is on its output port's capture range" },
46
+ rt: "its JACK process callback, counted by rt_watch.so",
47
+ },
48
+ "plugin-hostd": {
49
+ program: "plugin-hostd",
50
+ drive: "protocol",
51
+ formats: ["lv2", "clap"],
52
+ latency: { observable: true, how: "its worker (omx-clap-host) sets a JACK latency callback" },
53
+ rt: "the JACK process callback of every process it starts, counted by rt_watch.so",
54
+ },
55
+ "mod-host": {
56
+ program: "mod-host",
57
+ drive: "protocol",
58
+ formats: ["lv2"],
59
+ latency: { observable: false, how: "mod-host sets no JACK latency callback, so a plugin's latency is not on its ports" },
60
+ rt: "its JACK process callback, counted by rt_watch.so",
61
+ },
62
+ jalv: {
63
+ program: "jalv",
64
+ drive: "process",
65
+ formats: ["lv2"],
66
+ latency: { observable: true, how: "jalv sets a JACK latency callback: the plugin's latency is on its output port's capture range" },
67
+ rt: "its JACK process callback, counted by rt_watch.so",
68
+ },
69
+ };
70
+
71
+ export const isCompareHost = (s: string): s is CompareHost => (COMPARE_HOSTS as readonly string[]).includes(s);
72
+
73
+ // ---------------------------------------------------------------------------------------------------- arguments
74
+
75
+ export interface CompareArgs {
76
+ readonly help: boolean;
77
+ readonly all: boolean;
78
+ readonly targets: readonly string[];
79
+ readonly hosts: readonly CompareHost[];
80
+ readonly rate: number;
81
+ readonly quantum: number;
82
+ readonly cycles: number;
83
+ readonly soakS: number;
84
+ readonly windowMs: number;
85
+ readonly timeoutMs: number;
86
+ readonly out: string | undefined;
87
+ readonly hostClass: string | undefined;
88
+ readonly set: string | undefined;
89
+ readonly publish: boolean;
90
+ readonly server: string | undefined;
91
+ readonly limit: number;
92
+ readonly force: boolean;
93
+ readonly format: "text" | "json";
94
+ }
95
+
96
+ export const COMPARE_DEFAULTS = {
97
+ rate: 48000,
98
+ quantum: 256,
99
+ cycles: 5,
100
+ soakS: 10,
101
+ windowMs: 2000,
102
+ timeoutMs: 10000,
103
+ } as const;
104
+
105
+ export type ParseCompare = { ok: true; args: CompareArgs } | { ok: false; error: string };
106
+
107
+ const positiveInt = (v: string | undefined): number | undefined => (v !== undefined && /^[1-9]\d*$/.test(v) ? Number(v) : undefined);
108
+
109
+ export function parseCompareArgs(argv: readonly string[]): ParseCompare {
110
+ let help = false;
111
+ let all = false;
112
+ let publish = false;
113
+ let force = false;
114
+ let format: "text" | "json" = "text";
115
+ const targets: string[] = [];
116
+ let hosts: CompareHost[] = [...COMPARE_HOSTS];
117
+ const n: { -readonly [K in keyof typeof COMPARE_DEFAULTS]: number } = { ...COMPARE_DEFAULTS };
118
+ let out: string | undefined;
119
+ let hostClass: string | undefined;
120
+ let set: string | undefined;
121
+ let server: string | undefined;
122
+ let limit = 0;
123
+ const numeric: Record<string, keyof typeof COMPARE_DEFAULTS> = {
124
+ "--rate": "rate",
125
+ "--quantum": "quantum",
126
+ "--cycles": "cycles",
127
+ "--soak": "soakS",
128
+ "--window-ms": "windowMs",
129
+ "--timeout-ms": "timeoutMs",
130
+ };
131
+ for (let i = 0; i < argv.length; i++) {
132
+ const a = argv[i] ?? "";
133
+ const key = numeric[a];
134
+ const value = (): string | undefined => argv[++i];
135
+ if (a === "-h" || a === "--help") help = true;
136
+ else if (a === "--all") all = true;
137
+ else if (a === "--publish") publish = true;
138
+ else if (a === "--force") force = true;
139
+ else if (a === "--hosts") {
140
+ const v = value();
141
+ const list = (v ?? "").split(",").filter(Boolean);
142
+ const bad = list.filter((h) => !isCompareHost(h));
143
+ if (list.length === 0 || bad.length > 0) return { ok: false, error: `--hosts takes a list of ${COMPARE_HOSTS.join(", ")}${bad.length ? `; not a host: ${bad.join(", ")}` : ""}` };
144
+ hosts = COMPARE_HOSTS.filter((h) => list.includes(h));
145
+ } else if (key !== undefined) {
146
+ const v = positiveInt(value());
147
+ if (v === undefined) return { ok: false, error: `${a} takes a whole number above 0` };
148
+ n[key] = v;
149
+ } else if (a === "--limit") {
150
+ const v = positiveInt(value());
151
+ if (v === undefined) return { ok: false, error: "--limit takes a whole number above 0" };
152
+ limit = v;
153
+ } else if (a === "--out") out = value();
154
+ else if (a === "--host-class") hostClass = value();
155
+ else if (a === "--set") set = value();
156
+ else if (a === "--server") server = value();
157
+ else if (a === "--format") {
158
+ const v = value();
159
+ if (v !== "text" && v !== "json") return { ok: false, error: "--format is text or json" };
160
+ format = v;
161
+ } else if (a.startsWith("-")) return { ok: false, error: `unknown option ${a}` };
162
+ else targets.push(a);
163
+ }
164
+ for (const [k, v] of [["--out", out], ["--host-class", hostClass], ["--set", set], ["--server", server]] as const) {
165
+ if (argv.includes(k) && (v === undefined || v.startsWith("-"))) return { ok: false, error: `${k} needs a value` };
166
+ }
167
+ if (!help && !all && targets.length === 0) return { ok: false, error: "name the plugins (LV2 URIs, bundles or .clap files) or --all" };
168
+ if (all && targets.length > 0) return { ok: false, error: "--all and named plugins do not go together" };
169
+ if (publish && (out === undefined || hostClass === undefined || set === undefined)) {
170
+ return { ok: false, error: "--publish writes a summary for the records: it needs --out, --host-class and --set" };
171
+ }
172
+ if (publish && !REFERENCE_HOSTS.every((h) => hosts.includes(h))) {
173
+ return { ok: false, error: `--publish compares against ${REFERENCE_HOSTS.join(" and ")}: --hosts must name them` };
174
+ }
175
+ return {
176
+ ok: true,
177
+ args: {
178
+ help,
179
+ all,
180
+ targets,
181
+ hosts,
182
+ rate: n.rate,
183
+ quantum: n.quantum,
184
+ cycles: n.cycles,
185
+ soakS: n.soakS,
186
+ windowMs: n.windowMs,
187
+ timeoutMs: n.timeoutMs,
188
+ out,
189
+ hostClass,
190
+ set,
191
+ publish,
192
+ server,
193
+ limit,
194
+ force,
195
+ format,
196
+ },
197
+ };
198
+ }
199
+
200
+ export function compareHelpText(): string {
201
+ return [
202
+ "usage: plugin-qualify compare [options] (--all | <LV2 URI | bundle | .clap> ...)",
203
+ "",
204
+ "Runs every plugin through each host with the same checks, one row per plugin per host: it loads, it",
205
+ "instantiates, it gives output (an effect a tone, an instrument notes), it survives the load cycles and the soak,",
206
+ "its audio thread allocates, locks or makes a system call or not (rt_watch.so around the host's JACK process",
207
+ "callback), its CPU and the latency the host reports. A check a host does not let us see is `not-observable`.",
208
+ "",
209
+ ` --hosts <list> of ${COMPARE_HOSTS.join(",")} (default all); one not installed is skipped, with the reason`,
210
+ ` --rate <hz> of the private JACK server (default ${String(COMPARE_DEFAULTS.rate)})`,
211
+ ` --quantum <frames> (default ${String(COMPARE_DEFAULTS.quantum)})`,
212
+ ` --cycles <n> load and unload cycles before the run (default ${String(COMPARE_DEFAULTS.cycles)})`,
213
+ ` --soak <s> seconds the plugin runs under its stimulus (default ${String(COMPARE_DEFAULTS.soakS)})`,
214
+ ` --window-ms <ms> the output window, at the start and at the end of the soak (default ${String(COMPARE_DEFAULTS.windowMs)})`,
215
+ ` --timeout-ms <ms> the budget of one host step: a reply, a client on the graph (default ${String(COMPARE_DEFAULTS.timeoutMs)})`,
216
+ " --server <name> use this running JACK server instead of starting a private one (dummy driver)",
217
+ " --limit <n> the first n plugins only",
218
+ " --force compare again a plugin whose record in --out was made under the same conditions",
219
+ ` --out <dir> write one ${COMPARISON_RECORD_SUFFIX} per plugin and ${COMPARISON_SUMMARY_FILE} there`,
220
+ " --host-class <c> the machine class the summary is for (rpi5, x86-64)",
221
+ " --set <name> the plugin set the summary is for (zynthian-vangelis, fedora44)",
222
+ ` --publish a summary for the records: refused unless ${REFERENCE_HOSTS.join(" and ")} are installed and ran`,
223
+ " --format text|json",
224
+ "",
225
+ "exit 0 the comparison ran / 1 a --publish run lacked a reference host / 2 nothing could be compared",
226
+ "",
227
+ ].join("\n");
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------------------------------- the hosts here
231
+
232
+ export interface HostPresence {
233
+ readonly host: CompareHost;
234
+ readonly installed: boolean;
235
+ readonly version: string | null;
236
+ /** Why it is skipped, when it is. */
237
+ readonly reason?: string;
238
+ }
239
+
240
+ /** The reference hosts a publishable summary lacks, by name, with the reason each is missing. */
241
+ export function missingReferences(present: readonly HostPresence[]): readonly { host: CompareHost; reason: string }[] {
242
+ return REFERENCE_HOSTS.flatMap((h) => {
243
+ const p = present.find((x) => x.host === h);
244
+ if (p === undefined) return [{ host: h, reason: "not asked for with --hosts" }];
245
+ if (!p.installed) return [{ host: h, reason: p.reason ?? `${HOST_TRAITS[h].program} is not installed` }];
246
+ return [];
247
+ });
248
+ }
249
+
250
+ /** What a directory of records says of each host: it ran where a row of it ran, else the reason its rows give. */
251
+ export function presenceFromRecords(records: readonly ComparisonRecord[]): HostPresence[] {
252
+ return COMPARE_HOSTS.flatMap((h): HostPresence[] => {
253
+ const rows = records.flatMap((r) => r.hosts.filter((x) => x.host === h));
254
+ if (rows.length === 0) return [];
255
+ const ran = rows.find((x) => x.status === "ran");
256
+ if (ran !== undefined) return [{ host: h, installed: true, version: ran.version }];
257
+ if (rows.every((x) => x.status === "not-applicable")) return [{ host: h, installed: true, version: rows[0]?.version ?? null }];
258
+ const skipped = rows.find((x) => x.status === "skipped");
259
+ return [{ host: h, installed: false, version: null, reason: skipped?.reason ?? `${HOST_TRAITS[h].program} did not run` }];
260
+ });
261
+ }
262
+
263
+ /** The arguments of `plugin-qualify compare summary <dir>`: the summary of the records already in a directory. */
264
+ export type ParseSummary = { ok: true; args: { dir: string; hostClass?: string; set?: string; publish: boolean; help: boolean } } | { ok: false; error: string };
265
+
266
+ export function parseSummaryArgs(argv: readonly string[]): ParseSummary {
267
+ let dir: string | undefined;
268
+ let hostClass: string | undefined;
269
+ let set: string | undefined;
270
+ let publish = false;
271
+ let help = false;
272
+ for (let i = 0; i < argv.length; i++) {
273
+ const a = argv[i] ?? "";
274
+ if (a === "-h" || a === "--help") help = true;
275
+ else if (a === "--publish") publish = true;
276
+ else if (a === "--host-class") hostClass = argv[++i];
277
+ else if (a === "--set") set = argv[++i];
278
+ else if (a.startsWith("-")) return { ok: false, error: `unknown option ${a}` };
279
+ else if (dir === undefined) dir = a;
280
+ else return { ok: false, error: "one directory" };
281
+ }
282
+ if (help) return { ok: true, args: { dir: dir ?? ".", publish, help } };
283
+ if (dir === undefined) return { ok: false, error: "name the directory of the comparison records" };
284
+ if (publish && (hostClass === undefined || set === undefined)) return { ok: false, error: "--publish needs --host-class and --set" };
285
+ return { ok: true, args: { dir, publish, help, ...(hostClass === undefined ? {} : { hostClass }), ...(set === undefined ? {} : { set }) } };
286
+ }
287
+
288
+ export function summaryHelpText(): string {
289
+ return [
290
+ "usage: plugin-qualify compare summary <dir> [--host-class <c> --set <s> --publish]",
291
+ "",
292
+ `Reads every ${COMPARISON_RECORD_SUFFIX} in <dir> and writes ${COMPARISON_SUMMARY_FILE} beside them: per host the plugins`,
293
+ "it loads, runs clean, runs real-time safe and crashes on, and the plugins where each host does better than the others.",
294
+ `--publish marks it for the records, and is refused unless ${REFERENCE_HOSTS.join(" and ")} ran.`,
295
+ "",
296
+ ].join("\n");
297
+ }
298
+
299
+ /** Two runs are the same comparison when every condition but the machine's JACK version and real-time grant agree. */
300
+ export function sameConditions(a: ComparisonConditions, b: ComparisonConditions): boolean {
301
+ return a.rate === b.rate && a.quantum === b.quantum && a.cycles === b.cycles && a.soakS === b.soakS && a.windowMs === b.windowMs && a.timeoutMs === b.timeoutMs;
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------------------------------- one host's row
305
+
306
+ export type CheckResult = "pass" | "fail" | "not-observable" | "not-run";
307
+ export type FigureResult = "measured" | "not-observable" | "not-run";
308
+
309
+ export interface Check {
310
+ readonly result: CheckResult;
311
+ readonly detail?: string;
312
+ }
313
+ export interface OutputCheck extends Check {
314
+ readonly peak?: number;
315
+ readonly stimulus?: Stimulus;
316
+ }
317
+ export interface CyclesCheck extends Check {
318
+ readonly done: number;
319
+ readonly of: number;
320
+ }
321
+ export interface SoakCheck extends Check {
322
+ readonly seconds: number;
323
+ readonly peakAtEnd?: number;
324
+ }
325
+ export interface RtCheck extends Check {
326
+ readonly allocations?: number;
327
+ readonly locks?: number;
328
+ /** Try-locks: they never wait, so they are reported and do not fail the check. */
329
+ readonly trylocks?: number;
330
+ readonly syscalls?: number;
331
+ readonly cycles?: number;
332
+ readonly kinds?: readonly string[];
333
+ }
334
+ export interface CpuFigure {
335
+ readonly result: FigureResult;
336
+ /** utime + stime of the host's processes over the soak, in percent of one core. */
337
+ readonly percent?: number;
338
+ readonly detail?: string;
339
+ }
340
+ export interface LatencyFigure {
341
+ readonly result: FigureResult;
342
+ readonly frames?: number;
343
+ readonly detail?: string;
344
+ }
345
+
346
+ export interface HostChecks {
347
+ readonly loads: Check;
348
+ readonly instantiates: Check;
349
+ readonly output: OutputCheck;
350
+ readonly cycles: CyclesCheck;
351
+ readonly soak: SoakCheck;
352
+ readonly rtSafe: RtCheck;
353
+ readonly cpu: CpuFigure;
354
+ readonly latency: LatencyFigure;
355
+ }
356
+
357
+ export interface HostRow {
358
+ readonly host: CompareHost;
359
+ readonly version: string | null;
360
+ readonly status: "ran" | "skipped" | "not-applicable";
361
+ readonly reason?: string;
362
+ readonly checks?: HostChecks;
363
+ /** The step the host died at and how, when it did. */
364
+ readonly crash?: { readonly step: string; readonly signal: string };
365
+ /** Loaded, instantiated, survived the cycles and the soak, never crashed, and its output was not found silent. */
366
+ readonly runsClean?: boolean;
367
+ }
368
+
369
+ export type Stimulus = "tone" | "notes" | "none";
370
+
371
+ /** What tools/compare-host.mjs prints for one plugin under one host: observations, no judgment. */
372
+ export interface HostObservation {
373
+ readonly installed: boolean;
374
+ readonly started?: boolean;
375
+ /** Each add or start: ok, the reply code, or how the host ended. */
376
+ readonly load?: { readonly ok: boolean; readonly resp?: number; readonly died?: string; readonly detail?: string };
377
+ /** The plugin's ports appeared on the graph. */
378
+ readonly ports?: { readonly audioIn: number; readonly audioOut: number; readonly midiIn: number };
379
+ readonly stimulus?: Stimulus;
380
+ readonly peak?: number | null;
381
+ /** NaN and infinite samples read on the outputs, over both windows. */
382
+ readonly nonFinite?: number;
383
+ readonly peakProblem?: string;
384
+ readonly cycles?: { readonly done: number; readonly of: number; readonly died?: string; readonly failedAt?: string };
385
+ readonly soak?: { readonly seconds: number; readonly alive: boolean; readonly died?: string; readonly peakAtEnd?: number | null };
386
+ readonly rt?: readonly RtReport[];
387
+ readonly cpu?: { readonly percent: number } | { readonly problem: string };
388
+ readonly latencyFrames?: number | null;
389
+ readonly died?: { readonly step: string; readonly signal: string };
390
+ readonly problem?: string;
391
+ }
392
+
393
+ /** One process's rt_watch.so report. */
394
+ export interface RtReport {
395
+ readonly pid: number;
396
+ readonly callbacks: number;
397
+ readonly cycles: number;
398
+ readonly armedCycles: number;
399
+ readonly allocations: number;
400
+ readonly locks: number;
401
+ readonly trylocks?: number;
402
+ readonly syscalls: number;
403
+ readonly kinds?: readonly string[];
404
+ }
405
+
406
+ /** A peak at or under this is silence (-100 dBFS). */
407
+ export const SILENCE = 1e-5;
408
+
409
+ const audible = (p: number | null | undefined): boolean => typeof p === "number" && Number.isFinite(p) && p > SILENCE;
410
+
411
+ /** The real-time verdict from the reports of the host's processes. */
412
+ export function rtCheckOf(reports: readonly RtReport[] | undefined): RtCheck {
413
+ if (reports === undefined || reports.length === 0) return { result: "not-observable", detail: "no process of the host reported (rt_watch.so was not loaded, or the host was gone)" };
414
+ const watched = reports.filter((r) => r.callbacks > 0);
415
+ const armed = watched.reduce((s, r) => s + r.armedCycles, 0);
416
+ if (watched.length === 0) return { result: "not-observable", detail: "no process of the host set a JACK process callback that rt_watch.so could wrap" };
417
+ if (armed === 0) return { result: "not-observable", detail: "the process callback never ran past its warm-up" };
418
+ const allocations = watched.reduce((s, r) => s + r.allocations, 0);
419
+ const locks = watched.reduce((s, r) => s + r.locks, 0);
420
+ const syscalls = watched.reduce((s, r) => s + r.syscalls, 0);
421
+ const trylocks = watched.reduce((s, r) => s + (r.trylocks ?? 0), 0);
422
+ const kinds = [...new Set(watched.flatMap((r) => r.kinds ?? []))];
423
+ const clean = allocations === 0 && locks === 0 && syscalls === 0;
424
+ return {
425
+ result: clean ? "pass" : "fail",
426
+ allocations,
427
+ locks,
428
+ trylocks,
429
+ syscalls,
430
+ cycles: armed,
431
+ ...(kinds.length ? { kinds } : {}),
432
+ ...(clean ? (trylocks > 0 ? { detail: `${String(trylocks)} try-locks on the audio thread over ${String(armed)} cycles; a try-lock never waits` } : {}) : { detail: `on the audio thread over ${String(armed)} cycles: ${String(allocations)} allocations, ${String(locks)} locks, ${String(syscalls)} system calls` }),
433
+ };
434
+ }
435
+
436
+ const NOT_RUN = (why: string): Check => ({ result: "not-run", detail: why });
437
+
438
+ /** One host's observation of one plugin as its row. */
439
+ export function hostRowOf(host: CompareHost, version: string | null, obs: HostObservation, conditions: { cycles: number; soakS: number }): HostRow {
440
+ const base = { host, version };
441
+ if (!obs.installed) return { ...base, status: "skipped", reason: obs.problem ?? `${HOST_TRAITS[host].program} is not installed` };
442
+ if (obs.started === false) return { ...base, status: "skipped", reason: obs.problem ?? `${HOST_TRAITS[host].program} did not start` };
443
+ const load = obs.load;
444
+ const loaded = load?.ok === true;
445
+ const loads: Check = loaded
446
+ ? { result: "pass" }
447
+ : { result: "fail", detail: load?.died ? `the host ended (${load.died})` : load?.resp !== undefined ? `resp ${String(load.resp)}` : load?.detail ?? "no answer in time" };
448
+ const ports = obs.ports;
449
+ const anyPort = ports !== undefined && ports.audioIn + ports.audioOut + ports.midiIn > 0;
450
+ const instantiates: Check = !loaded ? NOT_RUN("it did not load") : anyPort ? { result: "pass" } : { result: "fail", detail: "no port of the plugin appeared on the graph" };
451
+ const running = loaded && anyPort;
452
+
453
+ let output: OutputCheck;
454
+ if (!running) output = { ...NOT_RUN("it is not running") };
455
+ else if (ports.audioOut === 0) output = { result: "not-observable", detail: "the plugin has no audio output" };
456
+ else if (obs.peakProblem !== undefined) output = { result: "not-observable", detail: obs.peakProblem };
457
+ else if ((obs.nonFinite ?? 0) > 0) output = { result: "fail", detail: `the output is not finite (${String(obs.nonFinite)} NaN or infinite samples)`, peak: obs.peak ?? 0, stimulus: obs.stimulus ?? "none" };
458
+ else if (audible(obs.peak)) output = { result: "pass", peak: obs.peak as number, stimulus: obs.stimulus ?? "none" };
459
+ else if (obs.stimulus === "none" || obs.stimulus === undefined) output = { result: "not-observable", detail: "no stimulus this plugin takes (no audio or MIDI input), and its output was silent", peak: obs.peak ?? 0, stimulus: "none" };
460
+ else output = { result: "fail", detail: `silent under ${obs.stimulus}`, peak: obs.peak ?? 0, stimulus: obs.stimulus };
461
+
462
+ const c = obs.cycles;
463
+ const cycles: CyclesCheck =
464
+ c === undefined
465
+ ? { result: "not-run", done: 0, of: conditions.cycles, detail: "the cycles did not run" }
466
+ : c.done === c.of
467
+ ? { result: "pass", done: c.done, of: c.of }
468
+ : { result: "fail", done: c.done, of: c.of, detail: c.died ? `the host ended at cycle ${String(c.done + 1)} (${c.died})` : `cycle ${String(c.done + 1)} failed${c.failedAt ? ` at ${c.failedAt}` : ""}` };
469
+
470
+ const s = obs.soak;
471
+ const soak: SoakCheck = !running || s === undefined
472
+ ? { result: "not-run", seconds: 0, detail: "it is not running" }
473
+ : s.alive
474
+ ? { result: "pass", seconds: s.seconds, ...(typeof s.peakAtEnd === "number" ? { peakAtEnd: s.peakAtEnd } : {}) }
475
+ : { result: "fail", seconds: s.seconds, detail: `the host ended during the soak${s.died ? ` (${s.died})` : ""}` };
476
+
477
+ const rtSafe: RtCheck = !running ? { result: "not-run", detail: "it is not running" } : rtCheckOf(obs.rt);
478
+ const cpu: CpuFigure = !running || obs.cpu === undefined
479
+ ? { result: "not-run", detail: "it is not running" }
480
+ : "percent" in obs.cpu
481
+ ? { result: "measured", percent: Math.round(obs.cpu.percent * 100) / 100 }
482
+ : { result: "not-observable", detail: obs.cpu.problem };
483
+ const traits = HOST_TRAITS[host];
484
+ const latency: LatencyFigure = !running
485
+ ? { result: "not-run", detail: "it is not running" }
486
+ : !traits.latency.observable
487
+ ? { result: "not-observable", detail: traits.latency.how }
488
+ : typeof obs.latencyFrames === "number"
489
+ ? { result: "measured", frames: obs.latencyFrames }
490
+ : { result: "not-observable", detail: "the output port's latency could not be read" };
491
+
492
+ const crashed = obs.died !== undefined;
493
+ const runsClean = !crashed && loaded && running && cycles.result === "pass" && soak.result === "pass" && output.result !== "fail";
494
+ return {
495
+ ...base,
496
+ status: "ran",
497
+ checks: { loads, instantiates, output, cycles, soak, rtSafe, cpu, latency },
498
+ ...(crashed ? { crash: obs.died } : {}),
499
+ runsClean,
500
+ };
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------------------------------- the record
504
+
505
+ export interface ComparisonConditions {
506
+ readonly rate: number;
507
+ readonly quantum: number;
508
+ readonly cycles: number;
509
+ readonly soakS: number;
510
+ readonly windowMs: number;
511
+ readonly timeoutMs: number;
512
+ readonly jack: string;
513
+ readonly realtime: string;
514
+ }
515
+
516
+ export interface ComparisonRecord {
517
+ readonly schema: typeof COMPARISON_RECORD_SCHEMA;
518
+ readonly plugin: { readonly format: string; readonly id: string; readonly binary?: string };
519
+ readonly stimulus: Stimulus;
520
+ readonly conditions: ComparisonConditions;
521
+ readonly measuredAt: string;
522
+ readonly hosts: readonly HostRow[];
523
+ }
524
+
525
+ /** The record's file name: the plugin's id made safe for a file, with a hash of the whole id so two ids never meet. */
526
+ export function comparisonFileName(format: string, id: string): string {
527
+ const slug = id.replace(/^[a-z]+:\/\//i, "").replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^[._]+|_+$/g, "").slice(0, 80);
528
+ const hash = createHash("sha256").update(`${format}\0${id}`).digest("hex").slice(0, 12);
529
+ return `${slug}-${format}-${hash}${COMPARISON_RECORD_SUFFIX}`;
530
+ }
531
+
532
+ export function comparisonRecordOf(
533
+ plugin: { format: string; id: string; binary?: string },
534
+ stimulus: Stimulus,
535
+ conditions: ComparisonConditions,
536
+ measuredAt: string,
537
+ rows: readonly HostRow[],
538
+ ): ComparisonRecord {
539
+ const ordered = COMPARE_HOSTS.flatMap((h) => rows.filter((r) => r.host === h));
540
+ return { schema: COMPARISON_RECORD_SCHEMA, plugin, stimulus, conditions, measuredAt, hosts: ordered };
541
+ }
542
+
543
+ /** The row of a host that does not take this plugin's format. */
544
+ export function notApplicableRow(host: CompareHost, version: string | null, format: string): HostRow {
545
+ return { host, version, status: "not-applicable", reason: `${HOST_TRAITS[host].program} takes no ${format.toUpperCase()} plugin` };
546
+ }
547
+
548
+ /** Problems with a record's shape, empty when it is one: the records repository's check holds the same rules. */
549
+ export function comparisonRecordProblems(rec: unknown): string[] {
550
+ const p: string[] = [];
551
+ const r = rec as Partial<ComparisonRecord> | null;
552
+ if (r === null || typeof r !== "object") return ["not an object"];
553
+ if (r.schema !== COMPARISON_RECORD_SCHEMA) p.push(`schema is ${JSON.stringify(r.schema)}`);
554
+ if (typeof r.plugin?.id !== "string" || typeof r.plugin.format !== "string") p.push("names no plugin");
555
+ if (!Array.isArray(r.hosts) || r.hosts.length === 0) p.push("holds no host row");
556
+ for (const h of r.hosts ?? []) {
557
+ if (!isCompareHost(String(h.host))) p.push(`a row of the unknown host ${JSON.stringify(h.host)}`);
558
+ if (h.status === "ran" && (h.checks === undefined || typeof h.runsClean !== "boolean")) p.push(`${h.host}: ran with no checks`);
559
+ if (h.status !== "ran" && !h.reason) p.push(`${h.host}: ${String(h.status)} with no reason`);
560
+ }
561
+ return p;
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------------------------------- the summary
565
+
566
+ export interface HostTotals {
567
+ readonly host: CompareHost;
568
+ readonly version: string | null;
569
+ readonly status: "ran" | "skipped";
570
+ readonly reason?: string;
571
+ /** Plugins of a format the host takes. */
572
+ readonly plugins: number;
573
+ readonly loads: number;
574
+ readonly instantiates: number;
575
+ readonly output: number;
576
+ readonly runsClean: number;
577
+ readonly rtSafe: number;
578
+ /** Plugins whose real-time behaviour could be watched under this host. */
579
+ readonly rtObserved: number;
580
+ readonly crashes: number;
581
+ readonly cpuMedianPercent: number | null;
582
+ readonly latencyObservable: boolean;
583
+ }
584
+
585
+ export interface Beat {
586
+ readonly format: string;
587
+ readonly id: string;
588
+ /** The hosts it does better than on this plugin. */
589
+ readonly over: readonly CompareHost[];
590
+ /** runs-clean: it runs clean where they do not; rt-safe: its audio thread is clean where theirs is not. */
591
+ readonly on: "runs-clean" | "rt-safe";
592
+ }
593
+
594
+ export interface ComparisonSummary {
595
+ readonly schema: typeof COMPARISON_SUMMARY_SCHEMA;
596
+ readonly hostClass: string | null;
597
+ readonly set: string | null;
598
+ readonly measured: string | null;
599
+ readonly publishable: boolean;
600
+ readonly missing: readonly { host: CompareHost; reason: string }[];
601
+ readonly conditions: ComparisonConditions | null;
602
+ readonly records: number;
603
+ /** Plugins every host that ran takes and ran: the like-for-like set the headline counts. */
604
+ readonly common: number;
605
+ readonly hosts: readonly HostTotals[];
606
+ readonly beats: Readonly<Record<string, readonly Beat[]>>;
607
+ }
608
+
609
+ const median = (xs: readonly number[]): number | null => {
610
+ if (xs.length === 0) return null;
611
+ const s = [...xs].sort((a, b) => a - b);
612
+ const m = Math.floor(s.length / 2);
613
+ const hi = s[m] ?? 0;
614
+ return Math.round((s.length % 2 ? hi : ((s[m - 1] ?? hi) + hi) / 2) * 100) / 100;
615
+ };
616
+
617
+ /**
618
+ * The summary of a set of comparison records. `present` is what the run found installed; a host absent from every
619
+ * record is still listed, skipped, with its reason. Beats are counted between hosts that both ran the plugin.
620
+ */
621
+ export function summarise(
622
+ records: readonly ComparisonRecord[],
623
+ present: readonly HostPresence[],
624
+ meta: { hostClass?: string; set?: string; publish: boolean },
625
+ ): ComparisonSummary {
626
+ const hostsSeen = COMPARE_HOSTS.filter((h) => present.some((p) => p.host === h) || records.some((r) => r.hosts.some((x) => x.host === h)));
627
+ const missing = missingReferences(present);
628
+ const ranRow = (r: ComparisonRecord, h: CompareHost): HostRow | undefined => r.hosts.find((x) => x.host === h && x.status === "ran");
629
+ const totals: HostTotals[] = hostsSeen.map((h) => {
630
+ const p = present.find((x) => x.host === h);
631
+ const rows = records.map((r) => ranRow(r, h)).filter((x): x is HostRow => x !== undefined);
632
+ const version = p?.version ?? rows.find((r) => r.version !== null)?.version ?? null;
633
+ const count = (f: (r: HostRow) => boolean): number => rows.filter(f).length;
634
+ const skipped = rows.length === 0 && (p === undefined || !p.installed);
635
+ return {
636
+ host: h,
637
+ version,
638
+ status: skipped ? "skipped" : "ran",
639
+ ...(skipped ? { reason: p?.reason ?? `${HOST_TRAITS[h].program} is not installed` } : {}),
640
+ plugins: rows.length,
641
+ loads: count((r) => r.checks?.loads.result === "pass"),
642
+ instantiates: count((r) => r.checks?.instantiates.result === "pass"),
643
+ output: count((r) => r.checks?.output.result === "pass"),
644
+ runsClean: count((r) => r.runsClean === true),
645
+ rtSafe: count((r) => r.checks?.rtSafe.result === "pass"),
646
+ rtObserved: count((r) => r.checks?.rtSafe.result === "pass" || r.checks?.rtSafe.result === "fail"),
647
+ crashes: count((r) => r.crash !== undefined),
648
+ cpuMedianPercent: median(rows.flatMap((r) => (r.checks?.cpu.result === "measured" && typeof r.checks.cpu.percent === "number" ? [r.checks.cpu.percent] : []))),
649
+ latencyObservable: HOST_TRAITS[h].latency.observable,
650
+ };
651
+ });
652
+ const ran = totals.filter((t) => t.status === "ran").map((t) => t.host);
653
+ const beats: Record<string, Beat[]> = {};
654
+ for (const h of ran) beats[h] = [];
655
+ for (const r of records) {
656
+ for (const h of ran) {
657
+ const mine = ranRow(r, h);
658
+ const list = beats[h];
659
+ if (mine === undefined || list === undefined) continue;
660
+ const others = ran.filter((o) => o !== h).map((o) => [o, ranRow(r, o)] as const).filter((x): x is readonly [CompareHost, HostRow] => x[1] !== undefined);
661
+ const cleanOver = mine.runsClean === true ? others.filter(([, row]) => row.runsClean !== true).map(([o]) => o) : [];
662
+ if (cleanOver.length) list.push({ format: r.plugin.format, id: r.plugin.id, over: cleanOver, on: "runs-clean" });
663
+ const rtOver = mine.checks?.rtSafe.result === "pass" ? others.filter(([, row]) => row.checks?.rtSafe.result === "fail").map(([o]) => o) : [];
664
+ if (rtOver.length) list.push({ format: r.plugin.format, id: r.plugin.id, over: rtOver, on: "rt-safe" });
665
+ }
666
+ }
667
+ for (const list of Object.values(beats)) list.sort((a, b) => a.on.localeCompare(b.on) || a.format.localeCompare(b.format) || a.id.localeCompare(b.id));
668
+ const measured = records.map((r) => r.measuredAt).sort().pop() ?? null;
669
+ return {
670
+ schema: COMPARISON_SUMMARY_SCHEMA,
671
+ hostClass: meta.hostClass ?? null,
672
+ set: meta.set ?? null,
673
+ measured: measured === null ? null : measured.slice(0, 10),
674
+ publishable: meta.publish && missing.length === 0 && REFERENCE_HOSTS.every((h) => ran.includes(h)),
675
+ missing,
676
+ conditions: records[0]?.conditions ?? null,
677
+ records: records.length,
678
+ common: records.filter((r) => ran.every((h) => ranRow(r, h) !== undefined)).length,
679
+ hosts: totals,
680
+ beats,
681
+ };
682
+ }
683
+
684
+ /** The text table of a summary, for the terminal. */
685
+ export function summaryTable(s: ComparisonSummary): string {
686
+ const lines = [`host version plugins loads clean rt-safe/observed crashes cpu%`];
687
+ for (const t of s.hosts) {
688
+ if (t.status === "skipped") {
689
+ lines.push(`${t.host.padEnd(15)} skipped: ${t.reason ?? ""}`);
690
+ continue;
691
+ }
692
+ lines.push(
693
+ `${t.host.padEnd(15)} ${(t.version ?? "?").padEnd(27)} ${String(t.plugins).padStart(7)} ${String(t.loads).padStart(5)} ${String(t.runsClean).padStart(5)} ${`${String(t.rtSafe)}/${String(t.rtObserved)}`.padStart(16)} ${String(t.crashes).padStart(7)} ${t.cpuMedianPercent === null ? "-" : String(t.cpuMedianPercent)}`,
694
+ );
695
+ }
696
+ for (const [h, list] of Object.entries(s.beats)) if (list.length) lines.push(`${h} does better on ${String(list.length)}: ${list.map((b) => `${b.id} (${b.on} over ${b.over.join(",")})`).join("; ")}`);
697
+ if (!s.publishable) lines.push(`not publishable${s.missing.length ? `: ${s.missing.map((m) => `${m.host}: ${m.reason}`).join("; ")}` : " (no --publish)"}`);
698
+ return `${lines.join("\n")}\n`;
699
+ }