diff --git a/MAP.md b/MAP.md index 143c2bb..f624dff 100644 --- a/MAP.md +++ b/MAP.md @@ -26,6 +26,7 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod - `settings_view.hpp` — `wire_settings(mode)`: adds on-device settings views to the MEMLNaut display carousel (TFT + rotary encoder). Joystick Dual/Single toggle for the 4-input ("two 2-D joystick") modes — "Single" pins ML input channels 2,3 to neutral via `ModeBase::set_input_pinned` (no net rebuild). Registered in the `.ino` after `addSystemInfoView()`. - `firmware/MEMLNaut-NISPS/src/{memllib,daisysp,nisps}` — symlinks (Arduino-CLI requires sketch-tree includes; preprocessor refuses `..` in headers). - `firmware/README.md` — structure + build instructions. +- `firmware/useq-celium/` — standalone RP2040 firmware (PlatformIO, Arduino-Pico core) that turns a uSEQ module + CV expander into a USB→CV/gate converter driven by the manifold `cvgate` backend. `shared/protocol.h` is the v2 wire-protocol single source of truth (mirrored by `manifold/src/backends/useq-protocol.ts`); `main/` (USB serial → CV1–3 + GATE1–3, I2C → expander) and `expander/` (I2C slave → CV4–11). Wire spec: `docs/useq-celium/protocol.md`. Restored from the April-2026 "uSEQ-Celium" mode. ### `playground/` — SolidJS + Vite + TypeScript app - `playground/index.html`, `vite.config.ts`, `tsconfig.json`, `package.json` — scaffold. COOP/COEP headers configured. @@ -63,7 +64,10 @@ anchor + locked decisions) and the `docs/redesign/*-spec.md` set. - `manifold/src/dock/` — `OutputControlRow` (off/fixed/live + mute + solo/arm + min/max/curve), `output-state.ts`, `OutputsBackendConfig.tsx` (per-backend specialised Outputs panel), `BackendAdvanced.tsx`. - `manifold/src/backends/` — `OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts` - (WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `presets.ts` (named presets), `manager.ts`. + (WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `vcv-backend.ts` (VCV-over-WS), `cv-backend.ts` + (`UseqCvBackend` — uSEQ CV/gate over USB Web Serial, backend id `cvgate`) + `useq-protocol.ts` (v2 wire + protocol, mirrors `firmware/useq-celium/shared/protocol.h`; `useq-protocol.test.ts` runs via `bun test`), + `particle-backend.ts`, `passthrough-backend.ts`, `presets.ts` (named presets), `manager.ts`. - `manifold/src/midi-devices/` — external-synth device templates. `generated/` is codegen output from `schemas/midi_devices/` (`MIDI_DEVICES` catalogue + `MIDI_DEVICES_BY_ID`, params by name+CC). The MIDI Outputs config (`dock/OutputsBackendConfig.tsx`) reads it for the device picker + param-select that fills the CC table. diff --git a/docs/slp-workshop/SPEC.md b/docs/slp-workshop/SPEC.md new file mode 100644 index 0000000..8260917 --- /dev/null +++ b/docs/slp-workshop/SPEC.md @@ -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 0–14` 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. diff --git a/docs/useq-celium/protocol.md b/docs/useq-celium/protocol.md new file mode 100644 index 0000000..e49bd36 --- /dev/null +++ b/docs/useq-celium/protocol.md @@ -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. diff --git a/firmware/useq-celium/README.md b/firmware/useq-celium/README.md new file mode 100644 index 0000000..a6b526f --- /dev/null +++ b/firmware/useq-celium/README.md @@ -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`. diff --git a/firmware/useq-celium/expander/platformio.ini b/firmware/useq-celium/expander/platformio.ini new file mode 100644 index 0000000..20c25b2 --- /dev/null +++ b/firmware/useq-celium/expander/platformio.ini @@ -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 diff --git a/firmware/useq-celium/expander/src/expander.cpp b/firmware/useq-celium/expander/src/expander.cpp new file mode 100644 index 0000000..50fd6e2 --- /dev/null +++ b/firmware/useq-celium/expander/src/expander.cpp @@ -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 +#include +#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)); + } + } +} diff --git a/firmware/useq-celium/main/platformio.ini b/firmware/useq-celium/main/platformio.ini new file mode 100644 index 0000000..440cc42 --- /dev/null +++ b/firmware/useq-celium/main/platformio.ini @@ -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 diff --git a/firmware/useq-celium/main/src/main.cpp b/firmware/useq-celium/main/src/main.cpp new file mode 100644 index 0000000..28e4d8f --- /dev/null +++ b/firmware/useq-celium/main/src/main.cpp @@ -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 +#include +#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(); +} diff --git a/firmware/useq-celium/shared/protocol.h b/firmware/useq-celium/shared/protocol.h new file mode 100644 index 0000000..1eeb92f --- /dev/null +++ b/firmware/useq-celium/shared/protocol.h @@ -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 + +// ─── 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; +} diff --git a/manifold/package.json b/manifold/package.json index 4218bec..23a0667 100644 --- a/manifold/package.json +++ b/manifold/package.json @@ -9,6 +9,7 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview --port 4273", "typecheck": "tsc --noEmit", + "test": "bun test src", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed" }, diff --git a/manifold/src/backends/cv-backend.ts b/manifold/src/backends/cv-backend.ts new file mode 100644 index 0000000..e9a37a0 --- /dev/null +++ b/manifold/src/backends/cv-backend.ts @@ -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 | null = null; + private reader: ReadableStreamDefaultReader | 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 { + 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 { + 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 { + 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 { + 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 { + await this.closePort(); + this.setStatus({ state: 'ready', message: 'uSEQ ready — click Connect device' }); + } + + private async closePort(): Promise { + 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 { + 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); + } +} diff --git a/manifold/src/backends/manager.ts b/manifold/src/backends/manager.ts index f205482..fe9e7c6 100644 --- a/manifold/src/backends/manager.ts +++ b/manifold/src/backends/manager.ts @@ -23,6 +23,7 @@ import { OscBridgeBackend } from './osc-backend'; import { PassthroughBackend } from './passthrough-backend'; import { ParticleBackend } from './particle-backend'; import { VcvBackend, type VcvFeedbackOp } from './vcv-backend'; +import { UseqCvBackend } from './cv-backend'; /** The slice of EngineApi the manager depends on (keeps it decoupled/testable). */ export interface ManagerEngine { @@ -56,7 +57,7 @@ export class BackendManager { ['osc', backends?.osc ?? new OscBridgeBackend()], ['synth', backends?.synth ?? new PassthroughBackend('synth', 'Built-in Synth — audio plays in the engine')], ['particles', backends?.particles ?? new ParticleBackend()], - ['cvgate', backends?.cvgate ?? new PassthroughBackend('cvgate', 'CV / gate (via VCV bridge)')], + ['cvgate', backends?.cvgate ?? new UseqCvBackend()], ['vcv', backends?.vcv ?? new VcvBackend()], ]); @@ -89,6 +90,11 @@ export class BackendManager { return b instanceof VcvBackend ? b : null; } + cv(): UseqCvBackend | null { + const b = this.backends.get('cvgate'); + return b instanceof UseqCvBackend ? b : null; + } + /** * Forward a verdict op to the VCV module's embedded learner over the bridge. * No-op unless the VCV backend is the ACTIVE one — the verdict loop only diff --git a/manifold/src/backends/useBackendManager.ts b/manifold/src/backends/useBackendManager.ts index 096e8e8..a8ab2f8 100644 --- a/manifold/src/backends/useBackendManager.ts +++ b/manifold/src/backends/useBackendManager.ts @@ -18,8 +18,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { EngineApi } from '../engine'; import type { MFParam } from '../console/model'; import type { BackendContext, BackendStatus, OutputMapping } from './backend'; -import type { BackendId, MidiCcSpec, OscSpec, VcvSpec } from '../dock/output-state'; -import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from '../dock/output-state'; +import type { BackendId, CvSpec, MidiCcSpec, OscSpec, VcvSpec } from '../dock/output-state'; +import { defaultCvSpec, defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from '../dock/output-state'; import { BackendManager } from './manager'; export interface MidiSettings { @@ -56,6 +56,12 @@ export interface UseBackendManager { /** Available MIDI output ports (refreshes when MIDI starts/hot-plugs). */ midiPorts: { id: string; name: string }[]; refreshMidiPorts: () => void; + /** uSEQ CV backend: pick + open a serial port (user gesture). */ + cvConnect: () => void; + /** uSEQ CV backend: flash the module LEDs to confirm the link. */ + cvIdentify: () => void; + /** uSEQ CV backend: close the serial port. */ + cvDisconnect: () => void; } export function useBackendManager( @@ -148,5 +154,17 @@ export function useBackendManager( vcv.setVcvConfig(specs, { url: vcvSettings.url, sendRaw: vcvSettings.sendRaw }); }, [manager, params, vcvSettings.url, vcvSettings.sendRaw]); - return { manager, status, midiPorts, refreshMidiPorts }; + // Push per-output uSEQ CV config (channel + gate threshold) whenever it changes. + useEffect(() => { + const cv = manager?.cv(); + if (!cv) return; + const specs: CvSpec[] = params.map((p, i) => (p.cv as CvSpec | undefined) ?? defaultCvSpec(i)); + cv.setCvConfig(specs); + }, [manager, params]); + + const cvConnect = () => void manager?.cv()?.connect(); + const cvIdentify = () => manager?.cv()?.identify(); + const cvDisconnect = () => void manager?.cv()?.disconnect(); + + return { manager, status, midiPorts, refreshMidiPorts, cvConnect, cvIdentify, cvDisconnect }; } diff --git a/manifold/src/backends/useq-protocol.test.ts b/manifold/src/backends/useq-protocol.test.ts new file mode 100644 index 0000000..2ff01c8 --- /dev/null +++ b/manifold/src/backends/useq-protocol.test.ts @@ -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]); +}); diff --git a/manifold/src/backends/useq-protocol.ts b/manifold/src/backends/useq-protocol.ts new file mode 100644 index 0000000..306e497 --- /dev/null +++ b/manifold/src/backends/useq-protocol.ts @@ -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, 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); + } + } +} diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index ca8dce4..aca34b7 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -271,7 +271,15 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp // spine and forwards routed outputs to the active backend; switching Mode // tears down the old backend, starts the new one, and gates synth audio // (mute on non-synth modes). MIDI/OSC config + names ride the shared params. - const { manager: backendManager, status: backendStatus, midiPorts, refreshMidiPorts } = useBackendManager( + const { + manager: backendManager, + status: backendStatus, + midiPorts, + refreshMidiPorts, + cvConnect, + cvIdentify, + cvDisconnect, + } = useBackendManager( engine, outputBackend, modeId, @@ -688,6 +696,9 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp setVcvUrl, vcvSendRaw, setVcvSendRaw, + cvConnect, + cvIdentify, + cvDisconnect, setParams: (next: MFParam[]) => setParams(next), markers, inputs, diff --git a/manifold/src/console/Dock.tsx b/manifold/src/console/Dock.tsx index 9481502..cbad630 100644 --- a/manifold/src/console/Dock.tsx +++ b/manifold/src/console/Dock.tsx @@ -56,6 +56,7 @@ const MODE_ICON: Record JSX.Element; glyph particles: { Icon: ParticleIcon, glyph: GLYPH_FALLBACK.particles }, midi: { Icon: MidiIcon, glyph: GLYPH_FALLBACK.midi }, osc: { Icon: OscIcon, glyph: GLYPH_FALLBACK.osc }, + cv: { Icon: OscIcon, glyph: GLYPH_FALLBACK.cv }, synth: { Icon: SynthIcon, glyph: GLYPH_FALLBACK.synth }, editor: { Icon: EditorIcon, glyph: GLYPH_FALLBACK.editor }, }; diff --git a/manifold/src/console/icons.tsx b/manifold/src/console/icons.tsx index a5da360..fba380c 100644 --- a/manifold/src/console/icons.tsx +++ b/manifold/src/console/icons.tsx @@ -245,6 +245,7 @@ export const GLYPH_FALLBACK = { particles: '✦', midi: '🎹', osc: '◉', + cv: '⎓', synth: '🔊', editor: '🔌', } as const; diff --git a/manifold/src/console/model.ts b/manifold/src/console/model.ts index 63b2010..f17196a 100644 --- a/manifold/src/console/model.ts +++ b/manifold/src/console/model.ts @@ -45,6 +45,8 @@ export interface MFParam { osc?: { path: string; rangeMin: number; rangeMax: number }; /** VCV backend spec ({ bipolar }). */ vcv?: { bipolar: boolean }; + /** uSEQ CV/gate backend spec ({ channel, gateThreshold }). */ + cv?: { channel: string; gateThreshold: number }; } export interface MFMode { diff --git a/manifold/src/console/output-mode.ts b/manifold/src/console/output-mode.ts index d33c1eb..06787fc 100644 --- a/manifold/src/console/output-mode.ts +++ b/manifold/src/console/output-mode.ts @@ -1,11 +1,12 @@ /** * output-mode.ts — the TOP dock selector catalogue (operator dock restructure). * - * "Mode" here = the active OUTPUT BACKEND/target. Five options, in order, the + * "Mode" here = the active OUTPUT BACKEND/target. Six options, in order, the * first the default: * • Particle System (visual) — DEFAULT * • MIDI * • OSC + * • CV / uSEQ — uSEQ CV/gate over USB Web Serial (cvgate backend) * • Built-in Synth — the synth backend; NEVER the string "C15" * • MEMLNaut Editor — hardware-connection mode (Web Serial) * @@ -58,6 +59,13 @@ export const OUTPUT_MODES: readonly OutputModeDescriptor[] = [ audio: false, backend: 'osc', }, + { + id: 'cv', + label: 'CV / uSEQ', + description: 'uSEQ CV/gate over USB serial — 11 CV + 3 gate.', + audio: false, + backend: 'cvgate', + }, { id: 'synth', label: 'Built-in Synth', diff --git a/manifold/src/console/types.ts b/manifold/src/console/types.ts index 60a2154..2756b00 100644 --- a/manifold/src/console/types.ts +++ b/manifold/src/console/types.ts @@ -24,7 +24,7 @@ export interface Pin { * (operator dock restructure). "Built-in Synth" is the synth backend — the * string "C15" must NEVER appear. Particle + Editor are non-audio. */ -export type OutputMode = 'particles' | 'midi' | 'osc' | 'synth' | 'editor'; +export type OutputMode = 'particles' | 'midi' | 'osc' | 'cv' | 'synth' | 'editor'; /** Feedback marker plotted on the 2D map at the input location it was given. */ export interface FeedbackMarker { @@ -106,6 +106,10 @@ export interface ConsoleCtx { setVcvUrl: (u: string) => void; vcvSendRaw: boolean; setVcvSendRaw: (v: boolean) => void; + /** uSEQ CV backend (USB serial): connect a port / flash LEDs / disconnect. */ + cvConnect: () => void; + cvIdentify: () => void; + cvDisconnect: () => void; /** Replace the whole params array (used when restoring a named preset). */ setParams: (next: MFParam[]) => void; diff --git a/manifold/src/dock/BackendAdvanced.tsx b/manifold/src/dock/BackendAdvanced.tsx index c1832b0..269df47 100644 --- a/manifold/src/dock/BackendAdvanced.tsx +++ b/manifold/src/dock/BackendAdvanced.tsx @@ -9,8 +9,8 @@ * inline config in OutputsBackendConfig; both write the same store. */ import type { MFParam } from '../console/model'; -import type { BackendId } from './output-state'; -import { defaultMidiSpec, defaultOscSpec } from './output-state'; +import type { BackendId, CvChannelId } from './output-state'; +import { CV_CHANNELS, defaultCvSpec, defaultMidiSpec, defaultOscSpec } from './output-state'; function num(s: string, fallback: number): number { const v = parseFloat(s); @@ -59,8 +59,9 @@ export function BackendAdvanced({ backend, params, setParam }: BackendAdvancedPr case 'osc': return ; case 'vcv': - case 'cvgate': return ; + case 'cvgate': + return ; default: return ; } @@ -269,6 +270,77 @@ function VcvChannelEditor({ ); } +// ---- uSEQ CV / gate (docs/useq-celium) ------------------------------------- + +function CvChannelEditor({ + params, + setParam, +}: { + params: MFParam[]; + setParam: (i: number, patch: Partial) => void; +}) { + return ( +
+

