@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.
- package/README.md +7 -1
- package/dist/clap-qualify.d.ts +60 -4
- package/dist/clap-qualify.js +141 -3
- package/dist/clap-qualify.js.map +1 -1
- package/dist/host-profiles.d.ts +1 -2
- package/dist/host-profiles.js +12 -2
- package/dist/host-profiles.js.map +1 -1
- package/dist/hosting-codes.d.ts +77 -57
- package/dist/hosting-codes.js +89 -77
- package/dist/hosting-codes.js.map +1 -1
- package/dist/hosting-suitability.d.ts +12 -4
- package/dist/hosting-suitability.js +18 -16
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/measurement/cli.d.ts +8 -1
- package/dist/measurement/cli.js +15 -1
- package/dist/measurement/cli.js.map +1 -1
- package/dist/measurement/format.d.ts +45 -5
- package/dist/measurement/format.js +46 -3
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/probe-json.js +3 -0
- package/dist/measurement/probe-json.js.map +1 -1
- package/dist/measurement/sweep-jsonl.js +2 -1
- package/dist/measurement/sweep-jsonl.js.map +1 -1
- package/dist/measurement/vocabulary.d.ts +39 -22
- package/dist/measurement/vocabulary.js +93 -145
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/probe-judge.d.ts +6 -1
- package/dist/probe-judge.js +7 -3
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-output.d.ts +3 -0
- package/dist/probe-output.js +5 -0
- package/dist/probe-output.js.map +1 -1
- package/dist/qualification-record.d.ts +3 -1
- package/dist/qualification-record.js.map +1 -1
- package/fixtures/clap-scan/fake.json +109 -7
- package/fixtures/probe-output/success-msi.json +11 -0
- package/package.json +2 -2
- package/src/clap-qualify.ts +177 -5
- package/src/host-profiles.ts +12 -2
- package/src/hosting-codes.ts +182 -77
- package/src/hosting-suitability.ts +25 -18
- package/src/measurement/cli.ts +21 -1
- package/src/measurement/format.ts +81 -3
- package/src/measurement/probe-json.ts +3 -0
- package/src/measurement/sweep-jsonl.ts +2 -1
- package/src/measurement/vocabulary.ts +148 -149
- package/src/probe-judge.ts +13 -5
- package/src/probe-output.ts +6 -0
- package/src/qualification-record.ts +9 -2
- package/tools/benchmark.py +104 -13
- package/tools/lv2-measure.mjs +2 -0
package/tools/benchmark.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
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:
|
package/tools/lv2-measure.mjs
CHANGED
|
@@ -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.
|