@openmixer/plugin-qualify 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +203 -2
- package/bin/plugin-qualify.mjs +77 -0
- package/dist/clap-loader.d.ts +147 -0
- package/dist/clap-loader.js +252 -0
- package/dist/clap-loader.js.map +1 -0
- package/dist/clap-qualify.d.ts +126 -0
- package/dist/clap-qualify.js +272 -0
- package/dist/clap-qualify.js.map +1 -0
- package/dist/host-profiles.d.ts +42 -0
- package/dist/host-profiles.js +106 -0
- package/dist/host-profiles.js.map +1 -0
- package/dist/hosting-codes.d.ts +62 -0
- package/dist/hosting-codes.js +72 -0
- package/dist/hosting-codes.js.map +1 -0
- package/dist/hosting-suitability.d.ts +354 -0
- package/dist/hosting-suitability.js +654 -0
- package/dist/hosting-suitability.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +26 -0
- package/dist/loader.js +9 -0
- package/dist/loader.js.map +1 -0
- package/dist/lv2-loader.d.ts +7 -0
- package/dist/lv2-loader.js +50 -0
- package/dist/lv2-loader.js.map +1 -0
- package/dist/measurement/cli.d.ts +168 -0
- package/dist/measurement/cli.js +353 -0
- package/dist/measurement/cli.js.map +1 -0
- package/dist/measurement/fixtures.d.ts +23 -0
- package/dist/measurement/fixtures.js +129 -0
- package/dist/measurement/fixtures.js.map +1 -0
- package/dist/measurement/format.d.ts +483 -0
- package/dist/measurement/format.js +627 -0
- package/dist/measurement/format.js.map +1 -0
- package/dist/measurement/guards.d.ts +33 -0
- package/dist/measurement/guards.js +61 -0
- package/dist/measurement/guards.js.map +1 -0
- package/dist/measurement/index.d.ts +36 -0
- package/dist/measurement/index.js +39 -0
- package/dist/measurement/index.js.map +1 -0
- package/dist/measurement/merge.d.ts +143 -0
- package/dist/measurement/merge.js +315 -0
- package/dist/measurement/merge.js.map +1 -0
- package/dist/measurement/plan.d.ts +127 -0
- package/dist/measurement/plan.js +141 -0
- package/dist/measurement/plan.js.map +1 -0
- package/dist/measurement/probe-json.d.ts +61 -0
- package/dist/measurement/probe-json.js +253 -0
- package/dist/measurement/probe-json.js.map +1 -0
- package/dist/measurement/provenance.d.ts +91 -0
- package/dist/measurement/provenance.js +173 -0
- package/dist/measurement/provenance.js.map +1 -0
- package/dist/measurement/sweep-jsonl.d.ts +40 -0
- package/dist/measurement/sweep-jsonl.js +154 -0
- package/dist/measurement/sweep-jsonl.js.map +1 -0
- package/dist/measurement/vocabulary.d.ts +125 -0
- package/dist/measurement/vocabulary.js +217 -0
- package/dist/measurement/vocabulary.js.map +1 -0
- package/dist/qualify-cli.d.ts +83 -0
- package/dist/qualify-cli.js +224 -0
- package/dist/qualify-cli.js.map +1 -0
- package/dist/qualify-declarations.d.ts +98 -0
- package/dist/qualify-declarations.js +76 -0
- package/dist/qualify-declarations.js.map +1 -0
- package/dist/qualify-options.d.ts +22 -0
- package/dist/qualify-options.js +36 -0
- package/dist/qualify-options.js.map +1 -0
- package/dist/rating.d.ts +32 -0
- package/dist/rating.js +30 -0
- package/dist/rating.js.map +1 -0
- package/dist/tool-paths.d.ts +12 -0
- package/dist/tool-paths.js +29 -0
- package/dist/tool-paths.js.map +1 -0
- package/fixtures/clap-scan/controls.json +8 -0
- package/fixtures/clap-scan/fake.json +16 -0
- package/fixtures/clap-scan/mixed.json +18 -0
- package/fixtures/zero-path.lv2/manifest.ttl +3 -0
- package/fixtures/zero-path.lv2/zero-path.c +85 -0
- package/fixtures/zero-path.lv2/zero-path.ttl +21 -0
- package/package.json +91 -3
- package/src/clap-loader.ts +393 -0
- package/src/clap-qualify.ts +387 -0
- package/src/host-profiles.ts +122 -0
- package/src/hosting-codes.ts +71 -0
- package/src/hosting-suitability.ts +982 -0
- package/src/index.ts +19 -0
- package/src/loader.ts +30 -0
- package/src/lv2-loader.ts +50 -0
- package/src/measurement/cli.ts +458 -0
- package/src/measurement/fixtures.ts +141 -0
- package/src/measurement/format.ts +1064 -0
- package/src/measurement/guards.ts +64 -0
- package/src/measurement/index.ts +39 -0
- package/src/measurement/merge.ts +434 -0
- package/src/measurement/plan.ts +219 -0
- package/src/measurement/probe-json.ts +271 -0
- package/src/measurement/provenance.ts +220 -0
- package/src/measurement/sweep-jsonl.ts +173 -0
- package/src/measurement/vocabulary.ts +298 -0
- package/src/qualify-cli.ts +277 -0
- package/src/qualify-declarations.ts +117 -0
- package/src/qualify-options.ts +49 -0
- package/src/rating.ts +53 -0
- package/src/tool-paths.ts +32 -0
- package/tools/_declarations.py +21 -0
- package/tools/benchmark.py +2462 -0
- package/tools/gen-python-declarations.d.mts +4 -0
- package/tools/gen-python-declarations.mjs +92 -0
- package/tools/hosting-sweep.mjs +51 -0
- package/tools/lv2-measure.mjs +204 -0
- package/tools/lv2_presets.py +132 -0
- package/tools/scan.py +432 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* Which HOST a plugin has earned: the console's own RT thread, or a `mod-host` process.
|
|
5
|
+
*
|
|
6
|
+
* "Thoroughly tested" is not a hand list and never an author's opinion. It is the derived
|
|
7
|
+
* verdict `hosting.rating === "suitable"`, computed on every read from measurements the
|
|
8
|
+
* catalog holds. `path` follows the rating and nothing else: `suitable` earns `in-process`,
|
|
9
|
+
* every rating below it is `isolated` with the reasons that decided it.
|
|
10
|
+
*
|
|
11
|
+
* The shape mirrors {@link import('./destination-suitability.js').classifyForRole} because
|
|
12
|
+
* the same law governs both — a standing is DERIVED, absence is an admission rather than a
|
|
13
|
+
* pass, and an override may only ever demote. What differs is the stake. A destination
|
|
14
|
+
* verdict that is wrong puts a reverb in a wedge; a hosting verdict that is wrong puts an
|
|
15
|
+
* untested plugin on the thread the whole desk's audio runs on, where its crash is not its
|
|
16
|
+
* own. So every dimension here fails CLOSED: no measurement means `unknown`, and `unknown`
|
|
17
|
+
* means isolated.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
RATING_SEVERITY,
|
|
21
|
+
worstRating,
|
|
22
|
+
type SuitabilityKind,
|
|
23
|
+
type SuitabilityRating,
|
|
24
|
+
} from "./rating.js";
|
|
25
|
+
import type {
|
|
26
|
+
FeaturesMeasurement,
|
|
27
|
+
LifecycleReading,
|
|
28
|
+
RtSafetyMeasurement,
|
|
29
|
+
SoakAtRate,
|
|
30
|
+
SoakReading,
|
|
31
|
+
ThreadReading,
|
|
32
|
+
} from "./measurement/format.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What a reason may interpolate: wire-serialisable scalars only. Structurally the same record as
|
|
36
|
+
* the console's coded-refusal parameters, so a consumer passes it straight through.
|
|
37
|
+
*/
|
|
38
|
+
export type CodedRefusalParams = Readonly<Record<string, string | number>>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The plugin the rating reads — exactly the fields it judges, and nothing a host's own catalog
|
|
42
|
+
* adds. The console's plugin descriptor satisfies it structurally; the standalone CLI
|
|
43
|
+
* builds it from the scan and the interchange document.
|
|
44
|
+
*/
|
|
45
|
+
export interface QualifiedPlugin {
|
|
46
|
+
readonly audioInputs: number;
|
|
47
|
+
readonly audioOutputs: number;
|
|
48
|
+
readonly hasMidiIn: boolean;
|
|
49
|
+
/** The benchmark's latency block; only whether it resolved, conforms and moves is read. */
|
|
50
|
+
readonly latency?: {
|
|
51
|
+
readonly unmeasurable?: unknown;
|
|
52
|
+
readonly scalingClass?: string;
|
|
53
|
+
readonly paramSweep?: unknown;
|
|
54
|
+
};
|
|
55
|
+
/** Measured cost per rate (Hz as a string key). */
|
|
56
|
+
readonly cpuCost?: {
|
|
57
|
+
readonly perRate?: Readonly<
|
|
58
|
+
Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
|
|
59
|
+
>;
|
|
60
|
+
};
|
|
61
|
+
readonly stability?: PluginStabilityMeasurement;
|
|
62
|
+
readonly rtSafety?: PluginRtSafetyMeasurement;
|
|
63
|
+
readonly lv2Features?: PluginLv2Features;
|
|
64
|
+
/** The plugin's binary format, the scan's declared fact (CLAP spec §6); absent reads `lv2`. */
|
|
65
|
+
readonly format?: "lv2" | "clap";
|
|
66
|
+
/** What the CLAP prober (`native/clap-qualify`) measured, for a `clap` plugin (CLAP spec §3). */
|
|
67
|
+
readonly clap?: PluginClapMeasurement;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where a plugin's audio is actually processed. */
|
|
71
|
+
export type HostingPath = "in-process" | "isolated";
|
|
72
|
+
|
|
73
|
+
/** The dimensions that decide a host. Six, folded worst-wins. */
|
|
74
|
+
export type HostingDimension =
|
|
75
|
+
| "stability"
|
|
76
|
+
| "rtSafety"
|
|
77
|
+
| "features"
|
|
78
|
+
| "cost"
|
|
79
|
+
| "latency"
|
|
80
|
+
| "topology";
|
|
81
|
+
|
|
82
|
+
// The in-process feature list and the CLAP host extensions are the `openmixer-console` host
|
|
83
|
+
// profile's; they live with the profiles (`host-profiles.ts`) and are re-exported here, where the
|
|
84
|
+
// native test (`lv2-inprocess-features.sh`) and the contract-limits render read them.
|
|
85
|
+
export { OMX_CLAP_HOST_EXTENSIONS, OMX_INPROCESS_FEATURES, OPENMIXER_CONSOLE_PROFILE, type HostProfile } from "./host-profiles.js";
|
|
86
|
+
import { OMX_CLAP_HOST_EXTENSIONS, OMX_INPROCESS_FEATURES, OPENMIXER_CONSOLE_PROFILE, type HostProfile } from "./host-profiles.js";
|
|
87
|
+
|
|
88
|
+
/** The catalog key of a CLAP plugin is `urn:clap:<clap_plugin_descriptor.id>` (CLAP spec §6). */
|
|
89
|
+
export const CLAP_URI_PREFIX = "urn:clap:";
|
|
90
|
+
|
|
91
|
+
/** Whether `uri` is a CLAP catalog key — the key grammar, for a plugin the catalog may not hold. */
|
|
92
|
+
export function isClapUri(uri: string): boolean {
|
|
93
|
+
return uri.startsWith(CLAP_URI_PREFIX);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the plugin `uri` names is hosted through the CLAP path: the descriptor's DECLARED
|
|
98
|
+
* format where the catalog holds one, else the key grammar (a `urn:clap:` uri the catalog never
|
|
99
|
+
* scanned is still a CLAP the console has no LV2 host for).
|
|
100
|
+
*/
|
|
101
|
+
export function isClapPlugin(uri: string, descriptor: Pick<QualifiedPlugin, "format"> | undefined): boolean {
|
|
102
|
+
return descriptor?.format === "clap" || (descriptor?.format === undefined && isClapUri(uri));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The CLAP descriptor feature that makes a plugin an audio effect (`clap/plugin-features.h`). */
|
|
106
|
+
export const CLAP_FEATURE_AUDIO_EFFECT = "audio-effect";
|
|
107
|
+
|
|
108
|
+
/** The CLAP descriptor feature that makes a plugin an instrument (`clap/plugin-features.h`). */
|
|
109
|
+
export const CLAP_FEATURE_INSTRUMENT = "instrument";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Features that disqualify outright rather than merely being unprovided: they need a host we
|
|
113
|
+
* are not and will not become inside the RT thread.
|
|
114
|
+
*/
|
|
115
|
+
const DISQUALIFYING_FEATURES: readonly string[] = [
|
|
116
|
+
"http://lv2plug.in/ns/ext/instance-access",
|
|
117
|
+
"http://lv2plug.in/ns/ext/data-access",
|
|
118
|
+
"http://lv2plug.in/ns/extensions/ui#idleInterface",
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/** Providable only while the console pins its quantum; without a pinned quantum they are
|
|
122
|
+
* promises the stage cannot keep, and §3 conditions them on exactly that. */
|
|
123
|
+
const QUANTUM_PINNED_FEATURES: readonly string[] = [
|
|
124
|
+
"http://lv2plug.in/ns/ext/buf-size#fixedBlockLength",
|
|
125
|
+
"http://lv2plug.in/ns/ext/buf-size#powerOf2BlockLength",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** The verdict's message codes, declared in their union list file. */
|
|
129
|
+
import { HOSTING_CODE } from "./hosting-codes.js";
|
|
130
|
+
export { HOSTING_CODE };
|
|
131
|
+
|
|
132
|
+
export type HostingCode = (typeof HOSTING_CODE)[keyof typeof HOSTING_CODE];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The refusals libomx-clap-core returns from `open`, `activate` and the timed open, each named by the KEY of the
|
|
136
|
+
* `HOSTING_CODE` it answers with — never by the string, so no second spelling of a code exists. The console's
|
|
137
|
+
* generator renders them into omx-clap-host's `clap_host_limits.h` as `CLAP_HOST_CODE_<NAME>`, the left column's
|
|
138
|
+
* name in upper snake case. The facts the qualifier judges from a measurement (latency, thread violations, process
|
|
139
|
+
* errors, RT safety) are the console's and are not here.
|
|
140
|
+
*/
|
|
141
|
+
export const CLAP_CORE_REFUSALS = {
|
|
142
|
+
headlessFailed: "clapHeadlessFailed",
|
|
143
|
+
notAudioEffect: "clapNotAudioEffect",
|
|
144
|
+
noteInput: "clapNoteInput",
|
|
145
|
+
noAudioInput: "topologyNoAudioInput",
|
|
146
|
+
noAudioOutput: "topologyNoAudioOutput",
|
|
147
|
+
widerThanStrip: "topologyWiderThanStrip",
|
|
148
|
+
extraInputs: "topologyExtraInputs",
|
|
149
|
+
crashedLive: "stabilityCrashedLive",
|
|
150
|
+
outputNonFinite: "outputNonFinite",
|
|
151
|
+
} as const satisfies Record<string, keyof typeof HOSTING_CODE>;
|
|
152
|
+
|
|
153
|
+
/** One dimension's answer for one plugin. */
|
|
154
|
+
export interface HostingReason {
|
|
155
|
+
readonly dimension: HostingDimension;
|
|
156
|
+
readonly kind: SuitabilityKind;
|
|
157
|
+
readonly rating: SuitabilityRating;
|
|
158
|
+
readonly code: HostingCode;
|
|
159
|
+
readonly params: CodedRefusalParams;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* What a reason may contribute. Unlike the destination classifier, every reason here is
|
|
164
|
+
* objective — there is no taste in whether a plugin allocates inside `run()`. The cautions
|
|
165
|
+
* that look advisory (`spawns-threads`, `links-gui-toolkit`) are MEASURED facts, so they are
|
|
166
|
+
* objective too and cap the plugin at `conditional` by their own rating rather than by a kind
|
|
167
|
+
* rule. An advisory reason, if one is ever added, may never do worse than `conditional`.
|
|
168
|
+
*/
|
|
169
|
+
export function contributedHostingRating(reason: HostingReason): SuitabilityRating {
|
|
170
|
+
if (reason.kind === "advisory" && RATING_SEVERITY[reason.rating] > RATING_SEVERITY.conditional) {
|
|
171
|
+
return "conditional";
|
|
172
|
+
}
|
|
173
|
+
return reason.rating;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** How a crash was attributed. `unknown` is the default and derives `unknown`, never blame. */
|
|
177
|
+
export type CrashAttribution = "plugin" | "host" | "unknown";
|
|
178
|
+
|
|
179
|
+
/** One row of the signature table: a crash shape we have seen and named. */
|
|
180
|
+
export interface CrashSignature {
|
|
181
|
+
readonly id: string;
|
|
182
|
+
readonly attribution: CrashAttribution;
|
|
183
|
+
/** The top plugin-owned stack frame, or a host frame that exonerates the plugin. */
|
|
184
|
+
readonly topFrame: string;
|
|
185
|
+
readonly signal?: "SIGSEGV" | "SIGABRT" | "SIGBUS";
|
|
186
|
+
/** The note that recorded this cluster. Provenance, so a row is never folklore. */
|
|
187
|
+
readonly note: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Seeded from the crash notes of 2026-09-03/04. A raw crash matching NO row is
|
|
192
|
+
* `unknown` — the table blames a plugin only where a named investigation did.
|
|
193
|
+
*/
|
|
194
|
+
export const CRASH_SIGNATURES: readonly CrashSignature[] = [
|
|
195
|
+
{
|
|
196
|
+
id: "pipewire-jack-close-double-free",
|
|
197
|
+
attribution: "host",
|
|
198
|
+
topFrame: "pw_memmap_free",
|
|
199
|
+
signal: "SIGSEGV",
|
|
200
|
+
note: "2026-09-04-pipewire-jack-client-close-double-free.md",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "pipewire-jack-close-double-free-abort",
|
|
204
|
+
attribution: "host",
|
|
205
|
+
topFrame: "tcache_double_free_verify",
|
|
206
|
+
signal: "SIGABRT",
|
|
207
|
+
note: "2026-09-04-pipewire-jack-client-close-double-free.md",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "mod-host-effects-remove",
|
|
211
|
+
attribution: "host",
|
|
212
|
+
topFrame: "effects_remove",
|
|
213
|
+
note: "2026-09-03-mod-host-crash-cluster2-3-investigation.md",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "plugin-instantiate-layout",
|
|
217
|
+
attribution: "plugin",
|
|
218
|
+
topFrame: "lilv_instance_instantiate",
|
|
219
|
+
note: "2026-09-03-mod-host-crash-cluster2-3-investigation.md",
|
|
220
|
+
},
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
/** A crash as observed, before attribution. */
|
|
224
|
+
export interface ObservedCrash {
|
|
225
|
+
readonly signal?: "SIGSEGV" | "SIGABRT" | "SIGBUS";
|
|
226
|
+
readonly topFrame?: string;
|
|
227
|
+
/** The sanitizer's error class, when a sanitizer build caught it (interchange §3a). */
|
|
228
|
+
readonly sanitizer?: string;
|
|
229
|
+
/** Basename of the object the faulting frame lies in. */
|
|
230
|
+
readonly frameObject?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Map a raw crash to blame. Fails to `unknown`: a signature must match the frame AND, where
|
|
235
|
+
* the row names one, the signal. An unmatched crash never reads as the plugin's fault.
|
|
236
|
+
*/
|
|
237
|
+
export function attributeCrash(
|
|
238
|
+
crash: ObservedCrash,
|
|
239
|
+
table: readonly CrashSignature[] = CRASH_SIGNATURES,
|
|
240
|
+
pluginBinary?: string,
|
|
241
|
+
): CrashAttribution {
|
|
242
|
+
// A sanitizer caught a memory error and the faulting frame is in the plugin's OWN binary:
|
|
243
|
+
// that is the plugin's (plugin-qualify spec §2 — the #98 zero-size atom:Path overread).
|
|
244
|
+
if (
|
|
245
|
+
crash.sanitizer !== undefined &&
|
|
246
|
+
pluginBinary !== undefined &&
|
|
247
|
+
crash.frameObject === pluginBinary
|
|
248
|
+
) {
|
|
249
|
+
return "plugin";
|
|
250
|
+
}
|
|
251
|
+
if (crash.topFrame === undefined) return "unknown";
|
|
252
|
+
for (const row of table) {
|
|
253
|
+
if (row.topFrame !== crash.topFrame) continue;
|
|
254
|
+
if (row.signal !== undefined && crash.signal !== undefined && row.signal !== crash.signal) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
return row.attribution;
|
|
258
|
+
}
|
|
259
|
+
return "unknown";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* What a lifecycle sweep MEASURED about a plugin surviving — the interchange format's stability
|
|
264
|
+
* block as it lands on a descriptor (`measurement-catalog.ts`), RAW: the cycles as run, the
|
|
265
|
+
* threads as counted, the crashes as observed, the soak per rate as rendered. Whether any of that
|
|
266
|
+
* adds up to a pass is {@link stabilityReasons}' to say, and nobody else's.
|
|
267
|
+
*/
|
|
268
|
+
export interface PluginStabilityMeasurement {
|
|
269
|
+
readonly lifecycle?: LifecycleReading;
|
|
270
|
+
readonly threads?: ThreadReading;
|
|
271
|
+
readonly crashes?: readonly ObservedCrash[];
|
|
272
|
+
/** A continuous render per rate with the controls swept — judged at EVERY rate it ran. */
|
|
273
|
+
readonly soak?: SoakReading;
|
|
274
|
+
readonly linksGuiToolkit?: boolean;
|
|
275
|
+
/** Recorded when a live console's pre-rack trial or a show killed it. */
|
|
276
|
+
readonly crashedLive?: boolean;
|
|
277
|
+
/** The host the figures were taken on — the run's, set by the door that landed them. */
|
|
278
|
+
readonly runHost?: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The interposer's counts inside `run()`, RAW, with the pair it ran at and the flags that say
|
|
283
|
+
* whether the count is a measurement at all ({@link rtSafetyMeasured}) — the interchange's
|
|
284
|
+
* rt-safety block as it lands, minus the reason a block with no reading carries instead.
|
|
285
|
+
*/
|
|
286
|
+
export type PluginRtSafetyMeasurement = Omit<RtSafetyMeasurement, "unmeasuredReason" | "unmeasuredDetail">;
|
|
287
|
+
|
|
288
|
+
/** What the plugin DEMANDS of a host, read off the instance the sweep loaded. */
|
|
289
|
+
export type PluginLv2Features = Omit<FeaturesMeasurement, "unmeasuredReason" | "unmeasuredDetail">;
|
|
290
|
+
|
|
291
|
+
export { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
|
|
292
|
+
import { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
|
|
293
|
+
|
|
294
|
+
/** One declared rate's latency reading from the CLAP qualifier (CLAP spec §3, latency). */
|
|
295
|
+
export interface ClapLatencyAtRate {
|
|
296
|
+
/** `latency.get()` after `activate` at this rate; a plugin without the ext declares 0. */
|
|
297
|
+
readonly declaredFrames: number;
|
|
298
|
+
/** Where the qualifier's impulse landed through `process()`, in frames. */
|
|
299
|
+
readonly measuredFrames: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* What the CLAP prober (`native/clap-qualify`) MEASURED about a CLAP plugin, RAW (CLAP spec §3): the facts the CLAP
|
|
304
|
+
* arm of each dimension reads. Whether any of it earns `suitable` is the `clap*Reasons` arms' to say.
|
|
305
|
+
*/
|
|
306
|
+
export interface PluginClapMeasurement {
|
|
307
|
+
/** Main audio ports: channel counts of the ONE main input and ONE main output the qualifier
|
|
308
|
+
* found (0 where none); `sidechainInputs` counts non-main audio inputs. */
|
|
309
|
+
readonly mainInputChannels: number;
|
|
310
|
+
readonly mainOutputChannels: number;
|
|
311
|
+
readonly sidechainInputs: number;
|
|
312
|
+
/** `clap_plugin_descriptor.features`, as declared. */
|
|
313
|
+
readonly features: readonly string[];
|
|
314
|
+
/** `init()` and `activate()` both succeeded HEADLESS, with only OMX_CLAP_HOST_EXTENSIONS offered. */
|
|
315
|
+
readonly headlessOk: boolean;
|
|
316
|
+
/** Note input ports the `note-ports` ext declares (0 where the ext is absent). */
|
|
317
|
+
readonly noteInputs: number;
|
|
318
|
+
/** Declared vs measured latency at every declared rate, keyed by the rate's decimal string. */
|
|
319
|
+
readonly latency: Readonly<Record<string, ClapLatencyAtRate>>;
|
|
320
|
+
/** Strict `thread-check` violations counted across the sweep. */
|
|
321
|
+
readonly threadViolations: number;
|
|
322
|
+
/** `CLAP_PROCESS_ERROR` returns counted across the sweep. */
|
|
323
|
+
readonly processErrors: number;
|
|
324
|
+
/** `process()` calls the sweep made after its warm-up; a sweep of 0 measured nothing. */
|
|
325
|
+
readonly processCalls: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** What the classifier is told about the console asking. */
|
|
329
|
+
export interface HostingClassifyOptions {
|
|
330
|
+
/** The LIVE rate. Cost is read here and nowhere else — never projected. */
|
|
331
|
+
readonly rate: number;
|
|
332
|
+
/** The LIVE quantum in frames, when the console pins one. */
|
|
333
|
+
readonly quantum?: number;
|
|
334
|
+
/** Basename of the plugin's own binary, when known: a sanitizer error faulting there is the plugin's. */
|
|
335
|
+
readonly pluginBinary?: string;
|
|
336
|
+
readonly policy?: HostingPolicy;
|
|
337
|
+
/** Curator override. `isolated` is the ONLY legal value; see {@link assertHostingOverride}. */
|
|
338
|
+
readonly override?: "isolated";
|
|
339
|
+
/** The host the console is actually running, to compare against the figures' provenance. */
|
|
340
|
+
readonly consoleHost?: string;
|
|
341
|
+
/** False when the sweep's positive control did not fire — poisons the whole verdict. */
|
|
342
|
+
readonly sweepControlFired?: boolean;
|
|
343
|
+
/** The host judged against (plugin-qualify §3a). Absent: `openmixer-console`, the console's own. */
|
|
344
|
+
readonly profile?: HostProfile;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The verdict, and everything that produced it. */
|
|
348
|
+
export interface PluginHosting {
|
|
349
|
+
readonly rating: SuitabilityRating;
|
|
350
|
+
readonly path: HostingPath;
|
|
351
|
+
readonly reasons: readonly HostingReason[];
|
|
352
|
+
/** The ONE reason that decided it: the worst, first-listed. Undefined when suitable. */
|
|
353
|
+
readonly deciding?: HostingReason;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function reason(
|
|
357
|
+
dimension: HostingDimension,
|
|
358
|
+
kind: SuitabilityKind,
|
|
359
|
+
rating: SuitabilityRating,
|
|
360
|
+
code: HostingCode,
|
|
361
|
+
params: CodedRefusalParams = {},
|
|
362
|
+
): HostingReason {
|
|
363
|
+
return { dimension, kind, rating, code, params };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Refuse an illegal override at the door. A curator may take a plugin OFF the RT thread and
|
|
368
|
+
* may never put one on it — no measurement, no in-process, and no "force" exists anywhere.
|
|
369
|
+
* Throws rather than coercing, because silently demoting a bad value would hide the mistake.
|
|
370
|
+
*/
|
|
371
|
+
export function assertHostingOverride(value: unknown, where: string): "isolated" | undefined {
|
|
372
|
+
if (value === undefined || value === null) return undefined;
|
|
373
|
+
if (value === "isolated") return "isolated";
|
|
374
|
+
throw new Error(
|
|
375
|
+
`${where}: hostingOverride may only be "isolated" (got ${JSON.stringify(value)}). ` +
|
|
376
|
+
`An override can demote a plugin to the isolated host; it can never grant in-process ` +
|
|
377
|
+
`hosting, which is earned from measurements alone.`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The level at which a rate is DEAD against the plugin's own best: `policy.soakDeadDropDb` or
|
|
383
|
+
* more below it. A ratio against the plugin itself, never an absolute floor — a quiet plugin is
|
|
384
|
+
* not a dead one. The one application of that threshold; the soak rule below and the report's
|
|
385
|
+
* short-render column both ask it.
|
|
386
|
+
*/
|
|
387
|
+
export function deadAgainstBest(levelDbfs: number, bestDbfs: number, policy: HostingPolicy): boolean {
|
|
388
|
+
return bestDbfs > -Infinity && levelDbfs < bestDbfs - policy.soakDeadDropDb;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** A soak's reading: what voided it, if anything, and what it proved when nothing did. */
|
|
392
|
+
export interface SoakFindings {
|
|
393
|
+
/** The SHORTEST rate's seconds — a soak proves no more than its weakest rate ran. */
|
|
394
|
+
readonly seconds: number;
|
|
395
|
+
/** Every rate that voids the soak, as `<why>@<rate>` — `died@`, `non-finite@`, `dead@`, `mostly-silent@`, `startup-blast@`. */
|
|
396
|
+
readonly failures: readonly string[];
|
|
397
|
+
readonly ratesTested: number;
|
|
398
|
+
/** The largest RSS growth across rates, for the record; not judged. */
|
|
399
|
+
readonly rssGrowthKb: number;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A soak rate that settled or opened ABOVE this is blasting: it voids the soak, and a rate
|
|
403
|
+
* settled above it cannot be the reference, or every sane rate reads dead against it. */
|
|
404
|
+
const BLAST_DBFS = 6;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Read a soak, rate by rate. A rate voids it if the plugin did not instantiate or died there,
|
|
408
|
+
* emitted a non-finite window, settled dead against its own best rate ({@link deadAgainstBest};
|
|
409
|
+
* settled = the LAST window, never the first — Calf opens at +200 dBFS at 192 k), spent more
|
|
410
|
+
* than half its windows silent, or opened above +6 dBFS. A few silent windows in ten thousand is
|
|
411
|
+
* a gate closing on a swept threshold — the plugin working, not dying.
|
|
412
|
+
*
|
|
413
|
+
* `undefined` when there is no soak, or it covered no rate: nothing was read.
|
|
414
|
+
*/
|
|
415
|
+
export function soakFindings(soak: SoakReading | undefined, policy: HostingPolicy): SoakFindings | undefined {
|
|
416
|
+
if (soak === undefined) return undefined;
|
|
417
|
+
const rates = Object.entries(soak.perRate);
|
|
418
|
+
if (rates.length === 0) return undefined;
|
|
419
|
+
const gone = (v: SoakAtRate): boolean => v.died === true || v.instantiated === false;
|
|
420
|
+
const settled = rates.map(([, v]) =>
|
|
421
|
+
gone(v) || (v.lastRmsDbfs ?? -Infinity) > BLAST_DBFS ? -Infinity : v.lastRmsDbfs ?? -Infinity,
|
|
422
|
+
);
|
|
423
|
+
const best = Math.max(...settled);
|
|
424
|
+
const failures: string[] = [];
|
|
425
|
+
for (const [rate, v] of rates) {
|
|
426
|
+
if (gone(v)) {
|
|
427
|
+
failures.push(`died@${rate}`);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if ((v.nonFiniteWindows ?? 0) > 0) {
|
|
431
|
+
failures.push(`non-finite@${rate}`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (deadAgainstBest(v.lastRmsDbfs ?? -Infinity, best, policy)) {
|
|
435
|
+
failures.push(`dead@${rate}`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if ((v.silentWindows ?? 0) > (v.windows ?? 1) * 0.5) {
|
|
439
|
+
failures.push(`mostly-silent@${rate}`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const first = v.firstRmsDbfs ?? -99;
|
|
443
|
+
if (first > BLAST_DBFS) failures.push(`startup-blast@${rate}(${first.toFixed(0)}dBFS)`);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
seconds: Math.min(...rates.map(([, v]) => v.seconds ?? 0)),
|
|
447
|
+
failures,
|
|
448
|
+
ratesTested: rates.length,
|
|
449
|
+
rssGrowthKb: Math.max(...rates.map(([, v]) => v.rssGrowthKb ?? 0)),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Threads the plugin's first instance brought with it. */
|
|
454
|
+
export function threadsSpawned(threads: ThreadReading | undefined): number {
|
|
455
|
+
return threads !== undefined && threads.afterFirstInstantiate > threads.before
|
|
456
|
+
? threads.afterFirstInstantiate - threads.before
|
|
457
|
+
: 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function stabilityReasons(
|
|
461
|
+
m: PluginStabilityMeasurement | undefined,
|
|
462
|
+
policy: HostingPolicy,
|
|
463
|
+
pluginBinary?: string,
|
|
464
|
+
/** Whether the plugin shares its process with others (profile isolation, §3a): threads it
|
|
465
|
+
* spawns and a GUI toolkit it links are cautions only there. */
|
|
466
|
+
sharesProcess = true,
|
|
467
|
+
): readonly HostingReason[] {
|
|
468
|
+
const out: HostingReason[] = [];
|
|
469
|
+
if (m === undefined || m.lifecycle === undefined) {
|
|
470
|
+
return [reason("stability", "objective", "unknown", HOSTING_CODE.stabilityUnmeasured)];
|
|
471
|
+
}
|
|
472
|
+
if (m.crashedLive === true) {
|
|
473
|
+
out.push(
|
|
474
|
+
reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilityCrashedLive, {}),
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const attributions = (m.crashes ?? []).map((c) => attributeCrash(c, CRASH_SIGNATURES, pluginBinary));
|
|
478
|
+
if (attributions.includes("plugin")) {
|
|
479
|
+
out.push(reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilityCrashedAttributed));
|
|
480
|
+
} else if (attributions.includes("unknown")) {
|
|
481
|
+
out.push(reason("stability", "objective", "unknown", HOSTING_CODE.stabilityCrashUnattributed));
|
|
482
|
+
}
|
|
483
|
+
const cycles = m.lifecycle.instantiated;
|
|
484
|
+
if (cycles < policy.cycleFloor) {
|
|
485
|
+
out.push(
|
|
486
|
+
reason("stability", "objective", "unknown", HOSTING_CODE.stabilityBelowFloor, {
|
|
487
|
+
cycles,
|
|
488
|
+
floor: policy.cycleFloor,
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
// Any failure voids the soak: the plugin has not proven a clean show, so it reads unsoaked —
|
|
493
|
+
// unknown, isolated — and never a crash, because silence at a rate is a measurement of
|
|
494
|
+
// audio, not of the process.
|
|
495
|
+
const soak = soakFindings(m.soak, policy);
|
|
496
|
+
const soaked = soak === undefined || soak.failures.length > 0 ? 0 : soak.seconds;
|
|
497
|
+
if (soaked < policy.soakSeconds || m.soak?.sweptParams !== true) {
|
|
498
|
+
out.push(
|
|
499
|
+
reason("stability", "objective", "unknown", HOSTING_CODE.stabilityUnsoaked, {
|
|
500
|
+
soakSeconds: soaked,
|
|
501
|
+
required: policy.soakSeconds,
|
|
502
|
+
...(soak !== undefined && soak.failures.length > 0 ? { failures: soak.failures.join(" ") } : {}),
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const spawned = sharesProcess ? threadsSpawned(m.threads) : 0;
|
|
507
|
+
if (spawned > 0) {
|
|
508
|
+
out.push(
|
|
509
|
+
reason("stability", "objective", "conditional", HOSTING_CODE.stabilitySpawnsThreads, {
|
|
510
|
+
threads: spawned,
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (sharesProcess && m.linksGuiToolkit === true) {
|
|
515
|
+
out.push(reason("stability", "objective", "conditional", HOSTING_CODE.stabilityLinksGuiToolkit));
|
|
516
|
+
}
|
|
517
|
+
if (out.length === 0) {
|
|
518
|
+
out.push(
|
|
519
|
+
reason("stability", "objective", "suitable", HOSTING_CODE.stabilitySoaked, {
|
|
520
|
+
cycles,
|
|
521
|
+
soakSeconds: soaked,
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Whether an interposer count is a MEASUREMENT: the interposer was loaded, all three counts are
|
|
530
|
+
* present, and the controls were swept — or the plugin has none a sweep could move, so its
|
|
531
|
+
* at-rest count is the only state there is. Measured at rest otherwise is not measured.
|
|
532
|
+
*/
|
|
533
|
+
export function rtSafetyMeasured(m: PluginRtSafetyMeasurement | undefined): boolean {
|
|
534
|
+
return (
|
|
535
|
+
m !== undefined &&
|
|
536
|
+
m.interposer === true &&
|
|
537
|
+
(m.swept === true || m.sweepApplicable === false) &&
|
|
538
|
+
m.allocationsInRun !== undefined &&
|
|
539
|
+
m.syscallsInRun !== undefined &&
|
|
540
|
+
m.locksInRun !== undefined
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** A measured count above zero — the disqualifier, asked in one place. */
|
|
545
|
+
export function rtSafetyViolating(m: PluginRtSafetyMeasurement | undefined): boolean {
|
|
546
|
+
return (
|
|
547
|
+
rtSafetyMeasured(m) &&
|
|
548
|
+
(m?.allocationsInRun ?? 0) + (m?.syscallsInRun ?? 0) + (m?.locksInRun ?? 0) > 0
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function rtSafetyReasons(
|
|
553
|
+
m: PluginRtSafetyMeasurement | undefined,
|
|
554
|
+
): readonly HostingReason[] {
|
|
555
|
+
if (m === undefined || !rtSafetyMeasured(m)) {
|
|
556
|
+
return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyUnmeasured)];
|
|
557
|
+
}
|
|
558
|
+
if (m.variable === true) {
|
|
559
|
+
return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotReproducible)];
|
|
560
|
+
}
|
|
561
|
+
if (rtSafetyViolating(m)) {
|
|
562
|
+
return [
|
|
563
|
+
reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyViolations, {
|
|
564
|
+
allocations: m.allocationsInRun ?? 0,
|
|
565
|
+
syscalls: m.syscallsInRun ?? 0,
|
|
566
|
+
locks: m.locksInRun ?? 0,
|
|
567
|
+
}),
|
|
568
|
+
];
|
|
569
|
+
}
|
|
570
|
+
return [
|
|
571
|
+
reason("rtSafety", "objective", "suitable", HOSTING_CODE.rtSafetyClean, {
|
|
572
|
+
blocks: m.blocks ?? 0,
|
|
573
|
+
}),
|
|
574
|
+
];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function featureReasons(
|
|
578
|
+
features: PluginLv2Features | undefined,
|
|
579
|
+
descriptor: Pick<QualifiedPlugin, "hasMidiIn">,
|
|
580
|
+
provided: readonly string[] = OMX_INPROCESS_FEATURES,
|
|
581
|
+
quantumPinned = false,
|
|
582
|
+
/** The host feeds MIDI input (an instrument host, §3a): a MIDI input is no caution there. */
|
|
583
|
+
instruments = false,
|
|
584
|
+
): readonly HostingReason[] {
|
|
585
|
+
const out: HostingReason[] = [];
|
|
586
|
+
if (features === undefined) {
|
|
587
|
+
out.push(reason("features", "objective", "unknown", HOSTING_CODE.featuresUnscanned));
|
|
588
|
+
} else {
|
|
589
|
+
const required = features.required ?? [];
|
|
590
|
+
// A host that LISTS one of these provides it: refused are only those it does not list.
|
|
591
|
+
const refused = (quantumPinned ? DISQUALIFYING_FEATURES : [...DISQUALIFYING_FEATURES, ...QUANTUM_PINNED_FEATURES])
|
|
592
|
+
.filter((f) => !provided.includes(f));
|
|
593
|
+
const disqualifying = required.filter((f) => refused.includes(f));
|
|
594
|
+
if (disqualifying.length > 0) {
|
|
595
|
+
out.push(
|
|
596
|
+
reason("features", "objective", "unsuitable", HOSTING_CODE.featuresDisqualifying, {
|
|
597
|
+
features: disqualifying.join(" "),
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
const missing = required.filter(
|
|
602
|
+
(f) => !provided.includes(f) && !refused.includes(f) && !QUANTUM_PINNED_FEATURES.includes(f),
|
|
603
|
+
);
|
|
604
|
+
if (missing.length > 0) {
|
|
605
|
+
out.push(
|
|
606
|
+
reason("features", "objective", "unsuitable", HOSTING_CODE.featuresMissing, {
|
|
607
|
+
features: missing.join(" "),
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
if ((features.cvPorts ?? 0) > 0) {
|
|
612
|
+
out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.featuresCv));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (descriptor.hasMidiIn && !instruments) {
|
|
616
|
+
out.push(reason("features", "objective", "conditional", HOSTING_CODE.featuresMidiIn));
|
|
617
|
+
}
|
|
618
|
+
if (out.length === 0) {
|
|
619
|
+
out.push(reason("features", "objective", "suitable", HOSTING_CODE.featuresProvided));
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export function hostingCostReasons(
|
|
625
|
+
descriptor: Pick<QualifiedPlugin, "cpuCost">,
|
|
626
|
+
options: HostingClassifyOptions,
|
|
627
|
+
policy: HostingPolicy,
|
|
628
|
+
): readonly HostingReason[] {
|
|
629
|
+
const perRate = descriptor.cpuCost?.perRate?.[String(options.rate)];
|
|
630
|
+
if (perRate === undefined) {
|
|
631
|
+
return [
|
|
632
|
+
reason("cost", "objective", "unknown", HOSTING_CODE.costUnmeasured, {
|
|
633
|
+
rate: options.rate,
|
|
634
|
+
quantum: options.quantum ?? 0,
|
|
635
|
+
}),
|
|
636
|
+
];
|
|
637
|
+
}
|
|
638
|
+
const out: HostingReason[] = [];
|
|
639
|
+
const p95 = perRate.coreFractionP95;
|
|
640
|
+
// The per-instance ceiling is the HOST's budget (§3a), not the policy's.
|
|
641
|
+
const ceiling = (options.profile ?? OPENMIXER_CONSOLE_PROFILE).cost.coreFractionCeiling;
|
|
642
|
+
if (p95 > ceiling) {
|
|
643
|
+
out.push(
|
|
644
|
+
reason("cost", "objective", "unsuitable", HOSTING_CODE.costAboveCeiling, {
|
|
645
|
+
coreFractionP95: p95,
|
|
646
|
+
ceiling,
|
|
647
|
+
}),
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const median = perRate.nsPerSampleMedian;
|
|
651
|
+
const p95ns = perRate.nsPerSampleP95;
|
|
652
|
+
if (median > 0 && p95ns / median > policy.costTailRatioCeiling) {
|
|
653
|
+
out.push(
|
|
654
|
+
reason("cost", "objective", "conditional", HOSTING_CODE.costSpiky, {
|
|
655
|
+
tailRatio: p95ns / median,
|
|
656
|
+
ceiling: policy.costTailRatioCeiling,
|
|
657
|
+
}),
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if (out.length === 0) {
|
|
661
|
+
out.push(
|
|
662
|
+
reason("cost", "objective", "suitable", HOSTING_CODE.costWithinCeiling, {
|
|
663
|
+
coreFractionP95: p95,
|
|
664
|
+
ceiling,
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function hostingLatencyReasons(
|
|
672
|
+
descriptor: Pick<QualifiedPlugin, "latency">,
|
|
673
|
+
handling: HostProfile["latency"] = "compensated",
|
|
674
|
+
): readonly HostingReason[] {
|
|
675
|
+
const latency = descriptor.latency;
|
|
676
|
+
// Fails closed, like every other dimension: a plugin with no latency figure at all, or
|
|
677
|
+
// one the benchmark declared unmeasurable, has not been judged and does not pass by
|
|
678
|
+
// default. The oracle's first fixture omitted latency and still expected `suitable`,
|
|
679
|
+
// which is how this branch was missing — a self-consistent suite proving nothing.
|
|
680
|
+
if (latency === undefined || latency.unmeasurable !== undefined) {
|
|
681
|
+
return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
|
|
682
|
+
}
|
|
683
|
+
if (latency.scalingClass === "nonconforming") {
|
|
684
|
+
return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyNonconforming)];
|
|
685
|
+
}
|
|
686
|
+
// A parameter-dependent latency is ALLOWED in-process: the live latency port is read every
|
|
687
|
+
// cycle, so a moving latency is reported rather than assumed. It is a caution, not a bar.
|
|
688
|
+
// A host that does not compensate aligns on nothing, so a moving figure costs it nothing.
|
|
689
|
+
if (latency.paramSweep !== undefined && handling === "compensated") {
|
|
690
|
+
return [reason("latency", "objective", "conditional", HOSTING_CODE.latencyParameterDependent)];
|
|
691
|
+
}
|
|
692
|
+
return [reason("latency", "objective", "suitable", HOSTING_CODE.latencyResolved)];
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export function hostingTopologyReasons(
|
|
696
|
+
descriptor: Pick<QualifiedPlugin, "audioInputs" | "audioOutputs"> & Partial<Pick<QualifiedPlugin, "hasMidiIn">>,
|
|
697
|
+
instruments = false,
|
|
698
|
+
): readonly HostingReason[] {
|
|
699
|
+
const { audioInputs: ins, audioOutputs: outs } = descriptor;
|
|
700
|
+
// An instrument host feeds MIDI: no audio input but a MIDI input and an audio output is an instrument.
|
|
701
|
+
if (ins === 0 && instruments && descriptor.hasMidiIn === true && outs > 0) {
|
|
702
|
+
return [reason("topology", "objective", "suitable", HOSTING_CODE.topologyInstrument, { ins, outs })];
|
|
703
|
+
}
|
|
704
|
+
if (ins === 0) {
|
|
705
|
+
return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyNoAudioInput)];
|
|
706
|
+
}
|
|
707
|
+
if (outs === 0) {
|
|
708
|
+
return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyNoAudioOutput)];
|
|
709
|
+
}
|
|
710
|
+
if ((ins === 1 && outs === 1) || (ins === 2 && outs === 2)) {
|
|
711
|
+
return [
|
|
712
|
+
reason("topology", "objective", "suitable", HOSTING_CODE.topologyMatched, { ins, outs }),
|
|
713
|
+
];
|
|
714
|
+
}
|
|
715
|
+
return [
|
|
716
|
+
reason("topology", "objective", "conditional", HOSTING_CODE.topologyExtraInputs, { ins, outs }),
|
|
717
|
+
];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The CLAP arm of `topology` (CLAP spec §3): the qualifier's main-port facts feed the same
|
|
722
|
+
* dimension the LV2 scan feeds. No measurement → `unknown` (`hosting.clap.unqualified`), never
|
|
723
|
+
* a guess from the descriptor.
|
|
724
|
+
*/
|
|
725
|
+
export function clapTopologyReasons(clap: PluginClapMeasurement | undefined, instruments = false): readonly HostingReason[] {
|
|
726
|
+
if (clap === undefined) {
|
|
727
|
+
return [reason("topology", "objective", "unknown", HOSTING_CODE.clapUnqualified)];
|
|
728
|
+
}
|
|
729
|
+
const ins = clap.mainInputChannels;
|
|
730
|
+
const outs = clap.mainOutputChannels;
|
|
731
|
+
if (ins === 0 && instruments && clap.noteInputs > 0 && outs > 0 && outs <= 2) {
|
|
732
|
+
return [reason("topology", "objective", "suitable", HOSTING_CODE.topologyInstrument, { ins, outs })];
|
|
733
|
+
}
|
|
734
|
+
if (ins === 0) return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyNoAudioInput)];
|
|
735
|
+
if (outs === 0) return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyNoAudioOutput)];
|
|
736
|
+
if (ins > 2 || outs > 2) {
|
|
737
|
+
return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyWiderThanStrip, { ins, outs })];
|
|
738
|
+
}
|
|
739
|
+
if (clap.sidechainInputs > 0) {
|
|
740
|
+
return [reason("topology", "objective", "conditional", HOSTING_CODE.topologyExtraInputs, { ins, outs, sidechains: clap.sidechainInputs })];
|
|
741
|
+
}
|
|
742
|
+
return [reason("topology", "objective", "suitable", HOSTING_CODE.topologyMatched, { ins, outs })];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* The CLAP arm of `latency` (CLAP spec §3): declared = measured at EVERY declared rate earns;
|
|
747
|
+
* one rate that lies refuses (`unsuitable` — compensation reads the figure as truth); no rate
|
|
748
|
+
* measured is `unknown`.
|
|
749
|
+
*/
|
|
750
|
+
export function clapLatencyReasons(clap: PluginClapMeasurement | undefined): readonly HostingReason[] {
|
|
751
|
+
const rates = clap ? Object.keys(clap.latency) : [];
|
|
752
|
+
if (clap === undefined || rates.length === 0) {
|
|
753
|
+
return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
|
|
754
|
+
}
|
|
755
|
+
for (const rate of rates) {
|
|
756
|
+
const at = clap.latency[rate]!;
|
|
757
|
+
if (at.declaredFrames !== at.measuredFrames) {
|
|
758
|
+
return [
|
|
759
|
+
reason("latency", "objective", "unsuitable", HOSTING_CODE.clapLatencyMismatch, {
|
|
760
|
+
rate: Number(rate), declared: at.declaredFrames, measured: at.measuredFrames,
|
|
761
|
+
}),
|
|
762
|
+
];
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return [reason("latency", "objective", "suitable", HOSTING_CODE.latencyResolved)];
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The CLAP arm of `features` (CLAP spec §3): an audio effect that initialised and activated
|
|
770
|
+
* HEADLESS with only OMX_CLAP_HOST_EXTENSIONS offered, and no note input (the measurement's or the
|
|
771
|
+
* scan's `hasMidiIn`). Every miss is its own code; a note input is an instrument, `unsuitable`.
|
|
772
|
+
*/
|
|
773
|
+
export function clapFeatureReasons(
|
|
774
|
+
clap: PluginClapMeasurement | undefined,
|
|
775
|
+
profile: HostProfile = OPENMIXER_CONSOLE_PROFILE,
|
|
776
|
+
scannedNoteInput = false,
|
|
777
|
+
): readonly HostingReason[] {
|
|
778
|
+
// A host with no CLAP path cannot run one, whatever it measured (§3a).
|
|
779
|
+
if (profile.clapExtensions === null) {
|
|
780
|
+
return [reason("features", "objective", "unsuitable", HOSTING_CODE.clapFormatNotHosted)];
|
|
781
|
+
}
|
|
782
|
+
if (clap === undefined) {
|
|
783
|
+
return [reason("features", "objective", "unknown", HOSTING_CODE.clapUnqualified)];
|
|
784
|
+
}
|
|
785
|
+
const out: HostingReason[] = [];
|
|
786
|
+
const offered = profile.clapExtensions;
|
|
787
|
+
const kindHosted =
|
|
788
|
+
clap.features.includes(CLAP_FEATURE_AUDIO_EFFECT) || (profile.instruments && clap.features.includes(CLAP_FEATURE_INSTRUMENT));
|
|
789
|
+
if (!kindHosted) {
|
|
790
|
+
out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.clapNotAudioEffect, { features: clap.features.join(" ") }));
|
|
791
|
+
}
|
|
792
|
+
if (!clap.headlessOk) {
|
|
793
|
+
out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.clapHeadlessFailed));
|
|
794
|
+
} else {
|
|
795
|
+
// The headless pass was taken with OMX_CLAP_HOST_EXTENSIONS offered: a host offering less
|
|
796
|
+
// has not been shown to initialise the plugin.
|
|
797
|
+
const uncovered = OMX_CLAP_HOST_EXTENSIONS.filter((e) => !offered.includes(e));
|
|
798
|
+
if (uncovered.length > 0) {
|
|
799
|
+
out.push(reason("features", "objective", "unknown", HOSTING_CODE.clapExtensionsNotCovered, { extensions: uncovered.join(" ") }));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if ((clap.noteInputs > 0 || scannedNoteInput) && !profile.instruments) {
|
|
803
|
+
out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.clapNoteInput, { noteInputs: clap.noteInputs }));
|
|
804
|
+
}
|
|
805
|
+
if (out.length === 0) out.push(reason("features", "objective", "suitable", HOSTING_CODE.featuresProvided));
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The CLAP additions to `rtSafety` (CLAP spec §3): the interposer's counts are read by
|
|
811
|
+
* `rtSafetyReasons` exactly as for an LV2; these are the two violations only a CLAP can commit
|
|
812
|
+
* — a strict thread-check violation and a `CLAP_PROCESS_ERROR` — each `unsuitable`. A sweep
|
|
813
|
+
* that made no `process()` call measured nothing.
|
|
814
|
+
*/
|
|
815
|
+
export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly HostingReason[] {
|
|
816
|
+
if (clap === undefined || clap.processCalls === 0) {
|
|
817
|
+
return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.clapUnqualified)];
|
|
818
|
+
}
|
|
819
|
+
const out: HostingReason[] = [];
|
|
820
|
+
if (clap.threadViolations > 0) {
|
|
821
|
+
out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapThreadViolation, { violations: clap.threadViolations }));
|
|
822
|
+
}
|
|
823
|
+
if (clap.processErrors > 0) {
|
|
824
|
+
out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: clap.processErrors }));
|
|
825
|
+
}
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* The verdict. Worst-wins across every dimension, exactly as `classifyForRole` folds — with
|
|
831
|
+
* three gates that can poison the whole result before the fold is even read: a curator
|
|
832
|
+
* override, a sweep whose positive control did not fire, and figures taken on a different
|
|
833
|
+
* host than the console is running.
|
|
834
|
+
*/
|
|
835
|
+
export function classifyPluginHosting(
|
|
836
|
+
descriptor: QualifiedPlugin,
|
|
837
|
+
options: HostingClassifyOptions,
|
|
838
|
+
): PluginHosting {
|
|
839
|
+
const policy = options.policy ?? DEFAULT_HOSTING_POLICY;
|
|
840
|
+
const profile = options.profile ?? OPENMIXER_CONSOLE_PROFILE;
|
|
841
|
+
const sharesProcess = profile.isolation !== "per-process";
|
|
842
|
+
// The descriptor's own measured blocks and nothing else: they reached it through the
|
|
843
|
+
// interchange's one door (`measurement-catalog.ts`), whose per-dimension precedence is the
|
|
844
|
+
// only precedence there is. A second "passed measurements win" seam here was how an offline
|
|
845
|
+
// report could agree with itself while every served descriptor read unknown.
|
|
846
|
+
// The CLAP arm (2026-09-26-clap-hosting-path.md §3): the SAME six dimensions, fed from the
|
|
847
|
+
// qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
|
|
848
|
+
// the interposer's counts and the cost are format-blind and read unchanged.
|
|
849
|
+
const reasons: HostingReason[] = descriptor.format === "clap"
|
|
850
|
+
? [
|
|
851
|
+
...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess),
|
|
852
|
+
...rtSafetyReasons(descriptor.rtSafety),
|
|
853
|
+
...clapRtReasons(descriptor.clap),
|
|
854
|
+
...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
|
|
855
|
+
...hostingCostReasons(descriptor, options, policy),
|
|
856
|
+
...clapLatencyReasons(descriptor.clap),
|
|
857
|
+
...clapTopologyReasons(descriptor.clap, profile.instruments),
|
|
858
|
+
]
|
|
859
|
+
: [
|
|
860
|
+
...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess),
|
|
861
|
+
...rtSafetyReasons(descriptor.rtSafety),
|
|
862
|
+
...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
|
|
863
|
+
...hostingCostReasons(descriptor, options, policy),
|
|
864
|
+
...hostingLatencyReasons(descriptor, profile.latency),
|
|
865
|
+
...hostingTopologyReasons(descriptor, profile.instruments),
|
|
866
|
+
];
|
|
867
|
+
|
|
868
|
+
// A sweep whose positive control did not fire proves nothing about anything it measured.
|
|
869
|
+
// The whole roster reads `unknown` — the false-signals law, made mechanical.
|
|
870
|
+
if (options.sweepControlFired === false) {
|
|
871
|
+
reasons.unshift(
|
|
872
|
+
reason("stability", "objective", "unknown", HOSTING_CODE.sweepControlDidNotFire),
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// A dnf upgrade must not carry a verdict forward. Different host, no `suitable`.
|
|
877
|
+
const figureHost = descriptor.stability?.runHost;
|
|
878
|
+
if (
|
|
879
|
+
options.consoleHost !== undefined &&
|
|
880
|
+
figureHost !== undefined &&
|
|
881
|
+
figureHost !== options.consoleHost
|
|
882
|
+
) {
|
|
883
|
+
reasons.unshift(
|
|
884
|
+
reason("stability", "objective", "unknown", HOSTING_CODE.hostProvenanceDiffers, {
|
|
885
|
+
measuredOn: figureHost,
|
|
886
|
+
runningOn: options.consoleHost,
|
|
887
|
+
}),
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (options.override === "isolated") {
|
|
892
|
+
reasons.unshift(
|
|
893
|
+
reason("stability", "objective", "unsuitable", HOSTING_CODE.overrideIsolated),
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const rating = worstRating(reasons.map(contributedHostingRating));
|
|
898
|
+
const deciding = [...reasons]
|
|
899
|
+
.sort(
|
|
900
|
+
(a, b) =>
|
|
901
|
+
RATING_SEVERITY[contributedHostingRating(b)] - RATING_SEVERITY[contributedHostingRating(a)],
|
|
902
|
+
)
|
|
903
|
+
.find((r) => contributedHostingRating(r) === rating);
|
|
904
|
+
return {
|
|
905
|
+
rating,
|
|
906
|
+
// In-process is a place only an in-process host has; every other host runs it in its own.
|
|
907
|
+
path: rating === "suitable" && profile.isolation === "in-process" ? "in-process" : "isolated",
|
|
908
|
+
reasons,
|
|
909
|
+
...(rating === "suitable" ? {} : { deciding }),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** What a racked slot's host actually IS, and the ONE reason it is that. */
|
|
914
|
+
export interface RealisedHosting {
|
|
915
|
+
readonly path: HostingPath;
|
|
916
|
+
/** The plugin's own rating, carried through — `unknown` where nothing could be judged. */
|
|
917
|
+
readonly rating: SuitabilityRating;
|
|
918
|
+
readonly reason: HostingCode;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** What the CONSOLE brings to the question, beside the plugin's own verdict. */
|
|
922
|
+
export interface HostingRealisation {
|
|
923
|
+
/** The operator's authored switch (`/console/plugins/hosting`). */
|
|
924
|
+
readonly inProcessEnabled: boolean;
|
|
925
|
+
/** Whether an in-process body exists to run a plugin in at all. */
|
|
926
|
+
readonly inProcessAvailable: boolean;
|
|
927
|
+
/**
|
|
928
|
+
* Whether the plugin is LINKED into the engine (its static-link registry): it needs no verdict
|
|
929
|
+
* to run in-process (`2026-09-26-clap-hosting-path.md` §7). Absent reads as not linked.
|
|
930
|
+
*/
|
|
931
|
+
readonly linked?: boolean;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The REALISED host of one racked plugin — a different fact from the verdict, and kept in a
|
|
936
|
+
* different field for that reason.
|
|
937
|
+
*
|
|
938
|
+
* `classifyPluginHosting` answers what a plugin EARNED. This answers where it RUNS, which
|
|
939
|
+
* three things decide in order, each with its own reason so the operator reads the first one
|
|
940
|
+
* that applies rather than a fold:
|
|
941
|
+
*
|
|
942
|
+
* 1. the console's switch is off — nothing runs in-process, whatever it earned;
|
|
943
|
+
* 1a. the plugin is LINKED into the engine — it runs in-process wherever a body exists, with no
|
|
944
|
+
* verdict to read;
|
|
945
|
+
* 2. nothing could be judged (no verdict: the console has observed no rate and quantum) or the
|
|
946
|
+
* verdict is below `suitable` — the plugin's own deciding reason stands;
|
|
947
|
+
* 3. it earned in-process and there is no in-process body to run it in yet.
|
|
948
|
+
*
|
|
949
|
+
* Order matters and is the operator's, not the machine's: a console-wide fact is a better
|
|
950
|
+
* answer to "why is this plugin not on the fast path" than a per-plugin one, because it is the
|
|
951
|
+
* one the operator can act on.
|
|
952
|
+
*/
|
|
953
|
+
export function realisedHosting(
|
|
954
|
+
verdict: PluginHosting | undefined,
|
|
955
|
+
realisation: HostingRealisation,
|
|
956
|
+
): RealisedHosting {
|
|
957
|
+
const rating = verdict?.rating ?? "unknown";
|
|
958
|
+
if (!realisation.inProcessEnabled) {
|
|
959
|
+
return { path: "isolated", rating, reason: HOSTING_CODE.disabled };
|
|
960
|
+
}
|
|
961
|
+
if (realisation.linked === true) {
|
|
962
|
+
return realisation.inProcessAvailable
|
|
963
|
+
? { path: "in-process", rating, reason: HOSTING_CODE.linked }
|
|
964
|
+
: { path: "isolated", rating, reason: HOSTING_CODE.noRealisation };
|
|
965
|
+
}
|
|
966
|
+
if (verdict === undefined) {
|
|
967
|
+
return { path: "isolated", rating, reason: HOSTING_CODE.unjudged };
|
|
968
|
+
}
|
|
969
|
+
if (verdict.path === "isolated") {
|
|
970
|
+
return {
|
|
971
|
+
path: "isolated",
|
|
972
|
+
rating,
|
|
973
|
+
// `deciding` is present for every rating below `suitable`; the fall-back names the one
|
|
974
|
+
// state that could produce an isolated verdict with no reason at all — nothing judged.
|
|
975
|
+
reason: verdict.deciding?.code ?? HOSTING_CODE.unjudged,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
if (!realisation.inProcessAvailable) {
|
|
979
|
+
return { path: "isolated", rating, reason: HOSTING_CODE.noRealisation };
|
|
980
|
+
}
|
|
981
|
+
return { path: "in-process", rating, reason: HOSTING_CODE.realisedInProcess };
|
|
982
|
+
}
|