@openmixer/plugin-qualify 0.20.1 → 0.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/bin/plugin-qualify-sync.mjs +1 -0
  2. package/catalogs/ca.json +8 -0
  3. package/dist/clap-loader.js +2 -1
  4. package/dist/clap-loader.js.map +1 -1
  5. package/dist/clap-qualify.d.ts +11 -0
  6. package/dist/clap-qualify.js +16 -0
  7. package/dist/clap-qualify.js.map +1 -1
  8. package/dist/host-admission.d.ts +25 -0
  9. package/dist/host-admission.js +41 -5
  10. package/dist/host-admission.js.map +1 -1
  11. package/dist/hostd-search-path.d.ts +38 -0
  12. package/dist/hostd-search-path.js +58 -0
  13. package/dist/hostd-search-path.js.map +1 -0
  14. package/dist/jack-registry.js +1 -1
  15. package/dist/lv2-loader.d.ts +33 -1
  16. package/dist/lv2-loader.js +82 -19
  17. package/dist/lv2-loader.js.map +1 -1
  18. package/dist/measurement/vocabulary.js +3 -3
  19. package/dist/measurement/vocabulary.js.map +1 -1
  20. package/dist/measurement-grid.d.ts +1 -1
  21. package/dist/measurement-grid.js +5 -3
  22. package/dist/measurement-grid.js.map +1 -1
  23. package/dist/pins-file.d.ts +3 -2
  24. package/dist/pins-file.js +3 -2
  25. package/dist/pins-file.js.map +1 -1
  26. package/dist/probe-judge.js +5 -0
  27. package/dist/probe-judge.js.map +1 -1
  28. package/dist/probe-output.d.ts +10 -0
  29. package/dist/probe-output.js +16 -0
  30. package/dist/probe-output.js.map +1 -1
  31. package/dist/probe-runner.js +24 -5
  32. package/dist/probe-runner.js.map +1 -1
  33. package/dist/qualification-record.d.ts +5 -0
  34. package/dist/qualification-record.js +13 -1
  35. package/dist/qualification-record.js.map +1 -1
  36. package/dist/qualify-adapter-pass.d.ts +1 -2
  37. package/dist/qualify-adapter-pass.js +6 -6
  38. package/dist/qualify-adapter-pass.js.map +1 -1
  39. package/dist/qualify-declarations.d.ts +23 -6
  40. package/dist/qualify-declarations.js +25 -6
  41. package/dist/qualify-declarations.js.map +1 -1
  42. package/dist/qualify-isolated.d.ts +9 -0
  43. package/dist/qualify-isolated.js +16 -5
  44. package/dist/qualify-isolated.js.map +1 -1
  45. package/dist/qualify-pool.js +4 -3
  46. package/dist/qualify-pool.js.map +1 -1
  47. package/dist/qualify-rejudge.js +3 -3
  48. package/dist/qualify-rejudge.js.map +1 -1
  49. package/dist/qualify-sync.d.ts +12 -5
  50. package/dist/qualify-sync.js +48 -28
  51. package/dist/qualify-sync.js.map +1 -1
  52. package/dist/run-home.d.ts +4 -0
  53. package/dist/run-home.js +26 -0
  54. package/dist/run-home.js.map +1 -0
  55. package/package.json +6 -4
  56. package/schema/codes.json +15 -0
  57. package/src/clap-loader.ts +2 -1
  58. package/src/clap-qualify.ts +25 -0
  59. package/src/host-admission.ts +52 -5
  60. package/src/hostd-search-path.ts +85 -0
  61. package/src/jack-registry.ts +1 -1
  62. package/src/lv2-loader.ts +89 -17
  63. package/src/measurement/vocabulary.ts +3 -3
  64. package/src/measurement-grid.ts +5 -3
  65. package/src/pins-file.ts +3 -2
  66. package/src/probe-judge.ts +5 -0
  67. package/src/probe-output.ts +23 -0
  68. package/src/probe-runner.ts +25 -4
  69. package/src/qualification-record.ts +13 -1
  70. package/src/qualify-adapter-pass.ts +6 -5
  71. package/src/qualify-declarations.ts +27 -6
  72. package/src/qualify-isolated.ts +18 -5
  73. package/src/qualify-pool.ts +4 -3
  74. package/src/qualify-rejudge.ts +3 -3
  75. package/src/qualify-sync.ts +52 -31
  76. package/src/run-home.ts +28 -0
  77. package/tools/_declarations.py +2 -1
  78. package/tools/descriptors.py +40 -3
  79. package/tools/gen-python-declarations.mjs +1 -0
  80. package/tools/isolated-step.mjs +24 -3
  81. package/tools/mod-host-admit.mjs +57 -1
  82. package/tools/scan.py +29 -5
