First public version

This commit is contained in:
David Whiting
2021-04-20 10:50:16 +02:00
commit 2e862f62ed
19 changed files with 1697 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.*

BIN
909BD.mp3 Normal file

Binary file not shown.

BIN
909CH.mp3 Normal file

Binary file not shown.

BIN
909OH.mp3 Normal file

Binary file not shown.

BIN
909SD.mp3 Normal file

Binary file not shown.

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# The Endless Acid Banger
An algorithmic human-computer techno jam
![Screenshot](https://github.com/vitling/acid-banger/blob/main/preview.png?raw=true)
Built in Typescript with the WebAudio API.
Live version running at [www.vitling.xyz/toys/acid-banger](https://www.vitling.xyz/toys/acid-banger)
## Support
You can support my work by [Sponsoring me on GitHub](https://github.com/sponsors/vitling) or [buying](https://music.vitling.xyz) [my music](https://edgenetwork.bandcamp.com/album/edge001-spaceport-lounge-music)
## License
This work is licensed under a [Creative Commons Attribution 4.0 International License](http://creativecommons.org/licenses/by/4.0/)
This means you can use the ideas and/or the code and/or the music output in derivative works, but you must give credit to the original source (ie. me and this project).

6
build.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
mkdir -p dist
tsc -p .
webpack ./js/app.js -o dist/ --mode production
cp index.html *.wav ui.css preview.png dist

34
index.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<!--Copyright 2021 David Whiting-->
<!--This work is licensed under a Creative Commons Attribution 4.0 International License-->
<!--https://creativecommons.org/licenses/by/4.0/-->
<html lang="en">
<head>
<meta charset="UTF-8">
<title>The Endless Acid Banger</title>
<script type="module" src="js/app.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title" content="vitling: The Endless Acid Banger" />
<meta property="og:type" content="music.song" />
<meta property="og:url" content="http://www.vitling.com/toys/acid-banger/" />
<meta property="og:image" content="http://www.vitling.com/toys/acid-banger/preview.png" />
<meta property="og:description" content="Algorithmic music experiement by Vitling" />
<meta property="music:musician" content="Vitling" />
<link rel="stylesheet" href="ui.css">
</head>
<body>
<h2>The Endless Acid Banger</h2>
<p>
The music you hear is generated in your browser by a randomised algorithm, below you can see the notes and parameters that are currently in use.
You can also interact with various parameters and buttons manually.
The green autopilot switches change how automatic playback is. Leave them on for a lean-back experience.
Buttons labelled ⟳ will generate new patterns.
</p>
<p>
The Endless Acid Banger was created by <a href="https://www.vitling.xyz">Vitling</a>.
If you want to support my work, please consider
<a href="https://music.vitling.xyz/album/long-walks-and-tough-talks">buying</a> <a href="https://edgenetwork.bandcamp.com/album/edge001-spaceport-lounge-music">my</a> <a href="https://midnight-people.bandcamp.com/album/destiny-ep">music</a> or
<a href="https://github.com/sponsors/vitling">sponsoring my GitHub</a>.
</p>
</body>
</html>

BIN
preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

256
src/app.ts Normal file
View File

@@ -0,0 +1,256 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
import {Clock, pressToStart} from "./boilerplate.js";
import {Audio, AudioT} from './audio.js';
import {NineOhGen, ThreeOhGen} from "./pattern.js";
import {UI} from "./ui.js";
import {
DrumPattern,
genericParameter,
NineOhMachine, NoteGenerator,
NumericParameter,
parameter,
Pattern, ProgramState,
ThreeOhMachine, trigger,
DelayUnit, ClockUnit,
AutoPilotUnit
} from "./interface.js";
function WanderingParameter(param: NumericParameter, scaleFactor = 1/400) {
const [min,max] = param.bounds;
let diff = 0.0;
let scale = scaleFactor * (max - min);
let touchCountdown = 0;
let previousValue = (min + max) / 2 ;
const step = () => {
if (previousValue != param.value) {
// Something else has touched this parameter
diff = 0;
previousValue = param.value;
touchCountdown = 200
} else {
if (touchCountdown > 0) {
touchCountdown--;
}
if (touchCountdown < 100) {
diff *= touchCountdown > 0 ? 0.8 : 0.98;
diff += (Math.random() - 0.5) * scale;
param.value += diff;
previousValue = param.value
if (param.value > min + 0.8 * (max - min)) {
diff -= Math.random() * scale;
} else if (param.value < min + 0.2 * (max - min)) {
diff += Math.random() * scale;
}
}
}
}
return {
step
}
}
function ThreeOhUnit(audio: AudioT, waveform: OscillatorType, output: AudioNode, gen: NoteGenerator, patternLength: number=16): ThreeOhMachine {
const synth = audio.ThreeOh(waveform, output);
const pattern = genericParameter<Pattern>("Pattern", []);
const newPattern = trigger("New Pattern Trigger", true);
gen.newNotes.subscribe(newNotes => {
if (newNotes == true) newPattern.value = true;
})
function step(index: number) {
if ((index === 0 && newPattern.value == true) || pattern.value.length == 0) {
pattern.value = gen.createPattern();
newPattern.value = false;
}
const slot = pattern.value[index % patternLength];
if (slot.note != "-") {
synth.noteOn(slot.note, slot.accent, slot.glide);
} else {
synth.noteOff();
}
}
const parameters = {
cutoff: parameter("Cutoff", [30,700],400),
resonance: parameter("Resonance", [1,30],15),
envMod: parameter("Env Mod", [0,8000], 4000),
decay: parameter("Decay", [0.1,0.9], 0.5)
};
parameters.cutoff.subscribe(v => synth.params.cutoff.value = v);
parameters.resonance.subscribe(v => synth.params.resonance.value = v);
parameters.envMod.subscribe(v => synth.params.envMod.value = v);
parameters.decay.subscribe(v => synth.params.decay.value = v);
return {
step,
pattern,
parameters,
newPattern
}
}
async function NineOhUnit(audio: AudioT): Promise<NineOhMachine> {
const drums = await audio.SamplerDrumMachine(["909BD.mp3","909OH.mp3","909CH.mp3","909SD.mp3"])
const pattern = genericParameter<DrumPattern>("Drum Pattern", []);
const mutes = [
genericParameter("Mute BD", false),
genericParameter("Mute OH", false),
genericParameter("Mute CH", false),
genericParameter("Mute SD", false)
];
const newPattern = trigger("New Pattern Trigger", true);
const gen = NineOhGen();
function step(index: number) {
if ((index == 0 && newPattern.value == true) || pattern.value.length == 0) {
pattern.value = gen.createPatterns(true);
newPattern.value = false;
}
for (let i in pattern.value) {
const entry = pattern.value[i][index % pattern.value[i].length];
if (entry && !mutes[i].value) {
drums.triggers[i].play(entry);
}
}
}
return {
step,
pattern,
mutes,
newPattern
}
}
function DelayUnit(audio: AudioT): DelayUnit {
const dryWet = parameter("Dry/Wet", [0,0.5], 0.5);
const feedback = parameter("Feedback", [0,0.9], 0.3);
const delayTime = parameter("Time", [0,2], 0.3);
const delay = audio.DelayInsert(delayTime.value, dryWet.value, feedback.value);
dryWet.subscribe(w => delay.wet.value = w);
feedback.subscribe(f => delay.feedback.value = f);
delayTime.subscribe(t => delay.delayTime.value = t);
return {
dryWet,
feedback,
delayTime,
inputNode: delay.in,
}
}
function AutoPilot(state: ProgramState): AutoPilotUnit {
const nextMeasure = parameter("upcomingMeasure", [0, Infinity],0);
const currentMeasure = parameter("measure", [0, Infinity], 0);
const patternEnabled = genericParameter("Alter Patterns", true);
const dialsEnabled = genericParameter("Twiddle With Knobs", true);
const mutesEnabled = genericParameter("Mute Drum Parts", true);
state.clock.currentStep.subscribe(step => {
if (step === 4) {
nextMeasure.value = nextMeasure.value + 1;
} else if (step === 15) { // slight hack to get mutes functioning as expected
currentMeasure.value = currentMeasure.value + 1;
}
});
nextMeasure.subscribe(measure => {
if (patternEnabled.value) {
if (measure % 64 === 0) {
if (Math.random() < 0.2) {
state.gen.newNotes.value = true;
}
}
if (measure % 16 === 0) {
state.notes.forEach((n, i) => {
if (Math.random() < 0.5) {
n.newPattern.value = true;
}
});
if (Math.random() < 0.3) {
state.drums.newPattern.value = true;
}
}
}
})
currentMeasure.subscribe(measure => {
if (mutesEnabled.value) {
if (measure % 8 == 0) {
const drumMutes = [Math.random() < 0.2, Math.random() < 0.5, Math.random() < 0.5, Math.random() < 0.5];
state.drums.mutes[0].value = drumMutes[0];
state.drums.mutes[1].value = drumMutes[1];
state.drums.mutes[2].value = drumMutes[2];
state.drums.mutes[3].value = drumMutes[3];
}
}
})
const noteParams = state.notes.flatMap(x => Object.values(x.parameters))
const delayParams = [state.delay.feedback, state.delay.dryWet];
const wanderers = [...noteParams, ...delayParams].map(param => WanderingParameter(param));
window.setInterval(() => { if (dialsEnabled.value) wanderers.forEach(w => w.step());},100);
return {
switches: [
patternEnabled,
dialsEnabled,
mutesEnabled
]
}
}
function ClockUnit(): ClockUnit {
const bpm = parameter("BPM", [70,200],142);
const currentStep = parameter("Current Step", [0,15],0);
const clockImpl = Clock(bpm.value, 4, 0.0);
bpm.subscribe(clockImpl.setBpm);
clockImpl.bind((time, step) => {
currentStep.value = step % 16;
})
return {
bpm,
currentStep
}
}
async function start() {
const audio = Audio();
const clock = ClockUnit();
const delay = DelayUnit(audio);
clock.bpm.subscribe(b => delay.delayTime.value = (3/4) * (60/b));
const gen = ThreeOhGen();
const programState: ProgramState = {
notes: [
ThreeOhUnit(audio, "sawtooth", delay.inputNode, gen),ThreeOhUnit(audio, "square", delay.inputNode, gen)
],
drums: await NineOhUnit(audio),
gen,
delay,
clock
}
clock.currentStep.subscribe(step => [...programState.notes, programState.drums].forEach(d => d.step(step)));
const autoPilot = AutoPilot(programState);
const ui = UI(programState, autoPilot, audio.master.analyser);
document.body.append(ui);
}
pressToStart(start, "The Endless Acid Banger", "A collaboration between human and algorithm by Vitling");

340
src/audio.ts Normal file
View File

@@ -0,0 +1,340 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
import {biRnd} from "./math.js";
export type Note = 'A' | 'A#' | 'B' | 'C' | 'C#' | 'D' | 'D#' | 'E' | 'F' | 'F#' | 'G' | 'G#';
export type Octave = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8';
export type FullNote = `${Note}${Octave}`
const lookupTable: Map<Note, number> = new Map<Note, number>();
const revLook: Map<number, Note> = new Map<number, Note>();
(()=>{
function add(note: Note, n: number) {
lookupTable.set(note, n);
revLook.set(n, note);
}
add('A', 9);
add('A#', 10);
add('B', 11);
add('C', 0);
add('C#', 1);
add('D', 2);
add('D#', 3);
add('E', 4);
add('F', 5);
add('F#', 6);
add('G', 7);
add('G#', 8);
})();
export function textNoteToNumber(note: FullNote) {
const o: Octave = note.substring(note.length - 1) as Octave;
const n: Note = note.substring(0,note.length -1) as Note;
// @ts-ignore
return parseInt(o) * 12 + lookupTable.get(n) + 12;
}
function midiNoteToFrequency(noteNumber: number) {
return 440 * Math.pow(2, (noteNumber - 69) / 12);
}
export function midiNoteToText(note: number): FullNote {
const octave = Math.floor(note / 12);
const n = Math.floor(note % 12);
const noteName = revLook.get(n) as Note;
return `${noteName}${octave}` as FullNote;
}
export function pitch(note: FullNote | number) {
if (typeof(note) === 'number') {
return midiNoteToFrequency(note);
} else {
return midiNoteToFrequency(textNoteToNumber(note));
}
}
// @ts-ignore
export function Audio(au: AudioContext = new (window.AudioContext || window.webkitAudioContext)()) {
function masterChannel() {
const gain = au.createGain();
gain.gain.value = 0.5;
const limiter = au.createDynamicsCompressor();
limiter.attack.value = 0.005;
limiter.release.value = 0.1;
limiter.ratio.value = 15.0;
limiter.knee.value = 0.0;
limiter.threshold.value = -0.5;
const analyser = au.createAnalyser();
analyser.fftSize = 2048;
limiter.connect(analyser);
gain.connect(limiter);
limiter.connect(au.destination);
return {
in: gain,
analyser
}
}
function constantSourceCompatible(): AudioNode & {offset: AudioParam, start: () => void} {
if (au.createConstantSource) {
return au.createConstantSource();
} else {
const src = au.createBufferSource();
src.buffer = au.createBuffer(1, 256, au.sampleRate);
const array = src.buffer.getChannelData(0);
for (let i = 0; i < array.length; i++) {
array[i] = 1.0;
}
const gain = au.createGain();
const offsetParam = gain.gain;
src.loop = true;
src.connect(gain);
return Object.assign(gain, {offset: offsetParam, start: () => src.start()});
}
}
function decodeAudioDataCompatible(audioData: ArrayBuffer): Promise<AudioBuffer> {
return new Promise((resolve, reject) => {
return au.decodeAudioData(audioData, resolve, reject);
});
}
const master = masterChannel();
function time(s: number) {
return new Promise<void>(resolve => {setTimeout(()=> resolve(), s * 1000)})
}
async function tone(pitch: number, attack: number, sustain: number, release:number, pan: number = 0.0, destination: AudioNode = master.in) {
const osc = au.createOscillator();
osc.type = "sawtooth";
osc.frequency.value = pitch;
osc.start();
const filter = au.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = pitch * 4;
filter.Q.value = 5;
const gain = au.createGain();
gain.gain.value = 0.0;
const panner = au.createPanner();
panner.panningModel = "equalpower";
panner.setPosition(pan, 0, 1-Math.abs(pan));
osc.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(destination);
gain.gain.linearRampToValueAtTime(0.1, au.currentTime + attack);
await time(sustain + attack);
gain.gain.setValueAtTime(0.1, au.currentTime);
gain.gain.linearRampToValueAtTime(0,au.currentTime + release);
filter.frequency.linearRampToValueAtTime(Math.max(pitch/2, 400), au.currentTime + release);
await time(release + 0.01);
osc.stop(au.currentTime);
panner.disconnect();
}
function SimpleToneSynth(attack: number, sustain: number, release:number, destination: AudioNode = master.in) {
function play(note: FullNote) {
tone(pitch(note), attack, sustain, release, biRnd(), destination);
}
return {
play
}
}
function DelayInsert(time: number, feedback: number, wet: number, destination: AudioNode = master.in) {
const delayNode = au.createDelay(1);
delayNode.delayTime.value = time;
const feedbackGain = au.createGain();
feedbackGain.gain.value = feedback;
delayNode.connect(feedbackGain);
feedbackGain.connect(delayNode);
const delayGain = au.createGain();
delayGain.gain.value = wet;
delayNode.connect(delayGain);
delayGain.connect(destination);
const synthOut = au.createGain();
synthOut.gain.value = 1.0;
synthOut.connect(delayNode);
synthOut.connect(destination);
return {
in: synthOut,
feedback: feedbackGain.gain,
wet: delayGain.gain,
delayTime: delayNode.delayTime
}
}
function ThreeOh(type: OscillatorType = "sawtooth", out: AudioNode = master.in) {
const filter = au.createBiquadFilter();
filter.type = "lowpass";
filter.Q.value = 20;
filter.frequency.value = 300;
const pResonance = filter.Q;
const pCutoff = filter.frequency;
const decayTimeNode = constantSourceCompatible();
decayTimeNode.start();
const pDecay = decayTimeNode.offset;
const env = constantSourceCompatible();
env.start();
env.offset.value = 0.0;
function trigger() {
}
const scaleNode = au.createGain();
scaleNode.gain.value = 4000;
const pEnvMod = scaleNode.gain;
env.connect(scaleNode);
scaleNode.connect(filter.detune);
const osc = au.createOscillator();
osc.type = type;
osc.frequency.value = 440;
osc.start();
const vca = au.createGain();
vca.gain.value = 0.0;
osc.connect(vca);
vca.connect(filter);
filter.connect(out);
function noteOn(note: FullNote, accent: boolean = false, glide: boolean = false) {
if (accent) {
env.offset.cancelScheduledValues(au.currentTime);
//env.offset.setTargetAtTime(1.0,au.currentTime, 0.001);
env.offset.setValueAtTime(1.0, au.currentTime);
env.offset.exponentialRampToValueAtTime(0.01, au.currentTime + pDecay.value/3);
} else {
env.offset.cancelScheduledValues(au.currentTime);
//env.offset.setTargetAtTime(1.0,au.currentTime, 0.001);
env.offset.setValueAtTime(1.0, au.currentTime);
env.offset.exponentialRampToValueAtTime(0.01, au.currentTime + pDecay.value);
}
osc.frequency.cancelScheduledValues(au.currentTime);
osc.frequency.setTargetAtTime(midiNoteToFrequency(textNoteToNumber(note)),au.currentTime, glide ? 0.02 : 0.002);
vca.gain.cancelScheduledValues(au.currentTime);
vca.gain.setValueAtTime(accent ? 0.2 : 0.15, au.currentTime);
//vca.gain.setTargetAtTime(accent ? 0.5 : 0.3,au.currentTime, 0.001);
//vca.gain.setValueAtTime(0.2, au.currentTime);
vca.gain.linearRampToValueAtTime(0.1, au.currentTime + 0.2);
trigger();
}
function noteOff() {
vca.gain.cancelScheduledValues(au.currentTime);
vca.gain.setTargetAtTime(0.0,au.currentTime,0.01);
}
return {
noteOn,
noteOff,
params: {
cutoff: pCutoff,
resonance: pResonance,
envMod: pEnvMod,
decay: pDecay
}
}
}
function kick(out: AudioNode = master.in) {
const osc = au.createOscillator();
osc.frequency.value = 400;
const gain = au.createGain();
gain.gain.value = 0.3;
osc.start();
osc.frequency.exponentialRampToValueAtTime(50, au.currentTime + 0.04);
gain.gain.setValueCurveAtTime([0.5,0.5,0.45,0.4,0.25,0.0], au.currentTime, 0.09);
osc.stop(au.currentTime + 0.1);
window.setTimeout(() => gain.disconnect(), 200);
osc.connect(gain);
gain.connect(out);
}
async function loadBuffer(filePath: string) {
const response = await fetch(filePath);
const arraybuffer = await response.arrayBuffer();
const audioBuffer = await decodeAudioDataCompatible(arraybuffer);
return audioBuffer;
}
async function Sampler(file: string) {
const sampleBuffer = await loadBuffer(file);
function play(gain: number = 0.4, decay: number = 1.0, out: AudioNode = master.in) {
const bufferSource = au.createBufferSource();
bufferSource.buffer = sampleBuffer;
bufferSource.loop = false;
const gainNode = au.createGain();
gainNode.gain.setValueAtTime(gain,au.currentTime);
gainNode.gain.linearRampToValueAtTime(0.0, au.currentTime + decay);
bufferSource.connect(gainNode);
gainNode.connect(out);
bufferSource.start(au.currentTime);
}
return {
play
}
}
async function SamplerDrumMachine(files: string[], out: AudioNode = master.in) {
const sum = au.createGain();
sum.gain.value = 1.0;
sum.connect(out);
const promisedMachines = files.map(Sampler)
const samplers = await Promise.all(promisedMachines);
const mapped = samplers.map(sampler => ({
play: (vel: number) => sampler.play(0.7 * vel, vel * 0.5, sum)
}));
return {
triggers: mapped
}
}
return {
tone,
SimpleToneSynth,
DelayInsert,
ThreeOh,
kick,
Sampler,
SamplerDrumMachine,
master,
context: au
}
}
export type AudioT = ReturnType<typeof Audio>

93
src/boilerplate.ts Normal file
View File

@@ -0,0 +1,93 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
export function pressToStart(fn: () => void, title: string, description: string, callToAction: string = "Click, tap or press any key to start") {
const button = document.createElement("button");
button.id="_start_button";
const introText = document.createElement("div");
introText.id = "_intro_text";
button.append(introText);
introText.innerHTML = title + "<br><br>" + description + "<br><br>" + callToAction;
document.head.insertAdjacentHTML("beforeend", `
<style>
body {
height: 95vh; margin: 0; padding: 0;
}
#${button.id} {
width: 100%;
height: 100%;
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 50;
color:grey;
background-color: black;
}
#${introText.id} {
max-width: 640px;
font-size: 1.5em;
margin-left: auto;
margin-right: auto;
text-align:left;
font-family: monospace;
}
</style>
`);
document.body.append(button);
let started = false;
function handleStartAction() {
if (!started) {
started = true;
fn();
button.style.display = "none";
}
}
button.addEventListener("click", handleStartAction);
window.addEventListener("keydown", handleStartAction);
}
export function repeat(seconds: number, fn: (time: number, step: number) => void) {
let time = new Date().getTime();
let n = 0;
function step() {
const t = new Date().getTime() - time;
fn(t, n);
n++;
}
step();
window.setInterval(step, seconds * 1000);
}
export function Clock(bpm: number, subdivision: number =4, shuffle: number = 0) {
let currentBpm = bpm;
let fn = (time: number, step: number) => {};
let time = new Date().getTime();
let n = 0;
function bind(newFn: (time: number, step: number) => void) {
fn = newFn;
}
function step() {
const t = new Date().getTime() - time;
fn(t, n);
const shuffleFactor = n % 2 == 0 ? 1 + shuffle : 1 - shuffle;
n++;
window.setTimeout(step, shuffleFactor * (60000 / currentBpm) / subdivision);
}
window.setTimeout(step, (60000 / bpm) / subdivision);
return {
bind,
setBpm: (bpm: number) => currentBpm = bpm
}
}

122
src/dial.ts Normal file
View File

@@ -0,0 +1,122 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
function clamp(n: number): number {
return n < 0 ? 0 : n > 1 ? 1 : n;
}
export function Dial(bounds: [number, number], text?: string, dialColor: string = "red", textColor: string="white"){
const element = document.createElement("canvas");
element.classList.add("dial");
const w = element.width = 70;
const h = element.height = 50;
const size = 20;
const g = element.getContext("2d") as CanvasRenderingContext2D;
let normalizedValue = 0.5;
let previousNormalisedValue = 0.5;
let fadeCounter = 0;
let fadeTimerHandler: number | null = null;
function paint() {
g.clearRect(0,0,w,h);
const arc = [Math.PI * 0.8, Math.PI * 2.2];
g.strokeStyle = dialColor;
g.lineWidth = 2;
g.beginPath();
g.arc(w/2, h/2, size, arc[0], arc[1]);
g.stroke();
g.lineWidth = w / 8;
const pos = arc[0] + normalizedValue * (arc[1] - arc[0]);
g.beginPath();
g.arc(w/2, h/2, size, pos - 0.2, pos + 0.2);
g.stroke();
if (fadeCounter > 0) {
g.strokeStyle = "rgba(0,255,0," + clamp(fadeCounter/10) + ")";
g.lineWidth = w / 8;
const pos = arc[0] + normalizedValue * (arc[1] - arc[0]);
g.beginPath();
g.arc(w/2, h/2, size, pos - 0.2, pos + 0.2);
g.stroke();
}
if (text) {
g.fillStyle = textColor;
g.font = "10px Orbitron";
const tw = g.measureText(text).width;
g.fillText(text, w/2 - tw / 2, h/2 + size);
}
}
function fade(frames: number) {
if (fadeTimerHandler) window.clearInterval(fadeTimerHandler);
fadeCounter = Math.min(frames, 10);
fadeTimerHandler = window.setInterval(() => {
fadeCounter--;
paint()
}, 100);
}
function normalise(v: number) {
return (v - bounds[0]) / (bounds[1] - bounds[0]);
}
function denormalise(n: number) {
return bounds[0] + (bounds[1] - bounds[0]) * n;
}
function setValue(n: number) {
normalizedValue = normalise(n);
paint();
if (Math.abs(normalizedValue - previousNormalisedValue) > 0.002) {
fade(4 + Math.floor(Math.abs(normalizedValue - previousNormalisedValue) / 0.001));
}
previousNormalisedValue = normalizedValue;
}
function getValue(): number {
return denormalise(normalizedValue);
}
const state = {isDragging: false, handler: [(v: number) => {}]};
function bind(h: (v: number) => void) {
state.handler.push(h);
}
element.addEventListener("mousedown", (e) => {
state.isDragging = true;
});
window.addEventListener("mousemove", (e) => {
if (state.isDragging) {
const delta = (e.movementX - e.movementY)/100;
normalizedValue = clamp(normalizedValue+delta);
const actualValue = denormalise(normalizedValue);
setValue(actualValue);
state.handler.forEach(h => h(actualValue));
}
})
window.addEventListener("mouseup", (e) => {
state.isDragging = false;
})
paint();
return {
element,
get value(): number { return getValue(); },
set value(v: number) { setValue(v); },
bind
};
}
export type DialT = ReturnType<typeof Dial>

111
src/interface.ts Normal file
View File

@@ -0,0 +1,111 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
import {FullNote} from "./audio.js";
export type Slot = {
note: FullNote | "-";
accent: boolean,
glide: boolean
}
export type Pattern = Slot[]
export type DrumPattern = number[][];
type ParameterCallback<T> = (v: T) => any;
export type GeneralisedParameter<T> = {
value: T,
name: string,
subscribe: (callback: ParameterCallback<T>) => any
}
export type Trigger = GeneralisedParameter<boolean>
export type NumericParameter = GeneralisedParameter<number> & {
bounds: [number,number]
}
export type PatternParameter = GeneralisedParameter<Pattern>;
export type ThreeOhMachine = {
pattern: GeneralisedParameter<Pattern>,
newPattern: Trigger
step: (step: number) => void
parameters: {
cutoff: NumericParameter,
resonance: NumericParameter,
envMod: NumericParameter,
decay: NumericParameter
}
}
export type NineOhMachine = {
pattern: GeneralisedParameter<DrumPattern>,
newPattern: Trigger,
mutes: GeneralisedParameter<boolean>[],
step: (step: number) => void
}
export type NoteGenerator = {
noteSet: GeneralisedParameter<FullNote[]>
newNotes: Trigger
createPattern: () => Pattern
}
export type DelayUnit = {
dryWet: NumericParameter,
feedback: NumericParameter,
delayTime: NumericParameter,
inputNode: AudioNode,
// outputNode: AudioNode
}
export type ClockUnit = {
currentStep: NumericParameter,
bpm: NumericParameter
}
export type AutoPilotUnit = {
switches: GeneralisedParameter<boolean>[]
}
export function genericParameter<T>(name: string, value: T): GeneralisedParameter<T> {
let listeners: ParameterCallback<T>[] = [];
const state = {value};
function subscribe(callback: ParameterCallback<T>) {
callback(state.value);
listeners.push(callback);
}
function publish() {
for (let l of listeners) {
l(state.value);
}
}
return {
name,
subscribe,
get value() { return state.value; },
set value(v: T) { state.value = v; publish(); }
}
}
export function trigger(name: string, value: boolean = false): Trigger {
return genericParameter(name, value);
}
export function parameter(name: string, bounds: [number, number], value: number): NumericParameter {
return Object.assign(genericParameter<number>(name, value), {bounds});
}
export type ProgramState = {
notes: ThreeOhMachine[],
drums: NineOhMachine,
gen: NoteGenerator,
delay: DelayUnit
clock: ClockUnit
}

18
src/math.ts Normal file
View File

@@ -0,0 +1,18 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
export function rndInt(maxExcl: number): number {
return Math.floor(Math.random() * (maxExcl-0.01));
}
export function biRnd(): number {
return Math.random() * 2 - 1;
}
export function choose<T>(array: T[]):T {
return array[rndInt(array.length)];
}

153
src/pattern.ts Normal file
View File

@@ -0,0 +1,153 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
import {FullNote, midiNoteToText} from "./audio.js";
import {choose, rndInt} from "./math.js";
import {
GeneralisedParameter,
genericParameter,
NoteGenerator,
parameter,
Pattern,
Slot,
trigger,
Trigger
} from "./interface.js";
export function ThreeOhGen(): NoteGenerator {
let noteSet: GeneralisedParameter<FullNote[]> = genericParameter("note set", ['C1']);
let newNotes: Trigger = trigger("new note set", true);
const density = 1.0;
const offsetChoices = [
[0,0,12,24,27],
[0,0,0,12,10,19,26,27],
[0,1,7,10,12,13],
[0],
[0,0,0,12],
[0,0,12,14,15,19],
[0,0,0,0,12,13,16,19,22,24,25],
[0,0,0,7,12,15,17,20,24],
];
function changeNotes() {
const root = rndInt(15) + 16;
const offsets: number[] = choose(offsetChoices);
noteSet.value = offsets.map(o => midiNoteToText(o + root));
}
function createPattern(): Pattern {
if (newNotes.value == true) {
changeNotes();
newNotes.value = false;
}
const pattern: Slot[] = [];
for (let i = 0; i < 16; i++) {
const chance = density * (i % 4 === 0 ? 0.6 : (i % 3 === 0 ? 0.5 : (i % 2 === 0 ? 0.3 : 0.1)));
if (Math.random() < chance) {
pattern.push({
note: choose(noteSet.value),
accent: Math.random() < 0.3,
glide: Math.random() < 0.1
})
} else {
pattern.push({
note: "-",
accent: false,
glide: false
})
}
}
return pattern;
}
return {
createPattern,
newNotes,
noteSet
}
}
export function NineOhGen() {
function createPatterns(full: boolean = false) {
const kickPattern: number[] = new Array(16);
const ohPattern: number[] = new Array(16);
const chPattern: number[] = new Array(16);
const sdPattern: number[] = new Array(16);
const kickMode: string = choose(["electro", "fourfloor"]);
const hatMode: string = choose(["offbeats", "closed", full ? "offbeats" : "none"]);
const snareMode: string = choose(["backbeat","skip", full ? "backbeat" : "none"]);
if (kickMode == "fourfloor") {
for (let i = 0; i < 16; i++) {
if (i % 4 == 0) {
kickPattern[i] = 0.9;
} else if (i % 2 == 0 && Math.random() < 0.1) {
kickPattern[i] = 0.6;
}
}
} else if (kickMode == "electro") {
for (let i = 0; i < 16; i++) {
if (i == 0) {
kickPattern[i] = 1;
} else if (i % 2 == 0 && i % 8 != 4 && Math.random() < 0.5) {
kickPattern[i] = Math.random() * 0.9;
} else if (Math.random() < 0.05) {
kickPattern[i] = Math.random() * 0.9;
}
}
}
if (snareMode == "backbeat") {
for (let i = 0; i < 16; i++) {
if (i % 8 === 4) {
sdPattern[i] = 1;
}
}
} else if (snareMode == "skip") {
for (let i = 0; i < 16; i++) {
if (i % 8 === 3 || i % 8 === 6) {
sdPattern[i] = 0.6 + Math.random() * 0.4;
} else if (i % 2 === 0 && Math.random() < 0.2) {
sdPattern[i] = 0.4 + Math.random() * 0.2;
} else if (Math.random() < 0.1) {
sdPattern[i] = 0.2 + Math.random() * 0.2;
}
}
}
if (hatMode == "offbeats") {
for (let i = 0; i < 16; i++) {
if (i % 4 == 2) {
ohPattern[i] = 0.4;
} else if (Math.random() < 0.3) {
if (Math.random() < 0.5) {
chPattern[i] = Math.random() * 0.2;
} else {
ohPattern[i] = Math.random() * 0.2;
}
}
}
} else if (hatMode == "closed") {
for (let i = 0; i < 16; i++) {
if (i % 2 === 0) {
chPattern[i] = 0.4;
} else if (Math.random() < 0.5) {
chPattern[i] = Math.random() * 0.3;
}
}
}
return [kickPattern,ohPattern,chPattern,sdPattern]
}
return {
createPatterns
}
}

335
src/ui.ts Normal file
View File

@@ -0,0 +1,335 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
import {
DelayUnit,
DrumPattern,
GeneralisedParameter, ClockUnit, NineOhMachine, NoteGenerator,
NumericParameter,
PatternParameter, ProgramState,
ThreeOhMachine, Trigger, AutoPilotUnit
} from "./interface.js";
import {textNoteToNumber} from "./audio.js";
import {Dial} from "./dial.js";
const defaultColors = {
bg: "#222266",
note: "#88aacc",
accent: "#AA88CC",
glide: "#CCAA88",
text: "#CCCCFF",
highlight: "rgba(255,255,255,0.2)",
grid: "rgba(255,255,255,0.2)",
dial: "#AA88CC"
}
type ColorScheme = { [color in keyof typeof defaultColors]: string; };
function DialSet(parameters: {[key: string]: NumericParameter} | NumericParameter[], ...classes: string[]) {
const params = Array.isArray(parameters) ? parameters : Object.keys(parameters).map(k => parameters[k]);
const container = document.createElement("div");
container.classList.add("params", ...classes);
params.forEach(param => {
//const param = parameters[p];
const dial = Dial(param.bounds, param.name, defaultColors.dial, defaultColors.text);
// Change the parameter if we move the dial
dial.bind(v => { param.value = v });
// Move the dial if the parameter changes elsewhere
param.subscribe(v => dial.value = v);
container.append(dial.element);
})
return container;
}
function triggerButton(target: Trigger) {
const but = document.createElement("button");
but.classList.add("trigger-button")
but.innerText = "⟳";
target.subscribe(v => {
if (v) but.classList.add("waiting"); else but.classList.remove("waiting");
});
but.addEventListener("click", function () {
target.value = true;
})
return but;
}
function toggleButton(param: GeneralisedParameter<boolean>, ...classes: string[]) {
const button = document.createElement("button");
button.classList.add(...classes);
button.innerText = param.name;
button.addEventListener("click", () => param.value = !param.value);
param.subscribe(v => {
if (v) {
button.classList.add("on");
button.classList.remove("off");
} else {
button.classList.add("off");
button.classList.remove("on");
}
})
return button;
}
function label(text: string) {
const element = document.createElement("div");
element.classList.add("label");
element.innerText = text;
return element;
}
function machine(...contents: HTMLElement[]) {
const element = document.createElement("div");
element.classList.add("machine");
element.append(...contents);
return element
}
function controlGroup(label: HTMLElement, content: HTMLElement, ...classes: string[]) {
const element = document.createElement("div");
element.classList.add("control-group", ...classes);
element.append(label, content);
return element
}
function controls(...contents: HTMLElement[]) {
const element = document.createElement("div");
element.classList.add("controls");
element.append(...contents);
return element
}
function group(...contents: HTMLElement[]) {
const element = document.createElement("div");
element.classList.add("group");
element.append(...contents);
return element;
}
function PatternDisplay(patternParam: PatternParameter, stepParam: NumericParameter, colors: ColorScheme = defaultColors) {
const canvas = document.createElement("canvas");
canvas.classList.add("pattern");
function repaint() {
const pattern = patternParam.value;
const w = canvas.width = canvas.clientWidth;
const h = canvas.height = 200;
const vScale = h / 50;
const g = canvas.getContext("2d") as CanvasRenderingContext2D;
g.font = "10px Orbitron";
g.fillStyle = colors.bg;
g.fillRect(0, 0, w, h);
g.strokeStyle = colors.grid;
for (let i = 0; i < pattern.length; i++) {
const x = w * i / pattern.length;
g.beginPath();
g.moveTo(x, 0);
g.lineTo(x, h);
g.stroke();
}
for (let i = 0; i < 80; i++) {
const y = h - (i * vScale);
g.beginPath();
g.moveTo(0, y);
g.lineTo(w, y);
g.stroke();
}
for (let i = 0; i < pattern.length; i++) {
const s = pattern[i];
if (s.note === "-") {
} else {
const n = textNoteToNumber(s.note) - 24;
const x = w * i / pattern.length;
const y = h - (n * vScale);
const bw = w / pattern.length;
const bh = 5;
g.fillStyle = s.glide ? colors.glide : (s.accent ? colors.accent : colors.note);
g.fillRect(x, y, bw, bh);
g.fillStyle = colors.text;
const xt = (x + bw / 2) - g.measureText(s.note).width / 2;
g.fillText(s.note, xt, y);
}
}
g.fillStyle = colors.highlight;
g.fillRect(w * stepParam.value / pattern.length, 0, w / pattern.length, h);
}
patternParam.subscribe(repaint);
stepParam.subscribe(repaint);
return canvas;
}
function DrumDisplay(pattern: GeneralisedParameter<DrumPattern>, mutes: GeneralisedParameter<boolean>[], stepParam: NumericParameter, colors: ColorScheme = defaultColors) {
const canvas = document.createElement("canvas");
canvas.classList.add("pattern");
function repaint() {
const w = canvas.width = canvas.clientWidth;
const h = canvas.height = 100;
const g = canvas.getContext("2d") as CanvasRenderingContext2D;
g.fillStyle = colors.bg;
g.fillRect(0, 0, w, h);
for (let i = 0; i < 16; i++) {
const x = w * i / 16;
for (let p = 0; p < pattern.value.length; p++) {
const y = (p / pattern.value.length) * h;
if (pattern.value[p][i]) {
if (mutes[p].value) {
g.fillStyle = "rgba(128,0,0,0.4)";
} else {
g.fillStyle = "rgba(136,170,204," + pattern.value[p][i] + ")";
}
g.fillRect(x, y, w / 16, h / pattern.value.length);
}
}
}
g.fillStyle = colors.highlight;
g.fillRect(w * stepParam.value / 16, 0, w / 16, h);
}
pattern.subscribe(repaint);
stepParam.subscribe(repaint);
return canvas;
}
function NoteGen(noteGenerator: NoteGenerator) {
const currentNotes = document.createElement("div");
currentNotes.classList.add("parameter-controlled", "notegen-note-display");
noteGenerator.noteSet.subscribe(notes => {
currentNotes.innerText = notes.join(", ");
})
return controlGroup(
label("Notegen"),
group(
triggerButton(noteGenerator.newNotes),
currentNotes
),
"notegen-box"
)
}
function Mutes(params: GeneralisedParameter<boolean>[]) {
const container = document.createElement("div");
container.classList.add("mutes");
container.append(...params.map(p => toggleButton(p)));
return container;
}
function DelayControls(delayUnit: DelayUnit) {
const controls = DialSet([delayUnit.dryWet, delayUnit.feedback]);
controls.classList.add("horizontal");
return controlGroup(
label("Delay"),
controls,
)
}
function AutopilotControls(autoPilot: AutoPilotUnit) {
return controlGroup(
label("Autopilot"),
group(
...autoPilot.switches.map(p => toggleButton(p, "autopilot-button"))
)
)
}
function AudioMeter(analyser: AnalyserNode) {
const canvas = document.createElement("canvas");
canvas.style.width = "100%";
let w = canvas.width = 200;
const h = canvas.height = 100;
const g = canvas.getContext("2d") as CanvasRenderingContext2D;
const output = new Uint8Array(analyser.fftSize);
function draw() {
//w = canvas.width = canvas.clientWidth;
analyser.getByteTimeDomainData(output);
g.clearRect(0,0,w,h);
g.strokeStyle = "white";
g.beginPath();
g.moveTo(0,h/2);
for (let i =0 ; i < output.length; i++) {
const v = (output[i] / 128) - 1;
g.lineTo(w * i/output.length, h/2 + (1.5 * v* h/2));
}
g.stroke();
window.requestAnimationFrame(draw);
}
window.requestAnimationFrame(draw);
return canvas;
}
export function UI(state: ProgramState, autoPilot: AutoPilotUnit, analyser: AnalyserNode) {
const ui = document.createElement("div");
ui.id = "ui";
const otherControls = controls(
AutopilotControls(autoPilot),
NoteGen(state.gen),
DelayControls(state.delay),
controlGroup(label("Clock"), DialSet([state.clock.bpm], "horizontal")),
controlGroup(label("Meter"), group(AudioMeter(analyser)), "meter")
)
const machineContainer = document.createElement("div");
machineContainer.classList.add("machines");
const noteMachines = state.notes.map((n, i) => machine(
label("303-0" + (i+1)),
group(
triggerButton(n.newPattern),
PatternDisplay(n.pattern, state.clock.currentStep),
DialSet(n.parameters)
)
));
const drumMachine = machine(
label("909-XX"),
group(
triggerButton(state.drums.newPattern),
DrumDisplay(state.drums.pattern, state.drums.mutes, state.clock.currentStep),
Mutes(state.drums.mutes)
)
)
machineContainer.append(...noteMachines, drumMachine)
ui.append(machineContainer, otherControls);
return ui;
}

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"module": "es2015",
"target": "es6",
"lib": ["dom", "esnext"],
"sourceMap": true,
"outDir": "./js",
"strict": true
},
"compileOnSave": true,
"include": [
"src/**/*"
]
}

193
ui.css Normal file
View File

@@ -0,0 +1,193 @@
/*
Copyright 2021 David Whiting
This work is licensed under a Creative Commons Attribution 4.0 International License
https://creativecommons.org/licenses/by/4.0/
*/
@import url('https://fonts.googleapis.com/css2?family=Orbitron&display=swap');
body {
background-color: #111;
color:white;
font-family: Orbitron, monospace;
}
#ui {
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
p {
max-width: 1200px;
margin-left: auto;
margin-right:auto;
font-family: monospace;
}
a {
color:white;
}
@media (max-width: 500px) {
p {
font-size: 0.8em;
}
}
.machine,.control-group {
background-color: black;
display: grid;
grid-template-columns: 20px auto;
border: 1px solid #444444;
margin:5px;
}
.label {
writing-mode: vertical-rl;
transform: rotate(180deg);
text-align: center;
/* left because we're 180 rotated */
border-left: 1px solid #444444;
}
.machine .group {
display: grid;
grid-template-columns: 25px auto 70px;
}
.controls {
display: flex;
flex-wrap: wrap;
}
.control-group {
height: 100px;
}
.control-group .group {
display: flex;
flex-direction: column;
align-items: stretch;
}
.control-group .group button {
flex-grow: 1;
}
.pattern {
width:100%;
height: 200px;
/*min-height: 150px;*/
/*border-right:1px solid rgba(255,255,255,0.3);*/
}
button {
color:white;
background-color: #111;
border: 1px solid #444;
text-align: center;
cursor: pointer;
font-family: Orbitron, monospace
}
button:hover {
background-color: #222;
}
.dial {
display: block;
}
.mutes button {
height: 50px;
display: block;
}
.mutes .on {
background-color: #770000;
}
.trigger-button {
font-size:20px;
padding:0;
text-align: center;
}
.params.horizontal .dial {
display: inline-block;
}
.control-group .params {
height: 50px;
margin-top: auto;
margin-bottom: auto;
text-align: center;
}
.control-group {
flex-grow: 1;
}
.meter canvas {
object-fit: fill;
height: 100px;
width:100px;
}
@keyframes wait-animate {
0% { background-color: black }
50% { background-color: red }
100% { background-color: black }
}
button.waiting {
background-color: red;
animation: wait-animate 0.2s infinite;
}
.parameter-controlled {
background-color: #222266;
}
.notegen-note-display {
width: 200px;
height: 50px;
margin-left: auto;
margin-right: auto;
padding:5px;
}
.controls button {
width: 100%;
}
@keyframes autopilot-animate {
0% { background-color: #113311 }
50% { background-color: green }
100% { background-color: #113311 }
}
/*@keyframes autopilot-border-animate {*/
/* 0% { border: 1px solid #444 }*/
/* 50% { border: 1px solid #494 }*/
/* 100% { border: 1px solid #444 }*/
/*}*/
.autopilot-button.on {
animation: autopilot-animate 2s infinite;
}
h2 {
text-align: center;
font-size:1.5em;
}
/*@media (max-width: 640px) {*/
/* h2 {*/
/* font-size: 1.2em;*/
/* }*/
/*}*/