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()`.
|
- `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/MEMLNaut-NISPS/src/{memllib,daisysp,nisps}` — symlinks (Arduino-CLI requires sketch-tree includes; preprocessor refuses `..` in headers).
|
||||||
- `firmware/README.md` — structure + build instructions.
|
- `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/` — SolidJS + Vite + TypeScript app
|
||||||
- `playground/index.html`, `vite.config.ts`, `tsconfig.json`, `package.json` — scaffold. COOP/COEP headers configured.
|
- `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`,
|
- `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`.
|
`OutputsBackendConfig.tsx` (per-backend specialised Outputs panel), `BackendAdvanced.tsx`.
|
||||||
- `manifold/src/backends/` — `OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts`
|
- `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
|
- `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
|
`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.
|
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",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --port 4273",
|
"preview": "vite preview --port 4273",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "bun test src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:headed": "playwright test --headed"
|
"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 { PassthroughBackend } from './passthrough-backend';
|
||||||
import { ParticleBackend } from './particle-backend';
|
import { ParticleBackend } from './particle-backend';
|
||||||
import { VcvBackend, type VcvFeedbackOp } from './vcv-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). */
|
/** The slice of EngineApi the manager depends on (keeps it decoupled/testable). */
|
||||||
export interface ManagerEngine {
|
export interface ManagerEngine {
|
||||||
|
|
@ -56,7 +57,7 @@ export class BackendManager {
|
||||||
['osc', backends?.osc ?? new OscBridgeBackend()],
|
['osc', backends?.osc ?? new OscBridgeBackend()],
|
||||||
['synth', backends?.synth ?? new PassthroughBackend('synth', 'Built-in Synth — audio plays in the engine')],
|
['synth', backends?.synth ?? new PassthroughBackend('synth', 'Built-in Synth — audio plays in the engine')],
|
||||||
['particles', backends?.particles ?? new ParticleBackend()],
|
['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()],
|
['vcv', backends?.vcv ?? new VcvBackend()],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -89,6 +90,11 @@ export class BackendManager {
|
||||||
return b instanceof VcvBackend ? b : null;
|
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.
|
* 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
|
* 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 { EngineApi } from '../engine';
|
||||||
import type { MFParam } from '../console/model';
|
import type { MFParam } from '../console/model';
|
||||||
import type { BackendContext, BackendStatus, OutputMapping } from './backend';
|
import type { BackendContext, BackendStatus, OutputMapping } from './backend';
|
||||||
import type { BackendId, MidiCcSpec, OscSpec, VcvSpec } from '../dock/output-state';
|
import type { BackendId, CvSpec, MidiCcSpec, OscSpec, VcvSpec } from '../dock/output-state';
|
||||||
import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from '../dock/output-state';
|
import { defaultCvSpec, defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from '../dock/output-state';
|
||||||
import { BackendManager } from './manager';
|
import { BackendManager } from './manager';
|
||||||
|
|
||||||
export interface MidiSettings {
|
export interface MidiSettings {
|
||||||
|
|
@ -56,6 +56,12 @@ export interface UseBackendManager {
|
||||||
/** Available MIDI output ports (refreshes when MIDI starts/hot-plugs). */
|
/** Available MIDI output ports (refreshes when MIDI starts/hot-plugs). */
|
||||||
midiPorts: { id: string; name: string }[];
|
midiPorts: { id: string; name: string }[];
|
||||||
refreshMidiPorts: () => void;
|
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(
|
export function useBackendManager(
|
||||||
|
|
@ -148,5 +154,17 @@ export function useBackendManager(
|
||||||
vcv.setVcvConfig(specs, { url: vcvSettings.url, sendRaw: vcvSettings.sendRaw });
|
vcv.setVcvConfig(specs, { url: vcvSettings.url, sendRaw: vcvSettings.sendRaw });
|
||||||
}, [manager, params, vcvSettings.url, 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
|
// spine and forwards routed outputs to the active backend; switching Mode
|
||||||
// tears down the old backend, starts the new one, and gates synth audio
|
// 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.
|
// (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,
|
engine,
|
||||||
outputBackend,
|
outputBackend,
|
||||||
modeId,
|
modeId,
|
||||||
|
|
@ -688,6 +696,9 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
setVcvUrl,
|
setVcvUrl,
|
||||||
vcvSendRaw,
|
vcvSendRaw,
|
||||||
setVcvSendRaw,
|
setVcvSendRaw,
|
||||||
|
cvConnect,
|
||||||
|
cvIdentify,
|
||||||
|
cvDisconnect,
|
||||||
setParams: (next: MFParam[]) => setParams(next),
|
setParams: (next: MFParam[]) => setParams(next),
|
||||||
markers,
|
markers,
|
||||||
inputs,
|
inputs,
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ const MODE_ICON: Record<OutputMode, { Icon: (p: IconProps) => JSX.Element; glyph
|
||||||
particles: { Icon: ParticleIcon, glyph: GLYPH_FALLBACK.particles },
|
particles: { Icon: ParticleIcon, glyph: GLYPH_FALLBACK.particles },
|
||||||
midi: { Icon: MidiIcon, glyph: GLYPH_FALLBACK.midi },
|
midi: { Icon: MidiIcon, glyph: GLYPH_FALLBACK.midi },
|
||||||
osc: { Icon: OscIcon, glyph: GLYPH_FALLBACK.osc },
|
osc: { Icon: OscIcon, glyph: GLYPH_FALLBACK.osc },
|
||||||
|
cv: { Icon: OscIcon, glyph: GLYPH_FALLBACK.cv },
|
||||||
synth: { Icon: SynthIcon, glyph: GLYPH_FALLBACK.synth },
|
synth: { Icon: SynthIcon, glyph: GLYPH_FALLBACK.synth },
|
||||||
editor: { Icon: EditorIcon, glyph: GLYPH_FALLBACK.editor },
|
editor: { Icon: EditorIcon, glyph: GLYPH_FALLBACK.editor },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,7 @@ export const GLYPH_FALLBACK = {
|
||||||
particles: '✦',
|
particles: '✦',
|
||||||
midi: '🎹',
|
midi: '🎹',
|
||||||
osc: '◉',
|
osc: '◉',
|
||||||
|
cv: '⎓',
|
||||||
synth: '🔊',
|
synth: '🔊',
|
||||||
editor: '🔌',
|
editor: '🔌',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export interface MFParam {
|
||||||
osc?: { path: string; rangeMin: number; rangeMax: number };
|
osc?: { path: string; rangeMin: number; rangeMax: number };
|
||||||
/** VCV backend spec ({ bipolar }). */
|
/** VCV backend spec ({ bipolar }). */
|
||||||
vcv?: { bipolar: boolean };
|
vcv?: { bipolar: boolean };
|
||||||
|
/** uSEQ CV/gate backend spec ({ channel, gateThreshold }). */
|
||||||
|
cv?: { channel: string; gateThreshold: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MFMode {
|
export interface MFMode {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* output-mode.ts — the TOP dock selector catalogue (operator dock restructure).
|
* 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:
|
* first the default:
|
||||||
* • Particle System (visual) — DEFAULT
|
* • Particle System (visual) — DEFAULT
|
||||||
* • MIDI
|
* • MIDI
|
||||||
* • OSC
|
* • OSC
|
||||||
|
* • CV / uSEQ — uSEQ CV/gate over USB Web Serial (cvgate backend)
|
||||||
* • Built-in Synth — the synth backend; NEVER the string "C15"
|
* • Built-in Synth — the synth backend; NEVER the string "C15"
|
||||||
* • MEMLNaut Editor — hardware-connection mode (Web Serial)
|
* • MEMLNaut Editor — hardware-connection mode (Web Serial)
|
||||||
*
|
*
|
||||||
|
|
@ -58,6 +59,13 @@ export const OUTPUT_MODES: readonly OutputModeDescriptor[] = [
|
||||||
audio: false,
|
audio: false,
|
||||||
backend: 'osc',
|
backend: 'osc',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'cv',
|
||||||
|
label: 'CV / uSEQ',
|
||||||
|
description: 'uSEQ CV/gate over USB serial — 11 CV + 3 gate.',
|
||||||
|
audio: false,
|
||||||
|
backend: 'cvgate',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'synth',
|
id: 'synth',
|
||||||
label: 'Built-in Synth',
|
label: 'Built-in Synth',
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export interface Pin {
|
||||||
* (operator dock restructure). "Built-in Synth" is the synth backend — the
|
* (operator dock restructure). "Built-in Synth" is the synth backend — the
|
||||||
* string "C15" must NEVER appear. Particle + Editor are non-audio.
|
* 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. */
|
/** Feedback marker plotted on the 2D map at the input location it was given. */
|
||||||
export interface FeedbackMarker {
|
export interface FeedbackMarker {
|
||||||
|
|
@ -106,6 +106,10 @@ export interface ConsoleCtx {
|
||||||
setVcvUrl: (u: string) => void;
|
setVcvUrl: (u: string) => void;
|
||||||
vcvSendRaw: boolean;
|
vcvSendRaw: boolean;
|
||||||
setVcvSendRaw: (v: boolean) => void;
|
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). */
|
/** Replace the whole params array (used when restoring a named preset). */
|
||||||
setParams: (next: MFParam[]) => void;
|
setParams: (next: MFParam[]) => void;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
* inline config in OutputsBackendConfig; both write the same store.
|
* inline config in OutputsBackendConfig; both write the same store.
|
||||||
*/
|
*/
|
||||||
import type { MFParam } from '../console/model';
|
import type { MFParam } from '../console/model';
|
||||||
import type { BackendId } from './output-state';
|
import type { BackendId, CvChannelId } from './output-state';
|
||||||
import { defaultMidiSpec, defaultOscSpec } from './output-state';
|
import { CV_CHANNELS, defaultCvSpec, defaultMidiSpec, defaultOscSpec } from './output-state';
|
||||||
|
|
||||||
function num(s: string, fallback: number): number {
|
function num(s: string, fallback: number): number {
|
||||||
const v = parseFloat(s);
|
const v = parseFloat(s);
|
||||||
|
|
@ -59,8 +59,9 @@ export function BackendAdvanced({ backend, params, setParam }: BackendAdvancedPr
|
||||||
case 'osc':
|
case 'osc':
|
||||||
return <OscPathEditor params={params} setParam={setParam} />;
|
return <OscPathEditor params={params} setParam={setParam} />;
|
||||||
case 'vcv':
|
case 'vcv':
|
||||||
case 'cvgate':
|
|
||||||
return <VcvChannelEditor params={params} setParam={setParam} />;
|
return <VcvChannelEditor params={params} setParam={setParam} />;
|
||||||
|
case 'cvgate':
|
||||||
|
return <CvChannelEditor params={params} setParam={setParam} />;
|
||||||
default:
|
default:
|
||||||
return <SynthGroupNote params={params} />;
|
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[] }) {
|
function SynthGroupNote({ params }: { params: MFParam[] }) {
|
||||||
const groups = Array.from(new Set(params.map((p) => p.group)));
|
const groups = Array.from(new Set(params.map((p) => p.group)));
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ConsoleCtx } from '../console/types';
|
import type { ConsoleCtx } from '../console/types';
|
||||||
import type { BackendId } from './output-state';
|
import type { BackendId, CvChannelId } from './output-state';
|
||||||
import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state';
|
import { CV_CHANNELS, defaultCvSpec, defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state';
|
||||||
import {
|
import {
|
||||||
applyPreset,
|
applyPreset,
|
||||||
deletePreset,
|
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 ----------------------------------------------------------
|
// ---- Public entry ----------------------------------------------------------
|
||||||
|
|
||||||
export interface OutputsBackendConfigProps {
|
export interface OutputsBackendConfigProps {
|
||||||
|
|
@ -592,14 +675,22 @@ export interface OutputsBackendConfigProps {
|
||||||
|
|
||||||
/** The specialised, editable per-backend config + preset bar for the Outputs panel. */
|
/** The specialised, editable per-backend config + preset bar for the Outputs panel. */
|
||||||
export function OutputsBackendConfig({ ctx, backend }: OutputsBackendConfigProps) {
|
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
|
// config is rendered by ModeConfig in Drawers. The full-depth BackendAdvanced
|
||||||
// modal reuses the same per-channel sections.
|
// 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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<PresetBar ctx={ctx} backend={backend} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const BACKENDS: readonly BackendDescriptor[] = [
|
||||||
{ id: 'synth', label: 'Powerful Synth Engine', description: 'Firmware-parity built-in audio engine.' },
|
{ 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: 'midi', label: 'MIDI', description: 'Web MIDI CC out — per-output CC#/channel.' },
|
||||||
{ id: 'osc', label: 'OSC', description: 'OSC bridge — named paths + physical ranges.' },
|
{ 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: 'vcv', label: 'VCV', description: 'VCV Rack module — 16 CV outs with LED rings.' },
|
||||||
{ id: 'particles', label: 'Particle', description: 'Flow-field visualiser (no audio).' },
|
{ id: 'particles', label: 'Particle', description: 'Flow-field visualiser (no audio).' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
@ -68,6 +68,49 @@ export interface VcvSpec {
|
||||||
bipolar: boolean; // unipolar 0..10V vs bipolar ±5V
|
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
|
* 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
|
* §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 {
|
export function defaultVcvSpec(): VcvSpec {
|
||||||
return { bipolar: false };
|
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>;
|
close(): Promise<void>;
|
||||||
readonly readable: ReadableStream<Uint8Array> | null;
|
readonly readable: ReadableStream<Uint8Array> | null;
|
||||||
readonly writable: WritableStream<Uint8Array> | null;
|
readonly writable: WritableStream<Uint8Array> | null;
|
||||||
|
addEventListener?(type: 'connect' | 'disconnect', listener: () => void): void;
|
||||||
|
removeEventListener?(type: 'connect' | 'disconnect', listener: () => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SerialPortRequestOptions {
|
interface SerialPortRequestOptions {
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,5 @@
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "vite.config.ts"],
|
"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