Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions native/isolated-signal.c
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com> */
/* The isolated step's signal (plugin-qualify#249): a sine into every audio input of a JACK client, a note held on its MIDI
* inputs for an instrument, and what comes out of its audio outputs over a window:
/* The isolated step's signal (plugin-qualify#249): a sine into every audio input of a JACK client, notes held on its MIDI
* inputs for an instrument, and what comes out of its audio outputs over a window. The notes start once the links are made,
* which an instrument needs to hear them, and each third of the window adds one more, held, so a patch that is slow to start
* is never restarted:
* isolated_signal <client> <ms>
* prints one JSON line, {"latencyFrames":n,"xruns":n,"finite":bool,"silent":bool}. Exit 2 when the server or the client's
* prints one JSON line, {"latencyFrames":n,"xruns":n,"finite":bool,"silent":bool,"midiInputsLinked":n}. Exit 2 when the server or the client's
* ports could not be had: nothing was learned about the plugin. libjack is opened at run time, as jack_peak opens it: the
* package needs it only on a machine with a JACK server. */
#include <dlfcn.h>
Expand All @@ -20,7 +22,8 @@
#define SINE_HZ 1000.0
#define SINE_PEAK 0.25f
#define SILENCE_PEAK 1e-5f
#define NOTE 60
static const unsigned char NOTES[] = {60, 64, 67};
#define NOTE_COUNT ((int)(sizeof NOTES / sizeof NOTES[0]))
#define NOTE_VELOCITY 100
#define JACK_NO_START_SERVER 1
#define JACK_PORT_IS_INPUT 1ul
Expand Down Expand Up @@ -52,7 +55,8 @@ static int (*p_midi_write)(void *, uint32_t, const unsigned char *, size_t);
static port_t *g_out, *g_midi, *g_in[MAX_PORTS];
static int g_inputs;
static double g_phase, g_step;
static atomic_int g_xruns, g_note_sent, g_nonfinite;
static atomic_int g_xruns, g_notes_due, g_nonfinite;
static int g_notes_sent;
static _Atomic float g_peak;

