@openmixer/plugin-qualify 0.13.0 → 0.14.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/tools/scan.py CHANGED
@@ -13,17 +13,25 @@ parameter are captured: classic LV2 **control ports** (set via mod-host
13
13
  `param_set <id> <symbol> <value>`) and LV2 **Patch properties** (`lv2:Parameter`
14
14
  flagged `patch:writable`, set via `patch_set <id> <uri> <value>`).
15
15
 
16
+ Beside the catalog, every plugin scanned is written as a scanned descriptor (omx-contract's
17
+ descriptor words, `descriptors.py`) into `descriptor_dir`: `--descriptor-dir`, else `--system`'s
18
+ directory, else `$XDG_STATE_HOME/omx/descriptors`, else `~/.local/state/omx/descriptors`.
19
+
16
20
  Usage:
17
21
  scan.py # scan all installed plugins -> stdout JSON
18
22
  scan.py --out catalog.json # write to a file
19
23
  scan.py URI [URI ...] # scan only these URIs (faster, for tests)
20
24
  scan.py --annotate catalog.json # refresh only the DECLARED fields, in place
25
+ scan.py --descriptor-dir DIR # the scanned descriptors go here
26
+ scan.py --no-descriptors # the catalog only
21
27
  """
22
28
  import argparse
23
29
  import json
30
+ import os
24
31
  import subprocess
25
32
  import sys
26
33
 
34
+ import descriptors as desc
27
35
  from lv2_presets import read_presets
28
36
 
29
37
  LV2 = "http://lv2plug.in/ns/lv2core#"
@@ -36,6 +44,9 @@ PATCH = "http://lv2plug.in/ns/ext/patch#"
36
44
  ATOM = "http://lv2plug.in/ns/ext/atom#"
37
45
  RDFS = "http://www.w3.org/2000/01/rdf-schema#"
38
46
  MIDI = "http://lv2plug.in/ns/ext/midi#"
47
+ PG = "http://lv2plug.in/ns/ext/port-groups#"
48
+ PARAM = "http://lv2plug.in/ns/ext/parameters#"
49
+ TIME = "http://lv2plug.in/ns/ext/time#"
39
50
 
40
51
  # An output control port designated lv2:latency reports the plugin's own latency
41
52
  # (in frames) — the truth a host delay-compensates by, and what openmixer reads to
@@ -61,6 +72,30 @@ KNOWN_UNITS = {
61
72
  UNITS + "bpm": "BPM", UNITS + "midiNote": "note", UNITS + "frame": "fr",
62
73
  }
63
74
 
75
+ # LV2 unit URI -> the descriptor's unit word; any other unit, or none, is `unknown`
76
+ DESCRIPTOR_UNITS = {
77
+ UNITS + "db": "dB", UNITS + "hz": "Hz", UNITS + "s": "s", UNITS + "ms": "ms",
78
+ UNITS + "min": "min", UNITS + "pc": "%", UNITS + "semitone12TET": "st", UNITS + "cent": "cents",
79
+ UNITS + "degree": "deg", UNITS + "coef": "x", UNITS + "oct": "oct", UNITS + "bpm": "bpm",
80
+ UNITS + "midiNote": "midiNote", UNITS + "frame": "frames",
81
+ }
82
+
83
+ # lv2:designation -> the descriptor's meaning, where the meaning carries the LV2 term's name
84
+ DESIGNATED_MEANINGS = {
85
+ **{PARAM + m: m for m in ("gain", "threshold", "ratio", "attack", "release", "hold", "decay", "frequency",
86
+ "cutoffFrequency", "resonance", "bypass", "wetDryRatio", "delay")},
87
+ LV2 + "enabled": "enabled",
88
+ }
89
+
90
+ # LV2 classes -> the descriptor's plugin features; a plugin of none of them is an audio effect
91
+ CLASS_FEATURES = {
92
+ LV2 + "InstrumentPlugin": "instrument",
93
+ LV2 + "AnalyserPlugin": "analyzer",
94
+ LV2 + "MIDIPlugin": "note-effect",
95
+ }
96
+
97
+ NUMERIC_PATCH_RANGES = ("Float", "Double", "Int", "Long")
98
+
64
99
 
65
100
  def fnum(node):
66
101
  """float(node) tolerant of None and integer/string-typed nodes.
@@ -80,11 +115,23 @@ def fnum(node):
80
115
  return None
81
116
 
82
117
 
