feat(dock): wire VCV bridge URL/connect + per-output polarity; forward verdict loop to /nisps/feedback in VCV mode

This commit is contained in:
monkey-w1n5t0n 2026-06-28 04:25:39 +02:00
parent ecb5bf2bb6
commit 964e37551f
5 changed files with 145 additions and 10 deletions

View file

@ -18,8 +18,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import type { EngineApi } from '../engine';
import type { MFParam } from '../console/model';
import type { BackendContext, BackendStatus, OutputMapping } from './backend';
import type { BackendId, MidiCcSpec, OscSpec } from '../dock/output-state';
import { defaultMidiSpec, defaultOscSpec } from '../dock/output-state';
import type { BackendId, MidiCcSpec, OscSpec, VcvSpec } from '../dock/output-state';
import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from '../dock/output-state';
import { BackendManager } from './manager';
export interface MidiSettings {
@ -32,6 +32,13 @@ export interface OscSettings {
sendRaw: boolean;
}
export interface VcvSettings {
/** Bridge WebSocket URL (the Deno bridge relays to the module over UDP). */
url: string;
/** Send raw 0..1 instead of the per-output bipolar/unipolar voltage range. */
sendRaw: boolean;
}
function toMapping(p: MFParam): OutputMapping {
return {
state: p.status,
@ -58,6 +65,7 @@ export function useBackendManager(
params: MFParam[],
midiSettings: MidiSettings,
oscSettings: OscSettings,
vcvSettings: VcvSettings,
): UseBackendManager {
const managerRef = useRef<BackendManager | null>(null);
const [status, setStatus] = useState<BackendStatus>({ state: 'idle', message: 'idle' });
@ -132,5 +140,13 @@ export function useBackendManager(
osc.setOscConfig(specs, { url: oscSettings.url, sendRaw: oscSettings.sendRaw });
}, [manager, params, oscSettings.url, oscSettings.sendRaw]);
// Push per-output VCV config (polarity) + bridge URL/raw whenever they change.
useEffect(() => {
const vcv = manager?.vcv();
if (!vcv) return;
const specs: VcvSpec[] = params.map((p) => p.vcv ?? defaultVcvSpec());
vcv.setVcvConfig(specs, { url: vcvSettings.url, sendRaw: vcvSettings.sendRaw });
}, [manager, params, vcvSettings.url, vcvSettings.sendRaw]);
return { manager, status, midiPorts, refreshMidiPorts };
}

View file

@ -127,6 +127,10 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
const [midiCcCount, setMidiCcCount] = useState(8);
const [oscUrl, setOscUrl] = useState('ws://localhost:8765');
const [oscSendRaw, setOscSendRaw] = useState(false);
// VCV bridge: WS URL of the Deno bridge that relays to the VCV module over UDP
// (default module UDP 7001). Independent of the OSC backend's bridge.
const [vcvUrl, setVcvUrl] = useState('ws://localhost:8765');
const [vcvSendRaw, setVcvSendRaw] = useState(false);
// Feedback markers plotted on the 2D map (both polarities; session-scoped).
const [markers, setMarkers] = useState<FeedbackMarker[]>([]);
const [volume, setVolume] = useState(0.8);
@ -252,15 +256,28 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
// spine and forwards routed outputs to the active backend; switching Mode
// tears down the old backend, starts the new one, and gates synth audio
// (mute on non-synth modes). MIDI/OSC config + names ride the shared params.
const { status: backendStatus, midiPorts, refreshMidiPorts } = useBackendManager(
const { manager: backendManager, status: backendStatus, midiPorts, refreshMidiPorts } = useBackendManager(
engine,
outputBackend,
modeId,
params,
{ outputId: midiOutputId, ccCount: midiCcCount },
{ url: oscUrl, sendRaw: oscSendRaw },
{ url: vcvUrl, sendRaw: vcvSendRaw },
);
// VCV bridge: forward a verdict op to the module's embedded learner. No-op
// unless Mode = VCV (the manager gates on the active backend). This is how
// thumbs-up/down + explore-and-place TRAIN the module across the bridge.
const forwardVcvFeedback = (op: 'up' | 'down' | 'rand' | 'clear') => {
backendManager?.forwardFeedback({
op,
spread: spread ? 1 : 0.6,
input: [pos[0], pos[1]],
output: Array.from(engine?.getOutputs() ?? new Float32Array(0)),
});
};
// values come from the REAL engine output, shaped per-param. Recomputed when
// the engine version bumps (new inference / weights) or params change.
const values = useMemo(
@ -314,6 +331,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
c?.like(pos, engine?.getOutputs() ?? new Float32Array(0));
pushMarker(pos, 'positive');
}
// VCV bridged mode: also train the module — thumbs-up = positive verdict.
forwardVcvFeedback('up');
syncController();
setNoiseCap((n) => Math.max(0.02, n * 0.7));
setHealth((h) => Math.min(1, h + 0.08));
@ -338,12 +357,16 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
} else {
pushSnap('explore');
c?.enterExplore();
// VCV bridged mode: entering explore re-rolls the module's net too.
forwardVcvFeedback('rand');
}
} else {
// Geometric dislike: push the current mapping away from this sound.
pushSnap('dislike ');
c?.dislike(pos, engine?.getOutputs() ?? new Float32Array(0), noiseCap, spread ? 1 : 0.6);
pushMarker(pos, 'negative');
// VCV bridged mode: thumbs-down = negative verdict.
forwardVcvFeedback('down');
setSeed((s) => s + (Math.random() - 0.5) * (noiseCap * 4 + 0.3));
setNoiseCap((n) => Math.min(0.5, n + 0.06));
setHealth((h) => Math.max(0.1, h - 0.06));
@ -364,6 +387,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
// Outside a scratchpad session a re-roll randomises the real net directly.
engine?.randomise(spread ? 1 : 0.6);
}
// VCV bridged mode: re-roll the module's net too.
forwardVcvFeedback('rand');
syncController();
setSeed(Math.random() * 6);
setNoiseCap(0.4);
@ -574,6 +599,10 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
setOscUrl,
oscSendRaw,
setOscSendRaw,
vcvUrl,
setVcvUrl,
vcvSendRaw,
setVcvSendRaw,
setParams: (next: MFParam[]) => setParams(next),
markers,
health,

View file

@ -99,6 +99,12 @@ export interface ConsoleCtx {
setOscUrl: (u: string) => void;
oscSendRaw: boolean;
setOscSendRaw: (v: boolean) => void;
/** VCV backend settings (bridge URL + send-raw toggle). The Deno bridge
* relays to the VCV module over UDP (default module port 7001). */
vcvUrl: string;
setVcvUrl: (u: string) => void;
vcvSendRaw: boolean;
setVcvSendRaw: (v: boolean) => void;
/** Replace the whole params array (used when restoring a named preset). */
setParams: (next: MFParam[]) => void;

View file

@ -6,7 +6,9 @@
* 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 per-output polarity (delegates to the existing BackendAdvanced body).
* - VCV/CV bridge URL + connect status + send-raw toggle, per-output polarity
* (uni 010 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
@ -16,7 +18,7 @@
import { useEffect, useState } from 'react';
import type { ConsoleCtx } from '../console/types';
import type { BackendId } from './output-state';
import { defaultMidiSpec, defaultOscSpec } from './output-state';
import { defaultMidiSpec, defaultOscSpec, defaultVcvSpec } from './output-state';
import {
applyPreset,
deletePreset,
@ -107,6 +109,7 @@ function PresetBar({ ctx, backend }: { ctx: ConsoleCtx; backend: BackendId }) {
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 {};
};
@ -118,6 +121,9 @@ function PresetBar({ ctx, backend }: { ctx: ConsoleCtx; backend: BackendId }) {
} 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));
}
};
@ -381,6 +387,76 @@ function OscConfig({ ctx }: { ctx: ConsoleCtx }) {
);
}
// ---- 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' : '010 V'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
}
// ---- Public entry ----------------------------------------------------------
export interface OutputsBackendConfigProps {
@ -390,14 +466,14 @@ export interface OutputsBackendConfigProps {
/** The specialised, editable per-backend config + preset bar for the Outputs panel. */
export function OutputsBackendConfig({ ctx, backend }: OutputsBackendConfigProps) {
// Only MIDI / OSC carry a config + preset surface here; synth/particle/editor
// config is rendered by ModeConfig in Drawers. VCV/CV polarity stays in the
// full-depth BackendAdvanced modal.
if (backend !== 'midi' && backend !== 'osc') return null;
// 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} /> : <OscConfig ctx={ctx} />}
{backend === 'midi' ? <MidiConfig ctx={ctx} /> : backend === 'osc' ? <OscConfig ctx={ctx} /> : <VcvConfig ctx={ctx} />}
</div>
);
}

View file

@ -141,6 +141,14 @@ export class EngineApi {
return this.spine.routedOutput();
}
/**
* Current control input vector (2-D for the fixed 2N MLP). Used by the VCV
* backend (via BackendManager) to drive the module's inputs over the bridge.
*/
inputVector(): ReadonlyArray<number> {
return [this.spine.lastRawX, this.spine.lastRawY];
}
/**
* Re-run the LAST raw input through the spine used after a weight change
* (train / randomise / feedback) so outputs + audio reflect the new MLP