Viktor Ahmeti.1 year ago
In case you didn’t know, JavaScript is capable of many things. As a beginner, one believes that the only use for JavaScript is to read JSON and display it beautifully on the user’s screen. In this tutorial we’ll take things to the next level by building a simple Piano for the Web which can be played with keyboard, mouse, or touch.
Before we proceed, let’s get some things out of the way. To play actual musical notes, we’re going to use a simple library called Musical.js because we don’t want to add that functionality from scratch. Simply download the file musical.min.js and then include it in your HTML file. All the code for this project can be found in the GitHub Repository. You can try out the Piano here.
As always, we will start with the HTML structure. Notice that the Piano by default shows 10 white keys which by default will be on the 4th octave. The Pitch Control is used to select the octave on which we’re operating. If you don’t know how pianos work, the octave will simply tell us how high or low a certain note is. Here’s the basic HTML structure:
<div class="piano-container">
<!-- The top part of the piano -->
<div class="piano-top">
<label for="octave">Pitch</label>
<input id="octave" type="range" min="1" max="7" step="1" value="4">
</div>
<!-- The actual piano -->
<div class="piano" id="piano">
<!-- ... the piano keys will come here -->
<div>
</div>
Notice that for the pitch control we’re using the HTML5 <input type="range">
which creates a simple slider. The octaves can go from 1 to 7 and the default value will be 4. The HTML for the actual piano keys will now look like this:
<div data-note="C" data-key="a" class="white-key">
<div data-note="C#" data-key="w" class="black-key"></div>
</div>
<div data-note="D" data-key="s" class="white-key">
<div data-note="D#" data-key="e" class="black-key"></div>
</div>
<div data-note="E" data-key="d" class="white-key"></div>
<!-- ... and so on -->
Notice that we’re placing the black keys inside the white keys, and we’ll later position them absolutely with CSS. We’re using Data Attributes to specify the note that a key plays and the letter that should be typed in order to play the note. Keep in mind that we’re only supporting the US Keyboard Layout. One more data attribute is needed for the notes in the end:
<div data-note="D" data-key="l" data-octave-offset="1" class="white-key">
<div data-note="D#" data-key="p" data-octave-offset="1" class="black-key"></div>
</div>
<div data-note="E" data-key=";" data-octave-offset="1" class="white-key"></div>
We’re adding the data-octave-offset
attribute to specify how many octaves higher these notes are. This is important because we have two C notes in the piano which we should differentiate. Let’s now style these with CSS!
CSS is an art so there’s many ways to go about this – here’s my take. The Piano will be a grid with 10 equal columns to make room for every white key:
.piano{
width: 100%;
display: grid;
grid-template-columns: repeat(10, 1fr);
}
With that all sorted the white keys will be positioned nicely and we can now style them to even include the black border which separates the keys:
.white-key{
background-color: #fff;
position: relative;
grid-column: span 1;
overflow: visible;
cursor: pointer;
}
.white-key:not(:last-child){
border-right: 2px solid black;
}
We’re setting their positioning to relative and the overflow to be visible because the black keys inside them will be positioned according to the parent. That is as simple as:
.black-key{
position: absolute;
height: 60%;
width: 60%;
background-color: black;
top: 0;
right: -30%;
z-index: 1;
pointer-events: all;
box-shadow: 2px 2px 4px rgb(1 1 1 / 56%);
}
The piano is all set with this CSS, but we will do one extra step to add some states. A key can either be clicked or not-clicked and it’s appearance will change depending on this state. If we can change the UI when a key is clicked, everything will feel more real and responsive, so let’s add them:
.white-key.clicked{
box-shadow: inset 0px 0px 20px 0px #80808096;
}
.black-key.clicked{
box-shadow: none;
}
Well that was simple! When a white key is clicked it shows a shadow while when the black key is clicked it removes its shadow. Cool! Other things like the range slider, the positioning of the piano, and so on will be left to you to code or you can just scroll through the Repository. But I’ll add one more thing.
We would like to show some suggestions on the keys so users know what letter to type to play a specific note (a is C, s is D, and so on) but we wouldn’t like to show these on a phone that has no keyboard. Apparently, there’s no way to differentiate between devices with or without physical keyboards using CSS, so we do a workaround: If a device is controlled with a mouse, it probably has a physical keyboard. Here’s the media query for this:
@media (hover: hover) and (pointer: fine) {
.white-key::after, .black-key::after{
content: attr(data-key);
position: absolute;
bottom: 5px;
left: 50%;
transform: translate(-50%);
font-size: 1.1rem;
color: rgb(157, 155, 155);
}
.black-key::after{
color: white;
}
}
Notice that the letter which we show on the key is taken directly from the data attributes by using the attr() function. With that, our piano is complete and it looks sick.
To play a note using the Musical.js library, we need a note’s MIDI number. This is a standardized format to represent notes in different octaves. The library will accept this number and then convert it to a frequency which will be played on the speakers. We define a Map (an associative array) which maps from a note to a MIDI number:
let noteToMidiMap = new Map();
noteToMidiMap.set('C', -24);
noteToMidiMap.set('C#', -25);
noteToMidiMap.set('D', -26);
noteToMidiMap.set('D#', -27);
noteToMidiMap.set('E', -28);
noteToMidiMap.set('F', -29);
noteToMidiMap.set('F#', -30);
noteToMidiMap.set('G', -31);
noteToMidiMap.set('G#', -32);
noteToMidiMap.set('A', -33);
noteToMidiMap.set('A#', -34);
noteToMidiMap.set('B', -35);
Notice that by default we’re using the 4th octave MIDI numbers and we’re also making them negative because the library accepts negative numbers only. Next, we make the association between the keys on the keyboard and the note that should be played. We will set the Home Row to be our white keys and the Top Row the black keys:
let keyboardToNoteMap = new Map()
keyboardToNoteMap.set(97, {note: 'C'}); // 'a'
keyboardToNoteMap.set(119, {note: 'C#'}); // 'w'
keyboardToNoteMap.set(115, {note: 'D'}); // 's'
keyboardToNoteMap.set(101, {note: 'D#'}); // 'e'
keyboardToNoteMap.set(100, {note: 'E'}); // 'd'
keyboardToNoteMap.set(102, {note: 'F'}); // 'f'
keyboardToNoteMap.set(116, {note: 'F#'}); // 't'
keyboardToNoteMap.set(103, {note: 'G'}); // 'g'
keyboardToNoteMap.set(121, {note: 'G#'}); // 'y'
keyboardToNoteMap.set(104, {note: 'A'}); // 'h'
keyboardToNoteMap.set(117, {note: 'A#'}); // 'u'
keyboardToNoteMap.set(106, {note: 'B'}); // 'j'
keyboardToNoteMap.set(107, {note: 'C', octaveOffset: 1}); // 'k'
keyboardToNoteMap.set(111, {note: 'C#', octaveOffset: 1}); // 'o'
keyboardToNoteMap.set(108, {note: 'D', octaveOffset: 1}); // 'l'
keyboardToNoteMap.set(112, {note: 'D#', octaveOffset: 1}); // 'p'
keyboardToNoteMap.set(59, {note: 'E', octaveOffset: 1}); // ';'
We’re using the key codes of the letters as keys while the values are objects which contain the note and an optional octave offset that we discussed above. Let’s now finish setting things up:
//The UI elements
let piano = document.getElementById('piano');
let octaveInput = document.getElementById('octave');
//The instrument and its sound
//as specified in the Musical.js documentation
var inst = new Instrument();
inst.setTimbre({wave:"piano", release: 0.1, decay: 0.1, sustain: 0});
We now have the instrument and the UI elements, so we’re ready to listen for keypresses, clicks, and gestures to play our magnificent sounds. The main function that will be used throughout the code is the function that actually plays a note – and it’s really simple:
function playNote(note, octaveOffset = 0){
let midi = noteToMidiMap.get(note) - (octaveInput.value - 1 + octaveOffset) * 12;
inst.tone(midi);
}
To get the MIDI number we indeed have to do some math. This is because the number depends on the pitch selected by the user and also the optional octave offset we get as a parameter. By noticing that 1 octave is equal to 12 midi numbers (and much trial and error) the above function calculates the note correctly and plays the note just as expected.
To Handle Click Events we will use the Event Delegation pattern by attaching only one listener to the piano element, like so:
piano.addEventListener('mousedown', (event) => {
onPress(event);
});
On the onPress() function we will now handle the logic of updating the UI and playing the note:
function onPress(event, finishEvent = 'mouseup'){
//get data
let element = event.target;
let note = element.dataset.note;
let octaveOffset = parseInt(element.dataset.octaveOffset || 0);
//add effect
element.classList.add('clicked');
document.addEventListener(finishEvent, () => element.classList.remove('clicked'), {once: true});
//play note
playNote(note, octaveOffset);
}
Notice that we’re adding the clicked class to the element so the UI changes and looks like the key is being clicked. Furthermore, we add an event listener for the ‘mouseup’ event which should remove the clicked class. This finishEvent is dynamic because we can now handle touch gestures very simply:
piano.addEventListener('touchstart', (event) => {
event.preventDefault();
onPress(event, 'touchend');
});
Notice that we provided a finish event called ‘touchend’ which occurs when the user removes their finger from the piano. This is enough to make the Piano work with a mouse and with touch, so let’s get to the next part: Playing the Piano with the keyboard:
document.addEventListener('keypress', (event) => {
//if it's held down return
if (event.repeat) return;
//get data
let character = event.keyCode;
if(!keyboardToNoteMap.get(character))
return;
let note = keyboardToNoteMap.get(character).note;
let octaveOffset = keyboardToNoteMap.get(character).octaveOffset || 0;
if(!note)
return;
//the selector for the correct div
let selector = `[data-note='${note}']`;
if(octaveOffset)
selector += `[data-octave-offset='${octaveOffset}']`;
let element = document.querySelector(selector);
element.classList.add('clicked');
document.addEventListener('keyup', () => element.classList.remove('clicked'), {once: true});
playNote(note, octaveOffset);
})
Yes, that is a monstrous function but it’s actually really simple. The reason it’s a bit more complicated is because we need to find the correct UI element to add a clicked state to depending on what note we’re playing. This is easily done using the Data Attribute Selectors which are supported in CSS, and the rest is just the same.
I hope this has been good practice on your JavaScript skills and you learned something new. There are a few things you should do now:
The times, they are changing!
← All blogs