├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── icons │ ├── icon-play.svg │ ├── icon-pause.svg │ ├── icon-forward.svg │ └── icon-rewind.svg ├── manifest.json └── index.html ├── src ├── fonts │ ├── AlumniSans.woff2 │ └── RobotoCondensed.woff2 ├── utils │ ├── AudioMath.ts │ ├── StringUtil.ts │ └── PitchUtil.ts ├── setupTests.js ├── App.test.js ├── reportWebVitals.js ├── index.tsx ├── styles │ ├── typography.scss │ ├── _variables.scss │ └── _mixins.scss ├── services │ ├── FileService.ts │ ├── MidiService.ts │ ├── CompositionService.ts │ └── AudioService.ts ├── interfaces │ └── CompositionSource.ts ├── index.scss ├── components │ ├── Player │ │ ├── Player.scss │ │ └── Player.tsx │ ├── CompositionsList │ │ ├── CompositionsList.scss │ │ └── CompositionsList.tsx │ ├── Info │ │ ├── Info.scss │ │ └── Info.tsx │ ├── UI │ │ └── SelectBox.tsx │ └── Form │ │ ├── Form.scss │ │ └── Form.tsx ├── definitions │ ├── scales.json │ └── samples.ts ├── model │ ├── Composition.ts │ ├── Note.ts │ └── Pattern.ts ├── App.scss └── App.tsx ├── tsconfig.json ├── .gitignore ├── LICENSE ├── package.json └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorski/molecular-music-generator-web/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorski/molecular-music-generator-web/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorski/molecular-music-generator-web/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/fonts/AlumniSans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorski/molecular-music-generator-web/HEAD/src/fonts/AlumniSans.woff2 -------------------------------------------------------------------------------- /src/fonts/RobotoCondensed.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorski/molecular-music-generator-web/HEAD/src/fonts/RobotoCondensed.woff2 -------------------------------------------------------------------------------- /src/utils/AudioMath.ts: -------------------------------------------------------------------------------- 1 | export const getMeasureDurationInSeconds = ( bpm:number, beatsPerMeasure:number = 4 ): number => { 2 | return beatsPerMeasure / ( bpm / 60 ); 3 | }; 4 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/utils/StringUtil.ts: -------------------------------------------------------------------------------- 1 | import { CompositionSource } from "../interfaces/CompositionSource"; 2 | 3 | export const getCompositionName = ( data: CompositionSource ): string => { 4 | const notes = data.scale.split( "," ).map( note => note.trim() ); 5 | return data.name || `${data.note1Length}${notes[ 0 ]}${data.note2Length}`; 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "module": "esnext", 6 | "target": "es5", 7 | "jsx": "react-jsx", 8 | "allowJs": true, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /public/icons/icon-play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /public/icons/icon-pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Molecular Music Generator", 3 | "name": "Moleculra Music Generator", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { init } from "./services/AudioService"; 4 | import "./index.scss"; 5 | import App from "./App"; 6 | //import reportWebVitals from "./reportWebVitals"; 7 | 8 | init(); 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById( "root" ) 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | //reportWebVitals(); 21 | -------------------------------------------------------------------------------- /public/icons/icon-forward.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /public/icons/icon-rewind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /src/styles/typography.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Roboto Condensed"; 3 | src: url(../fonts/RobotoCondensed.woff2); 4 | font-style: normal; 5 | font-weight: 700; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: "Alumni Sans"; 11 | src: url(../fonts/AlumniSans.woff2); 12 | font-style: normal; 13 | font-weight: 500; 14 | font-display: swap; 15 | } 16 | 17 | @mixin titleFont() { 18 | font-family: "Alumni Sans", sans-serif; 19 | } 20 | 21 | @mixin customFont() { 22 | font-family: "Roboto Condensed", sans-serif; 23 | } 24 | 25 | h2, h3 { 26 | @include customFont(); 27 | } 28 | 29 | p, li { 30 | font-size: 90%; 31 | } 32 | 33 | a { 34 | color: $color-1; 35 | } 36 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | /* application variables */ 2 | 3 | $app-width: 1200px; 4 | $tablet-width: 990px; // tablet cut off point 5 | $mobile-width: 685px; // the threshold below which we start to use the mobile view 6 | $tablet-height: 640px; // cut off point for app height on tablet size 7 | 8 | $spacing-xxsmall: 2px; 9 | $spacing-xsmall: 4px; 10 | $spacing-small: 8px; 11 | $spacing-medium: 14px; 12 | $spacing-large: 28px; 13 | $spacing-xlarge: 56px; 14 | $spacing-xxlarge: 112px; 15 | 16 | $color-1: #F9C846; // Maize Crayola 17 | $color-2: #302f4b; // Space cadet 18 | $color-3: #8D909B; // Fuzzy Wuzzy 19 | $color-4: #2D2D39; // Raisin black 20 | $color-5: #545863; // Black Coral 21 | $color-text: #A8AAB3; 22 | $color-background: #383B42; // Onyx 23 | -------------------------------------------------------------------------------- /src/services/FileService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Save given data as a file on disk. When working with Blob URLs 3 | * you can revoke these immediately after this invocation to free resources. 4 | * 5 | * @param {String} data as String, base64 encoded content 6 | * @param {String} fileName name of file 7 | */ 8 | export const saveAsFile = ( data: string, fileName: string ): void => { 9 | const anchor = document.createElement( "a" ); 10 | anchor.style.display = "none"; 11 | anchor.href = data; 12 | anchor.setAttribute( "download", fileName ); 13 | 14 | // Safari has no download attribute 15 | if ( typeof anchor.download === "undefined" ) { 16 | anchor.setAttribute( "target", "_blank" ); 17 | } 18 | document.body.appendChild( anchor ); 19 | anchor.click(); 20 | document.body.removeChild( anchor ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/interfaces/CompositionSource.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes the input properties to generate 3 | * a composition. 4 | * 5 | * @see Composition.ts and CompositionService.ts 6 | */ 7 | export interface CompositionSource { 8 | name: string, 9 | description: string, 10 | timeSigBeatAmount: number, 11 | timeSigBeatUnit: number, 12 | tempo: number, 13 | scale: string, 14 | note1Length: number, 15 | note2Length: number, 16 | patternLength: number, 17 | patternAmount: number, 18 | octaveLower: number, 19 | octaveUpper: number, 20 | uniqueTrackPerPattern: boolean 21 | }; 22 | 23 | /** 24 | * Extends CompositionSource to include source scale selection 25 | * allowing easy modification of key or note order 26 | * 27 | * @see Form.tsx 28 | */ 29 | export interface ScaledCompositionSource extends CompositionSource { 30 | scaleSelect: { 31 | note: string, 32 | name: string 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import "./styles/_mixins"; 2 | 3 | body { 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 6 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 7 | sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | background-color: $color-background; 11 | 12 | @include large() { 13 | overflow: hidden; // will have scrollable panels 14 | } 15 | 16 | ::-webkit-scrollbar { 17 | width: 12px; 18 | } 19 | 20 | ::-webkit-scrollbar-corner { 21 | background-color: $color-background; 22 | } 23 | 24 | ::-webkit-scrollbar-track { 25 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); 26 | border-radius: $spacing-medium; 27 | background-color: $color-background; 28 | } 29 | 30 | ::-webkit-scrollbar-thumb { 31 | border-radius: $spacing-medium; 32 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.5); 33 | background-color: $color-1; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Igor Zinken 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/Player/Player.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/_mixins"; 2 | @import "../../styles/typography"; 3 | 4 | .player { 5 | display: inline-block; 6 | 7 | &__transport-button { 8 | display: inline-block; 9 | 10 | @include large() { 11 | &:last-of-type { 12 | margin: 0 0 0 $spacing-small !important; 13 | } 14 | } 15 | } 16 | 17 | &__play-button, 18 | &__transport-button { 19 | @include button(); 20 | padding: 6px $spacing-small; 21 | 22 | img { 23 | width: 22px; 24 | vertical-align: middle; 25 | } 26 | } 27 | 28 | &__position { 29 | @include customFont(); 30 | display: inline-block; 31 | text-align: center; 32 | width: 60px; 33 | padding: $spacing-small 0; 34 | background-color: $color-5; 35 | border-radius: $spacing-small; 36 | vertical-align: middle; 37 | 38 | @include mobile() { 39 | display: none; 40 | } 41 | 42 | &--disabled { 43 | background-color: #333; 44 | color: #666; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "molecular-music-generator-web", 3 | "version": "1.0.0", 4 | "homepage": "./", 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.1", 7 | "@testing-library/react": "^12.1.2", 8 | "@testing-library/user-event": "^13.5.0", 9 | "midi-writer-js": "^2.0.1", 10 | "react": "^17.0.2", 11 | "react-dom": "^17.0.2", 12 | "react-scripts": "5.0.0", 13 | "react-toastify": "^8.1.0", 14 | "sass": "^1.49.0", 15 | "styled-components": "^5.3.3", 16 | "tone": "^14.7.77", 17 | "web-vitals": "^2.1.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject" 24 | }, 25 | "eslintConfig": { 26 | "extends": [ 27 | "react-app", 28 | "react-app/jest" 29 | ] 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | }, 43 | "devDependencies": { 44 | "@types/react": "^17.0.39", 45 | "@types/react-dom": "^17.0.13", 46 | "@types/styled-components": "^5.1.24", 47 | "typescript": "^4.6.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/CompositionsList/CompositionsList.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/_mixins"; 2 | 3 | .compositions-list { 4 | @include large() { 5 | flex: 0.3; 6 | } 7 | 8 | @include mobile() { 9 | display: none; // TODO 10 | } 11 | 12 | &__title { 13 | font-size: 100%; 14 | margin: 0 0 $spacing-medium $spacing-medium; 15 | } 16 | 17 | &__container { 18 | list-style-type: none; 19 | display: flex; 20 | flex-direction: column; 21 | margin: 0; 22 | padding: 0; 23 | @include scrollablePanel(); 24 | border-top-left-radius: $spacing-medium; 25 | background-color: $color-5; 26 | } 27 | 28 | &__entry { 29 | cursor: pointer; 30 | padding: $spacing-small $spacing-large 0; 31 | border-bottom: 1px dotted $color-4; 32 | 33 | &-title { 34 | margin: $spacing-small 0 0; 35 | } 36 | 37 | &-description { 38 | margin-top: $spacing-small; 39 | color: $color-text; 40 | } 41 | 42 | &--active { 43 | background-color: $color-1; 44 | color: $color-4; 45 | 46 | .compositions-list__entry-description { 47 | color: $color-4; 48 | } 49 | } 50 | 51 | &:hover { 52 | background-color: $color-4; 53 | color: #FFF; 54 | 55 | .compositions-list__entry-description { 56 | color: $color-text; 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/definitions/scales.json: -------------------------------------------------------------------------------- 1 | { 2 | "major / ionian": [0, 2, 4, 5, 7, 9, 11], 3 | "minor / aeolian": [0, 2, 3, 5, 7, 8, 10], 4 | "blues": [0, 3, 5, 6, 7, 10], 5 | "byzantine / double harmonic": [0, 1, 4, 5, 7, 8, 11], 6 | "chinese": [0, 4, 6, 7, 11], 7 | "augmented": [0, 3, 4, 7, 8, 11], 8 | "diminished / arabian": [0, 2, 3, 5, 6, 8, 9, 11], 9 | "diminished (half whole / dominant)": [0, 1, 3, 4, 6, 7, 9, 10], 10 | "dorian": [0, 2, 3, 5, 7, 9, 10], 11 | "enigmatic": [0, 1, 4, 6, 8, 10, 11], 12 | "egyptian pentatonic": [0, 2, 5, 7, 10], 13 | "han kumoi": [0, 2, 5, 7, 8 ], 14 | "harmonic minor": [0, 2, 3, 5, 7, 8, 11], 15 | "hatakambari": [0, 1, 4, 5, 7, 10, 11], 16 | "hijaz": [0, 1, 4, 5, 7, 8, 10], 17 | "hirajoshi": [0, 2, 3, 7, 8], 18 | "hungarian gypsy": [0, 2, 3, 6, 7, 8, 11], 19 | "indian (raga asavari) ascending": [0, 1, 5, 7, 8], 20 | "indian (raga asavari) descending": [0, 1, 3, 5, 7, 8, 10], 21 | "locrian": [0, 1, 3, 5, 6, 8, 10], 22 | "lydian": [0, 2, 4, 6, 7, 9, 11], 23 | "lydian augmented": [0, 2, 4, 6, 8, 9, 11], 24 | "mixolydian": [0, 2, 4, 5, 7, 9, 10], 25 | "mongolian": [0, 2, 4, 7, 9], 26 | "oriental": [0, 1, 4, 5, 6, 9, 10], 27 | "pentatonic major": [0, 2, 4, 7, 9], 28 | "pentatonic minor": [0, 3, 5, 7, 10], 29 | "persian": [0, 1, 4, 5, 6, 8, 11], 30 | "phrygian": [0, 1, 3, 5, 7, 8, 10], 31 | "phrygian dominant / freygish": [0, 1, 4, 5, 7, 8, 10 ], 32 | "prometheus": [0, 2, 4, 6, 9, 10], 33 | "prometheus neapolitan": [0, 1, 4, 6, 9, 10], 34 | "romanian / ukranian dorian": [0, 2, 3, 6, 7, 9, 10], 35 | "whole tone": [0, 2, 4, 6, 8, 10] 36 | } 37 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 23 | Molecular Music Generator 24 | 25 | 26 | 27 |
28 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @import "_variables"; 2 | @import "typography"; 3 | 4 | /* 1. responsive breakoff points */ 5 | 6 | // phones 7 | @mixin mobile { 8 | @media (max-width: #{$mobile-width}) { 9 | @content; 10 | } 11 | } 12 | 13 | // anything above phone resolution (e.g. tablet) 14 | @mixin large { 15 | @media (min-width: #{$mobile-width}) { 16 | @content; 17 | } 18 | } 19 | 20 | // anything above tablet resolution (e.g. laptop/desktop) 21 | @mixin ideal { 22 | @media (min-width: #{$app-width}) { 23 | @content; 24 | } 25 | } 26 | 27 | // anything below the ideal resolution 28 | 29 | @mixin lessThanIdeal { 30 | @media (max-width: #{$app-width}) { 31 | @content; 32 | } 33 | } 34 | 35 | /* 2. UX utilities */ 36 | 37 | @mixin truncate { 38 | white-space: nowrap; 39 | overflow: hidden; 40 | text-overflow: ellipsis; 41 | } 42 | 43 | /* 3. base UI components */ 44 | 45 | @mixin button { 46 | @include customFont(); 47 | cursor: pointer; 48 | display: inline-block; 49 | -webkit-appearance: none; 50 | -moz-appearance: none; 51 | appearance: none; 52 | background-color: $color-1; 53 | color: #000; 54 | font-size: 95%; 55 | border: 2px solid #333; 56 | border-radius: 7px; 57 | border: none; 58 | padding: $spacing-small $spacing-medium; 59 | margin-right: $spacing-small; 60 | box-sizing: border-box; 61 | 62 | &:hover { 63 | background-color: #FFF; 64 | } 65 | 66 | &:disabled { 67 | background-color: #333; 68 | color: #666; 69 | cursor: default; 70 | } 71 | } 72 | 73 | @mixin roundButton() { 74 | @include button(); 75 | border-radius: 50%; 76 | } 77 | 78 | @mixin scrollablePanel() { 79 | height: calc(100vh - 215px); 80 | overflow-y: auto; 81 | box-sizing: border-box; 82 | } 83 | -------------------------------------------------------------------------------- /src/components/Info/Info.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/_mixins"; 2 | 3 | .info { 4 | &--opened { 5 | .info__content { 6 | display: block; 7 | background-color: $color-4; 8 | overflow-y: scroll; 9 | } 10 | } 11 | 12 | @include ideal() { 13 | flex: 0.3; 14 | } 15 | 16 | &__title { 17 | font-size: 100%; 18 | margin-top: 0; 19 | } 20 | 21 | &__toggle-button { 22 | 23 | @include roundButton(); 24 | 25 | // toggle button is only visible on tablet size and below, to toggle info visibility 26 | 27 | @include ideal() { 28 | display: none !important; 29 | } 30 | position: fixed; 31 | top: $spacing-medium; 32 | right: $spacing-medium; 33 | margin-right: 0 !important; 34 | z-index: 3; 35 | } 36 | 37 | @include lessThanIdeal() { 38 | &__title { 39 | display: none; 40 | } 41 | 42 | // on tablets and below, the content isn't visible until its toggled 43 | &__content { 44 | display: none; 45 | position: fixed; 46 | top: 0; 47 | left: 0; 48 | width: 100%; 49 | height: 100%; 50 | z-index: 2; 51 | } 52 | } 53 | 54 | &__container { 55 | @include scrollablePanel(); 56 | padding: $spacing-small $spacing-large $spacing-medium; 57 | background-color: $color-5; 58 | color: $color-text; 59 | 60 | ul { 61 | padding: 0 0 0 $spacing-medium; 62 | } 63 | 64 | li { 65 | margin-bottom: $spacing-small; 66 | } 67 | 68 | @include ideal() { 69 | border-bottom-right-radius: $spacing-medium; 70 | } 71 | 72 | @include lessThanIdeal() { 73 | height: 100%; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/model/Composition.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2015-2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import Pattern from "./Pattern"; 25 | 26 | export default class Composition 27 | { 28 | beatAmount : number; 29 | beatUnit : number; 30 | totalMeasures : number; 31 | tempo : number; 32 | patterns : Array; 33 | 34 | constructor( timeSigBeatAmount: number, timeSigBeatUnit: number, tempo: number, 35 | patternLength: number, patternAmount: number ) { 36 | this.beatAmount = timeSigBeatAmount; 37 | this.beatUnit = timeSigBeatUnit; 38 | this.totalMeasures = patternLength * patternAmount; 39 | 40 | this.tempo = tempo; 41 | this.patterns = []; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/CompositionsList/CompositionsList.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import { COMPOSITIONS } from "../../definitions/samples"; 25 | import "./CompositionsList.scss"; 26 | 27 | type CompositionsListProps = { 28 | selected: string, 29 | onSelect: ( event: any ) => void 30 | }; 31 | 32 | export default function CompositionsList({ selected, onSelect }: CompositionsListProps ) { 33 | return ( 34 |
35 |

Sample compositions

36 |
    37 | { 38 | COMPOSITIONS.map( composition => 39 |
  • onSelect( composition ) } 42 | key={ composition.name } 43 | > 44 |

    { composition.name }

    45 |

    { composition.description }

    46 |
  • 47 | ) 48 | } 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/UI/SelectBox.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import styled from "styled-components"; 25 | 26 | const Select = styled.select` 27 | width: 100%; 28 | height: 35px; 29 | background: white; 30 | color: gray; 31 | padding: 0 5px; 32 | font-size: 14px; 33 | border: none; 34 | margin-right: 10px; 35 | border-radius: 7px; 36 | 37 | option { 38 | color: black; 39 | background: white; 40 | display: flex; 41 | white-space: pre; 42 | min-height: 20px; 43 | padding: 0px 2px 1px; 44 | } 45 | `; 46 | 47 | type SelectBoxProps = { 48 | title: string, 49 | items: object, 50 | onChange: ( event: any ) => void, 51 | selected?: string 52 | }; 53 | 54 | /** 55 | * @param {String} title of select box 56 | * @param {Object} items key value pairs of all options to display 57 | * @param {Function} onChange handler to fire when option is selected 58 | * @param {String=} selected name of the optionally selected option key 59 | */ 60 | export default function SelectBox({ title, items, onChange, selected = "" }: SelectBoxProps ) { 61 | 62 | return ( 63 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/model/Note.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2015-2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | export default class Note { 25 | note : string; 26 | octave : number; 27 | offset : number; 28 | duration : number; 29 | measure : number; 30 | 31 | constructor( note: string, octave: number, offset: number, duration: number, measure: number ) { 32 | this.note = note; 33 | this.octave = octave; 34 | this.offset = offset; // offset within the sequence 35 | this.duration = duration; // length of the note 36 | this.measure = measure; // measure the Note belongs to 37 | } 38 | 39 | clone(): Note { 40 | return new Note( this.note, this.octave, this.offset, this.duration, this.measure ); 41 | } 42 | 43 | equals( compareNote: Note ): boolean { 44 | if ( compareNote === this ) { 45 | return true; 46 | } 47 | 48 | return compareNote.note === this.note && 49 | compareNote.octave === this.octave && 50 | compareNote.offset === this.offset && 51 | compareNote.duration === this.duration && 52 | compareNote.measure === this.measure; 53 | } 54 | 55 | overlaps( compareNote: Note ): boolean { 56 | if ( compareNote === this ) { 57 | return false; 58 | } 59 | 60 | return compareNote.note === this.note && 61 | compareNote.octave === this.octave && 62 | compareNote.offset === this.offset && 63 | compareNote.measure === this.measure; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/model/Pattern.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2015 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import Note from "./Note"; 25 | 26 | export default class Pattern 27 | { 28 | name : string; 29 | notes : Array; 30 | patternNum : number; 31 | offset : number; 32 | 33 | constructor( name: string, notes: Array = [], patternNum: number = 0, offset: number = 0 ) { 34 | this.name = name; 35 | this.notes = notes; // all the notes within the pattern 36 | this.patternNum = patternNum; // the number of this pattern within the total sequence 37 | this.offset = offset; // the start offset of the pattern 38 | } 39 | 40 | offsetConflictsWithPattern( compareNoteOffset: number ): boolean { 41 | const noteOffsets = this.getNoteOffsets(); 42 | 43 | for ( const noteOffset of noteOffsets ) { 44 | const actualOffset = noteOffset - this.offset; 45 | if ( actualOffset === 0 ) { 46 | if ( actualOffset === compareNoteOffset ) { 47 | return true; 48 | } 49 | } else if ( compareNoteOffset % actualOffset === 0 ) { 50 | return true; 51 | } 52 | } 53 | return false; 54 | } 55 | 56 | getNoteOffsets(): Array { 57 | return this.notes.map(({ offset }) => offset ); 58 | } 59 | 60 | getRangeStartOffset(): number { 61 | return this.notes[ 0 ]?.offset ?? 0; 62 | } 63 | 64 | getRangeEndOffset(): number { 65 | if ( this.notes.length > 0 ) { 66 | const lastNote = this.notes[ this.notes.length - 1 ]; 67 | return lastNote.offset + lastNote.duration; 68 | } 69 | return 0; 70 | } 71 | 72 | getRangeLength(): number { 73 | return this.getRangeEndOffset() - this.getRangeStartOffset(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | @import "./styles/_mixins"; 2 | @import "./styles/typography"; 3 | 4 | $titleHeight: 66px; 5 | $actionsHeight: 55px; 6 | $headerHeight: #{$titleHeight + $actionsHeight}; 7 | 8 | .app { 9 | height: 100%; 10 | max-height: 100vh; 11 | font-size: 16px; 12 | color: white; 13 | 14 | &__header { 15 | position: fixed; 16 | width: 100%; 17 | height: $headerHeight; 18 | top: 0; 19 | left: 0; 20 | z-index: 1; 21 | background-color: $color-4; 22 | box-sizing: border-box; 23 | } 24 | 25 | &__title { 26 | max-width: $app-width; 27 | margin: 0 auto; 28 | padding: $spacing-small; 29 | display: flex; 30 | justify-content: space-between; 31 | align-items: center; 32 | 33 | @include lessThanIdeal() { 34 | justify-content: center; 35 | } 36 | 37 | &-logo { 38 | height: 50px; 39 | } 40 | 41 | &-text { 42 | @include titleFont(); 43 | font-size: 150%; 44 | color: #F6F6F6; 45 | margin: $spacing-xxsmall 0 0; 46 | } 47 | } 48 | 49 | &__actions { 50 | height: $actionsHeight; 51 | border-bottom: 1px solid $color-5; 52 | background-color: $color-4; 53 | padding: $spacing-small $spacing-medium; 54 | box-sizing: border-box; 55 | 56 | &-container { 57 | max-width: $app-width; 58 | margin: 0 auto; 59 | 60 | @include lessThanIdeal() { 61 | text-align: center; 62 | 63 | .app__actions-descr { 64 | display: none; 65 | } 66 | } 67 | 68 | @include ideal() { 69 | display: flex; 70 | justify-content: space-between; 71 | align-items: center; 72 | } 73 | } 74 | 75 | &-descr { 76 | color: $color-3; 77 | } 78 | 79 | &-ui { 80 | justify-content: center; 81 | display: flex; 82 | align-items: center; 83 | } 84 | 85 | &-button { 86 | @include button(); 87 | } 88 | } 89 | 90 | &__wrapper { 91 | max-width: $app-width; 92 | height: 100%; 93 | margin: 0 auto; 94 | padding-top: $headerHeight; 95 | } 96 | 97 | &__container { 98 | @include large() { 99 | display: flex; 100 | justify-content: center; 101 | margin-top: $spacing-medium; 102 | } 103 | 104 | @include mobile() { 105 | padding: 0 $spacing-medium 0; 106 | margin-top: $spacing-medium; 107 | } 108 | } 109 | 110 | &__footer { 111 | text-align: center; 112 | color: $color-text; 113 | 114 | @include mobile() { 115 | padding: 0 $spacing-small $spacing-small; 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/services/MidiService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import Composition from "../model/Composition"; 25 | import { getMeasureDurationInSeconds } from "../utils/AudioMath"; 26 | 27 | type MIDIWriterTrack = { 28 | addEvent: Function, 29 | setTempo: Function, 30 | addTrackName: Function, 31 | setTimeSignature: Function 32 | }; 33 | type NoteEventDef = { 34 | pitch: string, 35 | duration: string, 36 | startTick: number 37 | }; 38 | type MIDIWriter = { 39 | NoteEvent : new ( data: NoteEventDef ) => {}, 40 | Track : new () => MIDIWriterTrack, 41 | Writer : new ( tracks: MIDIWriterTrack[] ) => { dataUri: () => string }, 42 | }; 43 | 44 | export const createMIDI = async ( composition: Composition ): Promise => { 45 | 46 | const midiWriter: MIDIWriter = ( await import( "midi-writer-js" ) as MIDIWriter ); 47 | const midiTracks: Array = []; 48 | 49 | composition.patterns.forEach(({ name }) => { 50 | const track = new midiWriter.Track(); 51 | track.setTempo( composition.tempo ); 52 | track.addTrackName( name ); 53 | track.setTimeSignature( composition.beatAmount, composition.beatUnit ); 54 | midiTracks.push( track ); 55 | }); 56 | 57 | // all measures have the same duration 58 | const measureDuration = getMeasureDurationInSeconds( composition.tempo, composition.beatAmount ); 59 | // we specify event ranges in ticks (128 ticks == 1 beat) 60 | const TICKS = ( 128 * composition.beatAmount ) / measureDuration; // ticks per measure 61 | 62 | // walk through all patterns 63 | composition.patterns.forEach(( track, trackIndex ) => { 64 | const midiTrack = midiTracks[ trackIndex ]; 65 | track.notes.forEach(({ note, octave, offset, duration }) => { 66 | midiTrack.addEvent( 67 | new midiWriter.NoteEvent({ 68 | pitch : `${note}${octave}`, 69 | duration : `T${Math.round( duration * TICKS )}`, 70 | startTick : Math.round( offset * TICKS ) 71 | }) 72 | ); 73 | }); 74 | }); 75 | return ( new midiWriter.Writer( midiTracks )).dataUri(); 76 | }; 77 | -------------------------------------------------------------------------------- /src/utils/PitchUtil.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Igor Zinken 2016-2022 - https://www.igorski.nl 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | * this software and associated documentation files (the "Software"), to deal in 8 | * the Software without restriction, including without limitation the rights to 9 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | * the Software, and to permit persons to whom the Software is furnished to do so, 11 | * subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | */ 23 | 24 | /** 25 | * order of note names within a single octave 26 | */ 27 | export const OCTAVE_SCALE: Array = [ "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" ]; 28 | 29 | /** 30 | * @param {string} aNote - musical note to return ( A, B, C, D, E, F, G with 31 | * possible enharmonic notes ( 'b' meaning 'flat', '#' meaning 'sharp' ) 32 | * NOTE: flats are CASE sensitive ( to prevent seeing the note 'B' instead of 'b' ) 33 | * @param {number} aOctave - the octave to return ( accepted range 0 - 9 ) 34 | * @return {number} containing exact frequency in Hz for requested note 35 | */ 36 | export const getFrequency = ( aNote: string, aOctave: number ): number => { 37 | let freq; 38 | let enharmonic = 0; 39 | 40 | // detect flat enharmonic 41 | let i = aNote.indexOf( "b" ); 42 | if ( i > -1 ) { 43 | aNote = aNote.substr( i - 1, 1 ); 44 | enharmonic = -1; 45 | } 46 | 47 | // detect sharp enharmonic 48 | i = aNote.indexOf( "#" ); 49 | if ( i > -1 ) { 50 | aNote = aNote.substr( i - 1, 1 ); 51 | enharmonic = 1; 52 | } 53 | 54 | freq = getOctaveIndex( aNote, enharmonic ); 55 | 56 | if ( aOctave === 4 ) { 57 | return freq; 58 | } 59 | else { 60 | // translate the pitches to the requested octave 61 | const d = aOctave - 4; 62 | let j = Math.abs( d ); 63 | 64 | for ( i = 0; i < j; ++i ) { 65 | if ( d > 0 ) { 66 | freq *= 2; 67 | } 68 | else { 69 | freq *= 0.5; 70 | } 71 | } 72 | return freq; 73 | } 74 | }; 75 | 76 | /* internal methods */ 77 | 78 | /** 79 | * pitch table for all notes from C to B at octave 4 80 | * which is used for calculating all pitches at other octaves 81 | */ 82 | const OCTAVE: Array = [ 83 | 261.626, 277.183, 293.665, 311.127, 329.628, 349.228, 369.994, 391.995, 415.305, 440, 466.164, 493.883 84 | ]; 85 | 86 | /** 87 | * retrieves the index in the octave array for a given note 88 | * modifier enharmonic returns the previous ( for a 'flat' note ) 89 | * or next ( for a 'sharp' note ) index 90 | * 91 | * @param {string} aNote ( A, B, C, D, E, F, G ) 92 | * @param {number=} aEnharmonic optional, defaults to 0 ( 0, -1 for flat, 1 for sharp ) 93 | * @return {number} 94 | */ 95 | function getOctaveIndex( aNote: string, aEnharmonic?: number ): number { 96 | if ( typeof aEnharmonic !== "number" ) { 97 | aEnharmonic = 0; 98 | } 99 | 100 | for ( let i = 0, j = OCTAVE.length; i < j; ++i ) { 101 | if ( OCTAVE_SCALE[ i ] === aNote ) { 102 | let k = i + aEnharmonic; 103 | 104 | if ( k > j ) { 105 | return OCTAVE[ 0 ]; 106 | } 107 | if ( k < 0 ) { 108 | return OCTAVE[ OCTAVE.length - 1 ]; 109 | } 110 | return OCTAVE[ k ]; 111 | } 112 | } 113 | return NaN; 114 | } 115 | -------------------------------------------------------------------------------- /src/components/Info/Info.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import { useState } from "react"; 25 | import "./Info.scss"; 26 | 27 | export default function Info( props?: any ) { 28 | const [ opened, setOpened ] = useState( false ); 29 | 30 | return ( 31 |
32 |
33 |

What's this?

34 |
35 |

36 | The Molecular Music Generator (MMG) uses a simple algorithm to generate 37 | musical patterns which can be fed to hardware or software instruments. 38 |

39 |

40 | The algorithm is based on "the Molecular Music Box" by Duncan Lockerby. 41 |

42 |

43 | The rules for the algorithm are as follows: 44 |

45 |
    46 |
  • Two different note lengths need to be defined, e.g. "4" and "3" (in quarter notes)
  • 47 |
  • a scale needs to be defined, e.g. C major (the white keys on a piano), let's say we start on the E note, the list of notes will then contain : E, F, G, A, B, C 48 | a pattern length needs to be defined, e.g. 4 bars
  • 49 |
  • The algorithm will then function like so (keeping the above definitions in mind):
  • 50 |
  • The first note of the scale (E) is played at the length of the first defined note length (4)
  • 51 |
  • Each time the duration of the played note has ended, the next note in the scale (F) is played
  • 52 |
  • Once the first pattern length has been reached (4 bars), a new pattern will start
  • 53 |
  • The previously "recorded" pattern will loop its contents indefinitely while the new patterns are created / played
  • 54 |
  • When a newly played note sounds simultaneously with another note from a PREVIOUS pattern, the note length will change (in above example from 4 to 3)
  • 55 |
  • This will be the new note length to use for all subsequent added notes, until another simultaneously played note is found, leading it to switch back to the previous note length (in above example, back to 4)
  • 56 |
  • As the pattern is now played over an existing one, it is likely that notes will be played in unison, leading to the switching of note length
  • 57 |
  • As more patterns accumulate, a perfectly mathematical pattern of notes are weaving in and out of the notes of the other patterns
  • 58 |
59 |

60 | Experimenting with different note lengths, scales or even time signatures can lead to interesting results! 61 |

62 |
63 |
64 | 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/definitions/samples.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import { CompositionSource } from "../interfaces/CompositionSource"; 25 | 26 | export const DEFAULT_COMPOSITION: CompositionSource = { 27 | name: "Default", 28 | description: "Something to set the mood. Ascending the C minor scale.", 29 | timeSigBeatAmount: 4, 30 | timeSigBeatUnit: 4, 31 | tempo: 120, 32 | scale: "C,D,D#,F,G,G#,A#", 33 | note1Length: 2, 34 | note2Length: 0.5, 35 | patternLength: 16, 36 | patternAmount: 8, 37 | octaveLower: 2, 38 | octaveUpper: 7, 39 | uniqueTrackPerPattern: false 40 | }; 41 | 42 | export const COMPOSITIONS: Array = [ 43 | DEFAULT_COMPOSITION, 44 | { 45 | ...DEFAULT_COMPOSITION, 46 | name: "4F1", 47 | description: "Waltz in the \"saddest of keys\", starting on F.", 48 | timeSigBeatAmount: 3, 49 | timeSigBeatUnit: 4, 50 | tempo: 136, 51 | scale: "F,G,A,Bb,C,D,E", 52 | note1Length: 4, 53 | note2Length: 1, 54 | patternLength: 6, 55 | patternAmount: 16, 56 | octaveLower: 3, 57 | octaveUpper: 6, 58 | }, 59 | { 60 | ...DEFAULT_COMPOSITION, 61 | name: "4E3", 62 | description: "By Duncan Lockerby, as explained in his video. C major starting on E.", 63 | scale: "E,F,G,A,B,C,D", 64 | note1Length: 4, 65 | note2Length: 3, 66 | }, 67 | { 68 | ...DEFAULT_COMPOSITION, 69 | name: "10 G 3.5", 70 | description: "By Duncan Lockerby. A slow piece in C major, starting on G.", 71 | scale: "G,A,B,C,D,E,F", 72 | note1Length: 10, 73 | note2Length: 3.5, 74 | }, 75 | { 76 | ...DEFAULT_COMPOSITION, 77 | name: "9 C 14.5", 78 | description: "By Duncan Lockerby", 79 | tempo: 220, 80 | scale: "C,D,E,F,G,A,B", 81 | note1Length: 9, 82 | note2Length: 14.5, 83 | }, 84 | { 85 | ...DEFAULT_COMPOSITION, 86 | name: "11.5 B 4", 87 | description: "By Duncan Lockerby. C major starting on B.", 88 | tempo: 240, 89 | scale: "B,C,D,E,F,G,A", 90 | note1Length: 11.5, 91 | note2Length: 4, 92 | }, 93 | { 94 | ...DEFAULT_COMPOSITION, 95 | name: "0.5 C 3", 96 | description: "A frenetic piece, using a shuffled list of intervals in C Romanian / Ukranian Dorian", 97 | timeSigBeatAmount: 4, 98 | timeSigBeatUnit: 4, 99 | tempo: 110, 100 | scale: "A#,F#,D#,G,A,D,C", 101 | note1Length: 0.5, 102 | note2Length: 3, 103 | patternLength: 4, 104 | patternAmount: 8, 105 | octaveLower: 2, 106 | octaveUpper: 7, 107 | }, 108 | { 109 | ...DEFAULT_COMPOSITION, 110 | name: "Enigmatic scale", 111 | description: "The input used by Drosophelia for the song \"6581\", where each pattern was played through a separate Commodore 64.", 112 | tempo: 96, 113 | scale: "C,C#,G#,E,F#,B,G#", 114 | note1Length: 1.5, 115 | note2Length: 4, 116 | patternLength: 8, 117 | patternAmount: 18, 118 | }, 119 | { 120 | ...DEFAULT_COMPOSITION, 121 | name: "Sand Prince", 122 | description: "A brooding piece based around E phrygian dominant in 7/8 time.", 123 | tempo: 140, 124 | timeSigBeatAmount: 7, 125 | timeSigBeatUnit: 8, 126 | scale: "F,G#,E,A,B,D,C", 127 | note1Length: 3, 128 | note2Length: 4, 129 | patternLength: 14, 130 | patternAmount: 32, 131 | }, 132 | { 133 | ...DEFAULT_COMPOSITION, 134 | name: "Diminished scale in 5/8", 135 | description: "The input used by Drosophelia for the industrial piece \"Vexed\", where each pattern was processed by a separate noise synth.", 136 | timeSigBeatAmount: 5, 137 | timeSigBeatUnit: 8, 138 | tempo: 165, 139 | scale: "E,F,G,G#,A#,B,C#,D", 140 | note1Length: 6, 141 | note2Length: 1, 142 | patternLength: 8, 143 | patternAmount: 16, 144 | }, 145 | ]; 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Molecular Music Generator (Web) 2 | 3 | The Molecular Music Generator (MMG) is an application that uses a simple algorithm to generate musical patterns which can be fed to hardware or software instruments. 4 | 5 | The properties of the algorithm can easily be defined in a graphical interface, which will then be rendered into a MIDI file, which can in turn be opened in DAW music software or be played back by synthesizers. 6 | 7 | This is the second installment of the application, which previously was available as a downloadable Java application. 8 | 9 | ### I want to tinker with this with the least effort! 10 | 11 | If you just want to make music and aren't interested in modifying the source code, no worries, you can 12 | jump to the hosted version [right here](https://www.igorski.nl/application/molecular-music-generator) 13 | 14 | Read the sections below to learn how the algorithm works and how to tailor a composition to your liking. 15 | 16 | ## About the algorithm 17 | 18 | The algorithm is based on "the Molecular Music Box" by Duncan Lockerby. 19 | 20 | The rules for the algorithm are as follows : 21 | 22 | * two different note lengths need to be defined, e.g. "4" and "3" (in quarter notes) 23 | * a scale needs to be defined, e.g. C major (the white keys on a piano), let's say we start on the E note, the list of 24 | notes will then contain : E, F, G, A, B, C 25 | * a pattern length needs to be defined, e.g. 4 bars 26 | 27 | The algorithm will then function like so (keeping the above definitions in mind) : 28 | 29 | * the first note of the scale (E) is played at the length of the first defined note length (4) 30 | * each time the duration of the played note has ended, the NEXT note in the scale (F) is played 31 | * once the first pattern length has been reached (4 bars), a new pattern will start 32 | * the previously "recorded" pattern will loop its contents indefinitely while the new patterns are created / played 33 | * if a newly played note sounds simultaneously with another note from a PREVIOUS pattern, the note length will 34 | change (in above example from 4 to 3). 35 | * this will be the new note length to use for ALL SUBSEQUENT added notes, until another simultaneously played 36 | note is found, leading it to switch back to the previous note length (in above example, back to 4). 37 | * as the pattern is now played over an existing one, it is likely that notes will be played in unison, 38 | leading to the switching of note length 39 | * as more patterns are accumulated, a perfectly mathematical pattern of notes are weaving in and out of 40 | the notes of the other patterns 41 | 42 | Experimenting with different note lengths, scales or even time signatures can lead to interesting results! 43 | 44 | See https://www.youtube.com/watch?v=3Z8CuAC_-bg for the original video by Duncan Lockerby. 45 | 46 | ## How to setup the pattern properties in the application 47 | 48 | "First / second note lengths" define a list of two notes that describes the alternate note length/duration as a quarter note. 49 | 50 | "Pattern length" describes the length of a single pattern (in measures). Once the algorithm has generated notes 51 | for the given amount of measures, a new pattern will be created. This process will repeat itself until the configured "amount of patterns" has been reached. For instance: a configuration with a pattern length of 4 and a pattern amount of 8 will result in 32 measures of music. 52 | 53 | You can alter the time signature to any exotic meter of your liking, the first number is the upper numeral in 54 | a time signature, e.g. the "3" in 3/4, while the second number is the lower numeral, e.g. the "4" in 3/4. 55 | 56 | "Min octave" determines the octave at which the composition will start, this cannot be lower than 0. "Max octave" determines the octave at which the composition will end, this cannot be higher than 8. 57 | 58 | The value for "scale" can be changed to any sequence (or length!) of comma separated notes you like, meaning you can use exotic scales, or even determine the movement by creating a sequence in thirds, or by re-introducing a previously defined note, etc. Remember that the scale will be repeated over the determined octave range. You can create sharps and flats too, e.g.: "_Eb_", "_F#_", etc. are all valid input. 59 | 60 | "track per pattern" can be either '_true_' or '_false_'. When true, the resulting .MIDI file will have a unique MIDI track for each new pattern, when false, all the patterns are part of the same MIDI track. If the amount of patterns is high enough for the algorithm to go back down the scale, it is best to have this set to true to avoid conflicts in MIDI notes. 61 | 62 | ## Build scripts 63 | 64 | In the project directory, you can run: 65 | 66 | ``` 67 | npm start 68 | ``` 69 | 70 | Runs the app in the development mode.\ 71 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 72 | 73 | The page will reload when you make changes.\ 74 | You may also see any lint errors in the console. 75 | 76 | ``` 77 | npm test 78 | ``` 79 | 80 | Launches the test runner in the interactive watch mode.\ 81 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 82 | 83 | ``` 84 | npm run build 85 | ``` 86 | 87 | Builds the app for production to the `build` folder.\ 88 | It correctly bundles React in production mode and optimizes the build for the best performance. 89 | 90 | The build is minified and the filenames include the hashes.\ 91 | Your app is ready to be deployed! 92 | 93 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 94 | -------------------------------------------------------------------------------- /src/components/Player/Player.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import { Component } from "react"; 25 | import Composition from "../../model/Composition"; 26 | import { setupCompositionPlayback, play, pause, goToMeasure } from "../../services/AudioService"; 27 | import "./Player.scss"; 28 | 29 | type PlayerProps = { 30 | composition: Composition 31 | }; 32 | 33 | interface PlayerState { 34 | measure : number, 35 | beat : number, 36 | sixteenth : number, 37 | total : number, 38 | time : number, 39 | disabled : boolean, 40 | playing : boolean, 41 | }; 42 | 43 | export default class Player extends Component { 44 | _keyHandler: ( e:any ) => void; 45 | 46 | constructor( props: PlayerProps ) { 47 | super( props ); 48 | 49 | this.state = { 50 | measure : 0, 51 | beat : 0, 52 | sixteenth : 0, 53 | total : 1, 54 | time : 0, 55 | disabled : !props.composition, 56 | playing : false, 57 | }; 58 | } 59 | 60 | componentDidMount(): void { 61 | this._keyHandler = ( e: React.KeyboardEvent ) => { 62 | if ( e.keyCode === 32 && this.props.composition ) { 63 | e.preventDefault(); 64 | this.togglePlayBack( true ); 65 | } 66 | }; 67 | document.body.addEventListener( "keydown", this._keyHandler ); 68 | } 69 | 70 | componentWillUnmount(): void { 71 | document.body.removeEventListener( "keydown", this._keyHandler ); 72 | } 73 | 74 | componentDidUpdate( prevProps: PlayerProps ): void { 75 | if ( this.props.composition === prevProps.composition ) { 76 | return; 77 | } 78 | this.setState({ disabled: false, measure: 0, beat: 0, sixteenth: 0 }); 79 | setupCompositionPlayback( this.props.composition, ( measure: number, beat: number, sixteenth: number, total: number, time: number ) => { 80 | this.setState({ measure, beat, sixteenth, total, time }); 81 | }); 82 | if ( this.state.playing ) { 83 | play(); 84 | } 85 | } 86 | 87 | togglePlayBack( resetPosition: boolean = false ): void { 88 | if ( !this.state.playing ) { 89 | play(); 90 | } else { 91 | pause(); 92 | } 93 | if ( resetPosition ) { 94 | this.setState({ ...this.state, ...goToMeasure( 0 ) }); 95 | } 96 | this.setState({ playing: !this.state.playing }); 97 | } 98 | 99 | goToPreviousMeasure(): void { 100 | this.setState({ ...this.state, ...goToMeasure( this.state.measure - 1 ) }); 101 | } 102 | 103 | goToNextMeasure(): void { 104 | this.setState({ ...this.state, ...goToMeasure( this.state.measure + 1 ) }); 105 | } 106 | 107 | render() { 108 | return ( 109 |
110 | 116 | 122 |
{ this.state.measure + 1 }:{ this.state.beat + 1 }:1
125 | 131 |
132 | ); 133 | } 134 | }; 135 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import { useState, useEffect } from "react"; 25 | import { ToastContainer, toast } from "react-toastify"; 26 | import CompositionsList from "./components/CompositionsList/CompositionsList"; 27 | import Form from "./components/Form/Form"; 28 | import Info from "./components/Info/Info"; 29 | import Player from "./components/Player/Player"; 30 | import { DEFAULT_COMPOSITION } from "./definitions/samples"; 31 | import { CompositionSource, ScaledCompositionSource } from "./interfaces/CompositionSource"; 32 | import { createComposition } from "./services/CompositionService"; 33 | import { createMIDI } from "./services/MidiService"; 34 | import { saveAsFile } from "./services/FileService"; 35 | import { getCompositionName } from "./utils/StringUtil"; 36 | 37 | import "react-toastify/dist/ReactToastify.css"; 38 | import "./App.scss"; 39 | import "./styles/_mixins.scss"; 40 | 41 | // not part of the Composition model, but a data structure used to easily select a scale in the Form 42 | 43 | function App() { 44 | 45 | const [ data, setData ] = useState({ 46 | ...DEFAULT_COMPOSITION, 47 | scaleSelect: { note: "C", name: "" } 48 | }); 49 | 50 | const [ hasChanges, setHasChanges ] = useState( true ); 51 | const [ composition, setComposition ] = useState( null ); 52 | const [ midi, setMidi ] = useState( null ); 53 | 54 | // directly generate composition once data has been submitted (or on request) 55 | 56 | const generateComposition = ( optComposition?: CompositionSource ) => { 57 | try { 58 | setComposition( createComposition( optComposition || data )); 59 | setHasChanges( false ); 60 | } catch ( error ) { 61 | toast( `Error "${error}" occurred during generation of composition. Please verify input parameters and try again.` ); 62 | } 63 | }; 64 | 65 | // directly generate MIDI once composition has been created 66 | 67 | useEffect( () => { 68 | async function generateMIDI() { 69 | try { 70 | if ( composition ) { 71 | composition && setMidi( await createMIDI( composition )); 72 | } 73 | } catch ( error ) { 74 | toast( `Error "${error}" occurred during generation of MIDI file. Please verify input parameters and try again.` ); 75 | } 76 | } 77 | generateMIDI(); 78 | }, [ composition ]); 79 | 80 | 81 | const handleChange = ( data: ScaledCompositionSource ) => { 82 | setData( data ); 83 | setHasChanges( true ); 84 | }; 85 | 86 | const handleCompositionSelect = ( composition: CompositionSource ) => { 87 | setData({ ...composition, scaleSelect: { note: "", name: "" } }); 88 | setHasChanges( false ); 89 | generateComposition( composition ); 90 | }; 91 | 92 | const downloadMIDI = () => { 93 | const fileName = `composition_${getCompositionName( data )}.mid`; 94 | saveAsFile( midi, fileName ); 95 | toast( `MIDI file "${fileName}" generated successfully.` ); 96 | }; 97 | 98 | return ( 99 |
100 |
101 |
102 | Molecular Music Generator logo 103 |

Molecular Music Generator

104 |
105 |
106 |
107 |
Generate/select a composition and press play / the spacebar to toggle playback
108 |
109 | 115 | 121 | 122 |
123 |
124 |
125 |
126 |
127 |
128 | 132 |
133 | 134 |
135 |
136 |
137 |

138 | This is an open source tool by igorski.nl. Transport icons designed by  139 | www.wishforge.games 140 |  on freeicons.io. 141 |

142 |
143 | 144 |
145 | ); 146 | } 147 | export default App; 148 | -------------------------------------------------------------------------------- /src/services/CompositionService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2015-2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import { CompositionSource } from "../interfaces/CompositionSource"; 25 | import Pattern from "../model/Pattern"; 26 | import Note from "../model/Note"; 27 | import Composition from "../model/Composition"; 28 | import { getMeasureDurationInSeconds } from "../utils/AudioMath"; 29 | 30 | export const createComposition = ( props: CompositionSource ): Composition => { 31 | 32 | const out: Composition = new Composition( 33 | props.timeSigBeatAmount, 34 | props.timeSigBeatUnit, 35 | props.tempo, 36 | props.patternLength, 37 | props.patternAmount 38 | ); 39 | 40 | // --- COMPOSITION 41 | 42 | let pattern = new Pattern( "melody" ); 43 | out.patterns.push( pattern ); 44 | 45 | let currentPosition = 0; 46 | let currentBarLength = 0; 47 | let noteLength = props.note1Length; 48 | 49 | const patterns: Array = []; 50 | let notes: Array = []; 51 | 52 | // pre calculate all pitches 53 | 54 | const scale = props.scale.split( "," ).map( pitch => pitch.trim() ); 55 | const pitches = []; 56 | const maxIndex = scale.length - 1; 57 | let octave = props.octaveLower; 58 | 59 | for ( let i = 0, l = scale.length; i < l; ++i ) { 60 | const note = scale[ i ]; 61 | pitches.push({ note, octave }); 62 | 63 | // reached end of the note list ? increment octave 64 | 65 | if ( i === maxIndex && octave < props.octaveUpper ) { 66 | i = -1; // restart note generation for next octave 67 | ++octave; 68 | } 69 | } 70 | 71 | // create patterns from all available pitches 72 | 73 | const MEASURE = getMeasureDurationInSeconds( out.tempo, out.beatAmount ); 74 | const BEAT = MEASURE / out.beatUnit; 75 | const PATTERN_LENGTH = Math.ceil( props.patternLength / out.beatUnit ); 76 | 77 | let currentPattern: Pattern = new Pattern( pattern.name, notes, 0, currentPosition ); 78 | 79 | for ( let i = 0, l = pitches.length; i < l; ++i ) { 80 | 81 | const pitch = pitches[ i ]; 82 | 83 | // swap note length if there is a conflict with a previously added note in another pattern 84 | 85 | if ( offsetConflictsWithPattern( currentPosition - currentPattern.offset, patterns )) { 86 | if ( noteLength === props.note1Length ) { 87 | noteLength = props.note2Length; 88 | } else { 89 | noteLength = props.note1Length; 90 | } 91 | } 92 | 93 | // create new note 94 | 95 | const note: Note = new Note( 96 | pitch.note, pitch.octave, currentPosition, noteLength * BEAT, 97 | Math.floor( currentPosition / MEASURE ) 98 | ); 99 | 100 | // add note to list (so it can be re-added in next iterations) 101 | 102 | notes.push( note ); 103 | 104 | // update current sequence position 105 | 106 | currentPosition += note.duration; 107 | currentBarLength += note.duration; 108 | 109 | // pattern switch ? make it so (this starts the interleaving of the notes and thus, the magic!) 110 | 111 | if (( currentBarLength / MEASURE ) >= PATTERN_LENGTH ) { 112 | patterns.push( currentPattern ); 113 | 114 | // store current notes in new pattern 115 | notes = []; 116 | currentPattern = new Pattern( pattern.name, notes, patterns.length, currentPosition ); 117 | 118 | currentBarLength = 0; 119 | } 120 | 121 | // break the loop when we've rendered the desired amount of patterns 122 | 123 | if ( patterns.length >= props.patternAmount ) { 124 | break; 125 | } 126 | 127 | // if we have reached the end of the pitch range, start again 128 | // from the beginning until we have rendered all the patterns 129 | 130 | if ( i === ( l - 1 )) { 131 | pitches.reverse(); // go down the scale 132 | i = -1; 133 | } 134 | } 135 | 136 | const totalLength = currentPosition; 137 | 138 | // loop all patterns to fit song length, add their notes to the current pattern 139 | 140 | for ( const comparePattern of patterns ) { 141 | let patternLength = 0; 142 | while ( patternLength < ( totalLength - pattern.offset )) { 143 | for ( const note of comparePattern.notes ) { 144 | const offset = note.offset + patternLength; 145 | const clone = note.clone(); 146 | clone.offset = offset; 147 | clone.measure = Math.floor( offset / MEASURE ); 148 | 149 | // take care not to redeclare/overlap the existing Note at its original offset 150 | if ( !hasOverlap( out, clone )) { 151 | pattern.notes.push( clone ); 152 | } 153 | } 154 | patternLength += comparePattern.getRangeLength(); 155 | } 156 | 157 | // create new pattern for next sequence, if specified 158 | 159 | if ( props.uniqueTrackPerPattern ) { 160 | pattern = new Pattern( "melody" ); 161 | out.patterns.push( pattern ); 162 | } 163 | } 164 | return out; 165 | } 166 | 167 | /* internal methods */ 168 | 169 | function offsetConflictsWithPattern( noteOffset: number, patterns: Array ): boolean { 170 | for ( const pattern of patterns ) { 171 | if ( pattern.notes.length > 0 && 172 | pattern.offsetConflictsWithPattern( noteOffset )) { 173 | return true; 174 | } 175 | } 176 | return false; 177 | } 178 | 179 | function hasOverlap( composition: Composition, note: Note ): boolean { 180 | for ( const pattern of composition.patterns ) { 181 | for ( const compareNote of pattern.notes ) { 182 | if ( note.overlaps( compareNote )) { 183 | return true; 184 | } 185 | } 186 | } 187 | return false; 188 | } 189 | -------------------------------------------------------------------------------- /src/components/Form/Form.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @import "../../styles/_mixins"; 3 | 4 | .form { 5 | @include large() { 6 | flex: 0.7; 7 | 8 | &__container { 9 | @include scrollablePanel(); 10 | display: flex; 11 | flex-wrap: wrap; 12 | padding: $spacing-medium $spacing-medium 0; 13 | background-color: $color-5; 14 | border-left: 1px dashed $color-background; 15 | border-right: 1px dashed $color-background; 16 | border-bottom: 1px solid $color-5; 17 | background-image: linear-gradient(to bottom, $color-5, $color-background); 18 | } 19 | 20 | &__section { 21 | vertical-align: top; 22 | flex: 0.5; 23 | 24 | &:first-of-type { 25 | margin-right: $spacing-small; 26 | } 27 | } 28 | } 29 | 30 | &__header { 31 | &-title { 32 | font-size: 100%; 33 | margin: 0 0 $spacing-medium $spacing-medium; 34 | } 35 | } 36 | 37 | &__fieldset { 38 | color: $color-2; 39 | border: 1px dashed $color-3; 40 | border-radius: $spacing-small; 41 | padding: $spacing-large; 42 | margin-bottom: $spacing-medium; 43 | 44 | legend { 45 | @include customFont(); 46 | border: 1px solid $color-3; 47 | border-radius: $spacing-small; 48 | border-top: none; 49 | border-bottom: none; 50 | background-color: $color-1; 51 | padding: $spacing-xsmall $spacing-medium; 52 | margin-left: -$spacing-medium; 53 | } 54 | 55 | label { 56 | font-size: 90%; 57 | color: $color-text; 58 | } 59 | } 60 | 61 | &__wrapper { 62 | display: flex; 63 | justify-content: space-between; 64 | margin: $spacing-small 0; 65 | 66 | label { 67 | flex: 0.7; 68 | } 69 | 70 | input { 71 | flex: 0.3; 72 | 73 | &.full { 74 | flex: 1 !important; 75 | } 76 | 77 | &.form__time-signature-input { 78 | display: inline-block; 79 | flex: none; 80 | text-align: center; 81 | width: 30px; 82 | } 83 | } 84 | 85 | &--padded-top { 86 | margin-top: $spacing-medium; 87 | } 88 | } 89 | 90 | &__expl { 91 | color: $color-text; 92 | font-size: 85%; 93 | margin-top: $spacing-large; 94 | } 95 | 96 | &__time-signature-divider { 97 | @include customFont(); 98 | margin-top: $spacing-small; 99 | } 100 | 101 | &__button { 102 | @include button(); 103 | margin-left: $spacing-small; 104 | } 105 | 106 | input[type=text], 107 | input[type=number] { 108 | border-radius: $spacing-small; 109 | border: none; 110 | padding: $spacing-small $spacing-medium; 111 | } 112 | 113 | input[type=submit] { 114 | display: block; 115 | } 116 | } 117 | 118 | /* range slider */ 119 | 120 | $track-color: #333; 121 | $thumb-color: #FFF; 122 | $thumb-color-hover: #FFF; 123 | $thumb-color-disabled: #666; 124 | 125 | $thumb-radius: 50%; 126 | $thumb-height: $spacing-medium; 127 | $thumb-width: $spacing-medium; 128 | $mobile-thumb-height: 40px; 129 | $mobile-thumb-width: 40px; 130 | $thumb-shadow-size: 1px; 131 | $thumb-shadow-blur: 2px; 132 | $thumb-shadow-color: #111; 133 | $thumb-border-width: 2px; 134 | $thumb-border-color: darken($thumb-color-hover, 5%); 135 | 136 | $track-width: 100%; 137 | $track-height: $spacing-medium; 138 | $track-shadow-size: 0; 139 | $track-shadow-blur: 2px; 140 | $track-shadow-color: #000; 141 | $track-border-width: 1px; 142 | $track-border-color: #000; 143 | 144 | $track-radius: 5px; 145 | $contrast: 5%; 146 | 147 | @mixin shadow($shadow-size, $shadow-blur, $shadow-color) { 148 | box-shadow: $shadow-size $shadow-size $shadow-blur $shadow-color, 0 0 $shadow-size lighten($shadow-color, 5%); 149 | } 150 | 151 | @mixin track() { 152 | width: $track-width; 153 | height: $track-height; 154 | cursor: pointer; 155 | } 156 | 157 | @mixin thumb() { 158 | @include shadow($thumb-shadow-size, $thumb-shadow-blur, $thumb-shadow-color); 159 | border: $thumb-border-width solid $thumb-border-color; 160 | height: $thumb-height; 161 | width: $thumb-width; 162 | border-radius: $thumb-radius; 163 | background: $thumb-color; 164 | cursor: pointer; 165 | 166 | &:hover { 167 | background: $thumb-color-hover; 168 | } 169 | } 170 | 171 | input[type=range] { 172 | flex: 1; 173 | -webkit-appearance: none; 174 | margin: math.div( $thumb-height, 2 ) 0; 175 | width: $track-width; 176 | background-color: transparent; 177 | 178 | &:focus { 179 | outline: none; 180 | } 181 | 182 | &::-webkit-slider-runnable-track { 183 | @include track; 184 | @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color); 185 | background: $track-color; 186 | border-radius: $track-radius; 187 | border: $track-border-width solid $track-border-color; 188 | } 189 | 190 | &::-webkit-slider-thumb { 191 | @include thumb;; 192 | -webkit-appearance: none; 193 | margin-top: math.div( -$track-border-width * 2 + $track-height, 2 ) - math.div( $thumb-height, 2 ); 194 | } 195 | 196 | &:focus::-webkit-slider-runnable-track { 197 | background: lighten($track-color, $contrast); 198 | } 199 | 200 | &::-moz-range-track { 201 | @include track; 202 | @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color); 203 | background: $track-color; 204 | border-radius: $track-radius; 205 | border: $track-border-width solid $track-border-color; 206 | } 207 | &::-moz-range-thumb { 208 | @include thumb;; 209 | } 210 | 211 | &::-ms-track { 212 | @include track; 213 | background: transparent; 214 | border-color: transparent; 215 | border-width: $thumb-width 0; 216 | color: transparent; 217 | } 218 | 219 | &::-ms-fill-lower { 220 | background: darken($track-color, $contrast); 221 | border: $track-border-width solid $track-border-color; 222 | border-radius: $track-radius*2; 223 | @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color); 224 | } 225 | &::-ms-fill-upper { 226 | background: $track-color; 227 | border: $track-border-width solid $track-border-color; 228 | border-radius: $track-radius*2; 229 | @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color); 230 | } 231 | &::-ms-thumb { 232 | @include thumb;; 233 | } 234 | &:focus::-ms-fill-lower { 235 | background: $track-color; 236 | } 237 | &:focus::-ms-fill-upper { 238 | background: lighten($track-color, $contrast); 239 | } 240 | 241 | // disabled state 242 | 243 | &:disabled { 244 | &::-webkit-slider-thumb { 245 | background: $thumb-color-disabled; 246 | border-color: $thumb-color-disabled; 247 | } 248 | &::-moz-range-thumb { 249 | background: $thumb-color-disabled; 250 | border-color: $thumb-color-disabled; 251 | } 252 | &::-ms-thumb { 253 | background: $thumb-color-disabled; 254 | border-color: $thumb-color-disabled; 255 | } 256 | } 257 | } 258 | 259 | @include mobile() { 260 | input[type=range] { 261 | &::-webkit-slider-thumb { 262 | width: $mobile-thumb-width; 263 | height: $mobile-thumb-height; 264 | margin-top: math.div((-$track-border-width * 2 + $track-height), 2 ) - math.div( $mobile-thumb-height, 2 ); 265 | transform: scale(.5); 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/services/AudioService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import type Tone from "tone/build/esm/index.d"; 25 | import Composition from "../model/Composition"; 26 | import Note from "../model/Note"; 27 | import { getMeasureDurationInSeconds } from "../utils/AudioMath"; 28 | import { getFrequency } from "../utils/PitchUtil"; 29 | 30 | const initializeCallbacks: Array = []; 31 | let ToneAPI: typeof Tone = null; 32 | let initialized: boolean = false; 33 | 34 | const parts: Array = []; 35 | const envelopes: Array = []; 36 | const oscillators: Array = []; 37 | let sequence: Tone.Sequence = null; 38 | let limiter: Tone.Limiter = null; 39 | let eq: Tone.EQ3 = null; 40 | let comp: Tone.Compressor = null; 41 | let notes: Array = []; 42 | 43 | const MAX_POLYPHONY = 30; 44 | 45 | /** 46 | * AudioContext can only be started after a user interaction. 47 | * In this method we will automatically start the context when any 48 | * kind of interaction has occurred in the document. 49 | */ 50 | export const init = async () => { 51 | ToneAPI = ( await import( "tone" ) as typeof Tone ); 52 | const events: Array = [ "click", "touchstart", "keydown" ]; 53 | const handler = async () => { 54 | await ToneAPI.start(); 55 | initialized = true; 56 | events.forEach( event => { 57 | document.body.removeEventListener( event, handler, false ); 58 | }); 59 | while ( initializeCallbacks.length ) { 60 | initializeCallbacks.shift()(); 61 | } 62 | }; 63 | events.forEach( event => { 64 | document.body.addEventListener( event, handler, false ); 65 | }); 66 | }; 67 | 68 | /** 69 | * Start playback of the composition 70 | */ 71 | export const play = (): typeof Tone.Transport => ToneAPI.Transport.start(); 72 | 73 | /** 74 | * Halts playback of the composition 75 | */ 76 | export const pause = (): typeof Tone.Transport => ToneAPI.Transport.pause(); 77 | 78 | /** 79 | * Jump to a specific measure in the composition 80 | */ 81 | export const goToMeasure = ( measureNum: number ): { measure: number, beat: number, sixteenths: number } => { 82 | stopPlayingParts(); 83 | ToneAPI.Transport.position = `${measureNum}:0:0`; 84 | enqueueNextMeasure( measureNum ); 85 | 86 | return { measure: measureNum, beat: 0, sixteenths: 0 }; 87 | }; 88 | 89 | /** 90 | * Set up a Synth and Transport within tone.js to play back given composition 91 | */ 92 | export const setupCompositionPlayback = ( composition: Composition, sequencerCallback: Function ): void => { 93 | if ( !initialized ) { 94 | initializeCallbacks.push( setupCompositionPlayback.bind( this, composition, sequencerCallback )); 95 | return; 96 | } 97 | 98 | reset(); 99 | 100 | // lazily create a compressor and limiter to keep all levels in check 101 | 102 | if ( !limiter ) { 103 | limiter = new ToneAPI.Limiter( -20 ).toDestination(); 104 | comp = new ToneAPI.Compressor( -40, 3 ).connect( limiter ); 105 | eq = new ToneAPI.EQ3({ 106 | low : -3, 107 | mid : 0, 108 | high : -10, 109 | lowFrequency : 40, 110 | highFrequency : 6000 111 | }).connect( comp ); 112 | } 113 | 114 | // prepare notes for playback in tone.js 115 | 116 | const measureDuration: number = getMeasureDurationInSeconds( composition.tempo, composition.beatAmount ); 117 | 118 | notes = composition.patterns 119 | .flatMap(({ notes }) => notes ) 120 | .map(( note: Note ) => { 121 | const clone = note.clone(); 122 | clone.offset = note.offset - ( measureDuration * note.measure ); 123 | return clone; 124 | }); 125 | 126 | // set up Transport 127 | 128 | ToneAPI.Transport.timeSignature = [ composition.beatAmount, composition.beatUnit ]; 129 | ToneAPI.Transport.bpm.value = composition.tempo; 130 | 131 | // set a callback that enqueues the next measure on the first beat of a new bar 132 | 133 | sequence = new ToneAPI.Sequence(( time ) => { 134 | const [ bars, beats, sixteenths ] = ( ToneAPI.Transport.position as string ).split( ":" ).map( parseFloat ); 135 | sequencerCallback?.( 136 | bars, beats, sixteenths, composition.totalMeasures, time 137 | ); 138 | //console.warn( Tone.Transport.position ); 139 | if ( beats === 0 && Math.floor( sixteenths ) === 0 ) { 140 | enqueueNextMeasure( bars ); 141 | } 142 | }, [ "C3" ], measureDuration / composition.beatAmount ).start( 0 ); 143 | }; 144 | 145 | /* internal methods */ 146 | 147 | function reset(): void { 148 | stopPlayingParts(); 149 | sequence?.stop(); 150 | sequence?.dispose(); 151 | ToneAPI.Transport.stop(); 152 | } 153 | 154 | function stopPlayingParts(): void { 155 | resetActors( parts ); 156 | resetActors( envelopes ); 157 | resetActors( oscillators ); 158 | } 159 | 160 | function resetActors( actorList: Array ): void { 161 | for ( const actor of actorList ) { 162 | if ( actor instanceof ToneAPI.Oscillator ) { 163 | actor.stop(); 164 | } 165 | actor.dispose(); 166 | } 167 | actorList.length = 0; 168 | } 169 | 170 | function enqueueNextMeasure( measureNum: number, delta: number = 0 ): void { 171 | // note we enqueue the notes in reverse as we want to cap max polyphony by 172 | // excluding the oldest patterns 173 | const notesToEnqueue = notes.filter(({ measure }) => measure === measureNum ).reverse(); 174 | if ( !notesToEnqueue.length ) { 175 | return; 176 | } 177 | if ( notesToEnqueue.length > MAX_POLYPHONY ) { 178 | notesToEnqueue.splice( 0, MAX_POLYPHONY ); 179 | } 180 | parts.push( new ToneAPI.Part(( time, value ) => { 181 | 182 | // we use simple envelopes and oscillators instead of the Tonejs synths 183 | // as they suffer from max polyphony and performance issues on less powerful configurations 184 | 185 | const envelope = new ToneAPI.AmplitudeEnvelope({ 186 | attack : 0.2, 187 | decay : 0.5, 188 | sustain : 0.2, 189 | release : 0.1 190 | }); 191 | envelope.connect( eq ); 192 | 193 | const oscillator = new ToneAPI.Oscillator( 194 | getFrequency( value.note, value.octave ), "sawtooth" 195 | ).start( time ).stop( time + value.duration ); 196 | 197 | oscillator.connect( envelope ) 198 | envelope.triggerAttackRelease( value.duration, time ); 199 | 200 | oscillators.push( oscillator ); 201 | envelopes.push( envelope ); 202 | 203 | }, notesToEnqueue.map( note => ({ 204 | ...note, 205 | // use an array of objects as long as the object has a "time" attribute 206 | time : note.offset + delta//( Tone.now() + note.offset ) + delta 207 | })) ).start()); 208 | } 209 | -------------------------------------------------------------------------------- /src/components/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT) 3 | * 4 | * Copyright (c) 2022 Igor Zinken 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | import { ScaledCompositionSource } from "../../interfaces/CompositionSource"; 25 | import scales from "../../definitions/scales.json"; 26 | import { OCTAVE_SCALE } from "../../utils/PitchUtil"; 27 | import { getCompositionName } from "../../utils/StringUtil"; 28 | import SelectBox from "../UI/SelectBox"; 29 | import "./Form.scss"; 30 | 31 | type FormProps = { 32 | formData: ScaledCompositionSource, 33 | onChange: ( event: any ) => void 34 | }; 35 | 36 | const Form = ({ formData, onChange }: FormProps ) => { 37 | 38 | const data: ScaledCompositionSource = { ...formData }; 39 | 40 | const asFloat = ( value: string ): number|string => { 41 | const valueAsFloat = parseFloat( value ); 42 | return isNaN( valueAsFloat ) ? "" : valueAsFloat; 43 | }; 44 | 45 | const handleChange = ( prop: keyof ScaledCompositionSource, value: any ): void => { 46 | ( data as any )[ prop ] = value; 47 | onChange( data ); 48 | }; 49 | 50 | /* scale related operations */ 51 | 52 | const notes = OCTAVE_SCALE.reduce(( acc, note ) => ({ ...acc, [ note ]: note }), {}); 53 | 54 | const handleNoteSelect = ( event: React.ChangeEvent ): void => { 55 | setNotes(( event.target as HTMLFormElement ).value, data.scaleSelect.name ); 56 | }; 57 | 58 | const handleScaleSelect = ( event: React.ChangeEvent ): void => { 59 | setNotes( data.scaleSelect.note, ( event.target as HTMLFormElement ).value ); 60 | }; 61 | 62 | const setNotes = ( note: string, name: string ): void => { 63 | handleChange( "scaleSelect", { note, name }); 64 | const scaleIntervals = ( scales as any )[ name ]; 65 | if ( !scaleIntervals ) { 66 | return; 67 | } 68 | const scaleStart = OCTAVE_SCALE.indexOf( data.scaleSelect.note ); 69 | const scaleLength = OCTAVE_SCALE.length; 70 | handleChange( "scale", scaleIntervals.map(( index: number ) => { 71 | return OCTAVE_SCALE[( scaleStart + index ) % scaleLength ] 72 | }).filter( Boolean ).join( "," )); 73 | }; 74 | 75 | const shuffleScale = (): void => { 76 | handleChange( 77 | "scale", 78 | data.scale.split( "," ) 79 | .filter( Boolean ) 80 | .map( value => ({ value, sort: Math.random() })) 81 | .sort(( a: { sort: number }, b: { sort: number } ) => a.sort - b.sort) 82 | .map(({ value }) => value.trim() ) 83 | .join( "," ) 84 | ); 85 | }; 86 | 87 | return ( 88 | e.nativeEvent.preventDefault() }> 89 |
90 |

{ getCompositionName( data ) }

91 |
92 |
93 |
94 |
95 | Time signature and tempo 96 |
97 | 98 | handleChange( "timeSigBeatAmount", asFloat( e.target.value )) } 105 | /> 106 | / 107 | handleChange( "timeSigBeatUnit", asFloat( e.target.value )) } 114 | /> 115 |
116 |
117 | 118 | handleChange( "tempo", asFloat( e.target.value )) } 124 | /> 125 |
126 |
127 |
128 | Scale 129 |
130 | 136 | 142 |
143 |
144 | handleChange( "scale", e.target.value ) } 149 | /> 150 | 155 |
156 |

