├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicons │ ├── android-chrome-192x192.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── index.html ├── robots.txt └── scaler-preview.png └── src ├── components ├── AddTrack │ ├── AddTrack.js │ └── AddTrack.module.css ├── App │ ├── App.js │ └── App.module.css ├── Chart │ ├── Chart.js │ └── Chart.module.css ├── DropdownMenu │ ├── DropdownMenu.js │ └── DropdownMenu.module.css ├── EnabledNotes │ ├── EnabledNotes.js │ └── EnabledNotes.module.css ├── Header │ ├── Header.js │ └── Header.module.css ├── Load │ ├── Load.js │ └── Load.module.css ├── LoopOptions │ ├── LoopOptions.js │ └── LoopOptions.module.css ├── More │ ├── More.js │ └── More.module.css ├── Play │ ├── Play.js │ └── Play.module.css ├── Save │ ├── Save.js │ └── Save.module.css ├── TimelineArm │ ├── TimelineArm.js │ └── TimelineArm.module.css └── TrackOptions │ ├── TrackOptions.js │ └── TrackOptions.module.css ├── constants ├── colors.js ├── notes.js └── scales.js ├── index.css ├── index.js └── util ├── audioPlayer.js ├── fileDownloader.js ├── formulaEvaluator.js ├── midiWriter.js ├── noteCalculator.js ├── objectCompressor.js ├── sharer.js ├── stateLoader.js └── storageManager.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alexander Crist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📼 Scaler 2 | 3 | > Scaler is a web application that allows you to play scales according to math equations like `y = sin(x)`. 4 | 5 | https://alexcrist.github.io/scaler/ 6 | 7 | ## 🪀 Features 8 | 9 | * Create and loop multiple formulas 10 | * Enable / disable particular beats 11 | * Save and share creations 12 | * Export to MIDI 13 | * Experiment with different 14 | * Note durations 15 | * Tempos 16 | * Beats per measure 17 | * Pitch ranges 18 | * Scales 19 | 20 | ## 💻 Software development 21 | 22 | To run the project locally, you'll need Node (v14.17.4) and NPM (v7.20.6). 23 | 24 | After cloning or downloading the code, install the project's dependencies with `npm install`. 25 | 26 | From there, you can run the project by starting the development server with `npm run start`. 27 | 28 | The project can be automatically deployed to GitHub with `npm run deploy`. 29 | 30 | ## 🔭 Future improvements 31 | 32 | * Add more scales 33 | * Allow note duration to be a function 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scaler", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://alexcrist.github.io/scaler", 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^5.11.4", 8 | "@testing-library/react": "^11.1.0", 9 | "@testing-library/user-event": "^12.1.10", 10 | "gh-pages": "^3.2.3", 11 | "gsap": "^3.8.0", 12 | "lodash": "^4.17.21", 13 | "lz-string": "^1.4.4", 14 | "mathjs": "^9.5.0", 15 | "midi-writer-js": "^2.0.1", 16 | "react": "^17.0.2", 17 | "react-dom": "^17.0.2", 18 | "react-icons": "^4.3.1", 19 | "react-scripts": "4.0.3", 20 | "recharts": "^2.1.4", 21 | "web-vitals": "^1.0.1" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject", 28 | "predeploy": "npm run build", 29 | "deploy": "gh-pages -d build" 30 | }, 31 | "eslintConfig": { 32 | "extends": [ 33 | "react-app", 34 | "react-app/jest" 35 | ] 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcrist/scaler/fd6d5b24f9448f10db31a1c5483d1d3229f6c33e/public/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcrist/scaler/fd6d5b24f9448f10db31a1c5483d1d3229f6c33e/public/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcrist/scaler/fd6d5b24f9448f10db31a1c5483d1d3229f6c33e/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcrist/scaler/fd6d5b24f9448f10db31a1c5483d1d3229f6c33e/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcrist/scaler/fd6d5b24f9448f10db31a1c5483d1d3229f6c33e/public/favicons/favicon.ico -------------------------------------------------------------------------------- /public/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcrist/scaler/fd6d5b24f9448f10db31a1c5483d1d3229f6c33e/public/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /public/favicons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/favicons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Scaler", 3 | "short_name": "Scaler", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Scaler 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/scaler-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcrist/scaler/fd6d5b24f9448f10db31a1c5483d1d3229f6c33e/public/scaler-preview.png -------------------------------------------------------------------------------- /src/components/AddTrack/AddTrack.js: -------------------------------------------------------------------------------- 1 | import { COLORS, GRAY_1, OPACITY_1 } from '../../constants/colors'; 2 | import styles from './AddTrack.module.css'; 3 | 4 | const AddTrack = ({ tracks, setTracks }) => { 5 | 6 | const addTrack = () => { 7 | const lastColor = tracks[tracks.length - 1].color; 8 | const lastColorIndex = COLORS.indexOf(lastColor); 9 | const newColorIndex = (lastColorIndex + 1) % COLORS.length; 10 | const newColor = COLORS[newColorIndex]; 11 | const newTrack = { 12 | color: newColor, 13 | formula: 'y = 0', 14 | noteDuration: 100, 15 | disabledBeats: [], 16 | isMuted: false 17 | }; 18 | setTracks([...tracks, newTrack]); 19 | }; 20 | 21 | return ( 22 |
30 |
31 |
32 | ); 33 | }; 34 | 35 | export default AddTrack; -------------------------------------------------------------------------------- /src/components/AddTrack/AddTrack.module.css: -------------------------------------------------------------------------------- 1 | .createTrack { 2 | border-radius: 2px; 3 | border: 2px solid; 4 | cursor: pointer; 5 | transition: opacity 300ms; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | flex: 1; 10 | } 11 | 12 | .createTrack:hover { 13 | opacity: 0.7; 14 | } 15 | 16 | .createTrackText::before { 17 | content: 'Add track'; 18 | font-size: 15px; 19 | color: white; 20 | text-align: center; 21 | } 22 | 23 | @media screen and (max-width: 700px) { 24 | .createTrackText::before { 25 | content: 'Add'; 26 | padding: 5px 10px; 27 | } 28 | } 29 | 30 | @media screen and (max-width: 450px) { 31 | .createTrackText::before { 32 | content: 'Add track'; 33 | } 34 | } -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { useEffect, useMemo, useState } from 'react'; 3 | import { SCALES } from '../../constants/scales'; 4 | import { createAudioNodes, resetAudioNodes } from '../../util/audioPlayer'; 5 | import { calculateNotes } from '../../util/noteCalculator'; 6 | import { loadInitialState } from '../../util/stateLoader'; 7 | import { storeRecent } from '../../util/storageManager'; 8 | import AddTrack from '../AddTrack/AddTrack'; 9 | import Chart from '../Chart/Chart.js'; 10 | import EnabledNotes from '../EnabledNotes/EnabledNotes'; 11 | import Header from '../Header/Header'; 12 | import Load from '../Load/Load'; 13 | import LoopOptions from '../LoopOptions/LoopOptions'; 14 | import More from '../More/More'; 15 | import Play from '../Play/Play'; 16 | import Save from '../Save/Save'; 17 | import TimelineArm from '../TimelineArm/TimelineArm'; 18 | import TrackOptions from '../TrackOptions/TrackOptions.js'; 19 | import styles from './App.module.css'; 20 | 21 | const App = () => { 22 | 23 | const [tracks, setTracks] = useState([]); 24 | const [bpm, setBpm] = useState(60); 25 | const [numBeats, setNumBeats] = useState(16); 26 | const [noteRange, setNoteRange] = useState(14); 27 | const [isPlaying, setIsPlaying] = useState(false); 28 | const [scale, setScale] = useState(SCALES[3]); 29 | const [lowNote, setLowNote] = useState('E3'); 30 | 31 | // Calculate notes to play =================================================== 32 | 33 | const notes = useMemo(() => { 34 | return calculateNotes( 35 | tracks, 36 | numBeats, 37 | noteRange, 38 | scale, 39 | lowNote 40 | ); 41 | }, [tracks, numBeats, noteRange, scale, lowNote]); 42 | 43 | // On page load ============================================================== 44 | 45 | useEffect(() => { 46 | const { 47 | tracks, 48 | bpm, 49 | numBeats, 50 | noteRange, 51 | scale, 52 | lowNote 53 | } = loadInitialState(); 54 | setTracks(tracks); 55 | setBpm(bpm); 56 | setNumBeats(numBeats); 57 | setNoteRange(noteRange); 58 | setScale(scale); 59 | setLowNote(lowNote); 60 | }, []); 61 | 62 | // Playing and pausing audio ================================================= 63 | 64 | useEffect(() => { 65 | if (isPlaying) { 66 | createAudioNodes( 67 | tracks, 68 | notes, 69 | bpm, 70 | numBeats 71 | ); 72 | } else { 73 | resetAudioNodes(); 74 | } 75 | }, [ 76 | isPlaying, 77 | tracks, 78 | notes, 79 | bpm, 80 | numBeats 81 | ]); 82 | 83 | // Local storage ============================================================= 84 | 85 | const saveData = useMemo(() => ({ 86 | bpm, 87 | numBeats, 88 | noteRange, 89 | scale, 90 | lowNote, 91 | tracks 92 | }), [bpm, numBeats, noteRange, scale, lowNote, tracks]); 93 | 94 | useEffect(() => { 95 | window.history.pushState({}, '', window.location.origin + window.location.pathname); 96 | storeRecent(saveData); 97 | }, [saveData]); 98 | 99 | // Event handlers ============================================================ 100 | 101 | const createSetTrack = (index) => (track) => { 102 | const newTracks = _.cloneDeep(tracks); 103 | newTracks[index] = track; 104 | setTracks(newTracks); 105 | }; 106 | 107 | // Page content ============================================================== 108 | 109 | return ( 110 |
111 |
112 | 113 |
114 | 115 |
116 | 117 | {/* Left section ================================================= */} 118 |
119 |
120 | 125 | 130 |
131 | {tracks.map((track, index) => ( 132 | 138 | ))} 139 |
140 | 141 | {/* Right section ================================================ */} 142 |
143 |
144 | 148 | 152 | 153 | 161 | 165 |
166 | {tracks.map((track, index) => ( 167 | 175 | ))} 176 |
177 | 178 |
179 |
180 | 181 | 193 |
194 | ) 195 | } 196 | 197 | export default App; -------------------------------------------------------------------------------- /src/components/App/App.module.css: -------------------------------------------------------------------------------- 1 | .app { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 10px 10px; 5 | min-height: calc(100vh - 20px); 6 | justify-content: space-between; 7 | } 8 | 9 | .row { 10 | display: flex; 11 | } 12 | 13 | .left { 14 | padding-right: 20px; 15 | flex: 1; 16 | width: 0; 17 | } 18 | 19 | .chart { 20 | position: relative; 21 | width: 100%; 22 | } 23 | 24 | .right { 25 | flex: 1; 26 | padding-left: 20px; 27 | max-width: 500px; 28 | } 29 | 30 | .buttons { 31 | display: flex; 32 | margin-bottom: 30px; 33 | } 34 | 35 | .buttons > * { 36 | margin-right: 10px; 37 | } 38 | 39 | .buttons > *:last-child { 40 | margin-right: 0; 41 | } 42 | 43 | @media screen and (max-width: 450px) { 44 | .row { 45 | flex-direction: column; 46 | } 47 | 48 | .left { 49 | width: 100%; 50 | padding-right: 0; 51 | padding-bottom: 30px; 52 | } 53 | 54 | .right { 55 | width: 100%; 56 | padding-left: 0; 57 | } 58 | } -------------------------------------------------------------------------------- /src/components/Chart/Chart.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, YAxis } from 'recharts'; 3 | import { formulaToData } from '../../util/formulaEvaluator'; 4 | import styles from './Chart.module.css'; 5 | 6 | const H = 300; 7 | const dataResolution = 20; 8 | 9 | const Chart = ({ 10 | tracks, 11 | numBeats, 12 | notes, 13 | }) => { 14 | 15 | const xValues = []; 16 | const numXValues = numBeats * dataResolution; 17 | for (let i = 0; i < numXValues + 1; i++) { 18 | xValues.push(i * (2 * Math.PI) / numXValues); 19 | } 20 | 21 | let wasCapped = false; 22 | let cap = 0; 23 | const yValuesArray = tracks 24 | .map((track) => { 25 | try { 26 | const data = formulaToData(track.formula, xValues); 27 | wasCapped = wasCapped || data.wasCapped; 28 | cap = data.cap; 29 | return data.yValues; 30 | } catch (e) { 31 | return []; 32 | } 33 | }); 34 | 35 | const data = xValues.map((x, i) => ({ x, i })); 36 | 37 | for (let i = 0; i < yValuesArray.length; i++) { 38 | const name = `Track ${i + 1}`; 39 | const yValues = yValuesArray[i]; 40 | for (let j = 0; j < yValues.length; j++) { 41 | const y = yValues[j]; 42 | data[j][name] = y; 43 | } 44 | } 45 | 46 | const xLines = []; 47 | for (let i = 0; i < numBeats + 1; i++) { 48 | xLines.push( 49 | 54 | ); 55 | } 56 | 57 | const yLines = []; 58 | const yMin = Math.floor(_(yValuesArray).flatten().min()); 59 | const yMax = Math.ceil(_(yValuesArray).flatten().max()); 60 | const range = yMax - yMin; 61 | const inc = Math.max(1, 10 ** Math.floor(Math.log10(range - 1))); 62 | for (let i = yMin; i <= yMax; i += inc) { 63 | yLines.push( 64 | 69 | ); 70 | } 71 | 72 | const formatter = (_, __, properties) => { 73 | const { dataKey, payload: { i } } = properties; 74 | const trackIndex = Number(dataKey[dataKey.length - 1]) - 1; 75 | const beatIndex = Math.round(i / dataResolution); 76 | return notes[trackIndex][beatIndex]; 77 | }; 78 | 79 | return ( 80 |
81 | 82 | 85 | {xLines} 86 | {yLines} 87 | {tracks.map((track, i) => ( 88 | 97 | ))} 98 | Math.floor(label / dataResolution) + 1} 101 | /> 102 | 103 | 104 | 105 | {wasCapped 106 | ?
* y-values capped at ±{cap}
107 | : null 108 | } 109 |
110 | ); 111 | }; 112 | 113 | export default Chart; -------------------------------------------------------------------------------- /src/components/Chart/Chart.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin-bottom: 20px; 3 | } 4 | 5 | .cap { 6 | color: #999; 7 | font-size: 12px; 8 | margin-top: 10px; 9 | font-style: italic; 10 | } -------------------------------------------------------------------------------- /src/components/DropdownMenu/DropdownMenu.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import styles from './DropdownMenu.module.css'; 3 | 4 | const DropdownMenu = ({ 5 | containerRef, 6 | isVisible, 7 | setIsVisible, 8 | options 9 | }) => { 10 | 11 | // Close menu when user clicks somewhere else 12 | useEffect(() => { 13 | const handleClickOutside = (e) => { 14 | if (containerRef.current && !containerRef.current.contains(e.target)) { 15 | setIsVisible(false); 16 | } 17 | }; 18 | document.addEventListener('mousedown', handleClickOutside); 19 | return () => { 20 | document.removeEventListener('mousedown', handleClickOutside); 21 | }; 22 | }, [containerRef, setIsVisible]); 23 | 24 | const dropdownClasses = [styles.dropdown]; 25 | if (!isVisible) { 26 | dropdownClasses.push(styles.hidden); 27 | } 28 | 29 | const onClickOption = (option) => () => { 30 | setIsVisible(false); 31 | option.onClick(); 32 | }; 33 | 34 | return ( 35 |
36 | {options.map((option, index) => ( 37 |
42 | {option.label} 43 |
44 | ))} 45 |
46 | ); 47 | }; 48 | 49 | export default DropdownMenu; -------------------------------------------------------------------------------- /src/components/DropdownMenu/DropdownMenu.module.css: -------------------------------------------------------------------------------- 1 | .dropdown { 2 | background-color: #fff; 3 | border: 2px solid #858585; 4 | border-radius: 2px; 5 | position: absolute; 6 | right: 0; 7 | max-height: 300px; 8 | overflow-y: auto; 9 | overflow-x: hidden; 10 | } 11 | 12 | .hidden { 13 | display: none; 14 | } 15 | 16 | .option { 17 | min-width: 130px; 18 | max-width: 300px; 19 | cursor: pointer; 20 | padding: 8px 15px; 21 | transition: all 100ms; 22 | font-size: 15px; 23 | user-select: none; 24 | text-overflow: ellipsis; 25 | overflow: hidden; 26 | } 27 | 28 | .option:not(:last-child) { 29 | border-bottom: 1px solid #eee; 30 | } 31 | 32 | .option:hover { 33 | background-color: #eee; 34 | } -------------------------------------------------------------------------------- /src/components/EnabledNotes/EnabledNotes.js: -------------------------------------------------------------------------------- 1 | import { OPACITY_1 } from '../../constants/colors'; 2 | import styles from './EnabledNotes.module.css'; 3 | 4 | const EnabledNotes = ({ numBeats, track, setTrack }) => { 5 | 6 | if (!track) { 7 | return null; 8 | } 9 | 10 | const { color } = track; 11 | const buttonStyle = { 12 | backgroundColor: color + OPACITY_1, 13 | borderColor: color 14 | }; 15 | 16 | const buttons = []; 17 | for (let i = 0; i < numBeats; i++) { 18 | 19 | const onClick = () => { 20 | let wasEnabled = true; 21 | for (let j = 0; j < track.disabledBeats.length; j++) { 22 | if (i === track.disabledBeats[j]) { 23 | track.disabledBeats.splice(j, 1); 24 | wasEnabled = false; 25 | } 26 | } 27 | if (wasEnabled) { 28 | track.disabledBeats.push(i); 29 | } 30 | setTrack({ ...track }); 31 | }; 32 | 33 | const buttonClasses = [styles.button]; 34 | if (track.disabledBeats.includes(i)) { 35 | buttonClasses.push(styles.disabled); 36 | } 37 | 38 | buttons.push( 39 |
45 | {i + 1} 46 |
47 | ); 48 | } 49 | 50 | const buttonsClasses = [styles.buttons]; 51 | if (track.isMuted) { 52 | buttonsClasses.push(styles.muted); 53 | } 54 | 55 | return ( 56 |
57 | {buttons} 58 |
59 | ) 60 | }; 61 | 62 | export default EnabledNotes; -------------------------------------------------------------------------------- /src/components/EnabledNotes/EnabledNotes.module.css: -------------------------------------------------------------------------------- 1 | .buttons { 2 | display: flex; 3 | width: calc(100% - 8px); 4 | margin: 10px auto 0; 5 | transition: opacity 300ms; 6 | } 7 | 8 | .buttons.muted { 9 | opacity: 0.4; 10 | } 11 | 12 | .button { 13 | border-top: 2px solid; 14 | border-left: 2px solid; 15 | border-bottom: 2px solid; 16 | padding: 5px 0; 17 | flex: 1; 18 | transition: opacity 300ms; 19 | cursor: pointer; 20 | color: white; 21 | text-align: center; 22 | font-size: 13px; 23 | overflow: hidden; 24 | } 25 | 26 | .button:first-child { 27 | border-top-left-radius: 2px; 28 | border-bottom-left-radius: 2px; 29 | } 30 | 31 | .button:last-child { 32 | border-top-right-radius: 2px; 33 | border-bottom-right-radius: 2px; 34 | border-right: 2px solid; 35 | } 36 | 37 | .button.disabled { 38 | opacity: 0.5; 39 | } 40 | 41 | .button:hover { 42 | opacity: 0.85; 43 | } 44 | 45 | .button.disabled:hover { 46 | opacity: 0.4; 47 | } -------------------------------------------------------------------------------- /src/components/Header/Header.js: -------------------------------------------------------------------------------- 1 | import styles from './Header.module.css'; 2 | 3 | const Header = () => { 4 | return ( 5 |
6 |
7 | SCALER 8 |
9 |
10 | 15 | see the code 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default Header; -------------------------------------------------------------------------------- /src/components/Header/Header.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 10px; 3 | margin-bottom: 20px; 4 | border-radius: 2px; 5 | display: flex; 6 | justify-content: space-between; 7 | align-items: center; 8 | } 9 | 10 | .text { 11 | letter-spacing: 4px; 12 | color: #666; 13 | font-style: italic; 14 | font-size: 18px; 15 | } 16 | 17 | .link { 18 | color: #666; 19 | font-size: 13px; 20 | text-decoration: none; 21 | font-style: italic; 22 | transition: opacity 300ms; 23 | } 24 | 25 | .link:hover { 26 | opacity: 0.7; 27 | } 28 | 29 | .line { 30 | flex: 1; 31 | border-bottom: 2px solid #66666666; 32 | margin: 0 20px; 33 | } -------------------------------------------------------------------------------- /src/components/Load/Load.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react'; 2 | import { FaFolder } from 'react-icons/fa'; 3 | import { GRAY_1, OPACITY_1 } from '../../constants/colors'; 4 | import { loadLocalSaves } from '../../util/storageManager'; 5 | import DropdownMenu from '../DropdownMenu/DropdownMenu'; 6 | import styles from './Load.module.css'; 7 | 8 | const Load = ({ 9 | setBpm, 10 | setNumBeats, 11 | setNoteRange, 12 | setScale, 13 | setLowNote, 14 | setTracks 15 | }) => { 16 | 17 | const containerRef = useRef(null); 18 | const [isVisible, setIsVisible] = useState(false); 19 | const [options, setOptions] = useState([]); 20 | 21 | const onClickLoad = () => { 22 | setIsVisible(!isVisible); 23 | const saves = loadLocalSaves(); 24 | let newOptions = saves.map((save) => ({ 25 | label: save.name, 26 | onClick: () => { 27 | const { 28 | bpm, 29 | numBeats, 30 | noteRange, 31 | scale, 32 | lowNote, 33 | tracks 34 | } = save.data; 35 | setBpm(bpm); 36 | setNumBeats(numBeats); 37 | setNoteRange(noteRange); 38 | setScale(scale); 39 | setLowNote(lowNote); 40 | setTracks(tracks); 41 | } 42 | })); 43 | 44 | if (newOptions.length === 0) { 45 | newOptions = [{ 46 | label: 'No saves', 47 | onClick: () => {}, 48 | }] 49 | } 50 | 51 | setOptions(newOptions); 52 | }; 53 | 54 | return ( 55 |
59 |
68 |
69 | 70 |
71 |
72 | 73 | 79 |
80 | ) 81 | }; 82 | 83 | export default Load; -------------------------------------------------------------------------------- /src/components/Load/Load.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | } 4 | 5 | .load { 6 | border: 2px solid; 7 | border-radius: 2px; 8 | padding: 5px 10px; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | flex: 0; 13 | cursor: pointer; 14 | transition: opacity 300ms; 15 | } 16 | 17 | .load:hover { 18 | opacity: 0.7; 19 | } 20 | 21 | .icon { 22 | color: white; 23 | } 24 | 25 | .icon > * { 26 | width: 15px; 27 | height: 15px; 28 | margin-bottom: -3px 29 | } -------------------------------------------------------------------------------- /src/components/LoopOptions/LoopOptions.js: -------------------------------------------------------------------------------- 1 | import { update } from 'lodash'; 2 | import { GRAY_1, OPACITY_1 } from '../../constants/colors'; 3 | import { MODES, PITCHES, SCALES } from '../../constants/scales'; 4 | import styles from './LoopOptions.module.css'; 5 | 6 | const Input = (props) => { 7 | const inputStyle = { borderColor: GRAY_1 }; 8 | return ( 9 |
10 | 13 | 18 |
19 | ); 20 | }; 21 | 22 | const ScaleSelect = ({ scale, setScale, lowNote, setLowNote }) => { 23 | 24 | const [pitch, ...modeArray] = scale.name.split(' '); 25 | const mode = modeArray.join(' '); 26 | const modeNames = MODES.map((m) => m.name); 27 | 28 | const pitchIndex = PITCHES.indexOf(pitch); 29 | const modeIndex = modeNames.indexOf(mode); 30 | 31 | const updateScale = (newPitch, newMode) => { 32 | const scaleName = `${newPitch} ${newMode}`; 33 | const newScale = SCALES.filter((s) => s.name === scaleName)[0]; 34 | console.log(scaleName); 35 | const lowNoteIndex = scale.notes.indexOf(lowNote); 36 | const newLowNote = newScale.notes[lowNoteIndex]; 37 | setLowNote(newLowNote); 38 | setScale(newScale); 39 | } 40 | 41 | const onChangePitch = (e) => updateScale(PITCHES[e.target.value], mode); 42 | 43 | const onChangeMode = (e) => updateScale(pitch, modeNames[e.target.value]); 44 | 45 | return ( 46 |
47 | 50 |
51 | 66 | 81 |
82 |
83 | ) 84 | }; 85 | 86 | const LowNoteSelect = ({ scale, lowNote, setLowNote }) => { 87 | 88 | const selectedIndex = scale.notes.indexOf(lowNote); 89 | 90 | const onChange = (e) => { 91 | const newNote = scale.notes[e.target.value]; 92 | setLowNote(newNote); 93 | }; 94 | 95 | return ( 96 |
97 | 100 | 115 |
116 | ) 117 | }; 118 | 119 | const LoopOptions = ({ 120 | scale, 121 | lowNote, 122 | bpm, 123 | numBeats, 124 | noteRange, 125 | setScale, 126 | setLowNote, 127 | setBpm, 128 | setNumBeats, 129 | setNoteRange 130 | }) => { 131 | 132 | return ( 133 |
137 | setBpm(Math.min(Number(e.target.value), 999))} 142 | /> 143 | setNumBeats(Math.min(Number(e.target.value), 99))} 148 | /> 149 | setNoteRange(Math.min(Number(e.target.value), 99))} 154 | /> 155 | 160 | 166 |
167 | ); 168 | }; 169 | 170 | export default LoopOptions; -------------------------------------------------------------------------------- /src/components/LoopOptions/LoopOptions.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 10px; 3 | border: 2px solid; 4 | border-radius: 2px; 5 | display: flex; 6 | flex-wrap: wrap; 7 | align-items: flex-end; 8 | margin-top: 30px; 9 | } 10 | 11 | .inputGroup { 12 | flex: 1; 13 | margin-right: 20px; 14 | margin-bottom: 10px; 15 | min-width: 100px; 16 | } 17 | 18 | .label:first-child { 19 | margin-top: 0; 20 | } 21 | 22 | .label { 23 | color: white; 24 | } 25 | 26 | .input:disabled { 27 | opacity: 1; 28 | } 29 | 30 | .scaleInputGroup { 31 | margin-right: 0px; 32 | min-width: 200px; 33 | } 34 | 35 | .scaleSelects { 36 | display: flex; 37 | } 38 | 39 | .scaleSelects > :first-child { 40 | width: 70px; 41 | } 42 | -------------------------------------------------------------------------------- /src/components/More/More.js: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | import { FaEllipsisV } from 'react-icons/fa'; 3 | import { GRAY_1, OPACITY_1 } from '../../constants/colors'; 4 | import { downloadMidi } from '../../util/midiWriter'; 5 | import { shareUrl } from '../../util/sharer'; 6 | import DropdownMenu from '../DropdownMenu/DropdownMenu'; 7 | import styles from './More.module.css'; 8 | 9 | const More = ({ saveData, notes }) => { 10 | 11 | const moreRef = useRef(null); 12 | const [isVisible, setIsVisible] = useState(false); 13 | const onClickMore = () => setIsVisible(!isVisible); 14 | 15 | const options = [ 16 | { 17 | onClick: () => shareUrl(saveData), 18 | label: 'Share' 19 | }, 20 | { 21 | onClick: () => downloadMidi({ ...saveData, notes }), 22 | label: 'Export to MIDI' 23 | } 24 | ]; 25 | 26 | return ( 27 |
31 |
40 |
41 | 42 |
43 |
44 | 45 | 51 |
52 | ); 53 | }; 54 | 55 | export default More; -------------------------------------------------------------------------------- /src/components/More/More.module.css: -------------------------------------------------------------------------------- 1 | .moreContainer { 2 | position: relative; 3 | } 4 | 5 | .more { 6 | border: 2px solid; 7 | border-radius: 2px; 8 | padding: 5px 10px; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | flex: 0; 13 | cursor: pointer; 14 | transition: opacity 300ms; 15 | } 16 | 17 | .more:hover { 18 | opacity: 0.7; 19 | } 20 | 21 | .moreIcon { 22 | color: white; 23 | } 24 | 25 | .moreIcon > * { 26 | width: 15px; 27 | height: 15px; 28 | margin-bottom: -3px 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Play/Play.js: -------------------------------------------------------------------------------- 1 | import { FaPlay, FaStop } from 'react-icons/fa'; 2 | import styles from './Play.module.css'; 3 | 4 | const Play = ({ isPlaying, setIsPlaying }) => { 5 | 6 | const playStyle = isPlaying 7 | ? { 8 | borderColor: '#980031', 9 | backgroundColor: '#98003199' 10 | } 11 | : { 12 | borderColor: '#489900', 13 | backgroundColor: '#48990099' 14 | }; 15 | 16 | const onPlay = () => { 17 | setIsPlaying(!isPlaying); 18 | }; 19 | 20 | return ( 21 |
26 |
27 | {isPlaying ? 'Stop' : 'Play'} 28 |
29 |
30 | {isPlaying 31 | ? 32 | : 33 | } 34 |
35 |
36 | ); 37 | }; 38 | 39 | 40 | export default Play; -------------------------------------------------------------------------------- /src/components/Play/Play.module.css: -------------------------------------------------------------------------------- 1 | .play { 2 | font-size: 15px; 3 | border: 2px solid; 4 | width: var(--play-width); 5 | color: white; 6 | border-radius: 2px; 7 | cursor: pointer; 8 | text-align: center; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | transition: all 300ms; 13 | user-select: none; 14 | flex: 1; 15 | } 16 | 17 | .play:hover { 18 | opacity: 0.7; 19 | } 20 | 21 | .playIcon { 22 | display: none; 23 | padding: 5px 30px; 24 | } 25 | 26 | .playIcon > * { 27 | width: 15px; 28 | height: 15px; 29 | } 30 | 31 | @media screen and (max-width: 770px) { 32 | .play { 33 | flex: 0 34 | } 35 | 36 | .playIcon { 37 | display: flex; 38 | padding: 5px 10px; 39 | } 40 | 41 | .playText { 42 | display: none; 43 | } 44 | } -------------------------------------------------------------------------------- /src/components/Save/Save.js: -------------------------------------------------------------------------------- 1 | import { FaSave } from 'react-icons/fa'; 2 | import { GRAY_1, OPACITY_1 } from '../../constants/colors'; 3 | import { saveLocal } from '../../util/storageManager'; 4 | import styles from './Save.module.css'; 5 | 6 | const Save = ({ saveData }) => { 7 | 8 | const onSave = () => { 9 | saveLocal(saveData); 10 | }; 11 | 12 | return ( 13 |
22 |
23 | 24 |
25 |
26 | ); 27 | }; 28 | 29 | export default Save; 30 | -------------------------------------------------------------------------------- /src/components/Save/Save.module.css: -------------------------------------------------------------------------------- 1 | .save { 2 | border: 2px solid; 3 | border-radius: 2px; 4 | padding: 5px 10px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | flex: 0; 9 | cursor: pointer; 10 | transition: opacity 300ms; 11 | } 12 | 13 | .save:hover { 14 | opacity: 0.7; 15 | } 16 | 17 | .saveIcon { 18 | color: white; 19 | } 20 | 21 | .saveIcon > * { 22 | width: 15px; 23 | height: 15px; 24 | margin-bottom: -3px 25 | } -------------------------------------------------------------------------------- /src/components/TimelineArm/TimelineArm.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import gsap from 'gsap'; 3 | import styles from './TimelineArm.module.css'; 4 | 5 | const playAnimation = (ref, duration) => { 6 | gsap.fromTo(ref, { 7 | left: 0, 8 | opacity: 1 9 | }, { 10 | opacity: 1, 11 | left: '100%', 12 | ease: 'linear', 13 | repeat: -1, 14 | duration 15 | }); 16 | }; 17 | 18 | const pauseAnimation = (ref, duration) => { 19 | gsap.fromTo(ref, { 20 | left: 0, 21 | opacity: 0 22 | }, { 23 | left: 0, 24 | opacity: 0, 25 | ease: 'linear', 26 | repeat: -1, 27 | duration 28 | }); 29 | }; 30 | 31 | const TimelineArm = ({ 32 | isPlaying, 33 | numBeats, 34 | bpm, 35 | }) => { 36 | const armRef = useRef(null); 37 | 38 | useEffect(() => { 39 | // Multiply BPM times four to represent quarter notes 40 | const periodDuration = numBeats / (bpm * 4) * 60; 41 | if (isPlaying) { 42 | playAnimation(armRef.current, periodDuration); 43 | } else { 44 | pauseAnimation(armRef.current, periodDuration); 45 | } 46 | }, [isPlaying, numBeats, bpm]); 47 | 48 | return ( 49 |
53 | ); 54 | }; 55 | 56 | export default TimelineArm; -------------------------------------------------------------------------------- /src/components/TimelineArm/TimelineArm.module.css: -------------------------------------------------------------------------------- 1 | .arm { 2 | position: absolute; 3 | width: 4px; 4 | border-radius: 4px; 5 | bottom: 0; 6 | top: 0; 7 | background-color: #cccccccc; 8 | opacity: 0.7; 9 | left: 18px; 10 | } -------------------------------------------------------------------------------- /src/components/TrackOptions/TrackOptions.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { FaTrash, FaVolumeDown, FaVolumeMute } from 'react-icons/fa'; 3 | import { OPACITY_1 } from '../../constants/colors'; 4 | import { formulaToData } from '../../util/formulaEvaluator'; 5 | import styles from './TrackOptions.module.css'; 6 | 7 | const Input = ({ color, index, isError, ...props }) => { 8 | const borderColor = color; 9 | const style = { borderColor }; 10 | const classes = [styles.input]; 11 | if (isError) { 12 | classes.push(styles.inputError); 13 | } 14 | return ( 15 | 21 | ); 22 | }; 23 | 24 | const Inputs = ({ 25 | index, 26 | track, 27 | setTrack 28 | }) => { 29 | 30 | const { formula, noteDuration } = track; 31 | 32 | let isFormulaValid = true; 33 | try { 34 | formulaToData(formula, [0, Math.PI]); 35 | } catch (e) { 36 | isFormulaValid = false; 37 | } 38 | 39 | const createAttributeSetter = (attribute) => (e) => { 40 | track[attribute] = e.target.value; 41 | setTrack(track); 42 | }; 43 | 44 | return ( 45 |
46 | 47 | 48 | 55 | 56 | 57 | 64 |
65 | ); 66 | }; 67 | 68 | const Icons = ({ 69 | isCollapsed, 70 | index, 71 | track, 72 | tracks, 73 | setTrack, 74 | setTracks, 75 | }) => { 76 | if (isCollapsed) { 77 | return null; 78 | } 79 | 80 | const onMute = () => { 81 | setTrack({ 82 | ...track, 83 | isMuted: !track.isMuted 84 | }); 85 | }; 86 | 87 | const onDelete = () => { 88 | if (tracks.length === 1) { 89 | return; 90 | } 91 | tracks.splice(index, 1); 92 | setTracks([...tracks]); 93 | }; 94 | 95 | const muteClasses = [styles.icon]; 96 | if (track.isMuted) { 97 | muteClasses.push(styles.isMuted); 98 | } 99 | 100 | const MuteIcon = track.isMuted ? FaVolumeMute : FaVolumeDown; 101 | 102 | return ( 103 |
104 |
109 | 110 |
111 |
116 | 117 |
118 |
119 | ); 120 | }; 121 | 122 | const TrackOptions = ({ 123 | index, 124 | track, 125 | tracks, 126 | setTracks, 127 | setTrack 128 | }) => { 129 | 130 | const [isCollapsed, setIsCollapsed] = useState(index !== 0); 131 | 132 | const color = track.color; 133 | const borderColor = color; 134 | const backgroundColor = color + OPACITY_1; 135 | const containerStyle = { borderColor, backgroundColor }; 136 | const containerClasses = [styles.container]; 137 | if (isCollapsed) { 138 | containerClasses.push(styles.collapsed) 139 | } 140 | 141 | const onClickContainer = (e) => { 142 | setIsCollapsed(!isCollapsed); 143 | }; 144 | 145 | return ( 146 |
150 |
151 |
155 | Track {index + 1} 156 |
157 | 165 |
166 | {isCollapsed 167 | ? null 168 | : 173 | } 174 |
175 | ); 176 | }; 177 | 178 | export default TrackOptions; -------------------------------------------------------------------------------- /src/components/TrackOptions/TrackOptions.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | border: 2px solid; 3 | border-radius: 2px; 4 | margin-bottom: 10px; 5 | transition: all 300ms; 6 | height: 157px; 7 | overflow: hidden; 8 | color: white; 9 | } 10 | 11 | .container.collapsed { 12 | height: 36px; 13 | } 14 | 15 | .header { 16 | display: flex; 17 | justify-content: space-between; 18 | } 19 | 20 | .icons { 21 | display: flex; 22 | } 23 | 24 | .icon { 25 | margin-left: 5px; 26 | padding: 0 5px; 27 | margin-top: -5px; 28 | padding-top: 4px; 29 | margin-top: 4px; 30 | border: 2px solid transparent; 31 | height: 18px; 32 | } 33 | 34 | .icon:last-child { 35 | padding-right: 10px 36 | } 37 | 38 | .icon:hover { 39 | opacity: 0.7; 40 | cursor: pointer; 41 | } 42 | 43 | .isMuted { 44 | border: 2px solid white; 45 | border-radius: 2px; 46 | } 47 | 48 | .muteIcon { 49 | width: 14px; 50 | height: 14px; 51 | } 52 | 53 | .trashIcon { 54 | width: 12px; 55 | height: 12px; 56 | } 57 | 58 | .title { 59 | font-size: 15px; 60 | flex: 1; 61 | padding: 10px; 62 | cursor: pointer; 63 | user-select: none; 64 | } 65 | 66 | .title::before { 67 | display: inline-block; 68 | content: '^'; 69 | margin-right: 10px; 70 | transform-origin: 50% 40%; 71 | transition: all 300ms; 72 | font-size: 15px; 73 | transform: rotate(180deg); 74 | } 75 | 76 | .title:hover::before { 77 | transform: rotate(90deg) scale(1.2); 78 | 79 | } 80 | 81 | .collapsed .title:hover::before { 82 | transform: rotate(180deg); 83 | } 84 | 85 | .collapsed .title::before { 86 | transform: rotate(90deg); 87 | } 88 | 89 | .label:first-child { 90 | margin-top: 10px; 91 | } 92 | 93 | .label, 94 | .input { 95 | margin-left: 10px; 96 | margin-right: 10px; 97 | width: calc(100% - 44px); 98 | } 99 | 100 | .input { 101 | transition: all 300ms; 102 | } 103 | 104 | .inputError { 105 | background-color: #edc5c5; 106 | } -------------------------------------------------------------------------------- /src/constants/colors.js: -------------------------------------------------------------------------------- 1 | export const COLOR_1 = '#8338ec'; 2 | export const COLOR_2 = '#ff006e'; 3 | export const COLOR_3 = '#3a86ff'; 4 | export const COLOR_4 = '#fb5607'; 5 | export const COLOR_5 = '#ffbe0b'; 6 | 7 | export const COLORS = [ 8 | COLOR_1, 9 | COLOR_2, 10 | COLOR_3, 11 | COLOR_4, 12 | COLOR_5 13 | ]; 14 | 15 | export const OPACITY_1 = 'cc'; 16 | export const OPACITY_2 = '66'; 17 | export const OPACITY_3 = '11'; 18 | 19 | export const GRAY_1 = '#666666'; -------------------------------------------------------------------------------- /src/constants/notes.js: -------------------------------------------------------------------------------- 1 | export const NOTES = [ 2 | 'C0', 'Db0', 'D0', 'Eb0', 'E0', 'F0', 'Gb0', 'G0', 'Ab0', 'A0', 'Bb0', 'B0', 3 | 'C1', 'Db1', 'D1', 'Eb1', 'E1', 'F1', 'Gb1', 'G1', 'Ab1', 'A1', 'Bb1', 'B1', 4 | 'C2', 'Db2', 'D2', 'Eb2', 'E2', 'F2', 'Gb2', 'G2', 'Ab2', 'A2', 'Bb2', 'B2', 5 | 'C3', 'Db3', 'D3', 'Eb3', 'E3', 'F3', 'Gb3', 'G3', 'Ab3', 'A3', 'Bb3', 'B3', 6 | 'C4', 'Db4', 'D4', 'Eb4', 'E4', 'F4', 'Gb4', 'G4', 'Ab4', 'A4', 'Bb4', 'B4', 7 | 'C5', 'Db5', 'D5', 'Eb5', 'E5', 'F5', 'Gb5', 'G5', 'Ab5', 'A5', 'Bb5', 'B5', 8 | 'C6', 'Db6', 'D6', 'Eb6', 'E6', 'F6', 'Gb6', 'G6', 'Ab6', 'A6', 'Bb6', 'B6', 9 | 'C7', 'Db7', 'D7', 'Eb7', 'E7', 'F7', 'Gb7', 'G7', 'Ab7', 'A7', 'Bb7', 'B7', 10 | 'C8', 'Db8', 'D8', 'Eb8', 'E8', 'F8', 'Gb8', 'G8', 'Ab8', 'A8', 'Bb8', 'B8' 11 | ]; 12 | 13 | export const FREQS = [ 14 | 16.35, 17.32, 18.35, 19.45, 20.60, 21.83, 23.12, 24.50, 25.96, 27.50, 29.14, 15 | 30.87, 32.70, 34.65, 36.71, 38.89, 41.20, 43.65, 46.25, 49.00, 51.91, 55.00, 16 | 58.27, 61.74, 65.41, 69.30, 73.42, 77.78, 82.41, 87.31, 92.50, 98.00, 103.83, 17 | 110.00, 116.54, 123.47, 130.81, 138.59, 146.83, 155.56, 164.81, 174.61, 185.00, 18 | 196.00, 207.65, 220.00, 233.08, 246.94, 261.63, 277.18, 293.66, 311.13, 329.63, 19 | 349.23, 369.99, 392.00, 415.30, 440.00, 466.16, 493.88, 523.25, 554.37, 587.33, 20 | 622.25, 659.25, 698.46, 739.99, 783.99, 830.61, 880.00, 932.33, 987.77, 1046.50, 21 | 1108.73, 1174.66, 1244.51, 1318.51, 1396.91, 1479.98, 1567.98, 1661.22, 1760.00, 22 | 1864.66, 1975.53, 2093.00, 2217.46, 2349.32, 2489.02, 2637.02, 2793.83, 2959.96, 23 | 3135.96, 3322.44, 3520.00, 3729.31, 3951.07, 4186.01, 4434.92, 4698.63, 4978.03, 24 | 5274.04, 5587.65, 5919.91, 6271.93, 6644.88, 7040.00, 7458.62, 7902.13 25 | ]; 26 | 27 | if (NOTES.length !== FREQS.length) { 28 | throw Error('# notes !== # freqs'); 29 | } 30 | 31 | export const NOTE_MAP = {}; 32 | for (let i = 0; i < NOTES.length; i++) { 33 | NOTE_MAP[NOTES[i]] = FREQS[i]; 34 | } 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/constants/scales.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { NOTES } from "./notes"; 3 | 4 | export const PITCHES = [ 5 | 'C', 'Db', 'D', 'Eb', 6 | 'E', 'F', 'Gb', 'G', 7 | 'Ab', 'A', 'Bb', 'B' 8 | ]; 9 | 10 | export const MODES = [ 11 | { 12 | name: 'Major', 13 | steps: [2, 2, 1, 2, 2, 2, 1] 14 | }, 15 | { 16 | name: 'Minor', 17 | steps: [2, 1, 2, 2, 1, 2, 2] 18 | }, 19 | { 20 | name: 'Major (pentatonic)', 21 | steps: [2, 2, 3, 2, 3] 22 | }, 23 | { 24 | name: 'Minor (pentatonic)', 25 | steps: [3, 2, 2, 3, 2] 26 | } 27 | ]; 28 | 29 | const getScalePitches = (pitch, steps) => { 30 | let index = PITCHES.indexOf(pitch); 31 | const scale = steps.map((step) => { 32 | index = (index + step) % PITCHES.length; 33 | return PITCHES[index]; 34 | }); 35 | scale.unshift(scale.pop()); 36 | return scale; 37 | }; 38 | 39 | let scales = []; 40 | for (const pitch of PITCHES) { 41 | for (const mode of MODES) { 42 | const scalePitches = getScalePitches(pitch, mode.steps); 43 | const scaleNotes = NOTES.filter((note) => { 44 | const notePitch = note.substring(0, note.length - 1); 45 | return scalePitches.includes(notePitch); 46 | }) 47 | scales.push({ 48 | name: `${pitch} ${mode.name}`, 49 | pitches: scalePitches, 50 | notes: scaleNotes 51 | }) 52 | } 53 | } 54 | 55 | scales = _.sortBy(scales, [ 56 | (o) => o.name.includes('pentatonic') ? 1 : 0, 57 | (o) => PITCHES.indexOf(o.pitches[0]) 58 | ]); 59 | 60 | export const SCALES = scales; 61 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Courier New', Courier, monospace; 3 | font-weight: bold; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | } 9 | 10 | path { 11 | transition: all 300ms; 12 | } 13 | 14 | label { 15 | font-size: 13px; 16 | display: block; 17 | margin-top: 5px; 18 | margin-bottom: 5px; 19 | } 20 | 21 | input { 22 | padding: 4px 10px; 23 | border-radius: 2px; 24 | border: 2px solid; 25 | width: calc(100% - 24px); 26 | } 27 | 28 | input:focus-visible { 29 | border-color: black !important; 30 | outline: none; 31 | } 32 | 33 | select { 34 | padding: 4px 10px; 35 | border-radius: 2px; 36 | border: 2px solid; 37 | width: calc(100% - 24px); 38 | width: 100%; 39 | outline: none; 40 | } 41 | 42 | select:focus-visible { 43 | border-color: black !important; 44 | outline: none; 45 | } 46 | 47 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './components/App/App'; 4 | import './index.css'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | 13 | -------------------------------------------------------------------------------- /src/util/audioPlayer.js: -------------------------------------------------------------------------------- 1 | import { NOTE_MAP } from '../constants/notes'; 2 | 3 | const NUM_LOOPS_TO_QUEUE = 4; 4 | const NOTE_SHAPE = new Float32Array([0, 0.8, 1, 0.3, 0].map(v => v * 0.05)); 5 | const AudioContext = (window.AudioContext || window.webkitAudioContext); 6 | const audioContext = new AudioContext(); 7 | 8 | let audioId; 9 | let audioNodes = []; 10 | /* 11 | Structure of 'audioNodes': 12 | [ 13 | { 14 | beatIndex: number, 15 | startTime: number, 16 | volumeNode: GainNode, 17 | oscillatorNode: OscillatorNode 18 | } 19 | ] 20 | */ 21 | 22 | // Creating audio nodes (i.e.: starting audio playback) ======================== 23 | 24 | export const createAudioNodes = ( 25 | tracks, 26 | notes, 27 | bpm, 28 | numBeats 29 | ) => { 30 | const secondsPerBeat = (1 / (bpm * 4)) * 60; 31 | const secondsPerLoop = secondsPerBeat * numBeats; 32 | 33 | // Point at which to recreate audio nodes 34 | const audioLocation = getCurrentAudioLocation(); 35 | const startingBeatIndex = audioLocation.beatIndex; 36 | const offsetTime = audioLocation.offsetTime; 37 | const loopStartTime = audioContext.currentTime + offsetTime - (secondsPerBeat * startingBeatIndex); 38 | 39 | // Purge existing audio nodes 40 | resetAudioNodes(); 41 | 42 | // Build audio nodes 43 | const numTracks = tracks.length; 44 | for (let i = 0; i < NUM_LOOPS_TO_QUEUE; i++) { 45 | for (let j = 0; j < numTracks; j++) { 46 | for (let k = 0; k < numBeats; k++) { 47 | if (i === 0 && k < startingBeatIndex) { 48 | continue; 49 | } 50 | const audioNode = buildAudioNode( 51 | i, 52 | k, 53 | notes[j][k], 54 | tracks[j], 55 | loopStartTime, 56 | secondsPerBeat, 57 | secondsPerLoop 58 | ); 59 | audioNodes.push(audioNode); 60 | } 61 | } 62 | } 63 | 64 | // Create an ID unique to this update 65 | const localAudioId = generateId(); 66 | audioId = localAudioId; 67 | 68 | // Repeat 69 | const waitTime = (audioNodes.length / numTracks * secondsPerBeat + offsetTime) * 1000; 70 | setTimeout(() => { 71 | if (audioId === localAudioId) { 72 | createAudioNodes( 73 | tracks, 74 | notes, 75 | bpm, 76 | numBeats 77 | ); 78 | } 79 | }, waitTime); 80 | }; 81 | 82 | // Resetting audio nodes (i.e.: stopping audio playback) ======================= 83 | 84 | export const resetAudioNodes = () => { 85 | disconnectFutureNodes(); 86 | audioNodes = []; 87 | audioId = null; 88 | }; 89 | 90 | // Helper functions ============================================================ 91 | 92 | const generateId = () => Date.now(); 93 | 94 | const isValidPositveNumber = (number) => { 95 | return ( 96 | typeof number === 'number' && 97 | !isNaN(number) && 98 | number > 0 99 | ); 100 | } 101 | 102 | const buildAudioNode = ( 103 | loopIndex, 104 | beatIndex, 105 | note, 106 | track, 107 | loopStartTime, 108 | secondsPerBeat, 109 | secondsPerLoop 110 | ) => { 111 | const freq = NOTE_MAP[note]; 112 | const isTrackMuted = track.isMuted; 113 | const isNoteMuted = track.disabledBeats.includes(beatIndex); 114 | const isActive = !(isTrackMuted || isNoteMuted); 115 | const duration = track.noteDuration / 1000; 116 | const startTime = loopStartTime + (loopIndex * secondsPerLoop) + (beatIndex * secondsPerBeat); 117 | const isValid = isValidPositveNumber(freq) && isValidPositveNumber(duration); 118 | let volumeNode = null; 119 | let oscillatorNode = null; 120 | if (isActive && isValid) { 121 | volumeNode = audioContext.createGain(); 122 | volumeNode.connect(audioContext.destination); 123 | volumeNode.gain.setValueCurveAtTime(NOTE_SHAPE, startTime, duration); 124 | oscillatorNode = audioContext.createOscillator(); 125 | oscillatorNode.connect(volumeNode); 126 | oscillatorNode.frequency.setValueAtTime(freq, 0); 127 | oscillatorNode.start(startTime); 128 | oscillatorNode.stop(startTime + duration); 129 | } 130 | return { 131 | beatIndex, 132 | startTime, 133 | volumeNode, 134 | oscillatorNode 135 | }; 136 | }; 137 | 138 | const getCurrentAudioLocation = () => { 139 | const { currentTime } = audioContext; 140 | for (const audioNode of audioNodes) { 141 | const { startTime, beatIndex } = audioNode; 142 | if (startTime > currentTime) { 143 | const offsetTime = startTime - currentTime; 144 | return { beatIndex, offsetTime }; 145 | } 146 | } 147 | return { beatIndex: 0, offsetTime: 0 }; 148 | }; 149 | 150 | const disconnectFutureNodes = () => { 151 | const { currentTime } = audioContext; 152 | for (const audioNode of audioNodes) { 153 | const { startTime, volumeNode } = audioNode; 154 | if (startTime > currentTime) { 155 | if (volumeNode) { 156 | volumeNode.disconnect(); 157 | } 158 | } 159 | } 160 | }; 161 | -------------------------------------------------------------------------------- /src/util/fileDownloader.js: -------------------------------------------------------------------------------- 1 | export const downloadFile = (filename, textContent) => { 2 | const element = document.createElement('a'); 3 | element.setAttribute('href', textContent); 4 | element.setAttribute('download', filename); 5 | element.style.display = 'none'; 6 | document.body.appendChild(element); 7 | element.click(); 8 | document.body.removeChild(element); 9 | }; -------------------------------------------------------------------------------- /src/util/formulaEvaluator.js: -------------------------------------------------------------------------------- 1 | import { evaluate } from 'mathjs'; 2 | 3 | export const formulaToData = (formula, xValues) => { 4 | 5 | // Validate formula 6 | formula = formula.replace(/\s+/g, ''); 7 | if (formula.substring(0, 2) !== 'y=') { 8 | throw Error('Invalid formula: ' + formula); 9 | } 10 | formula = formula.substring(2); 11 | 12 | // Calculate y-values 13 | let yValues = []; 14 | for (const x of xValues) { 15 | try { 16 | const y = evaluate(formula, { x }); 17 | yValues.push(y); 18 | } catch (e) { 19 | throw Error('Formula evaluation error: ' + formula); 20 | } 21 | } 22 | 23 | // Cap big / small numbers 24 | const cap = 30; 25 | let wasCapped = false; 26 | yValues = yValues.map((y) => { 27 | if (y > cap) { 28 | wasCapped = true; 29 | return cap; 30 | } else if (y < -cap) { 31 | wasCapped = true; 32 | return -cap; 33 | } 34 | return y; 35 | }); 36 | 37 | // Handle bad values (functions) 38 | for (const y of yValues) { 39 | if (typeof y !== 'number') { 40 | console.error('Error in y-values:', yValues); 41 | throw Error('Formula produced invalid values: ' + formula); 42 | } 43 | } 44 | 45 | // Convert NaN to 0 46 | yValues = yValues.map((y) => isNaN(y) ? 0 : y); 47 | 48 | return { 49 | yValues, 50 | wasCapped, 51 | cap 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /src/util/midiWriter.js: -------------------------------------------------------------------------------- 1 | import MidiWriter from 'midi-writer-js'; 2 | import { downloadFile } from './fileDownloader'; 3 | 4 | export const downloadMidi = ({ bpm, tracks, notes }) => { 5 | const midiString = toMidiString({ bpm, tracks, notes }); 6 | downloadFile('scaler.mid', midiString); 7 | }; 8 | 9 | const toMidiString = ({ bpm, tracks, notes }) => { 10 | 11 | // BPM times four to represent quarter notes 12 | const msPerBeat = (1 / (bpm * 4)) * 60 * 1000; 13 | 14 | const midiTracks = []; 15 | 16 | for (let i = 0; i < tracks.length; i++) { 17 | const { noteDuration, disabledBeats } = tracks[i]; 18 | const midiTrack = new MidiWriter.Track(); 19 | midiTrack.setTempo(bpm); 20 | 21 | for (let j = 0; j < notes[i].length; j++) { 22 | const note = notes[i][j]; 23 | const isDisabled = disabledBeats.includes(j); 24 | const startMs = j * msPerBeat; 25 | 26 | // Don't allow a note's duration to exceed when the next same note begins 27 | let maxNoteDuration = Infinity; 28 | for (let k = j + 1; k < notes[i].length; k++) { 29 | const nextNote = notes[i][k]; 30 | if (note === nextNote) { 31 | const nextStartMs = k * msPerBeat; 32 | maxNoteDuration = nextStartMs - startMs - 1; 33 | break; 34 | } 35 | } 36 | 37 | const duration = Math.min(noteDuration, maxNoteDuration); 38 | 39 | if (!isDisabled) { 40 | const midiNote = new MidiWriter.NoteEvent({ 41 | pitch: note, 42 | duration: 'T' + msToTicks(duration, bpm), 43 | startTick: msToTicks(startMs, bpm) 44 | }); 45 | midiTrack.addEvent(midiNote); 46 | } 47 | } 48 | 49 | midiTracks.push(midiTrack); 50 | } 51 | 52 | const writer = new MidiWriter.Writer(midiTracks); 53 | const midiString = writer.dataUri(); 54 | return midiString; 55 | }; 56 | 57 | const msToTicks = (ms, bpm) => { 58 | const tempo = Math.round((60 * 1000000) / bpm); 59 | const ticksPerBeat = 128; 60 | const seconds = ms / 1000; 61 | const ticks = Math.round(seconds / (tempo * 1e-6 / ticksPerBeat)); 62 | return ticks; 63 | }; -------------------------------------------------------------------------------- /src/util/noteCalculator.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { formulaToData } from './formulaEvaluator'; 3 | 4 | export const calculateNotes = ( 5 | tracks, 6 | numBeats, 7 | noteRange, 8 | scale, 9 | lowNote 10 | ) => { 11 | const minNoteIndex = scale.notes.indexOf(lowNote); 12 | 13 | const xValues = []; 14 | for (let i = 0; i < numBeats; i++) { 15 | xValues.push(i / numBeats * 2 * Math.PI); 16 | } 17 | 18 | const yValuesArray = tracks.map(({ formula }) => { 19 | try { 20 | const data = formulaToData(formula, xValues); 21 | return data.yValues; 22 | } catch (e) { 23 | return []; 24 | } 25 | }); 26 | 27 | const yMin = _(yValuesArray).flatten().min(); 28 | const yMax = _(yValuesArray).flatten().max(); 29 | 30 | const notesArray = yValuesArray.map((yValues) => { 31 | 32 | const noteIndices = yValues.map((y) => { 33 | return Math.round( 34 | (y - yMin) / 35 | ((yMax - yMin) || 1) * 36 | (noteRange - 1) 37 | ); 38 | }); 39 | 40 | const notes = noteIndices.map((index) => { 41 | return scale.notes[index + minNoteIndex]; 42 | }); 43 | 44 | return notes; 45 | }); 46 | 47 | return notesArray; 48 | }; 49 | -------------------------------------------------------------------------------- /src/util/objectCompressor.js: -------------------------------------------------------------------------------- 1 | import LZString from 'lz-string'; 2 | 3 | export const toHash = (stateObject) => { 4 | const str = JSON.stringify(stateObject); 5 | return LZString.compressToEncodedURIComponent(str); 6 | }; 7 | 8 | export const fromHash = (hash) => { 9 | const str = LZString.decompressFromEncodedURIComponent(hash); 10 | return JSON.parse(str); 11 | }; -------------------------------------------------------------------------------- /src/util/sharer.js: -------------------------------------------------------------------------------- 1 | import { toHash } from './objectCompressor'; 2 | 3 | export const shareUrl = async (saveData) => { 4 | const hash = toHash(saveData); 5 | const url = window.location.origin + window.location.pathname + '?d=' + hash; 6 | try { 7 | await navigator.clipboard.writeText(url); 8 | alert('A link to your work has been copied to your clipboard.'); 9 | } catch (e) { 10 | prompt('Save the following URL to access your work:', url); 11 | } 12 | }; -------------------------------------------------------------------------------- /src/util/stateLoader.js: -------------------------------------------------------------------------------- 1 | import { COLORS } from '../constants/colors'; 2 | import { SCALES } from '../constants/scales'; 3 | import { fromHash } from './objectCompressor'; 4 | import { loadRecent } from './storageManager'; 5 | 6 | const defaultData = { 7 | bpm: 60, 8 | numBeats: 16, 9 | noteRange: 14, 10 | scale: SCALES[3], 11 | lowNote: 'E3', 12 | tracks: [ 13 | { 14 | color: COLORS[0], 15 | formula: 'y = min(sin(x), cos(x))', 16 | noteDuration: 20, 17 | disabledBeats: [1, 4, 7, 9, 10, 15], 18 | isMuted: false 19 | }, 20 | { 21 | color: COLORS[1], 22 | formula: 'y = (1 / (x + 0.5)) * cos(4x) - 2', 23 | noteDuration: 90, 24 | disabledBeats: [0, 2, 5, 6, 10, 12, 13], 25 | isMuted: false 26 | }, 27 | { 28 | color: COLORS[2], 29 | formula: 'y = 0', 30 | noteDuration: 60, 31 | disabledBeats: [0, 6, 9, 13], 32 | isMuted: false 33 | }, 34 | ] 35 | }; 36 | 37 | export const loadInitialState = () => { 38 | const queryString = window.location.search; 39 | const urlParams = new URLSearchParams(queryString); 40 | let data = urlParams.get('d'); 41 | if (data) { 42 | return fromHash(data); 43 | } 44 | data = loadRecent(); 45 | if (data) { 46 | return data; 47 | } 48 | return defaultData; 49 | }; 50 | -------------------------------------------------------------------------------- /src/util/storageManager.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { fromHash, toHash } from './objectCompressor'; 3 | 4 | const RECENT_KEY = 'd'; 5 | const LOCAL_SAVES_KEY = 'saves'; 6 | 7 | // Load / save the user's current work ========================================= 8 | 9 | export const loadRecent = () => { 10 | const hash = localStorage.getItem(RECENT_KEY); 11 | if (hash === null) { 12 | return null; 13 | } 14 | return fromHash(hash); 15 | }; 16 | 17 | export const storeRecent = (data) => { 18 | const hash = toHash(data); 19 | localStorage.setItem(RECENT_KEY, hash); 20 | }; 21 | 22 | // Load / save the user's explicitly saved work ================================ 23 | 24 | export const loadLocalSaves = () => { 25 | let saves = localStorage.getItem(LOCAL_SAVES_KEY); 26 | if (saves === null) { 27 | return []; 28 | } else { 29 | saves = fromHash(saves); 30 | } 31 | 32 | const savesArray = []; 33 | for (const name of Object.keys(saves)) { 34 | const { data, date } = saves[name]; 35 | savesArray.push({ 36 | name, 37 | data, 38 | date 39 | }); 40 | } 41 | 42 | return _(savesArray) 43 | .sortBy('date') 44 | .reverse() 45 | .value(); 46 | }; 47 | 48 | export const saveLocal = (data) => { 49 | let saves = localStorage.getItem(LOCAL_SAVES_KEY); 50 | if (saves === null) { 51 | saves = {}; 52 | } else { 53 | saves = fromHash(saves); 54 | } 55 | 56 | const name = prompt('Save as:') 57 | if (name === null) { 58 | return; 59 | } 60 | if (name === '') { 61 | alert('Invalid name.'); 62 | return; 63 | } 64 | 65 | const previous = saves[name]; 66 | if (previous) { 67 | const shouldOverwrite = window.confirm(`Overwrite previous "${name}"?`); 68 | if (!shouldOverwrite) { 69 | return; 70 | } 71 | } 72 | 73 | saves[name] = { 74 | data, 75 | date: Date.now() 76 | }; 77 | 78 | localStorage.setItem(LOCAL_SAVES_KEY, toHash(saves)); 79 | }; 80 | --------------------------------------------------------------------------------