@openmixer/plugin-qualify 0.5.0 → 0.6.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 (48) hide show
  1. package/README.md +19 -2
  2. package/dist/clap-qualify.d.ts +5 -2
  3. package/dist/clap-qualify.js +19 -14
  4. package/dist/clap-qualify.js.map +1 -1
  5. package/dist/cost-fields.d.ts +46 -0
  6. package/dist/cost-fields.js +46 -0
  7. package/dist/cost-fields.js.map +1 -0
  8. package/dist/hosting-codes.d.ts +2 -0
  9. package/dist/hosting-codes.js +2 -0
  10. package/dist/hosting-codes.js.map +1 -1
  11. package/dist/hosting-suitability.d.ts +15 -2
  12. package/dist/hosting-suitability.js +44 -12
  13. package/dist/hosting-suitability.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/measure-host.d.ts +21 -0
  18. package/dist/measure-host.js +124 -0
  19. package/dist/measure-host.js.map +1 -0
  20. package/dist/measurement/cli.d.ts +47 -7
  21. package/dist/measurement/cli.js +117 -9
  22. package/dist/measurement/cli.js.map +1 -1
  23. package/dist/measurement/format.d.ts +5 -0
  24. package/dist/measurement/format.js +2 -0
  25. package/dist/measurement/format.js.map +1 -1
  26. package/dist/measurement/vocabulary.d.ts +7 -2
  27. package/dist/measurement/vocabulary.js +7 -2
  28. package/dist/measurement/vocabulary.js.map +1 -1
  29. package/dist/qualify-declarations.d.ts +50 -2
  30. package/dist/qualify-declarations.js +51 -2
  31. package/dist/qualify-declarations.js.map +1 -1
  32. package/dist/tool-paths.d.ts +13 -0
  33. package/dist/tool-paths.js +29 -2
  34. package/dist/tool-paths.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/clap-qualify.ts +28 -14
  37. package/src/cost-fields.ts +72 -0
  38. package/src/hosting-codes.ts +2 -0
  39. package/src/hosting-suitability.ts +58 -12
  40. package/src/index.ts +2 -0
  41. package/src/measure-host.ts +112 -0
  42. package/src/measurement/cli.ts +146 -15
  43. package/src/measurement/format.ts +7 -0
  44. package/src/measurement/vocabulary.ts +8 -2
  45. package/src/qualify-declarations.ts +58 -2
  46. package/src/tool-paths.ts +43 -2
  47. package/tools/benchmark.py +126 -6
  48. package/tools/lv2-measure.mjs +76 -9
@@ -41,6 +41,27 @@ export const BOOT_TIMEOUT_S = 120;
41
41
  /** Seconds one plugin's cost pass may take (a sustained tone runs longer than an impulse). */
42
42
  export const COST_TIMEOUT_S = 60;
43
43
 
44
+ /**
45
+ * The soak's control sweep is bounded to musically sane ranges (spec §2a.4): a `units:db` port
46
+ * is swept at most this many dB either side of its default, and a strictly-positive-default
47
+ * linear port at most this ratio either side. Calf's linear gains reach 64x (+36 dB); a startup
48
+ * blast the sweep itself drives is not the plugin's. The measuring host reads both from the
49
+ * environment this package sets (`soakSweepEnv`), so the C default and this value cannot differ.
50
+ */
51
+ export const SOAK_SWEEP_DB = 12;
52
+ export const SOAK_SWEEP_RATIO = 4;
53
+
54
+ /**
55
+ * A soak's wall-clock limit is SIZED, never fixed (spec §2a): the calibration soak's wall seconds
56
+ * per audio second, times the soak length, times this margin, plus this floor in seconds.
57
+ * 2026-10-01: a fixed 900 s cut ZamGEQ31's 51-minute wall at 192 kHz and read as "died".
58
+ */
59
+ export const SOAK_TIMEOUT_MARGIN = 2;
60
+ export const SOAK_TIMEOUT_FLOOR_S = 60;
61
+
62
+ /** Audio seconds the calibration soak renders to measure the host's speed on this plugin and rate. */
63
+ export const SOAK_CALIBRATION_S = 30;
64
+
44
65
  /**
45
66
  * The ONE setting that locates the CLAP scanner (spec §7): the environment variable that names
46
67
  * the `omx-clap-scan` binary, and what is run when it is unset (looked up on `PATH`).
@@ -52,12 +73,36 @@ export const CLAP_SCAN_TOOL_DEFAULT = "omx-clap-scan";
52
73
  export const CLAP_SCAN_TIMEOUT_S = 300;
53
74
 
54
75
  /**
55
- * The CLAP prober (`native/clap-qualify.c`) and the interposer it runs under (`native/rt-interpose.c`):
56
- * each located by one environment variable, else the package's own build in `native/build/`.
76
+ * The CLAP prober (`native/clap-qualify.c`), the LV2 measurer (`native/lv2-measure.c`) and the
77
+ * interposer both run under (`native/rt-interpose.c`): each located by one environment variable, else
78
+ * the first of `NATIVE_TOOL_DIRS` that holds it, else the package's own build in `native/build/`.
57
79
  */
