@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
@@ -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.