jevstrudel.git / packages / draw / pitchwheel.mjs
1import { Pattern, midiToFreq, getFrequency } from '@strudel/core';
2import { getTheme, getDrawContext } from './draw.mjs';
3
4const c = midiToFreq(36);
5
6const circlePos = (cx, cy, radius, angle) => {
7  angle = angle * Math.PI * 2;
8  const x = Math.sin(angle) * radius + cx;
9  const y = Math.cos(angle) * radius + cy;
10  return [x, y];
11};
12
13const freq2angle = (freq, root) => {
14  return 0.5 - (Math.log2(freq / root) % 1);
15};
16
17export function pitchwheel({
18  haps,
19  ctx,
20  id,
21  hapcircles = 1,
22  circle = 0,
23  edo = 12,
24  root = c,
25  thickness = 3,
26  hapRadius = 6,
27  mode = 'flake',
28  margin = 10,
29} = {}) {
30  const connectdots = mode === 'polygon';
31  const centerlines = mode === 'flake';
32  const w = ctx.canvas.width;
33  const h = ctx.canvas.height;
34  ctx.clearRect(0, 0, w, h);
35  const color = getTheme().foreground;
36
37  const size = Math.min(w, h);
38  const radius = size / 2 - thickness / 2 - hapRadius - margin;
39  const centerX = w / 2;
40  const centerY = h / 2;
41
42  if (id) {
43    haps = haps.filter((hap) => hap.hasTag(id));
44  }
45  ctx.strokeStyle = color;
46  ctx.fillStyle = color;
47  ctx.globalAlpha = 1;
48  ctx.lineWidth = thickness;
49
50  if (circle) {
51    ctx.beginPath();
52    ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
53    ctx.stroke();
54  }
55
56  if (edo) {
57    edo = haps.length >= 1 && haps[0].value && haps[0].value.edo ? haps[0].value.edo : edo;
58    root = haps.length >= 1 && haps[0].value && haps[0].value.root ? haps[0].value.root : root;
59    const degreeIndexes =
60      haps.length >= 1 && haps[0].value && haps[0].value.degreeIndexes ? haps[0].value.degreeIndexes : null;
61    const intLabels = haps.length >= 1 && haps[0].value && haps[0].value.intLabels ? haps[0].value.intLabels : null;
62    ctx.font = '20px sans-serif';
63    const label = `${edo} EDO`;
64    // Draw EDO label:
65    ctx.fillText(label, centerX + radius - ctx.measureText(label).width + 15, centerY + radius);
66    Array.from({ length: edo }, (_, i) => {
67      const angle = freq2angle(root * Math.pow(2, i / edo), root);
68
69      const [x, y] = circlePos(centerX, centerY, radius, angle);
70      ctx.beginPath();
71      // Draw interval label for degree i when it exists:
72      if (degreeIndexes === null || degreeIndexes.includes(i)) {
73        ctx.globalAlpha = 1;
74        ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
75        if (intLabels !== null) {
76          const degree = degreeIndexes.indexOf(i);
77          if (intLabels[degree]) {
78            if (angle < 0.32 && angle > 0.125) {
79              ctx.fillText(intLabels[degree], x - 34, y);
80            } else {
81              if (angle < 0.1 && angle > -1.125) {
82                ctx.fillText(intLabels[degree], x - 7, y - 12);
83              } else {
84                ctx.fillText(intLabels[degree], x + 9, y);
85              }
86            }
87          }
88        }
89      } else {
90        ctx.globalAlpha = 0.15;
91        ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
92      }
93      ctx.fill();
94    });
95    ctx.stroke();
96  }
97
98  let shape = [];
99  ctx.lineWidth = hapRadius;
100  haps.forEach((hap) => {
101    let freq;
102    try {
103      freq = getFrequency(hap);
104    } catch (err) {
105      return;
106    }
107    const angle = freq2angle(freq, root);
108    const [x, y] = circlePos(centerX, centerY, radius, angle);
109    const hapColor = hap.value.color || color;
110    ctx.strokeStyle = hapColor;
111    ctx.fillStyle = hapColor;
112    const { velocity = 1, gain = 1 } = hap.value || {};
113    const alpha = velocity * gain;
114    ctx.globalAlpha = alpha;
115    shape.push([x, y, angle, hapColor, alpha]);
116    ctx.beginPath();
117    if (hapcircles) {
118      ctx.moveTo(x + hapRadius, y);
119      ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
120      ctx.fill();
121    }
122    if (centerlines) {
123      ctx.moveTo(centerX, centerY);
124      ctx.lineTo(x, y);
125    }
126    ctx.stroke();
127  });
128
129  ctx.strokeStyle = color;
130  ctx.globalAlpha = 1;
131  if (connectdots && shape.length) {
132    shape = shape.sort((a, b) => a[2] - b[2]);
133    ctx.beginPath();
134    ctx.moveTo(shape[0][0], shape[0][1]);
135    shape.forEach(([x, y, _, color, alpha]) => {
136      ctx.strokeStyle = color;
137      ctx.globalAlpha = alpha;
138      ctx.lineTo(x, y);
139    });
140    ctx.lineTo(shape[0][0], shape[0][1]);
141    ctx.stroke();
142  }
143
144  return;
145}
146
147/**
148 * Renders a pitch circle to visualize frequencies within one octave
149 * @name pitchwheel
150 * @tags visualization
151 * @param {number} hapcircles
152 * @param {number} circle
153 * @param {number} edo
154 * @param {string} root
155 * @param {number} thickness
156 * @param {number} hapRadius
157 * @param {string} mode
158 * @param {number} margin
159 * @example
160 * n("0 .. 12").scale("C:chromatic")
161 * .s("sawtooth")
162 * .lpf(500)
163 * ._pitchwheel()
164 */
165Pattern.prototype.pitchwheel = function (options = {}) {
166  let { ctx = getDrawContext(), id = 1 } = options;
167  return this.tag(id).onPaint((_, time, haps) =>
168    pitchwheel({
169      ...options,
170      time,
171      ctx,
172      haps: haps.filter((hap) => hap.isActive(time)),
173      id,
174    }),
175  );
176};