58
80
  export const CLAP_QUALIFY_TOOL_ENV = "OMX_CLAP_QUALIFY";
81
+ export const LV2_MEASURE_TOOL_ENV = "OMX_LV2_MEASURE";
59
82
  export const RT_INTERPOSE_ENV = "OMX_RT_INTERPOSE";
60
83
 
84
+ /** The file names of the native tools, in every place they are looked for. */
85
+ export const CLAP_QUALIFY_TOOL_NAME = "clap-qualify";
86
+ export const LV2_MEASURE_TOOL_NAME = "lv2_measure";
87
+ export const RT_INTERPOSE_NAME = "rt_interpose.so";
88
+
89
+ /**
90
+ * The ONE list of the native tools the packages install, each with the setting that names it.
91
+ * `make -C native install` installs exactly these; a test holds the Makefile to the list.
92
+ */
93
+ export const NATIVE_TOOLS: readonly { readonly name: string; readonly env: string }[] = [
94
+ { name: CLAP_QUALIFY_TOOL_NAME, env: CLAP_QUALIFY_TOOL_ENV },
95
+ { name: LV2_MEASURE_TOOL_NAME, env: LV2_MEASURE_TOOL_ENV },
96
+ { name: RT_INTERPOSE_NAME, env: RT_INTERPOSE_ENV },
97
+ ];
98
+
99
+ /**
100
+ * Where the RPM and the DEB install the native tools: the libexec directory on Fedora,
101
+ * `/usr/lib/<package>` on Debian (policy keeps a package's private programs there). The packaging
102
+ * installs into exactly these; a test holds the spec and `debian/rules` to them.
103
+ */
104
+ export const NATIVE_TOOL_DIRS: readonly string[] = ["/usr/libexec/plugin-qualify", "/usr/lib/plugin-qualify"];
105
+
61
106
  /** The CLAP sweep: blocks of `CLAP_SWEEP_FRAMES` frames per rate, every parameter moved through events. */
62
107
  export const CLAP_SWEEP_BLOCKS = 512;
63
108
  export const CLAP_SWEEP_FRAMES = 512;
@@ -65,6 +110,17 @@ export const CLAP_SWEEP_FRAMES = 512;
65
110
  /** Seconds one `.clap` may take to qualify at every rate before the prober is killed. */
66
111
  export const CLAP_QUALIFY_TIMEOUT_S = 120;
67
112
 
113
+ /**
114
+ * The LV2 hosting pass (`lv2-measure.mjs`, the native `lv2_measure`): each plugin at the run's highest
115
+ * rate, in blocks of `LV2_HOSTING_BLOCK_FRAMES` frames, `LV2_HOSTING_LIFECYCLES` instantiate/destroy
116
+ * cycles and `LV2_HOSTING_REPEATS` swept RT-safety runs (the worst kept), under the noise stimulus:
117
+ * the figures openmixer's hosting sweep took. One plugin may take `LV2_HOSTING_TIMEOUT_S` seconds.
118
+ */
119
+ export const LV2_HOSTING_BLOCK_FRAMES = 512;
120
+ export const LV2_HOSTING_LIFECYCLES = 30;
121
+ export const LV2_HOSTING_REPEATS = 3;
122
+ export const LV2_HOSTING_TIMEOUT_S = 240;
123
+
68
124
 
69
125
 
70
126
 
