@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/README.md
CHANGED
|
@@ -1,3 +1,204 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plugin-qualify
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Can this audio plugin be hosted in a real-time audio engine, and at what cost? `plugin-qualify`
|
|
4
|
+
scans a plugin, measures it offline (latency and CPU cost per rate and quantum), runs it
|
|
5
|
+
under a real host (mod-host, with an AddressSanitizer build), and gives one verdict.
|
|
6
|
+
Licence: GPL-3.0-or-later (`LICENSE`).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
plugin-qualify is published, signed, in the FreeMixer package channel, for Fedora 44 (x86_64, aarch64)
|
|
11
|
+
and for Debian bookworm and trixie, including Raspberry Pi OS (amd64, arm64).
|
|
12
|
+
|
|
13
|
+
Fedora:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
sudo dnf config-manager addrepo --from-repofile=https://freemixer.github.io/rpm/freemixer.repo
|
|
17
|
+
sudo dnf install plugin-qualify
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Debian and Raspberry Pi OS:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
sudo install -d /etc/apt/keyrings
|
|
24
|
+
sudo curl -fsSL -o /etc/apt/keyrings/freemixer.asc https://freemixer.github.io/deb/freemixer.asc
|
|
25
|
+
echo "deb [signed-by=/etc/apt/keyrings/freemixer.asc] https://freemixer.github.io/deb/debian/$(. /etc/os-release && echo $VERSION_CODENAME) ./" | sudo tee /etc/apt/sources.list.d/freemixer.list
|
|
26
|
+
sudo apt update
|
|
27
|
+
sudo apt install plugin-qualify
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
There is no npm package on the registry yet; the GitHub release carries the packed one,
|
|
31
|
+
`openmixer-plugin-qualify-<version>.tgz`, which `pnpm add` takes by its download URL. To run from a
|
|
32
|
+
checkout instead (Node.js 20 or newer and pnpm 10, see `BUILDING.md`):
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
git clone https://github.com/FreeMixer/plugin-qualify.git
|
|
36
|
+
cd plugin-qualify
|
|
37
|
+
pnpm install
|
|
38
|
+
pnpm exec plugin-qualify --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It needs Node.js 18 or newer to run. The measuring tools also want Python 3 with the lilv bindings
|
|
42
|
+
(`python3-lilv`) and, for the hosting sweep, `mod-host`. The `Containerfile` builds an image with all of
|
|
43
|
+
them. The commands below are written as `plugin-qualify`; from a checkout run them with `pnpm exec`.
|
|
44
|
+
|
|
45
|
+
## Run it on your own plugin
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
plugin-qualify urn:example:my-plugin # one plugin, by URI
|
|
49
|
+
plugin-qualify ./my-plugin.lv2 # every plugin in a bundle
|
|
50
|
+
plugin-qualify --all # everything lilv finds (LV2_PATH honoured)
|
|
51
|
+
plugin-qualify --help # every option and its default
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`--format json` prints the verdicts as JSON; `--out <dir>` writes the full measurement document.
|
|
55
|
+
`--isolate` measures each plugin in a fresh worker so a crash or hang takes only that plugin;
|
|
56
|
+
`--no-host-sweep` measures offline only, without mod-host. The `Containerfile` builds an image
|
|
57
|
+
with lilv, mod-host and the ASan mod-host, if you do not want to install them yourself.
|
|
58
|
+
|
|
59
|
+
Exit code: `0` every plugin qualified, `1` some did not, `2` something could not be measured.
|
|
60
|
+
|
|
61
|
+
## CLAP plugins: the RT-safety verdict
|
|
62
|
+
|
|
63
|
+
A `.clap` file, or a directory holding one, is scanned by `omx-clap-scan` (from omx-clap-core; set
|
|
64
|
+
`OMX_CLAP_SCAN` to its path when it is not on `PATH`) and each plugin in it is then qualified by the
|
|
65
|
+
CLAP prober built from `native/` (`make -C native deps all`, see `BUILDING.md`):
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
plugin-qualify ./my-fx.clap # every plugin in the file
|
|
69
|
+
plugin-qualify --out ./out ./my-fx.clap # and its RT-safety documents
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The prober runs each plugin headless at every `--rates` rate, under an interposer that counts the
|
|
73
|
+
allocations, locks and syscalls its `process()` makes while every parameter is swept, and prints
|
|
74
|
+
the file's SHA-256 beside what it measured. The verdict is the real-time-safety one: **suitable**
|
|
75
|
+
only on a clean count, **unsuitable** on any allocation, lock, syscall, main-thread host call from
|
|
76
|
+
`process()` or `CLAP_PROCESS_ERROR`, **unknown** when it could not be counted. `--out` writes one
|
|
77
|
+
document per plugin binary and host profile, named `<sha256>.<profile>.rt-safety.json`, so a
|
|
78
|
+
verdict never outlives the bytes it was measured on. `OMX_CLAP_QUALIFY` and `OMX_RT_INTERPOSE`
|
|
79
|
+
name the prober and the interposer when they are not the checkout's own build.
|
|
80
|
+
|
|
81
|
+
The prober also scans: `OMX_CLAP_SCAN=native/build/clap-qualify` makes it the scanner, and its scan
|
|
82
|
+
keeps what a console needs to draw a third-party plugin's controls: an enum parameter's labels (as
|
|
83
|
+
the plugin's `value_to_text` renders them), each parameter's module, and the plugin's
|
|
84
|
+
remote-control pages.
|
|
85
|
+
|
|
86
|
+
## What the verdicts mean
|
|
87
|
+
|
|
88
|
+
- **suitable**: every dimension was measured and passed. Safe to run inside the engine's own
|
|
89
|
+
process. This is the only verdict that counts as qualified.
|
|
90
|
+
- **conditional**: it works, with a caution (for example a high cost at small quanta). It is
|
|
91
|
+
run isolated, and the deciding reason names the caution.
|
|
92
|
+
- **unknown**: something was not measured. An unmeasured plugin is never a pass.
|
|
93
|
+
- **unsuitable**: a measured failure: a crash, a memory error in the plugin's own binary, a
|
|
94
|
+
real-time violation (allocation, locks or syscalls in `run()`), or a required feature the
|
|
95
|
+
host does not provide.
|
|
96
|
+
|
|
97
|
+
The worst dimension decides. The text output prints the plugin, its verdict and the reason that
|
|
98
|
+
decided it.
|
|
99
|
+
|
|
100
|
+
## Reproducing a failure
|
|
101
|
+
|
|
102
|
+
1. Run the plugin alone: `plugin-qualify --format json --out ./out <uri>`.
|
|
103
|
+
2. The document in `./out` names the dimension that failed and, for a crash, the sanitizer class
|
|
104
|
+
and the object of the faulting frame.
|
|
105
|
+
3. Run the ASan mod-host (`mod-host-asan` in the image) with just that plugin to get the full
|
|
106
|
+
sanitizer report.
|
|
107
|
+
|
|
108
|
+
`fixtures/zero-path.lv2` is a known-bad plugin (a zero-size `atom:Path` overread). It should
|
|
109
|
+
rate **unsuitable**; if it does not, the sweep is not catching memory errors.
|
|
110
|
+
|
|
111
|
+
## For other projects (zynthian, MOD, packagers)
|
|
112
|
+
|
|
113
|
+
The measurements describe the plugin and never the host: latency and cost per rate, whether it
|
|
114
|
+
survives a thousand lifecycles and a three-hour soak, whether `run()` allocates, locks or makes
|
|
115
|
+
syscalls, and which LV2 features it requires. They are taken once. The **verdict** is a separate
|
|
116
|
+
step: the same measurements judged against a **host profile**, the facts that differ between
|
|
117
|
+
hosts. Two projects share the measurements and differ only in the profile they choose.
|
|
118
|
+
|
|
119
|
+
### Choosing a host profile
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
plugin-qualify --host-profile jalv ./my-synth.lv2 # zynthian: one jalv process per plugin
|
|
123
|
+
plugin-qualify --host-profile mod-host ./my-fx.lv2 # MOD: one shared mod-host process
|
|
124
|
+
plugin-qualify --host-profile jalv,mod-host,openmixer-console <uri> # one verdict per profile
|
|
125
|
+
plugin-qualify --host-profile ./my-host.json <uri> # your own host
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| profile | isolation | instruments | LV2 features read from | cost budget (P95 of a core, rate / quantum) |
|
|
129
|
+
|---|---|---|---|---|
|
|
130
|
+
| `openmixer-console` (default) | in-process (the console's RT thread) | no | the console's in-process table | 0.05 at 96000 / 1024 |
|
|
131
|
+
| `jalv` | per-process | yes | jalv 1.6.8 `src/jalv.c` (zynthian's fork passes the same list) | 0.25 at 48000 / 256 |
|
|
132
|
+
| `mod-host` | shared-process | yes | mod-host f14a230 `src/effects.c` | 0.05 at 48000 / 128 |
|
|
133
|
+
|
|
134
|
+
The `jalv` and `mod-host` budgets are defaults, not a measurement of your board. State your own
|
|
135
|
+
by writing a profile file. Every field is required; a missing or mistyped field is refused and
|
|
136
|
+
never filled in with a default:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"id": "zynthian-v5",
|
|
141
|
+
"lv2Features": ["http://lv2plug.in/ns/ext/urid#map", "http://lv2plug.in/ns/ext/urid#unmap",
|
|
142
|
+
"http://lv2plug.in/ns/ext/worker#schedule", "http://lv2plug.in/ns/ext/log#log",
|
|
143
|
+
"http://lv2plug.in/ns/ext/options#options",
|
|
144
|
+
"http://lv2plug.in/ns/ext/state#loadDefaultState",
|
|
145
|
+
"http://lv2plug.in/ns/ext/buf-size#powerOf2BlockLength",
|
|
146
|
+
"http://lv2plug.in/ns/ext/buf-size#fixedBlockLength",
|
|
147
|
+
"http://lv2plug.in/ns/ext/buf-size#boundedBlockLength"],
|
|
148
|
+
"clapExtensions": null,
|
|
149
|
+
"instruments": true,
|
|
150
|
+
"cost": { "coreFractionCeiling": 0.3, "rate": 48000, "quantum": 256 },
|
|
151
|
+
"latency": "uncompensated",
|
|
152
|
+
"isolation": "per-process"
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
What each field changes. `lv2Features` is what the host passes to `instantiate`: a plugin that
|
|
157
|
+
requires anything else is **unsuitable** on that host. `clapExtensions` is `null` for a host with
|
|
158
|
+
no CLAP path. `instruments: true` means a MIDI input is fed, and a plugin with no audio input but
|
|
159
|
+
a MIDI input and an audio output rates as an instrument. `cost` is the per-instance ceiling, and
|
|
160
|
+
the rate and quantum the verdict is judged at. `latency: "compensated"` means the host aligns on
|
|
161
|
+
the plugin's latency, so a latency that moves with a control is a caution. `isolation` decides
|
|
162
|
+
whether spawned threads and a linked GUI toolkit count as cautions: they do only when the plugin
|
|
163
|
+
shares its process. An unmeasured dimension is **unknown** on every host.
|
|
164
|
+
|
|
165
|
+
With several profiles, the exit code is `0` only if every plugin qualifies under every profile.
|
|
166
|
+
|
|
167
|
+
### The JSON document
|
|
168
|
+
|
|
169
|
+
`--out <dir>` writes one interchange document per run (`lv2-plugin-measurements`, format 1.2,
|
|
170
|
+
the same format the OpenMixer console reads). The
|
|
171
|
+
`plugins` array holds the raw, host-free measurements. Beside it, `hostProfiles` holds each
|
|
172
|
+
profile the run judged against, in full, and `verdicts` holds one entry per plugin per profile:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{ "uri": "urn:example:verb", "host": "jalv", "rating": "unknown", "qualified": false,
|
|
176
|
+
"deciding": "hosting.stability.unsoaked" }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`host` is the `id` of an entry in `hostProfiles`. `deciding` is a stable code, absent when the
|
|
180
|
+
plugin is `suitable`. If you have your own host rules, re-derive them from `plugins` and do not
|
|
181
|
+
trust another host's verdict.
|
|
182
|
+
|
|
183
|
+
### As a library
|
|
184
|
+
|
|
185
|
+
`classifyPluginHosting(plugin, { rate, quantum, profile })` gives one verdict, and
|
|
186
|
+
`qualifyPlugins(measured, { profiles })` gives one per plugin per profile. `readHostProfile(json)`
|
|
187
|
+
validates a profile file, and `judgedDocument(run, plugins, profiles, verdicts)` builds the
|
|
188
|
+
document. All of them are exported from the package root. The package depends on no other
|
|
189
|
+
OpenMixer package.
|
|
190
|
+
|
|
191
|
+
### The container image
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
podman build -t plugin-qualify -f Containerfile .
|
|
195
|
+
podman run --rm -v "$PWD/my-plugins:/plugins:ro" -e LV2_PATH=/plugins -v "$PWD/out:/out" \
|
|
196
|
+
plugin-qualify --host-profile jalv --all --out /out
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The image has lilv, the distribution's mod-host and an AddressSanitizer build of mod-host.
|
|
200
|
+
|
|
201
|
+
**Status:** the pure steps are in place and tested: target resolution, profile resolution, the
|
|
202
|
+
verdict, the document and the exit code. The command-line entry point does not yet drive the
|
|
203
|
+
measuring tools (`tools/lv2-measure.mjs`, the mod-host sweep). A run that would measure exits
|
|
204
|
+
`2` and names the missing step, rather than rate a plugin on less than was asked.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
4
|
+
/**
|
|
5
|
+
* `plugin-qualify` — the entry point (spec 2026-09-25-plugin-qualify.md §5). Every decision is a
|
|
6
|
+
* pure function in `src/qualify-cli.ts`; this file only reads argv, prints and exits.
|
|
7
|
+
*
|
|
8
|
+
* CLAP targets are qualified here: the prober (`native/clap-qualify`) runs each under the interposer
|
|
9
|
+
* and every plugin gets its RT-safety verdict document, per binary hash and console.
|
|
10
|
+
*
|
|
11
|
+
* NOT YET WIRED for LV2 (refused with exit 2, never a silent partial run): driving the offline pass
|
|
12
|
+
* (`tools/lv2-measure.mjs`) for a named subset and the mod-host hosting sweep from this entry
|
|
13
|
+
* point. Until then a run that would measure an LV2 refuses and says which step is missing.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ClapScanError,
|
|
20
|
+
EXIT_CODE,
|
|
21
|
+
clapLoader,
|
|
22
|
+
exitCodeFor,
|
|
23
|
+
helpText,
|
|
24
|
+
lv2Loader,
|
|
25
|
+
parseQualifyArgs,
|
|
26
|
+
qualifyClapTargets,
|
|
27
|
+
resolveHostProfiles,
|
|
28
|
+
resolveTargets,
|
|
29
|
+
rtSafetyDocumentName,
|
|
30
|
+
textTable,
|
|
31
|
+
} from "../dist/index.js";
|
|
32
|
+
|
|
33
|
+
const parsed = parseQualifyArgs(process.argv.slice(2));
|
|
34
|
+
if (!parsed.ok) {
|
|
35
|
+
process.stderr.write(`plugin-qualify: ${parsed.error}\n\n${helpText()}`);
|
|
36
|
+
process.exit(EXIT_CODE.couldNotMeasure);
|
|
37
|
+
}
|
|
38
|
+
if (parsed.args.help) {
|
|
39
|
+
process.stdout.write(helpText());
|
|
40
|
+
process.exit(EXIT_CODE.allQualified);
|
|
41
|
+
}
|
|
42
|
+
const profiles = resolveHostProfiles(parsed.args.hostProfiles);
|
|
43
|
+
if (!profiles.ok) {
|
|
44
|
+
for (const r of profiles.refused) process.stderr.write(`plugin-qualify: --host-profile ${r.spec}: ${r.problem}\n`);
|
|
45
|
+
process.exit(EXIT_CODE.couldNotMeasure);
|
|
46
|
+
}
|
|
47
|
+
for (const p of profiles.profiles) process.stderr.write(`plugin-qualify: host profile ${p.id} (${p.isolation})\n`);
|
|
48
|
+
if (!parsed.args.all) {
|
|
49
|
+
let r;
|
|
50
|
+
try {
|
|
51
|
+
r = resolveTargets(parsed.args.targets, [clapLoader(), lv2Loader]);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (!(e instanceof ClapScanError)) throw e;
|
|
54
|
+
process.stderr.write(`plugin-qualify: ${e.message}\n`);
|
|
55
|
+
process.exit(EXIT_CODE.couldNotMeasure);
|
|
56
|
+
}
|
|
57
|
+
if (!r.ok) {
|
|
58
|
+
process.stderr.write(`plugin-qualify: no plugin found for: ${r.refused.join(", ")}\n`);
|
|
59
|
+
process.exit(EXIT_CODE.couldNotMeasure);
|
|
60
|
+
}
|
|
61
|
+
for (const p of r.plugins) process.stderr.write(`plugin-qualify: target ${p.uri}${p.bundle ? ` (${p.bundle})` : ""}\n`);
|
|
62
|
+
if (r.plugins.every((p) => p.format === "clap")) {
|
|
63
|
+
const pass = qualifyClapTargets(r.plugins, { rates: parsed.args.rates, profiles: profiles.profiles });
|
|
64
|
+
for (const f of pass.failures) process.stderr.write(`plugin-qualify: ${f.uri}: ${f.problem}\n`);
|
|
65
|
+
if (parsed.args.out !== undefined) {
|
|
66
|
+
mkdirSync(parsed.args.out, { recursive: true });
|
|
67
|
+
for (const doc of pass.documents) writeFileSync(join(parsed.args.out, rtSafetyDocumentName(doc)), `${JSON.stringify(doc, null, 2)}\n`);
|
|
68
|
+
}
|
|
69
|
+
process.stdout.write(parsed.args.format === "json" ? `${JSON.stringify(pass.documents, null, 2)}\n` : textTable(pass.verdicts));
|
|
70
|
+
process.exit(exitCodeFor(pass.verdicts));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
process.stderr.write(
|
|
74
|
+
"plugin-qualify: measuring from this entry point is not wired yet " +
|
|
75
|
+
"(offline pass: tools/lv2-measure.mjs; hosting sweep: mod-host) — refusing rather than rating on less\n",
|
|
76
|
+
);
|
|
77
|
+
process.exit(EXIT_CODE.couldNotMeasure);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { PluginLoader } from "./loader.js";
|
|
2
|
+
export declare class ClapScanError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
export interface ClapScanParam {
|
|
6
|
+
readonly id: number;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly module: string;
|
|
9
|
+
/** null where the plugin leaves the bound open (JSON has no infinity). */
|
|
10
|
+
readonly min: number | null;
|
|
11
|
+
readonly max: number | null;
|
|
12
|
+
readonly default: number | null;
|
|
13
|
+
readonly flags: readonly string[];
|
|
14
|
+
/** An enum parameter's labels, as `value_to_text` renders each whole value of its range. */
|
|
15
|
+
readonly labels?: readonly ClapScanLabel[];
|
|
16
|
+
}
|
|
17
|
+
export interface ClapScanLabel {
|
|
18
|
+
readonly value: number;
|
|
19
|
+
readonly label: string;
|
|
20
|
+
}
|
|
21
|
+
/** A remote-control page: eight slots, each a parameter id or null when empty. */
|
|
22
|
+
export interface ClapScanRemotePage {
|
|
23
|
+
readonly id: number;
|
|
24
|
+
readonly section: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly params: readonly (number | null)[];
|
|
27
|
+
readonly forPreset: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface ClapScanPort {
|
|
30
|
+
readonly id: number;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly role: "main" | "aux";
|
|
33
|
+
readonly channels: number;
|
|
34
|
+
}
|
|
35
|
+
/** A plugin the scanner created and read. */
|
|
36
|
+
export interface ClapScanPlugin {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly vendor: string;
|
|
40
|
+
readonly version: string;
|
|
41
|
+
readonly description: string;
|
|
42
|
+
readonly url: string;
|
|
43
|
+
readonly features: readonly string[];
|
|
44
|
+
readonly params: readonly ClapScanParam[];
|
|
45
|
+
/** Absent where the scanner does not read the pages. */
|
|
46
|
+
readonly remote_controls?: readonly ClapScanRemotePage[];
|
|
47
|
+
readonly audio_ports: {
|
|
48
|
+
readonly inputs: readonly ClapScanPort[];
|
|
49
|
+
readonly outputs: readonly ClapScanPort[];
|
|
50
|
+
};
|
|
51
|
+
readonly note_ports: {
|
|
52
|
+
readonly inputs: number;
|
|
53
|
+
readonly outputs: number;
|
|
54
|
+
};
|
|
55
|
+
/** Frames, present only when the plugin reported more than 0 before it was activated. */
|
|
56
|
+
readonly latency?: number;
|
|
57
|
+
}
|
|
58
|
+
/** A plugin whose descriptor was read but which could not be created or initialised. */
|
|
59
|
+
export interface ClapScanPluginError {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly error: string;
|
|
63
|
+
}
|
|
64
|
+
export type ClapScanFile = {
|
|
65
|
+
readonly path: string;
|
|
66
|
+
readonly plugins: readonly (ClapScanPlugin | ClapScanPluginError)[];
|
|
67
|
+
} | {
|
|
68
|
+
readonly path: string;
|
|
69
|
+
readonly error: string;
|
|
70
|
+
};
|
|
71
|
+
export interface ClapScanDocument {
|
|
72
|
+
readonly scanner: string;
|
|
73
|
+
readonly version: string;
|
|
74
|
+
readonly files: readonly ClapScanFile[];
|
|
75
|
+
}
|
|
76
|
+
/** The scanner's stdout as its document, or a {@link ClapScanError} saying how it differs. */
|
|
77
|
+
export declare function parseClapScan(text: string): ClapScanDocument;
|
|
78
|
+
/** One control, shaped as the catalog's `PluginParam` (structurally; this package imports no other). */
|
|
79
|
+
export interface ClapDescriptorParam extends ClapControlBounds {
|
|
80
|
+
readonly isInteger: boolean;
|
|
81
|
+
readonly isToggle: boolean;
|
|
82
|
+
readonly isEnumeration: boolean;
|
|
83
|
+
readonly kind: "control";
|
|
84
|
+
/** The CLAP parameter id in decimal — the key `param_set` takes. */
|
|
85
|
+
readonly symbol: string;
|
|
86
|
+
readonly name: string;
|
|
87
|
+
/** The parameter's module path (`Filter/Envelope`), where the plugin names one. */
|
|
88
|
+
readonly module?: string;
|
|
89
|
+
/** An enum parameter's labels, where the scan read them. */
|
|
90
|
+
readonly scalePoints?: readonly {
|
|
91
|
+
readonly label: string;
|
|
92
|
+
readonly value: number;
|
|
93
|
+
}[];
|
|
94
|
+
}
|
|
95
|
+
/** A remote-control page, its slots as parameter symbols (null where empty). */
|
|
96
|
+
export interface ClapDescriptorRemotePage {
|
|
97
|
+
readonly id: number;
|
|
98
|
+
readonly section: string;
|
|
99
|
+
readonly name: string;
|
|
100
|
+
readonly params: readonly (string | null)[];
|
|
101
|
+
}
|
|
102
|
+
/** Each bound is absent where the plugin leaves it open. */
|
|
103
|
+
interface ClapControlBounds {
|
|
104
|
+
readonly min?: number;
|
|
105
|
+
readonly max?: number;
|
|
106
|
+
readonly default?: number;
|
|
107
|
+
}
|
|
108
|
+
/** A scanned CLAP plugin as the catalog's `PluginDescriptor` reads it — a declared fact of the scan. */
|
|
109
|
+
export interface ClapDescriptorRow {
|
|
110
|
+
readonly uri: string;
|
|
111
|
+
readonly name: string;
|
|
112
|
+
readonly lv2Class: null;
|
|
113
|
+
readonly audioInputs: number;
|
|
114
|
+
readonly audioOutputs: number;
|
|
115
|
+
readonly hasMidiIn: boolean;
|
|
116
|
+
readonly params: readonly ClapDescriptorParam[];
|
|
117
|
+
readonly format: "clap";
|
|
118
|
+
readonly binaryPath: string;
|
|
119
|
+
readonly latency?: {
|
|
120
|
+
readonly reportedFrames: number;
|
|
121
|
+
};
|
|
122
|
+
/** The plugin's remote-control pages (presets' own pages excluded), where the scan read them. */
|
|
123
|
+
readonly remoteControls?: readonly ClapDescriptorRemotePage[];
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The rows for every plugin the scanner created. A hidden or read-only parameter is not a
|
|
127
|
+
* control anyone may set, so it is not offered as one. Nothing here claims a qualification:
|
|
128
|
+
* the `clap` measurement stays absent, which the verdict reads as `unknown`.
|
|
129
|
+
*/
|
|
130
|
+
export declare function clapScanDescriptors(doc: ClapScanDocument): ClapDescriptorRow[];
|
|
131
|
+
/** What a run of the scanner gave back; `error` is a spawn failure (absent binary, timeout). */
|
|
132
|
+
export interface ClapScanRunResult {
|
|
133
|
+
readonly status: number | null;
|
|
134
|
+
readonly stdout: string;
|
|
135
|
+
readonly error?: Error;
|
|
136
|
+
}
|
|
137
|
+
export type ClapScanRun = (tool: string, args: readonly string[], timeoutMs: number) => ClapScanRunResult;
|
|
138
|
+
/** The scanner binary: the declared setting when set, else the declared default (on `PATH`). */
|
|
139
|
+
export declare function clapScanTool(env: Readonly<Record<string, string | undefined>>): string;
|
|
140
|
+
export interface ClapLoaderOptions {
|
|
141
|
+
/** Runs the scanner; the default spawns it. Injectable so the seam is testable without the binary. */
|
|
142
|
+
readonly run?: ClapScanRun;
|
|
143
|
+
/** Where the one setting is read from; the default is `process.env`. */
|
|
144
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
145
|
+
}
|
|
146
|
+
export declare function clapLoader(options?: ClapLoaderOptions): PluginLoader;
|
|
147
|
+
export {};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The CLAP implementation of the one discovery seam (`loader.ts`). A target that is a `.clap` file, or a
|
|
5
|
+
* directory holding one, is scanned by `omx-clap-scan --json` (the scanner of omx-clap-host),
|
|
6
|
+
* located by the ONE declared setting; `urn:clap:<id>` is one plugin without a scan. The
|
|
7
|
+
* scanner's JSON is the input: {@link clapScanDescriptors} normalises it into the interchange
|
|
8
|
+
* rows. A scan that cannot be read is a {@link ClapScanError}, never an empty result.
|
|
9
|
+
*
|
|
10
|
+
* plugin-qualify's own prober writes the same document (`native/clap-qualify --json`, set as the
|
|
11
|
+
* scanner by the same setting), and with it an enum parameter's labels and the plugin's
|
|
12
|
+
* remote-control pages; a row keeps them, and each parameter's module, wherever the scan read them.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
16
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
17
|
+
import { CLAP_SCAN_TIMEOUT_S, CLAP_SCAN_TOOL_DEFAULT, CLAP_SCAN_TOOL_ENV } from "./qualify-declarations.js";
|
|
18
|
+
/** The scanner's own name in its document — anything else is not its output. */
|
|
19
|
+
const SCANNER_NAME = "omx-clap-scan";
|
|
20
|
+
/** The same document from plugin-qualify's own prober (`native/clap-qualify --json`). */
|
|
21
|
+
const SCANNER_NAMES = [SCANNER_NAME, "clap-qualify"];
|
|
22
|
+
const CLAP_URI = /^urn:clap:(\S+)$/;
|
|
23
|
+
const CLAP_SUFFIX = ".clap";
|
|
24
|
+
/** A `.clap` directory listing can be tens of megabytes of JSON. */
|
|
25
|
+
const MAX_SCAN_BYTES = 1 << 30;
|
|
26
|
+
export class ClapScanError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "ClapScanError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isRecord(v) {
|
|
33
|
+
return Object.prototype.toString.call(v) === "[object Object]";
|
|
34
|
+
}
|
|
35
|
+
function bad(what) {
|
|
36
|
+
throw new ClapScanError(`${SCANNER_NAME} output is not its document: ${what}`);
|
|
37
|
+
}
|
|
38
|
+
function str(o, key, where) {
|
|
39
|
+
const v = o[key];
|
|
40
|
+
return typeof v === "string" ? v : bad(`${where} has no string ${key}`);
|
|
41
|
+
}
|
|
42
|
+
function num(o, key, where) {
|
|
43
|
+
const v = o[key];
|
|
44
|
+
return typeof v === "number" ? v : bad(`${where} has no number ${key}`);
|
|
45
|
+
}
|
|
46
|
+
function list(o, key, where) {
|
|
47
|
+
const v = o[key];
|
|
48
|
+
return Array.isArray(v) ? v : bad(`${where} has no array ${key}`);
|
|
49
|
+
}
|
|
50
|
+
function bound(o, key, where) {
|
|
51
|
+
const v = o[key];
|
|
52
|
+
return v === null ? null : typeof v === "number" ? v : bad(`${where} has no number or null ${key}`);
|
|
53
|
+
}
|
|
54
|
+
function parsePort(v, where) {
|
|
55
|
+
if (!isRecord(v))
|
|
56
|
+
return bad(`${where} is not an object`);
|
|
57
|
+
const role = str(v, "role", where);
|
|
58
|
+
if (role !== "main" && role !== "aux")
|
|
59
|
+
return bad(`${where} has role ${role}`);
|
|
60
|
+
return { id: num(v, "id", where), name: str(v, "name", where), role, channels: num(v, "channels", where) };
|
|
61
|
+
}
|
|
62
|
+
function parseLabel(v, where) {
|
|
63
|
+
if (!isRecord(v))
|
|
64
|
+
return bad(`${where} is not an object`);
|
|
65
|
+
return { value: num(v, "value", where), label: str(v, "label", where) };
|
|
66
|
+
}
|
|
67
|
+
function parsePage(v, where) {
|
|
68
|
+
if (!isRecord(v))
|
|
69
|
+
return bad(`${where} is not an object`);
|
|
70
|
+
const slots = list(v, "params", where).map((p) => (p === null || typeof p === "number" ? p : bad(`${where} has a slot that is not an id or null`)));
|
|
71
|
+
const forPreset = v["forPreset"];
|
|
72
|
+
if (typeof forPreset !== "boolean")
|
|
73
|
+
return bad(`${where} has no boolean forPreset`);
|
|
74
|
+
return { id: num(v, "id", where), section: str(v, "section", where), name: str(v, "name", where), params: slots, forPreset };
|
|
75
|
+
}
|
|
76
|
+
function parsePlugin(v, where) {
|
|
77
|
+
if (!isRecord(v))
|
|
78
|
+
return bad(`${where} is not an object`);
|
|
79
|
+
const id = str(v, "id", where);
|
|
80
|
+
const name = str(v, "name", where);
|
|
81
|
+
if (typeof v["error"] === "string")
|
|
82
|
+
return { id, name, error: v["error"] };
|
|
83
|
+
const ports = v["audio_ports"];
|
|
84
|
+
const notes = v["note_ports"];
|
|
85
|
+
if (!isRecord(ports))
|
|
86
|
+
return bad(`${where} has no audio_ports`);
|
|
87
|
+
if (!isRecord(notes))
|
|
88
|
+
return bad(`${where} has no note_ports`);
|
|
89
|
+
const params = list(v, "params", where).map((p, i) => {
|
|
90
|
+
const w = `${where} param ${i}`;
|
|
91
|
+
if (!isRecord(p))
|
|
92
|
+
return bad(`${w} is not an object`);
|
|
93
|
+
return {
|
|
94
|
+
id: num(p, "id", w),
|
|
95
|
+
name: str(p, "name", w),
|
|
96
|
+
module: str(p, "module", w),
|
|
97
|
+
min: bound(p, "min", w),
|
|
98
|
+
max: bound(p, "max", w),
|
|
99
|
+
default: bound(p, "default", w),
|
|
100
|
+
flags: list(p, "flags", w).map((f) => (typeof f === "string" ? f : bad(`${w} has a flag that is not a string`))),
|
|
101
|
+
...(p["labels"] === undefined ? {} : { labels: list(p, "labels", w).map((l, k) => parseLabel(l, `${w} label ${k}`)) }),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
const pages = v["remote_controls"] === undefined ? undefined : list(v, "remote_controls", where).map((r, i) => parsePage(r, `${where} page ${i}`));
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
name,
|
|
108
|
+
vendor: str(v, "vendor", where),
|
|
109
|
+
version: str(v, "version", where),
|
|
110
|
+
description: str(v, "description", where),
|
|
111
|
+
url: str(v, "url", where),
|
|
112
|
+
features: list(v, "features", where).map((f) => (typeof f === "string" ? f : bad(`${where} has a feature that is not a string`))),
|
|
113
|
+
params,
|
|
114
|
+
audio_ports: {
|
|
115
|
+
inputs: list(ports, "inputs", `${where} audio_ports`).map((p, i) => parsePort(p, `${where} input ${i}`)),
|
|
116
|
+
outputs: list(ports, "outputs", `${where} audio_ports`).map((p, i) => parsePort(p, `${where} output ${i}`)),
|
|
117
|
+
},
|
|
118
|
+
note_ports: { inputs: num(notes, "inputs", `${where} note_ports`), outputs: num(notes, "outputs", `${where} note_ports`) },
|
|
119
|
+
...(pages === undefined ? {} : { remote_controls: pages }),
|
|
120
|
+
...(v["latency"] === undefined ? {} : { latency: num(v, "latency", where) }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** The scanner's stdout as its document, or a {@link ClapScanError} saying how it differs. */
|
|
124
|
+
export function parseClapScan(text) {
|
|
125
|
+
let doc;
|
|
126
|
+
try {
|
|
127
|
+
doc = JSON.parse(text);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new ClapScanError(`${SCANNER_NAME} printed something that is not JSON`);
|
|
131
|
+
}
|
|
132
|
+
if (!isRecord(doc))
|
|
133
|
+
return bad("not an object");
|
|
134
|
+
const scanner = doc["scanner"];
|
|
135
|
+
if (typeof scanner !== "string" || !SCANNER_NAMES.includes(scanner))
|
|
136
|
+
return bad(`scanner is ${JSON.stringify(scanner)}`);
|
|
137
|
+
const files = list(doc, "files", "the document").map((f, i) => {
|
|
138
|
+
const where = `file ${i}`;
|
|
139
|
+
if (!isRecord(f))
|
|
140
|
+
return bad(`${where} is not an object`);
|
|
141
|
+
const path = str(f, "path", where);
|
|
142
|
+
if (typeof f["error"] === "string")
|
|
143
|
+
return { path, error: f["error"] };
|
|
144
|
+
return { path, plugins: list(f, "plugins", `file ${path}`).map((p, j) => parsePlugin(p, `plugin ${j} of ${path}`)) };
|
|
145
|
+
});
|
|
146
|
+
return { scanner, version: str(doc, "version", "the document"), files };
|
|
147
|
+
}
|
|
148
|
+
function isPlugin(p) {
|
|
149
|
+
return !("error" in p);
|
|
150
|
+
}
|
|
151
|
+
function controlOf(p) {
|
|
152
|
+
const stepped = p.flags.includes("stepped");
|
|
153
|
+
return {
|
|
154
|
+
kind: "control",
|
|
155
|
+
symbol: String(p.id),
|
|
156
|
+
name: p.name,
|
|
157
|
+
...(p.min === null ? {} : { min: p.min }),
|
|
158
|
+
...(p.max === null ? {} : { max: p.max }),
|
|
159
|
+
...(p.default === null ? {} : { default: p.default }),
|
|
160
|
+
isInteger: stepped,
|
|
161
|
+
isToggle: stepped && p.min === 0 && p.max === 1,
|
|
162
|
+
isEnumeration: p.flags.includes("enum"),
|
|
163
|
+
...(p.module === "" ? {} : { module: p.module }),
|
|
164
|
+
...(p.labels === undefined || p.labels.length === 0 ? {} : { scalePoints: p.labels.map(({ label, value }) => ({ label, value })) }),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function pageOf(page) {
|
|
168
|
+
return { id: page.id, section: page.section, name: page.name, params: page.params.map((id) => (id === null ? null : String(id))) };
|
|
169
|
+
}
|
|
170
|
+
const channels = (ports) => ports.reduce((n, p) => n + p.channels, 0);
|
|
171
|
+
/**
|
|
172
|
+
* The rows for every plugin the scanner created. A hidden or read-only parameter is not a
|
|
173
|
+
* control anyone may set, so it is not offered as one. Nothing here claims a qualification:
|
|
174
|
+
* the `clap` measurement stays absent, which the verdict reads as `unknown`.
|
|
175
|
+
*/
|
|
176
|
+
export function clapScanDescriptors(doc) {
|
|
177
|
+
const rows = [];
|
|
178
|
+
for (const file of doc.files) {
|
|
179
|
+
if (!("plugins" in file))
|
|
180
|
+
continue;
|
|
181
|
+
const binaryPath = resolve(file.path);
|
|
182
|
+
for (const p of file.plugins) {
|
|
183
|
+
if (!isPlugin(p))
|
|
184
|
+
continue;
|
|
185
|
+
rows.push({
|
|
186
|
+
uri: `urn:clap:${p.id}`,
|
|
187
|
+
name: p.name,
|
|
188
|
+
lv2Class: null,
|
|
189
|
+
audioInputs: channels(p.audio_ports.inputs),
|
|
190
|
+
audioOutputs: channels(p.audio_ports.outputs),
|
|
191
|
+
hasMidiIn: p.note_ports.inputs > 0,
|
|
192
|
+
params: p.params.filter((q) => !q.flags.includes("hidden") && !q.flags.includes("readonly")).map(controlOf),
|
|
193
|
+
format: "clap",
|
|
194
|
+
binaryPath,
|
|
195
|
+
...(p.latency === undefined ? {} : { latency: { reportedFrames: p.latency } }),
|
|
196
|
+
...(p.remote_controls === undefined ? {} : { remoteControls: p.remote_controls.filter((r) => !r.forPreset).map(pageOf) }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
const spawnScan = (tool, args, timeoutMs) => {
|
|
203
|
+
const r = spawnSync(tool, [...args], { encoding: "utf8", timeout: timeoutMs, maxBuffer: MAX_SCAN_BYTES });
|
|
204
|
+
return { status: r.status, stdout: r.stdout ?? "", ...(r.error === undefined ? {} : { error: r.error }) };
|
|
205
|
+
};
|
|
206
|
+
/** The scanner binary: the declared setting when set, else the declared default (on `PATH`). */
|
|
207
|
+
export function clapScanTool(env) {
|
|
208
|
+
const named = env[CLAP_SCAN_TOOL_ENV];
|
|
209
|
+
return named === undefined || named === "" ? CLAP_SCAN_TOOL_DEFAULT : named;
|
|
210
|
+
}
|
|
211
|
+
function holdsClap(dir) {
|
|
212
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
213
|
+
const p = join(dir, e.name);
|
|
214
|
+
const isDir = e.isDirectory() || (e.isSymbolicLink() && existsSync(p) && statSync(p).isDirectory());
|
|
215
|
+
if (isDir ? holdsClap(p) : e.name.endsWith(CLAP_SUFFIX))
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
function scan(target, options) {
|
|
221
|
+
const tool = clapScanTool(options.env ?? process.env);
|
|
222
|
+
const r = (options.run ?? spawnScan)(tool, ["--json", target], CLAP_SCAN_TIMEOUT_S * 1000);
|
|
223
|
+
if (r.error !== undefined) {
|
|
224
|
+
throw new ClapScanError(`cannot run the CLAP scanner "${tool}" (${r.error.message}): set ${CLAP_SCAN_TOOL_ENV} to the omx-clap-scan binary of omx-clap-host`);
|
|
225
|
+
}
|
|
226
|
+
if (r.status !== 0)
|
|
227
|
+
throw new ClapScanError(`${tool} exited ${String(r.status)} scanning ${target}`);
|
|
228
|
+
return parseClapScan(r.stdout);
|
|
229
|
+
}
|
|
230
|
+
export function clapLoader(options = {}) {
|
|
231
|
+
return {
|
|
232
|
+
format: "clap",
|
|
233
|
+
resolve(target) {
|
|
234
|
+
const named = CLAP_URI.exec(target);
|
|
235
|
+
if (named !== null)
|
|
236
|
+
return [{ format: "clap", uri: target }];
|
|
237
|
+
if (!existsSync(target))
|
|
238
|
+
return undefined;
|
|
239
|
+
const st = statSync(target);
|
|
240
|
+
if (st.isFile() ? !target.endsWith(CLAP_SUFFIX) : !st.isDirectory() || !holdsClap(target))
|
|
241
|
+
return undefined;
|
|
242
|
+
return clapScanDescriptors(scan(target, options)).map((row) => ({
|
|
243
|
+
format: "clap",
|
|
244
|
+
uri: row.uri,
|
|
245
|
+
bundle: dirname(row.binaryPath),
|
|
246
|
+
binary: basename(row.binaryPath),
|
|
247
|
+
binaryPath: row.binaryPath,
|
|
248
|
+
}));
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=clap-loader.js.map
|