feat: basic midimap handling

This commit is contained in:
Felix Roos 2025-02-02 22:01:53 +01:00
parent 0338c5b222
commit 0b1bc74c3b
No known key found for this signature in database
2 changed files with 61 additions and 10 deletions

View File

@ -1514,6 +1514,7 @@ 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 { control } = registerControl('control');
export const { ccn } = registerControl('ccn');
export const { ccv } = registerControl('ccv');

View File

@ -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,57 @@ if (typeof window !== 'undefined') {
});
}
// registry for midi mappings, converting control names to cc messages
export const midiMappings = 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]) => [getControlName(key), mapping]));
}
// adds a midimap to the registry
export function addMidimap(name, mapping) {
midiMappings.set(name, unifyMapping(mapping));
}
// adds multiple midimaps to the registry
export function midimaps(map) {
if (typeof map === 'object') {
Object.entries(midiMappings).forEach(([name, mapping]) => addMidimap(name, mapping));
}
}
// 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) {
const ccs = Array.isArray(value.cc) ? value.cc : [];
const matches = Object.entries(value).filter(([key]) => !!mapping[getControlName(key)]);
matches.forEach((match) => {
const control = match[0];
const { ccn, min = 0, max = 1, exp = 1 } = mapping[control];
const ccv = normalize(value[control], min, max, exp);
ccs.push({ ccn, ccv });
});
return ccs;
}
// 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(
@ -124,10 +175,16 @@ Pattern.prototype.midi = function (output) {
// 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, nrpnn, nrpv, ccn, ccv, midichan = 1, midicmd, gain = 1, velocity = 0.9, midimap } = hap.value;
velocity = gain * velocity;
// if midimap is set, send a cc messages from defined controls
if (!!midimap && midiMappings.has(midimap)) {
const ccs = mapCC(midiMappings.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;
if (note != null) {
@ -138,14 +195,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