Building a Banjo - A Coding Exercise

This article was originally released over 8 days on my email list. If you want to get content like this daily, join us on the list!

The Brief

I'd like you to choose a physical object that you interact with, and model it as a simple software program. The example I'm going to use is my banjo, but I encourage you to pick your own item and work in parallel to my examples.

There are a few constraints:

The object that you choose should take some sort of input or interaction from the user, which results in some sort of output. Try to find something that takes a few different inputs. For instance, on the banjo I can model the tuning of each string, the position of my left-hand fingers on the frets, and the force that I use to pick each string with my right. That gives me outputs of pitch, loudness, and duration.

Don't worry about modeling in real units or real-world accuracy, at least not at first. Worry more about getting conceptual relationships right. I'm not measuring the force of my fingers in newtons or the volume in decibels. An arbitrary numeric scale is fine. Once you have a working model, if it's interesting to you to go for scientific accuracy, then go for it.

Finally, until I explicitly tell you to do so, your program should NOT have a graphical interface. Don't even start thinking about the UI yet. Focus on how you represent the components' behaviors in code and data, not on how the user interacts with your program. Ideally, your first version is just a command line script. Some options for getting input to your program could be reading from a text file or prompting the user at runtime. For the output, a simple text readout is fine.

Minimum Viable Banjo

Here are the basic requirements I came up with for my Minimum Viable Banjo (hereafter, MVB):

  1. A reasonable representation of the range of notes that the banjo can play. That is to say, we need a way of naming notes. This is not so much a quality of the banjo, but the context in which its outputs exist.

  2. A representation of which pitch an individual string is tuned to

  3. A representation of finger position on the fretboard (the long neck of the banjo) which changes the pitch of each string. Each fret on the fretboard corresponds to a raise in pitch by one semitone (aka a half step).

  4. A representation of picking an individual string, which takes the base pitch of that string plus a fret position if applicable, and produces a pitch

  5. A representation of the note that is played when a string is picked. If we include a strength argument when picking, then the resulting note could reasonably have properties of pitch, volume, and duration.

Representing the range of tones

The notes can be represented by a one-dimensional array, like a piano keyboard, where each entry in the list is a half-step higher than its predecessor. It turns out this work has already been done for us in the form of MIDI note numbers, and we can simply use integers to refer to each note.

Individual Strings

The Banjo has five strings, each tuned to a particular pitch. Keeping with our MVB approach, we can minimally represent this in our banjo as follows.


class Banjo  {
  // array of MIDI note numbers. Note names in comments next
  // to each integer are the corresponding International Pitch Notation.
  // Don't worry if you don't know IPN, it's just a way of labeling pitches
  strings = [
    62, // D4
    59, // B3
    55, // G3
    50, // D3
    67, // G4
  ]
}

So there's our five-string banjo. It doesn't do much yet, but it's a start.

Fretting the strings

