refactor(manifold): move MIDI controls onto output cards

This commit is contained in:
monkey-w1n5t0n 2026-07-25 15:34:06 +02:00
parent 5ed84cbf00
commit 5ab4d18523
5 changed files with 127 additions and 86 deletions

6
MAP.md
View file

@ -59,8 +59,8 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
consts (mode_id, engine_id, ml dims, params, voice_spaces, ui) — the SOURCE OF TRUTH for `MF_MODES`. consts (mode_id, engine_id, ml dims, params, voice_spaces, ui) — the SOURCE OF TRUTH for `MF_MODES`.
Switching mode reshapes the WASM net to the mode's `ml` dims (ConsoleApp P5.3; boot mode paf_synth → Switching mode reshapes the WASM net to the mode's `ml` dims (ConsoleApp P5.3; boot mode paf_synth →
4→[10,10,14]→33). 4→[10,10,14]→33).
- `manifold/src/dock/``OutputControlRow` (add/delete card identity + off/fixed/live + mute + solo/arm + min/max/curve), `output-state.ts`, - `manifold/src/dock/``OutputControlRow` (add/delete card identity + off/fixed/live + mute + solo/arm + min/max/curve, plus MIDI card fields), `output-state.ts`,
`OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor). `OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; the centered add-card control lives below the rows).
- `manifold/src/backends/``OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts` - `manifold/src/backends/``OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts`
(WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `vcv-backend.ts` (VCV-over-WS), `cv-backend.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 (`UseqCvBackend` — uSEQ CV/gate over USB Web Serial, backend id `cvgate`) + `useq-protocol.ts` (v2 wire
@ -68,7 +68,7 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
`particle-backend.ts`, `passthrough-backend.ts`, `presets.ts` (named presets), `manager.ts`. `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 - `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 `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. config (`dock/OutputsBackendConfig.tsx`) reads it for the device picker + param-select that fills the per-card MIDI fields.
- `manifold/src/inputs/` — modular INPUT layer feeding the ML head. The Inputs dock picks ONE exclusive mode - `manifold/src/inputs/` — modular INPUT layer feeding the ML head. The Inputs dock picks ONE exclusive mode
(`InputMode` = `internal` | `gamepad` | `midi`; Internal/XY-pad is default). `input-layer.ts` owns a single rAF (`InputMode` = `internal` | `gamepad` | `midi`; Internal/XY-pad is default). `input-layer.ts` owns a single rAF
loop composing the active source's axes → **one dedicated engine input slot per axis, 1:1, no blending** → one loop composing the active source's axes → **one dedicated engine input slot per axis, 1:1, no blending** → one

View file

@ -166,9 +166,10 @@ for the narrow pane.
curve/fixedValue + backend specs `MidiCcSpec`/`OscSpec`/`VcvSpec`), `toOutputControl()`, curve/fixedValue + backend specs `MidiCcSpec`/`OscSpec`/`VcvSpec`), `toOutputControl()`,
`buildArmMask()` (solo focus). `buildArmMask()` (solo focus).
- `OutputControlRow.tsx` — one output row: name · M(mute) · S(solo/arm) · off|fixed|live · dual-range - `OutputControlRow.tsx` — one output row: name · M(mute) · S(solo/arm) · off|fixed|live · dual-range
· curve pad · live value. **Writes eagerly to the shared `MFParam` store via `onChange`.** · curve pad · live value; MIDI mode adds the card's name, CC#, and channel fields. **Writes eagerly
- `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + per-backend config (MIDI to the shared `MFParam` store via `onChange`.** The centered `+` control follows the last visible card.
CC#/channel, OSC path/range, VCV polarity). - `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + transport/device config (MIDI
port/templates, OSC path/range, VCV polarity); MIDI per-output fields live on `OutputControlRow`.
### Primitives — `src/primitives/` (barrel: `index.ts`) ### Primitives — `src/primitives/` (barrel: `index.ts`)
Seven: `Button`, `Slider`, `PillToggle`, `Badge`, `Switch`, `XYPad`, `VirtualJoystick`. Dumb, Seven: `Button`, `Slider`, `PillToggle`, `Badge`, `Switch`, `XYPad`, `VirtualJoystick`. Dumb,

View file

