jevstrudel.git / packages / draw / pianoroll.mjs
1/*
2pianoroll.mjs - <short description TODO>
3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/canvas/pianoroll.mjs>
4This 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/>.
5*/
6
7import { Pattern, noteToMidi, freqToMidi, isPattern } from '@strudel/core';
8import { getTheme, getDrawContext } from './draw.mjs';
9
10const scale = (normalized, min, max) => normalized * (max - min) + min;
11const getValue = (e) => {
12  let { value } = e;
13  if (typeof e.value !== 'object') {
14    value = { value };
15  }
16  let { note, n, freq, s } = value;
17  if (freq) {
18    return freqToMidi(freq);
19  }
20  note = note ?? n;
21  if (typeof note === 'string') {
22    try {
23      // TODO: n(run(32)).scale("D:minor") fails when trying to query negative time..
24      return noteToMidi(note);
25    } catch (err) {
26      // console.warn(`error converting note to midi: ${err}`); // this spams to crazy
27      return 0;
28    }
29  }
30  if (typeof note === 'number') {
31    return note;
32  }
33  if (s) {
34    return '_' + s;
35  }
36  return value;
37};
38
39/**
40 * 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
41 * a pattern instead, prefix with `_`, e.g.: `sound("bd sd")._pianoroll()`.
42 *
43 * @name pianoroll
44 * @synonyms punchcard
45 * @tags visualization
46 * @param {Object} options Object containing all the optional following parameters as key value pairs:
47 * @param {integer} cycles number of cycles to be displayed at the same time - defaults to 4
48 * @param {number} playhead location of the active notes on the time axis - 0 to 1, defaults to 0.5
49 * @param {boolean} vertical displays the roll vertically - 0 by default
50 * @param {boolean} labels displays labels on individual notes (see the label function) - 0 by default
51 * @param {boolean} flipTime reverse the direction of the roll - 0 by default
52 * @param {boolean} flipValues reverse the relative location of notes on the value axis - 0 by default
53 * @param {number} overscan lookup X cycles outside of the cycles window to display notes in advance - 1 by default
54 * @param {boolean} hideNegative hide notes with negative time (before starting playing the pattern) - 0 by default
55 * @param {boolean} smear notes leave a solid trace - 0 by default
56 * @param {boolean} fold notes takes the full value axis width - 0 by default
57 * @param {string} active hexadecimal or CSS color of the active notes - defaults to #FFCA28
58 * @param {string} inactive hexadecimal or CSS color of the inactive notes - defaults to #7491D2
59 * @param {string} background hexadecimal or CSS color of the background - defaults to transparent
60 * @param {string} playheadColor hexadecimal or CSS color of the line representing the play head - defaults to white
61 * @param {boolean} fill notes are filled with color (otherwise only the label is displayed) - 0 by default
62 * @param {boolean} fillActive active notes are filled with color - 0 by default
63 * @param {boolean} stroke notes are shown with colored borders - 0 by default
64 * @param {boolean} strokeActive active notes are shown with colored borders - 0 by default
65 * @param {boolean} hideInactive only active notes are shown - 0 by default
66 * @param {boolean} colorizeInactive use note color for inactive notes - 1 by default
67 * @param {string} fontFamily define the font used by notes labels - defaults to 'monospace'
68 * @param {integer} minMidi minimum note value to display on the value axis - defaults to 10
69 * @param {integer} maxMidi maximum note value to display on the value axis - defaults to 90
70 * @param {boolean} autorange automatically calculate the minMidi and maxMidi parameters - 0 by default
71 * @see _pianoroll
72 * @example
73 * note("c2 a2 eb2")
74 * .euclid(5,8)
75 * .s('sawtooth')
76 * .lpenv(4).lpf(300)
77 * .pianoroll({ labels: 1 })
78 */
79
80Pattern.prototype.pianoroll = function (options = {}) {
81  let { cycles = 4, playhead = 0.5, overscan = 0, hideNegative = false, ctx = getDrawContext(), id = 1 } = options;
82
83  let from = -cycles * playhead;
84  let to = cycles * (1 - playhead);
85  const inFrame = (hap, t) => (!hideNegative || hap.whole.begin >= 0) && hap.isWithinTime(t + from, t + to);
86  this.draw(
87    (haps, time) => {
88      __pianoroll({
89        ...options,
90        time,
91        ctx,
92        haps: haps.filter((hap) => inFrame(hap, time)),
93      });
94    },
95    {
96      lookbehind: from - overscan,
97      lookahead: to + overscan,
98      id,
99    },
100  );
101  return this;
102};
103
104export function pianoroll(arg) {
105  if (isPattern(arg)) {
106    // Single argument as a pattern
107    // (to support `all(pianoroll)`)
108    return arg.pianoroll();
109  }
110  // Single argument with option - return function to get the pattern
111  // (to support `all(pianoroll(options))`)
112  return (pat) => pat.pianoroll(arg);
113}
114
115export function __pianoroll({
116  time,
117  haps,
118  cycles = 4,
119  playhead = 0.5,
120  flipTime = 0,
121  flipValues = 0,
122  hideNegative = false,
123  inactive = getTheme().foreground,
124  active = getTheme().foreground,
125  background = 'transparent',
126  smear = 0,
127  playheadColor = getTheme().foreground,
128  minMidi = 10,
129  maxMidi = 90,
130  autorange = 0,
131  timeframe: timeframeProp,
132  fold = 1,
133  vertical = 0,
134  labels = false,
135  fill = 1,
136  fillActive = false,
137  strokeActive = true,
138  stroke,
139  hideInactive = 0,
140  colorizeInactive = 1,
141  fontFamily,
142  ctx,
143  id,
144} = {}) {
145  const w = ctx.canvas.width;
146  const h = ctx.canvas.height;
147  let from = -cycles * playhead;
148  let to = cycles * (1 - playhead);
149
150  if (id) {
151    haps = haps.filter((hap) => hap.hasTag(id));
152  }
153
154  if (timeframeProp) {
155    console.warn('timeframe is deprecated! use from/to instead');
156    from = 0;
157    to = timeframeProp;
158  }
159  const timeAxis = vertical ? h : w;
160  const valueAxis = vertical ? w : h;
161  let timeRange = vertical ? [timeAxis, 0] : [0, timeAxis]; // pixel range for time
162  const timeExtent = to - from; // number of seconds that fit inside the canvas frame
163  const valueRange = vertical ? [0, valueAxis] : [valueAxis, 0]; // pixel range for values
164  let valueExtent = maxMidi - minMidi + 1; // number of "slots" for values, overwritten if autorange true
165  let barThickness = valueAxis / valueExtent; // pixels per value, overwritten if autorange true
166  let foldValues = [];
167  flipTime && timeRange.reverse();
168  flipValues && valueRange.reverse();
169
170  // onQuery
171  const { min, max, values } = haps.reduce(
172    ({ min, max, values }, e) => {
173      const v = getValue(e);
174      return {
175        min: v < min ? v : min,
176        max: v > max ? v : max,
177        values: values.includes(v) ? values : [...values, v],
178      };
179    },
180    { min: Infinity, max: -Infinity, values: [] },
181  );
182  if (autorange) {
183    minMidi = min;
184    maxMidi = max;
185    valueExtent = maxMidi - minMidi + 1;
186  }
187  foldValues = values.sort((a, b) =>
188    typeof a === 'number' && typeof b === 'number'
189      ? a - b
190      : typeof a === 'number'
191        ? 1
192        : String(a).localeCompare(String(b)),
193  );
194  barThickness = fold ? valueAxis / foldValues.length : valueAxis / valueExtent;
195  ctx.fillStyle = background;
196  ctx.globalAlpha = 1; // reset!
197  if (!smear) {
198    ctx.clearRect(0, 0, w, h);
199    ctx.fillRect(0, 0, w, h);
200  }
201  haps.forEach((event) => {
202    const isActive = event.whole.begin <= time && event.endClipped > time;
203    let strokeCurrent = stroke ?? (strokeActive && isActive);
204    let fillCurrent = (!isActive && fill) || (isActive && fillActive);
205    if (hideInactive && !isActive) {
206      return;
207    }
208    let color = event.value?.color;
209    active = color || active;
210    inactive = colorizeInactive ? color || inactive : inactive;
211    color = isActive ? active : inactive;
212    ctx.fillStyle = fillCurrent ? color : 'transparent';
213    ctx.strokeStyle = color;
214    const { velocity = 1, gain = 1 } = event.value || {};
215    ctx.globalAlpha = velocity * gain;
216    const timeProgress = (event.whole.begin - (flipTime ? to : from)) / timeExtent;
217    const timePx = scale(timeProgress, ...timeRange);
218    let durationPx = scale(event.duration / timeExtent, 0, timeAxis);
219    const value = getValue(event);
220    const valueProgress = fold
221      ? foldValues.indexOf(value) / foldValues.length
222      : (Number(value) - minMidi) / valueExtent;
223    const valuePx = scale(valueProgress, ...valueRange);
224    let margin = 0;
225    const offset = scale(time / timeExtent, ...timeRange);
226    let coords;
227    if (vertical) {
228      coords = [
229        valuePx + 1 - (flipValues ? barThickness : 0), // x
230        timeAxis - offset + timePx + margin + 1 - (flipTime ? 0 : durationPx), // y
231        barThickness - 2, // width
232        durationPx - 2, // height
233      ];
234    } else {
235      coords = [
236        timePx - offset + margin + 1 - (flipTime ? durationPx : 0), // x
237        valuePx + 1 - (flipValues ? 0 : barThickness), // y
238        durationPx - 2, // widith
239        barThickness - 2, // height
240      ];
241    }
242    /* const xFactor = Math.sin(performance.now() / 500) + 1;
243      coords[0] *= xFactor; */
244
245    if (strokeCurrent) {
246      ctx.strokeRect(...coords);
247    }
248    if (fillCurrent) {
249      ctx.fillRect(...coords);
250    }
251    //ctx.ellipse(...ellipseFromRect(...coords))
252    if (labels) {
253      const defaultLabel = event.value.note ?? event.value.s + (event.value.n ? `:${event.value.n}` : '');
254      const { label: inactiveLabel, activeLabel } = event.value;
255      const customLabel = isActive ? activeLabel || inactiveLabel : inactiveLabel;
256      const label = customLabel ?? defaultLabel;
257      let measure = vertical ? durationPx : barThickness * 0.75;
258      ctx.font = `${measure}px ${fontFamily || 'monospace'}`;
259      // font color
260      ctx.fillStyle = /* isActive &&  */ !fillCurrent ? color : 'black';
261      ctx.textBaseline = 'top';
262      ctx.fillText(label, ...coords);
263    }
264  });
265  ctx.globalAlpha = 1; // reset!
266  const playheadPosition = scale(-from / timeExtent, ...timeRange);
267  // draw playhead
268  ctx.strokeStyle = playheadColor;
269  ctx.beginPath();
270  if (vertical) {
271    ctx.moveTo(0, playheadPosition);
272    ctx.lineTo(valueAxis, playheadPosition);
273  } else {
274    ctx.moveTo(playheadPosition, 0);
275    ctx.lineTo(playheadPosition, valueAxis);
276  }
277  ctx.stroke();
278  return this;
279}
280
281export function getDrawOptions(drawTime, options = {}) {
282  let [lookbehind, lookahead] = drawTime;
283  lookbehind = Math.abs(lookbehind);
284  const cycles = lookahead + lookbehind;
285  const playhead = cycles !== 0 ? lookbehind / cycles : 0;
286  return { fold: 1, ...options, cycles, playhead };
287}
288
289export const getPunchcardPainter =
290  (options = {}) =>
291  (ctx, time, haps, drawTime) =>
292    __pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, options) });
293
294Pattern.prototype.punchcard = function (options) {
295  return this.onPaint(getPunchcardPainter(options));
296};
297
298/**
299 * Displays a vertical pianoroll with event labels.
300 * Supports all the same options as pianoroll.
301 *
302 * @name wordfall
303 * @tags visualization
304 */
305Pattern.prototype.wordfall = function (options) {
306  return this.punchcard({ vertical: 1, labels: 1, stroke: 0, fillActive: 1, active: 'white', ...options });
307};
308
309/* Pattern.prototype.pianoroll = function (options) {
310  return this.onPaint((ctx, time, haps, drawTime) =>
311    pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { fold: 0, ...options }) }),
312  );
313}; */
314
315export function drawPianoroll(options) {
316  const { drawTime, ...rest } = options;
317  __pianoroll({ ...getDrawOptions(drawTime), ...rest });
318}