package/src/tool-paths.ts CHANGED
@@ -5,8 +5,17 @@
5
5
  * wherever it is installed (a checkout, an RPM's bundled node_modules, a pnpm store). The
6
6
  * tools are shipped RUNTIME artefacts, not dev scripts.
7
7
  */
8
+ import { join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
 
11
+ import {
12
+ CLAP_QUALIFY_TOOL_NAME,
13
+ LV2_MEASURE_TOOL_NAME,
14
+ NATIVE_TOOLS,
15
+ NATIVE_TOOL_DIRS,
16
+ RT_INTERPOSE_NAME,
17
+ } from "./qualify-declarations.js";
18
+
10
19
  /**
11
20
  * `tools/` — `scan.py`, `benchmark.py`, `lv2-measure.mjs`, `hosting-sweep.mjs`. Resolved when a
12
21
  * console asks, never at import: a consumer that only reads this package's declarations from a
@@ -21,12 +30,44 @@ export function lv2MeasureToolPath(): string {
21
30
  return fileURLToPath(new URL("../tools/lv2-measure.mjs", import.meta.url));
22
31
  }
23
32
 
33
+ /** The package's own build of the native tools (`make -C native`). */
34
+ function nativeBuildDir(): string {
35
+ return fileURLToPath(new URL("../native/build/", import.meta.url));
36
+ }
37
+
24
38
  /** The package's own build of the CLAP prober (`make -C native`). */
25
39
  export function clapQualifyToolPath(): string {
26
- return fileURLToPath(new URL("../native/build/clap-qualify", import.meta.url));
40
+ return join(nativeBuildDir(), CLAP_QUALIFY_TOOL_NAME);
27
41
  }
28
42
 
29
43
  /** The package's own build of the interposer the CLAP prober runs under. */
30
44
  export function rtInterposePath(): string {
31
- return fileURLToPath(new URL("../native/build/rt_interpose.so", import.meta.url));
45
+ return join(nativeBuildDir(), RT_INTERPOSE_NAME);
46
+ }
47
+
48
+ /** The package's own build of the LV2 measurer (`make -C native`). */
49
+ export function lv2MeasureNativePath(): string {
50
+ return join(nativeBuildDir(), LV2_MEASURE_TOOL_NAME);
51
+ }
52
+
53
+ /** Where a native tool is looked for when no setting names it, in order: the installed directories, then the own build. */
54
+ export function nativeToolCandidates(name: string): string[] {
55
+ return [...NATIVE_TOOL_DIRS, nativeBuildDir()].map((dir) => join(dir, name));
56
+ }
57
+
58
+ /**
59
+ * One of `NATIVE_TOOLS`: its setting when set (and only that), else the first candidate that exists.
60
+ * `path` is absent when none does; `looked` is every place tried, for the refusal to name.
61
+ */
62
+ export function locateNativeTool(
63
+ name: string,
64
+ env: Readonly<Record<string, string | undefined>>,
65
+ exists: (path: string) => boolean,
66
+ ): { readonly path?: string; readonly looked: readonly string[]; readonly setting: string } {
67
+ const setting = NATIVE_TOOLS.find((t) => t.name === name)?.env;
68
+ if (setting === undefined) throw new Error(`${name} is not one of NATIVE_TOOLS`);
69
+ const v = env[setting];
70
+ const looked = v === undefined || v === "" ? nativeToolCandidates(name) : [v];
71
+ const path = looked.find((p) => exists(p));
72
+ return { ...(path === undefined ? {} : { path }), looked, setting };
32
73
  }
@@ -1088,13 +1088,66 @@ PARAMS = "http://lv2plug.in/ns/ext/parameters#"
1088
1088
  WORKER = "http://lv2plug.in/ns/ext/worker#"
1089
1089
 
1090
1090
 
1091
+ LV2_WORKER_SUCCESS = 0
1092
+ LV2_WORKER_ERR_NO_SPACE = 3
1093
+
1094
+
1095
+ class WorkerQueue:
1096
+ """The host half of `worker:schedule`: jobs are QUEUED, and run between blocks, off `run()`.
1097
+
1098
+ Spec 2a (`2026-09-25-plugin-qualify.md`): a schedule that accepts a job and
1099
+ drops it measures an UNBUILT plugin — the Hex Chain amp read 0.9 us/sample that way against 5.4
1100
+ built. After every block the host calls `pump(interface)`: the plugin's `work()` for each
1101
+ queued job, then `work_response()` with whatever it answered, then `end_run()` once every answer
1102
+ is delivered (the LV2 worker's order), all before the next block. Pure Python on purpose: the ctypes glue in `_Host` only adapts to it.
1103
+ """
1104
+
1105
+ def __init__(self):
1106
+ self.jobs = []
1107
+ self.reset()
1108
+
1109
+ def reset(self):
1110
+ self.jobs = []
1111
+ self._jobs_run = 0
1112
+ self._responses = 0
1113
+ self._dropped = 0
1114
+
1115
+ def schedule(self, data):
1116
+ self.jobs.append(bytes(data))
1117
+ return LV2_WORKER_SUCCESS
1118
+
1119
+ def pump(self, interface):
1120
+ """Drive one between-blocks turn. `interface` is None when the plugin exports no
1121
+ LV2_Worker_Interface: its jobs are then DROPPED and counted as dropped, never as done."""
1122
+ if interface is None:
1123
+ self._dropped += len(self.jobs)
1124
+ self.jobs = []
1125
+ return
1126
+ jobs, self.jobs = self.jobs, []
1127
+ answers = []
1128
+
1129
+ def respond(data):
1130
+ answers.append(bytes(data))
1131
+ return LV2_WORKER_SUCCESS
1132
+
1133
+ for data in jobs:
1134
+ interface.work(data, respond)
1135
+ self._jobs_run += 1
1136
+ for data in answers:
1137
+ interface.work_response(data)
1138
+ self._responses += 1
1139
+ interface.end_run()
1140
+
1141
+ def stats(self):
1142
+ return {"jobs": self._jobs_run, "responses": self._responses, "dropped": self._dropped}
1143
+
1144
+
1091
1145
  class _Host:
1092
1146
  """The minimal offline LV2 host the probe needs: a urid map, host options
1093
1147
  (sample rate + block sizes — DPF-based plugins refuse to instantiate without
1094
- them), block-length guarantees, and a stub worker schedule that accepts jobs
1095
- but never runs them (enough to satisfy `worker:schedule` as a required
1096
- feature; a plugin whose latency depends on completed worker jobs simply
1097
- reports its resting value). All ctypes callbacks/structs are kept as
1148
+ them), block-length guarantees, and a REAL worker (`WorkerQueue`: the host runs the
1149
+ plugin's work() between blocks and delivers its answers — call `pump` after every
1150
+ block). All ctypes callbacks/structs are kept as
1098
1151
  attributes so nothing is garbage-collected while plugins hold pointers.
1099
1152
  """
1100
1153
 
@@ -1113,7 +1166,9 @@ class _Host:
1113
1166
 
1114
1167
  self._map_cb = map_fn(lambda _h, uri: self.map_uri(uri))
1115
1168
  self._unmap_cb = unmap_fn(lambda _h, urid: self._unmap(urid))
1116
- self._sched_cb = sched_fn(lambda _h, _size, _data: 0) # LV2_WORKER_SUCCESS
1169
+ self.worker = WorkerQueue()
1170
+ self._sched_cb = sched_fn(
1171
+ lambda _h, size, data: self.worker.schedule(ctypes.string_at(data, size) if size else b""))
1117
1172
 
1118
1173
  class UridMap(ctypes.Structure):
1119
1174
  _fields_ = [("handle", ctypes.c_void_p), ("map", map_fn)]
@@ -1183,6 +1238,56 @@ class _Host:
1183
1238
  self.block = block
1184
1239
  self.seq_urid = self.map_uri(f"{ATOM}Sequence")
1185
1240
 
1241
+ def worker_interface(self, instance):
1242
+ """The plugin's LV2_Worker_Interface as an object `WorkerQueue.pump` drives, or None when
1243
+ it exports none (its jobs are then counted dropped). The ctypes layout is
1244
+ LV2_Worker_Interface {work, work_response, end_run}."""
1245
+ import ctypes
1246
+
1247
+ respond_fn = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p)
1248
+ work_fn = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, respond_fn, ctypes.c_void_p,
1249
+ ctypes.c_uint32, ctypes.c_void_p)
1250
+ response_fn = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p)
1251
+ end_fn = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)
1252
+
1253
+ class Interface(ctypes.Structure):
1254
+ _fields_ = [("work", work_fn), ("work_response", response_fn), ("end_run", end_fn)]
1255
+
1256
+ try:
1257
+ ext = instance.get_extension_data(self.world.new_uri(f"{WORKER}interface"))
1258
+ if not ext:
1259
+ return None
1260
+ iface = ctypes.cast(ext, ctypes.POINTER(Interface)).contents
1261
+ handle = ctypes.c_void_p(instance.get_handle())
1262
+ except (AttributeError, TypeError, ValueError):
1263
+ return None
1264
+
1265
+ def keep(buf):
1266
+ return ctypes.create_string_buffer(bytes(buf), len(buf))
1267
+
1268
+ class Adapter:
1269
+ def work(self_, data, respond):
1270
+ def cb(_h, size, ptr):
1271
+ return respond(ctypes.string_at(ptr, size) if size else b"")
1272
+
1273
+ cfn = respond_fn(cb)
1274
+ buf = keep(data)
1275
+ iface.work(handle, cfn, None, len(data), ctypes.cast(buf, ctypes.c_void_p))
1276
+
1277
+ def work_response(self_, data):
1278
+ buf = keep(data)
1279
+ iface.work_response(handle, len(data), ctypes.cast(buf, ctypes.c_void_p))
1280
+
1281
+ def end_run(self_):
1282
+ if iface.end_run:
1283
+ iface.end_run(handle)
1284
+
1285
+ return Adapter()
1286
+
1287
+ def pump(self, instance_interface):
1288
+ """After a block, between blocks: run the plugin's worker (see WorkerQueue.pump)."""
1289
+ self.worker.pump(instance_interface)
1290
+
1186
1291
  def map_uri(self, uri):
