├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── linter.yml ├── .gitignore ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── LICENSE ├── README.md ├── assets └── screenshot.png ├── docs └── intro.md ├── package.json ├── public └── index.html ├── src ├── components │ ├── attackKnob │ │ ├── AttackKnob.css │ │ └── index.tsx │ ├── decayKnob │ │ ├── DecayKnob.css │ │ └── index.tsx │ ├── detuneKnob │ │ ├── DetuneKnob.css │ │ └── index.tsx │ ├── gainKnob │ │ ├── GainKnob.css │ │ └── index.tsx │ ├── index.ts │ ├── keyboard │ │ ├── Key.css │ │ ├── index.tsx │ │ └── key.tsx │ ├── knob.tsx │ ├── noiseGainKnob │ │ ├── NoiseGainKnob.css │ │ └── index.tsx │ ├── oscilloscope │ │ ├── Oscilloscope.css │ │ └── index.tsx │ ├── releaseKnob │ │ ├── ReleaseKnob.css │ │ └── index.tsx │ ├── sustainKnob │ │ ├── SustainKnob.css │ │ └── index.tsx │ ├── unisonInput │ │ ├── UnisonInput.css │ │ └── index.tsx │ └── waveformSelectbox │ │ ├── WaveformSelectbox.css │ │ └── index.tsx ├── containers │ ├── attackKnob.tsx │ ├── decayKnob.tsx │ ├── detuneKnob.tsx │ ├── gainKnob.tsx │ ├── index.ts │ ├── keyboard.tsx │ ├── noiseGainKnob.tsx │ ├── oscilloscope.tsx │ ├── releaseKnob.tsx │ ├── sustainKnob.tsx │ ├── unisonInput.tsx │ └── waveformSelectbox.tsx ├── domains │ ├── keyboard.ts │ └── waveform.ts ├── global.css ├── index.tsx ├── react-redux.d.ts ├── sagas │ ├── index.ts │ ├── keyboard.ts │ └── midi.ts ├── store │ ├── actions.ts │ ├── amplifier │ │ ├── actions.ts │ │ ├── reducer.ts │ │ └── state.ts │ ├── index.ts │ ├── keyboard │ │ └── actions.ts │ ├── oscillator │ │ ├── actions.ts │ │ ├── reducer.ts │ │ └── state.ts │ ├── oscilloscope │ │ ├── actions.ts │ │ ├── reducer.ts │ │ └── state.ts │ ├── reducer.ts │ └── state.ts └── stories │ ├── Keyboard.stories.tsx │ ├── Knob.stories.tsx │ ├── Oscilloscope.stories.tsx │ ├── UnisonInput.stories.tsx │ └── WaveformSelectbox.stories.tsx ├── tsconfig.json ├── webpack.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | insert_final_newline = true 5 | end_of_line = lf 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es6: true, node: true }, 4 | extends: ['eslint:recommended', 'plugin:prettier/recommended'], 5 | plugins: ['import'], 6 | ignorePatterns: ['node_modules', 'public', '**/*.css.d.ts'], 7 | overrides: [ 8 | { 9 | files: ['**/*.ts', '**/*.tsx'], 10 | extends: [ 11 | 'plugin:@typescript-eslint/recommended', 12 | 'prettier/@typescript-eslint', 13 | 'plugin:import/errors', 14 | 'plugin:import/warnings', 15 | 'plugin:import/typescript', // this line does the trick 16 | 'plugin:react/recommended', 17 | ], 18 | plugins: ['@typescript-eslint', 'deprecation'], 19 | parser: '@typescript-eslint/parser', 20 | parserOptions: { 21 | sourceType: 'module', 22 | project: './tsconfig.json', 23 | }, 24 | rules: { 25 | 'deprecation/deprecation': 'error', 26 | 'import/order': 'error', 27 | 'no-undef': 'off', 28 | 'react/jsx-uses-react': 'off', 29 | 'react/prop-types': 'off', 30 | 'react/react-in-jsx-scope': 'off', 31 | }, 32 | settings: { 33 | react: { 34 | version: 'detect', 35 | }, 36 | }, 37 | }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Lint Code Base 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | if: "! contains(github.event.head_commit.message, '[ci skip]')" 8 | name: Lint Code Base 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Code 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Lint Code Base 16 | uses: github/super-linter@v3 17 | env: 18 | VALIDATE_JSON: false 19 | VALIDATE_JAVASCRIPT_STANDARD: false 20 | VALIDATE_TYPESCRIPT_STANDARD: false 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | yarn-error.log 4 | 5 | /public/* 6 | !index.html 7 | 8 | /src/**/*.css.d.ts 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: [ 3 | '../src/**/*.stories.mdx', 4 | '../src/**/*.stories.@(js|jsx|ts|tsx)', 5 | ], 6 | addons: [ 7 | '@storybook/addon-links', 8 | '@storybook/addon-essentials', 9 | ], 10 | webpackFinal: async (config, { configType }) => { 11 | const rules = [ 12 | { 13 | test: /\.tsx?$/, 14 | use: ['ts-loader'], 15 | }, 16 | { 17 | test: /\.css$/, 18 | use: ['style-loader', 'css-loader?modules'], 19 | }, 20 | ]; 21 | 22 | return { ...config, module: { ...config.module, rules }}; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: '^on[A-Z].*' }, 3 | }; 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 0918nobita 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Synthesizer 2 | 3 | ![Lint Code Base](https://github.com/0918nobita/synth/workflows/Lint%20Code%20Base/badge.svg) 4 | 5 | WIP 6 | 7 | - Master gain control (`0.00x`-`2.00x`) 8 | - Main oscillator (`sine`, `square`, `sawtooth`, `triangle`) 9 | - Unison control (`1`-`8`) 10 | - Detune control (`0.0hz`-`10.0hz`) 11 | - Noise oscillator 12 | - Amplifier 13 | - Attack control (`0.00s`-`1.00s`) 14 | - Decay control (`0.00s`-`1.00s`) 15 | - Sustain control (`0.00x`-`1.00x`) 16 | - Release control (`0.00s`-`1.00s`) 17 | - Virtual keyboard (`C3`-`B3`) 18 | - Controllable via MIDI keyboards 19 | 20 | ![screenshot](./assets/screenshot.png) 21 | 22 | ## Install dependencies 23 | 24 | ```bash 25 | yarn 26 | ``` 27 | 28 | ## Build 29 | 30 | ```bash 31 | yarn build 32 | ``` 33 | 34 | ## Generate `.d.ts` files 35 | 36 | ```bash 37 | yarn gen-dts 38 | ``` 39 | 40 | ## Launch devServer 41 | 42 | ```bash 43 | yarn dev 44 | ``` 45 | 46 | ## Lint 47 | 48 | ```bash 49 | yarn lint 50 | ``` 51 | 52 | ## Format 53 | 54 | ```bash 55 | yarn format 56 | ``` 57 | 58 | ## Launch Storybook 59 | 60 | ```bash 61 | yarn storybook 62 | ``` 63 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0918nobita/synth/4d73c29be015e39121269c21eaa0c795ebc94be5/assets/screenshot.png -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # はじめに 2 | 3 | この Web アプリは、僕がソフトウェアシンセサイザに対する理解を深めるために作成しているものです。 4 | Web Audio API と Web MIDI API を利用しています。 5 | 6 | ## 対象読者 7 | 8 | - これからソフトウェアシンセサイザを自作しようとしている人 9 | 10 | ## 前提条件 11 | 12 | - TypeScript の基本的な知識 13 | 14 | ## オシレータ 15 | 16 | ## アンプ 17 | 18 | ## フィルタ 19 | 20 | ## モジュレーション 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synth", 3 | "version": "0.1.0", 4 | "description": "Synthesizer", 5 | "repository": "git@github.com:0918nobita/synth.git", 6 | "author": "0918nobita ", 7 | "license": "MIT", 8 | "private": true, 9 | "scripts": { 10 | "build": "yarn gen-dts && webpack", 11 | "dev": "yarn gen-dts && webpack serve", 12 | "gen-dts": "tcm src", 13 | "lint": "eslint . --ext '.js,.ts,.tsx'", 14 | "format": "eslint . --fix --ext '.js,.ts,.tsx'", 15 | "storybook": "start-storybook -p 6006", 16 | "build-storybook": "build-storybook" 17 | }, 18 | "dependencies": { 19 | "classnames": "^2.2.6", 20 | "react": "^17.0.1", 21 | "react-dom": "^17.0.1", 22 | "react-redux": "^7.2.2", 23 | "redux": "^4.0.5", 24 | "redux-saga": "^1.1.3" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.12.10", 28 | "@storybook/addon-actions": "^6.1.11", 29 | "@storybook/addon-essentials": "^6.1.11", 30 | "@storybook/addon-links": "^6.1.11", 31 | "@storybook/react": "^6.1.11", 32 | "@types/classnames": "^2.2.11", 33 | "@types/react-dom": "^17.0.0", 34 | "@types/react-redux": "^7.1.12", 35 | "@types/redux-mock-store": "^1.0.2", 36 | "@types/webmidi": "^2.0.4", 37 | "@types/webpack-dev-server": "^3.11.1", 38 | "@typescript-eslint/eslint-plugin": "^4.10.0", 39 | "@typescript-eslint/parser": "^4.10.0", 40 | "babel-loader": "^8.2.2", 41 | "css-loader": "^5.0.1", 42 | "eslint": "^7.16.0", 43 | "eslint-config-prettier": "^7.1.0", 44 | "eslint-plugin-deprecation": "^1.2.0", 45 | "eslint-plugin-import": "^2.22.1", 46 | "eslint-plugin-prettier": "^3.3.0", 47 | "eslint-plugin-react": "^7.21.5", 48 | "prettier": "^2.2.1", 49 | "style-loader": "^2.0.0", 50 | "ts-loader": "^8.0.12", 51 | "ts-node": "^9.1.1", 52 | "typed-css-modules": "^0.6.4", 53 | "typescript": "^4.1.3", 54 | "webpack": "^4.44.2", 55 | "webpack-cli": "^4.2.0", 56 | "webpack-dev-server": "^3.11.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Synth 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/attackKnob/AttackKnob.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: rgb(239, 240, 241); 3 | user-select: none; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/attackKnob/index.tsx: -------------------------------------------------------------------------------- 1 | import { Knob } from '../knob'; 2 | 3 | import styles from './AttackKnob.css'; 4 | 5 | interface Props { 6 | knobValue: number; 7 | nextKnobValue: (_: number) => void; 8 | } 9 | 10 | export const AttackKnob: React.VFC = ({ knobValue, nextKnobValue }) => ( 11 |
12 | 20 |
ATTACK: {knobValue.toFixed(2)}
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /src/components/decayKnob/DecayKnob.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: rgb(239, 240, 241); 3 | user-select: none; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/decayKnob/index.tsx: -------------------------------------------------------------------------------- 1 | import { Knob } from '../knob'; 2 | 3 | import styles from './DecayKnob.css'; 4 | 5 | interface Props { 6 | knobValue: number; 7 | nextKnobValue: (_: number) => void; 8 | } 9 | 10 | export const DecayKnob: React.VFC = ({ knobValue, nextKnobValue }) => ( 11 |
12 | 20 |
DECAY: {knobValue.toFixed(2)}
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /src/components/detuneKnob/DetuneKnob.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: rgb(239, 240, 241); 3 | user-select: none; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/detuneKnob/index.tsx: -------------------------------------------------------------------------------- 1 | import { Knob } from '../knob'; 2 | 3 | import styles from './DetuneKnob.css'; 4 | 5 | interface Props { 6 | knobValue: number; 7 | nextKnobValue: (_: number) => void; 8 | } 9 | 10 | export const DetuneKnob: React.VFC = ({ knobValue, nextKnobValue }) => ( 11 |
12 | 20 |
DETUNE: {knobValue.toFixed(1)}
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /src/components/gainKnob/GainKnob.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: rgb(239, 240, 241); 3 | user-select: none; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/gainKnob/index.tsx: -------------------------------------------------------------------------------- 1 | import { Knob } from '../knob'; 2 | 3 | import styles from './GainKnob.css'; 4 | 5 | interface Props { 6 | knobValue: number; 7 | nextKnobValue: (_: number) => void; 8 | } 9 | 10 | export const GainKnob: React.VFC = ({ knobValue, nextKnobValue }) => ( 11 |
12 | 20 |
MASTER: {knobValue.toFixed(2)}
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './attackKnob'; 2 | export * from './decayKnob'; 3 | export * from './detuneKnob'; 4 | export * from './gainKnob'; 5 | export * from './keyboard'; 6 | export * from './noiseGainKnob'; 7 | export * from './oscilloscope'; 8 | export * from './releaseKnob'; 9 | export * from './sustainKnob'; 10 | export * from './unisonInput'; 11 | export * from './waveformSelectbox'; 12 | -------------------------------------------------------------------------------- /src/components/keyboard/Key.css: -------------------------------------------------------------------------------- 1 | .whiteKey { 2 | fill: rgb(244, 245, 245); 3 | } 4 | 5 | .whiteKey:hover { 6 | fill: rgb(224, 224, 224); 7 | } 8 | 9 | .blackKey { 10 | fill: rgb(58, 58, 58); 11 | } 12 | 13 | .blackKey:hover { 14 | fill: black; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/keyboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { Key, Props as PropsForKey } from './key'; 2 | 3 | export interface Props { 4 | strokeHandler: PropsForKey['strokeHandler']; 5 | releaseHandler: PropsForKey['releaseHandler']; 6 | } 7 | 8 | export const Keyboard: React.VFC = ({ 9 | strokeHandler, 10 | releaseHandler, 11 | }) => { 12 | const keys: PropsForKey[] = ([ 13 | { 14 | kind: 'white', 15 | x: 0, 16 | }, 17 | { 18 | kind: 'black', 19 | x: 20, 20 | }, 21 | { 22 | kind: 'white', 23 | x: 30, 24 | }, 25 | { 26 | kind: 'black', 27 | x: 52, 28 | }, 29 | { 30 | kind: 'white', 31 | x: 60, 32 | }, 33 | { 34 | kind: 'white', 35 | x: 90, 36 | }, 37 | { 38 | kind: 'black', 39 | x: 108, 40 | }, 41 | { 42 | kind: 'white', 43 | x: 120, 44 | }, 45 | { 46 | kind: 'black', 47 | x: 142, 48 | }, 49 | { 50 | kind: 'white', 51 | x: 150, 52 | }, 53 | { 54 | kind: 'black', 55 | x: 174, 56 | }, 57 | { 58 | kind: 'white', 59 | x: 180, 60 | }, 61 | ] as const).map((e, i) => ({ 62 | ...e, 63 | noteNum: i + 60, 64 | strokeHandler, 65 | releaseHandler, 66 | })); 67 | 68 | return ( 69 | 70 | {keys 71 | .filter((keyProps) => keyProps.kind === 'white') 72 | .map((keyProps) => ( 73 | 74 | ))} 75 | {keys 76 | .filter((keyProps) => keyProps.kind === 'black') 77 | .map((keyProps) => ( 78 | 79 | ))} 80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/keyboard/key.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | import { noteNumToFreq } from '../../domains/keyboard'; 4 | 5 | import styles from './Key.css'; 6 | 7 | export interface Props { 8 | kind: 'white' | 'black'; 9 | x: number; 10 | noteNum: number; 11 | strokeHandler: (_: { id: number; freq: number }) => void; 12 | releaseHandler: (id: number) => void; 13 | } 14 | 15 | export const Key: React.VFC = ({ 16 | kind, 17 | x, 18 | noteNum, 19 | strokeHandler, 20 | releaseHandler, 21 | }) => { 22 | const [stroked, setStroked] = useState(false); 23 | 24 | const onStroke = useCallback(() => { 25 | if (!stroked) strokeHandler({ id: noteNum, freq: noteNumToFreq(noteNum) }); 26 | setStroked(true); 27 | }, [stroked]); 28 | 29 | const onRelease = useCallback(() => { 30 | if (stroked) releaseHandler(noteNum); 31 | setStroked(false); 32 | }, [stroked]); 33 | 34 | switch (kind) { 35 | case 'white': 36 | return ( 37 | 49 | ); 50 | case 'black': 51 | return ( 52 | 62 | ); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/knob.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | interface Props { 4 | knobValue: number; 5 | min: number; 6 | max: number; 7 | step: number; 8 | dragSpeed: number; 9 | nextKnobValue: (_: number) => void; 10 | } 11 | 12 | const limitToWithinRange = ({ 13 | val, 14 | min, 15 | max, 16 | }: { 17 | val: number; 18 | min: number; 19 | max: number; 20 | }) => (val < min ? min : val > max ? max : val); 21 | 22 | const toConstMul = ({ 23 | val, 24 | multiplier, 25 | }: { 26 | val: number; 27 | multiplier: number; 28 | }) => { 29 | return Math.floor(val / multiplier) * multiplier; 30 | }; 31 | 32 | export const Knob: React.VFC = ({ 33 | knobValue, 34 | min, 35 | max, 36 | step, 37 | dragSpeed, 38 | nextKnobValue, 39 | }) => { 40 | const [dragging, setDragging] = useState(false); 41 | 42 | const [focused, setFocused] = useState(false); 43 | 44 | const mouseDownHandler = useCallback( 45 | (e: React.MouseEvent) => { 46 | if (dragging) return; 47 | const yCoord = e.screenY; 48 | setDragging(true); 49 | const mouseMoveHandler = (e: MouseEvent) => { 50 | const yDiff = yCoord - e.screenY; 51 | const draft = 52 | knobValue + toConstMul({ val: yDiff * dragSpeed, multiplier: step }); 53 | const val = limitToWithinRange({ val: draft, min, max }); 54 | nextKnobValue(val); 55 | }; 56 | const mouseUpHandler = () => { 57 | setDragging(false); 58 | document.removeEventListener('mousemove', mouseMoveHandler); 59 | document.removeEventListener('mouseup', mouseUpHandler); 60 | }; 61 | document.addEventListener('mousemove', mouseMoveHandler); 62 | document.addEventListener('mouseup', mouseUpHandler); 63 | }, 64 | [dragging] 65 | ); 66 | 67 | const focusHandler = useCallback(() => { 68 | setFocused(true); 69 | }, [setFocused]); 70 | 71 | const blurHandler = useCallback(() => { 72 | setFocused(false); 73 | }, [setFocused]); 74 | 75 | const keydownHandler = useCallback( 76 | (e: React.KeyboardEvent) => { 77 | if (!focused) return; 78 | 79 | if (e.key === 'Up' || e.key === 'ArrowUp') { 80 | const draft = knobValue + step; 81 | const val = limitToWithinRange({ val: draft, min, max }); 82 | nextKnobValue(val); 83 | e.preventDefault(); 84 | e.stopPropagation(); 85 | return; 86 | } 87 | 88 | if (e.key === 'Down' || e.key === 'ArrowDown') { 89 | const draft = knobValue - step; 90 | const val = limitToWithinRange({ val: draft, min, max }); 91 | nextKnobValue(val); 92 | e.preventDefault(); 93 | e.stopPropagation(); 94 | return; 95 | } 96 | }, 97 | [focused, knobValue] 98 | ); 99 | 100 | return ( 101 | 111 | 116 | 124 | 125 | 126 | 127 | ); 128 | }; 129 | -------------------------------------------------------------------------------- /src/components/noiseGainKnob/NoiseGainKnob.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: rgb(239, 240, 241); 3 | user-select: none; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/noiseGainKnob/index.tsx: -------------------------------------------------------------------------------- 1 | import { Knob } from '../knob'; 2 | 3 | import styles from './NoiseGainKnob.css'; 4 | 5 | interface Props { 6 | knobValue: number; 7 | nextKnobValue: (_: number) => void; 8 | } 9 | 10 | export const NoiseGainKnob: React.VFC = ({ 11 | knobValue, 12 | nextKnobValue, 13 | }) => ( 14 |
15 | 23 |
NOISE: {knobValue.toFixed(2)}
24 |
25 | ); 26 | -------------------------------------------------------------------------------- /src/components/oscilloscope/Oscilloscope.css: -------------------------------------------------------------------------------- 1 | .analyzer { 2 | width: 210px; 3 | height: 140px; 4 | border: 2px solid rgb(50, 56, 62); 5 | } 6 | -------------------------------------------------------------------------------- /src/components/oscilloscope/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import styles from './Oscilloscope.css'; 4 | 5 | interface Props { 6 | analyserNode: AnalyserNode; 7 | } 8 | 9 | export const Oscilloscope: React.VFC = ({ analyserNode }) => { 10 | const ref = useRef(null); 11 | const requestRef = useRef(); 12 | 13 | useEffect(() => { 14 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 15 | const ctx = ref.current!.getContext('2d')!; 16 | 17 | const bufferLength = analyserNode.frequencyBinCount; 18 | const dataArray = new Uint8Array(bufferLength); 19 | 20 | const animate = () => { 21 | analyserNode.getByteTimeDomainData(dataArray); 22 | 23 | ctx.fillStyle = 'rgb(38, 42, 46)'; 24 | ctx.fillRect(0, 0, 300, 200); 25 | 26 | ctx.lineWidth = 2; 27 | ctx.strokeStyle = 'rgb(125, 225, 30)'; 28 | ctx.beginPath(); 29 | 30 | for (let i = 0; i < bufferLength; i++) { 31 | const x = 300 * (i / bufferLength); 32 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 33 | const normalizedAmp = dataArray[i]! / 255.0; 34 | const y = (1 - normalizedAmp) * 200; 35 | if (i === 0) { 36 | ctx.moveTo(x, y); 37 | } else { 38 | ctx.lineTo(x, y); 39 | } 40 | } 41 | 42 | ctx.lineTo(300, 100); 43 | ctx.stroke(); 44 | 45 | requestRef.current = requestAnimationFrame(animate); 46 | }; 47 | 48 | animate(); 49 | 50 | return () => { 51 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 52 | cancelAnimationFrame(requestRef.current!); 53 | }; 54 | }, []); 55 | 56 | return ( 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/releaseKnob/ReleaseKnob.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: rgb(239, 240, 241); 3 | user-select: none; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/releaseKnob/index.tsx: -------------------------------------------------------------------------------- 1 | import { Knob } from '../knob'; 2 | 3 | import styles from './ReleaseKnob.css'; 4 | 5 | interface Props { 6 | knobValue: number; 7 | nextKnobValue: (_: number) => void; 8 | } 9 | 10 | export const ReleaseKnob: React.VFC = ({ knobValue, nextKnobValue }) => ( 11 |
12 | 20 |
RELEASE: {knobValue.toFixed(2)}
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /src/components/sustainKnob/SustainKnob.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: rgb(239, 240, 241); 3 | user-select: none; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/sustainKnob/index.tsx: -------------------------------------------------------------------------------- 1 | import { Knob } from '../knob'; 2 | 3 | import styles from './SustainKnob.css'; 4 | 5 | interface Props { 6 | knobValue: number; 7 | nextKnobValue: (_: number) => void; 8 | } 9 | 10 | export const SustainKnob: React.VFC = ({ knobValue, nextKnobValue }) => ( 11 |
12 | 20 |
SUSTAIN: {knobValue.toFixed(2)}
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /src/components/unisonInput/UnisonInput.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: rgb(239, 240, 241); 3 | user-select: none; 4 | } 5 | 6 | .numberInput { 7 | width: 3em; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/unisonInput/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './UnisonInput.css'; 2 | 3 | interface Props { 4 | unison: number; 5 | changeHandler: (e: React.ChangeEvent) => void; 6 | } 7 | 8 | export const UnisonInput: React.VFC = ({ unison, changeHandler }) => ( 9 |
10 |
11 | UNISON:  12 | 20 |
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /src/components/waveformSelectbox/WaveformSelectbox.css: -------------------------------------------------------------------------------- 1 | .selectbox { 2 | width: 150px; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/waveformSelectbox/index.tsx: -------------------------------------------------------------------------------- 1 | import { Waveform, waveforms } from '../../domains/waveform'; 2 | 3 | import styles from './WaveformSelectbox.css'; 4 | 5 | interface Props { 6 | waveform: Waveform; 7 | changeHandler: (e: React.ChangeEvent) => void; 8 | } 9 | 10 | export const WaveformSelectbox: React.VFC = ({ 11 | waveform, 12 | changeHandler, 13 | }) => ( 14 | 25 | ); 26 | -------------------------------------------------------------------------------- /src/containers/attackKnob.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { AttackKnob } from '../components'; 5 | 6 | export const AttackKnobContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | const attack = useSelector((state) => state.amplifier.attack); 9 | const nextKnobValue = useCallback( 10 | (period: number) => { 11 | dispatch({ type: 'updateAttack', payload: { period } }); 12 | }, 13 | [dispatch] 14 | ); 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /src/containers/decayKnob.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { DecayKnob } from '../components'; 5 | 6 | export const DecayKnobContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | const decay = useSelector((state) => state.amplifier.decay); 9 | const nextKnobValue = useCallback( 10 | (period: number) => { 11 | dispatch({ type: 'updateDecay', payload: { period } }); 12 | }, 13 | [dispatch] 14 | ); 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /src/containers/detuneKnob.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { DetuneKnob } from '../components'; 5 | 6 | export const DetuneKnobContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | const detune = useSelector((state) => state.oscillator.detune); 9 | const nextKnobValue = useCallback( 10 | (interval: number) => { 11 | dispatch({ type: 'updateDetune', payload: { interval } }); 12 | }, 13 | [dispatch] 14 | ); 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /src/containers/gainKnob.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { GainKnob } from '../components'; 5 | 6 | export const GainKnobContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | const gain = useSelector((state) => state.oscillator.gain); 9 | const nextKnobValue = useCallback( 10 | (rate: number) => { 11 | dispatch({ type: 'updateGain', payload: { rate } }); 12 | }, 13 | [dispatch] 14 | ); 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /src/containers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './attackKnob'; 2 | export * from './decayKnob'; 3 | export * from './detuneKnob'; 4 | export * from './gainKnob'; 5 | export * from './keyboard'; 6 | export * from './noiseGainKnob'; 7 | export * from './oscilloscope'; 8 | export * from './releaseKnob'; 9 | export * from './sustainKnob'; 10 | export * from './unisonInput'; 11 | export * from './waveformSelectbox'; 12 | -------------------------------------------------------------------------------- /src/containers/keyboard.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux'; 2 | 3 | import { Keyboard, Props as KeyboardProps } from '../components'; 4 | import { stroke, release } from '../store'; 5 | 6 | export const KeyboardContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | 9 | const strokeHandler: KeyboardProps['strokeHandler'] = ({ id, freq }) => { 10 | dispatch(stroke({ id, freq })); 11 | }; 12 | 13 | const releaseHandler: KeyboardProps['releaseHandler'] = (id) => { 14 | dispatch(release({ id })); 15 | }; 16 | 17 | return ( 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/containers/noiseGainKnob.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { NoiseGainKnob } from '../components'; 5 | 6 | export const NoiseGainKnobContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | const noiseGain = useSelector((state) => state.oscillator.noiseGain); 9 | const nextKnobValue = useCallback( 10 | (rate: number) => { 11 | dispatch({ type: 'updateNoiseGain', payload: { rate } }); 12 | }, 13 | [dispatch] 14 | ); 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /src/containers/oscilloscope.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | 3 | import { Oscilloscope } from '../components'; 4 | 5 | export const OscilloscopeContainer: React.VFC = () => { 6 | const analyserNode = useSelector((state) => state.oscilloscope.analyserNode); 7 | return <>{analyserNode && }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/containers/releaseKnob.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { ReleaseKnob } from '../components'; 5 | 6 | export const ReleaseKnobContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | const release = useSelector((state) => state.amplifier.release); 9 | const nextKnobValue = useCallback( 10 | (period: number) => { 11 | dispatch({ type: 'updateRelease', payload: { period } }); 12 | }, 13 | [dispatch] 14 | ); 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /src/containers/sustainKnob.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { SustainKnob } from '../components'; 5 | 6 | export const SustainKnobContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | const sustain = useSelector((state) => state.amplifier.sustain); 9 | const nextKnobValue = useCallback( 10 | (volume: number) => { 11 | dispatch({ type: 'updateSustain', payload: { volume } }); 12 | }, 13 | [dispatch] 14 | ); 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /src/containers/unisonInput.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { UnisonInput } from '../components'; 5 | 6 | export const UnisonInputContainer: React.VFC = () => { 7 | const dispatch = useDispatch(); 8 | 9 | const unison = useSelector((state) => state.oscillator.unison); 10 | 11 | const changeHandler = useCallback( 12 | (e: React.ChangeEvent) => { 13 | dispatch({ 14 | type: 'updateUnison', 15 | payload: { count: Number(e.target.value) }, 16 | }); 17 | }, 18 | [dispatch] 19 | ); 20 | 21 | return ; 22 | }; 23 | -------------------------------------------------------------------------------- /src/containers/waveformSelectbox.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { Waveform } from '../domains/waveform'; 5 | import { WaveformSelectbox } from '../components'; 6 | import { updateWaveform } from '../store'; 7 | 8 | export const WaveformSelectboxContainer: React.VFC = () => { 9 | const dispatch = useDispatch(); 10 | 11 | const changeHandler = useCallback( 12 | (e: React.ChangeEvent) => { 13 | dispatch( 14 | updateWaveform({ 15 | waveform: e.target.value as Waveform, 16 | }) 17 | ); 18 | }, 19 | [dispatch] 20 | ); 21 | 22 | const waveform = useSelector((state) => state.oscillator.waveform); 23 | 24 | return ( 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/domains/keyboard.ts: -------------------------------------------------------------------------------- 1 | export const noteNumToFreq = (noteNum: number): number => 2 | 440 * Math.pow(2, (noteNum - 69) / 12); 3 | -------------------------------------------------------------------------------- /src/domains/waveform.ts: -------------------------------------------------------------------------------- 1 | export const waveforms = ['sine', 'square', 'sawtooth', 'triangle'] as const; 2 | 3 | export type Waveform = typeof waveforms[number]; 4 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | padding: 0; 3 | margin: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | background-color: rgb(84, 91, 97); 9 | } 10 | 11 | .knobs { 12 | display: grid; 13 | grid-template-columns: 150px 150px 150px 150px; 14 | align-items: center; 15 | margin: 10px; 16 | } 17 | 18 | .analyzerAndKeyboard { 19 | display: flex; 20 | flex-direction: column; 21 | margin-left: 10px; 22 | } 23 | 24 | .selectbox { 25 | margin-bottom: 10px; 26 | } 27 | 28 | .analyzer { 29 | margin-bottom: 10px; 30 | } 31 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import { Provider } from 'react-redux'; 3 | import { applyMiddleware, compose, createStore } from 'redux'; 4 | import createSagaMiddleware from 'redux-saga'; 5 | 6 | import { 7 | AttackKnobContainer, 8 | DecayKnobContainer, 9 | DetuneKnobContainer, 10 | GainKnobContainer, 11 | KeyboardContainer, 12 | NoiseGainKnobContainer, 13 | OscilloscopeContainer, 14 | ReleaseKnobContainer, 15 | SustainKnobContainer, 16 | UnisonInputContainer, 17 | WaveformSelectboxContainer, 18 | } from './containers'; 19 | import { rootSaga } from './sagas'; 20 | import { reducer } from './store'; 21 | 22 | import styles from './global.css'; 23 | 24 | const composeEnhancers = 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | (window as any)?.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 27 | 28 | const sagaMiddleware = createSagaMiddleware(); 29 | 30 | const store = createStore( 31 | reducer, 32 | composeEnhancers(applyMiddleware(sagaMiddleware)) 33 | ); 34 | 35 | sagaMiddleware.run(rootSaga); 36 | 37 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 38 | const root = document.getElementById('root')!; 39 | ReactDOM.render( 40 | 41 |
42 | 43 | 44 | 45 | 46 |
47 |
48 | 49 | 50 | 51 | 52 |
53 |
54 |
55 | 56 |
57 |
58 | 59 |
60 | 61 |
62 |
, 63 | root 64 | ); 65 | -------------------------------------------------------------------------------- /src/react-redux.d.ts: -------------------------------------------------------------------------------- 1 | import 'react-redux'; 2 | import { Dispatch, Store } from 'redux'; 3 | 4 | import { Actions } from './store/actions'; 5 | import { State } from './store/state'; 6 | 7 | declare module 'react-redux' { 8 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 9 | interface DefaultRootState extends State {} 10 | 11 | export function useDispatch>(): TDispatch; 12 | 13 | export function useStore(): Store; 14 | } 15 | -------------------------------------------------------------------------------- /src/sagas/index.ts: -------------------------------------------------------------------------------- 1 | import { fork } from 'redux-saga/effects'; 2 | 3 | import { keyboard } from './keyboard'; 4 | import { midi } from './midi'; 5 | 6 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 7 | 8 | export function* rootSaga() { 9 | yield fork(keyboard); 10 | yield fork(midi); 11 | } 12 | -------------------------------------------------------------------------------- /src/sagas/keyboard.ts: -------------------------------------------------------------------------------- 1 | import { all, put, select, take } from 'redux-saga/effects'; 2 | 3 | import { ReleaseAction, State, StrokeAction, getAnalyserNode } from '../store'; 4 | 5 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 6 | 7 | type Oscillators = Map< 8 | number, 9 | { 10 | oscNodes: OscillatorNode[]; 11 | amp: GainNode; 12 | noise: AudioBufferSourceNode; 13 | noiseGain: GainNode; 14 | } 15 | >; 16 | 17 | export function* keyboard() { 18 | const ctx = new AudioContext(); 19 | 20 | const analyserNode = ctx.createAnalyser(); 21 | yield put(getAnalyserNode({ analyserNode })); 22 | 23 | const oscillators: Oscillators = new Map(); 24 | 25 | const masterGain = ctx.createGain(); 26 | masterGain.connect(analyserNode).connect(ctx.destination); 27 | 28 | yield all([stroke(ctx, masterGain, oscillators), release(ctx, oscillators)]); 29 | } 30 | 31 | function* stroke(ctx: AudioContext, masterGain: GainNode, oscMap: Oscillators) { 32 | while (true) { 33 | const { 34 | payload: { id, freq }, 35 | } = (yield take('stroke')) as StrokeAction; 36 | 37 | const { 38 | oscillator: { waveform, gain, unison, detune, noiseGain }, 39 | amplifier: { attack, decay, sustain }, 40 | } = (yield select()) as State; 41 | 42 | const oscs: OscillatorNode[] = []; 43 | const median = Math.floor(unison / 2); 44 | 45 | const amp = ctx.createGain(); 46 | 47 | for (let i = 0; i < unison; i++) { 48 | const oscNode = ctx.createOscillator(); 49 | oscNode.frequency.value = freq + (i - median) * detune; 50 | oscNode.type = waveform; 51 | amp.gain.setValueAtTime(0, ctx.currentTime); 52 | amp.gain.linearRampToValueAtTime(gain, ctx.currentTime + attack); 53 | amp.gain.linearRampToValueAtTime( 54 | sustain * gain, 55 | ctx.currentTime + attack + decay 56 | ); 57 | oscNode.connect(amp); 58 | oscs.push(oscNode); 59 | } 60 | 61 | const noise = ctx.createBufferSource(); 62 | const noiseGainNode = ctx.createGain(); 63 | noiseGainNode.gain.value = noiseGain; 64 | const channels = 2; 65 | const frameCount = ctx.sampleRate * 2.0; 66 | const audioBuffer = ctx.createBuffer(channels, frameCount, ctx.sampleRate); 67 | 68 | for (let channel = 0; channel < channels; channel++) { 69 | const nowBuffering = audioBuffer.getChannelData(channel); 70 | for (let i = 0; i < frameCount; i++) { 71 | nowBuffering[i] = Math.random() * 2 - 1; 72 | } 73 | } 74 | 75 | noise.buffer = audioBuffer; 76 | noise.connect(noiseGainNode).connect(amp); 77 | 78 | amp.connect(masterGain); 79 | 80 | oscMap.set(id, { oscNodes: oscs, amp, noise, noiseGain: noiseGainNode }); 81 | 82 | masterGain.gain.setValueAtTime(0, ctx.currentTime); 83 | masterGain.gain.linearRampToValueAtTime(gain, ctx.currentTime + attack); 84 | masterGain.gain.linearRampToValueAtTime( 85 | sustain * gain, 86 | ctx.currentTime + attack + decay 87 | ); 88 | 89 | for (const osc of oscs) osc.start(); 90 | noise.start(); 91 | } 92 | } 93 | 94 | function* release(ctx: AudioContext, oscMap: Oscillators) { 95 | while (true) { 96 | const { 97 | payload: { id }, 98 | } = (yield take('release')) as ReleaseAction; 99 | 100 | const { 101 | amplifier: { release }, 102 | } = (yield select()) as State; 103 | 104 | const oscs = oscMap.get(id); 105 | if (oscs !== undefined) { 106 | const endTime = ctx.currentTime + release; 107 | oscs.amp.gain.linearRampToValueAtTime(0, endTime); 108 | 109 | for (const oscNode of oscs.oscNodes) oscNode.stop(endTime); 110 | oscs.noise.stop(endTime); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/sagas/midi.ts: -------------------------------------------------------------------------------- 1 | import { channel } from 'redux-saga'; 2 | import { call, put, takeEvery } from 'redux-saga/effects'; 3 | 4 | import { noteNumToFreq } from '../domains/keyboard'; 5 | 6 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 7 | 8 | interface MIDIMsg { 9 | kind: 'noteOn' | 'noteOff'; 10 | noteNum: number; 11 | velocity: number; 12 | } 13 | 14 | const midiMsgChannel = channel(); 15 | 16 | export function* midi() { 17 | const requestMIDIAccess = async (): Promise< 18 | IterableIterator 19 | > => { 20 | const midiAccess = await navigator.requestMIDIAccess({ sysex: true }); 21 | return midiAccess.inputs.values(); 22 | }; 23 | 24 | const devices: WebMidi.MIDIInput[] = yield call(requestMIDIAccess); 25 | for (const device of devices) { 26 | device.addEventListener('midimessage', (e) => { 27 | const { data } = e; 28 | if (data.length < 3) return; 29 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 30 | const first = data[0]!; 31 | const noteNum = data[1]!; 32 | const velocity = data[2]!; 33 | /* eslint-enable @typescript-eslint/no-non-null-assertion */ 34 | const kind = Math.floor(first / 16); 35 | if (kind === 8) { 36 | midiMsgChannel.put({ kind: 'noteOff', noteNum, velocity }); 37 | } else if (kind === 9) { 38 | midiMsgChannel.put({ kind: 'noteOn', noteNum, velocity }); 39 | } 40 | }); 41 | } 42 | 43 | yield takeEvery(midiMsgChannel, play); 44 | } 45 | 46 | function* play(msg: MIDIMsg) { 47 | switch (msg.kind) { 48 | case 'noteOn': 49 | yield put({ 50 | type: 'stroke', 51 | payload: { id: msg.noteNum, freq: noteNumToFreq(msg.noteNum) }, 52 | }); 53 | break; 54 | case 'noteOff': 55 | yield put({ type: 'release', payload: { id: msg.noteNum } }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/store/actions.ts: -------------------------------------------------------------------------------- 1 | import { AmplifierActions } from './amplifier/actions'; 2 | import { KeyboardActions } from './keyboard/actions'; 3 | import { OscillatorActions } from './oscillator/actions'; 4 | import { OscilloscopeActions } from './oscilloscope/actions'; 5 | 6 | export * from './amplifier/actions'; 7 | export * from './keyboard/actions'; 8 | export * from './oscillator/actions'; 9 | export * from './oscilloscope/actions'; 10 | 11 | export type Actions = 12 | | OscillatorActions 13 | | AmplifierActions 14 | | OscilloscopeActions 15 | | KeyboardActions; 16 | -------------------------------------------------------------------------------- /src/store/amplifier/actions.ts: -------------------------------------------------------------------------------- 1 | export type AmplifierActions = 2 | | UpdateAttackAction 3 | | UpdateDecayAction 4 | | UpdateSustainAction 5 | | UpdateReleaseAction; 6 | 7 | export interface UpdateAttackAction { 8 | type: 'updateAttack'; 9 | payload: { 10 | period: number; 11 | }; 12 | } 13 | 14 | export interface UpdateDecayAction { 15 | type: 'updateDecay'; 16 | payload: { 17 | period: number; 18 | }; 19 | } 20 | 21 | export interface UpdateSustainAction { 22 | type: 'updateSustain'; 23 | payload: { 24 | volume: number; 25 | }; 26 | } 27 | 28 | export interface UpdateReleaseAction { 29 | type: 'updateRelease'; 30 | payload: { 31 | period: number; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/store/amplifier/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | import { AmplifierActions } from './actions'; 3 | import { AmplifierState, initialAmpState } from './state'; 4 | 5 | export const ampReducer: Reducer = ( 6 | state: AmplifierState = initialAmpState, 7 | action 8 | ): AmplifierState => { 9 | switch (action.type) { 10 | case 'updateAttack': 11 | return { ...state, attack: action.payload.period }; 12 | case 'updateDecay': 13 | return { ...state, decay: action.payload.period }; 14 | case 'updateSustain': 15 | return { ...state, sustain: action.payload.volume }; 16 | case 'updateRelease': 17 | return { ...state, release: action.payload.period }; 18 | default: 19 | return state; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/store/amplifier/state.ts: -------------------------------------------------------------------------------- 1 | export interface AmplifierState { 2 | attack: number; 3 | decay: number; 4 | sustain: number; 5 | release: number; 6 | } 7 | 8 | export const initialAmpState: AmplifierState = { 9 | attack: 0, 10 | decay: 0, 11 | sustain: 1, 12 | release: 0, 13 | }; 14 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './reducer'; 3 | export * from './state'; 4 | -------------------------------------------------------------------------------- /src/store/keyboard/actions.ts: -------------------------------------------------------------------------------- 1 | export type KeyboardActions = StrokeAction | ReleaseAction; 2 | 3 | export interface StrokeAction { 4 | type: 'stroke'; 5 | payload: { 6 | id: number; 7 | freq: number; 8 | }; 9 | } 10 | 11 | export const stroke = ({ 12 | id, 13 | freq, 14 | }: StrokeAction['payload']): StrokeAction => ({ 15 | type: 'stroke', 16 | payload: { id, freq }, 17 | }); 18 | 19 | export interface ReleaseAction { 20 | type: 'release'; 21 | payload: { 22 | id: number; 23 | }; 24 | } 25 | 26 | export const release = ({ id }: ReleaseAction['payload']): ReleaseAction => ({ 27 | type: 'release', 28 | payload: { id }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/store/oscillator/actions.ts: -------------------------------------------------------------------------------- 1 | export type OscillatorActions = 2 | | UpdateGainAction 3 | | UpdateUnisonAction 4 | | UpdateDetuneAction 5 | | UpdateWaveformAction 6 | | UpdateNoiseGainAction; 7 | 8 | export interface UpdateGainAction { 9 | type: 'updateGain'; 10 | payload: { 11 | rate: number; 12 | }; 13 | } 14 | 15 | export interface UpdateUnisonAction { 16 | type: 'updateUnison'; 17 | payload: { 18 | count: number; 19 | }; 20 | } 21 | 22 | export interface UpdateDetuneAction { 23 | type: 'updateDetune'; 24 | payload: { 25 | interval: number; 26 | }; 27 | } 28 | 29 | export interface UpdateWaveformAction { 30 | type: 'updateWaveform'; 31 | payload: { 32 | waveform: 'sine' | 'square' | 'sawtooth' | 'triangle'; 33 | }; 34 | } 35 | 36 | export const updateWaveform = ({ 37 | waveform, 38 | }: UpdateWaveformAction['payload']): UpdateWaveformAction => ({ 39 | type: 'updateWaveform', 40 | payload: { waveform }, 41 | }); 42 | 43 | export interface UpdateNoiseGainAction { 44 | type: 'updateNoiseGain'; 45 | payload: { 46 | rate: number; 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/store/oscillator/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | 3 | import { OscillatorActions } from './actions'; 4 | import { OscillatorState, initialOscillatorState } from './state'; 5 | 6 | export const oscillatorReducer: Reducer = ( 7 | state: OscillatorState = initialOscillatorState, 8 | action 9 | ): OscillatorState => { 10 | switch (action.type) { 11 | case 'updateGain': 12 | return { ...state, gain: action.payload.rate }; 13 | case 'updateUnison': 14 | return { ...state, unison: action.payload.count }; 15 | case 'updateDetune': 16 | return { ...state, detune: action.payload.interval }; 17 | case 'updateWaveform': 18 | return { ...state, waveform: action.payload.waveform }; 19 | case 'updateNoiseGain': 20 | return { ...state, noiseGain: action.payload.rate }; 21 | default: 22 | return state; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/store/oscillator/state.ts: -------------------------------------------------------------------------------- 1 | export interface OscillatorState { 2 | waveform: 'sine' | 'square' | 'sawtooth' | 'triangle'; 3 | gain: number; 4 | unison: number; 5 | detune: number; 6 | noiseGain: number; 7 | } 8 | 9 | export const initialOscillatorState: OscillatorState = { 10 | waveform: 'triangle', 11 | gain: 1, 12 | unison: 1, 13 | detune: 0, 14 | noiseGain: 0, 15 | }; 16 | -------------------------------------------------------------------------------- /src/store/oscilloscope/actions.ts: -------------------------------------------------------------------------------- 1 | export type OscilloscopeActions = GetAnalyserNodeAction; 2 | 3 | interface GetAnalyserNodeAction { 4 | type: 'getAnalyserNode'; 5 | payload: { 6 | analyserNode: AnalyserNode; 7 | }; 8 | } 9 | 10 | export const getAnalyserNode = ({ 11 | analyserNode, 12 | }: GetAnalyserNodeAction['payload']): GetAnalyserNodeAction => ({ 13 | type: 'getAnalyserNode', 14 | payload: { 15 | analyserNode, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/store/oscilloscope/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | 3 | import { OscilloscopeActions } from './actions'; 4 | import { OscilloscopeState, initialOscilloscopeState } from './state'; 5 | 6 | export const oscilloscopeReducer: Reducer< 7 | OscilloscopeState, 8 | OscilloscopeActions 9 | > = ( 10 | state: OscilloscopeState = initialOscilloscopeState, 11 | action 12 | ): OscilloscopeState => { 13 | switch (action.type) { 14 | case 'getAnalyserNode': 15 | return { ...state, analyserNode: action.payload.analyserNode }; 16 | default: 17 | return state; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/store/oscilloscope/state.ts: -------------------------------------------------------------------------------- 1 | export interface OscilloscopeState { 2 | analyserNode: AnalyserNode | null; 3 | } 4 | 5 | export const initialOscilloscopeState: OscilloscopeState = { 6 | analyserNode: null, 7 | }; 8 | -------------------------------------------------------------------------------- /src/store/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import { ampReducer } from './amplifier/reducer'; 4 | import { oscillatorReducer } from './oscillator/reducer'; 5 | import { oscilloscopeReducer } from './oscilloscope/reducer'; 6 | 7 | export const reducer = combineReducers({ 8 | oscillator: oscillatorReducer, 9 | amplifier: ampReducer, 10 | oscilloscope: oscilloscopeReducer, 11 | }); 12 | -------------------------------------------------------------------------------- /src/store/state.ts: -------------------------------------------------------------------------------- 1 | import { AmplifierState } from './amplifier/state'; 2 | import { OscillatorState } from './oscillator/state'; 3 | import { OscilloscopeState } from './oscilloscope/state'; 4 | 5 | export interface State { 6 | oscillator: OscillatorState; 7 | amplifier: AmplifierState; 8 | oscilloscope: OscilloscopeState; 9 | } 10 | -------------------------------------------------------------------------------- /src/stories/Keyboard.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Story, Meta } from '@storybook/react'; 2 | 3 | import { Keyboard } from '../components'; 4 | 5 | export const keyboard: Story = () => { 6 | const noop = () => void 0; 7 | return ; 8 | }; 9 | 10 | const meta: Meta = { 11 | title: 'Keyboard', 12 | }; 13 | 14 | export default meta; 15 | -------------------------------------------------------------------------------- /src/stories/Knob.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Story, Meta } from '@storybook/react'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { Knob } from '../components/knob'; 5 | 6 | interface Props { 7 | initialValue: number; 8 | min: number; 9 | max: number; 10 | step: number; 11 | dragSpeed: number; 12 | } 13 | 14 | const KnobContainer: React.VFC = ({ 15 | initialValue, 16 | min, 17 | max, 18 | step, 19 | dragSpeed, 20 | }) => { 21 | const [knobValue, setKnobValue] = useState(initialValue); 22 | 23 | useEffect(() => { 24 | setKnobValue(initialValue); 25 | }, [initialValue]); 26 | 27 | return ( 28 | 36 | ); 37 | }; 38 | 39 | export const knob: Story = (props) => { 40 | return ; 41 | }; 42 | 43 | const meta: Meta = { 44 | title: 'Knob', 45 | args: { 46 | initialValue: 0, 47 | min: 0, 48 | max: 1, 49 | step: 0.01, 50 | dragSpeed: 0.01, 51 | }, 52 | argTypes: { 53 | initialValue: { control: { type: 'number' } }, 54 | }, 55 | }; 56 | 57 | export default meta; 58 | -------------------------------------------------------------------------------- /src/stories/Oscilloscope.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Story, Meta } from '@storybook/react'; 2 | 3 | import { Oscilloscope } from '../components'; 4 | 5 | /** Mock of containers/oscilloscope */ 6 | const OscilloscopeContainer: React.VFC = () => { 7 | const ctx = new AudioContext(); 8 | const analyserNode = ctx.createAnalyser(); 9 | return ; 10 | }; 11 | 12 | export const oscilloscope: Story = () => ; 13 | 14 | const meta: Meta = { 15 | title: 'Oscilloscope', 16 | }; 17 | 18 | export default meta; 19 | -------------------------------------------------------------------------------- /src/stories/UnisonInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Story, Meta } from '@storybook/react'; 2 | import { useState } from 'react'; 3 | 4 | import { UnisonInput } from '../components'; 5 | 6 | /** Mock of containers/unisonInput */ 7 | const UnisonInputContainer: React.VFC = () => { 8 | const [unison, setUnison] = useState(1); 9 | return ( 10 | { 13 | setUnison(Number(e.target.value)); 14 | }} 15 | /> 16 | ); 17 | }; 18 | 19 | export const unisonInput: Story = () => ; 20 | 21 | const meta: Meta = { 22 | title: 'Unison Input', 23 | }; 24 | 25 | export default meta; 26 | -------------------------------------------------------------------------------- /src/stories/WaveformSelectbox.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Story, Meta } from '@storybook/react'; 2 | import { useState } from 'react'; 3 | 4 | import { WaveformSelectbox } from '../components'; 5 | import { Waveform } from '../domains/waveform'; 6 | 7 | /** Mock of containers/waveformSelectbox */ 8 | const WaveformSelectboxContainer: React.VFC = () => { 9 | const [waveform, setWaveform] = useState('triangle'); 10 | return ( 11 | { 14 | setWaveform(e.target.value as Waveform); 15 | }} 16 | /> 17 | ); 18 | }; 19 | 20 | export const waveformSelectbox: Story = () => ( 21 | 22 | ); 23 | 24 | const meta: Meta = { 25 | title: 'Waveform Selectbox', 26 | }; 27 | 28 | export default meta; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* このファイルの詳細については、https://aka.ms/tsconfig.json をご覧ください */ 4 | 5 | /* 基本オプション */ 6 | // "incremental": true, /* インクリメンタル コンパイルを有効にする */ 7 | "target": "es5", /* ECMAScript のターゲット バージョンを指定します: 'ES3' (既定値)、'ES5'、'ES2015'、'ES2016'、'ES2017'、'ES2018'、'ES2019'、'ES2020'、'ESNEXT'。 */ 8 | "module": "commonjs", /* モジュール コードの生成を指定します: 'none'、'commonjs'、'amd'、'system'、'umd'、'es2015'、'es2020、'ESNext'。 */ 9 | // "lib": [], /* コンパイルに含めるライブラリ ファイルを指定します。 */ 10 | // "allowJs": true, /* javascript ファイルのコンパイルを許可します。 */ 11 | // "checkJs": true, /* .js ファイルのエラーを報告します。 */ 12 | "jsx": "react-jsxdev", /* JSX コード生成を指定します: 'preserve'、'react-native'、'react'。 */ 13 | // "declaration": true, /* 対応する '.d.ts' ファイルを生成します。 */ 14 | // "declarationMap": true, /* 対応する各 '.d.ts' ファイルにソースマップを生成します。 */ 15 | "sourceMap": true, /* 対応する '.map' ファイルを生成します。 */ 16 | // "outFile": "./", /* 出力を連結して 1 つのファイルを生成します。 */ 17 | // "outDir": "./", /* ディレクトリへ出力構造をリダイレクトします。 */ 18 | // "rootDir": "./", /* 入力ファイルのルート ディレクトリを指定します。--outDir とともに、出力ディレクトリ構造の制御に使用します。 */ 19 | // "composite": true, /* プロジェクトのコンパイルを有効にします */ 20 | // "tsBuildInfoFile": "./", /* 増分コンパイル情報を格納するファイルを指定する */ 21 | // "removeComments": true, /* コメントを出力しないでください。 */ 22 | // "noEmit": true, /* 出力しないでください。 */ 23 | // "importHelpers": true, /* 生成ヘルパーを 'tslib' からインポートします。 */ 24 | "downlevelIteration": true, /* 'for-of' の iterable、spread、'ES5' や 'ES3' をターゲットとする場合は destructuring に対してフル サポートを提供します。 */ 25 | // "isolatedModules": true, /* 個々のモジュールとして各ファイルをトランスパイルします ('ts.transpileModule' に類似)。 */ 26 | 27 | /* 詳細オプション */ 28 | "locale": "ja", /* ユーザーにメッセージを表示するときに使用するロケール (例: 'en-us') */ 29 | "skipLibCheck": true, /* 宣言ファイルの型チェックをスキップします。 */ 30 | "forceConsistentCasingInFileNames": true, /* 同じファイルへの大文字小文字の異なる参照を許可しない。 */ 31 | 32 | /* Strict 型チェック オプション */ 33 | "strict": true, /* 厳密な型チェックのオプションをすべて有効にします。 */ 34 | // "noImplicitAny": true, /* 暗黙的な 'any' 型を含む式と宣言に関するエラーを発生させます。 */ 35 | // "strictNullChecks": true, /* 厳格な null チェックを有効にします。 */ 36 | // "strictFunctionTypes": true, /* 関数の型の厳密なチェックを有効にします。 */ 37 | // "strictBindCallApply": true, /* 厳格な 'bind'、'call'、'apply' メソッドを関数で有効にします。 */ 38 | // "strictPropertyInitialization": true, /* クラス内のプロパティの初期化の厳密なチェックを有効にします。 */ 39 | // "noImplicitThis": true, /* 暗黙的な 'any' 型を持つ 'this' 式でエラーが発生します。 */ 40 | // "alwaysStrict": true, /* 厳格モードで解析してソース ファイルごとに "use strict" を生成します。 */ 41 | 42 | /* 追加のチェック */ 43 | "noUnusedLocals": true, /* 使用されていないローカルに関するエラーを報告します。 */ 44 | "noUnusedParameters": true, /* 使用されていないパラメーターに関するエラーを報告します。 */ 45 | "noImplicitReturns": true, /* 関数の一部のコード パスが値を返さない場合にエラーを報告します。 */ 46 | "noFallthroughCasesInSwitch": true, /* switch ステートメントに case のフォールスルーがある場合にエラーを報告します。 */ 47 | "noUncheckedIndexedAccess": true, /* インデックス署名の結果に '未定義' を含めます */ 48 | 49 | /* モジュール解決のオプション */ 50 | // "moduleResolution": "node", /* モジュールの解決方法を指定します: 'node' (Node.js) または 'classic' (TypeScript pre-1.6)。 */ 51 | // "baseUrl": "./", /* 相対モジュール名を解決するためのベース ディレクトリ。 */ 52 | // "paths": {}, /* 'baseUrl' の相対的な場所を検索するためにインポートを再マップする一連のエントリ。 */ 53 | // "rootDirs": [], /* 結合されたコンテンツがランタイムでのプロジェクトの構成を表すルート フォルダーの一覧。 */ 54 | // "typeRoots": [], /* 含める型定義の元のフォルダーの一覧。 */ 55 | // "types": [], /* コンパイルに含む型宣言ファイル。 */ 56 | // "allowSyntheticDefaultImports": true, /* 既定のエクスポートがないモジュールからの既定のインポートを許可します。これは、型チェックのみのため、コード生成には影響を与えません。 */ 57 | "esModuleInterop": true /* すべてのインポートの名前空間オブジェクトを作成して、CommonJS と ES モジュール間の生成の相互運用性を有効にします。'allowSyntheticDefaultImports' を暗黙のうちに表します。 */ 58 | // "preserveSymlinks": true, /* symlink の実際のパスを解決しません。 */ 59 | // "allowUmdGlobalAccess": true, /* モジュールから UMD グローバルへのアクセスを許可します。 */ 60 | 61 | /* ソース マップ オプション */ 62 | // "sourceRoot": "", /* デバッガーがソースの場所の代わりに TypeScript ファイルを検索する必要のある場所を指定します。 */ 63 | // "mapRoot": "", /* デバッガーが、生成された場所の代わりにマップ ファイルを検索する必要のある場所を指定します。 */ 64 | // "inlineSourceMap": true, /* 個々のファイルを持つ代わりに、複数のソース マップを含む単一ファイルを生成します。 */ 65 | // "inlineSources": true, /* 単一ファイル内でソースマップと共にソースを生成します。'--inlineSourceMap' または '--sourceMap' を設定する必要があります。 */ 66 | 67 | /* 試験的なオプション */ 68 | // "experimentalDecorators": true, /* ES7 デコレーター用の実験的なサポートを有効にします。 */ 69 | // "emitDecoratorMetadata": true, /* デコレーター用の型メタデータを発行するための実験的なサポートを有効にします。 */ 70 | }, 71 | "include": [ 72 | "src", "webpack.config.ts" 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as webpack from 'webpack'; 3 | 4 | const config: webpack.Configuration = { 5 | mode: 'development', 6 | entry: './src/index.tsx', 7 | output: { 8 | path: path.resolve(__dirname, 'public'), 9 | filename: 'bundle.js', 10 | }, 11 | devtool: 'inline-source-map', 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.tsx?$/, 16 | use: 'ts-loader', 17 | }, 18 | { 19 | test: /\.css$/, 20 | loaders: ['style-loader', 'css-loader?modules'], 21 | }, 22 | ], 23 | }, 24 | resolve: { 25 | extensions: ['.ts', '.tsx', '.js'], 26 | }, 27 | devServer: { 28 | contentBase: './public', 29 | hot: true, 30 | open: true, 31 | }, 32 | }; 33 | 34 | export default config; 35 | --------------------------------------------------------------------------------