New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
Tests cover:
- Default state (C15, 126 params, heatmap cells)
- Engine switcher UI (3 cards, active state)
- Switch to Additive (48 params, outputs bounded, training works)
- Switch to FM Matrix (55 params, outputs bounded, training works)
- Round-trip switching (C15→Add→C15, C15→FM→Add→C15)
- Warm-start weight preservation across resize
- EOC chain accessibility across engines
- SynthVisualizer visibility across engines
- No console errors during any switch
Also fixes: faustJsonToParamMeta now filters [hidden:1] params,
giving correct counts (48 additive, 55 FM) instead of including
freq/gate/_vel control inputs.
When switching from C15 to Additive or FM engines, the MLP was resized
to 0 outputs because paramMeta wasn't loaded until init() (which needs
AudioContext). The SynthVisualizer also disappeared because its section
map was hardcoded to C15's 18 param groups.
- Add FaustEngineBase.loadParamMeta() for eager JSON-only loading
- Call loadParamMeta() before setActiveEngine() in engine switch handler
- Add SynthVisualizer.rebuild(paramMeta) to derive sections from groups
- Add rebuildParamToSection() / restoreC15ParamToSection() for engine-agnostic
param-to-section mapping
- Fix tooltip to use engineParamOverrides for non-C15 engines
Additive engine: 5 presets across 3 tiers (Spectral Basics, Formant Play,
Harmonic Sculptor, Phase Explorer, Full Spectrum). FM engine: 5 presets
across 3 tiers (Simple FM, Bell Tones, Matrix Explorer, Feedback Machine,
Full Matrix).
Engine-aware preset system: applyPreset() dispatches to C15 groupOverrides
or new flat engineParamOverrides depending on active engine. Preset selector
dropdown rebuilds on engine switch. URL ?preset param searches all engines.
- Add AdditiveEngine (48 params) and FMEngine (55 params) wrapping FaustEngineBase
- Remove comingSoon flags from ENGINES descriptors
- Replace stub onSwitch handler with real engine construction + init + setActiveEngine
- Fix setActiveEngine EOC rewire to work with both C15 (_bridge.audioContext) and Faust (_audioCtx)
- Fix _startEocChain audioCtx resolution to support Faust engines
- Fix synth param send loop: use N_OUTPUTS instead of hardcoded SYNTH_PARAM_MAP.length
- Wire EOCChain into audio graph after c15.start() (both start-btn and
quick-play paths); guarded by _eocInited flag so init happens once per
AudioContext lifetime
- getOutputNode() on C15Adapter now returns limiter (last node before
destination) so EOC inserts correctly between limiter and destination
- Expose limiterNode getter on C15Bridge
- Add getCurrentParamValue(i) to EOCModule base class, backed by _paramValues
array that setParam() writes to
- Add _buildParamSliders() to EOCChainUI: collapsible ▶ params toggle per
module row, one range slider per paramMeta entry, calls module.setParam()
on input; labelled "manual" when nispsMode=bypass
- eoc:change listener in a-app.js logs paramCount (hook point for future modes)
- saveState()/loadState() persist eocModules (id, enabled, params) and
eocNispsMode; restore re-adds modules via moduleFactory and re-applies values
- CSS: eoc-params-section, eoc-params-toggle, eoc-param-row/label/slider;
eoc-module-row gains flex-wrap to accommodate params section below controls
Adds the Faust DSP toolchain infrastructure: placeholder additive and FM DSP
files, build.sh (faust -lang wasm per .dsp), faustJsonToParamMeta() to convert
Faust JSON UI trees into the standard paramMeta format, FaustEngineBase
(SynthEngine subclass wiring init/setParam/noteOn/noteOff through AudioWorklet
messages), and FaustWorkletProcessor base class for concrete engine processors.
Browser-side OSCOutput module sends parameter values over WebSocket to
a companion Deno bridge script that converts them to OSC/UDP messages.
Enables controlling SuperCollider, Max/MSP, Pure Data, TouchDesigner,
or any OSC-capable software from the NISPS playground.
- Browser module (js/synth/osc-output.js): WebSocket client with
auto-reconnect, throttle (~20fps), and dead-zone filtering
- Deno bridge (osc-bridge/bridge.ts): zero-dependency, compiles to
standalone binaries via deno compile for Linux/macOS/Windows
- Test receiver (osc-bridge/test-receive.ts): terminal dashboard
showing live OSC parameter values with bar charts
- OSC pill button in floating bar for quick connect/disconnect
- Help modal section with platform-aware download, setup guide,
and examples for SuperCollider/PD/Max
- GitHub Actions workflow for cross-platform binary builds
- OSC sends in both visual and synth modes
Hand tracking at 30fps was writing 126 params per frame to the C15 ring
buffer (capacity 512), flooding it and starving arpeggiator noteOn/noteOff
messages — causing stuck/dropped notes.
Three-part fix:
- Arpeggiator now runs in a dedicated Web Worker with direct
SharedArrayBuffer access, bypassing the main thread entirely for
note timing. Worker setInterval isn't subject to main thread jank.
- RingBufferWriter uses CAS (Atomics.compareExchange) for multi-producer
safety — both main thread (params) and worker (notes) write safely.
- routeOutputs throttles synth parameter sends: dead-zone filter
(0.2% change threshold) + rate cap (~20fps), reducing ring buffer
pressure from ~3800 msg/s to ~50-100 msg/s of actual changes.
4 tiers of progressive complexity (Beginner 15 params → Expert 126),
13 presets total with per-param min/max/curve overrides that bias
distributions without clamping extremes. Preset dropdown in UI,
persisted to localStorage, supports ?preset= URL param.
Merge Examples/RL tabs into single unified toolbar across all four UIs.
Users can now freely mix supervised learning (Add Example + Train) with
RL feedback (thumbs up/down) without switching modes. Param bar dragging
is always enabled.
Add Web MIDI input module (js/synth/midi-input.js) for external MIDI
controllers — routes note on/off to C15 synth, CC 1/2 to joystick.
Add gamepad module (js/ui/gamepad.js) with auto-detection, deadzone,
and axis normalization across all UIs.
Fix synth parameter sync: routeOutputs() now called after trainModel()
in onThumbsUp and loadState to prevent stale params on first joystick
move. Add separate Clear Examples button (dataset only, keeps weights).
- Insert DynamicsCompressorNode as always-on brick-wall limiter after
master gain (threshold -6dB, ratio 20:1, 2ms attack)
- Add devmode tame parameter (0-1) controlling range constraining:
- ?tame=0.7 URL param (default) or window.setTameLevel(n)
- 0 = no mitigation, 1 = strongest constraining
- Constrain 20 volume/buildup-sensitive params with safeMin/safeMax:
- Envelope release/decay2 times capped to prevent infinite ringing
- Envelope gains kept in moderate range
- All 5 drive stages (ShpA/B, FB, Out, Cabinet) capped
- Output mixer levels given floor and ceiling
- Echo feedback and reverb size limited to prevent wash buildup
- Fix Comb Filter group count (8, not 9) in comments and color generator
Control all sonically meaningful C15 parameters through NISPS ML outputs.
Excludes hardware routing, macros, scale/tuning, key tracking, velocity,
envelope mod depths, discrete switches, and dangerous volume/pitch params.
- Expand param-map.js with 126 curated params grouped by synthesis section
- Widen MLP architecture from [3,10,10,14,20] to [3,32,48,64,126]
- Add ParamDisplay.rebuild() for mode-dependent bar count (20 visual, 126 synth)
- Add scrollable compact layout for synth mode param bars
- Pad visual presets and old saves to 126 outputs for backward compatibility
Adds a secondary Synth mode (toggled via collapsible left side panel)
where the NISPS ML engine's 20 outputs control curated C15 synthesizer
parameters (oscillator PM, shapers, filters, reverb, echo, flanger,
output mixer) through a WASM AudioWorklet bridge.
Includes a chord progression arpeggiator with controls for tempo (BPM),
octave range, octave offset, and 4 selectable progressions. The C15
engine runs in an AudioWorklet with lock-free SharedArrayBuffer ring
buffer communication.
New files: c15/ (WASM assets), js/synth/ (bridge, arpeggiator, param map),
serve-coop.py (COOP/COEP headers for SharedArrayBuffer support).