├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------