@openmixer/plugin-qualify 0.13.1 → 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/README.md +34 -0
- package/dist/qualify-declarations.d.ts +26 -0
- package/dist/qualify-declarations.js +26 -0
- package/dist/qualify-declarations.js.map +1 -1
- package/package.json +1 -1
- package/src/qualify-declarations.ts +29 -0
- package/tools/_declarations.py +11 -0
- package/tools/audit-overlap.sh +250 -0
- package/tools/descriptors.py +382 -0
- package/tools/gen-python-declarations.mjs +13 -1
- package/tools/scan.py +260 -4
package/README.md
CHANGED
|
@@ -193,6 +193,40 @@ names the options it ran under, `featureOptions`:
|
|
|
193
193
|
The defaults are the measurement as it has always been, so a verdict reached under them does not
|
|
194
194
|
move. `lv2_measure --help` lists the same flags.
|
|
195
195
|
|
|
196
|
+
## Scanned descriptors
|
|
197
|
+
|
|
198
|
+
Both scanners also write what each plugin states about itself as a scanned descriptor: one JSON file per
|
|
199
|
+
plugin in omx-contract's descriptor words (identity, controls, ports, what it needs from a host, whether it
|
|
200
|
+
reports latency), held to omx-contract's descriptor schema before it is written. A descriptor holds no
|
|
201
|
+
measurement and no verdict; those are the qualification record's.
|
|
202
|
+
|
|
203
|
+
The files go into the descriptor directory, the key `descriptor_dir`:
|
|
204
|
+
|
|
205
|
+
| | where |
|
|
206
|
+
| --- | --- |
|
|
207
|
+
| named | `--descriptor-dir <dir>` |
|
|
208
|
+
| a user (the default) | `$XDG_STATE_HOME/omx/descriptors`, else `~/.local/state/omx/descriptors` |
|
|
209
|
+
| a system service | `/var/lib/omx/descriptors` (`--system`) |
|
|
210
|
+
|
|
211
|
+
Each file is named `<id>@<version>-<sha256 first 12>.json`: the CLAP id or the LV2 URI, the version the
|
|
212
|
+
plugin states (`unversioned` when it states none) and the SHA-256 of its binary (the `.clap`, or the LV2
|
|
213
|
+
bundle's `lv2:binary`), with every character other than letters, digits, `.`, `_` and `-` written `_`. The hash
|
|
214
|
+
keys which file describes which binary: a changed binary is a new file, and the old one stays as history.
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
tools/scan.py --out catalog.json # LV2: the catalog, and the descriptors beside it
|
|
218
|
+
tools/scan.py --descriptor-dir ./descriptors urn:my:plugin # one plugin, into a directory of your own
|
|
219
|
+
native/build/clap-qualify --json ./my-fx.clap | tools/descriptors.py clap --descriptor-dir ./descriptors
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`scan.py --no-descriptors` writes the catalog only. A descriptor the schema refuses is not written and is
|
|
223
|
+
reported as `contract.file.invalid`, omx-contract's code; a plugin whose binary cannot be read is
|
|
224
|
+
`qualify.descriptor.binary-unhashed`; a directory that cannot be written is `qualify.descriptor.dir-unwritable`;
|
|
225
|
+
no schema found is `qualify.descriptor.schema-missing`. Any of them makes the run exit `2`, and the other
|
|
226
|
+
plugins are still written. The schema is the omx-contract package plugin-qualify depends on, else
|
|
227
|
+
`/usr/share/omx-contract/omx-contract.schema.json`; `OMX_CONTRACT_SCHEMA` names another one, for a test. The
|
|
228
|
+
writer needs Python's `jsonschema` (`python3-jsonschema`).
|
|
229
|
+
|
|
196
230
|
## What the verdicts mean
|
|
197
231
|
|
|
198
232
|
- **suitable**: every dimension was measured and passed. Safe to run inside the engine's own
|
|
@@ -117,6 +117,32 @@ export declare const LV2_HOSTING_BLOCK_FRAMES = 512;
|
|
|
117
117
|
export declare const LV2_HOSTING_LIFECYCLES = 30;
|
|
118
118
|
export declare const LV2_HOSTING_REPEATS = 3;
|
|
119
119
|
export declare const LV2_HOSTING_TIMEOUT_S = 240;
|
|
120
|
+
/**
|
|
121
|
+
* The scanned descriptors (owner spec 6.6 and 6.9): both scanners write one file per plugin into the
|
|
122
|
+
* directory the key `descriptor_dir` names, `<id>@<version>-<binary sha256 first DESCRIPTOR_HASH_PREFIX>.json`.
|
|
123
|
+
* With no directory named it is `$XDG_STATE_HOME/<DESCRIPTOR_DIR_STATE_SUBDIR>`, else
|
|
124
|
+
* `~/.local/state/<DESCRIPTOR_DIR_STATE_SUBDIR>`; a system service's is `DESCRIPTOR_DIR_SYSTEM`.
|
|
125
|
+
*/
|
|
126
|
+
export declare const DESCRIPTOR_DIR_KEY = "descriptor_dir";
|
|
127
|
+
export declare const DESCRIPTOR_DIR_STATE_SUBDIR = "omx/descriptors";
|
|
128
|
+
export declare const DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors";
|
|
129
|
+
export declare const DESCRIPTOR_HASH_PREFIX = 12;
|
|
130
|
+
/**
|
|
131
|
+
* The one descriptor schema both scanners hold a file to: omx-contract's, named by `DESCRIPTOR_SCHEMA_ENV`
|
|
132
|
+
* when set (a test, or a checkout pointing at another contract), else the omx-contract package this one
|
|
133
|
+
* depends on, else `DESCRIPTOR_SCHEMA_INSTALLED`. A written descriptor requires `DESCRIPTOR_SCHEMA_FLOOR`.
|
|
134
|
+
*/
|
|
135
|
+
export declare const DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA";
|
|
136
|
+
export declare const DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json";
|
|
137
|
+
export declare const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0";
|
|
138
|
+
/**
|
|
139
|
+
* Why a scanned descriptor is not written. A file the schema refuses is omx-contract's own
|
|
140
|
+
* `contract.file.invalid`; the others are the scanners' own.
|
|
141
|
+
*/
|
|
142
|
+
export declare const DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid";
|
|
143
|
+
export declare const DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing";
|
|
144
|
+
export declare const DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed";
|
|
145
|
+
export declare const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable";
|
|
120
146
|
/** Every threshold, so none is baked into the code. */
|
|
121
147
|
export interface HostingPolicy {
|
|
122
148
|
/** Minimum instantiate/destroy lifecycles. The operator's §14.4 ruling: 1000. */
|
|
@@ -122,6 +122,32 @@ export const LV2_HOSTING_BLOCK_FRAMES = 512;
|
|
|
122
122
|
export const LV2_HOSTING_LIFECYCLES = 30;
|
|
123
123
|
export const LV2_HOSTING_REPEATS = 3;
|
|
124
124
|
export const LV2_HOSTING_TIMEOUT_S = 240;
|
|
125
|
+
/**
|
|
126
|
+
* The scanned descriptors (owner spec 6.6 and 6.9): both scanners write one file per plugin into the
|
|
127
|
+
* directory the key `descriptor_dir` names, `<id>@<version>-<binary sha256 first DESCRIPTOR_HASH_PREFIX>.json`.
|
|
128
|
+
* With no directory named it is `$XDG_STATE_HOME/<DESCRIPTOR_DIR_STATE_SUBDIR>`, else
|
|
129
|
+
* `~/.local/state/<DESCRIPTOR_DIR_STATE_SUBDIR>`; a system service's is `DESCRIPTOR_DIR_SYSTEM`.
|
|
130
|
+
*/
|
|
131
|
+
export const DESCRIPTOR_DIR_KEY = "descriptor_dir";
|
|
132
|
+
export const DESCRIPTOR_DIR_STATE_SUBDIR = "omx/descriptors";
|
|
133
|
+
export const DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors";
|
|
134
|
+
export const DESCRIPTOR_HASH_PREFIX = 12;
|
|
135
|
+
/**
|
|
136
|
+
* The one descriptor schema both scanners hold a file to: omx-contract's, named by `DESCRIPTOR_SCHEMA_ENV`
|
|
137
|
+
* when set (a test, or a checkout pointing at another contract), else the omx-contract package this one
|
|
138
|
+
* depends on, else `DESCRIPTOR_SCHEMA_INSTALLED`. A written descriptor requires `DESCRIPTOR_SCHEMA_FLOOR`.
|
|
139
|
+
*/
|
|
140
|
+
export const DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA";
|
|
141
|
+
export const DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json";
|
|
142
|
+
export const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0";
|
|
143
|
+
/**
|
|
144
|
+
* Why a scanned descriptor is not written. A file the schema refuses is omx-contract's own
|
|
145
|
+
* `contract.file.invalid`; the others are the scanners' own.
|
|
146
|
+
*/
|
|
147
|
+
export const DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid";
|
|
148
|
+
export const DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing";
|
|
149
|
+
export const DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed";
|
|
150
|
+
export const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable";
|
|
125
151
|
/**
|
|
126
152
|
* Defaults. The cycle floor is the operator's ruling of 2026-09-05 (§14.4, option c) and the
|
|
127
153
|
* soak is §3's default show length. The per-instance cost ceiling is the HOST's budget and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"qualify-declarations.js","sourceRoot":"","sources":["../src/qualify-declarations.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"qualify-declarations.js","sourceRoot":"","sources":["../src/qualify-declarations.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmixer/plugin-qualify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Qualify audio plugins for real-time hosting: scan, offline latency and cost measurement, a mod-host hosting sweep, and a fail-closed rating.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -147,6 +147,35 @@ export const LV2_HOSTING_LIFECYCLES = 30;
|
|
|
147
147
|
export const LV2_HOSTING_REPEATS = 3;
|
|
148
148
|
export const LV2_HOSTING_TIMEOUT_S = 240;
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The scanned descriptors (owner spec 6.6 and 6.9): both scanners write one file per plugin into the
|
|
152
|
+
* directory the key `descriptor_dir` names, `<id>@<version>-<binary sha256 first DESCRIPTOR_HASH_PREFIX>.json`.
|
|
153
|
+
* With no directory named it is `$XDG_STATE_HOME/<DESCRIPTOR_DIR_STATE_SUBDIR>`, else
|
|
154
|
+
* `~/.local/state/<DESCRIPTOR_DIR_STATE_SUBDIR>`; a system service's is `DESCRIPTOR_DIR_SYSTEM`.
|
|
155
|
+
*/
|
|
156
|
+
export const DESCRIPTOR_DIR_KEY = "descriptor_dir";
|
|
157
|
+
export const DESCRIPTOR_DIR_STATE_SUBDIR = "omx/descriptors";
|
|
158
|
+
export const DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors";
|
|
159
|
+
export const DESCRIPTOR_HASH_PREFIX = 12;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The one descriptor schema both scanners hold a file to: omx-contract's, named by `DESCRIPTOR_SCHEMA_ENV`
|
|
163
|
+
* when set (a test, or a checkout pointing at another contract), else the omx-contract package this one
|
|
164
|
+
* depends on, else `DESCRIPTOR_SCHEMA_INSTALLED`. A written descriptor requires `DESCRIPTOR_SCHEMA_FLOOR`.
|
|
165
|
+
*/
|
|
166
|
+
export const DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA";
|
|
167
|
+
export const DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json";
|
|
168
|
+
export const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Why a scanned descriptor is not written. A file the schema refuses is omx-contract's own
|
|
172
|
+
* `contract.file.invalid`; the others are the scanners' own.
|
|
173
|
+
*/
|
|
174
|
+
export const DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid";
|
|
175
|
+
export const DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing";
|
|
176
|
+
export const DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed";
|
|
177
|
+
export const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable";
|
|
178
|
+
|
|
150
179
|
|
|
151
180
|
|
|
152
181
|
|
package/tools/_declarations.py
CHANGED
|
@@ -19,3 +19,14 @@ QUALIFY_QUANTA = (128, 256, 512, 1024)
|
|
|
19
19
|
PLUGIN_TIMEOUT_S = 15
|
|
20
20
|
BOOT_TIMEOUT_S = 120
|
|
21
21
|
COST_TIMEOUT_S = 60
|
|
22
|
+
DESCRIPTOR_DIR_KEY = "descriptor_dir"
|
|
23
|
+
DESCRIPTOR_DIR_STATE_SUBDIR = "omx/descriptors"
|
|
24
|
+
DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors"
|
|
25
|
+
DESCRIPTOR_HASH_PREFIX = 12
|
|
26
|
+
DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA"
|
|
27
|
+
DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json"
|
|
28
|
+
DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0"
|
|
29
|
+
DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid"
|
|
30
|
+
DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing"
|
|
31
|
+
DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed"
|
|
32
|
+
DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
# Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
4
|
+
#
|
|
5
|
+
# audit-overlap.sh — every number in docs/audit-qualify-vs-compat.md, from source, by one command each.
|
|
6
|
+
#
|
|
7
|
+
# tools/audit-overlap.sh <omx-clap-core dir> <plugin-hostd dir>
|
|
8
|
+
# tools/audit-overlap.sh --self-test
|
|
9
|
+
#
|
|
10
|
+
# It prints one line per unit (a function, or a set of functions, in one repository):
|
|
11
|
+
# unit <id> <repo> <file>:<first>-<last> lines=<n>
|
|
12
|
+
# one line per pair that does the same job in two places:
|
|
13
|
+
# pair <a> <b> shared=<s> a=<na> b=<nb> overlap=<s/min(na,nb) %> diff=<changed lines or "-"> strings=<both/a/b>
|
|
14
|
+
# and one line per counted marker:
|
|
15
|
+
# count <id> <repo> <n>
|
|
16
|
+
# A unit is found by its name (a C or TypeScript function runs to the first column-0 `}`, a Python def or class
|
|
17
|
+
# to the next column-0 statement), never by a line number, so the numbers follow the code. The audited commits are
|
|
18
|
+
# printed first (`tree <repo> <commit>`); the document states which ones it quotes.
|
|
19
|
+
#
|
|
20
|
+
# Similarity: both bodies are normalised (comments, blank lines and lone braces dropped, whitespace squeezed), then
|
|
21
|
+
# `shared` is the number of normalised lines the two have in common (comm -12 over the sorted lines, duplicates
|
|
22
|
+
# kept) and `diff` the number of lines `diff` adds or removes between them. Code in two languages shares no line;
|
|
23
|
+
# for it the pair prints overlap=0 and diff=-. `strings` is shared/a/b of the distinct string literals (three or
|
|
24
|
+
# more characters) each body spells: messages, field names and codes, which survive a change of brace style or of
|
|
25
|
+
# language, so a port of the same code shows there when its lines do not.
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
export LC_ALL=C
|
|
28
|
+
Q="$(cd "$(dirname "$0")/.." && pwd)"
|
|
29
|
+
|
|
30
|
+
die() { echo "audit-overlap: $*" >&2; exit 2; }
|
|
31
|
+
|
|
32
|
+
# span <lang> <file> <name> — print "<first> <last>" of the named unit, nothing when there is none
|
|
33
|
+
span() { # <lang> <file> <name>
|
|
34
|
+
awk -v lang="$1" -v name="$3" '
|
|
35
|
+
function starts(l) {
|
|
36
|
+
if (lang == "py") return l ~ ("^(def|class) " name "[(:]")
|
|
37
|
+
if (lang == "ts") return l ~ ("^(export )?(async )?function " name "[(<]")
|
|
38
|
+
return l ~ ("^[A-Za-z].*[ *]" name "\\(") && l !~ /;[ \t]*$/
|
|
39
|
+
}
|
|
40
|
+
!on && starts($0) { on = 1; first = NR; last = NR; next }
|
|
41
|
+
on && lang != "py" && /^}/ { last = NR; print first, last; exit }
|
|
42
|
+
on && lang == "py" && /^[^ \t#\n]/ { print first, last; exit }
|
|
43
|
+
on && lang == "py" && /[^ \t]/ { last = NR }
|
|
44
|
+
END { if (on && lang == "py") print first, last }
|
|
45
|
+
' "$2"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
|
|
49
|
+
declare -A ROOT LANG_OF
|
|
50
|
+
|
|
51
|
+
# unit <id> <repo> <lang> <file> <name>... — one unit, the names' bodies concatenated
|
|
52
|
+
unit() {
|
|
53
|
+
local id="$1" repo="$2" lang="$3" file="$4"; shift 4
|
|
54
|
+
local root="${ROOT[$repo]}" n total=0 where="" s
|
|
55
|
+
: > "$WORK/$id"
|
|
56
|
+
for n in "$@"; do
|
|
57
|
+
s="$(span "$lang" "$root/$file" "$n")"
|
|
58
|
+
[ -n "$s" ] || die "$repo $file: no $lang unit named $n"
|
|
59
|
+
local a b; read -r a b <<<"$s"
|
|
60
|
+
sed -n "${a},${b}p" "$root/$file" >> "$WORK/$id"
|
|
61
|
+
total=$((total + b - a + 1)); where="${where:+$where,}$a-$b"
|
|
62
|
+
done
|
|
63
|
+
LANG_OF[$id]="$lang"
|
|
64
|
+
echo "unit $id $repo $file:$where lines=$total"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# whole <id> <repo> <lang> <file> — a whole file as one unit
|
|
68
|
+
whole() {
|
|
69
|
+
local id="$1" repo="$2" lang="$3" file="$4" n
|
|
70
|
+
cp "${ROOT[$repo]}/$file" "$WORK/$id"; n=$(wc -l < "$WORK/$id")
|
|
71
|
+
LANG_OF[$id]="$lang"
|
|
72
|
+
echo "unit $id $repo $file:1-$n lines=$n"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
norm() { # <lang> < body
|
|
76
|
+
case "$1" in
|
|
77
|
+
py) sed -E 's/#.*$//' ;;
|
|
78
|
+
*) sed -E 's#//.*$##; s#/\*.*\*/##g; /^[[:space:]]*\*( |\/|$)/d; /^[[:space:]]*\/\*/d' ;;
|
|
79
|
+
esac | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' | grep -vE '^[{}();, ]*$' || true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
lits() { grep -oE "\"[^\"\\\\]{3,}\"|'[^'\\\\]{3,}'" | sed -E "s/^.(.*).$/\\1/"; } # quotes dropped: '' and "" alike
|
|
83
|
+
|
|
84
|
+
pair() { # <a> <b>
|
|
85
|
+
local a="$1" b="$2" na nb s d m l
|
|
86
|
+
norm "${LANG_OF[$a]}" < "$WORK/$a" | sort > "$WORK/$a.n"
|
|
87
|
+
norm "${LANG_OF[$b]}" < "$WORK/$b" | sort > "$WORK/$b.n"
|
|
88
|
+
na=$(wc -l < "$WORK/$a.n"); nb=$(wc -l < "$WORK/$b.n")
|
|
89
|
+
s=$(comm -12 "$WORK/$a.n" "$WORK/$b.n" | wc -l)
|
|
90
|
+
m=$(( na < nb ? na : nb ))
|
|
91
|
+
if [ "${LANG_OF[$a]}" = "${LANG_OF[$b]}" ]; then
|
|
92
|
+
norm "${LANG_OF[$a]}" < "$WORK/$a" > "$WORK/$a.o"; norm "${LANG_OF[$b]}" < "$WORK/$b" > "$WORK/$b.o"
|
|
93
|
+
d=$(diff "$WORK/$a.o" "$WORK/$b.o" | grep -c '^[<>]' || true)
|
|
94
|
+
else d="-"; fi
|
|
95
|
+
# the string literals (messages, field names, codes) both spell: what survives a change of style or language
|
|
96
|
+
norm "${LANG_OF[$a]}" < "$WORK/$a" | lits | sort -u > "$WORK/$a.l" || true
|
|
97
|
+
norm "${LANG_OF[$b]}" < "$WORK/$b" | lits | sort -u > "$WORK/$b.l" || true
|
|
98
|
+
l="$(comm -12 "$WORK/$a.l" "$WORK/$b.l" | wc -l)/$(wc -l < "$WORK/$a.l")/$(wc -l < "$WORK/$b.l")"
|
|
99
|
+
echo "pair $a $b shared=$s a=$na b=$nb overlap=$(( m ? 100 * s / m : 0 ))% diff=$d strings=$l"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
count() { # <id> <repo> <git grep args> -- <pathspec>... (this audit's own two files are never counted)
|
|
103
|
+
local id="$1" repo="$2"; shift 2
|
|
104
|
+
local self=(":(exclude)tools/audit-overlap.sh" ":(exclude)docs/audit-qualify-vs-compat.md")
|
|
105
|
+
echo "count $id $repo $(git -C "${ROOT[$repo]}" grep -IE "$@" "${self[@]}" | wc -l)"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
audit() {
|
|
109
|
+
local r
|
|
110
|
+
for r in qualify clap-core hostd; do echo "tree $r $(git -C "${ROOT[$r]}" rev-parse --short HEAD)"; done
|
|
111
|
+
|
|
112
|
+
# 1. plugin enumeration
|
|
113
|
+
unit q.clap-walk qualify c native/clap-qualify.c is_clap walk scan
|
|
114
|
+
unit c.clap-walk clap-core c src/scan.c has_suffix walk main
|
|
115
|
+
unit q.clap-fork qualify c native/clap-qualify.c scan_file
|
|
116
|
+
unit c.clap-fork clap-core c src/scan.c scan_file
|
|
117
|
+
unit q.clap-describe qualify c native/clap-qualify.c scan_file_entry scan_plugin scan_params scan_audio_ports
|
|
118
|
+
unit c.clap-describe clap-core c src/scan.c put_file put_plugin put_descriptor put_params put_audio_ports
|
|
119
|
+
unit q.clap-path qualify ts src/clap-loader.ts clapSearchPath
|
|
120
|
+
unit c.clap-path clap-core c src/scan.c default_paths
|
|
121
|
+
unit q.lv2-manifest qualify ts src/lv2-loader.ts pluginsInManifest lv2BundlesOnPath
|
|
122
|
+
unit c.lv2-manifest clap-core c src/lv2_locate.c lv2_locate_manifest_names walk_dir lv2_locate
|
|
123
|
+
unit q.lv2-lilv qualify py tools/scan.py Scanner
|
|
124
|
+
unit c.lv2-lilv clap-core py tests/lv2_parity.py lilv_facts
|
|
125
|
+
pair q.clap-walk c.clap-walk
|
|
126
|
+
pair q.clap-fork c.clap-fork
|
|
127
|
+
pair q.clap-describe c.clap-describe
|
|
128
|
+
pair q.clap-path c.clap-path
|
|
129
|
+
pair q.lv2-manifest c.lv2-manifest
|
|
130
|
+
pair q.lv2-lilv c.lv2-lilv
|
|
131
|
+
count q.calls-omx-clap-scan qualify -e 'omx-clap-scan' -- src
|
|
132
|
+
# clap-qualify's whole scan mode (--json), which writes the document omx-clap-scan writes, plus labels and pages
|
|
133
|
+
unit q.scan-mode qualify c native/clap-qualify.c put_labels scan_params scan_remote_controls scan_audio_ports \
|
|
134
|
+
scan_plugin put_file_error scan_file_entry scan_file list_add is_clap walk scan
|
|
135
|
+
count c.scan-labels-pages clap-core -i -e 'remote_controls|enum_labels|"labels"' -- src/scan.c
|
|
136
|
+
|
|
137
|
+
# 2. deterministic input
|
|
138
|
+
count q.lcg-nr qualify -e '1664525u' -- native
|
|
139
|
+
count q.lcg-glibc qualify -e '1103515245u' -- native
|
|
140
|
+
count c.lcg-nr clap-core -e '1664525u' -- tests src
|
|
141
|
+
unit q.noise-lv2 qualify c native/lv2-measure.c fill_input
|
|
142
|
+
unit c.signal clap-core c tests/jack_parity.c noise signal_at
|
|
143
|
+
pair q.noise-lv2 c.signal
|
|
144
|
+
unit q.notes qualify c native/clap-qualify.c note_event note_midi note_key note_block note_dialect
|
|
145
|
+
unit q.notes-py qualify py tools/benchmark.py held_keys midi_sequence
|
|
146
|
+
unit c.notes clap-core c tests/jack_parity.c midi_events
|
|
147
|
+
pair q.notes c.notes
|
|
148
|
+
pair q.notes-py q.notes
|
|
149
|
+
|
|
150
|
+
# 3. isolated PipeWire, the feeder
|
|
151
|
+
count q.pipewire qualify -i -e 'pipewire|unshare' -- src native tools bin
|
|
152
|
+
count c.unshare clap-core -e 'unshare' -- tests
|
|
153
|
+
unit c.pw-parity clap-core py tests/lv2_parity.py start_pipewire
|
|
154
|
+
unit c.pw-coverage clap-core py tests/clap_coverage.py start_pipewire
|
|
155
|
+
unit c.ns-parity clap-core py tests/lv2_parity.py inside_run cmd_run
|
|
156
|
+
unit c.ns-coverage clap-core py tests/clap_coverage.py inside_host cmd_host
|
|
157
|
+
unit c.clock clap-core c tests/jack_parity.c clock_process clock_state clock_wait clock_start clock_cycle clock_run
|
|
158
|
+
pair c.pw-parity c.pw-coverage
|
|
159
|
+
pair c.ns-parity c.ns-coverage
|
|
160
|
+
|
|
161
|
+
# 4. host launch, teardown, crash and hang
|
|
162
|
+
unit q.probe-runner qualify ts src/probe-runner.ts runOne probeTarget
|
|
163
|
+
unit h.run-child hostd c src/probe.c apply_limits kill_tree run_child child_end
|
|
164
|
+
unit q.bench-worker qualify py tools/benchmark.py WorkerQueue _Worker
|
|
165
|
+
unit c.host-parity clap-core py tests/lv2_parity.py Host run_plugin
|
|
166
|
+
unit c.host-coverage clap-core py tests/clap_coverage.py Host
|
|
167
|
+
pair c.host-parity c.host-coverage
|
|
168
|
+
pair q.bench-worker c.host-parity
|
|
169
|
+
count h.runs-clap-qualify hostd -e 'clap-qualify|clap_qualify' -- src
|
|
170
|
+
count h.runs-lv2-measure hostd -e 'lv2_measure|lv2-measure' -- src
|
|
171
|
+
|
|
172
|
+
# 5. records, classification, vocabulary, reports
|
|
173
|
+
unit q.records qualify ts src/measurement/sweep-jsonl.ts measuredPluginsFromSweep parseJsonl
|
|
174
|
+
unit c.tsv-parity clap-core py tests/lv2_parity.py read_tsv write_tsv
|
|
175
|
+
unit c.tsv-coverage clap-core py tests/clap_coverage.py read_tsv write_tsv append_tsv
|
|
176
|
+
pair c.tsv-parity c.tsv-coverage
|
|
177
|
+
unit q.classify qualify ts src/hosting-suitability.ts classifyPluginHosting
|
|
178
|
+
unit c.classify clap-core py tests/lv2_parity.py _classify
|
|
179
|
+
pair q.classify c.classify
|
|
180
|
+
unit c.report clap-core py tests/lv2_parity.py report_text
|
|
181
|
+
count q.contract-imports qualify -e "from \"@openmixer/omx-contract" -- src
|
|
182
|
+
count c.contract-macros clap-core -e 'OMX_HOSTING_[A-Z_]+' -- src lv2
|
|
183
|
+
count q.handtyped-hosting qualify -e '"hosting\.[a-z-]+\.[a-z-]+"' -- 'src/*.ts' ':!src/*.test.ts'
|
|
184
|
+
|
|
185
|
+
# 6. cost and latency
|
|
186
|
+
unit q.pct-c qualify c native/clap-qualify.c percentile_sorted
|
|
187
|
+
unit q.pct-py qualify py tools/benchmark.py percentile
|
|
188
|
+
unit q.cost-c qualify c native/clap-qualify.c cost_rate
|
|
189
|
+
unit c.stage-bench clap-core c tests/stage_bench.c main
|
|
190
|
+
pair q.pct-c q.pct-py
|
|
191
|
+
pair q.cost-c c.stage-bench
|
|
192
|
+
|
|
193
|
+
# the probing programs plugin-hostd's probe runs as its children today, and what the move into its child deletes
|
|
194
|
+
whole q.prober-clap qualify c native/clap-qualify.c
|
|
195
|
+
whole q.prober-lv2 qualify c native/lv2-measure.c
|
|
196
|
+
whole q.benchmark qualify py tools/benchmark.py
|
|
197
|
+
|
|
198
|
+
# 7. mod-host in plugin-qualify: code that runs it, and words that name it
|
|
199
|
+
count q.modhost-spawn qualify -e "spawn[A-Za-z]*\\(['\"]mod-host|execvp?\\(\"mod-host" -- src native tools bin
|
|
200
|
+
count q.modhost-words qualify -i -e 'mod-host' -- .
|
|
201
|
+
count c.modhost-words clap-core -i -e 'mod-host' -- tests
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
self_test() {
|
|
205
|
+
T="$(mktemp -d)"; local t="$T"; trap 'rm -rf "$T" "$WORK"' EXIT
|
|
206
|
+
for r in a b c; do mkdir -p "$t/$r"; git -C "$t/$r" init -q; done
|
|
207
|
+
cat > "$t/a/x.c" <<'EOF'
|
|
208
|
+
static int one(int v)
|
|
209
|
+
{
|
|
210
|
+
/* a comment */
|
|
211
|
+
return v + !"same"; /* "comment" */
|
|
212
|
+
}
|
|
213
|
+
static int two(int v) {
|
|
214
|
+
return v * 2;
|
|
215
|
+
}
|
|
216
|
+
EOF
|
|
217
|
+
cat > "$t/b/y.py" <<'EOF'
|
|
218
|
+
def one(v):
|
|
219
|
+
# a comment
|
|
220
|
+
return v + len("same") # "comment"
|
|
221
|
+
|
|
222
|
+
X = 1
|
|
223
|
+
EOF
|
|
224
|
+
cp "$t/a/x.c" "$t/c/z.c"; sed -i 's/v \* 2/v * 3/' "$t/c/z.c"
|
|
225
|
+
for r in a b c; do git -C "$t/$r" add -A; git -C "$t/$r" -c user.name=t -c user.email=t@t commit -qm t; done
|
|
226
|
+
ROOT[qualify]="$t/a"; ROOT[clap-core]="$t/b"; ROOT[hostd]="$t/c"
|
|
227
|
+
local out fail=0
|
|
228
|
+
expect() { grep -qxF -- "$1" <<<"$out" || { echo "self-test: missing: $1" >&2; fail=1; }; }
|
|
229
|
+
out="$(unit u1 qualify c x.c one; unit u2 qualify c x.c two; unit p1 clap-core py y.py one
|
|
230
|
+
unit h1 hostd c z.c one two; unit h2 qualify c x.c one two
|
|
231
|
+
pair h2 h1; pair u1 p1; count n qualify -e 'return' -- .)"
|
|
232
|
+
expect "unit u1 qualify x.c:1-5 lines=5"
|
|
233
|
+
expect "unit u2 qualify x.c:6-8 lines=3"
|
|
234
|
+
expect "unit p1 clap-core y.py:1-3 lines=3"
|
|
235
|
+
expect "pair h2 h1 shared=3 a=4 b=4 overlap=75% diff=2 strings=1/1/1"
|
|
236
|
+
expect "pair u1 p1 shared=0 a=2 b=2 overlap=0% diff=- strings=1/1/1"
|
|
237
|
+
expect "count n qualify 2"
|
|
238
|
+
# a name that is not there is refused, never counted as zero
|
|
239
|
+
if (unit u3 qualify c x.c three) 2>/dev/null; then echo "self-test: a missing unit was not refused" >&2; fail=1; fi
|
|
240
|
+
[ "$fail" = 0 ] && echo "audit-overlap self-test: ok"
|
|
241
|
+
return "$fail"
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case "${1:-}" in
|
|
245
|
+
--self-test) self_test ;;
|
|
246
|
+
-h|--help|"") sed -n '5,9p' "$0"; [ -n "${1:-}" ] ;;
|
|
247
|
+
*) [ $# = 2 ] || die "usage: $0 <omx-clap-core dir> <plugin-hostd dir>"
|
|
248
|
+
ROOT[qualify]="$Q"; ROOT[clap-core]="$(cd "$1" && pwd)"; ROOT[hostd]="$(cd "$2" && pwd)"
|
|
249
|
+
audit ;;
|
|
250
|
+
esac
|
|
@@ -0,0 +1,382 @@
|
|
|
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
|
+
The scanned descriptors both scanners write (owner spec 6.6 and 6.9): one file per plugin, in
|
|
6
|
+
omx-contract's descriptor words, named by the plugin's id, its version and the SHA-256 of its
|
|
7
|
+
binary, in `descriptor_dir`.
|
|
8
|
+
|
|
9
|
+
This module is the one writer: it resolves the directory, names the file, holds the document to
|
|
10
|
+
the one descriptor schema (omx-contract's) and writes it whole. `scan.py` maps an LV2 plugin into a
|
|
11
|
+
descriptor through lilv and writes it here; for CLAP, `clap-qualify --json` reads the plugin and
|
|
12
|
+
`descriptors.py clap` maps its scan document. A descriptor the schema refuses is never written:
|
|
13
|
+
it is refused with omx-contract's code, `contract.file.invalid`, and the scan goes on.
|
|
14
|
+
|
|
15
|
+
A scan writes only what the plugin states. What it does not state is `unknown` (a unit, a scale,
|
|
16
|
+
a meaning), or absent; a fact of the format that has no descriptor word is carried verbatim in
|
|
17
|
+
`extensions.<format>` under the field the format names it by.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
clap-qualify --json <path.clap>... | descriptors.py clap [--descriptor-dir DIR | --system] [-]
|
|
21
|
+
descriptors.py clap [--descriptor-dir DIR | --system] scan.json
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
from _declarations import (
|
|
31
|
+
DESCRIPTOR_CODE_BINARY_UNHASHED,
|
|
32
|
+
DESCRIPTOR_CODE_DIR_UNWRITABLE,
|
|
33
|
+
DESCRIPTOR_CODE_SCHEMA,
|
|
34
|
+
DESCRIPTOR_CODE_SCHEMA_MISSING,
|
|
35
|
+
DESCRIPTOR_DIR_STATE_SUBDIR,
|
|
36
|
+
DESCRIPTOR_DIR_SYSTEM,
|
|
37
|
+
DESCRIPTOR_HASH_PREFIX,
|
|
38
|
+
DESCRIPTOR_SCHEMA_ENV,
|
|
39
|
+
DESCRIPTOR_SCHEMA_FLOOR,
|
|
40
|
+
DESCRIPTOR_SCHEMA_INSTALLED,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
SCHEMA_URL = "https://github.com/FreeMixer/omx-contract/raw/v3/schema/omx-contract.schema.json"
|
|
44
|
+
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
|
45
|
+
PKG = os.path.dirname(TOOLS)
|
|
46
|
+
|
|
47
|
+
# the plugin features the descriptor has words for: CLAP's own names, LV2 classes mapped onto them
|
|
48
|
+
FEATURE_WORDS = ("audio-effect", "instrument", "note-effect", "analyzer")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DescriptorRefused(Exception):
|
|
52
|
+
"""A descriptor that is not written, with the code it is refused by."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, code, problem):
|
|
55
|
+
super().__init__(f"{code}: {problem}")
|
|
56
|
+
self.code = code
|
|
57
|
+
self.problem = problem
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---- the directory and the name -----------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def descriptor_dir(named=None, system=False, env=None, home=None):
|
|
63
|
+
"""`descriptor_dir`: the directory named, else a system service's, else the user's state directory."""
|
|
64
|
+
if named:
|
|
65
|
+
return named
|
|
66
|
+
if system:
|
|
67
|
+
return DESCRIPTOR_DIR_SYSTEM
|
|
68
|
+
env = os.environ if env is None else env
|
|
69
|
+
state = env.get("XDG_STATE_HOME") or os.path.join(home or os.path.expanduser("~"), ".local", "state")
|
|
70
|
+
return os.path.join(state, *DESCRIPTOR_DIR_STATE_SUBDIR.split("/"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _safe(text):
|
|
74
|
+
return re.sub(r"[^A-Za-z0-9._-]", "_", text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def descriptor_file_name(plugin_id, version, sha256):
|
|
78
|
+
"""`<id>@<version>-<sha256 first 12>.json`, the base-name rule of the qualification record."""
|
|
79
|
+
if not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", sha256):
|
|
80
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_BINARY_UNHASHED, f"{plugin_id}: no SHA-256 of its binary")
|
|
81
|
+
return f"{_safe(plugin_id)}@{_safe(version or 'unversioned')}-{sha256[:DESCRIPTOR_HASH_PREFIX]}.json"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def file_sha256(path):
|
|
85
|
+
"""The SHA-256 of a file's bytes, as plugin-hostd's pin.h computes it."""
|
|
86
|
+
h = hashlib.sha256()
|
|
87
|
+
try:
|
|
88
|
+
with open(path, "rb") as f:
|
|
89
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
90
|
+
h.update(chunk)
|
|
91
|
+
except OSError as e:
|
|
92
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_BINARY_UNHASHED, f"{path}: {e.strerror}") from e
|
|
93
|
+
return h.hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---- the schema ---------------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def schema_candidates(env=None):
|
|
99
|
+
"""Where the one schema is looked for, in order: the setting, this package's omx-contract, the installed one."""
|
|
100
|
+
env = os.environ if env is None else env
|
|
101
|
+
named = env.get(DESCRIPTOR_SCHEMA_ENV)
|
|
102
|
+
if named:
|
|
103
|
+
return [named]
|
|
104
|
+
rel = os.path.join("schema", "omx-contract.schema.json")
|
|
105
|
+
return [
|
|
106
|
+
os.path.join(PKG, "node_modules", "@openmixer", "omx-contract", rel), # a checkout
|
|
107
|
+
os.path.join(os.path.dirname(PKG), "omx-contract", rel), # installed beside this package
|
|
108
|
+
DESCRIPTOR_SCHEMA_INSTALLED,
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SchemaValidator:
|
|
113
|
+
"""omx-contract's schema, compiled once; `check` refuses a document it does not hold."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, path=None, env=None):
|
|
116
|
+
found = [path] if path else [p for p in schema_candidates(env) if os.path.isfile(p)]
|
|
117
|
+
if not found or not os.path.isfile(found[0]):
|
|
118
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA_MISSING,
|
|
119
|
+
f"no omx-contract schema at {', '.join(schema_candidates(env))}")
|
|
120
|
+
self.path = found[0]
|
|
121
|
+
try:
|
|
122
|
+
import jsonschema
|
|
123
|
+
except ImportError as e:
|
|
124
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA_MISSING, "python3-jsonschema is not installed") from e
|
|
125
|
+
with open(self.path, encoding="utf-8") as f:
|
|
126
|
+
schema = json.load(f)
|
|
127
|
+
cls = jsonschema.validators.validator_for(schema)
|
|
128
|
+
self._validator = cls(schema)
|
|
129
|
+
self.version = schema.get("x-omx-schema-version")
|
|
130
|
+
|
|
131
|
+
def check(self, doc):
|
|
132
|
+
if not isinstance(doc, dict) or doc.get("origin") != "scanned":
|
|
133
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, "a scanned descriptor carries origin scanned")
|
|
134
|
+
errors = sorted(self._validator.iter_errors(doc), key=lambda e: list(e.absolute_path))
|
|
135
|
+
if errors:
|
|
136
|
+
e = errors[0]
|
|
137
|
+
where = "/".join(str(p) for p in e.absolute_path) or "(the file)"
|
|
138
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, f"{where}: {e.message}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_descriptor(directory, doc, sha256, validator):
|
|
142
|
+
"""Hold `doc` to the schema and write it whole into `directory`; the path written, or DescriptorRefused."""
|
|
143
|
+
validator.check(doc)
|
|
144
|
+
name = descriptor_file_name(doc["plugin"]["id"], doc["plugin"].get("version"), sha256)
|
|
145
|
+
try:
|
|
146
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
147
|
+
path = os.path.join(directory, name)
|
|
148
|
+
pending = path + ".new"
|
|
149
|
+
with open(pending, "w", encoding="utf-8") as f:
|
|
150
|
+
f.write(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
|
|
151
|
+
os.replace(pending, path)
|
|
152
|
+
except OSError as e:
|
|
153
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_DIR_UNWRITABLE, f"{directory}: {e.strerror}") from e
|
|
154
|
+
return path
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_all(directory, described, validator, err=sys.stderr):
|
|
158
|
+
"""Write each `(doc, sha256)`; a refusal is reported by its code and the others go on. (written, refused)."""
|
|
159
|
+
written, refused = [], []
|
|
160
|
+
for doc, sha256 in described:
|
|
161
|
+
try:
|
|
162
|
+
written.append(write_descriptor(directory, doc, sha256, validator))
|
|
163
|
+
except DescriptorRefused as e:
|
|
164
|
+
pid = (doc.get("plugin") or {}).get("id", "?") if isinstance(doc, dict) else "?"
|
|
165
|
+
refused.append((pid, e.code, e.problem))
|
|
166
|
+
err.write(f"descriptor refused: {pid}: {e.code}: {e.problem}\n")
|
|
167
|
+
return written, refused
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---- shared words -------------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def head(fmt, plugin):
|
|
173
|
+
"""The fields every scanned descriptor opens with."""
|
|
174
|
+
return {
|
|
175
|
+
"$schema": SCHEMA_URL,
|
|
176
|
+
"requires": {"omx-contract-schema": DESCRIPTOR_SCHEMA_FLOOR},
|
|
177
|
+
"origin": "scanned",
|
|
178
|
+
"plugin": {"format": fmt, **plugin},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def link_segment(text):
|
|
183
|
+
"""A path segment or a selector key a link can hold; a character it cannot is written `_`."""
|
|
184
|
+
return re.sub(r"[^A-Za-z0-9_+.-]", "_", text) or "_"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def link_key(text):
|
|
188
|
+
"""A link's selector key, `[<key>]`; a character it cannot hold is written `_`."""
|
|
189
|
+
return re.sub(r"[^A-Za-z0-9_.-]", "_", text) or "_"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def link_symbol(text):
|
|
193
|
+
"""A link's symbol: an identifier, `_` for a character it cannot hold."""
|
|
194
|
+
s = re.sub(r"[^A-Za-z0-9_]", "_", text)
|
|
195
|
+
return s if re.match(r"[A-Za-z_]", s) else "_" + s
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def one_line(text):
|
|
199
|
+
text = " ".join((text or "").split())
|
|
200
|
+
return text or None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def choice_ids(labels):
|
|
204
|
+
"""A choice's ids, from its labels: their letters and digits, `v<n>` where that is empty or taken."""
|
|
205
|
+
ids, seen = [], set()
|
|
206
|
+
for n, label in enumerate(labels):
|
|
207
|
+
word = re.sub(r"[^A-Za-z0-9]", "", label)
|
|
208
|
+
word = word[:1].lower() + word[1:]
|
|
209
|
+
if not word or word in seen:
|
|
210
|
+
word = f"v{n}"
|
|
211
|
+
while word in seen:
|
|
212
|
+
word += "x"
|
|
213
|
+
seen.add(word)
|
|
214
|
+
ids.append(word)
|
|
215
|
+
return ids
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _number(v):
|
|
219
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool) and v == v and abs(v) != float("inf")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---- CLAP: clap-qualify's scan document ---------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
CLAP_DIALECTS = {"clap": "clap", "midi": "midi", "midi_mpe": "midiMpe", "midi2": "midi2"}
|
|
225
|
+
CLAP_LAYOUTS = {"mono": "mono", "stereo": "stereo", "surround": "surround"}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def clap_control(p, entry):
|
|
229
|
+
flags = set(p.get("flags", []))
|
|
230
|
+
c = {"id": p["id"], "name": p["name"] or str(p["id"])}
|
|
231
|
+
labels = p.get("labels") or []
|
|
232
|
+
if "enum" in flags and labels:
|
|
233
|
+
ids = choice_ids([lb["label"] for lb in labels])
|
|
234
|
+
c["kind"] = "choice"
|
|
235
|
+
c["ids"] = ids
|
|
236
|
+
if all(lb["label"] for lb in labels):
|
|
237
|
+
c["labels"] = [lb["label"] for lb in labels]
|
|
238
|
+
c["values"] = [lb["value"] for lb in labels]
|
|
239
|
+
for i, lb in enumerate(labels):
|
|
240
|
+
if lb["value"] == p.get("default"):
|
|
241
|
+
c["default"] = ids[i]
|
|
242
|
+
else:
|
|
243
|
+
c["kind"] = "travel"
|
|
244
|
+
travel = {
|
|
245
|
+
"min": p["min"],
|
|
246
|
+
"max": p["max"],
|
|
247
|
+
"unit": "unknown",
|
|
248
|
+
"scale": "stepped" if "stepped" in flags else "unknown",
|
|
249
|
+
}
|
|
250
|
+
if "stepped" in flags:
|
|
251
|
+
travel["step"] = 1
|
|
252
|
+
if _number(p.get("default")):
|
|
253
|
+
travel["default"] = p["default"]
|
|
254
|
+
c["travel"] = travel
|
|
255
|
+
if p.get("module"):
|
|
256
|
+
group = [g for g in p["module"].split("/") if g]
|
|
257
|
+
if group:
|
|
258
|
+
c["group"] = group
|
|
259
|
+
c["meaning"] = "bypass" if "bypass" in flags else "unknown"
|
|
260
|
+
for flag in ("hidden", "readonly", "automatable"):
|
|
261
|
+
if flag in flags:
|
|
262
|
+
c[flag] = True
|
|
263
|
+
c["source"] = {"from": f"{entry}[{p['id']}]"}
|
|
264
|
+
return c
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def open_bound(p):
|
|
268
|
+
"""A parameter the plugin leaves unbounded: no travel holds it, so it is carried verbatim instead."""
|
|
269
|
+
labels = p.get("labels") or []
|
|
270
|
+
return not ("enum" in p.get("flags", []) and labels) and not (_number(p.get("min")) and _number(p.get("max")))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def clap_ports(p):
|
|
274
|
+
ports = []
|
|
275
|
+
for direction, key in (("in", "inputs"), ("out", "outputs")):
|
|
276
|
+
for port in p["audio_ports"][key]:
|
|
277
|
+
q = {"id": port["id"], "name": port["name"] or f"audio {direction} {port['id']}",
|
|
278
|
+
"kind": "audio", "direction": direction, "role": port["role"]}
|
|
279
|
+
if port.get("channels", 0) >= 1:
|
|
280
|
+
q["channels"] = port["channels"]
|
|
281
|
+
layout = CLAP_LAYOUTS.get(port.get("port_type") or "")
|
|
282
|
+
if layout:
|
|
283
|
+
q["layout"] = layout
|
|
284
|
+
ports.append(q)
|
|
285
|
+
notes = p.get("note_port_info")
|
|
286
|
+
for direction, key in (("in", "inputs"), ("out", "outputs")):
|
|
287
|
+
for port in (notes or {}).get(key, []):
|
|
288
|
+
q = {"id": port["id"], "name": port["name"] or f"notes {direction} {port['id']}",
|
|
289
|
+
"kind": "note", "direction": direction}
|
|
290
|
+
dialects = [CLAP_DIALECTS[d] for d in port.get("dialects", []) if d in CLAP_DIALECTS]
|
|
291
|
+
if dialects:
|
|
292
|
+
q["dialects"] = dialects
|
|
293
|
+
ports.append(q)
|
|
294
|
+
return ports
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def clap_descriptor(p, path):
|
|
298
|
+
"""One plugin of clap-qualify's scan document as a scanned descriptor."""
|
|
299
|
+
entry = f"{link_segment(os.path.basename(path))}#clap_entry[{link_key(p['id'])}]"
|
|
300
|
+
plugin = {"id": p["id"], "name": p["name"] or p["id"]}
|
|
301
|
+
if p.get("vendor"):
|
|
302
|
+
plugin["vendor"] = p["vendor"]
|
|
303
|
+
if p.get("version"):
|
|
304
|
+
plugin["version"] = p["version"]
|
|
305
|
+
doc = one_line(p.get("description"))
|
|
306
|
+
if doc:
|
|
307
|
+
plugin["doc"] = doc
|
|
308
|
+
stated = [f for f in p.get("features", []) if f in FEATURE_WORDS]
|
|
309
|
+
plugin["features"] = list(dict.fromkeys(stated)) or ["audio-effect"]
|
|
310
|
+
d = head("clap", plugin)
|
|
311
|
+
params = p.get("params", [])
|
|
312
|
+
d["controls"] = [clap_control(q, entry) for q in params if not open_bound(q)]
|
|
313
|
+
d["ports"] = clap_ports(p)
|
|
314
|
+
if "latency_extension" in p:
|
|
315
|
+
d["latency"] = {"reports": bool(p["latency_extension"])}
|
|
316
|
+
bag = {}
|
|
317
|
+
if p.get("features"):
|
|
318
|
+
bag["clap_plugin_descriptor.features"] = list(p["features"])
|
|
319
|
+
if p.get("url"):
|
|
320
|
+
bag["clap_plugin_descriptor.url"] = p["url"]
|
|
321
|
+
if "latency" in p:
|
|
322
|
+
bag["clap_plugin_latency.get"] = p["latency"]
|
|
323
|
+
unbounded = [q for q in params if open_bound(q)]
|
|
324
|
+
if unbounded:
|
|
325
|
+
bag["clap_plugin_params.get_info"] = unbounded
|
|
326
|
+
if p.get("remote_controls"):
|
|
327
|
+
bag["clap_plugin_remote_controls.get"] = p["remote_controls"]
|
|
328
|
+
if bag:
|
|
329
|
+
d["extensions"] = {"clap": bag}
|
|
330
|
+
d["source"] = {"from": entry}
|
|
331
|
+
return d
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def clap_described(scan, err=sys.stderr):
|
|
335
|
+
"""Every plugin of a clap-qualify scan document as `(doc, sha256)`; a file that held none is reported."""
|
|
336
|
+
if not isinstance(scan, dict) or not isinstance(scan.get("files"), list):
|
|
337
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, "not a CLAP scan document")
|
|
338
|
+
out = []
|
|
339
|
+
for f in scan["files"]:
|
|
340
|
+
if "plugins" not in f:
|
|
341
|
+
err.write(f"not described: {f.get('path')}: {f.get('error')}\n")
|
|
342
|
+
continue
|
|
343
|
+
for p in f["plugins"]:
|
|
344
|
+
if "error" in p:
|
|
345
|
+
err.write(f"not described: {p.get('id')}: {p['error']}\n")
|
|
346
|
+
continue
|
|
347
|
+
out.append((clap_descriptor(p, f["path"]), f.get("sha256")))
|
|
348
|
+
return out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main(argv=None):
|
|
352
|
+
ap = argparse.ArgumentParser(description="Write the scanned descriptors of a CLAP scan document.")
|
|
353
|
+
ap.add_argument("format", choices=["clap"], help="the scan document's format")
|
|
354
|
+
ap.add_argument("scan", nargs="?", default="-", help="clap-qualify --json output (default: stdin)")
|
|
355
|
+
where = ap.add_mutually_exclusive_group()
|
|
356
|
+
where.add_argument("--descriptor-dir", help="where the descriptors go (descriptor_dir)")
|
|
357
|
+
where.add_argument("--system", action="store_true", help=f"a system service's directory, {DESCRIPTOR_DIR_SYSTEM}")
|
|
358
|
+
# intermixed: `clap --descriptor-dir DIR -` puts the scan after an option, and parse_args on Python 3.11
|
|
359
|
+
# and 3.12.3 (Debian bookworm, Ubuntu noble) fills the optional positional with its default first and
|
|
360
|
+
# then refuses the `-` as unrecognized; later interpreters accept it. Intermixed parsing reads it on all.
|
|
361
|
+
args = ap.parse_intermixed_args(argv)
|
|
362
|
+
try:
|
|
363
|
+
text = sys.stdin.read() if args.scan == "-" else open(args.scan, encoding="utf-8").read()
|
|
364
|
+
scan = json.loads(text)
|
|
365
|
+
except (OSError, ValueError) as e:
|
|
366
|
+
sys.stderr.write(f"cannot read the scan document: {e}\n")
|
|
367
|
+
return 2
|
|
368
|
+
try:
|
|
369
|
+
validator = SchemaValidator()
|
|
370
|
+
described = clap_described(scan)
|
|
371
|
+
except DescriptorRefused as e:
|
|
372
|
+
sys.stderr.write(f"{e.code}: {e.problem}\n")
|
|
373
|
+
return 2
|
|
374
|
+
directory = descriptor_dir(args.descriptor_dir, args.system)
|
|
375
|
+
written, refused = write_all(directory, described, validator)
|
|
376
|
+
for path in written:
|
|
377
|
+
print(path)
|
|
378
|
+
return 2 if refused else 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
sys.exit(main())
|
|
@@ -35,9 +35,21 @@ const PY_NAMES = [
|
|
|
35
35
|
'PLUGIN_TIMEOUT_S',
|
|
36
36
|
'BOOT_TIMEOUT_S',
|
|
37
37
|
'COST_TIMEOUT_S',
|
|
38
|
+
'DESCRIPTOR_DIR_KEY',
|
|
39
|
+
'DESCRIPTOR_DIR_STATE_SUBDIR',
|
|
40
|
+
'DESCRIPTOR_DIR_SYSTEM',
|
|
41
|
+
'DESCRIPTOR_HASH_PREFIX',
|
|
42
|
+
'DESCRIPTOR_SCHEMA_ENV',
|
|
43
|
+
'DESCRIPTOR_SCHEMA_INSTALLED',
|
|
44
|
+
'DESCRIPTOR_SCHEMA_FLOOR',
|
|
45
|
+
'DESCRIPTOR_CODE_SCHEMA',
|
|
46
|
+
'DESCRIPTOR_CODE_SCHEMA_MISSING',
|
|
47
|
+
'DESCRIPTOR_CODE_BINARY_UNHASHED',
|
|
48
|
+
'DESCRIPTOR_CODE_DIR_UNWRITABLE',
|
|
38
49
|
];
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
/** A Python literal: a tuple, a string (JSON's quoting is Python's for these ASCII words) or a number. */
|
|
52
|
+
const py = (v) => (Array.isArray(v) ? `(${v.join(', ')})` : typeof v === 'string' ? JSON.stringify(v) : String(v));
|
|
41
53
|
|
|
42
54
|
/** `decl` defaults to the built declarations; a test passes moved ones to prove the Python side follows. */
|
|
43
55
|
export function declarationsFile(decl = D) {
|
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
|
-
|
|
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
|
-
|
|
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__":
|