commit 2e862f62edeaa557ae5b10af555228725646ed3c Author: David Whiting Date: Tue Apr 20 10:50:16 2021 +0200 First public version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d98f9d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.* diff --git a/909BD.mp3 b/909BD.mp3 new file mode 100644 index 0000000..414f9a2 Binary files /dev/null and b/909BD.mp3 differ diff --git a/909CH.mp3 b/909CH.mp3 new file mode 100644 index 0000000..73db73c Binary files /dev/null and b/909CH.mp3 differ diff --git a/909OH.mp3 b/909OH.mp3 new file mode 100644 index 0000000..7d9ff0c Binary files /dev/null and b/909OH.mp3 differ diff --git a/909SD.mp3 b/909SD.mp3 new file mode 100644 index 0000000..584c5c1 Binary files /dev/null and b/909SD.mp3 differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cedece2 --- /dev/null +++ b/README.md @@ -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). \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..4500238 --- /dev/null +++ b/build.sh @@ -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 diff --git a/index.html b/index.html new file mode 100644 index 0000000..d0bfe22 --- /dev/null +++ b/index.html @@ -0,0 +1,34 @@ + + + + + + + + The Endless Acid Banger + + + + + + + + + + + +

The Endless Acid Banger

+

+ 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. +

+

+ The Endless Acid Banger was created by Vitling. + If you want to support my work, please consider + buying my music or + sponsoring my GitHub. +

+ + \ No newline at end of file diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..545a337 Binary files /dev/null and b/preview.png differ diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..34e6722 --- /dev/null +++ b/src/app.ts @@ -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", []); + 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 { + const drums = await audio.SamplerDrumMachine(["909BD.mp3","909OH.mp3","909CH.mp3","909SD.mp3"]) + const pattern = genericParameter("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"); diff --git a/src/audio.ts b/src/audio.ts new file mode 100644 index 0000000..dcbfa14 --- /dev/null +++ b/src/audio.ts @@ -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 = new Map(); +const revLook: Map = new Map(); +(()=>{ + 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 { + return new Promise((resolve, reject) => { + return au.decodeAudioData(audioData, resolve, reject); + }); + } + + const master = masterChannel(); + + function time(s: number) { + return new Promise(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 diff --git a/src/boilerplate.ts b/src/boilerplate.ts new file mode 100644 index 0000000..91d3ac5 --- /dev/null +++ b/src/boilerplate.ts @@ -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 + "

" + description + "

" + callToAction; + + document.head.insertAdjacentHTML("beforeend", ` + + `); + 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 + } +} \ No newline at end of file diff --git a/src/dial.ts b/src/dial.ts new file mode 100644 index 0000000..cc9b3bc --- /dev/null +++ b/src/dial.ts @@ -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 \ No newline at end of file diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 0000000..cc4b4a9 --- /dev/null +++ b/src/interface.ts @@ -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 = (v: T) => any; + +export type GeneralisedParameter = { + value: T, + name: string, + subscribe: (callback: ParameterCallback) => any +} + +export type Trigger = GeneralisedParameter + +export type NumericParameter = GeneralisedParameter & { + bounds: [number,number] +} + +export type PatternParameter = GeneralisedParameter; + +export type ThreeOhMachine = { + pattern: GeneralisedParameter, + newPattern: Trigger + step: (step: number) => void + parameters: { + cutoff: NumericParameter, + resonance: NumericParameter, + envMod: NumericParameter, + decay: NumericParameter + } +} + +export type NineOhMachine = { + pattern: GeneralisedParameter, + newPattern: Trigger, + mutes: GeneralisedParameter[], + step: (step: number) => void +} + +export type NoteGenerator = { + noteSet: GeneralisedParameter + 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[] +} + +export function genericParameter(name: string, value: T): GeneralisedParameter { + let listeners: ParameterCallback[] = []; + const state = {value}; + function subscribe(callback: ParameterCallback) { + 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(name, value), {bounds}); +} + +export type ProgramState = { + notes: ThreeOhMachine[], + drums: NineOhMachine, + gen: NoteGenerator, + delay: DelayUnit + clock: ClockUnit +} \ No newline at end of file diff --git a/src/math.ts b/src/math.ts new file mode 100644 index 0000000..0db07c3 --- /dev/null +++ b/src/math.ts @@ -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(array: T[]):T { + return array[rndInt(array.length)]; +} + diff --git a/src/pattern.ts b/src/pattern.ts new file mode 100644 index 0000000..a512013 --- /dev/null +++ b/src/pattern.ts @@ -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 = 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 + } +} \ No newline at end of file diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..5c3e7ec --- /dev/null +++ b/src/ui.ts @@ -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, ...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, mutes: GeneralisedParameter[], 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[]) { + 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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d88cf4e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "es2015", + "target": "es6", + "lib": ["dom", "esnext"], + "sourceMap": true, + "outDir": "./js", + "strict": true + }, + "compileOnSave": true, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/ui.css b/ui.css new file mode 100644 index 0000000..c2df6cf --- /dev/null +++ b/ui.css @@ -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;*/ +/* }*/ +/*}*/ + +