@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
@@ -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,11 +1115,20 @@ 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#"
1087
1122
  PARAMS = "http://lv2plug.in/ns/ext/parameters#"
1088
1123
  WORKER = "http://lv2plug.in/ns/ext/worker#"
1124
+ # The refusal's code: the LV2 worker spec does not make `work` or `work_response` optional, so a plugin
1125
+ # whose interface leaves either NULL is not hostable (end_run may be NULL).
1126
+ WORKER_NONCONFORMING = "hosting.worker.nonconforming"
1127
+
1128
+
1129
+ class WorkerInterfaceError(Exception):
1130
+ """The plugin's LV2_Worker_Interface is incomplete; the message starts with WORKER_NONCONFORMING."""
1131
+
1089
1132
 
1090
1133
 
1091
1134
  LV2_WORKER_SUCCESS = 0
@@ -1261,6 +1304,10 @@ class _Host:
1261
1304
  handle = ctypes.c_void_p(instance.get_handle())
1262
1305
  except (AttributeError, TypeError, ValueError):
1263
1306
  return None
1307
+ # calling a NULL function pointer through ctypes kills the process: refuse before anything is called
1308
+ for name in ("work", "work_response"):
1309
+ if not getattr(iface, name):
1310
+ raise WorkerInterfaceError(f"{WORKER_NONCONFORMING}: LV2_Worker_Interface.{name} is NULL")
1264
1311
 
1265
1312
  def keep(buf):
1266
1313
  return ctypes.create_string_buffer(bytes(buf), len(buf))
@@ -1371,6 +1418,23 @@ def audio_port_indices(host, plugin):
1371
1418
  return audio_in, audio_out
1372
1419
 
1373
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
+
1374
1438
  def instantiate_connected(host, plugin, overrides=None):
1375
1439
  """Instantiate `plugin` and connect a live buffer to every one of its ports.
1376
1440
 
@@ -1577,7 +1641,7 @@ def timing_floor_ns(iters=COST_CALIBRATION_ITERS):
1577
1641
 
1578
1642
 
1579
1643
  def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOCKS,
1580
- min_blocks=COST_MIN_BLOCKS, target_seconds=COST_TARGET_SECONDS):
1644
+ min_blocks=COST_MIN_BLOCKS, target_seconds=COST_TARGET_SECONDS, probe_notes=None):
1581
1645
  """Instantiate one plugin offline and MEASURE its CPU cost per processed sample.
1582
1646
 
1583
1647
  A sustained 1 kHz sine at -6 dBFS is held on every audio input, phase-continuous
@@ -1589,13 +1653,18 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1589
1653
  the gap between them is the xrun risk. Signal, not silence: a plugin that
1590
1654
  short-circuits on a silent input would otherwise be recorded as free.
1591
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
+
1592
1661
  Buffer filling and every statistic are outside the timed region; the GC is off
1593
1662
  across the loop, so a collection cannot land inside a block and be charged to
1594
1663
  the plugin.
1595
1664
 
1596
1665
  Returns exactly one of:
