@openmixer/plugin-qualify 0.11.0 → 0.13.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 (51) hide show
  1. package/README.md +7 -1
  2. package/dist/clap-qualify.d.ts +60 -4
  3. package/dist/clap-qualify.js +141 -3
  4. package/dist/clap-qualify.js.map +1 -1
  5. package/dist/host-profiles.d.ts +1 -2
  6. package/dist/host-profiles.js +12 -2
  7. package/dist/host-profiles.js.map +1 -1
  8. package/dist/hosting-codes.d.ts +77 -57
  9. package/dist/hosting-codes.js +89 -77
  10. package/dist/hosting-codes.js.map +1 -1
  11. package/dist/hosting-suitability.d.ts +12 -4
  12. package/dist/hosting-suitability.js +18 -16
  13. package/dist/hosting-suitability.js.map +1 -1
  14. package/dist/measurement/cli.d.ts +8 -1
  15. package/dist/measurement/cli.js +15 -1
  16. package/dist/measurement/cli.js.map +1 -1
  17. package/dist/measurement/format.d.ts +45 -5
  18. package/dist/measurement/format.js +46 -3
  19. package/dist/measurement/format.js.map +1 -1
  20. package/dist/measurement/probe-json.js +3 -0
  21. package/dist/measurement/probe-json.js.map +1 -1
  22. package/dist/measurement/sweep-jsonl.js +2 -1
  23. package/dist/measurement/sweep-jsonl.js.map +1 -1
  24. package/dist/measurement/vocabulary.d.ts +39 -22
  25. package/dist/measurement/vocabulary.js +93 -145
  26. package/dist/measurement/vocabulary.js.map +1 -1
  27. package/dist/probe-judge.d.ts +6 -1
  28. package/dist/probe-judge.js +7 -3
  29. package/dist/probe-judge.js.map +1 -1
  30. package/dist/probe-output.d.ts +3 -0
  31. package/dist/probe-output.js +5 -0
  32. package/dist/probe-output.js.map +1 -1
  33. package/dist/qualification-record.d.ts +3 -1
  34. package/dist/qualification-record.js.map +1 -1
  35. package/fixtures/clap-scan/fake.json +109 -7
  36. package/fixtures/probe-output/success-msi.json +11 -0
  37. package/package.json +2 -2
  38. package/src/clap-qualify.ts +177 -5
  39. package/src/host-profiles.ts +12 -2
  40. package/src/hosting-codes.ts +182 -77
  41. package/src/hosting-suitability.ts +25 -18
  42. package/src/measurement/cli.ts +21 -1
  43. package/src/measurement/format.ts +81 -3
  44. package/src/measurement/probe-json.ts +3 -0
  45. package/src/measurement/sweep-jsonl.ts +2 -1
  46. package/src/measurement/vocabulary.ts +148 -149
  47. package/src/probe-judge.ts +13 -5
  48. package/src/probe-output.ts +6 -0
  49. package/src/qualification-record.ts +9 -2
  50. package/tools/benchmark.py +104 -13
  51. package/tools/lv2-measure.mjs +2 -0
@@ -10,13 +10,41 @@
10
10
  * whether the plugin has nothing to measure or whether OUR PROBE failed — and each with
11
11
  * a plain description a UI can render.
12
12
  *
13
- * Everything is a data table, not a switch. Adding a term is one row; a consumer that
14
- * meets a term it does not know must be able to fall back on the `kind`, which is why the
15
- * readers below return a term record rather than a bare string.
13
+ * Everything is a data table, not a switch. A consumer that meets a term it does not know
14
+ * must be able to fall back on the `kind`, which is why the readers below return a term record
15
+ * rather than a bare string.
16
+ *
17
+ * The WORDS are omx-contract's (`data/measurement.json` and `data/resource-profile.json`, rendered
18
+ * as `<ITEM>_WORDS`), in its order: a word is added there, once, and every consumer reads it. What
19
+ * stays here is the prose, keyed by the contract's word type, so a word the contract adds without
20
+ * a description here, or a description for a word it lacks, is a type error.
16
21
  */
17
22
 
