Skip to content

Commit

Permalink
Merge pull request #1274 from tidalcycles/midimaps
Browse files Browse the repository at this point in the history
midimaps
  • Loading branch information
felixroos authored Feb 7, 2025
2 parents 8bde61f + efd4071 commit 5e81b54
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 37 deletions.
26 changes: 0 additions & 26 deletions .eslintignore

This file was deleted.

2 changes: 2 additions & 0 deletions packages/core/controls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
145 changes: 134 additions & 11 deletions packages/midi/midi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test/examples.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const skippedExamples = [
'accelerationZ',
'accelerationY',
'accelerationX',
'defaultmidimap',
'midimaps',
];

describe('runs examples', () => {
Expand Down
10 changes: 10 additions & 0 deletions website/src/pages/learn/input-output.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<JsDoc client:idle name="midimaps" h={0} />

## defaultmidimap

<JsDoc client:idle name="defaultmidimap" h={0} />

# 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.
Expand Down

0 comments on commit 5e81b54

Please sign in to comment.