mirror of
https://github.com/mtkennerly/bosca-ceoil-js.git
synced 2024-12-22 22:12:22 +01:00
Integrate React and prepare state management for multiple channels and patterns
This commit is contained in:
parent
a1d08f8e54
commit
716982f579
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@ node_modules/
|
|||||||
out/
|
out/
|
||||||
tmp/
|
tmp/
|
||||||
assets/
|
assets/
|
||||||
|
public/*.js
|
||||||
audio/**/*_data/
|
audio/**/*_data/
|
||||||
*.aup
|
*.aup
|
||||||
*.tgz
|
*.tgz
|
||||||
|
@ -28,7 +28,6 @@ Prerequisites:
|
|||||||
Initial setup:
|
Initial setup:
|
||||||
|
|
||||||
* `npm install`
|
* `npm install`
|
||||||
* `npm run assets`
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
|
161
index.html
161
index.html
@ -1,167 +1,12 @@
|
|||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<script src="/bosca-ceoil-js/assets/index.js"></script>
|
<script src="/bosca-ceoil-js/public/index.js"></script>
|
||||||
|
<link rel="stylesheet" href="/bosca-ceoil-js/public/index.css">
|
||||||
<link rel="stylesheet" href="/bosca-ceoil-js/assets/md-icons.css">
|
|
||||||
<link rel="stylesheet" href="/bosca-ceoil-js/assets/material.indigo-pink.min.css">
|
|
||||||
<script defer src="/bosca-ceoil-js/assets/material.min.js"></script>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/bosca-ceoil-js/assets/mdl-selectfield.min.css">
|
|
||||||
<script defer src="/bosca-ceoil-js/assets/mdl-selectfield.min.js"></script>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/bosca-ceoil-js/assets/dialog-polyfill.min.css">
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/bosca-ceoil-js/index.css">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="root"></div>
|
||||||
<div class="metaWorkspace">
|
|
||||||
<button class="mdl-button mdl-js-button mdl-button--raised mdl-button--primary mdl-js-ripple-effect"
|
|
||||||
id="playButton">
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="mdl-selectfield mdl-js-selectfield mdl-selectfield--floating-label">
|
|
||||||
<select class="mdl-selectfield__select" id="instruments">
|
|
||||||
<!-- MIDI -->
|
|
||||||
<option value="midi.piano1">MIDI ⮞ Piano ⮞ Grand Piano</option>
|
|
||||||
<option value="midi.chrom1">MIDI ⮞ Bells ⮞ Celesta</option>
|
|
||||||
<option value="midi.organ1">MIDI ⮞ Organ ⮞ Drawbar Organ</option>
|
|
||||||
<option value="midi.guitar1">MIDI ⮞ Guitar ⮞ Nylon Guitar</option>
|
|
||||||
<option value="midi.bass1">MIDI ⮞ Bass ⮞ Acoustic Bass</option>
|
|
||||||
<option value="midi.strings1">MIDI ⮞ Strings ⮞ Violin</option>
|
|
||||||
<option value="midi.ensemble1">MIDI ⮞ Ensemble ⮞ String Ensemble 1</option>
|
|
||||||
<option value="midi.brass1">MIDI ⮞ Brass ⮞ Trumpet</option>
|
|
||||||
<option value="midi.reed1">MIDI ⮞ Reed ⮞ Soprano Sax</option>
|
|
||||||
<option value="midi.pipe1">MIDI ⮞ Pipe ⮞ Piccolo</option>
|
|
||||||
<option value="midi.lead1">MIDI ⮞ Lead ⮞ Square Lead</option>
|
|
||||||
<option value="midi.pad1">MIDI ⮞ Pad ⮞ New Age Pad</option>
|
|
||||||
<option value="midi.fx1">MIDI ⮞ Synth ⮞ Rain</option>
|
|
||||||
<option value="midi.world1">MIDI ⮞ World ⮞ Sitar</option>
|
|
||||||
<option value="midi.percus1">MIDI ⮞ Drums ⮞ Tinkle Bell</option>
|
|
||||||
<option value="midi.se1">MIDI ⮞ Effects ⮞ Fret Noise</option>
|
|
||||||
|
|
||||||
<!-- Chiptune -->
|
|
||||||
<option value="square">Chiptune ⮞ Square Wave</option>
|
|
||||||
</select>
|
|
||||||
<label class="mdl-selectfield__label">Instrument</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label shortInput">
|
|
||||||
<input class="mdl-textfield__input" id="bpm" type="number" min="10" max="220" step="5" value="120"
|
|
||||||
required />
|
|
||||||
<label class="mdl-textfield__label">BPM</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label shortInput">
|
|
||||||
<input class="mdl-textfield__input" id="volume" type="number" min="0" max="200" step="5" value="100"
|
|
||||||
required />
|
|
||||||
<label class="mdl-textfield__label">Volume</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label shortInput">
|
|
||||||
<input class="mdl-textfield__input" id="swing" type="number" min="0" max="1" step="0.1" value="0"
|
|
||||||
required />
|
|
||||||
<label class="mdl-textfield__label">Swing</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label shortInput">
|
|
||||||
<input class="mdl-textfield__input" id="resonance" type="number" min="0" max="1" step="0.05" value="0"
|
|
||||||
required />
|
|
||||||
<label class="mdl-textfield__label">Resonance</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label shortInput">
|
|
||||||
<input class="mdl-textfield__input" id="dampening" type="number" min="0" max="3000" step="100"
|
|
||||||
value="3000" required />
|
|
||||||
<label class="mdl-textfield__label">Dampening</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="effectsButton"
|
|
||||||
class="mdl-button mdl-js-button mdl-button--raised mdl-button--primary mdl-js-ripple-effect">
|
|
||||||
Effects <i class="material-icons">arrow_drop_down</i>
|
|
||||||
</button>
|
|
||||||
<ul id="effectsMenu" class="mdl-menu mdl-menu--bottom-left mdl-js-menu mdl-js-ripple-effect"
|
|
||||||
for="effectsButton">
|
|
||||||
<li>
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label flexInput">
|
|
||||||
<input class="mdl-textfield__input" id="delayEffect" type="number" min="0" max="1" step="0.05"
|
|
||||||
value="0" required />
|
|
||||||
<label class="mdl-textfield__label">Delay</label>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label flexInput">
|
|
||||||
<input class="mdl-textfield__input" id="chorusEffect" type="number" min="0" max="1" step="0.05"
|
|
||||||
value="0" required />
|
|
||||||
<label class="mdl-textfield__label">Chorus</label>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label flexInput">
|
|
||||||
<input class="mdl-textfield__input" id="reverbEffect" type="number" min="0" max="1" step="0.05"
|
|
||||||
value="0" required />
|
|
||||||
<label class="mdl-textfield__label">Reverb</label>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label flexInput">
|
|
||||||
<input class="mdl-textfield__input" id="distortionEffect" type="number" min="0" max="8" step="1"
|
|
||||||
value="0" required />
|
|
||||||
<label class="mdl-textfield__label">Distortion</label>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label flexInput">
|
|
||||||
<input class="mdl-textfield__input" id="lowBoostEffect" type="number" min="0" max="3000"
|
|
||||||
step="100" value="0" required />
|
|
||||||
<label class="mdl-textfield__label">Low boost</label>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label flexInput">
|
|
||||||
<input class="mdl-textfield__input" id="compressorEffect" type="number" min="-100" max="0"
|
|
||||||
step="5" value="0" required />
|
|
||||||
<label class="mdl-textfield__label">Compressor</label>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label flexInput">
|
|
||||||
<input class="mdl-textfield__input" id="highPassEffect" type="number" min="0" max="3000"
|
|
||||||
step="100" value="0" required />
|
|
||||||
<label class="mdl-textfield__label">High pass</label>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button class="mdl-button mdl-js-button mdl-button--raised mdl-button--primary mdl-js-ripple-effect"
|
|
||||||
id="helpButton">
|
|
||||||
Help
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="patternWorkspace">
|
|
||||||
<table id="pattern">
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dialog id="helpModal" class="mdl-dialog">
|
|
||||||
<h4 class="mdl-dialog__title">Help</h4>
|
|
||||||
<div class="mdl-dialog__content">
|
|
||||||
<ul>
|
|
||||||
<li>Click in a box to add or remove a note.</li>
|
|
||||||
<li>Shift click to extend a note, and ctrl-shift click to shorten it.</li>
|
|
||||||
<li>Press the space bar to play or stop the song.</li>
|
|
||||||
<li>After clicking in an entry field, use the arrow keys to quickly change the value.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="mdl-dialog__actions">
|
|
||||||
<button type="button" class="mdl-button close">Close</button>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
917
package-lock.json
generated
917
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -5,7 +5,6 @@
|
|||||||
"main": "out/index.js",
|
"main": "out/index.js",
|
||||||
"types": "out/index.d.ts",
|
"types": "out/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"assets": "ts-node tasks/assets.ts",
|
|
||||||
"compile": "tsc -p ./",
|
"compile": "tsc -p ./",
|
||||||
"deploy": "ts-node tasks/deploy.ts",
|
"deploy": "ts-node tasks/deploy.ts",
|
||||||
"dev": "concurrently npm:watch npm:webpack-dev npm:serve",
|
"dev": "concurrently npm:watch npm:webpack-dev npm:serve",
|
||||||
@ -31,19 +30,30 @@
|
|||||||
"/out"
|
"/out"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dialog-polyfill": "^0.5.0",
|
"@material/react-button": "^0.14.1",
|
||||||
|
"@material/react-dialog": "^0.15.0",
|
||||||
|
"@material/react-menu-surface": "^0.15.0",
|
||||||
|
"@material/react-select": "^0.14.1",
|
||||||
|
"@material/react-text-field": "^0.14.1",
|
||||||
|
"react": "^16.8.6",
|
||||||
|
"react-dom": "^16.8.6",
|
||||||
|
"react-easy-state": "^6.1.3",
|
||||||
"tone": "^13.4.9"
|
"tone": "^13.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/gh-pages": "^2.0.0",
|
"@types/gh-pages": "^2.0.0",
|
||||||
"@types/js-yaml": "^3.12.1",
|
"@types/js-yaml": "^3.12.1",
|
||||||
"@types/node": "^12.6.3",
|
"@types/node": "^12.6.3",
|
||||||
|
"@types/react": "^16.8.23",
|
||||||
|
"@types/react-dom": "^16.8.4",
|
||||||
"@types/tone": "git+https://github.com/Tonejs/TypeScript.git",
|
"@types/tone": "git+https://github.com/Tonejs/TypeScript.git",
|
||||||
"concurrently": "^4.1.1",
|
"concurrently": "^4.1.1",
|
||||||
|
"css-loader": "^3.1.0",
|
||||||
"gh-pages": "^2.0.1",
|
"gh-pages": "^2.0.1",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"js-yaml-loader": "^1.2.2",
|
"js-yaml-loader": "^1.2.2",
|
||||||
"local-web-server": "^3.0.4",
|
"local-web-server": "^3.0.4",
|
||||||
|
"style-loader": "^0.23.1",
|
||||||
"ts-loader": "^6.0.4",
|
"ts-loader": "^6.0.4",
|
||||||
"ts-node": "^8.3.0",
|
"ts-node": "^8.3.0",
|
||||||
"tslint": "^5.18.0",
|
"tslint": "^5.18.0",
|
||||||
|
@ -10,6 +10,19 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mdc-text-field {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-panel {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-panel-popup {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
@ -17,12 +30,12 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#playButton, #effectsButton, #helpButton {
|
button {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#playButton, #helpButton {
|
#playButton {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +97,7 @@ table tr:nth-of-type(12n) {
|
|||||||
border-bottom: 3px solid white;
|
border-bottom: 3px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaWorkspace {
|
#metaWorkspace {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
@ -93,19 +106,14 @@ table tr:nth-of-type(12n) {
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
table#pattern {
|
table#pattern {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* MDL hides these by default. */
|
.no-select {
|
||||||
|
-moz-user-select: none;
|
||||||
input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button {
|
-webkit-user-select: none;
|
||||||
-webkit-appearance: inner-spin-button !important;
|
user-select: none;
|
||||||
}
|
|
||||||
|
|
||||||
#helpModal {
|
|
||||||
width: 600px;
|
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import * as tone from "tone";
|
import tone from "tone";
|
||||||
import { assertNever, notes } from "./index";
|
import { assertNever, notes } from "./index";
|
||||||
import { instrumentData } from "./data";
|
import { instrumentData } from "./data";
|
||||||
|
|
||||||
|
348
src/player.ts
348
src/player.ts
@ -1,348 +0,0 @@
|
|||||||
import * as tone from 'tone';
|
|
||||||
import { changeSampler, getSampler } from "./audio";
|
|
||||||
const dialogPolyfill = require("dialog-polyfill");
|
|
||||||
|
|
||||||
let playing = false;
|
|
||||||
const letters = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
||||||
const chords = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
|
|
||||||
const volume = new tone.Volume(0);
|
|
||||||
const lowPass = new tone.LowpassCombFilter(0, 0);
|
|
||||||
|
|
||||||
const delayEffect = new tone.FeedbackDelay(0, 0);
|
|
||||||
const chorusEffect = new tone.Chorus();
|
|
||||||
chorusEffect.wet.value = 0;
|
|
||||||
const reverbEffect = new tone.Freeverb(0, 3000);
|
|
||||||
reverbEffect.wet.value = 0;
|
|
||||||
const distortionEffect = new tone.BitCrusher(4);
|
|
||||||
distortionEffect.wet.value = 0;
|
|
||||||
const lowBoostEffect = new tone.Filter(0, "lowshelf");
|
|
||||||
const compressorEffect = new tone.Compressor(0);
|
|
||||||
const highPassEffect = new tone.Filter(0, "highpass");
|
|
||||||
|
|
||||||
interface Note {
|
|
||||||
length: number;
|
|
||||||
scheduledEvent: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let instrumentName = "midi.piano1";
|
|
||||||
let sampler = getSampler("midi.piano1");
|
|
||||||
let patterns: { [key: number]: { [key: string]: Array<Note> } } = {};
|
|
||||||
for (const chord of chords) {
|
|
||||||
patterns[chord] = {};
|
|
||||||
for (const letter of letters) {
|
|
||||||
patterns[chord][letter] = Array.from({ length: 16 }, () => { return { length: 0, scheduledEvent: null }; });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAudio() {
|
|
||||||
if (playing) {
|
|
||||||
tone.Transport.stop();
|
|
||||||
tone.Transport.position = "0";
|
|
||||||
} else {
|
|
||||||
resumeAudioContext();
|
|
||||||
tone.Transport.loopEnd = '1m';
|
|
||||||
tone.Transport.loop = true;
|
|
||||||
}
|
|
||||||
tone.Transport.toggle(0);
|
|
||||||
playing = !playing;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resumeAudioContext() {
|
|
||||||
let ac = (tone as any).context;
|
|
||||||
if (ac.state !== "running") {
|
|
||||||
ac.resume();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mdl-selectfield doesn't play nice with custom onchange event listeners,
|
|
||||||
// so we use this to handle instrument changes instead.
|
|
||||||
function setInstrumentLoop(instrumentField: HTMLSelectElement) {
|
|
||||||
setInstrument(instrumentField);
|
|
||||||
setTimeout(() => { setInstrumentLoop(instrumentField); }, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setInstrument(instrumentField: HTMLSelectElement) {
|
|
||||||
if (instrumentName !== instrumentField.value) {
|
|
||||||
instrumentName = instrumentField.value;
|
|
||||||
changeSampler(sampler, instrumentField.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBpm() {
|
|
||||||
let bpmField = document.getElementById("bpm");
|
|
||||||
if (bpmField !== null && bpmField instanceof HTMLInputElement) {
|
|
||||||
tone.Transport.bpm.value = parseInt(bpmField.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVolume() {
|
|
||||||
const field = document.getElementById("volume");
|
|
||||||
if (field !== null && field instanceof HTMLInputElement) {
|
|
||||||
let newValue = parseInt(field.value);
|
|
||||||
if (newValue === 0) {
|
|
||||||
volume.mute = true;
|
|
||||||
} else {
|
|
||||||
volume.volume.value = (newValue - 100) / 5;
|
|
||||||
volume.mute = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSwing() {
|
|
||||||
const field = document.getElementById("swing");
|
|
||||||
if (field !== null && field instanceof HTMLInputElement) {
|
|
||||||
tone.Transport.swing = parseFloat(field.value);
|
|
||||||
tone.Transport.swingSubdivision = "16n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setResonance() {
|
|
||||||
const field = document.getElementById("resonance");
|
|
||||||
if (field !== null && field instanceof HTMLInputElement) {
|
|
||||||
lowPass.resonance.value = parseFloat(field.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDampening() {
|
|
||||||
const field = document.getElementById("dampening");
|
|
||||||
if (field !== null && field instanceof HTMLInputElement) {
|
|
||||||
lowPass.dampening.value = parseInt(field.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePlayButton() {
|
|
||||||
let playButton = document.getElementById("playButton");
|
|
||||||
if (playButton !== null) {
|
|
||||||
if (playButton.textContent !== null && playButton.textContent.trim().toLowerCase() === "play") {
|
|
||||||
playButton.textContent = "Stop";
|
|
||||||
} else {
|
|
||||||
playButton.textContent = "Play";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEffect(effect: string, value: number) {
|
|
||||||
switch (effect) {
|
|
||||||
case "delay":
|
|
||||||
delayEffect.delayTime.value = value === 0 ? 0 : tone.Time("8n") * 2 * value;
|
|
||||||
delayEffect.feedback.value = 0.15 * 2 * value;
|
|
||||||
break;
|
|
||||||
case "chorus":
|
|
||||||
chorusEffect.wet.value = value;
|
|
||||||
break;
|
|
||||||
case "reverb":
|
|
||||||
reverbEffect.roomSize.value = value * 0.9;
|
|
||||||
reverbEffect.wet.value = value;
|
|
||||||
break;
|
|
||||||
case "distortion":
|
|
||||||
distortionEffect.wet.value = value === 0 ? 0 : 1;
|
|
||||||
distortionEffect.bits = value;
|
|
||||||
break;
|
|
||||||
case "lowBoost":
|
|
||||||
lowBoostEffect.frequency.value = value;
|
|
||||||
lowBoostEffect.gain.value = value === 0 ? 0 : 20;
|
|
||||||
break;
|
|
||||||
case "compressor":
|
|
||||||
compressorEffect.threshold.value = value;
|
|
||||||
break;
|
|
||||||
case "highPass":
|
|
||||||
highPassEffect.frequency.value = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleNote(chord: number, letter: string, index: number, length: number) {
|
|
||||||
unscheduleNote(chord, letter, index);
|
|
||||||
// console.log(`scheduleNote(chord=${chord}, letter=${letter}, index=${index}, length=${length})`);
|
|
||||||
patterns[chord][letter][index]["length"] = length;
|
|
||||||
|
|
||||||
patterns[chord][letter][index]["scheduledEvent"] = tone.Transport.schedule(
|
|
||||||
time => {
|
|
||||||
sampler.triggerAttackRelease(`${letter}${chord}`, tone.Time("16n") * length, time);
|
|
||||||
tone.Draw.schedule(() => {
|
|
||||||
const noteElement = document.querySelector(`#${letter.replace("#", "\\#")}-${chord}-${index}`);
|
|
||||||
if (noteElement !== null) {
|
|
||||||
if (length <= 1) {
|
|
||||||
noteElement.classList.add("playing");
|
|
||||||
} else {
|
|
||||||
noteElement.classList.add("playingLong");
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
noteElement.classList.remove("playing");
|
|
||||||
noteElement.classList.remove("playingLong");
|
|
||||||
}, 100 * length);
|
|
||||||
}
|
|
||||||
}, time);
|
|
||||||
},
|
|
||||||
`0:0:${index}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unscheduleNote(chord: number, letter: string, index: number) {
|
|
||||||
patterns[chord][letter][index]["length"] = 0;
|
|
||||||
const schedulee = patterns[chord][letter][index]["scheduledEvent"];
|
|
||||||
if (schedulee !== null) {
|
|
||||||
tone.Transport.clear(schedulee);
|
|
||||||
patterns[chord][letter][index]["scheduledEvent"] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClickNoteCell(event: MouseEvent, cell: HTMLTableCellElement, chord: number, letter: string, index: number) {
|
|
||||||
let length = patterns[chord][letter][index]["length"];
|
|
||||||
// console.log(`onClickNoteCell(chord=${chord}, letter=${letter}, index=${index}) | length ${length}`);
|
|
||||||
|
|
||||||
if (event.shiftKey && event.ctrlKey) {
|
|
||||||
length = Math.max(length - 1, 0);
|
|
||||||
} else if (event.shiftKey) {
|
|
||||||
length = Math.max(Math.min(length + 1, 16), 2);
|
|
||||||
} else if (length > 0) {
|
|
||||||
length = 0;
|
|
||||||
} else {
|
|
||||||
length = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.innerHTML = length.toString();
|
|
||||||
|
|
||||||
if (length <= 1) {
|
|
||||||
cell.classList.remove("noteLong");
|
|
||||||
} else {
|
|
||||||
cell.classList.add("noteLong");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (length > 0 && length !== patterns[chord][letter][index]["length"]) {
|
|
||||||
cell.classList.add("active");
|
|
||||||
scheduleNote(chord, letter, index, length);
|
|
||||||
} else if (length === 0) {
|
|
||||||
cell.classList.remove("active");
|
|
||||||
unscheduleNote(chord, letter, index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLoad() {
|
|
||||||
sampler.chain(
|
|
||||||
volume,
|
|
||||||
lowPass,
|
|
||||||
delayEffect,
|
|
||||||
chorusEffect,
|
|
||||||
reverbEffect,
|
|
||||||
distortionEffect,
|
|
||||||
lowBoostEffect,
|
|
||||||
compressorEffect,
|
|
||||||
highPassEffect,
|
|
||||||
tone.Master
|
|
||||||
);
|
|
||||||
|
|
||||||
document.onkeypress = event => {
|
|
||||||
if (event.keyCode === 32) {
|
|
||||||
toggleAudio();
|
|
||||||
togglePlayButton();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let playButton = document.getElementById("playButton");
|
|
||||||
if (playButton !== null) {
|
|
||||||
playButton.addEventListener("click", toggleAudio);
|
|
||||||
playButton.addEventListener("click", togglePlayButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
let helpButton = document.getElementById("helpButton");
|
|
||||||
let helpModal = document.getElementById("helpModal");
|
|
||||||
if (helpButton !== null && helpModal !== null) {
|
|
||||||
if (!(helpModal as any).showModal) {
|
|
||||||
dialogPolyfill.default.registerDialog(helpModal);
|
|
||||||
}
|
|
||||||
helpButton.addEventListener("click", () => { (helpModal as any).showModal(); });
|
|
||||||
const helpModalClose = helpModal.querySelector(".close");
|
|
||||||
if (helpModalClose !== null) {
|
|
||||||
helpModalClose.addEventListener("click", () => {
|
|
||||||
(helpModal as any).close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let instrumentField = document.getElementById("instruments");
|
|
||||||
if (instrumentField !== null && instrumentField instanceof HTMLSelectElement) {
|
|
||||||
setInstrumentLoop(instrumentField);
|
|
||||||
}
|
|
||||||
|
|
||||||
let bpmField = document.getElementById("bpm");
|
|
||||||
if (bpmField !== null) {
|
|
||||||
bpmField.addEventListener("change", setBpm);
|
|
||||||
}
|
|
||||||
|
|
||||||
let effectsMenu = document.getElementById("effectsMenu");
|
|
||||||
if (effectsMenu !== null) {
|
|
||||||
effectsMenu.addEventListener("click", event => {
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const effects = ["delay", "chorus", "reverb", "distortion", "lowBoost", "compressor", "highPass"];
|
|
||||||
for (const effect of effects) {
|
|
||||||
const effectField = document.getElementById(`${effect}Effect`);
|
|
||||||
if (effectField !== null && effectField instanceof HTMLInputElement) {
|
|
||||||
effectField.addEventListener("change", () => {
|
|
||||||
setEffect(effect, parseFloat(effectField.value));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let patternTable = document.getElementById("pattern");
|
|
||||||
if (patternTable !== null && patternTable instanceof HTMLTableElement) {
|
|
||||||
for (const chord of chords.slice().reverse()) {
|
|
||||||
let first = true;
|
|
||||||
|
|
||||||
|
|
||||||
for (const letter of letters.slice().reverse()) {
|
|
||||||
let row = patternTable.insertRow();
|
|
||||||
if (first) {
|
|
||||||
first = false;
|
|
||||||
let chordHeader = document.createElement("th");
|
|
||||||
chordHeader.rowSpan = letters.length;
|
|
||||||
chordHeader.innerHTML = chord.toString();
|
|
||||||
row.appendChild(chordHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
let letterHeader = document.createElement("th");
|
|
||||||
letterHeader.innerHTML = letter;
|
|
||||||
row.appendChild(letterHeader);
|
|
||||||
|
|
||||||
for (const i of Array(16).keys()) {
|
|
||||||
let cell = row.insertCell();
|
|
||||||
cell.id = `${letter}-${chord}-${i}`;
|
|
||||||
cell.classList.add(`note-${letter}`);
|
|
||||||
cell.onclick = event => { onClickNoteCell(event, cell, chord, letter, i); };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const centralRow = document.getElementById("F-5-0");
|
|
||||||
if (centralRow !== null) {
|
|
||||||
centralRow.scrollIntoView({ "behavior": "smooth", "block": "center" });
|
|
||||||
}
|
|
||||||
|
|
||||||
let volumeField = document.getElementById("volume");
|
|
||||||
if (volumeField !== null) {
|
|
||||||
volumeField.addEventListener("change", setVolume);
|
|
||||||
}
|
|
||||||
|
|
||||||
let swingField = document.getElementById("swing");
|
|
||||||
if (swingField !== null) {
|
|
||||||
swingField.addEventListener("change", setSwing);
|
|
||||||
}
|
|
||||||
|
|
||||||
let resonanceField = document.getElementById("resonance");
|
|
||||||
if (resonanceField !== null) {
|
|
||||||
resonanceField.addEventListener("change", setResonance);
|
|
||||||
}
|
|
||||||
|
|
||||||
let dampeningField = document.getElementById("dampening");
|
|
||||||
if (dampeningField !== null) {
|
|
||||||
dampeningField.addEventListener("change", setDampening);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onload = onLoad;
|
|
675
src/player.tsx
Normal file
675
src/player.tsx
Normal file
@ -0,0 +1,675 @@
|
|||||||
|
require("@material/react-button/dist/button.css");
|
||||||
|
require("@material/react-select/dist/select.css");
|
||||||
|
require("@material/react-text-field/dist/text-field.css");
|
||||||
|
require("@material/react-menu-surface/dist/menu-surface.css");
|
||||||
|
require("@material/react-dialog/dist/dialog.css");
|
||||||
|
|
||||||
|
import { store, view } from "react-easy-state";
|
||||||
|
import tone from "tone";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import ReactDom from "react-dom";
|
||||||
|
import Button from "@material/react-button";
|
||||||
|
import Select, { Option } from "@material/react-select";
|
||||||
|
import TextField, { Input } from "@material/react-text-field";
|
||||||
|
import MenuSurface, { Corner } from "@material/react-menu-surface";
|
||||||
|
import Dialog, { DialogTitle, DialogContent, DialogFooter, DialogButton } from "@material/react-dialog";
|
||||||
|
|
||||||
|
import { changeSampler, getSampler } from "./audio";
|
||||||
|
import { instrumentData } from "./data";
|
||||||
|
import { assertNever } from "./index";
|
||||||
|
|
||||||
|
let playing = false;
|
||||||
|
const letters = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
||||||
|
const chords = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
|
function fromMaybes<T>(maybes: Array<T | null | undefined>, fallback: T) {
|
||||||
|
for (const value of maybes) {
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Song {
|
||||||
|
meta: SongMeta = new SongMeta();
|
||||||
|
effects: Effects = new Effects();
|
||||||
|
patterns: Array<Pattern> = [new Pattern()];
|
||||||
|
channels: Array<Channel> = [new Channel()];
|
||||||
|
playing: boolean = false;
|
||||||
|
|
||||||
|
toggleAudio(): void {
|
||||||
|
if (this.playing) {
|
||||||
|
tone.Transport.stop();
|
||||||
|
tone.Transport.position = "0";
|
||||||
|
} else {
|
||||||
|
resumeAudioContext();
|
||||||
|
tone.Transport.loopEnd = "1m";
|
||||||
|
tone.Transport.loop = true;
|
||||||
|
}
|
||||||
|
tone.Transport.toggle(0);
|
||||||
|
this.playing = !this.playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEffect(effect: keyof Effects, channel: number, pattern: number): number {
|
||||||
|
return fromMaybes(
|
||||||
|
[
|
||||||
|
this.patterns[pattern].effects[effect],
|
||||||
|
this.channels[channel].effects[effect],
|
||||||
|
],
|
||||||
|
this.effects[effect]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEffectBy(effect: keyof Effects, level: "song" | "channel" | "pattern", index: number = 0) {
|
||||||
|
switch (level) {
|
||||||
|
case "song":
|
||||||
|
return this.effects[effect];
|
||||||
|
case "channel":
|
||||||
|
return this.channels[index].effects[effect];
|
||||||
|
case "pattern":
|
||||||
|
return this.patterns[index].effects[effect];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffectBy(effect: keyof Effects, value: number | undefined, level: "song" | "channel" | "pattern", index: number = 0) {
|
||||||
|
switch (level) {
|
||||||
|
case "song":
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error("Song-level effects cannot be undefined");
|
||||||
|
}
|
||||||
|
this.effects[effect] = value;
|
||||||
|
for (let channel = 0; channel < this.channels.length; channel++) {
|
||||||
|
for (const pattern of this.channels[channel].patterns) {
|
||||||
|
if (pattern !== null) {
|
||||||
|
this.patterns[pattern].pipeline.setEffect(effect, this.getEffect(effect, channel, pattern));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "channel":
|
||||||
|
this.channels[index].effects[effect] = value;
|
||||||
|
for (const pattern of this.channels[index].patterns) {
|
||||||
|
if (pattern !== null) {
|
||||||
|
this.patterns[pattern].pipeline.setEffect(effect, this.getEffect(effect, index, pattern));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "pattern":
|
||||||
|
this.patterns[index].effects[effect] = value;
|
||||||
|
for (let channel = 0; channel < this.channels.length; channel++) {
|
||||||
|
for (const pattern of this.channels[channel].patterns) {
|
||||||
|
if (pattern === index) {
|
||||||
|
this.patterns[pattern].pipeline.setEffect(effect, this.getEffect(effect, channel, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInstrument(value: string, pattern: number): void {
|
||||||
|
this.patterns[pattern].instrument = value;
|
||||||
|
changeSampler(this.patterns[pattern].pipeline.sampler, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBpm(value: number): void {
|
||||||
|
this.meta.bpm = value;
|
||||||
|
tone.Transport.bpm.value = song.meta.bpm;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwing(value: number): void {
|
||||||
|
this.meta.swing = value;
|
||||||
|
tone.Transport.swing = song.meta.swing;
|
||||||
|
tone.Transport.swingSubdivision = "16n";
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNote(channel: number, pattern: number, chord: number, letter: string, index: number, length: number, noteCell: NoteCell) {
|
||||||
|
this.unscheduleNote(channel, pattern, chord, letter, index);
|
||||||
|
// console.log(`Song.scheduleNote(chord=${chord}, letter=${letter}, index=${index}, length=${length})`);
|
||||||
|
this.patterns[pattern].notes[chord][letter][index].length = length;
|
||||||
|
|
||||||
|
this.patterns[pattern].notes[chord][letter][index].scheduledEvent = tone.Transport.schedule(
|
||||||
|
time => {
|
||||||
|
this.patterns[pattern].pipeline.sampler.triggerAttackRelease(
|
||||||
|
`${letter}${chord}`, tone.Time("16n") * length, time
|
||||||
|
);
|
||||||
|
tone.Draw.schedule(() => {
|
||||||
|
noteCell.setState({ playing: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
noteCell.setState({ playing: false });
|
||||||
|
}, 100 * length);
|
||||||
|
}, time);
|
||||||
|
},
|
||||||
|
`0:0:${index}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unscheduleNote(channel: number, pattern: number, chord: number, letter: string, index: number) {
|
||||||
|
// console.log(`Song.unscheduleNote(chord=${chord}, letter=${letter}, index=${index}, length=${length})`);
|
||||||
|
this.patterns[pattern].notes[chord][letter][index].length = 0;
|
||||||
|
const schedulee = this.patterns[pattern].notes[chord][letter][index].scheduledEvent;
|
||||||
|
if (schedulee !== null) {
|
||||||
|
tone.Transport.clear(schedulee);
|
||||||
|
this.patterns[pattern].notes[chord][letter][index].scheduledEvent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SongMeta {
|
||||||
|
bpm: number = 120;
|
||||||
|
swing: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Effects {
|
||||||
|
volume: number = 100;
|
||||||
|
resonance: number = 0;
|
||||||
|
dampening: number = 0;
|
||||||
|
delay: number = 0;
|
||||||
|
chorus: number = 0;
|
||||||
|
reverb: number = 0;
|
||||||
|
distortion: number = 0;
|
||||||
|
lowBoost: number = 0;
|
||||||
|
compressor: number = 0;
|
||||||
|
highPass: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Pipeline {
|
||||||
|
sampler: tone.Sampler;
|
||||||
|
volume = new tone.Volume(0);
|
||||||
|
lowPass = new tone.LowpassCombFilter(0, 0);
|
||||||
|
delayEffect = new tone.FeedbackDelay(0, 0);
|
||||||
|
chorusEffect = new tone.Chorus();
|
||||||
|
reverbEffect = new tone.Freeverb(0, 3000);
|
||||||
|
distortionEffect = new tone.BitCrusher(4);
|
||||||
|
lowBoostEffect = new tone.Filter(0, "lowshelf");
|
||||||
|
compressorEffect = new tone.Compressor(0);
|
||||||
|
highPassEffect = new tone.Filter(0, "highpass");
|
||||||
|
|
||||||
|
constructor(instrument: string = "midi.piano1") {
|
||||||
|
this.sampler = getSampler(instrument);
|
||||||
|
this.chorusEffect.wet.value = 0;
|
||||||
|
this.reverbEffect.wet.value = 0;
|
||||||
|
this.distortionEffect.wet.value = 0;
|
||||||
|
|
||||||
|
this.sampler.chain(
|
||||||
|
this.volume,
|
||||||
|
this.lowPass,
|
||||||
|
this.delayEffect,
|
||||||
|
this.chorusEffect,
|
||||||
|
this.reverbEffect,
|
||||||
|
this.distortionEffect,
|
||||||
|
this.lowBoostEffect,
|
||||||
|
this.compressorEffect,
|
||||||
|
this.highPassEffect,
|
||||||
|
tone.Master,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffect(effect: keyof Effects, value: number): void {
|
||||||
|
switch (effect) {
|
||||||
|
case "volume":
|
||||||
|
if (value === 0) {
|
||||||
|
this.volume.mute = true;
|
||||||
|
} else {
|
||||||
|
this.volume.volume.value = (value - 100) / 5;
|
||||||
|
this.volume.mute = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "resonance":
|
||||||
|
this.lowPass.resonance.value = value;
|
||||||
|
break;
|
||||||
|
case "dampening":
|
||||||
|
this.lowPass.dampening.value = value;
|
||||||
|
break;
|
||||||
|
case "delay":
|
||||||
|
this.delayEffect.delayTime.value = value === 0 ? 0 : tone.Time("8n") * 2 * value;
|
||||||
|
this.delayEffect.feedback.value = 0.15 * 2 * value;
|
||||||
|
break;
|
||||||
|
case "chorus":
|
||||||
|
this.chorusEffect.wet.value = value;
|
||||||
|
break;
|
||||||
|
case "reverb":
|
||||||
|
this.reverbEffect.roomSize.value = value * 0.9;
|
||||||
|
this.reverbEffect.wet.value = value;
|
||||||
|
break;
|
||||||
|
case "distortion":
|
||||||
|
this.distortionEffect.wet.value = value === 0 ? 0 : 1;
|
||||||
|
this.distortionEffect.bits = value;
|
||||||
|
break;
|
||||||
|
case "lowBoost":
|
||||||
|
this.lowBoostEffect.frequency.value = value;
|
||||||
|
this.lowBoostEffect.gain.value = value === 0 ? 0 : 20;
|
||||||
|
break;
|
||||||
|
case "compressor":
|
||||||
|
this.compressorEffect.threshold.value = value;
|
||||||
|
break;
|
||||||
|
case "highPass":
|
||||||
|
this.highPassEffect.frequency.value = value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
assertNever(effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Pattern {
|
||||||
|
effects: Partial<Effects> = {};
|
||||||
|
notes: { [key: number]: { [key: string]: Array<Note> } } = {};
|
||||||
|
pipeline: Pipeline;
|
||||||
|
|
||||||
|
constructor(public instrument: string = "midi.piano1") {
|
||||||
|
this.pipeline = new Pipeline(this.instrument);
|
||||||
|
for (const chord of chords) {
|
||||||
|
this.notes[chord] = {};
|
||||||
|
for (const letter of letters) {
|
||||||
|
this.notes[chord][letter] = Array.from({ length: 16 }, () => ({ length: 0, scheduledEvent: null }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Channel {
|
||||||
|
effects: Partial<Effects> = {};
|
||||||
|
patterns: Array<number | null>;
|
||||||
|
|
||||||
|
constructor(patterns: Array<number | null> = [0]) {
|
||||||
|
this.patterns = patterns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const song: Song = store(new Song());
|
||||||
|
|
||||||
|
@view
|
||||||
|
class EffectChoice extends React.Component<{
|
||||||
|
effect: keyof Effects,
|
||||||
|
mode: "song" | "channel" | "pattern",
|
||||||
|
modeIndex: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
step: number,
|
||||||
|
}> {
|
||||||
|
getLabel(): string {
|
||||||
|
switch (this.props.effect) {
|
||||||
|
case "chorus":
|
||||||
|
return "Chorus";
|
||||||
|
case "compressor":
|
||||||
|
return "Compressor";
|
||||||
|
case "dampening":
|
||||||
|
return "Dampening";
|
||||||
|
case "delay":
|
||||||
|
return "Delay";
|
||||||
|
case "distortion":
|
||||||
|
return "Distortion";
|
||||||
|
case "highPass":
|
||||||
|
return "High pass";
|
||||||
|
case "lowBoost":
|
||||||
|
return "Low boost";
|
||||||
|
case "resonance":
|
||||||
|
return "Resonance";
|
||||||
|
case "reverb":
|
||||||
|
return "Reverb";
|
||||||
|
case "volume":
|
||||||
|
return "Volume";
|
||||||
|
default:
|
||||||
|
return assertNever(this.props.effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render(): React.ReactNode {
|
||||||
|
return <TextField label={this.getLabel()}>
|
||||||
|
<Input
|
||||||
|
required={this.props.mode === "song"}
|
||||||
|
type="number"
|
||||||
|
min={this.props.min}
|
||||||
|
max={this.props.max}
|
||||||
|
step={this.props.step}
|
||||||
|
value={song.getEffectBy(this.props.effect, this.props.mode, this.props.modeIndex)}
|
||||||
|
// @ts-ignore
|
||||||
|
onChange={e => {
|
||||||
|
if (this.props.mode !== "song" && e.currentTarget.value === "") {
|
||||||
|
song.setEffectBy(this.props.effect, undefined, this.props.mode, this.props.modeIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newValue = parseFloat(e.currentTarget.value);
|
||||||
|
if (!isNaN(newValue)) {
|
||||||
|
song.setEffectBy(this.props.effect, newValue, this.props.mode, this.props.modeIndex);
|
||||||
|
} else if (e.currentTarget.value === undefined && this.props.mode !== "song") {
|
||||||
|
song.setEffectBy(this.props.effect, undefined, this.props.mode, this.props.modeIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TextField>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@view
|
||||||
|
class EffectPanel extends React.Component<{
|
||||||
|
mode: "song" | "channel" | "pattern",
|
||||||
|
modeIndex: number,
|
||||||
|
}> {
|
||||||
|
state = {
|
||||||
|
open: false,
|
||||||
|
anchorElement: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
getButtonText(): string {
|
||||||
|
switch (this.props.mode) {
|
||||||
|
case "song":
|
||||||
|
return "Song";
|
||||||
|
case "channel":
|
||||||
|
return "Channel";
|
||||||
|
case "pattern":
|
||||||
|
return "Pattern"
|
||||||
|
default:
|
||||||
|
return assertNever(this.props.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnchorElement = (element: HTMLDivElement) => {
|
||||||
|
if (this.state.anchorElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ anchorElement: element });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mdc-menu-surface--anchor effect-panel"
|
||||||
|
ref={this.setAnchorElement}
|
||||||
|
>
|
||||||
|
<Button raised onClick={() => this.setState({ open: true })}>{this.getButtonText()}</Button>
|
||||||
|
|
||||||
|
<MenuSurface
|
||||||
|
open={this.state.open}
|
||||||
|
anchorCorner={Corner.BOTTOM_LEFT}
|
||||||
|
onClose={() => this.setState({ open: false })}
|
||||||
|
anchorElement={this.state.anchorElement}
|
||||||
|
>
|
||||||
|
<div className="effect-panel-popup">
|
||||||
|
<EffectChoice effect="volume" min={0} max={200} step={5} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="resonance" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="dampening" min={0} max={3000} step={100} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="delay" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="chorus" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="reverb" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="distortion" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="lowBoost" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="compressor" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
<EffectChoice effect="highPass" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
|
||||||
|
</div>
|
||||||
|
</MenuSurface>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@view
|
||||||
|
class PlayButton extends React.Component {
|
||||||
|
onClick() {
|
||||||
|
song.toggleAudio();
|
||||||
|
}
|
||||||
|
render(): React.ReactNode {
|
||||||
|
return <Button id="playButton" onClick={() => this.onClick()} raised>
|
||||||
|
{song.playing ? "Stop" : "Play"}
|
||||||
|
</Button>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@view
|
||||||
|
class Help extends React.Component {
|
||||||
|
state = {
|
||||||
|
displayed: false,
|
||||||
|
};
|
||||||
|
render(): React.ReactNode {
|
||||||
|
return <a>
|
||||||
|
<Button id="helpButton" onClick={() => this.setState({displayed: true})} raised>Help</Button>
|
||||||
|
<Dialog open={this.state.displayed} onClose={() => this.setState({displayed: false})}>
|
||||||
|
<DialogTitle>Help</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
<li>Click in a box to add or remove a note.</li>
|
||||||
|
<li>Shift click to extend a note, and ctrl-shift click to shorten it.</li>
|
||||||
|
<li>Press the space bar to play or stop the song.</li>
|
||||||
|
<li>After clicking in an entry field, use the arrow keys to quickly change the value.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogButton action="close" isDefault>Close</DialogButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
</a>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@view
|
||||||
|
class MetaWorkspace extends React.Component<{ activeChannel: number, activePattern: number }> {
|
||||||
|
render(): React.ReactNode {
|
||||||
|
const instruments = [];
|
||||||
|
for (const [instrument, { category }] of Object.entries(instrumentData)) {
|
||||||
|
instruments.push(
|
||||||
|
<Option key={instrument} value={instrument}>{category.join(" > ")}</Option>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div id="metaWorkspace">
|
||||||
|
<PlayButton />
|
||||||
|
<Select
|
||||||
|
label="Pattern instrument"
|
||||||
|
value={song.patterns[this.props.activePattern].instrument}
|
||||||
|
onChange={e => {
|
||||||
|
song.setInstrument(e.currentTarget.value, this.props.activeChannel);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{instruments}
|
||||||
|
</Select>
|
||||||
|
<TextField label="BPM">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={220}
|
||||||
|
step={5}
|
||||||
|
value={song.meta.bpm}
|
||||||
|
// @ts-ignore
|
||||||
|
onChange={e => {
|
||||||
|
const value = parseInt(e.currentTarget.value);
|
||||||
|
if (isNaN(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
song.setBpm(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
<TextField label="Swing">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={song.meta.swing}
|
||||||
|
// @ts-ignore
|
||||||
|
onChange={e => {
|
||||||
|
const value = parseInt(e.currentTarget.value);
|
||||||
|
if (isNaN(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
song.setSwing(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
<EffectPanel mode="song" modeIndex={0} />
|
||||||
|
<EffectPanel mode="channel" modeIndex={0} />
|
||||||
|
<EffectPanel mode="pattern" modeIndex={0} />
|
||||||
|
<Help />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@view
|
||||||
|
class NoteCell extends React.Component<{ channel: number, pattern: number, chord: number, letter: string, index: number }> {
|
||||||
|
state = {
|
||||||
|
chord: this.props.chord,
|
||||||
|
letter: this.props.letter,
|
||||||
|
index: this.props.index,
|
||||||
|
length: 0,
|
||||||
|
playing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
onClick(chord: number, letter: string, index: number, withShift: boolean, withCtrl: boolean) {
|
||||||
|
let length = this.state.length;
|
||||||
|
|
||||||
|
if (withShift && withCtrl) {
|
||||||
|
length = Math.max(length - 1, 0);
|
||||||
|
} else if (withShift) {
|
||||||
|
length = Math.max(Math.min(length + 1, 16), 2);
|
||||||
|
} else if (length > 0) {
|
||||||
|
length = 0;
|
||||||
|
} else {
|
||||||
|
length = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length > 0 && length !== this.state.length) {
|
||||||
|
song.scheduleNote(this.props.channel, this.props.pattern, chord, letter, index, length, this);
|
||||||
|
} else if (length === 0) {
|
||||||
|
song.unscheduleNote(this.props.channel, this.props.pattern, chord, letter, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ length: length });
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
const classes = [`note-${this.state.letter}`];
|
||||||
|
if (this.state.length > 0) {
|
||||||
|
classes.push("active");
|
||||||
|
}
|
||||||
|
if (this.state.length > 1) {
|
||||||
|
classes.push("noteLong");
|
||||||
|
}
|
||||||
|
if (this.state.playing) {
|
||||||
|
if (this.state.length > 1) {
|
||||||
|
classes.push("playingLong");
|
||||||
|
} else {
|
||||||
|
classes.push("playing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <td
|
||||||
|
id={`${this.state.letter}-${this.state.chord}-${this.state.index}`}
|
||||||
|
className={classes.join(" ")}
|
||||||
|
onClick={event => {
|
||||||
|
this.onClick(this.state.chord, this.state.letter, this.state.index, event.shiftKey, event.ctrlKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.length === 0 ? "" : this.state.length}
|
||||||
|
</td>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@view
|
||||||
|
class PatternWorkspace extends React.Component<{ channel: number, pattern: number }> {
|
||||||
|
render(): React.ReactNode {
|
||||||
|
let rows = [];
|
||||||
|
for (const chord of chords.slice().reverse()) {
|
||||||
|
let first = true;
|
||||||
|
for (const letter of letters.slice().reverse()) {
|
||||||
|
let items = [];
|
||||||
|
if (first) {
|
||||||
|
first = false;
|
||||||
|
items.push(<th key={`chordHeader-${chord}`} rowSpan={letters.length}>{chord}</th>);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(<th key={`letterHeader-${chord}-${letter}`}>{letter}</th>);
|
||||||
|
|
||||||
|
for (const i of Array(16).keys()) {
|
||||||
|
items.push(
|
||||||
|
<NoteCell
|
||||||
|
key={`cell-${chord}-${letter}-${i}`}
|
||||||
|
channel={this.props.channel}
|
||||||
|
pattern={this.props.pattern}
|
||||||
|
chord={chord}
|
||||||
|
letter={letter}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows.push(<tr key={`row-${chord}-${letter}`}>{items}</tr>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div id="patternWorkspace" className="no-select">
|
||||||
|
<table id="pattern"><tbody>{rows}</tbody></table>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@view
|
||||||
|
class App extends React.Component {
|
||||||
|
state = {
|
||||||
|
activeChannel: 0,
|
||||||
|
activePattern: 0,
|
||||||
|
};
|
||||||
|
samplers: { [key: number]: tone.Sampler } = {};
|
||||||
|
render(): React.ReactNode {
|
||||||
|
return <div id="app">
|
||||||
|
<MetaWorkspace activeChannel={this.state.activeChannel} activePattern={this.state.activePattern} />
|
||||||
|
<PatternWorkspace channel={this.state.activeChannel} pattern={this.state.activePattern} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
length: number;
|
||||||
|
scheduledEvent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let patterns: { [key: number]: { [key: string]: Array<Note> } } = {};
|
||||||
|
for (const chord of chords) {
|
||||||
|
patterns[chord] = {};
|
||||||
|
for (const letter of letters) {
|
||||||
|
patterns[chord][letter] = Array.from({ length: 16 }, () => { return { length: 0, scheduledEvent: null }; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAudio() {
|
||||||
|
if (playing) {
|
||||||
|
tone.Transport.stop();
|
||||||
|
tone.Transport.position = "0";
|
||||||
|
} else {
|
||||||
|
resumeAudioContext();
|
||||||
|
tone.Transport.loopEnd = "1m";
|
||||||
|
tone.Transport.loop = true;
|
||||||
|
}
|
||||||
|
tone.Transport.toggle(0);
|
||||||
|
playing = !playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeAudioContext() {
|
||||||
|
let ac = (tone as any).context;
|
||||||
|
if (ac.state !== "running") {
|
||||||
|
ac.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoad() {
|
||||||
|
ReactDom.render(<App />, document.querySelector("#root"));
|
||||||
|
|
||||||
|
document.onkeypress = event => {
|
||||||
|
if (event.keyCode === 32) {
|
||||||
|
song.toggleAudio();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const centralRow = document.getElementById("F-5-0");
|
||||||
|
if (centralRow !== null) {
|
||||||
|
centralRow.scrollIntoView({ "behavior": "smooth", "block": "center" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = onLoad;
|
@ -1,43 +0,0 @@
|
|||||||
import * as http from "http";
|
|
||||||
import * as https from "https";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
const root = `${path.dirname(process.argv[1])}/..`;
|
|
||||||
const assets = `${root}/assets`;
|
|
||||||
|
|
||||||
function download(filename: string, url: string): void {
|
|
||||||
var file = fs.createWriteStream(filename);
|
|
||||||
var protocol = url.startsWith("https://") ? https : http;
|
|
||||||
protocol.get(url, response => {
|
|
||||||
response.pipe(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(assets)) {
|
|
||||||
fs.mkdirSync(assets);
|
|
||||||
}
|
|
||||||
download(
|
|
||||||
`${assets}/md-icons.css`,
|
|
||||||
"https://fonts.googleapis.com/icon?family=Material+Icons"
|
|
||||||
);
|
|
||||||
download(
|
|
||||||
`${assets}/material.indigo-pink.min.css`,
|
|
||||||
"https://code.getmdl.io/1.3.0/material.indigo-pink.min.css"
|
|
||||||
);
|
|
||||||
download(
|
|
||||||
`${assets}/material.min.js`,
|
|
||||||
"https://code.getmdl.io/1.3.0/material.min.js"
|
|
||||||
);
|
|
||||||
download(
|
|
||||||
`${assets}/mdl-selectfield.min.css`,
|
|
||||||
"https://cdn.rawgit.com/kybarg/mdl-selectfield/mdl-menu-implementation/mdl-selectfield.min.css"
|
|
||||||
);
|
|
||||||
download(
|
|
||||||
`${assets}/mdl-selectfield.min.js`,
|
|
||||||
"https://cdn.rawgit.com/kybarg/mdl-selectfield/mdl-menu-implementation/mdl-selectfield.min.js"
|
|
||||||
);
|
|
||||||
download(
|
|
||||||
`${assets}/dialog-polyfill.min.css`,
|
|
||||||
"https://cdnjs.cloudflare.com/ajax/libs/dialog-polyfill/0.5.0/dialog-polyfill.min.css"
|
|
||||||
);
|
|
@ -1,5 +1,5 @@
|
|||||||
import * as ghPages from "gh-pages";
|
import ghPages from "gh-pages";
|
||||||
import * as path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const root = `${path.dirname(process.argv[1])}/..`;
|
const root = `${path.dirname(process.argv[1])}/..`;
|
||||||
|
|
||||||
@ -8,9 +8,8 @@ ghPages.publish(
|
|||||||
{
|
{
|
||||||
"src": [
|
"src": [
|
||||||
"index.html",
|
"index.html",
|
||||||
"index.css",
|
"public/*.css",
|
||||||
"assets/*.css",
|
"public/*.js",
|
||||||
"assets/*.js",
|
|
||||||
"audio/**/*.ogg",
|
"audio/**/*.ogg",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import * as fs from "fs";
|
import fs from "fs";
|
||||||
import * as path from "path";
|
import path from "path";
|
||||||
import * as process from "process";
|
import process from "process";
|
||||||
import * as child_process from "child_process";
|
import child_process from "child_process";
|
||||||
import * as yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import { InstrumentData, notes } from "../src/index";
|
import { InstrumentData, notes } from "../src/index";
|
||||||
|
|
||||||
if (process.argv.length < 3) {
|
if (process.argv.length < 3) {
|
||||||
|
@ -12,6 +12,10 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
@ -2,7 +2,7 @@ path = require("path");
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
index: './src/player.ts'
|
index: './src/player.tsx'
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
@ -11,6 +11,10 @@ module.exports = {
|
|||||||
use: 'ts-loader',
|
use: 'ts-loader',
|
||||||
exclude: /node_modules/
|
exclude: /node_modules/
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: ["style-loader", "css-loader"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
test: /\.yaml$/,
|
test: /\.yaml$/,
|
||||||
use: 'js-yaml-loader',
|
use: 'js-yaml-loader',
|
||||||
@ -23,6 +27,6 @@ module.exports = {
|
|||||||
devtool: 'inline-source-map',
|
devtool: 'inline-source-map',
|
||||||
output: {
|
output: {
|
||||||
filename: '[name].js',
|
filename: '[name].js',
|
||||||
path: path.resolve(__dirname, 'assets')
|
path: path.resolve(__dirname, 'public')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user