├── .editorconfig ├── .env ├── .eslintrc ├── .github └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── package.json ├── public ├── favicon.ico ├── icons8-tuning-fork-64.png ├── index.html ├── manifest.json ├── robots.txt └── samples │ ├── HiHat.json │ ├── Kick.json │ └── Synth.json ├── src ├── App.tsx ├── audio.d.ts ├── components │ ├── Analyser2D │ │ ├── Analysers2D.tsx │ │ └── index.ts │ ├── AudioGraph │ │ ├── AudioGraph.tsx │ │ ├── GraphMenu.tsx │ │ ├── elems │ │ │ ├── AudioParamDefaults.tsx │ │ │ ├── AudioParamForm.tsx │ │ │ ├── AudioParams.tsx │ │ │ ├── AudioParamsView.tsx │ │ │ ├── HandleInputs.tsx │ │ │ ├── HandleOutputs.tsx │ │ │ └── styled.tsx │ │ ├── index.ts │ │ ├── nodes │ │ │ ├── Analyser.tsx │ │ │ ├── BiquadFilter.tsx │ │ │ ├── Gain.tsx │ │ │ └── Oscillator.tsx │ │ └── styled.tsx │ ├── FileSys │ │ ├── FileSys.tsx │ │ └── index.ts │ ├── Menu │ │ ├── Menu.tsx │ │ └── MenuOpener.tsx │ ├── Piano │ │ ├── Piano.tsx │ │ └── index.ts │ ├── SelectSound │ │ ├── SelectSound.tsx │ │ └── index.ts │ ├── Sequencer │ │ ├── Bar.tsx │ │ ├── BarSettings.tsx │ │ ├── PlaybackControls.tsx │ │ ├── Sequencer.tsx │ │ ├── Step.tsx │ │ └── index.ts │ └── misc │ │ ├── Brand.tsx │ │ ├── LocalSoundSelect.tsx │ │ └── Widget.tsx ├── features │ ├── activeSound │ │ └── activeSoundSlice.ts │ ├── sounds │ │ └── soundsSlice.ts │ └── ux │ │ └── uxSlice.ts ├── hooks │ ├── useAudioNodeDefs.ts │ ├── useAudioParamKeys.ts │ └── useTimer.ts ├── index.tsx ├── react-app-env.d.ts ├── scripts │ ├── Sound.ts │ ├── audio.ts │ ├── helpers.ts │ ├── setupAudioMiddleware.ts │ ├── utils.ts │ └── wav.ts ├── serviceWorker.ts ├── setupTests.ts ├── store.ts └── styled.tsx ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | EXTEND_ESLINT= 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "eslintConfig": { 3 | "extends": ["react-app", "shared-config"], 4 | "rules": { 5 | "additional-rule": "warn" 6 | }, 7 | "overrides": [ 8 | { 9 | "files": ["**/*.ts?(x)"], 10 | "rules": { 11 | "additional-typescript-only-rule": "warn" 12 | } 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to gh-pages 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [12.x] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: Install Packages 19 | run: | 20 | npm config set "@fortawesome:registry" https://npm.fontawesome.com/ && npm config set '//npm.fontawesome.com/:_authToken' "${{ secrets.FONTAWESOME_NPM_AUTH_TOKEN }}" 21 | npm install 22 | - name: Build page 23 | run: npm run build 24 | - name: Deploy to gh-pages 25 | uses: peaceiris/actions-gh-pages@v3 26 | with: 27 | github_token: ${{ secrets.GITHUB_TOKEN }} 28 | publish_dir: ./build 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: push 3 | jobs: 4 | setup-and-test: 5 | name: Setup & List TS 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Use Node.js 12 10 | uses: actions/setup-node@v1 11 | with: 12 | node-version: 12 13 | - name: Install dependencies 14 | run: | 15 | npm config set "@fortawesome:registry" https://npm.fontawesome.com/ && npm config set '//npm.fontawesome.com/:_authToken' "${{ secrets.FONTAWESOME_NPM_AUTH_TOKEN }}" 16 | npm install 17 | - name: Check TypeScript 18 | run: ./node_modules/.bin/tsc 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .npmrc 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "arrowParens": "avoid", 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Chrome", 6 | "type": "chrome", 7 | "request": "launch", 8 | "url": "http://localhost:3000", 9 | "webRoot": "${workspaceFolder}/src", 10 | "sourceMapPathOverrides": { 11 | "webpack:///src/*": "${webRoot}/*" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WAAπSynth :musical_keyboard: _Web Audio Api Synthesizer_ 2 | 3 | > Oscillator for the rule. 4 | 5 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e1c91465b17f43b6abbe2af98fe0ea13)](https://www.codacy.com/manual/SubZtep/synth?utm_source=github.com&utm_medium=referral&utm_content=SubZtep/synth&utm_campaign=Badge_Grade) 6 | 7 | ## Materials 8 | 9 | * [Colour Palette](https://coolors.co/11151c-212d40-364156-7d4e57-d66853) 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synth", 3 | "version": "0.2.4", 4 | "private": true, 5 | "homepage": "https://subztep.github.io/synth/", 6 | "dependencies": { 7 | "@emotion/core": "^10.0.35", 8 | "@emotion/styled": "^10.0.27", 9 | "@fortawesome/fontawesome-svg-core": "^1.2.30", 10 | "@fortawesome/free-brands-svg-icons": "^5.14.0", 11 | "@fortawesome/pro-duotone-svg-icons": "^5.14.0", 12 | "@fortawesome/pro-light-svg-icons": "^5.14.0", 13 | "@fortawesome/pro-regular-svg-icons": "^5.14.0", 14 | "@fortawesome/pro-solid-svg-icons": "^5.14.0", 15 | "@fortawesome/react-fontawesome": "^0.1.11", 16 | "@reduxjs/toolkit": "^1.4.0", 17 | "react": "^16.13.1", 18 | "react-dom": "^16.13.1", 19 | "react-flow-renderer": "^5.4.1", 20 | "react-hotkeys": "^2.0.0", 21 | "react-redux": "^7.2.1", 22 | "react-scripts": "3.4.3", 23 | "react-toastify": "^6.0.8", 24 | "uuid": "^8.3.0" 25 | }, 26 | "devDependencies": { 27 | "@testing-library/jest-dom": "^5.11.3", 28 | "@testing-library/react": "^10.4.8", 29 | "@testing-library/user-event": "^12.1.1", 30 | "@types/jest": "^26.0.10", 31 | "@types/node": "^14.6.0", 32 | "@types/react": "^16.9.46", 33 | "@types/react-dom": "^16.9.8", 34 | "@types/react-redux": "^7.1.9", 35 | "@types/uuid": "^8.3.0", 36 | "cross-env": "^7.0.2", 37 | "husky": "^4.2.5", 38 | "lint-staged": "^10.2.11", 39 | "prettier": "^2.0.5", 40 | "typescript": "~3.9.7" 41 | }, 42 | "scripts": { 43 | "start": "cross-env REACT_APP_VERSION=$npm_package_version PORT=3333 BROWSER=none react-scripts start", 44 | "build": "cross-env REACT_APP_VERSION=$npm_package_version GENERATE_SOURCEMAP=false react-scripts build", 45 | "test": "react-scripts test", 46 | "eject": "react-scripts eject" 47 | }, 48 | "browserslist": { 49 | "production": [ 50 | ">0.2%", 51 | "not dead", 52 | "not op_mini all" 53 | ], 54 | "development": [ 55 | "last 1 chrome version", 56 | "last 1 firefox version", 57 | "last 1 safari version" 58 | ] 59 | }, 60 | "lint-staged": { 61 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 62 | "prettier --write" 63 | ] 64 | }, 65 | "husky": { 66 | "hooks": { 67 | "pre-commit": "lint-staged" 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SubZtep/synth/5bdb52332bae00fd366df6c11a0594af01b690e0/public/favicon.ico -------------------------------------------------------------------------------- /public/icons8-tuning-fork-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SubZtep/synth/5bdb52332bae00fd366df6c11a0594af01b690e0/public/icons8-tuning-fork-64.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | WAAπSynth 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Synth", 3 | "name": "WAAπSynth", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "icons8-tuning-fork-64.png", 12 | "type": "image/png", 13 | "sizes": "64x64" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#364156", 19 | "background_color": "#c0c0c0" 20 | } 21 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/samples/HiHat.json: -------------------------------------------------------------------------------- 1 | { 2 | "destination": { 3 | "id": "destination", 4 | "connectIds": [], 5 | "position": { 6 | "x": 1104, 7 | "y": 336 8 | } 9 | }, 10 | "analysers": [], 11 | "gains": [ 12 | { 13 | "id": "10", 14 | "connectIds": ["destination"], 15 | "params": [ 16 | { 17 | "name": "gain", 18 | "call": "setValueAtTime", 19 | "values": [0.00001, 0] 20 | }, 21 | { 22 | "name": "gain", 23 | "call": "exponentialRampToValueAtTime", 24 | "values": [1, 0.02] 25 | }, 26 | { 27 | "name": "gain", 28 | "call": "exponentialRampToValueAtTime", 29 | "values": [0.3, 0.03] 30 | }, 31 | { 32 | "name": "gain", 33 | "call": "exponentialRampToValueAtTime", 34 | "values": [0.00001, 0.3] 35 | } 36 | ], 37 | "position": { 38 | "x": 736, 39 | "y": 272 40 | } 41 | } 42 | ], 43 | "biquadFilters": [ 44 | { 45 | "id": "2", 46 | "connectIds": ["3"], 47 | "type": "bandpass", 48 | "params": [ 49 | { 50 | "name": "frequency", 51 | "call": "setValueAtTime", 52 | "values": [10000, 0] 53 | } 54 | ], 55 | "position": { 56 | "x": 160, 57 | "y": 288 58 | } 59 | }, 60 | { 61 | "id": "3", 62 | "connectIds": ["10"], 63 | "type": "highpass", 64 | "params": [ 65 | { 66 | "name": "frequency", 67 | "call": "setValueAtTime", 68 | "values": [7000, 0] 69 | } 70 | ], 71 | "position": { 72 | "x": 448, 73 | "y": 288 74 | } 75 | } 76 | ], 77 | "oscillators": [ 78 | { 79 | "id": "4", 80 | "connectIds": ["2"], 81 | "type": "square", 82 | "params": [ 83 | { 84 | "name": "frequency", 85 | "call": "setValueAtTime", 86 | "values": [80, 0] 87 | }, 88 | { 89 | "name": "frequency", 90 | "call": "setValueAtTime", 91 | "values": [0, 0.2] 92 | } 93 | ], 94 | "position": { 95 | "x": -96, 96 | "y": -224 97 | } 98 | }, 99 | { 100 | "id": "5", 101 | "connectIds": ["2"], 102 | "type": "square", 103 | "params": [ 104 | { 105 | "name": "frequency", 106 | "call": "setValueAtTime", 107 | "values": [120, 0] 108 | }, 109 | { 110 | "name": "frequency", 111 | "call": "setValueAtTime", 112 | "values": [0, 0.2] 113 | } 114 | ], 115 | "position": { 116 | "x": 160, 117 | "y": -224 118 | } 119 | }, 120 | { 121 | "id": "6", 122 | "connectIds": ["2"], 123 | "type": "square", 124 | "params": [ 125 | { 126 | "name": "frequency", 127 | "call": "setValueAtTime", 128 | "values": [166.4, 0] 129 | }, 130 | { 131 | "name": "frequency", 132 | "call": "setValueAtTime", 133 | "values": [0, 0.2] 134 | } 135 | ], 136 | "position": { 137 | "x": 448, 138 | "y": -224 139 | } 140 | }, 141 | { 142 | "id": "7", 143 | "connectIds": ["2"], 144 | "type": "square", 145 | "params": [ 146 | { 147 | "name": "frequency", 148 | "call": "setValueAtTime", 149 | "values": [217.2, 0] 150 | }, 151 | { 152 | "name": "frequency", 153 | "call": "setValueAtTime", 154 | "values": [0, 0.2] 155 | } 156 | ], 157 | "position": { 158 | "x": 736, 159 | "y": -224 160 | } 161 | }, 162 | { 163 | "id": "8", 164 | "connectIds": ["2"], 165 | "type": "square", 166 | "params": [ 167 | { 168 | "name": "frequency", 169 | "call": "setValueAtTime", 170 | "values": [271.6, 0] 171 | }, 172 | { 173 | "name": "frequency", 174 | "call": "setValueAtTime", 175 | "values": [0, 0.2] 176 | } 177 | ], 178 | "position": { 179 | "x": 1024, 180 | "y": -224 181 | } 182 | }, 183 | { 184 | "id": "9", 185 | "connectIds": ["2"], 186 | "type": "square", 187 | "params": [ 188 | { 189 | "name": "frequency", 190 | "call": "setValueAtTime", 191 | "values": [328.4, 0] 192 | }, 193 | { 194 | "name": "frequency", 195 | "call": "setValueAtTime", 196 | "values": [0, 0.2] 197 | } 198 | ], 199 | "position": { 200 | "x": 1312, 201 | "y": -224 202 | } 203 | } 204 | ] 205 | } 206 | -------------------------------------------------------------------------------- /public/samples/Kick.json: -------------------------------------------------------------------------------- 1 | { 2 | "destination": { 3 | "id": "destination", 4 | "connectIds": [], 5 | "position": { 6 | "x": 704, 7 | "y": 544 8 | } 9 | }, 10 | "analysers": [ 11 | { 12 | "id": "3", 13 | "connectIds": [ 14 | "destination" 15 | ], 16 | "fftSize": 2048, 17 | "color": "#962813", 18 | "lineWidth": 4, 19 | "position": { 20 | "x": 896, 21 | "y": 368 22 | } 23 | } 24 | ], 25 | "gains": [ 26 | { 27 | "id": "2", 28 | "connectIds": [ 29 | "3" 30 | ], 31 | "params": [ 32 | { 33 | "name": "gain", 34 | "call": "setValueAtTime", 35 | "values": [ 36 | 1, 37 | 0 38 | ] 39 | }, 40 | { 41 | "name": "gain", 42 | "call": "exponentialRampToValueAtTime", 43 | "values": [ 44 | 0.01, 45 | 0.5 46 | ] 47 | } 48 | ], 49 | "position": { 50 | "x": 800, 51 | "y": -32 52 | } 53 | } 54 | ], 55 | "biquadFilters": [], 56 | "oscillators": [ 57 | { 58 | "id": "1", 59 | "connectIds": [ 60 | "2" 61 | ], 62 | "type": "sine", 63 | "params": [ 64 | { 65 | "name": "frequency", 66 | "call": "setValueAtTime", 67 | "values": [ 68 | 150, 69 | 0 70 | ] 71 | }, 72 | { 73 | "name": "frequency", 74 | "call": "exponentialRampToValueAtTime", 75 | "values": [ 76 | 0.01, 77 | 0.5 78 | ] 79 | }, 80 | { 81 | "name": "frequency", 82 | "call": "setValueAtTime", 83 | "values": [ 84 | 0, 85 | 0.5 86 | ] 87 | } 88 | ], 89 | "position": { 90 | "x": 384, 91 | "y": -32 92 | } 93 | } 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /public/samples/Synth.json: -------------------------------------------------------------------------------- 1 | { 2 | "HiHat": { 3 | "destination": { 4 | "id": "destination", 5 | "connectIds": [], 6 | "position": { 7 | "x": 1104, 8 | "y": 336 9 | } 10 | }, 11 | "analysers": [], 12 | "gains": [ 13 | { 14 | "id": "10", 15 | "connectIds": ["destination"], 16 | "params": [ 17 | { 18 | "name": "gain", 19 | "call": "setValueAtTime", 20 | "values": [0.00001, 0] 21 | }, 22 | { 23 | "name": "gain", 24 | "call": "exponentialRampToValueAtTime", 25 | "values": [1, 0.02] 26 | }, 27 | { 28 | "name": "gain", 29 | "call": "exponentialRampToValueAtTime", 30 | "values": [0.3, 0.03] 31 | }, 32 | { 33 | "name": "gain", 34 | "call": "exponentialRampToValueAtTime", 35 | "values": [0.00001, 0.3] 36 | } 37 | ], 38 | "position": { 39 | "x": 736, 40 | "y": 272 41 | } 42 | } 43 | ], 44 | "biquadFilters": [ 45 | { 46 | "id": "2", 47 | "connectIds": ["3"], 48 | "type": "bandpass", 49 | "params": [ 50 | { 51 | "name": "frequency", 52 | "call": "setValueAtTime", 53 | "values": [10000, 0] 54 | } 55 | ], 56 | "position": { 57 | "x": 160, 58 | "y": 288 59 | } 60 | }, 61 | { 62 | "id": "3", 63 | "connectIds": ["10"], 64 | "type": "highpass", 65 | "params": [ 66 | { 67 | "name": "frequency", 68 | "call": "setValueAtTime", 69 | "values": [7000, 0] 70 | } 71 | ], 72 | "position": { 73 | "x": 448, 74 | "y": 288 75 | } 76 | } 77 | ], 78 | "oscillators": [ 79 | { 80 | "id": "4", 81 | "connectIds": ["2"], 82 | "type": "square", 83 | "params": [ 84 | { 85 | "name": "frequency", 86 | "call": "setValueAtTime", 87 | "values": [80, 0] 88 | }, 89 | { 90 | "name": "frequency", 91 | "call": "setValueAtTime", 92 | "values": [0, 0.2] 93 | } 94 | ], 95 | "position": { 96 | "x": -96, 97 | "y": -224 98 | } 99 | }, 100 | { 101 | "id": "5", 102 | "connectIds": ["2"], 103 | "type": "square", 104 | "params": [ 105 | { 106 | "name": "frequency", 107 | "call": "setValueAtTime", 108 | "values": [120, 0] 109 | }, 110 | { 111 | "name": "frequency", 112 | "call": "setValueAtTime", 113 | "values": [0, 0.2] 114 | } 115 | ], 116 | "position": { 117 | "x": 160, 118 | "y": -224 119 | } 120 | }, 121 | { 122 | "id": "6", 123 | "connectIds": ["2"], 124 | "type": "square", 125 | "params": [ 126 | { 127 | "name": "frequency", 128 | "call": "setValueAtTime", 129 | "values": [166.4, 0] 130 | }, 131 | { 132 | "name": "frequency", 133 | "call": "setValueAtTime", 134 | "values": [0, 0.2] 135 | } 136 | ], 137 | "position": { 138 | "x": 448, 139 | "y": -224 140 | } 141 | }, 142 | { 143 | "id": "7", 144 | "connectIds": ["2"], 145 | "type": "square", 146 | "params": [ 147 | { 148 | "name": "frequency", 149 | "call": "setValueAtTime", 150 | "values": [217.2, 0] 151 | }, 152 | { 153 | "name": "frequency", 154 | "call": "setValueAtTime", 155 | "values": [0, 0.2] 156 | } 157 | ], 158 | "position": { 159 | "x": 736, 160 | "y": -224 161 | } 162 | }, 163 | { 164 | "id": "8", 165 | "connectIds": ["2"], 166 | "type": "square", 167 | "params": [ 168 | { 169 | "name": "frequency", 170 | "call": "setValueAtTime", 171 | "values": [271.6, 0] 172 | }, 173 | { 174 | "name": "frequency", 175 | "call": "setValueAtTime", 176 | "values": [0, 0.2] 177 | } 178 | ], 179 | "position": { 180 | "x": 1024, 181 | "y": -224 182 | } 183 | }, 184 | { 185 | "id": "9", 186 | "connectIds": ["2"], 187 | "type": "square", 188 | "params": [ 189 | { 190 | "name": "frequency", 191 | "call": "setValueAtTime", 192 | "values": [328.4, 0] 193 | }, 194 | { 195 | "name": "frequency", 196 | "call": "setValueAtTime", 197 | "values": [0, 0.2] 198 | } 199 | ], 200 | "position": { 201 | "x": 1312, 202 | "y": -224 203 | } 204 | } 205 | ] 206 | }, 207 | "sequencer": { 208 | "BPM": 140, 209 | "notesPerBeat": 4, 210 | "beatsPerBar": 4, 211 | "bars": { 212 | "350a2688-a2ed-45a1-a77b-e767b17f5f02": { 213 | "soundName": "Kick", 214 | "steps": [ 215 | 440, 216 | null, 217 | null, 218 | null, 219 | 440, 220 | null, 221 | null, 222 | null, 223 | 440, 224 | null, 225 | null, 226 | null, 227 | 440, 228 | null, 229 | 440, 230 | null 231 | ] 232 | }, 233 | "fd882def-f9e9-49f3-8bc6-6251acb69682": { 234 | "soundName": "HiHat", 235 | "steps": [ 236 | null, 237 | null, 238 | 440, 239 | 440, 240 | null, 241 | null, 242 | 440, 243 | 440, 244 | null, 245 | null, 246 | 440, 247 | 440, 248 | null, 249 | null, 250 | null, 251 | 440 252 | ] 253 | } 254 | } 255 | }, 256 | "Kick": { 257 | "destination": { 258 | "id": "destination", 259 | "connectIds": [], 260 | "position": { 261 | "x": 704, 262 | "y": 544 263 | } 264 | }, 265 | "analysers": [ 266 | { 267 | "id": "3", 268 | "connectIds": ["destination"], 269 | "fftSize": 2048, 270 | "color": "#962813", 271 | "lineWidth": 4, 272 | "position": { 273 | "x": 896, 274 | "y": 368 275 | } 276 | } 277 | ], 278 | "gains": [ 279 | { 280 | "id": "2", 281 | "connectIds": ["3"], 282 | "params": [ 283 | { 284 | "name": "gain", 285 | "call": "setValueAtTime", 286 | "values": [1, 0] 287 | }, 288 | { 289 | "name": "gain", 290 | "call": "exponentialRampToValueAtTime", 291 | "values": [0.01, 0.5] 292 | } 293 | ], 294 | "position": { 295 | "x": 800, 296 | "y": -32 297 | } 298 | } 299 | ], 300 | "biquadFilters": [], 301 | "oscillators": [ 302 | { 303 | "id": "1", 304 | "connectIds": ["2"], 305 | "type": "sine", 306 | "params": [ 307 | { 308 | "name": "frequency", 309 | "call": "setValueAtTime", 310 | "values": [150, 0] 311 | }, 312 | { 313 | "name": "frequency", 314 | "call": "exponentialRampToValueAtTime", 315 | "values": [0.01, 0.5] 316 | }, 317 | { 318 | "name": "frequency", 319 | "call": "setValueAtTime", 320 | "values": [0, 0.5] 321 | } 322 | ], 323 | "position": { 324 | "x": 384, 325 | "y": -32 326 | } 327 | } 328 | ] 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { HotKeys } from "react-hotkeys" 4 | import { useDispatch, useSelector } from "react-redux" 5 | import { ToastContainer, toast, Slide } from "react-toastify" 6 | import { ReactFlowProvider } from "react-flow-renderer" 7 | import { toggleEditMode, toggleDelSelected, selectSideLeft } from "./features/ux/uxSlice" 8 | import AudioGraph from "./components/AudioGraph" 9 | import MenuOpener from "./components/Menu/MenuOpener" 10 | import Analysers2D from "./components/Analyser2D" 11 | import { Main, SideBar } from "./styled" 12 | import Sequencer from "./components/Sequencer" 13 | import Piano from "./components/Piano" 14 | import Brand from "./components/misc/Brand" 15 | import SelectSound from "./components/SelectSound" 16 | import FileSys from "./components/FileSys" 17 | 18 | const keyMap = { 19 | TOGGLE_EDIT_MODE: "m", 20 | DEL_SELECTED: "del", 21 | } 22 | 23 | export default function App() { 24 | const dispatch = useDispatch() 25 | const sideLeft = useSelector(selectSideLeft) 26 | const handlers = { 27 | TOGGLE_EDIT_MODE: (event?: KeyboardEvent) => dispatch(toggleEditMode()), 28 | DEL_SELECTED: (event?: KeyboardEvent) => dispatch(toggleDelSelected()), 29 | } 30 | 31 | return ( 32 | 33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | 52 |
53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/audio.d.ts: -------------------------------------------------------------------------------- 1 | // import { XYPosition } from "react-flow-renderer" 2 | interface XYPosition { 3 | x: number 4 | y: number 5 | } 6 | export const AUDIO_CONTEXT_DESTINATION = "destination" 7 | export const fftSizes = [32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768] as const 8 | export type FFTSize = typeof fftSizes[number] 9 | 10 | export type BaseNode = { 11 | id: string 12 | connectIds: string[] 13 | position?: XYPosition 14 | } 15 | 16 | export interface Analyser extends BaseNode { 17 | fftSize: FFTSize 18 | color: string 19 | lineWidth: number 20 | } 21 | 22 | export interface Gain extends BaseNode { 23 | params: AudioParamSetting[] 24 | } 25 | 26 | export interface BiquadFilter extends BaseNode { 27 | type: BiquadFilterType 28 | params: AudioParamSetting[] 29 | } 30 | 31 | export interface Oscillator extends BaseNode { 32 | type: OscillatorType 33 | params: AudioParamSetting[] 34 | } 35 | 36 | export const audioParamCalls = [ 37 | "setValueAtTime", 38 | "linearRampToValueAtTime", 39 | "exponentialRampToValueAtTime", 40 | "setTargetAtTime", 41 | "setValueCurveAtTime", 42 | ] as const 43 | 44 | export type Call = typeof audioParamCalls[number] 45 | export type CallParams = (number | number[])[] 46 | 47 | export type AudioParamSetting = { 48 | name: string 49 | call: Call 50 | /** `call` values in order */ 51 | values: CallParams 52 | } 53 | 54 | export type AudioNodeType = "AnalyserNode" | "BiquadFilterNode" | "GainNode" | "OscillatorNode" 55 | 56 | export type Sounds = { 57 | BPM: number 58 | notesPerBeat: number 59 | beatsPerBar: number 60 | bars: Bars 61 | soundNames: string[] 62 | } 63 | 64 | export type SynthStore = { 65 | name: string 66 | destination: { 67 | position: XYPosition 68 | } 69 | analysers: Analyser[] 70 | gains: Gain[] 71 | biquadFilters: BiquadFilter[] 72 | oscillators: Oscillator[] 73 | sequencer?: Sounds 74 | } 75 | 76 | // 77 | // SEQUENCER 78 | // 79 | 80 | export type StepValue = number | null 81 | 82 | export type Bar = { 83 | soundName: string 84 | steps: StepValue[] 85 | } 86 | 87 | export type Bars = { 88 | [barId: string]: Bar 89 | } 90 | -------------------------------------------------------------------------------- /src/components/Analyser2D/Analysers2D.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /* eslint-disable react-hooks/exhaustive-deps */ 3 | import { jsx } from "@emotion/core" 4 | import { toast } from "react-toastify" 5 | import { useRef, useEffect } from "react" 6 | import { useSelector } from "react-redux" 7 | // import { audioNodes } from "../../scripts/audio" 8 | import { selectAnalysers } from "../../features/activeSound/activeSoundSlice" 9 | import { dpiFix } from "../../scripts/utils" 10 | import { sound } from "../../scripts/audio" 11 | import Widget from "../misc/Widget" 12 | 13 | export default () => { 14 | const analysers = useSelector(selectAnalysers) 15 | const canvas = useRef(null) 16 | const ctx = useRef() 17 | const width = useRef(0) 18 | const height = useRef(0) 19 | const halfHeight = useRef(0) 20 | const timer = useRef(null) 21 | 22 | useEffect(() => { 23 | ctx.current = canvas.current!.getContext("2d") as CanvasRenderingContext2D 24 | //TODO: `dpiFix()` uneffective with css width and height, remove them 25 | const dimensions = dpiFix(canvas.current!) 26 | width.current = dimensions.width 27 | height.current = dimensions.height 28 | halfHeight.current = height.current / 2 29 | 30 | return () => { 31 | if (timer.current !== null) { 32 | clearTimeout(timer.current) 33 | } 34 | } 35 | }, []) 36 | 37 | const draw = () => { 38 | timer.current = null 39 | ctx.current!.clearRect(0, 0, width.current, height.current) 40 | analysers.forEach(analyser => { 41 | const node = sound.nodes.get(analyser.id) 42 | if (node && node.audioNode) { 43 | ctx.current!.lineWidth = analyser.lineWidth 44 | try { 45 | drawWave(node.audioNode as AnalyserNode, analyser.color) 46 | } catch (e) { 47 | toast.error(e.message) 48 | } 49 | } 50 | }) 51 | // requestAnimationFrame(draw) 52 | timer.current = setTimeout(draw, 100) 53 | } 54 | 55 | useEffect(() => { 56 | if (timer.current !== null) { 57 | clearTimeout(timer.current) 58 | } 59 | if (analysers.length > 0) { 60 | console.time("draw") 61 | draw() 62 | console.timeEnd("draw") 63 | } else { 64 | ctx.current!.clearRect(0, 0, width.current, height.current) 65 | } 66 | }, [analysers]) 67 | 68 | const drawWave = (analyser: AnalyserNode, color: string) => { 69 | ctx.current!.strokeStyle = color 70 | const bufferLength = analyser.frequencyBinCount 71 | const data = new Float32Array(bufferLength) 72 | analyser.getFloatTimeDomainData(data) 73 | let sliceWidth = width.current / bufferLength 74 | let x = 0 75 | let y 76 | let i 77 | ctx.current!.beginPath() 78 | ctx.current!.moveTo(0, halfHeight.current) 79 | for (i = 0; i < bufferLength; i++) { 80 | y = halfHeight.current + data[i] * halfHeight.current 81 | ctx.current!.lineTo(x, y) 82 | x += sliceWidth 83 | } 84 | ctx.current!.lineTo(width.current, halfHeight.current) 85 | ctx.current!.stroke() 86 | } 87 | 88 | return ( 89 | 90 |
91 | 92 |
93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/components/Analyser2D/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Analysers2D" 2 | -------------------------------------------------------------------------------- /src/components/AudioGraph/AudioGraph.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | /** @jsx jsx */ 3 | import ReactFlow, { 4 | Edge, 5 | Node, 6 | isEdge, 7 | addEdge, 8 | Elements, 9 | Controls, 10 | Connection, 11 | Background, 12 | useStoreState, 13 | removeElements, 14 | BackgroundVariant, 15 | } from "react-flow-renderer" 16 | import { jsx, Global } from "@emotion/core" 17 | import { useSelector, useDispatch } from "react-redux" 18 | import { useState, useRef, useEffect, Fragment } from "react" 19 | import { globalGraph, globalGraphEditMode, globalGraphDraggableMode } from "./styled" 20 | import { addConnect, delConnect } from "../../features/activeSound/activeSoundSlice" 21 | import { 22 | selectEditMode, 23 | selectLoadElements, 24 | selectDelSelected, 25 | toggleDelSelected, 26 | setLoadElements, 27 | } from "../../features/ux/uxSlice" 28 | import { getNextId, checkSize } from "../../scripts/helpers" 29 | import { AUDIO_CONTEXT_DESTINATION } from "../../audio.d" 30 | import { newNodePosition } from "../../scripts/utils" 31 | import BiquadFilter from "./nodes/BiquadFilter" 32 | import Oscillator from "./nodes/Oscillator" 33 | import Analyser from "./nodes/Analyser" 34 | import GraphMenu from "./GraphMenu" 35 | import Gain from "./nodes/Gain" 36 | 37 | export const audioNodeTypes = { 38 | biquadfilter: BiquadFilter, 39 | oscillator: Oscillator, 40 | analyser: Analyser, 41 | gain: Gain, 42 | } 43 | 44 | export const defaultNode: Node = { 45 | id: AUDIO_CONTEXT_DESTINATION, 46 | data: { label: "Audio Output" }, 47 | type: "output", 48 | connectable: true, 49 | selectable: false, 50 | position: { x: 0, y: 0 }, 51 | className: "audioNode output", 52 | } 53 | 54 | export default () => { 55 | const dispatch = useDispatch() 56 | const loadElements = useSelector(selectLoadElements) 57 | const editMode = useSelector(selectEditMode) 58 | const isDelSelected = useSelector(selectDelSelected) 59 | const width = useStoreState(store => store.width, checkSize) 60 | const height = useStoreState(store => store.height, checkSize) 61 | const [elements, setElements] = useState([]) 62 | const selected = useRef(null) 63 | const nextId = useRef(1) 64 | 65 | const onConnect = (connection: Edge | Connection) => { 66 | if (connection.source !== null && connection.target !== null) { 67 | setElements(els => addEdge(connection, els)) 68 | dispatch(addConnect({ source: connection.source, target: connection.target })) 69 | } 70 | } 71 | 72 | useEffect(() => { 73 | if (loadElements) { 74 | setElements(loadElements) 75 | nextId.current = getNextId(loadElements) 76 | dispatch(setLoadElements(null)) 77 | } 78 | }, [loadElements, dispatch]) 79 | 80 | useEffect(() => { 81 | if (width > 0 && height > 0 && elements.length === 0) { 82 | defaultNode.position = { x: width / 2, y: height / 2 } 83 | setElements([defaultNode]) 84 | } 85 | }, [width, height, elements]) 86 | 87 | useEffect(() => { 88 | if (isDelSelected) { 89 | delSelected() 90 | dispatch(toggleDelSelected()) 91 | } 92 | }, [isDelSelected]) 93 | 94 | const delSelected = () => { 95 | if (selected.current !== null) { 96 | setElements(removeElements(selected.current, elements)) 97 | selected.current 98 | .filter(el => isEdge(el)) 99 | .forEach( 100 | el => 101 | void dispatch(delConnect({ source: (el as Edge).source, target: (el as Edge).target })) 102 | ) 103 | selected.current = null 104 | } 105 | } 106 | 107 | const addAudioNode = (type: keyof typeof audioNodeTypes) => () => 108 | setElements([ 109 | ...elements, 110 | { 111 | id: (nextId.current++).toString(), 112 | type, 113 | className: "audioNode", 114 | position: newNodePosition(width, height), 115 | }, 116 | ]) 117 | 118 | return ( 119 | 120 | 121 | {editMode ? ( 122 | 123 | ) : ( 124 | 125 | )} 126 | (selected.current = els)} 132 | snapGrid={[16, 16]} 133 | snapToGrid={true} 134 | onlyRenderVisibleNodes={false} 135 | connectionLineStyle={{ stroke: "#71474e" }} 136 | > 137 | 138 | 144 | 145 | 146 | 147 | ) 148 | } 149 | -------------------------------------------------------------------------------- /src/components/AudioGraph/GraphMenu.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { useSelector, useDispatch } from "react-redux" 3 | import { selectEditMode, toggleEditMode } from "../../features/ux/uxSlice" 4 | import { GraphButtons, GraphButton } from "./styled" 5 | import { Fragment } from "react" 6 | import { jsx } from "@emotion/core" 7 | import { audioNodeTypes } from "./AudioGraph" 8 | 9 | type Props = { 10 | addAudioNode: (type: keyof typeof audioNodeTypes) => () => void 11 | delSelected: () => void 12 | } 13 | 14 | export default ({ addAudioNode, delSelected }: Props) => { 15 | const dispatch = useDispatch() 16 | const editMode = useSelector(selectEditMode) 17 | 18 | return ( 19 | 20 | dispatch(toggleEditMode())} 23 | icon={["fas", editMode ? "edit" : "project-diagram"]} 24 | > 25 | {editMode ? ( 26 | 27 | To View mode 28 | 29 | ) : ( 30 | 31 | To Edit mode 32 | 33 | )} 34 | 35 | 36 | Add Oscillator 37 | 38 | 39 | Add Gain 40 | 41 | 42 | Add Biquad Filter 43 | 44 | 45 | Add Analyser 46 | 47 | 48 | Remove Selected 49 | 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/components/AudioGraph/elems/AudioParamDefaults.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { Fragment } from "react" 3 | import { jsx } from "@emotion/core" 4 | import { AudioParams } from "../../../hooks/useAudioNodeDefs" 5 | import { formatNumber } from "../../../scripts/utils" 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 7 | import { DataRow, DataKey, DataNote, H2 } from "./styled" 8 | import { IconButton } from "../../../styled" 9 | 10 | type Props = { 11 | audioParams: AudioParams 12 | addParam: (name?: string, defaultValue?: number) => void 13 | hideButton?: boolean 14 | } 15 | 16 | export default ({ audioParams, addParam, hideButton }: Props) => { 17 | return ( 18 | 19 |

Defaults

20 | {Object.entries(audioParams).map(([key, params]) => { 21 | return ( 22 | 23 |
24 | {key}: {params.defaultValue} 25 |
26 | 27 | {params.minValue <= Number.MIN_SAFE_INTEGER ? "∞" : formatNumber(params.minValue)} —{" "} 28 | {params.maxValue >= Number.MAX_SAFE_INTEGER ? "∞" : formatNumber(params.maxValue)} 29 | {!hideButton && ( 30 | addParam(key, params.defaultValue)}> 31 | 32 | 33 | )} 34 | 35 |
36 | ) 37 | })} 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/AudioGraph/elems/AudioParamForm.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { Fragment, ChangeEvent } from "react" 4 | import { AudioParams } from "../../../hooks/useAudioNodeDefs" 5 | import { Call, CallParams, audioParamCalls } from "../../../audio.d" 6 | 7 | export type AudioParamUpdate = { 8 | name?: string 9 | call?: Call 10 | /** `call` values in order */ 11 | values?: CallParams 12 | } 13 | 14 | type Props = { 15 | audioParams: AudioParams 16 | name: string 17 | call: Call 18 | values: CallParams 19 | onChange: (param: AudioParamUpdate) => void 20 | } 21 | 22 | export default ({ audioParams, name, call, values, onChange }: Props) => { 23 | const setNumber = (event: ChangeEvent) => { 24 | const nth = +event.currentTarget.getAttribute("data-nth")! 25 | const val = event.currentTarget.valueAsNumber 26 | if (!Number.isNaN(val)) { 27 | const currValues = [...values] 28 | currValues[nth] = val 29 | onChange({ values: currValues }) 30 | } 31 | } 32 | 33 | const setNumbers = (event: ChangeEvent) => { 34 | const nth = +event.currentTarget.getAttribute("data-nth")! 35 | const val = event.currentTarget.value.split(",").map(value => +value) 36 | const currValues = [...values] 37 | currValues[nth] = val 38 | onChange({ values: currValues }) 39 | } 40 | 41 | const getNumber = (nth: number) => values[nth] as number 42 | const getNumbers = (nth: number) => { 43 | if (Array.isArray(values[nth])) { 44 | return (values[nth] as number[]).join(",") 45 | } 46 | return values[nth].toString() 47 | } 48 | 49 | return ( 50 | 51 | 52 | 59 | 60 | 61 | 71 | 72 | 73 | {["setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime"].includes( 74 | call 75 | ) && ( 76 | 77 | 78 | 85 | 86 | 87 | 95 | 96 | 97 | )} 98 | 99 | {["setTargetAtTime"].includes(call) && ( 100 | 101 | 102 | 103 | 104 | 105 | 106 | 113 | 120 | 121 | 122 | )} 123 | 124 | {["setValueCurveAtTime"].includes(call) && ( 125 | 126 | 127 | 134 | 135 | 136 | 143 | 150 | 151 | 152 | )} 153 | 154 | ) 155 | } 156 | -------------------------------------------------------------------------------- /src/components/AudioGraph/elems/AudioParams.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { Fragment } from "react" 3 | import { jsx } from "@emotion/core" 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | import AudioParamForm, { AudioParamUpdate } from "./AudioParamForm" 6 | import { AudioParams } from "../../../hooks/useAudioNodeDefs" 7 | import { AudioParamSetting } from "../../../audio" 8 | import { IconButton } from "../../../styled" 9 | 10 | type Props = { 11 | audioParams: AudioParams 12 | params: AudioParamSetting[] 13 | setParams: (params: AudioParamSetting[]) => void 14 | } 15 | 16 | export default ({ audioParams, params, setParams }: Props) => { 17 | const paramChange = (index: number, newParam: AudioParamUpdate) => { 18 | const currParams = [...params] 19 | if (newParam.call !== undefined) { 20 | const dcalls = ["setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime"] 21 | if (dcalls.includes(newParam.call)) { 22 | if (!dcalls.includes(currParams[index].call)) { 23 | newParam.values = [0, 0] 24 | } 25 | } 26 | if (["setTargetAtTime"].includes(newParam.call)) { 27 | newParam.values = [0, 0, 0] 28 | } 29 | if (["setValueCurveAtTime"].includes(newParam.call)) { 30 | newParam.values = [[0], 0, 0] 31 | } 32 | } 33 | currParams[index] = { ...currParams[index], ...newParam } 34 | setParams(currParams) 35 | } 36 | 37 | const delParam = (index: number) => { 38 | const currParams = [...params] 39 | currParams.splice(index, 1) 40 | setParams(currParams) 41 | } 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 51 | 54 | 57 | 60 | 61 | 62 | 63 | 64 | {params.map((param, index) => ( 65 | 66 | paramChange(index, newParam)} 70 | /> 71 | 76 | 77 | ))} 78 | 79 |
49 | 50 | 52 | 53 | 55 | 56 | 58 | 59 |
72 | delParam(index)}> 73 | 74 | 75 |
80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /src/components/AudioGraph/elems/AudioParamsView.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { useEffect, useState, Fragment } from "react" 4 | import { DataRow, DataKey, DataNote, H2 } from "./styled" 5 | import { AudioParamSetting } from "../../../audio" 6 | 7 | /** Processed AudioParamSetting for display on coordinate system */ 8 | type CoordParam = { 9 | time: number 10 | value: number 11 | //TODO: add curve data 12 | } 13 | 14 | type Props = { 15 | params: AudioParamSetting[] 16 | showCoord?: boolean 17 | } 18 | 19 | export default ({ params, showCoord }: Props) => { 20 | const [maxTime, setMaxTime] = useState(0) 21 | const [minValue, setMinValue] = useState(0) 22 | const [maxValue, setMaxValue] = useState(0) 23 | const [coordParams, setCoordParams] = useState([]) 24 | 25 | useEffect(() => { 26 | if (!showCoord) return 27 | 28 | const values = params.map(param => param.values).flat(2) 29 | setMinValue(Math.min(...values)) 30 | setMaxValue(Math.max(...values)) 31 | 32 | const cps: CoordParam[] = [] 33 | 34 | setMaxTime( 35 | params.reduce((total, param) => { 36 | let time = total 37 | let value 38 | switch (param.call) { 39 | case "setValueAtTime": 40 | case "linearRampToValueAtTime": 41 | case "exponentialRampToValueAtTime": 42 | value = param.values[0] as number 43 | time += param.values[1] as number 44 | break 45 | case "setTargetAtTime": 46 | value = param.values[0] as number 47 | time += param.values[2] as number 48 | break 49 | case "setValueCurveAtTime": 50 | value = Math.max(...(param.values[0] as number[])) 51 | time += param.values[2] as number 52 | break 53 | } 54 | 55 | cps.push({ time, value }) 56 | 57 | return time 58 | }, 0) 59 | ) 60 | 61 | setCoordParams(cps) 62 | }, [params, showCoord]) 63 | 64 | if (params.length === 0) { 65 | return 66 | } 67 | 68 | return ( 69 | 70 |

Adjusted Params

71 | 72 | {showCoord && maxTime > 0 && ( 73 |
74 | 78 | {coordParams.map(cp => ( 79 | 86 | ))} 87 | 88 |
89 | maxTime: {maxTime}, minValue: {minValue}, maxValue: {maxValue} 90 |
91 |
92 | )} 93 | 94 | {params.map((param, index) => ( 95 | 96 |
97 | {param.name}: {param.values.join(", ")} 98 |
99 | {param.call} 100 |
101 | ))} 102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /src/components/AudioGraph/elems/HandleInputs.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, memo } from "react" 2 | import { Handle, Position } from "react-flow-renderer" 3 | 4 | type Props = { 5 | numberOfInputs: number 6 | } 7 | 8 | export default memo(({ numberOfInputs }: Props) => ( 9 | 10 | {new Array(numberOfInputs).fill(0).map((_value, index) => ( 11 | 17 | ))} 18 | 19 | )) 20 | -------------------------------------------------------------------------------- /src/components/AudioGraph/elems/HandleOutputs.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, Fragment } from "react" 2 | import { Handle, Position } from "react-flow-renderer" 3 | 4 | type Props = { 5 | numberOfOutputs: number 6 | } 7 | 8 | export default memo(({ numberOfOutputs }: Props) => ( 9 | 10 | {new Array(numberOfOutputs).fill(0).map((_value, index) => ( 11 | 17 | ))} 18 | 19 | )) 20 | -------------------------------------------------------------------------------- /src/components/AudioGraph/elems/styled.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled" 2 | 3 | export const NodeBody = styled.div` 4 | padding: 4px 6px; 5 | border-radius: 3px 3px 2px 2px; 6 | background-color: #364156; 7 | // font-weight: 300; 8 | 9 | input, 10 | select, 11 | button { 12 | font-size: 0.95rem; 13 | flex-grow: 1; 14 | border-radius: 3px; 15 | background-color: transparent; 16 | border: 1px solid var(--input-border); 17 | font-family: Roboto; 18 | padding: 6px; 19 | color: #fff; 20 | &:focus { 21 | border-color: var(--input-border-focus); 22 | border-width: 2px; 23 | padding: 5px; 24 | } 25 | } 26 | 27 | select option { 28 | background-color: var(--widget-bg); 29 | } 30 | 31 | button { 32 | width: 100%; 33 | background-color: var(--button-bg); 34 | border-width: 2px; 35 | border-style: outset; 36 | cursor: pointer; 37 | &:focus { 38 | padding: 6px; 39 | } 40 | } 41 | ` 42 | 43 | export const FormWrapper = styled.div` 44 | table { 45 | select, input { 46 | padding: 2px; 47 | width: 60px; 48 | &:focus { 49 | padding: 1px; 50 | } 51 | } 52 | } 53 | } 54 | ` 55 | 56 | export const FormWrapperGrid = styled(FormWrapper)` 57 | display: grid; 58 | gap: 2px; 59 | grid-template-columns: 0.8fr 1fr; 60 | align-items: center; 61 | } 62 | ` 63 | 64 | export const H1 = styled.div` 65 | font-family: Tomorrow, sans-serif; 66 | font-style: normal; 67 | font-weight: 400; 68 | text-align: center; 69 | font-size: 1.2rem; 70 | margin: 2px 0 5px; 71 | ` 72 | 73 | export const H2 = styled.div` 74 | font-family: Tomorrow, sans-serif; 75 | font-style: normal; 76 | font-weight: 500; 77 | font-size: 1.1rem; 78 | margin: 12px 0; 79 | &:first-of-type { 80 | margin-top: 0; 81 | } 82 | ` 83 | 84 | export const Hr = styled.hr` 85 | margin: 15px 0 10px 0; 86 | border: 0; 87 | border-bottom: 1px dashed var(--node-bg); 88 | ` 89 | 90 | export const DelButton = styled.button` 91 | border: 0; 92 | background: transparent; 93 | color: #f33; 94 | font-size: 0.7rem; 95 | padding: 0; 96 | ` 97 | 98 | export const DataRow = styled.div` 99 | display: flex; 100 | justify-content: space-between; 101 | align-items: center; 102 | gap: 6px; 103 | font-size: 0.95rem; 104 | line-height: 1.35rem; 105 | ` 106 | 107 | export const DataKey = styled.span` 108 | font-weight: 100; 109 | ` 110 | 111 | export const DataNote = styled.div` 112 | font-size: 0.8rem; 113 | color: #999; 114 | word-spacing: -1px; 115 | 116 | button { 117 | margin-left: 6px; 118 | } 119 | ` 120 | -------------------------------------------------------------------------------- /src/components/AudioGraph/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./AudioGraph" 2 | -------------------------------------------------------------------------------- /src/components/AudioGraph/nodes/Analyser.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { useDispatch, useSelector } from "react-redux" 4 | import { NodeComponentProps } from "react-flow-renderer" 5 | import { useMemo, useEffect, Fragment, ChangeEvent } from "react" 6 | import { H1, DataRow, DataKey, NodeBody } from "../elems/styled" 7 | import useAudioNodeDefs from "../../../hooks/useAudioNodeDefs" 8 | import { selectEditMode } from "../../../features/ux/uxSlice" 9 | import { Analyser, fftSizes, FFTSize } from "../../../audio.d" 10 | import { 11 | setAnalyser, 12 | delAnalyser, 13 | selectAnalyser, 14 | } from "../../../features/activeSound/activeSoundSlice" 15 | import HandleOutputs from "../elems/HandleOutputs" 16 | import HandleInputs from "../elems/HandleInputs" 17 | import { WidgetRows } from "../../../styled" 18 | 19 | export default ({ id, data }: NodeComponentProps) => { 20 | const basic: Analyser = useMemo( 21 | () => ({ 22 | id, 23 | connectIds: data?.connectIds ?? [], 24 | fftSize: data?.fftSize ?? fftSizes[6], 25 | color: data?.color ?? "#d66853", 26 | lineWidth: data?.lineWidth ?? 2, 27 | }), 28 | [id, data] 29 | ) 30 | const editMode = useSelector(selectEditMode) 31 | const defs = useAudioNodeDefs("AnalyserNode") 32 | const dispatch = useDispatch() 33 | const analyser: Analyser = useSelector(selectAnalyser)(id) || basic 34 | 35 | useEffect(() => { 36 | dispatch(setAnalyser(analyser)) 37 | return () => void dispatch(delAnalyser(id)) 38 | // eslint-disable-next-line react-hooks/exhaustive-deps 39 | }, []) 40 | 41 | const setFFTSize = (event: ChangeEvent) => { 42 | dispatch(setAnalyser({ ...analyser, fftSize: +event.currentTarget.value as FFTSize })) 43 | } 44 | 45 | const setColor = (event: ChangeEvent) => { 46 | dispatch(setAnalyser({ ...analyser, color: event.currentTarget.value })) 47 | } 48 | 49 | const setLineWidth = (event: ChangeEvent) => { 50 | dispatch(setAnalyser({ ...analyser, lineWidth: event.currentTarget.valueAsNumber })) 51 | } 52 | 53 | return ( 54 | 55 | 56 |

Analyser #{id}

57 | 58 | 59 | {editMode ? ( 60 | 61 |
62 | 65 | 72 |
73 |
74 | 77 | 78 |
79 |
80 | 83 | 91 |
92 |
93 | ) : ( 94 | 95 | 96 | FFT Size: {analyser.fftSize} 97 | 98 | 99 | Line: 100 |
103 |
104 |
105 | )} 106 |
107 | 108 | 109 |
110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/components/AudioGraph/nodes/BiquadFilter.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { useSelector, useDispatch } from "react-redux" 4 | import { NodeComponentProps } from "react-flow-renderer" 5 | import { useMemo, useEffect, Fragment, ChangeEvent } from "react" 6 | import { FormWrapper, H1, H2, NodeBody, DataRow, DataKey, Hr } from "../elems/styled" 7 | import { BiquadFilter, AudioParamSetting } from "../../../audio" 8 | import useAudioNodeDefs from "../../../hooks/useAudioNodeDefs" 9 | import { selectEditMode } from "../../../features/ux/uxSlice" 10 | import AudioParamDefaults from "../elems/AudioParamDefaults" 11 | import { 12 | setBiquadFilter, 13 | delBiquadFilter, 14 | selectBiquadFilter, 15 | } from "../../../features/activeSound/activeSoundSlice" 16 | import AudioParamsView from "../elems/AudioParamsView" 17 | import HandleOutputs from "../elems/HandleOutputs" 18 | import HandleInputs from "../elems/HandleInputs" 19 | import AudioParams from "../elems/AudioParams" 20 | 21 | const types: BiquadFilterType[] = [ 22 | "allpass", 23 | "bandpass", 24 | "highpass", 25 | "highshelf", 26 | "lowpass", 27 | "lowshelf", 28 | "notch", 29 | "peaking", 30 | ] 31 | 32 | export default ({ id, data }: NodeComponentProps) => { 33 | const basic: BiquadFilter = useMemo( 34 | () => ({ 35 | id, 36 | connectIds: data?.connectIds ?? [], 37 | type: data?.type ?? "lowpass", 38 | params: data?.params ?? [], 39 | }), 40 | [id, data] 41 | ) 42 | const editMode = useSelector(selectEditMode) 43 | const defs = useAudioNodeDefs("BiquadFilterNode") 44 | const dispatch = useDispatch() 45 | const biquadFilter: BiquadFilter = useSelector(selectBiquadFilter)(id) || basic 46 | 47 | useEffect(() => { 48 | dispatch(setBiquadFilter(biquadFilter)) 49 | return () => void dispatch(delBiquadFilter(id)) 50 | // eslint-disable-next-line react-hooks/exhaustive-deps 51 | }, []) 52 | 53 | const changeType = (event: ChangeEvent) => { 54 | dispatch( 55 | setBiquadFilter({ ...biquadFilter, type: event.currentTarget.value as BiquadFilterType }) 56 | ) 57 | } 58 | 59 | const setParams = (params: AudioParamSetting[]) => { 60 | dispatch(setBiquadFilter({ ...biquadFilter, params })) 61 | } 62 | 63 | const addParam = (name?: string, defaultValue = 1) => { 64 | const params = [...biquadFilter.params] 65 | params.push({ 66 | name: name || Object.keys(defs.audioParams)[0], 67 | call: "setValueAtTime", 68 | values: [defaultValue, 0], 69 | }) 70 | setParams(params) 71 | } 72 | 73 | return ( 74 | 75 | 76 |

Biquad Filter #{id}

77 | 78 | 79 | {editMode ? ( 80 | 81 | 82 |

Type

83 | 84 |
85 | {types.map(typeVal => ( 86 | 95 | ))} 96 |
97 | {biquadFilter.params.length > 0 && ( 98 | 99 |
100 | 105 |
106 | )} 107 |
108 |
109 | ) : ( 110 |
111 | 112 | Type: {biquadFilter.type} 113 | 114 | 115 |
116 | )} 117 |
118 | 119 |
120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /src/components/AudioGraph/nodes/Gain.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { useMemo, useEffect, Fragment } from "react" 4 | import { useSelector, useDispatch } from "react-redux" 5 | import { NodeComponentProps } from "react-flow-renderer" 6 | import { setGain, delGain, selectGain } from "../../../features/activeSound/activeSoundSlice" 7 | import { H1, FormWrapper, NodeBody, Hr } from "../elems/styled" 8 | import useAudioNodeDefs from "../../../hooks/useAudioNodeDefs" 9 | import { selectEditMode } from "../../../features/ux/uxSlice" 10 | import AudioParamDefaults from "../elems/AudioParamDefaults" 11 | import { Gain, AudioParamSetting } from "../../../audio" 12 | import AudioParamsView from "../elems/AudioParamsView" 13 | import HandleOutputs from "../elems/HandleOutputs" 14 | import HandleInputs from "../elems/HandleInputs" 15 | import AudioParams from "../elems/AudioParams" 16 | 17 | export default ({ id, data }: NodeComponentProps) => { 18 | const basic: Gain = useMemo( 19 | () => ({ 20 | id, 21 | connectIds: data?.connectIds ?? [], 22 | params: data?.params ?? [], 23 | }), 24 | [id, data] 25 | ) 26 | const editMode = useSelector(selectEditMode) 27 | const defs = useAudioNodeDefs("GainNode") 28 | const dispatch = useDispatch() 29 | const gain: Gain = useSelector(selectGain)(id) || basic 30 | 31 | useEffect(() => { 32 | dispatch(setGain(gain)) 33 | return () => void dispatch(delGain(id)) 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | }, []) 36 | 37 | const setParams = (params: AudioParamSetting[]) => { 38 | dispatch(setGain({ ...gain, params })) 39 | } 40 | 41 | const addParam = (name?: string, defaultValue = 1) => { 42 | const params = [...gain.params] 43 | params.push({ 44 | name: name || Object.keys(defs.audioParams)[0], 45 | call: "setValueAtTime", 46 | values: [defaultValue, 0], 47 | }) 48 | setParams(params) 49 | } 50 | 51 | return ( 52 | 53 | 54 |

Gain #{id}

55 | 56 | 57 | {(editMode || gain.params.length === 0) && ( 58 | 63 | )} 64 | 65 | 66 | 67 | {editMode && ( 68 | 69 | {gain.params.length > 0 && ( 70 | 71 |
72 | 77 |
78 | )} 79 |
80 | )} 81 |
82 | 83 | 84 |
85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /src/components/AudioGraph/nodes/Oscillator.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | /** @jsx jsx */ 3 | import { jsx } from "@emotion/core" 4 | import { useMemo, useEffect, Fragment, ChangeEvent } from "react" 5 | import { useSelector, useDispatch } from "react-redux" 6 | import { NodeComponentProps } from "react-flow-renderer" 7 | import { H1, FormWrapper, Hr, NodeBody, H2, DataRow, DataKey } from "../elems/styled" 8 | import { Oscillator, AudioParamSetting } from "../../../audio" 9 | import useAudioNodeDefs from "../../../hooks/useAudioNodeDefs" 10 | import { selectEditMode } from "../../../features/ux/uxSlice" 11 | import AudioParamDefaults from "../elems/AudioParamDefaults" 12 | import { 13 | setOscillator, 14 | delOscillator, 15 | selectOscillator, 16 | } from "../../../features/activeSound/activeSoundSlice" 17 | import AudioParamsView from "../elems/AudioParamsView" 18 | import HandleOutputs from "../elems/HandleOutputs" 19 | import HandleInputs from "../elems/HandleInputs" 20 | import AudioParams from "../elems/AudioParams" 21 | 22 | const types: OscillatorType[] = ["sine", "square", "sawtooth", "triangle"] 23 | 24 | export default ({ id, data }: NodeComponentProps) => { 25 | const basic: Oscillator = useMemo( 26 | () => ({ 27 | id, 28 | connectIds: data?.connectIds ?? [], 29 | type: data?.type ?? types[0], 30 | params: data?.params ?? [], 31 | }), 32 | [id, data] 33 | ) 34 | const editMode = useSelector(selectEditMode) 35 | const defs = useAudioNodeDefs("OscillatorNode") 36 | const dispatch = useDispatch() 37 | const oscillator: Oscillator = useSelector(selectOscillator)(id) || basic 38 | 39 | useEffect(() => { 40 | dispatch(setOscillator(oscillator)) 41 | return () => void dispatch(delOscillator(id)) 42 | // eslint-disable-next-line react-hooks/exhaustive-deps 43 | }, []) 44 | 45 | const changeType = (event: ChangeEvent) => { 46 | dispatch(setOscillator({ ...oscillator, type: event.currentTarget.value as OscillatorType })) 47 | } 48 | 49 | const setParams = (params: AudioParamSetting[]) => { 50 | dispatch(setOscillator({ ...oscillator, params })) 51 | } 52 | 53 | const addParam = (name?: string, defaultValue = 1) => { 54 | const params = [...oscillator.params] 55 | params.push({ 56 | name: name || Object.keys(defs.audioParams)[0], 57 | call: "setValueAtTime", 58 | values: [defaultValue, 0], 59 | }) 60 | setParams(params) 61 | } 62 | 63 | return ( 64 | 65 | {defs.numberOfInputs > 0 && } 66 |

Oscillator #{id}

67 | 68 | 69 | {editMode ? ( 70 | 71 | 72 |

Type

73 | 74 |
75 | {types.map(typeVal => ( 76 | 85 | ))} 86 |
87 | {oscillator.params.length > 0 && ( 88 | 89 |
90 | 95 |
96 | )} 97 |
98 |
99 | ) : ( 100 |
101 | 102 | Type: {oscillator.type} 103 | 104 | 105 |
106 | )} 107 |
108 | 109 | 110 |
111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /src/components/AudioGraph/styled.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/core" 3 | import styled from "@emotion/styled" 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | import { IconProp } from "@fortawesome/fontawesome-svg-core" 6 | import { audioNodeTypes } from "./AudioGraph" 7 | import { PropsWithChildren } from "react" 8 | 9 | export const globalGraph = css` 10 | .react-flow { 11 | background-color: var(--graph-bg); 12 | resize: horizontal; 13 | min-width: 80px; 14 | width: calc(100% - var(--side-default-width)); 15 | } 16 | 17 | .react-flow__node.audioNode { 18 | min-width: 165px; 19 | border-radius: 4px; 20 | background-color: var(--node-bg); 21 | color: #fff; 22 | padding: 3px; 23 | 24 | &.selected { 25 | border: 2px solid var(--node-selected-border); 26 | padding: 1px; 27 | } 28 | 29 | &.output { 30 | font-family: Tomorrow; 31 | font-size: 1rem; 32 | padding: 8px; 33 | } 34 | } 35 | 36 | .react-flow__handle { 37 | width: 0.92rem; 38 | height: 0.92rem; 39 | &.react-flow__handle-top { 40 | top: -0.64rem; 41 | background-color: #e62020; 42 | } 43 | &.react-flow__handle-bottom { 44 | bottom: -0.64rem; 45 | background-color: #74c365; 46 | } 47 | } 48 | .react-flow__edge { 49 | .react-flow__edge-path { 50 | stroke-width: 2; 51 | stroke: #7d4e57; 52 | } 53 | &.selected .react-flow__edge-path { 54 | stroke-width: 3; 55 | stroke: #d66853; 56 | } 57 | } 58 | .react-flow__controls-button { 59 | width: 25px; 60 | height: 25px; 61 | opacity: 0.5; 62 | } 63 | ` 64 | 65 | export const globalGraphEditMode = css` 66 | .react-flow__node.audioNode { 67 | cursor: default; 68 | } 69 | ` 70 | 71 | export const globalGraphDraggableMode = css` 72 | .react-flow__node.audioNode { 73 | box-shadow: 1px 1px 4px 1px #333; 74 | } 75 | ` 76 | 77 | const GraphButtonBase = styled.button` 78 | background-color: #364156; 79 | border-color: #7d4e57; 80 | color: #fff; 81 | font-family: Tomorrow; 82 | font-size: 0.85rem; 83 | border-radius: 3px; 84 | padding: 4px; 85 | cursor: pointer; 86 | display: flex; 87 | align-items: center; 88 | justify-content: space-between; 89 | svg { 90 | transition: 50ms; 91 | } 92 | &:hover svg { 93 | transition: 50ms; 94 | transform: scale(1.25); 95 | } 96 | ` 97 | 98 | export const GraphButtons = styled.div` 99 | position: absolute; 100 | gap: 2px; 101 | top: 8px; 102 | right: 10px; 103 | z-index: 4; 104 | display: flex; 105 | flex-direction: column; 106 | .text { 107 | overflow: hidden; 108 | white-space: nowrap; 109 | width: 0; 110 | transition: 60ms; 111 | } 112 | &:hover .text { 113 | width: 165px; 114 | transition: 120ms; 115 | } 116 | ` 117 | 118 | const GraphButtonMode = styled(GraphButtonBase)` 119 | background-color: #455e87; 120 | ` 121 | 122 | const GraphButtonDel = styled(GraphButtonBase)` 123 | background-color: #742a1b; 124 | ` 125 | 126 | type GraphBtnProps = { 127 | icon: IconProp 128 | onClick: (type: keyof typeof audioNodeTypes) => void 129 | mode?: "default" | "mode" | "del" 130 | } 131 | 132 | export const GraphButton = ({ 133 | icon, 134 | onClick, 135 | mode = "default", 136 | children, 137 | }: PropsWithChildren) => { 138 | switch (mode) { 139 | case "mode": 140 | return ( 141 | // @ts-ignore 142 | 143 |
{children}
144 | 145 |
146 | ) 147 | case "del": 148 | return ( 149 | // @ts-ignore 150 | 151 |
{children}
152 | 153 |
154 | ) 155 | default: 156 | return ( 157 | // @ts-ignore 158 | 159 |
{children}
160 | 161 |
162 | ) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/components/FileSys/FileSys.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable array-callback-return */ 3 | /** @jsx jsx */ 4 | import { jsx } from "@emotion/core" 5 | import { toast } from "react-toastify" 6 | import { useRef, useEffect } from "react" 7 | import { useDispatch, useSelector } from "react-redux" 8 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 9 | import { IconButton } from "../../styled" 10 | import Widget from "../misc/Widget" 11 | import { 12 | setBPM, 13 | setNotesPerBeat, 14 | setBeatsPerBar, 15 | setBars, 16 | refreshSoundNames, 17 | selectBars, 18 | selectStepsPerBar, 19 | selectBPM, 20 | } from "../../features/sounds/soundsSlice" 21 | import { loadSound } from "../../scripts/audio" 22 | import { wavHeader } from "../../scripts/wav" 23 | import { audioContext } from "../../scripts/audio" 24 | import { StepValue, Bar } from "../../audio" 25 | import Sound from "../../scripts/Sound" 26 | 27 | export default () => { 28 | const fileInput = useRef(null) 29 | const dispatch = useDispatch() 30 | const bars = useSelector(selectBars) 31 | const stepsPerBar = useSelector(selectStepsPerBar) //FIXME: selectNotesPerBeat + selectBeatsPerBar 32 | const BPM = useSelector(selectBPM) 33 | 34 | const loadSerialized = (readed: string, filename?: string) => { 35 | localStorage.clear() 36 | const data = JSON.parse(readed) 37 | Object.entries(data).forEach(([key, value]) => { 38 | if (key === "sequencer") { 39 | dispatch(setBPM((value as any).BPM)) 40 | dispatch(setNotesPerBeat((value as any).notesPerBeat)) 41 | dispatch(setBeatsPerBar((value as any).beatsPerBar)) 42 | dispatch(setBars((value as any).bars)) 43 | } else { 44 | localStorage.setItem(key, JSON.stringify(value)) 45 | } 46 | }) 47 | dispatch(refreshSoundNames()) 48 | toast.info(`${filename ?? "File"} loaded`) 49 | } 50 | 51 | const loadFile = (ev: Event) => { 52 | // @ts-ignore 53 | const files: FileList = ev.target.files 54 | if (files.length === 1) { 55 | const file = files[0] 56 | const reader = new FileReader() 57 | reader.onload = () => { 58 | //TODO: Validation 59 | loadSerialized(reader.result as string, file.name) 60 | } 61 | reader.readAsText(file) 62 | } 63 | } 64 | 65 | const loadDefaultSave = async () => { 66 | const filename = "Synth.json" 67 | try { 68 | const res = await fetch(`${window.location.pathname}/samples/${filename}`) 69 | if (res.ok) { 70 | loadSerialized(await res.text(), filename) 71 | } 72 | } catch (e) { 73 | toast.error(`${filename} failed to load. ${e}`) 74 | } 75 | } 76 | 77 | useEffect(() => { 78 | fileInput.current!.onchange = loadFile 79 | 80 | if (localStorage.length === 0) { 81 | loadDefaultSave() 82 | } 83 | // eslint-disable-next-line react-hooks/exhaustive-deps 84 | }, []) 85 | 86 | const saveBlob = (blob: Blob, filename: string) => { 87 | let url = window.URL.createObjectURL(blob) 88 | const a = document.createElement("a") 89 | document.body.appendChild(a) 90 | a.style.display = "none" 91 | a.href = url 92 | a.download = filename 93 | a.click() 94 | window.URL.revokeObjectURL(url) 95 | a.remove() 96 | } 97 | 98 | const saveFile = (): void => { 99 | const data: any = {} 100 | Object.keys(localStorage).map(key => { 101 | data[key] = JSON.parse(localStorage.getItem(key)!) 102 | }) 103 | if (data) { 104 | const blob = new Blob([JSON.stringify(data, null, 2)], { 105 | type: "application/json;charset=utf-8", 106 | }) 107 | saveBlob(blob, `Synth-${new Date().getTime()}.json`) 108 | } 109 | } 110 | 111 | const renderMusic = async () => { 112 | let i: number 113 | let j: number 114 | let bar: Bar 115 | let barId: string 116 | let step: StepValue 117 | let sound: Sound | null 118 | let repeat = 1 119 | let lengthOfStep = stepsPerBar / BPM 120 | // let sampleRate = 44_100 121 | let sampleRate = audioContext.sampleRate 122 | 123 | const offlineCtx = new OfflineAudioContext( 124 | 1, 125 | lengthOfStep * stepsPerBar * sampleRate * repeat, 126 | sampleRate 127 | ) 128 | // const offlineCtx = audioContext 129 | 130 | for (barId in bars) { 131 | bar = bars[barId] 132 | for (j = 0; j < repeat; j++) 133 | for (i = 0; i < stepsPerBar; i++) { 134 | step = bar.steps[i] 135 | if (step !== null) { 136 | sound = loadSound(bar.soundName, offlineCtx) 137 | if (sound !== null) { 138 | sound.play(step, lengthOfStep * i + j * stepsPerBar * lengthOfStep) 139 | } 140 | } 141 | } 142 | } 143 | 144 | const buffer = await offlineCtx.startRendering() 145 | saveBlob( 146 | new Blob([ 147 | // Sample size is fix 32-bit https://www.w3.org/TR/webaudio/#audio-sample-format 148 | Buffer.from(wavHeader(1, sampleRate, 32, buffer.length * 4 + 44)), 149 | buffer.getChannelData(0), 150 | ]), 151 | `Synth-${new Date().getTime()}.json` 152 | ) 153 | } 154 | 155 | return ( 156 | 157 |
158 | fileInput.current!.click()} title="Load Synth File"> 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 |
169 |
170 | ) 171 | } 172 | -------------------------------------------------------------------------------- /src/components/FileSys/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./FileSys" 2 | -------------------------------------------------------------------------------- /src/components/Menu/Menu.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/accessible-emoji */ 2 | /** @jsx jsx */ 3 | import { jsx } from "@emotion/core" 4 | import { toast } from "react-toastify" 5 | import { useDispatch } from "react-redux" 6 | import { toggleMenu, toggleSideLeft } from "../../features/ux/uxSlice" 7 | import { validateSound } from "../../scripts/helpers" 8 | import { MenuPopup } from "../../styled" 9 | 10 | export default () => { 11 | const dispatch = useDispatch() 12 | 13 | const loadDefaultSounds = async (name: string) => { 14 | try { 15 | const res = await fetch(`${window.location.pathname}/samples/${name}.json`) 16 | if (res.ok) { 17 | const sample = await res.json() 18 | if (validateSound(sample)) { 19 | localStorage.setItem(name, JSON.stringify(sample)) 20 | toast.success(`${name} Loaded`) 21 | } 22 | } 23 | } catch (e) { 24 | toast.error(`${name} failed to load. ${e}`) 25 | } 26 | } 27 | 28 | return ( 29 | dispatch(toggleMenu())}> 30 |

MENU

31 |
    32 |
  • 33 | Refresh often because something is not right yet. Load samples below with the buttons and 34 | select from the dropdown to have a look. Crash and Refresh. =] (This version only deployed 35 | for testing purposes.) 36 |
  • 37 |
  • 38 | Open poorly managed{" "} 39 | 40 | Wiki page 41 | 42 | . 43 |
  • 44 |
  • 45 | Preload and{" "} 46 | sounds to its name in 47 | local storage. 48 |
  • 49 |
  • 50 | sidebar position 51 | between left and right. 52 |
  • 53 |
54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/components/Menu/MenuOpener.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { Fragment, useRef } from "react" 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | import { selectMenu, toggleMenu } from "../../features/ux/uxSlice" 6 | import { useSelector, useDispatch } from "react-redux" 7 | import Menu from "./Menu" 8 | 9 | const MenuOpener = () => { 10 | const menu = useSelector(selectMenu) 11 | const dispatch = useDispatch() 12 | const timer = useRef(null) 13 | 14 | return ( 15 | 16 | { 22 | if (timer.current === null) { 23 | timer.current = setTimeout(() => { 24 | dispatch(toggleMenu()) 25 | timer.current = null 26 | }, 500) 27 | } 28 | }} 29 | onMouseLeave={() => { 30 | if (timer.current !== null) { 31 | clearTimeout(timer.current) 32 | timer.current = null 33 | } 34 | }} 35 | /> 36 | {menu && } 37 | 38 | ) 39 | } 40 | 41 | export default MenuOpener 42 | -------------------------------------------------------------------------------- /src/components/Piano/Piano.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled" 2 | import React, { useRef } from "react" 3 | import { useDispatch } from "react-redux" 4 | import { setPlayFrequency } from "../../features/activeSound/activeSoundSlice" 5 | import Widget from "../misc/Widget" 6 | 7 | const PianoWrapper = styled.div` 8 | display: flex; 9 | align-items: center; 10 | font-size: 3rem; 11 | justify-content: center; 12 | user-select: none; 13 | padding-bottom: 0.5rem; 14 | 15 | > div { 16 | flex: 1; 17 | position: relative; 18 | height: 150px; 19 | margin: 0 1px; 20 | border: 1px solid #999; 21 | border-radius: 1px 1px 5px 5px; 22 | box-shadow: 2px 2px 5px #0006; 23 | cursor: pointer; 24 | background-color: #aaa; 25 | &:hover { 26 | background-color: #999; 27 | } 28 | &:active { 29 | background-color: #888; 30 | } 31 | &::after { 32 | position: absolute; 33 | bottom: 0.3rem; 34 | width: 100%; 35 | text-align: center; 36 | content: attr(data-note); 37 | font: 500 1.6rem Candara; 38 | color: #121212; 39 | } 40 | } 41 | 42 | > div > div { 43 | position: absolute; 44 | top: -1px; 45 | left: 70%; 46 | height: 105px; 47 | width: calc(60% + 2px); 48 | border-radius: 1px 1px 5px 5px; 49 | box-shadow: 2px 2px 5px #0006; 50 | cursor: pointer; 51 | z-index: 10; 52 | background-color: #000; 53 | &:hover { 54 | background-color: #111; 55 | } 56 | &:active { 57 | background-color: #222; 58 | } 59 | } 60 | ` 61 | 62 | const Piano = () => { 63 | const dispatch = useDispatch() 64 | const lastFrequency = useRef(null) 65 | 66 | const play = (event: React.MouseEvent) => { 67 | if (event.buttons === 1) { 68 | const data = (event.target as HTMLElement).getAttribute("data-frequency") 69 | const frequency = data !== null ? +data : null 70 | if (lastFrequency.current !== frequency) { 71 | dispatch(setPlayFrequency(data !== null ? +data : null)) 72 | lastFrequency.current = frequency 73 | } 74 | } 75 | } 76 | 77 | const stop = () => { 78 | if (lastFrequency.current !== null) { 79 | dispatch(setPlayFrequency(null)) 80 | lastFrequency.current = null 81 | } 82 | } 83 | 84 | return ( 85 | 86 | 87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 | ) 125 | } 126 | 127 | export default Piano 128 | -------------------------------------------------------------------------------- /src/components/Piano/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Piano" 2 | -------------------------------------------------------------------------------- /src/components/SelectSound/SelectSound.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { toast } from "react-toastify" 4 | import { useEffect, useRef, useState } from "react" 5 | import { useDispatch, useSelector } from "react-redux" 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 7 | import { Elements, useStoreState, Node } from "react-flow-renderer" 8 | import { BaseNode, SynthStore } from "../../audio" 9 | import { resetSoundsState } from "../../features/sounds/soundsSlice" 10 | import { setLoadElements } from "../../features/ux/uxSlice" 11 | import LocalSoundSelect from "../misc/LocalSoundSelect" 12 | import { defaultNode } from "../AudioGraph/AudioGraph" 13 | import { validateSound } from "../../scripts/helpers" 14 | import { 15 | selectName, 16 | setName, 17 | emptyNodes, 18 | setAnalyser, 19 | setGain, 20 | setBiquadFilter, 21 | setOscillator, 22 | selectAudioNodes, 23 | } from "../../features/activeSound/activeSoundSlice" 24 | import { sound } from "../../scripts/audio" 25 | import { IconButton } from "../../styled" 26 | import Widget from "../misc/Widget" 27 | 28 | export default () => { 29 | const dispatch = useDispatch() 30 | const name = useSelector(selectName) 31 | const input = useRef(null) 32 | const [showSelect, setShowSelect] = useState(false) 33 | const audioNodes = useSelector(selectAudioNodes) 34 | const elements = useStoreState(store => store.elements) 35 | 36 | useEffect(() => { 37 | if (!showSelect) { 38 | input.current!.value = name 39 | } 40 | // eslint-disable-next-line react-hooks/exhaustive-deps 41 | }, [name]) 42 | 43 | useEffect(() => { 44 | return () => save() 45 | // eslint-disable-next-line react-hooks/exhaustive-deps 46 | }, []) 47 | 48 | const newSound = () => { 49 | save() 50 | dispatch(setName("No Name")) 51 | dispatch(emptyNodes()) 52 | dispatch(setLoadElements([])) 53 | dispatch(resetSoundsState()) 54 | setShowSelect(false) 55 | localStorage.clear() 56 | toast.success(`Create New Sound`) 57 | } 58 | 59 | const load = (name: string) => { 60 | save() 61 | const data = localStorage.getItem(name) 62 | if (data) { 63 | sound.destroyAudioNodes() 64 | 65 | const obj: SynthStore = JSON.parse(data) 66 | if (validateSound(obj)) { 67 | dispatch(setName(name)) 68 | dispatch(emptyNodes()) 69 | 70 | const nodeFactory = (node: BaseNode, type: string, data?: any) => ({ 71 | id: node.id, 72 | type, 73 | className: "audioNode", 74 | position: node.position!, 75 | data, 76 | }) 77 | 78 | const edgeFactory = (source: string, target: string) => ({ 79 | source, 80 | target, 81 | id: `reactflow__edge-${source}-${target}`, 82 | type: "default", 83 | }) 84 | 85 | const elements: Elements = [] 86 | 87 | obj.analysers.forEach(node => { 88 | elements.push(nodeFactory(node, "analyser", { ...node })) 89 | delete node.position 90 | node.connectIds.forEach(toId => void elements.push(edgeFactory(node.id, toId))) 91 | dispatch(setAnalyser(node)) 92 | }) 93 | 94 | obj.gains.forEach(node => { 95 | elements.push(nodeFactory(node, "gain", { ...node })) 96 | delete node.position 97 | node.connectIds.forEach(toId => void elements.push(edgeFactory(node.id, toId))) 98 | dispatch(setGain(node)) 99 | }) 100 | 101 | obj.biquadFilters.forEach(node => { 102 | elements.push(nodeFactory(node, "biquadfilter", { ...node })) 103 | delete node.position 104 | node.connectIds.forEach(toId => void elements.push(edgeFactory(node.id, toId))) 105 | dispatch(setBiquadFilter(node)) 106 | }) 107 | 108 | obj.oscillators.forEach(node => { 109 | elements.push(nodeFactory(node, "oscillator", { ...node })) 110 | delete node.position 111 | node.connectIds.forEach(toId => void elements.push(edgeFactory(node.id, toId))) 112 | dispatch(setOscillator(node)) 113 | }) 114 | 115 | elements.push({ 116 | ...defaultNode, 117 | position: obj.destination.position, 118 | }) 119 | 120 | dispatch(setLoadElements(elements)) 121 | 122 | setShowSelect(false) 123 | toast.success(`Sound "${name}" loaded`) 124 | return 125 | } 126 | } 127 | toast.error(`Error loading "${name}" sound`) 128 | } 129 | 130 | const save = () => { 131 | const addPosition = (node: BaseNode) => ({ 132 | ...node, 133 | position: (elements.find(element => element.id === node.id) as Node | undefined)?.__rf 134 | .position, 135 | }) 136 | 137 | try { 138 | localStorage.setItem( 139 | name || "No Name", 140 | JSON.stringify({ 141 | destination: addPosition({ id: "destination", connectIds: [] }), 142 | analysers: audioNodes.analysers.flatMap(addPosition), 143 | gains: audioNodes.gains.flatMap(addPosition), 144 | biquadFilters: audioNodes.biquadFilters.flatMap(addPosition), 145 | oscillators: audioNodes.oscillators.flatMap(addPosition), 146 | }) 147 | ) 148 | toast.success(`Sound "${name}" saved`) 149 | } catch (e) { 150 | toast.error(e.message) 151 | } 152 | } 153 | 154 | return ( 155 | 156 |
157 | {showSelect ? ( 158 | 159 | ) : ( 160 | dispatch(setName(event.target.value))} 165 | onFocus={event => event.target.select()} 166 | /> 167 | )} 168 | setShowSelect(!showSelect)} title="Toggle Select"> 169 | 170 | 171 | 172 | 173 | 174 |
175 |
176 | ) 177 | } 178 | -------------------------------------------------------------------------------- /src/components/SelectSound/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SelectSound" 2 | -------------------------------------------------------------------------------- /src/components/Sequencer/Bar.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | /** @jsx jsx */ 3 | import { jsx, css } from "@emotion/core" 4 | import { useEffect, useRef } from "react" 5 | import { useDispatch, useSelector } from "react-redux" 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 7 | import { setPlayFrequency } from "../../features/activeSound/activeSoundSlice" 8 | import { selectSteps, selectSoundName, setSoundName } from "../../features/sounds/soundsSlice" 9 | import LocalSoundSelect from "../misc/LocalSoundSelect" 10 | import { loadSound } from "../../scripts/audio" 11 | import { IconButton } from "../../styled" 12 | import Sound from "../../scripts/Sound" 13 | import Step from "./Step" 14 | 15 | const sequenceStyle = css` 16 | width: 100%; 17 | display: flex; 18 | justify-items: stretch; 19 | align-items: stretch; 20 | background-color: #000; 21 | flex-grow: 1; 22 | ` 23 | 24 | const barStyle = css` 25 | display: flex; 26 | > select { 27 | padding: 0px !important; 28 | font-size: 0.9rem !important; 29 | width: 45px; 30 | } 31 | ` 32 | 33 | type Props = { 34 | barId: string 35 | beatsPerBar: number 36 | cursor: number 37 | onRemove: () => void 38 | } 39 | 40 | export default ({ barId, beatsPerBar, cursor, onRemove }: Props) => { 41 | const dispatch = useDispatch() 42 | const steps = useSelector(selectSteps)(barId) 43 | const soundName = useSelector(selectSoundName)(barId) 44 | const sound = useRef(null) 45 | 46 | useEffect(() => { 47 | if (soundName !== "") { 48 | setTimeout(() => { 49 | sound.current = loadSound(soundName) 50 | }) 51 | } 52 | }, [soundName]) 53 | 54 | useEffect(() => { 55 | const freq = steps[cursor] 56 | if (freq !== null) { 57 | if (sound.current === null) { 58 | //FIXME: Use proper timing 59 | dispatch(setPlayFrequency(null)) 60 | setTimeout(() => void dispatch(setPlayFrequency(freq))) 61 | } else { 62 | sound.current.stop() 63 | sound.current.play(freq, 0.01) 64 | } 65 | } 66 | }, [cursor]) 67 | 68 | return ( 69 |
70 | void dispatch(setSoundName({ barId, soundName: name }))} 73 | defaultText="Editor Sound" 74 | title="Bar's Instrument" 75 | /> 76 |
77 | {steps.map((step, index) => ( 78 | 86 | ))} 87 |
88 | 89 | 90 | 91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /src/components/Sequencer/BarSettings.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/core" 3 | import { useDispatch, useSelector } from "react-redux" 4 | import { 5 | setBPM, 6 | setNotesPerBeat, 7 | setBeatsPerBar, 8 | selectBPM, 9 | selectNotesPerBeat, 10 | selectBeatsPerBar, 11 | } from "../../features/sounds/soundsSlice" 12 | 13 | const selectStyle = css` 14 | font-size: 0.8rem !important; 15 | padding: 1px 2px !important; 16 | margin-left: 2px; 17 | ` 18 | 19 | export default () => { 20 | const dispatch = useDispatch() 21 | const BPM = useSelector(selectBPM) 22 | const notesPerBeat = useSelector(selectNotesPerBeat) 23 | const beatsPerBar = useSelector(selectBeatsPerBar) 24 | 25 | return ( 26 |
34 |
35 | BPM: 36 | dispatch(setBPM(event.currentTarget.valueAsNumber))} 42 | /> 43 |
44 |
45 | Beats: 46 | dispatch(setBeatsPerBar(event.currentTarget.valueAsNumber))} 53 | /> 54 |
55 |
56 | Notes/Beat: 57 | dispatch(setNotesPerBeat(event.currentTarget.valueAsNumber))} 64 | /> 65 |
66 |
67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Sequencer/PlaybackControls.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 4 | import { IconButton } from "../../styled" 5 | 6 | type Props = { 7 | playing: boolean 8 | setPlaying: (playing: boolean) => void 9 | } 10 | 11 | export default ({ playing, setPlaying }: Props) => { 12 | return ( 13 |
14 | setPlaying(true)} disabled={playing}> 15 | 16 | 17 | setPlaying(false)} disabled={!playing}> 18 | 19 | 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Sequencer/Sequencer.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core" 3 | import { useState, useEffect, useMemo } from "react" 4 | import { useDispatch, useSelector } from "react-redux" 5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 6 | import PlaybackControls from "./PlaybackControls" 7 | import { sound } from "../../scripts/audio" 8 | import useTimer from "../../hooks/useTimer" 9 | import { 10 | selectBPM, 11 | selectBeatsPerBar, 12 | selectBarKeys, 13 | selectNotesPerBeat, 14 | selectStepsPerBar, 15 | selectBars, 16 | addBar, 17 | delBar, 18 | } from "../../features/sounds/soundsSlice" 19 | import { selectName } from "../../features/activeSound/activeSoundSlice" 20 | import { IconButton } from "../../styled" 21 | import BarSettings from "./BarSettings" 22 | import Bar from "./Bar" 23 | import Widget from "../misc/Widget" 24 | 25 | export default () => { 26 | const dispatch = useDispatch() 27 | const barKeys = useSelector(selectBarKeys) 28 | const BPM = useSelector(selectBPM) 29 | const notesPerBeat = useSelector(selectNotesPerBeat) 30 | const beatsPerBar = useSelector(selectBeatsPerBar) 31 | const stepsPerBar = useSelector(selectStepsPerBar) 32 | const bars = useSelector(selectBars) 33 | const name = useSelector(selectName) 34 | 35 | const baseBPMPerOneSecond = 60 36 | const barsPerSequence = 1 37 | const totalSteps = stepsPerBar * barsPerSequence 38 | const totalBeats = beatsPerBar * barsPerSequence 39 | const timePerSequence = useMemo(() => (baseBPMPerOneSecond / BPM) * 1000 * totalBeats, [ 40 | baseBPMPerOneSecond, 41 | BPM, 42 | totalBeats, 43 | ]) 44 | const timePerStep = useMemo(() => timePerSequence / totalSteps, [timePerSequence, totalSteps]) 45 | const [cursor, setCursor] = useState(0) 46 | const [playing, setPlaying] = useState(false) 47 | const [showSettings, setShowSettings] = useState(false) 48 | 49 | useTimer( 50 | () => { 51 | setCursor(cursor < totalSteps - 1 ? cursor + 1 : 0) 52 | }, 53 | playing ? timePerStep : null 54 | ) 55 | 56 | useEffect(() => { 57 | if (cursor > stepsPerBar) { 58 | setCursor(0) 59 | } 60 | // eslint-disable-next-line react-hooks/exhaustive-deps 61 | }, [stepsPerBar]) 62 | 63 | useEffect(() => { 64 | if (!playing) { 65 | sound.stop() 66 | } 67 | }, [playing]) 68 | 69 | useEffect(() => { 70 | localStorage.setItem("sequencer", JSON.stringify({ BPM, notesPerBeat, beatsPerBar, bars })) 71 | }, [BPM, notesPerBeat, beatsPerBar, bars]) 72 | 73 | return ( 74 | 75 |
76 | 77 |
78 | setShowSettings(!showSettings)} 80 | title="Open/Close Bar Settings" 81 | > 82 | 83 | 84 | void dispatch(addBar(name))} title="Add Bar"> 85 | 86 | 87 |
88 |
89 | {showSettings && } 90 |
91 | {barKeys.map(barKey => ( 92 | void dispatch(delBar(barKey))} 96 | /> 97 | ))} 98 |
99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/components/Sequencer/Step.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/core" 3 | import { useDispatch } from "react-redux" 4 | import { setStep } from "../../features/sounds/soundsSlice" 5 | import { StepValue } from "../../audio.d" 6 | 7 | const beatStyle = css` 8 | padding: 1px; 9 | flex-grow: 1; 10 | ` 11 | 12 | const newBeatStyle = css` 13 | background-color: #ccc; 14 | ` 15 | 16 | const dotStyle = css` 17 | background-color: #000; 18 | border: 2px solid #333; 19 | height: 35px; 20 | min-width: 5px; 21 | ` 22 | 23 | const dotOnStyle = css` 24 | background-color: #fff; 25 | ` 26 | 27 | const dotActiveStyle = css` 28 | border-color: #00ff00; 29 | ` 30 | 31 | type Props = { 32 | barId: string 33 | stepNr: number 34 | step: StepValue 35 | secondary: boolean 36 | active: boolean 37 | } 38 | 39 | export default ({ barId, stepNr, step, secondary, active }: Props) => { 40 | const dispatch = useDispatch() 41 | 42 | return ( 43 |
void dispatch(setStep({ barId, stepNr, step: step === null ? 440 : null }))} 46 | > 47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/components/Sequencer/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ideas: https://github.com/TheRobBrennan/react-808 3 | */ 4 | export { default } from "./Sequencer" 5 | -------------------------------------------------------------------------------- /src/components/misc/Brand.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/core" 3 | 4 | export default () => { 5 | return ( 6 |
21 |

