Skip to content

Commit

Permalink
midi mirror (#86)
Browse files Browse the repository at this point in the history
* ch settings is 1 indexed ;-;

* fix

* midi mirror

* add staged changes
  • Loading branch information
dtinth authored Aug 22, 2023
1 parent 662fdd7 commit af48807
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 8 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@
"markdown-it": "^12.0.4",
"mobx": "^6.1.8",
"mobx-react": "^7.1.0",
"nanostores": "^0.9.3",
"nanostores-computed-dynamic": "^0.2.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-emotion": "^9.2.6",
"react-error-boundary": "^3.1.3",
"react-router-dom": "^5.2.0",
"reakit": "^1.3.7",
"reselect": "^4.0.0",
"twind": "^0.16.10"
"twind": "^0.16.10",
"use-sync-external-store": "^1.2.0"
},
"devDependencies": {
"@playwright/test": "^1.27.1",
Expand All @@ -33,6 +36,7 @@
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.7",
"@types/use-sync-external-store": "^0.0.3",
"@types/webmidi": "^2.0.4",
"@vitejs/plugin-react-refresh": "^1.3.1",
"firebase-tools": "^11.14.1",
Expand Down
19 changes: 19 additions & 0 deletions src/core-midi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { atom } from 'nanostores'

export const $midiAccess = atom<WebMidi.MIDIAccess | null>(null)

/**
* Use an old-school method to enumerate the keys of a Map because
* iOS Web MIDI Browser seems to have implemented a Map that does not
* fully implement the iterable protocol.
*/
export function enumerateKeys<TMap extends Map<string, any>>(map: TMap) {
const keys: string[] = []
const iterator = map.keys()
for (;;) {
const { done, value: key } = iterator.next()
if (done) break
keys.push(key)
}
return keys
}
13 changes: 6 additions & 7 deletions src/core/MIDI.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="webmidi" />

import { action, observable } from 'mobx'
import { $midiAccess, enumerateKeys } from '../core-midi'

export type Output = {
key: string
Expand Down Expand Up @@ -78,6 +79,7 @@ const handleAvailableOutputs = action(

function ok(access: WebMidi.MIDIAccess) {
window.midiAccess = access
$midiAccess.set(access)
setStatus('Found MIDI outputs: ' + access.outputs.size)
try {
refreshOutputList(access)
Expand All @@ -87,13 +89,10 @@ function ok(access: WebMidi.MIDIAccess) {
}

function refreshOutputList(access: WebMidi.MIDIAccess) {
const ports: Output[] = []
const iterator = access.outputs.keys()
for (;;) {
const { done, value: key } = iterator.next()
if (done) break
ports.push({ key, name: access.outputs.get(key)!.name! })
}
const ports: Output[] = enumerateKeys(access.outputs).flatMap((key) => {
const output = access.outputs.get(key)
return output ? [{ key, name: output.name || `(${key})` }] : []
})
const previousKey = store.selectedOutputKey
handleAvailableOutputs(ports)
if (previousKey !== store.selectedOutputKey) {
Expand Down
2 changes: 2 additions & 0 deletions src/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as TouchPedal from './touch-pedal'
import * as Joypedal from './joypedal'
import * as ChordIdentifier from './chord-identifier'
import * as Gyrocon from './gyro-controller'
import * as MidiMirror from './midi-mirror'

export const featureModules = [
BeginnerChordMachine,
Expand All @@ -18,4 +19,5 @@ export const featureModules = [
Joypedal,
ChordIdentifier,
Gyrocon,
MidiMirror,
]
146 changes: 146 additions & 0 deletions src/midi-mirror/MidiMirror.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { MIDI, Store, useConfiguration } from '../core'

import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { tw } from 'twind'
import { $midiAccess, enumerateKeys } from '../core-midi'
import { atom, computed, onMount } from 'nanostores'
import { useStore } from '../nanostore-utils'
import { useObserver } from 'mobx-react'

const $refreshCount = atom(0)
const $selectedInput = atom('')
const $inputInfo = computed([$midiAccess, $refreshCount], (access) => {
if (!access) return []
const keys = enumerateKeys(access.inputs)
const inputs = keys.flatMap((key) => {
const input = access.inputs.get(key)
return input ? [{ key, input }] : []
})
return inputs
})

let transpose = 0
let channel = 0
const offMap = new Map<number, number[]>()

export function MidiMirror(props: { store: Store }) {
return (
<div
className={tw`absolute inset-0 flex flex-col justify-center items-center text-center`}
>
<div className={tw`text-3xl pre-wrap focus:font-bold p-4`} tabIndex={0}>
<StatusViewer />
</div>
<div>
<InputSelector />
</div>
<MirrorChannelSettingWorker />
<MirrorTransposeSettingWorker store={props.store} />
<MirrorWorker />
</div>
)
}

function StatusViewer() {
const selectedInput = useStore($selectedInput)
return <>{selectedInput ? 'Selected input' : 'No input selected'}</>
}

function InputSelector() {
const inputs = useStore($inputInfo)
const selectedInput = useStore($selectedInput)
return (
<div className={tw`flex gap-2`}>
<select
value={selectedInput}
className={tw`bg-black`}
onChange={(e) => {
const value = e.target.value
$selectedInput.set(value)
}}
>
<option value="">-- none --</option>
{inputs.map(({ key, input }) => (
<option value={key} key={key}>
{input.name}
</option>
))}
</select>
<button className={tw`bg-black px-2 py-1`}>Refresh</button>
</div>
)
}

export function MirrorChannelSettingWorker() {
const { value: configuredChannel } = useConfiguration<string>(
'midi.output.channel'
)
useLayoutEffect(() => {
channel = +configuredChannel || 1
}, [configuredChannel])
return <></>
}

export function MirrorTransposeSettingWorker(props: { store: Store }) {
const currentTranspose = useObserver(() => props.store.transpose)
useLayoutEffect(() => {
transpose = +currentTranspose || 0
}, [currentTranspose])
return <></>
}

export function MirrorWorker() {
const midiAccess = useStore($midiAccess)
const selectedInputKey = useStore($selectedInput)
const selectedInput = useMemo(() => {
if (!selectedInputKey || !midiAccess) return null
const input = midiAccess.inputs.get(selectedInputKey)
return input
}, [midiAccess, selectedInputKey])
useEffect(() => {
if (!selectedInput) return
const listener = (event: WebMidi.MIDIMessageEvent) => {
if (event.currentTarget !== selectedInput) return
let data = Array.from(event.data)

// Only process note on/off, control change.
const command = data[0] >> 4
if (command !== 8 && command !== 9 && command !== 11) return

// Replace the channel.
data[0] = (data[0] & 0xf0) | (channel - 1)

// For note on/off, transpose the note.
if (command === 8 || command === 9) {
// Note on - transpose and remember the note.
if (command === 9 && data[2] > 0) {
const oldNote = data[1]
data[1] = Math.min(127, Math.max(0, data[1] + transpose))
const offMessage = [0x80 | (data[0] & 0xf), data[1], data[2]]
offMap.set(oldNote, offMessage)
} else {
// Note off - use the remembered note.
const offMessage = offMap.get(data[1])
if (offMessage) {
offMap.delete(data[1])
data = offMessage
}
}
}

// Send the message.
MIDI.send(data)
}
selectedInput.addEventListener('midimessage', listener)
return () => {
selectedInput.removeEventListener('midimessage', listener as any)
}
})
return null
}
18 changes: 18 additions & 0 deletions src/midi-mirror/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createFeature } from '../core'
import { MidiMirror } from './MidiMirror'

export default createFeature({
name: 'midi-mirror',
category: 'instruments',
description: 'Use another MIDI controller as an input, applying channel modifications and transposition.',
instruments: [
{
id: 'mirror',
sortKey: '501_mirror',
name: 'MIDI Mirror',
description:
'Use another MIDI controller as an input, applying channel modifications and transposition.',
component: MidiMirror as any,
},
],
})
11 changes: 11 additions & 0 deletions src/nanostore-utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Store, StoreValue } from 'nanostores'
import { useCallback } from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim'

export function useStore<TStore extends Store>(
store: TStore
): StoreValue<TStore> {
let subscribe = useCallback((onChange) => store.listen(onChange), [store])
let get = store.get.bind(store)
return useSyncExternalStore(subscribe, get, get)
}
20 changes: 20 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,11 @@
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275"
integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==

"@types/use-sync-external-store@^0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==

"@types/webmidi@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/webmidi/-/webmidi-2.0.4.tgz#d1bb7287d59cc350333d225ce8b3ad4e54c5e6da"
Expand Down Expand Up @@ -4791,6 +4796,16 @@ nanoid@^3.1.20:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.21.tgz#25bfee7340ac4185866fbfb2c9006d299da1be7f"
integrity sha512-A6oZraK4DJkAOICstsGH98dvycPr/4GGDH7ZWKmMdd3vGcOurZ6JmWFUt0DA5bzrrn2FrUjmv6mFNWvv8jpppA==

nanostores-computed-dynamic@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/nanostores-computed-dynamic/-/nanostores-computed-dynamic-0.2.0.tgz#1ed2cd840bef39f6e3e366266aea86946e81d40e"
integrity sha512-GQdcIZ2ptCNtV9YbMKFkAA6rWYlZu70vbKvyDEXEMVyO2aa30pzGkxpIlYi5VY2bPvb2trRiiCVC1P9ysNx6IQ==

nanostores@^0.9.3:
version "0.9.3"
resolved "https://registry.yarnpkg.com/nanostores/-/nanostores-0.9.3.tgz#a095de5cb695e027b84657d5e31cabd6c5bb269c"
integrity sha512-KobZjcVyNndNrb5DAjfs0WG0lRcZu5Q1BOrfTOxokFLi25zFrWPjg+joXC6kuDqNfSt9fQwppyjUBkRPtsL+8w==

[email protected]:
version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
Expand Down Expand Up @@ -6591,6 +6606,11 @@ url-parse-lax@^3.0.0:
dependencies:
prepend-http "^2.0.0"

use-sync-external-store@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==

util-deprecate@^1.0.1, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
Expand Down

0 comments on commit af48807

Please sign in to comment.