Ver código fonte

Add MIDI event handling

Support MIDI event handling by suppling a `MIDIInput` object through the `midiInput` prop.
master
TheoryOfNekomata 4 anos atrás
pai
commit
7889c4cdd7
10 arquivos alterados com 263 adições e 98 exclusões
  1. +2
    -0
      example/controllers/Channel.ts
  2. +1
    -1
      example/controllers/Generator.ts
  3. +47
    -1
      example/index.tsx
  4. +1
    -0
      example/services/SoundGenerator.ts
  5. +5
    -1
      example/services/generators/MidiGenerator.ts
  6. +1
    -1
      package.json
  7. +0
    -1
      src/components/Keyboard/Keyboard.stories.tsx
  8. +20
    -7
      src/components/Keyboard/Keyboard.tsx
  9. +176
    -86
      src/components/KeyboardMap/KeyboardMap.tsx
  10. +10
    -0
      src/services/midi.ts

+ 2
- 0
example/controllers/Channel.ts Ver arquivo

@@ -35,6 +35,8 @@ export const handle: Handle = ({ setKeyChannels, generator, channel, }) => newKe
const keysOff = oldKeys.filter((ok) => !newKeysKeys.includes(ok.key))
const keysOn = newKeys.filter((nk) => !oldKeysKeys.includes(nk.key))



keysOn.forEach((k) => {
generator.noteOn(channel, k.key, Math.floor(k.velocity * 127))
})


+ 1
- 1
example/controllers/Generator.ts Ver arquivo

@@ -1,5 +1,5 @@
import SoundGenerator from '../services/SoundGenerator'
import MidiGenerator from '../services/generators/MidiGenerator'
import MidiGenerator, { MIDIOutput } from '../services/generators/MidiGenerator'
import WaveGenerator from '../services/generators/WaveGenerator'

type Load = () => Promise<SoundGenerator>


+ 47
- 1
example/index.tsx Ver arquivo

