@openmixer/plugin-qualify 0.0.0-stage → 0.3.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 (114) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +203 -2
  3. package/bin/plugin-qualify.mjs +77 -0
  4. package/dist/clap-loader.d.ts +147 -0
  5. package/dist/clap-loader.js +252 -0
  6. package/dist/clap-loader.js.map +1 -0
  7. package/dist/clap-qualify.d.ts +126 -0
  8. package/dist/clap-qualify.js +272 -0
  9. package/dist/clap-qualify.js.map +1 -0
  10. package/dist/host-profiles.d.ts +42 -0
  11. package/dist/host-profiles.js +106 -0
  12. package/dist/host-profiles.js.map +1 -0
  13. package/dist/hosting-codes.d.ts +62 -0
  14. package/dist/hosting-codes.js +72 -0
  15. package/dist/hosting-codes.js.map +1 -0
  16. package/dist/hosting-suitability.d.ts +354 -0
  17. package/dist/hosting-suitability.js +654 -0
  18. package/dist/hosting-suitability.js.map +1 -0
  19. package/dist/index.d.ts +17 -0
  20. package/dist/index.js +20 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/loader.d.ts +26 -0
  23. package/dist/loader.js +9 -0
  24. package/dist/loader.js.map +1 -0
  25. package/dist/lv2-loader.d.ts +7 -0
  26. package/dist/lv2-loader.js +50 -0
  27. package/dist/lv2-loader.js.map +1 -0
  28. package/dist/measurement/cli.d.ts +168 -0
  29. package/dist/measurement/cli.js +353 -0
  30. package/dist/measurement/cli.js.map +1 -0
  31. package/dist/measurement/fixtures.d.ts +23 -0
  32. package/dist/measurement/fixtures.js +129 -0
  33. package/dist/measurement/fixtures.js.map +1 -0
  34. package/dist/measurement/format.d.ts +483 -0
  35. package/dist/measurement/format.js +627 -0
  36. package/dist/measurement/format.js.map +1 -0
  37. package/dist/measurement/guards.d.ts +33 -0
  38. package/dist/measurement/guards.js +61 -0
  39. package/dist/measurement/guards.js.map +1 -0
  40. package/dist/measurement/index.d.ts +36 -0
  41. package/dist/measurement/index.js +39 -0
  42. package/dist/measurement/index.js.map +1 -0
  43. package/dist/measurement/merge.d.ts +143 -0
  44. package/dist/measurement/merge.js +315 -0
  45. package/dist/measurement/merge.js.map +1 -0
  46. package/dist/measurement/plan.d.ts +127 -0
  47. package/dist/measurement/plan.js +141 -0
  48. package/dist/measurement/plan.js.map +1 -0
  49. package/dist/measurement/probe-json.d.ts +61 -0
  50. package/dist/measurement/probe-json.js +253 -0
  51. package/dist/measurement/probe-json.js.map +1 -0
  52. package/dist/measurement/provenance.d.ts +91 -0
  53. package/dist/measurement/provenance.js +173 -0
  54. package/dist/measurement/provenance.js.map +1 -0
  55. package/dist/measurement/sweep-jsonl.d.ts +40 -0
  56. package/dist/measurement/sweep-jsonl.js +154 -0
  57. package/dist/measurement/sweep-jsonl.js.map +1 -0
  58. package/dist/measurement/vocabulary.d.ts +125 -0
  59. package/dist/measurement/vocabulary.js +217 -0
  60. package/dist/measurement/vocabulary.js.map +1 -0
  61. package/dist/qualify-cli.d.ts +83 -0
  62. package/dist/qualify-cli.js +224 -0
  63. package/dist/qualify-cli.js.map +1 -0
  64. package/dist/qualify-declarations.d.ts +98 -0
  65. package/dist/qualify-declarations.js +76 -0
  66. package/dist/qualify-declarations.js.map +1 -0
  67. package/dist/qualify-options.d.ts +22 -0
  68. package/dist/qualify-options.js +36 -0
  69. package/dist/qualify-options.js.map +1 -0
  70. package/dist/rating.d.ts +32 -0
  71. package/dist/rating.js +30 -0
  72. package/dist/rating.js.map +1 -0
  73. package/dist/tool-paths.d.ts +12 -0
  74. package/dist/tool-paths.js +29 -0
  75. package/dist/tool-paths.js.map +1 -0
  76. package/fixtures/clap-scan/controls.json +8 -0
  77. package/fixtures/clap-scan/fake.json +16 -0
  78. package/fixtures/clap-scan/mixed.json +18 -0
  79. package/fixtures/zero-path.lv2/manifest.ttl +3 -0
  80. package/fixtures/zero-path.lv2/zero-path.c +85 -0
  81. package/fixtures/zero-path.lv2/zero-path.ttl +21 -0
  82. package/package.json +91 -3
  83. package/src/clap-loader.ts +393 -0
  84. package/src/clap-qualify.ts +387 -0
  85. package/src/host-profiles.ts +122 -0
  86. package/src/hosting-codes.ts +71 -0
  87. package/src/hosting-suitability.ts +982 -0
  88. package/src/index.ts +19 -0
  89. package/src/loader.ts +30 -0
  90. package/src/lv2-loader.ts +50 -0
  91. package/src/measurement/cli.ts +458 -0
  92. package/src/measurement/fixtures.ts +141 -0
  93. package/src/measurement/format.ts +1064 -0
  94. package/src/measurement/guards.ts +64 -0
  95. package/src/measurement/index.ts +39 -0
  96. package/src/measurement/merge.ts +434 -0
  97. package/src/measurement/plan.ts +219 -0
  98. package/src/measurement/probe-json.ts +271 -0
  99. package/src/measurement/provenance.ts +220 -0
  100. package/src/measurement/sweep-jsonl.ts +173 -0
  101. package/src/measurement/vocabulary.ts +298 -0
  102. package/src/qualify-cli.ts +277 -0
  103. package/src/qualify-declarations.ts +117 -0
  104. package/src/qualify-options.ts +49 -0
  105. package/src/rating.ts +53 -0
  106. package/src/tool-paths.ts +32 -0
  107. package/tools/_declarations.py +21 -0
  108. package/tools/benchmark.py +2462 -0
  109. package/tools/gen-python-declarations.d.mts +4 -0
  110. package/tools/gen-python-declarations.mjs +92 -0
  111. package/tools/hosting-sweep.mjs +51 -0
  112. package/tools/lv2-measure.mjs +204 -0
  113. package/tools/lv2_presets.py +132 -0
  114. package/tools/scan.py +432 -0