static int on_process(uint32_t n, void *arg) {
Expand All @@ -65,9 +69,10 @@ static int on_process(uint32_t n, void *arg) {
}
void *midi = p_port_buffer(g_midi, n);
p_midi_clear(midi);
if (!atomic_exchange(&g_note_sent, 1)) {
unsigned char on[3] = {0x90, NOTE, NOTE_VELOCITY};
if (g_notes_sent < atomic_load(&g_notes_due)) {
unsigned char on[3] = {0x90, NOTES[g_notes_sent], NOTE_VELOCITY};
p_midi_write(midi, 0, on, sizeof on);
g_notes_sent++;
}
float peak = g_peak;
for (int p = 0; p < g_inputs; p++) {
Expand Down Expand Up @@ -151,16 +156,22 @@ int main(int argc, char **argv) {
return 2;
}
for (int i = 0; ins != NULL && ins[i] != NULL; i++) p_connect(c, p_port_name(g_out), ins[i]);
for (int i = 0; midis != NULL && midis[i] != NULL; i++) p_connect(c, p_port_name(g_midi), midis[i]);
int midi_linked = 0;
for (int i = 0; midis != NULL && midis[i] != NULL; i++)
if (p_connect(c, p_port_name(g_midi), midis[i]) == 0) midi_linked++;
for (int i = 0; i < g_inputs; i++) p_connect(c, outs[i], p_port_name(g_in[i]));
long ms = atol(argv[2]);
struct timespec window = {ms / 1000, (ms % 1000) * 1000000L};
nanosleep(&window, NULL);
long part = ms / NOTE_COUNT;
struct timespec third = {part / 1000, (part % 1000) * 1000000L};
for (int n = 1; n <= NOTE_COUNT; n++) {
atomic_store(&g_notes_due, n);
nanosleep(&third, NULL);
}
struct latency_range range = {0, 0};
p_latency_range(p_port_by_name(c, outs[0]), JACK_CAPTURE_LATENCY, &range);
float peak = g_peak;
printf("{\"latencyFrames\":%u,\"xruns\":%d,\"finite\":%s,\"silent\":%s}\n", range.max, atomic_load(&g_xruns),
atomic_load(&g_nonfinite) ? "false" : "true", peak < SILENCE_PEAK ? "true" : "false");
printf("{\"latencyFrames\":%u,\"xruns\":%d,\"finite\":%s,\"silent\":%s,\"midiInputsLinked\":%d}\n", range.max, atomic_load(&g_xruns),
atomic_load(&g_nonfinite) ? "false" : "true", peak < SILENCE_PEAK ? "true" : "false", midi_linked);
p_free(outs);
if (ins != NULL) p_free(ins);
if (midis != NULL) p_free(midis);
Expand Down
2 changes: 2 additions & 0 deletions src/qualification-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface IsolatedFigures {
/** The worker's CPU over the window, in percent of one core. */
readonly cpuPercent?: number;
readonly xruns?: number;
/** How many MIDI inputs of the plugin the step's notes were linked to. */
readonly midiInputsLinked?: number;
}

/** One step of a plugin's pass and how it ended. */
Expand Down
29 changes: 27 additions & 2 deletions src/qualify-isolated.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const scratch = mkdtempSync(join(tmpdir(), "qualify-isolated-"));
afterAll(() => rmSync(scratch, { recursive: true, force: true }));

const CELL = { rate: 48000, quantum: 128 };
const WELL: IsolatedRunFacts = { started: true, added: true, alive: true, workers: 1, instancesInWorker: 1, latencyFrames: 128, cpuPercent: 12.5, xruns: 0, finite: true, silent: false };
const WELL: IsolatedRunFacts = { started: true, added: true, alive: true, workers: 1, instancesInWorker: 1, latencyFrames: 128, cpuPercent: 12.5, xruns: 0, finite: true, silent: false, midiInputsLinked: 1 };
const pin = { uri: "urn:p", integrity: {}, verdict: "isolated" as const };

function record(over: { rating?: string; outcome?: string; run?: QualificationRecord["run"] } = {}): QualificationRecord {
Expand All @@ -51,7 +51,7 @@ function record(over: { rating?: string; outcome?: string; run?: QualificationRe

describe("the decision table", () => {
it("a plugin added, alive, alone and sounding is isolated, with the figures", () => {
expect(judgeIsolatedRun(WELL)).toEqual({ step: "isolated", result: "isolated", figures: { latencyFrames: 128, cpuPercent: 12.5, xruns: 0 } });
expect(judgeIsolatedRun(WELL)).toEqual({ step: "isolated", result: "isolated", figures: { latencyFrames: 128, cpuPercent: 12.5, xruns: 0, midiInputsLinked: 1 } });
});

it.each([
Expand All @@ -69,6 +69,11 @@ describe("the decision table", () => {
expect(step.code).toBe(code);
});

it("silent says whether a note was linked", () => {
expect(judgeIsolatedRun({ ...WELL, silent: true }).detail).toBe("notes held on 1 MIDI input and a sine at its audio inputs");
expect(judgeIsolatedRun({ ...WELL, silent: true, midiInputsLinked: 0 }).detail).toBe("no note input to link: a sine played at its audio inputs");
});

it("a machine that could not run it decides nothing about the plugin", () => {
const step = judgeIsolatedRun({ started: false, problem: "no jackd" });
expect(step.result).toBe(ISOLATED_RESULT.notRun);
Expand Down Expand Up @@ -116,6 +121,8 @@ describe("the steps a record's outcome calls for", () => {
describe("the step itself, on a private server", () => {
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "..", "native", "build", "fixtures", "rt-safe.clap");
const ID = "org.freemixer.plugin-qualify.fixture.rt-safe";
const SYNTH = join(dirname(FIXTURE), "synth.clap");
const SYNTH_ID = "org.freemixer.plugin-qualify.fixture.synth";
const installed = (tool: string): boolean => spawnSync("sh", ["-c", `command -v ${tool}`]).status === 0;
const ready = existsSync(FIXTURE) && installed("jackd") && installed("plugin-hostd");

Expand All @@ -131,6 +138,17 @@ describe("the step itself, on a private server", () => {
expect(facts.cpuPercent).toBeGreaterThanOrEqual(0);
});

it("an instrument, silent until it hears a note, gets the note and sounds: isolated", (ctx) => {
if (!ready) {
if (process.env["CI"] !== undefined) throw new Error("jackd, plugin-hostd or native/build is missing: CI installs them");
ctx.skip("needs jackd, plugin-hostd and native/build");
}
const facts = spawnedIsolatedRunner()(`clap:${SYNTH}#${SYNTH_ID}`, CELL, process.env);
const step = judgeIsolatedRun(facts);
expect(step.result, JSON.stringify(facts)).toBe(ISOLATED_RESULT.isolated);
expect(facts.midiInputsLinked, JSON.stringify(facts)).toBeGreaterThan(0);
});

it("a pass runs the step plugin after plugin: the server of the last one leaves its name free", (ctx) => {
if (!ready) {
if (process.env["CI"] !== undefined) throw new Error("jackd, plugin-hostd or native/build is missing: CI installs them");
Expand Down Expand Up @@ -188,6 +206,13 @@ describe("the completion of a record already judged", () => {
expect(done?.step.result).toBe(ISOLATED_RESULT.isolated);
const isolatedSteps = (JSON.parse(readFileSync(path, "utf8")) as QualificationRecord).run?.steps.filter((s) => s.step === "isolated");
expect(isolatedSteps).toEqual([done?.step]);
// silence from the tool that played its note before the link says nothing; silence that heard the notes does
const { midiInputsLinked: _linked, ...beforeTheLink } = WELL;
const unheard = judgeIsolatedRun({ ...beforeTheLink, silent: true });
const old = record({ run: { id: "r", steps: [...baseSteps(record()), unheard] } });
expect(completeIsolated(write(old, "unheard.qualification.json"), old, "urn:p", CELL, () => WELL, {})?.step.result).toBe(ISOLATED_RESULT.isolated);
const heard = record({ run: { id: "r", steps: [...baseSteps(record()), judgeIsolatedRun({ ...WELL, silent: true })] } });
expect(completeIsolated(write(heard, "heard.qualification.json"), heard, "urn:p", CELL, () => WELL, {})).toBeUndefined();
const refused = judgeIsolatedRun({ started: true, added: false });
const seen = record({ run: { id: "r", steps: [...baseSteps(record()), refused] } });
expect(completeIsolated(write(seen, "seen.qualification.json"), seen, "urn:p", CELL, () => WELL, {})).toBeUndefined();
Expand Down
16 changes: 12 additions & 4 deletions src/qualify-isolated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface IsolatedRunFacts {
readonly xruns?: number;
readonly finite?: boolean;
readonly silent?: boolean;
/** How many of the plugin's MIDI inputs the notes were linked to; none for a plugin that has none. */
readonly midiInputsLinked?: number;
}

export type IsolatedRunner = (uri: string, cell: GridCell, env: Readonly<Record<string, string | undefined>>) => IsolatedRunFacts;
Expand All @@ -78,16 +80,20 @@ export function judgeIsolatedRun(run: IsolatedRunFacts): RunStep {
if ((run.workers ?? 0) < 1) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.noWorker);
if (run.workers !== 1 || run.instancesInWorker !== 1) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.notAlone);
if (run.finite !== true) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.nonFinite);
if (run.silent !== false) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.silent);
if (run.silent !== false) {
const linked = run.midiInputsLinked;
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`);
}
return step(ISOLATED_RESULT.isolated);
}

function figuresOf(run: IsolatedRunFacts): IsolatedFigures | undefined {
if (run.latencyFrames === undefined && run.cpuPercent === undefined && run.xruns === undefined) return undefined;
if (run.latencyFrames === undefined && run.cpuPercent === undefined && run.xruns === undefined && run.midiInputsLinked === undefined) return undefined;
return {
...(run.latencyFrames === undefined ? {} : { latencyFrames: run.latencyFrames }),
...(run.cpuPercent === undefined ? {} : { cpuPercent: run.cpuPercent }),
...(run.xruns === undefined ? {} : { xruns: run.xruns }),
...(run.midiInputsLinked === undefined ? {} : { midiInputsLinked: run.midiInputsLinked }),
};
}

Expand Down Expand Up @@ -191,8 +197,10 @@ function runIdOf(record: QualificationRecord, dir: string): string | undefined {
*/
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 {
const isStep = (s: RunStep): boolean => s.step === RUN_STEP.isolated;
// a step that could not run decided nothing, so a pass tries it again and it takes that one's place
if (!owesIsolated(record) || record.run?.steps.some((s) => isStep(s) && s.result !== ISOLATED_RESULT.notRun) === true) return undefined;
// a step that could not run decided nothing, and neither did silence before the notes were played where a plugin could
// 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
const decided = (s: RunStep): boolean => s.result !== ISOLATED_RESULT.notRun && !(s.code === ISOLATED_CODE.silent && s.figures?.midiInputsLinked === undefined);
if (!owesIsolated(record) || record.run?.steps.some((s) => isStep(s) && decided(s)) === true) return undefined;
const id = runIdOf(record, join(path, ".."));
if (id === undefined) return undefined;
const step = judgeIsolatedRun(runner(uri, cell, env));
Expand Down
1 change: 1 addition & 0 deletions tools/isolated-step.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ async function main() {
xruns: heard.xruns,
finite: heard.finite,
silent: heard.silent,
midiInputsLinked: heard.midiInputsLinked,
...(ticksBefore === undefined || ticksAfter === undefined ? {} : { cpuPercent: ((ticksAfter - ticksBefore) * 100000) / CLOCK_TICKS / windowMs }),
};
}
Expand Down
Loading