118
+ def each(nodes):
119
+ """Every element of a lilv collection (None is empty), walked by the binding's own begin, is_end and next,
120
+ which every version has: the Python iteration of some does not hold for every collection."""
121
+ if nodes is None:
122
+ return []
123
+ if isinstance(nodes, (list, tuple)):
124
+ return list(nodes)
125
+ out = []
126
+ it = nodes.begin()
127
+ while not it.is_end():
128
+ out.append(it.next())
129
+ return out
130
+
131
+
83
132
  def first(nodes):
84
133
  """First element of a lilv Nodes collection, or None."""
85
- if nodes is None:
86
- return None
87
- for n in nodes:
134
+ for n in each(nodes):
88
135
  return n
89
136
  return None
90
137
 
@@ -144,6 +191,8 @@ class Scanner:
144
191
  RDFS + "label", RDFS + "range", MIDI + "MidiEvent",
145
192
  LV2 + "designation", LATENCY, REPORTS_LATENCY,
146
193
  MINIMUM, MAXIMUM, DEFAULT,
194
+ LV2 + "isSideChain", LV2 + "name", PG + "group", TIME + "Position",
195
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
147
196
  )}
148
197
 
149
198
  def is_a(self, port, uri):
@@ -306,6 +355,195 @@ class Scanner:
306
355
  out["owningRpm"] = owning_rpm(bp)
307
356
  return out
308
357
 
