mirror of
https://github.com/dudaanton/obsidian-strudel-plugin.git
synced 2026-07-22 06:43:01 +00:00
Initial
This commit is contained in:
commit
5b04f1358d
58 changed files with 20441 additions and 0 deletions
161
src/strudel/core/clockworker.js
Normal file
161
src/strudel/core/clockworker.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
// TODO: swap below line with above one when firefox supports esm imports in service workers
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker?retiredLocale=de#browser_compatibility
|
||||
// import createClock from './zyklus.mjs';
|
||||
|
||||
function getTime() {
|
||||
const seconds = performance.now() * 0.001;
|
||||
return seconds;
|
||||
// return Math.round(seconds * precision) / precision;
|
||||
}
|
||||
|
||||
let num_cycles_at_cps_change = 0;
|
||||
let num_ticks_since_cps_change = 0;
|
||||
let num_seconds_at_cps_change = 0;
|
||||
let cps = 0.5;
|
||||
// {id: {started: boolean}}
|
||||
const clients = new Map();
|
||||
const duration = 0.1;
|
||||
const channel = new BroadcastChannel('strudeltick');
|
||||
|
||||
const sendMessage = (type, payload) => {
|
||||
channel.postMessage({ type, payload });
|
||||
};
|
||||
|
||||
const sendTick = (phase, duration, tick, time) => {
|
||||
const num_seconds_since_cps_change = num_ticks_since_cps_change * duration;
|
||||
const tickdeadline = phase - time;
|
||||
const lastTick = time + tickdeadline;
|
||||
const num_cycles_since_cps_change = num_seconds_since_cps_change * cps;
|
||||
const begin = num_cycles_at_cps_change + num_cycles_since_cps_change;
|
||||
const secondsSinceLastTick = time - lastTick - duration;
|
||||
const eventLength = duration * cps;
|
||||
const end = begin + eventLength;
|
||||
const cycle = begin + secondsSinceLastTick * cps;
|
||||
|
||||
sendMessage('tick', {
|
||||
begin,
|
||||
end,
|
||||
cps,
|
||||
time,
|
||||
cycle,
|
||||
});
|
||||
num_ticks_since_cps_change++;
|
||||
};
|
||||
|
||||
//create clock method from zyklus
|
||||
const clock = createClock(getTime, sendTick, duration);
|
||||
let started = false;
|
||||
|
||||
const startClock = (id) => {
|
||||
clients.set(id, { started: true });
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
clock.start();
|
||||
started = true;
|
||||
};
|
||||
const stopClock = async (id) => {
|
||||
clients.set(id, { started: false });
|
||||
|
||||
const otherClientStarted = Array.from(clients.values()).some((c) => c.started);
|
||||
//dont stop the clock if other instances are running...
|
||||
if (!started || otherClientStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
clock.stop();
|
||||
setCycle(0);
|
||||
started = false;
|
||||
};
|
||||
|
||||
const setCycle = (cycle) => {
|
||||
num_ticks_since_cps_change = 0;
|
||||
num_cycles_at_cps_change = cycle;
|
||||
};
|
||||
|
||||
const processMessage = (message) => {
|
||||
const { type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'cpschange': {
|
||||
if (payload.cps !== cps) {
|
||||
const num_seconds_since_cps_change = num_ticks_since_cps_change * duration;
|
||||
num_cycles_at_cps_change = num_cycles_at_cps_change + num_seconds_since_cps_change * cps;
|
||||
num_seconds_at_cps_change = num_seconds_at_cps_change + num_seconds_since_cps_change;
|
||||
cps = payload.cps;
|
||||
num_ticks_since_cps_change = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'setcycle': {
|
||||
setCycle(payload.cycle);
|
||||
break;
|
||||
}
|
||||
case 'toggle': {
|
||||
if (payload.started) {
|
||||
startClock(message.id);
|
||||
} else {
|
||||
stopClock(message.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.onconnect = function (e) {
|
||||
// the incoming port
|
||||
const port = e.ports[0];
|
||||
|
||||
port.addEventListener('message', function (e) {
|
||||
processMessage(e.data);
|
||||
});
|
||||
port.start(); // Required when using addEventListener. Otherwise called implicitly by onmessage setter.
|
||||
};
|
||||
|
||||
// used to consistently schedule events, for use in a service worker - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/clockworker.mjs>
|
||||
function createClock(
|
||||
getTime,
|
||||
callback, // called slightly before each cycle
|
||||
duration = 0.05, // duration of each cycle
|
||||
interval = 0.1, // interval between callbacks
|
||||
overlap = 0.1, // overlap between callbacks
|
||||
) {
|
||||
let tick = 0; // counts callbacks
|
||||
let phase = 0; // next callback time
|
||||
let precision = 10 ** 4; // used to round phase
|
||||
let minLatency = 0.01;
|
||||
const setDuration = (setter) => (duration = setter(duration));
|
||||
overlap = overlap || interval / 2;
|
||||
const onTick = () => {
|
||||
const t = getTime();
|
||||
const lookahead = t + interval + overlap; // the time window for this tick
|
||||
if (phase === 0) {
|
||||
phase = t + minLatency;
|
||||
}
|
||||
// callback as long as we're inside the lookahead
|
||||
while (phase < lookahead) {
|
||||
phase = Math.round(phase * precision) / precision;
|
||||
phase >= t && callback(phase, duration, tick, t);
|
||||
phase < t && console.log('TOO LATE', phase); // what if latency is added from outside?
|
||||
phase += duration; // increment phase by duration
|
||||
tick++;
|
||||
}
|
||||
};
|
||||
let intervalID;
|
||||
const start = () => {
|
||||
clear(); // just in case start was called more than once
|
||||
onTick();
|
||||
intervalID = setInterval(onTick, interval * 1000);
|
||||
};
|
||||
const clear = () => intervalID !== undefined && clearInterval(intervalID);
|
||||
const pause = () => clear();
|
||||
const stop = () => {
|
||||
tick = 0;
|
||||
phase = 0;
|
||||
clear();
|
||||
};
|
||||
const getPhase = () => phase;
|
||||
// setCallback
|
||||
return { setDuration, start, stop, pause, duration, interval, getPhase, minLatency };
|
||||
}
|
||||
2376
src/strudel/core/controls.mjs
Normal file
2376
src/strudel/core/controls.mjs
Normal file
File diff suppressed because it is too large
Load diff
140
src/strudel/core/cyclist.mjs
Normal file
140
src/strudel/core/cyclist.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/neocyclist.mjs>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/cyclist.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import createClock from './zyklus.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
|
||||
export class Cyclist {
|
||||
constructor({
|
||||
interval,
|
||||
onTrigger,
|
||||
onToggle,
|
||||
onError,
|
||||
getTime,
|
||||
latency = 0.1,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
beforeStart,
|
||||
}) {
|
||||
this.started = false;
|
||||
this.beforeStart = beforeStart;
|
||||
this.cps = 0.5;
|
||||
this.num_ticks_since_cps_change = 0;
|
||||
this.lastTick = 0; // absolute time when last tick (clock callback) happened
|
||||
this.lastBegin = 0; // query begin of last tick
|
||||
this.lastEnd = 0; // query end of last tick
|
||||
this.getTime = getTime; // get absolute time
|
||||
this.num_cycles_at_cps_change = 0;
|
||||
this.seconds_at_cps_change; // clock phase when cps was changed
|
||||
this.onToggle = onToggle;
|
||||
this.latency = latency; // fixed trigger time offset
|
||||
this.clock = createClock(
|
||||
getTime,
|
||||
// called slightly before each cycle
|
||||
(phase, duration, _, t) => {
|
||||
if (this.num_ticks_since_cps_change === 0) {
|
||||
this.num_cycles_at_cps_change = this.lastEnd;
|
||||
this.seconds_at_cps_change = phase;
|
||||
}
|
||||
this.num_ticks_since_cps_change++;
|
||||
const seconds_since_cps_change = this.num_ticks_since_cps_change * duration;
|
||||
const num_cycles_since_cps_change = seconds_since_cps_change * this.cps;
|
||||
|
||||
try {
|
||||
const begin = this.lastEnd;
|
||||
this.lastBegin = begin;
|
||||
const end = this.num_cycles_at_cps_change + num_cycles_since_cps_change;
|
||||
this.lastEnd = end;
|
||||
this.lastTick = phase;
|
||||
|
||||
if (phase < t) {
|
||||
// avoid querying haps that are in the past anyway
|
||||
console.log(`skip query: too late`);
|
||||
return;
|
||||
}
|
||||
|
||||
// query the pattern for events
|
||||
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'cyclist' });
|
||||
|
||||
haps.forEach((hap) => {
|
||||
if (hap.hasOnset()) {
|
||||
const targetTime =
|
||||
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
|
||||
const duration = hap.duration / this.cps;
|
||||
// the following line is dumb and only here for backwards compatibility
|
||||
// see https://codeberg.org/uzu/strudel/pulls/1004
|
||||
const deadline = targetTime - phase;
|
||||
// this onTrigger has another signature
|
||||
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
|
||||
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
|
||||
this.cps = hap.value.cps;
|
||||
this.num_ticks_since_cps_change = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
errorLogger(e);
|
||||
onError?.(e);
|
||||
}
|
||||
},
|
||||
interval, // duration of each cycle
|
||||
0.1,
|
||||
0.1,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
);
|
||||
}
|
||||
now() {
|
||||
if (!this.started) {
|
||||
return 0;
|
||||
}
|
||||
const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration;
|
||||
return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency;
|
||||
}
|
||||
setStarted(v) {
|
||||
this.started = v;
|
||||
this.onToggle?.(v);
|
||||
}
|
||||
async start() {
|
||||
await this.beforeStart?.();
|
||||
this.num_ticks_since_cps_change = 0;
|
||||
this.num_cycles_at_cps_change = 0;
|
||||
if (!this.pattern) {
|
||||
throw new Error('Scheduler: no pattern set! call .setPattern first.');
|
||||
}
|
||||
logger('[cyclist] start');
|
||||
this.clock.start();
|
||||
this.setStarted(true);
|
||||
}
|
||||
pause() {
|
||||
logger('[cyclist] pause');
|
||||
this.clock.pause();
|
||||
this.setStarted(false);
|
||||
}
|
||||
stop() {
|
||||
logger('[cyclist] stop');
|
||||
this.clock.stop();
|
||||
this.lastEnd = 0;
|
||||
this.setStarted(false);
|
||||
}
|
||||
async setPattern(pat, autostart = false) {
|
||||
this.pattern = pat;
|
||||
if (autostart && !this.started) {
|
||||
await this.start();
|
||||
}
|
||||
}
|
||||
setCps(cps = 0.5) {
|
||||
if (this.cps === cps) {
|
||||
return;
|
||||
}
|
||||
this.cps = cps;
|
||||
this.num_ticks_since_cps_change = 0;
|
||||
}
|
||||
log(begin, end, haps) {
|
||||
const onsets = haps.filter((h) => h.hasOnset());
|
||||
console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`);
|
||||
}
|
||||
}
|
||||
62
src/strudel/core/drawLine.mjs
Normal file
62
src/strudel/core/drawLine.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
drawLine.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/drawLine.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Fraction, { gcd } from './fraction.mjs';
|
||||
|
||||
/**
|
||||
* Intended for a debugging, drawLine renders the pattern as a string, where each character represents the same time span.
|
||||
* Should only be used with single characters as values, otherwise the character slots will be messed up.
|
||||
* Character legend:
|
||||
*
|
||||
* - "|" cycle separator
|
||||
* - "-" hold previous value
|
||||
* - "." silence
|
||||
*
|
||||
* @param {Pattern} pattern the pattern to use
|
||||
* @param {number} chars max number of characters (approximately)
|
||||
* @returns string
|
||||
* @example
|
||||
* const line = drawLine("0 [1 2 3]", 10); // |0--123|0--123
|
||||
* console.log(line);
|
||||
* silence;
|
||||
*/
|
||||
function drawLine(pat, chars = 60) {
|
||||
let cycle = 0;
|
||||
let pos = Fraction(0);
|
||||
let lines = [''];
|
||||
let emptyLine = ''; // this will be the "reference" empty line, which will be copied into extra lines
|
||||
while (lines[0].length < chars) {
|
||||
const haps = pat.queryArc(cycle, cycle + 1);
|
||||
const durations = haps.filter((hap) => hap.hasOnset()).map((hap) => hap.duration);
|
||||
const charFraction = gcd(...durations);
|
||||
const totalSlots = charFraction.inverse(); // number of character slots for the current cycle
|
||||
lines = lines.map((line) => line + '|'); // add pipe character before each cycle
|
||||
emptyLine += '|';
|
||||
for (let i = 0; i < totalSlots; i++) {
|
||||
const [begin, end] = [pos, pos.add(charFraction)];
|
||||
const matches = haps.filter((hap) => hap.whole.begin.lte(begin) && hap.whole.end.gte(end));
|
||||
const missingLines = matches.length - lines.length;
|
||||
if (missingLines > 0) {
|
||||
lines = lines.concat(Array(missingLines).fill(emptyLine));
|
||||
}
|
||||
lines = lines.map((line, i) => {
|
||||
const hap = matches[i];
|
||||
if (hap) {
|
||||
const isOnset = hap.whole.begin.eq(begin);
|
||||
const char = isOnset ? '' + hap.value : '-';
|
||||
return line + char;
|
||||
}
|
||||
return line + '.';
|
||||
});
|
||||
emptyLine += '.';
|
||||
pos = pos.add(charFraction);
|
||||
}
|
||||
cycle++;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export default drawLine;
|
||||
221
src/strudel/core/euclid.mjs
Normal file
221
src/strudel/core/euclid.mjs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/*
|
||||
euclid.mjs - Bjorklund/Euclidean/Diaspora rhythms
|
||||
Copyright (C) 2023 Rohan Drape and strudel contributors
|
||||
|
||||
See <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/euclid.mjs> for authors of this file.
|
||||
|
||||
The Bjorklund algorithm implementation is ported from the Haskell Music Theory Haskell module by Rohan Drape -
|
||||
https://rohandrape.net/?t=hmt
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { timeCat, register, silence, stack, pure, _morph } from './pattern.mjs';
|
||||
import { rotate, flatten, splitAt, zipWith } from './util.mjs';
|
||||
import Fraction, { lcm } from './fraction.mjs';
|
||||
|
||||
const left = function (n, x) {
|
||||
const [ons, offs] = n;
|
||||
const [xs, ys] = x;
|
||||
const [_xs, __xs] = splitAt(offs, xs);
|
||||
return [
|
||||
[offs, ons - offs],
|
||||
[zipWith((a, b) => a.concat(b), _xs, ys), __xs],
|
||||
];
|
||||
};
|
||||
|
||||
const right = function (n, x) {
|
||||
const [ons, offs] = n;
|
||||
const [xs, ys] = x;
|
||||
const [_ys, __ys] = splitAt(ons, ys);
|
||||
const result = [
|
||||
[ons, offs - ons],
|
||||
[zipWith((a, b) => a.concat(b), xs, _ys), __ys],
|
||||
];
|
||||
return result;
|
||||
};
|
||||
|
||||
const _bjork = function (n, x) {
|
||||
const [ons, offs] = n;
|
||||
return Math.min(ons, offs) <= 1 ? [n, x] : _bjork(...(ons > offs ? left(n, x) : right(n, x)));
|
||||
};
|
||||
|
||||
export const bjork = function (ons, steps) {
|
||||
const inverted = ons < 0;
|
||||
const absOns = Math.abs(ons);
|
||||
const offs = steps - absOns;
|
||||
const ones = Array(absOns).fill([1]);
|
||||
const zeros = Array(offs).fill([0]);
|
||||
const result = _bjork([absOns, offs], [ones, zeros]);
|
||||
const pattern = flatten(result[1][0]).concat(flatten(result[1][1]));
|
||||
return inverted ? pattern.map((x) => 1 - x) : pattern;
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes the structure of the pattern to form an Euclidean rhythm.
|
||||
* Euclidean rhythms are rhythms obtained using the greatest common
|
||||
* divisor of two numbers. They were described in 2004 by Godfried
|
||||
* Toussaint, a Canadian computer scientist. Euclidean rhythms are
|
||||
* really useful for computer/algorithmic music because they can
|
||||
* describe a large number of rhythms with a couple of numbers.
|
||||
*
|
||||
* @memberof Pattern
|
||||
* @name euclid
|
||||
* @param {number} pulses the number of onsets/beats
|
||||
* @param {number} steps the number of steps to fill
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* // The Cuban tresillo pattern.
|
||||
* note("c3").euclid(3,8)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Like `euclid`, but has an additional parameter for 'rotating' the resulting sequence.
|
||||
* @memberof Pattern
|
||||
* @name euclidRot
|
||||
* @param {number} pulses the number of onsets/beats
|
||||
* @param {number} steps the number of steps to fill
|
||||
* @param {number} rotation offset in steps
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* // A Samba rhythm necklace from Brazil
|
||||
* note("c3").euclidRot(3,16,14)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @example // A thirteenth-century Persian rhythm called Khafif-e-ramal.
|
||||
* note("c3").euclid(2,5)
|
||||
* @example // The archetypal pattern of the Cumbia from Colombia, as well as a Calypso rhythm from Trinidad.
|
||||
* note("c3").euclid(3,4)
|
||||
* @example // Another thirteenth century Persian rhythm by the name of Khafif-e-ramal, as well as a Rumanian folk-dance rhythm.
|
||||
* note("c3").euclidRot(3,5,2)
|
||||
* @example // A Ruchenitza rhythm used in a Bulgarian folk dance.
|
||||
* note("c3").euclid(3,7)
|
||||
* @example // The Cuban tresillo pattern.
|
||||
* note("c3").euclid(3,8)
|
||||
* @example // Another Ruchenitza Bulgarian folk-dance rhythm.
|
||||
* note("c3").euclid(4,7)
|
||||
* @example // The Aksak rhythm of Turkey.
|
||||
* note("c3").euclid(4,9)
|
||||
* @example // The metric pattern used by Frank Zappa in his piece titled Outside Now.
|
||||
* note("c3").euclid(4,11)
|
||||
* @example // Yields the York-Samai pattern, a popular Arab rhythm.
|
||||
* note("c3").euclid(5,6)
|
||||
* @example // The Nawakhat pattern, another popular Arab rhythm.
|
||||
* note("c3").euclid(5,7)
|
||||
* @example // The Cuban cinquillo pattern.
|
||||
* note("c3").euclid(5,8)
|
||||
* @example // A popular Arab rhythm called Agsag-Samai.
|
||||
* note("c3").euclid(5,9)
|
||||
* @example // The metric pattern used by Moussorgsky in Pictures at an Exhibition.
|
||||
* note("c3").euclid(5,11)
|
||||
* @example // The Venda clapping pattern of a South African children’s song.
|
||||
* note("c3").euclid(5,12)
|
||||
* @example // The Bossa-Nova rhythm necklace of Brazil.
|
||||
* note("c3").euclid(5,16)
|
||||
* @example // A typical rhythm played on the Bendir (frame drum).
|
||||
* note("c3").euclid(7,8)
|
||||
* @example // A common West African bell pattern.
|
||||
* note("c3").euclid(7,12)
|
||||
* @example // A Samba rhythm necklace from Brazil.
|
||||
* note("c3").euclidRot(7,16,14)
|
||||
* @example // A rhythm necklace used in the Central African Republic.
|
||||
* note("c3").euclid(9,16)
|
||||
* @example // A rhythm necklace of the Aka Pygmies of Central Africa.
|
||||
* note("c3").euclidRot(11,24,14)
|
||||
* @example // Another rhythm necklace of the Aka Pygmies of the upper Sangha.
|
||||
* note("c3").euclidRot(13,24,5)
|
||||
*/
|
||||
|
||||
const _euclidRot = function (pulses, steps, rotation) {
|
||||
const b = bjork(pulses, steps);
|
||||
if (rotation) {
|
||||
return rotate(b, -rotation);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
export const euclid = register('euclid', function (pulses, steps, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, 0));
|
||||
});
|
||||
|
||||
export const e = register('e', function (euc, pat) {
|
||||
if (!Array.isArray(euc)) {
|
||||
euc = [euc];
|
||||
}
|
||||
const [pulses, steps = pulses, rot = 0] = euc;
|
||||
return pat.struct(_euclidRot(pulses, steps, rot));
|
||||
});
|
||||
|
||||
export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], function (pulses, steps, rotation, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, rotation));
|
||||
});
|
||||
|
||||
/**
|
||||
* Similar to `euclid`, but each pulse is held until the next pulse,
|
||||
* so there will be no gaps.
|
||||
* @name euclidLegato
|
||||
* @memberof Pattern
|
||||
* @param {number} pulses the number of onsets/beats
|
||||
* @param {number} steps the number of steps to fill
|
||||
* @param rotation offset in steps
|
||||
* @param pat
|
||||
* @example
|
||||
* note("c3").euclidLegato(3,8)
|
||||
*/
|
||||
|
||||
const _euclidLegato = function (pulses, steps, rotation, pat) {
|
||||
if (pulses < 1) {
|
||||
return silence;
|
||||
}
|
||||
const bin_pat = _euclidRot(pulses, steps, 0);
|
||||
const gapless = bin_pat
|
||||
.join('')
|
||||
.split('1')
|
||||
.slice(1)
|
||||
.map((s) => [s.length + 1, true]);
|
||||
return pat.struct(timeCat(...gapless)).late(Fraction(rotation).div(steps));
|
||||
};
|
||||
|
||||
export const euclidLegato = register(['euclidLegato'], function (pulses, steps, pat) {
|
||||
return _euclidLegato(pulses, steps, 0, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* Similar to `euclid`, but each pulse is held until the next pulse,
|
||||
* so there will be no gaps, and has an additional parameter for 'rotating'
|
||||
* the resulting sequence
|
||||
* @name euclidLegatoRot
|
||||
* @memberof Pattern
|
||||
* @param {number} pulses the number of onsets/beats
|
||||
* @param {number} steps the number of steps to fill
|
||||
* @param {number} rotation offset in steps
|
||||
* @example
|
||||
* note("c3").euclidLegatoRot(3,5,2)
|
||||
*/
|
||||
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
|
||||
return _euclidLegato(pulses, steps, rotation, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* A 'euclid' variant with an additional parameter that morphs the resulting
|
||||
* rhythm from 0 (no morphing) to 1 (completely 'even'). For example
|
||||
* `sound("bd").euclidish(3,8,0)` would be the same as
|
||||
* `sound("bd").euclid(3,8)`, and `sound("bd").euclidish(3,8,1)` would be the
|
||||
* same as `sound("bd bd bd")`. `sound("bd").euclidish(3,8,0.5)` would have a
|
||||
* groove somewhere between.
|
||||
* Inspired by the work of Malcom Braff.
|
||||
* @name euclidish
|
||||
* @synonyms eish
|
||||
* @memberof Pattern
|
||||
* @param {number} pulses the number of onsets
|
||||
* @param {number} steps the number of steps to fill
|
||||
* @param {number} groove exists between the extremes of 0 (straight euclidian) and 1 (straight pulse)
|
||||
* @example
|
||||
* sound("hh").euclidish(7,12,sine.slow(8))
|
||||
* .pan(sine.slow(8))
|
||||
*/
|
||||
export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) {
|
||||
const morphed = _morph(bjork(pulses, steps), new Array(pulses).fill(1), perc);
|
||||
return pat.struct(morphed).setSteps(steps);
|
||||
});
|
||||
54
src/strudel/core/evaluate.mjs
Normal file
54
src/strudel/core/evaluate.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
evaluate.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/evaluate.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const strudelScope = {};
|
||||
|
||||
export const evalScope = async (...args) => {
|
||||
const results = await Promise.allSettled(args);
|
||||
const modules = results.filter((result) => result.status === 'fulfilled').map((r) => r.value);
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn(`evalScope: module with index ${i} could not be loaded:`, result.reason);
|
||||
}
|
||||
});
|
||||
// Object.assign(globalThis, ...modules);
|
||||
// below is a fix for above commented out line
|
||||
// same error as https://github.com/vitest-dev/vitest/issues/1807 when running this on astro server
|
||||
modules.forEach((module) => {
|
||||
Object.entries(module).forEach(([name, value]) => {
|
||||
globalThis[name] = value;
|
||||
strudelScope[name] = value;
|
||||
});
|
||||
});
|
||||
return modules;
|
||||
};
|
||||
|
||||
function safeEval(str, options = {}) {
|
||||
const { wrapExpression = true, wrapAsync = true } = options;
|
||||
if (wrapExpression) {
|
||||
str = `{${str}}`;
|
||||
}
|
||||
if (wrapAsync) {
|
||||
str = `(async ()=>${str})()`;
|
||||
}
|
||||
const body = `"use strict";return (${str})`;
|
||||
return Function(body)();
|
||||
}
|
||||
|
||||
export const evaluate = async (code, transpiler, transpilerOptions) => {
|
||||
let meta = {};
|
||||
|
||||
if (transpiler) {
|
||||
// transform syntactically correct js code to semantically usable code
|
||||
const transpiled = transpiler(code, transpilerOptions);
|
||||
code = transpiled.output;
|
||||
meta = transpiled;
|
||||
}
|
||||
// if no transpiler is given, we expect a single instruction (!wrapExpression)
|
||||
const options = { wrapExpression: !!transpiler };
|
||||
let evaluated = await safeEval(code, options);
|
||||
return { mode: 'javascript', pattern: evaluated, meta };
|
||||
};
|
||||
147
src/strudel/core/fraction.mjs
Normal file
147
src/strudel/core/fraction.mjs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
fraction.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/fraction.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Fraction from 'fraction.js';
|
||||
import { TimeSpan } from './timespan.mjs';
|
||||
import { removeUndefineds } from './util.mjs';
|
||||
|
||||
// Returns the start of the cycle.
|
||||
Fraction.prototype.sam = function () {
|
||||
return this.floor();
|
||||
};
|
||||
|
||||
// Returns the start of the next cycle.
|
||||
Fraction.prototype.nextSam = function () {
|
||||
return this.sam().add(1);
|
||||
};
|
||||
|
||||
// Returns a TimeSpan representing the begin and end of the Time value's cycle
|
||||
Fraction.prototype.wholeCycle = function () {
|
||||
return new TimeSpan(this.sam(), this.nextSam());
|
||||
};
|
||||
|
||||
// The position of a time value relative to the start of its cycle.
|
||||
Fraction.prototype.cyclePos = function () {
|
||||
return this.sub(this.sam());
|
||||
};
|
||||
|
||||
Fraction.prototype.lt = function (other) {
|
||||
return this.compare(other) < 0;
|
||||
};
|
||||
|
||||
Fraction.prototype.gt = function (other) {
|
||||
return this.compare(other) > 0;
|
||||
};
|
||||
|
||||
Fraction.prototype.lte = function (other) {
|
||||
return this.compare(other) <= 0;
|
||||
};
|
||||
|
||||
Fraction.prototype.gte = function (other) {
|
||||
return this.compare(other) >= 0;
|
||||
};
|
||||
|
||||
Fraction.prototype.eq = function (other) {
|
||||
return this.compare(other) == 0;
|
||||
};
|
||||
|
||||
Fraction.prototype.ne = function (other) {
|
||||
return this.compare(other) != 0;
|
||||
};
|
||||
|
||||
Fraction.prototype.max = function (other) {
|
||||
return this.gt(other) ? this : other;
|
||||
};
|
||||
|
||||
Fraction.prototype.maximum = function (...others) {
|
||||
others = others.map((x) => new Fraction(x));
|
||||
return others.reduce((max, other) => other.max(max), this);
|
||||
};
|
||||
|
||||
Fraction.prototype.min = function (other) {
|
||||
return this.lt(other) ? this : other;
|
||||
};
|
||||
|
||||
Fraction.prototype.mulmaybe = function (other) {
|
||||
return other !== undefined ? this.mul(other) : undefined;
|
||||
};
|
||||
|
||||
Fraction.prototype.divmaybe = function (other) {
|
||||
return other !== undefined ? this.div(other) : undefined;
|
||||
};
|
||||
|
||||
Fraction.prototype.addmaybe = function (other) {
|
||||
return other !== undefined ? this.add(other) : undefined;
|
||||
};
|
||||
|
||||
Fraction.prototype.submaybe = function (other) {
|
||||
return other !== undefined ? this.sub(other) : undefined;
|
||||
};
|
||||
|
||||
Fraction.prototype.show = function (/* excludeWhole = false */) {
|
||||
// return this.toFraction(excludeWhole);
|
||||
return this.s * this.n + '/' + this.d;
|
||||
};
|
||||
|
||||
Fraction.prototype.or = function (other) {
|
||||
return this.eq(0) ? other : this;
|
||||
};
|
||||
|
||||
const fraction = (n) => {
|
||||
if (typeof n === 'number') {
|
||||
/*
|
||||
https://github.com/infusion/Fraction.js/#doubles
|
||||
„If you pass a double as it is, Fraction.js will perform a number analysis based on Farey Sequences."
|
||||
„If you want to keep the number as it is, convert it to a string, as the string parser will not perform any further observations“
|
||||
|
||||
-> those farey sequences turn out to make pattern querying ~20 times slower! always use strings!
|
||||
-> still, some optimizations could be done: .mul .div .add .sub calls still use numbers
|
||||
*/
|
||||
// n = String(n); // this is actually faster but imprecise...
|
||||
}
|
||||
return Fraction(n);
|
||||
};
|
||||
|
||||
export const gcd = (...fractions) => {
|
||||
fractions = removeUndefineds(fractions);
|
||||
if (fractions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fractions.reduce((gcd, fraction) => gcd.gcd(fraction), fraction(1));
|
||||
};
|
||||
|
||||
export const lcm = (...fractions) => {
|
||||
fractions = removeUndefineds(fractions);
|
||||
if (fractions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const x = fractions.pop();
|
||||
return fractions.reduce(
|
||||
(lcm, fraction) => (lcm === undefined || fraction === undefined ? undefined : lcm.lcm(fraction)),
|
||||
x,
|
||||
);
|
||||
};
|
||||
|
||||
export const isFraction = (x) => x instanceof Fraction;
|
||||
|
||||
fraction._original = Fraction;
|
||||
|
||||
export default fraction;
|
||||
|
||||
// "If you concern performance, cache Fraction.js objects and pass arrays/objects.“
|
||||
// -> tested memoized version, but it's slower than unmemoized, even with repeated evaluation
|
||||
/* const memo = {};
|
||||
const memoizedFraction = (n) => {
|
||||
if (typeof n === 'number') {
|
||||
n = String(n);
|
||||
}
|
||||
if (memo[n] !== undefined) {
|
||||
return memo[n];
|
||||
}
|
||||
memo[n] = Fraction(n);
|
||||
return memo[n];
|
||||
}; */
|
||||
178
src/strudel/core/hap.mjs
Normal file
178
src/strudel/core/hap.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/*
|
||||
hap.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/hap.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import Fraction from './fraction.mjs';
|
||||
import { stringifyValues } from './util.mjs';
|
||||
|
||||
export class Hap {
|
||||
/*
|
||||
Event class, representing a value active during the timespan
|
||||
'part'. This might be a fragment of an event, in which case the
|
||||
timespan will be smaller than the 'whole' timespan, otherwise the
|
||||
two timespans will be the same. The 'part' must never extend outside of the
|
||||
'whole'. If the event represents a continuously changing value
|
||||
then the whole will be returned as None, in which case the given
|
||||
value will have been sampled from the point halfway between the
|
||||
start and end of the 'part' timespan.
|
||||
The context is to store a list of source code locations causing the event.
|
||||
|
||||
The word 'Event' is more or less a reserved word in javascript, hence this
|
||||
class is named called 'Hap'.
|
||||
*/
|
||||
|
||||
constructor(whole, part, value, context = {}, stateful = false) {
|
||||
this.whole = whole;
|
||||
this.part = part;
|
||||
this.value = value;
|
||||
this.context = context;
|
||||
this.stateful = stateful;
|
||||
if (stateful) {
|
||||
console.assert(typeof this.value === 'function', 'Stateful values must be functions');
|
||||
}
|
||||
}
|
||||
|
||||
get duration() {
|
||||
let duration;
|
||||
if (typeof this.value?.duration === 'number') {
|
||||
duration = Fraction(this.value.duration);
|
||||
} else {
|
||||
duration = this.whole.end.sub(this.whole.begin);
|
||||
}
|
||||
if (typeof this.value?.clip === 'number') {
|
||||
return duration.mul(this.value.clip);
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
get endClipped() {
|
||||
return this.whole.begin.add(this.duration);
|
||||
}
|
||||
|
||||
isActive(currentTime) {
|
||||
return this.whole.begin <= currentTime && this.endClipped >= currentTime;
|
||||
}
|
||||
|
||||
isInPast(currentTime) {
|
||||
return currentTime > this.endClipped;
|
||||
}
|
||||
isInNearPast(margin, currentTime) {
|
||||
return currentTime - margin <= this.endClipped;
|
||||
}
|
||||
|
||||
isInFuture(currentTime) {
|
||||
return currentTime < this.whole.begin;
|
||||
}
|
||||
isInNearFuture(margin, currentTime) {
|
||||
return currentTime < this.whole.begin && currentTime > this.whole.begin - margin;
|
||||
}
|
||||
isWithinTime(min, max) {
|
||||
return this.whole.begin <= max && this.endClipped >= min;
|
||||
}
|
||||
|
||||
wholeOrPart() {
|
||||
return this.whole ? this.whole : this.part;
|
||||
}
|
||||
|
||||
withSpan(func) {
|
||||
// Returns a new hap with the function f applies to the hap timespan.
|
||||
const whole = this.whole ? func(this.whole) : undefined;
|
||||
return new Hap(whole, func(this.part), this.value, this.context);
|
||||
}
|
||||
|
||||
withValue(func) {
|
||||
// Returns a new hap with the function f applies to the hap value.
|
||||
return new Hap(this.whole, this.part, func(this.value), this.context);
|
||||
}
|
||||
|
||||
hasOnset() {
|
||||
// Test whether the hap contains the onset, i.e that
|
||||
// the beginning of the part is the same as that of the whole timespan."""
|
||||
return this.whole != undefined && this.whole.begin.equals(this.part.begin);
|
||||
}
|
||||
|
||||
hasTag(tag) {
|
||||
return this.context.tags?.includes(tag);
|
||||
}
|
||||
|
||||
resolveState(state) {
|
||||
if (this.stateful && this.hasOnset()) {
|
||||
console.log('stateful');
|
||||
const func = this.value;
|
||||
const [newState, newValue] = func(state);
|
||||
return [newState, new Hap(this.whole, this.part, newValue, this.context, false)];
|
||||
}
|
||||
return [state, this];
|
||||
}
|
||||
|
||||
spanEquals(other) {
|
||||
return (this.whole == undefined && other.whole == undefined) || this.whole.equals(other.whole);
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
return (
|
||||
this.spanEquals(other) &&
|
||||
this.part.equals(other.part) &&
|
||||
// TODO would == be better ??
|
||||
this.value === other.value
|
||||
);
|
||||
}
|
||||
|
||||
show(compact = false) {
|
||||
const value =
|
||||
typeof this.value === 'object'
|
||||
? compact
|
||||
? JSON.stringify(this.value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ')
|
||||
: JSON.stringify(this.value)
|
||||
: this.value;
|
||||
var spans = '';
|
||||
if (this.whole == undefined) {
|
||||
spans = '~' + this.part.show;
|
||||
} else {
|
||||
var is_whole = this.whole.begin.equals(this.part.begin) && this.whole.end.equals(this.part.end);
|
||||
if (!this.whole.begin.equals(this.part.begin)) {
|
||||
spans = this.whole.begin.show() + ' ⇜ ';
|
||||
}
|
||||
if (!is_whole) {
|
||||
spans += '(';
|
||||
}
|
||||
spans += this.part.show();
|
||||
if (!is_whole) {
|
||||
spans += ')';
|
||||
}
|
||||
if (!this.whole.end.equals(this.part.end)) {
|
||||
spans += ' ⇝ ' + this.whole.end.show();
|
||||
}
|
||||
}
|
||||
return '[ ' + spans + ' | ' + value + ' ]';
|
||||
}
|
||||
|
||||
showWhole(compact = false) {
|
||||
return `${this.whole == undefined ? '~' : this.whole.show()}: ${stringifyValues(this.value, compact)}`;
|
||||
}
|
||||
|
||||
combineContext(b) {
|
||||
const a = this;
|
||||
return { ...a.context, ...b.context, locations: (a.context.locations || []).concat(b.context.locations || []) };
|
||||
}
|
||||
|
||||
setContext(context) {
|
||||
return new Hap(this.whole, this.part, this.value, context);
|
||||
}
|
||||
|
||||
ensureObjectValue() {
|
||||
/* if (isNote(hap.value)) {
|
||||
// supports primitive hap values that look like notes
|
||||
hap.value = { note: hap.value };
|
||||
} */
|
||||
if (typeof this.value !== 'object') {
|
||||
throw new Error(
|
||||
`expected hap.value to be an object, but got "${this.value}". Hint: append .note() or .s() to the end`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Hap;
|
||||
40
src/strudel/core/index.mjs
Normal file
40
src/strudel/core/index.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
index.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/index.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as controls from './controls.mjs'; // legacy
|
||||
export * from './euclid.mjs';
|
||||
import Fraction from './fraction.mjs';
|
||||
import createClock from './zyklus.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
export { Fraction, controls, createClock };
|
||||
export * from './controls.mjs';
|
||||
export * from './hap.mjs';
|
||||
export * from './pattern.mjs';
|
||||
export * from './signal.mjs';
|
||||
export * from './pick.mjs';
|
||||
export * from './state.mjs';
|
||||
export * from './timespan.mjs';
|
||||
export * from './util.mjs';
|
||||
export * from './speak.mjs';
|
||||
export * from './evaluate.mjs';
|
||||
export * from './repl.mjs';
|
||||
export * from './cyclist.mjs';
|
||||
export * from './logger.mjs';
|
||||
export * from './time.mjs';
|
||||
export * from './ui.mjs';
|
||||
export { default as drawLine } from './drawLine.mjs';
|
||||
// below won't work with runtime.mjs (json import fails)
|
||||
/* import * as p from './package.json';
|
||||
export const version = p.version; */
|
||||
logger('🌀 @strudel/core loaded 🌀');
|
||||
if (globalThis._strudelLoaded) {
|
||||
console.warn(
|
||||
`@strudel/core was loaded more than once...
|
||||
This might happen when you have multiple versions of strudel installed.
|
||||
Please check with "npm ls @strudel/core".`,
|
||||
);
|
||||
}
|
||||
globalThis._strudelLoaded = true;
|
||||
35
src/strudel/core/logger.mjs
Normal file
35
src/strudel/core/logger.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export const logKey = 'strudel.log';
|
||||
|
||||
let debounce = 1000,
|
||||
lastMessage,
|
||||
lastTime;
|
||||
|
||||
export function errorLogger(e, origin = 'cyclist') {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(e);
|
||||
}
|
||||
logger(`[${origin}] error: ${e.message}`);
|
||||
}
|
||||
|
||||
export function logger(message, type, data = {}) {
|
||||
let t = performance.now();
|
||||
if (lastMessage === message && t - lastTime < debounce) {
|
||||
return;
|
||||
}
|
||||
lastMessage = message;
|
||||
lastTime = t;
|
||||
console.log(`%c${message}`, 'background-color: black;color:white;border-radius:15px');
|
||||
if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(logKey, {
|
||||
detail: {
|
||||
message,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.key = logKey;
|
||||
105
src/strudel/core/neocyclist.mjs
Normal file
105
src/strudel/core/neocyclist.mjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
neocyclist.mjs - event scheduler like cyclist, except recieves clock pulses from clockworker in order to sync across multiple instances.
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/neocyclist.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { logger } from './logger.mjs';
|
||||
import { ClockCollator, cycleToSeconds } from './util.mjs';
|
||||
|
||||
export class NeoCyclist {
|
||||
constructor({ onTrigger, onToggle, getTime }) {
|
||||
this.started = false;
|
||||
this.cps = 0.5;
|
||||
this.getTime = getTime; // get absolute time
|
||||
this.time_at_last_tick_message = 0;
|
||||
// the clock of the worker and the audio context clock can drift apart over time
|
||||
// aditionally, the message time of the worker pinging the callback to process haps can be inconsistent.
|
||||
// we need to keep a rolling average of the time difference between the worker clock and audio context clock
|
||||
// in order to schedule events consistently.
|
||||
this.collator = new ClockCollator({ getTargetClockTime: getTime });
|
||||
this.onToggle = onToggle;
|
||||
this.latency = 0.1; // fixed trigger time offset
|
||||
this.cycle = 0;
|
||||
this.id = Math.round(Date.now() * Math.random());
|
||||
this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url));
|
||||
this.worker.port.start();
|
||||
this.channel = new BroadcastChannel('strudeltick');
|
||||
const tickCallback = (payload) => {
|
||||
const { cps, begin, end, cycle, time } = payload;
|
||||
this.cps = cps;
|
||||
this.cycle = cycle;
|
||||
const currentTime = this.collator.calculateOffset(time) + time;
|
||||
processHaps(begin, end, currentTime);
|
||||
this.time_at_last_tick_message = currentTime;
|
||||
};
|
||||
|
||||
const processHaps = (begin, end, currentTime) => {
|
||||
if (this.started === false) {
|
||||
return;
|
||||
}
|
||||
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'neocyclist' });
|
||||
haps.forEach((hap) => {
|
||||
if (hap.hasOnset()) {
|
||||
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
|
||||
const targetTime = timeUntilTrigger + currentTime + this.latency;
|
||||
const duration = cycleToSeconds(hap.duration, this.cps);
|
||||
onTrigger?.(hap, 0, duration, this.cps, targetTime);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// receive messages from worker clock and process them
|
||||
this.channel.onmessage = (message) => {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
const { payload, type } = message.data;
|
||||
|
||||
switch (type) {
|
||||
case 'tick': {
|
||||
tickCallback(payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
sendMessage(type, payload) {
|
||||
this.worker.port.postMessage({ type, payload, id: this.id });
|
||||
}
|
||||
|
||||
now() {
|
||||
const gap = (this.getTime() - this.time_at_last_tick_message) * this.cps;
|
||||
return this.cycle + gap;
|
||||
}
|
||||
setCps(cps = 1) {
|
||||
this.sendMessage('cpschange', { cps });
|
||||
}
|
||||
setCycle(cycle) {
|
||||
this.sendMessage('setcycle', { cycle });
|
||||
}
|
||||
setStarted(started) {
|
||||
this.sendMessage('toggle', { started });
|
||||
this.started = started;
|
||||
this.onToggle?.(started);
|
||||
}
|
||||
start() {
|
||||
logger('[cyclist] start');
|
||||
this.setStarted(true);
|
||||
}
|
||||
stop() {
|
||||
logger('[cyclist] stop');
|
||||
this.collator.reset();
|
||||
this.setStarted(false);
|
||||
}
|
||||
setPattern(pat, autostart = false) {
|
||||
this.pattern = pat;
|
||||
if (autostart && !this.started) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
log(begin, end, haps) {
|
||||
const onsets = haps.filter((h) => h.hasOnset());
|
||||
console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`);
|
||||
}
|
||||
}
|
||||
3626
src/strudel/core/pattern.mjs
Normal file
3626
src/strudel/core/pattern.mjs
Normal file
File diff suppressed because it is too large
Load diff
213
src/strudel/core/pick.mjs
Normal file
213
src/strudel/core/pick.mjs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/*
|
||||
pick.mjs - methods that use one pattern to pick events from other patterns.
|
||||
Copyright (C) 2024 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/signal.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Pattern, reify, silence, register } from './pattern.mjs';
|
||||
|
||||
import { _mod, clamp, objectMap } from './util.mjs';
|
||||
|
||||
const _pick = function (lookup, pat, modulo = true) {
|
||||
const array = Array.isArray(lookup);
|
||||
const len = Object.keys(lookup).length;
|
||||
|
||||
lookup = objectMap(lookup, reify);
|
||||
|
||||
if (len === 0) {
|
||||
return silence;
|
||||
}
|
||||
return pat.fmap((i) => {
|
||||
let key = i;
|
||||
if (array) {
|
||||
key = modulo ? Math.round(key) % len : clamp(Math.round(key), 0, lookup.length - 1);
|
||||
}
|
||||
return lookup[key];
|
||||
});
|
||||
};
|
||||
|
||||
/** * Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
|
||||
* Similar to `inhabit`, but maintains the structure of the original patterns.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* note("<0 1 2!2 3>".pick(["g a", "e f", "f g f g" , "g c d"]))
|
||||
* @example
|
||||
* sound("<0 1 [2,0]>".pick(["bd sd", "cp cp", "hh hh"]))
|
||||
* @example
|
||||
* sound("<0!2 [0,1] 1>".pick(["bd(3,8)", "sd sd"]))
|
||||
* @example
|
||||
* s("<a!2 [a,b] b>".pick({a: "bd(3,8)", b: "sd sd"}))
|
||||
*/
|
||||
|
||||
export const pick = function (lookup, pat) {
|
||||
// backward compatibility - the args used to be flipped
|
||||
if (Array.isArray(pat)) {
|
||||
[pat, lookup] = [lookup, pat];
|
||||
}
|
||||
return __pick(lookup, pat);
|
||||
};
|
||||
|
||||
const __pick = register('pick', function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).innerJoin();
|
||||
});
|
||||
|
||||
/** * The same as `pick`, but if you pick a number greater than the size of the list,
|
||||
* it wraps around, rather than sticking at the maximum value.
|
||||
* For example, if you pick the fifth pattern of a list of three, you'll get the
|
||||
* second one.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
|
||||
export const pickmod = register('pickmod', function (lookup, pat) {
|
||||
return _pick(lookup, pat, true).innerJoin();
|
||||
});
|
||||
|
||||
/** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern.
|
||||
* @param {Pattern} pat
|
||||
* @param {Pattern} lookup a pattern of indices
|
||||
* @param {function[]} funcs the array of functions from which to pull
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* s("bd [rim hh]").pickF("<0 1 2>", [rev,jux(rev),fast(2)])
|
||||
* @example
|
||||
* note("<c2 d2>(3,8)").s("square")
|
||||
* .pickF("<0 2> 1", [jux(rev),fast(2),x=>x.lpf(800)])
|
||||
*/
|
||||
export const pickF = register('pickF', function (lookup, funcs, pat) {
|
||||
return pat.apply(pick(lookup, funcs));
|
||||
});
|
||||
|
||||
/** * The same as `pickF`, but if you pick a number greater than the size of the functions list,
|
||||
* it wraps around, rather than sticking at the maximum value.
|
||||
* @param {Pattern} pat
|
||||
* @param {Pattern} lookup a pattern of indices
|
||||
* @param {function[]} funcs the array of functions from which to pull
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const pickmodF = register('pickmodF', function (lookup, funcs, pat) {
|
||||
return pat.apply(pickmod(lookup, funcs));
|
||||
});
|
||||
|
||||
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const pickOut = register('pickOut', function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).outerJoin();
|
||||
});
|
||||
|
||||
/** * The same as `pickOut`, but if you pick a number greater than the size of the list,
|
||||
* it wraps around, rather than sticking at the maximum value.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const pickmodOut = register('pickmodOut', function (lookup, pat) {
|
||||
return _pick(lookup, pat, true).outerJoin();
|
||||
});
|
||||
|
||||
/** * Similar to `pick`, but the choosen pattern is restarted when its index is triggered.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const pickRestart = register('pickRestart', function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).restartJoin();
|
||||
});
|
||||
|
||||
/** * The same as `pickRestart`, but if you pick a number greater than the size of the list,
|
||||
* it wraps around, rather than sticking at the maximum value.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* "<a@2 b@2 c@2 d@2>".pickRestart({
|
||||
a: n("0 1 2 0"),
|
||||
b: n("2 3 4 ~"),
|
||||
c: n("[4 5] [4 3] 2 0"),
|
||||
d: n("0 -3 0 ~")
|
||||
}).scale("C:major").s("piano")
|
||||
*/
|
||||
export const pickmodRestart = register('pickmodRestart', function (lookup, pat) {
|
||||
return _pick(lookup, pat, true).restartJoin();
|
||||
});
|
||||
|
||||
/** * Similar to `pick`, but the choosen pattern is reset when its index is triggered.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const pickReset = register('pickReset', function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).resetJoin();
|
||||
});
|
||||
|
||||
/** * The same as `pickReset`, but if you pick a number greater than the size of the list,
|
||||
* it wraps around, rather than sticking at the maximum value.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const pickmodReset = register('pickmodReset', function (lookup, pat) {
|
||||
return _pick(lookup, pat, true).resetJoin();
|
||||
});
|
||||
|
||||
/** Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
|
||||
* Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
|
||||
* @name inhabit
|
||||
* @synonyms pickSqueeze
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* "<a b [a,b]>".inhabit({a: s("bd(3,8)"),
|
||||
b: s("cp sd")
|
||||
})
|
||||
* @example
|
||||
* s("a@2 [a b] a".inhabit({a: "bd(3,8)", b: "sd sd"})).slow(4)
|
||||
*/
|
||||
export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).squeezeJoin();
|
||||
});
|
||||
|
||||
/** * The same as `inhabit`, but if you pick a number greater than the size of the list,
|
||||
* it wraps around, rather than sticking at the maximum value.
|
||||
* For example, if you pick the fifth pattern of a list of three, you'll get the
|
||||
* second one.
|
||||
* @name inhabitmod
|
||||
* @synonyms pickmodSqueeze
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
|
||||
export const { inhabitmod, pickmodSqueeze } = register(['inhabitmod', 'pickmodSqueeze'], function (lookup, pat) {
|
||||
return _pick(lookup, pat, true).squeezeJoin();
|
||||
});
|
||||
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
279
src/strudel/core/repl.mjs
Normal file
279
src/strudel/core/repl.mjs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { NeoCyclist } from './neocyclist.mjs';
|
||||
import { Cyclist } from './cyclist.mjs';
|
||||
import { evaluate as _evaluate } from './evaluate.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
import { setTime } from './time.mjs';
|
||||
import { evalScope } from './evaluate.mjs';
|
||||
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||
|
||||
export function repl({
|
||||
defaultOutput,
|
||||
onEvalError,
|
||||
beforeEval,
|
||||
beforeStart,
|
||||
afterEval,
|
||||
getTime,
|
||||
transpiler,
|
||||
onToggle,
|
||||
editPattern,
|
||||
onUpdateState,
|
||||
sync = false,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
id,
|
||||
mondo = false,
|
||||
}) {
|
||||
const state = {
|
||||
schedulerError: undefined,
|
||||
evalError: undefined,
|
||||
code: '// LOADING',
|
||||
activeCode: '// LOADING',
|
||||
pattern: undefined,
|
||||
miniLocations: [],
|
||||
widgets: [],
|
||||
pending: false,
|
||||
started: false,
|
||||
};
|
||||
|
||||
const transpilerOptions = {
|
||||
id,
|
||||
};
|
||||
|
||||
const updateState = (update) => {
|
||||
Object.assign(state, update);
|
||||
state.isDirty = state.code !== state.activeCode;
|
||||
state.error = state.evalError || state.schedulerError;
|
||||
onUpdateState?.(state);
|
||||
};
|
||||
|
||||
const schedulerOptions = {
|
||||
onTrigger: getTrigger({ defaultOutput, getTime }),
|
||||
getTime,
|
||||
onToggle: (started) => {
|
||||
updateState({ started });
|
||||
onToggle?.(started);
|
||||
},
|
||||
setInterval,
|
||||
clearInterval,
|
||||
beforeStart,
|
||||
};
|
||||
|
||||
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
|
||||
const scheduler =
|
||||
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
|
||||
let pPatterns = {};
|
||||
let anonymousIndex = 0;
|
||||
let allTransform;
|
||||
let eachTransform;
|
||||
|
||||
const hush = function () {
|
||||
pPatterns = {};
|
||||
anonymousIndex = 0;
|
||||
allTransform = undefined;
|
||||
eachTransform = undefined;
|
||||
return silence;
|
||||
};
|
||||
|
||||
// helper to get a patternified pure value out
|
||||
function unpure(pat) {
|
||||
if (pat._Pattern) {
|
||||
return pat.__pure;
|
||||
}
|
||||
return pat;
|
||||
}
|
||||
|
||||
const setPattern = async (pattern, autostart = true) => {
|
||||
pattern = editPattern?.(pattern) || pattern;
|
||||
await scheduler.setPattern(pattern, autostart);
|
||||
return pattern;
|
||||
};
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
|
||||
const stop = () => scheduler.stop();
|
||||
const start = () => scheduler.start();
|
||||
const pause = () => scheduler.pause();
|
||||
const toggle = () => scheduler.toggle();
|
||||
const setCps = (cps) => {
|
||||
scheduler.setCps(unpure(cps));
|
||||
return silence;
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes the global tempo to the given cycles per minute
|
||||
*
|
||||
* @name setcpm
|
||||
* @alias setCpm
|
||||
* @param {number} cpm cycles per minute
|
||||
* @example
|
||||
* setcpm(140/4) // =140 bpm in 4/4
|
||||
* $: s("bd*4,[- sd]*2").bank('tr707')
|
||||
*/
|
||||
const setCpm = (cpm) => {
|
||||
scheduler.setCps(unpure(cpm) / 60);
|
||||
return silence;
|
||||
};
|
||||
|
||||
// TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`..
|
||||
|
||||
/** Applies a function to all the running patterns. Note that the patterns are groups together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
|
||||
* a version that applies the function to each pattern separately.
|
||||
* ```
|
||||
* $: sound("bd - cp sd")
|
||||
* $: sound("hh*8")
|
||||
* all(fast("<2 3>"))
|
||||
* ```
|
||||
* ```
|
||||
* $: sound("bd - cp sd")
|
||||
* $: sound("hh*8")
|
||||
* all(x => x.pianoroll())
|
||||
* ```
|
||||
*/
|
||||
let allTransforms = [];
|
||||
const all = function (transform) {
|
||||
allTransforms.push(transform);
|
||||
return silence;
|
||||
};
|
||||
/** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern.
|
||||
* ```
|
||||
* $: sound("bd - cp sd")
|
||||
* $: sound("hh*8")
|
||||
* each(fast("<2 3>"))
|
||||
* ```
|
||||
*/
|
||||
const each = function (transform) {
|
||||
eachTransform = transform;
|
||||
return silence;
|
||||
};
|
||||
|
||||
// set pattern methods that use this repl via closure
|
||||
const injectPatternMethods = () => {
|
||||
Pattern.prototype.p = function (id) {
|
||||
if (typeof id === 'string' && (id.startsWith('_') || id.endsWith('_'))) {
|
||||
// allows muting a pattern x with x_ or _x
|
||||
return silence;
|
||||
}
|
||||
if (id === '$') {
|
||||
// allows adding anonymous patterns with $:
|
||||
id = `$${anonymousIndex}`;
|
||||
anonymousIndex++;
|
||||
}
|
||||
pPatterns[id] = this;
|
||||
return this;
|
||||
};
|
||||
Pattern.prototype.q = function (id) {
|
||||
return silence;
|
||||
};
|
||||
try {
|
||||
for (let i = 1; i < 10; ++i) {
|
||||
Object.defineProperty(Pattern.prototype, `d${i}`, {
|
||||
get() {
|
||||
return this.p(i);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(Pattern.prototype, `p${i}`, {
|
||||
get() {
|
||||
return this.p(i);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Pattern.prototype[`q${i}`] = silence;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('injectPatternMethods: error:', err);
|
||||
}
|
||||
const cpm = register('cpm', function (cpm, pat) {
|
||||
return pat._fast(cpm / 60 / scheduler.cps);
|
||||
});
|
||||
return evalScope({
|
||||
all,
|
||||
each,
|
||||
hush,
|
||||
cpm,
|
||||
setCps,
|
||||
setcps: setCps,
|
||||
setCpm,
|
||||
setcpm: setCpm,
|
||||
});
|
||||
};
|
||||
|
||||
const evaluate = async (code, autostart = true, shouldHush = true) => {
|
||||
if (!code) {
|
||||
throw new Error('no code to evaluate');
|
||||
}
|
||||
try {
|
||||
updateState({ code, pending: true });
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
await beforeEval?.({ code });
|
||||
allTransforms = []; // reset all transforms
|
||||
shouldHush && hush();
|
||||
|
||||
if (mondo) {
|
||||
code = `mondolang\`${code}\``;
|
||||
}
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||
if (Object.keys(pPatterns).length) {
|
||||
let patterns = [];
|
||||
for (const [key, value] of Object.entries(pPatterns)) {
|
||||
patterns.push(value.withState((state) => state.setControls({ id: key })));
|
||||
}
|
||||
if (eachTransform) {
|
||||
// Explicit lambda so only element (not index and array) are passed
|
||||
patterns = patterns.map((x) => eachTransform(x));
|
||||
}
|
||||
pattern = stack(...patterns);
|
||||
} else if (eachTransform) {
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransforms.length) {
|
||||
for (let i in allTransforms) {
|
||||
pattern = allTransforms[i](pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPattern(pattern)) {
|
||||
const message = `got "${typeof evaluated}" instead of pattern`;
|
||||
throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.'));
|
||||
}
|
||||
logger(`[eval] code updated`);
|
||||
pattern = await setPattern(pattern, autostart);
|
||||
updateState({
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
activeCode: code,
|
||||
pattern,
|
||||
evalError: undefined,
|
||||
schedulerError: undefined,
|
||||
pending: false,
|
||||
});
|
||||
afterEval?.({ code, pattern, meta });
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
logger(`[eval] error: ${err.message}`, 'error');
|
||||
console.error(err);
|
||||
updateState({ evalError: err, pending: false });
|
||||
onEvalError?.(err);
|
||||
}
|
||||
};
|
||||
const setCode = (code) => updateState({ code });
|
||||
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||
}
|
||||
|
||||
export const getTrigger =
|
||||
({ getTime, defaultOutput }) =>
|
||||
async (hap, deadline, duration, cps, t) => {
|
||||
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
|
||||
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
|
||||
try {
|
||||
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
|
||||
await defaultOutput(hap, deadline, duration, cps, t);
|
||||
}
|
||||
if (hap.context.onTrigger) {
|
||||
// call signature of output / onTrigger is different...
|
||||
await hap.context.onTrigger(hap, getTime(), cps, t);
|
||||
}
|
||||
} catch (err) {
|
||||
errorLogger(err, 'getTrigger');
|
||||
}
|
||||
};
|
||||
839
src/strudel/core/signal.mjs
Normal file
839
src/strudel/core/signal.mjs
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
/*
|
||||
signal.mjs - continuous patterns
|
||||
Copyright (C) 2024 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/signal.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Hap } from './hap.mjs';
|
||||
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs';
|
||||
import Fraction from './fraction.mjs';
|
||||
|
||||
import { id, keyAlias, getCurrentKeyboardState } from './util.mjs';
|
||||
|
||||
export function steady(value) {
|
||||
// A continuous value
|
||||
return new Pattern((state) => [new Hap(undefined, state.span, value)]);
|
||||
}
|
||||
|
||||
export const signal = (func) => {
|
||||
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))];
|
||||
return new Pattern(query);
|
||||
};
|
||||
|
||||
/**
|
||||
* A sawtooth signal between 0 and 1.
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* note("<c3 [eb3,g3] g2 [g3,bb3]>*8")
|
||||
* .clip(saw.slow(2))
|
||||
* @example
|
||||
* n(saw.range(0,8).segment(8))
|
||||
* .scale('C major')
|
||||
*
|
||||
*/
|
||||
export const saw = signal((t) => t % 1);
|
||||
|
||||
/**
|
||||
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
|
||||
*
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export const saw2 = saw.toBipolar();
|
||||
|
||||
/**
|
||||
* A sawtooth signal between 1 and 0 (like `saw`, but flipped).
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* note("<c3 [eb3,g3] g2 [g3,bb3]>*8")
|
||||
* .clip(isaw.slow(2))
|
||||
* @example
|
||||
* n(isaw.range(0,8).segment(8))
|
||||
* .scale('C major')
|
||||
*
|
||||
*/
|
||||
export const isaw = signal((t) => 1 - (t % 1));
|
||||
|
||||
/**
|
||||
* A sawtooth signal between 1 and -1 (like `saw2`, but flipped).
|
||||
*
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export const isaw2 = isaw.toBipolar();
|
||||
|
||||
/**
|
||||
* A sine signal between -1 and 1 (like `sine`, but bipolar).
|
||||
*
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export const sine2 = signal((t) => Math.sin(Math.PI * 2 * t));
|
||||
|
||||
/**
|
||||
* A sine signal between 0 and 1.
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(sine.segment(16).range(0,15))
|
||||
* .scale("C:minor")
|
||||
*
|
||||
*/
|
||||
export const sine = sine2.fromBipolar();
|
||||
|
||||
/**
|
||||
* A cosine signal between 0 and 1.
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(stack(sine,cosine).segment(16).range(0,15))
|
||||
* .scale("C:minor")
|
||||
*
|
||||
*/
|
||||
export const cosine = sine._early(Fraction(1).div(4));
|
||||
|
||||
/**
|
||||
* A cosine signal between -1 and 1 (like `cosine`, but bipolar).
|
||||
*
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export const cosine2 = sine2._early(Fraction(1).div(4));
|
||||
|
||||
/**
|
||||
* A square signal between 0 and 1.
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(square.segment(4).range(0,7)).scale("C:minor")
|
||||
*
|
||||
*/
|
||||
export const square = signal((t) => Math.floor((t * 2) % 2));
|
||||
|
||||
/**
|
||||
* A square signal between -1 and 1 (like `square`, but bipolar).
|
||||
*
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export const square2 = square.toBipolar();
|
||||
|
||||
/**
|
||||
* A triangle signal between 0 and 1.
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(tri.segment(8).range(0,7)).scale("C:minor")
|
||||
*
|
||||
*/
|
||||
export const tri = fastcat(saw, isaw);
|
||||
|
||||
/**
|
||||
* A triangle signal between -1 and 1 (like `tri`, but bipolar).
|
||||
*
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export const tri2 = fastcat(saw2, isaw2);
|
||||
|
||||
/**
|
||||
* An inverted triangle signal between 1 and 0 (like `tri`, but flipped).
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(itri.segment(8).range(0,7)).scale("C:minor")
|
||||
*
|
||||
*/
|
||||
export const itri = fastcat(isaw, saw);
|
||||
|
||||
/**
|
||||
* An inverted triangle signal between -1 and 1 (like `itri`, but bipolar).
|
||||
*
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export const itri2 = fastcat(isaw2, saw2);
|
||||
|
||||
/**
|
||||
* A signal representing the cycle time.
|
||||
*
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export const time = signal(id);
|
||||
|
||||
/**
|
||||
* The mouse's x position value ranges from 0 to 1.
|
||||
* @name mousex
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(mousex.segment(4).range(0,7)).scale("C:minor")
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* The mouse's y position value ranges from 0 to 1.
|
||||
* @name mousey
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* n(mousey.segment(4).range(0,7)).scale("C:minor")
|
||||
*
|
||||
*/
|
||||
let _mouseY = 0,
|
||||
_mouseX = 0;
|
||||
if (typeof window !== 'undefined') {
|
||||
//document.onmousemove = (e) => {
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
_mouseY = e.clientY / document.body.clientHeight;
|
||||
_mouseX = e.clientX / document.body.clientWidth;
|
||||
});
|
||||
}
|
||||
|
||||
export const mousey = signal(() => _mouseY);
|
||||
export const mouseY = signal(() => _mouseY);
|
||||
export const mousex = signal(() => _mouseX);
|
||||
export const mouseX = signal(() => _mouseX);
|
||||
|
||||
// random signals
|
||||
|
||||
const xorwise = (x) => {
|
||||
const a = (x << 13) ^ x;
|
||||
const b = (a >> 17) ^ a;
|
||||
return (b << 5) ^ b;
|
||||
};
|
||||
|
||||
// stretch 300 cycles over the range of [0,2**29 == 536870912) then apply the xorshift algorithm
|
||||
const _frac = (x) => x - Math.trunc(x);
|
||||
|
||||
const timeToIntSeed = (x) => xorwise(Math.trunc(_frac(x / 300) * 536870912));
|
||||
|
||||
const intSeedToRand = (x) => (x % 536870912) / 536870912;
|
||||
|
||||
const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
|
||||
|
||||
const timeToRandsPrime = (seed, n) => {
|
||||
const result = [];
|
||||
// eslint-disable-next-line
|
||||
for (let i = 0; i < n; ++i) {
|
||||
result.push(intSeedToRand(seed));
|
||||
seed = xorwise(seed);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* A discrete pattern of numbers from 0 to n-1
|
||||
* @example
|
||||
* n(run(4)).scale("C4:pentatonic")
|
||||
* // n("0 1 2 3").scale("C4:pentatonic")
|
||||
*/
|
||||
export const run = (n) => saw.range(0, n).round().segment(n);
|
||||
|
||||
/**
|
||||
* Creates a pattern from a binary number.
|
||||
*
|
||||
* @name binary
|
||||
* @param {number} n - input number to convert to binary
|
||||
* @example
|
||||
* "hh".s().struct(binary(5))
|
||||
* // "hh".s().struct("1 0 1")
|
||||
*/
|
||||
export const binary = (n) => {
|
||||
const nBits = reify(n).log2(0).floor().add(1);
|
||||
return binaryN(n, nBits);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a pattern from a binary number, padded to n bits long.
|
||||
*
|
||||
* @name binaryN
|
||||
* @param {number} n - input number to convert to binary
|
||||
* @param {number} nBits - pattern length, defaults to 16
|
||||
* @example
|
||||
* "hh".s().struct(binaryN(55532, 16))
|
||||
* // "hh".s().struct("1 1 0 1 1 0 0 0 1 1 1 0 1 1 0 0")
|
||||
*/
|
||||
export const binaryN = (n, nBits = 16) => {
|
||||
nBits = reify(nBits);
|
||||
// Shift and mask, putting msb on the right-side
|
||||
const bitPos = run(nBits).mul(-1).add(nBits.sub(1));
|
||||
return reify(n).segment(nBits).brshift(bitPos).band(pure(1));
|
||||
};
|
||||
|
||||
export const randrun = (n) => {
|
||||
return signal((t) => {
|
||||
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
||||
const rands = timeToRands(t.floor().add(0.5), n);
|
||||
const nums = rands
|
||||
.map((n, i) => [n, i])
|
||||
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
|
||||
.map((x) => x[1]);
|
||||
const i = t.cyclePos().mul(n).floor() % n;
|
||||
return nums[i];
|
||||
})._segment(n);
|
||||
};
|
||||
|
||||
const _rearrangeWith = (ipat, n, pat) => {
|
||||
const pats = [...Array(n).keys()].map((i) => pat.zoom(Fraction(i).div(n), Fraction(i + 1).div(n)));
|
||||
return ipat.fmap((i) => pats[i].repeatCycles(n)._fast(n)).innerJoin();
|
||||
};
|
||||
|
||||
/**
|
||||
* Slices a pattern into the given number of parts, then plays those parts in random order.
|
||||
* Each part will be played exactly once per cycle.
|
||||
* @name shuffle
|
||||
* @example
|
||||
* note("c d e f").sound("piano").shuffle(4)
|
||||
* @example
|
||||
* seq("c d e f".shuffle(4), "g").note().sound("piano")
|
||||
*/
|
||||
export const shuffle = register('shuffle', (n, pat) => {
|
||||
return _rearrangeWith(randrun(n), n, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* Slices a pattern into the given number of parts, then plays those parts at random. Similar to `shuffle`,
|
||||
* but parts might be played more than once, or not at all, per cycle.
|
||||
* @name scramble
|
||||
* @example
|
||||
* note("c d e f").sound("piano").scramble(4)
|
||||
* @example
|
||||
* seq("c d e f".scramble(4), "g").note().sound("piano")
|
||||
*/
|
||||
export const scramble = register('scramble', (n, pat) => {
|
||||
return _rearrangeWith(_irand(n)._segment(n), n, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* A continuous pattern of random numbers, between 0 and 1.
|
||||
*
|
||||
* @name rand
|
||||
* @example
|
||||
* // randomly change the cutoff
|
||||
* s("bd*4,hh*8").cutoff(rand.range(500,8000))
|
||||
*
|
||||
*/
|
||||
export const rand = signal(timeToRand);
|
||||
/**
|
||||
* A continuous pattern of random numbers, between -1 and 1
|
||||
*/
|
||||
export const rand2 = rand.toBipolar();
|
||||
|
||||
export const _brandBy = (p) => rand.fmap((x) => x < p);
|
||||
|
||||
/**
|
||||
* A continuous pattern of 0 or 1 (binary random), with a probability for the value being 1
|
||||
*
|
||||
* @name brandBy
|
||||
* @param {number} probability - a number between 0 and 1
|
||||
* @example
|
||||
* s("hh*10").pan(brandBy(0.2))
|
||||
*/
|
||||
export const brandBy = (pPat) => reify(pPat).fmap(_brandBy).innerJoin();
|
||||
|
||||
/**
|
||||
* A continuous pattern of 0 or 1 (binary random)
|
||||
*
|
||||
* @name brand
|
||||
* @example
|
||||
* s("hh*10").pan(brand)
|
||||
*/
|
||||
export const brand = _brandBy(0.5);
|
||||
|
||||
export const _irand = (i) => rand.fmap((x) => Math.trunc(x * i));
|
||||
|
||||
/**
|
||||
* A continuous pattern of random integers, between 0 and n-1.
|
||||
*
|
||||
* @name irand
|
||||
* @param {number} n max value (exclusive)
|
||||
* @example
|
||||
* // randomly select scale notes from 0 - 7 (= C to C)
|
||||
* n(irand(8)).struct("x x*2 x x*3").scale("C:minor")
|
||||
*
|
||||
*/
|
||||
export const irand = (ipat) => reify(ipat).fmap(_irand).innerJoin();
|
||||
|
||||
export const __chooseWith = (pat, xs) => {
|
||||
xs = xs.map(reify);
|
||||
if (xs.length == 0) {
|
||||
return silence;
|
||||
}
|
||||
|
||||
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
|
||||
* pattern of numbers, which should be in the range of 0..1
|
||||
* @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();
|
||||
};
|
||||
|
||||
/**
|
||||
* As with {chooseWith}, but the structure comes from the chosen values, rather
|
||||
* than the pattern you're using to choose with.
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const chooseInWith = (pat, xs) => {
|
||||
return __chooseWith(pat, xs).innerJoin();
|
||||
};
|
||||
|
||||
/**
|
||||
* Chooses randomly from the given list of elements.
|
||||
* @param {...any} xs values / patterns to choose from.
|
||||
* @returns {Pattern} - a continuous pattern.
|
||||
* @example
|
||||
* note("c2 g2!2 d2 f1").s(choose("sine", "triangle", "bd:6"))
|
||||
*/
|
||||
export const choose = (...xs) => chooseWith(rand, xs);
|
||||
|
||||
// todo: doc
|
||||
export const chooseIn = (...xs) => chooseInWith(rand, xs);
|
||||
export const chooseOut = choose;
|
||||
|
||||
/**
|
||||
* Chooses from the given list of values (or patterns of values), according
|
||||
* to the pattern that the method is called on. The pattern should be in
|
||||
* the range 0 .. 1.
|
||||
* @param {...any} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
Pattern.prototype.choose = function (...xs) {
|
||||
return chooseWith(this, xs);
|
||||
};
|
||||
|
||||
/**
|
||||
* As with choose, but the pattern that this method is called on should be
|
||||
* in the range -1 .. 1
|
||||
* @param {...any} xs
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
Pattern.prototype.choose2 = function (...xs) {
|
||||
return chooseWith(this.fromBipolar(), xs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks one of the elements at random each cycle.
|
||||
* @synonyms randcat
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* chooseCycles("bd", "hh", "sd").s().fast(8)
|
||||
* @example
|
||||
* s("bd | hh | sd").fast(8)
|
||||
*/
|
||||
export const chooseCycles = (...xs) => chooseInWith(rand.segment(1), xs);
|
||||
|
||||
export const randcat = chooseCycles;
|
||||
|
||||
const _wchooseWith = function (pat, ...pairs) {
|
||||
// A list of patterns of values
|
||||
const values = pairs.map((pair) => reify(pair[0]));
|
||||
|
||||
// A list of weight patterns
|
||||
const weights = [];
|
||||
|
||||
let total = pure(0);
|
||||
for (const pair of pairs) {
|
||||
// 'add' accepts either values or patterns of values here, so no need
|
||||
// to explicitly reify
|
||||
total = total.add(pair[1]);
|
||||
// accumulate our list of weight patterns
|
||||
weights.push(total);
|
||||
}
|
||||
// a pattern of lists of weights
|
||||
const weightspat = sequenceP(weights);
|
||||
|
||||
// Takes a number from 0-1, returns a pattern of patterns of values
|
||||
const match = function (r) {
|
||||
const findpat = total.mul(r);
|
||||
return weightspat.fmap((weights) => (find) => values[weights.findIndex((x) => x > find, weights)]).appLeft(findpat);
|
||||
};
|
||||
// This returns a pattern of patterns.. The innerJoin is in wchooseCycles
|
||||
return pat.bind(match);
|
||||
};
|
||||
|
||||
const wchooseWith = (...args) => _wchooseWith(...args).outerJoin();
|
||||
|
||||
/**
|
||||
* Chooses randomly from the given list of elements by giving a probability to each element
|
||||
* @param {...any} pairs arrays of value and weight
|
||||
* @returns {Pattern} - a continuous pattern.
|
||||
* @example
|
||||
* note("c2 g2!2 d2 f1").s(wchoose(["sine",10], ["triangle",1], ["bd:6",1]))
|
||||
*/
|
||||
export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
|
||||
|
||||
/**
|
||||
* Picks one of the elements at random each cycle by giving a probability to each element
|
||||
* @synonyms wrandcat
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
|
||||
* @example
|
||||
* wchooseCycles(["bd bd bd",5], ["hh hh hh",3], ["sd sd sd",1]).fast(4).s()
|
||||
* @example
|
||||
* // The probability can itself be a pattern
|
||||
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
|
||||
*/
|
||||
export const wchooseCycles = (...pairs) => _wchooseWith(rand.segment(1), ...pairs).innerJoin();
|
||||
|
||||
export const wrandcat = wchooseCycles;
|
||||
|
||||
function _perlin(t) {
|
||||
let ta = Math.floor(t);
|
||||
let tb = ta + 1;
|
||||
const smootherStep = (x) => 6.0 * x ** 5 - 15.0 * x ** 4 + 10.0 * x ** 3;
|
||||
const interp = (x) => (a) => (b) => a + smootherStep(x) * (b - a);
|
||||
const v = interp(t - ta)(timeToRand(ta))(timeToRand(tb));
|
||||
return v;
|
||||
}
|
||||
export const perlinWith = (tpat) => {
|
||||
return tpat.fmap(_perlin);
|
||||
};
|
||||
|
||||
function _berlin(t) {
|
||||
const prevRidgeStartIndex = Math.floor(t);
|
||||
const nextRidgeStartIndex = prevRidgeStartIndex + 1;
|
||||
|
||||
const prevRidgeBottomPoint = timeToRand(prevRidgeStartIndex);
|
||||
const nextRidgeTopPoint = timeToRand(nextRidgeStartIndex) + prevRidgeBottomPoint;
|
||||
|
||||
const currentPercent = (t - prevRidgeStartIndex) / (nextRidgeStartIndex - prevRidgeStartIndex);
|
||||
const interp = (a, b, t) => {
|
||||
return a + (b - a) * t;
|
||||
};
|
||||
return interp(prevRidgeBottomPoint, nextRidgeTopPoint, currentPercent) / 2;
|
||||
}
|
||||
|
||||
export const berlinWith = (tpat) => {
|
||||
return tpat.fmap(_berlin);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a continuous pattern of [perlin noise](https://en.wikipedia.org/wiki/Perlin_noise), in the range 0..1.
|
||||
*
|
||||
* @name perlin
|
||||
* @example
|
||||
* // randomly change the cutoff
|
||||
* s("bd*4,hh*8").cutoff(perlin.range(500,8000))
|
||||
*
|
||||
*/
|
||||
export const perlin = perlinWith(time.fmap((v) => Number(v)));
|
||||
|
||||
/**
|
||||
* Generates a continuous pattern of [berlin noise](conceived by Jame Coyne and Jade Rowland as a joke but turned out to be surprisingly cool and useful,
|
||||
* like perlin noise but with sawtooth waves), in the range 0..1.
|
||||
*
|
||||
* @name berlin
|
||||
* @example
|
||||
* // ascending arpeggios
|
||||
* n("0!16".add(berlin.fast(4).mul(14))).scale("d:minor")
|
||||
*
|
||||
*/
|
||||
export const berlin = berlinWith(time.fmap((v) => Number(v)));
|
||||
|
||||
export const degradeByWith = register(
|
||||
'degradeByWith',
|
||||
(withPat, x, pat) => pat.fmap((a) => (_) => a).appLeft(withPat.filterValues((v) => v > x)),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
/**
|
||||
* Randomly removes events from the pattern by a given amount.
|
||||
* 0 = 0% chance of removal
|
||||
* 1 = 100% chance of removal
|
||||
*
|
||||
* @name degradeBy
|
||||
* @memberof Pattern
|
||||
* @param {number} amount - a number between 0 and 1
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").degradeBy(0.2)
|
||||
* @example
|
||||
* s("[hh?0.2]*8")
|
||||
* @example
|
||||
* //beat generator
|
||||
* s("bd").segment(16).degradeBy(.5).ribbon(16,1)
|
||||
*/
|
||||
export const degradeBy = register(
|
||||
'degradeBy',
|
||||
function (x, pat) {
|
||||
return pat._degradeByWith(rand, x);
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* Randomly removes 50% of events from the pattern. Shorthand for `.degradeBy(0.5)`
|
||||
*
|
||||
* @name degrade
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").degrade()
|
||||
* @example
|
||||
* s("[hh?]*8")
|
||||
*/
|
||||
export const degrade = register('degrade', (pat) => pat._degradeBy(0.5), true, true);
|
||||
|
||||
/**
|
||||
* Inverse of `degradeBy`: Randomly removes events from the pattern by a given amount.
|
||||
* 0 = 100% chance of removal
|
||||
* 1 = 0% chance of removal
|
||||
* Events that would be removed by degradeBy are let through by undegradeBy and vice versa (see second example).
|
||||
*
|
||||
* @name undegradeBy
|
||||
* @memberof Pattern
|
||||
* @param {number} amount - a number between 0 and 1
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").undegradeBy(0.2)
|
||||
* @example
|
||||
* s("hh*10").layer(
|
||||
* x => x.degradeBy(0.2).pan(0),
|
||||
* x => x.undegradeBy(0.8).pan(1)
|
||||
* )
|
||||
*/
|
||||
export const undegradeBy = register(
|
||||
'undegradeBy',
|
||||
function (x, pat) {
|
||||
return pat._degradeByWith(
|
||||
rand.fmap((r) => 1 - r),
|
||||
x,
|
||||
);
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
/**
|
||||
* Inverse of `degrade`: Randomly removes 50% of events from the pattern. Shorthand for `.undegradeBy(0.5)`
|
||||
* Events that would be removed by degrade are let through by undegrade and vice versa (see second example).
|
||||
*
|
||||
* @name undegrade
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").undegrade()
|
||||
* @example
|
||||
* s("hh*10").layer(
|
||||
* x => x.degrade().pan(0),
|
||||
* x => x.undegrade().pan(1)
|
||||
* )
|
||||
*/
|
||||
export const undegrade = register('undegrade', (pat) => pat._undegradeBy(0.5), true, true);
|
||||
|
||||
/**
|
||||
*
|
||||
* Randomly applies the given function by the given probability.
|
||||
* Similar to `someCyclesBy`
|
||||
*
|
||||
* @name sometimesBy
|
||||
* @memberof Pattern
|
||||
* @param {number | Pattern} probability - a number between 0 and 1
|
||||
* @param {function} function - the transformation to apply
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").sometimesBy(.4, x=>x.speed("0.5"))
|
||||
*/
|
||||
|
||||
export const sometimesBy = register('sometimesBy', function (patx, func, pat) {
|
||||
return reify(patx)
|
||||
.fmap((x) => stack(pat._degradeBy(x), func(pat._undegradeBy(1 - x))))
|
||||
.innerJoin();
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Applies the given function with a 50% chance
|
||||
*
|
||||
* @name sometimes
|
||||
* @memberof Pattern
|
||||
* @param {function} function - the transformation to apply
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").sometimes(x=>x.speed("0.5"))
|
||||
*/
|
||||
export const sometimes = register('sometimes', function (func, pat) {
|
||||
return pat._sometimesBy(0.5, func);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Randomly applies the given function by the given probability on a cycle by cycle basis.
|
||||
* Similar to `sometimesBy`
|
||||
*
|
||||
* @name someCyclesBy
|
||||
* @memberof Pattern
|
||||
* @param {number | Pattern} probability - a number between 0 and 1
|
||||
* @param {function} function - the transformation to apply
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("bd,hh*8").someCyclesBy(.3, x=>x.speed("0.5"))
|
||||
*/
|
||||
|
||||
export const someCyclesBy = register('someCyclesBy', function (patx, func, pat) {
|
||||
return reify(patx)
|
||||
.fmap((x) =>
|
||||
stack(
|
||||
pat._degradeByWith(rand._segment(1), x),
|
||||
func(pat._degradeByWith(rand.fmap((r) => 1 - r)._segment(1), 1 - x)),
|
||||
),
|
||||
)
|
||||
.innerJoin();
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Shorthand for `.someCyclesBy(0.5, fn)`
|
||||
*
|
||||
* @name someCycles
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("bd,hh*8").someCycles(x=>x.speed("0.5"))
|
||||
*/
|
||||
export const someCycles = register('someCycles', function (func, pat) {
|
||||
return pat._someCyclesBy(0.5, func);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Shorthand for `.sometimesBy(0.75, fn)`
|
||||
*
|
||||
* @name often
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").often(x=>x.speed("0.5"))
|
||||
*/
|
||||
export const often = register('often', function (func, pat) {
|
||||
return pat.sometimesBy(0.75, func);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Shorthand for `.sometimesBy(0.25, fn)`
|
||||
*
|
||||
* @name rarely
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").rarely(x=>x.speed("0.5"))
|
||||
*/
|
||||
export const rarely = register('rarely', function (func, pat) {
|
||||
return pat.sometimesBy(0.25, func);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Shorthand for `.sometimesBy(0.1, fn)`
|
||||
*
|
||||
* @name almostNever
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").almostNever(x=>x.speed("0.5"))
|
||||
*/
|
||||
export const almostNever = register('almostNever', function (func, pat) {
|
||||
return pat.sometimesBy(0.1, func);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Shorthand for `.sometimesBy(0.9, fn)`
|
||||
*
|
||||
* @name almostAlways
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").almostAlways(x=>x.speed("0.5"))
|
||||
*/
|
||||
export const almostAlways = register('almostAlways', function (func, pat) {
|
||||
return pat.sometimesBy(0.9, func);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Shorthand for `.sometimesBy(0, fn)` (never calls fn)
|
||||
*
|
||||
* @name never
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").never(x=>x.speed("0.5"))
|
||||
*/
|
||||
export const never = register('never', function (_, pat) {
|
||||
return pat;
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Shorthand for `.sometimesBy(1, fn)` (always calls fn)
|
||||
*
|
||||
* @name always
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("hh*8").always(x=>x.speed("0.5"))
|
||||
*/
|
||||
export const always = register('always', function (func, pat) {
|
||||
return func(pat);
|
||||
});
|
||||
|
||||
//keyname: string | Array<string>
|
||||
//keyname reference: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
|
||||
export function _keyDown(keyname) {
|
||||
if (Array.isArray(keyname) === false) {
|
||||
keyname = [keyname];
|
||||
}
|
||||
const keyState = getCurrentKeyboardState();
|
||||
return keyname.every((x) => {
|
||||
const keyName = keyAlias.get(x) ?? x;
|
||||
return keyState[keyName];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Do something on a keypress, or array of keypresses
|
||||
* [Key name reference](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values)
|
||||
*
|
||||
* @name whenKey
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("bd(5,8)").whenKey("Control:j", x => x.segment(16).color("red")).whenKey("Control:i", x => x.fast(2).color("blue"))
|
||||
*/
|
||||
|
||||
export const whenKey = register('whenKey', function (input, func, pat) {
|
||||
return pat.when(_keyDown(input), func);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* returns true when a key or array of keys is held
|
||||
* [Key name reference](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values)
|
||||
*
|
||||
* @name keyDown
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* keyDown("Control:j").pick([s("bd(5,8)"), s("cp(3,8)")])
|
||||
*/
|
||||
|
||||
export const keyDown = register('keyDown', function (pat) {
|
||||
return pat.fmap(_keyDown);
|
||||
});
|
||||
38
src/strudel/core/speak.mjs
Normal file
38
src/strudel/core/speak.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
speak.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/speak.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { register } from './index.mjs';
|
||||
|
||||
let synth;
|
||||
try {
|
||||
synth = window?.speechSynthesis;
|
||||
} catch (err) {
|
||||
console.warn('cannot use window: not in browser?');
|
||||
}
|
||||
|
||||
let allVoices = synth?.getVoices();
|
||||
// console.log('voices', allVoices);
|
||||
|
||||
function triggerSpeech(words, lang, voice) {
|
||||
synth.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(words);
|
||||
utterance.lang = lang;
|
||||
allVoices = synth.getVoices();
|
||||
const voices = allVoices.filter((v) => v.lang.includes(lang));
|
||||
if (typeof voice === 'number') {
|
||||
utterance.voice = voices[voice % voices.length];
|
||||
} else if (typeof voice === 'string') {
|
||||
utterance.voice = voices.find((voice) => voice.name === voice);
|
||||
}
|
||||
// console.log(utterance.voice?.name, utterance.voice?.lang);
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
export const speak = register('speak', function (lang, voice, pat) {
|
||||
return pat.onTrigger((hap) => {
|
||||
triggerSpeech(hap.value, lang, voice);
|
||||
});
|
||||
});
|
||||
28
src/strudel/core/state.mjs
Normal file
28
src/strudel/core/state.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
state.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/state.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export class State {
|
||||
constructor(span, controls = {}) {
|
||||
this.span = span;
|
||||
this.controls = controls;
|
||||
}
|
||||
|
||||
// Returns new State with different span
|
||||
setSpan(span) {
|
||||
return new State(span, this.controls);
|
||||
}
|
||||
|
||||
withSpan(func) {
|
||||
return this.setSpan(func(this.span));
|
||||
}
|
||||
|
||||
// Returns new State with added controls.
|
||||
setControls(controls) {
|
||||
return new State(this.span, { ...this.controls, ...controls });
|
||||
}
|
||||
}
|
||||
|
||||
export default State;
|
||||
11
src/strudel/core/time.mjs
Normal file
11
src/strudel/core/time.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
let time;
|
||||
export function getTime() {
|
||||
if (!time) {
|
||||
throw new Error('no time set! use setTime to define a time source');
|
||||
}
|
||||
return time();
|
||||
}
|
||||
|
||||
export function setTime(func) {
|
||||
time = func;
|
||||
}
|
||||
117
src/strudel/core/timespan.mjs
Normal file
117
src/strudel/core/timespan.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
timespan.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/timespan.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Fraction from './fraction.mjs';
|
||||
|
||||
export class TimeSpan {
|
||||
constructor(begin, end) {
|
||||
this.begin = Fraction(begin);
|
||||
this.end = Fraction(end);
|
||||
}
|
||||
|
||||
get spanCycles() {
|
||||
const spans = [];
|
||||
var begin = this.begin;
|
||||
const end = this.end;
|
||||
const end_sam = end.sam();
|
||||
|
||||
// Support zero-width timespans
|
||||
if (begin.equals(end)) {
|
||||
return [new TimeSpan(begin, end)];
|
||||
}
|
||||
|
||||
while (end.gt(begin)) {
|
||||
// If begin and end are in the same cycle, we're done.
|
||||
if (begin.sam().equals(end_sam)) {
|
||||
spans.push(new TimeSpan(begin, this.end));
|
||||
break;
|
||||
}
|
||||
// add a timespan up to the next sam
|
||||
const next_begin = begin.nextSam();
|
||||
spans.push(new TimeSpan(begin, next_begin));
|
||||
|
||||
// continue with the next cycle
|
||||
begin = next_begin;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this.end.sub(this.begin);
|
||||
}
|
||||
|
||||
cycleArc() {
|
||||
// Shifts a timespan to one of equal duration that starts within cycle zero.
|
||||
// (Note that the output timespan probably does not start *at* Time 0 --
|
||||
// that only happens when the input Arc starts at an integral Time.)
|
||||
const b = this.begin.cyclePos();
|
||||
const e = b.add(this.duration);
|
||||
return new TimeSpan(b, e);
|
||||
}
|
||||
|
||||
withTime(func_time) {
|
||||
// Applies given function to both the begin and end time of the timespan"""
|
||||
return new TimeSpan(func_time(this.begin), func_time(this.end));
|
||||
}
|
||||
|
||||
withEnd(func_time) {
|
||||
// Applies given function to the end time of the timespan"""
|
||||
return new TimeSpan(this.begin, func_time(this.end));
|
||||
}
|
||||
|
||||
withCycle(func_time) {
|
||||
// Like withTime, but time is relative to relative to the cycle (i.e. the
|
||||
// sam of the start of the timespan)
|
||||
const sam = this.begin.sam();
|
||||
const b = sam.add(func_time(this.begin.sub(sam)));
|
||||
const e = sam.add(func_time(this.end.sub(sam)));
|
||||
return new TimeSpan(b, e);
|
||||
}
|
||||
|
||||
intersection(other) {
|
||||
// Intersection of two timespans, returns undefined if they don't intersect.
|
||||
const intersect_begin = this.begin.max(other.begin);
|
||||
const intersect_end = this.end.min(other.end);
|
||||
|
||||
if (intersect_begin.gt(intersect_end)) {
|
||||
return undefined;
|
||||
}
|
||||
if (intersect_begin.equals(intersect_end)) {
|
||||
// Zero-width (point) intersection - doesn't intersect if it's at the end of a
|
||||
// non-zero-width timespan.
|
||||
if (intersect_begin.equals(this.end) && this.begin.lt(this.end)) {
|
||||
return undefined;
|
||||
}
|
||||
if (intersect_begin.equals(other.end) && other.begin.lt(other.end)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return new TimeSpan(intersect_begin, intersect_end);
|
||||
}
|
||||
|
||||
intersection_e(other) {
|
||||
// Like 'sect', but raises an exception if the timespans don't intersect.
|
||||
const result = this.intersection(other);
|
||||
if (result == undefined) {
|
||||
throw 'TimeSpans do not intersect';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
midpoint() {
|
||||
return this.begin.add(this.duration.div(Fraction(2)));
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
return this.begin.equals(other.begin) && this.end.equals(other.end);
|
||||
}
|
||||
|
||||
show() {
|
||||
return this.begin.show() + ' → ' + this.end.show();
|
||||
}
|
||||
}
|
||||
|
||||
export default TimeSpan;
|
||||
32
src/strudel/core/ui.mjs
Normal file
32
src/strudel/core/ui.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
ui.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/ui.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const backgroundImage = function (src, animateOptions = {}) {
|
||||
const container = document.getElementById('code');
|
||||
const bg = 'background-image:url(' + src + ');background-size:contain;';
|
||||
container.style = bg;
|
||||
const { className: initialClassName } = container;
|
||||
const handleOption = (option, value) => {
|
||||
({
|
||||
style: () => (container.style = bg + ';' + value),
|
||||
className: () => (container.className = value + ' ' + initialClassName),
|
||||
})[option]();
|
||||
};
|
||||
const funcOptions = Object.entries(animateOptions).filter(([_, v]) => typeof v === 'function');
|
||||
const stringOptions = Object.entries(animateOptions).filter(([_, v]) => typeof v === 'string');
|
||||
stringOptions.forEach(([option, value]) => handleOption(option, value));
|
||||
|
||||
if (funcOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupUi = () => {
|
||||
const container = document.getElementById('code');
|
||||
if (container) {
|
||||
container.style = '';
|
||||
}
|
||||
};
|
||||
499
src/strudel/core/util.mjs
Normal file
499
src/strudel/core/util.mjs
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
/*
|
||||
util.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/util.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
// returns true if the given string is a note
|
||||
export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name);
|
||||
export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]?$/.test(name);
|
||||
export const tokenizeNote = (note) => {
|
||||
if (typeof note !== 'string') {
|
||||
return [];
|
||||
}
|
||||
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || [];
|
||||
if (!pc) {
|
||||
return [];
|
||||
}
|
||||
return [pc, acc, oct ? Number(oct) : undefined];
|
||||
};
|
||||
|
||||
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
||||
const accs = { '#': 1, b: -1, s: 1, f: -1 };
|
||||
|
||||
// turns the given note into its midi number representation
|
||||
export const noteToMidi = (note, defaultOctave = 3) => {
|
||||
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
|
||||
if (!pc) {
|
||||
throw new Error('not a note: "' + note + '"');
|
||||
}
|
||||
const chroma = chromas[pc.toLowerCase()];
|
||||
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
||||
return (Number(oct) + 1) * 12 + chroma + offset;
|
||||
};
|
||||
export const midiToFreq = (n) => {
|
||||
return Math.pow(2, (n - 69) / 12) * 440;
|
||||
};
|
||||
|
||||
export const freqToMidi = (freq) => {
|
||||
return (12 * Math.log(freq / 440)) / Math.LN2 + 69;
|
||||
};
|
||||
|
||||
export const valueToMidi = (value, fallbackValue) => {
|
||||
if (typeof value !== 'object') {
|
||||
throw new Error('valueToMidi: expected object value');
|
||||
}
|
||||
let { freq, note } = value;
|
||||
if (typeof freq === 'number') {
|
||||
return freqToMidi(freq);
|
||||
}
|
||||
if (typeof note === 'string') {
|
||||
return noteToMidi(note);
|
||||
}
|
||||
if (typeof note === 'number') {
|
||||
return note;
|
||||
}
|
||||
if (!fallbackValue) {
|
||||
throw new Error('valueToMidi: expected freq or note to be set');
|
||||
}
|
||||
return fallbackValue;
|
||||
};
|
||||
|
||||
// used to schedule external event like midi and osc out
|
||||
export const getEventOffsetMs = (targetTimeSeconds, currentTimeSeconds) => {
|
||||
return (targetTimeSeconds - currentTimeSeconds) * 1000;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated does not appear to be referenced or invoked anywhere in the codebase
|
||||
* @noAutocomplete
|
||||
*/
|
||||
export const getFreq = (noteOrMidi) => {
|
||||
if (typeof noteOrMidi === 'number') {
|
||||
return midiToFreq(noteOrMidi);
|
||||
}
|
||||
return midiToFreq(noteToMidi(noteOrMidi));
|
||||
};
|
||||
|
||||
const pcs = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
/**
|
||||
* @deprecated only used in workshop (first-notes)
|
||||
* @noAutocomplete
|
||||
*/
|
||||
export const midi2note = (n) => {
|
||||
const oct = Math.floor(n / 12) - 1;
|
||||
const pc = pcs[n % 12];
|
||||
return pc + oct;
|
||||
};
|
||||
|
||||
// modulo that works with negative numbers e.g. _mod(-1, 3) = 2. Works on numbers (rather than patterns of numbers, as @mod@ from pattern.mjs does)
|
||||
export const _mod = (n, m) => ((n % m) + m) % m;
|
||||
|
||||
// average numbers in an array
|
||||
export const averageArray = (arr) => arr.reduce((a, b) => a + b) / arr.length;
|
||||
|
||||
export function nanFallback(value, fallback = 0) {
|
||||
if (isNaN(Number(value))) {
|
||||
logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning');
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// round to nearest int, negative numbers will output a subtracted index
|
||||
export const getSoundIndex = (n, numSounds) => {
|
||||
return _mod(Math.round(nanFallback(n ?? 0, 0)), numSounds);
|
||||
};
|
||||
|
||||
export const getPlayableNoteValue = (hap) => {
|
||||
let { value, context } = hap;
|
||||
let note = value;
|
||||
if (typeof note === 'object' && !Array.isArray(note)) {
|
||||
note = note.note || note.n || note.value;
|
||||
if (note === undefined) {
|
||||
throw new Error(`cannot find a playable note for ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
// if value is number => interpret as midi number as long as its not marked as frequency
|
||||
if (typeof note === 'number' && context.type !== 'frequency') {
|
||||
note = midiToFreq(hap.value);
|
||||
} else if (typeof note === 'number' && context.type === 'frequency') {
|
||||
note = hap.value; // legacy workaround.. will be removed in the future
|
||||
} else if (typeof note !== 'string' || !isNote(note)) {
|
||||
throw new Error('not a note: ' + JSON.stringify(note));
|
||||
}
|
||||
return note;
|
||||
};
|
||||
|
||||
export const getFrequency = (hap) => {
|
||||
let { value, context } = hap;
|
||||
// if value is number => interpret as midi number as long as its not marked as frequency
|
||||
if (typeof value === 'object') {
|
||||
if (value.freq) {
|
||||
return value.freq;
|
||||
}
|
||||
return getFreq(value.note || value.n || value.value);
|
||||
}
|
||||
if (typeof value === 'number' && context.type !== 'frequency') {
|
||||
value = midiToFreq(hap.value);
|
||||
} else if (typeof value === 'string' && isNote(value)) {
|
||||
value = midiToFreq(noteToMidi(hap.value));
|
||||
} else if (typeof value !== 'number') {
|
||||
throw new Error('not a note or frequency: ' + value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// rotate array by n steps (to the left)
|
||||
export const rotate = (arr, n) => arr.slice(n).concat(arr.slice(0, n));
|
||||
|
||||
export const pipe = (...funcs) => {
|
||||
return funcs.reduce(
|
||||
(f, g) =>
|
||||
(...args) =>
|
||||
f(g(...args)),
|
||||
(x) => x,
|
||||
);
|
||||
};
|
||||
|
||||
export const compose = (...funcs) => pipe(...funcs.reverse());
|
||||
|
||||
// Removes 'None' values from given list
|
||||
export const removeUndefineds = (xs) => xs.filter((x) => x != undefined);
|
||||
|
||||
// flattens by one level
|
||||
export const flatten = (arr) => [].concat(...arr);
|
||||
|
||||
export const id = (a) => a;
|
||||
export const constant = (a, b) => a;
|
||||
|
||||
export const listRange = (min, max) => Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
||||
|
||||
export function curry(func, overload, arity = func.length) {
|
||||
const fn = function curried(...args) {
|
||||
if (args.length >= arity) {
|
||||
return func.apply(this, args);
|
||||
} else {
|
||||
const partial = function (...args2) {
|
||||
return curried.apply(this, args.concat(args2));
|
||||
};
|
||||
if (overload) {
|
||||
overload(partial, args);
|
||||
}
|
||||
return partial;
|
||||
}
|
||||
};
|
||||
if (overload) {
|
||||
// overload function without args... needed for chordBass.transpose(2)
|
||||
overload(fn, []);
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function parseNumeral(numOrString) {
|
||||
const asNumber = Number(numOrString);
|
||||
if (!isNaN(asNumber)) {
|
||||
return asNumber;
|
||||
}
|
||||
if (isNote(numOrString)) {
|
||||
return noteToMidi(numOrString);
|
||||
}
|
||||
throw new Error(`cannot parse as numeral: "${numOrString}"`);
|
||||
}
|
||||
|
||||
export function mapArgs(fn, mapFn) {
|
||||
return (...args) => fn(...args.map(mapFn));
|
||||
}
|
||||
|
||||
export function numeralArgs(fn) {
|
||||
return mapArgs(fn, parseNumeral);
|
||||
}
|
||||
|
||||
export function parseFractional(numOrString) {
|
||||
const asNumber = Number(numOrString);
|
||||
if (!isNaN(asNumber)) {
|
||||
return asNumber;
|
||||
}
|
||||
const specialValue = {
|
||||
pi: Math.PI,
|
||||
w: 1,
|
||||
h: 0.5,
|
||||
q: 0.25,
|
||||
e: 0.125,
|
||||
s: 0.0625,
|
||||
t: 1 / 3,
|
||||
f: 0.2,
|
||||
x: 1 / 6,
|
||||
}[numOrString];
|
||||
if (typeof specialValue !== 'undefined') {
|
||||
return specialValue;
|
||||
}
|
||||
throw new Error(`cannot parse as fractional: "${numOrString}"`);
|
||||
}
|
||||
|
||||
export const fractionalArgs = (fn) => mapArgs(fn, parseFractional);
|
||||
|
||||
export const splitAt = function (index, value) {
|
||||
return [value.slice(0, index), value.slice(index)];
|
||||
};
|
||||
|
||||
// Uses the function f to combine the arrays xs, ys element-wise
|
||||
export const zipWith = (f, xs, ys) => xs.map((n, i) => f(n, ys[i]));
|
||||
|
||||
export const pairs = function (xs) {
|
||||
const result = [];
|
||||
for (let i = 0; i < xs.length - 1; ++i) {
|
||||
result.push([xs[i], xs[i + 1]]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
||||
|
||||
/* solmization, not used yet */
|
||||
const solfeggio = ['Do', 'Reb', 'Re', 'Mib', 'Mi', 'Fa', 'Solb', 'Sol', 'Lab', 'La', 'Sib', 'Si']; /*solffegio notes*/
|
||||
const indian = [
|
||||
'Sa',
|
||||
'Re',
|
||||
'Ga',
|
||||
'Ma',
|
||||
'Pa',
|
||||
'Dha',
|
||||
'Ni',
|
||||
]; /*indian musical notes, seems like they do not use flats or sharps*/
|
||||
const german = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Hb', 'H']; /*german & dutch musical notes*/
|
||||
const byzantine = [
|
||||
'Ni',
|
||||
'Pab',
|
||||
'Pa',
|
||||
'Voub',
|
||||
'Vou',
|
||||
'Ga',
|
||||
'Dib',
|
||||
'Di',
|
||||
'Keb',
|
||||
'Ke',
|
||||
'Zob',
|
||||
'Zo',
|
||||
]; /*byzantine musical notes*/
|
||||
const japanese = [
|
||||
'I',
|
||||
'Ro',
|
||||
'Ha',
|
||||
'Ni',
|
||||
'Ho',
|
||||
'He',
|
||||
'To',
|
||||
]; /*traditional japanese musical notes, seems like they do not use falts or sharps*/
|
||||
|
||||
const english = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
export const sol2note = (n, notation = 'letters') => {
|
||||
const pc =
|
||||
notation === 'solfeggio'
|
||||
? solfeggio /*check if its is any of the following*/
|
||||
: notation === 'indian'
|
||||
? indian
|
||||
: notation === 'german'
|
||||
? german
|
||||
: notation === 'byzantine'
|
||||
? byzantine
|
||||
: notation === 'japanese'
|
||||
? japanese
|
||||
: english; /*if not use standard version*/
|
||||
const note = pc[n % 12]; /*calculating the midi value to the note*/
|
||||
const oct = Math.floor(n / 12) - 1;
|
||||
return note + oct;
|
||||
};
|
||||
|
||||
// Remove duplicates from list
|
||||
export function uniq(a) {
|
||||
var seen = {};
|
||||
return a.filter(function (item) {
|
||||
return seen.hasOwn(item) ? false : (seen[item] = true);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove duplicates from list, sorting in the process. Mutates argument!
|
||||
export function uniqsort(a) {
|
||||
return a.sort().filter(function (item, pos, ary) {
|
||||
return !pos || item != ary[pos - 1];
|
||||
});
|
||||
}
|
||||
|
||||
// rational version
|
||||
export function uniqsortr(a) {
|
||||
return a
|
||||
.sort((x, y) => x.compare(y))
|
||||
.filter(function (item, pos, ary) {
|
||||
return !pos || item.ne(ary[pos - 1]);
|
||||
});
|
||||
}
|
||||
|
||||
// code hashing helpers
|
||||
|
||||
export function unicodeToBase64(text) {
|
||||
const utf8Bytes = new TextEncoder().encode(text);
|
||||
const base64String = btoa(String.fromCharCode(...utf8Bytes));
|
||||
return base64String;
|
||||
}
|
||||
|
||||
export function base64ToUnicode(base64String) {
|
||||
const utf8Bytes = new Uint8Array(
|
||||
atob(base64String)
|
||||
.split('')
|
||||
.map((char) => char.charCodeAt(0)),
|
||||
);
|
||||
const decodedText = new TextDecoder().decode(utf8Bytes);
|
||||
return decodedText;
|
||||
}
|
||||
|
||||
export function code2hash(code) {
|
||||
return encodeURIComponent(unicodeToBase64(code));
|
||||
//return '#' + encodeURIComponent(btoa(code));
|
||||
}
|
||||
|
||||
export function hash2code(hash) {
|
||||
return base64ToUnicode(decodeURIComponent(hash));
|
||||
//return atob(decodeURIComponent(codeParam || ''));
|
||||
}
|
||||
|
||||
export function objectMap(obj, fn) {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(fn);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(obj).map(([k, v], i) => [k, fn(v, k, i)]));
|
||||
}
|
||||
export function cycleToSeconds(cycle, cps) {
|
||||
return cycle / cps;
|
||||
}
|
||||
|
||||
// utility for averaging two clocks together to account for drift
|
||||
export class ClockCollator {
|
||||
constructor({
|
||||
getTargetClockTime = getUnixTimeSeconds,
|
||||
weight = 16,
|
||||
offsetDelta = 0.005,
|
||||
checkAfterTime = 2,
|
||||
resetAfterTime = 8,
|
||||
}) {
|
||||
this.offsetTime;
|
||||
this.timeAtPrevOffsetSample;
|
||||
this.prevOffsetTimes = [];
|
||||
this.getTargetClockTime = getTargetClockTime;
|
||||
this.weight = weight;
|
||||
this.offsetDelta = offsetDelta;
|
||||
this.checkAfterTime = checkAfterTime;
|
||||
this.resetAfterTime = resetAfterTime;
|
||||
this.reset = () => {
|
||||
this.prevOffsetTimes = [];
|
||||
this.offsetTime = null;
|
||||
this.timeAtPrevOffsetSample = null;
|
||||
};
|
||||
}
|
||||
calculateOffset(currentTime) {
|
||||
const targetClockTime = this.getTargetClockTime();
|
||||
const diffBetweenTimeSamples = targetClockTime - this.timeAtPrevOffsetSample;
|
||||
const newOffsetTime = targetClockTime - currentTime;
|
||||
// recalcuate the diff from scratch if the clock has been paused for some time.
|
||||
if (diffBetweenTimeSamples > this.resetAfterTime) {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
if (this.offsetTime == null) {
|
||||
this.offsetTime = newOffsetTime;
|
||||
}
|
||||
this.prevOffsetTimes.push(newOffsetTime);
|
||||
if (this.prevOffsetTimes.length > this.weight) {
|
||||
this.prevOffsetTimes.shift();
|
||||
}
|
||||
|
||||
// after X time has passed, the average of the previous weight offset times is calculated and used as a stable reference
|
||||
// for calculating the timestamp
|
||||
if (this.timeAtPrevOffsetSample == null || diffBetweenTimeSamples > this.checkAfterTime) {
|
||||
this.timeAtPrevOffsetSample = targetClockTime;
|
||||
const rollingOffsetTime = averageArray(this.prevOffsetTimes);
|
||||
//when the clock offsets surpass the delta, set the new reference time
|
||||
if (Math.abs(rollingOffsetTime - this.offsetTime) > this.offsetDelta) {
|
||||
this.offsetTime = rollingOffsetTime;
|
||||
}
|
||||
}
|
||||
|
||||
return this.offsetTime;
|
||||
}
|
||||
|
||||
calculateTimestamp(currentTime, targetTime) {
|
||||
return this.calculateOffset(currentTime) + targetTime;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPerformanceTimeSeconds() {
|
||||
return performance.now() * 0.001;
|
||||
}
|
||||
|
||||
function getUnixTimeSeconds() {
|
||||
return Date.now() * 0.001;
|
||||
}
|
||||
|
||||
export const keyAlias = new Map([
|
||||
['control', 'Control'],
|
||||
['ctrl', 'Control'],
|
||||
['alt', 'Alt'],
|
||||
['shift', 'Shift'],
|
||||
['down', 'ArrowDown'],
|
||||
['up', 'ArrowUp'],
|
||||
['left', 'ArrowLeft'],
|
||||
['right', 'ArrowRight'],
|
||||
]);
|
||||
let keyState;
|
||||
|
||||
export function getCurrentKeyboardState() {
|
||||
if (keyState == null) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
keyState = {};
|
||||
// Listen for the keydown event to mark the key as pressed
|
||||
window.addEventListener('keydown', (event) => {
|
||||
keyState[event.key] = true; // Mark the key as pressed
|
||||
});
|
||||
|
||||
// Listen for the keyup event to mark the key as released
|
||||
window.addEventListener('keyup', (event) => {
|
||||
keyState[event.key] = false; // Mark the key as released
|
||||
});
|
||||
}
|
||||
|
||||
return { ...keyState }; // Return a shallow copy of the key state object
|
||||
}
|
||||
|
||||
// Floating point versions, see Fraction for rational versions
|
||||
// // greatest common divisor
|
||||
// export const gcd = function (x, y, ...z) {
|
||||
// if (!y && z.length > 0) {
|
||||
// return gcd(x, ...z);
|
||||
// }
|
||||
// if (!y) {
|
||||
// return x;
|
||||
// }
|
||||
// return gcd(y, x % y, ...z);
|
||||
// };
|
||||
|
||||
// // lowest common multiple
|
||||
// export const lcm = function (x, y, ...z) {
|
||||
// if (z.length == 0) {
|
||||
// return (x * y) / gcd(x, y);
|
||||
// }
|
||||
// return lcm((x * y) / gcd(x, y), ...z);
|
||||
// };
|
||||
|
||||
// Takes values -- typically derived from events, i.e. `hap`s -- and renders them
|
||||
// into a readable format
|
||||
export function stringifyValues(value, compact = false) {
|
||||
return typeof value === 'object'
|
||||
? compact
|
||||
? JSON.stringify(value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ')
|
||||
: JSON.stringify(value)
|
||||
: value;
|
||||
}
|
||||
64
src/strudel/core/value.mjs
Normal file
64
src/strudel/core/value.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
value.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/value.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { curry } from './util.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
export function unionWithObj(a, b, func) {
|
||||
if (b?.value !== undefined && Object.keys(b).length === 1) {
|
||||
// https://codeberg.org/uzu/strudel/issues/1026
|
||||
logger(`[warn]: Can't do arithmetic on control pattern.`);
|
||||
return a;
|
||||
}
|
||||
const common = Object.keys(a).filter((k) => Object.keys(b).includes(k));
|
||||
return Object.assign({}, a, b, Object.fromEntries(common.map((k) => [k, func(a[k], b[k])])));
|
||||
}
|
||||
|
||||
export const mul = curry((a, b) => a * b);
|
||||
|
||||
export const valued = (value) => {
|
||||
if (value?.constructor?.name === 'Value') {
|
||||
return value;
|
||||
}
|
||||
return Value.of(value);
|
||||
};
|
||||
|
||||
export class Value {
|
||||
constructor(value) {
|
||||
this.value = value;
|
||||
}
|
||||
static of(x) {
|
||||
return new Value(x);
|
||||
}
|
||||
get isNothing() {
|
||||
return this.value === null || this.value === undefined;
|
||||
}
|
||||
map(f) {
|
||||
if (this.isNothing) {
|
||||
return this;
|
||||
}
|
||||
return Value.of(f(this.value));
|
||||
}
|
||||
mul(n) {
|
||||
return this.map(mul).ap(n);
|
||||
}
|
||||
ap(other) {
|
||||
return valued(other).map(this.value);
|
||||
}
|
||||
unionWith(other, func) {
|
||||
const type = typeof this.value;
|
||||
other = valued(other);
|
||||
if (type !== typeof other.value) {
|
||||
throw new Error('unionWith: both Values must have same type!');
|
||||
}
|
||||
if (Array.isArray(type) || type !== 'object') {
|
||||
throw new Error('unionWith: expected objects');
|
||||
}
|
||||
return this.map((v) => unionWithObj(v, other.value, func));
|
||||
}
|
||||
}
|
||||
|
||||
export const map = curry((f, anyFunctor) => anyFunctor.map(f));
|
||||
54
src/strudel/core/zyklus.mjs
Normal file
54
src/strudel/core/zyklus.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// will move to https://github.com/felixroos/zyklus
|
||||
// TODO: started flag
|
||||
|
||||
function createClock(
|
||||
getTime,
|
||||
callback, // called slightly before each cycle
|
||||
duration = 0.05, // duration of each cycle
|
||||
interval = 0.1, // interval between callbacks
|
||||
overlap = 0.1, // overlap between callbacks
|
||||
setInterval = globalThis.setInterval,
|
||||
clearInterval = globalThis.clearInterval,
|
||||
round = true,
|
||||
) {
|
||||
let tick = 0; // counts callbacks
|
||||
let phase = 0; // next callback time
|
||||
let precision = 10 ** 4; // used to round phase
|
||||
let minLatency = 0.01;
|
||||
const setDuration = (setter) => (duration = setter(duration));
|
||||
overlap = overlap || interval / 2;
|
||||
const onTick = () => {
|
||||
const t = getTime();
|
||||
const lookahead = t + interval + overlap; // the time window for this tick
|
||||
if (phase === 0) {
|
||||
phase = t + minLatency;
|
||||
}
|
||||
// callback as long as we're inside the lookahead
|
||||
while (phase < lookahead) {
|
||||
phase = round ? Math.round(phase * precision) / precision : phase;
|
||||
callback(phase, duration, tick, t); // callback has to skip / handle phase < t!
|
||||
phase += duration; // increment phase by duration
|
||||
tick++;
|
||||
}
|
||||
};
|
||||
let intervalID;
|
||||
const start = () => {
|
||||
clear(); // just in case start was called more than once
|
||||
onTick();
|
||||
intervalID = setInterval(onTick, interval * 1000);
|
||||
};
|
||||
const clear = () => {
|
||||
intervalID !== undefined && clearInterval(intervalID);
|
||||
intervalID = undefined;
|
||||
};
|
||||
const pause = () => clear();
|
||||
const stop = () => {
|
||||
tick = 0;
|
||||
phase = 0;
|
||||
clear();
|
||||
};
|
||||
const getPhase = () => phase;
|
||||
// setCallback
|
||||
return { setDuration, start, stop, pause, duration, interval, getPhase, minLatency };
|
||||
}
|
||||
export default createClock;
|
||||
69
src/strudel/draw/animate.mjs
Normal file
69
src/strudel/draw/animate.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Pattern, silence, register, pure, createParams } from '@strudel/core';
|
||||
import { getDrawContext } from './draw.mjs';
|
||||
|
||||
let clearColor = '#22222210';
|
||||
|
||||
Pattern.prototype.animate = function ({ callback, sync = false, smear = 0.5 } = {}) {
|
||||
window.frame && cancelAnimationFrame(window.frame);
|
||||
const ctx = getDrawContext();
|
||||
let { clientWidth: ww, clientHeight: wh } = ctx.canvas;
|
||||
ww *= window.devicePixelRatio;
|
||||
wh *= window.devicePixelRatio;
|
||||
let smearPart = smear === 0 ? '99' : Number((1 - smear) * 100).toFixed(0);
|
||||
smearPart = smearPart.length === 1 ? `0${smearPart}` : smearPart;
|
||||
clearColor = `#200010${smearPart}`;
|
||||
const render = (t) => {
|
||||
let frame;
|
||||
/* if (sync) {
|
||||
t = scheduler.now();
|
||||
frame = this.queryArc(t, t);
|
||||
} else { */
|
||||
t = Math.round(t);
|
||||
frame = this.slow(1000).queryArc(t, t);
|
||||
// }
|
||||
ctx.fillStyle = clearColor;
|
||||
ctx.fillRect(0, 0, ww, wh);
|
||||
frame.forEach((f) => {
|
||||
let { x, y, w, h, s, r, angle = 0, fill = 'darkseagreen' } = f.value;
|
||||
w *= ww;
|
||||
h *= wh;
|
||||
if (r !== undefined && angle !== undefined) {
|
||||
const radians = angle * 2 * Math.PI;
|
||||
const [cx, cy] = [(ww - w) / 2, (wh - h) / 2];
|
||||
x = cx + Math.cos(radians) * r * cx;
|
||||
y = cy + Math.sin(radians) * r * cy;
|
||||
} else {
|
||||
x *= ww - w;
|
||||
y *= wh - h;
|
||||
}
|
||||
const val = { ...f.value, x, y, w, h };
|
||||
ctx.fillStyle = fill;
|
||||
if (s === 'rect') {
|
||||
ctx.fillRect(x, y, w, h);
|
||||
} else if (s === 'ellipse') {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 2, 0, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
callback && callback(ctx, val, f);
|
||||
});
|
||||
window.frame = requestAnimationFrame(render);
|
||||
};
|
||||
window.frame = requestAnimationFrame(render);
|
||||
return silence;
|
||||
};
|
||||
|
||||
export const { x, y, w, h, angle, r, fill, smear } = createParams('x', 'y', 'w', 'h', 'angle', 'r', 'fill', 'smear');
|
||||
|
||||
export const rescale = register('rescale', function (f, pat) {
|
||||
return pat.mul(x(f).w(f).y(f).h(f));
|
||||
});
|
||||
|
||||
export const moveXY = register('moveXY', function (dx, dy, pat) {
|
||||
return pat.add(x(dx).y(dy));
|
||||
});
|
||||
|
||||
export const zoomIn = register('zoomIn', function (f, pat) {
|
||||
const d = pure(1).sub(f).div(2);
|
||||
return pat.rescale(f).move(d, d);
|
||||
});
|
||||
175
src/strudel/draw/color.mjs
Normal file
175
src/strudel/draw/color.mjs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
export const colorMap = {
|
||||
aliceblue: '#f0f8ff',
|
||||
antiquewhite: '#faebd7',
|
||||
aqua: '#00ffff',
|
||||
aquamarine: '#7fffd4',
|
||||
azure: '#f0ffff',
|
||||
beige: '#f5f5dc',
|
||||
bisque: '#ffe4c4',
|
||||
black: '#000000',
|
||||
blanchedalmond: '#ffebcd',
|
||||
blue: '#0000ff',
|
||||
blueviolet: '#8a2be2',
|
||||
brown: '#a52a2a',
|
||||
burlywood: '#deb887',
|
||||
cadetblue: '#5f9ea0',
|
||||
chartreuse: '#7fff00',
|
||||
chocolate: '#d2691e',
|
||||
coral: '#ff7f50',
|
||||
cornflowerblue: '#6495ed',
|
||||
cornsilk: '#fff8dc',
|
||||
crimson: '#dc143c',
|
||||
cyan: '#00ffff',
|
||||
darkblue: '#00008b',
|
||||
darkcyan: '#008b8b',
|
||||
darkgoldenrod: '#b8860b',
|
||||
darkgray: '#a9a9a9',
|
||||
darkgreen: '#006400',
|
||||
darkgrey: '#a9a9a9',
|
||||
darkkhaki: '#bdb76b',
|
||||
darkmagenta: '#8b008b',
|
||||
darkolivegreen: '#556b2f',
|
||||
darkorange: '#ff8c00',
|
||||
darkorchid: '#9932cc',
|
||||
darkred: '#8b0000',
|
||||
darksalmon: '#e9967a',
|
||||
darkseagreen: '#8fbc8f',
|
||||
darkslateblue: '#483d8b',
|
||||
darkslategray: '#2f4f4f',
|
||||
darkslategrey: '#2f4f4f',
|
||||
darkturquoise: '#00ced1',
|
||||
darkviolet: '#9400d3',
|
||||
deeppink: '#ff1493',
|
||||
deepskyblue: '#00bfff',
|
||||
dimgray: '#696969',
|
||||
dimgrey: '#696969',
|
||||
dodgerblue: '#1e90ff',
|
||||
firebrick: '#b22222',
|
||||
floralwhite: '#fffaf0',
|
||||
forestgreen: '#228b22',
|
||||
fuchsia: '#ff00ff',
|
||||
gainsboro: '#dcdcdc',
|
||||
ghostwhite: '#f8f8ff',
|
||||
gold: '#ffd700',
|
||||
goldenrod: '#daa520',
|
||||
gray: '#808080',
|
||||
green: '#008000',
|
||||
greenyellow: '#adff2f',
|
||||
grey: '#808080',
|
||||
honeydew: '#f0fff0',
|
||||
hotpink: '#ff69b4',
|
||||
indianred: '#cd5c5c',
|
||||
indigo: '#4b0082',
|
||||
ivory: '#fffff0',
|
||||
khaki: '#f0e68c',
|
||||
lavender: '#e6e6fa',
|
||||
lavenderblush: '#fff0f5',
|
||||
lawngreen: '#7cfc00',
|
||||
lemonchiffon: '#fffacd',
|
||||
lightblue: '#add8e6',
|
||||
lightcoral: '#f08080',
|
||||
lightcyan: '#e0ffff',
|
||||
lightgoldenrodyellow: '#fafad2',
|
||||
lightgray: '#d3d3d3',
|
||||
lightgreen: '#90ee90',
|
||||
lightgrey: '#d3d3d3',
|
||||
lightpink: '#ffb6c1',
|
||||
lightsalmon: '#ffa07a',
|
||||
lightseagreen: '#20b2aa',
|
||||
lightskyblue: '#87cefa',
|
||||
lightslategray: '#778899',
|
||||
lightslategrey: '#778899',
|
||||
lightsteelblue: '#b0c4de',
|
||||
lightyellow: '#ffffe0',
|
||||
lime: '#00ff00',
|
||||
limegreen: '#32cd32',
|
||||
linen: '#faf0e6',
|
||||
magenta: '#ff00ff',
|
||||
maroon: '#800000',
|
||||
mediumaquamarine: '#66cdaa',
|
||||
mediumblue: '#0000cd',
|
||||
mediumorchid: '#ba55d3',
|
||||
mediumpurple: '#9370db',
|
||||
mediumseagreen: '#3cb371',
|
||||
mediumslateblue: '#7b68ee',
|
||||
mediumspringgreen: '#00fa9a',
|
||||
mediumturquoise: '#48d1cc',
|
||||
mediumvioletred: '#c71585',
|
||||
midnightblue: '#191970',
|
||||
mintcream: '#f5fffa',
|
||||
mistyrose: '#ffe4e1',
|
||||
moccasin: '#ffe4b5',
|
||||
navajowhite: '#ffdead',
|
||||
navy: '#000080',
|
||||
oldlace: '#fdf5e6',
|
||||
olive: '#808000',
|
||||
olivedrab: '#6b8e23',
|
||||
orange: '#ffa500',
|
||||
orangered: '#ff4500',
|
||||
orchid: '#da70d6',
|
||||
palegoldenrod: '#eee8aa',
|
||||
palegreen: '#98fb98',
|
||||
paleturquoise: '#afeeee',
|
||||
palevioletred: '#db7093',
|
||||
papayawhip: '#ffefd5',
|
||||
peachpuff: '#ffdab9',
|
||||
peru: '#cd853f',
|
||||
pink: '#ffc0cb',
|
||||
plum: '#dda0dd',
|
||||
powderblue: '#b0e0e6',
|
||||
purple: '#800080',
|
||||
red: '#ff0000',
|
||||
rosybrown: '#bc8f8f',
|
||||
royalblue: '#4169e1',
|
||||
saddlebrown: '#8b4513',
|
||||
salmon: '#fa8072',
|
||||
sandybrown: '#f4a460',
|
||||
seagreen: '#2e8b57',
|
||||
seashell: '#fff5ee',
|
||||
sienna: '#a0522d',
|
||||
silver: '#c0c0c0',
|
||||
skyblue: '#87ceeb',
|
||||
slateblue: '#6a5acd',
|
||||
slategray: '#708090',
|
||||
slategrey: '#708090',
|
||||
snow: '#fffafa',
|
||||
springgreen: '#00ff7f',
|
||||
steelblue: '#4682b4',
|
||||
tan: '#d2b48c',
|
||||
teal: '#008080',
|
||||
thistle: '#d8bfd8',
|
||||
tomato: '#ff6347',
|
||||
turquoise: '#40e0d0',
|
||||
violet: '#ee82ee',
|
||||
wheat: '#f5deb3',
|
||||
white: '#ffffff',
|
||||
whitesmoke: '#f5f5f5',
|
||||
yellow: '#ffff00',
|
||||
yellowgreen: '#9acd32',
|
||||
};
|
||||
|
||||
export function convertColorToNumber(color) {
|
||||
// Convert color to lowercase for easier matching
|
||||
color = color.toLowerCase();
|
||||
|
||||
// If the color is a hex code, convert it to a number
|
||||
if (color[0] === '#') {
|
||||
return convertHexToNumber(color);
|
||||
}
|
||||
|
||||
// If the color is a named color, return the corresponding number
|
||||
if (colorMap[color] !== undefined) {
|
||||
return convertHexToNumber(colorMap[color]);
|
||||
}
|
||||
|
||||
// If the color is not recognized, return null
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function convertHexToNumber(hex) {
|
||||
// Remove the leading '#' from the hex code
|
||||
hex = hex.slice(1);
|
||||
|
||||
// Convert the hex code to a number
|
||||
return parseInt(hex, 16);
|
||||
}
|
||||
219
src/strudel/draw/draw.mjs
Normal file
219
src/strudel/draw/draw.mjs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
draw.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/canvas/draw.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Pattern, getTime, State, TimeSpan } from '@strudel/core';
|
||||
|
||||
export const getDrawContext = (id = 'test-canvas', options) => {
|
||||
let { contextType = '2d', pixelated = false, pixelRatio = window.devicePixelRatio } = options || {};
|
||||
let canvas = document.querySelector('#' + id);
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.id = id;
|
||||
canvas.width = window.innerWidth * pixelRatio;
|
||||
canvas.height = window.innerHeight * pixelRatio;
|
||||
canvas.style = 'pointer-events:none;width:100%;height:100%;position:fixed;top:0;left:0';
|
||||
pixelated && (canvas.style.imageRendering = 'pixelated');
|
||||
document.body.prepend(canvas);
|
||||
let timeout;
|
||||
window.addEventListener('resize', () => {
|
||||
timeout && clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
canvas.width = window.innerWidth * pixelRatio;
|
||||
canvas.height = window.innerHeight * pixelRatio;
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
return canvas.getContext(contextType, { willReadFrequently: true });
|
||||
};
|
||||
|
||||
let animationFrames = {};
|
||||
function stopAnimationFrame(id) {
|
||||
if (animationFrames[id] !== undefined) {
|
||||
cancelAnimationFrame(animationFrames[id]);
|
||||
delete animationFrames[id];
|
||||
}
|
||||
}
|
||||
function stopAllAnimations(replID) {
|
||||
Object.keys(animationFrames).forEach((id) => (!replID || id.startsWith(replID)) && stopAnimationFrame(id));
|
||||
}
|
||||
|
||||
let memory = {};
|
||||
Pattern.prototype.draw = function (fn, options) {
|
||||
if (typeof window === 'undefined') {
|
||||
return this;
|
||||
}
|
||||
let { id = 1, lookbehind = 0, lookahead = 0 } = options;
|
||||
let __t = Math.max(getTime(), 0);
|
||||
stopAnimationFrame(id);
|
||||
lookbehind = Math.abs(lookbehind);
|
||||
// init memory, clear future haps of old pattern
|
||||
memory[id] = (memory[id] || []).filter((h) => !h.isInFuture(__t));
|
||||
let newFuture = this.queryArc(__t, __t + lookahead).filter((h) => h.hasOnset());
|
||||
memory[id] = memory[id].concat(newFuture);
|
||||
|
||||
let last;
|
||||
const animate = () => {
|
||||
const _t = getTime();
|
||||
const t = _t + lookahead;
|
||||
// filter out haps that are too far in the past
|
||||
memory[id] = memory[id].filter((h) => h.isInNearPast(lookbehind, _t));
|
||||
// begin where we left off in last frame, but max -0.1s (inactive tab throttles to 1fps)
|
||||
let begin = Math.max(last || t, t - 1 / 10);
|
||||
const haps = this.queryArc(begin, t).filter((h) => h.hasOnset());
|
||||
memory[id] = memory[id].concat(haps);
|
||||
last = t; // makes sure no haps are missed
|
||||
fn(memory[id], _t, t, this);
|
||||
animationFrames[id] = requestAnimationFrame(animate);
|
||||
};
|
||||
animationFrames[id] = requestAnimationFrame(animate);
|
||||
return this;
|
||||
};
|
||||
|
||||
export const cleanupDraw = (clearScreen = true, id) => {
|
||||
const ctx = getDrawContext();
|
||||
clearScreen && ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
stopAllAnimations(id);
|
||||
};
|
||||
|
||||
Pattern.prototype.onPaint = function (painter) {
|
||||
return this.withState((state) => {
|
||||
if (!state.controls.painters) {
|
||||
state.controls.painters = [];
|
||||
}
|
||||
state.controls.painters.push(painter);
|
||||
});
|
||||
};
|
||||
|
||||
Pattern.prototype.getPainters = function () {
|
||||
let painters = [];
|
||||
this.queryArc(0, 0, { painters });
|
||||
return painters;
|
||||
};
|
||||
|
||||
// const round = (x) => Math.round(x * 1000) / 1000;
|
||||
|
||||
// encapsulates starting and stopping animation frames
|
||||
export class Framer {
|
||||
constructor(onFrame, onError) {
|
||||
this.onFrame = onFrame;
|
||||
this.onError = onError;
|
||||
}
|
||||
start() {
|
||||
const self = this;
|
||||
let frame = requestAnimationFrame(function updateHighlights(time) {
|
||||
try {
|
||||
self.onFrame(time);
|
||||
} catch (err) {
|
||||
self.onError(err);
|
||||
}
|
||||
frame = requestAnimationFrame(updateHighlights);
|
||||
});
|
||||
self.cancel = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}
|
||||
stop() {
|
||||
if (this.cancel) {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncs animation frames to a cyclist scheduler
|
||||
// see vite-vanilla-repl-cm6 for an example
|
||||
export class Drawer {
|
||||
constructor(onDraw, drawTime) {
|
||||
this.visibleHaps = [];
|
||||
this.lastFrame = null;
|
||||
this.drawTime = drawTime;
|
||||
this.painters = [];
|
||||
this.framer = new Framer(
|
||||
() => {
|
||||
if (!this.scheduler) {
|
||||
console.warn('Drawer: no scheduler');
|
||||
return;
|
||||
}
|
||||
const lookbehind = Math.abs(this.drawTime[0]);
|
||||
const lookahead = this.drawTime[1];
|
||||
// calculate current frame time (think right side of screen for pianoroll)
|
||||
const phase = this.scheduler.now() + lookahead;
|
||||
// first frame just captures the phase
|
||||
if (this.lastFrame === null) {
|
||||
this.lastFrame = phase;
|
||||
return;
|
||||
}
|
||||
// query haps from last frame till now. take last 100ms max
|
||||
const haps = this.scheduler.pattern.queryArc(Math.max(this.lastFrame, phase - 1 / 10), phase);
|
||||
this.lastFrame = phase;
|
||||
this.visibleHaps = (this.visibleHaps || [])
|
||||
// filter out haps that are too far in the past (think left edge of screen for pianoroll)
|
||||
.filter((h) => h.whole && h.endClipped >= phase - lookbehind - lookahead)
|
||||
// add new haps with onset (think right edge bars scrolling in)
|
||||
.concat(haps.filter((h) => h.hasOnset()));
|
||||
const time = phase - lookahead;
|
||||
onDraw(this.visibleHaps, time, this, this.painters);
|
||||
},
|
||||
(err) => {
|
||||
console.warn('draw error', err);
|
||||
},
|
||||
);
|
||||
}
|
||||
setDrawTime(drawTime) {
|
||||
this.drawTime = drawTime;
|
||||
}
|
||||
invalidate(scheduler = this.scheduler, t) {
|
||||
if (!scheduler) {
|
||||
return;
|
||||
}
|
||||
// TODO: scheduler.now() seems to move even when it's stopped, this hints at a bug...
|
||||
t = t ?? scheduler.now();
|
||||
this.scheduler = scheduler;
|
||||
let [_, lookahead] = this.drawTime;
|
||||
// +0.1 = workaround for weird holes in query..
|
||||
const [begin, end] = [Math.max(t, 0), t + lookahead + 0.1];
|
||||
// remove all future haps
|
||||
this.visibleHaps = this.visibleHaps.filter((h) => h.whole?.begin < t);
|
||||
this.painters = []; // will get populated by .onPaint calls attached to the pattern
|
||||
// query future haps
|
||||
const futureHaps = scheduler.pattern.queryArc(begin, end, { painters: this.painters });
|
||||
// append future haps
|
||||
this.visibleHaps = this.visibleHaps.concat(futureHaps);
|
||||
}
|
||||
start(scheduler) {
|
||||
this.scheduler = scheduler;
|
||||
this.invalidate();
|
||||
this.framer.start();
|
||||
}
|
||||
stop() {
|
||||
if (this.framer) {
|
||||
this.framer.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getComputedPropertyValue(name) {
|
||||
if (typeof window === 'undefined') {
|
||||
return '#fff';
|
||||
}
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
}
|
||||
|
||||
let theme = {
|
||||
background: '#222',
|
||||
foreground: '#75baff',
|
||||
caret: '#ffcc00',
|
||||
selection: 'rgba(128, 203, 196, 0.5)',
|
||||
selectionMatch: '#036dd626',
|
||||
lineHighlight: '#00000050',
|
||||
gutterBackground: 'transparent',
|
||||
gutterForeground: '#8a919966',
|
||||
};
|
||||
export function getTheme() {
|
||||
return theme;
|
||||
}
|
||||
export function setTheme(_theme) {
|
||||
theme = _theme;
|
||||
}
|
||||
6
src/strudel/draw/index.mjs
Normal file
6
src/strudel/draw/index.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './animate.mjs';
|
||||
export * from './color.mjs';
|
||||
export * from './draw.mjs';
|
||||
export * from './pianoroll.mjs';
|
||||
export * from './spiral.mjs';
|
||||
export * from './pitchwheel.mjs';
|
||||
316
src/strudel/draw/pianoroll.mjs
Normal file
316
src/strudel/draw/pianoroll.mjs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/*
|
||||
pianoroll.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/canvas/pianoroll.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Pattern, noteToMidi, freqToMidi, isPattern } from '@strudel/core';
|
||||
import { getTheme, getDrawContext } from './draw.mjs';
|
||||
|
||||
const scale = (normalized, min, max) => normalized * (max - min) + min;
|
||||
const getValue = (e) => {
|
||||
let { value } = e;
|
||||
if (typeof e.value !== 'object') {
|
||||
value = { value };
|
||||
}
|
||||
let { note, n, freq, s } = value;
|
||||
if (freq) {
|
||||
return freqToMidi(freq);
|
||||
}
|
||||
note = note ?? n;
|
||||
if (typeof note === 'string') {
|
||||
try {
|
||||
// TODO: n(run(32)).scale("D:minor") fails when trying to query negative time..
|
||||
return noteToMidi(note);
|
||||
} catch (err) {
|
||||
// console.warn(`error converting note to midi: ${err}`); // this spams to crazy
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (typeof note === 'number') {
|
||||
return note;
|
||||
}
|
||||
if (s) {
|
||||
return '_' + s;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Visualises a pattern as a scrolling 'pianoroll', displayed in the background of the editor. To show a pianoroll for all running patterns, use `all(pianoroll)`. To have a pianoroll appear below
|
||||
* a pattern instead, prefix with `_`, e.g.: `sound("bd sd")._pianoroll()`.
|
||||
*
|
||||
* @name pianoroll
|
||||
* @synonyms punchcard
|
||||
* @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
|
||||
* @see _pianoroll
|
||||
* @example
|
||||
* note("c2 a2 eb2")
|
||||
* .euclid(5,8)
|
||||
* .s('sawtooth')
|
||||
* .lpenv(4).lpf(300)
|
||||
* .pianoroll({ labels: 1 })
|
||||
*/
|
||||
|
||||
Pattern.prototype.pianoroll = function (options = {}) {
|
||||
let { cycles = 4, playhead = 0.5, overscan = 0, hideNegative = false, ctx = getDrawContext(), id = 1 } = options;
|
||||
|
||||
let from = -cycles * playhead;
|
||||
let to = cycles * (1 - playhead);
|
||||
const inFrame = (hap, t) => (!hideNegative || hap.whole.begin >= 0) && hap.isWithinTime(t + from, t + to);
|
||||
this.draw(
|
||||
(haps, time) => {
|
||||
__pianoroll({
|
||||
...options,
|
||||
time,
|
||||
ctx,
|
||||
haps: haps.filter((hap) => inFrame(hap, time)),
|
||||
});
|
||||
},
|
||||
{
|
||||
lookbehind: from - overscan,
|
||||
lookahead: to + overscan,
|
||||
id,
|
||||
},
|
||||
);
|
||||
return this;
|
||||
};
|
||||
|
||||
export function pianoroll(arg) {
|
||||
if (isPattern(arg)) {
|
||||
// Single argument as a pattern
|
||||
// (to support `all(pianoroll)`)
|
||||
return arg.pianoroll();
|
||||
}
|
||||
// Single argument with option - return function to get the pattern
|
||||
// (to support `all(pianoroll(options))`)
|
||||
return (pat) => pat.pianoroll(arg);
|
||||
}
|
||||
|
||||
export function __pianoroll({
|
||||
time,
|
||||
haps,
|
||||
cycles = 4,
|
||||
playhead = 0.5,
|
||||
flipTime = 0,
|
||||
flipValues = 0,
|
||||
hideNegative = false,
|
||||
inactive = getTheme().foreground,
|
||||
active = getTheme().foreground,
|
||||
background = 'transparent',
|
||||
smear = 0,
|
||||
playheadColor = getTheme().foreground,
|
||||
minMidi = 10,
|
||||
maxMidi = 90,
|
||||
autorange = 0,
|
||||
timeframe: timeframeProp,
|
||||
fold = 1,
|
||||
vertical = 0,
|
||||
labels = false,
|
||||
fill = 1,
|
||||
fillActive = false,
|
||||
strokeActive = true,
|
||||
stroke,
|
||||
hideInactive = 0,
|
||||
colorizeInactive = 1,
|
||||
fontFamily,
|
||||
ctx,
|
||||
id,
|
||||
} = {}) {
|
||||
const w = ctx.canvas.width;
|
||||
const h = ctx.canvas.height;
|
||||
let from = -cycles * playhead;
|
||||
let to = cycles * (1 - playhead);
|
||||
|
||||
if (id) {
|
||||
haps = haps.filter((hap) => hap.hasTag(id));
|
||||
}
|
||||
|
||||
if (timeframeProp) {
|
||||
console.warn('timeframe is deprecated! use from/to instead');
|
||||
from = 0;
|
||||
to = timeframeProp;
|
||||
}
|
||||
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();
|
||||
|
||||
// onQuery
|
||||
const { min, max, values } = haps.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) =>
|
||||
typeof a === 'number' && typeof b === 'number'
|
||||
? a - b
|
||||
: typeof a === 'number'
|
||||
? 1
|
||||
: String(a).localeCompare(String(b)),
|
||||
);
|
||||
barThickness = fold ? valueAxis / foldValues.length : valueAxis / valueExtent;
|
||||
ctx.fillStyle = background;
|
||||
ctx.globalAlpha = 1; // reset!
|
||||
if (!smear) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
haps.forEach((event) => {
|
||||
const isActive = event.whole.begin <= time && event.endClipped > time;
|
||||
let strokeCurrent = stroke ?? (strokeActive && isActive);
|
||||
let fillCurrent = (!isActive && fill) || (isActive && fillActive);
|
||||
if (hideInactive && !isActive) {
|
||||
return;
|
||||
}
|
||||
let color = event.value?.color;
|
||||
active = color || active;
|
||||
inactive = colorizeInactive ? color || inactive : inactive;
|
||||
color = isActive ? active : inactive;
|
||||
ctx.fillStyle = fillCurrent ? color : 'transparent';
|
||||
ctx.strokeStyle = color;
|
||||
const { velocity = 1, gain = 1 } = event.value || {};
|
||||
ctx.globalAlpha = velocity * gain;
|
||||
const timeProgress = (event.whole.begin - (flipTime ? to : from)) / timeExtent;
|
||||
const timePx = scale(timeProgress, ...timeRange);
|
||||
let durationPx = scale(event.duration / timeExtent, 0, timeAxis);
|
||||
const value = getValue(event);
|
||||
const valueProgress = fold
|
||||
? foldValues.indexOf(value) / foldValues.length
|
||||
: (Number(value) - minMidi) / valueExtent;
|
||||
const valuePx = scale(valueProgress, ...valueRange);
|
||||
let margin = 0;
|
||||
const offset = scale(time / 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
|
||||
];
|
||||
}
|
||||
/* const xFactor = Math.sin(performance.now() / 500) + 1;
|
||||
coords[0] *= xFactor; */
|
||||
|
||||
if (strokeCurrent) {
|
||||
ctx.strokeRect(...coords);
|
||||
}
|
||||
if (fillCurrent) {
|
||||
ctx.fillRect(...coords);
|
||||
}
|
||||
//ctx.ellipse(...ellipseFromRect(...coords))
|
||||
if (labels) {
|
||||
const defaultLabel = event.value.note ?? event.value.s + (event.value.n ? `:${event.value.n}` : '');
|
||||
const { label: inactiveLabel, activeLabel } = event.value;
|
||||
const customLabel = isActive ? activeLabel || inactiveLabel : inactiveLabel;
|
||||
const label = customLabel ?? defaultLabel;
|
||||
let measure = vertical ? durationPx : barThickness * 0.75;
|
||||
ctx.font = `${measure}px ${fontFamily || 'monospace'}`;
|
||||
// font color
|
||||
ctx.fillStyle = /* isActive && */ !fillCurrent ? color : 'black';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(label, ...coords);
|
||||
}
|
||||
});
|
||||
ctx.globalAlpha = 1; // reset!
|
||||
const playheadPosition = scale(-from / timeExtent, ...timeRange);
|
||||
// draw playhead
|
||||
ctx.strokeStyle = playheadColor;
|
||||
ctx.beginPath();
|
||||
if (vertical) {
|
||||
ctx.moveTo(0, playheadPosition);
|
||||
ctx.lineTo(valueAxis, playheadPosition);
|
||||
} else {
|
||||
ctx.moveTo(playheadPosition, 0);
|
||||
ctx.lineTo(playheadPosition, valueAxis);
|
||||
}
|
||||
ctx.stroke();
|
||||
return this;
|
||||
}
|
||||
|
||||
export function getDrawOptions(drawTime, options = {}) {
|
||||
let [lookbehind, lookahead] = drawTime;
|
||||
lookbehind = Math.abs(lookbehind);
|
||||
const cycles = lookahead + lookbehind;
|
||||
const playhead = cycles !== 0 ? lookbehind / cycles : 0;
|
||||
return { fold: 1, ...options, cycles, playhead };
|
||||
}
|
||||
|
||||
export const getPunchcardPainter =
|
||||
(options = {}) =>
|
||||
(ctx, time, haps, drawTime) =>
|
||||
__pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, options) });
|
||||
|
||||
Pattern.prototype.punchcard = function (options) {
|
||||
return this.onPaint(getPunchcardPainter(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a vertical pianoroll with event labels.
|
||||
* Supports all the same options as pianoroll.
|
||||
*
|
||||
* @name wordfall
|
||||
*/
|
||||
Pattern.prototype.wordfall = function (options) {
|
||||
return this.punchcard({ vertical: 1, labels: 1, stroke: 0, fillActive: 1, active: 'white', ...options });
|
||||
};
|
||||
|
||||
/* Pattern.prototype.pianoroll = function (options) {
|
||||
return this.onPaint((ctx, time, haps, drawTime) =>
|
||||
pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { fold: 0, ...options }) }),
|
||||
);
|
||||
}; */
|
||||
|
||||
export function drawPianoroll(options) {
|
||||
const { drawTime, ...rest } = options;
|
||||
__pianoroll({ ...getDrawOptions(drawTime), ...rest });
|
||||
}
|
||||
144
src/strudel/draw/pitchwheel.mjs
Normal file
144
src/strudel/draw/pitchwheel.mjs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Pattern, midiToFreq, getFrequency } from '@strudel/core';
|
||||
import { getTheme, getDrawContext } from './draw.mjs';
|
||||
|
||||
const c = midiToFreq(36);
|
||||
|
||||
const circlePos = (cx, cy, radius, angle) => {
|
||||
angle = angle * Math.PI * 2;
|
||||
const x = Math.sin(angle) * radius + cx;
|
||||
const y = Math.cos(angle) * radius + cy;
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
const freq2angle = (freq, root) => {
|
||||
return 0.5 - (Math.log2(freq / root) % 1);
|
||||
};
|
||||
|
||||
export function pitchwheel({
|
||||
haps,
|
||||
ctx,
|
||||
id,
|
||||
hapcircles = 1,
|
||||
circle = 0,
|
||||
edo = 12,
|
||||
root = c,
|
||||
thickness = 3,
|
||||
hapRadius = 6,
|
||||
mode = 'flake',
|
||||
margin = 10,
|
||||
} = {}) {
|
||||
const connectdots = mode === 'polygon';
|
||||
const centerlines = mode === 'flake';
|
||||
const w = ctx.canvas.width;
|
||||
const h = ctx.canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const color = getTheme().foreground;
|
||||
|
||||
const size = Math.min(w, h);
|
||||
const radius = size / 2 - thickness / 2 - hapRadius - margin;
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
|
||||
if (id) {
|
||||
haps = haps.filter((hap) => hap.hasTag(id));
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.lineWidth = thickness;
|
||||
|
||||
if (circle) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (edo) {
|
||||
Array.from({ length: edo }, (_, i) => {
|
||||
const angle = freq2angle(root * Math.pow(2, i / edo), root);
|
||||
const [x, y] = circlePos(centerX, centerY, radius, angle);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
let shape = [];
|
||||
ctx.lineWidth = hapRadius;
|
||||
haps.forEach((hap) => {
|
||||
let freq;
|
||||
try {
|
||||
freq = getFrequency(hap);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
const angle = freq2angle(freq, root);
|
||||
const [x, y] = circlePos(centerX, centerY, radius, angle);
|
||||
const hapColor = hap.value.color || color;
|
||||
ctx.strokeStyle = hapColor;
|
||||
ctx.fillStyle = hapColor;
|
||||
const { velocity = 1, gain = 1 } = hap.value || {};
|
||||
const alpha = velocity * gain;
|
||||
ctx.globalAlpha = alpha;
|
||||
shape.push([x, y, angle, hapColor, alpha]);
|
||||
ctx.beginPath();
|
||||
if (hapcircles) {
|
||||
ctx.moveTo(x + hapRadius, y);
|
||||
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
if (centerlines) {
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = 1;
|
||||
if (connectdots && shape.length) {
|
||||
shape = shape.sort((a, b) => a[2] - b[2]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(shape[0][0], shape[0][1]);
|
||||
shape.forEach(([x, y, _, color, alpha]) => {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.lineTo(shape[0][0], shape[0][1]);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a pitch circle to visualize frequencies within one octave
|
||||
* @name pitchwheel
|
||||
* @param {number} hapcircles
|
||||
* @param {number} circle
|
||||
* @param {number} edo
|
||||
* @param {string} root
|
||||
* @param {number} thickness
|
||||
* @param {number} hapRadius
|
||||
* @param {string} mode
|
||||
* @param {number} margin
|
||||
* @example
|
||||
* n("0 .. 12").scale("C:chromatic")
|
||||
* .s("sawtooth")
|
||||
* .lpf(500)
|
||||
* ._pitchwheel()
|
||||
*/
|
||||
Pattern.prototype.pitchwheel = function (options = {}) {
|
||||
let { ctx = getDrawContext(), id = 1 } = options;
|
||||
return this.tag(id).onPaint((_, time, haps) =>
|
||||
pitchwheel({
|
||||
...options,
|
||||
time,
|
||||
ctx,
|
||||
haps: haps.filter((hap) => hap.isActive(time)),
|
||||
id,
|
||||
}),
|
||||
);
|
||||
};
|
||||
157
src/strudel/draw/spiral.mjs
Normal file
157
src/strudel/draw/spiral.mjs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { Pattern } from '@strudel/core';
|
||||
import { getTheme } from './draw.mjs';
|
||||
|
||||
// polar coords -> xy
|
||||
function fromPolar(angle, radius, cx, cy) {
|
||||
const radians = ((angle - 90) * Math.PI) / 180;
|
||||
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
|
||||
}
|
||||
|
||||
const xyOnSpiral = (angle, margin, cx, cy, rotate = 0) => fromPolar((angle + rotate) * 360, margin * angle, cx, cy); // TODO: logSpiral
|
||||
|
||||
// draw spiral / segment of spiral
|
||||
function spiralSegment(options) {
|
||||
let {
|
||||
ctx,
|
||||
from = 0,
|
||||
to = 3,
|
||||
margin = 50,
|
||||
cx = 100,
|
||||
cy = 100,
|
||||
rotate = 0,
|
||||
thickness = margin / 2,
|
||||
color = getTheme().foreground,
|
||||
cap = 'round',
|
||||
stretch = 1,
|
||||
fromOpacity = 1,
|
||||
toOpacity = 1,
|
||||
} = options;
|
||||
from *= stretch;
|
||||
to *= stretch;
|
||||
rotate *= stretch;
|
||||
ctx.lineWidth = thickness;
|
||||
ctx.lineCap = cap;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = fromOpacity;
|
||||
|
||||
ctx.beginPath();
|
||||
let [sx, sy] = xyOnSpiral(from, margin, cx, cy, rotate);
|
||||
ctx.moveTo(sx, sy);
|
||||
|
||||
const increment = 1 / 60;
|
||||
let angle = from;
|
||||
while (angle <= to) {
|
||||
const [x, y] = xyOnSpiral(angle, margin, cx, cy, rotate);
|
||||
//ctx.lineWidth = angle*thickness;
|
||||
ctx.globalAlpha = ((angle - from) / (to - from)) * toOpacity;
|
||||
ctx.lineTo(x, y);
|
||||
angle += increment;
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawSpiral(options) {
|
||||
let {
|
||||
stretch = 1,
|
||||
size = 80,
|
||||
thickness = size / 2,
|
||||
cap = 'butt', // round butt squar,
|
||||
inset = 3, // start angl,
|
||||
playheadColor = '#ffffff',
|
||||
playheadLength = 0.02,
|
||||
playheadThickness = thickness,
|
||||
padding = 0,
|
||||
steady = 1,
|
||||
activeColor = getTheme().foreground,
|
||||
inactiveColor = getTheme().gutterForeground,
|
||||
colorizeInactive = 0,
|
||||
fade = true,
|
||||
// logSpiral = true,
|
||||
ctx,
|
||||
time,
|
||||
haps,
|
||||
drawTime,
|
||||
id,
|
||||
} = options;
|
||||
|
||||
if (id) {
|
||||
haps = haps.filter((hap) => hap.hasTag(id));
|
||||
}
|
||||
|
||||
const [w, h] = [ctx.canvas.width, ctx.canvas.height];
|
||||
ctx.clearRect(0, 0, w * 2, h * 2);
|
||||
const [cx, cy] = [w / 2, h / 2];
|
||||
const settings = {
|
||||
margin: size / stretch,
|
||||
cx,
|
||||
cy,
|
||||
stretch,
|
||||
cap,
|
||||
thickness,
|
||||
};
|
||||
|
||||
const playhead = {
|
||||
...settings,
|
||||
thickness: playheadThickness,
|
||||
from: inset - playheadLength,
|
||||
to: inset,
|
||||
color: playheadColor,
|
||||
};
|
||||
|
||||
const [min] = drawTime;
|
||||
const rotate = steady * time;
|
||||
haps.forEach((hap) => {
|
||||
const isActive = hap.whole.begin <= time && hap.endClipped > time;
|
||||
const from = hap.whole.begin - time + inset;
|
||||
const to = hap.endClipped - time + inset - padding;
|
||||
const hapColor = hap.value?.color || activeColor;
|
||||
const color = colorizeInactive || isActive ? hapColor : inactiveColor;
|
||||
const opacity = fade ? 1 - Math.abs((hap.whole.begin - time) / min) : 1;
|
||||
spiralSegment({
|
||||
ctx,
|
||||
...settings,
|
||||
from,
|
||||
to,
|
||||
rotate,
|
||||
color,
|
||||
fromOpacity: opacity,
|
||||
toOpacity: opacity,
|
||||
});
|
||||
});
|
||||
spiralSegment({
|
||||
ctx,
|
||||
...playhead,
|
||||
rotate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a spiral visual.
|
||||
*
|
||||
* @name spiral
|
||||
* @param {Object} options Object containing all the optional following parameters as key value pairs:
|
||||
* @param {number} stretch controls the rotations per cycle ratio, where 1 = 1 cycle / 360 degrees
|
||||
* @param {number} size the diameter of the spiral
|
||||
* @param {number} thickness line thickness
|
||||
* @param {string} cap style of line ends: butt (default), round, square
|
||||
* @param {string} inset number of rotations before spiral starts (default 3)
|
||||
* @param {string} playheadColor color of playhead, defaults to white
|
||||
* @param {number} playheadLength length of playhead in rotations, defaults to 0.02
|
||||
* @param {number} playheadThickness thickness of playheadrotations, defaults to thickness
|
||||
* @param {number} padding space around spiral
|
||||
* @param {number} steady steadyness of spiral vs playhead. 1 = spiral doesn't move, playhead does.
|
||||
* @param {number} activeColor color of active segment. defaults to foreground of theme
|
||||
* @param {number} inactiveColor color of inactive segments. defaults to gutterForeground of theme
|
||||
* @param {boolean} colorizeInactive wether or not to colorize inactive segments, defaults to 0
|
||||
* @param {boolean} fade wether or not past and future should fade out. defaults to 1
|
||||
* @param {boolean} logSpiral wether or not the spiral should be logarithmic. defaults to 0
|
||||
* @example
|
||||
* note("c2 a2 eb2")
|
||||
* .euclid(5,8)
|
||||
* .s('sawtooth')
|
||||
* .lpenv(4).lpf(300)
|
||||
* ._spiral({ steady: .96 })
|
||||
*/
|
||||
Pattern.prototype.spiral = function (options = {}) {
|
||||
return this.onPaint((ctx, time, haps, drawTime) => drawSpiral({ ctx, time, haps, drawTime, ...options }));
|
||||
};
|
||||
3
src/strudel/midi/index.mjs
Normal file
3
src/strudel/midi/index.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import './midi.mjs';
|
||||
|
||||
export * from './midi.mjs';
|
||||
511
src/strudel/midi/midi.mjs
Normal file
511
src/strudel/midi/midi.mjs
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
/*
|
||||
midi.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as _WebMidi from 'webmidi';
|
||||
import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
|
||||
import { noteToMidi, getControlName } from '@strudel/core';
|
||||
import { Note } from 'webmidi';
|
||||
|
||||
// if you use WebMidi from outside of this package, make sure to import that instance:
|
||||
export const { WebMidi } = _WebMidi;
|
||||
|
||||
function supportsMidi() {
|
||||
return typeof navigator.requestMIDIAccess === 'function';
|
||||
}
|
||||
|
||||
function getMidiDeviceNamesString(devices) {
|
||||
return devices.map((o) => `'${o.name}'`).join(' | ');
|
||||
}
|
||||
|
||||
export function enableWebMidi(options = {}) {
|
||||
const { onReady, onConnected, onDisconnected, onEnabled } = options;
|
||||
if (WebMidi.enabled) {
|
||||
return;
|
||||
}
|
||||
if (!supportsMidi()) {
|
||||
throw new Error('Your Browser does not support WebMIDI.');
|
||||
}
|
||||
WebMidi.addListener('connected', () => {
|
||||
onConnected?.(WebMidi);
|
||||
});
|
||||
WebMidi.addListener('enabled', () => {
|
||||
onEnabled?.(WebMidi);
|
||||
});
|
||||
// Reacting when a device becomes unavailable
|
||||
WebMidi.addListener('disconnected', (e) => {
|
||||
onDisconnected?.(WebMidi, e);
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
if (WebMidi.enabled) {
|
||||
// if already enabled, just resolve WebMidi
|
||||
resolve(WebMidi);
|
||||
return;
|
||||
}
|
||||
WebMidi.enable(
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
onReady?.(WebMidi);
|
||||
resolve(WebMidi);
|
||||
},
|
||||
{ sysex: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getDevice(indexOrName, devices) {
|
||||
if (!devices.length) {
|
||||
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
|
||||
}
|
||||
if (typeof indexOrName === 'number') {
|
||||
return devices[indexOrName];
|
||||
}
|
||||
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 = byName('IAC');
|
||||
const device = IACOutput ?? devices[0];
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`🔌 MIDI device '${device ? device : ''}' not found. Use one of ${getMidiDeviceNamesString(devices)}`,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
// registry for midi mappings, converting control names to cc messages
|
||||
export const midicontrolMap = new Map();
|
||||
|
||||
// takes midimap and converts each control key to the main control name
|
||||
function unifyMapping(mapping) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(mapping).map(([key, mapping]) => {
|
||||
if (typeof mapping === 'number') {
|
||||
mapping = { ccn: mapping };
|
||||
}
|
||||
return [getControlName(key), mapping];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function githubPath(base, subpath = '') {
|
||||
if (!base.startsWith('github:')) {
|
||||
throw new Error('expected "github:" at the start of pseudoUrl');
|
||||
}
|
||||
let [_, path] = base.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';
|
||||
}
|
||||
return `https://raw.githubusercontent.com/${path}/${subpath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* configures the default midimap, which is used when no "midimap" port is set
|
||||
* @example
|
||||
* defaultmidimap({ lpf: 74 })
|
||||
* $: note("c a f e").midi();
|
||||
* $: lpf(sine.slow(4).segment(16)).midi();
|
||||
*/
|
||||
export function defaultmidimap(mapping) {
|
||||
midicontrolMap.set('default', unifyMapping(mapping));
|
||||
}
|
||||
|
||||
let loadCache = {};
|
||||
|
||||
/**
|
||||
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
|
||||
* @example
|
||||
* midimaps({ mymap: { lpf: 74 } })
|
||||
* $: note("c a f e")
|
||||
* .lpf(sine.slow(4))
|
||||
* .midimap('mymap')
|
||||
* .midi()
|
||||
* @example
|
||||
* midimaps({ mymap: {
|
||||
* lpf: { ccn: 74, min: 0, max: 20000, exp: 0.5 }
|
||||
* }})
|
||||
* $: note("c a f e")
|
||||
* .lpf(sine.slow(2).range(400,2000))
|
||||
* .midimap('mymap')
|
||||
* .midi()
|
||||
*/
|
||||
export async function midimaps(map) {
|
||||
if (typeof map === 'string') {
|
||||
if (map.startsWith('github:')) {
|
||||
map = githubPath(map, 'midimap.json');
|
||||
}
|
||||
if (!loadCache[map]) {
|
||||
loadCache[map] = fetch(map).then((res) => res.json());
|
||||
}
|
||||
map = await loadCache[map];
|
||||
}
|
||||
if (typeof map === 'object') {
|
||||
Object.entries(map).forEach(([name, mapping]) => midicontrolMap.set(name, unifyMapping(mapping)));
|
||||
}
|
||||
}
|
||||
|
||||
// registry for midi sounds, converting sound names to controls
|
||||
export const midisoundMap = new Map();
|
||||
|
||||
// normalizes the given value from the given range and exponent
|
||||
function normalize(value = 0, min = 0, max = 1, exp = 1) {
|
||||
if (min === max) {
|
||||
throw new Error('min and max cannot be the same value');
|
||||
}
|
||||
let normalized = (value - min) / (max - min);
|
||||
normalized = Math.min(1, Math.max(0, normalized));
|
||||
return Math.pow(normalized, exp);
|
||||
}
|
||||
|
||||
function mapCC(mapping, value) {
|
||||
return Object.keys(value)
|
||||
.filter((key) => !!mapping[getControlName(key)])
|
||||
.map((key) => {
|
||||
const { ccn, min = 0, max = 1, exp = 1 } = mapping[key];
|
||||
const ccv = normalize(value[key], min, max, exp);
|
||||
return { ccn, ccv };
|
||||
});
|
||||
}
|
||||
|
||||
// sends a cc message to the given device on the given channel
|
||||
function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
|
||||
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||
throw new Error('expected ccv to be a number between 0 and 1');
|
||||
}
|
||||
if (!['string', 'number'].includes(typeof ccn)) {
|
||||
throw new Error('expected ccn to be a number or a string');
|
||||
}
|
||||
const scaled = Math.round(ccv * 127);
|
||||
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a program change message to the given device on the given channel
|
||||
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
|
||||
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
||||
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
||||
}
|
||||
device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a sysex message to the given device on the given channel
|
||||
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
||||
if (Array.isArray(sysexid)) {
|
||||
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
||||
}
|
||||
} else if (!Number.isInteger(sysexid) || sysexid < 0 || sysexid > 255) {
|
||||
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
|
||||
}
|
||||
|
||||
if (!Array.isArray(sysexdata)) {
|
||||
throw new Error('expected sysex to be an array of numbers (0-255)');
|
||||
}
|
||||
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysex bytes must be integers between 0 and 255');
|
||||
}
|
||||
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a NRPN message to the given device on the given channel
|
||||
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
|
||||
if (Array.isArray(nrpnn)) {
|
||||
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
||||
}
|
||||
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
|
||||
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
|
||||
}
|
||||
|
||||
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a pitch bend message to the given device on the given channel
|
||||
function sendPitchBend(midibend, device, midichan, timeOffsetString) {
|
||||
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
||||
throw new Error('expected midibend to be a number between -1 and 1');
|
||||
}
|
||||
device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a channel aftertouch message to the given device on the given channel
|
||||
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
|
||||
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
||||
throw new Error('expected miditouch to be a number between 0 and 1');
|
||||
}
|
||||
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a note message to the given device on the given channel
|
||||
function sendNote(note, velocity, duration, device, midichan, timeOffsetString) {
|
||||
if (note == null || note === '') {
|
||||
throw new Error('note cannot be null or empty');
|
||||
}
|
||||
if (velocity != null && (typeof velocity !== 'number' || velocity < 0 || velocity > 1)) {
|
||||
throw new Error('velocity must be a number between 0 and 1');
|
||||
}
|
||||
if (duration != null && (typeof duration !== 'number' || duration < 0)) {
|
||||
throw new Error('duration must be a positive number');
|
||||
}
|
||||
|
||||
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
|
||||
const midiNote = new Note(midiNumber, { attack: velocity, duration });
|
||||
device.playNote(midiNote, midichan, {
|
||||
time: timeOffsetString,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* MIDI output: Opens a MIDI output port.
|
||||
* @param {string | number} midiport MIDI device name or index defaulting to 0
|
||||
* @param {object} options Additional MIDI configuration options
|
||||
* @example
|
||||
* note("c4").midichan(1).midi('IAC Driver Bus 1')
|
||||
* @example
|
||||
* note("c4").midichan(1).midi('IAC Driver Bus 1', { controller: true, latency: 50 })
|
||||
*/
|
||||
|
||||
Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
if (isPattern(midiport)) {
|
||||
throw new Error(
|
||||
`.midi does not accept Pattern input for midiport. Make sure to pass device name with single quotes. Example: .midi('${
|
||||
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
|
||||
}')`,
|
||||
);
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
if (typeof midiport === 'object') {
|
||||
const { port, isController = false, ...configOptions } = midiport;
|
||||
options = {
|
||||
isController,
|
||||
...configOptions,
|
||||
...options, // Keep any options passed separately
|
||||
};
|
||||
midiport = port;
|
||||
}
|
||||
|
||||
let midiConfig = {
|
||||
// Default configuration values
|
||||
isController: false, // Disable sending notes for midi controllers
|
||||
latencyMs: 34, // Default latency to get audio engine to line up in ms
|
||||
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
|
||||
midichannel: 1, // Default MIDI channel
|
||||
velocity: 0.9, // Default velocity
|
||||
gain: 1, // Default gain
|
||||
midimap: 'default', // Default MIDI map
|
||||
midiport: midiport, // Store the port in the config
|
||||
...options, // Override defaults with provided options
|
||||
};
|
||||
|
||||
enableWebMidi({
|
||||
onEnabled: ({ outputs }) => {
|
||||
const device = getDevice(midiConfig.midiport, outputs);
|
||||
const otherOutputs = outputs.filter((o) => o.name !== device.name);
|
||||
logger(
|
||||
`Midi enabled! Using "${device.name}". ${
|
||||
otherOutputs?.length ? `Also available: ${getMidiDeviceNamesString(otherOutputs)}` : ''
|
||||
}`,
|
||||
);
|
||||
},
|
||||
onDisconnected: ({ outputs }) =>
|
||||
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
||||
});
|
||||
|
||||
return this.onTrigger((hap, currentTime, cps, targetTime) => {
|
||||
if (!WebMidi.enabled) {
|
||||
logger('Midi not enabled');
|
||||
return;
|
||||
}
|
||||
hap.ensureObjectValue();
|
||||
|
||||
//magic number to get audio engine to line up, can probably be calculated somehow
|
||||
const latencyMs = midiConfig.latencyMs;
|
||||
// passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
|
||||
const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
|
||||
|
||||
// midi event values from hap with configurable defaults
|
||||
let {
|
||||
note,
|
||||
nrpnn,
|
||||
nrpv,
|
||||
ccn,
|
||||
ccv,
|
||||
midichan = midiConfig.midichannel,
|
||||
midicmd,
|
||||
midibend,
|
||||
miditouch,
|
||||
polyTouch,
|
||||
gain = midiConfig.gain,
|
||||
velocity = midiConfig.velocity,
|
||||
progNum,
|
||||
sysexid,
|
||||
sysexdata,
|
||||
midimap = midiConfig.midimap,
|
||||
midiport = midiConfig.midiport,
|
||||
} = hap.value;
|
||||
|
||||
const device = getDevice(midiport, WebMidi.outputs);
|
||||
if (!device) {
|
||||
logger(
|
||||
`[midi] midiport "${midiport}" not found! available: ${WebMidi.outputs.map((output) => `'${output.name}'`).join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
velocity = gain * velocity;
|
||||
|
||||
// Handle midimap
|
||||
// if midimap is set, send a cc messages from defined controls
|
||||
if (midicontrolMap.has(midimap)) {
|
||||
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
|
||||
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString));
|
||||
} else if (midimap !== 'default') {
|
||||
// Add warning when a non-existent midimap is specified
|
||||
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
||||
}
|
||||
|
||||
// Handle note
|
||||
if (note !== undefined && !midiConfig.isController) {
|
||||
// note off messages will often a few ms arrive late,
|
||||
// try to prevent glitching by subtracting noteOffsetMs from the duration length
|
||||
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
|
||||
|
||||
sendNote(note, velocity, duration, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle program change
|
||||
if (progNum !== undefined) {
|
||||
sendProgramChange(progNum, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle sysex
|
||||
// sysex data is consist of 2 arrays, first is sysexid, second is sysexdata
|
||||
// sysexid is a manufacturer id it is either a number or an array of 3 numbers.
|
||||
// list of manufacturer ids can be found here : https://midi.org/sysexidtable
|
||||
// if sysexid is an array the first byte is 0x00
|
||||
|
||||
if (sysexid !== undefined && sysexdata !== undefined) {
|
||||
sendSysex(sysexid, sysexdata, device, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle control change
|
||||
if (ccv !== undefined && ccn !== undefined) {
|
||||
sendCC(ccn, ccv, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle NRPN non-registered parameter number
|
||||
if (nrpnn !== undefined && nrpv !== undefined) {
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle midibend
|
||||
if (midibend !== undefined) {
|
||||
sendPitchBend(midibend, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle miditouch
|
||||
if (miditouch !== undefined) {
|
||||
sendAftertouch(miditouch, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle midicmd
|
||||
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 });
|
||||
} else if (Array.isArray(midicmd)) {
|
||||
if (midicmd[0] === 'progNum') {
|
||||
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
|
||||
} else if (midicmd[0] === 'cc') {
|
||||
if (midicmd.length === 2) {
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
|
||||
}
|
||||
} else if (midicmd[0] === 'sysex') {
|
||||
if (midicmd.length === 3) {
|
||||
const [_, id, data] = midicmd;
|
||||
sendSysex(id, data, device, timeOffsetString);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let listeners = {};
|
||||
const refs = {};
|
||||
|
||||
/**
|
||||
* MIDI input: Opens a MIDI input port to receive MIDI control change messages.
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
* @returns {Function}
|
||||
* @example
|
||||
* let cc = await midin('IAC Driver Bus 1')
|
||||
* note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth")
|
||||
*/
|
||||
export async function midin(input) {
|
||||
if (isPattern(input)) {
|
||||
throw new Error(
|
||||
`midin: does not accept Pattern as input. Make sure to pass device name with single quotes. Example: midin('${
|
||||
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
|
||||
}')`,
|
||||
);
|
||||
}
|
||||
const initial = await enableWebMidi(); // only returns on first init
|
||||
const device = getDevice(input, WebMidi.inputs);
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(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)}` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
// ensure refs for this input are initialized
|
||||
if (!refs[input]) {
|
||||
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;
|
||||
}
|
||||
2
src/strudel/mini/index.mjs
Normal file
2
src/strudel/mini/index.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './mini.mjs';
|
||||
export * from './krill-parser.js';
|
||||
2497
src/strudel/mini/krill-parser.js
Normal file
2497
src/strudel/mini/krill-parser.js
Normal file
File diff suppressed because one or more lines are too long
303
src/strudel/mini/krill.pegjs
Normal file
303
src/strudel/mini/krill.pegjs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
/*
|
||||
krill.pegjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/mini/krill.pegjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Some terminology:
|
||||
// mini(notation) = a series of elements placed between quotes
|
||||
// a stack = a series of vertically aligned slices sharing the same overall length
|
||||
// a sequence = a series of horizontally aligned elements
|
||||
// a choose = a series of elements, one of which is chosen at random
|
||||
|
||||
|
||||
{
|
||||
var AtomStub = function(source)
|
||||
{
|
||||
this.type_ = "atom";
|
||||
this.source_ = source;
|
||||
this.location_ = location();
|
||||
}
|
||||
|
||||
var PatternStub = function(source, alignment, seed, _steps)
|
||||
{
|
||||
this.type_ = "pattern";
|
||||
this.arguments_ = { alignment: alignment, _steps: _steps };
|
||||
if (seed !== undefined) {
|
||||
this.arguments_.seed = seed;
|
||||
}
|
||||
this.source_ = source;
|
||||
}
|
||||
|
||||
var OperatorStub = function(name, args, source)
|
||||
{
|
||||
this.type_ = name;
|
||||
this.arguments_ = args;
|
||||
this.source_ = source;
|
||||
}
|
||||
|
||||
var ElementStub = function(source, options)
|
||||
{
|
||||
this.type_ = "element";
|
||||
this.source_ = source;
|
||||
this.options_ = options;
|
||||
this.location_ = location();
|
||||
}
|
||||
|
||||
var CommandStub = function(name, options)
|
||||
{
|
||||
this.type_ = "command";
|
||||
this.name_ = name;
|
||||
this.options_ = options;
|
||||
}
|
||||
|
||||
var seed = 0;
|
||||
}
|
||||
|
||||
start = statement
|
||||
|
||||
// ----- Numbers -----
|
||||
|
||||
number "number"
|
||||
= minus? int frac? exp? { return parseFloat(text()); }
|
||||
|
||||
decimal_point
|
||||
= "."
|
||||
|
||||
digit1_9
|
||||
= [1-9]
|
||||
|
||||
e
|
||||
= [eE]
|
||||
|
||||
exp
|
||||
= e (minus / plus)? DIGIT+
|
||||
|
||||
frac
|
||||
= decimal_point DIGIT+
|
||||
|
||||
int
|
||||
= zero / (digit1_9 DIGIT*)
|
||||
|
||||
intneg
|
||||
= minus? int { return parseInt(text()); }
|
||||
|
||||
minus
|
||||
= "-"
|
||||
|
||||
plus
|
||||
= "+"
|
||||
|
||||
zero
|
||||
= "0"
|
||||
|
||||
DIGIT = [0-9]
|
||||
|
||||
// ------------------ delimiters ---------------------------
|
||||
|
||||
ws "whitespace" = [ \n\r\t\u00A0]*
|
||||
comma = ws "," ws
|
||||
pipe = ws "|" ws
|
||||
dot = ws "." ws
|
||||
quote = '"' / "'"
|
||||
|
||||
// ------------------ steps and cycles ---------------------------
|
||||
|
||||
// single step definition (e.g bd)
|
||||
step_char "a letter, a number, \"-\", \"#\", \".\", \"^\", \"_\"" =
|
||||
unicode_letter / [0-9~] / "-" / "#" / "." / "^" / "_"
|
||||
|
||||
step = ws chars:step_char+ ws !{ const s = chars.join(""); return (s === ".") || (s === "_") } { return new AtomStub(chars.join("")) }
|
||||
|
||||
// define a sub cycle e.g. [1 2, 3 [4]]
|
||||
sub_cycle = ws "[" ws s:stack_or_choose ws "]" ws { return s }
|
||||
|
||||
// define a polymeter e.g. {1 2, 3 4 5}
|
||||
polymeter = ws "{" ws s:polymeter_stack ws "}" stepsPerCycle:polymeter_steps? ws
|
||||
{ s.arguments_.stepsPerCycle = stepsPerCycle ; return s; }
|
||||
|
||||
polymeter_steps = "%"a:slice
|
||||
{ return a }
|
||||
|
||||
// define a step-per-cycle timeline e.g <1 3 [3 5]>. We simply defer to a sequence and
|
||||
// change the alignment to slowcat
|
||||
slow_sequence = ws "<" ws s:polymeter_stack ws ">" ws
|
||||
{ s.arguments_.alignment = 'polymeter_slowcat'; return s; }
|
||||
|
||||
// a slice is either a single step or a sub cycle
|
||||
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 / op_range
|
||||
|
||||
op_weight = ws ("@" / "_") a:number?
|
||||
{ return x => x.options_['weight'] = (x.options_['weight'] ?? 1) + (a ?? 2) - 1 }
|
||||
|
||||
op_replicate = ws "!" a:number?
|
||||
{ return x => {// A bit fiddly, to support both x!4 and x!!! as equivalent..
|
||||
const reps = (x.options_['reps'] ?? 1) + (a ?? 2) - 1;
|
||||
x.options_['reps'] = reps;
|
||||
x.options_['ops'] = x.options_['ops'].filter(x => x.type_ !== "replicate");
|
||||
x.options_['ops'].push({ type_: "replicate", arguments_ :{ amount:reps }});
|
||||
x.options_['weight'] = reps;
|
||||
}
|
||||
}
|
||||
|
||||
op_bjorklund = "(" ws p:slice_with_ops ws comma ws s:slice_with_ops ws comma? ws r:slice_with_ops? ws ")"
|
||||
{ return x => x.options_['ops'].push({ type_: "bjorklund", arguments_ :{ pulse: p, step:s, rotation:r }}) }
|
||||
|
||||
op_slow = "/"a:slice
|
||||
{ return x => x.options_['ops'].push({ type_: "stretch", arguments_ :{ amount:a, type: 'slow' }}) }
|
||||
|
||||
op_fast = "*"a:slice
|
||||
{ return x => x.options_['ops'].push({ type_: "stretch", arguments_ :{ amount:a, type: 'fast' }}) }
|
||||
|
||||
op_degrade = "?"a:number?
|
||||
{ return x => x.options_['ops'].push({ type_: "degradeBy", arguments_ :{ amount:a, seed: seed++ } }) }
|
||||
|
||||
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});
|
||||
for (const op of ops) {
|
||||
op(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// a sequence is a combination of one or more successive slices (as an array)
|
||||
sequence = _steps:'^'? s:(slice_with_ops)+
|
||||
{ return new PatternStub(s, 'fastcat', undefined, !!_steps); }
|
||||
|
||||
// a stack is a series of vertically aligned sequence, separated by a comma
|
||||
stack_tail = tail:(comma @sequence)+
|
||||
{ return { alignment: 'stack', list: tail }; }
|
||||
|
||||
// a choose is a series of pipe-separated sequence, one of which is
|
||||
// chosen at random, each cycle
|
||||
choose_tail = tail:(pipe @sequence)+
|
||||
{ return { alignment: 'rand', list: tail, seed: seed++ }; }
|
||||
|
||||
// a foot separates subsequences, as an alternative to wrapping them in []
|
||||
dot_tail = tail:(dot @sequence)+
|
||||
{ return { alignment: 'feet', list: tail, seed: seed++ }; }
|
||||
|
||||
// if the stack contains only one element, we don't create a stack but return the
|
||||
// underlying element
|
||||
stack_or_choose = head:sequence tail:(stack_tail / choose_tail / dot_tail)?
|
||||
{if (tail && tail.list.length > 0) { return new PatternStub([head, ...tail.list], tail.alignment, tail.seed); } else { return head; } }
|
||||
|
||||
polymeter_stack = head:sequence tail:stack_tail?
|
||||
{ return new PatternStub(tail ? [head, ...tail.list] : [head], 'polymeter'); }
|
||||
|
||||
|
||||
// Mini-notation innards ends
|
||||
// ---------->8---------->8---------->8---------->8---------->8----------
|
||||
// Experimental haskellish parser begins
|
||||
|
||||
// mini-notation = a quoted stack
|
||||
mini = ws quote ws sc:stack_or_choose ws quote
|
||||
{ return sc; }
|
||||
|
||||
// ------------------ operators ---------------------------
|
||||
|
||||
operator = scale / slow / fast / target / bjorklund / struct / rotR / rotL
|
||||
|
||||
struct = "struct" ws s:mini_or_operator
|
||||
{ return { name: "struct", args: { mini:s }}}
|
||||
|
||||
target = "target" ws quote s:step quote
|
||||
{ return { name: "target", args : { name:s}}}
|
||||
|
||||
bjorklund = "euclid" ws p:int ws s:int ws r:int?
|
||||
{ return { name: "bjorklund", args :{ pulse: p, step:parseInt(s) }}}
|
||||
|
||||
slow = "slow" ws a:number
|
||||
{ return { name: "stretch", args :{ amount: a}}}
|
||||
|
||||
rotL = "rotL" ws a:number
|
||||
{ return { name: "shift", args :{ amount: "-"+a}}}
|
||||
|
||||
rotR = "rotR" ws a:number
|
||||
{ return { name: "shift", args :{ amount: a}}}
|
||||
|
||||
fast = "fast" ws a:number
|
||||
{ return { name: "stretch", args :{ amount: "1/"+a}}}
|
||||
|
||||
scale = "scale" ws quote s:(step_char)+ quote
|
||||
{ return { name: "scale", args :{ scale: s.join("")}}}
|
||||
|
||||
comment = '//' p:([^\n]*)
|
||||
|
||||
// ---------------- grouping --------------------------------
|
||||
|
||||
group_operator = cat
|
||||
|
||||
// cat is another form of timeline
|
||||
cat = "cat" ws "[" ws s:mini_or_operator ss:(comma v:mini_or_operator { return v})* ws "]"
|
||||
{ ss.unshift(s); return new PatternStub(ss, 'slowcat'); }
|
||||
|
||||
// ------------------ high level mini ---------------------------
|
||||
|
||||
mini_or_group =
|
||||
group_operator /
|
||||
mini
|
||||
|
||||
mini_or_operator =
|
||||
sg:mini_or_group ws (comment)*
|
||||
{return sg}
|
||||
/ o:operator ws "$" ws soc:mini_or_operator
|
||||
{ return new OperatorStub(o.name,o.args,soc)}
|
||||
|
||||
sequ_or_operator_or_comment =
|
||||
sc: mini_or_operator
|
||||
{ return sc }
|
||||
/ comment
|
||||
|
||||
mini_definition = s:sequ_or_operator_or_comment
|
||||
|
||||
// ---------------------- statements ----------------------------
|
||||
|
||||
command = ws c:(setcps / setbpm / hush) ws
|
||||
{ return c }
|
||||
|
||||
setcps = "setcps" ws v:number
|
||||
{ return new CommandStub("setcps", { value: v})}
|
||||
|
||||
setbpm = "setbpm" ws v:number
|
||||
{ return new CommandStub("setcps", { value: (v/120/2)})}
|
||||
|
||||
hush = "hush"
|
||||
{ return new CommandStub("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]
|
||||
261
src/strudel/mini/mini.mjs
Normal file
261
src/strudel/mini/mini.mjs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/*
|
||||
mini.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/mini/mini.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as krill from './krill-parser.js';
|
||||
import * as strudel from '@strudel/core';
|
||||
import Fraction, { lcm } from '@strudel/core/fraction.mjs';
|
||||
|
||||
const randOffset = 0.0003;
|
||||
|
||||
const applyOptions = (parent, enter) => (pat, i) => {
|
||||
const ast = parent.source_[i];
|
||||
const options = ast.options_;
|
||||
const ops = options?.ops;
|
||||
const steps_source = pat.__steps_source;
|
||||
if (ops) {
|
||||
for (const op of ops) {
|
||||
switch (op.type_) {
|
||||
case 'stretch': {
|
||||
const legalTypes = ['fast', 'slow'];
|
||||
const { type, amount } = op.arguments_;
|
||||
if (!legalTypes.includes(type)) {
|
||||
throw new Error(`mini: stretch: type must be one of ${legalTypes.join('|')} but got ${type}`);
|
||||
}
|
||||
pat = strudel.reify(pat)[type](enter(amount));
|
||||
break;
|
||||
}
|
||||
case 'replicate': {
|
||||
const { amount } = op.arguments_;
|
||||
pat = strudel.reify(pat);
|
||||
pat = pat._repeatCycles(amount)._fast(amount);
|
||||
break;
|
||||
}
|
||||
case 'bjorklund': {
|
||||
if (op.arguments_.rotation) {
|
||||
pat = pat.euclidRot(enter(op.arguments_.pulse), enter(op.arguments_.step), enter(op.arguments_.rotation));
|
||||
} else {
|
||||
pat = pat.euclid(enter(op.arguments_.pulse), enter(op.arguments_.step));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'degradeBy': {
|
||||
pat = strudel
|
||||
.reify(pat)
|
||||
._degradeByWith(strudel.rand.early(randOffset * op.arguments_.seed), op.arguments_.amount ?? 0.5);
|
||||
break;
|
||||
}
|
||||
case 'tail': {
|
||||
const friend = enter(op.arguments_.element);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pat.__steps_source = pat.__steps_source || steps_source;
|
||||
return pat;
|
||||
};
|
||||
|
||||
// expects ast from mini2ast + quoted mini string + optional callback when a node is entered
|
||||
export function patternifyAST(ast, code, onEnter, offset = 0) {
|
||||
onEnter?.(ast);
|
||||
const enter = (node) => patternifyAST(node, code, onEnter, offset);
|
||||
switch (ast.type_) {
|
||||
case 'pattern': {
|
||||
// resolveReplications(ast);
|
||||
const children = ast.source_.map((child) => enter(child)).map(applyOptions(ast, enter));
|
||||
const alignment = ast.arguments_.alignment;
|
||||
const with_steps = children.filter((child) => child.__steps_source);
|
||||
let pat;
|
||||
switch (alignment) {
|
||||
case 'stack': {
|
||||
pat = strudel.stack(...children);
|
||||
if (with_steps.length) {
|
||||
pat._steps = lcm(...with_steps.map((x) => Fraction(x._steps)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polymeter_slowcat': {
|
||||
pat = strudel.stack(...children.map((child) => child._slow(child.__weight)));
|
||||
if (with_steps.length) {
|
||||
pat._steps = lcm(...with_steps.map((x) => Fraction(x._steps)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polymeter': {
|
||||
// polymeter
|
||||
const stepsPerCycle = ast.arguments_.stepsPerCycle
|
||||
? enter(ast.arguments_.stepsPerCycle).fmap((x) => strudel.Fraction(x))
|
||||
: strudel.pure(strudel.Fraction(children.length > 0 ? children[0].__weight : 1));
|
||||
|
||||
const aligned = children.map((child) => child.fast(stepsPerCycle.fmap((x) => x.div(child.__weight))));
|
||||
pat = strudel.stack(...aligned);
|
||||
break;
|
||||
}
|
||||
case 'rand': {
|
||||
pat = strudel.chooseInWith(strudel.rand.early(randOffset * ast.arguments_.seed).segment(1), children);
|
||||
if (with_steps.length) {
|
||||
pat._steps = lcm(...with_steps.map((x) => Fraction(x._steps)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'feet': {
|
||||
pat = strudel.fastcat(...children);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const weightedChildren = ast.source_.some((child) => !!child.options_?.weight);
|
||||
if (weightedChildren) {
|
||||
const weightSum = ast.source_.reduce(
|
||||
(sum, child) => sum.add(child.options_?.weight || strudel.Fraction(1)),
|
||||
strudel.Fraction(0),
|
||||
);
|
||||
pat = strudel.timeCat(
|
||||
...ast.source_.map((child, i) => [child.options_?.weight || strudel.Fraction(1), children[i]]),
|
||||
);
|
||||
pat.__weight = weightSum; // for polymeter
|
||||
pat._steps = weightSum;
|
||||
if (with_steps.length) {
|
||||
pat._steps = pat._steps.mul(lcm(...with_steps.map((x) => Fraction(x._steps))));
|
||||
}
|
||||
} else {
|
||||
pat = strudel.sequence(...children);
|
||||
pat._steps = children.length;
|
||||
}
|
||||
if (ast.arguments_._steps) {
|
||||
pat.__steps_source = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (with_steps.length) {
|
||||
pat.__steps_source = true;
|
||||
}
|
||||
return pat;
|
||||
}
|
||||
case 'element': {
|
||||
1;
|
||||
return enter(ast.source_);
|
||||
}
|
||||
case 'atom': {
|
||||
if (ast.source_ === '~' || ast.source_ === '-') {
|
||||
return strudel.silence;
|
||||
}
|
||||
if (!ast.location_) {
|
||||
console.warn('no location for', ast);
|
||||
return ast.source_;
|
||||
}
|
||||
const value = !isNaN(Number(ast.source_)) ? Number(ast.source_) : ast.source_;
|
||||
if (offset === -1) {
|
||||
// skip location handling (used when getting leaves to avoid confusion)
|
||||
return strudel.pure(value);
|
||||
}
|
||||
const [from, to] = getLeafLocation(code, ast, offset);
|
||||
return strudel.pure(value).withLoc(from, to);
|
||||
}
|
||||
case 'stretch':
|
||||
return enter(ast.source_).slow(enter(ast.arguments_.amount));
|
||||
default:
|
||||
console.warn(`node type "${ast.type_}" not implemented -> returning silence`);
|
||||
return strudel.silence;
|
||||
}
|
||||
}
|
||||
|
||||
// takes quoted mini string + leaf node within, returns source location of node (whitespace corrected)
|
||||
export const getLeafLocation = (code, leaf, globalOffset = 0) => {
|
||||
// value is expected without quotes!
|
||||
const { start, end } = leaf.location_;
|
||||
const actual = code?.split('').slice(start.offset, end.offset).join('');
|
||||
// make sure whitespaces are not part of the highlight
|
||||
const [offsetStart = 0, offsetEnd = 0] = actual
|
||||
? actual.split(leaf.source_).map((p) => p.split('').filter((c) => c === ' ').length)
|
||||
: [];
|
||||
return [start.offset + offsetStart + globalOffset, end.offset - offsetEnd + globalOffset];
|
||||
};
|
||||
|
||||
// takes quoted mini string, returns ast
|
||||
export const mini2ast = (code, start = 0, userCode = code) => {
|
||||
try {
|
||||
return krill.parse(code);
|
||||
} catch (error) {
|
||||
const region = [error.location.start.offset + start, error.location.end.offset + start];
|
||||
const line = userCode.slice(0, region[0]).split('\n').length;
|
||||
throw new Error(`[mini] parse error at line ${line}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// takes quoted mini string, returns all nodes that are leaves
|
||||
export const getLeaves = (code, start, userCode) => {
|
||||
const ast = mini2ast(code, start, userCode);
|
||||
let leaves = [];
|
||||
patternifyAST(
|
||||
ast,
|
||||
code,
|
||||
(node) => {
|
||||
if (node.type_ === 'atom') {
|
||||
leaves.push(node);
|
||||
}
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return leaves;
|
||||
};
|
||||
|
||||
// takes quoted mini string, returns locations [fromCol,toCol] of all leaf nodes
|
||||
export const getLeafLocations = (code, start = 0, userCode) => {
|
||||
return getLeaves(code, start, userCode).map((l) => getLeafLocation(code, l, start));
|
||||
};
|
||||
|
||||
// mini notation only (wraps in "")
|
||||
export const mini = (...strings) => {
|
||||
const pats = strings.map((str) => {
|
||||
const code = `"${str}"`;
|
||||
const ast = mini2ast(code);
|
||||
return patternifyAST(ast, code);
|
||||
});
|
||||
return strudel.sequence(...pats);
|
||||
};
|
||||
|
||||
// turns str mini string (without quotes) into pattern
|
||||
// offset is the position of the mini string in the JS code
|
||||
// each leaf node will get .withLoc added
|
||||
// this function is used by the transpiler for double quoted strings
|
||||
export const m = (str, offset) => {
|
||||
const code = `"${str}"`;
|
||||
const ast = mini2ast(code);
|
||||
return patternifyAST(ast, code, null, offset);
|
||||
};
|
||||
|
||||
// includes haskell style (raw krill parsing)
|
||||
export const h = (string) => {
|
||||
const ast = mini2ast(string);
|
||||
return patternifyAST(ast, string);
|
||||
};
|
||||
|
||||
export function minify(thing) {
|
||||
if (typeof thing === 'string') {
|
||||
return mini(thing);
|
||||
}
|
||||
return strudel.reify(thing);
|
||||
}
|
||||
|
||||
// calling this function will cause patterns to parse strings as mini notation by default
|
||||
export function miniAllStrings() {
|
||||
strudel.setStringParser(mini);
|
||||
}
|
||||
13
src/strudel/soundfonts/convert.js
Normal file
13
src/strudel/soundfonts/convert.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// this script converts a soundfont into a json file, it has not been not used yet
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const name = '0000_JCLive';
|
||||
|
||||
const js = await fetch(`https://felixroos.github.io/webaudiofontdata/sound/${name}_sf2_file.js`).then((res) =>
|
||||
res.text(),
|
||||
);
|
||||
// console.log(js);
|
||||
|
||||
let [_, data] = js.split('_sf2_file=');
|
||||
data = eval(data);
|
||||
console.log(JSON.stringify(data));
|
||||
184
src/strudel/soundfonts/fontloader.mjs
Normal file
184
src/strudel/soundfonts/fontloader.mjs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { noteToMidi, freqToMidi, getSoundIndex } from '@strudel/core';
|
||||
import {
|
||||
getAudioContext,
|
||||
registerSound,
|
||||
getParamADSR,
|
||||
getADSRValues,
|
||||
getPitchEnvelope,
|
||||
getVibratoOscillator,
|
||||
} from '@strudel/webaudio';
|
||||
import gm from './gm.mjs';
|
||||
|
||||
let defaultSoundfontUrl = 'https://felixroos.github.io/webaudiofontdata/sound';
|
||||
let soundfontUrl = defaultSoundfontUrl;
|
||||
|
||||
export function setSoundfontUrl(value) {
|
||||
soundfontUrl = value;
|
||||
}
|
||||
|
||||
let loadCache = {};
|
||||
async function loadFont(name) {
|
||||
if (loadCache[name]) {
|
||||
return loadCache[name];
|
||||
}
|
||||
const load = async () => {
|
||||
// TODO: make soundfont source configurable
|
||||
const url = `${soundfontUrl}/${name}.js`;
|
||||
const preset = await fetch(url).then((res) => res.text());
|
||||
let [_, data] = preset.split('={');
|
||||
return eval('{' + data);
|
||||
};
|
||||
loadCache[name] = load();
|
||||
return loadCache[name];
|
||||
}
|
||||
|
||||
export async function getFontBufferSource(name, value, ac) {
|
||||
let { note = 'c3', freq } = value;
|
||||
let midi;
|
||||
if (freq) {
|
||||
midi = freqToMidi(freq);
|
||||
} else if (typeof note === 'string') {
|
||||
midi = noteToMidi(note);
|
||||
} else if (typeof note === 'number') {
|
||||
midi = note;
|
||||
} else {
|
||||
throw new Error(`unexpected "note" type "${typeof note}"`);
|
||||
}
|
||||
|
||||
const { buffer, zone } = await getFontPitch(name, midi, ac);
|
||||
const src = ac.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
const baseDetune = zone.originalPitch - 100.0 * zone.coarseTune - zone.fineTune;
|
||||
const playbackRate = 1.0 * Math.pow(2, (100.0 * midi - baseDetune) / 1200.0);
|
||||
// src detune?
|
||||
src.playbackRate.value = playbackRate;
|
||||
const loop = zone.loopStart > 1 && zone.loopStart < zone.loopEnd;
|
||||
if (!loop) {
|
||||
/* const waveDuration = duration + this.afterTime;
|
||||
if (waveDuration > zone.buffer.duration / playbackRate) {
|
||||
waveDuration = zone.buffer.duration / playbackRate;
|
||||
// TODO: do sth with waveduration
|
||||
} */
|
||||
} else {
|
||||
src.loop = true;
|
||||
src.loopStart = zone.loopStart / zone.sampleRate;
|
||||
src.loopEnd = zone.loopEnd / zone.sampleRate;
|
||||
//+ (zone.delay ? zone.delay : 0);
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
let bufferCache = {};
|
||||
export async function getFontPitch(name, pitch, ac) {
|
||||
const key = `${name}:::${pitch}`;
|
||||
if (bufferCache[key]) {
|
||||
return bufferCache[key];
|
||||
}
|
||||
// console.log('load buffer', key);
|
||||
const load = async () => {
|
||||
const preset = await loadFont(name);
|
||||
if (!preset) {
|
||||
throw new Error(`Could not load soundfont ${name}`);
|
||||
}
|
||||
const zone = findZone(preset, pitch);
|
||||
if (!zone) {
|
||||
throw new Error('no soundfont zone found for preset ', name, 'pitch', pitch);
|
||||
}
|
||||
const buffer = await getBuffer(zone, ac);
|
||||
if (!buffer) {
|
||||
throw new Error(`no soundfont buffer found for preset ${name}, pitch: ${pitch}`);
|
||||
}
|
||||
return { buffer, zone };
|
||||
};
|
||||
bufferCache[key] = load(); // dont await here to cache promise immediately!
|
||||
return bufferCache[key];
|
||||
}
|
||||
|
||||
function findZone(preset, pitch) {
|
||||
return preset.find((zone) => {
|
||||
return zone.keyRangeLow <= pitch && zone.keyRangeHigh + 1 >= pitch;
|
||||
});
|
||||
}
|
||||
|
||||
// promisified version of https://github.com/felixroos/webaudiofont/blob/c6f97249b60dcfafc20fca5bb381294a6b2f8f51/npm/dist/WebAudioFontPlayer.js#L740
|
||||
async function getBuffer(zone, audioContext) {
|
||||
if (zone.sample) {
|
||||
console.warn('zone.sample untested!');
|
||||
const decoded = atob(zone.sample);
|
||||
zone.buffer = audioContext.createBuffer(1, decoded.length / 2, zone.sampleRate);
|
||||
const float32Array = zone.buffer.getChannelData(0);
|
||||
let b1, b2, n;
|
||||
for (var i = 0; i < decoded.length / 2; i++) {
|
||||
b1 = decoded.charCodeAt(i * 2);
|
||||
b2 = decoded.charCodeAt(i * 2 + 1);
|
||||
if (b1 < 0) {
|
||||
b1 = 256 + b1;
|
||||
}
|
||||
if (b2 < 0) {
|
||||
b2 = 256 + b2;
|
||||
}
|
||||
n = b2 * 256 + b1;
|
||||
if (n >= 65536 / 2) {
|
||||
n = n - 65536;
|
||||
}
|
||||
float32Array[i] = n / 65536.0;
|
||||
}
|
||||
} else {
|
||||
if (zone.file) {
|
||||
const datalen = zone.file.length;
|
||||
const arraybuffer = new ArrayBuffer(datalen);
|
||||
const view = new Uint8Array(arraybuffer);
|
||||
const decoded = atob(zone.file);
|
||||
let b;
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
b = decoded.charCodeAt(i);
|
||||
view[i] = b;
|
||||
}
|
||||
return new Promise((resolve) => audioContext.decodeAudioData(arraybuffer, resolve));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSoundfonts() {
|
||||
Object.entries(gm).forEach(([name, fonts]) => {
|
||||
registerSound(
|
||||
name,
|
||||
async (time, value, onended) => {
|
||||
const [attack, decay, sustain, release] = getADSRValues([
|
||||
value.attack,
|
||||
value.decay,
|
||||
value.sustain,
|
||||
value.release,
|
||||
]);
|
||||
|
||||
const { duration } = value;
|
||||
const n = getSoundIndex(value.n, fonts.length);
|
||||
const font = fonts[n];
|
||||
const ctx = getAudioContext();
|
||||
const bufferSource = await getFontBufferSource(font, value, ctx);
|
||||
bufferSource.start(time);
|
||||
const envGain = ctx.createGain();
|
||||
const node = bufferSource.connect(envGain);
|
||||
const holdEnd = time + duration;
|
||||
getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, time, holdEnd, 'linear');
|
||||
let envEnd = holdEnd + release + 0.01;
|
||||
|
||||
// vibrato
|
||||
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, time);
|
||||
// pitch envelope
|
||||
getPitchEnvelope(bufferSource.detune, value, time, holdEnd);
|
||||
|
||||
bufferSource.stop(envEnd);
|
||||
const stop = (releaseTime) => {};
|
||||
bufferSource.onended = () => {
|
||||
bufferSource.disconnect();
|
||||
vibratoOscillator?.stop();
|
||||
node.disconnect();
|
||||
onended();
|
||||
};
|
||||
return { node, stop };
|
||||
},
|
||||
{ type: 'soundfont', prebake: true, fonts },
|
||||
);
|
||||
});
|
||||
}
|
||||
1787
src/strudel/soundfonts/gm.mjs
Normal file
1787
src/strudel/soundfonts/gm.mjs
Normal file
File diff suppressed because it is too large
Load diff
6
src/strudel/soundfonts/index.mjs
Normal file
6
src/strudel/soundfonts/index.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getFontBufferSource, registerSoundfonts, setSoundfontUrl } from './fontloader.mjs';
|
||||
import * as soundfontList from './list.mjs';
|
||||
import { startPresetNote } from 'sfumato';
|
||||
import { loadSoundfont } from './sfumato.mjs';
|
||||
|
||||
export { loadSoundfont, startPresetNote, getFontBufferSource, soundfontList, registerSoundfonts, setSoundfontUrl };
|
||||
2028
src/strudel/soundfonts/list.mjs
Normal file
2028
src/strudel/soundfonts/list.mjs
Normal file
File diff suppressed because it is too large
Load diff
49
src/strudel/soundfonts/sfumato.mjs
Normal file
49
src/strudel/soundfonts/sfumato.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Pattern, getPlayableNoteValue, noteToMidi } from '@strudel/core';
|
||||
import { getAudioContext, registerSound } from '@strudel/webaudio';
|
||||
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
|
||||
|
||||
Pattern.prototype.soundfont = function (sf, n = 0) {
|
||||
return this.onTrigger((h, ct, cps, targetTime) => {
|
||||
const ctx = getAudioContext();
|
||||
const note = getPlayableNoteValue(h);
|
||||
const preset = sf.presets[n % sf.presets.length];
|
||||
const deadline = targetTime;
|
||||
const args = [ctx, preset, noteToMidi(note), deadline];
|
||||
const stop = startPresetNote(...args);
|
||||
stop(deadline + h.duration);
|
||||
});
|
||||
};
|
||||
|
||||
const soundfontCache = new Map();
|
||||
export function loadSoundfont(url) {
|
||||
if (soundfontCache.get(url)) {
|
||||
return soundfontCache.get(url);
|
||||
}
|
||||
const sf = _loadSoundfont(url);
|
||||
soundfontCache.set(url, sf);
|
||||
/*sf.then((font) => {
|
||||
font.presets.forEach((preset) => {
|
||||
console.log('preset', preset.header.name);
|
||||
registerSound(
|
||||
preset.header.name.replaceAll(' ', '_'),
|
||||
(time, value, onended) => {
|
||||
const ctx = getAudioContext();
|
||||
let { note } = value; // freq ?
|
||||
|
||||
const p = font.presets.find((p) => p.header.name === preset.header.name);
|
||||
|
||||
if (!p) {
|
||||
throw new Error('preset not found');
|
||||
}
|
||||
const deadline = time; // - ctx.currentTime;
|
||||
const args = [ctx, p, noteToMidi(note), deadline];
|
||||
const stop = startPresetNote(...args);
|
||||
return { node: undefined, stop };
|
||||
},
|
||||
{ type: 'soundfont' },
|
||||
);
|
||||
});
|
||||
//console.log('f', f);
|
||||
});*/
|
||||
return sf;
|
||||
}
|
||||
9
src/strudel/tonal/index.mjs
Normal file
9
src/strudel/tonal/index.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import './tonal.mjs';
|
||||
import './voicings.mjs';
|
||||
import './ireal.mjs';
|
||||
|
||||
export * from './tonal.mjs';
|
||||
export * from './voicings.mjs';
|
||||
export * from './ireal.mjs';
|
||||
|
||||
export const packageName = '@strudel/tonal';
|
||||
523
src/strudel/tonal/ireal.mjs
Normal file
523
src/strudel/tonal/ireal.mjs
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
// explore them here: https://codesandbox.io/s/voicing-explorer-ireal-47tkx5?file=/src/ireal.js:0-16036
|
||||
// scraped via: https://codesandbox.io/s/ireal-midi-scraper-2-gjz2mr?file=/src/index.js
|
||||
|
||||
export const simple = {
|
||||
2: ['1P 5P 8P 9M', '1P 5P 8P 9M 12P', '5P 8P 9M 12P'],
|
||||
5: ['1P 5P 8P 12P', '5P 8P 12P 15P'],
|
||||
6: ['1P 5P 6M 8P 10M', '1P 5P 8P 10M 13M', '3M 5P 8P 10M 13M', '5P 8P 10M 12P 13M'],
|
||||
7: [
|
||||
'1P 5P 7m 8P 10M',
|
||||
'1P 7m 8P 10M 12P',
|
||||
'3M 7m 8P 10M 12P',
|
||||
'3M 7m 8P 10M 14m',
|
||||
'3M 7m 10M 12P 15P',
|
||||
'7m 10M 12P 14m 15P',
|
||||
'7m 10M 12P 15P 17M',
|
||||
],
|
||||
9: [
|
||||
'1P 5P 7m 9M 10M',
|
||||
'1P 7m 9M 10M 12P',
|
||||
'3M 7m 8P 9M 12P',
|
||||
'7m 9M 10M 14m 15P',
|
||||
'3M 7m 8P 12P 16M',
|
||||
'7m 10M 12P 15P 16M',
|
||||
],
|
||||
11: ['1P 5P 7m 9M 11P', '5P 7m 8P 9M 11P', '7m 8P 9M 11P 12P', '7m 8P 11P 12P 16M'],
|
||||
13: ['1P 6M 7m 9M 10M', '1P 7m 9M 10M 13M', '3M 7m 8P 9M 13M', '7m 8P 9M 10M 13M', '7m 9M 10M 13M 15P'],
|
||||
69: ['1P 5P 6M 9M 10M', '1P 5P 9M 10M 13M', '3M 5P 8P 9M 13M', '5P 8P 9M 10M 13M'],
|
||||
add9: ['1P 5P 8P 9M 10M', '1P 5P 9M 10M 12P', '3M 8P 9M 10M 12P', '3M 8P 9M 12P 15P', '5P 8P 9M 12P 17M'],
|
||||
'+': [
|
||||
'1P 3M 6m 8P 10M',
|
||||
'1P 6m 8P 10M 13m',
|
||||
'3M 6m 8P 10M 13m',
|
||||
'3M 8P 10M 13m 15P',
|
||||
'6m 8P 10M 13m 15P',
|
||||
'6m 10M 13m 15P 17M',
|
||||
],
|
||||
o: ['1P 5d 8P 10m 12d', '3m 8P 10m 12d 15P', '5d 8P 10m 12d 15P'],
|
||||
h: [
|
||||
'3m 5d 7m 8P 10m',
|
||||
'1P 5d 7m 10m 12d',
|
||||
'3m 7m 8P 10m 12d',
|
||||
'3m 7m 8P 12d 14m',
|
||||
'5d 7m 8P 10m 14m',
|
||||
'5d 8P 10m 12d 14m',
|
||||
'7m 10m 12d 14m 15P',
|
||||
'5d 8P 10m 14m 17m',
|
||||
],
|
||||
sus: ['1P 4P 5P 8P', '1P 4P 5P 8P 11P', '5P 8P 11P 12P', '5P 8P 11P 12P 15P'],
|
||||
'^': ['1P 5P 8P 10M', '1P 5P 8P 10M 12P', '3M 5P 8P 10M 12P', '3M 8P 10M 12P 15P', '5P 8P 10M 12P 15P'],
|
||||
'-': ['1P 3m 5P 8P 10m', '1P 5P 8P 10m 12P', '3m 5P 8P 10m 12P', '5P 8P 10m 12P 15P'],
|
||||
'^7': ['1P 5P 7M 10M 12P', '1P 10M 12P 14M', '3M 8P 10M 12P 14M', '5P 8P 10M 12P 14M', '5P 8P 10M 14M 17M'],
|
||||
'-7': [
|
||||
'1P 3m 5P 7m 10m',
|
||||
'1P 5P 7m 10m 12P',
|
||||
'3m 7m 8P 10m 12P',
|
||||
'3m 7m 8P 10m 14m',
|
||||
'5P 7m 8P 10m 14m',
|
||||
'7m 10m 12P 14m 15P',
|
||||
'5P 8P 10m 14m 17m',
|
||||
'7m 10m 12P 15P 17m',
|
||||
],
|
||||
'7sus': ['1P 5P 7m 8P 11P', '5P 8P 11P 12P 14m', '7m 8P 11P 12P 14m', '7m 11P 12P 14m 18P'],
|
||||
h7: [
|
||||
'3m 5d 7m 8P 10m',
|
||||
'1P 5d 7m 10m 12d',
|
||||
'1P 7m 10m 12d',
|
||||
'3m 7m 8P 10m 12d',
|
||||
'3m 7m 8P 12d 14m',
|
||||
'5d 7m 8P 10m 14m',
|
||||
'5d 8P 10m 12d 14m',
|
||||
'7m 10m 12d 14m 15P',
|
||||
'5d 8P 10m 14m 17m',
|
||||
],
|
||||
o7: [
|
||||
'1P 6M 8P 10m 12d',
|
||||
'1P 6M 10m 12d 13M',
|
||||
'3m 8P 10m 12d 13M',
|
||||
'3m 8P 12d 13M 15P',
|
||||
'5d 10m 12d 13M 15P',
|
||||
'5d 10m 13M 15P 17m',
|
||||
'6M 12d 13M 15P 17m',
|
||||
'6M 12d 15P 17m 19d',
|
||||
],
|
||||
'^9': [
|
||||
'1P 5P 7M 9M 10M',
|
||||
'1P 7M 9M 10M 12P',
|
||||
'3M 7M 8P 9M 12P',
|
||||
'3M 7M 8P 12P 16M',
|
||||
'5P 8P 10M 14M 16M',
|
||||
'7M 8P 10M 12P 16M',
|
||||
],
|
||||
'^13': ['1P 6M 7M 9M 10M', '1P 7M 9M 10M 13M', '3M 7M 8P 9M 13M', '3M 7M 8P 13M 16M', '7M 8P 10M 13M 16M'],
|
||||
'^7#11': ['1P 5P 7M 10M 12d', '3M 7M 8P 10M 12d', '1P 7M 10M 12d 14M', '3M 7M 8P 12d 14M', '5P 8P 10M 12d 14M'],
|
||||
'^9#11': ['1P 3M 5d 7M 9M', '1P 7M 9M 10M 12d', '3M 7M 8P 9M 12d', '3M 8P 9M 12d 14M'],
|
||||
'^7#5': ['1P 6m 7M 10M 13m', '3M 7M 8P 10M 13m', '6m 7M 8P 10M 13m'],
|
||||
'-6': [
|
||||
'1P 3m 5P 6M 8P',
|
||||
'1P 5P 6M 8P 10m',
|
||||
'3m 5P 6M 8P 10m',
|
||||
'1P 5P 8P 10m 13M',
|
||||
'3m 5P 8P 10m 13M',
|
||||
'5P 8P 10m 12P 13M',
|
||||
'5P 8P 10m 13M 15P',
|
||||
],
|
||||
'-69': [
|
||||
'1P 3m 5P 6M 9M',
|
||||
'3m 5P 6M 8P 9M',
|
||||
'3m 6M 9M 10m 12P',
|
||||
'1P 5P 9M 10m 13M',
|
||||
'3m 5P 8P 9M 13M',
|
||||
'5P 8P 9M 10m 13M',
|
||||
'5P 8P 10m 13M 16M',
|
||||
],
|
||||
'-^7': ['1P 3m 5P 7M 10m', '1P 5P 7M 10m 12P', '3m 7M 8P 10m 12P', '5P 7M 8P 10m 14M', '5P 8P 10m 14M 17m'],
|
||||
'-^9': ['1P 3m 5P 7M 9M', '1P 7M 9M 10m 12P', '3m 7M 8P 9M 12P', '5P 8P 9M 10m 14M'],
|
||||
'-9': [
|
||||
'1P 3m 5P 7m 9M',
|
||||
'3m 5P 7m 8P 9M',
|
||||
'3m 7m 8P 9M 12P',
|
||||
'5P 8P 9M 10m 14m',
|
||||
'3m 7m 9M 12P 15P',
|
||||
'7m 10m 12P 15P 16M',
|
||||
],
|
||||
'-add9': ['1P 2M 3m 5P 8P', '1P 3m 5P 9M', '3m 5P 8P 9M 12P', '5P 8P 9M 10m 12P'],
|
||||
'-11': [
|
||||
'1P 3m 7m 9M 11P',
|
||||
'3m 7m 8P 9M 11P',
|
||||
'1P 4P 7m 10m 12P',
|
||||
'5P 8P 11P 14m',
|
||||
'3m 7m 9M 11P 15P',
|
||||
'5P 8P 11P 14m 16M',
|
||||
'7m 10m 12P 15P 18P',
|
||||
],
|
||||
'-7b5': [
|
||||
'3m 5d 7m 8P 10m',
|
||||
'1P 7m 10m 12d',
|
||||
'1P 5d 7m 10m 12d',
|
||||
'3m 7m 8P 10m 12d',
|
||||
'3m 7m 8P 12d 14m',
|
||||
'5d 7m 8P 10m 14m',
|
||||
'5d 8P 10m 12d 14m',
|
||||
'7m 10m 12d 14m 15P',
|
||||
'5d 8P 10m 14m 17m',
|
||||
],
|
||||
h9: ['1P 7m 9M 10m 12d', '3m 7m 8P 9M 12d', '5d 8P 9M 10m 14m', '7m 10m 12d 15P 16M'],
|
||||
'-b6': ['1P 5P 6m 8P 10m', '1P 5P 8P 10m 13m', '3m 5P 8P 10m 13m', '5P 8P 10m 13m', '5P 8P 10m 13m 15P'],
|
||||
'-#5': ['1P 6m 8P 10m 13m', '3m 6m 8P 10m 13m', '6m 8P 10m 13m 15P'],
|
||||
'7b9': ['1P 3M 7m 9m 10M', '3M 7m 8P 9m 10M', '3M 7m 8P 9m 14m', '7m 9m 10M 14m 15P'],
|
||||
'7#9': ['1P 3M 7m 10m', '3M 7m 8P 10m 14m', '7m 10m 10M 14m 15P'],
|
||||
'7#11': ['1P 3M 7m 10M 12d', '3M 7m 8P 10M 12d', '7m 10M 12d 14m 15P'],
|
||||
'7b5': ['1P 3M 7m 10M 12d', '3M 7m 8P 10M 12d', '7m 10M 12d 14m 15P'],
|
||||
'7#5': ['1P 3M 7m 10M 13m', '3M 7m 8P 10M 13m', '3M 7m 8P 13m 14m', '7m 10M 13m 14m 15P'],
|
||||
'9#11': ['1P 7m 9M 10M 12d', '3M 7m 8P 9M 12d', '7m 10M 12d 15P 16M'],
|
||||
'9b5': ['1P 7m 9M 10M 12d', '3M 7m 8P 9M 12d', '7m 10M 12d 15P 16M'],
|
||||
'9#5': ['1P 7m 9M 10M 13m', '3M 7m 9M 10M 13m', '3M 7m 9M 13m 14m', '7m 10M 13m 14m 16M', '7m 10M 13m 16M 17M'],
|
||||
'7b13': ['1P 3M 7m 10M 13m', '3M 7m 8P 10M 13m', '3M 7m 8P 13m 14m', '7m 10M 13m 14m 15P'],
|
||||
'7#9#5': ['1P 3M 7m 10m 13m', '3M 7m 10m 13m 15P', '7m 10M 13m 15P 17m'],
|
||||
'7#9b5': ['1P 3M 7m 10m 12d', '3M 7m 10m 12d 15P', '7m 10M 12d 15P 17m'],
|
||||
'7#9#11': ['1P 3M 7m 10m 12d', '3M 7m 10m 12d 15P', '7m 10M 12d 15P 17m'],
|
||||
'7b9#11': ['1P 7m 9m 10M 12d', '3M 7m 8P 9m 12d', '7m 8P 10M 12d 16m'],
|
||||
'7b9b5': ['1P 7m 9m 10M 12d', '3M 7m 8P 9m 12d', '7m 8P 10M 12d 16m'],
|
||||
'7b9#5': ['1P 7m 9m 10M 13m', '3M 7m 8P 9m 13m', '7m 9m 10M 13m 15P'],
|
||||
'7b9#9': ['1P 3M 7m 9m 10m', '3M 7m 8P 9m 10m', '7m 8P 10M 16m 17m'],
|
||||
'7b9b13': ['1P 7m 9m 10M 13m', '3M 7m 8P 9m 13m', '7m 9m 10M 13m 15P'],
|
||||
'7alt': [
|
||||
'3M 7m 8P 9m 12d',
|
||||
'1P 7m 10m 10M 13m',
|
||||
'3M 7m 8P 10m 13m',
|
||||
'3M 7m 9m 12d 15P',
|
||||
'3M 7m 10m 13m 15P',
|
||||
'7m 10M 12d 15P 17m',
|
||||
'7m 10M 13m 15P 17m',
|
||||
],
|
||||
'13#11': ['1P 6M 7m 10M 12d', '3M 7m 9M 12d 13M', '7m 10M 12d 13M 16M'],
|
||||
'13b9': ['1P 3M 6M 7m 9m', '1P 6M 7m 9m 10M', '3M 7m 9m 10M 13M', '3M 7m 10M 13M 16m', '7m 10M 13M 16m 17M'],
|
||||
'13#9': ['1P 3M 6M 7m 10m', '3M 7m 8P 10m 13M', '7m 10M 13M 14m 17m'],
|
||||
'7b9sus': ['1P 5P 7m 9m 11P', '5P 7m 8P 9m 11P', '7m 8P 11P 14m 16m'],
|
||||
'7susadd3': ['1P 4P 5P 7m 10M', '5P 8P 10M 11P 14m', '7m 11P 12P 15P 17M'],
|
||||
'9sus': ['1P 5P 7m 9M 11P', '5P 7m 8P 9M 11P', '7m 8P 9M 11P 12P', '7m 8P 11P 12P 16M'],
|
||||
'13sus': ['1P 4P 6M 7m 9M', '1P 7m 9M 11P 13M', '5P 7m 9M 11P 13M', '7m 9M 11P 13M 15P'],
|
||||
'7b13sus': ['1P 5P 7m 11P 13m', '5P 7m 8P 11P 13m', '7m 11P 13m 14m 15P'],
|
||||
};
|
||||
|
||||
export const complex = {
|
||||
2: ['1P 5P 6M 8P 9M', '1P 5P 8P 9M 12P', '5P 8P 9M 12P 13M', '5P 8P 9M 12P 15P'],
|
||||
5: ['1P 5P 8P 12P', '1P 5P 8P 9M 12P', '5P 8P 12P 15P', '5P 8P 12P 15P 16M'],
|
||||
6: ['1P 5P 6M 9M 10M', '1P 5P 9M 10M 13M', '3M 5P 9M 10M 13M', '5P 8P 9M 10M 13M', '3M 6M 9M 12P 15P'],
|
||||
7: [
|
||||
'1P 5P 7m 8P 10M',
|
||||
'1P 7m 8P 10M 12P',
|
||||
'3M 7m 8P 10M 12P',
|
||||
'3M 7m 8P 10M 14m',
|
||||
'3M 7m 10M 12P 15P',
|
||||
'7m 10M 12P 14m 15P',
|
||||
'7m 10M 12P 15P 17M',
|
||||
'7m 10M 14m 17M 19P',
|
||||
],
|
||||
9: [
|
||||
'1P 6M 7m 9M 10M',
|
||||
'3M 7m 9M 10M 12P',
|
||||
'1P 7m 9M 10M 13M',
|
||||
'3M 7m 9M 10M 13M',
|
||||
'3M 7m 9M 12P 15P',
|
||||
'7m 10M 12P 13M 16M',
|
||||
'7m 10M 13M 16M 17M',
|
||||
'7m 10M 13M 16M 19P',
|
||||
],
|
||||
11: [
|
||||
'1P 4P 6M 7m 9M',
|
||||
'1P 5P 7m 9M 11P',
|
||||
'4P 6M 7m 9M 11P',
|
||||
'5P 8P 9M 11P 14m',
|
||||
'7m 9M 11P 13M 15P',
|
||||
'7m 11P 12P 14m 18P',
|
||||
],
|
||||
13: [
|
||||
'3M 7m 9M 10M 13M',
|
||||
'3M 7m 9M 13M 15P',
|
||||
'3M 7m 10M 13M 16M',
|
||||
'7m 10M 12P 13M 16M',
|
||||
'7m 10M 13M 16M 17M',
|
||||
'7m 10M 13M 16M 19P',
|
||||
],
|
||||
69: ['1P 5P 6M 9M 10M', '1P 5P 9M 10M 13M', '3M 5P 9M 10M 13M', '5P 8P 9M 10M 13M', '3M 6M 9M 12P 15P'],
|
||||
add9: [
|
||||
'1P 5P 8P 9M 10M',
|
||||
'1P 5P 9M 10M 12P',
|
||||
'3M 8P 9M 10M 12P',
|
||||
'3M 8P 9M 12P 15P',
|
||||
'5P 8P 9M 10M 15P',
|
||||
'5P 8P 9M 12P 17M',
|
||||
],
|
||||
'+': [
|
||||
'1P 6m 8P 9M 10M',
|
||||
'1P 6m 8P 10M 13m',
|
||||
'3M 8P 9M 10M 13m',
|
||||
'3M 8P 10M 13m 15P',
|
||||
'6m 10M 13m 15P 16M',
|
||||
'6m 10M 13m 15P 17M',
|
||||
],
|
||||
o: [
|
||||
'1P 6M 8P 10m 12d',
|
||||
'1P 6M 10m 12d 13M',
|
||||
'3m 8P 10m 12d 13M',
|
||||
'3m 8P 12d 13M 15P',
|
||||
'5d 10m 12d 13M 15P',
|
||||
'5d 10m 13M 15P 17m',
|
||||
'6M 12d 13M 15P 17m',
|
||||
'6M 12d 15P 17m 19d',
|
||||
],
|
||||
h: [
|
||||
'1P 5d 7m 10m 11P',
|
||||
'3m 5d 7m 8P 11P',
|
||||
'5d 7m 8P 10m 11P',
|
||||
'1P 7m 10m 12d',
|
||||
'3m 7m 8P 12d 14m',
|
||||
'5d 8P 10m 11P 14m',
|
||||
'7m 10m 11P 12d 14m',
|
||||
'7m 10m 12d 14m 15P',
|
||||
'5d 8P 10m 14m 17m',
|
||||
],
|
||||
sus: [
|
||||
'1P 4P 5P 8P 9M',
|
||||
'1P 4P 5P 8P 11P',
|
||||
'1P 5P 8P 9M 11P',
|
||||
'5P 8P 9M 11P 12P',
|
||||
'5P 8P 11P 12P 13M',
|
||||
'5P 8P 11P 13M 15P',
|
||||
],
|
||||
'^': [
|
||||
'1P 3M 5P 6M 9M',
|
||||
'1P 5P 8P 10M 12P',
|
||||
'3M 5P 9M 10M 12P',
|
||||
'1P 5P 8P 10M 13M',
|
||||
'3M 8P 10M 13M 15P',
|
||||
'5P 9M 10M 12P 15P',
|
||||
],
|
||||
'-': [
|
||||
'1P 3m 5P 8P 10m',
|
||||
'1P 3m 5P 9M 11P',
|
||||
'3m 5P 8P 9M 11P',
|
||||
'5P 8P 9M 10m 11P',
|
||||
'1P 5P 9M 10m 12P',
|
||||
'3m 5P 8P 10m 12P',
|
||||
'5P 8P 10m 12P 15P',
|
||||
],
|
||||
'^7': [
|
||||
'1P 6M 7M 9M 10M',
|
||||
'3M 7M 9M 10M 12P',
|
||||
'1P 7M 9M 10M 13M',
|
||||
'3M 7M 9M 10M 13M',
|
||||
'3M 7M 9M 12P 13M',
|
||||
'3M 7M 9M 13M 14M',
|
||||
'3M 7M 10M 13M 16M',
|
||||
'7M 10M 13M 14M 16M',
|
||||
'7M 10M 13M 16M 17M',
|
||||
'7M 10M 13M 16M 19P',
|
||||
],
|
||||
'-7': [
|
||||
'1P 3m 5P 7m 9M',
|
||||
'1P 3m 5P 7m 10m',
|
||||
'1P 5P 7m 10m 11P',
|
||||
'3m 7m 8P 10m 11P',
|
||||
'1P 5P 7m 10m 12P',
|
||||
'3m 7m 9M 10m 12P',
|
||||
'3m 7m 8P 10m 14m',
|
||||
'5P 7m 9M 10m 14m',
|
||||
'7m 10m 11P 14m 15P',
|
||||
'7m 10m 12P 15P 16M',
|
||||
'5P 8P 11P 14m 17m',
|
||||
'7m 10m 12P 15P 17m',
|
||||
],
|
||||
'7sus': [
|
||||
'1P 4P 6M 7m 9M',
|
||||
'1P 5P 7m 9M 11P',
|
||||
'4P 6M 7m 9M 11P',
|
||||
'5P 8P 9M 11P 14m',
|
||||
'7m 9M 11P 13M 15P',
|
||||
'7m 11P 12P 14m 18P',
|
||||
],
|
||||
h7: [
|
||||
'1P 5d 7m 10m 11P',
|
||||
'3m 5d 7m 8P 11P',
|
||||
'5d 7m 8P 10m 11P',
|
||||
'1P 7m 10m 12d',
|
||||
'3m 7m 8P 10m 12d',
|
||||
'3m 7m 8P 12d 14m',
|
||||
'5d 8P 10m 11P 14m',
|
||||
'7m 10m 11P 12d 14m',
|
||||
'7m 10m 12d 14m 15P',
|
||||
'5d 8P 10m 14m 17m',
|
||||
],
|
||||
o7: [
|
||||
'1P 6M 8P 10m 12d',
|
||||
'1P 6M 10m 12d 13M',
|
||||
'3m 8P 10m 12d 13M',
|
||||
'3m 8P 12d 13M 15P',
|
||||
'5d 10m 12d 13M 15P',
|
||||
'5d 10m 13M 15P 17m',
|
||||
'6M 12d 13M 15P 17m',
|
||||
'6M 12d 15P 17m 19d',
|
||||
],
|
||||
'^9': [
|
||||
'1P 6M 7M 9M 10M',
|
||||
'1P 7M 9M 10M 13M',
|
||||
'3M 7M 9M 10M 13M',
|
||||
'3M 7M 9M 12P 13M',
|
||||
'3M 7M 8P 9M 13M',
|
||||
'3M 7M 9M 13M 14M',
|
||||
'3M 7M 10M 13M 16M',
|
||||
'7M 10M 13M 14M 16M',
|
||||
'7M 10M 13M 16M 17M',
|
||||
'7M 10M 13M 16M 19P',
|
||||
],
|
||||
'^13': [
|
||||
'1P 6M 7M 9M 10M',
|
||||
'1P 7M 9M 10M 13M',
|
||||
'3M 7M 9M 12P 13M',
|
||||
'3M 7M 9M 10M 13M',
|
||||
'3M 7M 8P 9M 13M',
|
||||
'3M 7M 9M 13M 14M',
|
||||
'3M 7M 10M 13M 16M',
|
||||
'7M 10M 13M 14M 16M',
|
||||
'7M 10M 13M 16M 17M',
|
||||
'7M 10M 13M 16M 19P',
|
||||
],
|
||||
'^7#11': [
|
||||
'1P 3M 5d 7M 9M',
|
||||
'1P 7M 9M 10M 12d',
|
||||
'3M 7M 9M 10M 12d',
|
||||
'3M 7M 9M 12d 13M',
|
||||
'3M 7M 10M 12d 14M',
|
||||
'7M 10M 12d 13M 14M',
|
||||
'7M 10M 12d 13M 16M',
|
||||
'7M 10M 12d 14M 17M',
|
||||
],
|
||||
'^9#11': [
|
||||
'1P 3M 5d 7M 9M',
|
||||
'1P 7M 9M 10M 12d',
|
||||
'3M 7M 9M 10M 12d',
|
||||
'3M 7M 9M 12d 13M',
|
||||
'3M 7M 9M 12d 14M',
|
||||
'7M 10M 12d 14M 16M',
|
||||
'7M 10M 12d 13M 16M',
|
||||
],
|
||||
'^7#5': ['1P 6m 7M 10M 13m', '3M 7M 9M 10M 13m', '3M 7M 10M 13m 14M', '7M 10M 13m 14M 16M', '7M 10M 13m 14M 17M'],
|
||||
'-6': [
|
||||
'1P 3m 5P 6M 9M',
|
||||
'3m 5P 6M 8P 9M',
|
||||
'1P 5P 6M 10m 11P',
|
||||
'3m 5P 6M 8P 11P',
|
||||
'1P 5P 9M 10m 13M',
|
||||
'3m 5P 8P 9M 13M',
|
||||
'5P 8P 10m 11P 13M',
|
||||
'5P 8P 10m 13M 16M',
|
||||
],
|
||||
'-69': [
|
||||
'1P 3m 5P 6M 9M',
|
||||
'3m 5P 6M 8P 9M',
|
||||
'3m 6M 9M 10m 12P',
|
||||
'1P 5P 9M 10m 13M',
|
||||
'3m 5P 8P 9M 13M',
|
||||
'5P 8P 9M 10m 13M',
|
||||
'5P 8P 10m 13M 16M',
|
||||
],
|
||||
'-^7': [
|
||||
'1P 3m 5P 7M 9M',
|
||||
'1P 5P 7M 10m 11P',
|
||||
'3m 7M 9M 10m 11P',
|
||||
'3m 7M 9M 10m 12P',
|
||||
'3m 7M 9M 12P 14M',
|
||||
'7M 10m 11P 12P 14M',
|
||||
'7M 10m 12P 14M 16M',
|
||||
],
|
||||
'-^9': [
|
||||
'1P 3m 5P 7M 9M',
|
||||
'1P 5P 7M 10m 11P',
|
||||
'3m 7M 9M 10m 11P',
|
||||
'3m 7M 9M 10m 12P',
|
||||
'3m 7M 9M 12P 14M',
|
||||
'7M 10m 11P 12P 14M',
|
||||
'7M 10m 12P 14M 16M',
|
||||
],
|
||||
'-9': [
|
||||
'1P 3m 5P 7m 9M',
|
||||
'1P 3m 7m 9M 11P',
|
||||
'3m 7m 9M 10m 11P',
|
||||
'3m 7m 9M 10m 12P',
|
||||
'3m 7m 9M 10m 14m',
|
||||
'3m 7m 9M 12P 15P',
|
||||
'7m 10m 11P 14m 16M',
|
||||
'7m 10m 12P 16M 18P',
|
||||
],
|
||||
'-add9': ['1P 2M 3m 5P 8P', '1P 3m 5P 9M', '3m 5P 8P 9M 12P', '5P 8P 9M 10m 12P'],
|
||||
'-11': [
|
||||
'3m 5P 7m 9M 11P',
|
||||
'7m 9M 10m 11P',
|
||||
'1P 4P 7m 10m 12P',
|
||||
'3m 7m 9M 11P 12P',
|
||||
'7m 9M 10m 11P 12P',
|
||||
'3m 7m 9M 11P 14m',
|
||||
'4P 10m 12P 14m',
|
||||
'5P 8P 11P 14m',
|
||||
'5P 8P 11P 14m 16M',
|
||||
'7m 10m 12P 16M 18P',
|
||||
'7m 10m 11P 16M 21m',
|
||||
],
|
||||
'-7b5': [
|
||||
'1P 5d 7m 10m 11P',
|
||||
'3m 5d 7m 8P 11P',
|
||||
'5d 7m 8P 10m 11P',
|
||||
'1P 7m 10m 12d',
|
||||
'3m 7m 8P 10m 12d',
|
||||
'3m 7m 8P 12d 14m',
|
||||
'5d 8P 10m 11P 14m',
|
||||
'7m 10m 11P 12d 14m',
|
||||
'7m 10m 12d 14m 15P',
|
||||
'5d 8P 10m 14m 17m',
|
||||
],
|
||||
h9: [
|
||||
'3m 5d 7m 9M 11P',
|
||||
'1P 7m 9M 10m 12d',
|
||||
'3m 7m 9M 12d 14m',
|
||||
'5d 8P 9M 10m 14m',
|
||||
'7m 10m 11P 12d 14m',
|
||||
'7m 10m 12d 14m 16M',
|
||||
],
|
||||
'-b6': ['1P 3m 5P 6m 8P', '3m 5P 8P 11P 13m', '5P 8P 10m 11P 13m'],
|
||||
'-#5': ['1P 6m 8P 10m 13m', '3m 6m 8P 11P 13m', '6m 8P 10m 13m 15P'],
|
||||
'7b9': ['1P 3M 7m 9m 10M', '3M 7m 8P 9m 10M', '3M 7m 8P 9m 14m', '7m 9m 10M 14m 15P'],
|
||||
'7#9': ['1P 3M 7m 10m', '3M 7m 10m 10M 12P', '3M 7m 10m 12P 14m', '7m 10M 12P 14m 17m'],
|
||||
'7#11': ['1P 3M 7m 9M 12d', '3M 7m 9M 12d 13M', '7m 10M 12d 13M 16M'],
|
||||
'7b5': ['1P 3M 7m 9M 12d', '3M 7m 9M 12d 13M', '7m 10M 12d 13M 16M'],
|
||||
'7#5': ['1P 3M 7m 10M 13m', '3M 7m 8P 10M 13m', '3M 7m 8P 13m 14m', '7m 10M 13m 14m 15P', '7m 10M 13m 14m 17M'],
|
||||
'9#11': ['1P 7m 9M 10M 12d', '3M 7m 8P 9M 12d', '7m 10M 12d 15P 16M'],
|
||||
'9b5': ['1P 7m 9M 10M 12d', '3M 7m 8P 9M 12d', '7m 10M 12d 15P 16M'],
|
||||
'9#5': ['1P 7m 9M 10M 13m', '3M 7m 9M 10M 13m', '3M 7m 9M 13m 14m', '7m 10M 13m 14m 16M', '7m 10M 13m 16M 17M'],
|
||||
'7b13': ['1P 3M 7m 10M 13m', '3M 7m 8P 10M 13m', '3M 7m 8P 13m 14m', '7m 10M 13m 14m 15P', '7m 10M 13m 14m 17M'],
|
||||
'7#9#5': ['3M 7m 10m 10M 13m', '3M 7m 10m 13m 14m', '7m 10M 13m 14m 17m'],
|
||||
'7#9b5': ['3M 7m 10m 10M 12d', '3M 7m 10m 12d 14m', '7m 10M 12d 14m 17m'],
|
||||
'7#9#11': ['3M 7m 10m 10M 12d', '3M 7m 10m 12d 14m', '7m 10M 12d 14m 17m'],
|
||||
'7b9#11': ['3M 7m 9m 10M 12d', '3M 7m 9m 12d 14m', '7m 8P 10M 12d 16m', '7m 10M 12d 14m 16m'],
|
||||
'7b9b5': ['3M 7m 9m 10M 12d', '3M 7m 9m 12d 14m', '7m 8P 10M 12d 16m', '7m 10M 12d 14m 16m'],
|
||||
'7b9#5': ['1P 7m 9m 10M 13m', '3M 7m 9m 10M 13m', '3M 7m 10M 13m 16m', '7m 10M 13m 14m 16m', '7m 10M 13m 16m 17M'],
|
||||
'7b9#9': ['1P 3M 7m 9m 10m', '3M 7m 10m 13m 16m', '7m 10M 13m 16m 17m'],
|
||||
'7b9b13': ['1P 7m 9m 10M 13m', '3M 7m 9m 10M 13m', '3M 7m 10M 13m 16m', '7m 10M 13m 14m 16m', '7m 10M 13m 16m 17M'],
|
||||
'7alt': [
|
||||
'3M 7m 8P 10m 13m',
|
||||
'3M 7m 9m 12d 13m',
|
||||
'3M 7m 9m 10m 13m',
|
||||
'3M 7m 10m 13m 14m',
|
||||
'3M 7m 9m 12d 14m',
|
||||
'3M 7m 10m 13m 15P',
|
||||
'3M 7m 10m 13m 16m',
|
||||
'7m 10M 12d 14m 16m',
|
||||
'7m 10M 12d 13m 16m',
|
||||
'7m 10M 13m 15P 17m',
|
||||
'7m 10M 13m 16m 17m',
|
||||
'7m 10M 13m 16m 19d',
|
||||
],
|
||||
'13#11': ['3M 7m 9M 12d 13M', '7m 10M 12d 13M 16M'],
|
||||
'13b9': ['3M 7m 9m 10M 13M', '3M 7m 10M 13M 16m', '7m 10M 13M 16m 17M'],
|
||||
'13#9': ['3M 7m 10m 10M 13M', '7m 10M 13M 14m 17m'],
|
||||
'7b9sus': ['1P 5P 7m 9m 11P', '5P 7m 8P 9m 11P', '7m 8P 11P 14m 16m'],
|
||||
'7susadd3': ['1P 4P 5P 7m 10M', '5P 8P 10M 11P 14m', '7m 11P 12P 15P 17M'],
|
||||
'9sus': [
|
||||
'1P 4P 6M 7m 9M',
|
||||
'1P 5P 7m 9M 11P',
|
||||
'4P 6M 7m 9M 11P',
|
||||
'5P 8P 9M 11P 14m',
|
||||
'7m 9M 11P 13M 15P',
|
||||
'7m 11P 12P 14m 18P',
|
||||
],
|
||||
'13sus': [
|
||||
'1P 4P 6M 7m 9M',
|
||||
'1P 7m 9M 11P 13M',
|
||||
'4P 7m 9M 11P 13M',
|
||||
'7m 9M 11P 13M 15P',
|
||||
'7m 11P 13M 14m 16M',
|
||||
'7m 11P 13M 16M 18P',
|
||||
],
|
||||
'7b13sus': ['1P 5P 7m 11P 13m', '5P 7m 8P 11P 13m', '7m 11P 13m 14m 15P'],
|
||||
};
|
||||
306
src/strudel/tonal/tonal.mjs
Normal file
306
src/strudel/tonal/tonal.mjs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
/*
|
||||
tonal.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/tonal/tonal.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Note, Interval, Scale } from '@tonaljs/tonal';
|
||||
import { register, _mod, silence, logger, pure, isNote } from '@strudel/core';
|
||||
import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
|
||||
import { noteToMidi } from '../core/util.mjs';
|
||||
|
||||
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
|
||||
|
||||
function getScale(scaleName) {
|
||||
scaleName = scaleName.replaceAll(':', ' ');
|
||||
const scale = Scale.get(scaleName);
|
||||
const { tonic, empty } = scale;
|
||||
if ((empty && isNote(scaleName)) || (empty && !tonic)) {
|
||||
throw new Error(
|
||||
`Scale name ${scaleName} is incomplete. Make sure to use ":" instead of spaces, example: .scale("C:major")`,
|
||||
);
|
||||
} else if (empty) {
|
||||
throw new Error(`Invalid scale name "${scaleName}"`);
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
function scaleStep(step, scale) {
|
||||
step = Math.ceil(step);
|
||||
let { intervals, tonic } = getScale(scale);
|
||||
tonic = tonic || 'C';
|
||||
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) {
|
||||
let { notes } = getScale(scale);
|
||||
notes = notes.map((note) => Note.get(note).pc); // use only pc!
|
||||
offset = Number(offset);
|
||||
if (isNaN(offset)) {
|
||||
throw new Error(`scale offset "${offset}" not a number`);
|
||||
}
|
||||
const { pc: fromPc, oct = 3 } = Note.get(note);
|
||||
const noteIndex = notes.indexOf(fromPc);
|
||||
if (noteIndex === -1) {
|
||||
throw new Error(`note "${note}" is not in scale "${scale}"`);
|
||||
}
|
||||
let i = noteIndex,
|
||||
o = oct,
|
||||
n = fromPc;
|
||||
const direction = Math.sign(offset);
|
||||
// TODO: find way to do this smarter
|
||||
while (Math.abs(i - noteIndex) < Math.abs(offset)) {
|
||||
i += direction;
|
||||
const index = _mod(i, notes.length);
|
||||
if (direction < 0 && n[0] === 'C') {
|
||||
o += direction;
|
||||
}
|
||||
n = notes[index];
|
||||
if (direction > 0 && n[0] === 'C') {
|
||||
o += direction;
|
||||
}
|
||||
}
|
||||
return n + o;
|
||||
}
|
||||
|
||||
// Pattern.prototype._transpose = function (intervalOrSemitones: string | number) {
|
||||
/**
|
||||
* Change the pitch of each value by the given amount. Expects numbers or note strings as values.
|
||||
* The amount can be given as a number of semitones or as a string in interval short notation.
|
||||
* If you don't care about enharmonic correctness, just use numbers. Otherwise, pass the interval of
|
||||
* the form: ST where S is the degree number and T the type of interval with
|
||||
*
|
||||
* - M = major
|
||||
* - m = minor
|
||||
* - P = perfect
|
||||
* - A = augmented
|
||||
* - d = diminished
|
||||
*
|
||||
* Examples intervals:
|
||||
*
|
||||
* - 1P = unison
|
||||
* - 3M = major third
|
||||
* - 3m = minor third
|
||||
* - 4P = perfect fourth
|
||||
* - 4A = augmented fourth
|
||||
* - 5P = perfect fifth
|
||||
* - 5d = diminished fifth
|
||||
*
|
||||
* @param {string | number} amount Either number of semitones or interval string.
|
||||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
* @name transpose
|
||||
* @synonyms trans
|
||||
* @example
|
||||
* "c2 c3".fast(2).transpose("<0 -2 5 3>".slow(2)).note()
|
||||
* @example
|
||||
* "c2 c3".fast(2).transpose("<1P -2M 4P 3m>".slow(2)).note()
|
||||
*/
|
||||
|
||||
export const { transpose, trans } = register(['transpose', 'trans'], function transposeFn(intervalOrSemitones, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
const note = hap.value.note ?? hap.value;
|
||||
if (typeof note === 'number') {
|
||||
// note is a number, so just add the number semitones of the interval
|
||||
let semitones;
|
||||
if (typeof intervalOrSemitones === 'number') {
|
||||
semitones = intervalOrSemitones;
|
||||
} else if (typeof intervalOrSemitones === 'string') {
|
||||
semitones = Interval.semitones(intervalOrSemitones) || 0;
|
||||
}
|
||||
const targetNote = note + semitones;
|
||||
if (typeof hap.value === 'object') {
|
||||
return hap.withValue(() => ({ ...hap.value, note: targetNote }));
|
||||
}
|
||||
return hap.withValue(() => targetNote);
|
||||
}
|
||||
if (typeof note !== 'string' || !isNote(note)) {
|
||||
logger(`[tonal] transpose: not a note "${note}"`, 'warning');
|
||||
return hap;
|
||||
}
|
||||
// note is a string, so we might be able to preserve harmonics if interval is a string as well
|
||||
const interval = !isNaN(Number(intervalOrSemitones))
|
||||
? Interval.fromSemitones(intervalOrSemitones)
|
||||
: String(intervalOrSemitones);
|
||||
const targetNote = Note.transpose(note, interval);
|
||||
if (typeof hap.value === 'object') {
|
||||
return hap.withValue(() => ({ ...hap.value, note: targetNote }));
|
||||
}
|
||||
return hap.withValue(() => targetNote);
|
||||
});
|
||||
});
|
||||
|
||||
// example: transpose(3).late(0.2) will be equivalent to compose(transpose(3), late(0.2))
|
||||
// e.g. `stack(c3).superimpose(transpose(slowcat(7, 5)))` or
|
||||
// or even `stack(c3).superimpose(transpose.slowcat(7, 5))` or
|
||||
|
||||
/**
|
||||
* Transposes notes inside the scale by the number of steps.
|
||||
* Expected to be called on a Pattern which already has a {@link Pattern#scale}
|
||||
*
|
||||
* @memberof Pattern
|
||||
* @name scaleTranspose
|
||||
* @param {offset} offset number of steps inside the scale
|
||||
* @returns Pattern
|
||||
* @synonyms scaleTrans, strans
|
||||
* @example
|
||||
* "-8 [2,4,6]"
|
||||
* .scale('C4 bebop major')
|
||||
* .scaleTranspose("<0 -1 -2 -3 -4 -5 -6 -4>")
|
||||
* .note()
|
||||
*/
|
||||
|
||||
export const { scaleTranspose, scaleTrans, strans } = register(
|
||||
['scaleTranspose', 'scaleTrans', 'strans'],
|
||||
function (offset /* : number | string */, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
if (!hap.context.scale) {
|
||||
throw new Error('can only use scaleTranspose after .scale');
|
||||
}
|
||||
if (typeof hap.value === 'object')
|
||||
return hap.withValue(() => ({
|
||||
...hap.value,
|
||||
note: scaleOffset(hap.context.scale, Number(offset), hap.value.note),
|
||||
}));
|
||||
if (typeof hap.value !== 'string') {
|
||||
throw new Error('can only use scaleTranspose with notes');
|
||||
}
|
||||
return hap.withValue(() => scaleOffset(hap.context.scale, Number(offset), hap.value));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Converts a step value, which is a number optionally decorated with sharps and flats,
|
||||
// to a number and an `offset` number of semitones
|
||||
function _convertStepToNumberAndOffset(step) {
|
||||
let asNumber = Number(step);
|
||||
let offset = 0;
|
||||
if (isNaN(asNumber)) {
|
||||
step = String(step);
|
||||
// Check to see if the step matches the expected format:
|
||||
// - A number (possibly negative)
|
||||
// - Some number of sharps or flats (but not both)
|
||||
const match = /^(-?\d+)(#+|b+)?$/.exec(step);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`);
|
||||
}
|
||||
asNumber = Number(match[1]);
|
||||
// These decorations will determine the semitone offset based on the number of
|
||||
// sharps or flats
|
||||
const decorations = match[2] || '';
|
||||
offset = decorations[0] === '#' ? decorations.length : -decorations.length;
|
||||
}
|
||||
return [asNumber, offset];
|
||||
}
|
||||
|
||||
let scaleToMidisAndNotes = {};
|
||||
// Finds the nearest scale note to `note`
|
||||
function _getNearestScaleNote(scaleName, note, preferHigher = true) {
|
||||
let noteMidi = typeof note === 'string' ? noteToMidi(note) : note;
|
||||
if (scaleToMidisAndNotes[scaleName] === undefined) {
|
||||
const { intervals, tonic } = getScale(scaleName);
|
||||
const { pc } = Note.get(tonic);
|
||||
const expandedIntervals = intervals.concat('8P'); // add the octave for wrapping
|
||||
const sNotes = expandedIntervals.map((interval) => Note.transpose(pc + '0', interval));
|
||||
const sMidi = sNotes.map(noteToMidi);
|
||||
// Cache
|
||||
scaleToMidisAndNotes[scaleName] = [sMidi, sNotes];
|
||||
}
|
||||
const [scaleMidis, scaleNotes] = scaleToMidisAndNotes[scaleName];
|
||||
const rootMidi = scaleMidis[0];
|
||||
const octaveDiff = Math.floor((noteMidi - rootMidi) / 12);
|
||||
const alignedMidis = scaleMidis.map((m) => m + 12 * octaveDiff);
|
||||
const noteIdx = nearestNumberIndex(noteMidi, alignedMidis, preferHigher);
|
||||
const noteMatch = scaleNotes[noteIdx];
|
||||
return Note.transpose(noteMatch, Interval.fromSemitones(12 * octaveDiff));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale.
|
||||
*
|
||||
* When describing notes via numbers, note that negative numbers can be used to wrap backwards
|
||||
* in the scale as well as sharps or flats (but not both) to produce notes outside of the scale.
|
||||
*
|
||||
* Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
|
||||
*
|
||||
* A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts).
|
||||
*
|
||||
* The root note defaults to octave 3, if no octave number is given.
|
||||
*
|
||||
* @name scale
|
||||
* @param {string} scale Name of scale
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* n("0 2 4 6 4 2").scale("C:major")
|
||||
* @example
|
||||
* n("[0,7] 4 [2,7] 4")
|
||||
* .scale("C:<major minor>/2")
|
||||
* .s("piano")
|
||||
* @example
|
||||
* n(rand.range(0,12).segment(8))
|
||||
* .scale("C:ritusen")
|
||||
* .s("piano")
|
||||
* @example
|
||||
* n("<[0,7b] [-4# -4] [-2,7##] 4 [0,7] [-4# -4b] [-2,7###] 4b>*4")
|
||||
* .scale("C:<major minor>/2")
|
||||
* .s("piano")
|
||||
* @example
|
||||
* note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3)
|
||||
*/
|
||||
|
||||
export const scale = register(
|
||||
'scale',
|
||||
function (scale, pat) {
|
||||
// Supports ':' list syntax in mininotation
|
||||
if (Array.isArray(scale)) {
|
||||
scale = scale.flat().join(' ');
|
||||
}
|
||||
return (
|
||||
pat
|
||||
.fmap((value) => {
|
||||
const isObject = typeof value === 'object';
|
||||
// The case where the note has been defined via `n` or `pure`
|
||||
if (!isObject || (isObject && ('n' in value || 'value' in value))) {
|
||||
const step = isObject ? (value.n ?? value.value) : value;
|
||||
delete value.n; // remove n so it won't cause trouble
|
||||
if (isNote(step)) {
|
||||
// legacy..
|
||||
return pure(step);
|
||||
}
|
||||
try {
|
||||
const [number, offset] = _convertStepToNumberAndOffset(step);
|
||||
let note;
|
||||
if (isObject && value.anchor) {
|
||||
note = stepInNamedScale(number, scale, value.anchor);
|
||||
} else {
|
||||
note = scaleStep(number, scale);
|
||||
}
|
||||
if (offset != 0) note = Note.transpose(note, Interval.fromSemitones(offset));
|
||||
value = pure(isObject ? { ...value, note } : note);
|
||||
} catch (err) {
|
||||
logger(`[tonal] ${err.message}`, 'error');
|
||||
return silence;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// The case where the note has been defined via `note`
|
||||
else {
|
||||
const note = _getNearestScaleNote(scale, value.note);
|
||||
return pure(isObject ? { ...value, note } : note);
|
||||
}
|
||||
})
|
||||
.outerJoin()
|
||||
// legacy:
|
||||
.withHap((hap) => hap.setContext({ ...hap.context, scale }))
|
||||
);
|
||||
},
|
||||
true,
|
||||
true, // preserve step count
|
||||
);
|
||||
238
src/strudel/tonal/tonleiter.mjs
Normal file
238
src/strudel/tonal/tonleiter.mjs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { isNote, isNoteWithOctave, _mod, noteToMidi, tokenizeNote } from '@strudel/core';
|
||||
import { Interval, Scale } from '@tonaljs/tonal';
|
||||
|
||||
// https://codesandbox.io/s/stateless-voicings-g2tmz0?file=/src/lib.js:0-2515
|
||||
|
||||
const flats = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
const pcs = ['c', 'db', 'd', 'eb', 'e', 'f', 'gb', 'g', 'ab', 'a', 'bb', 'b'];
|
||||
const sharps = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const accs = { b: -1, '#': 1 };
|
||||
|
||||
export const pc2chroma = (pc) => {
|
||||
const [letter, ...rest] = pc.split('');
|
||||
return pcs.indexOf(letter.toLowerCase()) + rest.reduce((sum, sign) => sum + accs[sign], 0);
|
||||
};
|
||||
|
||||
export const rotateChroma = (chroma, steps) => (chroma + (steps % 12) + 12) % 12;
|
||||
|
||||
export const chroma2pc = (chroma, sharp = false) => {
|
||||
return (sharp ? sharps : flats)[chroma];
|
||||
};
|
||||
|
||||
export function tokenizeChord(chord) {
|
||||
const match = (chord || '').match(/^([A-G][b#]*)([^/]*)[/]?([A-G][b#]*)?$/);
|
||||
if (!match) {
|
||||
// console.warn('could not tokenize chord', chord);
|
||||
return [];
|
||||
}
|
||||
return match.slice(1);
|
||||
}
|
||||
export const note2pc = (note) => note.match(/^[A-G][#b]?/i)[0];
|
||||
export const note2oct = (note) => tokenizeNote(note)[2];
|
||||
export const note2midi = noteToMidi;
|
||||
|
||||
export const note2chroma = (note) => {
|
||||
return pc2chroma(note2pc(note));
|
||||
};
|
||||
|
||||
// TODO: test
|
||||
export const midi2chroma = (midi) => midi % 12;
|
||||
|
||||
// TODO: test and use in voicing function
|
||||
export const pitch2chroma = (x, defaultOctave) => {
|
||||
if (isNoteWithOctave(x)) {
|
||||
return note2chroma(x);
|
||||
}
|
||||
if (isNote(x)) {
|
||||
//pc
|
||||
return pc2chroma(x, defaultOctave);
|
||||
}
|
||||
if (typeof x === 'number') {
|
||||
// expect midi
|
||||
return midi2chroma(x);
|
||||
}
|
||||
};
|
||||
|
||||
export const step2semitones = (x) => {
|
||||
let num = Number(x);
|
||||
if (!isNaN(num)) {
|
||||
return num;
|
||||
}
|
||||
return Interval.semitones(x);
|
||||
};
|
||||
|
||||
export const x2midi = (x, defaultOctave) => {
|
||||
if (typeof x === 'number') {
|
||||
return x;
|
||||
}
|
||||
if (typeof x === 'string') {
|
||||
return noteToMidi(x, defaultOctave);
|
||||
}
|
||||
};
|
||||
|
||||
// duplicate: util.mjs (does not support sharp flag)
|
||||
export const midi2note = (midi, sharp = false) => {
|
||||
const oct = Math.floor(midi / 12) - 1;
|
||||
const pc = (sharp ? sharps : flats)[midi % 12];
|
||||
return pc + oct;
|
||||
};
|
||||
|
||||
export function scaleStep(notes, offset, octaves = 1) {
|
||||
notes = notes.map((note) => (typeof note === 'string' ? noteToMidi(note) : note));
|
||||
const octOffset = Math.floor(offset / notes.length) * octaves * 12;
|
||||
offset = _mod(offset, notes.length);
|
||||
return notes[offset] + octOffset;
|
||||
}
|
||||
|
||||
export function nearestNumberIndex(target, numbers, preferHigher) {
|
||||
let bestIndex = 0,
|
||||
bestDiff = Infinity;
|
||||
numbers.forEach((s, i) => {
|
||||
const diff = Math.abs(s - target);
|
||||
// preferHigher only works if numbers are sorted in ascending order!
|
||||
if ((!preferHigher && diff < bestDiff) || (preferHigher && diff <= bestDiff)) {
|
||||
bestIndex = i;
|
||||
bestDiff = diff;
|
||||
}
|
||||
});
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
let scaleSteps = {}; // [scaleName]: semitones[]
|
||||
|
||||
export function stepInNamedScale(step, scale, anchor, preferHigher) {
|
||||
const [root, scaleName] = Scale.tokenize(scale);
|
||||
const rootMidi = x2midi(root);
|
||||
const rootChroma = midi2chroma(rootMidi);
|
||||
if (!scaleSteps[scaleName]) {
|
||||
const { intervals } = Scale.get(`C ${scaleName}`);
|
||||
// cache result
|
||||
scaleSteps[scaleName] = intervals.map(step2semitones);
|
||||
}
|
||||
const steps = scaleSteps[scaleName];
|
||||
if (!steps) {
|
||||
return null;
|
||||
}
|
||||
let transpose = rootMidi;
|
||||
if (anchor) {
|
||||
anchor = x2midi(anchor, 3);
|
||||
const anchorChroma = midi2chroma(anchor);
|
||||
const anchorDiff = _mod(anchorChroma - rootChroma, 12);
|
||||
const zeroIndex = nearestNumberIndex(anchorDiff, steps, preferHigher);
|
||||
step = step + zeroIndex;
|
||||
transpose = anchor - anchorDiff;
|
||||
}
|
||||
const octOffset = Math.floor(step / steps.length) * 12;
|
||||
step = _mod(step, steps.length);
|
||||
const targetMidi = steps[step] + transpose;
|
||||
return targetMidi + octOffset;
|
||||
}
|
||||
|
||||
// different ways to resolve the note to compare the anchor to (see renderVoicing)
|
||||
let modeTarget = {
|
||||
below: (v) => v.slice(-1)[0],
|
||||
duck: (v) => v.slice(-1)[0],
|
||||
above: (v) => v[0],
|
||||
root: (v) => v[0],
|
||||
};
|
||||
|
||||
export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below', anchor = 'c5', octaves = 1 }) {
|
||||
const [root, symbol] = tokenizeChord(chord);
|
||||
const rootChroma = pc2chroma(root);
|
||||
anchor = x2midi(anchor?.note || anchor, 4);
|
||||
const anchorChroma = midi2chroma(anchor);
|
||||
const voicings = dictionary[symbol].map((voicing) =>
|
||||
(typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones),
|
||||
);
|
||||
|
||||
let minDistance, bestIndex;
|
||||
// calculate distances up from voicing top notes
|
||||
let chromaDiffs = voicings.map((v, i) => {
|
||||
const targetStep = modeTarget[mode](v);
|
||||
const diff = _mod(anchorChroma - targetStep - rootChroma, 12);
|
||||
if (minDistance === undefined || diff < minDistance) {
|
||||
minDistance = diff;
|
||||
bestIndex = i;
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
if (mode === 'root') {
|
||||
bestIndex = 0;
|
||||
}
|
||||
|
||||
const octDiff = Math.ceil(offset / voicings.length) * 12;
|
||||
const indexWithOffset = _mod(bestIndex + offset, voicings.length);
|
||||
const voicing = voicings[indexWithOffset];
|
||||
const targetStep = modeTarget[mode](voicing);
|
||||
const anchorMidi = anchor - chromaDiffs[indexWithOffset] + octDiff;
|
||||
|
||||
const voicingMidi = voicing.map((v) => anchorMidi - targetStep + v);
|
||||
let notes = voicingMidi.map((n) => midi2note(n));
|
||||
|
||||
if (mode === 'duck') {
|
||||
notes = notes.filter((_, i) => voicingMidi[i] !== anchor);
|
||||
}
|
||||
if (n !== undefined) {
|
||||
return [scaleStep(notes, n, octaves)];
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
// https://codeberg.org/uzu/strudel/blob/14184993d0ee7d69c47df57ac864a1a0f99a893f/packages/tonal/tonleiter.mjs
|
||||
const steps = [1, 0, 2, 0, 3, 4, 0, 5, 0, 6, 0, 7];
|
||||
const notes = ['C', '', 'D', '', 'E', 'F', '', 'G', '', 'A', '', 'B'];
|
||||
const noteLetters = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
|
||||
|
||||
export const accidentalOffset = (accidentals) => {
|
||||
return accidentals.split('#').length - accidentals.split('b').length;
|
||||
};
|
||||
|
||||
const accidentalString = (offset) => {
|
||||
if (offset < 0) {
|
||||
return 'b'.repeat(-offset);
|
||||
}
|
||||
if (offset > 0) {
|
||||
return '#'.repeat(offset);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const Step = {
|
||||
tokenize(step) {
|
||||
const matches = step.match(/^([#b]*)([1-9][0-9]*)$/);
|
||||
if (!matches) {
|
||||
throw new Error(`Step.tokenize: not a valid step: ${step}`);
|
||||
}
|
||||
const [accidentals, stepNumber] = matches.slice(1);
|
||||
return [accidentals, parseInt(stepNumber)];
|
||||
},
|
||||
accidentals(step) {
|
||||
return accidentalOffset(Step.tokenize(step)[0]);
|
||||
},
|
||||
};
|
||||
|
||||
export const Note = {
|
||||
// TODO: support octave numbers
|
||||
tokenize(note) {
|
||||
return [note[0], note.slice(1)];
|
||||
},
|
||||
accidentals(note) {
|
||||
return accidentalOffset(this.tokenize(note)[1]);
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: support octave numbers
|
||||
// Example: Note("Bb3").transpose("c3")
|
||||
export function transpose(note, step) {
|
||||
// example: E, 3
|
||||
const stepNumber = Step.tokenize(step)[1]; // 3
|
||||
const noteLetter = Note.tokenize(note)[0]; // E
|
||||
const noteIndex = noteLetters.indexOf(noteLetter); // 2 "E is C+2"
|
||||
const targetNote = noteLetters[(noteIndex + stepNumber - 1) % 8]; // G "G is a third above E"
|
||||
const rootIndex = notes.indexOf(noteLetter); // 4 "E is 4 semitones above C"
|
||||
const targetIndex = notes.indexOf(targetNote); // 7 "G is 7 semitones above C"
|
||||
const indexOffset = targetIndex - rootIndex; // 3 (E to G is normally a 3 semitones)
|
||||
const stepIndex = steps.indexOf(stepNumber); // 4 ("3" is normally 4 semitones)
|
||||
const offsetAccidentals = accidentalString(Step.accidentals(step) + Note.accidentals(note) + stepIndex - indexOffset); // "we need to add a # to to the G to make it a major third from E"
|
||||
return [targetNote, offsetAccidentals].join('');
|
||||
}
|
||||
251
src/strudel/tonal/voicings.mjs
Normal file
251
src/strudel/tonal/voicings.mjs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
voicings.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/tonal/voicings.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { stack, register, silence, logger } from '@strudel/core';
|
||||
import { renderVoicing } from './tonleiter.mjs';
|
||||
import _voicings from 'chord-voicings';
|
||||
import { complex, simple } from './ireal.mjs';
|
||||
const { dictionaryVoicing, minTopNoteDiff } = _voicings.default || _voicings; // parcel module resolution fuckup
|
||||
|
||||
const lefthand = {
|
||||
m7: ['3m 5P 7m 9M', '7m 9M 10m 12P'],
|
||||
7: ['3M 6M 7m 9M', '7m 9M 10M 13M'],
|
||||
'^7': ['3M 5P 7M 9M', '7M 9M 10M 12P'],
|
||||
69: ['3M 5P 6A 9M'],
|
||||
m7b5: ['3m 5d 7m 8P', '7m 8P 10m 12d'],
|
||||
'7b9': ['3M 6m 7m 9m', '7m 9m 10M 13m'],
|
||||
'7b13': ['3M 6m 7m 9m', '7m 9m 10M 13m'],
|
||||
o7: ['1P 3m 5d 6M', '5d 6M 8P 10m'],
|
||||
'7#11': ['7m 9M 11A 13A'],
|
||||
'7#9': ['3M 7m 9A'],
|
||||
mM7: ['3m 5P 7M 9M', '7M 9M 10m 12P'],
|
||||
m6: ['3m 5P 6M 9M', '6M 9M 10m 12P'],
|
||||
};
|
||||
|
||||
const guidetones = {
|
||||
m7: ['3m 7m', '7m 10m'],
|
||||
m9: ['3m 7m', '7m 10m'],
|
||||
7: ['3M 7m', '7m 10M'],
|
||||
'^7': ['3M 7M', '7M 10M'],
|
||||
'^9': ['3M 7M', '7M 10M'],
|
||||
69: ['3M 6M'],
|
||||
6: ['3M 6M', '6M 10M'],
|
||||
m7b5: ['3m 7m', '7m 10m'],
|
||||
'7b9': ['3M 7m', '7m 10M'],
|
||||
'7b13': ['3M 7m', '7m 10M'],
|
||||
o7: ['3m 6M', '6M 10m'],
|
||||
'7#11': ['3M 7m', '7m 10M'],
|
||||
'7#9': ['3M 7m', '7m 10M'],
|
||||
mM7: ['3m 7M', '7M 10m'],
|
||||
m6: ['3m 6M', '6M 10m'],
|
||||
};
|
||||
|
||||
const triads = {
|
||||
'': ['1P 3M 5P', '3M 5P 8P', '5P 8P 10M'],
|
||||
M: ['1P 3M 5P', '3M 5P 8P', '5P 8P 10M'],
|
||||
m: ['1P 3m 5P', '3m 5P 8P', '5P 8P 10m'],
|
||||
o: ['1P 3m 5d', '3m 5d 8P', '5d 8P 10m'],
|
||||
aug: ['1P 3m 5A', '3m 5A 8P', '5A 8P 10m'],
|
||||
};
|
||||
|
||||
const defaultDictionary = {
|
||||
// triads
|
||||
'': ['1P 3M 5P', '3M 5P 8P', '5P 8P 10M'],
|
||||
M: ['1P 3M 5P', '3M 5P 8P', '5P 8P 10M'],
|
||||
m: ['1P 3m 5P', '3m 5P 8P', '5P 8P 10m'],
|
||||
o: ['1P 3m 5d', '3m 5d 8P', '5d 8P 10m'],
|
||||
aug: ['1P 3m 5A', '3m 5A 8P', '5A 8P 10m'],
|
||||
// sevenths chords
|
||||
m7: ['3m 5P 7m 9M', '7m 9M 10m 12P'],
|
||||
7: ['3M 6M 7m 9M', '7m 9M 10M 13M'],
|
||||
'^7': ['3M 5P 7M 9M', '7M 9M 10M 12P'],
|
||||
69: ['3M 5P 6A 9M'],
|
||||
m7b5: ['3m 5d 7m 8P', '7m 8P 10m 12d'],
|
||||
'7b9': ['3M 6m 7m 9m', '7m 9m 10M 13m'],
|
||||
'7b13': ['3M 6m 7m 9m', '7m 9m 10M 13m'],
|
||||
o7: ['1P 3m 5d 6M', '5d 6M 8P 10m'],
|
||||
'7#11': ['7m 9M 11A 13A'],
|
||||
'7#9': ['3M 7m 9A'],
|
||||
mM7: ['3m 5P 7M 9M', '7M 9M 10m 12P'],
|
||||
m6: ['3m 5P 6M 9M', '6M 9M 10m 12P'],
|
||||
};
|
||||
|
||||
export const voicingRegistry = {
|
||||
lefthand: { dictionary: lefthand, range: ['F3', 'A4'], mode: 'below', anchor: 'a4' },
|
||||
triads: { dictionary: triads, mode: 'below', anchor: 'a4' },
|
||||
guidetones: { dictionary: guidetones, mode: 'above', anchor: 'a4' },
|
||||
legacy: { dictionary: defaultDictionary, mode: 'below', anchor: 'a4' },
|
||||
};
|
||||
|
||||
let defaultDict = 'ireal';
|
||||
export const setDefaultVoicings = (dict) => (defaultDict = dict);
|
||||
// e.g. typeof setDefaultVoicings !== 'undefined' && setDefaultVoicings('legacy');
|
||||
|
||||
export const setVoicingRange = (name, range) => addVoicings(name, voicingRegistry[name].dictionary, range);
|
||||
|
||||
/**
|
||||
* Adds a new custom voicing dictionary.
|
||||
*
|
||||
* @name addVoicings
|
||||
* @memberof Pattern
|
||||
* @param {string} name identifier for the voicing dictionary
|
||||
* @param {Object} dictionary maps chord symbol to possible voicings
|
||||
* @param {Array} range min, max note
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* addVoicings('cookie', {
|
||||
* 7: ['3M 7m 9M 12P 15P', '7m 10M 13M 16M 19P'],
|
||||
* '^7': ['3M 6M 9M 12P 14M', '7M 10M 13M 16M 19P'],
|
||||
* m7: ['8P 11P 14m 17m 19P', '5P 8P 11P 14m 17m'],
|
||||
* m7b5: ['3m 5d 8P 11P 14m', '5d 8P 11P 14m 17m'],
|
||||
* o7: ['3m 6M 9M 11A 15P'],
|
||||
* '7alt': ['3M 7m 10m 13m 15P'],
|
||||
* '7#11': ['7m 10m 13m 15P 17m'],
|
||||
* }, ['C3', 'C6'])
|
||||
* "<C^7 A7 Dm7 G7>".voicings('cookie').note()
|
||||
*/
|
||||
export const addVoicings = (name, dictionary, range = ['F3', 'A4']) => {
|
||||
Object.assign(voicingRegistry, { [name]: { dictionary, range } });
|
||||
};
|
||||
|
||||
// new call signature
|
||||
export const registerVoicings = (name, dictionary, options = {}) => {
|
||||
Object.assign(voicingRegistry, { [name]: { dictionary, ...options } });
|
||||
};
|
||||
|
||||
const getVoicing = (chord, dictionaryName, lastVoicing) => {
|
||||
const { dictionary, range } = voicingRegistry[dictionaryName];
|
||||
return dictionaryVoicing({
|
||||
chord,
|
||||
dictionary,
|
||||
range,
|
||||
picker: minTopNoteDiff,
|
||||
lastVoicing,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* DEPRECATED: still works, but it is recommended you use .voicing instead (without s).
|
||||
* Turns chord symbols into voicings, using the smoothest voice leading possible.
|
||||
* Uses [chord-voicings package](https://github.com/felixroos/chord-voicings#chord-voicings).
|
||||
*
|
||||
* @name voicings
|
||||
* @memberof Pattern
|
||||
* @param {string} dictionary which voicing dictionary to use.
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* stack("<C^7 A7 Dm7 G7>".voicings('lefthand'), "<C3 A2 D3 G2>").note()
|
||||
*/
|
||||
|
||||
let lastVoicing; // this now has to be global until another solution is found :-/
|
||||
// it used to be local to the voicings function at evaluation time
|
||||
// but since register will patternify by default, means that
|
||||
// the function is called over and over again, resetting the lastVoicing variables
|
||||
export const voicings = register('voicings', function (dictionary, pat) {
|
||||
return pat
|
||||
.fmap((value) => {
|
||||
lastVoicing = getVoicing(value, dictionary, lastVoicing);
|
||||
return stack(...lastVoicing);
|
||||
})
|
||||
.outerJoin();
|
||||
});
|
||||
|
||||
/**
|
||||
* Maps the chords of the incoming pattern to root notes in the given octave.
|
||||
*
|
||||
* @name rootNotes
|
||||
* @memberof Pattern
|
||||
* @param {octave} octave octave to use
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* "<C^7 A7 Dm7 G7>".rootNotes(2).note()
|
||||
*/
|
||||
export const rootNotes = register('rootNotes', function (octave, pat) {
|
||||
return pat.fmap((value) => {
|
||||
const chord = value.chord || value;
|
||||
const root = chord.match(/^([a-gA-G][b#]?).*$/)[1];
|
||||
const note = root + octave;
|
||||
return value.chord ? { note } : note;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Turns chord symbols into voicings. You can use the following control params:
|
||||
*
|
||||
* - `chord`: Note, followed by chord symbol, e.g. C Am G7 Bb^7
|
||||
* - `dict`: voicing dictionary to use, falls back to default dictionary
|
||||
* - `anchor`: the note that is used to align the chord
|
||||
* - `mode`: how the voicing is aligned to the anchor
|
||||
* - `below`: top note <= anchor
|
||||
* - `duck`: top note <= anchor, anchor excluded
|
||||
* - `above`: bottom note >= anchor
|
||||
* - `offset`: whole number that shifts the voicing up or down to the next voicing
|
||||
* - `n`: if set, the voicing is played like a scale. Overshooting numbers will be octaved
|
||||
*
|
||||
* All of the above controls are optional, except `chord`.
|
||||
* If you pass a pattern of strings to voicing, they will be interpreted as chords.
|
||||
*
|
||||
* @name voicing
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* n("0 1 2 3").chord("<C Am F G>").voicing()
|
||||
*/
|
||||
export const voicing = register('voicing', function (pat) {
|
||||
return pat
|
||||
.fmap((value) => {
|
||||
// destructure voicing controls out
|
||||
value = typeof value === 'string' ? { chord: value } : value;
|
||||
let { dictionary = defaultDict, chord, anchor, offset, mode, n, octaves, ...rest } = value;
|
||||
dictionary =
|
||||
typeof dictionary === 'string' ? voicingRegistry[dictionary] : { dictionary, mode: 'below', anchor: 'c5' };
|
||||
try {
|
||||
let notes = renderVoicing({ ...dictionary, chord, anchor, offset, mode, n, octaves });
|
||||
return stack(...notes)
|
||||
.note()
|
||||
.set(rest); // rest does not include voicing controls anymore!
|
||||
} catch (err) {
|
||||
logger(`[voicing]: unknown chord "${chord}"`);
|
||||
return silence;
|
||||
}
|
||||
})
|
||||
.outerJoin();
|
||||
});
|
||||
|
||||
export function voicingAlias(symbol, alias, setOrSets) {
|
||||
setOrSets = !Array.isArray(setOrSets) ? [setOrSets] : setOrSets;
|
||||
setOrSets.forEach((set) => {
|
||||
set[alias] = set[symbol];
|
||||
});
|
||||
}
|
||||
|
||||
// no symbol = major chord
|
||||
voicingAlias('^', '', [simple, complex]);
|
||||
|
||||
Object.keys(simple).forEach((symbol) => {
|
||||
// add aliases for "-" === "m"
|
||||
if (symbol.includes('-')) {
|
||||
let alias = symbol.replace('-', 'm');
|
||||
voicingAlias(symbol, alias, [complex, simple]);
|
||||
}
|
||||
// add aliases for "^" === "M"
|
||||
if (symbol.includes('^')) {
|
||||
let alias = symbol.replace('^', 'M');
|
||||
voicingAlias(symbol, alias, [complex, simple]);
|
||||
}
|
||||
// add aliases for "+" === "aug"
|
||||
if (symbol.includes('+')) {
|
||||
let alias = symbol.replace('+', 'aug');
|
||||
voicingAlias(symbol, alias, [complex, simple]);
|
||||
}
|
||||
});
|
||||
|
||||
registerVoicings('ireal', simple);
|
||||
registerVoicings('ireal-ext', complex);
|
||||
|
||||
export function resetVoicings() {
|
||||
lastVoicing = undefined;
|
||||
setDefaultVoicings('ireal');
|
||||
}
|
||||
5
src/strudel/transpiler/index.mjs
Normal file
5
src/strudel/transpiler/index.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { evaluate as _evaluate } from '@strudel/core';
|
||||
import { transpiler } from './transpiler.mjs';
|
||||
export * from './transpiler.mjs';
|
||||
|
||||
export const evaluate = (code) => _evaluate(code, transpiler);
|
||||
329
src/strudel/transpiler/transpiler.mjs
Normal file
329
src/strudel/transpiler/transpiler.mjs
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { getLeafLocations } from '@strudel/mini';
|
||||
import { parse } from 'acorn';
|
||||
import escodegen from 'escodegen';
|
||||
import { walk } from 'estree-walker';
|
||||
|
||||
let widgetMethods = [];
|
||||
export function registerWidgetType(type) {
|
||||
widgetMethods.push(type);
|
||||
}
|
||||
|
||||
let languages = new Map();
|
||||
// config = { getLocations: (code: string, offset?: number) => number[][] }
|
||||
// see mondough.mjs for example use
|
||||
// the language will kick in when the code contains a template literal of type
|
||||
// example: mondo`...` will use language of type "mondo"
|
||||
// TODO: refactor tidal.mjs to use this
|
||||
export function registerLanguage(type, config) {
|
||||
languages.set(type, config);
|
||||
}
|
||||
|
||||
export function transpiler(input, options = {}) {
|
||||
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
|
||||
|
||||
let ast = parse(input, {
|
||||
ecmaVersion: 2022,
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true,
|
||||
});
|
||||
|
||||
let miniLocations = [];
|
||||
const collectMiniLocations = (value, node) => {
|
||||
const minilang = languages.get('minilang');
|
||||
if (minilang) {
|
||||
const code = `[${value}]`;
|
||||
const locs = minilang.getLocations(code, node.start);
|
||||
miniLocations = miniLocations.concat(locs);
|
||||
} else {
|
||||
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
|
||||
miniLocations = miniLocations.concat(leafLocs);
|
||||
}
|
||||
};
|
||||
let widgets = [];
|
||||
|
||||
walk(ast, {
|
||||
enter(node, parent /* , prop, index */) {
|
||||
if (isLanguageLiteral(node)) {
|
||||
const { name } = node.tag;
|
||||
const language = languages.get(name);
|
||||
const code = node.quasi.quasis[0].value.raw;
|
||||
const offset = node.quasi.start + 1;
|
||||
if (emitMiniLocations) {
|
||||
const locs = language.getLocations(code, offset);
|
||||
miniLocations = miniLocations.concat(locs);
|
||||
}
|
||||
this.skip();
|
||||
return this.replace(languageWithLocation(name, code, offset));
|
||||
}
|
||||
if (isTemplateLiteral(node, 'tidal')) {
|
||||
const raw = node.quasi.quasis[0].value.raw;
|
||||
const offset = node.quasi.start + 1;
|
||||
if (emitMiniLocations) {
|
||||
const stringLocs = collectHaskellMiniLocations(raw, offset);
|
||||
miniLocations = miniLocations.concat(stringLocs);
|
||||
}
|
||||
this.skip();
|
||||
return this.replace(tidalWithLocation(raw, offset));
|
||||
}
|
||||
if (isBackTickString(node, parent)) {
|
||||
const { quasis } = node;
|
||||
const { raw } = quasis[0].value;
|
||||
this.skip();
|
||||
emitMiniLocations && collectMiniLocations(raw, node);
|
||||
return this.replace(miniWithLocation(raw, node));
|
||||
}
|
||||
if (isStringWithDoubleQuotes(node)) {
|
||||
const { value } = node;
|
||||
this.skip();
|
||||
emitMiniLocations && collectMiniLocations(value, node);
|
||||
return this.replace(miniWithLocation(value, node));
|
||||
}
|
||||
if (isSliderFunction(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,
|
||||
type: 'slider',
|
||||
});
|
||||
return this.replace(sliderWithLocation(node));
|
||||
}
|
||||
if (isWidgetMethod(node)) {
|
||||
const type = node.callee.property.name;
|
||||
const index = widgets.filter((w) => w.type === type).length;
|
||||
const widgetConfig = {
|
||||
to: node.end,
|
||||
index,
|
||||
type,
|
||||
id: options.id,
|
||||
};
|
||||
emitWidgets && widgets.push(widgetConfig);
|
||||
return this.replace(widgetWithLocation(node, widgetConfig));
|
||||
}
|
||||
if (isBareSamplesCall(node, parent)) {
|
||||
return this.replace(withAwait(node));
|
||||
}
|
||||
if (isLabelStatement(node)) {
|
||||
return this.replace(labelToP(node));
|
||||
}
|
||||
},
|
||||
leave(node, parent, prop, index) {},
|
||||
});
|
||||
|
||||
let { body } = ast;
|
||||
|
||||
if (!body.length) {
|
||||
console.warn('empty body -> fallback to silence');
|
||||
body.push({
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'Identifier',
|
||||
name: 'silence',
|
||||
},
|
||||
});
|
||||
} else if (!body?.[body.length - 1]?.expression) {
|
||||
throw new Error('unexpected ast format without body expression');
|
||||
}
|
||||
|
||||
// add return to last statement
|
||||
if (addReturn) {
|
||||
const { expression } = body[body.length - 1];
|
||||
body[body.length - 1] = {
|
||||
type: 'ReturnStatement',
|
||||
argument: expression,
|
||||
};
|
||||
}
|
||||
let output = escodegen.generate(ast);
|
||||
if (wrapAsync) {
|
||||
output = `(async ()=>{${output}})()`;
|
||||
}
|
||||
if (!emitMiniLocations) {
|
||||
return { output };
|
||||
}
|
||||
return { output, miniLocations, widgets };
|
||||
}
|
||||
|
||||
function isStringWithDoubleQuotes(node, locations, code) {
|
||||
if (node.type !== 'Literal') {
|
||||
return false;
|
||||
}
|
||||
return node.raw[0] === '"';
|
||||
}
|
||||
|
||||
function isBackTickString(node, parent) {
|
||||
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
|
||||
}
|
||||
|
||||
function miniWithLocation(value, node) {
|
||||
const { start: fromOffset } = node;
|
||||
|
||||
const minilang = languages.get('minilang');
|
||||
let name = 'm';
|
||||
if (minilang && minilang.name) {
|
||||
name = minilang.name; // name is expected to be exported from the package of the minilang
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name,
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: fromOffset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
// these functions are connected to @strudel/codemirror -> slider.mjs
|
||||
// maybe someday there will be pluggable transpiler functions, then move this there
|
||||
function isSliderFunction(node) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'slider';
|
||||
}
|
||||
|
||||
function isWidgetMethod(node) {
|
||||
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
||||
}
|
||||
|
||||
function sliderWithLocation(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;
|
||||
}
|
||||
|
||||
export function getWidgetID(widgetConfig) {
|
||||
// the widget id is used as id for the dom element + as key for eventual resources
|
||||
// for example, for each scope widget, a new analyser + buffer (large) is created
|
||||
// that means, if we use the index index of line position as id, less garbage is generated
|
||||
// return `widget_${widgetConfig.to}`; // more gargabe
|
||||
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
|
||||
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
|
||||
}
|
||||
|
||||
function widgetWithLocation(node, widgetConfig) {
|
||||
const id = getWidgetID(widgetConfig);
|
||||
// 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,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function isBareSamplesCall(node, parent) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
|
||||
}
|
||||
|
||||
function withAwait(node) {
|
||||
return {
|
||||
type: 'AwaitExpression',
|
||||
argument: node,
|
||||
};
|
||||
}
|
||||
|
||||
function isLabelStatement(node) {
|
||||
return node.type === 'LabeledStatement';
|
||||
}
|
||||
|
||||
// converts label expressions to p calls: "x: y" to "y.p('x')"
|
||||
// see https://codeberg.org/uzu/strudel/issues/990
|
||||
function labelToP(node) {
|
||||
return {
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
object: node.body.expression,
|
||||
property: {
|
||||
type: 'Identifier',
|
||||
name: 'p',
|
||||
},
|
||||
},
|
||||
arguments: [
|
||||
{
|
||||
type: 'Literal',
|
||||
value: node.label.name,
|
||||
raw: `'${node.label.name}'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLanguageLiteral(node) {
|
||||
return node.type === 'TaggedTemplateExpression' && languages.has(node.tag.name);
|
||||
}
|
||||
|
||||
// tidal highlighting
|
||||
// this feels kind of stupid, when we also know the location inside the string op (tidal.mjs)
|
||||
// but maybe it's the only way
|
||||
|
||||
function isTemplateLiteral(node, value) {
|
||||
return node.type === 'TaggedTemplateExpression' && node.tag.name === value;
|
||||
}
|
||||
|
||||
function collectHaskellMiniLocations(haskellCode, offset) {
|
||||
return haskellCode
|
||||
.split('')
|
||||
.reduce((acc, char, i) => {
|
||||
if (char !== '"') {
|
||||
return acc;
|
||||
}
|
||||
if (!acc.length || acc[acc.length - 1].length > 1) {
|
||||
acc.push([i + 1]);
|
||||
} else {
|
||||
acc[acc.length - 1].push(i);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.map(([start, end]) => {
|
||||
const miniString = haskellCode.slice(start, end);
|
||||
return getLeafLocations(`"${miniString}"`, offset + start - 1);
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function tidalWithLocation(value, offset) {
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: 'tidal',
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: offset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function languageWithLocation(name, value, offset) {
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: offset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
11
src/strudel/webaudio/index.mjs
Normal file
11
src/strudel/webaudio/index.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
index.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/webaudio/index.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export * from './webaudio.mjs';
|
||||
export * from './scope.mjs';
|
||||
export * from './spectrum.mjs';
|
||||
export * from './supradough.mjs';
|
||||
export * from 'superdough';
|
||||
149
src/strudel/webaudio/scope.mjs
Normal file
149
src/strudel/webaudio/scope.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Pattern, clamp } from '@strudel/core';
|
||||
import { getDrawContext, getTheme } from '@strudel/draw';
|
||||
import { analysers, getAnalyzerData } from 'superdough';
|
||||
|
||||
export function drawTimeScope(
|
||||
analyser,
|
||||
{
|
||||
align = true,
|
||||
color = 'white',
|
||||
thickness = 3,
|
||||
scale = 0.25,
|
||||
pos = 0.75,
|
||||
trigger = 0,
|
||||
ctx = getDrawContext(),
|
||||
id = 1,
|
||||
} = {},
|
||||
) {
|
||||
ctx.lineWidth = thickness;
|
||||
ctx.strokeStyle = color;
|
||||
let canvas = ctx.canvas;
|
||||
|
||||
if (!analyser) {
|
||||
// if analyser is undefined, draw straight line
|
||||
// it may be undefined when no sound has been played yet
|
||||
ctx.beginPath();
|
||||
let y = pos * canvas.height;
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(canvas.width, y);
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
const dataArray = getAnalyzerData('time', id);
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
const bufferSize = analyser.frequencyBinCount;
|
||||
let triggerIndex = align
|
||||
? Array.from(dataArray).findIndex((v, i, arr) => i && arr[i - 1] > -trigger && v <= -trigger)
|
||||
: 0;
|
||||
triggerIndex = Math.max(triggerIndex, 0); // fallback to 0 when no trigger is found
|
||||
|
||||
const sliceWidth = (canvas.width * 1.0) / bufferSize;
|
||||
let x = 0;
|
||||
for (let i = triggerIndex; i < bufferSize; i++) {
|
||||
const v = dataArray[i] + 1;
|
||||
const y = (pos - scale * (v - 1)) * canvas.height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
x += sliceWidth;
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export function drawFrequencyScope(
|
||||
analyser,
|
||||
{ color = 'white', scale = 0.25, pos = 0.75, lean = 0.5, min = -150, max = 0, ctx = getDrawContext(), id = 1 } = {},
|
||||
) {
|
||||
if (!analyser) {
|
||||
ctx.beginPath();
|
||||
let y = pos * canvas.height;
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(canvas.width, y);
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
const dataArray = getAnalyzerData('frequency', id);
|
||||
const canvas = ctx.canvas;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
const bufferSize = analyser.frequencyBinCount;
|
||||
const sliceWidth = (canvas.width * 1.0) / bufferSize;
|
||||
|
||||
let x = 0;
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const normalized = clamp((dataArray[i] - min) / (max - min), 0, 1);
|
||||
const v = normalized * scale;
|
||||
const h = v * canvas.height;
|
||||
const y = (pos - v * lean) * canvas.height;
|
||||
|
||||
ctx.fillRect(x, y, Math.max(sliceWidth, 1), h);
|
||||
x += sliceWidth;
|
||||
}
|
||||
}
|
||||
|
||||
function clearScreen(smear = 0, smearRGB = `0,0,0`, ctx = getDrawContext()) {
|
||||
if (!smear) {
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
} else {
|
||||
ctx.fillStyle = `rgba(${smearRGB},${1 - smear})`;
|
||||
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {}) {
|
||||
let id = config.id ?? 1;
|
||||
return this.analyze(id).draw(
|
||||
() => {
|
||||
clearScreen(config.smear, '0,0,0', config.ctx);
|
||||
analysers[id] && drawFrequencyScope(analysers[id], config);
|
||||
},
|
||||
{ id },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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()
|
||||
*/
|
||||
let latestColor = {};
|
||||
Pattern.prototype.tscope = function (config = {}) {
|
||||
let id = config.id ?? 1;
|
||||
return this.analyze(id).draw(
|
||||
(haps) => {
|
||||
config.color = haps[0]?.value?.color || getTheme().foreground;
|
||||
latestColor[id] = config.color;
|
||||
clearScreen(config.smear, '0,0,0', config.ctx);
|
||||
drawTimeScope(analysers[id], config);
|
||||
},
|
||||
{ id },
|
||||
);
|
||||
};
|
||||
|
||||
Pattern.prototype.scope = Pattern.prototype.tscope;
|
||||
69
src/strudel/webaudio/spectrum.mjs
Normal file
69
src/strudel/webaudio/spectrum.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Pattern, clamp } from '@strudel/core';
|
||||
import { getDrawContext, getTheme } from '@strudel/draw';
|
||||
import { analysers, getAnalyzerData } from 'superdough';
|
||||
|
||||
/**
|
||||
* Renders a spectrum analyzer for the incoming audio signal.
|
||||
* @name spectrum
|
||||
* @param {object} config optional config with options:
|
||||
* @param {integer} thickness line thickness in px (default 3)
|
||||
* @param {integer} speed scroll speed (default 1)
|
||||
* @param {integer} min min db (default -80)
|
||||
* @param {integer} max max db (default 0)
|
||||
* @example
|
||||
* n("<0 4 <2 3> 1>*3")
|
||||
* .off(1/8, add(n(5)))
|
||||
* .off(1/5, add(n(7)))
|
||||
* .scale("d3:minor:pentatonic")
|
||||
* .s('sine')
|
||||
* .dec(.3).room(.5)
|
||||
* ._spectrum()
|
||||
*/
|
||||
let latestColor = {};
|
||||
Pattern.prototype.spectrum = function (config = {}) {
|
||||
let id = config.id ?? 1;
|
||||
return this.analyze(id).draw(
|
||||
(haps) => {
|
||||
config.color = haps[0]?.value?.color || latestColor[id] || getTheme().foreground;
|
||||
latestColor[id] = config.color;
|
||||
drawSpectrum(analysers[id], config);
|
||||
},
|
||||
{ id },
|
||||
);
|
||||
};
|
||||
|
||||
Pattern.prototype.scope = Pattern.prototype.tscope;
|
||||
|
||||
const lastFrames = new Map();
|
||||
|
||||
function drawSpectrum(
|
||||
analyser,
|
||||
{ thickness = 3, speed = 1, min = -80, max = 0, ctx = getDrawContext(), id = 1, color } = {},
|
||||
) {
|
||||
ctx.lineWidth = thickness;
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
if (!analyser) {
|
||||
// if analyser is undefined, draw straight line
|
||||
// it may be undefined when no sound has been played yet
|
||||
return;
|
||||
}
|
||||
const scrollSize = speed;
|
||||
const dataArray = getAnalyzerData('frequency', id);
|
||||
const canvas = ctx.canvas;
|
||||
ctx.fillStyle = color;
|
||||
const bufferSize = analyser.frequencyBinCount;
|
||||
let imageData = lastFrames.get(id) || ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
lastFrames.set(id, imageData);
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
ctx.putImageData(imageData, -scrollSize, 0);
|
||||
let q = canvas.width - speed;
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const normalized = clamp((dataArray[i] - min) / (max - min), 0, 1);
|
||||
ctx.globalAlpha = normalized;
|
||||
const next = (Math.log(i + 1) / Math.log(bufferSize)) * canvas.height;
|
||||
const size = 2; //next - pos;
|
||||
ctx.fillRect(q, canvas.height - next, scrollSize, size);
|
||||
}
|
||||
lastFrames.set(id, ctx.getImageData(0, 0, canvas.width, canvas.height));
|
||||
}
|
||||
130
src/strudel/webaudio/supradough.mjs
Normal file
130
src/strudel/webaudio/supradough.mjs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Pattern } from '@strudel/core';
|
||||
import { connectToDestination, getAudioContext, getWorklet } from 'superdough';
|
||||
|
||||
let doughWorklet;
|
||||
|
||||
function initDoughWorklet() {
|
||||
const ac = getAudioContext();
|
||||
doughWorklet = getWorklet(
|
||||
ac,
|
||||
'dough-processor',
|
||||
{},
|
||||
{
|
||||
outputChannelCount: [2],
|
||||
},
|
||||
);
|
||||
connectToDestination(doughWorklet); // channels?
|
||||
}
|
||||
|
||||
const soundMap = new Map();
|
||||
const loadedSounds = new Map();
|
||||
|
||||
Pattern.prototype.supradough = function () {
|
||||
return this.onTrigger((hap, __, cps, begin) => {
|
||||
hap.value._begin = begin;
|
||||
hap.value._duration = hap.duration / cps;
|
||||
!doughWorklet && initDoughWorklet();
|
||||
const s = (hap.value.bank ? hap.value.bank + '_' : '') + hap.value.s;
|
||||
const n = hap.value.n ?? 0;
|
||||
const soundKey = `${s}:${n}`;
|
||||
if (soundMap.has(s)) {
|
||||
hap.value.s = soundKey; // dough.mjs is unaware of bank and n (only maps keys to buffers)
|
||||
}
|
||||
if (soundMap.has(s) && !loadedSounds.has(soundKey)) {
|
||||
const urls = soundMap.get(s);
|
||||
const url = urls[n % urls.length];
|
||||
console.log(`load ${soundKey} from ${url}`);
|
||||
const loadSample = fetchSample(url);
|
||||
loadedSounds.set(soundKey, loadSample);
|
||||
loadSample.then(({ channels, sampleRate }) =>
|
||||
doughWorklet.port.postMessage({
|
||||
sample: soundKey,
|
||||
channels,
|
||||
sampleRate,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
doughWorklet.port.postMessage({ spawn: hap.value });
|
||||
}, 1);
|
||||
};
|
||||
|
||||
function githubPath(base, subpath = '') {
|
||||
if (!base.startsWith('github:')) {
|
||||
throw new Error('expected "github:" at the start of pseudoUrl');
|
||||
}
|
||||
let [_, path] = base.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';
|
||||
}
|
||||
return `https://raw.githubusercontent.com/${path}/${subpath}`;
|
||||
}
|
||||
export async function fetchSampleMap(url) {
|
||||
if (url.startsWith('github:')) {
|
||||
url = githubPath(url, 'strudel.json');
|
||||
}
|
||||
if (url.startsWith('local:')) {
|
||||
url = `http://localhost:5432`;
|
||||
}
|
||||
if (url.startsWith('shabda:')) {
|
||||
let [_, path] = url.split('shabda:');
|
||||
url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
|
||||
}
|
||||
if (url.startsWith('shabda/speech')) {
|
||||
let [_, path] = url.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('/');
|
||||
}
|
||||
url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
|
||||
}
|
||||
if (typeof fetch !== 'function') {
|
||||
// not a browser
|
||||
return;
|
||||
}
|
||||
const base = url.split('/').slice(0, -1).join('/');
|
||||
if (typeof fetch === 'undefined') {
|
||||
// skip fetch when in node / testing
|
||||
return;
|
||||
}
|
||||
const json = await fetch(url)
|
||||
.then((res) => res.json())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
throw new Error(`error loading "${url}"`);
|
||||
});
|
||||
return [json, json._base || base];
|
||||
}
|
||||
|
||||
// for some reason, only piano and flute work.. is it because mp3??
|
||||
|
||||
async function fetchSample(url) {
|
||||
const buffer = await fetch(url)
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((buf) => getAudioContext().decodeAudioData(buf));
|
||||
let channels = [];
|
||||
for (let i = 0; i < buffer.numberOfChannels; i++) {
|
||||
channels.push(buffer.getChannelData(i));
|
||||
}
|
||||
return { channels, sampleRate: buffer.sampleRate };
|
||||
}
|
||||
|
||||
export async function doughsamples(sampleMap, baseUrl) {
|
||||
if (typeof sampleMap === 'string') {
|
||||
const [json, base] = await fetchSampleMap(sampleMap);
|
||||
// console.log('json', json, 'base', base);
|
||||
return doughsamples(json, base);
|
||||
}
|
||||
Object.entries(sampleMap).map(async ([key, urls]) => {
|
||||
if (key !== '_base') {
|
||||
urls = urls.map((url) => baseUrl + url);
|
||||
// console.log('set', key, urls);
|
||||
soundMap.set(key, urls);
|
||||
}
|
||||
});
|
||||
}
|
||||
40
src/strudel/webaudio/webaudio.mjs
Normal file
40
src/strudel/webaudio/webaudio.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
webaudio.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/webaudio/webaudio.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as strudel from '@strudel/core';
|
||||
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough';
|
||||
import './supradough.mjs';
|
||||
import { workletUrl } from 'supradough';
|
||||
|
||||
registerWorklet(workletUrl);
|
||||
|
||||
const { Pattern, logger, repl } = strudel;
|
||||
|
||||
setLogger(logger);
|
||||
|
||||
const hap2value = (hap) => {
|
||||
hap.ensureObjectValue();
|
||||
return hap.value;
|
||||
};
|
||||
|
||||
// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
|
||||
// TODO: refactor output callbacks to eliminate deadline
|
||||
export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
|
||||
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
||||
};
|
||||
|
||||
export function webaudioRepl(options = {}) {
|
||||
options = {
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
defaultOutput: webaudioOutput,
|
||||
...options,
|
||||
};
|
||||
return repl(options);
|
||||
}
|
||||
|
||||
Pattern.prototype.dough = function () {
|
||||
return this.onTrigger(doughTrigger, 1);
|
||||
};
|
||||
4
src/strudel/xen/index.mjs
Normal file
4
src/strudel/xen/index.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import './xen.mjs';
|
||||
import './tune.mjs';
|
||||
|
||||
export * from './xen.mjs';
|
||||
20
src/strudel/xen/tune.mjs
Normal file
20
src/strudel/xen/tune.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
tune.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/xen/tune.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Tune from './tunejs.js';
|
||||
import { register } from '@strudel/core';
|
||||
|
||||
export const tune = register('tune', (scale, pat) => {
|
||||
const tune = new Tune();
|
||||
if (!tune.isValidScale(scale)) {
|
||||
throw new Error('not a valid tune.js scale name: "' + scale + '". See http://abbernie.github.io/tune/scales.html');
|
||||
}
|
||||
tune.loadScale(scale);
|
||||
tune.tonicize(1);
|
||||
return pat.withHap((hap) => {
|
||||
return hap.withValue(() => tune.note(hap.value));
|
||||
});
|
||||
});
|
||||
244
src/strudel/xen/tunejs.js
Normal file
244
src/strudel/xen/tunejs.js
Normal file
File diff suppressed because one or more lines are too long
64
src/strudel/xen/xen.mjs
Normal file
64
src/strudel/xen/xen.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
xen.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/xen/xen.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { register, _mod, parseNumeral } from '@strudel/core';
|
||||
|
||||
export function edo(name) {
|
||||
if (!/^[1-9]+[0-9]*edo$/.test(name)) {
|
||||
throw new Error('not an edo scale: "' + name + '"');
|
||||
}
|
||||
const [_, divisions] = name.match(/^([1-9]+[0-9]*)edo$/);
|
||||
return Array.from({ length: divisions }, (_, i) => Math.pow(2, i / divisions));
|
||||
}
|
||||
|
||||
const presets = {
|
||||
'12ji': [1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4, 4 / 3, 45 / 32, 3 / 2, 8 / 5, 5 / 3, 16 / 9, 15 / 8],
|
||||
};
|
||||
|
||||
function withBase(freq, scale) {
|
||||
return scale.map((r) => r * freq);
|
||||
}
|
||||
|
||||
const defaultBase = 220;
|
||||
|
||||
function getXenScale(scale, indices) {
|
||||
if (typeof scale === 'string') {
|
||||
if (/^[1-9]+[0-9]*edo$/.test(scale)) {
|
||||
scale = edo(scale);
|
||||
} else if (presets[scale]) {
|
||||
scale = presets[scale];
|
||||
} else {
|
||||
throw new Error('unknown scale name: "' + scale + '"');
|
||||
}
|
||||
}
|
||||
scale = withBase(defaultBase, scale);
|
||||
if (!indices) {
|
||||
return scale;
|
||||
}
|
||||
return scale.filter((_, i) => indices.includes(i));
|
||||
}
|
||||
|
||||
function xenOffset(xenScale, offset, index = 0) {
|
||||
const i = _mod(index + offset, xenScale.length);
|
||||
const oct = Math.floor(offset / xenScale.length);
|
||||
return xenScale[i] * Math.pow(2, oct);
|
||||
}
|
||||
|
||||
// scaleNameOrRatios: string || number[], steps?: number
|
||||
export const xen = register('xen', function (scaleNameOrRatios, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
const scale = getXenScale(scaleNameOrRatios);
|
||||
const frequency = xenOffset(scale, parseNumeral(hap.value));
|
||||
return hap.withValue(() => frequency);
|
||||
});
|
||||
});
|
||||
|
||||
export const tuning = register('tuning', function (ratios, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
const frequency = xenOffset(ratios, parseNumeral(hap.value));
|
||||
return hap.withValue(() => frequency);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue