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