@@ -5,10 +5,14 @@
5
5
  // socket. Prints one JSON line, {installed, steps:[{cmd, resp | died}]}, for src/host-admission.ts to classify. The host
6
6
  // is always killed at the end, so the next plugin gets a fresh one.
7
7
  import { spawn, spawnSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { createConnection } from "node:net";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
10
12
  import { fileURLToPath } from "node:url";
11
13
 
14
+ import { lv2ContentParams } from "../dist/lv2-loader.js";
15
+
12
16
  const uri = process.argv[2];
13
17
  // the host to admit under: mod-host, or omx-clap-host for plugin-hostd's LV2 adapter, which answers the same protocol
14
18
  const bin = process.argv[3] ?? "mod-host";
@@ -70,6 +74,38 @@ function outputPorts(reply) {
70
74
  return [...reply.matchAll(/ out:[^ "]*"[^"]*":([^ ]+)/g)].flatMap((m) => m[1].split(",")).map((p) => `effect_${INSTANCE}:${p}`);
71
75
  }
72
76
 
77
+ // a sampler or a drum machine with nothing loaded is silent by design (#318): the content it declares (a patch:writable
78
+ // parameter of range atom:Path) is given a short sample of its own before the notes, so the note test hears the plugin
79
+ const CONTENT_RATE = 48000;
80
+ const CONTENT_SECONDS = 0.5;
81
+ const CONTENT_HZ = 220;
82
+ const CONTENT_LOAD_MS = 500;
83
+
84
+ // a mono 16-bit WAV of a decaying sine: the sample a content parameter is given
85
+ function writeContentSample(dir) {
86
+ const frames = Math.round(CONTENT_RATE * CONTENT_SECONDS);
87
+ const wav = Buffer.alloc(44 + frames * 2);
88
+ wav.write("RIFF", 0);
89
+ wav.writeUInt32LE(36 + frames * 2, 4);
90
+ wav.write("WAVEfmt ", 8);
91
+ wav.writeUInt32LE(16, 16);
92
+ wav.writeUInt16LE(1, 20);
93
+ wav.writeUInt16LE(1, 22);
94
+ wav.writeUInt32LE(CONTENT_RATE, 24);
95
+ wav.writeUInt32LE(CONTENT_RATE * 2, 28);
96
+ wav.writeUInt16LE(2, 32);
97
+ wav.writeUInt16LE(16, 34);
98
+ wav.write("data", 36);
99
+ wav.writeUInt32LE(frames * 2, 40);
100
+ for (let i = 0; i < frames; i++) {
101
+ const v = Math.sin((2 * Math.PI * CONTENT_HZ * i) / CONTENT_RATE) * Math.exp((-3 * i) / frames) * 0.5;
102
+ wav.writeInt16LE(Math.round(v * 32767), 44 + i * 2);
103
+ }
104
+ const path = join(dir, "plugin-qualify-content.wav");
105
+ writeFileSync(path, wav);
106
+ return path;
107
+ }
108
+
73
109
  const PEAK_TOOL_DIRS = [fileURLToPath(new URL("../native/build/", import.meta.url)), "/usr/libexec/plugin-qualify/", "/usr/lib/plugin-qualify/"];
74
110
  const peakTool = () => process.env.OMX_JACK_PEAK || PEAK_TOOL_DIRS.map((d) => `${d}jack_peak`).find((p) => existsSync(p));
75
111
 
@@ -132,6 +168,23 @@ async function main() {
132
168
  // an instrument the adapter took: does a held note sound? A plugin that dies on the note dies at the remove below
133
169
  if (cmd === "remove" && bin === ADAPTER && isInstrument()) {
134
170
  const ports = outputPorts((await ask(`audio_ports ${INSTANCE}`)) ?? "");
171
+ let content;
172
+ const params = lv2ContentParams(uri, process.env);
173
+ if (params.length > 0) {
174
+ const dir = mkdtempSync(join(tmpdir(), "plugin-qualify-content-"));
175
+ const file = writeContentSample(dir);
176
+ const loaded = [];
177
+ const refused = [];
178
+ for (const param of params) {
179
+ const reply = await ask(`patch_set ${INSTANCE} ${param} "${file}"`);
180
+ const resp = reply === undefined ? undefined : Number(/resp (-?\d+)/.exec(reply)?.[1]);
181
+ if (resp === 0) loaded.push(param);
182
+ else refused.push(resp === undefined ? `${param} (no answer)` : `${param} (resp ${String(resp)})`);
183
+ }
184
+ if (loaded.length > 0) await sleep(CONTENT_LOAD_MS);
185
+ content = { params, loaded, ...(refused.length === 0 ? {} : { refused }) };
186
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
187
+ }
135
188
  sound =
136
189
  ports.length === 0
137
190
  ? { problem: "no output port" }
@@ -141,6 +194,7 @@ async function main() {
141
194
  await sleep(NOTE_WINDOW_MS);
142
195
  });
143
196
  if (sound.peak !== undefined) sound.notes = NOTE_COUNT;
197
+ if (content !== undefined) sound.content = content;
144
198
  }
145
199
  buffer = "";
146
200
  sock.write(`${line}\0`);
@@ -164,7 +218,9 @@ async function main() {
164
218
  return { installed: true, steps, ...(sound === undefined ? {} : { sound }) };
165
219
  }
166
220
 
221
+ const cleanups = [];
167
222
  const result = await main();
223
+ for (const c of cleanups) c();
168
224
  try {
169
225
  process.kill(-host.pid, "SIGKILL");
170
226
  } catch {
package/tools/scan.py CHANGED
@@ -30,6 +30,7 @@ import json
30
30
  import os
31
31
  import subprocess
32
32
  import sys
33
+ from urllib.parse import unquote, urlparse
33
34
 
34
35
  import descriptors as desc
35
36
  from lv2_presets import read_presets
@@ -142,8 +143,9 @@ def bundle_path(plugin):
142
143
  if uri is None:
143
144
  return None
144
145
  p = str(uri)
145
- if p.startswith("file://"):
146
- p = p[len("file://"):]
146
+ if p.startswith("file:"):
147
+ # a file: URI's path is percent-decoded, as lilv and serd read it (`My%20Plugins` is `My Plugins` on the disk)
148
+ p = unquote(urlparse(p).path)
147
149
  return p.rstrip("/") or None
148
150
 
149
151
 
@@ -169,6 +171,8 @@ def owning_rpm(path, run=subprocess.run):
169
171
 
170
172
 
171
173
  class Scanner:
174
+ refusals = () # (uri, code, problem, args) of each plugin the last `descriptors` refused
175
+
172
176
  def __init__(self):
173
177
  # lilv is imported here, not at module load: the owner mapping and the pure helpers
174
178
  # (test_scan.py) run on a host without the bindings, the way benchmark.py's worker does.
@@ -338,6 +342,10 @@ class Scanner:
338
342
  # a DECLARED fact like the comment above, read off the bundle and judged against the params
339
343
  # just read. Always present on a scanned plugin — `[]` is "ships none", never "not read".
340
344
  out["presets"] = read_presets(self.w, plugin, params)
345
+ # a plugin the scan refuses to describe stays listed, with its code, args and sentence (plugin-qualify#27)
346
+ refusal = self.port_refusal(plugin)
347
+ if refusal is not None:
348
+ out["refused"] = {"code": refusal.code, "args": refusal.code_args, "text": refusal.problem}
341
349
  # Record the latency port symbol if the plugin reports one. The actual frame
342
350
  # value is filled later by the on-rig benchmark (benchmark.py); a plugin with
343
351
  # no latency port carries no `latency` key → host treats it as unknown, not 0.
@@ -418,6 +426,10 @@ class Scanner:
418
426
  if group:
419
427
  c["group"] = group
420
428
  meaning = self.designation_meaning(port)
429
+ # a toggle named bypass with no designation of its own is the plugin's bypass (EQ10Q's eq1qm..eq10qm): a
430
+ # panel leaves it to the header, which draws one bypass per panel (plugin-qualify#27)
431
+ if c["kind"] == "toggle" and sym.lower() == "bypass" and meaning in ("unknown", None):
432
+ meaning = "bypass"
421
433
  if meaning is not None:
422
434
  c["meaning"] = meaning
423
435
  if self.has(port, PP + "notOnGUI"):
@@ -427,9 +439,18 @@ class Scanner:
427
439
  c["source"] = {"from": f"{link}#{desc.link_symbol(sym)}"}
428
440
  return c
429
441
 
442
+ def port_refusal(self, plugin):
443
+ """The refusal a plugin's ports earn before any descriptor is drawn, else None: two ports with one symbol."""
444
+ dup = desc.duplicate_symbol(str(plugin.get_port_by_index(i).get_symbol()) for i in range(plugin.get_num_ports()))
445
+ return desc.refused(desc.DESCRIPTOR_CODE_DUPLICATE_SYMBOL, symbol=dup) if dup is not None else None
446
+
430
447
  def descriptor(self, plugin):
431
- """The plugin as a scanned descriptor, and the SHA-256 of its binary: `(doc, sha256)`."""
448
+ """The plugin as a scanned descriptor, and the SHA-256 of its binary: `(doc, sha256)`. A plugin whose ports
449
+ cannot be addressed raises its refusal (`port_refusal`): it is not repaired."""
432
450
  uri = str(plugin.get_uri())
451
+ refusal = self.port_refusal(plugin)
452
+ if refusal is not None:
453
+ raise refusal
433
454
  bundle = bundle_path(plugin) or ""
434
455
  lib = plugin.get_library_uri()
435
456
  lib_path = lib.get_path() if lib is not None else None
@@ -524,16 +545,19 @@ class Scanner:
524
545
  return d, sha256
525
546
 
526
547
  def descriptors(self, uris=None):
527
- """`(doc, sha256)` for every plugin `scan` would describe."""
548
+ """`(doc, sha256)` for every plugin `scan` would describe. A plugin refused here is kept in `self.refusals`,
549
+ `(uri, code, problem, args)`, and reported, never silently dropped (its catalog row says why, `describe`)."""
528
550
  plugins = self.w.get_all_plugins()
529
551
  chosen = [plugins.get_by_uri(self.w.new_uri(u)) for u in uris] if uris else list(plugins)
530
552
  out = []
553
+ self.refusals = []
531
554
  for p in chosen:
532
555
  if p is None:
533
556
  continue
534
557
  try:
535
558
  out.append(self.descriptor(p))
536
559
  except desc.DescriptorRefused as e:
560
+ self.refusals.append((str(p.get_uri()), e.code, e.problem, e.code_args))
537
561
  sys.stderr.write(f"descriptor refused: {p.get_uri()}: {e.code}: {e.problem}\n")
538
562
  except Exception as e: # noqa: BLE001 - one plugin's bad data never stops the scan
539
563
  line = e.__traceback__.tb_next.tb_lineno if e.__traceback__ and e.__traceback__.tb_next else "?"
@@ -657,7 +681,7 @@ def main():
657
681
  sys.exit(f"{e.code}: {e.problem}")
658
682
  directory = desc.descriptor_dir(args.descriptor_dir, args.system)
659
683
  written, bad = desc.write_all(directory, scanner.descriptors(args.uris or None), validator)
660
- refused = len(bad)
684
+ refused = len(bad) + len(scanner.refusals)
661
685
  sys.stderr.write(f"wrote {len(written)} descriptors -> {directory}\n")
662
686
  text = json.dumps(catalog, indent=2, ensure_ascii=False)
663
687
  if args.out: