feat(dock): wire VCV bridge URL/connect + per-output polarity; forward verdict loop to /nisps/feedback in VCV mode
This commit is contained in:
parent
ecb5bf2bb6
commit
964e37551f
5 changed files with 145 additions and 10 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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' : '0–10 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
|
|||
Loading…
Reference in a new issue