@openmixer/plugin-qualify 0.11.0 → 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 (41) hide show
  1. package/README.md +6 -0
  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/hosting-suitability.d.ts +11 -2
  6. package/dist/hosting-suitability.js +14 -3
  7. package/dist/hosting-suitability.js.map +1 -1
  8. package/dist/measurement/cli.d.ts +7 -0
  9. package/dist/measurement/cli.js +14 -0
  10. package/dist/measurement/cli.js.map +1 -1
  11. package/dist/measurement/format.d.ts +44 -4
  12. package/dist/measurement/format.js +46 -3
  13. package/dist/measurement/format.js.map +1 -1
  14. package/dist/measurement/probe-json.js +3 -0
  15. package/dist/measurement/probe-json.js.map +1 -1
  16. package/dist/measurement/sweep-jsonl.js +2 -1
  17. package/dist/measurement/sweep-jsonl.js.map +1 -1
  18. package/dist/measurement/vocabulary.d.ts +9 -0
  19. package/dist/measurement/vocabulary.js +27 -0
  20. package/dist/measurement/vocabulary.js.map +1 -1
  21. package/dist/probe-judge.d.ts +6 -1
  22. package/dist/probe-judge.js +7 -3
  23. package/dist/probe-judge.js.map +1 -1
  24. package/dist/probe-output.d.ts +3 -0
  25. package/dist/probe-output.js +5 -0
  26. package/dist/probe-output.js.map +1 -1
  27. package/dist/qualification-record.d.ts +3 -1
  28. package/dist/qualification-record.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/clap-qualify.ts +177 -5
  31. package/src/hosting-suitability.ts +21 -4
  32. package/src/measurement/cli.ts +20 -0
  33. package/src/measurement/format.ts +80 -2
  34. package/src/measurement/probe-json.ts +3 -0
  35. package/src/measurement/sweep-jsonl.ts +2 -1
  36. package/src/measurement/vocabulary.ts +31 -0
  37. package/src/probe-judge.ts +13 -5
  38. package/src/probe-output.ts +6 -0
  39. package/src/qualification-record.ts +9 -2
  40. package/tools/benchmark.py +104 -13
  41. package/tools/lv2-measure.mjs +2 -0
@@ -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,7 @@ 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" },
58
60
  { match: /hosting\.worker\.nonconforming/, code: "worker-nonconforming" },
59
61
  { match: /worker crashed|worker desync|worker unavailable/, code: "probe-crashed" },
60
62
  { match: /timed out|timeout/, code: "probe-timeout" },
@@ -198,6 +200,7 @@ function readCost(value: unknown): CostMeasurement | undefined {
198
200
  ...optional("nsPerSampleMax", finiteNumber(reading.nsPerSampleMax)),
199
201
  ...optional("warmupNsPerSampleMedian", finiteNumber(reading.warmupNsPerSampleMedian)),
200
202
  ...optional("silentOutput", booleanValue(reading.silentOutput)),
203
+ ...optional("voices", readVoicesHeld(reading.voices)),
201
204
  };
202
205
  }
203
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",
@@ -317,3 +336,15 @@ export type HostIsolation = (typeof HOST_ISOLATIONS)[number];
317
336
  export const HOST_LATENCY_HANDLING = ["compensated", "uncompensated"] as const;
318
337
  export type HostLatencyHandling = (typeof HOST_LATENCY_HANDLING)[number];
319
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
+ }
@@ -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. */
@@ -179,6 +179,14 @@ COST_PERCENTILE = 95.0
179
179
  DEFAULT_COST_TIMEOUT = float(COST_TIMEOUT_S)
180
180
  # Iterations of the empty-call loop that measures the harness's own timing floor.
181
181
  COST_CALIBRATION_ITERS = 512
