@openmixer/plugin-qualify 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +52 -10
  2. package/bin/plugin-qualify.mjs +61 -28
  3. package/dist/clap-loader.d.ts +1 -1
  4. package/dist/clap-loader.js +3 -3
  5. package/dist/clap-loader.js.map +1 -1
  6. package/dist/clap-qualify.d.ts +60 -51
  7. package/dist/clap-qualify.js +143 -91
  8. package/dist/clap-qualify.js.map +1 -1
  9. package/dist/cost-fields.d.ts +26 -0
  10. package/dist/cost-fields.js +30 -0
  11. package/dist/cost-fields.js.map +1 -1
  12. package/dist/hosting-codes.d.ts +1 -0
  13. package/dist/hosting-codes.js +1 -0
  14. package/dist/hosting-codes.js.map +1 -1
  15. package/dist/hosting-suitability.d.ts +22 -3
  16. package/dist/hosting-suitability.js +25 -5
  17. package/dist/hosting-suitability.js.map +1 -1
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +5 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/judge-cli.d.ts +41 -0
  22. package/dist/judge-cli.js +114 -0
  23. package/dist/judge-cli.js.map +1 -0
  24. package/dist/measurement/cli.d.ts +7 -0
  25. package/dist/measurement/cli.js +14 -0
  26. package/dist/measurement/cli.js.map +1 -1
  27. package/dist/measurement/format.d.ts +44 -4
  28. package/dist/measurement/format.js +46 -3
  29. package/dist/measurement/format.js.map +1 -1
  30. package/dist/measurement/probe-json.js +4 -0
  31. package/dist/measurement/probe-json.js.map +1 -1
  32. package/dist/measurement/sweep-jsonl.js +2 -1
  33. package/dist/measurement/sweep-jsonl.js.map +1 -1
  34. package/dist/measurement/vocabulary.d.ts +9 -0
  35. package/dist/measurement/vocabulary.js +33 -0
  36. package/dist/measurement/vocabulary.js.map +1 -1
  37. package/dist/probe-judge.d.ts +65 -0
  38. package/dist/probe-judge.js +217 -0
  39. package/dist/probe-judge.js.map +1 -0
  40. package/dist/probe-output-fixtures.d.ts +25 -0
  41. package/dist/probe-output-fixtures.js +134 -0
  42. package/dist/probe-output-fixtures.js.map +1 -0
  43. package/dist/probe-output.d.ts +153 -0
  44. package/dist/probe-output.js +374 -0
  45. package/dist/probe-output.js.map +1 -0
  46. package/dist/probe-runner.d.ts +79 -0
  47. package/dist/probe-runner.js +191 -0
  48. package/dist/probe-runner.js.map +1 -0
  49. package/dist/qualification-record.d.ts +82 -0
  50. package/dist/qualification-record.js +85 -0
  51. package/dist/qualification-record.js.map +1 -0
  52. package/dist/qualify-cli.js +1 -1
  53. package/dist/qualify-cli.js.map +1 -1
  54. package/dist/qualify-declarations.d.ts +11 -2
  55. package/dist/qualify-declarations.js +14 -4
  56. package/dist/qualify-declarations.js.map +1 -1
  57. package/fixtures/clap-scan/fake.json +30 -6
  58. package/fixtures/probe-output/memory-limit-refused.json +190 -0
  59. package/fixtures/probe-output/plugin-crash.json +236 -0
  60. package/fixtures/probe-output/success-msi.json +363 -0
  61. package/package.json +5 -1
  62. package/src/clap-loader.ts +3 -2
  63. package/src/clap-qualify.ts +179 -138
  64. package/src/cost-fields.ts +39 -0
  65. package/src/hosting-codes.ts +1 -0
  66. package/src/hosting-suitability.ts +42 -7
  67. package/src/index.ts +5 -0
  68. package/src/judge-cli.ts +133 -0
  69. package/src/measurement/cli.ts +20 -0
  70. package/src/measurement/format.ts +80 -2
  71. package/src/measurement/probe-json.ts +4 -0
  72. package/src/measurement/sweep-jsonl.ts +2 -1
  73. package/src/measurement/vocabulary.ts +38 -0
  74. package/src/probe-judge.ts +272 -0
  75. package/src/probe-output-fixtures.ts +146 -0
  76. package/src/probe-output.ts +511 -0
  77. package/src/probe-runner.ts +244 -0
  78. package/src/qualification-record.ts +149 -0
  79. package/src/qualify-cli.ts +1 -1
  80. package/src/qualify-declarations.ts +16 -4
  81. package/tools/benchmark.py +116 -13
  82. package/tools/lv2-measure.mjs +2 -0
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * `plugin-qualify judge <probe-output>...`: judge probe outputs already written (by
5
+ * `plugin-hostd-probe run`, by plugin-hostd's `judge_cmd`, or by hand) and write the qualification
6
+ * record beside a byte copy of each. `bin/plugin-qualify.mjs` only reads argv and prints.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import type { HostProfile } from "./host-profiles.js";
13
+ import { judgeProbeOutput, judgmentVerdicts } from "./probe-judge.js";
14
+ import { readProbeOutput } from "./probe-output.js";
15
+ import { writeQualification } from "./qualification-record.js";
16
+ import type { QualifyVerdict } from "./qualify-cli.js";
17
+ import { EXIT_CODE, OUTPUT_FORMATS, type OutputFormat } from "./qualify-options.js";
18
+ import { OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
19
+
20
+ export interface JudgeArgs {
21
+ readonly files: readonly string[];
22
+ readonly hostProfiles: readonly string[];
23
+ readonly qualificationDir?: string;
24
+ readonly format: OutputFormat;
25
+ readonly help: boolean;
26
+ }
27
+
28
+ export type ParseJudgeResult = { ok: true; args: JudgeArgs } | { ok: false; error: string };
29
+
30
+ const VALUE_FLAGS = ["--host-profile", "--qualification-dir", "--format"];
31
+
32
+ export function parseJudgeArgs(argv: readonly string[]): ParseJudgeResult {
33
+ const files: string[] = [];
34
+ const specs: string[] = [];
35
+ let dir: string | undefined;
36
+ let format: string = "text";
37
+ let help = false;
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i]!;
40
+ if (!a.startsWith("--")) {
41
+ files.push(a);
42
+ continue;
43
+ }
44
+ if (a === "--help") {
45
+ help = true;
46
+ continue;
47
+ }
48
+ if (!VALUE_FLAGS.includes(a)) return { ok: false, error: `unknown option ${a}` };
49
+ const v = argv[++i];
50
+ if (v === undefined || v.startsWith("--")) return { ok: false, error: `${a} needs a value` };
51
+ if (a === "--host-profile") specs.push(...v.split(",").map((t) => t.trim()).filter((t) => t !== ""));
52
+ else if (a === "--qualification-dir") dir = v;
53
+ else format = v;
54
+ }
55
+ if (!(OUTPUT_FORMATS as readonly string[]).includes(format)) return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
56
+ if (!help && files.length === 0) return { ok: false, error: "name at least one probe output" };
57
+ return {
58
+ ok: true,
59
+ args: {
60
+ files,
61
+ hostProfiles: [...new Set(specs.length > 0 ? specs : [OPENMIXER_CONSOLE_PROFILE.id])],
62
+ ...(dir === undefined ? {} : { qualificationDir: dir }),
63
+ format: format as OutputFormat,
64
+ help,
65
+ },
66
+ };
67
+ }
68
+
69
+ export function judgeHelpText(): string {
70
+ return [
71
+ "plugin-qualify judge — judge probe outputs and write the qualification records",
72
+ "",
73
+ " plugin-qualify judge [options] <probe-output.json>...",
74
+ "",
75
+ " --host-profile <file|id> judge against this host; repeat or comma-separate for one verdict per profile",
76
+ ` (default: ${OPENMIXER_CONSOLE_PROFILE.id})`,
77
+ " --qualification-dir <dir> where the records and the copies of the probe outputs go",
78
+ " (default: $XDG_STATE_HOME/omx/qualification, else ~/.local/state/omx/qualification)",
79
+ " --format <json|text> json = the records; text = plugin, verdict, deciding reason (default: text)",
80
+ "",
81
+ `exit ${EXIT_CODE.allQualified} all qualified / ${EXIT_CODE.someNotQualified} some not / ${EXIT_CODE.couldNotMeasure} something could not be read or judged`,
82
+ "",
83
+ ].join("\n");
84
+ }
85
+
86
+ /** The qualification directory when none is named: the state directory of owner spec section 6.9, per user. */
87
+ export function defaultQualificationDir(env: Readonly<Record<string, string | undefined>>, home: string = homedir()): string {
88
+ const state = env["XDG_STATE_HOME"];
89
+ return join(state !== undefined && state !== "" ? state : join(home, ".local", "state"), "omx", "qualification");
90
+ }
91
+
92
+ export interface JudgeFilesResult {
93
+ readonly verdicts: readonly QualifyVerdict[];
94
+ readonly records: readonly { readonly recordPath: string; readonly probePath: string }[];
95
+ /** Each file that was refused or could not be read, with the code or the reason. */
96
+ readonly failures: readonly { readonly file: string; readonly problem: string }[];
97
+ }
98
+
99
+ /** Judge each file under every profile and write its record; a file that does not read is a failure, and the others go on. */
100
+ export function judgeFiles(
101
+ files: readonly string[],
102
+ options: { profiles: readonly HostProfile[]; dir: string; read?: (path: string) => Buffer },
103
+ ): JudgeFilesResult {
104
+ const read = options.read ?? ((p: string) => readFileSync(p));
105
+ const verdicts: QualifyVerdict[] = [];
106
+ const records: { recordPath: string; probePath: string }[] = [];
107
+ const failures: { file: string; problem: string }[] = [];
108
+ for (const file of files) {
109
+ let bytes: Buffer;
110
+ try {
111
+ bytes = read(file);
112
+ } catch (e) {
113
+ failures.push({ file, problem: `cannot read: ${e instanceof Error ? e.message : String(e)}` });
114
+ continue;
115
+ }
116
+ const r = readProbeOutput(bytes.toString("utf8"));
117
+ if (!r.ok) {
118
+ failures.push({ file, problem: `${r.code}: ${r.problem}` });
119
+ continue;
120
+ }
121
+ const judgment = judgeProbeOutput(r.output, options.profiles);
122
+ const w = writeQualification(options.dir, bytes, judgment);
123
+ records.push({ recordPath: w.recordPath, probePath: w.probePath });
124
+ verdicts.push(...judgmentVerdicts(judgment));
125
+ }
126
+ return { verdicts, records, failures };
127
+ }
128
+
129
+ /** 2 when a file could not be judged or a plugin was not measured, else 0 when every verdict qualifies, else 1. */
130
+ export function judgeExitCode(result: JudgeFilesResult): number {
131
+ if (result.failures.length > 0 || result.verdicts.length === 0 || result.verdicts.some((v) => !v.measured)) return EXIT_CODE.couldNotMeasure;
132
+ return result.verdicts.every((v) => v.qualified) ? EXIT_CODE.allQualified : EXIT_CODE.someNotQualified;
133
+ }
@@ -54,6 +54,9 @@ export const TOOL_NAME = "lv2-measure";
54
54
  */
