working
This commit is contained in:
parent
92e3a80e65
commit
91e1cc136e
2 changed files with 64 additions and 42 deletions
|
|
@ -5,9 +5,10 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _WebMidi from 'webmidi';
|
import * as _WebMidi from 'webmidi';
|
||||||
import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
|
import { Pattern, isPattern, logger, ref } from '@strudel/core';
|
||||||
import { noteToMidi, getControlName } from '@strudel/core';
|
import { noteToMidi, getControlName } from '@strudel/core';
|
||||||
import { Note } from 'webmidi';
|
import { Note } from 'webmidi';
|
||||||
|
import { scheduleAtTime } from '../superdough/helpers.mjs';
|
||||||
|
|
||||||
// if you use WebMidi from outside of this package, make sure to import that instance:
|
// if you use WebMidi from outside of this package, make sure to import that instance:
|
||||||
export const { WebMidi } = _WebMidi;
|
export const { WebMidi } = _WebMidi;
|
||||||
|
|
@ -190,7 +191,7 @@ function mapCC(mapping, value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a cc message to the given device on the given channel
|
// sends a cc message to the given device on the given channel
|
||||||
function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
|
function sendCC(ccn, ccv, device, midichan, targetTime) {
|
||||||
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||||
throw new Error('expected ccv to be a number between 0 and 1');
|
throw new Error('expected ccv to be a number between 0 and 1');
|
||||||
}
|
}
|
||||||
|
|
@ -198,19 +199,23 @@ function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
|
||||||
throw new Error('expected ccn to be a number or a string');
|
throw new Error('expected ccn to be a number or a string');
|
||||||
}
|
}
|
||||||
const scaled = Math.round(ccv * 127);
|
const scaled = Math.round(ccv * 127);
|
||||||
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendControlChange(ccn, scaled, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a program change message to the given device on the given channel
|
// sends a program change message to the given device on the given channel
|
||||||
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
|
function sendProgramChange(progNum, device, midichan, targetTime) {
|
||||||
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
||||||
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
||||||
}
|
}
|
||||||
device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendProgramChange(progNum, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a sysex message to the given device on the given channel
|
// sends a sysex message to the given device on the given channel
|
||||||
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||||
if (Array.isArray(sysexid)) {
|
if (Array.isArray(sysexid)) {
|
||||||
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||||
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
||||||
|
|
@ -225,11 +230,13 @@ function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
||||||
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||||
throw new Error('all sysex bytes must be integers between 0 and 255');
|
throw new Error('all sysex bytes must be integers between 0 and 255');
|
||||||
}
|
}
|
||||||
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendSysex(sysexid, sysexdata);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a NRPN message to the given device on the given channel
|
// sends a NRPN message to the given device on the given channel
|
||||||
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
|
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
||||||
if (Array.isArray(nrpnn)) {
|
if (Array.isArray(nrpnn)) {
|
||||||
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||||
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
||||||
|
|
@ -237,28 +244,34 @@ function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
|
||||||
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
|
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
|
||||||
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
|
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
|
||||||
}
|
}
|
||||||
|
scheduleAtTime(() => {
|
||||||
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
|
device.sendNRPN(nrpnn, nrpv, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a pitch bend message to the given device on the given channel
|
// sends a pitch bend message to the given device on the given channel
|
||||||
function sendPitchBend(midibend, device, midichan, timeOffsetString) {
|
function sendPitchBend(midibend, device, midichan, targetTime) {
|
||||||
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
||||||
throw new Error('expected midibend to be a number between -1 and 1');
|
throw new Error('expected midibend to be a number between -1 and 1');
|
||||||
}
|
}
|
||||||
device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendPitchBend(midibend, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a channel aftertouch message to the given device on the given channel
|
// sends a channel aftertouch message to the given device on the given channel
|
||||||
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
|
function sendAftertouch(miditouch, device, midichan, targetTime) {
|
||||||
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
||||||
throw new Error('expected miditouch to be a number between 0 and 1');
|
throw new Error('expected miditouch to be a number between 0 and 1');
|
||||||
}
|
}
|
||||||
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
|
|
||||||
|
scheduleAtTime(() => {
|
||||||
|
device.sendChannelAftertouch(miditouch, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a note message to the given device on the given channel
|
// sends a note message to the given device on the given channel
|
||||||
function sendNote(note, velocity, duration, device, midichan, timeOffsetString) {
|
function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||||
if (note == null || note === '') {
|
if (note == null || note === '') {
|
||||||
throw new Error('note cannot be null or empty');
|
throw new Error('note cannot be null or empty');
|
||||||
}
|
}
|
||||||
|
|
@ -268,12 +281,12 @@ function sendNote(note, velocity, duration, device, midichan, timeOffsetString)
|
||||||
if (duration != null && (typeof duration !== 'number' || duration < 0)) {
|
if (duration != null && (typeof duration !== 'number' || duration < 0)) {
|
||||||
throw new Error('duration must be a positive number');
|
throw new Error('duration must be a positive number');
|
||||||
}
|
}
|
||||||
|
|
||||||
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
|
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
|
||||||
const midiNote = new Note(midiNumber, { attack: velocity, duration });
|
const midiNote = new Note(midiNumber, { attack: velocity, duration });
|
||||||
device.playNote(midiNote, midichan, {
|
|
||||||
time: timeOffsetString,
|
scheduleAtTime(() => {
|
||||||
});
|
device.playNote(midiNote, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -309,7 +322,6 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||||
let midiConfig = {
|
let midiConfig = {
|
||||||
// Default configuration values
|
// Default configuration values
|
||||||
isController: false, // Disable sending notes for midi controllers
|
isController: false, // Disable sending notes for midi controllers
|
||||||
latencyMs: 34, // Default latency to get audio engine to line up in ms
|
|
||||||
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
|
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
|
||||||
midichannel: 1, // Default MIDI channel
|
midichannel: 1, // Default MIDI channel
|
||||||
velocity: 0.9, // Default velocity
|
velocity: 0.9, // Default velocity
|
||||||
|
|
@ -333,18 +345,13 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||||
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.onTrigger((hap, currentTime, cps, targetTime) => {
|
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
|
||||||
if (!WebMidi.enabled) {
|
if (!WebMidi.enabled) {
|
||||||
logger('Midi not enabled');
|
logger('Midi not enabled');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hap.ensureObjectValue();
|
hap.ensureObjectValue();
|
||||||
|
|
||||||
//magic number to get audio engine to line up, can probably be calculated somehow
|
|
||||||
const latencyMs = midiConfig.latencyMs;
|
|
||||||
// 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}`;
|
|
||||||
|
|
||||||
// midi event values from hap with configurable defaults
|
// midi event values from hap with configurable defaults
|
||||||
let {
|
let {
|
||||||
note,
|
note,
|
||||||
|
|
@ -380,7 +387,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||||
// if midimap is set, send a cc messages from defined controls
|
// if midimap is set, send a cc messages from defined controls
|
||||||
if (midicontrolMap.has(midimap)) {
|
if (midicontrolMap.has(midimap)) {
|
||||||
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
|
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
|
||||||
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString));
|
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
|
||||||
} else if (midimap !== 'default') {
|
} else if (midimap !== 'default') {
|
||||||
// Add warning when a non-existent midimap is specified
|
// Add warning when a non-existent midimap is specified
|
||||||
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
||||||
|
|
@ -392,12 +399,12 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||||
// try to prevent glitching by subtracting noteOffsetMs from the duration length
|
// try to prevent glitching by subtracting noteOffsetMs from the duration length
|
||||||
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
|
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
|
||||||
|
|
||||||
sendNote(note, velocity, duration, device, midichan, timeOffsetString);
|
sendNote(note, velocity, duration, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle program change
|
// Handle program change
|
||||||
if (progNum !== undefined) {
|
if (progNum !== undefined) {
|
||||||
sendProgramChange(progNum, device, midichan, timeOffsetString);
|
sendProgramChange(progNum, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle sysex
|
// Handle sysex
|
||||||
|
|
@ -407,53 +414,63 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||||
// if sysexid is an array the first byte is 0x00
|
// if sysexid is an array the first byte is 0x00
|
||||||
|
|
||||||
if (sysexid !== undefined && sysexdata !== undefined) {
|
if (sysexid !== undefined && sysexdata !== undefined) {
|
||||||
sendSysex(sysexid, sysexdata, device, timeOffsetString);
|
sendSysex(sysexid, sysexdata, device, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle control change
|
// Handle control change
|
||||||
if (ccv !== undefined && ccn !== undefined) {
|
if (ccv !== undefined && ccn !== undefined) {
|
||||||
sendCC(ccn, ccv, device, midichan, timeOffsetString);
|
sendCC(ccn, ccv, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle NRPN non-registered parameter number
|
// Handle NRPN non-registered parameter number
|
||||||
if (nrpnn !== undefined && nrpv !== undefined) {
|
if (nrpnn !== undefined && nrpv !== undefined) {
|
||||||
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
|
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle midibend
|
// Handle midibend
|
||||||
if (midibend !== undefined) {
|
if (midibend !== undefined) {
|
||||||
sendPitchBend(midibend, device, midichan, timeOffsetString);
|
sendPitchBend(midibend, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle miditouch
|
// Handle miditouch
|
||||||
if (miditouch !== undefined) {
|
if (miditouch !== undefined) {
|
||||||
sendAftertouch(miditouch, device, midichan, timeOffsetString);
|
sendAftertouch(miditouch, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle midicmd
|
// Handle midicmd
|
||||||
if (hap.whole.begin + 0 === 0) {
|
if (hap.whole.begin + 0 === 0) {
|
||||||
// we need to start here because we have the timing info
|
// we need to start here because we have the timing info
|
||||||
device.sendStart({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendStart();
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
if (['clock', 'midiClock'].includes(midicmd)) {
|
if (['clock', 'midiClock'].includes(midicmd)) {
|
||||||
device.sendClock({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendClock();
|
||||||
|
}, targetTime);
|
||||||
} else if (['start'].includes(midicmd)) {
|
} else if (['start'].includes(midicmd)) {
|
||||||
device.sendStart({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendStart();
|
||||||
|
}, targetTime);
|
||||||
} else if (['stop'].includes(midicmd)) {
|
} else if (['stop'].includes(midicmd)) {
|
||||||
device.sendStop({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendStop();
|
||||||
|
}, targetTime);
|
||||||
} else if (['continue'].includes(midicmd)) {
|
} else if (['continue'].includes(midicmd)) {
|
||||||
device.sendContinue({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendContinue();
|
||||||
|
}, targetTime);
|
||||||
} else if (Array.isArray(midicmd)) {
|
} else if (Array.isArray(midicmd)) {
|
||||||
if (midicmd[0] === 'progNum') {
|
if (midicmd[0] === 'progNum') {
|
||||||
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
|
sendProgramChange(midicmd[1], device, midichan, targetTime);
|
||||||
} else if (midicmd[0] === 'cc') {
|
} else if (midicmd[0] === 'cc') {
|
||||||
if (midicmd.length === 2) {
|
if (midicmd.length === 2) {
|
||||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
|
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
} else if (midicmd[0] === 'sysex') {
|
} else if (midicmd[0] === 'sysex') {
|
||||||
if (midicmd.length === 3) {
|
if (midicmd.length === 3) {
|
||||||
const [_, id, data] = midicmd;
|
const [_, id, data] = midicmd;
|
||||||
sendSysex(id, data, device, timeOffsetString);
|
sendSysex(id, data, device, targetTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,11 @@ export function getVibratoOscillator(param, value, t) {
|
||||||
return vibratoOscillator;
|
return vibratoOscillator;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
|
||||||
|
const currentTime = audioContext.currentTime;
|
||||||
|
webAudioTimeout(audioContext, callback, currentTime, targetTime);
|
||||||
|
}
|
||||||
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
|
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
|
||||||
// a bit of a hack, but it works very well :)
|
// a bit of a hack, but it works very well :)
|
||||||
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue