refactor(manifold): extract BaseBackend mirroring the input layer's BaseSource

Phase 3 (L17). The midi/osc/vcv transports each carried their own copy of the
status plumbing, throttle gate and lastSent management; the inputs layer already
had this factored out as BaseSource, so BaseBackend mirrors its shape rather
than inventing a second convention.

AUDIT CORRECTION: L17 says "all four" backends duplicate lastSent. Only three
do. The uSEQ CV backend has a genuinely different frame-level dead-zone (an
Int32Array over 14 channels plus gate bits) and no per-output lastSent, so it
takes the status half only — forcing it into the shared per-output path would
have needed a special case, and a base class with a per-subclass escape hatch is
worse than three backends sharing one. Also: BaseSource itself has no throttle
or lastSent (it is status + action plumbing only), so the mirror is partial by
nature; the throttle/lastSent half is the duplication the backends actually had.

Three internal representation changes, none observable: the MIDI backend's
lastSent moves Int16Array -> Float32Array (stored values are integers -1..127,
exact in f32, so every dead-zone comparison is bit-identical); the CV backend
now inherits a setContext that also allocates a per-output buffer it never reads
(cold path, no effect); and onStatusChange's unsubscribe returns void rather
than Set.delete's boolean (the declared type was already `() => void` and no
caller used it).

Phase 2's fix to vcv-backend.ts — tracking and dead-zoning the full N-dim input
vector rather than 2 — is preserved.

Gates: typecheck clean, 17/17 unit tests.
This commit is contained in:
monkey-w1n5t0n 2026-07-21 14:02:48 +02:00
parent 96737a3d42
commit 87daac4f0d
6 changed files with 137 additions and 126 deletions

View file

