memlnaut-nisps/manifold/src/backends/manager.ts
2026-07-25 15:16:37 +02:00

219 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* BackendManager — the single consumer of the engine spine that forwards each
* routed output vector to the ACTIVE output backend (backends-spec §0§1).
*
* It is a CONSUMER of the engine, exactly like the stage / dock: it
* `engine.subscribe()`s and reads `engine.routedOutput()` on each bump, then
* calls `activeBackend.send(routed)`. It NEVER modifies the engine.
*
* Audio gating: the Built-in Synth plays inside the engine (EngineHost pushes
* params to the worklet on every spine tick — see engine-api.ts `send`). So
* selecting MIDI / OSC / Particle would ALSO blast the synth. The manager gates
* this with `engine.audio.setMuted(true)` on every non-synth mode and
* `setMuted(false)` on the synth mode. (Cleanly gating the worklet push without
* an engine change isn't possible — muting is the documented approach,
* backends-spec §1 / task constraint.)
*
* Framework-neutral: no React. A thin hook (useBackendManager.ts) exposes status.
*/
import type { BackendContext, BackendStatus, OutputBackend } from './backend';
import type { BackendId } from '../dock/output-state';
import { WebMidiBackend } from './midi-backend';
import { OscBridgeBackend } from './osc-backend';
import { PassthroughBackend } from './passthrough-backend';
import { ParticleBackend } from './particle-backend';
import { VcvBackend, type VcvFeedbackOp } from './vcv-backend';
import { UseqCvBackend } from './cv-backend';
/** The slice of EngineApi the manager depends on (keeps it decoupled/testable). */
export interface ManagerEngine {
subscribe(cb: () => void): () => void;
routedOutput(): Float32Array | null;
audio: { setMuted(muted: boolean): void };
/**
* Current FULL-width control input vector (one entry per active input axis
* — NOT fixed at 2-D). Optional — when present the VCV backend streams it
* to the module so the browser drives the module's inputs in bridged mode.
* May be a live reused buffer (ArrayLike) — the manager copies it straight
* through to `VcvBackend.setInputVector`, which itself copies.
*/
inputVector?(): ArrayLike<number>;
}
export class BackendManager {
private engine: ManagerEngine;
private backends: Map<BackendId, OutputBackend>;
private active: OutputBackend | null = null;
private activeId: BackendId | null = null;
private ctx: BackendContext | null = null;
private unsub: (() => void) | null = null;
private switching = false;
/** Latest id requested while a switch was already in flight (simplification
* audit L19) — re-run once the in-flight switch settles, so a rapid double
* click no longer silently drops the second request. Cleared before the
* re-run so it can only ever chain one hop at a time (no unbounded loop). */
private pendingId: BackendId | null = null;
/** Reused active-prefix buffer when model capacity exceeds active cards. */
private routedScratch = new Float32Array(0);
private statusListeners = new Set<(id: BackendId, s: BackendStatus) => void>();
private offBackendStatus: (() => void) | null = null;
constructor(engine: ManagerEngine, backends?: Partial<Record<BackendId, OutputBackend>>) {
this.engine = engine;
this.backends = new Map<BackendId, OutputBackend>([
['midi', backends?.midi ?? new WebMidiBackend()],
['osc', backends?.osc ?? new OscBridgeBackend()],
['synth', backends?.synth ?? new PassthroughBackend('synth', 'Built-in Synth — audio plays in the engine')],
['particles', backends?.particles ?? new ParticleBackend()],
['cvgate', backends?.cvgate ?? new UseqCvBackend()],
['vcv', backends?.vcv ?? new VcvBackend()],
]);
// Single subscription to the spine: forward routed → active backend. For the
// VCV backend we also stream the current input vector each tick so the
// browser drives the module's inputs in bridged mode.
this.unsub = this.engine.subscribe(() => {
if (!this.active) return;
if (this.active instanceof VcvBackend && this.engine.inputVector) {
this.active.setInputVector(this.engine.inputVector());
}
const routed = this.engine.routedOutput();
if (!routed) return;
const activeCount = this.ctx?.outputCount ?? routed.length;
if (activeCount >= routed.length) {
this.active.send(routed);
} else {
if (this.routedScratch.length !== activeCount) {
this.routedScratch = new Float32Array(activeCount);
}
this.routedScratch.set(routed.subarray(0, activeCount));
this.active.send(this.routedScratch);
}
});
}
/** Typed handle to a concrete backend (for the dock's per-backend config). */
midi(): WebMidiBackend | null {
const b = this.backends.get('midi');
return b instanceof WebMidiBackend ? b : null;
}
osc(): OscBridgeBackend | null {
const b = this.backends.get('osc');
return b instanceof OscBridgeBackend ? b : null;
}
vcv(): VcvBackend | null {
const b = this.backends.get('vcv');
return b instanceof VcvBackend ? b : null;
}
cv(): UseqCvBackend | null {
const b = this.backends.get('cvgate');
return b instanceof UseqCvBackend ? b : null;
}
/**
* Forward a verdict op to the VCV module's embedded learner over the bridge.
* No-op unless the VCV backend is the ACTIVE one — the verdict loop only
* trains the module when Mode = VCV (otherwise the browser engine is the
* learner). Returns true if it was forwarded.
*/
forwardFeedback(op: VcvFeedbackOp): boolean {
if (this.activeId !== 'vcv') return false;
const vcv = this.vcv();
if (!vcv) return false;
vcv.sendFeedback(op);
return true;
}
get(id: BackendId): OutputBackend | undefined {
return this.backends.get(id);
}
getActiveId(): BackendId | null {
return this.activeId;
}
/** Provide / refresh the BackendContext (mappings + names) for the active set. */
setContext(ctx: BackendContext): void {
this.ctx = ctx;
if (this.routedScratch.length !== ctx.outputCount) {
this.routedScratch = new Float32Array(ctx.outputCount);
}
this.active?.setContext?.(ctx);
}
/**
* Switch the active backend. Tears down the old, starts the new, and applies
* the synth audio gate. Idempotent for the same id.
*
* If a switch is already in flight, the request is NOT dropped: it is
* remembered as `pendingId` (the latest caller wins) and re-run once the
* in-flight switch's `finally` settles — see below.
*/
async setActive(id: BackendId): Promise<void> {
if (this.activeId === id) return;
if (this.switching) {
this.pendingId = id;
return;
}
this.switching = true;
try {
// Gate audio: only the synth mode drives sound.
this.engine.audio.setMuted(id !== 'synth');
const next = this.backends.get(id);
if (!next) return;
if (this.active) {
this.offBackendStatus?.();
this.offBackendStatus = null;
await this.active.teardown();
}
this.active = next;
this.activeId = id;
this.offBackendStatus = next.onStatusChange?.((s) => this.emitStatus(id, s)) ?? null;
if (this.ctx) {
next.setContext?.(this.ctx);
await next.start(this.ctx);
}
this.emitStatus(id, next.status());
} finally {
this.switching = false;
// A newer request arrived mid-switch — chase it. Clearing `pendingId`
// BEFORE the recursive call (rather than in it) bounds this to one hop
// per settled switch: the only way to chain further is another NEW
// request arriving during that hop, which is the intended behaviour,
// not an infinite loop.
const pending = this.pendingId;
this.pendingId = null;
if (pending !== null && pending !== this.activeId) {
void this.setActive(pending);
}
}
}
status(id?: BackendId): BackendStatus {
const b = id ? this.backends.get(id) : this.active;
return b?.status() ?? { state: 'idle', message: 'idle' };
}
onStatusChange(cb: (id: BackendId, s: BackendStatus) => void): () => void {
this.statusListeners.add(cb);
return () => this.statusListeners.delete(cb);
}
dispose(): void {
this.unsub?.();
this.unsub = null;
this.offBackendStatus?.();
if (this.active) void this.active.teardown();
this.active = null;
this.activeId = null;
}
private emitStatus(id: BackendId, s: BackendStatus): void {
for (const cb of this.statusListeners) cb(id, s);
}
}