diff --git a/.gitignore b/.gitignore index 2b914f3..01a2362 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /node_modules config.py config.js -*.pyc \ No newline at end of file +*.pyc +.ruff_cache/ \ No newline at end of file diff --git a/README.md b/README.md index 625ed61..a3bf340 100644 --- a/README.md +++ b/README.md @@ -30,19 +30,22 @@ We know that there are already some people using IDEs like Pulsar or Vim with Fo - the websocket package for Python (install it with `pip install websockets`) ## ✨ Features -- a code editor with syntax highlighting -- a beautiful and responsive interface with resizable panel and console logs -- change font family, code font size and interface font size -- change the code theme with dozens of themes available -- a console log to see the output of FoxDot with the last item appended at the top -- a panel to see all relevant information (Bpm, Scale, Root, Cpu usage, Timer, the current beat modulo, a list of active players and a list of available Loops, Synths, and Fx) -- show the list of active players with their id, name of synth or loop, time of activity (with color based on elapsed time). -- the ability to stop a player by clicking on the list. -- show the solo players with a different color -- a piano roll based on the actual scale and root and the ability to insert note in the editor by clicking on it (`Alt-P` or click on `Root` to enable/disable the piano roll) -- a auto-completion feature with the ability to insert new random player, list of synth, list of fx, list of loop and a lot more. -- comment and stop/start a player with just `Alt-x` -- and a lot more to come... +- Code editor with Python syntax highlighting +- Responsive interface with resizable panel and console logs +- Customizable font family, code font size and interface font size +- Dozens of code themes and interface themes available +- Console log with color-coded output (errors in red, prompts in green, input in blue) +- Info panel showing BPM, Scale, Root, CPU usage, Timer, beat modulo, active players, and available Loops/Synths/Fx +- Active player list with synth name, duration (color transitions from green to orange to red), and click-to-stop +- Solo players highlighted +- Piano roll based on current scale and root with click-to-insert notes (`Alt-P` or click `Root`) +- Auto-completion with synth/fx descriptions shown in the dropdown +- Line markers for visual annotation (Alt+1/2/3 for Red/Green/Blue, Alt+4 to reset) +- Comment and stop/start a player with `Alt-x` +- WebSocket auto-reconnect on connection loss +- WebSocket origin check to prevent cross-origin code injection +- Unified startup file supporting both FoxDot and Renardo +- Auto-detection of Python executable (python3/python) ## 🐍 Installation with Python @@ -61,15 +64,19 @@ nano config.py Change the FOXDOT_PATH to your foxdot path ``` -### Copy the content of this startup.py file to your FoxDot startup file: +### Copy `startup.py` and `synth_definitions.py` to your FoxDot startup directory: ```text -Your startup file is located in the FoxDot directory: -/FoxDot/lib/Custom/startup.py +Your startup directory is located in the FoxDot directory: +/FoxDot/lib/Custom/ + +Copy both files: +- startup.py +- synth_definitions.py (required for autocomplete descriptions) ``` ### Start SuperCollider as usual -### Run Foxdot with a server: +### Run FoxDot with a server: ```python python server.py ``` @@ -132,10 +139,14 @@ nano config.js Change the FOXDOT_PATH to your foxdot path. Not yet fully compatible with Renardo, you have to change the spawn in `server.js` to the correct command. ``` -### Copy the content of this startup.py file to your FoxDot startup file: +### Copy `startup.py` and `synth_definitions.py` to your FoxDot startup directory: ```text -Your startup file is located in the FoxDot directory: -/FoxDot/lib/Custom/startup.py +Your startup directory is located in the FoxDot directory: +/FoxDot/lib/Custom/ + +Copy both files: +- startup.py +- synth_definitions.py (required for autocomplete descriptions) ``` ### Start SuperCollider as usual @@ -171,39 +182,68 @@ Inject the websocket package dependency in the Renardo code: pipx inject renardo websockets ``` -Copy the content of startup-renardo.py to your Renardo startup file: +Copy `startup.py` and `synth_definitions.py` to your Renardo startup directory (the same `startup.py` now supports both FoxDot and Renardo): ```bash This should be located in the Renardo directory, something like: -~/.local/pipx/venvs/renardo/lib/python3.13/site-packages/renardo_lib/Custom/startup.py +~/.local/pipx/venvs/renardo/lib/python3.13/site-packages/renardo_lib/Custom/ + +Copy both files: +- startup.py +- synth_definitions.py (required for autocomplete descriptions) ``` ## πŸš€ Usage All things that work in FoxDot or Renardo will work in WebFoxDot. -- evaluate a line of code with `Ctrl-Enter` -- evaluate a block of code with `Ctrl-Alt-Enter` -- stop all players with `Ctrl-;` -- auto-completion with `Ctrl-Space` -- comment a line and stop a player with `Alt-x` -- you can comment and stop a block with `Ctrl-Alt-x` -- show the piano roll with `Alt-P` -- solo a player with `Alt-S` -- unsolo all players with `Ctrl-Alt-S` -- increment a value with `Alt-=` -- decrement a value with `Ctrl-Alt-=` +| Shortcut | Action | +| --------------------------- | ---------------------------------- | +| `Ctrl-Enter` | Evaluate line | +| `Ctrl-Alt-Enter` | Evaluate block | +| `Ctrl-;` | Stop all players | +| `Ctrl-Space` | Auto-completion | +| `Alt-X` | Comment line and stop player | +| `Ctrl-Alt-X` | Comment block and stop players | +| `Alt-P` | Toggle piano roll | +| `Alt-S` | Solo player | +| `Ctrl-Alt-S` | Unsolo all players | +| `Alt-=` | Increment value under cursor | +| `Ctrl-Alt-=` | Decrement value under cursor | +| `Alt-1` / `Alt-2` / `Alt-3` | Set Red / Green / Blue line marker | +| `Alt-4` | Reset all line markers | +| `Alt-F` | Open search | +| `Ctrl-G` | Find next | +| `Ctrl-S` | Save code to file | + +A full list of shortcuts is available in the config panel. + +## 🧹 Linting + +**Python** (requires [ruff](https://docs.astral.sh/ruff/)): +```bash +ruff check . +ruff format . +``` -And many more to discover, a full list of shortcuts is available in the config panel. +**JavaScript** (requires npm dependencies): +```bash +npm install +npx eslint src/js/ +``` ## πŸ—ΊοΈ Roadmap - [ ] Add more interface themes - [x] Add a vim mode -- [ ] a clear console button -- [X] renardo compatibility +- [x] Clear console button +- [x] Renardo compatibility (unified startup.py) +- [x] Error detection and colored log output +- [x] Auto-detection of python executable +- [x] Synth/Fx descriptions in autocomplete +- [x] Line markers (Alt+1/2/3/4) ## πŸ“ License -Copyright Β© 2025 [CrashServer](https://github.com/CrashServer). +Copyright Β© 2025-2026 [CrashServer](https://github.com/CrashServer). This project is MIT licensed. diff --git a/TODO.md b/TODO.md index a8b29f5..881904b 100644 --- a/TODO.md +++ b/TODO.md @@ -5,15 +5,15 @@ - [X] autocomplete # log -- [ ] fix better output (on error and color) +- [X] fix better output (on error and color) - errors detected via pattern matching, stderr captured ## Server -- [ ] python vs python3 +- [X] python vs python3 (auto-detected via `resolve_python()`) ## Autocomplete - [X] fix automatic retrieve - [X] fix end of line with . -- [ ] add definition +- [X] add definition (synth/fx descriptions in autocomplete tooltips) ## editor - [X] fix python syntax highlight \ No newline at end of file diff --git a/config.py.sample b/config.py.sample index 7a56ae9..d85eed0 100644 --- a/config.py.sample +++ b/config.py.sample @@ -1,21 +1,23 @@ # Configuration file when running the server with python server.py -# -# IMPORTANT: Please uncomment the PROGRAM_PATH AND PROGRAM_CMD lines that corresponds to the program you are using and fill the correct path when using a Github clone of FoxDot. -# Also make sure to set the correct python executable (python3 or python). # -# 1. I'm using FoxDot installed with pip +# IMPORTANT: Uncomment the PROGRAM_PATH and PROGRAM_CMD that matches your setup. +# The python executable is auto-detected (python3 or python), so you don't need +# to worry about which one to use. +# +# 1. FoxDot installed with pip # # PROGRAM_PATH = "/" # PROGRAM_CMD = ['FoxDot', '-p'] # # -# 2. I'm using the FoxDot library cloned from GitHub +# 2. FoxDot cloned from GitHub (python is auto-resolved at runtime) # +# from server import resolve_python # PROGRAM_PATH = "/my/path/to/FoxDot" -# PROGRAM_CMD = ['python', '-m', 'FoxDot', '-p'] +# PROGRAM_CMD = [resolve_python(), '-m', 'FoxDot', '-p'] # # -# 3. I'm using Renardo installed with pipx +# 3. Renardo installed with pipx # # PROGRAM_PATH = "/" -# PROGRAM_CMD = ["renardo", '-p'] \ No newline at end of file +# PROGRAM_CMD = ["renardo", '-p'] diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..bd6ba36 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,20 @@ +import js from "@eslint/js"; +import globals from "globals"; + +export default [ + js.configs.recommended, + { + languageOptions: { + globals: { + ...globals.browser, + CodeMirror: "readonly", + }, + }, + rules: { + "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + eqeqeq: "error", + "no-var": "error", + "prefer-const": "warn", + }, + }, +]; diff --git a/index.html b/index.html index 57e2781..3378cdf 100644 --- a/index.html +++ b/index.html @@ -96,6 +96,7 @@

