Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keyboard bindings #24

Merged
merged 9 commits into from
Nov 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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