├── .gitignore ├── .nvmrc ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── assets │ ├── icon.svg │ └── logo.svg ├── audioHelpers │ ├── noteNameFromPitch.ts │ └── pitchToFrequency.ts ├── components │ ├── App.tsx │ ├── Router.tsx │ ├── atoms │ │ ├── ButtonPrimary.css │ │ ├── ButtonPrimary.tsx │ │ ├── ButtonSecondary.css │ │ ├── ButtonSecondary.tsx │ │ ├── HollowButton.css │ │ ├── HollowButton.tsx │ │ ├── InputLabel.css │ │ ├── InputLabel.tsx │ │ ├── InputSelect.css │ │ ├── InputSelect.tsx │ │ ├── LinkExternal.css │ │ └── LinkExternal.tsx │ ├── hooks │ │ ├── useAudio.ts │ │ └── usePressedKeyCodes.ts │ ├── molecules │ │ ├── CheckboxLabelled.css │ │ ├── CheckboxLabelled.tsx │ │ ├── InstrumentSelector.tsx │ │ ├── RangeLabelled.css │ │ ├── RangeLabelled.tsx │ │ └── Selector.tsx │ ├── organisms │ │ ├── ControlModule.css │ │ ├── ControlModule.tsx │ │ ├── ControlPad │ │ │ ├── Token.ts │ │ │ ├── declarations.d.ts │ │ │ ├── fragment_shader.glsl │ │ │ ├── index.tsx │ │ │ ├── style.css │ │ │ └── vertex_shader.glsl │ │ ├── Navigation.css │ │ ├── Navigation.tsx │ │ ├── spinner.css │ │ └── table-center.css │ ├── pages │ │ ├── About │ │ │ ├── index.tsx │ │ │ └── style.css │ │ ├── AriadneSettings.tsx │ │ ├── ControlPadPage.css │ │ ├── ControlPadPage.tsx │ │ ├── ControlPadSettings.css │ │ ├── ControlPadSettings.tsx │ │ ├── Instrument.css │ │ ├── KeyboardSettings.css │ │ ├── KeyboardSettings.tsx │ │ ├── PrometheusSettings │ │ │ ├── ModuleFilter.tsx │ │ │ ├── ModuleLfo.tsx │ │ │ ├── ModuleMaster.tsx │ │ │ ├── ModuleOscSingle.tsx │ │ │ ├── ModuleOscSuper.tsx │ │ │ └── index.tsx │ │ └── Settings │ │ │ ├── index.tsx │ │ │ └── style.css │ └── shared │ │ ├── RedirectHome.tsx │ │ └── SelectOscType.tsx ├── constants.ts ├── constants │ ├── colors.css │ └── timings.css ├── index.html ├── index.tsx ├── manifest.webmanifest ├── store │ ├── ariadneAudioGraphSelector.ts │ ├── ariadneSlice.ts │ ├── audioGraphSelector.ts │ ├── controlPadSlice.ts │ ├── index.ts │ ├── keyboardSlice.ts │ ├── navSlice.ts │ ├── prometheusAudioGraphSelector.ts │ ├── prometheusSlice.ts │ ├── screenSlice.ts │ └── settingsSlice.ts ├── types.ts └── utils │ ├── globals.css │ ├── helpers.ts │ ├── keyframes.css │ └── webGl.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /dist 3 | /node_modules 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT NON-AI License 2 | 3 | Copyright © 2025 Ben Hall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | In addition, the following restrictions apply: 10 | 11 | 1. The Software and any modifications made to it may not be used for the purpose of training or improving machine learning algorithms, including but not limited to artificial intelligence, natural language processing, or data mining. This condition applies to any derivatives, modifications, or updates based on the Software code. Any usage of the Software in an AI-training dataset is considered a breach of this License. 12 | 2. The Software may not be included in any dataset used for training or improving machine learning algorithms, including but not limited to artificial intelligence, natural language processing, or data mining. 13 | 3. Any person or organization found to be in violation of these restrictions will be subject to legal action and may be held liable for any damages resulting from such use. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://andromeda.netlify.app) 2 | 3 | Andromeda is a pluggable digital audio workstation built on open web technologies 4 | 5 | **[Check it out here!](https://andromeda.netlify.app)** 6 | 7 | --- 8 | 9 | Built using [virtual-audio-graph](https://github.com/benji6/virtual-audio-graph) 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "andromeda", 3 | "description": "Make music in your browser!", 4 | "scripts": { 5 | "build": "vite build src --emptyOutDir --outDir ../dist && touch dist/robots.txt", 6 | "fmt": "prettier --ignore-unknown --write '**/*'", 7 | "start": "vite src --open", 8 | "test": "run-p test:*", 9 | "test:audit": "npm audit --audit-level=critical", 10 | "test:fmt": "prettier --check --ignore-unknown '**/*'", 11 | "test:types": "tsc --noEmit" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/benji6/andromeda.git" 16 | }, 17 | "keywords": [ 18 | "andromeda", 19 | "music", 20 | "synth", 21 | "web", 22 | "audio", 23 | "api" 24 | ], 25 | "author": "Ben Hall", 26 | "license": "SEE LICENSE IN /LICENSE", 27 | "bugs": { 28 | "url": "https://github.com/benji6/andromeda/issues" 29 | }, 30 | "homepage": "https://github.com/benji6/andromeda", 31 | "dependencies": { 32 | "@reduxjs/toolkit": "^2.6.0", 33 | "react": "^19.0.0", 34 | "react-dom": "^19.0.0", 35 | "react-redux": "^9.2.0", 36 | "react-router-dom": "^6.29.0", 37 | "virtual-audio-graph": "^1.3.0" 38 | }, 39 | "devDependencies": { 40 | "@types/react": "^19.0.10", 41 | "@types/react-dom": "^19.0.4", 42 | "npm-run-all": "^4.1.5", 43 | "prettier": "^3.5.2", 44 | "typescript": "^5.7.3", 45 | "vite": "^6.2.7" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 11 | 12 | 17 | 21 | 23 | 25 | 30 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /src/audioHelpers/noteNameFromPitch.ts: -------------------------------------------------------------------------------- 1 | const { floor } = Math; 2 | const alphabeticalComponents = [ 3 | "A", 4 | "A#/Bb", 5 | "B", 6 | "C", 7 | "C#/Db", 8 | "D", 9 | "D#/Eb", 10 | "E", 11 | "F", 12 | "F#/Gb", 13 | "G", 14 | "G#/Ab", 15 | ] as const; 16 | 17 | const { length } = alphabeticalComponents; 18 | 19 | export default (pitch: number) => { 20 | const octave = floor(pitch / length) + 4; 21 | return ( 22 | alphabeticalComponents[((pitch % length) + length) % length] + 23 | String(octave) 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/audioHelpers/pitchToFrequency.ts: -------------------------------------------------------------------------------- 1 | export default (pitch: number) => 440 * Math.pow(2, pitch / 12); 2 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import Router from "./Router"; 2 | import useAudio from "./hooks/useAudio"; 3 | import usePressedKeyCodes from "./hooks/usePressedKeyCodes"; 4 | 5 | export default function App() { 6 | useAudio(); 7 | usePressedKeyCodes(); 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Router.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; 3 | import store from "../store"; 4 | import { NAV } from "../constants"; 5 | import About from "./pages/About"; 6 | import ControlPadPage from "./pages/ControlPadPage"; 7 | import ControlPadSettings from "./pages/ControlPadSettings"; 8 | import KeyboardSettings from "./pages/KeyboardSettings"; 9 | import Settings from "./pages/Settings"; 10 | import RedirectHome from "./shared/RedirectHome"; 11 | import navSlice from "../store/navSlice"; 12 | import AriadneSettings from "./pages/AriadneSettings"; 13 | import PrometheusSettings from "./pages/PrometheusSettings"; 14 | import Navigation from "./organisms/Navigation"; 15 | import { useSelector } from "react-redux"; 16 | 17 | const RouteChangeHandler = ({ children }: { children: React.ReactNode }) => { 18 | const location = useLocation(); 19 | const [lastPathname, setLastPathname] = useState(location.pathname); 20 | const lastDirection = useSelector(navSlice.selectors.lastDirection); 21 | 22 | useEffect(() => { 23 | const prevIndex = NAV.findIndex(([pathname]) => pathname === lastPathname); 24 | const nextIndex = NAV.findIndex( 25 | ([pathname]) => pathname === location.pathname, 26 | ); 27 | 28 | if (nextIndex === prevIndex) return; 29 | 30 | const direction = nextIndex > prevIndex ? "right" : "left"; 31 | 32 | if (direction !== lastDirection) 33 | store.dispatch(navSlice.actions.lastDirectionSet(direction)); 34 | 35 | setLastPathname(location.pathname); 36 | }, [lastDirection, lastPathname, location]); 37 | 38 | return children; 39 | }; 40 | 41 | const Router = () => ( 42 | 43 | 44 |
45 | 46 | 47 | } /> 48 | } /> 49 | } 52 | /> 53 | } 56 | /> 57 | } 60 | /> 61 | } 64 | /> 65 | } /> 66 | } /> 67 | 68 |
69 |
70 |
71 | ); 72 | 73 | export default Router; 74 | -------------------------------------------------------------------------------- /src/components/atoms/ButtonPrimary.css: -------------------------------------------------------------------------------- 1 | .ButtonPrimary { 2 | background: linear-gradient(var(--blue-70), var(--blue-60)); 3 | border-radius: 2rem; 4 | border: 0.1rem solid var(--blue-60); 5 | box-shadow: var(--blue-60) 0 0 0.5rem; 6 | box-sizing: border-box; 7 | color: var(--gray-00); 8 | display: inline-block; 9 | font-size: 1rem; 10 | margin: 0.75rem; 11 | min-width: 10rem; 12 | padding: 0.75rem 1rem; 13 | text-align: center; 14 | text-decoration: none; 15 | transition: background var(--transition-fast); 16 | } 17 | .ButtonPrimary:active { 18 | background: linear-gradient(var(--blue-60), var(--blue-70)); 19 | } 20 | .ButtonPrimary:hover { 21 | box-shadow: var(--blue-60) 0 0 1rem 0.1rem; 22 | } 23 | 24 | .ButtonPrimary--small { 25 | border-radius: 0.25rem; 26 | border: 1px solid; 27 | box-shadow: none; 28 | color: var(--gray-00); 29 | min-width: 5rem; 30 | padding: 0.25rem 0.5rem; 31 | } 32 | .ButtonPrimary--small:active { 33 | background: linear-gradient(var(--blue-20), var(--blue-70)); 34 | border-color: var(--blue-20); 35 | } 36 | .ButtonPrimary--small:hover { 37 | background: linear-gradient(var(--blue-60), var(--blue-70)); 38 | box-shadow: none; 39 | } 40 | -------------------------------------------------------------------------------- /src/components/atoms/ButtonPrimary.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | interface Props { 4 | children?: string; 5 | onClick?: () => void; 6 | small?: boolean; 7 | to?: string; 8 | } 9 | 10 | const ButtonPrimary = ({ children, onClick, small, to }: Props) => { 11 | const className = `ButtonPrimary${small ? " ButtonPrimary--small" : ""}`; 12 | return to ? ( 13 | 14 | {children} 15 | 16 | ) : ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | export default ButtonPrimary; 24 | -------------------------------------------------------------------------------- /src/components/atoms/ButtonSecondary.css: -------------------------------------------------------------------------------- 1 | .ButtonSecondary { 2 | background: var(--green-60); 3 | border-radius: 1rem; 4 | color: var(--gray-00); 5 | font-size: 0.75rem; 6 | margin: 1rem 0.5rem; 7 | padding: 0.5rem; 8 | text-decoration: none; 9 | transition-duration: var(--transition-fast); 10 | } 11 | .ButtonSecondary:active { 12 | background: var(--green-20); 13 | } 14 | .ButtonSecondary:hover { 15 | background: var(--green-40); 16 | transition-duration: var(--transition-fast); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/atoms/ButtonSecondary.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | interface Props { 4 | children?: string; 5 | to: string; 6 | } 7 | 8 | const ButtonSecondary = ({ children, to }: Props) => ( 9 | 10 | {children} 11 | 12 | ); 13 | 14 | export default ButtonSecondary; 15 | -------------------------------------------------------------------------------- /src/components/atoms/HollowButton.css: -------------------------------------------------------------------------------- 1 | .HollowButton { 2 | align-items: center; 3 | background: linear-gradient(var(--blue-20), var(--gray-00), var(--blue-20)); 4 | border-radius: 1rem; 5 | border: 1px solid var(--blue-60); 6 | box-shadow: var(--blue-60) 0 0 0.5rem 0.1rem; 7 | color: var(--gray-99); 8 | display: inline-flex; 9 | font-size: 0.75rem; 10 | margin: 1rem 0.5rem; 11 | outline: 0; 12 | padding: 0.33rem 1rem; 13 | text-decoration: none; 14 | transition-duration: var(--transition-fast); 15 | } 16 | .HollowButton:active { 17 | background: linear-gradient(var(--gray-00), var(--gray-00), var(--blue-20)); 18 | } 19 | .HollowButton:hover { 20 | box-shadow: var(--blue-60) 0 0 1rem 0.1rem; 21 | transition-duration: var(--transition-fast); 22 | } 23 | .HollowButton--active { 24 | background: black; 25 | border: 1px solid var(--blue-30); 26 | box-shadow: none; 27 | } 28 | .HollowButton--active:active { 29 | background: black; 30 | } 31 | .HollowButton--active:hover { 32 | box-shadow: none; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/atoms/HollowButton.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from "react-router-dom"; 2 | 3 | interface Props { 4 | children: string; 5 | to: string; 6 | } 7 | 8 | const HollowButton = ({ children, to }: Props) => ( 9 | 11 | `HollowButton${isActive ? " HollowButton--active" : ""}` 12 | } 13 | to={to} 14 | > 15 | {children} 16 | 17 | ); 18 | 19 | export default HollowButton; 20 | -------------------------------------------------------------------------------- /src/components/atoms/InputLabel.css: -------------------------------------------------------------------------------- 1 | .InputLabel { 2 | display: inline-block; 3 | padding-right: 0.5rem; 4 | text-align: right; 5 | vertical-align: middle; 6 | width: 6rem; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/atoms/InputLabel.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | children?: string; 3 | } 4 | 5 | const InputLabel = ({ children }: Props) => ( 6 | {children} 7 | ); 8 | 9 | export default InputLabel; 10 | -------------------------------------------------------------------------------- /src/components/atoms/InputSelect.css: -------------------------------------------------------------------------------- 1 | .InputSelect { 2 | -moz-appearance: none; 3 | } 4 | .InputSelect[disabled] { 5 | visibility: hidden; 6 | } 7 | .InputSelect option { 8 | background: var(--gray-90); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/atoms/InputSelect.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | disabled?: boolean; 3 | onChange: (event: React.ChangeEvent) => void; 4 | options: { text: string; value: string }[]; 5 | value: string; 6 | } 7 | 8 | const InputSelect = ({ disabled, onChange, options, value }: Props) => ( 9 | 21 | ); 22 | 23 | export default InputSelect; 24 | -------------------------------------------------------------------------------- /src/components/atoms/LinkExternal.css: -------------------------------------------------------------------------------- 1 | .LinkExternal { 2 | color: var(--green-60); 3 | font-weight: 900; 4 | text-decoration: none; 5 | transition: color var(--transition-fast); 6 | } 7 | 8 | .LinkExternal:hover { 9 | color: var(--green-80); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/atoms/LinkExternal.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | children?: string; 3 | href: string; 4 | } 5 | 6 | const LinkExternal = ({ children, href }: Props) => ( 7 | 8 | {children} 9 | 10 | ); 11 | 12 | export default LinkExternal; 13 | -------------------------------------------------------------------------------- /src/components/hooks/useAudio.ts: -------------------------------------------------------------------------------- 1 | import createVirtualAudioGraph from "virtual-audio-graph"; 2 | import { useSelector } from "react-redux"; 3 | import audioGraphSelector from "../../store/audioGraphSelector"; 4 | 5 | const virtualAudioGraph = createVirtualAudioGraph(); 6 | 7 | export default function useAudio() { 8 | const audioGraph = useSelector(audioGraphSelector); 9 | virtualAudioGraph.update(audioGraph); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/hooks/usePressedKeyCodes.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import keyboardSlice from "../../store/keyboardSlice"; 4 | 5 | export default function usePressedKeyCodes() { 6 | const dispatch = useDispatch(); 7 | const pressedKeyCodes = useSelector(keyboardSlice.selectors.pressedKeyCodes); 8 | 9 | useEffect(() => { 10 | const keyDownHandler = (e: KeyboardEvent) => { 11 | const { keyCode } = e; 12 | if (keyCode === 191) e.preventDefault(); 13 | if (pressedKeyCodes.includes(keyCode)) return; 14 | dispatch(keyboardSlice.actions.pressedKeyCodesAdd(keyCode)); 15 | }; 16 | const keyUpHandler = ({ keyCode }: KeyboardEvent) => { 17 | dispatch(keyboardSlice.actions.pressedKeyCodesRemove(keyCode)); 18 | }; 19 | document.addEventListener("keyup", keyUpHandler); 20 | document.addEventListener("keydown", keyDownHandler); 21 | return () => { 22 | document.removeEventListener("keydown", keyDownHandler); 23 | document.removeEventListener("keyup", keyUpHandler); 24 | }; 25 | }, [pressedKeyCodes]); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/molecules/CheckboxLabelled.css: -------------------------------------------------------------------------------- 1 | .CheckboxLabelled__Checkbox { 2 | display: inline-block; 3 | margin: 1rem 0.5rem; 4 | text-align: center; 5 | width: 12rem; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/molecules/CheckboxLabelled.tsx: -------------------------------------------------------------------------------- 1 | import InputLabel from "../atoms/InputLabel"; 2 | 3 | interface Props { 4 | checked: boolean; 5 | children?: string; 6 | onChange: (e: React.ChangeEvent) => void; 7 | } 8 | 9 | const CheckboxLabelled = ({ checked, onChange, children }: Props) => ( 10 | 16 | ); 17 | 18 | export default CheckboxLabelled; 19 | -------------------------------------------------------------------------------- /src/components/molecules/InstrumentSelector.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from "react"; 2 | import Selector from "./Selector"; 3 | import ButtonSecondary from "../atoms/ButtonSecondary"; 4 | import { INSTRUMENTS } from "../../constants"; 5 | 6 | interface Props { 7 | defaultValue: string; 8 | disabled?: boolean; 9 | handleChange: (event: ChangeEvent) => void; 10 | label: string; 11 | } 12 | 13 | export default function InstrumentSelector(props: Props) { 14 | return ( 15 |
16 | ({ 19 | text: instrument, 20 | value: instrument, 21 | }))} 22 | /> 23 | 24 | edit 25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/molecules/RangeLabelled.css: -------------------------------------------------------------------------------- 1 | .RangeLabelled { 2 | display: flex; 3 | flex-direction: column; 4 | flex-grow: 1; 5 | height: 3rem; 6 | justify-content: space-around; 7 | margin: 1rem 1.5rem; 8 | max-width: 95vw; 9 | text-align: center; 10 | width: 18.5rem; 11 | } 12 | 13 | .RangeLabelled__Input { 14 | -webkit-appearance: none; 15 | appearance: none; 16 | background-color: var(--gray-99); 17 | height: 0.1rem; 18 | outline: 0; 19 | } 20 | 21 | .RangeLabelled__Input::-webkit-slider-thumb { 22 | -webkit-appearance: none; 23 | appearance: none; 24 | background: linear-gradient( 25 | to bottom, 26 | var(--gray-99) 0%, 27 | var(--gray-90) 55%, 28 | var(--blue-80) 80% 29 | ); 30 | border-radius: 0.3rem; 31 | box-shadow: 0 0.2rem 0.2rem 0 rgba(0, 0, 0, 0.5); 32 | height: 0.8rem; 33 | top: 0px; 34 | width: 0.8rem; 35 | } 36 | 37 | .RangeLabelled__LabelContainer { 38 | display: flex; 39 | justify-content: space-between; 40 | padding: 0 0.33rem; 41 | } 42 | -------------------------------------------------------------------------------- /src/components/molecules/RangeLabelled.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | children?: string; 3 | max?: number; 4 | min?: number; 5 | onChange: (event: React.ChangeEvent) => void; 6 | output?: number | string; 7 | step?: number; 8 | value: number; 9 | } 10 | 11 | const RangeLabelled = ({ 12 | children, 13 | max, 14 | min, 15 | onChange, 16 | output, 17 | step, 18 | value, 19 | }: Props) => { 20 | if (output === undefined) output = value; 21 | return ( 22 | 37 | ); 38 | }; 39 | 40 | export default RangeLabelled; 41 | -------------------------------------------------------------------------------- /src/components/molecules/Selector.tsx: -------------------------------------------------------------------------------- 1 | import { capitalizeFirst } from "../../utils/helpers"; 2 | import InputLabel from "../atoms/InputLabel"; 3 | import InputSelect from "../atoms/InputSelect"; 4 | 5 | interface Props { 6 | defaultValue: string; 7 | disabled?: boolean; 8 | handleChange: (event: React.ChangeEvent) => void; 9 | label: string; 10 | options: { text: string; value: string }[]; 11 | } 12 | 13 | export default function Selector({ 14 | defaultValue, 15 | disabled, 16 | handleChange, 17 | label, 18 | options, 19 | }: Props) { 20 | return ( 21 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/organisms/ControlModule.css: -------------------------------------------------------------------------------- 1 | .ControlModule { 2 | border: 0.1rem solid var(--blue-90); 3 | border-radius: 1rem; 4 | display: inline-block; 5 | margin: 0.75rem; 6 | padding: 0.75rem; 7 | } 8 | 9 | .ControlModule__Container { 10 | display: table; 11 | } 12 | 13 | .ControlModule__Input { 14 | display: table-row; 15 | font-size: 0.75rem; 16 | padding: 0.75rem; 17 | } 18 | 19 | .ControlModule__Input__Label { 20 | display: table-cell; 21 | padding: 0.5rem 0.5rem 0.25rem 0.25rem; 22 | text-align: left; 23 | } 24 | 25 | .ControlModule__Output { 26 | display: table-cell; 27 | height: 0.75rem; 28 | padding-left: 0.25rem; 29 | min-width: 2.25rem; 30 | } 31 | 32 | .ControlModule__Range { 33 | display: table-cell; 34 | height: 0.75rem; 35 | width: 10rem; 36 | } 37 | 38 | .ControlModule__Select { 39 | width: 10rem; 40 | } 41 | 42 | .ControlModule__Title { 43 | font-size: 1rem; 44 | font-weight: 600; 45 | margin: 0; 46 | padding-bottom: 0.75rem; 47 | } 48 | -------------------------------------------------------------------------------- /src/components/organisms/ControlModule.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HTMLAttributes, 3 | InputHTMLAttributes, 4 | ReactNode, 5 | SelectHTMLAttributes, 6 | } from "react"; 7 | 8 | const Input = ({ children, label }: { children: ReactNode; label: string }) => ( 9 | 13 | ); 14 | 15 | export const CheckBox = ( 16 | props: InputHTMLAttributes & { label: string }, 17 | ) => ( 18 | 19 | 20 | 21 | ); 22 | 23 | interface RangeProps extends InputHTMLAttributes { 24 | displayValue?: string; 25 | label: string; 26 | } 27 | 28 | export const Range = (props: RangeProps) => { 29 | const max = props.max ?? 1; 30 | const min = props.min ?? 0; 31 | const step = props.step ?? (Number(max) - Number(min)) / 1000; 32 | const displayValue = 33 | props.displayValue === undefined 34 | ? Number(props.defaultValue).toFixed(2) 35 | : props.displayValue; 36 | 37 | const inputProps = { 38 | className: "ControlModule__Range", 39 | max, 40 | min, 41 | step, 42 | type: "range", 43 | ...props, 44 | }; 45 | 46 | delete inputProps.displayValue; 47 | 48 | return ( 49 | 50 | 51 |
{displayValue}
52 | 53 | ); 54 | }; 55 | 56 | export const Select = ( 57 | props: SelectHTMLAttributes & { label: string }, 58 | ) => ( 59 | 60 | 52 | dispatch( 53 | ariadneSlice.actions.carrierOscTypeSet( 54 | e.currentTarget.value as OscillatorType, 55 | ), 56 | ) 57 | } 58 | > 59 | 60 | 61 | 62 | 63 | 64 | 80 | 86 | dispatch( 87 | ariadneSlice.actions.carrierDetuneSet( 88 | Number(e.currentTarget.value), 89 | ), 90 | ) 91 | } 92 | /> 93 | 99 | dispatch( 100 | ariadneSlice.actions.modulatorRatioSet( 101 | Number(e.currentTarget.value), 102 | ), 103 | ) 104 | } 105 | /> 106 | 112 | dispatch( 113 | ariadneSlice.actions.modulatorDetuneSet( 114 | Number(e.currentTarget.value), 115 | ), 116 | ) 117 | } 118 | /> 119 | 120 | 121 | navigate(-1)}>OK 122 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /src/components/pages/ControlPadPage.css: -------------------------------------------------------------------------------- 1 | .ControlPadPage { 2 | align-items: center; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/pages/ControlPadPage.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import ControlPad from "../organisms/ControlPad"; 3 | import ButtonPrimary from "../atoms/ButtonPrimary"; 4 | import controlPadSlice from "../../store/controlPadSlice"; 5 | import screenSlice from "../../store/screenSlice"; 6 | import navSlice from "../../store/navSlice"; 7 | 8 | export default function ControlPadPage() { 9 | const hasBeenTouched = useSelector(controlPadSlice.selectors.hasBeenTouched); 10 | const sideLength = useSelector(screenSlice.selectors.sideLength); 11 | const lastDirection = useSelector(navSlice.selectors.lastDirection); 12 | 13 | return ( 14 |
15 |
16 | 17 |
18 | 19 | Options 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/pages/ControlPadSettings.css: -------------------------------------------------------------------------------- 1 | .ControlPadSettings { 2 | align-items: center; 3 | animation-duration: var(--transition-medium); 4 | animation-name: fade-in; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/pages/ControlPadSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import ButtonPrimary from "../atoms/ButtonPrimary"; 3 | import InputLabel from "../atoms/InputLabel"; 4 | import CheckboxLabelled from "../molecules/CheckboxLabelled"; 5 | import RangeLabelled from "../molecules/RangeLabelled"; 6 | import InstrumentSelector from "../molecules/InstrumentSelector"; 7 | import { capitalizeWords } from "../../utils/helpers"; 8 | import controlPadSlice from "../../store/controlPadSlice"; 9 | import noteNameFromPitch from "../../audioHelpers/noteNameFromPitch"; 10 | import Selector from "../molecules/Selector"; 11 | import { SCALES } from "../../constants"; 12 | import { ScaleName } from "../../types"; 13 | 14 | const isScaleName = (scaleName: string): scaleName is ScaleName => 15 | Object.hasOwn(SCALES, scaleName); 16 | 17 | export default function ControlPadSettings() { 18 | const dispatch = useDispatch(); 19 | const instrument = useSelector(controlPadSlice.selectors.instrument); 20 | const noScale = useSelector(controlPadSlice.selectors.noScale); 21 | const octave = useSelector(controlPadSlice.selectors.octave); 22 | const range = useSelector(controlPadSlice.selectors.range); 23 | const rootNote = useSelector(controlPadSlice.selectors.rootNote); 24 | const selectedScale = useSelector(controlPadSlice.selectors.selectedScale); 25 | 26 | return ( 27 |
28 |

Control Pad Settings

29 | { 32 | const { value } = e.currentTarget; 33 | if (value !== "Ariadne" && value !== "Prometheus") 34 | throw Error("Invalid instrument"); 35 | dispatch(controlPadSlice.actions.instrumentSet(value)); 36 | }} 37 | label="Instrument" 38 | /> 39 | 43 | dispatch( 44 | controlPadSlice.actions.octaveSet(Number(e.currentTarget.value)), 45 | ) 46 | } 47 | output={octave} 48 | value={octave} 49 | > 50 | Octave 51 | 52 | 56 | dispatch( 57 | controlPadSlice.actions.rangeSet(Number(e.currentTarget.value)), 58 | ) 59 | } 60 | output={range} 61 | value={range} 62 | > 63 | Range 64 | 65 | 68 | dispatch(controlPadSlice.actions.noScaleSet(e.currentTarget.checked)) 69 | } 70 | > 71 | No Scale 72 | 73 | 77 | dispatch( 78 | controlPadSlice.actions.rootNoteSet(Number(e.currentTarget.value)), 79 | ) 80 | } 81 | output={noteNameFromPitch(rootNote)} 82 | value={rootNote} 83 | > 84 | Root Note 85 | 86 | { 89 | const { value } = e.currentTarget; 90 | if (!isScaleName(value)) throw Error("Invalid scale"); 91 | dispatch(controlPadSlice.actions.selectedScaleSet(value)); 92 | }} 93 | label="Scale" 94 | options={Object.keys(SCALES).map((value) => ({ 95 | text: capitalizeWords(value), 96 | value, 97 | }))} 98 | /> 99 |
100 | 101 | OK 102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/components/pages/Instrument.css: -------------------------------------------------------------------------------- 1 | /* TODO: move this somewhere sensible */ 2 | .Instrument { 3 | align-items: center; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/pages/KeyboardSettings.css: -------------------------------------------------------------------------------- 1 | .KeyboardSettings { 2 | align-items: center; 3 | animation-duration: var(--transition-medium); 4 | animation-name: fade-in; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/pages/KeyboardSettings.tsx: -------------------------------------------------------------------------------- 1 | import InstrumentSelector from "../molecules/InstrumentSelector"; 2 | import ButtonPrimary from "../atoms/ButtonPrimary"; 3 | import InputLabel from "../atoms/InputLabel"; 4 | import CheckboxLabelled from "../molecules/CheckboxLabelled"; 5 | import RangeLabelled from "../molecules/RangeLabelled"; 6 | import { useDispatch, useSelector } from "react-redux"; 7 | import keyboardSlice from "../../store/keyboardSlice"; 8 | 9 | export default function KeyboardSettings() { 10 | const dispatch = useDispatch(); 11 | const instrument = useSelector(keyboardSlice.selectors.instrument); 12 | const monophonic = useSelector(keyboardSlice.selectors.monophonic); 13 | const octave = useSelector(keyboardSlice.selectors.octave); 14 | const volume = useSelector(keyboardSlice.selectors.volume); 15 | 16 | return ( 17 |
18 |

Keyboard Settings

19 | { 22 | if ( 23 | e.currentTarget.value !== "Ariadne" && 24 | e.currentTarget.value !== "Prometheus" 25 | ) 26 | throw Error("Invalid instrument"); 27 | dispatch(keyboardSlice.actions.instrumentSet(e.currentTarget.value)); 28 | }} 29 | label="Instrument" 30 | /> 31 | 35 | dispatch( 36 | keyboardSlice.actions.volumeSet(Number(e.currentTarget.value)), 37 | ) 38 | } 39 | output={Math.round(volume * 100)} 40 | step={0.01} 41 | value={volume} 42 | > 43 | Volume 44 | 45 | 49 | dispatch( 50 | keyboardSlice.actions.octaveSet(Number(e.currentTarget.value)), 51 | ) 52 | } 53 | output={octave} 54 | value={octave} 55 | > 56 | Octave 57 | 58 | 61 | dispatch(keyboardSlice.actions.monophonicSet(e.target.checked)) 62 | } 63 | > 64 | Monophonic 65 | 66 |
67 | 68 | OK 69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/pages/PrometheusSettings/ModuleFilter.tsx: -------------------------------------------------------------------------------- 1 | import ControlModule, { Range, Select } from "../../organisms/ControlModule"; 2 | import { capitalizeFirst } from "../../../utils/helpers"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import prometheusSlice from "../../../store/prometheusSlice"; 5 | 6 | const paramsAll = ["frequency", "gain", "Q"]; 7 | const paramsFrequencyGain = ["frequency", "gain"]; 8 | const paramsFrequencyQ = ["frequency", "Q"]; 9 | 10 | const typesToParams = { 11 | allpass: paramsFrequencyQ, 12 | bandpass: paramsFrequencyQ, 13 | highpass: paramsFrequencyQ, 14 | highshelf: paramsFrequencyGain, 15 | lowpass: paramsFrequencyQ, 16 | lowshelf: paramsFrequencyGain, 17 | notch: paramsFrequencyQ, 18 | peaking: paramsAll, 19 | }; 20 | 21 | export default function ModuleFilter() { 22 | const { frequency, gain, Q, type } = useSelector( 23 | prometheusSlice.selectors.filter, 24 | ); 25 | const dispatch = useDispatch(); 26 | 27 | return ( 28 | 29 | 46 | {typesToParams[type].map((param) => 47 | param === "frequency" ? ( 48 | 56 | dispatch( 57 | prometheusSlice.actions.filterFrequencySet( 58 | Math.exp(Number(e.currentTarget.value)), 59 | ), 60 | ) 61 | } 62 | /> 63 | ) : param === "gain" ? ( 64 | 71 | dispatch( 72 | prometheusSlice.actions.filterGainSet( 73 | Number(e.currentTarget.value), 74 | ), 75 | ) 76 | } 77 | /> 78 | ) : ( 79 | 85 | dispatch( 86 | prometheusSlice.actions.filterQSet( 87 | Number(e.currentTarget.value), 88 | ), 89 | ) 90 | } 91 | /> 92 | ), 93 | )} 94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/pages/PrometheusSettings/ModuleLfo.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import ControlModule, { Range } from "../../organisms/ControlModule"; 3 | import prometheusSlice from "../../../store/prometheusSlice"; 4 | import SelectOscType from "../../shared/SelectOscType"; 5 | 6 | export default function ModuleLfo() { 7 | const { gain, frequency, type } = useSelector(prometheusSlice.selectors.lfo); 8 | const dispatch = useDispatch(); 9 | 10 | return ( 11 | 12 | 15 | dispatch( 16 | prometheusSlice.actions.lfoTypeSet( 17 | e.target.value as OscillatorType, 18 | ), 19 | ) 20 | } 21 | /> 22 | 28 | dispatch( 29 | prometheusSlice.actions.lfoFrequencySet( 30 | Number(e.currentTarget.value), 31 | ), 32 | ) 33 | } 34 | /> 35 | 41 | dispatch( 42 | prometheusSlice.actions.lfoGainSet(Number(e.currentTarget.value)), 43 | ) 44 | } 45 | /> 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/pages/PrometheusSettings/ModuleMaster.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import ControlModule, { Range } from "../../organisms/ControlModule"; 3 | import prometheusSlice from "../../../store/prometheusSlice"; 4 | 5 | export default function ModuleMaster() { 6 | const dispatch = useDispatch(); 7 | const { gain, pan } = useSelector(prometheusSlice.selectors.master); 8 | 9 | return ( 10 |
11 | 12 | 17 | dispatch( 18 | prometheusSlice.actions.masterGainSet( 19 | Number(e.currentTarget.value), 20 | ), 21 | ) 22 | } 23 | /> 24 | 29 | dispatch( 30 | prometheusSlice.actions.masterPanSet( 31 | Number(e.currentTarget.value), 32 | ), 33 | ) 34 | } 35 | /> 36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/pages/PrometheusSettings/ModuleOscSingle.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from "react-redux"; 2 | import ControlModule, { Range } from "../../organisms/ControlModule"; 3 | import prometheusSlice from "../../../store/prometheusSlice"; 4 | import SelectOscType from "../../shared/SelectOscType"; 5 | 6 | interface Props { 7 | detune: number; 8 | gain: number; 9 | id: number; 10 | pan: number; 11 | pitch: number; 12 | type: OscillatorType; 13 | } 14 | 15 | export default function ModuleOscSingle({ 16 | detune, 17 | gain, 18 | id, 19 | pan, 20 | pitch, 21 | type, 22 | }: Props) { 23 | const dispatch = useDispatch(); 24 | 25 | return ( 26 | 27 | 30 | dispatch( 31 | prometheusSlice.actions.oscillatorSinglesPatch({ 32 | id, 33 | type: e.target.value as OscillatorType, 34 | }), 35 | ) 36 | } 37 | /> 38 | 43 | dispatch( 44 | prometheusSlice.actions.oscillatorSinglesPatch({ 45 | id, 46 | gain: Number(e.target.value), 47 | }), 48 | ) 49 | } 50 | /> 51 | 56 | dispatch( 57 | prometheusSlice.actions.oscillatorSinglesPatch({ 58 | id, 59 | pan: Number(e.currentTarget.value), 60 | }), 61 | ) 62 | } 63 | /> 64 | 71 | dispatch( 72 | prometheusSlice.actions.oscillatorSinglesPatch({ 73 | id, 74 | pitch: Number(e.currentTarget.value), 75 | }), 76 | ) 77 | } 78 | step={1} 79 | /> 80 | 87 | dispatch( 88 | prometheusSlice.actions.oscillatorSinglesPatch({ 89 | id, 90 | detune: Number(e.currentTarget.value), 91 | }), 92 | ) 93 | } 94 | /> 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/components/pages/PrometheusSettings/ModuleOscSuper.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from "react-redux"; 2 | import ControlModule, { Range, Select } from "../../organisms/ControlModule"; 3 | import prometheusSlice from "../../../store/prometheusSlice"; 4 | 5 | const SelectOscType = ({ 6 | defaultValue, 7 | onChange, 8 | }: { 9 | defaultValue: string; 10 | onChange: (e: React.ChangeEvent) => void; 11 | }) => ( 12 | 19 | ); 20 | 21 | interface Props { 22 | detune: number; 23 | gain: number; 24 | id: number; 25 | numberOfOscillators: number; 26 | pan: number; 27 | pitch: number; 28 | spread: number; 29 | type: string; 30 | } 31 | 32 | export default function ModuleOscSuper({ 33 | detune, 34 | gain, 35 | id, 36 | numberOfOscillators, 37 | pan, 38 | pitch, 39 | spread, 40 | type, 41 | }: Props) { 42 | const dispatch = useDispatch(); 43 | 44 | return ( 45 | 46 | 49 | dispatch( 50 | prometheusSlice.actions.oscillatorSupersPatch({ 51 | id, 52 | type: e.target.value as OscillatorType, 53 | }), 54 | ) 55 | } 56 | /> 57 | 64 | dispatch( 65 | prometheusSlice.actions.oscillatorSupersPatch({ 66 | id, 67 | numberOfOscillators: Number(e.currentTarget.value), 68 | }), 69 | ) 70 | } 71 | step={1} 72 | /> 73 | 80 | dispatch( 81 | prometheusSlice.actions.oscillatorSupersPatch({ 82 | id, 83 | spread: Number(e.currentTarget.value), 84 | }), 85 | ) 86 | } 87 | /> 88 | 93 | dispatch( 94 | prometheusSlice.actions.oscillatorSupersPatch({ 95 | id, 96 | gain: Number(e.target.value), 97 | }), 98 | ) 99 | } 100 | /> 101 | 106 | dispatch( 107 | prometheusSlice.actions.oscillatorSupersPatch({ 108 | id, 109 | pan: Number(e.currentTarget.value), 110 | }), 111 | ) 112 | } 113 | /> 114 | 121 | dispatch( 122 | prometheusSlice.actions.oscillatorSupersPatch({ 123 | id, 124 | pitch: Number(e.currentTarget.value), 125 | }), 126 | ) 127 | } 128 | step={1} 129 | /> 130 | 137 | dispatch( 138 | prometheusSlice.actions.oscillatorSupersPatch({ 139 | id, 140 | detune: Number(e.currentTarget.value), 141 | }), 142 | ) 143 | } 144 | /> 145 | 146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /src/components/pages/PrometheusSettings/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { useNavigate } from "react-router-dom"; 3 | import ButtonPrimary from "../../atoms/ButtonPrimary"; 4 | import ModuleMaster from "./ModuleMaster"; 5 | import ModuleFilter from "./ModuleFilter"; 6 | import ModuleLfo from "./ModuleLfo"; 7 | import ModuleOscSuper from "./ModuleOscSuper"; 8 | import ModuleOscSingle from "./ModuleOscSingle"; 9 | import prometheusSlice from "../../../store/prometheusSlice"; 10 | 11 | export default function PrometheusSettings() { 12 | const navigate = useNavigate(); 13 | 14 | const oscillatorSingles = useSelector( 15 | prometheusSlice.selectors.oscillatorSingles, 16 | ); 17 | const oscillatorSupers = useSelector( 18 | prometheusSlice.selectors.oscillatorSupers, 19 | ); 20 | 21 | return ( 22 | // TOOD: something about this reused classname... 23 |
24 |
25 |

PROMETHEUS

26 | 27 |
28 | 29 | 30 |
31 |
32 | {oscillatorSupers.map( 33 | ({ 34 | detune, 35 | gain, 36 | id, 37 | numberOfOscillators, 38 | pan, 39 | pitch, 40 | spread, 41 | type, 42 | }) => ( 43 | 54 | ), 55 | )} 56 |
57 | {oscillatorSingles.map(({ detune, gain, id, pan, pitch, type }) => ( 58 | 67 | ))} 68 |
69 | navigate(-1)}>OK 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/pages/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import ButtonPrimary from "../../atoms/ButtonPrimary"; 2 | import RangeLabelled from "../../molecules/RangeLabelled"; 3 | import navSlice from "../../../store/navSlice"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import settingsSlice from "../../../store/settingsSlice"; 6 | 7 | export default function Settings() { 8 | const lastDirection = useSelector(navSlice.selectors.lastDirection); 9 | const bpm = useSelector(settingsSlice.selectors.bpm); 10 | const dispatch = useDispatch(); 11 | 12 | return ( 13 |
14 | 18 | dispatch(settingsSlice.actions.bpmSet(Number(e.currentTarget.value))) 19 | } 20 | value={bpm} 21 | > 22 | BPM 23 | 24 |
25 | 26 | Keyboard Settings 27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/pages/Settings/style.css: -------------------------------------------------------------------------------- 1 | .Settings { 2 | align-items: center; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/shared/RedirectHome.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | export default function RedirectHome() { 5 | const navigate = useNavigate(); 6 | useEffect(() => navigate("/")); 7 | return null; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/shared/SelectOscType.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from "../organisms/ControlModule"; 2 | 3 | interface Props { 4 | defaultValue: string; 5 | onChange: (e: React.ChangeEvent) => void; 6 | } 7 | 8 | export default function SelectOscType({ defaultValue, onChange }: Props) { 9 | return ( 10 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const INSTRUMENTS = ["Ariadne", "Prometheus"] as const; 2 | 3 | export const KEY_CODES_TO_PITCHES = { 4 | 220: -10, 5 | 90: -9, 6 | 83: -8, 7 | 88: -7, 8 | 68: -6, 9 | 67: -5, 10 | 86: -4, 11 | 71: -3, 12 | 66: -2, 13 | 72: -1, 14 | 78: 0, 15 | 74: 1, 16 | 49: 1, 17 | 77: 2, 18 | 81: 2, 19 | 87: 3, 20 | 188: 3, 21 | 51: 4, 22 | 76: 4, 23 | 69: 5, 24 | 190: 5, 25 | 186: 6, 26 | 52: 6, 27 | 59: 6, 28 | 82: 7, 29 | 191: 7, 30 | 84: 8, 31 | 222: 9, 32 | 54: 9, 33 | 89: 10, 34 | 55: 11, 35 | 85: 12, 36 | 56: 13, 37 | 73: 14, 38 | 79: 15, 39 | 48: 16, 40 | 80: 17, 41 | 189: 18, 42 | 219: 19, 43 | 221: 20, 44 | } as const; 45 | 46 | export const NAV = [ 47 | ["/", "Pad"], 48 | ["/settings", "🔧"], 49 | ["/about", "?"], 50 | ] as const; 51 | 52 | export const SCALES = { 53 | "aeolian (minor)": [0, 2, 3, 5, 7, 8, 10], 54 | dorian: [0, 2, 3, 5, 7, 9, 10], 55 | "harmonic minor": [0, 2, 3, 5, 7, 8, 11], 56 | "ionian (major)": [0, 2, 4, 5, 7, 9, 11], 57 | locrian: [0, 1, 3, 5, 6, 8, 10], 58 | lydian: [0, 2, 4, 6, 7, 9, 11], 59 | mixolydian: [0, 2, 4, 5, 7, 9, 10], 60 | pentatonic: [0, 3, 5, 7, 10], 61 | phrygian: [0, 1, 3, 5, 7, 8, 10], 62 | "phrygian dominant": [0, 1, 4, 5, 7, 8, 10], 63 | wholetone: [0, 2, 4, 6, 8, 10], 64 | } as const; 65 | -------------------------------------------------------------------------------- /src/constants/colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --blue-05: hsl(210, 62%, 5%); 3 | --blue-10: hsl(213, 64%, 10%); 4 | --blue-20: hsl(216, 68%, 20%); 5 | --blue-30: hsl(219, 72%, 30%); 6 | --blue-40: hsl(222, 76%, 40%); 7 | --blue-50: hsl(225, 80%, 50%); 8 | --blue-60: hsl(228, 84%, 60%); 9 | --blue-70: hsl(231, 88%, 70%); 10 | --blue-80: hsl(234, 92%, 80%); 11 | --blue-90: hsl(237, 96%, 90%); 12 | --gray-00: hsl(0, 0%, 0%); 13 | --gray-10: hsl(0, 0%, 10%); 14 | --gray-20: hsl(0, 0%, 20%); 15 | --gray-30: hsl(0, 0%, 30%); 16 | --gray-40: hsl(0, 0%, 40%); 17 | --gray-50: hsl(0, 0%, 50%); 18 | --gray-60: hsl(0, 0%, 60%); 19 | --gray-70: hsl(0, 0%, 70%); 20 | --gray-80: hsl(0, 0%, 80%); 21 | --gray-90: hsl(0, 0%, 90%); 22 | --gray-99: hsl(0, 0%, 100%); 23 | --green-05: hsl(135, 62%, 5%); 24 | --green-10: hsl(138, 64%, 10%); 25 | --green-20: hsl(141, 68%, 20%); 26 | --green-30: hsl(144, 72%, 30%); 27 | --green-40: hsl(147, 76%, 40%); 28 | --green-50: hsl(150, 80%, 50%); 29 | --green-60: hsl(153, 84%, 60%); 30 | --green-70: hsl(156, 88%, 70%); 31 | --green-80: hsl(159, 92%, 80%); 32 | --green-90: hsl(162, 96%, 90%); 33 | --red-05: hsl(29, 62%, 5%); 34 | --red-10: hsl(27, 64%, 10%); 35 | --red-20: hsl(24, 68%, 20%); 36 | --red-30: hsl(21, 72%, 30%); 37 | --red-40: hsl(18, 76%, 40%); 38 | --red-50: hsl(15, 80%, 50%); 39 | --red-60: hsl(12, 84%, 60%); 40 | --red-70: hsl(9, 88%, 70%); 41 | --red-80: hsl(6, 92%, 80%); 42 | --red-90: hsl(3, 96%, 90%); 43 | --pink-50: #d6e; 44 | } 45 | -------------------------------------------------------------------------------- /src/constants/timings.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --transition-fast: 0.2s; 3 | --transition-medium: 0.33s; 4 | --transition-slow: 1.5s; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | Andromeda - make music in your browser 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./constants/colors.css"; 2 | import "./constants/timings.css"; 3 | import "./utils/keyframes.css"; 4 | import "./utils/globals.css"; 5 | 6 | import "./components/atoms/ButtonPrimary.css"; 7 | import "./components/atoms/ButtonSecondary.css"; 8 | import "./components/atoms/HollowButton.css"; 9 | import "./components/atoms/InputLabel.css"; 10 | import "./components/atoms/InputSelect.css"; 11 | import "./components/atoms/LinkExternal.css"; 12 | import "./components/molecules/CheckboxLabelled.css"; 13 | import "./components/molecules/RangeLabelled.css"; 14 | import "./components/organisms/ControlModule.css"; 15 | import "./components/organisms/ControlPad/style.css"; 16 | import "./components/organisms/Navigation.css"; 17 | import "./components/organisms/spinner.css"; 18 | import "./components/organisms/table-center.css"; 19 | import "./components/pages/About/style.css"; 20 | import "./components/pages/ControlPadPage.css"; 21 | import "./components/pages/ControlPadSettings.css"; 22 | import "./components/pages/Instrument.css"; 23 | import "./components/pages/KeyboardSettings.css"; 24 | import "./components/pages/Settings/style.css"; 25 | 26 | import { createRoot } from "react-dom/client"; 27 | import { Provider } from "react-redux"; 28 | import store from "./store"; 29 | import screenSlice from "./store/screenSlice"; 30 | import App from "./components/App"; 31 | import { StrictMode } from "react"; 32 | 33 | const resizeHandler = () => 34 | requestAnimationFrame(() => 35 | store.dispatch( 36 | screenSlice.actions.screenResize({ 37 | sideLength: innerWidth < innerHeight ? innerWidth : innerHeight * 0.8, 38 | width: innerWidth, 39 | }), 40 | ), 41 | ); 42 | 43 | resizeHandler(); 44 | 45 | addEventListener("resize", resizeHandler); 46 | 47 | const rootEl = document.getElementById("app"); 48 | if (!(rootEl instanceof HTMLElement)) throw new Error("Root element not found"); 49 | createRoot(rootEl).render( 50 | 51 | 52 | 53 | 54 | , 55 | ); 56 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { 4 | "src": "assets/icon.svg", 5 | "sizes": "any", 6 | "type": "image/svg+xml" 7 | } 8 | ], 9 | "display": "minimal-ui", 10 | "lang": "en", 11 | "name": "Andromeda", 12 | "orientation": "portrait", 13 | "short_name": "Andromeda", 14 | "start_url": "/" 15 | } 16 | -------------------------------------------------------------------------------- /src/store/ariadneAudioGraphSelector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNode, 3 | gain, 4 | oscillator, 5 | OUTPUT, 6 | stereoPanner, 7 | } from "virtual-audio-graph"; 8 | import ariadneSlice from "./ariadneSlice"; 9 | import { createSelector } from "@reduxjs/toolkit"; 10 | import { Note } from "../types"; 11 | import controlPadSlice from "./controlPadSlice"; 12 | import keyboardSlice from "./keyboardSlice"; 13 | 14 | const oscBank = createNode( 15 | ({ 16 | carrierDetune, 17 | carrierOscType, 18 | gain: gainValue, 19 | frequency, 20 | masterGain, 21 | masterPan, 22 | modulatorDetune, 23 | modulatorOscType, 24 | modulatorRatio, 25 | }: { 26 | carrierDetune: number; 27 | carrierOscType: OscillatorType; 28 | gain: number; 29 | frequency: number; 30 | masterGain: number; 31 | masterPan: number; 32 | modulatorDetune: number; 33 | modulatorOscType: OscillatorType; 34 | modulatorRatio: number; 35 | }) => ({ 36 | 0: gain(["masterPan"], { gain: gainValue / 2 }), 37 | 1: oscillator(0, { 38 | detune: carrierDetune, 39 | frequency, 40 | type: carrierOscType, 41 | }), 42 | 2: gain({ destination: "frequency", key: 1 }, { gain: 1024 }), 43 | 3: oscillator(2, { 44 | detune: modulatorDetune, 45 | frequency: frequency * modulatorRatio, 46 | type: modulatorOscType, 47 | }), 48 | masterGain: gain([OUTPUT], { gain: masterGain }), 49 | masterPan: stereoPanner(["masterGain"], { pan: masterPan }), 50 | }), 51 | ); 52 | 53 | const ariadne = createNode( 54 | ({ 55 | carrierDetune, 56 | carrierOscType, 57 | masterGain, 58 | masterPan, 59 | modulatorDetune, 60 | modulatorOscType, 61 | modulatorRatio, 62 | notes, 63 | }: { 64 | carrierDetune: number; 65 | carrierOscType: OscillatorType; 66 | masterGain: number; 67 | masterPan: number; 68 | modulatorDetune: number; 69 | modulatorOscType: OscillatorType; 70 | modulatorRatio: number; 71 | notes: Note[]; 72 | }) => 73 | notes.reduce( 74 | (acc, { frequency, gain, id }) => 75 | Object.assign({}, acc, { 76 | [id]: oscBank(OUTPUT, { 77 | carrierDetune, 78 | carrierOscType, 79 | frequency, 80 | gain, 81 | masterGain, 82 | masterPan, 83 | modulatorDetune, 84 | modulatorOscType, 85 | modulatorRatio, 86 | }), 87 | }), 88 | {}, 89 | ), 90 | ); 91 | 92 | const ariadneActiveNotesSelector = createSelector( 93 | controlPadSlice.selectors.instrument, 94 | controlPadSlice.selectors.currentNote, 95 | keyboardSlice.selectors.instrument, 96 | keyboardSlice.selectors.currentNotes, 97 | ( 98 | controlPadInstrument, 99 | controlPadNote, 100 | keyboardInstrument, 101 | keyboardNotes, 102 | ): Note[] => { 103 | let notes = keyboardInstrument === "Ariadne" ? keyboardNotes : []; 104 | if (controlPadInstrument === "Ariadne" && controlPadNote) 105 | notes = [...keyboardNotes, controlPadNote]; 106 | return notes; 107 | }, 108 | ); 109 | 110 | export default createSelector( 111 | ariadneActiveNotesSelector, 112 | ariadneSlice.selectors.carrierDetune, 113 | ariadneSlice.selectors.carrierOscType, 114 | ariadneSlice.selectors.masterGain, 115 | ariadneSlice.selectors.masterPan, 116 | ariadneSlice.selectors.modulatorDetune, 117 | ariadneSlice.selectors.modulatorOscType, 118 | ariadneSlice.selectors.modulatorRatio, 119 | ( 120 | notes, 121 | carrierDetune, 122 | carrierOscType, 123 | masterGain, 124 | masterPan, 125 | modulatorDetune, 126 | modulatorOscType, 127 | modulatorRatio, 128 | ) => { 129 | if (!notes.length) return; 130 | return ariadne(0, { 131 | carrierDetune, 132 | carrierOscType, 133 | masterGain, 134 | masterPan, 135 | modulatorDetune, 136 | modulatorOscType, 137 | modulatorRatio, 138 | notes, 139 | }); 140 | }, 141 | ); 142 | -------------------------------------------------------------------------------- /src/store/ariadneSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | interface AriadneState { 4 | carrierDetune: number; 5 | carrierOscType: OscillatorType; 6 | masterGain: number; 7 | masterPan: number; 8 | modulatorDetune: number; 9 | modulatorOscType: OscillatorType; 10 | modulatorRatio: number; 11 | } 12 | 13 | const initialState: AriadneState = { 14 | carrierDetune: 0, 15 | carrierOscType: "sine", 16 | masterGain: 1, 17 | masterPan: 0, 18 | modulatorDetune: 0, 19 | modulatorOscType: "sine", 20 | modulatorRatio: 2.5, 21 | }; 22 | 23 | export default createSlice({ 24 | name: "ariadne", 25 | initialState, 26 | reducers: { 27 | carrierDetuneSet: (state, action: PayloadAction) => { 28 | state.carrierDetune = action.payload; 29 | }, 30 | carrierOscTypeSet: (state, action: PayloadAction) => { 31 | state.carrierOscType = action.payload; 32 | }, 33 | masterGainSet: (state, action: PayloadAction) => { 34 | state.masterGain = action.payload; 35 | }, 36 | masterPanSet: (state, action: PayloadAction) => { 37 | state.masterPan = action.payload; 38 | }, 39 | modulatorDetuneSet: (state, action: PayloadAction) => { 40 | state.modulatorDetune = action.payload; 41 | }, 42 | modulatorOscTypeSet: (state, action: PayloadAction) => { 43 | state.modulatorOscType = action.payload; 44 | }, 45 | modulatorRatioSet: (state, action: PayloadAction) => { 46 | state.modulatorRatio = action.payload; 47 | }, 48 | }, 49 | selectors: { 50 | carrierDetune: (state) => state.carrierDetune, 51 | carrierOscType: (state) => state.carrierOscType, 52 | masterGain: (state) => state.masterGain, 53 | masterPan: (state) => state.masterPan, 54 | modulatorDetune: (state) => state.modulatorDetune, 55 | modulatorOscType: (state) => state.modulatorOscType, 56 | modulatorRatio: (state) => state.modulatorRatio, 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /src/store/audioGraphSelector.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from "@reduxjs/toolkit"; 2 | import ariadneAudioGraphSelector from "./ariadneAudioGraphSelector"; 3 | import prometheusAudioGraphSelector from "./prometheusAudioGraphSelector"; 4 | import { 5 | createNode, 6 | gain, 7 | OUTPUT, 8 | stereoPanner, 9 | dynamicsCompressor, 10 | INPUT, 11 | delay, 12 | biquadFilter, 13 | } from "virtual-audio-graph"; 14 | import { IVirtualAudioNodeGraph } from "virtual-audio-graph/dist/types"; 15 | 16 | const leveller = createNode( 17 | ({ 18 | attack, 19 | gain: gainValue, 20 | knee, 21 | pan, 22 | ratio, 23 | release, 24 | threshold, 25 | }: { 26 | attack: number; 27 | gain: number; 28 | knee: number; 29 | pan: number; 30 | ratio: number; 31 | release: number; 32 | threshold: number; 33 | }) => ({ 34 | 0: gain(OUTPUT, { gain: gainValue }), 35 | 1: stereoPanner(0, { pan }), 36 | 2: dynamicsCompressor( 37 | 1, 38 | { attack, knee, ratio, release, threshold }, 39 | INPUT, 40 | ), 41 | }), 42 | ); 43 | 44 | const pingPongDelay = createNode( 45 | ({ 46 | delayTime, 47 | dryLevel, 48 | feedback, 49 | highCut, 50 | lowCut, 51 | maxDelayTime, 52 | pingPong, 53 | wetLevel, 54 | }: { 55 | delayTime: number; 56 | dryLevel: number; 57 | feedback: number; 58 | highCut: number; 59 | lowCut: number; 60 | maxDelayTime: number; 61 | pingPong: boolean; 62 | wetLevel: number; 63 | }) => ({ 64 | 0: gain(OUTPUT, { gain: wetLevel }), 65 | 1: stereoPanner(0, { pan: -1 }), 66 | 2: stereoPanner(0, { pan: 1 }), 67 | 3: delay([2, 8], { delayTime, maxDelayTime }), 68 | 4: gain(3, { gain: feedback }), 69 | 5: delay(pingPong ? [1, 3] : [0, 8], { delayTime, maxDelayTime }), 70 | 6: biquadFilter(5, { frequency: highCut }), 71 | 7: biquadFilter(6, { frequency: lowCut, type: "highpass" }), 72 | 8: gain(7, { gain: feedback }), 73 | 9: gain(OUTPUT, { gain: dryLevel }), 74 | input: gain([8, 9], { gain: 1 }, INPUT), 75 | }), 76 | ); 77 | 78 | export default createSelector( 79 | ariadneAudioGraphSelector, 80 | prometheusAudioGraphSelector, 81 | (ariadneAudioGraph, prometheusAudioGraph) => { 82 | const audioGraph: IVirtualAudioNodeGraph = { 83 | 0: pingPongDelay(OUTPUT, { 84 | delayTime: 1 / 3, 85 | dryLevel: 0.9, 86 | feedback: 0.25, 87 | highCut: 16000, 88 | lowCut: 50, 89 | maxDelayTime: 1.2, 90 | pingPong: true, 91 | wetLevel: 0.6, 92 | }), 93 | 1: leveller(0, { 94 | attack: 0, 95 | gain: 1, 96 | knee: 30, 97 | pan: 0, 98 | ratio: 12, 99 | release: 0.25, 100 | threshold: -50, 101 | }), 102 | }; 103 | if (ariadneAudioGraph) audioGraph[2] = ariadneAudioGraph; 104 | if (prometheusAudioGraph) audioGraph[3] = prometheusAudioGraph; 105 | 106 | return audioGraph; 107 | }, 108 | ); 109 | -------------------------------------------------------------------------------- /src/store/controlPadSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { SCALES } from "../constants"; 3 | import pitchToFrequency from "../audioHelpers/pitchToFrequency"; 4 | import { Instrument, Note, ScaleName } from "../types"; 5 | 6 | interface CurrentCoordinateRatios { 7 | x: number; 8 | y: number; 9 | } 10 | 11 | interface ControlPadState { 12 | currentCoordinateRatios: CurrentCoordinateRatios | undefined; 13 | hasBeenTouched: boolean; 14 | instrument: Instrument; 15 | noScale: boolean; 16 | octave: number; 17 | range: number; 18 | rootNote: number; 19 | selectedScale: ScaleName; 20 | } 21 | 22 | const initialState: ControlPadState = { 23 | currentCoordinateRatios: undefined, 24 | hasBeenTouched: false, 25 | instrument: "Ariadne", 26 | noScale: false, 27 | octave: 0, 28 | range: 1, 29 | rootNote: 0, 30 | selectedScale: "pentatonic", 31 | }; 32 | 33 | export default createSlice({ 34 | name: "controlPad", 35 | initialState, 36 | reducers: { 37 | instrumentSet: (state, action: PayloadAction) => { 38 | state.instrument = action.payload; 39 | }, 40 | noScaleSet: (state, action: PayloadAction) => { 41 | state.noScale = action.payload; 42 | }, 43 | octaveSet: (state, action: PayloadAction) => { 44 | state.octave = action.payload; 45 | }, 46 | rangeSet: (state, action: PayloadAction) => { 47 | state.range = action.payload; 48 | }, 49 | rootNoteSet: (state, action: PayloadAction) => { 50 | state.rootNote = action.payload; 51 | }, 52 | selectedScaleSet: (state, action: PayloadAction) => { 53 | state.selectedScale = action.payload; 54 | }, 55 | setCurrentCoordinateRatios: ( 56 | state, 57 | action: PayloadAction, 58 | ) => { 59 | state.currentCoordinateRatios = action.payload; 60 | if (!state.hasBeenTouched) state.hasBeenTouched = true; 61 | }, 62 | }, 63 | selectors: { 64 | currentNote: (state): Note | undefined => { 65 | if (!state.currentCoordinateRatios) return undefined; 66 | 67 | let pitch: number; 68 | if (state.noScale) 69 | pitch = 12 * state.range * state.currentCoordinateRatios.x; 70 | else { 71 | const scale = SCALES[state.selectedScale]; 72 | const { length } = scale; 73 | const i = Math.floor( 74 | (length + 1) * state.range * state.currentCoordinateRatios.x, 75 | ); 76 | pitch = 77 | scale[((i % length) + length) % length] + 12 * Math.floor(i / length); 78 | } 79 | 80 | return { 81 | frequency: pitchToFrequency(pitch + 12 * state.octave + state.rootNote), 82 | gain: state.currentCoordinateRatios.y, 83 | id: "CONTROL_PAD", 84 | }; 85 | }, 86 | hasBeenTouched: (state) => state.hasBeenTouched, 87 | instrument: (state) => state.instrument, 88 | noScale: (state) => state.noScale, 89 | octave: (state) => state.octave, 90 | range: (state) => state.range, 91 | rootNote: (state) => state.rootNote, 92 | selectedScale: (state) => state.selectedScale, 93 | }, 94 | }); 95 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import ariadneSlice from "./ariadneSlice"; 3 | import controlPadSlice from "./controlPadSlice"; 4 | import keyboardSlice from "./keyboardSlice"; 5 | import navSlice from "./navSlice"; 6 | import prometheusSlice from "./prometheusSlice"; 7 | import screenSlice from "./screenSlice"; 8 | import settingsSlice from "./settingsSlice"; 9 | 10 | export default configureStore({ 11 | reducer: { 12 | ariadne: ariadneSlice.reducer, 13 | controlPad: controlPadSlice.reducer, 14 | keyboard: keyboardSlice.reducer, 15 | nav: navSlice.reducer, 16 | prometheus: prometheusSlice.reducer, 17 | screen: screenSlice.reducer, 18 | settings: settingsSlice.reducer, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/store/keyboardSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { Instrument, Note } from "../types"; 3 | import { KEY_CODES_TO_PITCHES } from "../constants"; 4 | import pitchToFrequency from "../audioHelpers/pitchToFrequency"; 5 | 6 | interface KeyboardState { 7 | instrument: Instrument; 8 | monophonic: boolean; 9 | octave: number; 10 | pressedKeyCodes: number[]; 11 | volume: number; 12 | } 13 | 14 | const initialState: KeyboardState = { 15 | instrument: "Prometheus", 16 | monophonic: false, 17 | octave: 0, 18 | pressedKeyCodes: [], 19 | volume: 0.2, 20 | }; 21 | 22 | const isValidKeyCode = ( 23 | keyCode: number, 24 | ): keyCode is keyof typeof KEY_CODES_TO_PITCHES => 25 | keyCode in KEY_CODES_TO_PITCHES; 26 | 27 | export default createSlice({ 28 | name: "keyboard", 29 | initialState, 30 | reducers: { 31 | instrumentSet: (state, action: PayloadAction) => { 32 | state.instrument = action.payload; 33 | }, 34 | monophonicSet: (state, action: PayloadAction) => { 35 | state.monophonic = action.payload; 36 | }, 37 | octaveSet: (state, action: PayloadAction) => { 38 | state.octave = action.payload; 39 | }, 40 | pressedKeyCodesAdd: (state, action: PayloadAction) => { 41 | state.pressedKeyCodes.push(action.payload); 42 | }, 43 | pressedKeyCodesRemove: (state, action: PayloadAction) => { 44 | return { 45 | ...state, 46 | pressedKeyCodes: state.pressedKeyCodes.filter( 47 | (keyCode) => keyCode !== action.payload, 48 | ), 49 | }; 50 | }, 51 | volumeSet: (state, action: PayloadAction) => { 52 | state.volume = action.payload; 53 | }, 54 | }, 55 | selectors: { 56 | currentNotes: (state): Note[] => { 57 | // TODO add monophony 58 | // if (state.keyboard.monophonic) { 59 | // for (const code of pressedKeyCodes) 60 | // if (isValidKeyCode(code) && code !== keyCode) stopAndRemoveNote(code); 61 | // } 62 | return state.pressedKeyCodes.filter(isValidKeyCode).map((keyCode) => { 63 | const pitch = KEY_CODES_TO_PITCHES[keyCode]; 64 | return { 65 | frequency: pitchToFrequency(pitch + 12 * state.octave), 66 | gain: state.volume, 67 | id: `keyboard-pitch:${pitch}`, 68 | }; 69 | }); 70 | }, 71 | instrument: (state) => state.instrument, 72 | monophonic: (state) => state.monophonic, 73 | octave: (state) => state.octave, 74 | pressedKeyCodes: (state) => state.pressedKeyCodes, 75 | volume: (state) => state.volume, 76 | }, 77 | }); 78 | -------------------------------------------------------------------------------- /src/store/navSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | interface NavState { 4 | lastDirection: "left" | "right"; 5 | } 6 | 7 | const initialState: NavState = { lastDirection: "right" }; 8 | 9 | export default createSlice({ 10 | name: "nav", 11 | initialState, 12 | reducers: { 13 | lastDirectionSet: ( 14 | state, 15 | action: PayloadAction, 16 | ) => { 17 | state.lastDirection = action.payload; 18 | }, 19 | }, 20 | selectors: { 21 | lastDirection: (state) => state.lastDirection, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/store/prometheusAudioGraphSelector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | biquadFilter, 3 | createNode, 4 | gain as gainNode, 5 | oscillator, 6 | OUTPUT, 7 | stereoPanner, 8 | } from "virtual-audio-graph"; 9 | import pitchToFrequency from "../audioHelpers/pitchToFrequency"; 10 | import prometheusSlice, { PrometheusState } from "./prometheusSlice"; 11 | import { Note } from "../types"; 12 | import { IVirtualAudioNodeGraph } from "virtual-audio-graph/dist/types"; 13 | 14 | import { createSelector } from "@reduxjs/toolkit"; 15 | import controlPadSlice from "./controlPadSlice"; 16 | import keyboardSlice from "./keyboardSlice"; 17 | 18 | const prometheusActiveNotesSelector = createSelector( 19 | controlPadSlice.selectors.instrument, 20 | controlPadSlice.selectors.currentNote, 21 | keyboardSlice.selectors.instrument, 22 | keyboardSlice.selectors.currentNotes, 23 | ( 24 | controlPadInstrument, 25 | controlPadNote, 26 | keyboardInstrument, 27 | keyboardNotes, 28 | ): Note[] => { 29 | let notes = keyboardInstrument === "Prometheus" ? keyboardNotes : []; 30 | if (controlPadInstrument === "Prometheus" && controlPadNote) 31 | notes = [...keyboardNotes, controlPadNote]; 32 | return notes; 33 | }, 34 | ); 35 | 36 | const frequencyToPitch = (frequency: number) => Math.log2(frequency / 440) * 12; 37 | 38 | const lfoNode = createNode( 39 | ({ 40 | frequency, 41 | gain, 42 | type, 43 | }: { 44 | frequency: number; 45 | gain: number; 46 | type: OscillatorType; 47 | }) => ({ 48 | 0: gainNode(OUTPUT, { gain }), 49 | 1: oscillator(0, { frequency, type }), 50 | }), 51 | ); 52 | 53 | const osc = createNode( 54 | ({ 55 | detune = 0, 56 | frequency, 57 | gain, 58 | pan, 59 | pitch, 60 | type, 61 | }: { 62 | detune: number; 63 | frequency: number; 64 | gain: number; 65 | pan: number; 66 | pitch: number; 67 | type: OscillatorType; 68 | }) => ({ 69 | 0: gainNode(OUTPUT, { gain }), 70 | 1: stereoPanner(0, { pan }), 71 | 2: oscillator(1, { 72 | detune, 73 | frequency: pitchToFrequency(frequencyToPitch(frequency) + pitch), 74 | type, 75 | }), 76 | }), 77 | ); 78 | 79 | const prometheus = createNode( 80 | ({ 81 | filter, 82 | lfo, 83 | master, 84 | oscillatorSingles, 85 | oscillatorSupers, 86 | notes, 87 | }: PrometheusState & { notes: Note[] }) => 88 | notes.reduce( 89 | (acc: IVirtualAudioNodeGraph, { frequency, gain, id }) => { 90 | const noteGainId = `noteGain-${id}`; 91 | acc[noteGainId] = gainNode("filter", { gain }); 92 | 93 | for (let i = 0; i < oscillatorSingles.length; i++) { 94 | const oscillatorSingle = oscillatorSingles[i]; 95 | acc[`oscSingle-${oscillatorSingle.id}-${id}`] = osc( 96 | noteGainId, 97 | Object.assign({}, oscillatorSingle, { 98 | frequency, 99 | }), 100 | ); 101 | } 102 | 103 | for (let i = 0; i < oscillatorSupers.length; i++) { 104 | const oscillatorSuper = oscillatorSupers[i]; 105 | const { numberOfOscillators, type } = oscillatorSuper; 106 | for (let j = 0; j < numberOfOscillators; j++) { 107 | acc[`oscSuper-${oscillatorSuper.id}-${j}-${id}`] = osc(noteGainId, { 108 | detune: 109 | oscillatorSuper.detune + 110 | (j - Math.floor(numberOfOscillators / 2)) * 111 | oscillatorSuper.spread, 112 | frequency, 113 | gain: oscillatorSuper.gain, 114 | pan: oscillatorSuper.pan, 115 | pitch: oscillatorSuper.pitch, 116 | type: 117 | type === "random" 118 | ? (["sawtooth", "sine", "square", "triangle"] as const)[ 119 | Math.floor(Math.random() * 4) 120 | ] 121 | : type, 122 | }); 123 | } 124 | } 125 | 126 | return acc; 127 | }, 128 | { 129 | filter: biquadFilter("masterPan", filter), 130 | lfo: lfoNode({ destination: "frequency", key: "filter" }, lfo), 131 | masterGain: gainNode(OUTPUT, { gain: master.gain }), 132 | masterPan: stereoPanner("masterGain", { pan: master.pan }), 133 | }, 134 | ), 135 | ); 136 | 137 | export default createSelector( 138 | prometheusActiveNotesSelector, 139 | prometheusSlice.selectors.filter, 140 | prometheusSlice.selectors.lfo, 141 | prometheusSlice.selectors.master, 142 | prometheusSlice.selectors.oscillatorSingles, 143 | prometheusSlice.selectors.oscillatorSupers, 144 | (notes, filter, lfo, master, oscillatorSingles, oscillatorSupers) => { 145 | if (!notes.length) return; 146 | return prometheus(0, { 147 | filter, 148 | lfo, 149 | master, 150 | oscillatorSingles, 151 | oscillatorSupers, 152 | notes, 153 | }); 154 | }, 155 | ); 156 | -------------------------------------------------------------------------------- /src/store/prometheusSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | export interface PrometheusState { 4 | filter: { 5 | frequency: number; 6 | gain: number; 7 | Q: number; 8 | type: BiquadFilterType; 9 | }; 10 | lfo: { 11 | frequency: number; 12 | gain: number; 13 | type: OscillatorType; 14 | }; 15 | master: { 16 | gain: number; 17 | pan: number; 18 | }; 19 | oscillatorSingles: { 20 | detune: number; 21 | gain: number; 22 | id: number; 23 | pan: number; 24 | pitch: number; 25 | type: OscillatorType; 26 | }[]; 27 | oscillatorSupers: { 28 | detune: number; 29 | gain: number; 30 | id: number; 31 | numberOfOscillators: number; 32 | pan: number; 33 | pitch: number; 34 | spread: number; 35 | type: OscillatorType | "random"; 36 | }[]; 37 | } 38 | 39 | const initialState: PrometheusState = { 40 | filter: { 41 | frequency: 1300, 42 | gain: -12, 43 | Q: 5, 44 | type: "lowpass", 45 | }, 46 | lfo: { 47 | frequency: 0.3, 48 | gain: 400, 49 | type: "triangle", 50 | }, 51 | master: { 52 | gain: 0.75, 53 | pan: 0, 54 | }, 55 | oscillatorSingles: [ 56 | { detune: 13, gain: 0.5, id: 0, pan: 0.4, pitch: -12, type: "triangle" }, 57 | { detune: -7, gain: 0.8, id: 1, pan: 0.1, pitch: -12, type: "square" }, 58 | { detune: 10, gain: 0.2, id: 2, pan: -0.4, pitch: 0, type: "square" }, 59 | ], 60 | oscillatorSupers: [ 61 | { 62 | detune: -3, 63 | gain: 0.35, 64 | id: 0, 65 | numberOfOscillators: 5, 66 | pan: -0.3, 67 | pitch: 0, 68 | spread: 6, 69 | type: "sawtooth", 70 | }, 71 | ], 72 | }; 73 | 74 | export default createSlice({ 75 | name: "prometheus", 76 | initialState, 77 | reducers: { 78 | masterGainSet: (state, action: PayloadAction) => { 79 | state.master.gain = action.payload; 80 | }, 81 | masterPanSet: (state, action: PayloadAction) => { 82 | state.master.pan = action.payload; 83 | }, 84 | filterFrequencySet: (state, action: PayloadAction) => { 85 | state.filter.frequency = action.payload; 86 | }, 87 | filterGainSet: (state, action: PayloadAction) => { 88 | state.filter.gain = action.payload; 89 | }, 90 | filterQSet: (state, action: PayloadAction) => { 91 | state.filter.Q = action.payload; 92 | }, 93 | filterTypeSet: (state, action: PayloadAction) => { 94 | state.filter.type = action.payload; 95 | }, 96 | lfoFrequencySet: (state, action: PayloadAction) => { 97 | state.lfo.frequency = action.payload; 98 | }, 99 | lfoGainSet: (state, action: PayloadAction) => { 100 | state.lfo.gain = action.payload; 101 | }, 102 | lfoTypeSet: (state, action: PayloadAction) => { 103 | state.lfo.type = action.payload; 104 | }, 105 | oscillatorSinglesPatch: ( 106 | state, 107 | action: PayloadAction< 108 | Partial & { id: number } 109 | >, 110 | ) => { 111 | state.oscillatorSingles[action.payload.id] = { 112 | ...state.oscillatorSingles[action.payload.id], 113 | ...action.payload, 114 | }; 115 | }, 116 | oscillatorSupersPatch: ( 117 | state, 118 | action: PayloadAction< 119 | Partial & { id: number } 120 | >, 121 | ) => { 122 | state.oscillatorSupers[action.payload.id] = { 123 | ...state.oscillatorSupers[action.payload.id], 124 | ...action.payload, 125 | }; 126 | }, 127 | }, 128 | selectors: { 129 | filter: (state) => state.filter, 130 | lfo: (state) => state.lfo, 131 | master: (state) => state.master, 132 | oscillatorSingles: (state) => state.oscillatorSingles, 133 | oscillatorSupers: (state) => state.oscillatorSupers, 134 | }, 135 | }); 136 | -------------------------------------------------------------------------------- /src/store/screenSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | interface ScreenSize { 4 | sideLength: number; 5 | width: number; 6 | } 7 | 8 | const initialState: ScreenSize = { 9 | sideLength: 0, 10 | width: 0, 11 | }; 12 | 13 | export default createSlice({ 14 | name: "screen", 15 | initialState, 16 | reducers: { 17 | screenResize: (state, action: PayloadAction) => { 18 | return { ...state, ...action.payload }; 19 | }, 20 | }, 21 | selectors: { 22 | sideLength: (state: ScreenSize) => state.sideLength, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/store/settingsSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | const bpm = 140; 4 | 5 | interface SettingsState { 6 | bpm: number; 7 | } 8 | 9 | const initialState: SettingsState = { 10 | bpm, 11 | }; 12 | 13 | export default createSlice({ 14 | name: "settings", 15 | initialState, 16 | reducers: { 17 | bpmSet: (state, action: PayloadAction) => { 18 | state.bpm = action.payload; 19 | }, 20 | }, 21 | selectors: { 22 | bpm: (state) => state.bpm, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { INSTRUMENTS, SCALES } from "./constants"; 2 | 3 | export type Instrument = (typeof INSTRUMENTS)[number]; 4 | 5 | export interface Note { 6 | id: string; 7 | gain: number; 8 | frequency: number; 9 | } 10 | 11 | export type ScaleName = keyof typeof SCALES; 12 | -------------------------------------------------------------------------------- /src/utils/globals.css: -------------------------------------------------------------------------------- 1 | html { 2 | min-height: 100%; 3 | 4 | /* Disables pull down to refresh on mobile */ 5 | overscroll-behavior-y: contain; 6 | } 7 | 8 | body { 9 | background: var(--gray-00); 10 | color: var(--gray-99); 11 | font-family: sans-serif; 12 | min-height: 100%; 13 | margin: 0; 14 | overflow-x: hidden; 15 | user-select: none; 16 | } 17 | 18 | .slide-in-left { 19 | animation-duration: var(--transition-medium); 20 | animation-name: slide-in-left; 21 | } 22 | 23 | .slide-in-right { 24 | animation-duration: var(--transition-medium); 25 | animation-name: slide-in-right; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | export const noop = () => {}; 2 | 3 | export const capitalizeFirst = (string: string) => 4 | string[0].toUpperCase() + string.slice(1); 5 | 6 | export const capitalizeWords = (string: string) => 7 | string.split(" ").map(capitalizeFirst).join(" "); 8 | -------------------------------------------------------------------------------- /src/utils/keyframes.css: -------------------------------------------------------------------------------- 1 | @keyframes expand { 2 | 0% { 3 | transform: scale(0, 0); 4 | } 5 | 100% { 6 | transform: scale(1, 1); 7 | } 8 | } 9 | 10 | @keyframes fade-in { 11 | 0% { 12 | opacity: 0; 13 | } 14 | 100% { 15 | opacity: 1; 16 | } 17 | } 18 | 19 | @keyframes pulse { 20 | 0% { 21 | opacity: 0.25; 22 | } 23 | 100% { 24 | opacity: 1; 25 | } 26 | } 27 | 28 | @keyframes slide-in-left { 29 | 0% { 30 | transform: translateX(-100vw); 31 | } 32 | 100% { 33 | transform: translateX(0); 34 | } 35 | } 36 | 37 | @keyframes slide-in-right { 38 | 0% { 39 | transform: translateX(100vw); 40 | } 41 | 100% { 42 | transform: translateX(0); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/webGl.ts: -------------------------------------------------------------------------------- 1 | export const compileShader = ( 2 | gl: WebGLRenderingContext, 3 | src: string, 4 | shaderType: GLenum, 5 | ): WebGLShader => { 6 | const shader = gl.createShader(shaderType); 7 | if (shader === null) throw Error("Failed to create shader"); 8 | gl.shaderSource(shader, src); 9 | gl.compileShader(shader); 10 | return shader; 11 | }; 12 | 13 | export type Matrix16 = [ 14 | number, 15 | number, 16 | number, 17 | number, 18 | number, 19 | number, 20 | number, 21 | number, 22 | number, 23 | number, 24 | number, 25 | number, 26 | number, 27 | number, 28 | number, 29 | number, 30 | ]; 31 | 32 | export const mult = (a: Matrix16, b: Matrix16): Matrix16 => [ 33 | a[0] * b[0] + a[1] * b[4] + a[2] * b[8] + a[3] * b[12], 34 | a[0] * b[1] + a[1] * b[5] + a[2] * b[9] + a[3] * b[13], 35 | a[0] * b[2] + a[1] * b[6] + a[2] * b[10] + a[3] * b[14], 36 | a[0] * b[3] + a[1] * b[7] + a[2] * b[11] + a[3] * b[15], 37 | a[4] * b[0] + a[5] * b[4] + a[6] * b[8] + a[7] * b[12], 38 | a[4] * b[1] + a[5] * b[5] + a[6] * b[9] + a[7] * b[13], 39 | a[4] * b[2] + a[5] * b[6] + a[6] * b[10] + a[7] * b[14], 40 | a[4] * b[3] + a[5] * b[7] + a[6] * b[11] + a[7] * b[15], 41 | a[8] * b[0] + a[9] * b[4] + a[10] * b[8] + a[11] * b[12], 42 | a[8] * b[1] + a[9] * b[5] + a[10] * b[9] + a[11] * b[13], 43 | a[8] * b[2] + a[9] * b[6] + a[10] * b[10] + a[11] * b[14], 44 | a[8] * b[3] + a[9] * b[7] + a[10] * b[11] + a[11] * b[15], 45 | a[12] * b[0] + a[13] * b[4] + a[14] * b[8] + a[15] * b[12], 46 | a[12] * b[1] + a[13] * b[5] + a[14] * b[9] + a[15] * b[13], 47 | a[12] * b[2] + a[13] * b[6] + a[14] * b[10] + a[15] * b[14], 48 | a[12] * b[3] + a[13] * b[7] + a[14] * b[11] + a[15] * b[15], 49 | ]; 50 | 51 | export const rotateX = (a: number): Matrix16 => { 52 | const c = Math.cos(a); 53 | const s = Math.sin(a); 54 | 55 | return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]; 56 | }; 57 | 58 | export const rotateY = (a: number): Matrix16 => { 59 | const c = Math.cos(a); 60 | const s = Math.sin(a); 61 | 62 | return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; 63 | }; 64 | 65 | export const rotateZ = (a: number): Matrix16 => { 66 | const c = Math.cos(a); 67 | const s = Math.sin(a); 68 | 69 | return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; 70 | }; 71 | 72 | export const translate = (x: number, y: number, z: number): Matrix16 => [ 73 | 1, 74 | 0, 75 | 0, 76 | 0, 77 | 0, 78 | 1, 79 | 0, 80 | 0, 81 | 0, 82 | 0, 83 | 1, 84 | 0, 85 | x, 86 | y, 87 | z, 88 | 1, 89 | ]; 90 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "forceConsistentCasingInFileNames": true, 5 | "jsx": "react-jsx", 6 | "module": "ES2022", 7 | "moduleResolution": "bundler", 8 | "noUnusedLocals": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "target": "ES2023" 12 | } 13 | } 14 | --------------------------------------------------------------------------------