@openmixer/plugin-qualify 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +354 -0
- package/dist/hosting-suitability.js +654 -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 +91 -3
- 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 +982 -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
package/tools/scan.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
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
|
+
Introspect installed LV2 plugins with lilv (the same library Zynthian uses) and
|
|
6
|
+
emit a JSON catalog of plugin descriptors for openmixer.
|
|
7
|
+
|
|
8
|
+
Each descriptor carries everything the graphical configurator needs to
|
|
9
|
+
auto-generate the right control per parameter: type, range, default, integer/
|
|
10
|
+
toggle/enumeration/logarithmic/trigger flags, enumeration scale points, the unit
|
|
11
|
+
symbol (LV2 units extension), and audio in/out port counts. Both kinds of
|
|
12
|
+
parameter are captured: classic LV2 **control ports** (set via mod-host
|
|
13
|
+
`param_set <id> <symbol> <value>`) and LV2 **Patch properties** (`lv2:Parameter`
|
|
14
|
+
flagged `patch:writable`, set via `patch_set <id> <uri> <value>`).
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
scan.py # scan all installed plugins -> stdout JSON
|
|
18
|
+
scan.py --out catalog.json # write to a file
|
|
19
|
+
scan.py URI [URI ...] # scan only these URIs (faster, for tests)
|
|
20
|
+
scan.py --annotate catalog.json # refresh only the DECLARED fields, in place
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
from lv2_presets import read_presets
|
|
28
|
+
|
|
29
|
+
LV2 = "http://lv2plug.in/ns/lv2core#"
|
|
30
|
+
MINIMUM = LV2 + "minimum"
|
|
31
|
+
MAXIMUM = LV2 + "maximum"
|
|
32
|
+
DEFAULT = LV2 + "default"
|
|
33
|
+
PP = "http://lv2plug.in/ns/ext/port-props#"
|
|
34
|
+
UNITS = "http://lv2plug.in/ns/extensions/units#"
|
|
35
|
+
PATCH = "http://lv2plug.in/ns/ext/patch#"
|
|
36
|
+
ATOM = "http://lv2plug.in/ns/ext/atom#"
|
|
37
|
+
RDFS = "http://www.w3.org/2000/01/rdf-schema#"
|
|
38
|
+
MIDI = "http://lv2plug.in/ns/ext/midi#"
|
|
39
|
+
|
|
40
|
+
# An output control port designated lv2:latency reports the plugin's own latency
|
|
41
|
+
# (in frames) — the truth a host delay-compensates by, and what openmixer reads to
|
|
42
|
+
# annotate the catalog. Captured here as a symbol so the benchmark + the engine can
|
|
43
|
+
# read the live value. `lv2:reportsLatency` is the (legacy) port property form.
|
|
44
|
+
LATENCY = LV2 + "latency"
|
|
45
|
+
REPORTS_LATENCY = LV2 + "reportsLatency"
|
|
46
|
+
|
|
47
|
+
# A plugin's own version. Recorded because a measured latency without it is a trap:
|
|
48
|
+
# the next release moves the FFT window and a committed figure becomes wrong without
|
|
49
|
+
# becoming absent. Absent here means the plugin declares no version — NOT "0.0",
|
|
50
|
+
# which is a real LV2 answer meaning "unstable/development".
|
|
51
|
+
MINOR_VERSION = LV2 + "minorVersion"
|
|
52
|
+
MICRO_VERSION = LV2 + "microVersion"
|
|
53
|
+
|
|
54
|
+
# Common LV2 unit URIs -> display symbol. Plugins may also carry an explicit
|
|
55
|
+
# units:symbol (handled below); this is the fallback for the standard units.
|
|
56
|
+
KNOWN_UNITS = {
|
|
57
|
+
UNITS + "db": "dB", UNITS + "hz": "Hz", UNITS + "khz": "kHz",
|
|
58
|
+
UNITS + "s": "s", UNITS + "ms": "ms", UNITS + "pc": "%",
|
|
59
|
+
UNITS + "semitone12TET": "semi", UNITS + "cent": "ct",
|
|
60
|
+
UNITS + "degree": "deg", UNITS + "coef": "x", UNITS + "oct": "oct",
|
|
61
|
+
UNITS + "bpm": "BPM", UNITS + "midiNote": "note", UNITS + "frame": "fr",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fnum(node):
|
|
66
|
+
"""float(node) tolerant of None and integer/string-typed nodes.
|
|
67
|
+
|
|
68
|
+
lilv's Node.__float__ raises unless the node is a float literal, but LV2
|
|
69
|
+
min/max/default are often integer literals — so fall back to parsing the
|
|
70
|
+
string form, and return None for anything non-numeric.
|
|
71
|
+
"""
|
|
72
|
+
if node is None:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
return float(node)
|
|
76
|
+
except (ValueError, TypeError):
|
|
77
|
+
try:
|
|
78
|
+
return float(str(node))
|
|
79
|
+
except (ValueError, TypeError):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def first(nodes):
|
|
84
|
+
"""First element of a lilv Nodes collection, or None."""
|
|
85
|
+
if nodes is None:
|
|
86
|
+
return None
|
|
87
|
+
for n in nodes:
|
|
88
|
+
return n
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def bundle_path(plugin):
|
|
93
|
+
"""Filesystem path of a lilv plugin's bundle (the `.lv2` dir), or None.
|
|
94
|
+
|
|
95
|
+
lilv returns a `file://` URI for the bundle; strip the scheme so `rpm -qf` can own it.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
uri = plugin.get_bundle_uri()
|
|
99
|
+
except Exception: # noqa: BLE001 - a plugin with no resolvable bundle just has no owner
|
|
100
|
+
return None
|
|
101
|
+
if uri is None:
|
|
102
|
+
return None
|
|
103
|
+
p = str(uri)
|
|
104
|
+
if p.startswith("file://"):
|
|
105
|
+
p = p[len("file://"):]
|
|
106
|
+
return p.rstrip("/") or None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def owning_rpm(path, run=subprocess.run):
|
|
110
|
+
"""The RPM package name owning `path`, or None (unowned / rpm unavailable / error).
|
|
111
|
+
|
|
112
|
+
`run` is injectable (defaults to subprocess.run) so the mapping unit-tests without rpm.
|
|
113
|
+
Every plugin on the rig is expected to map to exactly one RPM (no hand-installed plugins);
|
|
114
|
+
a None here is surfaced by the assembler (Task 5) as an unpackaged plugin, never silently
|
|
115
|
+
dropped.
|
|
116
|
+
"""
|
|
117
|
+
if not path:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
r = run(["rpm", "-qf", "--queryformat", "%{NAME}", path],
|
|
121
|
+
capture_output=True, text=True, check=False, timeout=15)
|
|
122
|
+
except Exception: # noqa: BLE001 - rpm not installed / timeout: no owner, not a scan failure
|
|
123
|
+
return None
|
|
124
|
+
if r.returncode != 0:
|
|
125
|
+
return None
|
|
126
|
+
name = (r.stdout or "").strip()
|
|
127
|
+
return name or None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Scanner:
|
|
131
|
+
def __init__(self):
|
|
132
|
+
# lilv is imported here, not at module load: the owner mapping and the pure helpers
|
|
133
|
+
# (test_scan.py) run on a host without the bindings, the way benchmark.py's worker does.
|
|
134
|
+
import lilv
|
|
135
|
+
|
|
136
|
+
self.w = lilv.World()
|
|
137
|
+
self.w.load_all()
|
|
138
|
+
self.u = {k: self.w.new_uri(k) for k in (
|
|
139
|
+
LV2 + "AudioPort", LV2 + "ControlPort", LV2 + "CVPort",
|
|
140
|
+
LV2 + "InputPort", LV2 + "OutputPort", ATOM + "AtomPort",
|
|
141
|
+
LV2 + "integer", LV2 + "toggled", LV2 + "enumeration",
|
|
142
|
+
PP + "logarithmic", PP + "trigger", PP + "notOnGUI",
|
|
143
|
+
UNITS + "unit", UNITS + "symbol", PATCH + "writable",
|
|
144
|
+
RDFS + "label", RDFS + "range", MIDI + "MidiEvent",
|
|
145
|
+
LV2 + "designation", LATENCY, REPORTS_LATENCY,
|
|
146
|
+
MINIMUM, MAXIMUM, DEFAULT,
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
def is_a(self, port, uri):
|
|
150
|
+
return port.is_a(self.u[uri])
|
|
151
|
+
|
|
152
|
+
def has(self, port, uri):
|
|
153
|
+
return bool(port.has_property(self.u[uri]))
|
|
154
|
+
|
|
155
|
+
def unit_symbol(self, port):
|
|
156
|
+
un = first(port.get_value(self.u[UNITS + "unit"]))
|
|
157
|
+
if un is None:
|
|
158
|
+
return None
|
|
159
|
+
uri = str(un)
|
|
160
|
+
if uri in KNOWN_UNITS:
|
|
161
|
+
return KNOWN_UNITS[uri]
|
|
162
|
+
sym = first(self.w.find_nodes(un, self.u[UNITS + "symbol"], None))
|
|
163
|
+
return str(sym) if sym is not None else None
|
|
164
|
+
|
|
165
|
+
def scale_points(self, port):
|
|
166
|
+
out = []
|
|
167
|
+
sps = port.get_scale_points()
|
|
168
|
+
if sps:
|
|
169
|
+
for sp in sps:
|
|
170
|
+
v = fnum(sp.get_value())
|
|
171
|
+
if v is not None:
|
|
172
|
+
out.append({"label": str(sp.get_label()), "value": v})
|
|
173
|
+
out.sort(key=lambda s: s["value"])
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
def control_param(self, port):
|
|
177
|
+
d, mn, mx = port.get_range()
|
|
178
|
+
nm = port.get_name()
|
|
179
|
+
sps = self.scale_points(port)
|
|
180
|
+
mnf, mxf, df = fnum(mn), fnum(mx), fnum(d)
|
|
181
|
+
return {
|
|
182
|
+
"kind": "control",
|
|
183
|
+
"symbol": str(port.get_symbol()),
|
|
184
|
+
"name": str(nm) if nm is not None else str(port.get_symbol()),
|
|
185
|
+
"min": mnf if mnf is not None else 0.0,
|
|
186
|
+
"max": mxf if mxf is not None else 1.0,
|
|
187
|
+
"default": df if df is not None else (mnf if mnf is not None else 0.0),
|
|
188
|
+
"isInteger": self.has(port, LV2 + "integer"),
|
|
189
|
+
"isToggle": self.has(port, LV2 + "toggled"),
|
|
190
|
+
# Enumeration is ONLY the explicit lv2:enumeration designation. Scale
|
|
191
|
+
# points are labels/snap-hints layered on top (carried below) — a
|
|
192
|
+
# continuous port that happens to label one value (e.g. darc Ratio's
|
|
193
|
+
# lone "Lim" mark) is NOT a dropdown.
|
|
194
|
+
"isEnumeration": self.has(port, LV2 + "enumeration"),
|
|
195
|
+
"isLogarithmic": self.has(port, PP + "logarithmic"),
|
|
196
|
+
"isTrigger": self.has(port, PP + "trigger"),
|
|
197
|
+
"unit": self.unit_symbol(port),
|
|
198
|
+
**({"scalePoints": sps} if sps else {}),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def is_latency_port(self, port):
|
|
202
|
+
"""True for an output control port that reports the plugin's latency.
|
|
203
|
+
|
|
204
|
+
Two LV2 spellings: `lv2:designation lv2:latency` (current) and the legacy
|
|
205
|
+
`lv2:reportsLatency` port property. Either marks the port whose value is the
|
|
206
|
+
plugin's latency in frames.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
for d in (port.get_value(self.u[LV2 + "designation"]) or []):
|
|
210
|
+
if str(d) == LATENCY:
|
|
211
|
+
return True
|
|
212
|
+
except Exception: # noqa: BLE001
|
|
213
|
+
pass
|
|
214
|
+
return self.has(port, REPORTS_LATENCY)
|
|
215
|
+
|
|
216
|
+
def patch_params(self, plugin):
|
|
217
|
+
"""LV2 Parameters (patch:writable) — the atom-port / patch:Set kind.
|
|
218
|
+
|
|
219
|
+
Numeric patch params (Int/Long/Float/Double) often declare lv2:minimum /
|
|
220
|
+
maximum / default just like control ports; capture them so the configurator
|
|
221
|
+
can render a bounded widget (stepper/knob) instead of a bare readout.
|
|
222
|
+
"""
|
|
223
|
+
out = []
|
|
224
|
+
try:
|
|
225
|
+
writable = plugin.get_value(self.u[PATCH + "writable"])
|
|
226
|
+
for puri in (writable if writable is not None else []):
|
|
227
|
+
label = first(self.w.find_nodes(
|
|
228
|
+
puri, self.u[RDFS + "label"], None))
|
|
229
|
+
rng = first(self.w.find_nodes(
|
|
230
|
+
puri, self.u[RDFS + "range"], None))
|
|
231
|
+
mn = fnum(first(self.w.find_nodes(puri, self.u[MINIMUM], None)))
|
|
232
|
+
mx = fnum(first(self.w.find_nodes(puri, self.u[MAXIMUM], None)))
|
|
233
|
+
df = fnum(first(self.w.find_nodes(puri, self.u[DEFAULT], None)))
|
|
234
|
+
out.append({
|
|
235
|
+
"kind": "patch",
|
|
236
|
+
"symbol": str(puri).rsplit("#", 1)[-1].rsplit("/", 1)[-1],
|
|
237
|
+
"uri": str(puri),
|
|
238
|
+
"name": str(label) if label is not None else str(puri),
|
|
239
|
+
"datatype": str(rng).rsplit("#", 1)[-1] if rng is not None else "String",
|
|
240
|
+
**({"min": mn} if mn is not None else {}),
|
|
241
|
+
**({"max": mx} if mx is not None else {}),
|
|
242
|
+
**({"default": df} if df is not None else {}),
|
|
243
|
+
})
|
|
244
|
+
except Exception as e: # noqa: BLE001 - best-effort, never fail the scan
|
|
245
|
+
sys.stderr.write(f" patch params skipped: {e}\n")
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
def describe(self, plugin):
|
|
249
|
+
params, audio_in, audio_out, midi_in = [], 0, 0, False
|
|
250
|
+
latency_symbol = None
|
|
251
|
+
for i in range(plugin.get_num_ports()):
|
|
252
|
+
port = plugin.get_port_by_index(i)
|
|
253
|
+
if self.is_a(port, LV2 + "AudioPort"):
|
|
254
|
+
if self.is_a(port, LV2 + "InputPort"):
|
|
255
|
+
audio_in += 1
|
|
256
|
+
else:
|
|
257
|
+
audio_out += 1
|
|
258
|
+
elif self.is_a(port, LV2 + "ControlPort") and self.is_a(port, LV2 + "InputPort"):
|
|
259
|
+
if self.has(port, PP + "notOnGUI"):
|
|
260
|
+
continue
|
|
261
|
+
params.append(self.control_param(port))
|
|
262
|
+
elif self.is_a(port, LV2 + "ControlPort") and self.is_a(port, LV2 + "OutputPort"):
|
|
263
|
+
# output control ports aren't user params, but the lv2:latency one
|
|
264
|
+
# tells us (and the benchmark) where to read the plugin's latency.
|
|
265
|
+
if latency_symbol is None and self.is_latency_port(port):
|
|
266
|
+
latency_symbol = str(port.get_symbol())
|
|
267
|
+
elif self.is_a(port, ATOM + "AtomPort") and self.is_a(port, LV2 + "InputPort"):
|
|
268
|
+
try:
|
|
269
|
+
if port.supports_event(self.u[MIDI + "MidiEvent"]):
|
|
270
|
+
midi_in = True
|
|
271
|
+
except Exception: # noqa: BLE001
|
|
272
|
+
pass
|
|
273
|
+
params.extend(self.patch_params(plugin))
|
|
274
|
+
cls = plugin.get_class().get_label()
|
|
275
|
+
out = {
|
|
276
|
+
"uri": str(plugin.get_uri()),
|
|
277
|
+
"name": str(plugin.get_name()),
|
|
278
|
+
"lv2Class": str(cls) if cls is not None else None,
|
|
279
|
+
"audioInputs": audio_in,
|
|
280
|
+
"audioOutputs": audio_out,
|
|
281
|
+
"hasMidiIn": midi_in,
|
|
282
|
+
"params": params,
|
|
283
|
+
}
|
|
284
|
+
# The bundle's own one-line description of itself. A DECLARED fact, like the
|
|
285
|
+
# class beside it, and the only other thing a plugin says about its role —
|
|
286
|
+
# which is what the kind ladder falls through to when a plugin declares the
|
|
287
|
+
# bare lv2:Plugin and nothing more (416 of the reference install's 958 carry
|
|
288
|
+
# one). Absent stays absent: an empty string is not a description.
|
|
289
|
+
comment = declared_comment(plugin, self.w)
|
|
290
|
+
if comment:
|
|
291
|
+
out["lv2Comment"] = comment
|
|
292
|
+
# The presets the plugin SHIPS:
|
|
293
|
+
# a DECLARED fact like the comment above, read off the bundle and judged against the params
|
|
294
|
+
# just read. Always present on a scanned plugin — `[]` is "ships none", never "not read".
|
|
295
|
+
out["presets"] = read_presets(self.w, plugin, params)
|
|
296
|
+
# Record the latency port symbol if the plugin reports one. The actual frame
|
|
297
|
+
# value is filled later by the on-rig benchmark (benchmark.py); a plugin with
|
|
298
|
+
# no latency port carries no `latency` key → host treats it as unknown, not 0.
|
|
299
|
+
if latency_symbol is not None:
|
|
300
|
+
out["latency"] = {"portSymbol": latency_symbol}
|
|
301
|
+
version = self.plugin_version(plugin)
|
|
302
|
+
if version is not None:
|
|
303
|
+
out["minorVersion"], out["microVersion"] = version
|
|
304
|
+
bp = bundle_path(plugin)
|
|
305
|
+
out["bundlePath"] = bp
|
|
306
|
+
out["owningRpm"] = owning_rpm(bp)
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
def plugin_version(self, plugin):
|
|
310
|
+
"""(minor, micro) from lv2:minorVersion / lv2:microVersion, or None.
|
|
311
|
+
|
|
312
|
+
Both or neither: a half-declared version is not a version, and pairing a real
|
|
313
|
+
minor with an invented micro would make two builds compare equal that are not.
|
|
314
|
+
"""
|
|
315
|
+
minor = fnum(first(plugin.get_value(self.w.new_uri(MINOR_VERSION))))
|
|
316
|
+
micro = fnum(first(plugin.get_value(self.w.new_uri(MICRO_VERSION))))
|
|
317
|
+
if minor is None or micro is None:
|
|
318
|
+
return None
|
|
319
|
+
return int(minor), int(micro)
|
|
320
|
+
|
|
321
|
+
def scan(self, uris=None):
|
|
322
|
+
plugins = self.w.get_all_plugins()
|
|
323
|
+
out = []
|
|
324
|
+
if uris:
|
|
325
|
+
for uri in uris:
|
|
326
|
+
p = plugins.get_by_uri(self.w.new_uri(uri))
|
|
327
|
+
if p is not None:
|
|
328
|
+
out.append(self.describe(p))
|
|
329
|
+
else:
|
|
330
|
+
sys.stderr.write(f"not found: {uri}\n")
|
|
331
|
+
else:
|
|
332
|
+
for p in plugins:
|
|
333
|
+
try:
|
|
334
|
+
out.append(self.describe(p))
|
|
335
|
+
except Exception as e: # noqa: BLE001
|
|
336
|
+
sys.stderr.write(f"skip {p.get_uri()}: {e}\n")
|
|
337
|
+
out.sort(key=lambda d: d["uri"])
|
|
338
|
+
return out
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def declared_comment(plugin, world):
|
|
342
|
+
"""The bundle's `rdfs:comment` for this plugin, or None.
|
|
343
|
+
|
|
344
|
+
One value only: a bundle that declares several is declaring alternatives, and
|
|
345
|
+
concatenating them would invent a sentence nobody wrote.
|
|
346
|
+
"""
|
|
347
|
+
nodes = plugin.get_value(world.new_uri(RDFS + "comment"))
|
|
348
|
+
values = list(nodes) if nodes is not None else []
|
|
349
|
+
if not values:
|
|
350
|
+
return None
|
|
351
|
+
text = " ".join(str(values[0]).split())
|
|
352
|
+
return text or None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
DECLARED_FIELDS = ("name", "lv2Class", "lv2Comment", "presets")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def annotate(catalog, scanner):
|
|
359
|
+
"""Refresh the DECLARED fields of every URI already in `catalog`, in place.
|
|
360
|
+
|
|
361
|
+
The division of labour this file has always kept: scan.py owns what a plugin
|
|
362
|
+
SAYS about itself, benchmark.py owns what the rig MEASURED of it. So this pass
|
|
363
|
+
rewrites `name`, `lv2Class`, `lv2Comment` and `presets` from the installed bundle and
|
|
364
|
+
touches nothing else — no latency, no cost, no owning package, and no entry is
|
|
365
|
+
added or removed. A URI this host does not have installed is left exactly as it
|
|
366
|
+
is and counted, because "not installed here" and "declares nothing" are
|
|
367
|
+
different facts and only one of them is about the plugin.
|
|
368
|
+
|
|
369
|
+
Returns (changed, absent) counts.
|
|
370
|
+
"""
|
|
371
|
+
plugins = scanner.w.get_all_plugins()
|
|
372
|
+
changed = 0
|
|
373
|
+
absent = 0
|
|
374
|
+
for entry in catalog:
|
|
375
|
+
plugin = plugins.get_by_uri(scanner.w.new_uri(entry["uri"]))
|
|
376
|
+
if plugin is None:
|
|
377
|
+
absent += 1
|
|
378
|
+
continue
|
|
379
|
+
fresh = scanner.describe(plugin)
|
|
380
|
+
for field in DECLARED_FIELDS:
|
|
381
|
+
before = entry.get(field)
|
|
382
|
+
after = fresh.get(field)
|
|
383
|
+
if after is None:
|
|
384
|
+
entry.pop(field, None)
|
|
385
|
+
else:
|
|
386
|
+
entry[field] = after
|
|
387
|
+
if before != entry.get(field):
|
|
388
|
+
changed += 1
|
|
389
|
+
return changed, absent
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def main():
|
|
393
|
+
ap = argparse.ArgumentParser(description="Scan installed LV2 plugins -> JSON.")
|
|
394
|
+
ap.add_argument("uris", nargs="*", help="specific plugin URIs (default: all)")
|
|
395
|
+
ap.add_argument("--out", help="write JSON here (default: stdout)")
|
|
396
|
+
ap.add_argument("--annotate", metavar="FILE",
|
|
397
|
+
help="refresh only the DECLARED fields of an existing catalog, in place")
|
|
398
|
+
args = ap.parse_args()
|
|
399
|
+
if args.annotate:
|
|
400
|
+
with open(args.annotate, encoding="utf-8") as f:
|
|
401
|
+
catalog = json.load(f)
|
|
402
|
+
if not catalog:
|
|
403
|
+
sys.exit(f"{args.annotate} read as empty; refusing to annotate nothing")
|
|
404
|
+
changed, absent = annotate(catalog, Scanner())
|
|
405
|
+
with open(args.annotate, "w", encoding="utf-8") as f:
|
|
406
|
+
f.write(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n")
|
|
407
|
+
sys.stderr.write(
|
|
408
|
+
f"annotated {len(catalog)} plugins in {args.annotate}: "
|
|
409
|
+
f"{changed} declared fields changed, {absent} not installed here\n")
|
|
410
|
+
return
|
|
411
|
+
catalog = Scanner().scan(args.uris or None)
|
|
412
|
+
text = json.dumps(catalog, indent=2, ensure_ascii=False)
|
|
413
|
+
if args.out:
|
|
414
|
+
with open(args.out, "w", encoding="utf-8") as f:
|
|
415
|
+
f.write(text + "\n")
|
|
416
|
+
sys.stderr.write(f"wrote {len(catalog)} plugins -> {args.out}\n")
|
|
417
|
+
# Sibling URI manifest (committed, small) — the guardrail test cross-checks
|
|
418
|
+
# every curated URI against it, so a typo'd URI fails CI even though the
|
|
419
|
+
# full catalog.json is gitignored/host-specific. Only on a full scan.
|
|
420
|
+
if not args.uris:
|
|
421
|
+
import os
|
|
422
|
+
uri_path = os.path.join(os.path.dirname(args.out) or ".", "installed-uris.json")
|
|
423
|
+
uris = sorted(d["uri"] for d in catalog)
|
|
424
|
+
with open(uri_path, "w", encoding="utf-8") as f:
|
|
425
|
+
f.write(json.dumps(uris, indent=1, ensure_ascii=False) + "\n")
|
|
426
|
+
sys.stderr.write(f"wrote {len(uris)} URIs -> {uri_path}\n")
|
|
427
|
+
else:
|
|
428
|
+
print(text)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
if __name__ == "__main__":
|
|
432
|
+
main()
|