+ 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. +

+
+ + + + + + + + + + {params.map((p, i) => { + const c = (p.cv as { channel: CvChannelId; gateThreshold: number } | undefined) ?? defaultCvSpec(i); + const isGate = c.channel.startsWith('gate'); + return ( + + + + + + ); + })} + +
OutputuSEQ channelGate ≥
{p.name} + + + + setParam(i, { + cv: { ...c, gateThreshold: Math.max(0, Math.min(1, num(e.target.value, c.gateThreshold))) }, + }) + } + /> +
+
+
+ ); +} + function SynthGroupNote({ params }: { params: MFParam[] }) { const groups = Array.from(new Set(params.map((p) => p.group))); return ( diff --git a/manifold/src/dock/OutputsBackendConfig.tsx b/manifold/src/dock/OutputsBackendConfig.tsx index 1bc0943..63cde6a 100644 --- a/manifold/src/dock/OutputsBackendConfig.tsx +++ b/manifold/src/dock/OutputsBackendConfig.tsx @@ -17,8 +17,8 @@ */ import { useEffect, useState } from 'react'; import type { ConsoleCtx } from '../console/types'; -import type { BackendId } from './output-state'; -import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state'; +import type { BackendId, CvChannelId } from './output-state'; +import { CV_CHANNELS, defaultCvSpec, defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state'; import { applyPreset, deletePreset, @@ -583,6 +583,89 @@ function VcvConfig({ ctx }: { ctx: ConsoleCtx }) { ); } +// ---- uSEQ CV / gate config (backends-spec §2.5; docs/useq-celium) ----------- + +function CvConfig({ ctx }: { ctx: ConsoleCtx }) { + const s = ctx.backendStatus; + const statusColor = + s.state === 'ready' ? 'var(--good)' : s.state === 'connecting' ? 'var(--warn)' : 'var(--danger)'; + return ( + <> + uSEQ device (USB serial) +
+ + + + {s.message} +
+

+ Flash the uSEQ main + expander with firmware/useq-celium, 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. +

+ + Per-output channel · gate threshold +
+ + + + + + + + + + {ctx.params.map((p, i) => { + const c = (p.cv as { channel: CvChannelId; gateThreshold: number } | undefined) ?? defaultCvSpec(i); + const isGate = c.channel.startsWith('gate'); + return ( + + + + + + ); + })} + +
OutputuSEQ channelGate ≥
{p.name} + + + + ctx.setParam(i, { + cv: { ...c, gateThreshold: Math.max(0, Math.min(1, num(e.target.value, c.gateThreshold))) }, + }) + } + /> +
+
+ + ); +} + // ---- Public entry ---------------------------------------------------------- export interface OutputsBackendConfigProps { @@ -592,14 +675,22 @@ export interface OutputsBackendConfigProps { /** The specialised, editable per-backend config + preset bar for the Outputs panel. */ export function OutputsBackendConfig({ ctx, backend }: OutputsBackendConfigProps) { - // MIDI / OSC / VCV carry a config + preset surface here; synth/particle/editor + // MIDI / OSC / VCV / CV carry a config + preset surface here; synth/particle/editor // config is rendered by ModeConfig in Drawers. The full-depth BackendAdvanced // modal reuses the same per-channel sections. - if (backend !== 'midi' && backend !== 'osc' && backend !== 'vcv') return null; + if (backend !== 'midi' && backend !== 'osc' && backend !== 'vcv' && backend !== 'cvgate') return null; return (
- {backend === 'midi' ? : backend === 'osc' ? : } + {backend === 'midi' ? ( + + ) : backend === 'osc' ? ( + + ) : backend === 'vcv' ? ( + + ) : ( + + )}
); } diff --git a/manifold/src/dock/output-state.ts b/manifold/src/dock/output-state.ts index b240a97..74d66ed 100644 --- a/manifold/src/dock/output-state.ts +++ b/manifold/src/dock/output-state.ts @@ -41,7 +41,7 @@ export const BACKENDS: readonly BackendDescriptor[] = [ { id: 'synth', label: 'Powerful Synth Engine', description: 'Firmware-parity built-in audio engine.' }, { id: 'midi', label: 'MIDI', description: 'Web MIDI CC out — per-output CC#/channel.' }, { id: 'osc', label: 'OSC', description: 'OSC bridge — named paths + physical ranges.' }, - { id: 'cvgate', label: 'CV', description: 'CV / gate (via VCV bridge or DC-coupled audio).' }, + { id: 'cvgate', label: 'CV', description: 'uSEQ CV/gate over USB serial — 11 CV + 3 gate.' }, { id: 'vcv', label: 'VCV', description: 'VCV Rack module — 16 CV outs with LED rings.' }, { id: 'particles', label: 'Particle', description: 'Flow-field visualiser (no audio).' }, ] as const; @@ -68,6 +68,49 @@ export interface VcvSpec { bipolar: boolean; // unipolar 0..10V vs bipolar ±5V } +/** + * uSEQ CV/gate backend per-output extras. Each model output is assigned to one + * physical uSEQ channel (or 'none'); gate channels threshold the mapped 0..1 + * value. The fixed hardware topology is 11 CV + 3 gate — see + * docs/useq-celium/protocol.md. + */ +export type CvChannelId = + | 'none' + | 'cv1' | 'cv2' | 'cv3' | 'cv4' | 'cv5' | 'cv6' + | 'cv7' | 'cv8' | 'cv9' | 'cv10' | 'cv11' + | 'gate1' | 'gate2' | 'gate3'; + +export interface CvChannelDesc { + id: CvChannelId; + label: string; + kind: 'cv' | 'gate'; +} + +/** The fixed uSEQ channel roster (UI dropdown order). */ +export const CV_CHANNELS: readonly CvChannelDesc[] = [ + { id: 'cv1', label: 'CV1 · main A1', kind: 'cv' }, + { id: 'cv2', label: 'CV2 · main A2', kind: 'cv' }, + { id: 'cv3', label: 'CV3 · main A3', kind: 'cv' }, + { id: 'cv4', label: 'CV4 · exp E1', kind: 'cv' }, + { id: 'cv5', label: 'CV5 · exp E2', kind: 'cv' }, + { id: 'cv6', label: 'CV6 · exp E3', kind: 'cv' }, + { id: 'cv7', label: 'CV7 · exp E4', kind: 'cv' }, + { id: 'cv8', label: 'CV8 · exp E5', kind: 'cv' }, + { id: 'cv9', label: 'CV9 · exp E6', kind: 'cv' }, + { id: 'cv10', label: 'CV10 · exp E7', kind: 'cv' }, + { id: 'cv11', label: 'CV11 · exp E8', kind: 'cv' }, + { id: 'gate1', label: 'Gate1 · main D1', kind: 'gate' }, + { id: 'gate2', label: 'Gate2 · main D2', kind: 'gate' }, + { id: 'gate3', label: 'Gate3 · main D3', kind: 'gate' }, +] as const; + +export interface CvSpec { + /** Physical uSEQ channel this output drives, or 'none' to skip it. */ + channel: CvChannelId; + /** For gate channels: gate goes HIGH when the mapped 0..1 value ≥ this. */ + gateThreshold: number; +} + /** * The full per-output control. This is the spec's `OutputControl` (dock-spec * §3.2). It is represented on `MFParam` for the shared store; this interface @@ -135,3 +178,12 @@ export function defaultOscSpec(name: string): OscSpec { export function defaultVcvSpec(): VcvSpec { return { bipolar: false }; } + +/** + * Default uSEQ CV channel for output `index` — identity map: outputs 0..10 → + * CV1..CV11, outputs 11..13 → GATE1..3, the rest unassigned ('none'). + */ +export function defaultCvSpec(index: number): CvSpec { + const ch = CV_CHANNELS[index]?.id ?? 'none'; + return { channel: ch, gateThreshold: 0.5 }; +} diff --git a/manifold/src/serial/web-serial.d.ts b/manifold/src/serial/web-serial.d.ts index 0dc419b..c8dbbf7 100644 --- a/manifold/src/serial/web-serial.d.ts +++ b/manifold/src/serial/web-serial.d.ts @@ -20,6 +20,8 @@ interface SerialPort { close(): Promise; readonly readable: ReadableStream | null; readonly writable: WritableStream | null; + addEventListener?(type: 'connect' | 'disconnect', listener: () => void): void; + removeEventListener?(type: 'connect' | 'disconnect', listener: () => void): void; } interface SerialPortRequestOptions { diff --git a/manifold/tsconfig.json b/manifold/tsconfig.json index 6094815..71af7e1 100644 --- a/manifold/tsconfig.json +++ b/manifold/tsconfig.json @@ -26,5 +26,5 @@ "noEmit": true }, "include": ["src/**/*", "vite.config.ts"], - "exclude": ["node_modules", "dist", "tests/e2e"] + "exclude": ["node_modules", "dist", "tests/e2e", "src/**/*.test.ts"] }