Audio | Part 6 — Beat Sequencer with Interactive Graphical UI using JavaScript

In this blog post, we'll build a basic beat sequencer with three sound samples: a kick, a snare, and a hi-hat. The beat sequencer will have an interactive user interface where the user will be able to modify the beat by clicking on a visual display of the drum pattern.

Starter code

We'll be starting out with a simple Tone.js implementation we built in a previous blog post. The starter code is summarized below.

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

We begin by creating Tone.Player instances for the kick, snare, and hi-hat drum samples, setting these to be output to the destination (i.e., the speakers).

  1. Transport and Utility Function
const { Transport: T } = Tone;
const round = (x, places) =>  Number.parseFloat(x).toFixed(places);

Here, we alias Tone.Transport to T for easier use later and define a utility function round to round numbers to a specified decimal place. Tone.Transport is a timing control and scheduling object.

  1. Counter and Counter Update
let count = 0;
const updateCount = () => count = (count + 1) % 16;

We then set up a count variable and an updateCount function to manage the current step in the sequence. count is updated with every step and resets after 16 steps, providing a 16-step loop.

  1. Beat Callback
const playBeat = () => {
  T.scheduleRepeat((time) => { 
    hihat.start(time);
    if (count % 4 === 0) kick.start(time);
    if ((count + 2) % 4 === 0) snare.start(time);

    updateDisplay(time);
    updateCount();  
  }, "8n");
  
  T.start();
};

The playBeat function schedules and plays the beat. It sets up a repeated event every eighth note ("8n") and plays the hi-hat sound on each event. The kick sound plays every four counts (beginning of each bar), and the snare sound plays two counts after the kick (the third count of each bar). We then update the display and increment count.

  1. Start and Stop Functions
const startBeat = () => Tone.start().then(() => {
  playBeat();
});

const stopBeat = () => {
  T.stop();
  T.cancel();
  count = 0;
};

The startBeat function starts the audio context and then starts the beat. The stopBeat function stops the Transport, cancels any scheduled events, and resets the count.

  1. DOM Elements and Event Handlers:
const qs = x => document.querySelector(x);

const start_btn = qs('#start');
const stop_btn = qs('#stop');

start_btn.addEventListener('click', () => startBeat());
stop_btn.addEventListener('click', () => stopBeat());

The qs function is a helper function that simplifies selecting elements from the DOM. The <button id="start" /> and <button id="stop" /> buttons have click event listeners attached that call startBeat and stopBeat, respectively.

  1. Display Update
const time_display = qs('#time > span');
const count_display = qs('#count > span');
const bars_display = qs('#bars > span');
const beats_display = qs('#beats > span');

function updateDisplay(time) {
  const [bars, beats, sixteenths] = T.position.split(':');
  time_display.textContent = round(time, 2);
  count_display.textContent = count;
  bars_display.textContent = bars;
  beats_display.textContent = beats;
}

Here, we select display elements from the DOM and update them in the updateDisplay function. We then split the Transport's position property into bars, beats, and sixteenths and display them along with the current time and count.

  1. Volume and BPM Control
const volume_control = qs('#volume');
volume_control.addEventListener('input', ({ target: { value } }) => {
  Tone.Destination.volume.value = value - 50;
});

const bpm_control = qs('#bpm');
bpm_control.addEventListener('input', ({ target: { value } }) => {
  T.bpm.value = value;
});

Finally, we provide controls for volume and beats per minute (BPM). The volume control subtracts 50 from the input value because Tone.js uses a decibel scale where 0 is the maximum volume. The BPM control sets the Transport's BPM to the input value. Both controls listen for input events to make these changes.

Adding a Customizable Drum Pattern

We will now expand on the starter beat sequencer by adding a new feature: allowing the user to customize the hi-hat drum pattern. This is accomplished through the introduction of a pattern array, which maps each array index to the corresponding 16 steps in the beat sequence (four steps per bar over four bars).

