@openmixer/plugin-qualify 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +203 -2
- package/bin/plugin-qualify.mjs +77 -0
- package/dist/clap-loader.d.ts +147 -0
- package/dist/clap-loader.js +252 -0
- package/dist/clap-loader.js.map +1 -0
- package/dist/clap-qualify.d.ts +126 -0
- package/dist/clap-qualify.js +272 -0
- package/dist/clap-qualify.js.map +1 -0
- package/dist/host-profiles.d.ts +42 -0
- package/dist/host-profiles.js +106 -0
- package/dist/host-profiles.js.map +1 -0
- package/dist/hosting-codes.d.ts +62 -0
- package/dist/hosting-codes.js +72 -0
- package/dist/hosting-codes.js.map +1 -0
- package/dist/hosting-suitability.d.ts +362 -0
- package/dist/hosting-suitability.js +669 -0
- package/dist/hosting-suitability.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +26 -0
- package/dist/loader.js +9 -0
- package/dist/loader.js.map +1 -0
- package/dist/lv2-loader.d.ts +7 -0
- package/dist/lv2-loader.js +50 -0
- package/dist/lv2-loader.js.map +1 -0
- package/dist/measurement/cli.d.ts +168 -0
- package/dist/measurement/cli.js +353 -0
- package/dist/measurement/cli.js.map +1 -0
- package/dist/measurement/fixtures.d.ts +23 -0
- package/dist/measurement/fixtures.js +129 -0
- package/dist/measurement/fixtures.js.map +1 -0
- package/dist/measurement/format.d.ts +483 -0
- package/dist/measurement/format.js +627 -0
- package/dist/measurement/format.js.map +1 -0
- package/dist/measurement/guards.d.ts +33 -0
- package/dist/measurement/guards.js +61 -0
- package/dist/measurement/guards.js.map +1 -0
- package/dist/measurement/index.d.ts +36 -0
- package/dist/measurement/index.js +39 -0
- package/dist/measurement/index.js.map +1 -0
- package/dist/measurement/merge.d.ts +143 -0
- package/dist/measurement/merge.js +315 -0
- package/dist/measurement/merge.js.map +1 -0
- package/dist/measurement/plan.d.ts +127 -0
- package/dist/measurement/plan.js +141 -0
- package/dist/measurement/plan.js.map +1 -0
- package/dist/measurement/probe-json.d.ts +61 -0
- package/dist/measurement/probe-json.js +253 -0
- package/dist/measurement/probe-json.js.map +1 -0
- package/dist/measurement/provenance.d.ts +91 -0
- package/dist/measurement/provenance.js +173 -0
- package/dist/measurement/provenance.js.map +1 -0
- package/dist/measurement/sweep-jsonl.d.ts +40 -0
- package/dist/measurement/sweep-jsonl.js +154 -0
- package/dist/measurement/sweep-jsonl.js.map +1 -0
- package/dist/measurement/vocabulary.d.ts +125 -0
- package/dist/measurement/vocabulary.js +217 -0
- package/dist/measurement/vocabulary.js.map +1 -0
- package/dist/qualify-cli.d.ts +83 -0
- package/dist/qualify-cli.js +224 -0
- package/dist/qualify-cli.js.map +1 -0
- package/dist/qualify-declarations.d.ts +98 -0
- package/dist/qualify-declarations.js +76 -0
- package/dist/qualify-declarations.js.map +1 -0
- package/dist/qualify-options.d.ts +22 -0
- package/dist/qualify-options.js +36 -0
- package/dist/qualify-options.js.map +1 -0
- package/dist/rating.d.ts +32 -0
- package/dist/rating.js +30 -0
- package/dist/rating.js.map +1 -0
- package/dist/tool-paths.d.ts +12 -0
- package/dist/tool-paths.js +29 -0
- package/dist/tool-paths.js.map +1 -0
- package/fixtures/clap-scan/controls.json +8 -0
- package/fixtures/clap-scan/fake.json +16 -0
- package/fixtures/clap-scan/mixed.json +18 -0
- package/fixtures/zero-path.lv2/manifest.ttl +3 -0
- package/fixtures/zero-path.lv2/zero-path.c +85 -0
- package/fixtures/zero-path.lv2/zero-path.ttl +21 -0
- package/package.json +94 -4
- package/src/clap-loader.ts +393 -0
- package/src/clap-qualify.ts +387 -0
- package/src/host-profiles.ts +122 -0
- package/src/hosting-codes.ts +71 -0
- package/src/hosting-suitability.ts +998 -0
- package/src/index.ts +19 -0
- package/src/loader.ts +30 -0
- package/src/lv2-loader.ts +50 -0
- package/src/measurement/cli.ts +458 -0
- package/src/measurement/fixtures.ts +141 -0
- package/src/measurement/format.ts +1064 -0
- package/src/measurement/guards.ts +64 -0
- package/src/measurement/index.ts +39 -0
- package/src/measurement/merge.ts +434 -0
- package/src/measurement/plan.ts +219 -0
- package/src/measurement/probe-json.ts +271 -0
- package/src/measurement/provenance.ts +220 -0
- package/src/measurement/sweep-jsonl.ts +173 -0
- package/src/measurement/vocabulary.ts +298 -0
- package/src/qualify-cli.ts +277 -0
- package/src/qualify-declarations.ts +117 -0
- package/src/qualify-options.ts +49 -0
- package/src/rating.ts +53 -0
- package/src/tool-paths.ts +32 -0
- package/tools/_declarations.py +21 -0
- package/tools/benchmark.py +2462 -0
- package/tools/gen-python-declarations.d.mts +4 -0
- package/tools/gen-python-declarations.mjs +92 -0
- package/tools/hosting-sweep.mjs +51 -0
- package/tools/lv2-measure.mjs +204 -0
- package/tools/lv2_presets.py +132 -0
- 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()
|