@openmixer/plugin-qualify 0.5.1 → 0.7.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 +17 -0
- package/dist/clap-qualify.js +8 -10
- package/dist/clap-qualify.js.map +1 -1
- package/dist/cost-fields.d.ts +46 -0
- package/dist/cost-fields.js +46 -0
- package/dist/cost-fields.js.map +1 -0
- package/dist/hosting-codes.d.ts +2 -0
- package/dist/hosting-codes.js +2 -0
- package/dist/hosting-codes.js.map +1 -1
- package/dist/hosting-suitability.d.ts +15 -2
- package/dist/hosting-suitability.js +44 -12
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/measure-host.d.ts +21 -0
- package/dist/measure-host.js +124 -0
- package/dist/measure-host.js.map +1 -0
- package/dist/measurement/cli.d.ts +47 -7
- package/dist/measurement/cli.js +117 -9
- package/dist/measurement/cli.js.map +1 -1
- package/dist/measurement/format.d.ts +5 -0
- package/dist/measurement/format.js +2 -0
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/vocabulary.d.ts +7 -2
- package/dist/measurement/vocabulary.js +9 -3
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/qualify-declarations.d.ts +43 -5
- package/dist/qualify-declarations.js +44 -5
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/tool-paths.d.ts +11 -0
- package/dist/tool-paths.js +18 -1
- package/dist/tool-paths.js.map +1 -1
- package/package.json +1 -1
- package/src/clap-qualify.ts +6 -11
- package/src/cost-fields.ts +72 -0
- package/src/hosting-codes.ts +2 -0
- package/src/hosting-suitability.ts +58 -12
- package/src/index.ts +2 -0
- package/src/measure-host.ts +112 -0
- package/src/measurement/cli.ts +146 -15
- package/src/measurement/format.ts +7 -0
- package/src/measurement/vocabulary.ts +10 -3
- package/src/qualify-declarations.ts +49 -5
- package/src/tool-paths.ts +29 -1
- package/tools/benchmark.py +126 -6
- 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,19 +73,31 @@ 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`)
|
|
56
|
-
* each located by one environment variable, else
|
|
57
|
-
* 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/`.
|
|
58
79
|
*/
|
|
59
80
|
export const CLAP_QUALIFY_TOOL_ENV = "OMX_CLAP_QUALIFY";
|
|
81
|
+
export const LV2_MEASURE_TOOL_ENV = "OMX_LV2_MEASURE";
|
|
60
82
|
export const RT_INTERPOSE_ENV = "OMX_RT_INTERPOSE";
|
|
61
83
|
|
|
62
|
-
/** The file names of the
|
|
84
|
+
/** The file names of the native tools, in every place they are looked for. */
|
|
63
85
|
export const CLAP_QUALIFY_TOOL_NAME = "clap-qualify";
|
|
86
|
+
export const LV2_MEASURE_TOOL_NAME = "lv2_measure";
|
|
64
87
|
export const RT_INTERPOSE_NAME = "rt_interpose.so";
|
|
65
88
|
|
|
66
89
|
/**
|
|
67
|
-
*
|
|
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,
|
|
68
101
|
* `/usr/lib/<package>` on Debian (policy keeps a package's private programs there). The packaging
|
|
69
102
|
* installs into exactly these; a test holds the spec and `debian/rules` to them.
|
|
70
103
|
*/
|
|
@@ -77,6 +110,17 @@ export const CLAP_SWEEP_FRAMES = 512;
|
|
|
77
110
|
/** Seconds one `.clap` may take to qualify at every rate before the prober is killed. */
|
|
78
111
|
export const CLAP_QUALIFY_TIMEOUT_S = 120;
|
|
79
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
|
+
|
|
80
124
|
|
|
81
125
|
|
|
82
126
|
|
package/src/tool-paths.ts
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
11
|
-
import {
|
|
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";
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* `tools/` — `scan.py`, `benchmark.py`, `lv2-measure.mjs`, `hosting-sweep.mjs`. Resolved when a
|
|
@@ -39,7 +45,29 @@ export function rtInterposePath(): string {
|
|
|
39
45
|
return join(nativeBuildDir(), RT_INTERPOSE_NAME);
|
|
40
46
|
}
|
|
41
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
|
+
|
|
42
53
|
/** Where a native tool is looked for when no setting names it, in order: the installed directories, then the own build. */
|
|
43
54
|
export function nativeToolCandidates(name: string): string[] {
|
|
44
55
|
return [...NATIVE_TOOL_DIRS, nativeBuildDir()].map((dir) => join(dir, name));
|
|
45
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 };
|
|
73
|
+
}
|
package/tools/benchmark.py
CHANGED
|
@@ -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
|
|
1095
|
-
|
|
1096
|
-
|
|
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.
|
|
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)
|
package/tools/lv2-measure.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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, ` +
|