182
+ # An instrument (a MIDI input, and no audio input or the lv2:InstrumentPlugin class) is timed
183
+ # holding notes down, since it costs nothing until a voice plays: `--probe-notes` notes, struck at
184
+ # velocity 127 in the first block and never let go, spread across a piano's keys (every MIDI key
185
+ # past 88). LV2 declares no voice capacity, so the count is always the caller's: basis `probe`. The
186
+ # same rule as the CLAP prober's cost arm (`native/clap-qualify.c`, `held_key`).
187
+ NOTE_KEYS_MAX = 128
188
+ PIANO_LOW_KEY, PIANO_HIGH_KEY, PIANO_KEYS = 21, 108, 88
189
+ HELD_VELOCITY = 127
182
190
 
183
191
  # What measure_offline returns: measured latencies, the plugins that failed (each
184
192
  # with a human reason) so one finicky plugin never silently drops the batch,
@@ -858,6 +866,32 @@ def instances_per_core(fraction):
858
866
  return int(1.0 / float(fraction))
859
867
 
860
868
 
869
+ def held_keys(n):
870
+ """The `n` keys an instrument's cost holds down: spread evenly across a piano's keys, or every
871
+ MIDI key past 88 notes; one note is A4. Pure, and the CLAP prober's `held_key` rule."""
872
+ n = int(n)
873
+ if n <= 1:
874
+ return [69] if n == 1 else []
875
+ low, high = (PIANO_LOW_KEY, PIANO_HIGH_KEY) if n <= PIANO_KEYS else (0, NOTE_KEYS_MAX - 1)
876
+ return [low + i * (high - low) // (n - 1) for i in range(n)]
877
+
878
+
879
+ def midi_sequence(events, seq_urid, midi_urid):
880
+ """An LV2 atom:Sequence holding `events`, each `(frame, bytes)` of one MIDI message, as the
881
+ little-endian bytes a host writes into an atom input port. Pure.
882
+
883
+ The layout is the atom spec's: the sequence atom (size, type), its body (unit 0 = frames, pad),
884
+ then per event its time (int64 frames), the event atom (size, type) and the message padded to 8.
885
+ """
886
+ import struct
887
+
888
+ body = struct.pack("<II", 0, 0)
889
+ for frame, data in events:
890
+ msg = bytes(data)
891
+ body += struct.pack("<qII", int(frame), len(msg), midi_urid) + msg + b"\0" * (-len(msg) % 8)
892
+ return struct.pack("<II", len(body), seq_urid) + body
893
+
894
+
861
895
  def summarise_cost(block_times_ns, block_frames, rate, warmup=DEFAULT_COST_WARMUP,
862
896
  pct=COST_PERCENTILE):
863
897
  """Fold per-block wall times into one rate's cost record. Pure.
@@ -1081,6 +1115,7 @@ def annotate_measured(catalog, measured, unmeasurable, sample_rate, tol=2):
1081
1115
 
1082
1116
  LV2 = "http://lv2plug.in/ns/lv2core#"
1083
1117
  ATOM = "http://lv2plug.in/ns/ext/atom#"
1118
+ MIDI_EVENT = "http://lv2plug.in/ns/ext/midi#MidiEvent"
1084
1119
  URID = "http://lv2plug.in/ns/ext/urid#"
1085
1120
  OPTIONS = "http://lv2plug.in/ns/ext/options#"
1086
1121
  BUFSZ = "http://lv2plug.in/ns/ext/buf-size#"
@@ -1383,6 +1418,23 @@ def audio_port_indices(host, plugin):
1383
1418
  return audio_in, audio_out
1384
1419
 
1385
1420
 
1421
+ def is_instrument_class(host, plugin):
1422
+ """True when the plugin's class is lv2:InstrumentPlugin."""
1423
+ cls = plugin.get_class()
1424
+ return cls is not None and str(cls.get_uri()) == LV2 + "InstrumentPlugin"
1425
+
1426
+
1427
+ def midi_input_index(host, plugin):
1428
+ """The index of the plugin's first atom input that takes MIDI events, or None."""
1429
+ midi = host.world.new_uri(MIDI_EVENT)
1430
+ for i in range(plugin.get_num_ports()):
1431
+ port = plugin.get_port_by_index(i)
1432
+ if (port.is_a(host.u[ATOM + "AtomPort"]) and port.is_a(host.u[LV2 + "InputPort"])
1433
+ and port.supports_event(midi)):
1434
+ return i
1435
+ return None
1436
+
1437
+
1386
1438
  def instantiate_connected(host, plugin, overrides=None):
1387
1439
  """Instantiate `plugin` and connect a live buffer to every one of its ports.
1388
1440
 
@@ -1589,7 +1641,7 @@ def timing_floor_ns(iters=COST_CALIBRATION_ITERS):
1589
1641
 
1590
1642
 
1591
1643
  def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOCKS,
1592
- min_blocks=COST_MIN_BLOCKS, target_seconds=COST_TARGET_SECONDS):
1644
+ min_blocks=COST_MIN_BLOCKS, target_seconds=COST_TARGET_SECONDS, probe_notes=None):
1593
1645
  """Instantiate one plugin offline and MEASURE its CPU cost per processed sample.
1594
1646
 
1595
1647
  A sustained 1 kHz sine at -6 dBFS is held on every audio input, phase-continuous
@@ -1601,13 +1653,18 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1601
1653
  the gap between them is the xrun risk. Signal, not silence: a plugin that
1602
1654
  short-circuits on a silent input would otherwise be recorded as free.
1603
1655
 
1656
+ An instrument (a MIDI atom input, and no audio input or the lv2:InstrumentPlugin class) also
1657
+ holds `probe_notes` notes down ({@link held_keys}, velocity 127), struck in the first block and
1658
+ never let go, and the record says so (`voices: {held, basis: "probe"}`). Without a count it is
1659
+ not timed. A synth's audio input (synthv1 has one) hears the tone like any other.
1660
+
1604
1661
  Buffer filling and every statistic are outside the timed region; the GC is off
1605
1662
  across the loop, so a collection cannot land inside a block and be charged to
1606
1663
  the plugin.
1607
1664
 
1608
1665
  Returns exactly one of:
1609
- {"blockTimesNs": [...], "silentOutput": bool} timings, warm-up head first
1610
- {"unmeasurable": <reason>} no audio in/out — nothing to insert, nothing to cost
1666
+ {"blockTimesNs": [...], "silentOutput": bool[, "voices": {...}]} timings, warm-up head first
1667
+ {"unmeasurable": <reason>} no audio in/out, or an instrument with no count to hold
1611
1668
  {"error": <reason>} not installed / refused / non-finite output
1612
1669
  """
1613
1670
  import gc
@@ -1618,14 +1675,26 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1618
1675
  if plugin is None:
1619
1676
  return {"error": "not installed"}
1620
1677
  audio_in, audio_out = audio_port_indices(host, plugin)
1621
- if not audio_in:
1678
+ midi_in = midi_input_index(host, plugin)
1679
+ if midi_in is not None and audio_in and not is_instrument_class(host, plugin):
1680
+ midi_in = None # an effect that also takes MIDI: timed on the tone alone
1681
+ if not audio_in and midi_in is None:
1622
1682
  return {"unmeasurable": "no-audio-in"}
1623
1683
  if not audio_out:
1624
1684
  return {"unmeasurable": "no-audio-out"}
1685
+ if midi_in is not None and not probe_notes:
1686
+ return {"unmeasurable": "voices-unknown"}
1687
+ held = held_keys(min(int(probe_notes), NOTE_KEYS_MAX)) if midi_in is not None else []
1625
1688
 
1626
1689
  instance, buffers, reason = instantiate_connected(host, plugin)
1627
1690
  if instance is None:
1628
1691
  return {"error": reason}
1692
+ atoms = [(i, buf, buf[0]) for i, buf in buffers.items() if buf.dtype == np.uint32]
1693
+ struck = empty = None
1694
+ if midi_in is not None:
1695
+ seq, midi = host.seq_urid, host.map_uri(MIDI_EVENT)
1696
+ struck = np.frombuffer(midi_sequence([(0, (0x90, k, HELD_VELOCITY)) for k in held], seq, midi), dtype=np.uint32)
1697
+ empty = np.frombuffer(midi_sequence([], seq, midi), dtype=np.uint32)
1629
1698
 
1630
1699
  block = host.block
1631
1700
  ins = [buffers[i] for i in audio_in]
@@ -1636,7 +1705,13 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1636
1705
  ramp = np.arange(block, dtype=np.float32)
1637
1706
 
1638
1707
  def drive(blk):
1639
- """Hold the tone on every audio input for block `blk`. Never timed."""
1708
+ """Hold the tone on every audio input for block `blk`; for an instrument, strike the held notes
1709
+ in the first block and send none after; every atom port a fresh sequence. Never timed."""
1710
+ for _i, buf, size in atoms:
1711
+ buf[0] = size
1712
+ if midi_in is not None:
1713
+ notes = struck if blk == 0 else empty
1714
+ buffers[midi_in][:len(notes)] = notes
1640
1715
  sig = COST_TONE_AMP * np.sin(phase * (blk * block + ramp))
1641
1716
  for buf in ins:
1642
1717
  buf[:] = sig
@@ -1679,9 +1754,12 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1679
1754
 
1680
1755
  if not np.all(np.isfinite(out_buf)):
1681
1756
  return {"error": "non-finite output"}
1682
- return {"blockTimesNs": times,
1683
- "silentOutput": bool(np.max(np.abs(out_buf)) == 0.0),
1684
- "worker": host.worker.stats()}
1757
+ res = {"blockTimesNs": times,
1758
+ "silentOutput": bool(np.max(np.abs(out_buf)) == 0.0),
1759
+ "worker": host.worker.stats()}
1760
+ if midi_in is not None:
1761
+ res["voices"] = {"held": len(held), "basis": "probe"}
1762
+ return res
1685
1763
 
1686
1764
 
1687
1765
  def control_ranges(host, plugin, symbols):
@@ -1753,7 +1831,7 @@ def threshold_overrides(host, plugin):
1753
1831
 
1754
1832
 
1755
1833
  def worker_main(rate, block, measure=False, cost=False, cpu=None,
1756
- warmup=DEFAULT_COST_WARMUP, blocks=DEFAULT_COST_BLOCKS):
1834
+ warmup=DEFAULT_COST_WARMUP, blocks=DEFAULT_COST_BLOCKS, probe_notes=None):
1757
1835
  """The worker loop: JSON-lines protocol over stdio (see parse_worker_line).
1758
1836
 
1759
1837
  Loads the LV2 world once, announces readiness, then probes one plugin per
@@ -1784,7 +1862,7 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1784
1862
  uri, _tab, arg = line.partition("\t")
1785
1863
  if cost:
1786
1864
  try:
1787
- res = cost_one(host, uri, warmup=warmup, max_blocks=blocks)
1865
+ res = cost_one(host, uri, warmup=warmup, max_blocks=blocks, probe_notes=probe_notes)
1788
1866
  except Exception as e: # noqa: BLE001 - report, don't die
1789
1867
  res = {"error": f"cost error: {e}"}
1790
1868
  if "blockTimesNs" in res:
@@ -1796,6 +1874,8 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1796
1874
  continue
1797
1875
  if res.get("silentOutput"):
1798
1876
  record["silentOutput"] = True
1877
+ if "voices" in res:
1878
+ record["voices"] = res["voices"]
1799
1879
  # what the host did with the plugin's worker, so a cost read can be told apart
1800
1880
  # from an unbuilt plugin's (jobs and dropped are counts, never a verdict).
1801
1881
  if "worker" in res:
@@ -2179,13 +2259,15 @@ def host_provenance(cpu, rates, block, warmup, blocks, timing_floor_ns_value=Non
2179
2259
  }
2180
2260
 
2181
2261
 
2182
- def default_cost_worker_cmd(rate, block, cpu, warmup, blocks):
2262
+ def default_cost_worker_cmd(rate, block, cpu, warmup, blocks, probe_notes=None):
2183
2263
  """This same script, in --worker --cost mode, under the same interpreter."""
2184
2264
  cmd = [sys.executable, os.path.abspath(__file__), "--worker", "--cost",
2185
2265
  "--rate", str(rate), "--block", str(block),
2186
2266
  "--cost-warmup", str(warmup), "--cost-blocks", str(blocks)]
2187
2267
  if cpu is not None:
2188
2268
  cmd += ["--cost-cpu", str(cpu)]
2269
+ if probe_notes:
2270
+ cmd += ["--probe-notes", str(probe_notes)]
2189
2271
  return cmd
2190
2272
 
2191
2273
 
@@ -2198,7 +2280,8 @@ def cost_at_rate(catalog, rate, args, cpu):
2198
2280
  """
2199
2281
  worker_cmd = (shlex.split(args.worker_cmd) if args.worker_cmd
2200
2282
  else default_cost_worker_cmd(rate, args.cost_block, cpu,
2201
- args.cost_warmup, args.cost_blocks))
2283
+ args.cost_warmup, args.cost_blocks,
2284
+ args.probe_notes))
2202
2285
  # a cost probe runs hundreds of blocks where a latency probe runs a handful, so
2203
2286
  # the latency default would time out honest work; an explicit --timeout wins.
2204
2287
  timeout = DEFAULT_COST_TIMEOUT if args.timeout == DEFAULT_TIMEOUT else args.timeout
@@ -2504,6 +2587,11 @@ def main():
2504
2587
  ap.add_argument("--cost-cpu", type=int,
2505
2588
  help="pin the cost worker to this cpu (default: the last "
2506
2589
  "performance core this process may use)")
2590
+ ap.add_argument("--probe-notes", type=int,
2591
+ help="for --cost: the notes an instrument (a MIDI input, no audio "
2592
+ "input) holds down while it is timed, 1 to "
2593
+ f"{NOTE_KEYS_MAX}; its record says basis `probe`. Without it "
2594
+ "an instrument is unmeasurable `voices-unknown`")
2507
2595
  ap.add_argument("--cost-provenance",
2508
2596
  help="write the run's host/CPU/governor/settings provenance here "
2509
2597
  "as JSON — the machine the cost figures are only meaningful "
@@ -2522,11 +2610,14 @@ def main():
2522
2610
  "perRate figures already in the catalog. No probing, no LV2, "
2523
2611
  "no --run: use it when a classification rule changes.")
2524
2612
  args = ap.parse_args()
2613
+ if args.probe_notes is not None and not 1 <= args.probe_notes <= NOTE_KEYS_MAX:
2614
+ ap.error(f"--probe-notes takes 1 to {NOTE_KEYS_MAX} notes")
2525
2615
 
2526
2616
  if args.worker:
2527
2617
  worker_main(args.rate, args.block if not args.cost else args.cost_block,
2528
2618
  measure=args.measure, cost=args.cost, cpu=args.cost_cpu,
2529
- warmup=args.cost_warmup, blocks=args.cost_blocks)
2619
+ warmup=args.cost_warmup, blocks=args.cost_blocks,
2620
+ probe_notes=args.probe_notes)
2530
2621
  return
2531
2622
 
2532
2623
  if not args.inp:
@@ -80,6 +80,8 @@ const HELP = `${TOOL_NAME} ${TOOL_VERSION} — measure LV2 plugin latency and CP
80
80
  --python BIN Python interpreter (default: $PYTHON or python3).
81
81
  --sweep Also sweep latency-bearing controls (finds parameter-dependent latency).
82
82
  --cost-cpu N Pin the cost pass to core N.
83
+ --probe-notes N The notes an instrument holds down while its cost is timed (1 to 128);
84
+ without it an instrument's cost is unmeasured (voices-unknown).
83
85
  --label TEXT A note recorded with the run.
84
86
  --run Required to probe. Instantiates and runs every plugin.
85
87
  --dry-run Print the plan and probe nothing.