├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── CNAME ├── README.md ├── __mocks__ ├── fileMock.js └── styleMock.js ├── babel.config.js ├── index.html ├── manifest.json ├── package-lock.json ├── package.json ├── public ├── assets │ └── icons │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── apple-icon-precomposed.png │ │ ├── apple-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── favicon.ico │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-48x48.png │ │ ├── icon-512x512.png │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ └── ms-icon-70x70.png └── sw.js ├── src ├── __mocks__ │ └── samples.config.js ├── assets │ ├── drums │ │ ├── 707 │ │ │ ├── 707-bd.mp3 │ │ │ ├── 707-ch.mp3 │ │ │ ├── 707-clap.mp3 │ │ │ ├── 707-oh.mp3 │ │ │ ├── 707-sd-high.mp3 │ │ │ ├── 707-sd-low.mp3 │ │ │ └── 707-tamb.mp3 │ │ ├── 808 │ │ │ ├── 808-bd-long.mp3 │ │ │ ├── 808-bd-short.mp3 │ │ │ ├── 808-ch.mp3 │ │ │ ├── 808-clap.mp3 │ │ │ ├── 808-clav.mp3 │ │ │ ├── 808-cowbell.mp3 │ │ │ ├── 808-cym.mp3 │ │ │ ├── 808-ht.mp3 │ │ │ ├── 808-lt.mp3 │ │ │ ├── 808-mt.mp3 │ │ │ ├── 808-oh.mp3 │ │ │ ├── 808-rs.mp3 │ │ │ └── 808-sd.mp3 │ │ ├── acetone │ │ │ ├── acetone-bd.mp3 │ │ │ ├── acetone-ch.mp3 │ │ │ ├── acetone-oh.mp3 │ │ │ ├── acetone-perc-1.mp3 │ │ │ ├── acetone-perc-2.mp3 │ │ │ ├── acetone-sd-1.mp3 │ │ │ └── acetone-sd-2.mp3 │ │ ├── hip-hop │ │ │ ├── hip-hop-bd-1.mp3 │ │ │ ├── hip-hop-bd-2.mp3 │ │ │ ├── hip-hop-ch-1.mp3 │ │ │ ├── hip-hop-ch-2.mp3 │ │ │ ├── hip-hop-oh.mp3 │ │ │ ├── hip-hop-sd-1.mp3 │ │ │ └── hip-hop-sd-2.mp3 │ │ └── linndrum │ │ │ ├── linn-bd.mp3 │ │ │ ├── linn-ch.mp3 │ │ │ ├── linn-clap.mp3 │ │ │ ├── linn-cowbell.mp3 │ │ │ ├── linn-ht.mp3 │ │ │ ├── linn-lt.mp3 │ │ │ ├── linn-mt.mp3 │ │ │ ├── linn-ph.mp3 │ │ │ ├── linn-rim.mp3 │ │ │ ├── linn-sd-high.mp3 │ │ │ ├── linn-sd-low.mp3 │ │ │ ├── linn-sd.mp3 │ │ │ └── linn-tamb.mp3 │ ├── fonts │ │ ├── jost-bold-webfont.woff │ │ ├── jost-bold-webfont.woff2 │ │ ├── jost-medium-webfont.woff │ │ ├── jost-medium-webfont.woff2 │ │ ├── jost-semi-webfont.woff │ │ └── jost-semi-webfont.woff2 │ ├── images │ │ ├── construction-light.svg │ │ ├── construction.svg │ │ ├── github.svg │ │ ├── icon.png │ │ ├── maschine-100.png │ │ ├── maschine-50.png │ │ └── wds-1-screen.png │ ├── impulse-responses │ │ ├── chateau.wav │ │ ├── damped-room.wav │ │ ├── french-salon.wav │ │ ├── nice-drum-room.wav │ │ ├── ruby-room.mp3 │ │ ├── ruby-room.wav │ │ ├── small-drum-room.wav │ │ ├── trig-room.wav │ │ └── vocal_duo.wav │ ├── js │ │ ├── webaudio-controls.js │ │ └── webcomponents-lite.js │ └── silence.mp3 ├── common │ ├── channels │ │ ├── channels.actions.js │ │ ├── channels.constants.js │ │ ├── channels.reducer.js │ │ ├── channels.reducer.test.js │ │ ├── channels.selectors.js │ │ └── index.js │ ├── index.js │ ├── master │ │ ├── index.js │ │ ├── master.actions.js │ │ ├── master.constants.js │ │ ├── master.reducer.js │ │ ├── master.reducer.test.js │ │ └── master.selectors.js │ ├── notes │ │ ├── index.js │ │ ├── notes.actions.js │ │ ├── notes.constants.js │ │ ├── notes.reducer.js │ │ ├── notes.reducer.test.js │ │ └── notes.selectors.js │ ├── playbackSession │ │ ├── index.js │ │ ├── playbackSession.actions.js │ │ ├── playbackSession.constants.js │ │ ├── playbackSession.reducer.js │ │ ├── playbackSession.reducer.test.js │ │ └── playbackSession.selectors.js │ ├── presets │ │ ├── index.js │ │ ├── presets.actions.js │ │ ├── presets.constants.js │ │ ├── presets.reducer.js │ │ ├── presets.reducer.test.js │ │ └── presets.selectors.js │ ├── tempo │ │ ├── index.js │ │ ├── tempo.actions.js │ │ ├── tempo.constants.js │ │ ├── tempo.reducer.js │ │ ├── tempo.reducer.test.js │ │ └── tempo.selectors.js │ ├── userSamples │ │ ├── index.js │ │ ├── userSamples.actions.js │ │ ├── userSamples.constants.js │ │ ├── userSamples.reducer.js │ │ └── userSamples.selectors.js │ └── window │ │ ├── index.js │ │ ├── window.actions.js │ │ ├── window.constants.js │ │ ├── window.reducer.js │ │ ├── window.reducer.test.js │ │ └── window.selectors.js ├── components │ ├── AddChannelButton │ │ ├── AddChannelButton.component.jsx │ │ ├── AddChannelButton.container.js │ │ └── index.js │ ├── App.jsx │ ├── BPMInput │ │ ├── BPMInput.component.jsx │ │ ├── BPMInput.container.js │ │ ├── BPMInput.selectors.js │ │ └── index.js │ ├── Branding.jsx │ ├── Channel │ │ ├── Channel.component.jsx │ │ ├── Channel.container.js │ │ ├── Channel.selectors.js │ │ ├── HitButton.component.jsx │ │ ├── RemoveButton.component.jsx │ │ └── index.js │ ├── ChannelControls │ │ ├── ChannelControls.component.jsx │ │ ├── ChannelControls.container.js │ │ ├── ChannelControls.selectors.js │ │ └── index.js │ ├── ChannelHeader │ │ ├── ChannelHeader.component.jsx │ │ ├── ChannelHeaderLabel.component.jsx │ │ └── index.js │ ├── ChannelList │ │ ├── ChannelList.component.jsx │ │ ├── ChannelList.container.js │ │ ├── ChannelList.selectors.js │ │ └── index.js │ ├── FancyButton.component.jsx │ ├── FlashMessage │ │ ├── FlashMessage.component.jsx │ │ ├── FlashMessage.container.js │ │ ├── FlashMessage.selectors.js │ │ └── index.js │ ├── GithubLink.component.jsx │ ├── InfoKnob.component.jsx │ ├── InstallButton.jsx │ ├── Knob.component.jsx │ ├── LabelBox.jsx │ ├── Logo.component.jsx │ ├── Marker │ │ ├── Marker.component.jsx │ │ ├── Marker.container.js │ │ ├── Marker.selectors.js │ │ └── index.js │ ├── MasterControls │ │ ├── MasterControls.component.jsx │ │ └── index.js │ ├── Modal.component.jsx │ ├── MuteSolo │ │ ├── MuteSolo.component.jsx │ │ ├── MuteSolo.container.js │ │ └── index.js │ ├── PatternSelector │ │ ├── PatternSelector.component.jsx │ │ ├── PatternSelector.container.js │ │ ├── PatternSelector.selectors.js │ │ └── index.js │ ├── PlayButton │ │ ├── PlayButton.component.jsx │ │ ├── PlayButton.container.js │ │ ├── PlayButton.selectors.js │ │ └── index.js │ ├── PresetDeleted.component.jsx │ ├── PresetSaved.component.jsx │ ├── PresetSelector │ │ ├── PresetSelector.component.jsx │ │ ├── PresetSelector.container.js │ │ ├── PresetSelector.selectors.js │ │ └── index.js │ ├── SampleLoadError.component.jsx │ ├── SampleSelect │ │ ├── SampleSelect.component.jsx │ │ ├── SampleSelect.container.js │ │ ├── SampleSelect.selectors.js │ │ └── index.js │ ├── SavePresetModal │ │ ├── SavePresetModal.component.jsx │ │ ├── SavePresetModal.container.js │ │ ├── SavePresetModal.selectors.js │ │ └── index.js │ ├── SwingControl │ │ ├── SwingControl.component.jsx │ │ ├── SwingControl.container.js │ │ ├── SwingControl.selectors.js │ │ └── index.js │ ├── Toggles │ │ ├── Toggle.component.jsx │ │ ├── ToggleGroup.component.jsx │ │ ├── Toggles.component.jsx │ │ ├── Toggles.container.js │ │ ├── Toggles.selectors.js │ │ └── index.js │ ├── VolumeMeter.component.jsx │ ├── design-system │ │ ├── Box.js │ │ ├── Button.js │ │ ├── ControlLabel.js │ │ ├── Form.js │ │ ├── Heading.js │ │ ├── HoverButton.js │ │ ├── HoverLink.js │ │ ├── Image.js │ │ ├── Label.js │ │ ├── Line.js │ │ ├── Text.js │ │ ├── TextInput.js │ │ └── index.js │ ├── index.js │ └── timedCallback.hoc.jsx ├── index.html ├── index.jsx ├── presets │ ├── 707.js │ ├── 808.js │ ├── __mocks__ │ │ └── index.js │ ├── ace.js │ ├── empty.js │ ├── hip-hop.js │ ├── index.js │ └── ldrum.js ├── reducer.js ├── samples.config.js ├── services │ ├── __mocks__ │ │ ├── audioContext.js │ │ ├── audioRouter.js │ │ └── featureChecks.js │ ├── animations.js │ ├── audioAnalyzer.js │ ├── audioContext.js │ ├── audioContext.test.js │ ├── audioEngine.config.js │ ├── audioLoop.js │ ├── audioRouter.js │ ├── audioScheduler.js │ ├── audioScheduler.test.js │ ├── database.js │ ├── featureChecks.js │ ├── fileUtils.js │ ├── pwaInstall.js │ ├── reverb.js │ ├── sampleStore.js │ ├── swing.js │ ├── unmute.js │ └── uuid.js ├── store.js └── styles │ ├── globalStyles.js │ └── theme.js └── vite.config.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: cimg/node:16.13.2 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: npm install 30 | 31 | - run: npm run build 32 | 33 | - persist_to_workspace: 34 | root: ~/repo 35 | paths: 36 | - dist 37 | 38 | - save_cache: 39 | paths: 40 | - node_modules 41 | key: v1-dependencies-{{ checksum "package.json" }} 42 | 43 | test: 44 | docker: 45 | - image: cimg/node:16.13.2 46 | working_directory: ~/repo 47 | steps: 48 | - checkout 49 | 50 | # Download and cache dependencies 51 | - restore_cache: 52 | keys: 53 | - v1-dependencies-{{ checksum "package.json" }} 54 | # fallback to using the latest cache if no exact match is found 55 | - v1-dependencies- 56 | 57 | - run: npm install 58 | - run: 59 | name: Test 60 | command: npm run test 61 | - run: 62 | name: Lint 63 | command: npm run lint 64 | 65 | # run tests! 66 | #- run: yarn test 67 | deploy: 68 | docker: 69 | - image: circleci/python:2.7-jessie 70 | working_directory: ~/repo 71 | steps: 72 | - attach_workspace: 73 | # Must be absolute path or relative path from working_directory 74 | at: ~/repo 75 | - run: 76 | name: Install awscli 77 | command: sudo pip install awscli 78 | - run: 79 | name: Deploy to S3 80 | command: aws s3 sync --acl public-read ~/repo/dist s3://wds-1.com/ --delete 81 | 82 | workflows: 83 | version: 2 84 | build-deploy: 85 | jobs: 86 | - build 87 | - test: 88 | requires: 89 | - build 90 | - deploy: 91 | requires: 92 | - test 93 | filters: 94 | branches: 95 | only: master 96 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/assets/**/*.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:react/recommended"], 3 | "env": { 4 | "browser": true, 5 | "jest": true, 6 | "es6": true 7 | }, 8 | "rules": { 9 | "import/prefer-default-export": 0, 10 | "global-require": 0, 11 | "jsx-a11y/label-has-for": [0], 12 | "jsx-a11y/label-has-associated-control": [0], 13 | "default-param-last": 0, 14 | "implicit-arrow-linebreak": 0 15 | }, 16 | "parser": "@babel/eslint-parser" 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | .DS_Store 4 | dist/* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | wds-1.com -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Drum Sequencer 2 | 3 | A browser-based drum machine and sequencer built with the Web Audio API, React, and Redux. 4 | 5 | ## Demo 6 | 7 | https://wds-1.com 8 | 9 | ## Features 10 | * Swap drum samples 11 | * Choose drum samples from file 12 | * Pattern selector to save up to 8 patterns per drum kit 13 | * BPM and swing control 14 | * Sample hit buttons 15 | * Gain and pan 16 | * Reverb 17 | * Mute and solo 18 | * Pitch shift 19 | * Preset system for saving and loading drum kits 20 | * Works offline with service worker and caching 21 | * Installable as PWA 22 | * Drag to reorder channels 23 | 24 | ## Circle CI status 25 | 26 | [![CircleCI](https://circleci.com/gh/stufreen/web-drum-sequencer.svg?style=svg)](https://circleci.com/gh/stufreen/web-drum-sequencer) 27 | 28 | ## Installation 29 | 30 | To run a local development server: 31 | ``` 32 | npm install 33 | npm run start 34 | ``` 35 | 36 | To build a production version: `npm run build` 37 | 38 | ## Tests 39 | 40 | ``` 41 | npm run test 42 | ``` 43 | 44 | ## Thank You 45 | * [React-Select](https://github.com/JedWatson/react-select) 46 | * [Webaudio-Controls](https://github.com/g200kg/webaudio-controls) 47 | * Chris Wilson's article [here](https://www.html5rocks.com/en/tutorials/audio/scheduling/) 48 | * [Voxengo impluse response](https://www.voxengo.com/impulses/) 49 | * [Jost* typeface](https://github.com/indestructible-type/Jost) 50 | * [Draggable](https://shopify.github.io/draggable/) 51 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const presets = [ 2 | [ 3 | "@babel/env", 4 | { 5 | targets: "> 0.25%, not dead", 6 | useBuiltIns: "usage", 7 | }, 8 | ], 9 | "@babel/preset-react", 10 | ]; 11 | 12 | module.exports = { presets }; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { 4 | "src": "/assets/icons/icon-48x48.png", 5 | "sizes": "48x48", 6 | "type": "image/png", 7 | "purpose": "maskable any" 8 | }, 9 | { 10 | "src": "/assets/icons/icon-72x72.png", 11 | "sizes": "72x72", 12 | "type": "image/png", 13 | "purpose": "maskable any" 14 | }, 15 | { 16 | "src": "/assets/icons/icon-96x96.png", 17 | "sizes": "96x96", 18 | "type": "image/png", 19 | "purpose": "maskable any" 20 | }, 21 | { 22 | "src": "/assets/icons/icon-128x128.png", 23 | "sizes": "128x128", 24 | "type": "image/png", 25 | "purpose": "maskable any" 26 | }, 27 | { 28 | "src": "/assets/icons/icon-144x144.png", 29 | "sizes": "144x144", 30 | "type": "image/png", 31 | "purpose": "maskable any" 32 | }, 33 | { 34 | "src": "/assets/icons/icon-152x152.png", 35 | "sizes": "152x152", 36 | "type": "image/png", 37 | "purpose": "maskable any" 38 | }, 39 | { 40 | "src": "/assets/icons/icon-192x192.png", 41 | "sizes": "192x192", 42 | "type": "image/png", 43 | "purpose": "maskable any" 44 | }, 45 | { 46 | "src": "/assets/icons/icon-384x384.png", 47 | "sizes": "384x384", 48 | "type": "image/png", 49 | "purpose": "maskable any" 50 | }, 51 | { 52 | "src": "/assets/icons/icon-512x512.png", 53 | "sizes": "512x512", 54 | "type": "image/png", 55 | "purpose": "maskable any" 56 | } 57 | ], 58 | "name": "WDS-1: Wed Drum Sequencer", 59 | "short_name": "WDS-1", 60 | "orientation": "portrait", 61 | "display": "standalone", 62 | "start_url": "/", 63 | "background_color": "#202429", 64 | "theme_color": "#000000" 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-drum-sequencer", 3 | "version": "0.2.4", 4 | "description": "A drum machine and sequencer built with the Web Audio API and React.", 5 | "main": "dist/index.html", 6 | "scripts": { 7 | "start": "vite --host", 8 | "build": "rm -rf dist && vite build", 9 | "lint": "eslint src", 10 | "test": "jest", 11 | "gh-pages": "git subtree push --prefix dist origin gh-pages" 12 | }, 13 | "author": "Stu Freen (http://www.stufreen.com)", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/stufreen/web-drum-sequencer.git" 18 | }, 19 | "keywords": [ 20 | "Web Audio", 21 | "Web Audio API", 22 | "Drum Machine", 23 | "Playback", 24 | "Effect", 25 | "Instrument", 26 | "React", 27 | "Redux", 28 | "Interactive Music" 29 | ], 30 | "dependencies": { 31 | "@shopify/draggable": "^1.0.0-beta.8", 32 | "animol": "1.0.9", 33 | "prop-types": "^15.6.2", 34 | "ramda": "^0.25.0", 35 | "react": "^16.4.1", 36 | "react-dom": "^16.4.1", 37 | "react-redux": "^5.0.7", 38 | "react-select": "^2.0.0", 39 | "recompose": "^0.27.1", 40 | "redux": "^4.0.0", 41 | "redux-persist": "^5.10.0", 42 | "redux-thunk": "^2.3.0", 43 | "reselect": "^3.0.1", 44 | "serviceworker-webpack-plugin": "^1.0.1", 45 | "styled-components": "^3.3.3", 46 | "styled-system": "^3.0.2", 47 | "uuid": "^3.3.2" 48 | }, 49 | "jest": { 50 | "testURL": "http://localhost", 51 | "moduleNameMapper": { 52 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 53 | "\\.(css|less)$": "/__mocks__/styleMock.js" 54 | } 55 | }, 56 | "devDependencies": { 57 | "@babel/core": "^7.22.9", 58 | "@babel/eslint-parser": "^7.22.9", 59 | "@babel/preset-env": "^7.22.9", 60 | "@babel/preset-react": "^7.22.5", 61 | "@vitejs/plugin-react-refresh": "^1.3.6", 62 | "eslint": "^8.46.0", 63 | "eslint-config-airbnb": "^19.0.4", 64 | "eslint-plugin-import": "^2.27.5", 65 | "eslint-plugin-jsx-a11y": "^6.7.1", 66 | "eslint-plugin-react": "^7.33.1", 67 | "eslint-plugin-react-hooks": "^4.6.0", 68 | "jest": "^29.6.2", 69 | "vite": "^4.4.7" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/assets/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/apple-icon.png -------------------------------------------------------------------------------- /public/assets/icons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /public/assets/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/assets/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/assets/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/favicon-96x96.png -------------------------------------------------------------------------------- /public/assets/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/favicon.ico -------------------------------------------------------------------------------- /public/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /public/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/assets/icons/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-48x48.png -------------------------------------------------------------------------------- /public/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /public/assets/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/assets/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/assets/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/public/assets/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-globals: 1 */ 2 | 3 | const addToCache = (event, fetchResponse) => { 4 | // Check if we received a valid response 5 | if (!fetchResponse || fetchResponse.status !== 200 || fetchResponse.type !== 'basic') { 6 | return fetchResponse; 7 | } 8 | 9 | // IMPORTANT: Clone the response. A response is a stream 10 | // and because we want the browser to consume the response 11 | // as well as the cache consuming the response, we need 12 | // to clone it so we have two streams. 13 | const responseToCache = fetchResponse.clone(); 14 | 15 | caches.open('wdsCache').then((cache) => { 16 | cache.put(event.request, responseToCache); 17 | }); 18 | 19 | return fetchResponse; 20 | }; 21 | 22 | const fetchAndCache = (event, fetchRequest) => fetch(fetchRequest) 23 | .then(fetchResponse => addToCache(event, fetchResponse)); 24 | 25 | self.addEventListener('fetch', (event) => { 26 | // IMPORTANT: Clone the request. A request is a stream and 27 | // can only be consumed once. Since we are consuming this 28 | // once by cache and once by the browser for fetch, we need 29 | // to clone the response. 30 | const fetchRequest = event.request.clone(); 31 | 32 | // Try to get files from the "assets" directory from cache first 33 | if (fetchRequest.url.indexOf('/assets/') >= 0) { 34 | event.respondWith( 35 | caches.match(fetchRequest) 36 | .then(response => response || fetchAndCache(event, fetchRequest)), 37 | ); 38 | } else { 39 | event.respondWith( 40 | fetchAndCache(event, fetchRequest), 41 | ); 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /src/__mocks__/samples.config.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | name: 'Fake sample A', 4 | url: '/fake/sample/a/url.wav', 5 | }, 6 | { 7 | name: 'Fake sample B', 8 | url: '/fake/sample/b/url.wav', 9 | }, 10 | ]; 11 | -------------------------------------------------------------------------------- /src/assets/drums/707/707-bd.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/707/707-bd.mp3 -------------------------------------------------------------------------------- /src/assets/drums/707/707-ch.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/707/707-ch.mp3 -------------------------------------------------------------------------------- /src/assets/drums/707/707-clap.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/707/707-clap.mp3 -------------------------------------------------------------------------------- /src/assets/drums/707/707-oh.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/707/707-oh.mp3 -------------------------------------------------------------------------------- /src/assets/drums/707/707-sd-high.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/707/707-sd-high.mp3 -------------------------------------------------------------------------------- /src/assets/drums/707/707-sd-low.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/707/707-sd-low.mp3 -------------------------------------------------------------------------------- /src/assets/drums/707/707-tamb.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/707/707-tamb.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-bd-long.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-bd-long.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-bd-short.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-bd-short.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-ch.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-ch.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-clap.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-clap.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-clav.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-clav.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-cowbell.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-cowbell.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-cym.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-cym.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-ht.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-ht.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-lt.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-lt.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-mt.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-mt.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-oh.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-oh.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-rs.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-rs.mp3 -------------------------------------------------------------------------------- /src/assets/drums/808/808-sd.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/808/808-sd.mp3 -------------------------------------------------------------------------------- /src/assets/drums/acetone/acetone-bd.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/acetone/acetone-bd.mp3 -------------------------------------------------------------------------------- /src/assets/drums/acetone/acetone-ch.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/acetone/acetone-ch.mp3 -------------------------------------------------------------------------------- /src/assets/drums/acetone/acetone-oh.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/acetone/acetone-oh.mp3 -------------------------------------------------------------------------------- /src/assets/drums/acetone/acetone-perc-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/acetone/acetone-perc-1.mp3 -------------------------------------------------------------------------------- /src/assets/drums/acetone/acetone-perc-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/acetone/acetone-perc-2.mp3 -------------------------------------------------------------------------------- /src/assets/drums/acetone/acetone-sd-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/acetone/acetone-sd-1.mp3 -------------------------------------------------------------------------------- /src/assets/drums/acetone/acetone-sd-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/acetone/acetone-sd-2.mp3 -------------------------------------------------------------------------------- /src/assets/drums/hip-hop/hip-hop-bd-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/hip-hop/hip-hop-bd-1.mp3 -------------------------------------------------------------------------------- /src/assets/drums/hip-hop/hip-hop-bd-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/hip-hop/hip-hop-bd-2.mp3 -------------------------------------------------------------------------------- /src/assets/drums/hip-hop/hip-hop-ch-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/hip-hop/hip-hop-ch-1.mp3 -------------------------------------------------------------------------------- /src/assets/drums/hip-hop/hip-hop-ch-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/hip-hop/hip-hop-ch-2.mp3 -------------------------------------------------------------------------------- /src/assets/drums/hip-hop/hip-hop-oh.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/hip-hop/hip-hop-oh.mp3 -------------------------------------------------------------------------------- /src/assets/drums/hip-hop/hip-hop-sd-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/hip-hop/hip-hop-sd-1.mp3 -------------------------------------------------------------------------------- /src/assets/drums/hip-hop/hip-hop-sd-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/hip-hop/hip-hop-sd-2.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-bd.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-bd.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-ch.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-ch.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-clap.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-clap.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-cowbell.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-cowbell.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-ht.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-ht.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-lt.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-lt.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-mt.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-mt.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-ph.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-ph.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-rim.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-rim.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-sd-high.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-sd-high.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-sd-low.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-sd-low.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-sd.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-sd.mp3 -------------------------------------------------------------------------------- /src/assets/drums/linndrum/linn-tamb.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/drums/linndrum/linn-tamb.mp3 -------------------------------------------------------------------------------- /src/assets/fonts/jost-bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/fonts/jost-bold-webfont.woff -------------------------------------------------------------------------------- /src/assets/fonts/jost-bold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/fonts/jost-bold-webfont.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/jost-medium-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/fonts/jost-medium-webfont.woff -------------------------------------------------------------------------------- /src/assets/fonts/jost-medium-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/fonts/jost-medium-webfont.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/jost-semi-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/fonts/jost-semi-webfont.woff -------------------------------------------------------------------------------- /src/assets/fonts/jost-semi-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/fonts/jost-semi-webfont.woff2 -------------------------------------------------------------------------------- /src/assets/images/construction-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/images/construction.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | github 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/images/icon.png -------------------------------------------------------------------------------- /src/assets/images/maschine-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/images/maschine-100.png -------------------------------------------------------------------------------- /src/assets/images/maschine-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/images/maschine-50.png -------------------------------------------------------------------------------- /src/assets/images/wds-1-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/images/wds-1-screen.png -------------------------------------------------------------------------------- /src/assets/impulse-responses/chateau.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/chateau.wav -------------------------------------------------------------------------------- /src/assets/impulse-responses/damped-room.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/damped-room.wav -------------------------------------------------------------------------------- /src/assets/impulse-responses/french-salon.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/french-salon.wav -------------------------------------------------------------------------------- /src/assets/impulse-responses/nice-drum-room.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/nice-drum-room.wav -------------------------------------------------------------------------------- /src/assets/impulse-responses/ruby-room.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/ruby-room.mp3 -------------------------------------------------------------------------------- /src/assets/impulse-responses/ruby-room.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/ruby-room.wav -------------------------------------------------------------------------------- /src/assets/impulse-responses/small-drum-room.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/small-drum-room.wav -------------------------------------------------------------------------------- /src/assets/impulse-responses/trig-room.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/trig-room.wav -------------------------------------------------------------------------------- /src/assets/impulse-responses/vocal_duo.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/impulse-responses/vocal_duo.wav -------------------------------------------------------------------------------- /src/assets/silence.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stufreen/web-drum-sequencer/84446c7aa128e6267e2caaabcc1a9fd9df1bcbf0/src/assets/silence.mp3 -------------------------------------------------------------------------------- /src/common/channels/channels.actions.js: -------------------------------------------------------------------------------- 1 | import { loadSample } from '../../services/sampleStore'; 2 | import { CHANNELS_CONSTANTS } from './channels.constants'; 3 | import { setNotes, initializeChannelNotes } from '../notes'; 4 | import { uuid } from '../../services/uuid'; 5 | import factorySamples from '../../samples.config'; 6 | import { setSelectedChannel } from '../master'; 7 | import { showFlashMessage, FLASH_MESSAGES } from '../window'; 8 | 9 | export const setChannelGain = (channel, gain) => ({ 10 | type: CHANNELS_CONSTANTS.SET_CHANNEL_GAIN, 11 | payload: { 12 | channel, 13 | gain, 14 | }, 15 | }); 16 | 17 | export const setChannelPan = (channel, pan) => ({ 18 | type: CHANNELS_CONSTANTS.SET_CHANNEL_PAN, 19 | payload: { 20 | channel, 21 | pan, 22 | }, 23 | }); 24 | 25 | export const setChannelPitchCoarse = (channel, pitchCoarse) => ({ 26 | type: CHANNELS_CONSTANTS.SET_CHANNEL_PITCH_COARSE, 27 | payload: { 28 | channel, 29 | pitchCoarse, 30 | }, 31 | }); 32 | 33 | export const setChannelPitchFine = (channel, pitchFine) => ({ 34 | type: CHANNELS_CONSTANTS.SET_CHANNEL_PITCH_FINE, 35 | payload: { 36 | channel, 37 | pitchFine, 38 | }, 39 | }); 40 | 41 | export const setChannelMuted = (channel, muted) => ({ 42 | type: CHANNELS_CONSTANTS.SET_CHANNEL_MUTED, 43 | payload: { 44 | channel, 45 | muted, 46 | }, 47 | }); 48 | 49 | export const setChannelSolo = (channel, solo) => ({ 50 | type: CHANNELS_CONSTANTS.SET_CHANNEL_SOLO, 51 | payload: { 52 | channel, 53 | solo, 54 | }, 55 | }); 56 | 57 | export const addChannel = (channel) => ({ 58 | type: CHANNELS_CONSTANTS.ADD_CHANNEL, 59 | payload: channel, 60 | }); 61 | 62 | export const removeChannel = (id) => ({ 63 | type: CHANNELS_CONSTANTS.REMOVE_CHANNEL, 64 | payload: id, 65 | }); 66 | 67 | export const updateChannelOrder = (oldIndex, newIndex) => ({ 68 | type: CHANNELS_CONSTANTS.UPDATE_CHANNEL_ORDER, 69 | payload: { 70 | oldIndex, 71 | newIndex, 72 | }, 73 | }); 74 | 75 | export const replaceChannels = (channels) => ({ 76 | type: CHANNELS_CONSTANTS.SET_CHANNELS, 77 | payload: channels, 78 | }); 79 | 80 | export const sampleLoaded = (channelID, isLoaded) => ({ 81 | type: CHANNELS_CONSTANTS.SAMPLE_LOADED, 82 | payload: { 83 | channelID, 84 | isLoaded, 85 | }, 86 | }); 87 | 88 | export const setChannelSample = (channel, sampleURL) => ({ 89 | type: CHANNELS_CONSTANTS.SET_CHANNEL_SAMPLE, 90 | payload: { 91 | channel, 92 | sampleURL, 93 | }, 94 | }); 95 | 96 | export const setChannelReverb = (channel, reverb) => ({ 97 | type: CHANNELS_CONSTANTS.SET_CHANNEL_REVERB, 98 | payload: { 99 | channel, 100 | reverb, 101 | }, 102 | }); 103 | 104 | export const loadSampleStatefully = (dispatch, channel) => { 105 | dispatch(sampleLoaded(channel.id, false)); 106 | loadSample(channel.sample).then((success) => { 107 | if (success) { 108 | dispatch(sampleLoaded(channel.id, true)); 109 | } 110 | }); 111 | }; 112 | 113 | export const loadChannels = (channels, notes) => (dispatch) => { 114 | channels.forEach((channel) => { 115 | loadSampleStatefully(dispatch, channel); 116 | }); 117 | dispatch(replaceChannels(channels)); 118 | dispatch(setNotes(notes)); 119 | }; 120 | 121 | export const newChannel = () => (dispatch) => { 122 | const channelToAdd = { 123 | id: uuid(), 124 | sample: factorySamples[0].url, 125 | gain: 1, 126 | pitchCoarse: 0, 127 | pitchFine: 0, 128 | pan: 0, 129 | }; 130 | dispatch(addChannel(channelToAdd)); 131 | dispatch(initializeChannelNotes(channelToAdd.id)); 132 | dispatch(setSelectedChannel(channelToAdd.id)); 133 | loadSampleStatefully(dispatch, channelToAdd); 134 | }; 135 | 136 | export const loadAndSetChannelSample = (channelID, sampleURL) => (dispatch) => { 137 | dispatch(sampleLoaded(channelID, false)); 138 | loadSample(sampleURL).then((success) => { 139 | if (success) { 140 | dispatch(sampleLoaded(channelID, true)); 141 | } else { 142 | dispatch(showFlashMessage(FLASH_MESSAGES.SAMPLE_LOAD_ERROR)); 143 | } 144 | }); 145 | dispatch(setChannelSample(channelID, sampleURL)); 146 | }; 147 | 148 | export const deleteChannel = (channelID, channels, selectedChannelId) => (dispatch) => { 149 | if (channels.length === 1) { 150 | dispatch(newChannel()); 151 | } 152 | if (selectedChannelId === channelID) { 153 | dispatch(setSelectedChannel(channels[0].id)); 154 | } 155 | dispatch(removeChannel(channelID)); 156 | }; 157 | -------------------------------------------------------------------------------- /src/common/channels/channels.constants.js: -------------------------------------------------------------------------------- 1 | export const CHANNELS_CONSTANTS = { 2 | ADD_CHANNEL: 'ADD_CHANNEL', 3 | REMOVE_CHANNEL: 'REMOVE_CHANNEL', 4 | SET_CHANNEL_SAMPLE: 'SET_CHANNEL_SAMPLE', 5 | SET_CHANNEL_PITCH_COARSE: 'SET_CHANNEL_PITCH_COARSE', 6 | SET_CHANNEL_FINE: 'SET_CHANNEL_FINE', 7 | SET_CHANNEL_GAIN: 'SET_CHANNEL_GAIN', 8 | SET_CHANNEL_PAN: 'SET_CHANNEL_PAN', 9 | SET_CHANNEL_REVERB: 'SET_CHANNEL_REVERB', 10 | SET_CHANNEL_MUTED: 'SET_CHANNEL_MUTED', 11 | SET_CHANNEL_SOLO: 'SET_CHANNEL_SOLO', 12 | SET_CHANNELS: 'SET_CHANNELS', 13 | SAMPLE_LOADED: 'SAMPLE_LOADED', 14 | UPDATE_CHANNEL_ORDER: 'UPDATE_CHANNEL_ORDER', 15 | }; 16 | -------------------------------------------------------------------------------- /src/common/channels/channels.reducer.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | import { CHANNELS_CONSTANTS } from './channels.constants'; 3 | import presets from '../../presets'; 4 | 5 | export const channelsInitialState = R.clone(presets[1].channels); 6 | 7 | export const channelsReducer = (state = channelsInitialState, action) => { 8 | switch (action.type) { 9 | case CHANNELS_CONSTANTS.SET_CHANNEL_SAMPLE: 10 | return state.map((channel) => { 11 | if (channel.id === action.payload.channel) { 12 | return { ...channel, sample: action.payload.sampleURL }; 13 | } 14 | return channel; 15 | }); 16 | case CHANNELS_CONSTANTS.SET_CHANNEL_GAIN: 17 | return state.map((channel) => { 18 | if (channel.id === action.payload.channel) { 19 | return { ...channel, gain: action.payload.gain }; 20 | } 21 | return channel; 22 | }); 23 | case CHANNELS_CONSTANTS.SET_CHANNEL_PAN: 24 | return state.map((channel) => { 25 | if (channel.id === action.payload.channel) { 26 | return { ...channel, pan: action.payload.pan }; 27 | } 28 | return channel; 29 | }); 30 | case CHANNELS_CONSTANTS.SET_CHANNEL_PITCH_COARSE: 31 | return state.map((channel) => { 32 | if (channel.id === action.payload.channel) { 33 | return { ...channel, pitchCoarse: action.payload.pitchCoarse }; 34 | } 35 | return channel; 36 | }); 37 | case CHANNELS_CONSTANTS.SET_CHANNEL_PITCH_FINE: 38 | return state.map((channel) => { 39 | if (channel.id === action.payload.channel) { 40 | return { ...channel, pitchFine: action.payload.pitchFine }; 41 | } 42 | return channel; 43 | }); 44 | case CHANNELS_CONSTANTS.SET_CHANNEL_REVERB: 45 | return state.map((channel) => { 46 | if (channel.id === action.payload.channel) { 47 | return { ...channel, reverb: action.payload.reverb }; 48 | } 49 | return channel; 50 | }); 51 | case CHANNELS_CONSTANTS.ADD_CHANNEL: 52 | return [...state, action.payload]; 53 | case CHANNELS_CONSTANTS.REMOVE_CHANNEL: 54 | return state.filter((channel) => channel.id !== action.payload); 55 | case CHANNELS_CONSTANTS.SAMPLE_LOADED: 56 | return state.map((channel) => { 57 | if (channel.id === action.payload.channelID) { 58 | return { ...channel, sampleLoaded: action.payload.isLoaded }; 59 | } 60 | return channel; 61 | }); 62 | case CHANNELS_CONSTANTS.SET_CHANNEL_MUTED: 63 | return state.map((channel) => { 64 | if (channel.id === action.payload.channel) { 65 | return { 66 | ...channel, 67 | muted: action.payload.muted, 68 | solo: false, 69 | }; 70 | } 71 | return channel; 72 | }); 73 | case CHANNELS_CONSTANTS.SET_CHANNEL_SOLO: 74 | return state.map((channel) => { 75 | if (channel.id === action.payload.channel) { 76 | return { 77 | ...channel, 78 | solo: action.payload.solo, 79 | muted: false, 80 | }; 81 | } 82 | return channel; 83 | }); 84 | case CHANNELS_CONSTANTS.UPDATE_CHANNEL_ORDER: 85 | return R.insert( 86 | action.payload.newIndex, 87 | state[action.payload.oldIndex], 88 | R.remove(action.payload.oldIndex, 1, state), 89 | ); 90 | case CHANNELS_CONSTANTS.SET_CHANNELS: 91 | return [...action.payload]; 92 | default: 93 | return state; 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /src/common/channels/channels.selectors.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const channelsSelector = R.path(['channels']); 4 | -------------------------------------------------------------------------------- /src/common/channels/index.js: -------------------------------------------------------------------------------- 1 | export * from './channels.reducer'; 2 | export * from './channels.selectors'; 3 | export * from './channels.actions'; 4 | -------------------------------------------------------------------------------- /src/common/index.js: -------------------------------------------------------------------------------- 1 | export * from './playbackSession'; 2 | export * from './channels'; 3 | export * from './tempo'; 4 | export * from './master'; 5 | export * from './notes'; 6 | export * from './presets'; 7 | export * from './window'; 8 | export * from './userSamples'; 9 | -------------------------------------------------------------------------------- /src/common/master/index.js: -------------------------------------------------------------------------------- 1 | export * from './master.reducer'; 2 | export * from './master.selectors'; 3 | export * from './master.actions'; 4 | -------------------------------------------------------------------------------- /src/common/master/master.actions.js: -------------------------------------------------------------------------------- 1 | import { MASTER_CONSTANTS } from './master.constants'; 2 | 3 | export const setPattern = (patternIndex) => ({ 4 | type: MASTER_CONSTANTS.SET_PATTERN, 5 | payload: patternIndex, 6 | }); 7 | 8 | export const setSelectedChannel = (channelID) => ({ 9 | type: MASTER_CONSTANTS.SET_SELECTED_CHANNEL, 10 | payload: channelID, 11 | }); 12 | -------------------------------------------------------------------------------- /src/common/master/master.constants.js: -------------------------------------------------------------------------------- 1 | export const MASTER_CONSTANTS = { 2 | SET_PATTERN: 'SET_PATTERN', 3 | SET_SELECTED_CHANNEL: 'SET_SELECTED_CHANNEL', 4 | }; 5 | -------------------------------------------------------------------------------- /src/common/master/master.reducer.js: -------------------------------------------------------------------------------- 1 | import { MASTER_CONSTANTS } from './master.constants'; 2 | import presets from '../../presets'; 3 | 4 | export const masterInitialState = { 5 | pattern: 0, 6 | selectedChannel: presets[1].channels[0].id, 7 | }; 8 | 9 | export const masterReducer = (state = masterInitialState, action) => { 10 | switch (action.type) { 11 | case MASTER_CONSTANTS.SET_PATTERN: 12 | return { 13 | ...state, 14 | pattern: action.payload, 15 | }; 16 | case MASTER_CONSTANTS.SET_SELECTED_CHANNEL: 17 | return { 18 | ...state, 19 | selectedChannel: action.payload, 20 | }; 21 | default: 22 | return state; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/common/master/master.reducer.test.js: -------------------------------------------------------------------------------- 1 | import { masterInitialState, masterReducer } from './master.reducer'; 2 | import { 3 | setPattern, 4 | } from './master.actions'; 5 | 6 | describe('setPattern', () => { 7 | test('should change the pattern', () => { 8 | const state = masterReducer(masterInitialState, setPattern(1)); 9 | expect(state.pattern).toEqual(1); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/common/master/master.selectors.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const patternSelector = R.path(['master', 'pattern']); 4 | 5 | export const selectedChannelSelector = R.path(['master', 'selectedChannel']); 6 | -------------------------------------------------------------------------------- /src/common/notes/index.js: -------------------------------------------------------------------------------- 1 | export * from './notes.reducer'; 2 | export * from './notes.selectors'; 3 | export * from './notes.actions'; 4 | -------------------------------------------------------------------------------- /src/common/notes/notes.actions.js: -------------------------------------------------------------------------------- 1 | import { NOTES_CONSTANTS } from './notes.constants'; 2 | 3 | export const initializeChannelNotes = (channelID) => ({ 4 | type: NOTES_CONSTANTS.INITIALIZE_CHANNEL, 5 | payload: channelID, 6 | }); 7 | 8 | export const removeChannelNotes = (channelID) => ({ 9 | type: NOTES_CONSTANTS.REMOVE_CHANNEL, 10 | payload: channelID, 11 | }); 12 | 13 | export const setNotes = (notes) => ({ 14 | type: NOTES_CONSTANTS.SET_NOTES, 15 | payload: notes, 16 | }); 17 | 18 | export const toggleNote = (channelID, pattern, beat) => ({ 19 | type: NOTES_CONSTANTS.TOGGLE_NOTE, 20 | payload: { 21 | channelID, 22 | pattern, 23 | beat, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/common/notes/notes.constants.js: -------------------------------------------------------------------------------- 1 | export const NOTES_CONSTANTS = { 2 | INITIALIZE_CHANNEL: 'INITIALIZE_CHANNEL_NOTES', 3 | REMOVE_CHANNEL: 'REMOVE_CHANNEL_NOTES', 4 | TOGGLE_NOTE: 'TOGGLE_NOTE', 5 | SET_NOTES: 'SET_NOTES', 6 | }; 7 | -------------------------------------------------------------------------------- /src/common/notes/notes.reducer.js: -------------------------------------------------------------------------------- 1 | import { uuid } from '../../services/uuid'; 2 | import presets from '../../presets'; 3 | import { EMPTY_NOTE_ROW } from '../../presets/empty'; 4 | import { NOTES_CONSTANTS } from './notes.constants'; 5 | 6 | export const notesInitialState = presets[1].notes; 7 | 8 | // Returns a new noteAr clone with a note at beat either added or removed 9 | const toggleNote = (noteAr, beat) => { 10 | if (noteAr.find((note) => note.beat === beat)) { 11 | return noteAr.filter((note) => note.beat !== beat); 12 | } 13 | return [ 14 | ...noteAr, 15 | { 16 | beat, 17 | id: uuid(), 18 | }, 19 | ]; 20 | }; 21 | 22 | // Returns new state object with note at beat on pattern toggled 23 | const toggleNoteState = (state, { channelID, pattern, beat }) => ({ 24 | ...state, 25 | [channelID]: state[channelID].map((noteAr, patternIndex) => { 26 | if (patternIndex === pattern) { 27 | // This is the active pattern 28 | return toggleNote(noteAr, beat); 29 | } 30 | return noteAr; // Do nothing to other patterns 31 | }), 32 | }); 33 | 34 | export const notesReducer = (state = notesInitialState, action) => { 35 | switch (action.type) { 36 | case NOTES_CONSTANTS.TOGGLE_NOTE: 37 | return toggleNoteState(state, action.payload); 38 | case NOTES_CONSTANTS.INITIALIZE_CHANNEL: 39 | return { 40 | ...state, 41 | [action.payload]: EMPTY_NOTE_ROW, // TO DO: add empty array for each pattern 42 | }; 43 | case NOTES_CONSTANTS.REMOVE_CHANNEL: 44 | return { 45 | ...state, 46 | [action.payload]: undefined, 47 | }; 48 | case NOTES_CONSTANTS.SET_NOTES: 49 | return { 50 | ...action.payload, 51 | }; 52 | default: 53 | return state; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/common/notes/notes.reducer.test.js: -------------------------------------------------------------------------------- 1 | import { notesReducer } from './notes.reducer'; 2 | import { 3 | toggleNote, 4 | initializeChannelNotes, 5 | removeChannelNotes, 6 | setNotes, 7 | } from './notes.actions'; 8 | 9 | jest.mock('../../presets'); 10 | jest.mock('../../samples.config'); 11 | 12 | const testNotes = { 13 | bongo: [ 14 | [ 15 | { 16 | id: 'bing', 17 | beat: 1, 18 | }, 19 | { 20 | id: 'bong', 21 | beat: 3, 22 | }, 23 | ], 24 | [ 25 | { 26 | id: 'ping', 27 | beat: 2, 28 | }, 29 | { 30 | id: 'pang', 31 | beat: 4, 32 | }, 33 | ], 34 | ], 35 | }; 36 | 37 | describe('toggleNote', () => { 38 | test('should toggle a note off', () => { 39 | const state = notesReducer(testNotes, toggleNote('bongo', 0, 1)); 40 | expect(state.bongo[0].length).toBe(1); 41 | }); 42 | 43 | test('should toggle a note on', () => { 44 | const state = notesReducer(testNotes, toggleNote('bongo', 0, 2)); 45 | expect(state.bongo[0].length).toBe(3); 46 | }); 47 | 48 | test('should toggle a note on the second preset on', () => { 49 | const state = notesReducer(testNotes, toggleNote('bongo', 1, 1)); 50 | expect(state.bongo[1].length).toBe(3); 51 | }); 52 | }); 53 | 54 | describe('initializeChannel', () => { 55 | test('should add a channel', () => { 56 | const state = notesReducer( 57 | testNotes, 58 | initializeChannelNotes('cowbell'), 59 | ); 60 | expect(state.cowbell).not.toBeUndefined(); 61 | }); 62 | }); 63 | 64 | describe('removeChannel', () => { 65 | test('should remove a channel that exists', () => { 66 | const state = notesReducer( 67 | testNotes, 68 | removeChannelNotes('bongo'), 69 | ); 70 | expect(state.bongo).toBeUndefined(); 71 | }); 72 | 73 | test('should do nothing if no channel matches the ID', () => { 74 | const state = notesReducer( 75 | testNotes, 76 | removeChannelNotes('foobar'), 77 | ); 78 | expect(state.bongo).not.toBeUndefined(); 79 | expect(state.foobar).toBeUndefined(); 80 | }); 81 | }); 82 | 83 | describe('setNotes', () => { 84 | test('should replace existing channels', () => { 85 | const state = notesReducer( 86 | testNotes, 87 | setNotes({ 88 | maracas: [ 89 | [], 90 | [], 91 | ], 92 | }), 93 | ); 94 | expect(state.bongo).toBeUndefined(); 95 | expect(state.maracas).not.toBeUndefined(); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/common/notes/notes.selectors.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const notesSelector = R.path(['notes']); 4 | -------------------------------------------------------------------------------- /src/common/playbackSession/index.js: -------------------------------------------------------------------------------- 1 | export * from './playbackSession.actions'; 2 | export * from './playbackSession.reducer'; 3 | export * from './playbackSession.selectors'; 4 | -------------------------------------------------------------------------------- /src/common/playbackSession/playbackSession.actions.js: -------------------------------------------------------------------------------- 1 | import { PLAYBACK_SESSION_CONSTANTS } from './playbackSession.constants'; 2 | import { getAudioContext } from '../../services/audioContext'; 3 | import { unmute } from '../../services/unmute'; 4 | 5 | export const startPlayback = () => ({ 6 | type: PLAYBACK_SESSION_CONSTANTS.START_PLAYBACK, 7 | }); 8 | 9 | export const stopPlayback = () => ({ 10 | type: PLAYBACK_SESSION_CONSTANTS.STOP_PLAYBACK, 11 | }); 12 | 13 | export const setStartTime = (val) => ({ 14 | type: PLAYBACK_SESSION_CONSTANTS.SET_START_TIME, 15 | payload: val, 16 | }); 17 | 18 | export const startPlaybackAndResume = () => (dispatch) => { 19 | unmute(); 20 | getAudioContext().resume(); 21 | dispatch(startPlayback()); 22 | }; 23 | -------------------------------------------------------------------------------- /src/common/playbackSession/playbackSession.constants.js: -------------------------------------------------------------------------------- 1 | export const PLAYBACK_SESSION_CONSTANTS = { 2 | START_PLAYBACK: 'START_PLAYBACK', 3 | STOP_PLAYBACK: 'STOP_PLAYBACK', 4 | SET_START_TIME: 'SET_START_TIME', 5 | }; 6 | -------------------------------------------------------------------------------- /src/common/playbackSession/playbackSession.reducer.js: -------------------------------------------------------------------------------- 1 | import { PLAYBACK_SESSION_CONSTANTS } from './playbackSession.constants'; 2 | import { getAudioContext } from '../../services/audioContext'; 3 | import { LOOKAHEAD } from '../../services/audioEngine.config'; 4 | 5 | export const playbackSessionInitialState = { 6 | playing: false, 7 | startTime: null, 8 | currentBeat: 1, 9 | }; 10 | 11 | export const playbackSessionReducer = (state = playbackSessionInitialState, action) => { 12 | switch (action.type) { 13 | case PLAYBACK_SESSION_CONSTANTS.START_PLAYBACK: 14 | return { 15 | ...state, 16 | playing: true, 17 | startTime: getAudioContext().currentTime + LOOKAHEAD + LOOKAHEAD, 18 | }; 19 | case PLAYBACK_SESSION_CONSTANTS.STOP_PLAYBACK: 20 | return { 21 | ...state, 22 | playing: false, 23 | startTime: null, 24 | }; 25 | case PLAYBACK_SESSION_CONSTANTS.SET_START_TIME: 26 | return { 27 | ...state, 28 | startTime: action.payload, 29 | }; 30 | default: 31 | return state; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/common/playbackSession/playbackSession.reducer.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | playbackSessionInitialState, 3 | playbackSessionReducer, 4 | } from './playbackSession.reducer'; 5 | import { 6 | startPlayback, 7 | stopPlayback, 8 | setStartTime, 9 | } from './playbackSession.actions'; 10 | import { LOOKAHEAD } from '../../services/audioEngine.config'; 11 | 12 | jest.mock('../../services/audioContext.js'); 13 | 14 | describe('startPlayback', () => { 15 | test('should set playing to true', () => { 16 | const state = playbackSessionReducer(playbackSessionInitialState, startPlayback()); 17 | expect(state.playing).toBe(true); 18 | }); 19 | 20 | test('should set startTime to current time plus lookahead', () => { 21 | const state = playbackSessionReducer(playbackSessionInitialState, startPlayback()); 22 | expect(state.startTime).toBe(1 + LOOKAHEAD + LOOKAHEAD); 23 | }); 24 | }); 25 | 26 | describe('startPlayback', () => { 27 | test('should set playing to false', () => { 28 | const state = playbackSessionReducer(playbackSessionInitialState, stopPlayback()); 29 | expect(state.playing).toBe(false); 30 | }); 31 | 32 | test('should set startTime to null', () => { 33 | const state = playbackSessionReducer(playbackSessionInitialState, stopPlayback()); 34 | expect(state.startTime).toBeNull(); 35 | }); 36 | }); 37 | 38 | describe('setStartTime', () => { 39 | test('should set startTime', () => { 40 | const state = playbackSessionReducer(playbackSessionInitialState, setStartTime(2.1234)); 41 | expect(state.startTime).toBe(2.1234); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/common/playbackSession/playbackSession.selectors.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const playingSelector = R.path(['playbackSession', 'playing']); 4 | export const startTimeSelector = R.path(['playbackSession', 'startTime']); 5 | -------------------------------------------------------------------------------- /src/common/presets/index.js: -------------------------------------------------------------------------------- 1 | export * from './presets.actions'; 2 | export * from './presets.reducer'; 3 | export * from './presets.selectors'; 4 | -------------------------------------------------------------------------------- /src/common/presets/presets.actions.js: -------------------------------------------------------------------------------- 1 | import { setBPM, setSwing } from '../tempo'; 2 | import { loadChannels } from '../channels'; 3 | import { setPattern, setSelectedChannel } from '../master'; 4 | import { PRESETS_CONSTANTS } from './presets.constants'; 5 | import presets from '../../presets'; 6 | import { showFlashMessage, FLASH_MESSAGES } from '../window'; 7 | import { currentStateSelector } from './presets.selectors'; 8 | 9 | export const setPreset = (presetName) => ({ 10 | type: PRESETS_CONSTANTS.SET_PRESET, 11 | payload: presetName, 12 | }); 13 | 14 | export const savePreset = (preset) => ({ 15 | type: PRESETS_CONSTANTS.SAVE_PRESET, 16 | payload: preset, 17 | }); 18 | 19 | export const savePresetAs = (preset) => ({ 20 | type: PRESETS_CONSTANTS.SAVE_PRESET_AS, 21 | payload: preset, 22 | }); 23 | 24 | export const deletePreset = (presetName) => ({ 25 | type: PRESETS_CONSTANTS.DELETE_PRESET, 26 | payload: presetName, 27 | }); 28 | 29 | export const loadPreset = (preset) => (dispatch) => { 30 | dispatch(setBPM(preset.bpm)); 31 | dispatch(setSwing(preset.swing)); 32 | dispatch(loadChannels(preset.channels, preset.notes)); 33 | dispatch(setPreset(preset.name)); 34 | dispatch(setPattern(0)); 35 | dispatch(setSelectedChannel(preset.channels[0].id)); 36 | }; 37 | 38 | export const erasePreset = (presetName) => (dispatch) => { 39 | dispatch(setBPM(presets[0].bpm)); 40 | dispatch(setSwing(presets[0].swing)); 41 | dispatch(loadChannels(presets[0].channels, presets[0].notes)); 42 | dispatch(setPreset(presets[0].name)); 43 | dispatch(setPattern(0)); 44 | dispatch(setSelectedChannel(presets[0].channels[0].id)); 45 | dispatch(deletePreset(presetName)); 46 | dispatch(showFlashMessage(FLASH_MESSAGES.PRESET_DELETED)); 47 | }; 48 | 49 | export const doSavePresetAs = (presetName) => (dispatch, getState) => { 50 | const currentState = currentStateSelector(getState()); 51 | dispatch(savePresetAs({ 52 | ...currentState, 53 | name: presetName, 54 | })); 55 | dispatch(setPreset(presetName)); 56 | dispatch(showFlashMessage(FLASH_MESSAGES.PRESET_SAVED)); 57 | }; 58 | 59 | export const doSavePreset = (presetName) => (dispatch, getState) => { 60 | const currentState = currentStateSelector(getState()); 61 | dispatch(savePreset({ 62 | ...currentState, 63 | name: presetName, 64 | })); 65 | dispatch(showFlashMessage(FLASH_MESSAGES.PRESET_SAVED)); 66 | }; 67 | -------------------------------------------------------------------------------- /src/common/presets/presets.constants.js: -------------------------------------------------------------------------------- 1 | export const PRESETS_CONSTANTS = { 2 | SAVE_PRESET: 'SAVE_PRESET', 3 | SAVE_PRESET_AS: 'SAVE_PRESET_AS', 4 | DELETE_PRESET: 'DELETE_PRESET', 5 | SET_PRESET: 'SET_PRESET', 6 | }; 7 | -------------------------------------------------------------------------------- /src/common/presets/presets.reducer.js: -------------------------------------------------------------------------------- 1 | import { PRESETS_CONSTANTS } from './presets.constants'; 2 | import defaultPresets from '../../presets'; 3 | 4 | export const presetsInitialState = { 5 | userPresets: [], 6 | preset: defaultPresets[1].name, 7 | }; 8 | 9 | export const presetsReducer = (state = presetsInitialState, action) => { 10 | switch (action.type) { 11 | case PRESETS_CONSTANTS.SET_PRESET: 12 | return { 13 | ...state, 14 | preset: action.payload, 15 | }; 16 | case PRESETS_CONSTANTS.SAVE_PRESET: 17 | return { 18 | ...state, 19 | userPresets: state.userPresets.map( 20 | (userPreset) => (userPreset.name === action.payload.name 21 | ? action.payload 22 | : userPreset), 23 | ), 24 | }; 25 | case PRESETS_CONSTANTS.SAVE_PRESET_AS: 26 | return { 27 | ...state, 28 | userPresets: [ 29 | ...state.userPresets.filter( 30 | (userPreset) => userPreset.name !== action.payload.name, 31 | ), 32 | action.payload, 33 | ], 34 | }; 35 | case PRESETS_CONSTANTS.DELETE_PRESET: 36 | return { 37 | ...state, 38 | userPresets: state.userPresets.filter( 39 | (userPreset) => userPreset.name !== action.payload, 40 | ), 41 | }; 42 | default: 43 | return state; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/common/presets/presets.reducer.test.js: -------------------------------------------------------------------------------- 1 | import { presetsInitialState, presetsReducer } from './presets.reducer'; 2 | import { 3 | setPreset, 4 | savePreset, 5 | savePresetAs, 6 | deletePreset, 7 | } from './presets.actions'; 8 | 9 | jest.mock('../../presets'); 10 | jest.mock('../../services/featureChecks'); 11 | 12 | const testPreset = { 13 | name: 'Test preset', 14 | bpm: 120, 15 | }; 16 | 17 | describe('setPreset', () => { 18 | test('should change the preset', () => { 19 | const state = presetsReducer(presetsInitialState, setPreset('hello')); 20 | expect(state.preset).toEqual('hello'); 21 | }); 22 | }); 23 | 24 | describe('savePresetAs', () => { 25 | test('should add a new user preset', () => { 26 | const state = presetsReducer(presetsInitialState, savePresetAs(testPreset)); 27 | expect(state.userPresets.length).toEqual(1); 28 | }); 29 | }); 30 | 31 | describe('savePreset', () => { 32 | test('should update a user preset', () => { 33 | const state = presetsReducer(presetsInitialState, savePresetAs(testPreset)); 34 | expect(state.userPresets.length).toEqual(1); 35 | expect(state.userPresets[0].bpm).toEqual(120); 36 | const newState = presetsReducer(state, savePreset({ 37 | name: 'Test preset', 38 | bpm: 100, 39 | })); 40 | expect(newState.userPresets.length).toEqual(1); 41 | expect(newState.userPresets[0].bpm).toEqual(100); 42 | }); 43 | }); 44 | 45 | describe('deletePreset', () => { 46 | test('should add a new user preset', () => { 47 | const state = presetsReducer(presetsInitialState, savePresetAs(testPreset)); 48 | expect(state.userPresets.length).toEqual(1); 49 | const newState = presetsReducer(state, deletePreset('Test preset')); 50 | expect(newState.userPresets.length).toEqual(0); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/common/presets/presets.selectors.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | import { createSelector } from 'reselect'; 3 | import { channelsSelector } from '../channels'; 4 | import { notesSelector } from '../notes'; 5 | import { bpmSelector, swingSelector } from '../tempo'; 6 | 7 | export const userPresetsSelector = R.path(['presets', 'userPresets']); 8 | 9 | export const presetSelector = R.path(['presets', 'preset']); 10 | 11 | export const currentStateSelector = createSelector( 12 | channelsSelector, 13 | notesSelector, 14 | bpmSelector, 15 | swingSelector, 16 | (channels, notes, bpm, swing) => ({ 17 | notes, 18 | bpm, 19 | swing, 20 | channels: channels.map( 21 | (channel) => R.omit(['sampleLoaded'], channel), 22 | ), 23 | }), 24 | ); 25 | -------------------------------------------------------------------------------- /src/common/tempo/index.js: -------------------------------------------------------------------------------- 1 | export * from './tempo.actions'; 2 | export * from './tempo.reducer'; 3 | export * from './tempo.selectors'; 4 | -------------------------------------------------------------------------------- /src/common/tempo/tempo.actions.js: -------------------------------------------------------------------------------- 1 | import { TEMPO_CONSTANTS } from './tempo.constants'; 2 | 3 | export const setBPM = (val) => ({ 4 | type: TEMPO_CONSTANTS.SET_BPM, 5 | payload: val, 6 | }); 7 | 8 | export const setSwing = (val) => ({ 9 | type: TEMPO_CONSTANTS.SET_SWING, 10 | payload: val, 11 | }); 12 | -------------------------------------------------------------------------------- /src/common/tempo/tempo.constants.js: -------------------------------------------------------------------------------- 1 | export const TEMPO_CONSTANTS = { 2 | SET_BPM: 'SET_BPM', 3 | SET_SWING: 'SET_SWING', 4 | }; 5 | -------------------------------------------------------------------------------- /src/common/tempo/tempo.reducer.js: -------------------------------------------------------------------------------- 1 | import { TEMPO_CONSTANTS } from './tempo.constants'; 2 | import presets from '../../presets'; 3 | 4 | export const tempoInitialState = { 5 | bpm: presets[1].bpm, 6 | swing: presets[1].swing, 7 | }; 8 | 9 | export const tempoReducer = (state = tempoInitialState, action) => { 10 | switch (action.type) { 11 | case TEMPO_CONSTANTS.SET_BPM: 12 | return { 13 | ...state, 14 | bpm: action.payload, 15 | }; 16 | case TEMPO_CONSTANTS.SET_SWING: 17 | return { 18 | ...state, 19 | swing: action.payload, 20 | }; 21 | default: 22 | return state; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/common/tempo/tempo.reducer.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | tempoInitialState, 3 | tempoReducer, 4 | } from './tempo.reducer'; 5 | import { setBPM, setSwing } from './tempo.actions'; 6 | 7 | jest.mock('../../presets'); 8 | 9 | describe('setBPM', () => { 10 | test('should set bpm', () => { 11 | const state = tempoReducer(tempoInitialState, setBPM(123)); 12 | expect(state.bpm).toBe(123); 13 | }); 14 | }); 15 | 16 | describe('setSwing', () => { 17 | test('should set swing', () => { 18 | const state = tempoReducer(tempoInitialState, setSwing(0.4)); 19 | expect(state.swing).toBe(0.4); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/common/tempo/tempo.selectors.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const bpmSelector = R.path(['tempo', 'bpm']); 4 | export const swingSelector = R.path(['tempo', 'swing']); 5 | -------------------------------------------------------------------------------- /src/common/userSamples/index.js: -------------------------------------------------------------------------------- 1 | export * from './userSamples.actions'; 2 | export * from './userSamples.reducer'; 3 | export * from './userSamples.selectors'; 4 | -------------------------------------------------------------------------------- /src/common/userSamples/userSamples.actions.js: -------------------------------------------------------------------------------- 1 | import { saveToSampleStore } from '../../services/sampleStore'; 2 | import { USER_SAMPLES_CONSTANTS } from './userSamples.constants'; 3 | import { loadAndSetChannelSample } from '../channels'; 4 | import { showFlashMessage, FLASH_MESSAGES } from '../window'; 5 | 6 | export const addUserSample = (sample) => ({ 7 | type: USER_SAMPLES_CONSTANTS.ADD_USER_SAMPLE, 8 | payload: sample, 9 | }); 10 | 11 | export const removeUserSample = (sampleId) => ({ 12 | type: USER_SAMPLES_CONSTANTS.REMOVE_USER_SAMPLE, 13 | payload: sampleId, 14 | }); 15 | 16 | export const clearUserSamples = () => ({ 17 | type: USER_SAMPLES_CONSTANTS.CLEAR_USER_SAMPLES, 18 | }); 19 | 20 | export const saveUserSample = (channel, files) => (dispatch) => { 21 | saveToSampleStore(files[0]) 22 | .then((sampleURL) => { 23 | dispatch(addUserSample(sampleURL)); 24 | dispatch(loadAndSetChannelSample(channel, sampleURL)); 25 | }) 26 | .catch(() => { 27 | dispatch(showFlashMessage(FLASH_MESSAGES.SAMPLE_LOAD_ERROR)); 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /src/common/userSamples/userSamples.constants.js: -------------------------------------------------------------------------------- 1 | export const USER_SAMPLES_CONSTANTS = { 2 | ADD_USER_SAMPLE: 'ADD_USER_SAMPLE', 3 | REMOVE_USER_SAMPLE: 'REMOVE_USER_SAMPLE', 4 | CLEAR_USER_SAMPLES: 'CLEAR_USER_SAMPLES', 5 | }; 6 | -------------------------------------------------------------------------------- /src/common/userSamples/userSamples.reducer.js: -------------------------------------------------------------------------------- 1 | import { USER_SAMPLES_CONSTANTS } from './userSamples.constants'; 2 | 3 | export const userSamplesInitialState = []; 4 | 5 | export const userSamplesReducer = (state = userSamplesInitialState, action) => { 6 | switch (action.type) { 7 | case USER_SAMPLES_CONSTANTS.ADD_USER_SAMPLE: 8 | return [ 9 | ...state, 10 | action.payload, 11 | ]; 12 | case USER_SAMPLES_CONSTANTS.REMOVE_USER_SAMPLE: 13 | return state.filter((userSample) => userSample.id !== action.payload); 14 | case USER_SAMPLES_CONSTANTS.CLEAR_USER_SAMPLES: 15 | return userSamplesInitialState; 16 | default: 17 | return state; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/common/userSamples/userSamples.selectors.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const userSamplesSelector = R.path(['userSamples']); 4 | -------------------------------------------------------------------------------- /src/common/window/index.js: -------------------------------------------------------------------------------- 1 | export * from './window.actions'; 2 | export * from './window.reducer'; 3 | export * from './window.selectors'; 4 | export * from './window.constants'; 5 | -------------------------------------------------------------------------------- /src/common/window/window.actions.js: -------------------------------------------------------------------------------- 1 | import { WINDOW_CONSTANTS } from './window.constants'; 2 | 3 | export const setPresetPrompt = (isOpen) => ({ 4 | type: WINDOW_CONSTANTS.PRESET_PROMPT_OPEN, 5 | payload: isOpen, 6 | }); 7 | 8 | export const setPresetNameField = (val) => ({ 9 | type: WINDOW_CONSTANTS.SET_PRESET_NAME_FIELD, 10 | payload: val, 11 | }); 12 | 13 | export const showFlashMessage = (messageKey) => ({ 14 | type: WINDOW_CONSTANTS.SET_FLASH_MESSAGE, 15 | payload: messageKey, 16 | }); 17 | 18 | export const clearFlashMessage = () => ({ 19 | type: WINDOW_CONSTANTS.CLEAR_FLASH_MESSAGE, 20 | }); 21 | 22 | export const setCanInstall = (canInstall) => ({ 23 | type: WINDOW_CONSTANTS.SET_CAN_INSTALL, 24 | payload: canInstall, 25 | }); 26 | -------------------------------------------------------------------------------- /src/common/window/window.constants.js: -------------------------------------------------------------------------------- 1 | export const WINDOW_CONSTANTS = { 2 | PRESET_PROMPT_OPEN: 'PRESET_PROMPT_OPEN', 3 | SET_PRESET_NAME_FIELD: 'SET_PRESET_NAME_FIELD', 4 | SET_FLASH_MESSAGE: 'SET_FLASH_MESSAGE', 5 | CLEAR_FLASH_MESSAGE: 'CLEAR_FLASH_MESSAGE', 6 | SET_CAN_INSTALL: 'SET_CAN_INSTALL', 7 | }; 8 | 9 | export const FLASH_MESSAGES = { 10 | INSTALL_PWA: 'FLASH_MESSAGE_INSTALL_PWA', 11 | SAMPLE_LOAD_ERROR: 'SAMPLE_LOAD_ERROR', 12 | PRESET_SAVED: 'PRESET_SAVED', 13 | PRESET_DELETED: 'PRESET_DELETED', 14 | }; 15 | -------------------------------------------------------------------------------- /src/common/window/window.reducer.js: -------------------------------------------------------------------------------- 1 | import { WINDOW_CONSTANTS } from './window.constants'; 2 | 3 | export const windowInitialState = { 4 | presetPromptOpen: false, 5 | flashMessageKey: null, 6 | flashMessageVisible: false, 7 | canInstall: false, 8 | }; 9 | 10 | export const windowReducer = (state = windowInitialState, action) => { 11 | switch (action.type) { 12 | case WINDOW_CONSTANTS.PRESET_PROMPT_OPEN: 13 | return { 14 | ...state, 15 | presetPromptOpen: action.payload, 16 | }; 17 | case WINDOW_CONSTANTS.SET_FLASH_MESSAGE: 18 | return { 19 | ...state, 20 | flashMessageKey: action.payload, 21 | flashMessageVisible: true, 22 | }; 23 | case WINDOW_CONSTANTS.CLEAR_FLASH_MESSAGE: 24 | return { 25 | ...state, 26 | flashMessageVisible: false, 27 | }; 28 | case WINDOW_CONSTANTS.SET_CAN_INSTALL: 29 | return { 30 | ...state, 31 | canInstall: action.payload, 32 | }; 33 | default: 34 | return state; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/common/window/window.reducer.test.js: -------------------------------------------------------------------------------- 1 | import { windowInitialState, windowReducer } from './window.reducer'; 2 | import { 3 | setPresetPrompt, 4 | showFlashMessage, 5 | clearFlashMessage, 6 | setCanInstall, 7 | } from './window.actions'; 8 | 9 | describe('setPresetPrompt', () => { 10 | test('should set presetPromptOpen to true', () => { 11 | const state = windowReducer(windowInitialState, setPresetPrompt(true)); 12 | expect(state.presetPromptOpen).toBe(true); 13 | }); 14 | }); 15 | 16 | describe('showFlashMessage', () => { 17 | test('should set flashMessageKey to a string value', () => { 18 | const state = windowReducer(windowInitialState, showFlashMessage('foobar')); 19 | expect(state.flashMessageKey).toBe('foobar'); 20 | expect(state.flashMessageVisible).toEqual(true); 21 | }); 22 | }); 23 | 24 | describe('clearFlashMessage', () => { 25 | test('should set flashMessageKey to null', () => { 26 | const state = windowReducer(windowInitialState, showFlashMessage('foobar')); 27 | const nullState = windowReducer(state, clearFlashMessage()); 28 | expect(nullState.flashMessageKey).toBe('foobar'); 29 | expect(nullState.flashMessageVisible).toEqual(false); 30 | }); 31 | }); 32 | 33 | describe('setCanInstall', () => { 34 | test('should set canInstall to a value', () => { 35 | const state = windowReducer(windowInitialState, setCanInstall(true)); 36 | expect(state.canInstall).toBe(true); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/common/window/window.selectors.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const presetPromptOpenSelector = R.path(['window', 'presetPromptOpen']); 4 | export const flashMessageKeySelector = R.path(['window', 'flashMessageKey']); 5 | export const flashMessageVisibleSelector = R.path(['window', 'flashMessageVisible']); 6 | export const canInstallSelector = R.path(['window', 'canInstall']); 7 | -------------------------------------------------------------------------------- /src/components/AddChannelButton/AddChannelButton.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { HoverButton } from '../design-system'; 4 | 5 | export const AddChannelButtonComponent = ({ newChannel }) => ( 6 | 18 | Add Channel + 19 | 20 | ); 21 | 22 | AddChannelButtonComponent.propTypes = { 23 | newChannel: PropTypes.func.isRequired, 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/AddChannelButton/AddChannelButton.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose } from 'recompose'; 3 | import { AddChannelButtonComponent } from './AddChannelButton.component'; 4 | import { newChannel } from '../../common'; 5 | 6 | const mapDispatchToProps = { 7 | newChannel, 8 | }; 9 | 10 | export const AddChannelButton = compose( 11 | connect(null, mapDispatchToProps), 12 | )(AddChannelButtonComponent); 13 | -------------------------------------------------------------------------------- /src/components/AddChannelButton/index.js: -------------------------------------------------------------------------------- 1 | export * from './AddChannelButton.container'; 2 | -------------------------------------------------------------------------------- /src/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThemeProvider } from 'styled-components'; 3 | import theme from '../styles/theme'; 4 | import globalStyles from '../styles/globalStyles'; 5 | import { 6 | Box, 7 | ChannelList, 8 | ChannelHeader, 9 | ChannelControls, 10 | MasterControls, 11 | Branding, 12 | GithubLink, 13 | FlashMessage, 14 | InstallButton, 15 | } from '.'; 16 | 17 | globalStyles(); 18 | 19 | const App = () => ( 20 | 21 | 27 |
28 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 | 52 | 53 | 54 |
55 | 56 |
57 |
58 | ); 59 | 60 | export default App; 61 | -------------------------------------------------------------------------------- /src/components/BPMInput/BPMInput.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import theme from '../../styles/theme'; 4 | import { 5 | Box, 6 | Label, 7 | TextInput, 8 | Button, 9 | } from '../design-system'; 10 | 11 | const ShinyBox = Box.extend` 12 | background: linear-gradient(190deg, #19191D 0%, #303036 50%,#0a0e0a 51%, #29292D 100%); 13 | transition: border-color 0.2s; 14 | 15 | &:hover { 16 | border-color: ${theme.colors.gray}; 17 | } 18 | `; 19 | 20 | const BPMButton = Button.extend` 21 | &:active { 22 | background-color: rgba(255, 255, 255, 0.2); 23 | } 24 | `; 25 | 26 | export const BPMInputComponent = ({ bpm, setBPM }) => ( 27 | 34 | 50 | { 69 | setBPM(parseInt(e.target.value, 10)); 70 | }} 71 | /> 72 | 73 | { 81 | setBPM(bpm + 1); 82 | }} 83 | aria-label="Increase beat per minute" 84 | > 85 | 86 | 87 | 88 | 89 | { 97 | setBPM(bpm - 1); 98 | }} 99 | aria-label="Decrease beat per minute" 100 | > 101 | 102 | 103 | 104 | 105 | 106 | 107 | ); 108 | 109 | BPMInputComponent.propTypes = { 110 | bpm: PropTypes.number.isRequired, 111 | setBPM: PropTypes.func.isRequired, 112 | }; 113 | -------------------------------------------------------------------------------- /src/components/BPMInput/BPMInput.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose } from 'recompose'; 3 | import { BPMInputComponent } from './BPMInput.component'; 4 | import { bpmInputSelectors } from './BPMInput.selectors'; 5 | import { setBPM } from '../../common'; 6 | 7 | const mapDispatchToProps = { 8 | setBPM, 9 | }; 10 | 11 | export const BPMInput = compose( 12 | connect(bpmInputSelectors, mapDispatchToProps), 13 | )(BPMInputComponent); 14 | -------------------------------------------------------------------------------- /src/components/BPMInput/BPMInput.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { bpmSelector } from '../../common'; 3 | 4 | export const bpmInputSelectors = createStructuredSelector({ 5 | bpm: bpmSelector, 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/BPMInput/index.js: -------------------------------------------------------------------------------- 1 | export * from './BPMInput.container'; 2 | -------------------------------------------------------------------------------- /src/components/Branding.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import theme from '../styles/theme'; 4 | import { Logo } from './Logo.component'; 5 | import { Box } from './design-system'; 6 | 7 | const HeaderText = styled.h1` 8 | color: ${theme.colors.steel}; 9 | font-size: 1em; 10 | font-weight: 600; 11 | margin-left: 1.5em; 12 | line-height: 1.2em; 13 | margin-top: 0.5em; 14 | max-width: 4em; 15 | `; 16 | 17 | export const Branding = () => ( 18 | 19 | 20 | 21 | Web Drum Sequencer 22 | 23 | 24 | ); 25 | -------------------------------------------------------------------------------- /src/components/Channel/Channel.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import * as R from 'ramda'; 4 | import { Toggles } from '../Toggles'; 5 | import { 6 | Box, 7 | Text, 8 | Image, 9 | } from '../design-system'; 10 | import { RemoveButton } from './RemoveButton.component'; 11 | import { HitButton } from './HitButton.component'; 12 | import { MuteSolo } from '../MuteSolo'; 13 | import construction from '../../assets/images/construction-light.svg'; 14 | import samples from '../../samples.config'; 15 | 16 | const getSampleName = (sampleURL) => { 17 | const maybeName = R.find(R.propEq('url', sampleURL))(samples); 18 | return maybeName ? maybeName.name : sampleURL; 19 | }; 20 | 21 | const ChannelBox = Box.extend` 22 | outline: none; 23 | 24 | &.draggable-source--is-dragging { 25 | opacity: 0.2; 26 | } 27 | 28 | &.draggable-mirror { 29 | opacity: 0.9; 30 | z-index: 10; 31 | } 32 | `; 33 | 34 | const MoveImage = Image.extend` 35 | cursor: move; 36 | opacity: 0.2; 37 | transition: opacity 0.1s; 38 | 39 | &:hover, &:focus, &:active { 40 | opacity: 0.3; 41 | } 42 | `; 43 | 44 | export const ChannelComponent = ({ 45 | channel, 46 | onPressRemove, 47 | notes, 48 | pattern, 49 | onPressHitButton, 50 | onTouchChannel, 51 | selectedChannelId, 52 | }) => { 53 | const sampleName = getSampleName(channel.sample); 54 | return ( 55 | 67 | 77 | 78 | 79 | 80 | {sampleName} 81 | 82 | 83 | 84 | 85 | 86 | 90 | 91 | 92 | ); 93 | }; 94 | 95 | ChannelComponent.propTypes = { 96 | notes: PropTypes.objectOf(PropTypes.array).isRequired, 97 | channel: PropTypes.shape({ 98 | sample: PropTypes.string, 99 | id: PropTypes.string.isRequired, 100 | }).isRequired, 101 | onPressRemove: PropTypes.func.isRequired, 102 | pattern: PropTypes.number.isRequired, 103 | onPressHitButton: PropTypes.func.isRequired, 104 | onTouchChannel: PropTypes.func.isRequired, 105 | selectedChannelId: PropTypes.string.isRequired, 106 | }; 107 | -------------------------------------------------------------------------------- /src/components/Channel/Channel.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { ChannelComponent } from './Channel.component'; 4 | import { channelSelectors } from './Channel.selectors'; 5 | import { 6 | deleteChannel, 7 | setSelectedChannel, 8 | } from '../../common'; 9 | import { playNoteNow } from '../../services/audioScheduler'; 10 | 11 | const mapDispatchToProps = { 12 | deleteChannel, 13 | setSelectedChannel, 14 | }; 15 | 16 | const handlers = withHandlers({ 17 | onSelectSample: (props) => (sample) => { 18 | const { loadAndSetChannelSample: scs, channel } = props; 19 | scs(channel.id, sample.value); 20 | }, 21 | onTouchChannel: (props) => () => { 22 | const { channel, setSelectedChannel: sscs } = props; 23 | sscs(channel.id); 24 | }, 25 | onPressRemove: (props) => () => { 26 | const { 27 | channel, 28 | channels, 29 | selectedChannelId, 30 | deleteChannel: dc, 31 | } = props; 32 | dc(channel.id, channels, selectedChannelId); 33 | }, 34 | onPressHitButton: (props) => () => { 35 | const { channel } = props; 36 | playNoteNow(channel); 37 | }, 38 | }); 39 | 40 | export const Channel = compose( 41 | connect(channelSelectors, mapDispatchToProps), 42 | handlers, 43 | )(ChannelComponent); 44 | -------------------------------------------------------------------------------- /src/components/Channel/Channel.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { 3 | channelsSelector, 4 | notesSelector, 5 | patternSelector, 6 | selectedChannelSelector, 7 | } from '../../common'; 8 | 9 | export const channelSelectors = createStructuredSelector({ 10 | channels: channelsSelector, 11 | notes: notesSelector, 12 | pattern: patternSelector, 13 | selectedChannelId: selectedChannelSelector, 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/Channel/HitButton.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { HoverButton } from '../design-system'; 4 | 5 | export const HitButton = ({ channel, onMouseDown }) => ( 6 | e.preventDefault()} 18 | aria-label={`Play ${channel.sample.name}`} 19 | touch-action="manipulation" 20 | /> 21 | ); 22 | 23 | HitButton.propTypes = { 24 | channel: PropTypes.shape({ 25 | sample: PropTypes.string.isRequired, 26 | gain: PropTypes.number.isRequired, 27 | }).isRequired, 28 | onMouseDown: PropTypes.func.isRequired, 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/Channel/RemoveButton.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { HoverButton } from '../design-system'; 4 | import theme from '../../styles/theme'; 5 | 6 | export const RemoveButton = ({ onClick }) => ( 7 | 25 | 26 | 27 | 28 | 29 | ); 30 | 31 | RemoveButton.propTypes = { 32 | onClick: PropTypes.func.isRequired, 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/Channel/index.js: -------------------------------------------------------------------------------- 1 | export * from './Channel.container'; 2 | -------------------------------------------------------------------------------- /src/components/ChannelControls/ChannelControls.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | detuneSupported, 5 | } from '../../services/featureChecks'; 6 | import { Box } from '../design-system'; 7 | import { SampleSelect } from '../SampleSelect'; 8 | import { InfoKnob } from '../InfoKnob.component'; 9 | import { LabelBox } from '../LabelBox'; 10 | 11 | const ControlCluster = Box.extend` 12 | background-color: ${({ theme }) => theme.colors.darkGray}; 13 | border-radius: 0.3rem; 14 | display: flex; 15 | margin: 0.5rem; 16 | align-items: flex-start; 17 | padding: 0.8rem; 18 | `; 19 | 20 | export const ChannelControlsComponent = ({ 21 | channel, 22 | onSetGain, 23 | onSetPan, 24 | onSetChannelPitchCoarse, 25 | onSetReverb, 26 | }) => ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | {detuneSupported && ( 34 | 43 | )} 44 | 45 | 46 | 47 | 48 | 58 | 59 | 60 | 67 | 68 | 69 | 70 | 71 | 81 | 82 | 83 | 84 | ); 85 | 86 | ChannelControlsComponent.propTypes = { 87 | channel: PropTypes.shape({ 88 | id: PropTypes.string.isRequired, 89 | pitchCoarse: PropTypes.number, 90 | reverb: PropTypes.number, 91 | gain: PropTypes.number, 92 | pan: PropTypes.number, 93 | }).isRequired, 94 | onSetGain: PropTypes.func.isRequired, 95 | onSetPan: PropTypes.func.isRequired, 96 | onSetChannelPitchCoarse: PropTypes.func.isRequired, 97 | onSetReverb: PropTypes.func.isRequired, 98 | }; 99 | -------------------------------------------------------------------------------- /src/components/ChannelControls/ChannelControls.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { ChannelControlsComponent } from './ChannelControls.component'; 4 | import { channelControlsSelectors } from './ChannelControls.selectors'; 5 | import { 6 | setChannelGain, 7 | setChannelPitchCoarse, 8 | setChannelPan, 9 | setChannelReverb, 10 | } from '../../common'; 11 | 12 | const mapDispatchToProps = { 13 | setChannelGain, 14 | setChannelPan, 15 | setChannelPitchCoarse, 16 | setChannelReverb, 17 | }; 18 | 19 | const handlers = withHandlers({ 20 | onSetGain: (props) => (e) => { 21 | const { setChannelGain: setChannelGainConnected, channel } = props; 22 | setChannelGainConnected(channel.id, e.target.value / 100); 23 | }, 24 | onSetPan: (props) => (e) => { 25 | const { setChannelPan: setChannelPanConnected, channel } = props; 26 | setChannelPanConnected(channel.id, e.target.value); 27 | }, 28 | onSetChannelPitchCoarse: (props) => (e) => { 29 | const { setChannelPitchCoarse: setChannelPitchCoarseConnected, channel } = props; 30 | setChannelPitchCoarseConnected(channel.id, e.target.value); 31 | }, 32 | onSetReverb: (props) => (e) => { 33 | const { setChannelReverb: setChannelReverbConnected, channel } = props; 34 | setChannelReverbConnected(channel.id, e.target.value); 35 | }, 36 | }); 37 | 38 | export const ChannelControls = compose( 39 | connect(channelControlsSelectors, mapDispatchToProps), 40 | handlers, 41 | )(ChannelControlsComponent); 42 | -------------------------------------------------------------------------------- /src/components/ChannelControls/ChannelControls.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector, createSelector } from 'reselect'; 2 | import { channelsSelector, selectedChannelSelector } from '../../common'; 3 | 4 | const channelSelector = createSelector( 5 | channelsSelector, 6 | selectedChannelSelector, 7 | (channels, selectedChannelID) => { 8 | const selectedChannel = channels.find( 9 | (channel) => channel.id === selectedChannelID, 10 | ); 11 | return typeof selectedChannel === 'undefined' ? channels[0] : selectedChannel; 12 | }, 13 | ); 14 | 15 | export const channelControlsSelectors = createStructuredSelector({ 16 | channel: channelSelector, 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/ChannelControls/index.js: -------------------------------------------------------------------------------- 1 | export * from './ChannelControls.container'; 2 | -------------------------------------------------------------------------------- /src/components/ChannelHeader/ChannelHeader.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '../design-system'; 3 | import { ChannelHeaderLabel } from './ChannelHeaderLabel.component'; 4 | import { Marker } from '../Marker'; 5 | 6 | export const ChannelHeader = () => ( 7 | 8 | 9 | 10 | Channels 11 | 12 | 13 | Hit 14 | 15 | 16 | 17 | 18 | 1 19 | 20 | 21 | 2 22 | 23 | 24 | 3 25 | 26 | 27 | 4 28 | 29 | 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /src/components/ChannelHeader/ChannelHeaderLabel.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Box, Text } from '../design-system'; 4 | 5 | const HeaderText = Text.extend` 6 | text-transform: uppercase; 7 | `; 8 | 9 | export const ChannelHeaderLabel = ({ children, centerText, ...restProps }) => ( 10 | 11 | 17 | {children} 18 | 19 | 20 | ); 21 | 22 | ChannelHeaderLabel.propTypes = { 23 | children: PropTypes.node.isRequired, 24 | centerText: PropTypes.bool, 25 | }; 26 | 27 | ChannelHeaderLabel.defaultProps = { 28 | centerText: false, 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/ChannelHeader/index.js: -------------------------------------------------------------------------------- 1 | export * from './ChannelHeader.component'; 2 | -------------------------------------------------------------------------------- /src/components/ChannelList/ChannelList.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Sortable } from '@shopify/draggable'; 4 | import { Box } from '../design-system'; 5 | import { Channel } from '../Channel'; 6 | import { AddChannelButton } from '../AddChannelButton'; 7 | 8 | const ChannelListBox = Box.extend` 9 | outline: none; 10 | `; 11 | 12 | export class ChannelListComponent extends React.Component { 13 | componentDidMount() { 14 | // eslint-disable-next-line 15 | const sortable = new Sortable([this.channelContainer], { 16 | draggable: '.wds-draggable', 17 | handle: '.wds-channel-handle', 18 | mirror: { 19 | constrainDimensions: true, 20 | }, 21 | }); 22 | 23 | sortable.on('sortable:stop', ({ oldIndex, newIndex }) => { 24 | const { onUpdateChannelOrder } = this.props; 25 | onUpdateChannelOrder(oldIndex, newIndex); 26 | }); 27 | } 28 | 29 | render() { 30 | const { channels } = this.props; 31 | return ( 32 | { this.channelContainer = el; }}> 33 | {channels.map(channel => )} 34 | 35 | 36 | ); 37 | } 38 | } 39 | 40 | ChannelListComponent.propTypes = { 41 | channels: PropTypes.arrayOf(PropTypes.object).isRequired, 42 | onUpdateChannelOrder: PropTypes.func.isRequired, 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/ChannelList/ChannelList.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { ChannelListComponent } from './ChannelList.component'; 4 | import { channelListSelectors } from './ChannelList.selectors'; 5 | import { toggleNote, updateChannelOrder } from '../../common'; 6 | 7 | const mapDispatchToProps = { 8 | toggleNote, 9 | updateChannelOrder, 10 | }; 11 | 12 | const handlers = withHandlers({ 13 | onUpdateChannelOrder: (props) => (oldIndex, newIndex) => { 14 | const { updateChannelOrder: updateChannelOrderConnected } = props; 15 | updateChannelOrderConnected(oldIndex, newIndex); 16 | }, 17 | }); 18 | 19 | export const ChannelList = compose( 20 | connect(channelListSelectors, mapDispatchToProps), 21 | handlers, 22 | )(ChannelListComponent); 23 | -------------------------------------------------------------------------------- /src/components/ChannelList/ChannelList.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { channelsSelector } from '../../common'; 3 | 4 | export const channelListSelectors = createStructuredSelector({ 5 | channels: channelsSelector, 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/ChannelList/index.js: -------------------------------------------------------------------------------- 1 | export * from './ChannelList.container'; 2 | -------------------------------------------------------------------------------- /src/components/FancyButton.component.jsx: -------------------------------------------------------------------------------- 1 | import { variant } from 'styled-system'; 2 | import { Button } from './design-system'; 3 | 4 | const fancyButtonStyle = variant({ 5 | key: 'fancyButtons', 6 | }); 7 | 8 | export const FancyButton = Button.extend` 9 | ${fancyButtonStyle} 10 | transition: box-shadow 0.2s, transform 0.2s; 11 | text-transform: uppercase; 12 | height: calc(100% - 4px); 13 | 14 | &:active: { 15 | transform: translateY(0.3em); 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /src/components/FlashMessage/FlashMessage.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import * as animol from 'animol'; 4 | import { FLASH_MESSAGES } from '../../common'; 5 | import { 6 | Box, 7 | HoverButton, 8 | } from '../design-system'; 9 | import { SampleLoadError } from '../SampleLoadError.component'; 10 | import { PresetSaved } from '../PresetSaved.component'; 11 | import { PresetDeleted } from '../PresetDeleted.component'; 12 | import { timedCallback } from '../timedCallback.hoc'; 13 | 14 | const getMessageComponent = (messageKey) => { 15 | switch (messageKey) { 16 | case FLASH_MESSAGES.SAMPLE_LOAD_ERROR: 17 | return SampleLoadError; 18 | case FLASH_MESSAGES.PRESET_SAVED: 19 | return PresetSaved; 20 | case FLASH_MESSAGES.PRESET_DELETED: 21 | return PresetDeleted; 22 | default: 23 | return undefined; 24 | } 25 | }; 26 | 27 | export class FlashMessageComponent extends React.Component { 28 | componentDidMount() { 29 | this.animateBox(); 30 | } 31 | 32 | componentDidUpdate() { 33 | this.animateBox(); 34 | } 35 | 36 | animateBox() { 37 | const { flashMessageVisible, messageKey } = this.props; 38 | if (messageKey && flashMessageVisible) { 39 | this.flashBox.style.display = 'block'; 40 | animol.css( 41 | this.flashBox, 42 | 500, 43 | { opacity: 0, transform: { translateY: '10%' } }, 44 | { opacity: 1, transform: { translateY: '0%' } }, 45 | animol.Easing.easeOutCubic, 46 | ); 47 | } else if (messageKey) { 48 | const animation = animol.css( 49 | this.flashBox, 50 | 200, 51 | { opacity: 1 }, 52 | { opacity: 0 }, 53 | animol.Easing.easeInCubic, 54 | ); 55 | animation.promise.then(() => { 56 | this.flashBox.style.display = 'none'; 57 | }); 58 | } 59 | } 60 | 61 | render() { 62 | const { messageKey, onDismiss } = this.props; 63 | const Message = getMessageComponent(messageKey); 64 | const DisappearingMessage = timedCallback(onDismiss, 6000)(Message); 65 | return Message 66 | ? ( 67 | { this.flashBox = comp; }} 76 | opacity="0" 77 | > 78 | { this.flashMessage = comp; }} 80 | p={4} 81 | > 82 | 83 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | ) 108 | : null; 109 | } 110 | } 111 | 112 | FlashMessageComponent.propTypes = { 113 | messageKey: PropTypes.string, 114 | onDismiss: PropTypes.func.isRequired, 115 | flashMessageVisible: PropTypes.bool, 116 | }; 117 | 118 | FlashMessageComponent.defaultProps = { 119 | messageKey: null, 120 | flashMessageVisible: false, 121 | }; 122 | -------------------------------------------------------------------------------- /src/components/FlashMessage/FlashMessage.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { FlashMessageComponent } from './FlashMessage.component'; 4 | import { clearFlashMessage } from '../../common'; 5 | import { flashMessageSelectors } from './FlashMessage.selectors'; 6 | 7 | const mapDispatchToProps = { 8 | clearFlashMessage, 9 | }; 10 | 11 | const handlers = withHandlers({ 12 | onDismiss: (props) => () => { 13 | const { clearFlashMessage: connectedClearFlashMessage } = props; 14 | connectedClearFlashMessage(); 15 | }, 16 | }); 17 | 18 | export const FlashMessage = compose( 19 | connect(flashMessageSelectors, mapDispatchToProps), 20 | handlers, 21 | )(FlashMessageComponent); 22 | -------------------------------------------------------------------------------- /src/components/FlashMessage/FlashMessage.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { flashMessageKeySelector, flashMessageVisibleSelector } from '../../common'; 3 | 4 | export const flashMessageSelectors = createStructuredSelector({ 5 | messageKey: flashMessageKeySelector, 6 | flashMessageVisible: flashMessageVisibleSelector, 7 | }); 8 | -------------------------------------------------------------------------------- /src/components/FlashMessage/index.js: -------------------------------------------------------------------------------- 1 | export * from './FlashMessage.container'; 2 | -------------------------------------------------------------------------------- /src/components/GithubLink.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Image, 4 | HoverLink, 5 | Text, 6 | Box, 7 | } from './design-system'; 8 | import octocat from '../assets/images/github.svg'; 9 | 10 | export const GithubLink = () => ( 11 | 18 | 19 | 20 | Github 21 | 22 | Github Logo 23 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /src/components/InfoKnob.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Knob } from './Knob.component'; 4 | import { ControlLabel, Box } from './design-system'; 5 | 6 | export const InfoKnob = ({ 7 | label, 8 | minLabel, 9 | maxLabel, 10 | ...rest 11 | }) => ( 12 | 13 | 14 | {label} 15 | 16 | 17 | 18 | {minLabel} 19 | 20 | 21 | 22 | {maxLabel} 23 | 24 | 25 | 26 | ); 27 | 28 | InfoKnob.propTypes = { 29 | label: PropTypes.string.isRequired, 30 | minLabel: PropTypes.string.isRequired, 31 | maxLabel: PropTypes.string.isRequired, 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/InstallButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { HoverButton } from './design-system'; 5 | import { promptToInstall } from '../services/pwaInstall'; 6 | import { canInstallSelector } from '../common/window/window.selectors'; 7 | 8 | const isStandalone = window.matchMedia('(display-mode: standalone)').matches; 9 | 10 | const InstallButtonComponent = ({ canInstall }) => (canInstall && !isStandalone 11 | ? ( 12 | { 14 | promptToInstall(); 15 | }} 16 | width="auto" 17 | bg="blue" 18 | color="white" 19 | hoverColor="nearWhite" 20 | hoverBg="darkBlue" 21 | transitionSpeed="0.2s" 22 | p="0.6rem 1.2rem" 23 | > 24 | INSTALL 25 | 26 | ) 27 | : null); 28 | 29 | InstallButtonComponent.propTypes = { 30 | canInstall: PropTypes.bool.isRequired, 31 | }; 32 | 33 | const mapStateToProps = state => ({ 34 | canInstall: canInstallSelector(state), 35 | }); 36 | 37 | export const InstallButton = connect(mapStateToProps)(InstallButtonComponent); 38 | -------------------------------------------------------------------------------- /src/components/Knob.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | // import '../assets/js/webcomponents-lite'; 4 | import knobImage from '../assets/images/maschine-50.png'; 5 | import '../assets/js/webaudio-controls'; 6 | 7 | export class Knob extends React.Component { 8 | componentDidMount() { 9 | const { onChange } = this.props; 10 | this.knob.addEventListener('input', onChange); 11 | } 12 | 13 | componentDidUpdate() { 14 | const { value } = this.props; 15 | this.knob.setValue(value); 16 | } 17 | 18 | render() { 19 | const { size, value, ...rest } = this.props; 20 | return ( 21 | { 23 | this.knob = element; 24 | }} 25 | src={knobImage} 26 | sprites="50" 27 | min="0" 28 | max="100" 29 | width={size} 30 | height={size} 31 | value={value} 32 | {...rest} 33 | /> 34 | ); 35 | } 36 | } 37 | 38 | Knob.propTypes = { 39 | onChange: PropTypes.func.isRequired, 40 | size: PropTypes.number.isRequired, 41 | value: PropTypes.number.isRequired, 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/LabelBox.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import theme from '../styles/theme'; 4 | import { Box, Text } from './design-system'; 5 | 6 | const HoverBox = Box.extend` 7 | transition: border-color 0.2s; 8 | 9 | &:hover { 10 | ${({ hoverEffect }) => ( 11 | hoverEffect 12 | ? `border-color: ${theme.colors.gray};` 13 | : '')} 14 | } 15 | `; 16 | 17 | export const LabelBox = ({ label, children, hoverEffect }) => ( 18 | 28 | 41 | {label} 42 | 43 | {children} 44 | 45 | ); 46 | 47 | LabelBox.propTypes = { 48 | label: PropTypes.string.isRequired, 49 | children: PropTypes.node.isRequired, 50 | hoverEffect: PropTypes.bool, 51 | }; 52 | 53 | LabelBox.defaultProps = { 54 | hoverEffect: false, 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/Logo.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import theme from '../styles/theme'; 4 | 5 | export const Logo = ({ color, width }) => ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | Logo.propTypes = { 21 | width: PropTypes.string.isRequired, 22 | color: PropTypes.string.isRequired, 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/Marker/Marker.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Box } from '../design-system'; 4 | import { getCurrentBeat } from '../../services/audioContext'; 5 | import theme from '../../styles/theme'; 6 | 7 | const Container = Box.extend` 8 | overflow: hidden; 9 | `; 10 | 11 | export class MarkerComponent extends React.PureComponent { 12 | componentDidMount() { 13 | this.updateMarker.bind(this); 14 | this.updateMarker(); 15 | } 16 | 17 | updateMarker() { 18 | const { playing, startTime, bpm } = this.props; 19 | if (playing) { 20 | const currentBeat = getCurrentBeat(bpm, startTime); 21 | const progress = (currentBeat - 1) / 4 * 100; 22 | this.marker.style.width = `${progress}%`; 23 | } 24 | window.requestAnimationFrame(() => { 25 | this.updateMarker(); 26 | }); 27 | } 28 | 29 | render() { 30 | const { children } = this.props; 31 | return ( 32 | 33 |
{ this.marker = ref; }} 35 | style={{ 36 | height: '100%', 37 | backgroundColor: theme.colors.darkGray, 38 | position: 'absolute', 39 | width: 0, 40 | }} 41 | /> 42 | 43 | {children} 44 | 45 | 46 | ); 47 | } 48 | } 49 | 50 | MarkerComponent.defaultProps = { 51 | startTime: null, 52 | }; 53 | 54 | MarkerComponent.propTypes = { 55 | startTime: PropTypes.number, 56 | bpm: PropTypes.number.isRequired, 57 | playing: PropTypes.bool.isRequired, 58 | children: PropTypes.node.isRequired, 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/Marker/Marker.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose } from 'recompose'; 3 | import { MarkerComponent } from './Marker.component'; 4 | import { markerSelectors } from './Marker.selectors'; 5 | 6 | export const Marker = compose( 7 | connect(markerSelectors, null), 8 | )(MarkerComponent); 9 | -------------------------------------------------------------------------------- /src/components/Marker/Marker.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { 3 | bpmSelector, 4 | startTimeSelector, 5 | playingSelector, 6 | } from '../../common'; 7 | 8 | export const markerSelectors = createStructuredSelector({ 9 | bpm: bpmSelector, 10 | startTime: startTimeSelector, 11 | playing: playingSelector, 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/Marker/index.js: -------------------------------------------------------------------------------- 1 | export * from './Marker.container'; 2 | -------------------------------------------------------------------------------- /src/components/MasterControls/MasterControls.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '../design-system'; 3 | import { PlayButton } from '../PlayButton'; 4 | import { BPMInput } from '../BPMInput'; 5 | import { PresetSelector } from '../PresetSelector'; 6 | import { PatternSelector } from '../PatternSelector'; 7 | import { SwingControl } from '../SwingControl'; 8 | import { VolumeMeter } from '../VolumeMeter.component'; 9 | 10 | export const MasterControls = () => ( 11 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | -------------------------------------------------------------------------------- /src/components/MasterControls/index.js: -------------------------------------------------------------------------------- 1 | export * from './MasterControls.component'; 2 | -------------------------------------------------------------------------------- /src/components/Modal.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Box } from './design-system'; 4 | 5 | export const Modal = ({ children, show }) => ( 6 | 18 | {children} 19 | 20 | ); 21 | 22 | Modal.propTypes = { 23 | children: PropTypes.node.isRequired, 24 | show: PropTypes.bool.isRequired, 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/MuteSolo/MuteSolo.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Box, Button } from '../design-system'; 4 | 5 | const MSButton = Button.extend` 6 | width: 0.8rem; 7 | height: 0.8rem; 8 | padding: 0; 9 | border-radius: 100%; 10 | transition: all 0.1s; 11 | `; 12 | 13 | export const MuteSoloComponent = ({ onPressMuted, onPressSolo, channel }) => ( 14 | 22 | 26 | 30 | 31 | ); 32 | 33 | MuteSoloComponent.propTypes = { 34 | onPressMuted: PropTypes.func.isRequired, 35 | onPressSolo: PropTypes.func.isRequired, 36 | channel: PropTypes.shape({ 37 | solo: PropTypes.bool, 38 | muted: PropTypes.bool, 39 | }).isRequired, 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/MuteSolo/MuteSolo.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { MuteSoloComponent } from './MuteSolo.component'; 4 | import { 5 | setChannelMuted, 6 | setChannelSolo, 7 | } from '../../common'; 8 | 9 | const mapDispatchToProps = { 10 | setChannelMuted, 11 | setChannelSolo, 12 | }; 13 | 14 | const handlers = withHandlers({ 15 | onPressMuted: (props) => () => { 16 | const { channel, setChannelMuted: setChannelMutedConnected } = props; 17 | setChannelMutedConnected(channel.id, !channel.muted); 18 | }, 19 | onPressSolo: (props) => () => { 20 | const { channel, setChannelSolo: setChannelSoloConnected } = props; 21 | setChannelSoloConnected(channel.id, !channel.solo); 22 | }, 23 | }); 24 | 25 | export const MuteSolo = compose( 26 | connect(null, mapDispatchToProps), 27 | handlers, 28 | )(MuteSoloComponent); 29 | -------------------------------------------------------------------------------- /src/components/MuteSolo/index.js: -------------------------------------------------------------------------------- 1 | export * from './MuteSolo.container'; 2 | -------------------------------------------------------------------------------- /src/components/PatternSelector/PatternSelector.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as R from 'ramda'; 3 | import PropTypes from 'prop-types'; 4 | import { LabelBox } from '../LabelBox'; 5 | import { HoverButton } from '../design-system'; 6 | 7 | export const PatternSelectorComponent = ({ onSelectPattern, pattern }) => { 8 | const buttons = R.range(0, 8).map(buttonNumber => ( 9 | { 21 | onSelectPattern(buttonNumber); 22 | }} 23 | fontWeight="500" 24 | fontSize="0.7em" 25 | color="rgba(0,0,0,0.5)" 26 | activeBg="primaryDark" 27 | disabled={pattern === buttonNumber} 28 | aria-label={`Enable pattern ${buttonNumber}`} 29 | lineHeight="1.4em" 30 | > 31 | {buttonNumber + 1} 32 | 33 | )); 34 | 35 | return ( 36 | 37 | {buttons} 38 | 39 | ); 40 | }; 41 | 42 | PatternSelectorComponent.propTypes = { 43 | pattern: PropTypes.number.isRequired, 44 | onSelectPattern: PropTypes.func.isRequired, 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/PatternSelector/PatternSelector.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { PatternSelectorComponent } from './PatternSelector.component'; 4 | import { patternSelectorSelectors } from './PatternSelector.selectors'; 5 | import { setPattern } from '../../common'; 6 | 7 | const mapDispatchToProps = { 8 | setPattern, 9 | }; 10 | 11 | export const PatternSelector = compose( 12 | connect(patternSelectorSelectors, mapDispatchToProps), 13 | withHandlers({ 14 | onSelectPattern: (props) => (patternIndex) => { 15 | const { 16 | setPattern: connectedSetPattern, 17 | } = props; 18 | connectedSetPattern(patternIndex); 19 | }, 20 | }), 21 | )(PatternSelectorComponent); 22 | -------------------------------------------------------------------------------- /src/components/PatternSelector/PatternSelector.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { patternSelector } from '../../common'; 3 | 4 | export const patternSelectorSelectors = createStructuredSelector({ 5 | pattern: patternSelector, 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/PatternSelector/index.js: -------------------------------------------------------------------------------- 1 | export * from './PatternSelector.container'; 2 | -------------------------------------------------------------------------------- /src/components/PlayButton/PlayButton.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { FancyButton } from '../FancyButton.component'; 4 | import { Text } from '../design-system'; 5 | 6 | const StyledPlayButton = FancyButton.extend` 7 | margin-bottom: 1px; 8 | width: 8rem; 9 | display: flex; 10 | flex-direction: row; 11 | align-items: center; 12 | justify-content: center; 13 | `; 14 | 15 | export function PlayButtonComponent({ 16 | startPlaybackAndResume, 17 | stopPlayback, 18 | playing, 19 | }) { 20 | return playing ? ( 21 | 22 | 23 | Stop 24 | 25 | 32 | 33 | 34 | 35 | 36 | 37 | ) : ( 38 | 39 | 40 | PLAY 41 | 42 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | PlayButtonComponent.propTypes = { 61 | startPlaybackAndResume: PropTypes.func.isRequired, 62 | stopPlayback: PropTypes.func.isRequired, 63 | playing: PropTypes.bool.isRequired, 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/PlayButton/PlayButton.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose } from 'recompose'; 3 | import { PlayButtonComponent } from './PlayButton.component'; 4 | import { playButtonSelectors } from './PlayButton.selectors'; 5 | import { startPlaybackAndResume, stopPlayback } from '../../common'; 6 | 7 | const mapDispatchToProps = { 8 | startPlaybackAndResume, 9 | stopPlayback, 10 | }; 11 | 12 | export const PlayButton = compose( 13 | connect(playButtonSelectors, mapDispatchToProps), 14 | )(PlayButtonComponent); 15 | -------------------------------------------------------------------------------- /src/components/PlayButton/PlayButton.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { playingSelector } from '../../common'; 3 | 4 | export const playButtonSelectors = createStructuredSelector({ 5 | playing: playingSelector, 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/PlayButton/index.js: -------------------------------------------------------------------------------- 1 | export * from './PlayButton.container'; 2 | -------------------------------------------------------------------------------- /src/components/PresetDeleted.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | Box, 5 | Text, 6 | HoverButton, 7 | } from './design-system'; 8 | 9 | export const PresetDeleted = ({ onDismiss }) => ( 10 | 11 | 17 | User preset deleted. 18 | 19 | 20 | 29 | OK 30 | 31 | 32 | 33 | ); 34 | 35 | PresetDeleted.propTypes = { 36 | onDismiss: PropTypes.func.isRequired, 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/PresetSaved.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | Box, 5 | Text, 6 | HoverButton, 7 | } from './design-system'; 8 | 9 | export const PresetSaved = ({ onDismiss }) => ( 10 | 11 | 17 | User preset saved. 18 | 19 | 20 | 29 | OK 30 | 31 | 32 | 33 | ); 34 | 35 | PresetSaved.propTypes = { 36 | onDismiss: PropTypes.func.isRequired, 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/PresetSelector/PresetSelector.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Select from 'react-select'; 4 | import * as R from 'ramda'; 5 | import theme from '../../styles/theme'; 6 | import { Box, Text } from '../design-system'; 7 | import { SavePresetModal } from '../SavePresetModal'; 8 | 9 | export const PresetSelectorComponent = ({ 10 | onSelectPreset, 11 | presets, 12 | currentPreset, 13 | isEdited, 14 | userPresets, 15 | }) => { 16 | const defaultPresetOptions = presets.map(preset => ({ 17 | label: preset.name, 18 | value: preset, 19 | })); 20 | 21 | const userPresetOptions = userPresets.map(preset => ({ 22 | label: preset.name, 23 | value: preset, 24 | })); 25 | 26 | const groupedOptions = [ 27 | { 28 | label: 'Default', 29 | options: defaultPresetOptions, 30 | }, 31 | { 32 | label: 'User', 33 | options: userPresetOptions, 34 | }, 35 | { 36 | label: 'Memory', 37 | options: [ 38 | { 39 | label: 'Save As...', 40 | value: 'SAVE_PRESET_AS', 41 | }, 42 | { 43 | label: `Save “${currentPreset.name}”`, 44 | value: 'SAVE_PRESET', 45 | disabled: !isEdited || (defaultPresetOptions.find( 46 | option => option.label === currentPreset.name, 47 | ) !== undefined), 48 | }, 49 | { 50 | label: `Delete “${currentPreset.name}”`, 51 | value: 'DELETE_PRESET', 52 | disabled: (defaultPresetOptions.find( 53 | option => option.label === currentPreset.name, 54 | ) !== undefined), 55 | }, 56 | ], 57 | }, 58 | ]; 59 | 60 | let selectedOption = [...defaultPresetOptions, ...userPresetOptions].find( 61 | option => option.label === currentPreset.name, 62 | ); 63 | if (isEdited && selectedOption) { 64 | selectedOption = R.clone(selectedOption); 65 | selectedOption.label += ' *'; 66 | } 67 | 68 | return ( 69 | 70 | 84 | PRESETS 85 | 86 | { 72 | if (choice.value === 'CHOOSE_FILE') { 73 | openFileInput.current.click(); 74 | } else { 75 | onSelectSample(choice); 76 | } 77 | }} 78 | value={currentOption} 79 | isSearchable={false} 80 | styles={{ 81 | container: styles => ({ 82 | ...styles, 83 | height: '3rem', 84 | }), 85 | control: styles => ({ 86 | ...styles, 87 | backgroundColor: 'black', 88 | border: `2px solid ${theme.colors.steel}`, 89 | height: '100%', 90 | borderRadius: '0.5em', 91 | }), 92 | singleValue: styles => ({ 93 | ...styles, 94 | color: theme.colors.nearWhite, 95 | opacity: channel.sampleLoaded ? 1 : 0.3, 96 | }), 97 | menu: styles => ({ 98 | ...styles, 99 | fontSize: '0.8rem', 100 | width: '16rem', 101 | }), 102 | option: styles => ({ 103 | ...styles, 104 | paddingTop: '0.2em', 105 | paddingBottom: '0.2em', 106 | }), 107 | }} 108 | /> 109 | 116 | 117 | ); 118 | }; 119 | 120 | SampleSelectComponent.propTypes = { 121 | onSelectSample: PropTypes.func.isRequired, 122 | onSampleFileChosen: PropTypes.func.isRequired, 123 | channel: PropTypes.shape({ 124 | sample: PropTypes.string, 125 | sampleLoaded: PropTypes.bool, 126 | id: PropTypes.string.isRequired, 127 | }).isRequired, 128 | userSamples: PropTypes.arrayOf(PropTypes.string).isRequired, 129 | }; 130 | -------------------------------------------------------------------------------- /src/components/SampleSelect/SampleSelect.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { SampleSelectComponent } from './SampleSelect.component'; 4 | import { saveUserSample, loadAndSetChannelSample } from '../../common'; 5 | import { sampleSelectSelectors } from './SampleSelect.selectors'; 6 | 7 | const mapDispatchToProps = { 8 | loadAndSetChannelSample, 9 | saveUserSample, 10 | }; 11 | 12 | const handlers = withHandlers({ 13 | onSelectSample: (props) => (sample) => { 14 | const { loadAndSetChannelSample: connectedSetChannelSample, channel } = props; 15 | connectedSetChannelSample(channel.id, sample.value); 16 | }, 17 | onSampleFileChosen: (props) => (e) => { 18 | const { saveUserSample: connectedSaveUserSample, channel } = props; 19 | connectedSaveUserSample(channel.id, e.target.files); 20 | }, 21 | }); 22 | 23 | export const SampleSelect = compose( 24 | connect(sampleSelectSelectors, mapDispatchToProps), 25 | handlers, 26 | )(SampleSelectComponent); 27 | -------------------------------------------------------------------------------- /src/components/SampleSelect/SampleSelect.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { 3 | channelsSelector, 4 | notesSelector, 5 | patternSelector, 6 | selectedChannelSelector, 7 | userSamplesSelector, 8 | } from '../../common'; 9 | 10 | export const sampleSelectSelectors = createStructuredSelector({ 11 | channels: channelsSelector, 12 | notes: notesSelector, 13 | pattern: patternSelector, 14 | selectedChannelId: selectedChannelSelector, 15 | userSamples: userSamplesSelector, 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/SampleSelect/index.js: -------------------------------------------------------------------------------- 1 | export * from './SampleSelect.container'; 2 | -------------------------------------------------------------------------------- /src/components/SavePresetModal/SavePresetModal.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | TextInput, 5 | Text, 6 | Button, 7 | HoverButton, 8 | Form, 9 | } from '../design-system'; 10 | import { Modal } from '../Modal.component'; 11 | import theme from '../../styles/theme'; 12 | 13 | export class SavePresetModalComponent extends React.Component { 14 | componentDidUpdate() { 15 | const { presetPromptOpen } = this.props; 16 | if (presetPromptOpen) { 17 | this.nameInput.focus(); 18 | } 19 | } 20 | 21 | render() { 22 | const { 23 | presetPromptOpen, 24 | nameField, 25 | onChangeNameField, 26 | onClose, 27 | onSubmit, 28 | error, 29 | } = this.props; 30 | return ( 31 | 32 |
38 | 56 | 67 | SAVE 68 | 69 | 90 |
91 |
92 | ); 93 | } 94 | } 95 | 96 | SavePresetModalComponent.propTypes = { 97 | onClose: PropTypes.func.isRequired, 98 | presetPromptOpen: PropTypes.bool.isRequired, 99 | onChangeNameField: PropTypes.func.isRequired, 100 | onSubmit: PropTypes.func.isRequired, 101 | nameField: PropTypes.string.isRequired, 102 | error: PropTypes.string, 103 | }; 104 | 105 | SavePresetModalComponent.defaultProps = { 106 | error: null, 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/SavePresetModal/SavePresetModal.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers, withState } from 'recompose'; 3 | import { SavePresetModalComponent } from './SavePresetModal.component'; 4 | import { savePresetModalSelectors } from './SavePresetModal.selectors'; 5 | import { 6 | setPresetPrompt, 7 | doSavePresetAs, 8 | } from '../../common'; 9 | import defaultPresets from '../../presets'; 10 | 11 | const mapDispatchToProps = { 12 | setPresetPrompt, 13 | doSavePresetAs, 14 | }; 15 | 16 | const isNameUnique = (proposedName, userPresets) => [...defaultPresets, ...userPresets].find( 17 | (preset) => preset.name === proposedName, 18 | ) === undefined; 19 | 20 | const handlers = { 21 | onChangeNameField: ({ updateNameField, setError }) => (event) => { 22 | if (event.target.value.length < 32) { 23 | updateNameField(event.target.value); 24 | setError(null); 25 | } 26 | }, 27 | onClose: (props) => () => { 28 | const { 29 | setPresetPrompt: connectedSetPresetPrompt, 30 | updateNameField, 31 | } = props; 32 | updateNameField(''); 33 | connectedSetPresetPrompt(false); 34 | }, 35 | onSubmit: (props) => (event) => { 36 | event.preventDefault(); 37 | const { 38 | setPresetPrompt: connectedSetPresetPrompt, 39 | doSavePresetAs: connectedDoSavePresetAs, 40 | updateNameField, 41 | nameField, 42 | setError, 43 | userPresets, 44 | } = props; 45 | 46 | if (nameField.length < 1) { 47 | setError('Min length 1'); 48 | } else if (nameField.length > 32) { 49 | setError('Max length 32'); 50 | } else if (!isNameUnique(nameField, userPresets)) { 51 | setError('Must be unique'); 52 | } else { 53 | connectedSetPresetPrompt(false); 54 | connectedDoSavePresetAs(nameField); 55 | updateNameField(''); 56 | setError(null); 57 | } 58 | }, 59 | }; 60 | 61 | export const SavePresetModal = compose( 62 | connect(savePresetModalSelectors, mapDispatchToProps), 63 | withState('nameField', 'updateNameField', ''), 64 | withState('error', 'setError', null), 65 | withHandlers(handlers), 66 | )(SavePresetModalComponent); 67 | -------------------------------------------------------------------------------- /src/components/SavePresetModal/SavePresetModal.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { 3 | presetPromptOpenSelector, 4 | currentStateSelector, 5 | userPresetsSelector, 6 | } from '../../common'; 7 | 8 | export const savePresetModalSelectors = createStructuredSelector({ 9 | userPresets: userPresetsSelector, 10 | presetPromptOpen: presetPromptOpenSelector, 11 | currentState: currentStateSelector, 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/SavePresetModal/index.js: -------------------------------------------------------------------------------- 1 | export * from './SavePresetModal.container'; 2 | -------------------------------------------------------------------------------- /src/components/SwingControl/SwingControl.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Knob } from '../Knob.component'; 4 | import { Box, Text } from '../design-system'; 5 | 6 | const LabelText = Text.extend` 7 | transform: translateY(-0.3em); 8 | `; 9 | 10 | export const SwingControlComponent = ({ 11 | onSetSwing, 12 | swing, 13 | }) => ( 14 | 15 | 24 | SWING 25 | 26 | 27 | 28 | ); 29 | 30 | SwingControlComponent.propTypes = { 31 | onSetSwing: PropTypes.func.isRequired, 32 | swing: PropTypes.number.isRequired, 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/SwingControl/SwingControl.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose, withHandlers } from 'recompose'; 3 | import { SwingControlComponent } from './SwingControl.component'; 4 | import { swingControlSelectors } from './SwingControl.selectors'; 5 | import { setSwing } from '../../common'; 6 | 7 | const mapDispatchToProps = { setSwing }; 8 | 9 | const handlers = withHandlers({ 10 | onSetSwing: (props) => (e) => { 11 | const { setSwing: setSwingConnected } = props; 12 | setSwingConnected(e.target.value); 13 | }, 14 | }); 15 | 16 | export const SwingControl = compose( 17 | connect(swingControlSelectors, mapDispatchToProps), 18 | handlers, 19 | )(SwingControlComponent); 20 | -------------------------------------------------------------------------------- /src/components/SwingControl/SwingControl.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { swingSelector } from '../../common'; 3 | 4 | export const swingControlSelectors = createStructuredSelector({ 5 | swing: swingSelector, 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/SwingControl/index.js: -------------------------------------------------------------------------------- 1 | export * from './SwingControl.container'; 2 | -------------------------------------------------------------------------------- /src/components/Toggles/Toggle.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import * as ss from 'styled-system'; 5 | import { Box } from '../design-system'; 6 | import theme from '../../styles/theme'; 7 | 8 | const gradient = `linear-gradient(180deg, ${theme.colors.primary} 0%, ${theme.colors.secondary} 100%);`; 9 | 10 | const BeatButton = styled.button` 11 | ${ss.color} 12 | ${ss.space} 13 | ${ss.width} 14 | ${ss.height} 15 | ${ss.borders} 16 | ${ss.borderRadius} 17 | padding: 0; 18 | outline: none; 19 | transition: background-color 0.1s; 20 | position: relative; 21 | background: ${({ isActive }) => (isActive 22 | ? gradient 23 | : theme.colors.darkGray)} 24 | 25 | &:focus { 26 | box-shadow: 0 0 5px 5px rgba(100, 180, 255, 0.5); 27 | } 28 | `; 29 | 30 | BeatButton.defaultProps = { 31 | border: 'none', 32 | borderRadius: '100%', 33 | }; 34 | 35 | export const Toggle = ({ isActive, onClick, beat }) => ( 36 | 45 | 58 | 59 | ); 60 | 61 | Toggle.propTypes = { 62 | isActive: PropTypes.bool.isRequired, 63 | onClick: PropTypes.func.isRequired, 64 | beat: PropTypes.number.isRequired, 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/Toggles/ToggleGroup.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Box } from '../design-system'; 4 | 5 | export const ToggleGroup = ({ children }) => ( 6 | 16 | {children} 17 | 18 | ); 19 | 20 | ToggleGroup.propTypes = { 21 | children: PropTypes.node.isRequired, 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/Toggles/Toggles.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as R from 'ramda'; 3 | import PropTypes from 'prop-types'; 4 | import { Box } from '../design-system'; 5 | import { Toggle } from './Toggle.component'; 6 | import { ToggleGroup } from './ToggleGroup.component'; 7 | 8 | const isActive = (notes, beat) => notes.find(note => note.beat === beat) !== undefined; 9 | 10 | const sixteenthNotes = R.range(0, 16); 11 | 12 | export class TogglesComponent extends React.PureComponent { 13 | render() { 14 | const { 15 | notes, 16 | channelID, 17 | toggleNote, 18 | bpm, 19 | playing, 20 | pattern, 21 | } = this.props; 22 | const toggles = sixteenthNotes.map((index) => { 23 | const beat = 1 + index / 4; 24 | return ( 25 | { 29 | toggleNote(channelID, pattern, beat); 30 | }} 31 | bpm={bpm} 32 | playing={playing} 33 | beat={beat} 34 | /> 35 | ); 36 | }); 37 | 38 | const toggleGroups = R.splitEvery(4, toggles); 39 | 40 | return ( 41 | 42 | {toggleGroups.map((toggleGroup, i) => ( 43 | 44 | {toggleGroup} 45 | 46 | ))} 47 | 48 | ); 49 | } 50 | } 51 | 52 | TogglesComponent.propTypes = { 53 | notes: PropTypes.arrayOf(PropTypes.object).isRequired, 54 | channelID: PropTypes.string.isRequired, 55 | toggleNote: PropTypes.func.isRequired, 56 | bpm: PropTypes.number.isRequired, 57 | playing: PropTypes.bool.isRequired, 58 | pattern: PropTypes.number.isRequired, 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/Toggles/Toggles.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose } from 'recompose'; 3 | import { TogglesComponent } from './Toggles.component'; 4 | import { toggleNote } from '../../common'; 5 | import { togglesSelectors } from './Toggles.selectors'; 6 | 7 | const mapDispatchToProps = { 8 | toggleNote, 9 | }; 10 | 11 | export const Toggles = compose( 12 | connect(togglesSelectors, mapDispatchToProps), 13 | )(TogglesComponent); 14 | -------------------------------------------------------------------------------- /src/components/Toggles/Toggles.selectors.js: -------------------------------------------------------------------------------- 1 | import { createStructuredSelector } from 'reselect'; 2 | import { 3 | bpmSelector, 4 | playingSelector, 5 | patternSelector, 6 | } from '../../common'; 7 | 8 | export const togglesSelectors = createStructuredSelector({ 9 | bpm: bpmSelector, 10 | playing: playingSelector, 11 | pattern: patternSelector, 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/Toggles/index.js: -------------------------------------------------------------------------------- 1 | export * from './Toggles.container'; 2 | export * from './Toggle.component'; 3 | -------------------------------------------------------------------------------- /src/components/VolumeMeter.component.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { Box } from './design-system'; 3 | import { getVolume } from '../services/audioAnalyzer'; 4 | 5 | const DECAY = 0.95; 6 | 7 | let prevVal = 0; 8 | 9 | export const VolumeMeter = () => { 10 | const ref = useRef(); 11 | 12 | function updateVolumeMeter() { 13 | if (ref.current) { 14 | const currentVolume = getVolume(); 15 | 16 | // Decay slowly 17 | const isIncreasing = currentVolume > prevVal; 18 | let meteredVolume; 19 | if (isIncreasing) { 20 | meteredVolume = currentVolume; 21 | } else { 22 | meteredVolume = currentVolume + (prevVal - currentVolume) * DECAY; 23 | } 24 | prevVal = meteredVolume; 25 | 26 | // Convert to CSS 27 | const percent = Math.min(Math.round(meteredVolume * 75), 50); 28 | const color = `hsla(16, 100%, ${percent}%, 1)`; 29 | ref.current.style.backgroundColor = color; 30 | window.requestAnimationFrame(() => { 31 | updateVolumeMeter(); 32 | }); 33 | } 34 | } 35 | 36 | useEffect(() => { 37 | window.requestAnimationFrame(() => { 38 | updateVolumeMeter(); 39 | }); 40 | }); 41 | 42 | return ( 43 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/design-system/Box.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | export const Box = styled.div` 5 | ${ss.color} 6 | ${ss.space} 7 | ${ss.borders} 8 | ${ss.borderColor} 9 | ${ss.borderRadius} 10 | ${ss.width} 11 | ${ss.height} 12 | ${ss.flex} 13 | ${ss.flexDirection} 14 | ${ss.display} 15 | ${ss.justifyContent} 16 | ${ss.opacity} 17 | ${ss.position} 18 | ${ss.alignItems} 19 | ${ss.left} 20 | ${ss.top} 21 | ${ss.bottom} 22 | ${ss.right} 23 | ${ss.zIndex} 24 | ${ss.boxShadow} 25 | ${ss.maxWidth} 26 | ${ss.minWidth} 27 | ${ss.maxHeight} 28 | ${ss.minHeight} 29 | box-sizing: border-box; 30 | `; 31 | -------------------------------------------------------------------------------- /src/components/design-system/Button.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | const Button = styled.button` 5 | ${ss.color} 6 | ${ss.width} 7 | ${ss.height} 8 | ${ss.space} 9 | ${ss.borders} 10 | ${ss.borderRadius} 11 | ${ss.fontWeight} 12 | ${ss.fontSize} 13 | ${ss.alignSelf} 14 | ${ss.width} 15 | ${ss.height} 16 | ${ss.flex} 17 | ${ss.position} 18 | ${ss.left} 19 | ${ss.top} 20 | ${ss.bottom} 21 | ${ss.right} 22 | ${ss.display} 23 | ${ss.alignItems} 24 | ${ss.justifyContent} 25 | ${ss.opacity} 26 | ${ss.minWidth} 27 | outline: ${({ outline }) => outline}; 28 | touch-action: manipulation; 29 | `; 30 | 31 | Button.defaultProps = { 32 | border: 'none', 33 | fontWeight: 'bold', 34 | borderRadius: '0.25rem', 35 | variant: 'primary', 36 | width: 5, 37 | }; 38 | 39 | export { Button }; 40 | -------------------------------------------------------------------------------- /src/components/design-system/ControlLabel.js: -------------------------------------------------------------------------------- 1 | import { Text } from './Text'; 2 | 3 | export const ControlLabel = Text.extend` 4 | font-size: 0.7em; 5 | text-transform: uppercase; 6 | color: white; 7 | `; 8 | -------------------------------------------------------------------------------- /src/components/design-system/Form.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | export const Form = styled.form` 5 | ${ss.color} 6 | ${ss.space} 7 | ${ss.borders} 8 | ${ss.borderColor} 9 | ${ss.borderRadius} 10 | ${ss.width} 11 | ${ss.height} 12 | ${ss.flex} 13 | ${ss.flexDirection} 14 | ${ss.display} 15 | ${ss.justifyContent} 16 | ${ss.opacity} 17 | ${ss.position} 18 | ${ss.alignItems} 19 | ${ss.left} 20 | ${ss.top} 21 | ${ss.bottom} 22 | ${ss.right} 23 | ${ss.zIndex} 24 | box-sizing: border-box; 25 | `; 26 | -------------------------------------------------------------------------------- /src/components/design-system/Heading.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | export const Heading = styled.h1` 5 | ${ss.color} 6 | ${ss.fontSize} 7 | ${ss.fontWeight} 8 | ${ss.space} 9 | ${ss.fontFamily} 10 | `; 11 | -------------------------------------------------------------------------------- /src/components/design-system/HoverButton.js: -------------------------------------------------------------------------------- 1 | import { Button } from './Button'; 2 | 3 | const getColor = (theme, color) => theme.colors[color] || color; 4 | 5 | export const HoverButton = Button.extend` 6 | transition: all ${({ transitionSpeed }) => transitionSpeed} 7 | 8 | &:hover { 9 | color: ${({ theme, hoverColor }) => getColor(theme, hoverColor)}; 10 | background-color: ${({ theme, hoverBg }) => getColor(theme, hoverBg)}; 11 | opacity: ${({ hoverOpacity }) => hoverOpacity}; 12 | } 13 | 14 | &:active { 15 | background-color: ${({ theme, activeBg }) => getColor(theme, activeBg)}; 16 | opacity: ${({ hoverOpacity }) => hoverOpacity}; 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /src/components/design-system/HoverLink.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | export const HoverLink = styled.a` 5 | ${ss.opacity} 6 | text-decoration: none; 7 | display: inline-block; 8 | transition: all ${({ transitionSpeed }) => transitionSpeed}; 9 | 10 | &:hover, &:focus { 11 | opacity: ${({ hoverOpacity }) => hoverOpacity}; 12 | } 13 | 14 | &:active { 15 | opacity: ${({ activeOpacity }) => activeOpacity}; 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /src/components/design-system/Image.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | export const Image = styled.img` 5 | ${ss.color} 6 | ${ss.space} 7 | ${ss.width} 8 | ${ss.height} 9 | ${ss.flex} 10 | ${ss.display} 11 | ${ss.justifyContent} 12 | ${ss.opacity} 13 | ${ss.position} 14 | user-select: ${({ userSelect }) => userSelect}; 15 | `; 16 | -------------------------------------------------------------------------------- /src/components/design-system/Label.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | const Label = styled.label` 5 | ${ss.color} 6 | ${ss.fontWeight} 7 | ${ss.fontSize} 8 | ${ss.space} 9 | ${ss.position} 10 | ${ss.left} 11 | ${ss.top} 12 | ${ss.letterSpacing} 13 | ${ss.height} 14 | display: block; 15 | line-height: 1em; 16 | `; 17 | 18 | Label.defaultProps = { 19 | m: 0, 20 | p: 0, 21 | }; 22 | 23 | export { Label }; 24 | -------------------------------------------------------------------------------- /src/components/design-system/Line.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | const Line = styled.div` 5 | ${ss.color} 6 | ${ss.space} 7 | ${ss.borderRadius} 8 | ${ss.width} 9 | ${ss.height} 10 | ${ss.flex} 11 | ${ss.display} 12 | ${ss.opacity} 13 | ${ss.position} 14 | ${ss.alignItems} 15 | `; 16 | 17 | Line.defaultProps = { 18 | bg: 'nearWhite', 19 | width: '100%', 20 | display: 'block', 21 | height: 1, 22 | }; 23 | 24 | export { Line }; 25 | -------------------------------------------------------------------------------- /src/components/design-system/Text.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | const Text = styled.span` 5 | ${ss.color} 6 | ${ss.fontWeight} 7 | ${ss.fontSize} 8 | ${ss.space} 9 | ${ss.position} 10 | ${ss.left} 11 | ${ss.top} 12 | ${ss.letterSpacing} 13 | ${ss.height} 14 | ${ss.zIndex} 15 | ${ss.borderRadius} 16 | ${ss.textAlign} 17 | ${ss.opacity} 18 | ${ss.lineHeight} 19 | ${ss.display} 20 | ${ss.verticalAlign} 21 | user-select: ${({ userSelect }) => userSelect}; 22 | `; 23 | 24 | Text.defaultProps = { 25 | m: 0, 26 | p: 0, 27 | lineHeight: '1em', 28 | display: 'block', 29 | }; 30 | 31 | export { Text }; 32 | -------------------------------------------------------------------------------- /src/components/design-system/TextInput.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as ss from 'styled-system'; 3 | 4 | const TextInput = styled.input` 5 | ${ss.color} 6 | ${ss.fontWeight} 7 | ${ss.fontSize} 8 | ${ss.space} 9 | ${ss.position} 10 | ${ss.zIndex} 11 | ${ss.width} 12 | ${ss.height} 13 | ${ss.boxShadow} 14 | display: block; 15 | border: none; 16 | `; 17 | 18 | TextInput.defaultProps = { 19 | m: 0, 20 | p: 0, 21 | }; 22 | 23 | export { TextInput }; 24 | -------------------------------------------------------------------------------- /src/components/design-system/index.js: -------------------------------------------------------------------------------- 1 | export * from './Heading'; 2 | export * from './Box'; 3 | export * from './Button'; 4 | export * from './HoverButton'; 5 | export * from './Text'; 6 | export * from './Line'; 7 | export * from './Image'; 8 | export * from './TextInput'; 9 | export * from './Form'; 10 | export * from './HoverLink'; 11 | export * from './Label'; 12 | export * from './ControlLabel'; 13 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export * from './design-system'; 2 | export * from './ChannelList'; 3 | export * from './Channel'; 4 | export * from './ChannelHeader'; 5 | export * from './Toggles'; 6 | export * from './PlayButton'; 7 | export * from './BPMInput'; 8 | export * from './Marker'; 9 | export * from './FancyButton.component'; 10 | export * from './AddChannelButton'; 11 | export * from './PresetSelector'; 12 | export * from './MasterControls'; 13 | export * from './Modal.component'; 14 | export * from './Logo.component'; 15 | export * from './GithubLink.component'; 16 | export * from './ChannelControls'; 17 | export * from './FlashMessage'; 18 | export * from './SwingControl'; 19 | export * from './Branding'; 20 | export * from './InstallButton'; 21 | -------------------------------------------------------------------------------- /src/components/timedCallback.hoc.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const timedCallback = (callback, delay) => WrappedEl => class extends React.Component { 4 | constructor() { 5 | super(); 6 | this.timer = setTimeout(callback, delay); 7 | } 8 | 9 | componentWillUnmount() { 10 | clearTimeout(this.timer); 11 | } 12 | 13 | render() { 14 | return ; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | WDS-1: Web Drum Sequencer 17 | 18 | 19 |
20 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { PersistGate } from 'redux-persist/integration/react'; 5 | import App from './components/App'; 6 | import { initializeAudio } from './services/audioLoop'; 7 | import { configureStore } from './store'; 8 | import { loadSampleStatefully } from './common'; 9 | import { startAnimations } from './services/animations'; 10 | import { initializePwaInstall } from './services/pwaInstall'; 11 | import { initializeDB } from './services/database'; 12 | 13 | const { store, persistor } = configureStore(); 14 | 15 | /** 16 | * Watch for user going online, and try to load any samples 17 | * that haven't been loaded (e.g. because user was offline) 18 | */ 19 | window.addEventListener('online', () => { 20 | const { channels } = store.getState(); 21 | channels.forEach((channel) => { 22 | if (!channel.sampleLoaded) { 23 | loadSampleStatefully(store.dispatch, channel); 24 | } 25 | }); 26 | }); 27 | 28 | // Register service worker 29 | if (import.meta.env.DEV && 'serviceWorker' in navigator) { 30 | try { 31 | navigator.serviceWorker.register('./sw.js', { 32 | scope: '/', 33 | }); 34 | } catch (error) { 35 | console.error(`Registration failed with ${error}`); 36 | } 37 | } 38 | 39 | ReactDOM.render( 40 | 41 | 42 | 43 | 44 | , 45 | document.getElementById('root'), 46 | ); 47 | 48 | initializeAudio(store); 49 | 50 | startAnimations(store); 51 | 52 | initializePwaInstall(store); 53 | 54 | initializeDB().then(() => { 55 | const { channels } = store.getState(); 56 | // Load up all the initial samples 57 | channels.forEach((channel) => { 58 | loadSampleStatefully(store.dispatch, channel); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/presets/__mocks__/index.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | name: 'Roland 707', 4 | bpm: 130, 5 | channels: [ 6 | { 7 | id: 'channel-id', 8 | sample: 'channel-sample', 9 | gain: 1, 10 | }, 11 | ], 12 | notes: [], 13 | }, 14 | { 15 | name: 'Roland 707', 16 | bpm: 130, 17 | channels: [ 18 | { 19 | id: 'channel-id', 20 | sample: 'channel-sample', 21 | gain: 1, 22 | }, 23 | ], 24 | notes: [], 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /src/presets/empty.js: -------------------------------------------------------------------------------- 1 | import sampleList from '../samples.config'; 2 | 3 | export const EMPTY_NOTE_ROW = [[], [], [], [], [], [], [], []]; 4 | 5 | export default { 6 | name: 'Empty', 7 | bpm: 80, 8 | swing: 0, 9 | channels: [ 10 | { 11 | id: 'empty_channel', 12 | sample: sampleList[0].url, 13 | gain: 1, 14 | }, 15 | ], 16 | notes: { 17 | empty_channel: EMPTY_NOTE_ROW, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/presets/index.js: -------------------------------------------------------------------------------- 1 | import empty from './empty'; 2 | import hipHop from './hip-hop'; 3 | import lDrum from './ldrum'; 4 | import sevenohseven from './707'; 5 | import eightoheight from './808'; 6 | import ace from './ace'; 7 | 8 | export default [empty, eightoheight, ace, lDrum, hipHop, sevenohseven]; 9 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { 3 | channelsReducer, 4 | playbackSessionReducer, 5 | tempoReducer, 6 | masterReducer, 7 | notesReducer, 8 | presetsReducer, 9 | windowReducer, 10 | userSamplesReducer, 11 | } from './common'; 12 | 13 | export default combineReducers({ 14 | channels: channelsReducer, 15 | playbackSession: playbackSessionReducer, 16 | tempo: tempoReducer, 17 | master: masterReducer, 18 | notes: notesReducer, 19 | presets: presetsReducer, 20 | window: windowReducer, 21 | userSamples: userSamplesReducer, 22 | }); 23 | -------------------------------------------------------------------------------- /src/services/__mocks__/audioContext.js: -------------------------------------------------------------------------------- 1 | export const getAudioContext = () => ({ 2 | currentTime: 1, 3 | }); 4 | 5 | export const playNote = () => new AudioBufferSourceNode(); 6 | -------------------------------------------------------------------------------- /src/services/__mocks__/audioRouter.js: -------------------------------------------------------------------------------- 1 | export const playNote = () => {}; 2 | -------------------------------------------------------------------------------- /src/services/__mocks__/featureChecks.js: -------------------------------------------------------------------------------- 1 | export const detuneSupported = true; 2 | -------------------------------------------------------------------------------- /src/services/animations.js: -------------------------------------------------------------------------------- 1 | import { getCurrentBeat } from './audioContext'; 2 | import { swing } from './swing'; 3 | 4 | const draw = (store) => { 5 | // Get some data from redux store 6 | const state = store.getState(); 7 | const { bpm, swing: swingAmount } = state.tempo; 8 | const { playing, startTime } = state.playbackSession; 9 | const currentBeat = getCurrentBeat(bpm, startTime); 10 | 11 | // Grab all the toggles and animate them 12 | const toggles = document.getElementsByClassName('wds-beat-marker'); 13 | for (let i = 0; i < toggles.length; i += 1) { 14 | const toggle = toggles[i]; 15 | const { beat, active } = toggle.dataset; 16 | const beatNum = parseFloat(beat); 17 | const swingBeat = swing(beatNum, swingAmount); 18 | const isActive = active === 'true'; 19 | if ( 20 | playing && 21 | isActive && 22 | currentBeat - swingBeat < 0.25 && 23 | currentBeat - swingBeat > 0 24 | ) { 25 | toggle.style.transition = 'all 0s'; 26 | toggle.style.opacity = '0.8'; 27 | toggle.style.transform = 'scale(1.3)'; 28 | } else { 29 | toggle.style.transition = `all ${120 / bpm}s`; 30 | toggle.style.opacity = 0; 31 | toggle.style.transform = 'scale(1)'; 32 | } 33 | } 34 | 35 | window.requestAnimationFrame(() => { 36 | draw(store); 37 | }); 38 | }; 39 | 40 | export const startAnimations = (store) => { 41 | window.requestAnimationFrame(() => { 42 | draw(store); 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/services/audioAnalyzer.js: -------------------------------------------------------------------------------- 1 | import { analyserNode } from './audioRouter'; 2 | 3 | const pcmData = new Float32Array(analyserNode.fftSize); 4 | 5 | export function getVolume() { 6 | analyserNode.getFloatTimeDomainData(pcmData); 7 | let peak = 0; 8 | for (const amplitude of pcmData) { 9 | if (amplitude > peak) { 10 | peak = amplitude; 11 | } 12 | } 13 | return peak; 14 | } 15 | -------------------------------------------------------------------------------- /src/services/audioContext.js: -------------------------------------------------------------------------------- 1 | let audioCtx; 2 | 3 | export const getAudioContext = () => { 4 | if (typeof audioCtx === 'undefined') { 5 | audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 6 | } 7 | return audioCtx; 8 | }; 9 | 10 | export const getCurrentBeat = (bpm, startTime, currentTime) => { 11 | const safeCurrentTime = 12 | typeof currentTime === 'undefined' ? audioCtx.currentTime : currentTime; 13 | 14 | const beatLengthSeconds = bpm / 60; 15 | const currentBeat = (safeCurrentTime - startTime) * beatLengthSeconds; 16 | return (currentBeat % 4) + 1; 17 | }; 18 | -------------------------------------------------------------------------------- /src/services/audioContext.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getCurrentBeat, 3 | } from './audioContext'; 4 | 5 | jest.mock('./featureChecks'); 6 | 7 | describe('getCurrentBeat', () => { 8 | test('should return beat 1 if startTime is the same as currentTime', () => { 9 | expect(getCurrentBeat(60, 1, 1)).toBe(1); 10 | }); 11 | 12 | test('should return beat 2 if currentTime is one second ahead of startTime and bpm = 60', () => { 13 | expect(getCurrentBeat(60, 0, 1)).toBe(2); 14 | }); 15 | 16 | test('should return beat 3 if currentTime is one second ahead of startTime and bpm = 120', () => { 17 | expect(getCurrentBeat(120, 0, 1)).toBe(3); 18 | }); 19 | 20 | test('should return beat 2.5 if currentTime is one second ahead of startTime and bpm = 90', () => { 21 | expect(getCurrentBeat(90, 0, 1)).toBe(2.5); 22 | }); 23 | 24 | test('should return beat 1 if currentTime is one second ahead of startTime and bpm = 240', () => { 25 | expect(getCurrentBeat(240, 0, 1)).toBe(1); 26 | }); 27 | 28 | test('should return beat 2 if currentTime is one second ahead of startTime and bpm = 300', () => { 29 | expect(getCurrentBeat(300, 0, 1)).toBe(2); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/services/audioEngine.config.js: -------------------------------------------------------------------------------- 1 | export const LOOKAHEAD = 0.2; // seconds 2 | export const INTERVAL = 50; // milliseconds 3 | -------------------------------------------------------------------------------- /src/services/audioLoop.js: -------------------------------------------------------------------------------- 1 | import { getAudioContext, getCurrentBeat } from './audioContext'; 2 | import { updateChannelNodes } from './audioRouter'; 3 | import { scheduleNotes } from './audioScheduler'; 4 | import { setStartTime } from '../common'; 5 | import { INTERVAL } from './audioEngine.config'; 6 | 7 | export const initializeAudio = (store) => { 8 | const audioCtx = getAudioContext(); // Start the clock 9 | setInterval(() => { 10 | const { playbackSession, tempo, channels, notes, master } = 11 | store.getState(); 12 | 13 | updateChannelNodes(channels); 14 | 15 | if (playbackSession.playing) { 16 | let sT = playbackSession.startTime; 17 | // Loop if we reached the end of the bar 18 | const barLength = (4 * 60) / tempo.bpm; 19 | if (audioCtx.currentTime > playbackSession.startTime + barLength) { 20 | store.dispatch(setStartTime(playbackSession.startTime + barLength)); 21 | sT = playbackSession.startTime + barLength; 22 | } 23 | 24 | scheduleNotes({ 25 | notes, 26 | channels, 27 | startTime: sT, 28 | tempo, 29 | pattern: master.pattern, 30 | currentBeat: getCurrentBeat(tempo.bpm, playbackSession.startTime), 31 | }); 32 | } 33 | }, INTERVAL); 34 | }; 35 | -------------------------------------------------------------------------------- /src/services/audioRouter.js: -------------------------------------------------------------------------------- 1 | import { detuneSupported, stereoPannerSupported } from './featureChecks'; 2 | import { getAudioContext } from './audioContext'; 3 | import { loadImpulseResponse } from './reverb'; 4 | import impulseResponse from '../assets/impulse-responses/ruby-room.mp3'; 5 | 6 | const audioCtx = getAudioContext(); 7 | 8 | const masterOut = audioCtx.createGain(); 9 | masterOut.connect(audioCtx.destination); 10 | 11 | export const analyserNode = audioCtx.createAnalyser(); 12 | analyserNode.smoothingTimeConstant = 0; 13 | masterOut.connect(analyserNode); 14 | 15 | const reverbNode = audioCtx.createConvolver(); 16 | reverbNode.connect(masterOut); 17 | loadImpulseResponse(impulseResponse).then((impulseResponseArrayBuffer) => { 18 | reverbNode.buffer = impulseResponseArrayBuffer; 19 | }); 20 | 21 | /** 22 | * The channel routing is: 23 | * 24 | * Drum Sample 25 | * -> Gain node 26 | * -> Reverb node 27 | * -> Master out 28 | * -> Analyser node 29 | * -> Pan node 30 | * -> Master out 31 | * -> Analyser node 32 | */ 33 | 34 | const channelGainNodes = {}; 35 | const channelPanNodes = {}; 36 | const channelReverbNodes = {}; 37 | 38 | const calculateGain = (channel, soloEnabled) => { 39 | if (channel.muted) { 40 | return 0; 41 | } 42 | if (soloEnabled && !channel.solo) { 43 | return 0; 44 | } 45 | if (channel.gain === 'undefined') { 46 | return 1; 47 | } 48 | return channel.gain; 49 | }; 50 | 51 | const updateGainNode = (channel, soloEnabled) => { 52 | if (typeof channelGainNodes[channel.id] === 'undefined') { 53 | // Set up a GainNode to control note volume 54 | channelGainNodes[channel.id] = audioCtx.createGain(); 55 | channelGainNodes[channel.id].connect(channelPanNodes[channel.id]); 56 | 57 | // Also route to reverb 58 | channelGainNodes[channel.id].connect(channelReverbNodes[channel.id]); 59 | } 60 | channelGainNodes[channel.id].gain.setValueAtTime( 61 | calculateGain(channel, soloEnabled), 62 | audioCtx.currentTime, 63 | ); 64 | }; 65 | 66 | const updatePanNode = (channel) => { 67 | if (stereoPannerSupported) { 68 | if (typeof channelPanNodes[channel.id] === 'undefined') { 69 | channelPanNodes[channel.id] = audioCtx.createStereoPanner(); 70 | channelPanNodes[channel.id].connect(masterOut); 71 | } 72 | channelPanNodes[channel.id].pan.setValueAtTime( 73 | typeof channel.pan === 'undefined' ? 0 : channel.pan, 74 | audioCtx.currentTime, 75 | ); 76 | } else { 77 | if (typeof channelPanNodes[channel.id] === 'undefined') { 78 | channelPanNodes[channel.id] = audioCtx.createPanner(); 79 | channelPanNodes[channel.id].panningModel = 'equalpower'; 80 | channelPanNodes[channel.id].connect(masterOut); 81 | } 82 | const pan = typeof channel.pan === 'undefined' ? 0 : channel.pan; 83 | channelPanNodes[channel.id].setPosition(pan, 0, 1 - Math.abs(pan)); 84 | } 85 | }; 86 | 87 | const updateReverbNode = (channel) => { 88 | if (typeof channelReverbNodes[channel.id] === 'undefined') { 89 | // Set up a GainNode to control the send volume to reverb 90 | channelReverbNodes[channel.id] = audioCtx.createGain(); 91 | channelReverbNodes[channel.id].connect(reverbNode); 92 | } 93 | channelReverbNodes[channel.id].gain.setValueAtTime( 94 | typeof channel.reverb === 'undefined' ? 0 : channel.reverb, 95 | audioCtx.currentTime, 96 | ); 97 | }; 98 | 99 | const checkSoloEnabled = (channels) => { 100 | for (let i = 0; i < channels.length; i += 1) { 101 | if (channels[i].solo) { 102 | return true; 103 | } 104 | } 105 | return false; 106 | }; 107 | 108 | export const updateChannelNodes = (channels) => { 109 | channels.forEach((channel) => { 110 | updateReverbNode(channel); 111 | updatePanNode(channel); 112 | updateGainNode(channel, checkSoloEnabled(channels)); 113 | }); 114 | }; 115 | 116 | export const playNote = (noteTime, buffer, channelID, notePitch = 0) => { 117 | // Set up the AudioBufferSourceNode 118 | const source = audioCtx.createBufferSource(); 119 | source.buffer = buffer; 120 | 121 | // Detune if available 122 | if (detuneSupported) { 123 | source.detune.value = notePitch; 124 | } 125 | 126 | // Route to channel gain node 127 | source.connect(channelGainNodes[channelID]); 128 | 129 | // Connect and start 130 | source.start(noteTime); 131 | return source; 132 | }; 133 | -------------------------------------------------------------------------------- /src/services/audioScheduler.js: -------------------------------------------------------------------------------- 1 | import { LOOKAHEAD } from './audioEngine.config'; 2 | import { playNote } from './audioRouter'; 3 | import { sampleStore } from './sampleStore'; 4 | import { swing } from './swing'; 5 | 6 | // schedule is a lookup table of all the notes currently scheduled to be played 7 | const schedule = {}; 8 | 9 | export const pitchToCents = ({ pitchCoarse = 0, pitchFine = 0 }) => 10 | Math.round(pitchCoarse * 100 + pitchFine); 11 | 12 | export const playNoteNow = (noteChannel) => { 13 | const pitch = pitchToCents(noteChannel); 14 | playNote(null, sampleStore[noteChannel.sample], noteChannel.id, pitch); 15 | }; 16 | 17 | export const scheduleNote = (noteID, noteTime, noteChannel) => { 18 | if (typeof schedule[noteID] === 'undefined') { 19 | const pitch = pitchToCents(noteChannel); 20 | schedule[noteID] = playNote( 21 | noteTime, 22 | sampleStore[noteChannel.sample], 23 | noteChannel.id, 24 | pitch, 25 | ); 26 | } 27 | }; 28 | 29 | export const isBetween = (query, a, b) => query >= a && query < b; 30 | 31 | export const getScheduledNotes = ({ 32 | channelNotes, 33 | channel, 34 | startTime, 35 | tempo, 36 | currentBeat, 37 | }) => 38 | channelNotes.map((note) => { 39 | const lookaheadBeats = LOOKAHEAD * (tempo.bpm / 60); 40 | 41 | const swingAmount = typeof tempo.swing === 'undefined' ? 0 : tempo.swing; 42 | const swingBeat = swing(note.beat, swingAmount); 43 | 44 | const noteTime = startTime + (swingBeat - 1) * (60 / tempo.bpm); 45 | if (isBetween(note.beat, currentBeat, currentBeat + lookaheadBeats)) { 46 | return { 47 | id: note.id, 48 | time: noteTime, 49 | channel, 50 | }; 51 | } 52 | // If nearing the end of the bar, schedule notes at the start of the bar too 53 | if ( 54 | isBetween(note.beat, currentBeat - 4, currentBeat + lookaheadBeats - 4) 55 | ) { 56 | return { 57 | id: note.id, 58 | time: startTime + ((note.beat + 3) * 60) / tempo.bpm, 59 | channel, 60 | }; 61 | } 62 | // Return note objects with time: null that should not be scheduled 63 | return { 64 | id: note.id, 65 | time: null, 66 | channel, 67 | }; 68 | }); 69 | 70 | export const scheduleNotes = ({ 71 | notes, 72 | channels, 73 | startTime, 74 | pattern, 75 | tempo, 76 | currentBeat, 77 | }) => { 78 | // Determine which notes need to be scheduled 79 | const notesToSchedule = channels.reduce( 80 | (accumulator, channel) => [ 81 | ...accumulator, 82 | ...getScheduledNotes({ 83 | channelNotes: notes[channel.id][pattern], // Play the current pattern 84 | channel, 85 | startTime, 86 | tempo, 87 | currentBeat, 88 | }), 89 | ], 90 | [], 91 | ); 92 | 93 | // Schedule the notes 94 | notesToSchedule.forEach((note) => { 95 | if (note.time !== null) { 96 | scheduleNote(note.id, note.time, note.channel); 97 | } else { 98 | delete schedule[note.id]; 99 | } 100 | }); 101 | }; 102 | -------------------------------------------------------------------------------- /src/services/audioScheduler.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | isBetween, 3 | getScheduledNotes, 4 | } from './audioScheduler'; 5 | 6 | jest.mock('./featureChecks'); 7 | jest.mock('./audioContext'); 8 | jest.mock('./audioRouter'); 9 | 10 | describe('isBetween', () => { 11 | test('should return true if query is between a and b', () => { 12 | expect(isBetween(2, 1, 3)).toBe(true); 13 | }); 14 | 15 | test('should return false if query is note between a and b', () => { 16 | expect(isBetween(4, 1, 3)).toBe(false); 17 | }); 18 | }); 19 | 20 | describe('getScheduledNotes', () => { 21 | const testNotes = [ 22 | { 23 | beat: 1, 24 | id: 'foo', 25 | }, 26 | { 27 | beat: 2.5, 28 | id: 'bar', 29 | }, 30 | { 31 | beat: 4.25, 32 | id: 'bam', 33 | }, 34 | ]; 35 | 36 | const scheduledNotes = getScheduledNotes({ 37 | channel: { 38 | sample: { 39 | url: '/whatever.wav', 40 | }, 41 | }, 42 | channelNotes: testNotes, 43 | tempo: { 44 | bpm: 60, 45 | swing: 0.2, 46 | }, 47 | startTime: 0, 48 | currentBeat: 1, 49 | }); 50 | 51 | test('should return same number of notes', () => { 52 | expect(scheduledNotes.length).toBe(testNotes.length); 53 | }); 54 | 55 | test('should calculate noteTime correctly for notes in the lookahead period', () => { 56 | expect(scheduledNotes[0].time).toBe(0); 57 | }); 58 | 59 | test('should set noteTime to null if note should not be scheduled', () => { 60 | expect(scheduledNotes[1].time).toBeNull(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/services/database.js: -------------------------------------------------------------------------------- 1 | const DB_NAME = 'wds-1'; 2 | const DB_VERSION = 1; 3 | const USER_SAMPLES = 'USER_SAMPLES'; 4 | 5 | let db; 6 | 7 | export const initializeDB = () => new Promise((resolve, reject) => { 8 | const request = indexedDB.open(DB_NAME, DB_VERSION); 9 | request.onerror = (event) => { 10 | reject(event); 11 | }; 12 | 13 | request.onupgradeneeded = (event) => { 14 | // Create an objectStore for this database 15 | db = event.target.result; 16 | db.createObjectStore(USER_SAMPLES); 17 | }; 18 | 19 | request.onsuccess = (event) => { 20 | db = event.target.result; 21 | resolve(); 22 | }; 23 | }); 24 | 25 | export const saveToDB = (myArrayBuffer, myKey) => new Promise((resolve, reject) => { 26 | const trans = db.transaction([USER_SAMPLES], 'readwrite'); 27 | trans.objectStore(USER_SAMPLES).put(myArrayBuffer, myKey); 28 | trans.onerror = (event) => { 29 | reject(event); 30 | }; 31 | trans.onsuccess = () => { 32 | resolve(myKey); 33 | }; 34 | }); 35 | 36 | export const getFromDB = (myKey) => new Promise((resolve, reject) => { 37 | const trans = db.transaction([USER_SAMPLES], 'readwrite'); 38 | const request = trans.objectStore(USER_SAMPLES).get(myKey); 39 | request.onerror = (event) => { 40 | reject(event); 41 | }; 42 | request.onsuccess = () => { 43 | if (request.result) { 44 | resolve(request.result); 45 | } 46 | reject(); 47 | }; 48 | }); 49 | -------------------------------------------------------------------------------- /src/services/featureChecks.js: -------------------------------------------------------------------------------- 1 | const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 2 | const source = audioCtx.createBufferSource(); 3 | 4 | export const detuneSupported = typeof source.detune !== 'undefined'; 5 | 6 | export const stereoPannerSupported = typeof audioCtx.createStereoPanner !== 'undefined'; 7 | -------------------------------------------------------------------------------- /src/services/fileUtils.js: -------------------------------------------------------------------------------- 1 | import { getAudioContext } from './audioContext'; 2 | 3 | export const fetchFile = (url) => new Promise( 4 | (resolve, reject) => { 5 | fetch(url).then((response) => { 6 | if (response.ok) { 7 | resolve(response.blob()); 8 | } 9 | reject(new Error('Network response was not ok.')); 10 | }); 11 | }, 12 | ); 13 | 14 | export const decodeFile = (sampleBlob) => new Promise( 15 | (resolve) => { 16 | const fileReader = new FileReader(); 17 | fileReader.readAsArrayBuffer(sampleBlob); 18 | fileReader.onloadend = () => { 19 | resolve(fileReader.result); 20 | }; 21 | }, 22 | ); 23 | 24 | export const decodeAudio = (audioArrayBuffer) => new Promise( 25 | (resolve, reject) => { 26 | getAudioContext().decodeAudioData(audioArrayBuffer, resolve, reject); 27 | }, 28 | ); 29 | -------------------------------------------------------------------------------- /src/services/pwaInstall.js: -------------------------------------------------------------------------------- 1 | import { setCanInstall } from '../common'; 2 | 3 | let deferredPrompt; 4 | 5 | export const initializePwaInstall = (store) => { 6 | window.addEventListener('beforeinstallprompt', (e) => { 7 | e.preventDefault(); 8 | deferredPrompt = e; 9 | store.dispatch(setCanInstall(true)); 10 | }); 11 | 12 | window.addEventListener('appinstalled', () => { 13 | store.dispatch(setCanInstall(false)); 14 | }); 15 | }; 16 | 17 | export const promptToInstall = () => { 18 | if (typeof deferredPrompt !== 'undefined') { 19 | deferredPrompt.prompt(); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/services/reverb.js: -------------------------------------------------------------------------------- 1 | import { fetchFile, decodeFile, decodeAudio } from './fileUtils'; 2 | 3 | const impulseResponses = {}; 4 | 5 | export const loadImpulseResponse = (fileName) => { 6 | if (typeof impulseResponses[fileName] !== 'undefined') { 7 | return Promise.resolve(impulseResponses[fileName]); 8 | } 9 | return fetchFile(fileName) 10 | .then(decodeFile) 11 | .then(decodeAudio); 12 | }; 13 | -------------------------------------------------------------------------------- /src/services/sampleStore.js: -------------------------------------------------------------------------------- 1 | import { fetchFile, decodeFile, decodeAudio } from './fileUtils'; 2 | import { saveToDB, getFromDB } from './database'; 3 | 4 | export const sampleStore = {}; 5 | 6 | export const loadSample = (url) => { 7 | if (typeof sampleStore[url] !== 'undefined') { 8 | return Promise.resolve(true); 9 | } 10 | 11 | return getFromDB(url) 12 | .then(decodeAudio) 13 | .then((drumBuffer) => { 14 | sampleStore[url] = drumBuffer; 15 | return true; 16 | }) 17 | .catch(() => fetchFile(url) 18 | .then(decodeFile) 19 | .then(decodeAudio) 20 | .then((drumBuffer) => { 21 | sampleStore[url] = drumBuffer; 22 | return true; 23 | }) 24 | .catch(() => false)); 25 | }; 26 | 27 | export const saveToSampleStore = (file) => { 28 | const id = file.name; 29 | return decodeFile(file) 30 | .then((myArrayBuffer) => { 31 | saveToDB(myArrayBuffer, id); 32 | return decodeAudio(myArrayBuffer); 33 | }) 34 | .then((drumBuffer) => { 35 | sampleStore[id] = drumBuffer; 36 | return id; 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/services/swing.js: -------------------------------------------------------------------------------- 1 | export const swing = (beatTime, swingAmount) => { 2 | const SWING_TIMING = 0.5; // eight note cycles 3 | const MAX_SWING = 0.95; 4 | 5 | const beatCyclePos = beatTime % SWING_TIMING; 6 | const beatCyclePercentage = beatCyclePos / SWING_TIMING; 7 | 8 | const fx = (beatCyclePercentage ** (1 - swingAmount)) * MAX_SWING; // Exponential function 9 | const offset = (fx - beatCyclePercentage) * beatCyclePos; 10 | 11 | return beatTime + offset; 12 | }; 13 | -------------------------------------------------------------------------------- /src/services/unmute.js: -------------------------------------------------------------------------------- 1 | import silence from '../assets/silence.mp3'; 2 | 3 | export const unmute = () => { 4 | var el = document.createElement('audio'); 5 | el.src = silence; 6 | el.play(); 7 | }; 8 | -------------------------------------------------------------------------------- /src/services/uuid.js: -------------------------------------------------------------------------------- 1 | import uuidv4 from 'uuid/v4'; 2 | 3 | export const uuid = uuidv4; 4 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { persistStore, persistReducer, createMigrate } from 'redux-persist'; 4 | import storage from 'redux-persist/lib/storage'; 5 | import reducer from './reducer'; 6 | 7 | const migrations = { 8 | 1: () => ({}), 9 | 2: () => ({}), 10 | 3: () => ({}), 11 | }; 12 | 13 | export const configureStore = (callback) => { 14 | const persistConfig = { 15 | key: 'root-v1.0.0', 16 | storage, 17 | version: 3, 18 | blacklist: ['playbackSession', 'window'], 19 | migrate: createMigrate(migrations, { debug: import.meta.env.DEV }), // eslint-disable-line 20 | }; 21 | 22 | const persistedReducer = persistReducer(persistConfig, reducer); 23 | 24 | const composeEnhancers = 25 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; // eslint-disable-line 26 | const store = createStore( 27 | persistedReducer, 28 | composeEnhancers(applyMiddleware(thunk)), 29 | ); 30 | 31 | const persistor = persistStore(store, null, callback); 32 | 33 | return { 34 | store, 35 | persistor, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/styles/globalStyles.js: -------------------------------------------------------------------------------- 1 | import { injectGlobal } from 'styled-components'; 2 | import theme from './theme'; 3 | import jostMediumWoff2 from '../assets/fonts/jost-medium-webfont.woff2'; 4 | import jostMediumWoff from '../assets/fonts/jost-medium-webfont.woff'; 5 | import jostBoldWoff2 from '../assets/fonts/jost-bold-webfont.woff2'; 6 | import jostBoldWoff from '../assets/fonts/jost-bold-webfont.woff'; 7 | import jostSemiboldWoff2 from '../assets/fonts/jost-semi-webfont.woff2'; 8 | import jostSemiboldWoff from '../assets/fonts/jost-semi-webfont.woff'; 9 | 10 | export default () => injectGlobal` 11 | 12 | @font-face { 13 | font-family: 'Jost'; 14 | font-style: normal; 15 | font-weight: 400; 16 | src: local('Jost Medium'), local('Jost-Medium'), 17 | url(${jostMediumWoff2}) format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 18 | url(${jostMediumWoff}) format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 19 | } 20 | 21 | @font-face { 22 | font-family: 'Jost'; 23 | font-style: normal; 24 | font-weight: 600; 25 | src: local('Jost SemiBold'), local('Jost-SemiBold'), 26 | url(${jostSemiboldWoff2}) format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 27 | url(${jostSemiboldWoff}) format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 28 | } 29 | 30 | @font-face { 31 | font-family: 'Josts'; 32 | font-style: normal; 33 | font-weight: 700; 34 | src: local('Jost Bold'), local('Jost-Bold'), 35 | url(${jostBoldWoff2}) format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 36 | url(${jostBoldWoff}) format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 37 | } 38 | 39 | html { 40 | background-color: ${theme.colors.nearBlack}; 41 | } 42 | 43 | * { 44 | font-family: "Jost", "Futura", sans-serif; 45 | font-display: swap; 46 | } 47 | 48 | body { 49 | min-width: 750px; 50 | box-sizing: border-box; 51 | } 52 | 53 | .bpm-text-input { 54 | -moz-appearance:textfield; 55 | } 56 | 57 | /* Webkit browsers like Safari and Chrome */ 58 | .bpm-text-input::-webkit-inner-spin-button, 59 | .bpm-text-input::-webkit-outer-spin-button { 60 | -webkit-appearance: none; 61 | margin: 0; 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /src/styles/theme.js: -------------------------------------------------------------------------------- 1 | const colors = { 2 | nearWhite: '#F2F2F8', 3 | lightGray: '#C0C3C7', 4 | gray: '#909599', 5 | steel: '#606469', 6 | darkGray: '#404449', 7 | nearBlack: '#202427', 8 | black80: 'rgba(0,0,0,0.8)', 9 | green: '#58A291', 10 | lightGreen: '#68B2A1', 11 | darkGreen: '#1B806D', 12 | red: '#CD545B', 13 | lightRed: '#DD646B', 14 | darkRed: '#633231', 15 | brightRed: 'rgb(244, 83, 58)', 16 | brightRed30: 'rgba(244, 83, 58, 0.3)', 17 | gold: '#E6A65D', 18 | yellow: 'rgb(255, 224, 71)', 19 | yellow30: 'rgba(255, 224, 71, 0.3)', 20 | primary: 'rgba(213,255,169,1)', 21 | primaryDark: 'rgba(180,215,129,1)', 22 | secondary: 'rgba(152,255,193,1)', 23 | blue: '#2f85c6', 24 | darkBlue: '#196096', 25 | }; 26 | 27 | export default { 28 | fontSizes: [ 29 | 11, 13, 14, 24, 32, 48, 64, 96, 128, 30 | ], 31 | space: [ 32 | // margin and padding 33 | 0, 4, 8, 16, 32, 64, 128, 256, 34 | ], 35 | breakpoints: ['640px', '720px', '769px', '820px', '900px', '1024px', '1200px', '1400px'], 36 | colors, 37 | fancyButtons: { 38 | green: { 39 | color: 'white', 40 | backgroundColor: colors.green, 41 | boxShadow: `0 0.3em ${colors.darkGreen}`, 42 | '&:hover': { 43 | backgroundColor: colors.lightGreen, 44 | }, 45 | '&:active': { 46 | backgroundColor: colors.lightGreen, 47 | boxShadow: `0 0 ${colors.darkGreen}`, 48 | transform: 'translateY(0.3em)', 49 | }, 50 | }, 51 | red: { 52 | color: 'white', 53 | backgroundColor: colors.red, 54 | boxShadow: `0 0.3em ${colors.darkRed}`, 55 | '&:hover': { 56 | backgroundColor: colors.lightRed, 57 | }, 58 | '&:active': { 59 | backgroundColor: colors.lightRed, 60 | boxShadow: `0 0 ${colors.darkRed}`, 61 | transform: 'translateY(0.3em)', 62 | }, 63 | }, 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import reactRefresh from '@vitejs/plugin-react-refresh'; 3 | 4 | export default defineConfig({ 5 | plugins: [reactRefresh()], 6 | }); 7 | --------------------------------------------------------------------------------