memlnaut-nisps/manifold/src/backends/useq-protocol.ts
monkey-w1n5t0n 4656568d4f feat(manifold,firmware): restore uSEQ CV/gate as an Outputs backend
Restores the April-2026 "uSEQ-Celium" functionality (browser → uSEQ
hardware + CV expander over USB Web Serial) as a first-class Manifold
Outputs backend, and re-vendors the RP2040 firmware into the repo.

- protocol v2 (uSEQ-CV): firmware/useq-celium/shared/protocol.h is the
  single source of truth, mirrored by manifold/src/backends/useq-protocol.ts.
  26-byte OUTPUT frame, 11×u16 CV (12-bit) + 3-gate bitfield + XOR; fixed
  topology; host-agnostic so the MEMLNaut RP2350 can emit identical bytes.
  Spec in docs/useq-celium/protocol.md.
- firmware/useq-celium/{main,expander}: PlatformIO RP2040 firmware rewritten
  to v2 from the real April pin maps (expander I2C addr 0x10).
- UseqCvBackend (id cvgate): Web Serial connect/identify/disconnect, 100 Hz
  stream, per-channel dead-zone, gate thresholding; modeled on midi-backend.
  Per-output CvSpec (channel + gateThreshold) on MFParam; config UI in
  OutputsBackendConfig + BackendAdvanced; new "CV / uSEQ" top-dock mode.
- bun-test for the protocol frame layout; MAP.md updated.
2026-06-28 22:30:54 +02:00

143 lines
5.6 KiB
TypeScript

/**
* uSEQ-CV wire protocol v2 — TypeScript mirror of
* `firmware/useq-celium/shared/protocol.h`. Any change here MUST track the C
* header; useq-protocol.test.ts asserts the frame sizes match.
*
* Pure + framework-neutral (no navigator / DOM) so it is unit-testable and safe
* to import from anywhere. See docs/useq-celium/protocol.md.
*/
// ─── Sync bytes ──────────────────────────────────────────────────────────────
export const SYNC_HOST = 0xaa; // host → uSEQ
export const SYNC_DEV = 0xbb; // uSEQ → host
export const SYNC_I2C = 0xcc;
export const SYNC_I2C_IDENTIFY = 0xdd;
// ─── Message types ───────────────────────────────────────────────────────────
export const MSG_OUTPUT = 0x01; // host → uSEQ
export const MSG_IDENTIFY = 0x03; // host ↔ uSEQ
export const MSG_INPUT = 0x01; // uSEQ → host (on SYNC_DEV)
// ─── Topology ────────────────────────────────────────────────────────────────
export const NUM_CV = 11; // CV1..CV11
export const NUM_GATE = 3; // GATE1..GATE3
export const NUM_MAIN_CV = 3; // CV1..CV3 on the main board
export const NUM_EXP_CV = 8; // CV4..CV11 on the expander
// ─── Ranges ──────────────────────────────────────────────────────────────────
export const CV_MAX = 4095; // 12-bit canonical wire value
export const PWM_MAX = 2047; // 11-bit hardware PWM
// ─── Frame sizes ─────────────────────────────────────────────────────────────
export const FRAME_OUTPUT_LEN = 2 + NUM_CV * 2 + 1 + 1; // 26
export const FRAME_IDENTIFY_LEN = 3;
export const FRAME_ACK_LEN = 4;
export const FRAME_INPUT_LEN = 11;
export const FRAME_I2C_LEN = 1 + NUM_EXP_CV * 2 + 1; // 18
// ─── Offsets (OUTPUT frame) ──────────────────────────────────────────────────
export const OFF_CV0 = 2;
export const OFF_GATES = 2 + NUM_CV * 2; // 24
export const OFF_OXSUM = FRAME_OUTPUT_LEN - 1; // 25
/** Streaming cadence the backend targets (Hz). */
export const STREAM_HZ = 100;
/** XOR checksum over buf[start..end] inclusive. */
export function xorChecksum(buf: Uint8Array, start: number, end: number): number {
let cs = 0;
for (let i = start; i <= end; i++) cs ^= buf[i];
return cs & 0xff;
}
/** Clamp + round a normalised 0..1 value to a 12-bit wire CV (0..CV_MAX). */
export function cvToWire(v: number): number {
const x = v < 0 ? 0 : v > 1 ? 1 : v;
return Math.round(x * CV_MAX);
}
/**
* Encode an OUTPUT frame into `out` (length >= FRAME_OUTPUT_LEN). `cv` holds
* NUM_CV wire values (0..CV_MAX, already scaled); `gateBits` packs GATE1..3 in
* bits 0..2. Returns the number of bytes written (FRAME_OUTPUT_LEN).
*/
export function encodeOutput(out: Uint8Array, cv: ArrayLike<number>, gateBits: number): number {
out[0] = SYNC_HOST;
out[1] = MSG_OUTPUT;
for (let i = 0; i < NUM_CV; i++) {
const v = cv[i] | 0;
const c = v < 0 ? 0 : v > CV_MAX ? CV_MAX : v;
out[OFF_CV0 + i * 2] = c & 0xff;
out[OFF_CV0 + i * 2 + 1] = (c >> 8) & 0xff;
}
out[OFF_GATES] = gateBits & 0x07;
out[OFF_OXSUM] = xorChecksum(out, 1, OFF_OXSUM - 1);
return FRAME_OUTPUT_LEN;
}
/** Encode the 3-byte IDENTIFY frame into `out`. */
export function encodeIdentify(out: Uint8Array): number {
out[0] = SYNC_HOST;
out[1] = MSG_IDENTIFY;
out[2] = xorChecksum(out, 1, 1);
return FRAME_IDENTIFY_LEN;
}
/** Device→host input readings, decoded from an INPUT frame. */
export interface UseqInputs {
i1: number;
i2: number;
ai1: number;
ai2: number;
}
/**
* Tiny resync-capable parser for the device→host stream (SYNC_DEV). Feed bytes
* as they arrive; supplies decoded INPUT readings and IDENTIFY acks via the
* callbacks. Lean state machine, no allocation in steady state.
*/
export class UseqRxParser {
private buf = new Uint8Array(FRAME_INPUT_LEN);
private idx = 0;
private synced = false;
constructor(
private readonly onInputs: (v: UseqInputs) => void,
private readonly onAck: (ok: boolean) => void,
) {}
push(chunk: Uint8Array): void {
for (let k = 0; k < chunk.length; k++) {
const b = chunk[k];
if (!this.synced) {
if (b === SYNC_DEV) {
this.buf[0] = b;
this.idx = 1;
this.synced = true;
}
continue;
}
this.buf[this.idx++] = b;
if (this.idx === 2 && this.buf[1] !== MSG_INPUT && this.buf[1] !== MSG_IDENTIFY) {
this.synced = false; // unknown type → resync
continue;
}
const want = this.buf[1] === MSG_INPUT ? FRAME_INPUT_LEN : FRAME_ACK_LEN;
if (this.idx >= want) {
this.dispatch(want);
this.synced = false;
}
}
}
private dispatch(len: number): void {
if (this.buf[1] === MSG_INPUT) {
if (xorChecksum(this.buf, 1, len - 2) !== this.buf[len - 1]) return;
const rd = (o: number) => this.buf[o] | (this.buf[o + 1] << 8);
this.onInputs({ i1: rd(2), i2: rd(4), ai1: rd(6), ai2: rd(8) });
} else {
if (this.buf[3] !== (this.buf[1] ^ this.buf[2])) return;
this.onAck(this.buf[2] === 0x01);
}
}
}