├── .github └── ISSUE_TEMPLATE │ ├── ---bug-report.md │ ├── ---feature-request.md │ └── ---module-request.md ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── LICENSE.md ├── README.md ├── core ├── .eslintrc ├── .gitignore ├── LICENSE.md ├── README.md ├── config │ ├── bin.tsconfig.json │ └── lib.tsconfig.json ├── package.json ├── public │ ├── assets │ │ ├── patchcab.png │ │ ├── patchcab.svg │ │ ├── preview@2x.png │ │ ├── routed-gothic-wide.woff2 │ │ └── routed-gothic.woff2 │ ├── index.html │ └── robots.txt ├── rollup.config.js ├── src │ ├── Patchcab.svelte │ ├── actions │ │ ├── clickOutside.ts │ │ ├── drag.ts │ │ ├── index.ts │ │ └── pan.ts │ ├── bin │ │ ├── bin.ts │ │ ├── lib │ │ │ ├── copyFiles.ts │ │ │ ├── helpers.ts │ │ │ ├── parseLibs.ts │ │ │ ├── rollupConfig.ts │ │ │ ├── screenshot.ts │ │ │ └── types.ts │ │ └── libs.json │ ├── components │ │ ├── Faceplate.svelte │ │ ├── Knob.svelte │ │ ├── Label.svelte │ │ ├── Logo.svelte │ │ ├── Patch.svelte │ │ ├── Switch.svelte │ │ ├── Volume.svelte │ │ └── index.ts │ ├── contstants.ts │ ├── helpers │ │ ├── Catenary.ts │ │ ├── Point.ts │ │ ├── energy.ts │ │ ├── helpers.ts │ │ └── index.ts │ ├── index.ts │ ├── lib.ts │ ├── nodes │ │ ├── Bang.ts │ │ └── index.ts │ ├── rack │ │ ├── Bar.svelte │ │ ├── Cables.svelte │ │ ├── Container.svelte │ │ ├── Dialog.svelte │ │ ├── Help.svelte │ │ ├── Loading.svelte │ │ ├── Menu.svelte │ │ ├── Preview.svelte │ │ ├── Share.svelte │ │ ├── Shelf.svelte │ │ └── index.ts │ ├── state │ │ ├── helpers.ts │ │ ├── libraries.ts │ │ ├── modules.ts │ │ └── patches.ts │ └── types.ts └── tsconfig.json ├── modules ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── modules │ ├── adsr.png │ ├── clock.png │ ├── fm.png │ ├── lfo.png │ ├── midi.png │ ├── noise.png │ ├── notes.png │ ├── osc.png │ ├── out.png │ ├── revrb.png │ ├── scope.png │ ├── seq.png │ ├── vcf.png │ └── vol.png ├── package.json ├── src │ ├── ADSR.svelte │ ├── Clock.svelte │ ├── FM.svelte │ ├── LFO.svelte │ ├── MIDI.svelte │ ├── NOISE.svelte │ ├── Notes.svelte │ ├── OSC.svelte │ ├── OUT.svelte │ ├── REVRB.svelte │ ├── SCOPE.svelte │ ├── SEQ.svelte │ ├── VCF.svelte │ └── VOL.svelte └── tsconfig.json ├── package.json └── yarn.lock /.github/ISSUE_TEMPLATE/---bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41E Bug report" 3 | about: Something is not working correctly 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear description of what the bug is. 12 | 13 | **To Reproduce** 14 | If possible: 15 | - provide a link to a [https://patch.cab](Patch.cab) patch where the bug can be reproduced 16 | - or steps to reproduce the behavior 17 | 18 | **Environment:** 19 | - OS: [e.g. Windows, macOS] 20 | - Browser [e.g. Chrome, Safari, Firefox] 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature request" 3 | about: This would be nice to have 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is this feature request for a module or Patchcab in general?** 11 | - [ ] Module 12 | - [ ] General 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---module-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F39A Module request" 3 | about: I want this on my Patchcab rack 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the the module you would like to have** 11 | A clear and concise description of what the module should do. 12 | 13 | **References** 14 | Are there any Eurorack, Buchla, VCV Rack or any other modules that do the same? 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v1.1.3](https://github.com/spectrome/patchcab/compare/1.1.1...1.1.3) 2 | 3 | > 21 November 2021 4 | 5 | ### Fixes 6 | 7 | - Fix rack reset refreshes whole page instead of resetting the modules [#18](https://github.com/spectrome/patchcab/pull/18) 8 | 9 | ## [v1.1.0](https://github.com/spectrome/patchcab/compare/1.0.5...1.1.0) 10 | 11 | > 19 November 2021 12 | 13 | ### Updates 14 | 15 | - Add synth reset confirmation dialog 16 | - Add mouse wheel support on pan actions [#15](https://github.com/spectrome/patchcab/pull/15) 17 | 18 | ### Modules 19 | 20 | - Add internal trigger sweep to `OSC` module [#13](https://github.com/spectrome/patchcab/pull/13) 21 | 22 | ## [v1.0.5](https://github.com/spectrome/patchcab/compare/1.0.4...1.0.5) 23 | 24 | > 22 January 2021 25 | 26 | ### Fixes 27 | 28 | - Fix empty space calculation on module addition [#7](https://github.com/spectrome/patchcab/pull/7) 29 | - Fix drag action to calculate cursor position offset [#8](https://github.com/spectrome/patchcab/pull/8) 30 | - Skip existing patches on state import [#9](https://github.com/spectrome/patchcab/pull/9) 31 | - Fix duplicate connection callbacks triggered [#10](https://github.com/spectrome/patchcab/pull/10) 32 | 33 | ## [v1.0.4](https://github.com/spectrome/patchcab/compare/1.0.3...1.0.4) 34 | 35 | > 22 January 2021 36 | 37 | ### Updates 38 | 39 | - Interface UX updates [#6](https://github.com/spectrome/patchcab/pull/6) 40 | 41 | ### Modules 42 | 43 | - ADSR, LFO, VOL and OUT module parameter range tweaks [#5](https://github.com/spectrome/patchcab/pull/5) 44 | 45 | ## [v1.0.3](https://github.com/spectrome/patchcab/compare/1.0.2...1.0.3) 46 | 47 | > 20 January 2021 48 | 49 | ### Fixes 50 | 51 | - Straight patch cable drawn incorrectly [#1](https://github.com/spectrome/patchcab/pull/1) 52 | - Context menu shows under cables [#2](https://github.com/spectrome/patchcab/pull/2) 53 | - Connecting input patch to input patch results in error [#3](https://github.com/spectrome/patchcab/pull/3) 54 | 55 | ## [v1.0.2](https://github.com/spectrome/patchcab/compare/1.0.1...1.0.2) 56 | 57 | > 19 January 2021 58 | 59 | - Fix module filename resolution 60 | 61 | ## [v1.0.1](https://github.com/spectrome/patchcab/compare/1.0.0...1.0.1) 62 | 63 | > 19 January 2021 64 | 65 | - Fix patch sharing 66 | 67 | ## [1.0.0](https://github.com/spectrome/patchcab/tree/1.0.0) 68 | 69 | > 19 January 2021 70 | 71 | - Initial release 🤘 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Spectrome 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Spectrome 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎛 Patchcab 2 | 3 | Patchcab 4 | 5 | ### Patchcab is a modular Eurorack style synthesizer made with Web Audio. 6 | 7 | Modules are built using [Tone.js Web Audio framework](https://github.com/Tonejs/Tone.js/) and [Svelte Javascript framework](https://github.com/sveltejs/svelte). Patchcab is heavily inspired by [VCV Eurorack Simulator](https://vcvrack.com). 8 | 9 | --- 10 | 11 | ### 🎛 Patch and play 12 | 13 | Go to **https://patch.cab** to create, share and remix synths with community made modules. 14 | 15 | --- 16 | 17 | ### 💾 Run locally 18 | 19 | Install [Node.js](https://nodejs.org) and the latest version of Patchcab core: 20 | 21 | ```bash 22 | npm install @patchcab/core 23 | ``` 24 | 25 | Add some modules: 26 | 27 | ```bash 28 | npm install @patchcab/modules 29 | ``` 30 | 31 | Start Patchcab: 32 | 33 | ```bash 34 | npx patchcab 35 | ``` 36 | 37 | And finally - open the address http://localhost:3000 in your browser 🤘 38 | 39 | --- 40 | 41 | ### 🎚 Build modules 42 | 43 | - Read the ~~Patchcab documentation~~ (coming soon 🤞) 44 | - Read the [Tone.js documentation](https://tonejs.github.io/) 45 | - Browse [Patchcab default modules](https://github.com/spectrome/patchcab/tree/master/modules/src) for basic examples 46 | - Fork the [Patchcab module template](https://github.com/spectrome/patchcab-module-template) 47 | - Share your modules on [Patch.cab](https://patch.cab) by making a pull request to [spectrome/patch-dot-cab](https://github.com/spectrome/patch-dot-cab) -------------------------------------------------------------------------------- /core/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": ["prettier", "plugin:@typescript-eslint/recommended"] 5 | } 6 | -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib 3 | /bin 4 | public/modules* 5 | public/js* -------------------------------------------------------------------------------- /core/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Spectrome 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 | # 🎛 Patchcab 2 | 3 | Patchcab 4 | 5 | ### Patchcab is a modular Eurorack style synthesizer made with Web Audio. 6 | 7 | Modules are built using [Tone.js Web Audio framework](https://github.com/Tonejs/Tone.js/) and [Svelte Javascript framework](https://github.com/sveltejs/svelte). Patchcab is heavily inspired by [VCV Eurorack Simulator](https://vcvrack.com). 8 | 9 | --- 10 | 11 | ### 🎛 Patch and play 12 | 13 | Go to **https://patch.cab** to create, share and remix synths with community made modules. 14 | 15 | --- 16 | 17 | ### 💾 Run locally 18 | 19 | Install [Node.js](https://nodejs.org) and the latest version of Patchcab core: 20 | 21 | ```bash 22 | npm install @patchcab/core 23 | ``` 24 | 25 | Add some modules: 26 | 27 | ```bash 28 | npm install @patchcab/modules 29 | ``` 30 | 31 | Start Patchcab: 32 | 33 | ```bash 34 | npx patchcab 35 | ``` 36 | 37 | And finally - open the address http://localhost:3000 in your browser 🤘 38 | 39 | --- 40 | 41 | ### 🎚 Build modules 42 | 43 | - Read the ~~Patchcab documentation~~ (coming soon 🤞) 44 | - Read the [Tone.js documentation](https://tonejs.github.io/) 45 | - Browse [Patchcab default modules](https://github.com/spectrome/patchcab/tree/master/modules/src) for basic examples 46 | - Fork the [Patchcab module template](https://github.com/spectrome/patchcab-module-template) 47 | - Share your modules on [Patch.cab](https://patch.cab) by making a pull request to [spectrome/patch-dot-cab](https://github.com/spectrome/patch-dot-cab) 48 | -------------------------------------------------------------------------------- /core/config/bin.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "../bin", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "resolveJsonModule": true, 7 | "skipLibCheck": true 8 | }, 9 | "include": ["../src/bin/bin.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /core/config/lib.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../lib", 5 | "declaration": true 6 | }, 7 | "include": ["../src/lib.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@patchcab/core", 3 | "version": "1.1.3", 4 | "description": "Modular Eurorack style synthesizer made with Web Audio", 5 | "license": "MIT", 6 | "author": "Spectrome ", 7 | "files": [ 8 | "bin", 9 | "lib", 10 | "public" 11 | ], 12 | "main": "lib/lib.js", 13 | "types": "lib/lib.d.ts", 14 | "bin": { 15 | "patchcab": "./bin/bin.js" 16 | }, 17 | "scripts": { 18 | "dev": "rollup -c -w", 19 | "test": "svelte-check && tsc --noEmit", 20 | "build": "yarn build:lib && yarn build:web && yarn build:bin", 21 | "build:web": "rollup -c", 22 | "build:lib": "tsc --project ./config/lib.tsconfig.json && cpy './src/components/*.svelte' './lib/components/'", 23 | "build:bin": "tsc --project ./config/bin.tsconfig.json", 24 | "lint": "eslint './src/**/*.ts' --max-warnings 0", 25 | "prettier": "prettier --write './src/**/*.{ts,svelte}'", 26 | "prepublish": "yarn build", 27 | "cli": "patchcab" 28 | }, 29 | "dependencies": { 30 | "@rollup/plugin-commonjs": "^21.0.1", 31 | "@rollup/plugin-node-resolve": "^13.0.6", 32 | "dotenv": "^10.0.0", 33 | "file-dialog": "^0.0.8", 34 | "file-saver": "^2.0.5", 35 | "html-minifier-terser": "^6.0.2", 36 | "imagemin": "^7.0.1", 37 | "imagemin-pngquant": "^9.0.2", 38 | "ncp": "^2.0.0", 39 | "parse-author": "^2.0.0", 40 | "prettier": "^2.4.1", 41 | "prettier-plugin-svelte": "^2.5.0", 42 | "puppeteer": "^11.0.0", 43 | "rimraf": "^3.0.2", 44 | "rollup": "^2.60.0", 45 | "rollup-plugin-external-globals": "^0.6.1", 46 | "rollup-plugin-glslify": "^1.2.1", 47 | "rollup-plugin-livereload": "^2.0.5", 48 | "rollup-plugin-serve": "^1.1.0", 49 | "rollup-plugin-svelte": "^7.1.0", 50 | "rollup-plugin-terser": "^7.0.2", 51 | "rollup-plugin-typescript2": "^0.31.0", 52 | "standardized-audio-context": "^25.3.15", 53 | "svelte": "^3.44.2", 54 | "svelte-check": "^2.2.10", 55 | "svelte-preprocess": "^4.9.8", 56 | "tone": "14.7.77", 57 | "typescript": "^4.1.3" 58 | }, 59 | "prettier": { 60 | "useTabs": false, 61 | "arrowParens": "always", 62 | "semi": true, 63 | "bracketSpacing": true, 64 | "singleQuote": true, 65 | "printWidth": 120, 66 | "svelteSortOrder": "options-scripts-styles-markup" 67 | }, 68 | "devDependencies": { 69 | "@types/file-saver": "^2.0.4", 70 | "@typescript-eslint/eslint-plugin": "^5.4.0", 71 | "@typescript-eslint/parser": "^5.4.0", 72 | "cpy-cli": "^3.1.1", 73 | "eslint": "^8.2.0", 74 | "eslint-config-prettier": "^8.3.0", 75 | "eslint-plugin-prettier": "^4.0.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /core/public/assets/patchcab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/core/public/assets/patchcab.png -------------------------------------------------------------------------------- /core/public/assets/patchcab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/public/assets/preview@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/core/public/assets/preview@2x.png -------------------------------------------------------------------------------- /core/public/assets/routed-gothic-wide.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/core/public/assets/routed-gothic-wide.woff2 -------------------------------------------------------------------------------- /core/public/assets/routed-gothic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/core/public/assets/routed-gothic.woff2 -------------------------------------------------------------------------------- /core/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Patchcab 6 | 7 | 8 | 9 | 10 | 11 | 106 | 107 | 108 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /core/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /core/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import livereload from 'rollup-plugin-livereload'; 4 | import autoPreprocess from 'svelte-preprocess'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import common from '@rollup/plugin-commonjs'; 7 | import svelte from 'rollup-plugin-svelte'; 8 | import serve from 'rollup-plugin-serve'; 9 | import path from 'path'; 10 | 11 | const DIR = path.resolve(process.cwd(), 'public/js'); 12 | const DEV = process.env.ROLLUP_WATCH; 13 | 14 | module.exports = () => { 15 | return { 16 | input: { 17 | core: path.resolve(__dirname, './src/index.ts'), 18 | }, 19 | external: ['@patchcab/core'], 20 | output: { 21 | name: 'patchcab', 22 | format: 'es', 23 | dir: DIR, 24 | paths: { 25 | '@patchcab/core': '/js/core.js', 26 | }, 27 | }, 28 | plugins: [ 29 | common(), 30 | nodeResolve({ 31 | extensions: ['.ts', '.js', '.json', '.svelte', '.mjs'], 32 | }), 33 | svelte({ 34 | emitCss: false, 35 | preprocess: autoPreprocess(), 36 | }), 37 | typescript(), 38 | DEV && 39 | serve({ 40 | open: true, 41 | contentBase: path.resolve(DIR, '../'), 42 | historyApiFallback: true, 43 | port: '3000', 44 | headers: { 45 | 'Access-Control-Allow-Origin': '*', 46 | }, 47 | }), 48 | DEV && livereload(), 49 | !DEV && terser(), 50 | ], 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /core/src/Patchcab.svelte: -------------------------------------------------------------------------------- 1 | 57 | 58 | {#if loading} 59 | 60 | {:else} 61 | 62 | 63 | 64 | 65 | {#each $modulesAll as module (module.id)} 66 | 67 | {/each} 68 | 69 | {/if} 70 | -------------------------------------------------------------------------------- /core/src/actions/clickOutside.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '../types'; 2 | 3 | export type OnClickOutside = (e: MouseEvent) => void; 4 | 5 | /** 6 | * Detect a click outside of the target element or it's children 7 | * 8 | * @example 9 | * 10 | *
11 | * 12 | * @category Actions 13 | */ 14 | const useClickOutside: Action = (node, onClickOutside) => { 15 | if (typeof onClickOutside !== 'function') { 16 | return; 17 | } 18 | 19 | const onMousedown = (event: MouseEvent) => { 20 | let parent = event.target as HTMLElement; 21 | let isChild = false; 22 | 23 | while (parent) { 24 | if (parent === node) { 25 | isChild = true; 26 | break; 27 | } 28 | parent = parent.parentNode as HTMLElement; 29 | } 30 | 31 | if (!isChild) { 32 | onClickOutside(event); 33 | } 34 | }; 35 | 36 | document.addEventListener('mousedown', onMousedown, { passive: true }); 37 | document.addEventListener('touchstart', onMousedown, { passive: true }); 38 | 39 | return { 40 | destroy() { 41 | document.removeEventListener('mousedown', onMousedown); 42 | document.removeEventListener('touchstart', onMousedown); 43 | }, 44 | }; 45 | }; 46 | 47 | export default useClickOutside; 48 | -------------------------------------------------------------------------------- /core/src/actions/drag.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '../types'; 2 | import { BAR_HEIGHT } from '../contstants'; 3 | 4 | export type OnDrag = (x: number, y: number, box: DOMRect) => void; 5 | 6 | /** 7 | * Element drag event 8 | * 9 | * @example 10 | * 11 | *
12 | * 13 | * @category Actions 14 | */ 15 | const useDrag: Action = (node, onDrag) => { 16 | if (typeof onDrag !== 'function') { 17 | return; 18 | } 19 | 20 | let offset: number; 21 | 22 | const onMousedown = (event: MouseEvent | TouchEvent) => { 23 | if (event.target !== node && (event.target as HTMLElement).getAttribute('draggable') === null) { 24 | return; 25 | } 26 | 27 | const clientX = 'clientX' in event ? event.clientX : event.touches[0].clientX; 28 | offset = clientX - node.getBoundingClientRect().left; 29 | 30 | window.addEventListener('mousemove', onMousemove, { passive: true }); 31 | window.addEventListener('touchmove', onMousemove, { passive: true }); 32 | window.addEventListener('mouseup', onMouseup, { passive: true }); 33 | window.addEventListener('touchend', onMouseup, { passive: true }); 34 | }; 35 | 36 | const onMousemove = (event: MouseEvent | TouchEvent) => { 37 | const box = node.getBoundingClientRect(); 38 | 39 | const scrollX = document.documentElement.scrollLeft || document.body.scrollLeft; 40 | const scrollY = document.documentElement.scrollTop || document.body.scrollTop; 41 | 42 | const clientX = 'clientX' in event ? event.clientX : event.touches[0].clientX; 43 | const clientY = 'clientY' in event ? event.clientY : event.touches[0].clientY; 44 | 45 | const x = clientX + scrollX - offset; 46 | const y = clientY + scrollY - BAR_HEIGHT; 47 | 48 | onDrag(x, y, box); 49 | }; 50 | 51 | const onMouseup = () => { 52 | window.removeEventListener('mousemove', onMousemove); 53 | window.removeEventListener('touchmove', onMousemove); 54 | window.removeEventListener('mouseup', onMouseup); 55 | window.removeEventListener('touchend', onMouseup); 56 | }; 57 | 58 | node.addEventListener('mousedown', onMousedown, { passive: true }); 59 | node.addEventListener('touchstart', onMousedown, { passive: true }); 60 | 61 | return { 62 | destroy() { 63 | node.removeEventListener('mousedown', onMousedown); 64 | node.removeEventListener('touchstart', onMousedown); 65 | }, 66 | }; 67 | }; 68 | 69 | export default useDrag; 70 | -------------------------------------------------------------------------------- /core/src/actions/index.ts: -------------------------------------------------------------------------------- 1 | export { default as usePan } from './pan'; 2 | export type { OnPan } from './pan'; 3 | 4 | export { default as useDrag } from './drag'; 5 | export type { OnDrag } from './drag'; 6 | 7 | export { default as useClickOutside } from './clickOutside'; 8 | export type { OnClickOutside } from './clickOutside'; 9 | -------------------------------------------------------------------------------- /core/src/actions/pan.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '../types'; 2 | 3 | export type OnPan = (value: { x: number; y: number; dx?: number; dy?: number }) => void; 4 | 5 | /** 6 | * Element pan event 7 | * 8 | * @example 9 | * 10 | *
11 | * 12 | * @category Actions 13 | */ 14 | const usePan: Action = (node, onMove) => { 15 | let x: number; 16 | let y: number; 17 | 18 | if (typeof onMove !== 'function') { 19 | return; 20 | } 21 | 22 | const onMousedown = (event: MouseEvent | TouchEvent) => { 23 | x = 'clientX' in event ? event.clientX : event.touches[0].clientX; 24 | y = 'clientY' in event ? event.clientY : event.touches[0].clientY; 25 | 26 | window.addEventListener('mousemove', onMousemove, { passive: true }); 27 | window.addEventListener('touchmove', onMousemove, { passive: true }); 28 | window.addEventListener('mouseup', onMouseup, { passive: true }); 29 | window.addEventListener('touchend', onMouseup, { passive: true }); 30 | }; 31 | 32 | const onMousemove = (event: MouseEvent | TouchEvent) => { 33 | const newX = 'clientX' in event ? event.clientX : event.touches[0].clientX; 34 | const newY = 'clientY' in event ? event.clientY : event.touches[0].clientY; 35 | 36 | const dx = newX - x; 37 | const dy = newY - y; 38 | 39 | x = newX; 40 | y = newY; 41 | 42 | onMove({ x, y, dx, dy }); 43 | }; 44 | 45 | const onMouseup = () => { 46 | window.removeEventListener('mousemove', onMousemove); 47 | window.removeEventListener('touchmove', onMousemove); 48 | window.removeEventListener('mouseup', onMouseup); 49 | window.removeEventListener('touchend', onMouseup); 50 | }; 51 | 52 | const onWheel = (event: WheelEvent) => { 53 | event.preventDefault(); 54 | onMove({ x: event.clientX, y: event.clientY, dx: event.deltaX, dy: event.deltaY }); 55 | }; 56 | 57 | node.addEventListener('mousedown', onMousedown, { passive: true }); 58 | node.addEventListener('touchstart', onMousedown, { passive: true }); 59 | node.addEventListener('wheel', onWheel, { passive: false }); 60 | 61 | return { 62 | destroy() { 63 | node.removeEventListener('mousedown', onMousedown); 64 | node.removeEventListener('touchstart', onMousedown); 65 | node.removeEventListener('wheel', onWheel); 66 | }, 67 | }; 68 | }; 69 | 70 | export default usePan; 71 | -------------------------------------------------------------------------------- /core/src/bin/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { copyFileSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; 4 | import { extname, resolve } from 'path'; 5 | import * as rimraf from 'rimraf'; 6 | import { rollup, watch } from 'rollup'; 7 | import parseAuthor from 'parse-author'; 8 | import copyFiles from './lib/copyFiles'; 9 | import parseLibs from './lib/parseLibs'; 10 | import rollupConfig from './lib/rollupConfig'; 11 | import defaultLibs from './libs.json'; 12 | import { minify } from 'html-minifier-terser'; 13 | import screenshot from './lib/screenshot'; 14 | import { safeName } from './lib/helpers'; 15 | import dotenv from 'dotenv'; 16 | import type { Module, Library } from './lib/types'; 17 | 18 | dotenv.config(); 19 | 20 | const BUILD = process.argv.indexOf('--build') > -1; 21 | const DIR = process.cwd(); 22 | const CONFIG = JSON.parse(readFileSync(resolve(DIR, 'package.json'), 'utf8')); 23 | 24 | if (!CONFIG) { 25 | throw Error('No package.json file found'); 26 | } 27 | 28 | const PATH_BUILD = resolve(DIR, './modules'); 29 | const PATH_PUBLIC = resolve(DIR, './public'); 30 | const PATH_MODULES = resolve(DIR, `./public/modules/${CONFIG.name.replace(/\//g, '-')}`); 31 | 32 | const bin = async (): Promise => { 33 | const libs: Record = defaultLibs; 34 | 35 | // Remove build outputs 36 | rimraf.sync(PATH_BUILD); 37 | rimraf.sync(PATH_PUBLIC); 38 | 39 | // Copy public asset files 40 | await copyFiles(resolve(__dirname, '../public'), PATH_PUBLIC); 41 | 42 | // Update index file 43 | let indexFile: string = readFileSync(resolve(PATH_PUBLIC, `index.html`), 'utf8'); 44 | 45 | // Add optional head content 46 | if (process.env.PATCHCAB_HEAD) { 47 | indexFile = indexFile.replace(``, `${process.env.PATCHCAB_HEAD}`); 48 | } 49 | 50 | // Add optional body content 51 | if (process.env.PATCHCAB_BODY) { 52 | indexFile = indexFile.replace(``, `${process.env.PATCHCAB_BODY}`); 53 | } 54 | 55 | // Update props 56 | const props: Record = {}; 57 | 58 | if (process.env.PATCHCAB_API) { 59 | props['api'] = process.env.PATCHCAB_API; 60 | } 61 | 62 | // Check for default rack prop 63 | const rackIndex = process.argv.indexOf('--rack'); 64 | const fileName = rackIndex > -1 ? resolve(DIR, process.argv[rackIndex + 1]) : ''; 65 | if (existsSync(fileName)) { 66 | const rackContent = readFileSync(fileName, 'utf-8'); 67 | props['rack'] = JSON.parse(rackContent); 68 | } 69 | 70 | indexFile = await minify(indexFile.replace('props: {}', `props: ${JSON.stringify(props)}`), { 71 | collapseWhitespace: true, 72 | minifyCSS: true, 73 | minifyJS: true, 74 | }); 75 | 76 | writeFileSync(resolve(PATH_PUBLIC, `index.html`), indexFile); 77 | 78 | // Create empty public modules directory 79 | mkdirSync(PATH_MODULES, { recursive: true }); 80 | 81 | // Look for patchcab modules in project dependencies and copy those to public modules directory 82 | let dependencies = CONFIG.dependencies ? Object.keys(CONFIG.dependencies) : []; 83 | 84 | if (CONFIG.devDependencies) { 85 | dependencies = dependencies.concat(Object.keys(CONFIG.devDependencies)); 86 | } 87 | 88 | const depModules: Module[] = []; 89 | 90 | dependencies.forEach((dependency) => { 91 | try { 92 | const dependencyDir = resolve(DIR, `./node_modules/${dependency}`); 93 | const dependencyConfig = 94 | existsSync(resolve(dependencyDir, `./package.json`)) && 95 | JSON.parse(readFileSync(resolve(dependencyDir, `./package.json`), 'utf8')); 96 | 97 | if (!dependencyConfig || !dependencyConfig.patchcab) { 98 | return; 99 | } 100 | 101 | dependencyConfig.patchcab.forEach((item) => { 102 | if ( 103 | typeof item === 'object' && 104 | item.name && 105 | existsSync(resolve(dependencyDir, `./modules/${safeName(item.name)}.js`)) 106 | ) { 107 | const setName = dependencyConfig.name.replace(/\//g, '-'); 108 | 109 | const author = 110 | typeof dependencyConfig.author === 'string' 111 | ? parseAuthor(dependencyConfig.author) 112 | : dependencyConfig.author || {}; 113 | 114 | depModules.push({ 115 | set: setName, 116 | name: item.name, 117 | tags: item.tags, 118 | size: item.size, 119 | author, 120 | libs: parseLibs(item.name, item.libs, libs), 121 | }); 122 | 123 | const targetDir = resolve(DIR, `./public/modules/${setName}/`); 124 | 125 | if (!existsSync(targetDir)) { 126 | mkdirSync(targetDir, { recursive: true }); 127 | } 128 | 129 | copyFileSync( 130 | resolve(dependencyDir, `./modules/${safeName(item.name)}.js`), 131 | resolve(targetDir, `${safeName(item.name)}.js`) 132 | ); 133 | copyFileSync( 134 | resolve(dependencyDir, `./modules/${safeName(item.name)}.png`), 135 | resolve(targetDir, `${safeName(item.name)}.png`) 136 | ); 137 | } 138 | }); 139 | } catch (err) { 140 | console.log(err.message); 141 | } 142 | }); 143 | 144 | const ownModules: Module[] = []; 145 | const input: Record = {}; 146 | 147 | if (CONFIG.patchcab) { 148 | CONFIG.patchcab.forEach((item) => { 149 | const modulePath = resolve(DIR, `./src/${item.file}`); 150 | input[safeName(item.name)] = extname(modulePath) !== '.svelte' ? `${modulePath}.svelte` : modulePath; 151 | 152 | const author = typeof CONFIG.author === 'string' ? parseAuthor(CONFIG.author) : CONFIG.author || {}; 153 | 154 | ownModules.push({ 155 | set: CONFIG.name.replace(/\//g, '-'), 156 | name: item.name, 157 | tags: item.tags, 158 | size: item.size, 159 | author, 160 | libs: parseLibs(item.name, item.libs, libs), 161 | }); 162 | }); 163 | } 164 | 165 | const config = { 166 | ...rollupConfig(input, libs, PATH_BUILD), 167 | }; 168 | 169 | // Write modules files to public directory 170 | writeFileSync(resolve(PATH_PUBLIC, 'modules.json'), JSON.stringify([...depModules, ...ownModules])); 171 | 172 | if (CONFIG.patchcab && CONFIG.patchcab.length) { 173 | console.log('Building Patchcab...'); 174 | 175 | const bundle = await rollup(config); 176 | await bundle.write(config.output); 177 | 178 | console.log('Taking screenshots...'); 179 | 180 | await copyFiles(PATH_BUILD, PATH_MODULES); 181 | await screenshot(ownModules, PATH_BUILD, PATH_PUBLIC); 182 | } 183 | 184 | if (BUILD) { 185 | console.log('Patchcab built 🎉'); 186 | process.exit(); 187 | } else { 188 | console.log('Patchcab running at http://localhost:3000 🚀'); 189 | if (CONFIG.patchcab && CONFIG.patchcab.length) { 190 | const watcher = watch(config); 191 | let first = true; 192 | watcher.on('event', async (event) => { 193 | switch (event.code) { 194 | case 'START': 195 | if (!first) { 196 | console.log('Rebuilding Patchcab...'); 197 | } 198 | break; 199 | case 'END': 200 | await copyFiles(PATH_BUILD, PATH_MODULES); 201 | if (!first) { 202 | console.log('Done!'); 203 | } 204 | first = false; 205 | break; 206 | case 'ERROR': 207 | console.error(event.error); 208 | break; 209 | } 210 | }); 211 | } else { 212 | console.log('Patchcab running at http://localhost:3000'); 213 | } 214 | } 215 | }; 216 | 217 | bin(); 218 | -------------------------------------------------------------------------------- /core/src/bin/lib/copyFiles.ts: -------------------------------------------------------------------------------- 1 | import { ncp } from 'ncp'; 2 | 3 | const copyFiles = async (source: string, destination: string): Promise => { 4 | return new Promise((resolve, reject) => { 5 | ncp(source, destination, function (err) { 6 | if (err) { 7 | console.log(err); 8 | reject(err); 9 | } 10 | resolve(); 11 | }); 12 | }); 13 | }; 14 | 15 | export default copyFiles; 16 | -------------------------------------------------------------------------------- /core/src/bin/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | export const safeName = (name: string): string => { 2 | return name.replace(/[^a-z0-9_-]/gi, '_').toLowerCase(); 3 | }; 4 | -------------------------------------------------------------------------------- /core/src/bin/lib/parseLibs.ts: -------------------------------------------------------------------------------- 1 | import type { Library } from './types'; 2 | 3 | const parseLibs = (itemName: string, input: (string | Library)[], library: Record): string[] => { 4 | const output: string[] = []; 5 | 6 | if (input?.length === 0) { 7 | return output; 8 | } 9 | 10 | input.forEach((item) => { 11 | let lib: Library; 12 | 13 | if (typeof item === 'object') { 14 | if (item.cdn && item.alias) { 15 | lib = item; 16 | } else { 17 | throw Error(`Incorrect lib entry for module ${itemName}`); 18 | } 19 | } else if (library[item]) { 20 | lib = library[item]; 21 | } else { 22 | throw Error(`Incorrect lib entry for module ${itemName}`); 23 | } 24 | 25 | library[lib.alias] = lib; 26 | output.push(lib.cdn); 27 | }); 28 | 29 | return output; 30 | }; 31 | 32 | export default parseLibs; 33 | -------------------------------------------------------------------------------- /core/src/bin/lib/rollupConfig.ts: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import autoPreprocess from 'svelte-preprocess'; 3 | import common from '@rollup/plugin-commonjs'; 4 | import externalGlobals from 'rollup-plugin-external-globals'; 5 | import typescript from 'rollup-plugin-typescript2'; 6 | import svelte from 'rollup-plugin-svelte'; 7 | import livereload from 'rollup-plugin-livereload'; 8 | import glslify from 'rollup-plugin-glslify'; 9 | import { terser } from 'rollup-plugin-terser'; 10 | import serve from 'rollup-plugin-serve'; 11 | import path from 'path'; 12 | import fs from 'fs'; 13 | import type { Library } from './types'; 14 | 15 | const DEV = process.argv.indexOf('--build') < 0; 16 | 17 | const searchRecursive = (dir: string, pattern: string): boolean => { 18 | if (!fs.existsSync(dir)) { 19 | return false; 20 | } 21 | 22 | let results: boolean[] = []; 23 | 24 | fs.readdirSync(dir).forEach((dirInner) => { 25 | dirInner = path.resolve(dir, dirInner); 26 | const stat = fs.statSync(dirInner); 27 | if (stat.isDirectory()) { 28 | results = results.concat(searchRecursive(dirInner, pattern)); 29 | } 30 | if (stat.isFile() && dirInner.endsWith(pattern)) { 31 | results.push(true); 32 | } 33 | }); 34 | 35 | return results.length > 0; 36 | }; 37 | 38 | const config = (input: Record, libs: Record, dir: string): Record => { 39 | const TS = searchRecursive(path.resolve(process.cwd(), 'src'), '.ts'); 40 | 41 | const externals = Object.keys(libs); 42 | const globals: Record = {}; 43 | 44 | externals.forEach((item) => { 45 | globals[item] = libs[item].alias; 46 | }); 47 | 48 | return { 49 | input, 50 | external: ['@patchcab/core', 'svelte/internal'].concat(externals), 51 | output: { 52 | name: 'patchcab', 53 | format: 'es', 54 | dir, 55 | paths: { 56 | '@patchcab/core': '/js/core.js', 57 | }, 58 | }, 59 | plugins: [ 60 | common(), 61 | nodeResolve({ 62 | extensions: ['.ts', '.js', '.json', '.svelte', '.mjs', '.vert', '.frag'], 63 | }), 64 | glslify(), 65 | TS && 66 | typescript({ 67 | tsconfigOverride: { 68 | sourceMap: false, 69 | compilerOptions: { 70 | noImplicitAny: true, 71 | lib: ['es6', 'dom', 'es2015'], 72 | module: 'es2015', 73 | target: 'es5', 74 | moduleResolution: 'node', 75 | esModuleInterop: true, 76 | declaration: false, 77 | declarationMap: false, 78 | outDir: dir, 79 | }, 80 | include: [path.resolve(process.cwd(), 'src')], 81 | }, 82 | }), 83 | svelte({ 84 | emitCss: false, 85 | preprocess: autoPreprocess(), 86 | }), 87 | externalGlobals({ 88 | 'svelte/internal': '__sv', 89 | ...globals, 90 | }), 91 | DEV && 92 | serve({ 93 | open: false, 94 | contentBase: path.resolve(process.cwd(), './public'), 95 | port: 3000, 96 | historyApiFallback: true, 97 | headers: { 98 | 'Access-Control-Allow-Origin': '*', 99 | }, 100 | }), 101 | DEV && livereload(), 102 | !DEV && terser(), 103 | ], 104 | }; 105 | }; 106 | 107 | export default config; 108 | -------------------------------------------------------------------------------- /core/src/bin/lib/screenshot.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer'; 2 | import rollupServe from 'rollup-plugin-serve'; 3 | import imagemin from 'imagemin'; 4 | import imageminPngquant from 'imagemin-pngquant'; 5 | import path from 'path'; 6 | import fs from 'fs'; 7 | import { safeName } from './helpers'; 8 | import type { Module } from './types'; 9 | 10 | const HP = { 11 | w: 16, 12 | h: 380, 13 | }; 14 | 15 | const PORT = 3000; 16 | 17 | const screenshot = async (modules: Module[], PATH_BUILD: string, PATH_PUBLIC: string): Promise => { 18 | rollupServe({ 19 | contentBase: PATH_PUBLIC, 20 | port: PORT, 21 | headers: { 22 | 'Access-Control-Allow-Origin': '*', 23 | }, 24 | }); 25 | 26 | const browser = await puppeteer.launch({ 27 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 28 | }); 29 | 30 | const template = fs 31 | .readFileSync(path.resolve(__dirname, '../../public/index.html'), 'utf8') 32 | .replace( 33 | /]*>[^`]+?<\/script>/g, 34 | `` 50 | ) 51 | .replace(/href="\//g, `href="http://localhost:${PORT}/`) 52 | .replace(/url\('\//g, `url('http://localhost:${PORT}/`); 53 | 54 | const page = await browser.newPage(); 55 | 56 | for (let i = 0; i < modules.length; i++) { 57 | const module = modules[i]; 58 | const fileName = safeName(module.name); 59 | 60 | const html = template 61 | .replace('MODULE_NAME', `${module.set}/${safeName(module.name)}`) 62 | .replace('MODULE_SIZE_W', `${module.size.w}`) 63 | .replace('MODULE_SIZE_H', `${module.size.h}`) 64 | .replace('MODULE_LIBS', JSON.stringify(module.libs)); 65 | 66 | await page.setViewport({ 67 | width: module.size.w * HP.w, 68 | height: module.size.h * HP.h + 48, 69 | deviceScaleFactor: 2, 70 | }); 71 | 72 | await page.setContent(html, { waitUntil: 'networkidle0' }); 73 | 74 | await page.screenshot({ 75 | path: path.resolve(PATH_BUILD, `${fileName}.png`), 76 | clip: { 77 | x: 0, 78 | y: 48, 79 | width: module.size.w * HP.w, 80 | height: module.size.h * HP.h, 81 | }, 82 | }); 83 | 84 | await imagemin([PATH_BUILD, `${fileName}.png`], { 85 | destination: PATH_BUILD, 86 | plugins: [imageminPngquant()], 87 | }); 88 | 89 | fs.copyFileSync( 90 | path.resolve(PATH_BUILD, `${fileName}.png`), 91 | path.resolve(PATH_PUBLIC, `modules/${module.set}/${fileName}.png`) 92 | ); 93 | } 94 | 95 | await browser.close(); 96 | return true; 97 | }; 98 | 99 | export default screenshot; 100 | -------------------------------------------------------------------------------- /core/src/bin/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Module = { 2 | set: string; 3 | name: string; 4 | tags: string[]; 5 | size: { 6 | w: number; 7 | h: number; 8 | }; 9 | author: string; 10 | libs: string[]; 11 | }; 12 | 13 | export type Library = { 14 | cdn: string; 15 | alias: string; 16 | }; 17 | -------------------------------------------------------------------------------- /core/src/bin/libs.json: -------------------------------------------------------------------------------- 1 | { 2 | "Tone": { 3 | "cdn": "https://unpkg.com/tone@14.7.77/build/Tone.js", 4 | "alias": "Tone" 5 | }, 6 | "THREE": { 7 | "cdn": "https://unpkg.com/three@0.116.1/build/three.min.js", 8 | "alias": "THREE" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /core/src/components/Faceplate.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 55 | 56 |
57 | {#if title} 58 |

{title}

59 | {/if} 60 | 61 | {#each [1, 2, 3, 4] as index} 62 | 63 | 68 | 69 | {/each} 70 |
71 | -------------------------------------------------------------------------------- /core/src/components/Knob.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 55 | 56 |
57 | {#if size === 's'} 58 | 59 | 60 | 61 | 62 | 68 | 69 | 70 | 71 | 72 | 73 | 91 | 92 | {#if label} 93 | 94 | {/if} 95 | {:else} 96 | 103 | 113 | 114 | 120 | 130 | 131 | 132 | 133 | 134 | {#if label} 135 | 136 | {/if} 137 | {/if} 138 |
139 | -------------------------------------------------------------------------------- /core/src/components/Label.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | 18 |
19 | 20 |
21 | -------------------------------------------------------------------------------- /core/src/components/Logo.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /core/src/components/Patch.svelte: -------------------------------------------------------------------------------- 1 | 142 | 143 | 161 | 162 | 169 | 170 | 171 | 172 | 173 | 174 | {#if label} 175 | 176 | {/if} 177 | 178 | -------------------------------------------------------------------------------- /core/src/components/Switch.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 57 | 58 | 69 | -------------------------------------------------------------------------------- /core/src/components/Volume.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 54 | 55 |
56 | 57 | 58 | 59 | 60 | 61 | 66 | 67 |
68 | {#if label} 69 | 70 | {/if} 71 |
72 | -------------------------------------------------------------------------------- /core/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Faceplate } from './Faceplate.svelte'; 2 | export { default as Knob } from './Knob.svelte'; 3 | export { default as Label } from './Label.svelte'; 4 | export { default as Logo } from './Logo.svelte'; 5 | export { default as Patch } from './Patch.svelte'; 6 | export { default as Switch } from './Switch.svelte'; 7 | export { default as Volume } from './Volume.svelte'; 8 | -------------------------------------------------------------------------------- /core/src/contstants.ts: -------------------------------------------------------------------------------- 1 | export const HP = { 2 | w: 16, 3 | h: 380, 4 | }; 5 | 6 | export const BAR_HEIGHT = 48; 7 | -------------------------------------------------------------------------------- /core/src/helpers/Catenary.ts: -------------------------------------------------------------------------------- 1 | import type Point from './Point'; 2 | 3 | /** 4 | * Get an SVG quadratic bézier curve path based simulating a catenary curve 5 | * @param p1 - Line Start point 6 | * @param p2 - Line End point 7 | * 8 | * @category Helpers 9 | */ 10 | export const getCatenaryPath = (p1: Point, p2: Point): string => { 11 | const distance = p1.getDistanceTo(p2); 12 | 13 | let length = 100; 14 | 15 | switch (true) { 16 | case distance < 400: 17 | length = 420; 18 | break; 19 | case distance < 900: 20 | length = 940; 21 | break; 22 | case distance < 1400: 23 | length = 1440; 24 | break; 25 | default: 26 | length = distance * 1.05; 27 | } 28 | 29 | const controlX = Math.round((p1.x + p2.x) / 2); 30 | const controlY = Math.round(Math.max(p1.y, p2.y) + length - distance * 0.5); 31 | 32 | return `M ${p1.x} ${p1.y} Q ${controlX} ${controlY} ${p2.x} ${p2.y}`; 33 | }; 34 | -------------------------------------------------------------------------------- /core/src/helpers/Point.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Define a point in 2D space 3 | * 4 | * @example 5 | * // Create a point 6 | * const pointA = new Point(0, 0); 7 | * // Update point positions 8 | * pointA.update(10, 10) 9 | * // Get a distance to another point 10 | * const distance = pointA.getDistanceTo(pointB); 11 | * 12 | * @category Helpers 13 | */ 14 | class Point { 15 | public x: number; 16 | public y: number; 17 | 18 | constructor(x: number, y: number) { 19 | this.x = x; 20 | this.y = y; 21 | } 22 | 23 | /** 24 | * Update the x and y values 25 | */ 26 | update(point: Point): void { 27 | this.x = point.x; 28 | this.y = point.y; 29 | } 30 | 31 | /** 32 | * Get the difference for x and y axis to another point 33 | */ 34 | getDifferenceTo(point: Point): Point { 35 | return new Point(this.x - point.x, this.y - point.y); 36 | } 37 | 38 | /** 39 | * Calculate distance to another point 40 | */ 41 | getDistanceTo(point: Point): number { 42 | const diff = this.getDifferenceTo(point); 43 | return Math.sqrt(Math.pow(diff.x, 2) + Math.pow(diff.y, 2)); 44 | } 45 | } 46 | 47 | export default Point; 48 | -------------------------------------------------------------------------------- /core/src/helpers/energy.ts: -------------------------------------------------------------------------------- 1 | import type { Analyser } from 'tone'; 2 | /** 3 | * Frequency 4 | */ 5 | type FrequencyRange = 'low' | 'mid' | 'high'; 6 | 7 | const frequencyRanges: Record = { 8 | low: [20, 400], 9 | mid: [400, 2600], 10 | high: [2600, 14000], 11 | }; 12 | 13 | const getEnergyAtHz = (hz: number, analyzer: Analyser): number => { 14 | const nyquist = analyzer.context.sampleRate / 2; 15 | const frequencyBinCount = analyzer.size; 16 | 17 | return Math.max(0, Math.min(frequencyBinCount - 1, Math.floor((hz / nyquist) * frequencyBinCount))); 18 | }; 19 | 20 | const getEnergy = (analyser: Analyser, low: FrequencyRange, high?: FrequencyRange): number => { 21 | const buffer = analyser.getValue(); 22 | 23 | const lowHz = frequencyRanges[low][0]; 24 | const highHz = frequencyRanges[high || low][1]; 25 | 26 | const lowIndex = getEnergyAtHz(lowHz, analyser); 27 | const highIndex = getEnergyAtHz(highHz, analyser); 28 | 29 | let total = 0; 30 | let numFrequencies = 0; 31 | 32 | for (let i = lowIndex; i <= highIndex; i++) { 33 | total += buffer[i] as number; 34 | numFrequencies++; 35 | } 36 | 37 | const toReturn = total / numFrequencies; 38 | return toReturn; 39 | }; 40 | 41 | export default getEnergy; 42 | -------------------------------------------------------------------------------- /core/src/helpers/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Transform a value between two ranges 3 | * @param value - Current value 4 | * @param from - [min, max] current value range 5 | * @param to - [min, max] target value range 6 | * @param precision - scaled value decimal places precision 7 | * 8 | * @example 9 | * // scale a value 10 | * const scaledValue = scale(originalValue, [0, 100], [25, 50], 2); 11 | * 12 | * @category Helpers 13 | */ 14 | export const scale = (value: number, from: [number, number], to: [number, number], precision = 2): number => { 15 | const scaled = (to[1] - to[0]) / (from[1] - from[0]); 16 | const capped = Math.min(from[1], Math.max(from[0], value)) - from[0]; 17 | return round(capped * scaled + to[0], precision); 18 | }; 19 | 20 | /** 21 | * Round a number with specific decimal places precision 22 | * 23 | * @example 24 | * const freq = round(420.240); 25 | * 26 | * @category Helpers 27 | */ 28 | export const round = (value: number, precision = 0): number => { 29 | const p = Math.pow(10, precision); 30 | const m = value * p * (1 + Number.EPSILON); 31 | return Math.round(m) / p; 32 | }; 33 | 34 | /** 35 | * Check if a keyboard event can be intercepted as a shortcut 36 | * 37 | * @example 38 | * // skip processing a keyboard event if definately not a shortcut 39 | * const onKeyDown = (e: KeyboardEvent) => { 40 | * if(!isShortcut()){ 41 | * return true; 42 | * } 43 | * } 44 | * 45 | * @category Helpers 46 | */ 47 | export const isShortcut = (e: KeyboardEvent): boolean => { 48 | const tagName = (e.target as HTMLElement).tagName.toLowerCase(); 49 | return ['input', 'textarea'].indexOf(tagName) < 0; 50 | }; 51 | 52 | /** 53 | * Retruns a random color hex code from a predefined list 54 | * 55 | * @example 56 | * const color = randomColor() 57 | * 58 | * @category Helpers 59 | */ 60 | export const randomColor = (): string => { 61 | const colors = ['#E6EB74', '#98D2DE', '#8ACB74', '#DC4846']; 62 | return colors[Math.floor(Math.random() * colors.length)]; 63 | }; 64 | 65 | /** 66 | * Convert module title name to a safe file name 67 | * 68 | * @example 69 | * const fileName = safeName(input); 70 | * 71 | * @category Helpers 72 | */ 73 | export const safeName = (name: string): string => { 74 | return name.replace(/[^a-z0-9_-]/gi, '_').toLowerCase(); 75 | }; 76 | -------------------------------------------------------------------------------- /core/src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { getCatenaryPath } from './Catenary'; 2 | export { default as Point } from './Point'; 3 | export { default as getEnergy } from './energy'; 4 | 5 | export * from './helpers'; 6 | -------------------------------------------------------------------------------- /core/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as svelte from 'svelte/internal'; 2 | import Patchcab from './Patchcab.svelte'; 3 | 4 | declare global { 5 | interface Window { 6 | __sv: typeof svelte; 7 | } 8 | } 9 | 10 | window['__sv'] = svelte; 11 | 12 | export * from './lib'; 13 | export * from './components'; 14 | export { Container } from './rack'; 15 | 16 | export default Patchcab; 17 | -------------------------------------------------------------------------------- /core/src/lib.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './helpers'; 3 | export * from './contstants'; 4 | export * from './components'; 5 | export * from './nodes'; 6 | export { onMount } from 'svelte'; 7 | -------------------------------------------------------------------------------- /core/src/nodes/Bang.ts: -------------------------------------------------------------------------------- 1 | type BangCallback = (time: number, attack: boolean, release: boolean) => void; 2 | 3 | class Bang { 4 | private bangs: Bang[] = []; 5 | private output: BangCallback; 6 | 7 | constructor(output?: BangCallback) { 8 | this.output = output; 9 | } 10 | 11 | public connect(bang: Bang): void { 12 | this.bangs = [...this.bangs, bang]; 13 | } 14 | 15 | public disconnect(bang: Bang): void { 16 | this.bangs = this.bangs.filter((item) => item !== bang); 17 | } 18 | 19 | public bang(time: number, attack = false, release = false): void { 20 | this.bangs.forEach((item) => { 21 | item.trigger(time, attack, release); 22 | }); 23 | } 24 | 25 | public trigger(time: number, attack = false, release = false): void { 26 | this.output(time, attack, release); 27 | } 28 | } 29 | 30 | export default Bang; 31 | -------------------------------------------------------------------------------- /core/src/nodes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Bang } from './Bang'; 2 | -------------------------------------------------------------------------------- /core/src/rack/Bar.svelte: -------------------------------------------------------------------------------- 1 | 88 | 89 | 148 | 149 | 150 |
151 | 179 |
180 | {#if api} 181 | 182 | {/if} 183 |
184 |
185 | 186 |
187 |
Do you want to clear the current patch?
188 |

Your changes on current patch will be lost.

189 |
190 | 191 | 192 |
193 |
194 |
195 |
196 | -------------------------------------------------------------------------------- /core/src/rack/Cables.svelte: -------------------------------------------------------------------------------- 1 | 109 | 110 | 126 | 127 | 128 | 129 | 130 | {#each cables as cable} 131 | 132 | 133 | {/each} 134 | {#if activeCable} 135 | 136 | {/if} 137 | 138 | -------------------------------------------------------------------------------- /core/src/rack/Container.svelte: -------------------------------------------------------------------------------- 1 | 115 | 116 | 135 | 136 | 137 | 138 | {#if Component} 139 |
149 | 150 |
151 | {:else} 152 |
159 | 160 |
161 | {/if} 162 | 163 |
164 | 165 | 166 |
167 | 168 |
169 |
170 | -------------------------------------------------------------------------------- /core/src/rack/Dialog.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 42 | 43 | 44 |
45 |
46 | 47 |
48 |
49 | -------------------------------------------------------------------------------- /core/src/rack/Help.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 75 | 76 | 77 | {#if visible} 78 |
79 | 88 |
    89 |
  • 90 | Open/Close module library 91 |
    92 | SPACE 93 |
  • 94 |
  • 95 | Remove a module (click it first) 96 |
    97 | BACKSPACE 98 |
  • 99 |
  • 100 | Connect more than one patch cable 101 |
    102 | SHIFT+CLICK 103 |
  • 104 |
  • 105 | Duplicate a module 106 |
    107 | RIGHT CLICK 108 |
  • 109 |
  • 110 | Mute/Unmute all outputs 111 |
    112 | ENTER 113 |
  • 114 |
  • 115 | Open synth from a file 116 |
    117 | CTRL + O 118 |
  • 119 |
  • 120 | Save synth to a file 121 |
    122 | CTRL + S 123 |
  • 124 |
125 |
126 | {/if} 127 | -------------------------------------------------------------------------------- /core/src/rack/Loading.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 | 22 | 31 | 33 |
34 | -------------------------------------------------------------------------------- /core/src/rack/Menu.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 112 | 113 | 116 | -------------------------------------------------------------------------------- /core/src/rack/Preview.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 41 | 42 |
43 | {#if loaded} 44 | 45 | {:else} 46 | 47 | {/if} 48 |
49 | -------------------------------------------------------------------------------- /core/src/rack/Share.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 | 45 | 46 |

{title} ▾

47 | 48 |
49 | {#if loading} 50 | 51 | {/if} 52 | 53 | 54 | 55 |
56 | -------------------------------------------------------------------------------- /core/src/rack/Shelf.svelte: -------------------------------------------------------------------------------- 1 | 58 | 59 | 121 | 122 | 123 | {#if visible && library} 124 |
125 | 162 |
163 | {#each items as module (module.name)} 164 | onAdd(module)} 166 | src="/modules/{module.set}/{safeName(module.name)}.png" 167 | style="width: {module.size.w * HP.w}px; height: {module.size.h * HP.h}px;" 168 | /> 169 | {/each} 170 |
171 |
172 | {/if} 173 | -------------------------------------------------------------------------------- /core/src/rack/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Bar } from './Bar.svelte'; 2 | export { default as Cables } from './Cables.svelte'; 3 | export { default as Container } from './Container.svelte'; 4 | export { default as Shelf } from './Shelf.svelte'; 5 | export { default as Loading } from './Loading.svelte'; 6 | export { default as Menu } from './Menu.svelte'; 7 | export { default as Preview } from './Preview.svelte'; 8 | export { default as Share } from './Share.svelte'; 9 | export { default as Help } from './Help.svelte'; 10 | -------------------------------------------------------------------------------- /core/src/state/helpers.ts: -------------------------------------------------------------------------------- 1 | import modules from './modules'; 2 | import patches from './patches'; 3 | import type { Rack, Library } from '../types'; 4 | import { safeName } from '../helpers'; 5 | 6 | const stateImport = (state: Rack, library: Library): void => { 7 | const $modules = state.modules 8 | .map((module) => { 9 | if (!module.type) { 10 | module.type = module.id.substr(0, module.id.lastIndexOf('-')); 11 | } 12 | 13 | const $moduleLib = library.find(({ set, name }) => `${set}/${safeName(name)}` === module.type); 14 | 15 | if (!$moduleLib) { 16 | return undefined; 17 | } else { 18 | module.size = $moduleLib.size; 19 | module.libs = $moduleLib.libs; 20 | } 21 | 22 | return module; 23 | }) 24 | .filter(Boolean); 25 | 26 | const $patches = state.patches 27 | .map(($patch) => { 28 | const exists = 29 | $modules.findIndex((item) => { 30 | return $patch.input.indexOf(`${item.id}://`) === 0 || $patch.output.indexOf(`${item.id}://`) === 0; 31 | }) > -1; 32 | 33 | return exists ? $patch : undefined; 34 | }) 35 | .filter(Boolean); 36 | 37 | modules.import($modules); 38 | patches.import($patches); 39 | }; 40 | 41 | const stateExport = (title: string): Rack => { 42 | const $patches = patches.export(); 43 | const $modules = modules.export(); 44 | 45 | return { title, modules: $modules, patches: $patches }; 46 | }; 47 | 48 | const stateReset = (): void => { 49 | patches.reset(); 50 | modules.reset(); 51 | }; 52 | 53 | export { stateExport, stateImport, stateReset }; 54 | -------------------------------------------------------------------------------- /core/src/state/libraries.ts: -------------------------------------------------------------------------------- 1 | import { get, writable } from 'svelte/store'; 2 | 3 | class Libraries { 4 | private libraries = writable<{ id: string; loaded: boolean; script: HTMLScriptElement }[]>([]); 5 | 6 | public async add(libList: string[]) { 7 | const $libs = get(this.libraries); 8 | 9 | return Promise.all( 10 | libList.map((lib) => { 11 | return new Promise((resolve) => { 12 | const exists = $libs.find(($lib) => $lib.id === lib); 13 | let $script = exists?.script; 14 | 15 | if (!$script) { 16 | $script = document.createElement('script'); 17 | $script.src = lib; 18 | 19 | this.libraries.update(($libs) => $libs.concat({ id: lib, loaded: false, script: $script })); 20 | 21 | document.body.appendChild($script); 22 | 23 | $script.addEventListener('load', () => { 24 | this.libraries.update(($libs) => 25 | $libs.map(($lib) => ($lib.id === lib ? { ...$lib, loaded: true } : $lib)) 26 | ); 27 | }); 28 | } 29 | 30 | if (exists?.loaded) { 31 | resolve(null); 32 | } else { 33 | $script.addEventListener('load', resolve); 34 | } 35 | }); 36 | }) 37 | ); 38 | } 39 | } 40 | 41 | const libraries = new Libraries(); 42 | 43 | export default libraries; 44 | -------------------------------------------------------------------------------- /core/src/state/modules.ts: -------------------------------------------------------------------------------- 1 | import { HP } from 'contstants'; 2 | import { get, writable } from 'svelte/store'; 3 | import type { Module, State, ModuleState } from '../types'; 4 | 5 | class Modules { 6 | private modules = writable([]); 7 | private moduleStates = writable([]); 8 | private updateCallbacks: (() => void)[] = []; 9 | 10 | constructor(modules: Module[] = [], states: ModuleState[] = []) { 11 | this.modules.set(modules); 12 | this.moduleStates.set(states); 13 | } 14 | 15 | get store() { 16 | return this.modules; 17 | } 18 | 19 | get state() { 20 | return get(this.modules); 21 | } 22 | 23 | public add(module: Module) { 24 | const position = this.getEmptySpace(module.size); 25 | module.position = position; 26 | 27 | if (!module.id) { 28 | const id = Math.random().toString(36).substr(2, 9); 29 | module.id = `${module.type}-${id}`; 30 | } 31 | 32 | this.moduleStates.update(($states) => { 33 | return $states.concat([{ id: module.id, state: module.state, position }]); 34 | }); 35 | 36 | this.modules.update(($modules) => { 37 | return $modules.concat([module]); 38 | }); 39 | 40 | return module.id; 41 | } 42 | 43 | public update(id: string, state: State) { 44 | this.moduleStates.update(($states) => { 45 | return $states.map(($state) => ($state.id === id ? { id, state, position: $state.position } : $state)); 46 | }); 47 | } 48 | 49 | public remove(id: string) { 50 | this.modules.update(($modules) => { 51 | return $modules.filter((module) => module.id !== id); 52 | }); 53 | 54 | this.moduleStates.update(($state) => { 55 | return $state.filter((state) => state.id !== id); 56 | }); 57 | } 58 | 59 | public move(module: Module, x: number, y: number): boolean { 60 | const states = get(this.moduleStates); 61 | 62 | for (let i = 0; i < states.length; i++) { 63 | const state = states[i]; 64 | 65 | if (state.id === module.id) { 66 | continue; 67 | } 68 | 69 | const target = this.state.find((item) => item.id === state.id); 70 | 71 | if ( 72 | !( 73 | y + module.size.h * HP.h <= state.position.y || 74 | y >= state.position.y + target.size.h * HP.h || 75 | x + module.size.w * HP.w <= state.position.x || 76 | x >= state.position.x + target.size.w * HP.w 77 | ) 78 | ) { 79 | return false; 80 | } 81 | } 82 | 83 | this.moduleStates.update(($states) => 84 | $states.map(($state) => 85 | $state.id === module.id 86 | ? { 87 | ...$state, 88 | position: { x, y }, 89 | } 90 | : $state 91 | ) 92 | ); 93 | 94 | return true; 95 | } 96 | 97 | public import($modules: Module[]) { 98 | this.moduleStates.update(() => { 99 | const states = $modules.map((module) => ({ 100 | id: module.id, 101 | state: module.state, 102 | position: module.position, 103 | })); 104 | 105 | return states; 106 | }); 107 | 108 | this.modules.set($modules); 109 | } 110 | 111 | public export() { 112 | const states = get(this.moduleStates); 113 | 114 | const modules = this.state.map(($module) => { 115 | const module = { ...$module }; 116 | const { state, position } = states.find(($state) => $state.id === module.id); 117 | module.state = state; 118 | module.position = position; 119 | 120 | delete module.libs; 121 | delete module.size; 122 | delete module.type; 123 | 124 | return module; 125 | }); 126 | 127 | return modules; 128 | } 129 | 130 | public onAfterUpdate(callback: () => void) { 131 | this.updateCallbacks.push(callback); 132 | } 133 | 134 | public afterUpdate() { 135 | this.updateCallbacks.forEach((callback) => callback()); 136 | } 137 | 138 | private getEmptySpace(size: { w: number; h: number }): { x: number; y: number } { 139 | const moduleList = this.state; 140 | let x = 0; 141 | let y = 0; 142 | let empty = moduleList.length === 0; 143 | 144 | const scrollX = document.documentElement.scrollLeft || document.body.scrollLeft; 145 | const scrollY = document.documentElement.scrollTop || document.body.scrollTop; 146 | 147 | while (!empty) { 148 | let hit = false; 149 | for (let i = 0; i < moduleList.length; i++) { 150 | const targetBox = document.getElementById(moduleList[i].id).getBoundingClientRect(); 151 | if ( 152 | !( 153 | y + HP.h * size.h - 48 <= targetBox.y + scrollY || 154 | y >= targetBox.bottom - 48 + scrollY || 155 | x + HP.w * size.w <= targetBox.x + scrollX || 156 | x >= targetBox.right + scrollX 157 | ) 158 | ) { 159 | hit = true; 160 | break; 161 | } 162 | } 163 | 164 | if (!hit) { 165 | empty = true; 166 | } else { 167 | if (x > window.innerWidth) { 168 | y += 380; 169 | x = 0; 170 | } else { 171 | x += 16; 172 | } 173 | } 174 | } 175 | 176 | return { x, y }; 177 | } 178 | 179 | public reset() { 180 | this.modules.set([]); 181 | this.moduleStates.set([]); 182 | } 183 | } 184 | 185 | const modules = new Modules(); 186 | 187 | export default modules; 188 | -------------------------------------------------------------------------------- /core/src/state/patches.ts: -------------------------------------------------------------------------------- 1 | import { get, writable } from 'svelte/store'; 2 | import { randomColor } from '../helpers'; 3 | import type { Patch } from '../types'; 4 | 5 | class Patches { 6 | private patches = writable([]); 7 | 8 | get store() { 9 | return this.patches; 10 | } 11 | 12 | get state() { 13 | return get(this.patches); 14 | } 15 | 16 | public add(patch: Patch) { 17 | patch.color = randomColor(); 18 | 19 | this.patches.update(($patches) => { 20 | return [...$patches, patch]; 21 | }); 22 | } 23 | 24 | public remove(output: string, input?: string) { 25 | this.patches.update(($patches) => { 26 | return $patches.filter(($patch) => { 27 | if (!input) { 28 | return $patch.input?.indexOf(output) !== 0 && $patch.output?.indexOf(output) !== 0; 29 | } 30 | return !($patch.input === input && $patch.output === output); 31 | }); 32 | }); 33 | } 34 | 35 | public update(output: string, input: string, update: Partial) { 36 | this.patches.update(($patches) => { 37 | return $patches.map(($patch) => 38 | $patch.input === input && $patch.output === output 39 | ? { 40 | ...$patch, 41 | ...update, 42 | } 43 | : $patch 44 | ); 45 | }); 46 | } 47 | 48 | public import($patches: Patch[]) { 49 | const state = this.state; 50 | 51 | this.patches.set( 52 | $patches.map(($patch) => { 53 | const $exists = state.find((item) => item.input === $patch.input && item.output === $patch.output); 54 | 55 | if ($exists) { 56 | return $exists; 57 | } 58 | 59 | if (!$patch.color) { 60 | $patch.color = randomColor(); 61 | } 62 | return $patch; 63 | }) 64 | ); 65 | } 66 | 67 | public export() { 68 | const patches = this.state.map((patch: Patch) => { 69 | const $patch = { ...patch }; 70 | delete $patch.node; 71 | delete $patch.selected; 72 | delete $patch.color; 73 | return $patch; 74 | }); 75 | 76 | return patches; 77 | } 78 | 79 | public reset() { 80 | this.patches.set([]); 81 | } 82 | } 83 | 84 | const patches = new Patches(); 85 | 86 | export default patches; 87 | -------------------------------------------------------------------------------- /core/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ToneAudioNode } from 'tone'; 2 | import type Bang from './nodes/Bang'; 3 | export type { default as Bang } from './nodes/Bang'; 4 | 5 | /** 6 | * Library 7 | */ 8 | export type LibraryItem = { 9 | set: string; 10 | name: string; 11 | tags: string[]; 12 | size: { w: number; h: number }; 13 | author: { name: string; url?: string }; 14 | libs: string[]; 15 | }; 16 | 17 | export type Library = LibraryItem[]; 18 | 19 | /** 20 | * Modules 21 | */ 22 | export type Module = { 23 | id?: string; 24 | type: string; 25 | position?: { 26 | x: number; 27 | y: number; 28 | }; 29 | size: { 30 | w: number; 31 | h: number; 32 | }; 33 | libs: string[]; 34 | state?: State; 35 | }; 36 | 37 | export type State = { 38 | [key: string]: number | string | Array; 39 | }; 40 | 41 | export type ModuleState = { 42 | id?: string; 43 | state: State; 44 | position: { 45 | x: number; 46 | y: number; 47 | }; 48 | }; 49 | /** 50 | * Patch 51 | */ 52 | export type PatchInput = Bang | ToneAudioNode; 53 | export type PatchOutput = Bang | ToneAudioNode; 54 | 55 | export type Patch = { 56 | input: string; 57 | output: string; 58 | node: PatchInput; 59 | color?: string; 60 | selected?: string; 61 | }; 62 | 63 | /** 64 | * Action 65 | */ 66 | interface ActionResult

{ 67 | update?: (parameters?: P) => void; 68 | destroy?: () => void; 69 | } 70 | 71 | export type Action

= (node: Element, parameters?: P) => ActionResult

; 72 | 73 | /** 74 | * State 75 | */ 76 | 77 | export type Rack = { 78 | title?: string; 79 | modules: Module[]; 80 | patches: Patch[]; 81 | }; 82 | 83 | /** 84 | * App view 85 | */ 86 | 87 | export type View = 'shelf' | 'rack' | 'help'; 88 | 89 | /** 90 | * Connection 91 | */ 92 | 93 | export type Connection = { 94 | id: string; 95 | node: PatchInput | PatchOutput; 96 | }; 97 | 98 | /** 99 | * User 100 | */ 101 | 102 | export type User = { 103 | username: string; 104 | }; 105 | -------------------------------------------------------------------------------- /core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "lib": ["es6", "dom", "es2019"], 5 | "module": "es2015", 6 | "target": "es2019", 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "outDir": "./public/js", 10 | "importsNotUsedAsValues": "error", 11 | "isolatedModules": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "baseUrl": "src", 14 | "paths": { 15 | "@patchcab/core": ["./"] 16 | } 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /modules/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | modules/*.js 3 | public 4 | .env -------------------------------------------------------------------------------- /modules/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "trailingComma": "all", 9 | "svelteSortOrder": "scripts-styles-markup" 10 | } 11 | -------------------------------------------------------------------------------- /modules/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Spectrome 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /modules/README.md: -------------------------------------------------------------------------------- 1 | # Patchcab modules 2 | 3 | A collection of default basic [Patchcab](https://github.com/spectrome/patchcab) Eurorack style modules made with Web Audio, [Tone.js Web Audio framework](https://github.com/Tonejs/Tone.js/) and [Svelte Javascript framework](https://github.com/sveltejs/svelte). 4 | 5 | ## Install 6 | 7 | Install the modules to your Patchcab setup by running: 8 | 9 | ```bash 10 | npm install @patchcab/modules 11 | ``` 12 | 13 | ## Modules 14 | 15 | | | Module | | 16 | | :-----------------------------------------------------------------------------------------------------------------------------------: | :-------: | ------------------------------------------------------------------------------------------------------------------------------------------- | 17 | | ADSR | **ADSR** | An [ADSR]() (Attack, Decay, Sustain, Release) envelope generator. | 18 | | Clock | **Clock** | A simple clock which provides a callback at the given rate. | 19 | | FM | **FM** | FM module composed of two oscillators where one oscillator modulates the frequency of a second one. | 20 | | LFO | **LFO** | A [low frequency oscillator](https://en.wikipedia.org/wiki/Low-frequency_oscillation) module. | 21 | | MIDI | **MIDI** | **work in progress** module 😬 Currently sends only a trigger signal on keyboard letter Q-M key presses. | 22 | | NOISE | **NOISE** | A [noise generator](https://en.wikipedia.org/wiki/Noise_generator) module that produces pink, white or brown noise types. | 23 | | Notes | **Notes** | Simple utility module to write down your synth usage instructions or other random stuff. | 24 | | OSC | **OSC** | Simple [oscillator](https://en.wikipedia.org/wiki/Electronic_oscillator) module producing sine, square, triangle or sawtooth signal. | 25 | | OUT | **OUT** | Master ouput module connecting your synth output to your computers audio output. | 26 | | REVRB | **REVRB** | Simple reverb effect module. The response generation is asynchronous, so you have to wait until ready resolves before it will make a sound. | 27 | | SCOPE | **SCOPE** | Utility module to visualize the waveform of the audio input. | 28 | | SEQ | **SEQ** | A simple 16 step gate [sequencer](https://en.wikipedia.org/wiki/Music_sequencer). | 29 | | VCF | **VCF** | A highpass or lowpass filter to cutoff audio input frequency. | 30 | | VOL | **VOL** | Module to adjust the audio signal volume. | 31 | -------------------------------------------------------------------------------- /modules/modules/adsr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/adsr.png -------------------------------------------------------------------------------- /modules/modules/clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/clock.png -------------------------------------------------------------------------------- /modules/modules/fm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/fm.png -------------------------------------------------------------------------------- /modules/modules/lfo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/lfo.png -------------------------------------------------------------------------------- /modules/modules/midi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/midi.png -------------------------------------------------------------------------------- /modules/modules/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/noise.png -------------------------------------------------------------------------------- /modules/modules/notes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/notes.png -------------------------------------------------------------------------------- /modules/modules/osc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/osc.png -------------------------------------------------------------------------------- /modules/modules/out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/out.png -------------------------------------------------------------------------------- /modules/modules/revrb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/revrb.png -------------------------------------------------------------------------------- /modules/modules/scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/scope.png -------------------------------------------------------------------------------- /modules/modules/seq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/seq.png -------------------------------------------------------------------------------- /modules/modules/vcf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/vcf.png -------------------------------------------------------------------------------- /modules/modules/vol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spectrome/patchcab/0fdb815e35542f798e8b74499ac125a11ea65e6a/modules/modules/vol.png -------------------------------------------------------------------------------- /modules/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@patchcab/modules", 3 | "version": "1.1.3", 4 | "license": "MIT", 5 | "author": "Spectrome ", 6 | "files": [ 7 | "modules" 8 | ], 9 | "scripts": { 10 | "start": "patchcab", 11 | "build": "patchcab --build", 12 | "prepublish": "yarn build" 13 | }, 14 | "patchcab": [ 15 | { 16 | "name": "ADSR", 17 | "file": "ADSR", 18 | "size": { 19 | "w": 6, 20 | "h": 1 21 | }, 22 | "tags": [ 23 | "envelope" 24 | ], 25 | "libs": [ 26 | "Tone" 27 | ] 28 | }, 29 | { 30 | "name": "Clock", 31 | "file": "Clock", 32 | "size": { 33 | "w": 6, 34 | "h": 1 35 | }, 36 | "tags": [ 37 | "clock" 38 | ], 39 | "libs": [ 40 | "Tone" 41 | ] 42 | }, 43 | { 44 | "name": "LFO", 45 | "file": "LFO", 46 | "size": { 47 | "w": 6, 48 | "h": 1 49 | }, 50 | "tags": [ 51 | "oscillator" 52 | ], 53 | "libs": [ 54 | "Tone" 55 | ] 56 | }, 57 | { 58 | "name": "MIDI", 59 | "file": "MIDI", 60 | "size": { 61 | "w": 6, 62 | "h": 1 63 | }, 64 | "tags": [ 65 | "midi" 66 | ], 67 | "libs": [ 68 | "Tone" 69 | ] 70 | }, 71 | { 72 | "name": "Notes", 73 | "file": "Notes", 74 | "size": { 75 | "w": 12, 76 | "h": 1 77 | }, 78 | "tags": [ 79 | "utility" 80 | ], 81 | "libs": [ 82 | "Tone" 83 | ] 84 | }, 85 | { 86 | "name": "OUT", 87 | "file": "OUT", 88 | "size": { 89 | "w": 6, 90 | "h": 1 91 | }, 92 | "tags": [ 93 | "mixer" 94 | ], 95 | "libs": [ 96 | "Tone" 97 | ] 98 | }, 99 | { 100 | "name": "VOL", 101 | "file": "VOL", 102 | "size": { 103 | "w": 6, 104 | "h": 1 105 | }, 106 | "tags": [ 107 | "mixer" 108 | ], 109 | "libs": [ 110 | "Tone" 111 | ] 112 | }, 113 | { 114 | "name": "SCOPE", 115 | "file": "SCOPE", 116 | "size": { 117 | "w": 12, 118 | "h": 1 119 | }, 120 | "tags": [ 121 | "utility" 122 | ], 123 | "libs": [ 124 | "Tone" 125 | ] 126 | }, 127 | { 128 | "name": "VCF", 129 | "file": "VCF", 130 | "size": { 131 | "w": 6, 132 | "h": 1 133 | }, 134 | "tags": [ 135 | "filter" 136 | ], 137 | "libs": [ 138 | "Tone" 139 | ] 140 | }, 141 | { 142 | "name": "OSC", 143 | "file": "OSC", 144 | "size": { 145 | "w": 6, 146 | "h": 1 147 | }, 148 | "tags": [ 149 | "oscillator" 150 | ], 151 | "libs": [ 152 | "Tone" 153 | ] 154 | }, 155 | { 156 | "name": "FM", 157 | "file": "FM", 158 | "size": { 159 | "w": 6, 160 | "h": 1 161 | }, 162 | "tags": [ 163 | "oscillator" 164 | ], 165 | "libs": [ 166 | "Tone" 167 | ] 168 | }, 169 | { 170 | "name": "SEQ", 171 | "file": "SEQ", 172 | "size": { 173 | "w": 32, 174 | "h": 1 175 | }, 176 | "tags": [ 177 | "utility" 178 | ], 179 | "libs": [ 180 | "Tone" 181 | ] 182 | }, 183 | { 184 | "name": "NOISE", 185 | "file": "NOISE", 186 | "size": { 187 | "w": 6, 188 | "h": 1 189 | }, 190 | "tags": [ 191 | "noise" 192 | ], 193 | "libs": [ 194 | "Tone" 195 | ] 196 | }, 197 | { 198 | "name": "REVRB", 199 | "file": "REVRB", 200 | "size": { 201 | "w": 6, 202 | "h": 1 203 | }, 204 | "tags": [ 205 | "effect" 206 | ], 207 | "libs": [ 208 | "Tone" 209 | ] 210 | } 211 | ], 212 | "prettier": { 213 | "useTabs": false, 214 | "arrowParens": "always", 215 | "semi": true, 216 | "bracketSpacing": true, 217 | "singleQuote": true, 218 | "printWidth": 120, 219 | "svelteSortOrder": "options-scripts-styles-markup" 220 | }, 221 | "devDependencies": { 222 | "@patchcab/core": "1.1.3" 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /modules/src/ADSR.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /modules/src/Clock.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /modules/src/FM.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /modules/src/LFO.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /modules/src/MIDI.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /modules/src/NOISE.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /modules/src/Notes.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 24 | 25 |