diff --git a/packages/desktopbridge/midibridge.mjs b/packages/desktopbridge/midibridge.mjs index 8c38cb25..7e9528e7 100644 --- a/packages/desktopbridge/midibridge.mjs +++ b/packages/desktopbridge/midibridge.mjs @@ -1,10 +1,10 @@ import { Invoke } from './utils.mjs'; -import { noteToMidi } from '@strudel.cycles/core'; +import { Pattern, noteToMidi } from '@strudel.cycles/core'; const ON_MESSAGE = 0x90; const OFF_MESSAGE = 0x80; -export function processMidi(output) { +Pattern.prototype.midi = function (output) { return this.onTrigger((time, hap, currentTime) => { const { note, nrpnn, nrpv, ccn, ccv } = hap.value; const offset = (time - currentTime) * 1000; @@ -53,4 +53,4 @@ export function processMidi(output) { }); } }); -} +}; diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 5606539a..201840cc 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -5,8 +5,136 @@ This program is free software: you can redistribute it and/or modify it under th */ 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'; +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; -Pattern.prototype.midi = isTauri() ? midibridge.processMidi : webmidihandler.processMidi; +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]; +} + +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 }); + } + }); +}; diff --git a/packages/midi/webmidihandler.mjs b/packages/midi/webmidihandler.mjs deleted file mode 100644 index 52933190..00000000 --- a/packages/midi/webmidihandler.mjs +++ /dev/null @@ -1,133 +0,0 @@ -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 65232669..8b48a93f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -588,6 +588,9 @@ importers: '@strudel.cycles/xen': specifier: workspace:* version: link:../packages/xen + '@strudel/desktopbridge': + specifier: workspace:* + version: link:../packages/desktopbridge '@supabase/supabase-js': specifier: ^2.21.0 version: 2.21.0 @@ -597,6 +600,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.8 version: 0.5.9(tailwindcss@3.3.2) + '@tauri-apps/api': + specifier: ^1.4.0 + version: 1.4.0 '@types/node': specifier: ^18.16.3 version: 18.16.3 diff --git a/website/package.json b/website/package.json index c595f5a6..6ac799d3 100644 --- a/website/package.json +++ b/website/package.json @@ -34,9 +34,11 @@ "@strudel.cycles/transpiler": "workspace:*", "@strudel.cycles/webaudio": "workspace:*", "@strudel.cycles/xen": "workspace:*", + "@strudel/desktopbridge": "workspace:*", "@supabase/supabase-js": "^2.21.0", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.8", + "@tauri-apps/api": "^1.4.0", "@types/node": "^18.16.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.1", diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 8dafe8a4..4434ed7a 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -21,6 +21,7 @@ import { settingsMap, useSettings, setLatestCode } from '../settings.mjs'; import Loader from './Loader'; import { settingPatterns } from '../settings.mjs'; import { code2hash, hash2code } from './helpers.mjs'; +import { isTauri } from '../tauri.mjs'; const { latestCode } = settingsMap.get(); @@ -36,7 +37,7 @@ const modules = [ import('@strudel.cycles/core'), import('@strudel.cycles/tonal'), import('@strudel.cycles/mini'), - import('@strudel.cycles/midi'), + isTauri() ? import('@strudel/desktopbridge') : import('@strudel.cycles/midi'), import('@strudel.cycles/xen'), import('@strudel.cycles/webaudio'), import('@strudel.cycles/osc'), diff --git a/website/src/tauri.mjs b/website/src/tauri.mjs new file mode 100644 index 00000000..c4c69af5 --- /dev/null +++ b/website/src/tauri.mjs @@ -0,0 +1,4 @@ +import { invoke } from '@tauri-apps/api/tauri'; + +export const Invoke = invoke; +export const isTauri = () => window.__TAURI_IPC__ != null;