@@ -13,8 +13,11 @@ const App = () => {
const [keyChannels, setKeyChannels] = React.useState<{ key: number; velocity: number; channel: number }[]>([])
const [instruments, setInstruments, ] = React.useState<string[]>([])
const [instrument, setInstrument] = React.useState(0)
const [inputs, setInputs] = React.useState<any[]>([])
const [input, setInput] = React.useState<number>()
const generator = React.useRef<SoundGenerator | undefined>(undefined)
const scrollRef = React.useRef<HTMLDivElement>(null)
const midiInputRef = React.useRef<any>(null)

React.useEffect(() => {
if (!generator.current) {
@@ -38,6 +41,48 @@ const App = () => {
}
}, [scrollRef])

React.useEffect(() => {
const loadMIDIInputs = async () => {
const access = await navigator.requestMIDIAccess()
const inputs = Array.from(access.inputs.entries()).map(([handle, input]) => ({
handle,
input,
}))
midiInputRef.current = inputs[0].input
setInputs(inputs)
if (inputs.length > 0) {
setInput(0)
}
}

loadMIDIInputs()
}, [])

React.useEffect(() => {
const theInput = inputs[input]
const handleMidiMessage = (e: any) => {
const arg0 = e.data[0]
const arg1 = e.data[1]
const arg2 = e.data[2]

const type = arg0 & 0b11110000
if (type === 0b10010000 || type === 0b10000000) {
return
}
if (generator.current! && 'sendMessage' in generator.current!) {
generator.current!.sendMessage!(arg0 & 0b00001111, arg0 & 0b11110000, arg1, arg2)
}
}
if (theInput) {
theInput.input.addEventListener('midimessage', handleMidiMessage)
}
return () => {
if (theInput) {
theInput.input.removeEventListener('midimessage', handleMidiMessage)
}
}
}, [inputs, input])

return (
<React.Fragment>
<input
@@ -70,13 +115,14 @@ const App = () => {
id="keyboard-scroll"
>
<Keyboard
hasMap
startKey={0}
endKey={127}
keyChannels={keyChannels}
height="100%"
keyboardVelocity={0.75}
onChange={Channel.handle({ setKeyChannels, generator: generator.current!, channel, })}
keyboardMapping={keyboardMapping}
midiInput={inputs.length > 0 && typeof input! === 'number' ? inputs[input].input : undefined}
/>
</div>
</div>


+ 1
- 0
example/services/SoundGenerator.ts Ver arquivo

@@ -3,4 +3,5 @@ export default interface SoundGenerator {
noteOn(channel: number, key: number, velocity: number): void,
noteOff(channel: number, key: number, velocity: number): void,
getInstrumentNames(): string[],
sendMessage?(channel: number, type: number, arg1: number, arg2?: number): void,
}

+ 5
- 1
example/services/generators/MidiGenerator.ts Ver arquivo

@@ -2,7 +2,7 @@ import SoundGenerator from '../SoundGenerator'

type MIDIMessage = [number, number, number?]

interface MIDIOutput {
export interface MIDIOutput {
send(message: MIDIMessage): void
}

@@ -22,6 +22,10 @@ export default class MidiGenerator implements SoundGenerator {
this.output.send([0b11000000 + channel, patch])
}

sendMessage(channel: number, type: number, arg1: number, arg2?: number) {
this.output.send([type | channel, arg1, arg2])
}

getInstrumentNames(): string[] {
return [
'Acoustic Grand Piano',


+ 1
- 1
package.json Ver arquivo

@@ -1,5 +1,5 @@
{
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"main": "dist/index.js",
"typings": "dist/index.d.ts",


+ 0
- 1
src/components/Keyboard/Keyboard.stories.tsx Ver arquivo

@@ -223,7 +223,6 @@ const HasMapComponent = () => {
return (
<Wrapper>
<Keyboard
hasMap
startKey={21}
endKey={108}
keyChannels={keyChannels}


+ 20
- 7
src/components/Keyboard/Keyboard.tsx Ver arquivo

@@ -22,11 +22,6 @@ export const propTypes = {
*/
endKey: PropTypes.number.isRequired,

/**
* Does the component have a clickable map?
*/
hasMap: PropTypes.bool,

//octaveDivision: PropTypes.number,

/**
@@ -66,7 +61,7 @@ export const propTypes = {
*/
onChange: PropTypes.func,
/**
* Map from key code to key number.
* Map from key code to key number, used to activate the component from the keyboard.
*/
keyboardMapping: PropTypes.object,
/**
@@ -81,6 +76,17 @@ export const propTypes = {
* Destination of the component upon clicking a key, if behavior is set to 'link'.
*/
href: PropTypes.func,
/**
* MIDI input for sending MIDI messages to the component.
*/
midiInput: PropTypes.shape({
addEventListener: PropTypes.func.isRequired,
removeEventListener: PropTypes.func.isRequired,
}),
/**
* Received velocity when activating the component through the keyboard.
*/
keyboardVelocity: PropTypes.number,
}

type Props = PropTypes.InferProps<typeof propTypes>
@@ -89,7 +95,6 @@ type Props = PropTypes.InferProps<typeof propTypes>
* Component for displaying musical notes in the form of a piano keyboard.
* @param startKey - MIDI note of the first key.
* @param endKey - MIDI note of the last key.
* @param hasMap - The component's clickable map component.
* @param accidentalKeyLengthRatio - Ratio of the length of the accidental keys to the natural keys.
* @param keyChannels - Current active keys and their channel assignments.
* @param width - Width of the component.
@@ -98,6 +103,10 @@ type Props = PropTypes.InferProps<typeof propTypes>
* @param name - Name of the component used for forms.
* @param href - Destination of the component upon clicking a key, if behavior is set to 'link'.
* @param behavior - Behavior of the component when clicking.
* @param onChange - Event handler triggered upon change in activated keys in the component.
* @param keyboardMapping - Map from key code to key number, used to activate the component from the keyboard.
* @param midiInput - Can MIDI input messages activate the component?
* @param keyboardVelocity - Received velocity when activating the component through the keyboard.
*/
const Keyboard: React.FC<Props> = ({
startKey,
@@ -113,6 +122,8 @@ const Keyboard: React.FC<Props> = ({
behavior,
name,
href,
midiInput,
keyboardVelocity,
}) => {
const [clientSide, setClientSide] = React.useState(false)
const [clientSideKeys, setClientSideKeys] = React.useState<number[]>([])
@@ -225,6 +236,8 @@ const Keyboard: React.FC<Props> = ({
accidentalKeyLengthRatio={accidentalKeyLengthRatio}
onChange={onChange}
keyboardMapping={keyboardMapping}
midiInput={midiInput}
keyboardVelocity={keyboardVelocity}
/>
)}
</div>


+ 176
- 86
src/components/KeyboardMap/KeyboardMap.tsx Ver arquivo

@@ -1,6 +1,7 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import reverseGetKeyFromPoint from '../../services/reverseGetKeyFromPoint'
import { MIDIMessageEvent } from '../../services/midi'

const propTypes = {
/**
@@ -15,6 +16,17 @@ const propTypes = {
* Map from key code to key number.
*/
keyboardMapping: PropTypes.object,
/**
* Received velocity when activating the component through the keyboard.
*/
keyboardVelocity: PropTypes.number,
/**
* MIDI input for sending MIDI messages to the component.
*/
midiInput: PropTypes.shape({
addEventListener: PropTypes.func.isRequired,
removeEventListener: PropTypes.func.isRequired,
}),
}

type Props = PropTypes.InferProps<typeof propTypes>
@@ -24,31 +36,34 @@ type Props = PropTypes.InferProps<typeof propTypes>
* @param accidentalKeyLengthRatio - Ratio of the length of the accidental keys to the natural keys.
* @param onChange - Event handler triggered upon change in activated keys in the component.
* @param keyboardMapping - Map from key code to key number.
* @param midiInput - MIDI input for sending MIDI messages to the component.
* @param keyboardVelocity - Received velocity when activating the component through the keyboard.
*/
const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyboardMapping = {} }) => {
const KeyboardMap: React.FC<Props> = ({
accidentalKeyLengthRatio,
onChange,
keyboardMapping = {},
midiInput,
keyboardVelocity = 0.75,
}) => {
const baseRef = React.useRef<HTMLDivElement>(null)
const keysOnRef = React.useRef<any[]>([])
const lastVelocity = React.useRef<number | undefined>(undefined)
const isTouch = React.useRef<boolean>(false)

const handleContextMenu: React.EventHandler<any> = (e) => {
e.preventDefault()
}

const handleDragStart: React.DragEventHandler = (e) => {
const preventDefault: React.EventHandler<React.SyntheticEvent> = (e) => {
e.preventDefault()
}

const handleMouseDown: React.MouseEventHandler = (e) => {
if (isTouch.current) {
return
}
if (baseRef.current === null) {
return
}
if (baseRef.current.parentElement === null) {
return
}
if (e.buttons !== 1) {
return
}
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)(
e.clientX,
e.clientY,
@@ -56,56 +71,62 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb
if (keyData! === null) {
return
}

if (e.buttons === 1) {
if (lastVelocity.current === undefined) {
lastVelocity.current = keyData.velocity > 1 ? 1 : keyData.velocity < 0 ? 0 : keyData.velocity
}
keysOnRef.current = [...keysOnRef.current, { ...keyData, velocity: lastVelocity.current, id: -1 }]
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
}
}

const handleTouchStart: React.TouchEventHandler = (e) => {
isTouch.current = true
if (baseRef.current === null) {
return
if (lastVelocity.current === undefined) {
lastVelocity.current = keyData.velocity > 1 ? 1 : keyData.velocity < 0 ? 0 : keyData.velocity
}
if (baseRef.current.parentElement === null) {
return
keysOnRef.current = [...keysOnRef.current, { ...keyData, velocity: lastVelocity.current, id: -1 }]
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
}

Array.from(e.changedTouches).forEach((t) => {
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)(
t.clientX,
t.clientY,
)
if (keyData! === null) {
React.useEffect(() => {
const baseRefCurrent = baseRef.current
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault()
if (baseRef.current === null) {
return
}
if (lastVelocity.current === undefined) {
lastVelocity.current = keyData.velocity > 1 ? 1 : keyData.velocity < 0 ? 0 : keyData.velocity
if (baseRef.current.parentElement === null) {
return
}
keysOnRef.current = [...keysOnRef.current, { ...keyData, velocity: lastVelocity.current, id: t.identifier }]
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
const touches = Array.from(e.changedTouches)
const touchKeyData = touches.map<[React.Touch, { key: number; velocity: number } | null]>((t) => [
t,
reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)(t.clientX, t.clientY),
])
const validTouchKeyData = touchKeyData.filter(([, keyData]) => keyData! !== null)
validTouchKeyData.forEach(([t, keyData]) => {
const theKeyData = keyData!
if (lastVelocity.current === undefined) {
lastVelocity.current = theKeyData.velocity > 1 ? 1 : theKeyData.velocity < 0 ? 0 : theKeyData.velocity
}
keysOnRef.current = [...keysOnRef.current, { ...keyData, velocity: lastVelocity.current, id: t.identifier }]
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
})
}

if (baseRefCurrent !== null) {
baseRefCurrent.addEventListener('touchstart', handleTouchStart, { passive: false })
}
return () => {
if (baseRefCurrent !== null) {
baseRefCurrent.removeEventListener('touchstart', handleTouchStart)
}
})
}
}
}, [accidentalKeyLengthRatio, onChange])

React.useEffect(() => {
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault()
if (baseRef.current === null) {
return
}
if (baseRef.current.parentElement === null) {
return
}

e.preventDefault()

Array.from(e.changedTouches).forEach((t) => {
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)(
t.clientX,
@@ -118,7 +139,6 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb
}
return
}

const [mouseKey = null] = keysOnRef.current.filter((k) => k.id === t.identifier)
if (mouseKey === null) {
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== t.identifier)
@@ -158,36 +178,35 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb
if (baseRef.current.parentElement === null) {
return
}

if (e.buttons === 1) {
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement, accidentalKeyLengthRatio!)(
e.clientX,
e.clientY,
)
if (keyData! === null) {
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1)
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
return
if (e.buttons !== 1) {
return
}
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement, accidentalKeyLengthRatio!)(
e.clientX,
e.clientY,
)
if (keyData! === null) {
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1)
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}

const [mouseKey = null] = keysOnRef.current.filter((k) => k.id === -1)
if (mouseKey === null) {
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1)
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
return
return
}
const [mouseKey = null] = keysOnRef.current.filter((k) => k.id === -1)
if (mouseKey === null) {
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1)
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
if (mouseKey.key !== keyData.key) {
keysOnRef.current = [
...keysOnRef.current.filter((k) => k.id !== -1),
{ ...keyData, velocity: lastVelocity.current, id: -1 },
]
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
return
}
if (mouseKey.key !== keyData.key) {
keysOnRef.current = [
...keysOnRef.current.filter((k) => k.id !== -1),
{ ...keyData, velocity: lastVelocity.current, id: -1 },
]
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
}
}
@@ -218,7 +237,29 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb
return () => {
window.removeEventListener('touchend', handleTouchEnd)
}
})
}, [onChange])

React.useEffect(() => {
const handleTouchCancel = (e: TouchEvent) => {
if (baseRef.current === null) {
return
}
if (baseRef.current.parentElement === null) {
return
}
Array.from(e.changedTouches).forEach((t) => {
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== t.identifier)
lastVelocity.current = undefined
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
})
}
window.addEventListener('touchcancel', handleTouchCancel)
return () => {
window.removeEventListener('touchcancel', handleTouchCancel)
}
}, [onChange])

React.useEffect(() => {
const handleMouseUp = (e: MouseEvent) => {
@@ -244,16 +285,16 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb

React.useEffect(() => {
const baseRefComponent = baseRef.current
const theKeyboardMapping = keyboardMapping as Record<string, number>
const handleKeyDown = (e: KeyboardEvent) => {
if (!keyboardMapping!) {
if (!theKeyboardMapping) {
return
}

if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) {
return
}

const { [e.code]: key = null } = keyboardMapping as Record<string, number>
const { [e.code]: key = null } = theKeyboardMapping

if (key === null) {
return
@@ -262,7 +303,7 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb
if (keysOnRef.current.some((k) => k.key === key && k.id === -2)) {
return
}
keysOnRef.current = [...keysOnRef.current, { key, velocity: 0.75, id: -2 }]
keysOnRef.current = [...keysOnRef.current, { key, velocity: keyboardVelocity, id: -2 }]
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
@@ -276,19 +317,19 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb
baseRefComponent.removeEventListener('keydown', handleKeyDown)
}
}
})
}, [onChange, keyboardMapping, keyboardVelocity])

React.useEffect(() => {
const theKeyboardMapping = keyboardMapping as Record<string, number>
const handleKeyUp = (e: KeyboardEvent) => {
if (!keyboardMapping!) {
if (!theKeyboardMapping) {
return
}

if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) {
return
}

const { [e.code]: key = null } = keyboardMapping as Record<string, number>
const { [e.code]: key = null } = theKeyboardMapping

if (key === null) {
return
@@ -304,7 +345,57 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb
return () => {
window.removeEventListener('keyup', handleKeyUp)
}
})
}, [onChange, keyboardMapping])

React.useEffect(() => {
const handleMidiMessage = (e: MIDIMessageEvent) => {
const arg0 = e.data[0]
const arg1 = e.data[1]
const arg2 = e.data[2]

let key: number
let velocity: number

switch (arg0 & 0b11110000) {
case 0b10010000:
velocity = arg2 & 0b01111111
key = arg1 & 0b01111111
if (velocity > 0) {
keysOnRef.current = [
...keysOnRef.current,
{
key,
velocity: velocity / 127,
id: -3,
},
]
} else {
keysOnRef.current = keysOnRef.current.filter((k) => k.key !== key)
}
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
break
case 0b10000000:
key = arg1 & 0b01111111
keysOnRef.current = keysOnRef.current.filter((k) => k.key !== key)
if (typeof onChange! === 'function') {
onChange(keysOnRef.current)
}
break
default:
return
}
}
if (midiInput!) {
midiInput!.addEventListener('midimessage', handleMidiMessage)
}
return () => {
if (midiInput!) {
midiInput!.removeEventListener('midimessage', handleMidiMessage)
}
}
}, [midiInput, onChange])

return (
<div
@@ -319,10 +410,9 @@ const KeyboardMap: React.FC<Props> = ({ accidentalKeyLengthRatio, onChange, keyb
outline: 0,
cursor: 'pointer',
}}
onContextMenu={handleContextMenu}
onDragStart={handleDragStart}
onContextMenu={preventDefault}
onDragStart={preventDefault}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
tabIndex={0}
/>
)


+ 10
- 0
src/services/midi.ts Ver arquivo

@@ -0,0 +1,10 @@
export type MIDIMessageEventHandler = (e: MIDIMessageEvent) => void

export interface MIDIInput {
addEventListener(event: 'onmidimessage', handler: MIDIMessageEventHandler): void
removeEventListener(event: 'onmidimessage', handler: MIDIMessageEventHandler): void
}

export interface MIDIMessageEvent {
data: Uint8Array
}

Carregando…
Cancelar
Salvar