23
+ import {
24
+ MEASUREMENT_COST_METHODS_WORDS,
25
+ MEASUREMENT_DIMENSIONS_WORDS,
26
+ MEASUREMENT_LATENCY_EXCLUSIONS_WORDS,
27
+ MEASUREMENT_LATENCY_METHODS_WORDS,
28
+ MEASUREMENT_LATENCY_SCALING_NOTES_WORDS,
29
+ MEASUREMENT_LATENCY_SCALING_WORDS,
30
+ MEASUREMENT_UNMEASURED_KINDS_WORDS,
31
+ MEASUREMENT_UNMEASURED_REASONS,
32
+ MEASUREMENT_UNMEASURED_REASONS_WORDS,
33
+ MEASUREMENT_VERDICT_RATINGS_WORDS,
34
+ MEASUREMENT_VOICE_BASES_WORDS,
35
+ RESOURCE_HOST_ISOLATIONS_WORDS,
36
+ RESOURCE_HOST_LATENCY_HANDLING_WORDS,
37
+ } from "@openmixer/omx-contract";
18
38
  import { nonEmptyString } from "./guards.js";
19
39
 
40
+ /** Pairs each word, in the contract's order, with its description here. */
41
+ function described<Code extends string>(
42
+ words: readonly Code[],
43
+ descriptions: Readonly<Record<Code, string>>,
44
+ ): readonly { readonly code: Code; readonly description: string }[] {
45
+ return words.map((code) => ({ code, description: descriptions[code] }));
46
+ }
47
+
20
48
  // ---------------------------------------------------------------------------
21
49
  // Why a figure is absent
22
50
  // ---------------------------------------------------------------------------
@@ -35,105 +63,75 @@ import { nonEmptyString } from "./guards.js";
35
63
  * a finding nor a failure; it is the shape a PARTIAL run has, and partial runs are
36
64
  * first-class here.
37
65
  */
38
- export type UnmeasuredKind = "nothing-to-measure" | "probe-failed" | "not-attempted";
66
+ export type UnmeasuredKind = (typeof MEASUREMENT_UNMEASURED_KINDS_WORDS)[number];
67
+
68
+ /** One reason a figure may be absent: a word of the contract's MEASUREMENT_UNMEASURED_REASONS. */
69
+ export type UnmeasuredCode = (typeof MEASUREMENT_UNMEASURED_REASONS_WORDS)[number];
39
70
 
40
71
  /** One term of the "why is there no figure" vocabulary. */
41
72
  export interface UnmeasuredTerm {
42
- readonly code: string;
73
+ readonly code: UnmeasuredCode;
43
74
  readonly kind: UnmeasuredKind;
44
75
  /** Plain description, English. A UI localises from `code`; this is for logs and specs. */
45
76
  readonly description: string;
46
77
  }
47
78
 
48
79
  /**
49
- * Every reason a figure may be absent. These were derived from a sweep of 958 plugins — each
50
- * one is an outcome that sweep actually produced, not a hypothetical.
80
+ * Every reason a figure may be absent, in plain English. These were derived from a sweep of 958
81
+ * plugins — each one is an outcome that sweep actually produced, not a hypothetical. The words and
82
+ * their kinds are the contract's.
51
83
  */
