Building a JavaScript Yak Bak clone with Tone.js — Part 1

cmgustin

Chris Gustin

Posted on February 7, 2023

Building a JavaScript Yak Bak clone with Tone.js — Part 1

Building a JavaScript Yak Bak clone with Tone.js — Part 1

If you don’t remember Yak Bak’s, or you’re not familiar with them, they were these little handheld recording toys that came out in the mid-90’s. The original version was pretty simple: you hold down a button to record a sound, then push another button to play it back. Later versions included sound effects, and the Yak Bakward had a button that would play your sound back in reverse. As a kid who was obsessed with spy gadgets and James Bond movies, I loved them. And as a web dev obsessed adult, I thought a Yak Bak clone would be a fun weekend project, while getting to take a trip down memory lane.

Here’s the finished project:

https://medium.com/media/76a7a9f72a864dda8a9e110b3c584daa/href

Step 1: Scope

Before you start writing any code, or even open your IDE, it’s important to understand exactly what you’re trying to build. This doesn’t have to take long, and it doesn’t have to be a super official document, we just need to take a few minutes to outline the main features, and maybe brainstorm a couple nice-to-haves. I’m partial to the user story format, so using a Yak Bak as an example, here’s what our project scope might look like:

  • The user should be able to press a button to begin recording audio, and release the same button to stop recording audio
  • The user should be able to press a button to play back the audio they recorded
  • [Nice-to-have] The user should be able to press a button to play back their audio in reverse
  • [Nice-to-have] The user should be able to change the color scheme of their Yak Bak

Pretty straightforward. We have two key features, and two features that aren’t necessary, but will add to the users overall experience. When writing a user story, it’s important to keep it simple and avoid making technical judgements or recommendations. We’re simply describing how the feature should work from the users point of view, and leaving the implementation details for a later phase. Doing this allows the scoping phase to move quickly, while avoiding getting bogged down with technical details.

Now that we’ve outlined our scope, it’s time to start writing some code. For this tutorial, I recommend following along in CodePen.

Step 2: HTML

In most of my web dev projects that include HTML, CSS and JavaScript, I usually like to start with the HTML, then tackle the JavaScript, and finally the CSS. Typically, the HTML is the least technically intensive piece, which is a great way to build momentum. It also creates the interface elements which your JavaScript will hook into, and it can give you an idea of how much additional CSS will be needed to get the styles you’re after.

For our Yak Bak, the first thing we’ll need is a container to put our interface elements in. A div is perfect for wrapping or containing other elements, so let’s add an empty div with an id of yakbak to our HTML:

<div id="yakbak"></div>
Enter fullscreen mode Exit fullscreen mode

[Note : in a larger project I would avoid using ID’s to tag elements, however in a small project like this where I’m the only developer, there are only a few elements, and I have a lot of control over the id names, I find that it helps keep things simple.]

Inside the container, our interface will have three buttons: one to record (and stop recording when released), one to playback the recording, and one to playback the recording in reverse. On the Yak Bak, these buttons are labeled “Say,” “Play” and “Yalp.” Let’s add those to our HTML using button elements:

<div id="yakbak">
  <button type="button">Say</button>
  <button type="button">Play</button>
  <button type="button">Yalp</button>
</div>
Enter fullscreen mode Exit fullscreen mode

[Note: type="button" is the default type for button elements, so technically we could leave it off. However, explicitly declaring it ensures that the buttons behave exactly how we expect in all browsers.]

We also need a way to target these buttons with our JavaScript. There are several ways to do it, but document.getElementById() is easy and performant, so it’s perfect for a small project like this. Let’s add an id to each button:

<div id="yakbak">
  <button type="button" id="sayButton">Say</button>
  <button type="button" id="playButton">Play</button>
  <button type="button" id="yalpButton">Yalp</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Including “button” in the ID name will make it clear what element is being targeted when writing the JavaScript and help keep our code readable.

Our interface still needs some decorative elements, but in terms of core functionality, we’re good to go. Let’s move on to the JavaScript!

Step 3: JavaScript

We’ve added the buttons to our HTML, now we want something to happen when a user presses those buttons. The first thing we need to do is target our button elements in our JavaScript file. We can use document.getElementById("some-element-id") to target an element by its ID, and we can assign the result to a variable so we have a useful reference to that element that we can use throughout our code. Let’s target each button and save it to a variable:

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");
Enter fullscreen mode Exit fullscreen mode

