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

220 lines
8.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';
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 };
/**
fix(manifold): full-width input vector, dropped switches, MIDI churn, re-subscribe churn Phase 2 (S10, L19, L18, L24). - S10: EngineApi.inputVector() returned a freshly allocated [lastRawX, lastRawY] on every spine tick, so VCV bridged mode silently truncated gamepad/MIDI input to 2-D while the spine already held the full N-dim raw vector. It now returns spine.lastRawInputs (ArrayLike<number>, documented as a live reused buffer — copy, don't retain; VcvBackend already copies), and VcvBackend tracks and dead-zones the full length. Audit correction: "32-input head" is not a constant — 32 is DEFAULT_MODE_ML.inputSize, the over-provisioned default before any mode is chosen; real per-mode widths come from the schemas. - L19: BackendManager.setActive silently dropped a switch requested while another was in flight. Now stores the latest requested id and re-runs it in the finally block (latest-caller-wins). - L18: MIDI CC messages triggered a React state update plus a snapshot allocation each. notifyBindings now fires only when the binding LIST changes. - L24: two ConsoleApp global-listener effects had no dependency array and so re-subscribed on every render, including every pointer frame. Both now read through a single ref assigned in the render body, matching the existing onMoveRef pattern. Audit correction: its suggested `[inputs]` dep would not have worked — useInputLayer returns a fresh object literal each call, so that dep changes every render too. Regression tests: input-vector-truncation.test.ts, backend-manager-switch.test.ts (a fake backend whose start() is held open, to make the in-flight switch real), midi-notify-churn.test.ts (fail-before confirmed: 51 notifications vs 1). L24 has no test — this repo has no DOM render harness to count re-subscriptions against a mounted component; verified by reading and reference-stability tracing. ALSO: manifold/package.json's test script named its test files explicitly ("bun test src tests/pipeline-golden.test.ts"), so the three new files were not run by `bun run test` or CI — regression tests that never execute. Now a glob. Deliberately `tests/*.test.ts` rather than `tests`: bun's discovery matches *.spec.ts too, which would drag the Playwright e2e specs into the unit run (verified — it fails). Unit tests go 9 -> 17. Gates: run-all-tests.sh ALL GREEN.
2026-07-21 13:22:38 +02:00
* 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.
*/
fix(manifold): full-width input vector, dropped switches, MIDI churn, re-subscribe churn Phase 2 (S10, L19, L18, L24). - S10: EngineApi.inputVector() returned a freshly allocated [lastRawX, lastRawY] on every spine tick, so VCV bridged mode silently truncated gamepad/MIDI input to 2-D while the spine already held the full N-dim raw vector. It now returns spine.lastRawInputs (ArrayLike<number>, documented as a live reused buffer — copy, don't retain; VcvBackend already copies), and VcvBackend tracks and dead-zones the full length. Audit correction: "32-input head" is not a constant — 32 is DEFAULT_MODE_ML.inputSize, the over-provisioned default before any mode is chosen; real per-mode widths come from the schemas. - L19: BackendManager.setActive silently dropped a switch requested while another was in flight. Now stores the latest requested id and re-runs it in the finally block (latest-caller-wins). - L18: MIDI CC messages triggered a React state update plus a snapshot allocation each. notifyBindings now fires only when the binding LIST changes. - L24: two ConsoleApp global-listener effects had no dependency array and so re-subscribed on every render, including every pointer frame. Both now read through a single ref assigned in the render body, matching the existing onMoveRef pattern. Audit correction: its suggested `[inputs]` dep would not have worked — useInputLayer returns a fresh object literal each call, so that dep changes every render too. Regression tests: input-vector-truncation.test.ts, backend-manager-switch.test.ts (a fake backend whose start() is held open, to make the in-flight switch real), midi-notify-churn.test.ts (fail-before confirmed: 51 notifications vs 1). L24 has no test — this repo has no DOM render harness to count re-subscriptions against a mounted component; verified by reading and reference-stability tracing. ALSO: manifold/package.json's test script named its test files explicitly ("bun test src tests/pipeline-golden.test.ts"), so the three new files were not run by `bun run test` or CI — regression tests that never execute. Now a glob. Deliberately `tests/*.test.ts` rather than `tests`: bun's discovery matches *.spec.ts too, which would drag the Playwright e2e specs into the unit run (verified — it fails). Unit tests go 9 -> 17. Gates: run-all-tests.sh ALL GREEN.
2026-07-21 13:22:38 +02:00
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;
fix(manifold): full-width input vector, dropped switches, MIDI churn, re-subscribe churn Phase 2 (S10, L19, L18, L24). - S10: EngineApi.inputVector() returned a freshly allocated [lastRawX, lastRawY] on every spine tick, so VCV bridged mode silently truncated gamepad/MIDI input to 2-D while the spine already held the full N-dim raw vector. It now returns spine.lastRawInputs (ArrayLike<number>, documented as a live reused buffer — copy, don't retain; VcvBackend already copies), and VcvBackend tracks and dead-zones the full length. Audit correction: "32-input head" is not a constant — 32 is DEFAULT_MODE_ML.inputSize, the over-provisioned default before any mode is chosen; real per-mode widths come from the schemas. - L19: BackendManager.setActive silently dropped a switch requested while another was in flight. Now stores the latest requested id and re-runs it in the finally block (latest-caller-wins). - L18: MIDI CC messages triggered a React state update plus a snapshot allocation each. notifyBindings now fires only when the binding LIST changes. - L24: two ConsoleApp global-listener effects had no dependency array and so re-subscribed on every render, including every pointer frame. Both now read through a single ref assigned in the render body, matching the existing onMoveRef pattern. Audit correction: its suggested `[inputs]` dep would not have worked — useInputLayer returns a fresh object literal each call, so that dep changes every render too. Regression tests: input-vector-truncation.test.ts, backend-manager-switch.test.ts (a fake backend whose start() is held open, to make the in-flight switch real), midi-notify-churn.test.ts (fail-before confirmed: 51 notifications vs 1). L24 has no test — this repo has no DOM render harness to count re-subscriptions against a mounted component; verified by reading and reference-stability tracing. ALSO: manifold/package.json's test script named its test files explicitly ("bun test src tests/pipeline-golden.test.ts"), so the three new files were not run by `bun run test` or CI — regression tests that never execute. Now a glob. Deliberately `tests/*.test.ts` rather than `tests`: bun's discovery matches *.spec.ts too, which would drag the Playwright e2e specs into the unit run (verified — it fails). Unit tests go 9 -> 17. Gates: run-all-tests.sh ALL GREEN.
2026-07-21 13:22:38 +02:00
/** 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.
fix(manifold): full-width input vector, dropped switches, MIDI churn, re-subscribe churn Phase 2 (S10, L19, L18, L24). - S10: EngineApi.inputVector() returned a freshly allocated [lastRawX, lastRawY] on every spine tick, so VCV bridged mode silently truncated gamepad/MIDI input to 2-D while the spine already held the full N-dim raw vector. It now returns spine.lastRawInputs (ArrayLike<number>, documented as a live reused buffer — copy, don't retain; VcvBackend already copies), and VcvBackend tracks and dead-zones the full length. Audit correction: "32-input head" is not a constant — 32 is DEFAULT_MODE_ML.inputSize, the over-provisioned default before any mode is chosen; real per-mode widths come from the schemas. - L19: BackendManager.setActive silently dropped a switch requested while another was in flight. Now stores the latest requested id and re-runs it in the finally block (latest-caller-wins). - L18: MIDI CC messages triggered a React state update plus a snapshot allocation each. notifyBindings now fires only when the binding LIST changes. - L24: two ConsoleApp global-listener effects had no dependency array and so re-subscribed on every render, including every pointer frame. Both now read through a single ref assigned in the render body, matching the existing onMoveRef pattern. Audit correction: its suggested `[inputs]` dep would not have worked — useInputLayer returns a fresh object literal each call, so that dep changes every render too. Regression tests: input-vector-truncation.test.ts, backend-manager-switch.test.ts (a fake backend whose start() is held open, to make the in-flight switch real), midi-notify-churn.test.ts (fail-before confirmed: 51 notifications vs 1). L24 has no test — this repo has no DOM render harness to count re-subscriptions against a mounted component; verified by reading and reference-stability tracing. ALSO: manifold/package.json's test script named its test files explicitly ("bun test src tests/pipeline-golden.test.ts"), so the three new files were not run by `bun run test` or CI — regression tests that never execute. Now a glob. Deliberately `tests/*.test.ts` rather than `tests`: bun's discovery matches *.spec.ts too, which would drag the Playwright e2e specs into the unit run (verified — it fails). Unit tests go 9 -> 17. Gates: run-all-tests.sh ALL GREEN.
2026-07-21 13:22:38 +02:00
*
* 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> {
fix(manifold): full-width input vector, dropped switches, MIDI churn, re-subscribe churn Phase 2 (S10, L19, L18, L24). - S10: EngineApi.inputVector() returned a freshly allocated [lastRawX, lastRawY] on every spine tick, so VCV bridged mode silently truncated gamepad/MIDI input to 2-D while the spine already held the full N-dim raw vector. It now returns spine.lastRawInputs (ArrayLike<number>, documented as a live reused buffer — copy, don't retain; VcvBackend already copies), and VcvBackend tracks and dead-zones the full length. Audit correction: "32-input head" is not a constant — 32 is DEFAULT_MODE_ML.inputSize, the over-provisioned default before any mode is chosen; real per-mode widths come from the schemas. - L19: BackendManager.setActive silently dropped a switch requested while another was in flight. Now stores the latest requested id and re-runs it in the finally block (latest-caller-wins). - L18: MIDI CC messages triggered a React state update plus a snapshot allocation each. notifyBindings now fires only when the binding LIST changes. - L24: two ConsoleApp global-listener effects had no dependency array and so re-subscribed on every render, including every pointer frame. Both now read through a single ref assigned in the render body, matching the existing onMoveRef pattern. Audit correction: its suggested `[inputs]` dep would not have worked — useInputLayer returns a fresh object literal each call, so that dep changes every render too. Regression tests: input-vector-truncation.test.ts, backend-manager-switch.test.ts (a fake backend whose start() is held open, to make the in-flight switch real), midi-notify-churn.test.ts (fail-before confirmed: 51 notifications vs 1). L24 has no test — this repo has no DOM render harness to count re-subscriptions against a mounted component; verified by reading and reference-stability tracing. ALSO: manifold/package.json's test script named its test files explicitly ("bun test src tests/pipeline-golden.test.ts"), so the three new files were not run by `bun run test` or CI — regression tests that never execute. Now a glob. Deliberately `tests/*.test.ts` rather than `tests`: bun's discovery matches *.spec.ts too, which would drag the Playwright e2e specs into the unit run (verified — it fails). Unit tests go 9 -> 17. Gates: run-all-tests.sh ALL GREEN.
2026-07-21 13:22:38 +02:00
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);
fix(manifold): full-width input vector, dropped switches, MIDI churn, re-subscribe churn Phase 2 (S10, L19, L18, L24). - S10: EngineApi.inputVector() returned a freshly allocated [lastRawX, lastRawY] on every spine tick, so VCV bridged mode silently truncated gamepad/MIDI input to 2-D while the spine already held the full N-dim raw vector. It now returns spine.lastRawInputs (ArrayLike<number>, documented as a live reused buffer — copy, don't retain; VcvBackend already copies), and VcvBackend tracks and dead-zones the full length. Audit correction: "32-input head" is not a constant — 32 is DEFAULT_MODE_ML.inputSize, the over-provisioned default before any mode is chosen; real per-mode widths come from the schemas. - L19: BackendManager.setActive silently dropped a switch requested while another was in flight. Now stores the latest requested id and re-runs it in the finally block (latest-caller-wins). - L18: MIDI CC messages triggered a React state update plus a snapshot allocation each. notifyBindings now fires only when the binding LIST changes. - L24: two ConsoleApp global-listener effects had no dependency array and so re-subscribed on every render, including every pointer frame. Both now read through a single ref assigned in the render body, matching the existing onMoveRef pattern. Audit correction: its suggested `[inputs]` dep would not have worked — useInputLayer returns a fresh object literal each call, so that dep changes every render too. Regression tests: input-vector-truncation.test.ts, backend-manager-switch.test.ts (a fake backend whose start() is held open, to make the in-flight switch real), midi-notify-churn.test.ts (fail-before confirmed: 51 notifications vs 1). L24 has no test — this repo has no DOM render harness to count re-subscriptions against a mounted component; verified by reading and reference-stability tracing. ALSO: manifold/package.json's test script named its test files explicitly ("bun test src tests/pipeline-golden.test.ts"), so the three new files were not run by `bun run test` or CI — regression tests that never execute. Now a glob. Deliberately `tests/*.test.ts` rather than `tests`: bun's discovery matches *.spec.ts too, which would drag the Playwright e2e specs into the unit run (verified — it fails). Unit tests go 9 -> 17. Gates: run-all-tests.sh ALL GREEN.
2026-07-21 13:22:38 +02:00
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;
fix(manifold): full-width input vector, dropped switches, MIDI churn, re-subscribe churn Phase 2 (S10, L19, L18, L24). - S10: EngineApi.inputVector() returned a freshly allocated [lastRawX, lastRawY] on every spine tick, so VCV bridged mode silently truncated gamepad/MIDI input to 2-D while the spine already held the full N-dim raw vector. It now returns spine.lastRawInputs (ArrayLike<number>, documented as a live reused buffer — copy, don't retain; VcvBackend already copies), and VcvBackend tracks and dead-zones the full length. Audit correction: "32-input head" is not a constant — 32 is DEFAULT_MODE_ML.inputSize, the over-provisioned default before any mode is chosen; real per-mode widths come from the schemas. - L19: BackendManager.setActive silently dropped a switch requested while another was in flight. Now stores the latest requested id and re-runs it in the finally block (latest-caller-wins). - L18: MIDI CC messages triggered a React state update plus a snapshot allocation each. notifyBindings now fires only when the binding LIST changes. - L24: two ConsoleApp global-listener effects had no dependency array and so re-subscribed on every render, including every pointer frame. Both now read through a single ref assigned in the render body, matching the existing onMoveRef pattern. Audit correction: its suggested `[inputs]` dep would not have worked — useInputLayer returns a fresh object literal each call, so that dep changes every render too. Regression tests: input-vector-truncation.test.ts, backend-manager-switch.test.ts (a fake backend whose start() is held open, to make the in-flight switch real), midi-notify-churn.test.ts (fail-before confirmed: 51 notifications vs 1). L24 has no test — this repo has no DOM render harness to count re-subscriptions against a mounted component; verified by reading and reference-stability tracing. ALSO: manifold/package.json's test script named its test files explicitly ("bun test src tests/pipeline-golden.test.ts"), so the three new files were not run by `bun run test` or CI — regression tests that never execute. Now a glob. Deliberately `tests/*.test.ts` rather than `tests`: bun's discovery matches *.spec.ts too, which would drag the Playwright e2e specs into the unit run (verified — it fails). Unit tests go 9 -> 17. Gates: run-all-tests.sh ALL GREEN.
2026-07-21 13:22:38 +02:00
// 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);
}
}