1187
1292
  u = uri.decode() if isinstance(uri, bytes) else str(uri)
1188
1293
  if u not in self.urids:
@@ -1211,6 +1316,7 @@ def probe_one(host, uri, symbol, cycles=DEFAULT_RUN_CYCLES):
1211
1316
  plugin = host.world.get_all_plugins().get_by_uri(host.world.new_uri(uri))
1212
1317
  if plugin is None:
1213
1318
  return None, "not installed"
1319
+ host.worker.reset()
1214
1320
  instance = lilv.Instance(plugin, float(host.rate), host.features)
1215
1321
  if not instance.instance:
1216
1322
  # a required feature we don't provide, or the plugin vetoed the rate.
@@ -1243,9 +1349,11 @@ def probe_one(host, uri, symbol, cycles=DEFAULT_RUN_CYCLES):
1243
1349
  instance.connect_port(i, buf)
1244
1350
  if latency_buf is None:
1245
1351
  return None, f"latency port {symbol!r} not found"
1352
+ iface = host.worker_interface(instance)
1246
1353
  instance.activate()
1247
1354
  for _ in range(cycles):
1248
1355
  instance.run(host.block)
1356
+ host.pump(iface)
1249
1357
  instance.deactivate()
1250
1358
  return float(latency_buf[0]), None