Either select a root note and scale name or directly enter your 157 | scale notes in the input field, separating them using commas.

158 |
159 |
160 | MIDI export options 161 |
162 | 163 | handleChange( "uniqueTrackPerPattern", !data.uniqueTrackPerPattern ) } 168 | /> 169 |
170 |
171 |
172 |
173 |
174 | Pattern properties 175 |
176 | 177 | handleChange( "note1Length", asFloat( e.target.value )) } 184 | /> 185 |
186 |
187 | 188 | handleChange( "note2Length", asFloat( e.target.value )) } 195 | /> 196 |
197 |
198 | 199 | handleChange( "patternLength", asFloat( e.target.value )) } 205 | /> 206 |
207 |
208 | 209 | handleChange( "patternAmount", asFloat( e.target.value )) } 215 | /> 216 |
217 |

Note: all lengths above are defined in beats, relative to the time signatures denominator (lower number).

218 |
219 |
220 | Octave range 221 |
222 | 223 | handleChange( "octaveLower", asFloat( e.target.value )) } 229 | /> 230 |
231 |
232 | 233 | handleChange( "octaveUpper", asFloat( e.target.value )) } 239 | /> 240 |
241 |
242 |
243 |
244 | 245 | ); 246 | }; 247 | export default Form; 248 | --------------------------------------------------------------------------------