From 95054a70dfdd9ded709cd363cc24de1769027220 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 01:02:41 +0100 Subject: [PATCH] feat: initial Reaktor modular synth app React + Tone.js modular synthesizer with visual node editor. Includes: Oscillator, Filter, Envelope, LFO, VCA, Delay, Reverb, Distortion, Mixer, Scope, Output, and Keyboard modules. SVG wire connections, knob controls, preset save/load system. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + Dockerfile | 13 + index.html | 13 + package-lock.json | 1727 +++++++++++++++++++++++++++++ package.json | 21 + public/favicon.svg | 8 + server.js | 47 + src/App.jsx | 321 ++++++ src/components/KeyboardWidget.jsx | 91 ++ src/components/Knob.jsx | 82 ++ src/components/ModuleNode.jsx | 150 +++ src/components/ModulePalette.jsx | 24 + src/components/PresetModal.jsx | 72 ++ src/components/ScopeDisplay.jsx | 50 + src/components/WireLayer.jsx | 65 ++ src/engine/audioEngine.js | 343 ++++++ src/engine/moduleRegistry.js | 259 +++++ src/engine/presets.js | 83 ++ src/engine/state.js | 131 +++ src/index.css | 245 ++++ src/main.jsx | 6 + src/utils/bezier.js | 8 + vite.config.js | 8 + 23 files changed, 3770 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 server.js create mode 100644 src/App.jsx create mode 100644 src/components/KeyboardWidget.jsx create mode 100644 src/components/Knob.jsx create mode 100644 src/components/ModuleNode.jsx create mode 100644 src/components/ModulePalette.jsx create mode 100644 src/components/PresetModal.jsx create mode 100644 src/components/ScopeDisplay.jsx create mode 100644 src/components/WireLayer.jsx create mode 100644 src/engine/audioEngine.js create mode 100644 src/engine/moduleRegistry.js create mode 100644 src/engine/presets.js create mode 100644 src/engine/state.js create mode 100644 src/index.css create mode 100644 src/main.jsx create mode 100644 src/utils/bezier.js create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b431156 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.vite diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..112bd6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY --from=build /app/dist ./dist +COPY server.js . +EXPOSE 80 +CMD ["node", "server.js"] diff --git a/index.html b/index.html new file mode 100644 index 0000000..903f633 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Reaktor โ€” MontLab Modular Synth + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..72e8582 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1727 @@ +{ + "name": "reaktor-montlab", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reaktor-montlab", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tone": "^14.8.49" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/automation-events": { + "version": "7.1.16", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.16.tgz", + "integrity": "sha512-vAAHG8WO+Cx2PfwmWIAxSD51ZYg+zRam52pzOGVAJOqsqQO6oaPM2k4/cdEF7QQ786FYB8Wzbw//qTWCdyGvzA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/standardized-audio-context": { + "version": "25.3.77", + "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", + "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "automation-events": "^7.0.9", + "tslib": "^2.7.0" + } + }, + "node_modules/tone": { + "version": "14.9.17", + "resolved": "https://registry.npmjs.org/tone/-/tone-14.9.17.tgz", + "integrity": "sha512-+Qb7M4NMua+tb5Z52+MEVmjye0fjJuIFBePx423pqr9E6/lHDqZAG+fUAvo+Ujm48q0s9bVLRAyT1ETJJglNtg==", + "license": "MIT", + "dependencies": { + "standardized-audio-context": "^25.3.70", + "tslib": "^2.3.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d85af9 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "reaktor-montlab", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "start": "node server.js" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tone": "^14.8.49" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.4.0" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..0f37a79 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..0bece0f --- /dev/null +++ b/server.js @@ -0,0 +1,47 @@ +// Lightweight static file server for Reaktor modular synth +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = process.env.PORT || 80; +const STATIC_DIR = path.join(__dirname, 'dist'); + +const MIME = { + '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff2': 'font/woff2', + '.wasm': 'application/wasm', +}; + +const server = http.createServer((req, res) => { + let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url); + + // SPA fallback: if file doesn't exist, serve index.html + if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + filePath = path.join(STATIC_DIR, 'index.html'); + } + + if (!filePath.startsWith(STATIC_DIR)) { + res.writeHead(403); res.end('Forbidden'); return; + } + + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME[ext] || 'application/octet-stream'; + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(err.code === 'ENOENT' ? 404 : 500); + res.end(err.code === 'ENOENT' ? 'Not found' : 'Server error'); + return; + } + if (['.png', '.jpg', '.woff2', '.js', '.css'].includes(ext)) { + res.setHeader('Cache-Control', 'public, max-age=604800'); + } + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +}); + +server.listen(PORT, () => { + console.log(`[reaktor] Running on port ${PORT}`); +}); diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..a7beaaa --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,321 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { state, subscribe, addModule, emit, addConnection, updateModulePosition } from './engine/state.js'; +import { startAudio, stopAudio, connectWire, rebuildGraph, getAudioNode } from './engine/audioEngine.js'; +import { getModuleDef, PORT_TYPE } from './engine/moduleRegistry.js'; +import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js'; +import ModuleNode from './components/ModuleNode.jsx'; +import WireLayer from './components/WireLayer.jsx'; +import ModulePalette from './components/ModulePalette.jsx'; +import PresetModal from './components/PresetModal.jsx'; + +export default function App() { + const [, forceUpdate] = useState(0); + const containerRef = useRef(null); + const portPositions = useRef({}); + const [tempWire, setTempWire] = useState(null); + const connectingRef = useRef(null); + const [presetModal, setPresetModal] = useState(null); + const importRef = useRef(null); + + // Subscribe to state changes + useEffect(() => { + const unsub = subscribe(() => forceUpdate(n => n + 1)); + return unsub; + }, []); + + // Auto-load on mount + useEffect(() => { + const loaded = autoLoad(); + if (loaded && state.isRunning) { + startAudio(); + } + }, []); + + // Auto-save interval + useEffect(() => { + const interval = setInterval(autoSave, 3000); + return () => clearInterval(interval); + }, []); + + // Port position reporting + const handlePortPosition = useCallback((moduleId, portName, direction, el) => { + const key = `${moduleId}-${portName}-${direction}`; + portPositions.current[key] = el; + }, []); + + // Start connecting a wire + const handleStartConnect = useCallback((info) => { + connectingRef.current = info; + const containerRect = containerRef.current.getBoundingClientRect(); + setTempWire({ + portType: info.portType, + startX: info.startX - containerRect.left, + startY: info.startY - containerRect.top, + endX: info.startX - containerRect.left, + endY: info.startY - containerRect.top, + }); + }, []); + + // Canvas pointer events + const handlePointerDown = useCallback((e) => { + if (e.button === 1 || (e.button === 0 && e.altKey)) { + // Middle click or Alt+click: start panning + state.panning = true; + state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; + e.preventDefault(); + } else if (e.button === 2) { + // Right click: pan + state.panning = true; + state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; + e.preventDefault(); + } else if (e.button === 0 && !connectingRef.current) { + // Left click on empty space: deselect + state.selectedModuleId = null; + emit(); + } + }, []); + + const handlePointerMove = useCallback((e) => { + // Panning + if (state.panning && state.panStart) { + state.camX = e.clientX - state.panStart.x; + state.camY = e.clientY - state.panStart.y; + emit(); + return; + } + + // Module dragging + if (state.dragging) { + const newX = e.clientX / state.zoom - state.dragging.offsetX; + const newY = e.clientY / state.zoom - state.dragging.offsetY; + updateModulePosition(state.dragging.moduleId, newX, newY); + return; + } + + // Temp wire + if (connectingRef.current && containerRef.current) { + const containerRect = containerRef.current.getBoundingClientRect(); + setTempWire(prev => prev ? { + ...prev, + endX: e.clientX - containerRect.left, + endY: e.clientY - containerRect.top, + } : null); + } + }, []); + + const handlePointerUp = useCallback((e) => { + if (state.panning) { + state.panning = false; + state.panStart = null; + } + + if (state.dragging) { + state.dragging = null; + emit(); + } + + // End connecting โ€” check if we're over a port + if (connectingRef.current) { + const target = document.elementFromPoint(e.clientX, e.clientY); + if (target && target.classList.contains('port-dot')) { + // Find which module/port this target belongs to + const moduleEl = target.closest('.module'); + if (moduleEl) { + finishConnection(target, e); + } + } + connectingRef.current = null; + setTempWire(null); + } + }, []); + + const finishConnection = (portEl, e) => { + const from = connectingRef.current; + if (!from) return; + + // Find target module and port by inspecting DOM + // Walk up to .module, find moduleId, then find port + const moduleEl = portEl.closest('.module'); + if (!moduleEl) return; + + // Get all port-row elements to find index + const portRows = moduleEl.querySelectorAll('.port-row'); + let targetModuleId = null; + let targetPort = null; + let targetDirection = null; + + for (const mod of state.modules) { + const def = getModuleDef(mod.type); + if (!def) continue; + + const modX = mod.x * state.zoom; + const modY = mod.y * state.zoom; + const containerRect = containerRef.current.getBoundingClientRect(); + const moduleRect = moduleEl.getBoundingClientRect(); + + // Check if this module element matches + const expectedLeft = modX + state.camX + containerRect.left; + if (Math.abs(moduleRect.left - expectedLeft) > 5) continue; + + targetModuleId = mod.id; + + // Find which port-dot was clicked + const allDots = moduleEl.querySelectorAll('.port-dot'); + const allInputs = def.inputs.map(p => p.name); + const allOutputs = def.outputs.map(p => p.name); + + allDots.forEach((dot, idx) => { + if (dot === portEl) { + if (idx < allInputs.length) { + targetPort = allInputs[idx]; + targetDirection = 'input'; + } else { + targetPort = allOutputs[idx - allInputs.length]; + targetDirection = 'output'; + } + } + }); + break; + } + + if (!targetModuleId || !targetPort) return; + if (targetModuleId === from.moduleId) return; // No self-connections + + // Determine from/to based on direction + let fromMod, fromPort, toMod, toPort; + if (from.direction === 'output' && targetDirection === 'input') { + fromMod = from.moduleId; fromPort = from.port; + toMod = targetModuleId; toPort = targetPort; + } else if (from.direction === 'input' && targetDirection === 'output') { + fromMod = targetModuleId; fromPort = targetPort; + toMod = from.moduleId; toPort = from.port; + } else { + return; // Invalid: same direction + } + + const connId = addConnection(fromMod, fromPort, toMod, toPort); + if (connId && state.isRunning) { + const conn = state.connections.find(c => c.id === connId); + if (conn) connectWire(conn); + } + }; + + const handleWheel = useCallback((e) => { + e.preventDefault(); + const delta = -e.deltaY * 0.001; + const newZoom = Math.max(0.3, Math.min(3, state.zoom + delta)); + state.zoom = newZoom; + emit(); + }, []); + + const handleContextMenu = useCallback((e) => e.preventDefault(), []); + + // Toolbar actions + const handleToggleAudio = async () => { + if (state.isRunning) { + stopAudio(); + } else { + await startAudio(); + } + emit(); + }; + + const handleAddModule = (type) => { + const x = (-state.camX + 300) / state.zoom + Math.random() * 50; + const y = (-state.camY + 200) / state.zoom + Math.random() * 50; + const id = addModule(type, x, y); + if (state.isRunning) { + rebuildGraph(); + } + }; + + const handleImport = async (e) => { + const file = e.target.files[0]; + if (!file) return; + await importPatch(file); + emit(); + e.target.value = ''; + }; + + return ( +
+ {/* Toolbar */} +
+ Reaktor +
+ +
+
+ + + + + +
+
+ + {state.isRunning ? 'โ— LIVE' : 'โ—‹ OFF'} + + + {state.modules.length} modules ยท {state.connections.length} wires + +
+ + {/* Main canvas area */} +
+
+ {/* Grid background */} + + + + + + + + + + {/* Modules container (offset by camera) */} +
+ {state.modules.map(mod => ( + + ))} +
+ + {/* Wire layer */} + +
+ + {/* Module palette */} + +
+ + {/* Status bar */} +
+ Reaktor โ€” MontLab Modular Synth + Zoom: {(state.zoom * 100).toFixed(0)}% + Scroll: pan ยท Wheel: zoom ยท Click port + drag: wire ยท Click wire: delete +
+ + {/* Preset modal */} + {presetModal && setPresetModal(null)} />} +
+ ); +} diff --git a/src/components/KeyboardWidget.jsx b/src/components/KeyboardWidget.jsx new file mode 100644 index 0000000..6afc1ba --- /dev/null +++ b/src/components/KeyboardWidget.jsx @@ -0,0 +1,91 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { triggerKeyboard } from '../engine/audioEngine.js'; +import { state } from '../engine/state.js'; + +const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +// Computer keyboard to semitone offset mapping (2 octaves) +const KEY_MAP = { + 'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6, + 'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12, + 'q': 12, '2': 13, 'w': 14, '3': 15, 'e': 16, 'r': 17, + '5': 18, 't': 19, '6': 20, 'y': 21, '7': 22, 'u': 23, 'i': 24, +}; + +function midiToFreq(midi) { + return 440 * Math.pow(2, (midi - 69) / 12); +} + +export default function KeyboardWidget({ moduleId }) { + const mod = state.modules.find(m => m.id === moduleId); + const octave = mod?.params?.octave ?? 4; + const activeKeys = useRef(new Set()); + + const playNote = useCallback((semitone) => { + const midi = (octave + 1) * 12 + semitone; + const freq = midiToFreq(midi); + triggerKeyboard(moduleId, freq, true); + }, [moduleId, octave]); + + const stopNote = useCallback(() => { + triggerKeyboard(moduleId, 440, false); + }, [moduleId]); + + useEffect(() => { + const handleDown = (e) => { + if (e.repeat) return; + const key = e.key.toLowerCase(); + if (KEY_MAP[key] !== undefined && !activeKeys.current.has(key)) { + activeKeys.current.add(key); + playNote(KEY_MAP[key]); + } + }; + const handleUp = (e) => { + const key = e.key.toLowerCase(); + if (KEY_MAP[key] !== undefined) { + activeKeys.current.delete(key); + if (activeKeys.current.size === 0) stopNote(); + } + }; + + window.addEventListener('keydown', handleDown); + window.addEventListener('keyup', handleUp); + return () => { + window.removeEventListener('keydown', handleDown); + window.removeEventListener('keyup', handleUp); + }; + }, [playNote, stopNote]); + + // Draw mini keyboard (1 octave) + const whites = [0, 2, 4, 5, 7, 9, 11]; + const blacks = [1, 3, -1, 6, 8, 10]; + + return ( +
+ + {whites.map((note, i) => ( + playNote(note)} + onPointerUp={stopNote} + /> + ))} + {blacks.filter(n => n >= 0).map((note, i) => { + const pos = [1, 2, 4, 5, 6][i]; + return ( + playNote(note)} + onPointerUp={stopNote} + /> + ); + })} + +
+ Z-M / Q-I keys ยท Oct {octave} +
+
+ ); +} diff --git a/src/components/Knob.jsx b/src/components/Knob.jsx new file mode 100644 index 0000000..23d2c88 --- /dev/null +++ b/src/components/Knob.jsx @@ -0,0 +1,82 @@ +import React, { useRef, useCallback } from 'react'; + +const SIZE = 32; +const RADIUS = 12; +const STROKE = 3; +const START_ANGLE = 225; +const END_ANGLE = -45; +const RANGE = 270; // degrees + +function polarToCart(cx, cy, r, deg) { + const rad = (deg - 90) * Math.PI / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; +} + +function describeArc(cx, cy, r, startDeg, endDeg) { + const start = polarToCart(cx, cy, r, endDeg); + const end = polarToCart(cx, cy, r, startDeg); + const large = endDeg - startDeg <= 180 ? '0' : '1'; + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`; +} + +export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue }) { + const ref = useRef(null); + const dragRef = useRef(null); + + const norm = Math.max(0, Math.min(1, (value - min) / (max - min))); + const angleDeg = START_ANGLE - norm * RANGE; + + const cx = SIZE / 2, cy = SIZE / 2; + const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE); + const fillAngle = START_ANGLE - norm * RANGE; + const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : ''; + + const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg); + + const displayVal = formatValue ? formatValue(value) : + value >= 1000 ? `${(value / 1000).toFixed(1)}k` : + value >= 100 ? Math.round(value) : + value >= 1 ? value.toFixed(1) : + value.toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); + + const handlePointerDown = useCallback((e) => { + e.preventDefault(); e.stopPropagation(); + dragRef.current = { startY: e.clientY, startValue: value }; + const handleMove = (me) => { + const dy = dragRef.current.startY - me.clientY; + const sensitivity = (max - min) / 200; + let newVal = dragRef.current.startValue + dy * sensitivity; + newVal = Math.max(min, Math.min(max, newVal)); + // Snap to nice values for integer ranges + if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) { + newVal = Math.round(newVal); + } + onChange(newVal); + }; + const handleUp = () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + dragRef.current = null; + }; + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + }, [value, min, max, onChange]); + + const handleWheel = useCallback((e) => { + e.preventDefault(); e.stopPropagation(); + const step = (max - min) / 100; + const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step)); + onChange(newVal); + }, [value, min, max, onChange]); + + return ( +
+ + + {fillPath && } + + +
+ ); +} diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx new file mode 100644 index 0000000..d450f6d --- /dev/null +++ b/src/components/ModuleNode.jsx @@ -0,0 +1,150 @@ +import React, { useCallback, useRef } from 'react'; +import { getModuleDef } from '../engine/moduleRegistry.js'; +import { state, removeModule, updateModuleParam, updateModulePosition, isPortConnected, emit } from '../engine/state.js'; +import { updateParam } from '../engine/audioEngine.js'; +import Knob from './Knob.jsx'; +import ScopeDisplay from './ScopeDisplay.jsx'; +import KeyboardWidget from './KeyboardWidget.jsx'; + +export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) { + const def = getModuleDef(mod.type); + if (!def) return null; + + const isSelected = state.selectedModuleId === mod.id; + + // Merge default params + const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params }; + + const handleParamChange = useCallback((name, value) => { + updateModuleParam(mod.id, name, value); + updateParam(mod.id, name, value); + }, [mod.id]); + + const handleHeaderDown = useCallback((e) => { + if (e.button !== 0) return; + e.stopPropagation(); + state.selectedModuleId = mod.id; + state.dragging = { + moduleId: mod.id, + offsetX: e.clientX / zoom - mod.x, + offsetY: e.clientY / zoom - mod.y, + }; + emit(); + }, [mod, zoom]); + + const handleDelete = useCallback((e) => { + e.stopPropagation(); + removeModule(mod.id); + }, [mod.id]); + + const handlePortMouseDown = useCallback((e, portName, direction) => { + e.stopPropagation(); e.preventDefault(); + const portDef = direction === 'output' + ? def.outputs.find(p => p.name === portName) + : def.inputs.find(p => p.name === portName); + if (!portDef) return; + + const rect = e.currentTarget.getBoundingClientRect(); + onStartConnect({ + moduleId: mod.id, + port: portName, + portType: portDef.type, + direction, + startX: rect.left + rect.width / 2, + startY: rect.top + rect.height / 2, + }); + }, [mod.id, def, onStartConnect]); + + // Report port positions for wire rendering + const portRef = useCallback((el, portName, direction) => { + if (el) { + onPortPosition(mod.id, portName, direction, el); + } + }, [mod.id, onPortPosition]); + + return ( +
{ state.selectedModuleId = mod.id; emit(); }} + > +
+ {def.icon} + {def.name} + +
+ +
+ {/* Input ports */} + {def.inputs.map(port => ( +
+
portRef(el, port.name, 'input')} + onPointerDown={e => handlePortMouseDown(e, port.name, 'input')} + /> + {port.label} +
+ ))} + + {/* Parameters */} + {Object.entries(def.params).map(([name, paramDef]) => { + if (paramDef.type === 'knob') { + const color = paramDef.unit === 'Hz' ? 'var(--accent)' : + paramDef.unit === 'dB' ? 'var(--green)' : + paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)'; + return ( +
+ {paramDef.label} + handleParamChange(name, v)} + color={color} + /> + + {params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` : + params[name] >= 100 ? Math.round(params[name]) : + params[name] >= 1 ? Number(params[name]).toFixed(1) : + Number(params[name]).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')} + {paramDef.unit ? ` ${paramDef.unit}` : ''} + +
+ ); + } + if (paramDef.type === 'select') { + return ( +
+ {paramDef.label} + +
+ ); + } + return null; + })} + + {/* Scope display */} + {mod.type === 'scope' && } + + {/* Keyboard widget */} + {mod.type === 'keyboard' && } + + {/* Output ports */} + {def.outputs.map(port => ( +
+
portRef(el, port.name, 'output')} + onPointerDown={e => handlePortMouseDown(e, port.name, 'output')} + /> + {port.label} +
+ ))} +
+
+ ); +} diff --git a/src/components/ModulePalette.jsx b/src/components/ModulePalette.jsx new file mode 100644 index 0000000..8917488 --- /dev/null +++ b/src/components/ModulePalette.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { getModulesByCategory } from '../engine/moduleRegistry.js'; + +export default function ModulePalette({ onAddModule }) { + const categories = getModulesByCategory(); + + return ( +
+
Modules
+ {Object.entries(categories).map(([cat, modules]) => ( + +
{cat}
+ {modules.map(def => ( +
onAddModule(def.type)}> + {def.icon} + {def.name} +
+ ))} +
+ ))} +
+ ); +} diff --git a/src/components/PresetModal.jsx b/src/components/PresetModal.jsx new file mode 100644 index 0000000..c4ac3a0 --- /dev/null +++ b/src/components/PresetModal.jsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { getPresets, savePreset, loadPreset, deletePreset } from '../engine/presets.js'; + +export default function PresetModal({ mode, onClose }) { + const [name, setName] = useState(''); + const presets = getPresets(); + + const handleSave = () => { + if (!name.trim()) return; + savePreset(name.trim()); + onClose(); + }; + + const handleLoad = (presetName) => { + loadPreset(presetName); + onClose(); + }; + + const handleDelete = (e, presetName) => { + e.stopPropagation(); + deletePreset(presetName); + // Force re-render + setName(n => n + ''); + }; + + return ( +
+
e.stopPropagation()}> +

{mode === 'save' ? 'Save Preset' : 'Load Preset'}

+ + {mode === 'save' && ( + <> + setName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSave()} + /> +
+ + +
+ + )} + + {mode === 'load' && ( + <> +
+ {presets.length === 0 && ( +
No presets saved yet
+ )} + {presets.map(p => ( +
handleLoad(p.name)}> + {p.name} + {p.modules?.length || 0} modules + +
+ ))} +
+
+ +
+ + )} +
+
+ ); +} diff --git a/src/components/ScopeDisplay.jsx b/src/components/ScopeDisplay.jsx new file mode 100644 index 0000000..737d488 --- /dev/null +++ b/src/components/ScopeDisplay.jsx @@ -0,0 +1,50 @@ +import React, { useRef, useEffect } from 'react'; +import { getAnalyserData } from '../engine/audioEngine.js'; + +export default function ScopeDisplay({ moduleId }) { + const canvasRef = useRef(null); + const rafRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const w = canvas.width = 160; + const h = canvas.height = 60; + + const draw = () => { + ctx.fillStyle = '#050510'; + ctx.fillRect(0, 0, w, h); + + // Grid lines + ctx.strokeStyle = '#151530'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); + ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4); + ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4); + ctx.stroke(); + + const data = getAnalyserData(moduleId); + if (data && data.length > 0) { + ctx.strokeStyle = '#00e5ff'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + const step = w / data.length; + for (let i = 0; i < data.length; i++) { + const y = h / 2 + data[i] * h / 2 * -1; + if (i === 0) ctx.moveTo(0, y); + else ctx.lineTo(i * step, y); + } + ctx.stroke(); + } + + rafRef.current = requestAnimationFrame(draw); + }; + draw(); + + return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; + }, [moduleId]); + + return ; +} diff --git a/src/components/WireLayer.jsx b/src/components/WireLayer.jsx new file mode 100644 index 0000000..49f7602 --- /dev/null +++ b/src/components/WireLayer.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { wirePath } from '../utils/bezier.js'; +import { state, removeConnection } from '../engine/state.js'; +import { disconnectWire } from '../engine/audioEngine.js'; +import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js'; + +export default function WireLayer({ portPositions, tempWire, containerRef }) { + const getPortPos = (moduleId, portName, direction) => { + const key = `${moduleId}-${portName}-${direction}`; + const el = portPositions.current[key]; + if (!el || !containerRef.current) return null; + const rect = el.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2 - containerRect.left, + y: rect.top + rect.height / 2 - containerRect.top, + }; + }; + + const getPortType = (moduleId, portName, direction) => { + const mod = state.modules.find(m => m.id === moduleId); + if (!mod) return PORT_TYPE.AUDIO; + const def = getModuleDef(mod.type); + if (!def) return PORT_TYPE.AUDIO; + const ports = direction === 'output' ? def.outputs : def.inputs; + const port = ports.find(p => p.name === portName); + return port?.type || PORT_TYPE.AUDIO; + }; + + const handleWireClick = (conn) => { + disconnectWire(conn); + removeConnection(conn.id); + }; + + return ( + + {/* Existing connections */} + {state.connections.map(conn => { + const from = getPortPos(conn.from.moduleId, conn.from.port, 'output'); + const to = getPortPos(conn.to.moduleId, conn.to.port, 'input'); + if (!from || !to) return null; + + const portType = getPortType(conn.from.moduleId, conn.from.port, 'output'); + + return ( + handleWireClick(conn)} + /> + ); + })} + + {/* Temp wire while connecting */} + {tempWire && ( + + )} + + ); +} diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js new file mode 100644 index 0000000..d39dd3e --- /dev/null +++ b/src/engine/audioEngine.js @@ -0,0 +1,343 @@ +/** + * audioEngine.js โ€” Bridge between node graph state and Tone.js audio graph + * Creates, connects, and destroys Tone.js nodes as the user edits the patch + */ +import * as Tone from 'tone'; +import { state } from './state.js'; +import { getModuleDef } from './moduleRegistry.js'; + +// Map moduleId โ†’ { node: Tone.js node, inputs: {portName: node/param}, outputs: {portName: node} } +const audioNodes = {}; + +// Active keyboard state +const keyboardState = { frequency: 440, gate: false }; + +// ==================== Node creation ==================== + +function createNode(mod) { + const def = getModuleDef(mod.type); + if (!def) return null; + + const p = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params }; + + switch (mod.type) { + case 'oscillator': { + const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune }); + osc.start(); + return { + node: osc, + inputs: { freq: osc.frequency, detune: osc.detune }, + outputs: { out: osc }, + dispose: () => { osc.stop(); osc.dispose(); }, + }; + } + case 'lfo': { + const lfo = new Tone.LFO({ type: p.waveform, frequency: p.frequency, amplitude: p.amplitude, min: -1, max: 1 }); + lfo.start(); + return { + node: lfo, + inputs: {}, + outputs: { out: lfo }, + dispose: () => { lfo.stop(); lfo.dispose(); }, + }; + } + case 'noise': { + const noise = new Tone.Noise(p.type); + noise.start(); + return { + node: noise, + inputs: {}, + outputs: { out: noise }, + dispose: () => { noise.stop(); noise.dispose(); }, + }; + } + case 'filter': { + const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q }); + return { + node: filter, + inputs: { in: filter, cutoff: filter.frequency }, + outputs: { out: filter }, + dispose: () => filter.dispose(), + }; + } + case 'envelope': { + const env = new Tone.Envelope({ attack: p.attack, decay: p.decay, sustain: p.sustain, release: p.release }); + // Connect env to a signal so it can be used as modulation source + const sig = new Tone.Signal(0); + env.connect(sig); + return { + node: env, + _sig: sig, + inputs: { gate: null }, // Gate is handled via triggerAttack/Release + outputs: { out: sig }, + dispose: () => { env.dispose(); sig.dispose(); }, + }; + } + case 'vca': { + // Use a Multiply node: in ร— cv + const gain = new Tone.Gain(p.gain); + return { + node: gain, + inputs: { in: gain, cv: gain.gain }, + outputs: { out: gain }, + dispose: () => gain.dispose(), + }; + } + case 'delay': { + const delay = new Tone.FeedbackDelay({ delayTime: p.delayTime, feedback: p.feedback, wet: p.wet }); + return { + node: delay, + inputs: { in: delay }, + outputs: { out: delay }, + dispose: () => delay.dispose(), + }; + } + case 'reverb': { + const reverb = new Tone.Reverb({ decay: p.decay, wet: p.wet }); + return { + node: reverb, + inputs: { in: reverb }, + outputs: { out: reverb }, + dispose: () => reverb.dispose(), + }; + } + case 'distortion': { + const dist = new Tone.Distortion({ distortion: p.distortion, wet: p.wet }); + return { + node: dist, + inputs: { in: dist }, + outputs: { out: dist }, + dispose: () => dist.dispose(), + }; + } + case 'mixer': { + const master = new Tone.Gain(1); + const ch1 = new Tone.Gain(p.gain1); + const ch2 = new Tone.Gain(p.gain2); + const ch3 = new Tone.Gain(p.gain3); + const ch4 = new Tone.Gain(p.gain4); + ch1.connect(master); ch2.connect(master); ch3.connect(master); ch4.connect(master); + return { + node: master, + _channels: [ch1, ch2, ch3, ch4], + inputs: { in1: ch1, in2: ch2, in3: ch3, in4: ch4 }, + outputs: { out: master }, + dispose: () => { [ch1, ch2, ch3, ch4, master].forEach(n => n.dispose()); }, + }; + } + case 'scope': { + const analyser = new Tone.Analyser('waveform', 256); + return { + node: analyser, + inputs: { in: analyser }, + outputs: {}, + analyser, + dispose: () => analyser.dispose(), + }; + } + case 'output': { + const gain = new Tone.Gain(Tone.dbToGain(p.volume)); + gain.toDestination(); + return { + node: gain, + inputs: { left: gain, right: gain }, + outputs: {}, + dispose: () => { gain.disconnect(); gain.dispose(); }, + }; + } + case 'keyboard': { + // Keyboard outputs frequency as a Signal and gate as a Signal + const freqSig = new Tone.Signal(440); + const gateSig = new Tone.Signal(0); + return { + node: null, + inputs: {}, + outputs: { freq: freqSig, gate: gateSig }, + _freqSig: freqSig, + _gateSig: gateSig, + dispose: () => { freqSig.dispose(); gateSig.dispose(); }, + }; + } + default: + return null; + } +} + +// ==================== Public API ==================== + +export function ensureNode(moduleId) { + if (audioNodes[moduleId]) return audioNodes[moduleId]; + const mod = state.modules.find(m => m.id === moduleId); + if (!mod) return null; + const node = createNode(mod); + if (node) audioNodes[moduleId] = node; + return node; +} + +export function getAudioNode(moduleId) { + return audioNodes[moduleId] || null; +} + +export function destroyNode(moduleId) { + const entry = audioNodes[moduleId]; + if (!entry) return; + try { entry.dispose(); } catch (e) { console.warn('dispose error', e); } + delete audioNodes[moduleId]; +} + +export function connectWire(conn) { + const fromEntry = ensureNode(conn.from.moduleId); + const toEntry = ensureNode(conn.to.moduleId); + if (!fromEntry || !toEntry) return; + + const output = fromEntry.outputs[conn.from.port]; + const input = toEntry.inputs[conn.to.port]; + if (!output || input === undefined || input === null) return; + + try { + if (typeof output.connect === 'function') { + output.connect(input); + } + } catch (e) { + console.warn('connect error', e); + } +} + +export function disconnectWire(conn) { + const fromEntry = audioNodes[conn.from.moduleId]; + const toEntry = audioNodes[conn.to.moduleId]; + if (!fromEntry || !toEntry) return; + + const output = fromEntry.outputs[conn.from.port]; + const input = toEntry.inputs[conn.to.port]; + if (!output || !input) return; + + try { + if (typeof output.disconnect === 'function') { + output.disconnect(input); + } + } catch (e) { + // Tone.js may throw if not connected + } +} + +export function updateParam(moduleId, paramName, value) { + const entry = audioNodes[moduleId]; + const mod = state.modules.find(m => m.id === moduleId); + if (!entry || !mod) return; + + const def = getModuleDef(mod.type); + if (!def) return; + + switch (mod.type) { + case 'oscillator': + if (paramName === 'waveform') entry.node.type = value; + else if (paramName === 'frequency') entry.node.frequency.value = value; + else if (paramName === 'detune') entry.node.detune.value = value; + break; + case 'lfo': + if (paramName === 'waveform') entry.node.type = value; + else if (paramName === 'frequency') entry.node.frequency.value = value; + else if (paramName === 'amplitude') entry.node.amplitude.value = value; + break; + case 'noise': + if (paramName === 'type') entry.node.type = value; + break; + case 'filter': + if (paramName === 'type') entry.node.type = value; + else if (paramName === 'frequency') entry.node.frequency.value = value; + else if (paramName === 'Q') entry.node.Q.value = value; + break; + case 'envelope': + if (paramName === 'attack') entry.node.attack = value; + else if (paramName === 'decay') entry.node.decay = value; + else if (paramName === 'sustain') entry.node.sustain = value; + else if (paramName === 'release') entry.node.release = value; + break; + case 'vca': + if (paramName === 'gain') entry.node.gain.value = value; + break; + case 'delay': + if (paramName === 'delayTime') entry.node.delayTime.value = value; + else if (paramName === 'feedback') entry.node.feedback.value = value; + else if (paramName === 'wet') entry.node.wet.value = value; + break; + case 'reverb': + if (paramName === 'decay') entry.node.decay = value; + else if (paramName === 'wet') entry.node.wet.value = value; + break; + case 'distortion': + if (paramName === 'distortion') entry.node.distortion = value; + else if (paramName === 'wet') entry.node.wet.value = value; + break; + case 'mixer': + if (paramName.startsWith('gain')) { + const idx = parseInt(paramName.replace('gain', '')) - 1; + if (entry._channels && entry._channels[idx]) entry._channels[idx].gain.value = value; + } + break; + case 'output': + if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value); + break; + case 'keyboard': + if (paramName === 'octave') { /* stored in state only */ } + break; + } +} + +export function triggerKeyboard(moduleId, freq, gate) { + const entry = audioNodes[moduleId]; + if (!entry) return; + if (entry._freqSig) entry._freqSig.value = freq; + if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; + + // Also trigger any connected envelopes + for (const conn of state.connections) { + if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { + const envEntry = audioNodes[conn.to.moduleId]; + if (envEntry && envEntry.node instanceof Tone.Envelope) { + if (gate) envEntry.node.triggerAttack(); + else envEntry.node.triggerRelease(); + } + } + } +} + +export async function startAudio() { + await Tone.start(); + state.isRunning = true; + + // Rebuild entire audio graph + rebuildGraph(); +} + +export function stopAudio() { + // Destroy all nodes + for (const id of Object.keys(audioNodes)) { + destroyNode(parseInt(id)); + } + state.isRunning = false; +} + +export function rebuildGraph() { + // Destroy all existing nodes + for (const id of Object.keys(audioNodes)) { + destroyNode(parseInt(id)); + } + + // Create nodes for all modules + for (const mod of state.modules) { + ensureNode(mod.id); + } + + // Create all connections + for (const conn of state.connections) { + connectWire(conn); + } +} + +export function getAnalyserData(moduleId) { + const entry = audioNodes[moduleId]; + if (!entry || !entry.analyser) return null; + return entry.analyser.getValue(); +} diff --git a/src/engine/moduleRegistry.js b/src/engine/moduleRegistry.js new file mode 100644 index 0000000..715d251 --- /dev/null +++ b/src/engine/moduleRegistry.js @@ -0,0 +1,259 @@ +/** + * moduleRegistry.js โ€” Defines all available module types + * Each module type specifies: ports, params, icon, category, and audio factory + */ + +export const PORT_TYPE = { + AUDIO: 'audio', + CONTROL: 'control', + TRIGGER: 'trigger', +}; + +// Module type definitions +const registry = {}; + +export function defineModule(type, def) { + registry[type] = { type, ...def }; +} + +export function getModuleDef(type) { + return registry[type] || null; +} + +export function getAllModuleDefs() { + return Object.values(registry); +} + +export function getModulesByCategory() { + const cats = {}; + for (const def of Object.values(registry)) { + if (!cats[def.category]) cats[def.category] = []; + cats[def.category].push(def); + } + return cats; +} + +// ==================== SOURCE ==================== + +defineModule('oscillator', { + name: 'Oscillator', + icon: '~', + category: 'Source', + inputs: [ + { name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' }, + { name: 'detune', type: PORT_TYPE.CONTROL, label: 'Detune' }, + ], + outputs: [ + { name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' }, + ], + params: { + waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sawtooth', label: 'Wave' }, + frequency: { type: 'knob', min: 20, max: 8000, default: 440, unit: 'Hz', label: 'Freq' }, + detune: { type: 'knob', min: -1200, max: 1200, default: 0, unit: 'ct', label: 'Detune' }, + }, +}); + +defineModule('lfo', { + name: 'LFO', + icon: 'โˆฟ', + category: 'Source', + inputs: [], + outputs: [ + { name: 'out', type: PORT_TYPE.CONTROL, label: 'Out' }, + ], + params: { + waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sine', label: 'Wave' }, + frequency: { type: 'knob', min: 0.01, max: 50, default: 2, unit: 'Hz', label: 'Rate' }, + amplitude: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Depth' }, + }, +}); + +defineModule('noise', { + name: 'Noise', + icon: 'โฃฟ', + category: 'Source', + inputs: [], + outputs: [ + { name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' }, + ], + params: { + type: { type: 'select', options: ['white', 'pink', 'brown'], default: 'white', label: 'Type' }, + }, +}); + +// ==================== FILTER ==================== + +defineModule('filter', { + name: 'Filter', + icon: 'โ–ฝ', + category: 'Filter', + inputs: [ + { name: 'in', type: PORT_TYPE.AUDIO, label: 'In' }, + { name: 'cutoff', type: PORT_TYPE.CONTROL, label: 'Cutoff' }, + ], + outputs: [ + { name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' }, + ], + params: { + type: { type: 'select', options: ['lowpass', 'highpass', 'bandpass', 'notch'], default: 'lowpass', label: 'Type' }, + frequency: { type: 'knob', min: 20, max: 20000, default: 1000, unit: 'Hz', label: 'Cutoff' }, + Q: { type: 'knob', min: 0.1, max: 20, default: 1, unit: '', label: 'Reso' }, + }, +}); + +// ==================== ENVELOPE ==================== + +defineModule('envelope', { + name: 'Envelope', + icon: 'โคโ•ฒ', + category: 'Modulation', + inputs: [ + { name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' }, + ], + outputs: [ + { name: 'out', type: PORT_TYPE.CONTROL, label: 'Out' }, + ], + params: { + attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' }, + decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' }, + sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' }, + release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' }, + }, +}); + +// ==================== AMPLIFIER ==================== + +defineModule('vca', { + name: 'VCA', + icon: 'โ–ณ', + category: 'Utility', + inputs: [ + { name: 'in', type: PORT_TYPE.AUDIO, label: 'In' }, + { name: 'cv', type: PORT_TYPE.CONTROL, label: 'CV' }, + ], + outputs: [ + { name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' }, + ], + params: { + gain: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Gain' }, + }, +}); + +// ==================== EFFECTS ==================== + +defineModule('delay', { + name: 'Delay', + icon: 'โŸซ', + category: 'Effect', + inputs: [ + { name: 'in', type: PORT_TYPE.AUDIO, label: 'In' }, + ], + outputs: [ + { name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' }, + ], + params: { + delayTime: { type: 'knob', min: 0.01, max: 2, default: 0.3, unit: 's', label: 'Time' }, + feedback: { type: 'knob', min: 0, max: 0.95, default: 0.4, unit: '', label: 'Feedbk' }, + wet: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Mix' }, + }, +}); + +defineModule('reverb', { + name: 'Reverb', + icon: 'โ—Œ', + category: 'Effect', + inputs: [ + { name: 'in', type: PORT_TYPE.AUDIO, label: 'In' }, + ], + outputs: [ + { name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' }, + ], + params: { + decay: { type: 'knob', min: 0.1, max: 15, default: 3, unit: 's', label: 'Decay' }, + wet: { type: 'knob', min: 0, max: 1, default: 0.4, unit: '', label: 'Mix' }, + }, +}); + +defineModule('distortion', { + name: 'Distortion', + icon: 'โšก', + category: 'Effect', + inputs: [ + { name: 'in', type: PORT_TYPE.AUDIO, label: 'In' }, + ], + outputs: [ + { name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' }, + ], + params: { + distortion: { type: 'knob', min: 0, max: 1, default: 0.4, unit: '', label: 'Drive' }, + wet: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Mix' }, + }, +}); + +// ==================== MIXER ==================== + +defineModule('mixer', { + name: 'Mixer', + icon: 'โ‰ก', + category: 'Utility', + inputs: [ + { name: 'in1', type: PORT_TYPE.AUDIO, label: 'In 1' }, + { name: 'in2', type: PORT_TYPE.AUDIO, label: 'In 2' }, + { name: 'in3', type: PORT_TYPE.AUDIO, label: 'In 3' }, + { name: 'in4', type: PORT_TYPE.AUDIO, label: 'In 4' }, + ], + outputs: [ + { name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' }, + ], + params: { + gain1: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 1' }, + gain2: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 2' }, + gain3: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 3' }, + gain4: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 4' }, + }, +}); + +// ==================== SCOPE ==================== + +defineModule('scope', { + name: 'Scope', + icon: '๐Ÿ“Š', + category: 'Utility', + inputs: [ + { name: 'in', type: PORT_TYPE.AUDIO, label: 'In' }, + ], + outputs: [], + params: {}, +}); + +// ==================== OUTPUT ==================== + +defineModule('output', { + name: 'Output', + icon: '๐Ÿ”Š', + category: 'Output', + inputs: [ + { name: 'left', type: PORT_TYPE.AUDIO, label: 'Left' }, + { name: 'right', type: PORT_TYPE.AUDIO, label: 'Right' }, + ], + outputs: [], + params: { + volume: { type: 'knob', min: -60, max: 6, default: -6, unit: 'dB', label: 'Volume' }, + }, +}); + +// ==================== KEYBOARD ==================== + +defineModule('keyboard', { + name: 'Keyboard', + icon: '๐ŸŽน', + category: 'Source', + inputs: [], + outputs: [ + { name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' }, + { name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' }, + ], + params: { + octave: { type: 'knob', min: 1, max: 8, default: 4, unit: '', label: 'Octave' }, + }, +}); diff --git a/src/engine/presets.js b/src/engine/presets.js new file mode 100644 index 0000000..148283b --- /dev/null +++ b/src/engine/presets.js @@ -0,0 +1,83 @@ +/** + * presets.js โ€” Save/load presets to localStorage + */ +import { serialize, deserialize } from './state.js'; +import { rebuildGraph } from './audioEngine.js'; + +const STORAGE_KEY = 'reaktor_presets'; +const AUTOSAVE_KEY = 'reaktor_autosave'; + +export function getPresets() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); + } catch { return []; } +} + +export function savePreset(name) { + const presets = getPresets(); + const data = serialize(); + data.name = name; + data.savedAt = new Date().toISOString(); + // Replace if same name exists + const idx = presets.findIndex(p => p.name === name); + if (idx >= 0) presets[idx] = data; + else presets.unshift(data); + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +export function loadPreset(name) { + const presets = getPresets(); + const preset = presets.find(p => p.name === name); + if (!preset) return false; + deserialize(preset); + rebuildGraph(); + return true; +} + +export function deletePreset(name) { + const presets = getPresets().filter(p => p.name !== name); + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +export function autoSave() { + const data = serialize(); + data.savedAt = new Date().toISOString(); + localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data)); +} + +export function autoLoad() { + try { + const raw = localStorage.getItem(AUTOSAVE_KEY); + if (!raw) return false; + const data = JSON.parse(raw); + deserialize(data); + return true; + } catch { return false; } +} + +export function exportPatch() { + const data = serialize(); + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'patch.json'; + a.click(); + URL.revokeObjectURL(url); +} + +export function importPatch(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target.result); + deserialize(data); + rebuildGraph(); + resolve(true); + } catch (err) { reject(err); } + }; + reader.readAsText(file); + }); +} diff --git a/src/engine/state.js b/src/engine/state.js new file mode 100644 index 0000000..ead91f0 --- /dev/null +++ b/src/engine/state.js @@ -0,0 +1,131 @@ +/** + * state.js โ€” Centralized reactive state for the modular synth + * Uses a simple pub/sub pattern for React integration + */ + +let _listeners = new Set(); +let _nextModuleId = 1; +let _nextConnectionId = 1; + +export const state = { + modules: [], // { id, type, x, y, params, collapsed } + connections: [], // { id, from: {moduleId, port}, to: {moduleId, port} } + + // Interaction + selectedModuleId: null, + dragging: null, // { moduleId, offsetX, offsetY } + connecting: null, // { moduleId, port, portType, direction, x, y } (temp wire) + + // Camera + camX: 0, camY: 0, zoom: 1, + panning: false, panStart: null, + + // Audio + isRunning: false, + masterVolume: -6, + + // UI + showPalette: true, + presetModal: null, // null | 'save' | 'load' +}; + +export function subscribe(fn) { + _listeners.add(fn); + return () => _listeners.delete(fn); +} + +export function emit() { + _listeners.forEach(fn => fn()); +} + +export function addModule(type, x, y) { + const id = _nextModuleId++; + state.modules.push({ id, type, x, y, params: {}, collapsed: false }); + state.selectedModuleId = id; + emit(); + return id; +} + +export function removeModule(id) { + state.modules = state.modules.filter(m => m.id !== id); + state.connections = state.connections.filter( + c => c.from.moduleId !== id && c.to.moduleId !== id + ); + if (state.selectedModuleId === id) state.selectedModuleId = null; + emit(); +} + +export function updateModulePosition(id, x, y) { + const m = state.modules.find(m => m.id === id); + if (m) { m.x = x; m.y = y; emit(); } +} + +export function updateModuleParam(id, paramName, value) { + const m = state.modules.find(m => m.id === id); + if (m) { m.params[paramName] = value; emit(); } +} + +export function addConnection(fromModuleId, fromPort, toModuleId, toPort) { + // Prevent duplicates + const exists = state.connections.find(c => + c.from.moduleId === fromModuleId && c.from.port === fromPort && + c.to.moduleId === toModuleId && c.to.port === toPort + ); + if (exists) return null; + + // Prevent connecting to already-connected input + const inputTaken = state.connections.find(c => + c.to.moduleId === toModuleId && c.to.port === toPort + ); + if (inputTaken) { + // Remove old connection to this input + removeConnection(inputTaken.id); + } + + const id = _nextConnectionId++; + state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } }); + emit(); + return id; +} + +export function removeConnection(id) { + state.connections = state.connections.filter(c => c.id !== id); + emit(); +} + +export function getModule(id) { + return state.modules.find(m => m.id === id) || null; +} + +export function isPortConnected(moduleId, portName, direction) { + return state.connections.some(c => + direction === 'output' + ? (c.from.moduleId === moduleId && c.from.port === portName) + : (c.to.moduleId === moduleId && c.to.port === portName) + ); +} + +// Serialization +export function serialize() { + return { + modules: state.modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params } })), + connections: state.connections.map(c => ({ ...c })), + camera: { camX: state.camX, camY: state.camY, zoom: state.zoom }, + masterVolume: state.masterVolume, + }; +} + +export function deserialize(data) { + state.modules = data.modules || []; + state.connections = data.connections || []; + if (data.camera) { + state.camX = data.camera.camX || 0; + state.camY = data.camera.camY || 0; + state.zoom = data.camera.zoom || 1; + } + state.masterVolume = data.masterVolume ?? -6; + _nextModuleId = Math.max(1, ...state.modules.map(m => m.id)) + 1; + _nextConnectionId = Math.max(1, ...state.connections.map(c => c.id)) + 1; + state.selectedModuleId = null; + emit(); +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..84a7c39 --- /dev/null +++ b/src/index.css @@ -0,0 +1,245 @@ +/* ===== Reset & Base ===== */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #08080f; + --panel: #0e0e1a; + --surface: #14142a; + --surface2: #1a1a35; + --border: #252545; + --text: #c8cce0; + --text2: #6668a0; + --accent: #00e5ff; + --accent2: #ff6644; + --green: #44ff88; + --yellow: #ffcc00; + --purple: #aa55ff; + --red: #ff4466; + --wire-audio: #00e5ff; + --wire-control: #ff6644; + --wire-trigger: #ffcc00; + --knob-track: #333; + --knob-fill: #00e5ff; + --module-w: 180; + --port-r: 6; +} + +html, body, #root { + width: 100%; height: 100%; overflow: hidden; + background: var(--bg); color: var(--text); + font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif; + font-size: 12px; + -webkit-font-smoothing: antialiased; +} + +/* ===== Layout ===== */ +.app { display: flex; flex-direction: column; height: 100vh; } + +.toolbar { + height: 40px; background: var(--panel); border-bottom: 1px solid var(--border); + display: flex; align-items: center; padding: 0 12px; gap: 8px; flex-shrink: 0; + z-index: 10; +} + +.toolbar-title { + font-weight: 700; font-size: 14px; color: var(--accent); + letter-spacing: 1px; text-transform: uppercase; margin-right: 16px; +} + +.toolbar-btn { + padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px; + background: var(--surface); color: var(--text2); cursor: pointer; + font-size: 11px; font-weight: 500; transition: all 0.15s; + font-family: inherit; +} +.toolbar-btn:hover { border-color: var(--accent); color: var(--text); } +.toolbar-btn.active { background: var(--accent); color: #000; border-color: var(--accent); } +.toolbar-btn.danger { border-color: var(--red); color: var(--red); } +.toolbar-btn.danger:hover { background: var(--red); color: #000; } + +.toolbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; } + +.toolbar-group { display: flex; gap: 4px; align-items: center; } + +.toolbar-label { color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; } + +.main-area { flex: 1; position: relative; overflow: hidden; } + +/* ===== Node Canvas ===== */ +.node-canvas { + position: absolute; inset: 0; cursor: grab; +} +.node-canvas.grabbing { cursor: grabbing; } +.node-canvas.connecting { cursor: crosshair; } + +.wires-svg { + position: absolute; inset: 0; pointer-events: none; z-index: 1; +} +.wires-svg path { + fill: none; stroke-width: 2.5; stroke-linecap: round; +} +.wires-svg path.audio { stroke: var(--wire-audio); opacity: 0.75; } +.wires-svg path.control { stroke: var(--wire-control); opacity: 0.75; } +.wires-svg path.trigger { stroke: var(--wire-trigger); opacity: 0.75; } +.wires-svg path.temp { stroke-dasharray: 6 4; opacity: 0.5; } +.wires-svg path:hover { stroke-width: 4; opacity: 1; } + +/* ===== Modules ===== */ +.module { + position: absolute; width: 180px; + background: var(--surface); border: 1px solid var(--border); + border-radius: 8px; user-select: none; z-index: 2; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + transition: box-shadow 0.15s; +} +.module.selected { border-color: var(--accent); box-shadow: 0 0 20px rgba(0,229,255,0.15); } +.module:hover { box-shadow: 0 6px 24px rgba(0,0,0,0.5); } + +.module-header { + display: flex; align-items: center; gap: 6px; + padding: 6px 10px; border-bottom: 1px solid var(--border); + cursor: grab; border-radius: 8px 8px 0 0; + background: var(--surface2); +} +.module-header .type-icon { font-size: 14px; } +.module-header .type-name { + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.5px; color: var(--text); + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.module-header .close-btn { + width: 18px; height: 18px; border: none; background: transparent; + color: var(--text2); cursor: pointer; font-size: 12px; border-radius: 3px; + display: flex; align-items: center; justify-content: center; +} +.module-header .close-btn:hover { background: var(--red); color: #fff; } + +.module-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; } + +/* Ports */ +.port-row { + display: flex; align-items: center; gap: 6px; + position: relative; height: 20px; +} +.port-row.input { flex-direction: row; } +.port-row.output { flex-direction: row-reverse; } + +.port-dot { + width: 12px; height: 12px; border-radius: 50%; + border: 2px solid var(--border); background: var(--surface); + cursor: pointer; flex-shrink: 0; transition: all 0.15s; + position: relative; +} +.port-dot.audio { border-color: var(--wire-audio); } +.port-dot.control { border-color: var(--wire-control); } +.port-dot.trigger { border-color: var(--wire-trigger); } +.port-dot:hover { transform: scale(1.3); } +.port-dot.connected { background: currentColor; } +.port-dot.audio.connected { background: var(--wire-audio); } +.port-dot.control.connected { background: var(--wire-control); } +.port-dot.trigger.connected { background: var(--wire-trigger); } +.port-dot.compatible { animation: pulse-port 0.6s infinite alternate; } + +@keyframes pulse-port { + from { box-shadow: 0 0 2px currentColor; } + to { box-shadow: 0 0 8px currentColor; } +} + +.port-label { + font-size: 10px; color: var(--text2); text-transform: uppercase; + letter-spacing: 0.3px; white-space: nowrap; +} + +/* Knobs */ +.param-row { + display: flex; align-items: center; gap: 6px; +} +.param-label { + font-size: 10px; color: var(--text2); width: 48px; + text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0; +} + +.knob-container { position: relative; width: 32px; height: 32px; flex-shrink: 0; } +.knob-svg { width: 32px; height: 32px; cursor: pointer; } +.knob-track { fill: none; stroke: var(--knob-track); stroke-width: 3; stroke-linecap: round; } +.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; } +.knob-dot { fill: var(--text); } + +.param-value { + font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace; + min-width: 40px; text-align: right; +} + +/* Select param */ +.param-select { + flex: 1; background: var(--bg); border: 1px solid var(--border); + border-radius: 3px; padding: 2px 4px; color: var(--text); + font-size: 10px; font-family: inherit; cursor: pointer; +} +.param-select:focus { outline: none; border-color: var(--accent); } + +/* Scope canvas */ +.scope-canvas { + width: 100%; height: 60px; border-radius: 4px; + background: #050510; border: 1px solid var(--border); +} + +/* ===== Module Palette (sidebar) ===== */ +.palette { + position: absolute; left: 8px; top: 8px; z-index: 20; + background: var(--panel); border: 1px solid var(--border); border-radius: 8px; + padding: 8px; display: flex; flex-direction: column; gap: 4px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + max-height: calc(100% - 16px); overflow-y: auto; +} +.palette-title { + font-size: 9px; font-weight: 700; color: var(--text2); + text-transform: uppercase; letter-spacing: 1px; padding: 2px 4px; +} +.palette-item { + display: flex; align-items: center; gap: 6px; + padding: 5px 8px; border-radius: 4px; cursor: pointer; + font-size: 11px; color: var(--text); transition: all 0.1s; +} +.palette-item:hover { background: var(--surface2); } +.palette-item .p-icon { font-size: 14px; width: 20px; text-align: center; } +.palette-item .p-name { font-weight: 500; } +.palette-item .p-cat { font-size: 9px; color: var(--text2); margin-left: auto; } + +/* ===== Status Bar ===== */ +.status-bar { + height: 24px; background: var(--panel); border-top: 1px solid var(--border); + display: flex; align-items: center; padding: 0 12px; gap: 16px; + font-size: 10px; color: var(--text2); flex-shrink: 0; z-index: 10; +} +.status-bar .status-accent { color: var(--accent); } + +/* ===== Preset Modal ===== */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.7); + display: flex; align-items: center; justify-content: center; z-index: 100; +} +.modal { + background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + padding: 20px; min-width: 360px; max-width: 500px; + box-shadow: 0 24px 64px rgba(0,0,0,0.6); +} +.modal h2 { font-size: 15px; color: var(--accent); margin-bottom: 12px; } +.modal input { + width: 100%; padding: 8px 10px; background: var(--bg); + border: 1px solid var(--border); border-radius: 4px; + color: var(--text); font-size: 13px; font-family: inherit; +} +.modal input:focus { outline: none; border-color: var(--accent); } +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; } +.modal-actions button { padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600; border: 1px solid var(--border); background: var(--surface); color: var(--text); font-family: inherit; } +.modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); } + +.preset-list { max-height: 200px; overflow-y: auto; margin: 8px 0; } +.preset-item { + padding: 6px 10px; cursor: pointer; border-radius: 4px; + display: flex; align-items: center; justify-content: space-between; + font-size: 12px; +} +.preset-item:hover { background: var(--surface2); } +.preset-item .preset-date { color: var(--text2); font-size: 10px; } diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..abfd758 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; + +createRoot(document.getElementById('root')).render(); diff --git a/src/utils/bezier.js b/src/utils/bezier.js new file mode 100644 index 0000000..9c845e4 --- /dev/null +++ b/src/utils/bezier.js @@ -0,0 +1,8 @@ +/** + * Generate SVG bezier path between two points (for wires) + */ +export function wirePath(x1, y1, x2, y2) { + const dx = Math.abs(x2 - x1); + const cp = Math.max(50, dx * 0.5); + return `M ${x1} ${y1} C ${x1 + cp} ${y1}, ${x2 - cp} ${y2}, ${x2} ${y2}`; +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..1a1b84f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { port: 3000 }, + build: { outDir: 'dist' } +});