memlnaut-nisps/manifold/src/backends/manager.ts

177 lines
6.3 KiB
TypeScript
Raw Normal View History

/**
* 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';
/** 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 control input vector (2-D for the fixed 2N MLP). Optional when
* present the VCV backend streams it to the module so the browser drives the
* module's inputs in bridged mode.
*/
inputVector?(): ReadonlyArray<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;
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 PassthroughBackend('cvgate', 'CV / gate (via VCV bridge)')],
['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) this.active.send(routed);
});
}
/** 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;
}
/**
* 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;
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.
*/
async setActive(id: BackendId): Promise<void> {
if (this.activeId === id || this.switching) 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) {
this.switching = false;
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;
}
}
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);
}
}