Fretting of the strings can similarly be represented by a simple array, one fret position for each string. 0 represents an open string, and any positive integer represents a finger on the corresponding fret, which raises the pitch of that string the same number of MIDI tones (ie. half-steps if you're musically trained)


class Banjo {
  strings = [ 62, 59, 55, 50, 67 ]
  frets = [ 0, 0, 0, 0, 0 ]
}

Picking a fretted string

With these two representations in place, implementing the basic interactions of fretting and picking become trivial.


class Banjo {
  strings = [ 62, 59, 55, 50, 67 ]
  frets = [ 0, 0, 0, 0, 0 ]


  fretString(stringIdx, fret) {
    this.frets[stringIdx] = fret
    return this.frets
  }

  pickString(stringIdx, strength) {
    const basePitch = this.strings[stringIdx]
    const fret = this.frets[stringIdx]
    // Adding the fret value to the base pitch, and returning an object
    // with pitch, volume, and duration.
    return ({
      pitch: basePitch + fret,
      volume: strength,
      duration: strength * strength
    })
  }
}

Running it

So, our minimum viable banjo is complete. We could run it like so:

const banjo = new Banjo()
banjo.pickString(2, 50)
// Object { pitch: 55, volume: 50, duration: 2500 }
// Picking the string at index 2 results in a G

banjo.fretString(2, 3)
// Array [ 0, 0, 3, 0, 0 ]
// Placing a finger on the third fret of the string at index 2

banjo.pickString(2, 100)
// Object { pitch: 58, volume: 100, duration: 10000 }
// Picking the string at index 2 now results in a B-flat

Shortcomings, and next steps

I'm always in favor of starting with the simplest possible implementation. This is a good start, but the thing about software is that it lives for a long time, and has to evolve as it satisfies edge cases and demands from different stakeholders over time.

I'm going to add two curveballs into this mix and show next how I would solve them.

First, the fifth string of the banjo is actually shorter than all of the others, and only reaches partway up the neck. Its first available fret is fret #6.

Second, in our implementation of a picked string, we've represented the duration that the note lasts. In real life, if you change the fret and re-pick the string, the note that is ringing will be stopped, and replaced with a new note. How are we going to represent this?

If you're working with the banjo example, see what you can come up with for these new requirements before moving on. If you're using an object of your own, Try to find similar edge cases and refinements to your model and implement them.

Adding a BanjoString class

Before I go any further I want to point out that the purpose of this exercise is not to build the perfect banjo. In fact, what we end up with will be monstrously over-engineered for the scale of application that it is.

No, the purpose of this exercise is to get you used to thinking about which components of your application are responsible for what, and how to create stable, predictable interactions between them. When we're done, I'll show you explicitly how these lessons apply to some of the frameworks and libraries that you are familiar with.

With all that said, and considering our new requirements, the thing that stands out to me is that the string of the banjo is starting to emerge as the fundamental element related to our inputs and outputs. The base pitch, the relative fretting, and the strength of the pick all relate directly to the string, and so I believe it makes sense to make the string its own class.

class BanjoString {

  fretPosition = 0

  constructor(opts = { basePitch: 0 }) {
    this.basePitch = opts.basePitch
  }

  fret(fretPosition) {
    this.fretPosition = fretPosition
  }

  pick(strength) {
    return {
      pitch: this.basePitch + this.fretPosition,
      volume: strength,
      duration: strength * strength
    }
  }
}

You'll notice the implementation of BanjoString looks a lot like the previous implementation of Banjo. We can now update Banjo as follows, to essentially delegate the fretting and picking to the appropriate strings:

class Banjo {
  strings = [
    new BanjoString({ basePitch: 62 }),
    new BanjoString({ basePitch: 59 }),
    new BanjoString({ basePitch: 55 }),
    new BanjoString({ basePitch: 50 }),
    new BanjoString({ basePitch: 67 })
  ]

  fretString(stringIndex, fret) {
    // deal with the edge case of the 5th string
    if (stringIndex === 4 && fret > 0 && fret < 6) {
      throw new Error (`Fret ${fret} does not exist on stringIndex 4`)
    }
    const fretPosition = stringIndex === 4 ? fret - 5 : fret

    this.strings[stringIndex].fret(fretPosition)
  }

  pickString(stringIndex, strength) {
    return this.strings[stringIndex].pick(strength)
  }
}

It may look like we've doubled the amount of source code here just to account for the edge case of the short string. You wouldn't be wrong to point that out, but we've also created clear conceptual boundaries between the properties of the banjo itself, and those of the strings. That's going to become important as the application gains complexity.

The next requirement is to account for picking a string before its previous note has decayed. In other words, replacing a ringing note with a note plucked on the same string.

For that, I'm going to make a very minor change, and then kick the can down the road. You see, producing sound is really the responsibility of the UI layer. We've specifically said that we're not focusing on the UI for this portion of the exercise, so all we need for the banjo itself is to identify which string a note was played on. When we implement a real UI (which we will, don't worry), we can address the logic of overlapping tones there.

A simple change to the pickString method on the Banjo object accomplishes that:

  pickString(stringIndex, strength) {
    const tone = this.strings[stringIndex].pick(strength)
    // attach stringIndex property to returned tone
    return {...tone, stringIndex }
  }

Here's a gist of the whole thing so far.

Later on we'll use this to implement a UI that plays MIDI tunes and leaves open the possibility of adapting to other UI implementations as well.

How is a Banjo Like an Express Router?

At this point, our Banjo class is basically a dumb pass-through to the implementation of BanjoString. Here's why:

For Christmas, my wife got me a banjo mute. I'm not going to read too much into her motivation for that gift. It looks like a heavy-duty metal hair clip with felt padding on the inside edges. You clip it to the bridge (the piece of wood near your picking hand that holds the strings up) and it quiets the instrument by dampening the vibrations before they reach the head and the resonator (the round part of the banjo)

So you can see as we add complexity and detail to our application, that the behaviors of the the banjo and the individual strings continue to diverge. To model this, I can add a muted property to the banjo, and then alter the properties of the emitted tone after it's returned from the string's pick method, but before it's returned from the banjo's pickString method.

class Banjo {
  muted = false
  strings = [/* unchanged */]

  fretString(stringIndex, fret) {/* unchanged */}

