From 964e37551f19c35e5f93ef0554772af736847067 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 04:25:39 +0200 Subject: [PATCH] feat(dock): wire VCV bridge URL/connect + per-output polarity; forward verdict loop to /nisps/feedback in VCV mode --- manifold/src/backends/useBackendManager.ts | 20 ++++- manifold/src/console/ConsoleApp.tsx | 31 +++++++- manifold/src/console/types.ts | 6 ++ manifold/src/dock/OutputsBackendConfig.tsx | 90 ++++++++++++++++++++-- manifold/src/engine/engine-api.ts | 8 ++ 5 files changed, 145 insertions(+), 10 deletions(-) diff --git a/manifold/src/backends/useBackendManager.ts b/manifold/src/backends/useBackendManager.ts index 875b37a..096e8e8 100644 --- a/manifold/src/backends/useBackendManager.ts +++ b/manifold/src/backends/useBackendManager.ts @@ -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(null); const [status, setStatus] = useState({ 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 }; } diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index ef3b4cb..151f5de 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -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([]); 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, diff --git a/manifold/src/console/types.ts b/manifold/src/console/types.ts index 4d45dbd..554741f 100644 --- a/manifold/src/console/types.ts +++ b/manifold/src/console/types.ts @@ -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; diff --git a/manifold/src/dock/OutputsBackendConfig.tsx b/manifold/src/dock/OutputsBackendConfig.tsx index 116d9da..0644127 100644 --- a/manifold/src/dock/OutputsBackendConfig.tsx +++ b/manifold/src/dock/OutputsBackendConfig.tsx @@ -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 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 @@ -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 => { 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 ( + <> + VCV bridge +
+ setDraftUrl(e.target.value)} + onBlur={() => ctx.setVcvUrl(draftUrl)} + placeholder="ws://localhost:8765" + /> + + + {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 +
+ + + + + + + + + {ctx.params.map((p, i) => { + const v = p.vcv ?? defaultVcvSpec(); + return ( + + + + + ); + })} + +
outputrange
{p.name} + +
+
+ + ); +} + // ---- 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 (
- {backend === 'midi' ? : } + {backend === 'midi' ? : backend === 'osc' ? : }
); } diff --git a/manifold/src/engine/engine-api.ts b/manifold/src/engine/engine-api.ts index 9e5fada..34bd987 100644 --- a/manifold/src/engine/engine-api.ts +++ b/manifold/src/engine/engine-api.ts @@ -141,6 +141,14 @@ export class EngineApi { return this.spine.routedOutput(); } + /** + * Current control input vector (2-D for the fixed 2→N MLP). Used by the VCV + * backend (via BackendManager) to drive the module's inputs over the bridge. + */ + inputVector(): ReadonlyArray { + 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