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.
This commit is contained in:
parent
527b8fc679
commit
4656568d4f
25 changed files with 1240 additions and 18 deletions
6
MAP.md
6
MAP.md
|
|
@ -26,6 +26,7 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod
|
|||
- `settings_view.hpp` — `wire_settings(mode)`: adds on-device settings views to the MEMLNaut display carousel (TFT + rotary encoder). Joystick Dual/Single toggle for the 4-input ("two 2-D joystick") modes — "Single" pins ML input channels 2,3 to neutral via `ModeBase::set_input_pinned` (no net rebuild). Registered in the `.ino` after `addSystemInfoView()`.
|
||||
- `firmware/MEMLNaut-NISPS/src/{memllib,daisysp,nisps}` — symlinks (Arduino-CLI requires sketch-tree includes; preprocessor refuses `..` in headers).
|
||||
- `firmware/README.md` — structure + build instructions.
|
||||
- `firmware/useq-celium/` — standalone RP2040 firmware (PlatformIO, Arduino-Pico core) that turns a uSEQ module + CV expander into a USB→CV/gate converter driven by the manifold `cvgate` backend. `shared/protocol.h` is the v2 wire-protocol single source of truth (mirrored by `manifold/src/backends/useq-protocol.ts`); `main/` (USB serial → CV1–3 + GATE1–3, I2C → expander) and `expander/` (I2C slave → CV4–11). Wire spec: `docs/useq-celium/protocol.md`. Restored from the April-2026 "uSEQ-Celium" mode.
|
||||
|
||||
### `playground/` — SolidJS + Vite + TypeScript app
|
||||
- `playground/index.html`, `vite.config.ts`, `tsconfig.json`, `package.json` — scaffold. COOP/COEP headers configured.
|
||||
|
|
@ -63,7 +64,10 @@ anchor + locked decisions) and the `docs/redesign/*-spec.md` set.
|
|||
- `manifold/src/dock/` — `OutputControlRow` (off/fixed/live + mute + solo/arm + min/max/curve), `output-state.ts`,
|
||||
`OutputsBackendConfig.tsx` (per-backend specialised Outputs panel), `BackendAdvanced.tsx`.
|
||||
- `manifold/src/backends/` — `OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts`
|
||||
(WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `presets.ts` (named presets), `manager.ts`.
|
||||
(WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `vcv-backend.ts` (VCV-over-WS), `cv-backend.ts`
|
||||
(`UseqCvBackend` — uSEQ CV/gate over USB Web Serial, backend id `cvgate`) + `useq-protocol.ts` (v2 wire
|
||||
protocol, mirrors `firmware/useq-celium/shared/protocol.h`; `useq-protocol.test.ts` runs via `bun test`),
|
||||
`particle-backend.ts`, `passthrough-backend.ts`, `presets.ts` (named presets), `manager.ts`.
|
||||
- `manifold/src/midi-devices/` — external-synth device templates. `generated/` is codegen output from
|
||||
`schemas/midi_devices/` (`MIDI_DEVICES` catalogue + `MIDI_DEVICES_BY_ID`, params by name+CC). The MIDI Outputs
|
||||
config (`dock/OutputsBackendConfig.tsx`) reads it for the device picker + param-select that fills the CC table.
|
||||
|
|
|
|||
69
docs/useq-celium/protocol.md
Normal file
69
docs/useq-celium/protocol.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# uSEQ-CV wire protocol v2
|
||||
|
||||
The protocol the Manifold **CV output backend** (`manifold/src/backends/cv-backend.ts`)
|
||||
speaks over USB Web Serial to the uSEQ main module, which drives CV/gate jacks and
|
||||
forwards to its CV expander over I2C. Defined once in
|
||||
`firmware/useq-celium/shared/protocol.h` and mirrored by
|
||||
`manifold/src/backends/useq-protocol.ts` (a unit test asserts the frame sizes match).
|
||||
|
||||
## Provenance
|
||||
|
||||
Restored + modernised from the April-2026 **uSEQ-Celium** output mode (commits
|
||||
`cb1f16f`→`cd24d98`, refined `af4d4f5`; original chat
|
||||
`233900ff-c5b4-438e-937f-e8df877dae6b`). v1 sent the 3 gate channels as full
|
||||
`u16` values thresholded in firmware and a runtime CONFIG bitmask; v2 fixes the
|
||||
topology, collapses gates to a 1-byte bitfield, and widens CV to 12-bit canonical
|
||||
— leaner and host-agnostic so the MEMLNaut RP2350 firmware can emit identical
|
||||
bytes.
|
||||
|
||||
## Design
|
||||
|
||||
- **Transport:** USB CDC / UART, **115200 baud**, streamed at **~100 Hz**.
|
||||
- **Endianness:** little-endian. **Checksum:** XOR (drop frame + resync on mismatch).
|
||||
- **Framing:** fixed-length per type, keyed by a sync byte + type byte → O(1)
|
||||
parse, self-healing resync after a dropped/garbled byte.
|
||||
- **Topology (fixed):** 11 CV + 3 gate.
|
||||
- `CV1–CV3` → main module PWM (pins 21/20/19)
|
||||
- `CV4–CV11` → expander PWM (forwarded over I2C)
|
||||
- `GATE1–GATE3` → main module digital (pins 18/17/16)
|
||||
- **CV value:** 12-bit canonical `0..4095` on the wire; firmware scales to its
|
||||
11-bit PWM (`cv >> 1`). Headroom for a future 12-bit DAC.
|
||||
|
||||
## Frames
|
||||
|
||||
### `OUTPUT` — host → uSEQ (26 bytes, type `0x01`)
|
||||
|
||||
| Bytes | Field |
|
||||
|--------|--------------------------------------------------|
|
||||
| 0 | sync `0xAA` |
|
||||
| 1 | type `0x01` |
|
||||
| 2–23 | 11 × CV, `u16` LE, `0..4095` (CV1…CV11) |
|
||||
| 24 | gate bits: bit0=GATE1, bit1=GATE2, bit2=GATE3 |
|
||||
| 25 | XOR of bytes 1–24 |
|
||||
|
||||
CV order: indices 0–2 = main CV1–CV3; indices 3–10 = expander CV4–CV11.
|
||||
|
||||
### `IDENTIFY` — host → uSEQ (3 bytes, type `0x03`)
|
||||
|
||||
`[0xAA, 0x03, 0x03]` (last byte = XOR of byte 1). Main board flashes its LEDs,
|
||||
forwards `0xDD` to the expander (which flashes too), and replies with an ack.
|
||||
|
||||
### `IDENTIFY_ACK` — uSEQ → host (4 bytes)
|
||||
|
||||
`[0xBB, 0x03, 0x01, 0x02]` (status `0x01` = ok; last byte = XOR of bytes 1–2).
|
||||
|
||||
### `INPUT` — uSEQ → host (11 bytes, type `0x01`, optional)
|
||||
|
||||
`[0xBB, 0x01, i1:u16, i2:u16, ai1:u16, ai2:u16, xor(1..9)]` @ 20 Hz — the main
|
||||
board's two digital + two analog inputs, for browser-side status/visualisation.
|
||||
|
||||
### I2C — main → expander (18 bytes)
|
||||
|
||||
`[0xCC, cv:8×u16 LE (0..2047), xor(0..16)]` @ ~100 Hz on I2C addr `0x10`,
|
||||
400 kHz. A single `0xDD` byte = identify (LED sweep). The 8 values are the
|
||||
already-scaled 11-bit CV4–CV11.
|
||||
|
||||
## Reserved
|
||||
|
||||
Type `0x02` (the old runtime CONFIG / mode bitmask) is reserved and unused — v2's
|
||||
topology is fixed in firmware.
|
||||
46
firmware/useq-celium/README.md
Normal file
46
firmware/useq-celium/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# uSEQ-CV firmware
|
||||
|
||||
Turns a [uSEQ](https://www.emutelabinstruments.co.uk/useq/) module (+ its CV
|
||||
expander) into a USB→CV/gate converter driven by the Manifold browser app's **CV
|
||||
output backend** (or, in future, the MEMLNaut RP2350 firmware directly). This is
|
||||
the restored + modernised descendant of the April-2026 "uSEQ-Celium" output mode
|
||||
(provenance in `docs/useq-celium/protocol.md`).
|
||||
|
||||
Both boards are RP2040 (Raspberry Pi Pico / uSEQ hardware) flashed with the
|
||||
Arduino-Pico (Earle Philhower) core. The wire protocol (v2) is defined once in
|
||||
`shared/protocol.h` and mirrored by `manifold/src/backends/useq-protocol.ts`.
|
||||
|
||||
```
|
||||
shared/protocol.h uSEQ-CV v2 wire protocol (single source of truth)
|
||||
main/ uSEQ main module: USB serial → CV1..3 + GATE1..3, I2C → expander
|
||||
expander/ uSEQ expander: I2C slave → CV4..11 (8× PWM)
|
||||
```
|
||||
|
||||
## Topology
|
||||
|
||||
| Channel | Board | Pin(s) | Type |
|
||||
|------------|-----------|---------------|---------------|
|
||||
| CV1–CV3 | main | 21, 20, 19 | PWM (11-bit) |
|
||||
| GATE1–GATE3| main | 18, 17, 16 | digital |
|
||||
| CV4–CV11 | expander | 13,14,10,11,8,7,5,3 | PWM (11-bit) |
|
||||
|
||||
The main board forwards CV4–CV11 to the expander over I2C (addr `0x10`, 400 kHz)
|
||||
and rescans every 2 s until the expander is found (hot-attach friendly).
|
||||
|
||||
## Build / flash (PlatformIO)
|
||||
|
||||
```bash
|
||||
# main board
|
||||
cd firmware/useq-celium/main && pio run -e main -t upload
|
||||
# expander board
|
||||
cd firmware/useq-celium/expander && pio run -e expander -t upload
|
||||
```
|
||||
|
||||
(Or open each `.cpp` in the Arduino IDE with the RP2040 "Earle Philhower" core and
|
||||
add `firmware/useq-celium/shared` to the include path.)
|
||||
|
||||
## Protocol smoke
|
||||
|
||||
Hold the browser's **Identify** button (CV backend config) → both boards run an
|
||||
LED sweep, confirming the USB + I2C links end-to-end. Full frame layout:
|
||||
`docs/useq-celium/protocol.md`.
|
||||
10
firmware/useq-celium/expander/platformio.ini
Normal file
10
firmware/useq-celium/expander/platformio.ini
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
; uSEQ-CV expander board — RP2040 (uSEQ USEQHARDWARE_EXPANDER_OUT_0_1)
|
||||
; Build/upload: pio run -e expander -t upload (from firmware/useq-celium/expander)
|
||||
[env:expander]
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
|
||||
board = rpipico
|
||||
framework = arduino
|
||||
board_build.core = earlephilhower
|
||||
build_flags = -I${PROJECT_DIR}/../shared
|
||||
upload_protocol = picotool
|
||||
monitor_speed = 115200
|
||||
68
firmware/useq-celium/expander/src/expander.cpp
Normal file
68
firmware/useq-celium/expander/src/expander.cpp
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// uSEQ-CV expander firmware (protocol v2).
|
||||
//
|
||||
// Target: uSEQ USEQHARDWARE_EXPANDER_OUT_0_1 — Raspberry Pi Pico (RP2040),
|
||||
// Arduino-Pico core. I2C slave at USEQ_I2C_ADDR; receives 8 × 11-bit CV values
|
||||
// from the main board and writes them to PWM. See ../../shared/protocol.h.
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include "protocol.h"
|
||||
|
||||
// ─── Pin map (USEQHARDWARE_EXPANDER_OUT_0_1) ────────────────────────────────
|
||||
constexpr uint8_t PIN_E[USEQ_NUM_EXP_CV] = { 13, 14, 10, 11, 8, 7, 5, 3 };
|
||||
constexpr uint8_t PIN_E_LED[USEQ_NUM_EXP_CV] = { 15, 20, 17, 12, 9, 6, 2, 0 };
|
||||
constexpr uint8_t PIN_SDA = 4, PIN_SCL = 1;
|
||||
|
||||
volatile uint16_t cvValues[USEQ_NUM_EXP_CV] = {};
|
||||
volatile bool newFrame = false;
|
||||
volatile bool doSweep = false;
|
||||
|
||||
void onI2CReceive(int numBytes) {
|
||||
if (numBytes == 1) {
|
||||
if (Wire.read() == USEQ_SYNC_I2C_IDENTIFY) doSweep = true;
|
||||
return;
|
||||
}
|
||||
if (numBytes != USEQ_FRAME_I2C_LEN) {
|
||||
while (Wire.available()) Wire.read();
|
||||
return;
|
||||
}
|
||||
uint8_t buf[USEQ_FRAME_I2C_LEN];
|
||||
for (uint8_t i = 0; i < USEQ_FRAME_I2C_LEN; i++) buf[i] = Wire.read();
|
||||
if (buf[0] != USEQ_SYNC_I2C) return;
|
||||
if (useq_xor(buf, 0, USEQ_FRAME_I2C_LEN - 2) != buf[USEQ_FRAME_I2C_LEN - 1]) return;
|
||||
for (uint8_t i = 0; i < USEQ_NUM_EXP_CV; i++) {
|
||||
uint16_t v = useq_read_u16le(&buf[1 + i * 2]);
|
||||
cvValues[i] = (v > USEQ_PWM_MAX) ? USEQ_PWM_MAX : v;
|
||||
}
|
||||
newFrame = true;
|
||||
}
|
||||
|
||||
void ledSweep() {
|
||||
for (int i = 0; i < USEQ_NUM_EXP_CV; i++) { analogWrite(PIN_E_LED[i], USEQ_PWM_MAX); delay(40); }
|
||||
delay(80);
|
||||
for (int i = USEQ_NUM_EXP_CV - 1; i >= 0; i--) { analogWrite(PIN_E_LED[i], 0); delay(40); }
|
||||
}
|
||||
|
||||
void setup() {
|
||||
for (uint8_t i = 0; i < USEQ_NUM_EXP_CV; i++) {
|
||||
pinMode(PIN_E[i], OUTPUT);
|
||||
pinMode(PIN_E_LED[i], OUTPUT);
|
||||
}
|
||||
analogWriteFreq(100000);
|
||||
analogWriteRange(USEQ_PWM_MAX);
|
||||
Wire.setSDA(PIN_SDA);
|
||||
Wire.setSCL(PIN_SCL);
|
||||
Wire.begin(USEQ_I2C_ADDR);
|
||||
Wire.onReceive(onI2CReceive);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (doSweep) { doSweep = false; ledSweep(); }
|
||||
if (newFrame) {
|
||||
newFrame = false;
|
||||
for (uint8_t i = 0; i < USEQ_NUM_EXP_CV; i++) {
|
||||
uint16_t v = cvValues[i];
|
||||
analogWrite(PIN_E[i], v);
|
||||
analogWrite(PIN_E_LED[i], (uint16_t)(((uint32_t)v * v) >> 11));
|
||||
}
|
||||
}
|
||||
}
|
||||
10
firmware/useq-celium/main/platformio.ini
Normal file
10
firmware/useq-celium/main/platformio.ini
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
; uSEQ-CV main board — RP2040 (uSEQ USEQHARDWARE_1_0)
|
||||
; Build/upload: pio run -e main -t upload (from firmware/useq-celium/main)
|
||||
[env:main]
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
|
||||
board = rpipico
|
||||
framework = arduino
|
||||
board_build.core = earlephilhower
|
||||
build_flags = -I${PROJECT_DIR}/../shared
|
||||
upload_protocol = picotool
|
||||
monitor_speed = 115200
|
||||
174
firmware/useq-celium/main/src/main.cpp
Normal file
174
firmware/useq-celium/main/src/main.cpp
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// uSEQ-CV main board firmware (protocol v2).
|
||||
//
|
||||
// Target: uSEQ USEQHARDWARE_1_0 — Raspberry Pi Pico (RP2040), Arduino-Pico core
|
||||
// (Earle Philhower). No dependencies beyond Arduino + Wire.
|
||||
//
|
||||
// Receives OUTPUT frames over USB serial (host = browser CV backend OR the
|
||||
// MEMLNaut firmware), drives CV1..CV3 (PWM) + GATE1..GATE3 (digital) locally,
|
||||
// and forwards CV4..CV11 to the expander over I2C. See ../../shared/protocol.h.
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include "protocol.h"
|
||||
|
||||
// ─── Pin map (USEQHARDWARE_1_0) ──────────────────────────────────────────────
|
||||
constexpr uint8_t PIN_CV[USEQ_NUM_MAIN_CV] = { 21, 20, 19 }; // a1,a2,a3 (PWM)
|
||||
constexpr uint8_t PIN_GATE[USEQ_NUM_GATE] = { 18, 17, 16 }; // d1,d2,d3 (digital)
|
||||
constexpr uint8_t PIN_CV_LED[USEQ_NUM_MAIN_CV] = { 3, 2, 11 };
|
||||
constexpr uint8_t PIN_GATE_LED[USEQ_NUM_GATE] = { 12, 13, 22 };
|
||||
constexpr uint8_t PIN_I1 = 8, PIN_I2 = 9; // digital inputs
|
||||
constexpr uint8_t PIN_AI1 = 26, PIN_AI2 = 27; // analog inputs
|
||||
constexpr uint8_t PIN_SDA = 0, PIN_SCL = 1; // I2C to expander
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
uint16_t cvValues[USEQ_NUM_CV] = {}; // 12-bit wire values, CV1..CV11
|
||||
uint8_t gateBits = 0; // bit0..2 = GATE1..3
|
||||
|
||||
uint8_t rxBuf[USEQ_FRAME_OUTPUT_LEN];
|
||||
uint8_t rxIdx = 0;
|
||||
bool synced = false;
|
||||
|
||||
uint32_t lastInputMs = 0, lastI2CMs = 0, lastScanMs = 0;
|
||||
bool expanderFound = false;
|
||||
|
||||
// ─── Setup ───────────────────────────────────────────────────────────────────
|
||||
void probeExpander() {
|
||||
Wire.beginTransmission(USEQ_I2C_ADDR);
|
||||
expanderFound = (Wire.endTransmission() == 0);
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
analogReadResolution(12);
|
||||
for (uint8_t i = 0; i < USEQ_NUM_MAIN_CV; i++) {
|
||||
pinMode(PIN_CV[i], OUTPUT);
|
||||
pinMode(PIN_CV_LED[i], OUTPUT);
|
||||
}
|
||||
for (uint8_t i = 0; i < USEQ_NUM_GATE; i++) {
|
||||
pinMode(PIN_GATE[i], OUTPUT);
|
||||
pinMode(PIN_GATE_LED[i], OUTPUT);
|
||||
}
|
||||
pinMode(PIN_I1, INPUT);
|
||||
pinMode(PIN_I2, INPUT);
|
||||
analogWriteFreq(100000);
|
||||
analogWriteRange(USEQ_PWM_MAX);
|
||||
Wire.setSDA(PIN_SDA);
|
||||
Wire.setSCL(PIN_SCL);
|
||||
Wire.begin();
|
||||
Wire.setClock(400000);
|
||||
Wire.setTimeout(5);
|
||||
probeExpander();
|
||||
}
|
||||
|
||||
// ─── Frame handlers ──────────────────────────────────────────────────────────
|
||||
void processOutputFrame() {
|
||||
if (useq_xor(rxBuf, 1, USEQ_OFF_OXSUM - 1) != rxBuf[USEQ_OFF_OXSUM]) return;
|
||||
for (uint8_t i = 0; i < USEQ_NUM_CV; i++) {
|
||||
uint16_t v = useq_read_u16le(&rxBuf[USEQ_OFF_CV0 + i * 2]);
|
||||
cvValues[i] = (v > USEQ_CV_MAX) ? USEQ_CV_MAX : v;
|
||||
}
|
||||
gateBits = rxBuf[USEQ_OFF_GATES];
|
||||
}
|
||||
|
||||
void identifyLedSweep() {
|
||||
const uint8_t leds[6] = { PIN_CV_LED[0], PIN_CV_LED[1], PIN_CV_LED[2],
|
||||
PIN_GATE_LED[0], PIN_GATE_LED[1], PIN_GATE_LED[2] };
|
||||
for (int i = 0; i < 6; i++) { analogWrite(leds[i], USEQ_PWM_MAX); delay(40); }
|
||||
delay(80);
|
||||
for (int i = 5; i >= 0; i--) { analogWrite(leds[i], 0); delay(40); }
|
||||
}
|
||||
|
||||
void processIdentifyFrame() {
|
||||
if (rxBuf[1] != rxBuf[2]) return; // xor of byte 1 only
|
||||
if (expanderFound) {
|
||||
Wire.beginTransmission(USEQ_I2C_ADDR);
|
||||
Wire.write(USEQ_SYNC_I2C_IDENTIFY);
|
||||
Wire.endTransmission();
|
||||
}
|
||||
identifyLedSweep();
|
||||
uint8_t ack[USEQ_FRAME_ACK_LEN] = { USEQ_SYNC_DEV, USEQ_MSG_IDENTIFY, 0x01, 0 };
|
||||
ack[3] = ack[1] ^ ack[2];
|
||||
Serial.write(ack, USEQ_FRAME_ACK_LEN);
|
||||
}
|
||||
|
||||
void readSerial() {
|
||||
while (Serial.available()) {
|
||||
uint8_t b = Serial.read();
|
||||
if (!synced) {
|
||||
if (b == USEQ_SYNC_HOST) { rxBuf[0] = b; rxIdx = 1; synced = true; }
|
||||
continue;
|
||||
}
|
||||
rxBuf[rxIdx++] = b;
|
||||
if (rxIdx == 2 && rxBuf[1] != USEQ_MSG_OUTPUT && rxBuf[1] != USEQ_MSG_IDENTIFY) {
|
||||
synced = false; // unknown type → resync
|
||||
continue;
|
||||
}
|
||||
uint8_t want = (rxBuf[1] == USEQ_MSG_OUTPUT) ? USEQ_FRAME_OUTPUT_LEN
|
||||
: USEQ_FRAME_IDENTIFY_LEN;
|
||||
if (rxIdx >= want) {
|
||||
if (rxBuf[1] == USEQ_MSG_OUTPUT) processOutputFrame();
|
||||
else processIdentifyFrame();
|
||||
synced = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Outputs ─────────────────────────────────────────────────────────────────
|
||||
void writeOutputs() {
|
||||
for (uint8_t i = 0; i < USEQ_NUM_MAIN_CV; i++)
|
||||
analogWrite(PIN_CV[i], useq_cv_to_pwm(cvValues[i]));
|
||||
for (uint8_t i = 0; i < USEQ_NUM_GATE; i++) {
|
||||
bool on = (gateBits >> i) & 1;
|
||||
digitalWrite(PIN_GATE[i], on ? HIGH : LOW);
|
||||
digitalWrite(PIN_GATE_LED[i], on ? HIGH : LOW);
|
||||
}
|
||||
for (uint8_t i = 0; i < USEQ_NUM_MAIN_CV; i++) {
|
||||
uint16_t pwm = useq_cv_to_pwm(cvValues[i]);
|
||||
analogWrite(PIN_CV_LED[i], (uint16_t)(((uint32_t)pwm * pwm) >> 11));
|
||||
}
|
||||
}
|
||||
|
||||
void forwardToExpander() {
|
||||
if (!expanderFound) return;
|
||||
uint32_t now = millis();
|
||||
if (now - lastI2CMs < 10) return; // throttle ~100 Hz
|
||||
lastI2CMs = now;
|
||||
uint8_t f[USEQ_FRAME_I2C_LEN];
|
||||
f[0] = USEQ_SYNC_I2C;
|
||||
for (uint8_t i = 0; i < USEQ_NUM_EXP_CV; i++)
|
||||
useq_write_u16le(&f[1 + i * 2], useq_cv_to_pwm(cvValues[USEQ_NUM_MAIN_CV + i]));
|
||||
f[USEQ_FRAME_I2C_LEN - 1] = useq_xor(f, 0, USEQ_FRAME_I2C_LEN - 2);
|
||||
Wire.beginTransmission(USEQ_I2C_ADDR);
|
||||
Wire.write(f, USEQ_FRAME_I2C_LEN);
|
||||
Wire.endTransmission();
|
||||
}
|
||||
|
||||
void retryExpanderScan() {
|
||||
if (expanderFound) return;
|
||||
uint32_t now = millis();
|
||||
if (now - lastScanMs < 2000) return;
|
||||
lastScanMs = now;
|
||||
probeExpander();
|
||||
}
|
||||
|
||||
void sendInputs() {
|
||||
uint32_t now = millis();
|
||||
if (now - lastInputMs < 50) return; // 20 Hz
|
||||
lastInputMs = now;
|
||||
uint8_t f[USEQ_FRAME_INPUT_LEN];
|
||||
f[0] = USEQ_SYNC_DEV;
|
||||
f[1] = USEQ_MSG_INPUT;
|
||||
useq_write_u16le(&f[2], digitalRead(PIN_I1));
|
||||
useq_write_u16le(&f[4], digitalRead(PIN_I2));
|
||||
useq_write_u16le(&f[6], analogRead(PIN_AI1));
|
||||
useq_write_u16le(&f[8], analogRead(PIN_AI2));
|
||||
f[10] = useq_xor(f, 1, 9);
|
||||
Serial.write(f, USEQ_FRAME_INPUT_LEN);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
readSerial();
|
||||
writeOutputs();
|
||||
retryExpanderScan();
|
||||
forwardToExpander();
|
||||
sendInputs();
|
||||
}
|
||||
73
firmware/useq-celium/shared/protocol.h
Normal file
73
firmware/useq-celium/shared/protocol.h
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// uSEQ-CV wire protocol v2 — single source of truth (C/C++ side).
|
||||
//
|
||||
// Mirrored byte-for-byte by manifold/src/backends/useq-protocol.ts. Any change
|
||||
// here MUST be reflected there (a unit test asserts the frame sizes match).
|
||||
//
|
||||
// Topology (fixed): 3 CV + 3 gate on the uSEQ main module, 8 CV on the expander
|
||||
// = 11 CV (CV1..CV11) + 3 gate (GATE1..GATE3).
|
||||
// CV1..CV3 → main module PWM (a1,a2,a3)
|
||||
// CV4..CV11 → expander module PWM (e1..e8, forwarded over I2C)
|
||||
// GATE1..3 → main module digital (d1,d2,d3)
|
||||
//
|
||||
// Host-agnostic: the sender may be the browser (Web Serial) or the MEMLNaut
|
||||
// RP2350 firmware (USB/UART) — identical bytes either way.
|
||||
//
|
||||
// Transport: USB CDC / UART @ 115200, little-endian, XOR checksum, fixed-length
|
||||
// frames keyed by a sync byte + type so a stream parser can resync after a drop.
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
|
||||
// ─── Sync bytes ──────────────────────────────────────────────────────────────
|
||||
static const uint8_t USEQ_SYNC_HOST = 0xAA; // host → uSEQ
|
||||
static const uint8_t USEQ_SYNC_DEV = 0xBB; // uSEQ → host
|
||||
static const uint8_t USEQ_SYNC_I2C = 0xCC; // main → expander
|
||||
static const uint8_t USEQ_SYNC_I2C_IDENTIFY = 0xDD; // main → expander, identify
|
||||
|
||||
// ─── Message types ───────────────────────────────────────────────────────────
|
||||
static const uint8_t USEQ_MSG_OUTPUT = 0x01; // host → uSEQ : CV + gate values
|
||||
static const uint8_t USEQ_MSG_IDENTIFY = 0x03; // host → uSEQ : flash LEDs, ack
|
||||
static const uint8_t USEQ_MSG_INPUT = 0x01; // uSEQ → host : i1,i2,ai1,ai2
|
||||
// (0x02 reserved — the old CONFIG frame; topology is fixed in v2, so unused.)
|
||||
|
||||
// ─── Topology ────────────────────────────────────────────────────────────────
|
||||
static const uint8_t USEQ_NUM_CV = 11; // CV1..CV11
|
||||
static const uint8_t USEQ_NUM_GATE = 3; // GATE1..GATE3
|
||||
static const uint8_t USEQ_NUM_MAIN_CV = 3; // CV1..CV3 live on the main board
|
||||
static const uint8_t USEQ_NUM_EXP_CV = 8; // CV4..CV11 live on the expander
|
||||
static const uint8_t USEQ_I2C_ADDR = 0x10;// expander I2C slave address
|
||||
|
||||
// ─── Value ranges ────────────────────────────────────────────────────────────
|
||||
static const uint16_t USEQ_CV_MAX = 4095; // 12-bit canonical wire value
|
||||
static const uint16_t USEQ_PWM_MAX = 2047; // 11-bit hardware PWM (RP2040 analogWrite)
|
||||
|
||||
// ─── Frame sizes ─────────────────────────────────────────────────────────────
|
||||
// OUTPUT: sync(1) + type(1) + 11×u16 CV(22) + gate-bits(1) + xor(1)
|
||||
static const uint8_t USEQ_FRAME_OUTPUT_LEN = 2 + USEQ_NUM_CV * 2 + 1 + 1; // 26
|
||||
static const uint8_t USEQ_FRAME_IDENTIFY_LEN = 3; // sync + type + xor
|
||||
static const uint8_t USEQ_FRAME_ACK_LEN = 4; // sync + type + status + xor
|
||||
static const uint8_t USEQ_FRAME_INPUT_LEN = 11; // sync + type + 4×u16 + xor
|
||||
// I2C (main → expander): sync(1) + 8×u16(16) + xor(1)
|
||||
static const uint8_t USEQ_FRAME_I2C_LEN = 1 + USEQ_NUM_EXP_CV * 2 + 1; // 18
|
||||
|
||||
// ─── Byte offsets (OUTPUT frame) ─────────────────────────────────────────────
|
||||
static const uint8_t USEQ_OFF_CV0 = 2; // first CV u16
|
||||
static const uint8_t USEQ_OFF_GATES = 2 + USEQ_NUM_CV * 2; // 24
|
||||
static const uint8_t USEQ_OFF_OXSUM = USEQ_FRAME_OUTPUT_LEN - 1; // 25
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
static inline uint8_t useq_xor(const uint8_t* buf, uint8_t start, uint8_t end) {
|
||||
uint8_t cs = 0;
|
||||
for (uint8_t i = start; i <= end; i++) cs ^= buf[i];
|
||||
return cs;
|
||||
}
|
||||
static inline uint16_t useq_read_u16le(const uint8_t* p) {
|
||||
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
|
||||
}
|
||||
static inline void useq_write_u16le(uint8_t* p, uint16_t v) {
|
||||
p[0] = (uint8_t)(v & 0xFF);
|
||||
p[1] = (uint8_t)((v >> 8) & 0xFF);
|
||||
}
|
||||
// Scale a 12-bit wire CV (0..USEQ_CV_MAX) down to 11-bit hardware PWM.
|
||||
static inline uint16_t useq_cv_to_pwm(uint16_t cv) {
|
||||
return (cv > USEQ_CV_MAX ? USEQ_CV_MAX : cv) >> 1;
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 4273",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
|
|
|
|||
296
manifold/src/backends/cv-backend.ts
Normal file
296
manifold/src/backends/cv-backend.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* UseqCvBackend — real CV/gate output over USB Web Serial to a uSEQ module
|
||||
* (+ CV expander). The restored, modernised descendant of the April-2026
|
||||
* "uSEQ-Celium" output mode (provenance: docs/useq-celium/protocol.md).
|
||||
*
|
||||
* Per non-silent output: map (0..1) → baseline (min/max/curve) → assign to the
|
||||
* output's configured uSEQ channel — a CV jack (12-bit value) or a gate
|
||||
* (threshold the mapped value). The 11 CV + 3 gate channels are packed into one
|
||||
* 26-byte OUTPUT frame and streamed at ~100 Hz (useq-protocol.ts), throttled
|
||||
* with a per-channel dead-zone so we never flood the port.
|
||||
*
|
||||
* Web Serial needs a user gesture to pick a port, so unlike MIDI this backend
|
||||
* exposes connect()/identify()/disconnect() for the Outputs-config buttons;
|
||||
* start() only tries to silently re-open a previously-granted port.
|
||||
*
|
||||
* The per-output CV spec (channel + gate threshold) lives on the shared MFParam
|
||||
* store (output-state.ts CvSpec) and arrives via {@link setCvConfig}, parallel
|
||||
* to ctx.mappings — exactly like the MIDI backend.
|
||||
*/
|
||||
import type { BackendContext, BackendStatus, OutputBackend } from './backend';
|
||||
import { isSilent, mapOutput } from './mapping';
|
||||
import type { CvChannelId, CvSpec } from '../dock/output-state';
|
||||
import {
|
||||
CV_MAX,
|
||||
FRAME_OUTPUT_LEN,
|
||||
NUM_CV,
|
||||
STREAM_HZ,
|
||||
UseqRxParser,
|
||||
cvToWire,
|
||||
encodeIdentify,
|
||||
encodeOutput,
|
||||
FRAME_IDENTIFY_LEN,
|
||||
} from './useq-protocol';
|
||||
|
||||
const SEND_INTERVAL_MS = 1000 / STREAM_HZ; // ~10 ms
|
||||
const CV_DEAD_ZONE = 8; // on the 12-bit value (~0.2%)
|
||||
const IDENTIFY_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Resolve a channel id into a fast {kind, index} target (or null for 'none'). */
|
||||
type Target = { kind: 'cv'; idx: number } | { kind: 'gate'; idx: number } | null;
|
||||
function resolveTarget(id: CvChannelId): Target {
|
||||
if (id === 'none') return null;
|
||||
if (id.startsWith('cv')) return { kind: 'cv', idx: parseInt(id.slice(2), 10) - 1 };
|
||||
return { kind: 'gate', idx: parseInt(id.slice(4), 10) - 1 };
|
||||
}
|
||||
|
||||
export class UseqCvBackend implements OutputBackend {
|
||||
readonly id = 'cvgate' as const;
|
||||
|
||||
private ctx: BackendContext | null = null;
|
||||
/** Per-output CV specs, index-aligned with ctx.mappings. */
|
||||
private specs: CvSpec[] = [];
|
||||
/** Pre-resolved channel targets, index-aligned with specs. */
|
||||
private targets: Target[] = [];
|
||||
|
||||
private port: SerialPort | null = null;
|
||||
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
|
||||
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
private parser = new UseqRxParser(
|
||||
() => {},
|
||||
(ok) => this.onAck(ok),
|
||||
);
|
||||
|
||||
private cvVals = new Uint16Array(NUM_CV); // last computed 12-bit CV per channel
|
||||
private gateBits = 0;
|
||||
private lastCv = new Int32Array(NUM_CV).fill(-1);
|
||||
private lastGateBits = -1;
|
||||
private lastSendMs = 0;
|
||||
private writing = false; // in-flight write guard (avoid serial backpressure)
|
||||
|
||||
private pendingIdentify: (() => void) | null = null;
|
||||
|
||||
private statusState: BackendStatus = { state: 'idle', message: 'CV idle' };
|
||||
private statusListeners = new Set<(s: BackendStatus) => void>();
|
||||
|
||||
isAvailable(): boolean {
|
||||
return typeof navigator !== 'undefined' && 'serial' in navigator;
|
||||
}
|
||||
|
||||
async start(ctx: BackendContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
if (!this.isAvailable()) {
|
||||
this.setStatus({ state: 'unavailable', message: 'Web Serial not supported in this browser' });
|
||||
return;
|
||||
}
|
||||
if (this.port) {
|
||||
this.setStatus({ state: 'ready', message: 'uSEQ connected' });
|
||||
return;
|
||||
}
|
||||
// Try to silently re-open a previously-granted port (no user gesture needed).
|
||||
try {
|
||||
const ports = await navigator.serial.getPorts();
|
||||
if (ports.length > 0) {
|
||||
await this.open(ports[0]);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore — fall through to the idle "connect" prompt */
|
||||
}
|
||||
this.setStatus({ state: 'ready', message: 'uSEQ ready — click Connect device' });
|
||||
}
|
||||
|
||||
setContext(ctx: BackendContext): void {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
/** Update the per-output CV specs (channel + gate threshold). */
|
||||
setCvConfig(specs: CvSpec[]): void {
|
||||
this.specs = specs;
|
||||
this.targets = specs.map((s) => resolveTarget(s.channel));
|
||||
this.lastCv.fill(-1);
|
||||
this.lastGateBits = -1;
|
||||
}
|
||||
|
||||
/** User-gesture path: pick + open a serial port. Throws if the user cancels. */
|
||||
async connect(): Promise<void> {
|
||||
if (!this.isAvailable()) {
|
||||
this.setStatus({ state: 'unavailable', message: 'Web Serial not supported in this browser' });
|
||||
return;
|
||||
}
|
||||
this.setStatus({ state: 'connecting', message: 'Select the uSEQ serial port…' });
|
||||
let port: SerialPort;
|
||||
try {
|
||||
port = await navigator.serial.requestPort();
|
||||
} catch {
|
||||
this.setStatus({ state: 'ready', message: 'uSEQ ready — click Connect device' });
|
||||
return; // user dismissed the picker
|
||||
}
|
||||
await this.open(port);
|
||||
}
|
||||
|
||||
private async open(port: SerialPort): Promise<void> {
|
||||
try {
|
||||
await port.open({ baudRate: 115200 });
|
||||
} catch (err) {
|
||||
this.setStatus({ state: 'error', message: `Could not open port: ${(err as Error).message}` });
|
||||
return;
|
||||
}
|
||||
this.port = port;
|
||||
this.writer = port.writable?.getWriter() ?? null;
|
||||
this.lastCv.fill(-1);
|
||||
this.lastGateBits = -1;
|
||||
if (typeof port.addEventListener === 'function') {
|
||||
port.addEventListener('disconnect', () => this.handleDisconnect());
|
||||
}
|
||||
this.startReadLoop();
|
||||
this.setStatus({ state: 'ready', message: 'uSEQ connected' });
|
||||
}
|
||||
|
||||
private async startReadLoop(): Promise<void> {
|
||||
const readable = this.port?.readable;
|
||||
if (!readable) return;
|
||||
this.reader = readable.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await this.reader.read();
|
||||
if (done) break;
|
||||
if (value) this.parser.push(value);
|
||||
}
|
||||
} catch {
|
||||
/* stream errored (unplug) — handled by disconnect / teardown */
|
||||
} finally {
|
||||
try {
|
||||
this.reader?.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
this.reader = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send an IDENTIFY frame; both boards flash their LEDs. Resolves on ack/timeout. */
|
||||
identify(): void {
|
||||
const w = this.writer;
|
||||
if (!w) return;
|
||||
const frame = new Uint8Array(FRAME_IDENTIFY_LEN);
|
||||
encodeIdentify(frame);
|
||||
this.setStatus({ state: 'connecting', message: 'Identify… (watch the LEDs)' });
|
||||
let settled = false;
|
||||
const done = (msg: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.pendingIdentify = null;
|
||||
if (this.port) this.setStatus({ state: 'ready', message: msg });
|
||||
};
|
||||
this.pendingIdentify = () => done('uSEQ connected — identify ack');
|
||||
w.write(frame).catch(() => done('uSEQ connected'));
|
||||
setTimeout(() => done('uSEQ connected'), IDENTIFY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private onAck(ok: boolean): void {
|
||||
if (this.pendingIdentify) this.pendingIdentify();
|
||||
else if (ok && this.port) this.setStatus({ state: 'ready', message: 'uSEQ connected' });
|
||||
}
|
||||
|
||||
send(routed: Float32Array): void {
|
||||
const ctx = this.ctx;
|
||||
const w = this.writer;
|
||||
if (!ctx || !w) return;
|
||||
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
if (now - this.lastSendMs < SEND_INTERVAL_MS) return;
|
||||
this.lastSendMs = now;
|
||||
if (this.writing) return; // previous frame still draining — drop this one
|
||||
|
||||
// Rebuild the 14-channel snapshot from the routed outputs.
|
||||
this.cvVals.fill(0);
|
||||
let gates = 0;
|
||||
const n = Math.min(routed.length, ctx.mappings.length, this.targets.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = this.targets[i];
|
||||
if (!t) continue;
|
||||
const m = ctx.mappings[i];
|
||||
if (isSilent(m)) continue;
|
||||
const mapped = mapOutput(routed[i], m); // 0..1 in [min,max]
|
||||
if (t.kind === 'cv') {
|
||||
if (t.idx >= 0 && t.idx < NUM_CV) this.cvVals[t.idx] = cvToWire(mapped);
|
||||
} else if (t.idx >= 0 && t.idx < 3) {
|
||||
if (mapped >= (this.specs[i]?.gateThreshold ?? 0.5)) gates |= 1 << t.idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Dead-zone: skip the frame entirely if nothing moved enough.
|
||||
let changed = gates !== this.lastGateBits;
|
||||
for (let i = 0; i < NUM_CV; i++) {
|
||||
if (this.lastCv[i] < 0 || Math.abs(this.cvVals[i] - this.lastCv[i]) >= CV_DEAD_ZONE) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
for (let i = 0; i < NUM_CV; i++) this.lastCv[i] = this.cvVals[i];
|
||||
this.lastGateBits = gates;
|
||||
|
||||
// Fresh buffer per frame (the async writer may still hold the previous one).
|
||||
const frame = new Uint8Array(FRAME_OUTPUT_LEN);
|
||||
encodeOutput(frame, this.cvVals, gates);
|
||||
this.writing = true;
|
||||
w.write(frame)
|
||||
.catch(() => this.handleDisconnect())
|
||||
.finally(() => {
|
||||
this.writing = false;
|
||||
});
|
||||
}
|
||||
|
||||
private handleDisconnect(): void {
|
||||
if (!this.port) return;
|
||||
this.port = null;
|
||||
this.writer = null;
|
||||
this.setStatus({ state: 'error', message: 'uSEQ disconnected — click Connect device' });
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.closePort();
|
||||
this.setStatus({ state: 'ready', message: 'uSEQ ready — click Connect device' });
|
||||
}
|
||||
|
||||
private async closePort(): Promise<void> {
|
||||
try {
|
||||
this.reader?.cancel().catch(() => {});
|
||||
this.writer?.releaseLock();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.writer = null;
|
||||
this.reader = null;
|
||||
const port = this.port;
|
||||
this.port = null;
|
||||
if (port) {
|
||||
try {
|
||||
await port.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
await this.closePort();
|
||||
this.setStatus({ state: 'idle', message: 'CV idle' });
|
||||
}
|
||||
|
||||
status(): BackendStatus {
|
||||
return this.statusState;
|
||||
}
|
||||
|
||||
onStatusChange(cb: (s: BackendStatus) => void): () => void {
|
||||
this.statusListeners.add(cb);
|
||||
return () => this.statusListeners.delete(cb);
|
||||
}
|
||||
|
||||
private setStatus(s: BackendStatus): void {
|
||||
this.statusState = s;
|
||||
for (const cb of this.statusListeners) cb(s);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { OscBridgeBackend } from './osc-backend';
|
|||
import { PassthroughBackend } from './passthrough-backend';
|
||||
import { ParticleBackend } from './particle-backend';
|
||||
import { VcvBackend, type VcvFeedbackOp } from './vcv-backend';
|
||||
import { UseqCvBackend } from './cv-backend';
|
||||
|
||||
/** The slice of EngineApi the manager depends on (keeps it decoupled/testable). */
|
||||
export interface ManagerEngine {
|
||||
|
|
@ -56,7 +57,7 @@ export class BackendManager {
|
|||
['osc', backends?.osc ?? new OscBridgeBackend()],
|
||||
['synth', backends?.synth ?? new PassthroughBackend('synth', 'Built-in Synth — audio plays in the engine')],
|
||||
['particles', backends?.particles ?? new ParticleBackend()],
|
||||
['cvgate', backends?.cvgate ?? new PassthroughBackend('cvgate', 'CV / gate (via VCV bridge)')],
|
||||
['cvgate', backends?.cvgate ?? new UseqCvBackend()],
|
||||
['vcv', backends?.vcv ?? new VcvBackend()],
|
||||
]);
|
||||
|
||||
|
|
@ -89,6 +90,11 @@ export class BackendManager {
|
|||
return b instanceof VcvBackend ? b : null;
|
||||
}
|
||||
|
||||
cv(): UseqCvBackend | null {
|
||||
const b = this.backends.get('cvgate');
|
||||
return b instanceof UseqCvBackend ? b : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a verdict op to the VCV module's embedded learner over the bridge.
|
||||
* No-op unless the VCV backend is the ACTIVE one — the verdict loop only
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||
import type { EngineApi } from '../engine';
|
||||
import type { MFParam } from '../console/model';
|
||||
import type { BackendContext, BackendStatus, OutputMapping } from './backend';
|
||||
import type { BackendId, MidiCcSpec, OscSpec, VcvSpec } from '../dock/output-state';
|
||||
import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from '../dock/output-state';
|
||||
import type { BackendId, CvSpec, MidiCcSpec, OscSpec, VcvSpec } from '../dock/output-state';
|
||||
import { defaultCvSpec, defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from '../dock/output-state';
|
||||
import { BackendManager } from './manager';
|
||||
|
||||
export interface MidiSettings {
|
||||
|
|
@ -56,6 +56,12 @@ export interface UseBackendManager {
|
|||
/** Available MIDI output ports (refreshes when MIDI starts/hot-plugs). */
|
||||
midiPorts: { id: string; name: string }[];
|
||||
refreshMidiPorts: () => void;
|
||||
/** uSEQ CV backend: pick + open a serial port (user gesture). */
|
||||
cvConnect: () => void;
|
||||
/** uSEQ CV backend: flash the module LEDs to confirm the link. */
|
||||
cvIdentify: () => void;
|
||||
/** uSEQ CV backend: close the serial port. */
|
||||
cvDisconnect: () => void;
|
||||
}
|
||||
|
||||
export function useBackendManager(
|
||||
|
|
@ -148,5 +154,17 @@ export function useBackendManager(
|
|||
vcv.setVcvConfig(specs, { url: vcvSettings.url, sendRaw: vcvSettings.sendRaw });
|
||||
}, [manager, params, vcvSettings.url, vcvSettings.sendRaw]);
|
||||
|
||||
return { manager, status, midiPorts, refreshMidiPorts };
|
||||
// Push per-output uSEQ CV config (channel + gate threshold) whenever it changes.
|
||||
useEffect(() => {
|
||||
const cv = manager?.cv();
|
||||
if (!cv) return;
|
||||
const specs: CvSpec[] = params.map((p, i) => (p.cv as CvSpec | undefined) ?? defaultCvSpec(i));
|
||||
cv.setCvConfig(specs);
|
||||
}, [manager, params]);
|
||||
|
||||
const cvConnect = () => void manager?.cv()?.connect();
|
||||
const cvIdentify = () => manager?.cv()?.identify();
|
||||
const cvDisconnect = () => void manager?.cv()?.disconnect();
|
||||
|
||||
return { manager, status, midiPorts, refreshMidiPorts, cvConnect, cvIdentify, cvDisconnect };
|
||||
}
|
||||
|
|
|
|||
60
manifold/src/backends/useq-protocol.test.ts
Normal file
60
manifold/src/backends/useq-protocol.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* uSEQ-CV protocol v2 encoder tests (run with `bun test`). These pin the wire
|
||||
* layout so it cannot silently drift from firmware/useq-celium/shared/protocol.h.
|
||||
*/
|
||||
import { expect, test } from 'bun:test';
|
||||
import {
|
||||
CV_MAX,
|
||||
FRAME_IDENTIFY_LEN,
|
||||
FRAME_OUTPUT_LEN,
|
||||
OFF_CV0,
|
||||
OFF_GATES,
|
||||
OFF_OXSUM,
|
||||
SYNC_HOST,
|
||||
cvToWire,
|
||||
encodeIdentify,
|
||||
encodeOutput,
|
||||
xorChecksum,
|
||||
} from './useq-protocol';
|
||||
|
||||
test('frame sizes match the C header', () => {
|
||||
expect(FRAME_OUTPUT_LEN).toBe(26); // sync+type + 11×u16 + gate + xor
|
||||
expect(FRAME_IDENTIFY_LEN).toBe(3);
|
||||
expect(OFF_GATES).toBe(24);
|
||||
expect(OFF_OXSUM).toBe(25);
|
||||
});
|
||||
|
||||
test('encodeOutput lays out CV (LE), gate bits, and a valid XOR', () => {
|
||||
const cv = Array.from({ length: 11 }, (_, i) => i * 100); // 0,100,...,1000
|
||||
const out = new Uint8Array(FRAME_OUTPUT_LEN);
|
||||
const n = encodeOutput(out, cv, 0b101); // GATE1 + GATE3
|
||||
expect(n).toBe(FRAME_OUTPUT_LEN);
|
||||
expect(out[0]).toBe(SYNC_HOST);
|
||||
expect(out[1]).toBe(0x01);
|
||||
// CV4 (index 3) = 300 → 0x012C little-endian
|
||||
expect(out[OFF_CV0 + 3 * 2]).toBe(300 & 0xff);
|
||||
expect(out[OFF_CV0 + 3 * 2 + 1]).toBe((300 >> 8) & 0xff);
|
||||
expect(out[OFF_GATES]).toBe(0b101);
|
||||
expect(out[OFF_OXSUM]).toBe(xorChecksum(out, 1, OFF_OXSUM - 1));
|
||||
});
|
||||
|
||||
test('encodeOutput clamps CV to 12-bit and masks gates to 3 bits', () => {
|
||||
const out = new Uint8Array(FRAME_OUTPUT_LEN);
|
||||
encodeOutput(out, new Array(11).fill(99999), 0xff);
|
||||
expect(out[OFF_CV0] | (out[OFF_CV0 + 1] << 8)).toBe(CV_MAX);
|
||||
expect(out[OFF_GATES]).toBe(0x07);
|
||||
});
|
||||
|
||||
test('cvToWire maps the normalised range to 12-bit', () => {
|
||||
expect(cvToWire(0)).toBe(0);
|
||||
expect(cvToWire(1)).toBe(CV_MAX);
|
||||
expect(cvToWire(0.5)).toBe(Math.round(0.5 * CV_MAX));
|
||||
expect(cvToWire(-1)).toBe(0);
|
||||
expect(cvToWire(2)).toBe(CV_MAX);
|
||||
});
|
||||
|
||||
test('encodeIdentify is [0xAA, 0x03, 0x03]', () => {
|
||||
const out = new Uint8Array(FRAME_IDENTIFY_LEN);
|
||||
encodeIdentify(out);
|
||||
expect(Array.from(out)).toEqual([0xaa, 0x03, 0x03]);
|
||||
});
|
||||
143
manifold/src/backends/useq-protocol.ts
Normal file
143
manifold/src/backends/useq-protocol.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -271,7 +271,15 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
// spine and forwards routed outputs to the active backend; switching Mode
|
||||
// tears down the old backend, starts the new one, and gates synth audio
|
||||
// (mute on non-synth modes). MIDI/OSC config + names ride the shared params.
|
||||
const { manager: backendManager, status: backendStatus, midiPorts, refreshMidiPorts } = useBackendManager(
|
||||
const {
|
||||
manager: backendManager,
|
||||
status: backendStatus,
|
||||
midiPorts,
|
||||
refreshMidiPorts,
|
||||
cvConnect,
|
||||
cvIdentify,
|
||||
cvDisconnect,
|
||||
} = useBackendManager(
|
||||
engine,
|
||||
outputBackend,
|
||||
modeId,
|
||||
|
|
@ -688,6 +696,9 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
setVcvUrl,
|
||||
vcvSendRaw,
|
||||
setVcvSendRaw,
|
||||
cvConnect,
|
||||
cvIdentify,
|
||||
cvDisconnect,
|
||||
setParams: (next: MFParam[]) => setParams(next),
|
||||
markers,
|
||||
inputs,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const MODE_ICON: Record<OutputMode, { Icon: (p: IconProps) => JSX.Element; glyph
|
|||
particles: { Icon: ParticleIcon, glyph: GLYPH_FALLBACK.particles },
|
||||
midi: { Icon: MidiIcon, glyph: GLYPH_FALLBACK.midi },
|
||||
osc: { Icon: OscIcon, glyph: GLYPH_FALLBACK.osc },
|
||||
cv: { Icon: OscIcon, glyph: GLYPH_FALLBACK.cv },
|
||||
synth: { Icon: SynthIcon, glyph: GLYPH_FALLBACK.synth },
|
||||
editor: { Icon: EditorIcon, glyph: GLYPH_FALLBACK.editor },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ export const GLYPH_FALLBACK = {
|
|||
particles: '✦',
|
||||
midi: '🎹',
|
||||
osc: '◉',
|
||||
cv: '⎓',
|
||||
synth: '🔊',
|
||||
editor: '🔌',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export interface MFParam {
|
|||
osc?: { path: string; rangeMin: number; rangeMax: number };
|
||||
/** VCV backend spec ({ bipolar }). */
|
||||
vcv?: { bipolar: boolean };
|
||||
/** uSEQ CV/gate backend spec ({ channel, gateThreshold }). */
|
||||
cv?: { channel: string; gateThreshold: number };
|
||||
}
|
||||
|
||||
export interface MFMode {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* output-mode.ts — the TOP dock selector catalogue (operator dock restructure).
|
||||
*
|
||||
* "Mode" here = the active OUTPUT BACKEND/target. Five options, in order, the
|
||||
* "Mode" here = the active OUTPUT BACKEND/target. Six options, in order, the
|
||||
* first the default:
|
||||
* • Particle System (visual) — DEFAULT
|
||||
* • MIDI
|
||||
* • OSC
|
||||
* • CV / uSEQ — uSEQ CV/gate over USB Web Serial (cvgate backend)
|
||||
* • Built-in Synth — the synth backend; NEVER the string "C15"
|
||||
* • MEMLNaut Editor — hardware-connection mode (Web Serial)
|
||||
*
|
||||
|
|
@ -58,6 +59,13 @@ export const OUTPUT_MODES: readonly OutputModeDescriptor[] = [
|
|||
audio: false,
|
||||
backend: 'osc',
|
||||
},
|
||||
{
|
||||
id: 'cv',
|
||||
label: 'CV / uSEQ',
|
||||
description: 'uSEQ CV/gate over USB serial — 11 CV + 3 gate.',
|
||||
audio: false,
|
||||
backend: 'cvgate',
|
||||
},
|
||||
{
|
||||
id: 'synth',
|
||||
label: 'Built-in Synth',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export interface Pin {
|
|||
* (operator dock restructure). "Built-in Synth" is the synth backend — the
|
||||
* string "C15" must NEVER appear. Particle + Editor are non-audio.
|
||||
*/
|
||||
export type OutputMode = 'particles' | 'midi' | 'osc' | 'synth' | 'editor';
|
||||
export type OutputMode = 'particles' | 'midi' | 'osc' | 'cv' | 'synth' | 'editor';
|
||||
|
||||
/** Feedback marker plotted on the 2D map at the input location it was given. */
|
||||
export interface FeedbackMarker {
|
||||
|
|
@ -106,6 +106,10 @@ export interface ConsoleCtx {
|
|||
setVcvUrl: (u: string) => void;
|
||||
vcvSendRaw: boolean;
|
||||
setVcvSendRaw: (v: boolean) => void;
|
||||
/** uSEQ CV backend (USB serial): connect a port / flash LEDs / disconnect. */
|
||||
cvConnect: () => void;
|
||||
cvIdentify: () => void;
|
||||
cvDisconnect: () => void;
|
||||
/** Replace the whole params array (used when restoring a named preset). */
|
||||
setParams: (next: MFParam[]) => void;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
* inline config in OutputsBackendConfig; both write the same store.
|
||||
*/
|
||||
import type { MFParam } from '../console/model';
|
||||
import type { BackendId } from './output-state';
|
||||
import { defaultMidiSpec, defaultOscSpec } from './output-state';
|
||||
import type { BackendId, CvChannelId } from './output-state';
|
||||
import { CV_CHANNELS, defaultCvSpec, defaultMidiSpec, defaultOscSpec } from './output-state';
|
||||
|
||||
function num(s: string, fallback: number): number {
|
||||
const v = parseFloat(s);
|
||||
|
|
@ -59,8 +59,9 @@ export function BackendAdvanced({ backend, params, setParam }: BackendAdvancedPr
|
|||
case 'osc':
|
||||
return <OscPathEditor params={params} setParam={setParam} />;
|
||||
case 'vcv':
|
||||
case 'cvgate':
|
||||
return <VcvChannelEditor params={params} setParam={setParam} />;
|
||||
case 'cvgate':
|
||||
return <CvChannelEditor params={params} setParam={setParam} />;
|
||||
default:
|
||||
return <SynthGroupNote params={params} />;
|
||||
}
|
||||
|
|
@ -269,6 +270,77 @@ function VcvChannelEditor({
|
|||
);
|
||||
}
|
||||
|
||||
// ---- uSEQ CV / gate (docs/useq-celium) -------------------------------------
|
||||
|
||||
function CvChannelEditor({
|
||||
params,
|
||||
setParam,
|
||||
}: {
|
||||
params: MFParam[];
|
||||
setParam: (i: number, patch: Partial<MFParam>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: '0 0 8px' }}>
|
||||
Live CV/gate over USB serial to a uSEQ module + expander (docs/useq-celium). Assign each model output to a
|
||||
CV jack or gate; gates threshold the mapped 0–1 value. Connect the device in the Outputs panel.
|
||||
</p>
|
||||
<div style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Output</Th>
|
||||
<Th>uSEQ channel</Th>
|
||||
<Th>Gate ≥</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{params.map((p, i) => {
|
||||
const c = (p.cv as { channel: CvChannelId; gateThreshold: number } | undefined) ?? defaultCvSpec(i);
|
||||
const isGate = c.channel.startsWith('gate');
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: '3px 6px', fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>{p.name}</td>
|
||||
<td style={{ padding: '3px 6px', width: 150 }}>
|
||||
<select
|
||||
value={c.channel}
|
||||
onChange={(e) => setParam(i, { cv: { ...c, channel: e.target.value as CvChannelId } })}
|
||||
style={{ ...cellInput, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="none">— none —</option>
|
||||
{CV_CHANNELS.map((ch) => (
|
||||
<option key={ch.id} value={ch.id}>
|
||||
{ch.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ padding: '3px 6px', width: 80 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
disabled={!isGate}
|
||||
style={{ ...cellInput, opacity: isGate ? 1 : 0.4 }}
|
||||
value={c.gateThreshold}
|
||||
onChange={(e) =>
|
||||
setParam(i, {
|
||||
cv: { ...c, gateThreshold: Math.max(0, Math.min(1, num(e.target.value, c.gateThreshold))) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SynthGroupNote({ params }: { params: MFParam[] }) {
|
||||
const groups = Array.from(new Set(params.map((p) => p.group)));
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ConsoleCtx } from '../console/types';
|
||||
import type { BackendId } from './output-state';
|
||||
import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state';
|
||||
import type { BackendId, CvChannelId } from './output-state';
|
||||
import { CV_CHANNELS, defaultCvSpec, defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state';
|
||||
import {
|
||||
applyPreset,
|
||||
deletePreset,
|
||||
|
|
@ -583,6 +583,89 @@ function VcvConfig({ ctx }: { ctx: ConsoleCtx }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ---- uSEQ CV / gate config (backends-spec §2.5; docs/useq-celium) -----------
|
||||
|
||||
function CvConfig({ ctx }: { ctx: ConsoleCtx }) {
|
||||
const s = ctx.backendStatus;
|
||||
const statusColor =
|
||||
s.state === 'ready' ? 'var(--good)' : s.state === 'connecting' ? 'var(--warn)' : 'var(--danger)';
|
||||
return (
|
||||
<>
|
||||
<SectionLabel>uSEQ device (USB serial)</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button type="button" style={btn('var(--accent)')} onClick={ctx.cvConnect}>
|
||||
Connect device
|
||||
</button>
|
||||
<button type="button" style={btn('var(--fg-mute)')} onClick={ctx.cvIdentify}>
|
||||
Identify (flash LEDs)
|
||||
</button>
|
||||
<button type="button" style={btn('var(--danger)')} onClick={ctx.cvDisconnect}>
|
||||
Disconnect
|
||||
</button>
|
||||
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
Flash the uSEQ main + expander with <code>firmware/useq-celium</code>, connect over USB, then assign each
|
||||
model output to a CV jack or gate. 11 CV (3 main + 8 expander) + 3 gates, streamed at 100 Hz.
|
||||
</p>
|
||||
|
||||
<SectionLabel>Per-output channel · gate threshold</SectionLabel>
|
||||
<div style={{ maxHeight: 320, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Output</Th>
|
||||
<Th>uSEQ channel</Th>
|
||||
<Th>Gate ≥</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ctx.params.map((p, i) => {
|
||||
const c = (p.cv as { channel: CvChannelId; gateThreshold: number } | undefined) ?? defaultCvSpec(i);
|
||||
const isGate = c.channel.startsWith('gate');
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: '3px 6px', fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>{p.name}</td>
|
||||
<td style={{ padding: '3px 6px', width: 150 }}>
|
||||
<select
|
||||
value={c.channel}
|
||||
onChange={(e) => ctx.setParam(i, { cv: { ...c, channel: e.target.value as CvChannelId } })}
|
||||
style={{ ...cellInput, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="none">— none —</option>
|
||||
{CV_CHANNELS.map((ch) => (
|
||||
<option key={ch.id} value={ch.id}>
|
||||
{ch.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ padding: '3px 6px', width: 80 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
disabled={!isGate}
|
||||
style={{ ...cellInput, opacity: isGate ? 1 : 0.4 }}
|
||||
value={c.gateThreshold}
|
||||
onChange={(e) =>
|
||||
ctx.setParam(i, {
|
||||
cv: { ...c, gateThreshold: Math.max(0, Math.min(1, num(e.target.value, c.gateThreshold))) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Public entry ----------------------------------------------------------
|
||||
|
||||
export interface OutputsBackendConfigProps {
|
||||
|
|
@ -592,14 +675,22 @@ export interface OutputsBackendConfigProps {
|
|||
|
||||
/** The specialised, editable per-backend config + preset bar for the Outputs panel. */
|
||||
export function OutputsBackendConfig({ ctx, backend }: OutputsBackendConfigProps) {
|
||||
// MIDI / OSC / VCV carry a config + preset surface here; synth/particle/editor
|
||||
// MIDI / OSC / VCV / CV carry a config + preset surface here; synth/particle/editor
|
||||
// config is rendered by ModeConfig in Drawers. The full-depth BackendAdvanced
|
||||
// modal reuses the same per-channel sections.
|
||||
if (backend !== 'midi' && backend !== 'osc' && backend !== 'vcv') return null;
|
||||
if (backend !== 'midi' && backend !== 'osc' && backend !== 'vcv' && backend !== 'cvgate') return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<PresetBar ctx={ctx} backend={backend} />
|
||||
{backend === 'midi' ? <MidiConfig ctx={ctx} /> : backend === 'osc' ? <OscConfig ctx={ctx} /> : <VcvConfig ctx={ctx} />}
|
||||
{backend === 'midi' ? (
|
||||
<MidiConfig ctx={ctx} />
|
||||
) : backend === 'osc' ? (
|
||||
<OscConfig ctx={ctx} />
|
||||
) : backend === 'vcv' ? (
|
||||
<VcvConfig ctx={ctx} />
|
||||
) : (
|
||||
<CvConfig ctx={ctx} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const BACKENDS: readonly BackendDescriptor[] = [
|
|||
{ id: 'synth', label: 'Powerful Synth Engine', description: 'Firmware-parity built-in audio engine.' },
|
||||
{ id: 'midi', label: 'MIDI', description: 'Web MIDI CC out — per-output CC#/channel.' },
|
||||
{ id: 'osc', label: 'OSC', description: 'OSC bridge — named paths + physical ranges.' },
|
||||
{ id: 'cvgate', label: 'CV', description: 'CV / gate (via VCV bridge or DC-coupled audio).' },
|
||||
{ id: 'cvgate', label: 'CV', description: 'uSEQ CV/gate over USB serial — 11 CV + 3 gate.' },
|
||||
{ id: 'vcv', label: 'VCV', description: 'VCV Rack module — 16 CV outs with LED rings.' },
|
||||
{ id: 'particles', label: 'Particle', description: 'Flow-field visualiser (no audio).' },
|
||||
] as const;
|
||||
|
|
@ -68,6 +68,49 @@ export interface VcvSpec {
|
|||
bipolar: boolean; // unipolar 0..10V vs bipolar ±5V
|
||||
}
|
||||
|
||||
/**
|
||||
* uSEQ CV/gate backend per-output extras. Each model output is assigned to one
|
||||
* physical uSEQ channel (or 'none'); gate channels threshold the mapped 0..1
|
||||
* value. The fixed hardware topology is 11 CV + 3 gate — see
|
||||
* docs/useq-celium/protocol.md.
|
||||
*/
|
||||
export type CvChannelId =
|
||||
| 'none'
|
||||
| 'cv1' | 'cv2' | 'cv3' | 'cv4' | 'cv5' | 'cv6'
|
||||
| 'cv7' | 'cv8' | 'cv9' | 'cv10' | 'cv11'
|
||||
| 'gate1' | 'gate2' | 'gate3';
|
||||
|
||||
export interface CvChannelDesc {
|
||||
id: CvChannelId;
|
||||
label: string;
|
||||
kind: 'cv' | 'gate';
|
||||
}
|
||||
|
||||
/** The fixed uSEQ channel roster (UI dropdown order). */
|
||||
export const CV_CHANNELS: readonly CvChannelDesc[] = [
|
||||
{ id: 'cv1', label: 'CV1 · main A1', kind: 'cv' },
|
||||
{ id: 'cv2', label: 'CV2 · main A2', kind: 'cv' },
|
||||
{ id: 'cv3', label: 'CV3 · main A3', kind: 'cv' },
|
||||
{ id: 'cv4', label: 'CV4 · exp E1', kind: 'cv' },
|
||||
{ id: 'cv5', label: 'CV5 · exp E2', kind: 'cv' },
|
||||
{ id: 'cv6', label: 'CV6 · exp E3', kind: 'cv' },
|
||||
{ id: 'cv7', label: 'CV7 · exp E4', kind: 'cv' },
|
||||
{ id: 'cv8', label: 'CV8 · exp E5', kind: 'cv' },
|
||||
{ id: 'cv9', label: 'CV9 · exp E6', kind: 'cv' },
|
||||
{ id: 'cv10', label: 'CV10 · exp E7', kind: 'cv' },
|
||||
{ id: 'cv11', label: 'CV11 · exp E8', kind: 'cv' },
|
||||
{ id: 'gate1', label: 'Gate1 · main D1', kind: 'gate' },
|
||||
{ id: 'gate2', label: 'Gate2 · main D2', kind: 'gate' },
|
||||
{ id: 'gate3', label: 'Gate3 · main D3', kind: 'gate' },
|
||||
] as const;
|
||||
|
||||
export interface CvSpec {
|
||||
/** Physical uSEQ channel this output drives, or 'none' to skip it. */
|
||||
channel: CvChannelId;
|
||||
/** For gate channels: gate goes HIGH when the mapped 0..1 value ≥ this. */
|
||||
gateThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full per-output control. This is the spec's `OutputControl` (dock-spec
|
||||
* §3.2). It is represented on `MFParam` for the shared store; this interface
|
||||
|
|
@ -135,3 +178,12 @@ export function defaultOscSpec(name: string): OscSpec {
|
|||
export function defaultVcvSpec(): VcvSpec {
|
||||
return { bipolar: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default uSEQ CV channel for output `index` — identity map: outputs 0..10 →
|
||||
* CV1..CV11, outputs 11..13 → GATE1..3, the rest unassigned ('none').
|
||||
*/
|
||||
export function defaultCvSpec(index: number): CvSpec {
|
||||
const ch = CV_CHANNELS[index]?.id ?? 'none';
|
||||
return { channel: ch, gateThreshold: 0.5 };
|
||||
}
|
||||
|
|
|
|||
2
manifold/src/serial/web-serial.d.ts
vendored
2
manifold/src/serial/web-serial.d.ts
vendored
|
|
@ -20,6 +20,8 @@ interface SerialPort {
|
|||
close(): Promise<void>;
|
||||
readonly readable: ReadableStream<Uint8Array> | null;
|
||||
readonly writable: WritableStream<Uint8Array> | null;
|
||||
addEventListener?(type: 'connect' | 'disconnect', listener: () => void): void;
|
||||
removeEventListener?(type: 'connect' | 'disconnect', listener: () => void): void;
|
||||
}
|
||||
|
||||
interface SerialPortRequestOptions {
|
||||
|
|
|
|||
|
|
@ -26,5 +26,5 @@
|
|||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests/e2e"]
|
||||
"exclude": ["node_modules", "dist", "tests/e2e", "src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue