@openmixer/plugin-qualify 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +93 -3
  2. package/bin/plugin-qualify-sync.mjs +3 -1
  3. package/dist/clap-qualify.d.ts +3 -1
  4. package/dist/clap-qualify.js +3 -1
  5. package/dist/clap-qualify.js.map +1 -1
  6. package/dist/distributed-records.d.ts +4 -2
  7. package/dist/distributed-records.js +1 -1
  8. package/dist/distributed-records.js.map +1 -1
  9. package/dist/host-admission.d.ts +21 -0
  10. package/dist/host-admission.js +33 -3
  11. package/dist/host-admission.js.map +1 -1
  12. package/dist/hostd-pin-files.js +3 -1
  13. package/dist/hostd-pin-files.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/measurement/format.d.ts +10 -0
  18. package/dist/measurement/format.js +8 -0
  19. package/dist/measurement/format.js.map +1 -1
  20. package/dist/measurement/probe-json.js +2 -1
  21. package/dist/measurement/probe-json.js.map +1 -1
  22. package/dist/probe-judge.d.ts +11 -0
  23. package/dist/probe-judge.js +6 -3
  24. package/dist/probe-judge.js.map +1 -1
  25. package/dist/probe-runner.js +3 -2
  26. package/dist/probe-runner.js.map +1 -1
  27. package/dist/qualification-record.d.ts +113 -8
  28. package/dist/qualification-record.js +18 -13
  29. package/dist/qualification-record.js.map +1 -1
  30. package/dist/qualify-adapter-pass.d.ts +67 -0
  31. package/dist/qualify-adapter-pass.js +255 -0
  32. package/dist/qualify-adapter-pass.js.map +1 -0
  33. package/dist/qualify-declarations.d.ts +30 -0
  34. package/dist/qualify-declarations.js +24 -1
  35. package/dist/qualify-declarations.js.map +1 -1
  36. package/dist/qualify-isolated.d.ts +70 -0
  37. package/dist/qualify-isolated.js +184 -0
  38. package/dist/qualify-isolated.js.map +1 -0
  39. package/dist/qualify-rejudge.d.ts +82 -0
  40. package/dist/qualify-rejudge.js +434 -0
  41. package/dist/qualify-rejudge.js.map +1 -0
  42. package/dist/qualify-sync.d.ts +97 -1
  43. package/dist/qualify-sync.js +276 -24
  44. package/dist/qualify-sync.js.map +1 -1
  45. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.probe.json +46 -0
  46. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.qualification.json +185 -0
  47. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.probe.json +51 -0
  48. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.qualification.json +232 -0
  49. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.probe.json +51 -0
  50. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.qualification.json +245 -0
  51. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  52. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.qualification.json +145 -0
  53. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  54. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.qualification.json +155 -0
  55. package/package.json +1 -1
  56. package/src/clap-qualify.ts +5 -1
  57. package/src/distributed-records.ts +1 -1
  58. package/src/host-admission.ts +40 -3
  59. package/src/hostd-pin-files.ts +3 -1
  60. package/src/index.ts +2 -0
  61. package/src/measurement/format.ts +17 -0
  62. package/src/measurement/probe-json.ts +2 -1
  63. package/src/probe-judge.ts +10 -3
  64. package/src/probe-runner.ts +2 -2
  65. package/src/qualification-record.ts +126 -15
  66. package/src/qualify-adapter-pass.ts +278 -0
  67. package/src/qualify-declarations.ts +26 -1
  68. package/src/qualify-isolated.ts +201 -0
  69. package/src/qualify-rejudge.ts +466 -0
  70. package/src/qualify-sync.ts +295 -24
  71. package/tools/isolated-step.mjs +122 -0
  72. package/tools/lv2_resolve.py +49 -0
  73. package/tools/mod-host-admit.mjs +99 -7
