forked from BRNSystems/bosca-ceoil-js
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/
|
||||
tmp/
|
||||
assets/
|
||||
public/*.js
|
||||
audio/**/*_data/
|
||||
*.aup
|
||||
*.tgz
|
||||
|
@ -28,7 +28,6 @@ Prerequisites:
|
||||
Initial setup:
|
||||
|
||||
* `npm install`
|
||||
* `npm run assets`
|
||||
|
||||
Run:
|
||||
|
||||
|
161
index.html
161
index.html
@ -1,167 +1,12 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<script src="/bosca-ceoil-js/assets/index.js"></script>
|
||||
|
||||
<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">
|
||||
<script src="/bosca-ceoil-js/public/index.js"></script>
|
||||
<link rel="stylesheet" href="/bosca-ceoil-js/public/index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<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>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</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",
|
||||
"types": "out/index.d.ts",
|
||||
"scripts": {
|
||||
"assets": "ts-node tasks/assets.ts",
|
||||
"compile": "tsc -p ./",
|
||||
"deploy": "ts-node tasks/deploy.ts",
|
||||
"dev": "concurrently npm:watch npm:webpack-dev npm:serve",
|
||||
@ -31,19 +30,30 @@
|
||||
"/out"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/gh-pages": "^2.0.0",
|
||||
"@types/js-yaml": "^3.12.1",
|
||||
"@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",
|
||||
"concurrently": "^4.1.1",
|
||||
"css-loader": "^3.1.0",
|
||||
"gh-pages": "^2.0.1",
|
||||
"js-yaml": "^3.13.1",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"local-web-server": "^3.0.4",
|
||||
"style-loader": "^0.23.1",
|
||||
"ts-loader": "^6.0.4",
|
||||
"ts-node": "^8.3.0",
|
||||
"tslint": "^5.18.0",
|
||||
|
@ -10,6 +10,19 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mdc-text-field {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.effect-panel {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.effect-panel-popup {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@ -17,12 +30,12 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#playButton, #effectsButton, #helpButton {
|
||||
button {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#playButton, #helpButton {
|
||||
#playButton {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
@ -84,7 +97,7 @@ table tr:nth-of-type(12n) {
|
||||
border-bottom: 3px solid white;
|
||||
}
|
||||
|
||||
.metaWorkspace {
|
||||
#metaWorkspace {
|
||||
background-color: transparent;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@ -93,19 +106,14 @@ table tr:nth-of-type(12n) {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
table#pattern {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* MDL hides these by default. */
|
||||
|
||||
input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: inner-spin-button !important;
|
||||
}
|
||||
|
||||
#helpModal {
|
||||
width: 600px;
|
||||
.no-select {
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import * as tone from "tone";
|
||||
import tone from "tone";
|
||||
import { assertNever, notes } from "./index";
|
||||
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 * as path from "path";
|
||||
import ghPages from "gh-pages";
|
||||
import path from "path";
|
||||
|
||||
const root = `${path.dirname(process.argv[1])}/..`;
|
||||
|
||||
@ -8,9 +8,8 @@ ghPages.publish(
|
||||
{
|
||||
"src": [
|
||||
"index.html",
|
||||
"index.css",
|
||||
"assets/*.css",
|
||||
"assets/*.js",
|
||||
"public/*.css",
|
||||
"public/*.js",
|
||||
"audio/**/*.ogg",
|
||||
]
|
||||
},
|
||||
|
@ -1,8 +1,8 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as process from "process";
|
||||
import * as child_process from "child_process";
|
||||
import * as yaml from "js-yaml";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
import child_process from "child_process";
|
||||
import yaml from "js-yaml";
|
||||
import { InstrumentData, notes } from "../src/index";
|
||||
|
||||
if (process.argv.length < 3) {
|
||||
|
@ -12,6 +12,10 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictNullChecks": true,
|
||||
"jsx": "react",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
@ -2,7 +2,7 @@ path = require("path");
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
index: './src/player.ts'
|
||||
index: './src/player.tsx'
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
@ -11,6 +11,10 @@ module.exports = {
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ["style-loader", "css-loader"],
|
||||
},
|
||||
{
|
||||
test: /\.yaml$/,
|
||||
use: 'js-yaml-loader',
|
||||
@ -23,6 +27,6 @@ module.exports = {
|
||||
devtool: 'inline-source-map',
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'assets')
|
||||
path: path.resolve(__dirname, 'public')
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user