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
15 changes: 15 additions & 0 deletions docs/midi-vectors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# MIDI vectors

Each file here is one sentence of [midi-spec.md](../midi-spec.md) and the messages that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:midi-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `midi` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along.

A file's name says which table of the spec it reads. `in-` is a row of *Host → device*, or the channel rule under it, and shows what the panel does with that message. `out-` is a row of *Device → host*, something the panel sends when a hand moves it. `/api/midi` is REST, so its vectors are in [rest-api-vectors](../rest-api-vectors/README.md) and start with `midi-`.

A message is its bytes, the status and then its data. `[176, 24, 65]` is CC 24 at 65 on channel 1, `[144, 60, 127]` is note 60 on and `[192, 3]` is Program Change 3. Each one the vector sends goes in an RTP-MIDI packet of its own. What the panel sends is read back into the same form, one message to an entry, with running status filled in.

The suite brings the session. `web/scripts/drift/_rtpmidi.ts` is an initiator shaped like [`rtpmidi_probe.py`](../../tools/rtpmidi-probe/rtpmidi_probe.py). Before the first step it invites the panel on its control port and then on the data port after it, and syncs clocks once. After the last step it says `BY`.

The panel applies what has arrived at the end of a frame, and the pattern is given it on the next. So a vector gives a message 20 ms, a frame at 60 fps and a little more, before it looks. A hand does not take back a channel a CC wrote less than 250 ms before (`ABSOLUTE_RELEASE_GRACE_MS` in `core_bus.h`). That rule is the firmware's, not the spec's, and the REST vectors wait it out the same way: `in-cc-hold.json` holds the channel a second before the turn.

One vector keeps the spec where the firmware does something else. `in-ignored.json` sends CC 120, 121 and 123 while a note is held, and the firmware lets the note go on any of them (`core_midi.h`).

