feat(backends): add VcvBackend driving + training the VCV module over the OSC-WS bridge
This commit is contained in:
parent
19b7f7eee8
commit
f05669969f
1 changed files with 270 additions and 0 deletions
270
manifold/src/backends/vcv-backend.ts
Normal file
270
manifold/src/backends/vcv-backend.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
/**
|
||||||
|
* VcvBackend — drives + trains the VCV Rack NISPS module over the OSC↔WS bridge
|
||||||
|
* (backends-spec §2.6; vcv/SPEC.md OSC verbs). In "bridged" mode the BROWSER is
|
||||||
|
* authoritative: it streams the current input vector to the module and forwards
|
||||||
|
* the verdict loop (thumbs up/down, explore-and-place) so the module's embedded
|
||||||
|
* net trains in lock-step with the browser session.
|
||||||
|
*
|
||||||
|
* Transport (reuses {@link NispsOscClient} → the Deno bridge in
|
||||||
|
* manifold/osc-bridge, default ws://localhost:8765, default module UDP 7001):
|
||||||
|
*
|
||||||
|
* browser → module
|
||||||
|
* /nisps/input <f…f> the current 2-D input vector (drives the module)
|
||||||
|
* /nisps/output <f…f> per-output values (CV) — sent as params batch so
|
||||||
|
* the bridge maps each to /nisps/<name>; the module
|
||||||
|
* also derives its own outputs, but the browser
|
||||||
|
* value is authoritative in bridged mode
|
||||||
|
* /nisps/feedback {op,…} verdict op (up | down | rand | clear) as JSON
|
||||||
|
* state — { op, spread, input[], output[] }
|
||||||
|
*
|
||||||
|
* module → browser
|
||||||
|
* /nisps/output <f…f> module's live outputs (status / visualisation)
|
||||||
|
* /nisps/input <f…f> module's live inputs (echo / status)
|
||||||
|
* /nisps/state <json> module status snapshot (surfaced as a message)
|
||||||
|
*
|
||||||
|
* The bridge process AND the VCV module must both be running — until the WS
|
||||||
|
* connects we surface "bridge not running"; until the module replies we stay
|
||||||
|
* "connected, waiting for module".
|
||||||
|
*
|
||||||
|
* British spelling in product copy; the synth is the "Built-in Synth", never
|
||||||
|
* "C15".
|
||||||
|
*/
|
||||||
|
import type { BackendContext, BackendStatus, OutputBackend } from './backend';
|
||||||
|
import { isSilent, mapOutput } from './mapping';
|
||||||
|
import { NispsOscClient } from './osc-client';
|
||||||
|
import type { VcvSpec } from '../dock/output-state';
|
||||||
|
|
||||||
|
const SEND_INTERVAL_MS = 50;
|
||||||
|
const DEAD_ZONE = 0.002; // on the normalised value, pre physical-scale
|
||||||
|
|
||||||
|
/** A verdict op forwarded to the module's embedded learner. */
|
||||||
|
export interface VcvFeedbackOp {
|
||||||
|
op: 'up' | 'down' | 'rand' | 'clear';
|
||||||
|
/** Master spread (0..1) — mirrors the engine spread knob. */
|
||||||
|
spread: number;
|
||||||
|
/** The control input the verdict was given at (2-D). */
|
||||||
|
input: number[];
|
||||||
|
/** The heard output vector at that input (≤126 dims). */
|
||||||
|
output: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VcvBackendConfig {
|
||||||
|
/** Bridge WebSocket URL (the bridge then relays to the module over UDP). */
|
||||||
|
url: string;
|
||||||
|
/** Send raw normalised 0..1 instead of the per-output bipolar/unipolar range. */
|
||||||
|
sendRaw: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VcvBackend implements OutputBackend {
|
||||||
|
readonly id = 'vcv' as const;
|
||||||
|
|
||||||
|
private client = new NispsOscClient();
|
||||||
|
private ctx: BackendContext | null = null;
|
||||||
|
private specs: VcvSpec[] = [];
|
||||||
|
private sendRaw = false;
|
||||||
|
|
||||||
|
/** Latest input vector the browser is driving the module with (2-D). */
|
||||||
|
private inputVec: number[] = [0.5, 0.5];
|
||||||
|
|
||||||
|
private lastSent: Float32Array = new Float32Array(0); // last normalised output
|
||||||
|
private batch: Array<[string, number]> = []; // reused outer; entries reused
|
||||||
|
private lastSendMs = 0;
|
||||||
|
private lastInputSent: [number, number] = [-1, -1];
|
||||||
|
|
||||||
|
/** Latest module-reported outputs (for visualisation), null until first echo. */
|
||||||
|
private moduleOutputs: number[] | null = null;
|
||||||
|
private gotModuleReply = false;
|
||||||
|
|
||||||
|
private statusState: BackendStatus = { state: 'idle', message: 'VCV idle' };
|
||||||
|
private statusListeners = new Set<(s: BackendStatus) => void>();
|
||||||
|
private outputListeners = new Set<(v: number[]) => void>();
|
||||||
|
private offConn: (() => void) | null = null;
|
||||||
|
private offInfo: (() => void) | null = null;
|
||||||
|
private offOutputs: (() => void) | null = null;
|
||||||
|
private offInputs: (() => void) | null = null;
|
||||||
|
|
||||||
|
isAvailable(): boolean {
|
||||||
|
return typeof WebSocket !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(ctx: BackendContext): Promise<void> {
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.lastSent = new Float32Array(ctx.outputCount).fill(-1);
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
this.setStatus({ state: 'unavailable', message: 'WebSocket not available' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.offConn = this.client.onConnectionChange((connected) => {
|
||||||
|
if (!connected) {
|
||||||
|
this.gotModuleReply = false;
|
||||||
|
this.setStatus({ state: 'error', message: `VCV bridge not running — start it (${this.client.url})` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setStatus({
|
||||||
|
state: this.gotModuleReply ? 'ready' : 'connecting',
|
||||||
|
message: this.gotModuleReply
|
||||||
|
? `VCV module connected (${this.client.url})`
|
||||||
|
: `Bridge connected (${this.client.url}) — waiting for module…`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.offInfo = this.client.onInfo((m) => {
|
||||||
|
if (this.client.connected && !this.gotModuleReply) {
|
||||||
|
this.setStatus({ state: 'connecting', message: m });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Module → browser: a reply on either channel proves the module is alive.
|
||||||
|
this.offOutputs = this.client.onOutputsReceived((v) => this.onModuleReply(v, true));
|
||||||
|
this.offInputs = this.client.onInputsReceived((v) => this.onModuleReply(v, false));
|
||||||
|
|
||||||
|
this.setStatus({ state: 'connecting', message: `Connecting to VCV bridge (${this.client.url})…` });
|
||||||
|
this.client.connect({ reconnect: true }).catch(() => {
|
||||||
|
this.setStatus({ state: 'error', message: `VCV bridge not running — start it (${this.client.url})` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext(ctx: BackendContext): void {
|
||||||
|
this.ctx = ctx;
|
||||||
|
if (this.lastSent.length !== ctx.outputCount) {
|
||||||
|
this.lastSent = new Float32Array(ctx.outputCount).fill(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update per-output VCV specs (polarity) + bridge URL/raw toggle. */
|
||||||
|
setVcvConfig(specs: VcvSpec[], cfg: VcvBackendConfig): void {
|
||||||
|
this.specs = specs;
|
||||||
|
this.sendRaw = cfg.sendRaw;
|
||||||
|
if (cfg.url !== this.client.url) {
|
||||||
|
this.client.setUrl(cfg.url);
|
||||||
|
this.gotModuleReply = false;
|
||||||
|
if (this.isAvailable()) {
|
||||||
|
this.setStatus({ state: 'connecting', message: `Connecting to VCV bridge (${cfg.url})…` });
|
||||||
|
this.client.connect({ reconnect: true }).catch(() => {
|
||||||
|
this.setStatus({ state: 'error', message: `VCV bridge not running — start it (${cfg.url})` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.lastSent.fill(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the input vector the browser drives the module with (bridged mode). The
|
||||||
|
* next `send()` streams it to /nisps/input. Copied — caller may mutate.
|
||||||
|
*/
|
||||||
|
setInputVector(vec: ReadonlyArray<number>): void {
|
||||||
|
if (this.inputVec.length !== vec.length) this.inputVec = new Array(vec.length);
|
||||||
|
for (let i = 0; i < vec.length; i++) this.inputVec[i] = vec[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward a verdict op to the module's embedded learner over /nisps/feedback.
|
||||||
|
* Hooked from the BackendManager when the verdict loop fires in VCV mode, so
|
||||||
|
* thumbs-up/down + explore-and-place train the module across the bridge.
|
||||||
|
*/
|
||||||
|
sendFeedback(op: VcvFeedbackOp): void {
|
||||||
|
if (!this.client.connected) return;
|
||||||
|
// The bridge ships `{ type:'state', payload }` as an OSC string to
|
||||||
|
// /nisps/state. We reuse that string channel for /nisps/feedback by tagging
|
||||||
|
// the payload with a `feedback` envelope the module routes accordingly.
|
||||||
|
this.client.sendState({ feedback: op });
|
||||||
|
}
|
||||||
|
|
||||||
|
send(routed: Float32Array): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
if (!ctx || !this.client.connected) return;
|
||||||
|
|
||||||
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
|
if (now - this.lastSendMs < SEND_INTERVAL_MS) return;
|
||||||
|
this.lastSendMs = now;
|
||||||
|
|
||||||
|
// 1) Stream the current input vector so the browser drives the module.
|
||||||
|
const ix = this.inputVec[0] ?? 0.5;
|
||||||
|
const iy = this.inputVec[1] ?? 0.5;
|
||||||
|
if (Math.abs(ix - this.lastInputSent[0]) >= DEAD_ZONE || Math.abs(iy - this.lastInputSent[1]) >= DEAD_ZONE) {
|
||||||
|
this.lastInputSent[0] = ix;
|
||||||
|
this.lastInputSent[1] = iy;
|
||||||
|
// The bridge maps `input` → /nisps/input <f…f> (its `inputs` relay path).
|
||||||
|
this.client.sendParams([
|
||||||
|
['input', ix],
|
||||||
|
['input', iy],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Stream the routed per-output values (authoritative CV in bridged mode).
|
||||||
|
const n = Math.min(routed.length, ctx.mappings.length);
|
||||||
|
this.batch.length = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const m = ctx.mappings[i];
|
||||||
|
if (isSilent(m)) continue;
|
||||||
|
const mapped = mapOutput(routed[i], m); // 0..1 in [min,max]
|
||||||
|
const prev = this.lastSent[i];
|
||||||
|
if (prev >= 0 && Math.abs(mapped - prev) < DEAD_ZONE) continue;
|
||||||
|
this.lastSent[i] = mapped;
|
||||||
|
const spec = this.specs[i];
|
||||||
|
const value = this.sendRaw ? mapped : this.toVoltage(mapped, spec);
|
||||||
|
const name = ctx.names[i] ? sanitise(ctx.names[i]) : `out${i}`;
|
||||||
|
this.batch.push([name, value]);
|
||||||
|
}
|
||||||
|
if (this.batch.length) this.client.sendParams(this.batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a 0..1 value into the per-output VCV voltage range (uni/bipolar). */
|
||||||
|
private toVoltage(v: number, spec: VcvSpec | undefined): number {
|
||||||
|
// Unipolar 0..10 V; bipolar ±5 V (dock-spec §4.3 polarity).
|
||||||
|
return spec?.bipolar ? v * 10 - 5 : v * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest module-reported output vector for visualisation (may be null). */
|
||||||
|
moduleStatusOutputs(): number[] | null {
|
||||||
|
return this.moduleOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to module-reported outputs (visualisation feed). */
|
||||||
|
onModuleOutputs(cb: (v: number[]) => void): () => void {
|
||||||
|
this.outputListeners.add(cb);
|
||||||
|
return () => this.outputListeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onModuleReply(v: number[], isOutput: boolean): void {
|
||||||
|
if (!this.gotModuleReply) {
|
||||||
|
this.gotModuleReply = true;
|
||||||
|
this.setStatus({ state: 'ready', message: `VCV module connected (${this.client.url})` });
|
||||||
|
}
|
||||||
|
if (isOutput) {
|
||||||
|
this.moduleOutputs = v;
|
||||||
|
for (const cb of this.outputListeners) cb(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async teardown(): Promise<void> {
|
||||||
|
this.offConn?.();
|
||||||
|
this.offInfo?.();
|
||||||
|
this.offOutputs?.();
|
||||||
|
this.offInputs?.();
|
||||||
|
this.offConn = null;
|
||||||
|
this.offInfo = null;
|
||||||
|
this.offOutputs = null;
|
||||||
|
this.offInputs = null;
|
||||||
|
this.client.disconnect();
|
||||||
|
this.gotModuleReply = false;
|
||||||
|
this.setStatus({ state: 'idle', message: 'VCV idle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): BackendStatus {
|
||||||
|
return this.statusState;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChange(cb: (s: BackendStatus) => void): () => void {
|
||||||
|
this.statusListeners.add(cb);
|
||||||
|
return () => this.statusListeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(s: BackendStatus): void {
|
||||||
|
this.statusState = s;
|
||||||
|
for (const cb of this.statusListeners) cb(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sanitise an output name into an OSC-path-safe token (the bridge prefixes it). */
|
||||||
|
function sanitise(name: string): string {
|
||||||
|
return name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'out';
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue