Audio | Part 4 — Beat Timing Graphical Display with Tone.js
In this blog post, we will harness the power of Tone.js to create a web-based drum machine with an integrated beat timing display. In subsequent blog posts, we'll build upon the beat timing concepts introduced here to explore more advanced music production programming techniques.
UPDATE [July 30, 2023]: UI Synch Bug Fix
Please refer to the post titled Adding Track Lock and Disable Features to the Music Production App where we utilize the Tone.Draw
method in order to schedule a draw callback within the Transport
callback. This modification fixes the misalignment between visual and audio elements introduced in the current blog post.
Initializing Sounds
We first will initialize three different Player
objects using Tone.js, each representing a specific drum sound. Each Player
object is created with an audio file that is played when instructed. The files for this code are in the /assets/samples/drums/
directory and represent the kick, snare, and hi-hat sounds of a drum set.
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're using the toDestination()
method, which routes the output of these players to the default output, typically your device's speakers.
Setting up the Transport
Next, we create an alias T
for Tone.Transport
using a destructuring assignment. Tone.Transport
is a central part of Tone.js, serving as the conductor for managing timing and synchronizing various musical events. Our alias T
makes our code easier to read and write.
const { Transport: T } = Tone;
Tone.Transport
handles timing and sequencing - it's essentially our clock. According to the Tone.js wiki: "Tone.Transport
is the master timekeeper, allowing for application-wide synchronization of sources, signals and events along a shared timeline."
Creating the Rhythm
All the magic happens in our playBeat
function, which is where we will schedule our samples to play in a drum beat pattern.
const playBeat = () => {
T.scheduleRepeat((time) => {
hihat.start(time);
const [bars, beats, sixteenths] = T.position.split(':');
console.log('count: ', count,
'\nbars: ', bars,
'\nbeats: ', beats,
'\nsixteenths: ', sixteenths
);
count = (count + 1) % 16;
}, "8n");
T.start();
};
This function is scheduling a callback function that will be repeatedly called to play the hi-hat sound at a regular interval, set by "8n" (eighth note).
Inside the scheduled callback function, we're logging count
and the current bars, beats, and sixteenths of the Transport
time, split by ':'
. This gives us an insight into the state of our Transport
as the hi-hat sound plays.
The count
variable is incremented by 1 for each call and wrapped around mod 16 using the modulus operator %
.
Starting and Stopping the Beat
We have two additional functions: startBeat
and stopBeat
. The startBeat
function initiates the Transport
to start ticking, and the stopBeat
function halts it.
const startBeat = () => Tone.start().then(() => {
playBeat();
});
const stopBeat = () => {
T.stop();
T.cancel();
};
User Interface
Finally, our script interacts with the user interface. It gets the start and stop button elements from the HTML document and adds 'click' event listeners to them.
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());
Now, clicking the start button will start the beat and clicking the stop button will stop the beat, giving us a rudimentary but functional web-based drum machine!
Playing a drum beat pattern
Let's now play a complete drum pattern that includes a hi-hat, kick, and snare. We'll achieve this by modifying our playBeat
function to take into account count
and decide whether to play the hi-hat, kick, or the snare sound, creating a richer and more realistic drum beat.
const playBeat = () => {
T.scheduleRepeat((time) => {
hihat.start(time);
if (count % 4 === 0) {
kick.start(time);
}
if ((count + 2) % 4 === 0) {
snare.start(time);
}
const [bars, beats, sixteenths] = T.position.split(':');
console.log(
'count: ', count,
'\ntime: ', round(time, 2),
'\nbars: ', bars,
'\nbeats: ', beats,
'\nsixteenths: ', round(sixteenths, 2)
);
count = (count + 1) % 16;
}, "8n");
T.start();
};
In this updated playBeat
function, the hi-hat sound still plays on every eighth note, but we've added conditions for the kick and snare sounds. The kick drum plays whenever count
is a multiple of 4 (count % 4 === 0)
, which corresponds to the first and third beats of a standard 4/4 drum pattern. Meanwhile, the snare drum plays whenever count
is two greater than a multiple of 4 ((count + 2) % 4 === 0)
, which corresponds to the second and fourth beats.
This set of conditions creates a common rock or pop drum pattern: hi-hat plays on every eighth note, kick drum on the first and third beats, and snare drum on the second and fourth beats. You'll see that the beat now has a fuller, more rhythmic feel.
We've also updated the console log to include the current time value (rounded to two decimal places for readability) and the number of sixteenths (also rounded). These additions give us even more insight into the internal workings of our drum machine.
By enhancing our drum machine this way, we've significantly boosted its musical capabilities, and we're now creating a genuinely rhythmic drum beat!
Displaying the timing information
Now that we've set the rhythm in motion, it's time to make it visually tangible. We'll accomplish this by updating our drum machine to display real-time timing information on the screen.
Here's the updated code for our drum machine:
let count = 0;
const updateCount = () => count = (count + 1) % 16;
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();
};
// ==============================================
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());
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;
}
In this new version, we've added an updateDisplay
function which updates the contents of the <span>
elements in our timing display, showing the current time, count, bars, and beats. The function is called within our scheduleRepeat
block, which ensures that the display is updated every eighth note - in sync with our beat.
<body>
<button id="start">Start Beat</button>
<button id="stop">Stop Beat</button>
<div id="display">
<div id="time">Time: <span></span></div>
<div id="count">Count: <span></span></div>
<div id="bars">Bars: <span></span></div>
<div id="beats">Beats: <span></span></div>
</div>
</body>
Demo
Try out the demo below. Also, download the fully working code in its native HTML & JS form from below to run the demo locally.
- Time
- 0
- Count
- 0
- Bar
- 0
- Beat
- 0
Wrapping Up
What we've learned here lays a solid foundation for diving deeper into the sea of possibilities that web-based music production offers us. Concretely, the principles of beat timing we've discussed in this blog post will serve as a launch pad for creating more complex Web Audio API based applications (e.g. multi track interactive audio sequencing DAW's, etc.) in our upcoming discussions.