DEV Community 👩‍💻👨‍💻

Cover image for Playing Sound on the Web Using Tone.js and Alpine.js
seb
seb

Posted on

Playing Sound on the Web Using Tone.js and Alpine.js

I've been having a ton of fun creating interactive musical tools and references over at muted.io. Things like an interactive circle of 5ths, a reference to all major and minor scales and a tool to play chords in keys.

Under the hood, these tools are powered by the Tone.js library, which is a set of utilities build on top of the Web Audio API, which makes it easier to deal with audio in the browser from a musician's perspective. For the aformentioned tools, the user interactions are handled using Alpine.js. I've found that the combination of Tone.js + Alpine.js really works like a charm.

This short post gives you a little primer on how you'd go about setting things up to play audio files in the browser in such a fashion.

First things first, you'll want to have both Tone.js and Alpine.js loaded onto your page. If you have a look at the Tone.js documentation it'll tell you installation instruction via npm, but personally I've been enjoying working with just a call to the minified script file itself. To do that via a CDN, you can add this in your page's head section:

<script defer src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.32/Tone.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

And then similarly for installing Alpine.js:

<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

Note that on a site like muted.io I've decided to load Tone.js only when the user has scrolled passed the relevant portion of the page. I'm using Alpine's Intersect plugin to accomplish that. This is of course optional and I may talk about the details of that in a future post.


With the setup out of the way, you should now see a message in your browser console that says something like _ Tone.js v14.8.32 _ , meaning that Tone.js has been properly loaded and is ready to go.

Tone.js Sampler

A sampler is an instrument that makes it easy to playback different audio files. Tone.js offers its own sampler instrument:

const sampler = new Tone.Sampler({
  urls: {
    C3: 'C3.mp3',
    'D#3': 'Ds3.mp3',
    'F#3': 'Fs3.mp3',
    A3: 'A3.mp3',
    C4: 'C4.mp3',
    'D#4': 'Ds4.mp3',
    'F#4': 'Fs4.mp3',
    A4: 'A4.mp3',
  },
  release: 0.5,
  baseUrl: '/sounds/piano/',
}).toDestination();
Enter fullscreen mode Exit fullscreen mode

In the above code block I'm instantiating a sampler and passing in a path to audio files for different musical notes on the piano. In this case I'm using piano samples from the Salamander Grand Piano V3 project, but you could use any of your own samples. In this case, the sounds are in my project's directory under /sounds/piano/. You'll notice also that not all notes are included, that's because Tone.js is smart enough to repitch the samples and make up for any missing pitches in that way. This is really useful in saving on loading time for samples.

This setup works great in a musical contact for playing sounds that actually correspond to musical pitches, but you could of course use a sampler to trigger totally unrelated sounds. You could for example decide that C4 triggers the sound of a toucan while A4 is for an abrasive dog bark. 🐕

Playing the Sounds

Now that we have our sampler instrument setup, we're ready to start listening to user interactions and trigger the sounds. Let's first define a simple function that triggers the passed-in note:

function play(note = "C4") {
  sampler.triggerAttackRelease(note, "8n");
}
Enter fullscreen mode Exit fullscreen mode

With this, calling play() will trigger the audio file associated with the note provided (or default to C4) in your sampler for a duration of an 8th note. The default BPM value in Tone.js is 120, which will be what controls how long a 8th note is. You can tweak the BPM value like this:

Tone.Transport.bpm.value = 96; // 96 BPM instead of 120
Enter fullscreen mode Exit fullscreen mode

Now that we have our play function in place, we can use Alpine to setup a listnener on something like a button:

<button @click="play('A3')">Play A3</button>
Enter fullscreen mode Exit fullscreen mode

And done! You should now hear the sample that your sampler has for A3. Note here that the button click is important because modern browsers require a user interaction like a button click to start playing sounds on a page.

Separating the attack from the release

Earlier we made use of the triggerAttackRelease on our sampler, which takes care of triggering the sample and also of releasing that trigger after the duration provided (a 8th note in our example). What if instead we wanted to play a sound for as long as the user is currently pushing a button? This is often useful for long samples that are to be played only while a note is activated (e.g.: a button is pressed). We can easily decouple the operation by using the triggerAttack and triggerRelease methods instead:

function startPlay(note) {
  sampler.triggerAttack(note);
}
function stopPlay(note) {
  sampler.triggerRelease(note);
}
Enter fullscreen mode Exit fullscreen mode

Note that you could also pass in an array with multiple notes at once to any of those methods (triggerAttackRelease, triggerAttack, triggerRelease), allowing you to trigger things like chords, if you're triggering sounds in a musical context.

And now, we can once again make use of Alpine's event handling capabilities to :

<button
  @mousedown.stop="startPlay('A4');"
  @mouseup.stop="stopPlay('A4');"
  @touchstart.stop.prevent="startPlay('A4');"
  @touchend.stop.prevent="stopPlay('A4');"
>
  Play long sample
</button>
Enter fullscreen mode Exit fullscreen mode

Here I'm using the mousedown and mouseup events to decouple the button press and button unpress. You'll also notice that I'm using touchstart and touchend, which fixes the issue that touch screen devices don't have a mousedown or mouseup event. To stop the event's propagation, I'm using the stop modifier on all events, and to prevent the default behavior I'm also using the prevent modifier on the touch events. This fixes an issue where the event would otherwise be triggered twice on devices with a mouse.


That's it! Hopefully this short introduction was enough to show you how easy it can be to trigger sounds in the browser and start having fun with that in your own projects! ✨ 🔊

For the sake of brevity, I kept the part involving Alpine.js very short and sweet in this post. In a real-world scenario, you'll likely want to make use of x-data to do things like keep track of the notes/sounds being played:

<div x-data="{ currentNote: 'A4' }">
  <button @click="play(currentNote);">Play note</button>
  ...
</div>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)

Does your company have something to share with DEV?

Create an Organization and start sharing content with the community on DEV.