  pickString(stringIndex, strength) {
    const tone = this.strings[stringIndex].pick(strength)
    return {
      ...tone,
      stringIndex,
      // if muted, cut volume in half
      volume: this.muted ? tone.volume/2 : tone.volume,
    }
  }
}

Notice that the BanjoString class remains unchanged, because we're not changing the behavior of the string. We're adding properties and behaviors to the banjo.

If you're familiar with Express, Rails, or really any other modern web server framework, the Banjo is conceptually starting resemble a router or controller. It's combining the inputs from the user (frets and picks), applying them to a consistent model (the BanjoString), and transforming the results before returning them back to the user. We're basically mimicking the request/response pattern, where the request is the combination of frets and picks, and the response is the resulting tone.

Adding Error Handling for Broken Strings

When you play them long enough, eventually banjo strings break. If I wanted to model that in the BanjoString class, I might add some random chance to the pick() method:


class BanjoString {

  broken = false

  //... other methods/properties unchanged

  pick(strength) {
    // if string is already broken, throw early
    if (this.broken) {
      throw new Error("Can't pick a broken string")
    }

    // correlate chance of breakage to strength of pick
    const breakChance = strength * .00001  
    if (breakChance > Math.random()) {
      this.broken = true
      throw new Error("String Broke")
    }

    return {
      pitch: this.basePitch + this.fretPosition,
      volume: strength,
      duration: strength * strength
    }
  }
}

This seems pretty straightforward. The break is a catastrophic failure for the string. Therefore, we throw an error when it breaks, and also if you try to pick the string after it's already broken.

However, a single string breaking is not a catastrophic failure for the whole banjo. If you're playing with a group of people and a string breaks, you're probably not going to stop everyone while you replace it. You're going to keep playing until the end of the song, and avoid picking that string. And if by mistake you do try to pick that string, the banjo doesn't burst into flames. It simply doesn't make noise for that pick.

Sounds like some error handling is in order:


class Banjo {

  // ...rest of class is unchanged

  pickString(stringIndex, strength) {
    try {
      return this.strings[stringIndex].pick(strength)
    } catch (err) {
      console.warn(err.message)
      return null
    }
  }
}

This again has parallels to how a router or controller works in a server-side framework. If you encounter an error when handling a request, you probably don't want to crash the entire application. Crashing the entire app would mean that you wouldn't be able to serve further requests. Instead, you catch the error and return an appropriate HTTP status code and message to the user.

Or if you're more comfortable on the front end, this is similar to Error Boundaries in React. Same idea: if one component fails, you don't want to crash the entire application if you can help it. Depending on the context, a catastrophic failure of one component can be simply a minor inconvenience to its parent.

If you're working through this exercise with an object of your own choosing, start thinking about errors. In what ways can various components of it fail? How should those failures be handled at various levels of the application?

Planning the UI

Now let's start thinking about a UI for our little application. My audience is overwhelmingly composed of web developers, so we'll make it a browser app.

The thing about the UI layer of an application is that it needs to stay malleable over time. The UI is also tricky and time-consuming when it comes to writing and running automated tests. For these reasons, I always try to keep my UI layer thin as possible, and loosely coupled to the business logic of my application. In other words, I keep as much code out of my user event handlers as I can, instead putting it into the underlying business layer (in this case, our Banjo implementation). That's why I had you build your object first. So in this case, if you have a button that represents a string pick, the event handler should probably only be responsible for determining which string you want to pick, and then passing that info on to the banjo.pickString() method. Keep those UI event handlers nice and thin.

Here's a minimal HTML form we can use to represent 10 frets, a strength setting, and a pick event for each string. Consider this as the starting point for your UI, and start thinking about how you would wire this up to what we already have. See if you can do it without making any changes to the existing Banjo implementation. This is just a proof of concept. You're not going to play Foggy Mountain Breakdown through a web form.

Banjo UI

The other obvious part of the UI that we haven't talked about is how we're going to play the tones that come out of the banjo. There are a number of libraries we can use, and to do be honest I haven't decided which one I'm going with yet. This is not uncommon in real life software development, where you'll get started on one part of the application before requirements or implementation research have been completed for another part.

How can we begin to prep our banjo implementation to integrate with an unknown audio layer? Hint: did you know that you can create custom events and emitters natively in modern browsers?

Collecting User Inputs

For ease of programming, the open state of our strings are represented by the faded radio buttons on the left, and I've removed frets 1 through 5 on the top string, consistent with the banjo's shortened string at index 4. (remember, our strings are zero-indexed as part of an array)

