Audio | Part 8 — Refactoring and Loading Audio Samples

Live Demo

Today we'll refactor our program in order to allow our codebase to be more maintainable as it grows larger. We'll also add a feature that allows the user to upload their own audio samples to be played in each track.

This post is a continuation of our previous post.

Let's Refactor

Our program is getting a little too big to house in a single file so let's start to refactor it in order to make our codebase more maintainable as it grows larger.

Functional programming provides an approach that is generally simpler and more elegant than Object-Oriented Programming (OOP). By emphasizing pure functions, immutable data, and function composition, functional programming can make your code more predictable, easier to debug, and less prone to side-effects. It allows you to write less code while avoiding a great deal of boilerplate and unnessesary complexites (such as the this keyword). Writing code that favors the functional paradigm also generally gives rise to codebases that are far more readable and self-explanatory compared to OOP ones, leading to software that is easier to understand, test, and maintain. This sentiment is echoed by the great John Carmack, a pioneer in the gaming industry, who once said

Sometimes, the elegant implementation is just a function. Not a method. Not a class. Not a framework. Just a function.

John Carmack
Software Engineering Legend | Creator of Doom, Quake, ...

However, there are rare use cases where the encapsulation and abstraction provided by OOP, particularly through the use of classes, proves to be the best solution. Each track of our program is an example of such a use case.

So although we will attempt to keep our program mostly written in the functional paradigm, let's do implement a single Track class to encapuslate the behavior and functionality of each of our audio tracks.

The first version of our Track class can be seen below.

class Track {
  pattern = [];
  name = '';
  player = new Tone.Player().toDestination();
  steps = []; // DOM elements

  constructor({ pattern, name, path, steps }) {
    this.pattern = pattern;
    this.name = name;
    this.player.load(path);
    this.steps = steps;
    // <div class="steps">
    //   <div class="step step-A"></div>
    //   ...
    //   <div class="step step-B"></div>
    //   </div>
    // </div>
  }

  toggleUI(index) {
    this.steps[index].classList.toggle('step-on');
  }

  togglePattern(index) {
    this.pattern[index] = this.pattern[index] ? 0 : 1;
  }

  toggle(index) {
    this.togglePattern(index);
    this.toggleUI(index);
  }

  start(time) {
    this.player.start(time);
  }
}

Each track is responsible for playing an audio sample, displaying its step sequence in the user interface, as well as providing the toggling behavior of modifying each step in the pattern when the user clicks the corresponding step in the UI.

Here's an overview of what each part of our Track class does:

  • Properties: Each Track object has five properties: pattern, name, player, and steps. pattern is an array that represents the steps in the sequence for this track (where 1 represents a step where the sample should be played, and 0 represents a step where it should not). name is a string representing the name of the track. player is a Tone.js Player object that handles the actual sound playback. steps is an array of DOM elements representing the steps in the track's sequence in the UI.

  • Constructor: The constructor is called when a new Track object is created. It initializes the object's properties based on an options object passed in as an argument (desctructured inline into the parameter list). It also loads the audio file for the Player object.

  • .toggleUI() method: This method takes an index: number as an argument and toggles the .step-on CSS class for the step at that index in the UI. This will visually indicate whether or not the sample should be played at that step in time.

  • .togglePattern() method: This method toggles the value at a given index in the pattern array between 1 and 0.

  • .toggle() method: This method calls togglePattern and toggleUI methods to ensure both the UI and the pattern array are updated when a step is toggled.

In summary, the Track class abstracts the concept of an audio track in our sequencer, encapsulating the data (like the pattern and the sound sample) and behavior (like toggling steps in time and playing the sound) related to a track.

We use our Track class to instantiate a new Track object for every drum sample from our current program. Therefore, we will now replace our patterns two dimensional matrix with a Tracks one dimensional array.

Our old patterns matrix and corresponding Player objects storing our audio samples (that we will replace) had the following form.


const hihat = new Tone.Player("/assets/samples/drums/hi-hat.mp3").toDestination();
const kick = new Tone.Player("/assets/samples/drums/kick.mp3").toDestination();
const snare = new Tone.Player("/assets/samples/drums/snare.mp3").toDestination();

