├── public ├── favicon-32x32.png ├── favicon-64x64.png ├── favicon-128x128.png ├── favicon-192x192.png ├── favicon-256x256.png └── favicon-32x32alt2.png ├── src ├── client │ ├── assets │ │ ├── LA4.jpg │ │ └── night-sky.jpg │ ├── helpers │ │ ├── socket.js │ │ └── audioHelpers.js │ ├── components │ │ ├── Share.jsx │ │ ├── Footer.jsx │ │ ├── TimeDisplay.jsx │ │ ├── TempoSelector.jsx │ │ ├── Users.jsx │ │ ├── PlayPauseButton.jsx │ │ ├── GridButton.jsx │ │ ├── ControlBar.jsx │ │ ├── Board.jsx │ │ ├── ScaleSelector.jsx │ │ ├── Knob.jsx │ │ ├── KnobPanel.jsx │ │ ├── InstrumentColumn.jsx │ │ └── InlineEdit.jsx │ ├── index.js │ ├── constants │ │ ├── scales.js │ │ ├── soundPresets.js │ │ └── initState.js │ ├── reducer │ │ ├── reducerConstants.js │ │ └── reducer.jsx │ ├── containers │ │ ├── App.jsx │ │ ├── HeaderContainer.jsx │ │ └── MainContainer.jsx │ ├── styles │ │ └── globalStyles.js │ └── hooks │ │ ├── useOnClickOutside.js │ │ └── useKeypress.js └── server │ ├── assets │ └── audio │ │ ├── EA7301_808_Sd.wav │ │ ├── EA7419_909_Sd.wav │ │ ├── EA7701_R8_Sd.wav │ │ ├── EA7716_R8_Rim.wav │ │ ├── EA7720_R8_Sd.wav │ │ ├── EA8722_Sd_House.wav │ │ └── wamb_mbasefree_006.wav │ ├── cache.js │ └── server.js ├── .vscode ├── settings.json └── launch.json ├── tsconfig.json ├── .eslintrc ├── README.md ├── LICENSE ├── webpack.config.js ├── .gitignore ├── package.json └── sampleData.js /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/public/favicon-64x64.png -------------------------------------------------------------------------------- /public/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/public/favicon-128x128.png -------------------------------------------------------------------------------- /public/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/public/favicon-192x192.png -------------------------------------------------------------------------------- /public/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/public/favicon-256x256.png -------------------------------------------------------------------------------- /src/client/assets/LA4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/client/assets/LA4.jpg -------------------------------------------------------------------------------- /public/favicon-32x32alt2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/public/favicon-32x32alt2.png -------------------------------------------------------------------------------- /src/client/assets/night-sky.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/client/assets/night-sky.jpg -------------------------------------------------------------------------------- /src/client/helpers/socket.js: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client'; 2 | 3 | export const socket = io('http://localhost:3000'); 4 | -------------------------------------------------------------------------------- /src/server/assets/audio/EA7301_808_Sd.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/server/assets/audio/EA7301_808_Sd.wav -------------------------------------------------------------------------------- /src/server/assets/audio/EA7419_909_Sd.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/server/assets/audio/EA7419_909_Sd.wav -------------------------------------------------------------------------------- /src/server/assets/audio/EA7701_R8_Sd.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/server/assets/audio/EA7701_R8_Sd.wav -------------------------------------------------------------------------------- /src/server/assets/audio/EA7716_R8_Rim.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/server/assets/audio/EA7716_R8_Rim.wav -------------------------------------------------------------------------------- /src/server/assets/audio/EA7720_R8_Sd.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/server/assets/audio/EA7720_R8_Sd.wav -------------------------------------------------------------------------------- /src/server/assets/audio/EA8722_Sd_House.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/server/assets/audio/EA8722_Sd_House.wav -------------------------------------------------------------------------------- /src/server/assets/audio/wamb_mbasefree_006.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csAudioApps/stepSeq/HEAD/src/server/assets/audio/wamb_mbasefree_006.wav -------------------------------------------------------------------------------- /src/client/components/Share.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Share = () => ( 4 |
5 | ); 6 | 7 | export default Share; 8 | -------------------------------------------------------------------------------- /src/server/cache.js: -------------------------------------------------------------------------------- 1 | const memoryCache = module.exports = function () { 2 | const cache = {}; 3 | return { 4 | get: (key) => cache[key], 5 | set: (key, val) => cache[key] = val, 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ "javascript", "javascriptreact", "html", "typescriptreact" ], 3 | "eslint.enable": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | } 7 | } -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './containers/App'; 4 | 5 | const root = document.createElement('div'); 6 | root.id = 'root'; 7 | document.body.appendChild(root); 8 | ReactDOM.render(, root); 9 | -------------------------------------------------------------------------------- /src/client/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Footer = React.memo(() => ( 5 | 6 | {/*

footer

*/} 7 |
8 | )); 9 | 10 | export default Footer; 11 | 12 | const StyledFooter = styled.div` 13 | width: 100%; 14 | text-align: center; 15 | `; 16 | -------------------------------------------------------------------------------- /src/client/constants/scales.js: -------------------------------------------------------------------------------- 1 | const scales = [ 2 | ['C', 'D', 'E', 'F', 'G', 'A', 'B'], 3 | ['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb'], 4 | ['C', 'Db', 'Eb', 'F', 'G', 'Ab', 'B'], 5 | ['C', 'D', 'E', 'F#', 'G', 'A', 'B'], 6 | ['C', 'D', 'E', 'F', 'G', 'A', 'Bb'], 7 | ['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb'], 8 | ['C', 'Db', 'Eb', 'F', 'Gb', 'Ab', 'Bb'], 9 | ]; 10 | 11 | export default scales; 12 | -------------------------------------------------------------------------------- /src/client/constants/soundPresets.js: -------------------------------------------------------------------------------- 1 | const soundPresets = { 2 | bass: { 3 | volume: -12, // the oscillator volume set to -12dB 4 | oscillator: { 5 | type: 'square', // oscillator type to square wave 6 | }, 7 | envelope: { 8 | attack: 0.02, // envelope attack set to 20ms 9 | release: 1, // envelope release set to 1s 10 | }, 11 | }, 12 | }; 13 | 14 | export default soundPresets; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build", 4 | "sourceMap": true, 5 | // "noImplicitAny": true, 6 | "module": "commonjs", 7 | "target": "es6", 8 | "jsx": "react", 9 | "allowJs": true, 10 | // "moduleResolution": "node", 11 | // "resolveJsonModule": true, 12 | // "skipLibCheck": true, 13 | // "esModuleInterop": true, 14 | // "baseUrl": ".", 15 | }, 16 | "exclude": ["node_modules", ".vscode", "__tests__"] 17 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb", 4 | "airbnb/hooks" 5 | ], 6 | "plugins": [ 7 | "react-hooks" 8 | ], 9 | "rules": { 10 | "no-console": "off", 11 | "no-plusplus": "off", 12 | "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], 13 | "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks 14 | "react-hooks/exhaustive-deps": "warn", // Checks effect dependencies 15 | "arrow-body-style": "off", 16 | }, 17 | "globals": { 18 | "document": true, 19 | "window": true 20 | }, 21 | } -------------------------------------------------------------------------------- /src/client/components/TimeDisplay.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | export const TimeDisplay = ({ position }) => ( 6 |
  • 7 | {position} 8 |
  • 9 | ); 10 | 11 | export default TimeDisplay; 12 | 13 | // TimeDisplay.propTypes = { 14 | // position: PropTypes.string, 15 | // }; 16 | 17 | const StyledTimeDisplay = styled.div` 18 | font-family: inherit; 19 | padding: 5px; 20 | float: right 21 | `; 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stepSeq 2 | Collaborative step-sequencer 3 | 4 | ## Description 5 | Allows multiple users to connect and collaborate on a sequence in real-time 6 | 7 | ### Installing 8 | - npm install 9 | - npm run dev 10 | 11 | ### Executing program 12 | Point browser to localhost:8080 then make sick beats 13 | 14 | ## Authors 15 | - Greg Panciera 16 | - Tom Lutz 17 | - Jonathon Escamilla 18 | - Matt Gin 19 | 20 | ## Version History 21 | * 0.1 Initial Beta Release 22 | 23 | ## License 24 | This project is licensed under the MIT License - see the LICENSE.md file for details 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:8080", 12 | "webRoot": "${workspaceFolder}", 13 | "skipFiles": [ 14 | "${workspaceRoot}/node_modules/**/*.js", 15 | ] 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /src/client/reducer/reducerConstants.js: -------------------------------------------------------------------------------- 1 | export const TOGGLE_GRID_BUTTON = 'TOGGLE_GRID_BUTTON'; 2 | export const UPDATE_STATUS = 'UPDATE_STATUS'; 3 | export const TOGGLE_PLAY_STATE = 'TOGGLE_PLAY_STATE'; 4 | export const SET_STATE_FROM_SOCKET = 'SET_STATE_FROM_SOCKET'; 5 | export const ADD_USER = 'ADD_USER'; 6 | export const REMOVE_USER = 'REMOVE_USER'; 7 | export const SET_NEW_USER = 'SET_NEW_USER'; 8 | export const SET_LOCAL_USERID = 'SET_LOCAL_USERID'; 9 | export const SET_SELECTED_INSTRUMENT = 'SET_SELECTED_INSTRUMENT'; 10 | export const SET_SELECTED_SCALE = 'SET_SELECTED_SCALE'; 11 | export const SET_TEMPO = 'SET_TEMPO'; 12 | -------------------------------------------------------------------------------- /src/client/containers/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import MainContainer from './MainContainer'; 3 | import GlobalStyle from '../styles/globalStyles'; 4 | import { socket } from '../helpers/socket'; 5 | import ImagePath from '../assets/night-sky.jpg'; 6 | 7 | const App = () => { 8 | const [inputText] = useState(''); 9 | const send = () => { 10 | console.log('sending text ', inputText); 11 | socket.emit('updateServerState', inputText); 12 | }; 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /src/client/containers/HeaderContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Share from '../components/Share'; 4 | import Users from '../components/Users'; 5 | 6 | const HeaderContainer = React.memo(({ localUserId, username }) => ( 7 | 8 | 9 | 13 | 14 | )); 15 | 16 | const StyledDiv = styled.div` 17 | display: flex; 18 | justify-content: flex-end; 19 | color: white; 20 | background-color: #282828; 21 | height: 50px; 22 | width: 100%; 23 | `; 24 | 25 | export default HeaderContainer; 26 | -------------------------------------------------------------------------------- /src/client/components/TempoSelector.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { number, func } from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import InlineEdit from './InlineEdit'; 5 | import { SET_TEMPO } from '../reducer/reducerConstants'; 6 | 7 | const TempoSelector = React.memo(({ dispatch, curTempo }) => ( 8 | <> 9 | Tempo 10 | { 13 | let newTempo = Number(newText); 14 | // Make sure it's a valid number, otherwise keep current tempo 15 | newTempo = Number.isNaN(newTempo) ? curTempo : newTempo; 16 | dispatch({ type: SET_TEMPO, payload: { newTempo } }); 17 | }} 18 | /> 19 | 20 | )); 21 | 22 | TempoSelector.propTypes = { 23 | dispatch: func.isRequired, 24 | curTempo: number.isRequired, 25 | }; 26 | 27 | const TempoTitle = styled.li` 28 | margin: auto 0.4em auto auto; 29 | color: #ababab; 30 | `; 31 | 32 | export default TempoSelector; 33 | -------------------------------------------------------------------------------- /src/server/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/newline-after-import */ 2 | const path = require('path'); 3 | const express = require('express'); 4 | const app = express(); 5 | const http = require('http').createServer(app); 6 | const io = require('socket.io')(http); 7 | const cache = require('./cache')(); 8 | 9 | app.use(express.static(path.resolve(__dirname, '/'))); 10 | 11 | io.on('connection', (socket) => { 12 | console.log('a user connected'); 13 | socket.on('updateServerState', (msg, senderId) => { 14 | // set cached state and broadcast to clients 15 | cache.set('state', msg); 16 | console.log(msg.users); 17 | io.emit('updateClientState', cache.get('state'), senderId); 18 | }); 19 | 20 | // if board has already been initialized, send state to client 21 | socket.on('getInitialState', () => { 22 | if (cache.get('users')) io.emit('updateClientState', cache.get('state')); 23 | else (io.emit('firstUser')); 24 | }); 25 | }); 26 | 27 | http.listen(3000, () => { 28 | console.log('listening on *:3000'); 29 | }); 30 | -------------------------------------------------------------------------------- /src/client/components/Users.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Users = React.memo(({ localUserId, username }) => { 5 | console.log('username:', username, 'localUserId:', localUserId); 6 | 7 | return ( 8 | <> 9 | user 10 | {' '} 11 | 12 | {username} 13 | 14 | 15 | 16 | ); 17 | }); 18 | 19 | const StyledUserTitle = styled.span` 20 | color: #ababab; 21 | margin: auto 10px auto 0px; 22 | font: inherit; 23 | font-weight: 200; 24 | `; 25 | 26 | const UserColorSwatch = styled.span` 27 | background-color: #5dfdcb; 28 | height: 15px; 29 | width: 15px; 30 | border-radius: 50%; 31 | display: inline-block; 32 | margin: auto 10px; 33 | `; 34 | 35 | const StyledUserListDisplay = styled.div` 36 | color: #eaeaea; 37 | margin: auto 10px auto 0px; 38 | font: inherit; 39 | font-weight: 200; 40 | `; 41 | 42 | export default Users; 43 | -------------------------------------------------------------------------------- /src/client/helpers/audioHelpers.js: -------------------------------------------------------------------------------- 1 | import * as Tone from 'tone'; 2 | import scales from '../constants/scales'; 3 | 4 | export const updateNoteArray = (grid, scaleNum, rootOctaveNum) => { 5 | const retArr = []; 6 | let curNote; 7 | let oct; 8 | 9 | for (let i = 0; i < grid.length; i++) { 10 | if (grid[i].length > 0) { 11 | curNote = grid[i][0] % 7; 12 | // console.log("updateNoteArray -> curNote", curNote) 13 | 14 | oct = Math.floor(grid[i][0] / 7) + rootOctaveNum; 15 | // console.log("updateNoteArray -> oct", oct) 16 | 17 | retArr[i] = scales[scaleNum][curNote].toString() + oct.toString(); 18 | } 19 | else { 20 | retArr[i] = null; 21 | } 22 | } 23 | // console.log(retArr); 24 | return retArr; 25 | }; 26 | // export default updateNoteArray; 27 | 28 | export const toggleToneTransport = async () => { 29 | await Tone.start(); 30 | 31 | if (Tone.Transport.state === 'stopped' || Tone.Transport.state === 'paused') { 32 | Tone.Transport.start(); 33 | } 34 | else { 35 | Tone.Transport.pause(); 36 | } 37 | return true; 38 | }; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 csAudioApps 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/client/styles/globalStyles.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | const GlobalStyle = createGlobalStyle` 4 | * { 5 | margin: 0; 6 | padding: 0; 7 | box-sizing: border-box; 8 | font: inherit; 9 | } 10 | 11 | *:focus { 12 | outline: 0; 13 | } 14 | 15 | body { 16 | font-family: century-gothic, sans-serif; 17 | font-weight: 200; 18 | font-style: normal; 19 | letter-spacing: 0.15em; 20 | color: #cfcfcf; 21 | background-color: #242424; 22 | height: 100%; 23 | background-image: url(${(props) => props.img}); 24 | background-blend-mode: overlay; 25 | background-size: cover; 26 | background-attachment: fixed; 27 | height: 110vh; 28 | overflow-x:hidden; 29 | overflow-y:hidden; 30 | } 31 | 32 | button { 33 | display: inline-block; 34 | border: none; 35 | cursor: pointer; 36 | text-decoration: none; 37 | font-family: century-gothic, sans-serif; 38 | font-weight: 200; 39 | font-style: normal; 40 | font-size: inherit; 41 | color: #cfcfcf; 42 | } 43 | 44 | table { 45 | border-collapse: collapse; 46 | } 47 | 48 | ul { 49 | list-style: none; 50 | } 51 | 52 | `; 53 | 54 | export default GlobalStyle; 55 | -------------------------------------------------------------------------------- /src/client/hooks/useOnClickOutside.js: -------------------------------------------------------------------------------- 1 | // hook from https://usehooks.com/useOnClickOutside/ 2 | import { useEffect } from 'react'; 3 | 4 | // Hook 5 | function useOnClickOutside(ref, handler) { 6 | useEffect(() => { 7 | const listener = (event) => { 8 | // Do nothing if clicking ref's element or descendent elements 9 | if (!ref.current || ref.current.contains(event.target)) { 10 | return; 11 | } 12 | 13 | handler(event); 14 | }; 15 | 16 | document.addEventListener('mousedown', listener); 17 | document.addEventListener('touchstart', listener); 18 | 19 | return () => { 20 | document.removeEventListener('mousedown', listener); 21 | document.removeEventListener('touchstart', listener); 22 | }; 23 | }, 24 | // Add ref and handler to effect dependencies 25 | // It's worth noting that because passed in handler is a new ... 26 | // ... function on every render that will cause this effect ... 27 | // ... callback/cleanup to run every render. It's not a big deal ... 28 | // ... but to optimize you can wrap handler in useCallback before ... 29 | // ... passing it into this hook. 30 | [ref, handler]); 31 | } 32 | 33 | export default useOnClickOutside; 34 | -------------------------------------------------------------------------------- /src/client/hooks/useKeypress.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | // hook from https://usehooks.com/useKeyPress/ 3 | import { useState, useEffect } from 'react'; 4 | 5 | // Hook 6 | function useKeyPress(targetKey) { 7 | // State for keeping track of whether key is pressed 8 | const [keyPressed, setKeyPressed] = useState(false); 9 | 10 | // If pressed key is our target key then set to true 11 | const downHandler = ({ key }) => { 12 | if (key === targetKey) { 13 | setKeyPressed(true); 14 | } 15 | }; 16 | 17 | // If released key is our target key then set to false 18 | const upHandler = ({ key }) => { 19 | if (key === targetKey) { 20 | setKeyPressed(false); 21 | } 22 | }; 23 | 24 | // Add event listeners 25 | useEffect(() => { 26 | window.addEventListener('keydown', downHandler); 27 | window.addEventListener('keyup', upHandler); 28 | // Remove event listeners on cleanup 29 | return () => { 30 | window.removeEventListener('keydown', downHandler); 31 | window.removeEventListener('keyup', upHandler); 32 | }; 33 | }, []); // Empty array ensures that effect is only run on mount and unmount 34 | 35 | return keyPressed; 36 | } 37 | 38 | export default useKeyPress; 39 | -------------------------------------------------------------------------------- /src/client/components/PlayPauseButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { toggleToneTransport } from '../helpers/audioHelpers'; 5 | import { TOGGLE_PLAY_STATE } from '../reducer/reducerConstants'; 6 | 7 | const PlayPauseButton = React.memo(({ dispatch, localUserId, isPlaying }) => ( 8 | 9 | { 13 | if (toggleToneTransport()) { 14 | dispatch({ type: TOGGLE_PLAY_STATE, payload: { localUserId } }); 15 | } 16 | }} 17 | > 18 | { isPlaying ? 'Pause' : 'Play' } 19 | 20 | 21 | )); 22 | 23 | PlayPauseButton.propTypes = { 24 | dispatch: PropTypes.func.isRequired, 25 | localUserId: PropTypes.string.isRequired, 26 | isPlaying: PropTypes.bool.isRequired, 27 | }; 28 | 29 | const StyledLi = styled.li` 30 | margin-left: 25px; 31 | align-self: center; 32 | `; 33 | 34 | const StyledPlayPauseButton = styled.button` 35 | width: 120px; 36 | height: 35px; 37 | border-radius: 3px; 38 | background-color: #5b5b5b; 39 | color: #eaeaea; 40 | padding: 5px; 41 | `; 42 | 43 | export default PlayPauseButton; 44 | -------------------------------------------------------------------------------- /src/client/components/GridButton.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/control-has-associated-label */ 2 | import React from 'react'; 3 | import styled, { css } from 'styled-components'; 4 | import { TOGGLE_GRID_BUTTON } from '../reducer/reducerConstants'; 5 | 6 | const COLOR_DEFAULT = '#666666'; 7 | const COLOR_SELECTED = '#5dfdcb'; 8 | const COLOR_PLAYHEAD = '#c9f9ff'; 9 | 10 | const GridButton = ({ 11 | x, y, curStepColNum, dispatch, gridState, 12 | }) => { 13 | let isBtnOn = false; 14 | let buttonColor = ''; 15 | 16 | if (gridState[x] && Array.isArray(gridState[x])) { 17 | gridState[x].forEach((elem) => { 18 | if (elem === y) { 19 | isBtnOn = true; 20 | // break; 21 | } 22 | }); 23 | } 24 | 25 | if (curStepColNum === x) { buttonColor = COLOR_PLAYHEAD; } 26 | else { buttonColor = isBtnOn ? COLOR_SELECTED : COLOR_DEFAULT; } 27 | 28 | return ( 29 | dispatch({ 34 | type: TOGGLE_GRID_BUTTON, 35 | payload: { x, y }, 36 | })} 37 | /> 38 | ); 39 | }; 40 | 41 | const StyledGridButton = styled.button` 42 | border: 0; 43 | margin: 1px; 44 | border-radius: 6px; 45 | width: 50px; 46 | height: 50px; 47 | float: left; 48 | background-color: ${(props) => (props.buttonColor)}; 49 | `; 50 | 51 | export default GridButton; 52 | -------------------------------------------------------------------------------- /src/client/components/ControlBar.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable object-curly-newline */ 2 | /* eslint-disable jsx-a11y/control-has-associated-label */ 3 | /* eslint-disable jsx-a11y/click-events-have-key-events */ 4 | /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ 5 | import { number, func, string, bool } from 'prop-types'; 6 | import React from 'react'; 7 | import styled from 'styled-components'; 8 | import PlayPauseButton from './PlayPauseButton'; 9 | import ScaleSelector from './ScaleSelector'; 10 | import TempoSelector from './TempoSelector'; 11 | // import TimeDisplay from './TimeDisplay'; 12 | 13 | const ControlBar = React.memo(({ selectedScale, dispatch, localUserId, isPlaying, curTempo }) => ( 14 | 15 | 16 | 17 | 18 | {/* */} 19 | 20 | )); 21 | 22 | ControlBar.propTypes = { 23 | // position: PropTypes.string.isRequired, 24 | selectedScale: number.isRequired, 25 | dispatch: func.isRequired, 26 | localUserId: string.isRequired, 27 | isPlaying: bool.isRequired, 28 | curTempo: number.isRequired, 29 | }; 30 | 31 | const ControlBarWrapper = styled.ul` 32 | display: flex; 33 | height: 55px; 34 | border: 1px solid #444444; 35 | `; 36 | 37 | export default ControlBar; 38 | -------------------------------------------------------------------------------- /src/client/components/Board.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import GridButton from './GridButton'; 4 | 5 | const Board = ({ 6 | numRows, numColumns, curStepColNum, gridState, dispatch, 7 | }) => { 8 | const renderButtons = () => { 9 | const grid = []; 10 | 11 | for (let y = numRows - 1; y >= 0; y--) { 12 | const buttonRow = []; 13 | 14 | for (let x = 0; x < numColumns; x++) { 15 | buttonRow.push( 16 | 17 | 25 | , 26 | ); 27 | } 28 | grid.push( 29 | 33 | {buttonRow} 34 | , 35 | ); 36 | } 37 | // console.log("renderButtons -> grid", grid) 38 | return grid; 39 | }; 40 | 41 | return ( 42 | 43 | 44 | 45 | {renderButtons()} 46 | 47 |
    48 |
    49 | ); 50 | }; 51 | 52 | const StyledBoard = styled.div` 53 | padding: 12px; 54 | ${'' /* border: 1px solid grey; */} 55 | border-bottom: 1px solid #444444; 56 | border-right: 1px solid #444444; 57 | `; 58 | 59 | export default Board; 60 | -------------------------------------------------------------------------------- /src/client/components/ScaleSelector.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/click-events-have-key-events */ 2 | /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import styled from 'styled-components'; 6 | import scales from '../constants/scales'; 7 | import { SET_SELECTED_SCALE } from '../reducer/reducerConstants'; 8 | 9 | const ScaleSelector = React.memo(({ dispatch, localUserId, selectedScale }) => ( 10 | <> 11 | Scale 12 | { 13 | scales 14 | ? scales.map((item, scaleIndex) => ( 15 | dispatch({ 18 | type: SET_SELECTED_SCALE, 19 | payload: { localUserId, selectedScale: scaleIndex }, 20 | })} 21 | key={`scaleBtn${scaleIndex.toString()}`} 22 | > 23 | {scaleIndex + 1} 24 | 25 | )) 26 | :

    Loading...

    27 | } 28 | 29 | )); 30 | 31 | ScaleSelector.propTypes = { 32 | dispatch: PropTypes.func.isRequired, 33 | localUserId: PropTypes.string.isRequired, 34 | selectedScale: PropTypes.number.isRequired, 35 | }; 36 | 37 | export default ScaleSelector; 38 | 39 | const ScaleTitle = styled.li` 40 | margin: auto 0.4em auto 20px; 41 | ${'' /* padding: 5px; */} 42 | font-size: 0.95em; 43 | float: left; 44 | color: #eaeaea; 45 | `; 46 | 47 | const ScaleOption = styled.button` 48 | color: #ababab; 49 | margin: 0.3em; 50 | float: left; 51 | font-size: 0.95em; 52 | background: transparent; 53 | font-weight: ${(props) => (props.isSelected ? 700 : 200)}; 54 | `; 55 | -------------------------------------------------------------------------------- /src/client/components/Knob.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React, { useEffect, useRef, memo } from 'react'; 3 | import * as Nexus from 'nexusui'; 4 | 5 | let id = 0; 6 | function getId() { 7 | id += 1; 8 | return id; 9 | } 10 | 11 | function NO_OP() {} 12 | 13 | const Dial = memo(({ 14 | size, 15 | interaction, 16 | max, 17 | min, 18 | mode, 19 | value, 20 | onChange = NO_OP, 21 | onReady = NO_OP, 22 | }) => { 23 | const dial = useRef(null); 24 | const elementId = useRef(`nexus-ui-dial-${getId()}`); 25 | 26 | useEffect(() => { 27 | dial.current = new Nexus.Dial(elementId.current, { 28 | size, 29 | interaction, 30 | max, 31 | min, 32 | mode, 33 | }); 34 | onReady(dial.current); 35 | // dial.current.colorize('accent', 'rgba(201, 249, 255, 0.7)'); 36 | dial.current.colorize('accent', '#c1c1c1'); 37 | dial.current.colorize('fill', '#333'); 38 | dial.current.on('change', (newState) => { 39 | onChange(newState); 40 | }); 41 | return () => { 42 | dial.current.destroy(); 43 | }; 44 | }, []); 45 | 46 | useEffect(() => { 47 | if (dial.current === null) return; 48 | if (!Array.isArray(size)) { 49 | return; 50 | } 51 | dial.current.resize(...size); 52 | }, [size]); 53 | 54 | useEffect(() => { 55 | if (dial.current === null) return; 56 | if (value === undefined) return; 57 | 58 | dial.current.value = value; 59 | }, [value]); 60 | 61 | useEffect(() => { 62 | if (dial.current === null) return; 63 | if (min === undefined) return; 64 | dial.current.min = min; 65 | }, [min]); 66 | 67 | useEffect(() => { 68 | if (dial.current === null) return; 69 | if (max === undefined) return; 70 | dial.current.max = max; 71 | }, [max]); 72 | 73 | useEffect(() => { 74 | if (dial.current === null) return; 75 | if (interaction === undefined) return; 76 | dial.current.interaction = interaction; 77 | }, [interaction]); 78 | return
    ; 79 | }); 80 | 81 | export default Dial; 82 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | mode: process.env.NODE_ENV, 7 | entry: './src/client/index.js', 8 | 9 | // Enable sourcemaps for debugging webpack's output. 10 | devtool: 'source-map', 11 | 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | publicPath: './', 15 | filename: 'bundle.js', 16 | }, 17 | 18 | devServer: { 19 | host: 'localhost', 20 | port: 8080, 21 | // match the output path 22 | contentBase: path.resolve(__dirname, 'dist'), 23 | // enable HMR on the devServer 24 | hot: true, 25 | // match the output 'publicPath' 26 | publicPath: '/', 27 | // fallback to root for other urls 28 | historyApiFallback: true, 29 | }, 30 | 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.(ts|js)x?$/, 35 | include: [path.resolve(__dirname, './src')], 36 | use: { 37 | loader: 'babel-loader', 38 | options: { 39 | presets: [ 40 | '@babel/preset-env', 41 | '@babel/preset-react', 42 | // "@babel/preset-typescript", 43 | ], 44 | plugins: [ 45 | '@babel/transform-runtime', 46 | ], 47 | }, 48 | }, 49 | }, 50 | { 51 | test: /\.s[ac]ss$/i, 52 | use: ['style-loader', 'css-loader', 'sass-loader'], 53 | }, 54 | { 55 | test: /\.(mp3|wav)$/, 56 | use: { 57 | loader: 'file-loader', 58 | options: { 59 | name: '[name].[contenthash].[ext]', 60 | outputPath: 'assets/audio/', 61 | publicPath: 'assets/audio/', 62 | }, 63 | }, 64 | }, 65 | { 66 | test: /\.(png|svg|jpg|gif)$/, 67 | use: ['file-loader'], 68 | }, 69 | ], 70 | }, 71 | resolve: { 72 | extensions: ['.tsx', '.ts', '.js', '.jsx'], 73 | }, 74 | plugins: [ 75 | new HtmlWebpackPlugin({ 76 | title: 'stepSeq', 77 | favicon: 'public/favicon-32x32.png', 78 | }), 79 | ], 80 | }; 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | package-lock.json 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | -------------------------------------------------------------------------------- /src/client/components/KnobPanel.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React, { useState, useCallback } from 'react'; 3 | import styled from 'styled-components'; 4 | // import { Dial } from 'react-nexusui'; 5 | import Dial from './Knob'; 6 | 7 | const Knobs = React.memo(({ 8 | k1Val, k2Val, k3Val, k4Val, 9 | handleK1Change, handleK2Change, handleK3Change, handleK4Change, 10 | }) => { 11 | // console.log('Knobs, k1Val: ', k1Val, 'k2Val', k2Val); 12 | 13 | return ( 14 | 15 | 16 | 26 | 27 | 28 | 38 | 39 | 40 | 50 | 51 | 52 | 62 | 63 | 64 | ); 65 | }); 66 | 67 | const KnobPanelWrapper = styled.div` 68 | display: flex; 69 | flex-direction: column; 70 | padding: 24px 0 24px 24px; 71 | border-left: 1px solid #444444; 72 | border-right: 1px solid #444444; 73 | border-bottom: 1px solid #444444; 74 | `; 75 | 76 | const DialWrapper = styled.div` 77 | margin: ${(props) => (props.alignRight ? '0px 0px 0px 40px' : '0px 0px 0px 0px')}; 78 | `; 79 | 80 | export default Knobs; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stepseq", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "webpack.config.js", 6 | "scripts": { 7 | "start": "cross-env NODE_ENV=production node src/server/server.js", 8 | "build": "cross-env NODE_ENV=production webpack", 9 | "dev": "cross-env NODE_ENV=development webpack-dev-server --open & nodemon src/server/server.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/csAudioApps/stepSeq.git" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/csAudioApps/stepSeq/issues" 20 | }, 21 | "homepage": "https://github.com/csAudioApps/stepSeq#readme", 22 | "dependencies": { 23 | "@babel/plugin-transform-runtime": "^7.10.5", 24 | "@babel/runtime": "^7.10.5", 25 | "dompurify": "^2.0.12", 26 | "express": "^4.17.1", 27 | "nexusui": "^2.1.4", 28 | "prop-types": "^15.7.2", 29 | "react": "^16.13.1", 30 | "react-dom": "^16.13.1", 31 | "regenerator-runtime": "^0.13.7", 32 | "socket.io": "^2.3.0", 33 | "standardized-audio-context": "^24.1.22", 34 | "styled-components": "^5.2.0", 35 | "tone": "^14.7.32" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.10.5", 39 | "@babel/preset-env": "^7.10.4", 40 | "@babel/preset-react": "^7.10.4", 41 | "@babel/preset-typescript": "^7.10.4", 42 | "@types/react": "^16.9.43", 43 | "@types/react-dom": "^16.9.8", 44 | "babel-loader": "^8.1.0", 45 | "cross-env": "^7.0.2", 46 | "css-loader": "^3.6.0", 47 | "eslint": "^7.2.0", 48 | "eslint-config-airbnb": "^18.2.0", 49 | "eslint-plugin-import": "^2.22.0", 50 | "eslint-plugin-jsx-a11y": "^6.3.1", 51 | "eslint-plugin-react": "^7.20.6", 52 | "eslint-plugin-react-hooks": "^4.1.0", 53 | "file-loader": "^6.0.0", 54 | "html-webpack-plugin": "^4.3.0", 55 | "node-sass": "^4.14.1", 56 | "nodemon": "^2.0.4", 57 | "sass-loader": "^9.0.2", 58 | "source-map-loader": "^1.0.1", 59 | "style-loader": "^1.2.1", 60 | "ts-loader": "^8.0.1", 61 | "ts-node": "^8.10.2", 62 | "tslint": "^6.1.2", 63 | "typescript": "^3.9.7", 64 | "webpack": "^4.43.0", 65 | "webpack-cli": "^3.3.12", 66 | "webpack-dev-server": "^3.11.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /sampleData.js: -------------------------------------------------------------------------------- 1 | 2 | state = { 3 | // global state 4 | instruments = [ 5 | { name: "Drums", soundPreset: "BasicDrumset", mono: null, legato: false, grid: 6 | [ 7 | [0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0], 8 | [0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0], 9 | [0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0], 10 | [0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0], 11 | [0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0], 12 | [1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0], 13 | [1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0], 14 | [1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0], 15 | ], 16 | }, 17 | 18 | { 19 | name: "Bass", soundPreset: "ClassicBassSynth", mono: true, legato: true, grid: 20 | [ [2], [3], [4], [], [0], [], [], [], [], [2], [], [0], [], [0], [1], [2] ] 21 | }, 22 | { name: "Synth1", soundPreset: "SpaceySynth", mono: false, legato: false, grid: 23 | [ [2, 5], [3, 6], [4, 6], [], [0, 6], [], [], [], [], [2, 5], [], [0, 6], [], [0], [1, 3], [2, 5] ] 24 | }, 25 | ], 26 | 27 | status = { 28 | secsRemaining: 257, 29 | bpm: 120, 30 | isPlaying: true, 31 | }, 32 | 33 | users = [ 34 | { userId: 1, userName: 'Tom L', instrumentSelected: 2, color: 'blue'}, 35 | { userId: 2, userName: 'Matt Gin', instrumentSelected: 2, color: 'green'}, 36 | ], 37 | 38 | localState = { 39 | localUserId: 1, 40 | localScale: 0, 41 | // localOctaveStart: 'C2' 42 | } 43 | } 44 | 45 | const scales = [ 46 | ['C', 'D', 'E', 'F', 'G', 'A', 'B'], 47 | ['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb'], 48 | ['C', 'Db', 'Eb', 'F', 'G', 'Ab', 'B'], 49 | ['C', 'D', 'E', 'F#', 'G', 'A', 'B'], 50 | ['C', 'D', 'E', 'F', 'G', 'A', 'Bb'], 51 | ['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb'], 52 | ['C', 'Db', 'Eb', 'F', 'Gb', 'Aa', 'Bb'], 53 | ], 54 | 55 | 56 | // { name: "Bass", soundPreset: "ClassicBassSynth", mono: true, legato: true, grid: 57 | // [ 58 | // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 59 | // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 60 | // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 61 | // [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 62 | // [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 63 | // [1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], 64 | // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1], 65 | // [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0], 66 | // ], -------------------------------------------------------------------------------- /src/client/components/InstrumentColumn.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/control-has-associated-label */ 2 | /* eslint-disable jsx-a11y/click-events-have-key-events */ 3 | /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ 4 | import PropTypes from 'prop-types'; 5 | import React from 'react'; 6 | import styled from 'styled-components'; 7 | import * as reducerConstants from '../reducer/reducerConstants'; 8 | 9 | const InstrumentColumn = React.memo(({ 10 | instruments, dispatch, localUserId, selectedInstr, 11 | }) => ( 12 | 13 |
      14 | { 15 | instruments 16 | ? instruments.map((item, i) => ( 17 | 18 | {i + 1} 19 |
      20 | dispatch({ 25 | type: reducerConstants.SET_SELECTED_INSTRUMENT, 26 | payload: { localUserId, instrumentSelected: i }, 27 | })} 28 | > 29 | {instruments[i].name} 30 | 31 |
      32 | { selectedInstr === i ? : null } 33 |
      34 | )) 35 | :

      Loading...

      36 | } 37 |
    38 |
    39 | )); 40 | 41 | InstrumentColumn.propTypes = { 42 | instruments: PropTypes.arrayOf(PropTypes.shape({ 43 | name: PropTypes.string.isRequired, 44 | soundPreset: PropTypes.string, 45 | mono: PropTypes.bool, 46 | legato: PropTypes.bool, 47 | grid: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)).isRequired, 48 | })).isRequired, 49 | dispatch: PropTypes.func.isRequired, 50 | localUserId: PropTypes.string.isRequired, 51 | selectedInstr: PropTypes.number.isRequired, 52 | }; 53 | 54 | const InstrumentColumnWrapper = styled.div` 55 | display: flex; 56 | flex-direction: column; 57 | padding: 24px 20px 0 20px; 58 | width: 16em; 59 | flex-grow: 2; 60 | border-left: 1px solid #444444; 61 | border-right: 1px solid #444444; 62 | border-bottom: 1px solid #444444; 63 | `; 64 | 65 | const InstrumentItemWrapper = styled.div` 66 | display: flex; 67 | `; 68 | 69 | const InstrumentNumber = styled.span` 70 | color: grey; 71 | margin: auto 0.6em auto 0; 72 | font-size: 0.7em; 73 | `; 74 | 75 | const UserColorSwatch = styled.span` 76 | background-color: #5dfdcb; 77 | height: 12px; 78 | width: 12px; 79 | border-radius: 50%; 80 | display: inline-block; 81 | margin: auto 0; 82 | margin-left: auto; 83 | `; 84 | 85 | const InstrumentButton = styled.button` 86 | cursor: pointer; 87 | margin: 10px 0px; 88 | background-color: transparent; 89 | font-weight: 200; 90 | font-size: 0.85em; 91 | letter-spacing: 0.25em; 92 | color: #bbbbbb; 93 | ${'' /* font-weight: ${(props) => (props.isSelected ? 700 : 200)}; */} 94 | `; 95 | 96 | export default InstrumentColumn; 97 | -------------------------------------------------------------------------------- /src/client/components/InlineEdit.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable object-curly-newline */ 2 | /* eslint-disable jsx-a11y/click-events-have-key-events */ 3 | /* eslint-disable jsx-a11y/no-static-element-interactions */ 4 | import React, { useState, useEffect, useRef, useCallback } from 'react'; 5 | import { func, string } from 'prop-types'; 6 | import DOMPurify from 'dompurify'; 7 | import styled from 'styled-components'; 8 | import useKeypress from '../hooks/useKeypress'; 9 | import useOnClickOutside from '../hooks/useOnClickOutside'; 10 | 11 | const InlineEdit = ({ text, onSetText }) => { 12 | // console.log('InlineEdit -> text', text); 13 | // console.log('InlineEdit -> onSetText', onSetText); 14 | const [isInputActive, setIsInputActive] = useState(false); 15 | const [inputValue, setInputValue] = useState(text); 16 | 17 | const wrapperRef = useRef(null); 18 | const textRef = useRef(null); 19 | const inputRef = useRef(null); 20 | 21 | const enter = useKeypress('Enter'); 22 | const esc = useKeypress('Escape'); 23 | 24 | // check to see if the user clicked outside of this component 25 | useOnClickOutside(wrapperRef, () => { 26 | // console.log('OUTSIDE CLICK'); 27 | if (isInputActive) { 28 | onSetText(inputValue); 29 | setIsInputActive(false); 30 | } 31 | }); 32 | 33 | const onEnter = useCallback(() => { 34 | // console.log('ENTER'); 35 | if (enter) { 36 | onSetText(inputValue); 37 | setIsInputActive(false); 38 | } 39 | }, [enter, inputValue, onSetText]); 40 | 41 | const onEsc = useCallback(() => { 42 | // console.log('ESCAPE'); 43 | if (esc) { 44 | setInputValue(text); 45 | setIsInputActive(false); 46 | } 47 | }, [esc, text]); 48 | 49 | // focus the cursor in the input field on edit start 50 | useEffect(() => { 51 | if (isInputActive) { 52 | inputRef.current.focus(); 53 | } 54 | }, [isInputActive]); 55 | 56 | useEffect(() => { 57 | if (isInputActive) { 58 | // if Enter is pressed, save the text and close the editor 59 | onEnter(); 60 | // if Escape is pressed, revert the text and close the editor 61 | onEsc(); 62 | } 63 | }, [onEnter, onEsc, isInputActive]); // watch the Enter and Escape key presses 64 | 65 | const handleInputChange = useCallback( 66 | (event) => { 67 | // sanitize the input a little 68 | const sanitizedText = DOMPurify.sanitize(event.target.value); 69 | // console.log('InlineEdit -> sanitizedText', sanitizedText); 70 | 71 | setInputValue(sanitizedText); 72 | }, 73 | [setInputValue], 74 | ); 75 | 76 | const handleSpanClick = useCallback(() => setIsInputActive(true), [ 77 | setIsInputActive, 78 | ]); 79 | 80 | return ( 81 | 82 | 87 | {text} 88 | 89 | 95 | 96 | ); 97 | }; 98 | 99 | export default InlineEdit; 100 | 101 | InlineEdit.propTypes = { 102 | text: string.isRequired, 103 | onSetText: func.isRequired, 104 | }; 105 | 106 | const StyledInlineEdit = styled.span` 107 | background: #222222; 108 | padding: 0.3em; 109 | margin: auto 12px auto 0.1em; 110 | width: 2.7em; 111 | `; 112 | 113 | const StyledInputInactive = styled.span` 114 | display: ${(props) => (props.isVisible ? 'inline' : 'none')}; 115 | `; 116 | 117 | const StyledActiveInput = styled.input` 118 | color: #bbbbbb; 119 | background: grey; 120 | width: 2.7em; 121 | text-align: inherit; 122 | letter-spacing: inherit; 123 | border: inherit; 124 | cursor: pointer; 125 | display: ${(props) => (props.isVisible ? 'inline' : 'none')}; 126 | `; 127 | -------------------------------------------------------------------------------- /src/client/constants/initState.js: -------------------------------------------------------------------------------- 1 | export const userInitState = { 2 | username: 'beatsmith519', instrumentSelected: 0, selectedScale: 0, color: 'red', isPlaying: false, 3 | }; 4 | 5 | export const mainInitState = { 6 | instruments: [ 7 | { 8 | name: 'Percussion Synth', 9 | soundPreset: 'BasicDrumset', 10 | mono: null, 11 | legato: false, 12 | grid: 13 | [[], [], [], [], [], [], [], [], [], [], [], [], [], [], [], []], 14 | }, 15 | { 16 | name: 'Classic 808', 17 | soundPreset: 'BasicDrumset', 18 | mono: null, 19 | legato: false, 20 | grid: 21 | [[], [], [], [], [], [], [], [], [], [], [], [], [], [], [], []], 22 | }, 23 | { 24 | name: 'Monobass', 25 | soundPreset: 'ClassicBassSynth', 26 | mono: true, 27 | legato: true, 28 | grid: 29 | [[], [], [], [], [], [], [], [], [], [], [], [], [], [], [], []], 30 | }, 31 | { 32 | name: 'Spacey Poly', 33 | soundPreset: 'SpaceySynth', 34 | mono: false, 35 | legato: false, 36 | grid: 37 | [[], [], [], [], [], [], [], [], [], [], [], [], [], [], [], []], 38 | }, 39 | { 40 | name: 'Meatwave', 41 | soundPreset: 'Meatwave', 42 | mono: false, 43 | legato: false, 44 | grid: 45 | [[], [], [], [], [], [], [], [], [], [], [], [], [], [], [], []], 46 | }, 47 | ], 48 | 49 | status: { 50 | secsRemaining: 300, 51 | tempo: 120, 52 | }, 53 | 54 | users: { 55 | 56 | }, 57 | 58 | local: { 59 | localUserId: '12345', 60 | seqLen: 16, 61 | // localOctaveStart: 'C2' 62 | }, 63 | }; 64 | 65 | // export default mainInitState; 66 | 67 | // eslint-disable-next-line no-unused-vars 68 | // const exampleState = { 69 | // instruments: [ 70 | // { 71 | // name: 'Drums', 72 | // soundPreset: 'BasicDrumset', 73 | // mono: null, 74 | // legato: false, 75 | // grid: 76 | // [[], [], [], [], [], [], [], [], [], [], [], [], [], [], [], []], 77 | // }, 78 | // { 79 | // name: 'Bass', 80 | // soundPreset: 'ClassicBassSynth', 81 | // mono: true, 82 | // legato: true, 83 | // grid: 84 | // [[1], [1], [1], [1], [1], [1], [1], [], [], [], [], [], [], [], [], []], 85 | // }, 86 | // { 87 | // name: 'Synth1', 88 | // soundPreset: 'SpaceySynth', 89 | // mono: false, 90 | // legato: false, 91 | // grid: 92 | // [[], [], [], [], [], [1, 2, 3, 4, 5, 6], [], [], [], [], [], [], [], [], [], []], 93 | // }, 94 | // ], 95 | 96 | // status: { 97 | // secsRemaining: 300, 98 | // bpm: 120, 99 | // isPlaying: false, 100 | // }, 101 | 102 | // users: { 103 | // wsxk943KJk: { 104 | // userName: '', instrumentSelected: 2, color: 'blue', selectedScale: 0, isPlaying: true, 105 | // }, 106 | // asv543fgs: { 107 | // userName: '', instrumentSelected: 0, color: 'red', selectedScale: 0, isPlaying: true, 108 | // }, 109 | // }, 110 | 111 | // local: { 112 | // localUserId: 'wsxsk943KJk', 113 | // // localScale: 0, 114 | // seqLen: 16, 115 | // // localOctaveStart: 'C2' 116 | // }, 117 | // }; 118 | 119 | // export const initialState3 = { 120 | // instruments: [ 121 | // { 122 | // name: 'Drums', 123 | // soundPreset: 'BasicDrumset', 124 | // mono: null, 125 | // legato: false, 126 | // grid: 127 | // [[2], [5], [7], [], [], [], [4], [], [], [], [], [], [], [], [], []], 128 | // }, 129 | // { 130 | // name: 'Bass', 131 | // soundPreset: 'ClassicBassSynth', 132 | // mono: true, 133 | // legato: true, 134 | // grid: 135 | // [[], [], [], [], [], [], [], [], [], [], [], [], [], [], [], []], 136 | // }, 137 | // { 138 | // name: 'Synth1', 139 | // soundPreset: 'SpaceySynth', 140 | // mono: false, 141 | // legato: false, 142 | // grid: 143 | // [[], [], [], [], [], [1, 2, 3, 4, 5, 6], [], [], [], [], [], [], [], [], [], []], 144 | // }, 145 | // ], 146 | 147 | // status: { 148 | // secsRemaining: 300, 149 | // bpm: 120, 150 | // isPlaying: true, 151 | // }, 152 | 153 | // users: { 154 | // wsxk943KJk: { 155 | // userName: '', instrumentSelected: 2, color: 'blue', selectedScale: 0, 156 | // }, 157 | // asv543fgs: { 158 | // userName: '', instrumentSelected: 0, color: 'red', selectedScale: 0, 159 | // }, 160 | // }, 161 | 162 | // local: { 163 | // localUserId: 'wsxk943KJk', 164 | // // localScale: 0, 165 | // seqLen: 16, 166 | // // localOctaveStart: 'C2' 167 | // }, 168 | // }; 169 | -------------------------------------------------------------------------------- /src/client/reducer/reducer.jsx: -------------------------------------------------------------------------------- 1 | import * as Tone from 'tone'; 2 | import * as reducerConstants from './reducerConstants'; 3 | import { socket } from '../helpers/socket'; 4 | import scales from '../constants/scales'; 5 | 6 | const reducer = (state, action) => { 7 | // console.log('reducing, state: ', state); 8 | // console.log('reducing, action: ', action); 9 | let newState; 10 | let instrumentSelected; 11 | // console.log('action.type: ', action.type); 12 | switch (action.type) { 13 | case reducerConstants.SET_LOCAL_USERID: { 14 | return { 15 | ...state, 16 | local: { 17 | ...state.local, 18 | localUserId: action.payload, 19 | }, 20 | }; 21 | } 22 | // 23 | case reducerConstants.SET_NEW_USER: { 24 | return { 25 | ...state, 26 | }; 27 | } 28 | // payload = state object 29 | case reducerConstants.SET_STATE_FROM_SOCKET: { 30 | return { 31 | ...action.payload, 32 | local: state.local, 33 | }; 34 | } 35 | // payload = user object {string: {username, instrument...}} 36 | case reducerConstants.ADD_USER: { 37 | newState = { 38 | ...state, 39 | users: { 40 | ...state.users, 41 | ...action.payload, 42 | }, 43 | local: { 44 | ...state.local, 45 | localUserId: Object.keys(action.payload)[0], 46 | }, 47 | }; 48 | socket.emit('updateServerState', newState, socket.id); 49 | return newState; 50 | } 51 | // payload = userId: string 52 | case reducerConstants.REMOVE_USER: { 53 | const newUsers = { ...state.users }; 54 | delete newUsers[action.payload]; 55 | 56 | return { 57 | ...state, 58 | users: newUsers, 59 | }; 60 | } 61 | // payoad: object with x and y coordinates of grid button: {x: number, y: number} 62 | case reducerConstants.TOGGLE_GRID_BUTTON: { 63 | // make copy of grid at user's selected instrument 64 | instrumentSelected = state.users[state.local.localUserId].instrumentSelected; 65 | const newGrid = [...state.instruments[instrumentSelected].grid]; 66 | 67 | // if value is included in grid, remove, else add it and sort array 68 | const currentColumn = newGrid[action.payload.x]; 69 | const newColumn = currentColumn.includes(action.payload.y) 70 | ? currentColumn.filter((num) => num !== action.payload.y) 71 | : currentColumn.concat(action.payload.y).sort((a, b) => a - b); 72 | 73 | // set column into new grid 74 | newGrid[action.payload.x] = newColumn; 75 | 76 | // set new grid into instrument list 77 | const newInstruments = [...state.instruments]; 78 | newInstruments[instrumentSelected].grid = newGrid; 79 | 80 | // return state with new intrument list 81 | newState = { 82 | ...state, 83 | instruments: newInstruments, 84 | }; 85 | socket.emit('updateServerState', newState, socket.id); 86 | return newState; 87 | } 88 | 89 | case reducerConstants.TOGGLE_PLAY_STATE: { 90 | // const isCurrentlyPlaying = state.users[action.payload.localUserId].isPlaying; 91 | // console.log('Tone.Context.'); 92 | // if (!isCurrentlyPlaying) { 93 | // Tone.Transport.start(); 94 | // } 95 | // else { 96 | // Tone.Transport.toggle(); 97 | // // Tone.Transport.pause(); 98 | // } 99 | 100 | newState = { 101 | ...state, 102 | users: { 103 | ...state.users, 104 | [action.payload.localUserId]: { 105 | ...state.users[action.payload.localUserId], 106 | isPlaying: !state.users[action.payload.localUserId].isPlaying, 107 | }, 108 | }, 109 | }; 110 | socket.emit('updateServerState', newState, socket.id); 111 | return newState; 112 | } 113 | 114 | case reducerConstants.SET_SELECTED_INSTRUMENT: { 115 | let numSelected = action.payload.instrumentSelected; 116 | if (numSelected < state.instruments.length) { 117 | numSelected = action.payload.instrumentSelected; 118 | } 119 | else { 120 | return state; 121 | } 122 | 123 | newState = { 124 | ...state, 125 | users: { 126 | ...state.users, 127 | [action.payload.localUserId]: { 128 | ...state.users[action.payload.localUserId], 129 | instrumentSelected: numSelected, 130 | }, 131 | }, 132 | }; 133 | socket.emit('updateServerState', newState, socket.id); 134 | return newState; 135 | } 136 | case reducerConstants.SET_SELECTED_SCALE: { 137 | console.log('in reducer->set selected scale'); 138 | console.log(action.payload); 139 | let numSelected = action.payload.selectedScale; 140 | if (numSelected < scales.length) { 141 | numSelected = action.payload.selectedScale; 142 | } 143 | else { 144 | return state; 145 | } 146 | 147 | newState = { 148 | ...state, 149 | users: { 150 | ...state.users, 151 | [action.payload.localUserId]: { 152 | ...state.users[action.payload.localUserId], 153 | selectedScale: action.payload.selectedScale, 154 | }, 155 | }, 156 | }; 157 | console.log('reducer -> set selected scale', newState); 158 | 159 | // TODO This is gonna break - other users need to know what scale you're using 160 | socket.emit('updateServerState', newState, socket.id); 161 | return newState; 162 | } 163 | 164 | case reducerConstants.SET_TEMPO: { 165 | Tone.Transport.bpm.value = action.payload.newTempo; 166 | console.log('reducer -> Tone.Transport.bpm.value', Tone.Transport.bpm.value); 167 | newState = { 168 | ...state, 169 | status: { 170 | tempo: action.payload.newTempo, 171 | }, 172 | }; 173 | socket.emit('updateServerState', newState, socket.id); 174 | return newState; 175 | } 176 | 177 | default: { 178 | throw new Error('Error: invalid reducer action type'); 179 | } 180 | } 181 | }; 182 | 183 | export default reducer; 184 | -------------------------------------------------------------------------------- /src/client/containers/MainContainer.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable object-curly-newline */ 2 | /* eslint-disable no-multi-spaces */ 3 | /* eslint-disable consistent-return */ 4 | import React, { useState, useEffect, useRef, useReducer, useCallback } from 'react'; 5 | import * as Tone from 'tone'; 6 | import styled from 'styled-components'; 7 | import HeaderContainer from './HeaderContainer'; 8 | import ControlBar from '../components/ControlBar'; 9 | import KnobPanel from '../components/KnobPanel'; 10 | import InstrumentColumn from '../components/InstrumentColumn'; 11 | import Board from '../components/Board'; 12 | import Footer from '../components/Footer'; 13 | import { socket } from '../helpers/socket'; 14 | import reducer from '../reducer/reducer'; 15 | import * as types from '../reducer/reducerConstants'; 16 | import { updateNoteArray, toggleToneTransport } from '../helpers/audioHelpers'; 17 | import { mainInitState, userInitState } from '../constants/initState'; 18 | import { soundPresets } from '../constants/soundPresets'; 19 | 20 | const MainContainer = () => { 21 | const [state, dispatch] = useReducer(reducer, mainInitState); 22 | const [step, setStep] = useState(0); 23 | // const [position, setPosition] = useState('0:0:0'); 24 | 25 | const { users, local, instruments } = state; 26 | const { localUserId } = local; 27 | const selectedScale = (users[localUserId]) ? users[localUserId].selectedScale : 0; 28 | const selectedInstr = (users[localUserId]) ? users[localUserId].instrumentSelected : 1; 29 | const username = (users[localUserId]) ? users[localUserId].username : ''; 30 | const gridForCurInstr = (users[localUserId]) ? instruments[selectedInstr].grid : [[]]; 31 | const isPlaying = (users[localUserId]) ? users[localUserId].isPlaying : false; 32 | 33 | const transport = useRef(null); 34 | const bassSynth = useRef(null); 35 | const drumSynth = useRef(null); 36 | const dly = useRef(null); 37 | const dist = useRef(null); 38 | 39 | const [k1Val, setk1Val] = useState(0.7); 40 | const [k2Val, setk2Val] = useState(0.5); 41 | const [k3Val, setk3Val] = useState(0.6); 42 | const [k4Val, setk4Val] = useState(0.5); 43 | 44 | // BASS VOLUME KNOB 45 | const handleK1Change = useCallback((val) => { setk1Val(val); }, []); 46 | useEffect(() => { 47 | if (bassSynth && bassSynth.current) { 48 | bassSynth.current.volume.value = (Math.log(k1Val) * 8).toFixed(2); 49 | } 50 | }, [k1Val]); 51 | 52 | // BASS DISTORTION KNOB 53 | const handleK2Change = useCallback((val) => { setk2Val(val); }, []); 54 | useEffect(() => { if (dist && dist.current) { dist.current.distortion = k2Val; } }, [k2Val]); 55 | 56 | // BASS DELAY FEEDBACK KNOB 57 | const handleK3Change = useCallback((val) => { setk3Val(val); }, []); 58 | useEffect(() => { if (dly && dly.current) { dly.current.feedback.value = k3Val; } }, [k3Val]); 59 | 60 | // BASS DELAY MIX KNOB 61 | const handleK4Change = useCallback((val) => { setk4Val(val); }, []); 62 | useEffect(() => { if (dly && dly.current) { dly.current.wet.value = k4Val; } }, [k4Val]); 63 | 64 | // open socket connection 65 | useEffect(() => { 66 | // get initial state 67 | console.log('socket: ', socket); 68 | socket.emit('getInitialState'); 69 | socket.on('updateClientState', (msg, senderId) => { 70 | if (socket.id !== senderId) { 71 | dispatch({ type: types.SET_STATE_FROM_SOCKET, payload: msg }); 72 | } 73 | else { 74 | // console.log('they are equal') 75 | } 76 | }); 77 | return () => socket.disconnect(); 78 | }, []); 79 | 80 | // Add User 81 | useEffect(() => { 82 | if (!Object.keys(users).includes(localUserId)) { 83 | dispatch({ type: types.ADD_USER, payload: { [localUserId]: userInitState } }); 84 | } 85 | }, [users, localUserId]); 86 | 87 | // Initial (one-time) Tone and Tranpost Setup 88 | useEffect(() => { 89 | Tone.Context.latencyHint = 'playback'; 90 | Tone.Transport.bpm.value = state.status.tempo ? state.status.tempo : 120; 91 | transport.current = new Tone.Sequence((time, curStep) => { 92 | setStep(curStep); 93 | }, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], '8n').start(0); 94 | 95 | return () => { 96 | transport.current.dispose(); 97 | }; 98 | }, []); 99 | 100 | // Set up Bass synth 101 | useEffect(() => { 102 | dly.current = new Tone.FeedbackDelay('8n', k3Val).toDestination(); 103 | dly.current.wet.value = k4Val; 104 | dist.current = new Tone.Distortion(k2Val).connect(dly.current); 105 | bassSynth.current = new Tone.Synth({ volume: k1Val }).connect(dist.current); 106 | 107 | return () => { 108 | bassSynth.current.dispose(); 109 | dist.current.dispose(); 110 | dly.current.dispose(); 111 | }; 112 | }, []); 113 | 114 | // Set up Percussion synth 115 | useEffect(() => { 116 | drumSynth.current = new Tone.MembraneSynth({ volume: -12 }).toDestination(); 117 | return () => { drumSynth.current.dispose(); }; 118 | }, []); 119 | 120 | // Set up and handle changes to SEQUENCE for Bass 121 | useEffect(() => { 122 | if (bassSynth && bassSynth.current) { 123 | const bassNoteArr = updateNoteArray(instruments[2].grid, selectedScale, 2); 124 | const bassSynthSeq = new Tone.Sequence((time, note) => { 125 | bassSynth.current.triggerAttackRelease(note, '8n', time); 126 | }, bassNoteArr).start(0); 127 | 128 | return () => bassSynthSeq.dispose(); 129 | } 130 | return null; 131 | }, [instruments, selectedScale]); 132 | 133 | // Set up and handle changes to SEQUENCE for Percussion Synth 134 | useEffect(() => { 135 | if (drumSynth && drumSynth.current) { 136 | const drumNoteArr = updateNoteArray(instruments[0].grid, selectedScale, 0); 137 | const drumSynthSeq = new Tone.Sequence((time, note) => { 138 | drumSynth.current.triggerAttackRelease(note, '8n', time); 139 | }, drumNoteArr).start(0); 140 | 141 | return () => drumSynthSeq.dispose(); 142 | } 143 | return null; 144 | }, [instruments, selectedScale]); 145 | 146 | // If user changes tempo, update delayTime 147 | useEffect(() => { 148 | console.log('in useEffect delay update'); 149 | dly.current.delayTime.value = '8n'; 150 | }, [state.status.tempo]); 151 | 152 | const handleUserKeyPress = useCallback((event) => { 153 | const { code, altKey } = event; 154 | switch (code) { 155 | case 'Space': { 156 | if (toggleToneTransport()) { 157 | dispatch({ type: types.TOGGLE_PLAY_STATE, payload: { localUserId } }); 158 | } 159 | break; 160 | } 161 | case 'Digit1': 162 | case 'Digit2': 163 | case 'Digit3': 164 | case 'Digit4': 165 | case 'Digit5': 166 | case 'Digit6': 167 | case 'Digit7': 168 | case 'Digit8': 169 | case 'Digit9': 170 | case 'Digit0': { 171 | const lastDigit = Number(code[code.length - 1]); 172 | const selectedIndex = lastDigit === 0 ? 10 : lastDigit - 1; 173 | if (altKey === true) { // Numbers + alt key change scale 174 | dispatch({ 175 | type: types.SET_SELECTED_SCALE, 176 | payload: { localUserId, selectedScale: selectedIndex }, 177 | }); 178 | } 179 | else { // Numbers alone change instrument 180 | dispatch({ 181 | type: types.SET_SELECTED_INSTRUMENT, 182 | payload: { localUserId, instrumentSelected: selectedIndex }, 183 | }); 184 | } 185 | break; 186 | } 187 | default: 188 | break; 189 | } 190 | }, [localUserId]); 191 | 192 | // Add keyboard event listener 193 | useEffect(() => { 194 | window.addEventListener('keydown', handleUserKeyPress); 195 | return () => { window.removeEventListener('keydown', handleUserKeyPress); }; 196 | }, [handleUserKeyPress]); 197 | 198 | return ( 199 | 200 | 204 | 205 | 212 | 213 | 214 | 224 | 230 | 231 | 238 | 239 | 240 |