const pattern = [
  1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0,
];

The pattern array uses a binary representation where each value can be either 0 or 1. A 1 implies that a hi-hat sound should be played at that step, while a 0 means the hi-hat should remain silent.

This pattern is evaluated within the loopCallback function, which is called for each step of the beat. The if (pattern[count]) line checks the current step's corresponding value in the pattern array. If the value is 1 (and therefore truthy), the hi-hat sample is triggered with hihat.start(time).

const loopCallback = (time) => {
  
  if (pattern[count])
    hihat.start(time);  

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

Note that we've moved the callback function outside of the inline arguement passed into T.scheduleRepeat for code simplicity purposes.

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

Our plan to extend the code further

In the next several sections we're going to extend our code to add the following new features:

  1. Three drum patterns (hi-hat, bass drum, snare) in the patterns array (stored as a 2D matrix)
  2. User interface to toggle the steps in each pattern on and off
  3. Highlight the current step in the pattern
  4. Pause button

2D Matrix to Store Three Drum Patterns

Let's convert our pattern array into a two dimensional matrix named patterns in order to support three distinct patterns. Each row in the matrix is a 1D array representing a pattern for one track that each correspond to a different drum sound: hi-hat, kick, and snare.

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

In loopCallback, we now need to check each pattern (patterns[i][count]) to see if the corresponding sound should be played.

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

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

User Interface to Toggle Steps

Next, let's add a new UI feature to toggle steps on and off. By clicking on a step in the UI, users can enable or disable a sound in that step by inverting the binary value of the corresponding matrix index (patterns[i][j]). Below is the updated code that interacts with the UI followed by the corresponding added HTML.

const tracks = document.querySelectorAll('.track > div');
let Steps = [];

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

    // initialize the UI to match initial patterns
    if (patterns[i][j])
      steps[j].classList.toggle('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('on');
    });
  });
});
<div id="tracks">    

  <div class="track">
    <p>Track 1:</p>
    <div>
      <div>1</div>
      <!-- ... -->
      <div>16</div>
    </div>
  </div> <!-- .track -->

  <!-- ... -->
  <!-- Track 2 and 3 -->
  <!-- ... -->

</div> <!-- #tracks-->

Highlighting Current Step

Next, we make it easier to see where we are in each pattern as the beat plays by highlighting the sequence step corresponding to the current time index of each row in the UI. Below is the added step highlighting code and the corresponding CSS. Note that we're using the new native feature of CSS nesting. Please check to see if this feature is supported in your browser (Firefox is not currently supported [July 2023]).

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');
  });
};
#tracks {
  .track {
    border: solid transparent 2px;
    display: flex;
    align-items: center;

    > p { margin-right: 0.5rem; } 

    > div {
      display: inline-flex;
      border: solid black 1px;
    
      > div {
        width: 20px;
        height: 20px;
        background-color: skyblue;
        margin: 1px;
    
        display: grid;
        place-items: center;

        &.on {
          outline: solid darkorange 2px;
        }
        &.current {
          color: lime;
        }
      }
    }

  }
}

Pause Button

Finally, let's add a pause button, allowing the user to pause and resume the sequence.

let paused = false;
const pauseBeat = () => {
  if (paused) T.start();
  else  T.pause();

  paused = !paused;
}; // pauseBeat()

When the pause button is clicked, the sequence is either paused or resumed, depending on its current state.

Below are our updated event listeners for all three playback buttons (play, stop, pause).

const start_btn = qs('#start');
const stop_btn = qs('#stop');
const pause_btn = qs('#pause');

start_btn.addEventListener('click', () => {
  startBeat();
  start_btn.disabled = true;
  stop_btn.disabled = false;
  pause_btn.disabled = false;
});


stop_btn.addEventListener('click', () => {
  stopBeat();
  start_btn.disabled = false;
  stop_btn.disabled = true;
  pause_btn.disabled = true;
});