const patterns = [
  [1, 1, 1, 1,    1, 1, 1, 1,    1, 1, 1, 1,   1, 1, 1, 1,], // hi-hat
  [1, 0, 1, 0,    0, 0, 0, 1,    0, 1, 1, 0,   0, 1, 0, 1,], // kick
  [0, 0, 0, 0,    1, 0, 0, 0,    0, 0, 0, 0,   1, 0, 0, 0,], // snare
];

Our new Tracks array looks like the following.

const Tracks = [
  new Track({ 
    pattern: [1, 1, 1, 1,    1, 1, 1, 1,    1, 1, 1, 1,   1, 1, 1, 1,], 
    name: 'hi-hat',
    path: '/assets/samples/drums/hi-hat.mp3',
    steps: qsa('.track-0 > .steps > .step'),
  }),
  new Track({ 
    pattern: [1, 0, 1, 0,    0, 0, 0, 1,    0, 1, 1, 0,   0, 1, 0, 1,], 
    name: 'kick',
    path: '/assets/samples/drums/kick.mp3',
    steps: qsa('.track-1 > .steps > .step'),
  }),
  new Track({ 
    pattern: [0, 0, 0, 0,    1, 0, 0, 0,    0, 0, 0, 0,   1, 0, 0, 0,], 
    name: 'snare',
    path: '/assets/samples/drums/snare.mp3',
    steps: qsa('.track-2 > .steps > .step'),
  }),
];

We next can replace our UI initialization code. Recall our old UI initialization code, seen below.

const tracks = qsa('.track > .steps');
let Steps = [];

tracks.forEach((track, i) => {
  const steps = track.querySelectorAll('.step');
  Steps.push(steps);
  steps.forEach((step, j) => {

    // initialize the UI to match initial patterns
    if (patterns[i][j])
      steps[j].classList.toggle('step-on');

    // toggle the pattern and UI when a step is clicked
    step.addEventListener('click', () => {
      patterns[i][j] = patterns[i][j] ? 0 : 1;
      steps[j].classList.toggle('step-on');
    });
  });
});

Our new UI initialization code can be seen below.

// const tracks = qsa('.track > .steps'); // tracks stores one row of .steps
// let Steps = []; // Steps stores all .step elements in one .steps
// tracks.forEach((track, i) => {
Tracks.forEach((Track, i) => {
  // const steps = track.querySelectorAll('.step');
  const steps = Track.steps;
  // Steps.push(steps);
  steps.forEach((step, j) => {

    // initialize the UI to match initial patterns
    // if (patterns[i][j])
    // if (Tracks[i].pattern[j])
    if (Track.pattern[j]) {
      // steps[j].classList.toggle('step-on');
      Track.toggleUI(j);
    }

    // toggle the pattern and UI when a step is clicked
    step.addEventListener('click', () => {
      // patterns[i][j] = patterns[i][j] ? 0 : 1;
      // Tracks[i].pattern[j] = Tracks[i].pattern[j] ? 0 : 1;
      // Tracks[i].toggle(j);
      Track.toggle(j);

      // steps[j].classList.toggle('step-on');
      // absorbed into Track.toggle()
    });
  });
});

In fact, we can do even better than this. Let's go ahead and move this UI initialization code into a dedicated method in our Track class and call it initUI.

initUI() {
  // initialize the UI and set step click listeners
  this.steps.forEach((step, j) => {

    // initialize the UI to match initial patterns
    if (this.pattern[j]) this.toggleUI(j);

    // toggle the pattern and UI when a step is clicked
    step.addEventListener('click', () => {
      this.toggle(j);
    });
  });
}

Next, we can replace our code that highlights the time steps in the UI. Our old step highlighting code can be seen below.

const highlightStep = (count) => {
  const prev_idx = count - 1;
  const is_prev_idx_pos = prev_idx >= 0;
  
  Steps.forEach((steps, i) => {
    steps[is_prev_idx_pos ? prev_idx : 15].classList.remove('current');
    steps[count].classList.add('current');
  });
}; // highlightStep()

