diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index cbfa8917..00000000 --- a/.eslintignore +++ /dev/null @@ -1,26 +0,0 @@ -krill-parser.js -krill.pegjs -.eslintrc.json -server.js -tidal-sniffer.js -*.jsx -tunejs.js -out/** -postcss.config.js -postcss.config.cjs -tailwind.config.js -tailwind.config.cjs -vite.config.js -/**/dist/**/* -!**/*.mjs -**/*.tsx -**/*.ts -**/*.json -**/dev-dist -**/dist -/src-tauri/target/**/* -reverbGen.mjs -hydra.mjs -jsdoc-synonyms.js -packages/hs2js/src/hs2js.mjs -samples \ No newline at end of file diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 3d3530d5..1f2d3e70 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -1514,6 +1514,8 @@ export const { binshift } = registerControl('binshift'); export const { hbrick } = registerControl('hbrick'); export const { lbrick } = registerControl('lbrick'); export const { midichan } = registerControl('midichan'); +export const { midimap } = registerControl('midimap'); +export const { midiport } = registerControl('midiport'); export const { control } = registerControl('control'); export const { ccn } = registerControl('ccn'); export const { ccv } = registerControl('ccv'); diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 32e66f6c..2c1d1f20 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import * as _WebMidi from 'webmidi'; import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core'; -import { noteToMidi } from '@strudel/core'; +import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; // if you use WebMidi from outside of this package, make sure to import that instance: export const { WebMidi } = _WebMidi; @@ -89,6 +89,113 @@ if (typeof window !== 'undefined') { }); } +// registry for midi mappings, converting control names to cc messages +export const midicontrolMap = new Map(); + +// takes midimap and converts each control key to the main control name +function unifyMapping(mapping) { + return Object.fromEntries( + Object.entries(mapping).map(([key, mapping]) => { + if (typeof mapping === 'number') { + mapping = { ccn: mapping }; + } + return [getControlName(key), mapping]; + }), + ); +} + +function githubPath(base, subpath = '') { + if (!base.startsWith('github:')) { + throw new Error('expected "github:" at the start of pseudoUrl'); + } + let [_, path] = base.split('github:'); + path = path.endsWith('/') ? path.slice(0, -1) : path; + if (path.split('/').length === 2) { + // assume main as default branch if none set + path += '/main'; + } + return `https://raw.githubusercontent.com/${path}/${subpath}`; +} + +/** + * configures the default midimap, which is used when no "midimap" port is set + * @example + * defaultmidimap({ lpf: 74 }) + * $: note("c a f e").midi(); + * $: lpf(sine.slow(4).segment(16)).midi(); + */ +export function defaultmidimap(mapping) { + midicontrolMap.set('default', unifyMapping(mapping)); +} + +let loadCache = {}; + +/** + * Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers. + * @example + * midimaps({ mymap: { lpf: 74 } }) + * $: note("c a f e") + * .lpf(sine.slow(4)) + * .midimap('mymap') + * .midi() + * @example + * midimaps({ mymap: { + * lpf: { ccn: 74, min: 0, max: 20000, exp: 0.5 } + * }}) + * $: note("c a f e") + * .lpf(sine.slow(2).range(400,2000)) + * .midimap('mymap') + * .midi() + */ +export async function midimaps(map) { + if (typeof map === 'string') { + if (map.startsWith('github:')) { + map = githubPath(map, 'midimap.json'); + } + if (!loadCache[map]) { + loadCache[map] = fetch(map).then((res) => res.json()); + } + map = await loadCache[map]; + } + if (typeof map === 'object') { + Object.entries(map).forEach(([name, mapping]) => midicontrolMap.set(name, unifyMapping(mapping))); + } +} + +// registry for midi sounds, converting sound names to controls +export const midisoundMap = new Map(); + +// normalizes the given value from the given range and exponent +function normalize(value = 0, min = 0, max = 1, exp = 1) { + if (min === max) { + throw new Error('min and max cannot be the same value'); + } + let normalized = (value - min) / (max - min); + normalized = Math.min(1, Math.max(0, normalized)); + return Math.pow(normalized, exp); +} +function mapCC(mapping, value) { + return Object.keys(value) + .filter((key) => !!mapping[getControlName(key)]) + .map((key) => { + const { ccn, min = 0, max = 1, exp = 1 } = mapping[key]; + const ccv = normalize(value[key], min, max, exp); + return { ccn, ccv }; + }); +} + +// sends a cc message to the given device on the given channel +function sendCC(ccn, ccv, device, midichan, timeOffsetString) { + if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) { + throw new Error('expected ccv to be a number between 0 and 1'); + } + if (!['string', 'number'].includes(typeof ccn)) { + throw new Error('expected ccn to be a number or a string'); + } + const scaled = Math.round(ccv * 127); + device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString }); +} + Pattern.prototype.midi = function (output) { if (isPattern(output)) { throw new Error( @@ -117,16 +224,39 @@ Pattern.prototype.midi = function (output) { console.log('not enabled'); return; } - const device = getDevice(output, WebMidi.outputs); hap.ensureObjectValue(); //magic number to get audio engine to line up, can probably be calculated somehow const latencyMs = 34; // passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`; + // destructure value - let { note, nrpnn, nrpv, ccn, ccv, midichan = 1, midicmd, gain = 1, velocity = 0.9 } = hap.value; + let { + note, + ccn, + ccv, + midichan = 1, + midicmd, + gain = 1, + velocity = 0.9, + midimap = 'default', + midiport = output, + } = hap.value; + + const device = getDevice(midiport, WebMidi.outputs); + if (!device) { + logger( + `[midi] midiport "${midiport}" not found! available: ${WebMidi.outputs.map((output) => `'${output.name}'`).join(', ')}`, + ); + return; + } velocity = gain * velocity; + // if midimap is set, send a cc messages from defined controls + if (midicontrolMap.has(midimap)) { + const ccs = mapCC(midicontrolMap.get(midimap), hap.value); + ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString)); + } // note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length const duration = (hap.duration.valueOf() / cps) * 1000 - 10; @@ -138,14 +268,7 @@ Pattern.prototype.midi = function (output) { }); } if (ccv !== undefined && ccn !== undefined) { - if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) { - throw new Error('expected ccv to be a number between 0 and 1'); - } - if (!['string', 'number'].includes(typeof ccn)) { - throw new Error('expected ccn to be a number or a string'); - } - const scaled = Math.round(ccv * 127); - device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString }); + sendCC(ccn, ccv, device, midichan, timeOffsetString); } if (hap.whole.begin + 0 === 0) { // we need to start here because we have the timing info diff --git a/test/examples.test.mjs b/test/examples.test.mjs index 997c1cf3..896a02f8 100644 --- a/test/examples.test.mjs +++ b/test/examples.test.mjs @@ -18,6 +18,8 @@ const skippedExamples = [ 'accelerationZ', 'accelerationY', 'accelerationX', + 'defaultmidimap', + 'midimaps', ]; describe('runs examples', () => { diff --git a/website/src/pages/learn/input-output.mdx b/website/src/pages/learn/input-output.mdx index 14e46ba7..cec06a34 100644 --- a/website/src/pages/learn/input-output.mdx +++ b/website/src/pages/learn/input-output.mdx @@ -46,6 +46,16 @@ But you can also control cc messages separately like this: $: ccv(sine.segment(16).slow(4)).ccn(74).midi()`} /> +Instead of setting `ccn` and `ccv` directly, you can also create mappings with `midimaps`: + +## midimaps + + + +## defaultmidimap + + + # OSC/SuperDirt/StrudelDirt In TidalCycles, sound is usually generated using [SuperDirt](https://github.com/musikinformatik/SuperDirt/), which runs inside SuperCollider. Strudel also supports using SuperDirt, although it requires installing some additional software.