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