@openmixer/plugin-qualify 0.18.5 → 0.19.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.
Files changed (88) hide show
  1. package/README.md +19 -730
  2. package/bin/plugin-qualify-sync.mjs +1 -1
  3. package/bin/plugin-qualify.mjs +7 -0
  4. package/dist/clap-qualify.d.ts +1 -1
  5. package/dist/compare.d.ts +311 -0
  6. package/dist/compare.js +502 -0
  7. package/dist/compare.js.map +1 -0
  8. package/dist/distributed-records.d.ts +0 -2
  9. package/dist/distributed-records.js +0 -2
  10. package/dist/distributed-records.js.map +1 -1
  11. package/dist/host-admission.d.ts +2 -2
  12. package/dist/host-admission.js +7 -0
  13. package/dist/host-admission.js.map +1 -1
  14. package/dist/hosting-codes.generated.d.ts +1 -0
  15. package/dist/hosting-codes.generated.js +1 -0
  16. package/dist/hosting-codes.generated.js.map +1 -1
  17. package/dist/hosting-suitability.d.ts +3 -1
  18. package/dist/hosting-suitability.js +9 -0
  19. package/dist/hosting-suitability.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/lv2-loader.js +6 -5
  24. package/dist/lv2-loader.js.map +1 -1
  25. package/dist/measurement/format.d.ts +15 -0
  26. package/dist/measurement/format.js +19 -0
  27. package/dist/measurement/format.js.map +1 -1
  28. package/dist/measurement/sweep-jsonl.js +5 -1
  29. package/dist/measurement/sweep-jsonl.js.map +1 -1
  30. package/dist/measurement-grid.js +1 -1
  31. package/dist/pins-file.d.ts +22 -2
  32. package/dist/pins-file.js +118 -17
  33. package/dist/pins-file.js.map +1 -1
  34. package/dist/qualification-record.d.ts +26 -0
  35. package/dist/qualification-record.js +21 -0
  36. package/dist/qualification-record.js.map +1 -1
  37. package/dist/qualify-declarations.d.ts +6 -0
  38. package/dist/qualify-declarations.js +8 -0
  39. package/dist/qualify-declarations.js.map +1 -1
  40. package/dist/qualify-isolated.d.ts +16 -3
  41. package/dist/qualify-isolated.js +31 -3
  42. package/dist/qualify-isolated.js.map +1 -1
  43. package/dist/qualify-pool.d.ts +129 -0
  44. package/dist/qualify-pool.js +290 -0
  45. package/dist/qualify-pool.js.map +1 -0
  46. package/dist/qualify-rejudge.js +7 -0
  47. package/dist/qualify-rejudge.js.map +1 -1
  48. package/dist/qualify-sync.d.ts +36 -1
  49. package/dist/qualify-sync.js +144 -20
  50. package/dist/qualify-sync.js.map +1 -1
  51. package/dist/records-package.d.ts +5 -0
  52. package/dist/records-package.js +25 -8
  53. package/dist/records-package.js.map +1 -1
  54. package/fixtures/clap-scan/fake.json +8 -2
  55. package/fixtures/probe-output/success-msi.json +2 -2
  56. package/package.json +3 -3
  57. package/src/clap-qualify.ts +1 -1
  58. package/src/compare.ts +699 -0
  59. package/src/distributed-records.ts +0 -3
  60. package/src/host-admission.ts +9 -2
  61. package/src/hosting-codes.generated.ts +1 -0
  62. package/src/hosting-suitability.ts +14 -0
  63. package/src/index.ts +1 -0
  64. package/src/lv2-loader.ts +5 -4
  65. package/src/measurement/format.ts +32 -0
  66. package/src/measurement/sweep-jsonl.ts +5 -1
  67. package/src/measurement-grid.ts +1 -1
  68. package/src/pins-file.ts +109 -16
  69. package/src/qualification-record.ts +38 -0
  70. package/src/qualify-declarations.ts +8 -0
  71. package/src/qualify-isolated.ts +31 -5
  72. package/src/qualify-pool.ts +318 -0
  73. package/src/qualify-rejudge.ts +8 -0
  74. package/src/qualify-sync.ts +140 -19
  75. package/src/records-package.ts +25 -8
  76. package/tools/benchmark.py +9 -3
  77. package/tools/compare-host.mjs +432 -0
  78. package/tools/compare-run.mjs +355 -0
  79. package/tools/descriptors.py +27 -0
  80. package/tools/isolated-step.mjs +3 -1
  81. package/tools/lilv_load.py +25 -0
  82. package/tools/lv2_resolve.py +3 -1
  83. package/tools/pool-step.mjs +186 -0
  84. package/tools/scan.py +16 -20
  85. package/tools/audit-overlap.sh +0 -250
  86. package/tools/commit-message-check.sh +0 -128
  87. package/tools/commit-message-patterns.json +0 -41
  88. package/tools/private-refs-check.sh +0 -90
package/README.md CHANGED
@@ -1,23 +1,20 @@
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`).
4
+ scans an LV2 or CLAP plugin, measures it offline (latency and CPU cost per rate and quantum),
5
+ runs it under a real host, and gives one verdict. `plugin-qualify-sync` keeps a record for every
6
+ plugin installed on a machine, rescanning when a plugin directory changes.
7
7
 
8
8
  ## Install
9
9
 
10
- plugin-qualify is published, signed, in the OpenMixer package channel, for Fedora 44 (x86_64, aarch64)
11
- and for Debian bookworm and trixie, including Raspberry Pi OS (amd64, arm64).
12
-
13
- Fedora:
10
+ Fedora 44 (x86_64, aarch64):
14
11
 
15
12
  ```
16
13
  sudo dnf install https://www.openmixer.org/rpm/openmixer-release.noarch.rpm
17
14
  sudo dnf install plugin-qualify
18
15
  ```
19
16
 
20
- Debian and Raspberry Pi OS:
17
+ Debian bookworm and trixie, including Raspberry Pi OS (amd64, arm64):
21
18
 
22
19
  ```
23
20
  curl -fsSLO https://www.openmixer.org/deb/openmixer-release.deb && sudo apt install ./openmixer-release.deb
@@ -25,737 +22,29 @@ sudo apt update
25
22
  sudo apt install plugin-qualify
26
23
  ```
27
24
 
28
- From the npm registry:
29
-
30
- ```
31
- pnpm add @openmixer/plugin-qualify
32
- # or, to install the command globally:
33
- npm install -g @openmixer/plugin-qualify
34
- ```
35
-
36
- The npm package ships the TypeScript command only, not the native tools. The CLAP prober, the LV2 measurer and
37
- the interposer come with the RPM or DEB, or from `make -C native` in a checkout; with the npm package alone,
38
- point `OMX_CLAP_QUALIFY`, `OMX_LV2_MEASURE` and `OMX_RT_INTERPOSE` at them (the probe runner is plugin-hostd's, `OMX_PROBE_RUNNER`), or CLAP qualification and the LV2
39
- hosting dimensions refuse with "no CLAP prober at ...".
25
+ The npm package (`npm install -g @openmixer/plugin-qualify`) carries the TypeScript command only;
26
+ the native CLAP prober and LV2 measurer come with the RPM or DEB.
40
27
 
41
- The GitHub release also carries the packed tarball, `openmixer-plugin-qualify-<version>.tgz`. To run from a
42
- checkout instead (Node.js 20 or newer and pnpm 10, see `BUILDING.md`):
43
-
44
- ```
45
- git clone https://github.com/FreeMixer/plugin-qualify.git
46
- cd plugin-qualify
47
- pnpm install
48
- pnpm exec plugin-qualify --help
49
- ```
50
-
51
- It needs Node.js 18 or newer to run. The measuring tools also want Python 3 with the lilv bindings
52
- (`python3-lilv`) and, for the hosting sweep, `mod-host`. The `Containerfile` builds an image with all of
53
- them. The commands below are written as `plugin-qualify`; from a checkout run them with `pnpm exec`.
54
-
55
- ## Run it on your own plugin
28
+ ## Start
56
29
 
57
30
  ```
