@openmixer/plugin-qualify 0.18.3 → 0.18.5

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.
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * JACK's table of its servers, /dev/shm/jack-shm-registry: a header, then eight rows of a pid and a name
5
+ * (`jack-<uid>:<server>:`). A server that is killed never clears its row, and once the eight are taken no server starts.
6
+ * The adapter pass starts and stops servers of its own, `omx-qualify-<pid>-<quantum>`; a row of one of those whose
7
+ * process is gone is freed (pid 0, name empty) before the next start, as jackd frees its own row when it ends well.
8
+ * Rows of any other server are never touched, and a file of another layout is left as it is.
9
+ */
10
+ import { closeSync, existsSync, openSync, readSync, writeSync } from "node:fs";
11
+
12
+ export const JACK_REGISTRY = "/dev/shm/jack-shm-registry";
13
+ /** the header jack2 writes before the server rows, and the size of a row: a pid, then the name */
14
+ export const JACK_REGISTRY_HEADER = 24;
15
+ export const JACK_REGISTRY_ENTRY = 264;
16
+ const ROWS = 8;
17
+ const MAGIC = "KCAJ";
18
+ const OWN = ":omx-qualify-";
19
+
20
+ const alive = (pid: number): boolean => existsSync(`/proc/${String(pid)}`);
21
+
22
+ /** Frees the rows of the pass's own servers that are gone; the names freed, in the table's order. */
23
+ export function freeDeadJackSlots(path: string = process.env["OMX_JACK_REGISTRY"] ?? JACK_REGISTRY): string[] {
24
+ const freed: string[] = [];
25
+ let fd: number;
26
+ try {
27
+ fd = openSync(path, "r+");
28
+ } catch {
29
+ return freed;
30
+ }
31
+ try {
32
+ const head = Buffer.alloc(JACK_REGISTRY_HEADER + ROWS * JACK_REGISTRY_ENTRY);
33
+ if (readSync(fd, head, 0, head.length, 0) !== head.length) return freed;
34
+ // the layout this code knows, or nothing is touched
35
+ if (head.toString("latin1", 0, 4) !== MAGIC || head.readUInt32LE(16) !== JACK_REGISTRY_HEADER + ROWS * JACK_REGISTRY_ENTRY) return freed;
36
+ for (let k = 0; k < ROWS; k++) {
37
+ const at = JACK_REGISTRY_HEADER + k * JACK_REGISTRY_ENTRY;
38
+ const pid = head.readInt32LE(at);
39
+ const name = head.toString("latin1", at + 4, at + JACK_REGISTRY_ENTRY).split("\0")[0] ?? "";
40
+ if (pid <= 0 || !name.includes(OWN) || alive(pid)) continue;
41
+ writeSync(fd, Buffer.alloc(4), 0, 4, at);
42
+ writeSync(fd, Buffer.alloc(1), 0, 1, at + 4);
43
+ freed.push(name);
44
+ }
45
+ } finally {
46
+ closeSync(fd);
47
+ }
48
+ return freed;
49
+ }
@@ -197,6 +197,8 @@ export interface HostJudgment {
197
197
 
198
198
  export interface Judgment {
199
199
  readonly output: ProbeOutput;
200
+ /** How loaded the machine was when the probe started (probe-runner's ProbeLoad); absent when not taken. */
201
+ readonly load?: { readonly loadavg1: number; readonly cores: number };
200
202
  /** An LV2 plugin's run under plugin-hostd's LV2 adapter, beside the verdicts (plugin-qualify#223). */
201
203
  readonly adapter?: HostAdmission;
202
204
  readonly hosts: readonly HostJudgment[];
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { spawnSync } from "node:child_process";
15
15
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
16
- import { tmpdir } from "node:os";
16
+ import { availableParallelism, loadavg, tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
 
19
19
  import { ClapQualifyError, clapQualifyTools, proberOffer, type RtSafetyDocument } from "./clap-qualify.js";
@@ -155,11 +155,22 @@ export function lv2RunnerArgs(
155
155
  }
156
156
 
157
157
  /** One probe output and the bytes it was read from (the bytes are what gets copied beside the record). */
158
+ /** How loaded the machine was when a probe started: the one-minute load average and the cores, so that a crash under a
159
+ * full machine can be told from one on an idle one. */
160
+ export interface ProbeLoad {
161
+ readonly loadavg1: number;
162
+ readonly cores: number;
163
+ }
164
+
158
165
  export interface ProbedTarget {
159
166
  readonly bytes: Buffer;
160
167
  readonly output: ProbeOutput;
168
+ /** The most loaded the machine was when any of the target's runs started. */
169
+ readonly load?: ProbeLoad;
161
170
  }
162
171
 
172
+ const loadNow = (): ProbeLoad => ({ loadavg1: Math.round(loadavg()[0]! * 100) / 100, cores: availableParallelism() });
173
+
163
174
  function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: RunnerInvocation): ProbedTarget {
164
175
  const env = options.env ?? process.env;
165
176
  const tool = probeRunnerTool(env);
@@ -193,6 +204,7 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
193
204
  }
194
205
  // under a bound, the interposer ends the child the moment an allocation is refused, with the status that names it
195
206
  if (options.memory !== undefined) runEnv = { ...runEnv, [MEMORY_BOUND_EXIT_ENV]: "1" };
207
+ const load = loadNow();
196
208
  const r = (options.run ?? spawnRunner)(tool, args, runEnv);
197
209
  if (r.error !== undefined) {
198
210
  throw new ProbeRunnerError(`cannot run the probe runner "${tool}" (${r.error.message}): install plugin-hostd or set ${PROBE_RUNNER_ENV} to plugin-hostd-probe`);
@@ -214,7 +226,7 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
214
226
  if (read.output.plugin.id !== expectedId) {
215
227
  throw new ProbeRunnerError(`${tool} measured ${read.output.plugin.id} when asked for ${expectedId}: not judged as the plugin asked for`);
216
228
  }
217
- return { bytes, output: read.output };
229
+ return { bytes, output: read.output, load };
218
230
  } finally {
219
231
  rmSync(work, { recursive: true, force: true });
220
232
  }
@@ -231,8 +243,10 @@ export function targetCells(options: Pick<ProbeTargetOptions, "rates" | "cells">
231
243
  */
232
244
  export function probeTarget(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget {
233
245
  const parts = runnerInvocations(target.format, targetCells(options)).map((invocation) => ({ ...runOne(target, options, invocation), invocation }));
246
+ const loads = parts.flatMap((p) => (p.load === undefined ? [] : [p.load]));
247
+ const load = loads.length === 0 ? undefined : loads.reduce((a, b) => (b.loadavg1 > a.loadavg1 ? b : a));
234
248
  try {
235
- return mergeProbeRuns(parts, options.memory);
249
+ return { ...mergeProbeRuns(parts, options.memory), ...(load === undefined ? {} : { load }) };
236
250
  } catch (e) {
237
251
  if (e instanceof ProbeMergeError) throw new ProbeRunnerError(e.message);
238
252
  throw e;
@@ -321,7 +335,7 @@ export function qualifyTargets(
321
335
  for (const { offer, profiles: forOffer } of byOffer.values()) {
322
336
  const probed = probeTarget(target, { ...options, ...measured, offer });
323
337
  const plain = judgeProbeOutput(probed.output, forOffer, { admissions });
324
- const judgment = adapter === undefined ? plain : { ...plain, adapter };
338
+ const judgment = { ...plain, ...(adapter === undefined ? {} : { adapter }), ...(probed.load === undefined ? {} : { load: probed.load }) };
325
339
  targetRuns.push({ bytes: probed.bytes, judgment });
326
340
  for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
327
341
  }
@@ -72,6 +72,8 @@ export interface IsolatedFigures {
72
72
  /** The worker's CPU over the window, in percent of one core. */
73
73
  readonly cpuPercent?: number;
74
74
  readonly xruns?: number;
75
+ /** How many MIDI inputs of the plugin the step's notes were linked to. */
76
+ readonly midiInputsLinked?: number;
75
77
  }
76
78
 
77
79
  /** One step of a plugin's pass and how it ended. */
@@ -341,9 +343,38 @@ export interface QualificationRecord {
341
343
  readonly machineClass: Readonly<Record<string, string | boolean>>;
342
344
  /** Whether the machine met the protocol's optimised profile: a figure that did not is not a reference figure. */
343
345
  readonly meetsProtocol: boolean;
346
+ /** Each step whose child did not end well: the signal that killed it or the status it exited with, so a crash is
347
+ * told from the runner's own kill (a deadline or memory kill is SIGKILL). Absent when every child ended well. */
348
+ readonly ends?: readonly ProbeEnd[];
349
+ /** How loaded the machine was when the probe started; absent on a record written before it was taken. */
350
+ readonly load?: { readonly loadavg1: number; readonly cores: number };
344
351
  };
345
352
  }
346
353
 
354
+ /** How one step's child ended, when not well. */
355
+ export interface ProbeEnd {
356
+ readonly step: string;
357
+ readonly outcome: string;
358
+ readonly rate?: number;
359
+ readonly quantum?: number;
360
+ readonly signal?: string;
361
+ readonly exitStatus?: number;
362
+ }
363
+
364
+ /** The steps of a probe output whose child was killed by a signal or exited with a status other than 0. */
365
+ export function probeEnds(output: ProbeOutput): readonly ProbeEnd[] {
366
+ return output.steps
367
+ .filter((s) => (s.signal !== undefined && s.signal !== null && s.signal !== "") || (s.exitStatus !== undefined && s.exitStatus !== 0))
368
+ .map((s) => ({
369
+ step: s.step,
370
+ outcome: s.outcome,
371
+ ...(s.rate === undefined ? {} : { rate: s.rate }),
372
+ ...(s.quantum === undefined ? {} : { quantum: s.quantum }),
373
+ ...(s.signal ? { signal: s.signal } : {}),
374
+ ...(s.exitStatus === undefined || s.exitStatus === 0 ? {} : { exitStatus: s.exitStatus }),
375
+ }));
376
+ }
377
+
347
378
  /** One dimension a run did not measure, and the code that says so. */
348
379
  export interface NotMeasured {
349
380
  readonly dimension: string;
@@ -429,6 +460,8 @@ export function buildQualificationRecord(
429
460
  complete: o.run.complete,
430
461
  machineClass: o.machine.class,
431
462
  meetsProtocol: o.machine.profile.meetsProtocol,
463
+ ...(probeEnds(o).length === 0 ? {} : { ends: probeEnds(o) }),
464
+ ...(judgment.load === undefined ? {} : { load: judgment.load }),
432
465
  },
433
466
  };
434
467
  }
@@ -17,8 +17,9 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node
17
17
  import { tmpdir } from "node:os";
18
18
  import { basename, join } from "node:path";
19
19
 
20
- import { ADAPTER_HOST, SPAWNED_ADMISSIONS, admitUnderAdapter, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
20
+ import { ADAPTER_HOST, ADAPTER_SILENT_CODE, SPAWNED_ADMISSIONS, admitUnderAdapter, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
21
21
  import { HOSTD_ENV } from "./hostd-pin-files.js";
22
+ import { freeDeadJackSlots } from "./jack-registry.js";
22
23
  import { cellKey, type GridCell } from "./measurement-grid.js";
23
24
  import { defaultAddressSpaceLimit } from "./direct-limit.js";
24
25
  import { PROBE_OK } from "./probe-output.js";
@@ -36,7 +37,9 @@ export const NO_PRIVATE_SERVER = `${ADAPTER_HOST}.no-private-server`;
36
37
  const RECORD_SUFFIX = ".qualification.json";
37
38
  const SERVER_WAIT_MS = 5000;
38
39
  const SERVER_SETTLE_MS = 300;
39
- const SERVER_STOP_MS = 2000;
40
+ // a TERM is waited for this long: jackd on a Pi can take seconds to end, and a jackd that is KILLed keeps its row in
41
+ // JACK's server table for good (jack-registry.ts)
42
+ const SERVER_STOP_MS = 10000;
40
43
 
41
44
  /** What a program answers to `--version`: its last word on the line that says `version`. */
42
45
  export function parseVersion(text: string): string | undefined {
@@ -78,14 +81,18 @@ const sleepSync = (ms: number): void => {
78
81
  /**
79
82
  * jackd under a shell that waits for it. A pass is synchronous, so this process cannot reap a child; an unreaped jackd
80
83
  * that had a client stays in JACK's server table as alive, and the next start under its name reads "server already active".
81
- * The shell outlives jackd whatever happens: a TERM reaches jackd once, and HUP is how a stop that gives up kills jackd alone.
82
- * Killed with its group, the shell could not reap it.
84
+ * The shell outlives jackd whatever happens: a TERM to the shell is passed to jackd's own pid, and HUP is how a stop that
85
+ * gives up kills jackd alone. Killed with its group, the shell could not reap it. A TERM to the group does not do: a jackd
86
+ * that is not its group's leader keeps running after it (measured on a Raspberry Pi 5 and on CI), and the stop's KILL then
87
+ * left its row in JACK's registry.
83
88
  */
84
- const REAPING_JACKD = `trap : TERM; trap 'kill -KILL $p 2>/dev/null' HUP; jackd "$@" & p=$!; while kill -0 $p 2>/dev/null; do wait $p; done`;
89
+ const REAPING_JACKD = `trap 'kill -TERM $p 2>/dev/null' TERM; trap 'kill -KILL $p 2>/dev/null' HUP; jackd "$@" & p=$!; while kill -0 $p 2>/dev/null; do wait $p; done`;
85
90
 
86
91
  /** `jackd` on a name of its own and the dummy driver, at the cell's rate and quantum; `undefined` when none came up. */
87
92
  export const startPrivateJack: JackStarter = (cell) => {
88
93
  const name = `omx-qualify-${String(process.pid)}-${String(cell.quantum)}`;
94
+ // the rows of servers of ours that were killed, freed before this one asks for a row
95
+ freeDeadJackSlots();
89
96
  let child: ChildProcess;
90
97
  let ended = false;
91
98
  try {
@@ -110,7 +117,11 @@ export const startPrivateJack: JackStarter = (cell) => {
110
117
  }
111
118
  };
112
119
  const stop = (): void => {
113
- signal("SIGTERM");
120
+ try {
121
+ if (child.pid !== undefined) process.kill(child.pid, "SIGTERM");
122
+ } catch {
123
+ // already gone
124
+ }
114
125
  const until = Date.now() + SERVER_STOP_MS;
115
126
  while (running() && Date.now() < until) sleepSync(20);
116
127
  if (running()) {
@@ -124,6 +135,8 @@ export const startPrivateJack: JackStarter = (cell) => {
124
135
  }
125
136
  signal("SIGKILL");
126
137
  for (const d of serverDirs(name)) rmSync(d, { recursive: true, force: true });
138
+ // a jackd that had to be KILLed left its row: it is freed now, not by the next start
139
+ freeDeadJackSlots();
127
140
  };
128
141
  const until = Date.now() + SERVER_WAIT_MS;
129
142
  while (Date.now() < until) {
@@ -187,6 +200,8 @@ export const mergeAdapterRun = (record: QualificationRecord, block: AdapterBlock
187
200
  export function adapterFinished(record: QualificationRecord, versions: Readonly<Record<string, string>>, cells: readonly GridCell[] = ADAPTER_PASS_CELLS): boolean {
188
201
  const a = record.adapter;
189
202
  if (a === undefined || !a.measured || a.versions === undefined || a.cells === undefined) return false;
203
+ // silence under the one key the run once played says nothing of a plugin that maps others: it is run again
204
+ if (a.cells.some((c) => c.code === ADAPTER_SILENT_CODE && c.params["notes"] === undefined)) return false;
190
205
  const same = Object.keys(versions).length === Object.keys(a.versions).length && Object.entries(versions).every(([k, v]) => a.versions![k] === v);
191
206
  return same && cells.every((c) => a.cells!.some((x) => cellKey(x) === cellKey(c)));
192
207
  }
@@ -59,6 +59,8 @@ export interface IsolatedRunFacts {
59
59
  readonly xruns?: number;
60
60
  readonly finite?: boolean;
61
61
  readonly silent?: boolean;
62
+ /** How many of the plugin's MIDI inputs the notes were linked to; none for a plugin that has none. */
63
+ readonly midiInputsLinked?: number;
62
64
  }
63
65
 
64
66
  export type IsolatedRunner = (uri: string, cell: GridCell, env: Readonly<Record<string, string | undefined>>) => IsolatedRunFacts;
@@ -78,16 +80,20 @@ export function judgeIsolatedRun(run: IsolatedRunFacts): RunStep {
78
80
  if ((run.workers ?? 0) < 1) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.noWorker);
79
81
  if (run.workers !== 1 || run.instancesInWorker !== 1) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.notAlone);
80
82
  if (run.finite !== true) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.nonFinite);
81
- if (run.silent !== false) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.silent);
83
+ if (run.silent !== false) {
84
+ const linked = run.midiInputsLinked;
85
+ return step(ISOLATED_RESULT.refused, ISOLATED_CODE.silent, linked === undefined ? undefined : linked === 0 ? "no note input to link: a sine played at its audio inputs" : `notes held on ${String(linked)} MIDI input${linked === 1 ? "" : "s"} and a sine at its audio inputs`);
86
+ }
82
87
  return step(ISOLATED_RESULT.isolated);
83
88
  }
84
89
 
85
90
  function figuresOf(run: IsolatedRunFacts): IsolatedFigures | undefined {
86
- if (run.latencyFrames === undefined && run.cpuPercent === undefined && run.xruns === undefined) return undefined;
91
+ if (run.latencyFrames === undefined && run.cpuPercent === undefined && run.xruns === undefined && run.midiInputsLinked === undefined) return undefined;
87
92
  return {
88
93
  ...(run.latencyFrames === undefined ? {} : { latencyFrames: run.latencyFrames }),
89
94
  ...(run.cpuPercent === undefined ? {} : { cpuPercent: run.cpuPercent }),
90
95
  ...(run.xruns === undefined ? {} : { xruns: run.xruns }),
96
+ ...(run.midiInputsLinked === undefined ? {} : { midiInputsLinked: run.midiInputsLinked }),
91
97
  };
92
98
  }
93
99
 
@@ -191,8 +197,10 @@ function runIdOf(record: QualificationRecord, dir: string): string | undefined {
191
197
  */
192
198
  export function completeIsolated(path: string, record: QualificationRecord, uri: string, cell: GridCell, runner: IsolatedRunner, env: Readonly<Record<string, string | undefined>>): { readonly record: QualificationRecord; readonly step: RunStep } | undefined {
193
199
  const isStep = (s: RunStep): boolean => s.step === RUN_STEP.isolated;
194
- // a step that could not run decided nothing, so a pass tries it again and it takes that one's place
195
- if (!owesIsolated(record) || record.run?.steps.some((s) => isStep(s) && s.result !== ISOLATED_RESULT.notRun) === true) return undefined;
200
+ // a step that could not run decided nothing, and neither did silence before the notes were played where a plugin could
201
+ // hear them: a step of that tool does not say how many inputs it linked. A pass tries these again, and it takes their place
202
+ const decided = (s: RunStep): boolean => s.result !== ISOLATED_RESULT.notRun && !(s.code === ISOLATED_CODE.silent && s.figures?.midiInputsLinked === undefined);
203
+ if (!owesIsolated(record) || record.run?.steps.some((s) => isStep(s) && decided(s)) === true) return undefined;
196
204
  const id = runIdOf(record, join(path, ".."));
197
205
  if (id === undefined) return undefined;
198
206
  const step = judgeIsolatedRun(runner(uri, cell, env));
@@ -1218,6 +1218,53 @@ export function descriptorAccessProblems(dir: string): string[] {
1218
1218
  return problems;
1219
1219
  }
1220
1220
 
1221
+ /** What the records say per plugin, not per record: a CLAP plugin has a record per offer (.default, the console's
1222
+ * in-process tier, and .custom, omx-clap-host's isolated tier), and is offered when any of its records is. */
1223
+ export interface PluginCounts {
1224
+ readonly plugins: Readonly<Record<string, number>>;
1225
+ readonly offered: Readonly<Record<string, number>>;
1226
+ readonly notOffered: Readonly<Record<string, number>>;
1227
+ }
1228
+
1229
+ export function pluginCounts(qualificationDir: string): PluginCounts {
1230
+ const offeredBy = new Map<string, boolean>();
1231
+ let files: string[] = [];
1232
+ try {
1233
+ files = readdirSync(qualificationDir).filter((f) => f.endsWith(".qualification.json"));
1234
+ } catch {
1235
+ // no directory, no record
1236
+ }
1237
+ for (const f of files) {
1238
+ let r: { plugin?: { format?: string; id?: string }; tiers?: { offeredOn?: readonly string[] } };
1239
+ try {
1240
+ r = JSON.parse(readFileSync(join(qualificationDir, f), "utf8"));
1241
+ } catch {
1242
+ continue;
1243
+ }
1244
+ if (!r.plugin?.format || !r.plugin.id) continue;
1245
+ const key = `${r.plugin.format}\0${r.plugin.id}`;
1246
+ offeredBy.set(key, (offeredBy.get(key) ?? false) || (r.tiers?.offeredOn?.length ?? 0) > 0);
1247
+ }
1248
+ const plugins: Record<string, number> = {}, offered: Record<string, number> = {}, notOffered: Record<string, number> = {};
1249
+ for (const [key, isOffered] of offeredBy) {
1250
+ const format = key.split("\0")[0]!;
1251
+ plugins[format] = (plugins[format] ?? 0) + 1;
1252
+ const into = isOffered ? offered : notOffered;
1253
+ into[format] = (into[format] ?? 0) + 1;
1254
+ }
1255
+ return { plugins, offered, notOffered };
1256
+ }
1257
+
1258
+ /** The per-plugin counts as one line: `plugins 1577 (clap 248, lv2 1329), offered 1399 (...), not offered 178 (...)`. */
1259
+ export function pluginCountsLine(c: PluginCounts): string {
1260
+ const part = (m: Readonly<Record<string, number>>): string => {
1261
+ const keys = Object.keys(m).sort();
1262
+ const total = keys.reduce((n, k) => n + m[k]!, 0);
1263
+ return `${String(total)}${keys.length === 0 ? "" : ` (${keys.map((k) => `${k} ${String(m[k])}`).join(", ")})`}`;
1264
+ };
1265
+ return `plugins ${part(c.plugins)}, offered ${part(c.offered)}, not offered ${part(c.notOffered)}`;
1266
+ }
1267
+
1221
1268
  export function summaryLine(s: SyncSummary): string {
1222
1269
  return `qualified ${String(s.qualified)}, skipped ${String(s.skipped)}, refused ${String(s.refused)}, crashed ${String(s.crashed)}, hung ${String(s.hung)}, unmeasured ${String(s.unmeasured)}, distributed ${String(s.distributed)}${s.refreshed ? `, pins refreshed ${String(s.refreshed)}` : ""}${s.rejudge === undefined ? "" : `, rejudged ${String(s.rejudge.changed)}, unchanged ${String(s.rejudge.unchanged)}, owing ${String(s.rejudge.owing)}`}${s.memoryOnly === undefined ? "" : `, memory measured ${String(s.memoryOnly.measured)}, already ${String(s.memoryOnly.already)}, failed ${String(s.memoryOnly.failed)}, leaks not measured`}${s.adapterOnly === undefined ? "" : `, adapter run ${String(s.adapterOnly.measured)}, already ${String(s.adapterOnly.already)}, failed ${String(s.adapterOnly.failed)}`}, ${s.seconds.toFixed(1)} s`;
1223
1270
  }
@@ -107,6 +107,7 @@ async function main() {
107
107
  xruns: heard.xruns,
108
108
  finite: heard.finite,
109
109
  silent: heard.silent,
110
+ midiInputsLinked: heard.midiInputsLinked,
110
111
  ...(ticksBefore === undefined || ticksAfter === undefined ? {} : { cpuPercent: ((ticksAfter - ticksBefore) * 100000) / CLOCK_TICKS / windowMs }),
111
112
  };
112
113
  }
@@ -18,9 +18,12 @@ const STEP_MS = Number(process.env.OMX_ADMIT_STEP_MS ?? 0) || 10000;
18
18
  const ADDRESS_KB = process.env.OMX_ADMIT_ADDRESS_KB;
19
19
  const STATE_DIR = process.env.OMX_ADMIT_STATE_DIR;
20
20
  const INSTANCE = 0;
21
- // an instrument under the adapter is played: one note, held this long, its output read at its JACK ports
21
+ // an instrument under the adapter is played: a wide range of keys, since a drum kit answers only the ones it maps, each
22
+ // held, then this long; its output is read at its JACK ports from before the first note, so a short hit counts
22
23
  const NOTE_WINDOW_MS = 1000;
23
- const NOTE_ON = `note_on ${INSTANCE} 0 60 100`;
24
+ const NOTE_FIRST = 36; // the same keys as native/isolated-signal.c plays
25
+ const NOTE_COUNT = 30;
26
+ const NOTE_VELOCITY = 100;
24
27
  const ADAPTER = "omx-clap-host";
25
28
  const COMMANDS = [
26
29
  ["add", `add ${uri} ${INSTANCE}`],
@@ -133,9 +136,11 @@ async function main() {
133
136
  ports.length === 0
134
137
  ? { problem: "no output port" }
135
138
  : await peakDuring(ports, async () => {
136
- await ask(NOTE_ON);
139
+ // a host that stops answering costs one budget, not one for each key
140
+ for (let k = 0; k < NOTE_COUNT; k++) if ((await ask(`note_on ${INSTANCE} 0 ${NOTE_FIRST + k} ${NOTE_VELOCITY}`)) === undefined) break;
137
141
  await sleep(NOTE_WINDOW_MS);
138
142
  });
143
+ if (sound.peak !== undefined) sound.notes = NOTE_COUNT;
139
144
  }
140
145
  buffer = "";
141
146
  sock.write(`${line}\0`);