feat(manifold)!: P1 — retire playground/, manifold is the sole browser app

Playground archived at branch archive/playground-solidjs (tag
playground-solidjs-final) and deleted from main. Retargets:

- run-all-tests.sh stage 5 → manifold (typecheck + bun test + build +
  playwright via non-snap node runner, BUILD-PLAN gotcha)
- ci.yml playground-tests → manifold-tests; osc-bridge.yml → manifold/osc-bridge
  (was already broken: playground/osc-bridge no longer existed)
- codegen: TS emission target removed (returns at P5 → manifold); golden
  test now C++-only; TS emitters retained dormant
- .gitignore: manifold/osc-bridge paths; drop dead playground faust exception
- docs: AGENTS.md gates, README quickstart, MAP.md, ALIGNMENT.md (C15 now
  archive-only — defect #1 updated), AGENT-REFERENCE.md, specs/MAIN.md

Part of docs/specs/plans/one-core-engine-refactor.md P1.
This commit is contained in:
monkey-w1n5t0n 2026-07-13 23:27:56 +02:00
parent 29dc88be3a
commit 0d1a2102eb
159 changed files with 141 additions and 19347 deletions

View file

@ -5,8 +5,9 @@ name: CI
# Two parallel jobs: # Two parallel jobs:
# * cpp-tests — builds nisps host C++ tests, builds nisps.wasm, runs # * cpp-tests — builds nisps host C++ tests, builds nisps.wasm, runs
# the parity check, runs the lint script. # the parity check, runs the lint script.
# * playground-tests — typechecks the SolidJS playground, builds the # * manifold-tests — typechecks the React manifold app, runs bun unit
# production bundle, runs Playwright e2e tests. # tests, builds the production bundle, runs Playwright
# e2e tests.
# #
# Firmware compilation is NOT included in this workflow. Arduino-cli + # Firmware compilation is NOT included in this workflow. Arduino-cli +
# rp2040 board package add ~2 minutes per run, and the verification value # rp2040 board package add ~2 minutes per run, and the verification value
@ -84,8 +85,8 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
playground-tests: manifold-tests:
name: Playground typecheck + e2e name: Manifold typecheck + unit + e2e
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@ -96,24 +97,28 @@ jobs:
with: with:
bun-version: latest bun-version: latest
- name: Install playground deps - name: Install manifold deps
working-directory: playground working-directory: manifold
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Typecheck - name: Typecheck
working-directory: playground working-directory: manifold
run: bun run typecheck run: bun run typecheck
- name: Build playground bundle - name: Unit tests
working-directory: playground working-directory: manifold
run: bun run test
- name: Build manifold bundle
working-directory: manifold
run: bun run build run: bun run build
- name: Install Playwright browsers - name: Install Playwright browsers
working-directory: playground working-directory: manifold
run: bunx playwright install --with-deps chromium run: bunx playwright install --with-deps chromium
- name: Run Playwright tests - name: Run Playwright tests
working-directory: playground working-directory: manifold
run: bunx playwright test run: bunx playwright test
- name: Upload Playwright report on failure - name: Upload Playwright report on failure
@ -121,5 +126,5 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: playwright-report name: playwright-report
path: playground/playwright-report/ path: manifold/playwright-report/
retention-days: 7 retention-days: 7

View file

@ -40,7 +40,7 @@ jobs:
- name: Compile - name: Compile
run: | run: |
cd playground/osc-bridge cd manifold/osc-bridge
deno compile --allow-net --unstable-net --target ${{ matrix.target }} --output ../../dist/nisps-osc-bridge-${{ matrix.suffix }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} bridge.ts deno compile --allow-net --unstable-net --target ${{ matrix.target }} --output ../../dist/nisps-osc-bridge-${{ matrix.suffix }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} bridge.ts
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4

8
.gitignore vendored
View file

@ -25,10 +25,6 @@
*.la *.la
*.a *.a
*.lib *.lib
# Exception: Faust shared library files (text source, .lib extension by
# Faust convention) should be tracked.
!playground/faust/*.lib
# Executables # Executables
*.exe *.exe
*.out *.out
@ -38,8 +34,8 @@
build build
# OSC bridge compiled binaries (built via compile.sh or CI) # OSC bridge compiled binaries (built via compile.sh or CI)
playground/osc-bridge/dist/ manifold/osc-bridge/dist/
playground/osc-bridge/node_modules/ manifold/osc-bridge/node_modules/
# Dolt database files (added by bd init) # Dolt database files (added by bd init)
.dolt/ .dolt/

View file

@ -1,9 +1,9 @@
# Agent Instructions — MEMLNaut-NISPS # Agent Instructions — MEMLNaut-NISPS
One C++20 NISPS core targets RP2350 firmware and a SolidJS/WASM playground. Read One C++20 NISPS core targets RP2350 firmware and the Manifold React/WASM browser app. Read
`MAP.md`, `ALIGNMENT.md`, and `docs/AGENT-REFERENCE.md`; for Manifold UI work read `MAP.md`, `ALIGNMENT.md`, and `docs/AGENT-REFERENCE.md`; for Manifold UI work read
`manifold/ONBOARDING.md` first. Parameter JSON schemas generate both C++ and TypeScript `manifold/ONBOARDING.md` first. Parameter JSON schemas generate the C++ contracts
contracts. (TS codegen returns at P5 of `docs/specs/plans/one-core-engine-refactor.md`).
## Gates ## Gates
@ -12,7 +12,7 @@ bash scripts/build-cpp-tests.sh
bash scripts/parity-check.sh bash scripts/parity-check.sh
bash scripts/lint-cpp.sh bash scripts/lint-cpp.sh
scripts/build-firmware.sh [VARIANT] scripts/build-firmware.sh [VARIANT]
cd playground && bun run typecheck && bun run build cd manifold && bun run typecheck && bun run test && bun run build
``` ```
- `nisps/` must remain platform-neutral and allocation-free in hot paths: no heap, no - `nisps/` must remain platform-neutral and allocation-free in hot paths: no heap, no

View file

@ -6,17 +6,17 @@
A research platform for interactive ML control of audio. We're building it to figure out what works and what doesn't — different ergonomics and ergodynamics of parameter sets, modes, ML architectures, audio engines, UI, and UX. Therefore: keep most/all parameters tweakable, ML/engine/UI/UX should each be configurable on their own axis, and the codebase has to enable/assist agentic AI coding patterns (confident changes, verifiable without hardware). A research platform for interactive ML control of audio. We're building it to figure out what works and what doesn't — different ergonomics and ergodynamics of parameter sets, modes, ML architectures, audio engines, UI, and UX. Therefore: keep most/all parameters tweakable, ML/engine/UI/UX should each be configurable on their own axis, and the codebase has to enable/assist agentic AI coding patterns (confident changes, verifiable without hardware).
The clean-slate rewrite (2026-04-29) consolidates everything into one C++20 codebase compiling to firmware AND WASM, with SolidJS playground primitives composed into TSX mode components, and JSON schemas as the firmware↔browser parameter contract. The clean-slate rewrite (2026-04-29) consolidated everything into one C++20 codebase compiling to firmware AND WASM. Since 2026-07-13 (P1 of `docs/specs/plans/one-core-engine-refactor.md`) the sole browser app is the React Manifold; the SolidJS playground is archived (`archive/playground-solidjs`). JSON schemas remain the firmware↔browser parameter contract.
## Top defects (ranked by mission impact) ## Top defects (ranked by mission impact)
### 1. Browser-only audio engines incomplete (2026-04-29) ### 1. Browser-only audio engines incomplete (2026-04-29; updated 2026-07-13)
**What.** Stream 9 stubbed `C15Mode` as a placeholder ("C15 mode TODO"). The C15 worklet/bridge from the legacy playground is in `.local/playground-archive/js/synth/` but not wired in. Mic input for XIASRI / SoundAnalysisMIDI is also TODO — the UI renders but doesn't capture audio. **What.** C15 never got past a stubbed placeholder mode, and with the playground retired at P1 (2026-07-13) it has NO home on main — the stub UI, `c15.wasm`, and `c15-glue.js` live only on branch `archive/playground-solidjs`. Mic input for XIASRI / SoundAnalysisMIDI is likewise not wired in manifold.
**Why it blocks the mission.** "Browser engines ⊇ firmware engines" was a non-negotiable. Without C15 + mic input, the playground can't fully demonstrate the modes; users can't audition XIASRI or SoundAnalysisMIDI in the browser. **Why it blocks the mission.** "Browser engines ⊇ firmware engines" was a non-negotiable. Without C15 + mic input, manifold can't fully demonstrate the modes; users can't audition XIASRI or SoundAnalysisMIDI in the browser.
**Rough cost.** ~12 days. C15 wiring is mostly straightforward porting from the archived bridge. Mic input requires the engine-host to expose an input stream to the worklet (small worklet refactor). **Rough cost.** ~2 days. Reviving C15 now means porting the archived bridge into manifold's engine host. Mic input requires the engine-host to expose an input stream to the worklet (small worklet refactor).
### 2. Per-iteration loss curve not plumbed through WASM (2026-04-29) ### 2. Per-iteration loss curve not plumbed through WASM (2026-04-29)
@ -61,13 +61,13 @@ Schemas currently declare per-mode `hidden_layers` (some `[10, 10, 14]`, some `[
### Q2: How to express "advanced" features (gradient flow, weight health) without cluttering modes? (2026-04-29) ### Q2: How to express "advanced" features (gradient flow, weight health) without cluttering modes? (2026-04-29)
Current playground reproduces the a-immersive "Advanced" toggle. Power features hide behind it. Is this the right model, or should the mode UI itself decide what's exposed (some modes are "expert-only", some are simpler)? The retired playground reproduced the a-immersive "Advanced" toggle; manifold hides power features in drawers instead. Is this the right model, or should the mode UI itself decide what's exposed (some modes are "expert-only", some are simpler)?
### Q3: Engine event taxonomy (2026-04-29) ### Q3: Engine event taxonomy (2026-04-29)
`nisps/modes/base.hpp` exposes a `ControlEvent` ring buffer pop_events interface for sequencer modes (BreakOr, Elysiamorf). Currently events are a flat enum. As we add more event-emitting modes (custom MIDI mappings, lighting, networked control), how should the event vocabulary grow? Open question; revisit when we add the third event-emitting mode. `nisps/modes/base.hpp` exposes a `ControlEvent` ring buffer pop_events interface for sequencer modes (BreakOr, Elysiamorf). Currently events are a flat enum. As we add more event-emitting modes (custom MIDI mappings, lighting, networked control), how should the event vocabulary grow? Open question; revisit when we add the third event-emitting mode.
### Q4: Should the playground stay desktop-first? (2026-04-29) ### Q4: Should the browser app (manifold) stay desktop-first? (2026-04-29)
The original a-immersive was mobile-first ("designed for touch / foldable phone use"). The SolidJS rewrite is desktop-first by default. If the research story is "the user holds a phone and pinches to zoom while a synth runs in their pocket", we'll need a responsive pass. Defer until we have user data. The original a-immersive was mobile-first ("designed for touch / foldable phone use"). The SolidJS rewrite is desktop-first by default. If the research story is "the user holds a phone and pinches to zoom while a synth runs in their pocket", we'll need a responsive pass. Defer until we have user data.

37
MAP.md
View file

@ -1,6 +1,6 @@
# MAP # MAP
MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 codebase (`nisps/`) compiles to two targets: (1) Arduino/RP2350 firmware for the MEMLNaut hardware, (2) WASM in a SolidJS browser playground that runs the same engines + ML through an AudioWorklet. Browser audio engines are a superset of firmware engines (C15 is browser-only). See `CLAUDE.md` for the long-form architecture narrative and `ALIGNMENT.md` for current strategic gaps. MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 codebase (`nisps/`) compiles to two targets: (1) Arduino/RP2350 firmware for the MEMLNaut hardware, (2) WASM in the Manifold React browser app that runs the same engines + ML through an AudioWorklet. (The former SolidJS playground was retired 2026-07-13 — branch `archive/playground-solidjs`, tag `playground-solidjs-final`; the browser-only C15 engine lives only there for now.) See `CLAUDE.md` for the long-form architecture narrative and `ALIGNMENT.md` for current strategic gaps.
## Layout ## Layout
@ -29,22 +29,7 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod
- `firmware/MEMLCelium-upstream/`**vendored** verbatim snapshot of the upstream `MusicallyEmbodiedML/MEMLNaut-NISPS` @ `main` Arduino sketch (pre-refactor monorepo, does NOT use `nisps/`), preset to `MODE_MEMLCELIUM`. Self-contained: upstream `memllib`@`e291192d` + `memlp`@`ea777502` vendored as plain files; `src/daisysp` in-tree. Built directly with `arduino-cli` (not the repo build scripts) — see its `README.md` for provenance + the compile command. - `firmware/MEMLCelium-upstream/`**vendored** verbatim snapshot of the upstream `MusicallyEmbodiedML/MEMLNaut-NISPS` @ `main` Arduino sketch (pre-refactor monorepo, does NOT use `nisps/`), preset to `MODE_MEMLCELIUM`. Self-contained: upstream `memllib`@`e291192d` + `memlp`@`ea777502` vendored as plain files; `src/daisysp` in-tree. Built directly with `arduino-cli` (not the repo build scripts) — see its `README.md` for provenance + the compile command.
- `firmware/useq-celium/` — standalone RP2040 firmware (PlatformIO, Arduino-Pico core) that turns a uSEQ module + CV expander into a USB→CV/gate converter driven by the manifold `cvgate` backend. `shared/protocol.h` is the v2 wire-protocol single source of truth (mirrored by `manifold/src/backends/useq-protocol.ts`); `main/` (USB serial → CV13 + GATE13, I2C → expander) and `expander/` (I2C slave → CV411). Wire spec: `docs/specs/useq-cv-protocol.md`. Restored from the April-2026 "uSEQ-Celium" mode. - `firmware/useq-celium/` — standalone RP2040 firmware (PlatformIO, Arduino-Pico core) that turns a uSEQ module + CV expander into a USB→CV/gate converter driven by the manifold `cvgate` backend. `shared/protocol.h` is the v2 wire-protocol single source of truth (mirrored by `manifold/src/backends/useq-protocol.ts`); `main/` (USB serial → CV13 + GATE13, I2C → expander) and `expander/` (I2C slave → CV411). Wire spec: `docs/specs/useq-cv-protocol.md`. Restored from the April-2026 "uSEQ-Celium" mode.
### `playground/` — SolidJS + Vite + TypeScript app ### `manifold/` — Vite + React + TS convertible-mode app (the sole browser app)
- `playground/index.html`, `vite.config.ts`, `tsconfig.json`, `package.json` — scaffold. COOP/COEP headers configured.
- `playground/src/main.tsx`, `App.tsx` — entry + router (`/`, `/dev/primitives`, `/modes`).
- `playground/src/primitives/` — 16 UI building blocks: `Slider`, `SliderBank`, `VirtualJoystick`, `XYPad`, `Heatmap`, `OutputDisplay`, `TrainingControls`, `Drawer`, `ControlAxis`, `ProgressRing`, `PillToggle`, `ParamEditor`, `JoyMap`, `WeightHealth`, `GradientFlow`, `LossPlot`. Each has a `.demo.tsx` showcased on `/dev/primitives`.
- `playground/src/modes/` — one TSX per firmware mode (+ `C15Mode` browser-only). `ModeShell.tsx` is the shared scaffold; `ModeSwitcher.tsx` picks the active mode; `mode-runtime.ts` is the schema → ML → audio wiring hook; `mode-helpers.ts` for SliderBank configs. `generated/` holds codegen-produced TS schemas (do not edit).
- `playground/src/stores/` — Solid stores: `ml-store`, `input-store`, `output-store`, `mode-store`, `control-store` (compound axes Boldness/Memory/Precision), `session-store` (snapshots, A/B, presets), `exploration-store`, `bus` (typed signal bus). `persistence.ts` debounces localStorage writes.
- `playground/src/audio/engine-host.ts`, `worklet/nisps-processor.ts` — main-thread engine host + AudioWorklet processor. WASM loaded twice (main thread for ML, worklet for engines).
- `playground/src/ml/wasm-iml.ts`, `wasm-worker.ts`, `dataset.ts`, `types.ts` — main-thread WasmIML class + disposable async-training worker + FIFO dataset.
- `playground/src/input/pipeline.ts`, `playground/src/output/pipeline.ts`, `playground/src/output/curves.ts` — pure-fn pipelines (deadzone→zoom→curve→smoothing→momentum, then global curve→smoothing→slew→freeze).
- `playground/src/features/` — additional feature modules (heatmap sampling, snapshot stack, A/B compare, region pin, param pin, trail, weight health, etc.).
- `playground/src/debug/probe.ts` — synchronous `window.__nisps` debug probe for Playwright.
- `playground/public/nisps.{wasm,js}`, `c15.wasm`, `c15-glue.js` — WASM artifacts. `nisps.*` are a transitional copy from `scripts/build-wasm.sh` (canonical output is `manifold/public/`; copy dies with playground at P1).
- `playground/tests/e2e/` — Playwright specs (`ml-engine`, `modes`, `persistence`, `ui-interactions`) + `helpers.ts`.
- `playground/playwright.config.ts` — Vite preview server setup.
### `manifold/` — Vite + React + TS convertible-mode app (the NEW front-end, WIP)
The Manifold "convertible" Console on the real engine, deployed at `meml.lnfinitemonkeys.org/next` (staging, The Manifold "convertible" Console on the real engine, deployed at `meml.lnfinitemonkeys.org/next` (staging,
alongside the live vanilla a-immersive at `/`). Built 2026-06-27/28; see `docs/specs/plans/BUILD-PLAN.md` (resume alongside the live vanilla a-immersive at `/`). Built 2026-06-27/28; see `docs/specs/plans/BUILD-PLAN.md` (resume
anchor + locked decisions) and the `docs/specs/*-spec.md` set. anchor + locked decisions) and the `docs/specs/*-spec.md` set.
@ -102,7 +87,7 @@ the "BUILD DELTAS" block at the top of `docs/specs/vcv-module.md`). `src/MEMLNau
- `schemas/midi_devices/<device>.json` (×6) — CC-controllable external synths (Moog Sub 37 / Sub Phatty, Creamware Pro-12 ASB, Elektron Analog Keys, ASM Hydrasynth, Roland JD-800). Each param: `{id, cc, label, min, max, default, group}`. Canonical source for both firmware + browser device pickers. Verified-CC provenance + sources live in `synth-midi-cc.json` (repo root). - `schemas/midi_devices/<device>.json` (×6) — CC-controllable external synths (Moog Sub 37 / Sub Phatty, Creamware Pro-12 ASB, Elektron Analog Keys, ASM Hydrasynth, Roland JD-800). Each param: `{id, cc, label, min, max, default, group}`. Canonical source for both firmware + browser device pickers. Verified-CC provenance + sources live in `synth-midi-cc.json` (repo root).
### `codegen/` — schema → C++/TS code ### `codegen/` — schema → C++/TS code
- `codegen/generate.ts` — Bun script: validates schemas via ajv, emits per-mode `nisps/modes/generated/<mode>_schema.hpp` (`constexpr`, `nisps::modes::generated`) and `playground/src/modes/generated/<mode>_schema.ts`. Idempotent. - `codegen/generate.ts` — Bun script: validates schemas via ajv, emits per-mode `nisps/modes/generated/<mode>_schema.hpp` (`constexpr`, `nisps::modes::generated`). Idempotent. (TS emitters retained but dormant; re-targeted at `manifold/src/modes/generated/` in P5.)
- `codegen/generate-midi-devices.ts` — separate Bun script (isolated from the mode golden test): validates `schemas/midi_devices/` via ajv, emits `nisps/midi/generated/midi_devices.hpp` (no-heap `constexpr`) and `manifold/src/midi-devices/generated/{types,devices,index}.ts`. Idempotent. - `codegen/generate-midi-devices.ts` — separate Bun script (isolated from the mode golden test): validates `schemas/midi_devices/` via ajv, emits `nisps/midi/generated/midi_devices.hpp` (no-heap `constexpr`) and `manifold/src/midi-devices/generated/{types,devices,index}.ts`. Idempotent.
- `codegen/seed-midi-devices.ts` — one-time/idempotent seed deriving `schemas/midi_devices/*.json` from the `synth-midi-cc.json` research artifact (slugifies labels → ids, heuristic groups). - `codegen/seed-midi-devices.ts` — one-time/idempotent seed deriving `schemas/midi_devices/*.json` from the `synth-midi-cc.json` research artifact (slugifies labels → ids, heuristic groups).
- `codegen/templates/`, `codegen/tests/golden/` — reference templates + golden snapshot for paf_synth. - `codegen/templates/`, `codegen/tests/golden/` — reference templates + golden snapshot for paf_synth.
@ -113,14 +98,14 @@ the "BUILD DELTAS" block at the top of `docs/specs/vcv-module.md`). `src/MEMLNau
### `scripts/` — build + verify entry points ### `scripts/` — build + verify entry points
- `build-firmware.sh`, `flash-firmware.sh`, `build-and-flash-firmware.sh`, `firmware-common.sh` — Arduino-CLI wrapper for RP2350 target with C++20 flag. - `build-firmware.sh`, `flash-firmware.sh`, `build-and-flash-firmware.sh`, `firmware-common.sh` — Arduino-CLI wrapper for RP2350 target with C++20 flag.
- `build-wasm.sh` — Emscripten compile producing `manifold/public/nisps.{wasm,js}` (+ transitional copy to `playground/public/` until P1). - `build-wasm.sh` — Emscripten compile producing `manifold/public/nisps.{wasm,js}`.
- `build-cpp-tests.sh` — CMake configure + build + ctest (Ninja). - `build-cpp-tests.sh` — CMake configure + build + ctest (Ninja).
- `parity-check.sh` — runs native + WASM and diffs binary outputs. - `parity-check.sh` — runs native + WASM and diffs binary outputs.
- `lint-cpp.sh``.f` literal warn + heap/`Arduino.h` violation fail. - `lint-cpp.sh``.f` literal warn + heap/`Arduino.h` violation fail.
- `run-all-tests.sh` — master verification script. - `run-all-tests.sh` — master verification script.
### `.github/workflows/` ### `.github/workflows/`
- `ci.yml` — GitHub Actions: cmake build + ctest + WASM build + parity check + lint + Playwright (cpp-tests + playground-tests jobs). Firmware compile is documented as manual. - `ci.yml` — GitHub Actions: cmake build + ctest + WASM build + parity check + lint + Playwright (cpp-tests + manifold-tests jobs). Firmware compile is documented as manual.
### Submodules (in `src/`) ### Submodules (in `src/`)
- `src/memllib/` — hardware abstraction (audio driver, peripherals, MIDI). **Not auto-initialized** — fresh clones need `git submodule update --init --recursive`. - `src/memllib/` — hardware abstraction (audio driver, peripherals, MIDI). **Not auto-initialized** — fresh clones need `git submodule update --init --recursive`.
@ -136,22 +121,22 @@ the "BUILD DELTAS" block at the top of `docs/specs/vcv-module.md`). `src/MEMLNau
## Entry points ## Entry points
- **Firmware**: `scripts/build-firmware.sh [VARIANT]` (interactive prompt if omitted), `scripts/flash-firmware.sh`, `scripts/build-and-flash-firmware.sh`. Target: `rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3`, `-std=gnu++20`. - **Firmware**: `scripts/build-firmware.sh [VARIANT]` (interactive prompt if omitted), `scripts/flash-firmware.sh`, `scripts/build-and-flash-firmware.sh`. Target: `rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3`, `-std=gnu++20`.
- **Playground dev**: `cd playground && bun install && bun run dev` (Vite, port 5173, COOP/COEP headers). - **Manifold dev**: `cd manifold && bun install && bun run dev` (Vite, COOP/COEP headers).
- **Playground build**: `cd playground && bun run build`. - **Manifold build**: `cd manifold && bun run build`.
- **WASM rebuild**: `bash scripts/build-wasm.sh` (needs `emcc`). - **WASM rebuild**: `bash scripts/build-wasm.sh` (needs `emcc`).
- **Host C++ tests**: `bash scripts/build-cpp-tests.sh`. - **Host C++ tests**: `bash scripts/build-cpp-tests.sh`.
- **Parity check**: `bash scripts/parity-check.sh`. - **Parity check**: `bash scripts/parity-check.sh`.
- **All tests**: `bash scripts/run-all-tests.sh`. - **All tests**: `bash scripts/run-all-tests.sh`.
- **Playwright**: `cd playground && bunx playwright test`. - **Playwright**: `cd manifold && node node_modules/.bin/playwright test` (non-snap node runner on the VPS — BUILD-PLAN gotcha; `bunx playwright test` works elsewhere).
- **Codegen**: `cd codegen && bun run generate.ts` (regenerates `nisps/modes/generated/` and `playground/src/modes/generated/`). - **Codegen**: `cd codegen && bun run generate.ts` (regenerates `nisps/modes/generated/`; TS target returns at P5 for manifold).
## Conventions ## Conventions
- Firmware mode selection is compile-time only — `#define MEMLNAUT_MODE_TYPE` in the `.ino`. - Firmware mode selection is compile-time only — `#define MEMLNAUT_MODE_TYPE` in the `.ino`.
- `nisps/` follows Chris's RP2350 perf rules globally: no heap, `static const float` for non-trivial constants, strict `.f` suffix, memory section attrs (`NISPS_AUDIO_MEM`, `NISPS_AUDIO_FUNC`, `NISPS_APP_SRAM`, `NISPS_HOT`, `NISPS_FORCE_INLINE`). - `nisps/` follows Chris's RP2350 perf rules globally: no heap, `static const float` for non-trivial constants, strict `.f` suffix, memory section attrs (`NISPS_AUDIO_MEM`, `NISPS_AUDIO_FUNC`, `NISPS_APP_SRAM`, `NISPS_HOT`, `NISPS_FORCE_INLINE`).
- C++ identifiers: `PascalCase` types, `snake_case` functions/variables, `kPascalCase` constexpr. JSON keys `snake_case`. TS types `PascalCase`, components `PascalCase.tsx`, modules `kebab-case.ts`. - C++ identifiers: `PascalCase` types, `snake_case` functions/variables, `kPascalCase` constexpr. JSON keys `snake_case`. TS types `PascalCase`, components `PascalCase.tsx`, modules `kebab-case.ts`.
- `Curve` enum lives in `nisps/core/math.hpp` (lowercase: `linear/exp/log/square/sqrt/sigmoid/cubic`); generated mode headers re-export via `using Curve = ::nisps::Curve;`. TS mirror at `playground/src/output/curves.ts` with same names. - `Curve` enum lives in `nisps/core/math.hpp` (lowercase: `linear/exp/log/square/sqrt/sigmoid/cubic`); generated mode headers re-export via `using Curve = ::nisps::Curve;`. TS mirror at `manifold/src/engine/curves.ts` with same names (moves into core at P4).
- Modes are TSX components composed of primitives; mode parameter contracts are JSON schemas with codegen → C++/TS types. **No declarative JSON UI.** - Modes are TSX components composed of primitives; mode parameter contracts are JSON schemas with codegen → C++ types (TS codegen returns at P5). **No declarative JSON UI.**
- WASM and firmware share the same C++; WASM is fixed at `MLP<32, 10, 14, 18, 126>` (`nisps_ml_create` ignores requested dims — see `plans/one-core-engine-refactor.md` P2) and modes use a slice of outputs based on schema's `output_size`. - WASM and firmware share the same C++; WASM is fixed at `MLP<32, 10, 14, 18, 126>` (`nisps_ml_create` ignores requested dims — see `plans/one-core-engine-refactor.md` P2) and modes use a slice of outputs based on schema's `output_size`.
- Cross-platform parity: `scripts/parity-check.sh` enforces native vs WASM agreement within 1e-5. - Cross-platform parity: `scripts/parity-check.sh` enforces native vs WASM agreement within 1e-5.

View file

@ -21,16 +21,22 @@ Notes:
- `build-firmware.sh` accepts an optional variant name such as `MEMLCelium` or `BreakOr`. Matching remains case-insensitive, so `memlcelium` still works. If you omit it in an interactive shell, the script parses `MEMLNaut-NISPS.ino`, prompts for a variant, and rewrites the active `MEMLNAUT_MODE_TYPE` before building. - `build-firmware.sh` accepts an optional variant name such as `MEMLCelium` or `BreakOr`. Matching remains case-insensitive, so `memlcelium` still works. If you omit it in an interactive shell, the script parses `MEMLNaut-NISPS.ino`, prompts for a variant, and rewrites the active `MEMLNAUT_MODE_TYPE` before building.
- `flash-firmware.sh` accepts an optional mountpoint argument, or auto-detects common UF2 bootloader mounts such as `/run/media/$USER/RP2350` and `/run/media/$USER/RPI-RP2`. - `flash-firmware.sh` accepts an optional mountpoint argument, or auto-detects common UF2 bootloader mounts such as `/run/media/$USER/RP2350` and `/run/media/$USER/RPI-RP2`.
## Web Playground ## Manifold (browser app)
Try NISPS in your browser — no hardware required: Try NISPS in your browser — no hardware required. Manifold is the React front-end
running the same C++ engines + ML as the firmware, compiled to WASM:
```bash ```bash
cd playground cd manifold
python3 -m http.server bun install
# Open http://localhost:8000 bun run dev
``` ```
Train a neural network to map joystick positions to generative visuals through interactive machine learning. Two learning modes: direct example mapping and reinforcement learning with thumbs up/down feedback. Staging deployment: https://meml.lnfinitemonkeys.org/next/
The playground UI includes an **Expand** toggle on the visual surface so you can make the canvas nearly full-screen while compressing parameter/control panels into a minimal strip beneath it. Train a neural network to map input gestures to synth parameters through interactive
machine learning: place examples, or use verdict-based feedback (explore-and-place,
geometric dislike).
(The former SolidJS playground was retired in July 2026 — archived on branch
`archive/playground-solidjs`, tag `playground-solidjs-final`.)

View file

@ -7,9 +7,11 @@
* *
* Writes: nisps/modes/generated/<mode_id>_schema.hpp * Writes: nisps/modes/generated/<mode_id>_schema.hpp
* nisps/modes/generated/schema_types.hpp * nisps/modes/generated/schema_types.hpp
* playground/src/modes/generated/<mode_id>_schema.ts *
* playground/src/modes/generated/types.ts * The TS emission target (formerly playground/src/modes/generated/) was
* playground/src/modes/generated/index.ts * removed with the playground at P1 of
* docs/specs/plans/one-core-engine-refactor.md; the TS emitters below are
* retained and re-targeted at manifold/src/modes/generated/ in P5.
* *
* Idempotent: regenerating the same schemas yields byte-identical output. * Idempotent: regenerating the same schemas yields byte-identical output.
* Exits non-zero on validation failure. * Exits non-zero on validation failure.
@ -64,7 +66,6 @@ const SCHEMAS_DIR = join(REPO_ROOT, "schemas");
const MODES_DIR = join(SCHEMAS_DIR, "modes"); const MODES_DIR = join(SCHEMAS_DIR, "modes");
const META_SCHEMA_PATH = join(SCHEMAS_DIR, "schema.json"); const META_SCHEMA_PATH = join(SCHEMAS_DIR, "schema.json");
const CPP_OUT_DIR = join(REPO_ROOT, "nisps", "modes", "generated"); const CPP_OUT_DIR = join(REPO_ROOT, "nisps", "modes", "generated");
const TS_OUT_DIR = join(REPO_ROOT, "playground", "src", "modes", "generated");
// ----- Helpers -------------------------------------------------------------- // ----- Helpers --------------------------------------------------------------
@ -374,6 +375,8 @@ function emitModeHpp(schema: ModeSchema, sourceFile: string): string {
} }
// ----- Per-mode TS emission ------------------------------------------------- // ----- Per-mode TS emission -------------------------------------------------
// Currently unused: the playground TS target was removed at P1; these emitters
// return in P5 targeting manifold/src/modes/generated/ (one-core-engine plan).
function emitModeTs(schema: ModeSchema, sourceFile: string): string { function emitModeTs(schema: ModeSchema, sourceFile: string): string {
const constName = `${toPascalCase(schema.mode_id)}Schema`; const constName = `${toPascalCase(schema.mode_id)}Schema`;
@ -545,22 +548,9 @@ function main(): number {
writeFileSync(out, emitModeHpp(schema, source)); writeFileSync(out, emitModeHpp(schema, source));
} }
// 5. Emit TS outputs // 5. Report
ensureDir(TS_OUT_DIR);
writeFileSync(join(TS_OUT_DIR, "types.ts"), emitSharedTsTypes());
for (const { source, schema } of schemas) {
const out = join(TS_OUT_DIR, `${schema.mode_id}_schema.ts`);
writeFileSync(out, emitModeTs(schema, source));
}
writeFileSync(
join(TS_OUT_DIR, "index.ts"),
emitTsIndex(schemas.map(s => s.schema.mode_id).sort())
);
// 6. Report
console.log(`OK ${schemas.length} mode schema(s) processed.`); console.log(`OK ${schemas.length} mode schema(s) processed.`);
console.log(` C++ -> ${CPP_OUT_DIR}`); console.log(` C++ -> ${CPP_OUT_DIR}`);
console.log(` TS -> ${TS_OUT_DIR}`);
for (const { schema } of schemas) { for (const { schema } of schemas) {
console.log( console.log(
` - ${schema.mode_id}: ${schema.params.length} params, ` + ` - ${schema.mode_id}: ${schema.params.length} params, ` +

View file

@ -1,13 +1,17 @@
#!/usr/bin/env bun #!/usr/bin/env bun
/** /**
* Golden test: re-runs codegen and asserts that the freshly-generated * Golden test: re-runs codegen and asserts that the freshly-generated
* paf_synth_schema.{hpp,ts} files are byte-identical to the snapshots in * paf_synth_schema.hpp is byte-identical to the snapshot in
* codegen/tests/golden/. * codegen/tests/golden/.
* *
* (The TS golden case was removed with the playground target at P1 of
* docs/specs/plans/one-core-engine-refactor.md; it returns in P5 against
* manifold/src/modes/generated/. The golden/paf_synth_schema.ts snapshot is
* kept as the P5 template reference.)
*
* If you change the codegen template intentionally, regenerate the goldens: * If you change the codegen template intentionally, regenerate the goldens:
* bun run generate.ts * bun run generate.ts
* cp ../nisps/modes/generated/paf_synth_schema.hpp tests/golden/ * cp ../nisps/modes/generated/paf_synth_schema.hpp tests/golden/
* cp ../playground/src/modes/generated/paf_synth_schema.ts tests/golden/
*/ */
import { readFileSync, existsSync } from "node:fs"; import { readFileSync, existsSync } from "node:fs";
@ -31,11 +35,6 @@ const CASES: Case[] = [
generatedPath: join(REPO_ROOT, "nisps", "modes", "generated", "paf_synth_schema.hpp"), generatedPath: join(REPO_ROOT, "nisps", "modes", "generated", "paf_synth_schema.hpp"),
goldenPath: join(GOLDEN_DIR, "paf_synth_schema.hpp"), goldenPath: join(GOLDEN_DIR, "paf_synth_schema.hpp"),
}, },
{
name: "paf_synth_schema.ts",
generatedPath: join(REPO_ROOT, "playground", "src", "modes", "generated", "paf_synth_schema.ts"),
goldenPath: join(GOLDEN_DIR, "paf_synth_schema.ts"),
},
]; ];
function diff(name: string, expected: string, actual: string): string { function diff(name: string, expected: string, actual: string): string {

View file

@ -10,9 +10,9 @@ This file provides guidance to coding agents working with this repository.
MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. A research platform for interactive ML control of audio. **One C++20 codebase** (`nisps/`) compiles to two targets: MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. A research platform for interactive ML control of audio. **One C++20 codebase** (`nisps/`) compiles to two targets:
1. **RP2350 firmware** for the MEMLNaut hardware platform (`firmware/`). 1. **RP2350 firmware** for the MEMLNaut hardware platform (`firmware/`).
2. **WASM** in a SolidJS browser playground (`playground/`) — same engines + ML, run through an AudioWorklet. 2. **WASM** in the Manifold React browser app (`manifold/`) — same engines + ML, run through an AudioWorklet.
Browser audio engines are a superset of firmware engines (C15 is browser-only). Parameter contracts are JSON schemas (`schemas/`) with codegen producing both C++ headers and TypeScript types. (The former SolidJS playground was retired 2026-07-13 at P1 of `docs/specs/plans/one-core-engine-refactor.md`; archived on branch `archive/playground-solidjs`, tag `playground-solidjs-final`. The browser-only C15 engine currently lives only there.) Parameter contracts are JSON schemas (`schemas/`) with codegen producing the C++ headers (TS emission returns at P5).
Project documentation: https://musicallyembodiedml.github.io/memlnaut/approaches/nisps Project documentation: https://musicallyembodiedml.github.io/memlnaut/approaches/nisps
@ -76,36 +76,24 @@ Build: `scripts/build-firmware.sh [VARIANT]`. Verified compiling for PAFSynth, C
- **Core 1**: Real-time audio processing (`Mode::process`), MIDI polling. - **Core 1**: Real-time audio processing (`Mode::process`), MIDI polling.
- **Sync**: `nisps::core::ring_buffer` (templated SPSC lock-free, replaces pico/util/queue) + memory barriers (`nisps::core::memory_barrier`, `write_volatile`/`read_volatile`). - **Sync**: `nisps::core::ring_buffer` (templated SPSC lock-free, replaces pico/util/queue) + memory barriers (`nisps::core::memory_barrier`, `write_volatile`/`read_volatile`).
## The `playground/` target ## The `manifold/` target
``` ```
playground/ # Vite + SolidJS + TypeScript manifold/ # Vite + React + TypeScript (the sole browser app)
├── src/ ├── src/
│ ├── primitives/ # 16 UI building blocks (Slider, JoyMap, Heatmap, …) + .demo.tsx for /dev/primitives │ ├── engine/ # framework-neutral TS engine spine: wasm-iml, engine-host + worklet,
│ ├── modes/ # one TSX per firmware mode + C15Mode (browser-only); ModeShell + ModeSwitcher + mode-runtime │ │ # input/output pipelines + curves (move into core at P4), spine.ts, EngineProvider
│ ├── stores/ # Solid stores (ml, input, output, mode, control, session, exploration, bus + persistence) │ ├── primitives/ # 12 design primitives as typed React
│ ├── audio/ # engine-host + AudioWorklet processor (loads nisps.wasm separately on each thread) │ ├── console/ # convertible Console (CompositeStage, Dock, Drawers, Manifold canvas, …)
│ ├── ml/ # WasmIML class + disposable async-training Worker + dataset │ ├── dock/, backends/, inputs/, feedback/, settings/, serial/, midi-devices/
│ ├── input/, output/ # pure-fn pipelines (deadzone→zoom→curve→smoothing→momentum, then global curve→smoothing→slew→freeze) │ └── debug/probe.ts # window.__nisps behind ?debug=1 for Playwright
│ ├── features/ # heatmap, snapshots, A/B compare, region/param pin, trail, weight health, gradient flow ├── public/ # nisps.{wasm,js} — canonical build-wasm.sh output
│ └── debug/probe.ts # synchronous window.__nisps for Playwright └── tests/ # e2e Playwright specs + fixtures/ (P4 golden parity fixtures)
├── public/ # nisps.{wasm,js}, c15.{wasm,glue}
└── tests/e2e/ # Playwright specs + helpers
``` ```
Dev: `cd playground && bun install && bun run dev`. Build: `bun run build`. Typecheck: `bun run typecheck`. E2E: `bunx playwright test`. Dev: `cd manifold && bun install && bun run dev`. Build: `bun run build`. Typecheck: `bun run typecheck`. Unit: `bun run test`. E2E: `bunx playwright test` (on the VPS run the runner via non-snap node — BUILD-PLAN gotcha).
### Stores + reactivity Read `manifold/ONBOARDING.md` before touching manifold UI — layering, Stages, Dock, gotchas.
All stores use SolidJS `createStore` for objects, `createSignal` for primitives. ML outputs are stored in a separate Float32Array signal (per the migration plan's perf guidance). Persistence (debounced 200ms localStorage round-trip) wired in `playground/src/stores/persistence.ts`. The signal bus (`bus.ts`) handles cross-store events (`ml.*`, `mode.*`, `pin.*`, `ui.*`).
### Control surface
Three compound axes (Boldness / Memory / Precision) interpolate per-axis tables to drive ~6 underlying parameters each, with offset overrides ("trim-pot" model). State is in `control-store`. Six built-in control presets (Default, First Touch, Jazz Hands, Sculptor, Improviser, Microscope) available via the ModeShell control bar.
### Debug probe (Playwright)
`window.__nisps` is exposed synchronously and bypasses Solid reactivity (uses `untrack`/`batch`). API matches the `.local/recon/04-playground.md` spec — `setInputs`, `getOutputs`, `getLoss`, `train`, `thumbsUp`/`thumbsDown`, `randomise`, `clearExamples`, `inferBatch`, `getLayerStats`, `saveState`, etc.
## The `schemas/` + `codegen/` contract ## The `schemas/` + `codegen/` contract
@ -113,7 +101,7 @@ Each mode has a `schemas/modes/<mode>.json` describing its parameters (name, lab
Codegen (`bun run codegen/generate.ts`) emits: Codegen (`bun run codegen/generate.ts`) emits:
- `nisps/modes/generated/<mode>_schema.hpp``constexpr` C++ data, namespace `nisps::modes::generated`, re-exports `nisps::Curve` from `nisps/core/math.hpp`. - `nisps/modes/generated/<mode>_schema.hpp``constexpr` C++ data, namespace `nisps::modes::generated`, re-exports `nisps::Curve` from `nisps/core/math.hpp`.
- `playground/src/modes/generated/<mode>_schema.ts` — typed const objects + per-mode params interface. - (TS emission is dormant since P1; it returns at P5 targeting `manifold/src/modes/generated/`.)
Codegen is idempotent. Golden test ensures regenerating produces byte-identical output. Codegen is idempotent. Golden test ensures regenerating produces byte-identical output.
@ -121,29 +109,28 @@ Codegen is idempotent. Golden test ensures regenerating produces byte-identical
Two WASM instances at runtime: Two WASM instances at runtime:
1. **Main thread** (`playground/src/ml/wasm-iml.ts`): ML inference + sync training + RL primitives. Update store after each call. Async training via disposable Web Worker (`wasm-worker.ts`). 1. **Main thread** (`manifold/src/engine/wasm-iml.ts`): ML inference + sync training + RL primitives, reporting into an injected `EngineSink`. Async training via disposable Web Worker (`wasm-worker.ts`).
2. **AudioWorklet** (`playground/src/audio/worklet/nisps-processor.ts`): runs engine `process_block` per audio block. Loads `nisps.wasm` directly via `WebAssembly.compile` (no Emscripten glue in worklet). Bytes posted from main thread. 2. **AudioWorklet** (`manifold/src/engine/worklet/nisps-processor.ts`): runs engine `process_block` per audio block. Loads `nisps.wasm` directly via `WebAssembly.compile` (no Emscripten glue in worklet). Bytes posted from main thread.
C API is in `nisps/wasm/bindings.cpp`. Build: `bash scripts/build-wasm.sh` (~94KB output to `manifold/public/`, transitional copy to `playground/public/` until P1). C API is in `nisps/wasm/bindings.cpp`. Build: `bash scripts/build-wasm.sh` (~94KB output to `manifold/public/`).
The WASM target is fixed at `MLP<32u, 10u, 14u, 18u, 126u>` (`nisps_ml_create` ignores requested dims — see `docs/specs/plans/one-core-engine-refactor.md` P2). Modes with smaller `input_size`/`output_size` use a slice. The WASM target is fixed at `MLP<32u, 10u, 14u, 18u, 126u>` (`nisps_ml_create` ignores requested dims — see `docs/specs/plans/one-core-engine-refactor.md` P2). Modes with smaller `input_size`/`output_size` use a slice.
### Known limitations ### Known limitations
- Loss history not yet plumbed through C API; `lossHistory` in the store is a single-element array per training run. - Loss history not yet plumbed through C API; only the final loss of a training run reaches TS.
- Engine MLP architecture is fixed at compile time — supporting per-mode hidden-layer shapes would need either multiple WASM modules or runtime variation. - Engine MLP architecture is fixed at compile time — runtime-shaped browser MLP arrives at P2 of `docs/specs/plans/one-core-engine-refactor.md`.
- Mic input through the worklet for XIASRI / SoundAnalysisMIDI is not wired; UI scaffolds render but feature is TODO. - Mic input through the worklet for XIASRI / SoundAnalysisMIDI is not wired in manifold.
- C15 voice space integration in C15Mode is a placeholder. - C15 has no home on main (see `ALIGNMENT.md` defect #1).
- The browser Jolt/OU controls (`playground/src/ml/jolt.ts`, `playground/src/output/ou-explore.ts`) reimplement the gesture math in TS rather than calling the C++ `ml::Jolt`/`ml::OUNoise` through WASM. They drive weights via the existing `nisps_ml_get/set_weights` bindings and use `Math.random()` (not the deterministic `Rng`) — fine for stochastic exploration aids, but firmware↔browser bit-parity of the noise itself is intentionally not guaranteed. - The browser Jolt/OU controls in manifold reimplement the gesture math in TS (interim, ported from the retired playground) rather than calling the C++ `ml::Jolt`/`ml::OUNoise` through WASM. They drive weights via the existing `nisps_ml_get/set_weights` bindings — the P3 phase of the one-core-engine plan replaces them with `nisps_ml_jolt_press/release` + `nisps_ml_explore_intensity` bindings.
## URL parameters (playground) ## URL parameters (manifold)
| Param | Range | Default | Effect | | Param | Range | Default | Effect |
|-------|-------|---------|--------| |-------|-------|---------|--------|
| `tame` | 01 | 1 | Constrains synth output ranges toward safe limits. | | `debug` | 1 | _(off)_ | Exposes the `window.__nisps` debug probe. |
| `spread` | 01 | 0.6 | Master noise regime (init scale, RL noise cap, per-layer Xavier scaling, weight decay). |
| `preset` | preset id | _(none)_ | Auto-loads a synth preset on first visit. | (The playground-era `tame`/`spread`/`preset` URL params died with the playground; `spread` survives as an engine concept — see below.)
| `debug` | 1 | _(off)_ | Exposes `window.__nisps` debug probe. |
### `spread` — sigmoid saturation control ### `spread` — sigmoid saturation control
@ -187,12 +174,13 @@ scripts/build-firmware.sh PAFSynth # or any other variant
scripts/flash-firmware.sh scripts/flash-firmware.sh
scripts/build-and-flash-firmware.sh scripts/build-and-flash-firmware.sh
# Playground # Manifold
cd playground && bun install cd manifold && bun install
bun run dev # Vite dev (COOP/COEP enabled) bun run dev # Vite dev (COOP/COEP enabled)
bun run typecheck bun run typecheck
bun run test # bun unit tests
bun run build bun run build
bunx playwright test bunx playwright test # on the VPS: node node_modules/.bin/playwright test
# All tests # All tests
bash scripts/run-all-tests.sh bash scripts/run-all-tests.sh

View file

@ -6,7 +6,7 @@ layer: cross-cutting
# MEMLNaut-NISPS Spec Corpus # MEMLNaut-NISPS Spec Corpus
**Neural Interactive Shaping of Parameter Spaces** — a research platform for interactive ML control of audio. One C++20 codebase compiles to two targets: RP2350 firmware for the MEMLNaut hardware, and WASM in a SolidJS browser playground running the same engines + ML through an AudioWorklet. **Neural Interactive Shaping of Parameter Spaces** — a research platform for interactive ML control of audio. One C++20 codebase compiles to two targets: RP2350 firmware for the MEMLNaut hardware, and WASM in the Manifold React browser app running the same engines + ML through an AudioWorklet. (The SolidJS playground was retired 2026-07-13, P1 of plans/one-core-engine-refactor.md; archived on `archive/playground-solidjs`.)
--- ---
@ -30,8 +30,7 @@ The codebase's most architecturally significant areas referenced by this corpus:
| **Shared C++20** | `nisps/core/`, `nisps/ml/`, `nisps/dsp/`, `nisps/engines/`, `nisps/modes/` | One audio+ML library compiling to both firmware and WASM | | **Shared C++20** | `nisps/core/`, `nisps/ml/`, `nisps/dsp/`, `nisps/engines/`, `nisps/modes/` | One audio+ML library compiling to both firmware and WASM |
| **Schema/Codegen** | `schemas/`, `codegen/` | Parameter contracts + code generation (C++ headers, TS types) | | **Schema/Codegen** | `schemas/`, `codegen/` | Parameter contracts + code generation (C++ headers, TS types) |
| **Firmware** | `firmware/MEMLNaut-NISPS/glue/` | Hardware bindings (audio, peripherals, MIDI, settings) | | **Firmware** | `firmware/MEMLNaut-NISPS/glue/` | Hardware bindings (audio, peripherals, MIDI, settings) |
| **Browser** | `playground/src/engine/`, `playground/src/audio/` | Headless TS engine + AudioWorklet, wired to Solid stores | | **Browser (Manifold)** | `manifold/src/engine/`, `manifold/src/console/` | Headless TS engine + AudioWorklet + the convertible React Console |
| **Manifold** | `manifold/src/engine/`, `manifold/src/console/` | Same TS engine + React wrapper (the new convertible front-end) |
--- ---
@ -40,7 +39,7 @@ The codebase's most architecturally significant areas referenced by this corpus:
**What it is.** MEMLNaut-NISPS is a single C++20 codebase that compiles once to two distinct targets: **What it is.** MEMLNaut-NISPS is a single C++20 codebase that compiles once to two distinct targets:
1. **Firmware** — runs on RP2350 hardware (MEMLNaut instrument). Real-time audio engines (8 variants) + interactive ML (4-layer MLP, SGD + RL feedback) with deterministic RNG + dual-core orchestration (audio on core 1, control on core 0). 1. **Firmware** — runs on RP2350 hardware (MEMLNaut instrument). Real-time audio engines (8 variants) + interactive ML (4-layer MLP, SGD + RL feedback) with deterministic RNG + dual-core orchestration (audio on core 1, control on core 0).
2. **Browser (WASM)** — runs in a SolidJS playground (or React Manifold front-end) via AudioWorklet. Same C++20 engines + ML, compiled to WASM; audio engines are a superset of firmware (C15 synth browser-only). 2. **Browser (WASM)** — runs in the React Manifold front-end via AudioWorklet. Same C++20 engines + ML, compiled to WASM; audio engines are a superset of firmware (C15 synth browser-only).
**The unifying constraint**: one source tree, one ML architecture, cross-platform parity (native ↔ WASM within 1e-5 numerical tolerance). Parameter shapes are JSON schemas with codegen producing both C++ headers and TypeScript types. **The unifying constraint**: one source tree, one ML architecture, cross-platform parity (native ↔ WASM within 1e-5 numerical tolerance). Parameter shapes are JSON schemas with codegen producing both C++ headers and TypeScript types.
@ -65,7 +64,7 @@ The codebase's most architecturally significant areas referenced by this corpus:
Each firmware mode has a `schemas/modes/<mode>.json` describing parameters (name, label, range, default, curve, group), ML config (input/output sizes, hidden layers), voice spaces (names; bodies are inline lambdas in the engine), and UI config. The meta-schema at `schemas/schema.json` validates these. Codegen (`bun run codegen/generate.ts`) is idempotent and produces: Each firmware mode has a `schemas/modes/<mode>.json` describing parameters (name, label, range, default, curve, group), ML config (input/output sizes, hidden layers), voice spaces (names; bodies are inline lambdas in the engine), and UI config. The meta-schema at `schemas/schema.json` validates these. Codegen (`bun run codegen/generate.ts`) is idempotent and produces:
- `nisps/modes/generated/<mode>_schema.hpp``constexpr` C++ data under `nisps::modes::generated`. - `nisps/modes/generated/<mode>_schema.hpp``constexpr` C++ data under `nisps::modes::generated`.
- `playground/src/modes/generated/<mode>_schema.ts` — typed const objects + per-mode params interface. - TS schema emission is dormant since P1; it returns at P5 targeting `manifold/src/modes/generated/`.
Regenerate after editing any `schemas/modes/*.json`. Golden test ensures output is byte-identical. Regenerate after editing any `schemas/modes/*.json`. Golden test ensures output is byte-identical.

View file

@ -1,5 +0,0 @@
node_modules
dist
.vite
*.log
.DS_Store

View file

@ -0,0 +1 @@
._root_1attb_1{max-width:1200px;margin:0 auto;display:flex;flex-direction:column;gap:var(--sp-5)}._header_1attb_9{display:flex;flex-direction:column;gap:var(--sp-2)}._header_1attb_9 h1{margin:0;font-size:var(--fs-xl);color:var(--accent)}._lede_1attb_21{color:var(--fg-mute);margin:0;max-width:720px}._grid_1attb_27{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:var(--sp-4)}._card_1attb_33{background:var(--bg-1);border:1px solid var(--line);border-radius:var(--r-3);padding:var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-3);min-height:280px}._cardHead_1attb_44{display:flex;flex-direction:column;gap:var(--sp-1);border-bottom:1px solid var(--line);padding-bottom:var(--sp-2)}._cardTitle_1attb_52{margin:0;font-size:var(--fs-md);color:var(--fg)}._cardDesc_1attb_58{margin:0;font-size:var(--fs-xs);color:var(--fg-mute)}._cardBody_1attb_64{display:flex;flex-direction:column;align-items:stretch;flex:1;justify-content:flex-start}._bank_3njyn_1{display:flex;flex-direction:column;gap:var(--sp-3);background:var(--bg-1);border:1px solid var(--line);border-radius:var(--r-2);padding:var(--sp-3)}._title_3njyn_11{font-size:var(--fs-md);margin:0 0 var(--sp-2) 0;color:var(--fg)}._section_3njyn_17{display:flex;flex-direction:column;gap:var(--sp-2)}._sectionHeader_3njyn_23{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2);background:var(--bg-2);border:1px solid var(--line);text-align:left;width:100%;font-size:var(--fs-xs);text-transform:uppercase;letter-spacing:.08em;color:var(--fg-mute)}._caret_3njyn_38{width:1ch;display:inline-block}._sectionName_3njyn_43{flex:1}._sectionCount_3njyn_47{color:var(--fg-dim)}._list_3njyn_51{display:flex;flex-direction:column;gap:var(--sp-2);padding:var(--sp-1) 0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(function(){"use strict";class f extends AudioWorkletProcessor{instance=null;engineHandle=0;engineId="thru";muted=!0;inLPtr=0;inRPtr=0;outLPtr=0;outRPtr=0;idPtr=0;paramsPtr=0;inLView=null;inRView=null;outLView=null;outRView=null;paramsView=null;idView=null;mem=null;pendingParams=null;constructor(){super(),this.port.onmessage=i=>this.onMessage_(i.data)}async onMessage_(i){if(i.kind==="init")try{await this.init_(i.wasmBinary,i.sampleRate),this.post_({kind:"ready"})}catch(r){this.post_({kind:"error",message:r instanceof Error?r.message:String(r)})}else i.kind==="engine"?this.switchEngine_(i.engineId):i.kind==="params"?this.applyParams_(i.params):i.kind==="mute"&&(this.muted=i.muted)}post_(i){this.port.postMessage(i)}async init_(i,r){const t=new WebAssembly.Memory({initial:128,maximum:4096,shared:!1}),a=await WebAssembly.compile(i),h=WebAssembly.Module.imports(a),n={};for(const e of h){n[e.module]||(n[e.module]={});const s=n[e.module];e.kind==="function"?(e.name,s[e.name]=(...o)=>0):e.kind==="memory"?s[e.name]=t:e.kind==="table"?s[e.name]=new WebAssembly.Table({element:"anyfunc",initial:0}):e.kind==="global"&&(s[e.name]=new WebAssembly.Global({value:"i32",mutable:!0},0))}for(const e of h){const s=n[e.module];e.name==="a"&&e.kind==="function"&&(s[e.name]=()=>{throw new Error("wasm aborted")}),e.name==="b"&&e.kind==="function"&&(s[e.name]=o=>0)}const w=await WebAssembly.instantiate(a,n),_=WebAssembly.Module.exports(a),P=new Map;for(const e of _)P.set(e.name,e.name);const p=w.exports;function u(...e){for(const s of e){const o=p[s];if(typeof o=="function")return o}throw new Error(`worklet: missing wasm export, tried: ${e.join(", ")}`)}function m(...e){return u(...e)}const l=u("_malloc","malloc"),g=m("_free","free"),y=u("_nisps_engine_create"),R=m("_nisps_engine_destroy"),b=m("_nisps_engine_set_params"),L=m("_nisps_engine_process_block");let d=null;for(const e of _)if(e.kind==="memory"){const s=p[e.name];if(s instanceof WebAssembly.Memory){d=s;break}}this.mem=d??t,this.instance={exports:{memory:this.mem,malloc:l,free:g,_nisps_engine_create:(e,s)=>y(e,s),_nisps_engine_destroy:e=>R(e),_nisps_engine_set_params:(e,s,o)=>b(e,s,o),_nisps_engine_process_block:(e,s,o,V,A,C)=>L(e,s,o,V,A,C)}},this.inLPtr=l(128*4),this.inRPtr=l(128*4),this.outLPtr=l(128*4),this.outRPtr=l(128*4),this.paramsPtr=l(256*4),this.idPtr=l(32);const c=this.mem.buffer;this.inLView=new Float32Array(c,this.inLPtr,128),this.inRView=new Float32Array(c,this.inRPtr,128),this.outLView=new Float32Array(c,this.outLPtr,128),this.outRView=new Float32Array(c,this.outRPtr,128),this.paramsView=new Float32Array(c,this.paramsPtr,256),this.idView=new Uint8Array(c,this.idPtr,32),this.spawnEngine_("thru",r),this.pendingParams&&(this.applyParams_(this.pendingParams),this.pendingParams=null),this.muted=!1}spawnEngine_(i,r){if(!this.instance||!this.idView)return;this.engineHandle&&(this.instance.exports._nisps_engine_destroy(this.engineHandle),this.engineHandle=0);const a=new TextEncoder().encode(i);this.idView.fill(0),this.idView.set(a.subarray(0,Math.min(a.length,31))),this.engineHandle=this.instance.exports._nisps_engine_create(this.idPtr,r),this.engineId=i}switchEngine_(i){this.spawnEngine_(i,sampleRate)}applyParams_(i){if(!this.instance||!this.paramsView){this.pendingParams=i;return}const r=Math.min(i.length,256);for(let t=0;t<r;++t)this.paramsView[t]=i[t];this.engineHandle&&this.instance.exports._nisps_engine_set_params(this.engineHandle,this.paramsPtr,r)}process(i,r){const t=r[0];if(!t||t.length===0)return!0;const a=t[0],h=t.length>1?t[1]:t[0];if(this.muted||!this.instance||!this.engineHandle||!this.inLView||!this.outLView||!this.outRView||!this.inRView)return a.fill(0),t.length>1&&h.fill(0),!0;const n=i[0];return n&&n[0]?this.inLView.set(n[0].subarray(0,128)):this.inLView.fill(0),n&&n[1]?this.inRView.set(n[1].subarray(0,128)):n&&n[0]?this.inRView.set(n[0].subarray(0,128)):this.inRView.fill(0),this.instance.exports._nisps_engine_process_block(this.engineHandle,this.inLPtr,this.inRPtr,this.outLPtr,this.outRPtr,128),a.set(this.outLView.subarray(0,a.length)),t.length>1&&h.set(this.outRView.subarray(0,h.length)),!0}}registerProcessor("nisps-processor",f)})();
//# sourceMappingURL=nisps-processor-BuKuNOY1.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(function(){"use strict";if(typeof window>"u"&&typeof self<"u"&&typeof self.importScripts<"u"){let h=function(t,s,n,u){if(!e)throw new Error("worker module not loaded");c!==a&&(i&&e._free(i),i=e._malloc(a*4),c=a),t.length!==g&&(l&&e._free(l),l=e._malloc(t.length*4),g=t.length),s.length!==_&&(f&&e._free(f),f=e._malloc(s.length*4),_=s.length),n.length!==w&&(o&&e._free(o),o=n.length>0?e._malloc(n.length*4):0,w=n.length),new Float32Array(e.HEAPF32.buffer,i,a).set(u),new Float32Array(e.HEAPF32.buffer,l,t.length).set(t),new Float32Array(e.HEAPF32.buffer,f,s.length).set(s),o&&new Float32Array(e.HEAPF32.buffer,o,n.length).set(n)},p=function(t){if(!e)return{kind:"error",requestId:t.requestId,message:"worker not initialised"};try{h(t.features,t.labels,t.sampleWeights,t.weights),e._nisps_ml_set_weights(r,i),e._nisps_ml_clear_examples(r);const s=t.inputSize,n=t.outputSize,u=t.features.length/s;for(let d=0;d<u;++d){const S=l+d*s*4,H=f+d*n*4;e._nisps_ml_add_example(r,S,H)}const F=t.sampleWeights.length>0?o:0,m=e._nisps_ml_train(r,t.lr,t.maxIter,t.minErr,F);e._nisps_ml_get_weights(r,i);const A=new Float32Array(e.HEAPF32.buffer,i,a),P=new Float32Array(A),E=new Float32Array([m]);return{kind:"result",requestId:t.requestId,loss:m,weights:P,lossHistory:E}}catch(s){return{kind:"error",requestId:t.requestId,message:s instanceof Error?s.message:String(s)}}},y=function(){e&&(r&&(e._nisps_ml_destroy(r),r=0),i&&(e._free(i),i=0),l&&(e._free(l),l=0),f&&(e._free(f),f=0),o&&(e._free(o),o=0),e=null)},e=null,r=0,a=0,i=0,c=0,l=0,g=0,f=0,_=0,o=0,w=0;async function k(t){const s=await import(new URL("/nisps.js",self.location.origin).toString());e=await(s.default??s.createNispsModule)({locateFile:u=>u.endsWith(".wasm")?new URL("/nisps.wasm",self.location.origin).toString():u}),r=e._nisps_ml_create(0,0,0,0,t>>>0),a=e._nisps_ml_weight_count(r)}self.addEventListener("message",async t=>{const s=t.data;if(s.kind==="init")try{await k(s.seed),self.postMessage({kind:"ready"})}catch(n){self.postMessage({kind:"error",requestId:0,message:n instanceof Error?n.message:String(n)})}else if(s.kind==="train"){const n=p(s);n.kind==="result"?self.postMessage(n,[n.weights.buffer,n.lossHistory.buffer]):self.postMessage(n)}else s.kind==="dispose"&&y()})}})();
//# sourceMappingURL=wasm-worker-OATBBHaT.js.map

File diff suppressed because one or more lines are too long

View file

@ -5,9 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0d0d0d" /> <meta name="theme-color" content="#0d0d0d" />
<title>MEMLNaut Playground</title> <title>MEMLNaut Playground</title>
<script type="module" crossorigin src="/assets/index-C1-a2ZPT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D19lnkK_.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

2
playground/dist/nisps.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
playground/dist/nisps.wasm vendored Executable file

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,25 +0,0 @@
{
"name": "memlnaut-playground",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 4173",
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"solid-js": "^1.8.22"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^22.7.0",
"typescript": "^5.5.4",
"vite": "^5.4.10",
"vite-plugin-solid": "^2.10.2"
}
}

View file

@ -1,36 +0,0 @@
import { defineConfig } from '@playwright/test';
/**
* Playwright config for the SolidJS playground.
*
* The webServer block runs `vite preview` against the build output, so the
* tests exercise the real production bundle. Vite's preview command honors
* the COOP/COEP headers configured in vite.config.ts, which the AudioWorklet
* + WASM bridge needs.
*
* To run a fresh build before tests, run `bun run build` separately the
* preview server expects `dist/` to already exist. CI does this in the
* workflow before invoking Playwright.
*/
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: 'http://localhost:4173',
headless: true,
ignoreHTTPSErrors: true,
},
webServer: {
// `bun run preview` is `vite preview --port 4173` (see playground/package.json).
// Reuses an already-running server so `npx playwright test` after `bun
// dev` Just Works.
command: 'bun run preview',
cwd: '.',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
reporter: process.env.CI ? [['list'], ['github']] : [['list'], ['html', { open: 'never' }]],
});

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -1,84 +0,0 @@
.app {
display: flex;
flex-direction: column;
min-height: 100%;
}
.header {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--line);
background: var(--bg-1);
}
.dim {
color: var(--fg-mute);
font-size: var(--fs-sm);
}
.nav {
margin-left: auto;
display: flex;
gap: var(--sp-2);
}
.nav button {
padding: var(--sp-1) var(--sp-3);
font-size: var(--fs-sm);
}
.active {
border-color: var(--accent) !important;
color: var(--accent);
}
.main {
flex: 1;
padding: var(--sp-5);
overflow: auto;
}
.home {
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.title {
font-size: var(--fs-xl);
margin: 0;
color: var(--accent);
}
.tagline {
color: var(--fg-mute);
margin: 0;
}
.linkList {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.note {
color: var(--fg-dim);
font-size: var(--fs-sm);
margin-top: var(--sp-4);
}
.notFound {
max-width: 540px;
margin: var(--sp-6) auto;
}
.notFound h1 {
color: var(--accent);
}

View file

@ -1,145 +0,0 @@
import { Component, createMemo, createSignal, lazy, onCleanup, Show } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { modeStore } from './stores/mode-store';
import { MODE_REGISTRY, getModeById } from './modes';
import { ModeSwitcher } from './modes/ModeSwitcher';
import styles from './App.module.css';
type Route = 'home' | 'primitives' | 'modes' | 'unknown';
function parseRoute(path: string): Route {
if (path === '' || path === '/' || path === '/index.html') return 'home';
if (path === '/dev/primitives' || path === '/dev/primitives/') return 'primitives';
if (path === '/modes' || path === '/modes/' || path.startsWith('/modes/')) return 'modes';
return 'unknown';
}
// Lazy-loaded so the home route doesn't pay the cost of loading every
// primitive demo. Stream 9/10 can grow the showcase freely.
const PrimitivesShowcase = lazy(() => import('./dev/PrimitivesShowcase'));
const App: Component = () => {
const [route, setRoute] = createSignal<Route>(parseRoute(window.location.pathname));
const onPop = () => setRoute(parseRoute(window.location.pathname));
window.addEventListener('popstate', onPop);
onCleanup(() => window.removeEventListener('popstate', onPop));
const navigate = (path: string) => {
if (window.location.pathname === path) return;
window.history.pushState({}, '', path);
setRoute(parseRoute(path));
};
return (
<div class={styles.app}>
<header class={styles.header}>
<strong>MEMLNaut</strong>
<span class={styles.dim}>playground</span>
<nav class={styles.nav}>
<button
type="button"
class={route() === 'home' ? styles.active : ''}
onClick={() => navigate('/')}
>
home
</button>
<button
type="button"
class={route() === 'modes' ? styles.active : ''}
onClick={() => navigate('/modes')}
>
/modes
</button>
<button
type="button"
class={route() === 'primitives' ? styles.active : ''}
onClick={() => navigate('/dev/primitives')}
>
/dev/primitives
</button>
</nav>
</header>
<main class={styles.main}>
<Show when={route() === 'home'}>
<Home navigate={navigate} />
</Show>
<Show when={route() === 'modes'}>
<ModesPage />
</Show>
<Show when={route() === 'primitives'}>
<PrimitivesShowcase />
</Show>
<Show when={route() === 'unknown'}>
<div class={styles.notFound}>
<h1>404</h1>
<p>No route at <code>{window.location.pathname}</code>.</p>
<p>
<a href="/" onClick={(e) => { e.preventDefault(); navigate('/'); }}>back to home</a>
</p>
</div>
</Show>
</main>
</div>
);
};
const ModesPage: Component = () => {
const activeId = () => modeStore.state.activeModeId ?? MODE_REGISTRY[0]!.id;
const ActiveComponent = createMemo(() => getModeById(activeId()).Component);
return (
<div>
<ModeSwitcher />
<Dynamic component={ActiveComponent()} />
</div>
);
};
interface HomeProps {
navigate: (path: string) => void;
}
const Home: Component<HomeProps> = (props) => {
return (
<div class={styles.home}>
<h1 class={styles.title}>MEMLNaut Playground</h1>
<p class={styles.tagline}>
Interactive ML control of audio. Stream 9: nine modes, one shell.
</p>
<ul class={styles.linkList}>
<li>
<a
href="/modes"
onClick={(e) => {
e.preventDefault();
props.navigate('/modes');
}}
>
Modes
</a>{' '}
pick a mode and start playing
</li>
<li>
<a
href="/dev/primitives"
onClick={(e) => {
e.preventDefault();
props.navigate('/dev/primitives');
}}
>
Primitives showcase
</a>{' '}
UI building blocks
</li>
</ul>
<p class={styles.note}>
ML inference runs on the main thread via WASM. Audio synthesis runs
in an AudioWorklet start it from inside any mode using the "Start
audio" button. Audio cannot autoplay (browser policy).
</p>
</div>
);
};
export default App;

View file

@ -1,232 +0,0 @@
/**
* EngineHost main-thread side of the WASM AudioWorklet pipeline.
*
* Responsibilities:
* - Lazy-create AudioContext (browsers gate this on user gesture).
* - Register the AudioWorklet processor module.
* - Hand the worklet a copy of `nisps.wasm` bytes (we fetch on the main
* thread because AudioWorklet has no `fetch`/`importScripts`).
* - Send engine selection + parameter updates over the worklet `port`.
* - Tear everything down on `dispose()`.
*
* The actual DSP runs in `playground/src/audio/worklet/nisps-processor.ts`,
* which calls into a SECOND WASM instance owned by the worklet thread.
*
* IMPORTANT: this class never auto-starts audio. The caller must drive
* `start()` from a user gesture (button click, etc.) so browsers don't
* block AudioContext creation.
*/
import type { EngineId } from '../ml/types';
// `?worker&url` makes Vite COMPILE the worklet TS→JS, bundle its imports, and
// hand back a hashed .js URL. Plain `new URL('./x.ts', import.meta.url)` does
// NOT work for audioWorklet.addModule (Vite only treats that as a worker entry
// for `new Worker(...)`) — it copies raw .ts, which the browser rejects.
import workletUrl from './worklet/nisps-processor.ts?worker&url';
const NISPS_WASM_URL = '/nisps.wasm';
const PROCESSOR_NAME = 'nisps-processor';
/** Message protocol: main → worklet. */
export type HostToWorkletMessage =
| {
kind: 'init';
// ArrayBuffer transferred so the worklet can `WebAssembly.compile` it.
wasmBinary: ArrayBuffer;
sampleRate: number;
}
| {
kind: 'engine';
engineId: EngineId;
}
| {
kind: 'params';
// Float32Array transferred to avoid per-tick copy.
params: Float32Array;
}
| {
kind: 'mute';
muted: boolean;
};
/** Message protocol: worklet → main. */
export type WorkletToHostMessage =
| { kind: 'ready' }
| { kind: 'error'; message: string };
export interface EngineHostOptions {
/** Override sample rate (default: AudioContext.sampleRate). */
sampleRate?: number;
/** Override worklet processor URL (testing). */
processorUrl?: string;
}
export class EngineHost {
private ctx: AudioContext | null = null;
private node: AudioWorkletNode | null = null;
private workletReady = false;
private currentEngine: EngineId = 'thru';
private disposed = false;
private options: EngineHostOptions;
// Cached bytes of nisps.wasm (we fetch once per host instance).
private wasmBytes: ArrayBuffer | null = null;
constructor(options: EngineHostOptions = {}) {
this.options = options;
}
get isStarted(): boolean {
return !!this.ctx && this.workletReady;
}
get sampleRate(): number {
return this.ctx?.sampleRate ?? this.options.sampleRate ?? 48000;
}
/**
* Start audio. Must be called from a user gesture for AudioContext to
* resume. After this resolves, `setEngine()` and `setParams()` can be
* called.
*/
async start(engineId: EngineId = 'thru'): Promise<void> {
if (this.ctx) {
// Already started; just switch engine.
this.setEngine(engineId);
await this.ctx.resume();
return;
}
this.ctx = new AudioContext({
sampleRate: this.options.sampleRate,
latencyHint: 'interactive',
});
// Fetch the WASM bytes on the main thread (the worklet doesn't have
// fetch). We pass the buffer to the worklet as a transferable.
if (!this.wasmBytes) {
this.wasmBytes = await this.fetchWasm_();
}
// Register the processor module. `workletUrl` is the Vite-compiled,
// bundled .js (see import note at top); addModule needs real JS, not raw TS.
const procUrl = this.options.processorUrl ?? workletUrl;
await this.ctx.audioWorklet.addModule(procUrl);
this.node = new AudioWorkletNode(this.ctx, PROCESSOR_NAME, {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [2],
});
this.node.connect(this.ctx.destination);
// Wire up message handler before sending init.
this.workletReady = false;
const ready = new Promise<void>((resolve, reject) => {
const onMsg = (ev: MessageEvent<WorkletToHostMessage>) => {
if (ev.data.kind === 'ready') {
this.workletReady = true;
this.node?.port.removeEventListener('message', onMsg);
resolve();
} else if (ev.data.kind === 'error') {
this.node?.port.removeEventListener('message', onMsg);
reject(new Error(ev.data.message));
}
};
this.node!.port.addEventListener('message', onMsg);
this.node!.port.start();
});
// Send the WASM binary + sample rate. ArrayBuffer is transferable —
// we keep a copy on the main thread for re-init.
const copy = this.wasmBytes.slice(0);
this.node.port.postMessage(
{ kind: 'init', wasmBinary: copy, sampleRate: this.ctx.sampleRate } satisfies HostToWorkletMessage,
[copy],
);
await ready;
this.currentEngine = engineId;
if (engineId !== 'thru') {
this.setEngine(engineId);
}
}
/**
* Switch which engine the worklet is processing. Cheap just a message.
* The worklet handles engine destruction/creation internally.
*/
setEngine(engineId: EngineId): void {
if (!this.node || !this.workletReady) return;
this.currentEngine = engineId;
this.node.port.postMessage({ kind: 'engine', engineId } satisfies HostToWorkletMessage);
}
/**
* Push a fresh parameter vector. Caller should NOT reuse the buffer
* after this call we transfer it. If you need to keep yours, pass a
* copy: `host.setParams(new Float32Array(myBuf))`.
*/
setParams(params: Float32Array): void {
if (!this.node || !this.workletReady) return;
this.node.port.postMessage(
{ kind: 'params', params } satisfies HostToWorkletMessage,
[params.buffer],
);
}
setMuted(muted: boolean): void {
if (!this.node || !this.workletReady) return;
this.node.port.postMessage({ kind: 'mute', muted } satisfies HostToWorkletMessage);
}
async stop(): Promise<void> {
if (!this.ctx) return;
if (this.node) {
try {
this.node.disconnect();
} catch { /* ignore */ }
this.node = null;
}
try {
await this.ctx.close();
} catch { /* ignore */ }
this.ctx = null;
this.workletReady = false;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
void this.stop();
this.wasmBytes = null;
}
private async fetchWasm_(): Promise<ArrayBuffer> {
const url = new URL(NISPS_WASM_URL, window.location.origin).toString();
const resp = await fetch(url);
if (!resp.ok) throw new Error(`fetch nisps.wasm: ${resp.status} ${resp.statusText}`);
return await resp.arrayBuffer();
}
}
/**
* Smoke-test helper: returns true iff the WASM module exposes the C
* functions we need. Useful as a build-time check inside `engine-host.ts`
* tests; not used in production.
*/
export async function smokeCheckWasm(): Promise<boolean> {
const url = new URL('/nisps.js', window.location.origin).toString();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(/* @vite-ignore */ url);
const factory = mod.default ?? mod.createNispsModule;
if (!factory) return false;
const m = await factory({
locateFile: (p: string) => p.endsWith('.wasm')
? new URL(NISPS_WASM_URL, window.location.origin).toString()
: p,
});
return typeof m._nisps_ml_create === 'function'
&& typeof m._nisps_engine_create === 'function'
&& typeof m._nisps_engine_process_block === 'function';
}

View file

@ -1,52 +0,0 @@
# Why two WASM instances?
The playground loads `nisps.wasm` twice:
1. **Main thread**, via `playground/src/ml/wasm-iml.ts`. Used for ML
inference + sync training + RL operations + UI feedback.
2. **AudioWorklet thread**, via `nisps-processor.ts`. Used for engine
processing (per-128-sample-block).
Why not share?
- AudioWorklet runs on its own thread. Sharing memory across threads
requires SharedArrayBuffer + locking on every heap access; far more
expensive than two heaps.
- AudioWorklet has neither `fetch` nor ESM `import`, so it can't load
the Emscripten glue (`nisps.js`). The main thread fetches the WASM
bytes once and posts them here as a transferable `ArrayBuffer`; the
worklet then `WebAssembly.instantiate`s directly.
- Engines and ML never interact in the audio path. The main thread
computes the parameter vector each frame and pushes it into the
worklet via `port.postMessage`. The worklet pushes nothing back per
block (analysis features, if needed, are batched and sent at low
rate).
Per-frame data flow:
```
Joystick → input pipeline → mlStore.outputs (Float32Array, length=126)
↓ EngineHost.setParams()
↓ port.postMessage (transferable)
AudioWorklet ← WASM engine.process_block ← WASM engine.set_params
```
## Custom WASM loader
We do **not** use the Emscripten JS glue inside the worklet — the glue
contains `URL`, `Worker`, and `fetch` references that don't exist in
AudioWorkletGlobalScope. Instead `nisps-processor.ts` calls
`WebAssembly.instantiate` directly with hand-rolled imports and
discovers exports by walking the export descriptors. This makes the
worklet bundle small (just the processor TS) and avoids touching the
Emscripten init path.
The trade-off: only the engine API is callable here, not the ML API.
If you ever need ML inference inside the worklet (we don't), use the
main thread copy and post params over.
## Block size
AudioWorklet always calls `process()` with 128-sample blocks. Our
heap buffers in `nisps-processor.ts` are sized to match. Don't change
the block size without changing the buffer allocations.

View file

@ -1,26 +0,0 @@
/**
* Type declarations for AudioWorkletGlobalScope. The default `lib.dom`
* and `lib.dom.iterable` files don't include these because they only
* exist inside an AudioWorklet thread.
*
* Keep this file minimal only what `nisps-processor.ts` actually uses.
*/
declare const sampleRate: number;
declare const currentFrame: number;
declare const currentTime: number;
declare class AudioWorkletProcessor {
constructor(options?: { numberOfInputs?: number; numberOfOutputs?: number; processorOptions?: unknown });
readonly port: MessagePort;
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean;
}
declare function registerProcessor(
name: string,
processorCtor: new (options?: unknown) => AudioWorkletProcessor,
): void;

View file

@ -1,309 +0,0 @@
/**
* AudioWorkletProcessor that runs `nisps.wasm` engines.
*
* Why a separate WASM instance from the main thread? AudioWorklet runs in
* its own thread + global scope; reusing a single instance would require
* SharedArrayBuffer + locking on the heap. Architecture.md §6.4 specifies
* separate instances connected by `port` messages instead.
*
* Wasm load path: AudioWorklet has NO `fetch` and NO ESM `import`. The
* main thread fetches `nisps.wasm` once and posts the bytes here as an
* ArrayBuffer; we then `WebAssembly.compile` and `instantiate` directly,
* skipping the Emscripten glue entirely. This is fine because the
* exported functions don't need any of the JS-side runtime.
*
* Block size: AudioWorklet ALWAYS calls process() with 128-sample blocks.
* We allocate 128-sample input and output buffers in the WASM linear
* memory and shuttle samples in/out per call.
*/
/// <reference path="./audioworklet-globals.d.ts" />
import type { EngineId } from '../../ml/types';
import type { HostToWorkletMessage, WorkletToHostMessage } from '../engine-host';
const PROC_BLOCK = 128;
const MAX_PARAMS = 256; // upper bound across all engines
interface WasmInstance {
exports: {
memory: WebAssembly.Memory;
malloc: (n: number) => number;
free: (p: number) => void;
_nisps_engine_create: (id_ptr: number, sample_rate: number) => number;
_nisps_engine_destroy: (engine: number) => void;
_nisps_engine_set_params: (engine: number, params_ptr: number, n: number) => void;
_nisps_engine_process_block: (
engine: number,
in_l: number, in_r: number,
out_l: number, out_r: number,
n_samples: number,
) => void;
};
}
class NispsProcessor extends AudioWorkletProcessor {
private instance: WasmInstance | null = null;
private engineHandle = 0;
private engineId: EngineId = 'thru';
private muted = true;
// Pointers + buffer views (allocated once instance is up).
private inLPtr = 0;
private inRPtr = 0;
private outLPtr = 0;
private outRPtr = 0;
private idPtr = 0;
private paramsPtr = 0;
private inLView: Float32Array | null = null;
private inRView: Float32Array | null = null;
private outLView: Float32Array | null = null;
private outRView: Float32Array | null = null;
private paramsView: Float32Array | null = null;
private idView: Uint8Array | null = null;
private mem: WebAssembly.Memory | null = null;
// Pending params posted before the engine was ready.
private pendingParams: Float32Array | null = null;
constructor() {
super();
this.port.onmessage = (ev) => this.onMessage_(ev.data as HostToWorkletMessage);
}
private async onMessage_(msg: HostToWorkletMessage): Promise<void> {
if (msg.kind === 'init') {
try {
await this.init_(msg.wasmBinary, msg.sampleRate);
this.post_({ kind: 'ready' });
} catch (err) {
this.post_({
kind: 'error',
message: err instanceof Error ? err.message : String(err),
});
}
} else if (msg.kind === 'engine') {
this.switchEngine_(msg.engineId);
} else if (msg.kind === 'params') {
this.applyParams_(msg.params);
} else if (msg.kind === 'mute') {
this.muted = msg.muted;
}
}
private post_(msg: WorkletToHostMessage): void {
this.port.postMessage(msg);
}
/**
* Compile + instantiate the wasm module. We provide minimal imports
* the Emscripten module needs `__abort_js` and `_emscripten_resize_heap`
* (we keep memory non-resizing so the latter is a stub).
*/
private async init_(bytes: ArrayBuffer, sampleRate: number): Promise<void> {
const memory = new WebAssembly.Memory({ initial: 128, maximum: 4096, shared: false });
const imports: WebAssembly.Imports = {
// Emscripten import "a" group; field names match the generated JS.
a: {
a: () => { throw new Error('wasm aborted'); },
b: () => false, // _emscripten_resize_heap returning 0 disables growth
},
};
const compiled = await WebAssembly.compile(bytes);
// Discover the actual import shape from the module — names like "a",
// "b" depend on emcc's mangling; we accept whatever it produces.
const importDesc = WebAssembly.Module.imports(compiled);
const reshaped: WebAssembly.Imports = {};
for (const desc of importDesc) {
if (!reshaped[desc.module]) reshaped[desc.module] = {} as WebAssembly.ModuleImports;
const mod = reshaped[desc.module] as WebAssembly.ModuleImports;
if (desc.kind === 'function') {
if (desc.name === 'c') {
// unused
}
mod[desc.name] = (() => {
// Generic stub: log + return 0.
return (..._args: unknown[]) => 0;
})();
} else if (desc.kind === 'memory') {
mod[desc.name] = memory;
} else if (desc.kind === 'table') {
mod[desc.name] = new WebAssembly.Table({ element: 'anyfunc', initial: 0 });
} else if (desc.kind === 'global') {
mod[desc.name] = new WebAssembly.Global({ value: 'i32', mutable: true }, 0);
}
}
// For known-needed Emscripten imports, supply real implementations.
for (const desc of importDesc) {
const mod = reshaped[desc.module] as WebAssembly.ModuleImports;
// __abort_js
if (desc.name === 'a' && desc.kind === 'function') {
mod[desc.name] = () => { throw new Error('wasm aborted'); };
}
// _emscripten_resize_heap
if (desc.name === 'b' && desc.kind === 'function') {
mod[desc.name] = (_size: number) => 0; // refuse growth in worklet
}
}
void imports; // silence unused
const wasmInst = await WebAssembly.instantiate(compiled, reshaped);
// Many Emscripten exports use single-letter mangled names. Discover
// by reading the export descriptors.
const exDesc = WebAssembly.Module.exports(compiled);
const exMap = new Map<string, string>(); // logical name → mangled
for (const e of exDesc) {
// The exports list includes both the original (with leading
// underscore for C funcs) and the mangled single-letter alias used
// in the import section. We only see the export side here, but
// Emscripten in modern versions also re-exports the C names with
// their leading-underscore form. Walk both.
exMap.set(e.name, e.name);
}
const exports = wasmInst.exports as Record<string, WebAssembly.ExportValue>;
function pickFn(...names: string[]): (...args: number[]) => number {
for (const n of names) {
const v = exports[n];
if (typeof v === 'function') return v as unknown as (...a: number[]) => number;
}
throw new Error(`worklet: missing wasm export, tried: ${names.join(', ')}`);
}
function pickFnVoid(...names: string[]): (...args: number[]) => void {
return pickFn(...names) as unknown as (...args: number[]) => void;
}
// The exports we need.
const malloc = pickFn('_malloc', 'malloc');
const free = pickFnVoid('_free', 'free');
const ec = pickFn('_nisps_engine_create');
const ed = pickFnVoid('_nisps_engine_destroy');
const esp = pickFnVoid('_nisps_engine_set_params');
const epb = pickFnVoid('_nisps_engine_process_block');
// The wasm-exported memory might be named `memory` or another mangled
// alias. Find it.
let wasmMemory: WebAssembly.Memory | null = null;
for (const e of exDesc) {
if (e.kind === 'memory') {
const v = exports[e.name];
if (v instanceof WebAssembly.Memory) { wasmMemory = v; break; }
}
}
// If the module imports memory (which our build does — we passed it),
// there will be no exported memory; use the imported one.
this.mem = wasmMemory ?? memory;
this.instance = {
exports: {
memory: this.mem,
malloc,
free,
_nisps_engine_create: (id, sr) => ec(id, sr),
_nisps_engine_destroy: (h) => ed(h),
_nisps_engine_set_params: (h, p, n) => esp(h, p, n),
_nisps_engine_process_block: (h, il, ir, ol, or_, n) => epb(h, il, ir, ol, or_, n),
},
};
// Allocate buffers.
this.inLPtr = malloc(PROC_BLOCK * 4);
this.inRPtr = malloc(PROC_BLOCK * 4);
this.outLPtr = malloc(PROC_BLOCK * 4);
this.outRPtr = malloc(PROC_BLOCK * 4);
this.paramsPtr = malloc(MAX_PARAMS * 4);
// Engine ids are short ASCII; 32 bytes covers everything we have.
this.idPtr = malloc(32);
const buf = this.mem.buffer;
this.inLView = new Float32Array(buf, this.inLPtr, PROC_BLOCK);
this.inRView = new Float32Array(buf, this.inRPtr, PROC_BLOCK);
this.outLView = new Float32Array(buf, this.outLPtr, PROC_BLOCK);
this.outRView = new Float32Array(buf, this.outRPtr, PROC_BLOCK);
this.paramsView = new Float32Array(buf, this.paramsPtr, MAX_PARAMS);
this.idView = new Uint8Array(buf, this.idPtr, 32);
// Default engine: thru.
this.spawnEngine_('thru', sampleRate);
// Apply pending params if any arrived before init completed.
if (this.pendingParams) {
this.applyParams_(this.pendingParams);
this.pendingParams = null;
}
this.muted = false;
}
private spawnEngine_(id: EngineId, sampleRate: number): void {
if (!this.instance || !this.idView) return;
if (this.engineHandle) {
this.instance.exports._nisps_engine_destroy(this.engineHandle);
this.engineHandle = 0;
}
// Write engine_id as ASCII into idView, NUL-terminated.
const enc = new TextEncoder();
const bytes = enc.encode(id);
this.idView.fill(0);
this.idView.set(bytes.subarray(0, Math.min(bytes.length, 31)));
this.engineHandle = this.instance.exports._nisps_engine_create(this.idPtr, sampleRate);
this.engineId = id;
}
private switchEngine_(id: EngineId): void {
// sampleRate global from AudioWorkletGlobalScope.
this.spawnEngine_(id, sampleRate);
}
private applyParams_(params: Float32Array): void {
if (!this.instance || !this.paramsView) {
this.pendingParams = params;
return;
}
const n = Math.min(params.length, MAX_PARAMS);
for (let i = 0; i < n; ++i) this.paramsView[i] = params[i];
if (this.engineHandle) {
this.instance.exports._nisps_engine_set_params(this.engineHandle, this.paramsPtr, n);
}
}
override process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
const out = outputs[0];
if (!out || out.length === 0) return true;
const outL = out[0];
const outR = out.length > 1 ? out[1] : out[0];
if (this.muted || !this.instance || !this.engineHandle ||
!this.inLView || !this.outLView || !this.outRView || !this.inRView) {
// Silence.
outL.fill(0);
if (out.length > 1) outR.fill(0);
return true;
}
// Copy inputs into wasm buffers (zero-fill if missing).
const inp = inputs[0];
if (inp && inp[0]) this.inLView.set(inp[0].subarray(0, PROC_BLOCK));
else this.inLView.fill(0);
if (inp && inp[1]) this.inRView.set(inp[1].subarray(0, PROC_BLOCK));
else if (inp && inp[0]) this.inRView.set(inp[0].subarray(0, PROC_BLOCK));
else this.inRView.fill(0);
this.instance.exports._nisps_engine_process_block(
this.engineHandle,
this.inLPtr, this.inRPtr,
this.outLPtr, this.outRPtr,
PROC_BLOCK,
);
outL.set(this.outLView.subarray(0, outL.length));
if (out.length > 1) outR.set(this.outRView.subarray(0, outR.length));
return true;
}
}
registerProcessor('nisps-processor', NispsProcessor);

View file

@ -1,516 +0,0 @@
/**
* Debug probe: window.__nisps
*
* Synchronous-or-immediate Promise API for Playwright tests and dev console
* use. Bypasses SolidJS reactivity by reading/writing the underlying stores
* and WASM directly values propagate to the UI on the next reactive tick,
* but the probe always returns the freshest state.
*
* Stream 10 expanded the surface to cover stream 8/9 features:
* - Snapshot stack (push, pop, list)
* - A/B compare (capture, toggle, accept, revert)
* - Region pinning (add, remove, clear)
* - Auto-explore (toggle, interval, intensity)
* - Heatmap (refresh, color mode, getCells)
* - Weight health (histogram, status, layer stats)
* - Session presets (save, load, list, share URL)
* - Override application (read, write, clear)
*
* Test contract: every method either returns a value, returns null/empty,
* or returns an immediately-resolved Promise. None of them throw bad
* input is silently ignored.
*/
import { batch, untrack } from 'solid-js';
import { mlStore } from '../stores/ml-store';
import { modeStore } from '../stores/mode-store';
import { sessionStore } from '../stores/session-store';
import { explorationStore } from '../stores/exploration-store';
import { controlStore } from '../stores/control-store';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { coreBus } from '../stores/bus';
import {
autoSnapshot,
undoLastSnapshot,
captureA as snapCaptureA,
toggleAB as snapToggleAB,
acceptB as snapAcceptB,
revertToA as snapRevertToA,
} from '../features/snapshots';
import { HeatmapSampler } from '../features/heatmap-sampler';
import {
weightHistogram,
weightStatus as healthStatus,
layerNorms,
gradientStatuses,
layerWeightCounts,
} from '../features/weight-health';
import {
captureSessionPreset,
restoreSessionPreset,
saveNamedPreset,
loadNamedPreset,
buildShareUrl,
applyUrlParams,
type SessionPresetPayload,
} from '../features/session-preset';
import type { HeatmapColorMode } from '../features/heatmap-sampler';
export interface DebugProbe {
/** Current 126-element output vector (Float32Array). */
getOutputs(): Float32Array;
/** Current per-param post-override values (length = active mode params). */
getParamOutputs(): Float32Array;
/** Last training loss, or null if no training has occurred. */
getLoss(): number | null;
/** Loss history of last training run. */
getLossHistory(): ReadonlyArray<number>;
/** Flat weight array. */
getWeights(): Float32Array;
/** Number of training examples currently in the dataset. */
getExampleCount(): number;
/** Set joystick X/Y in [0,1] and run inference. */
setInputs(x: number, y: number): void;
/** Trigger thumbs-up RL feedback. */
thumbsUp(): void;
/** Trigger thumbs-down RL feedback. */
thumbsDown(): void;
/** Synchronous training; returns final loss. */
train(): number;
/** Async training; returns Promise<loss>. */
trainAsync(): Promise<number>;
/** Randomize weights with default spread. */
randomise(): void;
/** Clear all training examples. */
clearExamples(): void;
/** Force a save to localStorage now (no debounce). */
saveState(): void;
/** Non-destructive loss query against current dataset. */
evalLoss(): number | null;
/** Batch inference: input is Nx2 array of [x,y] pairs. */
inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array;
/** Per-layer weight statistics: layerCount * 4 floats (mean|w|, max|w|, dead%, sat%). */
getLayerStats(): Float32Array;
// Snapshot / undo / A/B
pushSnapshot(tag: string): void;
popSnapshot(): boolean;
listSnapshots(): ReadonlyArray<{ id: string; tag: string; timestamp: number; noiseLevel: number }>;
clearSnapshots(): void;
captureA(): void;
toggleAB(): 'A' | 'B';
acceptB(): void;
revertToA(): void;
// Pins
addRegionPin(pin: { x: number; y: number; width: number; height: number }): string;
removeRegionPin(id: string): void;
toggleParamPin(modeId: string, paramName: string, outputIndex: number): boolean;
isParamPinned(modeId: string, paramName: string): boolean;
// Auto-explore
setAutoExplore(enabled: boolean): void;
setAutoExploreInterval(ms: number): void;
setAutoExploreIntensity(v: number): void;
// Pressure
setPressure(force: number, holdMs?: number): void;
// Heatmap
refreshHeatmap(force?: boolean): boolean;
getHeatmapCells(): Float32Array;
setHeatmapColorMode(mode: HeatmapColorMode): void;
// Weight health
getWeightHistogram(): number[];
getWeightStatus(): 'dead' | 'saturating' | 'healthy';
getGradientFlow(beforeWeights: Float32Array, afterWeights: Float32Array): { norms: number[]; status: ReturnType<typeof gradientStatuses> };
// Overrides
setOverride(modeId: string, paramName: string, override: Partial<{ min: number; max: number; curve: string; muted: boolean; pinned: boolean; frozen: boolean; fixedValue: number }>): void;
clearOverride(modeId: string, paramName: string): void;
clearAllOverrides(modeId: string): void;
getOverride(modeId: string, paramName: string): unknown;
// Compound axes
setAxis(axis: 'boldness' | 'memory' | 'precision', value: number): void;
applyControlPreset(id: string): boolean;
// Pipelines
setZoom(zoom: number): void;
setSpread(spread: number): void;
setOutputFreeze(frozen: boolean): void;
// Session presets
captureSessionPreset(withWeights?: boolean): SessionPresetPayload;
restoreSessionPreset(payload: SessionPresetPayload): boolean;
saveSessionPreset(name: string, withWeights?: boolean): void;
loadSessionPreset(id: string): boolean;
buildShareUrl(): string;
applyUrlParams(search?: string): boolean;
// Bus
emit(topic: string, data: unknown): void;
on(topic: string, handler: (data: unknown) => void): () => void;
/** True once the WASM is fully initialised. */
readonly __ready: boolean;
/** Force initialisation. Returns a promise that resolves when the WASM is ready. */
__init(): Promise<void>;
}
declare global {
interface Window {
__nisps?: DebugProbe;
}
}
const EMPTY_F32 = new Float32Array(0);
let lazyInitPromise: Promise<void> | null = null;
function lazyInit(): Promise<void> {
if (mlStore.iml) return Promise.resolve();
if (!lazyInitPromise) {
lazyInitPromise = mlStore.initialize().then(() => undefined);
}
return lazyInitPromise;
}
// Probe-local heatmap sampler (independent of any active mode runtime).
const probeHeatmap = new HeatmapSampler({ resolution: 16 });
const probe: DebugProbe = {
get __ready(): boolean {
return !!mlStore.iml && mlStore.state.ready;
},
__init(): Promise<void> {
return lazyInit();
},
getOutputs(): Float32Array {
return mlStore.outputs();
},
getParamOutputs(): Float32Array {
// The probe doesn't have a runtime reference; recompute from raw outputs.
const raw = mlStore.outputs();
if (raw.length === 0) return EMPTY_F32;
// Just return the raw outputs — actual override application requires
// schema knowledge that the probe doesn't track. Tests can read
// overrides via `getOverride` instead.
return raw;
},
getLoss(): number | null {
return mlStore.state.lastLoss;
},
getLossHistory(): ReadonlyArray<number> {
return mlStore.state.lossHistory;
},
getWeights(): Float32Array {
return mlStore.getWeights();
},
getExampleCount(): number {
return mlStore.state.exampleCount;
},
setInputs(x: number, y: number): void {
if (!mlStore.iml) {
void lazyInit();
return;
}
untrack(() => mlStore.iml!.inferXY(x, y));
},
thumbsUp(): void {
if (!mlStore.iml) return;
untrack(() => {
autoSnapshot('before thumbs-up (probe)');
mlStore.iml!.train(explorationStore.state.learningRate);
explorationStore.decayNoise(0.5);
});
},
thumbsDown(): void {
if (!mlStore.iml) return;
untrack(() => {
autoSnapshot('before thumbs-down (probe)');
const cap = explorationStore.state.noiseCap;
const spread = explorationStore.state.spread;
mlStore.iml!.moveWeights(cap, spread);
explorationStore.growNoise(0.5);
});
},
train(): number {
if (!mlStore.iml) {
void lazyInit();
return 0;
}
return untrack(() => {
autoSnapshot('before train (probe)');
return mlStore.iml!.train(explorationStore.state.learningRate);
});
},
async trainAsync(): Promise<number> {
await lazyInit();
if (!mlStore.iml) return 0;
autoSnapshot('before trainAsync (probe)');
return mlStore.iml.trainAsync(explorationStore.state.learningRate);
},
randomise(): void {
if (!mlStore.iml) return;
untrack(() => {
autoSnapshot('before randomize (probe)');
mlStore.iml!.randomiseWeights(explorationStore.state.spread);
});
},
clearExamples(): void {
mlStore.clearExamples();
},
saveState(): void {
mlStore.saveNow();
},
evalLoss(): number | null {
if (!mlStore.iml) return null;
return mlStore.iml.evalLoss();
},
inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array {
if (!mlStore.iml) return new Float32Array(points.length * mlStore.state.outputSize);
return mlStore.iml.inferBatch(points);
},
getLayerStats(): Float32Array {
if (!mlStore.iml) return EMPTY_F32;
return mlStore.iml.getLayerStatsFlat();
},
// ----- Snapshot / undo / A/B ---------------------------------------------
pushSnapshot(tag: string): void {
autoSnapshot(tag);
},
popSnapshot(): boolean {
return undoLastSnapshot();
},
listSnapshots() {
return sessionStore.listSnapshots().map((s) => ({
id: s.id,
tag: s.tag,
timestamp: s.timestamp,
noiseLevel: s.noiseLevel,
}));
},
clearSnapshots(): void {
sessionStore.clearSnapshots();
},
captureA(): void {
snapCaptureA();
},
toggleAB(): 'A' | 'B' {
return snapToggleAB();
},
acceptB(): void {
snapAcceptB();
},
revertToA(): void {
snapRevertToA();
},
// ----- Pins ---------------------------------------------------------------
addRegionPin(pin) {
const created = sessionStore.addRegionPin(pin);
return created.id;
},
removeRegionPin(id: string): void {
sessionStore.removeRegionPin(id);
},
toggleParamPin(modeId: string, paramName: string, outputIndex: number): boolean {
return sessionStore.toggleParamPin(`${modeId}:${paramName}`, outputIndex);
},
isParamPinned(modeId: string, paramName: string): boolean {
return sessionStore.isParamPinned(`${modeId}:${paramName}`);
},
// ----- Auto-explore -------------------------------------------------------
setAutoExplore(enabled: boolean): void {
explorationStore.setAutoExplore(enabled);
},
setAutoExploreInterval(ms: number): void {
explorationStore.setAutoExploreInterval(ms);
},
setAutoExploreIntensity(v: number): void {
explorationStore.setAutoExploreIntensity(v);
},
setPressure(force: number, holdMs: number = 0): void {
explorationStore.setPressure(force, holdMs);
},
// ----- Heatmap ------------------------------------------------------------
refreshHeatmap(force = false): boolean {
if (!mlStore.iml) return false;
const cfg = inputStore.config;
const z = cfg.zoom;
const cx = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorX;
const cy = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorY;
return probeHeatmap.update({ cx, cy, zoom: z }, force);
},
getHeatmapCells(): Float32Array {
return probeHeatmap.getCells();
},
setHeatmapColorMode(mode: HeatmapColorMode): void {
probeHeatmap.setColorMode(mode);
},
// ----- Weight health ------------------------------------------------------
getWeightHistogram(): number[] {
return weightHistogram(mlStore.weights());
},
getWeightStatus() {
return healthStatus(mlStore.weights());
},
getGradientFlow(beforeWeights: Float32Array, afterWeights: Float32Array) {
if (!mlStore.iml) return { norms: [], status: [] };
const arch = mlStore.iml.architecture;
const sizes = layerWeightCounts({
inputSize: arch.inputSize,
hidden: arch.hidden,
outputSize: arch.outputSize,
});
const norms = layerNorms(beforeWeights, afterWeights, sizes);
return { norms, status: gradientStatuses(norms) };
},
// ----- Overrides ----------------------------------------------------------
setOverride(modeId: string, paramName: string, patch): void {
const existing = modeStore.getOverride(modeId, paramName);
const next = {
min: existing?.min ?? 0,
max: existing?.max ?? 1,
curve: existing?.curve ?? 'linear',
curveParam: existing?.curveParam,
muted: existing?.muted ?? false,
pinned: existing?.pinned ?? false,
frozen: existing?.frozen ?? false,
fixedValue: existing?.fixedValue ?? 0.5,
...patch,
} as Parameters<typeof modeStore.setOverride>[2];
modeStore.setOverride(modeId, paramName, next);
},
clearOverride(modeId: string, paramName: string): void {
modeStore.clearOverride(modeId, paramName);
},
clearAllOverrides(modeId: string): void {
modeStore.clearAllOverrides(modeId);
},
getOverride(modeId: string, paramName: string) {
return modeStore.getOverride(modeId, paramName);
},
// ----- Compound axes ------------------------------------------------------
setAxis(axis: 'boldness' | 'memory' | 'precision', value: number): void {
controlStore.setAxis(axis, value);
},
applyControlPreset(id: string): boolean {
return controlStore.applyPreset(id);
},
// ----- Pipelines ----------------------------------------------------------
setZoom(zoom: number): void {
inputStore.setZoom(zoom);
},
setSpread(spread: number): void {
explorationStore.setSpread(spread);
},
setOutputFreeze(frozen: boolean): void {
outputStore.setFreezeOutput(frozen);
},
// ----- Session presets ----------------------------------------------------
captureSessionPreset(withWeights = false): SessionPresetPayload {
return captureSessionPreset(withWeights);
},
restoreSessionPreset(payload: SessionPresetPayload): boolean {
return batch(() => restoreSessionPreset(payload));
},
saveSessionPreset(name: string, withWeights = false): void {
saveNamedPreset(name, withWeights);
},
loadSessionPreset(id: string): boolean {
return batch(() => loadNamedPreset(id));
},
buildShareUrl(): string {
return buildShareUrl();
},
applyUrlParams(search?: string): boolean {
return batch(() => applyUrlParams(search));
},
// ----- Bus ----------------------------------------------------------------
emit(topic: string, data: unknown): void {
// Lossy cast — the probe is intentionally weakly typed.
coreBus.emit(topic as never, data as never);
},
on(topic: string, handler): () => void {
return coreBus.onPrefix(topic, handler);
},
};
/**
* Install the probe on window. Idempotent the probe object is a
* singleton, so capturing `window.__nisps` once is safe across hot
* reloads and re-installs.
*/
export function installDebugProbe(): void {
if (typeof window === 'undefined') return;
window.__nisps = probe;
}

View file

@ -1,70 +0,0 @@
.root {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
.header {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.header h1 {
margin: 0;
font-size: var(--fs-xl);
color: var(--accent);
}
.lede {
color: var(--fg-mute);
margin: 0;
max-width: 720px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: var(--sp-4);
}
.card {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-3);
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-3);
min-height: 280px;
}
.cardHead {
display: flex;
flex-direction: column;
gap: var(--sp-1);
border-bottom: 1px solid var(--line);
padding-bottom: var(--sp-2);
}
.cardTitle {
margin: 0;
font-size: var(--fs-md);
color: var(--fg);
}
.cardDesc {
margin: 0;
font-size: var(--fs-xs);
color: var(--fg-mute);
}
.cardBody {
display: flex;
flex-direction: column;
align-items: stretch;
flex: 1;
justify-content: flex-start;
}

View file

@ -1,80 +0,0 @@
/**
* Primitives showcase Storybook-equivalent at /dev/primitives.
*
* Each section wraps one primitive's demo. Demos use realistic interactive
* state so a quick visual scan answers "does this thing actually work?".
*/
import { Component, For, JSX } from 'solid-js';
import styles from './PrimitivesShowcase.module.css';
import { SliderDemo } from '../primitives/Slider.demo';
import { SliderBankDemo } from '../primitives/SliderBank.demo';
import { VirtualJoystickDemo } from '../primitives/VirtualJoystick.demo';
import { XYPadDemo } from '../primitives/XYPad.demo';
import { HeatmapDemo } from '../primitives/Heatmap.demo';
import { OutputDisplayDemo } from '../primitives/OutputDisplay.demo';
import { TrainingControlsDemo } from '../primitives/TrainingControls.demo';
import { DrawerDemo } from '../primitives/Drawer.demo';
import { ControlAxisDemo } from '../primitives/ControlAxis.demo';
import { ProgressRingDemo } from '../primitives/ProgressRing.demo';
import { PillToggleDemo } from '../primitives/PillToggle.demo';
import { ParamEditorDemo } from '../primitives/ParamEditor.demo';
import { JoyMapDemo } from '../primitives/JoyMap.demo';
import { WeightHealthDemo } from '../primitives/WeightHealth.demo';
import { GradientFlowDemo } from '../primitives/GradientFlow.demo';
import { LossPlotDemo } from '../primitives/LossPlot.demo';
interface Section {
name: string;
description: string;
Demo: Component;
}
const SECTIONS: ReadonlyArray<Section> = [
{ name: 'Slider', description: 'Single-value slider with optional curve mapping.', Demo: SliderDemo },
{ name: 'SliderBank', description: 'Vertical stack with collapsible sections.', Demo: SliderBankDemo },
{ name: 'VirtualJoystick', description: 'Touch + pointer 2D input, returns [0,1]^2.', Demo: VirtualJoystickDemo },
{ name: 'XYPad', description: 'Rectangular 2D input pad.', Demo: XYPadDemo },
{ name: 'Heatmap', description: 'NxN colored grid, three color modes.', Demo: HeatmapDemo },
{ name: 'OutputDisplay', description: 'Bar chart of N output values.', Demo: OutputDisplayDemo },
{ name: 'TrainingControls', description: 'RL feedback + status panel.', Demo: TrainingControlsDemo },
{ name: 'Drawer', description: 'Slide-in panel from left/right.', Demo: DrawerDemo },
{ name: 'ControlAxis', description: 'Compound axis slider (Boldness/Memory/Precision shape).', Demo: ControlAxisDemo },
{ name: 'ProgressRing', description: 'Circular progress indicator.', Demo: ProgressRingDemo },
{ name: 'PillToggle', description: 'Segmented control.', Demo: PillToggleDemo },
{ name: 'ParamEditor', description: 'Range/curve/mute/pin for one parameter.', Demo: ParamEditorDemo },
{ name: 'JoyMap', description: 'Zoom minimap with trail, noise rings, region pins.', Demo: JoyMapDemo },
{ name: 'WeightHealth', description: 'Histogram with status glow.', Demo: WeightHealthDemo },
{ name: 'GradientFlow', description: 'Per-layer gradient bars.', Demo: GradientFlowDemo },
{ name: 'LossPlot', description: 'Training-loss line chart over time.', Demo: LossPlotDemo },
];
const PrimitivesShowcase: Component = () => {
return (
<div class={styles.root}>
<header class={styles.header}>
<h1>Primitive Library</h1>
<p class={styles.lede}>
Each card is a live demo. Use this page as a sanity-check during the rewrite.
Modes (stream 9) and feature parity (stream 10) compose these primitives.
</p>
</header>
<div class={styles.grid}>
<For each={SECTIONS}>{(section) => (
<article class={styles.card}>
<header class={styles.cardHead}>
<h2 class={styles.cardTitle}>{section.name}</h2>
<p class={styles.cardDesc}>{section.description}</p>
</header>
<div class={styles.cardBody}>
{section.Demo({}) as unknown as JSX.Element}
</div>
</article>
)}</For>
</div>
</div>
);
};
export default PrimitivesShowcase;

View file

@ -1,121 +0,0 @@
/**
* Tiny smoke check that exercises the debug probe API. Run from the dev
* console after the page loads:
*
* import('./dev/probe-smoke.ts').then((m) => m.smokeCheck());
*
* Returns a list of pass/fail results. Useful for manual verification when
* Playwright isn't available.
*
* This file is NOT bundled by default Vite only includes referenced
* modules and `dev/probe-smoke.ts` is opt-in.
*/
export interface SmokeResult {
name: string;
ok: boolean;
detail?: string;
}
export async function smokeCheck(): Promise<SmokeResult[]> {
const results: SmokeResult[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const probe = (window as unknown as { __nisps?: any }).__nisps;
if (!probe) {
results.push({ name: 'probe installed', ok: false, detail: 'window.__nisps missing' });
return results;
}
results.push({ name: 'probe installed', ok: true });
try {
await probe.__init();
results.push({ name: '__init', ok: true });
} catch (err) {
results.push({ name: '__init', ok: false, detail: String(err) });
}
try {
probe.setInputs(0.5, 0.5);
const outs = probe.getOutputs();
results.push({
name: 'setInputs(0.5, 0.5)',
ok: outs.length > 0,
detail: `outputs.length = ${outs.length}`,
});
} catch (err) {
results.push({ name: 'setInputs', ok: false, detail: String(err) });
}
try {
probe.thumbsUp();
results.push({ name: 'thumbsUp', ok: true });
} catch (err) {
results.push({ name: 'thumbsUp', ok: false, detail: String(err) });
}
try {
probe.thumbsDown();
results.push({ name: 'thumbsDown', ok: true });
} catch (err) {
results.push({ name: 'thumbsDown', ok: false, detail: String(err) });
}
try {
const before = probe.getWeights();
probe.pushSnapshot('smoke');
probe.randomise();
probe.popSnapshot();
const after = probe.getWeights();
const restored = before.length > 0 && after.length === before.length;
results.push({ name: 'snapshot push/pop', ok: restored });
} catch (err) {
results.push({ name: 'snapshot push/pop', ok: false, detail: String(err) });
}
try {
probe.setAxis('boldness', 0.7);
probe.setAxis('memory', 0.3);
probe.setAxis('precision', 0.5);
results.push({ name: 'compound axes', ok: true });
} catch (err) {
results.push({ name: 'compound axes', ok: false, detail: String(err) });
}
try {
const ok = probe.refreshHeatmap(true);
const cells = probe.getHeatmapCells();
results.push({
name: 'heatmap',
ok: cells.length > 0,
detail: `took=${ok} cells=${cells.length}`,
});
} catch (err) {
results.push({ name: 'heatmap', ok: false, detail: String(err) });
}
try {
const hist = probe.getWeightHistogram();
const status = probe.getWeightStatus();
results.push({
name: 'weight health',
ok: hist.length === 10,
detail: `status=${status} bins=${hist.length}`,
});
} catch (err) {
results.push({ name: 'weight health', ok: false, detail: String(err) });
}
try {
const url = probe.buildShareUrl();
const ok = typeof url === 'string' && url.startsWith('?session=');
results.push({ name: 'buildShareUrl', ok, detail: url.slice(0, 60) });
} catch (err) {
results.push({ name: 'buildShareUrl', ok: false, detail: String(err) });
}
return results;
}
if (typeof window !== 'undefined') {
(window as unknown as { smokeCheck?: typeof smokeCheck }).smokeCheck = smokeCheck;
}

View file

@ -1,77 +0,0 @@
/**
* Compound-axis store routing.
*
* Converts the resolved parameter map from `controlStore.resolveParams()`
* into concrete writes against:
* - `inputStore` (zoom, deadzone, inputCurve, smoothing, momentumZoom)
* - `outputStore` (slewRate)
* - `explorationStore` (noiseCap, noiseGrowth, noiseDecay, learningRate, weightDecay)
*
* Called as a Solid `createEffect` from the mode runtime; whenever any axis
* changes, this re-applies. Per-store setters internally clamp the values.
*
* Note: `inputCurve` and `slewRate` from the Precision axis intentionally
* use the centered_power exponent and slew-rate-per-frame contracts; the
* resolver mirrors the legacy table values.
*/
import { controlStore } from '../stores/control-store';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { explorationStore } from '../stores/exploration-store';
import type { MomentumZoomMode } from '../input/pipeline';
let lastApplied = '';
/**
* Apply the resolved compound-axis params. Returns true if any store was
* modified.
*/
export function applyControlRouting(): boolean {
const params = controlStore.resolveParams();
// Coalesce: only re-apply if any value changed.
const sig = JSON.stringify({
z: params['zoom'],
nc: params['noiseCap'],
ng: params['noiseGrowth'],
nd: params['noiseDecay'],
lr: params['learningRate'],
wd: params['weightDecay'],
ic: params['inputCurve'],
dz: params['deadzone'],
sm: params['smoothing'],
sr: params['slewRate'],
mz: params['momentumZoom'],
});
if (sig === lastApplied) return false;
lastApplied = sig;
const num = (k: string): number | undefined =>
typeof params[k] === 'number' ? (params[k] as number) : undefined;
// Input pipeline
const z = num('zoom');
if (z !== undefined) inputStore.setZoom(z);
const dz = num('deadzone');
if (dz !== undefined) inputStore.setDeadzone(dz);
const ic = num('inputCurve');
if (ic !== undefined) inputStore.setInputCurve(ic);
const ism = num('smoothing');
if (ism !== undefined) inputStore.setSmoothing(ism);
const mz = params['momentumZoom'];
if (typeof mz === 'string') {
const valid: MomentumZoomMode[] = ['off', 'gentle', 'strong'];
if (valid.includes(mz as MomentumZoomMode)) {
inputStore.setMomentumZoom(mz as MomentumZoomMode);
}
}
// Output pipeline
const sr = num('slewRate');
if (sr !== undefined) outputStore.setSlewRate(sr);
// Exploration / RL
explorationStore.applyCompoundParams(params);
return true;
}

View file

@ -1,160 +0,0 @@
/**
* Heatmap sampler samples MLP across a 2D input window, reduces outputs
* to a scalar per cell.
*
* Three color modes:
* - 'luminance' mean output magnitude
* - 'variance' output variance
* - 'divergence' distance from a reference (center) output
*
* Sampler is throttled: `update` no-ops if called more than once per
* `MIN_INTERVAL_MS`. Caller drives updates via `coreBus.on('ml.delta_update')`
* and `coreBus.on('ml.trained')` events plus the input `zoom` accessor.
*
* The sampler returns a `Float32Array` of length `resolution * resolution`
* directly consumable by the `Heatmap` primitive. Stale grid values stay
* around between updates so the canvas doesn't blank.
*/
import { mlStore } from '../stores/ml-store';
export type HeatmapColorMode = 'luminance' | 'variance' | 'divergence';
const MIN_INTERVAL_MS = 200; // 5 updates / sec
export interface HeatmapSamplerOptions {
/** Grid size N×N. Default 16. */
resolution?: number;
/** Initial color mode. */
colorMode?: HeatmapColorMode;
}
export interface HeatmapWindow {
/** Anchor point in input space [0,1]. */
cx: number;
cy: number;
/** Side length of the sampled window (zoom level). */
zoom: number;
}
export class HeatmapSampler {
resolution: number;
colorMode: HeatmapColorMode;
private cells: Float32Array;
private lastSampleMs = 0;
private pending = false;
constructor(opts: HeatmapSamplerOptions = {}) {
this.resolution = Math.max(2, Math.min(64, opts.resolution ?? 16));
this.colorMode = opts.colorMode ?? 'luminance';
this.cells = new Float32Array(this.resolution * this.resolution);
}
/** Current grid for binding to the Heatmap primitive. */
getCells(): Float32Array {
return this.cells;
}
setColorMode(mode: HeatmapColorMode): void {
this.colorMode = mode;
}
setResolution(n: number): void {
const r = Math.max(2, Math.min(64, n | 0));
if (r === this.resolution) return;
this.resolution = r;
this.cells = new Float32Array(r * r);
}
/**
* Refresh the grid. Throttled to MIN_INTERVAL_MS.
*
* Returns `true` if a fresh sample was taken, `false` if throttled.
* Reads MLP via `mlStore.inferBatch` (no-op if WASM not ready).
*/
update(window: HeatmapWindow, force: boolean = false): boolean {
const now = performance.now();
if (!force && now - this.lastSampleMs < MIN_INTERVAL_MS) {
this.pending = true;
return false;
}
this.lastSampleMs = now;
this.pending = false;
const N = this.resolution;
const total = N * N;
const points: Array<readonly [number, number]> = new Array(total);
const z = Math.max(0.01, Math.min(1, window.zoom));
const half = z * 0.5;
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const u = N > 1 ? x / (N - 1) : 0.5;
const v = N > 1 ? y / (N - 1) : 0.5;
const px = window.cx + (u - 0.5) * z; // could go negative; the sampler clamps
const py = window.cy + (v - 0.5) * z;
points[y * N + x] = [
Math.max(0, Math.min(1, px)),
Math.max(0, Math.min(1, py)),
];
// Note: we'd love to compute half-based bounds but the existing input
// pipeline already clamps, so just clamp here.
void half;
}
}
const flat = mlStore.inferBatch(points);
if (flat.length === 0) {
// WASM not ready — cells remain at last value.
return false;
}
const outSize = mlStore.state.outputSize;
// Reduce per cell.
let referenceMean = 0;
if (this.colorMode === 'divergence') {
// Reference = center cell (closest to N/2, N/2).
const cy = Math.floor(N / 2);
const cx = Math.floor(N / 2);
const base = (cy * N + cx) * outSize;
let sum = 0;
for (let i = 0; i < outSize; i++) sum += flat[base + i] ?? 0;
referenceMean = sum / outSize;
}
for (let i = 0; i < total; i++) {
const base = i * outSize;
let sum = 0;
let sumSq = 0;
for (let j = 0; j < outSize; j++) {
const v = flat[base + j] ?? 0;
sum += v;
sumSq += v * v;
}
const mean = sum / outSize;
const variance = Math.max(0, sumSq / outSize - mean * mean);
switch (this.colorMode) {
case 'luminance':
this.cells[i] = mean;
break;
case 'variance':
this.cells[i] = variance;
break;
case 'divergence':
this.cells[i] = Math.abs(mean - referenceMean);
break;
}
}
return true;
}
/** True if a call is queued behind throttling. */
hasPendingUpdate(): boolean {
return this.pending;
}
reset(): void {
this.cells.fill(0);
this.lastSampleMs = 0;
}
}

View file

@ -1,138 +0,0 @@
/**
* Microphone input captures user audio and emits audio analysis features
* for modes that take audio_in as primary input (XIASRI, SoundAnalysisMIDI).
*
* Uses an `AudioWorkletNode` for the analysis. Keeps things simple by
* computing four lightweight features in the main thread via AnalyserNode:
*
* - energy: RMS amplitude (0..1)
* - brightness: spectral centroid normalised to 0..1
* - pitch: dominant peak frequency normalised log scale
* - aperiodicity: 1 - peak_strength (0..1)
*
* Stream 10 doesn't ship a fully featured DSP analyser this is a
* pragmatic pipeline that gets values into the MLP. Firmware-equivalent
* XIASRI analysis is a deeper port (out of scope here).
*
* Usage: call `start()` from a user gesture; subscribe via `getFeatures()`
* (Float32Array of length 4). `stop()` releases the mic.
*/
const MIN_FREQ = 80;
const MAX_FREQ = 1200;
const FFT_SIZE = 1024;
export interface MicFeatures {
energy: number;
brightness: number;
pitch: number;
aperiodicity: number;
}
export interface MicInputOptions {
/** Optional shared AudioContext. If absent, creates one. */
audioContext?: AudioContext;
}
export class MicInput {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private analyser: AnalyserNode | null = null;
private freqBuf: Float32Array<ArrayBuffer> = new Float32Array(new ArrayBuffer(FFT_SIZE / 2 * 4));
private timeBuf: Float32Array<ArrayBuffer> = new Float32Array(new ArrayBuffer(FFT_SIZE * 4));
private features: MicFeatures = { energy: 0, brightness: 0, pitch: 0, aperiodicity: 1 };
private running = false;
constructor(private opts: MicInputOptions = {}) {}
/** Start mic capture. Resolves once audio is flowing. */
async start(): Promise<void> {
if (this.running) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
throw new Error('[mic-input] navigator.mediaDevices unavailable');
}
this.ctx = this.opts.audioContext ?? new AudioContext();
if (this.ctx.state === 'suspended') await this.ctx.resume();
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const src = this.ctx.createMediaStreamSource(this.stream);
this.analyser = this.ctx.createAnalyser();
this.analyser.fftSize = FFT_SIZE;
this.analyser.smoothingTimeConstant = 0.6;
src.connect(this.analyser);
this.running = true;
}
async stop(): Promise<void> {
if (!this.running) return;
this.running = false;
if (this.stream) {
for (const t of this.stream.getTracks()) t.stop();
this.stream = null;
}
if (this.ctx && !this.opts.audioContext) {
try { await this.ctx.close(); } catch { /* ignore */ }
}
this.ctx = null;
this.analyser = null;
}
isRunning(): boolean {
return this.running;
}
/** Compute and return the latest feature vector. */
getFeatures(): MicFeatures {
if (!this.running || !this.analyser || !this.ctx) return this.features;
const sr = this.ctx.sampleRate;
const a = this.analyser;
a.getFloatFrequencyData(this.freqBuf);
a.getFloatTimeDomainData(this.timeBuf);
// RMS energy
let sumSq = 0;
for (let i = 0; i < this.timeBuf.length; ++i) {
const s = this.timeBuf[i]!;
sumSq += s * s;
}
const rms = Math.sqrt(sumSq / this.timeBuf.length);
const energy = Math.min(1, rms * 4);
// Convert dB → linear, find spectral centroid + peak
let sumMag = 0;
let sumWeighted = 0;
let peakBin = 0;
let peakMag = -Infinity;
const binHz = sr / FFT_SIZE;
const minBin = Math.floor(MIN_FREQ / binHz);
const maxBin = Math.min(this.freqBuf.length - 1, Math.ceil(MAX_FREQ / binHz));
for (let i = 0; i < this.freqBuf.length; ++i) {
const db = this.freqBuf[i]!;
const lin = Math.pow(10, db / 20);
sumMag += lin;
sumWeighted += i * binHz * lin;
if (i >= minBin && i <= maxBin && db > peakMag) {
peakMag = db;
peakBin = i;
}
}
const centroidHz = sumMag > 1e-12 ? sumWeighted / sumMag : 0;
const brightness = Math.min(1, centroidHz / (sr * 0.25));
// Pitch (log scale 801200 Hz)
const pitchHz = peakBin * binHz;
const pitchNorm = pitchHz <= MIN_FREQ
? 0
: pitchHz >= MAX_FREQ
? 1
: (Math.log(pitchHz / MIN_FREQ) / Math.log(MAX_FREQ / MIN_FREQ));
// Aperiodicity: 1 - relative peak height (vs spectral mean)
const meanLin = sumMag / this.freqBuf.length;
const peakLin = Math.pow(10, peakMag / 20);
const ratio = meanLin > 1e-12 ? peakLin / meanLin : 0;
const aperiodicity = Math.max(0, Math.min(1, 1 - Math.min(1, ratio / 30)));
this.features = { energy, brightness, pitch: pitchNorm, aperiodicity };
return this.features;
}
}

View file

@ -1,131 +0,0 @@
/**
* Override application apply per-parameter overrides between MLP outputs
* and the engine's parameter destination.
*
* The MLP emits values in [0, 1]. The mode schema specifies a per-parameter
* `min`, `max`, and `curve`. Modes can layer overrides on top via
* `modeStore.setOverride` to:
* - Re-curve the value (`curve` and `curveParam`).
* - Mute it (replace with `fixedValue` [hardMin, hardMax]).
* - Freeze it (hold last processed value).
* - Tighten the active range (`min`/`max` schema bounds).
*
* `applyOverrides` walks the schema params in order, looks up overrides for
* each, and writes results into a target Float32Array of the same length as
* the MLP slice. The function is pure caller manages buffer reuse.
*
* The output buffer values are NORMALISED to [0, 1] in their effective
* range so the mode's audio engine, MIDI router, or visualiser can scale
* uniformly. Engines/MIDI consumers that need actual min/max units can use
* `paramsToSliderValues` from `mode-helpers`.
*/
import type { ParamOverride } from '../stores/mode-store';
import type { Param } from '../modes/generated/types';
import { applyCurve, type CurveName, clamp01 } from '../output/curves';
/**
* Result of applying overrides:
* - `values` is a Float32Array of length `params.length`, each in [0, 1]
* representing the post-override normalised parameter value.
* - `freezeMask` (optional) marks per-output frozen flags. Only allocated
* when at least one param is frozen saves work on the typical case.
*/
export interface OverrideResult {
values: Float32Array;
freezeMask: Uint8Array | null;
/** Number of muted/frozen entries (for telemetry). */
affectedCount: number;
}
/**
* Apply per-param overrides to the slice of MLP outputs corresponding to
* the schema's params.
*
* @param raw processed (post output-pipeline) MLP outputs in [0,1]
* @param prev previous post-override outputs (for freeze hold)
* @param params mode schema params
* @param overrides per-name override map (sparse; missing keys fall through)
* @param dest optional destination Float32Array; allocated if absent
*/
export function applyOverrides(
raw: Float32Array,
prev: Float32Array | null,
params: ReadonlyArray<Param>,
overrides: Record<string, ParamOverride>,
dest?: Float32Array,
): OverrideResult {
const n = params.length;
const out = dest && dest.length === n ? dest : new Float32Array(n);
let affected = 0;
let freezeMask: Uint8Array | null = null;
for (let i = 0; i < n; ++i) {
const p = params[i]!;
const ov = overrides[p.name];
const r = clamp01(raw[i] ?? 0);
if (!ov) {
// No override → identity (raw normalised value).
out[i] = r;
continue;
}
if (ov.frozen) {
// Freeze: hold last processed value. Track in freezeMask so the
// output pipeline can also honor it next frame.
if (!freezeMask) freezeMask = new Uint8Array(n);
freezeMask[i] = 1;
out[i] = prev?.[i] ?? r;
affected++;
continue;
}
if (ov.muted) {
// Mute: replace with fixedValue. fixedValue is stored in schema units
// [min, max] — normalise back to [0, 1] in the override's effective range.
const range = ov.max - ov.min;
const norm = range > 0 ? (ov.fixedValue - ov.min) / range : 0.5;
out[i] = clamp01(norm);
affected++;
continue;
}
// Apply curve (if any), then clamp.
let v = r;
if (ov.curve && ov.curve !== 'linear') {
v = applyCurve(ov.curve as CurveName, r, ov.curveParam);
}
out[i] = clamp01(v);
}
return { values: out, freezeMask, affectedCount: affected };
}
/**
* Build a Uint8Array of length `outputSize` marking every pinned output
* with a 1. Used by `WasmIML.moveWeights` to skip pinned weights during RL
* perturbation. Combines:
* - explicit param-pins from sessionStore
* - per-param `pinned` flag in modeStore overrides
*/
export function buildPinMask(
outputSize: number,
modeId: string,
params: ReadonlyArray<Param>,
overrides: Record<string, ParamOverride>,
paramPins: ReadonlyArray<{ key: string; outputIndex: number }>,
): Uint8Array {
const mask = new Uint8Array(outputSize);
for (let i = 0; i < params.length && i < outputSize; ++i) {
const ov = overrides[params[i]!.name];
if (ov && ov.pinned) mask[i] = 1;
}
for (const pin of paramPins) {
if (!pin.key.startsWith(modeId + ':')) continue;
if (pin.outputIndex >= 0 && pin.outputIndex < outputSize) {
mask[pin.outputIndex] = 1;
}
}
return mask;
}

View file

@ -1,288 +0,0 @@
/**
* Session preset full app-state snapshot for save/load and URL sharing.
*
* A preset captures: control axes + offsets, exploration config, output
* pipeline settings, input pipeline settings, mode override map, the
* active mode id. ML weights are NOT included by default (large), but
* `serializeWithWeights` is provided for power users.
*
* URL sharing uses a compact encoding:
* ?session=<base64url(JSON)>
* ?boldness=...&memory=...&precision=...
* Either form rehydrates on next load.
*/
import { controlStore } from '../stores/control-store';
import { explorationStore } from '../stores/exploration-store';
import { modeStore } from '../stores/mode-store';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { mlStore } from '../stores/ml-store';
import { sessionStore } from '../stores/session-store';
export interface SessionPresetPayload {
v: 1;
modeId: string | null;
control: {
boldness: number;
memory: number;
precision: number;
presetId: string | null;
offsets: typeof controlStore.state.offsets;
};
exploration: {
spread: number;
noiseFloor: number;
noiseCap: number;
noiseGrowth: number;
noiseDecay: number;
learningRate: number;
weightDecay: number;
};
output: {
globalCurve: number;
smoothing: number;
slewRate: number | null; // null encodes Infinity
freezeOutput: boolean;
};
input: {
zoom: number;
anchorMode: 'auto' | 'sticky' | 'center';
deadzone: number;
inputCurve: number;
smoothing: number;
momentumZoom: 'off' | 'gentle' | 'strong';
invertX: boolean;
invertY: boolean;
};
modeOverrides: typeof modeStore.state.overrides;
/** Base64-encoded weights, optional. */
weights?: string;
}
function encode(s: string): string {
// base64url encoding
const b64 = btoa(s);
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function decode(s: string): string | null {
try {
let b64 = s.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4 !== 0) b64 += '=';
return atob(b64);
} catch {
return null;
}
}
function f32ToBase64(arr: Float32Array): string {
const u8 = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
let s = '';
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]!);
return encode(s);
}
function base64ToF32(s: string): Float32Array | null {
const raw = decode(s);
if (!raw) return null;
const u8 = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) u8[i] = raw.charCodeAt(i);
// Round to 4 bytes
const len = u8.byteLength - (u8.byteLength % 4);
return new Float32Array(u8.buffer, u8.byteOffset, len / 4).slice();
}
/**
* Read every store and assemble a SessionPresetPayload. If
* `withWeights = true`, also encodes the current MLP weight blob.
*/
export function captureSessionPreset(withWeights: boolean = false): SessionPresetPayload {
const c = controlStore.state;
const e = explorationStore.state;
const o = outputStore.config;
const i = inputStore.config;
const payload: SessionPresetPayload = {
v: 1,
modeId: modeStore.state.activeModeId,
control: {
boldness: c.boldness,
memory: c.memory,
precision: c.precision,
presetId: c.presetId,
offsets: JSON.parse(JSON.stringify(c.offsets)),
},
exploration: {
spread: e.spread,
noiseFloor: e.noiseFloor,
noiseCap: e.noiseCap,
noiseGrowth: e.noiseGrowth,
noiseDecay: e.noiseDecay,
learningRate: e.learningRate,
weightDecay: e.weightDecay,
},
output: {
globalCurve: o.globalCurve,
smoothing: o.smoothing,
slewRate: isFinite(o.slewRate) ? o.slewRate : null,
freezeOutput: o.freezeOutput,
},
input: {
zoom: i.zoom,
anchorMode: i.anchorMode,
deadzone: i.deadzone,
inputCurve: i.inputCurve,
smoothing: i.smoothing,
momentumZoom: i.momentumZoom,
invertX: i.invertX,
invertY: i.invertY,
},
modeOverrides: JSON.parse(JSON.stringify(modeStore.state.overrides)),
};
if (withWeights) {
const w = mlStore.getWeights();
if (w.length > 0) {
payload.weights = f32ToBase64(w);
}
}
return payload;
}
/**
* Restore a SessionPresetPayload into all stores. Best-effort: missing
* fields fall back to current values. Returns true if at least one store
* was modified.
*/
export function restoreSessionPreset(payload: SessionPresetPayload): boolean {
if (!payload || payload.v !== 1) return false;
// Mode first (so override map is keyed correctly).
if (payload.modeId && payload.modeId !== modeStore.state.activeModeId) {
modeStore.switchMode(payload.modeId);
}
// Control axes & offsets.
if (payload.control) {
controlStore.setAxis('boldness', payload.control.boldness);
controlStore.setAxis('memory', payload.control.memory);
controlStore.setAxis('precision', payload.control.precision);
if (payload.control.presetId) controlStore.applyPreset(payload.control.presetId);
// Manually rewrite offsets — the store doesn't have a public bulk setter
// (control-store doesn't expose it because it's a private field).
// Use clearOffsets + setOffset roundtrip.
for (const axis of ['boldness', 'memory', 'precision'] as const) {
controlStore.clearOffsets(axis);
const off = payload.control.offsets[axis] ?? {};
for (const [k, v] of Object.entries(off)) {
if (typeof v === 'number') controlStore.setOffset(axis, k, v);
}
}
}
if (payload.exploration) {
explorationStore.setSpread(payload.exploration.spread);
explorationStore.setNoiseFloor(payload.exploration.noiseFloor);
explorationStore.setNoiseCap(payload.exploration.noiseCap);
explorationStore.setNoiseGrowth(payload.exploration.noiseGrowth);
explorationStore.setNoiseDecay(payload.exploration.noiseDecay);
explorationStore.setLearningRate(payload.exploration.learningRate);
explorationStore.setWeightDecay(payload.exploration.weightDecay);
}
if (payload.output) {
outputStore.setGlobalCurve(payload.output.globalCurve);
outputStore.setSmoothing(payload.output.smoothing);
outputStore.setSlewRate(payload.output.slewRate ?? Infinity);
outputStore.setFreezeOutput(payload.output.freezeOutput);
}
if (payload.input) {
inputStore.setZoom(payload.input.zoom);
inputStore.setAnchorMode(payload.input.anchorMode);
inputStore.setDeadzone(payload.input.deadzone);
inputStore.setInputCurve(payload.input.inputCurve);
inputStore.setSmoothing(payload.input.smoothing);
inputStore.setMomentumZoom(payload.input.momentumZoom);
inputStore.setInvert(payload.input.invertX, payload.input.invertY);
}
if (payload.modeOverrides) {
for (const modeId of Object.keys(payload.modeOverrides)) {
const map = payload.modeOverrides[modeId] ?? {};
modeStore.clearAllOverrides(modeId);
for (const [name, ov] of Object.entries(map)) {
modeStore.setOverride(modeId, name, ov);
}
}
}
if (payload.weights) {
const w = base64ToF32(payload.weights);
if (w && mlStore.iml && w.length > 0) {
try { mlStore.setWeights(w); } catch { /* ignore size mismatch */ }
}
}
return true;
}
/** Save preset to sessionStore (named blob in localStorage). */
export function saveNamedPreset(name: string, withWeights: boolean = false): void {
const payload = captureSessionPreset(withWeights);
sessionStore.savePreset(name, payload);
}
/** Load a named preset by id from sessionStore. */
export function loadNamedPreset(id: string): boolean {
const preset = sessionStore.state.presets.find((p) => p.id === id);
if (!preset) return false;
return restoreSessionPreset(preset.payload as SessionPresetPayload);
}
// ---------------------------------------------------------------------------
// URL sharing
// ---------------------------------------------------------------------------
/** Build a query-string fragment from current state (without weights). */
export function buildShareUrl(): string {
const payload = captureSessionPreset(false);
const json = JSON.stringify(payload);
const enc = encode(json);
return `?session=${enc}`;
}
/** Parse `window.location.search` and apply a session if present. */
export function applyUrlParams(search: string = window.location.search): boolean {
const sp = new URLSearchParams(search);
// Compact ?boldness=...&memory=... form.
const cb = sp.get('boldness');
const cm = sp.get('memory');
const cp = sp.get('precision');
let applied = false;
if (cb !== null || cm !== null || cp !== null) {
if (cb !== null) controlStore.setAxis('boldness', clamp01(parseFloat(cb)));
if (cm !== null) controlStore.setAxis('memory', clamp01(parseFloat(cm)));
if (cp !== null) controlStore.setAxis('precision', clamp01(parseFloat(cp)));
applied = true;
}
const sess = sp.get('session');
if (sess) {
const json = decode(sess);
if (json) {
try {
const payload = JSON.parse(json) as SessionPresetPayload;
applied = restoreSessionPreset(payload) || applied;
} catch {
// Ignore malformed
}
}
}
return applied;
}
function clamp01(v: number): number {
if (!isFinite(v)) return 0;
return v < 0 ? 0 : v > 1 ? 1 : v;
}

View file

@ -1,123 +0,0 @@
/**
* Snapshot helpers bridge sessionStore.pushSnapshot/popSnapshot to the
* actual MLP weights via mlStore. Auto-snapshot before train/randomize/
* thumbs-down.
*/
import { mlStore } from '../stores/ml-store';
import { sessionStore } from '../stores/session-store';
import { explorationStore } from '../stores/exploration-store';
import { inputStore } from '../stores/input-store';
/** Capture current weights + noise level + zoom level. */
export function autoSnapshot(tag: string): void {
if (!mlStore.iml) return;
const w = mlStore.getWeights();
if (w.length === 0) return;
sessionStore.pushSnapshot(tag, {
noiseLevel: explorationStore.state.noiseLevel,
zoomLevel: inputStore.config.zoom,
weights: w,
});
}
/**
* Pop the most recent snapshot and restore weights. Returns true on success.
*/
export function undoLastSnapshot(): boolean {
const snap = sessionStore.popSnapshot();
if (!snap || !snap.weights) return false;
if (!mlStore.iml) return false;
try {
mlStore.setWeights(snap.weights);
explorationStore.setNoiseLevel(snap.noiseLevel);
return true;
} catch {
return false;
}
}
/** Restore a specific snapshot by id. */
export function restoreSnapshotById(id: string): boolean {
const snap = sessionStore.jumpToSnapshot(id);
if (!snap || !snap.weights) return false;
if (!mlStore.iml) return false;
try {
mlStore.setWeights(snap.weights);
explorationStore.setNoiseLevel(snap.noiseLevel);
return true;
} catch {
return false;
}
}
/**
* A/B compare helpers capture the current weights as A, the next state
* becomes B. Toggle swaps in O(1).
*/
export function captureA(): void {
if (!mlStore.iml) return;
const w = mlStore.getWeights();
if (w.length === 0) return;
sessionStore.captureA({
id: `a-${Date.now().toString(36)}`,
tag: 'A',
timestamp: Date.now(),
noiseLevel: explorationStore.state.noiseLevel,
zoomLevel: inputStore.config.zoom,
weights: w,
});
}
export function toggleAB(): 'A' | 'B' {
if (!mlStore.iml) return sessionStore.state.ab.live;
// Capture the current live state on first toggle.
const cur = mlStore.getWeights();
const live = sessionStore.state.ab.live;
if (live === 'B' && !sessionStore.state.ab.b) {
// First toggle from B → A: capture the current state as B.
sessionStore.captureB({
id: `b-${Date.now().toString(36)}`,
tag: 'B',
timestamp: Date.now(),
noiseLevel: explorationStore.state.noiseLevel,
zoomLevel: inputStore.config.zoom,
weights: cur,
});
} else if (live === 'A') {
// Going back from A → B: update B with the current edits before flipping.
sessionStore.captureB({
id: `b-${Date.now().toString(36)}`,
tag: 'B',
timestamp: Date.now(),
noiseLevel: explorationStore.state.noiseLevel,
zoomLevel: inputStore.config.zoom,
weights: cur,
});
}
const next = sessionStore.toggleAB();
// Apply the side we just switched to.
const target = next === 'A' ? sessionStore.state.ab.a : sessionStore.state.ab.b;
if (target?.weights) {
try { mlStore.setWeights(target.weights); } catch { /* ignore */ }
explorationStore.setNoiseLevel(target.noiseLevel);
}
return next;
}
export function acceptB(): void {
sessionStore.acceptB();
}
export function revertToA(): void {
if (!mlStore.iml) {
sessionStore.revertToA();
return;
}
const a = sessionStore.state.ab.a;
if (a?.weights) {
try { mlStore.setWeights(a.weights); } catch { /* ignore */ }
explorationStore.setNoiseLevel(a.noiseLevel);
}
sessionStore.revertToA();
}

View file

@ -1,55 +0,0 @@
/**
* Trail tracking fixed-size ring of recent input positions for the JoyMap
* vanishing trail and tap-to-return.
*
* Uses `performance.now()` for timestamps. The JoyMap primitive filters by
* age internally (5-second window), so the buffer can be larger than the
* displayed window.
*/
import { createSignal, type Accessor } from 'solid-js';
export interface TrailPoint {
x: number;
y: number;
t: number; // performance.now()
}
const MAX_POINTS = 300;
const MIN_DELTA_PX = 0.005; // skip near-duplicate points (in 0..1 space)
export interface TrailRing {
/** Reactive accessor for current points. */
points: Accessor<ReadonlyArray<TrailPoint>>;
push(x: number, y: number): void;
clear(): void;
}
export function createTrailRing(): TrailRing {
const [points, setPoints] = createSignal<ReadonlyArray<TrailPoint>>([], { equals: false });
let buf: TrailPoint[] = [];
return {
points,
push(x, y) {
const now = performance.now();
// Skip if too close to last point.
const last = buf.length > 0 ? buf[buf.length - 1] : null;
if (last) {
const dx = x - last.x;
const dy = y - last.y;
if (dx * dx + dy * dy < MIN_DELTA_PX * MIN_DELTA_PX && now - last.t < 50) {
return;
}
}
buf.push({ x, y, t: now });
if (buf.length > MAX_POINTS) buf.shift();
setPoints(buf.slice());
},
clear() {
buf = [];
setPoints([]);
},
};
}

View file

@ -1,124 +0,0 @@
/**
* Weight health + gradient flow analysis.
*
* Pure functions over Float32Array weight buffers and `LayerStats[]` from
* the WasmIML. Mode UI binds these to the WeightHealth and GradientFlow
* primitives.
*/
import type { LayerStats } from '../ml/types';
import type { WeightStatus } from '../primitives/WeightHealth';
import type { GradientStatus } from '../primitives/GradientFlow';
const HISTOGRAM_BINS = 10;
/** Magnitude bin edges: [0, 0.01, 0.05, 0.15, 0.4, 0.8, 1.5, 2.5, 4, 7, ∞] */
const BIN_EDGES = [0, 0.01, 0.05, 0.15, 0.4, 0.8, 1.5, 2.5, 4, 7];
/** Build a 10-bin histogram of weight magnitudes from a flat weight array. */
export function weightHistogram(weights: Float32Array): number[] {
const bins = new Array<number>(HISTOGRAM_BINS).fill(0);
if (weights.length === 0) return bins;
for (let i = 0; i < weights.length; ++i) {
const m = Math.abs(weights[i]!);
let bin = HISTOGRAM_BINS - 1;
for (let b = 0; b < HISTOGRAM_BINS - 1; ++b) {
if (m < BIN_EDGES[b + 1]!) {
bin = b;
break;
}
}
bins[bin]++;
}
return bins;
}
/** Determine overall network health status. */
export function weightStatus(weights: Float32Array): WeightStatus {
if (weights.length === 0) return 'healthy';
let dead = 0;
let saturating = 0;
for (let i = 0; i < weights.length; ++i) {
const m = Math.abs(weights[i]!);
if (m < 0.01) dead++;
else if (m > 3.0) saturating++;
}
const deadFrac = dead / weights.length;
const satFrac = saturating / weights.length;
if (satFrac > 0.4) return 'saturating';
if (deadFrac > 0.3) return 'dead';
return 'healthy';
}
/**
* Per-layer L2 norm of weight delta (after - before). Used to populate
* GradientFlow primitive.
*/
export function layerNorms(beforeWeights: Float32Array, afterWeights: Float32Array, layerSizes: ReadonlyArray<number>): number[] {
const out: number[] = [];
let offset = 0;
for (const sz of layerSizes) {
let sum = 0;
const end = Math.min(beforeWeights.length, afterWeights.length, offset + sz);
for (let i = offset; i < end; ++i) {
const d = (afterWeights[i] ?? 0) - (beforeWeights[i] ?? 0);
sum += d * d;
}
out.push(Math.sqrt(sum));
offset += sz;
}
return out;
}
/**
* Detect vanishing/exploding/converged gradient flow given per-layer norms.
*/
export function gradientStatuses(norms: ReadonlyArray<number>): GradientStatus[] {
if (norms.length === 0) return [];
// Converged check first: all near-zero.
const allTiny = norms.every((n) => n < 1e-6);
if (allTiny) return norms.map(() => 'converged' as GradientStatus);
const out: GradientStatus[] = [];
for (let i = 0; i < norms.length; ++i) {
if (i === 0) {
out.push('healthy');
continue;
}
const prev = norms[i - 1] ?? 0;
const curr = norms[i] ?? 0;
if (prev <= 1e-12) {
out.push('healthy');
} else if (curr < 0.5 * prev) {
out.push('vanishing');
} else if (curr > 2.0 * prev) {
out.push('exploding');
} else {
out.push('healthy');
}
}
return out;
}
/**
* Estimate per-layer weight count given an MLP architecture.
*
* MLP layout: layer i has weights = inputs * outputs + outputs (bias).
* `[inputSize, ...hidden, outputSize]` N+1 layer sizes, N layers.
*/
export function layerWeightCounts(arch: { inputSize: number; hidden: ReadonlyArray<number>; outputSize: number }): number[] {
const sizes = [arch.inputSize, ...arch.hidden, arch.outputSize];
const counts: number[] = [];
for (let i = 0; i < sizes.length - 1; ++i) {
const ins = sizes[i]!;
const outs = sizes[i + 1]!;
counts.push(ins * outs + outs);
}
return counts;
}
/** Map LayerStats to a healthy/dead/saturating per-layer status. */
export function layerStatsToStatus(stats: LayerStats): WeightStatus {
if (stats.saturatingFrac > 0.4) return 'saturating';
if (stats.deadFrac > 0.3) return 'dead';
return 'healthy';
}

View file

@ -1,307 +0,0 @@
/**
* Input pipeline pure TS port of legacy `js/ui/input-pipeline.js`.
*
* Stages (in order), each input/output in [0,1]:
* 0. Invert (per-axis flip)
* 1. Deadzone (suppress jitter near center, remap live zone to [0,1])
* 2. Circular clamp (constrain to unit disk centered at 0.5,0.5)
* 3. Zoom (narrow window around anchor, modulated by momentum)
* 4. Centered power curve (per-axis exponent)
* 5. EMA smoothing (frame-rate-independent)
* 6. Momentum-as-zoom update (consumed next frame)
*
* `processInput` is a pure function over (raw, cfg, prev): returns the new
* processed coordinate plus the next-frame state. Consumer (input-store)
* holds the state and calls this each frame.
*
* Math is intentionally bit-for-bit equivalent to the legacy implementation.
*/
import { clamp, curveCenteredPower } from '../output/curves';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ZOOM_MIN = 0.01;
export const ZOOM_MAX = 1.0;
export const FREEZE_THRESHOLD = ZOOM_MIN;
export const DEADZONE_MAX = 0.4;
export const INPUT_CURVE_MIN = 0.2;
export const INPUT_CURVE_MAX = 5.0;
export const SMOOTHING_MAX = 0.95;
export const VELOCITY_WINDOW_DEFAULT = 150; // ms
const REFERENCE_DT = 1 / 60;
export type MomentumZoomMode = 'off' | 'gentle' | 'strong';
export type AnchorMode = 'auto' | 'sticky' | 'center';
interface MomentumPreset {
factor: number;
minZoomMul: number;
maxZoomMul: number;
}
const MOMENTUM_PRESETS: Record<MomentumZoomMode, MomentumPreset | null> = {
off: null,
gentle: { factor: 0.6, minZoomMul: 0.3, maxZoomMul: 1.0 },
strong: { factor: 1.5, minZoomMul: 0.15, maxZoomMul: 1.0 },
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface InputConfig {
/** Global zoom level [0.01, 1.0] */
zoom: number;
/** Optional per-axis zoom; overrides global when not null */
zoomX: number | null;
zoomY: number | null;
/** Anchor point [0,1]^2 (used in sticky/auto modes) */
anchorX: number;
anchorY: number;
anchorMode: AnchorMode;
/** Deadzone fraction of half-travel [0, 0.4] */
deadzone: number;
/** Centered power curve exponent [0.2, 5.0] (1.0 = linear) */
inputCurve: number;
inputCurveX: number | null;
inputCurveY: number | null;
/** EMA smoothing factor [0, 0.95] */
smoothing: number;
/** Momentum-as-zoom preset */
momentumZoom: MomentumZoomMode;
velocityWindow: number;
/** Per-axis inversion */
invertX: boolean;
invertY: boolean;
}
export interface InputState {
/** Last smoothed output x; seed at 0.5 */
smoothedX: number;
smoothedY: number;
/** Velocity history ring used for momentum-zoom */
velocityHistory: ReadonlyArray<{ x: number; y: number; t: number }>;
/** Most recent momentum-zoom multiplier (1 = no scale) */
momentumZoomMultiplier: number;
/** Whether last process call returned frozen=true */
frozen: boolean;
}
export interface ProcessResult {
x: number;
y: number;
frozen: boolean;
/** Next frame's state — consumer should keep this and pass it back. */
state: InputState;
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
export function defaultInputConfig(): InputConfig {
return {
zoom: 1.0,
zoomX: null,
zoomY: null,
anchorX: 0.5,
anchorY: 0.5,
anchorMode: 'center',
deadzone: 0,
inputCurve: 1.0,
inputCurveX: null,
inputCurveY: null,
smoothing: 0,
momentumZoom: 'off',
velocityWindow: VELOCITY_WINDOW_DEFAULT,
invertX: false,
invertY: false,
};
}
export function defaultInputState(): InputState {
return {
smoothedX: 0.5,
smoothedY: 0.5,
velocityHistory: [],
momentumZoomMultiplier: 1,
frozen: false,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function applyDeadzone(input: number, deadzone: number): number {
if (deadzone <= 0) return input;
const offset = input - 0.5;
const absOff = Math.abs(offset);
const halfDz = deadzone * 0.5;
if (absOff <= halfDz) return 0.5;
const sign = offset < 0 ? -1 : 1;
const remapped = ((absOff - halfDz) / (0.5 - halfDz)) * 0.5;
return 0.5 + sign * remapped;
}
function applyZoom(input: number, anchor: number, zoomLevel: number): number {
return clamp(anchor + (input - 0.5) * zoomLevel, 0, 1);
}
function emaSmooth(prev: number, raw: number, smoothing: number, dt: number): number {
if (smoothing <= 0) return raw;
const effectiveDt = dt > 0 ? dt : REFERENCE_DT;
const alpha = 1 - smoothing;
const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT);
return prev + alphaEff * (raw - prev);
}
function updateMomentumZoomMultiplier(
cfg: InputConfig,
state: InputState,
rawX: number,
rawY: number,
dt: number,
): { multiplier: number; history: InputState['velocityHistory'] } {
const preset = MOMENTUM_PRESETS[cfg.momentumZoom];
if (!preset) {
return { multiplier: 1, history: [] };
}
const now = performance.now();
const window = cfg.velocityWindow;
// Append, drop entries older than `window` ms
const trimmed = state.velocityHistory.filter((p) => now - p.t <= window);
const newHist = [...trimmed, { x: rawX, y: rawY, t: now }];
if (newHist.length < 2) {
return { multiplier: 1, history: newHist };
}
const a = newHist[0]!;
const b = newHist[newHist.length - 1]!;
const dtMs = b.t - a.t;
if (dtMs <= 0) return { multiplier: state.momentumZoomMultiplier, history: newHist };
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = dist / (dtMs / 1000); // [0,1]-space units per second
const normSpeed = clamp(speed * preset.factor, 0, 1);
// Higher speed → smaller multiplier (zoom out faster movements)
const target = preset.maxZoomMul - (preset.maxZoomMul - preset.minZoomMul) * normSpeed;
// Smooth toward target so the zoom doesn't jitter
const smoothCoeff = clamp(dt * 6, 0, 1);
const next = state.momentumZoomMultiplier + smoothCoeff * (target - state.momentumZoomMultiplier);
return { multiplier: next, history: newHist };
}
function resolveAnchorX(cfg: InputConfig, state: InputState): number {
if (cfg.anchorMode === 'center') return 0.5;
if (cfg.anchorMode === 'sticky') return cfg.anchorX;
// auto: use stored anchor (input-store updates it on zoom changes)
return cfg.anchorX;
}
function resolveAnchorY(cfg: InputConfig, state: InputState): number {
if (cfg.anchorMode === 'center') return 0.5;
if (cfg.anchorMode === 'sticky') return cfg.anchorY;
return cfg.anchorY;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Process raw 2D input through the pipeline.
*
* @param raw raw input [x, y] in [0,1]
* @param cfg pipeline configuration
* @param state prior state (use {@link defaultInputState} on first call)
* @param dt seconds since last call (default 1/60)
*/
export function processInput(
raw: readonly [number, number],
cfg: InputConfig,
state: InputState,
dt: number = REFERENCE_DT,
): ProcessResult {
const safeDt = Math.max(0, dt);
const baseZoomX = cfg.zoomX ?? cfg.zoom;
const baseZoomY = cfg.zoomY ?? cfg.zoom;
const frozenX = baseZoomX <= FREEZE_THRESHOLD;
const frozenY = baseZoomY <= FREEZE_THRESHOLD;
const fullyFrozen = frozenX && frozenY;
if (fullyFrozen) {
return {
x: state.smoothedX,
y: state.smoothedY,
frozen: true,
state: { ...state, frozen: true },
};
}
let [rawX, rawY] = raw;
// 0. Invert
let x = cfg.invertX ? 1 - rawX : rawX;
let y = cfg.invertY ? 1 - rawY : rawY;
// 1. Deadzone
x = applyDeadzone(x, cfg.deadzone);
y = applyDeadzone(y, cfg.deadzone);
// 2. Circular clamp to unit disk centered at (0.5, 0.5)
{
const cx = x - 0.5;
const cy = y - 0.5;
const dist = Math.sqrt(cx * cx + cy * cy);
if (dist > 0.5 && dist > 1e-12) {
const scale = 0.5 / dist;
x = 0.5 + cx * scale;
y = 0.5 + cy * scale;
}
}
// 3. Zoom around anchor (with momentum modulation)
const anchorX = resolveAnchorX(cfg, state);
const anchorY = resolveAnchorY(cfg, state);
const effZoomX = frozenX
? FREEZE_THRESHOLD
: clamp(baseZoomX * state.momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
const effZoomY = frozenY
? FREEZE_THRESHOLD
: clamp(baseZoomY * state.momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
x = frozenX ? state.smoothedX : applyZoom(x, anchorX, effZoomX);
y = frozenY ? state.smoothedY : applyZoom(y, anchorY, effZoomY);
// 4. Centered power curve
const curveX = cfg.inputCurveX ?? cfg.inputCurve;
const curveY = cfg.inputCurveY ?? cfg.inputCurve;
if (!frozenX) x = curveCenteredPower(x, curveX);
if (!frozenY) y = curveCenteredPower(y, curveY);
// 5. EMA smoothing
const smoothedX = frozenX ? state.smoothedX : emaSmooth(state.smoothedX, x, cfg.smoothing, safeDt);
const smoothedY = frozenY ? state.smoothedY : emaSmooth(state.smoothedY, y, cfg.smoothing, safeDt);
// 6. Update momentum-zoom for next frame
const { multiplier, history } = updateMomentumZoomMultiplier(cfg, state, rawX, rawY, safeDt);
return {
x: smoothedX,
y: smoothedY,
frozen: false,
state: {
smoothedX,
smoothedY,
velocityHistory: history,
momentumZoomMultiplier: multiplier,
frozen: false,
},
};
}

View file

@ -1,25 +0,0 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import App from './App';
import './styles/tokens.css';
import { installDebugProbe } from './debug/probe';
import { applyUrlParams } from './features/session-preset';
const root = document.getElementById('root');
if (!root) {
throw new Error('Root element #root not found');
}
// Install debug probe early. Playwright tests use it to drive the ML
// engine programmatically.
installDebugProbe();
// Apply URL params (?session=..., ?boldness=..., etc) before mounting so
// stores read the resolved values on first render.
try {
applyUrlParams();
} catch (err) {
console.warn('[main] applyUrlParams failed:', err);
}
render(() => <App />, root);

View file

@ -1,193 +0,0 @@
/**
* Dataset JS-side training-example store.
*
* Why duplicate the C++ ring buffer? Two reasons:
* 1. Sample-weight computation (recency / spatial / combined) lives in JS so
* that adjusting weighting modes doesn't burn a WASM round-trip.
* 2. The dataset is part of session state we serialize to localStorage
* the WASM heap is wiped on reload.
*
* On train() we ship features + labels into WASM via `addExample` calls. The
* order of insertion is preserved; FIFO eviction matches the C++ MLP's
* `dataset_head_` pointer so weighting stays consistent.
*
* The implementation is a faithful TypeScript port of the legacy
* `playground/_archive/js/nisps/dataset.js` with:
* - Float32Array backing instead of `Array<Array<number>>`
* - Stricter types
* - No `withBias` flag (the WASM bindings don't take a bias term)
*/
export type WeightMode = 'global' | 'local' | 'combined' | 'uniform';
export interface ComputeWeightsParams {
/** [0,1] — how strongly to bias toward newest examples (global/combined). */
recencyBias?: number;
/** Current input position, used for local/combined spatial weighting. */
queryInput?: ReadonlyArray<number>;
/** Spatial radius in input space (local/combined). */
radius?: number;
}
export class Dataset {
/** Maximum number of examples retained. FIFO eviction beyond this. */
readonly maxSize: number;
/** Length of feature vectors. Set on first add(); locked thereafter. */
private inputSize_ = 0;
/** Length of label vectors. Set on first add(); locked thereafter. */
private outputSize_ = 0;
/** Flat arrays — entries `[i*inputSize, (i+1)*inputSize)` belong to example i. */
private features_: Float32Array = new Float32Array(0);
private labels_: Float32Array = new Float32Array(0);
private size_ = 0;
constructor(maxSize = 100) {
if (maxSize <= 0) throw new Error('Dataset.maxSize must be > 0');
this.maxSize = maxSize;
}
/** Number of examples currently stored. */
get size(): number {
return this.size_;
}
isEmpty(): boolean {
return this.size_ === 0;
}
/**
* Add a feature/label pair. Returns true on success, false if the
* dimensions don't match a previously-added example.
*
* Eviction: when at capacity, the oldest example is removed (shift),
* then the new one is appended. This matches the legacy JS behaviour
* (and is conceptually equivalent to the C++ side's ring buffer with
* `head_` advancement).
*/
add(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): boolean {
if (this.size_ === 0) {
this.inputSize_ = features.length;
this.outputSize_ = labels.length;
// Allocate full-capacity buffers up front to avoid growth thrash.
this.features_ = new Float32Array(this.maxSize * this.inputSize_);
this.labels_ = new Float32Array(this.maxSize * this.outputSize_);
}
if (features.length !== this.inputSize_ || labels.length !== this.outputSize_) {
return false;
}
if (this.size_ >= this.maxSize) {
// FIFO: shift left in place. This is O(n*dim) and could be replaced
// with a head pointer; for maxSize ≤ a few hundred it's fine.
this.features_.copyWithin(0, this.inputSize_);
this.labels_.copyWithin(0, this.outputSize_);
this.size_ = this.maxSize - 1;
}
const fOff = this.size_ * this.inputSize_;
const lOff = this.size_ * this.outputSize_;
for (let i = 0; i < this.inputSize_; ++i) this.features_[fOff + i] = features[i];
for (let i = 0; i < this.outputSize_; ++i) this.labels_[lOff + i] = labels[i];
this.size_++;
return true;
}
clear(): void {
this.size_ = 0;
}
/** Read-only view of the i-th feature vector. */
feature(i: number): Float32Array {
if (i < 0 || i >= this.size_) throw new RangeError(`feature index ${i} out of bounds`);
return this.features_.subarray(i * this.inputSize_, (i + 1) * this.inputSize_);
}
/** Read-only view of the i-th label vector. */
label(i: number): Float32Array {
if (i < 0 || i >= this.size_) throw new RangeError(`label index ${i} out of bounds`);
return this.labels_.subarray(i * this.outputSize_, (i + 1) * this.outputSize_);
}
/** Flat view of all features (size * inputSize). */
featuresFlat(): Float32Array {
return this.features_.subarray(0, this.size_ * this.inputSize_);
}
/** Flat view of all labels (size * outputSize). */
labelsFlat(): Float32Array {
return this.labels_.subarray(0, this.size_ * this.outputSize_);
}
get inputSize(): number {
return this.inputSize_;
}
get outputSize(): number {
return this.outputSize_;
}
/**
* Compute per-sample training weights. Returns Float32Array (size=this.size)
* normalized to sum to 1. For an empty dataset returns a 0-length array;
* for a singleton, [1.0].
*
* Modes:
* - `uniform` every weight = 1/n.
* - `global` exponential recency decay over insertion order.
* - `local` within `radius` of `queryInput`, suppress older neighbours.
* - `combined` global × local.
*/
computeWeights(mode: WeightMode = 'uniform', params: ComputeWeightsParams = {}): Float32Array {
const n = this.size_;
if (n === 0) return new Float32Array(0);
if (n === 1) return new Float32Array([1.0]);
const weights = new Float32Array(n).fill(1.0);
if (mode === 'global' || mode === 'combined') {
const bias = params.recencyBias ?? 0.6;
if (bias > 0) {
const decay = 1 - 0.3 * bias;
for (let i = n - 2; i >= 0; --i) weights[i] = weights[i + 1] * decay;
}
}
if ((mode === 'local' || mode === 'combined') && params.queryInput) {
const query = params.queryInput;
const radius = params.radius ?? 0.15;
const radiusSq = radius * radius;
const dim = this.inputSize_;
for (let i = 0; i < n; ++i) {
const fOffI = i * dim;
let distSq = 0;
for (let d = 0; d < dim; ++d) {
const diff = this.features_[fOffI + d] - (query[d] ?? 0);
distSq += diff * diff;
}
if (distSq < radiusSq) {
const proximity = 1 - Math.sqrt(distSq) / radius;
let newerNearby = 0;
for (let j = i + 1; j < n; ++j) {
const fOffJ = j * dim;
let djSq = 0;
for (let d = 0; d < dim; ++d) {
const diff = this.features_[fOffI + d] - this.features_[fOffJ + d];
djSq += diff * diff;
}
if (djSq < radiusSq) ++newerNearby;
}
if (newerNearby > 0) {
weights[i] *= Math.pow(1 - proximity, newerNearby);
}
}
}
}
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;
}
}

View file

@ -1,122 +0,0 @@
/**
* jolt.ts "Jolt": held-button continuous weight morph.
*
* Faithful TS port of `nisps/ml/jolt.hpp` (which itself ports upstream
* memllib InterfaceRL "jolts"). While a button/pedal is held, pick a
* handful of random weights scattered across the whole network and
* EMA-glide each toward a bounded random target; on arrival, re-roll the
* target so the motion never stops. Releasing freezes the weights where
* they landed (the change is permanent).
*
* This operates on the MLP's FLAT weight buffer (the same buffer reached
* via `mlStore.getWeights()` / `setWeights()` / weight_count), so it is
* architecture-agnostic.
*
* RNG note: the C++ owns a per-instance deterministic xoshiro256+ for
* firmwarebrowser parity. This is a browser-only exploration aid that
* never round-trips through WASM, so we use `Math.random()` the exact
* sequence does not need to match firmware. Constants are reproduced
* verbatim from JoltParams.
*
* DEFAULT STATE IS INERT: a freshly constructed Jolt is inactive and
* `step()` is a no-op until `press()` is called.
*/
/** Upstream JoltParams defaults (kJolt* constants). */
export interface JoltParams {
/** kJoltNumWeights — number of weights morphed at once. */
numWeights: number;
/** kJoltMorphRate — EMA glide per tick (~1s @200Hz). */
morphRate: number;
/** kJoltWeightMin — lower bound of a random target. */
targetMin: number;
/** kJoltWeightMax — upper bound of a random target. */
targetMax: number;
/** kJoltTargetEpsilon — re-roll target once within this distance. */
targetEpsilon: number;
}
export const DEFAULT_JOLT_PARAMS: JoltParams = {
numWeights: 40,
morphRate: 0.017,
targetMin: -1.2,
targetMax: 0.9,
targetEpsilon: 0.05,
};
export class Jolt {
private params_: JoltParams = { ...DEFAULT_JOLT_PARAMS };
private active_ = false;
private n_ = 0;
private idx_: Int32Array = new Int32Array(0);
private target_: Float32Array = new Float32Array(0);
constructor(params?: Partial<JoltParams>) {
if (params) this.params_ = { ...this.params_, ...params };
}
setParams(p: Partial<JoltParams>): void {
this.params_ = { ...this.params_, ...p };
}
get params(): Readonly<JoltParams> {
return this.params_;
}
active(): boolean {
return this.active_;
}
/**
* Begin a jolt over a flat weight buffer of `weightCount` entries: pick
* `numWeights` random global indices and a bounded random target each.
*/
press(weightCount: number): void {
this.active_ = true;
this.n_ = this.params_.numWeights;
if (weightCount <= 0) {
this.n_ = 0;
return;
}
if (this.idx_.length < this.n_) this.idx_ = new Int32Array(this.n_);
if (this.target_.length < this.n_) this.target_ = new Float32Array(this.n_);
for (let i = 0; i < this.n_; ++i) {
this.idx_[i] = Math.floor(Math.random() * weightCount) % weightCount;
this.target_[i] = this.rollTarget_();
}
}
/**
* Per control tick while held: EMA-glide each selected weight toward its
* target, re-rolling targets that have been reached. No-op when inactive.
* Mutates `weights` in place.
*/
step(weights: Float32Array): void {
if (!this.active_) return;
const wc = weights.length;
const rate = this.params_.morphRate;
const eps = this.params_.targetEpsilon;
for (let i = 0; i < this.n_; ++i) {
const k = this.idx_[i]!;
if (k < 0 || k >= wc) continue;
let w = weights[k]!;
w += rate * (this.target_[i]! - w);
weights[k] = w;
if (Math.abs(this.target_[i]! - w) < eps) {
this.target_[i] = this.rollTarget_();
}
}
}
/** Release: freeze weights where they are (permanent). */
release(): void {
this.active_ = false;
}
private rollTarget_(): number {
return (
this.params_.targetMin +
Math.random() * (this.params_.targetMax - this.params_.targetMin)
);
}
}

View file

@ -1,156 +0,0 @@
/**
* TypeScript types matching the C API surface in `nisps/wasm/bindings.cpp`.
*
* These types are intentionally minimal: they describe the JS-visible shape
* of the Emscripten module, the heap views we read/write, and the per-layer
* stats record. They DO NOT mirror any internal C++ struct.
*
* The Emscripten glue produced by `scripts/build-wasm.sh` exposes a factory
* function, `createNispsModule(opts?) => Promise<NispsModule>`, which we
* call from `wasm-iml.ts` and `wasm-worker.ts`.
*/
/**
* Shape of the loaded WASM module the subset we use.
* Emscripten generates more on it; we type only what we need.
*
* NOTE: `_*` prefixed methods are the raw exported C functions (Emscripten
* naming convention). They take/return numbers (pointers + primitives).
*/
export interface NispsModule {
// Memory views (re-bound after grow).
HEAP8: Int8Array;
HEAP16: Int16Array;
HEAP32: Int32Array;
HEAPU8: Uint8Array;
HEAPU16: Uint16Array;
HEAPU32: Uint32Array;
HEAPF32: Float32Array;
HEAPF64: Float64Array;
_malloc(bytes: number): number;
_free(ptr: number): void;
// ML lifecycle.
// Seed is a uint32_t (not 64-bit) — see bindings.cpp file comment.
_nisps_ml_create(input_size: number, output_size: number, hidden_ptr: number, n_hidden: number, seed: number): number;
_nisps_ml_destroy(ml: number): void;
_nisps_ml_reset(ml: number): void;
// ML inference.
_nisps_ml_set_input(ml: number, idx: number, v: number): void;
_nisps_ml_process(ml: number): void;
_nisps_ml_outputs(ml: number): number; // returns float* into HEAPF32
_nisps_ml_infer_batch(ml: number, points_ptr: number, n_points: number, out_ptr: number): void;
// ML training.
_nisps_ml_add_example(ml: number, features_ptr: number, labels_ptr: number): void;
_nisps_ml_train(ml: number, lr: number, max_iter: number, min_err: number, sample_weights_ptr: number): number;
_nisps_ml_eval_loss(ml: number): number;
// ML examples.
_nisps_ml_clear_examples(ml: number): void;
_nisps_ml_example_count(ml: number): number;
// ML weights.
_nisps_ml_weight_count(ml: number): number;
_nisps_ml_get_weights(ml: number, out_ptr: number): void;
_nisps_ml_set_weights(ml: number, in_ptr: number): void;
_nisps_ml_draw_weights(ml: number, spread: number): void;
_nisps_ml_move_weights(ml: number, speed: number, spread: number, mask_ptr: number): void;
_nisps_ml_get_layer_stats(ml: number, out_ptr: number): void;
_nisps_ml_describe(out_ptr: number): void;
// Engines.
_nisps_engine_create(id_ptr: number, sample_rate: number): number;
_nisps_engine_destroy(engine: number): void;
_nisps_engine_set_params(engine: number, params_ptr: number, n_params: number): void;
_nisps_engine_process_block(
engine: number,
in_l_ptr: number, in_r_ptr: number,
out_l_ptr: number, out_r_ptr: number,
n_samples: number,
): void;
}
/** Factory function exposed by the Emscripten glue. */
export type NispsModuleFactory = (opts?: {
locateFile?: (path: string, prefix: string) => string;
wasmBinary?: ArrayBuffer | Uint8Array;
print?: (msg: string) => void;
printErr?: (msg: string) => void;
}) => Promise<NispsModule>;
/** Architecture descriptor returned from `nisps_ml_describe`. */
export interface MLArchitecture {
inputSize: number;
hidden: [number, number, number];
outputSize: number;
numLayers: number;
}
/** Per-layer weight health record (one per layer). */
export interface LayerStats {
meanAbs: number;
maxAbs: number;
deadFrac: number;
saturatingFrac: number;
}
/** The `engine_id` strings the C++ side recognises. Anything else falls back to "thru". */
export type EngineId =
| 'thru'
| 'paf_synth'
| 'channel_strip'
| 'xiasri'
| 'verb_fx'
| 'memlcelium'
| 'breakor'
| 'elysiamorf'
| 'analysis';
/** Message protocol between main thread and `wasm-worker.ts`. */
export type WorkerRequest =
| {
kind: 'init';
seed: number;
}
| {
kind: 'train';
requestId: number;
// Flat features: nExamples * inputSize floats.
features: Float32Array;
// Flat labels: nExamples * outputSize floats.
labels: Float32Array;
// Optional per-example weights, sums to 1. Empty = uniform.
sampleWeights: Float32Array;
// Current weights to seed worker MLP.
weights: Float32Array;
lr: number;
maxIter: number;
minErr: number;
inputSize: number;
outputSize: number;
}
| {
kind: 'dispose';
};
export type WorkerResponse =
| {
kind: 'ready';
}
| {
kind: 'result';
requestId: number;
loss: number;
weights: Float32Array;
// Loss curve (per-iteration). Currently always empty — the C++ MLP
// exposes loss_history but the WASM bridge does not yet plumb it.
lossHistory: Float32Array;
}
| {
kind: 'error';
requestId: number;
message: string;
};

View file

@ -1,650 +0,0 @@
/**
* WasmIML main-thread ML interface backed by `nisps.wasm`.
*
* Owns:
* - One `nisps.wasm` instance.
* - One MLP handle.
* - A JS-side `Dataset` (mirrors the C++ ring buffer; see dataset.ts).
* - Pre-allocated heap buffers for inputs/outputs/weights/etc.
* - A lazy `WasmTrainer` worker for off-thread async training.
*
* Threading model (architecture.md §6.4):
* - Inference + sync training run on this main-thread instance.
* - `trainAsync()` spawns/uses a worker which holds a SECOND wasm
* instance; weights round-trip through `getWeights()`/`setWeights()`.
*
* Side effects: every mutation that should be visible to the UI calls into
* `mlStore`. The store is the single source of truth for Solid components.
*
* Concept compatibility: this class re-implements the legacy WasmIML
* surface area documented in `recon/04-playground.md §6` so the existing
* Playwright debug-probe tests can keep passing once the new probe is
* wired up.
*/
import { produce } from 'solid-js/store';
import { mlStore } from '../stores/ml-store';
import { coreBus } from '../stores/bus';
import { Dataset } from './dataset';
import type {
LayerStats,
MLArchitecture,
NispsModule,
NispsModuleFactory,
} from './types';
import { createTrainer, type WasmTrainer } from './wasm-worker';
const NISPS_JS_URL = '/nisps.js';
const NISPS_WASM_URL = '/nisps.wasm';
/** Default architecture matches `nisps/wasm/bindings.cpp` instantiation. */
const DEFAULT_INPUT_SIZE = 2;
const DEFAULT_OUTPUT_SIZE = 126;
let cachedFactory: NispsModuleFactory | null = null;
/**
* Load the Emscripten glue once, cache the factory.
*
* The glue is served from `playground/public/nisps.js` (committed). We
* dynamically import it so the WASM only loads when the ML system is first
* used `/dev/primitives` doesn't pay for it.
*/
async function getFactory(): Promise<NispsModuleFactory> {
if (cachedFactory) return cachedFactory;
// Vite serves `/nisps.js` as a normal asset; we use a dynamic-eval import
// to avoid Vite trying to resolve it at build time.
const url = new URL(NISPS_JS_URL, window.location.origin).toString();
const mod = await import(/* @vite-ignore */ url);
// Emscripten MODULARIZE=1 default export key is `default`.
// tslint:disable-next-line:no-any
const factory = (mod as { default?: NispsModuleFactory; createNispsModule?: NispsModuleFactory })
.default ?? (mod as { createNispsModule?: NispsModuleFactory }).createNispsModule;
if (!factory) throw new Error('[wasm-iml] nisps.js does not export a module factory');
cachedFactory = factory;
return factory;
}
/** Aligned float-array allocation helper. Returns ptr + a view. */
class HeapBuffer {
readonly ptr: number;
readonly view: Float32Array;
constructor(private mod: NispsModule, public readonly count: number) {
this.ptr = mod._malloc(count * 4);
if (!this.ptr) throw new Error(`malloc(${count * 4}) failed`);
this.view = new Float32Array(mod.HEAPF32.buffer, this.ptr, count);
}
/** After memory growth, refresh the view onto the new ArrayBuffer. */
rebind(): void {
// Re-create the view with the (possibly new) underlying buffer.
Object.defineProperty(this, 'view', {
value: new Float32Array(this.mod.HEAPF32.buffer, this.ptr, this.count),
writable: false,
});
}
free(): void {
this.mod._free(this.ptr);
}
}
class HeapU8 {
readonly ptr: number;
readonly view: Uint8Array;
constructor(private mod: NispsModule, public readonly count: number) {
this.ptr = mod._malloc(count);
if (!this.ptr) throw new Error(`malloc(${count}) failed`);
this.view = new Uint8Array(mod.HEAPU8.buffer, this.ptr, count);
}
rebind(): void {
Object.defineProperty(this, 'view', {
value: new Uint8Array(this.mod.HEAPU8.buffer, this.ptr, this.count),
writable: false,
});
}
free(): void {
this.mod._free(this.ptr);
}
}
export interface WasmIMLOptions {
inputSize?: number;
outputSize?: number;
hiddenLayers?: ReadonlyArray<number>;
seed?: number;
/** localStorage key the loaded weights/dataset will be persisted under. */
storageKey?: string;
maxExamples?: number;
}
export class WasmIML {
// WASM binding state.
private module!: NispsModule;
private mlHandle = 0;
private weightCount_ = 0;
// Architecture descriptor (resolved post-init from the WASM build).
private arch_: MLArchitecture = {
inputSize: DEFAULT_INPUT_SIZE,
hidden: [10, 14, 18],
outputSize: DEFAULT_OUTPUT_SIZE,
numLayers: 4,
};
// Pre-allocated heap buffers.
private featuresBuf!: HeapBuffer;
private labelsBuf!: HeapBuffer;
private weightsBuf!: HeapBuffer;
private statsBuf!: HeapBuffer;
private batchInBuf!: HeapBuffer;
private batchOutBuf!: HeapBuffer;
private pinMaskBuf!: HeapU8;
private describeBuf!: HeapBuffer; // 6 ints, reused as 6 floats on the heap is wrong;
// We use HEAP32 directly via a tiny scratch malloc:
private describePtr = 0;
// JS-side state.
readonly dataset: Dataset;
private lastLoss_: number | null = null;
private trainer: WasmTrainer | null = null;
private storageKey: string;
private saveTimer: number | null = null;
private destroyed = false;
static MAX_BATCH = 4096;
private constructor(opts: WasmIMLOptions) {
this.dataset = new Dataset(opts.maxExamples ?? 100);
this.storageKey = opts.storageKey ?? 'nisps:wasm-iml';
}
/**
* Async factory. Loads the wasm, creates the MLP handle, allocates heap
* buffers, and rehydrates persisted state.
*/
static async create(opts: WasmIMLOptions = {}): Promise<WasmIML> {
const inst = new WasmIML(opts);
await inst.init_(opts);
return inst;
}
private async init_(opts: WasmIMLOptions): Promise<void> {
const factory = await getFactory();
this.module = await factory({
// Vite serves /nisps.wasm at the root; the default locateFile would
// resolve relative to nisps.js (also at root) so this is the same
// result, but explicit is better.
locateFile: (path: string) => {
if (path.endsWith('.wasm')) return new URL(NISPS_WASM_URL, window.location.origin).toString();
return path;
},
});
// Resolve architecture via the WASM module (compile-time fixed; we
// stash the values for callers that need them).
this.describePtr = this.module._malloc(6 * 4);
this.module._nisps_ml_describe(this.describePtr);
const dims = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6);
this.arch_ = {
inputSize: dims[0],
hidden: [dims[1], dims[2], dims[3]],
outputSize: dims[4],
numLayers: dims[5],
};
// Caller-supplied dimensions are accepted but ignored; warn if mismatch.
const wantedIn = opts.inputSize ?? this.arch_.inputSize;
const wantedOut = opts.outputSize ?? this.arch_.outputSize;
if (wantedIn !== this.arch_.inputSize || wantedOut !== this.arch_.outputSize) {
console.warn(
`[wasm-iml] requested ${wantedIn}->${wantedOut} but WASM build is fixed at ` +
`${this.arch_.inputSize}->${this.arch_.outputSize}; extras are ignored.`,
);
}
// Create the MLP. We pass dummy hidden ptr/count — the binding ignores them.
const seed = (opts.seed ?? (Date.now() >>> 0)) >>> 0;
this.mlHandle = this.module._nisps_ml_create(
this.arch_.inputSize,
this.arch_.outputSize,
0, // hidden ptr (unused)
0, // n_hidden (unused)
seed,
);
if (!this.mlHandle) throw new Error('[wasm-iml] nisps_ml_create returned null');
this.weightCount_ = this.module._nisps_ml_weight_count(this.mlHandle);
// Heap buffers (created after we know the architecture).
this.featuresBuf = new HeapBuffer(this.module, this.arch_.inputSize);
this.labelsBuf = new HeapBuffer(this.module, this.arch_.outputSize);
this.weightsBuf = new HeapBuffer(this.module, this.weightCount_);
this.statsBuf = new HeapBuffer(this.module, this.arch_.numLayers * 4);
this.batchInBuf = new HeapBuffer(this.module, WasmIML.MAX_BATCH * this.arch_.inputSize);
this.batchOutBuf = new HeapBuffer(this.module, WasmIML.MAX_BATCH * this.arch_.outputSize);
this.pinMaskBuf = new HeapU8(this.module, this.arch_.outputSize);
// Push initial state to the store.
mlStore.__setState(produce((s) => {
s.inputSize = this.arch_.inputSize;
s.outputSize = this.arch_.outputSize;
s.exampleCount = 0;
s.lastLoss = null;
s.lossHistory = [];
s.training = false;
s.ready = true;
}));
mlStore.__setOutputs(new Float32Array(this.arch_.outputSize));
this.publishWeights_();
// Load persisted state if present (best-effort).
this.tryLoadFromStorage_();
}
// -------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------
dispose(): void {
if (this.destroyed) return;
this.destroyed = true;
if (this.saveTimer !== null) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
if (this.trainer) {
this.trainer.dispose();
this.trainer = null;
}
if (this.module && this.mlHandle) {
this.module._nisps_ml_destroy(this.mlHandle);
this.mlHandle = 0;
}
if (this.featuresBuf) this.featuresBuf.free();
if (this.labelsBuf) this.labelsBuf.free();
if (this.weightsBuf) this.weightsBuf.free();
if (this.statsBuf) this.statsBuf.free();
if (this.batchInBuf) this.batchInBuf.free();
if (this.batchOutBuf) this.batchOutBuf.free();
if (this.pinMaskBuf) this.pinMaskBuf.free();
if (this.describePtr) this.module._free(this.describePtr);
mlStore.__setState(produce((s) => {
s.ready = false;
}));
}
get architecture(): MLArchitecture {
return this.arch_;
}
get weightCount(): number {
return this.weightCount_;
}
get exampleCount(): number {
return this.dataset.size;
}
get lastLoss(): number | null {
return this.lastLoss_;
}
// -------------------------------------------------------------------
// Inference
// -------------------------------------------------------------------
setInput(idx: number, value: number): void {
this.module._nisps_ml_set_input(this.mlHandle, idx, value);
}
process(): Float32Array {
this.module._nisps_ml_process(this.mlHandle);
const ptr = this.module._nisps_ml_outputs(this.mlHandle);
// Copy out so the caller can hold onto it across memory growth.
const view = new Float32Array(this.module.HEAPF32.buffer, ptr, this.arch_.outputSize);
const out = new Float32Array(view); // copy
mlStore.__setOutputs(out);
return out;
}
/** Convenience: setInput(0,x); setInput(1,y); process(). */
inferXY(x: number, y: number): Float32Array {
this.setInput(0, x);
this.setInput(1, y);
return this.process();
}
/**
* Batch inference. `points` is an array of [x,y] tuples (or any vector
* length <= inputSize; trailing entries zero-padded). Returns a flat
* Float32Array of length n * outputSize.
*
* Larger requests than `MAX_BATCH` are chunked transparently.
*/
inferBatch(points: ReadonlyArray<ReadonlyArray<number>>): Float32Array {
const n = points.length;
const inSz = this.arch_.inputSize;
const outSz = this.arch_.outputSize;
const result = new Float32Array(n * outSz);
let written = 0;
for (let offset = 0; offset < n; offset += WasmIML.MAX_BATCH) {
const chunk = Math.min(WasmIML.MAX_BATCH, n - offset);
// Pack into batchInBuf.
for (let i = 0; i < chunk; ++i) {
const src = points[offset + i];
const base = i * inSz;
for (let j = 0; j < inSz; ++j) this.batchInBuf.view[base + j] = src[j] ?? 0;
}
this.module._nisps_ml_infer_batch(
this.mlHandle,
this.batchInBuf.ptr,
chunk,
this.batchOutBuf.ptr,
);
// Copy out into result.
const slice = this.batchOutBuf.view.subarray(0, chunk * outSz);
result.set(slice, written);
written += chunk * outSz;
}
return result;
}
// -------------------------------------------------------------------
// Training
// -------------------------------------------------------------------
/**
* Add a feature/label pair to BOTH the JS dataset and the WASM ring
* buffer. The two stay in sync because every train() call pushes the
* full JS dataset back into WASM (in case of weight recompute, undo
* restore, etc.). For the sake of correctness, we re-sync on each add
* too cheap relative to training.
*/
addExample(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): boolean {
const ok = this.dataset.add(features, labels);
if (!ok) return false;
this.copyExampleToWasm_(features, labels);
mlStore.__setState(produce((s) => {
s.exampleCount = this.dataset.size;
}));
coreBus.emit('ml.example_added', { count: this.dataset.size });
this.scheduleSave_();
return true;
}
private copyExampleToWasm_(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): void {
const fv = this.featuresBuf.view;
const lv = this.labelsBuf.view;
const inSz = this.arch_.inputSize;
const outSz = this.arch_.outputSize;
for (let i = 0; i < inSz; ++i) fv[i] = features[i] ?? 0;
for (let i = 0; i < outSz; ++i) lv[i] = labels[i] ?? 0;
this.module._nisps_ml_add_example(this.mlHandle, this.featuresBuf.ptr, this.labelsBuf.ptr);
}
/**
* Synchronous training. Returns the final loss (also stored in
* `lastLoss`). Updates `mlStore.lastLoss` and emits `ml.trained`.
*
* Caller can pass per-sample weights; if omitted the WASM side uses
* uniform 1/n weighting.
*/
train(lr = 1.0, maxIter = 1000, minErr = 0.001, sampleWeights?: Float32Array): number {
if (this.dataset.isEmpty()) {
this.lastLoss_ = 0;
mlStore.__setState(produce((s) => { s.lastLoss = 0; }));
return 0;
}
let weightsPtr = 0;
let weightsHandle: HeapBuffer | null = null;
if (sampleWeights && sampleWeights.length === this.dataset.size) {
weightsHandle = new HeapBuffer(this.module, sampleWeights.length);
weightsHandle.view.set(sampleWeights);
weightsPtr = weightsHandle.ptr;
}
mlStore.__setState(produce((s) => { s.training = true; }));
let loss = 0;
try {
loss = this.module._nisps_ml_train(this.mlHandle, lr, maxIter, minErr, weightsPtr);
} finally {
if (weightsHandle) weightsHandle.free();
mlStore.__setState(produce((s) => { s.training = false; }));
}
this.lastLoss_ = loss;
mlStore.__setState(produce((s) => {
s.lastLoss = loss;
// The C++ MLP stores per-iter history but we don't currently expose
// it via the WASM bindings. Stream 9 may add nisps_ml_loss_history.
s.lossHistory = [loss];
}));
this.publishWeights_();
coreBus.emit('ml.trained', { loss });
this.scheduleSave_();
return loss;
}
/**
* Async training via worker. The worker holds a SECOND wasm instance,
* receives current weights + dataset, runs SGD, and returns updated
* weights. Main-thread weights are then `setWeights()`-restored.
*/
async trainAsync(lr = 1.0, maxIter = 1000, minErr = 0.001, sampleWeights?: Float32Array): Promise<number> {
if (this.dataset.isEmpty()) {
this.lastLoss_ = 0;
return 0;
}
if (!this.trainer) this.trainer = await createTrainer();
const weights = this.getWeights();
const features = new Float32Array(this.dataset.featuresFlat());
const labels = new Float32Array(this.dataset.labelsFlat());
const sw = sampleWeights ? new Float32Array(sampleWeights) : new Float32Array(0);
mlStore.__setState(produce((s) => { s.training = true; }));
try {
const result = await this.trainer.train({
weights,
features,
labels,
sampleWeights: sw,
lr,
maxIter,
minErr,
inputSize: this.arch_.inputSize,
outputSize: this.arch_.outputSize,
});
this.setWeights(result.weights);
this.lastLoss_ = result.loss;
mlStore.__setState(produce((s) => {
s.lastLoss = result.loss;
s.lossHistory = Array.from(result.lossHistory);
}));
coreBus.emit('ml.trained', { loss: result.loss });
this.scheduleSave_();
return result.loss;
} finally {
mlStore.__setState(produce((s) => { s.training = false; }));
}
}
/** Non-destructive loss query. */
evalLoss(): number {
return this.module._nisps_ml_eval_loss(this.mlHandle);
}
clearExamples(): void {
this.dataset.clear();
this.module._nisps_ml_clear_examples(this.mlHandle);
mlStore.__setState(produce((s) => { s.exampleCount = 0; }));
coreBus.emit('ml.examples_cleared', undefined);
this.scheduleSave_();
}
// -------------------------------------------------------------------
// RL ops
// -------------------------------------------------------------------
randomiseWeights(spread = 0.6): void {
this.module._nisps_ml_draw_weights(this.mlHandle, spread);
this.publishWeights_();
coreBus.emit('ml.delta_update', { reason: 'randomize' });
this.scheduleSave_();
}
moveWeights(speed: number, spread: number, pinMask?: Uint8Array): void {
let maskPtr = 0;
if (pinMask) {
const sz = Math.min(pinMask.length, this.arch_.outputSize);
for (let i = 0; i < sz; ++i) this.pinMaskBuf.view[i] = pinMask[i];
for (let i = sz; i < this.arch_.outputSize; ++i) this.pinMaskBuf.view[i] = 0;
maskPtr = this.pinMaskBuf.ptr;
}
this.module._nisps_ml_move_weights(this.mlHandle, speed, spread, maskPtr);
this.publishWeights_();
// The caller (RL handler) decides whether this is a thumbs-up/down;
// we emit a generic delta_update.
coreBus.emit('ml.delta_update', { reason: 'thumbs_down' });
}
// -------------------------------------------------------------------
// Weights I/O
// -------------------------------------------------------------------
getWeights(): Float32Array {
this.module._nisps_ml_get_weights(this.mlHandle, this.weightsBuf.ptr);
// Copy out so caller can mutate freely.
return new Float32Array(this.weightsBuf.view);
}
setWeights(w: Float32Array | Uint8Array): void {
if (w.length < this.weightCount_) {
throw new Error(`setWeights: expected ${this.weightCount_} floats, got ${w.length}`);
}
this.weightsBuf.view.set(w as Float32Array, 0);
this.module._nisps_ml_set_weights(this.mlHandle, this.weightsBuf.ptr);
this.publishWeights_();
}
/** Per-layer weight stats. Returns one record per layer. */
getLayerStats(): LayerStats[] {
this.module._nisps_ml_get_layer_stats(this.mlHandle, this.statsBuf.ptr);
const out: LayerStats[] = [];
for (let i = 0; i < this.arch_.numLayers; ++i) {
const base = i * 4;
out.push({
meanAbs: this.statsBuf.view[base],
maxAbs: this.statsBuf.view[base + 1],
deadFrac: this.statsBuf.view[base + 2],
saturatingFrac: this.statsBuf.view[base + 3],
});
}
return out;
}
/** Flat layer-stats Float32Array (numLayers * 4). For probe API. */
getLayerStatsFlat(): Float32Array {
this.module._nisps_ml_get_layer_stats(this.mlHandle, this.statsBuf.ptr);
return new Float32Array(this.statsBuf.view);
}
// -------------------------------------------------------------------
// Misc
// -------------------------------------------------------------------
reset(): void {
this.module._nisps_ml_reset(this.mlHandle);
this.dataset.clear();
this.lastLoss_ = null;
mlStore.__setState(produce((s) => {
s.exampleCount = 0;
s.lastLoss = null;
s.lossHistory = [];
}));
this.publishWeights_();
coreBus.emit('ml.examples_cleared', undefined);
this.scheduleSave_();
}
// -------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------
private scheduleSave_(): void {
if (this.saveTimer !== null) clearTimeout(this.saveTimer);
this.saveTimer = window.setTimeout(() => this.saveNow(), 500);
}
saveNow(): void {
if (this.destroyed) return;
if (this.saveTimer !== null) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
try {
const weights = this.getWeights();
const payload = {
v: 1,
arch: this.arch_,
weights: Array.from(weights),
features: Array.from(this.dataset.featuresFlat()),
labels: Array.from(this.dataset.labelsFlat()),
size: this.dataset.size,
lastLoss: this.lastLoss_,
};
localStorage.setItem(this.storageKey, JSON.stringify(payload));
} catch (err) {
// localStorage might be full or unavailable; not fatal.
console.warn('[wasm-iml] saveNow failed:', err);
}
}
private tryLoadFromStorage_(): void {
try {
const raw = localStorage.getItem(this.storageKey);
if (!raw) return;
const payload = JSON.parse(raw) as {
v: number;
weights: number[];
features: number[];
labels: number[];
size: number;
lastLoss: number | null;
};
if (payload.v !== 1) return;
// Restore dataset (rebuild via add()).
const inSz = this.arch_.inputSize;
const outSz = this.arch_.outputSize;
if (payload.size > 0 && payload.features.length === payload.size * inSz &&
payload.labels.length === payload.size * outSz) {
for (let i = 0; i < payload.size; ++i) {
const f = payload.features.slice(i * inSz, (i + 1) * inSz);
const l = payload.labels.slice(i * outSz, (i + 1) * outSz);
this.dataset.add(f, l);
// Also push to the WASM ring buffer.
this.copyExampleToWasm_(f, l);
}
}
// Restore weights.
if (payload.weights.length === this.weightCount_) {
this.setWeights(new Float32Array(payload.weights));
}
this.lastLoss_ = payload.lastLoss;
mlStore.__setState(produce((s) => {
s.exampleCount = this.dataset.size;
s.lastLoss = this.lastLoss_;
}));
} catch (err) {
console.warn('[wasm-iml] tryLoadFromStorage failed:', err);
}
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
private publishWeights_(): void {
const w = this.getWeights();
mlStore.__setWeights(w);
}
}

View file

@ -1,334 +0,0 @@
/**
* Disposable Web Worker that runs SGD off the main thread.
*
* The worker holds its own `nisps.wasm` instance (architecture.md §6.4).
* The main thread sends:
* - current weights (so the worker is in the same state as the UI),
* - dataset (features + labels),
* - SGD hyperparameters,
* and receives updated weights + final loss.
*
* This module exposes:
* - `createTrainer()` - factory that spawns the worker, loads its WASM,
* and returns a `WasmTrainer` handle.
* - The worker entry-point itself (when this file runs in a Worker).
*
* The worker is implemented inline so a single TS file becomes both the
* main-thread API and the worker bundle. Vite's `new Worker(new URL(...,
* import.meta.url))` pattern packs it correctly.
*
* Lifecycle: each `WasmTrainer` is disposable via `.dispose()` which
* terminates the worker. Tests should always dispose to avoid leaks.
*/
import type { NispsModule, NispsModuleFactory, WorkerRequest, WorkerResponse } from './types';
// ---------------------------------------------------------------------------
// Main-thread side
// ---------------------------------------------------------------------------
export interface TrainArgs {
weights: Float32Array;
features: Float32Array;
labels: Float32Array;
/** Optional; pass empty for uniform weighting. */
sampleWeights: Float32Array;
lr: number;
maxIter: number;
minErr: number;
inputSize: number;
outputSize: number;
}
export interface TrainResult {
loss: number;
weights: Float32Array;
lossHistory: Float32Array;
}
export class WasmTrainer {
private worker: Worker;
private nextId = 1;
private pending = new Map<number, { resolve: (r: TrainResult) => void; reject: (e: unknown) => void }>();
private disposed = false;
static async create(): Promise<WasmTrainer> {
const trainer = new WasmTrainer();
await trainer.init_();
return trainer;
}
private constructor() {
this.worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' });
this.worker.onmessage = (ev) => this.onMessage_(ev.data as WorkerResponse);
this.worker.onerror = (ev) => {
// Fail any pending requests.
for (const { reject } of this.pending.values()) reject(ev.message ?? 'worker error');
this.pending.clear();
};
}
private init_(): Promise<void> {
return new Promise((resolve, reject) => {
const handler = (ev: MessageEvent) => {
const msg = ev.data as WorkerResponse;
if (msg.kind === 'ready') {
this.worker.removeEventListener('message', handler);
resolve();
} else if (msg.kind === 'error') {
this.worker.removeEventListener('message', handler);
reject(new Error(msg.message));
}
};
this.worker.addEventListener('message', handler);
const seed = (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
this.worker.postMessage({ kind: 'init', seed } satisfies WorkerRequest);
});
}
train(args: TrainArgs): Promise<TrainResult> {
if (this.disposed) return Promise.reject(new Error('WasmTrainer disposed'));
const requestId = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(requestId, { resolve, reject });
const msg: WorkerRequest = {
kind: 'train',
requestId,
weights: args.weights,
features: args.features,
labels: args.labels,
sampleWeights: args.sampleWeights,
lr: args.lr,
maxIter: args.maxIter,
minErr: args.minErr,
inputSize: args.inputSize,
outputSize: args.outputSize,
};
// Transfer all the typed-array buffers we no longer need on the
// main thread; faster than copying. Caller has already cloned.
this.worker.postMessage(msg, [
args.weights.buffer,
args.features.buffer,
args.labels.buffer,
args.sampleWeights.buffer,
]);
});
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
try {
this.worker.postMessage({ kind: 'dispose' } satisfies WorkerRequest);
} catch {
/* ignore */
}
this.worker.terminate();
for (const { reject } of this.pending.values()) reject(new Error('disposed'));
this.pending.clear();
}
private onMessage_(msg: WorkerResponse): void {
if (msg.kind === 'result') {
const p = this.pending.get(msg.requestId);
if (p) {
this.pending.delete(msg.requestId);
p.resolve({ loss: msg.loss, weights: msg.weights, lossHistory: msg.lossHistory });
}
} else if (msg.kind === 'error') {
const p = this.pending.get(msg.requestId);
if (p) {
this.pending.delete(msg.requestId);
p.reject(new Error(msg.message));
}
}
// 'ready' handled in init_().
}
}
export function createTrainer(): Promise<WasmTrainer> {
return WasmTrainer.create();
}
// ---------------------------------------------------------------------------
// Worker-thread side
// ---------------------------------------------------------------------------
//
// When this module is loaded in a Worker, `self` is `WorkerGlobalScope` and
// `window` is undefined. We use that as the dispatch.
//
// We import the wasm via a same-origin fetch (no DOM available so we can't
// use a regular import URL — but Vite's bundler treats `new Worker(...)`
// specially and `?url` imports work for assets).
// Inside a Worker, `self` is a `DedicatedWorkerGlobalScope`. To keep TS
// happy in both build contexts we use a structural cast.
declare const self: {
postMessage: (msg: unknown, transfer?: Transferable[]) => void;
addEventListener: (event: string, handler: (ev: MessageEvent) => void) => void;
location: { origin: string };
importScripts?: unknown;
};
const isWorker =
typeof window === 'undefined' &&
typeof self !== 'undefined' &&
typeof (self as { importScripts?: unknown }).importScripts !== 'undefined';
if (isWorker) {
// Module-level state in worker scope.
let mod: NispsModule | null = null;
let mlHandle = 0;
let weightCount = 0;
// Heap buffers (allocated on first train).
let weightsPtr = 0;
let weightsViewLen = 0;
let featuresPtr = 0;
let featuresLen = 0;
let labelsPtr = 0;
let labelsLen = 0;
let sampleWeightsPtr = 0;
let sampleWeightsLen = 0;
async function loadModule(seed: number): Promise<void> {
// Same-origin fetch to /nisps.js. The worker is served by the dev
// server with COOP/COEP set, so this works.
const factoryMod = await import(/* @vite-ignore */ new URL('/nisps.js', self.location.origin).toString());
const factory: NispsModuleFactory =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(factoryMod as any).default ?? (factoryMod as any).createNispsModule;
mod = await factory({
locateFile: (path: string) => {
if (path.endsWith('.wasm')) return new URL('/nisps.wasm', self.location.origin).toString();
return path;
},
});
mlHandle = mod._nisps_ml_create(0, 0, 0, 0, seed >>> 0);
weightCount = mod._nisps_ml_weight_count(mlHandle);
}
function ensureBuffers(features: Float32Array, labels: Float32Array, sampleWeights: Float32Array, weights: Float32Array): void {
if (!mod) throw new Error('worker module not loaded');
if (weightsViewLen !== weightCount) {
if (weightsPtr) mod._free(weightsPtr);
weightsPtr = mod._malloc(weightCount * 4);
weightsViewLen = weightCount;
}
if (features.length !== featuresLen) {
if (featuresPtr) mod._free(featuresPtr);
featuresPtr = mod._malloc(features.length * 4);
featuresLen = features.length;
}
if (labels.length !== labelsLen) {
if (labelsPtr) mod._free(labelsPtr);
labelsPtr = mod._malloc(labels.length * 4);
labelsLen = labels.length;
}
if (sampleWeights.length !== sampleWeightsLen) {
if (sampleWeightsPtr) mod._free(sampleWeightsPtr);
sampleWeightsPtr = sampleWeights.length > 0 ? mod._malloc(sampleWeights.length * 4) : 0;
sampleWeightsLen = sampleWeights.length;
}
new Float32Array(mod.HEAPF32.buffer, weightsPtr, weightCount).set(weights);
new Float32Array(mod.HEAPF32.buffer, featuresPtr, features.length).set(features);
new Float32Array(mod.HEAPF32.buffer, labelsPtr, labels.length).set(labels);
if (sampleWeightsPtr) {
new Float32Array(mod.HEAPF32.buffer, sampleWeightsPtr, sampleWeights.length).set(sampleWeights);
}
}
function trainOnce(req: Extract<WorkerRequest, { kind: 'train' }>): WorkerResponse {
if (!mod) {
return { kind: 'error', requestId: req.requestId, message: 'worker not initialised' };
}
try {
ensureBuffers(req.features, req.labels, req.sampleWeights, req.weights);
// Push current weights into our MLP.
mod._nisps_ml_set_weights(mlHandle, weightsPtr);
// Seed the example ring buffer. We must clear first because past
// train calls may have left examples there.
mod._nisps_ml_clear_examples(mlHandle);
const inSz = req.inputSize;
const outSz = req.outputSize;
const n = req.features.length / inSz;
// Allocate small per-example scratch (re-used across iterations of
// this loop).
// We use stack-equivalents by allocating once, then shifting pointers.
for (let i = 0; i < n; ++i) {
const fPtr = featuresPtr + i * inSz * 4;
const lPtr = labelsPtr + i * outSz * 4;
mod._nisps_ml_add_example(mlHandle, fPtr, lPtr);
}
// Run training.
const swPtr = req.sampleWeights.length > 0 ? sampleWeightsPtr : 0;
const loss = mod._nisps_ml_train(mlHandle, req.lr, req.maxIter, req.minErr, swPtr);
// Read out final weights.
mod._nisps_ml_get_weights(mlHandle, weightsPtr);
const view = new Float32Array(mod.HEAPF32.buffer, weightsPtr, weightCount);
const outWeights = new Float32Array(view); // copy
// Loss history not yet plumbed via WASM; emit just final loss.
const lossHistory = new Float32Array([loss]);
return {
kind: 'result',
requestId: req.requestId,
loss,
weights: outWeights,
lossHistory,
};
} catch (err) {
return {
kind: 'error',
requestId: req.requestId,
message: err instanceof Error ? err.message : String(err),
};
}
}
function disposeModule(): void {
if (!mod) return;
if (mlHandle) {
mod._nisps_ml_destroy(mlHandle);
mlHandle = 0;
}
if (weightsPtr) { mod._free(weightsPtr); weightsPtr = 0; }
if (featuresPtr) { mod._free(featuresPtr); featuresPtr = 0; }
if (labelsPtr) { mod._free(labelsPtr); labelsPtr = 0; }
if (sampleWeightsPtr) { mod._free(sampleWeightsPtr); sampleWeightsPtr = 0; }
mod = null;
}
self.addEventListener('message', async (ev: MessageEvent<WorkerRequest>) => {
const req = ev.data;
if (req.kind === 'init') {
try {
await loadModule(req.seed);
self.postMessage({ kind: 'ready' } satisfies WorkerResponse);
} catch (err) {
self.postMessage({
kind: 'error',
requestId: 0,
message: err instanceof Error ? err.message : String(err),
} satisfies WorkerResponse);
}
} else if (req.kind === 'train') {
const res = trainOnce(req);
// Transfer weights back to main thread to avoid copy.
if (res.kind === 'result') {
self.postMessage(res, [res.weights.buffer, res.lossHistory.buffer]);
} else {
self.postMessage(res);
}
} else if (req.kind === 'dispose') {
disposeModule();
// Worker terminates from main thread side via .terminate().
}
});
}

View file

@ -1,50 +0,0 @@
/**
* BreakOrMode drum/breakbeat synthesis (xy_pad input, 56 outputs).
*/
import { Component } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { BreakorSchema } from './generated/breakor_schema';
export const BreakOrMode: Component = () => {
const schema = BreakorSchema;
const runtime = useModeRuntime(schema);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="Breakor settings"
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="Breakor pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Drag through drum space.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="var(--good)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default BreakOrMode;

View file

@ -1,48 +0,0 @@
/**
* C15Mode browser-only stub.
*
* The C15 (Nonlinear Labs C15) WASM synthesizer is not yet ported into the
* SolidJS playground; only firmware modes have schemas + WASM engines so
* far. This file exists so the mode switcher can list all eight firmware
* modes plus a "C15" entry that surfaces a clear "TODO" rather than 404'ing.
*
* Stream 11+ is expected to add a `c15` schema and wire the existing
* `playground/c15` directory into `nisps/wasm/engines/`.
*/
import { Component } from 'solid-js';
export const C15Mode: Component = () => {
return (
<section
style={{
display: 'flex',
'flex-direction': 'column',
'align-items': 'center',
gap: 'var(--sp-4)',
padding: 'var(--sp-6)',
margin: 'var(--sp-5) auto',
'max-width': '720px',
'background': 'var(--bg-1)',
border: '1px dashed var(--line-strong)',
'border-radius': 'var(--r-3)',
color: 'var(--fg)',
'text-align': 'center',
}}
>
<h2 style={{ color: 'var(--accent-2)', margin: 0 }}>C15 mode TODO</h2>
<p style={{ color: 'var(--fg-mute)', margin: 0 }}>
The C15 WASM synthesizer hasn't been ported into the SolidJS rewrite
yet. The legacy bridge lives in <code>playground/c15/</code> and
<code>playground/js/synth/c15-bridge.js</code>; stream 11+ will wrap
it as an AudioWorklet engine alongside the firmware-derived ones.
</p>
<p style={{ color: 'var(--fg-dim)', 'font-size': 'var(--fs-sm)', margin: 0 }}>
Until then, pick one of the firmware modes (PAFSynth, Channel Strip,
Verb FX, etc.) from the mode switcher above.
</p>
</section>
);
};
export default C15Mode;

View file

@ -1,53 +0,0 @@
/**
* ChannelStripMode channel-strip processor controlled by joystick.
*
* 24 outputs feed EQ / dynamics / gain. Settings drawer is the default
* SettingsDrawer (input/training/exploration/output/overrides/advanced).
*/
import { Component } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { ChannelStripSchema } from './generated/channel_strip_schema';
export const ChannelStripMode: Component = () => {
const schema = ChannelStripSchema;
const runtime = useModeRuntime(schema);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="Channel strip settings"
primaryInput={() => (
<>
<VirtualJoystick
size={260}
ariaLabel="Channel strip joystick"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Mix EQ + dynamics by moving the joystick.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="var(--accent-3)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default ChannelStripMode;

View file

@ -1,50 +0,0 @@
/**
* ElysiamorfMode granular / morphing synth (xy_pad input, 40 outputs).
*/
import { Component } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { ElysiamorfSchema } from './generated/elysiamorf_schema';
export const ElysiamorfMode: Component = () => {
const schema = ElysiamorfSchema;
const runtime = useModeRuntime(schema);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="Elysiamorf settings"
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="Elysiamorf pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Drag to morph through grain space.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="#ffb060"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default ElysiamorfMode;

View file

@ -1,54 +0,0 @@
/**
* MEMLCeliumMode uSEQ-Celium dual-MLP CV/gate output (56 outputs).
*/
import { Component, createSignal } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { MemlceliumSchema } from './generated/memlcelium_schema';
export const MEMLCeliumMode: Component = () => {
const schema = MemlceliumSchema;
const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0);
return (
<ModeShell
schema={schema}
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
drawerTitle="MEMLCelium settings"
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="MEMLCelium pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Sculpt voice + CV/gate. CV/gate streaming over USB serial requires
firmware (browser preview only).
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="#5b9eef"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default MEMLCeliumMode;

View file

@ -1,162 +0,0 @@
.shell {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: var(--sp-4);
min-height: 100%;
padding: var(--sp-4);
background: var(--bg);
color: var(--fg);
}
.header {
display: flex;
align-items: center;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--line);
}
.title {
font-size: var(--fs-lg);
font-weight: 600;
color: var(--accent);
margin: 0;
}
.subtitle {
font-size: var(--fs-sm);
color: var(--fg-mute);
margin: 0;
}
.audioToggle {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.audioBtn {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-sm);
color: var(--fg);
cursor: pointer;
}
.audioBtn.on {
border-color: var(--accent);
color: var(--accent);
}
.audioBtn:hover {
background: var(--bg-3);
}
.voiceSpaces {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.voiceSpacesLabel {
font-size: var(--fs-xs);
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.body {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(280px, 380px);
gap: var(--sp-4);
align-items: start;
}
@media (max-width: 900px) {
.body {
grid-template-columns: 1fr;
}
}
.primaryArea {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
min-height: 280px;
}
.outputArea {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
min-height: 200px;
}
.controls {
display: flex;
gap: var(--sp-4);
flex-wrap: wrap;
align-items: stretch;
padding: var(--sp-3);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
}
.controlAxes {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--sp-3);
}
.trainingPanel {
flex: 0 0 auto;
}
.drawerToggleBar {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
}
.drawerToggleBtn {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
font-size: var(--fs-sm);
color: var(--fg-mute);
}
.drawerToggleBtn:hover {
color: var(--fg);
background: var(--bg-3);
}
.statusLine {
font-size: var(--fs-xs);
color: var(--fg-dim);
font-family: var(--font-mono);
}
.frozen {
color: var(--accent-2);
}
.notReady {
color: var(--warn);
}

View file

@ -1,300 +0,0 @@
/**
* ModeShell common scaffolding shared by every concrete mode.
*
* Provides:
* - Header (mode name, voice space pill, audio start/stop, drawer button).
* - Primary input area (mode-supplied) + JoyMap navigator on its right.
* - Output area (mode-supplied).
* - Control axes bar (Boldness/Memory/Precision wired to controlStore).
* - Training controls undo, A/B, region/param pin all wired through.
* - SettingsDrawer (collapsible sections, per-param overrides, advanced).
*
* Modes only have to author the primary input + output JSX. Everything
* else is owned here so behaviour stays consistent across modes.
*/
import { Component, createSignal, JSX, Show, For, createMemo } from 'solid-js';
import { TrainingControls } from '../primitives/TrainingControls';
import { ControlAxis } from '../primitives/ControlAxis';
import { PillToggle } from '../primitives/PillToggle';
import { Drawer } from '../primitives/Drawer';
import { JoyMap } from '../primitives/JoyMap';
import { controlStore } from '../stores/control-store';
import { inputStore } from '../stores/input-store';
import { sessionStore } from '../stores/session-store';
import { explorationStore } from '../stores/exploration-store';
import type { ModeRuntime } from './mode-runtime';
import type { ModeSchema } from './generated';
import { SettingsDrawer } from './SettingsDrawer';
import styles from './ModeShell.module.css';
export interface ModeShellProps {
schema: ModeSchema;
runtime: ModeRuntime;
/** Primary input renderer (joystick / xy-pad / etc.). */
primaryInput: () => JSX.Element;
/** Output / visualisation area. */
outputArea: () => JSX.Element;
/** Optional drawer body for mode-specific settings (replaces default). */
drawerContent?: () => JSX.Element;
drawerTitle?: string;
/** Active voice space index (only used if schema.voice_spaces is non-empty). */
activeVoiceSpace?: () => number;
onVoiceSpaceChange?: (idx: number) => void;
}
export const ModeShell: Component<ModeShellProps> = (props) => {
const [drawerOpen, setDrawerOpen] = createSignal(false);
const [snapshotPopupOpen, setSnapshotPopupOpen] = createSignal(false);
const showVoiceSpaces = () =>
props.schema.ui.show_voice_space_selector && props.schema.voice_spaces.length > 0;
const voiceSpaceOptions = () =>
props.schema.voice_spaces.map((label, idx) => ({
value: String(idx),
label,
}));
const noiseRings = createMemo(() => {
// Outer = noiseCap, inner = noiseLevel — both as fractions of half-window.
const cap = explorationStore.state.noiseCap;
const cur = explorationStore.state.noiseLevel;
if (cap <= 0) return null;
return [cap * 0.4, cur * 0.4] as const;
});
return (
<section class={styles.shell} aria-label={`${props.schema.mode_id} mode`}>
<header class={styles.header}>
<div>
<h2 class={styles.title}>{formatModeName(props.schema.mode_id)}</h2>
<p class={styles.subtitle}>
{props.schema.ml.input_size} in {props.schema.ml.output_size} out
{' · '}
engine: <code>{props.schema.engine_id}</code>
</p>
</div>
<Show when={showVoiceSpaces()}>
<div class={styles.voiceSpaces}>
<span class={styles.voiceSpacesLabel}>Voice space</span>
<PillToggle
options={voiceSpaceOptions()}
value={() => String(props.activeVoiceSpace?.() ?? 0)}
onChange={(v) => props.onVoiceSpaceChange?.(Number(v))}
ariaLabel="Voice space"
/>
</div>
</Show>
<div class={styles.audioToggle}>
<Show
when={props.schema.ui.primary_input === 'audio_in'}
>
<Show
when={props.runtime.mic.started()}
fallback={
<button
type="button"
class={styles.audioBtn}
onClick={() => void props.runtime.mic.start()}
aria-label="Enable microphone"
>🎤 Mic</button>
}
>
<button
type="button"
class={`${styles.audioBtn} ${styles.on}`}
onClick={() => void props.runtime.mic.stop()}
aria-label="Disable microphone"
>🎤 Mic on</button>
</Show>
</Show>
<Show
when={props.runtime.audio.started()}
fallback={
<button
type="button"
class={styles.audioBtn}
onClick={() => void props.runtime.audio.start()}
aria-label="Start audio engine"
> Start audio</button>
}
>
<button
type="button"
class={`${styles.audioBtn} ${styles.on}`}
onClick={() => void props.runtime.audio.stop()}
aria-label="Stop audio engine"
> Stop audio</button>
</Show>
<button
type="button"
class={styles.drawerToggleBtn}
onClick={() => setDrawerOpen(true)}
aria-label="Open settings drawer"
></button>
</div>
</header>
<div class={styles.body}>
<div class={styles.primaryArea}>
{props.primaryInput()}
<JoyMap
size={150}
zoom={() => inputStore.config.zoom}
anchor={() => [
inputStore.config.anchorMode === 'center' ? 0.5 : inputStore.config.anchorX,
inputStore.config.anchorMode === 'center' ? 0.5 : inputStore.config.anchorY,
]}
position={props.runtime.pipedInput}
trail={props.runtime.trail}
regionPins={() => sessionStore.state.regionPins}
noiseRings={noiseRings as () => readonly [number, number] | null}
frozen={props.runtime.frozen}
onTrailTap={(p) => props.runtime.snapToTrail(p)}
onLongPress={() => props.runtime.pinCurrentRegion()}
ariaLabel="Joy map navigator"
/>
</div>
<div class={styles.outputArea}>{props.outputArea()}</div>
</div>
<div class={styles.controls}>
<div class={styles.controlAxes}>
<For each={AXES}>
{(axis) => (
<ControlAxis
label={axis.label}
endpoints={axis.endpoints}
value={() => controlStore.state[axis.key]}
onChange={(v) => controlStore.setAxis(axis.key, v)}
preset={() => controlStore.state.presetId}
onDoubleTap={() => controlStore.clearOffsets(axis.key)}
/>
)}
</For>
</div>
<div class={styles.trainingPanel}>
<TrainingControls
onTrain={() => props.runtime.trainOnCurrent()}
onRandomize={() => props.runtime.randomize()}
onThumbsUp={() => props.runtime.thumbsUp()}
onThumbsDown={() => props.runtime.thumbsDown()}
onUndo={() => {
// Click = pop one snapshot. Long-press = open list popup.
props.runtime.undo();
}}
exampleCount={() => props.runtime.training.examples()}
lastLoss={() => props.runtime.training.lastLoss()}
busy={() => props.runtime.training.busy()}
canUndo={() => props.runtime.canUndo()}
/>
<Show when={sessionStore.state.snapshots.length > 0}>
<button
type="button"
class={styles.drawerToggleBtn}
onClick={() => setSnapshotPopupOpen((b) => !b)}
aria-label="Show snapshot history"
title={`History (${sessionStore.state.snapshots.length})`}
style={{ 'margin-top': 'var(--sp-1)' }}
>📚 {sessionStore.state.snapshots.length}</button>
</Show>
</div>
</div>
<p class={styles.statusLine}>
<Show when={!props.runtime.ready()}>
<span class={styles.notReady}>Loading WASM ML</span>{' '}
</Show>
<Show when={props.runtime.frozen()}>
<span class={styles.frozen}>frozen</span>{' '}
</Show>
<span>
input ({props.runtime.pipedInput()[0].toFixed(2)},
{' '}
{props.runtime.pipedInput()[1].toFixed(2)})
{' · '}
noise {explorationStore.state.noiseLevel.toFixed(3)}
</span>
</p>
<Drawer
open={drawerOpen()}
onClose={() => setDrawerOpen(false)}
side="right"
title={props.drawerTitle ?? 'Mode settings'}
width={460}
>
<Show
when={props.drawerContent}
fallback={
<SettingsDrawer schema={props.schema} runtime={props.runtime} />
}
>
{props.drawerContent!()}
</Show>
</Drawer>
{/* Snapshot list popup (long-press undo equivalent) */}
<Drawer
open={snapshotPopupOpen()}
onClose={() => setSnapshotPopupOpen(false)}
side="right"
title="Snapshot history"
width={320}
>
<ul style={{ 'list-style': 'none', padding: 0, margin: 0, display: 'flex', 'flex-direction': 'column', gap: 'var(--sp-1)' }}>
<For each={sessionStore.listSnapshots().slice().reverse()}>{(s) => (
<li>
<button
type="button"
onClick={() => {
const map = sessionStore.state.snapshots;
const idx = map.findIndex((m) => m.id === s.id);
if (idx < 0) return;
for (let i = map.length - 1; i > idx; i--) sessionStore.popSnapshot();
props.runtime.undo();
setSnapshotPopupOpen(false);
}}
style={{
display: 'flex',
'flex-direction': 'column',
width: '100%',
background: 'var(--bg-2)',
border: '1px solid var(--line)',
'border-radius': 'var(--r-1)',
padding: 'var(--sp-1) var(--sp-2)',
'text-align': 'left',
cursor: 'pointer',
color: 'var(--fg)',
}}
>
<span style={{ 'font-family': 'var(--font-mono)', 'font-size': 'var(--fs-sm)' }}>{s.tag}</span>
<span style={{ 'font-family': 'var(--font-mono)', 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
noise {s.noiseLevel.toFixed(3)}
{s.zoomLevel !== null ? ` · zoom ${s.zoomLevel.toFixed(2)}` : ''}
</span>
</button>
</li>
)}</For>
</ul>
</Drawer>
</section>
);
};
const AXES = [
{ key: 'boldness' as const, label: 'Boldness', endpoints: ['Caution', 'Bold'] as const },
{ key: 'memory' as const, label: 'Memory', endpoints: ['Amnesia', 'Elephant'] as const },
{ key: 'precision' as const, label: 'Precision', endpoints: ['Raw', 'Precise'] as const },
];
function formatModeName(id: string): string {
return id
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export default ModeShell;

View file

@ -1,43 +0,0 @@
.bar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-4);
background: var(--bg-1);
border-bottom: 1px solid var(--line);
}
.label {
font-size: var(--fs-xs);
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.select {
background: var(--bg-2);
color: var(--fg);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-1) var(--sp-3);
font-family: var(--font-mono);
font-size: var(--fs-sm);
cursor: pointer;
}
.select:hover {
border-color: var(--line-strong);
}
.select:focus {
outline: 1px solid var(--accent);
}
.description {
font-size: var(--fs-xs);
color: var(--fg-dim);
}
.placeholder {
color: var(--warn);
}

View file

@ -1,54 +0,0 @@
/**
* ModeSwitcher top-level select that picks the active mode.
*
* Writes through `modeStore.switchMode(id)` so persistence + the bus event
* fire correctly. Reads the current selection back from the store so it
* stays in sync with persisted state on first paint.
*
* Audio engine switching is handled inside the mode runtime (each mode
* routes its own engine_id through `EngineHost.setEngine` when started).
*/
import { Component, createMemo, For, Show } from 'solid-js';
import { modeStore } from '../stores/mode-store';
import { MODE_REGISTRY, getModeById } from './index';
import styles from './ModeSwitcher.module.css';
export const ModeSwitcher: Component = () => {
const activeId = () => modeStore.state.activeModeId ?? MODE_REGISTRY[0]!.id;
const active = createMemo(() => getModeById(activeId()));
return (
<div class={styles.bar} role="region" aria-label="Mode selector">
<span class={styles.label}>Mode</span>
<select
class={styles.select}
value={activeId()}
onChange={(e) => modeStore.switchMode(e.currentTarget.value)}
aria-label="Select active mode"
>
<For each={MODE_REGISTRY}>
{(m) => (
<option value={m.id}>
{m.label}
{m.placeholder ? ' (TODO)' : ''}
</option>
)}
</For>
</select>
<span
class={styles.description}
classList={{ [styles.placeholder]: !!active().placeholder }}
>
{active().description}
</span>
<Show when={active().placeholder}>
<span class={styles.placeholder} aria-hidden="true">
</span>
</Show>
</div>
);
};
export default ModeSwitcher;

View file

@ -1,59 +0,0 @@
/**
* PAFSynthMode Phase Aligned Formant synth (XY pad input, 33 outputs).
*
* Uses the xy_pad as primary input (as per schema). Voice spaces are
* exposed via the shell header. A drawer renders the SliderBank for
* monitoring (and eventually editing) per-parameter values.
*/
import { Component, createSignal } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { PafSynthSchema } from './generated/paf_synth_schema';
export const PAFSynthMode: Component = () => {
const schema = PafSynthSchema;
const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0);
return (
<ModeShell
schema={schema}
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
drawerTitle="PAF synth settings"
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="PAF synth control pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Drag to sculpt formants. Voice space:&nbsp;
<strong>{schema.voice_spaces[voiceSpace()] ?? 'Default'}</strong>
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="var(--accent)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default PAFSynthMode;

View file

@ -1,118 +0,0 @@
/**
* SLPWorkshopMode the Synth Library Portland workshop instrument.
*
* Reuses the MEMLCelium engine and MLP shape verbatim (so the synth voice is
* identical), but foregrounds the adaptive-learning gestures ported from
* upstream InterfaceRL and now living in the shared core:
* - Jolt: hold to continuously morph the network's weights live, release to
* freeze (nisps/ml/jolt.hpp).
* - Explore: an Ornstein-Uhlenbeck random walk on the output that makes the
* sound slowly roam so likes/dislikes can steer it (nisps/ml/ou_noise.hpp).
*/
import { Component, createSignal } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { Slider } from '../primitives/Slider';
import { SlpWorkshopSchema } from './generated/slp_workshop_schema';
export const SLPWorkshopMode: Component = () => {
const schema = SlpWorkshopSchema;
const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0);
const [exploreLevel, setExploreLevel] = createSignal(0);
const setExplore = (v: number): void => {
setExploreLevel(v);
runtime.explore.setIntensity(v);
};
return (
<ModeShell
schema={schema}
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
drawerTitle="SLP-Workshop settings"
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="SLP-Workshop pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Synth Library Portland workshop instrument MEMLCelium voice with
live Jolt + Explore learning controls.
</span>
{/* Adaptive-learning gestures. */}
<div
style={{
display: 'flex',
'flex-direction': 'column',
gap: 'var(--sp-2)',
width: '280px',
'margin-top': 'var(--sp-2)',
}}
>
{/* Jolt — hold to morph weights live, release to freeze. */}
<button
type="button"
aria-label="Jolt — hold to morph the network's weights"
aria-pressed={runtime.jolt.active()}
onPointerDown={(e) => {
e.preventDefault();
runtime.jolt.press();
}}
onPointerUp={() => runtime.jolt.release()}
onPointerLeave={() => runtime.jolt.release()}
onPointerCancel={() => runtime.jolt.release()}
style={{
padding: 'var(--sp-2)',
'border-radius': 'var(--r-1)',
border: '1px solid var(--line)',
background: runtime.jolt.active() ? 'var(--accent, #ef9e5b)' : 'var(--bg-2)',
color: runtime.jolt.active() ? '#000' : 'var(--fg)',
'font-weight': 600,
cursor: 'pointer',
'touch-action': 'none',
'user-select': 'none',
}}
>
Jolt {runtime.jolt.active() ? '(hold…)' : '(hold)'}
</button>
{/* Explore — OU random-walk exploration intensity. */}
<Slider
label="Explore"
ariaLabel="Explore — exploration random-walk intensity"
min={0}
max={1}
value={exploreLevel()}
onChange={setExplore}
decimals={2}
/>
</div>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="#ef9e5b"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default SLPWorkshopMode;

View file

@ -1,250 +0,0 @@
.wrap {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.section {
border-top: 1px solid var(--line);
padding-top: var(--sp-2);
}
.section:first-of-type {
border-top: none;
padding-top: 0;
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
background: transparent;
border: none;
padding: var(--sp-2) 0;
font-size: var(--fs-sm);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-mute);
cursor: pointer;
text-align: left;
font-family: var(--font-mono);
}
.sectionHeader:hover {
color: var(--fg);
}
.caret {
color: var(--fg-dim);
font-size: var(--fs-xs);
width: 12px;
display: inline-block;
}
.badge {
margin-left: auto;
font-size: var(--fs-xs);
background: var(--bg-3);
color: var(--fg-mute);
padding: 1px 6px;
border-radius: var(--r-1);
font-family: var(--font-mono);
}
.body {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-2) 0 var(--sp-3);
}
.row {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.fieldLabel {
font-size: var(--fs-xs);
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: 0.06em;
min-width: 90px;
}
.checkbox {
display: flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--fs-sm);
color: var(--fg);
cursor: pointer;
}
.subhead {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-mute);
margin: var(--sp-2) 0 var(--sp-1);
}
.muted {
color: var(--fg-dim);
font-size: var(--fs-xs);
font-family: var(--font-mono);
}
.btnAlt {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-1) var(--sp-3);
font-size: var(--fs-sm);
color: var(--fg);
cursor: pointer;
}
.btnAlt:hover:not(:disabled) {
background: var(--bg-3);
}
.btnAlt:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnTiny {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: 2px 8px;
font-size: var(--fs-xs);
color: var(--fg-mute);
cursor: pointer;
}
.btnTiny:hover {
color: var(--fg);
background: var(--bg-3);
}
.snapshotList,
.presetList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 220px;
overflow-y: auto;
}
.snapshotBtn {
display: flex;
flex-direction: column;
width: 100%;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-1) var(--sp-2);
text-align: left;
cursor: pointer;
color: var(--fg);
}
.snapshotBtn:hover {
background: var(--bg-3);
}
.snapshotTag {
font-size: var(--fs-sm);
font-family: var(--font-mono);
}
.snapshotMeta {
font-size: var(--fs-xs);
color: var(--fg-mute);
font-family: var(--font-mono);
}
.presetRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1);
border-bottom: 1px dashed var(--line);
}
.presetRow > span {
flex: 1;
font-size: var(--fs-sm);
}
.paramList {
display: flex;
flex-direction: column;
gap: var(--sp-2);
max-height: 360px;
overflow-y: auto;
}
.layerStats {
display: flex;
flex-direction: column;
gap: var(--sp-1);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.layerStatRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 2px var(--sp-2);
background: var(--bg-2);
border-radius: var(--r-1);
}
.layerStatLabel {
color: var(--accent);
font-weight: 600;
min-width: 24px;
}
.layerStatValue {
flex: 1;
color: var(--fg-mute);
}
.layerStatStatus {
font-size: 10px;
text-transform: uppercase;
padding: 1px 6px;
border-radius: var(--r-1);
background: var(--bg-3);
}
.layerStatStatus[data-status="dead"] { color: #999; }
.layerStatStatus[data-status="saturating"] { color: var(--warn); }
.layerStatStatus[data-status="healthy"] { color: var(--good); }
.textInput {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
color: var(--fg);
font-family: var(--font-mono);
font-size: var(--fs-sm);
padding: var(--sp-1) var(--sp-2);
flex: 1;
}
.hr {
border: none;
border-top: 1px dashed var(--line);
margin: var(--sp-2) 0;
}

View file

@ -1,656 +0,0 @@
/**
* SettingsDrawer settings panel rendered inside ModeShell's drawer.
*
* Sections:
* - Input: deadzone, curve, smoothing, momentum, anchor mode, invert
* - Training: learning rate, weight decay
* - Exploration: spread, noise floor/cap/growth/decay,
* auto-explore (toggle, interval, intensity)
* - Output: global curve, smoothing, slew rate, freeze
* - Per-param overrides: ParamEditor list
* - Advanced: weight health histogram, gradient flow, session presets
*
* Sections are collapsible. Per-param-override editor renders inline (also
* accessible via double-tap on slider in the live readout wired in
* ModeShell). The drawer is a child of `ModeShell`'s `drawerContent` slot.
*/
import { Component, createSignal, For, Show, createMemo, createEffect } from 'solid-js';
import { ParamEditor, type ParamOverride as PrimitiveParamOverride } from '../primitives/ParamEditor';
import { Slider } from '../primitives/Slider';
import { PillToggle } from '../primitives/PillToggle';
import { WeightHealth } from '../primitives/WeightHealth';
import { GradientFlow } from '../primitives/GradientFlow';
import { Heatmap } from '../primitives/Heatmap';
import { ProgressRing } from '../primitives/ProgressRing';
import { LossPlot } from '../primitives/LossPlot';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { explorationStore } from '../stores/exploration-store';
import { modeStore, defaultParamOverride, type ParamOverride } from '../stores/mode-store';
import { sessionStore } from '../stores/session-store';
import { mlStore } from '../stores/ml-store';
import { coreBus } from '../stores/bus';
import {
ZOOM_MIN,
ZOOM_MAX,
DEADZONE_MAX,
INPUT_CURVE_MIN,
INPUT_CURVE_MAX,
SMOOTHING_MAX,
} from '../input/pipeline';
import { GLOBAL_CURVE_MIN, GLOBAL_CURVE_MAX } from '../output/pipeline';
import {
layerNorms,
gradientStatuses,
weightHistogram,
weightStatus,
layerStatsToStatus,
layerWeightCounts,
} from '../features/weight-health';
import { saveNamedPreset, loadNamedPreset, buildShareUrl } from '../features/session-preset';
import { captureA, toggleAB, acceptB, revertToA } from '../features/snapshots';
import type { ModeRuntime } from './mode-runtime';
import type { ModeSchema } from './generated';
import type { Param } from './generated/types';
import type { CurveName } from '../output/curves';
import type { HeatmapColorMode } from '../features/heatmap-sampler';
import styles from './SettingsDrawer.module.css';
export interface SettingsDrawerProps {
schema: ModeSchema;
runtime: ModeRuntime;
/** Show advanced section (weight health, gradient flow, session presets). */
showAdvanced?: boolean;
}
interface Section {
id: string;
title: string;
defaultOpen?: boolean;
render: () => any;
}
export const SettingsDrawer: Component<SettingsDrawerProps> = (props) => {
const [open, setOpen] = createSignal<Record<string, boolean>>({
input: false,
training: false,
exploration: false,
output: false,
overrides: false,
advanced: false,
snapshots: false,
});
const toggle = (id: string) => setOpen((s) => ({ ...s, [id]: !s[id] }));
// ----- Per-param overrides UI ----------------------------------------
const overrides = () => modeStore.state.overrides[props.schema.mode_id] ?? {};
const getOverride = (param: Param): PrimitiveParamOverride => {
const cur = overrides()[param.name];
if (cur) {
return {
min: cur.min,
max: cur.max,
curve: cur.curve as CurveName,
curveParam: cur.curveParam,
muted: cur.muted,
pinned: cur.pinned,
fixedValue: cur.fixedValue,
};
}
return {
min: param.min,
max: param.max,
curve: param.curve as CurveName,
muted: false,
pinned: false,
fixedValue: param.default,
};
};
const writeOverride = (param: Param, next: PrimitiveParamOverride) => {
const stored: ParamOverride = {
min: next.min,
max: next.max,
curve: next.curve,
curveParam: next.curveParam,
muted: next.muted,
pinned: next.pinned,
fixedValue: next.fixedValue,
// Frozen flag is local to the runtime (set via output-store freezeMask)
// — keep it false in the persistent override unless the user explicitly
// toggles it.
frozen: overrides()[param.name]?.frozen ?? false,
};
modeStore.setOverride(props.schema.mode_id, param.name, stored);
};
// ----- Per-output health (advanced section) -----------------------------
const [advancedExpanded, setAdvancedExpanded] = createSignal(props.showAdvanced ?? false);
const histogram = createMemo<number[]>(() => {
const w = mlStore.weights();
return weightHistogram(w);
});
const status = createMemo(() => weightStatus(mlStore.weights()));
// Gradient flow: capture before/after weights on training events.
const [normHistory, setNormHistory] = createSignal<number[]>([]);
const [gradStatuses, setGradStatuses] = createSignal<ReturnType<typeof gradientStatuses>>([]);
let lastBeforeWeights: Float32Array | null = null;
const offTrainStart = coreBus.on('snap.push', (e) => {
if (e.tag === 'before train' && mlStore.iml) {
lastBeforeWeights = mlStore.getWeights();
}
});
const offTrainEnd = coreBus.on('ml.trained', () => {
if (lastBeforeWeights && mlStore.iml) {
const after = mlStore.getWeights();
const arch = mlStore.iml.architecture;
const sizes = layerWeightCounts({
inputSize: arch.inputSize,
hidden: arch.hidden,
outputSize: arch.outputSize,
});
const norms = layerNorms(lastBeforeWeights, after, sizes);
setNormHistory(norms);
setGradStatuses(gradientStatuses(norms));
lastBeforeWeights = null;
}
});
// Session-preset save / share UI state
const [presetName, setPresetName] = createSignal('');
const [shareUrl, setShareUrl] = createSignal('');
// Snapshot list (long-press-undo equivalent)
const snapshots = () => sessionStore.listSnapshots();
// Cleanup on unmount (Solid handles via owner; we only need to detach bus
// subs explicitly because they live across renders).
// We can't use onCleanup here because this component is recreated per-mode,
// but bus.on returns an unsubscribe. Defer cleanup to when the component
// unmounts via createEffect cleanup function:
createEffect(() => {
return () => { offTrainStart(); offTrainEnd(); };
});
// ----- A/B compare visibility ------------------------------------------
const ab = () => sessionStore.state.ab;
const live = () => ab().live;
// ---------------------------------------------------------------------
// Render helpers
// ---------------------------------------------------------------------
const SectionHeader = (h: { id: string; title: string; count?: number }) => (
<button
type="button"
class={styles.sectionHeader}
onClick={() => toggle(h.id)}
aria-expanded={!!open()[h.id]}
>
<span class={styles.caret}>{open()[h.id] ? '▾' : '▸'}</span>
<span>{h.title}</span>
<Show when={h.count !== undefined}><span class={styles.badge}>{h.count}</span></Show>
</button>
);
return (
<div class={styles.wrap}>
{/* Snapshots / undo / A/B */}
<section class={styles.section}>
<SectionHeader id="snapshots" title="History (snapshots, A/B)" count={snapshots().length} />
<Show when={open().snapshots}>
<div class={styles.body}>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
disabled={!props.runtime.canUndo()}
onClick={() => props.runtime.undo()}
>Undo last</button>
<button
type="button"
class={styles.btnAlt}
onClick={() => sessionStore.clearSnapshots()}
>Clear stack</button>
</div>
<Show when={snapshots().length > 0}>
<ul class={styles.snapshotList}>
<For each={snapshots().slice().reverse()}>{(s) => (
<li>
<button
type="button"
class={styles.snapshotBtn}
onClick={() => {
const ok = props.runtime.undo();
if (!ok) {
// Restore by id instead.
const map = sessionStore.state.snapshots;
const idx = map.findIndex((m) => m.id === s.id);
if (idx >= 0) {
// walk back N times to make it the top of the stack
for (let i = map.length - 1; i > idx; i--) sessionStore.popSnapshot();
props.runtime.undo();
}
}
}}
>
<span class={styles.snapshotTag}>{s.tag}</span>
<span class={styles.snapshotMeta}>
noise {s.noiseLevel.toFixed(3)}
{s.zoomLevel !== null ? ` · zoom ${s.zoomLevel.toFixed(2)}` : ''}
</span>
</button>
</li>
)}</For>
</ul>
</Show>
<hr class={styles.hr} />
<h4 class={styles.subhead}>A / B compare</h4>
<div class={styles.row}>
<Show when={!ab().a} fallback={
<span class={styles.muted}>active: <strong>{live()}</strong></span>
}>
<span class={styles.muted}>no capture yet</span>
</Show>
</div>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
onClick={() => captureA()}
>Capture A</button>
<button
type="button"
class={styles.btnAlt}
disabled={!ab().a}
onClick={() => toggleAB()}
>Toggle A/B</button>
<button
type="button"
class={styles.btnAlt}
disabled={!ab().a}
onClick={() => acceptB()}
>Accept B</button>
<button
type="button"
class={styles.btnAlt}
disabled={!ab().a}
onClick={() => revertToA()}
>Revert to A</button>
</div>
</div>
</Show>
</section>
{/* Input pipeline */}
<section class={styles.section}>
<SectionHeader id="input" title="Input pipeline" />
<Show when={open().input}>
<div class={styles.body}>
<Slider
label="Zoom" min={ZOOM_MIN} max={ZOOM_MAX}
value={inputStore.config.zoom}
onChange={(v) => inputStore.setZoom(v)}
/>
<Slider
label="Deadzone" min={0} max={DEADZONE_MAX}
value={inputStore.config.deadzone}
onChange={(v) => inputStore.setDeadzone(v)}
/>
<Slider
label="Input curve" min={INPUT_CURVE_MIN} max={INPUT_CURVE_MAX}
value={inputStore.config.inputCurve}
onChange={(v) => inputStore.setInputCurve(v)}
/>
<Slider
label="Smoothing" min={0} max={SMOOTHING_MAX}
value={inputStore.config.smoothing}
onChange={(v) => inputStore.setSmoothing(v)}
/>
<div class={styles.row}>
<label class={styles.fieldLabel}>Anchor mode</label>
<PillToggle
options={[
{ value: 'auto', label: 'Auto' },
{ value: 'sticky', label: 'Sticky' },
{ value: 'center', label: 'Center' },
]}
value={() => inputStore.config.anchorMode}
onChange={(v) => inputStore.setAnchorMode(v as 'auto' | 'sticky' | 'center')}
ariaLabel="Anchor mode"
/>
</div>
<div class={styles.row}>
<label class={styles.fieldLabel}>Momentum zoom</label>
<PillToggle
options={[
{ value: 'off', label: 'Off' },
{ value: 'gentle', label: 'Gentle' },
{ value: 'strong', label: 'Strong' },
]}
value={() => inputStore.config.momentumZoom}
onChange={(v) => inputStore.setMomentumZoom(v as 'off' | 'gentle' | 'strong')}
ariaLabel="Momentum zoom"
/>
</div>
<div class={styles.row}>
<label class={styles.checkbox}>
<input
type="checkbox"
checked={inputStore.config.invertX}
onChange={(e) => inputStore.setInvert(e.currentTarget.checked, inputStore.config.invertY)}
/>
Invert X
</label>
<label class={styles.checkbox}>
<input
type="checkbox"
checked={inputStore.config.invertY}
onChange={(e) => inputStore.setInvert(inputStore.config.invertX, e.currentTarget.checked)}
/>
Invert Y
</label>
</div>
</div>
</Show>
</section>
{/* Training */}
<section class={styles.section}>
<SectionHeader id="training" title="Training" />
<Show when={open().training}>
<div class={styles.body}>
<Slider
label="Learning rate" min={0.01} max={5}
value={explorationStore.state.learningRate}
onChange={(v) => explorationStore.setLearningRate(v)}
/>
<Slider
label="Weight decay" min={0} max={0.3}
value={explorationStore.state.weightDecay}
onChange={(v) => explorationStore.setWeightDecay(v)}
/>
<LossPlot history={() => mlStore.state.lossHistory} width={320} height={70} />
</div>
</Show>
</section>
{/* Exploration */}
<section class={styles.section}>
<SectionHeader id="exploration" title="Exploration / RL" />
<Show when={open().exploration}>
<div class={styles.body}>
<Slider
label="Spread" min={0} max={1}
value={explorationStore.state.spread}
onChange={(v) => explorationStore.setSpread(v)}
/>
<Slider
label="Noise floor" min={0} max={0.5}
value={explorationStore.state.noiseFloor}
onChange={(v) => explorationStore.setNoiseFloor(v)}
/>
<Slider
label="Noise cap" min={explorationStore.state.noiseFloor} max={1}
value={explorationStore.state.noiseCap}
onChange={(v) => explorationStore.setNoiseCap(v)}
/>
<Slider
label="Noise growth" min={1} max={3}
value={explorationStore.state.noiseGrowth}
onChange={(v) => explorationStore.setNoiseGrowth(v)}
/>
<Slider
label="Noise decay" min={0.7} max={1}
value={explorationStore.state.noiseDecay}
onChange={(v) => explorationStore.setNoiseDecay(v)}
/>
<hr class={styles.hr} />
<h4 class={styles.subhead}>Auto-explore</h4>
<div class={styles.row}>
<label class={styles.checkbox}>
<input
type="checkbox"
checked={explorationStore.state.autoExploreEnabled}
onChange={(e) => explorationStore.setAutoExplore(e.currentTarget.checked)}
/>
Enable auto-explore
</label>
<Show when={explorationStore.state.autoExploreEnabled}>
<ProgressRing
size={28}
showLabel={false}
progress={() => 1}
ariaLabel="Auto-explore active"
/>
</Show>
</div>
<Slider
label="Interval (ms)" min={500} max={10000} step={100}
value={explorationStore.state.autoExploreIntervalMs}
onChange={(v) => explorationStore.setAutoExploreInterval(v)}
/>
<Slider
label="Intensity" min={0.1} max={1}
value={explorationStore.state.autoExploreIntensity}
onChange={(v) => explorationStore.setAutoExploreIntensity(v)}
/>
</div>
</Show>
</section>
{/* Output */}
<section class={styles.section}>
<SectionHeader id="output" title="Output pipeline" />
<Show when={open().output}>
<div class={styles.body}>
<Slider
label="Global curve" min={GLOBAL_CURVE_MIN} max={GLOBAL_CURVE_MAX}
value={outputStore.config.globalCurve}
onChange={(v) => outputStore.setGlobalCurve(v)}
/>
<Slider
label="Output smoothing" min={0} max={0.95}
value={outputStore.config.smoothing}
onChange={(v) => outputStore.setSmoothing(v)}
/>
<Slider
label="Slew rate (per sec)" min={0.005} max={1} step={0.005}
value={isFinite(outputStore.config.slewRate) ? outputStore.config.slewRate : 1}
onChange={(v) => outputStore.setSlewRate(v)}
/>
<label class={styles.checkbox}>
<input
type="checkbox"
checked={outputStore.config.freezeOutput}
onChange={(e) => outputStore.setFreezeOutput(e.currentTarget.checked)}
/>
Freeze output
</label>
</div>
</Show>
</section>
{/* Per-param overrides */}
<section class={styles.section}>
<SectionHeader
id="overrides"
title="Parameter overrides"
count={Object.keys(overrides()).length}
/>
<Show when={open().overrides}>
<div class={styles.body}>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
onClick={() => modeStore.clearAllOverrides(props.schema.mode_id)}
>Clear all</button>
</div>
<div class={styles.paramList}>
<For each={props.schema.params}>{(p) => (
<ParamEditor
param={{
name: p.name,
label: p.label,
hardMin: p.min,
hardMax: p.max,
default: p.default,
curve: p.curve as CurveName,
group: p.group,
}}
override={() => getOverride(p)}
onChange={(next) => writeOverride(p, next)}
compact
/>
)}</For>
</div>
</div>
</Show>
</section>
{/* Advanced: weight health, gradient flow, heatmap */}
<section class={styles.section}>
<SectionHeader id="advanced" title="Advanced" />
<Show when={open().advanced}>
<div class={styles.body}>
<button
type="button"
class={styles.btnAlt}
onClick={() => setAdvancedExpanded((b) => !b)}
>{advancedExpanded() ? 'Hide diagnostics' : 'Show diagnostics'}</button>
<Show when={advancedExpanded()}>
<h4 class={styles.subhead}>Weight health</h4>
<WeightHealth
histogram={histogram}
status={status}
width={300}
height={60}
/>
<h4 class={styles.subhead}>Gradient flow (last train)</h4>
<Show
when={normHistory().length > 0}
fallback={<p class={styles.muted}>No training run yet.</p>}
>
<GradientFlow
layerNorms={normHistory}
status={gradStatuses}
width={300}
height={70}
/>
</Show>
<h4 class={styles.subhead}>Per-layer stats</h4>
<div class={styles.layerStats}>
<For each={mlStore.getLayerStatsRecords()}>{(stats, idx) => (
<div class={styles.layerStatRow}>
<span class={styles.layerStatLabel}>L{idx()}</span>
<span class={styles.layerStatValue}>
mean|w| {stats.meanAbs.toFixed(3)}
· max|w| {stats.maxAbs.toFixed(3)}
· {(stats.deadFrac * 100).toFixed(0)}% dead
· {(stats.saturatingFrac * 100).toFixed(0)}% sat
</span>
<span
class={styles.layerStatStatus}
data-status={layerStatsToStatus(stats)}
>{layerStatsToStatus(stats)}</span>
</div>
)}</For>
</div>
<h4 class={styles.subhead}>Input heatmap</h4>
<div class={styles.row}>
<label class={styles.fieldLabel}>Color</label>
<PillToggle
options={[
{ value: 'luminance', label: 'Lumi' },
{ value: 'variance', label: 'Var' },
{ value: 'divergence', label: 'Div' },
]}
value={() => props.runtime.heatmap.colorMode()}
onChange={(v) => props.runtime.heatmap.setColorMode(v as HeatmapColorMode)}
ariaLabel="Heatmap color mode"
/>
<button
type="button"
class={styles.btnAlt}
onClick={() => props.runtime.heatmap.refresh(true)}
>Refresh</button>
</div>
<Heatmap
samples={props.runtime.heatmap.cells}
resolution={props.runtime.heatmap.resolution()}
colorMode={props.runtime.heatmap.colorMode() as 'luminance' | 'variance' | 'divergence'}
size={240}
/>
</Show>
<hr class={styles.hr} />
<h4 class={styles.subhead}>Session preset</h4>
<div class={styles.row}>
<input
class={styles.textInput}
placeholder="preset name"
value={presetName()}
onInput={(e) => setPresetName(e.currentTarget.value)}
/>
<button
type="button"
class={styles.btnAlt}
disabled={!presetName().trim()}
onClick={() => {
const n = presetName().trim();
if (!n) return;
saveNamedPreset(n, false);
setPresetName('');
}}
>Save</button>
</div>
<Show when={sessionStore.state.presets.length > 0}>
<ul class={styles.presetList}>
<For each={sessionStore.state.presets}>{(p) => (
<li class={styles.presetRow}>
<span>{p.name}</span>
<button
type="button"
class={styles.btnTiny}
onClick={() => loadNamedPreset(p.id)}
>Load</button>
<button
type="button"
class={styles.btnTiny}
onClick={() => sessionStore.removePreset(p.id)}
>Del</button>
</li>
)}</For>
</ul>
</Show>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
onClick={() => {
setShareUrl(window.location.origin + window.location.pathname + buildShareUrl());
}}
>Build share URL</button>
<Show when={shareUrl()}>
<input
class={styles.textInput}
readOnly
value={shareUrl()}
onClick={(e) => e.currentTarget.select()}
/>
</Show>
</div>
</div>
</Show>
</section>
</div>
);
};
export default SettingsDrawer;

View file

@ -1,96 +0,0 @@
/**
* SoundAnalysisMIDIMode sound analysis MIDI CC output (audio_in input).
*
* Schema declares `primary_input: 'audio_in'` and `engine_id: 'thru'` (no
* synthesis). Stream 10 wires the mic into channels 2..5 via ModeShell's
* Mic button + the runtime's MicInput. The joystick is used as a fallback
* for channels 0..1 and during testing without mic permissions.
*
* MIDI output routing is read-only here the SettingsDrawer's per-param
* override editor lets users tighten the active range / mute params; the
* actual MIDI device wiring is left for the engine host to handle (or for
* a future stream that adds WebMIDI sender). For now CC values are
* visible in the live OutputDisplay.
*/
import { Component, Show } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { SoundAnalysisMidiSchema } from './generated/sound_analysis_midi_schema';
export const SoundAnalysisMIDIMode: Component = () => {
const schema = SoundAnalysisMidiSchema;
const runtime = useModeRuntime(schema);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="Sound analysis → MIDI settings"
primaryInput={() => (
<>
<Show
when={schema.ui.primary_input === 'audio_in'}
fallback={
<VirtualJoystick
size={260}
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
ariaLabel="Sound analysis joystick"
/>
}
>
<div
style={{
display: 'flex',
'flex-direction': 'column',
'align-items': 'center',
gap: 'var(--sp-3)',
padding: 'var(--sp-4)',
background: 'var(--bg-2)',
border: `1px ${runtime.mic.started() ? 'solid var(--accent-2)' : 'dashed var(--line-strong)'}`,
'border-radius': 'var(--r-2)',
'min-width': '260px',
'min-height': '200px',
'justify-content': 'center',
color: 'var(--fg-mute)',
'text-align': 'center',
}}
>
<strong style={{ color: runtime.mic.started() ? 'var(--accent-2)' : 'var(--fg-mute)' }}>
{runtime.mic.started() ? '🎤 Mic active' : 'Mic input'}
</strong>
<span style={{ 'font-size': 'var(--fs-xs)' }}>
{runtime.mic.started()
? 'Audio features → channels 2..5. Joystick below drives channels 0..1.'
: 'Tap "Mic" in the header to feed mic features into the model.'}
</span>
<VirtualJoystick
size={200}
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
ariaLabel="Manual joystick fallback"
/>
</div>
</Show>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="var(--info)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default SoundAnalysisMIDIMode;

View file

@ -1,54 +0,0 @@
/**
* VerbFXMode verb / fx unit (joystick input, ~47 outputs).
*/
import { Component, createSignal } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { VerbFxSchema } from './generated/verb_fx_schema';
export const VerbFXMode: Component = () => {
const schema = VerbFxSchema;
const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0);
return (
<ModeShell
schema={schema}
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
drawerTitle="Verb / FX settings"
primaryInput={() => (
<>
<VirtualJoystick
size={260}
ariaLabel="Verb FX joystick"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Sweep through verb space. Voice space:&nbsp;
<strong>{schema.voice_spaces[voiceSpace()] ?? 'Default'}</strong>
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="#b464ff"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default VerbFXMode;

View file

@ -1,54 +0,0 @@
/**
* XIASRIMode audio-reactive verb / pitch effects.
*
* Schema declares `primary_input: 'joystick'` and feeds the MLP from
* joy_x/joy_y plus optional mic features. ModeShell exposes a Mic toggle
* that opt-in feeds analysis features into channels 2..5.
*/
import { Component } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { LossPlot } from '../primitives/LossPlot';
import { XiasriSchema } from './generated/xiasri_schema';
export const XIASRIMode: Component = () => {
const schema = XiasriSchema;
const runtime = useModeRuntime(schema);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="XIASRI settings"
primaryInput={() => (
<>
<VirtualJoystick
size={260}
ariaLabel="XIASRI joystick"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Joystick verb / pitch space. Enable mic for audio-reactive features.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="var(--accent-2)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default XIASRIMode;

View file

@ -1,596 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/breakor.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface BreakorParams {
readonly kick_ratio0: number;
readonly kick_ratio1: number;
readonly kick_ratio2: number;
readonly kick_pmul: number;
readonly kick_poff: number;
readonly kick_amp0: number;
readonly kick_amp1: number;
readonly snare_ratio0: number;
readonly snare_ratio1: number;
readonly snare_ratio2: number;
readonly snare_pmul: number;
readonly snare_poff: number;
readonly snare_amp0: number;
readonly snare_amp1: number;
readonly tom_ratio0: number;
readonly tom_ratio1: number;
readonly tom_ratio2: number;
readonly tom_pmul: number;
readonly tom_poff: number;
readonly tom_amp0: number;
readonly tom_amp1: number;
readonly lowtom_ratio0: number;
readonly lowtom_ratio1: number;
readonly lowtom_ratio2: number;
readonly lowtom_pmul: number;
readonly lowtom_poff: number;
readonly lowtom_amp0: number;
readonly lowtom_amp1: number;
readonly rim_ratio0: number;
readonly rim_ratio1: number;
readonly rim_ratio2: number;
readonly rim_pmul: number;
readonly rim_poff: number;
readonly rim_amp0: number;
readonly rim_amp1: number;
readonly hat_ratio0: number;
readonly hat_ratio1: number;
readonly hat_ratio2: number;
readonly hat_pmul: number;
readonly hat_poff: number;
readonly hat_amp0: number;
readonly hat_amp1: number;
readonly ophat_ratio0: number;
readonly ophat_ratio1: number;
readonly ophat_ratio2: number;
readonly ophat_pmul: number;
readonly ophat_poff: number;
readonly ophat_amp0: number;
readonly ophat_amp1: number;
readonly perc_ratio0: number;
readonly perc_ratio1: number;
readonly perc_ratio2: number;
readonly perc_pmul: number;
readonly perc_poff: number;
readonly perc_amp0: number;
readonly perc_amp1: number;
}
export const BreakorSchema: ModeSchema = {
mode_id: 'breakor',
engine_id: 'breakor',
ml: {
input_channels: [
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 4,
hidden_layers: [
10,
14,
18,
],
output_size: 56,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'kick_ratio0',
label: 'Kick Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'kick',
},
{
name: 'kick_ratio1',
label: 'Kick Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'kick',
},
{
name: 'kick_ratio2',
label: 'Kick Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'kick',
},
{
name: 'kick_pmul',
label: 'Kick Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'kick',
},
{
name: 'kick_poff',
label: 'Kick Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'kick',
},
{
name: 'kick_amp0',
label: 'Kick Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'kick',
},
{
name: 'kick_amp1',
label: 'Kick Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'kick',
},
{
name: 'snare_ratio0',
label: 'Snare Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'snare',
},
{
name: 'snare_ratio1',
label: 'Snare Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'snare',
},
{
name: 'snare_ratio2',
label: 'Snare Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'snare',
},
{
name: 'snare_pmul',
label: 'Snare Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'snare',
},
{
name: 'snare_poff',
label: 'Snare Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'snare',
},
{
name: 'snare_amp0',
label: 'Snare Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'snare',
},
{
name: 'snare_amp1',
label: 'Snare Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'snare',
},
{
name: 'tom_ratio0',
label: 'Tom Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'tom',
},
{
name: 'tom_ratio1',
label: 'Tom Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'tom',
},
{
name: 'tom_ratio2',
label: 'Tom Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'tom',
},
{
name: 'tom_pmul',
label: 'Tom Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'tom',
},
{
name: 'tom_poff',
label: 'Tom Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'tom',
},
{
name: 'tom_amp0',
label: 'Tom Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'tom',
},
{
name: 'tom_amp1',
label: 'Tom Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'tom',
},
{
name: 'lowtom_ratio0',
label: 'LowTom Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'lowtom',
},
{
name: 'lowtom_ratio1',
label: 'LowTom Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'lowtom',
},
{
name: 'lowtom_ratio2',
label: 'LowTom Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'lowtom',
},
{
name: 'lowtom_pmul',
label: 'LowTom Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'lowtom',
},
{
name: 'lowtom_poff',
label: 'LowTom Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'lowtom',
},
{
name: 'lowtom_amp0',
label: 'LowTom Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'lowtom',
},
{
name: 'lowtom_amp1',
label: 'LowTom Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'lowtom',
},
{
name: 'rim_ratio0',
label: 'Rim Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'rim',
},
{
name: 'rim_ratio1',
label: 'Rim Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'rim',
},
{
name: 'rim_ratio2',
label: 'Rim Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'rim',
},
{
name: 'rim_pmul',
label: 'Rim Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'rim',
},
{
name: 'rim_poff',
label: 'Rim Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'rim',
},
{
name: 'rim_amp0',
label: 'Rim Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'rim',
},
{
name: 'rim_amp1',
label: 'Rim Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'rim',
},
{
name: 'hat_ratio0',
label: 'Hat Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'hat',
},
{
name: 'hat_ratio1',
label: 'Hat Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'hat',
},
{
name: 'hat_ratio2',
label: 'Hat Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'hat',
},
{
name: 'hat_pmul',
label: 'Hat Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'hat',
},
{
name: 'hat_poff',
label: 'Hat Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'hat',
},
{
name: 'hat_amp0',
label: 'Hat Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'hat',
},
{
name: 'hat_amp1',
label: 'Hat Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'hat',
},
{
name: 'ophat_ratio0',
label: 'OpenHat Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'openhat',
},
{
name: 'ophat_ratio1',
label: 'OpenHat Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'openhat',
},
{
name: 'ophat_ratio2',
label: 'OpenHat Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'openhat',
},
{
name: 'ophat_pmul',
label: 'OpenHat Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'openhat',
},
{
name: 'ophat_poff',
label: 'OpenHat Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'openhat',
},
{
name: 'ophat_amp0',
label: 'OpenHat Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'openhat',
},
{
name: 'ophat_amp1',
label: 'OpenHat Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'openhat',
},
{
name: 'perc_ratio0',
label: 'Perc Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'perc',
},
{
name: 'perc_ratio1',
label: 'Perc Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'perc',
},
{
name: 'perc_ratio2',
label: 'Perc Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'perc',
},
{
name: 'perc_pmul',
label: 'Perc Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'perc',
},
{
name: 'perc_poff',
label: 'Perc Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'perc',
},
{
name: 'perc_amp0',
label: 'Perc Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'perc',
},
{
name: 'perc_amp1',
label: 'Perc Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'perc',
},
],
voice_spaces: [],
ui: {
primary_input: 'xy_pad',
show_voice_space_selector: false,
show_synth_visualizer: false,
},
};

View file

@ -1,283 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/channel_strip.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface ChannelStripParams {
readonly pre_gain: number;
readonly peak0_freq: number;
readonly p02: number;
readonly p03: number;
readonly peak1_freq: number;
readonly peak_q: number;
readonly peak_gain: number;
readonly in_lpf_cutoff: number;
readonly in_hpf_cutoff: number;
readonly p09: number;
readonly comp_threshold: number;
readonly comp_ratio: number;
readonly comp_attack: number;
readonly comp_release: number;
readonly lowshelf_freq: number;
readonly lowshelf_q: number;
readonly lowshelf_gain: number;
readonly highshelf_freq: number;
readonly highshelf_q: number;
readonly highshelf_gain: number;
readonly p20: number;
readonly p21: number;
readonly p22: number;
readonly post_gain: number;
}
export const ChannelStripSchema: ModeSchema = {
mode_id: 'channel_strip',
engine_id: 'channel_strip',
ml: {
input_channels: [
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 4,
hidden_layers: [
10,
10,
14,
],
output_size: 24,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'pre_gain',
label: 'Pre Gain',
min: 0,
max: 1,
default: 0.5,
curve: 'square',
group: 'gain',
},
{
name: 'peak0_freq',
label: 'Peak0 Freq',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'eq',
},
{
name: 'p02',
label: 'Param 02',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'p03',
label: 'Param 03',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'peak1_freq',
label: 'Peak1 Freq',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'eq',
},
{
name: 'peak_q',
label: 'Peak Q',
min: 0,
max: 1,
default: 0.3,
curve: 'linear',
group: 'eq',
},
{
name: 'peak_gain',
label: 'Peak Gain',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'eq',
},
{
name: 'in_lpf_cutoff',
label: 'Input LPF',
min: 0,
max: 1,
default: 0.7,
curve: 'square',
group: 'filters',
},
{
name: 'in_hpf_cutoff',
label: 'Input HPF',
min: 0,
max: 1,
default: 0.2,
curve: 'square',
group: 'filters',
},
{
name: 'p09',
label: 'Param 09',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'comp_threshold',
label: 'Comp Threshold',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'comp',
},
{
name: 'comp_ratio',
label: 'Comp Ratio',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'comp',
},
{
name: 'comp_attack',
label: 'Comp Attack',
min: 0,
max: 1,
default: 0.1,
curve: 'linear',
group: 'comp',
},
{
name: 'comp_release',
label: 'Comp Release',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'comp',
},
{
name: 'lowshelf_freq',
label: 'Lowshelf Freq',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'eq',
},
{
name: 'lowshelf_q',
label: 'Lowshelf Q',
min: 0,
max: 1,
default: 0.3,
curve: 'linear',
group: 'eq',
},
{
name: 'lowshelf_gain',
label: 'Lowshelf Gain',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'eq',
},
{
name: 'highshelf_freq',
label: 'Highshelf Freq',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'eq',
},
{
name: 'highshelf_q',
label: 'Highshelf Q',
min: 0,
max: 1,
default: 0.3,
curve: 'linear',
group: 'eq',
},
{
name: 'highshelf_gain',
label: 'Highshelf Gain',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'eq',
},
{
name: 'p20',
label: 'Param 20',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'p21',
label: 'Param 21',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'p22',
label: 'Param 22',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'post_gain',
label: 'Post Gain',
min: 0,
max: 1,
default: 0.5,
curve: 'square',
group: 'gain',
},
],
voice_spaces: [
'WannabeNeve66',
'SSL 4K G-ist',
'SSL 9K-inda',
'MaleVox',
'FemaleVox',
'Neve 80',
],
ui: {
primary_input: 'joystick',
show_voice_space_selector: true,
show_synth_visualizer: false,
},
};

View file

@ -1,436 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/elysiamorf.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface ElysiamorfParams {
readonly fm0_carrier: number;
readonly fm0_mod_freq: number;
readonly fm0_mod_index: number;
readonly fm0_phasor_mul: number;
readonly fm0_phase_off: number;
readonly fm1_carrier: number;
readonly fm1_mod_freq: number;
readonly fm1_mod_index: number;
readonly fm1_phasor_mul: number;
readonly fm1_phase_off: number;
readonly fm2_carrier: number;
readonly fm2_mod_freq: number;
readonly fm2_mod_index: number;
readonly fm2_phasor_mul: number;
readonly fm2_phase_off: number;
readonly fm3_carrier: number;
readonly fm3_mod_freq: number;
readonly fm3_mod_index: number;
readonly fm3_phasor_mul: number;
readonly fm3_phase_off: number;
readonly fm4_carrier: number;
readonly fm4_mod_freq: number;
readonly fm4_mod_index: number;
readonly fm4_phasor_mul: number;
readonly fm4_phase_off: number;
readonly fm5_carrier: number;
readonly fm5_mod_freq: number;
readonly fm5_mod_index: number;
readonly fm5_phasor_mul: number;
readonly fm5_phase_off: number;
readonly fm6_carrier: number;
readonly fm6_mod_freq: number;
readonly fm6_mod_index: number;
readonly fm6_phasor_mul: number;
readonly fm6_phase_off: number;
readonly fm7_carrier: number;
readonly fm7_mod_freq: number;
readonly fm7_mod_index: number;
readonly fm7_phasor_mul: number;
readonly fm7_phase_off: number;
}
export const ElysiamorfSchema: ModeSchema = {
mode_id: 'elysiamorf',
engine_id: 'elysiamorf',
ml: {
input_channels: [
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 4,
hidden_layers: [
10,
14,
18,
],
output_size: 40,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'fm0_carrier',
label: 'FM0 Carrier',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm0',
},
{
name: 'fm0_mod_freq',
label: 'FM0 Mod Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm0',
},
{
name: 'fm0_mod_index',
label: 'FM0 Mod Index',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm0',
},
{
name: 'fm0_phasor_mul',
label: 'FM0 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm0',
},
{
name: 'fm0_phase_off',
label: 'FM0 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm0',
},
{
name: 'fm1_carrier',
label: 'FM1 Carrier',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm1',
},
{
name: 'fm1_mod_freq',
label: 'FM1 Mod Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm1',
},
{
name: 'fm1_mod_index',
label: 'FM1 Mod Index',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm1',
},
{
name: 'fm1_phasor_mul',
label: 'FM1 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm1',
},
{
name: 'fm1_phase_off',
label: 'FM1 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm1',
},
{
name: 'fm2_carrier',
label: 'FM2 Carrier',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm2',
},
{
name: 'fm2_mod_freq',
label: 'FM2 Mod Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm2',
},
{
name: 'fm2_mod_index',
label: 'FM2 Mod Index',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm2',
},
{
name: 'fm2_phasor_mul',
label: 'FM2 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm2',
},
{
name: 'fm2_phase_off',
label: 'FM2 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm2',
},
{
name: 'fm3_carrier',
label: 'FM3 Carrier',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm3',
},
{
name: 'fm3_mod_freq',
label: 'FM3 Mod Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm3',
},
{
name: 'fm3_mod_index',
label: 'FM3 Mod Index',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm3',
},
{
name: 'fm3_phasor_mul',
label: 'FM3 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm3',
},
{
name: 'fm3_phase_off',
label: 'FM3 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm3',
},
{
name: 'fm4_carrier',
label: 'FM4 Carrier',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm4',
},
{
name: 'fm4_mod_freq',
label: 'FM4 Mod Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm4',
},
{
name: 'fm4_mod_index',
label: 'FM4 Mod Index',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm4',
},
{
name: 'fm4_phasor_mul',
label: 'FM4 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm4',
},
{
name: 'fm4_phase_off',
label: 'FM4 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm4',
},
{
name: 'fm5_carrier',
label: 'FM5 Carrier',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm5',
},
{
name: 'fm5_mod_freq',
label: 'FM5 Mod Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm5',
},
{
name: 'fm5_mod_index',
label: 'FM5 Mod Index',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm5',
},
{
name: 'fm5_phasor_mul',
label: 'FM5 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm5',
},
{
name: 'fm5_phase_off',
label: 'FM5 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm5',
},
{
name: 'fm6_carrier',
label: 'FM6 Carrier',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm6',
},
{
name: 'fm6_mod_freq',
label: 'FM6 Mod Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm6',
},
{
name: 'fm6_mod_index',
label: 'FM6 Mod Index',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm6',
},
{
name: 'fm6_phasor_mul',
label: 'FM6 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm6',
},
{
name: 'fm6_phase_off',
label: 'FM6 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm6',
},
{
name: 'fm7_carrier',
label: 'FM7 Carrier',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm7',
},
{
name: 'fm7_mod_freq',
label: 'FM7 Mod Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'fm7',
},
{
name: 'fm7_mod_index',
label: 'FM7 Mod Index',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm7',
},
{
name: 'fm7_phasor_mul',
label: 'FM7 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm7',
},
{
name: 'fm7_phase_off',
label: 'FM7 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'fm7',
},
],
voice_spaces: [],
ui: {
primary_input: 'xy_pad',
show_voice_space_selector: false,
show_synth_visualizer: false,
},
};

View file

@ -1,13 +0,0 @@
// AUTOGENERATED — do not edit. Run `bun run codegen/generate.ts` to regenerate.
// Re-exports every generated mode schema.
export * from './types';
export * from './breakor_schema';
export * from './channel_strip_schema';
export * from './elysiamorf_schema';
export * from './memlcelium_schema';
export * from './paf_synth_schema';
export * from './slp_workshop_schema';
export * from './sound_analysis_midi_schema';
export * from './verb_fx_schema';
export * from './xiasri_schema';

View file

@ -1,598 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/memlcelium.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface MemlceliumParams {
readonly seq0_ratio0: number;
readonly seq0_ratio1: number;
readonly seq0_ratio2: number;
readonly seq0_phasor_mul: number;
readonly seq0_phase_off: number;
readonly seq0_amp0: number;
readonly seq0_amp1: number;
readonly seq1_ratio0: number;
readonly seq1_ratio1: number;
readonly seq1_ratio2: number;
readonly seq1_phasor_mul: number;
readonly seq1_phase_off: number;
readonly seq1_amp0: number;
readonly seq1_amp1: number;
readonly v0_base_freq: number;
readonly v0_paf0_cf: number;
readonly v0_paf1_cf: number;
readonly v0_paf2_cf: number;
readonly v0_paf0_bw: number;
readonly v0_paf1_bw: number;
readonly v0_paf2_bw: number;
readonly v0_paf_vib: number;
readonly v0_paf_vfr: number;
readonly v0_paf0_shift: number;
readonly v0_paf1_shift: number;
readonly v0_paf2_shift: number;
readonly v0_amp_attack: number;
readonly v0_amp_decay: number;
readonly v0_amp_sustain: number;
readonly v0_amp_release: number;
readonly v0_pitch_attack: number;
readonly v0_pitch_decay: number;
readonly v0_pitch_emph: number;
readonly v0_shape_gain: number;
readonly v0_shape_asym: number;
readonly v0_shape_mix: number;
readonly v0_rm_gain: number;
readonly v1_base_freq: number;
readonly v1_detune1: number;
readonly v1_detune2: number;
readonly v1_paf0_cf: number;
readonly v1_paf1_cf: number;
readonly v1_paf2_cf: number;
readonly v1_paf0_bw: number;
readonly v1_paf1_bw: number;
readonly v1_paf2_bw: number;
readonly v1_paf0_shift: number;
readonly v1_paf1_shift: number;
readonly v1_paf2_shift: number;
readonly v1_amp_attack: number;
readonly v1_amp_decay: number;
readonly v1_amp_sustain: number;
readonly v1_amp_release: number;
readonly v1_pitch_attack: number;
readonly v1_pitch_decay: number;
readonly v1_pitch_emph: number;
}
export const MemlceliumSchema: ModeSchema = {
mode_id: 'memlcelium',
engine_id: 'memlcelium',
ml: {
input_channels: [
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 4,
hidden_layers: [
10,
14,
18,
],
output_size: 56,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'seq0_ratio0',
label: 'Seq0 Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_ratio1',
label: 'Seq0 Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_ratio2',
label: 'Seq0 Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_phasor_mul',
label: 'Seq0 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_phase_off',
label: 'Seq0 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_amp0',
label: 'Seq0 Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_amp1',
label: 'Seq0 Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_ratio0',
label: 'Seq1 Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_ratio1',
label: 'Seq1 Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_ratio2',
label: 'Seq1 Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_phasor_mul',
label: 'Seq1 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_phase_off',
label: 'Seq1 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_amp0',
label: 'Seq1 Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_amp1',
label: 'Seq1 Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'v0_base_freq',
label: 'V0 Base Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf0_cf',
label: 'V0 PAF0 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf1_cf',
label: 'V0 PAF1 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf2_cf',
label: 'V0 PAF2 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf0_bw',
label: 'V0 PAF0 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf1_bw',
label: 'V0 PAF1 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf2_bw',
label: 'V0 PAF2 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf_vib',
label: 'V0 PAF Vib',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'v0_synth',
},
{
name: 'v0_paf_vfr',
label: 'V0 PAF Vib Rate',
min: 0,
max: 1,
default: 0.5,
curve: 'square',
group: 'v0_synth',
},
{
name: 'v0_paf0_shift',
label: 'V0 PAF0 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf1_shift',
label: 'V0 PAF1 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf2_shift',
label: 'V0 PAF2 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_amp_attack',
label: 'V0 Amp Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_env',
},
{
name: 'v0_amp_decay',
label: 'V0 Amp Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v0_env',
},
{
name: 'v0_amp_sustain',
label: 'V0 Amp Sustain',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_env',
},
{
name: 'v0_amp_release',
label: 'V0 Amp Release',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v0_env',
},
{
name: 'v0_pitch_attack',
label: 'V0 Pitch Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_env',
},
{
name: 'v0_pitch_decay',
label: 'V0 Pitch Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v0_env',
},
{
name: 'v0_pitch_emph',
label: 'V0 Pitch Emph',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_env',
},
{
name: 'v0_shape_gain',
label: 'V0 Shape Gain',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_shape_asym',
label: 'V0 Shape Asym',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_shape_mix',
label: 'V0 Shape Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_rm_gain',
label: 'V0 RingMod Gain',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v1_base_freq',
label: 'V1 Base Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_detune1',
label: 'V1 Detune 1',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_detune2',
label: 'V1 Detune 2',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf0_cf',
label: 'V1 PAF0 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf1_cf',
label: 'V1 PAF1 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf2_cf',
label: 'V1 PAF2 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf0_bw',
label: 'V1 PAF0 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf1_bw',
label: 'V1 PAF1 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf2_bw',
label: 'V1 PAF2 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf0_shift',
label: 'V1 PAF0 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf1_shift',
label: 'V1 PAF1 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf2_shift',
label: 'V1 PAF2 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_amp_attack',
label: 'V1 Amp Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_env',
},
{
name: 'v1_amp_decay',
label: 'V1 Amp Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v1_env',
},
{
name: 'v1_amp_sustain',
label: 'V1 Amp Sustain',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_env',
},
{
name: 'v1_amp_release',
label: 'V1 Amp Release',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v1_env',
},
{
name: 'v1_pitch_attack',
label: 'V1 Pitch Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_env',
},
{
name: 'v1_pitch_decay',
label: 'V1 Pitch Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v1_env',
},
{
name: 'v1_pitch_emph',
label: 'V1 Pitch Emph',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_env',
},
],
voice_spaces: [
'Direct',
],
ui: {
primary_input: 'xy_pad',
show_voice_space_selector: false,
show_synth_visualizer: true,
},
};

View file

@ -1,374 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/paf_synth.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface PafSynthParams {
readonly p00: number;
readonly p01: number;
readonly paf0_cf: number;
readonly paf1_cf: number;
readonly p04: number;
readonly paf0_bw: number;
readonly paf1_bw: number;
readonly p07: number;
readonly paf0_vib: number;
readonly paf1_vib: number;
readonly p10: number;
readonly paf0_vfr: number;
readonly paf1_vfr: number;
readonly p13: number;
readonly paf0_shift: number;
readonly paf1_shift: number;
readonly p16: number;
readonly dl1mix: number;
readonly p18: number;
readonly dlfb: number;
readonly env_decay: number;
readonly p21: number;
readonly p22: number;
readonly p23: number;
readonly p24: number;
readonly p25: number;
readonly shape_gain: number;
readonly shape_asym: number;
readonly shape_mix: number;
readonly rm_gain: number;
readonly env_attack: number;
readonly env_sustain: number;
readonly env_release: number;
}
export const PafSynthSchema: ModeSchema = {
mode_id: 'paf_synth',
engine_id: 'paf_synth',
ml: {
input_channels: [
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 4,
hidden_layers: [
10,
10,
14,
],
output_size: 33,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'p00',
label: 'Param 00',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'p01',
label: 'Param 01',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'paf0_cf',
label: 'PAF0 Center Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'operators',
},
{
name: 'paf1_cf',
label: 'PAF1 Center Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'operators',
},
{
name: 'p04',
label: 'Param 04',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'paf0_bw',
label: 'PAF0 Bandwidth',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'operators',
},
{
name: 'paf1_bw',
label: 'PAF1 Bandwidth',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'operators',
},
{
name: 'p07',
label: 'Param 07',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'paf0_vib',
label: 'PAF0 Vibrato Depth',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'modulation',
},
{
name: 'paf1_vib',
label: 'PAF1 Vibrato Depth',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'modulation',
},
{
name: 'p10',
label: 'Param 10',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'paf0_vfr',
label: 'PAF0 Vibrato Rate',
min: 0,
max: 1,
default: 0.5,
curve: 'square',
group: 'modulation',
},
{
name: 'paf1_vfr',
label: 'PAF1 Vibrato Rate',
min: 0,
max: 1,
default: 0.5,
curve: 'square',
group: 'modulation',
},
{
name: 'p13',
label: 'Param 13',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'paf0_shift',
label: 'PAF0 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'operators',
},
{
name: 'paf1_shift',
label: 'PAF1 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'operators',
},
{
name: 'p16',
label: 'Param 16',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'dl1mix',
label: 'Delay Mix',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'delay',
},
{
name: 'p18',
label: 'Param 18',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'dlfb',
label: 'Delay Feedback',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'delay',
},
{
name: 'env_decay',
label: 'Env Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'envelope',
},
{
name: 'p21',
label: 'Param 21',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'p22',
label: 'Param 22',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'p23',
label: 'Param 23',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'p24',
label: 'Param 24',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'p25',
label: 'Param 25',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'shape_gain',
label: 'Sine Shape Gain',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'shaper',
},
{
name: 'shape_asym',
label: 'Sine Shape Asym',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'shaper',
},
{
name: 'shape_mix',
label: 'Sine Shape Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'shaper',
},
{
name: 'rm_gain',
label: 'Ring Mod Gain',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'shaper',
},
{
name: 'env_attack',
label: 'Env Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'envelope',
},
{
name: 'env_sustain',
label: 'Env Sustain',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'envelope',
},
{
name: 'env_release',
label: 'Env Release',
min: 0,
max: 1,
default: 0.3,
curve: 'linear',
group: 'envelope',
},
],
voice_spaces: [
'Ellipticacacia',
'Rowantares',
'Neemeda',
'Aquillow',
'Magnetarch',
'Elderstar',
'Ipeleiades',
],
ui: {
primary_input: 'xy_pad',
show_voice_space_selector: true,
show_synth_visualizer: true,
},
};

View file

@ -1,598 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/slp_workshop.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface SlpWorkshopParams {
readonly seq0_ratio0: number;
readonly seq0_ratio1: number;
readonly seq0_ratio2: number;
readonly seq0_phasor_mul: number;
readonly seq0_phase_off: number;
readonly seq0_amp0: number;
readonly seq0_amp1: number;
readonly seq1_ratio0: number;
readonly seq1_ratio1: number;
readonly seq1_ratio2: number;
readonly seq1_phasor_mul: number;
readonly seq1_phase_off: number;
readonly seq1_amp0: number;
readonly seq1_amp1: number;
readonly v0_base_freq: number;
readonly v0_paf0_cf: number;
readonly v0_paf1_cf: number;
readonly v0_paf2_cf: number;
readonly v0_paf0_bw: number;
readonly v0_paf1_bw: number;
readonly v0_paf2_bw: number;
readonly v0_paf_vib: number;
readonly v0_paf_vfr: number;
readonly v0_paf0_shift: number;
readonly v0_paf1_shift: number;
readonly v0_paf2_shift: number;
readonly v0_amp_attack: number;
readonly v0_amp_decay: number;
readonly v0_amp_sustain: number;
readonly v0_amp_release: number;
readonly v0_pitch_attack: number;
readonly v0_pitch_decay: number;
readonly v0_pitch_emph: number;
readonly v0_shape_gain: number;
readonly v0_shape_asym: number;
readonly v0_shape_mix: number;
readonly v0_rm_gain: number;
readonly v1_base_freq: number;
readonly v1_detune1: number;
readonly v1_detune2: number;
readonly v1_paf0_cf: number;
readonly v1_paf1_cf: number;
readonly v1_paf2_cf: number;
readonly v1_paf0_bw: number;
readonly v1_paf1_bw: number;
readonly v1_paf2_bw: number;
readonly v1_paf0_shift: number;
readonly v1_paf1_shift: number;
readonly v1_paf2_shift: number;
readonly v1_amp_attack: number;
readonly v1_amp_decay: number;
readonly v1_amp_sustain: number;
readonly v1_amp_release: number;
readonly v1_pitch_attack: number;
readonly v1_pitch_decay: number;
readonly v1_pitch_emph: number;
}
export const SlpWorkshopSchema: ModeSchema = {
mode_id: 'slp_workshop',
engine_id: 'memlcelium',
ml: {
input_channels: [
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 4,
hidden_layers: [
10,
14,
18,
],
output_size: 56,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'seq0_ratio0',
label: 'Seq0 Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_ratio1',
label: 'Seq0 Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_ratio2',
label: 'Seq0 Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_phasor_mul',
label: 'Seq0 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_phase_off',
label: 'Seq0 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_amp0',
label: 'Seq0 Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq0_amp1',
label: 'Seq0 Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_ratio0',
label: 'Seq1 Ratio 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_ratio1',
label: 'Seq1 Ratio 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_ratio2',
label: 'Seq1 Ratio 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_phasor_mul',
label: 'Seq1 Phasor Mul',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_phase_off',
label: 'Seq1 Phase Off',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_amp0',
label: 'Seq1 Amp 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'seq1_amp1',
label: 'Seq1 Amp 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'sequencer',
},
{
name: 'v0_base_freq',
label: 'V0 Base Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf0_cf',
label: 'V0 PAF0 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf1_cf',
label: 'V0 PAF1 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf2_cf',
label: 'V0 PAF2 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf0_bw',
label: 'V0 PAF0 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf1_bw',
label: 'V0 PAF1 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf2_bw',
label: 'V0 PAF2 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf_vib',
label: 'V0 PAF Vib',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'v0_synth',
},
{
name: 'v0_paf_vfr',
label: 'V0 PAF Vib Rate',
min: 0,
max: 1,
default: 0.5,
curve: 'square',
group: 'v0_synth',
},
{
name: 'v0_paf0_shift',
label: 'V0 PAF0 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf1_shift',
label: 'V0 PAF1 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_paf2_shift',
label: 'V0 PAF2 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_amp_attack',
label: 'V0 Amp Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_env',
},
{
name: 'v0_amp_decay',
label: 'V0 Amp Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v0_env',
},
{
name: 'v0_amp_sustain',
label: 'V0 Amp Sustain',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v0_env',
},
{
name: 'v0_amp_release',
label: 'V0 Amp Release',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v0_env',
},
{
name: 'v0_pitch_attack',
label: 'V0 Pitch Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_env',
},
{
name: 'v0_pitch_decay',
label: 'V0 Pitch Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v0_env',
},
{
name: 'v0_pitch_emph',
label: 'V0 Pitch Emph',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_env',
},
{
name: 'v0_shape_gain',
label: 'V0 Shape Gain',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_shape_asym',
label: 'V0 Shape Asym',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_shape_mix',
label: 'V0 Shape Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v0_rm_gain',
label: 'V0 RingMod Gain',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v0_synth',
},
{
name: 'v1_base_freq',
label: 'V1 Base Freq',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_detune1',
label: 'V1 Detune 1',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_detune2',
label: 'V1 Detune 2',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf0_cf',
label: 'V1 PAF0 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf1_cf',
label: 'V1 PAF1 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf2_cf',
label: 'V1 PAF2 CF',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf0_bw',
label: 'V1 PAF0 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf1_bw',
label: 'V1 PAF1 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf2_bw',
label: 'V1 PAF2 BW',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf0_shift',
label: 'V1 PAF0 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf1_shift',
label: 'V1 PAF1 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_paf2_shift',
label: 'V1 PAF2 Shift',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_synth',
},
{
name: 'v1_amp_attack',
label: 'V1 Amp Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_env',
},
{
name: 'v1_amp_decay',
label: 'V1 Amp Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v1_env',
},
{
name: 'v1_amp_sustain',
label: 'V1 Amp Sustain',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'v1_env',
},
{
name: 'v1_amp_release',
label: 'V1 Amp Release',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v1_env',
},
{
name: 'v1_pitch_attack',
label: 'V1 Pitch Attack',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_env',
},
{
name: 'v1_pitch_decay',
label: 'V1 Pitch Decay',
min: 0,
max: 1,
default: 0.3,
curve: 'square',
group: 'v1_env',
},
{
name: 'v1_pitch_emph',
label: 'V1 Pitch Emph',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'v1_env',
},
],
voice_spaces: [
'Direct',
],
ui: {
primary_input: 'xy_pad',
show_voice_space_selector: false,
show_synth_visualizer: true,
},
};

View file

@ -1,122 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/sound_analysis_midi.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface SoundAnalysisMidiParams {
readonly midi_cc0: number;
readonly midi_cc1: number;
readonly midi_cc2: number;
readonly midi_cc3: number;
readonly midi_cc4: number;
readonly midi_cc5: number;
readonly midi_cc6: number;
readonly midi_cc7: number;
}
export const SoundAnalysisMidiSchema: ModeSchema = {
mode_id: 'sound_analysis_midi',
engine_id: 'thru',
ml: {
input_channels: [
'audio_pitch',
'audio_aperiodicity',
'audio_energy',
'audio_attack',
'audio_brightness',
'audio_energy_crude',
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 10,
hidden_layers: [
10,
10,
14,
],
output_size: 8,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'midi_cc0',
label: 'MIDI CC 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'midi',
},
{
name: 'midi_cc1',
label: 'MIDI CC 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'midi',
},
{
name: 'midi_cc2',
label: 'MIDI CC 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'midi',
},
{
name: 'midi_cc3',
label: 'MIDI CC 3',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'midi',
},
{
name: 'midi_cc4',
label: 'MIDI CC 4',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'midi',
},
{
name: 'midi_cc5',
label: 'MIDI CC 5',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'midi',
},
{
name: 'midi_cc6',
label: 'MIDI CC 6',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'midi',
},
{
name: 'midi_cc7',
label: 'MIDI CC 7',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'midi',
},
],
voice_spaces: [],
ui: {
primary_input: 'audio_in',
show_voice_space_selector: false,
show_synth_visualizer: false,
},
};

View file

@ -1,54 +0,0 @@
// AUTOGENERATED — do not edit. Run `bun run codegen/generate.ts` to regenerate.
// Shared TypeScript types for generated mode schemas.
export type Curve =
| 'linear'
| 'exp'
| 'log'
| 'square'
| 'sqrt'
| 'sigmoid'
| 'cubic';
export type PrimaryInput =
| 'xy_pad'
| 'joystick'
| 'sliders'
| 'audio_in'
| 'midi_in'
| 'none';
export interface Param {
readonly name: string;
readonly label: string;
readonly min: number;
readonly max: number;
readonly default: number;
readonly curve: Curve;
readonly group: string;
}
export interface MLConfig {
readonly input_channels: readonly string[];
readonly input_size: number;
readonly hidden_layers: readonly number[];
readonly output_size: number;
readonly default_spread: number;
readonly default_learning_rate: number;
readonly default_max_iterations: number;
}
export interface UIConfig {
readonly primary_input: PrimaryInput;
readonly show_voice_space_selector: boolean;
readonly show_synth_visualizer: boolean;
}
export interface ModeSchema {
readonly mode_id: string;
readonly engine_id: string;
readonly ml: MLConfig;
readonly params: readonly Param[];
readonly voice_spaces: readonly string[];
readonly ui: UIConfig;
}

View file

@ -1,519 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/verb_fx.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface VerbFxParams {
readonly fb_delay_xfade: number;
readonly lp0_fb: number;
readonly lp0_cutoff: number;
readonly lp1_fb: number;
readonly lp1_cutoff: number;
readonly lp2_fb: number;
readonly lp2_cutoff: number;
readonly lp3_fb: number;
readonly lp3_cutoff: number;
readonly lp4_fb: number;
readonly lp4_cutoff: number;
readonly lp5_fb: number;
readonly lp5_cutoff: number;
readonly lp6_fb: number;
readonly lp6_cutoff: number;
readonly lp7_fb: number;
readonly lp7_cutoff: number;
readonly allp0_fb: number;
readonly allp1_fb: number;
readonly allp2_fb: number;
readonly allp3_fb: number;
readonly fbank_f0: number;
readonly fbank_f1: number;
readonly fbank_f2: number;
readonly fbank_f3: number;
readonly fbank_f4: number;
readonly fbank_f5: number;
readonly fbank_f6: number;
readonly fbank_f7: number;
readonly fbank_res0: number;
readonly fbank_res1: number;
readonly fbank_res2: number;
readonly fbank_res3: number;
readonly fbank_res4: number;
readonly fbank_res5: number;
readonly fbank_res6: number;
readonly fbank_res7: number;
readonly delay0_time: number;
readonly delay0_fb: number;
readonly delay1_time: number;
readonly delay1_fb: number;
readonly delay2_time: number;
readonly delay2_fb: number;
readonly verb_vs_delay: number;
readonly delay_to_verb: number;
readonly delay_morph: number;
readonly delay_blend: number;
}
export const VerbFxSchema: ModeSchema = {
mode_id: 'verb_fx',
engine_id: 'verb_fx',
ml: {
input_channels: [
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 4,
hidden_layers: [
10,
14,
18,
],
output_size: 47,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'fb_delay_xfade',
label: 'Bank/Delay XFade',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'routing',
},
{
name: 'lp0_fb',
label: 'LP Comb 0 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp0_cutoff',
label: 'LP Comb 0 Cutoff',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp1_fb',
label: 'LP Comb 1 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp1_cutoff',
label: 'LP Comb 1 Cutoff',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp2_fb',
label: 'LP Comb 2 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp2_cutoff',
label: 'LP Comb 2 Cutoff',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp3_fb',
label: 'LP Comb 3 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp3_cutoff',
label: 'LP Comb 3 Cutoff',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp4_fb',
label: 'LP Comb 4 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp4_cutoff',
label: 'LP Comb 4 Cutoff',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp5_fb',
label: 'LP Comb 5 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp5_cutoff',
label: 'LP Comb 5 Cutoff',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp6_fb',
label: 'LP Comb 6 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp6_cutoff',
label: 'LP Comb 6 Cutoff',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp7_fb',
label: 'LP Comb 7 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'lp7_cutoff',
label: 'LP Comb 7 Cutoff',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'allp0_fb',
label: 'Allpass 0 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'allp1_fb',
label: 'Allpass 1 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'allp2_fb',
label: 'Allpass 2 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'allp3_fb',
label: 'Allpass 3 FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'verb',
},
{
name: 'fbank_f0',
label: 'Filterbank Freq 0',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_f1',
label: 'Filterbank Freq 1',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_f2',
label: 'Filterbank Freq 2',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_f3',
label: 'Filterbank Freq 3',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_f4',
label: 'Filterbank Freq 4',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_f5',
label: 'Filterbank Freq 5',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_f6',
label: 'Filterbank Freq 6',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_f7',
label: 'Filterbank Freq 7',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_res0',
label: 'Filterbank Res 0',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_res1',
label: 'Filterbank Res 1',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_res2',
label: 'Filterbank Res 2',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_res3',
label: 'Filterbank Res 3',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_res4',
label: 'Filterbank Res 4',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_res5',
label: 'Filterbank Res 5',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_res6',
label: 'Filterbank Res 6',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'filterbank',
},
{
name: 'fbank_res7',
label: 'Filterbank Res 7',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'filterbank',
},
{
name: 'delay0_time',
label: 'Long Delay Time',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'delays',
},
{
name: 'delay0_fb',
label: 'Long Delay FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'delays',
},
{
name: 'delay1_time',
label: 'Med Delay Time',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'delays',
},
{
name: 'delay1_fb',
label: 'Med Delay FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'delays',
},
{
name: 'delay2_time',
label: 'Short Delay Time',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'delays',
},
{
name: 'delay2_fb',
label: 'Short Delay FB',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'delays',
},
{
name: 'verb_vs_delay',
label: 'Verb vs Delay',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'routing',
},
{
name: 'delay_to_verb',
label: 'Delay→Verb Send',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'routing',
},
{
name: 'delay_morph',
label: 'Delay Morph',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'delays',
},
{
name: 'delay_blend',
label: 'Delay Blend',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'delays',
},
],
voice_spaces: [
'Default',
'Resonant',
'Soft',
'Cathedral',
'Shimmer',
'Chamber',
'Metallic',
'Granular',
'Diffuse',
'Dark',
'Bright',
'Harmonic',
],
ui: {
primary_input: 'joystick',
show_voice_space_selector: true,
show_synth_visualizer: false,
},
};

View file

@ -1,278 +0,0 @@
// AUTOGENERATED — do not edit. Source: schemas/modes/xiasri.json. Run `bun run codegen/generate.ts` to regenerate.
import type { ModeSchema } from './types';
export interface XiasriParams {
readonly dl1_mix: number;
readonly dl2_mix: number;
readonly dl3_mix: number;
readonly p03: number;
readonly allp1_fb: number;
readonly allp2_fb: number;
readonly comb1_fb: number;
readonly comb2_fb: number;
readonly dl1_fb: number;
readonly dl2_fb: number;
readonly dl3_fb: number;
readonly wetdry_mix: number;
readonly pitch_transp: number;
readonly pitch_mix: number;
readonly allp3_fb: number;
readonly allp4_fb: number;
readonly allp5_fb: number;
readonly allp6_fb: number;
readonly allp3_mix: number;
readonly allp4_mix: number;
readonly allp5_mix: number;
readonly allp6_mix: number;
readonly dl4_mix: number;
readonly dl4_fb: number;
}
export const XiasriSchema: ModeSchema = {
mode_id: 'xiasri',
engine_id: 'xiasri',
ml: {
input_channels: [
'joy_x',
'joy_y',
'joy_z',
'joy_w',
],
input_size: 4,
hidden_layers: [
10,
10,
14,
],
output_size: 24,
default_spread: 0.6,
default_learning_rate: 1,
default_max_iterations: 1000,
},
params: [
{
name: 'dl1_mix',
label: 'Delay 1 Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'delays',
},
{
name: 'dl2_mix',
label: 'Delay 2 Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'delays',
},
{
name: 'dl3_mix',
label: 'Delay 3 Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'delays',
},
{
name: 'p03',
label: 'Param 03',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'general',
},
{
name: 'allp1_fb',
label: 'Allpass 1 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'allp2_fb',
label: 'Allpass 2 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'comb1_fb',
label: 'Comb 1 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'comb2_fb',
label: 'Comb 2 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'dl1_fb',
label: 'Delay 1 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'delays',
},
{
name: 'dl2_fb',
label: 'Delay 2 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'delays',
},
{
name: 'dl3_fb',
label: 'Delay 3 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'delays',
},
{
name: 'wetdry_mix',
label: 'Wet/Dry Mix',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'mix',
},
{
name: 'pitch_transp',
label: 'Pitch Transpose',
min: 0,
max: 1,
default: 0.5,
curve: 'linear',
group: 'pitch',
},
{
name: 'pitch_mix',
label: 'Pitch Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'pitch',
},
{
name: 'allp3_fb',
label: 'Allpass 3 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'allp4_fb',
label: 'Allpass 4 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'allp5_fb',
label: 'Allpass 5 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'allp6_fb',
label: 'Allpass 6 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'allp3_mix',
label: 'Allpass 3 Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'allp4_mix',
label: 'Allpass 4 Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'allp5_mix',
label: 'Allpass 5 Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'allp6_mix',
label: 'Allpass 6 Mix',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'verb',
},
{
name: 'dl4_mix',
label: 'Delay 4 Mix',
min: 0,
max: 1,
default: 0,
curve: 'square',
group: 'delays',
},
{
name: 'dl4_fb',
label: 'Delay 4 Feedback',
min: 0,
max: 1,
default: 0,
curve: 'linear',
group: 'delays',
},
],
voice_spaces: [
'Direct',
],
ui: {
primary_input: 'joystick',
show_voice_space_selector: false,
show_synth_visualizer: false,
},
};

View file

@ -1,106 +0,0 @@
/**
* Mode registry maps mode_id (and the special "c15" placeholder) to the
* TSX component that renders it. The ModeSwitcher reads from this list to
* populate its dropdown, and `App.tsx` looks up the active mode here.
*
* Order matters the switcher renders modes in this order.
*/
import type { Component } from 'solid-js';
import { PAFSynthMode } from './PAFSynthMode';
import { ChannelStripMode } from './ChannelStripMode';
import { XIASRIMode } from './XIASRIMode';
import { VerbFXMode } from './VerbFXMode';
import { MEMLCeliumMode } from './MEMLCeliumMode';
import { SLPWorkshopMode } from './SLPWorkshopMode';
import { BreakOrMode } from './BreakOrMode';
import { ElysiamorfMode } from './ElysiamorfMode';
import { SoundAnalysisMIDIMode } from './SoundAnalysisMIDIMode';
import { C15Mode } from './C15Mode';
export interface ModeRegistration {
/** Stable id; matches the schema's `mode_id` for firmware modes. */
id: string;
/** Human-readable label for the switcher. */
label: string;
/** Short description for the switcher tooltip. */
description: string;
/** TSX component rendering the mode. */
Component: Component;
/** True for browser-only placeholders (currently just C15). */
placeholder?: boolean;
}
export const MODE_REGISTRY: ReadonlyArray<ModeRegistration> = [
{
id: 'paf_synth',
label: 'PAF Synth',
description: 'Phase-aligned formant synth (XY pad).',
Component: PAFSynthMode,
},
{
id: 'channel_strip',
label: 'Channel Strip',
description: 'EQ + dynamics processing channel.',
Component: ChannelStripMode,
},
{
id: 'xiasri',
label: 'XIASRI',
description: 'Audio-reactive verb / pitch engine.',
Component: XIASRIMode,
},
{
id: 'verb_fx',
label: 'Verb FX',
description: 'Reverb / multi-effects unit.',
Component: VerbFXMode,
},
{
id: 'memlcelium',
label: 'MEML Celium',
description: 'Voice + dual-MLP CV/gate via uSEQ.',
Component: MEMLCeliumMode,
},
{
id: 'slp_workshop',
label: 'SLP-Workshop',
description: 'MEMLCelium voice + live Jolt / Explore learning (Synth Library Portland).',
Component: SLPWorkshopMode,
},
{
id: 'breakor',
label: 'Breakor',
description: 'Drum / breakbeat synthesis.',
Component: BreakOrMode,
},
{
id: 'elysiamorf',
label: 'Elysiamorf',
description: 'Granular morphing synth.',
Component: ElysiamorfMode,
},
{
id: 'sound_analysis_midi',
label: 'Sound Analysis → MIDI',
description: 'Audio features → MIDI CC routing.',
Component: SoundAnalysisMIDIMode,
},
{
id: 'c15',
label: 'C15 (browser-only)',
description: 'C15 WASM synth. Not yet ported.',
Component: C15Mode,
placeholder: true,
},
];
/** Look up a mode by id, falling back to the first registration. */
export function getModeById(id: string | null): ModeRegistration {
if (!id) return MODE_REGISTRY[0]!;
return MODE_REGISTRY.find((m) => m.id === id) ?? MODE_REGISTRY[0]!;
}
export { ModeShell } from './ModeShell';
export { useModeRuntime } from './mode-runtime';
export type { ModeRuntime } from './mode-runtime';

View file

@ -1,66 +0,0 @@
/**
* Helpers shared across mode TSX files. Pure functions; no Solid state.
*/
import type { Param } from './generated/types';
import type { SliderConfig } from '../primitives/SliderBank';
import type { CurveName } from '../output/curves';
/**
* Convert a schema param list into SliderConfig entries that the SliderBank
* primitive understands. Sliders are grouped by the schema's `group` field
* so the bank renders collapsible sections.
*/
export function paramsToSliderConfig(params: ReadonlyArray<Param>): SliderConfig[] {
let lastGroup: string | null = null;
return params.map((p) => {
const isNewGroup = p.group !== lastGroup;
lastGroup = p.group;
const cfg: SliderConfig = {
id: p.name,
label: p.label,
min: p.min,
max: p.max,
curve: p.curve as CurveName,
};
if (isNewGroup) cfg.section = formatGroupName(p.group);
return cfg;
});
}
/**
* Map a Float32Array (length = N) of normalized values [0,1] to a flat
* array sized to match the slider config (already in min/max range).
*
* If `overrides` is supplied, per-param min/max overrides are honoured
* instead of the schema defaults this is what the mode UI sliders
* display when the user has tightened the active range.
*/
export function outputsToSliderValues(
outputs: Float32Array,
params: ReadonlyArray<Param>,
overrides?: Record<string, { min: number; max: number; muted?: boolean; fixedValue?: number }>,
): number[] {
const out: number[] = [];
for (let i = 0; i < params.length; ++i) {
const v = outputs[i] ?? 0;
const p = params[i]!;
const ov = overrides?.[p.name];
if (ov && ov.muted && ov.fixedValue !== undefined) {
out.push(ov.fixedValue);
continue;
}
const min = ov?.min ?? p.min;
const max = ov?.max ?? p.max;
out.push(min + v * (max - min));
}
return out;
}
function formatGroupName(group: string): string {
if (!group) return '';
return group
.split(/[_\s]+/)
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join(' ');
}

View file

@ -1,785 +0,0 @@
/**
* Mode runtime shared wiring between mode TSX components and the
* playground's stores / WASM ML / audio engine host.
*
* Every mode does the same dance:
* 1. Hold a primary 2D input position (joystick / xy-pad / external feed).
* 2. Push it through the input pipeline.
* 3. Forward the processed (x, y) to the WASM MLP as input channels [0..N].
* Modes with input_size > 2 zero-pad the unused channels (or the mic
* analyser fills 0..3 if active).
* 4. Pull the WASM outputs (Float32Array of 126), slice to the schema's
* `output_size`, and run them through the output pipeline.
* 5. Apply per-param overrides (modeStore.activeOverrides).
* 6. Throttle + ship the processed slice to the AudioWorklet engine.
*
* Stream 10 wires:
* - Compound axes underlying stores (input/output/exploration).
* - Per-param overrides between MLP outputs and engine.
* - Snapshot + undo + A/B compare on RL events.
* - Pin mask passed to moveWeights.
* - Auto-explore interval timer.
* - Trail tracking for JoyMap.
* - Heatmap sampler invalidated on weight-change events.
* - Mic features pushed into MLP for audio-input modes.
*/
import { batch, createEffect, createMemo, createSignal, onCleanup, onMount, untrack } from 'solid-js';
import { mlStore, modeStore, controlStore, sessionStore, explorationStore } from '../stores';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { coreBus } from '../stores/bus';
import { processInput, defaultInputState, type InputState } from '../input/pipeline';
import {
processOutput,
defaultOutputState,
type OutputState,
} from '../output/pipeline';
import { EngineHost } from '../audio/engine-host';
import type { EngineId } from '../ml/types';
import type { ModeSchema } from './generated';
import { applyOverrides, buildPinMask } from '../features/overrides';
import { applyControlRouting } from '../features/control-routing';
import { Jolt } from '../ml/jolt';
import { OUExplore } from '../output/ou-explore';
import { createTrailRing, type TrailPoint } from '../features/trail';
import { autoSnapshot, undoLastSnapshot } from '../features/snapshots';
import { HeatmapSampler, type HeatmapColorMode } from '../features/heatmap-sampler';
import { MicInput } from '../features/mic-input';
/**
* Throttle interval for engine param updates (ms). 50ms 20fps which
* matches the legacy playground's C15 update cadence.
*/
const ENGINE_PARAM_THROTTLE_MS = 50;
/** A single shared EngineHost. Audio only starts on user gesture. */
let engineHost: EngineHost | null = null;
function getEngineHost(): EngineHost {
if (!engineHost) engineHost = new EngineHost();
return engineHost;
}
export interface ModeRuntime {
/** Driver — call from joystick/xy-pad/etc. */
setInput: (x: number, y: number) => void;
/** Most recent processed input (after pipeline). */
pipedInput: () => readonly [number, number];
/** Whether the input is currently frozen by zoom. */
frozen: () => boolean;
/** Raw 126-output ML vector (live). */
rawOutputs: () => Float32Array;
/** Output-sliced + pipeline-processed vector (length = schema.output_size). */
processedOutputs: () => Float32Array;
/**
* Final per-param values after override application (length = params.length).
* These are what get shipped to the engine and visualised in sliders.
*/
paramOutputs: () => Float32Array;
/** True iff WASM has loaded and the MLP is ready. */
ready: () => boolean;
/** Audio host control. */
audio: {
started: () => boolean;
start: () => Promise<void>;
stop: () => Promise<void>;
setMuted: (muted: boolean) => void;
};
/** Mic input control (for audio_in modes; safe to call on others — no-op). */
mic: {
started: () => boolean;
start: () => Promise<void>;
stop: () => Promise<void>;
};
/** Loss / training plumbing surfaced from mlStore. */
training: {
busy: () => boolean;
examples: () => number;
lastLoss: () => number | null;
lossHistory: () => ReadonlyArray<number>;
};
/** Trigger a sync train + push the current pipeline-processed sample. */
trainOnCurrent: () => void;
/** RL callbacks. */
thumbsUp: () => void;
thumbsDown: () => void;
randomize: () => void;
/** Pop snapshot stack and restore weights. Returns true on success. */
undo: () => boolean;
/** Whether the snapshot stack has anything to undo. */
canUndo: () => boolean;
/** Trail ring for JoyMap binding. */
trail: () => ReadonlyArray<TrailPoint>;
/** Snap input back to a trail point. */
snapToTrail: (p: { x: number; y: number }) => void;
/** Heatmap sampler. Returns the underlying cells; modes pass to <Heatmap>. */
heatmap: {
cells: () => Float32Array;
setColorMode: (m: HeatmapColorMode) => void;
colorMode: () => HeatmapColorMode;
refresh: (force?: boolean) => void;
resolution: () => number;
};
/** Region pin: pin the current zoom window (long-press handler). */
pinCurrentRegion: () => void;
/**
* Jolt held-button continuous weight morph (port of nisps/ml/jolt.hpp).
* `press()` starts morphing the network's weights; a control-rate timer
* glides them while held; `release()` freezes them in place. Inert until
* pressed; modes that never call `press()` are unaffected.
*/
jolt: {
/** Begin the jolt (button press / toggle on). */
press: () => void;
/** Freeze the jolt (button release / toggle off). */
release: () => void;
/** Whether the jolt is currently active. */
active: () => boolean;
};
/**
* Explore OU random-walk exploration intensity in [0,1] (port of
* nisps/ml/ou_noise.hpp). Added to the mode's output vector before audio.
* Intensity 0 = disabled passthrough; modes that never set it are
* unaffected.
*/
explore: {
setIntensity: (level: number) => void;
intensity: () => number;
};
}
interface RuntimeOptions {
/** Override the engine id (defaults to schema.engine_id). */
engineOverride?: EngineId;
/** Skip starting the audio engine even on user gesture. */
audioDisabled?: boolean;
}
/**
* Create the runtime for a given mode schema. Call from inside a Solid
* component (uses createSignal/onCleanup).
*/
export function useModeRuntime(
schema: ModeSchema,
opts: RuntimeOptions = {},
): ModeRuntime {
// ----- WASM init ---------------------------------------------------------
const [ready, setReady] = createSignal(mlStore.state.ready);
// Lazy initialise WASM. Idempotent across remounts.
void mlStore
.initialize(schema.ml.input_size, schema.ml.output_size)
.then(() => setReady(true))
.catch((err) => {
// Best-effort; UI keeps running without ML.
// eslint-disable-next-line no-console
console.error('[mode-runtime] mlStore.initialize failed:', err);
});
// ----- Mode-store side effects ------------------------------------------
// Make sure the active mode in the store matches what's actually rendered.
if (modeStore.state.activeModeId !== schema.mode_id) {
modeStore.switchMode(schema.mode_id);
}
// ----- Compound axis routing -------------------------------------------
// Whenever any axis or offset changes, re-derive the underlying store
// values. Track axis reads explicitly; do the writes inside untrack so
// we don't pick up stale dependencies on write-targets (input/output/
// exploration stores).
createEffect(() => {
void controlStore.state.boldness;
void controlStore.state.memory;
void controlStore.state.precision;
void controlStore.state.offsets.boldness;
void controlStore.state.offsets.memory;
void controlStore.state.offsets.precision;
untrack(() => applyControlRouting());
});
// ----- Input pipeline state --------------------------------------------
const [pipedInput, setPipedInput] = createSignal<readonly [number, number]>([0.5, 0.5]);
const [frozen, setFrozen] = createSignal(false);
let inputState: InputState = defaultInputState();
let lastFrameMs = performance.now();
// Trail ring
const trailRing = createTrailRing();
// Pressure tracking (set externally via setPressure on touch).
let pressDownAt: number | null = null;
const setInput = (rawX: number, rawY: number): void => {
const now = performance.now();
const dt = Math.max(0.001, (now - lastFrameMs) / 1000);
lastFrameMs = now;
const result = processInput([rawX, rawY], inputStore.config, inputState, dt);
inputState = result.state;
inputStore.__setLiveState(result.state);
setPipedInput([result.x, result.y]);
setFrozen(result.frozen);
trailRing.push(result.x, result.y);
if (!ready()) return;
// Push input to the MLP. Channels beyond [x,y] are zeroed out (or
// overridden with mic features in audio-input modes).
const inSz = schema.ml.input_size;
mlStore.setInput(0, result.x);
if (inSz > 1) mlStore.setInput(1, result.y);
// Mic features (if active) take channels 2..5.
if (mic.isRunning() && inSz > 2) {
const f = mic.getFeatures();
const fv = [f.energy, f.brightness, f.pitch, f.aperiodicity];
for (let i = 2; i < inSz; ++i) {
mlStore.setInput(i, fv[i - 2] ?? 0);
}
} else {
for (let i = 2; i < inSz; ++i) mlStore.setInput(i, 0);
}
mlStore.process();
// Update pressure-feedback hold timer.
if (pressDownAt !== null) {
explorationStore.setPressure(
explorationStore.state.pressureForce,
now - pressDownAt,
);
}
};
// ----- Output pipeline + override application --------------------------
let outputState: OutputState = defaultOutputState();
const sliceLen = schema.ml.output_size;
const [processedOutputs, setProcessedOutputs] = createSignal<Float32Array>(
new Float32Array(sliceLen),
{ equals: false },
);
const paramCount = schema.params.length;
let prevParamOutputs: Float32Array | null = null;
const [paramOutputs, setParamOutputs] = createSignal<Float32Array>(
new Float32Array(paramCount),
{ equals: false },
);
// ----- Explore (OU exploration noise) ----------------------------------
// Inert by default (intensity 0). Applied to the processed slice below,
// after the output pipeline and before override application, so it rides
// on top of the mode's mapping output. Modes that never set an intensity
// leave the output untouched (parity-safe).
const ouExplore = new OUExplore();
const [exploreIntensity, setExploreIntensitySig] = createSignal(0);
// Run the output pipeline whenever raw outputs change.
const rawOutputsAccessor = mlStore.outputs;
let lastOutFrameMs = performance.now();
const recomputeOutputs = () => {
const raw = rawOutputsAccessor();
if (raw.length === 0) return;
const now = performance.now();
const dtMs = Math.max(1, now - lastOutFrameMs);
lastOutFrameMs = now;
// Slice to mode's output_size up front.
const slice = raw.length === sliceLen ? raw : raw.subarray(0, sliceLen);
const result = processOutput(slice as Float32Array, outputStore.config, outputState, dtMs);
outputState = result.state;
// Explore (OU) noise: temporally-correlated random walk added on top of
// the processed output, clamped to [0,1]. No-op when intensity is 0.
ouExplore.apply(result.processed);
setProcessedOutputs(result.processed);
// Apply per-param overrides for the per-param consumer (engine, sliders).
// Only the first `params.length` outputs are user-visible parameters; the
// remainder is reserved for engine internals (none right now).
const paramSlice = result.processed.length === paramCount
? result.processed
: result.processed.subarray(0, paramCount);
const overrides = modeStore.state.overrides[schema.mode_id] ?? {};
const applied = applyOverrides(
paramSlice as Float32Array,
prevParamOutputs,
schema.params,
overrides,
);
prevParamOutputs = applied.values;
setParamOutputs(applied.values);
};
// Trigger recompute on any raw-output change. Wrap in untrack so reading
// outputStore.config inside processOutput doesn't add a tracked dep here.
createEffect(() => {
rawOutputsAccessor();
untrack(recomputeOutputs);
});
// Re-run override application when overrides change (no new ML output).
createEffect(() => {
void modeStore.state.overrides[schema.mode_id];
untrack(() => {
const raw = rawOutputsAccessor();
if (raw.length > 0) recomputeOutputs();
});
});
// Push the freeze mask into outputStore whenever overrides change.
createEffect(() => {
const ovs = modeStore.state.overrides[schema.mode_id] ?? {};
let mask: Uint8Array | null = null;
for (let i = 0; i < schema.params.length; ++i) {
const ov = ovs[schema.params[i]!.name];
if (ov && ov.frozen) {
if (!mask) mask = new Uint8Array(schema.params.length);
mask[i] = 1;
}
}
untrack(() => outputStore.setFreezeMask(mask));
});
// ----- Engine wiring (audio) -------------------------------------------
const host = getEngineHost();
const [audioStarted, setAudioStarted] = createSignal(host.isStarted);
let pendingParams: Float32Array | null = null;
let throttleTimer: number | null = null;
const flushParams = () => {
throttleTimer = null;
if (!pendingParams || !host.isStarted) {
pendingParams = null;
return;
}
const copy = new Float32Array(pendingParams);
pendingParams = null;
try {
host.setParams(copy);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[mode-runtime] setParams failed:', err);
}
};
const scheduleParamFlush = (params: Float32Array) => {
pendingParams = params;
if (throttleTimer === null) {
throttleTimer = window.setTimeout(flushParams, ENGINE_PARAM_THROTTLE_MS);
}
};
// Pipe paramOutputs into the engine host whenever they change.
createEffect(() => {
const out = paramOutputs();
if (out.length === 0) return;
if (!host.isStarted) return;
scheduleParamFlush(out);
});
const engineId = (opts.engineOverride ?? (schema.engine_id as EngineId));
const startAudio = async (): Promise<void> => {
if (opts.audioDisabled) return;
try {
await host.start(engineId);
setAudioStarted(true);
const out = paramOutputs();
if (out.length > 0) host.setParams(new Float32Array(out));
} catch (err) {
// eslint-disable-next-line no-console
console.error('[mode-runtime] audio start failed:', err);
}
};
const stopAudio = async (): Promise<void> => {
try {
await host.stop();
} finally {
setAudioStarted(false);
}
};
// Switch engine if mode changes engine_id (e.g. on remount).
onMount(() => {
if (host.isStarted) host.setEngine(engineId);
});
onCleanup(() => {
if (throttleTimer !== null) {
clearTimeout(throttleTimer);
throttleTimer = null;
}
pendingParams = null;
});
// ----- Mic input -------------------------------------------------------
const mic = new MicInput();
const [micStarted, setMicStarted] = createSignal(false);
const startMic = async () => {
try {
await mic.start();
setMicStarted(true);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[mode-runtime] mic start failed:', err);
}
};
const stopMic = async () => {
try {
await mic.stop();
} finally {
setMicStarted(false);
}
};
onCleanup(() => { void stopMic(); });
// ----- Snapshot stack helpers ------------------------------------------
const canUndoMemo = createMemo(() => sessionStore.state.snapshots.length > 0);
// ----- Heatmap sampler -------------------------------------------------
const sampler = new HeatmapSampler({ resolution: 16 });
const [heatmapColorMode, setHeatmapColorMode] = createSignal<HeatmapColorMode>('luminance');
const [heatmapCells, setHeatmapCells] = createSignal<Float32Array>(sampler.getCells(), { equals: false });
const refreshHeatmap = (force = false): void => {
untrack(() => {
if (!ready()) return;
const cfg = inputStore.config;
const z = cfg.zoom;
const cx = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorX;
const cy = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorY;
const took = sampler.update({ cx, cy, zoom: z }, force);
if (took) {
setHeatmapCells(new Float32Array(sampler.getCells()));
}
});
};
// Refresh heatmap on weight changes (training, RL feedback, randomize).
const offTrained = coreBus.on('ml.trained', () => refreshHeatmap());
const offDelta = coreBus.on('ml.delta_update', () => refreshHeatmap());
onCleanup(() => { offTrained(); offDelta(); });
// Initial heatmap refresh once WASM is ready.
createEffect(() => {
if (ready()) refreshHeatmap(true);
});
// ----- Auto-explore loop -----------------------------------------------
let autoExploreTimer: number | null = null;
const tickAutoExplore = () => {
untrack(() => {
if (!ready()) return;
if (!explorationStore.state.autoExploreEnabled) return;
const intensity = explorationStore.state.autoExploreIntensity;
// Zoom-scaled intensity: smaller zoom = gentler.
const zoom = inputStore.config.zoom;
const scaledIntensity = intensity * (0.3 + 0.7 * zoom);
const cap = explorationStore.state.noiseCap * scaledIntensity;
autoSnapshot('before auto-explore');
const spread = explorationStore.state.spread;
const overrides = modeStore.state.overrides[schema.mode_id] ?? {};
const pinMask = buildPinMask(
mlStore.state.outputSize,
schema.mode_id,
schema.params,
overrides,
sessionStore.state.paramPins,
);
mlStore.moveWeights(cap, spread, pinMask);
explorationStore.growNoise(0.5);
// Re-run inference to update the heatmap and visuals.
const [x, y] = pipedInput();
setInput(x, y);
});
};
createEffect(() => {
const enabled = explorationStore.state.autoExploreEnabled;
const interval = explorationStore.state.autoExploreIntervalMs;
if (autoExploreTimer !== null) {
window.clearInterval(autoExploreTimer);
autoExploreTimer = null;
}
if (enabled) {
autoExploreTimer = window.setInterval(tickAutoExplore, interval);
}
});
onCleanup(() => {
if (autoExploreTimer !== null) {
window.clearInterval(autoExploreTimer);
autoExploreTimer = null;
}
});
// ----- Jolt (held-button continuous weight morph) ----------------------
// Inert until press(). While active, a ~200Hz control-rate timer glides a
// scatter of the network's weights toward random targets (port of
// nisps/ml/jolt.hpp). release() freezes them where they landed.
const jolt = new Jolt();
let joltTimer: number | null = null;
// ~200Hz to match the upstream firmware control rate the constants assume.
const JOLT_TICK_MS = 5;
const tickJolt = () => {
if (!ready() || !jolt.active()) return;
const w = mlStore.getWeights();
if (w.length === 0) return;
jolt.step(w);
mlStore.setWeights(w);
coreBus.emit('ml.delta_update', { reason: 'jolt' });
// Re-run inference so the audio + visuals reflect the morphed weights.
const [x, y] = pipedInput();
setInput(x, y);
};
const joltPress = () => {
if (!ready()) return;
autoSnapshot('before jolt');
jolt.press(mlStore.iml?.weightCount ?? mlStore.getWeights().length);
if (joltTimer === null) {
joltTimer = window.setInterval(tickJolt, JOLT_TICK_MS);
}
};
const joltRelease = () => {
jolt.release();
if (joltTimer !== null) {
window.clearInterval(joltTimer);
joltTimer = null;
}
};
onCleanup(() => {
if (joltTimer !== null) {
window.clearInterval(joltTimer);
joltTimer = null;
}
});
// ----- Explore (OU) control-rate driver --------------------------------
// The OU walk advances inside recomputeOutputs (driven by setInput). When
// the input is static the walk would stall, so while Explore is active we
// keep ticking so the sound keeps roaming. Inert when intensity is 0.
let exploreTimer: number | null = null;
const EXPLORE_TICK_MS = 30;
const setExploreIntensity = (level: number) => {
ouExplore.setIntensity(level);
setExploreIntensitySig(ouExplore.intensity());
if (ouExplore.enabled() && exploreTimer === null) {
exploreTimer = window.setInterval(() => {
untrack(() => {
if (!ready()) return;
// Re-run the output pipeline (advances the OU state) and reship.
recomputeOutputs();
});
}, EXPLORE_TICK_MS);
} else if (!ouExplore.enabled() && exploreTimer !== null) {
window.clearInterval(exploreTimer);
exploreTimer = null;
ouExplore.reset();
}
};
onCleanup(() => {
if (exploreTimer !== null) {
window.clearInterval(exploreTimer);
exploreTimer = null;
}
});
// ----- Touch / pressure feedback ---------------------------------------
const onPointerDown = (e: PointerEvent) => {
pressDownAt = performance.now();
explorationStore.setPressure(
// Force is 0..1 on supported devices; default 0.5 elsewhere.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(e as any).pressure ?? 0.5,
0,
);
};
const onPointerUp = () => {
pressDownAt = null;
explorationStore.setPressure(0, 0);
};
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', onPointerDown, { passive: true });
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
onCleanup(() => {
window.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
});
}
// ----- Training helpers -------------------------------------------------
const trainOnCurrent = () => {
if (!ready()) return;
autoSnapshot('before train');
const lr = explorationStore.state.learningRate;
mlStore.train(lr, schema.ml.default_max_iterations, 0.001);
};
const thumbsUp = () => {
if (!ready()) return;
const [x, y] = pipedInput();
const out = paramOutputs();
if (out.length === 0) return;
const features = new Array(schema.ml.input_size).fill(0);
features[0] = x;
if (features.length > 1) features[1] = y;
// Mic features into the example too, if active.
if (mic.isRunning() && schema.ml.input_size > 2) {
const f = mic.getFeatures();
const fv = [f.energy, f.brightness, f.pitch, f.aperiodicity];
for (let i = 2; i < schema.ml.input_size; ++i) {
features[i] = fv[i - 2] ?? 0;
}
}
// Labels: the raw 126-vector targets (not the override-applied; the MLP
// doesn't know about overrides).
const labels = Array.from(processedOutputs());
autoSnapshot('before thumbs-up');
mlStore.addExample(features, labels);
explorationStore.decayNoise(explorationStore.state.pressureForce);
trainOnCurrent();
};
const thumbsDown = () => {
if (!ready()) return;
autoSnapshot('before thumbs-down');
const cap = explorationStore.state.noiseCap;
const spread = explorationStore.state.spread;
const overrides = modeStore.state.overrides[schema.mode_id] ?? {};
const pinMask = buildPinMask(
mlStore.state.outputSize,
schema.mode_id,
schema.params,
overrides,
sessionStore.state.paramPins,
);
mlStore.moveWeights(cap, spread, pinMask);
explorationStore.growNoise(explorationStore.state.pressureForce);
const [x, y] = pipedInput();
setInput(x, y);
};
const randomize = () => {
if (!ready()) return;
autoSnapshot('before randomize');
mlStore.drawWeights(explorationStore.state.spread);
const [x, y] = pipedInput();
setInput(x, y);
};
const undo = (): boolean => {
const ok = undoLastSnapshot();
if (ok) {
const [x, y] = pipedInput();
setInput(x, y);
}
return ok;
};
// ----- Region pins ------------------------------------------------------
const pinCurrentRegion = () => {
const cfg = inputStore.config;
const z = cfg.zoom;
const cx = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorX;
const cy = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorY;
const halfZ = z * 0.5;
sessionStore.addRegionPin({
x: Math.max(0, cx - halfZ),
y: Math.max(0, cy - halfZ),
width: Math.min(z, 1),
height: Math.min(z, 1),
});
autoSnapshot('pinned baseline');
};
// Snap-to-trail
const snapToTrail = (p: { x: number; y: number }) => {
batch(() => {
setInput(p.x, p.y);
});
};
return {
setInput,
pipedInput,
frozen,
rawOutputs: rawOutputsAccessor,
processedOutputs,
paramOutputs,
ready,
audio: {
started: audioStarted,
start: startAudio,
stop: stopAudio,
setMuted: (muted) => host.setMuted(muted),
},
mic: {
started: micStarted,
start: startMic,
stop: stopMic,
},
training: {
busy: () => mlStore.state.training,
examples: () => mlStore.state.exampleCount,
lastLoss: () => mlStore.state.lastLoss,
lossHistory: () => mlStore.state.lossHistory,
},
trainOnCurrent,
thumbsUp,
thumbsDown,
randomize,
undo,
canUndo: () => canUndoMemo(),
trail: trailRing.points,
snapToTrail,
heatmap: {
cells: heatmapCells,
setColorMode: (m) => {
sampler.setColorMode(m);
setHeatmapColorMode(m);
refreshHeatmap(true);
},
colorMode: heatmapColorMode,
refresh: refreshHeatmap,
resolution: () => sampler.resolution,
},
pinCurrentRegion,
jolt: {
press: joltPress,
release: joltRelease,
active: () => jolt.active(),
},
explore: {
setIntensity: setExploreIntensity,
intensity: exploreIntensity,
},
};
}
/**
* Disposes the shared EngineHost. Test helper production never calls this.
*/
export function __disposeEngineHost(): void {
if (engineHost) {
engineHost.dispose();
engineHost = null;
}
}

View file

@ -1,128 +0,0 @@
/**
* Curve catalog TypeScript mirror of the named curves from
* nisps/core/math.hpp (forthcoming, stream 1). All inputs and outputs are in
* [0, 1] unless noted otherwise.
*
* IMPORTANT: This file MUST stay in lockstep with the C++ side. The
* authoritative reference is `nisps/core/math.hpp`. Golden-vector tests
* (stream 11) compare WASM-computed vs TS-computed outputs and fail on
* any drift.
*
* Architecture §5.3:
* linear, exp, log, square, sqrt, sigmoid, cubic, centered_power
*
* The "centered_power" variant comes from the legacy input/output pipelines
* and shapes around 0.5 instead of 0.0. Kept as a named curve because both
* input and output pipelines use it.
*/
export type CurveName =
| 'linear'
| 'exp'
| 'log'
| 'square'
| 'sqrt'
| 'sigmoid'
| 'cubic'
| 'centered_power';
/** Hard clamp to [0, 1]. */
export function clamp01(v: number): number {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
/** Generic clamp. */
export function clamp(v: number, lo: number, hi: number): number {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
/** Linear: identity. */
export function curveLinear(x: number): number {
return clamp01(x);
}
/** Exponential: e^(k*x) - 1, normalized to [0,1] over [0,1] input. */
export function curveExp(x: number, k: number = 4.0): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const denom = Math.exp(k) - 1.0;
if (denom === 0) return x;
return (Math.exp(k * x) - 1.0) / denom;
}
/** Inverse of curveExp. */
export function curveLog(x: number, k: number = 4.0): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const denom = Math.exp(k) - 1.0;
if (denom === 0) return x;
return Math.log(1 + x * denom) / k;
}
/** Square: x^2. */
export function curveSquare(x: number): number {
const v = clamp01(x);
return v * v;
}
/** Square-root. */
export function curveSqrt(x: number): number {
return Math.sqrt(clamp01(x));
}
/** Logistic sigmoid mapped onto [0,1] domain (centered at x=0.5). */
export function curveSigmoid(x: number, slope: number = 8.0): number {
// Sigmoid centered at 0.5 with given slope. Output is in (0, 1).
// Normalize so endpoints map exactly to 0 and 1.
const t = (x - 0.5) * slope;
const s = 1 / (1 + Math.exp(-t));
// Anchor: when x=0, t=-slope/2; when x=1, t=+slope/2
const sLo = 1 / (1 + Math.exp(slope / 2));
const sHi = 1 / (1 + Math.exp(-slope / 2));
return (s - sLo) / (sHi - sLo);
}
/** Cubic ease-in-out. */
export function curveCubic(x: number): number {
const v = clamp01(x);
// Smoothstep cubic: 3v^2 - 2v^3
return v * v * (3 - 2 * v);
}
/**
* Centered power curve. Pivots around 0.5.
*
* exponent < 1 push toward extremes
* exponent = 1 identity
* exponent > 1 pull toward center
*/
export function curveCenteredPower(x: number, exponent: number): number {
if (exponent === 1) return clamp01(x);
const offset = x - 0.5;
const sign = offset < 0 ? -1 : 1;
// Range [-0.5, 0.5] -> [-1, 1] for the power op, then halve back.
const shaped = (sign * Math.pow(Math.abs(offset) * 2, exponent)) / 2;
return clamp01(shaped + 0.5);
}
/** Apply by name. `param` interpretation depends on the curve. */
export function applyCurve(name: CurveName, x: number, param?: number): number {
switch (name) {
case 'linear': return curveLinear(x);
case 'exp': return curveExp(x, param ?? 4.0);
case 'log': return curveLog(x, param ?? 4.0);
case 'square': return curveSquare(x);
case 'sqrt': return curveSqrt(x);
case 'sigmoid': return curveSigmoid(x, param ?? 8.0);
case 'cubic': return curveCubic(x);
case 'centered_power': return curveCenteredPower(x, param ?? 1.0);
}
}
export const CURVE_NAMES: ReadonlyArray<CurveName> = [
'linear', 'exp', 'log', 'square', 'sqrt', 'sigmoid', 'cubic', 'centered_power',
];

View file

@ -1,122 +0,0 @@
/**
* ou-explore.ts Ornstein-Uhlenbeck exploration noise on the output.
*
* Faithful TS port of `nisps/ml/ou_noise.hpp`. Adds a per-output-channel
* Ornstein-Uhlenbeck random walk to the network's action (output) vector
* before it reaches audio. Unlike i.i.d. per-frame noise, an OU process is
* temporally correlated: each output drifts in long, smooth sweeps and is
* gently pulled back toward the mapping output (mean reversion). Because
* learning stays active while the noise roams, "likes" registered during
* the wander steer the network toward sounds the player wants.
*
* Discrete Euler-Maruyama update, per output channel x (mu = 0):
* x += theta * (-x) * dt + noiseScale * N(0,1)
* out = clamp(out + x, 0, 1)
* where, to make the stationary std equal a requested `std`:
* noiseScale = std * sqrt(2 * theta * dt)
* and the exploration knob [0,1] maps std = level * kMaxAmplitude (0.65).
*
* RNG note: the C++ owns a per-instance deterministic xoshiro256+ for
* firmwarebrowser parity. This is a browser-only exploration aid that
* never round-trips through WASM, so `Math.random()` is fine. The gaussian
* is reproduced as the same sum-of-three-uniforms shape the C++ `Rng` uses
* (`next_float_gaussian`).
*
* DEFAULT STATE IS INERT: intensity defaults to 0, `enabled()` is false, and
* `apply()` neither advances the RNG nor touches the output so the output
* passes through bit-identically when intensity is 0 (parity-safe).
*/
/** Upstream kMaxAmplitude: the exploration knob's full-scale stationary std. */
export const OU_MAX_AMPLITUDE = 0.65;
/** Sum-of-three-uniforms gaussian (matches nisps::Rng::next_float_gaussian). */
function gaussian(stddev = 1): number {
// Three uniforms in [-1, 1): variance 1/3 each ⇒ sum has variance 1.
const a = Math.random() * 2 - 1;
const b = Math.random() * 2 - 1;
const c = Math.random() * 2 - 1;
return (a + b + c) * stddev;
}
export class OUExplore {
private theta_ = 0.02;
private dt_ = 0.001;
private stationaryStd_ = 0;
private noiseScale_ = 0;
private state_: Float32Array = new Float32Array(0);
constructor() {
this.recomputeScale_();
}
/**
* Exploration amount in [0,1]; 0 disables (inert). Maps to the OU
* stationary std = level * kMaxAmplitude.
*/
setIntensity(level: number): void {
let l = level;
if (l < 0) l = 0;
else if (l > 1) l = 1;
this.stationaryStd_ = l * OU_MAX_AMPLITUDE;
this.recomputeScale_();
}
intensity(): number {
return this.stationaryStd_ / OU_MAX_AMPLITUDE;
}
enabled(): boolean {
return this.stationaryStd_ > 0;
}
setTheta(theta: number): void {
this.theta_ = theta;
this.recomputeScale_();
}
setDt(dt: number): void {
this.dt_ = dt;
this.recomputeScale_();
}
/**
* Advance the per-channel OU state and add it (clamped) to `out`, mutating
* `out` in place. No-op (no state advance, no output change) when disabled.
* `out` is the post-inference parameter vector.
*/
apply(out: Float32Array): void {
if (!this.enabled()) return;
const n = out.length;
if (this.state_.length < n) {
// Grow the per-channel state, preserving existing drift.
const next = new Float32Array(n);
next.set(this.state_);
this.state_ = next;
}
const theta = this.theta_;
const dt = this.dt_;
const scale = this.noiseScale_;
for (let i = 0; i < n; ++i) {
// mu = 0: the walk is an offset that mean-reverts to zero, so the
// network's own mapping output stays the anchor.
let s = this.state_[i]!;
s += theta * -s * dt + scale * gaussian(1);
this.state_[i] = s;
let v = out[i]! + s;
if (v < 0) v = 0;
else if (v > 1) v = 1;
out[i] = v;
}
}
reset(): void {
this.state_.fill(0);
}
private recomputeScale_(): void {
let k = 2 * this.theta_ * this.dt_;
if (k < 0) k = 0;
this.noiseScale_ = this.stationaryStd_ * Math.sqrt(k);
}
}

View file

@ -1,153 +0,0 @@
/**
* Output pipeline pure TS port of legacy `js/ui/output-pipeline.js`.
*
* Stages (in order) for each output:
* 1. Global power curve (raw^exponent, exponent in [0.2, 5.0])
* 2. Per-output EMA smoothing (frame-rate-independent)
* 3. Slew-rate limiting (max change per second per output)
* 4. Freeze gate (global) and per-output freeze mask
*
* `processOutput` is a pure function: takes the raw output vector, the prior
* processed vector (or null on first call), and config; returns a new
* Float32Array. Consumer (output-store) holds prev between frames.
*
* NOTE: Reuses an internal scratch buffer ONLY when a prev buffer of the
* exact same length is supplied AND `cfg.reuseBuffer === true`. Otherwise it
* allocates a fresh Float32Array (safer for cross-component sharing).
*/
import { clamp, clamp01 } from './curves';
export const GLOBAL_CURVE_MIN = 0.2;
export const GLOBAL_CURVE_MAX = 5.0;
export const SMOOTHING_MAX = 0.95;
export const SLEW_RATE_MIN = 0.005;
const REFERENCE_DT = 1 / 60;
export interface OutputConfig {
/** Power curve exponent applied to ALL outputs. 1 = linear. */
globalCurve: number;
/** EMA smoothing factor [0, 0.95]. */
smoothing: number;
/** Max change per second per output. Infinity = unlimited. */
slewRate: number;
/** Global freeze gate. */
freezeOutput: boolean;
/** Per-output freeze mask (1 = frozen). Length must match output vector. */
freezeMask: Uint8Array | null;
/** If true and prev buffer matches length, reuse it for processed output. */
reuseBuffer: boolean;
}
export interface OutputState {
/** Last processed output (kept here for slew/freeze logic). */
prev: Float32Array | null;
/** Last EMA-smoothed values per output. */
smoothed: Float32Array | null;
}
export function defaultOutputConfig(): OutputConfig {
return {
globalCurve: 1.0,
smoothing: 0,
slewRate: Infinity,
freezeOutput: false,
freezeMask: null,
reuseBuffer: false,
};
}
export function defaultOutputState(): OutputState {
return { prev: null, smoothed: null };
}
function emaSmooth(prev: number, raw: number, smoothing: number, dt: number): number {
if (smoothing <= 0) return raw;
const effectiveDt = dt > 0 ? dt : REFERENCE_DT;
const alpha = 1 - smoothing;
const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT);
return prev + alphaEff * (raw - prev);
}
/**
* Process raw outputs through global curve smoothing slew freeze gate.
*
* @param raw raw output vector (Float32Array of size N)
* @param cfg pipeline config
* @param state prior state (use {@link defaultOutputState} first call)
* @param dtMs time since last call in milliseconds
* @returns { processed, state } with the new outputs and updated state
*/
export function processOutput(
raw: Float32Array,
cfg: OutputConfig,
state: OutputState,
dtMs: number,
): { processed: Float32Array; state: OutputState } {
const n = raw.length;
const dt = Math.max(0, dtMs / 1000);
let prev = state.prev;
let smoothed = state.smoothed;
if (!prev || prev.length !== n) {
prev = new Float32Array(n);
// Seed from raw on first call
for (let i = 0; i < n; i++) prev[i] = clamp01(raw[i] ?? 0);
}
if (!smoothed || smoothed.length !== n) {
smoothed = new Float32Array(n);
for (let i = 0; i < n; i++) smoothed[i] = clamp01(raw[i] ?? 0);
}
let processed: Float32Array;
if (cfg.reuseBuffer && prev.length === n) {
processed = prev;
} else {
processed = new Float32Array(n);
}
// Stage 1: global curve (mutates a working scratch via direct compute)
const exp = cfg.globalCurve;
if (cfg.freezeOutput) {
// Output frozen: hold prior values.
if (processed !== prev) {
processed.set(prev);
}
return { processed, state: { prev: processed, smoothed } };
}
for (let i = 0; i < n; i++) {
const r = clamp01(raw[i] ?? 0);
const curved = exp === 1.0 ? r : Math.pow(r, exp);
// Per-output freeze
if (cfg.freezeMask && cfg.freezeMask[i]) {
processed[i] = prev[i] ?? curved;
continue;
}
// Stage 2: EMA smoothing
let value = emaSmooth(smoothed[i] ?? curved, curved, cfg.smoothing, dt);
smoothed[i] = value;
// Stage 3: slew-rate limit
if (isFinite(cfg.slewRate) && cfg.slewRate > 0) {
const maxDelta = cfg.slewRate * dt;
const delta = value - (prev[i] ?? value);
if (Math.abs(delta) > maxDelta) {
value = (prev[i] ?? value) + Math.sign(delta) * maxDelta;
}
}
processed[i] = clamp01(value);
}
// Update prev for next call
if (processed !== prev) {
prev = new Float32Array(processed); // copy so caller can hold processed buffer freely
}
return { processed, state: { prev, smoothed } };
}

View file

@ -1,43 +0,0 @@
import { Component, createSignal } from 'solid-js';
import { ControlAxis } from './ControlAxis';
export const ControlAxisDemo: Component = () => {
const [boldness, setBoldness] = createSignal(0.5);
const [memory, setMemory] = createSignal(0.7);
const [precision, setPrecision] = createSignal(0.3);
const [preset, setPreset] = createSignal<string | null>('default');
const onAny = () => setPreset(null);
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px', 'min-width': '320px' }}>
<ControlAxis
label="Boldness"
value={boldness}
onChange={(v) => { setBoldness(v); onAny(); }}
preset={preset}
endpoints={['Caution', 'Bold']}
accentColor="var(--accent)"
onDoubleTap={() => setBoldness(0.5)}
/>
<ControlAxis
label="Memory"
value={memory}
onChange={(v) => { setMemory(v); onAny(); }}
preset={preset}
endpoints={['Amnesia', 'Elephant']}
accentColor="var(--accent-2)"
onDoubleTap={() => setMemory(0.5)}
/>
<ControlAxis
label="Precision"
value={precision}
onChange={(v) => { setPrecision(v); onAny(); }}
preset={preset}
endpoints={['Raw', 'Precise']}
accentColor="var(--good)"
onDoubleTap={() => setPrecision(0.3)}
/>
</div>
);
};

View file

@ -1,99 +0,0 @@
.axis {
display: flex;
flex-direction: column;
gap: var(--sp-1);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
padding: var(--sp-2) var(--sp-3);
--axis-accent: var(--accent);
}
.head {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
}
.label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg);
flex: 1;
}
.preset {
color: var(--axis-accent);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.value {
font-variant-numeric: tabular-nums;
color: var(--fg-mute);
font-size: var(--fs-xs);
min-width: 4ch;
text-align: right;
}
.input {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 24px;
background: transparent;
margin: 0;
cursor: pointer;
}
.input::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: var(--bg-3);
}
.input::-moz-range-track {
height: 6px;
border-radius: 999px;
background: var(--bg-3);
}
.input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--axis-accent);
margin-top: -6px;
box-shadow: 0 0 10px var(--axis-accent);
cursor: pointer;
}
.input::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--axis-accent);
border: none;
box-shadow: 0 0 10px var(--axis-accent);
}
.input:focus { outline: none; }
.endpoints {
display: flex;
justify-content: space-between;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
}
.disabled {
opacity: 0.5;
pointer-events: none;
}

View file

@ -1,73 +0,0 @@
import { Component, Show } from 'solid-js';
import styles from './ControlAxis.module.css';
export interface ControlAxisProps {
label: string;
value: () => number;
onChange: (v: number) => void;
/** Active control preset id (rendered as subtitle when set). */
preset?: () => string | null;
/** Endpoint labels (left, right). E.g. ['Caution', 'Bold']. */
endpoints?: readonly [string, string];
/** Called when user double-taps the axis (re-link offsets). */
onDoubleTap?: () => void;
/** Disabled state. */
disabled?: boolean;
/** Visual color hint (defaults to --accent). */
accentColor?: string;
}
export const ControlAxis: Component<ControlAxisProps> = (props) => {
let lastTap = 0;
const onPointerDown = () => {
const now = performance.now();
if (now - lastTap < 350 && props.onDoubleTap) {
props.onDoubleTap();
lastTap = 0;
} else {
lastTap = now;
}
};
const onInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
const v = parseFloat(e.currentTarget.value);
if (!Number.isNaN(v)) props.onChange(Math.max(0, Math.min(1, v)));
};
const accent = () => props.accentColor ?? 'var(--accent)';
return (
<div
class={styles.axis}
classList={{ [styles.disabled]: !!props.disabled }}
style={{ '--axis-accent': accent() }}
>
<div class={styles.head}>
<span class={styles.label}>{props.label}</span>
<Show when={props.preset?.()}>
<span class={styles.preset}>{props.preset!()}</span>
</Show>
<span class={styles.value}>{props.value().toFixed(2)}</span>
</div>
<input
class={styles.input}
type="range"
min={0}
max={1}
step={0.001}
value={props.value()}
onInput={onInput}
onPointerDown={onPointerDown}
disabled={props.disabled}
aria-label={`${props.label} control axis`}
/>
<Show when={props.endpoints}>
<div class={styles.endpoints}>
<span>{props.endpoints![0]}</span>
<span>{props.endpoints![1]}</span>
</div>
</Show>
</div>
);
};

View file

@ -1,21 +0,0 @@
import { Component, createSignal } from 'solid-js';
import { Drawer } from './Drawer';
export const DrawerDemo: Component = () => {
const [openR, setOpenR] = createSignal(false);
const [openL, setOpenL] = createSignal(false);
return (
<div style={{ display: 'flex', gap: '8px', 'flex-wrap': 'wrap' }}>
<button type="button" onClick={() => setOpenL(true)}>Open Left</button>
<button type="button" onClick={() => setOpenR(true)}>Open Right</button>
<Drawer open={openL()} onClose={() => setOpenL(false)} side="left" title="Left drawer">
<p>Slides in from the left.</p>
<p>Press Escape, click outside, or click × to close.</p>
</Drawer>
<Drawer open={openR()} onClose={() => setOpenR(false)} side="right" title="Right drawer">
<p>Slides in from the right.</p>
<p>Mode-specific drawers will use this primitive.</p>
</Drawer>
</div>
);
};

View file

@ -1,92 +0,0 @@
.root {
position: fixed;
inset: 0;
z-index: var(--z-drawer);
pointer-events: none;
}
.scrim {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
pointer-events: auto;
animation: fadeIn var(--dur-fast) var(--ease);
}
.panel {
position: absolute;
top: 0;
bottom: 0;
background: var(--bg-1);
border-left: 1px solid var(--line);
pointer-events: auto;
display: flex;
flex-direction: column;
box-shadow: 0 0 24px rgba(0, 0, 0, 0.5);
animation: slideIn var(--dur-med) var(--ease);
}
.left .panel {
left: 0;
border-right: 1px solid var(--line);
border-left: 0;
animation-name: slideInLeft;
}
.right .panel {
right: 0;
}
.header {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
.title {
flex: 1;
font-size: var(--fs-md);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-mute);
}
.close {
background: transparent;
border: 0;
font-size: 22px;
color: var(--fg-mute);
width: 32px;
height: 32px;
border-radius: var(--r-1);
padding: 0;
}
.close:hover {
background: var(--bg-2);
color: var(--fg);
}
.body {
flex: 1;
overflow: auto;
padding: var(--sp-3) var(--sp-4);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}

View file

@ -1,69 +0,0 @@
import { Component, JSX, onCleanup, onMount, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import styles from './Drawer.module.css';
export interface DrawerProps {
open: boolean;
onClose: () => void;
side: 'left' | 'right';
title?: string;
children: JSX.Element;
/** Optional width in px (default 360). */
width?: number;
/** Set to false to disable click-outside / Escape close behaviour. */
dismissable?: boolean;
}
export const Drawer: Component<DrawerProps> = (props) => {
const onKey = (e: KeyboardEvent) => {
if (!props.open) return;
if (props.dismissable === false) return;
if (e.key === 'Escape') {
e.stopPropagation();
props.onClose();
}
};
onMount(() => {
document.addEventListener('keydown', onKey);
});
onCleanup(() => {
document.removeEventListener('keydown', onKey);
});
return (
<Show when={props.open}>
<Portal>
<div
class={styles.root}
classList={{ [styles.left]: props.side === 'left', [styles.right]: props.side === 'right' }}
role="dialog"
aria-label={props.title ?? 'drawer'}
aria-modal="false"
>
<div
class={styles.scrim}
onClick={() => {
if (props.dismissable !== false) props.onClose();
}}
/>
<aside
class={styles.panel}
style={{ width: `${props.width ?? 360}px` }}
>
<header class={styles.header}>
<span class={styles.title}>{props.title ?? ''}</span>
<button
type="button"
class={styles.close}
onClick={props.onClose}
aria-label="Close drawer"
>×</button>
</header>
<div class={styles.body}>{props.children}</div>
</aside>
</div>
</Portal>
</Show>
);
};

View file

@ -1,36 +0,0 @@
import { Component, createMemo, createSignal } from 'solid-js';
import { GradientFlow, type GradientStatus } from './GradientFlow';
import { PillToggle } from './PillToggle';
export const GradientFlowDemo: Component = () => {
const [pattern, setPattern] = createSignal<'healthy' | 'vanishing' | 'exploding'>('healthy');
const norms = createMemo<number[]>(() => {
const p = pattern();
if (p === 'vanishing') return [1.0, 0.4, 0.15, 0.04];
if (p === 'exploding') return [0.4, 0.8, 1.6, 3.2];
return [0.6, 0.55, 0.5, 0.5];
});
const status = createMemo<GradientStatus[]>(() => {
const p = pattern();
if (p === 'vanishing') return ['healthy', 'vanishing', 'vanishing', 'vanishing'];
if (p === 'exploding') return ['healthy', 'healthy', 'exploding', 'exploding'];
return ['healthy', 'healthy', 'healthy', 'healthy'];
});
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' }}>
<GradientFlow layerNorms={norms} status={status} />
<PillToggle
options={[
{ value: 'healthy', label: 'Healthy' },
{ value: 'vanishing', label: 'Vanishing' },
{ value: 'exploding', label: 'Exploding' },
]}
value={pattern}
onChange={(v) => setPattern(v as 'healthy' | 'vanishing' | 'exploding')}
/>
</div>
);
};

View file

@ -1,6 +0,0 @@
.canvas {
display: block;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
}

View file

@ -1,69 +0,0 @@
import { Component, createEffect } from 'solid-js';
import styles from './GradientFlow.module.css';
export type GradientStatus = 'vanishing' | 'exploding' | 'converged' | 'healthy';
export interface GradientFlowProps {
/** Per-layer L2 norms of weight delta. */
layerNorms: () => ReadonlyArray<number>;
/** Per-layer status. Length must match layerNorms(). */
status: () => ReadonlyArray<GradientStatus>;
width?: number;
height?: number;
ariaLabel?: string;
}
const STATUS_COLOR: Record<GradientStatus, string> = {
vanishing: '#5b9eef',
exploding: '#ef5b5b',
converged: '#9a9a9a',
healthy: '#6bc26b',
};
export const GradientFlow: Component<GradientFlowProps> = (props) => {
let canvasEl: HTMLCanvasElement | undefined;
createEffect(() => {
if (!canvasEl) return;
const dpr = window.devicePixelRatio || 1;
const w = (props.width ?? 240) * dpr;
const h = (props.height ?? 80) * dpr;
if (canvasEl.width !== w || canvasEl.height !== h) {
canvasEl.width = w;
canvasEl.height = h;
}
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
const norms = props.layerNorms();
const status = props.status();
if (norms.length === 0) return;
let max = 0;
for (const n of norms) if (n > max) max = n;
if (max <= 0) max = 1;
const barW = w / norms.length;
for (let i = 0; i < norms.length; i++) {
const bh = (norms[i]! / max) * (h - 14 * dpr);
const s = status[i] ?? 'healthy';
ctx.fillStyle = STATUS_COLOR[s];
ctx.fillRect(i * barW + 2, h - bh - 2, barW - 4, bh);
ctx.fillStyle = '#5a5a5a';
ctx.font = `${10 * dpr}px var(--font-mono)`;
ctx.textAlign = 'center';
ctx.fillText(`L${i}`, i * barW + barW / 2, h - 2);
}
});
return (
<canvas
ref={canvasEl}
class={styles.canvas}
style={{ width: `${props.width ?? 240}px`, height: `${props.height ?? 80}px` }}
role="img"
aria-label={props.ariaLabel ?? 'Gradient flow per layer'}
/>
);
};

View file

@ -1,46 +0,0 @@
import { Component, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { Heatmap, type HeatmapColorMode } from './Heatmap';
import { PillToggle } from './PillToggle';
const N = 16;
function generateField(seed: number): Float32Array {
const arr = new Float32Array(N * N);
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const fx = (x - N / 2) / (N / 2);
const fy = (y - N / 2) / (N / 2);
const r = Math.sqrt(fx * fx + fy * fy);
const v = 0.5 + 0.5 * Math.sin(r * 3 + seed * 0.5) * Math.cos(fx * 4 + seed);
arr[y * N + x] = Math.max(0, Math.min(1, v));
}
}
return arr;
}
export const HeatmapDemo: Component = () => {
const [mode, setMode] = createSignal<HeatmapColorMode>('luminance');
const [tick, setTick] = createSignal(0);
let timer: ReturnType<typeof setInterval>;
onMount(() => {
timer = setInterval(() => setTick((t) => t + 1), 200);
});
onCleanup(() => clearInterval(timer));
const samples = createMemo(() => generateField(tick()));
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px', 'align-items': 'center' }}>
<Heatmap samples={samples} resolution={N} colorMode={mode()} size={240} showGrid={false} />
<PillToggle
options={[
{ value: 'luminance', label: 'Luminance' },
{ value: 'variance', label: 'Variance' },
{ value: 'divergence', label: 'Divergence' },
]}
value={mode}
onChange={(v) => setMode(v as HeatmapColorMode)}
/>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show more