diff --git a/packages/codemirror/widget.mjs b/packages/codemirror/widget.mjs index 1d86dc35..ae62b842 100644 --- a/packages/codemirror/widget.mjs +++ b/packages/codemirror/widget.mjs @@ -126,3 +126,10 @@ registerWidget('_scope', (id, options = {}, pat) => { const ctx = getCanvasWidget(id, options).getContext('2d'); return pat.tag(id).scope({ ...options, ctx, id }); }); + +registerWidget('_pitchwheel', (id, options = {}, pat) => { + let _size = options.size || 200; + options = { width: _size, height: _size, ...options, size: _size / 5 }; + const ctx = getCanvasWidget(id, options).getContext('2d'); + return pat.tag(id).pitchwheel({ ...options, ctx, id }); +}); diff --git a/packages/draw/index.mjs b/packages/draw/index.mjs index 89cda805..506c6151 100644 --- a/packages/draw/index.mjs +++ b/packages/draw/index.mjs @@ -3,3 +3,4 @@ export * from './color.mjs'; export * from './draw.mjs'; export * from './pianoroll.mjs'; export * from './spiral.mjs'; +export * from './pitchwheel.mjs'; diff --git a/packages/draw/pitchwheel.mjs b/packages/draw/pitchwheel.mjs new file mode 100644 index 00000000..c0c79118 --- /dev/null +++ b/packages/draw/pitchwheel.mjs @@ -0,0 +1,114 @@ +import { Pattern, midiToFreq } from '@strudel/core'; +import { getTheme, getDrawContext } from './draw.mjs'; + +const c = midiToFreq(36); + +const circlePos = (cx, cy, radius, angle) => { + angle = angle * Math.PI * 2; + const x = Math.sin(angle) * radius + cx; + const y = Math.cos(angle) * radius + cy; + return [x, y]; +}; + +const freq2angle = (freq, root) => { + return 0.5 - (Math.log2(freq / root) % 1); +}; + +export function pitchwheel({ + time, + haps, + ctx, + id, + connectdots = 0, + centerlines = 1, + circle = 0, + edo = 12, + root = c, +} = {}) { + const w = ctx.canvas.width; + const h = ctx.canvas.height; + ctx.clearRect(0, 0, w, h); + const color = getTheme().foreground; + const hapRadius = 10; + const margin = 10; + + const size = Math.min(w, h); + const thickness = 4; + const radius = size / 2 - thickness / 2 - hapRadius - margin; + const centerX = w / 2; + const centerY = h / 2; + + if (id) { + haps = haps.filter((hap) => hap.hasTag(id)); + } + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.globalAlpha = 1; + ctx.lineWidth = thickness; + + if (circle) { + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); + ctx.stroke(); + } + + if (edo) { + Array.from({ length: edo }, (_, i) => { + ctx.beginPath(); + const angle = freq2angle(root * Math.pow(2, i / edo), root); + const [x, y] = circlePos(centerX, centerY, radius, angle); + ctx.arc(x, y, hapRadius, 0, 2 * Math.PI); + ctx.stroke(); + }); + } + + let shape = []; + haps.forEach((hap) => { + const freq = getFrequency(hap); + const angle = freq2angle(freq, root); + const [x, y] = circlePos(centerX, centerY, radius, angle); + const hapColor = hap.value.color || color; + shape.push([x, y]); + ctx.strokeStyle = hapColor; + ctx.fillStyle = hapColor; + const { velocity = 1, gain = 1 } = hap.value || {}; + ctx.globalAlpha = velocity * gain; + ctx.beginPath(); + ctx.moveTo(x + hapRadius, y); + ctx.arc(x, y, hapRadius, 0, 2 * Math.PI); + ctx.fill(); + if (centerlines) { + ctx.moveTo(centerX, centerY); + ctx.lineTo(x, y); + } + ctx.stroke(); + }); + + ctx.strokeStyle = color; + ctx.globalAlpha = 1; + if (shape.length && connectdots) { + ctx.beginPath(); + ctx.moveTo(shape[0][0], shape[0][1]); + shape.forEach(([x, y]) => { + ctx.lineTo(x, y); + }); + ctx.lineTo(shape[0][0], shape[0][1]); + ctx.stroke(); + } + + return; +} + +Pattern.prototype.pitchwheel = function (options = {}) { + let { ctx = getDrawContext(), id = 1 } = options; + this.onPaint((_, time, haps) => + pitchwheel({ + ...options, + time, + ctx, + haps: haps.filter((hap) => hap.isActive(time)), + id, + }), + ); + return this; +};