diff --git a/index.mjs b/index.mjs index aeb5ff1b..941d6411 100644 --- a/index.mjs +++ b/index.mjs @@ -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'; diff --git a/packages/desktopbridge/README.md b/packages/desktopbridge/README.md new file mode 100644 index 00000000..6cf21bba --- /dev/null +++ b/packages/desktopbridge/README.md @@ -0,0 +1,3 @@ +# @strudel/desktopbridge + +This package contains utilities used to communicate with the Tauri backend \ No newline at end of file diff --git a/packages/desktopbridge/index.mjs b/packages/desktopbridge/index.mjs new file mode 100644 index 00000000..2cc9db3d --- /dev/null +++ b/packages/desktopbridge/index.mjs @@ -0,0 +1,8 @@ +/* +index.mjs - +Copyright (C) 2022 Strudel contributors - see +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 . +*/ + +export * from './midibridge.mjs'; +export * from './utils.mjs'; diff --git a/packages/desktopbridge/midibridge.mjs b/packages/desktopbridge/midibridge.mjs new file mode 100644 index 00000000..16ddd606 --- /dev/null +++ b/packages/desktopbridge/midibridge.mjs @@ -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 }); + }); + } + }); +} diff --git a/packages/desktopbridge/package.json b/packages/desktopbridge/package.json new file mode 100644 index 00000000..bdeda2d9 --- /dev/null +++ b/packages/desktopbridge/package.json @@ -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 ", + "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" + } \ No newline at end of file diff --git a/packages/desktopbridge/utils.mjs b/packages/desktopbridge/utils.mjs new file mode 100644 index 00000000..c4c69af5 --- /dev/null +++ b/packages/desktopbridge/utils.mjs @@ -0,0 +1,4 @@ +import { invoke } from '@tauri-apps/api/tauri'; + +export const Invoke = invoke; +export const isTauri = () => window.__TAURI_IPC__ != null; diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index fa86f629..5606539a 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -4,137 +4,9 @@ Copyright (C) 2022 Strudel contributors - see . */ -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; diff --git a/packages/midi/midibridge.mjs b/packages/midi/midibridge.mjs deleted file mode 100644 index 803259a1..00000000 --- a/packages/midi/midibridge.mjs +++ /dev/null @@ -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 }); - }); - } -} diff --git a/packages/midi/webmidihandler.mjs b/packages/midi/webmidihandler.mjs new file mode 100644 index 00000000..52933190 --- /dev/null +++ b/packages/midi/webmidihandler.mjs @@ -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 }); + } + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 643a4766..391c6681 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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