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.
- 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).
- 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.
- 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.
- 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
.
- 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.
- 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.
- 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
.
- 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:
- Three drum patterns (hi-hat, bass drum, snare) in the patterns array (stored as a 2D matrix)
- User interface to toggle the steps in each pattern on and off
- Highlight the current step in the pattern
- 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