Skip to content

Commit

Permalink
Keyboard bindings (#24)
Browse files Browse the repository at this point in the history
* Rename AudioRouter to AudioProvider

* add Keyboard context

* bind some keyboard actions

* select tracks on 0-9

* update keyboard binding display

* Fix Track controls

* update roadmnap

* make KeyboardBindings data driven
  • Loading branch information
ericyd authored Nov 25, 2022
1 parent f6c39d6 commit 572ca6e
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 63 deletions.
10 changes: 5 additions & 5 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 7 additions & 4 deletions src/App/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -9,9 +10,11 @@ type Props = {

function App(props: Props) {
return (
<AudioProvider stream={props.stream} audioContext={props.audioContext}>
<Metronome />
</AudioProvider>
<KeyboardProvider>
<AudioProvider stream={props.stream} audioContext={props.audioContext}>
<Metronome />
</AudioProvider>
</KeyboardProvider>
)
}

Expand Down
12 changes: 5 additions & 7 deletions src/AudioRouter/index.tsx → src/AudioProvider/index.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -24,11 +22,11 @@ export const AudioProvider: React.FC<Props> = ({ children, ...adapter }) => {
return <AudioRouter.Provider value={adapter}>{children}</AudioRouter.Provider>
}

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
Expand Down
21 changes: 19 additions & 2 deletions src/ControlPanel/MetronomeControls.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useCallback, useEffect } from 'react'
import PlayPause from '../icons/PlayPause'
import { useKeyboard } from '../KeyboardProvider'
import { VolumeControl } from './VolumeControl'

type MetronomeControlProps = {
Expand All @@ -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 (
<div className="flex items-start content-center mb-2 mr-2">
Expand Down
120 changes: 120 additions & 0 deletions src/KeyboardProvider/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string, EventHandler[]>

const KeyboardContext = createContext<KeyboardController | null>(null)

type Props = {
children: React.ReactNode
}

export const KeyboardProvider: React.FC<Props> = ({ 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 (
<KeyboardContext.Provider value={controller}>
{children}
</KeyboardContext.Provider>
)
}

export function useKeyboard() {
const keyboard = useContext(KeyboardContext)

if (keyboard === null) {
throw new Error('useKeyboard cannot be used outside of KeyboardProvider')
}

return keyboard
}
53 changes: 53 additions & 0 deletions src/Metronome/KeyboardBindings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h2 className="text-xl mb-8 mt-16">Keyboard controls</h2>
<table className="">
<thead>
<tr>
<th className="text-left">Key</th>
<th className="text-left">Binding</th>
</tr>
</thead>
<tbody>
{Object.entries(globalKeyBindings).map(([key, action]) => (
<tr>
<td className="w-32">{key}</td>
<td>{action}</td>
</tr>
))}

<tr>
<td colSpan={2}>
<em>After selecting track</em>
</td>
</tr>

{Object.entries(trackKeyBindings).map(([key, action]) => (
<tr>
<td className="w-32">{key}</td>
<td>{action}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
6 changes: 4 additions & 2 deletions src/Metronome/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -39,7 +40,7 @@ type Props = {
}

export const Metronome: React.FC<Props> = () => {
const { audioContext } = useAudioRouter()
const { audioContext } = useAudioContext()
const [currentTick, setCurrentTick] = useState(-1)
const [bpm, setBpm] = useState(120)
const [timeSignature, setTimeSignature] = useState<TimeSignature>({
Expand Down Expand Up @@ -187,6 +188,7 @@ export const Metronome: React.FC<Props> = () => {
<>
<ControlPanel metronome={reader} metronomeWriter={writer} />
<Scene metronome={reader} />
<KeyboardBindings />
</>
)
}
47 changes: 43 additions & 4 deletions src/Scene/index.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -9,12 +10,13 @@ type Props = {
}

export const Scene: React.FC<Props> = ({ 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 },
])
}

Expand All @@ -27,12 +29,49 @@ export const Scene: React.FC<Props> = ({ 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 }) => (
<Track
key={id}
id={id}
selected={selected}
onRemove={handleRemoveTrack(id)}
metronome={metronome}
/>
Expand Down
Loading

0 comments on commit 572ca6e

Please sign in to comment.