@ -757,9 +757,6 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
<Chip>off {counts.off || 0}</Chip> <Chip>off {counts.off || 0}</Chip>
<Chip tone="var(--danger)">muted {mutedN}</Chip> <Chip tone="var(--danger)">muted {mutedN}</Chip>
<BackendStatusChip ctx={ctx} /> <BackendStatusChip ctx={ctx} />
<Button size="sm" variant="ghost" onClick={ctx.addOutput}>
+ output
</Button>
</div> </div>
{ModeConfig(ctx, depth)} {ModeConfig(ctx, depth)}
{/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden when condensed. */} {/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden when condensed. */}
@ -787,9 +784,32 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
onChange={(patch) => ctx.setParam(i, patch)} onChange={(patch) => ctx.setParam(i, patch)}
onDelete={activeParams.length > 1 ? () => ctx.deleteOutput(i) : undefined} onDelete={activeParams.length > 1 ? () => ctx.deleteOutput(i) : undefined}
showCurve={expanded} showCurve={expanded}
showMidi={ctx.outputMode === 'midi'}
/> />
); );
})} })}
<button
type="button"
aria-label="+ output"
title="Add parameter"
onClick={ctx.addOutput}
style={{
alignSelf: 'center',
width: 30,
height: 26,
margin: '4px 0 2px',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
background: 'var(--bg-2)',
color: 'var(--accent)',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: 18,
lineHeight: 1,
}}
>
+
</button>
</div> </div>
{!expanded && activeParams.length > 6 && ( {!expanded && activeParams.length > 6 && (
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{activeParams.length - 6} more expand to edit</span> <span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{activeParams.length - 6} more expand to edit</span>

View file

@ -12,6 +12,7 @@ import { GROUP_COLOR } from '../console/model';
import type { MFParam, ParamStatus } from '../console/model'; import type { MFParam, ParamStatus } from '../console/model';
import { CurvePad } from '../console/CurvePad'; import { CurvePad } from '../console/CurvePad';
import { DualRange } from '../console/DualRange'; import { DualRange } from '../console/DualRange';
import { defaultMidiSpec } from './output-state';
const STATE_META: { v: ParamStatus; label: string; color: string }[] = [ const STATE_META: { v: ParamStatus; label: string; color: string }[] = [
{ v: 'off', label: 'off', color: 'var(--fg-dim)' }, { v: 'off', label: 'off', color: 'var(--fg-dim)' },
@ -64,6 +65,8 @@ export interface OutputControlRowProps {
onDelete?: () => void; onDelete?: () => void;
/** Show the curve pad inline (expand depth); hidden in compact rows. */ /** Show the curve pad inline (expand depth); hidden in compact rows. */
showCurve?: boolean; showCurve?: boolean;
/** Show the per-card MIDI name, CC, and channel controls. */
showMidi?: boolean;
} }
export function OutputControlRow({ export function OutputControlRow({
@ -72,12 +75,14 @@ export function OutputControlRow({
onChange, onChange,
onDelete, onDelete,
showCurve = false, showCurve = false,
showMidi = false,
}: OutputControlRowProps) { }: OutputControlRowProps) {
const gc = `var(${GROUP_COLOR[param.group] || '--accent'})`; const gc = `var(${GROUP_COLOR[param.group] || '--accent'})`;
const muted = param.muted ?? false; const muted = param.muted ?? false;
const armed = param.armed ?? false; const armed = param.armed ?? false;
const off = param.status === 'off'; const off = param.status === 'off';
const barVal = param.status === 'fixed' ? param.val : value; const barVal = param.status === 'fixed' ? param.val : value;
const midi = param.midi ?? defaultMidiSpec(param.engineIndex ?? 0);
return ( return (
<div <div
style={{ style={{
@ -93,6 +98,25 @@ export function OutputControlRow({
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{showMidi ? (
<input
aria-label={`MIDI name for ${param.name}`}
value={midi.name}
onChange={(e) => onChange({ midi: { ...midi, name: e.target.value } })}
style={{
flex: 1,
minWidth: 0,
width: 0,
border: 0,
borderBottom: '1px solid var(--line)',
background: 'transparent',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
padding: '1px 0',
}}
/>
) : (
<span <span
style={{ style={{
flex: 1, flex: 1,
@ -106,6 +130,7 @@ export function OutputControlRow({
> >
{param.name} {param.name}
</span> </span>
)}
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>{param.group}</span> <span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>{param.group}</span>
{onDelete && ( {onDelete && (
<button <button
@ -145,6 +170,59 @@ export function OutputControlRow({
/> />
</div> </div>
{showMidi && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, color: 'var(--fg-mute)' }}>
CC
<input
aria-label={`MIDI CC for ${param.name}`}
type="number"
min={0}
max={127}
value={midi.cc}
onChange={(e) =>
onChange({ midi: { ...midi, cc: Math.max(0, Math.min(127, Number(e.target.value) || 0)) } })
}
style={{
width: 54,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
fontSize: 10,
padding: '2px 4px',
}}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, color: 'var(--fg-mute)' }}>
ch
<input
aria-label={`MIDI channel for ${param.name}`}
type="number"
min={1}
max={16}
value={midi.channel}
onChange={(e) =>
onChange({
midi: { ...midi, channel: Math.max(1, Math.min(16, Number(e.target.value) || 1)) },
})
}
style={{
width: 44,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
fontSize: 10,
padding: '2px 4px',
}}
/>
</label>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* tri-state segmented */} {/* tri-state segmented */}
<div style={{ display: 'flex', gap: 1, flex: '0 0 auto' }}> <div style={{ display: 'flex', gap: 1, flex: '0 0 auto' }}>

View file

@ -4,7 +4,8 @@
* *
* Layout: ONE preset bar (save-as / restore / rename / delete, per-backend * Layout: ONE preset bar (save-as / restore / rename / delete, per-backend
* namespace) on top, then a per-backend config section: * namespace) on top, then a per-backend config section:
* - MIDI output-port picker, number-of-CCs, per-output CC#/channel/name. * - MIDI output-port picker, number-of-CCs, and device templates; each
* output card owns its CC#/channel/name fields.
* - OSC bridge URL + connect status + send-raw toggle, per-output path/range. * - OSC bridge URL + connect status + send-raw toggle, per-output path/range.
* - VCV/CV bridge URL + connect status + send-raw toggle, per-output polarity * - VCV/CV bridge URL + connect status + send-raw toggle, per-output polarity
* (uni 010 V / bipolar ±5 V). The browser drives + trains the VCV * (uni 010 V / bipolar ±5 V). The browser drives + trains the VCV
@ -20,7 +21,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ConsoleCtx } from '../console/types'; import type { ConsoleCtx } from '../console/types';
import type { BackendId, CvChannelId } from './output-state'; import type { BackendId, CvChannelId } from './output-state';
import { CV_CHANNELS, defaultCvSpec, defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state'; import { CV_CHANNELS, defaultCvSpec, defaultOscSpec, defaultVcvSpec } from './output-state';
import { import {
applyPreset, applyPreset,
deletePreset, deletePreset,
@ -212,8 +213,8 @@ function PresetBar({ ctx, backend }: { ctx: ConsoleCtx; backend: BackendId }) {
/** /**
* Pick an external synth (e.g. Moog Sub 37), see its parameters BY NAME, tick the * Pick an external synth (e.g. Moog Sub 37), see its parameters BY NAME, tick the
* ones to control, and "Apply" this fills the per-output CC table (CC#, channel, * ones to control, and "Apply" this fills the per-output card fields (CC#,
* name) from the device template and sets the CC count. Pure local state + the * channel, name) from the device template and sets the CC count. Pure local state + the
* existing setParam/setMidiCcCount; the applied result lives in the shared params * existing setParam/setMidiCcCount; the applied result lives in the shared params
* store, so it is saved/restored by the named-preset bar like any other config. * store, so it is saved/restored by the named-preset bar like any other config.
*/ */
@ -355,72 +356,13 @@ function MidiConfig({ ctx }: { ctx: ConsoleCtx }) {
))} ))}
</select> </select>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}> <span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>
{ctx.midiCcCount} CC output{ctx.midiCcCount === 1 ? '' : 's'} · add/delete cards below {ctx.midiCcCount} MIDI output card{ctx.midiCcCount === 1 ? '' : 's'}
</span> </span>
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span> <span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
</div> </div>
<DeviceTemplatePicker ctx={ctx} /> <DeviceTemplatePicker ctx={ctx} />
<SectionLabel>Per-output CC · name · channel</SectionLabel>
<div style={{ maxHeight: 320, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<Th>Name</Th>
<Th>CC#</Th>
<Th>Ch</Th>
<Th>State</Th>
</tr>
</thead>
<tbody>
{ctx.params.slice(0, ctx.midiCcCount).map((p, i) => {
const m = p.midi ?? defaultMidiSpec(i);
return (
<tr key={i}>
<td style={{ padding: '3px 6px' }}>
<input
style={cellInput}
value={m.name}
onChange={(e) => ctx.setParam(i, { midi: { ...m, name: e.target.value } })}
/>
</td>
<td style={{ padding: '3px 6px', width: 70 }}>
<input
type="number"
min={0}
max={127}
style={cellInput}
value={m.cc}
onChange={(e) =>
ctx.setParam(i, { midi: { ...m, cc: Math.max(0, Math.min(127, num(e.target.value, m.cc))) } })
}
/>
</td>
<td style={{ padding: '3px 6px', width: 60 }}>
<input
type="number"
min={1}
max={16}
style={cellInput}
value={m.channel}
onChange={(e) =>
ctx.setParam(i, {
midi: { ...m, channel: Math.max(1, Math.min(16, num(e.target.value, m.channel))) },
})
}
/>
</td>
<td style={{ padding: '3px 6px', fontSize: 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
{p.status}
{p.muted ? ' · muted' : ''}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</> </>
); );
} }