From a20ce6d4b4cc8e6e7b49a127a36f72094b5a1a3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:42:00 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=204=EB=8B=A8=EA=B3=84=20PR=2020?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M9se1jvH1i5Sc12oaN2xSW From 536bb0bab13d9211746455d189c57df5085802aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:51:10 +0000 Subject: [PATCH 2/2] docs: add the audio WebSocket vectors and check:audio-vectors Twelve vectors of docs/audio-ws-spec.md and three REST vectors of /api/audio, replayed against the virtual panel through a WebSocket sender Node 20 can run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M9se1jvH1i5Sc12oaN2xSW --- docs/audio-ws-vectors/README.md | 13 ++++ docs/audio-ws-vectors/lanes-bus.json | 12 +++ docs/audio-ws-vectors/lanes-close.json | 11 +++ docs/audio-ws-vectors/lanes-hand.json | 17 +++++ docs/audio-ws-vectors/messages-a.json | 15 ++++ docs/audio-ws-vectors/messages-d.json | 12 +++ docs/audio-ws-vectors/messages-k.json | 11 +++ docs/audio-ws-vectors/messages-off-n.json | 13 ++++ docs/audio-ws-vectors/messages-off.json | 11 +++ docs/audio-ws-vectors/messages-unknown.json | 17 +++++ docs/audio-ws-vectors/release-500ms.json | 11 +++ docs/audio-ws-vectors/release-resend.json | 14 ++++ docs/audio-ws-vectors/switch-off.json | 16 ++++ docs/rest-api-vectors/audio-caps.json | 9 +++ docs/rest-api-vectors/audio-get.json | 8 ++ docs/rest-api-vectors/audio-post.json | 11 +++ web/scripts/drift/_ws.ts | 83 +++++++++++++++++++++ web/scripts/drift/audio-vectors.ts | 10 +++ 18 files changed, 294 insertions(+) create mode 100644 docs/audio-ws-vectors/README.md create mode 100644 docs/audio-ws-vectors/lanes-bus.json create mode 100644 docs/audio-ws-vectors/lanes-close.json create mode 100644 docs/audio-ws-vectors/lanes-hand.json create mode 100644 docs/audio-ws-vectors/messages-a.json create mode 100644 docs/audio-ws-vectors/messages-d.json create mode 100644 docs/audio-ws-vectors/messages-k.json create mode 100644 docs/audio-ws-vectors/messages-off-n.json create mode 100644 docs/audio-ws-vectors/messages-off.json create mode 100644 docs/audio-ws-vectors/messages-unknown.json create mode 100644 docs/audio-ws-vectors/release-500ms.json create mode 100644 docs/audio-ws-vectors/release-resend.json create mode 100644 docs/audio-ws-vectors/switch-off.json create mode 100644 docs/rest-api-vectors/audio-caps.json create mode 100644 docs/rest-api-vectors/audio-get.json create mode 100644 docs/rest-api-vectors/audio-post.json create mode 100644 web/scripts/drift/_ws.ts create mode 100644 web/scripts/drift/audio-vectors.ts diff --git a/docs/audio-ws-vectors/README.md b/docs/audio-ws-vectors/README.md new file mode 100644 index 000000000..c9ec8b651 --- /dev/null +++ b/docs/audio-ws-vectors/README.md @@ -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. diff --git a/docs/audio-ws-vectors/lanes-bus.json b/docs/audio-ws-vectors/lanes-bus.json new file mode 100644 index 000000000..7dd5dbd4b --- /dev/null +++ b/docs/audio-ws-vectors/lanes-bus.json @@ -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, "", "", ""], "paramActive": [true, false, false, false], "laneActive": [false, true, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/lanes-close.json b/docs/audio-ws-vectors/lanes-close.json new file mode 100644 index 000000000..86bd585cf --- /dev/null +++ b/docs/audio-ws-vectors/lanes-close.json @@ -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 } } } + ] +} diff --git a/docs/audio-ws-vectors/lanes-hand.json b/docs/audio-ws-vectors/lanes-hand.json new file mode 100644 index 000000000..ef21309e7 --- /dev/null +++ b/docs/audio-ws-vectors/lanes-hand.json @@ -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] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-a.json b/docs/audio-ws-vectors/messages-a.json new file mode 100644 index 000000000..ba3f74ad0 --- /dev/null +++ b/docs/audio-ws-vectors/messages-a.json @@ -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": ["", 0.25, 0.75, 0.2] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-d.json b/docs/audio-ws-vectors/messages-d.json new file mode 100644 index 000000000..b0e0c37aa --- /dev/null +++ b/docs/audio-ws-vectors/messages-d.json @@ -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] } } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-k.json b/docs/audio-ws-vectors/messages-k.json new file mode 100644 index 000000000..7d0b43f11 --- /dev/null +++ b/docs/audio-ws-vectors/messages-k.json @@ -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, "", 1, 0], "laneActive": [true, false, true, true] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-off-n.json b/docs/audio-ws-vectors/messages-off-n.json new file mode 100644 index 000000000..e0c93db68 --- /dev/null +++ b/docs/audio-ws-vectors/messages-off-n.json @@ -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] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-off.json b/docs/audio-ws-vectors/messages-off.json new file mode 100644 index 000000000..ed702e332 --- /dev/null +++ b/docs/audio-ws-vectors/messages-off.json @@ -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] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-unknown.json b/docs/audio-ws-vectors/messages-unknown.json new file mode 100644 index 000000000..03995d998 --- /dev/null +++ b/docs/audio-ws-vectors/messages-unknown.json @@ -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] } } } + ] +} diff --git a/docs/audio-ws-vectors/release-500ms.json b/docs/audio-ws-vectors/release-500ms.json new file mode 100644 index 000000000..d4d37d3dc --- /dev/null +++ b/docs/audio-ws-vectors/release-500ms.json @@ -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] } } } + ] +} diff --git a/docs/audio-ws-vectors/release-resend.json b/docs/audio-ws-vectors/release-resend.json new file mode 100644 index 000000000..114351b02 --- /dev/null +++ b/docs/audio-ws-vectors/release-resend.json @@ -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] } } } + ] +} diff --git a/docs/audio-ws-vectors/switch-off.json b/docs/audio-ws-vectors/switch-off.json new file mode 100644 index 000000000..c2cc95d50 --- /dev/null +++ b/docs/audio-ws-vectors/switch-off.json @@ -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] } } } + ] +} diff --git a/docs/rest-api-vectors/audio-caps.json b/docs/rest-api-vectors/audio-caps.json new file mode 100644 index 000000000..2d0ac0727 --- /dev/null +++ b/docs/rest-api-vectors/audio-caps.json @@ -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" } } } } + ] +} diff --git a/docs/rest-api-vectors/audio-get.json b/docs/rest-api-vectors/audio-get.json new file mode 100644 index 000000000..84e71a92a --- /dev/null +++ b/docs/rest-api-vectors/audio-get.json @@ -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": "", "audioClients": 0 } } } } + ] +} diff --git a/docs/rest-api-vectors/audio-post.json b/docs/rest-api-vectors/audio-post.json new file mode 100644 index 000000000..80bbec28b --- /dev/null +++ b/docs/rest-api-vectors/audio-post.json @@ -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 } } } } + ] +} diff --git a/web/scripts/drift/_ws.ts b/web/scripts/drift/_ws.ts new file mode 100644 index 000000000..ad501f264 --- /dev/null +++ b/web/scripts/drift/_ws.ts @@ -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((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((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((done) => (socket && !socket.destroyed ? socket.once("close", () => done()).destroy() : done())), + }; +}; diff --git a/web/scripts/drift/audio-vectors.ts b/web/scripts/drift/audio-vectors.ts new file mode 100644 index 000000000..ae8dd4e6c --- /dev/null +++ b/web/scripts/drift/audio-vectors.ts @@ -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);