Merge branch 'main' into global-vibrato

This commit is contained in:
Felix Roos 2023-11-09 08:47:37 +01:00
commit 5aac042c80
106 changed files with 7033 additions and 3791 deletions

View file

@ -1,3 +1,4 @@
export * from './codemirror.mjs';
export * from './highlight.mjs';
export * from './flash.mjs';
export * from './slider.mjs';

View file

@ -0,0 +1,135 @@
import { ref, pure } from '@strudel.cycles/core';
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
import { StateEffect, StateField } from '@codemirror/state';
export let sliderValues = {};
const getSliderID = (from) => `slider_${from}`;
export class SliderWidget extends WidgetType {
constructor(value, min, max, from, to, step, view) {
super();
this.value = value;
this.min = min;
this.max = max;
this.from = from;
this.originalFrom = from;
this.to = to;
this.step = step;
this.view = view;
}
eq() {
return false;
}
toDOM() {
let wrap = document.createElement('span');
wrap.setAttribute('aria-hidden', 'true');
wrap.className = 'cm-slider'; // inline-flex items-center
let slider = wrap.appendChild(document.createElement('input'));
slider.type = 'range';
slider.min = this.min;
slider.max = this.max;
slider.step = this.step ?? (this.max - this.min) / 1000;
slider.originalValue = this.value;
// to make sure the code stays in sync, let's save the original value
// becuase .value automatically clamps values so it'll desync with the code
slider.value = slider.originalValue;
slider.from = this.from;
slider.originalFrom = this.originalFrom;
slider.to = this.to;
slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
this.slider = slider;
slider.addEventListener('input', (e) => {
const next = e.target.value;
let insert = next;
//let insert = next.toFixed(2);
const to = slider.from + slider.originalValue.length;
let change = { from: slider.from, to, insert };
slider.originalValue = insert;
slider.value = insert;
this.view.dispatch({ changes: change });
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler
window.postMessage({ type: 'cm-slider', value: Number(next), id });
});
return wrap;
}
ignoreEvent(e) {
return true;
}
}
export const setWidgets = StateEffect.define();
export const updateWidgets = (view, widgets) => {
view.dispatch({ effects: setWidgets.of(widgets) });
};
function getWidgets(widgetConfigs, view) {
return widgetConfigs.map(({ from, to, value, min, max, step }) => {
return Decoration.widget({
widget: new SliderWidget(value, min, max, from, to, step, view),
side: 0,
}).range(from /* , to */);
});
}
export const sliderPlugin = ViewPlugin.fromClass(
class {
decorations; //: DecorationSet
constructor(view /* : EditorView */) {
this.decorations = Decoration.set([]);
}
update(update /* : ViewUpdate */) {
update.transactions.forEach((tr) => {
if (tr.docChanged) {
this.decorations = this.decorations.map(tr.changes);
const iterator = this.decorations.iter();
while (iterator.value) {
// when the widgets are moved, we need to tell the dom node the current position
// this is important because the updateSliderValue function has to work with the dom node
if (iterator.value?.widget?.slider) {
iterator.value.widget.slider.from = iterator.from;
iterator.value.widget.slider.to = iterator.to;
}
iterator.next();
}
}
for (let e of tr.effects) {
if (e.is(setWidgets)) {
this.decorations = Decoration.set(getWidgets(e.value, update.view));
}
}
});
}
},
{
decorations: (v) => v.decorations,
},
);
export let slider = (value) => {
console.warn('slider will only work when the transpiler is used... passing value as is');
return pure(value);
};
// function transpiled from slider = (value, min, max)
export let sliderWithID = (id, value, min, max) => {
sliderValues[id] = value; // sync state at eval time (code -> state)
return ref(() => sliderValues[id]); // use state at query time
};
// update state when sliders are moved
if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => {
if (e.data.type === 'cm-slider') {
if (sliderValues[e.data.id] !== undefined) {
// update state when slider is moved
sliderValues[e.data.id] = e.data.value;
} else {
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
}
}
});
}

View file

