144 lines
6 KiB
TypeScript
144 lines
6 KiB
TypeScript
|
|
/**
|
||
|
|
* exploration.ts — ExplorationController: wires the two playground exploration
|
||
|
|
* gestures onto manifold's EngineApi.
|
||
|
|
*
|
||
|
|
* • Jolt — hold to continuously morph the network's weights live, release to
|
||
|
|
* freeze (nisps/ml/jolt.hpp; firmware TogB1).
|
||
|
|
* • Explore — an Ornstein-Uhlenbeck random walk on the output that makes the
|
||
|
|
* sound slowly roam so likes/dislikes can steer it (nisps/ml/
|
||
|
|
* ou_noise.hpp; firmware RVX1 = exploration amount).
|
||
|
|
*
|
||
|
|
* The gestures the playground drove from `mode-runtime.ts` are re-homed here as a
|
||
|
|
* plain framework-neutral class (no Solid stores, no React) that the Console owns
|
||
|
|
* one of, once the engine resolves. The Learning drawer's Jolt button + Explore
|
||
|
|
* slider call these methods.
|
||
|
|
*
|
||
|
|
* ─── P3 SWAP POINT ────────────────────────────────────────────────────────────
|
||
|
|
* The maths currently runs in TS (`jolt.ts` / `ou-explore.ts`, ported from the
|
||
|
|
* retired playground). In §P3 of docs/specs/plans/one-core-engine-refactor.md it
|
||
|
|
* moves into the C++/WASM core:
|
||
|
|
*
|
||
|
|
* joltPress(count) → nisps_ml_jolt_press
|
||
|
|
* joltRelease() → nisps_ml_jolt_release
|
||
|
|
* setExploreIntensity(l) → nisps_ml_explore_intensity
|
||
|
|
*
|
||
|
|
* When that lands, ONLY THIS MODULE changes: `jolt.ts` + `ou-explore.ts` are
|
||
|
|
* deleted, the per-tick stepping below (getWeights → step → setWeights → process,
|
||
|
|
* and the OU output-morph) is removed because the core owns the control-rate
|
||
|
|
* morph + OU advance, and the calls above target the bindings. The UI keeps
|
||
|
|
* calling the same `joltPress/joltRelease/joltActive/setExploreIntensity/
|
||
|
|
* exploreIntensity` surface — no drawer changes.
|
||
|
|
* ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { EngineApi } from './engine-api';
|
||
|
|
import { Jolt } from './jolt';
|
||
|
|
import { OUExplore } from './ou-explore';
|
||
|
|
|
||
|
|
// Control-rate cadences, reproduced from the playground's mode-runtime.ts.
|
||
|
|
// Manifold has no always-on control-rate loop to hook (the spine is push-driven;
|
||
|
|
// the input-layer rAF only runs while a poll-based source is active), so — like
|
||
|
|
// the playground — each gesture owns a scoped interval that exists ONLY while it
|
||
|
|
// is engaged and is torn down on release / at intensity 0. Not a global timer.
|
||
|
|
/** ~200Hz — matches the upstream firmware control rate the Jolt constants assume. */
|
||
|
|
const JOLT_TICK_MS = 5;
|
||
|
|
/** ~33Hz — keeps the OU walk roaming when the input is static. */
|
||
|
|
const EXPLORE_TICK_MS = 30;
|
||
|
|
|
||
|
|
export class ExplorationController {
|
||
|
|
private readonly engine: EngineApi;
|
||
|
|
private readonly jolt = new Jolt();
|
||
|
|
private readonly ou = new OUExplore();
|
||
|
|
|
||
|
|
private joltTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
|
private exploreTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
|
|
||
|
|
constructor(engine: EngineApi) {
|
||
|
|
this.engine = engine;
|
||
|
|
// Register the OU walk as the spine's post-output morph. It is inert while
|
||
|
|
// intensity is 0 (apply() early-returns), so the spine stays parity-safe
|
||
|
|
// until Explore is turned up. Applied on EVERY spine tick (user input or the
|
||
|
|
// EXPLORE_TICK_MS driver), exactly as the playground applied it in
|
||
|
|
// recomputeOutputs. In P3 this becomes a core-side step, not a JS morph.
|
||
|
|
engine.spine.setOutputMorph((routed) => this.ou.apply(routed));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Jolt (held-button continuous weight morph) --------------------------
|
||
|
|
|
||
|
|
joltActive(): boolean {
|
||
|
|
return this.jolt.active();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Press-and-hold on: begin morphing a scatter of weights toward random targets. */
|
||
|
|
joltPress(): void {
|
||
|
|
if (!this.engine.getState().ready) return;
|
||
|
|
const weightCount = this.engine.getWeights().length;
|
||
|
|
this.jolt.press(weightCount);
|
||
|
|
if (this.joltTimer === null) {
|
||
|
|
this.joltTimer = setInterval(() => this.tickJolt_(), JOLT_TICK_MS);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Release: freeze the weights where they landed (permanent) and stop ticking. */
|
||
|
|
joltRelease(): void {
|
||
|
|
this.jolt.release();
|
||
|
|
if (this.joltTimer !== null) {
|
||
|
|
clearInterval(this.joltTimer);
|
||
|
|
this.joltTimer = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private tickJolt_(): void {
|
||
|
|
if (!this.jolt.active()) return;
|
||
|
|
const w = this.engine.getWeights();
|
||
|
|
if (w.length === 0) return;
|
||
|
|
this.jolt.step(w);
|
||
|
|
this.engine.setWeights(w);
|
||
|
|
// Re-run inference so audio + visuals reflect the morphed weights without
|
||
|
|
// the user having to move the controller.
|
||
|
|
this.engine.process();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Explore (OU exploration noise on the output) ------------------------
|
||
|
|
|
||
|
|
exploreIntensity(): number {
|
||
|
|
return this.ou.intensity();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set the exploration amount in [0,1]. >0 starts the roaming driver so the
|
||
|
|
* sound keeps wandering even when the input is static; 0 stops it and resets
|
||
|
|
* the walk so the output passes through cleanly again.
|
||
|
|
*/
|
||
|
|
setExploreIntensity(level: number): void {
|
||
|
|
this.ou.setIntensity(level);
|
||
|
|
if (this.ou.enabled() && this.exploreTimer === null) {
|
||
|
|
this.exploreTimer = setInterval(() => {
|
||
|
|
if (!this.engine.getState().ready) return;
|
||
|
|
// Re-tick the last input through the spine; the registered output morph
|
||
|
|
// advances the OU state and reships the routed vector.
|
||
|
|
this.engine.process();
|
||
|
|
}, EXPLORE_TICK_MS);
|
||
|
|
} else if (!this.ou.enabled() && this.exploreTimer !== null) {
|
||
|
|
clearInterval(this.exploreTimer);
|
||
|
|
this.exploreTimer = null;
|
||
|
|
this.ou.reset();
|
||
|
|
// Flush the now-clean output (no residual drift) to audio + visuals.
|
||
|
|
this.engine.process();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Lifecycle -----------------------------------------------------------
|
||
|
|
|
||
|
|
dispose(): void {
|
||
|
|
this.joltRelease();
|
||
|
|
if (this.exploreTimer !== null) {
|
||
|
|
clearInterval(this.exploreTimer);
|
||
|
|
this.exploreTimer = null;
|
||
|
|
}
|
||
|
|
this.ou.setIntensity(0);
|
||
|
|
this.ou.reset();
|
||
|
|
this.engine.spine.setOutputMorph(null);
|
||
|
|
}
|
||
|
|
}
|