1251
1359
 
@@ -1279,6 +1387,7 @@ def instantiate_connected(host, plugin, overrides=None):
1279
1387
 
1280
1388
  import lilv
1281
1389
 
1390
+ host.worker.reset()
1282
1391
  instance = lilv.Instance(plugin, float(host.rate), host.features)
1283
1392
  if not instance.instance:
1284
1393
  del instance.instance # avoid lilv.py's NULL deref in __del__
@@ -1346,6 +1455,7 @@ def render(host, plugin, n_blocks, impulse=True, overrides=None, preroll_blocks=
1346
1455
  out_buf = buffers[audio_out[0]]
1347
1456
  captured = np.empty(n_blocks * block, dtype=np.float32)
1348
1457
 
1458
+ iface = host.worker_interface(instance)
1349
1459
  instance.activate()
1350
1460
  if preroll_blocks:
1351
1461
  phase = 2.0 * np.pi * PREROLL_TONE_HZ / float(host.rate)
@@ -1354,12 +1464,14 @@ def render(host, plugin, n_blocks, impulse=True, overrides=None, preroll_blocks=
1354
1464
  in_buf[:] = PREROLL_TONE_AMP * np.sin(
1355
1465
  phase * np.arange(start, start + block, dtype=np.float32))
1356
1466
  instance.run(block) # output discarded: this only opens the plugin up
1467
+ host.pump(iface)
1357
1468
  in_buf[:] = 0.0
1358
1469
  if impulse:
1359
1470
  in_buf[0] = 1.0 # 1.0 at frame 0 of the first block only
1360
1471
  for blk in range(n_blocks):
1361
1472
  instance.run(block)
1362
1473
  captured[blk * block:(blk + 1) * block] = out_buf
1474
+ host.pump(iface)
1363
1475
  in_buf[:] = 0.0 # every subsequent block is silence
1364
1476
  instance.deactivate()
1365
1477
  return captured, None
@@ -1518,6 +1630,7 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1518
1630
  buf[:] = sig
1519
1631
 
1520
1632
  times = []
1633
+ iface = host.worker_interface(instance)
1521
1634
  instance.activate()
1522
1635
  gc_was_on = gc.isenabled()
1523
1636
  gc.disable()
@@ -1528,6 +1641,7 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1528
1641
  t0 = clock()
1529
1642
  run(block)
1530
1643
  times.append(clock() - t0)
1644
+ host.pump(iface) # the plugin's worker runs between blocks, off the timed run()
1531
1645
  blk += 1
1532
1646
  spent = 0
1533
1647
  budget = int(target_seconds * 1e9)
@@ -1537,6 +1651,7 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1537
1651
  t0 = clock()
1538
1652
  run(block)
1539
1653
  dt = clock() - t0
1654
+ host.pump(iface)
1540
1655
  times.append(dt)
1541
1656
  spent += dt
1542
1657
  timed += 1
@@ -1553,7 +1668,8 @@ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOC
1553
1668
  if not np.all(np.isfinite(out_buf)):
1554
1669
  return {"error": "non-finite output"}
1555
1670
  return {"blockTimesNs": times,
1556
- "silentOutput": bool(np.max(np.abs(out_buf)) == 0.0)}
1671
+ "silentOutput": bool(np.max(np.abs(out_buf)) == 0.0),
1672
+ "worker": host.worker.stats()}
1557
1673
 
1558
1674
 
1559
1675
  def control_ranges(host, plugin, symbols):
@@ -1668,6 +1784,10 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1668
1784
  continue
1669
1785
  if res.get("silentOutput"):
1670
1786
  record["silentOutput"] = True
1787
+ # what the host did with the plugin's worker, so a cost read can be told apart
1788
+ # from an unbuilt plugin's (jobs and dropped are counts, never a verdict).
1789
+ if "worker" in res:
1790
+ record["worker"] = res["worker"]
1671
1791
  print(json.dumps({"uri": uri,
1672
1792
  "nsPerSample": record["nsPerSampleMedian"],
1673
1793
  "cost": record}), flush=True)
@@ -6,7 +6,10 @@
6
6
  * measurement document.
7
7
  *
8
8
  * Standalone by construction: it needs `python3` with the `lilv` bindings, the two probers
9
- * next to this file (`scan.py`, `benchmark.py`), and an LV2 world to look at. It does not
9
+ * next to this file (`scan.py`, `benchmark.py`), and an LV2 world to look at; the hosting
10
+ * dimensions need the package's native measurer and interposer (`lv2_measure`,
11
+ * `rt_interpose.so`), each its setting (`OMX_LV2_MEASURE`, `OMX_RT_INTERPOSE`), else where the
12
+ * package installs it, else `native/build` (`make -C native`). It does not
10
13
  * need the OpenMixer engine, its server, PipeWire, JACK, an audio interface, or root. Nothing