52
- export const UNMEASURED_TERMS: readonly UnmeasuredTerm[] = [
53
- {
54
- code: "no-audio-in",
55
- kind: "nothing-to-measure",
56
- description: "No audio input port: an instrument, a generator or an analyser. Nothing to drive.",
57
- },
58
- {
59
- code: "no-audio-out",
60
- kind: "nothing-to-measure",
61
- description: "No audio output port: a meter or a sink. Nothing to read.",
62
- },
63
- {
64
- code: "silent-output",
65
- kind: "nothing-to-measure",
66
- description:
67
- "Instantiated and driven, but emitted nothing under a stimulus that should have passed. " +
84
+ const UNMEASURED_DESCRIPTIONS: Readonly<Record<UnmeasuredCode, string>> = {
85
+ "no-audio-in":
86
+ "No audio input port: an instrument, a generator or an analyser. Nothing to drive.",
87
+ "no-audio-out":
88
+ "No audio output port: a meter or a sink. Nothing to read.",
89
+ "silent-output":
90
+ "Instantiated and driven, but emitted nothing under a stimulus that should have passed. " +
68
91
  "A convolver with no impulse response, a vocoder with no carrier, a gate that never opened.",
69
- },
70
- {
71
- code: "needs-carrier",
72
- kind: "nothing-to-measure",
73
- description: "Requires a second audio input (a carrier or side-chain) the prober did not supply.",
74
- },
75
- {
76
- code: "needs-impulse-response",
77
- kind: "nothing-to-measure",
78
- description: "A convolver requiring an IR file the prober did not load.",
79
- },
80
- {
81
- code: "unpinnable",
82
- kind: "nothing-to-measure",
83
- description:
84
- "The files the host loads cannot be pinned (plugin-qualify spec §11.1): a path holding ',', '=' or " +
92
+ "needs-carrier":
93
+ "Requires a second audio input (a carrier or side-chain) the prober did not supply.",
94
+ "needs-impulse-response":
95
+ "A convolver requiring an IR file the prober did not load.",
96
+ "unpinnable":
97
+ "The files the host loads cannot be pinned (plugin-qualify spec §11.1): a path holding ',', '=' or " +
85
98
  "whitespace, a manifest using @base or a percent-encoded IRI, or a CLAP plugin with more parameters than the " +
86
99
  "host accepts. The plugin stays unpinned, never partly pinned.",
87
- },
88
- {
89
- code: "refused-instantiation",
90
- kind: "probe-failed",
91
- description: "The plugin would not instantiate at this rate or block size.",
92
- },
93
- {
94
- code: "probe-crashed",
95
- kind: "probe-failed",
96
- description: "The probe worker died (signal or abort) while hosting this plugin.",
97
- },
98
- {
99
- code: "probe-timeout",
100
- kind: "probe-failed",
101
- description: "The probe exceeded its per-plugin time limit and was abandoned.",
102
- },
103
- {
104
- code: "non-finite-output",
105
- kind: "probe-failed",
106
- description: "Output contained NaN or infinity, so no onset or block time could be trusted.",
107
- },
108
- {
109
- code: "not-installed",
110
- kind: "probe-failed",
111
- description:
112
- "The URI was not present in this machine's LV2 world. A fact about this installation, " +
100
+ "no-voices":
101
+ "An instrument whose voice-info declares a voice capacity of 0: there is no voice to hold down, so its " +
102
+ "cost with notes held cannot be taken.",
103
+ "notes-unplayable":
104
+ "An instrument whose note input reads no dialect the prober speaks (CLAP's or MIDI's): it cannot be played.",
105
+ "voices-unknown":
106
+ "An instrument that declares no voice capacity, measured by a run given no probe count (`--probe-notes`): " +
107
+ "the run did not know how many notes to hold, so it timed none.",
108
+ "refused-instantiation":
109
+ "The plugin would not instantiate at this rate or block size.",
110
+ "probe-crashed":
111
+ "The probe worker died (signal or abort) while hosting this plugin.",
112
+ "probe-timeout":
113
+ "The probe exceeded its per-plugin time limit and was abandoned.",
114
+ "non-finite-output":
115
+ "Output contained NaN or infinity, so no onset or block time could be trusted.",
116
+ "not-installed":
117
+ "The URI was not present in this machine's LV2 world. A fact about this installation, " +
113
118
  "not about the plugin — another host may well have it.",
114
- },
115
- {
116
- code: "worker-nonconforming",
117
- kind: "probe-failed",
118
- description:
119
- "The plugin's LV2_Worker_Interface leaves work or work_response NULL, which the LV2 worker " +
119
+ "worker-nonconforming":
120
+ "The plugin's LV2_Worker_Interface leaves work or work_response NULL, which the LV2 worker " +
120
121
  "specification does not allow: it was refused before any call, not measured.",
121
- },
122
- {
123
- code: "probe-error",
124
- kind: "probe-failed",
125
- description:
126
- "The probe reported a failure with no more specific term. Its own words are kept verbatim " +
122
+ "probe-error":
123
+ "The probe reported a failure with no more specific term. Its own words are kept verbatim " +
127
124
  "in the measurement's `unmeasuredDetail`, so a coarse code never destroys the detail.",
128
- },
129
- {
130
- code: "not-attempted",
131
- kind: "not-attempted",
132
- description: "This run did not cover this plugin for this dimension. A partial run, not a finding.",
133
- },
134
- ];
125
+ "not-attempted":
126
+ "This run did not cover this plugin for this dimension. A partial run, not a finding.",
127
+ };
128
+
129
+ /** Every reason a figure may be absent, in the contract's order, of the contract's kind. */
130
+ export const UNMEASURED_TERMS: readonly UnmeasuredTerm[] = Object.values(MEASUREMENT_UNMEASURED_REASONS).map(
131
+ (reason) => ({ code: reason.word, kind: reason.group, description: UNMEASURED_DESCRIPTIONS[reason.word] }),
132
+ );
135
133
 