55
55
  export const TOOL_VERSION = "0.1.0";
56
56
 
57
+ /** The most notes a cost run holds: one per MIDI key, as the probers cap it. */
58
+ export const PROBE_NOTES_MAX = 128;
59
+
57
60
  /** A fully resolved invocation. */
58
61
  export interface MeasureOptions {
59
62
  /** Where the document goes. Absent = stdout. */
@@ -70,6 +73,11 @@ export interface MeasureOptions {
70
73
  readonly sweep: boolean;
71
74
  /** Core to pin the cost pass to. Absent = let the prober choose. */
72
75
  readonly costCpu?: number;
76
+ /**
77
+ * The notes the cost pass holds down while it times an instrument (`--probe-notes`, the console's
78
+ * `SYNTH_VOICE_PROBE_NOTES`), basis `probe`. Absent = an instrument is unmeasured `voices-unknown`.
79
+ */
80
+ readonly probeNotes?: number;
73
81
  readonly label?: string;
74
82
  /** Directory holding `scan.py` and `benchmark.py`. */
75
83
  readonly toolDir: string;
@@ -203,6 +211,7 @@ export function buildPipeline(options: MeasureOptions, native?: HostingTools): P
203
211
  "--cost-provenance",
204
212
  costProvenance,
205
213
  ...(options.costCpu === undefined ? [] : ["--cost-cpu", String(options.costCpu)]),
214
+ ...(options.probeNotes === undefined ? [] : ["--probe-notes", String(options.probeNotes)]),
206
215
  ],
207
216
  produces: cost,
208
217
  dimension: "cost",
@@ -356,6 +365,7 @@ export function parseMeasureArgs(
356
365
  let workDir = defaults.workDir;
357
366
  let sweep = false;
358
367
  let costCpu: number | undefined;
368
+ let probeNotes: number | undefined;
359
369
  let label: string | undefined;
360
370
  let toolDir = defaults.toolDir;
361
371
  let python = env.PYTHON ?? "python3";
@@ -430,6 +440,15 @@ export function parseMeasureArgs(
430
440
  costCpu = cpu;
431
441
  break;
432
442
  }
443
+ case "--probe-notes": {
444
+ const value = next();
445
+ const notes = value === undefined ? Number.NaN : Number(value);
446
+ if (!Number.isInteger(notes) || notes < 1 || notes > PROBE_NOTES_MAX) {
447
+ return { ok: false, error: `--probe-notes needs a count of notes, 1 to ${PROBE_NOTES_MAX}` };
448
+ }
449
+ probeNotes = notes;
450
+ break;
451
+ }
433
452
  case "--label": {
434
453
  const value = next();
435
454
  if (value === undefined) return { ok: false, error: "--label needs text" };
@@ -494,6 +513,7 @@ export function parseMeasureArgs(
494
513
  ...(scanPath === undefined ? {} : { scanPath }),
495
514
  ...(plugins === undefined ? {} : { plugins }),
496
515
  ...(costCpu === undefined ? {} : { costCpu }),
516
+ ...(probeNotes === undefined ? {} : { probeNotes }),
497
517
  ...(label === undefined ? {} : { label }),
498
518
  ...(featureFlags.length === 0 ? {} : { featureFlags }),
499
519
  },
@@ -58,10 +58,12 @@ import {
58
58
  type HostLatencyHandling,
59
59
  type MeasurementDimension,
60
60
  type VerdictRating,
61
+ type VoiceBasis,
61
62
  HOST_ISOLATIONS,
62
63
  HOST_LATENCY_HANDLING,
63
64
  MEASUREMENT_DIMENSIONS,
64
65
  VERDICT_RATINGS,
66
+ isVoiceBasis,
65
67
  readDimension,
66
68
  } from "./vocabulary.js";
67
69
 
@@ -74,8 +76,10 @@ export const MEASUREMENT_FORMAT_MAJOR = 1;
74
76
  /** The minor this build writes. Additive-only within {@link MEASUREMENT_FORMAT_MAJOR}.
75
77
  * `1.1` added the three hosting dimensions — {@link StabilityMeasurement},
76
78
  * {@link RtSafetyMeasurement}, {@link FeaturesMeasurement} — which a 1.0 reader skips.
77
- * `1.2` added the verdicts per host profile; `1.3` the {@link IntegrityMeasurement} block. */
78
- export const MEASUREMENT_FORMAT_MINOR = 3;
79
+ * `1.2` added the verdicts per host profile; `1.3` the {@link IntegrityMeasurement} block; `1.4` the notes
80
+ * an instrument's cost was measured with ({@link CostAtRate.voices}); `1.5` the note sweep an instrument's
81
+ * `run()` was counted under ({@link RtSafetyMeasurement.noteSweep}). */
82
+ export const MEASUREMENT_FORMAT_MINOR = 5;
79
83
 
80
84
  /** The version string this build writes, e.g. `"1.0"`. */
81
85
  export const MEASUREMENT_FORMAT_VERSION = `${MEASUREMENT_FORMAT_MAJOR}.${MEASUREMENT_FORMAT_MINOR}`;
@@ -270,6 +274,17 @@ export interface LatencyMeasurement {
270
274
  readonly unmeasuredDetail?: string;
271
275
  }
272
276
 
277
+ /**
278
+ * The notes an instrument held down while its cost was timed (1.4): how many, where that count came
279
+ * from ({@link VoiceBasis}), and the voice capacity the plugin declared at that rate when it declares one.
280
+ * An instrument costs nothing until a voice plays, so its figure means nothing without this.
281
+ */
282
+ export interface VoicesHeld {
283
+ readonly held: number;
284
+ readonly basis: VoiceBasis;
285
+ readonly capacity?: number;
286
+ }
287
+
273
288
  /** One rate's cost reading. Per SAMPLE, so it is independent of the block size. */
274
289
  export interface CostAtRate {
275
290
  readonly nsPerSampleMedian: number;
@@ -286,6 +301,8 @@ export interface CostAtRate {
286
301
  /** Timed, but emitted silence under a stimulus that should have passed — the figure may
287
302
  * describe a short-circuit path rather than the DSP. */
288
303
  readonly silentOutput?: boolean;
304
+ /** 1.4: for an instrument, the notes held while it was timed. Absent for an effect. */
305
+ readonly voices?: VoicesHeld;
289
306
  }
290
307
 
291
308
  /** Everything one run learned about one plugin's CPU cost. */
@@ -369,6 +386,27 @@ export interface StabilityMeasurement {
369
386
  readonly unmeasuredDetail?: string;
370
387
  }
371
388
 
389
+ /**
390
+ * 1.5: an instrument played through its MIDI input while the interposer counted, apart from the control
391
+ * sweep — a chord, bend and a controller, pressure, the chord let go, a burst of note ons and their offs,
392
+ * all notes off. `applicable: false` is a plugin with no MIDI input; the counts are present only when
393
+ * the interposer was loaded.
394
+ */
395
+ export interface NoteSweepMeasurement {
396
+ readonly applicable: boolean;
397
+ readonly dialect?: "clap" | "midi";
398
+ readonly blocks?: number;
399
+ readonly blockFrames?: number;
400
+ /** The note, expression and MIDI events the sweep sent. */
401
+ readonly noteEvents?: number;
402
+ readonly processErrors?: number;
403
+ readonly nonFinite?: boolean;
404
+ readonly interposer?: boolean;
405
+ readonly allocationsInRun?: number;
406
+ readonly locksInRun?: number;
407
+ readonly syscallsInRun?: number;
408
+ }
409
+
372
410
  /**
373
411
  * What an interposer counted INSIDE `run()`. `rate` and `blockFrames` are the pair it ran at —
374
412
  * a count is a property of the code paths the run took there. Readable when all three counts
@@ -391,6 +429,8 @@ export interface RtSafetyMeasurement {
391
429
  readonly syscallsInRun?: number;
392
430
  /** Repeats disagreed. */
393
431
  readonly variable?: boolean;
432
+ /** 1.5: the same counts taken while the plugin was played through its MIDI input. */
433
+ readonly noteSweep?: NoteSweepMeasurement;
394
434
  readonly unmeasuredReason?: string;
395
435
  readonly unmeasuredDetail?: string;
396
436
  }
@@ -834,9 +874,20 @@ function readCostAtRate(value: unknown): CostAtRate | undefined {
834
874
  ...optional("nsPerSampleMax", finiteNumber(value.nsPerSampleMax)),
835
875
  ...optional("warmupNsPerSampleMedian", finiteNumber(value.warmupNsPerSampleMedian)),
836
876
  ...optional("silentOutput", booleanValue(value.silentOutput)),
877
+ ...optional("voices", readVoicesHeld(value.voices)),
837
878
  };
838
879
  }
839
880
 
881
+ /** The notes held, or `undefined` unless the count and a known basis are there. */
882
+ export function readVoicesHeld(value: unknown): VoicesHeld | undefined {
883
+ if (!isRecord(value)) return undefined;
884
+ const held = positiveNumber(value.held);
885
+ const basis = isVoiceBasis(value.basis) ? value.basis : undefined;
886
+ const capacity = finiteNumber(value.capacity);
887
+ if (held === undefined || !Number.isInteger(held) || basis === undefined) return undefined;
888
+ return { held, basis, ...(capacity !== undefined && Number.isInteger(capacity) && capacity >= 0 ? { capacity } : {}) };
889
+ }
890
+
840
891
  function readCost(value: unknown): CostMeasurement | undefined {
841
892
  if (!isRecord(value)) return undefined;
842
893
  return {
@@ -942,10 +993,37 @@ function readRtSafety(value: unknown): RtSafetyMeasurement | undefined {
942
993
  ...optional("locksInRun", finiteNumber(value.locksInRun)),
943
994
  ...optional("syscallsInRun", finiteNumber(value.syscallsInRun)),
944
995
  ...optional("variable", booleanValue(value.variable)),
996
+ ...optional("noteSweep", readNoteSweep(value.noteSweep)),
945
997
  ...reasonOf(value),
946
998
  };
947
999
  }
948
1000
 
1001
+ /** A note sweep, or `undefined` unless it says whether it was applicable; a count it cannot vouch for is left out. */
1002
+ export function readNoteSweep(value: unknown): NoteSweepMeasurement | undefined {
1003
+ if (!isRecord(value)) return undefined;
1004
+ const applicable = booleanValue(value.applicable);
1005
+ if (applicable === undefined) return undefined;
1006
+ if (!applicable) return { applicable };
1007
+ const dialect = value.dialect === "clap" || value.dialect === "midi" ? value.dialect : undefined;
1008
+ const counted = (v: unknown) => {
1009
+ const n = finiteNumber(v);
1010
+ return n !== undefined && Number.isInteger(n) && n >= 0 ? n : undefined;
1011
+ };
1012
+ return {
1013
+ applicable,
1014
+ ...optional("dialect", dialect),
1015
+ ...optional("blocks", counted(value.blocks)),
1016
+ ...optional("blockFrames", counted(value.blockFrames)),
1017
+ ...optional("noteEvents", counted(value.noteEvents)),
1018
+ ...optional("processErrors", counted(value.processErrors)),
1019
+ ...optional("nonFinite", booleanValue(value.nonFinite)),
1020
+ ...optional("interposer", booleanValue(value.interposer)),
1021
+ ...optional("allocationsInRun", counted(value.allocationsInRun)),
1022
+ ...optional("locksInRun", counted(value.locksInRun)),
1023
+ ...optional("syscallsInRun", counted(value.syscallsInRun)),
1024
+ };
1025
+ }
1026
+
949
1027
  function readFeatures(value: unknown): FeaturesMeasurement | undefined {
950
1028
  if (!isRecord(value)) return undefined;
951
1029
  return {
@@ -35,6 +35,7 @@ import type {
35
35
  PluginTopology,
36
36
  } from "./format.js";
37
37
  import { booleanValue, finiteNumber, isRecord, nonEmptyString, optional, positiveNumber } from "./guards.js";
38
+ import { readVoicesHeld } from "./format.js";
38
39
  import type { MeasurementDimension } from "./vocabulary.js";
39
40
 
40
41
  /** One free-text prober failure, and the vocabulary term it means. */
@@ -55,6 +56,8 @@ export const PROBE_REASON_RULES: readonly ReasonRule[] = [
55
56
  { match: /^no-audio-in$/, code: "no-audio-in" },
56
57
  { match: /^no-audio-out$/, code: "no-audio-out" },
57
58
  { match: /^silent-output$/, code: "silent-output" },
59
+ { match: /^voices-unknown$/, code: "voices-unknown" },
60
+ { match: /hosting\.worker\.nonconforming/, code: "worker-nonconforming" },
58
61
  { match: /worker crashed|worker desync|worker unavailable/, code: "probe-crashed" },
59
62
  { match: /timed out|timeout/, code: "probe-timeout" },
60
63
  { match: /non-finite/, code: "non-finite-output" },
@@ -197,6 +200,7 @@ function readCost(value: unknown): CostMeasurement | undefined {
197
200
  ...optional("nsPerSampleMax", finiteNumber(reading.nsPerSampleMax)),
198
201
  ...optional("warmupNsPerSampleMedian", finiteNumber(reading.warmupNsPerSampleMedian)),
199
202
  ...optional("silentOutput", booleanValue(reading.silentOutput)),
203
+ ...optional("voices", readVoicesHeld(reading.voices)),
200
204
  };
201
205
  }
202
206
  }
@@ -29,7 +29,7 @@ import type {
29
29
  SoakReading,
30
30
  StabilityMeasurement,
31
31
  } from "./format.js";
32
- import { readIntegrity, readLifecycle, readSoakAtRate, readThreads } from "./format.js";
32
+ import { readIntegrity, readLifecycle, readNoteSweep, readSoakAtRate, readThreads } from "./format.js";
33
33
  import { booleanValue, finiteNumber, isRecord, mapArray, nonEmptyString, optional, positiveNumber } from "./guards.js";
34
34
 
35
35
  /** The sweep's three passes, each a list of parsed JSONL lines. Only `measurements` is required. */
@@ -106,6 +106,7 @@ function rtSafetyOf(row: Record<string, unknown>): RtSafetyMeasurement {
106
106
  ...optional("locksInRun", finiteNumber(rt.locksInRun)),
107
107
  ...optional("syscallsInRun", finiteNumber(rt.syscallsInRun)),
108
108
  ...optional("variable", booleanValue(rt.variable)),
109
+ ...optional("noteSweep", readNoteSweep(rt.noteSweep)),
109
110
  };
110
111
  }
111
112
 
@@ -85,6 +85,25 @@ export const UNMEASURED_TERMS: readonly UnmeasuredTerm[] = [
85
85
  "whitespace, a manifest using @base or a percent-encoded IRI, or a CLAP plugin with more parameters than the " +
86
86
  "host accepts. The plugin stays unpinned, never partly pinned.",
87
87
  },
88
+ {
89
+ code: "no-voices",
90
+ kind: "nothing-to-measure",
91
+ description:
92
+ "An instrument whose voice-info declares a voice capacity of 0: there is no voice to hold down, so its " +
93
+ "cost with notes held cannot be taken.",
94
+ },
95
+ {
96
+ code: "notes-unplayable",
97
+ kind: "nothing-to-measure",
98
+ description: "An instrument whose note input reads no dialect the prober speaks (CLAP's or MIDI's): it cannot be played.",
99
+ },
100
+ {
101
+ code: "voices-unknown",
102
+ kind: "not-attempted",
103
+ description:
104
+ "An instrument that declares no voice capacity, measured by a run given no probe count (`--probe-notes`): " +
105
+ "the run did not know how many notes to hold, so it timed none.",
106
+ },
88
107
  {
89
108
  code: "refused-instantiation",
90
109
  kind: "probe-failed",
@@ -112,6 +131,13 @@ export const UNMEASURED_TERMS: readonly UnmeasuredTerm[] = [
112
131
  "The URI was not present in this machine's LV2 world. A fact about this installation, " +
113
132
  "not about the plugin — another host may well have it.",
114
133
  },
134
+ {
135
+ code: "worker-nonconforming",
136
+ kind: "probe-failed",
137
+ description:
138
+ "The plugin's LV2_Worker_Interface leaves work or work_response NULL, which the LV2 worker " +
139
+ "specification does not allow: it was refused before any call, not measured.",
140
+ },
115
141
  {
116
142
  code: "probe-error",
117
143
  kind: "probe-failed",
@@ -310,3 +336,15 @@ export type HostIsolation = (typeof HOST_ISOLATIONS)[number];
310
336
  export const HOST_LATENCY_HANDLING = ["compensated", "uncompensated"] as const;
311
337
  export type HostLatencyHandling = (typeof HOST_LATENCY_HANDLING)[number];
312
338
 
339
+ /**
340
+ * Where the number of notes an instrument's cost was measured with came from: `declared` is the voice
341
+ * capacity the plugin declares (CLAP's voice-info, read after activate at that rate); `probe` is a count
342
+ * the caller gave (`--probe-notes`, the console's `SYNTH_VOICE_PROBE_NOTES`), used when the plugin declares
343
+ * none or when the caller asked for that count whatever it declares.
344
+ */
345
+ export const VOICE_BASES = ["declared", "probe"] as const;
346
+ export type VoiceBasis = (typeof VOICE_BASES)[number];
347
+
348
+ export function isVoiceBasis(value: unknown): value is VoiceBasis {
349
+ return VOICE_BASES.some((b) => b === value);
350
+ }