diff --git a/roadmap.md b/roadmap.md index 9aee3c9..6511e0f 100644 --- a/roadmap.md +++ b/roadmap.md @@ -72,11 +72,11 @@ A `Track` is a single mono or stereo audio buffer that contains audio data. A `T ## Keyboard -- [ ] Keyboard shortcuts are added for most common actions - - `1`, `2`, `3`, etc select a track - - once a track is selected, `r` toggles "armed for recording", `m` toggles mute - - `t` is "tap tempo" - - `space` is play/pause +- [x] Keyboard shortcuts are added for most common actions https://github.com/ericyd/loop-supreme/pull/24 + - [x] `1`, `2`, `3`, etc select a track + - [x] once a track is selected, `r` toggles "armed for recording", `m` toggles mute + - [x] `space` is play/pause +- [ ] add "tap tempo" functionlity and bind to `t` key ## HTML diff --git a/src/App/index.tsx b/src/App/index.tsx index e0a029f..a5b3a79 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -1,5 +1,6 @@ import React from 'react' -import { AudioProvider } from '../AudioRouter' +import { AudioProvider } from '../AudioProvider' +import { KeyboardProvider } from '../KeyboardProvider' import { Metronome } from '../Metronome' type Props = { @@ -9,9 +10,11 @@ type Props = { function App(props: Props) { return ( - - - + + + + + ) } diff --git a/src/AudioRouter/index.tsx b/src/AudioProvider/index.tsx similarity index 59% rename from src/AudioRouter/index.tsx rename to src/AudioProvider/index.tsx index f3fe041..5d9f839 100644 --- a/src/AudioRouter/index.tsx +++ b/src/AudioProvider/index.tsx @@ -1,9 +1,7 @@ /** - * AudioContext is already a global that is used extensively in this app. - * Although this is a "React Context", it seemed more important to avoid naming collisions, - * hence "AudioRouter" - * - * TODO: should this context be removed and values just be passed around as props? + * Exposes AudioContext (the web audio kind, not a React context) + * and a MediaStream globally. + * This could probably just be passed as props, but this is marginally more convenient. */ import React, { createContext, useContext } from 'react' @@ -24,11 +22,11 @@ export const AudioProvider: React.FC = ({ children, ...adapter }) => { return {children} } -export function useAudioRouter() { +export function useAudioContext() { const audioRouter = useContext(AudioRouter) if (audioRouter === null) { - throw new Error('useAudioRouter cannot be used outside of AudioProvider') + throw new Error('useAudioContext cannot be used outside of AudioProvider') } return audioRouter diff --git a/src/ControlPanel/MetronomeControls.tsx b/src/ControlPanel/MetronomeControls.tsx index 7bdebe6..9839255 100644 --- a/src/ControlPanel/MetronomeControls.tsx +++ b/src/ControlPanel/MetronomeControls.tsx @@ -1,4 +1,6 @@ +import { useCallback, useEffect } from 'react' import PlayPause from '../icons/PlayPause' +import { useKeyboard } from '../KeyboardProvider' import { VolumeControl } from './VolumeControl' type MetronomeControlProps = { @@ -10,9 +12,24 @@ type MetronomeControlProps = { gain: number } export default function MetronomeControl(props: MetronomeControlProps) { - const toggleMuted = () => { + const keyboard = useKeyboard() + const toggleMuted = useCallback(() => { props.setMuted((muted) => !muted) - } + }, [props]) + + useEffect(() => { + keyboard.on('c', 'Metronome', toggleMuted) + // kinda wish I could write "space" but I guess this is the way this works. + keyboard.on(' ', 'Metronome', (e) => { + // Only toggle playing if another control element is not currently focused + if ( + !['SELECT', 'BUTTON'].includes(document.activeElement?.tagName ?? '') + ) { + props.togglePlaying() + e.preventDefault() + } + }) + }, [keyboard, props, toggleMuted]) return (
diff --git a/src/KeyboardProvider/index.tsx b/src/KeyboardProvider/index.tsx new file mode 100644 index 0000000..e1e5b13 --- /dev/null +++ b/src/KeyboardProvider/index.tsx @@ -0,0 +1,120 @@ +/** + * Exposes a context that can be used to bind keyboard events throughout the app. + * Keydown/Keyup listeners are easy to add, but the syntax is kind of annoying + * because the callback has to check for the right key. + * This is a more convenient wrapper for `window.addEventListener('keydown', callback). + * Perhaps this doesn't need to be a context at all, and can just be an exported function + * that wraps `window.addEventListener` -- we'll see! + * + * (intended) Usage: + * + * function MyComponent() { + * const keyboard = useKeyboard() + * + * function myFunction() { + * // do something + * } + * + * keyboard.on('a', 'id', myFunction) + * } + */ +import React, { createContext, useContext, useEffect, useMemo } from 'react' +import { logger } from '../util/logger' + +type KeyboardEventHandler = (event: KeyboardEvent) => void + +type KeyboardController = { + on(key: string, id: string, callback: KeyboardEventHandler): void + off(key: string, id: string): void +} + +type EventHandler = { + id?: string + callback: KeyboardEventHandler +} +type CallbackMap = Record + +const KeyboardContext = createContext(null) + +type Props = { + children: React.ReactNode +} + +export const KeyboardProvider: React.FC = ({ children }) => { + // callbackMap is a map of keys to EventHandlers. + // EventHandlers contain an (optional) ID and a callback. + // The ID allows deduplication, so that multiple event registrations + // do not result in multiple callback calls. + // The ID also allows us to register multiple EventHandlers for a single key; + // this is primarily useful for the event registrations on Tracks, + // since they are added and removed depending on whether the track is selected. + const callbackMap: CallbackMap = useMemo( + () => ({ + Escape: [ + { + callback: () => { + // @ts-expect-error this is totally valid, not sure why TS doesn't think so + const maybeFn = document.activeElement?.blur?.bind( + document.activeElement + ) + if (typeof maybeFn === 'function') { + maybeFn() + } + }, + }, + ], + }), + [] + ) + + useEffect(() => { + const keydownCallback = (e: KeyboardEvent) => { + logger.debug({ key: e.key, meta: e.metaKey, shift: e.shiftKey }) + callbackMap[e.key]?.map((item) => item.callback(e)) + } + window.addEventListener('keydown', keydownCallback) + return () => { + window.removeEventListener('keydown', keydownCallback) + } + }, [callbackMap]) + + const controller = { + on(key: string, id: string, callback: KeyboardEventHandler) { + if (Array.isArray(callbackMap[key])) { + const index = callbackMap[key].findIndex((item) => item.id === id) + if (index < 0) { + callbackMap[key].push({ id, callback }) + } else { + callbackMap[key][index] = { id, callback } + } + } else { + callbackMap[key] = [{ id, callback }] + } + }, + off(key: string, id: string) { + if (!Array.isArray(callbackMap[key])) { + return // nothing to do + } + const index = callbackMap[key].findIndex((item) => item.id === id) + if (index >= 0) { + callbackMap[key].splice(index, 1) + } + }, + } + + return ( + + {children} + + ) +} + +export function useKeyboard() { + const keyboard = useContext(KeyboardContext) + + if (keyboard === null) { + throw new Error('useKeyboard cannot be used outside of KeyboardProvider') + } + + return keyboard +} diff --git a/src/Metronome/KeyboardBindings.tsx b/src/Metronome/KeyboardBindings.tsx new file mode 100644 index 0000000..3870f8f --- /dev/null +++ b/src/Metronome/KeyboardBindings.tsx @@ -0,0 +1,53 @@ +/** + * This is a simple display input, + * to inform users how to use keyboard bindings. + */ + +const globalKeyBindings = { + space: 'Play / pause', + c: 'Mute click track', + '0-9': 'Select track', +} + +const trackKeyBindings = { + r: 'Arm for recording', + m: 'Mute track', + i: 'Monitor input', +} + +export default function KeyboardBindings() { + return ( +
+

Keyboard controls

+ + + + + + + + + {Object.entries(globalKeyBindings).map(([key, action]) => ( + + + + + ))} + + + + + + {Object.entries(trackKeyBindings).map(([key, action]) => ( + + + + + ))} + +
KeyBinding
{key}{action}
+ After selecting track +
{key}{action}
+
+ ) +} diff --git a/src/Metronome/index.tsx b/src/Metronome/index.tsx index 9ad6b15..c8450d4 100644 --- a/src/Metronome/index.tsx +++ b/src/Metronome/index.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useAudioRouter } from '../AudioRouter' +import { useAudioContext } from '../AudioProvider' import { ControlPanel } from '../ControlPanel' import { Scene } from '../Scene' import type { ClockControllerMessage } from '../worklets/clock' +import KeyboardBindings from './KeyboardBindings' import { decayingSine } from './waveforms' export type TimeSignature = { @@ -39,7 +40,7 @@ type Props = { } export const Metronome: React.FC = () => { - const { audioContext } = useAudioRouter() + const { audioContext } = useAudioContext() const [currentTick, setCurrentTick] = useState(-1) const [bpm, setBpm] = useState(120) const [timeSignature, setTimeSignature] = useState({ @@ -187,6 +188,7 @@ export const Metronome: React.FC = () => { <> + ) } diff --git a/src/Scene/index.tsx b/src/Scene/index.tsx index c4133e6..618f31d 100644 --- a/src/Scene/index.tsx +++ b/src/Scene/index.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import ButtonBase from '../ButtonBase' import { Plus } from '../icons/Plus' +import { useKeyboard } from '../KeyboardProvider' import { MetronomeReader } from '../Metronome' import { Track } from '../Track' @@ -9,12 +10,13 @@ type Props = { } export const Scene: React.FC = ({ metronome }) => { - const [tracks, setTracks] = useState([{ id: 1 }]) + const keyboard = useKeyboard() + const [tracks, setTracks] = useState([{ id: 1, selected: false }]) function handleAddTrack() { setTracks((tracks) => [ ...tracks, - { id: Math.max(...tracks.map((t) => t.id)) + 1 }, + { id: Math.max(...tracks.map((t) => t.id)) + 1, selected: false }, ]) } @@ -27,12 +29,49 @@ export const Scene: React.FC = ({ metronome }) => { } } + const setSelected = (selectedIndex: number) => (event: KeyboardEvent) => { + if ('123456789'.includes(event.key)) { + setTracks((tracks) => + tracks.map((track, i) => ({ + ...track, + selected: i + 1 === selectedIndex, + })) + ) + } + + if (event.key === '0') { + setTracks((tracks) => + tracks.map((track, i) => ({ + ...track, + selected: i + 1 === 10, + })) + ) + } + } + + /** + * Attach keyboard events + */ + useEffect(() => { + keyboard.on('a', 'Scene', handleAddTrack) + for (let i = 0; i < 10; i++) { + keyboard.on(String(i), `Scene ${i}`, setSelected(i)) + } + return () => { + keyboard.off('a', 'Scene') + for (let i = 0; i < 10; i++) { + keyboard.off(String(i), `Scene ${i}`) + } + } + }, [keyboard]) + return ( <> - {tracks.map(({ id }) => ( + {tracks.map(({ id, selected }) => ( diff --git a/src/Track/index.tsx b/src/Track/index.tsx index 0e6a1f2..b4dc998 100644 --- a/src/Track/index.tsx +++ b/src/Track/index.tsx @@ -1,3 +1,11 @@ +/** + * Tracks are where the magic happens! + * A Track encapsulates a single audio recording stream, + * either mono or stereo. + * Tracks contain controls for recording, monitoring, muting, etc. + * After recording is complete, the audio buffer loops automatically, + * a nice feature of the Web Audio API. + */ import React, { ChangeEventHandler, useCallback, @@ -6,7 +14,7 @@ import React, { useRef, useState, } from 'react' -import { useAudioRouter } from '../AudioRouter' +import { useAudioContext } from '../AudioProvider' import { MetronomeReader } from '../Metronome' import { logger } from '../util/logger' import { VolumeControl } from './VolumeControl' @@ -22,11 +30,13 @@ import MonitorInput from './MonitorInput' import Mute from './Mute' import RemoveTrack from './RemoveTrack' import Waveform from './Waveform' +import { useKeyboard } from '../KeyboardProvider' type Props = { id: number onRemove(): void metronome: MetronomeReader + selected: boolean } type RecordingProperties = { @@ -61,8 +71,14 @@ type RecordingMessage = | ShareRecordingBufferMessage | UpdateWaveformMessage -export const Track: React.FC = ({ id, onRemove, metronome }) => { - const { audioContext, stream } = useAudioRouter() +export const Track: React.FC = ({ + id, + onRemove, + metronome, + selected, +}) => { + const { audioContext, stream } = useAudioContext() + const keyboard = useKeyboard() const [title, setTitle] = useState(`Track ${id}`) const [armed, setArmed] = useState(false) const toggleArmRecording = () => setArmed((value) => !value) @@ -307,49 +323,76 @@ export const Track: React.FC = ({ id, onRemove, metronome }) => { return () => { metronome.clock.removeEventListener('message', delegateClockMessage) } + // TODO: include dependency array so these event listeners aren't added/removed on every render }) + /** + * Attach keyboard listeners. + * For tracks, these are only applicable when the track is selected + */ + useEffect(() => { + logger.debug(`useEffect for track ${id}. Selected: ${selected}`) + if (selected) { + keyboard.on('r', `Track ${id}`, toggleArmRecording) + keyboard.on('i', `Track ${id}`, toggleMonitoring) + keyboard.on('m', `Track ${id}`, toggleMuted) + } + return () => { + logger.debug(`useEffect cleanup for track ${id}`) + keyboard.off('r', `Track ${id}`) + keyboard.off('i', `Track ${id}`) + keyboard.off('m', `Track ${id}`) + } + }, [selected, keyboard, id]) + return ( -
- {/* Controls */} -
- {/* Title, Record, Monitor */} -
- - - - -
+ <> +
+ {/* Controls */} +
+ {/* Title, Record, Monitor */} +
+ + + + +
- {/* Volume */} -
- -
+ {/* Volume */} +
+ +
- {/* Remove */} -
- + {/* Remove */} +
+ +
-
- {/* Waveform */} -
- + {/* Waveform */} +
+ +
-
+ {/* divider */} +
+ ) }