136
- const UNMEASURED_BY_CODE: ReadonlyMap<string, UnmeasuredTerm> = new Map(
134
+ const UNMEASURED_BY_CODE: ReadonlyMap<string, UnmeasuredTerm> = new Map<string, UnmeasuredTerm>(
137
135
  UNMEASURED_TERMS.map((term) => [term.code, term]),
138
136
  );
139
137
 
@@ -173,25 +171,19 @@ export interface ScalingTerm {
173
171
  readonly description: string;
174
172
  }
175
173
 
176
- export const SCALING_TERMS: readonly ScalingTerm[] = [
177
- {
178
- code: "fixed-frame",
179
- description: "Constant frame count at every rate — an FFT window. Milliseconds halve when the rate doubles.",
180
- },
181
- {
182
- code: "fixed-time",
183
- description: "Constant milliseconds at every rate — a look-ahead. Frames double when the rate doubles.",
184
- },
185
- { code: "zero", description: "No latency a mix can notice at any measured rate." },
186
- {
187
- code: "nonconforming",
188
- description:
189
- "Fits neither law at a magnitude that reaches a mix: the plugin changes what it does with " +
190
- "the rate. Flagged rather than averaged; nothing may be projected from it.",
191
- },
192
- { code: "single-rate", description: "One usable reading, so no law can be fitted." },
193
- { code: "unmeasured", description: "No rate produced a figure." },
194
- ];
174
+ /** A word of the contract's MEASUREMENT_LATENCY_SCALING. */
175
+ export type ScalingCode = (typeof MEASUREMENT_LATENCY_SCALING_WORDS)[number];
176
+
177
+ export const SCALING_TERMS: readonly ScalingTerm[] = described(MEASUREMENT_LATENCY_SCALING_WORDS, {
178
+ "fixed-frame": "Constant frame count at every rate — an FFT window. Milliseconds halve when the rate doubles.",
179
+ "fixed-time": "Constant milliseconds at every rate — a look-ahead. Frames double when the rate doubles.",
180
+ zero: "No latency a mix can notice at any measured rate.",
181
+ nonconforming:
182
+ "Fits neither law at a magnitude that reaches a mix: the plugin changes what it does with " +
183
+ "the rate. Flagged rather than averaged; nothing may be projected from it.",
184
+ "single-rate": "One usable reading, so no law can be fitted.",
185
+ unmeasured: "No rate produced a figure.",
186
+ });
195
187
 
196
188
  const SCALING_CODES: ReadonlySet<string> = new Set(SCALING_TERMS.map((t) => t.code));
197
189
 
@@ -206,60 +198,55 @@ export function isKnownScalingCode(code: string): boolean {
206
198
  * detector's rather than the plugin's, and `informational-rate`, where only a reading below
207
199
  * the operational rates breaks the law (`lv2-measurement-interchange.md` §1a).
208
200
  */
209
- export const SCALING_NOTE_TERMS = [
210
- {
211
- code: "below-budget",
212
- description:
213
- "The readings fit no law, but every one of them is inside the per-channel latency budget, " +
214
- "so the disagreement is measurement noise rather than plugin behaviour.",
215
- },
216
- {
217
- code: "informational-rate",
218
- description:
219
- "The readings together fit no law, but those at the operational rates (48 kHz and up) do; " +
220
- "the reading below them stays in perRate as information and does not flag the plugin.",
221
- },
222
- ] as const satisfies readonly ScalingTerm[];
201
+ export const SCALING_NOTE_TERMS = described(MEASUREMENT_LATENCY_SCALING_NOTES_WORDS, {
202
+ "below-budget":
203
+ "The readings fit no law, but every one of them is inside the per-channel latency budget, " +
204
+ "so the disagreement is measurement noise rather than plugin behaviour.",
205
+ "informational-rate":
206
+ "The readings together fit no law, but those at the operational rates (48 kHz and up) do; " +
207
+ "the reading below them stays in perRate as information and does not flag the plugin.",
208
+ });
223
209
 
224
210
  /** One of the {@link SCALING_NOTE_TERMS} codes — the words `latency.scalingNote` may carry. */
225
- export type ScalingNoteCode = (typeof SCALING_NOTE_TERMS)[number]["code"];
211
+ export type ScalingNoteCode = (typeof MEASUREMENT_LATENCY_SCALING_NOTES_WORDS)[number];
226
212
 
227
213
  /**
228
214
  * Why one rate's reading was excluded from the fit and from every rate resolution. A
229
215
  * failed reading served as an exact answer is worse than no reading, because a zero reads
230
216
  * as confidence.
231
217
  */
232
- export const EXCLUSION_TERMS: readonly ScalingTerm[] = [
233
- {
234
- code: "impossible-zero",
235
- description:
236
- "Zero frames at this rate while another rate measured non-zero. Both scaling laws map zero " +
237
- "to zero, so no single latency produces this; the onset detector was defeated.",
238
- },
239
- ];
218
+ export const EXCLUSION_TERMS: readonly ScalingTerm[] = described(MEASUREMENT_LATENCY_EXCLUSIONS_WORDS, {
219
+ "impossible-zero":
220
+ "Zero frames at this rate while another rate measured non-zero. Both scaling laws map zero " +
221
+ "to zero, so no single latency produces this; the onset detector was defeated.",
222
+ });
240
223
 
241
224
  // ---------------------------------------------------------------------------
242
225
  // How a figure was taken
243
226
  // ---------------------------------------------------------------------------
244
227
 
245
- /** The measurement methods the format has names for. */
228
+ /** How a latency figure was taken: a word of the contract's MEASUREMENT_LATENCY_METHODS. */
229
+ export type LatencyMethodCode = (typeof MEASUREMENT_LATENCY_METHODS_WORDS)[number];
230
+
231
+ /** How a cost figure was taken: a word of the contract's MEASUREMENT_COST_METHODS. */
232
+ export type CostMethodCode = (typeof MEASUREMENT_COST_METHODS_WORDS)[number];
233
+
234
+ /** The measurement methods the format has names for: the latency methods, then the cost methods. */
246
235
  export const METHOD_TERMS: readonly ScalingTerm[] = [
247
- {
248
- code: "impulse-onset",
249
- description:
236
+ ...described(MEASUREMENT_LATENCY_METHODS_WORDS, {
237
+ "impulse-onset":
250
238
  "Latency as the round-trip delay from a unit impulse to the first output sample above the " +
251
239
  "residual floor. Measures the plugin, not what it declares.",
252
- },
253
- {
254
- code: "declared-port",
255
- description: "Latency read from the plugin's own lv2:latency output port. What it CLAIMS.",
256
- },
257
- {
258
- code: "block-time-percentile",
259
- description:
240
+ "declared-port": "Latency read from the plugin's own lv2:latency output port. What it CLAIMS.",
241
+ "note-onset":
242
+ "An instrument's latency: the delay from a note on to the first output sample above the " +
243
+ "residual floor. Measures what the plugin does with a held note.",
244
+ }),
245
+ ...described(MEASUREMENT_COST_METHODS_WORDS, {
246
+ "block-time-percentile":
260
247
  "Cost as a percentile of per-block wall time under a sustained stimulus, warm-up blocks " +
261
248
  "discarded, normalised to nanoseconds per sample.",
262
- },
249
+ }),
263
250
  ];
264
251
 
265
252
  const METHOD_CODES: ReadonlySet<string> = new Set(METHOD_TERMS.map((t) => t.code));
@@ -276,7 +263,7 @@ export function isKnownMethodCode(code: string): boolean {
276
263
  * hosting verdict — whether a plugin survives, whether its `run()` is RT-clean, what it
277
264
  * demands of a host — and merge under exactly the same per-plugin, per-DIMENSION precedence.
278
265
  */
279
- export const MEASUREMENT_DIMENSIONS = ["latency", "cost", "stability", "rtSafety", "features"] as const;
266
+ export const MEASUREMENT_DIMENSIONS = MEASUREMENT_DIMENSIONS_WORDS;
280
267
 
281
268
  /**
282
269
  * The dimensions the standalone prober (`scan.py` + `benchmark.py`, driven by `lv2-measure.mjs`
@@ -302,18 +289,30 @@ export function readDimension(value: unknown): MeasurementDimension | undefined
302
289
  }
303
290
 
304
291
  /**
305
- * The four ratings a verdict speaks (`lv2-measurement-interchange.md` §3b) — the ONE list; the
306
- * rating vocabulary (`rating.ts`) derives its type from it, so the document and the classifier
307
- * can never disagree on a word.
292
+ * The four ratings a verdict speaks (`lv2-measurement-interchange.md` §3b): the contract's
293
+ * MEASUREMENT_VERDICT_RATINGS. The rating vocabulary (`rating.ts`) derives its type from it, so
294
+ * the document and the classifier can never disagree on a word.
308
295
  */
309
- export const VERDICT_RATINGS = ["suitable", "conditional", "unknown", "unsuitable"] as const;
296
+ export const VERDICT_RATINGS = MEASUREMENT_VERDICT_RATINGS_WORDS;
310
297
  export type VerdictRating = (typeof VERDICT_RATINGS)[number];
311
298
 
312
- /** How a host runs its plugins. */
313
- export const HOST_ISOLATIONS = ["in-process", "per-process", "shared-process"] as const;
299
+ /** How a host runs its plugins: the contract's RESOURCE_HOST_ISOLATIONS. */
300
+ export const HOST_ISOLATIONS = RESOURCE_HOST_ISOLATIONS_WORDS;
314
301
  export type HostIsolation = (typeof HOST_ISOLATIONS)[number];
315
302
 
316
- /** Whether a host aligns on a plugin's reported latency (`compensated`) or only passes it on. */
317
- export const HOST_LATENCY_HANDLING = ["compensated", "uncompensated"] as const;
303
+ /** Whether a host aligns on a plugin's reported latency (`compensated`) or only passes it on: the contract's RESOURCE_HOST_LATENCY_HANDLING. */
304
+ export const HOST_LATENCY_HANDLING = RESOURCE_HOST_LATENCY_HANDLING_WORDS;
318
305
  export type HostLatencyHandling = (typeof HOST_LATENCY_HANDLING)[number];
319
306
 
307
+ /**
308
+ * Where the number of notes an instrument's cost was measured with came from: `declared` is the voice
309
+ * capacity the plugin declares (CLAP's voice-info, read after activate at that rate); `probe` is a count
310
+ * the caller gave (`--probe-notes`, the console's `SYNTH_VOICE_PROBE_NOTES`), used when the plugin declares
311
+ * none or when the caller asked for that count whatever it declares.
312
+ */
313
+ export const VOICE_BASES = MEASUREMENT_VOICE_BASES_WORDS;
314
+ export type VoiceBasis = (typeof VOICE_BASES)[number];
315
+
316
+ export function isVoiceBasis(value: unknown): value is VoiceBasis {
317
+ return VOICE_BASES.some((b) => b === value);
318
+ }
@@ -27,6 +27,7 @@ import {
27
27
  import { gridCostReadings } from "./cost-fields.js";
28
28
  import {
29
29
  hostingCostReasons,
30
+ noteSweepReasons,
30
31
  rtSafetyReasons,
31
32
  contributedHostingRating,
32
33
  type HostingReason,
@@ -35,7 +36,7 @@ import type { HostProfile } from "./host-profiles.js";
35
36
  import { PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
36
37
  import type { QualifyVerdict } from "./qualify-cli.js";
37
38
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
38
- import type { IntegrityMeasurement } from "./measurement/format.js";
39
+ import type { IntegrityMeasurement, VoicesHeld } from "./measurement/format.js";
39
40
 
40
41
  /** Facts of the descriptor, which the probe output repeats none of. */
41
42
  export interface JudgeFacts {
@@ -168,8 +169,11 @@ export interface HostJudgment {
168
169
  /** In-process only for a suitable plugin on a host that runs its plugins in its own process, else isolated. */
169
170
  readonly hosting: "in-process" | "isolated";
170
171
  readonly reasons: readonly RecordReason[];
171
- /** The cost reasons at this host's rate and quantum, kept beside the verdict, not folded into it. */
172
- readonly cost: { readonly rate: number; readonly quantum: number; readonly reasons: readonly RecordReason[] };
172
+ /**
173
+ * The cost reasons at this host's rate and quantum, kept beside the verdict, not folded into it; for an instrument,
174
+ * the notes it held while that figure was timed.
175
+ */
176
+ readonly cost: { readonly rate: number; readonly quantum: number; readonly voices?: VoicesHeld; readonly reasons: readonly RecordReason[] };
173
177
  }
174
178
 
175
179
  export interface Judgment {
@@ -191,7 +195,8 @@ function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"]
191
195
  { rate, quantum, profile },
192
196
  DEFAULT_HOSTING_POLICY,
193
197
  );
194
- return { rate, quantum, reasons: reasons.map(asRecordReason) };
198
+ const voices = output.grid.find((p) => p.outcome === PROBE_OK && p.rate === rate && p.quantum === quantum)?.cost?.voices;
199
+ return { rate, quantum, ...(voices === undefined ? {} : { voices }), reasons: reasons.map(asRecordReason) };
195
200
  }
196
201
 
197
202
  /**
@@ -237,7 +242,10 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
237
242
  documents.push(doc);
238
243
  reasons = doc.verdict.reasons.map(asRecordReason);
239
244
  } else {
240
- reasons = rtSafetyReasons(rtSafetyOfProbeOutput(output)).map(asRecordReason);
245
+ // an LV2 plugin: the control sweep's counts, and the note sweep's when it was played through a MIDI input
246
+ const rt = rtSafetyOfProbeOutput(output);
247
+ const notes = noteSweepOfProbeOutput(output, rt);
248
+ reasons = [...rtSafetyReasons(rt), ...noteSweepReasons(notes, facts.noteInputs ?? (notes.applicable ? 1 : 0))].map(asRecordReason);
241
249
  }
242
250
  const rating = worstRating(reasons.map((r) => r.rating));
243
251
  const deciding = decidingOf(reasons, rating);
@@ -20,6 +20,7 @@
20
20
  import { createHash } from "node:crypto";
21
21
 
22
22
  import { finiteNumber, isRecord, nonEmptyString, optional } from "./measurement/guards.js";
23
+ import { readVoicesHeld, type VoicesHeld } from "./measurement/format.js";
23
24
 
24
25
  export const PROBE_OUTPUT_FORMAT = "plugin-hostd.probe-output";
25
26
  /** The one major this reader knows. A minor of it only adds fields. */
@@ -60,6 +61,8 @@ export interface ProbeCost {
60
61
  readonly percentile: { readonly p: number; readonly value: number };
61
62
  readonly blocks: number;
62
63
  readonly warmupBlocks: number;
64
+ /** For an instrument: the notes it held while it was timed, how many and why that many. */
65
+ readonly voices?: VoicesHeld;
63
66
  }
64
67
 
65
68
  export interface ProbeRtSafety {
@@ -240,11 +243,14 @@ function readCost(v: unknown, where: string): ProbeCost {
240
243
  const median = num(o, "median", where);
241
244
  const value = num(pct, "value", `${where} percentile`);
242
245
  if (median < 0 || value < 0) invalid(`${where} holds a negative cost`);
246
+ const voices = o["voices"] === undefined ? undefined : readVoicesHeld(o["voices"]);
247
+ if (o["voices"] !== undefined && voices === undefined) invalid(`${where} voices is not a count of held notes with its basis`);
243
248
  return {
244
249
  median,
245
250
  percentile: { p, value },
246
251
  blocks: posInt(o, "blocks", where),
247
252
  warmupBlocks: count(o, "warmupBlocks", where),
253
+ ...(voices === undefined ? {} : { voices }),
248
254
  };
249
255
  }
250
256
 
@@ -18,6 +18,7 @@ import { join } from "node:path";
18
18
 
19
19
  import type { Judgment, RecordReason } from "./probe-judge.js";
20
20
  import { sha256Hex, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
21
+ import type { VoicesHeld } from "./measurement/format.js";
21
22
 
22
23
  export const QUALIFICATION_RECORD_SCHEMA = "plugin-qualify.qualification-record/1";
23
24
 
@@ -41,8 +42,14 @@ export interface QualificationRecord {
41
42
  readonly testedVersion?: string;
42
43
  readonly binary: ProbeOutput["binary"];
43
44
  readonly verdicts: readonly RecordVerdict[];
44
- /** The cost reasons at each host's rate and quantum, beside the verdicts. */
45
- readonly cost: readonly { readonly host: string; readonly rate: number; readonly quantum: number; readonly reasons: readonly RecordReason[] }[];
45
+ /** The cost reasons at each host's rate and quantum, beside the verdicts; an instrument's with the notes it held. */
46
+ readonly cost: readonly {
47
+ readonly host: string;
48
+ readonly rate: number;
49
+ readonly quantum: number;
50
+ readonly voices?: VoicesHeld;
51
+ readonly reasons: readonly RecordReason[];
52
+ }[];
46
53
  /** The figures as measured, per rate and quantum. */
47
54
  readonly measurements: { readonly grid: readonly ProbeGridPoint[]; readonly behaviour?: Readonly<Record<string, unknown>> };
48
55
  /** Who measured and when. */