Note: In JavaScript, variables can be declared with var, let or const. If you’re unfamiliar with the difference, FreeCodeCamp has a great rundown. For our purposes, it’s important to know that const, short for constant, is used to define a variable that will not be reassigned. In this case, it’s perfect for defining these interface elements since they will not be reassigned in our code (and we will get an error if we try to reassign them).

We’ve identified each of the buttons and saved it to a variable, but they still don’t do anything when clicked. To change this, we can add an event listener to each element that will wait for a specific user event, then fire off a function when that event happens. We want the “Say” button to start recording when the user presses down, so we’ll need to target the mousedown event. We can do that like this:

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  console.log("Say button pushed down");
});
Enter fullscreen mode Exit fullscreen mode

We’ve added a console.log inside the event listener so we can test our button and make sure our code is working as expected so far. When you click the button, you should see the text in the console (on CodePen, click the “Console” button in the bottom left corner to open the console).

We also need the “Say” button to stop recording when released, which we can listen for with the mouseup event. The “Play” and “Yalp” buttons will play back the audio when clicked, so we’ll listen for the click event on those. Here’s how our code will look:

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  console.log("Say button pushed down");
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  console.log("Say button released");
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when released
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

We’ve built our HTML interface, and used JavaScript to target the interface elements and listen for specific actions, now we’re ready to add our recording and playback features.

Step 4: Tone.js and wiring up the “Say” button

Tone.js is a JavaScript framework that adds a layer of abstraction to the Web Audio API that’s included natively in the browser. While we could work directly with the Web Audio API to set up our recording and playback functionality, Tone.js makes achieving those things a little bit easier.

The first thing we need to do is include the Tone.js framework in our CodePen. In your CodePen, click the gear at the top right of the “JS” pane and under “Add External Scripts/Pens” search for “tone” and click on the package when it appears (the description should say “a Web Audio framework for making interactive music in the browser”).

Alternately, you can paste this URL https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js directly into one of the fields underneath the search bar.

Click “Save and Close.”

Now that we’ve added the package, we have access to the Tone.js framework within our code.

The first thing we want to do is record some audio from the user when they click the “Say” button. Looking through the documentation, Tone.js comes with a Tone.Recorder() class that is able to record audio. However, it needs some sort of input. In our case, we want to record from the users microphone, which we can do with the Tone.UserMedia() class.

So we’re going to access the users mic, feed it into a recorder, and eventually play back the result. We want this to happen when the user presses the “Say” button, but we also need the mic and recorder variables to be available to the other event listener functions. Let’s add an empty variable for each, and then initialize each one within the mousedown event listener.

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let mic, recorder;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  mic = new Tone.UserMedia();
  recorder = new Tone.Recorder();
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  console.log("Say button released");
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

We have our mic and our recorder, and they’ll be available to the other event listeners. Right now, mic and recorder will be re-initialized each time the user pushes the “Say” button, which is unnecessary. Let’s add a variable to check if they’ve been initialized or not, and then only initialize them if that variable if false.

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    initialized = true;
  }
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  console.log("Say button released");
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

We have our mic and recorder initialized on the first press, now we need to connect them together and start the recorder. Connecting the mic and recorder should only happen on the first initialization, but starting the recorder should happen every time the user presses the button. We use mic.connect(recorder) and mic.open() to prepare the mic for recording, and recorder.start() to start the recording. Here’s our code so far:

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  console.log("Say button released");
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

