Merge branch 'mfix' of github.com:daslyfe/strudel into mfix

This commit is contained in:
Jade (Rose) Rowland 2024-04-06 17:32:07 -04:00
commit 0f24e97855
48 changed files with 1090 additions and 377 deletions

View file

@ -27,6 +27,7 @@ import { persistentAtom } from '@nanostores/persistent';
const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
isLineNumbersDisplayed: (on) => (on ? lineNumbers() : []),
theme,
isAutoCompletionEnabled,
@ -41,6 +42,7 @@ const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [ke
export const defaultSettings = {
keybindings: 'codemirror',
isBracketMatchingEnabled: false,
isBracketClosingEnabled: true,
isLineNumbersDisplayed: true,
isActiveLineHighlighted: false,
isAutoCompletionEnabled: false,
@ -76,7 +78,6 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
widgetPlugin,
// indentOnInput(), // works without. already brought with javascript extension?
// bracketMatching(), // does not do anything
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle),
history(),
EditorView.updateListener.of((v) => onChange(v)),
@ -128,6 +129,7 @@ export class StrudelMirror {
id,
initialCode = '',
onDraw,
drawContext,
drawTime = [0, 0],
autodraw,
prebake,
@ -140,13 +142,14 @@ export class StrudelMirror {
this.widgets = [];
this.painters = [];
this.drawTime = drawTime;
this.onDraw = onDraw;
this.drawContext = drawContext;
this.onDraw = onDraw || this.draw;
this.id = id || s4();
this.drawer = new Drawer((haps, time) => {
const currentFrame = haps.filter((hap) => time >= hap.whole.begin && time <= hap.endClipped);
const currentFrame = haps.filter((hap) => hap.isActive(time));
this.highlight(currentFrame, time);
this.onDraw?.(haps, time, currentFrame, this.painters);
this.onDraw(haps, time, this.painters);
}, drawTime);
this.prebaked = prebake();
@ -236,6 +239,9 @@ export class StrudelMirror {
// when no painters are set, [0,0] is enough (just highlighting)
this.drawer.setDrawTime(this.painters.length ? this.drawTime : [0, 0]);
}
draw(haps, time) {
this.painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
}
async drawFirstFrame() {
if (!this.onDraw) {
return;
@ -246,7 +252,7 @@ export class StrudelMirror {
await this.repl.evaluate(this.code, false);
this.drawer.invalidate(this.repl.scheduler, -0.001);
// draw at -0.001 to avoid haps at 0 to be visualized as active
this.onDraw?.(this.drawer.visibleHaps, -0.001, [], this.painters);
this.onDraw?.(this.drawer.visibleHaps, -0.001, this.painters);
} catch (err) {
console.warn('first frame could not be painted');
}
@ -304,6 +310,9 @@ export class StrudelMirror {
setLineNumbersDisplayed(enabled) {
this.reconfigureExtension('isLineNumbersDisplayed', enabled);
}
setBracketClosingEnabled(enabled) {
this.reconfigureExtension('isBracketClosingEnabled', enabled);
}
setTheme(theme) {
this.reconfigureExtension('theme', theme);
}

View file

@ -37,6 +37,7 @@ import whitescreen, { settings as whitescreenSettings } from './themes/whitescre
import teletext, { settings as teletextSettings } from './themes/teletext';
import algoboy, { settings as algoboySettings } from './themes/algoboy';
import terminal, { settings as terminalSettings } from './themes/terminal';
import { setTheme } from '@strudel/draw';
export const themes = {
strudelTheme,
@ -513,6 +514,7 @@ export function activateTheme(name) {
.map(([key, value]) => `--${key}: ${value} !important;`)
.join('\n')}
}`;
setTheme(themeSettings);
// tailwind dark mode
if (themeSettings.light) {
document.documentElement.classList.remove('dark');

View file

@ -106,23 +106,30 @@ function getCanvasWidget(id, options = {}) {
registerWidget('_pianoroll', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.id(id).pianoroll({ fold: 1, ...options, ctx, id });
return pat.tag(id).pianoroll({ fold: 1, ...options, ctx, id });
});
registerWidget('_punchcard', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.id(id).punchcard({ fold: 1, ...options, ctx, id });
return pat.tag(id).punchcard({ fold: 1, ...options, ctx, id });
});
registerWidget('_spiral', (id, options = {}, pat) => {
let _size = options.size || 275;
options = { width: _size, height: _size, ...options, size: _size / 5 };
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.id(id).spiral({ ...options, ctx, id });
return pat.tag(id).spiral({ ...options, ctx, id });
});
registerWidget('_scope', (id, options = {}, pat) => {
options = { width: 500, height: 60, pos: 0.5, scale: 1, ...options };
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.id(id).scope({ ...options, ctx, id });
return pat.tag(id).scope({ ...options, ctx, id });
});
registerWidget('_pitchwheel', (id, options = {}, pat) => {
let _size = options.size || 200;
options = { width: _size, height: _size, ...options, size: _size / 5 };
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.pitchwheel({ ...options, ctx, id });
});

View file

@ -37,12 +37,17 @@ export class Cyclist {
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 });
this.lastTick = phase;
haps.forEach((hap) => {
if (hap.hasOnset()) {
const targetTime =
@ -67,6 +72,9 @@ export class Cyclist {
);
}
now() {
if (!this.started) {
return 0;
}
const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration;
return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency;
}

View file

@ -49,6 +49,27 @@ export class Hap {
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;
}
@ -70,6 +91,10 @@ export class Hap {
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');

View file

@ -29,22 +29,22 @@ export class Pattern {
* @param {function} query - The function that maps a `State` to an array of `Hap`.
* @noAutocomplete
*/
constructor(query, weight = undefined) {
constructor(query, tactus = undefined) {
this.query = query;
this._Pattern = true; // this property is used to detect if a pattern that fails instanceof Pattern is an instance of another Pattern
this.__weight = weight; // in terms of number of beats per cycle
this.__tactus = tactus; // in terms of number of beats per cycle
}
get weight() {
return this.__weight ?? Fraction(1);
get tactus() {
return this.__tactus ?? Fraction(1);
}
set weight(weight) {
this.__weight = Fraction(weight);
set tactus(tactus) {
this.__tactus = Fraction(tactus);
}
setWeight(weight) {
this.weight = weight;
setTactus(tactus) {
this.tactus = tactus;
return this;
}
@ -62,7 +62,7 @@ export class Pattern {
*/
withValue(func) {
const result = new Pattern((state) => this.query(state).map((hap) => hap.withValue(func)));
result.weight = this.weight;
result.tactus = this.tactus;
return result;
}
@ -130,7 +130,7 @@ export class Pattern {
return span_a.intersection_e(span_b);
};
const result = pat_func.appWhole(whole_func, pat_val);
result.weight = lcm(pat_val.weight, pat_func.weight);
result.tactus = lcm(pat_val.tactus, pat_func.tactus);
return result;
}
@ -165,7 +165,7 @@ export class Pattern {
return haps;
};
const result = new Pattern(query);
result.weight = this.weight;
result.tactus = this.tactus;
return result;
}
@ -198,7 +198,7 @@ export class Pattern {
return haps;
};
const result = new Pattern(query);
result.weight = pat_val.weight;
result.tactus = pat_val.tactus;
return result;
}
@ -452,7 +452,7 @@ export class Pattern {
*/
withHaps(func) {
const result = new Pattern((state) => func(this.query(state), state));
result.weight = this.weight;
result.tactus = this.tactus;
return result;
}
@ -542,7 +542,7 @@ export class Pattern {
* @noAutocomplete
*/
filterValues(value_test) {
return new Pattern((state) => this.query(state).filter((hap) => value_test(hap.value)));
return new Pattern((state) => this.query(state).filter((hap) => value_test(hap.value))).setTactus(this.tactus);
}
/**
@ -1147,7 +1147,7 @@ Pattern.prototype.factories = {
slowcat,
fastcat,
cat,
timeCat,
timecat,
sequence,
seq,
polymeter,
@ -1160,13 +1160,13 @@ Pattern.prototype.factories = {
// Elemental patterns
/**
* Does absolutely nothing, with a given metrical 'weight'
* Does absolutely nothing, but with a given metrical 'tactus'
* @name gap
* @param {number} weight
* @param {number} tactus
* @example
* gap(3) // "~@3"
*/
export const gap = (weight) => new Pattern(() => [], Fraction(weight));
export const gap = (tactus) => new Pattern(() => [], Fraction(tactus));
/**
* Does absolutely nothing..
@ -1176,7 +1176,7 @@ export const gap = (weight) => new Pattern(() => [], Fraction(weight));
*/
export const silence = gap(1);
/* Like silence, but with a 'weight' (relative duration) of 0 */
/* Like silence, but with a 'tactus' (relative duration) of 0 */
export const nothing = gap(0);
/** A discrete value that repeats once per cycle.
@ -1235,7 +1235,7 @@ export function stack(...pats) {
pats = pats.map((pat) => (Array.isArray(pat) ? sequence(...pat) : reify(pat)));
const query = (state) => flatten(pats.map((pat) => pat.query(state)));
const result = new Pattern(query);
result.weight = lcm(...pats.map((pat) => pat.weight));
result.tactus = lcm(...pats.map((pat) => pat.tactus));
return result;
}
@ -1247,54 +1247,54 @@ function _stackWith(func, pats) {
if (pats.length === 1) {
return pats[0];
}
const [left, ...right] = pats.map((pat) => pat.weight);
const weight = left.maximum(...right);
return stack(...func(weight, pats));
const [left, ...right] = pats.map((pat) => pat.tactus);
const tactus = left.maximum(...right);
return stack(...func(tactus, pats));
}
export function stackLeft(...pats) {
return _stackWith(
(weight, pats) => pats.map((pat) => (pat.weight.eq(weight) ? pat : timeCat(pat, gap(weight.sub(pat.weight))))),
(tactus, pats) => pats.map((pat) => (pat.tactus.eq(tactus) ? pat : timecat(pat, gap(tactus.sub(pat.tactus))))),
pats,
);
}
export function stackRight(...pats) {
return _stackWith(
(weight, pats) => pats.map((pat) => (pat.weight.eq(weight) ? pat : timeCat(gap(weight.sub(pat.weight)), pat))),
(tactus, pats) => pats.map((pat) => (pat.tactus.eq(tactus) ? pat : timecat(gap(tactus.sub(pat.tactus)), pat))),
pats,
);
}
export function stackCentre(...pats) {
return _stackWith(
(weight, pats) =>
(tactus, pats) =>
pats.map((pat) => {
if (pat.weight.eq(weight)) {
if (pat.tactus.eq(tactus)) {
return pat;
}
const g = gap(weight.sub(pat.weight).div(2));
return timeCat(g, pat, g);
const g = gap(tactus.sub(pat.tactus).div(2));
return timecat(g, pat, g);
}),
pats,
);
}
export function stackBy(by, ...pats) {
const [left, ...right] = pats.map((pat) => pat.weight);
const weight = left.maximum(...right);
const [left, ...right] = pats.map((pat) => pat.tactus);
const tactus = left.maximum(...right);
const lookup = {
centre: stackCentre,
left: stackLeft,
right: stackRight,
expand: stack,
beat: (...args) => polymeterSteps(weight, ...args),
repeat: (...args) => polymeterSteps(tactus, ...args),
};
return by
.inhabit(lookup)
.fmap((func) => func(...pats))
.innerJoin()
.setWeight(weight);
.setTactus(tactus);
}
/** Concatenation: combines a list of patterns, switching between them successively, one per cycle:
@ -1361,24 +1361,21 @@ export function cat(...pats) {
/** Sequences patterns like `seq`, but each pattern has a length, relative to the whole.
* This length can either be provided as a [length, pattern] pair, or inferred from
* mininotation as the number of toplevel steps. The latter only works if the mininotation
* hasn't first been modified by another function.
* the pattern's 'tactus', generally inferred by the mininotation.
* @return {Pattern}
* @example
* timeCat([3,"e3"],[1, "g3"]).note()
* timecat([3,"e3"],[1, "g3"]).note()
* // the same as "e3@3 g3".note()
* @example
* timeCat("bd sd cp","hh hh").sound()
* timecat("bd sd cp","hh hh").sound()
* // the same as "bd sd cp hh hh".sound()
*/
export function timeCat(...timepats) {
// Weights may either be provided explicitly in [weight, pattern] pairs, or
// where possible, inferred from the pattern.
const findWeight = (x) => (Array.isArray(x) ? x : [x.weight, x]);
timepats = timepats.map(findWeight);
export function timecat(...timepats) {
const findtactus = (x) => (Array.isArray(x) ? x : [x.tactus, x]);
timepats = timepats.map(findtactus);
if (timepats.length == 1) {
const result = reify(timepats[0][1]);
result.weight = timepats[0][0];
result.tactus = timepats[0][0];
return result;
}
@ -1391,10 +1388,13 @@ export function timeCat(...timepats) {
begin = end;
}
const result = stack(...pats);
result.weight = total;
result.tactus = total;
return result;
}
/** Deprecated alias for `timecat` */
export const timeCat = timecat;
/**
* Allows to arrange multiple patterns together over multiple cycles.
* Takes a variable number of arrays with two elements specifying the number of cycles and the pattern to use.
@ -1409,26 +1409,32 @@ export function timeCat(...timepats) {
export function arrange(...sections) {
const total = sections.reduce((sum, [cycles]) => sum + cycles, 0);
sections = sections.map(([cycles, section]) => [cycles, section.fast(cycles)]);
return timeCat(...sections).slow(total);
return timecat(...sections).slow(total);
}
export function fastcat(...pats) {
let result = slowcat(...pats);
if (pats.length > 1) {
result = result._fast(pats.length);
result.weight = pats.length;
result.tactus = pats.length;
}
return result;
}
/** See `fastcat` */
export function sequence(...pats) {
return fastcat(...pats);
}
/**
* Concatenates patterns beatwise, similar to `timeCat`, but if an argument is a list, the whole pattern will be repeated for each element in the list.
* Concatenates patterns stepwise, according to their 'tactus'.
* Similar to `timecat`, but if an argument is a list, the whole pattern will be repeated for each element in the list.
*
* @return {Pattern}
* @example
* beatCat(["bd cp", "mt"], "bd").sound()
* stepcat(["bd cp", "mt"], "bd").sound()
*/
export function beatCat(...groups) {
export function stepcat(...groups) {
groups = groups.map((a) => (Array.isArray(a) ? a.map(reify) : [reify(a)]));
const cycles = lcm(...groups.map((x) => Fraction(x.length)));
@ -1437,18 +1443,13 @@ export function beatCat(...groups) {
for (let cycle = 0; cycle < cycles; ++cycle) {
result.push(...groups.map((x) => (x.length == 0 ? silence : x[cycle % x.length])));
}
result = result.filter((x) => x.weight > 0);
const weight = result.reduce((a, b) => a.add(b.weight), Fraction(0));
result = timeCat(...result);
result.weight = weight;
result = result.filter((x) => x.tactus > 0);
const tactus = result.reduce((a, b) => a.add(b.tactus), Fraction(0));
result = timecat(...result);
result.tactus = tactus;
return result;
}
/** See `fastcat` */
export function sequence(...pats) {
return fastcat(...pats);
}
/** Like **cat**, but the items are crammed into one cycle.
* @synonyms fastcat, sequence
* @example
@ -1474,13 +1475,13 @@ function _sequenceCount(x) {
}
/**
* Speeds a pattern up or down, to fit to the given metrical 'weight'.
* Speeds a pattern up or down, to fit to the given metrical 'tactus'.
* @example
* s("bd sd cp").reweight(4)
* s("bd sd cp").toTactus(4)
* // The same as s("{bd sd cp}%4")
*/
export const reweight = register('reweight', function (targetWeight, pat) {
return pat.fast(Fraction(targetWeight).div(pat.weight));
export const toTactus = register('toTactus', function (targetTactus, pat) {
return pat.fast(Fraction(targetTactus).div(pat.tactus));
});
export function _polymeterListSteps(steps, ...args) {
@ -1525,7 +1526,7 @@ export function polymeterSteps(steps, ...args) {
return _polymeterListSteps(steps, ...args);
}
return polymeter(...args).reweight(steps);
return polymeter(...args).toTactus(steps);
}
/**
@ -1545,11 +1546,11 @@ export function polymeter(...args) {
if (args.length == 0) {
return silence;
}
const weight = args[0].weight;
const tactus = args[0].tactus;
const [head, ...tail] = args;
const result = stack(head, ...tail.map((pat) => pat._slow(pat.weight.div(weight))));
result.weight = weight;
const result = stack(head, ...tail.map((pat) => pat._slow(pat.tactus.div(tactus))));
result.tactus = tactus;
return result;
}
@ -1592,7 +1593,7 @@ export const func = curry((a, b) => reify(b).func(a));
* @noAutocomplete
*
*/
export function register(name, func, patternify = true, preserveWeight = false) {
export function register(name, func, patternify = true, preserveTactus = false) {
if (Array.isArray(name)) {
const result = {};
for (const name_item of name) {
@ -1632,8 +1633,8 @@ export function register(name, func, patternify = true, preserveWeight = false)
result = right.reduce((acc, p) => acc.appLeft(p), left.fmap(mapFn)).innerJoin();
}
}
if (preserveWeight) {
result.weight = pat.weight;
if (preserveTactus) {
result.tactus = pat.tactus;
}
return result;
};
@ -1641,8 +1642,8 @@ export function register(name, func, patternify = true, preserveWeight = false)
pfunc = function (...args) {
args = args.map(reify);
const result = func(...args);
if (preserveWeight) {
result.weight = args[args.length - 1].weight;
if (preserveTactus) {
result.tactus = args[args.length - 1].tactus;
}
return result;
};
@ -1880,7 +1881,7 @@ export const { focusSpan, focusspan } = register(['focusSpan', 'focusspan'], fun
*/
export const ply = register('ply', function (factor, pat) {
const result = pat.fmap((x) => pure(x)._fast(factor)).squeezeJoin();
result.weight = pat.weight.mul(factor);
result.tactus = pat.tactus.mul(factor);
return result;
});
@ -1902,7 +1903,7 @@ export const { fast, density } = register(['fast', 'density'], function (factor,
factor = Fraction(factor);
const fastQuery = pat.withQueryTime((t) => t.mul(factor));
const result = fastQuery.withHapTime((t) => t.div(factor));
result.weight = factor.mul(pat.weight);
result.tactus = factor.mul(pat.tactus);
return result;
});
@ -2106,7 +2107,7 @@ export const linger = register(
* note(saw.range(40,52).segment(24))
*/
export const segment = register('segment', function (rate, pat) {
return pat.struct(pure(true)._fast(rate)).setWeight(rate);
return pat.struct(pure(true)._fast(rate)).setTactus(rate);
});
/**
@ -2264,7 +2265,7 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
const left = pat.withValue((val) => Object.assign({}, val, { pan: elem_or(val, 'pan', 0.5) - by }));
const right = func(pat.withValue((val) => Object.assign({}, val, { pan: elem_or(val, 'pan', 0.5) + by })));
return stack(left, right).setWeight(lcm(left.weight, right.weight));
return stack(left, right).setTactus(lcm(left.tactus, right.tactus));
});
/**
@ -2477,13 +2478,13 @@ export const hsl = register('hsl', (h, s, l, pat) => {
});
/**
* Sets the id of the hap in, for filtering in the future.
* @name id
* Tags each Hap with an identifier. Good for filtering. The function populates Hap.context.tags (Array).
* @name tag
* @noAutocomplete
* @param {string} id anything unique
* @param {string} tag anything unique
*/
Pattern.prototype.id = function (id) {
return this.withContext((ctx) => ({ ...ctx, id }));
Pattern.prototype.tag = function (tag) {
return this.withContext((ctx) => ({ ...ctx, tags: (ctx.tags || []).concat([tag]) }));
};
//////////////////////////////////////////////////////////////////////
@ -2510,7 +2511,7 @@ export const chop = register('chop', function (n, pat) {
const func = function (o) {
return sequence(slice_objects.map((slice_o) => Object.assign({}, o, slice_o)));
};
return pat.squeezeBind(func).setWeight(pat.weight.mul(n));
return pat.squeezeBind(func).setTactus(pat.tactus.mul(n));
});
/**
@ -2574,7 +2575,7 @@ export const slice = register(
}),
),
)
.setWeight(ipat.weight);
.setTactus(ipat.tactus);
},
false, // turns off auto-patternification
);
@ -2605,7 +2606,7 @@ export const splice = register(
...v,
})),
);
}).setWeight(ipat.weight);
}).setTactus(ipat.tactus);
},
false, // turns off auto-patternification
);

View file

@ -156,6 +156,7 @@ export function repl({
return pattern;
} catch (err) {
logger(`[eval] error: ${err.message}`, 'error');
console.error(err);
updateState({ evalError: err, pending: false });
onEvalError?.(err);
}

View file

@ -30,13 +30,13 @@ describe('controls', () => {
expect(s(mini('bd').pan(1)).firstCycleValues).toEqual([{ s: 'bd', pan: 1 }]);
expect(s(mini('bd:1').pan(1)).firstCycleValues).toEqual([{ s: 'bd', n: 1, pan: 1 }]);
});
it('preserves weight of the left pattern', () => {
expect(s(mini('bd cp mt').pan(mini('1 2 3 4'))).weight).toEqual(Fraction(3));
it('preserves tactus of the left pattern', () => {
expect(s(mini('bd cp mt').pan(mini('1 2 3 4'))).tactus).toEqual(Fraction(3));
});
it('preserves weight of the right pattern for .out', () => {
expect(s(mini('bd cp mt').set.out(pan(mini('1 2 3 4')))).weight).toEqual(Fraction(4));
it('preserves tactus of the right pattern for .out', () => {
expect(s(mini('bd cp mt').set.out(pan(mini('1 2 3 4')))).tactus).toEqual(Fraction(4));
});
it('combines weight of the pattern for .mix as lcm', () => {
expect(s(mini('bd cp mt').set.mix(pan(mini('1 2 3 4')))).weight).toEqual(Fraction(12));
it('combines tactus of the pattern for .mix as lcm', () => {
expect(s(mini('bd cp mt').set.mix(pan(mini('1 2 3 4')))).tactus).toEqual(Fraction(12));
});
});

View file

@ -1119,21 +1119,21 @@ describe('Pattern', () => {
);
});
});
describe('weight', () => {
describe('tactus', () => {
it('Is correctly preserved/calculated through transformations', () => {
expect(sequence(0, 1, 2, 3).linger(4).weight).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).iter(4).weight).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).fast(4).weight).toStrictEqual(Fraction(16));
expect(sequence(0, 1, 2, 3).hurry(4).weight).toStrictEqual(Fraction(16));
expect(sequence(0, 1, 2, 3).rev().weight).toStrictEqual(Fraction(4));
expect(sequence(1).segment(10).weight).toStrictEqual(Fraction(10));
expect(sequence(1, 0, 1).invert().weight).toStrictEqual(Fraction(3));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).chop(4).weight).toStrictEqual(Fraction(8));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).striate(4).weight).toStrictEqual(Fraction(8));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).slice(4, sequence(0, 1, 2, 3)).weight).toStrictEqual(
expect(sequence(0, 1, 2, 3).linger(4).tactus).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).iter(4).tactus).toStrictEqual(Fraction(4));
expect(sequence(0, 1, 2, 3).fast(4).tactus).toStrictEqual(Fraction(16));
expect(sequence(0, 1, 2, 3).hurry(4).tactus).toStrictEqual(Fraction(16));
expect(sequence(0, 1, 2, 3).rev().tactus).toStrictEqual(Fraction(4));
expect(sequence(1).segment(10).tactus).toStrictEqual(Fraction(10));
expect(sequence(1, 0, 1).invert().tactus).toStrictEqual(Fraction(3));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).chop(4).tactus).toStrictEqual(Fraction(8));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).striate(4).tactus).toStrictEqual(Fraction(8));
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).slice(4, sequence(0, 1, 2, 3)).tactus).toStrictEqual(
Fraction(4),
);
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).splice(4, sequence(0, 1, 2, 3)).weight).toStrictEqual(
expect(sequence({ s: 'bev' }, { s: 'amenbreak' }).splice(4, sequence(0, 1, 2, 3)).tactus).toStrictEqual(
Fraction(4),
);
});

View file

@ -22,8 +22,4 @@ describe('Value', () => {
expect(valued(mul).ap(3).ap(3).value).toEqual(9);
expect(valued(3).mul(3).value).toEqual(9);
});
it('union bare numbers for numeral props', () => {
expect(n(3).cutoff(500).add(10).firstCycleValues).toEqual([{ n: 13, cutoff: 510 }]);
expect(n(3).cutoff(500).mul(2).firstCycleValues).toEqual([{ n: 6, cutoff: 1000 }]);
});
});

View file

@ -5,14 +5,13 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import { curry } from './util.mjs';
import { logger } from './logger.mjs';
export function unionWithObj(a, b, func) {
if (typeof b?.value === 'number') {
// https://github.com/tidalcycles/strudel/issues/262
const numKeys = Object.keys(a).filter((k) => typeof a[k] === 'number');
const numerals = Object.fromEntries(numKeys.map((k) => [k, b.value]));
b = Object.assign(b, numerals);
delete b.value;
if (b?.value !== undefined && Object.keys(b).length === 1) {
// https://github.com/tidalcycles/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])])));

View file

@ -26,8 +26,7 @@ function createClock(
// callback as long as we're inside the lookahead
while (phase < lookahead) {
phase = round ? Math.round(phase * precision) / precision : phase;
phase >= t && callback(phase, duration, tick, t);
phase < t && console.log('TOO LATE', phase); // what if latency is added from outside?
callback(phase, duration, tick, t); // callback has to skip / handle phase < t!
phase += duration; // increment phase by duration
tick++;
}

View file

@ -39,51 +39,36 @@ function stopAnimationFrame(id) {
function stopAllAnimations() {
Object.keys(animationFrames).forEach((id) => stopAnimationFrame(id));
}
Pattern.prototype.draw = function (callback, { id = 'std', from, to, onQuery, ctx } = {}) {
if (typeof window === 'undefined') {
return this;
}
stopAnimationFrame(id);
ctx = ctx || getDrawContext();
let cycle,
events = [];
const animate = (time) => {
const t = getTime();
if (from !== undefined && to !== undefined) {
const currentCycle = Math.floor(t);
if (cycle !== currentCycle) {
cycle = currentCycle;
const begin = currentCycle + from;
const end = currentCycle + to;
setTimeout(() => {
events = this.query(new State(new TimeSpan(begin, end)))
.filter(Boolean)
.filter((event) => event.part.begin.equals(event.whole.begin));
onQuery?.(events);
}, 0);
}
}
callback(ctx, events, t, time);
animationFrames[id] = requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
return this;
};
// this is a more generic helper to get a rendering callback for the currently active haps
// TODO: this misses events that are prolonged with clip or duration (would need state)
Pattern.prototype.onFrame = function (id, fn, offset = 0) {
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() + offset;
const haps = this.queryArc(t, t);
fn(haps, t, this);
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);
};
requestAnimationFrame(animate);
animationFrames[id] = requestAnimationFrame(animate);
return this;
};
@ -198,3 +183,18 @@ export class Drawer {
}
}
}
export function getComputedPropertyValue(name) {
if (typeof window === 'undefined') {
return '#fff';
}
return getComputedStyle(document.documentElement).getPropertyValue(name);
}
let theme = {};
export function getTheme() {
return theme;
}
export function setTheme(_theme) {
theme = _theme;
}

View file

@ -3,3 +3,4 @@ export * from './color.mjs';
export * from './draw.mjs';
export * from './pianoroll.mjs';
export * from './spiral.mjs';
export * from './pitchwheel.mjs';

View file

@ -5,6 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import { Pattern, noteToMidi, freqToMidi } from '@strudel/core';
import { getTheme, getDrawContext } from './draw.mjs';
const scale = (normalized, min, max) => normalized * (max - min) + min;
const getValue = (e) => {
@ -36,26 +37,23 @@ const getValue = (e) => {
};
Pattern.prototype.pianoroll = function (options = {}) {
let { cycles = 4, playhead = 0.5, overscan = 1, hideNegative = false, ctx, id } = 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(
(ctx, haps, t) => {
const inFrame = (event) =>
(!hideNegative || event.whole.begin >= 0) && event.whole.begin <= t + to && event.endClipped >= t + from;
(haps, time) => {
pianoroll({
...options,
time: t,
time,
ctx,
haps: haps.filter(inFrame),
haps: haps.filter((hap) => inFrame(hap, time)),
});
},
{
from: from - overscan,
to: to + overscan,
ctx,
lookbehind: from - overscan,
lookahead: to + overscan,
id,
},
);
@ -106,11 +104,8 @@ export function pianoroll({
flipTime = 0,
flipValues = 0,
hideNegative = false,
// inactive = '#C9E597',
// inactive = '#FFCA28',
inactive = '#7491D2',
active = '#FFCA28',
// background = '#2A3236',
inactive = getTheme().foreground,
active = getTheme().foreground,
background = 'transparent',
smear = 0,
playheadColor = 'white',
@ -137,7 +132,7 @@ export function pianoroll({
let to = cycles * (1 - playhead);
if (id) {
haps = haps.filter((hap) => hap.context.id === id);
haps = haps.filter((hap) => hap.hasTag(id));
}
if (timeframeProp) {
@ -277,8 +272,8 @@ export function getDrawOptions(drawTime, options = {}) {
export const getPunchcardPainter =
(options = {}) =>
(ctx, time, haps, drawTime, paintOptions = {}) =>
pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { ...paintOptions, ...options }) });
(ctx, time, haps, drawTime) =>
pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, options) });
Pattern.prototype.punchcard = function (options) {
return this.onPaint(getPunchcardPainter(options));

View file

@ -0,0 +1,127 @@
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;
}
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,
}),
);
};

View file

@ -1,4 +1,5 @@
import { Pattern } from '@strudel/core';
import { getTheme } from './draw.mjs';
// polar coords -> xy
function fromPolar(angle, radius, cx, cy) {
@ -19,7 +20,7 @@ function spiralSegment(options) {
cy = 100,
rotate = 0,
thickness = margin / 2,
color = 'steelblue',
color = getTheme().foreground,
cap = 'round',
stretch = 1,
fromOpacity = 1,
@ -61,7 +62,8 @@ function drawSpiral(options) {
playheadThickness = thickness,
padding = 0,
steady = 1,
inactiveColor = '#ffffff50',
activeColor = getTheme().foreground,
inactiveColor = getTheme().gutterForeground,
colorizeInactive = 0,
fade = true,
// logSpiral = true,
@ -73,7 +75,7 @@ function drawSpiral(options) {
} = options;
if (id) {
haps = haps.filter((hap) => hap.context.id === id);
haps = haps.filter((hap) => hap.hasTag(id));
}
const [w, h] = [ctx.canvas.width, ctx.canvas.height];
@ -102,7 +104,8 @@ function drawSpiral(options) {
const isActive = hap.whole.begin <= time && hap.endClipped > time;
const from = hap.whole.begin - time + inset;
const to = hap.endClipped - time + inset - padding;
const color = hap.value?.color;
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,
@ -110,7 +113,7 @@ function drawSpiral(options) {
from,
to,
rotate,
color: colorizeInactive || isActive ? color : inactiveColor,
color,
fromOpacity: opacity,
toOpacity: opacity,
});

View file

@ -308,11 +308,11 @@ function peg$parse(input, options) {
}
return result;
};
var peg$f17 = function(s) { return new PatternStub(s, 'fastcat'); };
var peg$f17 = function(tactus, s) { return new PatternStub(s, 'fastcat', undefined, !!tactus); };
var peg$f18 = function(tail) { return { alignment: 'stack', list: tail }; };
var peg$f19 = function(tail) { return { alignment: 'rand', list: tail, seed: seed++ }; };
var peg$f20 = function(tail) { return { alignment: 'feet', list: tail, seed: seed++ }; };
var peg$f21 = function(head, tail) { if (tail && tail.list.length > 0) { return new PatternStub([head, ...tail.list], tail.alignment, tail.seed); } else { return head; } };
var peg$f21 = function(head, tail) {if (tail && tail.list.length > 0) { return new PatternStub([head, ...tail.list], tail.alignment, tail.seed); } else { return head; } };
var peg$f22 = function(head, tail) { return new PatternStub(tail ? [head, ...tail.list] : [head], 'polymeter'); };
var peg$f23 = function(sc) { return sc; };
var peg$f24 = function(s) { return { name: "struct", args: { mini:s }}};
@ -1477,24 +1477,36 @@ function peg$parse(input, options) {
}
function peg$parsesequence() {
var s0, s1, s2;
var s0, s1, s2, s3;
s0 = peg$currPos;
s1 = [];
s2 = peg$parseslice_with_ops();
if (s2 !== peg$FAILED) {
while (s2 !== peg$FAILED) {
s1.push(s2);
s2 = peg$parseslice_with_ops();
}
if (input.charCodeAt(peg$currPos) === 94) {
s1 = peg$c9;
peg$currPos++;
} else {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e17); }
}
if (s1 !== peg$FAILED) {
if (s1 === peg$FAILED) {
s1 = null;
}
s2 = [];
s3 = peg$parseslice_with_ops();
if (s3 !== peg$FAILED) {
while (s3 !== peg$FAILED) {
s2.push(s3);
s3 = peg$parseslice_with_ops();
}
} else {
s2 = peg$FAILED;
}
if (s2 !== peg$FAILED) {
peg$savedPos = s0;
s1 = peg$f17(s1);
s0 = peg$f17(s1, s2);
} else {
peg$currPos = s0;
s0 = peg$FAILED;
}
s0 = s1;
return s0;
}
@ -2476,10 +2488,10 @@ function peg$parse(input, options) {
this.location_ = location();
}
var PatternStub = function(source, alignment, seed)
var PatternStub = function(source, alignment, seed, tactus)
{
this.type_ = "pattern";
this.arguments_ = { alignment: alignment };
this.arguments_ = { alignment: alignment, tactus: tactus };
if (seed !== undefined) {
this.arguments_.seed = seed;
}

View file

@ -19,10 +19,10 @@ This program is free software: you can redistribute it and/or modify it under th
this.location_ = location();
}
var PatternStub = function(source, alignment, seed)
var PatternStub = function(source, alignment, seed, tactus)
{
this.type_ = "pattern";
this.arguments_ = { alignment: alignment };
this.arguments_ = { alignment: alignment, tactus: tactus };
if (seed !== undefined) {
this.arguments_.seed = seed;
}
@ -165,8 +165,8 @@ slice_with_ops = s:slice ops:slice_op*
}
// a sequence is a combination of one or more successive slices (as an array)
sequence = s:(slice_with_ops)+
{ return new PatternStub(s, 'fastcat'); }
sequence = tactus:'^'? s:(slice_with_ops)+
{ return new PatternStub(s, 'fastcat', undefined, !!tactus); }
// a stack is a series of vertically aligned sequence, separated by a comma
stack_tail = tail:(comma @sequence)+
@ -184,7 +184,7 @@ dot_tail = tail:(dot @sequence)+
// 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; } }
{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'); }

View file

@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
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;
@ -88,45 +89,75 @@ export function patternifyAST(ast, code, onEnter, offset = 0) {
resolveReplications(ast);
const children = ast.source_.map((child) => enter(child)).map(applyOptions(ast, enter));
const alignment = ast.arguments_.alignment;
if (alignment === 'stack') {
return strudel.stack(...children);
}
if (alignment === 'polymeter_slowcat') {
const aligned = children.map((child) => child._slow(child.weight));
return strudel.stack(...aligned);
}
if (alignment === '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 with_tactus = children.filter((child) => child.__tactus_source);
let pat;
switch (alignment) {
case 'stack': {
pat = strudel.stack(...children);
if (with_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
}
break;
}
case 'polymeter_slowcat': {
pat = strudel.stack(...children.map((child) => child._slow(child.__weight)));
if (with_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
}
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))));
return strudel.stack(...aligned);
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_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
}
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.tactus = weightSum;
if (with_tactus.length) {
pat.tactus = pat.tactus.mul(lcm(...with_tactus.map((x) => Fraction(x.tactus))));
}
} else {
pat = strudel.sequence(...children);
pat.tactus = children.length;
}
if (ast.arguments_.tactus) {
pat.__tactus_source = true;
}
}
}
if (alignment === 'rand') {
return strudel.chooseInWith(strudel.rand.early(randOffset * ast.arguments_.seed).segment(1), children);
if (with_tactus.length) {
pat.__tactus_source = true;
}
if (alignment === 'feet') {
return strudel.fastcat(...children);
}
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),
);
const pat = strudel.timeCat(
...ast.source_.map((child, i) => [child.options_?.weight || strudel.Fraction(1), children[i]]),
);
pat.weight = weightSum;
return pat;
}
const pat = strudel.sequence(...children);
pat.weight = children.length;
return pat;
}
case 'element': {
1;
return enter(ast.source_);
}
case 'atom': {
@ -166,7 +197,7 @@ export const getLeafLocation = (code, leaf, globalOffset = 0) => {
};
// takes quoted mini string, returns ast
export const mini2ast = (code, start, userCode) => {
export const mini2ast = (code, start = 0, userCode = code) => {
try {
return krill.parse(code);
} catch (error) {

View file

@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { getLeafLocation, getLeafLocations, mini, mini2ast } from '../mini.mjs';
import '@strudel/core/euclid.mjs';
import { Fraction } from '@strudel/core/index.mjs';
import { describe, expect, it } from 'vitest';
describe('mini', () => {
@ -207,6 +208,15 @@ describe('mini', () => {
it('_ and @ are almost interchangeable', () => {
expect(minS('a @ b @ @')).toEqual(minS('a _2 b _3'));
});
it('supports ^ tactus marking', () => {
expect(mini('a [^b c]').tactus).toEqual(Fraction(4));
expect(mini('[a b c] [d [e f]]').tactus).toEqual(Fraction(2));
expect(mini('^[a b c] [d [e f]]').tactus).toEqual(Fraction(2));
expect(mini('[a b c] [d [^e f]]').tactus).toEqual(Fraction(8));
expect(mini('[a b c] [^d [e f]]').tactus).toEqual(Fraction(4));
expect(mini('[^a b c] [^d [e f]]').tactus).toEqual(Fraction(12));
expect(mini('[^a b c] [d [^e f]]').tactus).toEqual(Fraction(24));
});
});
describe('getLeafLocation', () => {

View file

@ -41,17 +41,8 @@ if (typeof HTMLElement !== 'undefined') {
initialCode: '// LOADING',
pattern: silence,
drawTime,
onDraw: (haps, time, frame, painters) => {
painters.length && drawContext.clearRect(0, 0, drawContext.canvas.width * 2, drawContext.canvas.height * 2);
painters?.forEach((painter) => {
// ctx time haps drawTime paintOptions
painter(drawContext, time, haps, drawTime, { clear: false });
});
},
drawContext,
prebake,
afterEval: ({ code }) => {
// window.location.hash = '#' + code2hash(code);
},
onUpdateState: (state) => {
const event = new CustomEvent('update', {
detail: state,

View file

@ -0,0 +1,22 @@
# @strudel/sampler
This package allows you to serve your samples on disk to the strudel REPL.
```sh
cd ~/your/samples/
npx @strudel/sampler
```
This will run a server on `http://localhost:5432`.
You can now load the samples via:
```js
samples('http://localhost:5432')
```
## Options
```sh
LOG=1 npx @strudel/sampler # adds logging
PORT=5555 npx @strudel/sampler # changes port
```

View file

@ -0,0 +1,19 @@
{
"name": "@strudel/sampler",
"version": "0.0.8",
"description": "",
"keywords": [
"tidalcycles",
"strudel",
"pattern",
"livecoding",
"algorave"
],
"author": "Felix Roos <flix91@gmail.com>",
"license": "AGPL-3.0-or-later",
"bin": "./sample-server.mjs",
"type": "module",
"dependencies": {
"cowsay": "^1.6.0"
}
}

View file

@ -0,0 +1,117 @@
#!/usr/bin/env node
import cowsay from 'cowsay';
import { createReadStream } from 'fs';
import { readdir } from 'fs/promises';
import http from 'http';
import { join } from 'path';
import os from 'os';
// eslint-disable-next-line
const LOG = !!process.env.LOG || false;
console.log(
cowsay.say({
text: 'welcome to @strudel/sampler',
e: 'oO',
T: 'U ',
}),
);
async function getFilesInDirectory(directory) {
let files = [];
const dirents = await readdir(directory, { withFileTypes: true });
for (const dirent of dirents) {
const fullPath = join(directory, dirent.name);
if (dirent.isDirectory()) {
if (dirent.name.startsWith('.')) {
LOG && console.warn(`ignore hidden folder: ${fullPath}`);
continue;
}
try {
const subFiles = (await getFilesInDirectory(fullPath)).filter((f) =>
['wav', 'mp3', 'ogg'].includes(f.split('.').slice(-1)[0].toLowerCase()),
);
files = files.concat(subFiles);
LOG && console.log(`${dirent.name} (${subFiles.length})`);
} catch (err) {
LOG && console.warn(`skipped due to error: ${fullPath}`);
}
} else {
files.push(fullPath);
}
}
return files;
}
async function getBanks(directory) {
// const directory = resolve(__dirname, '.');
let files = await getFilesInDirectory(directory);
let banks = {};
files = files.map((url) => {
const [bank] = url.split('/').slice(-2);
banks[bank] = banks[bank] || [];
url = url.replace(directory, '');
banks[bank].push(url);
return url;
});
banks._base = `http://localhost:5432`;
return { banks, files };
}
// eslint-disable-next-line
const directory = process.cwd();
const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
const { banks, files } = await getBanks(directory);
if (req.url === '/') {
res.setHeader('Content-Type', 'application/json');
return res.end(JSON.stringify(banks));
}
let subpath = decodeURIComponent(req.url);
if (!files.includes(subpath)) {
res.statusCode = 404;
res.end('File not found');
return;
}
const filePath = join(directory, subpath);
const readStream = createReadStream(filePath);
readStream.on('error', (err) => {
res.statusCode = 500;
res.end('Internal server error');
console.error(err);
});
readStream.pipe(res);
});
// eslint-disable-next-line
const PORT = process.env.PORT || 5432;
const IP_ADDRESS = '0.0.0.0';
let IP;
const networkInterfaces = os.networkInterfaces();
Object.keys(networkInterfaces).forEach((key) => {
networkInterfaces[key].forEach((networkInterface) => {
if (networkInterface.family === 'IPv4' && !networkInterface.internal) {
IP = networkInterface.address;
}
});
});
if (!IP) {
console.error("Unable to determine server's IP address.");
// eslint-disable-next-line
process.exit(1);
}
server.listen(PORT, IP_ADDRESS, () => {
console.log(`@strudel/sampler is now serving audio files from:
${directory}
To use them in the Strudel REPL, run:
samples('http://localhost:${PORT}')
Or on a machine in the same network:
samples('http://${IP}:${PORT}')
`);
});

View file

@ -194,6 +194,9 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option
if (sampleMap.startsWith('github:')) {
sampleMap = githubPath(sampleMap, 'strudel.json');
}
if (sampleMap.startsWith('local:')) {
sampleMap = `http://localhost:5432`;
}
if (sampleMap.startsWith('shabda:')) {
let [_, path] = sampleMap.split('shabda:');
sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`;

View file

@ -273,6 +273,12 @@ export const superdough = async (value, t, hapDuration) => {
value.duration = hapDuration;
// calculate absolute time
t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t;
if (t < ac.currentTime) {
console.warn(
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
);
return;
}
// destructure
let {
s = 'triangle',

View file

@ -235,6 +235,11 @@ Object.keys(simple).forEach((symbol) => {
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);

View file

@ -53,10 +53,12 @@ export function transpiler(input, options = {}) {
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: widgets.length,
type: node.callee.property.name,
index,
type,
};
emitWidgets && widgets.push(widgetConfig);
return this.replace(widgetWithLocation(node, widgetConfig));

View file

@ -1,5 +1,5 @@
import { Pattern, clamp } from '@strudel/core';
import { getDrawContext } from '../draw/index.mjs';
import { getDrawContext, getTheme } from '@strudel/draw';
import { analysers, getAnalyzerData } from 'superdough';
export function drawTimeScope(
@ -132,10 +132,13 @@ Pattern.prototype.fscope = function (config = {}) {
* @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);
},