diff --git a/packages/canvas/pianoroll.mjs b/packages/canvas/pianoroll.mjs new file mode 100644 index 00000000..0cfbdd35 --- /dev/null +++ b/packages/canvas/pianoroll.mjs @@ -0,0 +1,292 @@ +/* +pianoroll.mjs - +Copyright (C) 2022 Strudel contributors - see +This 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 . +*/ + +import { Pattern, noteToMidi, freqToMidi } from '@strudel/core'; + +const scale = (normalized, min, max) => normalized * (max - min) + min; +const getValue = (e) => { + let { value } = e; + if (typeof e.value !== 'object') { + value = { value }; + } + let { note, n, freq, s } = value; + if (freq) { + return freqToMidi(freq); + } + note = note ?? n; + if (typeof note === 'string') { + return noteToMidi(note); + } + if (typeof note === 'number') { + return note; + } + if (s) { + return '_' + s; + } + return value; +}; + +Pattern.prototype.pianoroll = function (options = {}) { + let { cycles = 4, playhead = 0.5, overscan = 1, hideNegative = false } = options; + + let from = -cycles * playhead; + let to = cycles * (1 - playhead); + + this.draw( + (ctx, haps, t) => { + const inFrame = (event) => + (!hideNegative || event.whole.begin >= 0) && event.whole.begin <= t + to && event.endClipped >= t + from; + pianoroll({ + ...options, + time: t, + ctx, + haps: haps.filter(inFrame), + }); + }, + { + from: from - overscan, + to: to + overscan, + }, + ); + return this; +}; + +// this function allows drawing a pianoroll without ties to Pattern.prototype +// it will probably replace the above in the future + +/** + * Displays a midi-style piano roll + * + * @name pianoroll + * @param {Object} options Object containing all the optional following parameters as key value pairs: + * @param {integer} cycles number of cycles to be displayed at the same time - defaults to 4 + * @param {number} playhead location of the active notes on the time axis - 0 to 1, defaults to 0.5 + * @param {boolean} vertical displays the roll vertically - 0 by default + * @param {boolean} labels displays labels on individual notes (see the label function) - 0 by default + * @param {boolean} flipTime reverse the direction of the roll - 0 by default + * @param {boolean} flipValues reverse the relative location of notes on the value axis - 0 by default + * @param {number} overscan lookup X cycles outside of the cycles window to display notes in advance - 1 by default + * @param {boolean} hideNegative hide notes with negative time (before starting playing the pattern) - 0 by default + * @param {boolean} smear notes leave a solid trace - 0 by default + * @param {boolean} fold notes takes the full value axis width - 0 by default + * @param {string} active hexadecimal or CSS color of the active notes - defaults to #FFCA28 + * @param {string} inactive hexadecimal or CSS color of the inactive notes - defaults to #7491D2 + * @param {string} background hexadecimal or CSS color of the background - defaults to transparent + * @param {string} playheadColor hexadecimal or CSS color of the line representing the play head - defaults to white + * @param {boolean} fill notes are filled with color (otherwise only the label is displayed) - 0 by default + * @param {boolean} fillActive active notes are filled with color - 0 by default + * @param {boolean} stroke notes are shown with colored borders - 0 by default + * @param {boolean} strokeActive active notes are shown with colored borders - 0 by default + * @param {boolean} hideInactive only active notes are shown - 0 by default + * @param {boolean} colorizeInactive use note color for inactive notes - 1 by default + * @param {string} fontFamily define the font used by notes labels - defaults to 'monospace' + * @param {integer} minMidi minimum note value to display on the value axis - defaults to 10 + * @param {integer} maxMidi maximum note value to display on the value axis - defaults to 90 + * @param {boolean} autorange automatically calculate the minMidi and maxMidi parameters - 0 by default + * + * @example + * note("C2 A2 G2").euclid(5,8).s('piano').clip(1).color('salmon').pianoroll({vertical:1, labels:1}) + */ +export function pianoroll({ + time, + haps, + cycles = 4, + playhead = 0.5, + flipTime = 0, + flipValues = 0, + hideNegative = false, + // inactive = '#C9E597', + // inactive = '#FFCA28', + inactive = '#7491D2', + active = '#FFCA28', + // background = '#2A3236', + background = 'transparent', + smear = 0, + playheadColor = 'white', + minMidi = 10, + maxMidi = 90, + autorange = 0, + timeframe: timeframeProp, + fold = 0, + vertical = 0, + labels = false, + fill = 1, + fillActive = false, + strokeActive = true, + stroke, + hideInactive = 0, + colorizeInactive = 1, + fontFamily, + ctx, +} = {}) { + const w = ctx.canvas.width; + const h = ctx.canvas.height; + let from = -cycles * playhead; + let to = cycles * (1 - playhead); + + if (timeframeProp) { + console.warn('timeframe is deprecated! use from/to instead'); + from = 0; + to = timeframeProp; + } + 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(); + + // onQuery + const { min, max, values } = haps.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) => + typeof a === 'number' && typeof b === 'number' + ? a - b + : typeof a === 'number' + ? 1 + : String(a).localeCompare(String(b)), + ); + barThickness = fold ? valueAxis / foldValues.length : valueAxis / valueExtent; + ctx.fillStyle = background; + ctx.globalAlpha = 1; // reset! + if (!smear) { + ctx.clearRect(0, 0, w, h); + ctx.fillRect(0, 0, w, h); + } + haps.forEach((event) => { + const isActive = event.whole.begin <= time && event.endClipped > time; + let strokeCurrent = stroke ?? (strokeActive && isActive); + let fillCurrent = (!isActive && fill) || (isActive && fillActive); + if (hideInactive && !isActive) { + return; + } + let color = event.value?.color || event.context?.color; + active = color || active; + inactive = colorizeInactive ? color || inactive : inactive; + color = isActive ? active : inactive; + ctx.fillStyle = fillCurrent ? color : 'transparent'; + ctx.strokeStyle = color; + ctx.globalAlpha = event.value?.velocity ?? event.value?.gain ?? 1; + const timeProgress = (event.whole.begin - (flipTime ? to : from)) / timeExtent; + const timePx = scale(timeProgress, ...timeRange); + let durationPx = scale(event.duration / timeExtent, 0, timeAxis); + const value = getValue(event); + const valueProgress = fold + ? foldValues.indexOf(value) / foldValues.length + : (Number(value) - minMidi) / valueExtent; + const valuePx = scale(valueProgress, ...valueRange); + let margin = 0; + const offset = scale(time / 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 + ]; + } + /* const xFactor = Math.sin(performance.now() / 500) + 1; + coords[0] *= xFactor; */ + + if (strokeCurrent) { + ctx.strokeRect(...coords); + } + if (fillCurrent) { + ctx.fillRect(...coords); + } + //ctx.ellipse(...ellipseFromRect(...coords)) + if (labels) { + const defaultLabel = event.value.note ?? event.value.s + (event.value.n ? `:${event.value.n}` : ''); + const { label: inactiveLabel, activeLabel } = event.value; + const customLabel = isActive ? activeLabel || inactiveLabel : inactiveLabel; + const label = customLabel ?? defaultLabel; + let measure = vertical ? durationPx : barThickness * 0.75; + ctx.font = `${measure}px ${fontFamily || 'monospace'}`; + // font color + ctx.fillStyle = /* isActive && */ !fillCurrent ? color : 'black'; + ctx.textBaseline = 'top'; + ctx.fillText(label, ...coords); + } + }); + ctx.globalAlpha = 1; // reset! + const playheadPosition = scale(-from / timeExtent, ...timeRange); + // draw playhead + ctx.strokeStyle = playheadColor; + ctx.beginPath(); + if (vertical) { + ctx.moveTo(0, playheadPosition); + ctx.lineTo(valueAxis, playheadPosition); + } else { + ctx.moveTo(playheadPosition, 0); + ctx.lineTo(playheadPosition, valueAxis); + } + ctx.stroke(); + return this; +} + +export function getDrawOptions(drawTime, options = {}) { + let [lookbehind, lookahead] = drawTime; + lookbehind = Math.abs(lookbehind); + const cycles = lookahead + lookbehind; + const playhead = lookbehind / cycles; + return { fold: 1, ...options, cycles, playhead }; +} + +export const getPunchcardPainter = + (options = {}) => + (ctx, time, haps, drawTime, paintOptions = {}) => + pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { ...paintOptions, ...options }) }); + +Pattern.prototype.punchcard = function (options) { + return this.onPaint(getPunchcardPainter(options)); +}; + +/** + * Displays a vertical pianoroll with event labels. + * Supports all the same options as pianoroll. + * + * @name wordfall + */ +Pattern.prototype.wordfall = function (options) { + return this.punchcard({ vertical: 1, labels: 1, stroke: 0, fillActive: 1, active: 'white', ...options }); +}; + +/* Pattern.prototype.pianoroll = function (options) { + return this.onPaint((ctx, time, haps, drawTime) => + pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { fold: 0, ...options }) }), + ); +}; */ + +export function drawPianoroll(options) { + const { drawTime, ...rest } = options; + pianoroll({ ...getDrawOptions(drawTime), ...rest }); +} diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index aaa6421b..9a236d89 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -125,6 +125,16 @@ export const { note } = registerControl(['note', 'n']); * */ export const { accelerate } = registerControl('accelerate'); +/** + * + * Sets the velocity from 0 to 1. Is multiplied together with gain. + * @name velocity + * @example + * s("hh*8") + * .gain(".4!2 1 .4!2 1 .4 1") + * .velocity(".4 1") + */ +export const { velocity } = registerControl('velocity'); /** * Controls the gain by an exponential amount. * @@ -1163,11 +1173,9 @@ export const { rate } = registerControl('rate'); export const { slide } = registerControl('slide'); // TODO: detune? https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare export const { semitone } = registerControl('semitone'); -// TODO: dedup with synth param, see https://tidalcycles.org/docs/reference/synthesizers/#superpiano -// ['velocity'], + // TODO: synth param export const { voice } = registerControl('voice'); - // voicings // https://github.com/tidalcycles/strudel/issues/506 // chord to voice, like C Eb Fm7 G7. the symbols can be defined via addVoicings export const { chord } = registerControl('chord'); diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 00f20adf..55a0f567 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2063,7 +2063,7 @@ export const { echoWith, echowith, stutWith, stutwith } = register( * s("bd sd").echo(3, 1/6, .8) */ export const echo = register('echo', function (times, time, feedback, pat) { - return pat._echoWith(times, time, (pat, i) => pat.velocity(Math.pow(feedback, i))); + return pat._echoWith(times, time, (pat, i) => pat.gain(Math.pow(feedback, i))); }); /** @@ -2076,7 +2076,7 @@ export const echo = register('echo', function (times, time, feedback, pat) { * s("bd sd").stut(3, .8, 1/6) */ export const stut = register('stut', function (times, feedback, time, pat) { - return pat._echoWith(times, time, (pat, i) => pat.velocity(Math.pow(feedback, i))); + return pat._echoWith(times, time, (pat, i) => pat.gain(Math.pow(feedback, i))); }); /** @@ -2221,19 +2221,6 @@ export const { color, colour } = register(['color', 'colour'], function (color, return pat.withContext((context) => ({ ...context, color })); }); -/** - * - * Sets the velocity from 0 to 1. Is multiplied together with gain. - * @name velocity - * @example - * s("hh*8") - * .gain(".4!2 1 .4!2 1 .4 1") - * .velocity(".4 1") - */ -export const velocity = register('velocity', function (velocity, pat) { - return pat.withContext((context) => ({ ...context, velocity: (context.velocity || 1) * velocity })); -}); - ////////////////////////////////////////////////////////////////////// // Control-related functions, i.e. ones that manipulate patterns of // objects diff --git a/packages/core/pianoroll.mjs b/packages/core/pianoroll.mjs index de6fc72b..2f8c9fca 100644 --- a/packages/core/pianoroll.mjs +++ b/packages/core/pianoroll.mjs @@ -187,7 +187,8 @@ export function pianoroll({ color = isActive ? active : inactive; ctx.fillStyle = fillCurrent ? color : 'transparent'; ctx.strokeStyle = color; - ctx.globalAlpha = event.context.velocity ?? event.value?.gain ?? 1; + const { velocity = 1, gain = 1 } = event.value || {}; + ctx.globalAlpha = velocity * gain; const timeProgress = (event.whole.begin - (flipTime ? to : from)) / timeExtent; const timePx = scale(timeProgress, ...timeRange); let durationPx = scale(event.duration / timeExtent, 0, timeAxis); diff --git a/packages/csound/index.mjs b/packages/csound/index.mjs index 05fd72e0..a00be74f 100644 --- a/packages/csound/index.mjs +++ b/packages/csound/index.mjs @@ -152,12 +152,14 @@ export const csoundm = register('csoundm', (instrument, pat) => { const p2 = tidal_time - getAudioContext().currentTime; const p3 = hap.duration.valueOf() + 0; const frequency = getFrequency(hap); + let { gain = 1, velocity = 0.9 } = hap.value; + velocity = gain * velocity; // Translate frequency to MIDI key number _without_ rounding. const C4 = 261.62558; let octave = Math.log(frequency / C4) / Math.log(2.0) + 8.0; const p4 = octave * 12.0 - 36.0; // We prefer floating point precision, but over the MIDI range [0, 127]. - const p5 = 127 * (hap.context?.velocity ?? 0.9); + const p5 = 127 * velocity; // The Strudel controls as a string. const p6 = Object.entries({ ...hap.value, frequency }) .flat() diff --git a/packages/desktopbridge/midibridge.mjs b/packages/desktopbridge/midibridge.mjs index ebbb185b..065f9839 100644 --- a/packages/desktopbridge/midibridge.mjs +++ b/packages/desktopbridge/midibridge.mjs @@ -7,9 +7,9 @@ const CC_MESSAGE = 0xb0; Pattern.prototype.midi = function (output) { return this.onTrigger((time, hap, currentTime, cps) => { - const { note, nrpnn, nrpv, ccn, ccv } = hap.value; + let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value; const offset = (time - currentTime) * 1000; - const velocity = Math.floor((hap.context?.velocity ?? 0.9) * 100); // TODO: refactor velocity + velocity = Math.floor(gain * velocity * 100); const duration = Math.floor((hap.duration.valueOf() / cps) * 1000 - 10); const roundedOffset = Math.round(offset); const midichan = (hap.value.midichan ?? 1) - 1; diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index b95558d4..54787f97 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -125,8 +125,9 @@ Pattern.prototype.midi = function (output) { const timeOffsetString = `+${offset}`; // destructure value - const { note, nrpnn, nrpv, ccn, ccv, midichan = 1, midicmd } = hap.value; - const velocity = hap.context?.velocity ?? 0.9; // TODO: refactor velocity + let { note, nrpnn, nrpv, ccn, ccv, midichan = 1, midicmd, gain = 1, velocity = 0.9 } = hap.value; + + velocity = gain * velocity; // note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length const duration = Math.floor((hap.duration.valueOf() / cps) * 1000 - 10); diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 4d67e038..95f1cc53 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -344,7 +344,7 @@ export const superdough = async (value, deadline, hapDuration) => { //music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); - gain *= velocity; // legacy fix for velocity + gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future let toDisconnect = []; // audio nodes that will be disconnected when the source has ended const onended = () => { toDisconnect.forEach((n) => n?.disconnect()); diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index a176d2cc..7f28b8b7 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -12,7 +12,7 @@ setLogger(logger); const hap2value = (hap) => { hap.ensureObjectValue(); - return { ...hap.value, velocity: hap.context.velocity }; + return hap.value; }; export const webaudioOutputTrigger = (t, hap, ct, cps) => superdough(hap2value(hap), t - ct, hap.duration / cps, cps);