mirror of
https://github.com/vitling/acid-banger.git
synced 2025-08-30 16:30:19 +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