mirror of
https://github.com/vitling/acid-banger.git
synced 2025-08-30 08:20:45 +02:00
First public version
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.*
|
21
README.md
Normal file
21
README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# The Endless Acid Banger
|
||||
|
||||
An algorithmic human-computer techno jam
|
||||
|
||||

|
||||
|
||||
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
6
build.sh
Executable 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
34
index.html
Normal 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
BIN
preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 95 KiB |
256
src/app.ts
Normal file
256
src/app.ts
Normal 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
340
src/audio.ts
Normal 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
93
src/boilerplate.ts
Normal 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
122
src/dial.ts
Normal 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
111
src/interface.ts
Normal 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
18
src/math.ts
Normal 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
153
src/pattern.ts
Normal 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
335
src/ui.ts
Normal 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
14
tsconfig.json
Normal 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
193
ui.css
Normal 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;*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
|
||||
|
Reference in New Issue
Block a user