/**
* OutputsBackendConfig — the editable, per-backend specialisation of the Outputs
* panel (backends-spec §4) plus the named-preset bar (§5).
*
* Layout: ONE preset bar (save-as / restore / rename / delete, per-backend
* namespace) on top, then a per-backend config section:
* - MIDI → output-port picker, number-of-CCs, per-output CC#/channel/name.
* - OSC → bridge URL + connect status + send-raw toggle, per-output path/range.
* - VCV/CV→ bridge URL + connect status + send-raw toggle, per-output polarity
* (uni 0–10 V / bipolar ±5 V). The browser drives + trains the VCV
* module over the same Deno bridge.
* - Synth/Particle/Editor → handled by ModeConfig in Drawers (no extra config here).
*
* Everything is editable inline; writes go through the shared MFParam store
* (ctx.setParam) — never a second data path. The full-depth modal reuses the
* same sections via BackendAdvanced.
*/
import { useEffect, useState } from 'react';
import type { ConsoleCtx } from '../console/types';
import type { BackendId } from './output-state';
import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state';
import {
applyPreset,
deletePreset,
getPreset,
listPresets,
renamePreset,
savePreset,
type OutputPreset,
} from '../backends/presets';
function num(s: string, fallback: number): number {
const v = parseFloat(s);
return Number.isFinite(v) ? v : fallback;
}
const cellInput: React.CSSProperties = {
width: '100%',
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
padding: '3px 6px',
boxSizing: 'border-box',
};
const btn = (color: string): React.CSSProperties => ({
fontSize: 'var(--fs-xs)',
fontFamily: 'var(--font-mono)',
padding: '3px 9px',
cursor: 'pointer',
borderRadius: 'var(--r-pill)',
border: `1px solid ${color}`,
background: 'transparent',
color,
});
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
// ---- Named-preset bar (backends-spec §5) -----------------------------------
function PresetBar({ ctx, backend }: { ctx: ConsoleCtx; backend: BackendId }) {
const [presets, setPresets] = useState([]);
const [name, setName] = useState('');
const [selected, setSelected] = useState('');
const refresh = () => setPresets(listPresets(backend));
useEffect(() => {
refresh();
setSelected('');
setName('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [backend]);
const backendSettings = (): Record => {
if (backend === 'midi') return { outputId: ctx.midiOutputId, ccCount: ctx.midiCcCount };
if (backend === 'osc') return { url: ctx.oscUrl, sendRaw: ctx.oscSendRaw };
if (backend === 'vcv') return { url: ctx.vcvUrl, sendRaw: ctx.vcvSendRaw };
return {};
};
const applySettings = (s?: Record) => {
if (!s) return;
if (backend === 'midi') {
if ('outputId' in s) ctx.setMidiOutputId((s.outputId as string | null) ?? null);
if ('ccCount' in s) ctx.setMidiCcCount(Number(s.ccCount) || ctx.midiCcCount);
} else if (backend === 'osc') {
if ('url' in s) ctx.setOscUrl(String(s.url));
if ('sendRaw' in s) ctx.setOscSendRaw(Boolean(s.sendRaw));
} else if (backend === 'vcv') {
if ('url' in s) ctx.setVcvUrl(String(s.url));
if ('sendRaw' in s) ctx.setVcvSendRaw(Boolean(s.sendRaw));
}
};
const doSave = () => {
const n = name.trim();
if (!n) return;
savePreset(backend, n, ctx.params, backendSettings());
refresh();
setSelected(n);
};
const doRestore = (n: string) => {
const p = getPreset(backend, n);
if (!p) return;
ctx.setParams(applyPreset(ctx.params, p));
applySettings(p.settings);
};
const doDelete = () => {
if (!selected) return;
deletePreset(backend, selected);
refresh();
setSelected('');
};
const doRename = () => {
const to = name.trim();
if (!selected || !to) return;
if (renamePreset(backend, selected, to)) {
refresh();
setSelected(to);
}
};
return (
Presets · {backend}
setName(e.target.value)}
/>
Save as
{
setSelected(e.target.value);
if (e.target.value) doRestore(e.target.value);
}}
style={{ ...cellInput, width: 'auto', flex: '0 0 auto', cursor: 'pointer' }}
>
restore…
{presets.map((p) => (
{p.name}
))}
Rename
Delete
);
}
// ---- MIDI config (backends-spec §2.3 / §4.1) -------------------------------
function MidiConfig({ ctx }: { ctx: ConsoleCtx }) {
const s = ctx.backendStatus;
const statusColor =
s.state === 'ready' ? 'var(--good)' : s.state === 'error' || s.state === 'unavailable' ? 'var(--danger)' : 'var(--warn)';
return (
<>
MIDI output
ctx.setMidiOutputId(e.target.value || null)}
onFocus={ctx.refreshMidiPorts}
style={{ ...cellInput, width: 'auto', cursor: 'pointer' }}
>
— pick output port —
{ctx.midiPorts.map((p) => (
{p.name}
))}
CCs
ctx.setMidiCcCount(Math.max(1, Math.min(ctx.params.length, num(e.target.value, ctx.midiCcCount))))
}
style={{ ...cellInput, width: 60 }}
/>
{s.message}
Per-output CC · name · channel
Name
CC#
Ch
State
{ctx.params.slice(0, ctx.midiCcCount).map((p, i) => {
const m = p.midi ?? defaultMidiSpec(i);
return (
ctx.setParam(i, { midi: { ...m, name: e.target.value } })}
/>
ctx.setParam(i, { midi: { ...m, cc: Math.max(0, Math.min(127, num(e.target.value, m.cc))) } })
}
/>
ctx.setParam(i, {
midi: { ...m, channel: Math.max(1, Math.min(16, num(e.target.value, m.channel))) },
})
}
/>
{p.status}
{p.muted ? ' · muted' : ''}
);
})}
>
);
}
// ---- OSC config (backends-spec §2.4 / §4.2) --------------------------------
function OscConfig({ ctx }: { ctx: ConsoleCtx }) {
const s = ctx.backendStatus;
const statusColor = s.state === 'ready' ? 'var(--good)' : s.state === 'connecting' ? 'var(--warn)' : 'var(--danger)';
const [draftUrl, setDraftUrl] = useState(ctx.oscUrl);
useEffect(() => setDraftUrl(ctx.oscUrl), [ctx.oscUrl]);
return (
<>
OSC bridge
setDraftUrl(e.target.value)}
onBlur={() => ctx.setOscUrl(draftUrl)}
placeholder="ws://localhost:8765"
/>
ctx.setOscUrl(draftUrl)}>
Connect
ctx.setOscSendRaw(e.target.checked)} />
send raw 0..1
{s.message}
The Deno OSC bridge process must be running locally (see manifold/osc-bridge). The browser sends over
WebSocket; the bridge encodes OSC and forwards over UDP.
Per-output address · physical range
>
);
}
// ---- VCV / CV config (backends-spec §2.6 / §4.3) ---------------------------
function VcvConfig({ ctx }: { ctx: ConsoleCtx }) {
const s = ctx.backendStatus;
const statusColor = s.state === 'ready' ? 'var(--good)' : s.state === 'connecting' ? 'var(--warn)' : 'var(--danger)';
const [draftUrl, setDraftUrl] = useState(ctx.vcvUrl);
useEffect(() => setDraftUrl(ctx.vcvUrl), [ctx.vcvUrl]);
return (
<>
VCV bridge
setDraftUrl(e.target.value)}
onBlur={() => ctx.setVcvUrl(draftUrl)}
placeholder="ws://localhost:8765"
/>
ctx.setVcvUrl(draftUrl)}>
Connect
ctx.setVcvSendRaw(e.target.checked)} />
send raw 0..1
{s.message}
The VCV Rack NISPS module AND the Deno OSC bridge (manifold/osc-bridge) must both be running. The browser drives
the module's inputs and forwards the verdict loop (thumbs up/down, explore-and-place) over the bridge, so the
module's embedded net trains in lock-step. Default module UDP port 7001.
Per-output polarity
output
range
{ctx.params.map((p, i) => {
const v = p.vcv ?? defaultVcvSpec();
return (
{p.name}
ctx.setParam(i, { vcv: { bipolar: !v.bipolar } })}
style={{
...btn(v.bipolar ? 'var(--danger)' : 'var(--fg-mute)'),
width: '100%',
}}
>
{v.bipolar ? '±5 V' : '0–10 V'}
);
})}
>
);
}
// ---- Public entry ----------------------------------------------------------
export interface OutputsBackendConfigProps {
ctx: ConsoleCtx;
backend: BackendId;
}
/** The specialised, editable per-backend config + preset bar for the Outputs panel. */
export function OutputsBackendConfig({ ctx, backend }: OutputsBackendConfigProps) {
// MIDI / OSC / VCV carry a config + preset surface here; synth/particle/editor
// config is rendered by ModeConfig in Drawers. The full-depth BackendAdvanced
// modal reuses the same per-channel sections.
if (backend !== 'midi' && backend !== 'osc' && backend !== 'vcv') return null;
return (
{backend === 'midi' ?
: backend === 'osc' ?
:
}
);
}
export { PresetBar as OutputPresetBar };
/** Tiny status pill for the Outputs drawer header. */
export function BackendStatusChip({ ctx }: { ctx: ConsoleCtx }) {
const s = ctx.backendStatus;
if (s.state === 'idle') return null;
const color =
s.state === 'ready'
? 'var(--good)'
: s.state === 'error' || s.state === 'unavailable'
? 'var(--danger)'
: 'var(--warn)';
return (
{s.message}
);
}