feat(manifold): MIDI device-template picker in Outputs config
Adds a device picker to the MIDI backend config: choose an external synth (Moog Sub 37, Hydrasynth, etc.), see its parameters grouped and BY NAME with their CC numbers, tick which ones to control, and Apply — fills the per-output CC table (CC#/channel/name) and sets the CC count from the template. Clamps to the engine output count. Self-contained (local state + setParam/setMidiCcCount); the result lives in the shared params store, so the existing named-preset bar saves/restores it. Sourced from manifold/src/midi-devices (codegen).
This commit is contained in:
parent
9a9b66c5ee
commit
b3c0e71bb7
2 changed files with 135 additions and 0 deletions
|
|
@ -28,6 +28,7 @@ import {
|
||||||
savePreset,
|
savePreset,
|
||||||
type OutputPreset,
|
type OutputPreset,
|
||||||
} from '../backends/presets';
|
} from '../backends/presets';
|
||||||
|
import { MIDI_DEVICES, MIDI_DEVICES_BY_ID, type MidiDeviceParam } from '../midi-devices';
|
||||||
|
|
||||||
function num(s: string, fallback: number): number {
|
function num(s: string, fallback: number): number {
|
||||||
const v = parseFloat(s);
|
const v = parseFloat(s);
|
||||||
|
|
@ -205,6 +206,129 @@ function PresetBar({ ctx, backend }: { ctx: ConsoleCtx; backend: BackendId }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Device template picker (synth-midi-cc templates) ----------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
* 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
|
||||||
|
* store, so it is saved/restored by the named-preset bar like any other config.
|
||||||
|
*/
|
||||||
|
function DeviceTemplatePicker({ ctx }: { ctx: ConsoleCtx }) {
|
||||||
|
const [deviceId, setDeviceId] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const device = deviceId ? MIDI_DEVICES_BY_ID[deviceId] : undefined;
|
||||||
|
const limit = ctx.params.length; // engine output count caps how many CCs we can drive
|
||||||
|
|
||||||
|
const pickDevice = (id: string) => {
|
||||||
|
setDeviceId(id);
|
||||||
|
setSelected(new Set());
|
||||||
|
};
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const selectFirst = (n: number) => {
|
||||||
|
if (!device) return;
|
||||||
|
setSelected(new Set(device.params.slice(0, n).map((p) => p.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (!device) return;
|
||||||
|
const chosen = device.params.filter((p) => selected.has(p.id)).slice(0, limit);
|
||||||
|
if (chosen.length === 0) return;
|
||||||
|
ctx.setMidiCcCount(chosen.length);
|
||||||
|
chosen.forEach((p, i) =>
|
||||||
|
ctx.setParam(i, {
|
||||||
|
name: p.label,
|
||||||
|
midi: { cc: p.cc, channel: device.default_channel, name: p.label, value: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stable, first-seen-order grouping for the checklist.
|
||||||
|
const groups: { group: string; params: MidiDeviceParam[] }[] = [];
|
||||||
|
if (device) {
|
||||||
|
const byGroup = new Map<string, MidiDeviceParam[]>();
|
||||||
|
for (const p of device.params) {
|
||||||
|
if (!byGroup.has(p.group)) {
|
||||||
|
const arr: MidiDeviceParam[] = [];
|
||||||
|
byGroup.set(p.group, arr);
|
||||||
|
groups.push({ group: p.group, params: arr });
|
||||||
|
}
|
||||||
|
byGroup.get(p.group)!.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const willApply = device ? Math.min(selected.size, limit) : 0;
|
||||||
|
const overflow = selected.size > limit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionLabel>Device template</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<select
|
||||||
|
value={deviceId}
|
||||||
|
onChange={(e) => pickDevice(e.target.value)}
|
||||||
|
style={{ ...cellInput, width: 'auto', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<option value="">— manual (no template) —</option>
|
||||||
|
{MIDI_DEVICES.map((d) => (
|
||||||
|
<option key={d.device_id} value={d.device_id}>
|
||||||
|
{d.name} · {d.params.length} params
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{device && (
|
||||||
|
<>
|
||||||
|
<button type="button" style={btn('var(--fg-mute)')} onClick={() => selectFirst(Math.min(limit, device.params.length))}>
|
||||||
|
Select first {Math.min(limit, device.params.length)}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={btn('var(--fg-mute)')} onClick={() => setSelected(new Set())}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button type="button" style={btn('var(--accent)')} onClick={apply} disabled={willApply === 0}>
|
||||||
|
Apply {willApply} → outputs
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{device && (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 9, color: overflow ? 'var(--warn)' : 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||||
|
{device.manufacturer} · default ch {device.default_channel} · {selected.size} selected
|
||||||
|
{overflow ? ` (only the first ${limit} will map — engine has ${limit} outputs)` : ''}. Tick the parameters
|
||||||
|
the model should drive, then Apply. You can still tweak CC#/channel below.
|
||||||
|
</p>
|
||||||
|
<div style={{ maxHeight: 240, overflow: 'auto', border: '1px solid var(--line)', borderRadius: 'var(--r-1)', padding: '4px 6px' }}>
|
||||||
|
{groups.map(({ group, params }) => (
|
||||||
|
<div key={group} style={{ marginBottom: 4 }}>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--fg-dim)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '4px 0 2px' }}>
|
||||||
|
{group}
|
||||||
|
</div>
|
||||||
|
{params.map((p) => (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 'var(--fs-xs)', padding: '1px 0', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={selected.has(p.id)} onChange={() => toggle(p.id)} />
|
||||||
|
<span style={{ flex: 1 }}>{p.label}</span>
|
||||||
|
<span style={{ color: 'var(--fg-mute)', fontFamily: 'var(--font-mono)' }}>CC {p.cc}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- MIDI config (backends-spec §2.3 / §4.1) -------------------------------
|
// ---- MIDI config (backends-spec §2.3 / §4.1) -------------------------------
|
||||||
|
|
||||||
function MidiConfig({ ctx }: { ctx: ConsoleCtx }) {
|
function MidiConfig({ ctx }: { ctx: ConsoleCtx }) {
|
||||||
|
|
@ -244,6 +368,8 @@ function MidiConfig({ ctx }: { ctx: ConsoleCtx }) {
|
||||||
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
|
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DeviceTemplatePicker ctx={ctx} />
|
||||||
|
|
||||||
<SectionLabel>Per-output CC · name · channel</SectionLabel>
|
<SectionLabel>Per-output CC · name · channel</SectionLabel>
|
||||||
<div style={{ maxHeight: 320, overflow: 'auto' }}>
|
<div style={{ maxHeight: 320, overflow: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
|
|
||||||
9
manifold/src/midi-devices/index.ts
Normal file
9
manifold/src/midi-devices/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* External MIDI synth device templates.
|
||||||
|
*
|
||||||
|
* Re-exports the codegen output (generated from schemas/midi_devices/ via
|
||||||
|
* `bun run codegen/generate-midi-devices.ts`). Import from here, not from
|
||||||
|
* ./generated directly, so the path stays stable if non-generated helpers are
|
||||||
|
* added later.
|
||||||
|
*/
|
||||||
|
export * from './generated';
|
||||||
Loading…
Reference in a new issue