├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── cra-template ├── README.md ├── package.json ├── template.json └── template │ ├── README.md │ ├── gitignore │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── serviceWorker.js │ └── setupTests.js ├── example ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── snare-top-off17.wav │ └── st2_kick_one_shot_low_punch_basic.wav ├── src │ ├── App.css │ ├── App.tsx │ ├── favicon.svg │ ├── index.css │ ├── logo.svg │ ├── main.tsx │ └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── __mocks__ │ ├── startaudiocontext.ts │ └── tone.ts ├── __tests__ │ ├── Effect.test.tsx │ ├── Instrument.test.tsx │ ├── Song.test.tsx │ └── Track.test.tsx ├── components │ ├── Effect.tsx │ ├── Instrument.tsx │ ├── Song.tsx │ └── Track.tsx ├── config │ └── index.ts ├── index.tsx ├── lib │ ├── buildSequencerStep.ts │ ├── hooks │ │ └── index.ts │ ├── tone.ts │ └── utils.ts └── types │ ├── index.d.ts │ └── midi-notes.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "react-app", 4 | "prettier/@typescript-eslint", 5 | "plugin:prettier/recommended" 6 | ], 7 | "settings": { 8 | "react": { 9 | "version": "detect" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 10 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 10.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: ~/.npm 20 | # path: node_modules 21 | key: nodeModules-${{ hashFiles('**/package-lock.json') }} 22 | # key: nodeModules-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | nodeModules- 25 | 26 | - name: Install dependencies 27 | run: yarn install --frozen-lockfile 28 | env: 29 | CI: true 30 | 31 | - name: Lint 32 | run: yarn lint 33 | env: 34 | CI: true 35 | 36 | - name: Test 37 | run: yarn test --ci --coverage --maxWorkers=2 38 | env: 39 | CI: true 40 | 41 | - name: Build 42 | run: yarn build 43 | env: 44 | CI: true 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # builds 7 | build 8 | dist 9 | .next 10 | .cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | *.code-workspace 25 | 26 | .vscode -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: false, 3 | semi: true, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | arrowParens: 'always', 7 | }; 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.8.0] - 2022-10-06 9 | 10 | - Improve `StepType` and Instrument `samples` type 11 | 12 | ## [0.7.0] - 2021-10-12 13 | 14 | - Remove ye olde `prop-types` in favour of `Typescript` types 15 | - Fix ESLint issues 16 | 17 | ## [0.6.2] - 2021-10-10 18 | 19 | - Fix new `samples` bug again, only adding new samples after initial render 20 | 21 | ## [0.6.1] - 2021-10-07 22 | 23 | - Fix bug where subsequent renders weren't adding new `samples` to sampler Instrument 24 | - Add test for above fix 25 | 26 | ## [0.6.0] - 2021-10-04 27 | 28 | - Revert to Typescript 3.7.3 to match TSDX 29 | - Improve `` `onLoad` type 30 | - Change from `string` to `MidiNote` in `StepType` 31 | 32 | ## [0.5.3] - 2021-08-20 33 | 34 | - Fix Track `steps` types and add `string[]`. 35 | 36 | ## [0.5.2] - 2021-05-02 37 | 38 | - Fix `midiNotes` export. 39 | 40 | ## [0.5.1] - 2021-05-01 41 | 42 | - Add `MidiNote` and `midiNotes`. Allow `null` in `StepType`. 43 | 44 | ## [0.5.0] - 2021-05-01 45 | 46 | - Fix Track `steps` updating when new steps are different length to previous `steps`. Can't update steps length while music is playing though, still need to stop and play again. 47 | - Update `StepNoteType` to allow `string` for `duration` 48 | - Remove website folder and move to new repo 49 | 50 | ## [0.4.6] - 2021-03-15 51 | 52 | - Fix bug where object step like `{ name: 'C3' }` works in `` `steps` prop. 53 | 54 | ## [0.4.5] - 2020-07-29 55 | 56 | - Fix iOS audio by moving `StartAudioContext` into body click event. iOS needs a user trigger to enable audio. 57 | 58 | ## [0.4.4] - 2020-07-20 59 | 60 | - Move `Tone.Channel` into `useEffect` and add cleanup function 61 | 62 | ## [0.4.3] - 2020-04-06 63 | 64 | - Oops. Removed console log. 65 | 66 | ## [0.4.2] - 2020-04-04 67 | 68 | - Update Instrument `notes` to not retrigger on every render. Revert back to `isPlaying` check and add unique `key` to `NoteType` to trigger sound. 69 | 70 | ## [0.4.1] - 2020-04-01 71 | 72 | - Ensure new `samples` load even after initial mount of `sampler` type in `Instrument` 73 | 74 | ## [0.4.0] - 2020-03-29 75 | 76 | - Install `tsdx` to manage pain of package config 77 | - Migrate core to Typescript 78 | - Remove Jest, Babel and ESLint, with `tsdx` managing these from now onwards 79 | - Fix tests to work with Typescript 80 | 81 | ## [0.3.5] - 2020-03-20 82 | 83 | - Update Instrument `notes` and allow multi trigger of same note 84 | - Update Rollup config to includeDependencies 85 | - Remove `tone` as peerDep and leave as dep 86 | 87 | ## [0.3.4] - 2020-03-16 88 | 89 | - Add `fast-deep-equal` to improve steps update performance on `Track` 90 | 91 | ## [0.3.3] - 2020-03-08 92 | 93 | - Refactor Track to use `Tone.Channel` instead of `Tone.PanVol` 94 | - Add Track `mute` and `solo` 95 | - Update `propTypes` 96 | 97 | ## [0.3.2] - 2020-03-03 98 | 99 | - Add Create React Template for Reactronica 100 | - Add `eq3` effect type with `low`, `mid`, `high`, `lowFrequency` and `highFrequency` props 101 | 102 | ## [0.3.1] - 2020-02-27 103 | 104 | - Add `duration` and `velocity` for Instrument notes 105 | - Add `onLoad` prop to `Instrument` for `type` of `sampler` 106 | 107 | ## [0.3.0] - 2020-02-07 108 | 109 | - Change `tempo` to `bpm` in `Song` to match `Tone` API 110 | - Add `volume` and `isMuted` prop to `Song` 111 | - Add `polyphony`, `oscillator`, `envelope` props to `Instrument` 112 | - Remove `type` of `polySynth` from `Instrument` as it is just a wrapper around other synths 113 | - Add `membraneSynth`, `metalSynth` and `pluckSynth` types to `Instrument` 114 | - Update `AMSynth` and `FMSynth` values in `Instrument` `type` to `amSynth` and `fmSynth` 115 | - Remove `/example` folder to focus on `/website` that already includes docs and examples 116 | - Change `steps` from `step.note` to `step.name` in `Track` 117 | - Add more Typescript definitions 118 | 119 | - Ensure `Instrument` and `Effect` don't crash if unknown `type` is passed 120 | - Update `onStepPlay` arguments to `StepNoteType[]` and `number`. 121 | - Add `wet` prop to `Effect` 122 | - Update `propTypes` 123 | - Update docs 124 | 125 | ## [0.2.2] - 2019-09-18 126 | 127 | - Add more effect types 128 | 129 | ## [0.2.1] - 2019-09-18 130 | 131 | - Add more effect types 132 | - Add more instrument types 133 | - Update `constants` to `config` 134 | 135 | ## [0.2.0] - 2019-09-10 136 | 137 | - Fix empty steps bug for Track 138 | - Add more instrument types to Instrument 139 | - Add Next JS documentation and examples website 140 | 141 | ## [0.1.1] - 2019-06-23 142 | 143 | - Add website 144 | - Fix sequence note repeat bug by adding JSON.stringify to Track 145 | 146 | ## [0.1.0] - 2019-06-23 147 | 148 | - Refactor library to use React Hooks 149 | - Update StepsEditorExample 150 | - Set up `PUBLIC_URL` for audio files in example 151 | - Add `constants` export for data about instrument and effects types. 152 | 153 | ## [0.0.5] - 2019-06-17 154 | 155 | - Update steps to enable chords to be played 156 | - Refactor example page 157 | - Add ukulele tab example 158 | - Update step editor example 159 | - Add `react-testing-library` for testing examples 160 | - Use css-modules in example 161 | 162 | ## [0.0.4] - 2018-08-25 163 | 164 | - First changelog entry 165 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kaho Cheung 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reactronica 2 | 3 | [https://reactronica.com](https://reactronica.com) 4 | 5 | React audio components for making music in the browser. 6 | 7 | React treats UI as a function of state. This library aims to treat **_music_** as a function of state, rendering sound instead of UI. Visual components live side by side with Reactronica, sharing the same state and elegantly kept in sync. 8 | 9 | Uses [ToneJS](https://tonejs.github.io/) under the hood. Inspired by [React Music](https://github.com/FormidableLabs/react-music). 10 | 11 | > Warning: Highly experimental. APIs will change. 12 | 13 | ## Install 14 | 15 | ```bash 16 | $ npm install --save reactronica 17 | ``` 18 | 19 | Note: Use React version >= 16.8 as [Hooks](https://reactjs.org/docs/hooks-intro.html) are used internally. 20 | 21 | ## Template 22 | 23 | To get started quickly with Create React App and Reactronica, just run the command below: 24 | 25 | ```bash 26 | $ npx create-react-app my-app --template reactronica 27 | ``` 28 | 29 | ## Documentation 30 | 31 | [https://reactronica.com](https://reactronica.com/#documentation) 32 | 33 | ### Components 34 | 35 | - [Song](https://reactronica.com/#song) 36 | - [Track](https://reactronica.com/#track) 37 | - [Instrument](https://reactronica.com/#instrument) 38 | - [Effect](https://reactronica.com/#effect) 39 | 40 | ## Demos 41 | 42 | - [Digital Audio Workstation](https://reactronica.com/daw) 43 | - [Music chord, scale and progression finder](https://music-toolbox.now.sh) 44 | 45 | ## Usage 46 | 47 | ```jsx 48 | import React from 'react'; 49 | import { Song, Track, Instrument, Effect } from 'reactronica'; 50 | 51 | const Example = () => { 52 | return ( 53 | // Top level component must be Song, with Tracks nested inside 54 | 55 | { 78 | console.log(step, index); 79 | }} 80 | > 81 | 82 | {/* Add effects chain here */} 83 | 84 | 85 | 86 | 87 | 88 | { 98 | // Runs when all samples are loaded 99 | }} 100 | /> 101 | 102 | 103 | ); 104 | }; 105 | ``` 106 | 107 | ## Thanks 108 | 109 | - https://tonejs.github.io/ 110 | - https://github.com/FormidableLabs/react-music 111 | - https://github.com/jaredpalmer/tsdx 112 | - https://github.com/crabacus/the-open-source-drumkit for the drum sounds 113 | 114 | ## License 115 | 116 | MIT © [unkleho](https://github.com/unkleho) 117 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | ## New Version 4 | 5 | - Make changes and commit/merge to `main` 6 | - `npm version major|minor|patch` 7 | - `git push && git push --tags` 8 | - `npm publish` 9 | 10 | ## Canary 11 | 12 | - `npm version prerelease --preid=canary` 13 | - `npm publish --tag canary` 14 | -------------------------------------------------------------------------------- /cra-template/README.md: -------------------------------------------------------------------------------- 1 | # CRA Template Reactronica 2 | 3 | Template for a simple Reactronica project based on Create React App. 4 | -------------------------------------------------------------------------------- /cra-template/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cra-template-reactronica", 3 | "version": "0.0.2", 4 | "keywords": [ 5 | "react", 6 | "create-react-app", 7 | "template", 8 | "reactronica" 9 | ], 10 | "description": "Template for a simple Reactronica project based on Create React App.", 11 | "main": "template.json", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/unkleho/reactronica.git", 15 | "directory": "cra-template" 16 | }, 17 | "license": "MIT", 18 | "engines": { 19 | "node": ">=10" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/unkleho/reactronica/issues" 23 | }, 24 | "files": [ 25 | "template", 26 | "template.json" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /cra-template/template.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": { 3 | "dependencies": { 4 | "reactronica": "^0.7.0" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /cra-template/template/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /cra-template/template/gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /cra-template/template/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unkleho/reactronica/e9840bf718069922f167ec6bd9fa4c9b7fb6d5ae/cra-template/template/public/favicon.ico -------------------------------------------------------------------------------- /cra-template/template/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /cra-template/template/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unkleho/reactronica/e9840bf718069922f167ec6bd9fa4c9b7fb6d5ae/cra-template/template/public/logo192.png -------------------------------------------------------------------------------- /cra-template/template/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unkleho/reactronica/e9840bf718069922f167ec6bd9fa4c9b7fb6d5ae/cra-template/template/public/logo512.png -------------------------------------------------------------------------------- /cra-template/template/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /cra-template/template/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /cra-template/template/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cra-template/template/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Song, Track, Instrument } from 'reactronica'; 3 | import logo from './logo.svg'; 4 | import './App.css'; 5 | 6 | function App() { 7 | const [isPlaying, setIsPlaying] = React.useState(false); 8 | 9 | return ( 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 28 | 29 |
30 | 31 | logo 32 | 33 |

34 | Edit src/App.js and save to reload. 35 |

36 | 42 | Learn React 43 | 44 |
45 |
46 | ); 47 | } 48 | 49 | export default App; 50 | -------------------------------------------------------------------------------- /cra-template/template/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /cra-template/template/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /cra-template/template/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /cra-template/template/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /cra-template/template/src/serviceWorker.js: -------------------------------------------------------------------------------- 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 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /cra-template/template/src/setupTests.js: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Reactronica Example 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactronica-example", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "react": "^17.0.0", 11 | "react-dom": "^17.0.0", 12 | "reactronica": "file:.." 13 | }, 14 | "devDependencies": { 15 | "@types/react": "^17.0.0", 16 | "@types/react-dom": "^17.0.0", 17 | "@vitejs/plugin-react-refresh": "^1.3.1", 18 | "typescript": "^4.3.2", 19 | "vite": "^2.4.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /example/public/snare-top-off17.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unkleho/reactronica/e9840bf718069922f167ec6bd9fa4c9b7fb6d5ae/example/public/snare-top-off17.wav -------------------------------------------------------------------------------- /example/public/st2_kick_one_shot_low_punch_basic.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unkleho/reactronica/e9840bf718069922f167ec6bd9fa4c9b7fb6d5ae/example/public/st2_kick_one_shot_low_punch_basic.wav -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | 40 | button { 41 | font-size: calc(10px + 2vmin); 42 | } 43 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Song, Track, Instrument } from 'reactronica'; 3 | import './App.css'; 4 | 5 | const snareSample = '/snare-top-off17.wav'; 6 | const kickSample = '/st2_kick_one_shot_low_punch_basic.wav'; 7 | 8 | function App() { 9 | const [isPlaying, setIsPlaying] = useState(false); 10 | const [samples, setSamples] = useState(null); 11 | 12 | return ( 13 |
14 |
15 |

Hello Vite + React + Reactronica!

16 |

17 | 20 | 37 |

38 |
39 | 40 | 41 | { 44 | // console.log(steps); 45 | // }} 46 | > 47 | 48 | 49 | 50 | 53 | { 57 | // console.log('loaded'); 58 | // console.log(buffers); 59 | // }} 60 | > 61 | 62 | 63 |
64 | ); 65 | } 66 | 67 | export default App; 68 | -------------------------------------------------------------------------------- /example/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ) 12 | -------------------------------------------------------------------------------- /example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "allowJs": false, 6 | "skipLibCheck": false, 7 | "esModuleInterop": false, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["./src"] 19 | } 20 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import reactRefresh from '@vitejs/plugin-react-refresh' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [reactRefresh()] 7 | }) 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testPathIgnorePatterns: ['/website/', '/cra-template/'], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactronica", 3 | "version": "0.8.1-canary.1", 4 | "description": "React components for making music", 5 | "author": { 6 | "name": "Kaho Cheung", 7 | "url": "https://twitter.com/unkleho" 8 | }, 9 | "license": "MIT", 10 | "repository": "https://github.com/unkleho/reactronica", 11 | "main": "dist/index.js", 12 | "module": "dist/reactronica.esm.js", 13 | "typings": "dist/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "src" 17 | ], 18 | "engines": { 19 | "node": ">=10" 20 | }, 21 | "scripts": { 22 | "start": "tsdx watch", 23 | "build": "tsdx build", 24 | "test": "tsdx test --passWithNoTests", 25 | "test:watch": "npm run test -- --watch", 26 | "lint": "tsdx lint", 27 | "prepare": "tsdx build" 28 | }, 29 | "dependencies": { 30 | "fast-deep-equal": "^3.1.3", 31 | "startaudiocontext": "^1.2.1", 32 | "tone": "^13.8.34" 33 | }, 34 | "peerDependencies": { 35 | "react": ">=16" 36 | }, 37 | "devDependencies": { 38 | "@testing-library/react": "^10.4.9", 39 | "@types/jest": "^25.2.3", 40 | "@types/react": "^16.14.5", 41 | "@types/react-dom": "^16.9.12", 42 | "husky": "^4.3.8", 43 | "react": "^16.14.0", 44 | "react-dom": "^16.14.0", 45 | "tsdx": "^0.13.3", 46 | "tslib": "^1.14.1", 47 | "typescript": "^3.7.3" 48 | }, 49 | "husky": { 50 | "hooks": { 51 | "pre-commit": "tsdx lint" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/__mocks__/startaudiocontext.ts: -------------------------------------------------------------------------------- 1 | const StartAudioContext = jest.fn; 2 | 3 | export default StartAudioContext; 4 | -------------------------------------------------------------------------------- /src/__mocks__/tone.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | /** 4 | * Tone JS Mock 5 | * 6 | * NOTE: Tone's sub-module classes are rebuilt with injected mock functions so 7 | * Jest can spy on them. This isn't the best option, but I was unable to mock 8 | * Tone's classes using mockImplementation as it is difficult to access them 9 | * directly. 10 | */ 11 | 12 | // ---------------------------------------------------------------------------- 13 | // Tone.Master 14 | // ---------------------------------------------------------------------------- 15 | 16 | const Master = { 17 | volume: { 18 | value: 0, 19 | }, 20 | mute: false, 21 | chain: jest.fn(), 22 | dispose: jest.fn(), 23 | }; 24 | 25 | // ---------------------------------------------------------------------------- 26 | // Tone.Transport 27 | // ---------------------------------------------------------------------------- 28 | 29 | const Transport = { 30 | bpm: { 31 | value: null, 32 | }, 33 | start: jest.fn(), 34 | stop: jest.fn(), 35 | }; 36 | 37 | // ---------------------------------------------------------------------------- 38 | // Tone.Channel 39 | // ---------------------------------------------------------------------------- 40 | 41 | export const mockChannelConstructor = jest.fn(); 42 | export const mockChannelVolume = jest.fn(); 43 | export const mockChannelPan = jest.fn(); 44 | export const mockChannelDispose = jest.fn(); 45 | 46 | class Channel { 47 | constructor(volume, pan) { 48 | mockChannelConstructor(volume, pan); 49 | 50 | this.volume = { 51 | value: volume, 52 | }; 53 | 54 | this.pan = { 55 | value: pan, 56 | }; 57 | 58 | this.dispose = mockChannelDispose; 59 | // console.log(this.mute); 60 | 61 | mockChannelVolume(this.volume.value); 62 | mockChannelPan(this.pan.value); 63 | } 64 | } 65 | 66 | // ---------------------------------------------------------------------------- 67 | // Tone.PolySynth 68 | // ---------------------------------------------------------------------------- 69 | 70 | export const mockPolySynthConstructor = jest.fn(); 71 | export const mockPolySynthTriggerAttack = jest.fn(); 72 | export const mockPolySynthTriggerRelease = jest.fn(); 73 | export const mockPolySynthDispose = jest.fn(); 74 | export const mockPolySynthChain = jest.fn(); 75 | export const mockPolySynthSet = jest.fn(); 76 | 77 | class PolySynth { 78 | constructor(polyphony, voice, voiceArgs) { 79 | mockPolySynthConstructor(polyphony, voice, voiceArgs); 80 | 81 | this.triggerAttack = mockPolySynthTriggerAttack; 82 | this.triggerRelease = mockPolySynthTriggerRelease; 83 | this.dispose = mockPolySynthDispose; 84 | this.chain = mockPolySynthChain; 85 | this.set = mockPolySynthSet; 86 | this.disconnect = jest.fn(); 87 | } 88 | } 89 | 90 | // ---------------------------------------------------------------------------- 91 | // Tone.Synth 92 | // ---------------------------------------------------------------------------- 93 | 94 | const Synth = 'Synth'; 95 | 96 | // ---------------------------------------------------------------------------- 97 | // Tone.AMSynth 98 | // ---------------------------------------------------------------------------- 99 | 100 | const AMSynth = 'AMSynth'; 101 | 102 | // ---------------------------------------------------------------------------- 103 | // Tone.DuoSynth 104 | // ---------------------------------------------------------------------------- 105 | 106 | const DuoSynth = 'DuoSynth'; 107 | 108 | // ---------------------------------------------------------------------------- 109 | // Tone.FMSynth 110 | // ---------------------------------------------------------------------------- 111 | 112 | const FMSynth = 'FMSynth'; 113 | 114 | // ---------------------------------------------------------------------------- 115 | // Tone.MonoSynth 116 | // ---------------------------------------------------------------------------- 117 | 118 | const MonoSynth = 'MonoSynth'; 119 | 120 | // ---------------------------------------------------------------------------- 121 | // Tone.MembraneSynth 122 | // ---------------------------------------------------------------------------- 123 | 124 | export const mockMembraneSynthConstructor = jest.fn(); 125 | 126 | class MembraneSynth { 127 | constructor(options) { 128 | mockMembraneSynthConstructor(options); 129 | 130 | this.triggerAttack = jest.fn(); 131 | this.triggerRelease = jest.fn(); 132 | this.dispose = jest.fn(); 133 | this.chain = jest.fn(); 134 | this.disconnect = jest.fn(); 135 | } 136 | } 137 | 138 | // ---------------------------------------------------------------------------- 139 | // Tone.MetalSynth 140 | // ---------------------------------------------------------------------------- 141 | 142 | export const mockMetalSynthConstructor = jest.fn(); 143 | 144 | class MetalSynth { 145 | constructor(options) { 146 | mockMetalSynthConstructor(options); 147 | 148 | this.triggerAttack = jest.fn(); 149 | this.triggerRelease = jest.fn(); 150 | this.dispose = jest.fn(); 151 | this.chain = jest.fn(); 152 | this.disconnect = jest.fn(); 153 | } 154 | } 155 | 156 | // ---------------------------------------------------------------------------- 157 | // Tone.NoiseSynth 158 | // ---------------------------------------------------------------------------- 159 | 160 | export const mockNoiseSynthConstructor = jest.fn(); 161 | 162 | class NoiseSynth { 163 | constructor(options) { 164 | mockNoiseSynthConstructor(options); 165 | 166 | this.triggerAttack = jest.fn(); 167 | this.triggerRelease = jest.fn(); 168 | this.dispose = jest.fn(); 169 | this.chain = jest.fn(); 170 | this.disconnect = jest.fn(); 171 | } 172 | } 173 | 174 | // ---------------------------------------------------------------------------- 175 | // Tone.PluckSynth 176 | // ---------------------------------------------------------------------------- 177 | 178 | export const mockPluckSynthConstructor = jest.fn(); 179 | 180 | class PluckSynth { 181 | constructor(options) { 182 | mockPluckSynthConstructor(options); 183 | 184 | this.triggerAttack = jest.fn(); 185 | this.triggerRelease = jest.fn(); 186 | this.dispose = jest.fn(); 187 | this.chain = jest.fn(); 188 | this.disconnect = jest.fn(); 189 | } 190 | } 191 | 192 | // ---------------------------------------------------------------------------- 193 | // Tone.Sampler 194 | // ---------------------------------------------------------------------------- 195 | 196 | export const mockSamplerConstructor = jest.fn(); 197 | export const mockSamplerDispose = jest.fn(); 198 | export const mockSamplerAdd = jest.fn(); 199 | 200 | class Sampler { 201 | constructor(samples) { 202 | mockSamplerConstructor(samples); 203 | 204 | this.add = mockSamplerAdd; 205 | this.dispose = mockSamplerDispose; 206 | this.chain = jest.fn(); 207 | this.disconnect = jest.fn(); 208 | } 209 | } 210 | 211 | // ---------------------------------------------------------------------------- 212 | // Tone.AutoFilter 213 | // ---------------------------------------------------------------------------- 214 | 215 | export const mockAutoFilterConstructor = jest.fn(); 216 | // export const mockAutoFilterWet = jest.fn(); 217 | // export const mockAutoFilterDispose = jest.fn(); 218 | 219 | class AutoFilter { 220 | constructor() { 221 | mockAutoFilterConstructor(); 222 | 223 | this.wet = { 224 | value: 1, 225 | }; 226 | // this.dispose = mockAutoFilterDispose; 227 | // this.chain = jest.fn(); 228 | // this.disconnect = jest.fn(); 229 | } 230 | } 231 | 232 | // ---------------------------------------------------------------------------- 233 | // Tone.AutoPanner 234 | // ---------------------------------------------------------------------------- 235 | 236 | export const mockAutoPannerConstructor = jest.fn(); 237 | 238 | class AutoPanner { 239 | constructor() { 240 | mockAutoPannerConstructor(); 241 | } 242 | } 243 | 244 | // ---------------------------------------------------------------------------- 245 | // Tone.EQ3 246 | // ---------------------------------------------------------------------------- 247 | 248 | export const mockEQ3Constructor = jest.fn(); 249 | 250 | class EQ3 { 251 | constructor(low, mid, high) { 252 | mockEQ3Constructor(low, mid, high); 253 | 254 | this.low = { 255 | value: low, 256 | }; 257 | 258 | this.mid = { 259 | value: mid, 260 | }; 261 | 262 | this.high = { 263 | value: high, 264 | }; 265 | 266 | this.lowFrequency = { 267 | value: 400, 268 | }; 269 | 270 | this.highFrequency = { 271 | value: 2500, 272 | }; 273 | } 274 | } 275 | 276 | // ---------------------------------------------------------------------------- 277 | // Tone.Sequence 278 | // ---------------------------------------------------------------------------- 279 | 280 | export const mockSequenceConstructor = jest.fn(); 281 | export const mockSequenceAdd = jest.fn(); 282 | export const mockSequenceRemove = jest.fn(); 283 | export const mockSequenceRemoveAll = jest.fn(); 284 | 285 | class Sequence { 286 | constructor(callback, steps) { 287 | mockSequenceConstructor(steps); 288 | 289 | this.start = jest.fn(); 290 | this.stop = jest.fn(); 291 | this.add = mockSequenceAdd; 292 | this.remove = mockSequenceRemove; 293 | this.removeAll = mockSequenceRemoveAll; 294 | this.dispose = jest.fn(); 295 | } 296 | } 297 | 298 | const MockTone = { 299 | Master, 300 | Transport, 301 | Channel, 302 | PolySynth, 303 | Synth, 304 | AMSynth, 305 | DuoSynth, 306 | FMSynth, 307 | MembraneSynth, 308 | MetalSynth, 309 | MonoSynth, 310 | NoiseSynth, 311 | PluckSynth, 312 | Sampler, 313 | AutoFilter, 314 | AutoPanner, 315 | EQ3, 316 | Sequence, 317 | }; 318 | 319 | export default MockTone; 320 | -------------------------------------------------------------------------------- /src/__tests__/Effect.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import Tone from 'tone'; 4 | 5 | import { Song, Track, Instrument, Effect } from '..'; 6 | import { 7 | mockAutoFilterConstructor, 8 | mockAutoPannerConstructor, 9 | mockPolySynthChain, 10 | mockChannelConstructor, 11 | } from '../__mocks__/tone'; 12 | 13 | beforeEach(() => { 14 | jest.resetAllMocks(); 15 | }); 16 | 17 | describe('Effect', () => { 18 | // TODO: Fix theses tests after upgrading Tone JS to v14 19 | xit('should add and remove effects from Instrument', () => { 20 | const { rerender } = render( 21 | 22 | 27 | 28 | 29 | 30 | , 31 | ); 32 | 33 | expect(mockAutoFilterConstructor).toBeCalled(); 34 | expect(mockPolySynthChain).toHaveBeenLastCalledWith( 35 | { id: 'effect-1', wet: { value: 1 } }, 36 | { 37 | dispose: mockChannelConstructor, 38 | mute: undefined, 39 | solo: undefined, 40 | pan: { value: 0 }, 41 | volume: { value: 0 }, 42 | }, 43 | Tone.Master, 44 | ); 45 | 46 | rerender( 47 | 48 | 52 | // 53 | // 54 | // 55 | // } 56 | > 57 | 58 | 59 | 60 | 61 | , 62 | ); 63 | 64 | expect(mockAutoPannerConstructor).toBeCalled(); 65 | expect(mockPolySynthChain).toHaveBeenLastCalledWith( 66 | { id: 'effect-2' }, 67 | { id: 'effect-1', wet: { value: 1 } }, 68 | { pan: { value: 0 }, volume: { value: 0 } }, 69 | Tone.Master, 70 | ); 71 | 72 | rerender( 73 | 74 | 78 | // 79 | // 80 | // } 81 | > 82 | 83 | 84 | 85 | , 86 | ); 87 | 88 | expect(mockPolySynthChain).toHaveBeenLastCalledWith( 89 | { id: 'effect-2' }, 90 | { pan: { value: 0 }, volume: { value: 0 } }, 91 | Tone.Master, 92 | ); 93 | 94 | rerender( 95 | 96 | 97 | 98 | 99 | , 100 | ); 101 | 102 | expect(mockPolySynthChain).toHaveBeenLastCalledWith( 103 | { 104 | pan: { value: 0 }, 105 | volume: { value: 0 }, 106 | }, 107 | Tone.Master, 108 | ); 109 | }); 110 | 111 | // TODO: Fix these tests after upgrading Tone JS to v14 112 | xit('should update wet prop', () => { 113 | render( 114 | 115 | 116 | 117 | 118 | 119 | , 120 | ); 121 | 122 | expect(mockPolySynthChain).toHaveBeenLastCalledWith( 123 | { id: 'effect-1', wet: { value: 0.5 } }, 124 | { pan: { value: 0 }, volume: { value: 0 } }, 125 | Tone.Master, 126 | ); 127 | }); 128 | 129 | // TODO: Fix these tests after upgrading Tone JS to v14 130 | xit('should add EQ3 effect and then update frequency', () => { 131 | const { rerender } = render( 132 | 133 | 134 | 135 | 136 | 137 | , 138 | ); 139 | 140 | expect(mockPolySynthChain).toHaveBeenLastCalledWith( 141 | { 142 | id: 'effect-1', 143 | low: { value: -6 }, 144 | mid: { value: 3 }, 145 | high: { value: 1 }, 146 | lowFrequency: { value: 400 }, 147 | highFrequency: { value: 2500 }, 148 | }, 149 | { pan: { value: 0 }, volume: { value: 0 } }, 150 | Tone.Master, 151 | ); 152 | 153 | rerender( 154 | 155 | 156 | 157 | 166 | 167 | , 168 | ); 169 | 170 | expect(mockPolySynthChain).toHaveBeenLastCalledWith( 171 | { 172 | id: 'effect-1', 173 | low: { value: -3 }, 174 | mid: { value: 1 }, 175 | high: { value: 0 }, 176 | lowFrequency: { value: 100 }, 177 | highFrequency: { value: 3000 }, 178 | }, 179 | { pan: { value: 0 }, volume: { value: 0 } }, 180 | Tone.Master, 181 | ); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/__tests__/Instrument.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import { Song, Track, Instrument } from '..'; 5 | import { 6 | mockPolySynthConstructor, 7 | mockPolySynthTriggerAttack, 8 | mockPolySynthTriggerRelease, 9 | mockPolySynthDispose, 10 | mockMembraneSynthConstructor, 11 | mockMetalSynthConstructor, 12 | // mockNoiseSynthConstructor, 13 | mockPluckSynthConstructor, 14 | mockSamplerConstructor, 15 | mockSamplerDispose, 16 | mockPolySynthSet, 17 | mockSamplerAdd, 18 | } from '../__mocks__/tone'; 19 | 20 | beforeEach(() => { 21 | jest.resetAllMocks(); 22 | }); 23 | 24 | describe('Instrument', () => { 25 | it('should add and remove polySynth from Song', () => { 26 | const { rerender } = render( 27 | 28 | 29 | 30 | 31 | , 32 | ); 33 | 34 | expect(mockPolySynthConstructor).toBeCalledTimes(1); 35 | expect(mockPolySynthConstructor).toBeCalledWith(4, 'Synth', undefined); 36 | expect(mockPolySynthDispose).toBeCalledTimes(0); 37 | 38 | // @ts-ignore 39 | rerender(); 40 | 41 | expect(mockPolySynthDispose).toBeCalledTimes(1); 42 | }); 43 | 44 | it('should add and remove sampler from Song', () => { 45 | const { rerender } = render( 46 | 47 | 48 | 54 | 55 | , 56 | ); 57 | 58 | expect(mockSamplerConstructor).toBeCalledWith({ 59 | C3: '../audio/file.mp3', 60 | }); 61 | 62 | rerender(); 63 | 64 | expect(mockSamplerDispose).toBeCalledTimes(1); 65 | }); 66 | 67 | it('should add and remove samples from sampler Instrument', () => { 68 | const { rerender } = render( 69 | 70 | 71 | 77 | 78 | , 79 | ); 80 | 81 | rerender( 82 | 83 | 84 | 91 | 92 | , 93 | ); 94 | 95 | // TODO: Figure out what to do in this scenario 96 | rerender( 97 | 98 | 99 | 106 | 107 | , 108 | ); 109 | 110 | expect(mockSamplerAdd).toHaveBeenNthCalledWith( 111 | 1, 112 | 'D3', 113 | '../audio/file2.mp3', 114 | expect.any(Function), 115 | ); 116 | expect(mockSamplerAdd).toHaveBeenNthCalledWith( 117 | 2, 118 | 'E3', 119 | '../audio/file3.mp3', 120 | expect.any(Function), 121 | ); 122 | }); 123 | 124 | it('should trigger and release note', () => { 125 | const { rerender } = render( 126 | 127 | 128 | 129 | 130 | , 131 | ); 132 | 133 | expect(mockPolySynthTriggerAttack).toBeCalledWith( 134 | 'C3', 135 | undefined, // Duration 136 | 0.5, 137 | ); 138 | expect(mockPolySynthTriggerRelease).not.toBeCalledWith('C3'); 139 | 140 | rerender( 141 | 142 | 143 | 144 | 145 | , 146 | ); 147 | 148 | expect(mockPolySynthTriggerRelease).toBeCalledWith('C3'); 149 | }); 150 | }); 151 | 152 | describe('Synth', () => { 153 | it('should render with polyphony and oscillator props', () => { 154 | const { rerender } = render( 155 | 156 | 157 | 162 | 163 | , 164 | ); 165 | 166 | expect(mockPolySynthConstructor).toHaveBeenLastCalledWith(5, 'Synth', { 167 | oscillator: { 168 | type: 'square', 169 | }, 170 | }); 171 | 172 | rerender( 173 | 174 | 175 | 180 | 181 | , 182 | ); 183 | 184 | expect(mockPolySynthConstructor).toHaveBeenLastCalledWith(3, 'Synth', { 185 | oscillator: { 186 | type: 'square', 187 | }, 188 | }); 189 | 190 | rerender( 191 | 192 | 193 | 198 | 199 | , 200 | ); 201 | 202 | expect(mockPolySynthSet).toHaveBeenLastCalledWith('oscillator', { 203 | type: 'sine', 204 | }); 205 | }); 206 | 207 | it('should render with `synth`, `amSynth` and go through all other synth types', () => { 208 | const { rerender } = render( 209 | 210 | 211 | 212 | 213 | , 214 | ); 215 | 216 | expect(mockPolySynthConstructor).toHaveBeenLastCalledWith( 217 | 4, 218 | 'Synth', 219 | undefined, 220 | ); 221 | 222 | rerender( 223 | 224 | 225 | 226 | 227 | , 228 | ); 229 | 230 | expect(mockPolySynthConstructor).toHaveBeenLastCalledWith( 231 | 4, 232 | 'AMSynth', 233 | undefined, 234 | ); 235 | 236 | rerender( 237 | 238 | 239 | 240 | 241 | , 242 | ); 243 | 244 | expect(mockPolySynthConstructor).toHaveBeenLastCalledWith( 245 | 4, 246 | 'DuoSynth', 247 | undefined, 248 | ); 249 | 250 | rerender( 251 | 252 | 253 | 254 | 255 | , 256 | ); 257 | 258 | expect(mockPolySynthConstructor).toHaveBeenLastCalledWith( 259 | 4, 260 | 'FMSynth', 261 | undefined, 262 | ); 263 | 264 | rerender( 265 | 266 | 267 | 268 | 269 | , 270 | ); 271 | 272 | expect(mockMembraneSynthConstructor).toHaveBeenLastCalledWith({ 273 | oscillator: { 274 | type: 'triangle', 275 | }, 276 | }); 277 | 278 | rerender( 279 | 280 | 281 | 282 | 283 | , 284 | ); 285 | 286 | expect(mockMetalSynthConstructor).toHaveBeenLastCalledWith(undefined); 287 | 288 | rerender( 289 | 290 | 291 | 292 | 293 | , 294 | ); 295 | 296 | expect(mockPolySynthConstructor).toHaveBeenLastCalledWith( 297 | 4, 298 | 'MonoSynth', 299 | undefined, 300 | ); 301 | 302 | // rerender( 303 | // 304 | // 305 | // 306 | // 307 | // , 308 | // ); 309 | 310 | // expect(mockNoiseSynthConstructor).toHaveBeenLastCalledWith(undefined); 311 | 312 | rerender( 313 | 314 | 315 | 316 | 317 | , 318 | ); 319 | 320 | expect(mockPluckSynthConstructor).toHaveBeenLastCalledWith(undefined); 321 | }); 322 | 323 | it('should render synth envelopes', () => { 324 | render( 325 | 326 | 327 | 328 | 329 | , 330 | ); 331 | 332 | expect(mockPolySynthConstructor).toHaveBeenLastCalledWith(4, 'Synth', { 333 | envelope: { 334 | attack: 0.02, 335 | }, 336 | }); 337 | }); 338 | }); 339 | -------------------------------------------------------------------------------- /src/__tests__/Song.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import Tone from 'tone'; 4 | 5 | import { Song, Track, Instrument } from '..'; 6 | 7 | beforeEach(() => { 8 | jest.resetAllMocks(); 9 | }); 10 | 11 | describe('Song', () => { 12 | it('should render Song with bpm of 100 and then play with volume -3', () => { 13 | const { rerender } = render( 14 | 15 | 16 | 17 | 18 | , 19 | ); 20 | 21 | expect(Tone.Transport.bpm.value).toEqual(100); 22 | expect(Tone.Transport.start).toBeCalledTimes(0); 23 | expect(Tone.Master.volume.value).toEqual(0); 24 | expect(Tone.Master.mute).toEqual(true); 25 | 26 | rerender( 27 | 28 | 29 | 30 | 31 | , 32 | ); 33 | 34 | expect(Tone.Transport.start).toBeCalledTimes(1); 35 | expect(Tone.Master.volume.value).toEqual(-3); 36 | expect(Tone.Master.mute).toEqual(false); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/__tests__/Track.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import { Song, Track, Instrument } from '..'; 5 | import { 6 | mockChannelConstructor, 7 | // mockChannelVolume, 8 | // mockChannelPan, 9 | mockPolySynthDispose, 10 | mockSequenceConstructor, 11 | mockSequenceAdd, 12 | mockSequenceRemove, 13 | mockSequenceRemoveAll, 14 | } from '../__mocks__/tone'; 15 | 16 | beforeEach(() => { 17 | jest.resetAllMocks(); 18 | }); 19 | 20 | describe('Track', () => { 21 | it('should render Track with steps, pan and volume props', () => { 22 | const { rerender } = render( 23 | 24 | 25 | 26 | 27 | , 28 | ); 29 | 30 | expect(mockChannelConstructor).toBeCalledWith(-6, 2); 31 | 32 | rerender( 33 | 34 | 40 | 41 | 42 | , 43 | ); 44 | 45 | // TODO: Fix both of these, still getting called with original values 46 | // expect(mockChannelVolume).toBeCalledWith(0); 47 | // expect(mockChannelPan).toBeCalledWith(0); 48 | expect(mockSequenceConstructor).toBeCalledWith([ 49 | { index: 0, notes: [{ name: 'C3' }] }, 50 | { index: 1, notes: [] }, 51 | { index: 2, notes: [{ name: 'C3' }, { name: 'G3' }] }, 52 | { index: 3, notes: [{ name: 'E3' }] }, 53 | ]); 54 | }); 55 | 56 | it('should remove Track from song', () => { 57 | const { rerender } = render( 58 | 59 | 60 | 61 | 62 | , 63 | ); 64 | 65 | expect(mockChannelConstructor).toBeCalledWith(0, 0); 66 | expect(mockPolySynthDispose).toBeCalledTimes(0); 67 | 68 | // @ts-ignore 69 | rerender(); 70 | 71 | expect(mockPolySynthDispose).toBeCalledTimes(1); 72 | }); 73 | 74 | it('should add and remove steps from sequencer', () => { 75 | const { rerender } = render( 76 | 77 | 78 | 79 | 80 | , 81 | ); 82 | 83 | rerender( 84 | 85 | 86 | 87 | 88 | , 89 | ); 90 | 91 | expect(mockSequenceAdd).toHaveBeenLastCalledWith(1, { 92 | index: 1, 93 | notes: [{ name: 'D3' }], 94 | }); 95 | 96 | rerender( 97 | 98 | 99 | 100 | 101 | , 102 | ); 103 | 104 | expect(mockSequenceAdd).toHaveBeenLastCalledWith(2, { 105 | index: 2, 106 | notes: [{ name: 'C3' }], 107 | }); 108 | 109 | rerender( 110 | 111 | 112 | 113 | 114 | , 115 | ); 116 | 117 | expect(mockSequenceRemove).toHaveBeenLastCalledWith(1); 118 | 119 | rerender( 120 | 121 | 122 | 123 | 124 | , 125 | ); 126 | 127 | expect(mockSequenceRemoveAll).toHaveBeenLastCalledWith(); 128 | expect(mockSequenceAdd).toHaveBeenCalledWith(0, { 129 | index: 0, 130 | notes: [{ name: 'C3' }], 131 | }); 132 | expect(mockSequenceAdd).toHaveBeenCalledWith(1, { 133 | index: 1, 134 | notes: [], 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/components/Effect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext, useRef } from 'react'; 2 | 3 | import { TrackContext } from './Track'; 4 | import Tone from '../lib/tone'; 5 | 6 | export type EffectType = 7 | | 'autoFilter' 8 | | 'autoPanner' 9 | | 'autoWah' 10 | | 'bitCrusher' 11 | | 'distortion' 12 | | 'feedbackDelay' 13 | | 'freeverb' 14 | | 'panVol' 15 | | 'tremolo' 16 | | 'eq3'; 17 | 18 | export interface EffectProps { 19 | type?: EffectType; 20 | id?: string; 21 | delayTime?: string; 22 | feedback?: number; 23 | wet?: number; 24 | low?: number; 25 | mid?: number; 26 | high?: number; 27 | lowFrequency?: number; 28 | highFrequency?: number; 29 | } 30 | 31 | export interface EffectConsumerProps extends EffectProps { 32 | onAddToEffectsChain?: Function; 33 | onRemoveFromEffectsChain?: Function; 34 | } 35 | 36 | const EffectConsumer: React.FC = ({ 37 | type, 38 | id, 39 | delayTime = '8n', 40 | feedback = 0.5, 41 | wet = 1, 42 | low, 43 | mid, 44 | high, 45 | lowFrequency, 46 | highFrequency, 47 | onAddToEffectsChain, 48 | onRemoveFromEffectsChain, 49 | }) => { 50 | const effect = useRef<{ 51 | id: string | number; 52 | feedback?: { 53 | value: number; 54 | }; 55 | delay?: { 56 | value: number; 57 | }; 58 | delayTime?: { 59 | value: string; 60 | }; 61 | wet?: { 62 | value: number; 63 | }; 64 | low?: { 65 | value: number; 66 | }; 67 | mid?: { 68 | value: number; 69 | }; 70 | high?: { 71 | value: number; 72 | }; 73 | lowFrequency?: { 74 | value: number; 75 | }; 76 | highFrequency?: { 77 | value: number; 78 | }; 79 | }>(); 80 | 81 | useEffect(() => { 82 | // console.log(' mount'); 83 | // console.log(`id: ${id}`); 84 | 85 | if (type === 'autoFilter') { 86 | effect.current = new Tone.AutoFilter(); 87 | } else if (type === 'autoPanner') { 88 | effect.current = new Tone.AutoPanner(); 89 | } else if (type === 'autoWah') { 90 | effect.current = new Tone.AutoWah(); 91 | } else if (type === 'bitCrusher') { 92 | effect.current = new Tone.BitCrusher(); 93 | // Removed for now because delayTime has to be in ms 94 | // } else if (type === 'chorus') { 95 | // effect.current = new Tone.Chorus(); 96 | } else if (type === 'distortion') { 97 | effect.current = new Tone.Distortion(0.5); 98 | } else if (type === 'feedbackDelay') { 99 | effect.current = new Tone.FeedbackDelay(delayTime, feedback); 100 | } else if (type === 'freeverb') { 101 | effect.current = new Tone.Freeverb(); 102 | } else if (type === 'panVol') { 103 | effect.current = new Tone.PanVol(); 104 | // Needs generate() 105 | // } else if (type === 'reverb') { 106 | // effect.current = new Tone.Reverb(); 107 | } else if (type === 'tremolo') { 108 | effect.current = new Tone.Tremolo(); 109 | } else if (type === 'eq3') { 110 | effect.current = new Tone.EQ3(low, mid, high); 111 | } 112 | 113 | if (effect.current) { 114 | effect.current.id = id; 115 | 116 | // Update effects chain 117 | // TODO: Work out which index to insert current this.effect 118 | onAddToEffectsChain(effect.current); 119 | } 120 | 121 | return () => { 122 | // console.log(' unmount'); 123 | onRemoveFromEffectsChain(effect.current); 124 | }; 125 | /* eslint-disable-next-line */ 126 | }, [type]); 127 | 128 | useEffect(() => { 129 | if (effect.current && effect.current.feedback) { 130 | effect.current.feedback.value = feedback; 131 | } 132 | }, [feedback]); 133 | 134 | useEffect(() => { 135 | if (effect.current && effect.current.delayTime) { 136 | effect.current.delayTime.value = delayTime; 137 | } 138 | }, [delayTime]); 139 | 140 | useEffect(() => { 141 | if (effect.current && effect.current.wet) { 142 | effect.current.wet.value = wet; 143 | } 144 | }, [wet]); 145 | 146 | useEffect(() => { 147 | if (typeof low !== 'undefined' && effect.current && effect.current.low) { 148 | effect.current.low.value = low; 149 | } 150 | }, [low]); 151 | 152 | useEffect(() => { 153 | if (typeof mid !== 'undefined' && effect.current && effect.current.mid) { 154 | effect.current.mid.value = mid; 155 | } 156 | }, [mid]); 157 | 158 | useEffect(() => { 159 | if (typeof high !== 'undefined' && effect.current && effect.current.high) { 160 | effect.current.high.value = high; 161 | } 162 | }, [high]); 163 | 164 | useEffect(() => { 165 | if ( 166 | typeof lowFrequency !== 'undefined' && 167 | effect.current && 168 | effect.current.lowFrequency 169 | ) { 170 | effect.current.lowFrequency.value = lowFrequency; 171 | } 172 | }, [lowFrequency]); 173 | 174 | useEffect(() => { 175 | if ( 176 | typeof highFrequency !== 'undefined' && 177 | effect.current && 178 | effect.current.highFrequency 179 | ) { 180 | effect.current.highFrequency.value = highFrequency; 181 | } 182 | }, [highFrequency]); 183 | 184 | return null; 185 | }; 186 | 187 | const Effect: React.FC = (props) => { 188 | const { onAddToEffectsChain, onRemoveFromEffectsChain } = useContext( 189 | TrackContext, 190 | ); 191 | 192 | return ( 193 | 198 | ); 199 | }; 200 | 201 | export default Effect; 202 | -------------------------------------------------------------------------------- /src/components/Instrument.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useRef, 4 | useContext, 5 | // useLayoutEffect 6 | } from 'react'; 7 | // import equal from 'fast-deep-equal'; 8 | 9 | // import { SongContext } from './Song'; 10 | import { TrackContext } from './Track'; 11 | import Tone from '../lib/tone'; 12 | import { usePrevious } from '../lib/hooks'; 13 | import { MidiNote } from 'types/midi-notes'; 14 | // import { MidiNote } from '../types/midi-notes'; 15 | 16 | type NoteType = { 17 | name: string; 18 | velocity?: number; 19 | duration?: number | string; 20 | /** Use unique key to differentiate from same notes, otherwise it won't play */ 21 | key?: string | number; 22 | }; 23 | 24 | export type InstrumentType = 25 | | 'amSynth' 26 | | 'duoSynth' 27 | | 'fmSynth' 28 | | 'membraneSynth' 29 | | 'metalSynth' 30 | | 'monoSynth' 31 | | 'noiseSynth' 32 | | 'pluckSynth' 33 | | 'synth' 34 | | 'sampler'; 35 | 36 | export interface InstrumentProps { 37 | type: InstrumentType; 38 | notes?: NoteType[]; 39 | /** Should deprecate */ 40 | options?: any; 41 | polyphony?: number; 42 | oscillator?: { 43 | type: 'triangle' | 'sine' | 'square'; 44 | }; 45 | envelope?: { 46 | attack?: number; 47 | decay?: number; 48 | sustain?: number; 49 | release?: number; 50 | }; 51 | samples?: { 52 | [key in MidiNote]?: string; 53 | }; 54 | mute?: boolean; 55 | solo?: boolean; 56 | /** TODO: Type properly and consider loading status */ 57 | onLoad?: (buffers: any[]) => void; 58 | } 59 | 60 | interface InstrumentConsumerProps extends InstrumentProps { 61 | volume?: number; 62 | pan?: number; 63 | effectsChain?: React.ReactNode[]; 64 | onInstrumentsUpdate?: Function; 65 | } 66 | 67 | const InstrumentConsumer: React.FC = ({ 68 | // Props 69 | type = 'synth', 70 | options, 71 | polyphony = 4, 72 | oscillator, 73 | envelope, 74 | notes = [], 75 | samples, 76 | onLoad, 77 | // Props 78 | volume, 79 | pan, 80 | mute, 81 | solo, 82 | effectsChain, 83 | onInstrumentsUpdate, 84 | }) => { 85 | const instrumentRef = useRef< 86 | Partial<{ 87 | curve: number; 88 | release: number; 89 | triggerAttack: Function; 90 | triggerAttackRelease: Function; 91 | triggerRelease: Function; 92 | add: Function; 93 | set: Function; 94 | chain: Function; 95 | dispose: Function; 96 | disconnect: Function; 97 | }> 98 | >(); 99 | // const trackChannelBase = useRef(new Tone.PanVol(pan, volume)); 100 | // const trackChannelBase = useRef(new Tone.Channel(volume, pan)); 101 | const trackChannelBase = useRef(null); 102 | const prevNotes: any[] = usePrevious(notes); 103 | 104 | // ------------------------------------------------------------------------- 105 | // CHANNEL 106 | // TODO: Consider moving this to 107 | // ------------------------------------------------------------------------- 108 | 109 | useEffect(() => { 110 | trackChannelBase.current = new Tone.Channel(volume, pan); 111 | 112 | return function cleanup() { 113 | if (trackChannelBase.current) { 114 | trackChannelBase.current.dispose(); 115 | } 116 | }; 117 | /* eslint-disable-next-line */ 118 | }, []); 119 | 120 | // ------------------------------------------------------------------------- 121 | // INSTRUMENT TYPE 122 | // ------------------------------------------------------------------------- 123 | 124 | const prevType = usePrevious(type); 125 | 126 | useEffect(() => { 127 | if (type === 'sampler') { 128 | instrumentRef.current = new Tone.Sampler(samples, onLoad); 129 | 130 | if (options && options.curve) { 131 | instrumentRef.current.curve = options.curve; 132 | } 133 | 134 | if (options && options.release) { 135 | instrumentRef.current.release = options.release; 136 | } 137 | } else if (type === 'membraneSynth') { 138 | instrumentRef.current = new Tone.MembraneSynth( 139 | buildSynthOptions({ 140 | oscillator, 141 | envelope, 142 | }), 143 | ); 144 | } else if (type === 'metalSynth') { 145 | instrumentRef.current = new Tone.MetalSynth(); 146 | } else if (type === 'noiseSynth') { 147 | instrumentRef.current = new Tone.NoiseSynth(); 148 | } else if (type === 'pluckSynth') { 149 | instrumentRef.current = new Tone.PluckSynth(); 150 | } else { 151 | let synth; 152 | 153 | if (type === 'amSynth') { 154 | synth = Tone.AMSynth; 155 | } else if (type === 'duoSynth') { 156 | synth = Tone.DuoSynth; 157 | } else if (type === 'fmSynth') { 158 | synth = Tone.FMSynth; 159 | } else if (type === 'monoSynth') { 160 | synth = Tone.MonoSynth; 161 | } else if (type === 'synth') { 162 | synth = Tone.Synth; 163 | } else { 164 | synth = Tone.Synth; 165 | } 166 | 167 | /** 168 | * PolySynth accepts other Synth types as second param, making them 169 | * polyphonic. As this is a common use case, all Synths will be created 170 | * via PolySynth. Monophonic synths can easily be created by setting the 171 | * `polyphony` prop to 1. 172 | */ 173 | instrumentRef.current = new Tone.PolySynth( 174 | polyphony, 175 | synth, 176 | buildSynthOptions({ 177 | oscillator, 178 | envelope, 179 | }), 180 | ); 181 | } 182 | 183 | instrumentRef.current.chain( 184 | ...effectsChain, 185 | trackChannelBase.current, 186 | Tone.Master, 187 | ); 188 | 189 | // Add this Instrument to Track Context 190 | onInstrumentsUpdate([instrumentRef.current]); 191 | 192 | return function cleanup() { 193 | if (instrumentRef.current) { 194 | instrumentRef.current.dispose(); 195 | } 196 | }; 197 | /* eslint-disable-next-line */ 198 | }, [type, polyphony]); 199 | 200 | useEffect(() => { 201 | if ( 202 | // TODO: Add other synth types 203 | type === 'synth' && 204 | instrumentRef && 205 | instrumentRef.current && 206 | oscillator 207 | ) { 208 | instrumentRef.current.set('oscillator', oscillator); 209 | // console.log(oscillator); 210 | } 211 | }, [oscillator, type]); 212 | 213 | // ------------------------------------------------------------------------- 214 | // VOLUME / PAN / MUTE / SOLO 215 | // ------------------------------------------------------------------------- 216 | 217 | useEffect(() => { 218 | trackChannelBase.current.volume.value = volume; 219 | }, [volume]); 220 | 221 | useEffect(() => { 222 | trackChannelBase.current.pan.value = pan; 223 | }, [pan]); 224 | 225 | useEffect(() => { 226 | trackChannelBase.current.mute = mute; 227 | }, [mute]); 228 | 229 | useEffect(() => { 230 | trackChannelBase.current.solo = solo; 231 | }, [solo]); 232 | 233 | // ------------------------------------------------------------------------- 234 | // NOTES 235 | // ------------------------------------------------------------------------- 236 | 237 | /** 238 | NOTE: Would prefer to use useLayoutEffect as it is a little faster, but unable to test it right now 239 | **/ 240 | useEffect(() => { 241 | // Loop through all current notes 242 | notes && 243 | notes.forEach((note) => { 244 | // Check if note is playing 245 | const isPlaying = 246 | prevNotes && 247 | prevNotes.filter((prevNote) => { 248 | // Check both note name and unique key. 249 | // Key helps differentiate same notes, otherwise it won't trigger 250 | return prevNote.name === note.name && prevNote.key === note.key; 251 | }).length > 0; 252 | 253 | // Only play note is it isn't already playing 254 | if (!isPlaying) { 255 | if (note.duration) { 256 | instrumentRef.current.triggerAttackRelease( 257 | note.name, 258 | note.duration, 259 | undefined, 260 | note.velocity, 261 | ); 262 | } else { 263 | instrumentRef.current.triggerAttack( 264 | note.name, 265 | undefined, 266 | note.velocity, 267 | ); 268 | } 269 | } 270 | }); 271 | 272 | // Loop through all previous notes 273 | prevNotes && 274 | prevNotes.forEach((note) => { 275 | // Check if note is still playing 276 | const isPlaying = 277 | notes && notes.filter((n) => n.name === note.name).length > 0; 278 | 279 | if (!isPlaying) { 280 | instrumentRef.current.triggerRelease(note.name); 281 | } 282 | }); 283 | }, [notes, prevNotes]); 284 | 285 | // ------------------------------------------------------------------------- 286 | // EFFECTS CHAIN 287 | // ------------------------------------------------------------------------- 288 | 289 | useEffect(() => { 290 | // NOTE: Using trackChannelBase causes effects to not turn off 291 | instrumentRef.current.disconnect(); 292 | instrumentRef.current.chain( 293 | ...effectsChain, 294 | trackChannelBase.current, 295 | Tone.Master, 296 | ); 297 | }, [effectsChain]); 298 | 299 | // ------------------------------------------------------------------------- 300 | // SAMPLES 301 | // Run whenever `samples` change, using Tone.Sampler's `add` method to load 302 | // more samples after initial mount 303 | // TODO: Check if first mount, as sampler constructor has already loaded samples 304 | // ------------------------------------------------------------------------- 305 | 306 | const prevSamples = usePrevious(samples); 307 | 308 | useEffect(() => { 309 | // When sampler is initiated, it already loads samples. 310 | // We'll use !isFirstSamplerInit to skip adding samples if sampler has been 311 | // initiated in this render. 312 | const isFirstSamplerInit = type === 'sampler' && prevType !== type; 313 | 314 | if (type === 'sampler' && Boolean(samples) && !isFirstSamplerInit) { 315 | // const isEqual = equal(samples, prevSamples); 316 | const prevSampleKeys = Object.keys(prevSamples); 317 | const sampleKeys = Object.keys(samples); 318 | 319 | // Samples to add 320 | const addSampleKeys = sampleKeys.filter( 321 | (key) => !prevSampleKeys.includes(key), 322 | ); 323 | 324 | // Samples to remove 325 | // const removeSampleKeys = prevSampleKeys.filter( 326 | // (key) => !sampleKeys.includes(key), 327 | // ); 328 | 329 | // console.log(addSampleKeys, removeSampleKeys); 330 | 331 | if (addSampleKeys.length) { 332 | // Create an array of promises from `samples` 333 | const loadSamplePromises = addSampleKeys.map((key) => { 334 | return new Promise((resolve: (buffer: any) => void) => { 335 | const sample = samples[key]; 336 | const prevSample = prevSamples ? (prevSamples as object)[key] : ''; 337 | 338 | // Only update sample if different than before 339 | if (sample !== prevSample) { 340 | // Pass `resolve` to `onLoad` parameter of Tone.Sampler 341 | // When sample loads, this promise will resolve 342 | instrumentRef.current.add(key, sample, resolve); 343 | } else { 344 | resolve(null); 345 | } 346 | }); 347 | }); 348 | 349 | // Once all promises in array resolve, run onLoad callback 350 | Promise.all(loadSamplePromises).then((event) => { 351 | if (typeof onLoad === 'function') { 352 | onLoad(event); 353 | } 354 | }); 355 | 356 | // TODO: Work out a way to remove samples. Below doesn't work 357 | // removeSampleKeys.forEach((key) => { 358 | // instrumentRef.current.add(key, null); 359 | // }); 360 | } 361 | } 362 | /* eslint-disable-next-line */ 363 | }, [samples, type]); 364 | 365 | return null; 366 | }; 367 | 368 | const Instrument: React.FC = ({ 369 | type, 370 | options, 371 | notes, 372 | polyphony, 373 | oscillator, 374 | envelope, 375 | samples, 376 | onLoad, 377 | }) => { 378 | const { 379 | volume, 380 | pan, 381 | mute, 382 | solo, 383 | effectsChain, 384 | onInstrumentsUpdate, 385 | } = useContext(TrackContext); 386 | 387 | if (typeof window === 'undefined') { 388 | return null; 389 | } 390 | 391 | return ( 392 | Props 394 | type={type} 395 | options={options} 396 | notes={notes} 397 | polyphony={polyphony} 398 | oscillator={oscillator} 399 | envelope={envelope} 400 | samples={samples} 401 | onLoad={onLoad} 402 | // Props 403 | volume={volume} 404 | pan={pan} 405 | mute={mute} 406 | solo={solo} 407 | effectsChain={effectsChain} 408 | onInstrumentsUpdate={onInstrumentsUpdate} 409 | /> 410 | ); 411 | }; 412 | 413 | /** 414 | * Use Instrument's flattened synth props to create options object for Tone JS 415 | */ 416 | const buildSynthOptions = ({ oscillator, envelope }) => { 417 | if (oscillator || envelope) { 418 | return { 419 | ...(envelope ? { envelope } : {}), 420 | ...(oscillator ? { oscillator } : {}), 421 | }; 422 | } 423 | 424 | return undefined; 425 | }; 426 | 427 | export default Instrument; 428 | -------------------------------------------------------------------------------- /src/components/Song.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import StartAudioContext from 'startaudiocontext'; 3 | 4 | import Tone from '../lib/tone'; 5 | 6 | type SongContextProps = { 7 | isPlaying: boolean; 8 | }; 9 | 10 | export const SongContext = React.createContext({ 11 | isPlaying: false, 12 | }); 13 | 14 | export type SongProps = { 15 | isPlaying?: boolean; 16 | bpm?: number; 17 | swing?: number; 18 | subdivision?: string; 19 | swingSubdivision?: string; 20 | volume?: number; 21 | isMuted?: boolean; 22 | children?: React.ReactNode; 23 | }; 24 | 25 | const Song: React.FC = ({ 26 | isPlaying = false, 27 | bpm = 90, 28 | // subdivision = '4n', 29 | swing = 0, 30 | swingSubdivision = '8n', 31 | volume = 0, 32 | isMuted = false, 33 | children, 34 | }) => { 35 | useEffect(() => { 36 | document.body.addEventListener( 37 | 'click', 38 | () => { 39 | // iOS Web Audio API requires this library. 40 | StartAudioContext(Tone.context); 41 | }, 42 | { 43 | once: true, 44 | }, 45 | ); 46 | }, []); 47 | 48 | useEffect(() => { 49 | Tone.Transport.bpm.value = bpm; 50 | Tone.Transport.swing = swing; 51 | Tone.Transport.swingSubdivision = swingSubdivision; 52 | }, [bpm, swing, swingSubdivision]); 53 | 54 | useEffect(() => { 55 | if (isPlaying) { 56 | // Hack to get Tone to NOT use same settings from another instance 57 | Tone.Transport.bpm.value = bpm; 58 | Tone.Transport.swing = swing; 59 | Tone.Transport.swingSubdivision = swingSubdivision; 60 | 61 | Tone.Transport.start(); 62 | } else { 63 | Tone.Transport.stop(); 64 | } 65 | /* eslint-disable-next-line */ 66 | }, [isPlaying]); 67 | 68 | useEffect(() => { 69 | Tone.Master.volume.value = volume; 70 | }, [volume]); 71 | 72 | useEffect(() => { 73 | Tone.Master.mute = isMuted; 74 | }, [isMuted]); 75 | 76 | if (typeof window === 'undefined') { 77 | return null; 78 | } 79 | 80 | return ( 81 | 86 | {children} 87 | 88 | ); 89 | }; 90 | 91 | export default Song; 92 | -------------------------------------------------------------------------------- /src/components/Track.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import equal from 'fast-deep-equal'; 3 | 4 | import { SongContext } from './Song'; 5 | import Tone from '../lib/tone'; 6 | import buildSequencerStep, { SequencerStep } from '../lib/buildSequencerStep'; 7 | import { usePrevious } from '../lib/hooks'; 8 | import { MidiNote } from '../types/midi-notes'; 9 | 10 | export interface StepNoteType { 11 | name: MidiNote; 12 | duration?: number | string; 13 | velocity?: number; 14 | } 15 | 16 | export type StepType = 17 | | StepNoteType 18 | | StepNoteType[] 19 | | MidiNote 20 | | MidiNote[] 21 | | (StepNoteType | MidiNote)[] 22 | | null; 23 | 24 | export interface TrackProps { 25 | steps?: StepType[]; 26 | volume?: number; 27 | pan?: number; 28 | mute?: boolean; 29 | solo?: boolean; 30 | subdivision?: string; 31 | effects?: React.ReactNode[]; 32 | children: React.ReactNode; 33 | onStepPlay?: (stepNotes: StepNoteType[], index: number) => void; 34 | } 35 | 36 | export interface TrackConsumerProps extends TrackProps { 37 | isPlaying: boolean; 38 | } 39 | 40 | export const TrackContext = React.createContext({ 41 | volume: 0, 42 | pan: 0, 43 | mute: false, 44 | solo: false, 45 | effectsChain: null, 46 | onInstrumentsUpdate: null, 47 | onAddToEffectsChain: null, 48 | onRemoveFromEffectsChain: null, 49 | }); 50 | 51 | const TrackConsumer: React.FC = ({ 52 | // props 53 | isPlaying, 54 | // props 55 | steps = [], 56 | volume = 0, 57 | pan = 0, 58 | mute, 59 | solo, 60 | subdivision = '4n', 61 | effects = [], 62 | children, 63 | onStepPlay, 64 | }) => { 65 | const [effectsChain, setEffectsChain] = useState([]); 66 | const [instruments, setInstruments] = useState([]); 67 | const sequencer = useRef<{ 68 | start: Function; 69 | stop: Function; 70 | remove: Function; 71 | add: Function; 72 | dispose: Function; 73 | removeAll: Function; 74 | }>(); 75 | const instrumentsRef = useRef(instruments); 76 | 77 | useEffect(() => { 78 | instrumentsRef.current = instruments; 79 | }, [instruments]); 80 | 81 | /* 82 | Tone.Sequence can't easily play chords. By default, arrays within steps are flattened out and subdivided. However an array of notes is our preferred way of representing chords. To get around this, buildSequencerStep() will transform notes and put them in a notes field as an array. We can then loop through and run triggerAttackRelease() to play the note/s. 83 | */ 84 | const sequencerSteps = steps.map(buildSequencerStep); 85 | const prevSequencerSteps: SequencerStep[] = usePrevious(sequencerSteps); 86 | 87 | useEffect(() => { 88 | // ------------------------------------------------------------------------- 89 | // STEPS 90 | // ------------------------------------------------------------------------- 91 | 92 | // Start/Stop sequencer! 93 | if (isPlaying) { 94 | sequencer.current = new Tone.Sequence( 95 | (_, step) => { 96 | step.notes.forEach((note) => { 97 | instrumentsRef.current.forEach((instrument) => { 98 | instrument.triggerAttackRelease( 99 | note.name, 100 | note.duration || 0.5, 101 | undefined, 102 | note.velocity, 103 | ); 104 | }); 105 | }); 106 | 107 | if (typeof onStepPlay === 'function') { 108 | onStepPlay(step.notes, step.index); 109 | } 110 | }, 111 | sequencerSteps, 112 | subdivision, 113 | ); 114 | 115 | sequencer.current?.start(0); 116 | } else { 117 | if (sequencer.current) { 118 | sequencer.current.stop(); 119 | } 120 | } 121 | /* eslint-disable-next-line */ 122 | }, [isPlaying]); 123 | 124 | useEffect(() => { 125 | if (sequencer.current) { 126 | if (prevSequencerSteps?.length === sequencerSteps.length) { 127 | // When steps length is the same, update steps in a more efficient way 128 | sequencerSteps.forEach((step, i) => { 129 | const isEqual = equal( 130 | sequencerSteps[i].notes, 131 | prevSequencerSteps && prevSequencerSteps[i] 132 | ? prevSequencerSteps[i].notes 133 | : [], 134 | ); 135 | 136 | if (!isEqual) { 137 | sequencer.current?.remove(i); 138 | sequencer.current?.add(i, step); 139 | } 140 | }); 141 | } else { 142 | // When new steps are less or more then prev, remove all and add new steps 143 | sequencer.current.removeAll(); 144 | sequencerSteps.forEach((step, i) => { 145 | sequencer.current.add(i, step); 146 | }); 147 | } 148 | } 149 | /* eslint-disable-next-line */ 150 | }, [JSON.stringify(sequencerSteps)]); 151 | 152 | useEffect(() => { 153 | return function cleanup() { 154 | if (sequencer.current) { 155 | sequencer.current.dispose(); 156 | } 157 | }; 158 | }, []); 159 | 160 | const handleAddToEffectsChain = (effect) => { 161 | // console.log('', 'onAddToEffectsChain'); 162 | 163 | setEffectsChain((prevEffectsChain) => { 164 | return [effect, ...prevEffectsChain]; 165 | }); 166 | }; 167 | 168 | const handleRemoveFromEffectsChain = (effect) => { 169 | // console.log('', 'onRemoveFromEffectsChain', effect); 170 | 171 | setEffectsChain((prevEffectsChain) => { 172 | return prevEffectsChain.filter((e) => e.id !== effect.id); 173 | }); 174 | }; 175 | 176 | const handleInstrumentsUpdate = (newInstruments) => { 177 | setInstruments(newInstruments); 178 | }; 179 | 180 | return ( 181 | 193 | {children} 194 | {effects} 195 | 196 | ); 197 | }; 198 | 199 | const Track: React.FC = (props) => { 200 | const { isPlaying } = React.useContext(SongContext); 201 | 202 | if (typeof window === 'undefined') { 203 | return null; 204 | } 205 | 206 | return ; 207 | }; 208 | 209 | export default Track; 210 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { MidiNote } from '../types/midi-notes'; 2 | 3 | export const instruments = [ 4 | { id: 'amSynth', name: 'AM Synth', props: ['polyphony', 'oscillatorType'] }, 5 | { id: 'duoSynth', name: 'Duo Synth', props: ['polyphony', 'oscillatorType'] }, 6 | { id: 'fmSynth', name: 'FM Synth', props: ['polyphony', 'oscillatorType'] }, 7 | { id: 'membraneSynth', name: 'Membrane Synth', props: [] }, 8 | { id: 'metalSynth', name: 'Metal Synth', props: [] }, 9 | { 10 | id: 'monoSynth', 11 | name: 'Mono Synth', 12 | props: ['polyphony', 'oscillatorType'], 13 | }, 14 | // { id: 'noiseSynth', name: 'Noise Synth' }, // No sound, disabled for now 15 | { id: 'pluckSynth', name: 'Pluck Synth', props: [] }, 16 | { id: 'sampler', name: 'Sampler', props: ['samples'] }, 17 | { id: 'synth', name: 'Synth', props: ['polyphony', 'oscillatorType'] }, 18 | ]; 19 | 20 | export const effects = [ 21 | // -------------------------------------------------------------------------- 22 | // Tone JS Effects 23 | // -------------------------------------------------------------------------- 24 | { id: 'autoFilter', name: 'Auto Filter' }, 25 | { id: 'autoPanner', name: 'Auto Panner' }, 26 | { id: 'autoWah', name: 'Auto Wah' }, 27 | { id: 'bitCrusher', name: 'Bit Crusher' }, 28 | // { id: 'chorus', name: 'Chorus' }, 29 | { id: 'distortion', name: 'Distortion' }, 30 | { id: 'feedbackDelay', name: 'Feedback Delay' }, 31 | { id: 'freeverb', name: 'Freeverb' }, 32 | { id: 'panVol', name: 'Volume/Pan' }, 33 | // { id: 'reverb', name: 'Reverb' }, 34 | { id: 'tremolo', name: 'Tremolo' }, 35 | // -------------------------------------------------------------------------- 36 | // Tone JS Components 37 | // -------------------------------------------------------------------------- 38 | { id: 'eq3', name: 'EQ3' }, 39 | ]; 40 | 41 | const config = { 42 | instruments, 43 | effects, 44 | }; 45 | 46 | export const midiNotes: MidiNote[] = [ 47 | 'C-2', 48 | 'C#-2', 49 | 'D-2', 50 | 'D#-2', 51 | 'E-2', 52 | 'F-2', 53 | 'F#-2', 54 | 'G-2', 55 | 'G#-2', 56 | 'A-2', 57 | 'A#-2', 58 | 'B-2', 59 | 'C-1', 60 | 'C#-1', 61 | 'D-1', 62 | 'D#-1', 63 | 'E-1', 64 | 'F-1', 65 | 'F#-1', 66 | 'G-1', 67 | 'G#-1', 68 | 'A-1', 69 | 'A#-1', 70 | 'B-1', 71 | 'C0', 72 | 'C#0', 73 | 'D0', 74 | 'D#0', 75 | 'E0', 76 | 'F0', 77 | 'F#0', 78 | 'G0', 79 | 'G#0', 80 | 'A0', 81 | 'A#0', 82 | 'B0', 83 | 'C1', 84 | 'C#1', 85 | 'D1', 86 | 'D#1', 87 | 'E1', 88 | 'F1', 89 | 'F#1', 90 | 'G1', 91 | 'G#1', 92 | 'A1', 93 | 'A#1', 94 | 'B1', 95 | 'C2', 96 | 'C#2', 97 | 'D2', 98 | 'D#2', 99 | 'E2', 100 | 'F2', 101 | 'F#2', 102 | 'G2', 103 | 'G#2', 104 | 'A2', 105 | 'A#2', 106 | 'B2', 107 | 'C3', 108 | 'C#3', 109 | 'D3', 110 | 'D#3', 111 | 'E3', 112 | 'F3', 113 | 'F#3', 114 | 'G3', 115 | 'G#3', 116 | 'A3', 117 | 'A#3', 118 | 'B3', 119 | 'C4', 120 | 'C#4', 121 | 'D4', 122 | 'D#4', 123 | 'E4', 124 | 'F4', 125 | 'F#4', 126 | 'G4', 127 | 'G#4', 128 | 'A4', 129 | 'A#4', 130 | 'B4', 131 | 'C5', 132 | 'C#5', 133 | 'D5', 134 | 'D#5', 135 | 'E5', 136 | 'F5', 137 | 'F#5', 138 | 'G5', 139 | 'G#5', 140 | 'A5', 141 | 'A#5', 142 | 'B5', 143 | 'C6', 144 | 'C#6', 145 | 'D6', 146 | 'D#6', 147 | 'E6', 148 | 'F6', 149 | 'F#6', 150 | 'G6', 151 | 'G#6', 152 | 'A6', 153 | 'A#6', 154 | 'B6', 155 | 'C7', 156 | 'C#7', 157 | 'D7', 158 | 'D#7', 159 | 'E7', 160 | 'F7', 161 | 'F#7', 162 | 'G7', 163 | 'G#7', 164 | 'A7', 165 | 'A#7', 166 | 'B7', 167 | 'C8', 168 | 'C#8', 169 | 'D8', 170 | 'D#8', 171 | 'E8', 172 | 'F8', 173 | 'F#8', 174 | 'G8', 175 | ]; 176 | 177 | export default config; 178 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Song, SongProps } from './components/Song'; 2 | export { 3 | default as Track, 4 | TrackProps, 5 | StepNoteType, 6 | StepType, 7 | } from './components/Track'; 8 | export { 9 | default as Instrument, 10 | InstrumentProps, 11 | InstrumentType, 12 | } from './components/Instrument'; 13 | export { 14 | default as Effect, 15 | EffectProps, 16 | EffectType, 17 | } from './components/Effect'; 18 | 19 | export { default as config, midiNotes } from './config'; 20 | export { MidiNote } from './types/midi-notes'; 21 | -------------------------------------------------------------------------------- /src/lib/buildSequencerStep.ts: -------------------------------------------------------------------------------- 1 | import { StepNoteType, StepType } from 'components/Track'; 2 | import { MidiNote } from '../types/midi-notes'; 3 | 4 | export type SequencerStep = { 5 | notes: StepNoteType[]; 6 | index: number; 7 | }; 8 | 9 | export default function buildSequencerStep(step: StepType, i): SequencerStep { 10 | if (typeof step === 'string') { 11 | return { 12 | notes: [ 13 | { 14 | name: step as MidiNote, 15 | }, 16 | ], 17 | index: i, 18 | }; 19 | } else if (step && (step as StepNoteType).name) { 20 | return { 21 | notes: [ 22 | { 23 | name: (step as StepNoteType).name, 24 | duration: (step as StepNoteType).duration, 25 | velocity: (step as StepNoteType).velocity, 26 | }, 27 | ], 28 | index: i, 29 | }; 30 | } else if (Array.isArray(step)) { 31 | return { 32 | notes: (step as Array).map((s) => { 33 | if (typeof s === 'string') { 34 | return { 35 | name: s, 36 | }; 37 | } 38 | 39 | return s; 40 | }), 41 | index: i, 42 | }; 43 | } 44 | 45 | return { 46 | notes: [], 47 | index: i, 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | export function usePrevious(value): Value { 4 | // The ref object is a generic container whose current property is mutable ... 5 | // ... and can hold any value, similar to an instance property on a class 6 | const ref = useRef(); 7 | 8 | // Store current value in ref 9 | useEffect(() => { 10 | ref.current = value; 11 | }, [value]); // Only re-run if value changes 12 | 13 | // Return previous value (happens before update in useEffect above) 14 | return ref.current; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/tone.ts: -------------------------------------------------------------------------------- 1 | import Tone from 'tone'; 2 | 3 | export default Tone; 4 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export const isEqual = function(value, other) { 3 | // Get the value type 4 | const type = Object.prototype.toString.call(value); 5 | 6 | // If the two objects are not the same type, return false 7 | if (type !== Object.prototype.toString.call(other)) return false; 8 | 9 | // If items are not an object or array, return false 10 | if (['[object Array]', '[object Object]'].indexOf(type) < 0) return false; 11 | 12 | // Compare the length of the length of the two items 13 | const valueLen = 14 | type === '[object Array]' ? value.length : Object.keys(value).length; 15 | const otherLen = 16 | type === '[object Array]' ? other.length : Object.keys(other).length; 17 | if (valueLen !== otherLen) return false; 18 | 19 | // Compare two items 20 | var compare = function(item1, item2) { 21 | // Get the object type 22 | var itemType = Object.prototype.toString.call(item1); 23 | 24 | // If an object or array, compare recursively 25 | if (['[object Array]', '[object Object]'].indexOf(itemType) >= 0) { 26 | if (!isEqual(item1, item2)) return false; 27 | } else { 28 | // Otherwise, do a simple comparison 29 | // If the two items are not the same type, return false 30 | if (itemType !== Object.prototype.toString.call(item2)) return false; 31 | 32 | // Else if it's a function, convert to a string and compare 33 | // Otherwise, just compare 34 | if (itemType === '[object Function]') { 35 | if (item1.toString() !== item2.toString()) return false; 36 | } else { 37 | if (item1 !== item2) return false; 38 | } 39 | } 40 | }; 41 | 42 | // Compare properties 43 | if (type === '[object Array]') { 44 | for (let i = 0; i < valueLen; i++) { 45 | if (compare(value[i], other[i]) === false) return false; 46 | } 47 | } else { 48 | for (let key in value) { 49 | if (value.hasOwnProperty(key)) { 50 | if (compare(value[key], other[key]) === false) return false; 51 | } 52 | } 53 | } 54 | 55 | // If nothing failed, return true 56 | return true; 57 | }; 58 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tone'; 2 | 3 | declare module 'startaudiocontext'; 4 | -------------------------------------------------------------------------------- /src/types/midi-notes.ts: -------------------------------------------------------------------------------- 1 | // TODO: Add flats, eg. Bb2 2 | export type MidiNote = 3 | | 'C-2' 4 | | 'C#-2' 5 | | 'D-2' 6 | | 'D#-2' 7 | | 'E-2' 8 | | 'F-2' 9 | | 'F#-2' 10 | | 'G-2' 11 | | 'G#-2' 12 | | 'A-2' 13 | | 'A#-2' 14 | | 'B-2' 15 | | 'C-1' 16 | | 'C#-1' 17 | | 'D-1' 18 | | 'D#-1' 19 | | 'E-1' 20 | | 'F-1' 21 | | 'F#-1' 22 | | 'G-1' 23 | | 'G#-1' 24 | | 'A-1' 25 | | 'A#-1' 26 | | 'B-1' 27 | | 'C0' 28 | | 'C#0' 29 | | 'D0' 30 | | 'D#0' 31 | | 'E0' 32 | | 'F0' 33 | | 'F#0' 34 | | 'G0' 35 | | 'G#0' 36 | | 'A0' 37 | | 'A#0' 38 | | 'B0' 39 | | 'C1' 40 | | 'C#1' 41 | | 'D1' 42 | | 'D#1' 43 | | 'E1' 44 | | 'F1' 45 | | 'F#1' 46 | | 'G1' 47 | | 'G#1' 48 | | 'A1' 49 | | 'A#1' 50 | | 'B1' 51 | | 'C2' 52 | | 'C#2' 53 | | 'D2' 54 | | 'D#2' 55 | | 'E2' 56 | | 'F2' 57 | | 'F#2' 58 | | 'G2' 59 | | 'G#2' 60 | | 'A2' 61 | | 'A#2' 62 | | 'B2' 63 | | 'C3' 64 | | 'C#3' 65 | | 'D3' 66 | | 'D#3' 67 | | 'E3' 68 | | 'F3' 69 | | 'F#3' 70 | | 'G3' 71 | | 'G#3' 72 | | 'A3' 73 | | 'A#3' 74 | | 'B3' 75 | | 'C4' 76 | | 'C#4' 77 | | 'D4' 78 | | 'D#4' 79 | | 'E4' 80 | | 'F4' 81 | | 'F#4' 82 | | 'G4' 83 | | 'G#4' 84 | | 'A4' 85 | | 'A#4' 86 | | 'B4' 87 | | 'C5' 88 | | 'C#5' 89 | | 'D5' 90 | | 'D#5' 91 | | 'E5' 92 | | 'F5' 93 | | 'F#5' 94 | | 'G5' 95 | | 'G#5' 96 | | 'A5' 97 | | 'A#5' 98 | | 'B5' 99 | | 'C6' 100 | | 'C#6' 101 | | 'D6' 102 | | 'D#6' 103 | | 'E6' 104 | | 'F6' 105 | | 'F#6' 106 | | 'G6' 107 | | 'G#6' 108 | | 'A6' 109 | | 'A#6' 110 | | 'B6' 111 | | 'C7' 112 | | 'C#7' 113 | | 'D7' 114 | | 'D#7' 115 | | 'E7' 116 | | 'F7' 117 | | 'F#7' 118 | | 'G7' 119 | | 'G#7' 120 | | 'A7' 121 | | 'A#7' 122 | | 'B7' 123 | | 'C8' 124 | | 'C#8' 125 | | 'D8' 126 | | 'D#8' 127 | | 'E8' 128 | | 'F8' 129 | | 'F#8' 130 | | 'G8'; 131 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": false, 11 | "noImplicitAny": false, 12 | "strictNullChecks": false, 13 | "strictFunctionTypes": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "moduleResolution": "node", 21 | "baseUrl": "./", 22 | "paths": { 23 | "*": ["src/*", "node_modules/*"] 24 | }, 25 | "jsx": "react", 26 | "esModuleInterop": true 27 | } 28 | } 29 | --------------------------------------------------------------------------------