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.