pause_btn.addEventListener('click', () => {
  pauseBeat();
  if (paused) {
    start_btn.disabled = true;
    stop_btn.disabled = true;
    pause_btn.disabled = false;
  } else {
    start_btn.disabled = true;
    stop_btn.disabled = false;
    pause_btn.disabled = false;
  }
});
<button id="start">Start Beat</button>
<button id="stop" disabled>Stop Beat</button>
<button id="pause" disabled>Pause Beat</button>

Putting it all together

const qs = x => document.querySelector(x);
const qsa = x => document.querySelectorAll(x);

// ==============================================

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

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

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

    // initialize the UI to match initial patterns
    if (patterns[i][j])
      steps[j].classList.toggle('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('on');
    });
  });
});

// ==============================================

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

// ==============================================

const { Transport: T } = Tone;
const round = (x, places) =>  Number.parseFloat(x).toFixed(places);

let count = 0;
const updateCount = () => count = (count + 1) % 16;

// ==============================================

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');
  });
};

const resetHighlightedSteps = () => {
  console.log('resetting highlighted steps');
  Steps.forEach(steps => {
    steps.forEach(step => step.classList.remove('current'));
  });
}

// ==============================================

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();
}; // loop()

// ==============================================

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

// ==============================================

const startBeat = () => Tone.start().then(() => {
  playBeat();
}); // startBeat()

// ==============================================

const stopBeat = () => {
  T.stop();
  T.cancel();
  resetHighlightedSteps();
  count = 0;
}; // stopBeat()

// ==============================================

let paused = false;
const pauseBeat = () => {
  if (paused) T.start();
  else  T.pause();

  paused = !paused;
}; // stopBeat()

// ==============================================

const start_btn = qs('#start');
const stop_btn = qs('#stop');
const pause_btn = qs('#pause');

start_btn.addEventListener('click', () => {

  // TODO: 
  console.log('Tone.context.state: ', Tone.context.state);

  startBeat();

  // TODO: Move into playing state function
  start_btn.disabled = true;
  stop_btn.disabled = false;
  pause_btn.disabled = false;
});


stop_btn.addEventListener('click', () => {
  stopBeat();

  // TODO: Move into stopped state function
  start_btn.disabled = false;
  stop_btn.disabled = true;
  pause_btn.disabled = true;
});
pause_btn.addEventListener('click', () => {
  pauseBeat();

  // TODO: Move into paused state function
  if (paused) {
    start_btn.disabled = true;
    stop_btn.disabled = true;
    pause_btn.disabled = false;
  } else {
    start_btn.disabled = true;
    stop_btn.disabled = false;
    pause_btn.disabled = false;
  }
});

const time_display = qs('#time > span');
const count_display = qs('#count > span');
const bars_display = qs('#bars > span');
const beats_display = qs('#beats > span');

function updateDisplay(time) {
  const [bars, beats, sixteenths] = T.position.split(':');
  time_display.textContent = round(time, 2);
  count_display.textContent = count;
  bars_display.textContent = bars;
  beats_display.textContent = beats;
} // updateDisplay()

// ==============================================

const volume_control = qs('#volume');
volume_control.addEventListener('input', ({ target: { value } }) => {
  Tone.Destination.volume.value = value - 50; // Tone.js uses a decibel scale for volume where 0 is maximum and -Infinity is minimum.
});

// ==============================================

const bpm_control = qs('#bpm');
bpm_control.addEventListener('input', ({ target: { value } }) => {
  T.bpm.value = value;
});

Live Demo

Conclusion

And that's it! We've successfully extended our simple drum machine to include a UI to allow the user to modify the drum patterns, a feature for highlighting the current step, and pause functionality. With this updated code, users can create their own custom beat sequences and control playback more accurately. In future posts we'll add even more features to our custom web based music production app!

Code download

  • Vanilla JS with minimal styling
  • Sexy React with blinged out styling