merge stream 8: SolidJS scaffold + UI primitives (meml-911)
This commit is contained in:
commit
8911079212
266 changed files with 5444 additions and 62070 deletions
5
playground/.gitignore
vendored
Normal file
5
playground/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# Playground Architecture
|
||||
|
||||
## Event Systems
|
||||
|
||||
### Event Bus (preferred for new code)
|
||||
|
||||
The shared event bus at `js/event-bus.js` is the preferred event system for all new playground code. It provides namespaced pub/sub with wildcard support and automatic timestamping.
|
||||
|
||||
**Import:**
|
||||
|
||||
```js
|
||||
import { EventBus, getDefaultBus, SEQ, ML, UI } from './js/event-bus.js';
|
||||
```
|
||||
|
||||
**API:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `new EventBus(audioCtx?)` | Create a new bus. Optional AudioContext for `seq.*` timestamps. |
|
||||
| `bus.on(event, cb)` | Subscribe. Exact (`'seq.step'`) or wildcard (`'seq.*'`). |
|
||||
| `bus.off(event, cb)` | Unsubscribe. Same signature as `on()`. |
|
||||
| `bus.emit(event, data?)` | Emit. A `timestamp` field is added automatically. |
|
||||
| `bus.setAudioContext(ctx)` | Set or replace the AudioContext for `seq.*` timestamps. |
|
||||
| `getDefaultBus(audioCtx?)` | Get (or lazily create) the shared singleton bus. |
|
||||
|
||||
**Namespace conventions:**
|
||||
|
||||
| Prefix | Constants | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `seq.*` | `SEQ.STEP`, `SEQ.NOTE_ON`, `SEQ.NOTE_OFF`, `SEQ.PARAM_CHANGE`, `SEQ.LOOP_START` | Sequencer transport and timing events. Timestamped via `AudioContext.currentTime`. |
|
||||
| `ml.*` | `ML.TRAINED`, `ML.FROZEN`, `ML.UNFROZEN`, `ML.DELTA_UPDATE` | ML engine state changes. |
|
||||
| `ui.*` | `UI.PARAM_SELECT`, `UI.CHAIN_EDIT`, `UI.PRESET_LOAD`, `UI.FREEZE_TOGGLE` | UI interaction events. |
|
||||
|
||||
New features should define their own namespace constants following the same pattern.
|
||||
|
||||
**Callbacks** receive `(data, eventName)` where `data` always includes a `timestamp` field (`AudioContext.currentTime` for `seq.*`, `performance.now()` for everything else).
|
||||
|
||||
### DOM CustomEvents (existing code)
|
||||
|
||||
Existing code uses DOM `CustomEvent` dispatched on DOM elements (e.g. `controlsurface:change`, `eoc:change`). These work fine and should **not** be migrated at this time. Both systems coexist without conflict.
|
||||
|
||||
When the app is rewritten in the future, the goal is to unify on the event bus and retire DOM CustomEvents for inter-module communication.
|
||||
|
|
@ -1,831 +0,0 @@
|
|||
# SolidJS Migration Plan
|
||||
|
||||
## Decision Record
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Framework | SolidJS (reactivity + components) |
|
||||
| Build | Vite + vite-plugin-solid |
|
||||
| Apps | Immersive only, extensible for future layouts |
|
||||
| Core integration | Deep — ML/synth/audio modeled as SolidJS stores/signals |
|
||||
| Output reactivity | Batch at frame rate (one signal update per rAF) |
|
||||
| Event system | Unified signal bus with topics (replaces EventBus + CustomEvents) |
|
||||
| Migration strategy | Big bang rewrite, old code as reference |
|
||||
| Routing | None — signals only, URL params for config |
|
||||
| Canvas | Components with refs, internal render loops |
|
||||
| Mobile | Desktop-first, mobile later |
|
||||
| Layout | Headless UI primitives (Drawer, Overlay, Panel, Dock) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.html # Single entry point
|
||||
├── App.tsx # Root component, provider tree
|
||||
├── vite.config.ts
|
||||
│
|
||||
├── core/ # Framework-agnostic engines (transplanted)
|
||||
│ ├── wasm/ # WASM binaries + Emscripten glue (unchanged)
|
||||
│ │ ├── nisps.wasm
|
||||
│ │ ├── nisps.js
|
||||
│ │ └── nisps-wasm-worker.js
|
||||
│ ├── iml.ts # WasmIML wrapper (thin adaptation)
|
||||
│ ├── dataset.ts # Training dataset (FIFO ring buffer)
|
||||
│ ├── synth/ # Synth engines (transplanted, minimal changes)
|
||||
│ │ ├── engine-interface.ts
|
||||
│ │ ├── c15-adapter.ts
|
||||
│ │ ├── c15-bridge.ts
|
||||
│ │ ├── additive-engine.ts
|
||||
│ │ ├── fm-engine.ts
|
||||
│ │ ├── faust-engine-base.ts
|
||||
│ │ ├── param-map.ts # 126 C15 params (data, unchanged)
|
||||
│ │ └── presets.ts # Synth presets (data, unchanged)
|
||||
│ ├── audio/
|
||||
│ │ ├── arpeggiator.ts
|
||||
│ │ ├── arpeggiator-worker.js
|
||||
│ │ ├── audio-canvas.ts
|
||||
│ │ └── midi-io.ts # MIDIInput + MIDIOutput merged
|
||||
│ ├── eoc/ # Effects chain (transplanted)
|
||||
│ │ ├── eoc-chain.ts
|
||||
│ │ ├── eoc-module.ts
|
||||
│ │ └── modules/ # Individual effect modules
|
||||
│ └── shapeseq/ # Sequencer (transplanted, minimal changes)
|
||||
│ ├── sequencer.ts
|
||||
│ ├── chain.ts
|
||||
│ ├── clock.ts
|
||||
│ ├── pattern.ts
|
||||
│ ├── primitives.ts
|
||||
│ └── ...
|
||||
│
|
||||
├── bus/ # Signal bus
|
||||
│ └── signal-bus.ts # createSignalBus(), topic(), match(), emit()
|
||||
│
|
||||
├── stores/ # SolidJS stores (the reactive state layer)
|
||||
│ ├── ml-store.ts # IML instances, outputs, loss, training state
|
||||
│ ├── input-store.ts # Joystick position, input mode, pipeline config
|
||||
│ ├── output-store.ts # Output mode, overrides, routing config
|
||||
│ ├── synth-store.ts # Active engine, arpeggiator, volume, presets
|
||||
│ ├── eoc-store.ts # EOC chain state, nisps mode, modules
|
||||
│ ├── midi-store.ts # MIDI CC map, devices, output state
|
||||
│ ├── session-store.ts # Persistence (save/load localStorage)
|
||||
│ └── ui-store.ts # Drawer state, active panels, help seen
|
||||
│
|
||||
├── hooks/ # Reactive glue (SolidJS "hooks" / composables)
|
||||
│ ├── useInference.ts # Per-frame inference loop
|
||||
│ ├── useTraining.ts # Train/thumbs-up/thumbs-down actions
|
||||
│ ├── useOutputRouting.ts # Route outputs → synth/visual/midi/audio-canvas
|
||||
│ ├── useInputPipeline.ts # Deadzone, zoom, curve, smoothing, momentum
|
||||
│ ├── useOutputPipeline.ts # Slew rate, smoothing, freeze gate
|
||||
│ ├── usePersistence.ts # Auto-save/load, URL param parsing
|
||||
│ ├── useAudioContext.ts # Lazy AudioContext creation, resume on gesture
|
||||
│ ├── useGamepad.ts # Gamepad polling
|
||||
│ └── useKeyboard.ts # Keyboard shortcuts
|
||||
│
|
||||
├── primitives/ # Headless UI primitives
|
||||
│ ├── Drawer.tsx # Slide-in panel (headless)
|
||||
│ ├── Overlay.tsx # Floating positioned element
|
||||
│ ├── Panel.tsx # Collapsible content section
|
||||
│ ├── Dock.tsx # Icon bar with drawer triggers
|
||||
│ ├── PillToggle.tsx # Segmented toggle (output mode, input mode)
|
||||
│ ├── Slider.tsx # Range input with label/value display
|
||||
│ └── Canvas.tsx # Canvas wrapper with ref + resize observer
|
||||
│
|
||||
├── components/ # Feature components
|
||||
│ ├── app/
|
||||
│ │ └── ImmersiveLayout.tsx # Main layout shell
|
||||
│ ├── input/
|
||||
│ │ ├── Joystick.tsx # Virtual joystick (pointer events)
|
||||
│ │ ├── JoyMap.tsx # Zoom minimap + trails (canvas)
|
||||
│ │ └── InputModeToggle.tsx
|
||||
│ ├── output/
|
||||
│ │ ├── FlowField.tsx # Particle visualizer (canvas)
|
||||
│ │ ├── SynthVisualizer.tsx # Param bar chart (canvas)
|
||||
│ │ ├── Heatmap.tsx # Parameter heatmap grid
|
||||
│ │ └── OutputModeToggle.tsx
|
||||
│ ├── training/
|
||||
│ │ ├── TrainingControls.tsx # Add/Train/Clear/Randomize buttons
|
||||
│ │ ├── RLControls.tsx # Thumbs up/down, noise display
|
||||
│ │ ├── LossPlot.tsx # Loss history (canvas)
|
||||
│ │ └── StatusLine.tsx # Example count, loss, mode
|
||||
│ ├── synth/
|
||||
│ │ ├── SynthControls.tsx # Start/stop, volume, arp controls
|
||||
│ │ ├── PresetSelector.tsx # Synth preset chips
|
||||
│ │ ├── EngineSwitcher.tsx # Engine dropdown
|
||||
│ │ └── ParamEditor.tsx # Per-param override popup
|
||||
│ ├── eoc/
|
||||
│ │ ├── EOCPanel.tsx # Effects chain UI
|
||||
│ │ ├── EOCModule.tsx # Single effect module card
|
||||
│ │ └── EOCJoystick.tsx # Independent mode joystick
|
||||
│ ├── midi/
|
||||
│ │ ├── MIDIInputPanel.tsx # Device select, CC mapping
|
||||
│ │ ├── MIDICCPanel.tsx # CC output config
|
||||
│ │ └── MIDIPresets.tsx # CC preset management
|
||||
│ ├── shapeseq/
|
||||
│ │ ├── ShapeSeqPanel.tsx # Sequencer controls
|
||||
│ │ ├── StepVisualizer.tsx # Step display (canvas)
|
||||
│ │ └── ChainBuilder.tsx # Primitive chain editor
|
||||
│ ├── controls/
|
||||
│ │ ├── ControlSurface.tsx # Boldness/Memory/Precision axes
|
||||
│ │ ├── InputHeatmap.tsx # 2D input space heatmap (canvas)
|
||||
│ │ └── EngineParams.tsx # Spread, noise, decay sliders
|
||||
│ └── debug/
|
||||
│ ├── DevPanel.tsx # Debug tools
|
||||
│ ├── WeightHealth.tsx # Weight magnitude histogram (canvas)
|
||||
│ └── GradientFlow.tsx # Per-layer gradient analysis (canvas)
|
||||
│
|
||||
├── actions/ # Imperative operations (not reactive)
|
||||
│ ├── resize-mlp.ts # Resize MLP, warm-start weights
|
||||
│ ├── apply-overrides.ts # Override application logic
|
||||
│ └── export-import.ts # Session export/import
|
||||
│
|
||||
└── assets/
|
||||
├── c15/ # C15 WASM synth binary
|
||||
└── faust/ # Faust DSP files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Store Design (Deep Integration)
|
||||
|
||||
### ML Store (`stores/ml-store.ts`)
|
||||
|
||||
```typescript
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
// The hot path: batched per-frame, NOT per-parameter
|
||||
const [outputs, setOutputs] = createSignal(new Float32Array(126));
|
||||
const [rawParams, setRawParams] = createSignal(new Float32Array(126));
|
||||
|
||||
const [mlState, setMlState] = createStore({
|
||||
// IML instances (not reactive themselves — opaque handles)
|
||||
imlJoy: null as WasmIML | null,
|
||||
imlHand: null as WasmIML | null,
|
||||
activeIml: 'joy' as 'joy' | 'hand',
|
||||
|
||||
// Reactive state derived from IML
|
||||
outputCount: 126,
|
||||
loss: null as number | null,
|
||||
lossHistory: [] as number[],
|
||||
exampleCount: 0,
|
||||
isTraining: false,
|
||||
layerStats: null,
|
||||
|
||||
// RL config
|
||||
spreadLevel: 0.6,
|
||||
noiseLevel: 0.05,
|
||||
rlDecay: 0.97,
|
||||
learningRate: 0.1,
|
||||
maxIterations: 50,
|
||||
|
||||
// Undo
|
||||
undoStack: [] as Float32Array[],
|
||||
});
|
||||
|
||||
// The IML instances live OUTSIDE the store (mutable, non-proxy-safe).
|
||||
// The store tracks their *state* reactively.
|
||||
// After each inference: setOutputs(iml.getOutputs())
|
||||
// After each train: setMlState({ loss, exampleCount, lossHistory })
|
||||
```
|
||||
|
||||
**Key design choice**: `Float32Array` outputs are a signal, not a store property. Stores use proxies which don't play well with typed arrays. A signal holding the array reference, replaced each frame, gives us batch reactivity cheaply.
|
||||
|
||||
### Input Store (`stores/input-store.ts`)
|
||||
|
||||
```typescript
|
||||
const [inputState, setInputState] = createStore({
|
||||
mode: 'joystick' as 'joystick' | 'hands',
|
||||
joyX: 0.5,
|
||||
joyY: 0.5,
|
||||
isDragging: false,
|
||||
followMode: false,
|
||||
|
||||
// Input pipeline config
|
||||
pipeline: {
|
||||
deadzone: 0.02,
|
||||
zoom: 1.0,
|
||||
zoomAnchorX: 0.5,
|
||||
zoomAnchorY: 0.5,
|
||||
anchorMode: 'auto' as 'auto' | 'sticky' | 'center',
|
||||
curve: 1.0, // 1.0 = linear
|
||||
smoothing: 0.0, // 0.0 = none
|
||||
momentumZoom: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Output Store (`stores/output-store.ts`)
|
||||
|
||||
```typescript
|
||||
const [outputState, setOutputState] = createStore({
|
||||
mode: 'visual' as 'visual' | 'synth' | 'midi-cc' | 'audio-canvas',
|
||||
|
||||
// Per-mode overrides (arrays of { min, max, curve, muted, fixedValue })
|
||||
visualOverrides: [] as ParamOverride[],
|
||||
synthOverrides: {
|
||||
type: 'grouped' as 'grouped' | 'flat',
|
||||
groups: [] as GroupOverride[], // C15 grouped
|
||||
flat: [] as ParamOverride[], // Faust flat
|
||||
},
|
||||
midiCCOverrides: [] as ParamOverride[],
|
||||
audioCanvasOverrides: [] as ParamOverride[],
|
||||
|
||||
// Output pipeline config
|
||||
pipeline: {
|
||||
globalCurve: 1.0,
|
||||
smoothing: 0.0,
|
||||
slewRate: 1.0,
|
||||
freezeGate: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Synth Store (`stores/synth-store.ts`)
|
||||
|
||||
```typescript
|
||||
const [synthState, setSynthState] = createStore({
|
||||
engineId: 'shaper-feedback' as string,
|
||||
engine: null as SynthEngine | null, // Opaque handle
|
||||
isRunning: false,
|
||||
volume: 0.7,
|
||||
|
||||
// Arpeggiator
|
||||
arp: {
|
||||
enabled: false,
|
||||
tempo: 120,
|
||||
progression: 'major' as string,
|
||||
octaves: 1,
|
||||
offset: 0,
|
||||
},
|
||||
|
||||
// Current preset
|
||||
presetId: null as string | null,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Signal Bus Design (`bus/signal-bus.ts`)
|
||||
|
||||
```typescript
|
||||
import { createSignal, createMemo } from "solid-js";
|
||||
|
||||
type Topic<T = any> = {
|
||||
(): T | undefined; // Read (reactive)
|
||||
fire: (data: T) => void; // Write (imperative)
|
||||
};
|
||||
|
||||
export function createSignalBus() {
|
||||
const topics = new Map<string, ReturnType<typeof createSignal>>();
|
||||
|
||||
function topic<T = any>(name: string): Topic<T> {
|
||||
if (!topics.has(name)) {
|
||||
const [get, set] = createSignal<T | undefined>(undefined, { equals: false });
|
||||
topics.set(name, [get, set]);
|
||||
}
|
||||
const [get, set] = topics.get(name)!;
|
||||
const accessor = () => get() as T | undefined;
|
||||
accessor.fire = (data: T) => set(() => data);
|
||||
return accessor as Topic<T>;
|
||||
}
|
||||
|
||||
function emit<T = any>(name: string, data: T) {
|
||||
topic<T>(name).fire(data);
|
||||
}
|
||||
|
||||
function match(pattern: string) {
|
||||
// 'seq.*' → derived signal merging all seq.* topics
|
||||
const prefix = pattern.replace('*', '');
|
||||
return createMemo(() => {
|
||||
let latest: { name: string; data: any } | undefined;
|
||||
for (const [name, [get]] of topics) {
|
||||
if (name.startsWith(prefix)) {
|
||||
const val = get();
|
||||
if (val !== undefined) {
|
||||
latest = { name, data: val };
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
});
|
||||
}
|
||||
|
||||
return { topic, emit, match };
|
||||
}
|
||||
```
|
||||
|
||||
**Usage equals: false** is critical — it means every `emit()` triggers subscribers even if the data is identical. This matches event semantics (every `seq.step` matters, even if the step number repeats).
|
||||
|
||||
---
|
||||
|
||||
## Inference Loop (`hooks/useInference.ts`)
|
||||
|
||||
```typescript
|
||||
import { createEffect, onCleanup } from "solid-js";
|
||||
|
||||
export function useInference(mlStore, inputStore, outputStore, bus) {
|
||||
let rafId: number;
|
||||
|
||||
function tick() {
|
||||
const iml = mlStore.activeIml === 'joy' ? mlStore.imlJoy : mlStore.imlHand;
|
||||
if (!iml) { rafId = requestAnimationFrame(tick); return; }
|
||||
|
||||
// Set inputs
|
||||
iml.setInput(0, inputStore.joyX);
|
||||
iml.setInput(1, inputStore.joyY);
|
||||
|
||||
// Forward pass
|
||||
iml.process();
|
||||
const raw = iml.getOutputs();
|
||||
|
||||
// Batch update — single signal write per frame
|
||||
setOutputs(new Float32Array(raw));
|
||||
|
||||
// Route to active output
|
||||
routeOutputs(raw, outputStore, synthStore, bus);
|
||||
|
||||
// Fire bus event (for non-UI subscribers like sequencer)
|
||||
bus.emit('ml.inference', { outputs: raw });
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
onCleanup(() => cancelAnimationFrame(rafId));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Routing (`hooks/useOutputRouting.ts`)
|
||||
|
||||
```typescript
|
||||
function routeOutputs(raw: Float32Array, outputStore, synthStore, bus) {
|
||||
const mode = outputStore.mode;
|
||||
const overrides = getOverridesForMode(mode, outputStore);
|
||||
|
||||
// Apply overrides (pure function, no side effects)
|
||||
const processed = applyOverrides(raw, overrides);
|
||||
|
||||
switch (mode) {
|
||||
case 'visual':
|
||||
bus.emit('output.visual', processed);
|
||||
break;
|
||||
case 'synth':
|
||||
// Throttled: only send to engine at 20fps
|
||||
bus.emit('output.synth', processed);
|
||||
break;
|
||||
case 'midi-cc':
|
||||
bus.emit('output.midi', processed);
|
||||
break;
|
||||
case 'audio-canvas':
|
||||
bus.emit('output.audiocanvas', processed);
|
||||
break;
|
||||
}
|
||||
|
||||
// Always update heatmap (unthrottled — it's just DOM)
|
||||
bus.emit('output.heatmap', raw);
|
||||
}
|
||||
```
|
||||
|
||||
Each output component subscribes to its own bus topic. The FlowField listens to `output.visual`, the SynthVisualizer to `output.synth`, etc. Throttling for synth param sends lives inside the synth subscriber, not in the routing.
|
||||
|
||||
---
|
||||
|
||||
## Component Examples
|
||||
|
||||
### Joystick Component
|
||||
|
||||
```tsx
|
||||
const Joystick = () => {
|
||||
const { inputState, setInputState } = useInputStore();
|
||||
const bus = useBus();
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
const rect = (e.target as Element).getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = 1 - (e.clientY - rect.top) / rect.height;
|
||||
setInputState({ joyX: x, joyY: y, isDragging: true });
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!inputState.isDragging) return;
|
||||
const rect = (e.target as Element).getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / rect.height));
|
||||
setInputState({ joyX: x, joyY: y });
|
||||
};
|
||||
|
||||
const onPointerUp = () => setInputState({ isDragging: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
class="joystick"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
class="joystick-dot"
|
||||
style={{
|
||||
left: `${inputState.joyX * 100}%`,
|
||||
bottom: `${inputState.joyY * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### FlowField (Canvas Component)
|
||||
|
||||
```tsx
|
||||
const FlowField = () => {
|
||||
let canvasRef: HTMLCanvasElement;
|
||||
const bus = useBus();
|
||||
const visualData = bus.topic<Float32Array>('output.visual');
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvasRef.getContext('2d')!;
|
||||
const vis = new FlowFieldVisualizer(ctx); // Transplanted engine
|
||||
|
||||
// Internal render loop — reads signal each frame
|
||||
let raf: number;
|
||||
const loop = () => {
|
||||
const data = visualData();
|
||||
if (data) vis.setParams(data);
|
||||
vis.draw();
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
onCleanup(() => cancelAnimationFrame(raf));
|
||||
});
|
||||
|
||||
return <canvas ref={canvasRef!} class="flow-field" />;
|
||||
};
|
||||
```
|
||||
|
||||
### Heatmap (Reactive DOM)
|
||||
|
||||
```tsx
|
||||
const Heatmap = () => {
|
||||
const bus = useBus();
|
||||
const heatmapData = bus.topic<Float32Array>('output.heatmap');
|
||||
const { outputState } = useOutputStore();
|
||||
const paramMeta = () => getParamMeta(outputState.mode);
|
||||
|
||||
return (
|
||||
<div class="heatmap-grid">
|
||||
<For each={paramMeta()}>
|
||||
{(param, i) => (
|
||||
<HeatmapCell
|
||||
index={i()}
|
||||
param={param}
|
||||
value={() => heatmapData()?.[i()] ?? 0}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeatmapCell = (props) => {
|
||||
const width = () => `${(props.value() * 100).toFixed(1)}%`;
|
||||
return (
|
||||
<div class="heatmap-cell" title={props.param.name}>
|
||||
<div class="heatmap-bar" style={{ width: width() }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Tree (`App.tsx`)
|
||||
|
||||
```tsx
|
||||
const App = () => {
|
||||
return (
|
||||
<BusProvider>
|
||||
<MLProvider>
|
||||
<InputProvider>
|
||||
<OutputProvider>
|
||||
<SynthProvider>
|
||||
<EOCProvider>
|
||||
<SessionProvider>
|
||||
<ImmersiveLayout />
|
||||
</SessionProvider>
|
||||
</EOCProvider>
|
||||
</SynthProvider>
|
||||
</OutputProvider>
|
||||
</InputProvider>
|
||||
</MLProvider>
|
||||
</BusProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Each provider creates its store and exposes it via context. The `SessionProvider` handles persistence (auto-save on interval, load on mount, URL param parsing).
|
||||
|
||||
---
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### Phase 1: Skeleton + Core Loop
|
||||
|
||||
**Goal**: Joystick → MLP → FlowField working in SolidJS. **Also**: validate all risky integration points early.
|
||||
|
||||
1. `npm create vite@latest playground-solid -- --template solid-ts`
|
||||
2. Set up project structure (`core/`, `stores/`, `components/`, `bus/`)
|
||||
3. **Configure Vite**: COOP/COEP headers, WASM asset handling (spike `public/` vs `locateFile`)
|
||||
4. Transplant WASM files (`nisps.wasm`, `nisps.js`, worker) into `core/wasm/`
|
||||
5. **Spike**: Verify WASM loads and runs inference in Vite dev server (before building any UI)
|
||||
6. **Spike**: Verify C15 WASM + SharedArrayBuffer works with COOP/COEP headers
|
||||
7. Implement `createSignalBus()`
|
||||
8. Implement `ml-store.ts` and `input-store.ts` (minimal)
|
||||
9. Implement reactive inference (`createEffect` on input signals, not rAF)
|
||||
10. Build `Joystick` component
|
||||
11. Build `FlowField` component (transplant `FlowFieldVisualizer` class, own rAF loop)
|
||||
12. Wire it up in `App.tsx` with minimal `ImmersiveLayout`
|
||||
13. Verify: drag joystick → see particles respond
|
||||
14. Add worker `dispose()` to IML, verify cleanup in `onCleanup()`
|
||||
|
||||
**Validates**: SolidJS + WASM integration, COOP/COEP, signal bus, reactive inference, canvas components, worker lifecycle.
|
||||
|
||||
### Phase 2: Training + RL
|
||||
|
||||
**Goal**: Full ML interaction loop.
|
||||
|
||||
1. Implement `useTraining` hook (add example, train async, thumbs up/down)
|
||||
2. Implement `output-store.ts` (visual overrides only for now)
|
||||
3. Build `TrainingControls` (Add/Train/Clear/Randomize)
|
||||
4. Build `RLControls` (thumbs up/down, noise display)
|
||||
5. Build `LossPlot` (canvas)
|
||||
6. Build `StatusLine`
|
||||
7. Implement undo stack in `ml-store`
|
||||
8. Implement `useOutputRouting` (visual mode only)
|
||||
|
||||
**Validates**: Async training with UI updates, RL feedback loop, undo.
|
||||
|
||||
### Phase 3: Synth Integration
|
||||
|
||||
**Goal**: Synth output mode working.
|
||||
|
||||
1. Transplant `c15-adapter.ts`, `c15-bridge.ts`, `param-map.ts`, `presets.ts`
|
||||
2. Implement `synth-store.ts`
|
||||
3. Implement `useAudioContext` (lazy creation, gesture resume)
|
||||
4. Build `SynthControls` (start/stop, volume)
|
||||
5. Build `SynthVisualizer` (canvas, param bars)
|
||||
6. Build `PresetSelector`
|
||||
7. Build `EngineSwitcher` (C15, Additive, FM)
|
||||
8. Implement synth output routing with throttling
|
||||
9. Implement `output-store` grouped overrides for C15
|
||||
10. Build `Heatmap` + `ParamEditor` popup
|
||||
|
||||
**Validates**: Multi-engine support, override system, audio integration.
|
||||
|
||||
### Phase 4: Layout + UI Primitives
|
||||
|
||||
**Goal**: Full immersive UI.
|
||||
|
||||
1. Build headless primitives: `Drawer`, `Overlay`, `Dock`, `PillToggle`, `Slider`
|
||||
2. Build `ImmersiveLayout` (fullscreen canvas + floating controls + drawer stack)
|
||||
3. Build `ControlSurface` (Boldness/Memory/Precision compound axes)
|
||||
4. Implement `useInputPipeline` (deadzone, zoom, curve, smoothing)
|
||||
5. Build `JoyMap` (zoom minimap with trails, canvas)
|
||||
6. Build `InputHeatmap` (2D color field, canvas)
|
||||
7. Implement `useKeyboard` (shortcuts: 1/2/Z etc.)
|
||||
8. Wire drawer system (dock icons → drawer toggles)
|
||||
|
||||
**Validates**: Layout primitives, compound axis system, input pipeline.
|
||||
|
||||
### Phase 5: Remaining Features
|
||||
|
||||
**Goal**: Feature parity with a-immersive.
|
||||
|
||||
1. **MIDI**: `MIDIInputPanel`, `MIDICCPanel`, `MIDIPresets`, midi-cc output mode
|
||||
2. **Audio Canvas**: Transplant, wire as output mode
|
||||
3. **EOC Chain**: `EOCPanel`, `EOCModule`, EOC joystick, nisps modes (Shared/Linked/Independent)
|
||||
4. **Arpeggiator**: Controls, worker integration
|
||||
5. **Output Pipeline**: `useOutputPipeline` (slew, smoothing, freeze)
|
||||
6. **Weight Health**: `WeightHealth`, `GradientFlow` (canvas)
|
||||
7. **Session Presets**: Save/load full state, URL sharing
|
||||
8. **Persistence**: Auto-save, localStorage round-trip
|
||||
9. **Debug probe**: `window.__nisps` when `?debug=1`
|
||||
|
||||
### Phase 6: ShapeSeq
|
||||
|
||||
**Goal**: Sequencer as optional subsystem.
|
||||
|
||||
1. Transplant sequencer core (already modular)
|
||||
2. Build `ShapeSeqPanel`, `StepVisualizer`, `ChainBuilder`
|
||||
3. Wire to signal bus (`seq.*` topics)
|
||||
4. Lazy-load when enabled via URL param
|
||||
|
||||
### Phase 7: Polish + Tests
|
||||
|
||||
1. Port Playwright e2e tests (update selectors for new DOM)
|
||||
2. Add component-level tests (vitest + solid-testing-library)
|
||||
3. Responsive CSS pass
|
||||
4. Performance profiling (ensure 60fps inference + rendering)
|
||||
5. Accessibility pass on interactive elements
|
||||
|
||||
---
|
||||
|
||||
## Fresh Eyes: What the Plan Was Missing
|
||||
|
||||
### 1. COOP/COEP Headers & SharedArrayBuffer (Blocker)
|
||||
|
||||
The C15 synth engine requires `SharedArrayBuffer` for its audio ring buffer. This means the server **must** send:
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
```
|
||||
|
||||
The current codebase uses `serve-coop.py` for this. **Vite dev server must be configured** with these headers or C15 synth mode will fail silently. This should be validated in Phase 1, not Phase 3.
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
export default defineConfig({
|
||||
plugins: [solidPlugin()],
|
||||
server: {
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Worker Lifecycle (Currently Leaked)
|
||||
|
||||
Workers are **never terminated** in the current code:
|
||||
- `nisps-wasm-worker.js` — created lazily on first `trainAsync()`, lives forever
|
||||
- `arpeggiator-worker.js` — created on arpeggiator init, lives forever
|
||||
|
||||
In a SolidJS app where components mount/unmount, workers must be cleaned up in `onCleanup()`. The current code leaks them because the page never changes — the SolidJS version will need explicit termination, especially if engine switching or mode changes recreate IML instances.
|
||||
|
||||
**Add to architecture**: `core/iml.ts` must expose a `dispose()` method that terminates the worker. `synth-store` cleanup must terminate the arpeggiator worker.
|
||||
|
||||
### 3. AudioContext Lifecycle Gotchas
|
||||
|
||||
- **C15Bridge creates its own AudioContext internally**; Faust engines require one passed in. This API inconsistency means `useAudioContext()` can't own the single context — C15 creates its own.
|
||||
- **No `dispose()` on C15Bridge** — relies on GC of the AudioContext. Engine switching disconnects audio nodes (line 1121 in a-app.js) but doesn't explicitly close the context.
|
||||
- **AudioWorklet module loading** via `addModule()` — browsers deduplicate, but if the context is recreated, modules must be re-registered.
|
||||
|
||||
**Add to architecture**: The `useAudioContext` hook needs to handle two patterns: "I own the context" (Faust) vs "the engine owns the context" (C15). Consider normalizing this in the transplant — make C15Bridge accept an external AudioContext.
|
||||
|
||||
### 4. Debug Probe Synchrony Contract
|
||||
|
||||
The Playwright tests depend on `window.__nisps` methods being **synchronous**:
|
||||
- `setInputs(x, y)` → synchronously runs inference + routes outputs + updates heatmap
|
||||
- `thumbsUp()` → synchronously calls `addExample()` before returning
|
||||
- `saveState()` → synchronously writes to localStorage
|
||||
- `train()` → synchronously trains and returns loss (not the async variant)
|
||||
|
||||
In SolidJS, store updates are batched by default. If `setInputs()` writes to a signal but the effect that runs inference hasn't flushed yet, the probe will return stale data. **The probe must bypass SolidJS reactivity** and call imperative methods directly on the IML instance.
|
||||
|
||||
**Add to Phase 5 (debug probe)**: Build the probe as a direct imperative bridge to the IML/store internals, not as a reactive consumer. Use `batch()` or `untrack()` where needed.
|
||||
|
||||
### 5. MIDI CC Map — Engine-Scoped Dynamic Storage Keys
|
||||
|
||||
MIDI CC maps are stored with engine-scoped localStorage keys: `nisps-midi-cc-map:${activeEngine.id}`. When the engine switches:
|
||||
1. Current map is saved to the old engine's key
|
||||
2. New map is loaded from the new engine's key
|
||||
3. `midiCCMap` and `midiCCOverrides` arrays are mutated in-place
|
||||
|
||||
In SolidJS, in-place array mutation (`arr.length = 0; arr.push(...)`) won't trigger reactivity. The `midi-store` must use `setStore(produce(...))` or replace arrays entirely. The engine-scoped key pattern needs to be replicated in `session-store.ts`.
|
||||
|
||||
### 6. EOC Chain — Mutable Audio Graph + MLP Resize Cascade
|
||||
|
||||
When EOC modules are added/removed in "Shared" mode:
|
||||
1. MLP output count changes → MLP must be destroyed and recreated
|
||||
2. Training examples are lost (different output dimensionality)
|
||||
3. Audio graph nodes must be reconnected
|
||||
4. Heatmap must be rebuilt
|
||||
|
||||
This is a **cascade of side effects** triggered by a single user action. In the current code it's handled by a `window.addEventListener('eoc:change', ...)` handler that orchestrates everything imperatively.
|
||||
|
||||
In SolidJS, this should be modeled as: EOC module list is a store → derived signal computes total output count → `createEffect` watches output count and triggers MLP resize when it changes. But the MLP resize is async (WASM allocation) and has a confirmation dialog ("this will clear examples"). **Effects can't show dialogs**.
|
||||
|
||||
**Proposed pattern**: EOC store exposes a `pendingResize` signal. A component watches it and shows the confirmation UI. On confirm, an action triggers the actual resize. Don't try to make this fully reactive — keep it as an explicit action flow.
|
||||
|
||||
### 7. WASM + Emscripten Glue Loading in Vite
|
||||
|
||||
The WASM is loaded via Emscripten's `nisps.js` glue file, which does its own `fetch()` of `nisps.wasm` using a relative path. Vite's asset handling will hash filenames in production builds, breaking the hardcoded path.
|
||||
|
||||
**Options**:
|
||||
- Configure Vite to copy WASM files to `public/` (no hashing, always available at known path)
|
||||
- Modify the Emscripten glue to accept a custom `locateFile` override
|
||||
- Use Vite's `?url` import to get the resolved asset path and pass it to the WASM loader
|
||||
|
||||
This must be spiked in Phase 1. Same issue applies to C15 WASM (`c15/c15_engine.wasm`), C15 parameters (`c15/parameters.json`), and Faust DSP files.
|
||||
|
||||
### 8. Session Presets Are Shared Across Apps
|
||||
|
||||
`nisps-session-presets` localStorage key is **shared across all three current apps**. Since we're consolidating to one app, this is fine — but the key should be documented, and the migration should handle importing presets saved by the old app.
|
||||
|
||||
### 9. Lazy Loading Needs Suspense Boundaries
|
||||
|
||||
Three features are lazily loaded:
|
||||
- **ShapeSeq**: dynamic `import()` when `?shapeseq=1`
|
||||
- **Hand tracking**: imports MediaPipe from CDN (`cdn.jsdelivr.net`) — external dependency that can fail
|
||||
- **Audio Canvas**: created on first switch to audio-canvas mode
|
||||
|
||||
SolidJS `lazy()` + `<Suspense>` handles this naturally, but:
|
||||
- MediaPipe CDN fetch failure needs a fallback UI (not just a blank screen)
|
||||
- ShapeSeq lazy loading should show a loading state, not block the whole app
|
||||
- Audio Canvas creation involves AudioContext (requires gesture) — can't be wrapped in Suspense naively
|
||||
|
||||
### 10. CSS Animations Are Stateful
|
||||
|
||||
Several CSS classes trigger animations that encode UI state:
|
||||
- `.follow-pulse` — 1.5s infinite pulse (follow mode active)
|
||||
- `.btn-flash` / `.rl-flash` — 0.2s feedback flash
|
||||
- `.drawerSlideIn` — drawer appearance
|
||||
|
||||
If SolidJS re-renders a component (e.g., `<Show>` toggling), CSS animations restart from the beginning. For the pulse animation this is fine, but for the flash animations, a re-render mid-flash would cause visual glitches.
|
||||
|
||||
**Mitigation**: Use `classList` toggling on stable DOM nodes rather than conditional rendering for animation-bearing elements. Or use the Web Animations API for imperative control.
|
||||
|
||||
### 11. `window.__nispsEoc` Is Unconditional
|
||||
|
||||
Unlike `window.__nisps` (gated by `?debug=1`), `window.__nispsEoc` is **always exposed** (line 1528 in a-app.js). It provides `trainingTarget` getter/setter and `imlEoc` reference. If external code depends on this, it needs to be preserved in the SolidJS version unconditionally.
|
||||
|
||||
### 12. Inference Should NOT Be in rAF
|
||||
|
||||
The plan puts inference in a `requestAnimationFrame` loop. But inference only needs to run **when inputs change** (joystick drag, gamepad poll, hand tracking frame). Running it every frame when the joystick is idle wastes CPU.
|
||||
|
||||
**Better pattern**: Run inference reactively — `createEffect` watching `inputState.joyX` and `inputState.joyY`. When they change, run inference and update outputs. The canvas render loops (FlowField, SynthVisualizer) still run on rAF for smooth animation, but they just read the latest outputs signal — they don't trigger inference.
|
||||
|
||||
Exception: gamepad polling needs a rAF loop to read the Gamepad API, but that loop should only set input signals, not run inference directly.
|
||||
|
||||
---
|
||||
|
||||
## Key Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| WASM + SolidJS reactivity overhead | Typed arrays stay as signals (not store properties). Batch per frame. Profile early in Phase 1. |
|
||||
| 126 params × 20fps = 2520 synth param sends/sec | Keep existing throttle (50ms interval, 0.002 dead zone) in synth subscriber, not in routing. |
|
||||
| Store proxy overhead on large arrays | Use `createSignal` for `Float32Array` outputs, not `createStore`. Stores only for structured config. |
|
||||
| Signal bus wildcard `match()` perf | Memoize prefix scans. Most topics are fixed at startup. Profile if >50 topics. |
|
||||
| Canvas components fighting rAF | Each canvas owns its loop. No shared orchestrator unless profiling shows frame contention. |
|
||||
| MLP resize destroys training data | Same as current: warn user, warm-start weights for joystick IML. Store handles the state transition. |
|
||||
| `equals: false` on bus signals | Required for event semantics but means every emit triggers all subscribers. Keep topic count bounded. |
|
||||
| SharedArrayBuffer / COOP+COEP | Configure Vite dev server headers in Phase 1. Validate C15 works before Phase 3. |
|
||||
| WASM asset paths broken by Vite hashing | Spike in Phase 1: use `public/` dir or `locateFile` override for all WASM/JSON assets. |
|
||||
| Worker leak on component unmount | Add `dispose()` to IML and arpeggiator. Call in `onCleanup()`. |
|
||||
| EOC resize cascade needs confirmation dialog | Model as pending action, not reactive effect. Component shows dialog, action triggers resize. |
|
||||
| Debug probe expects synchronous execution | Build probe as imperative bridge, bypass SolidJS batching with `batch()`/`untrack()`. |
|
||||
| CSS animation restart on re-render | Use `classList` on stable nodes, not `<Show>`/`<Switch>` for animated elements. |
|
||||
| Idle inference wastes CPU | Make inference reactive to input changes, not rAF-driven. Canvas loops stay on rAF. |
|
||||
|
||||
---
|
||||
|
||||
## What Gets Transplanted vs Rewritten
|
||||
|
||||
### Transplanted (minimal changes, mostly just TS types)
|
||||
- `nisps-wasm.js` + worker + WASM binary
|
||||
- `dataset.ts` (FIFO ring buffer)
|
||||
- `FlowFieldVisualizer` class (canvas rendering)
|
||||
- `c15-bridge.ts`, `c15-adapter.ts`
|
||||
- `param-map.ts`, `presets.ts` (pure data)
|
||||
- `additive-engine.ts`, `fm-engine.ts`, `faust-engine-base.ts`
|
||||
- `arpeggiator.ts` + worker
|
||||
- `eoc-chain.ts`, `eoc-module.ts`, all effect modules
|
||||
- ShapeSeq core (`sequencer.ts`, `chain.ts`, `clock.ts`, `pattern.ts`, `primitives.ts`)
|
||||
- `audio-canvas.ts`
|
||||
- `input-pipeline.ts` logic (becomes `useInputPipeline` hook wrapping same math)
|
||||
- `output-pipeline.ts` logic (becomes `useOutputPipeline` hook)
|
||||
- `control-surface.ts` compound axis logic (data tables + interpolation)
|
||||
|
||||
### Rewritten from scratch
|
||||
- All DOM manipulation (→ JSX components)
|
||||
- Event wiring (→ signal bus + reactive effects)
|
||||
- State management (scattered module-scope vars → stores)
|
||||
- Override application (→ `apply-overrides.ts` pure function)
|
||||
- Persistence (→ `SessionProvider` with `createEffect` auto-save)
|
||||
- Layout/CSS (→ new CSS with headless primitives)
|
||||
- Init/boot sequence (→ provider tree + `onMount` hooks)
|
||||
|
||||
### Deleted (not ported)
|
||||
- `b-app.js`, `c-app.js`, `app.js` (consolidated into one app)
|
||||
- `b-workbench.html`, `c-journey.html`, `index.html` (single entry point)
|
||||
- `iml.js`, `mlp.js`, `layer.js`, `node.js` (legacy JS ML engine, WASM only)
|
||||
- `event-bus.js` (replaced by signal bus)
|
||||
- All `wire*()` functions (replaced by component-local event handlers)
|
||||
- DOM-string-building functions (`buildHeatmap`, `buildDrawerHTML`, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **TypeScript strictness**: Full strict mode from day one, or gradual? (Transplanted JS modules will need type annotations.)
|
||||
2. **CSS approach**: Plain CSS files per component? CSS modules? Vanilla Extract? (Plain CSS is simplest and matches the current approach.)
|
||||
3. **Testing during migration**: Run old Playwright tests against old code in parallel, or wait for Phase 7?
|
||||
4. **WASM loading**: Vite handles `.wasm` imports natively, but the Emscripten glue (`nisps.js`) may need special config. Spike this in Phase 1.
|
||||
5. **Faust DSP loading**: Currently fetched at runtime. Keep as-is or bundle?
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# NISPS Playground
|
||||
|
||||
Browser-based interactive demo of the NISPS ML engine.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
python3 -m http.server
|
||||
# Open http://localhost:8000
|
||||
```
|
||||
|
||||
Run the command from the `playground/` directory.
|
||||
|
||||
## What it does
|
||||
|
||||
- Maps 2D joystick input (X/Y) through an MLP (`[3, 32, 48, 64, 126]`) to 126 outputs.
|
||||
- **Visual mode**: first 20 outputs drive a flow-field particle visualization on Canvas2D.
|
||||
- **Synth mode (C15)**: all 126 outputs control the C15 WASM synthesizer — envelopes, oscillators, shapers, filters, feedback/output mixers, cabinet, and effects.
|
||||
- Supports two learning modes:
|
||||
- **Examples**: add explicit input/output pairs and train.
|
||||
- **RL Feedback**: give thumbs up/down while exploring outputs.
|
||||
|
||||
## C15 synth parameters
|
||||
|
||||
The 126 synth parameters (`js/synth/param-map.js`) cover all sonically meaningful continuous parameters of the C15 engine. Excluded from the C15's 287 total params:
|
||||
|
||||
- Hardware routing (56) — no physical MIDI in browser
|
||||
- Macro controls (12) — conflicts with direct ML control
|
||||
- Scale/tuning (13) — would break pitch
|
||||
- Key tracking / velocity (22) — depend on note context ML can't observe
|
||||
- Envelope mod depths (19) — too many multiplicative interactions for 2-input ML
|
||||
- Discrete/structural/dangerous (19) — pitch sweep, volume, switches, resets
|
||||
- Secondary config (15) — curves, chirp, shaper blend, source selects
|
||||
|
||||
## UI controls
|
||||
|
||||
- **Output mode tabs** (floating bar): switch between Visual and Synth modes.
|
||||
- **Expand / Collapse** chevron: expands bottom sheet with training controls, synth settings, advanced param sliders.
|
||||
- **Synth presets**: tiered presets (Beginner → Expert) control which of the 126 params the ML engine can modify.
|
||||
- **Help** (`?`) overlay: in-app usage guide.
|
||||
- **Follow mode**: double-click joystick in RL mode to toggle no-hold interaction.
|
||||
- **Keyboard in Follow mode (RL)**: `2` = thumbs up, `1` = thumbs down.
|
||||
- **Gamepad in RL**: `RB` = thumbs up, `LB` = thumbs down, `A` = train, `X` = randomize, `B` = clear.
|
||||
|
||||
## Control Surface
|
||||
|
||||
The immersive app has a control surface system (Phase 1 of `SPEC-controls.md`) for tuning how exploration and learning feel.
|
||||
|
||||
**Compound axes** on the floating bar (3 sliders: Bold, Mem, Prec):
|
||||
|
||||
| Axis | Controls | Low end | High end |
|
||||
|------|----------|---------|----------|
|
||||
| **Boldness** | Input zoom, noise cap, noise growth, LR, weight decay | Cautious: small changes, heavy regularisation | Bold: full range, explosive exploration, fast learning |
|
||||
| **Memory** | Max examples, example decay, noise decay, convergence | Amnesia: only last few interactions matter | Elephant: every example sacred, stable mapping |
|
||||
| **Precision** | Input curve, deadzone, smoothing, slew rate, momentum | Raw: 1:1 with physical movement, twitchy | Precise: heavy shaping, deadzones, smooth |
|
||||
|
||||
**Settings drawer** (gear icon, bottom-right): individual param overrides for Input, Training, Exploration, and Output sections. Manual overrides persist as offsets when compound axes move (trim-pot model). Double-tap an axis to re-link all params.
|
||||
|
||||
**Control presets**: Default, First Touch, Jazz Hands, Sculptor, Improviser, Microscope.
|
||||
|
||||
**Input pipeline**: joystick input is processed through deadzone → zoom → curve → smoothing → momentum-as-zoom before reaching the MLP. Zoom narrows the effective input window around an anchor. Zoom-at-zero freezes input.
|
||||
|
||||
**Enhanced joy-map**: zoom minimap with adaptive grid (4×4 → 32×32), vanishing trail with tap-to-return, dual noise rings (zoom + noise level).
|
||||
|
||||
## Files
|
||||
|
||||
### Original app (index.html)
|
||||
- `index.html` - page structure.
|
||||
- `css/style.css` - layout and visual styling.
|
||||
- `js/app.js` - app wiring and interaction logic.
|
||||
|
||||
### Immersive app (a-immersive.html)
|
||||
- `a-immersive.html` - fullscreen immersive UI.
|
||||
- `css/a-immersive.css` - immersive layout and styling.
|
||||
- `js/a-app.js` - immersive app wiring, state management, persistence.
|
||||
|
||||
### Shared modules
|
||||
- `js/nisps/` - JavaScript MLP + IML core (also WASM variant).
|
||||
- `js/ui/visualizer.js` - flow-field particle system (Canvas2D).
|
||||
- `js/ui/joystick.js` - virtual joystick component.
|
||||
- `js/ui/gamepad.js` - gamepad input handling.
|
||||
- `js/ui/hand-tracker.js` - MediaPipe hand tracking (14 features).
|
||||
- `js/ui/input-pipeline.js` - input processing pipeline (zoom, deadzone, curve, smoothing, momentum).
|
||||
- `js/ui/control-surface.js` - compound axes, override resolution, control presets.
|
||||
- `js/ui/control-surface-ui.js` - settings drawer and floating bar axis sliders.
|
||||
- `js/ui/joy-map-enhanced.js` - zoom minimap, vanishing trail, dual noise rings.
|
||||
- `js/synth/` - C15 WASM bridge, parameter map, presets, arpeggiator.
|
||||
- `c15/` - C15 engine WASM binary and parameter definitions.
|
||||
- `SPEC-controls.md` - comprehensive control surface spec (4 phases).
|
||||
|
|
@ -1,802 +0,0 @@
|
|||
# NISPS Control Surface — Comprehensive Spec
|
||||
|
||||
## Overview
|
||||
|
||||
This spec defines the full control surface for the NISPS playground's immersive app (`a-immersive.html`). The goal: make NISPS work for creatives who need both broad exploration *and* millimeter-precision refinement. The difference between a good guitar player and a great one is within the millimeters and milliseconds — these controls give users that resolution when they want it.
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Compound axes over independent knobs** — Users interact with 3-4 perceptually meaningful axes. Individual parameters exist underneath as overrides, but the default experience is high-level.
|
||||
2. **Zoom is the core metaphor** — Like Google Maps: same physical movement, different scale of traversal. Zooming in reveals (and teaches) fine structure.
|
||||
3. **Pinning preserves what works** — Users can protect regions, parameters, or training snapshots from being disturbed while they refine other areas.
|
||||
4. **Everything is a preset** — Every control state can be saved, restored, and shared. The preset system tames complexity without removing capability.
|
||||
5. **Embodied first** — Momentum-as-zoom, pressure-sensitive feedback, and vanishing trails make the system feel like an instrument, not a control panel.
|
||||
|
||||
### System Diagram
|
||||
|
||||
```
|
||||
Physical Input
|
||||
(joystick / hand / gamepad)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Input Pipeline │
|
||||
│ deadzone → zoom → │
|
||||
│ curve → smoothing → │
|
||||
│ momentum scaling │
|
||||
└─────────┬───────────┘
|
||||
│ scaled [0,1]
|
||||
▼
|
||||
┌─────────────────────┐ ┌──────────────────┐
|
||||
│ NISPS MLP │◄────│ Training Engine │
|
||||
│ (WASM inference) │ │ LR, iterations, │
|
||||
│ │ │ convergence │
|
||||
└─────────┬───────────┘ └────────┬─────────┘
|
||||
│ raw [0,1] │
|
||||
▼ │
|
||||
┌─────────────────────┐ ┌────────▼─────────┐
|
||||
│ Output Pipeline │ │ RL Feedback │
|
||||
│ smoothing → slew → │ │ noise, decay, │
|
||||
│ tame → group curve │ │ pinning, zoom- │
|
||||
│ [freeze gate] │ │ aware scaling │
|
||||
└─────────┬───────────┘ └──────────────────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
▼ ▼
|
||||
Visual Engine C15 Synth
|
||||
```
|
||||
|
||||
### Pipeline Order Rationale
|
||||
|
||||
The input pipeline order is deliberate:
|
||||
|
||||
1. **Deadzone** first — kill physical jitter before any processing
|
||||
2. **Zoom** second — narrow the input window around the anchor
|
||||
3. **Curve** third — shape movement *within* the zoomed window (so zoomed-in precision gets the benefit of curve shaping, not just the physical input)
|
||||
4. **Smoothing** fourth — temporal filter on the shaped signal
|
||||
5. **Momentum scaling** last — velocity-based zoom modulation on the final signal
|
||||
|
||||
Alternative orderings are worth experimenting with (curve before zoom means shaping the physical input, which might feel more "instrument-like"). The implementation should make the pipeline order configurable or at least easy to swap during development.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Compound Control Axes
|
||||
|
||||
These are the primary user-facing controls. Each axis moves multiple underlying parameters along a perceptually coherent dimension.
|
||||
|
||||
### Axis 1: Caution ↔ Boldness
|
||||
|
||||
*"How adventurous is my exploration?"*
|
||||
|
||||
Controls the overall risk/reward balance of the RL loop and weight perturbation.
|
||||
|
||||
| Boldness | Input Zoom | Noise Cap | Noise Growth | LR | Weight Decay | Noise Distribution |
|
||||
|----------|-----------|-----------|-------------|----|--------------|--------------------|
|
||||
| 0 (Cautious) | 0.1 | 0.02 | 1.1x | 0.1 | 0.15 | gaussian |
|
||||
| 0.5 (Balanced) | 0.5 | 0.12 | 1.5x | 1.0 | 0.06 | gaussian |
|
||||
| 1.0 (Bold) | 1.0 | 0.3 | 2.5x | 3.0 | 0.0 | cauchy |
|
||||
|
||||
**Perceptual meaning**: At low boldness, everything is gentle — small input range, small perturbations, slow learning, heavy regularisation. At high boldness, full input range, explosive exploration, fast learning, no guardrails.
|
||||
|
||||
### Axis 2: Memory ↔ Amnesia
|
||||
|
||||
*"How much does the network remember vs adapt to what I'm doing right now?"*
|
||||
|
||||
Controls the temporal horizon of learning.
|
||||
|
||||
| Memory | Example Capacity | Example Decay | Weight Decay | Noise Decay (per +) | Convergence Threshold |
|
||||
|--------|-----------------|---------------|--------------|---------------------|-----------------------|
|
||||
| 0 (Amnesia) | 5 | 0.3 | 0.2 | 0.85 | 1e-3 |
|
||||
| 0.5 (Balanced) | 50 | 0.7 | 0.06 | 0.97 | 1e-5 |
|
||||
| 1.0 (Elephant) | 500 | 1.0 | 0.0 | 0.995 | 1e-8 |
|
||||
|
||||
**Perceptual meaning**: At low memory, the network forgets quickly — only the last few interactions matter, the mapping is fluid and impermanent. At high memory, every example is sacred, the mapping is stable and hard to shift.
|
||||
|
||||
### Axis 3: Precision ↔ Expression
|
||||
|
||||
*"How raw and responsive vs filtered and controlled is the input?"*
|
||||
|
||||
Controls the input pipeline's character.
|
||||
|
||||
| Precision | Input Curve | Deadzone | Smoothing | Slew Rate | Momentum Zoom |
|
||||
|-----------|-----------|----------|-----------|-----------|---------------|
|
||||
| 0 (Raw) | linear | 0 | 0 | unlimited | off |
|
||||
| 0.5 (Balanced) | mild expo (1.5) | 0.05 | 0.15 | 0.3/frame | gentle |
|
||||
| 1.0 (Precise) | strong expo (3.0) | 0.15 | 0.4 | 0.1/frame | strong |
|
||||
|
||||
**Perceptual meaning**: At low precision, input is 1:1 with physical movement — twitchy, expressive, immediate. At high precision, input is heavily shaped — deadzones eat jitter, curves give more resolution in the center, smoothing removes noise, slew rate prevents jumps.
|
||||
|
||||
### Axis 4: Stability ↔ Fluidity (stretch goal)
|
||||
|
||||
*"How locked-in vs free-flowing are the outputs?"*
|
||||
|
||||
Controls post-network output behavior.
|
||||
|
||||
| Stability | Output Smoothing | Tame | Global Curve | Pin Strength |
|
||||
|-----------|-----------------|------|-------------|--------------|
|
||||
| 0 (Fluid) | 0 | 0 | 1.0 (linear) | none |
|
||||
| 0.5 (Balanced) | 0.3 | 0.5 | 1.0 | soft pins |
|
||||
| 1.0 (Locked) | 0.8 | 1.0 | n/a | hard pins |
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Input Pipeline
|
||||
|
||||
### 2.1 Input Zoom
|
||||
|
||||
The core navigation control. Narrows the effective input window around an anchor point.
|
||||
|
||||
**Metaphor**: Google Maps zoom. Same physical joystick movement, but at zoom=0.1, your full joystick travel covers only 10% of the input space.
|
||||
|
||||
**Implementation**:
|
||||
```
|
||||
effective_input[i] = anchor[i] + (raw_input[i] - 0.5) * zoom_level
|
||||
clamped to [0, 1]
|
||||
```
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `zoom` | 0.01–1.0 (log) | 1.0 | Affects all axes uniformly |
|
||||
| `zoomX` / `zoomY` | 0.01–1.0 | 1.0 | Per-axis override (joystick only) |
|
||||
| `anchorMode` | auto / sticky / center | auto | See below |
|
||||
|
||||
**Anchor modes**:
|
||||
- **auto**: Anchor updates to current joystick position whenever zoom level changes. Natural "zoom in on what I'm looking at."
|
||||
- **sticky**: Anchor stays where last set. Joystick pans within the zoomed window. Change anchor explicitly (e.g., double-tap).
|
||||
- **center**: Always anchored at (0.5, 0.5). Zooming always narrows toward center of input space.
|
||||
|
||||
**Zoom-at-zero = Freeze**: The zoom slider has a detent at the bottom that freezes input. No separate "freeze input" toggle needed — it's the natural limit of zooming in.
|
||||
|
||||
**Critical coupling with training**: Zooming in and training teaches the network fine structure in that region. This is the primary refinement workflow:
|
||||
1. Zoom out → broad explore → find interesting region
|
||||
2. Zoom in → RL feedback to refine detail
|
||||
3. Zoom out → verify the big picture wasn't destroyed
|
||||
|
||||
**Risk**: Training while zoomed in can distort mappings outside the zoom window. Mitigated by pinning (see Part 4).
|
||||
|
||||
### 2.2 Momentum-as-Zoom
|
||||
|
||||
An alternative/complementary zoom mechanism tied to movement speed.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `momentumZoom` | off / gentle / strong | off | Toggle, not a slider |
|
||||
| `velocityWindow` | 50–500ms | 150ms | Time window for velocity estimation |
|
||||
|
||||
**When enabled**: Movement speed modulates effective zoom in real time.
|
||||
- Slow, deliberate movement → high effective zoom (fine control)
|
||||
- Fast sweeps → low effective zoom (broad traversal)
|
||||
- Stationary → zoom level holds at last value
|
||||
|
||||
**Interaction with explicit zoom**: Momentum zoom multiplies with the manual zoom slider. If manual zoom is 0.5 and you move slowly, effective zoom might be 0.15. If you move fast, effective zoom might be 0.8.
|
||||
|
||||
### 2.3 Input Curve
|
||||
|
||||
Response curve per input axis. Reshapes the relationship between physical movement and input value.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `inputCurve` | 0.2–5.0 | 1.0 (linear) | Power function exponent |
|
||||
| `inputCurveX` / `inputCurveY` | 0.2–5.0 | 1.0 | Per-axis override |
|
||||
|
||||
**Implementation** (centered power curve):
|
||||
```
|
||||
// input in [0,1], centered at 0.5
|
||||
offset = input - 0.5
|
||||
shaped = sign(offset) * pow(abs(offset) * 2, exponent) / 2
|
||||
output = shaped + 0.5
|
||||
```
|
||||
|
||||
- Exponent < 1.0: logarithmic feel — fine at center, coarse at edges
|
||||
- Exponent = 1.0: linear (no shaping)
|
||||
- Exponent > 1.0: exponential feel — coarse at center, fine at edges. Good for "I want to stay near center but occasionally sweep to extremes."
|
||||
|
||||
### 2.4 Input Deadzone
|
||||
|
||||
Percentage of travel from center that produces no change.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `deadzone` | 0–0.4 | 0 (joystick), 0.1 (hands) | Percentage of half-travel |
|
||||
|
||||
Remaps the live zone to still cover full [0,1] output range — deadzone doesn't shrink the output, it just eats jitter near center.
|
||||
|
||||
### 2.5 Input Smoothing
|
||||
|
||||
Exponential moving average on the input signal.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `inputSmoothing` | 0–0.95 | 0 (joystick), 0.3 (hands) | EMA factor (0 = no smoothing) |
|
||||
|
||||
**Implementation**:
|
||||
```
|
||||
smoothed = smoothing * previous + (1 - smoothing) * raw
|
||||
```
|
||||
|
||||
Higher values = more latency, smoother signal. Essential for hand tracking where raw MediaPipe coordinates jitter.
|
||||
|
||||
### 2.6 Axis Invert
|
||||
|
||||
| Parameter | Range | Default |
|
||||
|-----------|-------|---------|
|
||||
| `invertX` / `invertY` | bool | false |
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Training Dynamics
|
||||
|
||||
### 3.1 Learning Rate
|
||||
|
||||
SGD step size passed to WASM `Train()`.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `learningRate` | 0.01–5.0 (log scale) | 1.0 | Affects both example training and RL thumbs-up |
|
||||
|
||||
Lower LR = network changes more gently per training call. At 0.01, each thumbs-up barely nudges; at 5.0, each thumbs-up aggressively reshapes. Interacts with max iterations — low LR + low iterations = almost no change; low LR + high iterations = careful convergence.
|
||||
|
||||
### 3.2 Max Iterations
|
||||
|
||||
How many SGD passes per `train()` call.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `maxIterations` | 10–5000 | 1000 | Higher = more polished fit but longer async wait |
|
||||
|
||||
### 3.3 Convergence Threshold
|
||||
|
||||
Early-stop when loss drops below this.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `convergenceThreshold` | 1e-8–1e-2 (log) | 1e-5 | Lower = stricter fit |
|
||||
|
||||
### 3.4 RL Train Intensity
|
||||
|
||||
Number of training calls per thumbs-up event.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `rlTrainIntensity` | 1–10 | 1 | More = stronger reinforcement per positive signal |
|
||||
|
||||
### 3.5 Example Memory
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `maxExamples` | 5–500 | 100 | FIFO eviction when full |
|
||||
|
||||
### 3.6 Example Decay
|
||||
|
||||
Weighted forgetting — older examples contribute less to training loss.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `exampleDecay` | 0.1–1.0 | 1.0 | 1.0 = all equal, 0.5 = half-life per example age |
|
||||
|
||||
**Implementation**: During training, each example's loss contribution is weighted by `decay^age` where age is its position from most-recent (0) to oldest (N-1). Requires modifying the WASM training call to accept per-sample weights, or implementing weighted sampling on the JS side before sending to WASM.
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Pinning System
|
||||
|
||||
The ability to say "I like this, don't touch it" — the core of precision refinement.
|
||||
|
||||
### 4.1 Region Pinning
|
||||
|
||||
Pin a rectangular region of the input space. Training and noise perturbation are suppressed for examples/weights that primarily affect the pinned region.
|
||||
|
||||
**Interaction model**:
|
||||
1. Navigate to the region you like
|
||||
2. Long-press / dedicated gesture → "Pin this area"
|
||||
3. A colored overlay appears on the joy-map showing the pinned region
|
||||
4. Continue exploring elsewhere — pinned region's outputs remain stable
|
||||
|
||||
**Implementation approaches** (in order of complexity):
|
||||
|
||||
#### Approach A: Example Pinning (simplest)
|
||||
- When pinning, snapshot all current examples whose inputs fall within the pinned region
|
||||
- These examples are marked as "pinned" — they're always included in training with high weight and can't be evicted by FIFO
|
||||
- New training still affects the whole network, but the pinned examples anchor the behavior in that region
|
||||
|
||||
#### Approach B: Weight Masking (moderate)
|
||||
- On pin, snapshot the current weights
|
||||
- During `moveWeights()`, blend: for each weight, compute how much it contributes to the pinned region (approximated by which input neurons it connects to) and reduce noise proportionally
|
||||
- During training, add a regularisation term that penalises divergence from the pinned-region snapshot
|
||||
|
||||
#### Approach C: Dual Network (most powerful, most complex)
|
||||
- Maintain a "frozen" copy of weights for pinned regions
|
||||
- Inference blends: for inputs in/near the pinned region, use frozen weights; for inputs outside, use live weights; blend in the transition zone
|
||||
- Requires spatial partitioning of the input space
|
||||
|
||||
**Recommended starting point**: Approach A (example pinning) — simple, effective, leverages existing training. Approach B as a follow-up.
|
||||
|
||||
### 4.2 Parameter Pinning
|
||||
|
||||
Pin individual output parameters or parameter groups. Pinned parameters are excluded from `moveWeights()` perturbation and their training targets are held fixed.
|
||||
|
||||
This partially exists already — the mute system in the synth visualizer drawer removes params from NISPS control. Pinning is different: the param *stays* NISPS-controlled but its current learned mapping is protected.
|
||||
|
||||
**Implementation**:
|
||||
- Per-param pin flag in the group overrides structure
|
||||
- During `moveWeights()`, skip weights in the final layer that connect to pinned output nodes
|
||||
- During training, fix pinned output targets to their current inferred values (so the network maintains its current mapping for those outputs regardless of new examples)
|
||||
|
||||
### 4.3 Snapshot Stack (Undo/History)
|
||||
|
||||
A stack of weight snapshots that supports multi-level undo and zoom-aware branching.
|
||||
|
||||
| Action | Snapshot behavior |
|
||||
|--------|-------------------|
|
||||
| Zoom in | Auto-push snapshot ("before refinement") |
|
||||
| Thumbs-down | Push snapshot (can undo the perturbation) |
|
||||
| Train | Push snapshot (can undo the training) |
|
||||
| Randomize | Push snapshot (can undo the randomization) |
|
||||
| Pin region | Push snapshot + tag as "pinned baseline" |
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `maxSnapshots` | 5–50 | 20 | Ring buffer, oldest evicted |
|
||||
|
||||
**UI**: Undo button (single step back). Long-press for snapshot list showing tagged entries.
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Exploration Noise (RL)
|
||||
|
||||
### 5.1 Spread (Master Regime)
|
||||
|
||||
Existing parameter, promoted from URL-only to a panel slider.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `spread` | 0–1 | 0.6 | Controls init scale, noise cap, per-layer scaling, weight decay simultaneously |
|
||||
|
||||
Spread remains the "personality" of the exploration system. Individual noise parameters below can override spread's derived values.
|
||||
|
||||
### 5.2 Noise Floor
|
||||
|
||||
Minimum noise level that thumbs-up can't decay below.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `noiseFloor` | 0–0.1 | 0.005 | Higher = always some exploration |
|
||||
|
||||
### 5.3 Noise Cap
|
||||
|
||||
Maximum noise level reachable via thumbs-down. Overrides spread-derived cap when set explicitly.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `noiseCap` | 0.01–1.0 | derived from spread | `0.3*(1-spread) + 0.05*spread` when not overridden |
|
||||
|
||||
### 5.4 Noise Growth Rate
|
||||
|
||||
Multiplier per thumbs-down event.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `noiseGrowth` | 1.1–3.0 | 1.5 | Higher = faster escalation to exploration |
|
||||
|
||||
### 5.5 Noise Decay Rate
|
||||
|
||||
Multiplier per thumbs-up event.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `noiseDecay` | 0.8–0.99 | 0.97 | Lower = faster convergence after positive feedback |
|
||||
|
||||
### 5.6 Weight Decay
|
||||
|
||||
Per-`moveWeights` call shrinkage. Prevents unbounded weight drift from repeated exploration.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `weightDecay` | 0–0.3 | derived from spread | `0.1 * spread` when not overridden |
|
||||
|
||||
### 5.7 Noise Distribution
|
||||
|
||||
Shape of random perturbation in `moveWeights()`.
|
||||
|
||||
| Parameter | Values | Default | Notes |
|
||||
|-----------|--------|---------|-------|
|
||||
| `noiseDistribution` | gaussian / uniform / cauchy | gaussian | Cauchy = rare big jumps, good for escaping local optima |
|
||||
|
||||
### 5.8 Layer-Aware Noise
|
||||
|
||||
Whether noise scales by 1/sqrt(fan_in) per layer.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `layerAwareNoise` | 0–1 | derived from spread | 0 = flat noise, 1 = Xavier-scaled. Currently tied to spread. |
|
||||
|
||||
### 5.9 Zoom-Aware Feedback Scaling
|
||||
|
||||
When enabled, feedback intensity scales with zoom level.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `zoomAwareFeedback` | off / on | on | When on: zoomed-in thumbs-down = gentle nudge, zoomed-out = big shake |
|
||||
|
||||
**Implementation**: Multiply noise growth and RL train intensity by `zoom_level`. At zoom=0.1, a thumbs-down applies 1/10th the normal perturbation. This is the natural interaction: your feedback matches your exploration scale.
|
||||
|
||||
### 5.10 Asymmetric / Pressure-Sensitive Feedback
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `pressureFeedback` | off / on | off | Touch force modulates feedback intensity (where supported) |
|
||||
| `holdDurationFeedback` | off / on | on | Longer hold = stronger signal (already partially implemented for hand gestures) |
|
||||
|
||||
### 5.11 Auto-Explore
|
||||
|
||||
Automated thumbs-down at regular intervals. A "wander" mode for weight space — the system drifts continuously while the user only gives thumbs-up when it lands on something good. Useful for hands-free exploration, performance contexts, or when you want to sit back and listen/watch.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `autoExplore` | off / on | off | Toggle |
|
||||
| `autoExploreInterval` | 0.5–10s | 2s | Time between automatic perturbations |
|
||||
| `autoExploreIntensity` | 0.1–1.0 | 0.5 | Scales the noise applied per auto-step (relative to current noise level) |
|
||||
|
||||
**Interaction with other controls**:
|
||||
- Respects noise cap, spread, zoom-aware feedback scaling — all the same rules as manual thumbs-down
|
||||
- Thumbs-up still works normally (trains + decays noise), creating a "selection pressure" against the auto-drift
|
||||
- Auto-Explore + Follow Mode = fully autonomous exploration (joystick wanders + weights drift + user just watches and occasionally thumbs-up)
|
||||
- Auto-Explore intensity could scale with zoom: zoomed in = gentler auto-steps, zoomed out = bigger leaps
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Output Pipeline
|
||||
|
||||
### 6.1 Output Smoothing
|
||||
|
||||
Temporal smoothing on network outputs. Prevents jarring jumps when the mapping changes.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `outputSmoothing` | 0–0.95 | 0 | EMA factor. Higher = more latency, smoother transitions. |
|
||||
|
||||
### 6.2 Output Slew Rate
|
||||
|
||||
Maximum change per frame per output. Hard limiter (vs the soft EMA above).
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `outputSlewRate` | 0.005–1.0 | 1.0 (unlimited) | In units per frame. 0.01 = very slow transitions. |
|
||||
|
||||
### 6.3 Tame
|
||||
|
||||
Existing parameter — constrains synth output ranges toward safe defaults.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `tame` | 0–1 | 1.0 | Already implemented, promote to panel |
|
||||
|
||||
### 6.4 Global Output Curve
|
||||
|
||||
Apply a single power curve to all outputs.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `globalCurve` | 0.2–5.0 | 1.0 (linear) | < 1 = push toward extremes, > 1 = push toward center |
|
||||
|
||||
### 6.5 Freeze Output
|
||||
|
||||
Lock current outputs — the network still runs inference as input moves, but the output pipeline doesn't update the synth/visualizer. Use case: "let me hear this sound while I adjust control surface settings" or "hold this visual while I tweak noise parameters."
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `freezeOutput` | bool | false | Gate at the end of the output pipeline |
|
||||
|
||||
Distinct from Freeze Input (zoom-at-zero): with Freeze Input, inference stops because input doesn't change. With Freeze Output, inference keeps running (you can watch the heatmap shift) but the audible/visible result stays locked. This lets you preview what *would* change before committing.
|
||||
|
||||
### 6.6 A/B Compare
|
||||
|
||||
Rapid toggle between two weight states. Snapshot a "reference" state, keep exploring, then flip back and forth to hear/see the difference.
|
||||
|
||||
**Interaction model**:
|
||||
1. Press "A" to snapshot current state (weights + noise level + zoom)
|
||||
2. Continue exploring (this is the "B" state, live)
|
||||
3. Toggle A↔B to switch instantly between the two
|
||||
4. "Accept B" to discard the A snapshot and continue
|
||||
5. "Revert to A" to restore the snapshot and discard B
|
||||
|
||||
| Parameter | Notes |
|
||||
|-----------|-------|
|
||||
| `abSnapshot` | Stored weight array + noise level + control state |
|
||||
| `abActive` | bool — is A/B mode engaged? |
|
||||
|
||||
**UI**: Could be a toggle button, or a press-and-hold (hold to hear A, release to return to B — like a preview pedal).
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Visualization & Feedback
|
||||
|
||||
### 7.1 Zoom Minimap
|
||||
|
||||
The joy-map canvas shows the zoom window boundary overlaid on the full input space.
|
||||
|
||||
**Requirements**:
|
||||
- Show the full [0,1]x[0,1] input space at all times
|
||||
- Draw the current zoom window as a rectangle (gets smaller as you zoom in)
|
||||
- Inside the zoom window, show a "graph paper" grid that subdivides as zoom increases — like zooming into actual graph paper and seeing finer grid lines appear
|
||||
- Current joystick position shown as a dot within the zoom window
|
||||
- Pinned regions shown as colored overlays
|
||||
|
||||
### 7.2 Vanishing Trail
|
||||
|
||||
A fading trail of recent joystick positions on the joy-map.
|
||||
|
||||
**Requirements**:
|
||||
- Trail persists for ~5 seconds, fading from opaque to transparent
|
||||
- Trail is drawn in the full input space (not zoom-relative), so you can see where you've been even after zooming
|
||||
- Trail points are clickable/tappable — tap a trail point to snap the joystick back to that position ("I liked what I heard 3 seconds ago, go back")
|
||||
- Trail color matches current output mode (orange for visual, blue for synth, etc.)
|
||||
- Optional: trail width encodes zoom level at the time — thicker = more zoomed out, thinner = more zoomed in
|
||||
|
||||
The trail already partially exists (joyTrail in a-app.js). This extends it with persistence, interactivity, and zoom-awareness.
|
||||
|
||||
### 7.3 Noise Ring
|
||||
|
||||
Already exists — ring around joystick showing current noise level.
|
||||
|
||||
**Enhancement**: Ring could also encode zoom level (ring radius = zoom, ring thickness = noise). Two concentric rings: inner = zoom window, outer = noise magnitude.
|
||||
|
||||
### 7.4 Input Space Heatmap
|
||||
|
||||
A 2D color field on the joy-map showing what the network produces across the entire input space. A bird's-eye view of the learned landscape.
|
||||
|
||||
**Implementation**:
|
||||
- Sample the network at a grid of input points (e.g., 8x8 or 16x16)
|
||||
- For each point, run inference and reduce the output vector to a color (e.g., average output magnitude → brightness, output variance → saturation, dominant output cluster → hue)
|
||||
- Render as a background layer on the joy-map canvas
|
||||
- Update on weight change events (train, randomize, moveWeights), NOT every frame
|
||||
- At higher zoom levels, the heatmap re-samples the zoomed window at the same grid resolution, revealing finer structure (if the network has learned any)
|
||||
|
||||
**Use case**: "There's something interesting in that corner — the heatmap shows high variance there, let me navigate over." Also gives immediate visual feedback on whether training actually changed the landscape.
|
||||
|
||||
**Cost**: At 16x16 = 256 inference calls per update. With the WASM engine this should be <5ms. Can be throttled to update at most once per 200ms.
|
||||
|
||||
**Color reduction strategies** (which to try):
|
||||
- Mean output luminance (simple, shows "loud" vs "quiet" regions)
|
||||
- Output entropy / variance (shows "interesting" vs "flat" regions)
|
||||
- Principal component to RGB (shows the dominant output dimensions as color channels)
|
||||
- Difference-from-center (shows how much each region diverges from the center point's output)
|
||||
|
||||
### 7.5 Weight Health Indicator
|
||||
|
||||
A small ambient indicator showing network weight statistics.
|
||||
|
||||
| Visual | Meaning |
|
||||
|--------|---------|
|
||||
| Calm, low-saturation glow | Weights are well-distributed, network is healthy |
|
||||
| Hot, pulsing glow | Weights are saturating (high magnitude), sigmoid is clamping |
|
||||
| Dim/dead | Weights are near-zero, network is underpowered |
|
||||
|
||||
Implementation: Compute weight magnitude histogram from `_getFlatWeights()` periodically (not every frame — every 500ms or on weight change events).
|
||||
|
||||
### 7.6 Gradient Flow Indicator
|
||||
|
||||
During and after training, show per-layer gradient magnitudes — signals whether the network is actually learning or if gradients are vanishing/exploding.
|
||||
|
||||
| Visual | Meaning |
|
||||
|--------|---------|
|
||||
| Even bars across layers | Healthy gradient flow |
|
||||
| Bars shrinking left-to-right | Vanishing gradients (deeper layers aren't learning) |
|
||||
| Bars growing left-to-right | Exploding gradients (unstable training) |
|
||||
| All bars near zero | Network has converged or is stuck |
|
||||
|
||||
**Implementation**: Requires exposing per-layer gradient norms from the WASM training path. Options:
|
||||
- Add a `nisps_mlp_get_gradient_norms()` binding that returns per-layer L2 gradient norms after a training call
|
||||
- Or compute approximately on the JS side by measuring weight deltas before/after training (less accurate but no WASM changes)
|
||||
|
||||
**Audience**: Power users tuning LR, architecture, or diagnosing why training isn't working. Can be hidden behind an "Advanced" toggle.
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Engine Configuration (Separate from Runtime Controls)
|
||||
|
||||
These require a full network reset and destroy the current mapping. They live in a separate "Engine" panel gated behind a confirmation dialog.
|
||||
|
||||
| Parameter | Range | Default | Notes |
|
||||
|-----------|-------|---------|-------|
|
||||
| `hiddenLayers` | array of ints | [32, 48, 64] | MLP topology |
|
||||
| `hiddenLayerCount` | 1–6 | 3 | Convenience — resizes the array |
|
||||
| `outputActivation` | sigmoid / tanh | sigmoid | Final layer activation |
|
||||
| `hiddenActivation` | relu / leaky_relu / tanh / gelu | relu | Hidden layer activation |
|
||||
|
||||
Changing any of these triggers: "This will reset the network and all training. Continue?"
|
||||
|
||||
---
|
||||
|
||||
## Part 9: Preset System
|
||||
|
||||
### Control Presets
|
||||
|
||||
Control presets define a complete control surface state (all parameters from Parts 1-6). They do NOT include network weights or training data — those are separate.
|
||||
|
||||
**Built-in control presets** (initial set, to be expanded through experimentation):
|
||||
|
||||
| Preset | Caution/Boldness | Memory/Amnesia | Precision/Expression | Character |
|
||||
|--------|-----------------|----------------|---------------------|-----------|
|
||||
| **Default** | 0.5 | 0.5 | 0.3 | Balanced starting point |
|
||||
| **First Touch** | 0.2 | 0.7 | 0.6 | Gentle for newcomers. Small changes, stable memory, forgiving input. |
|
||||
| **Jazz Hands** | 0.8 | 0.2 | 0.0 | Wild exploration. Big noise, short memory, raw input. |
|
||||
| **Sculptor** | 0.3 | 0.9 | 0.8 | Precision refinement. Small careful changes, long memory, heavy smoothing. |
|
||||
| **Improviser** | 0.6 | 0.3 | 0.2 | Responsive and forgetful. Medium exploration, recent-biased, low latency. |
|
||||
| **Microscope** | 0.1 | 1.0 | 1.0 | Maximum zoom, maximum memory, maximum precision. For fine detail work. |
|
||||
|
||||
### Preset Storage
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: "Sculptor",
|
||||
// Compound axis positions (for UI display)
|
||||
axes: { boldness: 0.3, memory: 0.9, precision: 0.8, stability: 0.5 },
|
||||
// Resolved individual parameters (actual values used)
|
||||
input: { zoom: 0.2, momentumZoom: 'gentle', inputCurve: 2.0, deadzone: 0.08, smoothing: 0.3, invertX: false, invertY: false },
|
||||
training: { learningRate: 0.3, maxIterations: 2000, convergenceThreshold: 1e-7, rlTrainIntensity: 1, maxExamples: 300, exampleDecay: 0.95 },
|
||||
noise: { spread: 0.7, noiseFloor: 0.003, noiseCap: 0.08, noiseGrowth: 1.2, noiseDecay: 0.99, weightDecay: 0.07, noiseDistribution: 'gaussian', layerAwareNoise: 0.8, zoomAwareFeedback: true },
|
||||
output: { outputSmoothing: 0.4, outputSlewRate: 0.1, tame: 0.8, globalCurve: 1.0 },
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 10: Implementation Priority
|
||||
|
||||
### Phase 1 — Core Zoom + Compound Axes ✅ IMPLEMENTED
|
||||
1. ✅ Input zoom with anchor modes and minimap visualization — `js/ui/input-pipeline.js`, `js/ui/joy-map-enhanced.js`
|
||||
2. ✅ Zoom-at-zero freeze behavior — `InputPipeline.isFrozen()`, frozen overlay in joy-map
|
||||
3. ✅ Vanishing trail with tap-to-return — Catmull-Rom spline, ring buffer, 5s duration, zoom-width encoding
|
||||
4. ✅ Compound axis sliders (Boldness, Memory, Precision) wired to underlying params — `js/ui/control-surface.js`
|
||||
5. ✅ Spread, LR, noise cap promoted to panel sliders — settings drawer with all params from Parts 2-6
|
||||
6. ✅ Resolve UI location (Part 11.5) — Option E (hybrid): axes on floating bar, overrides in gear drawer
|
||||
7. ✅ (bonus) Input curve, deadzone, smoothing, momentum-zoom — all implemented in pipeline, exposed in drawer
|
||||
8. ✅ (bonus) Zoom-aware feedback scaling — thumbs-down noise scales by zoom level
|
||||
9. ✅ (bonus) Control presets with offset-based override resolution — 6 built-in presets, trim-pot model
|
||||
10. ✅ (bonus) Dual concentric noise rings (zoom + noise) replacing CSS-only ring
|
||||
|
||||
### Phase 2 — Pinning + History ✅ IMPLEMENTED
|
||||
7. ✅ Parameter pinning — per-output pin flags, pin mask passed to `moveWeights()`, double-tap to toggle in synth visualizer
|
||||
8. ✅ Region pinning (Approach A: example pinning) — long-press joy-map pins current zoom window, pinned examples always included in training with high weight
|
||||
9. ✅ Snapshot stack with undo — ring buffer (20 max), auto-snapshot on train/randomize/thumbs-down, long-press for history popup
|
||||
10. ✅ A/B Compare toggle — capture A, toggle between states, accept B or revert to A
|
||||
11. ✅ Modified `mlp.js` moveWeights to accept optional `outputPinMask` for pinned output nodes
|
||||
|
||||
### Phase 3 — Input Refinement + Exploration ✅ IMPLEMENTED
|
||||
12. ✅ Pressure/hold-duration feedback — touch force + hold duration modulate noise growth/decay strength
|
||||
13. ✅ Auto-Explore mode — automated thumbs-down at configurable interval, zoom-scaled intensity, emerald toggle button with progress ring
|
||||
14. ✅ Input space heatmap — 16×16 grid inference sampling, 3 color modes (luminance/variance/divergence), zoom-aware resampling, throttled updates
|
||||
|
||||
### Phase 4 — Output, Persistence + Polish ✅ IMPLEMENTED
|
||||
15. ✅ Output pipeline — global curve → smoothing → slew rate → freeze gate, wired into `routeOutputs()`
|
||||
16. ✅ Weight health indicator — weight magnitude histogram, dead/saturating/healthy status, ambient glow visualization
|
||||
17. ✅ Gradient flow indicator — per-layer weight-delta analysis, vanishing/exploding/converged detection, bar visualization
|
||||
18. ✅ Session presets — save/load full state (control surface + synth preset + pipelines), URL sharing via compact params
|
||||
|
||||
### Remaining
|
||||
- Engine configuration panel (Part 8) — network architecture, loss function, optimizer selection
|
||||
|
||||
---
|
||||
|
||||
## Part 11: Open Design Questions
|
||||
|
||||
Issues that need resolution through experimentation. Leaving all possibilities open.
|
||||
|
||||
### 11.1 Compound Axis Override Resolution
|
||||
|
||||
When a user moves the Boldness axis, it sets ~6 individual parameters. If they then manually override Noise Cap, what happens when they move Boldness again?
|
||||
|
||||
**Options**:
|
||||
- **Axis always wins** — Simple but frustrating. Manual tweaks get blown away.
|
||||
- **Override sticks** — Manual overrides detach the param from its axis. Moving the axis moves the other 5 params but leaves Noise Cap alone. Visual indicator shows detached params (e.g., dimmed link icon).
|
||||
- **Override as offset** — The manual tweak becomes a delta on top of the axis-derived value. Moving the axis shifts the base, the offset persists. E.g., if axis says noiseCap=0.12 and user overrides to 0.15, the offset is +0.03. Moving boldness to a new position that says noiseCap=0.08 results in effective noiseCap=0.11.
|
||||
- **Re-engage gesture** — Double-tap the axis slider to re-link all params. Single moves only affect still-linked params.
|
||||
|
||||
All four are worth prototyping. The offset approach is most "musical" (like trim pots on a mixing desk), but the detach approach is most predictable.
|
||||
|
||||
### 11.2 Gamepad Input Pipeline
|
||||
|
||||
Gamepad sticks (Steam Deck, Xbox) often have OS-level deadzone and response curves applied. The input pipeline would double-process these.
|
||||
|
||||
**Options**:
|
||||
- **Bypass pipeline for gamepad** — Gamepad feeds directly into the zoom stage, skipping deadzone and curve (since the OS already applied them). Smoothing and momentum still apply.
|
||||
- **Full pipeline, user manages** — Let users set deadzone to 0 for gamepad. Simpler implementation, slight risk of feeling "mushy."
|
||||
- **Per-input-type pipeline presets** — Different default pipeline settings for joystick vs gamepad vs hand tracking. The "Precision ↔ Expression" axis resolves to different underlying values depending on input type.
|
||||
- **Raw mode toggle** — Gamepad can request raw stick values (bypassing OS processing) on some platforms. Offer this as an option.
|
||||
|
||||
Currently `GamepadInput` in `gamepad.js` maps stick values to joystick position. The pipeline applies on top of that.
|
||||
|
||||
### 11.3 Persistence & Sharing
|
||||
|
||||
Where are user control presets saved, and how are they shared?
|
||||
|
||||
**Storage options**:
|
||||
- **localStorage** — Current approach for app state (`nisps-a-immersive` key). Natural extension for control presets.
|
||||
- **URL parameters** — Like existing `?spread=0.6&tame=1`. Enables sharing via link. Gets unwieldy with 30+ params but works well for compound axes (e.g., `?boldness=0.3&memory=0.9&precision=0.8`).
|
||||
- **Export/Import JSON** — Copy-paste or file download/upload. Full fidelity, shareable, but more friction.
|
||||
- **Named presets in localStorage** — User can save multiple named presets, select from a dropdown.
|
||||
|
||||
**Sharing scenarios**:
|
||||
- "Try this control setup" → URL with compound axis values
|
||||
- "Here's my complete session" → JSON export (control preset + synth preset + training data + weights)
|
||||
- "Starting point for a workshop" → URL with preset name that maps to a built-in
|
||||
|
||||
All of these should be possible. URL params for quick sharing, localStorage for persistence, JSON for full export.
|
||||
|
||||
### 11.4 Control Presets vs Synth Presets Composition
|
||||
|
||||
Control presets (how you explore) and synth presets (what parameters exist and their ranges) are orthogonal. But users will want combined "session" presets.
|
||||
|
||||
**Options**:
|
||||
- **Independent** — Two separate dropdowns. User picks a synth preset AND a control preset. Simple, composable, but requires two decisions.
|
||||
- **Bundled sessions** — A "session" preset bundles both. "Beginner Sculptor" = beginner-1 synth + Sculptor controls. More opinionated, fewer choices.
|
||||
- **Synth preset suggests controls** — Loading a beginner synth preset auto-suggests "First Touch" controls. User can override. A soft coupling.
|
||||
- **All of the above** — Independent selection as the base, with bundled suggestions as convenience shortcuts.
|
||||
|
||||
### 11.5 UI Location & Panel Architecture
|
||||
|
||||
Where do these controls physically live in the immersive app?
|
||||
|
||||
**Options under consideration**:
|
||||
|
||||
#### Option A: Settings Drawer (gear icon)
|
||||
- New gear icon next to the help button (top right)
|
||||
- Opens a side drawer with compound axis sliders at top, expandable sections for individual params below
|
||||
- Pro: Doesn't clutter the main performance surface. Con: Hidden, less discoverable.
|
||||
|
||||
#### Option B: Extended Bottom Sheet
|
||||
- Add a "Controls" tab to the existing bottom sheet (alongside the existing examples/training/synth tabs)
|
||||
- Compound axes as prominent sliders, individual params in expandable sections
|
||||
- Pro: Consistent with existing architecture. Con: Bottom sheet is already busy.
|
||||
|
||||
#### Option C: Floating Control Strip
|
||||
- A minimal floating strip (like the existing floating bar) with compound axis sliders
|
||||
- Tap any axis to expand into a popover showing the individual override params
|
||||
- Pro: Always visible, minimal footprint. Con: Screen real estate on mobile.
|
||||
|
||||
#### Option D: Dedicated Mode
|
||||
- A "Control Surface" mode alongside Visual and Synth modes
|
||||
- Full-screen control panel when active, hidden during performance
|
||||
- Pro: Maximum space for controls. Con: Can't adjust while playing.
|
||||
|
||||
#### Option E: Hybrid
|
||||
- Compound axes on the floating bar (always visible, 3 small sliders)
|
||||
- Individual overrides in a settings drawer (gear icon)
|
||||
- Pinning/zoom controls integrated into the joy-map (contextual, spatial)
|
||||
- A/B compare as a floating toggle button near the RL buttons
|
||||
|
||||
This is likely the right approach — distribute controls by frequency of use and spatial relevance.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Hand Tracking Zoom
|
||||
|
||||
For hand tracking's 14 input dimensions, per-axis zoom is unmanageable. Instead, group inputs into semantic clusters:
|
||||
|
||||
| Group | Inputs | Description |
|
||||
|-------|--------|-------------|
|
||||
| **Position** | palmX, palmY, depth | Spatial position of the hand |
|
||||
| **Pose** | 5 finger curls, finger spread | Hand shape |
|
||||
| **Orientation** | pitch, yaw, roll | Hand rotation |
|
||||
| **Pinch** | pinch distance, pinch confidence | Fine gesture |
|
||||
|
||||
Each group gets a single zoom slider. This is a backlog item for deeper exploration — see beads issue.
|
||||
|
||||
## Appendix B: Zoom-Training Coupling
|
||||
|
||||
When zoomed in, the training dynamics change fundamentally:
|
||||
|
||||
1. **Examples are clustered** — all new examples fall within the zoom window. The network learns fine distinctions in this small region.
|
||||
2. **Risk of catastrophic forgetting** — the network may distort its mapping for inputs outside the zoom window, since it's not seeing examples from those regions.
|
||||
3. **Pinning mitigates this** — pinned examples from the broader region anchor the network's behavior outside the zoom window.
|
||||
|
||||
**Recommended workflow for refinement**:
|
||||
1. Explore broadly, find a mapping you mostly like
|
||||
2. Pin the regions/parameters you want to keep
|
||||
3. Zoom into the area that needs refinement
|
||||
4. Use RL feedback at the zoomed-in scale
|
||||
5. Zoom back out to verify the big picture
|
||||
6. Unpin and iterate
|
||||
|
||||
This workflow should be surfaced in the help modal and possibly guided by an onboarding tooltip sequence.
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
# ShapeSeq — NISPS Generative Sequencing System
|
||||
|
||||
## Overview
|
||||
|
||||
ShapeSeq is a generative sequencing system for the NISPS playground where interactive ML (via the NISPS MLP engine) controls **parameters of algorithmic sequencing primitives** rather than raw note data. The user shapes sequences by navigating a learned parameter space with a joystick or hand tracking, can freeze sequences they like, then selectively re-expose specific parameters for further ML-driven exploration.
|
||||
|
||||
ShapeSeq replaces the existing placeholder arpeggiator. In synth output mode, the ShapeSeq UI (circular step visualizer + chain builder + param sliders) replaces the flow-field particle system.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **MLP outputs are abstract [0,1] values** — musical meaning is applied downstream by the primitive chain and its symbolic processing
|
||||
2. **Separate NISPS instances** for timbre control and sequence control, following the existing `imlJoy`/`imlHand` dual-instance pattern in `a-app.js`. Architecture supports future unification into a single instance
|
||||
3. **Port-ready JS** — no closures in hot paths, explicit state, data structures that map cleanly to C++ for future RP2040 firmware porting. Note: the event bus and clock orchestration are JS-only concerns and not expected to port directly; the primitives themselves are the portable layer
|
||||
4. **Modular primitives** — small, combinable algorithmic building blocks that generate musical patterns from continuous parameters
|
||||
5. **Symbolic chain** — primitives compose as transforms over a pattern *description*, not concrete values. Each primitive takes the previous "pattern-generating machine" specification and produces a new one. The chain is evaluated once per loop (or on param change) to produce a complete pattern, which the clock then steps through
|
||||
|
||||
### System Diagram
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Clock Engine │ ← drives everything
|
||||
│ (AudioContext) │
|
||||
└──────┬───────────┘
|
||||
│ tick
|
||||
▼
|
||||
┌──────────────────┐ ┌─────────────────┐
|
||||
│ Sequencer Core │◄────│ Input Router │
|
||||
│ (orchestrator) │ │ (configurable) │
|
||||
└──┬───────────┬───┘ └──┬──────────┬───┘
|
||||
│ │ │ │
|
||||
│ query │ query ┌────▼───┐ ┌───▼────────┐
|
||||
│ pattern │ MLP │NISPS │ │ NISPS MLP │
|
||||
│ │ │(timbre)│ │ (sequence) │
|
||||
│ │ └───┬────┘ └───┬────────┘
|
||||
│ │ │ │
|
||||
│ ┌──────▼────────┐ │ ┌─────▼──────────┐
|
||||
│ │ Param Mapping │ │ │ Param Mapping │
|
||||
│ │ (16 MLP outs │ │ │ (16 MLP outs → │
|
||||
│ │ → N prim │ │ │ 126 synth │
|
||||
│ │ params) │ │ │ params) │
|
||||
│ └──────┬────────┘ │ └─────┬──────────┘
|
||||
│ │ │ │
|
||||
│ ┌──────▼────────┐ │ ┌─────▼──────────┐
|
||||
│ │Delta Controller│ │ │ Synth Param Map │
|
||||
│ │(frozen+deltas)│ │ └─────┬──────────┘
|
||||
│ └──────┬────────┘ │ │
|
||||
│ │ │ │
|
||||
│ ┌──────▼────────┐ │ │
|
||||
│ │Primitive Chain │ │ │
|
||||
│ │(symbolic eval) │ │ │
|
||||
│ └──────┬────────┘ │ │
|
||||
│ │ │ │
|
||||
┌──────▼───────────▼───┐ │ │
|
||||
│ Namespaced Event Bus │ │ │
|
||||
│ seq.* ml.* ui.* │ │ │
|
||||
└──┬───────────────┬───┘ │ │
|
||||
│ │ │ │
|
||||
┌──────▼───────┐ ┌────▼───────────▼────────▼──┐
|
||||
│ Circular Viz │ │ C15 Synth │
|
||||
│ + Chain UI │ │ (noteOn/Off + params) │
|
||||
└──────────────┘ └────────────────────────────┘
|
||||
```
|
||||
|
||||
### Fixed MLP + Param Mapping Layer
|
||||
|
||||
The `WasmIML` creates an MLP with a **fixed output count** at construction time — it cannot be resized. Since the primitive chain is dynamic (users add/remove primitives, changing total param count), the MLP cannot output directly to primitive params.
|
||||
|
||||
**Solution:** The sequence MLP always outputs a fixed number of values (e.g., 16). A **param mapping layer** fans these 16 outputs to however many primitive params the current chain requires. This is the same pattern used by the timbre MLP (which maps to 126 synth params via `param-map.js`).
|
||||
|
||||
The mapping can be:
|
||||
- **Automatic** (default): outputs are distributed across primitive params in chain order. If there are 30 primitive params and 16 MLP outputs, each output influences ~2 params via interpolation.
|
||||
- Future: configurable user-defined mapping.
|
||||
|
||||
> **Design note:** The fixed-16-output approach is the simplest starting point. If experimentation reveals that 16 is too few (or too many), the MLP can be reconstructed with a different size — this is a one-time setup cost, not a per-frame cost. The mapping layer insulates the rest of the system from this choice. Revisit if the mapping layer becomes a bottleneck for expressiveness.
|
||||
|
||||
### Namespaced Event Bus
|
||||
|
||||
A pub/sub event system with namespaced channels:
|
||||
|
||||
| Namespace | Events | Purpose |
|
||||
|-----------|--------|---------|
|
||||
| `seq.*` | `seq.step`, `seq.noteOn`, `seq.noteOff`, `seq.paramChange`, `seq.loopStart` | Musical output from sequencer to synth and visualizer |
|
||||
| `ml.*` | `ml.trained`, `ml.frozen`, `ml.unfrozen`, `ml.deltaUpdate` | ML state changes |
|
||||
| `ui.*` | `ui.paramSelect`, `ui.chainEdit`, `ui.presetLoad`, `ui.freezeToggle` | User actions |
|
||||
|
||||
All events carry a timestamp (AudioContext.currentTime for `seq.*`, performance.now() for others).
|
||||
|
||||
Note: the event bus is a JS-only orchestration concern (string-namespaced pub/sub). It does not need to be port-ready — the portable layer is the primitives themselves.
|
||||
|
||||
### Input Routing Matrix
|
||||
|
||||
A configurable routing layer that maps any input source to either NISPS instance's inputs. Builds on the existing `imlJoy`/`imlHand` switching pattern in `a-app.js`.
|
||||
|
||||
**Input sources:**
|
||||
- Joystick X, Y (2 values)
|
||||
- Hand tracking features (14 values)
|
||||
- Gamepad axes (variable)
|
||||
|
||||
**Routing targets:**
|
||||
- Timbre NISPS input 0, input 1
|
||||
- Sequence NISPS input 0, input 1
|
||||
|
||||
Default: joystick → timbre NISPS, hand tracking features 0+1 → sequence NISPS. User-configurable via UI.
|
||||
|
||||
## Sequencing Primitives
|
||||
|
||||
### Primitive Categories
|
||||
|
||||
Primitives are categorized by their role in the chain:
|
||||
|
||||
| Category | Role | Examples |
|
||||
|----------|------|----------|
|
||||
| **Generator** | Produces data from params alone (no input required) | Euclidean, Density Morph, Pitch Walker |
|
||||
| **Processor** | Transforms incoming data | Probability Gate, Velocity Shaper |
|
||||
| **Timing Modifier** | Modulates the timing of events in the pattern description | Swing/Groove, Ratchet |
|
||||
| **Converter** | Changes data type (e.g., continuous → discrete) | Interval Lock |
|
||||
|
||||
**Generator combination rule:** When multiple generators appear in the same chain, their outputs combine according to the chain's **combination mode** (user-configurable in real time):
|
||||
- **Additive** (OR) — triggers from any generator fire. Pitch/velocity values are averaged where multiple generators contribute.
|
||||
- **Multiplicative** (AND) — only steps where ALL generators agree will fire. Creates sparser, more selective patterns.
|
||||
|
||||
### Symbolic Chain Evaluation
|
||||
|
||||
Primitives do NOT process concrete note data step-by-step. Instead, each primitive takes the previous **pattern description** (a symbolic representation of the entire sequence) and produces a new one. The complete chain is evaluated to produce a full pattern, which the clock then steps through.
|
||||
|
||||
This means:
|
||||
- **Timing modifiers** (Swing, Ratchet) work by annotating the pattern description with timing offsets and subdivisions *before* any concrete scheduling happens
|
||||
- The clock reads the finalized pattern description and schedules all events (including ratchet subdivisions and swing offsets) using AudioContext.currentTime
|
||||
- Re-evaluation happens when params change (MLP output updates, user edits), not on every tick
|
||||
|
||||
**Pattern description structure:**
|
||||
```javascript
|
||||
// The symbolic output of the chain — a complete loop description
|
||||
{
|
||||
steps: [
|
||||
{
|
||||
trigger: true, // whether this step fires
|
||||
pitch: 0.72, // [0,1] abstract pitch (pre-quantization)
|
||||
velocity: 0.85, // [0,1]
|
||||
accent: false, // accent flag
|
||||
timeOffset: 0.0, // swing offset in fractions of a step (-0.5 to +0.5)
|
||||
subdivisions: 1, // ratchet: 1 = normal, 2-4 = subdivided
|
||||
},
|
||||
// ... one per step
|
||||
],
|
||||
stepCount: 8,
|
||||
metadata: { ... } // chain-specific info for visualization
|
||||
}
|
||||
```
|
||||
|
||||
### Primitive Definitions
|
||||
|
||||
Each primitive is a pure function (or stateful generator with explicit state) that accepts a parameter object and produces typed output. All parameters are normalized [0,1].
|
||||
|
||||
#### 1. Euclidean Rhythm Generator
|
||||
|
||||
**Category:** Generator
|
||||
**Params:** `steps` (int, from continuous), `pulses` (int), `rotation` (int)
|
||||
**Output:** trigger pattern (boolean array)
|
||||
**Stateless:** yes
|
||||
|
||||
Generates Bjorklund-distributed trigger patterns. The continuous [0,1] params are projected to integer ranges based on current step count.
|
||||
|
||||
#### 2. Probability Gate
|
||||
|
||||
**Category:** Processor
|
||||
**Params:** `density` [0,1], `accentProbability` [0,1]
|
||||
**Input:** trigger pattern
|
||||
**Output:** filtered trigger pattern with accent flags
|
||||
**Stateless:** yes (per-step coin flip using seeded PRNG)
|
||||
|
||||
Each incoming trigger survives with probability `density`. Surviving triggers receive accent flag with probability `accentProbability`.
|
||||
|
||||
#### 3. Pitch Walker
|
||||
|
||||
**Category:** Generator
|
||||
**Params:** `stepSize` [0,1], `directionBias` [0,1] (0.5=unbiased), `gravity` [0,1] (pull toward center), `range` [0,1]
|
||||
**Output:** pitch values [0,1] per triggered step
|
||||
**Stateful:** yes — maintains current position in pitch space
|
||||
|
||||
Constrained random walk that generates melodic contour. `gravity` pulls the walk toward center (0.5), preventing it from getting stuck at extremes. State includes current position and PRNG state.
|
||||
|
||||
#### 4. Ratchet
|
||||
|
||||
**Category:** Timing Modifier
|
||||
**Params:** `maxDivision` [0,1] (maps to 1-4 subdivisions), `probability` [0,1]
|
||||
**Input:** pattern description with triggers
|
||||
**Output:** pattern description with `subdivisions` field set per step
|
||||
**Stateless:** yes (per-step coin flip)
|
||||
|
||||
Annotates triggered steps with subdivision counts. The clock engine reads `subdivisions` and schedules rapid repeats within the step's time window. Division count determined by `maxDivision`, applied probabilistically.
|
||||
|
||||
#### 5. Swing / Groove
|
||||
|
||||
**Category:** Timing Modifier
|
||||
**Params:** `swingAmount` [0,1] (0=straight, 1=full swing), `swingGrid` [0,1] (which subdivisions swing)
|
||||
**Input:** pattern description
|
||||
**Output:** pattern description with `timeOffset` field set per step
|
||||
**Stateless:** yes
|
||||
|
||||
Annotates alternating steps with timing offsets. At `swingAmount=0.67` this produces classic 2:1 shuffle. `swingGrid` controls whether swing applies to 8th notes, 16th notes, or triplets. The clock engine reads `timeOffset` and adjusts scheduling accordingly.
|
||||
|
||||
#### 6. Density Morph
|
||||
|
||||
**Category:** Generator
|
||||
**Params:** `density` [0,1], `clustering` [0,1] (0=spread evenly, 1=clustered together)
|
||||
**Output:** trigger pattern
|
||||
**Stateless:** yes
|
||||
|
||||
Alternative to Euclidean — generates trigger patterns with controllable density and spatial distribution. At high clustering, triggers group together creating bursts; at low clustering, triggers spread evenly.
|
||||
|
||||
#### 7. Interval Lock (Scale Quantizer)
|
||||
|
||||
**Category:** Converter
|
||||
**Params:** `root` [0,1] (maps to 0-11 semitones), `mode` [0,1] (maps to scale index), `octaveRange` [0,1] (1-4 octaves)
|
||||
**Input:** pitch values [0,1]
|
||||
**Output:** MIDI note numbers
|
||||
**Stateless:** yes
|
||||
|
||||
The sole pitch quantization mechanism — the projection layer does NOT duplicate this. All pitch quantization goes through Interval Lock.
|
||||
|
||||
Available scales: chromatic, major, natural minor, harmonic minor, pentatonic major, pentatonic minor, blues, dorian, mixolydian, whole tone, diminished.
|
||||
|
||||
#### 8. Velocity Shaper
|
||||
|
||||
**Category:** Processor
|
||||
**Params:** `curveType` [0,1] (maps to: flat, accent-every-N, crescendo, decrescendo, random), `depth` [0,1], `phase` [0,1]
|
||||
**Input:** trigger pattern with step indices
|
||||
**Output:** velocity values [0,1] per step
|
||||
**Stateless:** yes
|
||||
|
||||
Applies cyclic velocity patterns. `phase` rotates the pattern, `depth` controls contrast between quiet and loud.
|
||||
|
||||
### Primitive Interface
|
||||
|
||||
```javascript
|
||||
// Port-ready: explicit state, no closures
|
||||
class Primitive {
|
||||
constructor(name, paramSchema, category) { ... }
|
||||
|
||||
// category: 'generator' | 'processor' | 'timing' | 'converter'
|
||||
|
||||
// paramSchema: array of { name, min, max, default, boundary }
|
||||
// boundary: 'clamp' | 'wrap' | 'scaled'
|
||||
// For 'scaled': operates within ±scaledRange of frozen value
|
||||
|
||||
// Symbolic processing: transforms a pattern description
|
||||
process(params, patternDesc, state, rng) → { patternDesc, nextState }
|
||||
|
||||
// State management for freeze
|
||||
getState() → serializable object
|
||||
setState(state) → void
|
||||
getSeed() → number
|
||||
setSeed(seed) → void
|
||||
}
|
||||
```
|
||||
|
||||
### Chain Connection Modes
|
||||
|
||||
Three configurable modes for how primitives connect in a chain:
|
||||
|
||||
**1. Sequential Pipeline** — each primitive transforms the pattern description in order. Generators create initial data, processors/timing modifiers transform it. If multiple generators appear, they combine according to the generator combination mode (additive/multiplicative, configurable in real time).
|
||||
|
||||
**2. Parallel + Merge** — each primitive runs independently and produces a pattern description. Descriptions merge (OR for triggers in additive mode, AND in multiplicative mode; average for continuous values). Order doesn't matter.
|
||||
|
||||
**3. Typed Routing** — primitives connect via typed ports. A primitive's output connects to the next primitive that accepts that type. Multiple primitives can feed the same type (merged). Most flexible, most complex.
|
||||
|
||||
The chain connection mode is a global setting (per-chain), configurable via UI. Default: sequential pipeline.
|
||||
|
||||
**Generator combination mode** (additive/multiplicative) is an independent setting, also configurable in real time via UI.
|
||||
|
||||
## Delta Control System
|
||||
|
||||
### Freeze Workflow
|
||||
|
||||
1. User plays with NISPS, finds a sequence they like
|
||||
2. User activates **freeze** — all current parameter values are captured
|
||||
3. User selects specific parameters to **re-expose** (mark as "live"):
|
||||
- Click/tap parameters in the UI
|
||||
- Or use hand tracking: point with index finger, pinch gesture to toggle
|
||||
4. Live parameters receive **deltas** from NISPS MLP output
|
||||
5. Frozen parameters hold their captured values
|
||||
|
||||
### Freeze Modes
|
||||
|
||||
**Freeze as Algorithm** — captures parameter values + PRNG seed. Stateful primitives (pitch walker) will replay identically. Re-exposing params resumes algorithmic generation with delta-modified params.
|
||||
|
||||
**Freeze as Pattern** — captures the realized note pattern (snapshot of all step events for one full loop). The primitive chain is bypassed; the sequencer loops the frozen pattern directly. Re-exposing params requires switching back to algorithm mode.
|
||||
|
||||
User chooses freeze mode via UI toggle.
|
||||
|
||||
### Delta Boundary Behavior
|
||||
|
||||
Each parameter declares its boundary behavior:
|
||||
|
||||
| Behavior | Description | Good for |
|
||||
|----------|-------------|----------|
|
||||
| `clamp` | Delta result clamped to [0,1] | Velocity, volume, most continuous params |
|
||||
| `wrap` | Values wrap around (1.1 → 0.1) | Rotation, phase, cyclic params |
|
||||
| `scaled` | Delta operates within ±`scaledRange` centered on frozen value | Precision control near a sweet spot |
|
||||
|
||||
Parameters also declare a `scaledRange` (default 0.3) for the scaled boundary mode. Example: frozen value 0.8 with scaledRange 0.3 → effective range [0.5, 1.0], clamped at boundaries.
|
||||
|
||||
## Clock Engine
|
||||
|
||||
Replaces setTimeout-based arpeggiator with AudioContext-scheduled timing.
|
||||
|
||||
```javascript
|
||||
class ClockEngine {
|
||||
constructor(audioContext) { ... }
|
||||
|
||||
// Properties
|
||||
bpm // beats per minute
|
||||
stepCount // total steps in sequence
|
||||
|
||||
// Lookahead scheduling: schedule events slightly ahead of time
|
||||
// using AudioContext.currentTime for sample-accurate timing
|
||||
start() → void
|
||||
stop() → void
|
||||
setTempo(bpm) → void
|
||||
|
||||
// The clock reads the finalized pattern description and schedules
|
||||
// all events, including:
|
||||
// - timeOffset per step (swing)
|
||||
// - subdivisions per step (ratchet)
|
||||
// - accent flags (velocity scaling)
|
||||
schedulePattern(patternDesc) → void
|
||||
|
||||
// Callback: called with { stepIndex, time, velocity, pitch, isSubdivision }
|
||||
onEvent(callback) → void
|
||||
}
|
||||
```
|
||||
|
||||
The clock uses the standard Web Audio lookahead pattern:
|
||||
- A setInterval (~25ms) checks if any events need scheduling in the next ~100ms
|
||||
- Events are scheduled using AudioContext.currentTime for sample-accurate timing
|
||||
- This decouples visual updates (requestAnimationFrame) from audio timing
|
||||
- The clock handles ratchet subdivisions and swing offsets natively by reading the pattern description's per-step `subdivisions` and `timeOffset` fields
|
||||
|
||||
## Projection Layer
|
||||
|
||||
A composable chain of transform functions that convert raw [0,1] primitive outputs into final musical values. Each transform is a small, independent module.
|
||||
|
||||
Note: pitch quantization is handled by the **Interval Lock** primitive, not the projection layer. The projection layer handles non-pitch transforms only.
|
||||
|
||||
### Available Transforms
|
||||
|
||||
| Transform | Input | Output | Params |
|
||||
|-----------|-------|--------|--------|
|
||||
| Velocity Curve | [0,1] | [0,1] | curve shape (linear, exponential, S-curve) |
|
||||
| Gate Threshold | [0,1] | boolean | threshold value |
|
||||
| Range Map | [0,1] | [min,max] | min, max |
|
||||
| Octave Folder | MIDI note | MIDI note | target octave range |
|
||||
| Stutter Map | [0,1] | repeat count | max repeats |
|
||||
|
||||
Transforms snap together: output type of one must match input type of next. The chain is validated on construction.
|
||||
|
||||
### Projection Presets
|
||||
|
||||
Pre-built chain configurations for common use cases:
|
||||
- **Expressive** — velocity curve (exponential) → range map (48-84)
|
||||
- **Percussive** — gate threshold (0.5) → velocity curve (accent)
|
||||
- **Full Range** — range map (24-96) → velocity curve (linear)
|
||||
|
||||
Users can edit any preset or build custom chains.
|
||||
|
||||
## UI Design
|
||||
|
||||
### Mode Integration
|
||||
|
||||
ShapeSeq activates in **synth output mode**. When synth mode is active:
|
||||
- The flow-field particle visualizer is replaced by the ShapeSeq UI (circular step viz + chain builder + param sliders)
|
||||
- The timbre NISPS instance continues to control C15 synth parameters as before
|
||||
- The sequence NISPS instance drives the ShapeSeq primitive chain
|
||||
|
||||
In visual output mode, the particle system remains unchanged.
|
||||
|
||||
### Circular Step Visualizer
|
||||
|
||||
Steps arranged in a circle with even angular spacing (7 steps = heptagon, 13 steps = 13-gon, etc.). No grid overlay — the ear provides rhythmic context.
|
||||
|
||||
**Visual elements:**
|
||||
- Each step is a node on the circle
|
||||
- Active/triggered steps glow or pulse
|
||||
- Current playback position shown with a rotating indicator
|
||||
- Pitch mapped to node distance from center (low=outer, high=inner)
|
||||
- Velocity mapped to node size
|
||||
- Accents shown with brighter color
|
||||
|
||||
**Interaction:**
|
||||
- Tap a step to solo/mute it
|
||||
- Long-press for step detail (all params for that step)
|
||||
|
||||
### Primitive Chain Builder
|
||||
|
||||
Vertical stack layout (like a guitar pedalboard):
|
||||
- Each primitive is a card with its name and key params visible
|
||||
- Drag to reorder
|
||||
- Swipe left to delete
|
||||
- "+" button at bottom opens primitive palette
|
||||
- Each card expandable to show all params as sliders
|
||||
- Params marked as "live" (NISPS-controlled) get a distinct visual indicator (e.g., pulsing border)
|
||||
|
||||
### Parameter Selection (for freeze/re-expose)
|
||||
|
||||
Two input modes:
|
||||
1. **Mouse/touch** — tap a parameter slider to toggle it between frozen (dimmed) and live (highlighted)
|
||||
2. **Hand tracking** — point index finger at parameter, pinch to toggle. Visual cursor follows index finger tip.
|
||||
|
||||
Live params show their current NISPS delta as a secondary indicator on the slider.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Circular Step Viz │
|
||||
│ (upper half of screen) │
|
||||
│ │
|
||||
│ ○ ○ │
|
||||
│ ○ ○ │
|
||||
│ ○ ▶ ○ │
|
||||
│ ○ ○ │
|
||||
│ ○ ○ │
|
||||
│ │
|
||||
├──────────────────────────────────┤
|
||||
│ Chain Builder (scrollable stack) │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ Euclidean [steps][pulses] │ │
|
||||
│ │ [rotation] │ │
|
||||
│ ├──────────────────────────────┤ │
|
||||
│ │ Prob Gate [density][accent] │ │
|
||||
│ ├──────────────────────────────┤ │
|
||||
│ │ Pitch Walk [step][bias] │ │
|
||||
│ ├──────────────────────────────┤ │
|
||||
│ │ [ + Add Primitive ] │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
├──────────────────────────────────┤
|
||||
│ [▶ Play] [❄ Freeze] [Chain:Seq] │
|
||||
│ [+×] BPM:120 Steps:8 Gen:Add │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Phased Implementation Plan
|
||||
|
||||
### Phase 1 — Foundation (MVP)
|
||||
|
||||
**Goal:** All 8 primitives working, chain builder, basic UI, NISPS control. Full architecture with minimal polish.
|
||||
|
||||
1. **Event bus** — namespaced pub/sub system
|
||||
2. **Clock engine** — AudioContext-based precise timing with pattern description scheduling (handles swing offsets + ratchet subdivisions)
|
||||
3. **Primitive framework** — base class, param schema, category system, state management, symbolic pattern description structure
|
||||
4. **All 8 primitives** — implement each with their param schemas and categories
|
||||
5. **Sequential chain** — primitives connected in sequence (pipeline mode only), with additive/multiplicative generator combination mode
|
||||
6. **Param mapping layer** — fixed 16-output MLP → N primitive params, automatic distribution
|
||||
7. **Projection layer** — velocity curve + gate threshold (2 transforms minimum, no scale quantizer — that's Interval Lock)
|
||||
8. **Sequence NISPS instance** — second WasmIML (16 outputs), following existing `imlJoy`/`imlHand` pattern
|
||||
9. **Basic circular viz** — step circle with playback indicator
|
||||
10. **Basic chain UI** — vertical stack with sliders, add/remove primitives, generator combo mode toggle
|
||||
11. **Bridge integration** — sequence events → C15 noteOn/noteOff via event bus
|
||||
12. **Replace arpeggiator** — remove old arpeggiator, ShapeSeq takes over in synth mode
|
||||
|
||||
### Phase 2 — Freeze & Delta Control
|
||||
|
||||
1. **Freeze system** — capture params + seed, capture pattern snapshot
|
||||
2. **Parameter selection UI** — click to toggle frozen/live
|
||||
3. **Delta controller** — applies MLP deltas to live params with boundary config
|
||||
4. **Hand tracking param select** — pinch gesture to toggle params
|
||||
5. **Freeze mode toggle** — algorithm vs pattern freeze
|
||||
|
||||
### Phase 3 — Advanced Chain & Routing
|
||||
|
||||
1. **Parallel + merge chain mode**
|
||||
2. **Typed routing chain mode**
|
||||
3. **Input routing matrix** — configurable input → NISPS instance mapping
|
||||
4. **Projection chain builder** — user-editable transform chains
|
||||
5. **Projection presets**
|
||||
|
||||
### Phase 4 — Polish & Expansion
|
||||
|
||||
1. **Preset chains** — pre-built primitive combinations for common genres
|
||||
2. **Save/load** — persist chain configs, frozen sequences, NISPS state
|
||||
3. **Unified NISPS mode** — single MLP controlling both timbre + sequence
|
||||
4. **Additional primitives** as discovered through experimentation
|
||||
5. **Freeform lasso param selection** (see Future Work)
|
||||
6. **Per-track variable step counts** (polyrhythm)
|
||||
|
||||
## Open Design Questions
|
||||
|
||||
These are deliberately deferred decisions to be revisited after experimentation:
|
||||
|
||||
1. **MLP output count:** Is 16 the right number for the sequence MLP? Too few may limit expressiveness; too many may make learning harder. The param mapping layer insulates the system, so this can be changed without architectural impact.
|
||||
2. **Param mapping strategy:** Automatic distribution is the starting point. Should users be able to manually wire MLP outputs to specific primitive params? This could enable more intentional control but adds UI complexity.
|
||||
3. **Generator combination modes:** Additive and multiplicative are the starting pair. Other modes worth exploring: weighted average, priority (first generator wins), XOR (one or the other but not both).
|
||||
4. **Chain evaluation frequency:** Currently re-evaluates when params change. Should there be an option for per-loop re-evaluation (stateful primitives produce different patterns each loop)?
|
||||
|
||||
## Future Work
|
||||
|
||||
- **Freeform lasso selection** — draw/lasso over the step visualization to select params spatially. Intuitive but complex to implement. (Backlog issue: meml-hud)
|
||||
- **MIDI clock sync** — accept external MIDI clock for hardware sync
|
||||
- **OSC output** — route sequencer events via OSC for external software/hardware
|
||||
- **C++ port** — port primitive framework and chain system to nisps-core for RP2040 firmware
|
||||
- **Multi-track** — multiple independent primitive chains running simultaneously with different step counts (polyrhythm)
|
||||
- **Markov chain primitive** — transition-probability-based note selection
|
||||
- **L-system primitive** — Lindenmayer system string rewriting for self-similar patterns
|
||||
- **Cellular automata primitive** — 1D CA rules (e.g., Rule 30) generating trigger patterns
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Port-Ready JS Conventions
|
||||
|
||||
To facilitate future C++ porting of the **primitive layer**:
|
||||
- No closures in primitive process functions — all state is explicit
|
||||
- Use typed arrays (Float32Array) for parameter vectors where possible
|
||||
- Primitives are pure functions with explicit state in/out
|
||||
- Seeded PRNG (not Math.random()) for deterministic replay
|
||||
- All time values in seconds (AudioContext convention), not milliseconds
|
||||
|
||||
The orchestration layer (event bus, clock, UI) is JS-only and not expected to port.
|
||||
|
||||
### PRNG
|
||||
|
||||
Use a seedable PRNG (e.g., mulberry32 or xoshiro128) so that:
|
||||
- Freeze-as-algorithm can replay identical sequences from seed
|
||||
- Different primitives in a chain get independent PRNG streams (derived from a master seed)
|
||||
- Deterministic behavior aids debugging and reproducibility
|
||||
|
||||
### Performance Budget
|
||||
|
||||
The chain evaluates on param change, not per tick. The clock merely steps through the pre-computed pattern description. Per-tick cost is minimal: read the next step from the pattern, schedule the event. MLP inference (~<1ms) only runs when input changes.
|
||||
|
||||
Chain re-evaluation (all 8 primitives) happens when the MLP output changes. At ~60fps input update rate, this means ~16ms budget per evaluation. Each primitive is simple math, so 8 primitives is well within budget even on mobile.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
|
||||
|
||||
## synth view Immersive UI
|
||||
- [x] add hover tooltip for each parameter slider (canvas tooltip follows mouse, shows name/value/range/curve)
|
||||
- [x] hovering on the name of a module/group at the top should open a little drawer panel that allows us to set
|
||||
- [x] minimum and maximum values for each parameter (dual-thumb range slider)
|
||||
- [x] a curve parameter that's normalised and goes between logarithmic and exponential, with a little graph to visualise, to skew the distribution in either direction (per-param draggable canvas + group master curve with relative adjustment)
|
||||
- [x] mute toggle per parameter (removes from NISPS, replaces with fixed value slider)
|
||||
- [x] if the audio engine hasn't been initialised yet, the play button at the top left should be pulsing and have an orange highlight
|
||||
|
|
@ -1,446 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>NISPS Immersive</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/a-immersive.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Back button -->
|
||||
<a href="designs.html" class="back-btn" title="Back to designs">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 2L4 8l6 6"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Synth quick controls (play button + drawer, next to back button) -->
|
||||
<div class="synth-quick-controls hidden" id="synth-quick-controls">
|
||||
<button class="play-btn" id="quick-play" title="Start audio + arpeggiator">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" id="quick-play-icon"><path d="M4 2l10 6-10 6z"/></svg>
|
||||
</button>
|
||||
<div class="play-drawer" id="play-drawer">
|
||||
<label>Vol <input type="range" id="quick-vol" min="0" max="1" step="0.01" value="0.5"></label>
|
||||
<label>BPM <input type="range" id="quick-bpm" min="40" max="240" step="1" value="120"><span id="quick-bpm-val">120</span></label>
|
||||
</div>
|
||||
<select class="preset-select" id="synth-preset-select" title="Synth preset">
|
||||
<option value="">Manual</option>
|
||||
<optgroup label="Beginner">
|
||||
<option value="beginner-1">1.1</option>
|
||||
<option value="beginner-2">1.2</option>
|
||||
<option value="beginner-3">1.3</option>
|
||||
<option value="beginner-4">1.4</option>
|
||||
</optgroup>
|
||||
<optgroup label="Intermediate">
|
||||
<option value="intermediate-1">2.1</option>
|
||||
<option value="intermediate-2">2.2</option>
|
||||
<option value="intermediate-3">2.3</option>
|
||||
<option value="intermediate-4">2.4</option>
|
||||
</optgroup>
|
||||
<optgroup label="Advanced">
|
||||
<option value="advanced-1">3.1</option>
|
||||
<option value="advanced-2">3.2</option>
|
||||
<option value="advanced-3">3.3</option>
|
||||
</optgroup>
|
||||
<optgroup label="Expert">
|
||||
<option value="expert-1">4.1</option>
|
||||
<option value="expert-2">4.2</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- MIDI CC quick controls (shown in midi-cc mode, mirrors synth quick controls layout) -->
|
||||
<div class="midi-cc-quick-controls hidden" id="midi-cc-quick-controls">
|
||||
<button class="play-btn" id="midi-cc-enable-btn" title="Enable MIDI output">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M4 9v2M6 7v4M8 8v3M10 7v4M12 9v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<select class="preset-select" id="midi-cc-output-select" title="MIDI output device">
|
||||
<option value="">No device</option>
|
||||
</select>
|
||||
<select class="preset-select" id="midi-cc-quick-preset" title="MIDI CC preset" style="max-width:130px">
|
||||
<option value="">Manual</option>
|
||||
</select>
|
||||
<span class="synth-status" id="midi-cc-status" style="font-size:0.65rem;opacity:0.7"></span>
|
||||
</div>
|
||||
|
||||
<!-- Audio Canvas mode container -->
|
||||
<div id="audio-canvas-wrap" style="display:none;position:absolute;inset:0;z-index:1"></div>
|
||||
|
||||
<!-- ShapeSeq container (visible when ?shapeseq=1 and synth mode) -->
|
||||
<div id="shapeseq-container" style="display:none;position:absolute;bottom:120px;left:16px;z-index:10;pointer-events:auto;">
|
||||
<canvas id="shapeseq-viz" width="200" height="200" style="border-radius:12px;background:#0d0d0d;"></canvas>
|
||||
<div id="shapeseq-chain"></div>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen flow field canvas -->
|
||||
<canvas id="vis-canvas"></canvas>
|
||||
|
||||
<!-- Synth visualization canvas -->
|
||||
<canvas id="synth-vis-canvas"></canvas>
|
||||
|
||||
<!-- Heatmap strip (top) — bars are click/draggable -->
|
||||
<div class="heatmap-strip" id="heatmap-strip">
|
||||
<div class="heatmap-cells" id="heatmap-cells"></div>
|
||||
<div class="heatmap-tooltip" id="heatmap-tooltip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Floating joystick with integrated map -->
|
||||
<div class="joystick-container" id="joystick-container">
|
||||
<div class="joystick-glow" id="joystick-glow"></div>
|
||||
<canvas id="joy-map" width="160" height="160"></canvas>
|
||||
<div class="noise-ring" id="noise-ring"></div>
|
||||
<div class="follow-badge hidden" id="follow-badge">FOLLOW</div>
|
||||
<div id="gamepad-status" style="color: #ff6a00; font-size: 0.6rem; text-align: center; position: absolute; bottom: -16px; left: 0; right: 0; pointer-events: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- EOC Independent mode joystick (shown only in independent nispsMode) -->
|
||||
<div id="eoc-joy-container" class="eoc-joy-container hidden">
|
||||
<canvas id="eoc-joy-map" class="eoc-joy-map" width="120" height="120"></canvas>
|
||||
<div class="eoc-joy-label">FX</div>
|
||||
</div>
|
||||
|
||||
<!-- Hand tracking PIP (replaces joystick position when active) -->
|
||||
<div class="hand-pip hidden" id="hand-pip">
|
||||
<video id="hand-video" playsinline autoplay muted></video>
|
||||
<canvas id="hand-overlay"></canvas>
|
||||
<div class="hand-status" id="hand-status">Loading...</div>
|
||||
<div class="gesture-indicator" id="gesture-indicator">
|
||||
<svg class="gesture-ring" viewBox="0 0 36 36">
|
||||
<circle class="gesture-ring-bg" cx="18" cy="18" r="16" />
|
||||
<circle class="gesture-ring-progress" cx="18" cy="18" r="16" />
|
||||
</svg>
|
||||
<span class="gesture-label" id="gesture-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RL floating buttons (with undo between/below) -->
|
||||
<div class="rl-buttons" id="rl-buttons">
|
||||
<span class="rl-label hidden" id="rl-label">Synth</span>
|
||||
<button class="rl-btn rl-down" id="btn-thumbsdown" title="Explore more"><span class="rl-icon">−</span><span class="key-num">1</span></button>
|
||||
<button class="rl-btn rl-up" id="btn-thumbsup" title="Keep this"><span class="rl-icon">+</span><span class="key-num">2</span></button>
|
||||
<button class="rl-undo-btn" id="btn-undo" title="Undo last action">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7h6a4 4 0 0 1 0 8H7"/><path d="M3 7l3-3M3 7l3 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- EOC RL buttons (shown only in Linked mode) -->
|
||||
<div id="eoc-rl-buttons" class="eoc-rl-buttons hidden">
|
||||
<span class="eoc-rl-label">FX</span>
|
||||
<button id="eoc-rl-minus" class="rl-btn eoc-rl-btn rl-down" title="Explore FX (change effects)"><span class="rl-icon">−</span><span class="key-num">3</span></button>
|
||||
<button id="eoc-rl-plus" class="rl-btn eoc-rl-btn rl-up" title="Keep FX (reinforce effects)"><span class="rl-icon">+</span><span class="key-num">4</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Status line (floating, minimal) -->
|
||||
<div class="status-line" id="status-line">
|
||||
<span id="status-text">0 examples · untrained</span>
|
||||
</div>
|
||||
|
||||
<!-- Right-side dock (macOS-style) -->
|
||||
<div class="dock" id="dock">
|
||||
<button class="dock-icon" data-drawer="training" title="Training">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 8h10"/><rect x="1" y="5" width="3" height="6" rx="0.5"/><rect x="12" y="5" width="3" height="6" rx="0.5"/><rect x="3" y="6.5" width="2" height="3" rx="0.3"/><rect x="11" y="6.5" width="2" height="3" rx="0.3"/>
|
||||
</svg>
|
||||
<span class="dock-label">Train</span>
|
||||
</button>
|
||||
<button class="dock-icon" data-drawer="mode" title="Input / Output mode">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="5" cy="5" r="2"/><circle cx="11" cy="11" r="2"/><path d="M3 13L13 3"/>
|
||||
</svg>
|
||||
<span class="dock-label">Mode</span>
|
||||
</button>
|
||||
<button class="dock-icon" data-drawer="synth" title="Synth controls">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 10c2-4 4-4 6 0s4-4 6 0"/>
|
||||
</svg>
|
||||
<span class="dock-label">Synth</span>
|
||||
</button>
|
||||
<button class="dock-icon" data-drawer="params" title="NISPS ML parameters">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="3"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41"/>
|
||||
</svg>
|
||||
<span class="dock-label">NISPS</span>
|
||||
</button>
|
||||
<button class="dock-icon" data-drawer="eoc" title="Effects Chain" aria-label="Effects Chain">
|
||||
<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor">
|
||||
<path d="M4 8a4 4 0 0 1 4-4h1a1 1 0 0 0 0-2H8a6 6 0 0 0 0 12h1a1 1 0 0 0 0-2H8a4 4 0 0 1-4-4zm8 0a4 4 0 0 1-4 4H7a1 1 0 0 0 0 2h1a6 6 0 0 0 0-12H7a1 1 0 0 0 0 2h1a4 4 0 0 1 4 4z"/>
|
||||
</svg>
|
||||
<span class="dock-label">FX</span>
|
||||
</button>
|
||||
<button class="dock-icon" data-drawer="help" title="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6"/><path d="M6 6a2 2 0 0 1 4 0c0 1.5-2 1.5-2 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="dock-label">Help</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drawer panels (stack on right side, left of dock) -->
|
||||
<div class="drawer-stack" id="drawer-stack">
|
||||
<!-- Training drawer -->
|
||||
<div class="drawer hidden" id="drawer-training" data-drawer="training">
|
||||
<div class="drawer-header">
|
||||
<span>Training</span>
|
||||
<button class="drawer-close" data-drawer="training">×</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<div class="action-row" id="examples-actions">
|
||||
<button class="action-btn" id="btn-add-example">Add Example</button>
|
||||
<button class="action-btn accent" id="btn-train">Train</button>
|
||||
<button class="action-btn dim" id="btn-clear-examples">Clear Ex</button>
|
||||
<button class="action-btn dim" id="btn-clear">Clear All</button>
|
||||
<button class="action-btn dim" id="btn-randomize">Randomize</button>
|
||||
</div>
|
||||
<div class="preset-row" id="preset-row">
|
||||
<span class="preset-label">Presets</span>
|
||||
<button class="preset-chip" data-preset="calm-to-chaotic">Calm/Chaos</button>
|
||||
<button class="preset-chip" data-preset="rainbow-sweep">Rainbow</button>
|
||||
<button class="preset-chip" data-preset="vortex">Vortex</button>
|
||||
<button class="preset-chip" data-preset="spiral">Spiral</button>
|
||||
<button class="preset-chip" data-preset="embers">Embers</button>
|
||||
</div>
|
||||
<div class="loss-section">
|
||||
<label>Loss History</label>
|
||||
<canvas id="loss-canvas" width="280" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode drawer -->
|
||||
<div class="drawer hidden" id="drawer-mode" data-drawer="mode">
|
||||
<div class="drawer-header">
|
||||
<span>Mode</span>
|
||||
<button class="drawer-close" data-drawer="mode">×</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<div class="mode-group">
|
||||
<span class="mode-label">Input</span>
|
||||
<div class="pill-toggle pill-toggle-sm" id="input-toggle">
|
||||
<button class="pill-opt active" data-input="joystick">Joystick</button>
|
||||
<button class="pill-opt" data-input="hands">Hands</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-group">
|
||||
<span class="mode-label">Output</span>
|
||||
<div class="pill-toggle pill-toggle-sm" id="output-toggle-float">
|
||||
<button class="pill-opt active" data-mode="visual">Visual</button>
|
||||
<button class="pill-opt" data-mode="synth" id="synth-mode-btn">Synth</button>
|
||||
<button class="pill-opt" data-mode="midi-cc">MIDI CC</button>
|
||||
<button class="pill-opt" data-mode="audio-canvas">Audio Canvas</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-group">
|
||||
<button class="follow-pill" id="follow-pill" title="Toggle follow mode">Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Synth drawer -->
|
||||
<div class="drawer hidden" id="drawer-synth" data-drawer="synth">
|
||||
<div class="drawer-header">
|
||||
<span>Synth</span>
|
||||
<button class="drawer-close" data-drawer="synth">×</button>
|
||||
</div>
|
||||
<div class="drawer-body" id="synth-panel">
|
||||
<div id="synth-engine-switcher"></div>
|
||||
<div class="synth-row">
|
||||
<button class="action-btn accent" id="synth-start">Start Audio</button>
|
||||
<span class="synth-status" id="synth-status">Stopped</span>
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Volume</label>
|
||||
<input type="range" id="synth-volume" min="0" max="1" step="0.01" value="0.5">
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Arpeggiator</label>
|
||||
<button class="action-btn" id="arp-toggle">Play</button>
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Progression</label>
|
||||
<select id="arp-progression">
|
||||
<option value="I-vi-IV-V">I-vi-IV-V</option>
|
||||
<option value="I-IV-vi-V">I-IV-vi-V</option>
|
||||
<option value="i-VI-III-VII">i-VI-III-VII</option>
|
||||
<option value="I-V-vi-IV">I-V-vi-IV</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Tempo <span id="tempo-val">120</span></label>
|
||||
<input type="range" id="arp-tempo" min="40" max="240" step="1" value="120">
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Octaves <span id="octaves-val">2</span></label>
|
||||
<input type="range" id="arp-octaves" min="1" max="5" step="1" value="2">
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Oct Offset <span id="offset-val">0</span></label>
|
||||
<input type="range" id="arp-offset" min="-2" max="3" step="1" value="0">
|
||||
</div>
|
||||
<div class="synth-row" id="midi-row" style="display:none">
|
||||
<label>MIDI Input</label>
|
||||
<button class="action-btn" id="midi-toggle">Enable</button>
|
||||
<select id="midi-select" style="flex:1;min-width:0"></select>
|
||||
</div>
|
||||
<div class="synth-row" id="midi-status-row" style="display:none">
|
||||
<span class="synth-status" id="midi-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MIDI CC drawer (param management) -->
|
||||
<div class="drawer hidden" id="drawer-midi-cc" data-drawer="midi-cc">
|
||||
<div class="drawer-header">
|
||||
<span>MIDI CC</span>
|
||||
<button class="drawer-close" data-drawer="midi-cc">×</button>
|
||||
</div>
|
||||
<div class="drawer-body" id="midi-cc-panel">
|
||||
<div class="synth-row">
|
||||
<label>Preset</label>
|
||||
<select id="midi-cc-preset-select" style="flex:1;min-width:0">
|
||||
<option value="">Manual</option>
|
||||
</select>
|
||||
<button class="action-btn" id="midi-cc-file-import" title="Load preset from JSON file">File</button>
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Parameters</label>
|
||||
<span id="midi-cc-count">8</span>
|
||||
<button class="action-btn" id="midi-cc-add" title="Add CC parameter">+</button>
|
||||
<button class="action-btn" id="midi-cc-remove" title="Remove last CC parameter">−</button>
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<button class="action-btn" id="midi-cc-import">Paste JSON</button>
|
||||
<button class="action-btn" id="midi-cc-export">Copy JSON</button>
|
||||
</div>
|
||||
<div id="midi-cc-param-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Params drawer (NISPS ML engine tuning) -->
|
||||
<div class="drawer hidden" id="drawer-params" data-drawer="params">
|
||||
<div class="drawer-header">
|
||||
<span>NISPS</span>
|
||||
<button class="drawer-close" data-drawer="params">×</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<div class="engine-params" id="engine-params"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EOC Effects Chain drawer -->
|
||||
<div class="drawer hidden" id="drawer-eoc" data-drawer="eoc">
|
||||
<div class="drawer-header">
|
||||
<span>Effects Chain</span>
|
||||
<button class="drawer-close" data-drawer="eoc">×</button>
|
||||
</div>
|
||||
<div class="drawer-body" id="eoc-drawer-body">
|
||||
<!-- EOCChainUI renders here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help modal (unchanged) -->
|
||||
<div class="help-overlay hidden" id="help-overlay">
|
||||
<div class="help-modal" id="help-modal">
|
||||
<button class="help-close" id="help-close">×</button>
|
||||
<h2>Welcome to NISPS</h2>
|
||||
<p class="help-subtitle">Neural Interactive Shaping of Parameter Spaces</p>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>What is this?</h3>
|
||||
<p>NISPS lets you <strong>teach a neural network</strong> how to map a 2D joystick position to a rich set of visual or audio parameters. Move the joystick, shape the outputs you want, and the network learns your preferences in real time.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>How it works</h3>
|
||||
<p>A small neural network takes your joystick X/Y position as input and produces dozens of output parameters. You teach it by either:</p>
|
||||
<ul>
|
||||
<li><strong>Examples</strong> — set the parameter sliders to what you want at a given joystick position, add the example, then hit Train. Do this a few times from different positions and the network interpolates between them.</li>
|
||||
<li><strong>Feedback (RL)</strong> — move the joystick around. If you like what you see/hear, press <strong>+</strong> (keep this). If you don't, press <strong>−</strong> (explore more). The network gradually learns what you prefer.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>What to expect</h3>
|
||||
<ul>
|
||||
<li><strong>Moving the joystick</strong> changes all outputs in real time through the network</li>
|
||||
<li><strong>Randomize</strong> shuffles the network weights — instant new mapping</li>
|
||||
<li><strong>Train</strong> fits the network to your saved examples</li>
|
||||
<li><strong>+ / − feedback</strong> nudges the network: + reinforces the current mapping, − adds exploration noise</li>
|
||||
<li>Switch between <strong>Visual</strong> (particle flow field) and <strong>Synth</strong> (C15 synthesizer) output modes</li>
|
||||
<li>The <strong>heatmap bar</strong> at the top shows all output parameters at a glance — drag any bar to set its value</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>Controls</h3>
|
||||
<table class="help-keys">
|
||||
<tr><th colspan="2">Touch / Mouse</th></tr>
|
||||
<tr><td>Drag joystick</td><td>Move through parameter space</td></tr>
|
||||
<tr><td>+ / − buttons</td><td>Positive / negative feedback</td></tr>
|
||||
<tr><td>Undo (between +/−)</td><td>Revert last feedback action</td></tr>
|
||||
<tr><td>Drag heatmap bar</td><td>Set parameter value directly</td></tr>
|
||||
<tr><td>Right-side dock</td><td>Training, Mode, Synth, Params drawers</td></tr>
|
||||
<tr><th colspan="2">Keyboard</th></tr>
|
||||
<tr><td><kbd>1</kbd></td><td>Negative feedback (−)</td></tr>
|
||||
<tr><td><kbd>2</kbd></td><td>Positive feedback (+)</td></tr>
|
||||
<tr><td><kbd>3</kbd></td><td>FX negative feedback (Linked mode)</td></tr>
|
||||
<tr><td><kbd>4</kbd></td><td>FX positive feedback (Linked mode)</td></tr>
|
||||
<tr><td><kbd>Z</kbd></td><td>Undo</td></tr>
|
||||
<tr><th colspan="2">Gamepad (Steam Deck, Xbox, etc.)</th></tr>
|
||||
<tr><td>Left stick</td><td>Joystick control</td></tr>
|
||||
<tr><td>LB (left bumper)</td><td>Negative feedback</td></tr>
|
||||
<tr><td>RB (right bumper)</td><td>Positive feedback</td></tr>
|
||||
<tr><td>A</td><td>Train</td></tr>
|
||||
<tr><td>X</td><td>Randomize</td></tr>
|
||||
<tr><td>B</td><td>Clear examples</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>Hand Tracking</h3>
|
||||
<p>Toggle to <strong>Hands</strong> mode in the Mode drawer to use your webcam for input.</p>
|
||||
<ul>
|
||||
<li><strong>Right hand</strong> controls parameters — palm position, finger curls, spread, rotation, and pinch map to 14 input dimensions</li>
|
||||
<li><strong>Left hand</strong> gives feedback via gestures:</li>
|
||||
<ul>
|
||||
<li><strong>1 finger</strong> (index) held 0.4s → positive feedback (+)</li>
|
||||
<li><strong>2 fingers</strong> (index + middle) held 0.4s → negative feedback (−)</li>
|
||||
</ul>
|
||||
<li>If only one hand is visible, it's treated as the tracking hand — use keyboard/buttons for feedback</li>
|
||||
<li>Camera permission is requested only when you switch to Hands mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>Synth Controls</h3>
|
||||
<ul>
|
||||
<li><strong>Presets</strong> — use the dropdown (top left, next to play) to choose a parameter preset. Lower tiers (1.x) expose fewer parameters for simpler exploration; higher tiers (3.x, 4.x) unlock cross-modulation, feedback, and the full engine. You can tweak any preset afterwards using the controls below.</li>
|
||||
<li><strong>Hover any bar</strong> in the synth visualizer to see parameter name, current value, range, and curve</li>
|
||||
<li><strong>Hover a group name</strong> (e.g. "Env A", "Osc B") at the top to open its control drawer:</li>
|
||||
<ul>
|
||||
<li><strong>Group curve</strong> — drag the top graph vertically to shape the response curve for the whole group</li>
|
||||
<li><strong>Per-param curve</strong> — drag each small graph to shape that parameter individually</li>
|
||||
<li><strong>Min/Max range</strong> — dual-thumb slider constrains the parameter's output range</li>
|
||||
<li><strong>Mute (M)</strong> — removes the parameter from NISPS control</li>
|
||||
</ul>
|
||||
<li><strong>MIDI input</strong> — connect a MIDI controller and it will be detected automatically</li>
|
||||
<li><strong>Arpeggiator</strong> — open the Synth drawer in the dock to access tempo, octave range, offset, and chord progression controls</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="help-got-it" id="help-got-it">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/a-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>NISPS Workbench</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/b-workbench.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="workbench">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="wb-header">
|
||||
<div class="wb-header-left">
|
||||
<a href="designs.html" class="wb-back-btn" title="Back">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 2L4 8l6 6"/></svg>
|
||||
</a>
|
||||
<h1 class="wb-title">NISPS <span class="wb-title-accent">Workbench</span></h1>
|
||||
</div>
|
||||
<div class="wb-header-right">
|
||||
<span class="wb-mode-badge" id="mode-badge">Visual</span>
|
||||
<button class="wb-help-btn" id="help-btn" title="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main grid -->
|
||||
<div class="wb-grid">
|
||||
|
||||
<!-- Left column: Canvas -->
|
||||
<section class="wb-panel wb-canvas-panel">
|
||||
<div class="wb-panel-header">
|
||||
<span class="wb-panel-title">Flow Field</span>
|
||||
<div class="presets" id="presets-visual">
|
||||
<button class="preset-pill" data-preset="calm-to-chaotic">Calm/Chaos</button>
|
||||
<button class="preset-pill" data-preset="rainbow-sweep">Rainbow</button>
|
||||
<button class="preset-pill" data-preset="vortex">Vortex</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wb-canvas-wrap">
|
||||
<canvas id="visual-canvas"></canvas>
|
||||
<canvas id="synth-vis-canvas" class="hidden"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right column: Input Space (merged joystick + mapping) -->
|
||||
<section class="wb-panel wb-mapping-panel">
|
||||
<div class="wb-panel-header">
|
||||
<span class="wb-panel-title">Input Space</span>
|
||||
<button class="wb-follow-toggle-btn" id="follow-toggle-btn">Follow</button>
|
||||
</div>
|
||||
<div class="wb-mapping-area">
|
||||
<div class="wb-heatmap-wrap">
|
||||
<canvas id="mapping-canvas" width="280" height="280"></canvas>
|
||||
<div class="wb-follow-badge hidden" id="follow-badge">FOLLOW</div>
|
||||
<div class="wb-heatmap-label">Drag to explore · double-click or <kbd>f</kbd> to toggle follow</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Constellation -->
|
||||
<div class="wb-constellation-wrap">
|
||||
<canvas id="constellation-canvas" width="120" height="120"></canvas>
|
||||
<span class="wb-constellation-label" id="constellation-label">20 parameters</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bottom left: Param Groups -->
|
||||
<section class="wb-panel wb-params-panel">
|
||||
<div class="wb-panel-header">
|
||||
<span class="wb-panel-title">Parameters</span>
|
||||
<div class="wb-output-toggle">
|
||||
<button class="wb-toggle-btn active" id="toggle-visual" data-mode="visual">Visual</button>
|
||||
<button class="wb-toggle-btn" id="toggle-synth" data-mode="synth">Synth</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wb-param-groups" id="param-groups"></div>
|
||||
</section>
|
||||
|
||||
<!-- Bottom right: Controls + Metrics -->
|
||||
<section class="wb-panel wb-controls-panel">
|
||||
<div class="wb-panel-header">
|
||||
<span class="wb-panel-title">Controls</span>
|
||||
</div>
|
||||
|
||||
<!-- Unified controls -->
|
||||
<div class="wb-action-group" id="controls">
|
||||
<button class="wb-btn wb-btn-bad" id="btn-thumbsdown"><span class="wb-btn-label">− Explore</span><span class="wb-key-hint">1</span></button>
|
||||
<button class="wb-btn wb-btn-good" id="btn-thumbsup"><span class="wb-btn-label">+ Good</span><span class="wb-key-hint">2</span></button>
|
||||
<button class="wb-btn wb-btn-primary" id="btn-add">Add Example</button>
|
||||
<button class="wb-btn wb-btn-accent" id="btn-train">Train</button>
|
||||
<button class="wb-btn" id="btn-randomize">Randomize</button>
|
||||
<button class="wb-btn" id="btn-clear-examples">Clear Examples</button>
|
||||
<button class="wb-btn wb-btn-danger" id="btn-clear">Clear All</button>
|
||||
</div>
|
||||
|
||||
<!-- Synth controls (shown in synth mode) -->
|
||||
<div class="wb-synth-controls hidden" id="synth-controls">
|
||||
<div class="wb-synth-row">
|
||||
<button class="wb-btn wb-btn-primary" id="synth-start">Start Audio</button>
|
||||
<span class="wb-synth-status" id="synth-status">Stopped</span>
|
||||
</div>
|
||||
<div class="wb-synth-row">
|
||||
<label>Volume</label>
|
||||
<input type="range" id="synth-volume" min="0" max="1" step="0.01" value="0.5">
|
||||
</div>
|
||||
<div class="wb-synth-row">
|
||||
<button class="wb-btn" id="arp-toggle">Arp: Play</button>
|
||||
<select id="arp-progression">
|
||||
<option value="I-vi-IV-V">I-vi-IV-V</option>
|
||||
<option value="I-IV-vi-V">I-IV-vi-V</option>
|
||||
<option value="i-VI-III-VII">i-VI-III-VII</option>
|
||||
<option value="I-V-vi-IV">I-V-vi-IV</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wb-synth-row">
|
||||
<label>Tempo <span id="tempo-val">120</span></label>
|
||||
<input type="range" id="arp-tempo" min="40" max="240" step="1" value="120">
|
||||
</div>
|
||||
<div class="wb-synth-row">
|
||||
<label>Octaves <span id="octaves-val">2</span></label>
|
||||
<input type="range" id="arp-octaves" min="1" max="5" step="1" value="2">
|
||||
</div>
|
||||
<div class="wb-synth-row">
|
||||
<label>Offset <span id="offset-val">0</span></label>
|
||||
<input type="range" id="arp-offset" min="-2" max="3" step="1" value="0">
|
||||
</div>
|
||||
<div class="wb-synth-row" id="midi-row" style="display:none">
|
||||
<label>MIDI</label>
|
||||
<button class="wb-btn" id="midi-toggle">Enable</button>
|
||||
<select id="midi-select" style="flex:1;min-width:0"></select>
|
||||
</div>
|
||||
<div class="wb-synth-row" id="midi-status-row" style="display:none">
|
||||
<span class="wb-synth-status" id="midi-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loss plot -->
|
||||
<div class="wb-loss-wrap">
|
||||
<div class="wb-loss-label">Loss</div>
|
||||
<canvas id="loss-canvas" width="280" height="60"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Status metrics -->
|
||||
<div class="wb-metrics" id="metrics">
|
||||
<div class="wb-metric"><span class="wb-metric-label">Examples</span><span class="wb-metric-value" id="metric-examples">0</span></div>
|
||||
<div class="wb-metric"><span class="wb-metric-label">Loss</span><span class="wb-metric-value" id="metric-loss">—</span></div>
|
||||
<div class="wb-metric"><span class="wb-metric-label">Noise</span><span class="wb-metric-value" id="metric-noise">0.050</span></div>
|
||||
<div class="wb-metric"><span class="wb-metric-label">Joy X,Y</span><span class="wb-metric-value" id="metric-joy">0.50, 0.50</span></div>
|
||||
<div class="wb-metric"><span class="wb-metric-value" id="gamepad-status" style="color: #ff6a00; font-size: 0.7rem;"></span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /wb-grid -->
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div class="wb-toast hidden" id="toast"></div>
|
||||
|
||||
<!-- Help overlay -->
|
||||
<div class="wb-help-overlay hidden" id="help-overlay">
|
||||
<div class="wb-help-content" onclick="event.stopPropagation()">
|
||||
<h2>NISPS Workbench</h2>
|
||||
<p>Train a neural network to map 2D joystick positions to output parameters using interactive machine learning.</p>
|
||||
<h3>Input Space</h3>
|
||||
<p>The heatmap shows what the network has learned across the input space. Bright dots show training examples. Drag anywhere on the map to move the input position. Double-click or press Follow to toggle follow mode.</p>
|
||||
<h3>Learning</h3>
|
||||
<p>Both approaches can be freely mixed:</p>
|
||||
<ul>
|
||||
<li><strong>RL Feedback</strong>: Press <strong>+ Good</strong> to reinforce the current mapping, or <strong>− Explore</strong> to add noise and try variations.</li>
|
||||
<li><strong>Supervised</strong>: Drag param bars to set desired outputs, then <strong>Add Example</strong> and <strong>Train</strong>.</li>
|
||||
</ul>
|
||||
<h3>Output Modes</h3>
|
||||
<p><strong>Visual</strong>: 20 params control the flow-field particle system. <strong>Synth</strong>: 126 params control a C15 synthesizer engine.</p>
|
||||
<p style="margin-top:16px;text-align:center;color:#555;">Tap outside to close</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/b-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>NISPS Journey</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/c-journey.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app phase-explore" data-phase="explore">
|
||||
|
||||
<!-- Back button -->
|
||||
<a href="designs.html" class="back-btn" title="Back">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 2L4 8l6 6"/></svg>
|
||||
</a>
|
||||
|
||||
<!-- Flow field canvas — always present -->
|
||||
<canvas id="vis-canvas"></canvas>
|
||||
<!-- Synth visualizer canvas (shown in synth mode) -->
|
||||
<canvas id="synth-vis-canvas" class="synth-vis hidden"></canvas>
|
||||
|
||||
<!-- Phase indicator (3 dots, top-center) -->
|
||||
<div class="phase-dots" id="phase-dots">
|
||||
<button class="phase-dot active" data-phase="explore" title="Explore"></button>
|
||||
<button class="phase-dot" data-phase="teach" title="Teach"></button>
|
||||
<button class="phase-dot" data-phase="perform" title="Perform"></button>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap strip (top) -->
|
||||
<div class="heatmap-strip" id="heatmap-strip">
|
||||
<div class="heatmap-cells" id="heatmap-cells"></div>
|
||||
<span class="heatmap-count" id="heatmap-count">20</span>
|
||||
<div class="heatmap-tooltip" id="heatmap-tooltip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Output mode toggle (top-left pill) -->
|
||||
<div class="mode-pill" id="mode-pill">
|
||||
<button class="mode-opt active" data-mode="visual">Visual</button>
|
||||
<button class="mode-opt" data-mode="synth">Synth</button>
|
||||
</div>
|
||||
|
||||
<!-- Explore phase prompts -->
|
||||
<div class="explore-prompt" id="explore-prompt">
|
||||
<p class="prompt-text" id="prompt-text">Move the joystick to explore</p>
|
||||
</div>
|
||||
|
||||
<div class="explore-ctas hidden" id="explore-ctas">
|
||||
<button class="cta-pill" id="cta-preset">Try a preset</button>
|
||||
<button class="cta-pill primary" id="cta-teach">Teach it yourself</button>
|
||||
</div>
|
||||
|
||||
<!-- Joystick -->
|
||||
<div class="joystick-area" id="joystick-area">
|
||||
<canvas class="joystick-canvas" id="joystick-canvas" width="300" height="300"></canvas>
|
||||
<!-- Noise ring overlay for RL mode -->
|
||||
<div class="noise-ring" id="noise-ring"></div>
|
||||
<!-- Follow mode indicator -->
|
||||
<div class="follow-indicator hidden" id="follow-indicator">FOLLOW</div>
|
||||
<!-- Follow mode toggle pill -->
|
||||
<button class="follow-pill" id="follow-pill" title="Toggle follow mode (double-click joystick)">Follow</button>
|
||||
<!-- Gamepad status -->
|
||||
<div id="gamepad-status" style="color: #ff6a00; font-size: 0.65rem; text-align: center; margin-top: 4px; min-height: 1em;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Teach phase panel -->
|
||||
<div class="teach-panel" id="teach-panel">
|
||||
|
||||
<!-- Unified teaching controls -->
|
||||
<div class="teach-content" id="teach-unified">
|
||||
<div class="teach-layout">
|
||||
<!-- RL feedback buttons -->
|
||||
<div class="rl-buttons">
|
||||
<button class="rl-btn down" id="btn-thumbs-down" title="Explore more"><span class="rl-icon">👎</span><span class="key-hint">1</span></button>
|
||||
<button class="rl-btn up" id="btn-thumbs-up" title="Keep this"><span class="rl-icon">👍</span><span class="key-hint">2</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Preset grid -->
|
||||
<div class="preset-grid" id="preset-grid">
|
||||
<button class="preset-thumb" data-preset="0">
|
||||
<canvas class="preset-canvas" width="60" height="60"></canvas>
|
||||
<span>Calm</span>
|
||||
</button>
|
||||
<button class="preset-thumb" data-preset="1">
|
||||
<canvas class="preset-canvas" width="60" height="60"></canvas>
|
||||
<span>Storm</span>
|
||||
</button>
|
||||
<button class="preset-thumb" data-preset="2">
|
||||
<canvas class="preset-canvas" width="60" height="60"></canvas>
|
||||
<span>Spiral</span>
|
||||
</button>
|
||||
<button class="preset-thumb" data-preset="3">
|
||||
<canvas class="preset-canvas" width="60" height="60"></canvas>
|
||||
<span>Pulse</span>
|
||||
</button>
|
||||
<button class="preset-thumb" data-preset="4">
|
||||
<canvas class="preset-canvas" width="60" height="60"></canvas>
|
||||
<span>Drift</span>
|
||||
</button>
|
||||
<button class="preset-thumb" data-preset="5">
|
||||
<canvas class="preset-canvas" width="60" height="60"></canvas>
|
||||
<span>Swarm</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="teach-actions">
|
||||
<button class="action-btn primary" id="btn-add-example">Add Example</button>
|
||||
<button class="action-btn accent" id="btn-train">Train</button>
|
||||
</div>
|
||||
|
||||
<!-- Training minimap -->
|
||||
<canvas class="minimap" id="minimap" width="120" height="120"></canvas>
|
||||
|
||||
<!-- Collapsible fine-tune -->
|
||||
<details class="fine-tune" id="fine-tune">
|
||||
<summary>Fine-tune parameters</summary>
|
||||
<div class="param-sliders" id="param-sliders"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Synth controls section (shown when synth mode active) -->
|
||||
<div class="synth-section hidden" id="synth-section">
|
||||
<div class="synth-row">
|
||||
<button class="action-btn accent" id="synth-start">Start Audio</button>
|
||||
<span class="synth-status" id="synth-status">Stopped</span>
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Volume</label>
|
||||
<input type="range" id="synth-volume" min="0" max="1" step="0.01" value="0.5">
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Arpeggiator</label>
|
||||
<button class="action-btn" id="arp-toggle">Play</button>
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Progression</label>
|
||||
<select id="arp-progression">
|
||||
<option value="I-vi-IV-V">I-vi-IV-V</option>
|
||||
<option value="I-IV-vi-V">I-IV-vi-V</option>
|
||||
<option value="i-VI-III-VII">i-VI-III-VII</option>
|
||||
<option value="I-V-vi-IV">I-V-vi-IV</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="synth-row">
|
||||
<label>Tempo <span id="tempo-val">120</span></label>
|
||||
<input type="range" id="arp-tempo" min="40" max="240" step="1" value="120">
|
||||
</div>
|
||||
<div class="synth-row" id="midi-row" style="display:none">
|
||||
<label>MIDI Input</label>
|
||||
<button class="action-btn" id="midi-toggle">Enable</button>
|
||||
<select id="midi-select" style="flex:1;min-width:0"></select>
|
||||
</div>
|
||||
<div class="synth-row" id="midi-status-row" style="display:none">
|
||||
<span class="synth-status" id="midi-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="teach-status" id="teach-status">
|
||||
<span id="status-text">0 examples</span>
|
||||
<button class="action-btn dim small" id="btn-randomize">Randomize</button>
|
||||
<button class="action-btn dim small" id="btn-clear-examples">Clear Examples</button>
|
||||
<button class="action-btn dim small" id="btn-clear">Clear All</button>
|
||||
</div>
|
||||
|
||||
<!-- Done button -->
|
||||
<button class="done-btn" id="btn-done">Done teaching</button>
|
||||
</div>
|
||||
|
||||
<!-- Perform phase: Edit pill -->
|
||||
<div class="edit-pill-wrap" id="edit-pill-wrap">
|
||||
<button class="edit-pill" id="btn-edit">Edit</button>
|
||||
</div>
|
||||
|
||||
<!-- Perform phase: floating audio control -->
|
||||
<div class="audio-control hidden" id="audio-control">
|
||||
<button class="audio-btn" id="audio-toggle">▶</button>
|
||||
<input type="range" class="audio-vol" id="audio-vol" min="0" max="1" step="0.01" value="0.5">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/c-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -1,592 +0,0 @@
|
|||
/**
|
||||
* C15 Audio Engine - AudioWorklet Processor
|
||||
*
|
||||
* This processor loads the C15 WASM module and calls render() each audio frame.
|
||||
* It runs in AudioWorkletGlobalScope and connects to the Web Audio graph with
|
||||
* 0 inputs and 2 outputs (stereo).
|
||||
*
|
||||
* WASM API:
|
||||
* - engineInit(sampleRate, polyphony) -> int
|
||||
* - render(numFrames) -> float* (interleaved stereo)
|
||||
* - noteOn(note, velocity)
|
||||
* - noteOff(note, velocity)
|
||||
* - setParameter(paramId, value)
|
||||
* - reset()
|
||||
*
|
||||
* Ring Buffer Protocol (SharedArrayBuffer):
|
||||
* - Lock-free SPSC ring buffer for main thread -> worklet communication
|
||||
* - Message types: 0=parameter, 1=noteOn, 2=noteOff
|
||||
* - Message format: [type, id/note, value/velocity, reserved]
|
||||
*
|
||||
* @file worklet-processor.js
|
||||
*/
|
||||
|
||||
// WASM module state (shared across all processor instances)
|
||||
let wasmInstance = null;
|
||||
let wasmMemory = null;
|
||||
let wasmReady = false;
|
||||
|
||||
// Ring buffer state
|
||||
let ringBufferReader = null;
|
||||
|
||||
// Message type constants (must match ring-buffer.js)
|
||||
const MessageType = {
|
||||
PARAMETER: 0,
|
||||
NOTE_ON: 1,
|
||||
NOTE_OFF: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* Ring buffer layout constants
|
||||
*/
|
||||
const HEADER_SIZE = 3;
|
||||
const MESSAGE_SIZE = 4;
|
||||
const RING_CAPACITY = 512;
|
||||
|
||||
/**
|
||||
* RingBufferReader - Reads messages from SharedArrayBuffer ring buffer
|
||||
*
|
||||
* This is the consumer side of the SPSC ring buffer, designed for use
|
||||
* in the AudioWorklet's process() callback.
|
||||
*
|
||||
* @class RingBufferReader
|
||||
*/
|
||||
class RingBufferReader {
|
||||
constructor(sharedBuffer) {
|
||||
this._buffer = new Float32Array(sharedBuffer);
|
||||
this._capacity = RING_CAPACITY;
|
||||
this._messageSize = MESSAGE_SIZE;
|
||||
this._headerSize = HEADER_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current write index (main thread updates this)
|
||||
* @private
|
||||
*/
|
||||
_getWriteIndex() {
|
||||
return Atomics.load(new Int32Array(this._buffer.buffer), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current read index
|
||||
* @private
|
||||
*/
|
||||
_getReadIndex() {
|
||||
return Atomics.load(new Int32Array(this._buffer.buffer), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance read index with atomic store
|
||||
* @private
|
||||
*/
|
||||
_advanceReadIndex(currentIdx) {
|
||||
const nextIdx = (currentIdx + 1) % this._capacity;
|
||||
Atomics.store(new Int32Array(this._buffer.buffer), 1, nextIdx);
|
||||
return nextIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and process messages with callbacks
|
||||
*
|
||||
* This is the preferred method for use in the AudioWorklet process() call.
|
||||
* It avoids creating arrays and directly calls the appropriate callback.
|
||||
*
|
||||
* @param {Object} callbacks - Callback handlers
|
||||
* @param {Function} callbacks.onParameter - Called for parameter updates (paramId, value)
|
||||
* @param {Function} callbacks.onNoteOn - Called for note on events (note, velocity)
|
||||
* @param {Function} callbacks.onNoteOff - Called for note off events (note, velocity)
|
||||
* @param {number} maxMessages - Maximum messages to process per call
|
||||
* @returns {number} Number of messages processed
|
||||
*/
|
||||
processMessages(callbacks, maxMessages = 32) {
|
||||
const writeIdx = this._getWriteIndex();
|
||||
let readIdx = this._getReadIndex();
|
||||
|
||||
let count = 0;
|
||||
|
||||
while (readIdx !== writeIdx && count < maxMessages) {
|
||||
// Read message from buffer
|
||||
const msgOffset = this._headerSize + (readIdx * this._messageSize);
|
||||
|
||||
const type = this._buffer[msgOffset + 0];
|
||||
const id = this._buffer[msgOffset + 1];
|
||||
const value = this._buffer[msgOffset + 2];
|
||||
|
||||
// Dispatch to callback based on type
|
||||
switch (type) {
|
||||
case MessageType.PARAMETER:
|
||||
if (callbacks.onParameter) {
|
||||
callbacks.onParameter(id, value);
|
||||
}
|
||||
break;
|
||||
|
||||
case MessageType.NOTE_ON:
|
||||
if (callbacks.onNoteOn) {
|
||||
callbacks.onNoteOn(id, value);
|
||||
}
|
||||
break;
|
||||
|
||||
case MessageType.NOTE_OFF:
|
||||
if (callbacks.onNoteOff) {
|
||||
callbacks.onNoteOff(id, value);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[RingBufferReader] Unknown message type:', type);
|
||||
}
|
||||
|
||||
// Advance read index
|
||||
readIdx = this._advanceReadIndex(readIdx);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of available messages (for debugging/monitoring)
|
||||
*/
|
||||
getAvailableCount() {
|
||||
const writeIdx = this._getWriteIndex();
|
||||
const readIdx = this._getReadIndex();
|
||||
|
||||
if (writeIdx >= readIdx) {
|
||||
return writeIdx - readIdx;
|
||||
} else {
|
||||
return this._capacity - readIdx + writeIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize WASM module from compiled WebAssembly.Module and memory
|
||||
* This is called when the main thread sends the 'init-wasm' message
|
||||
*/
|
||||
function initWasmFromModule(wasmModule, memory) {
|
||||
return WebAssembly.instantiate(wasmModule, {
|
||||
// Import object - Emscripten typically uses 'a' for the main import namespace
|
||||
a: {
|
||||
// Memory import if needed
|
||||
d: () => { throw new Error('abort'); },
|
||||
b: () => 1, // nowIsMonotonic
|
||||
a: () => performance.now(), // _emscripten_get_now
|
||||
c: (size) => { // _emscripten_resize_heap - not typically needed with fixed memory
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}).then(instance => {
|
||||
wasmInstance = instance;
|
||||
wasmMemory = memory;
|
||||
wasmReady = true;
|
||||
|
||||
// Call the constructors
|
||||
if (instance.exports.f) {
|
||||
instance.exports.f();
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* C15Processor - AudioWorklet processor for the C15 synth engine
|
||||
*
|
||||
* Fulfills validation assertions:
|
||||
* - VAL-M2-001: WASM module loads in AudioWorkletGlobalScope
|
||||
* - VAL-M2-002: registerProcessor() succeeds with 0 inputs, 2 outputs
|
||||
* - VAL-M2-003: AudioWorkletNode connects to AudioContext destination
|
||||
* - VAL-M2-004: WASM render produces valid stereo float32 output
|
||||
* - VAL-M2-005: Ring buffer enables lock-free parameter updates without corruption
|
||||
* - VAL-M2-006: Note on/off via ring buffer triggers audio start/release
|
||||
*
|
||||
* NOTE: WebAssembly.Module cannot be sent via MessagePort.postMessage() in Chrome.
|
||||
* The WASM module must be passed via processorOptions in the AudioWorkletNode constructor.
|
||||
* See: https://issues.chromium.org/issues/40855462
|
||||
*/
|
||||
class C15Processor extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
// Processor configuration
|
||||
this._initialized = false;
|
||||
this._sampleRate = 48000;
|
||||
this._polyphony = 24;
|
||||
this._bufferSize = 128;
|
||||
|
||||
// Default configuration
|
||||
const processorOptions = options.processorOptions || {};
|
||||
this._sampleRate = processorOptions.sampleRate || 48000;
|
||||
this._polyphony = processorOptions.polyphony || 24;
|
||||
|
||||
// Listen for messages from main thread
|
||||
this.port.onmessage = this._handleMessage.bind(this);
|
||||
|
||||
// Debug: verify message handler is bound
|
||||
this.port.postMessage({ type: 'status', status: 'handler-bound', test: true });
|
||||
|
||||
// Log that processor was created
|
||||
this._postStatus('created', {
|
||||
sampleRate: this._sampleRate,
|
||||
polyphony: this._polyphony
|
||||
});
|
||||
|
||||
// Initialize WASM from processorOptions if provided
|
||||
// Chrome requires this approach - WebAssembly.Module cannot be sent via postMessage
|
||||
// See: https://issues.chromium.org/issues/40855462
|
||||
if (processorOptions.wasmModule instanceof WebAssembly.Module) {
|
||||
this._postStatus('wasm-module-received-via-options', {
|
||||
hasModule: true,
|
||||
moduleType: 'WebAssembly.Module'
|
||||
});
|
||||
// Initialize WASM asynchronously
|
||||
this._initFromWasmModule(processorOptions.wasmModule, null);
|
||||
} else if (processorOptions.wasmModule) {
|
||||
this._postError(new Error('wasmModule in processorOptions is not a WebAssembly.Module'), 'constructor');
|
||||
} else {
|
||||
this._postStatus('no-wasm-in-options', {
|
||||
hint: 'WASM module should be passed via processorOptions.wasmModule'
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize ring buffer if provided
|
||||
if (processorOptions.ringBuffer) {
|
||||
ringBufferReader = new RingBufferReader(processorOptions.ringBuffer);
|
||||
this._postStatus('ring-buffer-ready', {
|
||||
hasRingBuffer: true,
|
||||
capacity: RING_CAPACITY
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post status message to main thread
|
||||
* @private
|
||||
*/
|
||||
_postStatus(status, data = {}) {
|
||||
this.port.postMessage({
|
||||
type: 'status',
|
||||
status: status,
|
||||
...data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Post error message to main thread
|
||||
* @private
|
||||
*/
|
||||
_postError(error, context = '') {
|
||||
this.port.postMessage({
|
||||
type: 'error',
|
||||
error: error.toString(),
|
||||
context: context
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from main thread
|
||||
* @private
|
||||
*/
|
||||
_handleMessage(event) {
|
||||
const data = event.data; // event.data contains the message
|
||||
|
||||
// Debug log all messages (except high-frequency ones)
|
||||
if (data.type !== 'setParameter' && data.type !== 'tick') {
|
||||
this._postStatus('message-received', { msgType: data.type });
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case 'test':
|
||||
this._postStatus('test-received', { value: data.value });
|
||||
break;
|
||||
|
||||
case 'init-wasm':
|
||||
// Initialize WASM from compiled module sent by main thread
|
||||
// NOTE: This may not work in Chrome due to cross-origin issues
|
||||
// See: https://issues.chromium.org/issues/40855462
|
||||
// The WASM module should be passed via processorOptions instead
|
||||
if (this._initialized) {
|
||||
this._postStatus('wasm-already-initialized', {
|
||||
hint: 'WASM was already initialized via processorOptions'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if wasmModule is valid
|
||||
if (!data.wasmModule) {
|
||||
this._postError(new Error('No wasmModule in init-wasm message'), 'init-wasm');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(data.wasmModule instanceof WebAssembly.Module)) {
|
||||
this._postError(new Error('wasmModule is not a WebAssembly.Module: ' + typeof data.wasmModule), 'init-wasm');
|
||||
return;
|
||||
}
|
||||
|
||||
this._postStatus('starting-wasm-init', {
|
||||
hasModule: true,
|
||||
moduleType: 'WebAssembly.Module',
|
||||
note: 'Using postMessage (may fail in Chrome)'
|
||||
});
|
||||
this._initFromWasmModule(data.wasmModule, data.memory);
|
||||
break;
|
||||
|
||||
case 'init-ring-buffer':
|
||||
// Initialize ring buffer from SharedArrayBuffer sent by main thread
|
||||
if (this._initialized && ringBufferReader) {
|
||||
this._postStatus('ring-buffer-already-initialized', {
|
||||
hint: 'Ring buffer was already initialized via processorOptions'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.ringBuffer) {
|
||||
ringBufferReader = new RingBufferReader(data.ringBuffer);
|
||||
this._postStatus('ring-buffer-ready', {
|
||||
hasRingBuffer: true,
|
||||
capacity: RING_CAPACITY
|
||||
});
|
||||
} else {
|
||||
this._postError(new Error('No SharedArrayBuffer provided'), 'ring buffer init');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noteOn':
|
||||
if (this._initialized && wasmInstance) {
|
||||
wasmInstance.exports.o(data.note, data.velocity); // _noteOn
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noteOff':
|
||||
if (this._initialized && wasmInstance) {
|
||||
wasmInstance.exports.p(data.note, data.velocity); // _noteOff
|
||||
}
|
||||
break;
|
||||
|
||||
case 'setParameter':
|
||||
if (this._initialized && wasmInstance) {
|
||||
wasmInstance.exports.q(data.paramId, data.value); // _setParameter
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reset':
|
||||
if (this._initialized && wasmInstance) {
|
||||
wasmInstance.exports.t(); // _reset
|
||||
}
|
||||
break;
|
||||
|
||||
case 'getConfig':
|
||||
this._postStatus('config', {
|
||||
sampleRate: this._sampleRate,
|
||||
polyphony: this._polyphony,
|
||||
initialized: this._initialized
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[C15Processor] Unknown message type:', data.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from pre-compiled WASM module
|
||||
* @private
|
||||
*/
|
||||
async _initFromWasmModule(wasmModule, memory) {
|
||||
try {
|
||||
this._postStatus('loading');
|
||||
|
||||
// Lazy reference to WASM memory — needed by _clock_time_get before we can
|
||||
// assign wasmMemory (which only exists after WebAssembly.instantiate returns).
|
||||
const memRef = [null];
|
||||
|
||||
// Define the WASM imports matching Emscripten's expected structure.
|
||||
// Mapping (namespace 'a') as of the current build:
|
||||
// a -> _emscripten_get_now
|
||||
// b -> _proc_exit
|
||||
// c -> __emscripten_runtime_keepalive_clear
|
||||
// d -> __setitimer_js
|
||||
// e -> _clock_time_get (WASI clock; writes i64 nanoseconds into WASM heap)
|
||||
// f -> _emscripten_resize_heap
|
||||
// g -> __abort_js
|
||||
const imports = {
|
||||
a: {
|
||||
a: () => performance.now(),
|
||||
b: (code) => { throw new Error('WASM exit: ' + code); },
|
||||
c: () => {},
|
||||
d: (_which, _timeout_ms) => 0,
|
||||
e: (clk_id, _ignored_precision, ptime) => {
|
||||
const now = clk_id === 0 ? Date.now() : performance.now();
|
||||
const nsec = BigInt(Math.round(now * 1e6));
|
||||
if (memRef[0]) {
|
||||
new BigInt64Array(memRef[0].buffer)[ptime >> 3] = nsec;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
f: (requestedSize) => {
|
||||
console.warn('[C15Processor] Heap resize requested but not supported');
|
||||
return 0;
|
||||
},
|
||||
g: () => { throw new Error('WASM abort called'); }
|
||||
}
|
||||
};
|
||||
|
||||
// Instantiate the compiled module
|
||||
const instance = await WebAssembly.instantiate(wasmModule, imports);
|
||||
|
||||
wasmInstance = instance;
|
||||
|
||||
// Get memory from exports (export 'h' is memory)
|
||||
wasmMemory = instance.exports.h;
|
||||
memRef[0] = wasmMemory;
|
||||
|
||||
// Call runtime init (export 'i' is initRuntime/__wasm_call_ctors)
|
||||
if (instance.exports.i) {
|
||||
instance.exports.i();
|
||||
}
|
||||
|
||||
// Initialize the engine (export 'j' is _engineInit)
|
||||
const initResult = instance.exports.j(this._sampleRate, this._polyphony);
|
||||
|
||||
if (initResult !== 1) {
|
||||
throw new Error('engineInit failed with result: ' + initResult);
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
wasmReady = true;
|
||||
|
||||
// Get the default frames per render call (export 'v' is _getDefaultFrames)
|
||||
this._bufferSize = instance.exports.v();
|
||||
|
||||
this._postStatus('ready', {
|
||||
sampleRate: this._sampleRate,
|
||||
polyphony: this._polyphony,
|
||||
bufferSize: this._bufferSize
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this._postError(error, 'WASM load/init');
|
||||
console.error('[C15Processor] Failed to init WASM:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process audio frames
|
||||
*
|
||||
* Called by the browser for each audio render quantum (typically 128 frames).
|
||||
*
|
||||
* VAL-M2-005: Processes ring buffer messages lock-free from main thread
|
||||
* VAL-M2-006: Note on/off via ring buffer triggers audio
|
||||
*
|
||||
* @param {Float32Array[][]} inputs - Input audio buffers (unused)
|
||||
* @param {Float32Array[][]} outputs - Output audio buffers (stereo)
|
||||
* @param {Object} parameters - Automatable parameters (unused)
|
||||
* @returns {boolean} - True to keep processor alive
|
||||
*/
|
||||
process(inputs, outputs, parameters) {
|
||||
const output = outputs[0];
|
||||
|
||||
if (!output || output.length < 2) {
|
||||
return true; // Keep alive but no output
|
||||
}
|
||||
|
||||
const leftChannel = output[0];
|
||||
const rightChannel = output[1];
|
||||
const numFrames = leftChannel.length;
|
||||
|
||||
// If not initialized, output silence
|
||||
if (!this._initialized || !wasmInstance) {
|
||||
for (let i = 0; i < numFrames; i++) {
|
||||
leftChannel[i] = 0;
|
||||
rightChannel[i] = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process ring buffer messages first (VAL-M2-005, VAL-M2-006)
|
||||
if (ringBufferReader) {
|
||||
ringBufferReader.processMessages({
|
||||
onParameter: (paramId, value) => {
|
||||
wasmInstance.exports.q(paramId, value); // _setParameter
|
||||
},
|
||||
onNoteOn: (note, velocity) => {
|
||||
this._dbgNoteOns = (this._dbgNoteOns || 0) + 1;
|
||||
wasmInstance.exports.o(note, velocity); // _noteOn
|
||||
},
|
||||
onNoteOff: (note, velocity) => {
|
||||
wasmInstance.exports.p(note, velocity); // _noteOff
|
||||
}
|
||||
}, 64); // Process up to 64 messages per audio frame
|
||||
}
|
||||
|
||||
// Cache HEAPF32 view (recreate if memory grew)
|
||||
if (!this._heapF32 || this._heapF32.buffer !== wasmMemory.buffer) {
|
||||
this._heapF32 = new Float32Array(wasmMemory.buffer);
|
||||
}
|
||||
|
||||
try {
|
||||
// Render audio using WASM
|
||||
// export 'm' is _render - returns pointer to interleaved stereo buffer
|
||||
const bufferPtr = wasmInstance.exports.m(numFrames);
|
||||
|
||||
if (!bufferPtr) {
|
||||
// Render failed, output silence
|
||||
for (let i = 0; i < numFrames; i++) {
|
||||
leftChannel[i] = 0;
|
||||
rightChannel[i] = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const heapF32 = this._heapF32;
|
||||
const bufferOffset = bufferPtr >> 2; // Convert byte offset to float32 index
|
||||
|
||||
// Deinterleave stereo data from WASM buffer to output channels
|
||||
let frameMax = 0;
|
||||
for (let i = 0; i < numFrames; i++) {
|
||||
const sampleIndex = bufferOffset + (i * 2);
|
||||
const l = heapF32[sampleIndex];
|
||||
const r = heapF32[sampleIndex + 1];
|
||||
leftChannel[i] = l;
|
||||
rightChannel[i] = r;
|
||||
const v = Math.abs(l) > Math.abs(r) ? Math.abs(l) : Math.abs(r);
|
||||
if (v > frameMax) frameMax = v;
|
||||
}
|
||||
|
||||
// Periodic diagnostics (every ~2 seconds = 750 frames at 128 frames/quantum)
|
||||
this._dbgFrameCount = (this._dbgFrameCount || 0) + 1;
|
||||
this._dbgMaxSample = Math.max(this._dbgMaxSample || 0, frameMax);
|
||||
if (this._dbgFrameCount % 750 === 0) {
|
||||
this.port.postMessage({
|
||||
type: 'diag',
|
||||
frames: this._dbgFrameCount,
|
||||
maxSample: this._dbgMaxSample,
|
||||
noteOns: this._dbgNoteOns || 0
|
||||
});
|
||||
this._dbgMaxSample = 0;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// On error, output silence
|
||||
console.error('[C15Processor] Render error:', error);
|
||||
for (let i = 0; i < numFrames; i++) {
|
||||
leftChannel[i] = 0;
|
||||
rightChannel[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return true; // Keep processor alive
|
||||
}
|
||||
|
||||
/**
|
||||
* Static getter for parameter descriptors (for automatable parameters)
|
||||
* Currently not used but required for proper AudioWorklet interface.
|
||||
*/
|
||||
static get parameterDescriptors() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Register the processor with the AudioWorkletGlobalScope
|
||||
// VAL-M2-002: This registration must succeed for the processor to be usable
|
||||
registerProcessor('c15-processor', C15Processor);
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,836 +0,0 @@
|
|||
/* NISPS Workbench — Design B CSS */
|
||||
|
||||
/* === Reset & Base === */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0d0d0d;
|
||||
--surface: #1a1a1a;
|
||||
--elevated: #252525;
|
||||
--border: #222;
|
||||
--border-light: #333;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--text-muted: #555;
|
||||
--accent-visual: #ff6a00;
|
||||
--accent-synth: #ff8c00;
|
||||
--accent: var(--accent-visual);
|
||||
--danger: #ff4444;
|
||||
--good: #ff8c00;
|
||||
--bad: #ff4444;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--transition: 0.2s ease;
|
||||
/* Group accent colors */
|
||||
--group-motion: #00ccff;
|
||||
--group-color: #ff00cc;
|
||||
--group-particles: #ffcc00;
|
||||
--group-forces: #ff3366;
|
||||
--group-effects: #9b59b6;
|
||||
/* Synth group colors */
|
||||
--group-osc: #ff6b35;
|
||||
--group-shapers: #ff006e;
|
||||
--group-filters: #3a86ff;
|
||||
--group-comb: #06d6a0;
|
||||
--group-fx: #8338ec;
|
||||
--group-output: #ffd166;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body.synth-mode {
|
||||
--accent: var(--accent-synth);
|
||||
}
|
||||
|
||||
/* === Layout === */
|
||||
.workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wb-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wb-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid #333;
|
||||
background: var(--elevated);
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wb-back-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.wb-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wb-title-accent {
|
||||
color: var(--accent);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wb-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-mode-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wb-mode-badge.synth {
|
||||
background: var(--accent-synth);
|
||||
}
|
||||
|
||||
.wb-help-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--elevated);
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-help-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* === Grid === */
|
||||
.wb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* === Panel === */
|
||||
.wb-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wb-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.wb-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* === Canvas Panel === */
|
||||
.wb-canvas-panel {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.wb-canvas-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.wb-canvas-wrap canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#synth-vis-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
z-index: 1;
|
||||
}
|
||||
#synth-vis-canvas.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Presets */
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preset-pill {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--elevated);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.preset-pill:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* === Mapping Panel === */
|
||||
.wb-mapping-panel {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.wb-mapping-panel.follow-active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wb-mapping-area {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wb-heatmap-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wb-heatmap-wrap canvas {
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.wb-heatmap-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Follow toggle button */
|
||||
.wb-follow-toggle-btn {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
.wb-follow-toggle-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.wb-follow-toggle-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Follow badge */
|
||||
.wb-follow-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Constellation */
|
||||
.wb-constellation-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wb-constellation-wrap canvas {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wb-constellation-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* === Params Panel === */
|
||||
.wb-params-panel {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wb-output-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wb-toggle-btn {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.wb-toggle-btn:first-child { border-radius: 10px 0 0 10px; }
|
||||
.wb-toggle-btn:last-child { border-radius: 0 10px 10px 0; }
|
||||
.wb-toggle-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wb-param-groups {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Param group card */
|
||||
.wb-group {
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.wb-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.wb-group-header:hover {
|
||||
background: var(--elevated);
|
||||
}
|
||||
|
||||
.wb-group-accent {
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wb-group-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wb-group-sparkline {
|
||||
width: 40px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wb-group-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
.wb-group.open .wb-group-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.wb-group-body {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
.wb-group.open .wb-group-body {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.wb-group-params {
|
||||
padding: 4px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* Individual param bar */
|
||||
.wb-param {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.wb-param-name {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wb-param-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--surface);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.wb-param-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.1s ease;
|
||||
min-width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wb-param-value {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
width: 32px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* === Controls Panel === */
|
||||
.wb-controls-panel {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wb-action-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.wb-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--elevated);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wb-btn:hover { border-color: var(--text-muted); }
|
||||
.wb-btn:active { transform: scale(0.97); }
|
||||
|
||||
.wb-btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-btn-primary:hover { filter: brightness(1.15); }
|
||||
|
||||
.wb-btn-primary.audio-needs-init {
|
||||
animation: audioInitPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes audioInitPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 6px rgba(255, 106, 0, 0.2);
|
||||
filter: brightness(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 14px rgba(255, 106, 0, 0.4);
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.wb-btn-accent {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.wb-btn-accent:hover { background: var(--accent); color: var(--bg); }
|
||||
|
||||
.wb-btn-danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
.wb-btn-danger:hover { background: var(--danger); color: var(--bg); }
|
||||
|
||||
.wb-btn-good {
|
||||
background: rgba(0, 200, 160, 0.12);
|
||||
color: #00c8a0;
|
||||
border-color: rgba(0, 200, 160, 0.4);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-btn-good:hover {
|
||||
background: rgba(0, 200, 160, 0.25);
|
||||
}
|
||||
.wb-btn-bad {
|
||||
background: rgba(220, 60, 20, 0.12);
|
||||
color: #dc3c14;
|
||||
border-color: rgba(220, 60, 20, 0.4);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-btn-bad:hover {
|
||||
background: rgba(220, 60, 20, 0.25);
|
||||
}
|
||||
|
||||
.wb-btn.flash {
|
||||
filter: brightness(1.8);
|
||||
}
|
||||
|
||||
.wb-btn-bad,
|
||||
.wb-btn-good {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.wb-btn-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wb-key-hint {
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Synth controls */
|
||||
.wb-synth-controls {
|
||||
padding: 4px 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wb-synth-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.wb-synth-row label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
min-width: 70px;
|
||||
}
|
||||
.wb-synth-row input[type="range"] {
|
||||
flex: 1;
|
||||
accent-color: var(--accent);
|
||||
height: 4px;
|
||||
}
|
||||
.wb-synth-row select {
|
||||
flex: 1;
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.wb-synth-status {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Loss plot */
|
||||
.wb-loss-wrap {
|
||||
padding: 4px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.wb-loss-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.wb-loss-wrap canvas {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
display: block;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Metrics */
|
||||
.wb-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wb-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wb-metric-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wb-metric-value {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === Toast === */
|
||||
.wb-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.5px;
|
||||
z-index: 2000;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.wb-toast.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* === Help Overlay === */
|
||||
.wb-help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.wb-help-content {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
color: var(--text);
|
||||
}
|
||||
.wb-help-content h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.wb-help-content h3 {
|
||||
font-size: 14px;
|
||||
margin: 16px 0 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.wb-help-content p, .wb-help-content ol {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wb-help-content ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.wb-help-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* === Utility === */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* === Mobile: single column === */
|
||||
@media (max-width: 767px) {
|
||||
.workbench {
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wb-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto auto;
|
||||
}
|
||||
|
||||
.wb-canvas-panel { grid-column: 1; grid-row: 1; min-height: 220px; }
|
||||
.wb-mapping-panel { grid-column: 1; grid-row: 2; }
|
||||
.wb-params-panel { grid-column: 1; grid-row: 3; max-height: 300px; }
|
||||
.wb-controls-panel { grid-column: 1; grid-row: 4; }
|
||||
|
||||
.wb-mapping-area {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#mapping-canvas {
|
||||
max-width: 220px !important;
|
||||
}
|
||||
|
||||
.wb-title { font-size: 14px; }
|
||||
.presets { display: none; }
|
||||
|
||||
/* Collapsible sections on mobile */
|
||||
.wb-panel.collapsible > .wb-panel-header {
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-panel.collapsible.collapsed > *:not(.wb-panel-header) {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Tablet and up === */
|
||||
@media (min-width: 768px) {
|
||||
.wb-canvas-panel {
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.wb-mapping-area {
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large screens */
|
||||
@media (min-width: 1100px) {
|
||||
.wb-canvas-panel {
|
||||
min-height: 450px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,971 +0,0 @@
|
|||
/* NISPS Journey — Design C */
|
||||
/* Phases that dissolve: Explore -> Teach -> Perform */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0d0d0d;
|
||||
--glass-bg: rgba(13, 13, 13, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-blur: 16px;
|
||||
--accent: #ff6a00;
|
||||
--accent-dim: rgba(255, 106, 0, 0.2);
|
||||
--accent-glow: rgba(255, 106, 0, 0.4);
|
||||
--danger: #ff4466;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: rgba(255, 255, 255, 0.45);
|
||||
--text-muted: rgba(255, 255, 255, 0.25);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-pill: 6px;
|
||||
--transition: 400ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--transition-fast: 200ms ease-out;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Canvas
|
||||
============================================================ */
|
||||
#vis-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
z-index: 0;
|
||||
transition: height var(--transition);
|
||||
}
|
||||
|
||||
.app.phase-teach #vis-canvas,
|
||||
.app.phase-teach #synth-vis-canvas {
|
||||
height: 40vh;
|
||||
}
|
||||
|
||||
/* Synth visualizer canvas */
|
||||
#synth-vis-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
z-index: 0;
|
||||
transition: height var(--transition);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Phase dots (top-center)
|
||||
============================================================ */
|
||||
.phase-dots {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 6px 14px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.phase-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text-dim);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.phase-dot.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.phase-dot:hover {
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Heatmap strip
|
||||
============================================================ */
|
||||
.heatmap-strip {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: opacity var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-cells {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 16px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
transition: width 0.1s ease-out;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.heatmap-strip.wide .heatmap-cell {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.heatmap-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
min-width: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.heatmap-tooltip {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.heatmap-tooltip.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Mode pill (top-left)
|
||||
============================================================ */
|
||||
.mode-pill {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 56px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.mode-opt {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mode-opt.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Explore phase prompts
|
||||
============================================================ */
|
||||
.explore-prompt {
|
||||
position: fixed;
|
||||
top: 35%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 50;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-size: 18px;
|
||||
color: var(--text-dim);
|
||||
animation: fadeSlideIn 800ms ease-out forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.explore-ctas {
|
||||
position: fixed;
|
||||
bottom: calc(180px + var(--safe-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.cta-pill {
|
||||
padding: 10px 22px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cta-pill:hover, .cta-pill:active {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.cta-pill.primary {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.cta-pill.primary:hover, .cta-pill.primary:active {
|
||||
background: rgba(255, 106, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Joystick
|
||||
============================================================ */
|
||||
.joystick-area {
|
||||
position: fixed;
|
||||
z-index: 85;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
/* Phase 1: bottom-center */
|
||||
.app.phase-explore .joystick-area {
|
||||
bottom: calc(24px + var(--safe-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Phase 2: left side, smaller */
|
||||
.app.phase-teach .joystick-area {
|
||||
bottom: auto;
|
||||
top: calc(40vh + 16px);
|
||||
left: 16px;
|
||||
transform: none;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Phase 3: ghost, bottom-center, smaller */
|
||||
.app.phase-perform .joystick-area {
|
||||
bottom: calc(60px + var(--safe-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.joystick-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Noise ring for RL mode */
|
||||
.noise-ring {
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Teach panel
|
||||
============================================================ */
|
||||
.teach-panel {
|
||||
position: fixed;
|
||||
top: 40vh;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 80;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 16px 24px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(20px);
|
||||
transition: opacity var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.app.phase-teach .teach-panel {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Teach content sections */
|
||||
.teach-content {
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.teach-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-left: 136px; /* room for joystick on left */
|
||||
}
|
||||
|
||||
/* Preset grid */
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-thumb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1.5px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preset-thumb.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preset-thumb:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.preset-canvas {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Teach actions */
|
||||
.teach-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Minimap */
|
||||
.minimap {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--glass-border);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Fine-tune */
|
||||
.fine-tune {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.fine-tune summary {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.param-sliders {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.param-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.param-slider-row label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.param-slider-row input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.param-slider-row input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* RL buttons (now part of unified teach panel) */
|
||||
.rl-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 48px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.rl-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--glass-border);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 2px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.rl-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rl-btn.up {
|
||||
border-color: rgba(0, 200, 160, 0.4);
|
||||
color: #00c8a0;
|
||||
background: rgba(0, 200, 160, 0.08);
|
||||
}
|
||||
|
||||
.rl-btn.up:hover, .rl-btn.up:active {
|
||||
background: rgba(0, 200, 160, 0.2);
|
||||
box-shadow: 0 0 20px rgba(0, 200, 160, 0.3);
|
||||
}
|
||||
|
||||
.rl-btn.down {
|
||||
border-color: rgba(220, 60, 20, 0.4);
|
||||
color: #dc3c14;
|
||||
background: rgba(220, 60, 20, 0.08);
|
||||
}
|
||||
|
||||
.rl-btn.down:hover, .rl-btn.down:active {
|
||||
background: rgba(220, 60, 20, 0.2);
|
||||
box-shadow: 0 0 20px rgba(220, 60, 20, 0.3);
|
||||
}
|
||||
|
||||
/* Synth section */
|
||||
.synth-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.synth-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.synth-row label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.synth-row input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.synth-row input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.synth-row select {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.synth-status {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Teach status bar */
|
||||
.teach-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
/* Done button */
|
||||
.done-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.done-btn:hover, .done-btn:active {
|
||||
background: #ff8833;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Buttons (shared)
|
||||
============================================================ */
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:hover, .action-btn:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: rgba(255, 106, 0, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.accent {
|
||||
background: rgba(255, 106, 0, 0.12);
|
||||
border-color: rgba(255, 106, 0, 0.3);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn.accent:hover {
|
||||
background: rgba(255, 106, 0, 0.2);
|
||||
}
|
||||
|
||||
.action-btn.accent.audio-needs-init,
|
||||
.audio-btn.audio-needs-init {
|
||||
animation: audioInitPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes audioInitPulse {
|
||||
0%, 100% {
|
||||
border-color: rgba(255, 106, 0, 0.3);
|
||||
box-shadow: 0 0 6px rgba(255, 106, 0, 0.15);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(255, 106, 0, 0.6);
|
||||
box-shadow: 0 0 14px rgba(255, 106, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn.dim {
|
||||
color: var(--text-dim);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.action-btn.small {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Train button pulse animation */
|
||||
.action-btn.accent.pulse {
|
||||
animation: trainPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes trainPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); }
|
||||
50% { box-shadow: 0 0 12px 4px var(--accent-glow); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Edit pill (Phase 3)
|
||||
============================================================ */
|
||||
.edit-pill-wrap {
|
||||
position: fixed;
|
||||
bottom: calc(16px + var(--safe-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 80;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.app.phase-perform .edit-pill-wrap {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.edit-pill {
|
||||
padding: 10px 28px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.edit-pill:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Audio control (Phase 3, synth mode) */
|
||||
.audio-control {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.audio-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audio-vol {
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.audio-vol::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Phase visibility rules
|
||||
============================================================ */
|
||||
|
||||
/* Explore: show prompts, CTAs; hide teach panel, edit pill */
|
||||
.app.phase-explore .teach-panel,
|
||||
.app.phase-explore .edit-pill-wrap,
|
||||
.app.phase-explore .audio-control { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* Teach: hide prompts, CTAs, edit pill */
|
||||
.app.phase-teach .explore-prompt,
|
||||
.app.phase-teach .explore-ctas,
|
||||
.app.phase-teach .edit-pill-wrap,
|
||||
.app.phase-teach .audio-control { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* Perform: hide prompts, CTAs, teach panel */
|
||||
.app.phase-perform .explore-prompt,
|
||||
.app.phase-perform .explore-ctas,
|
||||
.app.phase-perform .teach-panel { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* Perform phase: auto-fade idle elements */
|
||||
.app.phase-perform.idle .edit-pill-wrap {
|
||||
opacity: 0.1;
|
||||
}
|
||||
.app.phase-perform.idle .heatmap-strip {
|
||||
opacity: 0.15;
|
||||
}
|
||||
.app.phase-perform.idle .phase-dots {
|
||||
opacity: 0.15;
|
||||
}
|
||||
.app.phase-perform.idle .mode-pill {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Back button
|
||||
============================================================ */
|
||||
.back-btn {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 110;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RL key hints
|
||||
============================================================ */
|
||||
.key-hint {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Follow mode
|
||||
============================================================ */
|
||||
.follow-indicator {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 8px var(--accent-glow);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.follow-pill {
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.follow-pill:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.follow-pill.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Hidden utility
|
||||
============================================================ */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Responsive
|
||||
============================================================ */
|
||||
@media (min-width: 600px) {
|
||||
.teach-layout {
|
||||
padding-left: 140px;
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.rl-buttons {
|
||||
gap: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.preset-canvas {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.teach-layout {
|
||||
padding-left: 110px;
|
||||
}
|
||||
|
||||
.cta-pill {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,750 +0,0 @@
|
|||
/* NISPS Playground - Dark theme, mobile-first */
|
||||
|
||||
:root {
|
||||
--bg: #0d0d0d;
|
||||
--bg-surface: #1a1a1a;
|
||||
--bg-elevated: #252525;
|
||||
--text: #ccc;
|
||||
--text-dim: #666;
|
||||
--accent: #ff6a00;
|
||||
--accent-dim: #cc5500;
|
||||
--danger: #ff3366;
|
||||
--good: #ff8c00;
|
||||
--bad: #ff4444;
|
||||
--synth-accent: #ff8c00;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* --- Layout --- */
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto auto;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header,
|
||||
.visual-container,
|
||||
.param-container,
|
||||
.controls-area,
|
||||
.presets,
|
||||
.expand-visual-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header button {
|
||||
background: none;
|
||||
border: 1px solid #333;
|
||||
color: var(--text-dim);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 255, 136, 0.12);
|
||||
border: 1px solid var(--accent-dim);
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mode-badge.synth {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
border-color: var(--synth-accent);
|
||||
color: var(--synth-accent);
|
||||
}
|
||||
|
||||
/* ==================== Side Panel ==================== */
|
||||
.side-panel-toggle {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 50;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid #333;
|
||||
border-left: none;
|
||||
border-radius: 0 8px 8px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
padding: 12px 6px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.side-panel-toggle:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 260px;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid #333;
|
||||
z-index: 60;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.side-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.side-panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 55;
|
||||
}
|
||||
|
||||
.side-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.side-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.side-panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.side-panel-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.sp-tab {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid #333;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sp-tab:first-child {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.sp-tab:last-child {
|
||||
border-radius: 0 6px 6px 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.sp-tab.active[data-mode="visual"] {
|
||||
background: rgba(0, 255, 136, 0.12);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sp-tab.active[data-mode="synth"] {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
border-color: var(--synth-accent);
|
||||
color: var(--synth-accent);
|
||||
}
|
||||
|
||||
.sp-hint {
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Synth controls */
|
||||
.synth-controls {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.synth-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.synth-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.synth-label span {
|
||||
color: var(--synth-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.synth-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.synth-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--synth-accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.synth-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--synth-accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.synth-select {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid #333;
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.synth-start-btn {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.synth-arp-btn {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.synth-arp-btn.playing {
|
||||
background: rgba(255, 107, 53, 0.15);
|
||||
border-color: var(--synth-accent);
|
||||
color: var(--synth-accent);
|
||||
}
|
||||
|
||||
.synth-status {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Visual canvas */
|
||||
.visual-container {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.expand-visual-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
background: rgba(26, 26, 26, 0.82);
|
||||
border: 1px solid #333;
|
||||
color: #9f9f9f;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.expand-visual-btn:active,
|
||||
.expand-visual-btn[aria-pressed="true"] {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
|
||||
.visual-container canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Preset pills */
|
||||
.presets {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.preset-pill {
|
||||
background: rgba(26, 26, 26, 0.8);
|
||||
border: 1px solid #333;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.preset-pill:active {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Parameter display */
|
||||
.param-container {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid #222;
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
width: 42px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.param-container.synth-mode {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #444 #1a1a1a;
|
||||
}
|
||||
|
||||
.param-container.synth-mode .param-row {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.param-container.synth-mode .param-label {
|
||||
width: 82px;
|
||||
font-size: 9px;
|
||||
color: #886644;
|
||||
}
|
||||
|
||||
.param-container.synth-mode .param-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.param-container.synth-mode .param-value {
|
||||
font-size: 8px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.param-container.synth-mode.draggable .param-track {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.param-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #222;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.draggable .param-track {
|
||||
cursor: ew-resize;
|
||||
height: 14px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.draggable .param-track:active {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.param-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.05s ease-out;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
width: 32px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Controls area */
|
||||
.controls-area {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid #222;
|
||||
}
|
||||
|
||||
.app.expanded {
|
||||
grid-template-rows: auto minmax(0, 1fr) 58px 102px 0;
|
||||
}
|
||||
|
||||
.app.expanded .header {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.app.expanded .header h1 {
|
||||
font-size: 13px;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.app.expanded .presets {
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app.expanded .preset-pill {
|
||||
font-size: 10px;
|
||||
padding: 3px 7px;
|
||||
color: #868686;
|
||||
}
|
||||
|
||||
.app.expanded .param-container {
|
||||
padding: 4px 10px;
|
||||
overflow: hidden;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.app.expanded .param-row {
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.app.expanded .param-track {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.app.expanded .draggable .param-track {
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.app.expanded .param-label,
|
||||
.app.expanded .param-value {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.app.expanded .controls-area {
|
||||
padding: 6px 10px;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.app.expanded #joystick-container {
|
||||
width: 94px;
|
||||
height: 94px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app.expanded #joystick-container canvas {
|
||||
transform: scale(0.58);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.app.expanded .controls-actions {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app.expanded .btn {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.app.expanded .btn-good,
|
||||
.app.expanded .btn-bad {
|
||||
font-size: 14px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
.app.expanded .controls-status,
|
||||
.app.expanded .metrics-grid,
|
||||
.app.expanded .loss-plot-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#joystick-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#controls-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controls-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid #333;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(0, 255, 136, 0.12);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
background: rgba(0, 255, 136, 0.25);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
border-color: #552233;
|
||||
}
|
||||
|
||||
.btn-danger:active {
|
||||
background: rgba(255, 51, 102, 0.15);
|
||||
}
|
||||
|
||||
.btn-good {
|
||||
background: rgba(0, 255, 136, 0.12);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--good);
|
||||
font-size: 18px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
.btn-good:active {
|
||||
background: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.btn-bad {
|
||||
background: rgba(255, 102, 68, 0.1);
|
||||
border-color: #553322;
|
||||
color: var(--bad);
|
||||
font-size: 18px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
.btn-bad:active {
|
||||
background: rgba(255, 102, 68, 0.25);
|
||||
}
|
||||
|
||||
.flash {
|
||||
animation: flash-anim 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes flash-anim {
|
||||
0% { box-shadow: 0 0 12px var(--accent); }
|
||||
100% { box-shadow: none; }
|
||||
}
|
||||
|
||||
.controls-status {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: monospace;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 10px;
|
||||
font-size: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: #b5b5b5;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.loss-plot-wrap {
|
||||
width: 100%;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#loss-plot {
|
||||
width: 100%;
|
||||
height: 86px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Help overlay */
|
||||
.help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.help-content {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
max-width: 360px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-content h2 {
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.help-content h3 {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.help-content p, .help-content li {
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.help-content ol {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
/* Responsive: wider screens (foldable inner, tablet) */
|
||||
@media (min-width: 500px) {
|
||||
.app {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.controls-area {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.param-container {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NISPS Playground</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #111;
|
||||
--border: #1e1e1e;
|
||||
--accent: #ff6a00;
|
||||
--accent-dim: rgba(255, 106, 0, 0.12);
|
||||
--text: #c8c8c8;
|
||||
--text-dim: #606060;
|
||||
--mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Faint grid background */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,106,0,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,106,0,0.02) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 24px 80px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--mono);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 span {
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(255, 106, 0, 0.06), inset 0 0 20px rgba(255, 106, 0, 0.02);
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #080808;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview iframe {
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.card-body h2 {
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-body p {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card:hover .card-body h2 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Desktop: horizontal row */
|
||||
@media (min-width: 860px) {
|
||||
.cards {
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
flex: 1;
|
||||
}
|
||||
.preview {
|
||||
height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large: more breathing room */
|
||||
@media (min-width: 1100px) {
|
||||
.page { max-width: 1100px; }
|
||||
.preview { height: 280px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<h1>NISPS Playground
|
||||
<span>Neural Interactive Shaping of Parameter Spaces</span>
|
||||
</h1>
|
||||
<p class="intro">Map a 2D joystick to 126 synthesizer parameters through a neural network. Two learning modes: show examples of what you want, or give thumbs up/down feedback. The network learns to interpolate the space between them.</p>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<a class="card" href="a-immersive.html">
|
||||
<div class="preview">
|
||||
<iframe src="a-immersive.html" loading="lazy" tabindex="-1"></iframe>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>Immersive</h2>
|
||||
<p>Full-screen canvas with floating controls. The visualization is the interface.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="b-workbench.html">
|
||||
<div class="preview">
|
||||
<iframe src="b-workbench.html" loading="lazy" tabindex="-1"></iframe>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>Workbench</h2>
|
||||
<p>Dashboard layout with a 2D heatmap. Built for understanding the mapping.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="c-journey.html">
|
||||
<div class="preview">
|
||||
<iframe src="c-journey.html" loading="lazy" tabindex="-1"></iframe>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>Journey</h2>
|
||||
<p>Three phases that dissolve as you master the mapping. The UI fades away.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
# Playground UI Redesign Explorations
|
||||
|
||||
**Date**: 2026-03-21 → 2026-03-22
|
||||
**Status**: Three designs built and iterated through 4 rounds of feedback. Several refinements still open.
|
||||
|
||||
## Context
|
||||
|
||||
The existing playground UI (`index.html`) is a single-column mobile-first layout: header, canvas, 20 parameter bars, joystick + controls. It works but has several tensions:
|
||||
|
||||
- **Individual param bars dominate the screen** and are only interactive in Examples mode
|
||||
- **Two orthogonal mode axes** (learning mode: examples/RL, output mode: visual/synth) live in different places with different UI patterns
|
||||
- The synth side panel feels bolted on
|
||||
- The "teach then perform" arc isn't surfaced — new users don't know what to do first
|
||||
- Most importantly: **manually setting sliders contradicts the core thesis** — NISPS exists precisely because you *shouldn't* need to understand individual parameters
|
||||
|
||||
With the C15 synth mode now at **126 parameters**, the slider approach is impossible, making the ML mapping the only viable interface.
|
||||
|
||||
## Design Thesis
|
||||
|
||||
The three designs explore different answers to: **if users shouldn't set params directly, what DO they interact with?**
|
||||
|
||||
## Design A: "Immersive"
|
||||
|
||||
**File**: `a-immersive.html` | **Philosophy**: The canvas IS the app
|
||||
|
||||
The flow field fills the entire viewport. Everything else floats on top as translucent glassmorphism overlays:
|
||||
|
||||
- **Floating circular joystick** with integrated training-example minimap (merged into one control)
|
||||
- **Heatmap strip** at top — horizontal fill bars (like a tiny equalizer) showing output values at a glance. Hidden in synth mode since the full-screen synth visualizer serves this purpose.
|
||||
- **Bottom sheet** with chevron toggle (collapsed/expanded), mode toggles always visible in a sticky floating bar
|
||||
- **RL mode is default** — floating thumbs up/down buttons with key hints (1/2) built in
|
||||
- **Follow mode** for trackpad-friendly exploration without holding the mouse
|
||||
- **Synth mode**: full-screen parameter landscape (126 vertical bars grouped by synthesis section), interactive/draggable in examples mode, with a quick play button + hover drawer next to the back button
|
||||
|
||||
**Strengths**: Feels like an instrument. Canvas gets maximum space. The synth visualizer conveys the full 126-param scale.
|
||||
**Open issues**: See bd issue for remaining refinements (synth bar layout, slider interactivity, default mode).
|
||||
|
||||
## Design B: "Workbench"
|
||||
|
||||
**File**: `b-workbench.html` | **Philosophy**: Show the mapping, not the params
|
||||
|
||||
A CSS grid dashboard (2-column on desktop, single-column with collapsible sections on mobile):
|
||||
|
||||
- **Mapping heatmap** (the key innovation): a 2D canvas sampling a 20×20 grid through the network, showing the learned function as a color field. Training examples are visible dots. Click/drag anywhere to control position — the mapping IS the joystick (merged).
|
||||
- **Parameter constellation**: dots in a circle, sized by value, with a count label ("20 parameters" / "126 synth parameters")
|
||||
- **Semantic param groups**: collapsible cards with sparkline headers. 5 groups for visual mode, 17 groups for synth mode (covering all 126 params by synthesis section)
|
||||
- **Synth mode**: replaces the flow field canvas with a parameter landscape visualization (126 grouped bars)
|
||||
- **Softened heatmap colors** with grid overlay for readability
|
||||
|
||||
**Strengths**: The mapping heatmap makes the learned function tangible. Param groups reduce cognitive load.
|
||||
**Open issues**: Visual pill toggle reliability, heatmap color tuning.
|
||||
|
||||
## Design C: "Journey"
|
||||
|
||||
**File**: `c-journey.html` | **Philosophy**: The UI teaches you, then disappears
|
||||
|
||||
Three phases that transition based on user actions:
|
||||
|
||||
1. **Explore** (start): fullscreen canvas + floating joystick + gentle prompts. Minimal chrome.
|
||||
2. **Teach** (active training): canvas shrinks to ~40%. Teaching interface with mode tabs (Examples/RL). Preset snapshot grid. Minimap of examples. Collapsible raw sliders.
|
||||
3. **Perform** (post-training): canvas expands back to fullscreen. Controls fade to near-invisible after 5s inactivity.
|
||||
|
||||
- **RL mode is default** teach mode with keyboard shortcuts (1/2) working in all phases
|
||||
- **Heatmap strip** with horizontal fill bars, scales from 20 to 126 params
|
||||
- **Synth mode**: parameter landscape visualization replacing the flow field
|
||||
- **Follow mode** with double-click toggle and pill button
|
||||
|
||||
**Strengths**: Maps perfectly to the real user arc. Phase 3 auto-dissolve is genuinely novel.
|
||||
**Open issues**: RL mode joystick visibility, fine-tune slider style consistency.
|
||||
|
||||
## Shared Architecture
|
||||
|
||||
All three designs share unchanged modules:
|
||||
- `js/nisps/` — MLP, IML, Dataset, Layer, Node (the ML engine)
|
||||
- `js/ui/visualizer.js` — FlowFieldVisualizer (Canvas2D particle system)
|
||||
- `js/synth/` — C15Bridge (WASM synth), Arpeggiator, param-map (126 params)
|
||||
|
||||
Each design has its own HTML + CSS + app.js. The `designs.html` homepage links to all three with live iframe previews.
|
||||
|
||||
### Common features across all designs:
|
||||
- **126-output MLP** with `[32, 48, 64]` hidden layers
|
||||
- **Visual mode**: first 20 outputs → flow field particle system
|
||||
- **Synth mode**: all 126 outputs → SynthVisualizer (grouped bar chart) + C15 WASM engine
|
||||
- **`?tame=0.7`** URL parameter for safe-range constraining of synth params
|
||||
- **RL as default** learning mode with keyboard shortcuts (1=negative, 2=positive)
|
||||
- **Follow mode** for trackpad-friendly exploration
|
||||
- **localStorage persistence** (random on first boot, restores on refresh)
|
||||
- **Presets** padded from 20 to 126 outputs with 0.5 defaults
|
||||
|
||||
## Answered Questions
|
||||
|
||||
- **RL mode is now the default** across all designs — it's the purer expression of the NISPS thesis
|
||||
- **Network architecture scaled** from `[10, 10, 14]` (20 outputs) to `[32, 48, 64]` (126 outputs)
|
||||
- **Synth mode gets its own visualization** — parameter landscape with 126 grouped bars, not the particle system
|
||||
- **Snapshot teaching + fine-grained sliders coexist** — presets for quick setup, expandable raw params for power users
|
||||
|
||||
## Iteration History
|
||||
|
||||
1. **Initial build** (2026-03-21): Three designs from PRD, all functional
|
||||
2. **Round 1**: Added back buttons, follow mode, merged joystick+minimap (A), merged joystick+mapping (B), fixed RL functions (C)
|
||||
3. **Round 2**: Restored localStorage, added keyboard hints, fixed slider responsiveness (B), fixed Visual pill toggle (B), horizontal heatmap bars (A)
|
||||
4. **Round 3**: Upgraded to 126 outputs + `[32, 48, 64]` MLP, added SynthVisualizer to all designs, added randomize buttons, `?tame` URL param, back button SVGs
|
||||
5. **Round 4**: Homepage with live previews, synth quick-play button (A), interactive synth bars (A), heatmap hidden in synth mode (A), tame param to B+C
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
# Modular engines — modulation destination table
|
||||
|
||||
Each of the three `modular-*` Faust engines exposes a 48-source × 10-destination
|
||||
modulation matrix. All engines import `mod-pool.lib` (16 ADSRs + 32 LFOs = 48
|
||||
sources) and bind those sources to engine-specific destinations via the
|
||||
`MM_Matrix/sNN_dNN_<destname>` slider labels.
|
||||
|
||||
The destination index (`dNN`) is consistent per engine but NOT consistent across
|
||||
engines — `d08` happens to be `amp` for all three because the generator places
|
||||
the master amplitude destination in the same slot for every engine, which makes
|
||||
it easy for Phase B to wire up a default ADSR1→amp patch regardless of the
|
||||
active engine.
|
||||
|
||||
| Engine | d00 | d01 | d02 | d03 | d04 | d05 | d06 | d07 | d08 | d09 |
|
||||
|-------------|-------|-------------|-------------|----------------|------------|------------------|-----------------|----------------|-----|-----|
|
||||
| subtractive | pitch | osc2_detune | osc3_detune | osc_mix_bal | noise_level| cutoff | resonance | filter_env_amt | amp | pan |
|
||||
| additive | pitch | bright | tilt | inharmonicity | odd_even | formant_ctr | formant_depth | noise_mix | amp | pan |
|
||||
| fm | pitch | op1_level | op2_level | op3_level | op4_level | cross_mod_global | feedback_global | global_ratio | amp | pan |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Source index** (`sNN`): 00..15 = ADSR slots 1..16, 16..47 = LFO slots 1..32.
|
||||
- **Amount range**: each matrix slider is `[-1.0, +1.0]`, default 0 (no route).
|
||||
- **`amp` destination** (d08): always the master amplitude destination for the
|
||||
engine. Without any source routed here, the voice is either silent
|
||||
(subtractive — the mod_amp signal multiplies into the VCA) or produces a
|
||||
sustained tone at the engine's natural level (additive and fm — still gated
|
||||
through mod_amp; without a route the amp signal is 0). **Route ADSR1 → d08
|
||||
for a standard VCA envelope in all three engines.**
|
||||
- **`pitch` destination** (d00): always semitones, ±12 per mod unit.
|
||||
- **`pan` destination** (d09): added to the master pan knob, clamped to [-1,1].
|
||||
|
||||
## Notes
|
||||
|
||||
- `additive.bright` is a progressive high-partial boost/cut (replaces the old
|
||||
brightness envelope in the non-modular `additive.dsp`). At mod=0, it is
|
||||
unity; at mod=+1 it boosts top partials, at -1 it cuts them.
|
||||
- `additive.formant_ctr` shifts BOTH formant centre frequencies by the same
|
||||
amount (up to ±8 harmonics) so you can "move" the vocal shape with an LFO or
|
||||
envelope.
|
||||
- `fm.cross_mod_global` and `fm.feedback_global` are `(1 + mod)` multipliers
|
||||
applied to the respective matrix cells BEFORE the feedback loop is closed,
|
||||
so a mod of +1 doubles FM depth and -1 zeroes it out.
|
||||
- `fm.global_ratio` is an ADDITIVE offset on all four operator ratios (±8),
|
||||
clamped to ≥0.01. This can behave like a pitch-bend when animated slowly.
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
# playground/faust — Faust DSP Build Pipeline
|
||||
|
||||
This directory contains Faust DSP source files and the toolchain for compiling
|
||||
them to WebAssembly for use in the MEMLNaut playground.
|
||||
|
||||
## Required Tools
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
|---------|----------|-------------------------------------------------|
|
||||
| `faust` | >= 2.60 | Compiles `.dsp` → `.wasm` + `.json` |
|
||||
| `emcc` | >= 3.1.x | Optional — needed only if linking extra C++ code |
|
||||
|
||||
`emcc` is already available at `/usr/lib/emscripten/emcc` on this system.
|
||||
`faust` is not currently installed — use `nix-shell -p faust` for a one-off
|
||||
build, or `nix profile install nixpkgs#faust` to install permanently.
|
||||
|
||||
## How to Compile
|
||||
|
||||
```bash
|
||||
cd playground/faust
|
||||
./build.sh
|
||||
```
|
||||
|
||||
This compiles every `.dsp` file in the directory, producing alongside it:
|
||||
|
||||
- `<name>.wasm` — the compiled DSP binary (loaded as an AudioWorkletNode)
|
||||
- `<name>.json` — the Faust UI descriptor (consumed by `faustJsonToParamMeta`)
|
||||
- `<name>.js` — JS glue / AudioWorklet wrapper generated by faust
|
||||
|
||||
## DSP Files
|
||||
|
||||
| File | Status | Description |
|
||||
|------------------|-------------|-----------------------------------------------------|
|
||||
| `additive.dsp` | Placeholder | 4-harmonic sine bank (2 params). Full engine: meml-pj4 |
|
||||
| `fm-matrix.dsp` | Placeholder | 2-op FM synth (4 params). Full engine: meml-wgg |
|
||||
|
||||
## Output Format — `.json` Descriptor
|
||||
|
||||
Faust's `-json` flag emits a UI descriptor tree. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "additive",
|
||||
"version": "2.75.7",
|
||||
"options": "-vec",
|
||||
"size": "0",
|
||||
"inputs": "0",
|
||||
"outputs": "2",
|
||||
"meta": [...],
|
||||
"ui": [
|
||||
{
|
||||
"type": "vgroup",
|
||||
"label": "additive",
|
||||
"items": [
|
||||
{
|
||||
"type": "hslider",
|
||||
"label": "freq",
|
||||
"address": "/additive/freq",
|
||||
"meta": [{"unit": "Hz"}],
|
||||
"init": 220,
|
||||
"min": 20,
|
||||
"max": 4000,
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"type": "hslider",
|
||||
"label": "amp",
|
||||
"address": "/additive/amp",
|
||||
"init": 0.5,
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"step": 0.001
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## How `faustJsonToParamMeta` Consumes the JSON
|
||||
|
||||
`playground/js/synth/faust-param-meta.js` exports:
|
||||
|
||||
```js
|
||||
import { faustJsonToParamMeta, loadFaustParamMeta } from './faust-param-meta.js';
|
||||
|
||||
// From a pre-parsed object:
|
||||
const paramMeta = faustJsonToParamMeta(faustJson);
|
||||
|
||||
// Or fetch + parse in one step:
|
||||
const paramMeta = await loadFaustParamMeta('faust/additive.json');
|
||||
```
|
||||
|
||||
`faustJsonToParamMeta` recursively walks the `ui` tree, collects all
|
||||
`hslider` / `vslider` / `nentry` items, and returns:
|
||||
|
||||
```js
|
||||
[
|
||||
{ id: 'freq', name: 'freq', min: 20, max: 4000, init: 220, curve: 0.5, group: 'additive' },
|
||||
{ id: 'amp', name: 'amp', min: 0, max: 1, init: 0.5, curve: 0.5, group: 'additive' },
|
||||
]
|
||||
```
|
||||
|
||||
This is the standard `paramMeta` format used throughout the playground
|
||||
(`SynthEngine.paramMeta`, preset system, group drawer, etc.).
|
||||
|
||||
## FaustEngineBase Loading Pattern
|
||||
|
||||
`playground/js/synth/faust-engine-base.js` provides a base class for any
|
||||
engine compiled with this pipeline:
|
||||
|
||||
```js
|
||||
import { FaustEngineBase } from './js/synth/faust-engine-base.js';
|
||||
|
||||
class AdditiveEngine extends FaustEngineBase {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'additive',
|
||||
displayName: 'Additive',
|
||||
wasmUrl: 'faust/additive.wasm',
|
||||
jsonUrl: 'faust/additive.json',
|
||||
workletUrl: 'faust/additive-processor.js', // AudioWorklet file
|
||||
processorName: 'additive-processor', // registerProcessor() name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const engine = new AdditiveEngine();
|
||||
await engine.init(audioCtx); // fetches JSON + WASM, loads worklet
|
||||
engine.noteOn(69, 0.8); // A4, velocity 0.8
|
||||
engine.setParam(0, 0.6); // normalized [0,1] → maps to param range
|
||||
engine.noteOff(69);
|
||||
```
|
||||
|
||||
The `init()` call:
|
||||
1. Fetches `jsonUrl` → calls `loadFaustParamMeta()` → populates `engine.paramMeta`
|
||||
2. Fetches `wasmUrl` → transfers bytes to AudioWorklet
|
||||
3. Registers `workletUrl` with `audioCtx.audioWorklet.addModule()`
|
||||
4. Creates an `AudioWorkletNode` and connects it to `audioCtx.destination`
|
||||
5. Sends `{ type: 'init', wasmBytes, sampleRate }` to the worklet
|
||||
6. Waits for the worklet to reply `{ type: 'ready' }` (10 s timeout)
|
||||
|
||||
## AudioWorklet Processor Base
|
||||
|
||||
`faust-worklet-processor.js` (this directory) defines `FaustWorkletProcessor`,
|
||||
a base class for concrete engine worklets. It handles:
|
||||
|
||||
- `{ type: 'init', wasmBytes, sampleRate }` — calls `_initWasm()`
|
||||
- `{ type: 'setParam', index, value }` — calls `_onSetParam()`
|
||||
- `{ type: 'noteOn', freq, vel }` — calls `_onNoteOn()`
|
||||
- `{ type: 'noteOff', freq }` — calls `_onNoteOff()`
|
||||
- Replies `{ type: 'ready' }` or `{ type: 'error', message }` to main thread
|
||||
|
||||
Each concrete engine processor overrides `_initWasm()` and `_renderBlock()`.
|
||||
|
|
@ -1,402 +0,0 @@
|
|||
/**
|
||||
* additive-processor.js — AudioWorklet processor for the Faust additive synthesiser.
|
||||
*
|
||||
* Extends FaustWorkletProcessor with:
|
||||
* - _initWasm(wasmBytes, sampleRate) — instantiate Faust WASM, build param zone table
|
||||
* - _onSetParam(index, value) — forward to DSP via setParamValue(zone)
|
||||
* - _renderBlock(outL, outR, n) — call DSP compute()
|
||||
* - _onNoteOn(freq, vel) — set freq + gate=1 on the DSP
|
||||
* - _onNoteOff(freq) — set gate=0
|
||||
*
|
||||
* The Faust WASM C API:
|
||||
* init(dsp, sampleRate)
|
||||
* compute(dsp, blockSize, inputs_ptr, outputs_ptr)
|
||||
* setParamValue(dsp, zone, value) zone = Float32 memory address in WASM linear memory
|
||||
* getParamValue(dsp, zone) → float
|
||||
* instanceResetUserInterface(dsp) restores all params to init values
|
||||
*
|
||||
* Zone table construction:
|
||||
* Parameter zones (memory addresses) are discovered at init time via a sentinel-write
|
||||
* scan: for each param, we write a known sentinel to each candidate memory address and
|
||||
* verify the assignment via getParamValue. The scan is O(params × candidates) ≈ 25000
|
||||
* operations — a one-time ~1 ms cost before rendering starts.
|
||||
*
|
||||
* WASM import requirements (Faust math builtins):
|
||||
* env._sinf, _cosf, _tanf, _expf, _logf, _powf, _tanhf, _sqrtf, _fabsf, _floorf
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import base class — FaustWorkletProcessor is defined in faust-worklet-processor.js
|
||||
// which must be loaded by addModule() before this file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Number of output channels (stereo)
|
||||
const NUM_OUTPUTS = 2;
|
||||
|
||||
// Block size for DSP rendering
|
||||
const BLOCK_SIZE = 128;
|
||||
|
||||
// DSP instance pointer — Faust single-instance WASM always uses 0
|
||||
const DSP = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdditiveProcessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class AdditiveProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
// DSP state
|
||||
this._dspInst = null; // WebAssembly instance
|
||||
this._dspMemory = null; // Float32Array view over WASM memory
|
||||
this._paramZones = []; // Array of zone addresses, one per param (incl. hidden)
|
||||
this._freqZone = 0; // Zone address for freq param (hidden)
|
||||
this._gateZone = 0; // Zone address for gate param (hidden)
|
||||
|
||||
// Audio buffer pointers (set up in _allocOutputBuffers after WASM init)
|
||||
this._outPtrsAddr = 0; // WASM address of [outL_addr, outR_addr] array
|
||||
this._outLAddr = 0; // WASM address of left channel buffer
|
||||
this._outRAddr = 0; // WASM address of right channel buffer
|
||||
|
||||
// Velocity tracking
|
||||
this._currentVel = 0.7;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _initWasm — called once with the binary WASM bytes from the main thread
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sampleRate) {
|
||||
// Build the WASM import object with the math functions Faust needs
|
||||
const importObj = {
|
||||
env: {
|
||||
_sinf: Math.sin,
|
||||
_cosf: Math.cos,
|
||||
_tanf: Math.tan,
|
||||
_expf: Math.exp,
|
||||
_logf: Math.log,
|
||||
_powf: Math.pow,
|
||||
_tanhf: Math.tanh,
|
||||
_sqrtf: Math.sqrt,
|
||||
_fabsf: Math.abs,
|
||||
_floorf: Math.floor,
|
||||
_ceilf: Math.ceil,
|
||||
_remainderf: (a, b) => a % b,
|
||||
_fmodf: (a, b) => a % b,
|
||||
_roundf: Math.round,
|
||||
_truncf: Math.trunc,
|
||||
_log10f: Math.log10,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await WebAssembly.instantiate(wasmBytes, importObj);
|
||||
this._dspInst = result.instance;
|
||||
|
||||
const exports = this._dspInst.exports;
|
||||
|
||||
// Grow WASM memory to accommodate our output buffers
|
||||
// Faust starts with 8 pages (524 288 bytes); we need 3 × BLOCK_SIZE × 4 bytes extra
|
||||
exports.memory.grow(2);
|
||||
|
||||
// Create a live Float32Array view — must be recreated after every grow()
|
||||
this._dspMemory = new Float32Array(exports.memory.buffer);
|
||||
|
||||
// Initialise the DSP
|
||||
exports.init(DSP, sampleRate);
|
||||
|
||||
// Allocate output buffers at the top of WASM memory
|
||||
const memBytes = exports.memory.buffer.byteLength;
|
||||
this._outLAddr = memBytes - BLOCK_SIZE * 4 * 3;
|
||||
this._outRAddr = this._outLAddr + BLOCK_SIZE * 4;
|
||||
this._outPtrsAddr = this._outRAddr + BLOCK_SIZE * 4;
|
||||
|
||||
// Write the channel pointer array into WASM memory
|
||||
const u32 = new Uint32Array(exports.memory.buffer);
|
||||
u32[this._outPtrsAddr / 4] = this._outLAddr;
|
||||
u32[this._outPtrsAddr / 4 + 1] = this._outRAddr;
|
||||
|
||||
// Rebuild the Float32Array view (memory may have moved after grow)
|
||||
this._dspMemory = new Float32Array(exports.memory.buffer);
|
||||
|
||||
// Build the parameter zone table
|
||||
await this._buildZoneTable(exports);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _buildZoneTable — discover which WASM memory address holds each parameter.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Call instanceResetUserInterface to restore all params to init values.
|
||||
// 2. Write a large sentinel to every address in the "param zone" region.
|
||||
// 3. Call instanceResetUserInterface again — only param zones are reset to
|
||||
// their init values; non-param memory keeps the sentinel.
|
||||
// 4. Record all (addr → initValue) pairs where the sentinel was cleared.
|
||||
// 5. For each JSON param in order, resolve its zone by process of elimination:
|
||||
// - params with unique init values match directly
|
||||
// - params with ambiguous init values: write unique sentinels one-by-one
|
||||
// to the candidate list, calling instanceResetUserInterface each time to
|
||||
// identify which candidate gets reset
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildZoneTable(exports) {
|
||||
const f32 = this._dspMemory;
|
||||
const SENTINEL = 99999.9;
|
||||
|
||||
// Param zone region (empirically determined from this compiled WASM)
|
||||
const SCAN_START = 262100;
|
||||
const SCAN_END = 264200;
|
||||
|
||||
// Step 1+2: write sentinel everywhere in range
|
||||
for (let addr = SCAN_START; addr <= SCAN_END; addr += 4) {
|
||||
f32[addr / 4] = SENTINEL;
|
||||
}
|
||||
|
||||
// Step 3: reset — param zones revert to init, others keep sentinel
|
||||
exports.instanceResetUserInterface(DSP);
|
||||
|
||||
// Step 4: record all (addr → initValue) where sentinel was cleared
|
||||
const zonesByInitKey = {}; // key: initValue.toFixed(7) → [addr, ...]
|
||||
for (let addr = SCAN_START; addr <= SCAN_END; addr += 4) {
|
||||
const v = f32[addr / 4];
|
||||
if (Math.abs(v - SENTINEL) > 1.0) {
|
||||
const key = v.toFixed(7);
|
||||
if (!zonesByInitKey[key]) zonesByInitKey[key] = [];
|
||||
zonesByInitKey[key].push(addr);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: match each JSON param to a zone.
|
||||
// paramDefs is an ordered list of all params (incl. hidden) from the JSON.
|
||||
// Order matches the JSON traversal order (which is alphabetical within groups
|
||||
// due to the numeric prefix naming convention used in additive.dsp).
|
||||
//
|
||||
// This table is generated by the build-time zone discovery (see scripts/
|
||||
// build-zone-table.js) and is hard-coded here for performance. The zones
|
||||
// are deterministic for a given WASM binary.
|
||||
//
|
||||
// Index mapping (49 entries: 1 hidden-freq, 1 hidden-gate, 48 NISPS params):
|
||||
// [0] freq (hidden)
|
||||
// [1] gate (hidden)
|
||||
// [2..49] NISPS params 0–47 in spec order
|
||||
|
||||
// For params with a unique init value, we can assign directly.
|
||||
// For ambiguous ones, we use the sequential sentinel method.
|
||||
|
||||
// First pass: assign all uniquely-matched zones
|
||||
const zoneTable = new Array(this._paramDefsLength()).fill(0);
|
||||
const assigned = new Array(this._paramDefsLength()).fill(false);
|
||||
const usedZones = new Set();
|
||||
|
||||
const paramDefs = this._paramDefs();
|
||||
|
||||
for (let i = 0; i < paramDefs.length; i++) {
|
||||
const p = paramDefs[i];
|
||||
const key = p.init.toFixed(7);
|
||||
const candidates = (zonesByInitKey[key] || []).filter(a => !usedZones.has(a));
|
||||
|
||||
if (candidates.length === 1) {
|
||||
zoneTable[i] = candidates[0];
|
||||
assigned[i] = true;
|
||||
usedZones.add(candidates[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: for unresolved params, use individual sentinel writes.
|
||||
for (let i = 0; i < paramDefs.length; i++) {
|
||||
if (assigned[i]) continue;
|
||||
|
||||
const p = paramDefs[i];
|
||||
const key = p.init.toFixed(7);
|
||||
const candidates = (zonesByInitKey[key] || []).filter(a => !usedZones.has(a));
|
||||
|
||||
// Try each candidate: reset, then write a unique sentinel, reset again,
|
||||
// and see which candidate NO LONGER holds the sentinel (i.e. got reset).
|
||||
// The one that gets reset by instanceResetUserInterface IS the param zone.
|
||||
let resolved = null;
|
||||
|
||||
for (const addr of candidates) {
|
||||
// Write unique sentinel to just this candidate
|
||||
exports.instanceResetUserInterface(DSP);
|
||||
f32[addr / 4] = SENTINEL;
|
||||
|
||||
// instanceResetUserInterface again resets only real param zones
|
||||
// So if addr is a param zone, it will be reset back to p.init
|
||||
exports.instanceResetUserInterface(DSP);
|
||||
const v = exports.getParamValue(DSP, addr);
|
||||
|
||||
if (Math.abs(v - p.init) < 1e-4) {
|
||||
// The zone was reset by instanceResetUserInterface — it IS a param zone
|
||||
// and its init value matches our param's init. Assign it.
|
||||
resolved = addr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved !== null) {
|
||||
zoneTable[i] = resolved;
|
||||
assigned[i] = true;
|
||||
usedZones.add(resolved);
|
||||
} else if (candidates.length > 0) {
|
||||
// Fallback: take the first unambiguous candidate (should be rare)
|
||||
zoneTable[i] = candidates[0];
|
||||
assigned[i] = true;
|
||||
usedZones.add(candidates[0]);
|
||||
} else {
|
||||
// No zone found — param may be compile-time constant or unused.
|
||||
// Write to address 0 (DSP instance pointer) is safe (read-only effectively).
|
||||
zoneTable[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose freq and gate zones separately (first two entries in paramDefs)
|
||||
this._freqZone = zoneTable[0];
|
||||
this._gateZone = zoneTable[1];
|
||||
// NISPS param zones start at index 2
|
||||
this._paramZones = zoneTable.slice(2);
|
||||
|
||||
// Restore param defaults
|
||||
exports.instanceResetUserInterface(DSP);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _paramDefs — ordered list matching the JSON traversal order.
|
||||
//
|
||||
// This list defines the zone-building traversal order. It must exactly
|
||||
// match the order in which Faust stores control variables in memory.
|
||||
// For additive.dsp, the JSON param order is:
|
||||
// 0_Hidden (freq, gate) → 1_Spectral Shape (14) → 2_Temporal (10) →
|
||||
// 3_Phase (8) → 4_Modulation (10) → 5_Master (6)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_paramDefs() {
|
||||
return [
|
||||
// Hidden
|
||||
{ init: 220 }, // [0] freq
|
||||
{ init: 0 }, // [1] gate (button)
|
||||
|
||||
// 1_Spectral Shape (params 0–13)
|
||||
{ init: 0.8 }, // [2] h1_amp
|
||||
{ init: 0.5 }, // [3] h2_amp
|
||||
{ init: 0.35 }, // [4] h3_amp
|
||||
{ init: 0.25 }, // [5] h4_amp
|
||||
{ init: 0.18 }, // [6] h5_amp
|
||||
{ init: 0.12 }, // [7] h6_amp
|
||||
{ init: 0.08 }, // [8] h7_amp
|
||||
{ init: 0.06 }, // [9] h8_amp
|
||||
{ init: 0.05 }, // [10] h9_16_amp
|
||||
{ init: 0.025 }, // [11] h17_32_amp
|
||||
{ init: 0.01 }, // [12] h33_64_amp
|
||||
{ init: 0 }, // [13] spectral_tilt
|
||||
{ init: 0 }, // [14] inharmonicity
|
||||
{ init: 0.5 }, // [15] odd_even
|
||||
|
||||
// 2_Temporal (params 14–23)
|
||||
{ init: 0.01 }, // [16] attack
|
||||
{ init: 0.3 }, // [17] decay
|
||||
{ init: 0.7 }, // [18] sustain
|
||||
{ init: 0.5 }, // [19] release
|
||||
{ init: 0.005 }, // [20] brightness_attack
|
||||
{ init: 0.15 }, // [21] brightness_decay
|
||||
{ init: 0.4 }, // [22] brightness_sustain
|
||||
{ init: 0.3 }, // [23] brightness_release
|
||||
{ init: 0.5 }, // [24] spectral_flux_rate
|
||||
{ init: 0.1 }, // [25] spectral_flux_depth
|
||||
|
||||
// 3_Phase (params 24–31)
|
||||
{ init: 0 }, // [26] phase_random
|
||||
{ init: 0 }, // [27] phase_walk_rate
|
||||
{ init: 0 }, // [28] beating_depth
|
||||
{ init: 1 }, // [29] beating_rate
|
||||
{ init: 0.1 }, // [30] stereo_phase_spread
|
||||
{ init: 0 }, // [31] noise_floor
|
||||
{ init: 0.5 }, // [32] noise_color
|
||||
{ init: 0 }, // [33] sub_harmonic
|
||||
|
||||
// 4_Modulation (params 32–41)
|
||||
{ init: 5 }, // [34] vibrato_rate
|
||||
{ init: 0 }, // [35] vibrato_depth
|
||||
{ init: 0.3 }, // [36] vibrato_delay
|
||||
{ init: 4 }, // [37] tremolo_rate
|
||||
{ init: 0 }, // [38] tremolo_depth
|
||||
{ init: 0 }, // [39] drift_rate
|
||||
{ init: 0 }, // [40] drift_depth
|
||||
{ init: 3 }, // [41] formant1_freq
|
||||
{ init: 6 }, // [42] formant2_freq
|
||||
{ init: 0 }, // [43] formant_depth
|
||||
|
||||
// 5_Master (params 42–47)
|
||||
{ init: 0.7 }, // [44] level
|
||||
{ init: 0.5 }, // [45] vel_sens
|
||||
{ init: 0.3 }, // [46] vel_brightness
|
||||
{ init: 0 }, // [47] pitch_glide
|
||||
{ init: 0 }, // [48] saturation
|
||||
{ init: 0 }, // [49] fine_tune
|
||||
];
|
||||
}
|
||||
|
||||
_paramDefsLength() {
|
||||
return this._paramDefs().length;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _onSetParam — called when the main thread sends { type: 'setParam' }
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._dspInst) return;
|
||||
const zone = this._paramZones[index];
|
||||
if (!zone) return;
|
||||
this._dspInst.exports.setParamValue(DSP, zone, value);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _onNoteOn — set freq and open the gate
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_onNoteOn(freq, vel) {
|
||||
if (!this._dspInst) return;
|
||||
this._currentVel = vel ?? 0.7;
|
||||
const ex = this._dspInst.exports;
|
||||
if (this._freqZone) ex.setParamValue(DSP, this._freqZone, freq);
|
||||
if (this._gateZone) ex.setParamValue(DSP, this._gateZone, 1.0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _onNoteOff — close the gate (DSP release envelope takes over)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_onNoteOff(_freq) {
|
||||
if (!this._dspInst) return;
|
||||
if (this._gateZone) this._dspInst.exports.setParamValue(DSP, this._gateZone, 0.0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _renderBlock — fill stereo output buffers each block
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._dspInst || !this._dspMemory) return;
|
||||
|
||||
const ex = this._dspInst.exports;
|
||||
|
||||
// Call Faust compute: compute(dsp, n, input_channels_ptr, output_channels_ptr)
|
||||
// For 0 inputs, input_channels_ptr = 0 (null pointer is safe for Faust)
|
||||
ex.compute(DSP, blockSize, 0, this._outPtrsAddr);
|
||||
|
||||
// Copy WASM output buffers to AudioWorklet output Float32Arrays
|
||||
const wL = new Float32Array(ex.memory.buffer, this._outLAddr, blockSize);
|
||||
const wR = new Float32Array(ex.memory.buffer, this._outRAddr, blockSize);
|
||||
outL.set(wL);
|
||||
outR.set(wR);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register the processor
|
||||
// ---------------------------------------------------------------------------
|
||||
registerProcessor('additive-processor', AdditiveProcessor);
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
// additive.dsp — Full 48-parameter additive synthesiser for MEMLNaut NISPS playground
|
||||
//
|
||||
// 64 harmonic sine banks with spectral-descriptor parametrisation.
|
||||
// All 48 params are designed for continuous ML exploration via NISPS.
|
||||
//
|
||||
// Parameter order matches NISPS output indices 0–47:
|
||||
// Group 1 — Spectral Shape (0–13): harmonic bank + tilt + inharmonicity + odd/even
|
||||
// Group 2 — Temporal (14–23): global ADSR + brightness ADSR + spectral flux
|
||||
// Group 3 — Phase (24–31): phase randomisation, beating, stereo, noise, sub
|
||||
// Group 4 — Modulation (32–41): vibrato, tremolo, drift, formants
|
||||
// Group 5 — Master (42–47): level, velocity, glide, saturation, fine-tune
|
||||
//
|
||||
// Groups are prefixed "1_", "2_" etc., and each parameter is prefixed "00_",
|
||||
// "01_" etc., so that Faust's alphabetical JSON ordering matches the spec.
|
||||
// The faustJsonToParamMeta parser strips numeric prefixes and "[...]" metadata
|
||||
// from labels to produce clean names.
|
||||
//
|
||||
// Hidden controls (not in paramMeta — worklet drives them directly):
|
||||
// freq — fundamental frequency Hz (noteOn)
|
||||
// gate — gate signal 0/1 (noteOn/noteOff)
|
||||
//
|
||||
// Build:
|
||||
// faust -lang wasm -cn additive -e additive.dsp -o additive.wasm
|
||||
// faust -json additive.dsp -o /dev/null (produces additive.dsp.json)
|
||||
//
|
||||
import("stdfaust.lib");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hidden controls
|
||||
// ---------------------------------------------------------------------------
|
||||
freq = hslider("0_Hidden/freq[hidden:1][unit:Hz]", 220, 20, 4000, 0.01);
|
||||
gate = button("0_Hidden/gate[hidden:1]");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1 — Spectral Shape (params 0–13)
|
||||
// ---------------------------------------------------------------------------
|
||||
h1_amp = hslider("1_Spectral Shape/00_h1_amp[tooltip:H1 amplitude]", 0.8, 0, 1, 0.001);
|
||||
h2_amp = hslider("1_Spectral Shape/01_h2_amp[tooltip:H2 amplitude]", 0.5, 0, 1, 0.001);
|
||||
h3_amp = hslider("1_Spectral Shape/02_h3_amp[tooltip:H3 amplitude]", 0.35, 0, 1, 0.001);
|
||||
h4_amp = hslider("1_Spectral Shape/03_h4_amp[tooltip:H4 amplitude]", 0.25, 0, 1, 0.001);
|
||||
h5_amp = hslider("1_Spectral Shape/04_h5_amp[tooltip:H5 amplitude]", 0.18, 0, 1, 0.001);
|
||||
h6_amp = hslider("1_Spectral Shape/05_h6_amp[tooltip:H6 amplitude]", 0.12, 0, 1, 0.001);
|
||||
h7_amp = hslider("1_Spectral Shape/06_h7_amp[tooltip:H7 amplitude]", 0.08, 0, 1, 0.001);
|
||||
h8_amp = hslider("1_Spectral Shape/07_h8_amp[tooltip:H8 amplitude]", 0.06, 0, 1, 0.001);
|
||||
h9_16_amp = hslider("1_Spectral Shape/08_h9_16_amp[tooltip:H9-16 group amp]", 0.05, 0, 1, 0.001);
|
||||
h17_32_amp = hslider("1_Spectral Shape/09_h17_32_amp[tooltip:H17-32 group amp]",0.025, 0, 1, 0.001);
|
||||
h33_64_amp = hslider("1_Spectral Shape/10_h33_64_amp[tooltip:H33-64 group amp]",0.01, 0, 1, 0.001);
|
||||
spectral_tilt = hslider("1_Spectral Shape/11_spectral_tilt[tooltip:Global tilt]", 0, -1, 1, 0.001);
|
||||
inharmonicity = hslider("1_Spectral Shape/12_inharmonicity[tooltip:Inharmonicity]", 0, 0, 0.15, 0.0001);
|
||||
odd_even = hslider("1_Spectral Shape/13_odd_even[tooltip:Odd/even balance]", 0.5, 0, 1, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2 — Temporal (params 14–23)
|
||||
// ---------------------------------------------------------------------------
|
||||
attack = hslider("2_Temporal/00_attack[scale:log][tooltip:Attack]", 0.01, 0.001, 5, 0.001);
|
||||
decay = hslider("2_Temporal/01_decay[tooltip:Decay]", 0.3, 0.001, 10, 0.001);
|
||||
sustain = hslider("2_Temporal/02_sustain[tooltip:Sustain]", 0.7, 0, 1, 0.001);
|
||||
release = hslider("2_Temporal/03_release[tooltip:Release]", 0.5, 0.01, 10, 0.001);
|
||||
brightness_attack = hslider("2_Temporal/04_brightness_attack[scale:log][tooltip:Bright A]", 0.005, 0.001, 5, 0.001);
|
||||
brightness_decay = hslider("2_Temporal/05_brightness_decay[tooltip:Bright D]", 0.15, 0.001, 5, 0.001);
|
||||
brightness_sustain = hslider("2_Temporal/06_brightness_sustain[tooltip:Bright S]", 0.4, 0, 1, 0.001);
|
||||
brightness_release = hslider("2_Temporal/07_brightness_release[tooltip:Bright R]", 0.3, 0.01, 5, 0.001);
|
||||
spectral_flux_rate = hslider("2_Temporal/08_spectral_flux_rate[tooltip:Flux rate]", 0.5, 0, 10, 0.01);
|
||||
spectral_flux_depth= hslider("2_Temporal/09_spectral_flux_depth[tooltip:Flux depth]", 0.1, 0, 1, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3 — Phase & Coherence (params 24–31)
|
||||
// ---------------------------------------------------------------------------
|
||||
phase_random = hslider("3_Phase/00_phase_random[tooltip:Phase randomisation]", 0, 0, 1, 0.001);
|
||||
phase_walk_rate = hslider("3_Phase/01_phase_walk_rate[tooltip:Phase walk rate]", 0, 0, 5, 0.001);
|
||||
beating_depth = hslider("3_Phase/02_beating_depth[tooltip:Beating depth]", 0, 0, 0.02, 0.0001);
|
||||
beating_rate = hslider("3_Phase/03_beating_rate[tooltip:Beating rate]", 1, 0, 10, 0.01);
|
||||
stereo_phase_spread= hslider("3_Phase/04_stereo_phase_spread[tooltip:Stereo spread]", 0.1, 0, 1, 0.001);
|
||||
noise_floor = hslider("3_Phase/05_noise_floor[tooltip:Noise floor]", 0, 0, 0.2, 0.001);
|
||||
noise_color = hslider("3_Phase/06_noise_color[tooltip:Noise colour]", 0.5, 0, 1, 0.001);
|
||||
sub_harmonic = hslider("3_Phase/07_sub_harmonic[tooltip:Sub-harmonic]", 0, 0, 1, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 4 — Modulation (params 32–41)
|
||||
// ---------------------------------------------------------------------------
|
||||
vibrato_rate = hslider("4_Modulation/00_vibrato_rate[tooltip:Vibrato rate]", 5, 0, 10, 0.01);
|
||||
vibrato_depth = hslider("4_Modulation/01_vibrato_depth[tooltip:Vibrato depth]", 0, 0, 0.05, 0.0001);
|
||||
vibrato_delay = hslider("4_Modulation/02_vibrato_delay[tooltip:Vibrato delay]", 0.3, 0, 2, 0.001);
|
||||
tremolo_rate = hslider("4_Modulation/03_tremolo_rate[tooltip:Tremolo rate]", 4, 0, 20, 0.01);
|
||||
tremolo_depth = hslider("4_Modulation/04_tremolo_depth[tooltip:Tremolo depth]", 0, 0, 1, 0.001);
|
||||
drift_rate = hslider("4_Modulation/05_drift_rate[tooltip:Drift rate]", 0, 0, 2, 0.001);
|
||||
drift_depth = hslider("4_Modulation/06_drift_depth[tooltip:Drift depth]", 0, 0, 0.3, 0.001);
|
||||
formant1_freq = hslider("4_Modulation/07_formant1_freq[tooltip:Formant 1 freq]",3, 1, 16, 0.01);
|
||||
formant2_freq = hslider("4_Modulation/08_formant2_freq[tooltip:Formant 2 freq]",6, 1, 16, 0.01);
|
||||
formant_depth = hslider("4_Modulation/09_formant_depth[tooltip:Formant depth]", 0, 0, 1, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 5 — Master (params 42–47)
|
||||
// ---------------------------------------------------------------------------
|
||||
level = hslider("5_Master/00_level[tooltip:Output level]", 0.7, 0, 1, 0.001);
|
||||
vel_sens = hslider("5_Master/01_vel_sens[tooltip:Velocity sens]", 0.5, 0, 1, 0.001);
|
||||
vel_bright = hslider("5_Master/02_vel_brightness[tooltip:Vel bright]",0.3, 0, 1, 0.001);
|
||||
pitch_glide= hslider("5_Master/03_pitch_glide[tooltip:Portamento]", 0, 0, 10, 0.01);
|
||||
saturation = hslider("5_Master/04_saturation[tooltip:Saturation]", 0, 0, 1, 0.001);
|
||||
fine_tune = hslider("5_Master/05_fine_tune[unit:ct][tooltip:Cents]", 0, -50, 50, 0.1);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal constants
|
||||
// ---------------------------------------------------------------------------
|
||||
N = 64;
|
||||
PI = ma.PI;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pitch — portamento + fine tune
|
||||
// ---------------------------------------------------------------------------
|
||||
glide_tau = 0.0001 + pitch_glide * 0.2;
|
||||
fine_ratio = pow(2.0, fine_tune / 1200.0);
|
||||
freq_smooth = freq * fine_ratio : si.smooth(ba.tau2pole(glide_tau));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vibrato LFO — delayed onset via slow-attack envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
vibrato_env = en.adsr(vibrato_delay, 0.01, 1.0, 0.5, gate);
|
||||
vibrato_lfo = os.osc(vibrato_rate) * vibrato_depth * vibrato_env;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tremolo LFO
|
||||
// ---------------------------------------------------------------------------
|
||||
tremolo_lfo = 1.0 - tremolo_depth * 0.5 * (1.0 + os.osc(tremolo_rate));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global amplitude ADSR
|
||||
// ---------------------------------------------------------------------------
|
||||
amp_env = en.adsr(attack, decay, sustain, release, gate);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brightness envelope — controls high harmonic amplitude over time
|
||||
// ---------------------------------------------------------------------------
|
||||
bright_env = en.adsr(brightness_attack, brightness_decay,
|
||||
brightness_sustain, brightness_release, gate);
|
||||
bright_blend(k) = float(k - 1) / float(N - 1);
|
||||
bright_factor(k) = 1.0 - bright_blend(k) + bright_blend(k) * bright_env;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spectral flux — slow LFO on upper harmonic amplitudes
|
||||
// ---------------------------------------------------------------------------
|
||||
flux_lfo = os.osc(spectral_flux_rate);
|
||||
flux_factor(k) = ba.if(k > 8,
|
||||
1.0 + spectral_flux_depth * flux_lfo * bright_blend(k),
|
||||
1.0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drift — slow random walk on per-partial amplitudes
|
||||
// ---------------------------------------------------------------------------
|
||||
drift_lfo(k) = no.noise * (float(k % 7 + 1) / 7.0)
|
||||
: fi.lowpass(1, max(0.1, drift_rate * 2.0))
|
||||
: *(drift_depth);
|
||||
drift_factor(k) = 1.0 + drift_lfo(k);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-harmonic base amplitude from group sliders
|
||||
// ---------------------------------------------------------------------------
|
||||
group_amp(k) =
|
||||
ba.if(k == 1, h1_amp,
|
||||
ba.if(k == 2, h2_amp,
|
||||
ba.if(k == 3, h3_amp,
|
||||
ba.if(k == 4, h4_amp,
|
||||
ba.if(k == 5, h5_amp,
|
||||
ba.if(k == 6, h6_amp,
|
||||
ba.if(k == 7, h7_amp,
|
||||
ba.if(k == 8, h8_amp,
|
||||
ba.if(k <= 16, h9_16_amp,
|
||||
ba.if(k <= 32, h17_32_amp,
|
||||
h33_64_amp))))))))));
|
||||
|
||||
// Spectral tilt: amp *= k^tilt (k=1 is always unity)
|
||||
tilt_factor(k) = pow(float(k), spectral_tilt);
|
||||
|
||||
// Odd/even balance: ×2 so unity gain at odd_even=0.5
|
||||
odd_weight = (1.0 - odd_even) * 2.0;
|
||||
even_weight = odd_even * 2.0;
|
||||
odd_even_factor(k) = ba.if(k % 2 == 0, even_weight, odd_weight);
|
||||
|
||||
// Combined raw amplitude
|
||||
harm_amp_raw(k) = group_amp(k) * tilt_factor(k) * odd_even_factor(k);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formant shaping — two Gaussian bumps in harmonic-index space
|
||||
// ---------------------------------------------------------------------------
|
||||
sigma_sq = 1.5 * 1.5;
|
||||
formant_bump(k, ctr) = exp(-0.5 * (float(k) - ctr) * (float(k) - ctr) / sigma_sq);
|
||||
formant_factor(k) =
|
||||
1.0 + formant_depth * (formant_bump(k, formant1_freq) + formant_bump(k, formant2_freq));
|
||||
|
||||
harm_amp(k) = harm_amp_raw(k) * formant_factor(k);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inharmonic partial frequency: freq_k = k*f0*(1 + B*(k^2 - 1))
|
||||
// ---------------------------------------------------------------------------
|
||||
harm_freq(k) = freq_smooth * float(k)
|
||||
* (1.0 + inharmonicity * (float(k) * float(k) - 1.0));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase randomisation — adds per-harmonic noise to the oscillator frequency,
|
||||
// gradually dephasing partials (phase_random=0: phase-locked, =1: random)
|
||||
// ---------------------------------------------------------------------------
|
||||
phase_rand_lfo(k) = no.noise * (float((k * 17 + 3) % 31 + 1) / 31.0)
|
||||
: si.smooth(ba.tau2pole(0.05))
|
||||
: *(phase_random * harm_freq(k) * 0.01);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase random walk — slower independent drift per partial
|
||||
// ---------------------------------------------------------------------------
|
||||
phase_walk(k) = no.noise * (float(k % 7 + 1) / 7.0)
|
||||
: si.smooth(ba.tau2pole(0.1))
|
||||
: *(phase_walk_rate);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inter-partial beating — sinusoidal detuning stagger
|
||||
// ---------------------------------------------------------------------------
|
||||
beating_offset(k) = beating_depth
|
||||
* os.osc(beating_rate * float(k % 3 + 1) * 0.7)
|
||||
* harm_freq(k);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stereo spread — R channel gets a small per-harmonic pitch offset
|
||||
// ---------------------------------------------------------------------------
|
||||
stereo_spread_freq(k) = stereo_phase_spread * float(k) * 0.01;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additive oscillator sums — L and R
|
||||
// ---------------------------------------------------------------------------
|
||||
additive_L = sum(k, N,
|
||||
harm_amp(k+1) * bright_factor(k+1) * flux_factor(k+1) * drift_factor(k+1) *
|
||||
os.osc( harm_freq(k+1) * (1.0 + vibrato_lfo)
|
||||
+ beating_offset(k+1)
|
||||
+ phase_rand_lfo(k+1)
|
||||
+ phase_walk(k+1)
|
||||
)
|
||||
);
|
||||
|
||||
additive_R = sum(k, N,
|
||||
harm_amp(k+1) * bright_factor(k+1) * flux_factor(k+1) * drift_factor(k+1) *
|
||||
os.osc( harm_freq(k+1) * (1.0 + vibrato_lfo + stereo_spread_freq(k+1))
|
||||
+ beating_offset(k+1)
|
||||
+ phase_rand_lfo(k+1)
|
||||
+ phase_walk(k+1)
|
||||
)
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-harmonic (0.5× fundamental)
|
||||
// ---------------------------------------------------------------------------
|
||||
sub_osc = sub_harmonic * os.osc(freq_smooth * 0.5);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Noise floor — coloured via one-pole LP (noise_color=0→white, =1→dark)
|
||||
// ---------------------------------------------------------------------------
|
||||
noise_lp_cutoff = 200.0 + (1.0 - noise_color) * 19800.0;
|
||||
noise_signal = no.noise : fi.lowpass(1, noise_lp_cutoff);
|
||||
noise_out = noise_floor * noise_signal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Soft-clip saturation — tanh waveshaper
|
||||
// ---------------------------------------------------------------------------
|
||||
drive = 1.0 + saturation * 9.0;
|
||||
softclip(x) = ma.tanh(x * drive) / drive;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output gain
|
||||
// vel_bright modulates how much the brightness envelope boosts the overall
|
||||
// level during note onset (couples velocity sensitivity to brightness).
|
||||
// Here it acts as a subtle mid-term gain shaper via the bright_env signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
vel_bright_boost = 1.0 + vel_bright * bright_env * 0.3;
|
||||
out_gain = level * (1.0 - vel_sens * 0.3) * vel_bright_boost;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Final assembly
|
||||
// ---------------------------------------------------------------------------
|
||||
signal_L = (additive_L + sub_osc + noise_out) * amp_env * tremolo_lfo * out_gain;
|
||||
signal_R = (additive_R + sub_osc + noise_out) * amp_env * tremolo_lfo * out_gain;
|
||||
|
||||
process = softclip(signal_L), softclip(signal_R);
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,123 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# build.sh — compile all Faust DSP files in this directory to WASM + JSON
|
||||
#
|
||||
# Required tools:
|
||||
# faust >= 2.60.0 (https://faust.grame.fr / nix: faust)
|
||||
# emcc >= 3.1.x (Emscripten, already present at /usr/lib/emscripten/emcc)
|
||||
#
|
||||
# Usage:
|
||||
# cd playground/faust && ./build.sh
|
||||
#
|
||||
# Outputs (alongside each .dsp):
|
||||
# <name>.wasm — compiled audio DSP binary
|
||||
# <name>.json — Faust UI descriptor (consumed by faustJsonToParamMeta)
|
||||
# <name>.js — JS glue / AudioWorklet wrapper generated by faust2wasm
|
||||
#
|
||||
# The JSON descriptor is the key artifact: it contains the full UI tree
|
||||
# (hslider / vslider / nentry / groups) that faustJsonToParamMeta.js parses
|
||||
# into the playground's standard paramMeta format.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency checks
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# If faust isn't in PATH, automatically re-exec under `nix-shell -p faust`
|
||||
# when nix-shell is available. NISPS_BUILD_IN_NIX_SHELL prevents infinite
|
||||
# recursion if the nix-shell environment somehow still lacks faust.
|
||||
|
||||
if ! command -v faust &>/dev/null; then
|
||||
if [ "${NISPS_BUILD_IN_NIX_SHELL:-0}" = "1" ]; then
|
||||
echo "ERROR: 'faust' still not found inside nix-shell. Check your nixpkgs channel."
|
||||
exit 1
|
||||
fi
|
||||
if command -v nix-shell &>/dev/null; then
|
||||
echo "faust not in PATH — re-exec under nix-shell -p faust ..."
|
||||
exec env NISPS_BUILD_IN_NIX_SHELL=1 nix-shell -p faust --run "\"$0\" $*"
|
||||
fi
|
||||
echo ""
|
||||
echo "ERROR: 'faust' not found in PATH (and nix-shell is unavailable)."
|
||||
echo ""
|
||||
echo "Install options:"
|
||||
echo " nix-shell -p faust # one-off"
|
||||
echo " nix profile install nixpkgs#faust # permanent"
|
||||
echo " Or download from: https://faust.grame.fr/downloads/"
|
||||
echo ""
|
||||
echo "Required version: >= 2.60.0"
|
||||
echo "Check: faust --version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FAUST_VER="$(faust --version 2>&1 | head -1)"
|
||||
echo "faust: $FAUST_VER"
|
||||
|
||||
# emcc is optional — faust -lang wasm doesn't require it.
|
||||
# It is needed if you want to link extra C++ into the WASM module.
|
||||
if command -v emcc &>/dev/null; then
|
||||
EMCC_VER="$(emcc --version 2>&1 | head -1)"
|
||||
echo "emcc : $EMCC_VER"
|
||||
else
|
||||
echo "emcc : not found (not required for basic faust -lang wasm builds)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compile each .dsp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DSP_FILES=("$SCRIPT_DIR"/*.dsp)
|
||||
|
||||
if [ ${#DSP_FILES[@]} -eq 0 ]; then
|
||||
echo "No .dsp files found in $SCRIPT_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for DSP in "${DSP_FILES[@]}"; do
|
||||
NAME="$(basename "$DSP" .dsp)"
|
||||
echo "Compiling $NAME.dsp ..."
|
||||
|
||||
# Faust -o is interpreted relative to -O (so we must cd into SCRIPT_DIR
|
||||
# and pass just the filename). Class names (-cn) must be valid C identifiers,
|
||||
# so dash characters are replaced with underscores.
|
||||
CLASSNAME="$(echo "$NAME" | tr '-' '_')"
|
||||
|
||||
# Step 1: emit WASM binary + JS glue
|
||||
# -lang wasm — target WebAssembly
|
||||
# -cn <Name> — class name prefix in generated JS
|
||||
# -O <dir> — output directory
|
||||
#
|
||||
# NB: do NOT pass -e here. In Faust 2.79, -e means "export expanded DSP
|
||||
# (textual)" — it causes the .wasm output to be Faust source text, not a
|
||||
# real WebAssembly binary. This script historically used -e by mistake.
|
||||
( cd "$SCRIPT_DIR" && \
|
||||
faust -lang wasm \
|
||||
-cn "$CLASSNAME" \
|
||||
-O "$SCRIPT_DIR" \
|
||||
"${NAME}.dsp" \
|
||||
-o "${NAME}.wasm" )
|
||||
|
||||
# Note: `faust -lang wasm -O . foo.dsp -o foo.wasm` already writes
|
||||
# foo.json alongside foo.wasm with the wasm-native descriptor (including
|
||||
# numeric `index` fields that workers use as setParamValue zone addresses).
|
||||
# The old standalone `-json` pass produced a smaller JSON WITHOUT the
|
||||
# `index` field, which is the wrong shape for workers — don't run it.
|
||||
if [ ! -f "$SCRIPT_DIR/${NAME}.json" ]; then
|
||||
echo " WARNING: ${NAME}.json not produced — check faust version"
|
||||
fi
|
||||
|
||||
if [ -f "$SCRIPT_DIR/${NAME}.wasm" ]; then
|
||||
WASM_SIZE="$(du -h "$SCRIPT_DIR/${NAME}.wasm" | cut -f1)"
|
||||
echo " -> ${NAME}.wasm (${WASM_SIZE})"
|
||||
fi
|
||||
if [ -f "$SCRIPT_DIR/${NAME}.json" ]; then
|
||||
echo " -> ${NAME}.json"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. Load engines in the playground via FaustEngineBase:"
|
||||
echo " import { AdditiveEngine } from './js/synth/additive-engine.js';"
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
/**
|
||||
* eoc-compressor-processor.js — AudioWorklet processor for the stereo compressor
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads eoc-compressor.wasm compiled from eoc-compressor.dsp.
|
||||
*
|
||||
* 7 params (alphabetical order as emitted by Faust JSON):
|
||||
* 0: attack (ms)
|
||||
* 1: knee (dB)
|
||||
* 2: makeup (dB)
|
||||
* 3: mix (0–1)
|
||||
* 4: ratio
|
||||
* 5: release (ms)
|
||||
* 6: threshold (dB)
|
||||
*/
|
||||
|
||||
// Runs in AudioWorkletGlobalScope — faust-worklet-processor.js must be loaded first.
|
||||
|
||||
class EOCCompressorProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dsp = null;
|
||||
this._exports = null;
|
||||
this._heap = null;
|
||||
this._heapi32 = null;
|
||||
this._mem = null;
|
||||
this._paramAddresses = [];
|
||||
this._inputL = null;
|
||||
this._inputR = null;
|
||||
this._sampleRate = 48000;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FaustWorkletProcessor overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sr) {
|
||||
this._sampleRate = sr;
|
||||
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
const memory = new WebAssembly.Memory({ initial: 32, maximum: 256 });
|
||||
|
||||
const imports = {
|
||||
env: {
|
||||
memory,
|
||||
memoryBase: 0,
|
||||
tableBase: 0,
|
||||
_abs: Math.abs,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
_ceilf: Math.ceil,
|
||||
_cosf: Math.cos,
|
||||
_expf: Math.exp,
|
||||
_floorf: Math.floor,
|
||||
_fmodf: (x, y) => x % y,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_max_f: Math.max,
|
||||
_min_f: Math.min,
|
||||
_remainderf: (x, y) => x - Math.round(x / y) * y,
|
||||
_powf: Math.pow,
|
||||
_roundf: Math.round,
|
||||
_sinf: Math.sin,
|
||||
_sqrtf: Math.sqrt,
|
||||
_tanf: Math.tan,
|
||||
_fabs: Math.abs,
|
||||
table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' }),
|
||||
},
|
||||
};
|
||||
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
this._exports = instance.exports;
|
||||
this._mem = memory;
|
||||
this._heap = new Float32Array(memory.buffer);
|
||||
this._heapi32 = new Int32Array(memory.buffer);
|
||||
|
||||
const exps = this._exports;
|
||||
|
||||
if (exps.createDSPInstance) {
|
||||
this._dsp = exps.createDSPInstance();
|
||||
} else if (exps.eoc_compressor) {
|
||||
this._dsp = exps.eoc_compressor();
|
||||
} else {
|
||||
const fnKeys = Object.keys(exps).filter(k => typeof exps[k] === 'function');
|
||||
console.warn('[EOCCompressorProcessor] No DSP constructor found; exports:', fnKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
exps.init(this._dsp, sr);
|
||||
this._buildParamIndex(exps, memory);
|
||||
}
|
||||
|
||||
_buildParamIndex(exports, memory) {
|
||||
if (!exports.getJSON) return;
|
||||
const ptr = exports.getJSON(this._dsp);
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
let str = '';
|
||||
let i = ptr;
|
||||
while (buf[i] !== 0) { str += String.fromCharCode(buf[i++]); }
|
||||
|
||||
let desc;
|
||||
try { desc = JSON.parse(str); } catch { return; }
|
||||
|
||||
const addresses = [];
|
||||
|
||||
function walk(items) {
|
||||
for (const item of items) {
|
||||
const type = item.type ?? '';
|
||||
if (['hslider', 'vslider', 'nentry'].includes(type)) {
|
||||
addresses.push(item.address ?? '');
|
||||
} else if (item.items) {
|
||||
walk(item.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(desc.ui ?? []);
|
||||
this._paramAddresses = addresses;
|
||||
}
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const addr = this._paramAddresses[index];
|
||||
if (addr !== undefined && this._exports.setParamValue) {
|
||||
this._exports.setParamValue(this._dsp, addr, value);
|
||||
}
|
||||
}
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
|
||||
const exps = this._exports;
|
||||
const mem = this._mem;
|
||||
const heap = this._heap;
|
||||
const i32 = this._heapi32;
|
||||
|
||||
const heapWords = mem.buffer.byteLength >> 2;
|
||||
const inLOff = heapWords - blockSize * 6 - 32;
|
||||
const inROff = inLOff + blockSize;
|
||||
const outLOff = inROff + blockSize;
|
||||
const outROff = outLOff + blockSize;
|
||||
const inPtrsOff = outROff + blockSize;
|
||||
const outPtrsOff = inPtrsOff + 2;
|
||||
|
||||
i32[inPtrsOff] = inLOff * 4;
|
||||
i32[inPtrsOff + 1] = inROff * 4;
|
||||
i32[outPtrsOff] = outLOff * 4;
|
||||
i32[outPtrsOff + 1] = outROff * 4;
|
||||
|
||||
const srcL = this._inputL;
|
||||
const srcR = this._inputR;
|
||||
if (srcL) for (let i = 0; i < blockSize; i++) heap[inLOff + i] = srcL[i];
|
||||
if (srcR) for (let i = 0; i < blockSize; i++) heap[inROff + i] = srcR[i];
|
||||
|
||||
exps.compute(this._dsp, blockSize, inPtrsOff * 4, outPtrsOff * 4);
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
outL[i] = heap[outLOff + i];
|
||||
outR[i] = heap[outROff + i];
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs, outputs, params) {
|
||||
const inp = inputs[0];
|
||||
this._inputL = inp?.[0] ?? null;
|
||||
this._inputR = inp?.[1] ?? inp?.[0] ?? null;
|
||||
return super.process(inputs, outputs, params);
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('eoc-compressor-processor', EOCCompressorProcessor);
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
// eoc-compressor.dsp — Stereo feed-forward compressor for MEMLNaut EOC chain
|
||||
//
|
||||
// 7 params: threshold, ratio, attack, release, knee, makeup, mix
|
||||
//
|
||||
// Compile:
|
||||
// faust -lang wasm -cn eoc_compressor -e eoc-compressor.dsp -o eoc-compressor.wasm -json
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
threshold = hslider("threshold[unit:dB]", -24.0, -60.0, 0.0, 0.1);
|
||||
ratio = hslider("ratio", 4.0, 1.0, 20.0, 0.1);
|
||||
attack = hslider("attack[unit:ms]", 10.0, 0.1, 200.0, 0.1);
|
||||
release = hslider("release[unit:ms]", 100.0, 10.0, 2000.0, 1.0);
|
||||
knee = hslider("knee[unit:dB]", 6.0, 0.0, 24.0, 0.1);
|
||||
makeup = hslider("makeup[unit:dB]", 0.0, 0.0, 24.0, 0.1);
|
||||
mix = hslider("mix", 1.0, 0.0, 1.0, 0.001);
|
||||
|
||||
// Convert ms to seconds for Faust
|
||||
attackSec = attack / 1000.0;
|
||||
releaseSec = release / 1000.0;
|
||||
|
||||
// Soft-knee threshold adjustment (shift threshold down by half the knee)
|
||||
threshKnee = threshold - knee / 2.0;
|
||||
|
||||
// Makeup gain as linear multiplier
|
||||
makeupLin = ba.db2linear(makeup);
|
||||
|
||||
// Compressor on a single channel with makeup applied
|
||||
compCh(x) = co.compressor_mono(ratio, threshKnee, attackSec, releaseSec, x) * makeupLin;
|
||||
|
||||
// Parallel compression (dry/wet blend)
|
||||
parallelComp(x) = (1.0 - mix) * x + mix * compCh(x);
|
||||
|
||||
process = parallelComp, parallelComp;
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,159 +0,0 @@
|
|||
/**
|
||||
* eoc-delay-processor.js — AudioWorklet processor for the EOC Stereo Delay.
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads eoc-delay.wasm compiled from eoc-delay.dsp.
|
||||
*
|
||||
* Parameter index order (alphabetical within group, matching eoc-delay.json):
|
||||
* 0 feedback [0, 0.95]
|
||||
* 1 lp_cutoff [500, 20000]
|
||||
* 2 mix [0, 1]
|
||||
* 3 ping_pong [0, 1]
|
||||
* 4 spread [0, 1]
|
||||
* 5 sync [0, 3] (nentry)
|
||||
* 6 time [1, 2000]
|
||||
*/
|
||||
|
||||
// Must be loaded in AudioWorkletGlobalScope after faust-worklet-processor.js.
|
||||
|
||||
class EOCDelayProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dsp = null;
|
||||
this._paramAddresses = [];
|
||||
this._blockSize = 128;
|
||||
this._sampleRate = 48000;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FaustWorkletProcessor overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sr) {
|
||||
this._sampleRate = sr;
|
||||
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
const memory = new WebAssembly.Memory({ initial: 32, maximum: 256 });
|
||||
|
||||
const imports = {
|
||||
env: {
|
||||
memory,
|
||||
memoryBase: 0,
|
||||
tableBase: 0,
|
||||
_abs: Math.abs,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
_ceilf: Math.ceil,
|
||||
_cosf: Math.cos,
|
||||
_expf: Math.exp,
|
||||
_floorf: Math.floor,
|
||||
_fmodf: (x, y) => x % y,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_max_f: Math.max,
|
||||
_min_f: Math.min,
|
||||
_remainderf: (x, y) => x - Math.round(x / y) * y,
|
||||
_powf: Math.pow,
|
||||
_roundf: Math.round,
|
||||
_sinf: Math.sin,
|
||||
_sqrtf: Math.sqrt,
|
||||
_tanf: Math.tan,
|
||||
_fabs: Math.abs,
|
||||
table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' }),
|
||||
},
|
||||
};
|
||||
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
const exports = instance.exports;
|
||||
|
||||
this._exports = exports;
|
||||
this._heap = new Float32Array(memory.buffer);
|
||||
this._heapi32 = new Int32Array(memory.buffer);
|
||||
this._mem = memory;
|
||||
|
||||
// Create DSP instance
|
||||
if (exports.createDSPInstance) {
|
||||
this._dsp = exports.createDSPInstance();
|
||||
} else if (exports.eoc_delay) {
|
||||
this._dsp = exports.eoc_delay();
|
||||
} else {
|
||||
console.warn('[EOCDelayProcessor] No DSP factory found; exports:', Object.keys(exports));
|
||||
return;
|
||||
}
|
||||
|
||||
exports.init(this._dsp, sr);
|
||||
this._buildParamIndex(exports, memory);
|
||||
}
|
||||
|
||||
_buildParamIndex(exports, memory) {
|
||||
if (!exports.getJSON) return;
|
||||
const ptr = exports.getJSON(this._dsp);
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
let str = '';
|
||||
let i = ptr;
|
||||
while (buf[i] !== 0) { str += String.fromCharCode(buf[i++]); }
|
||||
|
||||
let desc;
|
||||
try { desc = JSON.parse(str); } catch { return; }
|
||||
|
||||
const addresses = [];
|
||||
|
||||
function walk(items, path) {
|
||||
for (const item of items) {
|
||||
const label = item.label ?? '';
|
||||
const type = item.type ?? '';
|
||||
const addr = item.address ?? (path + '/' + label);
|
||||
if (['hslider', 'vslider', 'nentry', 'button', 'checkbox'].includes(type)) {
|
||||
addresses.push(addr);
|
||||
} else if (item.items) {
|
||||
walk(item.items, path + '/' + label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(desc.ui ?? [], '');
|
||||
this._paramAddresses = addresses;
|
||||
}
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const addr = this._paramAddresses[index];
|
||||
if (addr && this._exports.setParamValue) {
|
||||
this._exports.setParamValue(this._dsp, addr, value);
|
||||
}
|
||||
}
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const exports = this._exports;
|
||||
const mem = this._mem;
|
||||
const heap = this._heap;
|
||||
|
||||
const heapBytes = mem.buffer.byteLength;
|
||||
const inLOff = (heapBytes >> 2) - blockSize * 4 - 128;
|
||||
const inROff = inLOff + blockSize;
|
||||
const outLOff = inROff + blockSize;
|
||||
const outROff = outLOff + blockSize;
|
||||
|
||||
// Zero input buffers (delay is an effect — pass through audio)
|
||||
// Input pointers
|
||||
const i32 = this._heapi32;
|
||||
const inPtrsOff = outROff + blockSize;
|
||||
const outPtrsOff = inPtrsOff + 2;
|
||||
i32[inPtrsOff] = inLOff * 4;
|
||||
i32[inPtrsOff + 1] = inROff * 4;
|
||||
i32[outPtrsOff] = outLOff * 4;
|
||||
i32[outPtrsOff + 1] = outROff * 4;
|
||||
|
||||
exports.compute(this._dsp, blockSize, inPtrsOff * 4, outPtrsOff * 4);
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
outL[i] = heap[outLOff + i];
|
||||
outR[i] = heap[outROff + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('eoc-delay-processor', EOCDelayProcessor);
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
// eoc-delay.dsp — Stereo delay effect for the EOC rack.
|
||||
//
|
||||
// Parameters (7):
|
||||
// time [250ms, 1-2000ms] delay time (ms) when sync=0
|
||||
// feedback [0.3, 0-0.95] feedback amount
|
||||
// lp_cutoff [8000, 500-20000] LP filter cutoff on feedback path (Hz)
|
||||
// ping_pong [0.0, 0-1] 0=normal stereo, 1=full ping-pong L<->R
|
||||
// spread [0.5, 0-1] stereo width of delay tails
|
||||
// sync [0] nentry: 0=free, 1=half, 2=quarter, 3=eighth (120bpm)
|
||||
// mix [0.3, 0-1] dry/wet mix
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
time = hslider("time[unit:ms]", 250, 1, 2000, 0.1);
|
||||
feedback = hslider("feedback", 0.3, 0, 0.95, 0.001);
|
||||
lp_cutoff = hslider("lp_cutoff[unit:Hz]", 8000, 500, 20000, 1);
|
||||
ping_pong = hslider("ping_pong", 0.0, 0, 1, 0.001);
|
||||
spread = hslider("spread", 0.5, 0, 1, 0.001);
|
||||
sync = nentry("sync", 0, 0, 3, 1);
|
||||
mix = hslider("mix", 0.3, 0, 1, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived delay time: free or tempo-synced at 120bpm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bpm = 120.0;
|
||||
beat_ms = 60000.0 / bpm;
|
||||
|
||||
sync_time_ms =
|
||||
ba.if(sync < 0.5, time,
|
||||
ba.if(sync < 1.5, beat_ms * 2.0,
|
||||
ba.if(sync < 2.5, beat_ms,
|
||||
beat_ms * 0.5)));
|
||||
|
||||
// Fixed max delay: 96000 samples (1s at 96kHz, covers all tempos for eighth notes at 30bpm+)
|
||||
max_delay_samp = 96000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stereo ping-pong delay.
|
||||
// Uses f ~ (_, _) pattern for stereo feedback loop.
|
||||
// de.delay used for variable delay (no LP on feedback path inside the loop —
|
||||
// LP is applied to the whole wet signal for efficiency).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
dSamp = int(sync_time_ms * float(ma.SR) / 1000.0);
|
||||
|
||||
delayCore(inL, inR, fbL, fbR) = wetL, wetR
|
||||
with {
|
||||
pp = ping_pong;
|
||||
fb = feedback;
|
||||
mixL = inL + (fbL * (1.0 - pp) + fbR * pp) * fb;
|
||||
mixR = inR + (fbR * (1.0 - pp) + fbL * pp) * fb;
|
||||
wetL = de.delay(max_delay_samp, dSamp, mixL);
|
||||
wetR = de.delay(max_delay_samp, dSamp, mixR);
|
||||
};
|
||||
|
||||
stereoDelay = (_, _) : delayCore ~ (_, _);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-delay LP filter (tone shaping on the feedback tail)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
delayLP(x) = fi.lowpass(1, lp_cutoff, x);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mid-side stereo spread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
msSpread(inL, inR) = outL, outR
|
||||
with {
|
||||
w = spread;
|
||||
mid = (inL + inR) * 0.5;
|
||||
side = (inL - inR) * 0.5;
|
||||
outL = mid + side * w * 2.0;
|
||||
outR = mid - side * w * 2.0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main process: dry/wet mix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Delay + LP + spread on two signals
|
||||
delayAndProcess(inL, inR) = wideL, wideR
|
||||
with {
|
||||
wetL0 = stereoDelay(inL, inR) : _,!;
|
||||
wetR0 = stereoDelay(inL, inR) : !,_;
|
||||
wetL1 = delayLP(wetL0);
|
||||
wetR1 = delayLP(wetR0);
|
||||
wideL = msSpread(wetL1, wetR1) : _,!;
|
||||
wideR = msSpread(wetL1, wetR1) : !,_;
|
||||
};
|
||||
|
||||
process(inL, inR) = outL, outR
|
||||
with {
|
||||
wideL = delayAndProcess(inL, inR) : _,!;
|
||||
wideR = delayAndProcess(inL, inR) : !,_;
|
||||
outL = inL * (1.0 - mix) + wideL * mix;
|
||||
outR = inR * (1.0 - mix) + wideR * mix;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,183 +0,0 @@
|
|||
/**
|
||||
* eoc-eq-processor.js — AudioWorklet processor for the 4-band parametric EQ
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads eoc-eq.wasm compiled from eoc-eq.dsp.
|
||||
*
|
||||
* 10 exposed params (4 bands × freq/gain; mid bands also have Q):
|
||||
* Band 1 (Low Shelf): freq1, gain1
|
||||
* Band 2 (Low-Mid): freq2, gain2, q2
|
||||
* Band 3 (High-Mid): freq3, gain3, q3
|
||||
* Band 4 (High Shelf): freq4, gain4
|
||||
*
|
||||
* Note: shelf bands (1 and 4) omit Q — Faust fi.low_shelf / fi.high_shelf
|
||||
* take only freq and gain.
|
||||
*
|
||||
* Parameter index order follows the Faust JSON descriptor (group declaration order,
|
||||
* alphabetical within groups — confirmed by eoc-eq.json).
|
||||
*/
|
||||
|
||||
// Runs in AudioWorkletGlobalScope — faust-worklet-processor.js must be loaded first.
|
||||
|
||||
class EOCEQProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dsp = null;
|
||||
this._exports = null;
|
||||
this._heap = null;
|
||||
this._heapi32 = null;
|
||||
this._mem = null;
|
||||
this._paramAddresses = [];
|
||||
this._sampleRate = 48000;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FaustWorkletProcessor overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sr) {
|
||||
this._sampleRate = sr;
|
||||
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
const memory = new WebAssembly.Memory({ initial: 32, maximum: 256 });
|
||||
|
||||
const imports = {
|
||||
env: {
|
||||
memory,
|
||||
memoryBase: 0,
|
||||
tableBase: 0,
|
||||
_abs: Math.abs,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
_ceilf: Math.ceil,
|
||||
_cosf: Math.cos,
|
||||
_expf: Math.exp,
|
||||
_floorf: Math.floor,
|
||||
_fmodf: (x, y) => x % y,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_max_f: Math.max,
|
||||
_min_f: Math.min,
|
||||
_remainderf: (x, y) => x - Math.round(x / y) * y,
|
||||
_powf: Math.pow,
|
||||
_roundf: Math.round,
|
||||
_sinf: Math.sin,
|
||||
_sqrtf: Math.sqrt,
|
||||
_tanf: Math.tan,
|
||||
_fabs: Math.abs,
|
||||
table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' }),
|
||||
},
|
||||
};
|
||||
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
this._exports = instance.exports;
|
||||
this._mem = memory;
|
||||
this._heap = new Float32Array(memory.buffer);
|
||||
this._heapi32 = new Int32Array(memory.buffer);
|
||||
|
||||
const exps = this._exports;
|
||||
|
||||
// Create DSP instance
|
||||
if (exps.createDSPInstance) {
|
||||
this._dsp = exps.createDSPInstance();
|
||||
} else if (exps.eoc_eq) {
|
||||
this._dsp = exps.eoc_eq();
|
||||
} else {
|
||||
const fnKeys = Object.keys(exps).filter(k => typeof exps[k] === 'function');
|
||||
console.warn('[EOCEQProcessor] No DSP constructor found; exports:', fnKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
exps.init(this._dsp, sr);
|
||||
this._buildParamIndex(exps, memory);
|
||||
}
|
||||
|
||||
_buildParamIndex(exports, memory) {
|
||||
if (!exports.getJSON) return;
|
||||
const ptr = exports.getJSON(this._dsp);
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
let str = '';
|
||||
let i = ptr;
|
||||
while (buf[i] !== 0) { str += String.fromCharCode(buf[i++]); }
|
||||
|
||||
let desc;
|
||||
try { desc = JSON.parse(str); } catch { return; }
|
||||
|
||||
const addresses = [];
|
||||
|
||||
function walk(items) {
|
||||
for (const item of items) {
|
||||
const type = item.type ?? '';
|
||||
if (['hslider', 'vslider', 'nentry'].includes(type)) {
|
||||
addresses.push(item.address ?? '');
|
||||
} else if (item.items) {
|
||||
walk(item.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(desc.ui ?? []);
|
||||
this._paramAddresses = addresses;
|
||||
}
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const addr = this._paramAddresses[index];
|
||||
if (addr !== undefined && this._exports.setParamValue) {
|
||||
this._exports.setParamValue(this._dsp, addr, value);
|
||||
}
|
||||
}
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
|
||||
const exps = this._exports;
|
||||
const mem = this._mem;
|
||||
const heap = this._heap;
|
||||
const i32 = this._heapi32;
|
||||
|
||||
// Allocate input + output buffers at end of WASM heap
|
||||
const heapWords = mem.buffer.byteLength >> 2;
|
||||
const inLOff = heapWords - blockSize * 6 - 32;
|
||||
const inROff = inLOff + blockSize;
|
||||
const outLOff = inROff + blockSize;
|
||||
const outROff = outLOff + blockSize;
|
||||
|
||||
// Copy inputs (silence — effect processes in-place from AudioWorklet inputs)
|
||||
// The AudioWorklet process() provides inputs[0] to the raw in buffers via
|
||||
// the overridden process() below.
|
||||
// For now fill with stored input (set by process()).
|
||||
const inPtrsOff = outROff + blockSize;
|
||||
const outPtrsOff = inPtrsOff + 2;
|
||||
|
||||
i32[inPtrsOff] = inLOff * 4;
|
||||
i32[inPtrsOff + 1] = inROff * 4;
|
||||
i32[outPtrsOff] = outLOff * 4;
|
||||
i32[outPtrsOff + 1] = outROff * 4;
|
||||
|
||||
// Copy input from staging buffers (filled in process())
|
||||
const srcL = this._inputL;
|
||||
const srcR = this._inputR;
|
||||
if (srcL) for (let i = 0; i < blockSize; i++) heap[inLOff + i] = srcL[i];
|
||||
if (srcR) for (let i = 0; i < blockSize; i++) heap[inROff + i] = srcR[i];
|
||||
|
||||
exps.compute(this._dsp, blockSize, inPtrsOff * 4, outPtrsOff * 4);
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
outL[i] = heap[outLOff + i];
|
||||
outR[i] = heap[outROff + i];
|
||||
}
|
||||
}
|
||||
|
||||
// Override process() to capture inputs before calling parent
|
||||
process(inputs, outputs, params) {
|
||||
const inp = inputs[0];
|
||||
this._inputL = inp?.[0] ?? null;
|
||||
this._inputR = inp?.[1] ?? inp?.[0] ?? null; // mono fallback to L
|
||||
return super.process(inputs, outputs, params);
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('eoc-eq-processor', EOCEQProcessor);
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// eoc-eq.dsp — 4-band parametric EQ for MEMLNaut EOC chain
|
||||
//
|
||||
// Band 1: Low Shelf (default 80 Hz, range 20–500)
|
||||
// Band 2: Low-Mid bell (default 400 Hz, range 100–2000)
|
||||
// Band 3: High-Mid bell (default 2500 Hz, range 500–8000)
|
||||
// Band 4: High Shelf (default 8000 Hz, range 2000–20000)
|
||||
//
|
||||
// Compile:
|
||||
// faust -lang wasm -cn eoc_eq -e eoc-eq.dsp -o eoc-eq.wasm -json
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
// Band 1 — Low Shelf
|
||||
freq1 = hslider("Band 1 (Low Shelf)/freq1[unit:Hz]", 80, 20, 500, 0.1);
|
||||
gain1 = hslider("Band 1 (Low Shelf)/gain1[unit:dB]", 0, -12, 12, 0.1);
|
||||
q1 = hslider("Band 1 (Low Shelf)/q1", 1.0, 0.1, 10.0, 0.01);
|
||||
|
||||
// Band 2 — Low-Mid bell
|
||||
freq2 = hslider("Band 2 (Low-Mid)/freq2[unit:Hz]", 400, 100, 2000, 1.0);
|
||||
gain2 = hslider("Band 2 (Low-Mid)/gain2[unit:dB]", 0, -12, 12, 0.1);
|
||||
q2 = hslider("Band 2 (Low-Mid)/q2", 1.0, 0.1, 10.0, 0.01);
|
||||
|
||||
// Band 3 — High-Mid bell
|
||||
freq3 = hslider("Band 3 (High-Mid)/freq3[unit:Hz]", 2500, 500, 8000, 1.0);
|
||||
gain3 = hslider("Band 3 (High-Mid)/gain3[unit:dB]", 0, -12, 12, 0.1);
|
||||
q3 = hslider("Band 3 (High-Mid)/q3", 1.0, 0.1, 10.0, 0.01);
|
||||
|
||||
// Band 4 — High Shelf
|
||||
freq4 = hslider("Band 4 (High Shelf)/freq4[unit:Hz]", 8000, 2000, 20000, 10.0);
|
||||
gain4 = hslider("Band 4 (High Shelf)/gain4[unit:dB]", 0, -12, 12, 0.1);
|
||||
q4 = hslider("Band 4 (High Shelf)/q4", 1.0, 0.1, 10.0, 0.01);
|
||||
|
||||
eqChain = fi.low_shelf(gain1, freq1) :
|
||||
fi.peak_eq(gain2, freq2, q2) :
|
||||
fi.peak_eq(gain3, freq3, q3) :
|
||||
fi.high_shelf(gain4, freq4);
|
||||
|
||||
process = eqChain, eqChain;
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,149 +0,0 @@
|
|||
/**
|
||||
* eoc-master-processor.js — AudioWorklet processor for the EOC Master Bus.
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads eoc-master.wasm compiled from eoc-master.dsp.
|
||||
*
|
||||
* Parameter index order (alphabetical within group, matching eoc-master.json):
|
||||
* 0 dc_block [0, 1] (nentry)
|
||||
* 1 gain [0, 2]
|
||||
* 2 limiter_thresh [-12, 0]
|
||||
* 3 width [0, 2]
|
||||
*/
|
||||
|
||||
// Must be loaded in AudioWorkletGlobalScope after faust-worklet-processor.js.
|
||||
|
||||
class EOCMasterProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dsp = null;
|
||||
this._paramAddresses = [];
|
||||
this._blockSize = 128;
|
||||
this._sampleRate = 48000;
|
||||
}
|
||||
|
||||
async _initWasm(wasmBytes, sr) {
|
||||
this._sampleRate = sr;
|
||||
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
const memory = new WebAssembly.Memory({ initial: 32, maximum: 256 });
|
||||
|
||||
const imports = {
|
||||
env: {
|
||||
memory,
|
||||
memoryBase: 0,
|
||||
tableBase: 0,
|
||||
_abs: Math.abs,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
_ceilf: Math.ceil,
|
||||
_cosf: Math.cos,
|
||||
_expf: Math.exp,
|
||||
_floorf: Math.floor,
|
||||
_fmodf: (x, y) => x % y,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_max_f: Math.max,
|
||||
_min_f: Math.min,
|
||||
_remainderf: (x, y) => x - Math.round(x / y) * y,
|
||||
_powf: Math.pow,
|
||||
_roundf: Math.round,
|
||||
_sinf: Math.sin,
|
||||
_sqrtf: Math.sqrt,
|
||||
_tanf: Math.tan,
|
||||
_fabs: Math.abs,
|
||||
table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' }),
|
||||
},
|
||||
};
|
||||
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
const exports = instance.exports;
|
||||
|
||||
this._exports = exports;
|
||||
this._heap = new Float32Array(memory.buffer);
|
||||
this._heapi32 = new Int32Array(memory.buffer);
|
||||
this._mem = memory;
|
||||
|
||||
if (exports.createDSPInstance) {
|
||||
this._dsp = exports.createDSPInstance();
|
||||
} else if (exports.eoc_master) {
|
||||
this._dsp = exports.eoc_master();
|
||||
} else {
|
||||
console.warn('[EOCMasterProcessor] No DSP factory found; exports:', Object.keys(exports));
|
||||
return;
|
||||
}
|
||||
|
||||
exports.init(this._dsp, sr);
|
||||
this._buildParamIndex(exports, memory);
|
||||
}
|
||||
|
||||
_buildParamIndex(exports, memory) {
|
||||
if (!exports.getJSON) return;
|
||||
const ptr = exports.getJSON(this._dsp);
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
let str = '';
|
||||
let i = ptr;
|
||||
while (buf[i] !== 0) { str += String.fromCharCode(buf[i++]); }
|
||||
|
||||
let desc;
|
||||
try { desc = JSON.parse(str); } catch { return; }
|
||||
|
||||
const addresses = [];
|
||||
|
||||
function walk(items, path) {
|
||||
for (const item of items) {
|
||||
const label = item.label ?? '';
|
||||
const type = item.type ?? '';
|
||||
const addr = item.address ?? (path + '/' + label);
|
||||
if (['hslider', 'vslider', 'nentry', 'button', 'checkbox'].includes(type)) {
|
||||
addresses.push(addr);
|
||||
} else if (item.items) {
|
||||
walk(item.items, path + '/' + label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(desc.ui ?? [], '');
|
||||
this._paramAddresses = addresses;
|
||||
}
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const addr = this._paramAddresses[index];
|
||||
if (addr && this._exports.setParamValue) {
|
||||
this._exports.setParamValue(this._dsp, addr, value);
|
||||
}
|
||||
}
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const exports = this._exports;
|
||||
const mem = this._mem;
|
||||
const heap = this._heap;
|
||||
|
||||
const heapBytes = mem.buffer.byteLength;
|
||||
const inLOff = (heapBytes >> 2) - blockSize * 4 - 128;
|
||||
const inROff = inLOff + blockSize;
|
||||
const outLOff = inROff + blockSize;
|
||||
const outROff = outLOff + blockSize;
|
||||
|
||||
const i32 = this._heapi32;
|
||||
const inPtrsOff = outROff + blockSize;
|
||||
const outPtrsOff = inPtrsOff + 2;
|
||||
i32[inPtrsOff] = inLOff * 4;
|
||||
i32[inPtrsOff + 1] = inROff * 4;
|
||||
i32[outPtrsOff] = outLOff * 4;
|
||||
i32[outPtrsOff + 1] = outROff * 4;
|
||||
|
||||
exports.compute(this._dsp, blockSize, inPtrsOff * 4, outPtrsOff * 4);
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
outL[i] = heap[outLOff + i];
|
||||
outR[i] = heap[outROff + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('eoc-master-processor', EOCMasterProcessor);
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
// eoc-master.dsp — Master bus effect for the EOC rack.
|
||||
//
|
||||
// Parameters (4):
|
||||
// gain [1.0, 0-2] output gain multiplier
|
||||
// width [1.0, 0-2] stereo width: 0=mono, 1=normal, 2=extra-wide
|
||||
// limiter_thresh [-1.0, -12 to 0] brick-wall limiter threshold (dB)
|
||||
// dc_block [1] nentry: 0=off, 1=on — DC blocking filter
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
gain = hslider("gain", 1.0, 0, 2, 0.001);
|
||||
width = hslider("width", 1.0, 0, 2, 0.001);
|
||||
limiter_thresh = hslider("limiter_thresh[unit:dB]", -1.0, -12, 0, 0.1);
|
||||
dc_block_on = nentry("dc_block", 1, 0, 1, 1);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DC blocking filter (~10Hz one-pole HP)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
dcBlock(on, x) = on * fi.highpass(1, 10.0, x) + (1.0 - on) * x;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stereo width via mid-side processing
|
||||
// width = 0: mono (side removed)
|
||||
// width = 1: original stereo
|
||||
// width = 2: enhanced stereo (doubled sides)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
stereoWidth(inL, inR) = outL, outR
|
||||
with {
|
||||
w = width;
|
||||
mid = (inL + inR) * 0.5;
|
||||
side = (inL - inR) * 0.5;
|
||||
outL = mid + side * w;
|
||||
outR = mid - side * w;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brick-wall limiter: peak-following gain reduction.
|
||||
// Uses a leaky envelope follower with fast attack (~0.5ms) and slow release (~100ms).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
threshLin = ba.db2linear(limiter_thresh);
|
||||
|
||||
// Leaky-peak envelope follower: exponential release ~100ms
|
||||
// Takes a signal, outputs its peak envelope
|
||||
releaseCoeff = exp(-1.0 / (float(ma.SR) * 0.100));
|
||||
|
||||
peakEnv = abs : (+ ~ *(releaseCoeff));
|
||||
|
||||
// Gain reduction: clamp to threshold
|
||||
limiterGR(env) = threshLin / max(threshLin, env);
|
||||
|
||||
// Stereo limiter: linked L/R gain reduction from max of both peaks
|
||||
limiter(inL, inR) = inL * gr, inR * gr
|
||||
with {
|
||||
envL = peakEnv(inL);
|
||||
envR = peakEnv(inR);
|
||||
peakStereo = max(envL, envR);
|
||||
gr = limiterGR(peakStereo);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
process(inL, inR) = outL, outR
|
||||
with {
|
||||
// 1. DC block
|
||||
dcL = dcBlock(dc_block_on, inL);
|
||||
dcR = dcBlock(dc_block_on, inR);
|
||||
// 2. Gain
|
||||
gL = dcL * gain;
|
||||
gR = dcR * gain;
|
||||
// 3. Stereo width
|
||||
wL = stereoWidth(gL, gR) : _,!;
|
||||
wR = stereoWidth(gL, gR) : !,_;
|
||||
// 4. Limiter
|
||||
outL = limiter(wL, wR) : _,!;
|
||||
outR = limiter(wL, wR) : !,_;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,178 +0,0 @@
|
|||
/**
|
||||
* eoc-reverb-processor.js — AudioWorklet processor for the zita reverb
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads eoc-reverb.wasm compiled from eoc-reverb.dsp.
|
||||
*
|
||||
* 8 exposed params (alphabetical order as emitted by Faust JSON):
|
||||
* 0: decay (s)
|
||||
* 1: diffusion (0–1)
|
||||
* 2: hi_damp (0–1)
|
||||
* 3: lo_damp (0–1)
|
||||
* 4: mix (0–1)
|
||||
* 5: predelay (ms)
|
||||
* 6: size (0–1)
|
||||
* 7: width (0–1)
|
||||
*
|
||||
* Note: mod_rate is declared in the DSP file but has no effect in this version
|
||||
* (zita_rev1_stereo does not expose modulation rate externally).
|
||||
*/
|
||||
|
||||
// Runs in AudioWorkletGlobalScope — faust-worklet-processor.js must be loaded first.
|
||||
|
||||
class EOCReverbProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dsp = null;
|
||||
this._exports = null;
|
||||
this._heap = null;
|
||||
this._heapi32 = null;
|
||||
this._mem = null;
|
||||
this._paramAddresses = [];
|
||||
this._inputL = null;
|
||||
this._inputR = null;
|
||||
this._sampleRate = 48000;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FaustWorkletProcessor overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sr) {
|
||||
this._sampleRate = sr;
|
||||
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
// Reverb needs more memory due to large delay lines in zita
|
||||
const memory = new WebAssembly.Memory({ initial: 64, maximum: 512 });
|
||||
|
||||
const imports = {
|
||||
env: {
|
||||
memory,
|
||||
memoryBase: 0,
|
||||
tableBase: 0,
|
||||
_abs: Math.abs,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
_ceilf: Math.ceil,
|
||||
_cosf: Math.cos,
|
||||
_expf: Math.exp,
|
||||
_floorf: Math.floor,
|
||||
_fmodf: (x, y) => x % y,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_max_f: Math.max,
|
||||
_min_f: Math.min,
|
||||
_remainderf: (x, y) => x - Math.round(x / y) * y,
|
||||
_powf: Math.pow,
|
||||
_roundf: Math.round,
|
||||
_sinf: Math.sin,
|
||||
_sqrtf: Math.sqrt,
|
||||
_tanf: Math.tan,
|
||||
_fabs: Math.abs,
|
||||
table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' }),
|
||||
},
|
||||
};
|
||||
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
this._exports = instance.exports;
|
||||
this._mem = memory;
|
||||
this._heap = new Float32Array(memory.buffer);
|
||||
this._heapi32 = new Int32Array(memory.buffer);
|
||||
|
||||
const exps = this._exports;
|
||||
|
||||
if (exps.createDSPInstance) {
|
||||
this._dsp = exps.createDSPInstance();
|
||||
} else if (exps.eoc_reverb) {
|
||||
this._dsp = exps.eoc_reverb();
|
||||
} else {
|
||||
const fnKeys = Object.keys(exps).filter(k => typeof exps[k] === 'function');
|
||||
console.warn('[EOCReverbProcessor] No DSP constructor found; exports:', fnKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
exps.init(this._dsp, sr);
|
||||
this._buildParamIndex(exps, memory);
|
||||
}
|
||||
|
||||
_buildParamIndex(exports, memory) {
|
||||
if (!exports.getJSON) return;
|
||||
const ptr = exports.getJSON(this._dsp);
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
let str = '';
|
||||
let i = ptr;
|
||||
while (buf[i] !== 0) { str += String.fromCharCode(buf[i++]); }
|
||||
|
||||
let desc;
|
||||
try { desc = JSON.parse(str); } catch { return; }
|
||||
|
||||
const addresses = [];
|
||||
|
||||
function walk(items) {
|
||||
for (const item of items) {
|
||||
const type = item.type ?? '';
|
||||
if (['hslider', 'vslider', 'nentry'].includes(type)) {
|
||||
addresses.push(item.address ?? '');
|
||||
} else if (item.items) {
|
||||
walk(item.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(desc.ui ?? []);
|
||||
this._paramAddresses = addresses;
|
||||
}
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const addr = this._paramAddresses[index];
|
||||
if (addr !== undefined && this._exports.setParamValue) {
|
||||
this._exports.setParamValue(this._dsp, addr, value);
|
||||
}
|
||||
}
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
|
||||
const exps = this._exports;
|
||||
const mem = this._mem;
|
||||
const heap = this._heap;
|
||||
const i32 = this._heapi32;
|
||||
|
||||
const heapWords = mem.buffer.byteLength >> 2;
|
||||
const inLOff = heapWords - blockSize * 6 - 32;
|
||||
const inROff = inLOff + blockSize;
|
||||
const outLOff = inROff + blockSize;
|
||||
const outROff = outLOff + blockSize;
|
||||
const inPtrsOff = outROff + blockSize;
|
||||
const outPtrsOff = inPtrsOff + 2;
|
||||
|
||||
i32[inPtrsOff] = inLOff * 4;
|
||||
i32[inPtrsOff + 1] = inROff * 4;
|
||||
i32[outPtrsOff] = outLOff * 4;
|
||||
i32[outPtrsOff + 1] = outROff * 4;
|
||||
|
||||
const srcL = this._inputL;
|
||||
const srcR = this._inputR;
|
||||
if (srcL) for (let i = 0; i < blockSize; i++) heap[inLOff + i] = srcL[i];
|
||||
if (srcR) for (let i = 0; i < blockSize; i++) heap[inROff + i] = srcR[i];
|
||||
|
||||
exps.compute(this._dsp, blockSize, inPtrsOff * 4, outPtrsOff * 4);
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
outL[i] = heap[outLOff + i];
|
||||
outR[i] = heap[outROff + i];
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs, outputs, params) {
|
||||
const inp = inputs[0];
|
||||
this._inputL = inp?.[0] ?? null;
|
||||
this._inputR = inp?.[1] ?? inp?.[0] ?? null;
|
||||
return super.process(inputs, outputs, params);
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('eoc-reverb-processor', EOCReverbProcessor);
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
// eoc-reverb.dsp — Stereo reverb for MEMLNaut EOC chain
|
||||
//
|
||||
// 9 params: predelay, size, diffusion, hi_damp, lo_damp, decay, mix, width, mod_rate
|
||||
//
|
||||
// Uses re.zita_rev1_stereo for the core reverb algorithm.
|
||||
//
|
||||
// Compile:
|
||||
// faust -lang wasm -cn eoc_reverb -json eoc-reverb.dsp -o eoc-reverb.wasm
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
predelay = hslider("predelay[unit:ms]", 0.0, 0.0, 100.0, 0.5);
|
||||
size = hslider("size", 0.5, 0.0, 1.0, 0.001);
|
||||
diffusion= hslider("diffusion", 0.7, 0.0, 1.0, 0.001);
|
||||
hi_damp = hslider("hi_damp", 0.5, 0.0, 1.0, 0.001);
|
||||
lo_damp = hslider("lo_damp", 0.0, 0.0, 1.0, 0.001);
|
||||
decay = hslider("decay[unit:s]", 3.0, 0.1, 20.0, 0.1);
|
||||
mix = hslider("mix", 0.2, 0.0, 1.0, 0.001);
|
||||
width = hslider("width", 0.8, 0.0, 1.0, 0.001);
|
||||
mod_rate = hslider("mod_rate[unit:Hz]", 0.5, 0.0, 5.0, 0.01);
|
||||
|
||||
// Pre-delay in samples (minimum 1)
|
||||
pdSamps = max(1, int(predelay / 1000.0 * ma.SR));
|
||||
|
||||
// Frequency crossovers for zita
|
||||
f1 = 200.0 + lo_damp * 1800.0;
|
||||
f2 = 20000.0 - hi_damp * 18000.0;
|
||||
|
||||
// Reverb decay times scaled by size
|
||||
t60dc = decay * (1.0 + size * 0.5);
|
||||
t60m = decay;
|
||||
|
||||
// Pre-delay: single channel
|
||||
predelayLine = _ @ pdSamps;
|
||||
|
||||
// Width processing: M/S encode-scale-decode
|
||||
// L R → L' R' where side channels scaled by width
|
||||
widthL(l, r) = (l + r) * 0.5 + (l - r) * 0.5 * width;
|
||||
widthR(l, r) = (l + r) * 0.5 - (l - r) * 0.5 * width;
|
||||
|
||||
// Wet signal through predelay, diffusion scale, and zita reverb
|
||||
wetL(inL, inR) = (re.zita_rev1_stereo(0.0, f1, f2, t60dc, t60m, 192000.0,
|
||||
inL * diffusion @ pdSamps,
|
||||
inR * diffusion @ pdSamps)) : widthL;
|
||||
|
||||
wetR(inL, inR) = (re.zita_rev1_stereo(0.0, f1, f2, t60dc, t60m, 192000.0,
|
||||
inL * diffusion @ pdSamps,
|
||||
inR * diffusion @ pdSamps)) : widthR;
|
||||
|
||||
process(inL, inR) =
|
||||
inL * (1.0 - mix) + wetL(inL, inR) * mix,
|
||||
inR * (1.0 - mix) + wetR(inL, inR) * mix;
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,149 +0,0 @@
|
|||
/**
|
||||
* eoc-saturation-processor.js — AudioWorklet processor for the EOC Saturation.
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads eoc-saturation.wasm compiled from eoc-saturation.dsp.
|
||||
*
|
||||
* Parameter index order (alphabetical within group, matching eoc-saturation.json):
|
||||
* 0 character [0, 1]
|
||||
* 1 drive [0, 1]
|
||||
* 2 mix [0, 1]
|
||||
* 3 tone [0, 1]
|
||||
*/
|
||||
|
||||
// Must be loaded in AudioWorkletGlobalScope after faust-worklet-processor.js.
|
||||
|
||||
class EOCSaturationProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dsp = null;
|
||||
this._paramAddresses = [];
|
||||
this._blockSize = 128;
|
||||
this._sampleRate = 48000;
|
||||
}
|
||||
|
||||
async _initWasm(wasmBytes, sr) {
|
||||
this._sampleRate = sr;
|
||||
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
const memory = new WebAssembly.Memory({ initial: 32, maximum: 256 });
|
||||
|
||||
const imports = {
|
||||
env: {
|
||||
memory,
|
||||
memoryBase: 0,
|
||||
tableBase: 0,
|
||||
_abs: Math.abs,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
_ceilf: Math.ceil,
|
||||
_cosf: Math.cos,
|
||||
_expf: Math.exp,
|
||||
_floorf: Math.floor,
|
||||
_fmodf: (x, y) => x % y,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_max_f: Math.max,
|
||||
_min_f: Math.min,
|
||||
_remainderf: (x, y) => x - Math.round(x / y) * y,
|
||||
_powf: Math.pow,
|
||||
_roundf: Math.round,
|
||||
_sinf: Math.sin,
|
||||
_sqrtf: Math.sqrt,
|
||||
_tanf: Math.tan,
|
||||
_fabs: Math.abs,
|
||||
table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' }),
|
||||
},
|
||||
};
|
||||
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
const exports = instance.exports;
|
||||
|
||||
this._exports = exports;
|
||||
this._heap = new Float32Array(memory.buffer);
|
||||
this._heapi32 = new Int32Array(memory.buffer);
|
||||
this._mem = memory;
|
||||
|
||||
if (exports.createDSPInstance) {
|
||||
this._dsp = exports.createDSPInstance();
|
||||
} else if (exports.eoc_saturation) {
|
||||
this._dsp = exports.eoc_saturation();
|
||||
} else {
|
||||
console.warn('[EOCSaturationProcessor] No DSP factory found; exports:', Object.keys(exports));
|
||||
return;
|
||||
}
|
||||
|
||||
exports.init(this._dsp, sr);
|
||||
this._buildParamIndex(exports, memory);
|
||||
}
|
||||
|
||||
_buildParamIndex(exports, memory) {
|
||||
if (!exports.getJSON) return;
|
||||
const ptr = exports.getJSON(this._dsp);
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
let str = '';
|
||||
let i = ptr;
|
||||
while (buf[i] !== 0) { str += String.fromCharCode(buf[i++]); }
|
||||
|
||||
let desc;
|
||||
try { desc = JSON.parse(str); } catch { return; }
|
||||
|
||||
const addresses = [];
|
||||
|
||||
function walk(items, path) {
|
||||
for (const item of items) {
|
||||
const label = item.label ?? '';
|
||||
const type = item.type ?? '';
|
||||
const addr = item.address ?? (path + '/' + label);
|
||||
if (['hslider', 'vslider', 'nentry', 'button', 'checkbox'].includes(type)) {
|
||||
addresses.push(addr);
|
||||
} else if (item.items) {
|
||||
walk(item.items, path + '/' + label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(desc.ui ?? [], '');
|
||||
this._paramAddresses = addresses;
|
||||
}
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const addr = this._paramAddresses[index];
|
||||
if (addr && this._exports.setParamValue) {
|
||||
this._exports.setParamValue(this._dsp, addr, value);
|
||||
}
|
||||
}
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const exports = this._exports;
|
||||
const mem = this._mem;
|
||||
const heap = this._heap;
|
||||
|
||||
const heapBytes = mem.buffer.byteLength;
|
||||
const inLOff = (heapBytes >> 2) - blockSize * 4 - 128;
|
||||
const inROff = inLOff + blockSize;
|
||||
const outLOff = inROff + blockSize;
|
||||
const outROff = outLOff + blockSize;
|
||||
|
||||
const i32 = this._heapi32;
|
||||
const inPtrsOff = outROff + blockSize;
|
||||
const outPtrsOff = inPtrsOff + 2;
|
||||
i32[inPtrsOff] = inLOff * 4;
|
||||
i32[inPtrsOff + 1] = inROff * 4;
|
||||
i32[outPtrsOff] = outLOff * 4;
|
||||
i32[outPtrsOff + 1] = outROff * 4;
|
||||
|
||||
exports.compute(this._dsp, blockSize, inPtrsOff * 4, outPtrsOff * 4);
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
outL[i] = heap[outLOff + i];
|
||||
outR[i] = heap[outROff + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('eoc-saturation-processor', EOCSaturationProcessor);
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
// eoc-saturation.dsp — Stereo saturation effect for the EOC rack.
|
||||
//
|
||||
// Parameters (4):
|
||||
// drive [0.0, 0-1] 0=clean, 1=full drive
|
||||
// character [0.0, 0-1] 0=soft-clip/tanh, 0.5=tape/asymmetric, 1=hard-clip
|
||||
// tone [0.5, 0-1] post-saturation tone: 0=dark (LP), 1=bright (HP blend)
|
||||
// mix [1.0, 0-1] dry/wet for parallel saturation
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
drive = hslider("drive", 0.0, 0, 1, 0.001);
|
||||
character = hslider("character", 0.0, 0, 1, 0.001);
|
||||
tone = hslider("tone", 0.5, 0, 1, 0.001);
|
||||
mix = hslider("mix", 1.0, 0, 1, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saturation shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
driveGain = 1.0 + drive * 15.0; // 1x to 16x gain before clipping
|
||||
|
||||
// Soft clip: tanh
|
||||
softClip(x) = ma.tanh(x);
|
||||
|
||||
// Tape: asymmetric waveshaper — slightly harder clipping on positive peaks
|
||||
// Blends tanh with a gentle second-harmonic bias
|
||||
tapeClip(x) = softClip(x * 1.2) * 0.55 + softClip(x) * 0.45 + x * x * 0.04 * (1.0 - softClip(abs(x)));
|
||||
|
||||
// Hard clip: simple saturate
|
||||
hardClip(x) = max(-1.0, min(1.0, x));
|
||||
|
||||
// Character crossfade between the three shapes:
|
||||
// c=0.0 → soft (tanh)
|
||||
// c=0.5 → tape (asymmetric)
|
||||
// c=1.0 → hard clip
|
||||
saturate(c, x) =
|
||||
softClip(x) * (max(0.0, 1.0 - c * 2.0)) +
|
||||
tapeClip(x) * (1.0 - abs(c - 0.5) * 2.0) +
|
||||
hardClip(x) * max(0.0, (c - 0.5) * 2.0);
|
||||
|
||||
// Apply drive, saturate, and normalise output level
|
||||
processChannel(x) = saturate(character, x * driveGain) / max(0.001, sqrt(driveGain));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tone control: LP/HP blend via one-pole filters
|
||||
//
|
||||
// tone=0.0 → dark (400Hz LP)
|
||||
// tone=0.5 → flat (passthrough)
|
||||
// tone=1.0 → bright (8kHz HP blend)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
toneFreqLP = 400.0 + tone * 19600.0; // 400Hz to 20kHz (fully open at tone=1)
|
||||
toneFreqHP = 200.0 + tone * 7800.0; // 200Hz to 8kHz
|
||||
|
||||
applyTone(x) = fi.lowpass(1, toneFreqLP, x);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main process: stereo saturation with parallel dry/wet mix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
process(inL, inR) = outL, outR
|
||||
with {
|
||||
wetL = applyTone(processChannel(inL));
|
||||
wetR = applyTone(processChannel(inR));
|
||||
outL = inL * (1.0 - mix) + wetL * mix;
|
||||
outR = inR * (1.0 - mix) + wetR * mix;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,187 +0,0 @@
|
|||
/**
|
||||
* faust-worklet-processor.js — Base AudioWorklet processor for Faust WASM engines
|
||||
*
|
||||
* This file must be loaded via audioContext.audioWorklet.addModule() before
|
||||
* creating a FaustWorkletNode. It runs in AudioWorkletGlobalScope.
|
||||
*
|
||||
* Each Faust engine subclasses FaustWorkletProcessor and overrides:
|
||||
* - static get processorName() — returns the unique processor name string
|
||||
* - _initWasm(wasmBytes, sampleRate) — initialises the Faust WASM instance
|
||||
* - _renderBlock(outputL, outputR, blockSize) — fills output buffers per block
|
||||
*
|
||||
* Message protocol (port.postMessage from main thread):
|
||||
* { type: 'init', wasmBytes: ArrayBuffer, sampleRate: number }
|
||||
* { type: 'setParam', index: number, value: number }
|
||||
* { type: 'noteOn', freq: number, vel: number }
|
||||
* { type: 'noteOff', freq: number }
|
||||
*
|
||||
* Replies from worklet to main thread:
|
||||
* { type: 'ready' }
|
||||
* { type: 'error', message: string }
|
||||
*/
|
||||
|
||||
class FaustWorkletProcessor extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._ready = false;
|
||||
this._paramValues = {}; // index → current value (raw Faust units)
|
||||
|
||||
// Messages that arrive before init() finishes are buffered here and
|
||||
// drained by _drainPendingMessages() in order once the subclass sets
|
||||
// _ready = true. Subclasses with extended _handleMessage() should call
|
||||
// _queueIfNotReady(msg) as their first line to participate.
|
||||
this._pendingMessages = [];
|
||||
|
||||
this.port.onmessage = (e) => this._handleMessage(e.data);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-ready message buffering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if the message was queued (processor not ready yet).
|
||||
* Subclasses should call this at the top of their _handleMessage override
|
||||
* after the 'init' special case, before touching _dspInst.
|
||||
*/
|
||||
_queueIfNotReady(msg) {
|
||||
if (this._ready) return false;
|
||||
if (!msg || msg.type === 'init') return false;
|
||||
this._pendingMessages.push(msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drain all buffered pre-ready messages, in arrival order. */
|
||||
_drainPendingMessages() {
|
||||
if (this._pendingMessages.length === 0) return;
|
||||
const pending = this._pendingMessages;
|
||||
this._pendingMessages = [];
|
||||
for (const msg of pending) {
|
||||
try { this._handleMessage(msg); }
|
||||
catch (err) {
|
||||
this.port.postMessage({
|
||||
type: 'error',
|
||||
message: 'drain failed: ' + String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message handler (runs in worklet thread)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_handleMessage(msg) {
|
||||
if (!msg || !msg.type) return;
|
||||
|
||||
if (msg.type === 'init') {
|
||||
this._initWasm(msg.wasmBytes, msg.sampleRate || sampleRate)
|
||||
.then(() => {
|
||||
this._ready = true;
|
||||
this.port.postMessage({ type: 'ready' });
|
||||
this._drainPendingMessages();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.port.postMessage({ type: 'error', message: String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._queueIfNotReady(msg)) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'setParam':
|
||||
this._paramValues[msg.index] = msg.value;
|
||||
this._onSetParam(msg.index, msg.value);
|
||||
break;
|
||||
|
||||
case 'noteOn':
|
||||
this._onNoteOn(msg.freq, msg.vel);
|
||||
break;
|
||||
|
||||
case 'noteOff':
|
||||
this._onNoteOff(msg.freq);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[FaustWorkletProcessor] Unknown message type:', msg.type);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AudioWorkletProcessor interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
process(_inputs, outputs, _params) {
|
||||
if (!this._ready) return true;
|
||||
|
||||
const out = outputs[0];
|
||||
const blockSize = out[0]?.length ?? 128;
|
||||
const outL = out[0] ?? new Float32Array(blockSize);
|
||||
const outR = out[1] ?? new Float32Array(blockSize);
|
||||
|
||||
this._renderBlock(outL, outR, blockSize);
|
||||
|
||||
return true; // keep processor alive
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subclass API — override these in concrete engine processors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialise the WASM module. Called once with the raw bytes and sample rate.
|
||||
* Must return a Promise that resolves when the engine is ready to render.
|
||||
*
|
||||
* @param {ArrayBuffer} wasmBytes
|
||||
* @param {number} sampleRate
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _initWasm(_wasmBytes, _sampleRate) {
|
||||
// Default no-op: subclasses that don't use WASM can override _renderBlock only.
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a parameter value changes. Override to forward to DSP.
|
||||
* @param {number} index Param index (matches paramMeta order)
|
||||
* @param {number} value Raw value in Faust units
|
||||
*/
|
||||
_onSetParam(_index, _value) {}
|
||||
|
||||
/**
|
||||
* Called on note-on.
|
||||
* @param {number} freq Hz
|
||||
* @param {number} vel 0–1
|
||||
*/
|
||||
_onNoteOn(_freq, _vel) {}
|
||||
|
||||
/**
|
||||
* Called on note-off.
|
||||
* @param {number} freq Hz
|
||||
*/
|
||||
_onNoteOff(_freq) {}
|
||||
|
||||
/**
|
||||
* Fill a single audio block. Called from process() every 128 samples.
|
||||
* Both arrays are pre-allocated Float32Arrays of length blockSize.
|
||||
*
|
||||
* @param {Float32Array} outL Left channel output buffer (write to this)
|
||||
* @param {Float32Array} outR Right channel output buffer (write to this)
|
||||
* @param {number} blockSize
|
||||
*/
|
||||
_renderBlock(_outL, _outR, _blockSize) {
|
||||
// Default: silence — override in subclass
|
||||
}
|
||||
}
|
||||
|
||||
// Explicitly attach to globalThis so subsequent addModule() scripts can see
|
||||
// it. Class declarations at the top of a classic script are lexically scoped
|
||||
// to that script's evaluation context and do NOT propagate across separate
|
||||
// addModule() calls; an explicit property assignment is required for the
|
||||
// cross-script reference to resolve.
|
||||
globalThis.FaustWorkletProcessor = FaustWorkletProcessor;
|
||||
|
||||
// Note: registerProcessor() is called by each concrete engine file, not here,
|
||||
// because each engine has its own processor name.
|
||||
// Subclass files should end with:
|
||||
// registerProcessor('my-engine-processor', MyEngineProcessor);
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
/**
|
||||
* fm-matrix-processor.js — AudioWorklet processor for the FM Matrix synth engine
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads fm-matrix.wasm compiled from fm-matrix.dsp.
|
||||
*
|
||||
* Parameter addresses are built from the Faust JSON descriptor at init time.
|
||||
* Index→address mapping follows the order returned by faustJsonToParamMeta()
|
||||
* (alphabetical within groups, groups in declaration order).
|
||||
*
|
||||
* Hidden params (not in NISPS 55-param list):
|
||||
* /fm-matrix/Master/freq — set by noteOn
|
||||
* /fm-matrix/Master/gate — set by noteOn/noteOff
|
||||
* /fm-matrix/Master/_vel — set by noteOn
|
||||
*/
|
||||
|
||||
// Must be imported in AudioWorkletGlobalScope — include faust-worklet-processor.js
|
||||
// via audioCtx.audioWorklet.addModule() before this file.
|
||||
|
||||
class FMMatrixProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dsp = null;
|
||||
this._paramAddresses = []; // ordered by NISPS index (from JSON)
|
||||
this._hiddenAddresses = {}; // freq, gate, _vel
|
||||
this._blockSize = 128;
|
||||
this._sampleRate = 48000;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FaustWorkletProcessor overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sr) {
|
||||
this._sampleRate = sr;
|
||||
|
||||
// The Faust -lang wasm output exports a single factory function
|
||||
// named by the -cn flag (fm_matrix).
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
const memory = new WebAssembly.Memory({ initial: 32, maximum: 256 });
|
||||
|
||||
const imports = {
|
||||
env: {
|
||||
memory,
|
||||
memoryBase: 0,
|
||||
tableBase: 0,
|
||||
_abs: Math.abs,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
_ceilf: Math.ceil,
|
||||
_cosf: Math.cos,
|
||||
_expf: Math.exp,
|
||||
_floorf: Math.floor,
|
||||
_fmodf: (x, y) => x % y,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_max_f: Math.max,
|
||||
_min_f: Math.min,
|
||||
_remainderf: (x, y) => x - Math.round(x / y) * y,
|
||||
_powf: Math.pow,
|
||||
_roundf: Math.round,
|
||||
_sinf: Math.sin,
|
||||
_sqrtf: Math.sqrt,
|
||||
_tanf: Math.tan,
|
||||
_fabs: Math.abs,
|
||||
table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' }),
|
||||
},
|
||||
};
|
||||
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
const exports = instance.exports;
|
||||
|
||||
// Faust -lang wasm exports: getNumInputs, getNumOutputs, init,
|
||||
// instanceInit, getSampleRate, compute, setParamValue, getParamValue,
|
||||
// getJSON (pointer to null-terminated JSON string in memory)
|
||||
this._exports = exports;
|
||||
this._heap = new Float32Array(memory.buffer);
|
||||
this._heapi32 = new Int32Array(memory.buffer);
|
||||
this._mem = memory;
|
||||
|
||||
// Allocate DSP instance (Faust C++ new equivalent)
|
||||
if (exports.createDSPInstance) {
|
||||
this._dsp = exports.createDSPInstance();
|
||||
} else if (exports.fm_matrix) {
|
||||
this._dsp = exports.fm_matrix();
|
||||
} else {
|
||||
// fallback: look for any exported constructor-like function
|
||||
const keys = Object.keys(exports).filter(k => typeof exports[k] === 'function');
|
||||
console.warn('[FMMatrixProcessor] No createDSPInstance found; exports:', keys);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialise at sample rate
|
||||
exports.init(this._dsp, sr);
|
||||
|
||||
// Parse embedded JSON to build param address list
|
||||
this._buildParamIndex(exports, memory);
|
||||
}
|
||||
|
||||
_buildParamIndex(exports, memory) {
|
||||
// getJSON() returns a pointer to a JSON string in WASM memory
|
||||
if (!exports.getJSON) return;
|
||||
const ptr = exports.getJSON(this._dsp);
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
let str = '';
|
||||
let i = ptr;
|
||||
while (buf[i] !== 0) { str += String.fromCharCode(buf[i++]); }
|
||||
|
||||
let desc;
|
||||
try { desc = JSON.parse(str); } catch { return; }
|
||||
|
||||
const HIDDEN = new Set(['freq', 'gate', '_vel']);
|
||||
const addresses = [];
|
||||
|
||||
function walk(items, path) {
|
||||
for (const item of items) {
|
||||
const label = item.label ?? '';
|
||||
const type = item.type ?? '';
|
||||
const addr = item.address ?? (path + '/' + label);
|
||||
if (['hslider', 'vslider', 'nentry', 'button', 'checkbox'].includes(type)) {
|
||||
if (HIDDEN.has(label)) {
|
||||
// store hidden separately
|
||||
addresses._hidden = addresses._hidden || {};
|
||||
addresses._hidden[label] = addr;
|
||||
} else {
|
||||
addresses.push(addr);
|
||||
}
|
||||
} else if (item.items) {
|
||||
walk(item.items, path + '/' + label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(desc.ui ?? [], '');
|
||||
this._paramAddresses = addresses;
|
||||
this._hiddenAddresses = addresses._hidden ?? {};
|
||||
}
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const addr = this._paramAddresses[index];
|
||||
if (addr && this._exports.setParamValue) {
|
||||
this._exports.setParamValue(this._dsp, addr, value);
|
||||
}
|
||||
}
|
||||
|
||||
_onNoteOn(freq, vel) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const set = (label, v) => {
|
||||
const addr = this._hiddenAddresses[label];
|
||||
if (addr) this._exports.setParamValue?.(this._dsp, addr, v);
|
||||
};
|
||||
set('freq', freq);
|
||||
set('_vel', vel);
|
||||
set('gate', 1);
|
||||
}
|
||||
|
||||
_onNoteOff(_freq) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const addr = this._hiddenAddresses['gate'];
|
||||
if (addr) this._exports.setParamValue?.(this._dsp, addr, 0);
|
||||
}
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._exports || !this._dsp) return;
|
||||
const exports = this._exports;
|
||||
const heap = this._heap;
|
||||
const mem = this._mem;
|
||||
|
||||
// Allocate output buffers in WASM heap (crude bump allocator using high addresses)
|
||||
const heapBytes = mem.buffer.byteLength;
|
||||
const outLOff = (heapBytes >> 2) - blockSize * 2 - 64;
|
||||
const outROff = outLOff + blockSize;
|
||||
|
||||
// Allocate pointer arrays for outputs
|
||||
const ptrSize = 4; // 32-bit pointers in wasm32
|
||||
const outPtrsOff = outROff + blockSize;
|
||||
const i32 = this._heapi32;
|
||||
i32[outPtrsOff] = outLOff * 4; // byte offset in memory
|
||||
i32[outPtrsOff + 1] = outROff * 4;
|
||||
|
||||
exports.compute(this._dsp, blockSize, 0 /* no inputs */, outPtrsOff * 4);
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
outL[i] = heap[outLOff + i];
|
||||
outR[i] = heap[outROff + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('fm-matrix-processor', FMMatrixProcessor);
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
// fm-matrix.dsp — Full 56-parameter 4-operator FM synthesizer with continuous routing matrix
|
||||
//
|
||||
// Design: 4 operators with fully continuous N×N cross-modulation matrix.
|
||||
// No fixed algorithm slots — the algorithm emerges from modulation index values.
|
||||
// When all routing matrix indices are near 0: additive synthesis.
|
||||
// As indices grow: increasingly complex FM timbres emerge.
|
||||
//
|
||||
// Parameter ordering (56 total, maps to MLP output indices 0–55):
|
||||
// 0–23: Operator Core × 4 (ratio, level, attack, decay, sustain, release)
|
||||
// 24–35: Cross-Modulation Matrix (12 directed pairs)
|
||||
// 36–39: Self-Feedback × 4
|
||||
// 40–47: Global Modulation (LFO, pitch env, velocity)
|
||||
// 48–55: Master (level, vel sens, glide, fine tune, waveform blend, stereo spread,
|
||||
// output saturation, output HP)
|
||||
//
|
||||
// Hidden params (not in the 56-param NISPS list):
|
||||
// freq — set by noteOn (Hz)
|
||||
// gate — set by noteOn/noteOff (0/1)
|
||||
// _vel — set by noteOn (velocity 0–1)
|
||||
//
|
||||
// FM implementation: each operator phase-modulates others via a 1-sample delayed
|
||||
// feedback bus. The 4 operator outputs are collected into a 4-channel bus and
|
||||
// looped via ~. This is the standard Faust FM pattern.
|
||||
//
|
||||
// Build:
|
||||
// nix-shell -p faust --run \
|
||||
// "faust -lang wasm -cn fm_matrix fm-matrix.dsp -o fm-matrix.wasm"
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hidden control params (driven by noteOn/noteOff, not NISPS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
freq = hslider("Master/freq[hidden:1][unit:Hz]", 220, 20, 4000, 0.01);
|
||||
gate = button("Master/gate[hidden:1]");
|
||||
vel = hslider("Master/_vel[hidden:1]", 0.7, 0.0, 1.0, 0.001) : si.smoo;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1 — Operator Core × 4 (params 0–23)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
op1_ratio = hslider("Operators/Op 1/op1_ratio", 1.0, 0.125, 16.0, 0.001) : si.smoo;
|
||||
op1_level = hslider("Operators/Op 1/op1_level", 0.8, 0.0, 1.0, 0.001) : si.smoo;
|
||||
op1_attack = hslider("Operators/Op 1/op1_attack", 0.01, 0.001, 5.0, 0.001);
|
||||
op1_decay = hslider("Operators/Op 1/op1_decay", 0.3, 0.001, 10.0, 0.001);
|
||||
op1_sustain = hslider("Operators/Op 1/op1_sustain", 0.7, 0.0, 1.0, 0.001);
|
||||
op1_release = hslider("Operators/Op 1/op1_release", 0.5, 0.01, 10.0, 0.001);
|
||||
|
||||
op2_ratio = hslider("Operators/Op 2/op2_ratio", 2.0, 0.125, 16.0, 0.001) : si.smoo;
|
||||
op2_level = hslider("Operators/Op 2/op2_level", 0.6, 0.0, 1.0, 0.001) : si.smoo;
|
||||
op2_attack = hslider("Operators/Op 2/op2_attack", 0.01, 0.001, 5.0, 0.001);
|
||||
op2_decay = hslider("Operators/Op 2/op2_decay", 0.3, 0.001, 10.0, 0.001);
|
||||
op2_sustain = hslider("Operators/Op 2/op2_sustain", 0.7, 0.0, 1.0, 0.001);
|
||||
op2_release = hslider("Operators/Op 2/op2_release", 0.5, 0.01, 10.0, 0.001);
|
||||
|
||||
op3_ratio = hslider("Operators/Op 3/op3_ratio", 3.0, 0.125, 16.0, 0.001) : si.smoo;
|
||||
op3_level = hslider("Operators/Op 3/op3_level", 0.4, 0.0, 1.0, 0.001) : si.smoo;
|
||||
op3_attack = hslider("Operators/Op 3/op3_attack", 0.01, 0.001, 5.0, 0.001);
|
||||
op3_decay = hslider("Operators/Op 3/op3_decay", 0.3, 0.001, 10.0, 0.001);
|
||||
op3_sustain = hslider("Operators/Op 3/op3_sustain", 0.7, 0.0, 1.0, 0.001);
|
||||
op3_release = hslider("Operators/Op 3/op3_release", 0.5, 0.01, 10.0, 0.001);
|
||||
|
||||
op4_ratio = hslider("Operators/Op 4/op4_ratio", 0.5, 0.125, 16.0, 0.001) : si.smoo;
|
||||
op4_level = hslider("Operators/Op 4/op4_level", 0.3, 0.0, 1.0, 0.001) : si.smoo;
|
||||
op4_attack = hslider("Operators/Op 4/op4_attack", 0.01, 0.001, 5.0, 0.001);
|
||||
op4_decay = hslider("Operators/Op 4/op4_decay", 0.3, 0.001, 10.0, 0.001);
|
||||
op4_sustain = hslider("Operators/Op 4/op4_sustain", 0.7, 0.0, 1.0, 0.001);
|
||||
op4_release = hslider("Operators/Op 4/op4_release", 0.5, 0.01, 10.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2 — Cross-Modulation Matrix (params 24–35)
|
||||
// mXY = op X modulates op Y (X's output is added to Y's phase)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
m12 = hslider("Matrix/m12", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m13 = hslider("Matrix/m13", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m14 = hslider("Matrix/m14", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m21 = hslider("Matrix/m21", 1.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m23 = hslider("Matrix/m23", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m24 = hslider("Matrix/m24", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m31 = hslider("Matrix/m31", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m32 = hslider("Matrix/m32", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m34 = hslider("Matrix/m34", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m41 = hslider("Matrix/m41", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m42 = hslider("Matrix/m42", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
m43 = hslider("Matrix/m43", 0.0, 0.0, 10.0, 0.001) : si.smoo;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3 — Self-Feedback × 4 (params 36–39)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fb1 = hslider("Feedback/fb1", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
fb2 = hslider("Feedback/fb2", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
fb3 = hslider("Feedback/fb3", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
fb4 = hslider("Feedback/fb4", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 4 — Global Modulation (params 40–47)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
lfo_rate = hslider("Global/lfo_rate", 5.0, 0.01, 20.0, 0.001);
|
||||
lfo_depth = hslider("Global/lfo_depth", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
lfo_pitch = hslider("Global/lfo_pitch", 0.5, 0.0, 1.0, 0.001) : si.smoo;
|
||||
lfo_levels = hslider("Global/lfo_levels", 0.5, 0.0, 1.0, 0.001) : si.smoo;
|
||||
lfo_waveform = hslider("Global/lfo_waveform", 0.0, 0.0, 1.0, 0.001);
|
||||
pitch_env_amount = hslider("Global/pitch_env_amount", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
pitch_env_decay = hslider("Global/pitch_env_decay", 0.1, 0.001, 2.0, 0.001);
|
||||
vel_index_scale = hslider("Global/vel_index_scale", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 5 — Master (params 48–55)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
master_level = hslider("Master/level", 0.7, 0.0, 1.0, 0.001) : si.smoo;
|
||||
vel_sens = hslider("Master/vel_sens", 0.5, 0.0, 1.0, 0.001);
|
||||
pitch_glide = hslider("Master/pitch_glide", 0.0, 0.0, 10.0, 0.001);
|
||||
fine_tune = hslider("Master/fine_tune[unit:cents]", 0.0, -50.0, 50.0, 0.01);
|
||||
waveform_blend = hslider("Master/waveform_blend", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
stereo_spread = hslider("Master/stereo_spread", 0.1, 0.0, 1.0, 0.001) : si.smoo;
|
||||
output_saturation = hslider("Master/output_saturation", 0.0, 0.0, 1.0, 0.001) : si.smoo;
|
||||
output_hp = hslider("Master/output_hp[unit:Hz]", 20.0, 20.0, 200.0, 0.1);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Cents → frequency ratio
|
||||
cent2ratio(c) = pow(2.0, c / 1200.0);
|
||||
|
||||
// Base frequency with fine-tune
|
||||
base_freq = freq * cent2ratio(fine_tune);
|
||||
|
||||
// Velocity gain: blend between 1.0 (no velocity) and actual velocity
|
||||
vel_gain = (1.0 - vel_sens) + vel_sens * vel;
|
||||
|
||||
// Velocity-scaled FM index multiplier
|
||||
// vel_index_scale=0: velocity doesn't affect FM indices
|
||||
// vel_index_scale=1: FM indices are fully scaled by velocity
|
||||
vel_idx_mult = (1.0 - vel_index_scale) + vel_index_scale * vel;
|
||||
|
||||
// LFO: 4 waveforms crossfaded
|
||||
// Segments: [0,0.333)=sine→tri, [0.333,0.667)=tri→saw, [0.667,1]=saw→square
|
||||
lfo_sig =
|
||||
ba.if(lfo_waveform < 0.333,
|
||||
(1.0 - lfo_waveform*3.0) * os.osc(lfo_rate) + lfo_waveform*3.0 * os.triangle(lfo_rate),
|
||||
ba.if(lfo_waveform < 0.667,
|
||||
(1.0 - (lfo_waveform-0.333)*3.0) * os.triangle(lfo_rate) + (lfo_waveform-0.333)*3.0 * os.sawtooth(lfo_rate),
|
||||
(1.0 - (lfo_waveform-0.667)*3.0) * os.sawtooth(lfo_rate) + (lfo_waveform-0.667)*3.0 * os.square(lfo_rate)));
|
||||
|
||||
// Pitch envelope: fast attack, configurable decay, triggered by gate
|
||||
pitch_env = en.ar(0.001, pitch_env_decay, gate);
|
||||
|
||||
// Modulated frequency: fine-tune + LFO pitch + pitch envelope
|
||||
// LFO pitch: ±0.0833 semitones per unit depth (subtle vibrato)
|
||||
// Pitch env: up to +1 octave (ratio +1.0 = +octave)
|
||||
lfo_pitch_offset = lfo_sig * lfo_depth * lfo_pitch * 0.0083; // ~0.1 semitone max
|
||||
pitch_env_offset = pitch_env * pitch_env_amount; // 0..1 oct
|
||||
mod_freq = base_freq * pow(2.0, lfo_pitch_offset + pitch_env_offset);
|
||||
|
||||
// LFO level modulation (tremolo): 1.0 when depth=0, dips to (1-0.5*depth*lfo_levels) at trough
|
||||
lfo_level_factor = 1.0 - lfo_depth * lfo_levels * 0.5 * (1.0 - lfo_sig) * 0.5;
|
||||
|
||||
// Per-operator oscillator: blend sine (0) → triangle (1)
|
||||
op_osc(f) = (1.0 - waveform_blend) * os.osc(f) + waveform_blend * os.triangle(f);
|
||||
|
||||
// Per-operator ADSR envelopes
|
||||
env1 = en.adsr(op1_attack, op1_decay, op1_sustain, op1_release, gate);
|
||||
env2 = en.adsr(op2_attack, op2_decay, op2_sustain, op2_release, gate);
|
||||
env3 = en.adsr(op3_attack, op3_decay, op3_sustain, op3_release, gate);
|
||||
env4 = en.adsr(op4_attack, op4_decay, op4_sustain, op4_release, gate);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4-Operator FM Routing
|
||||
//
|
||||
// Implementation strategy: build a recursive 4-channel bus using ~
|
||||
// The bus carries (op1_out, op2_out, op3_out, op4_out) with 1-sample delay.
|
||||
// Each frame, we compute new outputs from delayed previous outputs.
|
||||
//
|
||||
// fmbus: (d1,d2,d3,d4) → (op1_out,op2_out,op3_out,op4_out)
|
||||
// where dN are the 1-sample-delayed previous outputs (via ~)
|
||||
//
|
||||
// FM phase deviation: deviation_hz = index * modulator_out * carrier_freq
|
||||
// This is the standard "frequency modulation" formula where modulator_out ∈ [-1,1]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fmbus(d1,d2,d3,d4) =
|
||||
op1_out, op2_out, op3_out, op4_out
|
||||
with {
|
||||
// Effective indices scaled by velocity
|
||||
m12e = m12 * vel_idx_mult; m13e = m13 * vel_idx_mult; m14e = m14 * vel_idx_mult;
|
||||
m21e = m21 * vel_idx_mult; m23e = m23 * vel_idx_mult; m24e = m24 * vel_idx_mult;
|
||||
m31e = m31 * vel_idx_mult; m32e = m32 * vel_idx_mult; m34e = m34 * vel_idx_mult;
|
||||
m41e = m41 * vel_idx_mult; m42e = m42 * vel_idx_mult; m43e = m43 * vel_idx_mult;
|
||||
fb1e = fb1 * vel_idx_mult; fb2e = fb2 * vel_idx_mult;
|
||||
fb3e = fb3 * vel_idx_mult; fb4e = fb4 * vel_idx_mult;
|
||||
|
||||
f1 = mod_freq * op1_ratio;
|
||||
f2 = mod_freq * op2_ratio;
|
||||
f3 = mod_freq * op3_ratio;
|
||||
f4 = mod_freq * op4_ratio;
|
||||
|
||||
op1_out = env1 * op1_level * lfo_level_factor * vel_gain *
|
||||
op_osc(f1 + (m21e*d2 + m31e*d3 + m41e*d4 + fb1e*d1) * f1);
|
||||
|
||||
op2_out = env2 * op2_level * lfo_level_factor * vel_gain *
|
||||
op_osc(f2 + (m12e*d1 + m32e*d3 + m42e*d4 + fb2e*d2) * f2);
|
||||
|
||||
op3_out = env3 * op3_level * lfo_level_factor * vel_gain *
|
||||
op_osc(f3 + (m13e*d1 + m23e*d2 + m43e*d4 + fb3e*d3) * f3);
|
||||
|
||||
op4_out = env4 * op4_level * lfo_level_factor * vel_gain *
|
||||
op_osc(f4 + (m14e*d1 + m24e*d2 + m34e*d3 + fb4e*d4) * f4);
|
||||
};
|
||||
|
||||
// Route 4-channel bus through feedback loop (introduces mandatory 1-sample delay)
|
||||
// Output of fmbus is (op1,op2,op3,op4); sum all for audio out
|
||||
fm_out = (fmbus ~ (si.bus(4))) : (_, _, _, _) :> _;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Soft clip: tanh approximation (drive controlled by output_saturation)
|
||||
// At 0: linear passthrough. At 1: clips at ±~0.6
|
||||
soft_clip(x) = x / max(0.001, 1.0 + output_saturation * abs(x));
|
||||
|
||||
// Stereo spread via slight pitch detuning L vs R
|
||||
spread_cents = stereo_spread * 5.0; // ±5 cents max
|
||||
|
||||
// HP filter removes DC offset from heavy feedback FM
|
||||
hp_out(x) = fi.highpass(1, output_hp, x);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// process: mono FM sum → scale → soft-clip → HP → stereo spread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
process =
|
||||
fm_out
|
||||
: *(master_level * 0.25) // 4 ops summing: scale to safe range
|
||||
: soft_clip
|
||||
: hp_out
|
||||
<: *(cent2ratio(spread_cents)), *(cent2ratio(0.0 - spread_cents));
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,737 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
gen-modular-dsp.py — Emit modular-*.dsp files for the Faust "Modular" mode.
|
||||
|
||||
The modulation matrix has 48 sources × 10 destinations and every amount must be
|
||||
a unique hslider (Faust slider labels are compile-time string literals). Hand-
|
||||
writing 480+ sliders is impractical, so this script produces a fully expanded
|
||||
.dsp file with one hslider per (source, destination) cell plus an explicit
|
||||
48-term weighted-sum expression for each destination.
|
||||
|
||||
The generator emits three engines in one run:
|
||||
|
||||
modular-subtractive.dsp Minimoog-style 3-osc + moog filter voice
|
||||
modular-additive.dsp 64-partial additive voice (spectral descriptors)
|
||||
modular-fm.dsp 4-operator FM voice with full cross-mod matrix
|
||||
|
||||
Each engine has its own 48×10 modulation matrix and its own set of sound
|
||||
parameters. All modulation (envelopes, LFOs) comes from the shared
|
||||
mod-pool.lib — the engines themselves contain ZERO internal envs/LFOs.
|
||||
|
||||
Run:
|
||||
python3 gen-modular-dsp.py
|
||||
|
||||
Re-running this script is reproducible: same inputs produce byte-identical
|
||||
output. Please DO NOT hand-edit the generated .dsp files — edit this
|
||||
generator and re-run it instead.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
N_SRC = 48
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine definitions. One dict per engine; each has:
|
||||
# name file stem ("modular-<name>.dsp")
|
||||
# title header comment title
|
||||
# description short audio-path description
|
||||
# destinations list of (idx, short_name, doc) — exactly 10 entries
|
||||
# sound_params Faust source for engine sound params (hsliders/buttons)
|
||||
# signal_flow Faust source for derived signals + process line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Common hidden-controls block (identical for all engines)
|
||||
HIDDEN_BLOCK = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hidden controls
|
||||
// ---------------------------------------------------------------------------
|
||||
freq = hslider("0_Hidden/freq[hidden:1][unit:Hz]", 220, 20, 4000, 0.01);
|
||||
gate = button("0_Hidden/gate[hidden:1]");
|
||||
vel = hslider("0_Hidden/_vel[hidden:1]", 0.7, 0.0, 1.0, 0.001) : si.smoo;
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine 1 — Subtractive (Minimoog-style)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SUBTRACTIVE_DESTS = [
|
||||
(0, "pitch", "semitones, applied to all osc frequencies"),
|
||||
(1, "osc2_detune", "cents offset added to osc2 detune knob"),
|
||||
(2, "osc3_detune", "cents offset added to osc3 detune knob"),
|
||||
(3, "osc_mix_bal", "-1..+1 crossfades osc1<->osc3"),
|
||||
(4, "noise_level", "added to noise level knob"),
|
||||
(5, "cutoff", "+/-5 octaves of filter cutoff modulation"),
|
||||
(6, "resonance", "added to res knob"),
|
||||
(7, "filter_env_amt", "second independent +/-5 octave cutoff mod channel"),
|
||||
(8, "amp", "added to amp knob (route an ADSR here for a VCA env)"),
|
||||
(9, "pan", "added to pan knob"),
|
||||
]
|
||||
|
||||
SUBTRACTIVE_PARAMS = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1 — Oscillators
|
||||
// ---------------------------------------------------------------------------
|
||||
osc1_wave = hslider("1_Oscillators/00_osc1_wave[tooltip:0=saw,0.5=tri,1=square]", 0.0, 0.0, 1.0, 0.001);
|
||||
osc1_range = hslider("1_Oscillators/01_osc1_range[unit:oct]", 0.0, -2.0, 2.0, 0.01);
|
||||
osc1_level = hslider("1_Oscillators/02_osc1_level", 0.8, 0.0, 1.0, 0.001);
|
||||
|
||||
osc2_wave = hslider("1_Oscillators/03_osc2_wave[tooltip:0=saw,0.5=tri,1=square]", 0.0, 0.0, 1.0, 0.001);
|
||||
osc2_range = hslider("1_Oscillators/04_osc2_range[unit:oct]", 0.0, -2.0, 2.0, 0.01);
|
||||
osc2_detune = hslider("1_Oscillators/05_osc2_detune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
osc2_level = hslider("1_Oscillators/06_osc2_level", 0.6, 0.0, 1.0, 0.001);
|
||||
|
||||
osc3_wave = hslider("1_Oscillators/07_osc3_wave[tooltip:0=saw,0.5=tri,1=square]", 0.5, 0.0, 1.0, 0.001);
|
||||
osc3_range = hslider("1_Oscillators/08_osc3_range[unit:oct]", -1.0, -2.0, 2.0, 0.01);
|
||||
osc3_detune = hslider("1_Oscillators/09_osc3_detune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
osc3_level = hslider("1_Oscillators/10_osc3_level", 0.4, 0.0, 1.0, 0.001);
|
||||
osc3_kb_track = hslider("1_Oscillators/11_osc3_kb_track[tooltip:1=tracks keyboard,0=LFO]", 1.0, 0.0, 1.0, 1.0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2 — Mixer
|
||||
// ---------------------------------------------------------------------------
|
||||
noise_type = hslider("2_Mixer/00_noise_type[tooltip:0=white,1=pink]", 0.0, 0.0, 1.0, 1.0);
|
||||
noise_level = hslider("2_Mixer/01_noise_level", 0.0, 0.0, 1.0, 0.001);
|
||||
mixer_drive = hslider("2_Mixer/02_mixer_drive[tooltip:Pre-filter overdrive]", 1.0, 0.5, 4.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3 — Filter
|
||||
// ---------------------------------------------------------------------------
|
||||
cutoff = hslider("3_Filter/00_cutoff[scale:log][unit:Hz]", 1200.0, 20.0, 20000.0, 0.1);
|
||||
resonance = hslider("3_Filter/01_resonance", 0.3, 0.0, 1.0, 0.001);
|
||||
filter_kb = hslider("3_Filter/02_filter_kb_track", 0.5, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 4 — Master
|
||||
// ---------------------------------------------------------------------------
|
||||
master_level = hslider("4_Master/00_master_level", 0.7, 0.0, 1.0, 0.001);
|
||||
master_glide = hslider("4_Master/01_master_glide[unit:s][scale:log]", 0.0, 0.0, 2.0, 0.001);
|
||||
master_tune = hslider("4_Master/02_master_tune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
master_pan = hslider("4_Master/03_master_pan", 0.0, -1.0, 1.0, 0.001);
|
||||
// Static amp floor. At 1.0 the voice is always fully open and d08_amp
|
||||
// modulation is purely additive decoration. Drop to 0 for classic
|
||||
// ADSR-gated VCA behaviour (route s00_d08_amp to an ADSR).
|
||||
base_amp = hslider("4_Master/04_base_amp", 1.0, 0.0, 1.0, 0.001);
|
||||
"""
|
||||
|
||||
SUBTRACTIVE_FLOW = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
cent2ratio(c) = pow(2.0, c / 1200.0);
|
||||
oct2ratio(o) = pow(2.0, o);
|
||||
semi2ratio(s) = pow(2.0, s / 12.0);
|
||||
|
||||
// Portamento
|
||||
glide_tau = 0.0001 + master_glide * 1.0;
|
||||
freq_glided = freq : si.smooth(ba.tau2pole(glide_tau));
|
||||
|
||||
// Base (glide + master fine tune)
|
||||
base_freq = freq_glided * cent2ratio(master_tune);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-oscillator wavetable (saw → tri → square crossfade)
|
||||
// 0.0 → saw, 0.5 → triangle, 1.0 → square. Output in [-1,+1].
|
||||
// ---------------------------------------------------------------------------
|
||||
osc_wave(shape, f) =
|
||||
ba.if(shape < 0.5,
|
||||
(1.0 - shape*2.0) * os.lf_saw(f) + (shape*2.0) * os.lf_triangle(f),
|
||||
(1.0 - (shape-0.5)*2.0) * os.lf_triangle(f) + ((shape-0.5)*2.0) * os.lf_squarewave(f));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oscillator frequencies (pitch modulation: 1 unit = 12 semitones)
|
||||
// ---------------------------------------------------------------------------
|
||||
pitch_ratio = semi2ratio(mod_pitch(gate) * 12.0);
|
||||
|
||||
osc1_freq = base_freq * oct2ratio(osc1_range) * pitch_ratio;
|
||||
osc2_freq = base_freq * oct2ratio(osc2_range) * pitch_ratio
|
||||
* cent2ratio(osc2_detune + mod_osc2_detune(gate) * 50.0);
|
||||
osc3_freq_tracked = base_freq * oct2ratio(osc3_range) * pitch_ratio
|
||||
* cent2ratio(osc3_detune + mod_osc3_detune(gate) * 50.0);
|
||||
// When kb_track=0, osc3 becomes a free-running sub/LFO source at ~55 Hz * oct
|
||||
osc3_freq_untracked = 55.0 * oct2ratio(osc3_range)
|
||||
* cent2ratio(osc3_detune + mod_osc3_detune(gate) * 50.0);
|
||||
osc3_freq = osc3_kb_track * osc3_freq_tracked + (1.0 - osc3_kb_track) * osc3_freq_untracked;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oscillators
|
||||
// ---------------------------------------------------------------------------
|
||||
o1 = osc_wave(osc1_wave, osc1_freq);
|
||||
o2 = osc_wave(osc2_wave, osc2_freq);
|
||||
o3 = osc_wave(osc3_wave, osc3_freq);
|
||||
|
||||
// Osc mix balance: -1 = all osc1, +1 = all osc3, 0 = both equal. osc2 unaffected.
|
||||
mix_bal = max(-1.0, min(1.0, mod_osc_mix_bal(gate)));
|
||||
mix_t = (mix_bal + 1.0) * 0.5; // 0..1
|
||||
w_o1 = 1.0 - mix_t;
|
||||
w_o3 = mix_t;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Noise: white (noise_type<0.5) / pink (else)
|
||||
// ---------------------------------------------------------------------------
|
||||
white_noise = no.noise;
|
||||
pink_noise = no.pink_noise;
|
||||
noise_raw = ba.if(noise_type < 0.5, white_noise, pink_noise);
|
||||
noise_lvl = max(0.0, min(1.0, noise_level + mod_noise_level(gate)));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixer sum
|
||||
// ---------------------------------------------------------------------------
|
||||
mix_sum =
|
||||
o1 * osc1_level * w_o1
|
||||
+ o2 * osc2_level
|
||||
+ o3 * osc3_level * w_o3
|
||||
+ noise_raw * noise_lvl;
|
||||
|
||||
mix_driven = ma.tanh(mix_sum * mixer_drive);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter — Moog ladder. Cutoff is modulated in octaves (±5 oct per mod unit).
|
||||
// ---------------------------------------------------------------------------
|
||||
cutoff_mod_oct = (mod_cutoff(gate) + mod_filter_env_amt(gate)) * 5.0;
|
||||
kb_scale = pow(freq_glided / 440.0, filter_kb); // 1.0 at A4 when kb=1
|
||||
eff_cutoff_raw = cutoff * kb_scale * oct2ratio(cutoff_mod_oct);
|
||||
eff_cutoff = max(20.0, min(18000.0, eff_cutoff_raw));
|
||||
eff_res = max(0.0, min(0.99, resonance + mod_resonance(gate)));
|
||||
|
||||
filtered = mix_driven : ve.moog_vcf(eff_res, eff_cutoff);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Amp and pan
|
||||
// ---------------------------------------------------------------------------
|
||||
// Velocity gain: blend 1.0 (no velocity) -> vel. Referenced here so Faust
|
||||
// keeps the _vel hidden param alive in the JSON descriptor.
|
||||
vel_gain = (1.0 - 0.3) + 0.3 * vel; // 30% velocity sensitivity, fixed
|
||||
// d08_amp modulation is additive on top of base_amp (positive only). A
|
||||
// fully untrained or adversarial matrix therefore cannot silence the
|
||||
// voice while base_amp is high — which is the safety the "main amp gate
|
||||
// on at all times" default relies on. Users who want envelope-gated
|
||||
// voices set base_amp=0 and route a positive ADSR/LFO amount.
|
||||
amp_val = max(0.0, min(1.0, base_amp + max(0.0, mod_amp(gate)))) * master_level * vel_gain;
|
||||
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
|
||||
|
||||
// equal-power pan
|
||||
pan_l = cos((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
pan_r = sin((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
|
||||
signal = filtered * amp_val;
|
||||
signal_L = signal * pan_l;
|
||||
signal_R = signal * pan_r;
|
||||
|
||||
process = signal_L, signal_R;
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine 2 — Additive (64-partial spectral descriptors)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ADDITIVE_DESTS = [
|
||||
(0, "pitch", "semitones, applied to the fundamental frequency"),
|
||||
(1, "bright", "high-partial group amplitude boost/cut (replaces bright env)"),
|
||||
(2, "tilt", "added to spectral_tilt knob"),
|
||||
(3, "inharmonicity", "added to inharmonicity knob"),
|
||||
(4, "odd_even", "added to odd/even balance knob"),
|
||||
(5, "formant_ctr", "shifts both formant centre frequencies (harmonic index)"),
|
||||
(6, "formant_depth", "added to formant_depth knob"),
|
||||
(7, "noise_mix", "added to noise_floor knob (clamped 0..1)"),
|
||||
(8, "amp", "master amplitude dest (route an ADSR here for a VCA env)"),
|
||||
(9, "pan", "stereo balance"),
|
||||
]
|
||||
|
||||
ADDITIVE_PARAMS = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1 — Spectral Shape (harmonic bank + descriptors)
|
||||
// ---------------------------------------------------------------------------
|
||||
h1_amp = hslider("1_Spectral/00_h1_amp", 0.8, 0.0, 1.0, 0.001);
|
||||
h2_amp = hslider("1_Spectral/01_h2_amp", 0.5, 0.0, 1.0, 0.001);
|
||||
h3_amp = hslider("1_Spectral/02_h3_amp", 0.35, 0.0, 1.0, 0.001);
|
||||
h4_amp = hslider("1_Spectral/03_h4_amp", 0.25, 0.0, 1.0, 0.001);
|
||||
h5_amp = hslider("1_Spectral/04_h5_amp", 0.18, 0.0, 1.0, 0.001);
|
||||
h6_amp = hslider("1_Spectral/05_h6_amp", 0.12, 0.0, 1.0, 0.001);
|
||||
h7_amp = hslider("1_Spectral/06_h7_amp", 0.08, 0.0, 1.0, 0.001);
|
||||
h8_amp = hslider("1_Spectral/07_h8_amp", 0.06, 0.0, 1.0, 0.001);
|
||||
h9_16_amp = hslider("1_Spectral/08_h9_16_amp", 0.05, 0.0, 1.0, 0.001);
|
||||
h17_32_amp = hslider("1_Spectral/09_h17_32_amp", 0.025, 0.0, 1.0, 0.001);
|
||||
h33_64_amp = hslider("1_Spectral/10_h33_64_amp", 0.01, 0.0, 1.0, 0.001);
|
||||
spectral_tilt = hslider("1_Spectral/11_spectral_tilt", 0.0, -1.0, 1.0, 0.001);
|
||||
inharmonicity = hslider("1_Spectral/12_inharmonicity", 0.0, 0.0, 0.15, 0.0001);
|
||||
odd_even = hslider("1_Spectral/13_odd_even", 0.5, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2 — Formants & Noise
|
||||
// ---------------------------------------------------------------------------
|
||||
formant1_freq = hslider("2_Formants/00_formant1_freq[tooltip:Formant 1 harmonic idx]", 3.0, 1.0, 16.0, 0.01);
|
||||
formant2_freq = hslider("2_Formants/01_formant2_freq[tooltip:Formant 2 harmonic idx]", 6.0, 1.0, 16.0, 0.01);
|
||||
formant_depth = hslider("2_Formants/02_formant_depth", 0.0, 0.0, 1.0, 0.001);
|
||||
noise_floor = hslider("2_Formants/03_noise_floor", 0.0, 0.0, 0.2, 0.001);
|
||||
noise_color = hslider("2_Formants/04_noise_color", 0.5, 0.0, 1.0, 0.001);
|
||||
sub_harmonic = hslider("2_Formants/05_sub_harmonic", 0.0, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3 — Master
|
||||
// ---------------------------------------------------------------------------
|
||||
level = hslider("3_Master/00_level", 0.7, 0.0, 1.0, 0.001);
|
||||
fine_tune = hslider("3_Master/01_fine_tune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
saturation = hslider("3_Master/02_saturation", 0.0, 0.0, 1.0, 0.001);
|
||||
stereo_phase_spread= hslider("3_Master/03_stereo_phase_spread",0.1, 0.0, 1.0, 0.001);
|
||||
master_pan = hslider("3_Master/04_master_pan", 0.0, -1.0, 1.0, 0.001);
|
||||
// Static amp floor — see subtractive engine for full explanation.
|
||||
base_amp = hslider("3_Master/05_base_amp", 1.0, 0.0, 1.0, 0.001);
|
||||
"""
|
||||
|
||||
ADDITIVE_FLOW = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
N = 64;
|
||||
PI = ma.PI;
|
||||
|
||||
cent2ratio(c) = pow(2.0, c / 1200.0);
|
||||
semi2ratio(s) = pow(2.0, s / 12.0);
|
||||
|
||||
// Pitch: modulation (semitones), no glide
|
||||
fine_ratio = cent2ratio(fine_tune);
|
||||
pitch_ratio = semi2ratio(mod_pitch(gate) * 12.0);
|
||||
base_freq = freq * fine_ratio * pitch_ratio;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-harmonic base amplitude from group sliders
|
||||
// ---------------------------------------------------------------------------
|
||||
group_amp(k) =
|
||||
ba.if(k == 1, h1_amp,
|
||||
ba.if(k == 2, h2_amp,
|
||||
ba.if(k == 3, h3_amp,
|
||||
ba.if(k == 4, h4_amp,
|
||||
ba.if(k == 5, h5_amp,
|
||||
ba.if(k == 6, h6_amp,
|
||||
ba.if(k == 7, h7_amp,
|
||||
ba.if(k == 8, h8_amp,
|
||||
ba.if(k <= 16, h9_16_amp,
|
||||
ba.if(k <= 32, h17_32_amp,
|
||||
h33_64_amp))))))))));
|
||||
|
||||
// Spectral tilt (modulated) — amp *= k^tilt
|
||||
eff_tilt = spectral_tilt + mod_tilt(gate);
|
||||
tilt_factor(k) = pow(float(k), eff_tilt);
|
||||
|
||||
// Odd/even balance (modulated, clamped)
|
||||
eff_odd_even = max(0.0, min(1.0, odd_even + mod_odd_even(gate)));
|
||||
odd_weight = (1.0 - eff_odd_even) * 2.0;
|
||||
even_weight = eff_odd_even * 2.0;
|
||||
odd_even_factor(k) = ba.if(k % 2 == 0, even_weight, odd_weight);
|
||||
|
||||
// Brightness destination: progressive boost/cut on upper partials.
|
||||
// bright_blend(k): 0 at k=1, 1 at k=N. mod_bright is [-1,+1].
|
||||
// factor(k) = 1 + bright_blend(k) * mod_bright. At mod=0 -> unity (neutral).
|
||||
bright_blend(k) = float(k - 1) / float(N - 1);
|
||||
mb = mod_bright(gate);
|
||||
bright_factor(k) = 1.0 + bright_blend(k) * mb;
|
||||
|
||||
// Inharmonicity (modulated, clamped to keep partials monotonic)
|
||||
eff_inharm = max(0.0, min(0.2, inharmonicity + mod_inharmonicity(gate) * 0.15));
|
||||
|
||||
// Formant centres (modulated together — mod_formant_ctr shifts both by same amt)
|
||||
eff_formant_shift = mod_formant_ctr(gate) * 8.0; // ±8 harmonics
|
||||
eff_f1 = max(1.0, min(32.0, formant1_freq + eff_formant_shift));
|
||||
eff_f2 = max(1.0, min(32.0, formant2_freq + eff_formant_shift));
|
||||
eff_formant_depth = max(0.0, min(1.0, formant_depth + mod_formant_depth(gate)));
|
||||
|
||||
// Formant shaping — two Gaussian bumps in harmonic-index space
|
||||
sigma_sq = 1.5 * 1.5;
|
||||
formant_bump(k, ctr) = exp(-0.5 * (float(k) - ctr) * (float(k) - ctr) / sigma_sq);
|
||||
formant_factor(k) =
|
||||
1.0 + eff_formant_depth * (formant_bump(k, eff_f1) + formant_bump(k, eff_f2));
|
||||
|
||||
// Combined per-harmonic amplitude
|
||||
harm_amp(k) =
|
||||
group_amp(k) * tilt_factor(k) * odd_even_factor(k) *
|
||||
bright_factor(k) * formant_factor(k);
|
||||
|
||||
// Inharmonic partial frequency: freq_k = k*f0*(1 + B*(k^2 - 1))
|
||||
harm_freq(k) = base_freq * float(k)
|
||||
* (1.0 + eff_inharm * (float(k) * float(k) - 1.0));
|
||||
|
||||
// Stereo spread — R channel gets a small per-harmonic pitch offset
|
||||
stereo_spread_freq(k) = stereo_phase_spread * float(k) * 0.01;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additive oscillator sums — L and R
|
||||
// ---------------------------------------------------------------------------
|
||||
additive_L = sum(k, N, harm_amp(k+1) * os.osc(harm_freq(k+1)));
|
||||
additive_R = sum(k, N,
|
||||
harm_amp(k+1) * os.osc(harm_freq(k+1) * (1.0 + stereo_spread_freq(k+1))));
|
||||
|
||||
// Sub-harmonic (0.5× fundamental)
|
||||
sub_osc = sub_harmonic * os.osc(base_freq * 0.5);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Noise floor — coloured via one-pole LP
|
||||
// ---------------------------------------------------------------------------
|
||||
eff_noise = max(0.0, min(1.0, noise_floor + mod_noise_mix(gate)));
|
||||
noise_lp_cutoff = 200.0 + (1.0 - noise_color) * 19800.0;
|
||||
noise_signal = no.noise : fi.lowpass(1, noise_lp_cutoff);
|
||||
noise_out = eff_noise * noise_signal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Soft-clip saturation — tanh waveshaper
|
||||
// ---------------------------------------------------------------------------
|
||||
drive = 1.0 + saturation * 9.0;
|
||||
softclip(x) = ma.tanh(x * drive) / drive;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Velocity gain — keeps _vel alive in the JSON descriptor
|
||||
// ---------------------------------------------------------------------------
|
||||
vel_gain = (1.0 - 0.3) + 0.3 * vel;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Amplitude and pan (both mod-driven)
|
||||
// ---------------------------------------------------------------------------
|
||||
// See subtractive engine for the positive-only mod_amp rationale.
|
||||
amp_val = max(0.0, min(1.0, base_amp + max(0.0, mod_amp(gate)))) * level * vel_gain;
|
||||
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
|
||||
|
||||
pan_l = cos((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
pan_r = sin((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
|
||||
raw_L = (additive_L + sub_osc + noise_out) * amp_val;
|
||||
raw_R = (additive_R + sub_osc + noise_out) * amp_val;
|
||||
|
||||
signal_L = softclip(raw_L) * pan_l;
|
||||
signal_R = softclip(raw_R) * pan_r;
|
||||
|
||||
process = signal_L, signal_R;
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine 3 — FM (4-operator with cross-mod matrix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FM_DESTS = [
|
||||
(0, "pitch", "semitones added to the base frequency"),
|
||||
(1, "op1_level", "added to op1 level knob"),
|
||||
(2, "op2_level", "added to op2 level knob"),
|
||||
(3, "op3_level", "added to op3 level knob"),
|
||||
(4, "op4_level", "added to op4 level knob"),
|
||||
(5, "cross_mod_global", "global FM depth multiplier on all 12 cross-mod cells"),
|
||||
(6, "feedback_global", "global feedback multiplier on all 4 self-feedback cells"),
|
||||
(7, "global_ratio", "additive scale factor on all 4 operator ratios"),
|
||||
(8, "amp", "master amplitude dest (route an ADSR here for a VCA env)"),
|
||||
(9, "pan", "stereo balance"),
|
||||
]
|
||||
|
||||
FM_PARAMS = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1 — Operators (ratio & level per op)
|
||||
// ---------------------------------------------------------------------------
|
||||
op1_ratio = hslider("1_Operators/00_op1_ratio", 1.0, 0.125, 16.0, 0.001);
|
||||
op1_level = hslider("1_Operators/01_op1_level", 0.8, 0.0, 1.0, 0.001);
|
||||
op2_ratio = hslider("1_Operators/02_op2_ratio", 2.0, 0.125, 16.0, 0.001);
|
||||
op2_level = hslider("1_Operators/03_op2_level", 0.6, 0.0, 1.0, 0.001);
|
||||
op3_ratio = hslider("1_Operators/04_op3_ratio", 3.0, 0.125, 16.0, 0.001);
|
||||
op3_level = hslider("1_Operators/05_op3_level", 0.4, 0.0, 1.0, 0.001);
|
||||
op4_ratio = hslider("1_Operators/06_op4_ratio", 0.5, 0.125, 16.0, 0.001);
|
||||
op4_level = hslider("1_Operators/07_op4_level", 0.3, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2 — Cross-mod Matrix (mXY = op X modulates op Y)
|
||||
// ---------------------------------------------------------------------------
|
||||
m12 = hslider("2_CrossMod/00_m12", 0.0, 0.0, 10.0, 0.001);
|
||||
m13 = hslider("2_CrossMod/01_m13", 0.0, 0.0, 10.0, 0.001);
|
||||
m14 = hslider("2_CrossMod/02_m14", 0.0, 0.0, 10.0, 0.001);
|
||||
m21 = hslider("2_CrossMod/03_m21", 1.0, 0.0, 10.0, 0.001);
|
||||
m23 = hslider("2_CrossMod/04_m23", 0.0, 0.0, 10.0, 0.001);
|
||||
m24 = hslider("2_CrossMod/05_m24", 0.0, 0.0, 10.0, 0.001);
|
||||
m31 = hslider("2_CrossMod/06_m31", 0.0, 0.0, 10.0, 0.001);
|
||||
m32 = hslider("2_CrossMod/07_m32", 0.0, 0.0, 10.0, 0.001);
|
||||
m34 = hslider("2_CrossMod/08_m34", 0.0, 0.0, 10.0, 0.001);
|
||||
m41 = hslider("2_CrossMod/09_m41", 0.0, 0.0, 10.0, 0.001);
|
||||
m42 = hslider("2_CrossMod/10_m42", 0.0, 0.0, 10.0, 0.001);
|
||||
m43 = hslider("2_CrossMod/11_m43", 0.0, 0.0, 10.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3 — Self-feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
fb1 = hslider("3_Feedback/00_fb1", 0.0, 0.0, 1.0, 0.001);
|
||||
fb2 = hslider("3_Feedback/01_fb2", 0.0, 0.0, 1.0, 0.001);
|
||||
fb3 = hslider("3_Feedback/02_fb3", 0.0, 0.0, 1.0, 0.001);
|
||||
fb4 = hslider("3_Feedback/03_fb4", 0.0, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 4 — Master
|
||||
// ---------------------------------------------------------------------------
|
||||
master_level = hslider("4_Master/00_master_level", 0.7, 0.0, 1.0, 0.001);
|
||||
fine_tune = hslider("4_Master/01_fine_tune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
stereo_spread = hslider("4_Master/02_stereo_spread", 0.1, 0.0, 1.0, 0.001);
|
||||
output_saturation = hslider("4_Master/03_output_saturation", 0.0, 0.0, 1.0, 0.001);
|
||||
output_hp = hslider("4_Master/04_output_hp[unit:Hz]",20.0, 20.0, 200.0,0.1);
|
||||
master_pan = hslider("4_Master/05_master_pan", 0.0, -1.0, 1.0, 0.001);
|
||||
// Static amp floor — see subtractive engine for full explanation.
|
||||
base_amp = hslider("4_Master/06_base_amp", 1.0, 0.0, 1.0, 0.001);
|
||||
"""
|
||||
|
||||
FM_FLOW = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
cent2ratio(c) = pow(2.0, c / 1200.0);
|
||||
semi2ratio(s) = pow(2.0, s / 12.0);
|
||||
|
||||
// Base frequency: fine tune + pitch mod (semitones, ±12)
|
||||
base_freq = freq * cent2ratio(fine_tune) * semi2ratio(mod_pitch(gate) * 12.0);
|
||||
|
||||
// Velocity gain — keeps _vel alive
|
||||
vel_gain = (1.0 - 0.3) + 0.3 * vel;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effective per-operator ratios and levels (mod applied)
|
||||
// global_ratio mod is an additive ratio offset (±8)
|
||||
// ---------------------------------------------------------------------------
|
||||
ratio_offset = mod_global_ratio(gate) * 8.0;
|
||||
r1 = max(0.01, op1_ratio + ratio_offset);
|
||||
r2 = max(0.01, op2_ratio + ratio_offset);
|
||||
r3 = max(0.01, op3_ratio + ratio_offset);
|
||||
r4 = max(0.01, op4_ratio + ratio_offset);
|
||||
|
||||
lev1 = max(0.0, min(1.0, op1_level + mod_op1_level(gate)));
|
||||
lev2 = max(0.0, min(1.0, op2_level + mod_op2_level(gate)));
|
||||
lev3 = max(0.0, min(1.0, op3_level + mod_op3_level(gate)));
|
||||
lev4 = max(0.0, min(1.0, op4_level + mod_op4_level(gate)));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global multipliers for cross-mod and feedback.
|
||||
// (1 + mod) in [0, 2] roughly; clamp to stay sane.
|
||||
// ---------------------------------------------------------------------------
|
||||
cross_mul = max(0.0, 1.0 + mod_cross_mod_global(gate));
|
||||
fb_mul = max(0.0, 1.0 + mod_feedback_global(gate));
|
||||
|
||||
m12e = m12 * cross_mul; m13e = m13 * cross_mul; m14e = m14 * cross_mul;
|
||||
m21e = m21 * cross_mul; m23e = m23 * cross_mul; m24e = m24 * cross_mul;
|
||||
m31e = m31 * cross_mul; m32e = m32 * cross_mul; m34e = m34 * cross_mul;
|
||||
m41e = m41 * cross_mul; m42e = m42 * cross_mul; m43e = m43 * cross_mul;
|
||||
|
||||
fb1e = fb1 * fb_mul;
|
||||
fb2e = fb2 * fb_mul;
|
||||
fb3e = fb3 * fb_mul;
|
||||
fb4e = fb4 * fb_mul;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4-operator FM bus — feedback loop via ~ on a 4-channel bus
|
||||
// ---------------------------------------------------------------------------
|
||||
fmbus(d1,d2,d3,d4) =
|
||||
op1_out, op2_out, op3_out, op4_out
|
||||
with {
|
||||
f1 = base_freq * r1;
|
||||
f2 = base_freq * r2;
|
||||
f3 = base_freq * r3;
|
||||
f4 = base_freq * r4;
|
||||
|
||||
op1_out = lev1 *
|
||||
os.osc(f1 + (m21e*d2 + m31e*d3 + m41e*d4 + fb1e*d1) * f1);
|
||||
|
||||
op2_out = lev2 *
|
||||
os.osc(f2 + (m12e*d1 + m32e*d3 + m42e*d4 + fb2e*d2) * f2);
|
||||
|
||||
op3_out = lev3 *
|
||||
os.osc(f3 + (m13e*d1 + m23e*d2 + m43e*d4 + fb3e*d3) * f3);
|
||||
|
||||
op4_out = lev4 *
|
||||
os.osc(f4 + (m14e*d1 + m24e*d2 + m34e*d3 + fb4e*d4) * f4);
|
||||
};
|
||||
|
||||
// Route 4-channel bus through feedback loop, sum all ops
|
||||
fm_out = (fmbus ~ (si.bus(4))) : (_, _, _, _) :> _;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-processing
|
||||
// ---------------------------------------------------------------------------
|
||||
soft_clip(x) = x / max(0.001, 1.0 + output_saturation * abs(x));
|
||||
hp_out(x) = fi.highpass(1, output_hp, x);
|
||||
|
||||
// Master amplitude destination (route an ADSR here for VCA)
|
||||
// See subtractive engine for the positive-only mod_amp rationale.
|
||||
amp_val = max(0.0, min(1.0, base_amp + max(0.0, mod_amp(gate)))) * master_level * vel_gain * 0.25;
|
||||
|
||||
// Pan
|
||||
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
|
||||
pan_l = cos((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
pan_r = sin((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
|
||||
// Stereo spread via slight detuning (apply after main FM sum)
|
||||
spread_cents = stereo_spread * 5.0;
|
||||
spread_l = cent2ratio(spread_cents);
|
||||
spread_r = cent2ratio(0.0 - spread_cents);
|
||||
|
||||
mono_post = fm_out * amp_val : soft_clip : hp_out;
|
||||
|
||||
signal_L = mono_post * spread_l * pan_l;
|
||||
signal_R = mono_post * spread_r * pan_r;
|
||||
|
||||
process = signal_L, signal_R;
|
||||
"""
|
||||
|
||||
|
||||
ENGINES = [
|
||||
{
|
||||
"name": "subtractive",
|
||||
"title": "Minimoog-style subtractive voice",
|
||||
"description": "3 oscillators + noise → mixer → moog ladder filter → amp/pan.",
|
||||
"destinations": SUBTRACTIVE_DESTS,
|
||||
"sound_params": SUBTRACTIVE_PARAMS,
|
||||
"signal_flow": SUBTRACTIVE_FLOW,
|
||||
},
|
||||
{
|
||||
"name": "additive",
|
||||
"title": "64-partial additive voice",
|
||||
"description": "64 harmonic sines with spectral descriptors (tilt, odd/even, formants).",
|
||||
"destinations": ADDITIVE_DESTS,
|
||||
"sound_params": ADDITIVE_PARAMS,
|
||||
"signal_flow": ADDITIVE_FLOW,
|
||||
},
|
||||
{
|
||||
"name": "fm",
|
||||
"title": "4-operator FM voice",
|
||||
"description": "4 ops with 12-cell cross-mod matrix and per-op feedback.",
|
||||
"destinations": FM_DESTS,
|
||||
"sound_params": FM_PARAMS,
|
||||
"signal_flow": FM_FLOW,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared matrix-emission helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def src_name(s):
|
||||
return f"s{s:02d}"
|
||||
|
||||
|
||||
def dest_label(d, name):
|
||||
return f"d{d:02d}_{name}"
|
||||
|
||||
|
||||
def amt_slider_line(s, d, name):
|
||||
label = f"MM_Matrix/{src_name(s)}_{dest_label(d, name)}"
|
||||
varname = f"amt_{src_name(s)}_{dest_label(d, name)}"
|
||||
return f'{varname} = hslider("{label}", 0.0, -1.0, 1.0, 0.001);'
|
||||
|
||||
|
||||
def dest_expression(d, name):
|
||||
varname = f"mod_{name}"
|
||||
lines = [f"{varname}(gate) ="]
|
||||
for s in range(N_SRC):
|
||||
amt_var = f"amt_{src_name(s)}_{dest_label(d, name)}"
|
||||
src_call = f"mp.src{s:02d}(gate)"
|
||||
if s < N_SRC - 1:
|
||||
lines.append(f" {src_call} * {amt_var} +")
|
||||
else:
|
||||
lines.append(f" {src_call} * {amt_var};")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def gen_matrix_sliders(dests):
|
||||
blocks = []
|
||||
for (d, name, _doc) in dests:
|
||||
blocks.append(f"// Matrix column: destination {d} = {name}")
|
||||
for s in range(N_SRC):
|
||||
blocks.append(amt_slider_line(s, d, name))
|
||||
blocks.append("")
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
def gen_dest_expressions(dests):
|
||||
blocks = []
|
||||
for (d, name, _doc) in dests:
|
||||
blocks.append(dest_expression(d, name))
|
||||
blocks.append("")
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
def emit_engine(cfg, out_dir):
|
||||
name = cfg["name"]
|
||||
title = cfg["title"]
|
||||
description = cfg["description"]
|
||||
dests = cfg["destinations"]
|
||||
sound_params = cfg["sound_params"]
|
||||
signal_flow = cfg["signal_flow"]
|
||||
|
||||
n_dest = len(dests)
|
||||
assert n_dest == 10, f"engine {name}: expected 10 destinations, got {n_dest}"
|
||||
|
||||
# Header comment with per-engine destination docs
|
||||
dest_doc_lines = []
|
||||
for (d, dname, doc) in dests:
|
||||
dest_doc_lines.append(f"// {d} {dname:<17} {doc}")
|
||||
dest_doc = "\n".join(dest_doc_lines)
|
||||
|
||||
header = f"""\
|
||||
// modular-{name}.dsp — {title} for the "Modular" mode.
|
||||
//
|
||||
// AUTO-GENERATED by gen-modular-dsp.py. DO NOT EDIT BY HAND.
|
||||
// Regenerate with:
|
||||
// cd playground/faust && python3 gen-modular-dsp.py
|
||||
//
|
||||
// Audio path: {description}
|
||||
// All envelopes and LFOs come from mod-pool.lib via a 48-source × {n_dest}-dest
|
||||
// modulation matrix. No internal envs/LFOs.
|
||||
//
|
||||
// Modulation destinations ({n_dest}):
|
||||
{dest_doc}
|
||||
//
|
||||
// Hidden controls (driven by noteOn/noteOff, not by the ML engine):
|
||||
// freq, gate, _vel
|
||||
//
|
||||
// Build:
|
||||
// cd playground/faust && ./build.sh
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import("stdfaust.lib");
|
||||
mp = library("mod-pool.lib");
|
||||
|
||||
{HIDDEN_BLOCK}
|
||||
{sound_params}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modulation matrix — 48 sources × {n_dest} destinations.
|
||||
// Each hslider is one "amount" entry in [-1, +1]. Zero = no connection.
|
||||
// Group "MM_Matrix" so the paramMeta parser can recognise matrix params.
|
||||
// ---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
parts = [
|
||||
header,
|
||||
gen_matrix_sliders(dests),
|
||||
"// ---------------------------------------------------------------------------",
|
||||
"// Destination signals — one per modulation destination. Each is a 48-term",
|
||||
"// weighted sum of sources × amounts. The `gate` argument is threaded through",
|
||||
"// so that mp.src??(gate) can drive ADSR envelopes.",
|
||||
"// ---------------------------------------------------------------------------",
|
||||
"",
|
||||
gen_dest_expressions(dests),
|
||||
signal_flow,
|
||||
]
|
||||
|
||||
out_path = os.path.join(out_dir, f"modular-{name}.dsp")
|
||||
with open(out_path, "w") as f:
|
||||
f.write("\n".join(parts))
|
||||
|
||||
n_matrix = N_SRC * n_dest
|
||||
print(f"Wrote {out_path}")
|
||||
print(f" destinations: {n_dest}")
|
||||
print(f" matrix sliders: {n_matrix}")
|
||||
|
||||
|
||||
def main():
|
||||
out_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
for cfg in ENGINES:
|
||||
emit_engine(cfg, out_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,435 +0,0 @@
|
|||
// mod-pool.lib — Shared modulation pool (ADSRs + LFOs) for the "Modular" mode.
|
||||
//
|
||||
// Provides 16 ADSR slots + 32 LFO slots that can be routed through a
|
||||
// per-engine modulation matrix to any parameter in the host .dsp.
|
||||
// Engines import this via:
|
||||
//
|
||||
// mp = library("mod-pool.lib");
|
||||
// sources = mp.sources(gate); // 48-wide parallel bus
|
||||
// mod_x = sum(s, 48, sources : ba.selector(s, 48) * amount(s));
|
||||
//
|
||||
// PUBLIC API
|
||||
// ----------
|
||||
//
|
||||
// mp.N_ADSR integer — 16
|
||||
// mp.N_LFO integer — 32
|
||||
// mp.N_SOURCES integer — 48 (= N_ADSR + N_LFO)
|
||||
//
|
||||
// mp.adsrs(gate) bus — 16-wide parallel bus, each element [0,1]
|
||||
// mp.lfos bus — 32-wide parallel bus, each element [-1,+1]
|
||||
// mp.sources(gate) bus — 48-wide parallel bus (adsrs(gate), lfos)
|
||||
//
|
||||
// PARAMETER NAMESPACE CONVENTION
|
||||
// ------------------------------
|
||||
// All mod-pool sliders live under groups prefixed with "MM_" so that the
|
||||
// paramMeta parser can segregate them from engine sound params:
|
||||
//
|
||||
// MM_ADSR/NN_adsrNN_{attack,decay,sustain,release,enable} (16 slots)
|
||||
// MM_LFO /NN_lfoNN_{rate,morph,enable} (32 slots)
|
||||
//
|
||||
// Engine-side matrix sliders (declared in the engine .dsp, not in this file)
|
||||
// should follow:
|
||||
//
|
||||
// MM_Matrix/sNN_dNN_<destname> (48 * n_dst)
|
||||
//
|
||||
// where sNN = source index (0..47; 0..15 = ADSRs, 16..47 = LFOs),
|
||||
// dNN = destination index, and <destname> = short destination label.
|
||||
//
|
||||
// MUTING
|
||||
// ------
|
||||
// Each slot has an "enable" hslider (0 or 1). The slot's output is multiplied
|
||||
// by the enable value so a muted slot contributes 0 at the matrix input. This
|
||||
// is cheap but does NOT let Faust eliminate the underlying env/LFO compute.
|
||||
// Compile-time dead-code elimination of unused slots is a future concern.
|
||||
//
|
||||
// LFO WAVEMORPH
|
||||
// -------------
|
||||
// A single "morph" slider (0..1) crossfades through four shapes:
|
||||
//
|
||||
// [0.000, 0.333) : sine -> triangle
|
||||
// [0.333, 0.666) : triangle -> square
|
||||
// [0.666, 1.000] : square -> saw
|
||||
//
|
||||
// Implemented via nested ba.if so the chosen pair is a linear crossfade.
|
||||
// The output is always in [-1,+1].
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
// Counts (engine code can reference mp.N_SOURCES etc.)
|
||||
N_ADSR = 16;
|
||||
N_LFO = 32;
|
||||
N_SOURCES = 48;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-slot hslider helpers. Unique label per slot index.
|
||||
// Slot indices are 1-based in labels (adsr01..adsr16, lfo01..lfo32) to keep
|
||||
// alphabetical ordering stable in the Faust JSON output.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ADSR param factories — declared per-slot because Faust slider labels must
|
||||
// be string literals known at compile time. One function per slot index.
|
||||
|
||||
adsr01_a = hslider("MM_ADSR/00_adsr01_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr01_d = hslider("MM_ADSR/00_adsr01_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr01_s = hslider("MM_ADSR/00_adsr01_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr01_r = hslider("MM_ADSR/00_adsr01_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr01_e = hslider("MM_ADSR/00_adsr01_enable", 1.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr02_a = hslider("MM_ADSR/01_adsr02_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr02_d = hslider("MM_ADSR/01_adsr02_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr02_s = hslider("MM_ADSR/01_adsr02_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr02_r = hslider("MM_ADSR/01_adsr02_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr02_e = hslider("MM_ADSR/01_adsr02_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr03_a = hslider("MM_ADSR/02_adsr03_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr03_d = hslider("MM_ADSR/02_adsr03_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr03_s = hslider("MM_ADSR/02_adsr03_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr03_r = hslider("MM_ADSR/02_adsr03_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr03_e = hslider("MM_ADSR/02_adsr03_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr04_a = hslider("MM_ADSR/03_adsr04_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr04_d = hslider("MM_ADSR/03_adsr04_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr04_s = hslider("MM_ADSR/03_adsr04_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr04_r = hslider("MM_ADSR/03_adsr04_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr04_e = hslider("MM_ADSR/03_adsr04_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr05_a = hslider("MM_ADSR/04_adsr05_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr05_d = hslider("MM_ADSR/04_adsr05_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr05_s = hslider("MM_ADSR/04_adsr05_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr05_r = hslider("MM_ADSR/04_adsr05_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr05_e = hslider("MM_ADSR/04_adsr05_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr06_a = hslider("MM_ADSR/05_adsr06_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr06_d = hslider("MM_ADSR/05_adsr06_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr06_s = hslider("MM_ADSR/05_adsr06_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr06_r = hslider("MM_ADSR/05_adsr06_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr06_e = hslider("MM_ADSR/05_adsr06_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr07_a = hslider("MM_ADSR/06_adsr07_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr07_d = hslider("MM_ADSR/06_adsr07_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr07_s = hslider("MM_ADSR/06_adsr07_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr07_r = hslider("MM_ADSR/06_adsr07_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr07_e = hslider("MM_ADSR/06_adsr07_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr08_a = hslider("MM_ADSR/07_adsr08_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr08_d = hslider("MM_ADSR/07_adsr08_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr08_s = hslider("MM_ADSR/07_adsr08_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr08_r = hslider("MM_ADSR/07_adsr08_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr08_e = hslider("MM_ADSR/07_adsr08_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr09_a = hslider("MM_ADSR/08_adsr09_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr09_d = hslider("MM_ADSR/08_adsr09_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr09_s = hslider("MM_ADSR/08_adsr09_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr09_r = hslider("MM_ADSR/08_adsr09_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr09_e = hslider("MM_ADSR/08_adsr09_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr10_a = hslider("MM_ADSR/09_adsr10_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr10_d = hslider("MM_ADSR/09_adsr10_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr10_s = hslider("MM_ADSR/09_adsr10_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr10_r = hslider("MM_ADSR/09_adsr10_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr10_e = hslider("MM_ADSR/09_adsr10_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr11_a = hslider("MM_ADSR/10_adsr11_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr11_d = hslider("MM_ADSR/10_adsr11_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr11_s = hslider("MM_ADSR/10_adsr11_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr11_r = hslider("MM_ADSR/10_adsr11_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr11_e = hslider("MM_ADSR/10_adsr11_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr12_a = hslider("MM_ADSR/11_adsr12_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr12_d = hslider("MM_ADSR/11_adsr12_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr12_s = hslider("MM_ADSR/11_adsr12_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr12_r = hslider("MM_ADSR/11_adsr12_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr12_e = hslider("MM_ADSR/11_adsr12_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr13_a = hslider("MM_ADSR/12_adsr13_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr13_d = hslider("MM_ADSR/12_adsr13_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr13_s = hslider("MM_ADSR/12_adsr13_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr13_r = hslider("MM_ADSR/12_adsr13_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr13_e = hslider("MM_ADSR/12_adsr13_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr14_a = hslider("MM_ADSR/13_adsr14_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr14_d = hslider("MM_ADSR/13_adsr14_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr14_s = hslider("MM_ADSR/13_adsr14_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr14_r = hslider("MM_ADSR/13_adsr14_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr14_e = hslider("MM_ADSR/13_adsr14_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr15_a = hslider("MM_ADSR/14_adsr15_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr15_d = hslider("MM_ADSR/14_adsr15_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr15_s = hslider("MM_ADSR/14_adsr15_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr15_r = hslider("MM_ADSR/14_adsr15_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr15_e = hslider("MM_ADSR/14_adsr15_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr16_a = hslider("MM_ADSR/15_adsr16_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr16_d = hslider("MM_ADSR/15_adsr16_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr16_s = hslider("MM_ADSR/15_adsr16_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr16_r = hslider("MM_ADSR/15_adsr16_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr16_e = hslider("MM_ADSR/15_adsr16_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
// Single-slot ADSR expression. The enable multiplier zeros muted slots.
|
||||
adsr_slot(a, d, s, r, e, gate) = en.adsr(a, d, s, r, gate) : *(e);
|
||||
|
||||
// 16-wide parallel bus of ADSR outputs. Takes the global gate as an argument
|
||||
// so engines can bind their own gate signal (noteOn/noteOff).
|
||||
adsrs(gate) =
|
||||
adsr_slot(adsr01_a, adsr01_d, adsr01_s, adsr01_r, adsr01_e, gate),
|
||||
adsr_slot(adsr02_a, adsr02_d, adsr02_s, adsr02_r, adsr02_e, gate),
|
||||
adsr_slot(adsr03_a, adsr03_d, adsr03_s, adsr03_r, adsr03_e, gate),
|
||||
adsr_slot(adsr04_a, adsr04_d, adsr04_s, adsr04_r, adsr04_e, gate),
|
||||
adsr_slot(adsr05_a, adsr05_d, adsr05_s, adsr05_r, adsr05_e, gate),
|
||||
adsr_slot(adsr06_a, adsr06_d, adsr06_s, adsr06_r, adsr06_e, gate),
|
||||
adsr_slot(adsr07_a, adsr07_d, adsr07_s, adsr07_r, adsr07_e, gate),
|
||||
adsr_slot(adsr08_a, adsr08_d, adsr08_s, adsr08_r, adsr08_e, gate),
|
||||
adsr_slot(adsr09_a, adsr09_d, adsr09_s, adsr09_r, adsr09_e, gate),
|
||||
adsr_slot(adsr10_a, adsr10_d, adsr10_s, adsr10_r, adsr10_e, gate),
|
||||
adsr_slot(adsr11_a, adsr11_d, adsr11_s, adsr11_r, adsr11_e, gate),
|
||||
adsr_slot(adsr12_a, adsr12_d, adsr12_s, adsr12_r, adsr12_e, gate),
|
||||
adsr_slot(adsr13_a, adsr13_d, adsr13_s, adsr13_r, adsr13_e, gate),
|
||||
adsr_slot(adsr14_a, adsr14_d, adsr14_s, adsr14_r, adsr14_e, gate),
|
||||
adsr_slot(adsr15_a, adsr15_d, adsr15_s, adsr15_r, adsr15_e, gate),
|
||||
adsr_slot(adsr16_a, adsr16_d, adsr16_s, adsr16_r, adsr16_e, gate);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LFO slots (32)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
lfo01_r = hslider("MM_LFO/00_lfo01_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo01_m = hslider("MM_LFO/00_lfo01_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo01_e = hslider("MM_LFO/00_lfo01_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo02_r = hslider("MM_LFO/01_lfo02_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo02_m = hslider("MM_LFO/01_lfo02_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo02_e = hslider("MM_LFO/01_lfo02_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo03_r = hslider("MM_LFO/02_lfo03_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo03_m = hslider("MM_LFO/02_lfo03_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo03_e = hslider("MM_LFO/02_lfo03_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo04_r = hslider("MM_LFO/03_lfo04_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo04_m = hslider("MM_LFO/03_lfo04_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo04_e = hslider("MM_LFO/03_lfo04_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo05_r = hslider("MM_LFO/04_lfo05_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo05_m = hslider("MM_LFO/04_lfo05_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo05_e = hslider("MM_LFO/04_lfo05_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo06_r = hslider("MM_LFO/05_lfo06_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo06_m = hslider("MM_LFO/05_lfo06_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo06_e = hslider("MM_LFO/05_lfo06_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo07_r = hslider("MM_LFO/06_lfo07_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo07_m = hslider("MM_LFO/06_lfo07_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo07_e = hslider("MM_LFO/06_lfo07_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo08_r = hslider("MM_LFO/07_lfo08_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo08_m = hslider("MM_LFO/07_lfo08_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo08_e = hslider("MM_LFO/07_lfo08_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo09_r = hslider("MM_LFO/08_lfo09_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo09_m = hslider("MM_LFO/08_lfo09_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo09_e = hslider("MM_LFO/08_lfo09_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo10_r = hslider("MM_LFO/09_lfo10_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo10_m = hslider("MM_LFO/09_lfo10_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo10_e = hslider("MM_LFO/09_lfo10_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo11_r = hslider("MM_LFO/10_lfo11_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo11_m = hslider("MM_LFO/10_lfo11_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo11_e = hslider("MM_LFO/10_lfo11_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo12_r = hslider("MM_LFO/11_lfo12_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo12_m = hslider("MM_LFO/11_lfo12_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo12_e = hslider("MM_LFO/11_lfo12_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo13_r = hslider("MM_LFO/12_lfo13_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo13_m = hslider("MM_LFO/12_lfo13_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo13_e = hslider("MM_LFO/12_lfo13_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo14_r = hslider("MM_LFO/13_lfo14_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo14_m = hslider("MM_LFO/13_lfo14_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo14_e = hslider("MM_LFO/13_lfo14_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo15_r = hslider("MM_LFO/14_lfo15_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo15_m = hslider("MM_LFO/14_lfo15_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo15_e = hslider("MM_LFO/14_lfo15_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo16_r = hslider("MM_LFO/15_lfo16_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo16_m = hslider("MM_LFO/15_lfo16_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo16_e = hslider("MM_LFO/15_lfo16_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo17_r = hslider("MM_LFO/16_lfo17_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo17_m = hslider("MM_LFO/16_lfo17_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo17_e = hslider("MM_LFO/16_lfo17_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo18_r = hslider("MM_LFO/17_lfo18_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo18_m = hslider("MM_LFO/17_lfo18_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo18_e = hslider("MM_LFO/17_lfo18_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo19_r = hslider("MM_LFO/18_lfo19_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo19_m = hslider("MM_LFO/18_lfo19_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo19_e = hslider("MM_LFO/18_lfo19_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo20_r = hslider("MM_LFO/19_lfo20_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo20_m = hslider("MM_LFO/19_lfo20_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo20_e = hslider("MM_LFO/19_lfo20_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo21_r = hslider("MM_LFO/20_lfo21_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo21_m = hslider("MM_LFO/20_lfo21_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo21_e = hslider("MM_LFO/20_lfo21_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo22_r = hslider("MM_LFO/21_lfo22_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo22_m = hslider("MM_LFO/21_lfo22_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo22_e = hslider("MM_LFO/21_lfo22_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo23_r = hslider("MM_LFO/22_lfo23_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo23_m = hslider("MM_LFO/22_lfo23_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo23_e = hslider("MM_LFO/22_lfo23_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo24_r = hslider("MM_LFO/23_lfo24_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo24_m = hslider("MM_LFO/23_lfo24_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo24_e = hslider("MM_LFO/23_lfo24_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo25_r = hslider("MM_LFO/24_lfo25_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo25_m = hslider("MM_LFO/24_lfo25_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo25_e = hslider("MM_LFO/24_lfo25_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo26_r = hslider("MM_LFO/25_lfo26_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo26_m = hslider("MM_LFO/25_lfo26_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo26_e = hslider("MM_LFO/25_lfo26_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo27_r = hslider("MM_LFO/26_lfo27_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo27_m = hslider("MM_LFO/26_lfo27_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo27_e = hslider("MM_LFO/26_lfo27_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo28_r = hslider("MM_LFO/27_lfo28_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo28_m = hslider("MM_LFO/27_lfo28_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo28_e = hslider("MM_LFO/27_lfo28_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo29_r = hslider("MM_LFO/28_lfo29_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo29_m = hslider("MM_LFO/28_lfo29_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo29_e = hslider("MM_LFO/28_lfo29_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo30_r = hslider("MM_LFO/29_lfo30_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo30_m = hslider("MM_LFO/29_lfo30_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo30_e = hslider("MM_LFO/29_lfo30_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo31_r = hslider("MM_LFO/30_lfo31_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo31_m = hslider("MM_LFO/30_lfo31_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo31_e = hslider("MM_LFO/30_lfo31_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo32_r = hslider("MM_LFO/31_lfo32_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo32_m = hslider("MM_LFO/31_lfo32_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo32_e = hslider("MM_LFO/31_lfo32_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
// Wavemorph — 0..1 crossfades sine -> triangle -> square -> saw.
|
||||
// Uses nested ba.if. Each shape is already in [-1,+1].
|
||||
wavemorph(rate, morph) =
|
||||
ba.if(morph < 0.333,
|
||||
(1.0 - morph*3.0) * os.osc(rate) + (morph*3.0) * os.lf_triangle(rate),
|
||||
ba.if(morph < 0.666,
|
||||
(1.0 - (morph-0.333)*3.0) * os.lf_triangle(rate) + ((morph-0.333)*3.0) * os.lf_squarewave(rate),
|
||||
(1.0 - (morph-0.666)*3.0) * os.lf_squarewave(rate) + ((morph-0.666)*3.0) * os.lf_saw(rate)));
|
||||
|
||||
lfo_slot(rate, morph, en_flag) = wavemorph(rate, morph) : *(en_flag);
|
||||
|
||||
lfos =
|
||||
lfo_slot(lfo01_r, lfo01_m, lfo01_e),
|
||||
lfo_slot(lfo02_r, lfo02_m, lfo02_e),
|
||||
lfo_slot(lfo03_r, lfo03_m, lfo03_e),
|
||||
lfo_slot(lfo04_r, lfo04_m, lfo04_e),
|
||||
lfo_slot(lfo05_r, lfo05_m, lfo05_e),
|
||||
lfo_slot(lfo06_r, lfo06_m, lfo06_e),
|
||||
lfo_slot(lfo07_r, lfo07_m, lfo07_e),
|
||||
lfo_slot(lfo08_r, lfo08_m, lfo08_e),
|
||||
lfo_slot(lfo09_r, lfo09_m, lfo09_e),
|
||||
lfo_slot(lfo10_r, lfo10_m, lfo10_e),
|
||||
lfo_slot(lfo11_r, lfo11_m, lfo11_e),
|
||||
lfo_slot(lfo12_r, lfo12_m, lfo12_e),
|
||||
lfo_slot(lfo13_r, lfo13_m, lfo13_e),
|
||||
lfo_slot(lfo14_r, lfo14_m, lfo14_e),
|
||||
lfo_slot(lfo15_r, lfo15_m, lfo15_e),
|
||||
lfo_slot(lfo16_r, lfo16_m, lfo16_e),
|
||||
lfo_slot(lfo17_r, lfo17_m, lfo17_e),
|
||||
lfo_slot(lfo18_r, lfo18_m, lfo18_e),
|
||||
lfo_slot(lfo19_r, lfo19_m, lfo19_e),
|
||||
lfo_slot(lfo20_r, lfo20_m, lfo20_e),
|
||||
lfo_slot(lfo21_r, lfo21_m, lfo21_e),
|
||||
lfo_slot(lfo22_r, lfo22_m, lfo22_e),
|
||||
lfo_slot(lfo23_r, lfo23_m, lfo23_e),
|
||||
lfo_slot(lfo24_r, lfo24_m, lfo24_e),
|
||||
lfo_slot(lfo25_r, lfo25_m, lfo25_e),
|
||||
lfo_slot(lfo26_r, lfo26_m, lfo26_e),
|
||||
lfo_slot(lfo27_r, lfo27_m, lfo27_e),
|
||||
lfo_slot(lfo28_r, lfo28_m, lfo28_e),
|
||||
lfo_slot(lfo29_r, lfo29_m, lfo29_e),
|
||||
lfo_slot(lfo30_r, lfo30_m, lfo30_e),
|
||||
lfo_slot(lfo31_r, lfo31_m, lfo31_e),
|
||||
lfo_slot(lfo32_r, lfo32_m, lfo32_e);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sources(gate) — concatenated 48-wide mod-source bus.
|
||||
// Order: adsr01..adsr16, lfo01..lfo32
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
sources(gate) = adsrs(gate), lfos;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual named source functions — 48 total, in canonical order.
|
||||
// Engines can reference these directly in matrix sums, since each hslider
|
||||
// inside a matrix amount must have a unique compile-time label.
|
||||
//
|
||||
// Canonical source ordering (source index 0..47):
|
||||
// 0..15 : adsr01..adsr16
|
||||
// 16..47 : lfo01..lfo32
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
src00(gate) = adsr_slot(adsr01_a, adsr01_d, adsr01_s, adsr01_r, adsr01_e, gate);
|
||||
src01(gate) = adsr_slot(adsr02_a, adsr02_d, adsr02_s, adsr02_r, adsr02_e, gate);
|
||||
src02(gate) = adsr_slot(adsr03_a, adsr03_d, adsr03_s, adsr03_r, adsr03_e, gate);
|
||||
src03(gate) = adsr_slot(adsr04_a, adsr04_d, adsr04_s, adsr04_r, adsr04_e, gate);
|
||||
src04(gate) = adsr_slot(adsr05_a, adsr05_d, adsr05_s, adsr05_r, adsr05_e, gate);
|
||||
src05(gate) = adsr_slot(adsr06_a, adsr06_d, adsr06_s, adsr06_r, adsr06_e, gate);
|
||||
src06(gate) = adsr_slot(adsr07_a, adsr07_d, adsr07_s, adsr07_r, adsr07_e, gate);
|
||||
src07(gate) = adsr_slot(adsr08_a, adsr08_d, adsr08_s, adsr08_r, adsr08_e, gate);
|
||||
src08(gate) = adsr_slot(adsr09_a, adsr09_d, adsr09_s, adsr09_r, adsr09_e, gate);
|
||||
src09(gate) = adsr_slot(adsr10_a, adsr10_d, adsr10_s, adsr10_r, adsr10_e, gate);
|
||||
src10(gate) = adsr_slot(adsr11_a, adsr11_d, adsr11_s, adsr11_r, adsr11_e, gate);
|
||||
src11(gate) = adsr_slot(adsr12_a, adsr12_d, adsr12_s, adsr12_r, adsr12_e, gate);
|
||||
src12(gate) = adsr_slot(adsr13_a, adsr13_d, adsr13_s, adsr13_r, adsr13_e, gate);
|
||||
src13(gate) = adsr_slot(adsr14_a, adsr14_d, adsr14_s, adsr14_r, adsr14_e, gate);
|
||||
src14(gate) = adsr_slot(adsr15_a, adsr15_d, adsr15_s, adsr15_r, adsr15_e, gate);
|
||||
src15(gate) = adsr_slot(adsr16_a, adsr16_d, adsr16_s, adsr16_r, adsr16_e, gate);
|
||||
|
||||
src16(gate) = lfo_slot(lfo01_r, lfo01_m, lfo01_e);
|
||||
src17(gate) = lfo_slot(lfo02_r, lfo02_m, lfo02_e);
|
||||
src18(gate) = lfo_slot(lfo03_r, lfo03_m, lfo03_e);
|
||||
src19(gate) = lfo_slot(lfo04_r, lfo04_m, lfo04_e);
|
||||
src20(gate) = lfo_slot(lfo05_r, lfo05_m, lfo05_e);
|
||||
src21(gate) = lfo_slot(lfo06_r, lfo06_m, lfo06_e);
|
||||
src22(gate) = lfo_slot(lfo07_r, lfo07_m, lfo07_e);
|
||||
src23(gate) = lfo_slot(lfo08_r, lfo08_m, lfo08_e);
|
||||
src24(gate) = lfo_slot(lfo09_r, lfo09_m, lfo09_e);
|
||||
src25(gate) = lfo_slot(lfo10_r, lfo10_m, lfo10_e);
|
||||
src26(gate) = lfo_slot(lfo11_r, lfo11_m, lfo11_e);
|
||||
src27(gate) = lfo_slot(lfo12_r, lfo12_m, lfo12_e);
|
||||
src28(gate) = lfo_slot(lfo13_r, lfo13_m, lfo13_e);
|
||||
src29(gate) = lfo_slot(lfo14_r, lfo14_m, lfo14_e);
|
||||
src30(gate) = lfo_slot(lfo15_r, lfo15_m, lfo15_e);
|
||||
src31(gate) = lfo_slot(lfo16_r, lfo16_m, lfo16_e);
|
||||
src32(gate) = lfo_slot(lfo17_r, lfo17_m, lfo17_e);
|
||||
src33(gate) = lfo_slot(lfo18_r, lfo18_m, lfo18_e);
|
||||
src34(gate) = lfo_slot(lfo19_r, lfo19_m, lfo19_e);
|
||||
src35(gate) = lfo_slot(lfo20_r, lfo20_m, lfo20_e);
|
||||
src36(gate) = lfo_slot(lfo21_r, lfo21_m, lfo21_e);
|
||||
src37(gate) = lfo_slot(lfo22_r, lfo22_m, lfo22_e);
|
||||
src38(gate) = lfo_slot(lfo23_r, lfo23_m, lfo23_e);
|
||||
src39(gate) = lfo_slot(lfo24_r, lfo24_m, lfo24_e);
|
||||
src40(gate) = lfo_slot(lfo25_r, lfo25_m, lfo25_e);
|
||||
src41(gate) = lfo_slot(lfo26_r, lfo26_m, lfo26_e);
|
||||
src42(gate) = lfo_slot(lfo27_r, lfo27_m, lfo27_e);
|
||||
src43(gate) = lfo_slot(lfo28_r, lfo28_m, lfo28_e);
|
||||
src44(gate) = lfo_slot(lfo29_r, lfo29_m, lfo29_e);
|
||||
src45(gate) = lfo_slot(lfo30_r, lfo30_m, lfo30_e);
|
||||
src46(gate) = lfo_slot(lfo31_r, lfo31_m, lfo31_e);
|
||||
src47(gate) = lfo_slot(lfo32_r, lfo32_m, lfo32_e);
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
/**
|
||||
* modular-additive-processor.js — AudioWorklet processor for the
|
||||
* "Modular" mode additive voice (modular-additive.dsp).
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads modular-subtractive.wasm compiled by `faust -lang wasm`.
|
||||
*
|
||||
* Param-index strategy:
|
||||
* modular-additive.dsp has ~700 parameters (25 engine + 80 ADSR + 96 LFO
|
||||
* + 480 matrix + 3 hidden). Hand-maintained zone tables (additive-processor
|
||||
* style) do not scale. Instead we use the wasm-native JSON descriptor
|
||||
* produced by `faust -lang wasm`, which has a numeric `index` field on each
|
||||
* leaf item. That index is the direct memory offset that setParamValue
|
||||
* expects as its zone argument — no sentinel scanning, no address strings.
|
||||
*
|
||||
* The main thread fetches modular-additive.json, parses it, and passes
|
||||
* the UI tree to the worklet via the init message.
|
||||
*
|
||||
* Calling convention (faust -lang wasm, single-instance):
|
||||
* init(dsp, sampleRate) dsp is always 0
|
||||
* compute(dsp, n, inputs, outputs)
|
||||
* setParamValue(dsp, zone, value) zone = numeric memory address from JSON
|
||||
* getParamValue(dsp, zone) -> float
|
||||
*
|
||||
* Hidden params (not in the engine's ML-controllable list):
|
||||
* 0_Hidden/freq — set by noteOn
|
||||
* 0_Hidden/gate — set by noteOn/noteOff (button, value 0/1)
|
||||
* 0_Hidden/_vel — set by noteOn
|
||||
*/
|
||||
|
||||
// Must be imported in AudioWorkletGlobalScope — include faust-worklet-processor.js
|
||||
// via audioCtx.audioWorklet.addModule() before this file.
|
||||
|
||||
const DSP = 0;
|
||||
const BLOCK_SIZE = 128;
|
||||
|
||||
class ModularAdditiveProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dspInst = null;
|
||||
this._dspMemory = null; // live Float32Array view
|
||||
this._sampleRate = 48000;
|
||||
|
||||
// Zone tables, populated once the init message has been processed.
|
||||
this._paramZones = []; // NISPS index (order from JSON walk) -> zone
|
||||
this._paramZonesByLabel = {}; // label -> zone (for smoke test)
|
||||
this._hiddenZones = {}; // {freq, gate, _vel} -> zone
|
||||
|
||||
// Audio output buffer pointers
|
||||
this._outPtrsAddr = 0;
|
||||
this._outLAddr = 0;
|
||||
this._outRAddr = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _initWasm — called by FaustWorkletProcessor.handleMessage on 'init'.
|
||||
//
|
||||
// Extra fields on the init message:
|
||||
// uiJson — parsed Faust UI descriptor (the .json file contents) for
|
||||
// zone discovery. Faust's -lang wasm embeds numeric `index`
|
||||
// fields that we use directly as zone memory addresses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sampleRate, uiJson) {
|
||||
this._sampleRate = sampleRate;
|
||||
|
||||
const importObj = {
|
||||
env: {
|
||||
_sinf: Math.sin,
|
||||
_cosf: Math.cos,
|
||||
_tanf: Math.tan,
|
||||
_expf: Math.exp,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_powf: Math.pow,
|
||||
_tanhf: Math.tanh,
|
||||
_sqrtf: Math.sqrt,
|
||||
_fabsf: Math.abs,
|
||||
_floorf: Math.floor,
|
||||
_ceilf: Math.ceil,
|
||||
_remainderf: (a, b) => a - Math.round(a / b) * b,
|
||||
_fmodf: (a, b) => a % b,
|
||||
_roundf: Math.round,
|
||||
_truncf: Math.trunc,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await WebAssembly.instantiate(wasmBytes, importObj);
|
||||
this._dspInst = result.instance;
|
||||
const ex = this._dspInst.exports;
|
||||
|
||||
// Grow memory by 2 pages (128 KB) so the output buffer region sits past
|
||||
// Faust's allocated parameter zones. After growth we have at least
|
||||
// 3 * BLOCK_SIZE * 4 bytes of scratch at the high end of memory.
|
||||
ex.memory.grow(2);
|
||||
|
||||
this._dspMemory = new Float32Array(ex.memory.buffer);
|
||||
|
||||
// Initialise the DSP instance (single-instance convention: dsp = 0)
|
||||
ex.init(DSP, sampleRate);
|
||||
|
||||
// Allocate output buffers at the top of WASM memory.
|
||||
const memBytes = ex.memory.buffer.byteLength;
|
||||
this._outLAddr = memBytes - BLOCK_SIZE * 4 * 3;
|
||||
this._outRAddr = this._outLAddr + BLOCK_SIZE * 4;
|
||||
this._outPtrsAddr = this._outRAddr + BLOCK_SIZE * 4;
|
||||
|
||||
const u32 = new Uint32Array(ex.memory.buffer);
|
||||
u32[this._outPtrsAddr / 4] = this._outLAddr;
|
||||
u32[this._outPtrsAddr / 4 + 1] = this._outRAddr;
|
||||
|
||||
// Refresh the float view after any potential reallocation
|
||||
this._dspMemory = new Float32Array(ex.memory.buffer);
|
||||
|
||||
// Build the zone index from the JSON we were passed.
|
||||
if (uiJson) {
|
||||
this._buildZoneIndexFromJson(uiJson);
|
||||
} else {
|
||||
console.warn('[ModularAdditiveProcessor] no uiJson in init message — ' +
|
||||
'setParam will silently no-op.');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _buildZoneIndexFromJson — walk the Faust UI tree and collect numeric
|
||||
// zone indexes, separating hidden params from the main NISPS-controllable
|
||||
// list. Preserves the UI tree's traversal order, which matches the order
|
||||
// the playground's faustJsonToParamMeta() parser will produce.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildZoneIndexFromJson(faustJson) {
|
||||
const HIDDEN_TAIL = new Set(['freq', 'gate', '_vel']);
|
||||
const zones = [];
|
||||
const byLabel = {};
|
||||
const hidden = {};
|
||||
|
||||
const walk = (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
for (const item of items) {
|
||||
const type = item?.type;
|
||||
if (type === 'hslider' || type === 'vslider' ||
|
||||
type === 'nentry' || type === 'button' ||
|
||||
type === 'checkbox') {
|
||||
const label = item.label ?? '';
|
||||
const idx = item.index;
|
||||
if (typeof idx !== 'number') continue;
|
||||
|
||||
const hasHiddenMeta = Array.isArray(item.meta) &&
|
||||
item.meta.some(m => m.hidden === '1' || m.hidden === 1);
|
||||
const tail = label.includes('/') ? label.split('/').pop() : label;
|
||||
|
||||
if (hasHiddenMeta || HIDDEN_TAIL.has(tail)) {
|
||||
hidden[tail] = idx;
|
||||
} else {
|
||||
zones.push(idx);
|
||||
byLabel[label] = idx;
|
||||
}
|
||||
} else if (item?.items) {
|
||||
walk(item.items);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(faustJson.ui ?? []);
|
||||
|
||||
this._paramZones = zones;
|
||||
this._paramZonesByLabel = byLabel;
|
||||
this._hiddenZones = hidden;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _onSetParam — look up the zone by NISPS index and poke the DSP
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._dspInst) return;
|
||||
const zone = this._paramZones[index];
|
||||
if (zone === undefined) return;
|
||||
this._dspInst.exports.setParamValue(DSP, zone, value);
|
||||
}
|
||||
|
||||
_onNoteOn(freq, vel) {
|
||||
if (!this._dspInst) return;
|
||||
const ex = this._dspInst.exports;
|
||||
const z = this._hiddenZones;
|
||||
if (z.freq !== undefined) ex.setParamValue(DSP, z.freq, freq);
|
||||
if (z._vel !== undefined) ex.setParamValue(DSP, z._vel, vel ?? 0.7);
|
||||
if (z.gate !== undefined) ex.setParamValue(DSP, z.gate, 1.0);
|
||||
}
|
||||
|
||||
_onNoteOff(_freq) {
|
||||
if (!this._dspInst) return;
|
||||
const z = this._hiddenZones;
|
||||
if (z.gate !== undefined) {
|
||||
this._dspInst.exports.setParamValue(DSP, z.gate, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Extended message handling — override for setByLabel + init-with-json
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_handleMessage(msg) {
|
||||
if (!msg || !msg.type) return;
|
||||
|
||||
if (msg.type === 'init') {
|
||||
// Override the base class's init path so we can thread `uiJson` through.
|
||||
this._initWasm(msg.wasmBytes, msg.sampleRate || sampleRate, msg.uiJson)
|
||||
.then(() => {
|
||||
this._ready = true;
|
||||
this.port.postMessage({ type: 'ready' });
|
||||
this._drainPendingMessages();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.port.postMessage({ type: 'error', message: String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer anything that arrives before init() finishes. See
|
||||
// faust-worklet-processor.js._queueIfNotReady.
|
||||
if (this._queueIfNotReady(msg)) return;
|
||||
|
||||
if (msg.type === 'setByLabel') {
|
||||
const zone = this._paramZonesByLabel[msg.label];
|
||||
if (zone !== undefined) {
|
||||
this._dspInst.exports.setParamValue(DSP, zone, msg.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate everything else (setParam, noteOn, noteOff) to the base class.
|
||||
super._handleMessage(msg);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _renderBlock — call Faust compute, copy output buffers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._dspInst || !this._dspMemory) return;
|
||||
|
||||
const ex = this._dspInst.exports;
|
||||
ex.compute(DSP, blockSize, 0, this._outPtrsAddr);
|
||||
|
||||
const wL = new Float32Array(ex.memory.buffer, this._outLAddr, blockSize);
|
||||
const wR = new Float32Array(ex.memory.buffer, this._outRAddr, blockSize);
|
||||
outL.set(wL);
|
||||
outR.set(wR);
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('modular-additive-processor', ModularAdditiveProcessor);
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,257 +0,0 @@
|
|||
/**
|
||||
* modular-fm-processor.js — AudioWorklet processor for the
|
||||
* "Modular" mode 4-operator FM voice (modular-fm.dsp).
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads modular-subtractive.wasm compiled by `faust -lang wasm`.
|
||||
*
|
||||
* Param-index strategy:
|
||||
* modular-fm.dsp has ~690 parameters (30 engine + 80 ADSR + 96 LFO
|
||||
* + 480 matrix + 3 hidden). Hand-maintained zone tables (additive-processor
|
||||
* style) do not scale. Instead we use the wasm-native JSON descriptor
|
||||
* produced by `faust -lang wasm`, which has a numeric `index` field on each
|
||||
* leaf item. That index is the direct memory offset that setParamValue
|
||||
* expects as its zone argument — no sentinel scanning, no address strings.
|
||||
*
|
||||
* The main thread fetches modular-fm.json, parses it, and passes
|
||||
* the UI tree to the worklet via the init message.
|
||||
*
|
||||
* Calling convention (faust -lang wasm, single-instance):
|
||||
* init(dsp, sampleRate) dsp is always 0
|
||||
* compute(dsp, n, inputs, outputs)
|
||||
* setParamValue(dsp, zone, value) zone = numeric memory address from JSON
|
||||
* getParamValue(dsp, zone) -> float
|
||||
*
|
||||
* Hidden params (not in the engine's ML-controllable list):
|
||||
* 0_Hidden/freq — set by noteOn
|
||||
* 0_Hidden/gate — set by noteOn/noteOff (button, value 0/1)
|
||||
* 0_Hidden/_vel — set by noteOn
|
||||
*/
|
||||
|
||||
// Must be imported in AudioWorkletGlobalScope — include faust-worklet-processor.js
|
||||
// via audioCtx.audioWorklet.addModule() before this file.
|
||||
|
||||
const DSP = 0;
|
||||
const BLOCK_SIZE = 128;
|
||||
|
||||
class ModularFmProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dspInst = null;
|
||||
this._dspMemory = null; // live Float32Array view
|
||||
this._sampleRate = 48000;
|
||||
|
||||
// Zone tables, populated once the init message has been processed.
|
||||
this._paramZones = []; // NISPS index (order from JSON walk) -> zone
|
||||
this._paramZonesByLabel = {}; // label -> zone (for smoke test)
|
||||
this._hiddenZones = {}; // {freq, gate, _vel} -> zone
|
||||
|
||||
// Audio output buffer pointers
|
||||
this._outPtrsAddr = 0;
|
||||
this._outLAddr = 0;
|
||||
this._outRAddr = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _initWasm — called by FaustWorkletProcessor.handleMessage on 'init'.
|
||||
//
|
||||
// Extra fields on the init message:
|
||||
// uiJson — parsed Faust UI descriptor (the .json file contents) for
|
||||
// zone discovery. Faust's -lang wasm embeds numeric `index`
|
||||
// fields that we use directly as zone memory addresses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sampleRate, uiJson) {
|
||||
this._sampleRate = sampleRate;
|
||||
|
||||
const importObj = {
|
||||
env: {
|
||||
_sinf: Math.sin,
|
||||
_cosf: Math.cos,
|
||||
_tanf: Math.tan,
|
||||
_expf: Math.exp,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_powf: Math.pow,
|
||||
_tanhf: Math.tanh,
|
||||
_sqrtf: Math.sqrt,
|
||||
_fabsf: Math.abs,
|
||||
_floorf: Math.floor,
|
||||
_ceilf: Math.ceil,
|
||||
_remainderf: (a, b) => a - Math.round(a / b) * b,
|
||||
_fmodf: (a, b) => a % b,
|
||||
_roundf: Math.round,
|
||||
_truncf: Math.trunc,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await WebAssembly.instantiate(wasmBytes, importObj);
|
||||
this._dspInst = result.instance;
|
||||
const ex = this._dspInst.exports;
|
||||
|
||||
// Grow memory by 2 pages (128 KB) so the output buffer region sits past
|
||||
// Faust's allocated parameter zones. After growth we have at least
|
||||
// 3 * BLOCK_SIZE * 4 bytes of scratch at the high end of memory.
|
||||
ex.memory.grow(2);
|
||||
|
||||
this._dspMemory = new Float32Array(ex.memory.buffer);
|
||||
|
||||
// Initialise the DSP instance (single-instance convention: dsp = 0)
|
||||
ex.init(DSP, sampleRate);
|
||||
|
||||
// Allocate output buffers at the top of WASM memory.
|
||||
const memBytes = ex.memory.buffer.byteLength;
|
||||
this._outLAddr = memBytes - BLOCK_SIZE * 4 * 3;
|
||||
this._outRAddr = this._outLAddr + BLOCK_SIZE * 4;
|
||||
this._outPtrsAddr = this._outRAddr + BLOCK_SIZE * 4;
|
||||
|
||||
const u32 = new Uint32Array(ex.memory.buffer);
|
||||
u32[this._outPtrsAddr / 4] = this._outLAddr;
|
||||
u32[this._outPtrsAddr / 4 + 1] = this._outRAddr;
|
||||
|
||||
// Refresh the float view after any potential reallocation
|
||||
this._dspMemory = new Float32Array(ex.memory.buffer);
|
||||
|
||||
// Build the zone index from the JSON we were passed.
|
||||
if (uiJson) {
|
||||
this._buildZoneIndexFromJson(uiJson);
|
||||
} else {
|
||||
console.warn('[ModularFmProcessor] no uiJson in init message — ' +
|
||||
'setParam will silently no-op.');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _buildZoneIndexFromJson — walk the Faust UI tree and collect numeric
|
||||
// zone indexes, separating hidden params from the main NISPS-controllable
|
||||
// list. Preserves the UI tree's traversal order, which matches the order
|
||||
// the playground's faustJsonToParamMeta() parser will produce.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildZoneIndexFromJson(faustJson) {
|
||||
const HIDDEN_TAIL = new Set(['freq', 'gate', '_vel']);
|
||||
const zones = [];
|
||||
const byLabel = {};
|
||||
const hidden = {};
|
||||
|
||||
const walk = (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
for (const item of items) {
|
||||
const type = item?.type;
|
||||
if (type === 'hslider' || type === 'vslider' ||
|
||||
type === 'nentry' || type === 'button' ||
|
||||
type === 'checkbox') {
|
||||
const label = item.label ?? '';
|
||||
const idx = item.index;
|
||||
if (typeof idx !== 'number') continue;
|
||||
|
||||
const hasHiddenMeta = Array.isArray(item.meta) &&
|
||||
item.meta.some(m => m.hidden === '1' || m.hidden === 1);
|
||||
const tail = label.includes('/') ? label.split('/').pop() : label;
|
||||
|
||||
if (hasHiddenMeta || HIDDEN_TAIL.has(tail)) {
|
||||
hidden[tail] = idx;
|
||||
} else {
|
||||
zones.push(idx);
|
||||
byLabel[label] = idx;
|
||||
}
|
||||
} else if (item?.items) {
|
||||
walk(item.items);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(faustJson.ui ?? []);
|
||||
|
||||
this._paramZones = zones;
|
||||
this._paramZonesByLabel = byLabel;
|
||||
this._hiddenZones = hidden;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _onSetParam — look up the zone by NISPS index and poke the DSP
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._dspInst) return;
|
||||
const zone = this._paramZones[index];
|
||||
if (zone === undefined) return;
|
||||
this._dspInst.exports.setParamValue(DSP, zone, value);
|
||||
}
|
||||
|
||||
_onNoteOn(freq, vel) {
|
||||
if (!this._dspInst) return;
|
||||
const ex = this._dspInst.exports;
|
||||
const z = this._hiddenZones;
|
||||
if (z.freq !== undefined) ex.setParamValue(DSP, z.freq, freq);
|
||||
if (z._vel !== undefined) ex.setParamValue(DSP, z._vel, vel ?? 0.7);
|
||||
if (z.gate !== undefined) ex.setParamValue(DSP, z.gate, 1.0);
|
||||
}
|
||||
|
||||
_onNoteOff(_freq) {
|
||||
if (!this._dspInst) return;
|
||||
const z = this._hiddenZones;
|
||||
if (z.gate !== undefined) {
|
||||
this._dspInst.exports.setParamValue(DSP, z.gate, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Extended message handling — override for setByLabel + init-with-json
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_handleMessage(msg) {
|
||||
if (!msg || !msg.type) return;
|
||||
|
||||
if (msg.type === 'init') {
|
||||
// Override the base class's init path so we can thread `uiJson` through.
|
||||
this._initWasm(msg.wasmBytes, msg.sampleRate || sampleRate, msg.uiJson)
|
||||
.then(() => {
|
||||
this._ready = true;
|
||||
this.port.postMessage({ type: 'ready' });
|
||||
this._drainPendingMessages();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.port.postMessage({ type: 'error', message: String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer anything that arrives before init() finishes. See
|
||||
// faust-worklet-processor.js._queueIfNotReady.
|
||||
if (this._queueIfNotReady(msg)) return;
|
||||
|
||||
if (msg.type === 'setByLabel') {
|
||||
const zone = this._paramZonesByLabel[msg.label];
|
||||
if (zone !== undefined) {
|
||||
this._dspInst.exports.setParamValue(DSP, zone, msg.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate everything else (setParam, noteOn, noteOff) to the base class.
|
||||
super._handleMessage(msg);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _renderBlock — call Faust compute, copy output buffers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._dspInst || !this._dspMemory) return;
|
||||
|
||||
const ex = this._dspInst.exports;
|
||||
ex.compute(DSP, blockSize, 0, this._outPtrsAddr);
|
||||
|
||||
const wL = new Float32Array(ex.memory.buffer, this._outLAddr, blockSize);
|
||||
const wR = new Float32Array(ex.memory.buffer, this._outRAddr, blockSize);
|
||||
outL.set(wL);
|
||||
outR.set(wR);
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('modular-fm-processor', ModularFmProcessor);
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,261 +0,0 @@
|
|||
/**
|
||||
* modular-subtractive-processor.js — AudioWorklet processor for the
|
||||
* "Modular" mode subtractive voice (modular-subtractive.dsp).
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads modular-subtractive.wasm compiled by `faust -lang wasm`.
|
||||
*
|
||||
* Param-index strategy:
|
||||
* modular-subtractive.dsp has ~681 parameters (22 engine + 80 ADSR + 96 LFO
|
||||
* + 480 matrix + 3 hidden). Hand-maintained zone tables (additive-processor
|
||||
* style) do not scale. Instead we use the wasm-native JSON descriptor
|
||||
* produced by `faust -lang wasm`, which has a numeric `index` field on each
|
||||
* leaf item. That index is the direct memory offset that setParamValue
|
||||
* expects as its zone argument — no sentinel scanning, no address strings.
|
||||
*
|
||||
* The main thread fetches modular-subtractive.json, parses it, and passes
|
||||
* the UI tree to the worklet via the init message.
|
||||
*
|
||||
* Calling convention (faust -lang wasm, single-instance):
|
||||
* init(dsp, sampleRate) dsp is always 0
|
||||
* compute(dsp, n, inputs, outputs)
|
||||
* setParamValue(dsp, zone, value) zone = numeric memory address from JSON
|
||||
* getParamValue(dsp, zone) -> float
|
||||
*
|
||||
* Hidden params (not in the engine's ML-controllable list):
|
||||
* 0_Hidden/freq — set by noteOn
|
||||
* 0_Hidden/gate — set by noteOn/noteOff (button, value 0/1)
|
||||
* 0_Hidden/_vel — set by noteOn
|
||||
*/
|
||||
|
||||
// Must be imported in AudioWorkletGlobalScope — include faust-worklet-processor.js
|
||||
// via audioCtx.audioWorklet.addModule() before this file.
|
||||
|
||||
const DSP = 0;
|
||||
const BLOCK_SIZE = 128;
|
||||
|
||||
class ModularSubtractiveProcessor extends FaustWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._dspInst = null;
|
||||
this._dspMemory = null; // live Float32Array view
|
||||
this._sampleRate = 48000;
|
||||
|
||||
// Zone tables, populated once the init message has been processed.
|
||||
this._paramZones = []; // NISPS index (order from JSON walk) -> zone
|
||||
this._paramZonesByLabel = {}; // label -> zone (for smoke test)
|
||||
this._hiddenZones = {}; // {freq, gate, _vel} -> zone
|
||||
|
||||
// Audio output buffer pointers
|
||||
this._outPtrsAddr = 0;
|
||||
this._outLAddr = 0;
|
||||
this._outRAddr = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _initWasm — called by FaustWorkletProcessor.handleMessage on 'init'.
|
||||
//
|
||||
// Extra fields on the init message:
|
||||
// uiJson — parsed Faust UI descriptor (the .json file contents) for
|
||||
// zone discovery. Faust's -lang wasm embeds numeric `index`
|
||||
// fields that we use directly as zone memory addresses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _initWasm(wasmBytes, sampleRate, uiJson) {
|
||||
this._sampleRate = sampleRate;
|
||||
|
||||
const importObj = {
|
||||
env: {
|
||||
_sinf: Math.sin,
|
||||
_cosf: Math.cos,
|
||||
_tanf: Math.tan,
|
||||
_expf: Math.exp,
|
||||
_logf: Math.log,
|
||||
_log10f: Math.log10,
|
||||
_powf: Math.pow,
|
||||
_tanhf: Math.tanh,
|
||||
_sqrtf: Math.sqrt,
|
||||
_fabsf: Math.abs,
|
||||
_floorf: Math.floor,
|
||||
_ceilf: Math.ceil,
|
||||
_remainderf: (a, b) => a - Math.round(a / b) * b,
|
||||
_fmodf: (a, b) => a % b,
|
||||
_roundf: Math.round,
|
||||
_truncf: Math.trunc,
|
||||
_acosf: Math.acos,
|
||||
_asinf: Math.asin,
|
||||
_atanf: Math.atan,
|
||||
_atan2f: Math.atan2,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await WebAssembly.instantiate(wasmBytes, importObj);
|
||||
this._dspInst = result.instance;
|
||||
const ex = this._dspInst.exports;
|
||||
|
||||
// Grow memory by 2 pages (128 KB) so the output buffer region sits past
|
||||
// Faust's allocated parameter zones. The parameter zone region in
|
||||
// modular-subtractive lives around bytes 262144..265280; after growth we
|
||||
// have at least 3 * BLOCK_SIZE * 4 bytes at the high end.
|
||||
ex.memory.grow(2);
|
||||
|
||||
this._dspMemory = new Float32Array(ex.memory.buffer);
|
||||
|
||||
// Initialise the DSP instance (single-instance convention: dsp = 0)
|
||||
ex.init(DSP, sampleRate);
|
||||
|
||||
// Allocate output buffers at the top of WASM memory.
|
||||
const memBytes = ex.memory.buffer.byteLength;
|
||||
this._outLAddr = memBytes - BLOCK_SIZE * 4 * 3;
|
||||
this._outRAddr = this._outLAddr + BLOCK_SIZE * 4;
|
||||
this._outPtrsAddr = this._outRAddr + BLOCK_SIZE * 4;
|
||||
|
||||
const u32 = new Uint32Array(ex.memory.buffer);
|
||||
u32[this._outPtrsAddr / 4] = this._outLAddr;
|
||||
u32[this._outPtrsAddr / 4 + 1] = this._outRAddr;
|
||||
|
||||
// Refresh the float view after any potential reallocation
|
||||
this._dspMemory = new Float32Array(ex.memory.buffer);
|
||||
|
||||
// Build the zone index from the JSON we were passed.
|
||||
if (uiJson) {
|
||||
this._buildZoneIndexFromJson(uiJson);
|
||||
} else {
|
||||
console.warn('[ModularSubtractiveProcessor] no uiJson in init message — ' +
|
||||
'setParam will silently no-op.');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _buildZoneIndexFromJson — walk the Faust UI tree and collect numeric
|
||||
// zone indexes, separating hidden params from the main NISPS-controllable
|
||||
// list. Preserves the UI tree's traversal order, which matches the order
|
||||
// the playground's faustJsonToParamMeta() parser will produce.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildZoneIndexFromJson(faustJson) {
|
||||
const HIDDEN_TAIL = new Set(['freq', 'gate', '_vel']);
|
||||
const zones = [];
|
||||
const byLabel = {};
|
||||
const hidden = {};
|
||||
|
||||
const walk = (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
for (const item of items) {
|
||||
const type = item?.type;
|
||||
if (type === 'hslider' || type === 'vslider' ||
|
||||
type === 'nentry' || type === 'button' ||
|
||||
type === 'checkbox') {
|
||||
const label = item.label ?? '';
|
||||
const idx = item.index;
|
||||
if (typeof idx !== 'number') continue;
|
||||
|
||||
const hasHiddenMeta = Array.isArray(item.meta) &&
|
||||
item.meta.some(m => m.hidden === '1' || m.hidden === 1);
|
||||
const tail = label.includes('/') ? label.split('/').pop() : label;
|
||||
|
||||
if (hasHiddenMeta || HIDDEN_TAIL.has(tail)) {
|
||||
hidden[tail] = idx;
|
||||
} else {
|
||||
zones.push(idx);
|
||||
byLabel[label] = idx;
|
||||
}
|
||||
} else if (item?.items) {
|
||||
walk(item.items);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(faustJson.ui ?? []);
|
||||
|
||||
this._paramZones = zones;
|
||||
this._paramZonesByLabel = byLabel;
|
||||
this._hiddenZones = hidden;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _onSetParam — look up the zone by NISPS index and poke the DSP
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_onSetParam(index, value) {
|
||||
if (!this._dspInst) return;
|
||||
const zone = this._paramZones[index];
|
||||
if (zone === undefined) return;
|
||||
this._dspInst.exports.setParamValue(DSP, zone, value);
|
||||
}
|
||||
|
||||
_onNoteOn(freq, vel) {
|
||||
if (!this._dspInst) return;
|
||||
const ex = this._dspInst.exports;
|
||||
const z = this._hiddenZones;
|
||||
if (z.freq !== undefined) ex.setParamValue(DSP, z.freq, freq);
|
||||
if (z._vel !== undefined) ex.setParamValue(DSP, z._vel, vel ?? 0.7);
|
||||
if (z.gate !== undefined) ex.setParamValue(DSP, z.gate, 1.0);
|
||||
}
|
||||
|
||||
_onNoteOff(_freq) {
|
||||
if (!this._dspInst) return;
|
||||
const z = this._hiddenZones;
|
||||
if (z.gate !== undefined) {
|
||||
this._dspInst.exports.setParamValue(DSP, z.gate, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Extended message handling — override for setByLabel + init-with-json
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_handleMessage(msg) {
|
||||
if (!msg || !msg.type) return;
|
||||
|
||||
if (msg.type === 'init') {
|
||||
// Override the base class's init path so we can thread `uiJson` through.
|
||||
this._initWasm(msg.wasmBytes, msg.sampleRate || sampleRate, msg.uiJson)
|
||||
.then(() => {
|
||||
this._ready = true;
|
||||
this.port.postMessage({ type: 'ready' });
|
||||
this._drainPendingMessages();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.port.postMessage({ type: 'error', message: String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer anything that arrives before init() finishes, so the main
|
||||
// thread can disable slots / push matrix values eagerly without worrying
|
||||
// about worklet readiness. See faust-worklet-processor.js.
|
||||
if (this._queueIfNotReady(msg)) return;
|
||||
|
||||
if (msg.type === 'setByLabel') {
|
||||
const zone = this._paramZonesByLabel[msg.label];
|
||||
if (zone !== undefined) {
|
||||
this._dspInst.exports.setParamValue(DSP, zone, msg.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate everything else (setParam, noteOn, noteOff) to the base class.
|
||||
// Note: the base class also calls _queueIfNotReady, but since we already
|
||||
// passed it above, we won't re-queue here.
|
||||
super._handleMessage(msg);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// _renderBlock — call Faust compute, copy output buffers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_renderBlock(outL, outR, blockSize) {
|
||||
if (!this._dspInst || !this._dspMemory) return;
|
||||
|
||||
const ex = this._dspInst.exports;
|
||||
ex.compute(DSP, blockSize, 0, this._outPtrsAddr);
|
||||
|
||||
const wL = new Float32Array(ex.memory.buffer, this._outLAddr, blockSize);
|
||||
const wR = new Float32Array(ex.memory.buffer, this._outRAddr, blockSize);
|
||||
outL.set(wL);
|
||||
outR.set(wR);
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('modular-subtractive-processor', ModularSubtractiveProcessor);
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Modular Subtractive — Smoke Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 720px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
}
|
||||
h1 { margin-bottom: 0.5rem; }
|
||||
.sub { color: #aaa; margin-top: 0; }
|
||||
button {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.7rem 1.4rem;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid #555;
|
||||
background: #222;
|
||||
color: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #333; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.big { font-size: 1.4rem; padding: 1rem 2rem; background: #2a5; }
|
||||
.big:hover { background: #3c7; }
|
||||
#log {
|
||||
margin-top: 1rem;
|
||||
padding: 0.8rem;
|
||||
background: #000;
|
||||
border: 1px solid #333;
|
||||
border-radius: 0.4rem;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
#meter {
|
||||
display: inline-block;
|
||||
width: 260px;
|
||||
height: 22px;
|
||||
background: #111;
|
||||
border: 1px solid #555;
|
||||
vertical-align: middle;
|
||||
border-radius: 0.2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
#meter-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, #2a5, #fd5 70%, #f44 90%);
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>modular-subtractive smoke test</h1>
|
||||
<p class="sub">Phase A gate. Loads the worklet, installs a default patch (ADSR 1 → amp), plays a 1 s note.</p>
|
||||
|
||||
<button id="init">1. Initialise audio</button>
|
||||
<button id="patch" disabled>2. Apply default patch</button>
|
||||
<button id="play" class="big" disabled>3. Play note (440 Hz, 1 s)</button>
|
||||
|
||||
<p>
|
||||
Output level: <span id="meter"><span id="meter-fill"></span></span>
|
||||
<span id="meter-val" style="font-family:monospace; color:#aaa;"> 0.000</span>
|
||||
</p>
|
||||
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
const logEl = document.getElementById('log');
|
||||
const log = (...args) => {
|
||||
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
||||
console.log(...args);
|
||||
logEl.textContent += msg + '\n';
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
};
|
||||
|
||||
let audioCtx = null;
|
||||
let workletNode = null;
|
||||
let analyser = null;
|
||||
let meterRAF = null;
|
||||
|
||||
const btnInit = document.getElementById('init');
|
||||
const btnPatch = document.getElementById('patch');
|
||||
const btnPlay = document.getElementById('play');
|
||||
|
||||
btnInit.addEventListener('click', async () => {
|
||||
btnInit.disabled = true;
|
||||
try {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
await audioCtx.resume();
|
||||
log('AudioContext created, state=' + audioCtx.state + ', sr=' + audioCtx.sampleRate);
|
||||
|
||||
// Load base class (defines FaustWorkletProcessor in worklet scope)
|
||||
await audioCtx.audioWorklet.addModule('faust-worklet-processor.js');
|
||||
log('Loaded faust-worklet-processor.js');
|
||||
|
||||
// Load engine-specific processor
|
||||
await audioCtx.audioWorklet.addModule('modular-subtractive-processor.js');
|
||||
log('Loaded modular-subtractive-processor.js');
|
||||
|
||||
workletNode = new AudioWorkletNode(audioCtx, 'modular-subtractive-processor', {
|
||||
numberOfInputs: 0,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
log('Created AudioWorkletNode');
|
||||
|
||||
// Ready / error messages
|
||||
await new Promise((resolve, reject) => {
|
||||
const to = setTimeout(() => reject(new Error('worklet ready timeout')), 10000);
|
||||
workletNode.port.onmessage = (e) => {
|
||||
if (e.data?.type === 'ready') {
|
||||
clearTimeout(to);
|
||||
log('Worklet reported ready');
|
||||
resolve();
|
||||
} else if (e.data?.type === 'error') {
|
||||
clearTimeout(to);
|
||||
reject(new Error('worklet error: ' + e.data.message));
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch both WASM binary and JSON UI descriptor. We parse the JSON
|
||||
// on the main thread and pass the already-parsed object to the
|
||||
// worklet — the wasm-native JSON has `index` fields that the worklet
|
||||
// uses directly as setParamValue zone addresses.
|
||||
Promise.all([
|
||||
fetch('modular-subtractive.wasm').then(r => r.arrayBuffer()),
|
||||
fetch('modular-subtractive.json').then(r => r.json()),
|
||||
]).then(([bytes, uiJson]) => {
|
||||
const paramCount = countUiParams(uiJson);
|
||||
log('Fetched WASM ' + bytes.byteLength + ' bytes, JSON ' + paramCount + ' params; sending init');
|
||||
workletNode.port.postMessage({
|
||||
type: 'init',
|
||||
wasmBytes: bytes,
|
||||
sampleRate: audioCtx.sampleRate,
|
||||
uiJson,
|
||||
}, [bytes]);
|
||||
}).catch(reject);
|
||||
});
|
||||
|
||||
// Hook up analyser for the meter
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
workletNode.connect(analyser);
|
||||
analyser.connect(audioCtx.destination);
|
||||
|
||||
startMeter();
|
||||
|
||||
btnPatch.disabled = false;
|
||||
log('Ready.');
|
||||
} catch (err) {
|
||||
log('INIT FAILED: ' + (err?.message || err));
|
||||
console.error(err);
|
||||
btnInit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
btnPatch.addEventListener('click', () => {
|
||||
if (!workletNode) return;
|
||||
|
||||
// Default patch:
|
||||
// ADSR 1 enabled with A=0.01, D=0.2, S=0.7, R=0.3
|
||||
// Matrix s00_d08_amp = 1.0 (src00 = adsr01 → dest 8 = amp)
|
||||
//
|
||||
// Uses the setByLabel custom message to bypass paramMeta index ordering
|
||||
// (which is dependent on the faust-param-meta parser and not loaded here).
|
||||
const writes = [
|
||||
// ADSR 1 — enabled by default but re-send to be explicit
|
||||
['MM_ADSR/00_adsr01_enable', 1.0],
|
||||
['MM_ADSR/00_adsr01_attack', 0.01],
|
||||
['MM_ADSR/00_adsr01_decay', 0.2],
|
||||
['MM_ADSR/00_adsr01_sustain', 0.7],
|
||||
['MM_ADSR/00_adsr01_release', 0.3],
|
||||
|
||||
// Matrix: src00 (adsr01) → d08 (amp), amount 1.0
|
||||
['MM_Matrix/s00_d08_amp', 1.0],
|
||||
|
||||
// Engine defaults known sane: set filter cutoff high, reasonable osc1 level
|
||||
['3_Filter/00_cutoff', 3000],
|
||||
['3_Filter/01_resonance', 0.2],
|
||||
['1_Oscillators/02_osc1_level', 0.8],
|
||||
// osc2/3 silenced to keep the smoke test clean
|
||||
['1_Oscillators/06_osc2_level', 0.0],
|
||||
['1_Oscillators/10_osc3_level', 0.0],
|
||||
|
||||
// Master level
|
||||
['4_Master/00_master_level', 0.7],
|
||||
];
|
||||
|
||||
for (const [label, value] of writes) {
|
||||
workletNode.port.postMessage({ type: 'setByLabel', label, value });
|
||||
}
|
||||
log('Applied default patch (' + writes.length + ' writes)');
|
||||
btnPlay.disabled = false;
|
||||
});
|
||||
|
||||
btnPlay.addEventListener('click', () => {
|
||||
if (!workletNode) return;
|
||||
// 440 Hz = MIDI note 69 (A4). Use noteOn/noteOff with freq directly.
|
||||
const freq = 440;
|
||||
workletNode.port.postMessage({ type: 'noteOn', freq, vel: 0.8 });
|
||||
log('noteOn freq=' + freq);
|
||||
setTimeout(() => {
|
||||
workletNode.port.postMessage({ type: 'noteOff', freq });
|
||||
log('noteOff freq=' + freq);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
function countUiParams(json) {
|
||||
let n = 0;
|
||||
const walk = (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
for (const it of items) {
|
||||
if (['hslider','vslider','nentry','button','checkbox'].includes(it.type)) n++;
|
||||
if (it.items) walk(it.items);
|
||||
}
|
||||
};
|
||||
walk(json.ui ?? []);
|
||||
return n;
|
||||
}
|
||||
|
||||
function startMeter() {
|
||||
if (!analyser) return;
|
||||
const fillEl = document.getElementById('meter-fill');
|
||||
const valEl = document.getElementById('meter-val');
|
||||
const buf = new Float32Array(analyser.fftSize);
|
||||
let peakDecay = 0;
|
||||
|
||||
const tick = () => {
|
||||
analyser.getFloatTimeDomainData(buf);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const a = Math.abs(buf[i]);
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
peakDecay = Math.max(peak, peakDecay * 0.92);
|
||||
fillEl.style.width = Math.min(100, peakDecay * 100) + '%';
|
||||
valEl.textContent = ' ' + peakDecay.toFixed(3);
|
||||
meterRAF = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,211 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NISPS Playground</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #111;
|
||||
--border: #1e1e1e;
|
||||
--accent: #ff6a00;
|
||||
--accent-dim: rgba(255, 106, 0, 0.12);
|
||||
--text: #c8c8c8;
|
||||
--text-dim: #606060;
|
||||
--mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Faint grid background */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,106,0,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,106,0,0.02) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 24px 80px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--mono);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 span {
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(255, 106, 0, 0.06), inset 0 0 20px rgba(255, 106, 0, 0.02);
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #080808;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview iframe {
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.card-body h2 {
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-body p {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card:hover .card-body h2 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Desktop: horizontal row */
|
||||
@media (min-width: 860px) {
|
||||
.cards {
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
flex: 1;
|
||||
}
|
||||
.preview {
|
||||
height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large: more breathing room */
|
||||
@media (min-width: 1100px) {
|
||||
.page { max-width: 1100px; }
|
||||
.preview { height: 280px; }
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0d0d0d" />
|
||||
<title>MEMLNaut Playground</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<h1>NISPS Playground
|
||||
<span>Neural Interactive Shaping of Parameter Spaces</span>
|
||||
</h1>
|
||||
<p class="intro">Map a 2D joystick to 126 synthesizer parameters through a neural network. Two learning modes: show examples of what you want, or give thumbs up/down feedback. The network learns to interpolate the space between them.</p>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<a class="card" href="a-immersive.html">
|
||||
<div class="preview">
|
||||
<iframe src="a-immersive.html" loading="lazy" tabindex="-1"></iframe>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>Immersive</h2>
|
||||
<p>Full-screen canvas with floating controls.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="b-workbench.html">
|
||||
<div class="preview">
|
||||
<iframe src="b-workbench.html" loading="lazy" tabindex="-1"></iframe>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>Workbench</h2>
|
||||
<p>Dashboard layout with a 2D heatmap, for understanding the mappings.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="c-journey.html">
|
||||
<div class="preview">
|
||||
<iframe src="c-journey.html" loading="lazy" tabindex="-1"></iframe>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>Journey</h2>
|
||||
<p>Three phases of exploration.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,603 +0,0 @@
|
|||
// NISPS Playground - Main application
|
||||
// Wires IML engine to visual system OR C15 synth with joystick input and unified learning controls
|
||||
|
||||
import { IML } from './nisps/iml.js';
|
||||
import { FlowFieldVisualizer } from './ui/visualizer.js';
|
||||
import { VirtualJoystick } from './ui/joystick.js';
|
||||
import { Controls } from './ui/controls.js';
|
||||
import { ParamDisplay } from './ui/param-display.js';
|
||||
import { C15Bridge } from './synth/c15-bridge.js';
|
||||
import { Arpeggiator } from './synth/arpeggiator.js';
|
||||
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js';
|
||||
import { GamepadInput } from './ui/gamepad.js';
|
||||
|
||||
const N_INPUTS = 2;
|
||||
const N_VISUAL_OUTPUTS = 20;
|
||||
const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126
|
||||
const N_OUTPUTS = N_SYNTH_OUTPUTS; // MLP always produces full output; visual uses first 20
|
||||
|
||||
// Visual mode param display config
|
||||
const VISUAL_PARAM_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate'];
|
||||
const VISUAL_PARAM_COLORS = ['#ff6a00', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff', '#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f', '#ffc15f', '#ff8a3d'];
|
||||
|
||||
// --- State ---
|
||||
let iml;
|
||||
let visualizer;
|
||||
let joystick;
|
||||
let controls;
|
||||
let paramDisplay;
|
||||
let outputMode = 'visual'; // 'visual' | 'synth'
|
||||
let noiseLevel = 0.05;
|
||||
let rlExplorationDecay = 0.97;
|
||||
let animating = true;
|
||||
let gamepad;
|
||||
let gamepadConnected = false;
|
||||
let followMode = false;
|
||||
let visualExpanded = false;
|
||||
let appRoot;
|
||||
let expandVisualBtn;
|
||||
|
||||
// Synth state
|
||||
let c15 = null;
|
||||
let arpeggiator = null;
|
||||
|
||||
// Devmode: tame level (0 = no mitigation, 1 = strongest)
|
||||
// Set via URL ?tame=0.7 or window.setTameLevel(0.7)
|
||||
const _urlParams = new URLSearchParams(location.search);
|
||||
let tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
|
||||
if (isNaN(tameLevel)) tameLevel = 1;
|
||||
tameLevel = Math.max(0, Math.min(1, tameLevel));
|
||||
let spreadLevel = parseFloat(_urlParams.get('spread') ?? '0.6');
|
||||
if (isNaN(spreadLevel)) spreadLevel = 0.6;
|
||||
spreadLevel = Math.max(0, Math.min(1, spreadLevel));
|
||||
window.setTameLevel = (v) => { tameLevel = Math.max(0, Math.min(1, v)); console.log(`[NISPS] tame=${tameLevel}`); };
|
||||
window.getTameLevel = () => tameLevel;
|
||||
|
||||
// --- Init ---
|
||||
function init() {
|
||||
iml = new IML(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
|
||||
iml.setLogger(msg => console.log('[NISPS]', msg));
|
||||
|
||||
// Visualizer
|
||||
appRoot = document.querySelector('.app');
|
||||
expandVisualBtn = document.getElementById('expand-visual-btn');
|
||||
|
||||
const canvas = document.getElementById('visual-canvas');
|
||||
visualizer = new FlowFieldVisualizer(canvas);
|
||||
|
||||
// Joystick
|
||||
joystick = new VirtualJoystick(document.getElementById('joystick-container'), {
|
||||
size: 160,
|
||||
trackpadScale: 2,
|
||||
springBack: false,
|
||||
onChange: onJoystickMove,
|
||||
onFollowModeChange: (enabled) => {
|
||||
followMode = enabled;
|
||||
refreshDashboard();
|
||||
},
|
||||
});
|
||||
|
||||
// Parameter display (start in visual mode with 20 params)
|
||||
paramDisplay = new ParamDisplay(document.getElementById('param-display'), N_VISUAL_OUTPUTS);
|
||||
|
||||
// Controls
|
||||
controls = new Controls(document.getElementById('controls-container'), {
|
||||
onAddExample,
|
||||
onTrain,
|
||||
onRandomize,
|
||||
onClear,
|
||||
onClearExamples,
|
||||
onThumbsUp,
|
||||
onThumbsDown,
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
window.addEventListener('resize', () => {
|
||||
resizeVisualizerSurface();
|
||||
});
|
||||
|
||||
if (expandVisualBtn) {
|
||||
expandVisualBtn.addEventListener('click', () => setVisualExpanded(!visualExpanded));
|
||||
updateExpandButtonState();
|
||||
}
|
||||
|
||||
// Help overlay
|
||||
const helpBtn = document.getElementById('help-btn');
|
||||
const helpOverlay = document.getElementById('help-overlay');
|
||||
if (helpBtn && helpOverlay) {
|
||||
helpBtn.addEventListener('click', () => helpOverlay.classList.toggle('hidden'));
|
||||
helpOverlay.addEventListener('click', () => helpOverlay.classList.add('hidden'));
|
||||
}
|
||||
|
||||
// Side panel
|
||||
initSidePanel();
|
||||
|
||||
// Init synth
|
||||
c15 = new C15Bridge();
|
||||
c15.onStatusChange = (msg) => {
|
||||
const el = document.getElementById('synth-status');
|
||||
if (el) el.textContent = msg;
|
||||
};
|
||||
c15.loadParams();
|
||||
arpeggiator = new Arpeggiator(c15);
|
||||
|
||||
// Wire synth controls
|
||||
initSynthControls();
|
||||
|
||||
gamepad = new GamepadInput({
|
||||
onMove: (x, y) => {
|
||||
if (paramDisplay.activeBar < 0) {
|
||||
joystick.setPosition(x, y, { emit: true, touching: true });
|
||||
}
|
||||
},
|
||||
onButton: (btn) => {
|
||||
if (btn === 'rb') onThumbsUp();
|
||||
if (btn === 'lb') onThumbsDown();
|
||||
},
|
||||
onConnectionChange: (connected) => {
|
||||
gamepadConnected = connected;
|
||||
refreshDashboard();
|
||||
},
|
||||
});
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
// Run initial inference to populate outputs
|
||||
iml.setInput(0, 0.5);
|
||||
iml.setInput(1, 0.5);
|
||||
iml.process();
|
||||
visualizer.setParams(iml.getOutputs());
|
||||
paramDisplay.update(iml.getOutputs());
|
||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||
controls.updateLossPlot(iml.lossHistory);
|
||||
refreshDashboard();
|
||||
|
||||
// Start animation
|
||||
animate();
|
||||
|
||||
// Load from localStorage if available
|
||||
loadState();
|
||||
}
|
||||
|
||||
// --- Side Panel ---
|
||||
function initSidePanel() {
|
||||
const panel = document.getElementById('side-panel');
|
||||
const toggle = document.getElementById('side-panel-toggle');
|
||||
const close = document.getElementById('side-panel-close');
|
||||
const backdrop = document.getElementById('side-panel-backdrop');
|
||||
|
||||
const openPanel = () => {
|
||||
panel.classList.add('open');
|
||||
backdrop.classList.remove('hidden');
|
||||
};
|
||||
const closePanel = () => {
|
||||
panel.classList.remove('open');
|
||||
backdrop.classList.add('hidden');
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', openPanel);
|
||||
close.addEventListener('click', closePanel);
|
||||
backdrop.addEventListener('click', closePanel);
|
||||
|
||||
// Tab switching
|
||||
panel.querySelectorAll('.sp-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const mode = tab.dataset.mode;
|
||||
setOutputMode(mode);
|
||||
|
||||
panel.querySelectorAll('.sp-tab').forEach(t => t.classList.toggle('active', t === tab));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setOutputMode(mode) {
|
||||
outputMode = mode;
|
||||
|
||||
const synthControls = document.getElementById('synth-controls');
|
||||
const visualInfo = document.getElementById('visual-info');
|
||||
const presetsVisual = document.getElementById('presets-visual');
|
||||
const modeBadge = document.getElementById('mode-badge');
|
||||
const paramContainer = document.getElementById('param-display');
|
||||
|
||||
if (mode === 'synth') {
|
||||
synthControls.classList.remove('hidden');
|
||||
visualInfo.classList.add('hidden');
|
||||
presetsVisual.classList.add('hidden');
|
||||
modeBadge.textContent = 'Synth';
|
||||
modeBadge.classList.add('synth');
|
||||
paramContainer.classList.add('synth-mode');
|
||||
|
||||
// Rebuild param display with all synth params
|
||||
paramDisplay.rebuild(N_SYNTH_OUTPUTS, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS);
|
||||
paramDisplay.setDraggable(true);
|
||||
} else {
|
||||
synthControls.classList.add('hidden');
|
||||
visualInfo.classList.remove('hidden');
|
||||
presetsVisual.classList.remove('hidden');
|
||||
modeBadge.textContent = 'Visual';
|
||||
modeBadge.classList.remove('synth');
|
||||
paramContainer.classList.remove('synth-mode');
|
||||
|
||||
// Rebuild param display with visual params (first 20)
|
||||
paramDisplay.rebuild(N_VISUAL_OUTPUTS, VISUAL_PARAM_NAMES, VISUAL_PARAM_COLORS);
|
||||
paramDisplay.setDraggable(true);
|
||||
}
|
||||
|
||||
// Re-run inference and route outputs
|
||||
routeOutputs(iml.getOutputs());
|
||||
}
|
||||
|
||||
function routeOutputs(outputs) {
|
||||
// Always update visualizer (it's always visible)
|
||||
visualizer.setParams(outputs);
|
||||
|
||||
// If in synth mode, also send to C15 with tame-level constraining
|
||||
if (outputMode === 'synth' && c15 && c15.running) {
|
||||
for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) {
|
||||
const value = applyTame(outputs[i], SYNTH_PARAM_MAP[i], tameLevel);
|
||||
c15.setParameter(SYNTH_PARAM_MAP[i].id, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Synth Controls ---
|
||||
function initSynthControls() {
|
||||
const startBtn = document.getElementById('synth-start');
|
||||
const volumeSlider = document.getElementById('synth-volume');
|
||||
const arpToggle = document.getElementById('arp-toggle');
|
||||
const arpProgression = document.getElementById('arp-progression');
|
||||
const arpTempo = document.getElementById('arp-tempo');
|
||||
const arpOctaves = document.getElementById('arp-octaves');
|
||||
const arpOffset = document.getElementById('arp-offset');
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
if (c15.running) {
|
||||
arpeggiator.stop();
|
||||
arpToggle.textContent = 'Play';
|
||||
arpToggle.classList.remove('playing');
|
||||
await c15.stop();
|
||||
startBtn.textContent = 'Start Audio';
|
||||
} else {
|
||||
await c15.start();
|
||||
startBtn.textContent = 'Stop Audio';
|
||||
// Send current NISPS outputs to synth
|
||||
routeOutputs(iml.getOutputs());
|
||||
}
|
||||
});
|
||||
|
||||
volumeSlider.addEventListener('input', (e) => {
|
||||
c15.setMasterVolume(parseFloat(e.target.value));
|
||||
});
|
||||
|
||||
arpToggle.addEventListener('click', () => {
|
||||
if (!c15.running) return;
|
||||
if (arpeggiator.playing) {
|
||||
arpeggiator.stop();
|
||||
arpToggle.textContent = 'Play';
|
||||
arpToggle.classList.remove('playing');
|
||||
} else {
|
||||
arpeggiator.start();
|
||||
arpToggle.textContent = 'Stop';
|
||||
arpToggle.classList.add('playing');
|
||||
}
|
||||
});
|
||||
|
||||
arpProgression.addEventListener('change', (e) => {
|
||||
arpeggiator.progression = e.target.value;
|
||||
});
|
||||
|
||||
arpTempo.addEventListener('input', (e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
arpeggiator.bpm = val;
|
||||
document.getElementById('tempo-val').textContent = val;
|
||||
});
|
||||
|
||||
arpOctaves.addEventListener('input', (e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
arpeggiator.octaves = val;
|
||||
document.getElementById('octaves-val').textContent = val;
|
||||
});
|
||||
|
||||
arpOffset.addEventListener('input', (e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
arpeggiator.octaveOffset = val;
|
||||
document.getElementById('offset-val').textContent = val;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Animation loop ---
|
||||
function animate() {
|
||||
if (!animating) return;
|
||||
gamepad.poll();
|
||||
visualizer.draw();
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// --- Joystick handler ---
|
||||
function onJoystickMove(x, y) {
|
||||
iml.setInput(0, x);
|
||||
iml.setInput(1, y);
|
||||
iml.process();
|
||||
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
|
||||
// Only update param display from network in inference (not when user is dragging)
|
||||
if (paramDisplay.activeBar < 0) {
|
||||
paramDisplay.update(outputs);
|
||||
}
|
||||
|
||||
refreshDashboard();
|
||||
}
|
||||
|
||||
// --- Examples mode callbacks ---
|
||||
function onAddExample() {
|
||||
// Use current joystick position as input, param bar values as desired output
|
||||
const inputs = [joystick.x, joystick.y];
|
||||
const outputs = [...paramDisplay.values];
|
||||
iml.addExample(inputs, outputs);
|
||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||
refreshDashboard();
|
||||
flash('btn-add');
|
||||
}
|
||||
|
||||
function onTrain() {
|
||||
const loss = trainModel();
|
||||
if (loss !== null) {
|
||||
// After training, switch back to inference and update display
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
paramDisplay.update(outputs);
|
||||
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
|
||||
controls.updateLossPlot(iml.lossHistory);
|
||||
refreshDashboard();
|
||||
flash('btn-train');
|
||||
}
|
||||
}
|
||||
|
||||
function onRandomize() {
|
||||
iml.randomiseWeights(spreadLevel);
|
||||
iml.setInput(0, joystick.x);
|
||||
iml.setInput(1, joystick.y);
|
||||
iml.process();
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
paramDisplay.update(outputs);
|
||||
noiseLevel = 0.05; // reset noise
|
||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||
refreshDashboard();
|
||||
}
|
||||
|
||||
function onClearExamples() {
|
||||
iml.clearDataset();
|
||||
controls.updateStatus(0, iml.lastLoss, noiseLevel);
|
||||
refreshDashboard();
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
iml.clearDataset();
|
||||
iml.lossHistory = [];
|
||||
iml.bestLoss = null;
|
||||
iml.totalTrainingIterations = 0;
|
||||
noiseLevel = 0.05;
|
||||
controls.updateStatus(0, null, noiseLevel);
|
||||
controls.updateLossPlot(iml.lossHistory);
|
||||
refreshDashboard();
|
||||
clearState();
|
||||
}
|
||||
|
||||
// --- RL mode callbacks ---
|
||||
function onThumbsUp() {
|
||||
// Save current input->output mapping as a positive example
|
||||
const inputs = [joystick.x, joystick.y];
|
||||
const outputs = [...iml.getOutputs()];
|
||||
iml.addExample(inputs, outputs);
|
||||
|
||||
// Retrain incrementally
|
||||
trainModel();
|
||||
const trainedOutputs = iml.getOutputs();
|
||||
routeOutputs(trainedOutputs);
|
||||
paramDisplay.update(trainedOutputs);
|
||||
|
||||
// Decay noise - more positive examples = less exploration
|
||||
noiseLevel *= rlExplorationDecay;
|
||||
noiseLevel = Math.max(noiseLevel, 0.005);
|
||||
|
||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||
controls.updateLossPlot(iml.lossHistory);
|
||||
refreshDashboard();
|
||||
flash('btn-thumbsup');
|
||||
}
|
||||
|
||||
function onThumbsDown() {
|
||||
// Increase noise for more exploration
|
||||
// spread reduces the noise cap: at spread=1 cap is 0.05 (vs 0.3 at spread=0)
|
||||
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
|
||||
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
|
||||
|
||||
// Perturb weights (spread scales noise per-layer by 1/sqrt(fan_in))
|
||||
iml.moveWeights(noiseLevel, spreadLevel);
|
||||
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
paramDisplay.update(outputs);
|
||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||
refreshDashboard();
|
||||
flash('btn-thumbsdown');
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Escape' && visualExpanded) {
|
||||
setVisualExpanded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!followMode || e.repeat) return;
|
||||
const tag = e.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
||||
if (e.key === '1' || e.code === 'Numpad1') {
|
||||
e.preventDefault();
|
||||
onThumbsDown();
|
||||
} else if (e.key === '2' || e.code === 'Numpad2') {
|
||||
e.preventDefault();
|
||||
onThumbsUp();
|
||||
}
|
||||
}
|
||||
|
||||
function resizeVisualizerSurface() {
|
||||
visualizer.resize();
|
||||
visualizer.initParticles();
|
||||
}
|
||||
|
||||
function updateExpandButtonState() {
|
||||
if (!expandVisualBtn) return;
|
||||
expandVisualBtn.textContent = visualExpanded ? 'Collapse' : 'Expand';
|
||||
expandVisualBtn.title = visualExpanded ? 'Collapse visual area' : 'Expand visual area';
|
||||
expandVisualBtn.setAttribute('aria-pressed', visualExpanded ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function setVisualExpanded(expanded) {
|
||||
visualExpanded = expanded;
|
||||
if (appRoot) {
|
||||
appRoot.classList.toggle('expanded', visualExpanded);
|
||||
}
|
||||
updateExpandButtonState();
|
||||
|
||||
// Let CSS layout settle before resizing the drawing surface.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
resizeVisualizerSurface();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Presets ---
|
||||
// Pad a 20-element visual preset array to N_OUTPUTS with 0.5 defaults
|
||||
function padPreset(values20) {
|
||||
const padded = new Array(N_OUTPUTS).fill(0.5);
|
||||
for (let i = 0; i < values20.length; i++) padded[i] = values20[i];
|
||||
return padded;
|
||||
}
|
||||
|
||||
window.loadPreset = function(name) {
|
||||
iml.clearDataset();
|
||||
|
||||
if (name === 'calm-to-chaotic') {
|
||||
// Bottom-left: slow, smooth, cool; top-right: fast, turbulent, warm
|
||||
iml.addExample([0.1, 0.9], padPreset([0.25, 0.3, 0.1, 0.55, 0.2, 0.3, 0.02, 0.05, 0.9, 0.45, 0.25, 0.2, 0.9, 0.0, 0.0, 0.2, 0.05, 0.0, 0.0, 0.2]));
|
||||
iml.addExample([0.9, 0.1], padPreset([0.75, 0.7, 0.9, 0.05, 0.8, 0.7, 0.9, 0.95, 0.3, 0.2, 0.85, 0.7, 0.25, 0.55, 1.0, 0.92, 0.02, 0.95, 0.8, 0.85]));
|
||||
iml.addExample([0.5, 0.5], padPreset([0.5, 0.5, 0.5, 0.3, 0.5, 0.5, 0.4, 0.5, 0.7, 0.6, 0.5, 0.45, 0.55, 0.9, 0.5, 0.65, 0.08, 0.45, 0.4, 0.5]));
|
||||
} else if (name === 'rainbow-sweep') {
|
||||
// Left to right sweeps through hues
|
||||
iml.addExample([0.0, 0.5], padPreset([0.5, 0.5, 0.4, 0.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.4, 0.3, 0.8, 0.0, 0.0, 0.45, 0.08, 0.2, 0.25, 0.45]));
|
||||
iml.addExample([0.5, 0.5], padPreset([0.5, 0.5, 0.4, 0.5, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.55, 0.35, 0.7, 0.0, 0.4, 0.45, 0.08, 0.45, 0.4, 0.6]));
|
||||
iml.addExample([1.0, 0.5], padPreset([0.5, 0.5, 0.4, 1.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.75, 0.45, 0.6, 0.0, 0.8, 0.45, 0.08, 0.7, 0.55, 0.75]));
|
||||
} else if (name === 'vortex') {
|
||||
// Center: tight spiral, edges: wide flow
|
||||
iml.addExample([0.5, 0.5], padPreset([0.0, 0.8, 0.8, 0.6, 0.1, 0.15, 0.02, 1.0, 1.0, 0.3, 0.95, 0.85, 0.25, 1.0, 0.5, 0.95, 0.01, 1.0, 1.0, 1.0]));
|
||||
iml.addExample([0.0, 0.0], padPreset([0.5, 0.2, 0.3, 0.8, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15, 0.8, 0.5, 0.2, 0.35, 0.2, 0.15, 0.2, 0.25]));
|
||||
iml.addExample([1.0, 1.0], padPreset([0.5, 0.2, 0.3, 0.2, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15, 0.8, 0.5, 0.8, 0.35, 0.2, 0.15, 0.2, 0.25]));
|
||||
iml.addExample([0.0, 1.0], padPreset([0.3, 0.4, 0.5, 0.4, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45, 0.45, 0.2, 0.4, 0.7, 0.1, 0.5, 0.4, 0.55]));
|
||||
iml.addExample([1.0, 0.0], padPreset([0.7, 0.4, 0.5, 0.0, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45, 0.45, 0.2, 0.9, 0.7, 0.1, 0.5, 0.4, 0.55]));
|
||||
}
|
||||
|
||||
const loss = trainModel();
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
paramDisplay.update(outputs);
|
||||
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
|
||||
controls.updateLossPlot(iml.lossHistory);
|
||||
refreshDashboard();
|
||||
};
|
||||
|
||||
// --- Persistence ---
|
||||
function saveState() {
|
||||
try {
|
||||
const state = {
|
||||
features: iml.dataset.features,
|
||||
labels: iml.dataset.labels,
|
||||
};
|
||||
localStorage.setItem('nisps-playground', JSON.stringify(state));
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const data = JSON.parse(localStorage.getItem('nisps-playground'));
|
||||
if (data && data.features && data.features.length > 0) {
|
||||
for (let i = 0; i < data.features.length; i++) {
|
||||
// Pad old saves (20 outputs) to current N_OUTPUTS with 0.5 defaults
|
||||
const labels = data.labels[i];
|
||||
if (labels.length < N_OUTPUTS) {
|
||||
while (labels.length < N_OUTPUTS) labels.push(0.5);
|
||||
}
|
||||
iml.addExample(data.features[i], labels);
|
||||
}
|
||||
trainModel();
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
paramDisplay.update(outputs);
|
||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||
controls.updateLossPlot(iml.lossHistory);
|
||||
refreshDashboard();
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function clearState() {
|
||||
try { localStorage.removeItem('nisps-playground'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Auto-save periodically
|
||||
setInterval(saveState, 10000);
|
||||
|
||||
// Visual feedback flash
|
||||
function flash(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.add('flash');
|
||||
setTimeout(() => el.classList.remove('flash'), 200);
|
||||
}
|
||||
|
||||
function trainModel() {
|
||||
let lastPlotUpdate = 0;
|
||||
const loss = iml.train({
|
||||
onIteration: (iter, iterLoss) => {
|
||||
if (iter - lastPlotUpdate < 8) return;
|
||||
lastPlotUpdate = iter;
|
||||
controls.updateLossPlot([...iml.lossHistory, iterLoss]);
|
||||
},
|
||||
});
|
||||
return loss;
|
||||
}
|
||||
|
||||
function refreshDashboard() {
|
||||
const outputs = iml.getOutputs();
|
||||
let mean = 0;
|
||||
for (let i = 0; i < outputs.length; i++) mean += outputs[i];
|
||||
mean /= Math.max(outputs.length, 1);
|
||||
|
||||
let variance = 0;
|
||||
for (let i = 0; i < outputs.length; i++) {
|
||||
const diff = outputs[i] - mean;
|
||||
variance += diff * diff;
|
||||
}
|
||||
variance /= Math.max(outputs.length, 1);
|
||||
|
||||
controls.updateMetrics({
|
||||
joystickX: joystick.x,
|
||||
joystickY: joystick.y,
|
||||
outputMean: mean,
|
||||
outputSpread: Math.sqrt(variance),
|
||||
bestLoss: iml.bestLoss,
|
||||
totalTrainingIterations: iml.totalTrainingIterations,
|
||||
gamepadConnected,
|
||||
followMode,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Start ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
// Always enable param dragging
|
||||
paramDisplay.setDraggable(true);
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,347 +0,0 @@
|
|||
// eoc-chain.js — End-of-Chain container.
|
||||
//
|
||||
// Manages an ordered list of EOCModule instances, wires them into the Web Audio
|
||||
// graph, and exposes a flat NISPS parameter surface across all enabled modules.
|
||||
//
|
||||
// Canonical module order (used as insertion default, not enforced):
|
||||
// Saturation → EQ → Compressor → Reverb → Delay → Master Bus
|
||||
//
|
||||
// Usage:
|
||||
// const chain = new EOCChain();
|
||||
// await chain.init(audioCtx);
|
||||
// chain.addModule(new SaturationModule());
|
||||
// chain.addModule(new ReverbModule());
|
||||
// chain.connect(synthOutputNode, audioCtx.destination);
|
||||
//
|
||||
// The chain dispatches 'eoc:change' CustomEvents on window whenever the
|
||||
// structure changes (add/remove/reorder/bypass/nispsMode).
|
||||
|
||||
import { EOCModule } from './eoc-module.js';
|
||||
|
||||
// Default slot order — used to sort modules by type when no explicit position
|
||||
// is given. Modules not in this list are appended after the last known slot.
|
||||
const DEFAULT_ORDER = ['saturation', 'eq', 'compressor', 'reverb', 'delay', 'master'];
|
||||
|
||||
export class EOCChain {
|
||||
constructor() {
|
||||
/** @type {EOCModule[]} */
|
||||
this._modules = [];
|
||||
this._audioCtx = null;
|
||||
this._inputNode = null; // stored after first connect() call
|
||||
this._outputNode = null; // stored after first connect() call
|
||||
this._initialized = false;
|
||||
|
||||
// NISPS integration mode.
|
||||
// 'bypass' — EOC params not used by NISPS at all (default)
|
||||
// 'shared' — EOC params appended to synth param pool (future)
|
||||
// 'linked' — EOC params driven by a separate, linked NISPS instance (future)
|
||||
// 'independent'— EOC has its own independent NISPS instance (future)
|
||||
this._nispsMode = 'bypass';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Insert a module at a given position. If no position is given, the module
|
||||
* is appended in DEFAULT_ORDER slot sequence.
|
||||
*
|
||||
* @param {EOCModule} module
|
||||
* @param {number} [position] — 0-based insertion index (optional)
|
||||
*/
|
||||
addModule(module, position) {
|
||||
if (!(module instanceof EOCModule)) {
|
||||
throw new TypeError('EOCChain.addModule: argument must be an EOCModule instance');
|
||||
}
|
||||
if (this._modules.some(m => m.id === module.id)) {
|
||||
throw new Error(`EOCChain.addModule: module '${module.id}' is already in the chain`);
|
||||
}
|
||||
|
||||
if (position !== undefined) {
|
||||
this._modules.splice(Math.max(0, Math.min(position, this._modules.length)), 0, module);
|
||||
} else {
|
||||
// Insert at canonical slot position
|
||||
const targetSlot = DEFAULT_ORDER.indexOf(module.id);
|
||||
if (targetSlot === -1) {
|
||||
// Unknown module type — append at end
|
||||
this._modules.push(module);
|
||||
} else {
|
||||
// Find the first existing module whose canonical slot is after this one
|
||||
const insertAt = this._modules.findIndex(m => {
|
||||
const slot = DEFAULT_ORDER.indexOf(m.id);
|
||||
return slot === -1 || slot > targetSlot;
|
||||
});
|
||||
if (insertAt === -1) {
|
||||
this._modules.push(module);
|
||||
} else {
|
||||
this._modules.splice(insertAt, 0, module);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the chain is already initialised, init the new module immediately
|
||||
if (this._initialized && this._audioCtx) {
|
||||
module.init(this._audioCtx).then(() => {
|
||||
this._rewire();
|
||||
this._dispatchChange('module-added');
|
||||
});
|
||||
} else {
|
||||
this._dispatchChange('module-added');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a module from the chain by id.
|
||||
* Disposes the module and rewires.
|
||||
*
|
||||
* @param {string} id
|
||||
*/
|
||||
removeModule(id) {
|
||||
const idx = this._modules.findIndex(m => m.id === id);
|
||||
if (idx === -1) {
|
||||
console.warn(`EOCChain.removeModule: no module with id '${id}'`);
|
||||
return;
|
||||
}
|
||||
const [removed] = this._modules.splice(idx, 1);
|
||||
removed.dispose();
|
||||
this._rewire();
|
||||
this._dispatchChange('module-removed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a module to a new position.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {number} newPosition — 0-based target index (after removal)
|
||||
*/
|
||||
moveModule(id, newPosition) {
|
||||
const idx = this._modules.findIndex(m => m.id === id);
|
||||
if (idx === -1) {
|
||||
console.warn(`EOCChain.moveModule: no module with id '${id}'`);
|
||||
return;
|
||||
}
|
||||
const [module] = this._modules.splice(idx, 1);
|
||||
const clampedPos = Math.max(0, Math.min(newPosition, this._modules.length));
|
||||
this._modules.splice(clampedPos, 0, module);
|
||||
this._rewire();
|
||||
this._dispatchChange('module-moved');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a module by id.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {EOCModule|undefined}
|
||||
*/
|
||||
getModule(id) {
|
||||
return this._modules.find(m => m.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered array of all modules in the chain (enabled and bypassed).
|
||||
* @returns {EOCModule[]}
|
||||
*/
|
||||
get modules() {
|
||||
return [...this._modules];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialize all currently registered modules, then store the AudioContext.
|
||||
* Safe to call before or after addModule() calls.
|
||||
*
|
||||
* @param {AudioContext} audioCtx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init(audioCtx) {
|
||||
this._audioCtx = audioCtx;
|
||||
await Promise.all(this._modules.map(m => m.init(audioCtx)));
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all modules and disconnect the chain.
|
||||
*/
|
||||
dispose() {
|
||||
this._disconnect();
|
||||
this._modules.forEach(m => m.dispose());
|
||||
this._modules = [];
|
||||
this._audioCtx = null;
|
||||
this._inputNode = null;
|
||||
this._outputNode = null;
|
||||
this._initialized = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio graph wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wire the full chain into the audio graph:
|
||||
* inputNode → [enabled modules in order] → outputNode
|
||||
*
|
||||
* If no modules are enabled, inputNode connects directly to outputNode (all-bypass).
|
||||
*
|
||||
* Stores inputNode/outputNode for _rewire() calls triggered by later
|
||||
* structural changes (add/remove/reorder/bypass toggle).
|
||||
*
|
||||
* @param {AudioNode} inputNode — upstream node (e.g. synth output GainNode)
|
||||
* @param {AudioNode} outputNode — downstream node (e.g. AudioContext.destination)
|
||||
*/
|
||||
connect(inputNode, outputNode) {
|
||||
this._inputNode = inputNode;
|
||||
this._outputNode = outputNode;
|
||||
this._rewire();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NISPS integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Current NISPS integration mode.
|
||||
* @returns {'bypass'|'shared'|'linked'|'independent'}
|
||||
*/
|
||||
get nispsMode() {
|
||||
return this._nispsMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set NISPS integration mode.
|
||||
* Only stores the value and dispatches an event; actual wiring is handled
|
||||
* by the tasks that implement each mode (meml-xp3 and later).
|
||||
*
|
||||
* @param {'bypass'|'shared'|'linked'|'independent'} mode
|
||||
*/
|
||||
set nispsMode(mode) {
|
||||
const valid = ['bypass', 'shared', 'linked', 'independent'];
|
||||
if (!valid.includes(mode)) {
|
||||
throw new Error(`EOCChain.nispsMode: invalid mode '${mode}'. Must be one of: ${valid.join(', ')}`);
|
||||
}
|
||||
this._nispsMode = mode;
|
||||
this._dispatchChange('nispsMode-changed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Total parameter count across all enabled modules.
|
||||
* @returns {number}
|
||||
*/
|
||||
get paramCount() {
|
||||
return this._enabledModules().reduce((sum, m) => sum + m.paramCount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat parameter metadata array across all enabled modules, in chain order.
|
||||
* Each entry carries an additional `_moduleId` field for routing.
|
||||
*
|
||||
* @returns {Array<{id:string, name:string, min:number, max:number, init:number, curve:number, group:string, _moduleId:string}>}
|
||||
*/
|
||||
get paramMeta() {
|
||||
const result = [];
|
||||
for (const module of this._enabledModules()) {
|
||||
for (const meta of module.paramMeta) {
|
||||
result.push({ ...meta, _moduleId: module.id });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a normalized [0,1] value to the correct module by global index.
|
||||
*
|
||||
* @param {number} globalIndex — flat index across all enabled modules' params
|
||||
* @param {number} value — [0, 1]
|
||||
*/
|
||||
setParam(globalIndex, value) {
|
||||
let offset = 0;
|
||||
for (const module of this._enabledModules()) {
|
||||
if (globalIndex < offset + module.paramCount) {
|
||||
module.setParam(globalIndex - offset, value);
|
||||
return;
|
||||
}
|
||||
offset += module.paramCount;
|
||||
}
|
||||
console.warn(`EOCChain.setParam: globalIndex ${globalIndex} out of range (paramCount=${this.paramCount})`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return only the currently enabled modules, in chain order.
|
||||
* @returns {EOCModule[]}
|
||||
*/
|
||||
_enabledModules() {
|
||||
return this._modules.filter(m => m.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all inter-module and endpoint connections, then reconnect
|
||||
* in the current order. Safe to call at any time after connect() has been
|
||||
* called at least once.
|
||||
*/
|
||||
_rewire() {
|
||||
if (!this._inputNode || !this._outputNode) return;
|
||||
|
||||
// Tear down: disconnect the input node and each module's output node.
|
||||
// We only disconnect from the nodes we own in the chain to avoid clobbering
|
||||
// any other connections the caller may have set up on inputNode/outputNode.
|
||||
try { this._inputNode.disconnect(); } catch (_) { /* not yet connected */ }
|
||||
for (const m of this._modules) {
|
||||
try { m.getOutputNode().disconnect(); } catch (_) { /* not yet connected */ }
|
||||
}
|
||||
|
||||
const active = this._enabledModules();
|
||||
|
||||
if (active.length === 0) {
|
||||
// All modules disabled (or chain is empty): straight wire
|
||||
this._inputNode.connect(this._outputNode);
|
||||
return;
|
||||
}
|
||||
|
||||
// inputNode → first module
|
||||
this._inputNode.connect(active[0].getInputNode());
|
||||
|
||||
// module[i] → module[i+1]
|
||||
for (let i = 0; i < active.length - 1; i++) {
|
||||
active[i].getOutputNode().connect(active[i + 1].getInputNode());
|
||||
}
|
||||
|
||||
// last module → outputNode
|
||||
active[active.length - 1].getOutputNode().connect(this._outputNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect everything the chain owns.
|
||||
* Called during dispose().
|
||||
*/
|
||||
_disconnect() {
|
||||
if (!this._inputNode) return;
|
||||
try { this._inputNode.disconnect(); } catch (_) { /* ok */ }
|
||||
for (const m of this._modules) {
|
||||
try { m.getOutputNode().disconnect(); } catch (_) { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an 'eoc:change' CustomEvent on window.
|
||||
*
|
||||
* @param {string} reason — short description of what changed
|
||||
*/
|
||||
_dispatchChange(reason) {
|
||||
window.dispatchEvent(new CustomEvent('eoc:change', {
|
||||
detail: {
|
||||
reason,
|
||||
chain: this,
|
||||
modules: this.modules,
|
||||
paramCount: this.paramCount,
|
||||
nispsMode: this.nispsMode,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
// eoc-module.js — Base class for a single End-of-Chain effect module.
|
||||
//
|
||||
// All EOC effect modules extend this class. The contract mirrors the SynthEngine
|
||||
// interface pattern (engine-interface.js) so both layers feel consistent.
|
||||
//
|
||||
// Subclasses MUST override:
|
||||
// get id() — stable machine ID
|
||||
// get displayName() — human label
|
||||
// get paramMeta() — [{id, name, min, max, init, curve, group}]
|
||||
// async init(audioCtx) — create AudioNodes, call super.init() first
|
||||
//
|
||||
// Subclasses MAY override:
|
||||
// setParam(index, normalizedValue)
|
||||
// dispose()
|
||||
//
|
||||
// Usage:
|
||||
// class ReverbModule extends EOCModule {
|
||||
// get id() { return 'reverb'; }
|
||||
// get displayName() { return 'Reverb'; }
|
||||
// get paramMeta() { return [...]; }
|
||||
// async init(audioCtx) {
|
||||
// await super.init(audioCtx);
|
||||
// // create effect nodes, wire between this._bypassIn → effect → this._bypassOut
|
||||
// }
|
||||
// }
|
||||
|
||||
export class EOCModule {
|
||||
constructor() {
|
||||
this._enabled = true;
|
||||
this._audioCtx = null;
|
||||
// Bypass graph nodes — allocated in init(), used by _applyBypass()
|
||||
this._bypassIn = null; // GainNode: input entry point
|
||||
this._bypassOut = null; // GainNode: output exit point
|
||||
this._bypassDry = null; // GainNode: direct input→output path when bypassed
|
||||
this._initialized = false;
|
||||
// Stored normalized param values — set via setParam(), read by getCurrentParamValue()
|
||||
this._paramValues = [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity (override in subclass)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stable machine ID.
|
||||
* One of: 'eq' | 'compressor' | 'reverb' | 'delay' | 'saturation' | 'master'
|
||||
* @returns {string}
|
||||
*/
|
||||
get id() {
|
||||
throw new Error(`${this.constructor.name}: id not implemented`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label shown in the UI.
|
||||
* @returns {string}
|
||||
*/
|
||||
get displayName() {
|
||||
throw new Error(`${this.constructor.name}: displayName not implemented`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enable / bypass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether this module is active in the signal chain.
|
||||
* When false, audio passes directly from input to output (true bypass).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get enabled() {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle bypass. Reconnects the internal audio graph immediately.
|
||||
* @param {boolean} v
|
||||
*/
|
||||
set enabled(v) {
|
||||
const changed = this._enabled !== !!v;
|
||||
this._enabled = !!v;
|
||||
if (changed && this._initialized) {
|
||||
this._applyBypass();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameter schema (override in subclass)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Number of continuous parameters this module exposes.
|
||||
* Derived automatically from paramMeta — override paramMeta, not this.
|
||||
* @returns {number}
|
||||
*/
|
||||
get paramCount() {
|
||||
return this.paramMeta.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of parameter descriptors, one per controllable parameter.
|
||||
*
|
||||
* Each entry must have:
|
||||
* id {string} — stable machine ID (used for presets / NISPS routing)
|
||||
* name {string} — short display name
|
||||
* min {number} — raw minimum value (units depend on param)
|
||||
* max {number} — raw maximum value
|
||||
* init {number} — default normalized value [0, 1]
|
||||
* curve {number} — power-curve bias: 0.5 = linear, <0.5 = log, >0.5 = exp
|
||||
* group {string} — section label (for group drawer / colour coding)
|
||||
*
|
||||
* @returns {Array<{id:string, name:string, min:number, max:number, init:number, curve:number, group:string}>}
|
||||
*/
|
||||
get paramMeta() {
|
||||
throw new Error(`${this.constructor.name}: paramMeta not implemented`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Allocate AudioNodes and connect the internal graph.
|
||||
* Subclasses MUST call super.init(audioCtx) first, then wire their effect
|
||||
* nodes between this._bypassIn and this._bypassOut.
|
||||
*
|
||||
* @param {AudioContext} audioCtx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init(audioCtx) {
|
||||
if (this._initialized) return;
|
||||
this._audioCtx = audioCtx;
|
||||
|
||||
// Two GainNodes bracket the effect processing path.
|
||||
// A third (dry) provides a direct signal route for bypass.
|
||||
this._bypassIn = audioCtx.createGain();
|
||||
this._bypassOut = audioCtx.createGain();
|
||||
this._bypassDry = audioCtx.createGain();
|
||||
|
||||
// Wire the dry path (always present; gain toggled by _applyBypass)
|
||||
this._bypassIn.connect(this._bypassDry);
|
||||
this._bypassDry.connect(this._bypassOut);
|
||||
|
||||
this._initialized = true;
|
||||
// Note: subclass connects its effect nodes, then calls _applyBypass()
|
||||
// via _finishInit() to set initial gain values correctly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Call from the end of a subclass init() once effect nodes are wired, to
|
||||
* apply the correct initial bypass state.
|
||||
*
|
||||
* Subclass pattern:
|
||||
* async init(audioCtx) {
|
||||
* await super.init(audioCtx);
|
||||
* // ... wire effect nodes ...
|
||||
* this._finishInit();
|
||||
* }
|
||||
*/
|
||||
_finishInit() {
|
||||
this._applyBypass();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all AudioNodes and release resources.
|
||||
* Override in subclasses to clean up effect-specific nodes.
|
||||
*/
|
||||
dispose() {
|
||||
if (!this._initialized) return;
|
||||
try {
|
||||
this._bypassIn.disconnect();
|
||||
this._bypassOut.disconnect();
|
||||
this._bypassDry.disconnect();
|
||||
} catch (_) { /* already disconnected */ }
|
||||
this._initialized = false;
|
||||
this._audioCtx = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-time control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set a single parameter by index.
|
||||
*
|
||||
* @param {number} index — 0-based index into paramMeta
|
||||
* @param {number} normalizedValue — [0, 1]
|
||||
*/
|
||||
setParam(index, normalizedValue) { // eslint-disable-line no-unused-vars
|
||||
// Store the value so getCurrentParamValue() can read it back
|
||||
this._paramValues[index] = normalizedValue;
|
||||
// default no-op — override in subclass for actual audio effect
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last normalized value set for a parameter.
|
||||
* Returns the param's init value (from paramMeta) if never explicitly set.
|
||||
*
|
||||
* @param {number} index — 0-based index into paramMeta
|
||||
* @returns {number} normalized value [0, 1]
|
||||
*/
|
||||
getCurrentParamValue(index) {
|
||||
if (this._paramValues[index] !== undefined) {
|
||||
return this._paramValues[index];
|
||||
}
|
||||
const meta = this.paramMeta[index];
|
||||
return meta ? (meta.init ?? 0) : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio graph
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The AudioNode that upstream sources should connect to.
|
||||
* Everything flows into this node.
|
||||
* @returns {AudioNode}
|
||||
*/
|
||||
getInputNode() {
|
||||
return this._bypassIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* The AudioNode to connect downstream (to next module or destination).
|
||||
* Everything exits through this node.
|
||||
* @returns {AudioNode}
|
||||
*/
|
||||
getOutputNode() {
|
||||
return this._bypassOut;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply bypass state to the internal audio graph.
|
||||
*
|
||||
* When enabled:
|
||||
* - dry path (bypassIn → bypassOut) gain = 0
|
||||
* - effect path is live (subclass manages its own nodes)
|
||||
*
|
||||
* When bypassed:
|
||||
* - dry path gain = 1 (signal passes straight through)
|
||||
* - effect processing gains are muted (subclass handles via _onBypassChange)
|
||||
*
|
||||
* Both _bypassIn and _bypassOut remain in the graph at all times so the
|
||||
* EOCChain's wiring never needs to change when bypass is toggled.
|
||||
*/
|
||||
_applyBypass() {
|
||||
if (!this._bypassDry) return;
|
||||
const t = this._audioCtx.currentTime;
|
||||
if (this._enabled) {
|
||||
// Effect active: cut the dry path
|
||||
this._bypassDry.gain.setTargetAtTime(0, t, 0.005);
|
||||
} else {
|
||||
// Bypassed: open dry path
|
||||
this._bypassDry.gain.setTargetAtTime(1, t, 0.005);
|
||||
}
|
||||
// Let subclass mute/unmute its own effect nodes
|
||||
this._onBypassChange(this._enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by _applyBypass() after the dry-path gain is updated.
|
||||
* Subclasses should override to mute/unmute effect processing nodes.
|
||||
*
|
||||
* @param {boolean} enabled — true = effect active, false = bypassed
|
||||
*/
|
||||
_onBypassChange(enabled) { // eslint-disable-line no-unused-vars
|
||||
// default no-op — override in subclass if effect has its own gain to manage
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: normalize a [0,1] value to the raw param range
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a normalized [0,1] value to the raw range defined by paramMeta[index].
|
||||
* Applies the curve bias: value^(1/curve) gives log-like feel for small curve,
|
||||
* value^curve gives exp-like feel for large curve.
|
||||
*
|
||||
* @param {number} index
|
||||
* @param {number} normalizedValue [0, 1]
|
||||
* @returns {number} raw value in [min, max]
|
||||
*/
|
||||
_denormalize(index, normalizedValue) {
|
||||
const meta = this.paramMeta[index];
|
||||
if (!meta) return 0;
|
||||
const { min, max, curve = 0.5 } = meta;
|
||||
// Apply power curve: curve=0.5 is linear, <0.5 pulls toward min, >0.5 toward max
|
||||
const shaped = Math.pow(Math.max(0, Math.min(1, normalizedValue)), curve === 0.5 ? 1 : 1 / (curve * 2));
|
||||
return min + shaped * (max - min);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
// eoc/index.js — barrel re-export for the End-of-Chain effects system.
|
||||
//
|
||||
// Import from here to get the full EOC API:
|
||||
// import { EOCModule, EOCChain } from './eoc/index.js';
|
||||
|
||||
export { EOCModule } from './eoc-module.js';
|
||||
export { EOCChain } from './eoc-chain.js';
|
||||
|
||||
// Effect module implementations
|
||||
export { EQModule } from './modules/eq-module.js';
|
||||
export { CompressorModule } from './modules/compressor-module.js';
|
||||
export { ReverbModule } from './modules/reverb-module.js';
|
||||
export { DelayModule } from './modules/delay-module.js';
|
||||
export { SaturationModule } from './modules/saturation-module.js';
|
||||
export { MasterModule } from './modules/master-module.js';
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
// playground/js/eoc/modules/compressor-module.js — Stereo compressor EOCModule
|
||||
//
|
||||
// Faust DSP: playground/faust/eoc-compressor.dsp
|
||||
// WASM: playground/faust/eoc-compressor.wasm
|
||||
// JSON: playground/faust/eoc-compressor.json
|
||||
//
|
||||
// 7 params (Faust JSON alphabetical order):
|
||||
// 0: attack [0.1–200 ms, init 10]
|
||||
// 1: knee [0–24 dB, init 6]
|
||||
// 2: makeup [0–24 dB, init 0]
|
||||
// 3: mix [0–1, init 1] (dry/wet for parallel compression)
|
||||
// 4: ratio [1–20, init 4]
|
||||
// 5: release [10–2000 ms, init 100]
|
||||
// 6: threshold [-60–0 dBFS, init -24]
|
||||
|
||||
import { EOCModule } from '../eoc-module.js';
|
||||
import { loadFaustParamMeta } from '../../synth/faust-param-meta.js';
|
||||
|
||||
const COMP_PARAM_META = [
|
||||
{ id: 'attack', name: 'Attack', min: 0.1, max: 200, init: (10-0.1)/(200-0.1), curve: 0.3, group: 'Compressor' },
|
||||
{ id: 'knee', name: 'Knee', min: 0, max: 24, init: 6/24, curve: 0.5, group: 'Compressor' },
|
||||
{ id: 'makeup', name: 'Makeup', min: 0, max: 24, init: 0, curve: 0.5, group: 'Compressor' },
|
||||
{ id: 'mix', name: 'Mix', min: 0, max: 1, init: 1, curve: 0.5, group: 'Compressor' },
|
||||
{ id: 'ratio', name: 'Ratio', min: 1, max: 20, init: (4-1)/(20-1), curve: 0.4, group: 'Compressor' },
|
||||
{ id: 'release', name: 'Release', min: 10, max: 2000, init: (100-10)/(2000-10), curve: 0.3, group: 'Compressor' },
|
||||
{ id: 'threshold', name: 'Threshold', min: -60, max: 0, init: (-24-(-60))/(0-(-60)), curve: 0.5, group: 'Compressor' },
|
||||
];
|
||||
|
||||
export class CompressorModule extends EOCModule {
|
||||
constructor() {
|
||||
super();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null;
|
||||
this._paramMeta = COMP_PARAM_META;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EOCModule identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
get id() { return 'compressor'; }
|
||||
get displayName() { return 'Compressor'; }
|
||||
get paramMeta() { return this._paramMeta; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async init(audioCtx) {
|
||||
await super.init(audioCtx);
|
||||
|
||||
try {
|
||||
const fetched = await loadFaustParamMeta('faust/eoc-compressor.json');
|
||||
if (fetched && fetched.length > 0) this._paramMeta = fetched;
|
||||
} catch (err) {
|
||||
console.warn('[CompressorModule] Could not load eoc-compressor.json, using static paramMeta:', err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await audioCtx.audioWorklet.addModule('faust/faust-worklet-processor.js');
|
||||
} catch (_) { /* already registered */ }
|
||||
await audioCtx.audioWorklet.addModule('faust/eoc-compressor-processor.js');
|
||||
|
||||
this._workletNode = new AudioWorkletNode(audioCtx, 'eoc-compressor-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
|
||||
const wasmResp = await fetch('faust/eoc-compressor.wasm');
|
||||
const wasmBytes = await wasmResp.arrayBuffer();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('CompressorModule worklet init timeout')), 10000);
|
||||
this._workletNode.port.onmessage = (e) => {
|
||||
if (e.data.type === 'ready') { clearTimeout(timeout); resolve(); }
|
||||
if (e.data.type === 'error') { clearTimeout(timeout); reject(new Error(e.data.message)); }
|
||||
};
|
||||
this._workletNode.port.postMessage(
|
||||
{ type: 'init', wasmBytes, sampleRate: audioCtx.sampleRate },
|
||||
[wasmBytes],
|
||||
);
|
||||
});
|
||||
|
||||
this._effectGain = audioCtx.createGain();
|
||||
this._effectGain.gain.value = 1;
|
||||
|
||||
this._bypassIn.connect(this._workletNode);
|
||||
this._workletNode.connect(this._effectGain);
|
||||
this._effectGain.connect(this._bypassOut);
|
||||
|
||||
this._finishInit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-time control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
setParam(index, normalizedValue) {
|
||||
super.setParam(index, normalizedValue);
|
||||
if (!this._workletNode) return;
|
||||
const meta = this._paramMeta[index];
|
||||
if (!meta) return;
|
||||
const raw = meta.min + normalizedValue * (meta.max - meta.min);
|
||||
this._workletNode.port.postMessage({ type: 'setParam', index, value: raw });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bypass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_onBypassChange(enabled) {
|
||||
if (!this._effectGain) return;
|
||||
const t = this._audioCtx.currentTime;
|
||||
this._effectGain.gain.setTargetAtTime(enabled ? 1 : 0, t, 0.005);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
dispose() {
|
||||
this._workletNode?.disconnect();
|
||||
this._effectGain?.disconnect();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
// delay-module.js — EOC Stereo Delay module (meml-2sl).
|
||||
//
|
||||
// Faust source: playground/faust/eoc-delay.dsp
|
||||
// Worklet processor: playground/faust/eoc-delay-processor.js
|
||||
// Processor name: 'eoc-delay-processor'
|
||||
//
|
||||
// 7 parameters (in JSON / Faust alphabetical order):
|
||||
// 0 feedback [0, 0.95] init=0.3
|
||||
// 1 lp_cutoff [500, 20000] init=8000
|
||||
// 2 mix [0, 1] init=0.3
|
||||
// 3 ping_pong [0, 1] init=0.0
|
||||
// 4 spread [0, 1] init=0.5
|
||||
// 5 sync [0, 3] init=0 (nentry)
|
||||
// 6 time [1, 2000] init=250
|
||||
|
||||
import { EOCModule } from '../eoc-module.js';
|
||||
import { loadFaustParamMeta } from '../../synth/faust-param-meta.js';
|
||||
|
||||
export class DelayModule extends EOCModule {
|
||||
constructor() {
|
||||
super();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null; // GainNode: muted when bypassed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EOCModule identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
get id() { return 'delay'; }
|
||||
get displayName() { return 'Delay'; }
|
||||
|
||||
get paramMeta() {
|
||||
// Normalized init values derived from Faust defaults
|
||||
return [
|
||||
{ id: 'feedback', name: 'Feedback', min: 0, max: 0.95, init: 0.3 / 0.95, curve: 0.5, group: 'Delay' },
|
||||
{ id: 'lp_cutoff', name: 'LP Cutoff', min: 500, max: 20000, init: (8000 - 500) / 19500, curve: 0.35, group: 'Delay' },
|
||||
{ id: 'mix', name: 'Mix', min: 0, max: 1, init: 0.3, curve: 0.5, group: 'Delay' },
|
||||
{ id: 'ping_pong', name: 'Ping-Pong', min: 0, max: 1, init: 0.0, curve: 0.5, group: 'Delay' },
|
||||
{ id: 'spread', name: 'Spread', min: 0, max: 1, init: 0.5, curve: 0.5, group: 'Delay' },
|
||||
{ id: 'sync', name: 'Sync', min: 0, max: 3, init: 0.0, curve: 0.5, group: 'Delay' },
|
||||
{ id: 'time', name: 'Time (ms)', min: 1, max: 2000, init: (250 - 1) / 1999, curve: 0.35, group: 'Delay' },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async init(audioCtx) {
|
||||
await super.init(audioCtx);
|
||||
|
||||
// Fetch WASM bytes
|
||||
const wasmResp = await fetch('faust/eoc-delay.wasm');
|
||||
if (!wasmResp.ok) throw new Error(`[DelayModule] Failed to fetch eoc-delay.wasm: ${wasmResp.status}`);
|
||||
const wasmBytes = await wasmResp.arrayBuffer();
|
||||
|
||||
// Register worklet (browser deduplicates)
|
||||
await audioCtx.audioWorklet.addModule('faust/faust-worklet-processor.js');
|
||||
await audioCtx.audioWorklet.addModule('faust/eoc-delay-processor.js');
|
||||
|
||||
// Create worklet node: 2-in / 2-out stereo effect
|
||||
this._workletNode = new AudioWorkletNode(audioCtx, 'eoc-delay-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
channelCount: 2,
|
||||
channelCountMode: 'explicit',
|
||||
});
|
||||
|
||||
// Listen for ready / error messages
|
||||
this._workletNode.port.onmessage = (e) => {
|
||||
if (e.data?.type === 'error') {
|
||||
console.error('[DelayModule] Worklet error:', e.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect gain node — muted when bypassed
|
||||
this._effectGain = audioCtx.createGain();
|
||||
this._effectGain.gain.value = 1;
|
||||
|
||||
// Wire: bypassIn → worklet → effectGain → bypassOut
|
||||
this._bypassIn.connect(this._workletNode);
|
||||
this._workletNode.connect(this._effectGain);
|
||||
this._effectGain.connect(this._bypassOut);
|
||||
|
||||
// Send init message to worklet
|
||||
this._workletNode.port.postMessage(
|
||||
{ type: 'init', wasmBytes, sampleRate: audioCtx.sampleRate },
|
||||
[wasmBytes]
|
||||
);
|
||||
|
||||
// Apply initial default param values
|
||||
this.paramMeta.forEach((_, i) => {
|
||||
this.setParam(i, this.getCurrentParamValue(i));
|
||||
});
|
||||
|
||||
this._finishInit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-time control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
setParam(index, normalizedValue) {
|
||||
super.setParam(index, normalizedValue);
|
||||
if (!this._workletNode) return;
|
||||
const raw = this._denormalize(index, normalizedValue);
|
||||
this._workletNode.port.postMessage({ type: 'setParam', index, value: raw });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bypass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_onBypassChange(enabled) {
|
||||
if (!this._effectGain) return;
|
||||
const t = this._audioCtx.currentTime;
|
||||
this._effectGain.gain.setTargetAtTime(enabled ? 1 : 0, t, 0.005);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
dispose() {
|
||||
if (this._workletNode) {
|
||||
this._workletNode.disconnect();
|
||||
this._workletNode.port.onmessage = null;
|
||||
this._workletNode = null;
|
||||
}
|
||||
if (this._effectGain) {
|
||||
this._effectGain.disconnect();
|
||||
this._effectGain = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
// playground/js/eoc/modules/eq-module.js — 4-band parametric EQ EOCModule
|
||||
//
|
||||
// Faust DSP: playground/faust/eoc-eq.dsp
|
||||
// WASM: playground/faust/eoc-eq.wasm
|
||||
// JSON: playground/faust/eoc-eq.json
|
||||
//
|
||||
// 10 params (shelf bands have no Q in Faust fi.low_shelf / fi.high_shelf):
|
||||
// Band 1 (Low Shelf): freq1 [20–500 Hz, init 80], gain1 [-12–+12 dB, init 0]
|
||||
// Band 2 (Low-Mid): freq2 [100–2000 Hz, init 400], gain2 [-12–+12 dB, init 0], q2 [0.1–10, init 1]
|
||||
// Band 3 (High-Mid): freq3 [500–8000 Hz, init 2500], gain3 [-12–+12 dB, init 0], q3 [0.1–10, init 1]
|
||||
// Band 4 (High Shelf): freq4 [2000–20000 Hz, init 8000], gain4 [-12–+12 dB, init 0]
|
||||
//
|
||||
// param order = Faust JSON alphabetical-within-group, groups in declaration order:
|
||||
// 0 freq1, 1 gain1, 2 freq2, 3 gain2, 4 q2, 5 freq3, 6 gain3, 7 q3, 8 freq4, 9 gain4
|
||||
|
||||
import { EOCModule } from '../eoc-module.js';
|
||||
import { loadFaustParamMeta } from '../../synth/faust-param-meta.js';
|
||||
|
||||
// Static paramMeta — mirrors eoc-eq.json, used before/without JSON fetch
|
||||
const EQ_PARAM_META = [
|
||||
// Band 1 (Low Shelf)
|
||||
{ id: 'band_1__low_shelf__freq1', name: 'Freq 1', min: 20, max: 500, init: (80-20)/(500-20), curve: 0.3, group: 'Band 1 (Low Shelf)' },
|
||||
{ id: 'band_1__low_shelf__gain1', name: 'Gain 1', min: -12, max: 12, init: (0-(-12))/(12-(-12)), curve: 0.5, group: 'Band 1 (Low Shelf)' },
|
||||
// Band 2 (Low-Mid bell)
|
||||
{ id: 'band_2__low-mid__freq2', name: 'Freq 2', min: 100, max: 2000, init: (400-100)/(2000-100), curve: 0.3, group: 'Band 2 (Low-Mid)' },
|
||||
{ id: 'band_2__low-mid__gain2', name: 'Gain 2', min: -12, max: 12, init: (0-(-12))/(12-(-12)), curve: 0.5, group: 'Band 2 (Low-Mid)' },
|
||||
{ id: 'band_2__low-mid__q2', name: 'Q 2', min: 0.1, max: 10, init: (1-0.1)/(10-0.1), curve: 0.3, group: 'Band 2 (Low-Mid)' },
|
||||
// Band 3 (High-Mid bell)
|
||||
{ id: 'band_3__high-mid__freq3', name: 'Freq 3', min: 500, max: 8000, init: (2500-500)/(8000-500),curve: 0.3, group: 'Band 3 (High-Mid)' },
|
||||
{ id: 'band_3__high-mid__gain3', name: 'Gain 3', min: -12, max: 12, init: (0-(-12))/(12-(-12)), curve: 0.5, group: 'Band 3 (High-Mid)' },
|
||||
{ id: 'band_3__high-mid__q3', name: 'Q 3', min: 0.1, max: 10, init: (1-0.1)/(10-0.1), curve: 0.3, group: 'Band 3 (High-Mid)' },
|
||||
// Band 4 (High Shelf)
|
||||
{ id: 'band_4__high_shelf__freq4',name: 'Freq 4', min: 2000, max: 20000, init: (8000-2000)/(20000-2000),curve: 0.3, group: 'Band 4 (High Shelf)' },
|
||||
{ id: 'band_4__high_shelf__gain4',name: 'Gain 4', min: -12, max: 12, init: (0-(-12))/(12-(-12)), curve: 0.5, group: 'Band 4 (High Shelf)' },
|
||||
];
|
||||
|
||||
export class EQModule extends EOCModule {
|
||||
constructor() {
|
||||
super();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null;
|
||||
this._paramMeta = EQ_PARAM_META;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EOCModule identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
get id() { return 'eq'; }
|
||||
get displayName() { return 'Parametric EQ'; }
|
||||
get paramMeta() { return this._paramMeta; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async init(audioCtx) {
|
||||
await super.init(audioCtx);
|
||||
|
||||
// Attempt to load richer paramMeta from JSON (non-fatal if fetch fails)
|
||||
try {
|
||||
const fetched = await loadFaustParamMeta('faust/eoc-eq.json');
|
||||
if (fetched && fetched.length > 0) this._paramMeta = fetched;
|
||||
} catch (err) {
|
||||
console.warn('[EQModule] Could not load eoc-eq.json, using static paramMeta:', err.message);
|
||||
}
|
||||
|
||||
// Register worklet module (idempotent across calls)
|
||||
try {
|
||||
await audioCtx.audioWorklet.addModule('faust/faust-worklet-processor.js');
|
||||
} catch (_) { /* already registered */ }
|
||||
await audioCtx.audioWorklet.addModule('faust/eoc-eq-processor.js');
|
||||
|
||||
// Create AudioWorkletNode
|
||||
this._workletNode = new AudioWorkletNode(audioCtx, 'eoc-eq-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
|
||||
// Load WASM and send to worklet
|
||||
const wasmResp = await fetch('faust/eoc-eq.wasm');
|
||||
const wasmBytes = await wasmResp.arrayBuffer();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('EQModule worklet init timeout')), 10000);
|
||||
this._workletNode.port.onmessage = (e) => {
|
||||
if (e.data.type === 'ready') { clearTimeout(timeout); resolve(); }
|
||||
if (e.data.type === 'error') { clearTimeout(timeout); reject(new Error(e.data.message)); }
|
||||
};
|
||||
this._workletNode.port.postMessage(
|
||||
{ type: 'init', wasmBytes, sampleRate: audioCtx.sampleRate },
|
||||
[wasmBytes],
|
||||
);
|
||||
});
|
||||
|
||||
// Gain node so bypass can mute the effect path
|
||||
this._effectGain = audioCtx.createGain();
|
||||
this._effectGain.gain.value = 1;
|
||||
|
||||
// Wire: bypassIn → worklet → effectGain → bypassOut
|
||||
this._bypassIn.connect(this._workletNode);
|
||||
this._workletNode.connect(this._effectGain);
|
||||
this._effectGain.connect(this._bypassOut);
|
||||
|
||||
this._finishInit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-time control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
setParam(index, normalizedValue) {
|
||||
super.setParam(index, normalizedValue);
|
||||
if (!this._workletNode) return;
|
||||
const meta = this._paramMeta[index];
|
||||
if (!meta) return;
|
||||
const raw = meta.min + normalizedValue * (meta.max - meta.min);
|
||||
this._workletNode.port.postMessage({ type: 'setParam', index, value: raw });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bypass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_onBypassChange(enabled) {
|
||||
if (!this._effectGain) return;
|
||||
const t = this._audioCtx.currentTime;
|
||||
this._effectGain.gain.setTargetAtTime(enabled ? 1 : 0, t, 0.005);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
dispose() {
|
||||
this._workletNode?.disconnect();
|
||||
this._effectGain?.disconnect();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
// master-module.js — EOC Master Bus module (meml-2zm part 2).
|
||||
//
|
||||
// Faust source: playground/faust/eoc-master.dsp
|
||||
// Worklet processor: playground/faust/eoc-master-processor.js
|
||||
// Processor name: 'eoc-master-processor'
|
||||
//
|
||||
// 4 parameters (in JSON / Faust alphabetical order):
|
||||
// 0 dc_block [0, 1] init=1 (nentry: off/on)
|
||||
// 1 gain [0, 2] init=1.0
|
||||
// 2 limiter_thresh [-12, 0] init=-1.0
|
||||
// 3 width [0, 2] init=1.0
|
||||
|
||||
import { EOCModule } from '../eoc-module.js';
|
||||
|
||||
export class MasterModule extends EOCModule {
|
||||
constructor() {
|
||||
super();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EOCModule identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
get id() { return 'master'; }
|
||||
get displayName() { return 'Master Bus'; }
|
||||
|
||||
get paramMeta() {
|
||||
return [
|
||||
{ id: 'dc_block', name: 'DC Block', min: 0, max: 1, init: 1.0, curve: 0.5, group: 'Master' },
|
||||
{ id: 'gain', name: 'Gain', min: 0, max: 2, init: 1.0 / 2.0, curve: 0.5, group: 'Master' },
|
||||
{ id: 'limiter_thresh', name: 'Limiter (dB)', min: -12, max: 0, init: (-1.0 - (-12)) / 12, curve: 0.5, group: 'Master' },
|
||||
{ id: 'width', name: 'Width', min: 0, max: 2, init: 1.0 / 2.0, curve: 0.5, group: 'Master' },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async init(audioCtx) {
|
||||
await super.init(audioCtx);
|
||||
|
||||
const wasmResp = await fetch('faust/eoc-master.wasm');
|
||||
if (!wasmResp.ok) throw new Error(`[MasterModule] Failed to fetch eoc-master.wasm: ${wasmResp.status}`);
|
||||
const wasmBytes = await wasmResp.arrayBuffer();
|
||||
|
||||
await audioCtx.audioWorklet.addModule('faust/faust-worklet-processor.js');
|
||||
await audioCtx.audioWorklet.addModule('faust/eoc-master-processor.js');
|
||||
|
||||
this._workletNode = new AudioWorkletNode(audioCtx, 'eoc-master-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
channelCount: 2,
|
||||
channelCountMode: 'explicit',
|
||||
});
|
||||
|
||||
this._workletNode.port.onmessage = (e) => {
|
||||
if (e.data?.type === 'error') {
|
||||
console.error('[MasterModule] Worklet error:', e.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
this._effectGain = audioCtx.createGain();
|
||||
this._effectGain.gain.value = 1;
|
||||
|
||||
// Wire: bypassIn → worklet → effectGain → bypassOut
|
||||
this._bypassIn.connect(this._workletNode);
|
||||
this._workletNode.connect(this._effectGain);
|
||||
this._effectGain.connect(this._bypassOut);
|
||||
|
||||
this._workletNode.port.postMessage(
|
||||
{ type: 'init', wasmBytes, sampleRate: audioCtx.sampleRate },
|
||||
[wasmBytes]
|
||||
);
|
||||
|
||||
this.paramMeta.forEach((_, i) => {
|
||||
this.setParam(i, this.getCurrentParamValue(i));
|
||||
});
|
||||
|
||||
this._finishInit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-time control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
setParam(index, normalizedValue) {
|
||||
super.setParam(index, normalizedValue);
|
||||
if (!this._workletNode) return;
|
||||
const raw = this._denormalize(index, normalizedValue);
|
||||
this._workletNode.port.postMessage({ type: 'setParam', index, value: raw });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bypass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_onBypassChange(enabled) {
|
||||
if (!this._effectGain) return;
|
||||
const t = this._audioCtx.currentTime;
|
||||
this._effectGain.gain.setTargetAtTime(enabled ? 1 : 0, t, 0.005);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
dispose() {
|
||||
if (this._workletNode) {
|
||||
this._workletNode.disconnect();
|
||||
this._workletNode.port.onmessage = null;
|
||||
this._workletNode = null;
|
||||
}
|
||||
if (this._effectGain) {
|
||||
this._effectGain.disconnect();
|
||||
this._effectGain = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
// playground/js/eoc/modules/reverb-module.js — Zita reverb EOCModule
|
||||
//
|
||||
// Faust DSP: playground/faust/eoc-reverb.dsp
|
||||
// WASM: playground/faust/eoc-reverb.wasm
|
||||
// JSON: playground/faust/eoc-reverb.json
|
||||
//
|
||||
// 8 params (Faust JSON alphabetical order; mod_rate is declared in DSP but not
|
||||
// exposed by re.zita_rev1_stereo, so it does not appear in the JSON):
|
||||
// 0: decay [0.1–20 s, init 3]
|
||||
// 1: diffusion [0–1, init 0.7]
|
||||
// 2: hi_damp [0–1, init 0.5]
|
||||
// 3: lo_damp [0–1, init 0]
|
||||
// 4: mix [0–1, init 0.2]
|
||||
// 5: predelay [0–100 ms, init 0]
|
||||
// 6: size [0–1, init 0.5]
|
||||
// 7: width [0–1, init 0.8]
|
||||
//
|
||||
// Note: mod_rate param is listed in paramMeta as a placeholder (fixed at 0.5 Hz)
|
||||
// to preserve the 9-param count described in the spec. It has no effect on audio
|
||||
// until zita modulation is plumbed through.
|
||||
|
||||
import { EOCModule } from '../eoc-module.js';
|
||||
import { loadFaustParamMeta } from '../../synth/faust-param-meta.js';
|
||||
|
||||
const REVERB_PARAM_META = [
|
||||
{ id: 'decay', name: 'Decay', min: 0.1, max: 20, init: (3-0.1)/(20-0.1), curve: 0.3, group: 'Reverb' },
|
||||
{ id: 'diffusion', name: 'Diffusion', min: 0, max: 1, init: 0.7, curve: 0.5, group: 'Reverb' },
|
||||
{ id: 'hi_damp', name: 'Hi Damp', min: 0, max: 1, init: 0.5, curve: 0.5, group: 'Reverb' },
|
||||
{ id: 'lo_damp', name: 'Lo Damp', min: 0, max: 1, init: 0, curve: 0.5, group: 'Reverb' },
|
||||
{ id: 'mix', name: 'Mix', min: 0, max: 1, init: 0.2, curve: 0.5, group: 'Reverb' },
|
||||
{ id: 'predelay', name: 'Predelay', min: 0, max: 100, init: 0, curve: 0.4, group: 'Reverb' },
|
||||
{ id: 'size', name: 'Size', min: 0, max: 1, init: 0.5, curve: 0.5, group: 'Reverb' },
|
||||
{ id: 'width', name: 'Width', min: 0, max: 1, init: 0.8, curve: 0.5, group: 'Reverb' },
|
||||
// Placeholder: mod_rate is not yet connected to zita internals
|
||||
{ id: 'mod_rate', name: 'Mod Rate', min: 0, max: 5, init: 0.5/5, curve: 0.5, group: 'Reverb' },
|
||||
];
|
||||
|
||||
export class ReverbModule extends EOCModule {
|
||||
constructor() {
|
||||
super();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null;
|
||||
this._paramMeta = REVERB_PARAM_META;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EOCModule identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
get id() { return 'reverb'; }
|
||||
get displayName() { return 'Reverb'; }
|
||||
get paramMeta() { return this._paramMeta; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async init(audioCtx) {
|
||||
await super.init(audioCtx);
|
||||
|
||||
// After loading JSON, merge — but keep mod_rate placeholder (index 8)
|
||||
try {
|
||||
const fetched = await loadFaustParamMeta('faust/eoc-reverb.json');
|
||||
if (fetched && fetched.length > 0) {
|
||||
// Append mod_rate placeholder so total stays 9
|
||||
this._paramMeta = [
|
||||
...fetched,
|
||||
{ id: 'mod_rate', name: 'Mod Rate', min: 0, max: 5, init: 0.5/5, curve: 0.5, group: 'Reverb' },
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ReverbModule] Could not load eoc-reverb.json, using static paramMeta:', err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await audioCtx.audioWorklet.addModule('faust/faust-worklet-processor.js');
|
||||
} catch (_) { /* already registered */ }
|
||||
await audioCtx.audioWorklet.addModule('faust/eoc-reverb-processor.js');
|
||||
|
||||
this._workletNode = new AudioWorkletNode(audioCtx, 'eoc-reverb-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
|
||||
const wasmResp = await fetch('faust/eoc-reverb.wasm');
|
||||
const wasmBytes = await wasmResp.arrayBuffer();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('ReverbModule worklet init timeout')), 10000);
|
||||
this._workletNode.port.onmessage = (e) => {
|
||||
if (e.data.type === 'ready') { clearTimeout(timeout); resolve(); }
|
||||
if (e.data.type === 'error') { clearTimeout(timeout); reject(new Error(e.data.message)); }
|
||||
};
|
||||
this._workletNode.port.postMessage(
|
||||
{ type: 'init', wasmBytes, sampleRate: audioCtx.sampleRate },
|
||||
[wasmBytes],
|
||||
);
|
||||
});
|
||||
|
||||
this._effectGain = audioCtx.createGain();
|
||||
this._effectGain.gain.value = 1;
|
||||
|
||||
this._bypassIn.connect(this._workletNode);
|
||||
this._workletNode.connect(this._effectGain);
|
||||
this._effectGain.connect(this._bypassOut);
|
||||
|
||||
this._finishInit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-time control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
setParam(index, normalizedValue) {
|
||||
super.setParam(index, normalizedValue);
|
||||
if (!this._workletNode) return;
|
||||
const meta = this._paramMeta[index];
|
||||
if (!meta) return;
|
||||
// mod_rate (index 8) is a placeholder — skip sending to worklet
|
||||
if (meta.id === 'mod_rate') return;
|
||||
const raw = meta.min + normalizedValue * (meta.max - meta.min);
|
||||
this._workletNode.port.postMessage({ type: 'setParam', index, value: raw });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bypass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_onBypassChange(enabled) {
|
||||
if (!this._effectGain) return;
|
||||
const t = this._audioCtx.currentTime;
|
||||
this._effectGain.gain.setTargetAtTime(enabled ? 1 : 0, t, 0.005);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
dispose() {
|
||||
this._workletNode?.disconnect();
|
||||
this._effectGain?.disconnect();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
// saturation-module.js — EOC Saturation module (meml-2zm part 1).
|
||||
//
|
||||
// Faust source: playground/faust/eoc-saturation.dsp
|
||||
// Worklet processor: playground/faust/eoc-saturation-processor.js
|
||||
// Processor name: 'eoc-saturation-processor'
|
||||
//
|
||||
// 4 parameters (in JSON / Faust alphabetical order):
|
||||
// 0 character [0, 1] init=0.0
|
||||
// 1 drive [0, 1] init=0.0
|
||||
// 2 mix [0, 1] init=1.0
|
||||
// 3 tone [0, 1] init=0.5
|
||||
|
||||
import { EOCModule } from '../eoc-module.js';
|
||||
|
||||
export class SaturationModule extends EOCModule {
|
||||
constructor() {
|
||||
super();
|
||||
this._workletNode = null;
|
||||
this._effectGain = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EOCModule identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
get id() { return 'saturation'; }
|
||||
get displayName() { return 'Saturation'; }
|
||||
|
||||
get paramMeta() {
|
||||
return [
|
||||
{ id: 'character', name: 'Character', min: 0, max: 1, init: 0.0, curve: 0.5, group: 'Saturation' },
|
||||
{ id: 'drive', name: 'Drive', min: 0, max: 1, init: 0.0, curve: 0.6, group: 'Saturation' },
|
||||
{ id: 'mix', name: 'Mix', min: 0, max: 1, init: 1.0, curve: 0.5, group: 'Saturation' },
|
||||
{ id: 'tone', name: 'Tone', min: 0, max: 1, init: 0.5, curve: 0.5, group: 'Saturation' },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async init(audioCtx) {
|
||||
await super.init(audioCtx);
|
||||
|
||||
const wasmResp = await fetch('faust/eoc-saturation.wasm');
|
||||
if (!wasmResp.ok) throw new Error(`[SaturationModule] Failed to fetch eoc-saturation.wasm: ${wasmResp.status}`);
|
||||
const wasmBytes = await wasmResp.arrayBuffer();
|
||||
|
||||
await audioCtx.audioWorklet.addModule('faust/faust-worklet-processor.js');
|
||||
await audioCtx.audioWorklet.addModule('faust/eoc-saturation-processor.js');
|
||||
|
||||
this._workletNode = new AudioWorkletNode(audioCtx, 'eoc-saturation-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
channelCount: 2,
|
||||
channelCountMode: 'explicit',
|
||||
});
|
||||
|
||||
this._workletNode.port.onmessage = (e) => {
|
||||
if (e.data?.type === 'error') {
|
||||
console.error('[SaturationModule] Worklet error:', e.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
this._effectGain = audioCtx.createGain();
|
||||
this._effectGain.gain.value = 1;
|
||||
|
||||
// Wire: bypassIn → worklet → effectGain → bypassOut
|
||||
this._bypassIn.connect(this._workletNode);
|
||||
this._workletNode.connect(this._effectGain);
|
||||
this._effectGain.connect(this._bypassOut);
|
||||
|
||||
this._workletNode.port.postMessage(
|
||||
{ type: 'init', wasmBytes, sampleRate: audioCtx.sampleRate },
|
||||
[wasmBytes]
|
||||
);
|
||||
|
||||
this.paramMeta.forEach((_, i) => {
|
||||
this.setParam(i, this.getCurrentParamValue(i));
|
||||
});
|
||||
|
||||
this._finishInit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-time control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
setParam(index, normalizedValue) {
|
||||
super.setParam(index, normalizedValue);
|
||||
if (!this._workletNode) return;
|
||||
const raw = this._denormalize(index, normalizedValue);
|
||||
this._workletNode.port.postMessage({ type: 'setParam', index, value: raw });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bypass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_onBypassChange(enabled) {
|
||||
if (!this._effectGain) return;
|
||||
const t = this._audioCtx.currentTime;
|
||||
this._effectGain.gain.setTargetAtTime(enabled ? 1 : 0, t, 0.005);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
dispose() {
|
||||
if (this._workletNode) {
|
||||
this._workletNode.disconnect();
|
||||
this._workletNode.port.onmessage = null;
|
||||
this._workletNode = null;
|
||||
}
|
||||
if (this._effectGain) {
|
||||
this._effectGain.disconnect();
|
||||
this._effectGain = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
// Namespaced Event Bus
|
||||
// Lightweight pub/sub with seq.*/ml.*/ui.* namespaces and wildcard support.
|
||||
|
||||
// ── Event name constants ────────────────────────────────────────────
|
||||
|
||||
export const SEQ = Object.freeze({
|
||||
STEP: 'seq.step',
|
||||
NOTE_ON: 'seq.noteOn',
|
||||
NOTE_OFF: 'seq.noteOff',
|
||||
PARAM_CHANGE: 'seq.paramChange',
|
||||
LOOP_START: 'seq.loopStart',
|
||||
});
|
||||
|
||||
export const ML = Object.freeze({
|
||||
TRAINED: 'ml.trained',
|
||||
FROZEN: 'ml.frozen',
|
||||
UNFROZEN: 'ml.unfrozen',
|
||||
DELTA_UPDATE: 'ml.deltaUpdate',
|
||||
});
|
||||
|
||||
export const UI = Object.freeze({
|
||||
PARAM_SELECT: 'ui.paramSelect',
|
||||
CHAIN_EDIT: 'ui.chainEdit',
|
||||
PRESET_LOAD: 'ui.presetLoad',
|
||||
FREEZE_TOGGLE: 'ui.freezeToggle',
|
||||
});
|
||||
|
||||
// ── Bus implementation ──────────────────────────────────────────────
|
||||
|
||||
export class EventBus {
|
||||
/** @param {AudioContext} [audioCtx] - optional, used for seq.* timestamps */
|
||||
constructor(audioCtx) {
|
||||
this._listeners = new Map(); // event -> Set<callback>
|
||||
this._wildcards = new Map(); // namespace prefix (e.g. "seq") -> Set<callback>
|
||||
this._audioCtx = audioCtx ?? null;
|
||||
}
|
||||
|
||||
/** Provide or replace the AudioContext used for seq.* timestamps. */
|
||||
setAudioContext(ctx) {
|
||||
this._audioCtx = ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event or a wildcard namespace.
|
||||
* on('seq.step', cb) — exact match
|
||||
* on('seq.*', cb) — all events in the seq namespace
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (event.endsWith('.*')) {
|
||||
const ns = event.slice(0, -2); // "seq.*" -> "seq"
|
||||
if (!this._wildcards.has(ns)) this._wildcards.set(ns, new Set());
|
||||
this._wildcards.get(ns).add(callback);
|
||||
} else {
|
||||
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
||||
this._listeners.get(event).add(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsubscribe. Mirrors the on() signature. */
|
||||
off(event, callback) {
|
||||
if (event.endsWith('.*')) {
|
||||
const ns = event.slice(0, -2);
|
||||
const set = this._wildcards.get(ns);
|
||||
if (set) {
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this._wildcards.delete(ns);
|
||||
}
|
||||
} else {
|
||||
const set = this._listeners.get(event);
|
||||
if (set) {
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this._listeners.delete(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event.
|
||||
* A `timestamp` field is added automatically:
|
||||
* - seq.* events use AudioContext.currentTime (seconds)
|
||||
* - all others use performance.now() (milliseconds)
|
||||
*/
|
||||
emit(event, data = {}) {
|
||||
const ns = event.split('.')[0];
|
||||
const stamped = Object.assign({ timestamp: this._stamp(ns) }, data);
|
||||
|
||||
// Exact listeners
|
||||
const exact = this._listeners.get(event);
|
||||
if (exact) {
|
||||
for (const cb of exact) cb(stamped, event);
|
||||
}
|
||||
|
||||
// Wildcard listeners for this namespace
|
||||
const wild = this._wildcards.get(ns);
|
||||
if (wild) {
|
||||
for (const cb of wild) cb(stamped, event);
|
||||
}
|
||||
}
|
||||
|
||||
// ── private ──
|
||||
|
||||
_stamp(ns) {
|
||||
if (ns === 'seq' && this._audioCtx) return this._audioCtx.currentTime;
|
||||
return performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Singleton convenience ───────────────────────────────────────────
|
||||
|
||||
let _default = null;
|
||||
|
||||
/** Return (and lazily create) the shared default bus. */
|
||||
export function getDefaultBus(audioCtx) {
|
||||
if (!_default) _default = new EventBus(audioCtx);
|
||||
else if (audioCtx) _default.setAudioContext(audioCtx);
|
||||
return _default;
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
// MIDI CC Map — user-defined CC parameter definitions
|
||||
// Each param maps one MLP output to a MIDI CC message.
|
||||
|
||||
const STORAGE_KEY = 'nisps-midi-cc-map';
|
||||
// Scoped key used when an engine ID is provided (avoids collision between engines).
|
||||
// Falls back to STORAGE_KEY for legacy/unknown callers.
|
||||
|
||||
// Default starter set — common CC numbers
|
||||
const DEFAULT_CC_MAP = [
|
||||
{ name: 'Cutoff', cc: 74, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
|
||||
{ name: 'Resonance', cc: 71, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
|
||||
{ name: 'Attack', cc: 73, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
|
||||
{ name: 'Release', cc: 72, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
|
||||
{ name: 'Mod Wheel', cc: 1, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
|
||||
{ name: 'Volume', cc: 7, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
|
||||
{ name: 'Pan', cc: 10, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
|
||||
{ name: 'Expression',cc: 11, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
|
||||
];
|
||||
|
||||
// Well-known CC names for auto-labeling
|
||||
const CC_NAMES = {
|
||||
1: 'Mod Wheel', 2: 'Breath', 5: 'Portamento Time', 7: 'Volume', 10: 'Pan',
|
||||
11: 'Expression', 64: 'Sustain', 65: 'Portamento', 66: 'Sostenuto',
|
||||
67: 'Soft Pedal', 70: 'Sound Variation', 71: 'Resonance', 72: 'Release',
|
||||
73: 'Attack', 74: 'Cutoff', 75: 'Decay', 76: 'Vib Rate', 77: 'Vib Depth',
|
||||
78: 'Vib Delay', 91: 'Reverb', 92: 'Tremolo', 93: 'Chorus', 94: 'Detune',
|
||||
95: 'Phaser',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a fresh CC param with defaults.
|
||||
* @param {number} [cc=74] — CC number
|
||||
* @param {number} [channel=1] — MIDI channel 1-16
|
||||
* @returns {object} CC param definition
|
||||
*/
|
||||
export function createCCParam(cc = 74, channel = 1) {
|
||||
return {
|
||||
name: CC_NAMES[cc] || `CC ${cc}`,
|
||||
cc,
|
||||
channel,
|
||||
min: 0,
|
||||
max: 1,
|
||||
curve: 0.5,
|
||||
muted: false,
|
||||
fixedValue: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load CC map from localStorage, or return default.
|
||||
* @param {string} [key] — optional storage key (default: STORAGE_KEY)
|
||||
* @returns {Array} CC param definitions
|
||||
*/
|
||||
export function loadCCMap(key = STORAGE_KEY) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw);
|
||||
if (Array.isArray(saved) && saved.length > 0) return saved;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[MIDI CC] Failed to load map:', e);
|
||||
}
|
||||
return DEFAULT_CC_MAP.map(p => ({ ...p }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save CC map to localStorage.
|
||||
* @param {Array} ccMap
|
||||
* @param {string} [key] — optional storage key (default: STORAGE_KEY)
|
||||
*/
|
||||
export function saveCCMap(ccMap, key = STORAGE_KEY) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(ccMap));
|
||||
} catch (e) {
|
||||
console.warn('[MIDI CC] Failed to save map:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export CC map as JSON string (for sharing).
|
||||
* @param {Array} ccMap
|
||||
* @returns {string}
|
||||
*/
|
||||
export function exportCCMap(ccMap) {
|
||||
return JSON.stringify(ccMap, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import CC map from JSON string.
|
||||
* @param {string} json
|
||||
* @returns {Array|null} parsed map or null on failure
|
||||
*/
|
||||
export function importCCMap(json) {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||
// Validate shape
|
||||
for (const p of parsed) {
|
||||
if (typeof p.cc !== 'number' || typeof p.channel !== 'number') return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { CC_NAMES, DEFAULT_CC_MAP };
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
// MIDI CC Presets — bundled device configurations
|
||||
// Each preset is a JSON file with { name, description, channel, params[] }
|
||||
|
||||
const PRESET_URLS = {
|
||||
'polybrute': './js/midi/presets/polybrute.json',
|
||||
};
|
||||
|
||||
// In-memory cache
|
||||
const _cache = new Map();
|
||||
|
||||
/**
|
||||
* Get list of available preset IDs and names.
|
||||
* @returns {Array<{id: string, name: string}>}
|
||||
*/
|
||||
export function listPresets() {
|
||||
return [
|
||||
{ id: 'polybrute', name: 'Arturia PolyBrute' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a preset by ID. Returns the parsed preset object.
|
||||
* @param {string} id — preset identifier
|
||||
* @returns {Promise<{name: string, description: string, channel: number, params: Array}>}
|
||||
*/
|
||||
export async function loadPreset(id) {
|
||||
if (_cache.has(id)) return _cache.get(id);
|
||||
|
||||
const url = PRESET_URLS[id];
|
||||
if (!url) throw new Error(`Unknown MIDI CC preset: ${id}`);
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`Failed to load preset ${id}: ${resp.status}`);
|
||||
|
||||
const preset = await resp.json();
|
||||
_cache.set(id, preset);
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a preset from a user-provided JSON file (File object).
|
||||
* @param {File} file
|
||||
* @returns {Promise<{name: string, description: string, channel: number, params: Array}|null>}
|
||||
*/
|
||||
export async function loadPresetFromFile(file) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const preset = JSON.parse(text);
|
||||
if (!preset.params || !Array.isArray(preset.params) || preset.params.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Validate param shape
|
||||
for (const p of preset.params) {
|
||||
if (typeof p.cc !== 'number' || typeof p.channel !== 'number') return null;
|
||||
}
|
||||
return preset;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
// MIDI Output — sends CC messages to external MIDI devices
|
||||
// Uses Web MIDI API, complementary to midi-input.js
|
||||
|
||||
export class MIDIOutput {
|
||||
constructor() {
|
||||
this.enabled = false;
|
||||
this.midiAccess = null;
|
||||
this.selectedOutputId = null;
|
||||
this.activeOutput = null;
|
||||
this._onStatusChange = null;
|
||||
this._onOutputsChange = null;
|
||||
this._boundOnStateChange = this._onStateChange.bind(this);
|
||||
|
||||
// Throttling
|
||||
this._lastSentCC = new Map(); // key: `${channel}-${cc}` -> value
|
||||
this._lastSendTime = 0;
|
||||
this.sendInterval = 50; // ms — max ~20 sends/sec
|
||||
this.deadZone = 1; // skip if CC value hasn't changed by at least 1
|
||||
}
|
||||
|
||||
set onStatusChange(fn) { this._onStatusChange = fn; }
|
||||
set onOutputsChange(fn) { this._onOutputsChange = fn; }
|
||||
|
||||
_status(msg) {
|
||||
console.log('[MIDI Out]', msg);
|
||||
this._onStatusChange?.(msg);
|
||||
}
|
||||
|
||||
/** Request MIDI access. Returns true if available. */
|
||||
async init() {
|
||||
if (this.midiAccess) return true;
|
||||
|
||||
if (!navigator.requestMIDIAccess) {
|
||||
this._status('Web MIDI not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.midiAccess = await navigator.requestMIDIAccess({ sysex: false });
|
||||
this.midiAccess.onstatechange = this._boundOnStateChange;
|
||||
this._status('MIDI output available');
|
||||
return true;
|
||||
} catch (err) {
|
||||
this._status(`MIDI access denied: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get list of available MIDI outputs as [{id, name}] */
|
||||
getOutputs() {
|
||||
if (!this.midiAccess) return [];
|
||||
const outputs = [];
|
||||
for (const [id, output] of this.midiAccess.outputs) {
|
||||
outputs.push({ id, name: output.name || `MIDI Output ${id}` });
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
/** Select a specific MIDI output by id */
|
||||
selectOutput(outputId) {
|
||||
this.selectedOutputId = outputId;
|
||||
this.activeOutput = null;
|
||||
this._lastSentCC.clear();
|
||||
|
||||
if (!this.midiAccess || !outputId) return;
|
||||
const output = this.midiAccess.outputs.get(outputId);
|
||||
if (output) {
|
||||
this.activeOutput = output;
|
||||
this._status(`Selected: ${output.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
enable() {
|
||||
if (this.enabled) return;
|
||||
this.enabled = true;
|
||||
this._status('MIDI output enabled');
|
||||
}
|
||||
|
||||
disable() {
|
||||
if (!this.enabled) return;
|
||||
this.enabled = false;
|
||||
this._status('MIDI output disabled');
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.enabled) this.disable();
|
||||
else this.enable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a CC message. channel is 1-16, cc is 0-127, value is 0-127.
|
||||
* Applies dead-zone filtering (skips if value unchanged).
|
||||
*/
|
||||
sendCC(channel, cc, value) {
|
||||
if (!this.enabled || !this.activeOutput) return;
|
||||
|
||||
const ch = Math.max(0, Math.min(15, (channel - 1) | 0));
|
||||
const ccNum = Math.max(0, Math.min(127, cc | 0));
|
||||
const val = Math.max(0, Math.min(127, value | 0));
|
||||
|
||||
// Dead-zone filter
|
||||
const key = `${ch}-${ccNum}`;
|
||||
const lastVal = this._lastSentCC.get(key);
|
||||
if (lastVal !== undefined && Math.abs(val - lastVal) < this.deadZone) return;
|
||||
|
||||
this._lastSentCC.set(key, val);
|
||||
this.activeOutput.send([0xB0 | ch, ccNum, val]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send multiple CC values at once (throttled).
|
||||
* @param {Array<{channel: number, cc: number, value: number}>} messages
|
||||
*/
|
||||
sendBatch(messages) {
|
||||
if (!this.enabled || !this.activeOutput) return;
|
||||
|
||||
const now = performance.now();
|
||||
if (now - this._lastSendTime < this.sendInterval) return;
|
||||
this._lastSendTime = now;
|
||||
|
||||
for (const msg of messages) {
|
||||
this.sendCC(msg.channel, msg.cc, msg.value);
|
||||
}
|
||||
}
|
||||
|
||||
_onStateChange(e) {
|
||||
this._onOutputsChange?.(this.getOutputs());
|
||||
// Reconnect if selected device was re-plugged
|
||||
if (this.selectedOutputId && this.enabled) {
|
||||
const output = this.midiAccess.outputs.get(this.selectedOutputId);
|
||||
if (output) {
|
||||
this.activeOutput = output;
|
||||
} else {
|
||||
this.activeOutput = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.disable();
|
||||
if (this.midiAccess) {
|
||||
this.midiAccess.onstatechange = null;
|
||||
}
|
||||
this.midiAccess = null;
|
||||
this.activeOutput = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
{
|
||||
"name": "Arturia PolyBrute",
|
||||
"description": "Full CC map for PolyBrute analog synthesizer (firmware 3.0+)",
|
||||
"channel": 1,
|
||||
"params": [
|
||||
{ "name": "Ladder Cutoff", "cc": 25, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Steiner Cutoff", "cc": 23, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Master Cutoff", "cc": 27, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Ladder Resonance", "cc": 87, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Steiner Resonance", "cc": 83, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Ladder Disto", "cc": 85, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Steiner Brute", "cc": 82, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Ladder Level", "cc": 8, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Steiner Level", "cc": 7, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 1 Tune", "cc": 66, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 2 Tune", "cc": 72, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 1 Metalizer", "cc": 70, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 1 Saw/Tri", "cc": 17, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 1 Saw/Sq", "cc": 12, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 1 PW", "cc": 69, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 2 Sub Mix", "cc": 14, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 2 Saw/Tri", "cc": 15, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 2 Saw/Sq", "cc": 16, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 2 PW", "cc": 75, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO 2 FM 2>1", "cc": 77, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mixer VCO 1", "cc": 18, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mixer VCO 2", "cc": 19, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mixer Noise", "cc": 21, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Noise Color", "cc": 22, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCO2>VCF1", "cc": 79, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Noise>VCF2", "cc": 80, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCF Env Amt Lad", "cc": 26, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCF Env Amt Stein", "cc": 24, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Lad Series/Para", "cc": 86, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Stein LP>HP>BP", "cc": 81, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Key Track", "cc": 71, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCF Env Attack", "cc": 102, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCF Env Decay", "cc": 103, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCF Env Sustain", "cc": 28, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCF Env Release", "cc": 104, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCA Env Attack", "cc": 105, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCA Env Decay", "cc": 106, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCA Env Sustain", "cc": 29, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "VCA Env Release", "cc": 107, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mod Env Delay", "cc": 108, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mod Env Attack", "cc": 109, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mod Env Decay", "cc": 110, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mod Env Sustain", "cc": 30, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mod Env Release", "cc": 111, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "LFO 1 Phase", "cc": 90, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "LFO 1 Rate", "cc": 91, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "LFO 2 Fade In", "cc": 92, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "LFO 2 Rate", "cc": 93, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "LFO 3 Curve", "cc": 67, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "LFO 3 Symmetry", "cc": 68, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "LFO 3 Rate", "cc": 73, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mod Intensity", "cc": 13, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Delay Level", "cc": 31, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Delay Time", "cc": 112, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Delay Regen", "cc": 113, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Reverb Level", "cc": 2, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Reverb Time", "cc": 78, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Reverb Damping", "cc": 76, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Stereo", "cc": 10, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Mod Wheel", "cc": 1, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 },
|
||||
{ "name": "Glide", "cc": 5, "channel": 1, "min": 0, "max": 1, "curve": 0.5, "muted": false, "fixedValue": 0.5 }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
// NISPS Dataset - faithful port of nisps-core/include/nisps/dataset.hpp
|
||||
// Manages feature-label pairs for training
|
||||
|
||||
export class Dataset {
|
||||
constructor(maxExamples = 100) {
|
||||
this.features = [];
|
||||
this.labels = [];
|
||||
this.maxExamples = maxExamples;
|
||||
}
|
||||
|
||||
add(feature, label) {
|
||||
if (this.features.length > 0) {
|
||||
if (feature.length !== this.features[0].length || label.length !== this.labels[0].length) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this.features.length >= this.maxExamples) {
|
||||
// FIFO: remove oldest
|
||||
this.features.shift();
|
||||
this.labels.shift();
|
||||
}
|
||||
this.features.push([...feature]);
|
||||
this.labels.push([...label]);
|
||||
return true;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.features = [];
|
||||
this.labels = [];
|
||||
}
|
||||
|
||||
getFeatures(withBias = true) {
|
||||
return this.features.map(f => withBias ? [...f, 1.0] : [...f]);
|
||||
}
|
||||
|
||||
getLabels() {
|
||||
return this.labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-sample training weights. Returns Float32Array normalized to sum to 1.
|
||||
* @param {'global'|'local'|'combined'} mode
|
||||
* @param {object} params
|
||||
* @param {number} params.recencyBias - 0 = uniform, 1 = strong recency (global/combined)
|
||||
* @param {number[]} [params.queryInput] - current input position (local/combined)
|
||||
* @param {number} [params.radius] - spatial radius in input space (local/combined), default 0.15
|
||||
* @returns {Float32Array} weights summing to 1
|
||||
*/
|
||||
computeWeights(mode = 'global', params = {}) {
|
||||
const n = this.features.length;
|
||||
if (n === 0) return new Float32Array(0);
|
||||
if (n === 1) return new Float32Array([1.0]);
|
||||
|
||||
const weights = new Float32Array(n).fill(1.0);
|
||||
|
||||
// Global recency: exponential decay — newest = 1, each older *= decay
|
||||
if (mode === 'global' || mode === 'combined') {
|
||||
const bias = params.recencyBias ?? 0.6;
|
||||
if (bias > 0) {
|
||||
// decay per step: at bias=1, decay=0.7 (newest ~10x oldest for 10 examples)
|
||||
// at bias=0.5, decay=0.85 (gentler)
|
||||
const decay = 1 - 0.3 * bias;
|
||||
for (let i = n - 2; i >= 0; i--) {
|
||||
weights[i] = weights[i + 1] * decay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local recency: newer examples near the query suppress older nearby ones
|
||||
if ((mode === 'local' || mode === 'combined') && params.queryInput) {
|
||||
const query = params.queryInput;
|
||||
const radius = params.radius ?? 0.15;
|
||||
const radiusSq = radius * radius;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const feat = this.features[i];
|
||||
// Distance from this example to the query point
|
||||
let distSq = 0;
|
||||
for (let d = 0; d < feat.length; d++) {
|
||||
const diff = feat[d] - (query[d] ?? 0);
|
||||
distSq += diff * diff;
|
||||
}
|
||||
|
||||
if (distSq < radiusSq) {
|
||||
// Count newer examples also within the radius
|
||||
const proximity = 1 - Math.sqrt(distSq) / radius; // 1 = on top, 0 = at edge
|
||||
let newerNearby = 0;
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
let djSq = 0;
|
||||
for (let d = 0; d < feat.length; d++) {
|
||||
const diff = feat[d] - this.features[j][d];
|
||||
djSq += diff * diff;
|
||||
}
|
||||
if (djSq < radiusSq) newerNearby++;
|
||||
}
|
||||
// Suppress: more newer neighbors + closer to query = more suppression
|
||||
if (newerNearby > 0) {
|
||||
weights[i] *= Math.pow(1 - proximity, newerNearby);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to sum to 1
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) sum += weights[i];
|
||||
if (sum > 0) {
|
||||
for (let i = 0; i < n; i++) weights[i] /= sum;
|
||||
}
|
||||
|
||||
return weights;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.features.length;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.features.length === 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
// NISPS IML - faithful port of nisps-core/include/nisps/iml.hpp + iml_impl.hpp
|
||||
// Interactive Machine Learning interface
|
||||
|
||||
import { MLP } from './mlp.js';
|
||||
import { Dataset } from './dataset.js';
|
||||
|
||||
export class IML {
|
||||
/**
|
||||
* @param {number} nInputs
|
||||
* @param {number} nOutputs
|
||||
* @param {number[]} hiddenLayers
|
||||
* @param {number} maxIterations
|
||||
* @param {number} learningRate
|
||||
* @param {number} convergenceThreshold
|
||||
*/
|
||||
constructor(
|
||||
nInputs,
|
||||
nOutputs,
|
||||
hiddenLayers = [10, 10, 14],
|
||||
maxIterations = 1000,
|
||||
learningRate = 1.0,
|
||||
convergenceThreshold = 0.00001
|
||||
) {
|
||||
this.nInputs = nInputs;
|
||||
this.nOutputs = nOutputs;
|
||||
this.maxIterations = maxIterations;
|
||||
this.learningRate = learningRate;
|
||||
this.convergenceThreshold = convergenceThreshold;
|
||||
|
||||
// Build layer sizes: input+bias, hidden..., output
|
||||
const BIAS = 1;
|
||||
const layerSizes = [nInputs + BIAS, ...hiddenLayers, nOutputs];
|
||||
|
||||
// Activation functions: RELU for hidden, SIGMOID for output
|
||||
const activationNames = [
|
||||
...hiddenLayers.map(() => 'relu'),
|
||||
'sigmoid',
|
||||
];
|
||||
|
||||
this.dataset = new Dataset(100);
|
||||
this.mlp = new MLP(layerSizes, activationNames);
|
||||
|
||||
this.inputState = new Array(nInputs).fill(0.5);
|
||||
this.outputState = new Array(nOutputs).fill(0);
|
||||
this.mode = 'inference';
|
||||
this.performInference = true;
|
||||
this.inputUpdated = true;
|
||||
this.storedWeights = null;
|
||||
this.weightsRandomised = false;
|
||||
this.lastLoss = null;
|
||||
this.bestLoss = null;
|
||||
this.lossHistory = [];
|
||||
this.totalTrainingIterations = 0;
|
||||
this.logFn = null;
|
||||
this.recencyBias = 0.6; // 0 = uniform, 1 = strong recency
|
||||
this.weightingMode = 'global'; // 'global' | 'local' | 'combined'
|
||||
this.localRadius = 0.15; // input-space radius for local weighting
|
||||
}
|
||||
|
||||
setLogger(fn) {
|
||||
this.logFn = fn;
|
||||
}
|
||||
|
||||
log(msg) {
|
||||
if (this.logFn) this.logFn(msg);
|
||||
}
|
||||
|
||||
setInput(index, value) {
|
||||
if (index >= this.nInputs) return;
|
||||
this.inputState[index] = Math.max(0, Math.min(1, value));
|
||||
this.inputUpdated = true;
|
||||
}
|
||||
|
||||
setInputs(values) {
|
||||
for (let i = 0; i < values.length && i < this.nInputs; i++) {
|
||||
this.inputState[i] = Math.max(0, Math.min(1, values[i]));
|
||||
}
|
||||
this.inputUpdated = true;
|
||||
}
|
||||
|
||||
getOutputs() {
|
||||
return this.outputState;
|
||||
}
|
||||
|
||||
setOutput(index, value) {
|
||||
if (index >= this.nOutputs) return;
|
||||
this.outputState[index] = Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
setOutputs(values) {
|
||||
for (let i = 0; i < values.length && i < this.nOutputs; i++) {
|
||||
this.outputState[i] = Math.max(0, Math.min(1, values[i]));
|
||||
}
|
||||
}
|
||||
|
||||
process() {
|
||||
if (!this.performInference || !this.inputUpdated) return;
|
||||
|
||||
// Add bias term
|
||||
const inputWithBias = [...this.inputState, 1.0];
|
||||
const { output } = this.mlp.getOutput(inputWithBias);
|
||||
this.outputState = output;
|
||||
this.inputUpdated = false;
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
if (mode === 'inference' && this.mode === 'training') {
|
||||
this.train();
|
||||
}
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
// Two-step save example (hardware workflow)
|
||||
saveExample() {
|
||||
if (this.performInference) {
|
||||
this.performInference = false;
|
||||
this.log('Move to desired output position...');
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataset.add(this.inputState, this.outputState);
|
||||
this.performInference = true;
|
||||
|
||||
// Run inference
|
||||
const inputWithBias = [...this.inputState, 1.0];
|
||||
const { output } = this.mlp.getOutput(inputWithBias);
|
||||
this.outputState = output;
|
||||
|
||||
this.log('Example saved.');
|
||||
}
|
||||
|
||||
// Direct programmatic example addition
|
||||
addExample(inputs, outputs) {
|
||||
const inVec = inputs.slice(0, this.nInputs);
|
||||
while (inVec.length < this.nInputs) inVec.push(0);
|
||||
const outVec = outputs.slice(0, this.nOutputs);
|
||||
while (outVec.length < this.nOutputs) outVec.push(0);
|
||||
this.dataset.add(inVec, outVec);
|
||||
}
|
||||
|
||||
clearDataset() {
|
||||
this.dataset.clear();
|
||||
this.log('Dataset cleared.');
|
||||
}
|
||||
|
||||
randomiseWeights(spread = 0) {
|
||||
this.storedWeights = this.mlp.getWeights();
|
||||
this.mlp.drawWeights(spread);
|
||||
this.weightsRandomised = true;
|
||||
|
||||
// Run inference to show effect
|
||||
const inputWithBias = [...this.inputState, 1.0];
|
||||
const { output } = this.mlp.getOutput(inputWithBias);
|
||||
this.outputState = output;
|
||||
|
||||
this.log('Weights randomised.');
|
||||
}
|
||||
|
||||
// Add Gaussian noise to weights (for RL exploration)
|
||||
// spread: 0 = flat noise, 1 = Xavier-scaled per layer
|
||||
// outputPinMask: optional Uint8Array[nOutputs], 1 = skip that output node
|
||||
moveWeights(speed, spread = 0, outputPinMask = null) {
|
||||
this.mlp.moveWeights(speed, spread, outputPinMask);
|
||||
// Run inference to show effect
|
||||
this.inputUpdated = true;
|
||||
this.process();
|
||||
}
|
||||
|
||||
train(options = {}) {
|
||||
// Restore weights if randomised
|
||||
if (this.weightsRandomised && this.storedWeights) {
|
||||
this.mlp.setWeights(this.storedWeights);
|
||||
this.weightsRandomised = false;
|
||||
}
|
||||
|
||||
const features = this.dataset.getFeatures(true); // with bias
|
||||
const labels = this.dataset.getLabels();
|
||||
|
||||
if (features.length === 0 || labels.length === 0) {
|
||||
this.log('Empty dataset, skipping training.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampleWeights = this.dataset.computeWeights(this.weightingMode, {
|
||||
recencyBias: this.recencyBias,
|
||||
queryInput: this.inputState,
|
||||
radius: this.localRadius,
|
||||
});
|
||||
|
||||
this.log('Training...');
|
||||
this.lastLoss = this.mlp.train(
|
||||
features,
|
||||
labels,
|
||||
this.learningRate,
|
||||
this.maxIterations,
|
||||
this.convergenceThreshold,
|
||||
{ ...options, sampleWeights }
|
||||
);
|
||||
|
||||
const latestHistory = this.mlp.lastTrainingHistory || [];
|
||||
if (latestHistory.length > 0) {
|
||||
this.lossHistory.push(...latestHistory);
|
||||
this.totalTrainingIterations += latestHistory.length;
|
||||
if (this.lossHistory.length > 1200) {
|
||||
this.lossHistory = this.lossHistory.slice(this.lossHistory.length - 1200);
|
||||
}
|
||||
const runBest = Math.min(...latestHistory);
|
||||
this.bestLoss = this.bestLoss === null ? runBest : Math.min(this.bestLoss, runBest);
|
||||
}
|
||||
|
||||
// Run inference after training
|
||||
const inputWithBias = [...this.inputState, 1.0];
|
||||
const { output } = this.mlp.getOutput(inputWithBias);
|
||||
this.outputState = output;
|
||||
|
||||
this.log(`Training complete. Loss: ${this.lastLoss.toFixed(6)}`);
|
||||
return this.lastLoss;
|
||||
}
|
||||
|
||||
get exampleCount() {
|
||||
return this.dataset.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export state as a .nisps-compatible JSON object.
|
||||
* The weight format matches the VCV Rack serialization: a 3D array of
|
||||
* connection weights [layer][node][weight], without bias values.
|
||||
* Bias is stored in a separate parallel structure for lossless round-trip
|
||||
* within the webapp, but VCV Rack will ignore it.
|
||||
*
|
||||
* @returns {object} .nisps JSON object
|
||||
*/
|
||||
exportState() {
|
||||
const internalWeights = this.mlp.getWeights();
|
||||
|
||||
// Convert from JS format [{weights, bias}, ...] per layer
|
||||
// to .nisps format: float[][][] (connection weights only)
|
||||
const weights = internalWeights.map(layer =>
|
||||
layer.map(node => node.weights)
|
||||
);
|
||||
|
||||
// Also capture bias values for lossless webapp round-trip
|
||||
const biases = internalWeights.map(layer =>
|
||||
layer.map(node => node.bias)
|
||||
);
|
||||
|
||||
// Features stored without bias term
|
||||
const features = this.dataset.features.map(f => [...f]);
|
||||
const labels = this.dataset.labels.map(l => [...l]);
|
||||
|
||||
const activations = this.mlp.layers.map(layer => layer.activationName);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
weights: weights,
|
||||
biases: biases,
|
||||
examples: { features, labels },
|
||||
mlpConfig: {
|
||||
layers: [...this.mlp.layersNodes],
|
||||
activations: activations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import state from a .nisps JSON object.
|
||||
* Accepts both the VCV Rack format (3D weight array without bias) and
|
||||
* the webapp extended format (with separate biases array).
|
||||
*
|
||||
* @param {object} state - Parsed .nisps JSON
|
||||
* @throws {Error} If version is invalid or architecture mismatches
|
||||
*/
|
||||
importState(state) {
|
||||
if (!state || !state.version || state.version < 1) {
|
||||
throw new Error('Invalid .nisps format: missing or unsupported version');
|
||||
}
|
||||
|
||||
// Validate architecture compatibility if mlpConfig is present
|
||||
if (state.mlpConfig && state.mlpConfig.layers) {
|
||||
const expected = this.mlp.layersNodes;
|
||||
const actual = state.mlpConfig.layers;
|
||||
if (expected.length !== actual.length ||
|
||||
!expected.every((v, i) => v === actual[i])) {
|
||||
throw new Error(
|
||||
`Architecture mismatch: expected [${expected}], got [${actual}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Load weights
|
||||
if (state.weights) {
|
||||
// Convert from .nisps 3D format to JS internal format
|
||||
const internalWeights = state.weights.map((layer, li) =>
|
||||
layer.map((nodeWeights, ni) => ({
|
||||
weights: Array.isArray(nodeWeights) ? [...nodeWeights] : nodeWeights,
|
||||
bias: (state.biases && state.biases[li] && state.biases[li][ni] !== undefined)
|
||||
? state.biases[li][ni]
|
||||
: 0,
|
||||
}))
|
||||
);
|
||||
this.mlp.setWeights(internalWeights);
|
||||
}
|
||||
|
||||
// Load examples
|
||||
if (state.examples) {
|
||||
this.dataset.clear();
|
||||
const { features, labels } = state.examples;
|
||||
if (features && labels) {
|
||||
const count = Math.min(features.length, labels.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.dataset.add(features[i], labels[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run inference with current inputs
|
||||
this.inputUpdated = true;
|
||||
this.process();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
// NISPS Layer - faithful port of nisps-core/include/nisps/layer.hpp
|
||||
// Layer of neural network nodes with shared activation function
|
||||
|
||||
import { Node } from './node.js';
|
||||
|
||||
// Activation functions matching C++ utils.hpp exactly
|
||||
const RELU_SLOPE = 0.01; // kReLUSlope
|
||||
|
||||
export const activations = {
|
||||
relu: x => x > 0 ? x : RELU_SLOPE * x,
|
||||
derivRelu: x => x > 0 ? 1 : RELU_SLOPE,
|
||||
sigmoid: x => 1 / (1 + Math.exp(-x)),
|
||||
derivSigmoid: x => {
|
||||
const s = 1 / (1 + Math.exp(-x));
|
||||
return s * (1 - s);
|
||||
},
|
||||
linear: x => x,
|
||||
derivLinear: () => 1,
|
||||
tanh: x => Math.tanh(x),
|
||||
derivTanh: x => 1 - Math.pow(Math.tanh(x), 2),
|
||||
};
|
||||
|
||||
// Map activation names to [fn, derivFn] pairs
|
||||
const activationPairs = {
|
||||
relu: [activations.relu, activations.derivRelu],
|
||||
sigmoid: [activations.sigmoid, activations.derivSigmoid],
|
||||
linear: [activations.linear, activations.derivLinear],
|
||||
tanh: [activations.tanh, activations.derivTanh],
|
||||
};
|
||||
|
||||
export class Layer {
|
||||
constructor(numInputsPerNode, numNodes, activationName, useConstantInit = true, constantInit = 0.5) {
|
||||
this.numInputsPerNode = numInputsPerNode;
|
||||
this.numNodes = numNodes;
|
||||
this.nodes = [];
|
||||
|
||||
this.activationName = activationName;
|
||||
const pair = activationPairs[activationName];
|
||||
this.activationFn = pair[0];
|
||||
this.derivActivationFn = pair[1];
|
||||
|
||||
for (let i = 0; i < numNodes; i++) {
|
||||
this.nodes.push(new Node(numInputsPerNode, useConstantInit, constantInit));
|
||||
}
|
||||
}
|
||||
|
||||
getOutputAfterActivation(input) {
|
||||
const output = new Array(this.numNodes);
|
||||
for (let i = 0; i < this.numNodes; i++) {
|
||||
output[i] = this.nodes[i].getOutputAfterActivation(input, this.activationFn);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
initializeGradientAccumulators() {
|
||||
for (const node of this.nodes) {
|
||||
node.initializeGradientAccumulator();
|
||||
}
|
||||
}
|
||||
|
||||
clearGradientAccumulators() {
|
||||
for (const node of this.nodes) {
|
||||
node.clearGradientAccumulator();
|
||||
}
|
||||
}
|
||||
|
||||
// Backprop with accumulation or direct update
|
||||
updateWeights(inputLayerActivation, derivError, learningRate, accumulate = false) {
|
||||
const deltas = new Array(this.numInputsPerNode).fill(0);
|
||||
|
||||
if (accumulate) {
|
||||
// Accumulate gradients mode
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
const dE_doj = derivError[i];
|
||||
const doj_dnetj = this.derivActivationFn(this.nodes[i].innerProd);
|
||||
const errorSignal = dE_doj * doj_dnetj;
|
||||
|
||||
this.nodes[i].accumulateGradients(inputLayerActivation, errorSignal);
|
||||
|
||||
for (let j = 0; j < this.numInputsPerNode; j++) {
|
||||
deltas[j] += errorSignal * this.nodes[i].weights[j];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct update mode
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
const dE_doj = derivError[i];
|
||||
const doj_dnetj = this.derivActivationFn(this.nodes[i].innerProd);
|
||||
|
||||
for (let j = 0; j < this.numInputsPerNode; j++) {
|
||||
deltas[j] += dE_doj * doj_dnetj * this.nodes[i].weights[j];
|
||||
const dnetj_dwij = inputLayerActivation[j];
|
||||
this.nodes[i].updateWeight(j, -(dE_doj * doj_dnetj * dnetj_dwij), learningRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
applyAccumulatedGradients(learningRate, batchSizeInv) {
|
||||
for (const node of this.nodes) {
|
||||
node.applyAccumulatedGradients(learningRate, batchSizeInv);
|
||||
}
|
||||
}
|
||||
|
||||
getGradSumSquared(batchSizeInv) {
|
||||
let sumsq = 0;
|
||||
for (const node of this.nodes) {
|
||||
sumsq += node.getGradSumSquared(batchSizeInv);
|
||||
}
|
||||
return sumsq;
|
||||
}
|
||||
|
||||
scaleAccumulatedGradients(clipCoef) {
|
||||
for (const node of this.nodes) {
|
||||
node.scaleAccumulatedGradients(clipCoef);
|
||||
}
|
||||
}
|
||||
|
||||
resetOptimizerState() {
|
||||
for (const node of this.nodes) {
|
||||
node.resetOptimizerState();
|
||||
}
|
||||
}
|
||||
|
||||
checkAndFixWeights() {
|
||||
let had = false;
|
||||
for (const node of this.nodes) {
|
||||
had |= node.checkAndFixWeights();
|
||||
}
|
||||
return had;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
// NISPS MLP - faithful port of nisps-core/include/nisps/mlp.hpp + mlp_impl.hpp
|
||||
// Multi-layer perceptron with Train, TrainBatch, GetOutput, weight management
|
||||
|
||||
import { Layer } from './layer.js';
|
||||
|
||||
// MSE loss function - port of loss.hpp
|
||||
function mseLoss(expected, actual, lossDeriv, sampleSizeReciprocal) {
|
||||
let accumLoss = 0;
|
||||
const oneOverN = 1 / actual.length;
|
||||
|
||||
for (let j = 0; j < actual.length; j++) {
|
||||
const diff = expected[j] - actual[j];
|
||||
accumLoss += (diff * diff) * oneOverN;
|
||||
lossDeriv[j] = -2 * oneOverN * diff * sampleSizeReciprocal;
|
||||
}
|
||||
accumLoss *= sampleSizeReciprocal;
|
||||
return accumLoss;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
function shuffleArray(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
}
|
||||
|
||||
export class MLP {
|
||||
/**
|
||||
* @param {number[]} layersNodes - e.g. [3, 10, 10, 14, 8]
|
||||
* @param {string[]} layersActivations - e.g. ['relu', 'relu', 'relu', 'sigmoid']
|
||||
*/
|
||||
constructor(layersNodes, layersActivations) {
|
||||
this.layersNodes = layersNodes;
|
||||
this.numInputs = layersNodes[0];
|
||||
this.numOutputs = layersNodes[layersNodes.length - 1];
|
||||
this.numHiddenLayers = layersNodes.length - 2;
|
||||
this.layers = [];
|
||||
this.progressCallback = null;
|
||||
|
||||
for (let i = 0; i < layersNodes.length - 1; i++) {
|
||||
this.layers.push(
|
||||
new Layer(layersNodes[i], layersNodes[i + 1], layersActivations[i], false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getOutput(input, forInference = true) {
|
||||
if (input.length !== this.numInputs) return null;
|
||||
|
||||
let tempIn = [...input];
|
||||
let tempOut;
|
||||
const allActivations = [];
|
||||
|
||||
for (let i = 0; i < this.layers.length; i++) {
|
||||
if (i > 0) {
|
||||
allActivations.push(tempIn);
|
||||
tempIn = tempOut;
|
||||
}
|
||||
tempOut = this.layers[i].getOutputAfterActivation(tempIn);
|
||||
}
|
||||
|
||||
// Push last layer's input activation
|
||||
allActivations.push(tempIn);
|
||||
|
||||
return { output: tempOut, activations: allActivations };
|
||||
}
|
||||
|
||||
// Per-sample SGD training (Train method from C++)
|
||||
// sampleWeights: optional Float32Array of per-sample weights (normalized, sum to 1)
|
||||
train(features, labels, learningRate, maxIterations = 1000, minError = 0.00001, options = {}) {
|
||||
const sampleSizeRecip = 1 / features.length;
|
||||
const sampleWeights = options.sampleWeights || null;
|
||||
let loss = 0;
|
||||
const history = [];
|
||||
const onIteration = typeof options.onIteration === 'function' ? options.onIteration : null;
|
||||
|
||||
for (let iter = 0; iter < maxIterations; iter++) {
|
||||
loss = 0;
|
||||
|
||||
for (let s = 0; s < features.length; s++) {
|
||||
const weight = sampleWeights ? sampleWeights[s] : sampleSizeRecip;
|
||||
const { output, activations } = this.getOutput(features[s], false);
|
||||
const derivError = new Array(output.length);
|
||||
|
||||
loss += mseLoss(labels[s], output, derivError, weight);
|
||||
|
||||
// Backprop with direct weight update
|
||||
let tempDerivError = derivError;
|
||||
for (let i = this.numHiddenLayers; i >= 0; i--) {
|
||||
const deltas = this.layers[i].updateWeights(activations[i], tempDerivError, learningRate, false);
|
||||
if (i > 0) tempDerivError = deltas;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom weights already normalized — loss is already correctly scaled
|
||||
if (!sampleWeights) loss *= sampleSizeRecip;
|
||||
history.push(loss);
|
||||
|
||||
if (onIteration) onIteration(iter, loss);
|
||||
|
||||
if (this.progressCallback && (iter & 0x1F) === 0) {
|
||||
this.progressCallback(iter, loss);
|
||||
}
|
||||
|
||||
if (loss < minError) break;
|
||||
}
|
||||
|
||||
this.lastTrainingHistory = history;
|
||||
return loss;
|
||||
}
|
||||
|
||||
// Batch training with RMSProp (TrainBatch from C++)
|
||||
trainBatch(features, labels, learningRate, maxIterations = 1000, batchSize = 8, minError = 0.00001, options = {}) {
|
||||
const nSamples = features.length;
|
||||
const nBatches = Math.ceil(nSamples / batchSize);
|
||||
let epochLoss = 0;
|
||||
const history = [];
|
||||
const onIteration = typeof options.onIteration === 'function' ? options.onIteration : null;
|
||||
|
||||
for (let iter = 0; iter < maxIterations; iter++) {
|
||||
epochLoss = 0;
|
||||
|
||||
// Shuffle indices
|
||||
const indices = Array.from({ length: nSamples }, (_, i) => i);
|
||||
shuffleArray(indices);
|
||||
|
||||
let sampleIdx = 0;
|
||||
|
||||
for (let batch = 0; batch < nBatches; batch++) {
|
||||
const currentBatchSize = Math.min(batchSize, nSamples - sampleIdx);
|
||||
const batchSizeRecip = 1 / currentBatchSize;
|
||||
|
||||
// Initialize gradient accumulators
|
||||
for (const layer of this.layers) {
|
||||
layer.initializeGradientAccumulators();
|
||||
}
|
||||
|
||||
let batchLoss = 0;
|
||||
|
||||
for (let i = 0; i < currentBatchSize; i++) {
|
||||
const idx = indices[sampleIdx++];
|
||||
const { output, activations } = this.getOutput(features[idx], false);
|
||||
const derivError = new Array(output.length);
|
||||
|
||||
batchLoss += mseLoss(labels[idx], output, derivError, 1.0);
|
||||
|
||||
// Backprop with accumulation
|
||||
let tempDerivError = derivError;
|
||||
for (let li = this.numHiddenLayers; li >= 0; li--) {
|
||||
const deltas = this.layers[li].updateWeights(activations[li], tempDerivError, 0, true);
|
||||
if (li > 0) tempDerivError = deltas;
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient clipping (norm > 5.0)
|
||||
let gradSumSq = 0;
|
||||
for (const layer of this.layers) {
|
||||
gradSumSq += layer.getGradSumSquared(batchSizeRecip);
|
||||
}
|
||||
const gradNorm = Math.sqrt(gradSumSq);
|
||||
|
||||
if (gradNorm > 5.0) {
|
||||
const clipCoef = 5.0 / gradNorm;
|
||||
for (const layer of this.layers) {
|
||||
layer.scaleAccumulatedGradients(clipCoef);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply accumulated gradients
|
||||
for (const layer of this.layers) {
|
||||
layer.applyAccumulatedGradients(learningRate, batchSizeRecip);
|
||||
}
|
||||
|
||||
epochLoss += batchLoss / currentBatchSize;
|
||||
}
|
||||
|
||||
epochLoss /= nBatches;
|
||||
history.push(epochLoss);
|
||||
if (onIteration) onIteration(iter, epochLoss);
|
||||
|
||||
if (this.progressCallback) {
|
||||
this.progressCallback(iter, epochLoss);
|
||||
}
|
||||
|
||||
if (epochLoss < minError) break;
|
||||
}
|
||||
|
||||
this.lastTrainingHistory = history;
|
||||
return epochLoss;
|
||||
}
|
||||
|
||||
getWeights() {
|
||||
return this.layers.map(layer =>
|
||||
layer.nodes.map(node => ({
|
||||
weights: node.getWeightsCopy(),
|
||||
bias: node.bias,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
setWeights(weights) {
|
||||
for (let l = 0; l < this.layers.length; l++) {
|
||||
for (let n = 0; n < this.layers[l].nodes.length; n++) {
|
||||
this.layers[l].nodes[n].setWeights(weights[l][n].weights);
|
||||
this.layers[l].nodes[n].bias = weights[l][n].bias;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DrawWeights - randomize all weights
|
||||
// spread: 0 = uniform [-1,1] (polarised sigmoid outputs), 1 = Xavier-scaled (centered outputs)
|
||||
drawWeights(spread = 0) {
|
||||
for (let l = 0; l < this.layers.length; l++) {
|
||||
const fanIn = this.layersNodes[l];
|
||||
const xavierScale = 1 / Math.sqrt(fanIn);
|
||||
const scale = 1 * (1 - spread) + xavierScale * spread;
|
||||
for (const node of this.layers[l].nodes) {
|
||||
for (let j = 0; j < node.weights.length; j++) {
|
||||
node.weights[j] = (Math.random() * 2 - 1) * scale;
|
||||
}
|
||||
node.bias = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MoveWeights - add Gaussian noise (port of gen_randn)
|
||||
// spread: 0 = flat noise across all layers (original), 1 = scale noise by 1/sqrt(fan_in)
|
||||
// per layer so perturbations stay proportional to Xavier-scale weights and don't
|
||||
// saturate sigmoid outputs.
|
||||
// Weight decay (proportional to spread) shrinks weights toward zero before adding noise,
|
||||
// preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is
|
||||
// no decay (original behavior). At spread=1 each call decays weights by ~10%, creating
|
||||
// a natural equilibrium where exploration can't permanently saturate sigmoid.
|
||||
//
|
||||
// outputPinMask: optional Uint8Array[numOutputs]. If provided, output-layer nodes where
|
||||
// mask[i] === 1 are skipped (their weights are not perturbed). Only affects the last layer.
|
||||
moveWeights(speed, spread = 0, outputPinMask = null) {
|
||||
const decay = 1 - 0.1 * spread; // spread=0 → 1.0 (no decay), spread=1 → 0.9
|
||||
for (let l = 0; l < this.layers.length; l++) {
|
||||
const fanIn = this.layersNodes[l];
|
||||
const xavierScale = 1 / Math.sqrt(fanIn);
|
||||
const layerScale = 1 * (1 - spread) + xavierScale * spread;
|
||||
const isOutputLayer = l === this.layers.length - 1;
|
||||
const nodes = this.layers[l].nodes;
|
||||
for (let ni = 0; ni < nodes.length; ni++) {
|
||||
// Skip pinned output nodes in the final layer
|
||||
if (isOutputLayer && outputPinMask && outputPinMask[ni]) continue;
|
||||
|
||||
const node = nodes[ni];
|
||||
for (let j = 0; j < node.weights.length; j++) {
|
||||
// Decay toward zero to prevent magnitude drift
|
||||
node.weights[j] *= decay;
|
||||
// gen_randn: sum of 3 uniform randoms * kN_times * stddev + mean
|
||||
let accum = 0;
|
||||
for (let n = 0; n < 3; n++) {
|
||||
accum += Math.random() * 2 - 1; // gen_rand with range 2.0
|
||||
}
|
||||
node.weights[j] += 3 * accum * speed * layerScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetOptimizerState() {
|
||||
for (const layer of this.layers) {
|
||||
layer.resetOptimizerState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
// Web Worker for async WASM training (module worker)
|
||||
// Loads its own nisps WASM instance for off-thread training
|
||||
|
||||
import NispsModule from '../../wasm/nisps.js';
|
||||
|
||||
let mod = null;
|
||||
let w = null;
|
||||
let mlp = null;
|
||||
let currentLayerSizes = null;
|
||||
|
||||
async function ensureModule() {
|
||||
if (mod) return;
|
||||
mod = await NispsModule();
|
||||
w = {
|
||||
mod,
|
||||
create: mod.cwrap('nisps_mlp_create', 'number', ['number', 'number', 'number', 'number']),
|
||||
destroy: mod.cwrap('nisps_mlp_destroy', null, ['number']),
|
||||
weightCount: mod.cwrap('nisps_mlp_weight_count', 'number', ['number']),
|
||||
getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']),
|
||||
setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']),
|
||||
train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
|
||||
trainEx: mod.cwrap('nisps_mlp_train_ex', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
|
||||
alloc: mod.cwrap('nisps_alloc', 'number', ['number']),
|
||||
free: mod.cwrap('nisps_free', null, ['number']),
|
||||
allocInt: mod.cwrap('nisps_alloc_int', 'number', ['number']),
|
||||
freeInt: mod.cwrap('nisps_free_int', null, ['number']),
|
||||
};
|
||||
}
|
||||
|
||||
function toHeapF32(arr) {
|
||||
const ptr = w.alloc(arr.length);
|
||||
w.mod.HEAPF32.set(arr, ptr >> 2);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function toHeapI32(arr) {
|
||||
const ptr = w.allocInt(arr.length);
|
||||
w.mod.HEAP32.set(arr, ptr >> 2);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function fromHeapF32(ptr, length) {
|
||||
const offset = ptr >> 2;
|
||||
return Array.from(w.mod.HEAPF32.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
function arraysEqual(a, b) {
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
self.onmessage = async function(e) {
|
||||
const { type, payload } = e.data;
|
||||
|
||||
if (type === 'train') {
|
||||
await ensureModule();
|
||||
|
||||
const {
|
||||
layerSizes, activationIds, weights,
|
||||
features, labels, sampleWeights,
|
||||
nInputs, nOutputs,
|
||||
learningRate, maxIterations, convergenceThreshold,
|
||||
} = payload;
|
||||
|
||||
// Recreate MLP if architecture changed
|
||||
if (!mlp || !arraysEqual(currentLayerSizes, layerSizes)) {
|
||||
if (mlp) w.destroy(mlp);
|
||||
const layerPtr = toHeapI32(new Int32Array(layerSizes));
|
||||
const actPtr = toHeapI32(new Int32Array(activationIds));
|
||||
mlp = w.create(layerPtr, layerSizes.length, actPtr, activationIds.length);
|
||||
w.freeInt(layerPtr);
|
||||
w.freeInt(actPtr);
|
||||
currentLayerSizes = [...layerSizes];
|
||||
}
|
||||
|
||||
// Load weights from main thread
|
||||
const weightCount = w.weightCount(mlp);
|
||||
const wPtr = toHeapF32(new Float32Array(weights));
|
||||
w.setWeights(mlp, wPtr);
|
||||
w.free(wPtr);
|
||||
|
||||
// Build flat training arrays with bias
|
||||
const featureDim = nInputs + 1;
|
||||
const nSamples = features.length;
|
||||
const featFlat = new Float32Array(nSamples * featureDim);
|
||||
const labFlat = new Float32Array(nSamples * nOutputs);
|
||||
|
||||
for (let i = 0; i < nSamples; i++) {
|
||||
for (let j = 0; j < nInputs; j++) {
|
||||
featFlat[i * featureDim + j] = features[i][j];
|
||||
}
|
||||
featFlat[i * featureDim + nInputs] = 1.0;
|
||||
for (let j = 0; j < nOutputs; j++) {
|
||||
labFlat[i * nOutputs + j] = labels[i][j] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const featPtr = toHeapF32(featFlat);
|
||||
const labPtr = toHeapF32(labFlat);
|
||||
const weightPtr = sampleWeights ? toHeapF32(new Float32Array(sampleWeights)) : 0;
|
||||
const lossHistPtr = w.alloc(maxIterations);
|
||||
|
||||
// Train (extended — captures per-iteration loss history)
|
||||
const itersRun = w.trainEx(
|
||||
mlp, featPtr, nSamples, featureDim,
|
||||
labPtr, nOutputs,
|
||||
weightPtr,
|
||||
learningRate, maxIterations, convergenceThreshold,
|
||||
lossHistPtr
|
||||
);
|
||||
|
||||
// Read per-iteration loss history
|
||||
const lossHistory = fromHeapF32(lossHistPtr, itersRun);
|
||||
|
||||
w.free(featPtr);
|
||||
w.free(labPtr);
|
||||
if (weightPtr) w.free(weightPtr);
|
||||
w.free(lossHistPtr);
|
||||
|
||||
const loss = itersRun > 0 ? lossHistory[itersRun - 1] : 0;
|
||||
|
||||
// Extract trained weights
|
||||
const outPtr = w.alloc(weightCount);
|
||||
w.getWeights(mlp, outPtr);
|
||||
const trainedWeights = fromHeapF32(outPtr, weightCount);
|
||||
w.free(outPtr);
|
||||
|
||||
self.postMessage({
|
||||
type: 'trained',
|
||||
payload: { weights: trainedWeights, loss, lossHistory },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,612 +0,0 @@
|
|||
// WASM-backed IML — drop-in replacement for the JS IML class.
|
||||
// Uses nisps-core compiled to WASM for inference, training, and weight ops.
|
||||
// Training runs in a Web Worker for non-blocking operation.
|
||||
|
||||
import { Dataset } from './dataset.js';
|
||||
|
||||
// Activation function IDs matching C++ nisps::ACTIVATION_FUNCTIONS enum
|
||||
const ACTIVATION = { SIGMOID: 0, TANH: 1, LINEAR: 2, RELU: 3 };
|
||||
|
||||
/**
|
||||
* Load the Emscripten module. Returns the initialized module.
|
||||
*/
|
||||
async function loadNispsModule() {
|
||||
const { default: NispsModule } = await import('../../wasm/nisps.js');
|
||||
const mod = await NispsModule();
|
||||
return mod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw Emscripten module with typed JS helpers.
|
||||
*/
|
||||
function wrapModule(mod) {
|
||||
return {
|
||||
mod,
|
||||
create: mod.cwrap('nisps_mlp_create', 'number', ['number', 'number', 'number', 'number']),
|
||||
destroy: mod.cwrap('nisps_mlp_destroy', null, ['number']),
|
||||
weightCount: mod.cwrap('nisps_mlp_weight_count', 'number', ['number']),
|
||||
getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']),
|
||||
setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']),
|
||||
inference: mod.cwrap('nisps_mlp_inference', null, ['number', 'number', 'number', 'number', 'number']),
|
||||
train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
|
||||
drawWeightsSpread: mod.cwrap('nisps_mlp_draw_weights_spread', null, ['number', 'number']),
|
||||
moveWeightsSpread: mod.cwrap('nisps_mlp_move_weights_spread', null, ['number', 'number', 'number']),
|
||||
inferBatch: mod.cwrap('nisps_mlp_infer_batch', null, ['number', 'number', 'number', 'number', 'number', 'number']),
|
||||
trainEx: mod.cwrap('nisps_mlp_train_ex', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
|
||||
moveWeightsEx: mod.cwrap('nisps_mlp_move_weights_ex', null, ['number', 'number', 'number', 'number', 'number']),
|
||||
evalLoss: mod.cwrap('nisps_mlp_eval_loss', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number']),
|
||||
getLayerStats: mod.cwrap('nisps_mlp_get_layer_stats', null, ['number', 'number', 'number']),
|
||||
alloc: mod.cwrap('nisps_alloc', 'number', ['number']),
|
||||
free: mod.cwrap('nisps_free', null, ['number']),
|
||||
allocInt: mod.cwrap('nisps_alloc_int', 'number', ['number']),
|
||||
freeInt: mod.cwrap('nisps_free_int', null, ['number']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a JS array into WASM heap, returning the pointer.
|
||||
* Caller must free with w.free(ptr).
|
||||
*/
|
||||
function toHeapF32(w, arr) {
|
||||
const ptr = w.alloc(arr.length);
|
||||
w.mod.HEAPF32.set(arr, ptr >> 2);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function toHeapI32(w, arr) {
|
||||
const ptr = w.allocInt(arr.length);
|
||||
w.mod.HEAP32.set(arr, ptr >> 2);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function fromHeapF32(w, ptr, length) {
|
||||
const offset = ptr >> 2;
|
||||
return Array.from(w.mod.HEAPF32.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
/**
|
||||
* WASM-backed IML class — API-compatible with the JS IML.
|
||||
*
|
||||
* Construction is async: use `await WasmIML.create(...)` instead of `new IML(...)`.
|
||||
*/
|
||||
export class WasmIML {
|
||||
/**
|
||||
* Async factory — loads WASM and constructs the IML.
|
||||
*/
|
||||
static async create(
|
||||
nInputs,
|
||||
nOutputs,
|
||||
hiddenLayers = [10, 10, 14],
|
||||
maxIterations = 1000,
|
||||
learningRate = 1.0,
|
||||
convergenceThreshold = 0.00001
|
||||
) {
|
||||
const mod = await loadNispsModule();
|
||||
const iml = new WasmIML(mod, nInputs, nOutputs, hiddenLayers,
|
||||
maxIterations, learningRate, convergenceThreshold);
|
||||
return iml;
|
||||
}
|
||||
|
||||
constructor(mod, nInputs, nOutputs, hiddenLayers, maxIterations, learningRate, convergenceThreshold) {
|
||||
this.nInputs = nInputs;
|
||||
this.nOutputs = nOutputs;
|
||||
this.maxIterations = maxIterations;
|
||||
this.learningRate = learningRate;
|
||||
this.convergenceThreshold = convergenceThreshold;
|
||||
this.recencyBias = 0.6; // 0 = uniform, 1 = strong recency
|
||||
this.weightingMode = 'global'; // 'global' | 'local' | 'combined'
|
||||
this.localRadius = 0.15; // input-space radius for local weighting
|
||||
|
||||
// Layer sizes: input+bias, hidden..., output
|
||||
const BIAS = 1;
|
||||
this.layerSizes = [nInputs + BIAS, ...hiddenLayers, nOutputs];
|
||||
// Activations: RELU for hidden, SIGMOID for output
|
||||
this.activationIds = [
|
||||
...hiddenLayers.map(() => ACTIVATION.RELU),
|
||||
ACTIVATION.SIGMOID,
|
||||
];
|
||||
|
||||
// WASM module + helpers
|
||||
this._w = wrapModule(mod);
|
||||
this._createMLP();
|
||||
|
||||
// State (JS-side, same as original IML)
|
||||
this.inputState = new Array(nInputs).fill(0.5);
|
||||
this.outputState = new Array(nOutputs).fill(0);
|
||||
this.performInference = true;
|
||||
this.inputUpdated = true;
|
||||
this.storedWeights = null;
|
||||
this.weightsRandomised = false;
|
||||
this.lastLoss = null;
|
||||
this.bestLoss = null;
|
||||
this.lossHistory = [];
|
||||
this.totalTrainingIterations = 0;
|
||||
this.logFn = null;
|
||||
|
||||
// Dataset (JS-side for persistence/visualization access and sample weighting)
|
||||
this.dataset = new Dataset(100);
|
||||
|
||||
// Persistent WASM buffers for inference (avoid alloc/free per frame)
|
||||
const inputDim = nInputs + BIAS;
|
||||
this._inputPtr = this._w.alloc(inputDim);
|
||||
this._outputPtr = this._w.alloc(nOutputs);
|
||||
this._inputDim = inputDim;
|
||||
|
||||
// Worker for async training
|
||||
this._worker = null;
|
||||
this._training = false;
|
||||
}
|
||||
|
||||
_createMLP() {
|
||||
const w = this._w;
|
||||
const layerPtr = toHeapI32(w, new Int32Array(this.layerSizes));
|
||||
const actPtr = toHeapI32(w, new Int32Array(this.activationIds));
|
||||
this._mlp = w.create(layerPtr, this.layerSizes.length, actPtr, this.activationIds.length);
|
||||
w.freeInt(layerPtr);
|
||||
w.freeInt(actPtr);
|
||||
this._weightCount = w.weightCount(this._mlp);
|
||||
}
|
||||
|
||||
// ---- Logging ----
|
||||
setLogger(fn) { this.logFn = fn; }
|
||||
log(msg) { if (this.logFn) this.logFn(msg); }
|
||||
|
||||
// ---- Input / Output ----
|
||||
setInput(index, value) {
|
||||
if (index >= this.nInputs) return;
|
||||
this.inputState[index] = Math.max(0, Math.min(1, value));
|
||||
this.inputUpdated = true;
|
||||
}
|
||||
|
||||
setInputs(values) {
|
||||
for (let i = 0; i < values.length && i < this.nInputs; i++) {
|
||||
this.inputState[i] = Math.max(0, Math.min(1, values[i]));
|
||||
}
|
||||
this.inputUpdated = true;
|
||||
}
|
||||
|
||||
getOutputs() { return this.outputState; }
|
||||
|
||||
setOutput(index, value) {
|
||||
if (index >= this.nOutputs) return;
|
||||
this.outputState[index] = Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
setOutputs(values) {
|
||||
for (let i = 0; i < values.length && i < this.nOutputs; i++) {
|
||||
this.outputState[i] = Math.max(0, Math.min(1, values[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inference (WASM, synchronous — fast) ----
|
||||
process() {
|
||||
if (!this.performInference || !this.inputUpdated) return;
|
||||
|
||||
// Write input + bias into persistent WASM buffer
|
||||
const heap = this._w.mod.HEAPF32;
|
||||
const inOff = this._inputPtr >> 2;
|
||||
for (let i = 0; i < this.nInputs; i++) {
|
||||
heap[inOff + i] = this.inputState[i];
|
||||
}
|
||||
heap[inOff + this.nInputs] = 1.0; // bias
|
||||
|
||||
this._w.inference(this._mlp, this._inputPtr, this._inputDim, this._outputPtr, this.nOutputs);
|
||||
|
||||
// Read output
|
||||
const outOff = this._outputPtr >> 2;
|
||||
for (let i = 0; i < this.nOutputs; i++) {
|
||||
this.outputState[i] = heap[outOff + i];
|
||||
}
|
||||
|
||||
this.inputUpdated = false;
|
||||
}
|
||||
|
||||
// ---- Batch inference (WASM) ----
|
||||
inferBatch(inputPoints) {
|
||||
// inputPoints: array of [x,y,...] arrays (each length nInputs)
|
||||
// Returns: array of output arrays (each length nOutputs)
|
||||
const nPoints = inputPoints.length;
|
||||
const inputDim = this.nInputs + 1; // +bias
|
||||
const inFlat = new Float32Array(nPoints * inputDim);
|
||||
for (let i = 0; i < nPoints; i++) {
|
||||
for (let j = 0; j < this.nInputs; j++) {
|
||||
inFlat[i * inputDim + j] = inputPoints[i][j];
|
||||
}
|
||||
inFlat[i * inputDim + this.nInputs] = 1.0; // bias
|
||||
}
|
||||
const inPtr = toHeapF32(this._w, inFlat);
|
||||
const outPtr = this._w.alloc(nPoints * this.nOutputs);
|
||||
this._w.inferBatch(this._mlp, inPtr, nPoints, inputDim, outPtr, this.nOutputs);
|
||||
// Read outputs
|
||||
const results = [];
|
||||
const heap = this._w.mod.HEAPF32;
|
||||
const outOff = outPtr >> 2;
|
||||
for (let i = 0; i < nPoints; i++) {
|
||||
const row = new Array(this.nOutputs);
|
||||
for (let j = 0; j < this.nOutputs; j++) {
|
||||
row[j] = heap[outOff + i * this.nOutputs + j];
|
||||
}
|
||||
results.push(row);
|
||||
}
|
||||
this._w.free(inPtr);
|
||||
this._w.free(outPtr);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- Dataset ----
|
||||
addExample(inputs, outputs) {
|
||||
const inVec = inputs.slice(0, this.nInputs);
|
||||
while (inVec.length < this.nInputs) inVec.push(0);
|
||||
const outVec = outputs.slice(0, this.nOutputs);
|
||||
while (outVec.length < this.nOutputs) outVec.push(0);
|
||||
this.dataset.add(inVec, outVec);
|
||||
}
|
||||
|
||||
clearDataset() {
|
||||
this.dataset.clear();
|
||||
this.log('Dataset cleared.');
|
||||
}
|
||||
|
||||
get exampleCount() { return this.dataset.features.length; }
|
||||
|
||||
// ---- Training (WASM, synchronous) ----
|
||||
train(options = {}) {
|
||||
if (this.weightsRandomised && this.storedWeights) {
|
||||
this._setFlatWeights(this.storedWeights);
|
||||
this.weightsRandomised = false;
|
||||
}
|
||||
|
||||
if (this.dataset.features.length === 0) {
|
||||
this.log('Empty dataset, skipping training.');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.log('Training...');
|
||||
|
||||
const features = this.dataset.features;
|
||||
const labels = this.dataset.labels;
|
||||
|
||||
// Compute per-sample weights
|
||||
const sampleWeights = this.dataset.computeWeights(this.weightingMode, {
|
||||
recencyBias: this.recencyBias,
|
||||
queryInput: this.inputState,
|
||||
radius: this.localRadius,
|
||||
});
|
||||
|
||||
// Build flat arrays with bias appended to features
|
||||
const featureDim = this.nInputs + 1; // +bias
|
||||
const nSamples = features.length;
|
||||
const featFlat = new Float32Array(nSamples * featureDim);
|
||||
const labFlat = new Float32Array(nSamples * this.nOutputs);
|
||||
|
||||
for (let i = 0; i < nSamples; i++) {
|
||||
for (let j = 0; j < this.nInputs; j++) {
|
||||
featFlat[i * featureDim + j] = features[i][j];
|
||||
}
|
||||
featFlat[i * featureDim + this.nInputs] = 1.0; // bias
|
||||
for (let j = 0; j < this.nOutputs; j++) {
|
||||
labFlat[i * this.nOutputs + j] = labels[i][j] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const featPtr = toHeapF32(this._w, featFlat);
|
||||
const labPtr = toHeapF32(this._w, labFlat);
|
||||
const weightPtr = toHeapF32(this._w, sampleWeights);
|
||||
const lossHistPtr = this._w.alloc(this.maxIterations);
|
||||
|
||||
const itersRun = this._w.trainEx(
|
||||
this._mlp, featPtr, nSamples, featureDim,
|
||||
labPtr, this.nOutputs,
|
||||
weightPtr,
|
||||
this.learningRate, this.maxIterations, this.convergenceThreshold,
|
||||
lossHistPtr
|
||||
);
|
||||
|
||||
// Read per-iteration loss history
|
||||
const lossHist = fromHeapF32(this._w, lossHistPtr, itersRun);
|
||||
|
||||
this._w.free(featPtr);
|
||||
this._w.free(labPtr);
|
||||
this._w.free(weightPtr);
|
||||
this._w.free(lossHistPtr);
|
||||
|
||||
const loss = itersRun > 0 ? lossHist[itersRun - 1] : 0;
|
||||
this.lastLoss = loss;
|
||||
for (let i = 0; i < lossHist.length; i++) {
|
||||
this.lossHistory.push(lossHist[i]);
|
||||
}
|
||||
this.totalTrainingIterations += itersRun;
|
||||
if (this.lossHistory.length > 1200) {
|
||||
this.lossHistory = this.lossHistory.slice(this.lossHistory.length - 1200);
|
||||
}
|
||||
this.bestLoss = this.bestLoss === null ? loss : Math.min(this.bestLoss, loss);
|
||||
|
||||
// Run inference after training
|
||||
this.inputUpdated = true;
|
||||
this.process();
|
||||
|
||||
this.log(`Training complete. Loss: ${loss.toFixed(6)}`);
|
||||
return loss;
|
||||
}
|
||||
|
||||
// ---- Loss evaluation (WASM, no weight update) ----
|
||||
evalLoss() {
|
||||
if (this.dataset.features.length === 0) return null;
|
||||
|
||||
const features = this.dataset.features;
|
||||
const labels = this.dataset.labels;
|
||||
|
||||
const sampleWeights = this.dataset.computeWeights(this.weightingMode, {
|
||||
recencyBias: this.recencyBias,
|
||||
queryInput: this.inputState,
|
||||
radius: this.localRadius,
|
||||
});
|
||||
|
||||
const featureDim = this.nInputs + 1;
|
||||
const nSamples = features.length;
|
||||
const featFlat = new Float32Array(nSamples * featureDim);
|
||||
const labFlat = new Float32Array(nSamples * this.nOutputs);
|
||||
|
||||
for (let i = 0; i < nSamples; i++) {
|
||||
for (let j = 0; j < this.nInputs; j++) {
|
||||
featFlat[i * featureDim + j] = features[i][j];
|
||||
}
|
||||
featFlat[i * featureDim + this.nInputs] = 1.0;
|
||||
for (let j = 0; j < this.nOutputs; j++) {
|
||||
labFlat[i * this.nOutputs + j] = labels[i][j] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const featPtr = toHeapF32(this._w, featFlat);
|
||||
const labPtr = toHeapF32(this._w, labFlat);
|
||||
const weightPtr = toHeapF32(this._w, sampleWeights);
|
||||
|
||||
const loss = this._w.evalLoss(
|
||||
this._mlp, featPtr, nSamples, featureDim,
|
||||
labPtr, this.nOutputs,
|
||||
weightPtr
|
||||
);
|
||||
|
||||
this._w.free(featPtr);
|
||||
this._w.free(labPtr);
|
||||
this._w.free(weightPtr);
|
||||
|
||||
return loss;
|
||||
}
|
||||
|
||||
// ---- Weight manipulation ----
|
||||
randomiseWeights(spread = 0) {
|
||||
this.storedWeights = this._getFlatWeights();
|
||||
this._w.drawWeightsSpread(this._mlp, spread);
|
||||
this.weightsRandomised = true;
|
||||
|
||||
this.inputUpdated = true;
|
||||
this.process();
|
||||
this.log('Weights randomised.');
|
||||
}
|
||||
|
||||
// outputPinMask: optional Uint8Array[nOutputs], 1 = skip that output node.
|
||||
moveWeights(speed, spread = 0, outputPinMask = null) {
|
||||
let pinMaskPtr = 0;
|
||||
|
||||
if (outputPinMask && outputPinMask.some(v => v)) {
|
||||
const pinI32 = new Int32Array(this.nOutputs);
|
||||
for (let i = 0; i < this.nOutputs; i++) {
|
||||
pinI32[i] = outputPinMask[i] ? 1 : 0;
|
||||
}
|
||||
pinMaskPtr = toHeapI32(this._w, pinI32);
|
||||
}
|
||||
|
||||
this._w.moveWeightsEx(this._mlp, speed, spread, pinMaskPtr, this.nOutputs);
|
||||
|
||||
if (pinMaskPtr) this._w.freeInt(pinMaskPtr);
|
||||
|
||||
this.inputUpdated = true;
|
||||
this.process();
|
||||
}
|
||||
|
||||
// ---- Public weight snapshot for warm-start transfer ----
|
||||
/**
|
||||
* Returns a plain object describing the full network weights for later
|
||||
* reinjection via WasmIML.createWithWarmStart().
|
||||
*/
|
||||
extractWeights() {
|
||||
return {
|
||||
layerSizes: [...this.layerSizes], // e.g. [3, 32, 48, 64, 126]
|
||||
weights: this._getFlatWeights(), // plain Array from fromHeapF32
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Async static factory: create a new WasmIML with newOutputCount outputs,
|
||||
* transferring as much of snapshot.weights as possible.
|
||||
* Hidden-layer weights are copied unchanged; output nodes beyond the old
|
||||
* count retain their random-initialised values.
|
||||
*/
|
||||
static async createWithWarmStart(snapshot, newOutputCount, maxIter, learningRate, convergenceThreshold) {
|
||||
const oldLayerSizes = snapshot.layerSizes;
|
||||
const nInputs = oldLayerSizes[0] - 1; // stored with bias (+1), strip it
|
||||
const hiddenLayers = oldLayerSizes.slice(1, -1); // e.g. [32, 48, 64]
|
||||
const oldOutputCount = oldLayerSizes[oldLayerSizes.length - 1];
|
||||
|
||||
// Create fresh instance with new output count
|
||||
const newIml = await WasmIML.create(nInputs, newOutputCount, hiddenLayers, maxIter, learningRate, convergenceThreshold);
|
||||
|
||||
// Calculate prefix weight count (all layers except the final output layer).
|
||||
// Each layer l: n_nodes[l] * (n_nodes[l-1] + 1) weights (inputs + bias).
|
||||
// fullOldSizes: [nInputs+1, ...hiddenLayers, oldOutputCount]
|
||||
let prefixCount = 0;
|
||||
for (let l = 1; l < oldLayerSizes.length - 1; l++) {
|
||||
prefixCount += oldLayerSizes[l] * (oldLayerSizes[l - 1] + 1);
|
||||
}
|
||||
|
||||
const lastHidden = hiddenLayers[hiddenLayers.length - 1]; // e.g. 64
|
||||
const weightsPerOutputNode = lastHidden + 1; // inputs from last hidden + bias
|
||||
|
||||
// Build new weight array, starting from random init
|
||||
const newWeights = newIml._getFlatWeights();
|
||||
const oldWeights = snapshot.weights;
|
||||
|
||||
// Copy hidden layer weights unchanged
|
||||
for (let i = 0; i < prefixCount; i++) {
|
||||
newWeights[i] = oldWeights[i];
|
||||
}
|
||||
|
||||
// Copy output layer weights for nodes that existed in the old network
|
||||
const sharedOutputNodes = Math.min(oldOutputCount, newOutputCount);
|
||||
for (let n = 0; n < sharedOutputNodes; n++) {
|
||||
const oldOff = prefixCount + n * weightsPerOutputNode;
|
||||
const newOff = prefixCount + n * weightsPerOutputNode;
|
||||
for (let w = 0; w < weightsPerOutputNode; w++) {
|
||||
newWeights[newOff + w] = oldWeights[oldOff + w];
|
||||
}
|
||||
}
|
||||
// Nodes beyond old count retain their random init — good for exploration
|
||||
|
||||
newIml._setFlatWeights(newWeights);
|
||||
return newIml;
|
||||
}
|
||||
|
||||
// ---- Flat weight get/set (for storedWeights save/restore) ----
|
||||
_getFlatWeights() {
|
||||
const ptr = this._w.alloc(this._weightCount);
|
||||
this._w.getWeights(this._mlp, ptr);
|
||||
const weights = fromHeapF32(this._w, ptr, this._weightCount);
|
||||
this._w.free(ptr);
|
||||
return weights;
|
||||
}
|
||||
|
||||
_setFlatWeights(flatWeights) {
|
||||
const ptr = toHeapF32(this._w, new Float32Array(flatWeights));
|
||||
this._w.setWeights(this._mlp, ptr);
|
||||
this._w.free(ptr);
|
||||
}
|
||||
|
||||
// ---- Per-layer weight statistics (WASM) ----
|
||||
getLayerStats() {
|
||||
const nLayers = this.layerSizes.length - 1;
|
||||
const statsPtr = this._w.alloc(nLayers * 4);
|
||||
this._w.getLayerStats(this._mlp, statsPtr, nLayers);
|
||||
const stats = [];
|
||||
const heap = this._w.mod.HEAPF32;
|
||||
const off = statsPtr >> 2;
|
||||
for (let l = 0; l < nLayers; l++) {
|
||||
stats.push({
|
||||
meanAbs: heap[off + l * 4 + 0],
|
||||
maxAbs: heap[off + l * 4 + 1],
|
||||
deadFrac: heap[off + l * 4 + 2],
|
||||
satFrac: heap[off + l * 4 + 3],
|
||||
});
|
||||
}
|
||||
this._w.free(statsPtr);
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ---- Async training via Web Worker ----
|
||||
get isTraining() { return this._training; }
|
||||
|
||||
trainAsync(onComplete) {
|
||||
if (this._training) {
|
||||
this.log('Training already in progress, skipping.');
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
// Restore weights if randomised
|
||||
if (this.weightsRandomised && this.storedWeights) {
|
||||
this._setFlatWeights(this.storedWeights);
|
||||
this.weightsRandomised = false;
|
||||
}
|
||||
|
||||
if (this.dataset.features.length === 0) {
|
||||
this.log('Empty dataset, skipping training.');
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
this._training = true;
|
||||
this.log('Training (async)...');
|
||||
|
||||
// Snapshot current weights + dataset for the worker
|
||||
const flatWeights = this._getFlatWeights();
|
||||
const features = this.dataset.features;
|
||||
const labels = this.dataset.labels;
|
||||
const sampleWeights = Array.from(this.dataset.computeWeights(this.weightingMode, {
|
||||
recencyBias: this.recencyBias,
|
||||
queryInput: this.inputState,
|
||||
radius: this.localRadius,
|
||||
}));
|
||||
|
||||
// Lazy-init worker
|
||||
if (!this._worker) {
|
||||
const workerUrl = new URL('./nisps-wasm-worker.js', import.meta.url);
|
||||
this._worker = new Worker(workerUrl, { type: 'module' });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const handler = (e) => {
|
||||
if (e.data.type === 'trained') {
|
||||
this._worker.removeEventListener('message', handler);
|
||||
this._training = false;
|
||||
|
||||
const { weights, loss, lossHistory } = e.data.payload;
|
||||
|
||||
// Swap in trained weights
|
||||
this._setFlatWeights(weights);
|
||||
this.lastLoss = loss;
|
||||
if (lossHistory && lossHistory.length > 0) {
|
||||
for (let i = 0; i < lossHistory.length; i++) {
|
||||
this.lossHistory.push(lossHistory[i]);
|
||||
}
|
||||
this.totalTrainingIterations += lossHistory.length;
|
||||
} else {
|
||||
this.lossHistory.push(loss);
|
||||
this.totalTrainingIterations += 1;
|
||||
}
|
||||
if (this.lossHistory.length > 1200) {
|
||||
this.lossHistory = this.lossHistory.slice(this.lossHistory.length - 1200);
|
||||
}
|
||||
this.bestLoss = this.bestLoss === null ? loss : Math.min(this.bestLoss, loss);
|
||||
|
||||
// Run inference with new weights
|
||||
this.inputUpdated = true;
|
||||
this.process();
|
||||
|
||||
this.log(`Training complete. Loss: ${loss.toFixed(6)}`);
|
||||
if (onComplete) onComplete({ loss, outputs: [...this.outputState] });
|
||||
resolve(loss);
|
||||
}
|
||||
};
|
||||
|
||||
this._worker.addEventListener('message', handler);
|
||||
this._worker.postMessage({
|
||||
type: 'train',
|
||||
payload: {
|
||||
layerSizes: this.layerSizes,
|
||||
activationIds: this.activationIds,
|
||||
weights: flatWeights,
|
||||
features,
|
||||
labels,
|
||||
sampleWeights,
|
||||
nInputs: this.nInputs,
|
||||
nOutputs: this.nOutputs,
|
||||
learningRate: this.learningRate,
|
||||
maxIterations: this.maxIterations,
|
||||
convergenceThreshold: this.convergenceThreshold,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Cleanup ----
|
||||
destroy() {
|
||||
if (this._mlp) {
|
||||
this._w.free(this._inputPtr);
|
||||
this._w.free(this._outputPtr);
|
||||
this._w.destroy(this._mlp);
|
||||
this._mlp = null;
|
||||
}
|
||||
if (this._worker) {
|
||||
this._worker.terminate();
|
||||
this._worker = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
// NISPS Node - faithful port of nisps-core/include/nisps/node.hpp
|
||||
// Single neural network node with weights, bias, and RMSProp optimizer
|
||||
|
||||
const RMSPROP_DECAY = 0.9;
|
||||
const RMSPROP_DECAY_INV = 0.1;
|
||||
const RMSPROP_EPSILON = 1e-6;
|
||||
const MAX_SQUARED_GRAD_AVG = 1e6;
|
||||
const MAX_ADJUSTED_LR = 1.0;
|
||||
const GRADIENT_CLIP_VALUE = 10.0;
|
||||
|
||||
export class Node {
|
||||
constructor(numInputs, useConstantInit = true, constantInit = 0.5) {
|
||||
this.numInputs = numInputs;
|
||||
this.bias = 0.0;
|
||||
this.weights = new Float64Array(numInputs);
|
||||
this.squaredGradientAvg = new Float64Array(numInputs);
|
||||
this.biasSquaredGradientAvg = 0;
|
||||
this.gradientAccumulator = new Float64Array(numInputs);
|
||||
this.biasGradientAccumulator = 0;
|
||||
this.innerProd = 0;
|
||||
|
||||
if (useConstantInit) {
|
||||
this.weights.fill(constantInit);
|
||||
} else {
|
||||
// gen_rand<T>(2.0) produces values in [-1, 1]
|
||||
for (let i = 0; i < numInputs; i++) {
|
||||
this.weights[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getInputInnerProdWithWeights(input) {
|
||||
let res = 0;
|
||||
for (let j = 0; j < input.length; j++) {
|
||||
res += input[j] * this.weights[j];
|
||||
}
|
||||
res += this.bias;
|
||||
this.innerProd = res;
|
||||
return this.innerProd;
|
||||
}
|
||||
|
||||
getOutputAfterActivation(input, activationFn) {
|
||||
this.getInputInnerProdWithWeights(input);
|
||||
return activationFn(this.innerProd);
|
||||
}
|
||||
|
||||
initializeGradientAccumulator() {
|
||||
this.gradientAccumulator = new Float64Array(this.weights.length);
|
||||
this.biasGradientAccumulator = 0;
|
||||
}
|
||||
|
||||
clearGradientAccumulator() {
|
||||
this.gradientAccumulator.fill(0);
|
||||
}
|
||||
|
||||
accumulateGradients(input, errorSignal) {
|
||||
for (let i = 0; i < this.weights.length; i++) {
|
||||
this.gradientAccumulator[i] += input[i] * errorSignal;
|
||||
}
|
||||
this.biasGradientAccumulator += errorSignal;
|
||||
}
|
||||
|
||||
applyAccumulatedGradients(learningRate, batchSizeInv) {
|
||||
for (let i = 0; i < this.weights.length; i++) {
|
||||
let gradient = this.gradientAccumulator[i] * batchSizeInv;
|
||||
|
||||
// Clamp gradient
|
||||
gradient = Math.max(Math.min(gradient, GRADIENT_CLIP_VALUE), -GRADIENT_CLIP_VALUE);
|
||||
|
||||
this.squaredGradientAvg[i] =
|
||||
RMSPROP_DECAY * this.squaredGradientAvg[i] +
|
||||
RMSPROP_DECAY_INV * gradient * gradient;
|
||||
|
||||
// Clamp squared gradient average
|
||||
this.squaredGradientAvg[i] = Math.min(this.squaredGradientAvg[i], MAX_SQUARED_GRAD_AVG);
|
||||
|
||||
let adjustedLR = learningRate / (Math.sqrt(this.squaredGradientAvg[i]) + RMSPROP_EPSILON);
|
||||
|
||||
// Clamp adjusted learning rate
|
||||
adjustedLR = Math.min(adjustedLR, MAX_ADJUSTED_LR);
|
||||
|
||||
this.weights[i] -= adjustedLR * gradient;
|
||||
this.gradientAccumulator[i] = 0;
|
||||
}
|
||||
|
||||
// Bias update
|
||||
let biasGradient = this.biasGradientAccumulator * batchSizeInv;
|
||||
biasGradient = Math.max(Math.min(biasGradient, GRADIENT_CLIP_VALUE), -GRADIENT_CLIP_VALUE);
|
||||
|
||||
this.biasSquaredGradientAvg =
|
||||
RMSPROP_DECAY * this.biasSquaredGradientAvg +
|
||||
RMSPROP_DECAY_INV * biasGradient * biasGradient;
|
||||
|
||||
this.biasSquaredGradientAvg = Math.min(this.biasSquaredGradientAvg, MAX_SQUARED_GRAD_AVG);
|
||||
|
||||
let biasAdjustedLR = learningRate / (Math.sqrt(this.biasSquaredGradientAvg) + RMSPROP_EPSILON);
|
||||
biasAdjustedLR = Math.min(biasAdjustedLR, MAX_ADJUSTED_LR);
|
||||
|
||||
this.bias -= biasAdjustedLR * biasGradient;
|
||||
this.biasGradientAccumulator = 0;
|
||||
}
|
||||
|
||||
getGradSumSquared(batchSizeInv) {
|
||||
let sumsq = 0;
|
||||
for (let i = 0; i < this.gradientAccumulator.length; i++) {
|
||||
const scaled = this.gradientAccumulator[i] * batchSizeInv;
|
||||
sumsq += scaled * scaled;
|
||||
}
|
||||
return sumsq;
|
||||
}
|
||||
|
||||
scaleAccumulatedGradients(clipCoef) {
|
||||
for (let i = 0; i < this.gradientAccumulator.length; i++) {
|
||||
this.gradientAccumulator[i] *= clipCoef;
|
||||
}
|
||||
}
|
||||
|
||||
updateWeight(weightId, increment, learningRate) {
|
||||
this.weights[weightId] += learningRate * increment;
|
||||
}
|
||||
|
||||
resetOptimizerState() {
|
||||
this.squaredGradientAvg.fill(0);
|
||||
this.biasSquaredGradientAvg = 0;
|
||||
}
|
||||
|
||||
checkAndFixWeights() {
|
||||
let hadCorruption = false;
|
||||
for (let i = 0; i < this.weights.length; i++) {
|
||||
if (!isFinite(this.weights[i])) {
|
||||
this.weights[i] = 0;
|
||||
this.squaredGradientAvg[i] = 0;
|
||||
hadCorruption = true;
|
||||
}
|
||||
}
|
||||
if (!isFinite(this.bias)) {
|
||||
this.bias = 0;
|
||||
this.biasSquaredGradientAvg = 0;
|
||||
hadCorruption = true;
|
||||
}
|
||||
return hadCorruption;
|
||||
}
|
||||
|
||||
getWeightsCopy() {
|
||||
return Array.from(this.weights);
|
||||
}
|
||||
|
||||
setWeights(weights) {
|
||||
for (let i = 0; i < this.weights.length; i++) {
|
||||
this.weights[i] = weights[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
// osc-client.js — WebSocket client for NISPS <-> OSC bridge
|
||||
// Connects the webapp to VCV Rack MEMLNaut module (or any OSC target)
|
||||
// via the bridge server (bridge.ts / bridge.mjs).
|
||||
//
|
||||
// Usage:
|
||||
// import { NispsOscClient } from './osc-client.js';
|
||||
// const osc = new NispsOscClient('ws://localhost:8765');
|
||||
// osc.onOutputsReceived(values => console.log('outputs:', values));
|
||||
// osc.onInputsReceived(values => console.log('inputs:', values));
|
||||
// await osc.connect();
|
||||
// osc.sendState(stateJson);
|
||||
|
||||
export class NispsOscClient extends EventTarget {
|
||||
/**
|
||||
* @param {string} wsUrl WebSocket URL of the bridge server
|
||||
*/
|
||||
constructor(wsUrl = 'ws://localhost:8765') {
|
||||
super();
|
||||
this._wsUrl = wsUrl;
|
||||
this._ws = null;
|
||||
this._connected = false;
|
||||
this._reconnect = false;
|
||||
this._reconnectDelay = 1000;
|
||||
this._reconnectTimer = null;
|
||||
|
||||
// Registered callbacks
|
||||
this._outputsCallbacks = [];
|
||||
this._inputsCallbacks = [];
|
||||
this._infoCallbacks = [];
|
||||
}
|
||||
|
||||
/** Current connection state */
|
||||
get connected() { return this._connected; }
|
||||
|
||||
/** The WebSocket URL */
|
||||
get url() { return this._wsUrl; }
|
||||
set url(val) {
|
||||
if (this._connected) {
|
||||
this.disconnect();
|
||||
}
|
||||
this._wsUrl = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the bridge server.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.reconnect=true] Auto-reconnect on disconnect
|
||||
* @returns {Promise<void>} Resolves when connected
|
||||
*/
|
||||
connect({ reconnect = true } = {}) {
|
||||
this._reconnect = reconnect;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this._connected && this._ws) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(this._wsUrl);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.onopen = () => {
|
||||
this._connected = true;
|
||||
this._reconnectDelay = 1000; // reset backoff
|
||||
this.dispatchEvent(new CustomEvent('connected'));
|
||||
resolve();
|
||||
};
|
||||
|
||||
this._ws.onclose = () => {
|
||||
const wasConnected = this._connected;
|
||||
this._connected = false;
|
||||
this._ws = null;
|
||||
this.dispatchEvent(new CustomEvent('disconnected'));
|
||||
|
||||
if (this._reconnect) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
|
||||
if (!wasConnected) {
|
||||
reject(new Error('WebSocket closed before connecting'));
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onerror = (e) => {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: e }));
|
||||
};
|
||||
|
||||
this._ws.onmessage = (e) => {
|
||||
this._handleMessage(e.data);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Disconnect from the bridge. */
|
||||
disconnect() {
|
||||
this._reconnect = false;
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // prevent reconnect trigger
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
this._connected = false;
|
||||
this.dispatchEvent(new CustomEvent('disconnected'));
|
||||
}
|
||||
|
||||
// ── Send methods ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send full .nisps state JSON to the VCV module.
|
||||
* The module will call dataFromJson to apply it.
|
||||
* @param {object|string} stateJson
|
||||
*/
|
||||
sendState(stateJson) {
|
||||
const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
|
||||
this._send({ type: 'state', payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send just weights to the VCV module.
|
||||
* @param {object} weightsObj { weights: [[[...]]] }
|
||||
*/
|
||||
sendWeights(weightsObj) {
|
||||
const payload = typeof weightsObj === 'string' ? JSON.parse(weightsObj) : weightsObj;
|
||||
this._send({ type: 'weights', payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send individual parameter updates (legacy format, for synth params).
|
||||
* @param {Array<[string, number]>} params e.g. [["Env_A_Att", 0.35], ...]
|
||||
*/
|
||||
sendParams(params) {
|
||||
this._send({ type: 'params', payload: params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a raw param batch in legacy format (backwards compatible).
|
||||
* @param {Array<[string, number]>} batch
|
||||
*/
|
||||
sendParamBatch(batch) {
|
||||
this._send(batch);
|
||||
}
|
||||
|
||||
// ── Receive handlers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a callback for output values from VCV.
|
||||
* @param {function(number[]): void} callback
|
||||
* @returns {function} unsubscribe function
|
||||
*/
|
||||
onOutputsReceived(callback) {
|
||||
this._outputsCallbacks.push(callback);
|
||||
return () => {
|
||||
const idx = this._outputsCallbacks.indexOf(callback);
|
||||
if (idx >= 0) this._outputsCallbacks.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for input values from VCV.
|
||||
* @param {function(number[]): void} callback
|
||||
* @returns {function} unsubscribe function
|
||||
*/
|
||||
onInputsReceived(callback) {
|
||||
this._inputsCallbacks.push(callback);
|
||||
return () => {
|
||||
const idx = this._inputsCallbacks.indexOf(callback);
|
||||
if (idx >= 0) this._inputsCallbacks.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for bridge info messages.
|
||||
* @param {function(string): void} callback
|
||||
* @returns {function} unsubscribe function
|
||||
*/
|
||||
onInfo(callback) {
|
||||
this._infoCallbacks.push(callback);
|
||||
return () => {
|
||||
const idx = this._infoCallbacks.indexOf(callback);
|
||||
if (idx >= 0) this._infoCallbacks.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────
|
||||
|
||||
_send(data) {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
||||
this._ws.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
_handleMessage(raw) {
|
||||
try {
|
||||
const msg = JSON.parse(raw);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'outputs':
|
||||
for (const cb of this._outputsCallbacks) cb(msg.values);
|
||||
this.dispatchEvent(new CustomEvent('outputs', { detail: msg.values }));
|
||||
break;
|
||||
|
||||
case 'inputs':
|
||||
for (const cb of this._inputsCallbacks) cb(msg.values);
|
||||
this.dispatchEvent(new CustomEvent('inputs', { detail: msg.values }));
|
||||
break;
|
||||
|
||||
case 'info':
|
||||
for (const cb of this._infoCallbacks) cb(msg.message);
|
||||
this.dispatchEvent(new CustomEvent('info', { detail: msg.message }));
|
||||
break;
|
||||
|
||||
case 'osc':
|
||||
// Generic OSC message passthrough
|
||||
this.dispatchEvent(new CustomEvent('osc', { detail: msg }));
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (this._reconnectTimer) return;
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
if (!this._connected && this._reconnect) {
|
||||
this.connect({ reconnect: true }).catch(() => {
|
||||
// increase backoff, max 30s
|
||||
this._reconnectDelay = Math.min(this._reconnectDelay * 1.5, 30000);
|
||||
});
|
||||
}
|
||||
}, this._reconnectDelay);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,397 +0,0 @@
|
|||
/**
|
||||
* ShapeSeq Chain — sequential pipeline runner with generator combination modes
|
||||
*
|
||||
* Evaluates an ordered list of primitives as a sequential pipeline:
|
||||
* 1. Generators run first, combined via additive or multiplicative merge
|
||||
* 2. Processors transform the pattern in chain order
|
||||
* 3. Converters run in chain order
|
||||
* 4. Timing modifiers annotate last
|
||||
*
|
||||
* Params are distributed flat across primitives in chain order.
|
||||
* Each primitive gets a deterministic PRNG stream via fork(masterPRNG, index).
|
||||
*
|
||||
* Port-ready: explicit state, typed arrays, no closures in hot path.
|
||||
*
|
||||
* @module shapeseq/chain
|
||||
*/
|
||||
|
||||
import { createPattern, mergePatterns } from './pattern.js';
|
||||
import { createPRNG, fork } from './prng.js';
|
||||
|
||||
// ── Category execution order ────────────────────────────────────────
|
||||
|
||||
const PHASE_ORDER = ['generator', 'processor', 'converter', 'timing'];
|
||||
|
||||
// ── Chain class ─────────────────────────────────────────────────────
|
||||
|
||||
export class Chain {
|
||||
constructor() {
|
||||
/** @private @type {Array<import('./primitive.js').Primitive>} */
|
||||
this._primitives = [];
|
||||
|
||||
/** @type {'additive'|'multiplicative'} */
|
||||
this.generatorCombineMode = 'additive';
|
||||
|
||||
/**
|
||||
* Per-generator step counts for polyrhythm support.
|
||||
* Maps chain index → step count. Generators not in this map
|
||||
* use the global stepCount passed to evaluate().
|
||||
* @private @type {Map<number, number>}
|
||||
*/
|
||||
this._generatorStepCounts = new Map();
|
||||
|
||||
/** @private @type {number} */
|
||||
this._masterSeed = 0;
|
||||
|
||||
/**
|
||||
* Per-primitive state objects, indexed by position in chain.
|
||||
* Populated after evaluate() calls; used for freeze support.
|
||||
* @private @type {Array<Object>}
|
||||
*/
|
||||
this._primitiveStates = [];
|
||||
}
|
||||
|
||||
// ── Primitive management ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append a primitive to the end of the chain.
|
||||
* @param {import('./primitive.js').Primitive} primitive
|
||||
*/
|
||||
addPrimitive(primitive) {
|
||||
this._primitives.push(primitive);
|
||||
this._primitiveStates.push(primitive.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the primitive at the given index.
|
||||
* @param {number} index
|
||||
*/
|
||||
removePrimitive(index) {
|
||||
const idx = index | 0;
|
||||
if (idx < 0 || idx >= this._primitives.length) {
|
||||
throw new RangeError('removePrimitive: index ' + index + ' out of range [0, ' + (this._primitives.length - 1) + ']');
|
||||
}
|
||||
this._primitives.splice(idx, 1);
|
||||
this._primitiveStates.splice(idx, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a primitive at the given index, shifting others right.
|
||||
* @param {number} index
|
||||
* @param {import('./primitive.js').Primitive} primitive
|
||||
*/
|
||||
insertPrimitive(index, primitive) {
|
||||
const idx = index | 0;
|
||||
if (idx < 0 || idx > this._primitives.length) {
|
||||
throw new RangeError('insertPrimitive: index ' + index + ' out of range [0, ' + this._primitives.length + ']');
|
||||
}
|
||||
this._primitives.splice(idx, 0, primitive);
|
||||
this._primitiveStates.splice(idx, 0, primitive.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a primitive from one position to another.
|
||||
* @param {number} fromIndex
|
||||
* @param {number} toIndex
|
||||
*/
|
||||
movePrimitive(fromIndex, toIndex) {
|
||||
const from = fromIndex | 0;
|
||||
const to = toIndex | 0;
|
||||
const len = this._primitives.length;
|
||||
if (from < 0 || from >= len) {
|
||||
throw new RangeError('movePrimitive: fromIndex ' + fromIndex + ' out of range [0, ' + (len - 1) + ']');
|
||||
}
|
||||
if (to < 0 || to >= len) {
|
||||
throw new RangeError('movePrimitive: toIndex ' + toIndex + ' out of range [0, ' + (len - 1) + ']');
|
||||
}
|
||||
|
||||
const [prim] = this._primitives.splice(from, 1);
|
||||
const [state] = this._primitiveStates.splice(from, 1);
|
||||
this._primitives.splice(to, 0, prim);
|
||||
this._primitiveStates.splice(to, 0, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current list of primitives (shallow copy).
|
||||
* @returns {Array<import('./primitive.js').Primitive>}
|
||||
*/
|
||||
getPrimitives() {
|
||||
return this._primitives.slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any primitive in the chain has reEvalOnLoop === true.
|
||||
* Used by the sequencer to decide whether to re-evaluate the pipeline
|
||||
* on each loop start, even when inputs haven't changed.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasReEvalPrimitives() {
|
||||
for (let i = 0; i < this._primitives.length; i++) {
|
||||
if (this._primitives[i].reEvalOnLoop === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Per-generator step counts (polyrhythm) ─────────────────────
|
||||
|
||||
/**
|
||||
* Set a per-generator step count for polyrhythm.
|
||||
* The chain index must refer to a primitive in the chain.
|
||||
*
|
||||
* @param {number} chainIndex - Index in the chain's primitive list
|
||||
* @param {number} stepCount - Step count for this generator (positive integer)
|
||||
*/
|
||||
setGeneratorStepCount(chainIndex, stepCount) {
|
||||
const idx = chainIndex | 0;
|
||||
const sc = stepCount | 0;
|
||||
if (idx < 0 || idx >= this._primitives.length) {
|
||||
throw new RangeError('setGeneratorStepCount: chainIndex ' + chainIndex + ' out of range [0, ' + (this._primitives.length - 1) + ']');
|
||||
}
|
||||
if (sc < 1) {
|
||||
throw new RangeError('setGeneratorStepCount: stepCount must be >= 1, got ' + stepCount);
|
||||
}
|
||||
this._generatorStepCounts.set(idx, sc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the per-generator step count, or null if using global.
|
||||
*
|
||||
* @param {number} chainIndex
|
||||
* @returns {number|null}
|
||||
*/
|
||||
getGeneratorStepCount(chainIndex) {
|
||||
const idx = chainIndex | 0;
|
||||
return this._generatorStepCounts.has(idx) ? this._generatorStepCounts.get(idx) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all per-generator step counts, reverting to global stepCount.
|
||||
*/
|
||||
clearGeneratorStepCounts() {
|
||||
this._generatorStepCounts.clear();
|
||||
}
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Total parameter count across all primitives in the chain.
|
||||
* @returns {number}
|
||||
*/
|
||||
get totalParamCount() {
|
||||
let total = 0;
|
||||
for (let i = 0; i < this._primitives.length; i++) {
|
||||
total += this._primitives[i].paramCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a flat list of all param schemas across all primitives,
|
||||
* annotated with their primitive and param indices.
|
||||
*
|
||||
* @returns {Array<{ primitiveIndex: number, paramIndex: number, schema: Object }>}
|
||||
*/
|
||||
getParamSchemas() {
|
||||
const result = [];
|
||||
for (let pi = 0; pi < this._primitives.length; pi++) {
|
||||
const prim = this._primitives[pi];
|
||||
const schema = prim.paramSchema;
|
||||
for (let si = 0; si < schema.length; si++) {
|
||||
result.push({
|
||||
primitiveIndex: pi,
|
||||
paramIndex: si,
|
||||
schema: schema[si],
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Evaluation ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate the chain, producing a pattern description.
|
||||
*
|
||||
* Pipeline order:
|
||||
* 1. Generators — combined via generatorCombineMode
|
||||
* 2. Processors — sequential transform
|
||||
* 3. Converters — sequential transform
|
||||
* 4. Timing modifiers — annotate last
|
||||
*
|
||||
* @param {Float32Array|Array<number>} params - flat param array distributed across primitives
|
||||
* @param {number} stepCount - number of steps in the output pattern
|
||||
* @param {number} masterSeed - seed for the master PRNG
|
||||
* @param {Map<number,number>|null} [generatorStepCounts=null] - optional per-generator step counts (chain index → step count). If null, uses this._generatorStepCounts.
|
||||
* @returns {{ steps: Array, stepCount: number, metadata: Object }}
|
||||
*/
|
||||
evaluate(params, stepCount, masterSeed, generatorStepCounts = null) {
|
||||
const primitives = this._primitives;
|
||||
const primCount = primitives.length;
|
||||
|
||||
// Create master PRNG from seed
|
||||
const masterPRNG = createPRNG(masterSeed >>> 0);
|
||||
|
||||
// ── Bucket primitives by category, preserving chain order ──
|
||||
|
||||
/** @type {Array<{ index: number, prim: Object }>} */
|
||||
const generators = [];
|
||||
const processors = [];
|
||||
const converters = [];
|
||||
const timingMods = [];
|
||||
|
||||
for (let i = 0; i < primCount; i++) {
|
||||
const entry = { index: i, prim: primitives[i] };
|
||||
switch (primitives[i].category) {
|
||||
case 'generator': generators.push(entry); break;
|
||||
case 'processor': processors.push(entry); break;
|
||||
case 'converter': converters.push(entry); break;
|
||||
case 'timing': timingMods.push(entry); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compute param offsets per primitive ──
|
||||
|
||||
const paramOffsets = new Array(primCount);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < primCount; i++) {
|
||||
paramOffsets[i] = offset;
|
||||
offset += primitives[i].paramCount;
|
||||
}
|
||||
|
||||
// ── Helper: run a single primitive ──
|
||||
|
||||
const self = this;
|
||||
|
||||
function runPrimitive(entry, inputPattern) {
|
||||
const idx = entry.index;
|
||||
const prim = entry.prim;
|
||||
const pOffset = paramOffsets[idx];
|
||||
const pCount = prim.paramCount;
|
||||
|
||||
// Slice params for this primitive
|
||||
const primParams = new Float32Array(pCount);
|
||||
for (let p = 0; p < pCount; p++) {
|
||||
primParams[p] = pOffset + p < params.length ? +params[pOffset + p] : prim.paramSchema[p].default;
|
||||
}
|
||||
|
||||
// Fork a deterministic PRNG for this primitive
|
||||
const primRNG = fork(masterPRNG, idx);
|
||||
|
||||
// Get current state
|
||||
const state = self._primitiveStates[idx] || prim.getState();
|
||||
|
||||
// Process
|
||||
const result = prim.process(primParams, inputPattern, state, primRNG);
|
||||
|
||||
// Store updated state
|
||||
self._primitiveStates[idx] = result.nextState;
|
||||
|
||||
return result.patternDesc;
|
||||
}
|
||||
|
||||
// ── Phase 1: Generators ──
|
||||
|
||||
// Resolve per-generator step counts: explicit arg > instance map > global
|
||||
const genStepMap = generatorStepCounts || this._generatorStepCounts;
|
||||
|
||||
/** Look up the step count for a generator by its chain index. */
|
||||
function genStepsFor(chainIndex) {
|
||||
if (genStepMap && genStepMap.size > 0) {
|
||||
const override = genStepMap.get(chainIndex);
|
||||
if (override != null) return override;
|
||||
}
|
||||
return stepCount;
|
||||
}
|
||||
|
||||
let pattern;
|
||||
|
||||
if (generators.length === 0) {
|
||||
// Default pattern: all steps triggered
|
||||
pattern = createPattern(stepCount);
|
||||
for (let i = 0; i < stepCount; i++) {
|
||||
pattern.steps[i].trigger = true;
|
||||
}
|
||||
} else if (generators.length === 1) {
|
||||
// Single generator — no merge needed
|
||||
const gs = genStepsFor(generators[0].index);
|
||||
pattern = runPrimitive(generators[0], createPattern(gs));
|
||||
} else {
|
||||
// Multiple generators — run each with its own step count, then merge
|
||||
let merged = runPrimitive(generators[0], createPattern(genStepsFor(generators[0].index)));
|
||||
for (let g = 1; g < generators.length; g++) {
|
||||
const gs = genStepsFor(generators[g].index);
|
||||
const next = runPrimitive(generators[g], createPattern(gs));
|
||||
merged = mergePatterns(merged, next, this.generatorCombineMode);
|
||||
}
|
||||
pattern = merged;
|
||||
}
|
||||
|
||||
// ── Phase 2: Processors ──
|
||||
|
||||
for (let i = 0; i < processors.length; i++) {
|
||||
pattern = runPrimitive(processors[i], pattern);
|
||||
}
|
||||
|
||||
// ── Phase 3: Converters ──
|
||||
|
||||
for (let i = 0; i < converters.length; i++) {
|
||||
pattern = runPrimitive(converters[i], pattern);
|
||||
}
|
||||
|
||||
// ── Phase 4: Timing modifiers ──
|
||||
|
||||
for (let i = 0; i < timingMods.length; i++) {
|
||||
pattern = runPrimitive(timingMods[i], pattern);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
// ── State management (for freeze) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Get serializable state for all primitives in the chain.
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
getState() {
|
||||
const states = new Array(this._primitives.length);
|
||||
for (let i = 0; i < this._primitives.length; i++) {
|
||||
states[i] = this._primitiveStates[i] || this._primitives[i].getState();
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all primitive states from a previously serialized state array.
|
||||
* @param {Array<Object>} states
|
||||
*/
|
||||
setState(states) {
|
||||
if (!Array.isArray(states)) {
|
||||
throw new TypeError('setState expects an array of state objects');
|
||||
}
|
||||
const len = Math.min(states.length, this._primitives.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
this._primitives[i].setState(states[i]);
|
||||
this._primitiveStates[i] = states[i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the master PRNG seed.
|
||||
* @returns {number}
|
||||
*/
|
||||
getMasterSeed() {
|
||||
return this._masterSeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the master PRNG seed.
|
||||
* @param {number} seed - 32-bit integer seed
|
||||
*/
|
||||
setMasterSeed(seed) {
|
||||
this._masterSeed = seed >>> 0;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue