From 6e02bf59e94024985d7f9f3dc2bdd3365a8f6a72 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 10 May 2023 11:44:55 +0200 Subject: [PATCH] Revert "refactor: remove old draw logic" This reverts commit 95719654f3bfc5d3cc98c33816b402e2c0e38bd7. --- packages/core/draw.mjs | 38 +++++++++- packages/core/index.mjs | 1 + packages/core/pianoroll.mjs | 134 +++++++++++++++++++++++++++++++++++- packages/core/repl.mjs | 2 + packages/core/time.mjs | 11 +++ packages/core/ui.mjs | 23 +++++++ website/src/repl/Repl.jsx | 5 +- 7 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 packages/core/time.mjs 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