/**
* Console — the FIVE real dock drawers (operator dock restructure). Each renderer
* takes (ctx, depth); what shows is gated by depth (condensed | expanded):
*
* learn — Learning : feedback-mode selector, solo/arm chooser, live params
* inputs — Inputs : input source
* route — Outputs : per-output control matrix for the ACTIVE mode/backend,
* a Mode-specific config section, and (Editor mode) the
* MEMLNaut serial panel. The old separate "Synth" and
* "Particle/Visual" drawers are REMOVED — their config now
* lives here under the active Mode (TOP dock selector).
* settings — Settings : icon style, input-map shape + feature flags
* help — Help : keymap + the loop explanation
*
* The TOP dock selector ("Mode") chooses the active OUTPUT backend/target; this
* drawer renders whatever that backend needs.
*
* Engine wiring: the feedback-mode pill → controller.setMode; the arm flags →
* engine.feedback.setFocus; per-output rows write the shared MFParam store.
* Where engine support does not yet exist the UI + state are wired and a TODO
* references the relevant spec — no faked engine behaviour.
*/
import { useState, type ReactNode } from 'react';
import { Badge, Button, PillToggle, Slider, Switch } from '../primitives';
import type { ConsoleCtx, DrawerDepth, DrawerKey, FeedbackModeUI, SoloMode } from './types';
import type { InputMode } from '../inputs';
import { OutputControlRow } from '../dock/OutputControlRow';
import { OutputsBackendConfig, BackendStatusChip } from '../dock/OutputsBackendConfig';
import { shapeValues, type MFParam } from './model';
import { outputModeDescriptor } from './output-mode';
import { useSettings, unfocusedIconCss } from '../settings/settings-store';
import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store';
import type { ExampleResizePolicy, NetworkResizePolicy } from '../engine/io-reshape';
import { useEngine, useEngineVersion } from '../engine';
import { EditorPanel } from '../serial/EditorPanel';
import { TrainingHealth } from './TrainingHealth';
import {
LearningIcon,
InputsIcon,
OutputsIcon,
SettingsIcon,
HelpIcon,
} from './icons';
function Chip({ children, tone }: { children: ReactNode; tone?: string }) {
return (
{children}
);
}
function SectionLabel({ children }: { children: ReactNode }) {
return (
{children}
);
}
/** A 2+ segment selector pill that drives a typed value. */
function Segmented({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
{options.map((o) => {
const on = value === o.value;
return (
);
})}
);
}
// ===========================================================================
// 1. LEARNING-BEHAVIOUR (dock-spec §1; rl-feedback-design)
// ===========================================================================
const FEEDBACK_OPTS: { value: FeedbackModeUI; label: string }[] = [
{ value: 'geometric-dislike', label: 'Push away' },
{ value: 'explore-and-place', label: 'Explore & place' },
];
const FEEDBACK_DESC: Record = {
'geometric-dislike':
'Down carves the current sound away from what you like — directed repulsion (Mode 1).',
'explore-and-place':
'Down re-rolls the whole net into a scratchpad you audition; + places a liked sound (Mode 2).',
};
/**
* Solo behaviour. `mask-gradients` is the only variant the core actually
* implements — `soloMode` has no other observable effect today (the other two
* options need the C API's `train_masked` step). Rendered as a fixed,
* non-selectable label rather than a picker that offers dead choices
* (simplification audit L20).
*/
const SOLO_OPTS: { value: SoloMode; label: string }[] = [
{ value: 'mask-gradients', label: 'Mask gradients' },
{ value: 'zero-loss', label: 'Zero loss' },
{ value: 'dont-care', label: "Don't-care mask" },
];
const SOLO_DESC: Record = {
'mask-gradients': 'Column-freeze (default) — only the armed output moves; the rest stay bit-identical.',
'zero-loss': 'Expressive, but armed and unarmed outputs share hidden weights, so others can drift.',
'dont-care': 'Each example stores a per-output mask so stale labels never pull unarmed outputs.',
};
function ModelArchitecture() {
const engine = useEngine();
useEngineVersion(engine);
const [detailsOpen, setDetailsOpen] = useState(false);
if (!engine) {
return
Down action · feedback mode
{depth === 'expanded' && (
{FEEDBACK_DESC[ctx.feedbackMode]}
)}
Exploration
hold to morph the whole net live · release to freeze
{depth === 'expanded' && (
Explore adds a slow random walk (Ornstein-Uhlenbeck) on the outputs so the sound roams;
likes and dislikes registered mid-wander steer the net toward what you want. 0 = off. Jolt
(firmware TogB1) and Explore (RVX1) drive the same core gestures as the MEMLNaut hardware.
)}
Recorded examples
{ctx.datasetCount} example{ctx.datasetCount === 1 ? '' : 's'}
forget every example & wipe the on-map marks
Current model
{depth === 'expanded' && (
<>
Solo / arm scope
{ctx.armedCount
? `${ctx.armedCount} armed — arm with the S button on each output row`
: 'every live output learns'}
Solo behaviour{SOLO_OPTS.find((o) => o.value === ctx.soloMode)?.label}
{SOLO_DESC[ctx.soloMode]} Solo only freezes the rest as far as a shared network allows.
The other two behaviours need real core support (`train_masked`) and aren't selectable yet.
Live training params
>
)}
{depth === 'expanded' && (
<>
Feedback lab
State machine: idle → exploring → commit / cancel. While Explore & place is exploring,
training is paused and the joystick auditions a random scratchpad net; + commits a placed
anchor and restores the real net.
{ctx.xavierSpreadEnabled && (
)}
Training health
>
)}
>
);
}
// ===========================================================================
// 2. INPUTS (workstream F; inputs-spec — modular input layer)
// ===========================================================================
const STATUS_TONE: Record = {
ready: 'var(--accent)',
connecting: 'var(--warn)',
unavailable: 'var(--fg-dim)',
error: 'var(--danger)',
idle: 'var(--fg-dim)',
};
const INPUT_MODE_OPTS: { value: InputMode; label: string }[] = [
{ value: 'internal', label: 'Internal' },
{ value: 'gamepad', label: 'Game Controller' },
{ value: 'midi', label: 'MIDI' },
];
/** Standard-mapping gamepad button → verdict legend (mirrors ConsoleApp). */
const GAMEPAD_LEGEND: { btn: string; action: string }[] = [
{ btn: 'RB', action: 'Up · positive feedback' },
{ btn: 'LB', action: 'Down · negative feedback' },
{ btn: 'X', action: 'Randomise' },
{ btn: 'Y', action: 'Nudge' },
{ btn: 'B', action: 'Undo' },
{ btn: 'A (hold)', action: 'Reposition — hold, move stick, release to place' },
];
/**
* Read-only INPUT meter — shows a learned MIDI control's live value. Deliberately
* styled apart from the output Sliders (which are orange, interactive thumbs):
* these are inset bars on the secondary accent with an "in" tag, so the user can
* see at a glance that these feed the net rather than being driven by it.
*/
function MidiInputMeter({ label, value, onClear }: { label: string; value: number; onClear: () => void }) {
const pct = Math.max(0, Math.min(1, value));
return (
{label}
{value.toFixed(2)}
);
}
function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const inp = ctx.inputs;
// The net is runtime-shaped (P2): each active axis drives its OWN input slot
// 1:1. A mismatch means the net is over-provisioned (axes < slots, extra slots
// zero-padded) or over capacity (axes > slots, extras dropped) — the reshape
// offer resolves either.
const inputMismatch = inp.axisCount > 0 && inp.axisCount !== inp.engineInputSize;
const active = inp.sources.find((s) => s.enabled);
const modeLabel = INPUT_MODE_OPTS.find((o) => o.value === inp.inputMode)?.label ?? 'Internal';
return (
<>
The net has {inp.engineInputSize} dedicated inputs — each active axis drives its own
dimension 1:1 (no blending).
{inputMismatch
? ` Its ${inp.engineInputSize} slots don't match the ${inp.axisCount} active axes; ` +
`changing the layout offers a reshape to ${inp.axisCount} inputs (warm-started).`
: ''}
)}
>
);
}
// ===========================================================================
// 3. OUTPUTS / ROUTING (dock-spec §3, §4)
// ===========================================================================
const VISUAL_NAMES = [
'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius',
'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse',
'RepCnt', 'RepRate',
];
/**
* Per-Mode config section shown ABOVE the per-output rows. The synth Mode shows
* transport + tempo; the particle Mode names the outputs; MIDI/OSC/Editor show
* their own affordances. Replaces the removed Synth + Visual drawers.
*/
function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
switch (ctx.outputMode) {
case 'synth':
return (
<>
Transport
audio starts on the play gesture
{depth === 'expanded' && (
The active engine follows the selected mode ({ctx.mode.label}).
{/* TODO(dock-spec §5): arpeggiator + tiered synth presets + the
18-section group-override matrix are workstream E. Master
volume + bpm sliders were deleted (simplification audit
S16) — they drove no engine parameter. */}
)}
>
);
case 'editor':
return (
<>
MEMLNaut · USB serial
>
);
case 'particles':
return depth === 'expanded' ? (
Flow-field visualiser driven by the first {Math.min(20, ctx.params.length)} outputs (no audio).
{/* TODO(backends-spec §4): port FlowFieldVisualizer + visual preset chips. */}
) : null;
case 'midi':
// The full MIDI config (port picker, CC count, per-output CC/channel/name)
// + preset bar render via OutputsBackendConfig in RoutingDrawer below.
return depth === 'expanded' ? (
Each output sends a real Web MIDI CC. Pick a port and set CC# / channel per output.
set('unfocusedIconColour', v as UnfocusedIconColour)}
options={ICON_COLOUR_OPTS}
/>
Focused / active icons are always accent orange. This sets the resting colour of unfocused
icons (preview below).
>
)}
Input map set('inputMap', v as InputMapMode)}
options={INPUT_MAP_OPTS}
/>
{depth === 'expanded' && (
The 2D input surface: a rectangular XY map or a circular joystick-style disc. "Follow mode"
uses the active mode's declared input (joystick → circular, else rectangular).
)}
I/O editing
Network size
set('networkResizePolicy', v as NetworkResizePolicy)}
options={NETWORK_RESIZE_OPTS}
ariaLabel="Network resize policy"
/>
Existing examples
set('exampleResizePolicy', v as ExampleResizePolicy)}
options={EXAMPLE_RESIZE_OPTS}
ariaLabel="Example resize policy"
/>
{depth === 'expanded' && (
<>
Keep capacity edits mappings in place and reconstructs only when the active cards outgrow
the network. Exact I/O keeps network arity equal to the cards. Surviving dimensions retain
their identity and weights; exploration scratch state resets after an I/O edit.
Off by default: new networks and re-rolls use full-range uniform weights for broad,
strongly varied mappings. Enable this to restore the legacy centred regime and its
Learning-drawer switch.
Drag the manifold to explore. Hear something good → + to keep it. Wrong → − to push away or
re-roll (set the behaviour in the Learning drawer). Went too far → undo. The dock reveals
exactly as much machinery as you reach for.