508 lines
18 KiB
TypeScript
508 lines
18 KiB
TypeScript
/**
|
||
* 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 (
|
||
<div
|
||
style={{
|
||
fontSize: 10,
|
||
color: 'var(--fg-dim)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.1em',
|
||
marginTop: 'var(--sp-2)',
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Th({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<th
|
||
style={{
|
||
textAlign: 'left',
|
||
fontSize: 9,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.06em',
|
||
color: 'var(--fg-dim)',
|
||
padding: '4px 6px',
|
||
borderBottom: '1px solid var(--line)',
|
||
}}
|
||
>
|
||
{children}
|
||
</th>
|
||
);
|
||
}
|
||
|
||
// ---- Named-preset bar (backends-spec §5) -----------------------------------
|
||
|
||
function PresetBar({ ctx, backend }: { ctx: ConsoleCtx; backend: BackendId }) {
|
||
const [presets, setPresets] = useState<OutputPreset[]>([]);
|
||
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<string, unknown> => {
|
||
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<string, unknown>) => {
|
||
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 (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 6,
|
||
alignItems: 'center',
|
||
padding: '6px 8px',
|
||
background: 'var(--bg-2)',
|
||
border: '1px solid var(--line)',
|
||
borderRadius: 'var(--r-1)',
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 9, color: 'var(--fg-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||
Presets · {backend}
|
||
</span>
|
||
<input
|
||
style={{ ...cellInput, width: 120, flex: '0 0 auto' }}
|
||
placeholder="preset name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
<button type="button" style={btn('var(--accent)')} onClick={doSave} disabled={!name.trim()}>
|
||
Save as
|
||
</button>
|
||
<select
|
||
value={selected}
|
||
onChange={(e) => {
|
||
setSelected(e.target.value);
|
||
if (e.target.value) doRestore(e.target.value);
|
||
}}
|
||
style={{ ...cellInput, width: 'auto', flex: '0 0 auto', cursor: 'pointer' }}
|
||
>
|
||
<option value="">restore…</option>
|
||
{presets.map((p) => (
|
||
<option key={p.name} value={p.name}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button type="button" style={btn('var(--fg-mute)')} onClick={doRename} disabled={!selected || !name.trim()}>
|
||
Rename
|
||
</button>
|
||
<button type="button" style={btn('var(--danger)')} onClick={doDelete} disabled={!selected}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- 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 (
|
||
<>
|
||
<SectionLabel>MIDI output</SectionLabel>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<select
|
||
value={ctx.midiOutputId ?? ''}
|
||
onChange={(e) => ctx.setMidiOutputId(e.target.value || null)}
|
||
onFocus={ctx.refreshMidiPorts}
|
||
style={{ ...cellInput, width: 'auto', cursor: 'pointer' }}
|
||
>
|
||
<option value="">— pick output port —</option>
|
||
{ctx.midiPorts.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>
|
||
CCs
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={ctx.params.length}
|
||
value={ctx.midiCcCount}
|
||
onChange={(e) =>
|
||
ctx.setMidiCcCount(Math.max(1, Math.min(ctx.params.length, num(e.target.value, ctx.midiCcCount))))
|
||
}
|
||
style={{ ...cellInput, width: 60 }}
|
||
/>
|
||
</label>
|
||
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
|
||
</div>
|
||
|
||
<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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---- 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 (
|
||
<>
|
||
<SectionLabel>OSC bridge</SectionLabel>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<input
|
||
style={{ ...cellInput, width: 200 }}
|
||
value={draftUrl}
|
||
onChange={(e) => setDraftUrl(e.target.value)}
|
||
onBlur={() => ctx.setOscUrl(draftUrl)}
|
||
placeholder="ws://localhost:8765"
|
||
/>
|
||
<button type="button" style={btn('var(--accent)')} onClick={() => ctx.setOscUrl(draftUrl)}>
|
||
Connect
|
||
</button>
|
||
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>
|
||
<input type="checkbox" checked={ctx.oscSendRaw} onChange={(e) => ctx.setOscSendRaw(e.target.checked)} />
|
||
send raw 0..1
|
||
</label>
|
||
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
|
||
</div>
|
||
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||
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.
|
||
</p>
|
||
|
||
<SectionLabel>Per-output address · physical range</SectionLabel>
|
||
<div style={{ maxHeight: 320, overflow: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr>
|
||
<Th>OSC path</Th>
|
||
<Th>range min</Th>
|
||
<Th>range max</Th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ctx.params.map((p, i) => {
|
||
const o = p.osc ?? defaultOscSpec(p.name);
|
||
return (
|
||
<tr key={i}>
|
||
<td style={{ padding: '3px 6px' }}>
|
||
<input
|
||
style={cellInput}
|
||
value={o.path}
|
||
onChange={(e) => ctx.setParam(i, { osc: { ...o, path: e.target.value } })}
|
||
/>
|
||
</td>
|
||
<td style={{ padding: '3px 6px', width: 90 }}>
|
||
<input
|
||
type="number"
|
||
style={cellInput}
|
||
value={o.rangeMin}
|
||
onChange={(e) => ctx.setParam(i, { osc: { ...o, rangeMin: num(e.target.value, o.rangeMin) } })}
|
||
/>
|
||
</td>
|
||
<td style={{ padding: '3px 6px', width: 90 }}>
|
||
<input
|
||
type="number"
|
||
style={cellInput}
|
||
value={o.rangeMax}
|
||
onChange={(e) => ctx.setParam(i, { osc: { ...o, rangeMax: num(e.target.value, o.rangeMax) } })}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---- 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 (
|
||
<>
|
||
<SectionLabel>VCV bridge</SectionLabel>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<input
|
||
style={{ ...cellInput, width: 200 }}
|
||
value={draftUrl}
|
||
onChange={(e) => setDraftUrl(e.target.value)}
|
||
onBlur={() => ctx.setVcvUrl(draftUrl)}
|
||
placeholder="ws://localhost:8765"
|
||
/>
|
||
<button type="button" style={btn('var(--accent)')} onClick={() => ctx.setVcvUrl(draftUrl)}>
|
||
Connect
|
||
</button>
|
||
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>
|
||
<input type="checkbox" checked={ctx.vcvSendRaw} onChange={(e) => ctx.setVcvSendRaw(e.target.checked)} />
|
||
send raw 0..1
|
||
</label>
|
||
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
|
||
</div>
|
||
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||
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.
|
||
</p>
|
||
|
||
<SectionLabel>Per-output polarity</SectionLabel>
|
||
<div style={{ maxHeight: 320, overflow: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr>
|
||
<Th>output</Th>
|
||
<Th>range</Th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ctx.params.map((p, i) => {
|
||
const v = p.vcv ?? defaultVcvSpec();
|
||
return (
|
||
<tr key={i}>
|
||
<td style={{ padding: '3px 6px', fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>{p.name}</td>
|
||
<td style={{ padding: '3px 6px', width: 110 }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => 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'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---- 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 (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
<PresetBar ctx={ctx} backend={backend} />
|
||
{backend === 'midi' ? <MidiConfig ctx={ctx} /> : backend === 'osc' ? <OscConfig ctx={ctx} /> : <VcvConfig ctx={ctx} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<span
|
||
style={{
|
||
fontSize: 9,
|
||
fontFamily: 'var(--font-mono)',
|
||
color,
|
||
border: `1px solid ${color}`,
|
||
borderRadius: 'var(--r-pill)',
|
||
padding: '2px 8px',
|
||
}}
|
||
title={s.message}
|
||
>
|
||
{s.message}
|
||
</span>
|
||
);
|
||
}
|