const resetHighlightedSteps = () => {
  Steps.forEach(steps => {
    steps.forEach(step => step.classList.remove('current'));
  });
} // resetHighlightedSteps()

Our new time step highlighting code can be seen below.

const highlightStep = (count) => {
  const prev_idx = count - 1;
  const is_prev_idx_pos = prev_idx >= 0;
  
  // Steps.forEach((steps, i) => {
  Tracks.forEach((Track, i) => {
    // steps[is_prev_idx_pos ? prev_idx : 15].classList.remove('current');
    Track.steps[is_prev_idx_pos ? prev_idx : 15].classList.remove('current');

    // steps[count].classList.add('current');
    Track.steps[count].classList.add('current');
  });
}; // highlightStep()

const resetHighlightedSteps = () => {
  Tracks.forEach(Track => {
    Track.steps.forEach(step => step.classList.remove('current'));
  });
} // resetHighlightedSteps()

And finally, we can modify our loopCallback function to now allow for our more dynamic Tracks array in order to play an arbitrary number of tracks. Our old loopCallback function, that played only our three fixed audio Player objects, can be seen below.

const loopCallback = (time) => {  
  if (patterns[0][count]) hihat.start(time);
  if (patterns[1][count]) kick.start(time);
  if (patterns[2][count]) snare.start(time);
  highlightStep(count);
  updateDisplay(time);
  updateCount();
}; // loopCallback()

Our new loopCallback function that allows for playing an arbitrary number of audio samples can be seen below.

const loopCallback = (time) => {   
  Tracks.forEach(track => {
    if (track.pattern[count]) 
      track.start(time);
  });

  highlightStep(count);
  updateDisplay(time);
  updateCount();
}; // loopCallback()

Recall that this loopCallback function is what drives the playing of our beat sequence for each track by being invoked at every time step. The actual usage of the callback is unchanged from the previous version of our app.

const playBeat = () => {
  T.scheduleRepeat((time) => loopCallback(time), "8n");
  T.start();
}; // playBeat()

Loading New Audio Samples

Now that we have our app refactored into a form that will be more maintainable as we expand our code base, let's go ahead and add a new feature.

Let's add a button that allows the user to load a new audio sample. We'll modify the track title in each track row to be a clickable button. When the user clicks this button we want them to be prompted to load an .mp3 file from their own device.

