Merge pull request #77 from tidalcycles/webaudio-compat

Webaudio in REPL
This commit is contained in:
Felix Roos 2022-04-20 20:17:26 +02:00 committed by GitHub
commit 1cdff32b5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 140 additions and 229 deletions

View File

@ -1,5 +1,6 @@
import { strict as assert } from 'assert';
import { isNote, tokenizeNote, toMidi, mod, compose } from '../util.mjs';
import { pure } from '../pattern.mjs';
import { isNote, tokenizeNote, toMidi, fromMidi, mod, compose, getFrequency } from '../util.mjs';
describe('isNote', () => {
it('should recognize notes without accidentals', () => {
@ -64,6 +65,21 @@ describe('toMidi', () => {
assert.equal(toMidi('C##3'), 50);
});
});
describe('fromMidi', () => {
it('should turn midi into frequency', () => {
assert.equal(fromMidi(69), 440);
assert.equal(fromMidi(57), 220);
});
});
describe('getFrequency', () => {
it('should turn midi into frequency', () => {
const happify = (val, context = {}) => pure(val).firstCycle()[0].setContext(context);
assert.equal(getFrequency(happify('a4')), 440);
assert.equal(getFrequency(happify('a3')), 220);
assert.equal(getFrequency(happify(440, { type: 'frequency' })), 440); // TODO: migrate when values are objects..
assert.equal(getFrequency(happify(432, { type: 'frequency' })), 432);
});
});
describe('mod', () => {
it('should work like regular modulo with positive numbers', () => {

View File

@ -40,6 +40,22 @@ export const getPlayableNoteValue = (event) => {
return note;
};
export const getFrequency = (event) => {
let { value, context } = event;
// if value is number => interpret as midi number as long as its not marked as frequency
if (typeof value === 'object' && value.freq) {
return value.freq;
}
if (typeof value === 'number' && context.type !== 'frequency') {
value = fromMidi(event.value);
} else if (typeof value === 'string' && isNote(value)) {
value = fromMidi(toMidi(event.value));
} else if (typeof value !== 'number') {
throw new Error('not a note or frequency:' + value);
}
return value;
};
// rotate array by n steps (to the left)
export const rotate = (arr, n) => arr.slice(n).concat(arr.slice(0, n));

View File

@ -117,157 +117,3 @@ export const highpass = (v) => new Filter(v, 'highpass');
export const adsr = (a, d = 0.1, s = 0.4, r = 0.01) => ({ envelope: { attack: a, decay: d, sustain: s, release: r } });
export const osc = (type) => ({ oscillator: { type } });
export const out = () => getDestination();
/*
You are entering experimental zone
*/
// the following code is an attempt to minimize tonejs code.. it is still an experiment
const chainable = function (instr) {
const _chain = instr.chain.bind(instr);
let chained = [];
instr.chain = (...args) => {
chained = chained.concat(args);
instr.disconnect(); // disconnect from destination / previous chain
return _chain(...chained, getDestination());
};
// shortcuts: chaining multiple won't work forn now.. like filter(1000).gain(0.5). use chain + native Tone calls instead
// instr.filter = (freq = 1000, type: BiquadFilterType = 'lowpass') =>
instr.filter = (freq = 1000, type = 'lowpass') =>
instr.chain(
new Filter(freq, type), // .Q.setValueAtTime(q, time);
);
instr.gain = (gain = 0.9) => instr.chain(new Gain(gain));
return instr;
};
// helpers
export const poly = (type) => {
const s = new PolySynth(Synth, { oscillator: { type } }).toDestination();
return chainable(s);
};
Pattern.prototype._poly = function (type = 'triangle') {
const instrumentConfig = {
oscillator: { type },
envelope: { attack: 0.01, decay: 0.01, sustain: 0.6, release: 0.01 },
};
if (!this.instrument) {
// create only once to keep the js heap happy
// this.instrument = new PolySynth(Synth, instrumentConfig).toDestination();
this.instrument = poly(type);
}
return this._withEvent((event) => {
const onTrigger = (time, event) => {
this.instrument.set(instrumentConfig);
this.instrument.triggerAttackRelease(event.value, event.duration, time);
};
return event.setContext({ ...event.context, instrumentConfig, onTrigger });
});
};
Pattern.prototype.define('poly', (type, pat) => pat.poly(type), { composable: true, patternified: true });
/*
You are entering danger zone
*/
// everything below is nice in theory, but not healthy for the JS heap, as nodes get recreated on every call
const getTrigger = (getChain, value) => (time, event) => {
const chain = getChain(); // make sure this returns a node that is connected toDestination // time
if (!isNote(value)) {
throw new Error('not a note: ' + value);
}
chain.triggerAttackRelease(value, event.duration, time);
setTimeout(() => {
// setTimeout is a little bit better compared to Transport.scheduleOnce
chain.dispose(); // mark for garbage collection
}, event.duration * 2000);
};
Pattern.prototype._synth = function (type = 'triangle') {
return this._withEvent((event) => {
const instrumentConfig = {
oscillator: { type },
envelope: { attack: 0.01, decay: 0.01, sustain: 0.6, release: 0.01 },
};
const getInstrument = () => {
const instrument = new Synth();
instrument.set(instrumentConfig);
return instrument;
};
const onTrigger = getTrigger(() => getInstrument().toDestination(), event.value);
return event.setContext({ ...event.context, getInstrument, instrumentConfig, onTrigger });
});
};
Pattern.prototype.adsr = function (attack = 0.01, decay = 0.01, sustain = 0.6, release = 0.01) {
return this._withEvent((event) => {
if (!event.context.getInstrument) {
throw new Error('cannot chain adsr: need instrument first (like synth)');
}
const instrumentConfig = { ...event.context.instrumentConfig, envelope: { attack, decay, sustain, release } };
const getInstrument = () => {
const instrument = event.context.getInstrument();
instrument.set(instrumentConfig);
return instrument;
};
const onTrigger = getTrigger(() => getInstrument().toDestination(), event.value);
return event.setContext({ ...event.context, getInstrument, instrumentConfig, onTrigger });
});
};
Pattern.prototype.chain = function (...effectGetters) {
return this._withEvent((event) => {
if (!event.context?.getInstrument) {
throw new Error('cannot chain: need instrument first (like synth)');
}
const chain = (event.context.chain || []).concat(effectGetters);
const getChain = () => {
const effects = chain.map((getEffect) => getEffect());
return event.context.getInstrument().chain(...effects, getDestination());
};
const onTrigger = getTrigger(getChain, event.value);
return event.setContext({ ...event.context, getChain, onTrigger, chain });
});
};
export const autofilter =
(freq = 1) =>
() =>
new AutoFilter(freq).start();
export const filter =
// (freq = 1, q = 1, type: BiquadFilterType = 'lowpass') =>
(freq = 1, q = 1, type = 'lowpass') =>
() =>
new Filter(freq, type); // .Q.setValueAtTime(q, time);
export const gain =
(gain = 0.9) =>
() =>
new Gain(gain);
Pattern.prototype._gain = function (g) {
return this.chain(gain(g));
};
// Pattern.prototype._filter = function (freq: number, q: number, type: BiquadFilterType = 'lowpass') {
Pattern.prototype._filter = function (freq, q, type = 'lowpass') {
return this.chain(filter(freq, q, type));
};
Pattern.prototype._autofilter = function (g) {
return this.chain(autofilter(g));
};
Pattern.prototype.define('synth', (type, pat) => pat.synth(type), { composable: true, patternified: true });
Pattern.prototype.define('gain', (gain, pat) => pat.synth(gain), { composable: true, patternified: true });
Pattern.prototype.define('filter', (cutoff, pat) => pat.filter(cutoff), { composable: true, patternified: true });
Pattern.prototype.define('autofilter', (cutoff, pat) => pat.filter(cutoff), { composable: true, patternified: true });

View File

@ -11,9 +11,12 @@ Loading...</textarea
>
<script type="module">
document.body.style = 'margin: 0';
import * as strudel from 'https://cdn.skypack.dev/@strudel.cycles/core';
import 'https://cdn.skypack.dev/@strudel.cycles/core/euclid.mjs';
import { Scheduler, getAudioContext } from 'https://cdn.skypack.dev/@strudel.cycles/webaudio@0.0.4';
// import * as strudel from '@strudel.cycles/core';
import * as strudel from '../../core/index.mjs';
import * as util from '../../core/util.mjs';
import '@strudel.cycles/core/euclid.mjs';
// import { Scheduler, getAudioContext } from 'https://cdn.skypack.dev/@strudel.cycles/webaudio@0.0.4';
import { Scheduler, getAudioContext } from '../index.mjs';
const { cat, State, TimeSpan } = strudel;
Object.assign(window, strudel); // add strudel to eval scope
@ -31,7 +34,7 @@ Loading...</textarea
.mul(slowcat(1,3/2,4/3,5/3).slow(8))
.fast(3)
.velocity(.5)
.osc(cat('sawtooth','square').fast(2))
.wave(cat('sawtooth','square').fast(2))
.adsr(0.01,.02,.5,0.1)
.filter('lowshelf',800,25)
.out()`;

View File

@ -6,6 +6,9 @@
"directories": {
"example": "examples"
},
"scripts": {
"example": "npx parcel examples/repl.html"
},
"repository": {
"type": "git",
"url": "git+https://github.com/tidalcycles/strudel.git"

View File

@ -1,11 +1,13 @@
import { Pattern } from '@strudel.cycles/core';
import { Pattern, getFrequency, patternify2 } from '@strudel.cycles/core';
import { Tone } from '@strudel.cycles/tone';
let audioContext;
// let audioContext;
export const getAudioContext = () => {
if (!audioContext) {
return Tone.getContext().rawContext;
/* if (!audioContext) {
audioContext = new AudioContext();
}
return audioContext;
return audioContext; */
};
const lookahead = 0.2;
@ -25,50 +27,61 @@ Pattern.prototype.withAudioNode = function (createAudioNode) {
return this._withEvent((event) => {
return event.setContext({
...event.context,
createAudioNode: (e) => createAudioNode(e, event.context.createAudioNode?.(event)),
createAudioNode: (t, e) => createAudioNode(t, e, event.context.createAudioNode?.(t, event)),
});
});
};
Pattern.prototype._osc = function (type) {
return this.withAudioNode((e) => {
Pattern.prototype._wave = function (type) {
return this.withAudioNode((t, 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?
const f = getFrequency(e);
osc.frequency.value = f; // expects frequency..
const begin = t ?? e.whole.begin.valueOf() + lookahead;
const end = begin + e.duration;
osc.start(begin);
osc.stop(end); // release?
return osc;
});
};
Pattern.prototype.adsr = function (a = 0.01, d = 0.05, s = 1, r = 0.01) {
return this.withAudioNode((e, node) => {
return this.withAudioNode((t, 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);
const begin = t ?? e.whole.begin.valueOf() + lookahead;
const end = begin + e.duration + lookahead;
const envelope = adsr(a, d, s, r, velocity, begin, end);
node?.connect(envelope);
return envelope;
});
};
Pattern.prototype.filter = function (type = 'lowshelf', frequency = 1000, gain = 25) {
return this.withAudioNode((e, node) => {
Pattern.prototype._filter = function (type = 'lowpass', frequency = 1000) {
return this.withAudioNode((t, e, node) => {
const filter = getAudioContext().createBiquadFilter();
filter.type = type;
filter.frequency.value = frequency;
filter.gain.value = gain;
node?.connect(filter);
return filter;
});
};
Pattern.prototype.filter = function (type, frequency) {
return patternify2(Pattern.prototype._filter)(reify(type), reify(frequency), this);
};
Pattern.prototype.out = function () {
const master = getAudioContext().createGain();
master.gain.value = 0.1;
master.connect(getAudioContext().destination);
return this.withAudioNode((e, node) => {
return this.withAudioNode((t, e, node) => {
if (!node) {
console.warn('out: no source! call .osc() first');
}
node?.connect(master);
})._withEvent((event) => {
const onTrigger = (time, e) => e.context?.createAudioNode?.(time, e);
return event.setContext({ ...event.context, onTrigger });
});
};
Pattern.prototype.define('osc', (type, pat) => pat.osc(type), { patternified: true });
Pattern.prototype.define('wave', (type, pat) => pat.wave(type), { patternified: true });

View File

@ -30,6 +30,7 @@ import '@strudel.cycles/core/speak.mjs';
import '@strudel.cycles/tone/pianoroll.mjs';
import '@strudel.cycles/tone/draw.mjs';
import '@strudel.cycles/osc/osc.mjs';
import '@strudel.cycles/webaudio/webaudio.mjs';
import '@strudel.cycles/serial/serial.mjs';
import controls from '@strudel.cycles/core/controls.mjs';

View File

@ -742,3 +742,69 @@ bell = bell.chain(vol(0.6).connect(delay),out());
.stack("<D2 A2 G2 F2>".euclidLegato(6,8,1).tone(bass.toDestination()))
.slow(6)
.pianoroll({minMidi:20,maxMidi:120,background:'transparent'})`;
export const waa = `"a4 [a3 c3] a3 c3"
.sub("<7 12>/2")
.off(1/8, add("12"))
.off(1/4, add("7"))
.legato(.5)
.slow(2)
.wave("sawtooth square")
.filter('lowpass', "<2000 1000 500>")
.out()`;
export const waar = `"a4 [a3 c3] a3 c3".color('#F9D649')
.sub("<7 12 5 12>".slow(2))
.off(1/4,x=>x.add(7).color("#FFFFFF #0C3AA1 #C63928"))
.off(1/8,x=>x.add(12).color('#215CB6'))
.slow(2)
.legato(sine.range(0.3, 2).slow(28))
.wave("sawtooth square".fast(2))
.filter('lowpass', cosine.range(500,4000).slow(16))
.out()
.pianoroll({minMidi:20,maxMidi:120,background:'#202124'})`;
export const hyperpop = `const lfo = cosine.slow(15);
const lfo2 = sine.slow(16);
const filter1 = x=>x.filter('lowpass', lfo2.range(300,3000));
const filter2 = x=>x.filter('highpass', lfo.range(1000,6000)).filter('lowpass',4000)
const scales = slowcat('D3 major', 'G3 major').slow(8)
const drums = await players({
bd: '344/344757_1676145-lq.mp3',
sn: '387/387186_7255534-lq.mp3',
hh: '561/561241_12517458-lq.mp3',
hh2:'44/44944_236326-lq.mp3',
hh3: '44/44944_236326-lq.mp3',
}, 'https://freesound.org/data/previews/')
stack(
"-7 0 -7 7".struct("x(5,8,2)").fast(2).sub(7)
.scale(scales).wave("sawtooth,square").velocity(.3).adsr(0.01,0.1,.5,0)
.apply(filter1),
"~@3 [<2 3>,<4 5>]"
.echo(8,1/16,.7)
.scale(scales)
.wave('square').velocity(.7).adsr(0.01,0.1,0).apply(filter1),
"6 5 4".add(14)
.superimpose(sub("5"))
.fast(1).euclidLegato(3,8)
.mask("<1 0@7>")
.fast(2)
.echo(32, 1/8, .9)
.scale(scales)
.wave("sawtooth")
.velocity(.2)
.adsr(.01,.5,0)
.apply(filter2)
//.echo(4,1/16,.5)
).out().stack(
stack(
"bd <~@7 [~ bd]>".fast(2),
"~ sn",
"[~ hh3]*2"
).tone(drums.chain(vol(.18),out())).fast(2)
).slow(2)
//.pianoroll({minMidi:20, maxMidi:160})
// strudel disable-highlighting`;

View File

@ -590,59 +590,6 @@ Helper to set the envelope of a Tone.js instrument. Intended to be used with Ton
.tone(synth(adsr(0,.1,0,0)).chain(out()))`}
/>
### Experimental: Patternification
While the above methods work for static sounds, there is also the option to patternify tone methods.
This is currently experimental, because the performance is not stable, and audio glitches will appear after some time.
It would be great to get this to work without glitches though, because it is fun!
#### synth(type)
With .synth, you can create a synth with a variable wave type:
<MiniRepl
tune={`"[c4 c4 bb3 c4] [~ g3 ~ g3] [c4 f4 e4 c4] ~"
.synth("<sawtooth8 square8>").slow(4)`}
/>
#### adsr(attack, decay?, sustain?, release?)
Chainable Envelope helper:
<MiniRepl
tune={`"[c5 c5 bb4 c5] [~ g4 ~ g4] [c5 f5 e5 c5] ~".slow(4)
.synth('sawtooth16').adsr(0,.1,0,0)`}
/>
Due to having more than one argument, this method is not patternified.
#### filter(cuttoff)
Patternified filter:
<MiniRepl
tune={`"[c4 c4 bb3 c4] [~ g3 ~ g3] [c4 f4 e4 c4] ~"
.synth('sawtooth16').filter("[500 2000]*8").slow(4)`}
/>
#### gain(value)
Patternified gain:
<MiniRepl
tune={`"[c4 c4 bb3 c4] [~ g3 ~ g3] [c4 f4 e4 c4] ~"
.synth('sawtooth16').gain("[.2 .8]*8").slow(4)`}
/>
#### autofilter(value)
Patternified autofilter:
<MiniRepl
tune={`"c2 c3"
.synth('sawtooth16').autofilter("<1 4 8>")`}
/>
## Tonal API
The Tonal API, uses [tonaljs](https://github.com/tonaljs/tonal) to provide helpers for musical operations.