feat(manifold): improve output controls and particle input

This commit is contained in:
monkey-w1n5t0n 2026-07-25 15:45:40 +02:00
parent b5891a6ab9
commit 7e308a4e62
7 changed files with 266 additions and 46 deletions

5
MAP.md
View file

@ -47,7 +47,8 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
- `manifold/src/primitives/` — the 7 design primitives as typed React (Badge, Button, PillToggle, Slider, Switch, VirtualJoystick, XYPad). Five unused ones were deleted in the 2026-07 sweep (L22). - `manifold/src/primitives/` — the 7 design primitives as typed React (Badge, Button, PillToggle, Slider, Switch, VirtualJoystick, XYPad). Five unused ones were deleted in the 2026-07 sweep (L22).
- `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible - `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible
with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas, with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas,
rect↔circular + feedback markers; ParticleStage has interactive cursor-labelled heatmap sliders), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers` rect↔circular + feedback markers; ParticleStage has interactive cursor-labelled heatmap sliders, an adjustable
joystick, and double-click whole-screen follow-mouse input), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers`
(Learning/Inputs/Outputs/Settings/Help; Learning includes the live model-architecture inspector and (Learning/Inputs/Outputs/Settings/Help; Learning includes the live model-architecture inspector and
expanded Outputs owns the remaining scroll height), `TrainingHealth` (real per-iteration loss curve from expanded Outputs owns the remaining scroll height), `TrainingHealth` (real per-iteration loss curve from
`nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at `nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at
@ -60,7 +61,7 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
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` (editable names, corner delete, cycling off/fixed/live status, mute + solo/arm + min/max/curve, plus MIDI card fields), `output-state.ts`, - `manifold/src/dock/``OutputControlRow` (editable names, corner delete, cycling off/fixed/live status, 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; the centered add-card control lives below the rows). `OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; centered prepend/append add-card controls live above and 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

View file

@ -109,7 +109,7 @@ Manifold ships a single "composite" altitude. Selection is now a plain three-way
|---|---|---|---| |---|---|---|---|
| CompositeStage | `CompositeStage.tsx` | **default / hero** | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. | | CompositeStage | `CompositeStage.tsx` | **default / hero** | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. |
| SandwichStage | `SandwichStage.tsx` | `sandwich===true` (wins over the others) | Three-pane layout: `Manifold` input surface left, 3D parameter-landscape centre (input → MLP heatmap grid → outputs, drag to orbit), compact `OutputStage` right. | | SandwichStage | `SandwichStage.tsx` | `sandwich===true` (wins over the others) | Three-pane layout: `Manifold` input surface left, 3D parameter-landscape centre (input → MLP heatmap grid → outputs, drag to orbit), compact `OutputStage` right. |
| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + interactive output heatmap sliders with cursor tooltips + corner joystick. | | ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + interactive output heatmap sliders with cursor tooltips + a larger, explicitly adjustable/repositionable joystick; double-click anywhere in the stage enters whole-screen follow-mouse mode. |
`Manifold.tsx` and `OutputStage.tsx` are no longer top-level stages — they are panes composed by `Manifold.tsx` and `OutputStage.tsx` are no longer top-level stages — they are panes composed by
CompositeStage/SandwichStage. `Manifold.tsx` is the full-bleed 2D input surface (canvas trail + pins CompositeStage/SandwichStage. `Manifold.tsx` is the full-bleed 2D input surface (canvas trail + pins
@ -157,8 +157,9 @@ for the narrow pane.
surface there rather than inventing one. The panel renders "no training run yet" when the surface there rather than inventing one. The panel renders "no training run yet" when the
core has no history; it never synthesises a curve. core has no history; it never synthesises a curve.
- **inputs** — enable/configure input sources (XY pad / MIDI / gamepad). - **inputs** — enable/configure input sources (XY pad / MIDI / gamepad).
- **route** (label "Outputs") — per-output control matrix + per-backend config; at expanded depth - **route** (label "Outputs") — per-output control matrix + per-backend config; centered prepend/append `+`
the output rows fill the remaining drawer height and scroll independently. controls are available in both condensed and expanded views, and at expanded depth the output rows fill the
remaining drawer height and scroll independently. The orange drawer tab uses `<` to expand and `>` to condense.
- **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius. - **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
- **help** — keymap pills + loop explanation. - **help** — keymap pills + loop explanation.
- `src/dock/` holds the output-routing internals used by the `route` drawer: - `src/dock/` holds the output-routing internals used by the `route` drawer:
@ -168,7 +169,8 @@ for the narrow pane.
- `OutputControlRow.tsx` — one output card: editable name · M(mute) · S(solo/arm) · cycling - `OutputControlRow.tsx` — one output card: editable name · M(mute) · S(solo/arm) · cycling
off/fixed/live status · dual-range · live value; the curve pad is a right-hand column and MIDI mode off/fixed/live status · dual-range · live value; the curve pad is a right-hand column and MIDI mode
adds CC#/channel fields. The delete `×` sits on the upper-right corner. **Writes eagerly to the shared adds CC#/channel fields. The delete `×` sits on the upper-right corner. **Writes eagerly to the shared
`MFParam` store via `onChange`.** The centered `+` control follows the last visible card. `MFParam` store via `onChange`.** The Outputs drawer has centered `+` controls that prepend before the first
visible card or append after the last.
- `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + transport/device config (MIDI - `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`. port/templates, OSC path/range, VCV polarity); MIDI per-output fields live on `OutputControlRow`.

View file

@ -737,13 +737,11 @@ export function ConsoleApp() {
setOutputCounts((counts) => ({ ...counts, [outputMode]: activeCards.length })); setOutputCounts((counts) => ({ ...counts, [outputMode]: activeCards.length }));
}; };
const addOutput = () => { const addOutput = (placement: 'prepend' | 'append' = 'append') => {
const active = params.slice(0, displayOutputCount); const active = params.slice(0, displayOutputCount);
const next = params[displayOutputCount] ?? createOutputParam(params.length); const next = params[displayOutputCount] ?? createOutputParam(params.length);
const spares = params const spares = params.filter((param) => param.id !== next.id && !active.some((p) => p.id === param.id));
.slice(displayOutputCount + (params[displayOutputCount] ? 1 : 0)) applyOutputCards(placement === 'prepend' ? [next, ...active] : [...active, next], spares);
.filter((param) => param.id !== next.id);
applyOutputCards([...active, next], spares);
}; };
const deleteOutput = (index: number) => { const deleteOutput = (index: number) => {

View file

@ -31,7 +31,6 @@ import {
EditorIcon, EditorIcon,
SandwichIcon, SandwichIcon,
CloseIcon, CloseIcon,
ExpandIcon,
GLYPH_FALLBACK, GLYPH_FALLBACK,
} from './icons'; } from './icons';
import type { IconProps } from './icons'; import type { IconProps } from './icons';
@ -309,14 +308,14 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
top: '50%', top: '50%',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
width: 22, width: 22,
height: 56, height: 40,
borderRadius: 'var(--r-2) 0 0 var(--r-2)', borderRadius: 'var(--r-2) 0 0 var(--r-2)',
border: '1px solid var(--glass-line)', border: '1px solid var(--glass-line)',
borderRight: 'none', borderRight: 'none',
background: 'var(--glass)', background: 'var(--glass)',
backdropFilter: 'blur(14px)', backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
color: 'var(--fg-mute)', color: 'var(--accent)',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -324,7 +323,9 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
zIndex: 1, zIndex: 1,
}} }}
> >
<ExpandIcon size={12} /> <span aria-hidden="true" style={{ fontSize: 22, lineHeight: 1, fontFamily: 'var(--font-mono)' }}>
{expanded ? '>' : '<'}
</span>
</button> </button>
{/* Inner scroll wrapper — holds header + content, scrolls independently. */} {/* Inner scroll wrapper — holds header + content, scrolls independently. */}
<div <div

View file

@ -747,6 +747,30 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const expanded = depth === 'expanded'; const expanded = depth === 'expanded';
const rows = expanded ? activeParams : activeParams.slice(0, 6); const rows = expanded ? activeParams : activeParams.slice(0, 6);
const addButton = (placement: 'prepend' | 'append') => (
<button
type="button"
aria-label={placement === 'append' ? '+ output' : '+ output (prepend)'}
title={placement === 'append' ? 'Add parameter after the last card' : 'Add parameter before the first card'}
onClick={() => ctx.addOutput(placement)}
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>
);
return ( return (
<> <>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
@ -774,6 +798,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
overflow: 'auto', overflow: 'auto',
}} }}
> >
{addButton('prepend')}
{rows.map((p) => { {rows.map((p) => {
const i = ctx.params.indexOf(p); const i = ctx.params.indexOf(p);
const labelled = { ...p, name: nameFor(p) }; const labelled = { ...p, name: nameFor(p) };
@ -789,28 +814,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
/> />
); );
})} })}
<button {addButton('append')}
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

@ -9,8 +9,9 @@
* value the same `.heatmap-strip` the a-immersive app shows above the * value the same `.heatmap-strip` the a-immersive app shows above the
* flow field (NOT the Boldness/Memory/Precision macro axes, which the * flow field (NOT the Boldness/Memory/Precision macro axes, which the
* deployed a-immersive never used); * deployed a-immersive never used);
* a small circular pad in the bottom-left corner that drives the 2D input * a large circular pad that drives the 2D input (engine.setInput) the
* (engine.setInput) the "joystick" of the immersive app. * "joystick" of the immersive app; its explicit edit handles reposition
* and resize it without making normal input gestures destructive.
* *
* The canvas animates on its own rAF clock so particles keep flowing between * The canvas animates on its own rAF clock so particles keep flowing between
* inferences; only the *field* parameters change when the MLP outputs do. The * inferences; only the *field* parameters change when the MLP outputs do. The
@ -18,7 +19,7 @@
* churn React state every frame. * churn React state every frame.
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react'; import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from 'react';
import { useEngine } from '../engine'; import { useEngine } from '../engine';
import { VirtualJoystick } from '../primitives/VirtualJoystick'; import { VirtualJoystick } from '../primitives/VirtualJoystick';
import type { MFParam } from './model'; import type { MFParam } from './model';
@ -49,6 +50,22 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 }); const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 });
const [clampMarker, setClampMarker] = useState<{ i: number; value: number } | null>(null); const [clampMarker, setClampMarker] = useState<{ i: number; value: number } | null>(null);
const [followMouse, setFollowMouse] = useState(false);
const onMoveRef = useRef(onMove);
const [padSize, setPadSize] = useState(200);
const [padPosition, setPadPosition] = useState(() => ({
left: 24,
top: typeof window === 'undefined' ? 24 : Math.max(24, window.innerHeight - 224),
}));
const [padEdit, setPadEdit] = useState(false);
const padGesture = useRef<{
kind: 'move' | 'resize';
startX: number;
startY: number;
left: number;
top: number;
size: number;
} | null>(null);
const clampTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const clampTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const drag = useRef<{ i: number; moved: boolean; startX: number; el: HTMLDivElement | null }>({ const drag = useRef<{ i: number; moved: boolean; startX: number; el: HTMLDivElement | null }>({
i: -1, i: -1,
@ -57,6 +74,49 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
el: null, el: null,
}); });
valuesRef.current = values; valuesRef.current = values;
onMoveRef.current = onMove;
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
const beginPadGesture = (e: ReactPointerEvent<HTMLElement>, kind: 'move' | 'resize') => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture?.(e.pointerId);
padGesture.current = {
kind,
startX: e.clientX,
startY: e.clientY,
left: padPosition.left,
top: padPosition.top,
size: padSize,
};
};
const movePadGesture = (e: ReactPointerEvent<HTMLElement>) => {
const gesture = padGesture.current;
if (!gesture) return;
e.preventDefault();
e.stopPropagation();
if (gesture.kind === 'move') {
setPadPosition({
left: clamp(gesture.left + e.clientX - gesture.startX, 8, Math.max(8, window.innerWidth - padSize - 56)),
top: clamp(gesture.top + e.clientY - gesture.startY, 8, Math.max(8, window.innerHeight - padSize - 8)),
});
} else {
const maxSize = Math.max(140, Math.min(window.innerWidth - gesture.left - 56, window.innerHeight - gesture.top - 8, 360));
setPadSize(clamp(gesture.size + e.clientX - gesture.startX, 140, maxSize));
}
};
const endPadGesture = (e: ReactPointerEvent<HTMLElement>) => {
e.stopPropagation();
padGesture.current = null;
};
const toggleFollowMouse = (e?: ReactMouseEvent<HTMLDivElement>) => {
e?.preventDefault();
setFollowMouse((active) => !active);
};
const updateHover = (i: number, e: ReactPointerEvent<HTMLDivElement>) => { const updateHover = (i: number, e: ReactPointerEvent<HTMLDivElement>) => {
hoverRef.current = i; hoverRef.current = i;
@ -168,8 +228,35 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
}; };
}, []); }, []);
useEffect(() => {
if (!followMouse) return;
const onWinMove = (e: PointerEvent) => {
const w = window.innerWidth || 1;
const h = window.innerHeight || 1;
onMoveRef.current(clamp(e.clientX / w, 0, 1), clamp(1 - e.clientY / h, 0, 1));
};
const onWinKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFollowMouse(false);
};
window.addEventListener('pointermove', onWinMove);
window.addEventListener('keydown', onWinKey);
return () => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('keydown', onWinKey);
};
}, [followMouse]);
return ( return (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0d0d0d' }}> <div
onDoubleClick={toggleFollowMouse}
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
background: '#0d0d0d',
cursor: followMouse ? 'none' : 'default',
}}
>
{/* Main view — the flow-field particle system */} {/* Main view — the flow-field particle system */}
<canvas <canvas
ref={canvasRef} ref={canvasRef}
@ -227,7 +314,7 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
height: 16, height: 16,
background: 'rgba(255,255,255,0.04)', background: 'rgba(255,255,255,0.04)',
overflow: 'hidden', overflow: 'hidden',
cursor: 'ew-resize', cursor: followMouse ? 'none' : 'ew-resize',
touchAction: 'none', touchAction: 'none',
}} }}
> >
@ -285,9 +372,136 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
}} }}
/> />
{/* Bottom-left circular pad — drives the 2D input */} {followMouse && (
<div style={{ position: 'absolute', left: 18, bottom: 18, zIndex: 20 }}> <div
<VirtualJoystick size={120} position={pos} onMove={onMove} ariaLabel="particle input pad" /> style={{
position: 'absolute',
top: 30,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 26,
padding: '4px 10px',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-pill)',
background: 'rgba(0,0,0,0.78)',
color: 'var(--accent)',
fontFamily: 'var(--font-mono)',
fontSize: 10,
pointerEvents: 'none',
}}
>
FOLLOW MOUSE · ESC OR DOUBLE-CLICK TO EXIT
</div>
)}
{/* Adjustable circular pad — explicit edit handles keep repositioning/resizing deliberate. */}
<div
style={{
position: 'absolute',
left: padPosition.left,
top: padPosition.top,
width: padSize,
height: padSize,
zIndex: 20,
pointerEvents: 'none',
}}
>
<VirtualJoystick
size={padSize}
position={pos}
onMove={onMove}
disabled={padEdit || followMouse}
ariaLabel="particle input pad"
style={{ pointerEvents: padEdit || followMouse ? 'none' : 'auto' }}
/>
<button
type="button"
aria-label={padEdit ? 'Finish adjusting joystick' : 'Adjust joystick size and position'}
title={padEdit ? 'Finish adjusting joystick' : 'Adjust joystick size and position'}
onClick={(e) => {
e.stopPropagation();
setPadEdit((editing) => !editing);
}}
style={{
position: 'absolute',
top: 6,
right: 6,
zIndex: 3,
width: 24,
height: 24,
border: '1px solid var(--line-strong)',
borderRadius: 'var(--r-1)',
background: 'rgba(0,0,0,0.72)',
color: 'var(--accent)',
cursor: 'pointer',
pointerEvents: 'auto',
fontFamily: 'var(--font-mono)',
fontSize: 14,
lineHeight: 1,
}}
>
{padEdit ? '✓' : '⋮'}
</button>
{padEdit && (
<>
<button
type="button"
aria-label="Move joystick"
title="Drag to move joystick"
onPointerDown={(e) => beginPadGesture(e, 'move')}
onPointerMove={movePadGesture}
onPointerUp={endPadGesture}
onPointerCancel={endPadGesture}
style={{
position: 'absolute',
top: 6,
left: 6,
zIndex: 3,
width: 24,
height: 24,
border: '1px solid var(--accent)',
borderRadius: 'var(--r-1)',
background: 'rgba(0,0,0,0.72)',
color: 'var(--accent)',
cursor: 'move',
pointerEvents: 'auto',
fontFamily: 'var(--font-mono)',
fontSize: 13,
lineHeight: 1,
}}
>
</button>
<button
type="button"
aria-label="Resize joystick"
title="Drag to resize joystick"
onPointerDown={(e) => beginPadGesture(e, 'resize')}
onPointerMove={movePadGesture}
onPointerUp={endPadGesture}
onPointerCancel={endPadGesture}
style={{
position: 'absolute',
right: 4,
bottom: 4,
zIndex: 3,
width: 28,
height: 28,
border: '1px solid var(--accent)',
borderRadius: 'var(--r-1)',
background: 'rgba(0,0,0,0.72)',
color: 'var(--accent)',
cursor: 'nwse-resize',
pointerEvents: 'auto',
fontFamily: 'var(--font-mono)',
fontSize: 13,
lineHeight: 1,
}}
>
</button>
</>
)}
</div> </div>
</div> </div>
); );

View file

@ -76,7 +76,7 @@ export interface ConsoleCtx {
params: MFParam[]; params: MFParam[];
/** Patch one output row in the shared store (drives stage + dock in sync). */ /** Patch one output row in the shared store (drives stage + dock in sync). */
setParam: (i: number, patch: Partial<MFParam>) => void; setParam: (i: number, patch: Partial<MFParam>) => void;
addOutput: () => void; addOutput: (placement?: 'prepend' | 'append') => void;
deleteOutput: (i: number) => void; deleteOutput: (i: number) => void;
/** Active backend outputs currently presented by the stage + routing rows. */ /** Active backend outputs currently presented by the stage + routing rows. */
displayOutputCount: number; displayOutputCount: number;