Musings from my desk

Loop Supreme, part 3: Metronome click

2022-11-06 10:42:28 +0000 UTC

This is part 3 in a series:

Goals

  1. Add an audible “click” when the metronome advances to the next tick. I’m excited because this is the time I’ll use the Web Audio API!
  2. Keep it absolutely dead simple. There will likely be lots of refactoring as I understand more about how to properly initialize and route the audio, so there’s no need to search for the “perfect” solution right now

Implementation

Producing a tone

To keep things dead simple, I wanted to use an OscillatorNode to play a simple sine wave on each beat.

It turns out this is actually fairly simple, and there is ample documentation on MDN about how to create a simple OscillatorNode.

Playing in time

After I was able to produce a tone, I needed to synchronize the tone with the metronome tick. There is a great tutorial on MDN on how to do this very thing, but since I’m using React I had to substantially alter their example to fit the React paradigm. The queue-based playback mechanism they demonstrate probably works great for a simple script, but I knew that mutating lots of state within a React component would be bad news bears. Unless I was prepared to use a bunch of refs and useEffect hooks, I knew that I wouldn’t want to copy their example precisely.

After some experimentation, it seemed that simply adding a playTone() call inside my useInterval callback was sufficient to make the metronome work! Keep in mind, I did not scientifically test this to make sure it’s playing in perfect time, but it seems to be “good enough” for now. I have about 98% certainty that I will need to refactor this in the future, but for now the timing appears accurate from simple observation.

Learnings

There were lots of interesting surprises in this one! That makes sense, given that I’ve never touched the Web Audio API before in my life. It was fun to wrap my brain around some of the suggested patterns, and understand why the API is built the way it is. Here are some things that surprised me specifically:

  1. OscillatorNodes can only be started once! Since I’m using an OscillatorNode for the main “beep” of the metronome, that means I had to create a new OscillatorNode every time I wanted to play a tone. Of course this isn’t complex from a code perspective, but my initial assumption was that it would be preferable to create a ref of an OscillatorNode and then start()/stop() it every time I wanted to emit a tone. Turns out this is not the recommended pattern, and creating a new node every time is correct. (This also matches the examples in the MDN docs.)
  2. AudioContext is not supposed to be created without user input. This is shown as a console warning; in Firefox it reads: “An AudioContext was prevented from starting automatically. It must be created or resumed after a user gesture on the page.” This contributed to my first scope creep! I realized that I needed to add a way for the user to start the metronome, without it auto-playing on page load. I expect in the future I’ll defer on creating the AudioContext at all until the user clicks “play” (as opposed to my current pattern where I instantiate the AudioContext immediately, but allow the user to start it explicitly with the “play” button). I decided to punt on that until I understood my final patterns and routing a bit more.
  3. AudioContext has a high precision timer built in that can be accessed via audioContext.currentTime. This timer is used as the source of truth in the MDN example of playing in time, and I suspect I may refactor to use this as my source of truth as well. However, I’m currently pleased that a simple JS setInterval is keeping time that appears pretty accurate from a human perspective.

State of the app

  1. Merged PR https://github.com/ericyd/loop-supreme/pull/4
  2. Metronome makes a noise!
  3. Metronome can be started/stopped by user
  4. Minor styling updates

Time log