Some of the spec has no vectors. MIDI over USB needs the S3's OTG controller, and a Node process has no USB device side. The receive map both transports share is checked natively in CI by `midi_test.cpp`. A vector opens one session, so the limit of two participants is not replayed, and the virtual panel does not advertise itself over Bonjour. It does not reboot or run shows, so a Program Change remembered across a reboot and one a show ignores are not replayed either. Channel `0` is a build setting. CC 24 to 27 are sent only at 63, 64 and 65, where the spec's two readings of a relative value agree.
19 changes: 19 additions & 0 deletions docs/midi-vectors/in-cc-absolute.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "`0..127` maps to the device's `0..1000` bus (rounded; 127 = 1000).",
"features": ["midi"],
"steps": [
{ "send": { "midi": [176, 20, 0] } },
{ "send": { "wait": 20 }, "expect": { "status": { "params": [0, "<integer>", "<integer>", "<integer>"], "paramActive": [true, false, false, false] } } },
{ "send": { "midi": [176, 20, 1] } },
{ "send": { "wait": 20 }, "expect": { "status": { "params": [8, "<integer>", "<integer>", "<integer>"] } } },
{ "send": { "midi": [176, 20, 64] } },
{ "send": { "wait": 20 }, "expect": { "status": { "params": [504, "<integer>", "<integer>", "<integer>"] } } },
{ "send": { "midi": [176, 20, 127] } },
{ "send": { "wait": 20 }, "expect": { "status": { "params": [1000, "<integer>", "<integer>", "<integer>"] } } },
{ "send": { "midi": [176, 21, 127] } },
{ "send": { "midi": [176, 22, 64] } },
{ "send": { "midi": [176, 23, 1] } },
{ "send": { "wait": 20 }, "expect": { "status": { "params": [1000, 1000, 504, 8], "paramActive": [true, true, true, true] } } }
]
}
13 changes: 13 additions & 0 deletions docs/midi-vectors/in-cc-hold.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "The value **holds** until a hand turns that knob, exactly like `POST /api/params` and MQTT `param/N`.",
"features": ["midi"],
"steps": [
{ "send": { "midi": [176, 21, 100] } },
{ "send": { "wait": 1000 }, "expect": { "status": { "params": ["<integer>", 787, "<integer>", "<integer>"], "paramActive": [false, true, false, false] } } },
{ "send": { "hand": { "knob": 1, "turn": 1 } } },
{ "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, true, false, false] } } },
{ "send": { "hand": { "knob": 2, "turn": -1 } } },
{ "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } }
]
}
11 changes: 11 additions & 0 deletions docs/midi-vectors/in-cc-keeps-hold.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "Does not release an absolute hold.",
"features": ["midi"],
"steps": [
{ "send": { "midi": [176, 20, 127] } },
{ "send": { "wait": 1000 }, "expect": { "status": { "paramActive": [true, false, false, false] } } },
{ "send": { "midi": [176, 24, 63] } },
{ "send": { "wait": 20 }, "expect": { "status": { "params": [1000, "<integer>", "<integer>", "<integer>"], "paramActive": [true, false, false, false] } } }
]
}
12 changes: 12 additions & 0 deletions docs/midi-vectors/in-cc-merged.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`.",
"features": ["midi"],
"steps": [
{ "send": { "hand": { "knob": 3, "turn": 2 } } },
{ "send": { "midi": [176, 26, 65] } },
{ "send": { "midi": [176, 26, 65] } },
{ "send": { "midi": [176, 26, 65] } },
{ "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 0, 5, 0] } } } }
]
}
15 changes: 15 additions & 0 deletions docs/midi-vectors/in-cc-relative.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "`64` = no motion, `65` = +1 detent, `63` = −1",
"features": ["midi"],
"steps": [
{ "send": { "midi": [176, 24, 65] } },
{ "send": { "midi": [176, 25, 63] } },
{ "send": { "midi": [176, 26, 64] } },
{ "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [1, -1, 0, 0] } } } },
{ "send": { "midi": [176, 27, 63] } },
{ "send": { "midi": [176, 27, 63] } },
{ "send": { "midi": [176, 24, 65] } },
{ "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [2, -1, 0, -2] } } } }
]
}
14 changes: 14 additions & 0 deletions docs/midi-vectors/in-channel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "Messages on another channel are ignored unless the channel is `0`.",
"features": ["midi"],
"steps": [
{ "send": { "midi": [177, 20, 127] } },
{ "send": { "midi": [178, 25, 65] } },
{ "send": { "midi": [159, 60, 127] } },
{ "send": { "midi": [193, 3] } },
{ "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] }, "input": { "given": { "deltas": [0, 0, 0, 0], "presses": [0, 0, 0, 0] }, "pattern": { "index": 0 } } } },
{ "send": { "midi": [176, 25, 65] } },
{ "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 1, 0, 0] } } } }
]
}
20 changes: 20 additions & 0 deletions docs/midi-vectors/in-ignored.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "Everything else is ignored.",
"features": ["midi"],
"steps": [
{ "send": { "midi": [144, 60, 127] } },
{ "send": { "wait": 20 }, "expect": { "input": { "given": { "held": [true, false, false, false] } } } },
{ "send": { "midi": [176, 123, 0] } },
{ "send": { "midi": [176, 120, 0] } },
{ "send": { "midi": [176, 121, 0] } },
{ "send": { "midi": [176, 19, 127] } },
{ "send": { "midi": [176, 28, 65] } },
{ "send": { "midi": [144, 64, 127] } },
{ "send": { "midi": [144, 59, 127] } },
{ "send": { "midi": [224, 0, 127] } },
{ "send": { "midi": [208, 100] } },
{ "send": { "midi": [160, 61, 100] } },
{ "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] }, "input": { "given": { "deltas": [0, 0, 0, 0], "presses": [1, 0, 0, 0], "held": [true, false, false, false] }, "pattern": { "index": 0 } } } }
]
}
18 changes: 18 additions & 0 deletions docs/midi-vectors/in-notes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "Note-on = press (edge), the button reads as held for as long as the note is held; note-off (or velocity 0) releases.",
"features": ["midi"],
"steps": [
{ "send": { "midi": [144, 60, 127] } },
{ "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 0, 0, 0], "held": [true, false, false, false] } } } },
{ "send": { "wait": 1000 }, "expect": { "input": { "given": { "presses": [1, 0, 0, 0], "held": [true, false, false, false] } } } },
{ "send": { "midi": [144, 61, 127] } },
{ "send": { "midi": [144, 62, 1] } },
{ "send": { "midi": [144, 63, 64] } },
{ "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 1, 1, 1], "held": [true, true, true, true] } } } },
{ "send": { "midi": [128, 60, 0] } },
{ "send": { "midi": [144, 61, 0] } },
{ "send": { "midi": [128, 62, 64] } },
{ "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 1, 1, 1], "held": [false, false, false, true] } } } }
]
}
13 changes: 13 additions & 0 deletions docs/midi-vectors/in-program-change.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"contract": "docs/midi-spec.md#host--device",
"says": "Select pattern index `n` (0-based, the order on `/patterns`).",
"features": ["midi"],
"steps": [
{ "send": { "midi": [192, 3] } },
{ "send": { "wait": 20 } },
{ "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 3 } } } },
{ "send": { "midi": [192, 0] } },
{ "send": { "wait": 20 } },
{ "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 0 } } } }
]
}
130 changes: 130 additions & 0 deletions web/scripts/drift/_rtpmidi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* The RTP-MIDI sender check:midi-vectors gives _vectors.ts, an initiator shaped like
* tools/rtpmidi-probe/rtpmidi_probe.py. It invites the panel on its midi port from a control socket and
* on the next port from a data socket, then syncs clocks once. Each step's message goes in an RTP-MIDI
* packet of its own. A vector writes a message as its bytes (docs/midi-vectors/README.md), [176, 24, 65]
* for CC 24 at 65 on channel 1, and heard is each message the panel sent, read back into the same form.
*/
import { randomBytes } from "node:crypto";
import dgram from "node:dgram";

import type { Sender } from "./_vectors";

const NAME = "check:midi-vectors";
const ANSWER_MS = 2000; // the longest an invitation or a clock sync waits for the panel's answer
// Data octets after a channel status, by its high nibble.
const DATA: Record<number, number> = { 0x8: 2, 0x9: 2, 0xa: 2, 0xb: 2, 0xc: 1, 0xd: 1, 0xe: 2 };

// A session packet is two 0xFF octets and a two-letter command (AppleMIDI). Its clock counts 100 µs.
const isSession = (b: Buffer) => b.length >= 4 && b.readUInt16BE(0) === 0xffff;
const ticks = () => Math.round(performance.now() * 10);
function session(command: string, ...words: number[]) {
const b = Buffer.alloc(4 + 4 * words.length);
b.writeUInt16BE(0xffff, 0);
b.write(command, 2, "latin1");
words.forEach((w, i) => b.writeUInt32BE(w, 4 + 4 * i));
return b;
}
// A clock sync: the initiator's SSRC, the count, three pad octets and three times.
function sync(ssrc: number, count: number, times: (number | bigint)[]) {
const b = Buffer.concat([session("CK", ssrc, count << 24), Buffer.alloc(24)]);
times.forEach((t, i) => b.writeBigUInt64BE(BigInt(t), 12 + 8 * i));
return b;
}

const isMessage = (m: unknown): m is number[] =>
Array.isArray(m) && m.every((b, i) => Number.isInteger(b) && b >= (i ? 0 : 0x80) && b < (i ? 0x80 : 0xf0)) && m.length === 1 + DATA[m[0] >> 4];

/** Each channel message in an RTP-MIDI packet (RFC 6295), running status filled in. Anything else comes back in hex. */
function read(b: Buffer): unknown[] {
const long = (b[12] & 0x80) !== 0; // B: a 12-bit length
let at = long ? 14 : 13;
const end = at + (long ? ((b[12] & 0x0f) << 8) | b[13] : b[12] & 0x0f);
if (b.length < at || b[0] >> 6 !== 2 || (b[1] & 0x7f) !== 0x61 || end > b.length) return [`not RTP-MIDI: ${b.toString("hex")}`];
let [delta, running] = [(b[12] & 0x20) !== 0, 0]; // Z: the first message has a delta time too
const out: unknown[] = [];
while (at < end) {
if (delta) while (at < end && b[at++] & 0x80); // one to four octets, the last under 128
delta = true;
const status = b[at] & 0x80 ? b[at++] : running;
const m = [status, ...b.subarray(at, (at += DATA[status >> 4] ?? end))];
if (!isMessage(m)) return [...out, `not a channel message: ${b.toString("hex", 12, end)}`];
out.push(m);
running = status;
}
return out;
}

/** Sends a session packet and resolves with the answer of that command on the same socket. */
function ask(socket: dgram.Socket, port: number, b: Buffer, command: string) {
const what = `${b.toString("latin1", 2, 4)} to port ${port}`;
return new Promise<Buffer>((done, fail) => {
const timer = setTimeout(() => (socket.off("message", hear), fail(new Error(`${what}: no answer in ${ANSWER_MS} ms`))), ANSWER_MS);
function hear(r: Buffer) {
if (!isSession(r) || ![command, "NO"].includes(r.toString("latin1", 2, 4))) return;
clearTimeout(timer);
socket.off("message", hear);
if (r.toString("latin1", 2, 4) === "NO") fail(new Error(`${what}: the panel said NO`));
else done(r);
}
socket.on("message", hear);
socket.send(b, port, "127.0.0.1", (e) => e && fail(e));
});
}

const MARK = Buffer.from("settle");

async function socket() {
const s = dgram.createSocket("udp4");
await new Promise<void>((done, fail) => void s.once("error", fail).bind(0, "127.0.0.1", () => done()));
return s;
}

export const midi: Sender = async () => {
const [control, data] = [await socket(), await socket()];
const me = data.address().port;
const [ssrc, token] = [randomBytes(4).readUInt32BE(0), randomBytes(4).readUInt32BE(0)];
const [heard, marks] = [[] as unknown[], [] as (() => void)[]];
data.on("message", (b, from) => {
if (from.port === me) marks.shift()?.();
else if (!isSession(b)) heard.push(...read(b)); // session packets are open()'s
});
const send = (b: Buffer, port: number) => new Promise<void>((done, fail) => data.send(b, port, "127.0.0.1", (e) => (e ? fail(e) : done())));

let [panel, seq] = [0, randomBytes(2).readUInt16BE(0)];
return {
// An invitation on control and then on data, which the panel keeps on the port after it, and one clock sync.
open: async ({ ports }) => {
const invite = Buffer.concat([session("IN", 2, token, ssrc), Buffer.from(`${NAME}\0`)]);
await ask(control, ports.midi, invite, "OK");
await ask(data, ports.midi + 1, invite, "OK");
const answer = await ask(data, ports.midi + 1, sync(ssrc, 0, [ticks(), 0, 0]), "CK");
await send(sync(ssrc, 2, [answer.readBigUInt64BE(12), answer.readBigUInt64BE(20), ticks()]), ports.midi + 1);
panel = ports.midi;
},
send: async (what) => {
if (!isMessage(what)) throw new Error(`midi sends one channel message, [status, ...data], not ${JSON.stringify(what)}`);
const b = Buffer.alloc(13);
b[0] = 0x80; // version 2
b[1] = 0x61; // payload type 97
b.writeUInt16BE(seq, 2);
b.writeUInt32BE(ticks() % 2 ** 32, 4);
b.writeUInt32BE(ssrc, 8);
b[12] = what.length; // a list under 16 octets has the short header
seq = (seq + 1) & 0xffff;
await send(Buffer.concat([b, Buffer.from(what)]), panel + 1);
},
heard,
// A datagram to itself. The socket reads in order, so once it is back, all the panel sent before it is in heard.
settle: () =>
new Promise<void>((done, fail) => {
marks.push(done);
send(MARK, me).catch(fail);
}),
// BY, as the probe ends, since a device keeps two participants at most.
close: async () => {
if (panel) await new Promise<void>((done) => control.send(session("BY", 2, token, ssrc), panel, "127.0.0.1", () => done()));
await Promise.all([control, data].map((s) => new Promise<void>((done) => s.close(() => done()))));
},
};
};
10 changes: 10 additions & 0 deletions web/scripts/drift/midi-vectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* check:midi-vectors: docs/midi-vectors/ against a fresh virtual panel each
* (docs/contract-vectors.md), built with the midi feature. The sender is
* _rtpmidi.ts, one RTP-MIDI session a vector.
*/
import { done } from "./_lib";
import { midi } from "./_rtpmidi";
import { replay } from "./_vectors";

replay("docs/midi-vectors", { midi }).then(done);
Loading