Musings from my desk

Loop Supreme, part 10: Keyboard bindings

2022-11-28 15:46:21 +0000 UTC

This is part 10 in a series about building a browser-based audio live looper


  1. Fix the audio buffer length, so it matches the loop length
  2. Add keyboard bindings for common user interactions


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!


State of the app

Next steps