58
31
  plugin-qualify urn:example:my-plugin # one plugin, by URI
59
32
  plugin-qualify ./my-plugin.lv2 # every plugin in a bundle
60
- plugin-qualify --all --out ./qualification # every LV2 bundle on LV2_PATH and every CLAP on the search path, one record each
33
+ plugin-qualify --all --out ./qualification # every installed LV2 and CLAP plugin, one record each
34
+ plugin-qualify compare --all --out ./compare # every plugin under omx-clap-host, plugin-hostd, mod-host and jalv
61
35
  plugin-qualify --help # every option and its default
62
36
  ```
63
37
 
64
- `--format json` prints the verdicts as JSON; `--out <dir>` writes the full measurement document.
65
- `--isolate` measures each plugin in a fresh worker so a crash or hang takes only that plugin. The `Containerfile` builds an image
66
- with lilv, mod-host and the ASan mod-host, if you do not want to install them yourself.
67
-
68
- A named LV2 plugin is measured the same way as a CLAP one: through the probe runner, then judged from the probe
69
- output it writes (`plugin-qualify judge` below). Exit code: `0` every plugin qualified, `1` some did not, `2`
70
- something could not be measured.
71
-
72
- ## The measurement grid
73
-
74
- A plugin is measured in cells, each one sample rate and one quantum (block size in frames). The verdict a host
75
- gets reads the cell of that host's rate and quantum, so the grid has to hold it.
76
-
77
- ```
78
- plugin-qualify --all --out ./q # the full grid (the default): 4 rates x 4 block sizes
79
- plugin-qualify --all --grid-hosts --out ./q # the hosts' grid, the fast option (also --grid hosts)
80
- plugin-qualify --grid 48000/128,96000/1024 urn:my:fx # exactly these cells
81
- ```
82
-
83
- - `--grid hosts` (the fast option; `--grid-hosts` says the same): the rate and quantum of every host the run judges
84
- (each `--host-profile`, and the console's two tiers, `openmixer-console` and the format's isolated host), then
85
- quantum 512 at every `--rates` rate, so that a record stays comparable with the ones written before the grid.
86
- With the four shipped hosts that is seven cells: 96000/1024 (`openmixer-console`), 48000/256 (`jalv`),
87
- 48000/128 (`mod-host`, `omx-clap-host`), and 512 at 44100, 48000, 96000 and 192000.
88
- - `--grid full` (the default; every plugin is measured under the same conditions, so any host's verdict can be read at any cell and the figures compare): every `--rates` rate at every `--quanta` quantum, 16 cells with the defaults
89
- (44100, 48000, 96000, 192000 by 128, 256, 512, 1024).
90
- - `--grid <rate>/<quantum>,...`: exactly the cells named.
91
-
92
- The cells are taken in that order: the hosts' own cells first, in the order the hosts are named, then the
93
- 512 cells, then the rest by rate and quantum. `--cells-per-plugin-budget N` keeps the first N, so a first pass
94
- over a large machine can measure the hosts' cells alone (`--cells-per-plugin-budget 3` with the four shipped hosts).
95
- `--blocks N` shortens the CLAP sweep of each cell from 512 blocks to N; the LV2 pass has a fixed shape (30
96
- lifecycles and 3 swept runs per cell) and is not shortened by it.
97
-
98
- An LV2 plugin is measured on the same grid as a CLAP one. plugin-hostd's runner measures an LV2 plugin at one
99
- rate per run (the highest it is given), which is why records written before the grid hold LV2 figures at 192000
100
- only; plugin-qualify now runs it once per cell. A CLAP plugin is run once per quantum, at every rate of the grid
101
- at that quantum. The runs of one plugin and one offer make one probe output: the grid points of every run, the
102
- steps of every run, and the run's outcome. The record and its byte copy hold that document.
103
-
104
- A run that ended badly leaves a grid point at each of its cells with the run's outcome in place of figures, so a
105
- cell is never silently absent. The cost a host is judged by is read at its cell: a measured cell is compared with
106
- the host's budget; a cell whose run failed reads `unknown` with that run's outcome (`probe.oom`, say); only a
107
- cell the grid does not hold reads `hosting.cost.unmeasured-at-rate-and-quantum`.
108
-
109
- ### The memory bound
110
-
111
- A sampler loading its library can take all the memory of a small machine, and the kernel then kills it, or
112
- something else. Every run is bounded: `--memory-limit <bytes>` (`512M`, `2G`, or `off`), by default half of
113
- the machine's RAM. The bound is the runner's data limit (`--data-bytes`, `RLIMIT_DATA` on the measuring child:
114
- its heap and private writable mappings), and the interposer the child runs under turns an allocation refused
115
- under that limit into an immediate exit with status 121. A run that ends that way, or by a `SIGKILL` the
116
- runner did not send (the kernel's out-of-memory killer), is recorded as `probe.oom`: its cells carry the
117
- outcome and `memoryLimit { bytes, mechanism }`, the plugin's record is still written, and the other cells are
118
- judged as usual. A plugin whose every run ended `probe.oom` is `unknown` under every host, deciding `probe.oom`.
119
- A host whose own cell ended `probe.oom` is `unknown` there as well (`memory`, `probe.oom`): the plugin did not
120
- run within the bound at that host's rate and quantum.
121
-
122
- Where the machine delegates the memory controller, the runner sets the bound as `memory.max` of a cgroup instead: a run the
123
- kernel kills at it is `probe.memory-limit`, read the same way, and the document's `limits` says which it was. A process
124
- plugin-qualify spawns itself, with no runner (`tools/lv2-measure.mjs`, `tools/benchmark.py`), runs under `RLIMIT_AS` at the
125
- machine's RAM, off for a sanitizer build; hitting it is `memory-limit-exceeded`, with the limit.
126
-
127
- ### Memory
128
-
129
- Each cell carries a footprint: how far the resident set of a process of its own grew, in KB, after instantiate, after
130
- activate, at rest after processing, with notes held for an instrument, and what a second instance added. A record adds what
131
- they come to: `firstInstanceKb`, the most one instance held with everything its instances share, and `perInstanceKb`, what
132
- one more costs.
133
-
134
- A host with a memory budget reads them, at every cell it runs, and the verdict follows:
135
- `hosting.memory.within-ceiling`, `hosting.memory.above-ceiling` (`unsuitable`), or `hosting.memory.unmeasured` (`unknown`,
136
- since a missing figure never reads as fitting). `openmixer-console` has one, sized for the desk, and the Raspberry Pi hosts
137
- `openmixer-pi-4gb` and `openmixer-pi-8gb`, at 48000/128 and 48000/256, have one each. The Pi budgets are not measured on a Pi
138
- yet, so a plugin under them is `conditional`, `hosting.memory.within-provisional-ceiling`: that gives the figure and the
139
- ceiling and does not say the plugin fits. The other hosts have no budget, and read nothing.
140
-
141
- A soak (LV2; the CLAP prober has no continuous render yet) also reads the growth: past 10 MB per minute of audio over its
142
- second half, provisional, the rate fails as `leaks`.
143
-
144
- ### How long a run takes
145
-
146
- The default grid is the full one, so the expected time of a run with no grid option is the `full` row: about 65 hours on a Raspberry Pi 5 for the vangelis set (1823 records). `--grid-hosts` is the fast option, about 30 hours there.
147
-
148
- Times from one deep run on a Raspberry Pi 5 (4 cores, 4 GB), 1823 records (494 CLAP, two offers of each of 247,
149
- and 1329 LV2), six hours in all with the grid of before (CLAP at four rates in one run at quantum 512, LV2 at
150
- 192000/512 alone):
151
-
152
- | | runs | mean a run | total |
153
- | --- | --- | --- | --- |
154
- | CLAP, one run per offer (4 cells) | 494 | 2.8 s | 0.38 h |
155
- | LV2, one run (1 cell) | 1329 | 10.5 s | 3.86 h |
156
- | host admissions (jalv, mod-host) and the scan | | | about 1.8 h |
157
-
158
- A CLAP plugin takes one run per quantum of the grid, an LV2 plugin one run per cell; the admissions do not
159
- grow with the grid. On that machine, then, about:
160
-
161
- | grid | CLAP runs a plugin | LV2 runs a plugin | expected |
162
- | --- | --- | --- | --- |
163
- | hosts (7 cells) | 4 | 7 | 1.5 h + 27 h + 1.8 h, about 30 h |
164
- | full (16 cells) | 4 | 16 | 1.5 h + 62 h + 1.8 h, about 65 h |
165
- | hosts, `--cells-per-plugin-budget 3` | 3 | 3 | 1.1 h + 11.6 h + 1.8 h, about 15 h |
166
-
167
- These are ceilings: the LV2 time a run was taken at 192000, the dearest rate. `--blocks 128` takes a quarter of
168
- the CLAP sweep a cell. A first pass with `--cells-per-plugin-budget 3`, then `--grid full` over the plugins worth
169
- it, keeps a small machine's night bounded.
170
-
171
- ## CLAP plugins: the RT-safety verdict
172
-
173
- A `.clap` file, or a directory holding one, is scanned by `omx-clap-scan` (from omx-clap-core; set
174
- `OMX_CLAP_SCAN` to its path when it is not on `PATH`) and each plugin in it is then qualified by the
175
- CLAP prober the package installs (from a checkout, the one built from `native/`, see `BUILDING.md`):
176
-
177
- ```
178
- plugin-qualify ./my-fx.clap # every plugin in the file
179
- plugin-qualify --out ./out ./my-fx.clap # and its RT-safety documents
180
- CLAP_PATH=~/my-clap plugin-qualify --all # every CLAP on the search path
181
- ```
182
-
183
- Each plugin is measured by plugin-hostd's probe runner, `plugin-hostd-probe` (from the `plugin-hostd` package, 0.4.0 or later, which
184
- the RPM and DEB require;
185
- `OMX_PROBE_RUNNER` names it when it is not on `PATH`). The runner starts the prober under limits (a wall clock
186
- that kills the whole process tree, CPU time, memory, files), and plugin-qualify waits for the runner and reads
187
- the document it writes, the probe output. Nothing in plugin-qualify times a prober out on its own any more.
188
-
189
- The files a pin holds are plugin-hostd's: the sync writes each pin with the files `plugin-hostd --pin-files <uri>`
190
- prints for the device's LV2 directories (plugin-hostd 0.15.0 or later; `PLUGIN_HOSTD` names the daemon when it is not
191
- on `PATH`), the list an add checks, and hashes exactly those. When plugin-hostd cannot name them, or is older, the
192
- plugin gets no pin, the log says why, and the daemon pins it on its first add (`pin_mode first-use`).
193
-
194
- `--all` walks the standard CLAP search path through the same scanner: each directory `CLAP_PATH`
195
- names (colon-separated), then `~/.clap`, then `/usr/lib/clap`, which is the system directory on every
196
- distribution and architecture. A plugin id found twice keeps its first place on that path. LV2 plugins
197
- are measured by `tools/lv2-measure.mjs` (below); this entry point does not drive that pass yet, so
198
- `--all` refuses with exit 2 when any `.lv2` bundle is on the LV2 path (`LV2_PATH`, else `~/.lv2` and the
199
- system lv2 directories) rather than qualify only the CLAP side. A directory on the CLAP path that cannot
200
- be read (permissions, a symlink loop) also refuses with exit 2, naming it.
201
-
202
- The prober runs each plugin headless at every cell of the grid (see "The measurement grid"), under an interposer that counts the
203
- allocations, locks and syscalls its `process()` makes while every parameter is swept, and prints
204
- the file's SHA-256 beside what it measured. The verdict is the real-time-safety one: **suitable**
205
- only on a clean count, **conditional** (isolated in the pin: it runs in a worker of its own, never refused) on any
206
- allocation, lock, syscall or main-thread host call from `process()`, **unsuitable** on `CLAP_PROCESS_ERROR`, **unknown** when it could not be counted. An instrument is
207
- quiet until it is played, so a plugin with a note input is also played, at every rate and under a
208
- count of its own: a chord, the pitch bent and a controller moved, pressure, the chord let go, a
209
- burst of 32 note ons in one block and their offs, all notes off, in the dialect its note input
210
- reads (CLAP's, else MIDI's). An allocation, lock or syscall while it plays is **conditional**, isolated in the pin like the same
211
- violation in the run (`hosting.rt-safety.violations-under-notes`); an instrument that could not be played under the
212
- interposer is **unknown** (`hosting.rt-safety.notes-unmeasured`). `--out` writes the qualification records and the probe outputs they judged (see
213
- "Judging a probe output" below), one pair per plugin binary and offered host extensions. Each plugin's pin is
214
- an `integrity` block in the probe output: the `.clap`'s SHA-256 by its file name and the parameter-layout
215
- fingerprint `omx-layout/1:<sha256>`, both computed through plugin-hostd's `pin.h`, which is what a console
216
- hands plugin-hostd as `pin_set` before it adds the plugin. `OMX_CLAP_QUALIFY` and `OMX_RT_INTERPOSE`
217
- name the prober and the interposer the runner starts when they are not the installed ones or the checkout's
218
- own build.
219
-
220
- ## Judging a probe output
221
-
222
- The probe output is the document `plugin-hostd-probe run` writes: one binary and one offer, the steps the
223
- prober reached and how each ended, and the figures per rate and quantum, with no verdict in it. plugin-qualify
224
- reads it with `readProbeOutput`, which refuses a document of a major version it does not know
225
- (`probe.format.major-unknown`), a document that carries a verdict, and a document whose derived fields disagree
226
- with the fields they are computed from (`probe.format.derived-mismatch`): `machine.class.cstatesLimited` and
227
- `machine.profile.meetsProtocol` are recomputed from `cpuidle[]`, the governor and `unmet[]`, never trusted.
228
-
229
- ```
230
- plugin-hostd-probe run --out out/plate.probe.json ./example-plate.clap org.example.plate
231
- plugin-qualify judge --host-profile omx-clap-host --qualification-dir ./qualification out/plate.probe.json
232
- ```
233
-
234
- `judge` writes, into the qualification directory (`--qualification-dir`, else `$XDG_STATE_HOME/omx/qualification`,
235
- else `~/.local/state/omx/qualification`), the qualification record `<id>@<version>-<sha256 first 12>.<offer>.qualification.json`
236
- and, beside it, a byte copy of the probe output it judged, `<same name>.probe.json`, because the probe directory
237
- may be wiped. The record names that copy and its SHA-256 in `probe`, with the run's protocol, reason and machine
238
- class, so a reference figure and a local one are told apart. The record holds, for each host profile, the verdict
239
- (`rating`, `hosting`, the deciding reason code and every reason) and the cost reasons at that profile's rate and
240
- quantum, and the figures as measured. Exit `0` every plugin qualified, `1` some did not, `2` something could not
241
- be read or judged.
242
-
243
- The verdict is the same one `plugin-qualify` gives CLAP plugins, the real-time-safety one, now for LV2 as
244
- well; cost is reported beside it, not folded into it. Cost is read at the profile's rate AND quantum: a pair the
245
- document does not hold reads `hosting.cost.unmeasured-at-rate-and-quantum`, and two quanta of one plugin that cost
246
- differently give two different cost reasons. Only a measurement of the old format, which keeps one figure per
247
- rate, is read per rate. A run that did not end `probe.ok` is `unknown`, with the run's outcome code as the reason.
248
-
249
- An instrument costs nothing until a voice plays, so its cost is taken with notes held down, at velocity 127,
250
- spread across the keyboard, while every parameter is swept: as many notes as the voice capacity the plugin
251
- declares (CLAP `voice-info`, basis `declared`), or, when it declares none, the count `--probe-notes` gives
252
- (basis `probe`; LV2 declares none). Each rate's figure says how many notes it held and why (`voices: { held,
253
- basis, capacity }`). An instrument with no count to hold is not timed (`voices-unknown`).
254
-
255
- An LV2 plugin's record holds the same two figures, under the same field names, in `measurements.grid[]`:
256
- `latency { declaredFrames, measuredFrames }` and `cost`. `lv2_measure` takes them at the run's rate and block, the
257
- way the CLAP prober does: the declared figure is what the plugin reports on its latency port
258
- (`lv2:designation lv2:latency`, or the older `lv2:reportsLatency`; 0 when it has none), the measured one is where an
259
- impulse at frame 0 lands (the first frame at or above 1e-3 of the response's own peak), or, for an instrument, where a
260
- struck A4 sounds. A plugin that has no latency port is measured, never read as 0. An instrument's cost holds
261
- `OMX_PROBE_NOTES` keys down (the LV2 counterpart of `--probe-notes`). A figure the run could not take (a response that
262
- stays silent, an instrument with no count to hold) is left out of the grid point and named in
263
- `measurements.unmeasured` with its hosting code (`hosting.latency.unmeasured`,
264
- `hosting.cost.unmeasured-at-rate-and-quantum`).
265
-
266
- Every cell of a record carries `latency`: the two figures when the run took them, else
267
- `{ unmeasuredReason, code }`, the contract's word for why and the hosting code a reader classifies by. A latency
268
- step that did not end well gives the child's reason (`silent-output`, say); a cell whose run failed, its outcome's
269
- word (`probe-crashed`, or `probe-error` for `probe.oom`); a run that took no latency at all (a runner or a child
270
- older than the latency arm), `not-attempted`. The declared figure alone, for a plugin whose response could not be
271
- measured, is not in the probe output: plugin-hostd's runner writes a cell's latency only with both figures.
272
-
273
-
274
- The prober also scans: `OMX_CLAP_SCAN=native/build/clap-qualify` makes it the scanner, and its scan
275
- keeps what a console needs to draw a third-party plugin's controls: an enum parameter's labels (as
276
- the plugin's `value_to_text` renders them), each parameter's module, and the plugin's
277
- remote-control pages.
278
-
279
- ## Adjusting a verdict by what the plugin did on this machine
280
-
281
- ```
282
- plugin-qualify adjust --history ~/.local/state/plugin-hostd/history --qualification-dir ./qualification --host-profile omx-clap-host
283
- plugin-qualify adjust --history history --qualification-dir ./qualification --host-class aarch64/4/2400 --pins pins.txt
284
- ```
285
-
286
- plugin-hostd keeps a running history of every plugin binary on every host class it ran on (windows run alone and in a
287
- group, CPU, xrun windows, crashes, restarts, quarantines). `adjust` reads that file directly, so it runs without a daemon,
288
- and writes a second verdict beside the measured one in each qualification record: a `history` block per host class of the
289
- file, holding the line it looked for (the plugin's plugin-hostd uri and the digest of the first file of its pin), whether
290
- the history has it, the windows it counts, and for each `--host-profile` the measured pin verdict, the adjusted one, the
291
- move (`demote`, `promote` or `none`) and a reason code. The measured `verdicts` and the measurement are never changed: the
292
- same plugin on another host class has another history and another adjusted verdict.
293
-
294
- | move | when | reason |
295
- |---|---|---|
296
- | `in-process` to `isolated` | a crash was counted within 30 days, on this host class | `history.crashed` |
297
- | `in-process` to `isolated` | from 30 grouped windows, the share of xrun windows grouped exceeds the share alone by 5 points | `history.xruns-grouped` |
298
- | `isolated` to `refused` | three or more solo crashes, the latest within 30 days | `history.crashed-alone` |
299
- | `isolated` to `in-process` | every reason against the plugin is a `hosting.cost.*` one, 360 windows run alone (six hours), no crash or restart ever counted, no xruns raised when grouped, `cpu_p95_solo` under the host profile's ceiling | `history.steady` |
300
- | none | no line for this binary on this class | `history.no-record` |
301
- | none | a line, and no rule moves the verdict | `history.no-change` |
302
- | none | the line would promote, but the verdict is a fact about the plugin: a real-time-safety, feature, stability, latency or topology reason, an `unknown`, or a `refused` | `history.not-liftable`, with `blockedBy` |
303
-
304
- History never lifts a real-time-safety, feature or unknown verdict, and never promotes a refused one; a demotion is one
305
- step. The thresholds are one table, `HISTORY_POLICY` in `src/history-adjust.ts`. `--pins <file>` writes the pins with the
306
- adjusted verdict of the first `--host-profile` instead of the measured one; it needs exactly one host class, so name one
307
- with `--host-class` when the history holds several. `--now <seconds>` fixes the time the history is read at. A line of the
308
- history outside its grammar is not read and is named on stderr; a history of a version this tool does not know is refused.
309
-
310
- ## LV2 plugins: the hosting measurement
311
-
312
- `tools/lv2-measure.mjs` measures the hosting dimensions (`stability`, `rtSafety`, `features`) through
313
- the LV2 measurer the package installs, `lv2_measure`: each plugin is loaded through lilv in its own
314
- process, instantiated and destroyed 30 times, and run under the interposer while every control is
315
- swept. `tools/lv2-measure.mjs` measures at the highest `--rates` rate; `plugin-qualify` runs the measurer
316
- once per cell of its grid (see "The measurement grid"):
317
-
318
- ```
319
- node tools/lv2-measure.mjs --rates 48000 --dimensions stability,rtSafety,features \
320
- --plugins urn:my:plugin --out measurements.json --run
321
- ```
322
-
323
- Without `--plugins` it measures every plugin the scan finds. Each plugin's entry carries its pin, an
324
- `integrity` block: the SHA-256 of the files the host loads and the parameter-layout fingerprint,
325
- through plugin-hostd's `pin.h`, as for CLAP. `OMX_LV2_MEASURE` and `OMX_RT_INTERPOSE` name the
326
- measurer and the interposer when they are not the installed ones or the checkout's own build.
327
-
328
- The features the measurer offers a plugin are the console's own: omx-clap-core's LV2 feature
329
- provider, the code its LV2 host builds every instance's features with. Each of the provider's
330
- options is a flag of `tools/lv2-measure.mjs` and of `lv2_measure` itself, and each plugin's record
331
- names the options it ran under, `featureOptions`:
332
-
333
- | flag | what it sets | default |
334
- | --- | --- | --- |
335
- | `--features LIST` | the kinds offered: `map`, `unmap`, `options`, `bounded`, `worker`, `default-state`, `log`, `rtmempool` (empty: none) | all but `default-state` |
336
- | `--strict` / `--no-strict` | refuse a plugin requiring a feature not offered (the record's `refusedFeature` names it), or hand it to its own instantiate without it | `--no-strict` |
337
- | `--block-declares LIST` | the buf-size declarations made when `bounded` is offered: `bounded`, `fixed`, `pow2` (empty: none) | `bounded` |
338
- | `--block-min N` | `bufsz:minBlockLength`; 0 is the run's block | 1 |
339
- | `--block-max N` | `bufsz:maxBlockLength`; 0 is the run's block | 0 |
340
- | `--block-nominal N` | `bufsz:nominalBlockLength`; 0 is the maximum | 0 |
341
- | `--log MODE` | `log:log` lines: `silent`, `route` (to stderr) or `capture` (into the record's `featureOptions.log.lines`) | `silent` |
342
- | `--log-capture-bytes N` | the capture buffer, its NUL included | 4096 |
343
-
344
- The defaults are the measurement as it has always been, so a verdict reached under them does not
345
- move. `lv2_measure --help` lists the same flags.
346
-
347
- ## Scanned descriptors
348
-
349
- Both scanners also write what each plugin states about itself as a scanned descriptor: one JSON file per
350
- plugin in omx-contract's descriptor words (identity, controls, ports, what it needs from a host, whether it
351
- reports latency), held to omx-contract's descriptor schema before it is written. A descriptor holds no
352
- measurement and no verdict; those are the qualification record's.
353
-
354
- The files go into the descriptor directory, the key `descriptor_dir`:
355
-
356
- | | where |
357
- | --- | --- |
358
- | named | `--descriptor-dir <dir>` |
359
- | a user (the default) | `$XDG_STATE_HOME/omx/descriptors`, else `~/.local/state/omx/descriptors` |
360
- | a system service | `/var/lib/omx/descriptors` (`--system`) |
361
-
362
- Each file is named `<id>@<version>-<sha256 first 12>.json`: the CLAP id or the LV2 URI, the version the
363
- plugin states (`unversioned` when it states none) and the SHA-256 of its binary (the `.clap`, or the LV2
364
- bundle's `lv2:binary`), with every character other than letters, digits, `.`, `_` and `-` written `_`. The hash
365
- keys which file describes which binary: a changed binary is a new file, and the old one stays as history.
366
-
367
- ```
368
- tools/scan.py --out catalog.json # LV2: the catalog, and the descriptors beside it
369
- tools/scan.py --descriptor-dir ./descriptors urn:my:plugin # one plugin, into a directory of your own
370
- native/build/clap-qualify --json ./my-fx.clap | tools/descriptors.py clap --descriptor-dir ./descriptors
371
- ```
372
-
373
- `scan.py --no-descriptors` writes the catalog only. A descriptor the schema refuses is not written and is
374
- reported as `contract.file.invalid`, omx-contract's code; a plugin whose binary cannot be read is
375
- `qualify.descriptor.binary-unhashed`; a directory that cannot be written is `qualify.descriptor.dir-unwritable`;
376
- no schema found is `qualify.descriptor.schema-missing`. Any of them makes the run exit `2`, and the other
377
- plugins are still written. The schema is the omx-contract package plugin-qualify depends on, else
378
- `/usr/share/omx-contract/omx-contract.schema.json`; `OMX_CONTRACT_SCHEMA` names another one, for a test. The
379
- writer needs Python's `jsonschema` (`python3-jsonschema`).
380
-
381
- ## What the verdicts mean
382
-
383
- - **suitable**: every dimension was measured and passed. Safe to run inside the engine's own
384
- process. This is the only verdict that counts as qualified.
385
- - **conditional**: it works, with a caution (for example a high cost at small quanta, or an
386
- allocation, lock or syscall in `run()`). It is run isolated, and the deciding reason names the caution.
387
- - **unknown**: something was not measured. An unmeasured plugin is never a pass.
388
- - **unsuitable**: a measured failure: a crash, a memory error in the plugin's own binary, or a
389
- required feature the host does not provide. An allocation, lock or syscall in `run()` is not
390
- one: that plugin is **conditional**, and runs isolated.
391
-
392
- The worst dimension decides. The text output prints the plugin, its verdict and the reason that
393
- decided it.
394
-
395
- ## Reproducing a failure
396
-
397
- 1. Run the plugin alone: `plugin-qualify --format json --out ./out <uri>`.
398
- 2. The document in `./out` names the dimension that failed and, for a crash, the sanitizer class
399
- and the object of the faulting frame.
400
- 3. Run the ASan mod-host (`mod-host-asan` in the image) with just that plugin to get the full
401
- sanitizer report.
402
-
403
- `fixtures/zero-path.lv2` is a known-bad plugin (a zero-size `atom:Path` overread). It should
404
- rate **unsuitable**; if it does not, the sweep is not catching memory errors.
405
-
406
- ## For other projects (zynthian, MOD, packagers)
407
-
408
- The measurements describe the plugin and never the host: latency and cost per rate, whether it
409
- survives a thousand lifecycles and a three-hour soak, whether `run()` allocates, locks or makes
410
- syscalls, and which LV2 features it requires. They are taken once. The **verdict** is a separate
411
- step: the same measurements judged against a **host profile**, the facts that differ between
412
- hosts. Two projects share the measurements and differ only in the profile they choose.
413
-
414
- ### Choosing a host profile
415
-
416
- ```
417
- plugin-qualify --host-profile jalv ./my-synth.lv2 # zynthian: one jalv process per plugin
418
- plugin-qualify --host-profile mod-host ./my-fx.lv2 # MOD: one shared mod-host process
419
- plugin-qualify --host-profile omx-clap-host ./my-synth.clap # omx-clap-host, alone or under plugin-hostd
420
- plugin-qualify --host-profile jalv,mod-host,openmixer-console <uri> # one verdict per profile
421
- plugin-qualify --host-profile ./my-host.json <uri> # your own host
422
- ```
423
-
424
- | profile | isolation | verdict | instruments | LV2 features read from | cost budget (P95 of a core, rate / quantum) |
425
- |---|---|---|---|---|---|
426
- | `openmixer-console` (default) | in-process (the console's RT thread) | judged from a declaration | no | the console's in-process table | 0.05 at 96000 / 1024 |
427
- | `jalv` | per-process | **measured by a run** (`jalv -n <name> <uri>`, headless) | yes | jalv 1.6.8 `src/jalv.c` (zynthian's fork passes the same list) | 0.25 at 48000 / 256 |
428
- | `mod-host` | shared-process | **measured by a run** (`add`, `activate`, `remove` on mod-host's socket) | yes | mod-host f14a230 `src/effects.c` | 0.05 at 48000 / 128 |
429
- | `omx-clap-host` | shared-process | judged from a declaration | yes | the console's in-process table (the same LV2 feature provider) | 0.05 at 48000 / 128 |
430
-
431
- **Measured or declared.** A *declared* verdict is what the profile's feature list and cost budget make of
432
- the one probe output. A *measured* verdict (`jalv`, `mod-host`, LV2 plugins) also runs the plugin under that
433
- host, one admission per plugin, and its record entry carries `arm: "run"` and `measured: true`. A refusal
434
- (`resp -102`), a crash (the signal), a host that dies on `add`, or a port type the host does not know makes the
435
- plugin `unsuitable` for that host, with the code as the deciding reason (`mod-host.resp-102`,
436
- `mod-host.signal-SIGABRT`, `mod-host.died-on-add`, `jalv.unknown-port-type`, `jalv.signal-SIGABRT`). A host
437
- that is not installed leaves the verdict unmeasured (`unknown`, deciding `<host>.not-installed`). After any
438
- death on mod-host the next admission starts a fresh host. `plugin-qualify judge` reads a probe output
439
- that holds no run, so its `jalv` and `mod-host` entries stay `arm: "declaration"`.
440
-
441
- **The console's two tiers.** Every record carries `tiers`: the *in-process* verdict (`openmixer-console`) and the
442
- *isolated* verdict (`omx-clap-host`, an LV2 plugin through plugin-hostd's LV2 adapter), `offeredOn` (`in-process` when its
443
- verdict qualified, `isolated` when its verdict qualified or is `conditional`) and, when neither, `dimmed` with the reason. A
444
- `conditional` plugin, RT-unsafe for instance, is offered on the isolated tier with `runsAlone: true`: it runs in a worker of
445
- its own, never grouped, and its pin says `isolated`. An LV2 plugin is run once under the adapter, and
446
- that run decides its `omx-clap-host` verdict as a mod-host run decides mod-host's. An `lv2:InstrumentPlugin` is also played one
447
- held note there, its output read at its JACK ports (`jack_peak`): at or under -120 dBFS it reads `unknown`
448
- (`omx-clap-host.silent-under-note`), not qualified and not refused, since a sampler with nothing loaded is silent too. A level
449
- that could not be read reads the same way (`omx-clap-host.level-unmeasured`). A plugin that kills the isolated
450
- host and qualifies in-process is offered in-process only. The tiers are judged for every plugin whether or not
451
- `--host-profile` names them; mod-host and jalv are run only when `--host-profile` asks for them.
452
-
453
- `omx-clap-host` offers a CLAP plugin the console's six host extensions and three more,
454
- `clap.preset-load/2`, `clap.track-info/1` and `clap.remote-controls/2`. A CLAP plugin judged
455
- under it is probed again with those three offered, so its verdict holds for the host it runs in.
456
-
457
- The `jalv`, `mod-host` and `omx-clap-host` budgets are defaults, not a measurement of your board. State your own
458
- by writing a profile file. Every field is required; a missing or mistyped field is refused and
459
- never filled in with a default:
460
-
461
- ```json
462
- {
463
- "id": "zynthian-v5",
464
- "lv2Features": ["http://lv2plug.in/ns/ext/urid#map", "http://lv2plug.in/ns/ext/urid#unmap",
465
- "http://lv2plug.in/ns/ext/worker#schedule", "http://lv2plug.in/ns/ext/log#log",
466
- "http://lv2plug.in/ns/ext/options#options",
467
- "http://lv2plug.in/ns/ext/state#loadDefaultState",
468
- "http://lv2plug.in/ns/ext/buf-size#powerOf2BlockLength",
469
- "http://lv2plug.in/ns/ext/buf-size#fixedBlockLength",
470
- "http://lv2plug.in/ns/ext/buf-size#boundedBlockLength"],
471
- "clapExtensions": null,
472
- "instruments": true,
473
- "cost": { "coreFractionCeiling": 0.3, "rate": 48000, "quantum": 256 },
474
- "latency": "uncompensated",
475
- "isolation": "per-process"
476
- }
477
- ```
478
-
479
- What each field changes. `lv2Features` is what the host passes to `instantiate`: a plugin that
480
- requires anything else is **unsuitable** on that host. `clapExtensions` is `null` for a host with
481
- no CLAP path. `instruments: true` means a MIDI input is fed, and a plugin with no audio input but
482
- a MIDI input and an audio output rates as an instrument. `cost` is the per-instance ceiling, and
483
- the rate and quantum the verdict is judged at. `latency: "compensated"` means the host aligns on
484
- the plugin's latency, so a latency that moves with a control is a caution. `isolation` decides
485
- whether spawned threads and a linked GUI toolkit count as cautions: they do only when the plugin
486
- shares its process. An unmeasured dimension is **unknown** on every host.
487
-
488
- With several profiles, the exit code is `0` only if every plugin qualifies under every profile.
489
-
490
- ### The JSON document
491
-
492
- `--out <dir>` writes one interchange document per run (`lv2-plugin-measurements`, format 1.6,
493
- the same format the OpenMixer console reads). The
494
- `plugins` array holds the raw, host-free measurements. Beside it, `hostProfiles` holds each
495
- profile the run judged against, in full, and `verdicts` holds one entry per plugin per profile:
496
-
497
- ```json
498
- { "uri": "urn:example:verb", "host": "jalv", "rating": "unknown", "qualified": false,
499
- "deciding": "hosting.stability.unsoaked" }
500
- ```
501
-
502
- `host` is the `id` of an entry in `hostProfiles`. `deciding` is a stable code, absent when the
503
- plugin is `suitable`. If you have your own host rules, re-derive them from `plugins` and do not
504
- trust another host's verdict.
505
-
506
- ### As a library
507
-
508
- `classifyPluginHosting(plugin, { rate, quantum, profile })` gives one verdict, and
509
- `qualifyPlugins(measured, { profiles })` gives one per plugin per profile. `readHostProfile(json)`
510
- validates a profile file, and `judgedDocument(run, plugins, profiles, verdicts)` builds the
511
- document. All of them are exported from the package root. The package depends on no other
512
- OpenMixer package.
513
-
514
- ### Pinning what was qualified
515
-
516
- ```
517
- plugin-qualify --pins pins.txt ./my-synth.clap # a pin and a verdict per plugin, in a file plugin-hostd loads
518
- ```
519
-
520
- `--pins <file>` writes one line per qualified plugin, the same line as plugin-hostd's `pin_set` verb with the verdict as
521
- a last word: the SHA-256 of every file the host loads, the parameter-layout fingerprint, and `verdict=in-process`
522
- (suitable), `isolated` (conditional) or `refused` (unsuitable). A plugin with no verdict is pinned without one. Put the
523
- file in plugin-hostd's settings as `pin_file` and an `add` of that plugin needs no `pin_set`; a changed file is refused
524
- with -509. The verdict is the first `--host-profile`'s. A run that took no pin leaves an existing file alone.
525
-
526
- ### Qualifying every plugin on a machine
527
-
528
- `plugin-qualify-sync` qualifies every plugin installed on the machine, and again each one whose files
529
- change. With its pin file in plugin-hostd's settings, the daemon runs in its default mode (`require_pins 1`)
530
- and chains any plugin that qualified, the image's own and any installed later:
531
-
532
- ```
533
- pin_file /var/lib/omx/qualified.pins # in plugin-hostd.conf
534
- ```
535
-
536
- The package ships it as a service that runs two minutes after boot and whenever a plugin directory
537
- changes, once the directory has been quiet for a minute. A run finds every `.clap` under the CLAP
538
- directories and every plugin of every bundle under the LV2 ones, and hashes each one's files as
539
- plugin-hostd does for a pin. A plugin whose files hash as recorded is skipped. A new or changed one gets the
540
- quick qualification through the probe runner, on the grid `grid` names (see "The measurement grid"): its pin, the RT-safety sweep, cost and latency, its
541
- qualification record and its descriptor. The soak is not part of it, and every record it writes says so in
542
- `notMeasured` (`hosting.stability.unsoaked`).
543
-
544
- Each run of the runner (one cell of an LV2 plugin, one quantum of a CLAP one) has `budget_s` seconds, under `memory_limit`. One that crashes or hangs is recorded as `crashed` or `hung`, one that
545
- fails the sweep is pinned `verdict=refused`, and the run goes on to the next. The pin file and the state are
546
- written whole after every plugin, so a run cut short keeps what it finished. The last line counts the run:
547
-
548
- ```
549
- qualified 2, skipped 0, refused 0, crashed 1, hung 1, unmeasured 0, distributed 0, 4.6 s
550
- ```
551
-
552
- A plugin that is no longer found, or whose bytes changed, leaves nothing behind: its pin, its qualification
553
- record (with the probe output copied beside it) and its descriptor are removed. Records the sync did not write
554
- stay, and nothing is removed in a run that could not read a bundle, so a half-written install is no uninstall.
555
- A run looks only where its settings point: a plugin outside every directory of its format that the run scans is
556
- kept as it is (a run scoped to LV2 keeps every CLAP pin), and only one missing from a directory it scans is gone.
557
- A running plugin-hostd is told with `pin_clear`.
558
-
559
- When plugin-hostd answers on `hostd_port`, the changed pins are also sent to it with `pin_set` and apply at
560
- once. plugin-hostd serves one controller at a time, so while one is connected the run says so and the pins
561
- apply at the daemon's next start.
562
-
563
- The settings are `/etc/omx/qualify.conf`, one `key value` per line, and each is also a flag
564
- (`--budget-s 30`):
565
-
566
- | key | default |
567
- | --- | --- |
568
- | `lv2_path` | `$LV2_PATH`, else plugin-hostd's: `/usr/lib64/lv2:/usr/lib/lv2:/usr/local/lib/lv2` |
569
- | `lv2_extra` | `/zynthian/zynthian-plugins/lv2:/zynthian/zynthian-my-data/plugins/lv2` |
570
- | `clap_path`, `clap_extra` | `$CLAP_PATH`, then `~/.clap`, then `/usr/lib/clap`; none |
571
- | `pin_file` | `/var/lib/omx/qualified.pins` |
572
- | `qualification_dir`, `descriptor_dir` | `/var/lib/omx/qualification`, `/var/lib/omx/descriptors` |
573
- | `state_file` | `/var/lib/omx/qualify-sync.json` |
574
- | `budget_s` | `60` |
575
- | `host_profile` | `omx-clap-host` |
576
- | `grid` | `full`: every rate at every block size, 16 cells (`hosts`, or `--grid-hosts`: the cells of `host_profile` and of the console's two tiers, then 512 at every rate; or `<rate>/<quantum>,...`) |
577
- | `cells_per_plugin_budget` | `0`, every cell of the grid |
578
- | `memory_limit` | half of the machine's RAM (`off` for none) |
579
- | `memory_notes` | `16`: the notes an instrument with no declared capacity holds in a `--memory-only` run |
580
- | `hostd_port`, `hostd_feedback_port` | `5555`, the next port |
581
- | `host_class` | the board's own, from the device tree (`rpi5`); none elsewhere |
582
- | `distributed_dir` | `/usr/share/omx/qualification` |
583
-
584
- Those are the service's (`--system`); run by a user, the files go under `$XDG_STATE_HOME/omx`. An LV2
585
- directory added here belongs in plugin-hostd's `lv2_path` too, or the daemon cannot find the bundle it
586
- pinned. `--force` qualifies everything again, and `--no-distributed` reads no distributed record. A second `plugin-qualify-sync` waits for the run in progress (a lock on `<state_file>.lock`); `--no-wait` exits 0 at once and leaves a marker, and the holder runs once more at its end over what changed meanwhile.
38
+ Exit code: `0` every plugin qualified, `1` some did not, `2` something could not be measured.
39
+ `man plugin-qualify` and `man plugin-qualify-sync` describe the commands.
587
40
 
588
- Every run starts by bringing each pin's files up to the ones `plugin-hostd --pin-files` names, measuring nothing: a pin
589
- written before 0.17.3 lacks the preset files an LV2's manifest names, which plugin-hostd 0.15.0 also checks, and its add
590
- answers -509 until then. The new list replaces the old one when no file both lists hold changed its hash, so the verdict
591
- stands; a changed file is measured again by the run. `--refresh-pins` does only that, writes the pin file and sends the
592
- changed pins to a running plugin-hostd; the package's upgrade runs it (plugin-qualify#239).
41
+ ## Documentation
593
42
 
594
- The path unit names the standard folders (`/usr/lib/lv2`, `/usr/lib64/lv2`, `/usr/local/lib/lv2`,
595
- `/usr/lib/clap`, `/usr/lib64/clap`). The folders the settings file adds (`lv2_path`, `lv2_extra`, `clap_path`,
596
- `clap_extra`) are written into a drop-in of the unit by a systemd generator at boot and at every
597
- `systemctl daemon-reload`, so the file is the one place a machine's extra folders are written.
598
-
599
- The path unit watches a folder, not the bundles inside it: a new bundle appearing in a watched folder is
600
- seen at once, but a package's own files being replaced in place (an upgrade) is not a change of the folder
601
- itself. A packaged plugin is still caught at once, by the distribution's install triggers over the standard
602
- folders. An unpackaged bundle replaced in place in an extra folder is caught at the next run of
603
- plugin-qualify-sync.timer, at most about 15 minutes after the change.
604
-
605
- ### Keeping the records through an upgrade
606
-
607
- An upgrade of plugin-qualify, of the plugins' libraries or of the judge's rules does not call for measuring every plugin
608
- again. The figures in the records are carried across and judged again:
609
-
610
- ```bash
611
- plugin-qualify-sync --system --rejudge
612
- ```
613
-
614
- `--rejudge` measures nothing and needs no measuring tool. It reads every record in `qualification_dir` with the probe
615
- output it names, and passes them through the same judge a measurement goes through, under the rules of the running
616
- version: a real-time violation under `run()` or under notes is `conditional` and pinned `isolated`, each host's budget is
617
- read, and a plugin with no footprint is `unknown` on a host with a memory budget, never read as fitting. It also judges
618
- each record for `omx-clap-host`, `openmixer-console` and the Pi boards `openmixer-pi-4gb` and `openmixer-pi-8gb`. The
619
- verdicts, the tiers and the plugin's pin word are replaced; the figures, the probe output and who took them stay as they
620
- were. The record gains a `rejudged` block with `at`, `by` (the version that judged) and `figures` (`tool`, `measuredAt`
621
- and `runId`, as measured). A run that finds nothing to change writes nothing.
622
-
623
- A verdict never says more than the figures do. An LV2 plugin's `omx-clap-host` verdict is decided by its run under the LV2
624
- adapter (the record's `adapter` block). A record with no measured adapter run reads `unknown`
625
- (`qualify.sync.adapter-run-owed`), is not offered on the isolated tier, and its pin has no verdict word: plugin-hostd loads
626
- such a plugin without grouping it. The same goes for a `mod-host` or `jalv` verdict with no run under that host. A record
627
- whose plugin's bytes changed, or whose probe output is not the copy it names, is left as it is. Everything left owing
628
- is listed under `owes` in the record and in `qualify-owed.json` beside the state file (`--rejudge` prints each line too),
629
- with the measurement it needs:
630
-
631
- | `measurement` | code | what pays it |
632
- |---|---|---|
633
- | `adapter-run` | `qualify.sync.adapter-run-owed` | `--adapter-only`, then `--rejudge` |
634
- | `host-run` | `qualify.sync.host-run-owed` | a run under that host; today, qualifying the plugin again with `--host-profile` |
635
- | `qualification` | `qualify.sync.bytes-changed`, `qualify.sync.figures-unreadable`, `qualify.sync.no-figures` | qualifying the plugin again; the next run does so for changed bytes |
636
-
637
- A probe that crashed took no figures: its record stays `unknown` with `probe.crashed`, no pin word and the state's
638
- outcome `crashed`. A crash under a host's run stays `unsuitable` and `refused`.
639
-
640
- Footprints older records lack are taken apart from everything else:
641
-
642
- ```bash
643
- plugin-qualify-sync --system --memory-only # the footprints, at the cells a Pi runs
644
- plugin-qualify-sync --system --rejudge # the verdicts that read them
645
- ```
646
-
647
- `--memory-only` takes the footprint of each plugin the state holds (after instantiate, after activate, at rest, a second
648
- instance, and notes held for an instrument) at 48 kHz in blocks of 128 and 256 frames, and nothing else: no sweep, no
649
- cost, no latency, no lifecycle, no soak. It goes through the probe runner under the limits of an ordinary run (`budget_s`,
650
- `memory_limit`), each plugin in processes of its own, an LV2 plugin in a world of its own bundle. An instrument that
651
- declares its voice capacity holds that many notes; one that does not holds `memory_notes`, with basis `probe`. Only the
652
- `memory` of those cells changes in the record, a cell it lacked joins with no other figure, and a `memoryRun` block says
653
- which runner took them and when. A cell whose run failed holds a reason and no figure. A plugin whose footprints are
654
- already merged is skipped, so a pass cut short can be run again; `--force` takes them again. The pass judges nothing:
655
- `--rejudge` afterwards does.
656
-
657
- The adapter runs a record owes are taken apart as well:
658
-
659
- ```bash
660
- plugin-qualify-sync --system --adapter-only # the runs, at the cells a Pi runs
661
- plugin-qualify-sync --system --rejudge # the verdicts they decide
662
- ```
663
-
664
- `--isolated-only` runs the isolated step a record still owes: a plugin rated conditional, or one that crashed or hung in
665
- the probe, added alone under a plugin-hostd of its own on a private JACK server, with a sine through it for 3 seconds. The
666
- record's `run` keeps its id and gains the step, and the pin follows it; nothing else is measured. A step the machine could
667
- not run stays owed.
668
-
669
- `--adapter-only` starts each LV2 plugin the state holds under a fresh omx-clap-host, `add` then `remove` through the LV2
670
- adapter, once per cell: 48 kHz in blocks of 128 and 256 frames. Each cell has a private JACK server (`jackd -d dummy`, so
671
- `jackd` must be installed), so the device's live server is never touched. A command has `budget_s` seconds, the host's
672
- address space is limited as a direct spawn's is, and it is killed with whatever it started. Only the record's `adapter`
673
- block changes: `versions` (omx-clap-host and plugin-hostd, read from `--version`), `measuredAt`, `cells`, and at its top
674
- what the judge reads. A crash or a command that gets no answer in time is the plugin's recorded outcome, at the cell it
675
- happened, and reads `unsuitable` at the next `--rejudge`. A host that could not be run decides nothing: the block says
676
- `measured: false`, and the run stays owed and is tried again. The pass refuses to start, writing nothing, when a host does
677
- not say its version or there is no `jackd`. A plugin whose block was taken at these host versions is skipped, so a pass
678
- cut short can be run again; a host upgrade or `--force` takes them again. A plugin whose probe failed is skipped: it has
679
- no verdict a run could decide.
680
-
681
- **A leak is not measured.** Growth over time takes a soak, which this pass does not run: every record it writes names
682
- `memoryGrowth` under `notMeasured`, and the last line says `leaks not measured`.
683
-
684
- On a Raspberry Pi 5 a plugin takes under 0.6 s at the median, 5 s at the 90th percentile and 22 s at the 99th; about an
685
- hour at most for the 1,542 plugins of the Zynthian set. Those are upper bounds: twice the time the 2026-10-28 records say a
686
- whole pass at one cell took, which also ran 30 lifecycles, the sweep, the latency and the cost. A plugin that hangs
687
- takes `budget_s` a cell. The test fixtures take 0.1 s each, both cells, on an x86-64 desktop.
688
-
689
- ### Distributed records
690
-
691
- A device that ships with a known plugin set need not measure it again at first start. The qualification records
692
- another device of the same machine class wrote for that set are installed read-only, and the sync takes from them
693
- every plugin whose files are the bytes that were measured:
694
-
695
- ```
696
- /usr/share/omx/qualification/<host class>/<set>/ # system (--system)
697
- $XDG_DATA_HOME/omx/qualification/<host class>/<set>/ # a user run, then each $XDG_DATA_DIRS/omx/qualification
698
- ```
699
-
700
- Each `<set>` directory holds the records (`*.qualification.json`, each with its probe output `*.probe.json`) and a
701
- `provenance.json` that says where they come from: the set, the host class, the image, the package versions of the
702
- measuring device, the date and the package that installed them. The host class is a directory name: `host_class`
703
- in the settings, or, when it is unset, the board's own name read from the device tree (`rpi5` on a Raspberry Pi 5,
704
- `rpi4` on a Raspberry Pi 4). Where neither gives one, the layer is not read and the run says so.
705
-
706
- Before a plugin is measured, the sync looks for a distributed record of it: a CLAP record whose binary is the same
707
- `.clap` path, or an LV2 record of the same plugin uri. The name only finds the record; a record applies when, and
708
- only when:
709
-
710
- - every file of its `binary.files` is installed at that path relative to the plugin's root, with that SHA-256,
711
- computed on the device;
712
- - its schema is the one this version writes and its probe output is one this version's reader accepts, the copy
713
- intact (its SHA-256 the one the record names): the same acceptance the sync gives a record of its own, which it
714
- never measures again for a newer tool;
715
- - it holds a verdict for the sync's `host_profile`, and that verdict ended `qualified`, `refused`, `crashed` or
716
- `hung`. One that could not be measured at the source (`unmeasured`) is measured here.
717
-
718
- A plugin whose distributed records apply is not measured: the records are copied into `qualification_dir` with the
719
- set's `provenance` object added, its pin is taken from the probe output the record judged, its descriptor is
720
- written, and the run says
721
-
722
- ```
723
- distributed: http://distrho.sf.net/plugins/3BandEQ: applied (zynthian-vangelis)
724
- ```
725
-
726
- A plugin with a distributed record whose files differ is measured as before, and the run says
727
- `distributed: <uri>: binary differs`; one with no distributed record is measured without a word. The summary counts
728
- the plugins taken from the layer, `distributed N`, apart from the ones measured and skipped. A later run treats an
729
- applied plugin like one it measured: skipped while its files are unchanged, measured when they change. `--force`
730
- measures everything and reads no distributed record; `--no-distributed` keeps the layer out of a run.
731
-
732
- The package that installs a set is built from a device's `qualification_dir` by `plugin-qualify-records-package`:
733
-
734
- ```
735
- plugin-qualify-records-package /var/lib/omx/qualification --host-class rpi5 --set zynthian-vangelis \
736
- --provenance provenance.json --out .
737
- ```
738
-
739
- It writes `omx-qualification-<host class>-<set>_<version>_all.deb`, the version the build date
740
- (`2026.10.29`) unless `--version` names one. The package installs the records under
741
- `/usr/share/omx/qualification/<host class>/<set>/` with `provenance.json`, depends on the plugin-qualify that reads
742
- the layer, and is `Architecture: all`. `--provenance` names a JSON file (or holds the JSON itself) with what the tool
743
- cannot know: the image, the package versions and the source revisions of the device. The tool adds the set, the host
744
- class, the package and the date. A record whose probe output is missing or does not match its SHA-256 is left out
745
- and named; a record of a plugin that could not be measured (`*.unmeasured.json`) is left out, since it never
746
- applies. The tool builds the Debian package only; an RPM of a set is not built yet.
747
-
748
- ### The container image
749
-
750
- ```
751
- podman build -t plugin-qualify -f Containerfile .
752
- podman run --rm -v "$PWD/my-plugins:/plugins:ro" -e LV2_PATH=/plugins -v "$PWD/out:/out" \
753
- plugin-qualify --host-profile jalv --all --out /out
754
- ```
43
+ <https://www.openmixer.org/projects/plugin-qualify>, and in this repository the full guide
44
+ ([docs/overview.md](docs/overview.md)): the measurement grid, the verdicts and what they mean,
45
+ host profiles, the JSON document, qualifying every plugin on a machine and keeping the records.
46
+ Building from a checkout: [BUILDING.md](BUILDING.md).
755
47
 
756
- The image has lilv, the distribution's mod-host and an AddressSanitizer build of mod-host.
48
+ ## Licence
757
49
 
758
- **Status:** the pure steps are in place and tested: target resolution, profile resolution, the
759
- verdict, the document and the exit code. The command-line entry point does not yet drive the
760
- measuring tools (`tools/lv2-measure.mjs`, the mod-host sweep). A run that would measure exits
761
- `2` and names the missing step, rather than rate a plugin on less than was asked.
50
+ GPL-3.0-or-later. See [LICENSE](LICENSE).