@ -86,6 +86,16 @@ const generic_params = [
*
*/
['gain'],
/**
* Gain applied after all effects have been processed.
*
* @name postgain
* @example
* s("bd sd,hh*4")
* .compressor("-20:20:10:.002:.02").postgain(1.5)
*
*/
['postgain'],
/**
* Like {@link gain}, but linear.
*
@ -655,6 +665,15 @@ const generic_params = [
* .vib("<.5 1 2 4 8 16>:12")
*/
[['vib', 'vibmod'], 'vibrato', 'v'],
/**
* Adds pink noise to the mix
*
* @name noise
* @param {number | Pattern} wet wet amount
* @example
* sound("<white pink brown>/2")
*/
['noise'],
/**
* Sets the vibrato depth in semitones. Only has an effect if `vibrato` | `vib` | `v` is is also set
*
@ -848,7 +867,12 @@ const generic_params = [
*
*/
['lsize'],
// label for pianoroll
/**
* Sets the displayed text for an event on the pianoroll
*
* @name label
* @param {string} label text to display
*/
['activeLabel'],
[['label', 'activeLabel']],
// ['lfo'],
@ -970,20 +994,73 @@ const generic_params = [
*
*/
[['room', 'size']],
/**
* Reverb lowpass starting frequency (in hertz).
* When this property is changed, the reverb will be recaculated, so only change this sparsely..
*
* @name roomlp
* @synonyms rlp
* @param {number} frequency between 0 and 20000hz
* @example
* s("bd sd").room(0.5).rlp(10000)
* @example
* s("bd sd").room(0.5).rlp(5000)
*/
['roomlp', 'rlp'],
/**
* Reverb lowpass frequency at -60dB (in hertz).
* When this property is changed, the reverb will be recaculated, so only change this sparsely..
*
* @name roomdim
* @synonyms rdim
* @param {number} frequency between 0 and 20000hz
* @example
* s("bd sd").room(0.5).rlp(10000).rdim(8000)
* @example
* s("bd sd").room(0.5).rlp(5000).rdim(400)
*
*/
['roomdim', 'rdim'],
/**
* Reverb fade time (in seconds).
* When this property is changed, the reverb will be recaculated, so only change this sparsely..
*
* @name roomfade
* @synonyms rfade
* @param {number} seconds for the reverb to fade
* @example
* s("bd sd").room(0.5).rlp(10000).rfade(0.5)
* @example
* s("bd sd").room(0.5).rlp(5000).rfade(4)
*
*/
['roomfade', 'rfade'],
/**
* Sets the sample to use as an impulse response for the reverb. * * @name iresponse
* @param {string | Pattern} sample to use as an impulse response
* @synonyms ir
* @example
* s("bd sd").room(.8).ir("<shaker_large:0 shaker_large:2>")
*
*/
[['ir', 'i'], 'iresponse'],
/**
* Sets the room size of the reverb, see {@link room}.
* When this property is changed, the reverb will be recaculated, so only change this sparsely..
*
* @name roomsize
* @param {number | Pattern} size between 0 and 10
* @synonyms size, sz
* @synonyms rsize, sz, size
* @example
* s("bd sd").room(.8).roomsize("<0 1 2 4 8>")
* s("bd sd").room(.8).rsize(1)
* @example
* s("bd sd").room(.8).rsize(4)
*
*/
// TODO: find out why :
// s("bd sd").room(.8).roomsize("<0 .2 .4 .6 .8 [1,0]>").osc()
// .. does not work. Is it because room is only one effect?
['size', 'sz', 'roomsize'],
['roomsize', 'size', 'sz', 'rsize'],
// ['sagogo'],
// ['sclap'],
// ['sclaves'],
@ -998,6 +1075,21 @@ const generic_params = [
*
*/
['shape'],
/**
* Dynamics Compressor. The params are `compressor("threshold:ratio:knee:attack:release")`
* More info [here](https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode?retiredLocale=de#instance_properties)
*
* @name compressor
* @example
* s("bd sd,hh*4")
* .compressor("-20:20:10:.002:.02")
*
*/
[['compressor', 'compressorRatio', 'compressorKnee', 'compressorAttack', 'compressorRelease']],
['compressorKnee'],
['compressorRatio'],
['compressorAttack'],
['compressorRelease'],
/**
* Changes the speed of sample playback, i.e. a cheap way of changing pitch.
*
@ -1153,7 +1245,7 @@ const generic_params = [
['pitchJump'],
['pitchJumpTime'],
['lfo', 'repeatTime'],
['noise'],
['znoise'], // noise on the frequency or as bubo calls it "frequency fog" :)
['zmod'],
['zcrush'], // like crush but scaled differently
['zdelay'],
@ -1209,6 +1301,17 @@ generic_params.forEach(([names, ...aliases]) => {
controls.createParams = (...names) =>
names.reduce((acc, name) => Object.assign(acc, { [name]: controls.createParam(name) }), {});
/**
* ADSR envelope: Combination of Attack, Decay, Sustain, and Release.
*
* @name adsr
* @param {number | Pattern} time attack time in seconds
* @param {number | Pattern} time decay time in seconds
* @param {number | Pattern} gain sustain level (0 to 1)
* @param {number | Pattern} time release time in seconds
* @example
* note("<c3 bb2 f3 eb3>").sound("sawtooth").lpf(600).adsr(".1:.1:.5:.2")
*/
controls.adsr = register('adsr', (adsr, pat) => {
adsr = !Array.isArray(adsr) ? [adsr] : adsr;
const [attack, decay, sustain, release] = adsr;

View file

@ -3,6 +3,6 @@
This folder demonstrates how to set up a strudel repl using vite and vanilla JS + codemirror. Run it using:
```sh
npm i
npm run dev
pnpm i
pnpm dev
```

View file

@ -1,6 +1,6 @@
export const bumpStreet = `// froos - "22 bump street", licensed with CC BY-NC-SA 4.0
await samples('github:felixroos/samples/main')
await samples('https://strudel.tidalcycles.org/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
await samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
"<[0,<6 7 9>,13,<17 20 22 26>]!2>/2"
// make it 22 edo
@ -34,7 +34,7 @@ await samples('https://strudel.tidalcycles.org/tidal-drum-machines.json', 'githu
export const trafficFlam = `// froos - "traffic flam", licensed with CC BY-NC-SA 4.0
await samples('github:felixroos/samples/main')
await samples('https://strudel.tidalcycles.org/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
await samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
addVoicings('hip', {
m11: ['2M 3m 4P 7m'],
@ -70,7 +70,7 @@ export const funk42 = `// froos - how to funk in 42 lines of code
// thanks to peach for the transcription: https://www.youtube.com/watch?v=8eiPXvIgda4
await samples('github:felixroos/samples/main')
await samples('https://strudel.tidalcycles.org/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
await samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
setcps(.5)

View file

@ -29,7 +29,7 @@
"bugs": {
"url": "https://github.com/tidalcycles/strudel/issues"
},
"homepage": "https://strudel.tidalcycles.org",
"homepage": "https://strudel.cc",
"dependencies": {
"fraction.js": "^4.2.0"
},

View file

@ -2100,23 +2100,43 @@ export const { iterBack, iterback } = register(['iterBack', 'iterback'], functio
return _iter(times, pat, true);
});
/**
* Repeats each cycle the given number of times.
* @name repeatCycles
* @memberof Pattern
* @returns Pattern
* @example
* note(irand(12).add(34)).segment(4).repeatCycles(2).s("gm_acoustic_guitar_nylon")
*/
const _repeatCycles = function (n, pat) {
return slowcat(...Array(n).fill(pat));
};
const { repeatCycles } = register('repeatCycles', _repeatCycles);
/**
* Divides a pattern into a given number of parts, then cycles through those parts in turn, applying the given function to each part in turn (one part per cycle).
* @name chunk
* @synonyms slowChunk, slowchunk
* @memberof Pattern
* @returns Pattern
* @example
* "0 1 2 3".chunk(4, x=>x.add(7)).scale('A minor').note()
*/
const _chunk = function (n, func, pat, back = false) {
const _chunk = function (n, func, pat, back = false, fast = false) {
const binary = Array(n - 1).fill(false);
binary.unshift(true);
const binary_pat = _iter(n, sequence(...binary), back);
// Invert the 'back' because we want to shift the pattern forwards,
// and so time backwards
const binary_pat = _iter(n, sequence(...binary), !back);
if (!fast) {
pat = pat.repeatCycles(n);
}
return pat.when(binary_pat, func);
};
export const chunk = register('chunk', function (n, func, pat) {
return _chunk(n, func, pat, false);
const { chunk, slowchunk, slowChunk } = register(['chunk', 'slowchunk', 'slowChunk'], function (n, func, pat) {
return _chunk(n, func, pat, false, false);
});
/**
@ -2132,6 +2152,21 @@ export const { chunkBack, chunkback } = register(['chunkBack', 'chunkback'], fun
return _chunk(n, func, pat, true);
});
/**
* Like `chunk`, but the cycles of the source pattern aren't repeated
* for each set of chunks.
* @name fastChunk
* @synonyms fastchunk
* @memberof Pattern
* @returns Pattern
* @example
* "<0 8> 1 2 3 4 5 6 7".fastChunk(4, x => x.color('red')).slow(4).scale("C2:major").note()
.s("folkharp")
*/
const { fastchunk, fastChunk } = register(['fastchunk', 'fastChunk'], function (n, func, pat) {
return _chunk(n, func, pat, false, true);
});
// TODO - redefine elsewhere in terms of mask
export const bypass = register('bypass', function (on, pat) {
on = Boolean(parseInt(on));
@ -2156,6 +2191,9 @@ export const duration = register('duration', function (value, pat) {
/**
* Sets the color of the hap in visualizations like pianoroll or highlighting.
* @name color
* @synonyms colour
* @param {string} color Hexadecimal or CSS color name
*/
// TODO: move this to controls https://github.com/tidalcycles/strudel/issues/288
export const { color, colour } = register(['color', 'colour'], function (color, pat) {
@ -2217,6 +2255,14 @@ export const chop = register('chop', function (n, pat) {
return pat.squeezeBind(func);
});
/**
* Cuts each sample into the given number of parts, triggering progressive portions of each sample at each loop.
* @name striate
* @memberof Pattern
* @returns Pattern
* @example
* s("numbers:0 numbers:1 numbers:2").striate(6).slow(6)
*/
export const striate = register('striate', function (n, pat) {
const slices = Array.from({ length: n }, (x, i) => i);
const slice_objects = slices.map((i) => ({ begin: i / n, end: (i + 1) / n }));
@ -2343,3 +2389,35 @@ export const fit = register('fit', (pat) =>
export const { loopAtCps, loopatcps } = register(['loopAtCps', 'loopatcps'], function (factor, cps, pat) {
return _loopAt(factor, pat, cps);
});
/** exposes a custom value at query time. basically allows mutating state without evaluation */
export const ref = (accessor) =>
pure(1)
.withValue(() => reify(accessor()))
.innerJoin();
let fadeGain = (p) => (p < 0.5 ? 1 : 1 - (p - 0.5) / 0.5);
/**
* Cross-fades between left and right from 0 to 1:
* - 0 = (full left, no right)
* - .5 = (both equal)
* - 1 = (no left, full right)
*
* @name xfade
* @example
* xfade(s("bd*2"), "<0 .25 .5 .75 1>", s("hh*8"))
*/
export let xfade = (a, pos, b) => {
pos = reify(pos);
a = reify(a);
b = reify(b);
let gaina = pos.fmap((v) => ({ gain: fadeGain(v) }));
let gainb = pos.fmap((v) => ({ gain: fadeGain(1 - v) }));
return stack(a.mul(gaina), b.mul(gainb));
};
// the prototype version is actually flipped so left/right makes sense
Pattern.prototype.xfade = function (pos, b) {
return xfade(this, pos, b);
};

View file

@ -56,6 +56,40 @@ Pattern.prototype.pianoroll = function (options = {}) {
// this function allows drawing a pianoroll without ties to Pattern.prototype
// it will probably replace the above in the future
/**
* Displays a midi-style piano roll
*
* @name pianoroll
* @param {Object} options Object containing all the optional following parameters as key value pairs:
* @param {integer} cycles number of cycles to be displayed at the same time - defaults to 4
* @param {number} playhead location of the active notes on the time axis - 0 to 1, defaults to 0.5
* @param {boolean} vertical displays the roll vertically - 0 by default
* @param {boolean} labels displays labels on individual notes (see the label function) - 0 by default
* @param {boolean} flipTime reverse the direction of the roll - 0 by default
* @param {boolean} flipValues reverse the relative location of notes on the value axis - 0 by default
* @param {number} overscan lookup X cycles outside of the cycles window to display notes in advance - 1 by default
* @param {boolean} hideNegative hide notes with negative time (before starting playing the pattern) - 0 by default
* @param {boolean} smear notes leave a solid trace - 0 by default
* @param {boolean} fold notes takes the full value axis width - 0 by default
* @param {string} active hexadecimal or CSS color of the active notes - defaults to #FFCA28
* @param {string} inactive hexadecimal or CSS color of the inactive notes - defaults to #7491D2
* @param {string} background hexadecimal or CSS color of the background - defaults to transparent
* @param {string} playheadColor hexadecimal or CSS color of the line representing the play head - defaults to white
* @param {boolean} fill notes are filled with color (otherwise only the label is displayed) - 0 by default
* @param {boolean} fillActive active notes are filled with color - 0 by default
* @param {boolean} stroke notes are shown with colored borders - 0 by default
* @param {boolean} strokeActive active notes are shown with colored borders - 0 by default
* @param {boolean} hideInactive only active notes are shown - 0 by default
* @param {boolean} colorizeInactive use note color for inactive notes - 1 by default
* @param {string} fontFamily define the font used by notes labels - defaults to 'monospace'
* @param {integer} minMidi minimum note value to display on the value axis - defaults to 10
* @param {integer} maxMidi maximum note value to display on the value axis - defaults to 90
* @param {boolean} autorange automatically calculate the minMidi and maxMidi parameters - 0 by default
*
* @example
* note("C2 A2 G2").euclid(5,8).s('piano').clip(1).color('salmon').pianoroll({vertical:1, labels:1})
*/
export function pianoroll({
time,
haps,

View file

@ -24,6 +24,7 @@ export function repl({
getTime,
onToggle,
});
let playPatterns = [];
const setPattern = (pattern, autostart = true) => {
pattern = editPattern?.(pattern) || pattern;
scheduler.setPattern(pattern, autostart);
@ -35,7 +36,11 @@ export function repl({
}
try {
await beforeEval?.({ code });
playPatterns = [];
let { pattern, meta } = await _evaluate(code, transpiler);
if (playPatterns.length) {
pattern = pattern.stack(...playPatterns);
}
logger(`[eval] code updated`);
setPattern(pattern, autostart);
afterEval?.({ code, pattern, meta });
@ -57,6 +62,11 @@ export function repl({
return pat.loopAtCps(cycles, scheduler.cps);
});
const play = register('play', (pat) => {
playPatterns.push(pat);
return pat;
});
const fit = register('fit', (pat) =>
pat.withHap((hap) =>
hap.withValue((v) => ({
@ -70,6 +80,7 @@ export function repl({
evalScope({
loopAt,
fit,
play,
setCps,
setcps: setCps,
setCpm,

View file

@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { Hap } from './hap.mjs';
import { Pattern, fastcat, reify, silence, stack, register } from './pattern.mjs';
import Fraction from './fraction.mjs';
import { id } from './util.mjs';
import { id, _mod, clamp } from './util.mjs';
export function steady(value) {
// A continuous value
@ -155,12 +155,62 @@ export const _irand = (i) => rand.fmap((x) => Math.trunc(x * i));
*/
export const irand = (ipat) => reify(ipat).fmap(_irand).innerJoin();
/**
* pick from the list of values (or patterns of values) via the index using the given
* pattern of integers
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
* @example
* note(pick("<0 1 [2!2] 3>", ["g a", "e f", "f g f g" , "g a c d"]))
*/
export const pick = (pat, xs) => {
xs = xs.map(reify);
if (xs.length == 0) {
return silence;
}
return pat
.fmap((i) => {
const key = clamp(Math.round(i), 0, xs.length - 1);
return xs[key];
})
.innerJoin();
};
/**
* pick from the list of values (or patterns of values) via the index using the given
* pattern of integers. The selected pattern will be compressed to fit the duration of the selecting event
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
* @example
* note(squeeze("<0@2 [1!2] 2>", ["g a", "f g f g" , "g a c d"]))
*/
export const squeeze = (pat, xs) => {
xs = xs.map(reify);
if (xs.length == 0) {
return silence;
}
return pat
.fmap((i) => {
const key = _mod(Math.round(i), xs.length);
return xs[key];
})
.squeezeJoin();
};
export const __chooseWith = (pat, xs) => {
xs = xs.map(reify);
if (xs.length == 0) {
return silence;
}
return pat.range(0, xs.length).fmap((i) => xs[Math.floor(i)]);
return pat.range(0, xs.length).fmap((i) => {
const key = Math.min(Math.max(Math.floor(i), 0), xs.length - 1);
return xs[key];
});
};
/**
* Choose from the list of values (or patterns of values) using the given
@ -168,6 +218,8 @@ export const __chooseWith = (pat, xs) => {
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
* @example
* note("c2 g2!2 d2 f1").s(chooseWith(sine.fast(2), ["sawtooth", "triangle", "bd:6"]))
*/
export const chooseWith = (pat, xs) => {
return __chooseWith(pat, xs).outerJoin();

View file

@ -1003,4 +1003,23 @@ describe('Pattern', () => {
);
});
});
describe('chunk', () => {
it('Processes each cycle of the source pattern multiple times, once for each chunk', () => {
expect(sequence(0, 1, 2, 3).slow(2).chunk(2, add(10)).fast(4).firstCycleValues).toStrictEqual([
10, 1, 0, 11, 12, 3, 2, 13,
]);
});
});
describe('fastChunk', () => {
it('Unlike chunk, cycles of the source pattern proceed cycle-by-cycle', () => {
expect(sequence(0, 1, 2, 3).slow(2).fastChunk(2, add(10)).fast(4).firstCycleValues).toStrictEqual([
10, 1, 2, 13, 10, 1, 2, 13,
]);
});
});
describe('repeatCycles', () => {
it('Repeats each cycle of the source pattern the given number of times', () => {
expect(slowcat(0, 1).repeatCycles(2).fast(6).firstCycleValues).toStrictEqual([0, 0, 1, 1, 0, 0]);
});
});
});

View file

@ -46,7 +46,5 @@ export const cleanupUi = () => {
const container = document.getElementById('code');
if (container) {
container.style = '';
// TODO: find a way to remove that duplication..
container.className = 'grow flex text-gray-100 relative overflow-auto cursor-text pb-0'; // has to match App.tsx
}
};

View file

@ -6,7 +6,7 @@ class Strudel extends HTMLElement {
setTimeout(() => {
const code = (this.innerHTML + '').replace('<!--', '').replace('-->', '').trim();
const iframe = document.createElement('iframe');
const src = `https://strudel.tidalcycles.org/#${encodeURIComponent(btoa(code))}`;
const src = `https://strudel.cc/#${encodeURIComponent(btoa(code))}`;
// const src = `http://localhost:3000/#${encodeURIComponent(btoa(code))}`;
iframe.setAttribute('src', src);
iframe.setAttribute('width', '600');

29
packages/hydra/README.md Normal file
View file

@ -0,0 +1,29 @@
# @strudel/hydra
This package integrates [hydra-synth](https://www.npmjs.com/package/hydra-synth) into strudel.
## Usage in Strudel
This package is imported into strudel by default. To activate Hydra, place this code at the top of your code:
```js
await initHydra();
```
Then you can use hydra below!
## Usage via npm
```sh
npm i @strudel/hydra
```
Then add the import to your evalScope:
```js
import { evalScope } from '@strudel.cycles/core';
evalScope(
import('@strudel/hydra')
)
```

15
packages/hydra/hydra.mjs Normal file
View file

@ -0,0 +1,15 @@
import { getDrawContext } from '@strudel.cycles/core';
export async function initHydra() {
if (!document.getElementById('hydra-canvas')) {
const { canvas: testCanvas } = getDrawContext();
await import('https://unpkg.com/hydra-synth');
const hydraCanvas = testCanvas.cloneNode(true);
hydraCanvas.id = 'hydra-canvas';
testCanvas.after(hydraCanvas);
new Hydra({ canvas: hydraCanvas, detectAudio: false });
s0.init({ src: testCanvas });
}
}
export const H = (p) => () => p.queryArc(getTime(), getTime())[0].value;

View file

@ -0,0 +1,43 @@
{
"name": "@strudel/hydra",
"version": "0.9.0",
"description": "Hydra integration for strudel",
"main": "hydra.mjs",
"publishConfig": {
"main": "dist/index.js",
"module": "dist/index.mjs"
},
"scripts": {
"server": "node server.js",
"tidal-sniffer": "node tidal-sniffer.js",
"client": "npx serve -p 4321",
"build-bin": "npx pkg server.js --targets node16-macos-x64,node16-win-x64,node16-linux-x64 --out-path bin",
"build": "vite build",
"prepublishOnly": "npm run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/tidalcycles/strudel.git"
},
"keywords": [
"tidalcycles",
"strudel",
"pattern",
"livecoding",
"algorave"
],
"author": "Felix Roos <flix91@gmail.com>",
"license": "AGPL-3.0-or-later",
"bugs": {
"url": "https://github.com/tidalcycles/strudel/issues"
},
"homepage": "https://github.com/tidalcycles/strudel#readme",
"dependencies": {
"@strudel.cycles/core": "workspace:*",
"hydra-synth": "^1.3.29"
},
"devDependencies": {
"pkg": "^5.8.1",
"vite": "^4.3.3"
}
}

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import { dependencies } from './package.json';
import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [],
build: {
lib: {
entry: resolve(__dirname, 'hydra.mjs'),
formats: ['es', 'cjs'],
fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]),
},
rollupOptions: {
external: [...Object.keys(dependencies)],
},
target: 'esnext',
},
});

View file

@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import * as _WebMidi from 'webmidi';
import { Pattern, isPattern, logger } from '@strudel.cycles/core';
import { Pattern, isPattern, logger, ref } from '@strudel.cycles/core';
import { noteToMidi } from '@strudel.cycles/core';
import { Note } from 'webmidi';
// if you use WebMidi from outside of this package, make sure to import that instance:
@ -15,8 +15,8 @@ function supportsMidi() {
return typeof navigator.requestMIDIAccess === 'function';
}
function getMidiDeviceNamesString(outputs) {
return outputs.map((o) => `'${o.name}'`).join(' | ');
function getMidiDeviceNamesString(devices) {
return devices.map((o) => `'${o.name}'`).join(' | ');
}
export function enableWebMidi(options = {}) {
@ -52,30 +52,41 @@ export function enableWebMidi(options = {}) {
});
});
}
// const outputByName = (name: string) => WebMidi.getOutputByName(name);
const outputByName = (name) => WebMidi.getOutputByName(name);
// output?: string | number, outputs: typeof WebMidi.outputs
function getDevice(output, outputs) {
if (!outputs.length) {
function getDevice(indexOrName, devices) {
if (!devices.length) {
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
}
if (typeof output === 'number') {
return outputs[output];
if (typeof indexOrName === 'number') {
return devices[indexOrName];
}
if (typeof output === 'string') {
return outputByName(output);
const byName = (name) => devices.find((output) => output.name.includes(name));
if (typeof indexOrName === 'string') {
return byName(indexOrName);
}
// attempt to default to first IAC device if none is specified
const IACOutput = outputs.find((output) => output.name.includes('IAC'));
const device = IACOutput ?? outputs[0];
const IACOutput = byName('IAC');
const device = IACOutput ?? devices[0];
if (!device) {
throw new Error(
`🔌 MIDI device '${output ? output : ''}' not found. Use one of ${getMidiDeviceNamesString(WebMidi.outputs)}`,
`🔌 MIDI device '${device ? device : ''}' not found. Use one of ${getMidiDeviceNamesString(devices)}`,
);
}
return IACOutput ?? outputs[0];
return IACOutput ?? devices[0];
}
// send start/stop messages to outputs when repl starts/stops
if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => {
if (!WebMidi?.enabled) {
return;
}
if (e.data === 'strudel-stop') {
WebMidi.outputs.forEach((output) => output.sendStop());
}
// cannot start here, since we have no timing info, see sendStart below
});
}
Pattern.prototype.midi = function (output) {
@ -103,6 +114,7 @@ Pattern.prototype.midi = function (output) {
return this.onTrigger((time, hap, currentTime, cps) => {
if (!WebMidi.enabled) {
console.log('not enabled');
return;
}
const device = getDevice(output, WebMidi.outputs);
@ -113,7 +125,7 @@ Pattern.prototype.midi = function (output) {
const timeOffsetString = `+${offset}`;
// destructure value
const { note, nrpnn, nrpv, ccn, ccv, midichan = 1 } = hap.value;
const { note, nrpnn, nrpv, ccn, ccv, midichan = 1, midicmd } = hap.value;
const velocity = hap.context?.velocity ?? 0.9; // TODO: refactor velocity
// note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length
@ -125,7 +137,7 @@ Pattern.prototype.midi = function (output) {
time: timeOffsetString,
});
}
if (ccv && ccn) {
if (ccv !== undefined && ccn !== undefined) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1');
}
@ -135,5 +147,46 @@ Pattern.prototype.midi = function (output) {
const scaled = Math.round(ccv * 127);
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
}
if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info
device.sendStart({ time: timeOffsetString });
}
if (['clock', 'midiClock'].includes(midicmd)) {
device.sendClock({ time: timeOffsetString });
} else if (['start'].includes(midicmd)) {
device.sendStart({ time: timeOffsetString });
} else if (['stop'].includes(midicmd)) {
device.sendStop({ time: timeOffsetString });
} else if (['continue'].includes(midicmd)) {
device.sendContinue({ time: timeOffsetString });
}
});
};
let listeners = {};
const refs = {};
export async function midin(input) {
const initial = await enableWebMidi(); // only returns on first init
const device = getDevice(input, WebMidi.inputs);
if (initial) {
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
logger(
`Midi enabled! Using "${device.name}". ${
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
}`,
);
refs[input] = {};
}
const cc = (cc) => ref(() => refs[input][cc] || 0);
listeners[input] && device.removeListener('midimessage', listeners[input]);
listeners[input] = (e) => {
const cc = e.dataBytes[0];
const v = e.dataBytes[1];
refs[input] && (refs[input][cc] = v / 127);
};
device.addListener('midimessage', listeners[input]);
return cc;
}

View file

@ -32,7 +32,7 @@ yields:
## Mini Notation API
See "Mini Notation" in the [Strudel Tutorial](https://strudel.tidalcycles.org/learn/mini-notation)
See "Mini Notation" in the [Strudel Tutorial](https://strudel.cc/learn/mini-notation)
## Building the Parser
@ -40,5 +40,5 @@ The parser [krill-parser.js] is generated from [krill.pegjs](./krill.pegjs) usin
To generate the parser, run
```js
npm build:parser
npm run build:parser
```

File diff suppressed because one or more lines are too long

View file

@ -79,6 +79,9 @@ frac
int
= zero / (digit1_9 DIGIT*)
intneg
= minus? int { return parseInt(text()); }
minus
= "-"
@ -100,7 +103,8 @@ quote = '"' / "'"
// ------------------ steps and cycles ---------------------------
// single step definition (e.g bd)
step_char = [0-9a-zA-Z~] / "-" / "#" / "." / "^" / "_"
step_char "a letter, a number, \"-\", \"#\", \".\", \"^\", \"_\"" =
unicode_letter / [0-9~] / "-" / "#" / "." / "^" / "_"
step = ws chars:step_char+ ws { return new AtomStub(chars.join("")) }
// define a sub cycle e.g. [1 2, 3 [4]]
@ -123,7 +127,7 @@ slice = step / sub_cycle / polymeter / slow_sequence
// slice modifier affects the timing/size of a slice (e.g. [a b c]@3)
// at this point, we assume we can represent them as regular sequence operators
slice_op = op_weight / op_bjorklund / op_slow / op_fast / op_replicate / op_degrade / op_tail
slice_op = op_weight / op_bjorklund / op_slow / op_fast / op_replicate / op_degrade / op_tail / op_range
op_weight = "@" a:number
{ return x => x.options_['weight'] = a }
@ -146,6 +150,9 @@ op_degrade = "?"a:number?
op_tail = ":" s:slice
{ return x => x.options_['ops'].push({ type_: "tail", arguments_ :{ element:s } }) }
op_range = ".." s:slice
{ return x => x.options_['ops'].push({ type_: "range", arguments_ :{ element:s } }) }
// a slice with an modifier applied i.e [bd@4 sd@3]@2 hh]
slice_with_ops = s:slice ops:slice_op*
{ const result = new ElementStub(s, {ops: [], weight: 1, reps: 1});
@ -259,3 +266,25 @@ hush = "hush"
// ---------------------- statements ----------------------------
statement = mini_definition / command
// ---------------------- unicode ----------------------------
unicode_letter = Lu / Ll / Lt / Lm / Lo / Nl
// Letter, Lowercase
Ll = [\u0061-\u007A\u00B5\u00DF-\u00F6\u00F8-\u00FF\u0101\u0103\u0105\u0107\u0109\u010B\u010D\u010F\u0111\u0113\u0115\u0117\u0119\u011B\u011D\u011F\u0121\u0123\u0125\u0127\u0129\u012B\u012D\u012F\u0131\u0133\u0135\u0137-\u0138\u013A\u013C\u013E\u0140\u0142\u0144\u0146\u0148-\u0149\u014B\u014D\u014F\u0151\u0153\u0155\u0157\u0159\u015B\u015D\u015F\u0161\u0163\u0165\u0167\u0169\u016B\u016D\u016F\u0171\u0173\u0175\u0177\u017A\u017C\u017E-\u0180\u0183\u0185\u0188\u018C-\u018D\u0192\u0195\u0199-\u019B\u019E\u01A1\u01A3\u01A5\u01A8\u01AA-\u01AB\u01AD\u01B0\u01B4\u01B6\u01B9-\u01BA\u01BD-\u01BF\u01C6\u01C9\u01CC\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC-\u01DD\u01DF\u01E1\u01E3\u01E5\u01E7\u01E9\u01EB\u01ED\u01EF-\u01F0\u01F3\u01F5\u01F9\u01FB\u01FD\u01FF\u0201\u0203\u0205\u0207\u0209\u020B\u020D\u020F\u0211\u0213\u0215\u0217\u0219\u021B\u021D\u021F\u0221\u0223\u0225\u0227\u0229\u022B\u022D\u022F\u0231\u0233-\u0239\u023C\u023F-\u0240\u0242\u0247\u0249\u024B\u024D\u024F-\u0293\u0295-\u02AF\u0371\u0373\u0377\u037B-\u037D\u0390\u03AC-\u03CE\u03D0-\u03D1\u03D5-\u03D7\u03D9\u03DB\u03DD\u03DF\u03E1\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF-\u03F3\u03F5\u03F8\u03FB-\u03FC\u0430-\u045F\u0461\u0463\u0465\u0467\u0469\u046B\u046D\u046F\u0471\u0473\u0475\u0477\u0479\u047B\u047D\u047F\u0481\u048B\u048D\u048F\u0491\u0493\u0495\u0497\u0499\u049B\u049D\u049F\u04A1\u04A3\u04A5\u04A7\u04A9\u04AB\u04AD\u04AF\u04B1\u04B3\u04B5\u04B7\u04B9\u04BB\u04BD\u04BF\u04C2\u04C4\u04C6\u04C8\u04CA\u04CC\u04CE-\u04CF\u04D1\u04D3\u04D5\u04D7\u04D9\u04DB\u04DD\u04DF\u04E1\u04E3\u04E5\u04E7\u04E9\u04EB\u04ED\u04EF\u04F1\u04F3\u04F5\u04F7\u04F9\u04FB\u04FD\u04FF\u0501\u0503\u0505\u0507\u0509\u050B\u050D\u050F\u0511\u0513\u0515\u0517\u0519\u051B\u051D\u051F\u0521\u0523\u0525\u0527\u0529\u052B\u052D\u052F\u0560-\u0588\u10D0-\u10FA\u10FD-\u10FF\u13F8-\u13FD\u1C80-\u1C88\u1D00-\u1D2B\u1D6B-\u1D77\u1D79-\u1D9A\u1E01\u1E03\u1E05\u1E07\u1E09\u1E0B\u1E0D\u1E0F\u1E11\u1E13\u1E15\u1E17\u1E19\u1E1B\u1E1D\u1E1F\u1E21\u1E23\u1E25\u1E27\u1E29\u1E2B\u1E2D\u1E2F\u1E31\u1E33\u1E35\u1E37\u1E39\u1E3B\u1E3D\u1E3F\u1E41\u1E43\u1E45\u1E47\u1E49\u1E4B\u1E4D\u1E4F\u1E51\u1E53\u1E55\u1E57\u1E59\u1E5B\u1E5D\u1E5F\u1E61\u1E63\u1E65\u1E67\u1E69\u1E6B\u1E6D\u1E6F\u1E71\u1E73\u1E75\u1E77\u1E79\u1E7B\u1E7D\u1E7F\u1E81\u1E83\u1E85\u1E87\u1E89\u1E8B\u1E8D\u1E8F\u1E91\u1E93\u1E95-\u1E9D\u1E9F\u1EA1\u1EA3\u1EA5\u1EA7\u1EA9\u1EAB\u1EAD\u1EAF\u1EB1\u1EB3\u1EB5\u1EB7\u1EB9\u1EBB\u1EBD\u1EBF\u1EC1\u1EC3\u1EC5\u1EC7\u1EC9\u1ECB\u1ECD\u1ECF\u1ED1\u1ED3\u1ED5\u1ED7\u1ED9\u1EDB\u1EDD\u1EDF\u1EE1\u1EE3\u1EE5\u1EE7\u1EE9\u1EEB\u1EED\u1EEF\u1EF1\u1EF3\u1EF5\u1EF7\u1EF9\u1EFB\u1EFD\u1EFF-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB0-\u1FB4\u1FB6-\u1FB7\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FC7\u1FD0-\u1FD3\u1FD6-\u1FD7\u1FE0-\u1FE7\u1FF2-\u1FF4\u1FF6-\u1FF7\u210A\u210E-\u210F\u2113\u212F\u2134\u2139\u213C-\u213D\u2146-\u2149\u214E\u2184\u2C30-\u2C5E\u2C61\u2C65-\u2C66\u2C68\u2C6A\u2C6C\u2C71\u2C73-\u2C74\u2C76-\u2C7B\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8B\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CB3\u2CB5\u2CB7\u2CB9\u2CBB\u2CBD\u2CBF\u2CC1\u2CC3\u2CC5\u2CC7\u2CC9\u2CCB\u2CCD\u2CCF\u2CD1\u2CD3\u2CD5\u2CD7\u2CD9\u2CDB\u2CDD\u2CDF\u2CE1\u2CE3-\u2CE4\u2CEC\u2CEE\u2CF3\u2D00-\u2D25\u2D27\u2D2D\uA641\uA643\uA645\uA647\uA649\uA64B\uA64D\uA64F\uA651\uA653\uA655\uA657\uA659\uA65B\uA65D\uA65F\uA661\uA663\uA665\uA667\uA669\uA66B\uA66D\uA681\uA683\uA685\uA687\uA689\uA68B\uA68D\uA68F\uA691\uA693\uA695\uA697\uA699\uA69B\uA723\uA725\uA727\uA729\uA72B\uA72D\uA72F-\uA731\uA733\uA735\uA737\uA739\uA73B\uA73D\uA73F\uA741\uA743\uA745\uA747\uA749\uA74B\uA74D\uA74F\uA751\uA753\uA755\uA757\uA759\uA75B\uA75D\uA75F\uA761\uA763\uA765\uA767\uA769\uA76B\uA76D\uA76F\uA771-\uA778\uA77A\uA77C\uA77F\uA781\uA783\uA785\uA787\uA78C\uA78E\uA791\uA793-\uA795\uA797\uA799\uA79B\uA79D\uA79F\uA7A1\uA7A3\uA7A5\uA7A7\uA7A9\uA7AF\uA7B5\uA7B7\uA7B9\uA7FA\uAB30-\uAB5A\uAB60-\uAB65\uAB70-\uABBF\uFB00-\uFB06\uFB13-\uFB17\uFF41-\uFF5A]
// Letter, Modifier
Lm = [\u02B0-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0374\u037A\u0559\u0640\u06E5-\u06E6\u07F4-\u07F5\u07FA\u081A\u0824\u0828\u0971\u0E46\u0EC6\u10FC\u17D7\u1843\u1AA7\u1C78-\u1C7D\u1D2C-\u1D6A\u1D78\u1D9B-\u1DBF\u2071\u207F\u2090-\u209C\u2C7C-\u2C7D\u2D6F\u2E2F\u3005\u3031-\u3035\u303B\u309D-\u309E\u30FC-\u30FE\uA015\uA4F8-\uA4FD\uA60C\uA67F\uA69C-\uA69D\uA717-\uA71F\uA770\uA788\uA7F8-\uA7F9\uA9CF\uA9E6\uAA70\uAADD\uAAF3-\uAAF4\uAB5C-\uAB5F\uFF70\uFF9E-\uFF9F]
// Letter, Other
Lo = [\u00AA\u00BA\u01BB\u01C0-\u01C3\u0294\u05D0-\u05EA\u05EF-\u05F2\u0620-\u063F\u0641-\u064A\u066E-\u066F\u0671-\u06D3\u06D5\u06EE-\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u0800-\u0815\u0840-\u0858\u0860-\u086A\u08A0-\u08B4\u08B6-\u08BD\u0904-\u0939\u093D\u0950\u0958-\u0961\u0972-\u0980\u0985-\u098C\u098F-\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC-\u09DD\u09DF-\u09E1\u09F0-\u09F1\u09FC\u0A05-\u0A0A\u0A0F-\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32-\u0A33\u0A35-\u0A36\u0A38-\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2-\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0-\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F-\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32-\u0B33\u0B35-\u0B39\u0B3D\u0B5C-\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99-\u0B9A\u0B9C\u0B9E-\u0B9F\u0BA3-\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60-\u0C61\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0-\u0CE1\u0CF1-\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32-\u0E33\u0E40-\u0E45\u0E81-\u0E82\u0E84\u0E87-\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA-\u0EAB\u0EAD-\u0EB0\u0EB2-\u0EB3\u0EBD\u0EC0-\u0EC4\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065-\u1066\u106E-\u1070\u1075-\u1081\u108E\u1100-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17DC\u1820-\u1842\u1844-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE-\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C77\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5-\u1CF6\u2135-\u2138\u2D30-\u2D67\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u3006\u303C\u3041-\u3096\u309F\u30A1-\u30FA\u30FF\u3105-\u312F\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FEF\uA000-\uA014\uA016-\uA48C\uA4D0-\uA4F7\uA500-\uA60B\uA610-\uA61F\uA62A-\uA62B\uA66E\uA6A0-\uA6E5\uA78F\uA7F7\uA7FB-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD-\uA8FE\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9E0-\uA9E4\uA9E7-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA6F\uAA71-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5-\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADC\uAAE0-\uAAEA\uAAF2\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40-\uFB41\uFB43-\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF66-\uFF6F\uFF71-\uFF9D\uFFA0-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]
// Letter, Titlecase
Lt = [\u01C5\u01C8\u01CB\u01F2\u1F88-\u1F8F\u1F98-\u1F9F\u1FA8-\u1FAF\u1FBC\u1FCC\u1FFC]
// Letter, Uppercase
Lu = [\u0041-\u005A\u00C0-\u00D6\u00D8-\u00DE\u0100\u0102\u0104\u0106\u0108\u010A\u010C\u010E\u0110\u0112\u0114\u0116\u0118\u011A\u011C\u011E\u0120\u0122\u0124\u0126\u0128\u012A\u012C\u012E\u0130\u0132\u0134\u0136\u0139\u013B\u013D\u013F\u0141\u0143\u0145\u0147\u014A\u014C\u014E\u0150\u0152\u0154\u0156\u0158\u015A\u015C\u015E\u0160\u0162\u0164\u0166\u0168\u016A\u016C\u016E\u0170\u0172\u0174\u0176\u0178-\u0179\u017B\u017D\u0181-\u0182\u0184\u0186-\u0187\u0189-\u018B\u018E-\u0191\u0193-\u0194\u0196-\u0198\u019C-\u019D\u019F-\u01A0\u01A2\u01A4\u01A6-\u01A7\u01A9\u01AC\u01AE-\u01AF\u01B1-\u01B3\u01B5\u01B7-\u01B8\u01BC\u01C4\u01C7\u01CA\u01CD\u01CF\u01D1\u01D3\u01D5\u01D7\u01D9\u01DB\u01DE\u01E0\u01E2\u01E4\u01E6\u01E8\u01EA\u01EC\u01EE\u01F1\u01F4\u01F6-\u01F8\u01FA\u01FC\u01FE\u0200\u0202\u0204\u0206\u0208\u020A\u020C\u020E\u0210\u0212\u0214\u0216\u0218\u021A\u021C\u021E\u0220\u0222\u0224\u0226\u0228\u022A\u022C\u022E\u0230\u0232\u023A-\u023B\u023D-\u023E\u0241\u0243-\u0246\u0248\u024A\u024C\u024E\u0370\u0372\u0376\u037F\u0386\u0388-\u038A\u038C\u038E-\u038F\u0391-\u03A1\u03A3-\u03AB\u03CF\u03D2-\u03D4\u03D8\u03DA\u03DC\u03DE\u03E0\u03E2\u03E4\u03E6\u03E8\u03EA\u03EC\u03EE\u03F4\u03F7\u03F9-\u03FA\u03FD-\u042F\u0460\u0462\u0464\u0466\u0468\u046A\u046C\u046E\u0470\u0472\u0474\u0476\u0478\u047A\u047C\u047E\u0480\u048A\u048C\u048E\u0490\u0492\u0494\u0496\u0498\u049A\u049C\u049E\u04A0\u04A2\u04A4\u04A6\u04A8\u04AA\u04AC\u04AE\u04B0\u04B2\u04B4\u04B6\u04B8\u04BA\u04BC\u04BE\u04C0-\u04C1\u04C3\u04C5\u04C7\u04C9\u04CB\u04CD\u04D0\u04D2\u04D4\u04D6\u04D8\u04DA\u04DC\u04DE\u04E0\u04E2\u04E4\u04E6\u04E8\u04EA\u04EC\u04EE\u04F0\u04F2\u04F4\u04F6\u04F8\u04FA\u04FC\u04FE\u0500\u0502\u0504\u0506\u0508\u050A\u050C\u050E\u0510\u0512\u0514\u0516\u0518\u051A\u051C\u051E\u0520\u0522\u0524\u0526\u0528\u052A\u052C\u052E\u0531-\u0556\u10A0-\u10C5\u10C7\u10CD\u13A0-\u13F5\u1C90-\u1CBA\u1CBD-\u1CBF\u1E00\u1E02\u1E04\u1E06\u1E08\u1E0A\u1E0C\u1E0E\u1E10\u1E12\u1E14\u1E16\u1E18\u1E1A\u1E1C\u1E1E\u1E20\u1E22\u1E24\u1E26\u1E28\u1E2A\u1E2C\u1E2E\u1E30\u1E32\u1E34\u1E36\u1E38\u1E3A\u1E3C\u1E3E\u1E40\u1E42\u1E44\u1E46\u1E48\u1E4A\u1E4C\u1E4E\u1E50\u1E52\u1E54\u1E56\u1E58\u1E5A\u1E5C\u1E5E\u1E60\u1E62\u1E64\u1E66\u1E68\u1E6A\u1E6C\u1E6E\u1E70\u1E72\u1E74\u1E76\u1E78\u1E7A\u1E7C\u1E7E\u1E80\u1E82\u1E84\u1E86\u1E88\u1E8A\u1E8C\u1E8E\u1E90\u1E92\u1E94\u1E9E\u1EA0\u1EA2\u1EA4\u1EA6\u1EA8\u1EAA\u1EAC\u1EAE\u1EB0\u1EB2\u1EB4\u1EB6\u1EB8\u1EBA\u1EBC\u1EBE\u1EC0\u1EC2\u1EC4\u1EC6\u1EC8\u1ECA\u1ECC\u1ECE\u1ED0\u1ED2\u1ED4\u1ED6\u1ED8\u1EDA\u1EDC\u1EDE\u1EE0\u1EE2\u1EE4\u1EE6\u1EE8\u1EEA\u1EEC\u1EEE\u1EF0\u1EF2\u1EF4\u1EF6\u1EF8\u1EFA\u1EFC\u1EFE\u1F08-\u1F0F\u1F18-\u1F1D\u1F28-\u1F2F\u1F38-\u1F3F\u1F48-\u1F4D\u1F59\u1F5B\u1F5D\u1F5F\u1F68-\u1F6F\u1FB8-\u1FBB\u1FC8-\u1FCB\u1FD8-\u1FDB\u1FE8-\u1FEC\u1FF8-\u1FFB\u2102\u2107\u210B-\u210D\u2110-\u2112\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u2130-\u2133\u213E-\u213F\u2145\u2183\u2C00-\u2C2E\u2C60\u2C62-\u2C64\u2C67\u2C69\u2C6B\u2C6D-\u2C70\u2C72\u2C75\u2C7E-\u2C80\u2C82\u2C84\u2C86\u2C88\u2C8A\u2C8C\u2C8E\u2C90\u2C92\u2C94\u2C96\u2C98\u2C9A\u2C9C\u2C9E\u2CA0\u2CA2\u2CA4\u2CA6\u2CA8\u2CAA\u2CAC\u2CAE\u2CB0\u2CB2\u2CB4\u2CB6\u2CB8\u2CBA\u2CBC\u2CBE\u2CC0\u2CC2\u2CC4\u2CC6\u2CC8\u2CCA\u2CCC\u2CCE\u2CD0\u2CD2\u2CD4\u2CD6\u2CD8\u2CDA\u2CDC\u2CDE\u2CE0\u2CE2\u2CEB\u2CED\u2CF2\uA640\uA642\uA644\uA646\uA648\uA64A\uA64C\uA64E\uA650\uA652\uA654\uA656\uA658\uA65A\uA65C\uA65E\uA660\uA662\uA664\uA666\uA668\uA66A\uA66C\uA680\uA682\uA684\uA686\uA688\uA68A\uA68C\uA68E\uA690\uA692\uA694\uA696\uA698\uA69A\uA722\uA724\uA726\uA728\uA72A\uA72C\uA72E\uA732\uA734\uA736\uA738\uA73A\uA73C\uA73E\uA740\uA742\uA744\uA746\uA748\uA74A\uA74C\uA74E\uA750\uA752\uA754\uA756\uA758\uA75A\uA75C\uA75E\uA760\uA762\uA764\uA766\uA768\uA76A\uA76C\uA76E\uA779\uA77B\uA77D-\uA77E\uA780\uA782\uA784\uA786\uA78B\uA78D\uA790\uA792\uA796\uA798\uA79A\uA79C\uA79E\uA7A0\uA7A2\uA7A4\uA7A6\uA7A8\uA7AA-\uA7AE\uA7B0-\uA7B4\uA7B6\uA7B8\uFF21-\uFF3A]
// Number, Letter
Nl = [\u16EE-\u16F0\u2160-\u2182\u2185-\u2188\u3007\u3021-\u3029\u3038-\u303A\uA6E6-\uA6EF]

View file

@ -45,6 +45,17 @@ const applyOptions = (parent, enter) => (pat, i) => {
pat = pat.fmap((a) => (b) => Array.isArray(a) ? [...a, b] : [a, b]).appLeft(friend);
break;
}
case 'range': {
const friend = enter(op.arguments_.element);
pat = strudel.reify(pat);
const arrayRange = (start, stop, step = 1) =>
Array.from({ length: Math.abs(stop - start) / step + 1 }, (value, index) =>
start < stop ? start + index * step : start - index * step,
);
let range = (apat, bpat) => apat.squeezeBind((a) => bpat.bind((b) => strudel.fastcat(...arrayRange(a, b))));
pat = range(pat, friend);
break;
}
default: {
console.warn(`operator "${op.type_}" not implemented`);
}

View file

@ -184,6 +184,12 @@ describe('mini', () => {
it('supports lists', () => {
expect(minV('a:b c:d:[e:f] g')).toEqual([['a', 'b'], ['c', 'd', ['e', 'f']], 'g']);
});
it('supports ranges', () => {
expect(minV('0 .. 4')).toEqual([0, 1, 2, 3, 4]);
});
it('supports patterned ranges', () => {
expect(minS('[<0 1> .. <2 4>]*2')).toEqual(minS('[0 1 2] [1 2 3 4]'));
});
});
describe('getLeafLocation', () => {

View file

@ -34,6 +34,6 @@ Now open the REPL and type:
s("<bd sd> hh").osc()
```
or just [click here](https://strudel.tidalcycles.org/#cygiPGJkIHNkPiBoaCIpLm9zYygp)...
or just [click here](https://strudel.cc/#cygiPGJkIHNkPiBoaCIpLm9zYygp)...
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.tidalcycles.org/learn/input-output/#superdirt-api)
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)

View file

@ -37,7 +37,7 @@ function connect() {
/**
*
* Sends each hap as an OSC message, which can be picked up by SuperCollider or any other OSC-enabled software.
* For more info, read [MIDI & OSC in the docs](https://strudel.tidalcycles.org/learn/input-output)
* For more info, read [MIDI & OSC in the docs](https://strudel.cc/learn/input-output)
*
* @name osc
* @memberof Pattern

View file

@ -21,12 +21,12 @@ import { samples, initAudioOnFirstClick } from '@strudel.cycles/webaudio';
async function prebake() {
await samples(
'https://strudel.tidalcycles.org/tidal-drum-machines.json',
'https://strudel.cc/tidal-drum-machines.json',
'github:ritchse/tidal-drum-machines/main/machines/'
);
await samples(
'https://strudel.tidalcycles.org/EmuSP12.json',
'https://strudel.tidalcycles.org/EmuSP12/'
'https://strudel.cc/EmuSP12.json',
'https://strudel.cc/EmuSP12/'
);
}

View file

@ -33,12 +33,17 @@
"homepage": "https://github.com/tidalcycles/strudel#readme",
"dependencies": {
"@codemirror/autocomplete": "^6.6.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-javascript": "^6.1.7",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.2.0",
"@codemirror/view": "^6.10.0",
"@lezer/highlight": "^1.1.4",
"@replit/codemirror-emacs": "^6.0.1",
"@replit/codemirror-vim": "^6.0.14",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@strudel.cycles/core": "workspace:*",
"@strudel.cycles/transpiler": "workspace:*",
"@strudel.cycles/webaudio": "workspace:*",

View file

@ -26,7 +26,6 @@ export function Autocomplete({ doc }) {
<pre
className="cursor-pointer"
onMouseDown={(e) => {
console.log('ola!');
navigator.clipboard.writeText(example);
e.stopPropagation();
}}

View file

@ -1,12 +1,15 @@
import { autocompletion } from '@codemirror/autocomplete';
import { Prec } from '@codemirror/state';
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
import { EditorView } from '@codemirror/view';
import { ViewPlugin, EditorView, keymap } from '@codemirror/view';
import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import _CodeMirror from '@uiw/react-codemirror';
import React, { useCallback, useMemo } from 'react';
import strudelTheme from '../themes/strudel-theme';
import { strudelAutocomplete } from './Autocomplete';
import { strudelTooltip } from './Tooltip';
import {
highlightExtension,
flashField,
@ -15,10 +18,11 @@ import {
updateMiniLocations,
} from '@strudel/codemirror';
import './style.css';
import { sliderPlugin } from '@strudel/codemirror/slider.mjs';
export { flash, highlightMiniLocations, updateMiniLocations };
const staticExtensions = [javascript(), flashField, highlightExtension];
const staticExtensions = [javascript(), flashField, highlightExtension, sliderPlugin];
export default function CodeMirror({
value,
@ -30,6 +34,7 @@ export default function CodeMirror({
keybindings,
isLineNumbersDisplayed,
isAutoCompletionEnabled,
isTooltipEnabled,
isLineWrappingEnabled,
fontSize = 18,
fontFamily = 'monospace',
@ -60,11 +65,25 @@ export default function CodeMirror({
[onSelectionChange],
);
const vscodePlugin = ViewPlugin.fromClass(
class {
constructor(view) {}
},
{
provide: (plugin) => {
return Prec.highest(keymap.of([...vscodeKeymap]));
},
},
);
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
const extensions = useMemo(() => {
let _extensions = [...staticExtensions];
let bindings = {
vim,
emacs,
vscode: vscodeExtension,
};
if (bindings[keybindings]) {
@ -77,12 +96,18 @@ export default function CodeMirror({
_extensions.push(autocompletion({ override: [] }));
}
if (isTooltipEnabled) {
_extensions.push(strudelTooltip);
}
_extensions.push([keymap.of({})]);
if (isLineWrappingEnabled) {
_extensions.push(EditorView.lineWrapping);
}
return _extensions;
}, [keybindings, isAutoCompletionEnabled, isLineWrappingEnabled]);
}, [keybindings, isAutoCompletionEnabled, isTooltipEnabled, isLineWrappingEnabled]);
const basicSetup = useMemo(() => ({ lineNumbers: isLineNumbersDisplayed }), [isLineNumbersDisplayed]);

View file

@ -0,0 +1,69 @@
import { createRoot } from 'react-dom/client';
import { hoverTooltip } from '@codemirror/view';
import jsdoc from '../../../../doc.json';
import { Autocomplete } from './Autocomplete';
const getDocLabel = (doc) => doc.name || doc.longname;
let ctrlDown = false;
// Record Control key event to trigger or block the tooltip depending on the state
window.addEventListener(
'keyup',
function (e) {
if (e.key == 'Control') {
ctrlDown = false;
}
},
true,
);
window.addEventListener(
'keydown',
function (e) {
if (e.key == 'Control') {
ctrlDown = true;
}
},
true,
);
export const strudelTooltip = hoverTooltip(
(view, pos, side) => {
// Word selection from CodeMirror Hover Tooltip example https://codemirror.net/examples/tooltip/#hover-tooltips
let { from, to, text } = view.state.doc.lineAt(pos);
let start = pos,
end = pos;
while (start > from && /\w/.test(text[start - from - 1])) {
start--;
}
while (end < to && /\w/.test(text[end - from])) {
end++;
}
if ((start == pos && side < 0) || (end == pos && side > 0)) {
return null;
}
let word = text.slice(start - from, end - from);
// Get entry from Strudel documentation
let entry = jsdoc.docs.filter((doc) => getDocLabel(doc) === word)[0];
if (!entry) {
return null;
}
if (!ctrlDown) {
return null;
}
return {
pos: start,
end,
above: false,
arrow: true,
create(view) {
let dom = document.createElement('div');
dom.className = 'strudel-tooltip';
createRoot(dom).render(<Autocomplete doc={entry} />);
return { dom };
},
};
},
{ hoverTime: 10 },
);

View file

@ -28,3 +28,7 @@
footer {
z-index: 0 !important;
}
.strudel-tooltip {
padding: 5px;
}

View file

@ -0,0 +1,13 @@
import { useEffect, useState } from 'react';
import { updateWidgets } from '@strudel/codemirror';
// i know this is ugly.. in the future, repl needs to run without react
export function useWidgets(view) {
const [widgets, setWidgets] = useState([]);
useEffect(() => {
if (view) {
updateWidgets(view, widgets);
}
}, [view, widgets]);
return { widgets, setWidgets };
}

View file

@ -1,7 +1,7 @@
# superdough
superdough is a simple web audio sampler and synth, intended for live coding.
It is the default output of [strudel](https://strudel.tidalcycles.org/).
It is the default output of [strudel](https://strudel.cc/).
This package has no ties to strudel and can be used to quickly bake your own music system on the web.
## Install

View file

@ -0,0 +1,79 @@
import { getAudioContext } from './superdough.mjs';
let worklet;
export async function dspWorklet(ac, code) {
const name = `dsp-worklet-${Date.now()}`;
const workletCode = `${code}
let __q = []; // trigger queue
class MyProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.t = 0;
this.stopped = false;
this.port.onmessage = (e) => {
if(e.data==='stop') {
this.stopped = true;
} else if(e.data?.dough) {
__q.push(e.data)
} else {
msg?.(e.data)
}
};
}
process(inputs, outputs, parameters) {
const output = outputs[0];
if(__q.length) {
for(let i=0;i<__q.length;++i) {
const deadline = __q[i].time-currentTime;
if(deadline<=0) {
trigger(__q[i].dough)
__q.splice(i,1)
}
}
}
for (let i = 0; i < output[0].length; i++) {
const out = dsp(this.t / sampleRate);
output.forEach((channel) => {
channel[i] = out;
});
this.t++;
}
return !this.stopped;
}
}
registerProcessor('${name}', MyProcessor);
`;
const base64String = btoa(workletCode);
const dataURL = `data:text/javascript;base64,${base64String}`;
await ac.audioWorklet.addModule(dataURL);
const node = new AudioWorkletNode(ac, name);
const stop = () => node.port.postMessage('stop');
return { node, stop };
}
const stop = () => {
if (worklet) {
worklet?.stop();
worklet?.node?.disconnect();
}
};
if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => {
if (e.data === 'strudel-stop') {
stop();
} else if (e.data?.dough) {
worklet?.node.port.postMessage(e.data);
}
});
}
export const dough = async (code) => {
const ac = getAudioContext();
stop();
worklet = await dspWorklet(ac, code);
worklet.node.connect(ac.destination);
};
export function doughTrigger(t, hap, currentTime, duration, cps) {
window.postMessage({ time: t, dough: hap.value, currentTime, duration, cps });
}

View file

@ -78,6 +78,17 @@ export const getParamADSR = (param, attack, decay, sustain, release, min, max, b
param.linearRampToValueAtTime(min, end + Math.max(release, 0.1));
};
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
const options = {
threshold: threshold ?? -3,
ratio: ratio ?? 10,
knee: knee ?? 10,
attack: attack ?? 0.005,
release: release ?? 0.05,
};
return new DynamicsCompressorNode(ac, options);
}
export function createFilter(
context,
type,
@ -112,3 +123,25 @@ export function createFilter(
return filter;
}
// stays 1 until .5, then fades out
let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5);
// mix together dry and wet nodes. 0 = only dry 1 = only wet
// still not too sure about how this could be used more generally...
export function drywet(dry, wet, wetAmount = 0) {
const ac = getAudioContext();
if (!wetAmount) {
return dry;
}
let dry_gain = ac.createGain();
let wet_gain = ac.createGain();
dry.connect(dry_gain);
wet.connect(wet_gain);
dry_gain.gain.value = wetfade(wetAmount);
wet_gain.gain.value = wetfade(1 - wetAmount);
let mix = ac.createGain();
dry_gain.connect(mix);
wet_gain.connect(mix);
return mix;
}

View file

@ -10,3 +10,4 @@ export * from './helpers.mjs';
export * from './synth.mjs';
export * from './zzfx.mjs';
export * from './logger.mjs';
export * from './dspworklet.mjs';

View file

@ -0,0 +1,63 @@
import { drywet } from './helpers.mjs';
import { getAudioContext } from './superdough.mjs';
let noiseCache = {};
// lazy generates noise buffers and keeps them forever
function getNoiseBuffer(type) {
const ac = getAudioContext();
if (noiseCache[type]) {
return noiseCache[type];
}
const bufferSize = 2 * ac.sampleRate;
const noiseBuffer = ac.createBuffer(1, bufferSize, ac.sampleRate);
const output = noiseBuffer.getChannelData(0);
let lastOut = 0;
let b0, b1, b2, b3, b4, b5, b6;
b0 = b1 = b2 = b3 = b4 = b5 = b6 = 0.0;
for (let i = 0; i < bufferSize; i++) {
if (type === 'white') {
output[i] = Math.random() * 2 - 1;
} else if (type === 'brown') {
let white = Math.random() * 2 - 1;
output[i] = (lastOut + 0.02 * white) / 1.02;
lastOut = output[i];
} else if (type === 'pink') {
let white = Math.random() * 2 - 1;
b0 = 0.99886 * b0 + white * 0.0555179;
b1 = 0.99332 * b1 + white * 0.0750759;
b2 = 0.969 * b2 + white * 0.153852;
b3 = 0.8665 * b3 + white * 0.3104856;
b4 = 0.55 * b4 + white * 0.5329522;
b5 = -0.7616 * b5 - white * 0.016898;
output[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
output[i] *= 0.11;
b6 = white * 0.115926;
}
}
noiseCache[type] = noiseBuffer;
return noiseBuffer;
}
// expects one of noises as type
export function getNoiseOscillator(type = 'white', t) {
const ac = getAudioContext();
const o = ac.createBufferSource();
o.buffer = getNoiseBuffer(type);
o.loop = true;
o.start(t);
return {
node: o,
stop: (time) => o.stop(time),
};
}
export function getNoiseMix(inputNode, wet, t) {
const noiseOscillator = getNoiseOscillator('pink', t);
const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
return {
node: noiseMix,
stop: (time) => noiseOscillator?.stop(time),
};
}

View file

@ -1,6 +1,6 @@
{
"name": "superdough",
"version": "0.9.8",
"version": "0.9.10",
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
"main": "index.mjs",
"type": "module",

View file

@ -1,23 +1,47 @@
import reverbGen from './reverbGen.mjs';
if (typeof AudioContext !== 'undefined') {
AudioContext.prototype.impulseResponse = function (duration, channels = 1) {
const length = this.sampleRate * duration;
const impulse = this.createBuffer(channels, length, this.sampleRate);
const IR = impulse.getChannelData(0);
for (let i = 0; i < length; i++) IR[i] = (2 * Math.random() - 1) * Math.pow(1 - i / length, duration);
return impulse;
AudioContext.prototype.adjustLength = function (duration, buffer) {
const newLength = buffer.sampleRate * duration;
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
let oldData = buffer.getChannelData(channel);
let newData = newBuffer.getChannelData(channel);
for (let i = 0; i < newLength; i++) {
newData[i] = oldData[i] || 0;
}
}
return newBuffer;
};
AudioContext.prototype.createReverb = function (duration) {
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir) {
const convolver = this.createConvolver();
convolver.setDuration = (d) => {
convolver.buffer = this.impulseResponse(d);
convolver.duration = duration;
return convolver;
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir) => {
convolver.duration = d;
convolver.fade = fade;
convolver.lp = lp;
convolver.dim = dim;
convolver.ir = ir;
if (ir) {
convolver.buffer = this.adjustLength(d, ir);
} else {
reverbGen.generateReverb(
{
audioContext: this,
numChannels: 2,
decayTime: d,
fadeInTime: fade,
lpFreqStart: lp,
lpFreqEnd: dim,
},
(buffer) => {
convolver.buffer = buffer;
},
);
}
};
convolver.setDuration(duration);
convolver.generate(duration, fade, lp, dim, ir);
return convolver;
};
}
// TODO: make the reverb more exciting
// check out https://blog.gskinner.com/archives/2019/02/reverb-web-audio-api.html

View file

@ -0,0 +1,130 @@
// Copyright 2014 Alan deLespinasse
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
var reverbGen = {};
/** Generates a reverb impulse response.
@param {!Object} params TODO: Document the properties.
@param {!function(!AudioBuffer)} callback Function to call when
the impulse response has been generated. The impulse response
is passed to this function as its parameter. May be called
immediately within the current execution context, or later. */
reverbGen.generateReverb = function (params, callback) {
var audioContext = params.audioContext || new AudioContext();
var sampleRate = audioContext.sampleRate;
var numChannels = params.numChannels || 2;
// params.decayTime is the -60dB fade time. We let it go 50% longer to get to -90dB.
var totalTime = params.decayTime * 1.5;
var decaySampleFrames = Math.round(params.decayTime * sampleRate);
var numSampleFrames = Math.round(totalTime * sampleRate);
var fadeInSampleFrames = Math.round((params.fadeInTime || 0) * sampleRate);
// 60dB is a factor of 1 million in power, or 1000 in amplitude.
var decayBase = Math.pow(1 / 1000, 1 / decaySampleFrames);
var reverbIR = audioContext.createBuffer(numChannels, numSampleFrames, sampleRate);
for (var i = 0; i < numChannels; i++) {
var chan = reverbIR.getChannelData(i);
for (var j = 0; j < numSampleFrames; j++) {
chan[j] = randomSample() * Math.pow(decayBase, j);
}
for (var j = 0; j < fadeInSampleFrames; j++) {
chan[j] *= j / fadeInSampleFrames;
}
}
applyGradualLowpass(reverbIR, params.lpFreqStart || 0, params.lpFreqEnd || 0, params.decayTime, callback);
};
/** Creates a canvas element showing a graph of the given data.
@param {!Float32Array} data An array of numbers, or a Float32Array.
@param {number} width Width in pixels of the canvas.
@param {number} height Height in pixels of the canvas.
@param {number} min Minimum value of data for the graph (lower edge).
@param {number} max Maximum value of data in the graph (upper edge).
@return {!CanvasElement} The generated canvas element. */
reverbGen.generateGraph = function (data, width, height, min, max) {
var canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
var gc = canvas.getContext('2d');
gc.fillStyle = '#000';
gc.fillRect(0, 0, canvas.width, canvas.height);
gc.fillStyle = '#fff';
var xscale = width / data.length;
var yscale = height / (max - min);
for (var i = 0; i < data.length; i++) {
gc.fillRect(i * xscale, height - (data[i] - min) * yscale, 1, 1);
}
return canvas;
};
/** Applies a constantly changing lowpass filter to the given sound.
@private
@param {!AudioBuffer} input
@param {number} lpFreqStart
@param {number} lpFreqEnd
@param {number} lpFreqEndAt
@param {!function(!AudioBuffer)} callback May be called
immediately within the current execution context, or later.*/
var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt, callback) {
if (lpFreqStart == 0) {
callback(input);
return;
}
var channelData = getAllChannelData(input);
var context = new OfflineAudioContext(input.numberOfChannels, channelData[0].length, input.sampleRate);
var player = context.createBufferSource();
player.buffer = input;
var filter = context.createBiquadFilter();
lpFreqStart = Math.min(lpFreqStart, input.sampleRate / 2);
lpFreqEnd = Math.min(lpFreqEnd, input.sampleRate / 2);
filter.type = 'lowpass';
filter.Q.value = 0.0001;
filter.frequency.setValueAtTime(lpFreqStart, 0);
filter.frequency.linearRampToValueAtTime(lpFreqEnd, lpFreqEndAt);
player.connect(filter);
filter.connect(context.destination);
player.start();
context.oncomplete = function (event) {
callback(event.renderedBuffer);
};
context.startRendering();
window.filterNode = filter;
};
/** @private
@param {!AudioBuffer} buffer
@return {!Array.<!Float32Array>} An array containing the Float32Array of each channel's samples. */
var getAllChannelData = function (buffer) {
var channels = [];
for (var i = 0; i < buffer.numberOfChannels; i++) {
channels[i] = buffer.getChannelData(i);
}
return channels;
};
/** @private
@return {number} A random number from -1 to 1. */
var randomSample = function () {
return Math.random() * 2 - 1;
};
export default reverbGen;

View file

@ -76,6 +76,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, vib, vibmod
export const loadBuffer = (url, ac, s, n = 0) => {
const label = s ? `sound "${s}:${n}"` : 'sample';
url = url.replace('#', '%23');
if (!loadCache[url]) {
logger(`[sampler] load ${label}..`, 'load-sample', { url });
const timestamp = Date.now();
@ -158,7 +159,12 @@ function getSamplesPrefixHandler(url) {
* sd: '808sd/SD0010.WAV'
* }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/');
* s("[bd ~]*2, [~ hh]*2, ~ sd")
*
* @example
* samples('shabda:noise,chimp:2')
* s("noise <chimp:0*2 chimp:1>")
* @example
* samples('shabda/speech/fr-FR/f:chocolat')
* s("chocolat*4")
*/
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
@ -168,11 +174,34 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option
if (handler) {
return handler(sampleMap);
}
if (sampleMap.startsWith('bubo:')) {
const [_, repo] = sampleMap.split(':');
sampleMap = `github:Bubobubobubobubo/dough-${repo}`;
}
if (sampleMap.startsWith('github:')) {
let [_, path] = sampleMap.split('github:');
path = path.endsWith('/') ? path.slice(0, -1) : path;
if (path.split('/').length === 2) {
// assume main as default branch if none set
path += '/main';
}
sampleMap = `https://raw.githubusercontent.com/${path}/strudel.json`;
}
if (sampleMap.startsWith('shabda:')) {
let [_, path] = sampleMap.split('shabda:');
sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`;
}
if (sampleMap.startsWith('shabda/speech')) {
let [_, path] = sampleMap.split('shabda/speech');
path = path.startsWith('/') ? path.substring(1) : path;
let [params, words] = path.split(':');
let gender = 'f';
let language = 'en-GB';
if (params) {
[language, gender] = params.split('/');
}
sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
}
if (typeof fetch !== 'function') {
// not a browser
return;

View file

@ -9,17 +9,21 @@ import './reverb.mjs';
import './vowel.mjs';
import { clamp } from './util.mjs';
import workletsUrl from './worklets.mjs?url';
import { createFilter, gainNode } from './helpers.mjs';
import { createFilter, gainNode, getCompressor } from './helpers.mjs';
import { map } from 'nanostores';
import { logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs';
export const soundMap = map();
export function registerSound(key, onTrigger, data = {}) {
soundMap.setKey(key, { onTrigger, data });
}
export function getSound(s) {
return soundMap.get()[s];
}
export const resetLoadedSounds = () => soundMap.set({});
let audioContext;
@ -46,6 +50,7 @@ export const panic = () => {
};
let workletsLoading;
function loadWorklets() {
if (workletsLoading) {
return workletsLoading;
@ -89,6 +94,7 @@ export async function initAudioOnFirstClick(options) {
let delays = {};
const maxfeedback = 0.98;
function getDelay(orbit, delaytime, delayfeedback, t) {
if (delayfeedback > maxfeedback) {
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
@ -107,21 +113,36 @@ function getDelay(orbit, delaytime, delayfeedback, t) {
}
let reverbs = {};
function getReverb(orbit, duration = 2) {
let hasChanged = (now, before) => now !== undefined && now !== before;
function getReverb(orbit, duration, fade, lp, dim, ir) {
// If no reverb has been created for a given orbit, create one
if (!reverbs[orbit]) {
const ac = getAudioContext();
const reverb = ac.createReverb(duration);
const reverb = ac.createReverb(duration, fade, lp, dim, ir);
reverb.connect(getDestination());
reverbs[orbit] = reverb;
}
if (reverbs[orbit].duration !== duration) {
reverbs[orbit] = reverbs[orbit].setDuration(duration);
reverbs[orbit].duration = duration;
if (
hasChanged(duration, reverbs[orbit].duration) ||
hasChanged(fade, reverbs[orbit].fade) ||
hasChanged(lp, reverbs[orbit].lp) ||
hasChanged(dim, reverbs[orbit].dim) ||
reverbs[orbit].ir !== ir
) {
// only regenerate when something has changed
// avoids endless regeneration on things like
// stack(s("a"), s("b").rsize(8)).room(.5)
// this only works when args may stay undefined until here
// setting default values breaks this
reverbs[orbit].generate(duration, fade, lp, dim, ir);
}
return reverbs[orbit];
}
export let analyser, analyserData /* s = {} */;
export function getAnalyser(/* orbit, */ fftSize = 2048) {
if (!analyser /*s [orbit] */) {
const analyserNode = getAudioContext().createAnalyser();
@ -177,6 +198,7 @@ export const superdough = async (value, deadline, hapDuration) => {
bank,
source,
gain = 0.8,
postgain = 1,
// filters
ftype = '12db',
fanchor = 0.5,
@ -215,10 +237,20 @@ export const superdough = async (value, deadline, hapDuration) => {
delaytime = 0.25,
orbit = 1,
room,
size = 2,
roomfade,
roomlp,
roomdim,
roomsize,
ir,
i = 0,
velocity = 1,
analyze, // analyser wet
fft = 8, // fftSize 0 - 10
compressor: compressorThreshold,
compressorRatio,
compressorKnee,
compressorAttack,
compressorRelease,
} = value;
gain *= velocity; // legacy fix for velocity
let toDisconnect = []; // audio nodes that will be disconnected when the source has ended
@ -247,6 +279,7 @@ export const superdough = async (value, deadline, hapDuration) => {
// this can be used for things like speed(0) in the sampler
return;
}
if (ac.currentTime > t) {
logger('[webaudio] skip hap: still loading', ac.currentTime - t);
return;
@ -333,6 +366,11 @@ export const superdough = async (value, deadline, hapDuration) => {
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape }));
compressorThreshold !== undefined &&
chain.push(
getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease),
);
// panning
if (pan !== undefined) {
const panner = ac.createStereoPanner();
@ -341,7 +379,7 @@ export const superdough = async (value, deadline, hapDuration) => {
}
// last gain
const post = gainNode(1);
const post = gainNode(postgain);
chain.push(post);
post.connect(getDestination());
@ -353,8 +391,19 @@ export const superdough = async (value, deadline, hapDuration) => {
}
// reverb
let reverbSend;
if (room > 0 && size > 0) {
const reverbNode = getReverb(orbit, size);
if (room > 0) {
let roomIR;
if (ir !== undefined) {
let url;
let sample = getSound(ir);
if (Array.isArray(sample)) {
url = sample.data.samples[i % sample.data.samples.length];
} else if (typeof sample === 'object') {
url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length];
}
roomIR = await loadBuffer(url, ac, ir, 0);
}
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR);
reverbSend = effectSend(post, reverbNode, room);
}

View file

@ -1,6 +1,7 @@
import { midiToFreq, noteToMidi } from './util.mjs';
import { registerSound, getAudioContext } from './superdough.mjs';
import { gainNode, getEnvelope, getExpEnvelope } from './helpers.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
const mod = (freq, range = 1, type = 'sine') => {
const ctx = getAudioContext();
@ -20,75 +21,26 @@ const fm = (osc, harmonicityRatio, modulationIndex, wave = 'sine') => {
return mod(modfreq, modgain, wave);
};
const waveforms = ['sine', 'square', 'triangle', 'sawtooth'];
const noises = ['pink', 'white', 'brown'];
export function registerSynthSounds() {
['sine', 'square', 'triangle', 'sawtooth'].forEach((wave) => {
[...waveforms, ...noises].forEach((s) => {
registerSound(
wave,
s,
(t, value, onended) => {
// destructure adsr here, because the default should be different for synths and samples
let {
attack = 0.001,
decay = 0.05,
sustain = 0.6,
release = 0.01,
fmh: fmHarmonicity = 1,
fmi: fmModulationIndex,
fmenv: fmEnvelopeType = 'lin',
fmattack: fmAttack,
fmdecay: fmDecay,
fmsustain: fmSustain,
fmrelease: fmRelease,
fmvelocity: fmVelocity,
fmwave: fmWaveform = 'sine',
vib = 0,
vibmod = 0.5,
} = value;
let { n, note, freq } = value;
// with synths, n and note are the same thing
note = note || 36;
if (typeof note === 'string') {
note = noteToMidi(note); // e.g. c3 => 48
}
// get frequency
if (!freq && typeof note === 'number') {
freq = midiToFreq(note); // + 48);
}
// maybe pull out the above frequency resolution?? (there is also getFrequency but it has no default)
// make oscillator
const { node: o, stop } = getOscillator({
t,
s: wave,
freq,
vib,
vibmod,
partials: n,
});
let { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = value;
// FM + FM envelope
let stopFm, fmEnvelope;
if (fmModulationIndex) {
const { node: modulator, stop } = fm(o, fmHarmonicity, fmModulationIndex, fmWaveform);
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) {
// no envelope by default
modulator.connect(o.frequency);
} else {
fmAttack = fmAttack ?? 0.001;
fmDecay = fmDecay ?? 0.001;
fmSustain = fmSustain ?? 1;
fmRelease = fmRelease ?? 0.001;
fmVelocity = fmVelocity ?? 1;
fmEnvelope = getEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t);
if (fmEnvelopeType === 'exp') {
fmEnvelope = getExpEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t);
fmEnvelope.node.maxValue = fmModulationIndex * 2;
fmEnvelope.node.minValue = 0.00001;
}
modulator.connect(fmEnvelope.node);
fmEnvelope.node.connect(o.frequency);
}
stopFm = stop;
let sound;
if (waveforms.includes(s)) {
sound = getOscillator(s, t, value);
} else {
sound = getNoiseOscillator(s, t);
}
let { node: o, stop, triggerRelease } = sound;
// turn down
const g = gainNode(0.3);
@ -104,10 +56,9 @@ export function registerSynthSounds() {
node: o.connect(g).connect(envelope),
stop: (releaseTime) => {
releaseEnvelope(releaseTime);
fmEnvelope?.stop(releaseTime);
triggerRelease?.(releaseTime);
let end = releaseTime + release;
stop(end);
stopFm?.(end);
},
};
},
@ -122,13 +73,13 @@ export function waveformN(partials, type) {
const ac = getAudioContext();
const osc = ac.createOscillator();
const amplitudes = {
sawtooth: (n) => 1 / n,
square: (n) => (n % 2 === 0 ? 0 : 1 / n),
triangle: (n) => (n % 2 === 0 ? 0 : 1 / (n * n)),
const terms = {
sawtooth: (n) => [0, -1 / n],
square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
};
if (!amplitudes[type]) {
if (!terms[type]) {
throw new Error(`unknown wave type ${type}`);
}
@ -136,8 +87,9 @@ export function waveformN(partials, type) {
imag[0] = 0;
let n = 1;
while (n <= partials) {
real[n] = amplitudes[type](n);
imag[n] = 0;
const [r, i] = terms[type](n);
real[n] = r;
imag[n] = i;
n++;
}
@ -146,36 +98,108 @@ export function waveformN(partials, type) {
return osc;
}
export function getOscillator({ s, freq, t, vib, vibmod, partials }) {
// Make oscillator with partial count
// expects one of waveforms as s
export function getOscillator(
s,
t,
{
n: partials,
note,
freq,
vib = 0,
vibmod = 0.5,
noise = 0,
// fm
fmh: fmHarmonicity = 1,
fmi: fmModulationIndex,
fmenv: fmEnvelopeType = 'lin',
fmattack: fmAttack,
fmdecay: fmDecay,
fmsustain: fmSustain,
fmrelease: fmRelease,
fmvelocity: fmVelocity,
fmwave: fmWaveform = 'sine',
},
) {
let ac = getAudioContext();
let o;
// If no partials are given, use stock waveforms
if (!partials || s === 'sine') {
o = getAudioContext().createOscillator();
o.type = s || 'triangle';
} else {
}
// generate custom waveform if partials are given
else {
o = waveformN(partials, s);
}
// get frequency from note...
note = note || 36;
if (typeof note === 'string') {
note = noteToMidi(note); // e.g. c3 => 48
}
// get frequency
if (!freq && typeof note === 'number') {
freq = midiToFreq(note); // + 48);
}
// set frequency
o.frequency.value = Number(freq);
o.start(t);
// FM
let stopFm, fmEnvelope;
if (fmModulationIndex) {
const { node: modulator, stop } = fm(o, fmHarmonicity, fmModulationIndex, fmWaveform);
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) {
// no envelope by default
modulator.connect(o.frequency);
} else {
fmAttack = fmAttack ?? 0.001;
fmDecay = fmDecay ?? 0.001;
fmSustain = fmSustain ?? 1;
fmRelease = fmRelease ?? 0.001;
fmVelocity = fmVelocity ?? 1;
fmEnvelope = getEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t);
if (fmEnvelopeType === 'exp') {
fmEnvelope = getExpEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t);
fmEnvelope.node.maxValue = fmModulationIndex * 2;
fmEnvelope.node.minValue = 0.00001;
}
modulator.connect(fmEnvelope.node);
fmEnvelope.node.connect(o.frequency);
}
stopFm = stop;
}
// Additional oscillator for vibrato effect
let vibrato_oscillator;
let vibratoOscillator;
if (vib > 0) {
vibrato_oscillator = getAudioContext().createOscillator();
vibrato_oscillator.frequency.value = vib;
vibratoOscillator = getAudioContext().createOscillator();
vibratoOscillator.frequency.value = vib;
const gain = getAudioContext().createGain();
// Vibmod is the amount of vibrato, in semitones
gain.gain.value = vibmod * 100;
vibrato_oscillator.connect(gain);
vibratoOscillator.connect(gain);
gain.connect(o.detune);
vibrato_oscillator.start(t);
vibratoOscillator.start(t);
}
let noiseMix;
if (noise) {
noiseMix = getNoiseMix(o, noise, t);
}
return {
node: o,
node: noiseMix?.node || o,
stop: (time) => {
vibrato_oscillator?.stop(time);
vibratoOscillator?.stop(time);
noiseMix?.stop(time);
stopFm?.(time);
o.stop(time);
},
triggerRelease: (time) => {
fmEnvelope?.stop(time);
},
};
}

View file

@ -20,7 +20,7 @@ export const getZZFX = (value, t) => {
pitchJump = 0,
pitchJumpTime = 0,
lfo = 0,
noise = 0,
znoise = 0,
zmod = 0,
zcrush = 0,
zdelay = 0,
@ -54,7 +54,7 @@ export const getZZFX = (value, t) => {
pitchJump,
pitchJumpTime,
lfo,
noise,
znoise,
zmod,
zcrush,
zdelay,

View file

@ -31,4 +31,4 @@ yields:
## Tonal API
See "Tonal API" in the [Strudel Tutorial](https://strudel.tidalcycles.org/learn/tonal)
See "Tonal API" in the [Strudel Tutorial](https://strudel.cc/learn/tonal)

View file

@ -7,6 +7,19 @@ This program is free software: you can redistribute it and/or modify it under th
import { Note, Interval, Scale } from '@tonaljs/tonal';
import { register, _mod } from '@strudel.cycles/core';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
function scaleStep(step, scale) {
scale = scale.replaceAll(':', ' ');
step = Math.ceil(step);
const { intervals, tonic } = Scale.get(scale);
const { pc, oct = 3 } = Note.get(tonic);
const octaveOffset = Math.floor(step / intervals.length);
const scaleStep = _mod(step, intervals.length);
const interval = Interval.add(intervals[scaleStep], octavesInterval(octaveOffset));
return Note.transpose(pc + oct, interval);
}
// transpose note inside scale by offset steps
// function scaleOffset(scale: string, offset: number, note: string) {
function scaleOffset(scale, offset, note) {
@ -150,13 +163,12 @@ export const scale = register('scale', function (scale, pat) {
return pat.withHap((hap) => {
const isObject = typeof hap.value === 'object';
let note = isObject ? hap.value.n : hap.value;
if (isObject) {
delete hap.value.n; // remove n so it won't cause trouble
}
const asNumber = Number(note);
if (!isNaN(asNumber)) {
// TODO: worth keeping for supporting ':' in (non-mininotation) strings?
scale = scale.replaceAll(':', ' ');
let [tonic, scaleName] = Scale.tokenize(scale);
const { pc, oct = 3 } = Note.get(tonic);
note = scaleOffset(pc + ' ' + scaleName, asNumber, pc + oct);
note = scaleStep(asNumber, scale);
}
return hap.withValue(() => (isObject ? { ...hap.value, note } : note)).setContext({ ...hap.context, scale });
});

View file

@ -5,7 +5,7 @@ import { isNoteWithOctave } from '@strudel.cycles/core';
import { getLeafLocations } from '@strudel.cycles/mini';
export function transpiler(input, options = {}) {
const { wrapAsync = false, addReturn = true, emitMiniLocations = true } = options;
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
let ast = parse(input, {
ecmaVersion: 2022,
@ -16,9 +16,9 @@ export function transpiler(input, options = {}) {
let miniLocations = [];
const collectMiniLocations = (value, node) => {
const leafLocs = getLeafLocations(`"${value}"`, node.start); // stimmt!
//const withOffset = leafLocs.map((offsets) => offsets.map((o) => o + node.start));
miniLocations = miniLocations.concat(leafLocs);
};
let widgets = [];
walk(ast, {
enter(node, parent /* , prop, index */) {
@ -35,6 +35,18 @@ export function transpiler(input, options = {}) {
emitMiniLocations && collectMiniLocations(value, node);
return this.replace(miniWithLocation(value, node));
}
if (isWidgetFunction(node)) {
emitWidgets &&
widgets.push({
from: node.arguments[0].start,
to: node.arguments[0].end,
value: node.arguments[0].raw, // don't use value!
min: node.arguments[1]?.value ?? 0,
max: node.arguments[2]?.value ?? 1,
step: node.arguments[3]?.value,
});
return this.replace(widgetWithLocation(node));
}
// TODO: remove pseudo note variables?
if (node.type === 'Identifier' && isNoteWithOctave(node.name)) {
this.skip();
@ -64,15 +76,14 @@ export function transpiler(input, options = {}) {
if (!emitMiniLocations) {
return { output };
}
return { output, miniLocations };
return { output, miniLocations, widgets };
}
function isStringWithDoubleQuotes(node, locations, code) {
const { raw, type } = node;
if (type !== 'Literal') {
if (node.type !== 'Literal') {
return false;
}
return raw[0] === '"';
return node.raw[0] === '"';
}
function isBackTickString(node, parent) {
@ -94,3 +105,22 @@ function miniWithLocation(value, node) {
optional: false,
};
}
// these functions are connected to @strudel/codemirror -> slider.mjs
// maybe someday there will be pluggable transpiler functions, then move this there
function isWidgetFunction(node) {
return node.type === 'CallExpression' && node.callee.name === 'slider';
}
function widgetWithLocation(node) {
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
node.callee.name = 'sliderWithID';
return node;
}

View file

@ -51,7 +51,7 @@ document.getElementById('play').addEventListener('click',
)
```
You can learn [more about the `samples` function here](https://strudel.tidalcycles.org/learn/samples#loading-custom-samples).
You can learn [more about the `samples` function here](https://strudel.cc/learn/samples#loading-custom-samples).
### Evaluating Code
@ -72,7 +72,7 @@ document.getElementById('play').addEventListener('stop',
### Double vs Single Quotes
There is a tiny difference between the [Strudel REPL](https://strudel.tidalcycles.org/) and `@strudel/web`.
There is a tiny difference between the [Strudel REPL](https://strudel.cc/) and `@strudel/web`.
In the REPL you can use 'single quotes' for regular JS strings and "double quotes" for mini notation patterns.
In `@strudel/web`, it does not matter which types of quotes you're using.
There will probably be an escapte hatch for that in the future.

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="https://strudel.tidalcycles.org/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="https://strudel.cc/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@strudel/web REPL Example</title>
</head>

View file

@ -33,4 +33,4 @@ document.getElementById("stop").addEventListener("click", () => scheduler.stop()
[Play with the example codesandbox](https://codesandbox.io/s/amazing-dawn-gclfwg?file=/src/index.js).
Read more in the docs about [samples](https://strudel.tidalcycles.org/learn/samples/), [synths](https://strudel.tidalcycles.org/learn/synths/) and [effects](https://strudel.tidalcycles.org/learn/effects/).
Read more in the docs about [samples](https://strudel.cc/learn/samples/), [synths](https://strudel.cc/learn/synths/) and [effects](https://strudel.cc/learn/effects/).

View file

@ -3,7 +3,7 @@ import { analyser, getAnalyzerData } from 'superdough';
export function drawTimeScope(
analyser,
{ align = true, color = 'white', thickness = 3, scale = 0.25, pos = 0.75, next = 1, trigger = 0 } = {},
{ align = true, color = 'white', thickness = 3, scale = 0.25, pos = 0.75, trigger = 0 } = {},
) {
const ctx = getDrawContext();
const dataArray = getAnalyzerData('time');
@ -22,10 +22,9 @@ export function drawTimeScope(
const sliceWidth = (canvas.width * 1.0) / bufferSize;
let x = 0;
for (let i = triggerIndex; i < bufferSize; i++) {
const v = dataArray[i] + 1;
const y = (scale * (v - 1) + pos) * canvas.height;
const y = (pos - scale * (v - 1)) * canvas.height;
if (i === 0) {
ctx.moveTo(x, y);
@ -71,6 +70,18 @@ function clearScreen(smear = 0, smearRGB = `0,0,0`) {
}
}
/**
* Renders an oscilloscope for the frequency domain of the audio signal.
* @name fscope
* @param {string} color line color as hex or color name. defaults to white.
* @param {number} scale scales the y-axis. Defaults to 0.25
* @param {number} pos y-position relative to screen height. 0 = top, 1 = bottom of screen
* @param {number} lean y-axis alignment where 0 = top and 1 = bottom
* @param {number} min min value
* @param {number} max max value
* @example
* s("sawtooth").fscope()
*/
Pattern.prototype.fscope = function (config = {}) {
return this.analyze(1).draw(() => {
clearScreen(config.smear);
@ -78,6 +89,20 @@ Pattern.prototype.fscope = function (config = {}) {
});
};
/**
* Renders an oscilloscope for the time domain of the audio signal.
* @name scope
* @synonyms tscope
* @param {object} config optional config with options:
* @param {boolean} align if 1, the scope will be aligned to the first zero crossing. defaults to 1
* @param {string} color line color as hex or color name. defaults to white.
* @param {number} thickness line thickness. defaults to 3
* @param {number} scale scales the y-axis. Defaults to 0.25
* @param {number} pos y-position relative to screen height. 0 = top, 1 = bottom of screen
* @param {number} trigger amplitude value that is used to align the scope. defaults to 0.
* @example
* s("sawtooth").scope()
*/
Pattern.prototype.tscope = function (config = {}) {
return this.analyze(1).draw(() => {
clearScreen(config.smear);

View file

@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import * as strudel from '@strudel.cycles/core';
import { superdough, getAudioContext, setLogger } from 'superdough';
import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough';
const { Pattern, logger } = strudel;
setLogger(logger);
@ -35,3 +35,7 @@ export function webaudioScheduler(options = {}) {
onTrigger: strudel.getTrigger({ defaultOutput, getTime }),
});
}
Pattern.prototype.dough = function () {
return this.onTrigger(doughTrigger, 1);
};