Integrate React and prepare state management for multiple channels and patterns

This commit is contained in:
mtkennerly 2019-07-27 20:03:45 -04:00
parent a1d08f8e54
commit 716982f579
14 changed files with 1635 additions and 590 deletions

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:

@ -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

File diff suppressed because it is too large Load Diff

@ -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";

@ -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

@ -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')
} }
}; };