Table of contents
Heads up! I've decided to self-manage my blog. I'm leaving this post live as to not "break the internet" but feel free check out the post on my new blog here! blog.ericyd.com/loop-supreme-part-10-keyboa..
This is part 10 in a series about building a browser-based audio live looper
Part 10: Keyboard bindings
Goal
Fix the audio buffer length, so it matches the loop length
Add keyboard bindings for common user interactions
Implementation
Fixing the audio buffer length ended up being the easy part of this update. The PR is quite simple - it was really just a matter of adding a few dependencies to the dependency array when building the recording callback, and then using the metronome settings to calculate the correct number of samples for the buffer.
Adding keyboard bindings proved much more difficult. I tried just making a module exposed a method to wrap window.addEventListener('keydown', cb)
, but the issue I ran into was that cleanup effects didn't happen gracefully and a bunch of additional event listeners were created.
I then decided to use a context. My thinking was: create a single callback that delegates the event based on which key was pressed. To delegate the events, I just created a mutable map which mapped keys to callbacks. This actually worked shockingly well for such a simple model, until I tried to bind events to Tracks. The UX I had in mind was that a user could select a specific track with the 0-9
keys; then, once a track was "selected", the user could trigger track-specific behaviors like muting (m
) or arming for recording (r
). The problem was that this required adding and removing elements from the callback map fairly rapidly when different tracks were selected. I'm sure it would be possible to code this correctly, but I found it frustrating to figure out the nuances of the effects in React.
To accommodate this behavior, I decided to make the "callback map" a map of keys to lists of callbacks, where each element of the list had an ID associated with it. This allowed the bindings to be de-duped on add, and also cleared correctly. It more closely resembles a traditional EventTarget
, which perhaps indicates that I should have just used an EventTarget
instead of rolling my own janky version. Regardless, the behavior worked as I hoped, and I ended up using this!
Learnings
- Keyboard event listeners are not optimized for binding callbacks to specific keys. Using a mutable map to define event handlers for specific keys ended up being a very useful pattern to follow. I'm curious if there are technical reasons that it shouldn't be done this way, but from a pragmatic perspective it seemed quite successful.
State of the app
Merged PRs
Track buffers now have the correct length, which makes the loops align properly
The app can be controlled with a keyboard more easily, since main actions are bound to specific keyboard events
Next steps
Add ability to export audio (at least stems - I think it will be quite challenging to record a live performance)
Add ability to select input per track
Some UX improvements I've noticed as I've been testing