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};