22 | WAA 23 | 32 | π 33 | 34 | Synth 35 | 36 | v{process.env.REACT_APP_VERSION} 37 | 38 |

39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/misc/LocalSoundSelect.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | /** @jsx jsx */ 3 | import { useRef } from "react" 4 | import { jsx } from "@emotion/core" 5 | import { useSelector } from "react-redux" 6 | import { selectSoundNames } from "../../features/sounds/soundsSlice" 7 | 8 | type Props = { 9 | defaultText?: string 10 | onChange?: (name: string) => void 11 | unchangeable?: boolean 12 | disabled?: boolean 13 | selected?: string 14 | title?: string 15 | } 16 | 17 | export default ({ defaultText, onChange, unchangeable, disabled, selected, title }: Props) => { 18 | const soundNames = useSelector(selectSoundNames) 19 | const select = useRef(null) 20 | 21 | return ( 22 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/misc/Widget.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/core" 3 | import { useState, Fragment, PropsWithChildren } from "react" 4 | 5 | const titleStyle = css` 6 | border-top: 2px solid #000; 7 | // background-color: #11151c !important; 8 | background: linear-gradient(0deg, #000 0%, #11151c 100%); 9 | color: #d66853 !important; 10 | font-family: Tomorrow; 11 | font-size: 0.75rem; 12 | font-weight: 500; 13 | text-align: center; 14 | padding: 2px 0 4px; 15 | cursor: pointer; 16 | ` 17 | 18 | type Props = { 19 | title: string 20 | } 21 | 22 | export default ({ title, children }: PropsWithChildren) => { 23 | const [show, setShow] = useState(true) 24 | return ( 25 | 26 |
setShow(!show)}> 27 | {title} 28 |
29 | {show && children} 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/features/activeSound/activeSoundSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit" 2 | import { Analyser, Gain, BiquadFilter, Oscillator } from "../../audio" 3 | import { RootState } from "../../store" 4 | 5 | type Connect = { 6 | source: string 7 | target: string 8 | } 9 | 10 | type ActiveSound = { 11 | name: string 12 | playFrequency: number | null 13 | analysers: Analyser[] 14 | gains: Gain[] 15 | biquadFilters: BiquadFilter[] 16 | oscillators: Oscillator[] 17 | } 18 | 19 | const initialState: ActiveSound = { 20 | name: "", 21 | playFrequency: null, 22 | analysers: [], 23 | gains: [], 24 | biquadFilters: [], 25 | oscillators: [], 26 | } 27 | 28 | const activeSoundSlice = createSlice({ 29 | name: "activeSound", 30 | initialState, 31 | reducers: { 32 | setName: (state: ActiveSound, { payload }: PayloadAction) => { 33 | state.name = payload 34 | }, 35 | setPlayFrequency: (state: ActiveSound, { payload }: PayloadAction) => { 36 | state.playFrequency = payload 37 | }, 38 | addConnect: (state: ActiveSound, { payload }: PayloadAction) => { 39 | ;[state.analysers, state.gains, state.biquadFilters, state.oscillators] 40 | .flat() 41 | .find(el => el.id === payload.source) 42 | ?.connectIds.push(payload.target) 43 | }, 44 | delConnect: (state: ActiveSound, { payload }: PayloadAction) => { 45 | const node = [state.analysers, state.gains, state.biquadFilters, state.oscillators] 46 | .flat() 47 | .find(el => el.id === payload.source) 48 | if (node !== undefined) { 49 | node.connectIds = node.connectIds.filter(id => id !== payload.target) 50 | } 51 | }, 52 | emptyNodes: state => { 53 | // state.analysers = [] 54 | // state.gains = [] 55 | // state.biquadFilters = [] 56 | // state.oscillators = [] 57 | state.analysers.length = 0 58 | state.gains.length = 0 59 | state.biquadFilters.length = 0 60 | state.oscillators.length = 0 61 | }, 62 | 63 | setAnalyser: (state: ActiveSound, { payload }: PayloadAction) => { 64 | const index = state.analysers.findIndex(node => node.id === payload.id) 65 | if (index === -1) { 66 | state.analysers.push(payload) 67 | } else { 68 | state.analysers[index] = payload 69 | } 70 | }, 71 | delAnalyser: (state: ActiveSound, { payload }: PayloadAction) => { 72 | state.analysers = state.analysers.filter(node => node.id !== payload) 73 | }, 74 | 75 | setGain: (state: ActiveSound, { payload }: PayloadAction) => { 76 | const index = state.gains.findIndex(node => node.id === payload.id) 77 | if (index === -1) { 78 | state.gains.push(payload) 79 | } else { 80 | state.gains[index] = payload 81 | } 82 | }, 83 | delGain: (state: ActiveSound, { payload }: PayloadAction) => { 84 | state.gains = state.gains.filter(node => node.id !== payload) 85 | }, 86 | 87 | setBiquadFilter: (state: ActiveSound, { payload }: PayloadAction) => { 88 | const index = state.biquadFilters.findIndex(node => node.id === payload.id) 89 | if (index === -1) { 90 | state.biquadFilters.push(payload) 91 | } else { 92 | state.biquadFilters[index] = payload 93 | } 94 | }, 95 | delBiquadFilter: (state: ActiveSound, { payload }: PayloadAction) => { 96 | state.biquadFilters = state.biquadFilters.filter(node => node.id !== payload) 97 | }, 98 | 99 | setOscillator: (state: ActiveSound, { payload }: PayloadAction) => { 100 | const index = state.oscillators.findIndex(node => node.id === payload.id) 101 | if (index === -1) { 102 | state.oscillators.push(payload) 103 | } else { 104 | state.oscillators[index] = payload 105 | } 106 | }, 107 | delOscillator: (state: ActiveSound, { payload }: PayloadAction) => { 108 | state.oscillators = state.oscillators.filter(node => node.id !== payload) 109 | }, 110 | }, 111 | }) 112 | 113 | export const selectName = ({ activeSound }: RootState) => activeSound.name 114 | export const selectPlayFrequency = ({ activeSound }: RootState) => activeSound.playFrequency 115 | export const selectAudioNodes = ({ activeSound }: RootState) => ({ 116 | analysers: activeSound.analysers, 117 | gains: activeSound.gains, 118 | biquadFilters: activeSound.biquadFilters, 119 | oscillators: activeSound.oscillators, 120 | }) 121 | 122 | export const selectAnalyser = ({ activeSound }: RootState) => (id: string) => 123 | activeSound.analysers.find(node => node.id === id) 124 | export const selectAnalysers = ({ activeSound }: RootState) => activeSound.analysers 125 | 126 | export const selectGain = ({ activeSound }: RootState) => (id: string) => 127 | activeSound.gains.find(node => node.id === id) 128 | export const selectGains = ({ activeSound }: RootState) => activeSound.gains 129 | 130 | export const selectBiquadFilter = ({ activeSound }: RootState) => (id: string) => 131 | activeSound.biquadFilters.find(node => node.id === id) 132 | export const selectBiquadFilters = ({ activeSound }: RootState) => activeSound.biquadFilters 133 | 134 | export const selectOscillator = ({ activeSound }: RootState) => (id: string) => 135 | activeSound.oscillators.find(node => node.id === id) 136 | export const selectOscillators = ({ activeSound }: RootState) => activeSound.oscillators 137 | 138 | export const { 139 | setName, 140 | setPlayFrequency, 141 | addConnect, 142 | delConnect, 143 | emptyNodes, 144 | setAnalyser, 145 | delAnalyser, 146 | setGain, 147 | delGain, 148 | setBiquadFilter, 149 | delBiquadFilter, 150 | setOscillator, 151 | delOscillator, 152 | } = activeSoundSlice.actions 153 | export default activeSoundSlice.reducer 154 | -------------------------------------------------------------------------------- /src/features/sounds/soundsSlice.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid" 2 | import { createSlice, PayloadAction } from "@reduxjs/toolkit" 3 | import { validateSound } from "../../scripts/helpers" 4 | import { Bars, StepValue, Sounds } from "../../audio.d" 5 | import { RootState } from "../../store" 6 | 7 | const retreiveSoundNames = () => 8 | Object.keys(localStorage).filter(name => { 9 | let obj 10 | try { 11 | obj = JSON.parse(localStorage[name]) 12 | } catch { 13 | return false 14 | } 15 | return validateSound(obj) 16 | }) 17 | 18 | const local: Partial = 19 | localStorage.getItem("sequencer") !== null ? JSON.parse(localStorage.getItem("sequencer")!) : {} 20 | 21 | const initialState: Sounds = { 22 | BPM: local.BPM ?? 140, 23 | notesPerBeat: local.notesPerBeat ?? 4, 24 | beatsPerBar: local.beatsPerBar ?? 4, 25 | bars: local.bars ?? {}, 26 | soundNames: retreiveSoundNames(), 27 | } 28 | 29 | const soundsSlice = createSlice({ 30 | name: "sounds", 31 | initialState, 32 | reducers: { 33 | setBPM: (state: Sounds, { payload }: PayloadAction) => { 34 | state.BPM = payload 35 | }, 36 | setNotesPerBeat: (state: Sounds, { payload }: PayloadAction) => { 37 | state.notesPerBeat = payload 38 | // resize all the bars 39 | const barLength = state.notesPerBeat * state.beatsPerBar 40 | Object.values(state.bars).forEach(bar => { 41 | const oldLength = bar.steps.length 42 | bar.steps.length = barLength 43 | bar.steps.fill(null, oldLength) 44 | }) 45 | }, 46 | setBeatsPerBar: (state: Sounds, { payload }: PayloadAction) => { 47 | state.beatsPerBar = payload 48 | // resize all the bars 49 | const barLength = state.notesPerBeat * state.beatsPerBar 50 | Object.values(state.bars).forEach(bar => { 51 | const oldLength = bar.steps.length 52 | bar.steps.length = barLength 53 | bar.steps.fill(null, oldLength) 54 | }) 55 | }, 56 | setBars: (state: Sounds, { payload }: PayloadAction) => { 57 | state.bars = payload 58 | }, 59 | addBar: (state: Sounds, { payload }: PayloadAction) => { 60 | state.bars[uuidv4()] = { 61 | // soundName: payload, 62 | soundName: "", 63 | steps: new Array(state.notesPerBeat * state.beatsPerBar).fill(null), 64 | } 65 | }, 66 | delBar: (state: Sounds, { payload }: PayloadAction) => { 67 | delete state.bars[payload] 68 | }, 69 | setStep: ( 70 | state: Sounds, 71 | { payload }: PayloadAction<{ barId: string; stepNr: number; step: StepValue }> 72 | ) => { 73 | state.bars[payload.barId].steps[payload.stepNr] = payload.step 74 | }, 75 | setSoundName: ( 76 | state: Sounds, 77 | { payload }: PayloadAction<{ barId: string; soundName: string }> 78 | ) => { 79 | state.bars[payload.barId].soundName = payload.soundName 80 | }, 81 | refreshSoundNames: state => { 82 | state.soundNames = retreiveSoundNames() 83 | }, 84 | resetSoundsState: state => { 85 | Object.entries(initialState).forEach(([key, value]) => { 86 | // @ts-ignore 87 | state[key] = value 88 | }) 89 | }, 90 | }, 91 | }) 92 | 93 | export const { 94 | setBPM, 95 | setNotesPerBeat, 96 | setBeatsPerBar, 97 | setBars, 98 | addBar, 99 | delBar, 100 | setStep, 101 | setSoundName, 102 | refreshSoundNames, 103 | resetSoundsState, 104 | } = soundsSlice.actions 105 | 106 | export const selectBPM = ({ sounds }: RootState) => sounds.BPM 107 | export const selectNotesPerBeat = ({ sounds }: RootState) => sounds.notesPerBeat 108 | export const selectBeatsPerBar = ({ sounds }: RootState) => sounds.beatsPerBar 109 | export const selectBarKeys = ({ sounds }: RootState) => Object.keys(sounds.bars) 110 | export const selectBars = ({ sounds }: RootState) => sounds.bars 111 | export const selectStepsPerBar = ({ sounds }: RootState) => sounds.notesPerBeat * sounds.beatsPerBar 112 | export const selectSteps = ({ sounds }: RootState) => (barId: string) => sounds.bars[barId].steps 113 | export const selectStep = ({ sounds }: RootState) => (barId: string, stepNr: number) => 114 | sounds.bars[barId].steps[stepNr] 115 | export const selectSoundName = ({ sounds }: RootState) => (barId: string) => 116 | sounds.bars[barId].soundName 117 | export const selectSoundNames = ({ sounds }: RootState) => sounds.soundNames 118 | 119 | export default soundsSlice.reducer 120 | -------------------------------------------------------------------------------- /src/features/ux/uxSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit" 2 | import { Elements } from "react-flow-renderer" 3 | import { RootState } from "../../store" 4 | 5 | type UX = { 6 | /** 7 | * Tells if popup menu visible 8 | */ 9 | menu: boolean 10 | 11 | /** 12 | * All audio node on graph become editable 13 | */ 14 | editMode: boolean 15 | 16 | /** 17 | * Load _**React-Flow** Elements_ from local storage, 18 | * `const setElements = useStoreActions(actions => actions.setElements)` 19 | * in Load component somehow doesn't work. 20 | */ 21 | loadElements: Elements | null 22 | 23 | /** 24 | * Set to true to del selected in AudioGraph component 25 | * */ 26 | delSelected: boolean 27 | 28 | /** 29 | * Sidebar on the left 30 | */ 31 | sideLeft: boolean 32 | } 33 | 34 | const initialState: UX = { 35 | menu: false, 36 | editMode: true, 37 | loadElements: null, 38 | delSelected: false, 39 | sideLeft: false, 40 | } 41 | 42 | const uxSlice = createSlice({ 43 | name: "ux", 44 | initialState, 45 | reducers: { 46 | toggleMenu: state => { 47 | state.menu = !state.menu 48 | }, 49 | toggleEditMode: state => { 50 | state.editMode = !state.editMode 51 | }, 52 | toggleDelSelected: state => { 53 | state.delSelected = !state.delSelected 54 | }, 55 | toggleSideLeft: state => { 56 | state.sideLeft = !state.sideLeft 57 | }, 58 | setLoadElements: (state: UX, { payload }: PayloadAction) => { 59 | state.loadElements = payload 60 | }, 61 | }, 62 | }) 63 | 64 | export const selectMenu = ({ ux }: RootState) => ux.menu 65 | export const selectEditMode = ({ ux }: RootState) => ux.editMode 66 | export const selectDelSelected = ({ ux }: RootState) => ux.delSelected 67 | export const selectSideLeft = ({ ux }: RootState) => ux.sideLeft 68 | export const selectLoadElements = ({ ux }: RootState) => ux.loadElements 69 | export const { 70 | toggleMenu, 71 | toggleEditMode, 72 | toggleDelSelected, 73 | toggleSideLeft, 74 | setLoadElements, 75 | } = uxSlice.actions 76 | export default uxSlice.reducer 77 | -------------------------------------------------------------------------------- /src/hooks/useAudioNodeDefs.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { AudioNodeType } from "../audio.d" 3 | 4 | export type AudioParams = { 5 | [key: string]: { 6 | automationRate: "a-rate" 7 | minValue: number 8 | maxValue: number 9 | defaultValue: number 10 | } 11 | } 12 | 13 | export default (type: AudioNodeType) => { 14 | const [numberOfInputs] = useState(type === "OscillatorNode" ? 0 : 1) 15 | const [numberOfOutputs] = useState(1) 16 | const [audioParams] = useState( 17 | (): AudioParams => { 18 | switch (type) { 19 | case "BiquadFilterNode": 20 | return { 21 | frequency: { 22 | automationRate: "a-rate", 23 | minValue: 10, 24 | /** half of the sample rate */ 25 | maxValue: 24000, 26 | defaultValue: 350, 27 | }, 28 | detune: { 29 | automationRate: "a-rate", 30 | defaultValue: 0, 31 | minValue: -153600, 32 | maxValue: 153600, 33 | }, 34 | Q: { 35 | automationRate: "a-rate", 36 | defaultValue: 1, 37 | minValue: 0.0001, 38 | maxValue: 1000, 39 | }, 40 | gain: { 41 | automationRate: "a-rate", 42 | /** dB value */ 43 | defaultValue: 0, 44 | minValue: -40, 45 | maxValue: 40, 46 | }, 47 | } 48 | case "GainNode": 49 | return { 50 | gain: { 51 | automationRate: "a-rate", 52 | minValue: 0, 53 | /** half of the sample rate */ 54 | maxValue: 1, 55 | defaultValue: 1, 56 | }, 57 | } 58 | case "OscillatorNode": 59 | return { 60 | frequency: { 61 | automationRate: "a-rate", 62 | minValue: -24000, 63 | maxValue: 24000, 64 | defaultValue: 440, 65 | }, 66 | detune: { 67 | automationRate: "a-rate", 68 | defaultValue: 0, 69 | minValue: -153600, 70 | maxValue: 153600, 71 | }, 72 | } 73 | } 74 | return {} 75 | } 76 | ) 77 | 78 | return { 79 | numberOfInputs, 80 | numberOfOutputs, 81 | audioParams, 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/hooks/useAudioParamKeys.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react" 2 | 3 | export default (audioNode: AudioNode) => { 4 | const keys = useRef( 5 | Object.keys(audioNode.constructor.prototype).filter( 6 | // @ts-ignore 7 | key => audioNode[key].constructor === AudioParam 8 | ) 9 | ) 10 | 11 | return keys.current 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/useTimer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import { useState, useEffect, useRef } from "react" 3 | 4 | const useTimer = (callback: () => void, ms: number | null) => { 5 | const [time, setTime] = useState(false) 6 | const timeRef = useRef(false) 7 | 8 | useEffect(() => { 9 | let timer: number 10 | if (ms !== null) { 11 | timer = window.setInterval(() => { 12 | timeRef.current = !timeRef.current 13 | setTime(timeRef.current) 14 | }, ms) 15 | } 16 | return () => { 17 | if (ms !== null) { 18 | window.clearInterval(timer) 19 | } 20 | } 21 | }, [ms]) 22 | 23 | useEffect(() => { 24 | if (ms !== null) { 25 | callback() 26 | } 27 | }, [time]) 28 | } 29 | 30 | export default useTimer 31 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom" 2 | import { Provider } from "react-redux" 3 | import React, { Fragment } from "react" 4 | import { Global } from "@emotion/core" 5 | import * as serviceWorker from "./serviceWorker" 6 | import { fal } from "@fortawesome/pro-light-svg-icons" 7 | import { fas } from "@fortawesome/pro-solid-svg-icons" 8 | import { fab } from "@fortawesome/free-brands-svg-icons" 9 | import { fad } from "@fortawesome/pro-duotone-svg-icons" 10 | import { far } from "@fortawesome/pro-regular-svg-icons" 11 | import { library } from "@fortawesome/fontawesome-svg-core" 12 | import "react-toastify/dist/ReactToastify.css" 13 | import { globalStyles } from "./styled" 14 | import store from "./store" 15 | import App from "./App" 16 | 17 | //TODO: collect icons and check sizes https://fontawesome.com/how-to-use/on-the-web/using-with/react#using 18 | library.add(fab, fad, fal, far, fas) 19 | 20 | ReactDOM.render( 21 | // 22 | 23 | 24 | 25 | 26 | 27 | , 28 | // , 29 | document.getElementById("root") 30 | ) 31 | 32 | // If you want your app to work offline and load faster, you can change 33 | // unregister() to register() below. Note this comes with some pitfalls. 34 | // Learn more about service workers: https://bit.ly/CRA-PWA 35 | serviceWorker.register() 36 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/scripts/Sound.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AudioNodeType, 3 | AudioParamSetting, 4 | BaseNode, 5 | AUDIO_CONTEXT_DESTINATION, 6 | Gain, 7 | Analyser, 8 | BiquadFilter, 9 | Oscillator, 10 | } from "../audio.d" 11 | 12 | type SoundNode = { 13 | id: string 14 | audioNode?: AudioNode 15 | connectIds: string[] 16 | type: AudioNodeType 17 | attrs: { [key: string]: any } 18 | params: AudioParamSetting[] 19 | startTime?: number 20 | } 21 | 22 | const soundNodeFactory = (node: BaseNode, type: AudioNodeType): SoundNode => { 23 | return { 24 | id: node.id, 25 | connectIds: [], 26 | type, 27 | attrs: {}, 28 | params: [], 29 | } 30 | } 31 | 32 | export default class { 33 | audioCtx: BaseAudioContext | null = null 34 | nodes = new Map() 35 | 36 | constructor(audioContext: BaseAudioContext) { 37 | this.audioCtx = audioContext 38 | } 39 | 40 | destroyAudioNodes() { 41 | this.nodes.forEach(node => { 42 | node.audioNode = undefined 43 | }) 44 | this.nodes.clear() 45 | this.nodes = new Map() 46 | } 47 | 48 | applyParams(node: AudioNode, params: AudioParamSetting[], time?: number) { 49 | if (this.audioCtx === null) return 50 | if (time === undefined) { 51 | time = this.audioCtx.currentTime 52 | } 53 | params.forEach(param => { 54 | const values = [...param.values] 55 | if ( 56 | [ 57 | "setValueAtTime", 58 | "linearRampToValueAtTime", 59 | "exponentialRampToValueAtTime", 60 | "setTargetAtTime", 61 | "setValueCurveAtTime", 62 | ].includes(param.call) 63 | ) { 64 | // @ts-ignore 65 | values[1] += time 66 | } 67 | if (["cancelScheduledValues", "cancelAndHoldAtTime"].includes(param.call)) { 68 | // @ts-ignore 69 | values[0] += time 70 | } 71 | 72 | // @ts-ignore 73 | node[param.name][param.call](...values) 74 | }) 75 | } 76 | 77 | setGain(node: Gain) { 78 | let soundNode = this.nodes.get(node.id) || soundNodeFactory(node, "GainNode") 79 | soundNode.connectIds = node.connectIds 80 | soundNode.params = node.params 81 | if (!this.nodes.has(node.id)) { 82 | this.nodes.set(node.id, soundNode) 83 | } 84 | } 85 | 86 | setAnalyser(node: Analyser) { 87 | let soundNode = this.nodes.get(node.id) || soundNodeFactory(node, "AnalyserNode") 88 | soundNode.connectIds = node.connectIds 89 | soundNode.attrs.fftSize = node.fftSize 90 | if (soundNode.audioNode !== undefined) { 91 | ;(soundNode.audioNode as AnalyserNode).fftSize = soundNode.attrs.fftSize 92 | } 93 | if (!this.nodes.has(node.id)) { 94 | this.nodes.set(node.id, soundNode) 95 | } 96 | } 97 | 98 | setBiquadFilter(node: BiquadFilter) { 99 | let soundNode = this.nodes.get(node.id) || soundNodeFactory(node, "BiquadFilterNode") 100 | soundNode.connectIds = node.connectIds 101 | soundNode.attrs.type = node.type 102 | soundNode.params = node.params 103 | if (soundNode.audioNode !== undefined) { 104 | ;(soundNode.audioNode as BiquadFilterNode).type = soundNode.attrs.type 105 | } 106 | if (!this.nodes.has(node.id)) { 107 | this.nodes.set(node.id, soundNode) 108 | } 109 | } 110 | 111 | setOscillator(node: Oscillator) { 112 | let soundNode = this.nodes.get(node.id) || soundNodeFactory(node, "OscillatorNode") 113 | soundNode.connectIds = node.connectIds 114 | soundNode.attrs.type = node.type 115 | soundNode.params = node.params 116 | if (!this.nodes.has(node.id)) { 117 | this.nodes.set(node.id, soundNode) 118 | } 119 | } 120 | 121 | delNode(id: string) { 122 | this.nodes.delete(id) 123 | } 124 | 125 | addConnect(id: string, target: string) { 126 | const node = this.nodes.get(id) 127 | if (node) { 128 | node.connectIds = [...node.connectIds, target] 129 | } 130 | } 131 | 132 | delConnect(id: string, target: string) { 133 | const node = this.nodes.get(id) 134 | if (node) { 135 | node.connectIds = node.connectIds.filter(toId => toId !== target) 136 | } 137 | } 138 | 139 | play(frequency: number, playDelay = 0.01) { 140 | if (this.audioCtx === null) return 141 | const t = this.audioCtx.currentTime + playDelay 142 | 143 | this.nodes.forEach(node => { 144 | if (node.type === "OscillatorNode") { 145 | node.audioNode = this.audioCtx!.createOscillator() 146 | ;(node.audioNode as OscillatorNode).type = node.attrs.type 147 | ;(node.audioNode as OscillatorNode).frequency.setValueAtTime(frequency, 0) 148 | ;(node.audioNode as OscillatorNode).start(t) 149 | node.startTime = t 150 | } else { 151 | if (node.audioNode === undefined) { 152 | switch (node.type) { 153 | case "AnalyserNode": 154 | node.audioNode = this.audioCtx!.createAnalyser() 155 | ;(node.audioNode as AnalyserNode).fftSize = node.attrs.fftSize 156 | break 157 | case "BiquadFilterNode": 158 | node.audioNode = this.audioCtx!.createBiquadFilter() 159 | ;(node.audioNode as BiquadFilterNode).type = node.attrs.type 160 | break 161 | case "GainNode": 162 | node.audioNode = this.audioCtx!.createGain() 163 | break 164 | } 165 | } 166 | } 167 | if (node.params.length > 0) { 168 | this.applyParams(node.audioNode!, node.params, t) 169 | } 170 | }) 171 | 172 | this.nodes.forEach(node => { 173 | node.connectIds.forEach(toId => { 174 | node.audioNode!.connect( 175 | toId === AUDIO_CONTEXT_DESTINATION 176 | ? this.audioCtx!.destination 177 | : this.nodes.get(toId)!.audioNode! 178 | ) 179 | }) 180 | }) 181 | } 182 | 183 | stop(stopDelay?: number) { 184 | if (this.audioCtx === null) return 185 | this.nodes.forEach(node => { 186 | if (node.audioNode === undefined) return 187 | 188 | if (node.type === "OscillatorNode") { 189 | let stopTime: number 190 | 191 | if ( 192 | stopDelay === undefined && 193 | node.startTime !== undefined && 194 | node.params.every(param => param.name !== "frequency") 195 | ) { 196 | //FIXME: Stop without click noise 197 | //https://webaudiotech.com/2017/02/27/stopping-a-web-audio-oscillator-at-cycle-completion/ 198 | let halfCycleDuration = 0.5 / (node.attrs?.frequency ?? 440) //FIXME: read default from setting 199 | let runningTime = this.audioCtx!.currentTime - node.startTime 200 | let completedHalfCycles = Math.floor(runningTime / halfCycleDuration) 201 | let timeOfLastZC = node.startTime + halfCycleDuration * completedHalfCycles 202 | stopTime = timeOfLastZC + halfCycleDuration 203 | } else { 204 | stopTime = this.audioCtx!.currentTime 205 | } 206 | 207 | ;(node.audioNode as OscillatorNode).stop(stopDelay ?? stopTime) 208 | } 209 | // node.connectIds.forEach(toId => { 210 | // if (toId === AUDIO_CONTEXT_DESTINATION) { 211 | // node.audioNode?.disconnect() 212 | // } else { 213 | // node.audioNode?.disconnect(this.nodes.get(toId)!.audioNode!) 214 | // } 215 | // }) 216 | // if (node.type !== undefined && node.audioNode !== undefined) { 217 | // node.audioNode = undefined 218 | // } 219 | }) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/scripts/audio.ts: -------------------------------------------------------------------------------- 1 | import Sound from "./Sound" 2 | import { validateSound } from "./helpers" 3 | import { SynthStore } from "../audio" 4 | 5 | // @ts-ignore 6 | // eslint-disable-next-line no-native-reassign 7 | AudioContext = window.AudioContext || window.webkitAudioContext 8 | export let audioContext = new AudioContext() 9 | 10 | export const sound = new Sound(audioContext) 11 | 12 | export const restartAudioContext = async () => { 13 | await audioContext.close() 14 | audioContext = new AudioContext() 15 | return audioContext 16 | } 17 | 18 | export const loadSound = (name: string, ctx?: BaseAudioContext) => { 19 | const data = localStorage.getItem(name) 20 | if (!data) return null 21 | const obj: SynthStore = JSON.parse(data) 22 | if (!validateSound(obj)) return null 23 | 24 | const s = new Sound(ctx ?? audioContext) 25 | 26 | obj.analysers.forEach(node => s.setAnalyser(node)) 27 | obj.gains.forEach(node => s.setGain(node)) 28 | obj.biquadFilters.forEach(node => s.setBiquadFilter(node)) 29 | obj.oscillators.forEach(node => s.setOscillator(node)) 30 | 31 | return s 32 | } 33 | -------------------------------------------------------------------------------- /src/scripts/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Elements, isNode } from "react-flow-renderer" 2 | import { AUDIO_CONTEXT_DESTINATION } from "../audio.d" 3 | 4 | export const checkSize = (prev: number, next: number) => prev === next 5 | 6 | export const getNextId = (elems: Elements) => 7 | +elems 8 | .filter(el => isNode(el)) 9 | .filter(el => el.id !== AUDIO_CONTEXT_DESTINATION) 10 | .sort((a, b) => +b.id - +a.id)[0]?.id + 1 || 1 11 | 12 | export const validateSound = (obj: any) => Object.keys(obj).some(key => key === "destination") 13 | -------------------------------------------------------------------------------- /src/scripts/setupAudioMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, PayloadAction, Dispatch, AnyAction } from "@reduxjs/toolkit" 2 | import { Gain, Analyser, BiquadFilter, Oscillator } from "../audio" 3 | import { sound } from "./audio" 4 | 5 | const setupAudioMiddleware: Middleware = () => (next: Dispatch) => ( 6 | action: PayloadAction 7 | ) => { 8 | if (action.type.startsWith("activeSound/") && !action.type.endsWith("setName")) { 9 | const asActionType = action.type.substring(action.type.indexOf("/") + 1) 10 | switch (asActionType) { 11 | case "emptyNodes": 12 | sound.destroyAudioNodes() 13 | break 14 | case "setGain": 15 | sound.setGain((action.payload as unknown) as Gain) 16 | break 17 | case "setAnalyser": 18 | sound.setAnalyser((action.payload as unknown) as Analyser) 19 | break 20 | case "setBiquadFilter": 21 | sound.setBiquadFilter((action.payload as unknown) as BiquadFilter) 22 | break 23 | case "setOscillator": 24 | sound.setOscillator((action.payload as unknown) as Oscillator) 25 | break 26 | case "delGain": 27 | case "delAnalyser": 28 | case "delBiquadFilter": 29 | case "delOscillator": 30 | sound.delNode((action.payload as unknown) as string) 31 | break 32 | case "addConnect": 33 | sound.addConnect((action.payload as any).source, (action.payload as any).target) 34 | break 35 | case "delConnect": 36 | sound.delConnect((action.payload as any).source, (action.payload as any).target) 37 | break 38 | case "setPlayFrequency": 39 | if (action.payload === null) { 40 | sound.stop() 41 | } else { 42 | sound.play((action.payload as unknown) as number) 43 | } 44 | break 45 | } 46 | } 47 | 48 | next(action) 49 | } 50 | 51 | export default setupAudioMiddleware 52 | -------------------------------------------------------------------------------- /src/scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { XYPosition } from "react-flow-renderer" 2 | 3 | /** 4 | * Random number between: 5 | * @param min (included) 6 | * @param max (not included) 7 | */ 8 | export const randomBetween = (min: number, max: number) => Math.random() * (max - min) + min 9 | 10 | /** 11 | * Find appropriate position for the coming node. 12 | * @param canvasWidth Audio graph canvas width 13 | * @param canvasHeight Audio graph canvas height 14 | * @param bottom Place should be on the top or on the bottom 15 | */ 16 | export const newNodePosition = ( 17 | canvasWidth: number, 18 | canvasHeight: number, 19 | bottom = false 20 | ): XYPosition => { 21 | const halfHeight = canvasHeight / 2 22 | return { 23 | x: randomBetween(0, canvasWidth - 200), 24 | y: bottom ? randomBetween(halfHeight, canvasHeight) : randomBetween(0, halfHeight), 25 | } 26 | } 27 | 28 | /** 29 | * Fix canvas blur problem 30 | * @param canvas Canvas DOM element 31 | * @returns Dimensions array 32 | */ 33 | export const dpiFix = (canvas: HTMLCanvasElement) => { 34 | const width = +getComputedStyle(canvas).getPropertyValue("width").slice(0, -2) 35 | const height = +getComputedStyle(canvas).getPropertyValue("height").slice(0, -2) 36 | const dpi = window.devicePixelRatio 37 | canvas.setAttribute("width", (width * dpi).toString()) 38 | canvas.setAttribute("height", (height * dpi).toString()) 39 | return { width, height } 40 | } 41 | 42 | /** 43 | * Format large number with spaces 44 | * @param num Float or integer number 45 | * @return Formatted number 46 | */ 47 | export const formatNumber = (num: number) => { 48 | var parts = num.toString().split(".") 49 | parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " ") 50 | return parts.join(".") 51 | } 52 | -------------------------------------------------------------------------------- /src/scripts/wav.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wav header description: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html 3 | */ 4 | export const wavHeader = ( 5 | numOfChan = 1, 6 | sampleRate = 48_000, 7 | sampleSize = 32, 8 | fileSize = 44 9 | ): ArrayBuffer => { 10 | const buffer = new ArrayBuffer(44) 11 | let view = new DataView(buffer) 12 | let pos = 0 13 | 14 | const setUint16 = (data: number) => { 15 | view.setUint16(pos, data, true) 16 | pos += 2 17 | } 18 | 19 | const setUint32 = (data: number) => { 20 | view.setUint32(pos, data, true) 21 | pos += 4 22 | } 23 | 24 | // write WAVE header 25 | setUint32(0x46464952) // "RIFF" in ASCII form (little-endian form) 26 | setUint32(fileSize - 8) // file length - 8 27 | setUint32(0x45564157) // "WAVE" 28 | 29 | setUint32(0x20746d66) // "fmt " chunk 30 | setUint32(16) // length = 16 for PCM - This is the size of therest of the Subchunk which follows this number. 31 | // setUint16(1) // PCM (uncompressed) 32 | setUint16(3) // IEEE FLOAT 33 | setUint16(numOfChan) 34 | setUint32(sampleRate) 35 | 36 | setUint32((sampleRate * numOfChan * sampleSize) / 8) // ByteRate = SampleRate * NumChannels * BitsPerSample/8 37 | setUint16((numOfChan * sampleSize) / 8) // BlockAlign = NumChannels * BitsPerSample/8 38 | setUint16(sampleSize) 39 | 40 | setUint32(0x61746164) // "data" - chunk 41 | // setUint32((length * numOfChan * sampleSize) / 8) // Subchunk2Size = NumSamples * NumChannels * BitsPerSample/8 42 | setUint32(fileSize - 44) 43 | 44 | return buffer 45 | } 46 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, getDefaultMiddleware } from "@reduxjs/toolkit" 2 | import activeSoundSlice from "./features/activeSound/activeSoundSlice" 3 | import setupAudioMiddleware from "./scripts/setupAudioMiddleware" 4 | import soundsSlice from "./features/sounds/soundsSlice" 5 | import uxSlice from "./features/ux/uxSlice" 6 | 7 | const store = configureStore({ 8 | reducer: { 9 | activeSound: activeSoundSlice, 10 | sounds: soundsSlice, 11 | ux: uxSlice, 12 | }, 13 | middleware: [...getDefaultMiddleware(), setupAudioMiddleware], 14 | }) 15 | 16 | export default store 17 | export type RootState = ReturnType 18 | -------------------------------------------------------------------------------- /src/styled.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/core" 2 | import styled from "@emotion/styled" 3 | 4 | export const globalStyles = css` 5 | :root { 6 | --graph-bg: #212d40; 7 | --graph-bg-lines: #364156; 8 | --side-bg: #11151c; 9 | --side-default-width: 350px; 10 | --widget-bg: #364156; 11 | --input-border: #7d4e57; 12 | --input-border-focus: #d66853; 13 | --button-bg: #4f5f7d; 14 | --node-bg: #11151c; 15 | --node-selected-border: #d66853; 16 | } 17 | 18 | @import url("https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400&family=Tomorrow:wght@400;500&display=swap"); 19 | * { 20 | box-sizing: border-box; 21 | } 22 | html, 23 | body { 24 | padding: 0; 25 | margin: 0; 26 | width: 100vw; 27 | height: 100vh; 28 | font-family: Roboto, sans-serif; 29 | font-style: normal; 30 | font-weight: 400; 31 | font-size: 16px; 32 | overflow: hidden; 33 | } 34 | ` 35 | 36 | export const Main = styled.div` 37 | margin: 0; 38 | padding: 0; 39 | width: 100vw; 40 | height: 100vh; 41 | display: flex; 42 | 43 | &.rev { 44 | flex-direction: row-reverse; 45 | 46 | > div:last-of-type { 47 | border-left-width: 0; 48 | border-right-width: 2px; 49 | } 50 | 51 | .menuOpener { 52 | left: 6px; 53 | right: auto; 54 | } 55 | 56 | .menu { 57 | border-bottom-left-radius: 0; 58 | border-bottom-right-radius: 0; 59 | right: auto; 60 | left: 0; 61 | } 62 | } 63 | ` 64 | 65 | export const SideBar = styled.div` 66 | flex-grow: 1; 67 | background-color: var(--side-bg); 68 | color: #fff; 69 | border: 0 solid #000; 70 | border-left-width: 2px; 71 | 72 | .menuOpener { 73 | position: fixed; 74 | top: 6px; 75 | right: 6px; 76 | &:hover { 77 | transform: rotate(90deg); 78 | transition: 500ms; 79 | } 80 | } 81 | 82 | > *:not(.brand):not(.menuOpener):not(.menu) { 83 | /*border-top: 2px solid #000;*/ 84 | background-color: var(--widget-bg); 85 | color: #fff; 86 | 87 | input, 88 | select, 89 | button { 90 | font-size: 0.95rem; 91 | flex-grow: 1; 92 | border-radius: 3px; 93 | background-color: transparent; 94 | border: 1px solid var(--input-border); 95 | font-family: Roboto; 96 | padding: 6px; 97 | color: #fff; 98 | &:focus { 99 | border-color: var(--input-border-focus); 100 | border-width: 2px; 101 | padding: 5px; 102 | } 103 | } 104 | 105 | select option { 106 | background-color: var(--widget-bg); 107 | } 108 | 109 | button { 110 | width: 100%; 111 | background-color: var(--button-bg); 112 | border-width: 2px; 113 | border-style: outset; 114 | cursor: pointer; 115 | &:focus { 116 | padding: 6px; 117 | } 118 | } 119 | } 120 | ` 121 | 122 | export const MenuPopup = styled.div` 123 | top: 0; 124 | right: 0; 125 | width: 380px; 126 | position: fixed; 127 | z-index: 200; 128 | background-color: #000; 129 | padding: 25px; 130 | border-bottom-left-radius: 4px; 131 | box-shadow: 5px 5px 15px 5px #000000; 132 | 133 | a { 134 | color: #66f; 135 | } 136 | 137 | h2 { 138 | text-align: center; 139 | } 140 | 141 | ul { 142 | line-height: 28px; 143 | font-size: 1.1rem; 144 | padding-left: 25px; 145 | } 146 | li { 147 | margin-top: 12px; 148 | } 149 | ` 150 | 151 | export const WidgetRows = styled.div` 152 | padding: 8px 6px; 153 | display: flex; 154 | flex-direction: column; 155 | gap: 6px; 156 | 157 | > div { 158 | align-items: center; 159 | display: flex; 160 | gap: 8px; 161 | } 162 | ` 163 | 164 | export const IconButton = styled.button` 165 | background-color: transparent !important; 166 | width: auto !important; 167 | flex-shrink: 1 !important; 168 | flex-grow: 0 !important; 169 | border-width: 1px !important; 170 | opacity: 0.5; 171 | cursor: not-allowed !important; 172 | 173 | div { 174 | display: inline-block; 175 | margin-left: 8px; 176 | font-size: 0.8rem; 177 | position: relative; 178 | } 179 | 180 | &:not([disabled]) { 181 | cursor: pointer !important; 182 | opacity: 1; 183 | svg { 184 | transition: 50ms; 185 | } 186 | &:hover { 187 | svg { 188 | transition: 50ms; 189 | transform: scale(1.25); 190 | } 191 | div { 192 | font-size: 0.85rem; 193 | top: 1px; 194 | } 195 | } 196 | } 197 | ` 198 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "forceConsistentCasingInFileNames": true, 8 | "allowSyntheticDefaultImports": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "isolatedModules": true, 12 | "skipLibCheck": true, 13 | "declaration": true, 14 | "allowJs": true, 15 | "strict": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["src"] 20 | } 21 | --------------------------------------------------------------------------------