Restores the April-2026 "uSEQ-Celium" functionality (browser → uSEQ
hardware + CV expander over USB Web Serial) as a first-class Manifold
Outputs backend, and re-vendors the RP2040 firmware into the repo.
- protocol v2 (uSEQ-CV): firmware/useq-celium/shared/protocol.h is the
single source of truth, mirrored by manifold/src/backends/useq-protocol.ts.
26-byte OUTPUT frame, 11×u16 CV (12-bit) + 3-gate bitfield + XOR; fixed
topology; host-agnostic so the MEMLNaut RP2350 can emit identical bytes.
Spec in docs/useq-celium/protocol.md.
- firmware/useq-celium/{main,expander}: PlatformIO RP2040 firmware rewritten
to v2 from the real April pin maps (expander I2C addr 0x10).
- UseqCvBackend (id cvgate): Web Serial connect/identify/disconnect, 100 Hz
stream, per-channel dead-zone, gate thresholding; modeled on midi-backend.
Per-output CvSpec (channel + gateThreshold) on MFParam; config UI in
OutputsBackendConfig + BackendAdvanced; new "CV / uSEQ" top-dock mode.
- bun-test for the protocol frame layout; MAP.md updated.
189 lines
7.1 KiB
TypeScript
189 lines
7.1 KiB
TypeScript
/**
|
||
* The per-output control model for the Outputs / Routing dock (workstream D,
|
||
* docs/redesign/dock-spec.md §3.2).
|
||
*
|
||
* DELIBERATE DIVERGENCE from the deployed a-immersive app (dock-spec §3.3 note,
|
||
* open choice 3): the deployed override system conflates "frozen" (heatmap
|
||
* popup) and "muted" (group drawer) onto ONE underlying field. This model splits
|
||
* the three orthogonal concepts into distinct fields:
|
||
*
|
||
* - `state` : 'off' | 'fixed' | 'live' — the model-control tri-state.
|
||
* - `muted` : boolean — downstream silence (still computed + visible).
|
||
* - `armed` : boolean — solo / focus-training (=arm).
|
||
*
|
||
* They compose freely (e.g. an output can be `off` AND `muted` AND `armed`).
|
||
* Recorded in ALIGNMENT.md.
|
||
*
|
||
* To keep the dock tri-state and the existing OutputStage / ReadoutStrip
|
||
* tri-state in sync WITHOUT a second data path, this model is folded onto the
|
||
* existing `MFParam` (model.ts) — `MFParam.status` carries `state`, and the new
|
||
* `muted` / `armed` / backend fields live alongside it. ConsoleApp owns the
|
||
* single `MFParam[]` store; the dock and the stage both read/write it.
|
||
*/
|
||
|
||
import type { MFParam, ParamStatus } from '../console/model';
|
||
|
||
/** The model-control tri-state (alias of the console ParamStatus). */
|
||
export type OutputState = ParamStatus; // 'off' | 'fixed' | 'live'
|
||
|
||
/** The selectable output backend (dock-spec §3.4; backends-spec §1). */
|
||
export type BackendId = 'synth' | 'particles' | 'midi' | 'osc' | 'cvgate' | 'vcv';
|
||
|
||
export interface BackendDescriptor {
|
||
id: BackendId;
|
||
/** Dock label — NEVER "C15" (backends-spec naming guard). */
|
||
label: string;
|
||
description: string;
|
||
}
|
||
|
||
/** The backend roster surfaced in the dock's backend selector. */
|
||
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: '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;
|
||
|
||
// ---- Backend-specific per-output specs (dock-spec §4) ----------------------
|
||
|
||
/** MIDI CC backend per-output extras (dock-spec §4.1). */
|
||
export interface MidiCcSpec {
|
||
cc: number; // 0..127
|
||
channel: number; // 1..16
|
||
name: string;
|
||
value: number; // last sent, round(v*127)
|
||
}
|
||
|
||
/** OSC backend per-output extras (dock-spec §4.2). */
|
||
export interface OscSpec {
|
||
path: string; // e.g. "/synth/cutoff"
|
||
rangeMin: number; // physical (engineering) units, NOT [0,1]
|
||
rangeMax: number;
|
||
}
|
||
|
||
/** VCV backend per-output extras (dock-spec §4.3) — baseline min/max IS the range. */
|
||
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
|
||
* documents the complete contract and is what {@link toOutputControl} yields.
|
||
*/
|
||
export interface OutputControl {
|
||
index: number;
|
||
name: string;
|
||
group: string;
|
||
state: OutputState; // off | fixed | live
|
||
muted: boolean; // downstream silence; still computed
|
||
armed: boolean; // solo / focus-training (=arm)
|
||
min: number; // [0,1]
|
||
max: number; // [0,1], min<=max
|
||
curve: number; // [0,1], 0.5 linear
|
||
fixedValue: number; // held value when state==='fixed'
|
||
// backend-specific, populated by the active backend adapter:
|
||
midi?: MidiCcSpec;
|
||
osc?: OscSpec;
|
||
vcv?: VcvSpec;
|
||
}
|
||
|
||
/** Project an MFParam (the shared store row) into the full OutputControl view. */
|
||
export function toOutputControl(p: MFParam, index: number): OutputControl {
|
||
return {
|
||
index,
|
||
name: p.name,
|
||
group: p.group,
|
||
state: p.status,
|
||
muted: p.muted ?? false,
|
||
armed: p.armed ?? false,
|
||
min: p.min,
|
||
max: p.max,
|
||
curve: p.curve,
|
||
fixedValue: p.val,
|
||
midi: p.midi,
|
||
osc: p.osc,
|
||
vcv: p.vcv,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build the focus / solo mask from the per-row armed flags (dock-spec §1.2).
|
||
* Returns null when nothing is armed (⇒ all outputs active / no focus).
|
||
*/
|
||
export function buildArmMask(params: MFParam[]): Uint8Array | null {
|
||
const anyArmed = params.some((p) => p.armed);
|
||
if (!anyArmed) return null;
|
||
const mask = new Uint8Array(params.length);
|
||
for (let i = 0; i < params.length; i++) mask[i] = params[i].armed ? 1 : 0;
|
||
return mask;
|
||
}
|
||
|
||
/** Default MIDI CC spec for a freshly-added output, auto-named by index. */
|
||
export function defaultMidiSpec(index: number): MidiCcSpec {
|
||
return { cc: index % 128, channel: 1, name: `CC ${index % 128}`, value: 0 };
|
||
}
|
||
|
||
/** Default OSC spec for an output. */
|
||
export function defaultOscSpec(name: string): OscSpec {
|
||
return { path: `/nisps/${name.toLowerCase()}`, rangeMin: 0, rangeMax: 1 };
|
||
}
|
||
|
||
/** Default VCV spec for an output — unipolar 0–10 V by default. */
|
||
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 };
|
||
}
|