58 lines
5.1 KiB
Markdown
58 lines
5.1 KiB
Markdown
# @suaveplan/control-surface-schema — universal control-surface JSON schema
|
|
|
|
- **Target path**: `packages/media/audio/control-surface-schema/`
|
|
- **Tier**: universal (zero React/3D/audio coupling — pure data + Zod)
|
|
- **Date**: 2026-05-23
|
|
- **Status**: PROPOSED
|
|
|
|
## Why
|
|
|
|
Every downstream control-surface renderer — 2D React (`@suaveplan/react-control-surface`), 3D R3F (`@suaveplan/r3f-control-surface`), VR/XR runtimes, future native ports — needs to read the same surface definition: nested groups of atomic controls bound to plugin parameters / MIDI CCs / DMX channels, with persistable layout and skin information.
|
|
|
|
If each renderer owns its own format, they diverge. The same surface saved by a 2D editor can't be opened by the 3D editor. Cross-platform replication (the live-DJ-in-gaming-session scenario) becomes impossible.
|
|
|
|
This package owns the canonical schema. It is the source of truth for surface persistence, the validation layer at every save/load boundary, and the shared vocabulary every renderer + editor + replication layer agrees on.
|
|
|
|
## What changes
|
|
|
|
1. **NEW package** `@suaveplan/control-surface-schema` at `packages/media/audio/control-surface-schema/`.
|
|
2. **`ControlSurface`** root document type:
|
|
- `id: string` (uuid), `name`, `version` (semver of the schema), `createdAt`, `updatedAt`
|
|
- `skin: { id: string; vars?: Record<string, string> }` — references a skin package + optional CSS-var overrides
|
|
- `root: ControlNode` — nested group tree
|
|
3. **`ControlNode`** discriminated union:
|
|
- `Group` — `{ type: "group"; id; label?; layout: GroupLayout; transform: Transform; children: ControlNode[]; collapsible?: boolean }`
|
|
- `Control` — `{ type: "control"; id; kind: ControlKind; label?; transform; appearance?; binding?; range?; behaviour? }`
|
|
4. **`ControlKind`** enum — `knob`, `slider-v`, `slider-h`, `button-momentary`, `button-latching`, `switch-2`, `switch-3`, `led`, `digital-display`, `meter-vu`, `meter-peak`, `meter-spectral`, `xy-pad`, `jog-wheel`, `piano-key`, `drum-pad`, `text-label`, `image`.
|
|
5. **`Transform`** — `{ position: { x; y; z? }; rotation?: { x?; y?; z? }; scale?: { x?; y?; z? } }`. `z` and rotation/scale fields are optional so 2D-only surfaces stay clean.
|
|
6. **`GroupLayout`** discriminated union — `Grid` (`{ kind: "grid"; columns; rows; gap }`), `Absolute` (`{ kind: "absolute" }`), `Stack` (`{ kind: "stack"; direction: "row" | "column"; gap }`), `Free3D` (`{ kind: "free3d" }`).
|
|
7. **`Binding`** discriminated union:
|
|
- `PluginParameter` — `{ kind: "pluginParam"; pluginId; paramPath: string; curve?: "linear" | "log" | "exp" | { kind: "custom"; points: ReadonlyArray<{ at: number; value: number }> } }`
|
|
- `TransportControl` — `{ kind: "transport"; field: "play" | "stop" | "tempo" | "position" | "loop" }`
|
|
- `MidiOut` — `{ kind: "midiOut"; portId; channel; cc: number; range?: [number, number] }`
|
|
- `MidiIn` — `{ kind: "midiIn"; portId; channel; cc: number; range?: [number, number] }` (control reflects external MIDI input)
|
|
- `DmxChannel` — `{ kind: "dmx"; universe; channel; range?: [0, 255] }`
|
|
- `Compound` — `{ kind: "compound"; bindings: ReadonlyArray<Binding> }` (one control drives many)
|
|
8. **`Range`** — `{ min: number; max: number; step?: number; default?: number; units?: string }`.
|
|
9. **`Behaviour`** — `{ momentaryDelayMs?; latchSticky?; doubleTapAction?: Action; longPressAction?: Action; …}`.
|
|
10. **`Action`** discriminated union — `{ kind: "resetToDefault" } | { kind: "setTo"; value: number } | { kind: "toggleGroupCollapse"; targetId } | { kind: "emitEvent"; eventId; payload? }`.
|
|
11. **`Appearance`** — `{ skinKey?: string; renderHint?: "auto" | "2d" | "3d"; cssVars?: Record<string, string>; tooltip?: string; icon?: { kind: "url" | "svg" | "skinAsset"; value: string } }`.
|
|
12. **`validateSurface(unknown): { surface: ControlSurface } | { error: SchemaError }`** — Zod-driven safe parse, returns typed error union.
|
|
13. **`upgradeSurface(unknown): ControlSurface`** — accepts schema version N-1 documents and migrates forward via registered upgraders.
|
|
14. **`walkSurface(surface, visitor): void`** — DFS traversal helper that yields every node with its parent stack.
|
|
15. **Error codes** — `CONTROL_SURFACE_SCHEMA.{INVALID_DOCUMENT, UNSUPPORTED_VERSION, UPGRADE_FAILED, DUPLICATE_ID, BINDING_REFERENCE_MISSING}` via `defineCodes`.
|
|
16. **Schema versioning** — bump on every breaking change; `upgradeSurface` chains migrations.
|
|
|
|
## Impact
|
|
|
|
- New universal package. Zero runtime deps except `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`.
|
|
- Unblocks renderers (`react-control-surface`, `r3f-control-surface`), editors, persistence layers, network replication.
|
|
- Schema versioning means `0.1.0 → 0.2.0` requires registering an upgrader before consumers can read old documents.
|
|
|
|
## Out of scope
|
|
|
|
- Renderers — separate packages, no React/R3F imports here.
|
|
- Skin packages — separate per-device families (`control-surface-skin-{native-instruments, mackie, piano, …}`).
|
|
- Editor UI — separate (`react-control-surface-editor`).
|
|
- Replication / persistence transports — separate (`network-control-surface`).
|