From 47a046ca1d10cc889a7008864789a4f4674ac929 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 1 Apr 2022 15:44:17 +0200 Subject: [PATCH] begin webaudio package --- packages/webaudio/metro.mjs | 70 +++++++++++++++++++++++++++++++++ packages/webaudio/scheduler.mjs | 28 +++++++++++++ packages/webaudio/webaudio.mjs | 69 ++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 packages/webaudio/metro.mjs create mode 100644 packages/webaudio/scheduler.mjs create mode 100644 packages/webaudio/webaudio.mjs diff --git a/packages/webaudio/metro.mjs b/packages/webaudio/metro.mjs new file mode 100644 index 00000000..0f3c0798 --- /dev/null +++ b/packages/webaudio/metro.mjs @@ -0,0 +1,70 @@ +// 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; + lastEnd; + lookahead = 0.2; // query offset + interval = 0.2; // query span + constructor(audioContext, callback, interval = this.interval, lookahead = this.lookahead) { + this.audioContext = audioContext; + this.interval = interval; + this.lookahead = lookahead; + 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 }); + this.worker.onmessage = (e) => { + if (e.data === 'tick') { + const begin = this.lastEnd || this.startedAt + this.lookahead; + const end = begin + this.interval; + this.lastEnd = end; + // callback with query span, using clock #2 (the audio clock) + callback(begin, end, this.lookahead); + } + }; + } + start() { + this.audioContext.resume(); + delete this.lastEnd; + this.startedAt = this.audioContext.currentTime; + this.worker.postMessage('start'); + } + stop() { + this.worker.postMessage('stop'); + } +} + +export default Metro; diff --git a/packages/webaudio/scheduler.mjs b/packages/webaudio/scheduler.mjs new file mode 100644 index 00000000..2cdb1aec --- /dev/null +++ b/packages/webaudio/scheduler.mjs @@ -0,0 +1,28 @@ +class Scheduler { + metro; + constructor(interval = 0.2, lookahead = 0.2) { + this.metro = new Metro( + audioContext, + (begin, end, lookahead) => { + pattern.query(new State(new TimeSpan(begin - lookahead, end - lookahead))).forEach((e) => { + if (!e.part.begin.equals(e.whole.begin)) { + return; + } + if (e.context.createAudioNode) { + e.context.createAudioNode(e); + } else { + console.warn('unplayable event: no audio node'); + } + }); + }, + interval, + lookahead, + ); + } + start() { + this.metro.start(); + } + stop() { + this.metro.stop(); + } +} diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs new file mode 100644 index 00000000..273a45f8 --- /dev/null +++ b/packages/webaudio/webaudio.mjs @@ -0,0 +1,69 @@ +import { Pattern } from '@strudel.cycles/core'; + +let audioContext; +export const getAudioContext = () => audioContext || new AudioContext(); + +const lookahead = 0.2; + +const adsr = (attack, decay, sustain, release, velocity, begin, end) => { + const gainNode = getAudioContext().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; +}; + +Pattern.prototype.withAudioNode = function (createAudioNode) { + return this._withEvent((event) => { + return event.setContext({ + ...event.context, + createAudioNode: (e) => createAudioNode(e, event.context.createAudioNode?.(event)), + }); + }); +}; + +Pattern.prototype._osc = function (type) { + return this.withAudioNode((e) => { + const osc = getAudioContext().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? + return osc; + }); +}; +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; + }); +}; +Pattern.prototype.filter = function (type = 'lowshelf', frequency = 1000, gain = 25) { + return this.withAudioNode((e, node) => { + const filter = getAudioContext().createBiquadFilter(); + filter.type = type; + filter.frequency.value = frequency; + filter.gain.value = gain; + node?.connect(filter); + return filter; + }); +}; + +Pattern.prototype.out = function () { + const master = getAudioContext().createGain(); + master.gain.value = 0.1; + master.connect(getAudioContext().destination); + return this.withAudioNode((e, node) => { + if (!node) { + console.warn('out: no source! call .osc() first'); + } + node?.connect(master); + }); +}; + +Pattern.prototype.define('osc', (type, pat) => pat.osc(type), { patternified: true }); \ No newline at end of file