Merge remote-tracking branch 'origin/main' into workshop/synth-fw-audit

This commit is contained in:
monkey-w1n5t0n 2026-06-28 22:36:59 +02:00
commit 68f7d681fe
26 changed files with 1471 additions and 18 deletions

6
MAP.md
View file

@ -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 → CV13 + GATE13, I2C → expander) and `expander/` (I2C slave → CV411). 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.

231
docs/slp-workshop/SPEC.md Normal file
View file

@ -0,0 +1,231 @@
# SLP-Workshop — output modes, gate sequences & config UX
**Status:** DRAFT / spec-only (2026-06-28). The SLP-Workshop firmware exists as
the `slp_workshop` mode (Synth Library Portland workshop; reuses the MEMLCelium
engine + MLP shape verbatim, foregrounds the Jolt / OU-noise learning gestures —
branch `workshop/synth-fw-audit`). The **three output modes** below are a planned
evolution: compile-time *slices* of that mode. This document specs the mode model,
the Manifold-side gate-sequence configuration, and a proposed UI so the firmware
and the browser app stay aligned. The output-mode slicing + Manifold config are
NOT implemented yet.
Companion docs: `docs/useq-celium/protocol.md` (the CV wire protocol), the
manifold backends (`manifold/src/backends/`), the input layer
(`manifold/src/inputs/`).
**Decisions locked (2026-06-28, operator):** (1) hardware keeps exactly 2 ratio
sequences (memlcelium verbatim); the browser may instantiate as many as the user
wants. (2) The 3 uSEQ gate-only jacks make gates *optional* — pure CV is valid;
if doing gates at all, use those 3 first, then convert CV jacks. (3) Split-net
input is per-input-channel engine routing (each pad/stick/CC tagged Continuous or
Rhythm). (4) Internal BPM clock now; external-MIDI-clock sync is a later nicety.
---
## 1. Ground truth: what `memlcelium` actually is
`memlcelium` is the **hybrid**, and it is **one MLP**, not two:
- Net: `MLP<4, [10,14,18], 56>` (`nisps/engines/memlcelium.hpp`).
- The 56 outputs are split:
- `[0..13]`**sequencer**: 2 sequences × **7 ratio-seq params** each.
- `[14..55]`**synthesis**: 42 continuous params (Voice 0 + Voice 1).
- The single net therefore produces **both** the continuous values **and** the
RatioSeq parameters. An internal RatioSeq tick turns the seq params into note
triggers; the engine already exposes `pop_events()` so those triggers can be
consumed externally (MIDI / CV / gate) — that is the hook for gate outputs.
So the **two-separate-networks** hybrid is a genuinely different topology (two MLP
heads), which is why the hardware does the shared-net version and only the browser
(more compute, dynamic) does the split one.
### 7 ratio-seq params per track
`ratios[0..2]` (3), `phasor_mul` (1), `phase_off` (1), `amp_ratios[0..1]` (2) = 7.
(The April browser uSEQ-Celium used 8 — it added a pulse-width param. Use **7**
for firmware parity; pulse width can be an optional 8th later.)
---
## 2. The unifying model: two output STREAMS
Everything below collapses to two output streams, each driven by an MLP head:
| Stream | MLP outputs | Generates | Routes to |
|----------------|----------------------------------|----------------------|----------------------|
| **Continuous** | 1 value per channel | smooth 0..1 values | MIDI CC / CV jack |
| **Rhythm** | 7 params per gate-sequence track | clock-driven gates | MIDI note / gate jack|
A **mode** is just *which streams are active* and *whether they share a network*.
### How the Rhythm stream generates gates (RatioSeq)
Per gate-sequence track, each control tick (`nisps/engines/*` `ratio_seq_*`):
1. A shared **internal-BPM clock** advances a bar phasor. (External-MIDI-clock
sync is a desirable later addition, not a launch requirement — operator.)
2. `seq_phasor = (bar_phasor × phasor_mul + phase_off) mod 1`.
3. `ratio_seq_3(seq_phasor, ratios, pw=0.5)`**boolean gate** (the 3 ratios
carve the cycle into proportional segments; the phasor's segment + pulse-width
decide high/low).
4. `ratio_seq_2(amp_ratios)`**2-level velocity** (127 / 64).
5. Rising edge → note-on / gate-high (with velocity); falling edge → note-off /
gate-low.
So a gate is **clock + learned pattern**, not a threshold on a continuous value —
this is what makes "Rhythm" a distinct stream, and why it needs its own 7-params
per track rather than one output per gate.
**Defaults = firmware parity** (pulse width fixed at 0.5; 2-level velocity). Two
deferred extensions, not blocking: a per-track **pulse-width** (the 8th param →
controllable gate length, matters for envelope vs trigger) and **continuous
velocity/accent** instead of the 127/64 two-level. Add later if the workshop
wants them.
> Where RatioSeq runs in the browser: when the active engine *is* the memlcelium /
> SLP WASM engine, consume `pop_events()`. For CV/MIDI modes that don't run that
> audio engine, a small **TS RatioSeq** fed by the Rhythm MLP's 7-params/track
> drives the gates. (Implementation note — not built yet.)
---
## 3. Modes
### Firmware (SLP-Workshop) — compile-time, one chosen at build
Always **exactly 2 ratio sequences** (memlcelium verbatim) — the firmware does not
vary sequence count.
| Mode | Streams | Net | MLP output_size |
|-----------------------|----------------------|-----|----------------------------|
| **Continuous only** | Continuous | 1 | continuous params only |
| **Continuous & Rhythm** (= memlcelium) | both, **shared** | 1 | 14 seq + 42 synth = 56 |
| **Rhythm only** | Rhythm | 1 | 2 × 7 = 14 seq params |
### Browser (Manifold) — dynamic, switchable live
Same three **plus** a fourth, and with **as many ratio sequences as the user wants**
(each gate sequence = its own independent 7-param track; the net reshapes to suit):
- **Continuous & Rhythm (split nets)** — Continuous MLP + a separate Rhythm MLP.
Browser-only (two MLP heads; hardware uses one). `output_size`: shared net =
`continuous + 7 × n_gates` (mind the 126-output WASM cap → ~16 gates max shared);
split net = the Rhythm net is sized independently, so it scales further.
The browser mode is implied by the Outputs config (§4), not a separate picker:
choosing continuous-count > 0 and gate-sequences = 0 ⇒ Continuous-only;
gates > 0 with Shared net ⇒ hybrid-shared; gates > 0 with Separate net ⇒
hybrid-split; continuous-count = 0 ⇒ Rhythm-only.
---
## 4. Configuring gate sequences (Manifold, CV **and** MIDI modes)
The user can add **gate-sequence outputs** in both CV and MIDI modes. Two new
controls only; everything else is automatic.
### 4.1 The two output kinds
- **Continuous** → CC (MIDI) / CV jack (CV).
- **Gate sequence** → note (MIDI) / gate jack (CV). Each = one RatioSeq track.
**MIDI mode** — two independent steppers (capped by the model output budget):
```
Continuous (CC): [ 8 ]
Gate sequences: [ 2 ] each → a rhythm track → note on/off
```
**CV mode** — hardware is fixed (11 PWM/CV-capable + 3 digital/gate-only), so the
two counts are *linked*. Gates are **optional** — pure CV (0 gates) is valid. One
control:
```
uSEQ jacks CV 11 · Gate 0
Gate sequences 0 ●───────────── 14
└ first 3 are FREE (the gate-only digital jacks); the 4th+ convert a CV jack
```
Rule: `gates ∈ [0, 14]`; `CV = gates ≤ 3 ? 11 : 14 gates`. So the first 3 gate
sequences land on the dedicated gate-only jacks and cost no CV (gates 0→11 CV;
3→11 CV; 6→8 CV; 14→0 CV). "Add more gates by swapping CV outs to gate outs" only
kicks in past 3. (If you want gates at all, those 3 gate-only jacks are there to
use; if you don't, they sit idle and you keep all 11 CV.)
> Wire-protocol impact: **none**. A CV jack acting as a gate just carries 0/full
> (or the 2-level velocity) in its `u16` slot; the 3 dedicated gate bits stay the
> digital pins (`docs/useq-celium/protocol.md`). The CV backend's `CvSpec` already
> lets any output target a CV *or* a gate channel — this extends it so PWM jacks
> can be gate targets too.
### 4.2 The Rhythm network (shown only when gate sequences > 0)
```
Rhythm network: ( Shared ) ( Separate ◀ default )
```
- **Separate** (default): the Rhythm stream gets its **own MLP**, so each input
channel is **routed to one of the two engines** (Continuous or Rhythm). The
routing is automatic per source kind:
- **XY pad (internal)** → a **second on-screen XY pad** appears; pad 1 →
Continuous, pad 2 → Rhythm.
- **Gamepad****double-stick**: left stick → Continuous, right stick → Rhythm.
- **MIDI controller****per-CC routing**: each learned CC has a small
`Continuous | Rhythm` toggle, so the user assigns which knobs drive which
engine. (The pad/gamepad cases are just the pre-grouped 2-axis versions of
this same per-channel routing.)
- **Shared**: one MLP drives both streams; **all** input channels feed the single
net (one pad / one stick / all CCs). This is the hardware-parity hybrid.
That is the whole decision surface: **two numbers + one toggle.** The second
pad / double-stick / per-CC tag is a *consequence* of Separate, surfaced inline,
not a separate mode control. It reuses the existing input layer
(`InputSource.axisCount()` already supports gamepad single↔double-stick; per-axis
labels exist via `axisLabels()`) — "Separate" adds an engine tag per input axis
and composes a second `xy-pad` source / flips the gamepad to 4-axis as needed.
### 4.3 Per-output detail (advanced, optional)
In `BackendAdvanced` (full-depth modal), each output row can override the
defaults: which model output drives each CC/CV; which rhythm track drives each
note/gate; gate threshold (for continuous-derived gates); MIDI note#/channel; CV
polarity. Defaults (identity assignment) make this unnecessary for the common case.
---
## 5. Proposed UI summary (the "simple" target)
Outputs panel, top to bottom:
1. **Output kinds**`Continuous [n]` + `Gate sequences [n]` (MIDI), or the
linked `Gate sequences 014` slider with the live `CV n · Gate n` readout (CV).
2. **Rhythm network** toggle `( Shared | Separate )` + the inline input surface
(2nd pad / double-stick / per-CC engine tags) — only when gate sequences > 0.
3. The existing per-output rows (off/fixed/live, mute, arm, min/max/curve) +
the per-backend specifics (CC#, CV jack, note#) as today.
No mode dropdown for the rhythm/continuous split — the counts + toggle *are* the
mode.
---
## 6. Open questions / deltas to build later
Build deltas (when we proceed):
- **Manifold engine**: today one MLP head on the spine. "Separate" needs a second
Rhythm MLP head + per-input-axis engine routing. Scope: engine + input-layer.
- **TS RatioSeq**: a browser-side RatioSeq (or a `pop_events()` bridge) to turn
Rhythm-MLP params into gate/note events for the CV & MIDI backends. 2 sequences
on firmware; arbitrary count in the browser.
- **MIDI backend**: gate sequences → note on/off (it currently only sends CC).
- **CvSpec**: allow PWM jacks (cv1..cv11) to be gate targets (incl. velocity-CV),
with the 3 digital pins as the first-used gate jacks.
- **Reshape**: shared-net `output_size` = continuous + 7 × gate_sequences (≤126
WASM cap) — confirm against the reshape/reset-on-reshape flow.
- **Firmware output_size** for "Continuous only" / "Rhythm only" — slices of the
`slp_workshop` mode (`workshop/synth-fw-audit`); the firmware agent's call. This
doc fixes the *shapes*, not exact counts.
Deferred niceties (explicitly not blocking): external-MIDI-clock sync; per-track
pulse-width (gate length); continuous velocity/accent beyond 127/64.

View 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.
- `CV1CV3` → main module PWM (pins 21/20/19)
- `CV4CV11` → expander PWM (forwarded over I2C)
- `GATE1GATE3` → 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` |
| 223 | 11 × CV, `u16` LE, `0..4095` (CV1…CV11) |
| 24 | gate bits: bit0=GATE1, bit1=GATE2, bit2=GATE3 |
| 25 | XOR of bytes 124 |
CV order: indices 02 = main CV1CV3; indices 310 = expander CV4CV11.
### `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 12).
### `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 CV4CV11.
## Reserved
Type `0x02` (the old runtime CONFIG / mode bitmask) is reserved and unused — v2's
topology is fixed in firmware.

View 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 |
|------------|-----------|---------------|---------------|
| CV1CV3 | main | 21, 20, 19 | PWM (11-bit) |
| GATE1GATE3| main | 18, 17, 16 | digital |
| CV4CV11 | expander | 13,14,10,11,8,7,5,3 | PWM (11-bit) |
The main board forwards CV4CV11 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`.

View 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

View 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));
}
}
}

View 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

View 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();
}

View 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;
}

View file

@ -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"
}, },

View 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);
}
}

View file

@ -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

View file

@ -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 };
} }

View 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]);
});

View 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 devicehost 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);
}
}
}

View file

@ -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,

View file

@ -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 },
}; };

View file

@ -245,6 +245,7 @@ export const GLYPH_FALLBACK = {
particles: '✦', particles: '✦',
midi: '🎹', midi: '🎹',
osc: '◉', osc: '◉',
cv: '⎓',
synth: '🔊', synth: '🔊',
editor: '🔌', editor: '🔌',
} as const; } as const;

View file

@ -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 {

View file

@ -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',

View file

@ -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;

View file

@ -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 01 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 (

View file

@ -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&nbsp;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>
); );
} }

View file

@ -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 };
}

View file

@ -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 {

View file

@ -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"]
} }