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
13 changes: 13 additions & 0 deletions docs/audio-ws-vectors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Audio WebSocket vectors

Each file here is one sentence of [audio-ws-spec.md](../audio-ws-spec.md) and the messages that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:audio-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `audio` 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 starts with the part of the spec it reads. `messages-` is a row of the message table, or the rule for anything else. `lanes-` is what a lane is and what outranks it, and `release-` is the 500 ms release. `switch-` is the AUD switch. `/api/audio` is REST, so its vectors are in [rest-api-vectors](../rest-api-vectors/README.md) and start with `audio-`.

A message is the text of one frame, `"a=0.8,-,-,0.2"`, and `{ "close": 1000 }` closes the socket with that code. Node 20 has no WebSocket client, so the suite brings one, `web/scripts/drift/_ws.ts`, and opens one socket a vector before the first step. After each message it sends a ping and waits for the pong, so the panel holds the message before the clock moves. The panel reads what has arrived at the end of a frame, 16.7 ms at 60 fps. A vector gives a lane 20 ms before it reads `/api/status`, and a `d=` 50 ms, since its clicks reach the pattern a frame later. A fresh panel has Audio-React on, as a board with nothing saved boots, so no vector switches it on first.

`messages-d.json` reads "normalized" as a share of a parameter's whole range, which rest-api.md puts at 48 detents. A `d=` of 0.5 is 24 clicks.

Three vectors keep the spec where the firmware does something else. `messages-a.json` sends `a=-0.5,0.25,-,-`, and the firmware takes the leading `-` for a skipped slot and puts 0.5 on the second lane (the `a=` parse in `core_audio_ws.h`). `messages-unknown.json` sends `off=x`, which the firmware reads as `off=0`. `lanes-hand.json` sends a `d=` to a knob a lane drives, and the firmware holds that knob five seconds as if a hand had turned it (`applyLaneMotion()` in `patternflow.ino`).