358
+ # ---- the scanned descriptor ----
359
+
360
+ def designation_meaning(self, port):
361
+ """The meaning an lv2:designation names; `unknown` with none; absent (None) for one with no word."""
362
+ des = [str(d) for d in each(port.get_value(self.u[LV2 + "designation"]))]
363
+ if not des:
364
+ return "unknown"
365
+ for d in des:
366
+ if d in DESIGNATED_MEANINGS:
367
+ return DESIGNATED_MEANINGS[d]
368
+ return None
369
+
370
+ def port_group(self, port):
371
+ """The pg:group a port belongs to, by its name, as a one-step group path."""
372
+ g = first(port.get_value(self.u[PG + "group"]))
373
+ if g is None:
374
+ return None
375
+ label = first(self.w.find_nodes(g, self.u[LV2 + "name"], None)) or first(self.w.find_nodes(g, self.u[RDFS + "label"], None))
376
+ return [str(label)] if label is not None and str(label) else None
377
+
378
+ def descriptor_unit(self, port):
379
+ un = first(port.get_value(self.u[UNITS + "unit"]))
380
+ return DESCRIPTOR_UNITS.get(str(un), "unknown") if un is not None else "unknown"
381
+
382
+ def descriptor_control(self, port, link, readonly=False):
383
+ """An input (or, readonly, an output) control port as a descriptor control."""
384
+ d, mn, mx = port.get_range()
385
+ mnf, mxf, df = fnum(mn), fnum(mx), fnum(d)
386
+ sym = str(port.get_symbol())
387
+ nm = port.get_name()
388
+ c = {"id": sym, "name": str(nm) if nm is not None else sym}
389
+ sps = self.scale_points(port)
390
+ if self.has(port, LV2 + "toggled"):
391
+ c["kind"] = "toggle"
392
+ if df is not None:
393
+ c["default"] = df > 0
394
+ elif self.has(port, PP + "trigger"):
395
+ c["kind"] = "trigger"
396
+ elif self.has(port, LV2 + "enumeration") and sps:
397
+ ids = desc.choice_ids([sp["label"] for sp in sps])
398
+ c["kind"] = "choice"
399
+ c["ids"] = ids
400
+ if all(sp["label"] for sp in sps):
401
+ c["labels"] = [sp["label"] for sp in sps]
402
+ c["values"] = [sp["value"] for sp in sps]
403
+ for i, sp in enumerate(sps):
404
+ if df is not None and sp["value"] == df:
405
+ c["default"] = ids[i]
406
+ else:
407
+ # an LV2 port's range defaults to 0..1 where it declares none (the catalog row's own rule)
408
+ integer = self.has(port, LV2 + "integer")
409
+ travel = {
410
+ "min": mnf if mnf is not None else 0.0,
411
+ "max": mxf if mxf is not None else 1.0,
412
+ "unit": self.descriptor_unit(port),
413
+ "scale": "log" if self.has(port, PP + "logarithmic") else "stepped" if integer else "linear",
414
+ }
415
+ if integer:
416
+ travel["step"] = 1
417
+ if df is not None:
418
+ travel["default"] = df
419
+ c["kind"] = "travel"
420
+ c["travel"] = travel
421
+ group = self.port_group(port)
422
+ if group:
423
+ c["group"] = group
424
+ meaning = self.designation_meaning(port)
425
+ if meaning is not None:
426
+ c["meaning"] = meaning
427
+ if self.has(port, PP + "notOnGUI"):
428
+ c["hidden"] = True
429
+ if readonly:
430
+ c["readonly"] = True
431
+ c["source"] = {"from": f"{link}#{desc.link_symbol(sym)}"}
432
+ return c
433
+
434
+ def descriptor(self, plugin):
435
+ """The plugin as a scanned descriptor, and the SHA-256 of its binary: `(doc, sha256)`."""
436
+ uri = str(plugin.get_uri())
437
+ bundle = bundle_path(plugin) or ""
438
+ lib = plugin.get_library_uri()
439
+ lib_path = lib.get_path() if lib is not None else None
440
+ try:
441
+ sha256 = desc.file_sha256(lib_path) if lib_path else None
442
+ except desc.DescriptorRefused:
443
+ sha256 = None # refused by its code when it is written: no name without the binary's hash
444
+ data = sorted(os.path.basename(str(u)) for u in each(plugin.get_data_uris()))
445
+ ttl = next((f for f in data if f != "manifest.ttl"), "manifest.ttl")
446
+ bundle_seg = desc.link_segment(os.path.basename(bundle))
447
+ link = f"{bundle_seg}/{desc.link_segment(ttl)}"
448
+
449
+ types = [str(t) for t in each(plugin.get_value(self.u["http://www.w3.org/1999/02/22-rdf-syntax-ns#type"]))]
450
+ features = [CLASS_FEATURES[t] for t in types if t in CLASS_FEATURES]
451
+ p = {"id": uri, "uri": uri, "name": str(plugin.get_name())}
452
+ author = plugin.get_author_name()
453
+ if author is not None and str(author):
454
+ p["vendor"] = str(author)
455
+ version = self.plugin_version(plugin)
456
+ if version is not None:
457
+ p["version"] = f"{version[0]}.{version[1]}"
458
+ comment = declared_comment(plugin, self.w)
459
+ if comment:
460
+ p["doc"] = comment
461
+ p["features"] = list(dict.fromkeys(features)) or ["audio-effect"]
462
+ d = desc.head("lv2", p)
463
+
464
+ controls, ports = [], []
465
+ latency_symbol, timed = None, False
466
+ for i in range(plugin.get_num_ports()):
467
+ port = plugin.get_port_by_index(i)
468
+ sym = str(port.get_symbol())
469
+ nm = port.get_name()
470
+ name = str(nm) if nm is not None else sym
471
+ direction = "in" if self.is_a(port, LV2 + "InputPort") else "out"
472
+ if self.is_a(port, LV2 + "AudioPort"):
473
+ ports.append({"id": sym, "name": name, "kind": "audio", "direction": direction,
474
+ "role": "sidechain" if self.has(port, LV2 + "isSideChain") else "main", "channels": 1})
475
+ elif self.is_a(port, LV2 + "CVPort"):
476
+ ports.append({"id": sym, "name": name, "kind": "cv", "direction": direction})
477
+ elif self.is_a(port, LV2 + "ControlPort"):
478
+ if direction == "out" and latency_symbol is None and self.is_latency_port(port):
479
+ latency_symbol = sym
480
+ continue
481
+ controls.append(self.descriptor_control(port, link, readonly=direction == "out"))
482
+ elif self.is_a(port, ATOM + "AtomPort"):
483
+ try:
484
+ midi = bool(port.supports_event(self.u[MIDI + "MidiEvent"]))
485
+ timed = timed or (direction == "in" and bool(port.supports_event(self.u[TIME + "Position"])))
486
+ except Exception: # noqa: BLE001
487
+ midi = False
488
+ if midi:
489
+ ports.append({"id": sym, "name": name, "kind": "midi", "direction": direction})
490
+
491
+ writable = []
492
+ for prm in self.patch_params(plugin):
493
+ writable.append({"uri": prm["uri"], "label": prm["name"], "range": prm["datatype"]})
494
+ if prm["datatype"] in NUMERIC_PATCH_RANGES and "min" in prm and "max" in prm:
495
+ travel = {"min": prm["min"], "max": prm["max"], "unit": "unknown", "scale": "linear"}
496
+ if "default" in prm:
497
+ travel["default"] = prm["default"]
498
+ controls.append({"id": desc.link_symbol(prm["symbol"]), "name": prm["name"], "kind": "travel",
499
+ "travel": travel, "meaning": "unknown",
500
+ "source": {"from": f"{link}#{desc.link_symbol(prm['symbol'])}"}})
501
+ d["controls"] = controls
502
+ d["ports"] = ports
503
+
504
+ needs = {}
505
+ if timed:
506
+ needs["tempo"] = needs["transport"] = True
507
+ required = [str(f) for f in each(plugin.get_required_features())]
508
+ optional = [str(f) for f in each(plugin.get_optional_features())]
509
+ ext = {}
510
+ if required:
511
+ ext["required"] = sorted(set(required))
512
+ if optional:
513
+ ext["optional"] = sorted(set(optional))
514
+ if ext:
515
+ needs["extensions"] = ext
516
+ if needs:
517
+ d["needs"] = needs
518
+ d["latency"] = {"reports": latency_symbol is not None}
519
+
520
+ bag = {"rdf:type": str(plugin.get_class().get_uri())}
521
+ if latency_symbol is not None:
522
+ bag["lv2:latency"] = latency_symbol
523
+ if writable:
524
+ bag["patch:writable"] = writable
525
+ d["extensions"] = {"lv2": bag}
526
+ lib_name = os.path.basename(lib_path) if lib_path else "manifest.ttl"
527
+ d["source"] = {"from": f"{bundle_seg}/{desc.link_segment(lib_name)}#lv2_descriptor"}
528
+ return d, sha256
529
+
530
+ def descriptors(self, uris=None):
531
+ """`(doc, sha256)` for every plugin `scan` would describe."""
532
+ plugins = self.w.get_all_plugins()
533
+ chosen = [plugins.get_by_uri(self.w.new_uri(u)) for u in uris] if uris else list(plugins)
534
+ out = []
535
+ for p in chosen:
536
+ if p is None:
537
+ continue
538
+ try:
539
+ out.append(self.descriptor(p))
540
+ except desc.DescriptorRefused as e:
541
+ sys.stderr.write(f"descriptor refused: {p.get_uri()}: {e.code}: {e.problem}\n")
542
+ except Exception as e: # noqa: BLE001 - one plugin's bad data never stops the scan
543
+ line = e.__traceback__.tb_next.tb_lineno if e.__traceback__ and e.__traceback__.tb_next else "?"
544
+ sys.stderr.write(f"not described: {p.get_uri()}: {type(e).__name__}: {e} (scan.py:{line})\n")
545
+ return out
546
+
309
547
  def plugin_version(self, plugin):