1597
- {"blockTimesNs": [...], "silentOutput": bool} timings, warm-up head first
1598
- {"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
1599
1668
  {"error": <reason>} not installed / refused / non-finite output
1600
1669
  """
1601
1670
  import gc
@@ -1606,14 +1675,26 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1606
1675
  if plugin is None:
1607
1676
  return {"error": "not installed"}
1608
1677
  audio_in, audio_out = audio_port_indices(host, plugin)
1609
- 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:
1610
1682
  return {"unmeasurable": "no-audio-in"}
1611
1683
  if not audio_out:
1612
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 []
1613
1688
 
1614
1689
  instance, buffers, reason = instantiate_connected(host, plugin)
1615
1690
  if instance is None:
1616
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)
1617
1698
 
1618
1699
  block = host.block
1619
1700
  ins = [buffers[i] for i in audio_in]
@@ -1624,7 +1705,13 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1624
1705
  ramp = np.arange(block, dtype=np.float32)
1625
1706
 
1626
1707
  def drive(blk):
1627
- """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
1628
1715
  sig = COST_TONE_AMP * np.sin(phase * (blk * block + ramp))
1629
1716
  for buf in ins:
1630
1717
  buf[:] = sig
@@ -1667,9 +1754,12 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1667
1754
 
1668
1755
  if not np.all(np.isfinite(out_buf)):
1669
1756
  return {"error": "non-finite output"}
1670
- return {"blockTimesNs": times,
1671
- "silentOutput": bool(np.max(np.abs(out_buf)) == 0.0),
1672
- "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
1673
1763
 
1674
1764
 
1675
1765
  def control_ranges(host, plugin, symbols):
@@ -1741,7 +1831,7 @@ def threshold_overrides(host, plugin):
1741
1831
 
1742
1832
 
1743
1833
  def worker_main(rate, block, measure=False, cost=False, cpu=None,
1744
- warmup=DEFAULT_COST_WARMUP, blocks=DEFAULT_COST_BLOCKS):
1834
+ warmup=DEFAULT_COST_WARMUP, blocks=DEFAULT_COST_BLOCKS, probe_notes=None):
1745
1835
  """The worker loop: JSON-lines protocol over stdio (see parse_worker_line).
1746
1836
 
1747
1837
  Loads the LV2 world once, announces readiness, then probes one plugin per
@@ -1772,7 +1862,7 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1772
1862
  uri, _tab, arg = line.partition("\t")
1773
1863
  if cost:
1774
1864
  try:
1775
- 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)
1776
1866
  except Exception as e: # noqa: BLE001 - report, don't die
1777
1867
  res = {"error": f"cost error: {e}"}
1778
1868
  if "blockTimesNs" in res:
@@ -1784,6 +1874,8 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1784
1874
  continue
1785
1875
  if res.get("silentOutput"):
1786
1876
  record["silentOutput"] = True
1877
+ if "voices" in res:
1878
+ record["voices"] = res["voices"]
1787
1879
  # what the host did with the plugin's worker, so a cost read can be told apart
1788
1880
  # from an unbuilt plugin's (jobs and dropped are counts, never a verdict).
1789
1881
  if "worker" in res:
@@ -2167,13 +2259,15 @@ def host_provenance(cpu, rates, block, warmup, blocks, timing_floor_ns_value=Non
2167
2259
  }
2168
2260
 
2169
2261
 
2170
- def default_cost_worker_cmd(rate, block, cpu, warmup, blocks):
2262
+ def default_cost_worker_cmd(rate, block, cpu, warmup, blocks, probe_notes=None):
2171
2263
  """This same script, in --worker --cost mode, under the same interpreter."""
2172
2264
  cmd = [sys.executable, os.path.abspath(__file__), "--worker", "--cost",
2173
2265
  "--rate", str(rate), "--block", str(block),
2174
2266
  "--cost-warmup", str(warmup), "--cost-blocks", str(blocks)]
2175
2267
  if cpu is not None:
2176
2268
  cmd += ["--cost-cpu", str(cpu)]
2269
+ if probe_notes:
2270
+ cmd += ["--probe-notes", str(probe_notes)]
2177
2271
  return cmd
2178
2272
 
2179
2273
 
@@ -2186,7 +2280,8 @@ def cost_at_rate(catalog, rate, args, cpu):
2186
2280
  """
2187
2281
  worker_cmd = (shlex.split(args.worker_cmd) if args.worker_cmd
2188
2282
  else default_cost_worker_cmd(rate, args.cost_block, cpu,
2189
- args.cost_warmup, args.cost_blocks))
2283
+ args.cost_warmup, args.cost_blocks,
2284
+ args.probe_notes))
2190
2285
  # a cost probe runs hundreds of blocks where a latency probe runs a handful, so
2191
2286
  # the latency default would time out honest work; an explicit --timeout wins.
2192
2287
  timeout = DEFAULT_COST_TIMEOUT if args.timeout == DEFAULT_TIMEOUT else args.timeout
@@ -2492,6 +2587,11 @@ def main():
2492
2587
  ap.add_argument("--cost-cpu", type=int,
2493
2588
  help="pin the cost worker to this cpu (default: the last "
2494
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`")
2495
2595
  ap.add_argument("--cost-provenance",
2496
2596
  help="write the run's host/CPU/governor/settings provenance here "
2497
2597
  "as JSON — the machine the cost figures are only meaningful "
@@ -2510,11 +2610,14 @@ def main():
2510
2610
  "perRate figures already in the catalog. No probing, no LV2, "
2511
2611
  "no --run: use it when a classification rule changes.")
2512
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")
2513
2615
 
2514
2616
  if args.worker:
2515
2617
  worker_main(args.rate, args.block if not args.cost else args.cost_block,
2516
2618
  measure=args.measure, cost=args.cost, cpu=args.cost_cpu,
2517
- warmup=args.cost_warmup, blocks=args.cost_blocks)
2619
+ warmup=args.cost_warmup, blocks=args.cost_blocks,
2620
+ probe_notes=args.probe_notes)
2518
2621
  return
2519
2622
 
2520
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.