Some of the spec has no vectors. The virtual panel has no microphone and no weather, so the WebSocket is the only source its lanes have. A vector talks over one socket, so two clients writing the same knob are not replayed, and neither is a socket that closes while another stays open. The one-in-flight pacing and the extension's resend timer are rules for a client, and a panel has nothing to show for them.
12 changes: 12 additions & 0 deletions docs/audio-ws-vectors/lanes-bus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is",
"says": "Priority per knob, highest first (see `abi/pf_params.h`):",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.5,0.5,-,-" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, false] } } },
{ "send": { "http": "POST /api/params?p1=700" } },
{ "send": { "ws": "a=0.5,0.5,-,-" } },
{ "send": { "wait": 20 }, "expect": { "status": { "params": [700, "<integer>", "<integer>", "<integer>"], "paramActive": [true, false, false, false], "laneActive": [false, true, false, false] } } }
]
}
11 changes: 11 additions & 0 deletions docs/audio-ws-vectors/lanes-close.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is",
"says": "the firmware also releases lanes when the socket closes",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.25,0.5,0.75,1" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true], "audioClients": 1 } } },
{ "send": { "ws": { "close": 1000 } } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, false, false, false], "audioClients": 0 } } }
]
}
17 changes: 17 additions & 0 deletions docs/audio-ws-vectors/lanes-hand.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is",
"says": "A physical turn of an encoder takes that knob back and holds it for five seconds; keep streaming and the lane resumes when the hold expires.",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.5,0.5,-,-" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, false] } } },
{ "send": { "hand": { "knob": 1, "turn": 1 } } },
{ "send": { "ws": "d=1,v=0.25" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, false] } } },
{ "send": { "wait": 4700 } },
{ "send": { "ws": "a=0.5,0.5,-,-" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, false] } } },
{ "send": { "ws": "a=0.5,0.5,-,-" } },
{ "send": { "wait": 300 }, "expect": { "status": { "laneActive": [true, true, false, false] } } }
]
}
15 changes: 15 additions & 0 deletions docs/audio-ws-vectors/messages-a.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"contract": "docs/audio-ws-spec.md#messages",
"says": "A literal `-` in a slot leaves that lane untouched: `a=0.8,-,-,0.2`.",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.25,0.5,0.75,1" } },
{ "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, 0.5, 0.75, 1], "laneActive": [true, true, true, true] } } },
{ "send": { "wait": 200 } },
{ "send": { "ws": "a=0.8,-,-,0.2" } },
{ "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.8, 0.5, 0.75, 0.2], "laneActive": [true, true, true, true] } } },
{ "send": { "wait": 400 }, "expect": { "status": { "laneActive": [true, false, false, true] } } },
{ "send": { "ws": "a=-0.5,0.25,-,-" } },
{ "send": { "wait": 20 }, "expect": { "status": { "lanes": ["<number>", 0.25, 0.75, 0.2] } } }
]
}
12 changes: 12 additions & 0 deletions docs/audio-ws-vectors/messages-d.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"contract": "docs/audio-ws-spec.md#messages",
"says": "Add a normalized delta `F` (−1..1) to knob `N` — encoder-style motion rather than a level.",
"features": ["audio"],
"steps": [
{ "send": { "ws": "d=1,v=0.5" } },
{ "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [0, 24, 0, 0] } }, "status": { "laneActive": [false, false, false, false] } } },
{ "send": { "ws": "d=1,v=-0.25" } },
{ "send": { "ws": "d=3,v=-1" } },
{ "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [0, 12, 0, -48] } } } }
]
}
11 changes: 11 additions & 0 deletions docs/audio-ws-vectors/messages-k.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"contract": "docs/audio-ws-spec.md#messages",
"says": "Set lane `N` (0..3) to `F` (clamped to `0..1`).",
"features": ["audio"],
"steps": [
{ "send": { "ws": "k=0,v=0.25" } },
{ "send": { "ws": "k=2,v=1.5" } },
{ "send": { "ws": "k=3,v=-2" } },
{ "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, "<number>", 1, 0], "laneActive": [true, false, true, true] } } }
]
}
13 changes: 13 additions & 0 deletions docs/audio-ws-vectors/messages-off-n.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"contract": "docs/audio-ws-spec.md#messages",
"says": "Release knob `N` back to encoder control.",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.25,0.5,0.75,1" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } },
{ "send": { "ws": "off=2" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, true] } } },
{ "send": { "ws": "off=0" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, true] } } }
]
}
11 changes: 11 additions & 0 deletions docs/audio-ws-vectors/messages-off.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"contract": "docs/audio-ws-spec.md#messages",
"says": "Release all four.",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.25,0.5,0.75,1" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } },
{ "send": { "ws": "off" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, false, false, false] } } }
]
}
17 changes: 17 additions & 0 deletions docs/audio-ws-vectors/messages-unknown.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"contract": "docs/audio-ws-spec.md#messages",
"says": "Anything else is **silently ignored** — that is the compatibility rule.",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.25,0.5,0.75,1" } },
{ "send": { "wait": 20 } },
{ "send": { "ws": "x=1" } },
{ "send": { "ws": "k=4,v=0.5" } },
{ "send": { "ws": "d=4,v=1" } },
{ "send": { "ws": "off=x" } },
{ "send": { "ws": "hello" } },
{ "send": { "wait": 50 }, "expect": { "status": { "lanes": [0.25, 0.5, 0.75, 1], "laneActive": [true, true, true, true] }, "input": { "given": { "deltas": [0, 0, 0, 0] } } } },
{ "send": { "ws": "k=1,v=0" } },
{ "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, 0, 0.75, 1] } } }
]
}
11 changes: 11 additions & 0 deletions docs/audio-ws-vectors/release-500ms.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"contract": "docs/audio-ws-spec.md#holding-a-value--the-500-ms-release",
"says": "A lane is handed back to its encoder 500 ms after the last message that set it.",
"features": ["audio"],
"steps": [
{ "send": { "http": "POST /api/audio?on=1" } },
{ "send": { "ws": "a=0.8,-,-,0.2" } },
{ "send": { "wait": 400 }, "expect": { "status": { "laneActive": [true, false, false, true] } } },
{ "send": { "wait": 200 }, "expect": { "status": { "laneActive": [false, false, false, false] } } }
]
}
14 changes: 14 additions & 0 deletions docs/audio-ws-vectors/release-resend.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"contract": "docs/audio-ws-spec.md#holding-a-value--the-500-ms-release",
"says": "So a client that means to keep driving **resends its last message at least every 250 ms**, changed or not.",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.5,-,-,-" } },
{ "send": { "wait": 250 } },
{ "send": { "ws": "a=0.5,-,-,-" } },
{ "send": { "wait": 250 } },
{ "send": { "ws": "a=0.5,-,-,-" } },
{ "send": { "wait": 250 }, "expect": { "status": { "laneActive": [true, false, false, false] } } },
{ "send": { "wait": 300 }, "expect": { "status": { "laneActive": [false, false, false, false] } } }
]
}
16 changes: 16 additions & 0 deletions docs/audio-ws-vectors/switch-off.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"contract": "docs/audio-ws-spec.md#the-switch-on-the-panel",
"says": "Switched off, the messages are parsed and nothing is driven — an open socket is not evidence that the panel is listening.",
"features": ["audio"],
"steps": [
{ "send": { "ws": "a=0.25,0.5,0.75,1" } },
{ "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } },
{ "send": { "http": "POST /api/audio?on=0" }, "expect": { "status": { "audioRuntime": false, "laneActive": [false, false, false, false] } } },
{ "send": { "ws": "a=0.25,0.5,0.75,1" } },
{ "send": { "ws": "d=0,v=0.5" } },
{ "send": { "wait": 50 }, "expect": { "status": { "laneActive": [false, false, false, false], "audioClients": 1 }, "input": { "given": { "deltas": [0, 0, 0, 0] } } } },
{ "send": { "http": "POST /api/audio?on=1" } },
{ "send": { "ws": "k=0,v=0.25" } },
{ "send": { "wait": 20 }, "expect": { "status": { "audioRuntime": true, "laneActive": [true, false, false, false] } } }
]
}
9 changes: 9 additions & 0 deletions docs/rest-api-vectors/audio-caps.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"contract": "docs/rest-api.md#conventions-for-future-additions",
"says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.",
"features": ["audio"],
"steps": [
{ "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "caps": ["patterns", "params", "sleep", "audio"] } } } },
{ "send": { "http": "GET /api/audio" }, "expect": { "reply": { "status": 200, "headers": { "content-type": "application/json" } } } }
]
}
8 changes: 8 additions & 0 deletions docs/rest-api-vectors/audio-get.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"contract": "docs/rest-api.md#audio-react-audio-edition",
"says": "`audioRuntime` (the switch) and `audioClients` (WebSocket senders connected on :81).",
"features": ["audio"],
"steps": [
{ "send": { "http": "GET /api/audio" }, "expect": { "reply": { "status": 200, "body": { "audioRuntime": "<boolean>", "audioClients": 0 } } } }
]
}
11 changes: 11 additions & 0 deletions docs/rest-api-vectors/audio-post.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"contract": "docs/rest-api.md#audio-react-audio-edition",
"says": "`on=0/1`. Persisted in NVS; answers with the new state.",
"features": ["audio"],
"steps": [
{ "send": { "http": "POST /api/audio?on=0" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "audioRuntime": false } } } },
{ "send": { "http": "GET /api/audio" }, "expect": { "reply": { "body": { "audioRuntime": false } } } },
{ "send": { "http": { "request": "POST /api/audio", "form": "on=1" } }, "expect": { "reply": { "status": 200, "body": { "ok": true, "audioRuntime": true } } } },
{ "send": { "http": "GET /api/audio" }, "expect": { "reply": { "body": { "audioRuntime": true } } } }
]
}
83 changes: 83 additions & 0 deletions web/scripts/drift/_ws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* The WebSocket sender check:audio-vectors gives _vectors.ts. Node 20, which CI runs, has no WebSocket client, so
* this is RFC 6455 on node:http's upgrade, as much as a client that only sends needs. One socket a vector, opened on
* the panel's ws port before the first step. A step's ws is the text of one frame, or { "close": 1000 } for a close
* frame with that code (docs/audio-ws-vectors/README.md). heard is each text frame the panel sent, and
* docs/audio-ws-spec.md says it sends none.
*/
import { createHash, randomBytes } from "node:crypto";
import http from "node:http";
import type { Socket } from "node:net";

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

