Merge branch 'main' of github.com:tidalcycles/strudel

This commit is contained in:
alex 2022-06-18 13:19:38 +01:00
commit 363895761e
104 changed files with 4570 additions and 3969 deletions

View file

@ -760,4 +760,13 @@ generic_params.forEach(([type, name, description]) => {
Pattern.prototype[name] = _setter(controls[name]);
});
// create custom param
controls.createParam = (name) => {
Pattern.prototype[name] = _setter(controls[name]);
return (...pats) => _name(name, ...pats);
};
controls.createParams = (...names) =>
names.reduce((acc, name) => Object.assign(acc, { [name]: createParam(name) }), {});
export default controls;

View file

@ -6,8 +6,8 @@
"packages": {
"": {
"name": "@strudel.cycles/core",
"version": "0.0.3",
"license": "GPL-3.0-or-later",
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"bjork": "^0.0.1",
"fraction.js": "^4.2.0"

View file

@ -1049,6 +1049,9 @@ export class Pattern {
.unit('c')
.slow(factor);
}
onTrigger(onTrigger) {
return this._withHap((hap) => hap.setContext({ ...hap.context, onTrigger }));
}
}
// TODO - adopt value.mjs fully..

View file

@ -15,9 +15,11 @@ npm i @strudel.cycles/eval --save
```js
import { evaluate, extend } from '@strudel.cycles/eval';
import * as strudel from '@strudel.cycles/core';
extend(strudel); // add strudel to eval scope
evalScope(
import('@strudel.cycles/core'),
// import other strudel packages here
); // add strudel to eval scope
async function run(code) {
const { pattern } = await evaluate(code);

View file

@ -6,8 +6,8 @@
"packages": {
"": {
"name": "@strudel.cycles/eval",
"version": "0.0.3",
"license": "GPL-3.0-or-later",
"version": "0.1.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"estraverse": "^5.3.0",
"shift-ast": "^6.1.0",

View file

@ -6,8 +6,8 @@
"packages": {
"": {
"name": "@strudel.cycles/midi",
"version": "0.0.4",
"license": "GPL-3.0-or-later",
"version": "0.1.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"tone": "^14.7.77",
"webmidi": "^2.5.2"

View file

@ -22,13 +22,15 @@ let startedAt = -1;
*/
Pattern.prototype.osc = function () {
return this._withHap((hap) => {
const onTrigger = (time, hap, currentTime, cps, cycle, delta) => {
const onTrigger = (time, hap, currentTime, cps) => {
const cycle = hap.wholeOrPart().begin.valueOf();
const delta = hap.duration.valueOf();
// time should be audio time of onset
// currentTime should be current time of audio context (slightly before time)
if (startedAt < 0) {
startedAt = Date.now() - currentTime * 1000;
}
const controls = Object.assign({}, { cps: cps, cycle: cycle, delta: delta }, hap.value);
const controls = Object.assign({}, { cps, cycle, delta }, hap.value);
const keyvals = Object.entries(controls).flat();
const ts = Math.floor(startedAt + (time + latency) * 1000);
const message = new OSC.Message('/dirt/play', ...keyvals);

View file

@ -6,8 +6,8 @@
"packages": {
"": {
"name": "@strudel.cycles/osc",
"version": "0.0.1",
"license": "GPL-3.0-or-later",
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"osc-js": "^2.3.2"
}

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@ const ivory = '#abb2bf',
// background = '#292d3e',
background = 'transparent',
tooltipBackground = '#353a42',
selection = 'rgba(128, 203, 196, 0.2)',
selection = 'rgba(128, 203, 196, 0.5)',
cursor = '#ffcc00';
/// The editor theme styles for Material Palenight.
@ -50,7 +50,8 @@ const materialPalenightTheme = EditorView.theme(
},
// done
'&.cm-focused .cm-cursor': {
borderLeftColor: cursor,
backgroundColor: cursor,
width: '3px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
@ -80,7 +81,7 @@ const materialPalenightTheme = EditorView.theme(
// done
'.cm-gutters': {
background: '#2C323699',
background: 'transparent',
color: '#676e95',
border: 'none',
},

3984
packages/react/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -57,14 +57,7 @@ function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw: onDrawP
/* console.warn('no instrument chosen', event);
throw new Error(`no instrument chosen for ${JSON.stringify(event)}`); */
} else {
onTrigger(
time,
event,
currentTime,
1 /* cps */,
event.wholeOrPart().begin.valueOf(),
event.duration.valueOf(),
);
onTrigger(time, event, currentTime, 1 /* cps */);
}
} catch (err) {
console.warn(err);

View file

@ -16,7 +16,7 @@ const ivory = '#abb2bf',
// background = '#292d3e',
background = 'transparent',
tooltipBackground = '#353a42',
selection = 'rgba(128, 203, 196, 0.2)',
selection = 'rgba(128, 203, 196, 0.5)',
cursor = '#ffcc00';
/// The editor theme styles for Material Palenight.
@ -40,7 +40,8 @@ export const materialPalenightTheme = EditorView.theme(
},
// done
'&.cm-focused .cm-cursor': {
borderLeftColor: cursor,
backgroundColor: cursor,
width: '3px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
@ -70,7 +71,7 @@ export const materialPalenightTheme = EditorView.theme(
// done
'.cm-gutters': {
background: '#2C323699',
background: 'transparent',
color: '#676e95',
border: 'none',
},

View file

@ -6,8 +6,8 @@
"packages": {
"": {
"name": "@strudel.cycles/tonal",
"version": "0.0.3",
"license": "GPL-3.0-or-later",
"version": "0.1.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@tonaljs/tonal": "^4.6.5",
"webmidi": "^3.0.15"

View file

@ -20,7 +20,7 @@ export const getDrawContext = (id = 'test-canvas') => {
return canvas.getContext('2d');
};
Pattern.prototype.draw = function (callback, cycleSpan, lookaheadCycles = 1) {
Pattern.prototype.draw = function (callback, { from, to, onQuery }) {
if (window.strudelAnimation) {
cancelAnimationFrame(window.strudelAnimation);
}
@ -29,19 +29,22 @@ Pattern.prototype.draw = function (callback, cycleSpan, lookaheadCycles = 1) {
events = [];
const animate = (time) => {
const t = Tone.getTransport().seconds;
if (cycleSpan) {
const currentCycle = Math.floor(t / cycleSpan);
if (from !== undefined && to !== undefined) {
const currentCycle = Math.floor(t);
if (cycle !== currentCycle) {
cycle = currentCycle;
const begin = currentCycle * cycleSpan;
const end = (currentCycle + lookaheadCycles) * cycleSpan;
events = this._asNumber(true) // true = silent error
.query(new State(new TimeSpan(begin, end)))
.filter(Boolean)
.filter((event) => event.part.begin.equals(event.whole.begin));
const begin = currentCycle + from;
const end = currentCycle + to;
setTimeout(() => {
events = this._asNumber(true) // true = silent error
.query(new State(new TimeSpan(begin, end)))
.filter(Boolean)
.filter((event) => event.part.begin.equals(event.whole.begin));
onQuery?.(events);
}, 0);
}
}
callback(ctx, events, t, cycleSpan, time);
callback(ctx, events, t, time);
window.strudelAnimation = requestAnimationFrame(animate);
};
requestAnimationFrame(animate);

View file

@ -6,8 +6,8 @@
"packages": {
"": {
"name": "@strudel.cycles/tone",
"version": "0.0.4",
"license": "GPL-3.0-or-later",
"version": "0.1.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@tonejs/piano": "^0.2.1",
"chord-voicings": "^0.0.1",

View file

@ -6,39 +6,126 @@ This program is free software: you can redistribute it and/or modify it under th
import { Pattern } from '@strudel.cycles/core';
const scale = (normalized, min, max) => normalized * (max - min) + min;
Pattern.prototype.pianoroll = function ({
timeframe = 10,
cycles = 4,
playhead = 0.5,
overscan = 1,
flipTime = 0,
flipValues = 0,
hideNegative = false,
inactive = '#C9E597',
active = '#FFCA28',
background = '#2A3236',
// background = '#2A3236',
background = 'transparent',
minMidi = 10,
maxMidi = 90,
minMidi = 0,
autorange = 0,
timeframe: timeframeProp,
fold = 0,
vertical = 0,
} = {}) {
const w = window.innerWidth;
const h = window.innerHeight;
const midiRange = maxMidi - minMidi + 1;
const height = h / midiRange;
const ctx = getDrawContext();
const w = ctx.canvas.width;
const h = ctx.canvas.height;
let from = -cycles * playhead;
let to = cycles * (1 - playhead);
if (timeframeProp) {
console.warn('timeframe is deprecated! use from/to instead');
from = 0;
to = timeframeProp;
}
if (!autorange && fold) {
console.warn('disabling autorange has no effect when fold is enabled');
}
const timeAxis = vertical ? h : w;
const valueAxis = vertical ? w : h;
let timeRange = vertical ? [timeAxis, 0] : [0, timeAxis]; // pixel range for time
const timeExtent = to - from; // number of seconds that fit inside the canvas frame
const valueRange = vertical ? [0, valueAxis] : [valueAxis, 0]; // pixel range for values
let valueExtent = maxMidi - minMidi + 1; // number of "slots" for values, overwritten if autorange true
let barThickness = valueAxis / valueExtent; // pixels per value, overwritten if autorange true
let foldValues = [];
flipTime && timeRange.reverse();
flipValues && valueRange.reverse();
const playheadPosition = scale(-from / timeExtent, ...timeRange);
this.draw(
(ctx, events, t) => {
ctx.fillStyle = background;
ctx.clearRect(0, 0, w, h);
ctx.fillRect(0, 0, w, h);
events.forEach((event) => {
const isActive = event.whole.begin <= t && event.whole.end >= t;
const inFrame = (event) =>
(!hideNegative || event.whole.begin >= 0) && event.whole.begin <= t + to && event.whole.end >= t + from;
events.filter(inFrame).forEach((event) => {
const isActive = event.whole.begin <= t && event.whole.end > t;
ctx.fillStyle = event.context?.color || inactive;
ctx.strokeStyle = event.context?.color || active;
ctx.globalAlpha = event.context.velocity ?? 1;
const x = Math.round((event.whole.begin / timeframe) * w);
const width = Math.round(((event.whole.end - event.whole.begin) / timeframe) * w);
const y = Math.round(h - ((Number(event.value) - minMidi) / midiRange) * h);
const offset = (t / timeframe) * w;
const margin = 0;
const coords = [x - offset + margin + 1, y + 1, width - 2, height - 2];
ctx.beginPath();
if (vertical) {
ctx.moveTo(0, playheadPosition);
ctx.lineTo(valueAxis, playheadPosition);
} else {
ctx.moveTo(playheadPosition, 0);
ctx.lineTo(playheadPosition, valueAxis);
}
ctx.stroke();
const timePx = scale((event.whole.begin - (flipTime ? to : from)) / timeExtent, ...timeRange);
let durationPx = scale(event.duration / timeExtent, 0, timeAxis);
const valuePx = scale(
fold ? foldValues.indexOf(event.value) / foldValues.length : (Number(event.value) - minMidi) / valueExtent,
...valueRange,
);
let margin = 0;
const offset = scale(t / timeExtent, ...timeRange);
let coords;
if (vertical) {
coords = [
valuePx + 1 - (flipValues ? barThickness : 0), // x
timeAxis - offset + timePx + margin + 1 - (flipTime ? 0 : durationPx), // y
barThickness - 2, // width
durationPx - 2, // height
];
} else {
coords = [
timePx - offset + margin + 1 - (flipTime ? durationPx : 0), // x
valuePx + 1 - (flipValues ? 0 : barThickness), // y
durationPx - 2, // widith
barThickness - 2, // height
];
}
isActive ? ctx.strokeRect(...coords) : ctx.fillRect(...coords);
});
},
timeframe,
2, // lookaheadCycles
{
from: from - overscan,
to: to + overscan,
onQuery: (events) => {
const getValue = (e) => Number(e.value);
const { min, max, values } = events.reduce(
({ min, max, values }, e) => {
const v = getValue(e);
return {
min: v < min ? v : min,
max: v > max ? v : max,
values: values.includes(v) ? values : [...values, v],
};
},
{ min: Infinity, max: -Infinity, values: [] },
);
if (autorange) {
minMidi = min;
maxMidi = max;
valueExtent = maxMidi - minMidi + 1;
}
foldValues = values.sort((a, b) => a - b);
barThickness = fold ? valueAxis / foldValues.length : valueAxis / valueExtent;
},
},
);
return this;
};

View file

@ -10,18 +10,20 @@ import { State, TimeSpan } from '@strudel.cycles/core';
export class Scheduler {
worker;
pattern;
constructor({ audioContext, interval = 0.2, onEvent }) {
constructor({ audioContext, interval = 0.2, onEvent, latency = 0.2 }) {
this.worker = new ClockWorker(
audioContext,
(begin, end) => {
this.pattern.query(new State(new TimeSpan(begin, end))).forEach((e) => {
this.pattern.query(new State(new TimeSpan(begin + latency, end + latency))).forEach((e) => {
if (!e.part.begin.equals(e.whole.begin)) {
return;
}
if (e.context.onTrigger) {
// TODO: kill first param, as it's contained in e
e.context.onTrigger(e.whole.begin, e, audioContext.currentTime, 1 /* cps */);
}
if (onEvent) {
onEvent?.(e);
} else {
console.warn('unplayable event: no audio node nor onEvent callback', e);
}
});
},

View file

@ -0,0 +1,11 @@
# @strudel.cycles/webdirt
This package adds [webdirt](https://github.com/dktr0/WebDirt) support to strudel!
## Dev Notes
Add default samples to repl:
1. move samples to `repl/public` folder. the samples folder is expected to have subfolders, with samples in it. the subfolders will be the names of the samples.
2. run `./makeSampleMap.sh ../../repl/public/EmuSP12 > ../../repl/public/EmuSP12.json`
3. adapt `loadWebDirt` in App.jsx + MiniRepl.jsx to use the generated json file

View file

@ -0,0 +1,2 @@
export * from './webdirt.mjs';
export * from './sampler.mjs';

View file

@ -0,0 +1,32 @@
#/bin/sh
printf "{\n"
dircount=0
# for d in $searchRoot/*; do
find $1 -mindepth 1 -maxdepth 1 -iname "*" | sort | while read d; do
if [ -d "$d" ]
then
if [ $dircount -ne 0 ]
then
printf ",\n"
fi
(( dircount++ ))
dirname=`basename $d`
printf "\"%s\": [" "$dirname"
search2=$searchRoot/$dirname/*.WAV
filecount=0
find "$d" -iname "*.wav" | sort | while read f; do
# for f in $search2; do
filename=$(printf %q "$f")
basename=${f##*/}
if [[ ${basename:0:1} != "." ]]; then
if [ $filecount -ne 0 ]; then
printf ","
fi
(( filecount++ ))
printf "\"%s/%s\"" "$dirname" "$basename"
fi
done
printf "]"
fi
done
printf "\n}\n"

28
packages/webdirt/package-lock.json generated Normal file
View file

@ -0,0 +1,28 @@
{
"name": "@strudel.cycles/webdirt",
"version": "0.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@strudel.cycles/webdirt",
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"WebDirt": "github:dktr0/WebDirt"
}
},
"node_modules/WebDirt": {
"name": "webdirt",
"version": "1.0.0",
"resolved": "git+ssh://git@github.com/dktr0/WebDirt.git#425dc8fd023440d9c61ffdb8642e44e2710faea0",
"license": "ISC"
}
},
"dependencies": {
"WebDirt": {
"version": "git+ssh://git@github.com/dktr0/WebDirt.git#425dc8fd023440d9c61ffdb8642e44e2710faea0",
"from": "WebDirt@github:dktr0/WebDirt"
}
}
}

View file

@ -0,0 +1,28 @@
{
"name": "@strudel.cycles/webdirt",
"version": "0.1.0",
"description": "WebDirt integration for Strudel",
"main": "index.mjs",
"type": "module",
"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": "^0.1.0",
"WebDirt": "github:dktr0/WebDirt"
}
}

View file

@ -0,0 +1,112 @@
const bufferCache = {}; // string: Promise<ArrayBuffer>
const loadCache = {}; // string: Promise<ArrayBuffer>
export const loadBuffer = (url, ac) => {
if (!loadCache[url]) {
loadCache[url] = fetch(url)
.then((res) => res.arrayBuffer())
.then(async (res) => {
const decoded = await ac.decodeAudioData(res);
bufferCache[url] = decoded;
return decoded;
});
}
return loadCache[url];
};
export const getLoadedBuffer = (url) => {
return bufferCache[url];
};
/* export const playBuffer = (buffer, time = ac.currentTime, destination = ac.destination) => {
const src = ac.createBufferSource();
src.buffer = buffer;
src.connect(destination);
src.start(time);
};
export const playSample = async (url) => playBuffer(await loadBuffer(url)); */
// https://estuary.mcmaster.ca/samples/resources.json
// Array<{ "url":string, "bank": string, "n": number}>
// ritchse/tidal-drum-machines/tree/main/machines/AkaiLinn
const githubCache = {};
let sampleCache = { current: undefined };
export const loadGithubSamples = async (path, nameFn) => {
const storageKey = 'loadGithubSamples ' + path;
const stored = localStorage.getItem(storageKey);
if (stored) {
console.log('[sampler]: loaded sample list from localstorage', path);
githubCache[path] = JSON.parse(stored);
}
if (githubCache[path]) {
sampleCache.current = githubCache[path];
return githubCache[path];
}
console.log('[sampler]: fetching sample list from github', path);
try {
const [user, repo, ...folders] = path.split('/');
const baseUrl = `https://api.github.com/repos/${user}/${repo}/contents`;
const banks = await fetch(`${baseUrl}/${folders.join('/')}`).then((res) => res.json());
// fetch each subfolder
githubCache[path] = (
await Promise.all(
banks.map(async ({ name, path }) => ({
name,
content: await fetch(`${baseUrl}/${path}`)
.then((res) => res.json())
.catch((err) => {
console.error('could not load path', err);
}),
})),
)
)
.filter(({ content }) => !!content)
.reduce(
(acc, { name, content }) => ({
...acc,
[nameFn?.(name) || name]: content.map(({ download_url }) => download_url),
}),
{},
);
} catch (err) {
console.error('[sampler]: failed to fetch sample list from github', err);
return;
}
sampleCache.current = githubCache[path];
localStorage.setItem(storageKey, JSON.stringify(sampleCache.current));
console.log('[sampler]: loaded samples:', sampleCache.current);
return githubCache[path];
};
/**
* load the given sample map for webdirt
*
* @example
* loadSamples({
* bd: '808bd/BD0000.WAV',
* sd: ['808sd/SD0000.WAV','808sd/SD0010.WAV','808sd/SD0050.WAV']
* }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/');
* s("bd <sd!7 sd(3,4,2)>").n(2).webdirt()
*
*/
export const samples = (sampleMap, baseUrl = '') => {
sampleCache.current = {
...sampleCache.current,
...Object.fromEntries(
Object.entries(sampleMap).map(([key, value]) => [
key,
(typeof value === 'string' ? [value] : value).map((v) =>
(baseUrl + v).replace('github:', 'https://raw.githubusercontent.com/'),
),
]),
),
};
};
export const resetLoadedSamples = () => {
sampleCache.current = undefined;
};
export const getLoadedSamples = () => sampleCache.current;