@@ -0,0 +1,145 @@
1
+ {
2
+ "schema": "plugin-qualify.qualification-record/1",
3
+ "plugin": {
4
+ "format": "clap",
5
+ "id": "in.lsp-plug.impulse_responses_mono"
6
+ },
7
+ "binary": {
8
+ "path": "/usr/lib/clap/lsp-plugins.clap",
9
+ "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2",
10
+ "files": [
11
+ {
12
+ "path": "lsp-plugins.clap",
13
+ "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2"
14
+ }
15
+ ],
16
+ "layout": {
17
+ "scheme": "omx-layout/1",
18
+ "sha256": "86f71f835f0db0150b6b219b738b97ae0d9d423d2305e29ebdb172b8e20a6381"
19
+ }
20
+ },
21
+ "verdicts": [
22
+ {
23
+ "host": "omx-clap-host",
24
+ "measured": true,
25
+ "rating": "unsuitable",
26
+ "qualified": false,
27
+ "hosting": "isolated",
28
+ "arm": "declaration",
29
+ "deciding": "hosting.rt-safety.violations-in-run",
30
+ "reasons": [
31
+ {
32
+ "dimension": "rtSafety",
33
+ "rating": "unsuitable",
34
+ "code": "hosting.rt-safety.violations-in-run",
35
+ "params": {
36
+ "allocations": 0,
37
+ "syscalls": 1,
38
+ "locks": 0
39
+ }
40
+ }
41
+ ]
42
+ }
43
+ ],
44
+ "tiers": {
45
+ "isolated": {
46
+ "host": "omx-clap-host",
47
+ "measured": true,
48
+ "qualified": false,
49
+ "rating": "unsuitable",
50
+ "deciding": "hosting.rt-safety.violations-in-run"
51
+ },
52
+ "offeredOn": [],
53
+ "dimmed": "omx-clap-host: hosting.rt-safety.violations-in-run"
54
+ },
55
+ "cost": [
56
+ {
57
+ "host": "omx-clap-host",
58
+ "rate": 48000,
59
+ "quantum": 128,
60
+ "reasons": [
61
+ {
62
+ "dimension": "cost",
63
+ "rating": "unknown",
64
+ "code": "hosting.cost.unmeasured-at-rate-and-quantum",
65
+ "params": {
66
+ "rate": 48000,
67
+ "quantum": 128
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ ],
73
+ "measurements": {
74
+ "grid": [
75
+ {
76
+ "rate": 48000,
77
+ "quantum": 512,
78
+ "outcome": "probe.ok",
79
+ "latency": {
80
+ "declaredFrames": 0,
81
+ "measuredFrames": 0
82
+ },
83
+ "cost": {
84
+ "median": 213.506,
85
+ "percentile": {
86
+ "p": 95,
87
+ "value": 230.143
88
+ },
89
+ "blocks": 512,
90
+ "warmupBlocks": 64
91
+ },
92
+ "rtSafety": {
93
+ "interposer": true,
94
+ "swept": true,
95
+ "sweepApplicable": true,
96
+ "blocks": 512,
97
+ "allocations": 0,
98
+ "locks": 0,
99
+ "syscalls": 1,
100
+ "threadViolations": 0,
101
+ "processErrors": 0,
102
+ "events": [
103
+ "nanosleep"
104
+ ]
105
+ },
106
+ "noteSweep": {
107
+ "applicable": false
108
+ }
109
+ }
110
+ ]
111
+ },
112
+ "notMeasured": [
113
+ {
114
+ "dimension": "stability",
115
+ "code": "hosting.stability.unsoaked"
116
+ }
117
+ ],
118
+ "tool": {
119
+ "runner": "plugin-hostd-probe 0.13.1",
120
+ "child": "clap-qualify",
121
+ "interposer": "rt_interpose.so"
122
+ },
123
+ "measuredAt": "2026-10-28T20:56:58+00:00",
124
+ "probe": {
125
+ "file": "in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json",
126
+ "sha256": "bc818dfeff46238de3b5558ab8d54aa5677a18f6e3cad79c67bc117313d0a79d",
127
+ "bytes": 3894,
128
+ "protocol": {
129
+ "name": "plugin-hostd.probe-protocol",
130
+ "version": 1
131
+ },
132
+ "reason": "on-request",
133
+ "outcome": "probe.ok",
134
+ "complete": true,
135
+ "machineClass": {
136
+ "arch": "aarch64",
137
+ "family": "arm",
138
+ "coreBand": "3-4",
139
+ "clockBand": "1.5-2.5GHz",
140
+ "preemptRt": false,
141
+ "cstatesLimited": false
142
+ },
143
+ "meetsProtocol": false
144
+ }
145
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "format": "plugin-hostd.probe-output",
3
+ "formatVersion": "1.1",
4
+ "run": {
5
+ "id": "20261028T210152Z-321dd-bb8cc3c7",
6
+ "startedAt": "2026-10-28T21:01:52+00:00",
7
+ "elapsedMs": 332,
8
+ "complete": true,
9
+ "outcome": "probe.ok",
10
+ "reason": "on-request",
11
+ "protocol": {"name": "plugin-hostd.probe-protocol", "version": 1},
12
+ "tools": {"runner": "plugin-hostd-probe 0.13.1", "child": "clap-qualify", "interposer": "rt_interpose.so"},
13
+ "via": "runner"
14
+ },
15
+ "plugin": {"format": "clap", "id": "in.lsp-plug.trigger_midi_mono"},
16
+ "binary": {"path": "/usr/lib/clap/lsp-plugins.clap", "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2", "files": [{"path": "lsp-plugins.clap", "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2"}], "layout": {"scheme": "omx-layout/1", "sha256": "5a2b073f777379e75dcd931ff9930352d12dcfab9e1fb599d3b1d45d62fe3e1d"}},
17
+ "machine": {
18
+ "class": {"arch": "aarch64", "family": "arm", "coreBand": "3-4", "clockBand": "1.5-2.5GHz", "preemptRt": false, "cstatesLimited": false},
19
+ "profile": {"governor": "powersave", "turbo": false, "smt": false, "preempt": "PREEMPT",
20
+ "cpuidle": [],
21
+ "meetsProtocol": false, "unmet": ["probe.profile.governor", "probe.profile.not-pinned"]},
22
+ "pinnedCpus": "0-3"
23
+ },
24
+ "offer": {"id": "custom", "clapExtensions": ["clap.log", "clap.thread-check", "clap.latency", "clap.params", "clap.audio-ports", "clap.state", "clap.note-ports", "clap.note-name", "clap.voice-info", "clap.event-registry", "clap.timer-support", "clap.posix-fd-support", "clap.thread-pool", "clap.tail", "clap.surround/4", "clap.ambisonic/3", "clap.audio-ports-config", "clap.preset-load/2", "clap.track-info/1", "clap.remote-controls/2"], "realtime": "none"},
25
+ "limits": {"set": "default",
26
+ "applied": [
27
+ {"limit": "wall-clock", "value": 60, "unit": "s", "by": "supervisor"},
28
+ {"limit": "cpu-time", "value": 540, "unit": "s", "by": "rlimit"},
29
+ {"limit": "memory", "value": 4294967296, "unit": "bytes", "by": "rlimit"},
30
+ {"limit": "files", "value": 256, "unit": "count", "by": "rlimit"},
31
+ {"limit": "file-size", "value": 67108864, "unit": "bytes", "by": "rlimit"},
32
+ {"limit": "core-dump", "value": 0, "unit": "bytes", "by": "rlimit"},
33
+ {"limit": "rt-priority", "value": 0, "by": "rlimit"},
34
+ {"limit": "no-new-privs", "by": "prctl"}
35
+ ],
36
+ "unavailable": [
37
+ {"limit": "tasks", "code": "probe.limit.not-implemented"}
38
+ ]
39
+ },
40
+ "steps": [
41
+ {"step": "hash", "outcome": "probe.ok", "elapsedMs": 0},
42
+ {"step": "lifecycle", "outcome": "probe.ok", "elapsedMs": 332, "exitStatus": 0},
43
+ {"step": "instantiate", "outcome": "probe.ok", "elapsedMs": 0, "detail": "read from the child's line"},
44
+ {"step": "latency", "rate": 48000, "quantum": 512, "outcome": "probe.ok", "elapsedMs": 0, "detail": "read from the child's line"},
45
+ {"step": "rt-safety", "outcome": "probe.ok", "elapsedMs": 0, "detail": "counts are totals over every rate, read from the child's line"},
46
+ {"step": "note-sweep", "outcome": "probe.ok", "elapsedMs": 0, "detail": "counts are totals over every rate, read from the child's line"}
47
+ ],
48
+ "grid": [
49
+ {"rate": 48000, "quantum": 512, "outcome": "probe.ok", "latency": {"declaredFrames": 0, "measuredFrames": 0, "method": "impulse-onset"}, "cost": {"unit": "ns-per-sample", "median": 47.9961, "percentile": {"p": 95, "value": 57.2365}, "min": 31.5742, "max": 114.258, "blocks": 512, "warmupBlocks": 64}, "rtSafety": {"interposer": true, "swept": true, "sweepApplicable": true, "blocks": 512, "allocations": 0, "locks": 0, "syscalls": 0, "threadViolations": 0, "processErrors": 0, "events": []}, "noteSweep": {"applicable": true, "dialect": "midi", "allocations": 0, "locks": 0, "syscalls": 1}}
50
+ ],
51
+ "logs": [{"name": "stderr", "path": "/tmp/plugin-qualify-probe-0t8B87/child.stderr.log", "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "bytes": 0}]
52
+ }
@@ -0,0 +1,155 @@
1
+ {
2
+ "schema": "plugin-qualify.qualification-record/1",
3
+ "plugin": {
4
+ "format": "clap",
5
+ "id": "in.lsp-plug.trigger_midi_mono"
6
+ },
7
+ "binary": {
8
+ "path": "/usr/lib/clap/lsp-plugins.clap",
9
+ "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2",
10
+ "files": [
11
+ {
12
+ "path": "lsp-plugins.clap",
13
+ "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2"
14
+ }
15
+ ],
16
+ "layout": {
17
+ "scheme": "omx-layout/1",
18
+ "sha256": "5a2b073f777379e75dcd931ff9930352d12dcfab9e1fb599d3b1d45d62fe3e1d"
19
+ }
20
+ },
21
+ "verdicts": [
22
+ {
23
+ "host": "omx-clap-host",
24
+ "measured": true,
25
+ "rating": "unsuitable",
26
+ "qualified": false,
27
+ "hosting": "isolated",
28
+ "arm": "declaration",
29
+ "deciding": "hosting.rt-safety.violations-under-notes",
30
+ "reasons": [
31
+ {
32
+ "dimension": "rtSafety",
33
+ "rating": "suitable",
34
+ "code": "hosting.rt-safety.clean-under-sweep",
35
+ "params": {
36
+ "blocks": 512
37
+ }
38
+ },
39
+ {
40
+ "dimension": "rtSafety",
41
+ "rating": "unsuitable",
42
+ "code": "hosting.rt-safety.violations-under-notes",
43
+ "params": {
44
+ "allocations": 0,
45
+ "syscalls": 1,
46
+ "locks": 0
47
+ }
48
+ }
49
+ ]
50
+ }
51
+ ],
52
+ "tiers": {
53
+ "isolated": {
54
+ "host": "omx-clap-host",
55
+ "measured": true,
56
+ "qualified": false,
57
+ "rating": "unsuitable",
58
+ "deciding": "hosting.rt-safety.violations-under-notes"
59
+ },
60
+ "offeredOn": [],
61
+ "dimmed": "omx-clap-host: hosting.rt-safety.violations-under-notes"
62
+ },
63
+ "cost": [
64
+ {
65
+ "host": "omx-clap-host",
66
+ "rate": 48000,
67
+ "quantum": 128,
68
+ "reasons": [
69
+ {
70
+ "dimension": "cost",
71
+ "rating": "unknown",
72
+ "code": "hosting.cost.unmeasured-at-rate-and-quantum",
73
+ "params": {
74
+ "rate": 48000,
75
+ "quantum": 128
76
+ }
77
+ }
78
+ ]
79
+ }
80
+ ],
81
+ "measurements": {
82
+ "grid": [
83
+ {
84
+ "rate": 48000,
85
+ "quantum": 512,
86
+ "outcome": "probe.ok",
87
+ "latency": {
88
+ "declaredFrames": 0,
89
+ "measuredFrames": 0
90
+ },
91
+ "cost": {
92
+ "median": 47.9961,
93
+ "percentile": {
94
+ "p": 95,
95
+ "value": 57.2365
96
+ },
97
+ "blocks": 512,
98
+ "warmupBlocks": 64
99
+ },
100
+ "rtSafety": {
101
+ "interposer": true,
102
+ "swept": true,
103
+ "sweepApplicable": true,
104
+ "blocks": 512,
105
+ "allocations": 0,
106
+ "locks": 0,
107
+ "syscalls": 0,
108
+ "threadViolations": 0,
109
+ "processErrors": 0,
110
+ "events": []
111
+ },
112
+ "noteSweep": {
113
+ "applicable": true,
114
+ "dialect": "midi",
115
+ "allocations": 0,
116
+ "locks": 0,
117
+ "syscalls": 1
118
+ }
119
+ }
120
+ ]
121
+ },
122
+ "notMeasured": [
123
+ {
124
+ "dimension": "stability",
125
+ "code": "hosting.stability.unsoaked"
126
+ }
127
+ ],
128
+ "tool": {
129
+ "runner": "plugin-hostd-probe 0.13.1",
130
+ "child": "clap-qualify",
131
+ "interposer": "rt_interpose.so"
132
+ },
133
+ "measuredAt": "2026-10-28T21:01:52+00:00",
134
+ "probe": {
135
+ "file": "in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json",
136
+ "sha256": "17986ad33bc5087175fcce9512761cc28fb66d18beb01598ee2e72b5c5302fa6",
137
+ "bytes": 3978,
138
+ "protocol": {
139
+ "name": "plugin-hostd.probe-protocol",
140
+ "version": 1
141
+ },
142
+ "reason": "on-request",
143
+ "outcome": "probe.ok",
144
+ "complete": true,
145
+ "machineClass": {
146
+ "arch": "aarch64",
147
+ "family": "arm",
148
+ "coreBand": "3-4",
149
+ "clockBand": "1.5-2.5GHz",
150
+ "preemptRt": false,
151
+ "cstatesLimited": false
152
+ },
153
+ "meetsProtocol": false
154
+ }
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmixer/plugin-qualify",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Qualify audio plugins for real-time hosting: scan, offline latency and cost measurement, and a fail-closed rating.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -36,7 +36,7 @@ import {
36
36
  import type { QualifyVerdict } from "./qualify-cli.js";
37
37
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
38
38
  import { halfPopulatedRefusal, locateNativeTools } from "./tool-paths.js";
39
- import { readIntegrity, readMemoryFootprint, type IntegrityMeasurement, type MemoryFootprint, type VoicesHeld } from "./measurement/format.js";
39
+ import { readIntegrity, readMemoryFootprint, readTimingScheduling, type IntegrityMeasurement, type MemoryFootprint, type TimingScheduling, type VoicesHeld } from "./measurement/format.js";
40
40
  import { isVoiceBasis } from "./measurement/vocabulary.js";
41
41
  import { coreFraction } from "./cost-fields.js";
42
42
 
@@ -77,6 +77,8 @@ export interface ClapCostFigure {
77
77
  readonly blocks: number;
78
78
  /** For an instrument: the notes held while it was timed. Absent for an effect. */
79
79
  readonly voices?: VoicesHeld;
80
+ /** What these blocks were timed under. */
81
+ readonly scheduling?: TimingScheduling;
80
82
  }
81
83
 
82
84
  /** One rate the cost arm did not time, and why; an instrument's declared capacity is kept when it has one. */
@@ -267,6 +269,7 @@ function readCostAtRate(v: unknown, where: string, notes: boolean): ClapCostAtRa
267
269
  }
268
270
  const blocks = count(v, "blocks", where);
269
271
  if (blocks === 0) return bad(`${where} timed no block`);
272
+ const scheduling = readTimingScheduling(v["scheduling"]);
270
273
  return {
271
274
  nsPerSampleMedian: figure(v, "nsPerSampleMedian", where),
272
275
  nsPerSampleP95: figure(v, "nsPerSampleP95", where),
@@ -277,6 +280,7 @@ function readCostAtRate(v: unknown, where: string, notes: boolean): ClapCostAtRa
277
280
  ...(voices?.held === undefined || voices.basis === undefined
278
281
  ? {}
279
282
  : { voices: { held: voices.held, basis: voices.basis, ...(voices.capacity === undefined ? {} : { capacity: voices.capacity }) } }),
283
+ ...(scheduling === undefined ? {} : { scheduling }),
280
284
  };
281
285
  }
282
286
 
@@ -153,7 +153,7 @@ export function distributedUri(item: SyncItem, r: DistributedRecord): string {
153
153
  }
154
154
 
155
155
  /** What the sync's own judgment would make of the record's verdict for the host, as `outcomeOf` reads a run. */
156
- function outcomeOfVerdict(v: RecordVerdict): SyncOutcome {
156
+ export function outcomeOfVerdict(v: Pick<RecordVerdict, "measured" | "rating" | "deciding">): SyncOutcome {
157
157
  if (!v.measured) return v.deciding === "probe.crashed" ? "crashed" : v.deciding === "probe.timeout" ? "hung" : "unmeasured";
158
158
  if (v.rating === "suitable" || v.rating === "conditional") return "qualified";
159
159
  return v.rating === "unsuitable" ? "refused" : "unmeasured";
@@ -12,6 +12,7 @@ import { spawnSync } from "node:child_process";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
14
  import type { HostProfile } from "./host-profiles.js";
15
+ import { ADMIT_STEP_MS_ENV } from "./qualify-declarations.js";
15
16
 
16
17
  /** The profile ids whose verdict is measured by a run. */
17
18
  export const ADMITTED_HOSTS = ["mod-host", "jalv"] as const;
@@ -29,13 +30,16 @@ export function admittedHostOf(profile: HostProfile): AdmittedHost | undefined {
29
30
  */
30
31
  export const ADAPTER_HOST = "omx-clap-host";
31
32
 
33
+ /** The profiles whose LV2 plugins run under that adapter: omx-clap-host, and the Pi boards it runs on (`host-profiles.ts`). */
34
+ export const ADAPTER_RUN_HOSTS: readonly string[] = [ADAPTER_HOST, "openmixer-pi-4gb", "openmixer-pi-8gb"];
35
+
32
36
  /**
33
37
  * Whether this run decides the profile's verdict for a plugin of this format. A mod-host or jalv run always does, an
34
38
  * unmeasured one making the verdict unknown; the adapter's only when it measured, since the omx-clap-host verdict
35
39
  * otherwise stands on the measurement.
36
40
  */
37
41
  export function runDecides(profile: HostProfile, format: string, admission: HostAdmission): boolean {
38
- return admittedHostOf(profile) !== undefined || (profile.id === ADAPTER_HOST && format === "lv2" && admission.measured);
42
+ return admittedHostOf(profile) !== undefined || (ADAPTER_RUN_HOSTS.includes(profile.id) && format === "lv2" && admission.measured);
39
43
  }
40
44
 
41
45
  /** What running the plugin under one host came to. */
@@ -74,6 +78,28 @@ export interface ModHostRun {
74
78
  /** False when the host never took a connection (omx-clap-host with no JACK server): no plugin was loaded. */
75
79
  readonly started?: boolean;
76
80
  readonly steps: readonly ModHostStep[];
81
+ /**
82
+ * An instrument under the adapter, played one held note at its JACK ports: the peak in linear magnitude, or why it could
83
+ * not be read. Absent for a plugin that is no instrument, and for any plugin under mod-host.
84
+ */
85
+ readonly sound?: { readonly peak?: number; readonly problem?: string };
86
+ }
87
+
88
+ /** At or under this magnitude (-120 dBFS) a held note is taken as digital silence: clap-qualify's own SILENCE. */
89
+ export const SILENT_PEAK = 1e-6;
90
+
91
+ /**
92
+ * The codes of an adapter run that took the instrument and could not show it sounding. No record of an instrument that makes
93
+ * no sound reads as qualified; but a sampler with nothing loaded is silent too, so this reads `unknown`, not refused: the
94
+ * plugin stays loadable and its pin has no verdict word (`host-admission.test.ts`).
95
+ */
96
+ export const ADAPTER_SILENT_CODE = `${ADAPTER_HOST}.silent-under-note`;
97
+ export const ADAPTER_LEVEL_UNMEASURED_CODE = `${ADAPTER_HOST}.level-unmeasured`;
98
+ const UNPROVEN_SOUND: readonly string[] = [ADAPTER_SILENT_CODE, ADAPTER_LEVEL_UNMEASURED_CODE];
99
+
100
+ /** The rating a run that did not admit the plugin gives its host's verdict. */
101
+ export function admissionRating(admission: HostAdmission): "unsuitable" | "unknown" {
102
+ return !admission.measured || UNPROVEN_SOUND.includes(admission.code ?? "") ? "unknown" : "unsuitable";
77
103
  }
78
104
 
79
105
  /** The verdict of a run of a host that speaks mod-host's protocol: the first step that did not answer `resp 0` decides. */
@@ -91,6 +117,11 @@ export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAd
91
117
  if (s.resp < 0) return refused(host, `resp-${-s.resp}`, { step: s.cmd, resp: s.resp });
92
118
  }
93
119
  if (run.steps.length === 0) return refused(host, "no-answer");
120
+ if (run.sound !== undefined) {
121
+ const { peak, problem } = run.sound;
122
+ if (peak === undefined) return { host, measured: true, admitted: false, code: ADAPTER_LEVEL_UNMEASURED_CODE, params: { problem: problem ?? "no figure" } };
123
+ if (!(peak > SILENT_PEAK)) return { host, measured: true, admitted: false, code: ADAPTER_SILENT_CODE, params: { peak } };
124
+ }
94
125
  return admitted(host);
95
126
  }
96
127
 
@@ -144,7 +175,10 @@ const MOD_HOST_ADMIT = fileURLToPath(new URL("../tools/mod-host-admit.mjs", impo
144
175
  /** The runs against the installed hosts. */
145
176
  export const SPAWNED_ADMISSIONS: AdmissionRuns = {
146
177
  adapter(uri, env) {
147
- const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri, ADAPTER_HOST], { encoding: "utf8", timeout: MOD_HOST_ADMISSION_MS, ...(env === undefined ? {} : { env: { ...env } }) });
178
+ // two commands at the limit the pass gives each, and the tool's own start and end: it kills the host itself first
179
+ const stepMs = Number(env?.[ADMIT_STEP_MS_ENV] ?? 0);
180
+ const timeout = stepMs > 0 ? 2 * stepMs + 15000 : MOD_HOST_ADMISSION_MS;
181
+ const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri, ADAPTER_HOST], { encoding: "utf8", timeout, killSignal: "SIGKILL", ...(env === undefined ? {} : { env: { ...env } }) });
148
182
  if (r.error !== undefined || r.status !== 0) {
149
183
  throw new Error(`${ADAPTER_HOST} admission of ${uri} gave no answer (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim()}`);
150
184
  }
@@ -158,7 +192,10 @@ export const SPAWNED_ADMISSIONS: AdmissionRuns = {
158
192
  return JSON.parse(r.stdout) as ModHostRun;
159
193
  },
160
194
  jalv(uri, env) {
161
- const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, input: "", ...(env === undefined ? {} : { env: { ...env } }) });
195
+ // SIGKILL at the deadline: jalv hosting some plugins ignores SIGTERM, and one stuck in jack_client_open never reaches
196
+ // the loop that reads the flag TERM sets, so a TERM left spawnSync waiting with it (#187). Alive at the deadline is
197
+ // all the verdict asks, so nothing is lost by not letting it exit cleanly.
198
+ const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, killSignal: "SIGKILL", input: "", ...(env === undefined ? {} : { env: { ...env } }) });
162
199
  const code = (r.error as NodeJS.ErrnoException | undefined)?.code;
163
200
  if (code === "ENOENT") return { installed: false, stillRunning: false, status: null, signal: null, stderr: "" };
164
201
  return { installed: true, stillRunning: code === "ETIMEDOUT", status: r.status, signal: code === "ETIMEDOUT" ? null : r.signal, stderr: r.stderr ?? "" };
@@ -67,7 +67,9 @@ export function hostdPinFiles(
67
67
  }
68
68
  if (root === undefined || names.length === 0) return { ok: false, detail: "plugin-hostd --pin-files printed no root or no file" };
69
69
  try {
70
- const files = names.map((path) => ({ path, sha256: createHash("sha256").update(read(join(root!, path))).digest("hex") }));
70
+ // a name is the word of a pin, percent-encoded when the file's name needs it (Surge XT's libSurge%20XT.so): the pin
71
+ // keeps it as written, the file is the name it decodes to, as the daemon opens it
72
+ const files = names.map((path) => ({ path, sha256: createHash("sha256").update(read(join(root!, decodeURIComponent(path)))).digest("hex") }));
71
73
  return { ok: true, root, files: files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
72
74
  } catch (e) {
73
75
  return { ok: false, detail: `a file plugin-hostd names cannot be read: ${e instanceof Error ? e.message : String(e)}` };
package/src/index.ts CHANGED
@@ -31,6 +31,8 @@ export * from "./probe-merge.js";
31
31
  export * from "./judge-cli.js";
32
32
  export * from "./whole-file.js";
33
33
  export * from "./qualify-sync.js";
34
+ export * from "./qualify-rejudge.js";
35
+ export * from "./qualify-adapter-pass.js";
34
36
  export * from "./distributed-records.js";
35
37
  export * from "./records-package.js";
36
38
  export * from "./host-admission.js";
@@ -309,6 +309,20 @@ export interface MemoryFootprint {
309
309
  readonly unmeasuredDetail?: string;
310
310
  }
311
311
 
312
+ /** The scheduling a cost was timed under, read on the thread that ran the clock. */
313
+ export interface TimingScheduling {
314
+ /** One of plugin-hostd's probe policy names: `other`, `fifo`... */
315
+ readonly policy: string;
316
+ readonly priority: number;
317
+ }
318
+
319
+ /** The scheduling a measurer printed, or `undefined` unless both its fields are there. */
320
+ export function readTimingScheduling(value: unknown): TimingScheduling | undefined {
321
+ if (!isRecord(value) || typeof value.policy !== "string" || value.policy === "") return undefined;
322
+ const priority = finiteNumber(value.priority);
323
+ return priority === undefined ? undefined : { policy: value.policy, priority };
324
+ }
325
+
312
326
  /** One rate's cost reading. Per SAMPLE, so it is independent of the block size. */
313
327
  export interface CostAtRate {
314
328
  readonly nsPerSampleMedian: number;
@@ -327,6 +341,8 @@ export interface CostAtRate {
327
341
  readonly silentOutput?: boolean;
328
342
  /** 1.4: for an instrument, the notes held while it was timed. Absent for an effect. */
329
343
  readonly voices?: VoicesHeld;
344
+ /** What these blocks were timed under; a figure from before the measurers printed it has none. */
345
+ readonly scheduling?: TimingScheduling;
330
346
  /** 1.6: this instance's memory footprint at the same rate. */
331
347
  readonly memory?: MemoryFootprint;
332
348
  }
@@ -964,6 +980,7 @@ function readCostAtRate(value: unknown): CostAtRate | undefined {
964
980
  ...optional("warmupNsPerSampleMedian", finiteNumber(value.warmupNsPerSampleMedian)),
965
981
  ...optional("silentOutput", booleanValue(value.silentOutput)),
966
982
  ...optional("voices", readVoicesHeld(value.voices)),
983
+ ...optional("scheduling", readTimingScheduling(value.scheduling)),
967
984
  ...optional("memory", readMemoryFootprint(value.memory)),
968
985
  };
969
986
  }
@@ -35,7 +35,7 @@ import type {
35
35
  PluginTopology,
36
36
  } from "./format.js";
37
37
  import { booleanValue, finiteNumber, isRecord, nonEmptyString, optional, positiveNumber } from "./guards.js";
38
- import { readVoicesHeld } from "./format.js";
38
+ import { readTimingScheduling, readVoicesHeld } from "./format.js";
39
39
  import type { MeasurementDimension } from "./vocabulary.js";
40
40
 
41
41
  /** One free-text prober failure, and the vocabulary term it means. */
@@ -201,6 +201,7 @@ function readCost(value: unknown): CostMeasurement | undefined {
201
201
  ...optional("warmupNsPerSampleMedian", finiteNumber(reading.warmupNsPerSampleMedian)),
202
202
  ...optional("silentOutput", booleanValue(reading.silentOutput)),
203
203
  ...optional("voices", readVoicesHeld(reading.voices)),
204
+ ...optional("scheduling", readTimingScheduling(reading.scheduling)),
204
205
  };
205
206
  }
206
207
  }
@@ -34,7 +34,7 @@ import {
34
34
  type HostingReason,
35
35
  } from "./hosting-suitability.js";
36
36
  import type { HostProfile } from "./host-profiles.js";
37
- import { runDecides, type HostAdmission } from "./host-admission.js";
37
+ import { admissionRating, runDecides, type HostAdmission } from "./host-admission.js";
38
38
  import { PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
39
39
  import type { QualifyVerdict } from "./qualify-cli.js";
40
40
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
@@ -51,6 +51,11 @@ export interface JudgeFacts {
51
51
  * judged by it as well as by its declaration; one that has none is judged from its declaration alone.
52
52
  */
53
53
  readonly admissions?: Readonly<Record<string, HostAdmission>>;
54
+ /**
55
+ * The run each profile's verdict needs and the figures do not hold, by profile id (`qualify-rejudge.ts`): an `unknown`
56
+ * reason in that verdict, so that a verdict never claims a run nobody made.
57
+ */
58
+ readonly owed?: Readonly<Record<string, RecordReason>>;
54
59
  }
55
60
 
56
61
  /** A sum of one count over the points, or `undefined` when a point lacks it: a missing count is not zero. */
@@ -244,7 +249,7 @@ function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"]
244
249
  * first word; a grid point that failed under a run that says `probe.ok` is a document that contradicts
245
250
  * itself, and is read as the failure it records rather than as a pass that skipped the point.
246
251
  */
247
- function failedOutcome(output: ProbeOutput): string | undefined {
252
+ export function failedOutcome(output: ProbeOutput): string | undefined {
248
253
  if (output.run.outcome !== PROBE_OK) return output.run.outcome;
249
254
  // a cell that outgrew the memory bound is a recorded outcome of that cell, judged per host below, not the run's end
250
255
  return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED && !isOutOfMemory(p.outcome))?.outcome;
@@ -303,7 +308,7 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
303
308
  if (!admission.measured || !admission.admitted) {
304
309
  // the host could not be run, or the plugin did not survive it: the run decides, whatever the declaration says
305
310
  const code = admission.code ?? `${admission.host}.not-admitted`;
306
- const reason: RecordReason = { dimension: "hostAdmission", rating: admission.measured ? "unsuitable" : "unknown", code, params: admission.params };
311
+ const reason: RecordReason = { dimension: "hostAdmission", rating: admissionRating(admission), code, params: admission.params };
307
312
  return {
308
313
  verdict: { uri, host: profile.id, measured: admission.measured, rating: reason.rating, qualified: false, deciding: code },
309
314
  hosting: "isolated",
@@ -313,6 +318,8 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
313
318
  };
314
319
  }
315
320
  }
321
+ const owed = facts.owed?.[profile.id];
322
+ if (owed !== undefined) reasons = [owed, ...reasons];
316
323
  const rating = worstRating(reasons.map((r) => r.rating));
317
324
  const deciding = decidingOf(reasons, rating);
318
325
  return {
@@ -22,7 +22,7 @@ import type { HostProfile } from "./host-profiles.js";
22
22
  import { OPENMIXER_CONSOLE_PROFILE, shippedHostProfile } from "./host-profiles.js";
23
23
  import { IN_PROCESS_TIER_HOST, ISOLATED_TIER_HOST } from "./qualification-record.js";
24
24
  import type { PluginTarget } from "./loader.js";
25
- import { ADAPTER_HOST, SPAWNED_ADMISSIONS, admitPlugin, admitUnderAdapter, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
25
+ import { ADAPTER_RUN_HOSTS, SPAWNED_ADMISSIONS, admitPlugin, admitUnderAdapter, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
26
26
  import { judgeProbeOutput, judgmentVerdicts, type Judgment } from "./probe-judge.js";
27
27
  import { readProbeOutput, type ProbeOutput } from "./probe-output.js";
28
28
  import { budgetCells, gridCells, hostCells, runnerInvocations, type GridCell, type GridSpec, type RunnerInvocation } from "./measurement-grid.js";
@@ -299,7 +299,7 @@ export function qualifyTargets(
299
299
  // an LV2 plugin is also run once under plugin-hostd's LV2 adapter, recorded beside the verdicts (#223)
300
300
  const adapter = target.format === "lv2" ? admitUnderAdapter(target.uri, admissionRuns, admissionEnv) : undefined;
301
301
  // and that run decides its omx-clap-host verdict, the isolated tier
302
- if (adapter !== undefined) admissions[ADAPTER_HOST] = adapter;
302
+ if (adapter !== undefined) for (const host of ADAPTER_RUN_HOSTS) admissions[host] = adapter;
303
303
  if (target.format === "lv2") {
304
304
  for (const profile of judgedProfiles) {
305
305
  const host = admittedHostOf(profile);