mirror of
https://github.com/eliasstepanik/strudel-docker.git
synced 2026-01-26 13:08:28 +00:00
commit
f8f806e8eb
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,3 +27,4 @@ node_modules/
|
|||||||
repl-parcel
|
repl-parcel
|
||||||
mytunes.ts
|
mytunes.ts
|
||||||
doc
|
doc
|
||||||
|
.parcel-cache
|
||||||
3572
package-lock.json
generated
3572
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
packages/webaudio/README.md
Normal file
29
packages/webaudio/README.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# @strudel.cycles/webaudio
|
||||||
|
|
||||||
|
This package contains a scheduler + a clockworker and synths based on the Web Audio API.
|
||||||
|
It's an alternative to `@strudel.cycles/tone`, with better performance, but less features.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i @strudel.cycles/webaudio --save
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Scheduler, getAudioContext } from '@strudel.cycles/webaudio';
|
||||||
|
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
audioContext: getAudioContext(),
|
||||||
|
interval: 0.1,
|
||||||
|
onEvent: (e) => e.context?.createAudioNode?.(e),
|
||||||
|
});
|
||||||
|
const pattern = sequence([55, 99], 110).osc('sawtooth').out()
|
||||||
|
scheduler.setPattern(pattern);
|
||||||
|
scheduler.start()
|
||||||
|
//scheduler.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
A more sophisticated example can be found in [examples/repl.html](./examples/repl.html).
|
||||||
|
You can run it inside this directory with `npm run example`.
|
||||||
69
packages/webaudio/clockworker.mjs
Normal file
69
packages/webaudio/clockworker.mjs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// 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 ClockWorker {
|
||||||
|
worker;
|
||||||
|
audioContext;
|
||||||
|
interval = 0.2; // query span
|
||||||
|
lastEnd = 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 = this.lastEnd || this.audioContext.currentTime;
|
||||||
|
const end = this.audioContext.currentTime + this.interval; // DONT reference begin here!
|
||||||
|
this.lastEnd = end;
|
||||||
|
// callback with query span, using clock #2 (the audio clock)
|
||||||
|
callback(begin, end);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
start() {
|
||||||
|
console.log('start...');
|
||||||
|
this.audioContext.resume();
|
||||||
|
this.worker.postMessage('start');
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
console.log('stop...');
|
||||||
|
this.worker.postMessage('stop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClockWorker;
|
||||||
61
packages/webaudio/examples/repl.html
Normal file
61
packages/webaudio/examples/repl.html
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<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 '@strudel.cycles/core';
|
||||||
|
import '@strudel.cycles/core/euclid.mjs';
|
||||||
|
import { Scheduler, getAudioContext } from '@strudel.cycles/webaudio';
|
||||||
|
|
||||||
|
const { cat, State, TimeSpan } = strudel;
|
||||||
|
Object.assign(window, strudel); // add strudel to eval scope
|
||||||
|
|
||||||
|
const scheduler = new Scheduler({
|
||||||
|
audioContext: getAudioContext(),
|
||||||
|
interval: 0.1,
|
||||||
|
onEvent: (e) => {
|
||||||
|
e.context?.createAudioNode?.(e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let initialCode = `sequence(1,2).mul(55/2) // frequencies
|
||||||
|
.mul(slowcat(1,2))
|
||||||
|
.mul(slowcat(1,3/2,4/3,5/3).slow(8))
|
||||||
|
.fast(3)
|
||||||
|
.velocity(.5)
|
||||||
|
.osc(cat('sawtooth','square').fast(2))
|
||||||
|
.adsr(0.01,.02,.5,0.1)
|
||||||
|
.filter('lowshelf',800,25)
|
||||||
|
.out()`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64 = decodeURIComponent(window.location.href.split('#')[1]);
|
||||||
|
initialCode = atob(base64);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('failed to decode', err);
|
||||||
|
}
|
||||||
|
const input = document.getElementById('text');
|
||||||
|
input.value = initialCode;
|
||||||
|
const evaluate = () => {
|
||||||
|
try {
|
||||||
|
const pattern = eval(input.value);
|
||||||
|
scheduler.setPattern(pattern);
|
||||||
|
window.location.hash = '#' + encodeURIComponent(btoa(input.value)); // update url hash
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
evaluate();
|
||||||
|
input.addEventListener('input', () => evaluate());
|
||||||
|
|
||||||
|
document.getElementById('start').addEventListener('click', () => scheduler.start());
|
||||||
|
document.getElementById('stop').addEventListener('click', () => scheduler.stop());
|
||||||
|
</script>
|
||||||
3
packages/webaudio/index.mjs
Normal file
3
packages/webaudio/index.mjs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default as ClockWorker } from './clockworker.mjs';
|
||||||
|
export { default as Scheduler } from './scheduler.mjs';
|
||||||
|
export * from './webaudio.mjs';
|
||||||
3851
packages/webaudio/package-lock.json
generated
Normal file
3851
packages/webaudio/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
packages/webaudio/package.json
Normal file
32
packages/webaudio/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@strudel.cycles/webaudio",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Web Audio helpers for Strudel",
|
||||||
|
"main": "index.mjs",
|
||||||
|
"directories": {
|
||||||
|
"example": "examples"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"example": "parcel examples/repl.html"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/tidalcycles/strudel.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"tidalcycles",
|
||||||
|
"strudel",
|
||||||
|
"pattern",
|
||||||
|
"livecoding",
|
||||||
|
"algorave"
|
||||||
|
],
|
||||||
|
"author": "Felix Roos <flix91@gmail.com>",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/tidalcycles/strudel/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/tidalcycles/strudel#readme",
|
||||||
|
"devDependencies": {
|
||||||
|
"parcel": "^2.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
packages/webaudio/scheduler.mjs
Normal file
38
packages/webaudio/scheduler.mjs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import ClockWorker from './clockworker.mjs';
|
||||||
|
|
||||||
|
class Scheduler {
|
||||||
|
worker;
|
||||||
|
pattern;
|
||||||
|
constructor({ audioContext, interval = 0.2, onEvent }) {
|
||||||
|
this.worker = new ClockWorker(
|
||||||
|
audioContext,
|
||||||
|
(begin, end) => {
|
||||||
|
this.pattern.query(new State(new TimeSpan(begin, end))).forEach((e) => {
|
||||||
|
if (!e.part.begin.equals(e.whole.begin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onEvent) {
|
||||||
|
onEvent?.(e);
|
||||||
|
} else {
|
||||||
|
console.warn('unplayable event: no audio node nor onEvent callback', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
interval,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
start() {
|
||||||
|
if (!this.pattern) {
|
||||||
|
throw new Error('Scheduler: no pattern set! call .setPattern first.');
|
||||||
|
}
|
||||||
|
this.worker.start();
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this.worker.stop();
|
||||||
|
}
|
||||||
|
setPattern(pat) {
|
||||||
|
this.pattern = pat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Scheduler;
|
||||||
74
packages/webaudio/webaudio.mjs
Normal file
74
packages/webaudio/webaudio.mjs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Pattern } from '@strudel.cycles/core';
|
||||||
|
|
||||||
|
let audioContext;
|
||||||
|
export const getAudioContext = () => {
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new AudioContext();
|
||||||
|
}
|
||||||
|
return 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 });
|
||||||
Loading…
x
Reference in New Issue
Block a user