We can utilize the native HTML input with the attribute of type=file to allow the user to upload a file. We can also set the accept=audio/* attribute to only allow the user to open audio files.

<div class="track-title-container">
  <label label="audio-input-0" id="audio-label-0" class="track-title"></label>
  <input type="file" id="audio-input-0" accept="audio/*" />
</div>

We can now store a reference to the file upload button in each Track object by utilizing our utility function const qs = (x) => document.querySelector(x) to target our <div class="track-title-container"> in the instantiation of each Track object via the corresponding constructor invocation.

const Tracks = [
  new Track({ 
    pattern: [1, 1, 1, 1,    1, 1, 1, 1,    1, 1, 1, 1,   1, 1, 1, 1,], 
    name: 'hi-hat',
    path: '/assets/samples/drums/hi-hat.mp3',
    steps: qsa('.track-0 > .steps > .step'),
    load_btn: qs('.track-0 > .track-title-container'),
  }),
  new Track({ 
    ...
    load_btn: qs('.track-1 > .track-title-container'),
  }),
  ...
];

The costructor of our Track class now simply needs to forward the passed in <div class="track-title-container"> DOM reference to the corresponding property. We then invoke our previously introduced initUI method.

class Track {
  pattern = [];
  name = '';
  player = new Tone.Player().toDestination();
  steps = []; // DOM elements
  load_btn = null;

  constructor({ pattern, name, path, steps, load_btn }) {
    this.pattern = pattern;
    this.name = name;
    this.player.load(path);
    this.steps = steps;

    this.load_btn = load_btn;

    this.initUI();
  }
  .
  .
  .
}

In our initUI method we can now add the logic to allow the user to open a file by clicking on the button. Our initUI method now takes on the following form. Each new section will be briefly explained following the code snippet.

  initUI() {
    // initialize the UI and set step click listeners
    this.steps.forEach((step, j) => ... );

    // grab reference to <input> & <label>
    const load_btn_label = this.load_btn.querySelector('label');
    const load_btn_input = this.load_btn.querySelector('input');

    // not currently used
    load_btn_label.textContent = this.name;
    this.load_btn.addEventListener('click', () => {
      console.log('clicked track load button: ', this.name);
    });

    // file upload:
    load_btn_input.addEventListener('change', (e) => ...);
  }

We first grab a reference to the <input> and corresponding <label> elements.

  // grab reference to <input> & <label>
  const load_btn_label = this.load_btn.querySelector('label');
  const load_btn_input = this.load_btn.querySelector('input');

We actually aren't currently using load_btn_label (the reference to our <label> element) in our JavaScript. We are using the <label> element merely for the UI styling. We need the <input> element in our markup in order to utilize the native HTML file upload feature. However, we follow the advice in the MDN article on <input type="file"> to visually hide the <input> element, paraphrased in the following paragraph.

We hide the <input> element in our CSS because file inputs tend to be ugly, difficult to style, and inconsistent in their design across browsers. We activate the input element by clicking its <label>, so it is better to visually hide the input and style the <label> like a button, so the user will know to interact with it if they want to upload files.

We next attach an event listener to load_btn_input (the reference to our <input> element).

// file upload:
load_btn_input.addEventListener('change', (e) => {

  // step 1: upload audio file
  
  // step 2: change title
});

In the callback passed to our event listener we do two things:

  1. Upload the audio file into our Player object
  // step 1: upload audio file
  const file = e.target.files[0];
  const url = URL.createObjectURL(file);
  this.player.load(url);
  1. Change text content of button
  // step 2: change title
  // -if file has file extension, then remove it from name:
  let name = file.name.split('.');
  if (name.length > 1) {
    name.pop();
    name = name.join('.');
  }
  // -write title to button:
  this.name = name;
  load_btn_label.textContent = name;

Extra Credit: Notification Animation

To add some icing to the cake, let's add a simple notification animation to tell the user that the audio file has been uploaded.

load_btn_input.addEventListener('change', (e) => {

  // step 1: upload audio file
  // step 2: change title

  // show notification to user:
  const notification = () => {
    const elem = document.createElement('div');
    
    // ...add class or inline styles here...

    elem.textContent = `uploaded file: ${file.name}`;
    document.body.appendChild(elem);

    const options = {
      duration: 750,
      easing: 'ease-in-out',
      fill: 'forwards',
    };

    elem.style.transform = 'translateX(-100%)';
    elem.animate([
        { transform: 'translateX(-100%)', opacity: 0 },
        { transform: 'translateX(0%)', opacity: 1 }
      ], 
      options
    );

    setTimeout(() => {
      elem.animate([
          { transform: 'translateX(0%)', opacity: 1 },
          { transform: 'translateX(-100%)', opacity: 0 }
        ], 
        options
      );
      setTimeout(() => document.body.removeChild(elem), options.duration);
    }, 3e3);
  };
  notification();
});

In future posts we'll generalize this notification animation into a full blown generalized notification system to help the user make sense of how the state of the app changes in response to interactions they make with the app. The notification system will of course eventually be wired up to display any errors that may occur in the app - both frontend errors and errors that may occur during HTTP requests once we build out a backend in order to have a connected database and a credit card payment system for SaaS style subscriptions, etc.

Live Demo

Conclusion

And there we have it! We have successfully refactored our app to be architected 📐 in such a way that our codebase will be highly structured 🏛️ and maintainable as we continue to build out 🚧 our codebase. We also added a file 📁 upload ⬆️ feature where the user can import new audio samples for each track. Finally, we added some bling 💎 by creating a simple notification animation to tell the user that the file has been successfully uploaded.

In future blog posts we'll add even more features to our music production app, so stay tuned!

Video Demo