@@ -0,0 +1,2462 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ """
5
+ Offline plugin-latency benchmark for openmixer.
6
+
7
+ For each plugin in a scanned catalog that reports an `lv2:latency` output port
8
+ (found by scan.py), this instantiates the plugin **offline** with lilv — no JACK,
9
+ no PipeWire, no mod-host — connects silence buffers, runs a few blocks, and reads
10
+ the latency port's value (the plugin's reported latency, in frames). The figure is
11
+ merged back into the catalog under `latency.{reportedFrames,reportedMs,sampleRate}`
12
+ so the operator can weigh a plugin by its REAL latency before inserting it.
13
+
14
+ Why offline lilv and not mod-host: mod-host's `param_get` refuses output control
15
+ ports outright (`resp -103`, invalid symbol — it only reads *input* params), and
16
+ its `monitor_output` feedback channel is change-driven, so a genuinely-zero port
17
+ never reports and is indistinguishable from "not measured". Probing in-process
18
+ reads the port buffer directly after `run()`: zero is zero, unknown is unknown.
19
+ It also keeps the live rig untouched by construction — the plugin never connects
20
+ to an audio server, so nothing can reach the speakers, and the running mixer's
21
+ mod-host is never involved.
22
+
23
+ The probing runs in a **worker subprocess** so one finicky plugin (a lookahead
24
+ pitch-shifter, say) can only crash the worker, never the batch: the parent records
25
+ the in-flight plugin as failed with a reason, respawns the worker, and carries on.
26
+ A per-plugin timeout bounds a plugin that simply never answers. `--isolate` goes
27
+ further and gives each plugin its own throwaway worker, at the cost of reloading
28
+ the LV2 world per plugin; the default reuses one worker and only respawns after a
29
+ crash. The parsing + merge (`parse_worker_line`, `merge_latency`, `annotate`) are
30
+ pure and unit-tested with fixtures (no LV2 needed), per the latency-curation
31
+ design; the smoke test drives the whole batch loop through a stub `--worker-cmd`.
32
+
33
+ The run is still gated (`--run` / `OPENMIXER_BENCH=1`): it needs the host's real
34
+ LV2 plugins installed, so CI must not attempt it.
35
+
36
+ Usage:
37
+ benchmark.py --in data/catalog.json --out data/catalog.json --run
38
+ benchmark.py --in data/catalog.json --rate 48000 --run # explicit rate
39
+ benchmark.py --in data/catalog.json --run --isolate # fresh worker each
40
+ benchmark.py --in data/catalog.json --dry-run # list what would run
41
+ """
42
+ import argparse
43
+ import json
44
+ import os
45
+ import select
46
+ import shlex
47
+ import subprocess
48
+ import sys
49
+ import time
50
+ from collections import namedtuple
51
+
52
+ # `COST_REFERENCE_RATE` and `QUANTUM_FRAMES` are the rig's canonical-rate and quantum
53
+ # declarations (with the rate list and timeouts), generated from `src/qualify-declarations.ts` because this script cannot import
54
+ # a TS module (2026-09-17 declare-derive audit, F8) — see `_declarations.py`'s own header for the
55
+ # regenerate/verify commands.
56
+ from _declarations import (BOOT_TIMEOUT_S, COST_REFERENCE_RATE, COST_TIMEOUT_S,
57
+ MEASURE_BASE_RATE, OPERATIONAL_RATE_FLOOR, PLUGIN_TIMEOUT_S,
58
+ QUALIFY_RATES, QUANTUM_FRAMES)
59
+
60
+ try: # numpy is only needed by the worker (and as a fast path in the pure helpers)
61
+ import numpy as _np
62
+ except ImportError: # pragma: no cover - CI runs the pure tests without numpy
63
+ _np = None
64
+
65
+ DEFAULT_RATE = MEASURE_BASE_RATE
66
+ # The rate the flat/back-compat fields (`measuredFrames`, `measuredMs`) are quoted
67
+ # at. Every rate the product supports is measured; this one is merely the anchor
68
+ # older consumers keep reading.
69
+ REFERENCE_RATE = MEASURE_BASE_RATE
70
+ # The rates a multi-rate run measures by default: the two the product actually
71
+ # runs at plus the two ends of the supported span. A plugin's scaling class is
72
+ # DERIVED from how its numbers move across these, never assumed.
73
+ DEFAULT_RATES = tuple(QUALIFY_RATES)
74
+ # Scaling-class tolerances. Frames: 2 frames or 1 %, whichever is larger (a
75
+ # resampling plugin can land a frame either side).
76
+ SCALING_TOL_FRAMES = 2.0
77
+ SCALING_TOL_FRAMES_REL = 0.01
78
+ # Milliseconds: 0.1 ms or 2 %. The absolute figure is what a fixed-TIME look-ahead
79
+ # quantised to a power-of-two buffer needs — x42-dpl holds 1.33 ms as 64/128/256
80
+ # frames at 48/96/192 kHz but can only manage 56 (not 58.8) at 44.1, a 0.06 ms
81
+ # stagger that is not a different scaling law. It stays an order of magnitude
82
+ # under anything the 5 ms tier boundary can notice.
83
+ SCALING_TOL_MS = 0.1
84
+ SCALING_TOL_MS_REL = 0.02
85
+ # Coherence floor for the `nonconforming` flag, in milliseconds. A set of readings
86
+ # that fits neither scaling law says something worth flagging only if the disagreement
87
+ # could reach a mix; under this floor the flag fires on the onset detector's own noise
88
+ # and devalues itself where rate-dependence is real.
89
+ #
90
+ # 2 ms is the per-channel latency budget,
91
+ # the amount ONE plugin is allowed to spend — not a number tuned to this dataset. The measured population
92
+ # happens to leave it unambiguous: over the 124 plugins that read non-zero at any
93
+ # rate, the `nonconforming` set is bimodal in its worst-rate latency, nine plugins
94
+ # between 0.34 and 1.13 ms (every one a non-linear waveshaper: the guitarix amp/
95
+ # distortion models and ZamTube) and then nothing at all until 2.59 ms. The only
96
+ # plugins inside that empty band — x42-dpl (1.33 ms), the Bode frequency shifter
97
+ # (1.54), the Hilbert transformer (2.25) — all fit a law cleanly, so no coherent
98
+ # plugin sits near the floor and moving it anywhere in the gap changes no verdict.
99
+ COHERENCE_BUDGET_MS = 2.0
100
+ # Blocks of this many frames are run through the plugin before the latency port is
101
+ # read — some plugins only settle their reported latency after processing starts.
102
+ # `QUANTUM_FRAMES` is the rig's own quantum declaration (imported above); this is that fact
103
+ # under the name this file's probing code already uses.
104
+ DEFAULT_BLOCK = QUANTUM_FRAMES
105
+ DEFAULT_RUN_CYCLES = 4
106
+ # Per-plugin probe timeout: a plugin that never answers is recorded as failed
107
+ # rather than hanging the whole batch.
108
+ DEFAULT_TIMEOUT = float(PLUGIN_TIMEOUT_S)
109
+ # Worker boot timeout: loading the LV2 world (600+ plugins) takes a few seconds.
110
+ DEFAULT_BOOT_TIMEOUT = float(BOOT_TIMEOUT_S)
111
+
112
+ # --measure mode: the round-trip probe feeds a unit impulse and reads back the
113
+ # ACTUAL latency (onset index), rather than trusting the declared lv2:latency port.
114
+ DEFAULT_MEASURE_BLOCK = 512
115
+ # Absolute onset backstop, referenced to the unit-amplitude input impulse: -180 dBFS.
116
+ # Only a response whose PEAK stays under this counts as silent, so a genuinely quiet
117
+ # plugin (say -100 dBFS out) is still measured rather than written off.
118
+ MEASURE_THRESHOLD = 1e-9
119
+ # Relative onset criterion: the onset is the first sample within 80 dB of the
120
+ # response's own peak. Referencing the plugin's own answer (not a fixed floor) is
121
+ # what keeps a DC offset or denormal fuzz from reading as an early onset.
122
+ ONSET_RELATIVE = 1e-4
123
+ # How many output frames to capture per plugin. A declared-0 plugin uses the
124
+ # default; a plugin that declares a large latency gets a window that comfortably
125
+ # exceeds it (else its onset would fall past the end and read as "silent").
126
+ DEFAULT_CAPTURE_FRAMES = 16384
127
+ MAX_CAPTURE_FRAMES = 524288
128
+
129
+ # Sustained-signal fallback: a plugin that gates, expands or compresses by
130
+ # threshold passes NO impulse at its default settings, so the impulse probe reads
131
+ # it as silent and it lands in `unclassified` for ever. The fallback holds a tone
132
+ # on the input long enough for the detector to open, then times the impulse that
133
+ # follows while the gate is still within its release.
134
+ PREROLL_SECONDS = 0.25
135
+ PREROLL_TONE_HZ = 1000.0
136
+ PREROLL_TONE_AMP = 0.5 # -6 dBFS: above any plausible default gate threshold
137
+
138
+ # --- CPU-cost pass (--cost) ----------------------------------------------------
139
+ # Bumped whenever a change here would move the numbers; recorded in the run
140
+ # provenance so a stale figure can be told from a fresh one.
141
+ COST_PROBER_VERSION = "cost/1"
142
+ # The block the cost figure is quoted at. 512 frames is what the console's own
143
+ # live target uses, and cost per sample is only weakly block-dependent above a
144
+ # couple of hundred frames — but it IS quoted, never implied.
145
+ DEFAULT_COST_BLOCK = 512
146
+ # Blocks run and TIMED before the steady-state window opens. Their timings are
147
+ # discarded (only their median is kept, as evidence the discard was warranted):
148
+ # the first blocks pay for lazy allocation, filter-table building, cold i-cache
149
+ # and the branch predictor still learning the plugin's inner loops. 64 is the
150
+ # empirically-derived figure: the cost readings stopped moving by then.
151
+ DEFAULT_COST_WARMUP = 64
152
+ # Ceiling on timed blocks per plugin per rate. More blocks buy a better tail
153
+ # estimate; past a few hundred they only buy wall-clock across 958 plugins x 4 rates.
154
+ DEFAULT_COST_BLOCKS = 512
155
+ # Floor on timed blocks: below this a p95 is a single sample's opinion.
156
+ COST_MIN_BLOCKS = 64
157
+ # Per-plugin per-rate wall-time budget. A cheap plugin runs the full ceiling; an
158
+ # expensive one stops at COST_MIN_BLOCKS-or-more once it has spent this long, which
159
+ # is what keeps a convolution reverb from eating the sweep.
160
+ COST_TARGET_SECONDS = 0.25
161
+ # The stimulus: a sustained 1 kHz sine at -6 dBFS held on EVERY audio input, phase
162
+ # continuous across blocks. Silence is not a valid cost stimulus — a plugin that
163
+ # short-circuits on it (or that runs its whole DSP into denormals) reports a cost
164
+ # that has nothing to do with what it does under signal, the same trap the impulse
165
+ # latency probe hit.
166
+ COST_TONE_HZ = 1000.0
167
+ COST_TONE_AMP = 0.5
168
+ # The high percentile reported next to the median. p95, not p99 or max: with
169
+ # COST_MIN_BLOCKS..DEFAULT_COST_BLOCKS timed blocks a p99 is the 1st-to-5th worst
170
+ # sample and a max is exactly one sample, so both are dominated by whatever the OS
171
+ # did to us once (a migration, an interrupt, an SMI). p95 still exposes a plugin
172
+ # whose tail is genuinely fat — the one that xruns mid-show — while being a stable
173
+ # statistic at this sample count.
174
+ COST_PERCENTILE = 95.0
175
+ # Cost is anchored at the rig's canonical rate: the headline figure the operator asked for is
176
+ # "how many of these fit in one core at the rig's rate". `COST_REFERENCE_RATE` is imported above
177
+ # from `_declarations.py` — the same fact `cpu-cost.ts` declares, not a second 96000 literal.
178
+ # A cost probe runs many more blocks than a latency probe; give it room.
179
+ DEFAULT_COST_TIMEOUT = float(COST_TIMEOUT_S)
180
+ # Iterations of the empty-call loop that measures the harness's own timing floor.
181
+ COST_CALIBRATION_ITERS = 512
182
+
183
+ # What measure_offline returns: measured latencies, the plugins that failed (each
184
+ # with a human reason) so one finicky plugin never silently drops the batch,
185
+ # whatever else the worker said about a measurement (which stimulus it took), and
186
+ # what the worker said about ITSELF at boot (the cost pass's timing floor and the
187
+ # cpu it managed to pin to — provenance that has to survive back to the caller).
188
+ BenchmarkResult = namedtuple("BenchmarkResult",
189
+ ["measured", "failures", "details", "workerInfo"],
190
+ defaults=({}, {}))
191
+
192
+
193
+ def latency_port_symbol(descriptor):
194
+ """The plugin's lv2:latency output-port symbol, or None (set by scan.py)."""
195
+ lat = descriptor.get("latency")
196
+ if isinstance(lat, dict):
197
+ return lat.get("portSymbol")
198
+ return None
199
+
200
+
201
+ def benchmark_targets(catalog):
202
+ """Descriptors that report a latency port — the only ones worth benchmarking."""
203
+ return [d for d in catalog if latency_port_symbol(d) is not None]
204
+
205
+
206
+ def format_job(uri, symbol):
207
+ """One job line for the worker: tab-separated so URIs stay unambiguous."""
208
+ return f"{uri}\t{symbol}"
209
+
210
+
211
+ def parse_worker_line(line):
212
+ """Parse one worker reply line into a dict, or None for garbage.
213
+
214
+ The worker speaks JSON lines: `{"ready": true}` once after loading the LV2
215
+ world, then per job either `{"uri": …, "frames": <float>}` on success or
216
+ `{"uri": …, "error": "<reason>"}` on a soft failure. Anything unparseable
217
+ (a crashing plugin can spray text onto stdout) yields None — the caller
218
+ treats the read as failed, never guesses a number.
219
+ """
220
+ if not line:
221
+ return None
222
+ try:
223
+ out = json.loads(line)
224
+ except (ValueError, TypeError):
225
+ return None
226
+ return out if isinstance(out, dict) else None
227
+
228
+
229
+ def format_measure_job(declared_frames, sweep=()):
230
+ """The job payload a MEASURE worker receives: declared-latency hint + sweep list.
231
+
232
+ Encoded as JSON so the sweep list rides along without a second protocol; a bare
233
+ number is still accepted ({@link parse_measure_job}) so an older job line, and
234
+ the stub workers the tests drive, keep working.
235
+ """
236
+ if not sweep:
237
+ return str(float(declared_frames or 0.0))
238
+ return json.dumps({"declared": float(declared_frames or 0.0), "sweep": list(sweep)})
239
+
240
+
241
+ def parse_measure_job(arg):
242
+ """Parse a MEASURE job payload → `{"declared": float, "sweep": [symbol, …]}`. Pure.
243
+
244
+ Accepts both encodings ({@link format_measure_job}) and never raises: garbage
245
+ degrades to "measure at the defaults with no hint", which is the safe reading.
246
+ """
247
+ text = (arg or "").strip()
248
+ if text.startswith("{"):
249
+ try:
250
+ job = json.loads(text)
251
+ except ValueError:
252
+ job = {}
253
+ if isinstance(job, dict):
254
+ try:
255
+ declared = float(job.get("declared") or 0.0)
256
+ except (ValueError, TypeError):
257
+ declared = 0.0
258
+ sweep = job.get("sweep")
259
+ return {"declared": declared,
260
+ "sweep": [str(s) for s in sweep] if isinstance(sweep, list) else []}
261
+ try:
262
+ return {"declared": float(text), "sweep": []}
263
+ except ValueError:
264
+ return {"declared": 0.0, "sweep": []}
265
+
266
+
267
+ def merge_latency(descriptor, frames, sample_rate):
268
+ """Return a copy of `descriptor` with measured latency merged into `latency`.
269
+
270
+ Preserves the scanner's `portSymbol`; adds `reportedFrames`, `reportedMs`,
271
+ `sampleRate`. `frames=None` leaves the descriptor's latency block untouched
272
+ (a read that failed must not erase a port symbol or fake a zero).
273
+ """
274
+ out = dict(descriptor)
275
+ base = dict(out.get("latency") or {})
276
+ if frames is not None and sample_rate and sample_rate > 0:
277
+ base["reportedFrames"] = frames
278
+ base["reportedMs"] = (frames / sample_rate) * 1000.0
279
+ base["sampleRate"] = sample_rate
280
+ if base:
281
+ out["latency"] = base
282
+ return out
283
+
284
+
285
+ def annotate(catalog, measured, sample_rate):
286
+ """Apply `measured` ({uri: frames}) onto a catalog → a new annotated catalog.
287
+
288
+ Pure: no subprocesses. The benchmark's batch loop produces `measured`; this
289
+ folds it in. A uri absent from `measured` is passed through unchanged.
290
+ """
291
+ out = []
292
+ for d in catalog:
293
+ frames = measured.get(d["uri"])
294
+ out.append(merge_latency(d, frames, sample_rate) if frames is not None else d)
295
+ return out
296
+
297
+
298
+ def peak_abs(samples):
299
+ """Largest |value| in `samples` (0.0 when empty). Pure; numpy fast path."""
300
+ if _np is not None and isinstance(samples, _np.ndarray):
301
+ return float(_np.max(_np.abs(samples))) if samples.size else 0.0
302
+ peak = 0.0
303
+ for v in samples:
304
+ a = abs(v)
305
+ if a > peak:
306
+ peak = a
307
+ return peak
308
+
309
+
310
+ def first_above(samples, level):
311
+ """Index of the first sample whose |value| exceeds `level`, or None. Pure."""
312
+ if _np is not None and isinstance(samples, _np.ndarray):
313
+ hits = _np.flatnonzero(_np.abs(samples) > level)
314
+ return int(hits[0]) if hits.size else None
315
+ for i, v in enumerate(samples):
316
+ if abs(v) > level:
317
+ return i
318
+ return None
319
+
320
+
321
+ def dc_level(samples):
322
+ """A robust constant-offset estimate for `samples`: their median. Pure.
323
+
324
+ The median, not the mean: an impulse response is sparse, so the median lands
325
+ on the plugin's resting output (the DC offset it leaks) and is barely moved by
326
+ the response itself, whereas the mean is dragged by a long tail. Subtracting it
327
+ is what stops a leaked offset from reading as an onset at frame 0.
328
+ """
329
+ if _np is not None and isinstance(samples, _np.ndarray):
330
+ return float(_np.median(samples)) if samples.size else 0.0
331
+ ordered = sorted(samples)
332
+ n = len(ordered)
333
+ if n == 0:
334
+ return 0.0
335
+ mid = n // 2
336
+ return ordered[mid] if n % 2 else (ordered[mid - 1] + ordered[mid]) / 2.0
337
+
338
+
339
+ def detect_onset(samples, threshold=MEASURE_THRESHOLD, relative=ONSET_RELATIVE):
340
+ """Index of the response's onset, or None when there is no response.
341
+
342
+ Pure + unit-tested: this is the actual round-trip latency recovery. `samples`
343
+ is the plugin's RESIDUAL response to a unit impulse fed at frame 0 — the
344
+ impulse pass minus a silent-input baseline pass, so anything the plugin emits
345
+ regardless of the stimulus (a DC offset, denormal fuzz, idle hiss) is already
346
+ common-moded out. Any sequence of floats works (a list in tests, a numpy array
347
+ in the probe); the returned index IS the measured latency in frames.
348
+
349
+ Three steps, all referenced to the signal rather than to a magic constant:
350
+
351
+ - any constant offset ({@link dc_level}) is subtracted first, so a plugin that
352
+ leaks DC from frame 0 does not read as "onset 0, zero latency";
353
+ - the centred response must PEAK above `threshold`, an absolute backstop
354
+ referenced to the unit-amplitude input impulse (-180 dBFS). Below that there
355
+ is no answer to time and the result is `None` (silent — not "0 frames"). The
356
+ backstop is deliberately far below the audible floor so a genuinely quiet
357
+ response (-100 dBFS) is still measured;
358
+ - the onset is then the first sample within `relative` of that peak, so a
359
+ precursor 80 dB down (denormal fuzz, filter warm-up) cannot pull the onset
360
+ earlier than the real arrival.
361
+
362
+ A plain plugin returns 0/1, a linear-phase EQ its half-window, a look-ahead
363
+ limiter its look-ahead; a 100%-wet reverb returns ~0 (its tail is not latency).
364
+ The acknowledged corner is a plugin whose response to an impulse is a sustained
365
+ DC STEP: its median is the step, which centring removes. Nothing in the scanned
366
+ 958 behaves that way, and the probe's differential pass would keep it honest.
367
+ """
368
+ dc = dc_level(samples)
369
+ if not dc:
370
+ centred = samples
371
+ elif _np is not None and isinstance(samples, _np.ndarray):
372
+ centred = samples - dc
373
+ else:
374
+ centred = [v - dc for v in samples]
375
+ peak = peak_abs(centred)
376
+ if peak <= threshold:
377
+ return None
378
+ return first_above(centred, max(threshold, relative * peak))
379
+
380
+
381
+ def residual(response, baseline):
382
+ """`response - baseline`, elementwise, over the common length. Pure.
383
+
384
+ The differential measurement: the same plugin run twice, once with the impulse
385
+ and once with silence. Whatever it emits either way (DC, denormals, idle noise,
386
+ a deterministic LFO) cancels, leaving only what the impulse caused.
387
+ """
388
+ if _np is not None and isinstance(response, _np.ndarray) and isinstance(baseline, _np.ndarray):
389
+ n = min(response.size, baseline.size)
390
+ return response[:n] - baseline[:n]
391
+ return [a - b for a, b in zip(response, baseline)]
392
+
393
+
394
+ def capture_frames(declared_frames, rate=DEFAULT_RATE):
395
+ """How many output frames to capture for a plugin declaring `declared_frames`.
396
+
397
+ Comfortably exceeds the declared value so a genuine large latency isn't
398
+ truncated (which would misread as silent), floored at DEFAULT_CAPTURE_FRAMES
399
+ for the common declared-0 case and capped at MAX_CAPTURE_FRAMES to bound the
400
+ run. Pure so it can be reasoned about without instantiating anything.
401
+
402
+ The floor and the cap scale with `rate`: the window is a span of TIME, and a
403
+ fixed-time plugin's latency in frames doubles when the rate doubles, so a
404
+ window frozen at the 48 kHz frame count would truncate at 96/192 kHz and
405
+ misread a real look-ahead as silence.
406
+ """
407
+ scale = max(1.0, float(rate) / DEFAULT_RATE)
408
+ base = int(declared_frames) if declared_frames and declared_frames > 0 else 0
409
+ floor = int(DEFAULT_CAPTURE_FRAMES * scale)
410
+ cap = int(MAX_CAPTURE_FRAMES * scale)
411
+ return min(max(floor, base * 3 + 2048), cap)
412
+
413
+
414
+ # --- the parameter sweep -------------------------------------------------------
415
+ # Latency is frequently A CONTROL: a look-ahead time, an FFT window size, an
416
+ # oversampling factor. Sweeping every control of every plugin is not affordable
417
+ # (5399 candidate ports over the 958 scanned, and each measurement is two renders),
418
+ # so candidates are ranked by how specifically their name points at a latency
419
+ # mechanism and only the top few per plugin are measured. Ordered most-specific
420
+ # first; the rank IS the tuple order.
421
+ SWEEP_KEYWORDS = (
422
+ "lookahead", "look-ahead", "look ahead", "latency", "fft", "linphase",
423
+ "linear phase", "oversampl", "window", "taps", "tap ", "order", "predelay",
424
+ "pre-delay", "buffer size", "block size", "resolution", "precision",
425
+ "length", "size", "quality", "mode",
426
+ )
427
+ # Per-plugin cap on swept controls. Three covers every plugin that has a genuine
428
+ # latency control (the rest of the matches are algorithm/voicing switches) and
429
+ # keeps the sweep to a few thousand extra measurements rather than tens of
430
+ # thousands. Note "delay" is deliberately NOT a keyword: a delay line's time
431
+ # control moves the onset because moving the onset IS the effect, and 1998 ports
432
+ # would have matched it.
433
+ SWEEP_MAX_CONTROLS = 3
434
+ # A control counts as latency-bearing when it moves the onset by more than this.
435
+ SWEEP_MIN_DELTA_FRAMES = 2.0
436
+
437
+
438
+ def sweep_candidates(params, limit=SWEEP_MAX_CONTROLS):
439
+ """The control symbols worth sweeping for latency, most-promising first. Pure.
440
+
441
+ `params` is a scanned descriptor's `params` list. A candidate must be a control
442
+ port with a real range (a fixed `min == max` cannot move anything) and a name or
443
+ symbol naming a latency mechanism ({@link SWEEP_KEYWORDS}). At most `limit` are
444
+ returned — this is a cost bound, and the ranking is what makes the bound safe.
445
+ """
446
+ ranked = []
447
+ for index, p in enumerate(params):
448
+ if p.get("kind") != "control":
449
+ continue
450
+ mn, mx = p.get("min"), p.get("max")
451
+ if mn is None or mx is None or mn == mx:
452
+ continue
453
+ text = f"{p.get('symbol', '')} {p.get('name', '')}".lower()
454
+ for rank, keyword in enumerate(SWEEP_KEYWORDS):
455
+ if keyword in text:
456
+ ranked.append((rank, index, p["symbol"]))
457
+ break
458
+ ranked.sort()
459
+ return [symbol for _rank, _index, symbol in ranked[:limit]]
460
+
461
+
462
+ def summarise_sweep(default_frames, swept, rate, min_delta=SWEEP_MIN_DELTA_FRAMES):
463
+ """Fold a sweep's raw points into the catalog's `paramSweep` block, or None. Pure.
464
+
465
+ `swept` maps a control symbol to `{"min": frames, "max": frames}` — the latency
466
+ measured with that one control pinned to each end of its range, everything else
467
+ at its default. Only controls that MOVE the latency by more than `min_delta`
468
+ are kept: the point of the sweep is to find which controls are latency-bearing,
469
+ and recording the ones that are not would bury that in noise. Returns None when
470
+ no control moved anything, so the vast majority of plugins carry no block at all.
471
+
472
+ The surviving block carries the full span (`minFrames`/`maxFrames` in frames and
473
+ milliseconds) so a UI can tell an operator what raising look-ahead will cost.
474
+ """
475
+ moving = {}
476
+ for symbol, points in sorted(swept.items()):
477
+ values = [v for v in (points.get("min"), points.get("max")) if v is not None]
478
+ if not values:
479
+ continue
480
+ if max(abs(v - default_frames) for v in values) <= min_delta:
481
+ continue
482
+ moving[symbol] = {"minFrames": float(min(values)), "maxFrames": float(max(values))}
483
+ if not moving:
484
+ return None
485
+ span = [default_frames]
486
+ for entry in moving.values():
487
+ span += [entry["minFrames"], entry["maxFrames"]]
488
+ lo, hi = min(span), max(span)
489
+ return {
490
+ "rate": int(rate),
491
+ "defaultFrames": float(default_frames),
492
+ "defaultMs": float(default_frames) / rate * 1000.0,
493
+ "minFrames": float(lo),
494
+ "minMs": float(lo) / rate * 1000.0,
495
+ "maxFrames": float(hi),
496
+ "maxMs": float(hi) / rate * 1000.0,
497
+ "controls": moving,
498
+ }
499
+
500
+
501
+ def control_default(default, minimum, maximum):
502
+ """A sane value for a control-input port: its lv2:default, else the midpoint.
503
+
504
+ `default`/`minimum`/`maximum` are lilv range nodes (any may be None). The
505
+ default is authoritative; with no default, the midpoint of the declared range
506
+ keeps the plugin in a normal operating point; failing that, 0.0.
507
+ """
508
+ if default is not None:
509
+ try:
510
+ return float(str(default))
511
+ except (ValueError, TypeError):
512
+ pass
513
+ try:
514
+ return (float(str(minimum)) + float(str(maximum))) / 2.0
515
+ except (ValueError, TypeError):
516
+ return 0.0
517
+
518
+
519
+ def _within(values, absolute, relative):
520
+ """True when `values` all agree to within `absolute` or `relative` of their mean."""
521
+ lo, hi = min(values), max(values)
522
+ tol = max(absolute, relative * (sum(values) / len(values)))
523
+ return (hi - lo) <= tol
524
+
525
+
526
+ def unreliable_readings(per_rate):
527
+ """The readings in `per_rate` that are physically impossible → {rate: reason}. Pure.
528
+
529
+ A reading is evidence only if some latency could have produced it. One kind
530
+ cannot: **a 0-frame reading from a plugin that measured non-zero at another
531
+ rate.** Both scaling laws map zero to zero — a constant frame count of 0 stays
532
+ 0, and 0 ms is 0 ms at every rate — so no single latency yields 0 here and 16
533
+ frames there. What produces it is the onset detector, on a plugin whose impulse
534
+ response is not a fixed filter: a non-linear waveshaper (an amp or distortion
535
+ model) with internal oversampling answers a unit impulse differently at each
536
+ rate, and the first sample within the relative arrival threshold lands
537
+ somewhere else each time — or, at 96 kHz, on sample 0.
538
+
539
+ Guarded against the case that IS legitimate: sub-frame rounding. A true latency
540
+ of 0.4 frames honestly reads 0 at 48 kHz and 1 at 192. So a zero is condemned
541
+ only when the SMALLEST non-zero evidence, projected onto this rate under the more
542
+ forgiving of the two laws, still exceeds a whole frame — i.e. even the kindest
543
+ reading of the other rates says a frame should have been visible here.
544
+
545
+ Reasons are short, machine-readable and stored on the plugin, so the next reader
546
+ of the catalog inherits the finding rather than re-deriving it.
547
+ """
548
+ points = {int(r): float(f) for r, f in per_rate.items() if f is not None}
549
+ nonzero = [(r, f) for r, f in points.items() if f > 0.0]
550
+ if not nonzero:
551
+ return {}
552
+ out = {}
553
+ for rate, frames in points.items():
554
+ if frames != 0.0:
555
+ continue
556
+ # fixed-frame projects f frames onto this rate; fixed-time projects f*rate/r.
557
+ implied = min(min(f, f * rate / r) for r, f in nonzero)
558
+ if implied >= 1.0:
559
+ out[rate] = "impossible-zero"
560
+ return dict(sorted(out.items()))
561
+
562
+
563
+ def _fit_scaling(points):
564
+ """The scaling law `points` (rate → frames, coherent readings only) obey. Pure."""
565
+ if not points:
566
+ return "unmeasured"
567
+ if len(points) < 2:
568
+ return "single-rate"
569
+ frames = list(points.values())
570
+ if all(f == 0.0 for f in frames):
571
+ return "zero"
572
+ if _within(frames, SCALING_TOL_FRAMES, SCALING_TOL_FRAMES_REL):
573
+ return "fixed-frame"
574
+ ms = [f / r * 1000.0 for r, f in points.items()]
575
+ if _within(ms, SCALING_TOL_MS, SCALING_TOL_MS_REL):
576
+ return "fixed-time"
577
+ if max(ms) < COHERENCE_BUDGET_MS:
578
+ # incoherent, but every reading fits inside the budget one plugin may spend:
579
+ # nothing a mix can hear turns on which law this obeys, and flagging it would
580
+ # spend the flag's credibility on the detector's noise.
581
+ return "zero"
582
+ return "nonconforming"
583
+
584
+
585
+ def _coherent_points(per_rate):
586
+ """`per_rate` as rate → frames with the {@link unreliable_readings} dropped. Pure."""
587
+ points = {int(r): float(f) for r, f in per_rate.items() if f is not None}
588
+ for rate in unreliable_readings(points):
589
+ del points[rate]
590
+ return points
591
+
592
+
593
+ def _operational_refit(points):
594
+ """The class the operational rates alone give a plugin the full fit flags, or None. Pure.
595
+
596
+ A reading below {@link OPERATIONAL_RATE_FLOOR} is informational for the class
597
+ (`lv2-measurement-interchange.md` §1a): it may not make a plugin `nonconforming` on its own.
598
+ So when every reading together fits no law, the readings at and above the floor are fitted
599
+ alone — given at least two of them — and a law found there is the class. None when the full
600
+ fit is not `nonconforming`, or the operational readings flag it too: only the flag is ever
601
+ re-derived, so a fit that holds over every reading is never overturned by fewer points.
602
+ """
603
+ if _fit_scaling(points) != "nonconforming":
604
+ return None
605
+ operational = {r: f for r, f in points.items() if r >= OPERATIONAL_RATE_FLOOR}
606
+ if len(operational) < 2 or len(operational) == len(points):
607
+ return None
608
+ refit = _fit_scaling(operational)
609
+ return None if refit == "nonconforming" else refit
610
+
611
+
612
+ def classify_scaling(per_rate):
613
+ """Derive a plugin's latency SCALING CLASS from measurements at several rates.
614
+
615
+ `per_rate` maps sample rate (Hz) → measured latency in frames. The class is
616
+ read off the evidence, never assumed from what the plugin claims to be:
617
+
618
+ - `"zero"` — no latency a mix can notice. Either literally 0 frames at
619
+ every rate (both laws hold; calling it either would pollute
620
+ the census), or readings that fit no law but stay inside
621
+ {@link COHERENCE_BUDGET_MS} at every rate, where the
622
+ disagreement is the detector's noise and not the plugin's.
623
+ - `"fixed-frame"` — the frame count is constant, so the millisecond cost
624
+ HALVES when the rate doubles (an FFT window, a fixed
625
+ look-ahead buffer, a filter of N taps).
626
+ - `"fixed-time"` — the millisecond figure is constant, so the frame count
627
+ DOUBLES with the rate (a 5 ms look-ahead limiter).
628
+ - `"nonconforming"` — fits neither law AT A MAGNITUDE THAT MATTERS. Genuinely
629
+ interesting (a plugin that switches algorithm by rate, or
630
+ internally resamples); flagged, never averaged away.
631
+ - `"single-rate"` — one usable reading; nothing can be derived.
632
+ - `"unmeasured"` — no rate produced a figure.
633
+
634
+ Coherence comes first: readings {@link unreliable_readings} condemns are dropped
635
+ before any law is fitted, because a failed measurement that defines a class is
636
+ worse than no measurement at all — ZamTube's 32/32/**0**/32 is a 32-frame
637
+ fixed-frame plugin with one dropped reading, not a plugin whose latency vanishes
638
+ at 96 kHz and returns at 192.
639
+
640
+ When both laws hold at once beyond the all-zero case (a 1-frame plugin, say,
641
+ where 2 frames of tolerance swallows the difference) the frame fit wins: a
642
+ constant tiny frame count is what such plugins actually are.
643
+
644
+ `nonconforming` is earned at the operational rates ({@link _operational_refit}): Calf's
645
+ psychoacoustic clipper holds 5.333 ms at 48/96/192 kHz and rounds its 44.1 kHz buffer to
646
+ the 48 kHz frame count — `fixed-time`, with the 44.1 kHz reading kept as informational.
647
+ """
648
+ points = _coherent_points(per_rate)
649
+ refit = _operational_refit(points)
650
+ return refit if refit is not None else _fit_scaling(points)
651
+
652
+
653
+ def scaling_note(per_rate):
654
+ """Why a derived class is not the naive fit over every reading, or None. Pure.
655
+
656
+ Machine-readable, stored as `latency.scalingNote`. Two values:
657
+ `"below-budget"`: the surviving readings fit no law, but every one of them is under
658
+ {@link COHERENCE_BUDGET_MS}, so the plugin is called `zero` rather than flagged.
659
+ `perRate` still holds what was actually measured, so no figure is lost — only the
660
+ claim that the disagreement means anything.
661
+
662
+ `"informational-rate"`: the full fit flags the plugin, but the operational rates alone obey
663
+ a law ({@link _operational_refit}); the reading below {@link OPERATIONAL_RATE_FLOOR} that
664
+ disagrees stays in `perRate` and the note says why the class ignores it.
665
+
666
+ Excluded readings are NOT noted here; they are recorded per rate, with their reason,
667
+ in `latency.unreliableRates`.
668
+ """
669
+ points = _coherent_points(per_rate)
670
+ if len(points) < 2 or all(f == 0.0 for f in points.values()):
671
+ return None
672
+ if _operational_refit(points) is not None:
673
+ return "informational-rate"
674
+ return "below-budget" if _fit_scaling(points) == "zero" else None
675
+
676
+
677
+ def apply_coherence(lat, per_rate):
678
+ """Write the derived class and its coherence findings into a `latency` dict, in place.
679
+
680
+ The one place `scalingClass`, `unreliableRates` and `scalingNote` are set together, so
681
+ a fresh measurement (`annotate_per_rate`) and a re-derivation over already-measured
682
+ data (`--reclassify`) cannot drift apart. Absent findings are REMOVED, not left
683
+ stale, so re-running over an older catalog is idempotent.
684
+ """
685
+ lat["scalingClass"] = classify_scaling(per_rate)
686
+ bad = unreliable_readings(per_rate)
687
+ if bad:
688
+ lat["unreliableRates"] = [
689
+ {"rate": rate, "frames": float(per_rate[rate]), "reason": reason}
690
+ for rate, reason in bad.items()
691
+ ]
692
+ else:
693
+ lat.pop("unreliableRates", None)
694
+ note = scaling_note(per_rate)
695
+ if note:
696
+ lat["scalingNote"] = note
697
+ else:
698
+ lat.pop("scalingNote", None)
699
+ return lat
700
+
701
+
702
+ def reclassify(catalog):
703
+ """Re-derive every plugin's scaling class from the `perRate` ALREADY in the catalog. Pure.
704
+
705
+ The measurement is the expensive, rig-bound part (958 plugins × 4 rates); the class is
706
+ arithmetic over figures the catalog already holds. When a classification rule changes,
707
+ this replays it over the committed data instead of re-probing hardware, so the diff is
708
+ provably a change of interpretation and not a second, differently-noisy measurement.
709
+ """
710
+ out = []
711
+ for d in catalog:
712
+ lat = d.get("latency")
713
+ per_rate = (lat or {}).get("perRate")
714
+ if not isinstance(lat, dict) or not isinstance(per_rate, dict):
715
+ out.append(d)
716
+ continue
717
+ points = {int(r): float(v["frames"]) for r, v in per_rate.items()
718
+ if isinstance(v, dict) and v.get("frames") is not None}
719
+ nd = dict(d)
720
+ nd["latency"] = apply_coherence(dict(lat), points)
721
+ out.append(nd)
722
+ return out
723
+
724
+
725
+ def per_rate_block(per_rate):
726
+ """The catalog's `latency.perRate` block: rate (as a string key) → frames + ms."""
727
+ return {
728
+ str(int(rate)): {"frames": float(frames), "ms": float(frames) / int(rate) * 1000.0}
729
+ for rate, frames in sorted(per_rate.items())
730
+ if frames is not None
731
+ }
732
+
733
+
734
+ def annotate_per_rate(catalog, by_rate, reference_rate=REFERENCE_RATE, tol=2):
735
+ """Fold a MULTI-RATE measurement into a catalog → a new annotated catalog. Pure.
736
+
737
+ `by_rate` maps sample rate → `{"measured": {uri: frames}, "unmeasurable": {uri: reason}}`
738
+ (one entry per rate the batch ran at). Each plugin gets:
739
+
740
+ - `latency.perRate` — every rate that measured, in frames AND milliseconds, so
741
+ no consumer has to re-derive one from the other;
742
+ - `latency.scalingClass` — {@link classify_scaling} over those points;
743
+ - `latency.{measuredFrames,measuredMs,sampleRate}` at `reference_rate`, kept flat
744
+ for consumers written before the per-rate schema existed;
745
+ - `latency.declaredMismatch` against the declared `lv2:latency` port, at the
746
+ reference rate (the rate the port was read at);
747
+ - `latency.unmeasurable` ONLY when NO rate could measure it — a plugin that
748
+ measures at 96 kHz but not at 48 is measured, not quarantined.
749
+ """
750
+ out = []
751
+ for d in catalog:
752
+ uri = d["uri"]
753
+ points = {r: run["measured"][uri] for r, run in by_rate.items()
754
+ if uri in run.get("measured", {})}
755
+ reasons = {r: run["unmeasurable"][uri] for r, run in by_rate.items()
756
+ if uri in run.get("unmeasurable", {})}
757
+ if not points and not reasons:
758
+ out.append(d)
759
+ continue
760
+ nd = dict(d)
761
+ lat = dict(nd.get("latency") or {})
762
+ if points:
763
+ lat["perRate"] = per_rate_block(points)
764
+ apply_coherence(lat, points)
765
+ lat.pop("unmeasurable", None)
766
+ stimulus = next(
767
+ (run["details"][uri]["stimulus"] for run in by_rate.values()
768
+ if uri in run.get("details", {}) and "stimulus" in run["details"][uri]),
769
+ None,
770
+ )
771
+ if stimulus:
772
+ # measured only under an escalated stimulus: the figure describes
773
+ # that operating point, not the plugin sitting at its defaults.
774
+ lat["stimulus"] = stimulus
775
+ else:
776
+ lat.pop("stimulus", None)
777
+ lat.pop("paramSweep", None)
778
+ for rate, run in by_rate.items():
779
+ swept = run.get("details", {}).get(uri, {}).get("sweep")
780
+ if not swept or uri not in run["measured"]:
781
+ continue
782
+ block = summarise_sweep(float(run["measured"][uri]), swept, rate)
783
+ if block:
784
+ lat["paramSweep"] = block
785
+ anchor = reference_rate if reference_rate in points else min(points)
786
+ frames = float(points[anchor])
787
+ lat["measuredFrames"] = frames
788
+ lat["measuredMs"] = frames / anchor * 1000.0
789
+ lat["sampleRate"] = anchor
790
+ if anchor == reference_rate:
791
+ # the declared lv2:latency port was read at the reference rate, so
792
+ # that is the only rate the cross-check is a like-for-like compare.
793
+ declared = lat.get("reportedFrames") or 0.0
794
+ lat["declaredMismatch"] = abs(frames - declared) > tol
795
+ else:
796
+ lat.pop("declaredMismatch", None)
797
+ else:
798
+ lat["unmeasurable"] = reasons.get(reference_rate, next(iter(reasons.values())))
799
+ nd["latency"] = lat
800
+ out.append(nd)
801
+ return out
802
+
803
+
804
+ # --- CPU cost: pure aggregation + realtime-budget maths ------------------------
805
+
806
+
807
+ def percentile(values, pct):
808
+ """Linear-interpolated percentile of `values` (unsorted ok). Pure, no numpy.
809
+
810
+ Matches the usual "type 7" definition (numpy's default) so a figure computed
811
+ here and one recomputed by a consumer agree. `None` for an empty input — an
812
+ absent statistic is never a zero one.
813
+ """
814
+ data = sorted(float(v) for v in values)
815
+ if not data:
816
+ return None
817
+ if len(data) == 1:
818
+ return data[0]
819
+ pos = (len(data) - 1) * max(0.0, min(100.0, float(pct))) / 100.0
820
+ low = int(pos)
821
+ high = min(low + 1, len(data) - 1)
822
+ return data[low] + (data[high] - data[low]) * (pos - low)
823
+
824
+
825
+ def drop_warmup(block_times, warmup):
826
+ """`(warm, steady)` — the discarded warm-up head and the steady-state tail. Pure.
827
+
828
+ The split is positional and deliberately dumb: the FIRST `warmup` timed blocks
829
+ go, whatever they cost. An adaptive "drop until it settles" rule would let a
830
+ plugin that never settles report the tail it happens to like, which is the one
831
+ failure mode a cost figure must not have.
832
+ """
833
+ n = max(0, int(warmup))
834
+ return list(block_times[:n]), list(block_times[n:])
835
+
836
+
837
+ def core_fraction(ns_per_sample, rate):
838
+ """Fraction of ONE core's realtime budget: ns of CPU per sample x samples per second.
839
+
840
+ Pure, and the whole normalisation in one line — a plugin costing 20 ns per
841
+ sample at 96 kHz burns 20e-9 * 96000 = 0.19 % of a core. Independent of block
842
+ size by construction, which is why the per-sample figure is what gets stored.
843
+ """
844
+ if rate <= 0:
845
+ return None
846
+ return float(ns_per_sample) * float(rate) / 1e9
847
+
848
+
849
+ def instances_per_core(fraction):
850
+ """How many instances fit in one core's realtime budget at that cost. Pure.
851
+
852
+ Floor, not round: 3.9 instances means 3 fit. `None` when the cost is zero or
853
+ negative — "unbounded" is not a number an operator can act on, and reporting a
854
+ huge integer there would be a lie dressed as precision.
855
+ """
856
+ if fraction is None or fraction <= 0.0:
857
+ return None
858
+ return int(1.0 / float(fraction))
859
+
860
+
861
+ def summarise_cost(block_times_ns, block_frames, rate, warmup=DEFAULT_COST_WARMUP,
862
+ pct=COST_PERCENTILE):
863
+ """Fold per-block wall times into one rate's cost record. Pure.
864
+
865
+ `block_times_ns` is EVERY timed block including the warm-up head; the split is
866
+ done here (not in the worker) so the record can carry `warmupNsPerSampleMedian`
867
+ as evidence that discarding was warranted rather than assumed.
868
+
869
+ Returns `None` when no steady-state block survives — an unmeasurable cost is
870
+ recorded as unmeasurable, never as a cheap one.
871
+ """
872
+ warm, steady = drop_warmup(block_times_ns, warmup)
873
+ if not steady or block_frames <= 0:
874
+ return None
875
+ per_sample = [t / float(block_frames) for t in steady]
876
+ median = percentile(per_sample, 50.0)
877
+ high = percentile(per_sample, pct)
878
+ record = {
879
+ "nsPerSampleMedian": median,
880
+ "nsPerSampleP95": high,
881
+ "nsPerSampleMin": min(per_sample),
882
+ "nsPerSampleMax": max(per_sample),
883
+ "coreFractionMedian": core_fraction(median, rate),
884
+ "coreFractionP95": core_fraction(high, rate),
885
+ "instancesPerCoreP95": instances_per_core(core_fraction(high, rate)),
886
+ "blocks": len(steady),
887
+ "warmupBlocks": len(warm),
888
+ "blockFrames": int(block_frames),
889
+ }
890
+ if warm:
891
+ record["warmupNsPerSampleMedian"] = percentile(
892
+ [t / float(block_frames) for t in warm], 50.0)
893
+ return record
894
+
895
+
896
+ def annotate_cost(catalog, by_rate, reference_rate=COST_REFERENCE_RATE):
897
+ """Fold a multi-rate CPU-cost run into a catalog → a new annotated catalog. Pure.
898
+
899
+ `by_rate` maps rate → `{"measured": {uri: record}, "unmeasurable": {uri: reason},
900
+ "failures": {uri: reason}}`. Each plugin gets a `cpuCost` block holding:
901
+
902
+ - `perRate` — the full record per rate, keyed by rate as a string;
903
+ - `referenceRate` + the flat `nsPerSampleMedian` / `coreFractionP95` /
904
+ `instancesPerCoreP95` at it, so the headline figure needs no lookup;
905
+ - `unmeasurable` / `failed` ONLY when NO rate produced a figure, with the
906
+ reason. A plugin measured at one rate and not another is measured.
907
+
908
+ The provenance (host, CPU, governor, block, stimulus) is NOT copied into every
909
+ descriptor: it is one run-level fact, written alongside by {@link host_provenance}.
910
+ """
911
+ out = []
912
+ for d in catalog:
913
+ uri = d["uri"]
914
+ points = {r: run["measured"][uri] for r, run in by_rate.items()
915
+ if uri in run.get("measured", {})}
916
+ reasons = {r: run["unmeasurable"][uri] for r, run in by_rate.items()
917
+ if uri in run.get("unmeasurable", {})}
918
+ failed = {r: run["failures"][uri] for r, run in by_rate.items()
919
+ if uri in run.get("failures", {})}
920
+ if not points and not reasons and not failed:
921
+ out.append(d)
922
+ continue
923
+ nd = dict(d)
924
+ cost = {}
925
+ if points:
926
+ cost["perRate"] = {str(r): points[r] for r in sorted(points)}
927
+ anchor = reference_rate if reference_rate in points else max(points)
928
+ cost["referenceRate"] = anchor
929
+ for key in ("nsPerSampleMedian", "nsPerSampleP95",
930
+ "coreFractionMedian", "coreFractionP95",
931
+ "instancesPerCoreP95"):
932
+ cost[key] = points[anchor][key]
933
+ if any(p.get("silentOutput") for p in points.values()):
934
+ # It ran and it was timed, but it emitted nothing under a signal
935
+ # that should have passed: the figure may describe a short-circuit
936
+ # path rather than the DSP. Flagged, not dropped, not trusted.
937
+ cost["silentOutput"] = True
938
+ elif reasons:
939
+ cost["unmeasurable"] = reasons.get(reference_rate,
940
+ next(iter(reasons.values())))
941
+ else:
942
+ cost["failed"] = failed.get(reference_rate, next(iter(failed.values())))
943
+ nd["cpuCost"] = cost
944
+ out.append(nd)
945
+ return out
946
+
947
+
948
+ def cost_census(annotated, reference_rate=COST_REFERENCE_RATE):
949
+ """`(measured, unmeasurable, failed)` URI counts of a cost annotation. Pure."""
950
+ measured = unmeasurable = failed = 0
951
+ for d in annotated:
952
+ cost = d.get("cpuCost")
953
+ if not isinstance(cost, dict):
954
+ continue
955
+ if cost.get("perRate"):
956
+ measured += 1
957
+ elif cost.get("unmeasurable"):
958
+ unmeasurable += 1
959
+ elif cost.get("failed"):
960
+ failed += 1
961
+ return measured, unmeasurable, failed
962
+
963
+
964
+ def parse_cpu_list(text):
965
+ """`"0-7,12"` → `[0,1,2,3,4,5,6,7,12]`. Pure; parses a Linux sysfs cpu list."""
966
+ out = []
967
+ for part in str(text).strip().split(","):
968
+ part = part.strip()
969
+ if not part:
970
+ continue
971
+ if "-" in part:
972
+ lo, _dash, hi = part.partition("-")
973
+ try:
974
+ out.extend(range(int(lo), int(hi) + 1))
975
+ except ValueError:
976
+ continue
977
+ else:
978
+ try:
979
+ out.append(int(part))
980
+ except ValueError:
981
+ continue
982
+ return sorted(set(out))
983
+
984
+
985
+ def parse_cpu_model(cpuinfo):
986
+ """The first `model name` line of a /proc/cpuinfo text, or None. Pure."""
987
+ for line in str(cpuinfo).splitlines():
988
+ key, _colon, value = line.partition(":")
989
+ if key.strip() == "model name":
990
+ return value.strip()
991
+ return None
992
+
993
+
994
+ def parse_lv2bench_tsv(text):
995
+ """Parse lv2bench's TSV → `{uri: {"min":s,"mean":s,"max":s,"block":n,"rate":hz}}`. Pure.
996
+
997
+ Upstream's columns are `Block Frames Rate Min Mean Max Total Plugin`, all times
998
+ in SECONDS per block. Used only by the cross-check ({@link compare_lv2bench}):
999
+ lv2bench is the independent second opinion our numbers are validated against,
1000
+ not a source we import figures from.
1001
+ """
1002
+ out = {}
1003
+ for line in str(text).splitlines():
1004
+ cols = line.rstrip("\n").split("\t")
1005
+ if len(cols) < 8 or cols[0].strip() == "Block":
1006
+ continue
1007
+ try:
1008
+ out[cols[7].strip()] = {
1009
+ "block": int(float(cols[0])),
1010
+ "rate": float(cols[2]),
1011
+ "min": float(cols[3]),
1012
+ "mean": float(cols[4]),
1013
+ "max": float(cols[5]),
1014
+ }
1015
+ except ValueError:
1016
+ continue
1017
+ return out
1018
+
1019
+
1020
+ def compare_lv2bench(theirs, ours, block_frames=DEFAULT_COST_BLOCK):
1021
+ """Cross-check our per-sample medians against lv2bench's per-block means. Pure.
1022
+
1023
+ `theirs` is {@link parse_lv2bench_tsv} output (seconds per block); `ours` maps
1024
+ uri → ns per sample. Returns a list of per-URI rows with the ratio ours/theirs,
1025
+ sorted by URI, plus every URI only one side has — an agreement report has to
1026
+ show its misses or it is just a filtered success story.
1027
+ """
1028
+ rows = []
1029
+ for uri in sorted(set(theirs) | set(ours)):
1030
+ their = theirs.get(uri)
1031
+ our_ns = ours.get(uri)
1032
+ if their is None or our_ns is None:
1033
+ rows.append({"uri": uri, "ratio": None,
1034
+ "reason": "only in lv2bench" if our_ns is None
1035
+ else "only in ours"})
1036
+ continue
1037
+ # lv2bench quotes seconds per block; normalise to ns per sample at ITS block.
1038
+ their_ns = their["mean"] * 1e9 / float(their.get("block") or block_frames)
1039
+ rows.append({"uri": uri, "theirNsPerSample": their_ns,
1040
+ "ourNsPerSample": float(our_ns),
1041
+ "ratio": (float(our_ns) / their_ns) if their_ns > 0 else None})
1042
+ return rows
1043
+
1044
+
1045
+ def annotate_measured(catalog, measured, unmeasurable, sample_rate, tol=2):
1046
+ """Fold measured round-trip latencies into a catalog → a new annotated catalog.
1047
+
1048
+ Pure. For a measured uri, writes `latency.{measuredFrames,measuredMs}` next to
1049
+ any existing declared `reportedFrames`, and sets `latency.declaredMismatch`
1050
+ when the measured value differs from the declared one (0 if none) by more than
1051
+ `tol` frames — the important class being declared-0-but-measured-nonzero. For
1052
+ an unmeasurable uri, records `latency.unmeasurable` (the reason). A uri in
1053
+ neither map is passed through unchanged.
1054
+ """
1055
+ out = []
1056
+ for d in catalog:
1057
+ uri = d["uri"]
1058
+ if uri in measured:
1059
+ nd = dict(d)
1060
+ lat = dict(nd.get("latency") or {})
1061
+ frames = float(measured[uri])
1062
+ lat["measuredFrames"] = frames
1063
+ lat["measuredMs"] = (frames / sample_rate * 1000.0) if sample_rate else None
1064
+ lat["sampleRate"] = sample_rate
1065
+ declared = lat.get("reportedFrames") or 0.0
1066
+ lat["declaredMismatch"] = abs(frames - declared) > tol
1067
+ nd["latency"] = lat
1068
+ out.append(nd)
1069
+ elif uri in unmeasurable:
1070
+ nd = dict(d)
1071
+ lat = dict(nd.get("latency") or {})
1072
+ lat["unmeasurable"] = unmeasurable[uri]
1073
+ nd["latency"] = lat
1074
+ out.append(nd)
1075
+ else:
1076
+ out.append(d)
1077
+ return out
1078
+
1079
+
1080
+ # --- the worker: offline lilv probing (plugin-host imports live in here) -------
1081
+
1082
+ LV2 = "http://lv2plug.in/ns/lv2core#"
1083
+ ATOM = "http://lv2plug.in/ns/ext/atom#"
1084
+ URID = "http://lv2plug.in/ns/ext/urid#"
1085
+ OPTIONS = "http://lv2plug.in/ns/ext/options#"
1086
+ BUFSZ = "http://lv2plug.in/ns/ext/buf-size#"
1087
+ PARAMS = "http://lv2plug.in/ns/ext/parameters#"
1088
+ WORKER = "http://lv2plug.in/ns/ext/worker#"
1089
+
1090
+
1091
+ class _Host:
1092
+ """The minimal offline LV2 host the probe needs: a urid map, host options
1093
+ (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
1098
+ attributes so nothing is garbage-collected while plugins hold pointers.
1099
+ """
1100
+
1101
+ def __init__(self, rate, block):
1102
+ import ctypes
1103
+
1104
+ import lilv
1105
+
1106
+ self.urids = {}
1107
+
1108
+ map_fn = ctypes.CFUNCTYPE(ctypes.c_uint32, ctypes.c_void_p, ctypes.c_char_p)
1109
+ unmap_fn = ctypes.CFUNCTYPE(ctypes.c_char_p, ctypes.c_void_p, ctypes.c_uint32)
1110
+ sched_fn = ctypes.CFUNCTYPE(
1111
+ ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p
1112
+ )
1113
+
1114
+ self._map_cb = map_fn(lambda _h, uri: self.map_uri(uri))
1115
+ 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
1117
+
1118
+ class UridMap(ctypes.Structure):
1119
+ _fields_ = [("handle", ctypes.c_void_p), ("map", map_fn)]
1120
+
1121
+ class UridUnmap(ctypes.Structure):
1122
+ _fields_ = [("handle", ctypes.c_void_p), ("unmap", unmap_fn)]
1123
+
1124
+ class WorkerSchedule(ctypes.Structure):
1125
+ _fields_ = [("handle", ctypes.c_void_p), ("schedule_work", sched_fn)]
1126
+
1127
+ class Option(ctypes.Structure): # LV2_Options_Option
1128
+ _fields_ = [
1129
+ ("context", ctypes.c_uint32),
1130
+ ("subject", ctypes.c_uint32),
1131
+ ("key", ctypes.c_uint32),
1132
+ ("size", ctypes.c_uint32),
1133
+ ("type", ctypes.c_uint32),
1134
+ ("value", ctypes.c_void_p),
1135
+ ]
1136
+
1137
+ self._map_data = UridMap(None, self._map_cb)
1138
+ self._unmap_data = UridUnmap(None, self._unmap_cb)
1139
+ self._sched_data = WorkerSchedule(None, self._sched_cb)
1140
+
1141
+ self._rate_val = ctypes.c_float(float(rate))
1142
+ self._block_val = ctypes.c_int32(int(block))
1143
+ self._seq_size = ctypes.c_int32(16384)
1144
+ atom_float = self.map_uri(f"{ATOM}Float")
1145
+ atom_int = self.map_uri(f"{ATOM}Int")
1146
+
1147
+ def opt(key, val, typ):
1148
+ return Option(0, 0, self.map_uri(key), 4, typ,
1149
+ ctypes.cast(ctypes.byref(val), ctypes.c_void_p))
1150
+
1151
+ self._options = (Option * 6)(
1152
+ opt(f"{PARAMS}sampleRate", self._rate_val, atom_float),
1153
+ opt(f"{BUFSZ}minBlockLength", self._block_val, atom_int),
1154
+ opt(f"{BUFSZ}maxBlockLength", self._block_val, atom_int),
1155
+ opt(f"{BUFSZ}nominalBlockLength", self._block_val, atom_int),
1156
+ opt(f"{BUFSZ}sequenceSize", self._seq_size, atom_int),
1157
+ Option(0, 0, 0, 0, 0, None), # terminator
1158
+ )
1159
+
1160
+ def feature(uri, data):
1161
+ ptr = ctypes.cast(ctypes.byref(data), ctypes.c_void_p) if data is not None else None
1162
+ return lilv.LV2_Feature(uri.encode(), ptr)
1163
+
1164
+ self._features = [
1165
+ feature(f"{URID}map", self._map_data),
1166
+ feature(f"{URID}unmap", self._unmap_data),
1167
+ feature(f"{OPTIONS}options", self._options),
1168
+ feature(f"{WORKER}schedule", self._sched_data),
1169
+ feature(f"{BUFSZ}boundedBlockLength", None),
1170
+ feature(f"{BUFSZ}fixedBlockLength", None),
1171
+ feature(f"{BUFSZ}powerOf2BlockLength", None),
1172
+ ]
1173
+ arr = ctypes.POINTER(lilv.LV2_Feature) * (len(self._features) + 1)
1174
+ self.features = arr(*[ctypes.pointer(f) for f in self._features], None)
1175
+
1176
+ self.world = lilv.World()
1177
+ self.world.load_all()
1178
+ self.u = {k: self.world.new_uri(k) for k in (
1179
+ LV2 + "AudioPort", LV2 + "ControlPort", LV2 + "CVPort",
1180
+ LV2 + "InputPort", LV2 + "OutputPort", ATOM + "AtomPort",
1181
+ )}
1182
+ self.rate = rate
1183
+ self.block = block
1184
+ self.seq_urid = self.map_uri(f"{ATOM}Sequence")
1185
+
1186
+ def map_uri(self, uri):
1187
+ u = uri.decode() if isinstance(uri, bytes) else str(uri)
1188
+ if u not in self.urids:
1189
+ self.urids[u] = len(self.urids) + 1
1190
+ return self.urids[u]
1191
+
1192
+ def _unmap(self, urid):
1193
+ for k, v in self.urids.items():
1194
+ if v == urid:
1195
+ return k.encode()
1196
+ return None
1197
+
1198
+
1199
+ def probe_one(host, uri, symbol, cycles=DEFAULT_RUN_CYCLES):
1200
+ """Instantiate one plugin offline, run silence through it, read its latency port.
1201
+
1202
+ Returns `(frames, reason)`: on success `frames` is a float and `reason` is
1203
+ None; on a soft failure (not installed, refused instantiation, port symbol
1204
+ not found) `frames` is None and `reason` explains it. A plugin that crashes
1205
+ outright takes the worker process down — the parent handles that.
1206
+ """
1207
+ import numpy as np
1208
+
1209
+ import lilv
1210
+
1211
+ plugin = host.world.get_all_plugins().get_by_uri(host.world.new_uri(uri))
1212
+ if plugin is None:
1213
+ return None, "not installed"
1214
+ instance = lilv.Instance(plugin, float(host.rate), host.features)
1215
+ if not instance.instance:
1216
+ # a required feature we don't provide, or the plugin vetoed the rate.
1217
+ del instance.instance # avoid lilv.py's NULL deref in __del__
1218
+ return None, "instantiate failed"
1219
+ buffers = [] # keep every connected buffer alive for the plugin's lifetime
1220
+ latency_buf = None
1221
+ for i in range(plugin.get_num_ports()):
1222
+ port = plugin.get_port_by_index(i)
1223
+ if port.is_a(host.u[LV2 + "ControlPort"]):
1224
+ default, _mn, _mx = port.get_range()
1225
+ try:
1226
+ val = float(str(default)) if default is not None else 0.0
1227
+ except (ValueError, TypeError):
1228
+ val = 0.0
1229
+ buf = np.array([val], dtype=np.float32)
1230
+ if port.is_a(host.u[LV2 + "OutputPort"]) and str(port.get_symbol()) == symbol:
1231
+ latency_buf = buf
1232
+ elif port.is_a(host.u[ATOM + "AtomPort"]):
1233
+ # a minimal valid Atom Sequence: header {size,type} + empty body for
1234
+ # inputs; for outputs the size field advertises the buffer capacity.
1235
+ cap = 16384
1236
+ buf = np.zeros(cap // 4, dtype=np.uint32)
1237
+ buf[0] = 8 if port.is_a(host.u[LV2 + "InputPort"]) else cap - 8
1238
+ buf[1] = host.seq_urid
1239
+ else:
1240
+ # audio / CV / anything else: a block of silence is always safe.
1241
+ buf = np.zeros(host.block, dtype=np.float32)
1242
+ buffers.append(buf)
1243
+ instance.connect_port(i, buf)
1244
+ if latency_buf is None:
1245
+ return None, f"latency port {symbol!r} not found"
1246
+ instance.activate()
1247
+ for _ in range(cycles):
1248
+ instance.run(host.block)
1249
+ instance.deactivate()
1250
+ return float(latency_buf[0]), None
1251
+
1252
+
1253
+ def audio_port_indices(host, plugin):
1254
+ """`(audio_in, audio_out)` port-index lists for a plugin."""
1255
+ audio_in, audio_out = [], []
1256
+ for i in range(plugin.get_num_ports()):
1257
+ port = plugin.get_port_by_index(i)
1258
+ if port.is_a(host.u[LV2 + "AudioPort"]):
1259
+ if port.is_a(host.u[LV2 + "InputPort"]):
1260
+ audio_in.append(i)
1261
+ elif port.is_a(host.u[LV2 + "OutputPort"]):
1262
+ audio_out.append(i)
1263
+ return audio_in, audio_out
1264
+
1265
+
1266
+ def instantiate_connected(host, plugin, overrides=None):
1267
+ """Instantiate `plugin` and connect a live buffer to every one of its ports.
1268
+
1269
+ Shared by the latency render ({@link render}) and the cost pass
1270
+ ({@link cost_one}) so both probe a plugin set up identically: control inputs at
1271
+ their `lv2:default` (or at `overrides`), Atom ports holding a minimal valid
1272
+ Sequence, audio/CV ports holding a block of silence the caller then fills.
1273
+
1274
+ Returns `(instance, buffers, None)` — `buffers` maps port index to the numpy
1275
+ array connected to it, and MUST be kept alive as long as the instance is — or
1276
+ `(None, None, reason)` when the plugin refuses to instantiate.
1277
+ """
1278
+ import numpy as np
1279
+
1280
+ import lilv
1281
+
1282
+ instance = lilv.Instance(plugin, float(host.rate), host.features)
1283
+ if not instance.instance:
1284
+ del instance.instance # avoid lilv.py's NULL deref in __del__
1285
+ return None, None, "instantiate failed"
1286
+
1287
+ buffers = {}
1288
+ for i in range(plugin.get_num_ports()):
1289
+ port = plugin.get_port_by_index(i)
1290
+ if port.is_a(host.u[LV2 + "ControlPort"]):
1291
+ default, mn, mx = port.get_range()
1292
+ val = control_default(default, mn, mx)
1293
+ if overrides:
1294
+ val = overrides.get(str(port.get_symbol()), val)
1295
+ buf = np.array([val], dtype=np.float32)
1296
+ elif port.is_a(host.u[ATOM + "AtomPort"]):
1297
+ cap = 16384
1298
+ buf = np.zeros(cap // 4, dtype=np.uint32)
1299
+ buf[0] = 8 if port.is_a(host.u[LV2 + "InputPort"]) else cap - 8
1300
+ buf[1] = host.seq_urid
1301
+ else: # audio / CV / anything else: a block of silence
1302
+ buf = np.zeros(host.block, dtype=np.float32)
1303
+ buffers[i] = buf
1304
+ instance.connect_port(i, buf)
1305
+ return instance, buffers, None
1306
+
1307
+
1308
+ def is_threshold_control(symbol, name):
1309
+ """True when a control port looks like the threshold that keeps a gate shut.
1310
+
1311
+ Pure name matching, deliberately narrow: `threshold`/`thres`/`thresh` in the
1312
+ symbol or the label. Pinned to its MINIMUM by the sustained fallback, which is
1313
+ the "open the gate via its own controls" half of measuring a processor that
1314
+ passes nothing at its defaults.
1315
+ """
1316
+ text = f"{symbol} {name}".lower()
1317
+ return any(k in text for k in ("threshold", "thresh", "thres"))
1318
+
1319
+
1320
+ def render(host, plugin, n_blocks, impulse=True, overrides=None, preroll_blocks=0):
1321
+ """Run one fresh instance for `n_blocks` blocks; return its captured output.
1322
+
1323
+ `impulse` selects the stimulus: a unit impulse at frame 0 (the measurement
1324
+ pass) or pure silence (the baseline pass whose difference isolates the real
1325
+ response). `overrides` maps a control-port SYMBOL to the value to pin it at,
1326
+ for the parameter sweep; every other control sits at its `lv2:default`.
1327
+
1328
+ `preroll_blocks` runs that many blocks of a sustained tone through the plugin
1329
+ FIRST, discarding the output: a gate or threshold compressor needs a real
1330
+ signal before it will pass anything, and the impulse then arrives while the
1331
+ detector is still open. The pre-roll is identical on the impulse and baseline
1332
+ passes, so whatever it leaves ringing cancels in the difference.
1333
+
1334
+ Returns `(captured, reason)` — a float32 array of `n_blocks * block` frames,
1335
+ or `(None, reason)` when the plugin refused to instantiate.
1336
+ """
1337
+ import numpy as np
1338
+
1339
+ instance, buffers, reason = instantiate_connected(host, plugin, overrides)
1340
+ if instance is None:
1341
+ return None, reason
1342
+
1343
+ block = host.block
1344
+ audio_in, audio_out = audio_port_indices(host, plugin)
1345
+ in_buf = buffers[audio_in[0]] # measure the FIRST in -> FIRST out pair
1346
+ out_buf = buffers[audio_out[0]]
1347
+ captured = np.empty(n_blocks * block, dtype=np.float32)
1348
+
1349
+ instance.activate()
1350
+ if preroll_blocks:
1351
+ phase = 2.0 * np.pi * PREROLL_TONE_HZ / float(host.rate)
1352
+ for blk in range(preroll_blocks):
1353
+ start = blk * block
1354
+ in_buf[:] = PREROLL_TONE_AMP * np.sin(
1355
+ phase * np.arange(start, start + block, dtype=np.float32))
1356
+ instance.run(block) # output discarded: this only opens the plugin up
1357
+ in_buf[:] = 0.0
1358
+ if impulse:
1359
+ in_buf[0] = 1.0 # 1.0 at frame 0 of the first block only
1360
+ for blk in range(n_blocks):
1361
+ instance.run(block)
1362
+ captured[blk * block:(blk + 1) * block] = out_buf
1363
+ in_buf[:] = 0.0 # every subsequent block is silence
1364
+ instance.deactivate()
1365
+ return captured, None
1366
+
1367
+
1368
+ def measure_one(host, uri, declared_frames=0.0, threshold=MEASURE_THRESHOLD,
1369
+ overrides=None, fallback=True):
1370
+ """Instantiate one plugin offline and MEASURE its real round-trip latency.
1371
+
1372
+ A DIFFERENTIAL measurement: the plugin is rendered twice from a fresh
1373
+ instance, once fed a unit impulse and once fed silence, and the onset is
1374
+ detected on the difference. Everything the plugin emits irrespective of the
1375
+ stimulus — a DC offset, denormal fuzz, idle hiss, a deterministic internal
1376
+ LFO — cancels, so it can neither fake an early onset nor mask a quiet one.
1377
+ Control inputs are pinned to their defaults (or to `overrides`, for the
1378
+ parameter sweep) so the plugin runs at a stated operating point.
1379
+
1380
+ When the impulse alone produces silence and `fallback` is set, the probe
1381
+ escalates rather than giving up: a sustained tone is held on the input first
1382
+ (opening a gate / expander / threshold compressor), and failing that the
1383
+ plugin's own threshold controls are pinned to their minimum. Either escalation
1384
+ is recorded in `stimulus`, since the figure then describes a stated non-default
1385
+ operating point instead of the resting one.
1386
+
1387
+ Returns a small dict, exactly one key of:
1388
+ {"measuredFrames": <float>} measured onset (frames), + optional "stimulus"
1389
+ {"unmeasurable": <reason>} no audio in/out (instrument), or silent output
1390
+ {"error": <reason>} not installed / refused / non-finite output
1391
+ A plugin that crashes outright takes the worker down — the parent handles it.
1392
+ """
1393
+ import numpy as np
1394
+
1395
+ plugin = host.world.get_all_plugins().get_by_uri(host.world.new_uri(uri))
1396
+ if plugin is None:
1397
+ return {"error": "not installed"}
1398
+
1399
+ audio_in, audio_out = audio_port_indices(host, plugin)
1400
+ if not audio_in:
1401
+ return {"unmeasurable": "no-audio-in"}
1402
+ if not audio_out:
1403
+ return {"unmeasurable": "no-audio-out"}
1404
+
1405
+ total = capture_frames(declared_frames, host.rate)
1406
+ n_blocks = (total + host.block - 1) // host.block
1407
+
1408
+ def attempt(preroll_blocks, controls):
1409
+ response, reason = render(host, plugin, n_blocks, impulse=True,
1410
+ overrides=controls, preroll_blocks=preroll_blocks)
1411
+ if response is None:
1412
+ return {"error": reason}
1413
+ baseline, reason = render(host, plugin, n_blocks, impulse=False,
1414
+ overrides=controls, preroll_blocks=preroll_blocks)
1415
+ if baseline is None:
1416
+ return {"error": reason}
1417
+ if not (np.all(np.isfinite(response)) and np.all(np.isfinite(baseline))):
1418
+ return {"error": "non-finite output"}
1419
+ onset = detect_onset(residual(response, baseline), threshold)
1420
+ if onset is None:
1421
+ return {"unmeasurable": "silent-output"}
1422
+ return {"measuredFrames": float(onset)}
1423
+
1424
+ result = attempt(0, overrides)
1425
+ if fallback and result.get("unmeasurable") == "silent-output":
1426
+ # A gate / expander / threshold compressor passes nothing at rest, so the
1427
+ # impulse alone reads silent. Escalate: hold a tone on the input first,
1428
+ # then (if it is still shut) open its threshold controls right down. The
1429
+ # operating point is no longer the default, so it is recorded.
1430
+ preroll = int(PREROLL_SECONDS * host.rate) // host.block
1431
+ result = attempt(preroll, overrides)
1432
+ if "measuredFrames" in result:
1433
+ result["stimulus"] = "sustained"
1434
+ elif result.get("unmeasurable") == "silent-output":
1435
+ opened = dict(overrides or {})
1436
+ opened.update(threshold_overrides(host, plugin))
1437
+ if opened != (overrides or {}):
1438
+ result = attempt(preroll, opened)
1439
+ if "measuredFrames" in result:
1440
+ result["stimulus"] = "sustained+open"
1441
+ return result
1442
+
1443
+
1444
+ def timing_floor_ns(iters=COST_CALIBRATION_ITERS):
1445
+ """Median cost of the timing instrumentation itself, in ns per timed block.
1446
+
1447
+ The cost loop brackets each `instance.run()` with two `perf_counter_ns()`
1448
+ calls, so every plugin figure carries that bracket plus one ctypes dispatch.
1449
+ This times the identical bracket around a no-op Python call, which captures the
1450
+ clock pair but NOT the ctypes dispatch — so it is a LOWER bound on the harness
1451
+ floor, and it is reported as such rather than subtracted from the plugin
1452
+ figures. Its job is to say which plugins are simply too cheap to rank apart.
1453
+ """
1454
+ clock = time.perf_counter_ns
1455
+
1456
+ def noop(_frames):
1457
+ return None
1458
+
1459
+ samples = []
1460
+ for _ in range(iters):
1461
+ t0 = clock()
1462
+ noop(0)
1463
+ samples.append(clock() - t0)
1464
+ return percentile(samples, 50.0)
1465
+
1466
+
1467
+ def cost_one(host, uri, warmup=DEFAULT_COST_WARMUP, max_blocks=DEFAULT_COST_BLOCKS,
1468
+ min_blocks=COST_MIN_BLOCKS, target_seconds=COST_TARGET_SECONDS):
1469
+ """Instantiate one plugin offline and MEASURE its CPU cost per processed sample.
1470
+
1471
+ A sustained 1 kHz sine at -6 dBFS is held on every audio input, phase-continuous
1472
+ across blocks, and the plugin is run block after block as fast as the machine
1473
+ will go (this is not a realtime test — it measures how much CPU one block costs,
1474
+ which is then normalised into a realtime budget fraction). Every block is timed
1475
+ individually with `perf_counter_ns`, so the answer is a DISTRIBUTION: the median
1476
+ is what the plugin usually costs, the p95 is what it costs on a bad block, and
1477
+ the gap between them is the xrun risk. Signal, not silence: a plugin that
1478
+ short-circuits on a silent input would otherwise be recorded as free.
1479
+
1480
+ Buffer filling and every statistic are outside the timed region; the GC is off
1481
+ across the loop, so a collection cannot land inside a block and be charged to
1482
+ the plugin.
1483
+
1484
+ Returns exactly one of:
1485
+ {"blockTimesNs": [...], "silentOutput": bool} timings, warm-up head first
1486
+ {"unmeasurable": <reason>} no audio in/out — nothing to insert, nothing to cost
1487
+ {"error": <reason>} not installed / refused / non-finite output
1488
+ """
1489
+ import gc
1490
+
1491
+ import numpy as np
1492
+
1493
+ plugin = host.world.get_all_plugins().get_by_uri(host.world.new_uri(uri))
1494
+ if plugin is None:
1495
+ return {"error": "not installed"}
1496
+ audio_in, audio_out = audio_port_indices(host, plugin)
1497
+ if not audio_in:
1498
+ return {"unmeasurable": "no-audio-in"}
1499
+ if not audio_out:
1500
+ return {"unmeasurable": "no-audio-out"}
1501
+
1502
+ instance, buffers, reason = instantiate_connected(host, plugin)
1503
+ if instance is None:
1504
+ return {"error": reason}
1505
+
1506
+ block = host.block
1507
+ ins = [buffers[i] for i in audio_in]
1508
+ out_buf = buffers[audio_out[0]]
1509
+ phase = 2.0 * np.pi * COST_TONE_HZ / float(host.rate)
1510
+ clock = time.perf_counter_ns
1511
+ run = instance.run
1512
+ ramp = np.arange(block, dtype=np.float32)
1513
+
1514
+ def drive(blk):
1515
+ """Hold the tone on every audio input for block `blk`. Never timed."""
1516
+ sig = COST_TONE_AMP * np.sin(phase * (blk * block + ramp))
1517
+ for buf in ins:
1518
+ buf[:] = sig
1519
+
1520
+ times = []
1521
+ instance.activate()
1522
+ gc_was_on = gc.isenabled()
1523
+ gc.disable()
1524
+ try:
1525
+ blk = 0
1526
+ while blk < warmup:
1527
+ drive(blk)
1528
+ t0 = clock()
1529
+ run(block)
1530
+ times.append(clock() - t0)
1531
+ blk += 1
1532
+ spent = 0
1533
+ budget = int(target_seconds * 1e9)
1534
+ timed = 0
1535
+ while timed < max_blocks:
1536
+ drive(blk)
1537
+ t0 = clock()
1538
+ run(block)
1539
+ dt = clock() - t0
1540
+ times.append(dt)
1541
+ spent += dt
1542
+ timed += 1
1543
+ blk += 1
1544
+ if timed >= min_blocks and spent >= budget:
1545
+ # bounded on purpose: an expensive plugin gets its floor of blocks
1546
+ # and stops, so one convolution reverb cannot eat the whole sweep.
1547
+ break
1548
+ finally:
1549
+ if gc_was_on:
1550
+ gc.enable()
1551
+ instance.deactivate()
1552
+
1553
+ if not np.all(np.isfinite(out_buf)):
1554
+ return {"error": "non-finite output"}
1555
+ return {"blockTimesNs": times,
1556
+ "silentOutput": bool(np.max(np.abs(out_buf)) == 0.0)}
1557
+
1558
+
1559
+ def control_ranges(host, plugin, symbols):
1560
+ """`{symbol: (min, max)}` for the named control input ports of a plugin."""
1561
+ wanted = set(symbols)
1562
+ out = {}
1563
+ for i in range(plugin.get_num_ports()):
1564
+ port = plugin.get_port_by_index(i)
1565
+ if not (port.is_a(host.u[LV2 + "ControlPort"]) and port.is_a(host.u[LV2 + "InputPort"])):
1566
+ continue
1567
+ symbol = str(port.get_symbol())
1568
+ if symbol not in wanted:
1569
+ continue
1570
+ _default, mn, mx = port.get_range()
1571
+ try:
1572
+ out[symbol] = (float(str(mn)), float(str(mx)))
1573
+ except (ValueError, TypeError):
1574
+ continue
1575
+ return out
1576
+
1577
+
1578
+ def sweep_control_latencies(host, uri, declared_frames, base_frames, symbols):
1579
+ """Re-measure a plugin with each candidate control pinned to each end of its range.
1580
+
1581
+ Returns `{symbol: {"min": frames, "max": frames}}` — only the ends that
1582
+ measured. The capture window is sized off the DEFAULT measurement rather than
1583
+ the declared port, generously, because the whole point is that one of these
1584
+ controls may multiply the latency and a window sized for the resting value
1585
+ would truncate the answer and misread it as silence.
1586
+ """
1587
+ plugin = host.world.get_all_plugins().get_by_uri(host.world.new_uri(uri))
1588
+ if plugin is None:
1589
+ return {}
1590
+ hint = max(float(declared_frames or 0.0), float(base_frames)) * 8 + 16384
1591
+ out = {}
1592
+ for symbol, (mn, mx) in control_ranges(host, plugin, symbols).items():
1593
+ points = {}
1594
+ for end, value in (("min", mn), ("max", mx)):
1595
+ res = measure_one(host, uri, hint, overrides={symbol: value}, fallback=False)
1596
+ if "measuredFrames" in res:
1597
+ points[end] = res["measuredFrames"]
1598
+ if points:
1599
+ out[symbol] = points
1600
+ return out
1601
+
1602
+
1603
+ def threshold_overrides(host, plugin):
1604
+ """`{symbol: minimum}` for every threshold-looking control port of a plugin.
1605
+
1606
+ Pinning a gate's threshold to its minimum is the most direct way to make a
1607
+ processor that passes nothing at rest pass something — the "open the gate via
1608
+ its own controls" fallback.
1609
+ """
1610
+ out = {}
1611
+ for i in range(plugin.get_num_ports()):
1612
+ port = plugin.get_port_by_index(i)
1613
+ if not (port.is_a(host.u[LV2 + "ControlPort"]) and port.is_a(host.u[LV2 + "InputPort"])):
1614
+ continue
1615
+ symbol = str(port.get_symbol())
1616
+ name = str(port.get_name())
1617
+ if not is_threshold_control(symbol, name):
1618
+ continue
1619
+ _default, mn, _mx = port.get_range()
1620
+ try:
1621
+ out[symbol] = float(str(mn))
1622
+ except (ValueError, TypeError):
1623
+ continue
1624
+ return out
1625
+
1626
+
1627
+ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1628
+ warmup=DEFAULT_COST_WARMUP, blocks=DEFAULT_COST_BLOCKS):
1629
+ """The worker loop: JSON-lines protocol over stdio (see parse_worker_line).
1630
+
1631
+ Loads the LV2 world once, announces readiness, then probes one plugin per
1632
+ input line. Runs in its own process so a crashing plugin kills only this
1633
+ loop; the parent respawns it and skips the culprit.
1634
+
1635
+ In `cost` mode the worker first pins itself to ONE cpu (`cpu`). On a hybrid
1636
+ part — this host is an 8 P-core + 16 E-core Arrow Lake, whose two core types
1637
+ differ by ~600 MHz of peak clock — an unpinned measurement would silently
1638
+ compare plugins that happened to land on different core types, so the pinning
1639
+ is not a nicety. It is recorded in the run provenance.
1640
+ """
1641
+ if cost and cpu is not None:
1642
+ try:
1643
+ os.sched_setaffinity(0, {int(cpu)})
1644
+ except (OSError, ValueError) as e: # noqa: BLE001 - report, don't die
1645
+ sys.stderr.write(f" could not pin to cpu {cpu}: {e}\n")
1646
+ host = _Host(rate, block)
1647
+ ready = {"ready": True, "rate": rate}
1648
+ if cost:
1649
+ ready["timingFloorNs"] = timing_floor_ns()
1650
+ ready["cpu"] = sorted(os.sched_getaffinity(0))
1651
+ print(json.dumps(ready), flush=True)
1652
+ for line in sys.stdin:
1653
+ line = line.strip()
1654
+ if not line:
1655
+ continue
1656
+ uri, _tab, arg = line.partition("\t")
1657
+ if cost:
1658
+ try:
1659
+ res = cost_one(host, uri, warmup=warmup, max_blocks=blocks)
1660
+ except Exception as e: # noqa: BLE001 - report, don't die
1661
+ res = {"error": f"cost error: {e}"}
1662
+ if "blockTimesNs" in res:
1663
+ record = summarise_cost(res["blockTimesNs"], host.block, host.rate,
1664
+ warmup=warmup)
1665
+ if record is None:
1666
+ print(json.dumps({"uri": uri, "error": "no steady-state blocks"}),
1667
+ flush=True)
1668
+ continue
1669
+ if res.get("silentOutput"):
1670
+ record["silentOutput"] = True
1671
+ print(json.dumps({"uri": uri,
1672
+ "nsPerSample": record["nsPerSampleMedian"],
1673
+ "cost": record}), flush=True)
1674
+ elif "unmeasurable" in res:
1675
+ print(json.dumps({"uri": uri,
1676
+ "error": "unmeasurable:" + res["unmeasurable"]}),
1677
+ flush=True)
1678
+ else:
1679
+ print(json.dumps({"uri": uri,
1680
+ "error": res.get("error", "unknown error")}),
1681
+ flush=True)
1682
+ continue
1683
+ if measure:
1684
+ # `arg` is the declared-latency hint (frames) used to size the capture
1685
+ # so a genuinely-large latency isn't truncated. Onset is measured, not
1686
+ # trusted from the port. Soft failures and "unmeasurable" (instrument
1687
+ # / silent) both come back as errors so the parent's batch loop, which
1688
+ # only distinguishes frames-vs-error, needs no changes; the parent then
1689
+ # splits the "unmeasurable:" prefix back out.
1690
+ job = parse_measure_job(arg)
1691
+ declared = job["declared"]
1692
+ try:
1693
+ res = measure_one(host, uri, declared)
1694
+ if "measuredFrames" in res and job["sweep"]:
1695
+ swept = sweep_control_latencies(
1696
+ host, uri, declared, res["measuredFrames"], job["sweep"])
1697
+ if swept:
1698
+ res["sweep"] = swept
1699
+ except Exception as e: # noqa: BLE001 - report, don't die
1700
+ res = {"error": f"probe error: {e}"}
1701
+ if "measuredFrames" in res:
1702
+ reply = {"uri": uri, "frames": res["measuredFrames"]}
1703
+ for key in ("stimulus", "sweep"):
1704
+ if key in res:
1705
+ reply[key] = res[key]
1706
+ print(json.dumps(reply), flush=True)
1707
+ elif "unmeasurable" in res:
1708
+ print(json.dumps({"uri": uri, "error": "unmeasurable:" + res["unmeasurable"]}), flush=True)
1709
+ else:
1710
+ print(json.dumps({"uri": uri, "error": res.get("error", "unknown error")}), flush=True)
1711
+ continue
1712
+ symbol = arg
1713
+ try:
1714
+ frames, reason = probe_one(host, uri, symbol)
1715
+ except Exception as e: # noqa: BLE001 - report, don't die, on python-level errors
1716
+ frames, reason = None, f"probe error: {e}"
1717
+ if frames is None:
1718
+ print(json.dumps({"uri": uri, "error": reason}), flush=True)
1719
+ else:
1720
+ print(json.dumps({"uri": uri, "frames": frames}), flush=True)
1721
+
1722
+
1723
+ # --- the parent: batch loop with crash containment -----------------------------
1724
+
1725
+
1726
+ class _Worker:
1727
+ """Handle on one worker subprocess: spawn, job/reply exchange, kill."""
1728
+
1729
+ def __init__(self, cmd, boot_timeout):
1730
+ self.proc = subprocess.Popen(
1731
+ cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
1732
+ stderr=subprocess.DEVNULL, text=True, bufsize=1,
1733
+ )
1734
+ ready = self._read_line(boot_timeout)
1735
+ parsed = parse_worker_line(ready)
1736
+ if not (parsed and parsed.get("ready")):
1737
+ self.close()
1738
+ raise OSError("worker did not become ready")
1739
+ # what the worker learned about itself at boot (cost mode: its timing floor
1740
+ # and the cpu set it actually got pinned to) — run provenance, not a result.
1741
+ self.info = {k: v for k, v in parsed.items() if k != "ready"}
1742
+
1743
+ def _read_line(self, timeout):
1744
+ """One stdout line within `timeout` seconds, or None (timeout/EOF; EOF sets `self.eof`)."""
1745
+ self.eof = False
1746
+ deadline = time.monotonic() + timeout
1747
+ while True:
1748
+ remaining = deadline - time.monotonic()
1749
+ if remaining <= 0:
1750
+ return None
1751
+ readable, _, _ = select.select([self.proc.stdout], [], [], remaining)
1752
+ if not readable:
1753
+ return None
1754
+ line = self.proc.stdout.readline()
1755
+ if line == "":
1756
+ self.eof = True
1757
+ return None # EOF — the worker died
1758
+ if line.strip():
1759
+ return line
1760
+
1761
+ def probe(self, uri, symbol, timeout):
1762
+ """Send one job, wait for its reply. Raises OSError if the worker died."""
1763
+ try:
1764
+ self.proc.stdin.write(format_job(uri, symbol) + "\n")
1765
+ self.proc.stdin.flush()
1766
+ except (OSError, ValueError) as e:
1767
+ raise OSError(f"worker gone: {e}") from e
1768
+ line = self._read_line(timeout)
1769
+ if line is None and (self.eof or self.proc.poll() is not None):
1770
+ # EOF on the reply channel IS the death: the exit may not be reaped yet (a loaded
1771
+ # host), so wait for it rather than reading a not-yet-reaped worker as a timeout.
1772
+ try:
1773
+ self.proc.wait(timeout=5)
1774
+ except subprocess.TimeoutExpired:
1775
+ pass
1776
+ raise OSError(f"worker crashed (exit {self.proc.returncode})")
1777
+ return parse_worker_line(line)
1778
+
1779
+ def close(self):
1780
+ try:
1781
+ if self.proc.poll() is None:
1782
+ self.proc.terminate()
1783
+ try:
1784
+ self.proc.wait(timeout=5)
1785
+ except subprocess.TimeoutExpired:
1786
+ self.proc.kill()
1787
+ self.proc.wait(timeout=5)
1788
+ except OSError:
1789
+ pass
1790
+
1791
+
1792
+ def default_worker_cmd(rate, block, measure=False):
1793
+ """This same script, in --worker mode, under the same interpreter."""
1794
+ cmd = [sys.executable, os.path.abspath(__file__), "--worker",
1795
+ "--rate", str(rate), "--block", str(block)]
1796
+ if measure:
1797
+ cmd.append("--measure")
1798
+ return cmd
1799
+
1800
+
1801
+ def describe_latency(uri, value, extra, rate):
1802
+ """The batch loop's progress line for a latency probe. Pure."""
1803
+ stimulus = extra.get("stimulus") if isinstance(extra, dict) else None
1804
+ return (f" {uri}: {value:.0f} frames ({value / rate * 1000:.3f} ms @ {rate} Hz)"
1805
+ + (f" [{stimulus}]" if stimulus else ""))
1806
+
1807
+
1808
+ def describe_cost(uri, value, extra, rate):
1809
+ """The batch loop's progress line for a cost probe. Pure."""
1810
+ record = extra.get("cost") if isinstance(extra, dict) else None
1811
+ p95 = record.get("nsPerSampleP95") if isinstance(record, dict) else None
1812
+ blocks = record.get("blocks") if isinstance(record, dict) else None
1813
+ fraction = core_fraction(value, rate) or 0.0
1814
+ return (f" {uri}: {value:.2f} ns/sample median"
1815
+ + (f", {p95:.2f} p95" if p95 is not None else "")
1816
+ + f" ({fraction * 100:.3f} % of a core @ {rate} Hz"
1817
+ + (f", {blocks} blocks)" if blocks else ")"))
1818
+
1819
+
1820
+ def measure_offline(targets, rate, worker_cmd, timeout=DEFAULT_TIMEOUT,
1821
+ boot_timeout=DEFAULT_BOOT_TIMEOUT, isolate=False,
1822
+ job_arg=latency_port_symbol, value_key="frames", describe=None):
1823
+ """Probe each target in a worker subprocess → BenchmarkResult.
1824
+
1825
+ One finicky plugin must never abort the batch: a plugin that crashes or
1826
+ times out is recorded as failed (with a reason), the worker is respawned,
1827
+ and the run continues with the remaining plugins. With `isolate=True` every
1828
+ plugin gets its own fresh worker (LV2 world reloaded each time) so a crash
1829
+ can't even perturb the next measurement; the default reuses one worker and
1830
+ only respawns after a crash/timeout.
1831
+
1832
+ `worker_cmd` is the argv used to spawn a worker — injectable so the smoke
1833
+ test can drive this whole loop with a stub instead of real LV2 plugins.
1834
+
1835
+ `job_arg`, `value_key` and `describe` are what let the CPU-cost pass reuse this
1836
+ loop verbatim instead of forking a second copy of the crash-containment: cost
1837
+ jobs carry no per-plugin argument, answer with `nsPerSample` instead of
1838
+ `frames`, and log a different line. Everything about spawning, timeouts, desync
1839
+ detection and respawn is identical, because it is the same code.
1840
+ """
1841
+ measured = {}
1842
+ failures = {}
1843
+ details = {}
1844
+ worker_info = {}
1845
+
1846
+ def spawn():
1847
+ try:
1848
+ return _Worker(worker_cmd, boot_timeout)
1849
+ except OSError as e:
1850
+ sys.stderr.write(f" worker spawn failed: {e}\n")
1851
+ return None
1852
+
1853
+ worker = None
1854
+ try:
1855
+ for i, d in enumerate(targets):
1856
+ uri = d["uri"]
1857
+ symbol = job_arg(d)
1858
+ if worker is None:
1859
+ worker = spawn()
1860
+ if worker is None:
1861
+ for later in targets[i:]:
1862
+ failures.setdefault(later["uri"], "worker unavailable")
1863
+ break
1864
+ worker_info = worker_info or worker.info
1865
+ try:
1866
+ reply = worker.probe(uri, symbol, timeout)
1867
+ except OSError as e:
1868
+ failures[uri] = f"worker crashed ({e})"
1869
+ sys.stderr.write(f" worker crashed on {uri}: {e}\n")
1870
+ worker.close()
1871
+ worker = None
1872
+ continue
1873
+ if reply is None:
1874
+ # no reply in time (or garbage): blame the plugin, get a clean
1875
+ # worker — the stuck probe may still be wedged inside run().
1876
+ failures[uri] = "probe timed out"
1877
+ sys.stderr.write(f" probe timed out: {uri}\n")
1878
+ worker.close()
1879
+ worker = None
1880
+ continue
1881
+ if reply.get("uri") != uri:
1882
+ failures[uri] = "worker desync"
1883
+ sys.stderr.write(f" worker desync on {uri}: {reply!r}\n")
1884
+ worker.close()
1885
+ worker = None
1886
+ continue
1887
+ if value_key in reply:
1888
+ value = float(reply[value_key])
1889
+ measured[uri] = value
1890
+ extra = {k: v for k, v in reply.items() if k not in ("uri", value_key)}
1891
+ if extra:
1892
+ details[uri] = extra
1893
+ sys.stderr.write(
1894
+ (describe or describe_latency)(uri, value, extra, rate) + "\n")
1895
+ else:
1896
+ failures[uri] = str(reply.get("error", "unknown error"))
1897
+ sys.stderr.write(f" {failures[uri]}: {uri}\n")
1898
+ if isolate and worker is not None:
1899
+ worker.close()
1900
+ worker = None
1901
+ finally:
1902
+ if worker is not None:
1903
+ worker.close()
1904
+ return BenchmarkResult(measured, failures, details, worker_info)
1905
+
1906
+
1907
+ def measure_declared_frames(descriptor):
1908
+ """The declared latency (reportedFrames) for a descriptor, else 0.0.
1909
+
1910
+ Comes from a prior declared-latency run; used both to size the capture window
1911
+ and to compute declaredMismatch against the fresh measurement.
1912
+ """
1913
+ lat = descriptor.get("latency")
1914
+ if isinstance(lat, dict):
1915
+ try:
1916
+ return float(lat.get("reportedFrames") or 0.0)
1917
+ except (ValueError, TypeError):
1918
+ return 0.0
1919
+ return 0.0
1920
+
1921
+
1922
+ def split_unmeasurable(failures):
1923
+ """Split a failures map into `(unmeasurable, real_failures)`.
1924
+
1925
+ The worker reports "there is nothing to time here" (an instrument, a silent
1926
+ output) as an `unmeasurable:<reason>` error so the batch loop, which only
1927
+ distinguishes frames-vs-error, needs no special case. Splitting them back out
1928
+ keeps a quarantined plugin distinct from a crashed one in the report and in
1929
+ the catalog.
1930
+ """
1931
+ unmeasurable = {u: r.split("unmeasurable:", 1)[1]
1932
+ for u, r in failures.items() if r.startswith("unmeasurable:")}
1933
+ real = {u: r for u, r in failures.items() if not r.startswith("unmeasurable:")}
1934
+ return unmeasurable, real
1935
+
1936
+
1937
+ def measure_at_rate(catalog, rate, args, sweep=False):
1938
+ """Measure every plugin in `catalog` at one sample rate → a per-rate run dict.
1939
+
1940
+ Every plugin is a target (not just those with a declared latency port): the
1941
+ whole point is to catch declared-0-but-measured-nonzero. The declared frames
1942
+ (and, with `sweep`, the controls worth pinning) ride in the job's second field
1943
+ via a shim descriptor, so the worker can size each capture and run the sweep
1944
+ without any change to the batch loop.
1945
+ """
1946
+ block = args.block if args.block != DEFAULT_BLOCK else DEFAULT_MEASURE_BLOCK
1947
+ targets = [{"uri": d["uri"],
1948
+ "latency": {"portSymbol": format_measure_job(
1949
+ measure_declared_frames(d),
1950
+ sweep_candidates(d.get("params") or []) if sweep else ())}}
1951
+ for d in catalog]
1952
+ worker_cmd = (shlex.split(args.worker_cmd) if args.worker_cmd
1953
+ else default_worker_cmd(rate, block, measure=True))
1954
+ result = measure_offline(targets, rate, worker_cmd,
1955
+ timeout=args.timeout, isolate=args.isolate)
1956
+ unmeasurable, failures = split_unmeasurable(result.failures)
1957
+ return {"measured": result.measured, "unmeasurable": unmeasurable,
1958
+ "failures": failures, "details": result.details}
1959
+
1960
+
1961
+ # --- the CPU-cost run ----------------------------------------------------------
1962
+
1963
+
1964
+ def read_text(path):
1965
+ """File contents, or None when it isn't there (sysfs varies by machine)."""
1966
+ try:
1967
+ with open(path, encoding="utf-8") as f:
1968
+ return f.read()
1969
+ except OSError:
1970
+ return None
1971
+
1972
+
1973
+ def performance_cpus():
1974
+ """The performance-core cpu ids on a hybrid x86 part, else []. Reads sysfs.
1975
+
1976
+ Intel's hybrid parts expose their two core types as separate PMUs:
1977
+ `/sys/devices/cpu_core/cpus` is the P-cores, `cpu_atom` the E-cores. A uniform
1978
+ part has neither and the caller falls back to whatever it is allowed to run on.
1979
+ """
1980
+ text = read_text("/sys/devices/cpu_core/cpus")
1981
+ return parse_cpu_list(text) if text else []
1982
+
1983
+
1984
+ def choose_cost_cpu():
1985
+ """The cpu to pin the cost worker to: the LAST performance core we may use.
1986
+
1987
+ Last, not first: cpu0 fields most of the machine's interrupt load, and an
1988
+ interrupt landing inside a timed block is exactly the tail noise the p95 is
1989
+ supposed to be measuring the plugin for, not the kernel.
1990
+ """
1991
+ allowed = sorted(os.sched_getaffinity(0))
1992
+ if not allowed:
1993
+ return None
1994
+ perf = [c for c in performance_cpus() if c in allowed]
1995
+ return (perf or allowed)[-1]
1996
+
1997
+
1998
+ def host_provenance(cpu, rates, block, warmup, blocks, timing_floor_ns_value=None):
1999
+ """The machine + settings a cost figure is only meaningful next to.
2000
+
2001
+ A cost number without its host is a decoration: the same plugin is cheaper on a
2002
+ P-core than an E-core, cheaper with the performance governor than powersave,
2003
+ and cheaper with turbo than without. Everything recorded here is read from the
2004
+ machine at run time, never assumed.
2005
+ """
2006
+ cpuinfo = read_text("/proc/cpuinfo") or ""
2007
+ governor = (read_text("/sys/devices/system/cpu/cpu%d/cpufreq/scaling_governor" % cpu)
2008
+ if cpu is not None else None)
2009
+ max_khz = (read_text("/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq" % cpu)
2010
+ if cpu is not None else None)
2011
+ no_turbo = read_text("/sys/devices/system/cpu/intel_pstate/no_turbo")
2012
+ uname = os.uname()
2013
+ perf = performance_cpus()
2014
+ return {
2015
+ "proberVersion": COST_PROBER_VERSION,
2016
+ "measuredAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
2017
+ "host": uname.nodename,
2018
+ "kernel": uname.release,
2019
+ "python": sys.version.split()[0],
2020
+ "cpuModel": parse_cpu_model(cpuinfo),
2021
+ "cpuCount": os.cpu_count(),
2022
+ "pinnedCpu": cpu,
2023
+ "pinnedCpuKind": ("performance" if cpu in perf else "efficiency") if perf else "uniform",
2024
+ "pinnedCpuMaxKHz": int(max_khz.strip()) if max_khz and max_khz.strip().isdigit() else None,
2025
+ "governor": governor.strip() if governor else None,
2026
+ "turboEnabled": (no_turbo.strip() == "0") if no_turbo else None,
2027
+ "realtimePriority": False,
2028
+ "memoryLocked": False,
2029
+ "rates": list(rates),
2030
+ "referenceRate": COST_REFERENCE_RATE,
2031
+ "blockFrames": block,
2032
+ "warmupBlocks": warmup,
2033
+ "maxTimedBlocks": blocks,
2034
+ "minTimedBlocks": COST_MIN_BLOCKS,
2035
+ "targetSecondsPerPlugin": COST_TARGET_SECONDS,
2036
+ "percentile": COST_PERCENTILE,
2037
+ "stimulus": (f"sustained {COST_TONE_HZ:.0f} Hz sine at "
2038
+ f"{COST_TONE_AMP} full-scale on every audio input, "
2039
+ "phase-continuous across blocks"),
2040
+ "controls": "lv2:default on every control input",
2041
+ "timingFloorNsPerBlock": timing_floor_ns_value,
2042
+ "relativeRankingOnly": True,
2043
+ "note": ("A RELATIVE ranking measured on one machine, not a guarantee on "
2044
+ "any other. Customer hardware with a different core, a different "
2045
+ "governor or a busier machine will produce different absolute "
2046
+ "figures; what carries over is the ORDER and the rough ratios."),
2047
+ }
2048
+
2049
+
2050
+ def default_cost_worker_cmd(rate, block, cpu, warmup, blocks):
2051
+ """This same script, in --worker --cost mode, under the same interpreter."""
2052
+ cmd = [sys.executable, os.path.abspath(__file__), "--worker", "--cost",
2053
+ "--rate", str(rate), "--block", str(block),
2054
+ "--cost-warmup", str(warmup), "--cost-blocks", str(blocks)]
2055
+ if cpu is not None:
2056
+ cmd += ["--cost-cpu", str(cpu)]
2057
+ return cmd
2058
+
2059
+
2060
+ def cost_at_rate(catalog, rate, args, cpu):
2061
+ """Measure every plugin's CPU cost at one sample rate → a per-rate run dict.
2062
+
2063
+ Every plugin is a target, including the ones with no audio ports: they come
2064
+ back as `unmeasurable` with a reason rather than being filtered out of the run
2065
+ silently, so the census at the end accounts for all 958.
2066
+ """
2067
+ worker_cmd = (shlex.split(args.worker_cmd) if args.worker_cmd
2068
+ else default_cost_worker_cmd(rate, args.cost_block, cpu,
2069
+ args.cost_warmup, args.cost_blocks))
2070
+ # a cost probe runs hundreds of blocks where a latency probe runs a handful, so
2071
+ # the latency default would time out honest work; an explicit --timeout wins.
2072
+ timeout = DEFAULT_COST_TIMEOUT if args.timeout == DEFAULT_TIMEOUT else args.timeout
2073
+ result = measure_offline(catalog, rate, worker_cmd,
2074
+ timeout=timeout, isolate=args.isolate,
2075
+ job_arg=lambda _d: "", value_key="nsPerSample",
2076
+ describe=describe_cost)
2077
+ unmeasurable, failures = split_unmeasurable(result.failures)
2078
+ measured = {uri: result.details.get(uri, {}).get("cost", {})
2079
+ for uri in result.measured}
2080
+ return {"measured": measured, "unmeasurable": unmeasurable,
2081
+ "failures": failures, "workerInfo": result.workerInfo}
2082
+
2083
+
2084
+ def cost_distribution(annotated, rate=COST_REFERENCE_RATE):
2085
+ """`[(uri, nsPerSampleMedian, instancesPerCoreP95)]` at `rate`, cheapest first. Pure."""
2086
+ rows = []
2087
+ for d in annotated:
2088
+ cost = d.get("cpuCost")
2089
+ if not isinstance(cost, dict):
2090
+ continue
2091
+ record = (cost.get("perRate") or {}).get(str(rate))
2092
+ if not isinstance(record, dict):
2093
+ continue
2094
+ rows.append((d["uri"], record["nsPerSampleMedian"],
2095
+ record.get("instancesPerCoreP95")))
2096
+ return sorted(rows, key=lambda r: r[1])
2097
+
2098
+
2099
+ def run_cost(catalog, args):
2100
+ """Drive the CPU-cost measurement at every requested rate, annotate, write, report.
2101
+
2102
+ Why this and not `lv2bench`, which is installed here and does benchmark LV2
2103
+ plugins: lv2bench (lilv 0.26.4) has no sample-rate option at all — it is wired
2104
+ to 48 kHz, so the per-rate requirement is simply unmeetable with it; its
2105
+ positional PLUGIN_URI argument is ignored, so it cannot be driven one plugin
2106
+ per process as a poor man's isolation; it has no crash containment, and on this
2107
+ host it segfaults on plugin 35 of 958 and takes the whole run with it; and it
2108
+ reports min/mean/max only, where max is one unlucky block and mean hides the
2109
+ tail. What it IS good for is an independent second opinion on the 34 plugins it
2110
+ survives, which `--cross-check` does against these numbers.
2111
+ """
2112
+ gated = args.run or os.environ.get("OPENMIXER_BENCH") == "1"
2113
+ if not gated:
2114
+ sys.stderr.write(
2115
+ "refusing to measure cost without --run (or OPENMIXER_BENCH=1).\n"
2116
+ f"The probe instantiates the host's real LV2 plugins; {len(catalog)} "
2117
+ "plugin(s) would be measured.\n"
2118
+ )
2119
+ sys.exit(2)
2120
+
2121
+ rates = parse_rates(args.rates) if args.rates else [args.rate]
2122
+ cpu = args.cost_cpu if args.cost_cpu is not None else choose_cost_cpu()
2123
+ by_rate = {}
2124
+ floors = []
2125
+ for rate in rates:
2126
+ sys.stderr.write(f"== CPU cost of {len(catalog)} plugin(s) at {rate} Hz "
2127
+ f"(block {args.cost_block}, cpu {cpu}) ==\n")
2128
+ run = cost_at_rate(catalog, rate, args, cpu)
2129
+ by_rate[rate] = run
2130
+ floor = run.get("workerInfo", {}).get("timingFloorNs")
2131
+ if floor is not None:
2132
+ floors.append(floor)
2133
+ sys.stderr.write(
2134
+ f" {rate} Hz: measured {len(run['measured'])} "
2135
+ f"unmeasurable {len(run['unmeasurable'])} failed {len(run['failures'])}\n")
2136
+
2137
+ annotated = annotate_cost(catalog, by_rate)
2138
+ text = json.dumps(annotated, indent=2, ensure_ascii=False)
2139
+ if args.out:
2140
+ with open(args.out, "w", encoding="utf-8") as f:
2141
+ f.write(text + "\n")
2142
+ else:
2143
+ print(text)
2144
+
2145
+ provenance = host_provenance(cpu, rates, args.cost_block, args.cost_warmup,
2146
+ args.cost_blocks,
2147
+ percentile(floors, 50.0) if floors else None)
2148
+ if args.cost_provenance:
2149
+ with open(args.cost_provenance, "w", encoding="utf-8") as f:
2150
+ f.write(json.dumps(provenance, indent=2, ensure_ascii=False) + "\n")
2151
+
2152
+ measured, unmeasurable, failed = cost_census(annotated)
2153
+ rows = cost_distribution(annotated)
2154
+ sys.stderr.write(
2155
+ f"cost measured {measured}/{len(catalog)} unmeasurable {unmeasurable} "
2156
+ f"failed {failed}\n")
2157
+ if rows:
2158
+ median_row = rows[len(rows) // 2]
2159
+ sys.stderr.write(
2160
+ f" at {COST_REFERENCE_RATE} Hz — cheapest {rows[0][0]} "
2161
+ f"({rows[0][1]:.2f} ns/sample, {rows[0][2]} instances/core)\n"
2162
+ f" median {median_row[0]} ({median_row[1]:.2f} ns/sample, "
2163
+ f"{median_row[2]} instances/core)\n"
2164
+ f" dearest {rows[-1][0]} ({rows[-1][1]:.2f} ns/sample, "
2165
+ f"{rows[-1][2]} instances/core)\n")
2166
+
2167
+
2168
+ def run_cross_check(catalog, args):
2169
+ """Compare our 48 kHz cost medians against an lv2bench TSV → an agreement report.
2170
+
2171
+ lv2bench is quoted at 48 kHz and dies early, so the overlap is small; that is
2172
+ the point of reporting the miss counts alongside the ratios rather than only
2173
+ the plugins that agreed.
2174
+ """
2175
+ text = read_text(args.cross_check)
2176
+ if text is None:
2177
+ sys.stderr.write(f"cannot read lv2bench TSV: {args.cross_check}\n")
2178
+ sys.exit(2)
2179
+ theirs = parse_lv2bench_tsv(text)
2180
+ ours = {}
2181
+ for d in catalog:
2182
+ cost = d.get("cpuCost")
2183
+ record = (cost or {}).get("perRate", {}).get(str(args.rate))
2184
+ if isinstance(record, dict):
2185
+ ours[d["uri"]] = record["nsPerSampleMedian"]
2186
+ rows = compare_lv2bench(theirs, ours)
2187
+ both = [r for r in rows if r.get("ratio")]
2188
+ ratios = sorted(r["ratio"] for r in both)
2189
+ sys.stderr.write(
2190
+ f"cross-check @ {args.rate} Hz: {len(both)} plugin(s) in both, "
2191
+ f"{sum(1 for r in rows if r.get('reason') == 'only in lv2bench')} only in "
2192
+ f"lv2bench, {sum(1 for r in rows if r.get('reason') == 'only in ours')} "
2193
+ "only in ours\n")
2194
+ if ratios:
2195
+ sys.stderr.write(
2196
+ f" ours/lv2bench ratio: median {percentile(ratios, 50.0):.3f} "
2197
+ f"min {ratios[0]:.3f} max {ratios[-1]:.3f}\n")
2198
+ for row in sorted(both, key=lambda r: r["ratio"]):
2199
+ sys.stderr.write(f" {row['ratio']:6.3f} {row['uri']} "
2200
+ f"(ours {row['ourNsPerSample']:.2f} ns/sample, "
2201
+ f"lv2bench {row['theirNsPerSample']:.2f})\n")
2202
+
2203
+
2204
+ def parse_rates(text):
2205
+ """`"44100,48000"` → `[44100, 48000]`, deduplicated and ordered. Pure."""
2206
+ rates = {int(part) for part in str(text).split(",") if part.strip()}
2207
+ return sorted(rates)
2208
+
2209
+
2210
+ def scaling_census(annotated):
2211
+ """Count plugins per derived scaling class → an ordered {class: count} dict."""
2212
+ census = {}
2213
+ for d in annotated:
2214
+ lat = d.get("latency")
2215
+ if isinstance(lat, dict) and lat.get("scalingClass"):
2216
+ cls = lat["scalingClass"]
2217
+ census[cls] = census.get(cls, 0) + 1
2218
+ return dict(sorted(census.items()))
2219
+
2220
+
2221
+ def param_dependent(annotated):
2222
+ """The URIs whose latency the sweep proved a control actually moves."""
2223
+ return [d["uri"] for d in annotated
2224
+ if isinstance(d.get("latency"), dict) and d["latency"].get("paramSweep")]
2225
+
2226
+
2227
+ def run_reclassify(catalog, args):
2228
+ """Replay {@link reclassify} over an already-measured catalog, write it, report the census."""
2229
+ before = scaling_census(catalog)
2230
+ annotated = reclassify(catalog)
2231
+ after = scaling_census(annotated)
2232
+ payload = json.dumps(annotated, indent=2, ensure_ascii=False) + "\n"
2233
+ if args.out:
2234
+ with open(args.out, "w", encoding="utf-8") as f:
2235
+ f.write(payload)
2236
+ else:
2237
+ sys.stdout.write(payload)
2238
+ excluded = sum(len(d["latency"]["unreliableRates"]) for d in annotated
2239
+ if isinstance(d.get("latency"), dict) and d["latency"].get("unreliableRates"))
2240
+ sys.stderr.write(
2241
+ f"reclassified from perRate (no plugin was probed)\n"
2242
+ f" before: {before}\n"
2243
+ f" after: {after}\n"
2244
+ f" {excluded} reading(s) excluded as unreliable\n"
2245
+ + (f" -> {args.out}\n" if args.out else ""))
2246
+
2247
+
2248
+ def run_measure(catalog, args):
2249
+ """Drive the round-trip MEASURE at every requested rate, annotate, write, report.
2250
+
2251
+ The rate loop is the point: a plugin's latency is not one number, and which of
2252
+ the two laws it obeys (constant frames vs constant milliseconds) can only be
2253
+ read off measurements at more than one rate. Each rate gets its own worker (the
2254
+ rate is fixed at instantiation), and the results are folded together by
2255
+ {@link annotate_per_rate}, which also derives the scaling class.
2256
+ """
2257
+ gated = args.run or os.environ.get("OPENMIXER_BENCH") == "1"
2258
+ if not gated:
2259
+ sys.stderr.write(
2260
+ "refusing to measure without --run (or OPENMIXER_BENCH=1).\n"
2261
+ f"The probe instantiates the host's real LV2 plugins; {len(catalog)} "
2262
+ "plugin(s) would be measured.\n"
2263
+ )
2264
+ sys.exit(2)
2265
+
2266
+ rates = parse_rates(args.rates) if args.rates else [args.rate]
2267
+ by_rate = {}
2268
+ all_failures = {}
2269
+ # The sweep runs at ONE rate only. Which controls are latency-bearing is a
2270
+ # property of the plugin, not of the rate, so paying for it four times over
2271
+ # would buy nothing; the reference rate is the one the flat fields quote.
2272
+ sweep_rate = REFERENCE_RATE if REFERENCE_RATE in rates else rates[0]
2273
+ for rate in rates:
2274
+ sweep = args.sweep and rate == sweep_rate
2275
+ sys.stderr.write(f"== measuring {len(catalog)} plugin(s) at {rate} Hz"
2276
+ + (" (+ parameter sweep)" if sweep else "") + " ==\n")
2277
+ run = measure_at_rate(catalog, rate, args, sweep=sweep)
2278
+ by_rate[rate] = run
2279
+ for uri, reason in run["failures"].items():
2280
+ all_failures.setdefault(uri, {})[rate] = reason
2281
+ sys.stderr.write(
2282
+ f" {rate} Hz: measured {len(run['measured'])} "
2283
+ f"unmeasurable {len(run['unmeasurable'])} failed {len(run['failures'])}\n"
2284
+ )
2285
+
2286
+ annotated = annotate_per_rate(catalog, by_rate)
2287
+ text = json.dumps(annotated, indent=2, ensure_ascii=False)
2288
+ if args.out:
2289
+ with open(args.out, "w", encoding="utf-8") as f:
2290
+ f.write(text + "\n")
2291
+ else:
2292
+ print(text)
2293
+
2294
+ # distribution + mismatch summary (the trustworthy-gate payoff)
2295
+ measured_any = {u for run in by_rate.values() for u in run["measured"]}
2296
+ reference = by_rate.get(REFERENCE_RATE) or by_rate[rates[0]]
2297
+ ref_rate = REFERENCE_RATE if REFERENCE_RATE in by_rate else rates[0]
2298
+ ms = {u: f / ref_rate * 1000.0 for u, f in reference["measured"].items()}
2299
+ live = sum(1 for v in ms.values() if v <= 5.0)
2300
+ studio = sum(1 for v in ms.values() if v > 5.0)
2301
+ mismatch = [d for d in annotated
2302
+ if isinstance(d.get("latency"), dict)
2303
+ and d["latency"].get("declaredMismatch")]
2304
+ quarantined = [d for d in annotated
2305
+ if isinstance(d.get("latency"), dict)
2306
+ and d["latency"].get("unmeasurable")]
2307
+ sys.stderr.write(
2308
+ f"measured {len(measured_any)}/{len(catalog)} plugin(s) at >=1 rate"
2309
+ + (f" -> {args.out}\n" if args.out else "\n")
2310
+ )
2311
+ sys.stderr.write(
2312
+ f" at {ref_rate} Hz: live-tier (<=5 ms) {live} studio (>5 ms) {studio}\n"
2313
+ f" unmeasurable at every rate: {len(quarantined)} "
2314
+ f"failed somewhere: {len(all_failures)}\n"
2315
+ f" declaredMismatch: {len(mismatch)}\n"
2316
+ f" scaling class: {scaling_census(annotated)}\n"
2317
+ f" parameter-dependent latency: {len(param_dependent(annotated))}\n"
2318
+ )
2319
+ for uri, per_rate in all_failures.items():
2320
+ sys.stderr.write(f" FAIL {uri}: {per_rate}\n")
2321
+
2322
+
2323
+ def main():
2324
+ ap = argparse.ArgumentParser(description="Benchmark plugin latency offline via lilv.")
2325
+ ap.add_argument("--in", dest="inp", help="input catalog JSON")
2326
+ ap.add_argument("--out", help="write annotated catalog here (default: stdout)")
2327
+ ap.add_argument("--rate", type=int, default=DEFAULT_RATE, help="sample rate (Hz)")
2328
+ ap.add_argument("--rates",
2329
+ help="comma-separated sample rates to MEASURE at, e.g. "
2330
+ f"'{','.join(str(r) for r in DEFAULT_RATES)}'. Each rate is "
2331
+ "measured in full and the scaling class (fixed-frame / "
2332
+ "fixed-time / nonconforming) is derived from how the numbers "
2333
+ "move. Without it, only --rate is measured.")
2334
+ ap.add_argument("--block", type=int, default=DEFAULT_BLOCK,
2335
+ help="frames per run() block")
2336
+ ap.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT,
2337
+ help="per-plugin probe timeout in seconds")
2338
+ ap.add_argument("--sweep", action="store_true",
2339
+ help="also sweep the latency-bearing CONTROLS of each plugin "
2340
+ f"(up to {SWEEP_MAX_CONTROLS} per plugin, ranked by name) at "
2341
+ "each end of their range, and record the resulting latency "
2342
+ "span. Runs at the reference rate only.")
2343
+ ap.add_argument("--isolate", action="store_true",
2344
+ help="fresh worker per plugin (slower, fully crash-proof); "
2345
+ "default reuses one worker and respawns on a crash")
2346
+ ap.add_argument("--worker-cmd",
2347
+ help="override the worker argv (used by the smoke test)")
2348
+ ap.add_argument("--worker", action="store_true",
2349
+ help="internal: run as the probing worker")
2350
+ ap.add_argument("--measure", action="store_true",
2351
+ help="MEASURE real round-trip latency (impulse -> onset) for "
2352
+ "every plugin, instead of reading the declared lv2:latency "
2353
+ "port. Writes latency.{measuredFrames,measuredMs} + "
2354
+ "declaredMismatch. Uses a 512-frame block by default.")
2355
+ ap.add_argument("--cost", action="store_true",
2356
+ help="MEASURE per-plugin CPU cost: hold a sustained tone on "
2357
+ "every audio input, time each processed block, and record "
2358
+ "the steady-state distribution (median + p95 ns per sample) "
2359
+ "into `cpuCost`, normalised to a fraction of one core's "
2360
+ "realtime budget and to instances-per-core. Honours --rates.")
2361
+ ap.add_argument("--cost-block", type=int, default=DEFAULT_COST_BLOCK,
2362
+ help="frames per timed block for --cost "
2363
+ f"(default {DEFAULT_COST_BLOCK})")
2364
+ ap.add_argument("--cost-warmup", type=int, default=DEFAULT_COST_WARMUP,
2365
+ help="blocks timed then DISCARDED before the steady-state window "
2366
+ f"(default {DEFAULT_COST_WARMUP})")
2367
+ ap.add_argument("--cost-blocks", type=int, default=DEFAULT_COST_BLOCKS,
2368
+ help="ceiling on timed steady-state blocks per plugin per rate "
2369
+ f"(default {DEFAULT_COST_BLOCKS}); a plugin that spends "
2370
+ f"{COST_TARGET_SECONDS} s stops early, never below "
2371
+ f"{COST_MIN_BLOCKS}")
2372
+ ap.add_argument("--cost-cpu", type=int,
2373
+ help="pin the cost worker to this cpu (default: the last "
2374
+ "performance core this process may use)")
2375
+ ap.add_argument("--cost-provenance",
2376
+ help="write the run's host/CPU/governor/settings provenance here "
2377
+ "as JSON — the machine the cost figures are only meaningful "
2378
+ "next to")
2379
+ ap.add_argument("--cross-check",
2380
+ help="compare the catalog's recorded --cost medians against an "
2381
+ "lv2bench TSV (lv2bench -b 512 -n 262144 -s 64 -o FILE) and "
2382
+ "report the agreement. Reads only; measures nothing.")
2383
+ ap.add_argument("--run", action="store_true",
2384
+ help="actually probe plugins (needs them installed). "
2385
+ "Also OPENMIXER_BENCH=1.")
2386
+ ap.add_argument("--dry-run", action="store_true",
2387
+ help="list the plugins that would be benchmarked, then exit")
2388
+ ap.add_argument("--reclassify", action="store_true",
2389
+ help="re-derive scalingClass/unreliableRates/scalingNote from the "
2390
+ "perRate figures already in the catalog. No probing, no LV2, "
2391
+ "no --run: use it when a classification rule changes.")
2392
+ args = ap.parse_args()
2393
+
2394
+ if args.worker:
2395
+ worker_main(args.rate, args.block if not args.cost else args.cost_block,
2396
+ measure=args.measure, cost=args.cost, cpu=args.cost_cpu,
2397
+ warmup=args.cost_warmup, blocks=args.cost_blocks)
2398
+ return
2399
+
2400
+ if not args.inp:
2401
+ ap.error("--in is required")
2402
+
2403
+ with open(args.inp, encoding="utf-8") as f:
2404
+ catalog = json.load(f)
2405
+
2406
+ if args.cross_check:
2407
+ run_cross_check(catalog, args)
2408
+ return
2409
+
2410
+ if args.cost:
2411
+ run_cost(catalog, args)
2412
+ return
2413
+
2414
+ if args.reclassify:
2415
+ run_reclassify(catalog, args)
2416
+ return
2417
+
2418
+ if args.measure:
2419
+ run_measure(catalog, args)
2420
+ return
2421
+
2422
+ targets = benchmark_targets(catalog)
2423
+
2424
+ if args.dry_run:
2425
+ sys.stderr.write(f"{len(targets)} plugin(s) report a latency port:\n")
2426
+ for d in targets:
2427
+ sys.stderr.write(f" {d['uri']} (port {latency_port_symbol(d)})\n")
2428
+ return
2429
+
2430
+ gated = args.run or os.environ.get("OPENMIXER_BENCH") == "1"
2431
+ if not gated:
2432
+ sys.stderr.write(
2433
+ "refusing to run the benchmark without --run (or OPENMIXER_BENCH=1).\n"
2434
+ "The probe instantiates the host's real LV2 plugins; run it on a host\n"
2435
+ f"that has them installed. {len(targets)} plugin(s) would be "
2436
+ "benchmarked (see --dry-run).\n"
2437
+ )
2438
+ sys.exit(2)
2439
+
2440
+ worker_cmd = (shlex.split(args.worker_cmd) if args.worker_cmd
2441
+ else default_worker_cmd(args.rate, args.block))
2442
+ result = measure_offline(targets, args.rate, worker_cmd,
2443
+ timeout=args.timeout, isolate=args.isolate)
2444
+ annotated = annotate(catalog, result.measured, args.rate)
2445
+ text = json.dumps(annotated, indent=2, ensure_ascii=False)
2446
+ if args.out:
2447
+ with open(args.out, "w", encoding="utf-8") as f:
2448
+ f.write(text + "\n")
2449
+ sys.stderr.write(
2450
+ f"annotated {len(result.measured)}/{len(targets)} plugin(s) -> {args.out}\n"
2451
+ )
2452
+ else:
2453
+ print(text)
2454
+
2455
+ if result.failures:
2456
+ sys.stderr.write(f"{len(result.failures)} plugin(s) failed:\n")
2457
+ for uri, reason in result.failures.items():
2458
+ sys.stderr.write(f" {uri}: {reason}\n")
2459
+
2460
+
2461
+ if __name__ == "__main__":
2462
+ main()