11
14
  * it writes is openmixer-shaped — the output is the interchange format described in
12
15
  * `src/measurement/format.ts`, which any LV2 host can read.
@@ -22,6 +25,7 @@
22
25
  * pnpm build # this shell reads the compiled dist/
23
26
  * node tools/lv2-measure.mjs --rates 48000,96000 --out measurements.json --run
24
27
  * node tools/lv2-measure.mjs --rates 48000 --dimensions latency --dry-run
28
+ * node tools/lv2-measure.mjs --rates 48000 --dimensions stability,rtSafety,features --plugins <uri> --run
25
29
  * ```
26
30
  *
27
31
  * `--run` (or `LV2_MEASURE_RUN=1`) is required to probe: the passes instantiate and execute
@@ -34,9 +38,9 @@
34
38
  * `@progress `. A supervising process (the OpenMixer console's Setup screen) parses those and
35
39
  * ignores everything else, so the human log stays readable.
36
40
  */
37
- import { spawn } from "node:child_process";
41
+ import { spawn, spawnSync } from "node:child_process";
38
42
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
39
- import { hostname } from "node:os";
43
+ import { constants, hostname } from "node:os";
40
44
  import { dirname, join, resolve } from "node:path";
41
45
  import { fileURLToPath } from "node:url";
42
46
 
@@ -45,13 +49,18 @@ import {
45
49
  assembleDocument,
46
50
  buildPipeline,
47
51
  collectHostFacts,
52
+ HOSTING_PASS_DIMENSIONS,
53
+ hostingLine,
48
54
  INITIAL_PROGRESS,
49
55
  parseMeasureArgs,
50
56
  parseProbeProgress,
51
57
  serialiseMeasurementDocument,
52
58
  TOOL_NAME,
53
59
  TOOL_VERSION,
60
+ urisFromScan,
54
61
  } from "../dist/measurement/index.js";
62
+ import { LV2_HOSTING_TIMEOUT_S, LV2_MEASURE_TOOL_NAME, RT_INTERPOSE_NAME } from "../dist/qualify-declarations.js";
63
+ import { locateNativeTool } from "../dist/tool-paths.js";
55
64
 
56
65
  const HERE = dirname(fileURLToPath(import.meta.url));
57
66
 
@@ -60,7 +69,10 @@ const HELP = `${TOOL_NAME} ${TOOL_VERSION} — measure LV2 plugin latency and CP
60
69
  --rates 48000,96000 REQUIRED. Sample rates to measure at. There is no default:
61
70
  a figure without a stated rate is not a measurement.
62
71
  --dimensions latency,cost
63
- What to measure (default: both).
72
+ What to measure (default: latency,cost); also any of
73
+ stability,rtSafety,features, the hosting pass (the native lv2_measure
74
+ under the interposer, one process per plugin).
75
+ --plugins URI,URI The hosting pass measures only these, and needs no scan.
64
76
  --out PATH Where the document goes (default: stdout).
65
77
  --scan PATH Reuse an existing prober scan instead of scanning.
66
78
  --work-dir PATH Where intermediates go (default: alongside --out, or a temp dir).
@@ -84,6 +96,45 @@ function readJson(path) {
84
96
  return JSON.parse(readFileSync(path, "utf8"));
85
97
  }
86
98
 
99
+ /** The native measurer and its interposer, located; absent, the run is refused naming where it looked. */
100
+ function hostingTools() {
101
+ const at = (name) => {
102
+ const found = locateNativeTool(name, process.env, existsSync);
103
+ if (found.path === undefined) {
104
+ fail(`no ${name} at ${found.looked.join(", ")}: install the plugin-qualify package, build it with make -C native, or set ${found.setting}`);
105
+ }
106
+ return found.path;
107
+ };
108
+ return { tool: at(LV2_MEASURE_TOOL_NAME), interposer: at(RT_INTERPOSE_NAME) };
109
+ }
110
+
111
+ /** The hosting pass: the measurer once per plugin, each in its own process, its lines written as JSONL. */
112
+ function runHosting(step, uris, onProgress) {
113
+ const lines = [];
114
+ onProgress(step, { kind: "phase", plugins: uris.length, rate: Number(step.args[0]) });
115
+ for (const uri of uris) {
116
+ const r = spawnSync(step.command, [uri, ...step.args], {
117
+ encoding: "utf8",
118
+ env: { ...process.env, ...step.env },
119
+ stdio: ["ignore", "pipe", "inherit"],
120
+ timeout: LV2_HOSTING_TIMEOUT_S * 1000,
121
+ maxBuffer: 16 << 20,
122
+ });
123
+ if (r.error !== undefined && r.error.code !== "ETIMEDOUT") throw new Error(`hosting: cannot run ${step.command}: ${r.error.message}`);
124
+ const line = hostingLine(uri, {
125
+ status: r.status,
126
+ ...(r.signal ? { signalNumber: constants.signals[r.signal] } : {}),
127
+ ...(r.error?.code === "ETIMEDOUT" ? { timedOut: true } : {}),
128
+ stdout: r.stdout ?? "",
129
+ });
130
+ process.stderr.write(` ${uri}: exit ${line.exit}\n`);
131
+ onProgress(step, { kind: "plugin" });
132
+ lines.push(line);
133
+ }
134
+ writeFileSync(step.produces, lines.map((l) => `${JSON.stringify(l)}\n`).join(""));
135
+ return lines;
136
+ }
137
+
87
138
  /** Run one pipeline step, forwarding its stderr and counting the plugins it reports. */