const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // RFC 6455's, for Sec-WebSocket-Accept
const [TEXT, CLOSE, PING, PONG] = [0x1, 0x8, 0x9, 0xa];
const SETTLE_MS = 2000; // the longest the panel may take to answer a ping or a close

/** One piece, masked as a client masks every frame, its length in the seven bits that hold up to 125 bytes. */
function frame(op: number, payload: Buffer) {
if (payload.length > 125) throw new Error(`a ws message is 125 bytes at most, not ${payload.length}`);
const mask = randomBytes(4);
return Buffer.concat([Buffer.from([0x80 | op, 0x80 | payload.length]), mask, payload.map((x, i) => x ^ mask[i % 4])]);
}

export const ws: Sender = async () => {
const [heard, waits] = [[] as string[], [] as (() => void)[]];
let [socket, closed, buf] = [undefined as Socket | undefined, false, Buffer.alloc(0)];
const gone = () => {
closed = true;
waits.splice(0).forEach((done) => done());
};
// The panel's frames are unmasked and short. A pong ends the oldest wait, and a close ends them all.
function read(more: Buffer) {
buf = Buffer.concat([buf, more]);
while (buf.length >= 2 && buf.length >= 2 + (buf[1] & 0x7f)) {
const [op, payload] = [buf[0] & 0x0f, buf.subarray(2, 2 + (buf[1] & 0x7f))];
buf = buf.subarray(2 + payload.length);
if (op === TEXT) heard.push(payload.toString("utf8"));
else if (op === PONG) waits.shift()?.();
else if (op === CLOSE) return void (gone(), socket?.end());
}
}
// Writes a frame and resolves on the panel's answer, or once the socket closes.
const answered = (b: Buffer) =>
new Promise<void>((done, fail) => {
if (closed) return done();
const timer = setTimeout(() => fail(new Error(`the panel did not answer in ${SETTLE_MS} ms`)), SETTLE_MS);
waits.push(() => (clearTimeout(timer), done()));
socket!.write(b);
});

return {
open: async ({ ports }) => {
const key = randomBytes(16).toString("base64");
const headers = { Connection: "Upgrade", Upgrade: "websocket", "Sec-WebSocket-Key": key, "Sec-WebSocket-Version": "13" };
socket = await new Promise<Socket>((done, fail) => {
const req = http.request({ host: "127.0.0.1", port: ports.ws, path: "/", headers, agent: false });
req.once("response", (res) => fail(new Error(`the panel answered the handshake with ${res.statusCode}, not 101`)));
req.once("upgrade", (res, s: Socket, head: Buffer) => {
if (res.headers["sec-websocket-accept"] !== createHash("sha1").update(key + GUID).digest("base64")) return fail(new Error("Sec-WebSocket-Accept does not match the key"));
s.setNoDelay(true); // a ping behind a message not yet acknowledged would wait out a 40 ms delayed ACK
s.on("data", read).on("close", gone).on("error", () => s.destroy());
done(s);
read(head);
});
req.once("error", fail).end();
});
},
send: async (what) => {
if (closed) throw new Error("the socket is closed");
if (typeof what === "string") return void socket!.write(frame(TEXT, Buffer.from(what)));
const code = (what as { close?: unknown } | null)?.close;
if (!Number.isInteger(code) || Object.keys(what as object).length !== 1) throw new Error(`ws sends a frame's text or { "close": code }, not ${JSON.stringify(what)}`);
const b = Buffer.alloc(2);
b.writeUInt16BE(code as number);
await answered(frame(CLOSE, b)); // the panel's close frame back says it has read this one
},
heard,
// A ping. The panel reads frames in order, so once its pong is back it holds every message sent before it.
settle: () => answered(frame(PING, Buffer.alloc(0))),
close: () => new Promise<void>((done) => (socket && !socket.destroyed ? socket.once("close", () => done()).destroy() : done())),
};
};
10 changes: 10 additions & 0 deletions web/scripts/drift/audio-vectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* check:audio-vectors: docs/audio-ws-vectors/ against a fresh virtual panel each
* (docs/contract-vectors.md), built with the audio feature. The sender is
* _ws.ts, one WebSocket a vector.
*/
import { done } from "./_lib";
import { replay } from "./_vectors";
import { ws } from "./_ws";

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