View file

@ -0,0 +1,98 @@
import * as strudel from '@strudel.cycles/core';
const { Pattern } = strudel;
import * as WebDirt from 'WebDirt';
import { getLoadedSamples, loadBuffer, getLoadedBuffer } from './sampler.mjs';
let webDirt;
/*
example config:
{
sampleMapUrl: 'EmuSP12.json',
sampleFolder: 'EmuSP12',
}
*/
export function loadWebDirt(config) {
webDirt = new WebDirt.WebDirt(config);
webDirt.initializeWebAudio();
}
/**
*
* Uses [webdirt](https://github.com/dktr0/WebDirt) as output.
*
* <details>
* <summary>show supported Webdirt controls</summary>
*
* - s :: String, -- name of sample bank
* - n :: Int, -- number of sample within a bank
* - {@link gain} :: Number, -- clamped from 0 to 2; 1 is default and full-scale
* - overgain :: Number, -- additional gain added to gain to go past clamp at 2
* - {@link pan} :: Number, -- range: 0 to 1
* - nudge :: Number, -- nudge the time of the sample forwards/backwards in seconds
* - {@link speed} :: Number, -- speed / pitch of the sample
* - {@link unit} :: String
* - note :: Number, -- pitch offset in semitones
* - {@link begin} :: Number, -- cut from sample start, normalized
* - {@link end} :: Number, -- cut from sample end, normalized
* - {@link cut} :: Int, -- samples with same cut number will interupt each other
* - {@link cutoff} :: Number, -- lowpass filter frequency
* - {@link resonance} :: Number, -- lowpass filter resonance
* - {@link hcutoff} :: Number, -- highpass filter frequency
* - {@link hresonance} :: Number, -- highpass filter resonance
* - {@link bandf} :: Number, -- bandpass filter frequency
* - {@link bandq} :: Number, -- bandpass filter resonance
* - {@link vowel} :: String, -- name of vowel ('a' | 'e' | 'i' | 'o' | 'u')
* - delay :: Number, -- delay wet/dry mix
* - delaytime :: Number, -- delay time in seconds
* - delayfeedback :: Number, -- delay feedback
* - {@link loop} :: Number, -- loop sample n times (relative to sample length)
* - {@link crush} :: Number, -- bitcrusher (currently not working)
* - {@link coarse} :: Number, -- coarse effect (currently not working)
* - {@link shape} :: Number, -- (currently not working)
*
* </details>
*
* @name webdirt
* @memberof Pattern
* @returns Pattern
* @example
* s("bd*2 hh sd hh").n("<0 1>").webdirt()
*/
Pattern.prototype.webdirt = function () {
// create a WebDirt object and initialize Web Audio context
return this._withHap((hap) => {
const onTrigger = async (time, e, currentTime) => {
if (!webDirt) {
throw new Error('WebDirt not initialized!');
}
const deadline = time - currentTime;
const { s, n = 0, ...rest } = e.value || {};
if (!s) {
console.warn('Pattern.webdirt: no "s" was set!');
}
const samples = getLoadedSamples();
if (!samples?.[s]) {
// try default samples
webDirt.playSample({ s, n, ...rest }, deadline);
return;
}
if (!samples?.[s]) {
console.warn(`Pattern.webdirt: sample "${s}" not found in loaded samples`, samples);
} else {
const bank = samples[s];
const sampleUrl = bank[n % bank.length];
const buffer = getLoadedBuffer(sampleUrl);
if (!buffer) {
console.log(`Pattern.webdirt: load ${s}:${n} from ${sampleUrl}`);
loadBuffer(sampleUrl, webDirt.ac);
} else {
const msg = { buffer: { buffer }, ...rest };
webDirt.playSample(msg, deadline);
}
}
};
return hap.setContext({ ...hap.context, onTrigger });
});
};