mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-22 05:05:26 -04:00
240 lines
7.9 KiB
HTML
240 lines
7.9 KiB
HTML
<div style="position: absolute; top: 0; right: 0; padding: 4px">
|
|
<button id="start" style="margin-bottom: 4px; font-size: 2em">start</button><br />
|
|
<button id="stop" style="font-size: 2em">stop</button>
|
|
</div>
|
|
<textarea
|
|
style="font-size: 2em; background: #e8d565; color: #323230; height: 100%; width: 100%; outline: none; border: 0"
|
|
id="text"
|
|
spellcheck="false"
|
|
>
|
|
Loading...</textarea
|
|
>
|
|
<script type="module">
|
|
document.body.style = 'margin: 0';
|
|
import * as strudel from 'https://cdn.skypack.dev/@strudel.cycles/core@0.0.2';
|
|
import 'https://cdn.skypack.dev/@strudel.cycles/core@0.0.2/euclid.mjs';
|
|
const { cat, State, TimeSpan } = strudel;
|
|
let pattern;
|
|
Object.assign(window, strudel); // add strudel to eval scope
|
|
const input = document.getElementById('text');
|
|
|
|
let initialCode = `sequence(880, [440, 660], 440, 660)
|
|
.div(slowcat(3,2).slow(2))
|
|
.off(1/8,mul(2))
|
|
.off(1/4,mul(3))
|
|
.legato(2)
|
|
.slow(2)`;
|
|
try {
|
|
initialCode = atob(decodeURIComponent(window.location.href.split('#')[1]));
|
|
} catch (err) {
|
|
console.warn('failed to decode', err);
|
|
}
|
|
input.value = initialCode;
|
|
|
|
const evaluate = () => {
|
|
try {
|
|
pattern = eval(input.value);
|
|
window.location.hash = '#' + encodeURIComponent(btoa(input.value)); // update url hash
|
|
} catch (err) {
|
|
console.warn(err);
|
|
}
|
|
};
|
|
input.addEventListener('input', () => evaluate());
|
|
|
|
// helpers to create a worker dynamically without needing a server / extra file
|
|
const stringifyFunction = (func) => '(' + func + ')();';
|
|
const urlifyFunction = (func) =>
|
|
URL.createObjectURL(new Blob([stringifyFunction(func)], { type: 'text/javascript' }));
|
|
const createWorker = (func) => new Worker(urlifyFunction(func));
|
|
|
|
// this class is basically the tale of two clocks
|
|
class Metro {
|
|
worker;
|
|
audioContext;
|
|
startedAt = 0;
|
|
interval = 0.2; // query span
|
|
tick = 0;
|
|
constructor(audioContext, callback, interval = this.interval) {
|
|
this.audioContext = audioContext;
|
|
this.interval = interval;
|
|
this.worker = createWorker(() => {
|
|
// we cannot use closures here!
|
|
let interval;
|
|
let timerID = null; // this is clock #1 (the sloppy js clock)
|
|
const clear = () => {
|
|
if (timerID) {
|
|
clearInterval(timerID);
|
|
timerID = null;
|
|
}
|
|
};
|
|
const start = () => {
|
|
clear();
|
|
if (!interval) {
|
|
throw new Error('no interval set! call worker.postMessage({interval}) before starting.');
|
|
}
|
|
timerID = setInterval(() => postMessage('tick'), interval * 1000);
|
|
};
|
|
self.onmessage = function (e) {
|
|
if (e.data == 'start') {
|
|
start();
|
|
} else if (e.data.interval) {
|
|
interval = e.data.interval;
|
|
if (timerID) {
|
|
start();
|
|
}
|
|
} else if (e.data == 'stop') {
|
|
clear();
|
|
}
|
|
};
|
|
});
|
|
this.worker.postMessage({ interval });
|
|
const round = (n, d) => Math.round(n * d) / d;
|
|
const precision = 100;
|
|
this.worker.onmessage = (e) => {
|
|
if (e.data === 'tick') {
|
|
const begin = round(this.tick * this.interval, precision) + this.startedAt; // begin of tick
|
|
const end = round(begin + this.interval, precision); // end of tick
|
|
this.tick++;
|
|
// callback with query span, using clock #2 (the audio clock)
|
|
callback(begin, end);
|
|
}
|
|
};
|
|
}
|
|
start() {
|
|
console.log('start...');
|
|
this.audioContext.resume();
|
|
this.tick = 0;
|
|
this.startedAt = this.audioContext.currentTime;
|
|
this.worker.postMessage('start');
|
|
}
|
|
stop() {
|
|
console.log('stop...');
|
|
this.worker.postMessage('stop');
|
|
}
|
|
}
|
|
|
|
const audioContext = new AudioContext();
|
|
const interval = 0.2;
|
|
const lookahead = 0.5;
|
|
const metro = new Metro(
|
|
audioContext,
|
|
(begin, end) => {
|
|
pattern.query(new State(new TimeSpan(begin, end))).forEach((e) => {
|
|
if (!e.part.begin.equals(e.whole.begin)) {
|
|
return;
|
|
}
|
|
if (e.context.createAudioNode) {
|
|
e.context.createAudioNode(e);
|
|
} else {
|
|
// fallback sine wave
|
|
const attack = 0.01;
|
|
const decay = 0.05;
|
|
const sustain = 0.5;
|
|
const release = 0.01;
|
|
const velocity = (e.context?.velocity || 1) * 0.1;
|
|
const osc = audioContext.createOscillator();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = e.value;
|
|
osc.start(e.whole.begin);
|
|
const envelope = adsr(attack, decay, sustain, release, velocity, e.whole.begin, e.whole.end);
|
|
osc.stop(e.whole.end + release);
|
|
osc.connect(envelope);
|
|
envelope.connect(audioContext.destination);
|
|
}
|
|
});
|
|
},
|
|
interval,
|
|
lookahead,
|
|
);
|
|
|
|
const adsr = (attack, decay, sustain, release, velocity, begin, end) => {
|
|
const gainNode = audioContext.createGain();
|
|
gainNode.gain.setValueAtTime(0, begin);
|
|
gainNode.gain.linearRampToValueAtTime(velocity, begin + attack); // attack
|
|
gainNode.gain.linearRampToValueAtTime(sustain * velocity, begin + attack + decay); // sustain start
|
|
gainNode.gain.setValueAtTime(sustain * velocity, end); // sustain end
|
|
gainNode.gain.linearRampToValueAtTime(0, end + release); // release
|
|
// for some reason, using exponential ramping creates little cracklings
|
|
return gainNode;
|
|
};
|
|
|
|
strudel.Pattern.prototype.withAudioNode = function (createAudioNode) {
|
|
return this._withEvent((event) => {
|
|
return event.setContext({
|
|
...event.context,
|
|
createAudioNode: (e) => createAudioNode(e, event.context.createAudioNode?.(event)),
|
|
});
|
|
});
|
|
};
|
|
|
|
strudel.Pattern.prototype._osc = function (type) {
|
|
return this.withAudioNode((e) => {
|
|
const osc = audioContext.createOscillator();
|
|
osc.type = type;
|
|
osc.frequency.value = e.value; // expects frequency..
|
|
osc.start(e.whole.begin.valueOf() + lookahead);
|
|
osc.stop(e.whole.end.valueOf() + lookahead); // release?
|
|
// osc.connect(audioContext.destination);
|
|
return osc;
|
|
});
|
|
};
|
|
strudel.Pattern.prototype.define('osc', (type, pat) => pat.osc(type), { patternified: true });
|
|
strudel.Pattern.prototype.adsr = function (a = 0.01, d = 0.05, s = 1, r = 0.01) {
|
|
return this.withAudioNode((e, node) => {
|
|
const velocity = e.context?.velocity || 1;
|
|
const envelope = adsr(
|
|
a,
|
|
d,
|
|
s,
|
|
r,
|
|
velocity,
|
|
e.whole.begin.valueOf() + lookahead,
|
|
e.whole.end.valueOf() + lookahead,
|
|
);
|
|
node?.connect(envelope);
|
|
return envelope;
|
|
});
|
|
};
|
|
strudel.Pattern.prototype.filter = function (type = 'lowshelf', frequency = 1000, gain = 25) {
|
|
return this.withAudioNode((e, node) => {
|
|
const filter = audioContext.createBiquadFilter();
|
|
filter.type = type;
|
|
filter.frequency.value = frequency;
|
|
filter.gain.value = gain;
|
|
node?.connect(filter);
|
|
return filter;
|
|
});
|
|
};
|
|
|
|
strudel.Pattern.prototype.out = function () {
|
|
const master = audioContext.createGain();
|
|
master.gain.value = 0.1;
|
|
master.connect(audioContext.destination);
|
|
return this.withAudioNode((e, node) => {
|
|
if (!node) {
|
|
console.warn('out: no source! call .osc() first');
|
|
}
|
|
node?.connect(master);
|
|
});
|
|
};
|
|
|
|
document.getElementById('start').addEventListener('click', () => metro.start());
|
|
document.getElementById('stop').addEventListener('click', () => metro.stop());
|
|
evaluate(); // evaluate initial code
|
|
/*
|
|
sequence(1,3/2,1,2).stack(
|
|
stack(5,3,4).velocity(.8).slow(2).legato(.2)
|
|
//.echo(3, 1/4, .5)
|
|
).mul(110) // frequencies
|
|
.div(slowcat(slowcat(2,5/4, 3/2),slowcat(3,4/3)).slow(2))
|
|
.echoWith(
|
|
16, // n of partials / cutoff
|
|
tri2.slow(128).div(2), // time between partials
|
|
(x,n)=>x // n = partial index 0..n
|
|
.mul((n+1)*3/2)
|
|
.legato(2)
|
|
.velocity(1/((n+1)**1.6)) // amplitude falloff
|
|
).legato(2) // note length
|
|
*/
|
|
</script>
|