@openmixer/plugin-qualify 0.18.0 → 0.18.2

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 (73) hide show
  1. package/README.md +93 -3
  2. package/bin/plugin-qualify-sync.mjs +3 -1
  3. package/dist/clap-qualify.d.ts +3 -1
  4. package/dist/clap-qualify.js +3 -1
  5. package/dist/clap-qualify.js.map +1 -1
  6. package/dist/distributed-records.d.ts +4 -2
  7. package/dist/distributed-records.js +1 -1
  8. package/dist/distributed-records.js.map +1 -1
  9. package/dist/host-admission.d.ts +21 -0
  10. package/dist/host-admission.js +33 -3
  11. package/dist/host-admission.js.map +1 -1
  12. package/dist/hostd-pin-files.js +3 -1
  13. package/dist/hostd-pin-files.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/measurement/format.d.ts +10 -0
  18. package/dist/measurement/format.js +8 -0
  19. package/dist/measurement/format.js.map +1 -1
  20. package/dist/measurement/probe-json.js +2 -1
  21. package/dist/measurement/probe-json.js.map +1 -1
  22. package/dist/probe-judge.d.ts +11 -0
  23. package/dist/probe-judge.js +6 -3
  24. package/dist/probe-judge.js.map +1 -1
  25. package/dist/probe-runner.js +3 -2
  26. package/dist/probe-runner.js.map +1 -1
  27. package/dist/qualification-record.d.ts +113 -8
  28. package/dist/qualification-record.js +18 -13
  29. package/dist/qualification-record.js.map +1 -1
  30. package/dist/qualify-adapter-pass.d.ts +67 -0
  31. package/dist/qualify-adapter-pass.js +255 -0
  32. package/dist/qualify-adapter-pass.js.map +1 -0
  33. package/dist/qualify-declarations.d.ts +30 -0
  34. package/dist/qualify-declarations.js +24 -1
  35. package/dist/qualify-declarations.js.map +1 -1
  36. package/dist/qualify-isolated.d.ts +70 -0
  37. package/dist/qualify-isolated.js +184 -0
  38. package/dist/qualify-isolated.js.map +1 -0
  39. package/dist/qualify-rejudge.d.ts +82 -0
  40. package/dist/qualify-rejudge.js +434 -0
  41. package/dist/qualify-rejudge.js.map +1 -0
  42. package/dist/qualify-sync.d.ts +97 -1
  43. package/dist/qualify-sync.js +276 -24
  44. package/dist/qualify-sync.js.map +1 -1
  45. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.probe.json +46 -0
  46. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.qualification.json +185 -0
  47. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.probe.json +51 -0
  48. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.qualification.json +232 -0
  49. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.probe.json +51 -0
  50. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.qualification.json +245 -0
  51. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  52. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.qualification.json +145 -0
  53. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  54. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.qualification.json +155 -0
  55. package/package.json +1 -1
  56. package/src/clap-qualify.ts +5 -1
  57. package/src/distributed-records.ts +1 -1
  58. package/src/host-admission.ts +40 -3
  59. package/src/hostd-pin-files.ts +3 -1
  60. package/src/index.ts +2 -0
  61. package/src/measurement/format.ts +17 -0
  62. package/src/measurement/probe-json.ts +2 -1
  63. package/src/probe-judge.ts +10 -3
  64. package/src/probe-runner.ts +2 -2
  65. package/src/qualification-record.ts +126 -15
  66. package/src/qualify-adapter-pass.ts +278 -0
  67. package/src/qualify-declarations.ts +26 -1
  68. package/src/qualify-isolated.ts +201 -0
  69. package/src/qualify-rejudge.ts +466 -0
  70. package/src/qualify-sync.ts +295 -24
  71. package/tools/isolated-step.mjs +122 -0
  72. package/tools/lv2_resolve.py +49 -0
  73. package/tools/mod-host-admit.mjs +99 -7
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ // One isolated step (plugin-qualify#249): a plugin-hostd of its own on the JACK server the environment names, the plugin
5
+ // added alone, worker_list read, a sine through it for a window, and worker_list read again. Prints one JSON line for
6
+ // src/qualify-isolated.ts to judge. A host that never listened says started: false: nothing was learned about the plugin.
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
9
+ import { createConnection } from "node:net";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ const [uri, windowMsWord] = process.argv.slice(2);
14
+ const windowMs = Number(windowMsWord);
15
+ const signalTool = process.env.OMX_ISOLATED_SIGNAL;
16
+ const hostd = process.env.PLUGIN_HOSTD ?? "plugin-hostd";
17
+ const port = 40000 + (process.pid % 20000);
18
+ const CONNECT_TRIES = 50;
19
+ // the sync gives each command its budget: a plugin that hangs in add ends there
20
+ const COMMAND_MS = Number(process.env.OMX_ADMIT_STEP_MS ?? 0) || 30000;
21
+ const INSTANCE = 0;
22
+ const CLOCK_TICKS = 100;
23
+ const WORKER_SPAWN = -503;
24
+
25
+ const state = mkdtempSync(join(tmpdir(), "plugin-qualify-isolated-"));
26
+ const host = spawn(hostd, ["-n", "-p", String(port)], { stdio: "ignore", detached: true, env: { ...process.env, XDG_STATE_HOME: state } });
27
+ let ended = false;
28
+ host.on("exit", () => (ended = true));
29
+ host.on("error", () => (ended = true));
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+
32
+ async function connect() {
33
+ for (let i = 0; i < CONNECT_TRIES && !ended; i++) {
34
+ const s = await new Promise((resolve) => {
35
+ const c = createConnection({ port, host: "127.0.0.1" }, () => resolve(c));
36
+ c.on("error", () => resolve(undefined));
37
+ });
38
+ if (s !== undefined) return s;
39
+ await sleep(100);
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ function commander(sock) {
45
+ let buffer = "";
46
+ let closed = false;
47
+ sock.on("data", (d) => (buffer += d.toString()));
48
+ sock.on("close", () => (closed = true));
49
+ sock.on("error", () => (closed = true));
50
+ return async (line) => {
51
+ buffer = "";
52
+ sock.write(`${line}\0`);
53
+ const until = Date.now() + COMMAND_MS;
54
+ while (Date.now() < until && !closed) {
55
+ const m = /resp (-?\d+)([^\0]*)\0?/.exec(buffer);
56
+ if (m !== null && buffer.includes("\0")) return { code: Number(m[1]), rest: m[2].trim() };
57
+ await sleep(20);
58
+ }
59
+ return undefined;
60
+ };
61
+ }
62
+
63
+ /** `<worker>:<pid>:<format>:<state>:<core>:<pool>:<place>:<i>,<i>,...` per worker. */
64
+ const workersOf = (answer) =>
65
+ (answer?.rest ?? "").split(/\s+/).filter(Boolean).map((w) => {
66
+ const f = w.split(":");
67
+ return { pid: Number(f[1]), instances: (f[7] ?? "").split(",").filter(Boolean) };
68
+ });
69
+
70
+ function cpuTicks(pid) {
71
+ try {
72
+ const after = readFileSync(`/proc/${String(pid)}/stat`, "utf8").replace(/^.*\) /, "").split(" ");
73
+ return Number(after[11]) + Number(after[12]);
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ async function main() {
80
+ const sock = await connect();
81
+ if (sock === undefined) return { started: false, problem: `${hostd} did not listen` };
82
+ const send = commander(sock);
83
+ const added = await send(`add ${uri} ${String(INSTANCE)}`);
84
+ // WORKER_SPAWN: no worker came up to hold the plugin, which says nothing of the plugin. The next pass tries it again.
85
+ if (added?.code === WORKER_SPAWN) return { started: false, problem: `no worker came up: add answered ${String(WORKER_SPAWN)}` };
86
+ if (added === undefined || added.code < 0) return { started: true, added: false, problem: added === undefined ? "the host gave no answer to add" : `add answered ${String(added.code)}` };
87
+ const before = workersOf(await send("worker_list"));
88
+ const worker = before[0];
89
+ const ticksBefore = worker === undefined ? undefined : cpuTicks(worker.pid);
90
+ const signal = spawnSync(signalTool, [`effect_${String(INSTANCE)}`, String(windowMs)], { encoding: "utf8", timeout: windowMs + 15000, killSignal: "SIGKILL" });
91
+ const ticksAfter = worker === undefined ? undefined : cpuTicks(worker.pid);
92
+ const after = workersOf(await send("worker_list"));
93
+ sock.destroy();
94
+ const alive = after.length > 0 && after[0].pid === worker?.pid && ticksAfter !== undefined;
95
+ if (signal.error !== undefined || signal.status !== 0) {
96
+ if (signal.status === 2 && alive) return { started: false, problem: `isolated_signal: ${signal.stderr.trim()}` };
97
+ return { started: true, added: true, alive, workers: before.length, instancesInWorker: worker?.instances.length ?? 0, problem: signal.stderr?.trim() };
98
+ }
99
+ const heard = JSON.parse(signal.stdout);
100
+ return {
101
+ started: true,
102
+ added: true,
103
+ alive,
104
+ workers: before.length,
105
+ instancesInWorker: worker?.instances.length ?? 0,
106
+ latencyFrames: heard.latencyFrames,
107
+ xruns: heard.xruns,
108
+ finite: heard.finite,
109
+ silent: heard.silent,
110
+ ...(ticksBefore === undefined || ticksAfter === undefined ? {} : { cpuPercent: ((ticksAfter - ticksBefore) * 100000) / CLOCK_TICKS / windowMs }),
111
+ };
112
+ }
113
+
114
+ const result = await main();
115
+ try {
116
+ process.kill(-host.pid, "SIGKILL");
117
+ } catch {
118
+ // the host went on its own
119
+ }
120
+ rmSync(state, { recursive: true, force: true });
121
+ process.stdout.write(`${JSON.stringify(result)}\n`);
122
+ process.exit(0);
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ """Which bundle lilv takes for each LV2 plugin on the search path, and its binary (plugin-qualify#199).
5
+
6
+ lilv loads one world over the whole LV2_PATH; where two bundles describe one plugin URI, its version rule decides
7
+ (the higher lv2:minorVersion, then lv2:microVersion, replaces the other). An LV2 complement writes a replacing bundle
8
+ with the microVersion one above, so lilv, and every host built on it, loads that one. The sync keys an LV2 plugin by
9
+ the bundle lilv takes, never by the first one a directory listing finds.
10
+
11
+ LV2_PATH=<dir>:<dir>... lv2_resolve.py
12
+
13
+ prints one JSON object: {"plugins": [{"uri", "bundle", "binary"}, ...]}, `bundle` a directory, `binary` the absolute
14
+ path of the library lilv would load (it may lie outside the bundle).
15
+ """
16
+ import json
17
+ import sys
18
+ from urllib.parse import unquote, urlparse
19
+
20
+
21
+ def path_of(uri):
22
+ """A file: URI as a path, its trailing slash dropped."""
23
+ return unquote(urlparse(uri).path).rstrip("/")
24
+
25
+
26
+ def main():
27
+ try:
28
+ import lilv
29
+ except ImportError as e:
30
+ print(f"lv2_resolve: lilv's Python bindings are not installed: {e}", file=sys.stderr)
31
+ return 2
32
+ world = lilv.World()
33
+ world.load_all()
34
+ out = []
35
+ for plugin in world.get_all_plugins():
36
+ library = plugin.get_library_uri()
37
+ out.append({
38
+ "uri": str(plugin.get_uri()),
39
+ "bundle": path_of(str(plugin.get_bundle_uri())),
40
+ "binary": path_of(str(library)) if library is not None else None,
41
+ })
42
+ out.sort(key=lambda p: p["uri"])
43
+ json.dump({"plugins": out}, sys.stdout)
44
+ sys.stdout.write("\n")
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ sys.exit(main())
@@ -4,23 +4,41 @@
4
4
  // One mod-host admission: a fresh `mod-host -n` on its own port, then `add` and `remove` of one plugin on its
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
- import { spawn } from "node:child_process";
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import { existsSync } from "node:fs";
8
9
  import { createConnection } from "node:net";
10
+ import { fileURLToPath } from "node:url";
9
11
 
10
12
  const uri = process.argv[2];
11
13
  // the host to admit under: mod-host, or omx-clap-host for plugin-hostd's LV2 adapter, which answers the same protocol
12
14
  const bin = process.argv[3] ?? "mod-host";
13
15
  const port = 20000 + (process.pid % 20000);
14
- const STEP_MS = 10000;
16
+ // an adapter-only pass gives the host its budget for a command, an address space and a state directory of the plugin's own
17
+ const STEP_MS = Number(process.env.OMX_ADMIT_STEP_MS ?? 0) || 10000;
18
+ const ADDRESS_KB = process.env.OMX_ADMIT_ADDRESS_KB;
19
+ const STATE_DIR = process.env.OMX_ADMIT_STATE_DIR;
15
20
  const INSTANCE = 0;
21
+ // an instrument under the adapter is played: one note, held this long, its output read at its JACK ports
22
+ const NOTE_WINDOW_MS = 1000;
23
+ const NOTE_ON = `note_on ${INSTANCE} 0 60 100`;
24
+ const ADAPTER = "omx-clap-host";
16
25
  const COMMANDS = [
17
26
  ["add", `add ${uri} ${INSTANCE}`],
18
27
  ["remove", `remove ${INSTANCE}`],
19
28
  ];
20
29
 
21
- const host = spawn(bin, ["-n", "-p", String(port)], { stdio: "ignore" });
30
+ const args = ["-n", "-p", String(port), ...(STATE_DIR === undefined ? [] : ["-s", STATE_DIR])];
31
+ // in a process group of its own, so that the end below takes whatever a plugin started as well
32
+ const host =
33
+ ADDRESS_KB === undefined
34
+ ? spawn(bin, args, { stdio: "ignore", detached: true })
35
+ : spawn("/bin/sh", ["-c", `command -v "$1" >/dev/null || exit 127; ulimit -v ${ADDRESS_KB} && exec "$@"`, "sh", bin, ...args], { stdio: "ignore", detached: true });
22
36
  let hostEnd; // how the host went, once it has
23
- host.on("exit", (code, signal) => (hostEnd = signal ?? "exit"));
37
+ let shellNotFound = false;
38
+ host.on("exit", (code, signal) => {
39
+ hostEnd = signal ?? "exit";
40
+ if (code === 127) shellNotFound = true;
41
+ });
24
42
  const spawnFailed = new Promise((resolve) => host.on("error", (e) => resolve(e.code)));
25
43
 
26
44
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -37,8 +55,54 @@ async function connect() {
37
55
  return undefined;
38
56
  }
39
57
 
58
+ // the LV2 class says it is an instrument; the note port and the audio input say nothing, a synth can have both, an effect
59
+ // can have both. omx-clap-scan comes with omx-clap-host: without an answer from it this is not known to be an instrument
60
+ function isInstrument() {
61
+ const r = spawnSync("omx-clap-scan", ["--fields", `lv2:${uri}`], { encoding: "utf8", timeout: STEP_MS });
62
+ return r.status === 0 && /^plugin\trdf:type\tlv2:InstrumentPlugin$/m.test(r.stdout);
63
+ }
64
+
65
+ // the JACK output ports of the instance, from audio_ports's reply: resp 0 [dry] in:... out:main:1:2:stereo:"out":out_1,out_2
66
+ function outputPorts(reply) {
67
+ return [...reply.matchAll(/ out:[^ "]*"[^"]*":([^ ]+)/g)].flatMap((m) => m[1].split(",")).map((p) => `effect_${INSTANCE}:${p}`);
68
+ }
69
+
70
+ const PEAK_TOOL_DIRS = [fileURLToPath(new URL("../native/build/", import.meta.url)), "/usr/libexec/plugin-qualify/", "/usr/lib/plugin-qualify/"];
71
+ const peakTool = () => process.env.OMX_JACK_PEAK || PEAK_TOOL_DIRS.map((d) => `${d}jack_peak`).find((p) => existsSync(p));
72
+
73
+ // the peak on `ports` while `during` runs: { peak } in linear magnitude, or { problem } with why
74
+ async function peakDuring(ports, during) {
75
+ const tool = peakTool();
76
+ if (tool === undefined) return { problem: "no jack_peak" };
77
+ const meter = spawn(tool, ports, { stdio: ["pipe", "pipe", "pipe"] });
78
+ let out = "";
79
+ let err = "";
80
+ const ended = new Promise((resolve) => {
81
+ meter.on("error", (e) => resolve(e.code ?? "error"));
82
+ meter.on("exit", (code) => resolve(code));
83
+ });
84
+ meter.stdout.on("data", (d) => (out += d.toString()));
85
+ meter.stderr.on("data", (d) => (err += d.toString()));
86
+ const until = Date.now() + STEP_MS;
87
+ while (!out.includes("ready") && Date.now() < until && meter.exitCode === null) await sleep(10);
88
+ if (!out.includes("ready")) {
89
+ meter.kill("SIGKILL");
90
+ await ended;
91
+ return { problem: err.trim() || "jack_peak did not start" };
92
+ }
93
+ await during();
94
+ meter.stdin.end();
95
+ await Promise.race([ended, sleep(STEP_MS)]);
96
+ meter.kill("SIGKILL");
97
+ try {
98
+ return { peak: JSON.parse(out.slice(out.indexOf("ready") + 5)).peak };
99
+ } catch {
100
+ return { problem: err.trim() || "jack_peak gave no figure" };
101
+ }
102
+ }
103
+
40
104
  async function main() {
41
- if ((await Promise.race([spawnFailed, sleep(200).then(() => undefined)])) === "ENOENT") return { installed: false, steps: [] };
105
+ if ((await Promise.race([spawnFailed, sleep(200).then(() => undefined)])) === "ENOENT" || shellNotFound) return { installed: false, steps: [] };
42
106
  const sock = await connect();
43
107
  // the host never listened (omx-clap-host with no JACK server): the plugin was never loaded
44
108
  if (sock === undefined) return { installed: true, started: false, steps: [] };
@@ -48,7 +112,31 @@ async function main() {
48
112
  sock.on("close", () => (closed = true));
49
113
  sock.on("error", () => (closed = true));
50
114
  const steps = [];
115
+ let sound;
116
+ // one line on the socket and its whole answer: undefined when none came in time
117
+ const ask = async (line) => {
118
+ buffer = "";
119
+ sock.write(`${line}\0`);
120
+ const until = Date.now() + STEP_MS;
121
+ while (Date.now() < until && !closed && hostEnd === undefined) {
122
+ const m = /resp -?\d+[^\0]*/.exec(buffer);
123
+ if (m !== null && buffer.includes("\0")) return m[0];
124
+ await sleep(20);
125
+ }
126
+ return undefined;
127
+ };
51
128
  for (const [cmd, line] of COMMANDS) {
129
+ // an instrument the adapter took: does a held note sound? A plugin that dies on the note dies at the remove below
130
+ if (cmd === "remove" && bin === ADAPTER && isInstrument()) {
131
+ const ports = outputPorts((await ask(`audio_ports ${INSTANCE}`)) ?? "");
132
+ sound =
133
+ ports.length === 0
134
+ ? { problem: "no output port" }
135
+ : await peakDuring(ports, async () => {
136
+ await ask(NOTE_ON);
137
+ await sleep(NOTE_WINDOW_MS);
138
+ });
139
+ }
52
140
  buffer = "";
53
141
  sock.write(`${line}\0`);
54
142
  const until = Date.now() + STEP_MS;
@@ -68,10 +156,14 @@ async function main() {
68
156
  if (resp < 0) break;
69
157
  }
70
158
  sock.destroy();
71
- return { installed: true, steps };
159
+ return { installed: true, steps, ...(sound === undefined ? {} : { sound }) };
72
160
  }
73
161
 
74
162
  const result = await main();
75
- host.kill("SIGKILL");
163
+ try {
164
+ process.kill(-host.pid, "SIGKILL");
165
+ } catch {
166
+ // the host went on its own
167
+ }
76
168
  process.stdout.write(`${JSON.stringify(result)}\n`);
77
169
  process.exit(0);