configuring package, wiring everything together

This commit is contained in:
Jade Rowland 2023-08-30 08:24:02 -04:00
parent 284f5615cc
commit c29c4c0a8b
10 changed files with 258 additions and 191 deletions

View File

@ -2,6 +2,7 @@
export * from './packages/core/index.mjs';
export * from './packages/csound/index.mjs';
export * from './packages/embed/index.mjs';
export * from './packages/desktopbridge/index.mjs';
export * from './packages/midi/index.mjs';
export * from './packages/mini/index.mjs';
export * from './packages/osc/index.mjs';

View File

@ -0,0 +1,3 @@
# @strudel/desktopbridge
This package contains utilities used to communicate with the Tauri backend

View File

@ -0,0 +1,8 @@
/*
index.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/desktopbridge/index.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export * from './midibridge.mjs';
export * from './utils.mjs';

View File

@ -0,0 +1,57 @@
import { Invoke } from './utils.mjs';
import { noteToMidi } from '@strudel.cycles/core';
const ON_MESSAGE = 0x90;
const OFF_MESSAGE = 0x80;
export function processMidi(output) {
return this.onTrigger((time, hap, currentTime) => {
console.log('here');
const { note, nrpnn, nrpv, ccn, ccv } = hap.value;
const offset = (time - currentTime) * 1000;
const velocity = Math.floor((hap.context?.velocity ?? 0.9) * 100); // TODO: refactor velocity
const duration = Math.floor(hap.duration.valueOf() * 1000 - 10);
const roundedOffset = Math.round(offset);
const midichan = (hap.value.midichan ?? 1) - 1;
const requestedport = output ?? 'IAC';
const messagesfromjs = [];
if (note != null) {
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
messagesfromjs.push({
requestedport,
message: [ON_MESSAGE + midichan, midiNumber, velocity],
offset: roundedOffset,
});
messagesfromjs.push({
requestedport,
message: [OFF_MESSAGE + midichan, midiNumber, velocity],
offset: roundedOffset + duration,
});
}
if (ccv && ccn) {
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);
messagesfromjs.push({
requestedport,
message: [ON_MESSAGE + midichan, ccn, scaled],
offset: roundedOffset,
});
messagesfromjs.push({
requestedport,
message: [OFF_MESSAGE + midichan, ccn, scaled],
offset: roundedOffset + duration,
});
}
// invoke is temporarily blocking, run in an async process
if (messagesfromjs.length) {
setTimeout(() => {
Invoke('sendmidi', { messagesfromjs });
});
}
});
}

View File

@ -0,0 +1,28 @@
{
"name": "@strudel/desktopbridge",
"version": "0.1.0",
"description": "send midi messages between the JS and Tauri (Rust) sides of the Studel desktop app",
"main": "index.mjs",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/tidalcycles/strudel.git"
},
"keywords": [
"tidalcycles",
"strudel",
"pattern",
"livecoding",
"algorave"
],
"author": "Jade Rowland <jaderowlanddev@gmail.com>",
"license": "AGPL-3.0-or-later",
"bugs": {
"url": "https://github.com/tidalcycles/strudel/issues"
},
"dependencies": {
"@strudel.cycles/core": "workspace:*",
"@tauri-apps/api": "^1.4.0"
},
"homepage": "https://github.com/tidalcycles/strudel#readme"
}

View File

@ -0,0 +1,4 @@
import { invoke } from '@tauri-apps/api/tauri';
export const Invoke = invoke;
export const isTauri = () => window.__TAURI_IPC__ != null;

View File

@ -4,137 +4,9 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as _WebMidi from 'webmidi';
import { Pattern, isPattern, logger } from '@strudel.cycles/core';
import { noteToMidi } from '@strudel.cycles/core';
import { Note } from 'webmidi';
// if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi;
import { Pattern } from '@strudel.cycles/core';
import * as webmidihandler from './webmidihandler.mjs';
import * as midibridge from '../desktopbridge/midibridge.mjs';
import { isTauri } from '../desktopbridge/utils.mjs';
function supportsMidi() {
return typeof navigator.requestMIDIAccess === 'function';
}
function getMidiDeviceNamesString(outputs) {
return outputs.map((o) => `'${o.name}'`).join(' | ');
}
export function enableWebMidi(options = {}) {
const { onReady, onConnected, onDisconnected, onEnabled } = options;
if (WebMidi.enabled) {
return;
}
if (!supportsMidi()) {
throw new Error('Your Browser does not support WebMIDI.');
}
WebMidi.addListener('connected', () => {
onConnected?.(WebMidi);
});
WebMidi.addListener('enabled', () => {
onEnabled?.(WebMidi);
});
// Reacting when a device becomes unavailable
WebMidi.addListener('disconnected', (e) => {
onDisconnected?.(WebMidi, e);
});
return new Promise((resolve, reject) => {
if (WebMidi.enabled) {
// if already enabled, just resolve WebMidi
resolve(WebMidi);
return;
}
WebMidi.enable((err) => {
if (err) {
reject(err);
}
onReady?.(WebMidi);
resolve(WebMidi);
});
});
}
// const outputByName = (name: string) => WebMidi.getOutputByName(name);
const outputByName = (name) => WebMidi.getOutputByName(name);
// output?: string | number, outputs: typeof WebMidi.outputs
function getDevice(output, outputs) {
if (!outputs.length) {
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
}
if (typeof output === 'number') {
return outputs[output];
}
if (typeof output === 'string') {
return outputByName(output);
}
// attempt to default to first IAC device if none is specified
const IACOutput = outputs.find((output) => output.name.includes('IAC'));
const device = IACOutput ?? outputs[0];
if (!device) {
throw new Error(
`🔌 MIDI device '${output ? output : ''}' not found. Use one of ${getMidiDeviceNamesString(WebMidi.outputs)}`,
);
}
return IACOutput ?? outputs[0];
}
// Pattern.prototype.midi = function (output: string | number, channel = 1) {
Pattern.prototype.midi = function (output) {
if (isPattern(output)) {
throw new Error(
`.midi does not accept Pattern input. Make sure to pass device name with single quotes. Example: .midi('${
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
}')`,
);
}
enableWebMidi({
onEnabled: ({ outputs }) => {
const device = getDevice(output, outputs);
const otherOutputs = outputs.filter((o) => o.name !== device.name);
logger(
`Midi enabled! Using "${device.name}". ${
otherOutputs?.length ? `Also available: ${getMidiDeviceNamesString(otherOutputs)}` : ''
}`,
);
},
onDisconnected: ({ outputs }) =>
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
});
return this.onTrigger((time, hap, currentTime, cps) => {
if (!WebMidi.enabled) {
return;
}
const device = getDevice(output, WebMidi.outputs);
hap.ensureObjectValue();
const offset = (time - currentTime) * 1000;
// 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 = `+${offset}`;
// destructure value
const { note, nrpnn, nrpv, ccn, ccv, midichan = 1 } = hap.value;
const velocity = hap.context?.velocity ?? 0.9; // TODO: refactor velocity
// note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length
const duration = Math.floor(hap.duration.valueOf() * 1000 - 10);
if (note != null) {
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration });
device.playNote(midiNote, midichan, {
time: timeOffsetString,
});
}
if (ccv && ccn) {
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 = isTauri() ? midibridge.processMidi : webmidihandler.processMidi;

View File

@ -1,58 +0,0 @@
import { invoke } from '@tauri-apps/api/tauri';
import { noteToMidi } from '@strudel.cycles/core';
const ON_MESSAGE = 0x90;
const OFF_MESSAGE = 0x80;
export function playNote(hap, offset, output) {
const { note, nrpnn, nrpv, ccn, ccv } = hap.value;
const velocity = Math.floor((hap.context?.velocity ?? 0.9) * 100); // TODO: refactor velocity
const duration = Math.floor(hap.duration.valueOf() * 1000 - 10);
const roundedOffset = Math.round(offset);
const midichan = (hap.value.midichan ?? 1) - 1;
const requestedport = output ?? 'IAC';
const messagesfromjs = [];
if (note != null) {
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
messagesfromjs.push({
requestedport,
message: [ON_MESSAGE + midichan, midiNumber, velocity],
offset: roundedOffset,
});
messagesfromjs.push({
requestedport,
message: [OFF_MESSAGE + midichan, midiNumber, velocity],
offset: roundedOffset + duration,
});
}
if (ccv && ccn) {
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);
messagesfromjs.push({
requestedport,
message: [ON_MESSAGE + midichan, ccn, scaled],
offset: roundedOffset,
});
messagesfromjs.push({
requestedport,
message: [OFF_MESSAGE + midichan, ccn, scaled],
offset: roundedOffset + duration,
});
}
// invoke is temporarily blocking, run in an async process
if (messagesfromjs.length) {
setTimeout(() => {
invoke('sendmidi', { messagesfromjs });
});
}
}

View File

@ -0,0 +1,133 @@
import * as _WebMidi from 'webmidi';
import { isPattern, logger } from '@strudel.cycles/core';
import { noteToMidi } from '@strudel.cycles/core';
import { Note } from 'webmidi';
// if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi;
function supportsMidi() {
return typeof navigator.requestMIDIAccess === 'function';
}
function getMidiDeviceNamesString(outputs) {
return outputs.map((o) => `'${o.name}'`).join(' | ');
}
function enableWebMidi(options = {}) {
const { onReady, onConnected, onDisconnected, onEnabled } = options;
if (WebMidi.enabled) {
return;
}
if (!supportsMidi()) {
throw new Error('Your Browser does not support WebMIDI.');
}
WebMidi.addListener('connected', () => {
onConnected?.(WebMidi);
});
WebMidi.addListener('enabled', () => {
onEnabled?.(WebMidi);
});
// Reacting when a device becomes unavailable
WebMidi.addListener('disconnected', (e) => {
onDisconnected?.(WebMidi, e);
});
return new Promise((resolve, reject) => {
if (WebMidi.enabled) {
// if already enabled, just resolve WebMidi
resolve(WebMidi);
return;
}
WebMidi.enable((err) => {
if (err) {
reject(err);
}
onReady?.(WebMidi);
resolve(WebMidi);
});
});
}
// const outputByName = (name: string) => WebMidi.getOutputByName(name);
const outputByName = (name) => WebMidi.getOutputByName(name);
// output?: string | number, outputs: typeof WebMidi.outputs
function getDevice(output, outputs) {
if (!outputs.length) {
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
}
if (typeof output === 'number') {
return outputs[output];
}
if (typeof output === 'string') {
return outputByName(output);
}
// attempt to default to first IAC device if none is specified
const IACOutput = outputs.find((output) => output.name.includes('IAC'));
const device = IACOutput ?? outputs[0];
if (!device) {
throw new Error(
`🔌 MIDI device '${output ? output : ''}' not found. Use one of ${getMidiDeviceNamesString(WebMidi.outputs)}`,
);
}
return IACOutput ?? outputs[0];
}
export function processMidi(output) {
if (isPattern(output)) {
throw new Error(
`.midi does not accept Pattern input. Make sure to pass device name with single quotes. Example: .midi('${
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
}')`,
);
}
enableWebMidi({
onEnabled: ({ outputs }) => {
const device = getDevice(output, outputs);
const otherOutputs = outputs.filter((o) => o.name !== device.name);
logger(
`Midi enabled! Using "${device.name}". ${
otherOutputs?.length ? `Also available: ${getMidiDeviceNamesString(otherOutputs)}` : ''
}`,
);
},
onDisconnected: ({ outputs }) =>
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
});
return this.onTrigger((time, hap, currentTime, cps) => {
if (!WebMidi.enabled) {
return;
}
const device = getDevice(output, WebMidi.outputs);
hap.ensureObjectValue();
const offset = (time - currentTime) * 1000;
// 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 = `+${offset}`;
// destructure value
const { note, nrpnn, nrpv, ccn, ccv, midichan = 1 } = hap.value;
const velocity = hap.context?.velocity ?? 0.9; // TODO: refactor velocity
// note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length
const duration = Math.floor(hap.duration.valueOf() * 1000 - 10);
if (note != null) {
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration });
device.playNote(midiNote, midichan, {
time: timeOffsetString,
});
}
if (ccv && ccn) {
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 });
}
});
}

19
pnpm-lock.yaml generated
View File

@ -1,5 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
@ -176,6 +180,15 @@ importers:
specifier: ^4.3.3
version: 4.3.3(@types/node@18.16.3)
packages/desktopbridge:
dependencies:
'@strudel.cycles/core':
specifier: workspace:*
version: link:../core
'@tauri-apps/api':
specifier: ^1.4.0
version: 1.4.0
packages/embed: {}
packages/midi:
@ -3944,6 +3957,11 @@ packages:
tailwindcss: 3.3.2
dev: false
/@tauri-apps/api@1.4.0:
resolution: {integrity: sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw==}
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
dev: false
/@tauri-apps/cli-darwin-arm64@1.4.0:
resolution: {integrity: sha512-nA/ml0SfUt6/CYLVbHmT500Y+ijqsuv5+s9EBnVXYSLVg9kbPUZJJHluEYK+xKuOj6xzyuT/+rZFMRapmJD3jQ==}
engines: {node: '>= 10'}
@ -8025,6 +8043,7 @@ packages:
/iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
requiresBuild: true
dependencies:
safer-buffer: 2.1.2
dev: true