Loop Supreme, part 10: Keyboard bindings

Loop Supreme, part 10: Keyboard bindings

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

Goal

  1. Fix the audio buffer length, so it matches the loop length

  2. 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

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