310
548
  """(minor, micro) from lv2:minorVersion / lv2:microVersion, or None.
311
549
 
@@ -395,6 +633,11 @@ def main():
395
633
  ap.add_argument("--out", help="write JSON here (default: stdout)")
396
634
  ap.add_argument("--annotate", metavar="FILE",
397
635
  help="refresh only the DECLARED fields of an existing catalog, in place")
636
+ where = ap.add_mutually_exclusive_group()
637
+ where.add_argument("--descriptor-dir", help="where the scanned descriptors go (descriptor_dir)")
638
+ where.add_argument("--system", action="store_true",
639
+ help=f"a system service's descriptor_dir, {desc.DESCRIPTOR_DIR_SYSTEM}")
640
+ where.add_argument("--no-descriptors", action="store_true", help="write the catalog only")
398
641
  args = ap.parse_args()
399
642
  if args.annotate:
400
643
  with open(args.annotate, encoding="utf-8") as f:
@@ -408,7 +651,18 @@ def main():
408
651
  f"annotated {len(catalog)} plugins in {args.annotate}: "
409
652
  f"{changed} declared fields changed, {absent} not installed here\n")
410
653
  return
411
- catalog = Scanner().scan(args.uris or None)
654
+ scanner = Scanner()
655
+ catalog = scanner.scan(args.uris or None)
656
+ refused = 0
657
+ if not args.no_descriptors:
658
+ try:
659
+ validator = desc.SchemaValidator()
660
+ except desc.DescriptorRefused as e:
661
+ sys.exit(f"{e.code}: {e.problem}")
662
+ directory = desc.descriptor_dir(args.descriptor_dir, args.system)
663
+ written, bad = desc.write_all(directory, scanner.descriptors(args.uris or None), validator)
664
+ refused = len(bad)
665
+ sys.stderr.write(f"wrote {len(written)} descriptors -> {directory}\n")
412
666
  text = json.dumps(catalog, indent=2, ensure_ascii=False)
413
667
  if args.out:
414
668
  with open(args.out, "w", encoding="utf-8") as f:
@@ -426,6 +680,8 @@ def main():
426
680
  sys.stderr.write(f"wrote {len(uris)} URIs -> {uri_path}\n")
427
681
  else:
428
682
  print(text)
683
+ if refused:
684
+ sys.exit(2)
429
685
 
430
686
 
431
687
  if __name__ == "__main__":