Here's the HTML for this form.

I advised in the last step to keep your UI event handlers thin, and pass information onward to business logic as quickly as possible. With our existing banjo implementation, that becomes pretty straightforward.

I'll start by simply instantiating the banjo and attaching an event handler to the "Pick" buttons:


// File: ui.js

const banjo = new Banjo()  
const pickButtons = document.querySelectorAll("button.pick")
const handlePick = (evt) => {
  // extract the data-string attribute and parse it to a number
  const stringIndex = +evt.target.dataset.string

  // this is the document selector for the corresponding strength input
  const selector = `input[name='strength-${stringIndex}']`

  // get the value of the strenth input and parse to a number
  const strength = +document.querySelector(selector).value

  // Keep those UI handlers thin!
  banjo.pickString(stringIndex, strength)
}
pickButtons.forEach(pick => pick.addEventListener('click', handlePick))

The event handler does three things. First, it pulls the string index out of the data-string attribute of the pick button. Using that string index, it gets the value of the corresponding strength slider, then it passes both of those arguments to the banjo object.

We can add a similar handler to the frets:


const frets = document.querySelectorAll("input.fret")
const handleFret = (evt) => {
  const stringIndex = +evt.target.dataset.string
  const fret = +evt.target.value
  banjo.fretString(stringIndex, fret)
}
frets.forEach(fret => fret.addEventListener('change', handleFret))

For simplicity I'm attaching individual event handlers to each input. In a real app you might consider delegating the event handling to a DOM element higher up in the tree. I'll leave that performance optimization as an exercise to the user.

But wait! How do we test our code? I mean, other than the unit tests that you've been writing all along. You have been writing unit tests, haven't you?

But seriously, there's no observable output yet. I hinted in the previous step that we should turn the Banjo class into an event emitter, and that's what we'll do.


// File: banjo.js

// extend the EventTarget class
class Banjo extends EventTarget {

  // rest of class is unchanged...

  pickString(stringIndex, strength) {
    try {
      const tone = this.strings[stringIndex].pick(strength)

      // emit a tone event when we pick the string. We can attach arbitrary
      // information, in this case our tone object, to the detail property
      this.dispatchEvent(new CustomEvent('tone', { detail: tone }))

      return {
        ...tone,
        stringIndex,
        volume: this.muted ? tone.volume / 2 : tone.volume
      }
    } catch (err) {
      console.warn(err.message)
      return null
    }
  }

}

With these two small changes, the banjo now emits a tone event each time we pick the string. We can listen on that event right now for debugging purposes as we're building our UI inputs, and tomorrow, we'll use it in the final step to actually produce sound.


// File: ui.js

banjo.addEventListener('tone', tone => {
  // the `tone` object that we emitted from the banjo lives on the
  // detail property
  console.log(tone.detail)
})

Now we can verify on the console as we pick and fret in the UI that we are seeing the expected results.

Here's the whole thing so far.

Adding Sound

Finally, the big moment has arrived. We're adding sound to our little toy browser banjo. As I mentioned a few steps ago, it took me a while to settle on exactly how I wanted to do this. I had it narrowed down to three possibilities:

  1. Using an audio element, I could play individual mp3 or aiff files for each note that could be produced by the banjo. Finding, downloading, and managing all of those individual files seemed pretty tedious for the scale of exercise that this is. So I thought about other options where I could programatically generate tones.

  2. The web audio API can do some pretty cool stuff with oscillators and sequencers, but it's quite low-level. The amount of code necessary for this exercise would be significant, and a distraction from the main point of the exercise, which is how to modularize your code based on responsibility.

  3. Tone.js is a browser-based MIDI library. We can use it to create an in-browser synthesizer, and programmatically generate notes by name (or MIDI number). Out of the box it doesn't sound anything like a banjo, but for our toy example it should do just fine.

At this point we've done most of the work. We have a working user interface to collect inputs, and the banjo emits events on each tone with all the information that we need to reproduce the note.

Setting up Tone.js

Once we've included tone.js in our html file, we have to do a little bit of setup in order for the browser to play sounds. Chrome (and I think all other modern browsers) won't let you play sound on a page until the user has interacted with it in some way. I'm going to initially disable all of the banjo inputs and add a button which initializes a tone.js synthesizer and enables all the inputs when you click it. Actually, I'm going to initialize five synthesizers, one for each string:

// File: ui.js

// Hoisting synths to a higher scope so they can be used in  other
// event handlers once they've been initialized.
let synths

const allBanjoInputs = document.querySelectorAll(".string input, .string button")

