From 87daac4f0d29d666038e3454748b2110e66d68bc Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Tue, 21 Jul 2026 14:02:48 +0200 Subject: [PATCH] refactor(manifold): extract BaseBackend mirroring the input layer's BaseSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- manifold/src/backends/README.md | 3 + manifold/src/backends/base-backend.ts | 93 +++++++++++++++++++++++++++ manifold/src/backends/cv-backend.ts | 37 +++-------- manifold/src/backends/midi-backend.ts | 45 ++++--------- manifold/src/backends/osc-backend.ts | 41 +++--------- manifold/src/backends/vcv-backend.ts | 44 ++++--------- 6 files changed, 137 insertions(+), 126 deletions(-) create mode 100644 manifold/src/backends/base-backend.ts diff --git a/manifold/src/backends/README.md b/manifold/src/backends/README.md index b41adde..04a2f40 100644 --- a/manifold/src/backends/README.md +++ b/manifold/src/backends/README.md @@ -12,11 +12,14 @@ See `docs/specs/backends-spec.md` for the authoritative design. | File | Role | |---|---| | `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. | | `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). | | `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. | +| `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). | | `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). | diff --git a/manifold/src/backends/base-backend.ts b/manifold/src/backends/base-backend.ts new file mode 100644 index 0000000..c1e8d27 --- /dev/null +++ b/manifold/src/backends/base-backend.ts @@ -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; + abstract send(routed: Float32Array): void; + abstract teardown(): Promise; + + 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); + } +} diff --git a/manifold/src/backends/cv-backend.ts b/manifold/src/backends/cv-backend.ts index 43fa39c..2b21354 100644 --- a/manifold/src/backends/cv-backend.ts +++ b/manifold/src/backends/cv-backend.ts @@ -17,7 +17,8 @@ * store (output-state.ts CvSpec) and arrives via {@link setCvConfig}, parallel * 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 type { CvChannelId, CvSpec } from '../dock/output-state'; import { @@ -44,10 +45,9 @@ function resolveTarget(id: CvChannelId): Target { 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; - private ctx: BackendContext | null = null; /** Per-output CV specs, index-aligned with ctx.mappings. */ private specs: CvSpec[] = []; /** 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 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 lastGateBits = -1; - private lastSendMs = 0; private writing = false; // in-flight write guard (avoid serial backpressure) private pendingIdentify: (() => void) | null = null; - private statusState: BackendStatus = { state: 'idle', message: 'CV idle' }; - private statusListeners = new Set<(s: BackendStatus) => void>(); + constructor() { + super({ state: 'idle', message: 'CV idle' }); + } isAvailable(): boolean { 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' }); } - setContext(ctx: BackendContext): void { - this.ctx = ctx; - } - /** Update the per-output CV specs (channel + gate threshold). */ setCvConfig(specs: CvSpec[]): void { this.specs = specs; @@ -198,9 +197,7 @@ export class UseqCvBackend implements OutputBackend { const w = this.writer; if (!ctx || !w) return; - const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); - if (now - this.lastSendMs < SEND_INTERVAL_MS) return; - this.lastSendMs = now; + if (this.throttled(SEND_INTERVAL_MS)) return; if (this.writing) return; // previous frame still draining — drop this one // Rebuild the 14-channel snapshot from the routed outputs. @@ -279,18 +276,4 @@ export class UseqCvBackend implements OutputBackend { await this.closePort(); 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); - } } diff --git a/manifold/src/backends/midi-backend.ts b/manifold/src/backends/midi-backend.ts index 9a9bd38..92b9ed7 100644 --- a/manifold/src/backends/midi-backend.ts +++ b/manifold/src/backends/midi-backend.ts @@ -10,10 +10,12 @@ * a parallel `midiSpecs` array set through {@link setMidiConfig}. The port is * picked in the Outputs panel and applied via {@link selectOutput}. * - * No per-frame allocation: the 3-byte message array is reused; `lastSent` - * tracks per-CC values for the dead-zone. + * No per-frame allocation: the 3-byte message array is reused; the inherited + * `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 type { MidiCcSpec } from '../dock/output-state'; @@ -27,7 +29,7 @@ export interface MidiBackendConfig { ccCount: number; } -export class WebMidiBackend implements OutputBackend { +export class WebMidiBackend extends BaseBackend { readonly id = 'midi' as const; private access: MIDIAccess | null = null; @@ -35,16 +37,14 @@ export class WebMidiBackend implements OutputBackend { private outputId: string | null = null; private ccCount = 0; - private ctx: BackendContext | null = null; /** Per-output MIDI specs, index-aligned with ctx.mappings. */ 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 lastSendMs = 0; - private statusState: BackendStatus = { state: 'idle', message: 'MIDI idle' }; - private statusListeners = new Set<(s: BackendStatus) => void>(); + constructor() { + super({ state: 'idle', message: 'MIDI idle' }); + } isAvailable(): boolean { return typeof navigator !== 'undefined' && typeof navigator.requestMIDIAccess === 'function'; @@ -52,7 +52,7 @@ export class WebMidiBackend implements OutputBackend { async start(ctx: BackendContext): Promise { this.ctx = ctx; - this.lastSent = new Int16Array(ctx.outputCount).fill(-1); + this.resetLastSent(ctx.outputCount); if (!this.isAvailable()) { this.setStatus({ state: 'unavailable', message: 'Web MIDI not supported in this browser' }); 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. */ setMidiConfig(specs: MidiCcSpec[], cfg: MidiBackendConfig): void { this.specs = specs; @@ -124,9 +117,7 @@ export class WebMidiBackend implements OutputBackend { const ctx = this.ctx; if (!out || !ctx) return; - const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); - if (now - this.lastSendMs < SEND_INTERVAL_MS) return; - this.lastSendMs = now; + if (this.throttled(SEND_INTERVAL_MS)) return; const n = Math.min(this.ccCount, routed.length, ctx.mappings.length, this.specs.length); for (let i = 0; i < n; i++) { @@ -159,18 +150,4 @@ export class WebMidiBackend implements OutputBackend { this.output = null; 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); - } } diff --git a/manifold/src/backends/osc-backend.ts b/manifold/src/backends/osc-backend.ts index 290cfa6..78fe144 100644 --- a/manifold/src/backends/osc-backend.ts +++ b/manifold/src/backends/osc-backend.ts @@ -12,7 +12,8 @@ * surface "bridge not running" until the WS connects. Throttled to ~50ms with a * 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 { NispsOscClient } from './osc-client'; import type { OscSpec } from '../dock/output-state'; @@ -27,30 +28,29 @@ export interface OscBackendConfig { sendRaw: boolean; } -export class OscBridgeBackend implements OutputBackend { +export class OscBridgeBackend extends BaseBackend { readonly id = 'osc' as const; private client = new NispsOscClient(); - private ctx: BackendContext | null = null; private specs: OscSpec[] = []; private sendRaw = false; - private lastSent: Float32Array = new Float32Array(0); // last normalised value 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 offInfo: (() => void) | null = null; + constructor() { + super({ state: 'idle', message: 'OSC idle' }); + } + isAvailable(): boolean { return typeof WebSocket !== 'undefined'; } async start(ctx: BackendContext): Promise { this.ctx = ctx; - this.lastSent = new Float32Array(ctx.outputCount).fill(-1); + this.resetLastSent(ctx.outputCount); if (!this.isAvailable()) { this.setStatus({ state: 'unavailable', message: 'WebSocket not available' }); 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. */ setOscConfig(specs: OscSpec[], cfg: OscBackendConfig): void { this.specs = specs; @@ -99,9 +92,7 @@ export class OscBridgeBackend implements OutputBackend { 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; + if (this.throttled(SEND_INTERVAL_MS)) return; const n = Math.min(routed.length, ctx.mappings.length, this.specs.length); this.batch.length = 0; @@ -130,18 +121,4 @@ export class OscBridgeBackend implements OutputBackend { this.client.disconnect(); 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); - } } diff --git a/manifold/src/backends/vcv-backend.ts b/manifold/src/backends/vcv-backend.ts index ce05e50..7b6f7df 100644 --- a/manifold/src/backends/vcv-backend.ts +++ b/manifold/src/backends/vcv-backend.ts @@ -30,7 +30,8 @@ * British spelling in product copy; the synth is the "Built-in Synth", never * "C15". */ -import type { BackendContext, BackendStatus, OutputBackend } from './backend'; +import type { BackendContext } from './backend'; +import { BaseBackend } from './base-backend'; import { isSilent, mapOutput } from './mapping'; import { NispsOscClient } from './osc-client'; import type { VcvSpec } from '../dock/output-state'; @@ -56,11 +57,10 @@ export interface VcvBackendConfig { sendRaw: boolean; } -export class VcvBackend implements OutputBackend { +export class VcvBackend extends BaseBackend { readonly id = 'vcv' as const; private client = new NispsOscClient(); - private ctx: BackendContext | null = null; private specs: VcvSpec[] = []; private sendRaw = false; @@ -68,31 +68,32 @@ export class VcvBackend implements OutputBackend { * net's full input arity, not fixed at 2; simplification audit S10). */ 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; /** 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 * 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 gotModuleReply = false; - private statusState: BackendStatus = { state: 'idle', message: 'VCV idle' }; - private statusListeners = new Set<(s: BackendStatus) => void>(); private offConn: (() => void) | null = null; private offInfo: (() => void) | null = null; private offOutputs: (() => void) | null = null; private offInputs: (() => void) | null = null; + constructor() { + super({ state: 'idle', message: 'VCV idle' }); + } + isAvailable(): boolean { return typeof WebSocket !== 'undefined'; } async start(ctx: BackendContext): Promise { this.ctx = ctx; - this.lastSent = new Float32Array(ctx.outputCount).fill(-1); + this.resetLastSent(ctx.outputCount); if (!this.isAvailable()) { this.setStatus({ state: 'unavailable', message: 'WebSocket not available' }); 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. */ setVcvConfig(specs: VcvSpec[], cfg: VcvBackendConfig): void { this.specs = specs; @@ -173,9 +167,7 @@ export class VcvBackend implements OutputBackend { 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; + if (this.throttled(SEND_INTERVAL_MS)) return; // 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 @@ -240,20 +232,6 @@ export class VcvBackend implements OutputBackend { 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). */