From 785935bf2548ade665ecb882d15ee4bf7a6cdc35 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 04:21:22 +0200 Subject: [PATCH 1/3] feat(manifold): add faithful flow-field particle port + ParticleStage view Faithful TypeScript port of the a-immersive visualiser (deployments/meml-aimmersive/js/ui/visualizer.js): 400 particles, identical value-noise + 20-output param mapping + advection/attractor/dispersion/repulsor integration. ParticleStage renders the full-bleed canvas (driven by engine outputs each rAF) plus the macro-axis bar and a corner input pad. --- manifold/src/console/ParticleStage.tsx | 127 +++++++++ manifold/src/console/flow-field.ts | 367 +++++++++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 manifold/src/console/ParticleStage.tsx create mode 100644 manifold/src/console/flow-field.ts diff --git a/manifold/src/console/ParticleStage.tsx b/manifold/src/console/ParticleStage.tsx new file mode 100644 index 0000000..8fc178c --- /dev/null +++ b/manifold/src/console/ParticleStage.tsx @@ -0,0 +1,127 @@ +/** + * ParticleStage — the Particle System output Mode's main view. + * + * Mirrors the a-immersive playground layout: + * • a full-bleed Canvas2D flow-field particle system (the main view), driven + * by the live model outputs (first 20) read each animation frame; + * • a horizontal macro-axis slider bar across the top (Boldness / Memory / + * Precision), the same compound axes the rest of the console uses; + * • a small circular pad in the bottom-left corner that drives the 2D input + * (engine.setInput) — the "joystick" of the immersive app. + * + * The canvas animates on its own rAF clock so particles keep flowing between + * inferences; only the *field* parameters change when the MLP outputs do. + */ +import { useEffect, useRef } from 'react'; +import { useEngine } from '../engine'; +import { ControlAxis } from '../primitives/ControlAxis'; +import { VirtualJoystick } from '../primitives/VirtualJoystick'; +import { FlowFieldVisualizer } from './flow-field'; +import type { Axes } from './types'; + +export interface ParticleStageProps { + pos: [number, number]; + onMove: (x: number, y: number) => void; + axes: Axes; + setAxis: (k: keyof Axes, v: number) => void; +} + +export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps) { + const engine = useEngine(); + const canvasRef = useRef(null); + const vizRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const viz = new FlowFieldVisualizer(canvas); + vizRef.current = viz; + + let raf = 0; + const tick = () => { + const outputs = engine?.getOutputs(); + if (outputs) viz.setParams(outputs); + viz.draw(); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + + const ro = new ResizeObserver(() => viz.resize()); + ro.observe(canvas); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + vizRef.current = null; + }; + }, [engine]); + + return ( +
+ {/* Main view — the flow-field particle system */} + + + {/* Top horizontal macro-axis slider bar (Boldness / Memory / Precision) */} +
+ + MEMLNaut + + setAxis('boldness', v)} + style={{ flex: 1 }} + /> + setAxis('memory', v)} + accent="var(--accent-2)" + style={{ flex: 1 }} + /> + setAxis('precision', v)} + accent="var(--ok, var(--accent))" + style={{ flex: 1 }} + /> +
+ + {/* Bottom-left circular pad — drives the 2D input */} +
+ +
+
+ ); +} diff --git a/manifold/src/console/flow-field.ts b/manifold/src/console/flow-field.ts new file mode 100644 index 0000000..4c2f51a --- /dev/null +++ b/manifold/src/console/flow-field.ts @@ -0,0 +1,367 @@ +/** + * flow-field.ts — Canvas2D flow-field particle system, a faithful TypeScript + * port of the a-immersive playground visualiser + * (`/home/w1n5t0n/deployments/meml-aimmersive/js/ui/visualizer.js`). + * + * FAITHFULNESS (verified 2026-06-28 against the original): + * • 400 particles, identical permutation-table value noise (`PERM`/`noise2D`). + * • The first 20 model outputs (each ∈ [0,1]) map to the visual ranges exactly + * as the original `setParams` (p0..p19 — see the per-line comments below). + * • The advection/attractor/dispersion/repulsor integration in `draw()` is a + * line-for-line port of the original. + * • Deliberate improvements over the original (NOT drift): + * - `resize()` resets the transform before `scale(dpr)` so repeated resizes + * don't compound the device-pixel-ratio scale (the original double-scaled + * on every resize); it also repaints the background so a resize doesn't + * leave stale trails. + * - `draw()` early-returns while the canvas has zero size (pre-layout). + * + * The simulation advances on its own clock, so particles keep flowing between + * inferences — only the *field* changes when the MLP outputs do. + */ + +// Simple value noise (no dependencies) — identical permutation scheme to the +// a-immersive port. The table is shuffled once at module load. +const PERM = new Uint8Array(512); +{ + const p = new Uint8Array(256); + for (let i = 0; i < 256; i++) p[i] = i; + for (let i = 255; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [p[i], p[j]] = [p[j], p[i]]; + } + for (let i = 0; i < 512; i++) PERM[i] = p[i & 255]; +} + +function fade(t: number): number { + return t * t * t * (t * (t * 6 - 15) + 10); +} +function lerp(a: number, b: number, t: number): number { + return a + t * (b - a); +} + +function grad(hash: number, x: number, y: number): number { + const h = hash & 3; + const u = h < 2 ? x : y; + const v = h < 2 ? y : x; + return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); +} + +function noise2D(x: number, y: number): number { + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + const xf = x - Math.floor(x); + const yf = y - Math.floor(y); + const u = fade(xf); + const v = fade(yf); + + const aa = PERM[PERM[X] + Y]; + const ab = PERM[PERM[X] + Y + 1]; + const ba = PERM[PERM[X + 1] + Y]; + const bb = PERM[PERM[X + 1] + Y + 1]; + + return lerp( + lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u), + lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u), + v, + ); +} + +const TWO_PI = Math.PI * 2; + +interface Particle { + x: number; + y: number; + id: number; + age: number; + life: number; + vx: number; + vy: number; +} + +interface FlowParams { + angleOffset: number; + scale: number; + speed: number; + hueBase: number; + hueSpread: number; + particleSize: number; + fadeRate: number; + turbulence: number; + attractStrength: number; + attractRadius: number; + dispersionRate: number; + dispersionAmount: number; + particleLifetime: number; + respawnStyle: number; + advectionMode: number; + inertia: number; + drag: number; + repulsorStrength: number; + repulsorCount: number; + repulsorOrbitRate: number; +} + +export class FlowFieldVisualizer { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private particles: Particle[] = []; + private numParticles = 400; + private time = 0; + private width = 0; + private height = 0; + + private params: FlowParams = { + angleOffset: 0, // p0: flow direction + scale: 0.005, // p1: pattern size + speed: 2, // p2: particle speed + hueBase: 180, // p3: base colour + hueSpread: 60, // p4: colour variation + particleSize: 3, // p5: dot radius + fadeRate: 0.05, // p6: trail length + turbulence: 1, // p7: chaos + attractStrength: 0.8, // p8: pull toward screen centre + attractRadius: 200, // p9: radius where attraction is strongest + dispersionRate: 2.0, // p10: speed of outward dispersion pulses + dispersionAmount: 1.0, // p11: strength of outward dispersion + particleLifetime: 220, // p12: average frames before respawn + respawnStyle: 0.0, // p13: 0=random, 1=edge, 2=center-burst + advectionMode: 0.0, // p14: flow->orbit->radial blend + inertia: 0.2, // p15: velocity memory + drag: 0.02, // p16: velocity damping + repulsorStrength: 0.0, // p17: repulsor force amount + repulsorCount: 0, // p18: number of active repulsors + repulsorOrbitRate: 0.8, // p19: repulsor orbital speed + }; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('FlowFieldVisualizer: 2D context unavailable'); + this.ctx = ctx; + this.resize(); + this.initParticles(); + } + + resize(): void { + const rect = this.canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this.canvas.width = Math.max(1, Math.round(rect.width * dpr)); + this.canvas.height = Math.max(1, Math.round(rect.height * dpr)); + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.scale(dpr, dpr); + this.width = rect.width; + this.height = rect.height; + // Clear to background so a resize doesn't leave stale trails. + this.ctx.fillStyle = '#0d0d0d'; + this.ctx.fillRect(0, 0, this.width, this.height); + } + + private initParticles(): void { + this.particles = []; + for (let i = 0; i < this.numParticles; i++) { + this.particles.push(this.makeParticle(i)); + } + this.ctx.fillStyle = '#0d0d0d'; + this.ctx.fillRect(0, 0, this.width, this.height); + } + + private makeParticle(id: number): Particle { + return { + x: Math.random() * this.width, + y: Math.random() * this.height, + id, + age: Math.floor(Math.random() * this.params.particleLifetime), + life: this.computeLifetime(), + vx: 0, + vy: 0, + }; + } + + private computeLifetime(): number { + const variance = 0.65 + Math.random() * 0.7; + return Math.max(10, Math.floor(this.params.particleLifetime * variance)); + } + + private respawnParticle(p: Particle): void { + const mode = Math.min(2, Math.floor(this.params.respawnStyle * 2.999)); + const { width, height } = this; + + if (mode === 1) { + // Edge respawn + const side = Math.floor(Math.random() * 4); + if (side === 0) { + p.x = Math.random() * width; + p.y = 0; + } + if (side === 1) { + p.x = width; + p.y = Math.random() * height; + } + if (side === 2) { + p.x = Math.random() * width; + p.y = height; + } + if (side === 3) { + p.x = 0; + p.y = Math.random() * height; + } + // Give edge spawns an inward impulse. + const towardCenterX = width * 0.5 - p.x; + const towardCenterY = height * 0.5 - p.y; + const inwardDist = Math.hypot(towardCenterX, towardCenterY) + 1e-6; + p.vx = (towardCenterX / inwardDist) * 2.0; + p.vy = (towardCenterY / inwardDist) * 2.0; + } else if (mode === 2) { + // Center-burst respawn + const angle = Math.random() * TWO_PI; + const r = Math.random() * Math.min(width, height) * 0.08; + p.x = width * 0.5 + Math.cos(angle) * r; + p.y = height * 0.5 + Math.sin(angle) * r; + p.vx = Math.cos(angle) * 2.5; + p.vy = Math.sin(angle) * 2.5; + } else { + // Random respawn + p.x = Math.random() * width; + p.y = Math.random() * height; + p.vx = (Math.random() * 2 - 1) * 0.5; + p.vy = (Math.random() * 2 - 1) * 0.5; + } + + p.age = 0; + p.life = this.computeLifetime(); + } + + /** Set parameters from the model output (first 20 values, each ∈ [0,1]). */ + setParams(outputs: ArrayLike | null | undefined): void { + if (!outputs || outputs.length < 20) return; + this.params.angleOffset = outputs[0] * TWO_PI; + this.params.scale = 0.001 + outputs[1] * 0.009; + this.params.speed = 0.5 + outputs[2] * 4.5; + this.params.hueBase = outputs[3] * 360; + this.params.hueSpread = outputs[4] * 120; + this.params.particleSize = 1 + outputs[5] * 5; + this.params.fadeRate = 0.01 + outputs[6] * 0.14; + this.params.turbulence = outputs[7] * 2; + this.params.attractStrength = 0.1 + outputs[8] * 2.9; + this.params.attractRadius = 40 + outputs[9] * 420; + this.params.dispersionRate = 0.2 + outputs[10] * 8; + this.params.dispersionAmount = outputs[11] * 3; + this.params.particleLifetime = 30 + outputs[12] * 470; + this.params.respawnStyle = outputs[13]; + this.params.advectionMode = outputs[14]; + this.params.inertia = outputs[15] * 0.98; + this.params.drag = outputs[16] * 0.35; + this.params.repulsorStrength = outputs[17] * 4.5; + this.params.repulsorCount = Math.floor(outputs[18] * 4.999); + this.params.repulsorOrbitRate = 0.1 + outputs[19] * 2.9; + } + + draw(): void { + const { ctx, width, height, params } = this; + if (width === 0 || height === 0) return; + this.time += 0.003; + + // Fade existing content (creates trails) + ctx.fillStyle = `rgba(13, 13, 13, ${params.fadeRate})`; + ctx.fillRect(0, 0, width, height); + + for (const p of this.particles) { + const cx = width * 0.5; + const cy = height * 0.5; + + // Sample flow field + const nx = p.x * params.scale; + const ny = p.y * params.scale; + const angle = noise2D(nx + this.time, ny) * TWO_PI + params.angleOffset; + const curl = noise2D(nx + 100, ny + 100 + this.time * 0.5) * params.turbulence; + + // Mix between three advection fields for larger visual mode changes. + const flowVx = Math.cos(angle + curl) * params.speed; + const flowVy = Math.sin(angle + curl) * params.speed; + const fromCenterX = p.x - cx; + const fromCenterY = p.y - cy; + const centerDist = Math.hypot(fromCenterX, fromCenterY) + 1e-6; + const radialX = fromCenterX / centerDist; + const radialY = fromCenterY / centerDist; + const orbitX = -radialY; + const orbitY = radialX; + const orbitVx = orbitX * params.speed; + const orbitVy = orbitY * params.speed; + const radialVx = radialX * params.speed; + const radialVy = radialY * params.speed; + + const modeBlend = params.advectionMode * 2; + let targetVx: number; + let targetVy: number; + if (modeBlend < 1) { + targetVx = lerp(flowVx, orbitVx, modeBlend); + targetVy = lerp(flowVy, orbitVy, modeBlend); + } else { + targetVx = lerp(orbitVx, radialVx, modeBlend - 1); + targetVy = lerp(orbitVy, radialVy, modeBlend - 1); + } + p.vx = p.vx * params.inertia + targetVx * (1 - params.inertia); + p.vy = p.vy * params.inertia + targetVy * (1 - params.inertia); + p.vx *= 1 - params.drag; + p.vy *= 1 - params.drag; + let nextX = p.x + p.vx; + let nextY = p.y + p.vy; + + // Central attractor keeps trajectories from sticking to the outer edges. + const dx = cx - nextX; + const dy = cy - nextY; + const dist = Math.hypot(dx, dy) + 1e-6; + const nxCenter = dx / dist; + const nyCenter = dy / dist; + const normalizedDist = Math.min(dist / params.attractRadius, 2); + const falloff = 1 / (1 + normalizedDist * normalizedDist); + nextX += nxCenter * params.attractStrength * falloff; + nextY += nyCenter * params.attractStrength * falloff; + + // Time-varying dispersion pushes particles outward near the centre. + const dispersionPulse = + 0.5 + 0.5 * Math.sin(this.time * params.dispersionRate + p.id * 0.07); + const dispersionForce = params.dispersionAmount * dispersionPulse * falloff; + nextX -= nxCenter * dispersionForce; + nextY -= nyCenter * dispersionForce; + + // Orbiting repulsor points carve dynamic voids and bursts. + const repulsorRadius = Math.min(width, height) * 0.28; + for (let r = 0; r < params.repulsorCount; r++) { + const phase = this.time * params.repulsorOrbitRate + (r / 4) * TWO_PI; + const wobble = 0.6 + 0.15 * r; + const rx = cx + Math.cos(phase * (1.0 + wobble)) * repulsorRadius; + const ry = cy + Math.sin(phase * (1.3 + wobble)) * repulsorRadius; + const repulseDx = nextX - rx; + const repulseDy = nextY - ry; + const distSq = repulseDx * repulseDx + repulseDy * repulseDy + 160; + const distInv = 1 / Math.sqrt(distSq); + const force = params.repulsorStrength * (650 / distSq); + nextX += repulseDx * distInv * force; + nextY += repulseDy * distInv * force; + } + + p.x = nextX; + p.y = nextY; + + // Wrap around edges + if (p.x < 0) p.x += width; + if (p.x > width) p.x -= width; + if (p.y < 0) p.y += height; + if (p.y > height) p.y -= height; + + p.age += 1; + if (p.age >= p.life) this.respawnParticle(p); + + // Colour based on particle id + hue params + const hue = (params.hueBase + (p.id / this.numParticles) * params.hueSpread) % 360; + const lightness = 50 + Math.sin(p.id * 0.1 + this.time) * 15; + + ctx.fillStyle = `hsl(${hue}, 75%, ${lightness}%)`; + ctx.beginPath(); + ctx.arc(p.x, p.y, params.particleSize, 0, TWO_PI); + ctx.fill(); + } + } +} From 711415e9ff278ae19a5387883eb1e40f258d9247 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 04:22:53 +0200 Subject: [PATCH 2/3] feat(manifold): render ParticleStage when output Mode is Particle System Particle System is the default output Mode. When active it renders the full-bleed ParticleStage (flow field driven by engine outputs each rAF) in place of the manifold stage, regardless of the in/split/out/composite focus, and suppresses the duplicate corner MEMLNaut label (the stage's top axis bar already shows it). --- manifold/src/console/ConsoleApp.tsx | 36 ++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index ef3b4cb..c73c10d 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -25,6 +25,7 @@ import { useEngine, useEngineVersion } from '../engine'; import { MF_MODES, modeEngineId, seededGradient, shapeValues } from './model'; import type { MFParam } from './model'; import { CompositeStage } from './CompositeStage'; +import { ParticleStage } from './ParticleStage'; import { SplitStage } from './SplitStage'; import { OutputStage } from './OutputStage'; import { InputMini } from './InputMini'; @@ -676,7 +677,14 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp bottom: 0, }} > - {focus === 'composite' ? ( + {outputMode === 'particles' ? ( + setAxes((s) => ({ ...s, [k]: v }))} + /> + ) : focus === 'composite' ? ( )} - {/* corner overlay */} -
- MEMLNaut -
+ {/* corner overlay — hidden in Particle mode (top axis bar owns that row) */} + {outputMode !== 'particles' && ( +
+ MEMLNaut +
+ )} Date: Sun, 28 Jun 2026 04:22:53 +0200 Subject: [PATCH 3/3] feat(manifold): add dedicated ParticleBackend (no-op transport, gates audio) Registers a named ParticleBackend in the BackendManager + barrel in place of the inline particles PassthroughBackend. Transport is a no-op (the flow-field canvas reads engine outputs directly via rAF); selecting it gates synth audio (setMuted) like the other non-synth backends and reports a clear ready status. --- manifold/src/backends/index.ts | 1 + manifold/src/backends/manager.ts | 3 +- manifold/src/backends/particle-backend.ts | 35 +++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 manifold/src/backends/particle-backend.ts diff --git a/manifold/src/backends/index.ts b/manifold/src/backends/index.ts index a0bea84..d7e30a4 100644 --- a/manifold/src/backends/index.ts +++ b/manifold/src/backends/index.ts @@ -18,6 +18,7 @@ export { OscBridgeBackend } from './osc-backend'; export type { OscBackendConfig } from './osc-backend'; export { NispsOscClient } from './osc-client'; export { PassthroughBackend } from './passthrough-backend'; +export { ParticleBackend } from './particle-backend'; export { useBackendManager, } from './useBackendManager'; diff --git a/manifold/src/backends/manager.ts b/manifold/src/backends/manager.ts index 4afe0c2..38cdb24 100644 --- a/manifold/src/backends/manager.ts +++ b/manifold/src/backends/manager.ts @@ -21,6 +21,7 @@ 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'; /** The slice of EngineApi the manager depends on (keeps it decoupled/testable). */ export interface ManagerEngine { @@ -47,7 +48,7 @@ export class BackendManager { ['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 PassthroughBackend('particles', 'Particle visualiser')], + ['particles', backends?.particles ?? new ParticleBackend()], ['cvgate', backends?.cvgate ?? new PassthroughBackend('cvgate', 'CV / gate (via VCV bridge)')], ['vcv', backends?.vcv ?? new PassthroughBackend('vcv', 'VCV bridge')], ]); diff --git a/manifold/src/backends/particle-backend.ts b/manifold/src/backends/particle-backend.ts new file mode 100644 index 0000000..fee1ef0 --- /dev/null +++ b/manifold/src/backends/particle-backend.ts @@ -0,0 +1,35 @@ +/** + * ParticleBackend — the output backend for the Particle System mode (the DEFAULT + * mode). + * + * Transport is a NO-OP: the flow-field visualiser (console/flow-field.ts, driven + * by ParticleStage) is a SEPARATE consumer of the engine spine — it reads + * `engine.getOutputs()` directly in its own requestAnimationFrame loop and maps + * the first 20 outputs onto the visual field. So there is nothing for the + * BackendManager to "send" here. + * + * What this backend DOES contribute: + * - It is a non-synth backend, so selecting it makes the BackendManager gate + * audio (`engine.audio.setMuted(true)`) — the synth is silenced while the + * particles are on screen, exactly like the MIDI / OSC modes. + * - It reports a sensible "ready" status for the Outputs panel. + * + * It extends PassthroughBackend (the shared no-op sink) purely to keep the + * empty-`send` / nothing-to-start behaviour in one place; the audio gate lives + * in the manager (`setActive`), not here. + */ +import { PassthroughBackend } from './passthrough-backend'; +import type { BackendStatus } from './backend'; + +export class ParticleBackend extends PassthroughBackend { + constructor() { + super('particles', 'Particle visualiser — outputs drive the flow field (no audio)'); + } + + override status(): BackendStatus { + return { + state: 'ready', + message: 'Particle visualiser — outputs drive the flow field (no audio)', + }; + } +}