Finally, we need to add Tone.Context.Resume() after the user pushes the button, but before we start initializing our variables to fix a bug that otherwise pops up in some cases (full details here if you’re curious https://github.com/Tonejs/Tone.js/issues/341). Our code should now look like this, and when clicking the “Say” button, you should get a prompt to allow the browser to access the microphone (or see a “recording” indicator if you’ve already allowed access).

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  console.log("Say button released");
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

Our “Say” button is halfway there, now it’s time to stop recording and retrieve the audio when the user lets go.

Step 5: Finishing the “Say” button

When the user lets go of the “Say” button, we need to stop recording, retrieve the audio clip, and load it into a player for playback. The player that the audio will get loaded into, like the mic and recorder variables, will need to be accessible in other event listeners, meaning we need to declare it outside of any other functions. Let’s do that now:

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder, player; // Add the player variable

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  console.log("Say button released");
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

Next, when the user releases the “Say” button, we want to stop the recording. This can be done by calling recorder.stop(). When we call this method, it’s going to return the recorded audio data, so we want to not only call the method, but assign the result to a variable so we can capture the recorded audio data it returns. Here’s how we can do that:

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder, player;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  const data = recorder.stop();
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

We use const because the data variable is only assigned once and will not be reassigned. To load our raw audio data into our player, we need to first convert it to a URL which the player will be able to read from. We can use the built-in browser method URL.createObjectURL(data) to convert the raw data to a URL.

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder, player;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  const data = recorder.stop();
  const audioUrl = URL.createObjectURL(data);
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

Finally, we initialize a Tone.js player and load the file URL into it so it’s ready for playback. We use Tone.Player(url) to initialize the player and pass in the audio, and we chain the method toDestination() to route the audio through the users default audio output.

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder, player;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", function() {
  // Do stuff when released
  const data = recorder.stop();
  const audioUrl = URL.createObjectURL(data);
  player = new Tone.Player(audioUrl).toDestination();
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

We need to do one last thing to get this to work. The recorder.stop() method is an asynchronous function, meaning it returns a Promise object and behaves a little different from a normal JavaScript function. If you’re not familiar with asynchronous JavaScript, I would recommend digging in as it’s an important concept to grasp, but it is also a little advanced so if you’re just starting out, all you need to know is that we can add the async and await keywords to make everything behave more like the synchronous JavaScript we’re used to.

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder, player;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", async function() {
  // Do stuff when released
  const data = await recorder.stop();
  const audioUrl = URL.createObjectURL(data);
  player = new Tone.Player(audioUrl).toDestination();
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Play button clicked");
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

We’ve made it through the meat of our Yak Bak functionality, now we just need to get our audio to play back.

Step 6: The “Play” and “Yalp” buttons

We’re recording input, converting it to audio, and loading it into a player, but we still need the audio to play back when the user pushes the “Play” button. This part is as simple as calling player.start() from within the playButton event listener.

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder, player;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", async function() {
  // Do stuff when released
  const data = await recorder.stop();
  const audioUrl = URL.createObjectURL(data);
  player = new Tone.Player(audioUrl).toDestination();
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  player.start();
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  console.log("Yalp button clicked");
});
Enter fullscreen mode Exit fullscreen mode

Just like that, you should now be able to hold down the “Say” button, record some audio, let go to end the recording, and hear it play back when you click “Play”. The last piece of functionality is playing the audio back in reverse when the user presses the “Yalp” button.

Luckily the Tone.js player has a reverse property, so reversing our audio is a simple as setting this property to true, then calling the player.start() method from within our yalpButton listener.

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder, player;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", async function() {
  // Do stuff when released
  const data = await recorder.stop();
  const audioUrl = URL.createObjectURL(data);
  player = new Tone.Player(audioUrl).toDestination();
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  player.start();
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  player.reverse = true;
  player.start();
});
Enter fullscreen mode Exit fullscreen mode

We’re almost there, but we have a small bug. When the user clicks the “Yalp” button, it causes the “Play” button to playback in reverse too, since they both share the player variable. To fix this, we just need to set player.reverse to false when the user clicks the “Play” button. Here’s our final code:

const sayButton = document.getElementById("sayButton");
const playButton = document.getElementById("playButton");
const yalpButton = document.getElementById("yalpButton");

let initialized = false;
let mic, recorder, player;

sayButton.addEventListener("mousedown", function() {
  // Do stuff while pushed down
  Tone.context.resume();

  if (!initialized) {
    mic = new Tone.UserMedia();
    recorder = new Tone.Recorder();
    mic.connect(recorder);
    mic.open();
    initialized = true;
  }

  recorder.start();
});

sayButton.addEventListener("mouseup", async function() {
  // Do stuff when released
  const data = await recorder.stop();
  const audioUrl = URL.createObjectURL(data);
  player = new Tone.Player(audioUrl).toDestination();
});

playButton.addEventListener("click", function() {
  // Do stuff when clicked
  player.reverse = false; // Make sure reverse playback is off
  player.start();
});

yalpButton.addEventListener("click", function() {
  // Do stuff when clicked
  player.reverse = true;
  player.start();
});
Enter fullscreen mode Exit fullscreen mode

And just like that, the core functionality is done, plus one of the “nice-to-haves” on our list. In part 2, I’ll walk through the styling and some UI enhancements, and in part 3 I’ll go through how we can allow users to change the color scheme.

If you’ve made it this far, thanks for following along!

💖 💪 🙅 🚩
cmgustin
Chris Gustin

Posted on February 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related