diff --git a/packages/core/draw.mjs b/packages/core/draw.mjs
index 58b14040..1b5c87e7 100644
--- a/packages/core/draw.mjs
+++ b/packages/core/draw.mjs
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see .
*/
-import { Pattern } from './index.mjs';
+import { Pattern, getTime, State, TimeSpan } from './index.mjs';
export const getDrawContext = (id = 'test-canvas') => {
let canvas = document.querySelector('#' + id);
@@ -19,9 +19,45 @@ export const getDrawContext = (id = 'test-canvas') => {
return canvas.getContext('2d');
};
+Pattern.prototype.draw = function (callback, { from, to, onQuery }) {
+ if (window.strudelAnimation) {
+ cancelAnimationFrame(window.strudelAnimation);
+ }
+ const 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);
+ window.strudelAnimation = requestAnimationFrame(animate);
+ };
+ requestAnimationFrame(animate);
+ return this;
+};
+
export const cleanupDraw = (clearScreen = true) => {
const ctx = getDrawContext();
clearScreen && ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
+ if (window.strudelAnimation) {
+ cancelAnimationFrame(window.strudelAnimation);
+ }
+ if (window.strudelScheduler) {
+ clearInterval(window.strudelScheduler);
+ }
};
Pattern.prototype.onPaint = function (onPaint) {
diff --git a/packages/core/index.mjs b/packages/core/index.mjs
index 78241f74..16ef3be4 100644
--- a/packages/core/index.mjs
+++ b/packages/core/index.mjs
@@ -20,6 +20,7 @@ export * from './evaluate.mjs';
export * from './repl.mjs';
export * from './cyclist.mjs';
export * from './logger.mjs';
+export * from './time.mjs';
export * from './draw.mjs';
export * from './animate.mjs';
export * from './pianoroll.mjs';
diff --git a/packages/core/pianoroll.mjs b/packages/core/pianoroll.mjs
index 336bd428..59476015 100644
--- a/packages/core/pianoroll.mjs
+++ b/packages/core/pianoroll.mjs
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see .
*/
-import { Pattern, noteToMidi, freqToMidi } from './index.mjs';
+import { Pattern, noteToMidi, getDrawContext, freqToMidi, isNote } from './index.mjs';
const scale = (normalized, min, max) => normalized * (max - min) + min;
const getValue = (e) => {
@@ -29,6 +29,134 @@ const getValue = (e) => {
return value;
};
+Pattern.prototype.pianoroll = function ({
+ cycles = 4,
+ playhead = 0.5,
+ overscan = 1,
+ flipTime = 0,
+ flipValues = 0,
+ hideNegative = false,
+ // inactive = '#C9E597',
+ // inactive = '#FFCA28',
+ inactive = '#7491D2',
+ active = '#FFCA28',
+ // background = '#2A3236',
+ background = 'transparent',
+ smear = 0,
+ playheadColor = 'white',
+ minMidi = 10,
+ maxMidi = 90,
+ autorange = 0,
+ timeframe: timeframeProp,
+ fold = 0,
+ vertical = 0,
+} = {}) {
+ const ctx = getDrawContext();
+ const w = ctx.canvas.width;
+ const h = ctx.canvas.height;
+ let from = -cycles * playhead;
+ let to = cycles * (1 - playhead);
+
+ if (timeframeProp) {
+ console.warn('timeframe is deprecated! use from/to instead');
+ from = 0;
+ to = timeframeProp;
+ }
+ 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();
+
+ this.draw(
+ (ctx, events, t) => {
+ ctx.fillStyle = background;
+ ctx.globalAlpha = 1; // reset!
+ if (!smear) {
+ ctx.clearRect(0, 0, w, h);
+ ctx.fillRect(0, 0, w, h);
+ }
+ const inFrame = (event) =>
+ (!hideNegative || event.whole.begin >= 0) && event.whole.begin <= t + to && event.whole.end >= t + from;
+ events.filter(inFrame).forEach((event) => {
+ const isActive = event.whole.begin <= t && event.whole.end > t;
+ ctx.fillStyle = event.context?.color || inactive;
+ ctx.strokeStyle = event.context?.color || active;
+ ctx.globalAlpha = event.context.velocity ?? 1;
+ const timePx = scale((event.whole.begin - (flipTime ? to : from)) / timeExtent, ...timeRange);
+ let durationPx = scale(event.duration / timeExtent, 0, timeAxis);
+ const value = getValue(event);
+ const valuePx = scale(
+ fold ? foldValues.indexOf(value) / foldValues.length : (Number(value) - minMidi) / valueExtent,
+ ...valueRange,
+ );
+ let margin = 0;
+ const offset = scale(t / timeExtent, ...timeRange);
+ let coords;
+ if (vertical) {
+ coords = [
+ valuePx + 1 - (flipValues ? barThickness : 0), // x
+ timeAxis - offset + timePx + margin + 1 - (flipTime ? 0 : durationPx), // y
+ barThickness - 2, // width
+ durationPx - 2, // height
+ ];
+ } else {
+ coords = [
+ timePx - offset + margin + 1 - (flipTime ? durationPx : 0), // x
+ valuePx + 1 - (flipValues ? 0 : barThickness), // y
+ durationPx - 2, // widith
+ barThickness - 2, // height
+ ];
+ }
+ isActive ? ctx.strokeRect(...coords) : ctx.fillRect(...coords);
+ });
+ 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();
+ },
+ {
+ from: from - overscan,
+ to: to + overscan,
+ onQuery: (events) => {
+ const { min, max, values } = events.reduce(
+ ({ min, max, values }, e) => {
+ const v = getValue(e);
+ return {
+ min: v < min ? v : min,
+ max: v > max ? v : max,
+ values: values.includes(v) ? values : [...values, v],
+ };
+ },
+ { min: Infinity, max: -Infinity, values: [] },
+ );
+ if (autorange) {
+ minMidi = min;
+ maxMidi = max;
+ valueExtent = maxMidi - minMidi + 1;
+ }
+ foldValues = values.sort((a, b) => String(a).localeCompare(String(b)));
+ barThickness = fold ? valueAxis / foldValues.length : valueAxis / valueExtent;
+ },
+ },
+ );
+ return this;
+};
+
// this function allows drawing a pianoroll without ties to Pattern.prototype
// it will probably replace the above in the future
export function pianoroll({
@@ -170,11 +298,11 @@ Pattern.prototype.punchcard = function (options) {
);
};
-Pattern.prototype.pianoroll = function (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;
diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs
index abb9ef80..c88145d1 100644
--- a/packages/core/repl.mjs
+++ b/packages/core/repl.mjs
@@ -1,6 +1,7 @@
import { Cyclist } from './cyclist.mjs';
import { evaluate as _evaluate } from './evaluate.mjs';
import { logger } from './logger.mjs';
+import { setTime } from './time.mjs';
import { evalScope } from './evaluate.mjs';
export function repl({
@@ -26,6 +27,7 @@ export function repl({
pattern = editPattern?.(pattern) || pattern;
scheduler.setPattern(pattern, autostart);
};
+ setTime(() => scheduler.now()); // TODO: refactor?
const evaluate = async (code, autostart = true) => {
if (!code) {
throw new Error('no code to evaluate');
diff --git a/packages/core/time.mjs b/packages/core/time.mjs
new file mode 100644
index 00000000..80daaf53
--- /dev/null
+++ b/packages/core/time.mjs
@@ -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;
+}
diff --git a/packages/core/ui.mjs b/packages/core/ui.mjs
index cc148553..df8230ec 100644
--- a/packages/core/ui.mjs
+++ b/packages/core/ui.mjs
@@ -4,6 +4,19 @@ Copyright (C) 2022 Strudel contributors - see .
*/
+import { getTime } from './time.mjs';
+
+function frame(callback) {
+ if (window.strudelAnimation) {
+ cancelAnimationFrame(window.strudelAnimation);
+ }
+ const animate = (animationTime) => {
+ callback(animationTime, getTime());
+ window.strudelAnimation = requestAnimationFrame(animate);
+ };
+ requestAnimationFrame(animate);
+}
+
export const backgroundImage = function (src, animateOptions = {}) {
const container = document.getElementById('code');
const bg = 'background-image:url(' + src + ');background-size:contain;';
@@ -15,8 +28,18 @@ export const backgroundImage = function (src, animateOptions = {}) {
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;
+ }
+ frame((_, t) =>
+ funcOptions.forEach(([option, value]) => {
+ handleOption(option, value(t));
+ }),
+ );
};
export const cleanupUi = () => {
diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx
index d8270b16..4ad387fe 100644
--- a/website/src/repl/Repl.jsx
+++ b/website/src/repl/Repl.jsx
@@ -50,9 +50,10 @@ const modulesLoading = evalScope(
const presets = prebake();
-let drawContext;
+let drawContext, clearCanvas;
if (typeof window !== 'undefined') {
drawContext = getDrawContext();
+ clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
}
const getTime = () => getAudioContext().currentTime;
@@ -207,7 +208,7 @@ export function Repl({ embedded = false }) {
const handleShuffle = async () => {
const { code, name } = getRandomTune();
logger(`[repl] ✨ loading random tune "${name}"`);
- cleanupDraw();
+ clearCanvas();
resetLoadedSounds();
scheduler.setCps(1);
await prebake(); // declare default samples