WebFoxDot

+
diff --git a/package-lock.json b/package-lock.json index 36083cf..95ea399 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "ws": "^8.18.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.2.1", + "globals": "^17.5.0", "vite": "^6.0.5" } }, @@ -440,6 +443,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.34.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.2.tgz", @@ -706,6 +903,13 @@ "win32" ] }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -713,12 +917,122 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/codemirror": { "version": "5.65.18", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.18.tgz", "integrity": "sha512-Gaz4gHnkbHMGgahNt3CA5HBk5lLQBqmD/pBgeB4kQU6OedZmqMBjlRF0LSrp2tJ4wlLNPm2FfaUd1pDy0mdlpA==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", @@ -760,6 +1074,250 @@ "@esbuild/win32-x64": "0.24.2" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope/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/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -775,6 +1333,166 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -794,6 +1512,83 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -830,6 +1625,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/rollup": { "version": "4.34.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.2.tgz", @@ -869,6 +1684,29 @@ "fsevents": "~2.3.2" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -879,6 +1717,29 @@ "node": ">=0.10.0" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", @@ -951,6 +1812,32 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -971,6 +1858,19 @@ "optional": true } } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 6489046..bbff3ec 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "preview": "vite preview" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.2.1", + "globals": "^17.5.0", "vite": "^6.0.5" }, "dependencies": { diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..3173da4 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,3 @@ +[lint] +# F821: undefined names are FoxDot/Renardo runtime globals injected at startup +per-file-ignores = { "startup.py" = ["F821"] } diff --git a/server.py b/server.py index 85f220d..f8a93f2 100644 --- a/server.py +++ b/server.py @@ -1,93 +1,152 @@ -import json import asyncio -import websockets -from subprocess import Popen, PIPE +import json import os +import shutil +import sys +from subprocess import PIPE, Popen + +import websockets try: - from config import PROGRAM_PATH, PROGRAM_CMD -except Exception as e: - print("Make sure to uncomment the correct PROGRAM_PATH and PROGRAM_CMD in config.py") - + from config import PROGRAM_CMD, PROGRAM_PATH +except Exception: + print( + "Make sure to uncomment the correct PROGRAM_PATH and PROGRAM_CMD in config.py" + ) + + +def resolve_python(): + """Find the correct python executable (python3 or python).""" + for name in ("python3", "python"): + if shutil.which(name): + return name + print("No python executable found in PATH") + sys.exit(1) + + +HOST = "localhost" +WS_PORT = 1234 +ALLOWED_ORIGINS = {"http://localhost", "http://127.0.0.1"} + + +_ERROR_PATTERNS = ( + "Traceback", + "Error", + "Exception", + "SyntaxError", + "NameError", + "TypeError", + "ValueError", + "AttributeError", + "KeyError", + "IndexError", + "ZeroDivisionError", +) + + +def detect_log_color(message): + if any(p in message for p in _ERROR_PATTERNS): + return "error" + if message.startswith(">>>"): + return "prompt" + if message.startswith(">>"): + return "input" + return None + async def broadcast_log(message, clients): + color = detect_log_color(message) + payload = json.dumps({"type": "foxdot_log", "data": message, "color": color}) for client in clients: - await client.send(json.dumps({"type": "foxdot_log", "data": message, "color": None})) + await client.send(payload) + + +def check_origin(origin): + if origin is None: + return True # non-browser clients (e.g. Python websocket) + return any(origin.startswith(allowed) for allowed in ALLOWED_ORIGINS) + + +async def handle_websocket(websocket, _path, foxdot_process, clients): + origin = websocket.request_headers.get("Origin") + if not check_origin(origin): + print(f"Rejected connection from origin: {origin}") + await websocket.close(4003, "Forbidden origin") + return -# Handle WebSocket connections -async def handle_websocket(websocket, path, foxdot_process, clients): print("New client connected") clients.add(websocket) try: async for message in websocket: try: data = json.loads(message) - if data["type"] == "evaluate_code": - code = data["code"] - await broadcast_log(f"{code}", clients) - foxdot_process.stdin.write(f"{code}\n\n".encode()) - foxdot_process.stdin.flush() except json.JSONDecodeError: - # Ignore non-JSON messages - pass + continue + + if data["type"] == "evaluate_code": + code = data["code"] + await broadcast_log(code, clients) + foxdot_process.stdin.write(f"{code}\n\n".encode()) + foxdot_process.stdin.flush() finally: clients.remove(websocket) print("Client disconnected") + +async def _read_stream(stream, clients, is_stderr=False): + buffer = [] + while True: + line = await asyncio.get_event_loop().run_in_executor(None, stream.readline) + if not line: + continue + + log_message = line.decode() + if "^" not in log_message or not log_message.replace("^", "").isspace(): + log_message = log_message.strip() + print(log_message) + + buffer.append(log_message) + if (not log_message or log_message.endswith((">>>", "..."))) and buffer: + await broadcast_log("\n".join(buffer), clients) + buffer = [] + + +async def read_foxdot_output(foxdot_process, clients): + await asyncio.gather( + _read_stream(foxdot_process.stdout, clients), + _read_stream(foxdot_process.stderr, clients, is_stderr=True), + ) + + async def main(): - # Start FoxDot - try: + try: foxdot_process = Popen( PROGRAM_CMD, cwd=PROGRAM_PATH, stdin=PIPE, - stdout=PIPE, - stderr=PIPE, - env={**os.environ, "PYTHONUNBUFFERED": "1"} + stdout=PIPE, + stderr=PIPE, + env={**os.environ, "PYTHONUNBUFFERED": "1"}, ) - print(f"FoxDot started, pid: {foxdot_process.pid}") except Exception as e: print(f"Error starting FoxDot: {e}") - print("##### IMPORTANT #####") - print("Make sure FoxDot or Renardo is correctly installed and the path is set in config.py") - + print( + "Make sure FoxDot or Renardo is correctly installed and the path is set in config.py" + ) return - # Set of connected clients clients = set() - - # Function to handle FoxDot output - async def handle_foxdot_output(): - buffer = [] - while True: - line = await asyncio.get_event_loop().run_in_executor( - None, - foxdot_process.stdout.readline - ) - if line: - log_message = line.decode() - if '^' not in log_message or not log_message.replace('^', '').isspace(): - log_message = line.decode().strip() - print(log_message) - - buffer.append(log_message) - if not log_message or log_message.endswith((">>>", "...")): - if buffer: - await broadcast_log("\n".join(buffer), clients) - buffer = [] - - # start the task to handle FoxDot output in the background - asyncio.create_task(handle_foxdot_output()) - - # Start WebSocket server + _output_task = asyncio.create_task(read_foxdot_output(foxdot_process, clients)) + async with websockets.serve( lambda ws, path: handle_websocket(ws, path, foxdot_process, clients), - "localhost", - 1234 + HOST, + WS_PORT, ): - print("WebSocket server started on port 1234") - await asyncio.Future() # run forever + print(f"WebSocket server started on port {WS_PORT}") + await asyncio.Future() + if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/src/css/style.css b/src/css/style.css index 5b0a1a0..7b32994 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -124,12 +124,10 @@ body { #logPanel { display: flex; flex-direction: column; + position: relative; margin: 0; width: 100%; - /* flex-grow: 1; */ height: 20%; - /* padding-top: 30px; */ - } #logAndChat { @@ -151,6 +149,25 @@ body { margin: 0 0.1rem; } +#clearLogsBtn { + position: absolute; + right: 8px; + bottom: 8px; + background: var(--bg-col-2); + color: var(--text-col-1); + border: 1px solid var(--border-col-1); + border-radius: 4px; + cursor: pointer; + font-size: 12px; + padding: 2px 6px; + opacity: 0.6; + z-index: 10; +} + +#clearLogsBtn:hover { + opacity: 1; +} + .log-entry { margin: 4px 2px; padding: 2px 5px; @@ -164,6 +181,16 @@ body { color: var(--col-full-red); } +.log-entry.prompt-log { + border-color: var(--col-green); + color: var(--col-green); +} + +.log-entry.input-log { + border-color: var(--col-blue); + color: var(--text-col-1); +} + .log-entry.help { border-color: var(--col-blue); } @@ -267,4 +294,15 @@ body { } .CodeMirror-hint-active { background: var(--col-blue); +} + +.hint-name { + font-weight: bold; +} + +.hint-description { + margin-left: 8px; + opacity: 0.6; + font-size: 0.9em; + font-style: italic; } \ No newline at end of file diff --git a/src/js/configPanel.js b/src/js/configPanel.js index ac684c3..82d0442 100644 --- a/src/js/configPanel.js +++ b/src/js/configPanel.js @@ -113,21 +113,21 @@ export function setupConfigPanel(editor) { }); // Open the shortcut modal - modalbtn.onclick = function() { + modalbtn.addEventListener('click', () => { modal.style.display = "block"; - } + }); // Close the shortcut modal - closeModal.onclick = function() { + closeModal.addEventListener('click', () => { modal.style.display = "none"; - } + }); // Close the modal when clicking outside - window.onclick = function(event) { - if (event.target == modal) { - modal.style.display = "none"; + window.addEventListener('click', (event) => { + if (event.target === modal) { + modal.style.display = "none"; } - } + }); // Theme change // themeInterface.addEventListener('change', (event) => { diff --git a/src/js/foxdotAutocomplete.js b/src/js/foxdotAutocomplete.js index f04ad86..1fd9800 100644 --- a/src/js/foxdotAutocomplete.js +++ b/src/js/foxdotAutocomplete.js @@ -148,6 +148,27 @@ export const foxdotAutocomplete = { { text: 'drive=', displayText: 'drive' }, ], + withRender: function(items) { + const self = this; + return items.map(item => item.description + ? { ...item, render: (el, data, cur) => self.renderHint(el, data, cur) } + : item + ); + }, + + renderHint: function(element, _data, cur) { + const name = document.createElement('span'); + name.className = 'hint-name'; + name.textContent = cur.displayText; + element.appendChild(name); + if (cur.description) { + const desc = document.createElement('span'); + desc.className = 'hint-description'; + desc.textContent = cur.description; + element.appendChild(desc); + } + }, + hint: function(cm, CodeMirror) { const cursor = cm.getCursor(); const token = cm.getTokenAt(cursor); @@ -200,13 +221,12 @@ export const foxdotAutocomplete = { else if (isInsideParentheses) { const prefix = token.string.slice(0, cursorPosition - token.start).replace(/[^a-zA-Z:]/g, ""); - let foxdotKeyword = []; + let foxdotKeyword; if (prefix.startsWith('x')) { - foxdotKeyword = this.fxList.filter(f => f.displayText.toLowerCase().startsWith(prefix.slice(1,).toLowerCase()));; - } - else { + foxdotKeyword = this.withRender(this.fxList.filter(f => f.displayText.toLowerCase().startsWith(prefix.slice(1).toLowerCase()))); + } else { const combinedKeyword = [...this.foxKeyword, ...this.patternFunction]; - foxdotKeyword = combinedKeyword.filter(f => f.displayText.toLowerCase().startsWith(prefix.toLowerCase()));; + foxdotKeyword = combinedKeyword.filter(f => f.displayText.toLowerCase().startsWith(prefix.toLowerCase())); } return { list: foxdotKeyword.length > 0 ? foxdotKeyword.sort((a, b) => a.displayText.localeCompare(b.displayText)) : foxdotKeyword.sort((a, b) => a.displayText.localeCompare(b.displayText)), @@ -224,7 +244,7 @@ export const foxdotAutocomplete = { }; } else if (matchPlayer) { - const filteredSynths = this.synths.filter(synth => synth.displayText.includes(token.string)); + const filteredSynths = this.withRender(this.synths.filter(synth => synth.displayText.includes(token.string))); const synthMatch = line.match(/>>\s*([a-zA-Z0-9_]+)\(/); if (synthMatch) { const synthWithoutUndescore = filteredSynths.filter(synth => !synth.displayText.endsWith("_")); @@ -236,7 +256,7 @@ export const foxdotAutocomplete = { } else { return { list: filteredSynths.length > 0 ? filteredSynths.sort((a, b) => a.displayText.localeCompare(b.displayText)) : this.synths.sort((a, b) => a.displayText.localeCompare(b.displayText)), - from: CodeMirror.Pos(cursor.line, (token.string.trim() == "") ? token.start +1 : token.start ), + from: CodeMirror.Pos(cursor.line, (token.string.trim() === "") ? token.start + 1 : token.start), to: cursor, }; } diff --git a/src/js/functionUtils.js b/src/js/functionUtils.js index d37996d..916e2b5 100644 --- a/src/js/functionUtils.js +++ b/src/js/functionUtils.js @@ -1,48 +1,6 @@ export const functionUtils = { playersList: [], - stopClock(wsServer) { - wsServer.send(JSON.stringify({ - type: 'evaluate_code', - code: 'Clock.clear()\n' - })); - }, - - unSoloPlayers(wsServer) { - wsServer.send(JSON.stringify({ - type: 'evaluate_code', - code: 'unsolo()\n' - })); - }, - - soloPlayer(cm, wsServer) { - const cursor = cm.getCursor(); - let startLine = cursor.line; - let endLine = cursor.line; - - const blockCode = cm.getRange( - {line: startLine, ch: 0}, - {line: endLine, ch: cm.getLine(endLine).length} - ); - - const playerName = this.getPlayer(blockCode); - - if (playerName) { - wsServer.send(JSON.stringify({ - type: 'evaluate_code', - code: `${playerName}.solo()\n` - })); - } - }, - - // reset the chrono - resetChrono(wsServer) { - wsServer.send(JSON.stringify({ - type: 'evaluate_code', - code: 'wsPanel.timeInit = time()\n' - })); - }, - // Check if the code to evaluate is a player and if it is, stop it ifPlayerStop(codeToEvaluate) { const playerPattern = /^[#_]\s*([a-zA-Z]\d+|[a-zA-Z]{2})\s*>>|^#\s*[a-zA-Z]\d+\s*\.\w+\s*=\s*\d+/; @@ -82,7 +40,7 @@ export const functionUtils = { if (blockCode.trim()) { // Check if the code is a player and if it is, stop it - let blockCodeArray = blockCode.split('\n'); + const blockCodeArray = blockCode.split('\n'); blockCodeArray.forEach((code, index) => { blockCodeArray[index] = functionUtils.ifPlayerStop(code); }); @@ -94,8 +52,8 @@ export const functionUtils = { // Save the content of the editor into a .py file - saveEditorContent(cm, wsServer) { - let content = cm.getValue(); + saveEditorContent(cm) { + const content = cm.getValue(); const timestamp = new Date().toISOString().replace(/[:.-]/g, ''); const filename = `code_${timestamp}.py`; @@ -129,9 +87,10 @@ export const functionUtils = { // Increment/decrement the value of the number under the cursor incrementValue(cm, value){ const cursor = cm.getCursor(); - let text = cm.getRange({line: cursor.line, ch: cursor.ch}, {line: cursor.line, ch: cursor.ch +1}); - if (parseInt(text) || parseInt(text) === 0) { - cm.replaceRange((parseInt(text) + value).toString(), {line: cursor.line, ch: cursor.ch}, {line: cursor.line, ch: cursor.ch +1}); + const text = cm.getRange({line: cursor.line, ch: cursor.ch}, {line: cursor.line, ch: cursor.ch +1}); + const num = parseInt(text); + if (!isNaN(num)) { + cm.replaceRange((num + value).toString(), {line: cursor.line, ch: cursor.ch}, {line: cursor.line, ch: cursor.ch + 1}); cm.setCursor({line: cursor.line, ch: cursor.ch}); } }, diff --git a/src/js/logs.js b/src/js/logs.js index 92ce883..23cb28c 100644 --- a/src/js/logs.js +++ b/src/js/logs.js @@ -6,7 +6,7 @@ export const logsUtils = { logs: document.getElementById('logs'), initResize: function(editor) { - this.logSeparator.addEventListener('mousedown', (e) => { + this.logSeparator.addEventListener('mousedown', () => { this.isResizing = true; document.addEventListener('mousemove', this.resize.bind(this, editor)); document.addEventListener('mouseup', this.stopResize.bind(this)); @@ -31,35 +31,35 @@ export const logsUtils = { document.removeEventListener('mouseup', this.stopResize); }, - // Show logs in the console appendLog(message, color) { - if (message === '') { - return; - } + if (message === '') return; + const entry = document.createElement('pre'); entry.className = 'log-entry'; - if (color){ - entry.style.color = color; - } - if (message.includes('Traceback')) { + + if (color === 'error' || message.includes('Traceback')) { message = this.formatErrorMessage(message); entry.classList.add('error-log'); - } - else if (!message.includes('>>')) { + } else if (color === 'prompt') { + entry.classList.add('prompt-log'); + } else if (color === 'input') { + entry.classList.add('input-log'); + } else if (!message.includes('>>')) { entry.classList.add('help'); } + entry.textContent = message; - this.logs.insertBefore(entry, logs.firstChild); + this.logs.insertBefore(entry, this.logs.firstChild); this.logs.scrollTop = 0; }, + clear() { + this.logs.innerHTML = ''; + }, + formatErrorMessage(errorMessage) { const lines = errorMessage.split('\n'); const caretIndex = lines.findIndex(line => line.includes('File "FoxDot", line 1')); - if (caretIndex) { - return lines.slice(caretIndex + 1).join('\n'); - } else { - return errorMessage; - } + return caretIndex !== -1 ? lines.slice(caretIndex + 1).join('\n') : errorMessage; } }; \ No newline at end of file diff --git a/src/js/main.js b/src/js/main.js index 1dfc117..966bf6a 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -32,9 +32,29 @@ document.addEventListener('DOMContentLoaded', async () => { // DOM elements const chrono = document.getElementById('timer'); - // WebSocket - const wsServer = new WebSocket(`ws://localhost:1234`); - let foxdotWs = null; + // WebSocket with auto-reconnect + let wsServer = null; + + function connectWsServer() { + wsServer = new WebSocket(`ws://localhost:1234`); + wsServer.onopen = () => console.log('Backend WebSocket connected'); + wsServer.onclose = () => { + console.log('Backend WebSocket closed, reconnecting in 3s...'); + setTimeout(connectWsServer, 3000); + }; + wsServer.onerror = () => wsServer.close(); + wsServer.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + if (message.type === 'foxdot_log') { + logsUtils.appendLog(message.data, message.color); + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + } + connectWsServer(); // CodeMirror const editor = CodeMirror(document.getElementById('editor'), { @@ -67,38 +87,44 @@ document.addEventListener('DOMContentLoaded', async () => { // Init the logs panel logsUtils.initResize(editor); + document.getElementById('clearLogsBtn').addEventListener('click', () => logsUtils.clear()); + + // Line markers for visual annotation + const activeMarkers = []; + + function setMarker(cm, color) { + const cursor = cm.getCursor(); + const line = cursor.line; + const className = `line${color}`; + const existing = activeMarkers.findIndex(m => m.line === line); + if (existing !== -1) { + activeMarkers[existing].handle.clear(); + activeMarkers.splice(existing, 1); + } + const handle = cm.addLineClass(line, 'background', className); + activeMarkers.push({ line, handle, color: className }); + } - EventEmitter.on('send_foxdot', (command) => { - wsServer.send(JSON.stringify({ - type: 'evaluate_code', - code: command - })); - }); - - // Log the FoxDot output - wsServer.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - if (message.type === 'foxdot_log') { - // console.log(message.data); - logsUtils.appendLog(message.data, message.color); - } - } catch (error) { + function resetMarkers(cm) { + activeMarkers.forEach(m => cm.removeLineClass(m.handle, 'background', m.color)); + activeMarkers.length = 0; + } + + function sendToServer(code) { + if (wsServer && wsServer.readyState === WebSocket.OPEN) { + wsServer.send(JSON.stringify({ type: 'evaluate_code', code })); } - }; + } + + EventEmitter.on('send_foxdot', sendToServer); // Reset timer on click - chrono.addEventListener('click', ()=> functionUtils.resetChrono(wsServer)); + chrono.addEventListener('click', () => sendToServer('ws_panel.time_init = time()')); // Evaluate the code and highlight the block with a flash function evaluateCode(cm, multi){ - var [blockCode, startLine, endLine] = functionUtils.getCodeAndCheckStop(cm, multi); - - // Send code - wsServer.send(JSON.stringify({ - type: 'evaluate_code', - code: blockCode, - })); + const [blockCode, startLine, endLine] = functionUtils.getCodeAndCheckStop(cm, multi); + sendToServer(blockCode); // Highlight the code for (let i = startLine; i <= endLine; i++) { @@ -112,9 +138,9 @@ document.addEventListener('DOMContentLoaded', async () => { } editor.setOption('extraKeys', { - 'Ctrl-;': ()=> functionUtils.stopClock(wsServer), + 'Ctrl-;': () => sendToServer('Clock.clear()'), 'Ctrl-Space': 'autocomplete', - 'Ctrl-S': (cm)=> {functionUtils.saveEditorContent(cm,wsServer)}, + 'Ctrl-S': (cm) => functionUtils.saveEditorContent(cm), 'Alt-X': (cm) => { cm.toggleComment(); evaluateCode(cm, false); @@ -125,12 +151,18 @@ document.addEventListener('DOMContentLoaded', async () => { cm.toggleComment(); evaluateCode(cm, true); }, - 'Alt-S': (cm) => {functionUtils.soloPlayer(cm, wsServer)}, - 'Ctrl-Alt-S': () => {functionUtils.unSoloPlayers(wsServer)}, - 'Alt-1': (cm) => markerUtils.setMarker(cm, "Red"), - 'Alt-2': (cm) => markerUtils.setMarker(cm, "Green"), - 'Alt-3': (cm) => markerUtils.setMarker(cm, "Blue"), - 'Alt-4': () => markerUtils.resetMarkers(), + 'Alt-S': (cm) => { + const player = functionUtils.getPlayer(cm.getRange( + {line: cm.getCursor().line, ch: 0}, + {line: cm.getCursor().line, ch: cm.getLine(cm.getCursor().line).length} + )); + if (player) sendToServer(`${player}.solo()`); + }, + 'Ctrl-Alt-S': () => sendToServer('unsolo()'), + 'Alt-1': (cm) => setMarker(cm, "Red"), + 'Alt-2': (cm) => setMarker(cm, "Green"), + 'Alt-3': (cm) => setMarker(cm, "Blue"), + 'Alt-4': (cm) => resetMarkers(cm), 'Ctrl-Enter': (cm) => {evaluateCode(cm, false)}, 'Ctrl-Alt-Enter': (cm) => {evaluateCode(cm, true)}, 'Alt-F': "findPersistent", @@ -149,8 +181,8 @@ document.addEventListener('DOMContentLoaded', async () => { hint: (cm) => foxdotAutocomplete.hint(cm, CodeMirror), }); - function foxDotWs(){ - foxdotWs = new WebSocket(`ws://localhost:20000`); + function connectFoxDotWs(){ + const foxdotWs = new WebSocket(`ws://localhost:20000`); foxdotWs.onopen = () => { foxdotWs.send(JSON.stringify({ type: 'get_autocomplete' })); }; @@ -161,11 +193,11 @@ document.addEventListener('DOMContentLoaded', async () => { const { loops, fxList, synthList } = functionUtils.formatFoxDotAutocomplete(message); foxdotAutocomplete.loopList = loops; foxdotAutocomplete.fxList = fxList; - foxdotAutocomplete.synths= synthList; + foxdotAutocomplete.synths = synthList; updateHelpPanel(loops, fxList, synthList); - if (loops.length == 0 || fxList.length == 0 || synthList.length == 0) { + if (loops.length === 0 || fxList.length === 0 || synthList.length === 0) { console.error(`Error on retrieving loops name (${loops.length}), effets (${fxList.length}), synths (${synthList.length})`); } } @@ -173,13 +205,14 @@ document.addEventListener('DOMContentLoaded', async () => { console.error('Error on FoxDot message ', error); } }; - foxdotWs.onerror = (err) => { - console.error('Socket encountered error: ', err.message, 'Closing socket'); - foxdotWs.close(); + foxdotWs.onclose = () => { + console.log('FoxDot WebSocket closed, reconnecting in 3s...'); + setTimeout(connectFoxDotWs, 3000); }; + foxdotWs.onerror = () => foxdotWs.close(); } - foxDotWs(); + connectFoxDotWs(); // piano insert at cursor document.querySelectorAll('#piano-roll .piano-key li').forEach(key => { diff --git a/src/js/panel.js b/src/js/panel.js index b14ec36..76400d3 100644 --- a/src/js/panel.js +++ b/src/js/panel.js @@ -1,5 +1,5 @@ import { EventEmitter } from './eventBus.js'; -import { playersList, updatePlayersList } from './functionUtils.js'; +import { updatePlayersList } from './functionUtils.js'; // Toggle Panel const panel = document.getElementById('panel') @@ -37,56 +37,64 @@ panelToggle.addEventListener('change', () => { } }) -const ws = new WebSocket(`ws://localhost:20000`); +let ws = null; -ws.onopen = function() { - console.log('FoxDot WebSocket connection opened'); -}; +function connectPanelWs() { + ws = new WebSocket(`ws://localhost:20000`); -ws.onmessage = function(event) { - const data = JSON.parse(event.data); - - switch(data.type) { - case 'scale': - document.getElementById('scale').textContent = data.scale; - updatePianoKeys(data.scale, document.getElementById('root').textContent); - break; - case 'root': - document.getElementById('root').textContent = data.root; - updatePianoKeys(document.getElementById('scale').textContent, data.root); - break; - case 'cpu': - updateCpu(data.cpu); - break; - case 'bpm': - document.getElementById('bpm').textContent = data.bpm; - break; - case 'beat': - updateBeat(data.beat); - break; - case 'chrono': - document.getElementById('timer').textContent = formatTime(data.chrono); - break; - case 'players': - formatPlayers(data.players) - break; - case 'help': - const helpContainer = document.getElementById('help') - helpContainer.textContent = data.help; - helpContainer.style.height = helpContainer.scrollHeight + 'px'; - break; - default: - break; - } -}; + ws.onopen = () => console.log('Panel WebSocket connected'); -ws.onclose = function() { - console.log('WebSocket FoxDot connection closed'); -}; + ws.onmessage = (event) => { + let data; + try { + data = JSON.parse(event.data); + } catch { + return; + } + switch(data.type) { + case 'scale': + document.getElementById('scale').textContent = data.scale; + updatePianoKeys(data.scale, document.getElementById('root').textContent); + break; + case 'root': + document.getElementById('root').textContent = data.root; + updatePianoKeys(document.getElementById('scale').textContent, data.root); + break; + case 'cpu': + updateCpu(data.cpu); + break; + case 'bpm': + document.getElementById('bpm').textContent = data.bpm; + break; + case 'beat': + updateBeat(data.beat); + break; + case 'chrono': + document.getElementById('timer').textContent = formatTime(data.chrono); + break; + case 'players': + formatPlayers(data.players); + break; + case 'help': { + const helpContainer = document.getElementById('help'); + helpContainer.textContent = data.help; + helpContainer.style.height = helpContainer.scrollHeight + 'px'; + break; + } + default: + break; + } + }; -ws.onerror = function(error) { - console.error('WebSocket FoxDot error:', error); -}; + ws.onclose = () => { + console.log('Panel WebSocket closed, reconnecting in 3s...'); + setTimeout(connectPanelWs, 3000); + }; + + ws.onerror = () => ws.close(); +} + +connectPanelWs(); document.getElementById('root').addEventListener('click', () => { const pianoRoll = document.getElementById('piano-roll'); @@ -170,13 +178,28 @@ function formatPlayers(message) { const hasSolo = players.some(p => p.solo); const playersDiv = document.getElementById('players'); - playersDiv.innerHTML = players.map(p => ` -
- ${p.id} - ${p.synth} - ${p.duration} -
- `).join(''); + playersDiv.innerHTML = ''; + players.forEach(p => { + const line = document.createElement('div'); + line.className = `player-line${hasSolo && !p.solo ? ' player-solo' : ''}`; + line.dataset.playerId = p.id; + + const idSpan = document.createElement('span'); + idSpan.className = 'player-id'; + idSpan.textContent = p.id; + + const synthSpan = document.createElement('span'); + synthSpan.className = 'player-synth'; + synthSpan.textContent = p.synth; + + const durSpan = document.createElement('span'); + durSpan.className = 'player-duration'; + durSpan.style.color = p.durationColor; + durSpan.textContent = p.duration; + + line.append(idSpan, synthSpan, durSpan); + playersDiv.appendChild(line); + }); document.querySelectorAll('.player-line').forEach(line => { line.addEventListener('click', (e) => { @@ -190,12 +213,13 @@ function getDurationColor(totalMinutes) { const green = '#4caf50'; const orange = '#ff9800'; const red = '#f44336'; - + if (totalMinutes <= 1) { return green; + } else if (totalMinutes <= 3) { + return interpolateColor(green, orange, (totalMinutes - 1) / 2); } else if (totalMinutes <= 5) { - const factor = (totalMinutes - 1) / 4; // 4 = (5-1) - return interpolateColor(green, red, factor); + return interpolateColor(orange, red, (totalMinutes - 3) / 2); } return red; } diff --git a/startup-renardo.py b/startup-renardo.py deleted file mode 100644 index 1007970..0000000 --- a/startup-renardo.py +++ /dev/null @@ -1,246 +0,0 @@ -import asyncio -import json -import threading -import websockets -from time import * -import os -import re - -# Alias for loops -loops = Samples.loops - -# Fix the missing FxList -try: - FxList = effect_manager -except: - pass - - -class WebFoxDotPanelWs(): - def __init__(self, ip="localhost", port=20000): - self.isRunning = False - self.ip = ip - self.port = port - - # OSC Server - self.oscServer = ThreadingOSCServer((self.ip, 2887)) - self.oscServer.addDefaultHandlers() - self.oscServer.addMsgHandler( - "/CPU", self.receiveCpu) - self.oscThread = threading.Thread(target=self.oscServer.serve_forever, daemon=True) - self.oscThread.start() - # websocket server - self.wsClients = set() - self.websocket_started_event = threading.Event() - self.websocket_thread = threading.Thread(target=self.start_websocket_server, daemon=True) - self.websocket_thread.start() - self.websocket_started_event.wait() - # bpm send - self.sendBpm_thread = threading.Thread(target=self.send_bpm_periodically, daemon=True) - self.sendBpm_thread.start() - - self.bpmTime = 0.2 # time cycle send Scale,Root - self.beatTime = 0.1 # time cycle send beat - self.plyTime = 1.0 # time cycle send player - self.chronoTime = 1.0 # time cycle send chrono - - self.playerCounter = {} - self.timeInit = time() - - self.threadScale = threading.Thread(target=self.sendScale, daemon=True) - self.threadRoot = threading.Thread(target=self.sendRoot, daemon=True) - self.threadBeat = threading.Thread(target=self.sendBeat, daemon=True) - self.threadPlayer = threading.Thread(target=self.sendPlayer, daemon=True) - self.threadChrono = threading.Thread(target=self.sendChrono, daemon=True) - - self.start() - - def receiveCpu(self, address, tags, contents, source): - ''' receive CPU usage from SC by OSC and send it to websocket ''' - cpu = round(float(contents[0]), 2) - if cpu: - asyncio.run(self.sendWebsocket( - json.dumps({"type": "cpu", "cpu": cpu}))) - - async def sendWsServer(self, websocket): - self.wsClients.add(websocket) - try: - async for message in websocket: - await asyncio.gather(*[client.send(message) for client in self.wsClients]) - data = json.loads(message) - if data["type"] == "get_autocomplete": - await self.sendFoxdotAutocomplete() - except websockets.ConnectionClosed: - pass - finally: - self.wsClients.remove(websocket) - - async def mainWebsocket(self): - ''' The websocket server ''' - async with websockets.serve(self.sendWsServer, self.ip, self.port): - self.websocket_started_event.set() - await asyncio.get_running_loop().create_future() # run forever - - def start_websocket_server(self): - ''' For using threading ''' - print(f"Start FoxDot WebSocket server at ws://{self.ip}:{self.port}") - asyncio.run(self.mainWebsocket()) - - async def sendWebsocket(self, msg=""): - ''' Send websocket msg to websocket server ''' - try: - # send message as json format - uri = f"ws://{self.ip}:{self.port}" - async with websockets.connect(uri) as websocket: - await websocket.send(msg) - except Exception as e: - print(f"Error sending websocket message: {e}") - - def send_bpm_periodically(self): - ''' Send bpm to websocket server every second ''' - while True: - bpm = int(Clock.get_bpm()) - asyncio.run(self.sendWebsocket(json.dumps({"type": "bpm", "bpm": bpm}))) - sleep(60/bpm) - - async def sendFoxdotAutocomplete(self): - ''' Send FoxDot autocomplete data to websocket server ''' - fxList = await self.sendFxDict() - synthList = await self.sendSynthList() - combined_message = json.dumps({"type": "autocomplete", "autocomplete": {"loopList": loops, "fxList": fxList, "synthList": synthList}}) - await self.sendWebsocket(combined_message) - - async def sendFxDict(self): - ''' Send fx list to websocket server ''' - fx_json_list = [] - for fx_name in FxList.keys(): - fxDefault = FxList[fx_name].defaults - filtered_fx = {k: v for k,v in fxDefault.items() if not (k.endswith('_') or k.endswith('_d') or k == 'sus')} - fx_text = ', '.join([f"{k}={v}" for k, v in filtered_fx.items()]) - fx_json_list.append({'text': fx_text, 'displayText': fx_name + '_'}) - fxDict = json.dumps({"type": "fxList", "fx": fx_json_list}) - return fx_json_list - - async def sendSynthList(self): - args_to_remove = ['amp', 'sus', 'gate', 'pan', 'freq', 'mul', 'bus', 'atk', 'decay', 'rel', 'level', 'peak', 'blur', 'beat_dur', 'buf', "vib", "fmod"] - synthList = [] - pathSynth = SYNTHDEF_DIR - synth_list = sorted([f for f in SynthDefs]) - for syn in synth_list: - if syn != "": - path = os.path.join(pathSynth, syn + ".scd") - with open(str(path), "r") as synth: - synth = synth.readlines() - synth_txt = [line.strip() for line in synth if line != "\n"] - txt = str(''.join(synth_txt)) - synthname = re.findall(r"SynthDef(?:\.new)?\(\\(\w+)", txt) - synthargs = re.findall(r"{\|(.{3,})\|(?:var)", txt) - if (len(synthname) != 0 and len(synthargs) != 0): - filtered_args = ', '.join([arg.strip() for arg in synthargs[0].split(', ') if arg.split('=')[0].strip() not in args_to_remove]) - synthList.append({'text': filtered_args, 'displayText': synthname[0]}) - return synthList - - def sendScale(self): - ''' send Scale to OSC server ''' - try: - while self.isRunning: - msg = json.dumps({"type": "scale", "scale": str(Scale.default.name)}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.bpmTime*10) - except: - pass - - def sendRoot(self): - ''' send Root to OSC server ''' - try: - while self.isRunning: - msg = json.dumps({"type": "root", "root": str(Root.default)}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.bpmTime*10) - except: - pass - - def sendBeat(self): - ''' send Clock.beat to OSC server ''' - try: - while self.isRunning: - msg = json.dumps({"type": "beat", "beat": Clock.beat}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.beatTime) - except: - pass - - def sendPlayer(self): - ''' send active player to OSC server ''' - try: - while self.isRunning: - self.addPlayerTurn() - playerListCount = [] - soloPlayers = [p.name for p in Clock.solo.data] - for k,v in self.playerCounter.items(): - duration = f'{divmod(v, 60)[0]:02d}:{divmod(v, 60)[1]:02d}' - player = k.name - if (k.synthdef in ["loop", "stretch"]): - name = k.filename - else: - name = k.synthdef - playerListCount.append(json.dumps({"player": player, "name": name, "duration": duration, "solo": player in soloPlayers})) - msg = json.dumps({"type": "players", "players": playerListCount}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.plyTime) - except: - pass - - def sendChrono(self): - ''' send ChronoTime to OSC server ''' - try: - while self.isRunning: - elapsedTime = time() - self.timeInit - msg = json.dumps({"type": "chrono", "chrono": elapsedTime}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.chronoTime) - except: - pass - - def addPlayerTurn(self): - ''' add one to player dictionary turn ''' - try: - playerList = Clock.playing - for p in playerList: - if p in self.playerCounter.keys(): - self.playerCounter[p] += 1 - else: - self.playerCounter[p] = 1 - # Clean non playing player - delplayer = [ - k for k in self.playerCounter.keys() if k not in playerList] - for d in delplayer: - self.playerCounter.pop(d, None) - except Exception as err: - print("addPlayerTurn problem : ", err) - - def sendOnce(self, txt, helpType=""): - ''' send on txt msg to OSC ''' - msg = json.dumps({"type": "help", "helpType": helpType, "help": txt}) - asyncio.run(self.sendWebsocket(msg)) - - def stop(self): - self.isRunning = False - - def start(self): - self.isRunning = True - self.threadScale.start() - self.threadRoot.start() - self.threadBeat.start() - self.threadPlayer.start() - self.threadChrono.start() - -try: - wsPanel = WebFoxDotPanelWs() -except Exception as e: - print(f"Error starting FoxDot WebSocket server: {e}") - -def unsolo(): - ''' Unsolo all soloed players''' - for p in Clock.playing: - p.solo(0) diff --git a/startup.py b/startup.py index 34b7dd3..8e60c5d 100644 --- a/startup.py +++ b/startup.py @@ -1,237 +1,291 @@ import asyncio import json -import threading -import websockets -from time import * import os import re +import threading +from time import sleep, time + +import websockets + +from synth_definitions import FX_DESCRIPTIONS, SYNTH_DESCRIPTIONS + loops = Samples.loops -class WebFoxDotPanelWs(): - def __init__(self, ip="localhost", port=20000): - self.isRunning = False - self.ip = ip - self.port = port - - # OSC Server - self.oscServer = ThreadingOSCServer((self.ip, 2887)) - self.oscServer.addDefaultHandlers() - self.oscServer.addMsgHandler( - "/CPU", self.receiveCpu) - self.oscThread = threading.Thread(target=self.oscServer.serve_forever, daemon=True) - self.oscThread.start() - # websocket server - self.wsClients = set() - self.websocket_started_event = threading.Event() - self.websocket_thread = threading.Thread(target=self.start_websocket_server, daemon=True) - self.websocket_thread.start() - self.websocket_started_event.wait() - # bpm send - self.sendBpm_thread = threading.Thread(target=self.send_bpm_periodically, daemon=True) - self.sendBpm_thread.start() - - self.bpmTime = 0.2 # time cycle send Scale,Root - self.beatTime = 0.1 # time cycle send beat - self.plyTime = 1.0 # time cycle send player - self.chronoTime = 1.0 # time cycle send chrono - - self.playerCounter = {} - self.timeInit = time() - - self.threadScale = threading.Thread(target=self.sendScale, daemon=True) - self.threadRoot = threading.Thread(target=self.sendRoot, daemon=True) - self.threadBeat = threading.Thread(target=self.sendBeat, daemon=True) - self.threadPlayer = threading.Thread(target=self.sendPlayer, daemon=True) - self.threadChrono = threading.Thread(target=self.sendChrono, daemon=True) - - self.start() - - def receiveCpu(self, address, tags, contents, source): - ''' receive CPU usage from SC by OSC and send it to websocket ''' - cpu = round(float(contents[0]), 2) - if cpu: - asyncio.run(self.sendWebsocket( - json.dumps({"type": "cpu", "cpu": cpu}))) - - async def sendWsServer(self, websocket): - self.wsClients.add(websocket) - try: - async for message in websocket: - await asyncio.gather(*[client.send(message) for client in self.wsClients]) - data = json.loads(message) - if data["type"] == "get_autocomplete": - await self.sendFoxdotAutocomplete() - except websockets.ConnectionClosed: - pass - finally: - self.wsClients.remove(websocket) - - async def mainWebsocket(self): - ''' The websocket server ''' - async with websockets.serve(self.sendWsServer, self.ip, self.port): - self.websocket_started_event.set() - await asyncio.get_running_loop().create_future() # run forever - - def start_websocket_server(self): - ''' For using threading ''' - print(f"Start FoxDot WebSocket server at ws://{self.ip}:{self.port}") - asyncio.run(self.mainWebsocket()) - - async def sendWebsocket(self, msg=""): - ''' Send websocket msg to websocket server ''' - try: - # send message as json format - uri = f"ws://{self.ip}:{self.port}" - async with websockets.connect(uri) as websocket: - await websocket.send(msg) - except Exception as e: - print(f"Error sending websocket message: {e}") - - def send_bpm_periodically(self): - ''' Send bpm to websocket server every second ''' - while True: - bpm = int(Clock.get_bpm()) - asyncio.run(self.sendWebsocket(json.dumps({"type": "bpm", "bpm": bpm}))) - sleep(60/bpm) - - async def sendFoxdotAutocomplete(self): - ''' Send FoxDot autocomplete data to websocket server ''' - fxList = await self.sendFxDict() - synthList = await self.sendSynthList() - combined_message = json.dumps({"type": "autocomplete", "autocomplete": {"loopList": loops, "fxList": fxList, "synthList": synthList}}) - await self.sendWebsocket(combined_message) - - async def sendFxDict(self): - ''' Send fx list to websocket server ''' - fx_json_list = [] - for fx_name in FxList.keys(): - fxDefault = FxList[fx_name].defaults - filtered_fx = {k: v for k,v in fxDefault.items() if not (k.endswith('_') or k.endswith('_d') or k == 'sus')} - fx_text = ', '.join([f"{k}={v}" for k, v in filtered_fx.items()]) - fx_json_list.append({'text': fx_text, 'displayText': fx_name + '_'}) - fxDict = json.dumps({"type": "fxList", "fx": fx_json_list}) - return fx_json_list - - async def sendSynthList(self): - args_to_remove = ['amp', 'sus', 'gate', 'pan', 'freq', 'mul', 'bus', 'atk', 'decay', 'rel', 'level', 'peak', 'blur', 'beat_dur', 'buf', "vib", "fmod"] - synthList = [] - path = os.path.join(FOXDOT_ROOT, "osc", "scsyndef", "") - synth_list = sorted([f for f in SynthDefs]) - for syn in synth_list: - if syn != "": - path = os.path.join(FOXDOT_ROOT, "osc", "scsyndef", syn + ".scd") - with open(str(path), "r") as synth: - synth = synth.readlines() - synth_txt = [line.strip() for line in synth if line != "\n"] - txt = str(''.join(synth_txt)) - synthname = re.findall(r"SynthDef(?:\.new)?\(\\(\w+)", txt) - synthargs = re.findall(r"{\|(.{3,})\|(?:var)", txt) - if (len(synthname) != 0 and len(synthargs) != 0): - filtered_args = ', '.join([arg.strip() for arg in synthargs[0].split(', ') if arg.split('=')[0].strip() not in args_to_remove]) - synthList.append({'text': filtered_args, 'displayText': synthname[0]}) - return synthList - - def sendScale(self): - ''' send Scale to OSC server ''' - try: - while self.isRunning: - msg = json.dumps({"type": "scale", "scale": str(Scale.default.name)}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.bpmTime*10) - except: - pass - - def sendRoot(self): - ''' send Root to OSC server ''' - try: - while self.isRunning: - msg = json.dumps({"type": "root", "root": str(Root.default)}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.bpmTime*10) - except: - pass - - def sendBeat(self): - ''' send Clock.beat to OSC server ''' - try: - while self.isRunning: - msg = json.dumps({"type": "beat", "beat": Clock.beat}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.beatTime) - except: - pass - - def sendPlayer(self): - ''' send active player to OSC server ''' - try: - while self.isRunning: - self.addPlayerTurn() - playerListCount = [] - soloPlayers = [p.name for p in Clock.solo.data] - for k,v in self.playerCounter.items(): - duration = f'{divmod(v, 60)[0]:02d}:{divmod(v, 60)[1]:02d}' - player = k.name - if (k.synthdef in ["loop", "stretch"]): - name = k.filename - else: - name = k.synthdef - playerListCount.append(json.dumps({"player": player, "name": name, "duration": duration, "solo": player in soloPlayers})) - msg = json.dumps({"type": "players", "players": playerListCount}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.plyTime) - except: - pass - - def sendChrono(self): - ''' send ChronoTime to OSC server ''' - try: - while self.isRunning: - elapsedTime = time() - self.timeInit - msg = json.dumps({"type": "chrono", "chrono": elapsedTime}) - asyncio.run(self.sendWebsocket(msg)) - sleep(self.chronoTime) - except: - pass - - def addPlayerTurn(self): - ''' add one to player dictionary turn ''' - try: - playerList = Clock.playing - for p in playerList: - if p in self.playerCounter.keys(): - self.playerCounter[p] += 1 - else: - self.playerCounter[p] = 1 - # Clean non playing player - delplayer = [ - k for k in self.playerCounter.keys() if k not in playerList] - for d in delplayer: - self.playerCounter.pop(d, None) - except Exception as err: - print("addPlayerTurn problem : ", err) - - def sendOnce(self, txt, helpType=""): - ''' send on txt msg to OSC ''' - msg = json.dumps({"type": "help", "helpType": helpType, "help": txt}) - asyncio.run(self.sendWebsocket(msg)) - - def stop(self): - self.isRunning = False - - def start(self): - self.isRunning = True - self.threadScale.start() - self.threadRoot.start() - self.threadBeat.start() - self.threadPlayer.start() - self.threadChrono.start() +# Renardo compatibility: FxList is named differently +try: + FxList = effect_manager +except Exception: + pass + +# Resolve synth definition directory for both FoxDot and Renardo +try: + _SYNTH_DIR = SYNTHDEF_DIR +except Exception: + _SYNTH_DIR = os.path.join(FOXDOT_ROOT, "osc", "scsyndef") + +_SYNTH_ARGS_IGNORE = { + "amp", + "sus", + "gate", + "pan", + "freq", + "mul", + "bus", + "atk", + "decay", + "rel", + "level", + "peak", + "blur", + "beat_dur", + "buf", + "vib", + "fmod", +} + + +class WebFoxDotPanelWs: + def __init__(self, ip="localhost", port=20000): + self.is_running = False + self.ip = ip + self.port = port + self.ws_clients = set() + self.player_counter = {} + self.time_init = time() + + self.bpm_time = 0.2 + self.beat_time = 0.1 + self.player_time = 1.0 + self.chrono_time = 1.0 + + self._start_osc_server() + self._start_websocket() + self._start_bpm_sender() + + self._periodic_threads = [ + threading.Thread( + target=self._send_loop, + args=("scale", self._get_scale, self.bpm_time * 10), + daemon=True, + ), + threading.Thread( + target=self._send_loop, + args=("root", self._get_root, self.bpm_time * 10), + daemon=True, + ), + threading.Thread( + target=self._send_loop, + args=("beat", self._get_beat, self.beat_time), + daemon=True, + ), + threading.Thread(target=self._send_player, daemon=True), + threading.Thread( + target=self._send_loop, + args=("chrono", self._get_chrono, self.chrono_time), + daemon=True, + ), + ] + + self.start() + + def _start_osc_server(self): + self._osc_server = ThreadingOSCServer((self.ip, 2887)) + self._osc_server.addDefaultHandlers() + self._osc_server.addMsgHandler("/CPU", self._receive_cpu) + threading.Thread(target=self._osc_server.serve_forever, daemon=True).start() + + def _start_websocket(self): + self._ws_event = threading.Event() + threading.Thread(target=self._run_ws_server, daemon=True).start() + self._ws_event.wait() + + def _start_bpm_sender(self): + threading.Thread(target=self._send_bpm_periodically, daemon=True).start() + + def _receive_cpu(self, _address, _tags, contents, _source): + cpu = round(float(contents[0]), 2) + if cpu: + asyncio.run(self._send_ws(json.dumps({"type": "cpu", "cpu": cpu}))) + + async def _handle_ws_client(self, websocket): + self.ws_clients.add(websocket) + try: + async for message in websocket: + await asyncio.gather(*[c.send(message) for c in self.ws_clients]) + data = json.loads(message) + if data["type"] == "get_autocomplete": + await self._send_autocomplete() + except websockets.ConnectionClosed: + pass + finally: + self.ws_clients.remove(websocket) + + async def _ws_main(self): + async with websockets.serve(self._handle_ws_client, self.ip, self.port): + self._ws_event.set() + await asyncio.get_running_loop().create_future() + + def _run_ws_server(self): + print(f"Start FoxDot WebSocket server at ws://{self.ip}:{self.port}") + asyncio.run(self._ws_main()) + + async def _send_ws(self, msg=""): + try: + uri = f"ws://{self.ip}:{self.port}" + async with websockets.connect(uri) as ws: + await ws.send(msg) + except Exception as e: + print(f"Error sending websocket message: {e}") + + def _send_bpm_periodically(self): + while True: + bpm = int(Clock.get_bpm()) + asyncio.run(self._send_ws(json.dumps({"type": "bpm", "bpm": bpm}))) + sleep(60 / bpm) + + # --- Data getters for periodic sends --- + + def _get_scale(self): + return {"type": "scale", "scale": str(Scale.default.name)} + + def _get_root(self): + return {"type": "root", "root": str(Root.default)} + + def _get_beat(self): + return {"type": "beat", "beat": Clock.beat} + + def _get_chrono(self): + return {"type": "chrono", "chrono": time() - self.time_init} + + def _send_loop(self, name, getter, interval): + try: + while self.is_running: + asyncio.run(self._send_ws(json.dumps(getter()))) + sleep(interval) + except Exception: + pass + + def _send_player(self): + try: + while self.is_running: + self._update_player_counter() + solo_players = [p.name for p in Clock.solo.data] + player_list = [ + json.dumps( + { + "player": k.name, + "name": k.filename + if k.synthdef in ("loop", "stretch") + else k.synthdef, + "duration": f"{divmod(v, 60)[0]:02d}:{divmod(v, 60)[1]:02d}", + "solo": k.name in solo_players, + } + ) + for k, v in self.player_counter.items() + ] + asyncio.run( + self._send_ws( + json.dumps({"type": "players", "players": player_list}) + ) + ) + sleep(self.player_time) + except Exception: + pass + + def _update_player_counter(self): + try: + playing = Clock.playing + for p in playing: + self.player_counter[p] = self.player_counter.get(p, 0) + 1 + for k in [k for k in self.player_counter if k not in playing]: + del self.player_counter[k] + except Exception as err: + print(f"_update_player_counter error: {err}") + + # --- Autocomplete --- + + async def _send_autocomplete(self): + fx_list = await self._get_fx_list() + synth_list = await self._get_synth_list() + msg = json.dumps( + { + "type": "autocomplete", + "autocomplete": { + "loopList": loops, + "fxList": fx_list, + "synthList": synth_list, + }, + } + ) + await self._send_ws(msg) + + async def _get_fx_list(self): + result = [] + for fx_name in FxList.keys(): + defaults = FxList[fx_name].defaults + filtered = { + k: v + for k, v in defaults.items() + if not (k.endswith("_") or k.endswith("_d") or k == "sus") + } + result.append( + { + "text": ", ".join(f"{k}={v}" for k, v in filtered.items()), + "displayText": fx_name + "_", + "description": FX_DESCRIPTIONS.get(fx_name, ""), + } + ) + return result + + async def _get_synth_list(self): + result = [] + for syn in sorted(SynthDefs): + if not syn: + continue + path = os.path.join(_SYNTH_DIR, syn + ".scd") + with open(path) as f: + content = "".join(line.strip() for line in f if line.strip()) + names = re.findall(r"SynthDef(?:\.new)?\(\\(\w+)", content) + args = re.findall(r"{\|(.{3,})\|(?:var)", content) + if names and args: + filtered = ", ".join( + a.strip() + for a in args[0].split(", ") + if a.split("=")[0].strip() not in _SYNTH_ARGS_IGNORE + ) + result.append( + { + "text": filtered, + "displayText": names[0], + "description": SYNTH_DESCRIPTIONS.get(names[0], ""), + } + ) + return result + + # --- Control --- + + def send_once(self, txt, help_type=""): + asyncio.run( + self._send_ws( + json.dumps({"type": "help", "helpType": help_type, "help": txt}) + ) + ) + + def stop(self): + self.is_running = False + + def start(self): + self.is_running = True + for t in self._periodic_threads: + t.start() + try: - wsPanel = WebFoxDotPanelWs() + ws_panel = WebFoxDotPanelWs() except Exception as e: - print(f"Error starting FoxDot WebSocket server: {e}") + print(f"Error starting FoxDot WebSocket server: {e}") + def unsolo(): - ''' Unsolo all soloed players''' for p in Clock.playing: - p.solo(0) \ No newline at end of file + p.solo(0) diff --git a/synth_definitions.py b/synth_definitions.py new file mode 100644 index 0000000..e2fa208 --- /dev/null +++ b/synth_definitions.py @@ -0,0 +1,90 @@ +"""Static descriptions for FoxDot/Renardo SynthDefs and effects.""" + +SYNTH_DESCRIPTIONS = { + "play1": "Mono sample playback", + "play2": "Stereo sample playback", + "audioin": "Audio input from sound card channels", + "noise": "LF noise-based sound", + "dab": "High-pass filtered sawtooth blend", + "varsaw": "Variable sawtooth wave", + "lazer": "Complex modulated sawtooth", + "growl": "Sine oscillator with saw modulation", + "bass": "Tri/sawtooth/feedback sine blend", + "dirt": "Sawtooth-based gritty sound", + "crunch": "Noise-based percussive sound", + "rave": "Dynamic stochastic synthesis", + "scatter": "Modulated sawtooth pair", + "charm": "Filtered sine/sawtooth combination", + "bell": "Klank resonator instrument", + "gong": "Klank with feedback modulation", + "soprano": "Vibrato-modulated high sine", + "dub": "Triangle/feedback sine blend", + "viola": "Phase-modulated oscillator", + "scratch": "Vibrato with frequency modulation", + "klank": "Resonant filtered noise", + "feel": "Klank-based warm resonance", + "glass": "Klank-based bright resonance", + "soft": "Klank-based soft resonance", + "quin": "Klank-based quintet resonance", + "pluck": "Plucked string sound", + "spark": "Bright transient sound", + "blip": "Short bright blip", + "ripple": "Pulse-based ripple pattern", + "creep": "Modulated creeping pattern", + "star": "Sawtooth with complex phase modulation", + "jbass": "ADSR-controlled bass", + "orient": "Oriental-flavored synth", + "zap": "Quick percussive zap", + "marimba": "Marimba-like percussive tone", + "fuzz": "Fuzzy distorted sound", + "bug": "Glitchy bug-like sound", + "pulse": "Pulse wave synth", + "saw": "Sawtooth wave synth", + "snick": "Short snick percussive", + "twang": "Twangy plucked string", + "karp": "Karplus-Strong plucked string", + "arpy": "Arpeggiated synth tone", + "nylon": "Nylon string guitar", + "donk": "Percussive donk hit", + "squish": "Squished modulated sound", + "swell": "Swelling pad sound", + "razz": "Razzy buzzing synth", + "sitar": "Sitar-like resonant string", + "piano": "MDA Piano emulation (requires SC3 plugins)", + "sawbass": "Sawtooth bass", + "prophet": "Prophet-style polysynth", + "pads": "Sustained pad sound", + "pasha": "Pasha atmospheric synth", + "ambi": "Ambient atmospheric synth", + "space": "Spacey reverberant synth", + "keys": "Electric keys", + "dbass": "Deep bass", + "sinepad": "Sine wave pad", + "loop": "Loop sample playback", + "stretch": "Time-stretched sample playback", +} + +FX_DESCRIPTIONS = { + "vib": "Vibrato - pitch oscillation", + "slide": "Slide to next note pitch", + "slidefrom": "Slide from a pitch to current", + "bend": "Pitch bend", + "coarse": "Coarse granular effect", + "striate": "Striated granular effect", + "pshift": "Pitch shift", + "hpf": "High-pass filter", + "lpf": "Low-pass filter", + "swell": "Volume swell envelope", + "bpf": "Band-pass filter", + "crush": "Bitcrusher distortion", + "dist": "Distortion", + "spin": "Stereo spinning panner", + "cut": "Cut/gate effect", + "room": "Reverb room size", + "chop": "Amplitude chopping", + "tremolo": "Tremolo amplitude modulation", + "echo": "Echo/delay effect", + "formant": "Formant vowel filter", + "shape": "Waveshaping distortion", + "drive": "Overdrive distortion", +}