@ -12,11 +12,14 @@ See `docs/specs/backends-spec.md` for the authoritative design.
| File | Role | | File | Role |
|---|---| |---|---|
| `backend.ts` | The `OutputBackend` interface + `BackendContext` / `OutputMapping` / `BackendStatus`. | | `backend.ts` | The `OutputBackend` interface + `BackendContext` / `OutputMapping` / `BackendStatus`. |
| `base-backend.ts` | `BaseBackend` — shared status/throttle/`lastSent` plumbing for the real transports (mirrors `inputs/base-source.ts`). |
| `mapping.ts` | Universal per-output baseline mapping (`applyCurve`, `mapOutput`) — shared by all backends, input-clamped. | | `mapping.ts` | Universal per-output baseline mapping (`applyCurve`, `mapOutput`) — shared by all backends, input-clamped. |
| `manager.ts` | `BackendManager` — single spine consumer; switches/teardowns backends; **gates synth audio**. | | `manager.ts` | `BackendManager` — single spine consumer; switches/teardowns backends; **gates synth audio**. |
| `midi-backend.ts` | `WebMidiBackend` — real Web MIDI CC out (per-output CC#/channel/range/name, throttled + dead-zone). | | `midi-backend.ts` | `WebMidiBackend` — real Web MIDI CC out (per-output CC#/channel/range/name, throttled + dead-zone). |
| `osc-client.ts` | `NispsOscClient` — WS transport to the Deno OSC bridge (JSON protocol, auto-reconnect). | | `osc-client.ts` | `NispsOscClient` — WS transport to the Deno OSC bridge (JSON protocol, auto-reconnect). |
| `osc-backend.ts` | `OscBridgeBackend` — OSC out over WS; per-output address path + physical range. | | `osc-backend.ts` | `OscBridgeBackend` — OSC out over WS; per-output address path + physical range. |
| `cv-backend.ts` | `UseqCvBackend` — CV/gate out over USB Web Serial to a uSEQ module (`useq-protocol.ts` frames, channel-level dead-zone). |
| `useq-protocol.ts` | Frame encode/decode + RX parser for the uSEQ serial protocol (see `docs/specs/useq-cv-protocol.md`). |
| `vcv-backend.ts` | `VcvBackend` — drives + **trains** the VCV Rack NISPS module over the OSC↔WS bridge (streams `/nisps/input`, forwards the verdict loop to `/nisps/feedback`, receives `/nisps/output` / `/nisps/input` as module-alive proof). | | `vcv-backend.ts` | `VcvBackend` — drives + **trains** the VCV Rack NISPS module over the OSC↔WS bridge (streams `/nisps/input`, forwards the verdict loop to `/nisps/feedback`, receives `/nisps/output` / `/nisps/input` as module-alive proof). |
| `passthrough-backend.ts` | No-op sink for synth (plays in-engine) / particles (rAF consumer) / editor. | | `passthrough-backend.ts` | No-op sink for synth (plays in-engine) / particles (rAF consumer) / editor. |
| `presets.ts` | Named per-backend output-config presets (localStorage, per-backend namespace). | | `presets.ts` | Named per-backend output-config presets (localStorage, per-backend namespace). |

View file

@ -0,0 +1,93 @@
/**
* BaseBackend shared status + throttle + lastSent plumbing for the real
* output backends (MIDI / OSC / uSEQ CV / VCV), mirroring the inputs layer's
* BaseSource (inputs/base-source.ts). Subclasses implement
* isAvailable/start/send/teardown.
*
* What lives here:
* - Status plumbing: `statusState` + listeners + `status()` /
* `onStatusChange()` / `setStatus()` byte-for-byte what each backend
* previously carried.
* - Throttle gate: `throttled(intervalMs)` around the shared `lastSendMs`
* stamp. Call it first thing in `send()` (after the connected/ctx guards);
* it stamps on pass, so a frame dropped AFTER the gate (e.g. serial
* backpressure in the CV backend) still consumed its slot exactly the
* previous inline semantics. Tests poke `lastSendMs` directly to bypass
* the gate (tests/input-vector-truncation.test.ts) keep the field name.
* - lastSent: the per-output dead-zone sentinel buffer (-1 = unsent),
* unconditionally reset by `resetLastSent()` (start / config change) and
* resized-if-needed by `setContext()`. The uSEQ CV backend tracks
* CHANNEL-level state instead (lastCv/lastGateBits its dead-zone is
* frame-level over the 14 uSEQ channels) and simply never reads this
* buffer; it still shares the status/throttle/ctx plumbing.
*/
import type { BackendId } from '../dock/output-state';
import type { BackendContext, BackendStatus, OutputBackend } from './backend';
export abstract class BaseBackend implements OutputBackend {
abstract readonly id: BackendId;
protected ctx: BackendContext | null = null;
protected statusState: BackendStatus;
private statusListeners = new Set<(s: BackendStatus) => void>();
/**
* Per-output dead-zone sentinels (-1 = unsent). Float32 holds the MIDI
* backend's 7-bit integers (0..127) exactly, so one buffer type serves the
* float (OSC/VCV) and integer (MIDI) dead-zones alike.
*/
protected lastSent = new Float32Array(0);
/** Throttle-gate timestamp. Tests poke this directly — do not rename. */
private lastSendMs = 0;
constructor(initial: BackendStatus) {
this.statusState = initial;
}
abstract isAvailable(): boolean;
abstract start(ctx: BackendContext): Promise<void>;
abstract send(routed: Float32Array): void;
abstract teardown(): Promise<void>;
status(): BackendStatus {
return this.statusState;
}
onStatusChange(cb: (s: BackendStatus) => void): () => void {
this.statusListeners.add(cb);
return () => {
this.statusListeners.delete(cb);
};
}
/** Apply a fresh context; resize the dead-zone buffer only when the output
* count changed (an unchanged-width remap keeps its sentinels matching
* the previous per-backend behaviour). */
setContext(ctx: BackendContext): void {
this.ctx = ctx;
if (this.lastSent.length !== ctx.outputCount) this.resetLastSent(ctx.outputCount);
}
protected setStatus(s: BackendStatus): void {
this.statusState = s;
for (const cb of this.statusListeners) cb(s);
}
/**
* Send-interval gate. Returns true when this call falls inside the interval
* (caller should drop the frame); otherwise stamps `lastSendMs` and returns
* false.
*/
protected throttled(intervalMs: number): boolean {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (now - this.lastSendMs < intervalMs) return true;
this.lastSendMs = now;
return false;
}
/** (Re)allocate the per-output dead-zone buffer, every sentinel -1. */
protected resetLastSent(count: number): void {
this.lastSent = new Float32Array(count).fill(-1);
}
}

View file

@ -17,7 +17,8 @@
* store (output-state.ts CvSpec) and arrives via {@link setCvConfig}, parallel * store (output-state.ts CvSpec) and arrives via {@link setCvConfig}, parallel
* to ctx.mappings exactly like the MIDI backend. * to ctx.mappings exactly like the MIDI backend.
*/ */
import type { BackendContext, BackendStatus, OutputBackend } from './backend'; import type { BackendContext } from './backend';
import { BaseBackend } from './base-backend';
import { isSilent, mapOutput } from './mapping'; import { isSilent, mapOutput } from './mapping';
import type { CvChannelId, CvSpec } from '../dock/output-state'; import type { CvChannelId, CvSpec } from '../dock/output-state';
import { import {
@ -44,10 +45,9 @@ function resolveTarget(id: CvChannelId): Target {
return { kind: 'gate', idx: parseInt(id.slice(4), 10) - 1 }; return { kind: 'gate', idx: parseInt(id.slice(4), 10) - 1 };
} }
export class UseqCvBackend implements OutputBackend { export class UseqCvBackend extends BaseBackend {
readonly id = 'cvgate' as const; readonly id = 'cvgate' as const;
private ctx: BackendContext | null = null;
/** Per-output CV specs, index-aligned with ctx.mappings. */ /** Per-output CV specs, index-aligned with ctx.mappings. */
private specs: CvSpec[] = []; private specs: CvSpec[] = [];
/** Pre-resolved channel targets, index-aligned with specs. */ /** Pre-resolved channel targets, index-aligned with specs. */
@ -63,15 +63,18 @@ export class UseqCvBackend implements OutputBackend {
private cvVals = new Uint16Array(NUM_CV); // last computed 12-bit CV per channel private cvVals = new Uint16Array(NUM_CV); // last computed 12-bit CV per channel
private gateBits = 0; private gateBits = 0;
/** Channel-level dead-zone state (12-bit CV + gate bits) this backend's
* dead-zone is per-FRAME over the 14 uSEQ channels, so the inherited
* per-output `lastSent` buffer goes unused here. */
private lastCv = new Int32Array(NUM_CV).fill(-1); private lastCv = new Int32Array(NUM_CV).fill(-1);
private lastGateBits = -1; private lastGateBits = -1;
private lastSendMs = 0;
private writing = false; // in-flight write guard (avoid serial backpressure) private writing = false; // in-flight write guard (avoid serial backpressure)
private pendingIdentify: (() => void) | null = null; private pendingIdentify: (() => void) | null = null;
private statusState: BackendStatus = { state: 'idle', message: 'CV idle' }; constructor() {
private statusListeners = new Set<(s: BackendStatus) => void>(); super({ state: 'idle', message: 'CV idle' });
}
isAvailable(): boolean { isAvailable(): boolean {
return typeof navigator !== 'undefined' && 'serial' in navigator; return typeof navigator !== 'undefined' && 'serial' in navigator;
@ -100,10 +103,6 @@ export class UseqCvBackend implements OutputBackend {
this.setStatus({ state: 'ready', message: 'uSEQ ready — click Connect device' }); this.setStatus({ state: 'ready', message: 'uSEQ ready — click Connect device' });
} }
setContext(ctx: BackendContext): void {
this.ctx = ctx;
}
/** Update the per-output CV specs (channel + gate threshold). */ /** Update the per-output CV specs (channel + gate threshold). */
setCvConfig(specs: CvSpec[]): void { setCvConfig(specs: CvSpec[]): void {
this.specs = specs; this.specs = specs;
@ -198,9 +197,7 @@ export class UseqCvBackend implements OutputBackend {
const w = this.writer; const w = this.writer;
if (!ctx || !w) return; if (!ctx || !w) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (this.throttled(SEND_INTERVAL_MS)) return;
if (now - this.lastSendMs < SEND_INTERVAL_MS) return;
this.lastSendMs = now;
if (this.writing) return; // previous frame still draining — drop this one if (this.writing) return; // previous frame still draining — drop this one
// Rebuild the 14-channel snapshot from the routed outputs. // Rebuild the 14-channel snapshot from the routed outputs.
@ -279,18 +276,4 @@ export class UseqCvBackend implements OutputBackend {
await this.closePort(); await this.closePort();
this.setStatus({ state: 'idle', message: 'CV idle' }); this.setStatus({ state: 'idle', message: 'CV 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);
}
} }

View file

@ -10,10 +10,12 @@
* a parallel `midiSpecs` array set through {@link setMidiConfig}. The port is * a parallel `midiSpecs` array set through {@link setMidiConfig}. The port is
* picked in the Outputs panel and applied via {@link selectOutput}. * picked in the Outputs panel and applied via {@link selectOutput}.
* *
* No per-frame allocation: the 3-byte message array is reused; `lastSent` * No per-frame allocation: the 3-byte message array is reused; the inherited
* tracks per-CC values for the dead-zone. * `lastSent` buffer tracks per-CC values for the dead-zone (7-bit integers
* exact in float32). Status/throttle/lastSent plumbing lives in BaseBackend.
*/ */
import type { BackendContext, BackendStatus, OutputBackend } from './backend'; import type { BackendContext } from './backend';
import { BaseBackend } from './base-backend';
import { isSilent, mapOutput } from './mapping'; import { isSilent, mapOutput } from './mapping';
import type { MidiCcSpec } from '../dock/output-state'; import type { MidiCcSpec } from '../dock/output-state';
@ -27,7 +29,7 @@ export interface MidiBackendConfig {
ccCount: number; ccCount: number;
} }
export class WebMidiBackend implements OutputBackend { export class WebMidiBackend extends BaseBackend {
readonly id = 'midi' as const; readonly id = 'midi' as const;
private access: MIDIAccess | null = null; private access: MIDIAccess | null = null;
@ -35,16 +37,14 @@ export class WebMidiBackend implements OutputBackend {
private outputId: string | null = null; private outputId: string | null = null;
private ccCount = 0; private ccCount = 0;
private ctx: BackendContext | null = null;
/** Per-output MIDI specs, index-aligned with ctx.mappings. */ /** Per-output MIDI specs, index-aligned with ctx.mappings. */
private specs: MidiCcSpec[] = []; private specs: MidiCcSpec[] = [];
private lastSent = new Int16Array(0); // per-output last value, -1 = unsent
private msg: number[] = [0, 0, 0]; // reused 3-byte buffer private msg: number[] = [0, 0, 0]; // reused 3-byte buffer
private lastSendMs = 0;
private statusState: BackendStatus = { state: 'idle', message: 'MIDI idle' }; constructor() {
private statusListeners = new Set<(s: BackendStatus) => void>(); super({ state: 'idle', message: 'MIDI idle' });
}
isAvailable(): boolean { isAvailable(): boolean {
return typeof navigator !== 'undefined' && typeof navigator.requestMIDIAccess === 'function'; return typeof navigator !== 'undefined' && typeof navigator.requestMIDIAccess === 'function';
@ -52,7 +52,7 @@ export class WebMidiBackend implements OutputBackend {
async start(ctx: BackendContext): Promise<void> { async start(ctx: BackendContext): Promise<void> {
this.ctx = ctx; this.ctx = ctx;
this.lastSent = new Int16Array(ctx.outputCount).fill(-1); this.resetLastSent(ctx.outputCount);
if (!this.isAvailable()) { if (!this.isAvailable()) {
this.setStatus({ state: 'unavailable', message: 'Web MIDI not supported in this browser' }); this.setStatus({ state: 'unavailable', message: 'Web MIDI not supported in this browser' });
return; return;
@ -72,13 +72,6 @@ export class WebMidiBackend implements OutputBackend {
} }
} }
setContext(ctx: BackendContext): void {
this.ctx = ctx;
if (this.lastSent.length !== ctx.outputCount) {
this.lastSent = new Int16Array(ctx.outputCount).fill(-1);
}
}
/** Update the per-output MIDI specs + how many CCs are mapped + the port. */ /** Update the per-output MIDI specs + how many CCs are mapped + the port. */
setMidiConfig(specs: MidiCcSpec[], cfg: MidiBackendConfig): void { setMidiConfig(specs: MidiCcSpec[], cfg: MidiBackendConfig): void {
this.specs = specs; this.specs = specs;
@ -124,9 +117,7 @@ export class WebMidiBackend implements OutputBackend {
const ctx = this.ctx; const ctx = this.ctx;
if (!out || !ctx) return; if (!out || !ctx) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (this.throttled(SEND_INTERVAL_MS)) return;
if (now - this.lastSendMs < SEND_INTERVAL_MS) return;
this.lastSendMs = now;
const n = Math.min(this.ccCount, routed.length, ctx.mappings.length, this.specs.length); const n = Math.min(this.ccCount, routed.length, ctx.mappings.length, this.specs.length);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
@ -159,18 +150,4 @@ export class WebMidiBackend implements OutputBackend {
this.output = null; this.output = null;
this.setStatus({ state: 'idle', message: 'MIDI idle' }); this.setStatus({ state: 'idle', message: 'MIDI 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);
}
} }

View file

@ -12,7 +12,8 @@
* surface "bridge not running" until the WS connects. Throttled to ~50ms with a * surface "bridge not running" until the WS connects. Throttled to ~50ms with a
* per-output dead-zone (matching the deployed osc-output.js). * per-output dead-zone (matching the deployed osc-output.js).
*/ */
import type { BackendContext, BackendStatus, OutputBackend } from './backend'; import type { BackendContext } from './backend';
import { BaseBackend } from './base-backend';
import { isSilent, mapOutput } from './mapping'; import { isSilent, mapOutput } from './mapping';
import { NispsOscClient } from './osc-client'; import { NispsOscClient } from './osc-client';
import type { OscSpec } from '../dock/output-state'; import type { OscSpec } from '../dock/output-state';
@ -27,30 +28,29 @@ export interface OscBackendConfig {
sendRaw: boolean; sendRaw: boolean;
} }
export class OscBridgeBackend implements OutputBackend { export class OscBridgeBackend extends BaseBackend {
readonly id = 'osc' as const; readonly id = 'osc' as const;
private client = new NispsOscClient(); private client = new NispsOscClient();
private ctx: BackendContext | null = null;
private specs: OscSpec[] = []; private specs: OscSpec[] = [];
private sendRaw = false; private sendRaw = false;
private lastSent: Float32Array = new Float32Array(0); // last normalised value
private batch: Array<[string, number]> = []; // reused outer; entries reused private batch: Array<[string, number]> = []; // reused outer; entries reused
private lastSendMs = 0;
private statusState: BackendStatus = { state: 'idle', message: 'OSC idle' };
private statusListeners = new Set<(s: BackendStatus) => void>();
private offConn: (() => void) | null = null; private offConn: (() => void) | null = null;
private offInfo: (() => void) | null = null; private offInfo: (() => void) | null = null;
constructor() {
super({ state: 'idle', message: 'OSC idle' });
}
isAvailable(): boolean { isAvailable(): boolean {
return typeof WebSocket !== 'undefined'; return typeof WebSocket !== 'undefined';
} }
async start(ctx: BackendContext): Promise<void> { async start(ctx: BackendContext): Promise<void> {
this.ctx = ctx; this.ctx = ctx;
this.lastSent = new Float32Array(ctx.outputCount).fill(-1); this.resetLastSent(ctx.outputCount);
if (!this.isAvailable()) { if (!this.isAvailable()) {
this.setStatus({ state: 'unavailable', message: 'WebSocket not available' }); this.setStatus({ state: 'unavailable', message: 'WebSocket not available' });
return; return;
@ -72,13 +72,6 @@ export class OscBridgeBackend implements OutputBackend {
}); });
} }
setContext(ctx: BackendContext): void {
this.ctx = ctx;
if (this.lastSent.length !== ctx.outputCount) {
this.lastSent = new Float32Array(ctx.outputCount).fill(-1);
}
}
/** Update per-output OSC specs + bridge URL/raw toggle. */ /** Update per-output OSC specs + bridge URL/raw toggle. */
setOscConfig(specs: OscSpec[], cfg: OscBackendConfig): void { setOscConfig(specs: OscSpec[], cfg: OscBackendConfig): void {
this.specs = specs; this.specs = specs;
@ -99,9 +92,7 @@ export class OscBridgeBackend implements OutputBackend {
const ctx = this.ctx; const ctx = this.ctx;
if (!ctx || !this.client.connected) return; if (!ctx || !this.client.connected) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (this.throttled(SEND_INTERVAL_MS)) return;
if (now - this.lastSendMs < SEND_INTERVAL_MS) return;
this.lastSendMs = now;
const n = Math.min(routed.length, ctx.mappings.length, this.specs.length); const n = Math.min(routed.length, ctx.mappings.length, this.specs.length);
this.batch.length = 0; this.batch.length = 0;
@ -130,18 +121,4 @@ export class OscBridgeBackend implements OutputBackend {
this.client.disconnect(); this.client.disconnect();
this.setStatus({ state: 'idle', message: 'OSC idle' }); this.setStatus({ state: 'idle', message: 'OSC 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);
}
} }

View file

@ -30,7 +30,8 @@
* British spelling in product copy; the synth is the "Built-in Synth", never * British spelling in product copy; the synth is the "Built-in Synth", never
* "C15". * "C15".
*/ */
import type { BackendContext, BackendStatus, OutputBackend } from './backend'; import type { BackendContext } from './backend';
import { BaseBackend } from './base-backend';
import { isSilent, mapOutput } from './mapping'; import { isSilent, mapOutput } from './mapping';
import { NispsOscClient } from './osc-client'; import { NispsOscClient } from './osc-client';
import type { VcvSpec } from '../dock/output-state'; import type { VcvSpec } from '../dock/output-state';
@ -56,11 +57,10 @@ export interface VcvBackendConfig {
sendRaw: boolean; sendRaw: boolean;
} }
export class VcvBackend implements OutputBackend { export class VcvBackend extends BaseBackend {
readonly id = 'vcv' as const; readonly id = 'vcv' as const;
private client = new NispsOscClient(); private client = new NispsOscClient();
private ctx: BackendContext | null = null;
private specs: VcvSpec[] = []; private specs: VcvSpec[] = [];
private sendRaw = false; private sendRaw = false;
@ -68,31 +68,32 @@ export class VcvBackend implements OutputBackend {
* net's full input arity, not fixed at 2; simplification audit S10). */ * net's full input arity, not fixed at 2; simplification audit S10). */
private inputVec: number[] = [0.5, 0.5]; 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 batch: Array<[string, number]> = []; // reused outer; entries reused
private lastSendMs = 0;
/** Dead-zone sentinel per input axis, sized to `inputVec` (out-of-range -1 /** Dead-zone sentinel per input axis, sized to `inputVec` (out-of-range -1
* forces the first send). Tracks the FULL vector, not just the first two * forces the first send). Tracks the FULL vector, not just the first two
* axes, so a change in axis 2+ (gamepad/MIDI beyond the XY pair) still * axes, so a change in axis 2+ (gamepad/MIDI beyond the XY pair) still
* triggers a resend instead of being silently swallowed. */ * triggers a resend instead of being silently swallowed. Separate from the
* inherited per-OUTPUT `lastSent` buffer. */
private lastInputSent: number[] = []; private lastInputSent: number[] = [];
private gotModuleReply = false; private gotModuleReply = false;
private statusState: BackendStatus = { state: 'idle', message: 'VCV idle' };
private statusListeners = new Set<(s: BackendStatus) => void>();
private offConn: (() => void) | null = null; private offConn: (() => void) | null = null;
private offInfo: (() => void) | null = null; private offInfo: (() => void) | null = null;
private offOutputs: (() => void) | null = null; private offOutputs: (() => void) | null = null;
private offInputs: (() => void) | null = null; private offInputs: (() => void) | null = null;
constructor() {
super({ state: 'idle', message: 'VCV idle' });
}
isAvailable(): boolean { isAvailable(): boolean {
return typeof WebSocket !== 'undefined'; return typeof WebSocket !== 'undefined';
} }
async start(ctx: BackendContext): Promise<void> { async start(ctx: BackendContext): Promise<void> {
this.ctx = ctx; this.ctx = ctx;
this.lastSent = new Float32Array(ctx.outputCount).fill(-1); this.resetLastSent(ctx.outputCount);
if (!this.isAvailable()) { if (!this.isAvailable()) {
this.setStatus({ state: 'unavailable', message: 'WebSocket not available' }); this.setStatus({ state: 'unavailable', message: 'WebSocket not available' });
return; return;
@ -125,13 +126,6 @@ export class VcvBackend implements OutputBackend {
}); });
} }
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. */ /** Update per-output VCV specs (polarity) + bridge URL/raw toggle. */
setVcvConfig(specs: VcvSpec[], cfg: VcvBackendConfig): void { setVcvConfig(specs: VcvSpec[], cfg: VcvBackendConfig): void {
this.specs = specs; this.specs = specs;
@ -173,9 +167,7 @@ export class VcvBackend implements OutputBackend {
const ctx = this.ctx; const ctx = this.ctx;
if (!ctx || !this.client.connected) return; if (!ctx || !this.client.connected) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (this.throttled(SEND_INTERVAL_MS)) return;
if (now - this.lastSendMs < SEND_INTERVAL_MS) return;
this.lastSendMs = now;
// 1) Stream the current input vector so the browser drives the module. // 1) Stream the current input vector so the browser drives the module.
// Dead-zone over the FULL vector — any axis moving (not just the first // Dead-zone over the FULL vector — any axis moving (not just the first
@ -240,20 +232,6 @@ export class VcvBackend implements OutputBackend {
this.gotModuleReply = false; this.gotModuleReply = false;
this.setStatus({ state: 'idle', message: 'VCV idle' }); 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). */ /** Sanitise an output name into an OSC-path-safe token (the bridge prefixes it). */