2026-06-28 03:28:45 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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';
|
2026-06-28 04:22:53 +02:00
|
|
|
|
import { ParticleBackend } from './particle-backend';
|
2026-06-28 04:22:05 +02:00
|
|
|
|
import { VcvBackend, type VcvFeedbackOp } from './vcv-backend';
|
2026-06-28 21:28:03 +02:00
|
|
|
|
import { UseqCvBackend } from './cv-backend';
|
2026-06-28 03:28:45 +02:00
|
|
|
|
|
|
|
|
|
|
/** 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 };
|
2026-06-28 04:22:05 +02:00
|
|
|
|
/**
|
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.
|
2026-06-28 04:22:05 +02:00
|
|
|
|
*/
|
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>;
|
2026-06-28 03:28:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-28 03:28:45 +02:00
|
|
|
|
|
|
|
|
|
|
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')],
|
2026-06-28 04:22:53 +02:00
|
|
|
|
['particles', backends?.particles ?? new ParticleBackend()],
|
2026-06-28 21:28:03 +02:00
|
|
|
|
['cvgate', backends?.cvgate ?? new UseqCvBackend()],
|
2026-06-28 04:22:05 +02:00
|
|
|
|
['vcv', backends?.vcv ?? new VcvBackend()],
|
2026-06-28 03:28:45 +02:00
|
|
|
|
]);
|
|
|
|
|
|
|
2026-06-28 04:22:05 +02:00
|
|
|
|
// 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.
|
2026-06-28 03:28:45 +02:00
|
|
|
|
this.unsub = this.engine.subscribe(() => {
|
|
|
|
|
|
if (!this.active) return;
|
2026-06-28 04:22:05 +02:00
|
|
|
|
if (this.active instanceof VcvBackend && this.engine.inputVector) {
|
|
|
|
|
|
this.active.setInputVector(this.engine.inputVector());
|
|
|
|
|
|
}
|
2026-06-28 03:28:45 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 04:22:05 +02:00
|
|
|
|
vcv(): VcvBackend | null {
|
|
|
|
|
|
const b = this.backends.get('vcv');
|
|
|
|
|
|
return b instanceof VcvBackend ? b : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 21:28:03 +02:00
|
|
|
|
cv(): UseqCvBackend | null {
|
|
|
|
|
|
const b = this.backends.get('cvgate');
|
|
|
|
|
|
return b instanceof UseqCvBackend ? b : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 04:22:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 03:28:45 +02:00
|
|
|
|
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.
|
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.
|
2026-06-28 03:28:45 +02:00
|
|
|
|
*/
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-28 03:28:45 +02:00
|
|
|
|
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;
|
2026-06-28 03:28:45 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-06-28 03:28:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|