memlnaut-nisps/manifold/src/dock/output-state.ts
monkey-w1n5t0n 4656568d4f feat(manifold,firmware): restore uSEQ CV/gate as an Outputs backend
Restores the April-2026 "uSEQ-Celium" functionality (browser → uSEQ
hardware + CV expander over USB Web Serial) as a first-class Manifold
Outputs backend, and re-vendors the RP2040 firmware into the repo.

- protocol v2 (uSEQ-CV): firmware/useq-celium/shared/protocol.h is the
  single source of truth, mirrored by manifold/src/backends/useq-protocol.ts.
  26-byte OUTPUT frame, 11×u16 CV (12-bit) + 3-gate bitfield + XOR; fixed
  topology; host-agnostic so the MEMLNaut RP2350 can emit identical bytes.
  Spec in docs/useq-celium/protocol.md.
- firmware/useq-celium/{main,expander}: PlatformIO RP2040 firmware rewritten
  to v2 from the real April pin maps (expander I2C addr 0x10).
- UseqCvBackend (id cvgate): Web Serial connect/identify/disconnect, 100 Hz
  stream, per-channel dead-zone, gate thresholding; modeled on midi-backend.
  Per-output CvSpec (channel + gateThreshold) on MFParam; config UI in
  OutputsBackendConfig + BackendAdvanced; new "CV / uSEQ" top-dock mode.
- bun-test for the protocol frame layout; MAP.md updated.
2026-06-28 22:30:54 +02:00

189 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 010 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 };
}