From 7889c4cdd70fe1c683ca63059c41df8393bd3578 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sun, 4 Oct 2020 15:20:55 +0800 Subject: [PATCH] Add MIDI event handling Support MIDI event handling by suppling a `MIDIInput` object through the `midiInput` prop. --- example/controllers/Channel.ts | 2 + example/controllers/Generator.ts | 2 +- example/index.tsx | 48 +++- example/services/SoundGenerator.ts | 1 + example/services/generators/MidiGenerator.ts | 6 +- package.json | 2 +- src/components/Keyboard/Keyboard.stories.tsx | 1 - src/components/Keyboard/Keyboard.tsx | 27 +- src/components/KeyboardMap/KeyboardMap.tsx | 262 +++++++++++++------ src/services/midi.ts | 10 + 10 files changed, 263 insertions(+), 98 deletions(-) create mode 100644 src/services/midi.ts diff --git a/example/controllers/Channel.ts b/example/controllers/Channel.ts index 246d484..a080d14 100644 --- a/example/controllers/Channel.ts +++ b/example/controllers/Channel.ts @@ -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)) }) diff --git a/example/controllers/Generator.ts b/example/controllers/Generator.ts index e26c21a..5cf36b4 100644 --- a/example/controllers/Generator.ts +++ b/example/controllers/Generator.ts @@ -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 diff --git a/example/index.tsx b/example/index.tsx index f052df9..fb69f9f 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -13,8 +13,11 @@ const App = () => { const [keyChannels, setKeyChannels] = React.useState<{ key: number; velocity: number; channel: number }[]>([]) const [instruments, setInstruments, ] = React.useState([]) const [instrument, setInstrument] = React.useState(0) + const [inputs, setInputs] = React.useState([]) + const [input, setInput] = React.useState() const generator = React.useRef(undefined) const scrollRef = React.useRef(null) + const midiInputRef = React.useRef(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 ( { id="keyboard-scroll" > 0 && typeof input! === 'number' ? inputs[input].input : undefined} /> diff --git a/example/services/SoundGenerator.ts b/example/services/SoundGenerator.ts index 329e649..b5b5b9a 100644 --- a/example/services/SoundGenerator.ts +++ b/example/services/SoundGenerator.ts @@ -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, } diff --git a/example/services/generators/MidiGenerator.ts b/example/services/generators/MidiGenerator.ts index 0469bc9..17f849d 100644 --- a/example/services/generators/MidiGenerator.ts +++ b/example/services/generators/MidiGenerator.ts @@ -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', diff --git a/package.json b/package.json index 3312c0e..6dd3d27 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/src/components/Keyboard/Keyboard.stories.tsx b/src/components/Keyboard/Keyboard.stories.tsx index 9c2bf99..1bb9651 100644 --- a/src/components/Keyboard/Keyboard.stories.tsx +++ b/src/components/Keyboard/Keyboard.stories.tsx @@ -223,7 +223,6 @@ const HasMapComponent = () => { return ( @@ -89,7 +95,6 @@ type Props = PropTypes.InferProps * 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 * @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 = ({ startKey, @@ -113,6 +122,8 @@ const Keyboard: React.FC = ({ behavior, name, href, + midiInput, + keyboardVelocity, }) => { const [clientSide, setClientSide] = React.useState(false) const [clientSideKeys, setClientSideKeys] = React.useState([]) @@ -225,6 +236,8 @@ const Keyboard: React.FC = ({ accidentalKeyLengthRatio={accidentalKeyLengthRatio} onChange={onChange} keyboardMapping={keyboardMapping} + midiInput={midiInput} + keyboardVelocity={keyboardVelocity} /> )} diff --git a/src/components/KeyboardMap/KeyboardMap.tsx b/src/components/KeyboardMap/KeyboardMap.tsx index 2fe616d..ebde761 100644 --- a/src/components/KeyboardMap/KeyboardMap.tsx +++ b/src/components/KeyboardMap/KeyboardMap.tsx @@ -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 @@ -24,31 +36,34 @@ type Props = PropTypes.InferProps * @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 = ({ accidentalKeyLengthRatio, onChange, keyboardMapping = {} }) => { +const KeyboardMap: React.FC = ({ + accidentalKeyLengthRatio, + onChange, + keyboardMapping = {}, + midiInput, + keyboardVelocity = 0.75, +}) => { const baseRef = React.useRef(null) const keysOnRef = React.useRef([]) const lastVelocity = React.useRef(undefined) - const isTouch = React.useRef(false) - const handleContextMenu: React.EventHandler = (e) => { - e.preventDefault() - } - - const handleDragStart: React.DragEventHandler = (e) => { + const preventDefault: React.EventHandler = (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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ accidentalKeyLengthRatio, onChange, keyb React.useEffect(() => { const baseRefComponent = baseRef.current + const theKeyboardMapping = keyboardMapping as Record 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 + const { [e.code]: key = null } = theKeyboardMapping if (key === null) { return @@ -262,7 +303,7 @@ const KeyboardMap: React.FC = ({ 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 = ({ accidentalKeyLengthRatio, onChange, keyb baseRefComponent.removeEventListener('keydown', handleKeyDown) } } - }) + }, [onChange, keyboardMapping, keyboardVelocity]) React.useEffect(() => { + const theKeyboardMapping = keyboardMapping as Record 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 + const { [e.code]: key = null } = theKeyboardMapping if (key === null) { return @@ -304,7 +345,57 @@ const KeyboardMap: React.FC = ({ 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 (
= ({ accidentalKeyLengthRatio, onChange, keyb outline: 0, cursor: 'pointer', }} - onContextMenu={handleContextMenu} - onDragStart={handleDragStart} + onContextMenu={preventDefault} + onDragStart={preventDefault} onMouseDown={handleMouseDown} - onTouchStart={handleTouchStart} tabIndex={0} /> ) diff --git a/src/services/midi.ts b/src/services/midi.ts new file mode 100644 index 0000000..527ca59 --- /dev/null +++ b/src/services/midi.ts @@ -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 +}