88
139
  function runStep(step, onProgress) {
89
140
  return new Promise((settle, reject) => {
@@ -124,14 +175,17 @@ async function main() {
124
175
  if (!parsed.ok) fail(`${parsed.error}\n\n${HELP}`);
125
176
  const options = parsed.options;
126
177
 
127
- const pipeline = buildPipeline(options);
178
+ const hosting = options.dimensions.some((d) => HOSTING_PASS_DIMENSIONS.includes(d));
179
+ const pipeline = buildPipeline(options, hosting ? hostingTools() : undefined);
128
180
 
129
181
  if (options.dryRun) {
130
182
  process.stderr.write(
131
183
  `${TOOL_NAME} would measure ${options.dimensions.join(" + ")} at ${options.rates.join(", ")} Hz\n`,
132
184
  );
133
185
  for (const step of pipeline.steps) {
134
- process.stderr.write(` ${step.id}: ${step.command} ${step.args.join(" ")}\n`);
186
+ const env = Object.entries(step.env ?? {}).map(([k, v]) => `${k}=${v} `).join("");
187
+ const each = step.perPlugin ? `<each of ${options.plugins?.join(", ") ?? "the scan's plugins"}> ` : "";
188
+ process.stderr.write(` ${step.id}: ${env}${step.command} ${each}${step.args.join(" ")}\n`);
135
189
  }
136
190
  return;
137
191
  }
@@ -162,15 +216,22 @@ async function main() {
162
216
  const startedAt = Date.now();
163
217
  const measuredAt = new Date().toISOString();
164
218
  emit({ step: "start", steps: pipeline.steps.map((s) => s.id) });
219
+ let hostingLines;
165
220
  for (const step of pipeline.steps) {
166
221
  process.stderr.write(`== ${step.id}\n`);
167
- await runStep(step, onProgress);
222
+ if (step.perPlugin) {
223
+ const uris = options.plugins ?? urisFromScan(readJson(pipeline.hostingScan));
224
+ hostingLines = runHosting(step, uris, onProgress);
225
+ } else {
226
+ await runStep(step, onProgress);
227
+ }
168
228
  }
169
229
  const elapsedSeconds = (Date.now() - startedAt) / 1000;
170
230
 
171
231
  const document = assembleDocument({
172
232
  options,
173
- catalog: readJson(pipeline.finalCatalog),
233
+ ...(pipeline.finalCatalog === undefined ? {} : { catalog: readJson(pipeline.finalCatalog) }),
234
+ ...(hostingLines === undefined ? {} : { hosting: hostingLines }),
174
235
  costProvenance:
175
236
  pipeline.costProvenance !== undefined && existsSync(pipeline.costProvenance)
176
237
  ? readJson(pipeline.costProvenance)
@@ -191,8 +252,14 @@ async function main() {
191
252
  if (options.outPath === undefined) process.stdout.write(serialised);
192
253
  else writeFileSync(resolve(options.outPath), serialised);
193
254
 
255
+ const hostingFigure = (block) => block !== undefined && block.unmeasuredReason === undefined;
194
256
  const measured = document.plugins.filter(
195
- (p) => Object.keys(p.latency?.perRate ?? {}).length > 0 || Object.keys(p.cost?.perRate ?? {}).length > 0,
257
+ (p) =>
258
+ Object.keys(p.latency?.perRate ?? {}).length > 0 ||
259
+ Object.keys(p.cost?.perRate ?? {}).length > 0 ||
260
+ hostingFigure(p.stability) ||
261
+ hostingFigure(p.rtSafety) ||
262
+ hostingFigure(p.features),
196
263
  ).length;
197
264
  process.stderr.write(
198
265
  `${TOOL_NAME}: ${document.plugins.length} plugin(s), ${measured} with at least one figure, ` +