function disableBanjo() {
  allBanjoInputs.forEach(input => input.setAttribute('disabled', 'disabled'))
}

function enableBanjo() {
  allBanjoInputs.forEach(input => input.removeAttribute('disabled'))
}

// initially disable all the banjo controls, then when the
// user clicks the "Initialize" button, we create a synth for
// each banjo string, disable the Initialize button, and
// enable all the banjo controls
disableBanjo()
document.querySelector("#initialize").addEventListener('click', () => {
  // documentation on tone.js can be found here
  // https://tonejs.github.io/docs/14.7.77/Synth.html
  synths = Array(5).fill('').map(x => new Tone.Synth({
    envelope: {
      attack: 0,
      attackCurve: 'linear',
      decay: 0.2,
      sustain: 0.5,
      release: 5,
      releaseCurve: 'exponential'
    }
  }).toDestination())
  document.querySelector("#initialize").setAttribute('disabled', 'disabled')
  enableBanjo()
})

Why did I create a synth for each string? If you'll recall from a few steps back, we have a requirement that if a tone is ringing on one string, and that string is re-picked, we have to cancel the existing tone before playing the new tone. One string can't play two tones at the same time.

Well, it turns out that a tone.js Synth is monophonic by default. It can only play one note at a time. By creating five monotonic synthesizers, we get our one-note-per-string behavior right out of the box.

For this toy project, this is a perfectly fine solution. In a realistic environment, I would do more research about the implications of five monotonic synths as opposed to a polyphonic synth with some additional logic to address our requirement. But in fact, this is a perfect illustration of the purpose of this exercise. If we do need to refactor, the changes are contained to the UI layer.

Making Sound

We've done the hard work now. Producing sound is just a 4-line update to the event handler for banjo tones:


banjo.addEventListener('tone', tone => {
  // get stringIndex of the tone and parse to a number
  const stringIndex = +tone.detail.stringIndex

  // Set the volume on the corresponding synth
  // Our range sliders go 0-100, but the synth volume is measured in
  // decibels. With some experimentation, the scale of -20 to +80  
  // turned out to be a suitable range and an easy conversion
  synths[stringIndex].volume.value = tone.detail.volume - 80

  // convert integer note number to pitch name eg. G3
  const noteName = Tone.Frequency(tone.detail.pitch, "midi").toNote()

  // Trigger a pick on the synth
  synths[stringIndex].triggerAttackRelease(noteName, 0)
})

That should do it! The full gist is here. You can poke around at it make some noise. If you're feeling ambitious and you want to hack on this example a bit, see if you can implement something like this. Or if you're working on your own object, come up with some advanced features for your UI. Does the modular logic make it easier to build?

Lessons to take from this exercise

I will once again remind you that this exercise was quite contrived, and so what we ended up with was extremely over-engineered for its scale. Even so, here are some of the lessons that I hope you took from it.

Learn to draw conceptual boundaries around the entities in your application

By distinguishing between the banjo itself and the banjo strings, we were able to address nuanced edge cases that would have been more difficult if the banjo was just producing a tone based on string number and fret number. The shortened fifth string, one note at a time per string, and handling broken strings all would have been significantly harder to keep track of and reliably build without some notion of a banjo string instance.

Keep your UI thin

Ideally, the UI should only be about presenting information to and collecting input from your users. Action handlers should delegate work of any complexity to modules that are responsible for that work specifically.

You know this pattern. It's the purpose of using Redux or any other state management library with React. React renders the page and its event handlers pass relevant information from those events to whatever you're using for state.

On the server side, the same is true for controllers and models. Your controllers are responsible for receiving an HTTP request and delegating that information to the appropriate model methods, then returning the result.

In our application, the ui.js file served as our UI, and the banjo.js file served as our data store.

I didn't write automated tests for this project (shame on me!) but a lesson that I want you to understand anyway is that UI is slow and brittle to test. The more you can keep your business logic independent of your UI, the easier it is to test.

Keep functions small and focused

Long, meandering functions are difficult to make sense of and debug. Don't be afraid to break functions down into smaller functions. It makes your code more readable, and it's easier to test and debug small functions.

The web is an event driven domain

User interactions, incoming server requests, task queues, they're all event-driven. As modern cloud architecture progresses, web development is only going to get more so. Get used to working with event-driven architecture. It's a very useful tool to have for decoupling components of your applications.

And with that we'll call the banjo exercise done.

Next Up:
The #1 Thing Your Bootcamp Didn't Teach You


Want to impress your boss?

Useful articles delivered to your inbox. Learn to to think about software development like a professional.

Icon