How to Code a Playable Synth Keyboard
With just a little knowledge of music theory, we can use ordinary HTML, CSS, and JavaScript (no libraries or audio samples required) to create a simple digital instrument. Let's put it into practice and explore a way to create a digital synthesizer that can be played and hosted on the internet.
Here is what we want to make:
We will use the AudioContext API to create our voice digitally without relying on samples. But first, let's deal with the appearance of the keyboard first.
HTML Structure
We will support a standard Western keyboard where each letter between A to; corresponds to a playable natural note (white key), while the lines above can be used for ascending and downing (black key). This means our keyboard covers a little more than one octave, starting at C₃ and ending at E₄. (For anyone who is not familiar with the score, the subscript numbers indicate the octave.)
One useful thing we can do is store the note value in a custom note property so that it can be easily accessed in our JavaScript. I will print letters on my computer keyboard to help our users understand what keys to press.
<code></code>
- A
- W
- S
- E
- D
- F
- T
- G
- Y
- H
- U
- J
- K
- O
- L
- P
- ;
CSS Style
We'll start with some boilerplate for our CSS:
<code>html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; } body { margin: 0; }</code>
Let's specify the CSS variable for some of the colors we'll use. Feel free to change to any color you like!
<code>:root { --keyboard: hsl(300, 100%, 16%); --keyboard-shadow: hsla(19, 50%, 66%, 0.2); --keyboard-border: hsl(20, 91%, 5%); --black-10: hsla(0, 0%, 0%, 0.1); --black-20: hsla(0, 0%, 0%, 0.2); --black-30: hsla(0, 0%, 0%, 0.3); --black-50: hsla(0, 0%, 0%, 0.5); --black-60: hsla(0, 0%, 0%, 0.6); --white-20: hsla(0, 0%, 100%, 0.2); --white-50: hsla(0, 0%, 100%, 0.5); --white-80: hsla(0, 0%, 100%, 0.8); }</code>
In particular, changing the --keyboard and --keyboard-border variables will greatly change the final result.
For style keys and keyboards – especially when pressed – I was inspired by this CodePen from zastrow. First, we specify the CSS for all key sharing:
<code>.white, .black { position: relative; float: left; display: flex; justify-content: center; align-items: flex-end; padding: 0.5rem 0; user-select: none; cursor: pointer; }</code>
Using specific rounded corners on the first and last keys helps make the design look more natural. Without rounded corners, the upper left and upper right corners of the key look a bit unnatural. This is a final design, minus any extra rounded corners on the first and last keys.
Let's add some CSS to improve this.
<code>#keyboard li:first-child { border-radius: 5px 0 5px 5px; } #keyboard li:last-child { border-radius: 0 5px 5px 5px; }</code>
The difference is subtle but effective:
Next, we apply styles that distinguish white and black keys. Note that the z-index for the white key is 1 and the z-index for the black key is 2:
<code>.white { height: 12.5rem; width: 3.5rem; z-index: 1; border-left: 1px solid hsl(0, 0%, 73%); border-bottom: 1px solid hsl(0, 0%, 73%); border-radius: 0 0 5px 5px; box-shadow: -1px 0 0 var(--white-80) inset, 0 0 5px hsl(0, 0%, 80%) inset, 0 0 3px var(--black-20); background: linear-gradient(to bottom, hsl(0, 0%, 93%) 0%, white 100%); color: var(--black-30); } .black { height: 8rem; width: 2rem; margin: 0 0 0 -1rem; z-index: 2; border: 1px solid black; border-radius: 0 0 3px 3px; box-shadow: -1px -1px 2px var(--white-20) inset, 0 -5px 2px 3px var(--black-60) inset, 0 2px 4px var(--black-50); background: linear-gradient(45deg, hsl(0, 0%, 13%) 0%, hsl(0, 0%, 33%) 100%); color: var(--white-50); }</code>
When we press the key, we will use JavaScript to add a "pressed" class to the relevant li element. Now we can test this by adding the class directly to our HTML element.
<code>.white.pressed { border-top: 1px solid hsl(0, 0%, 47%); border-left: 1px solid hsl(0, 0%, 60%); border-bottom: 1px solid hsl(0, 0%, 60%); box-shadow: 2px 0 3px var(--black-10) inset, -5px 5px 20px var(--black-20) inset, 0 0 3px var(--black-20); background: linear-gradient(to bottom, white 0%, hsl(0, 0%, 91%) 100%); outline: none; } .black.pressed { box-shadow: -1px -1px 2px var(--white-20) inset, 0 -2px 2px 3px var(--black-60) inset, 0 1px 2px var(--black-50); background: linear-gradient( to right, hsl(0, 0%, 27%) 0%, hsl(0, 0%, 13%) 100% ); outline: none; }</code>
Some white keys need to be moved left so that they are below the black keys. We assign these keys to the "offset" class in HTML so that we can keep CSS simple:
<code>.offset { margin: 0 0 0 -1rem; }</code>
If you've followed this CSS, you should have something like this:
Finally, we will style the keyboard itself:
<code>#keyboard { height: 15.25rem; width: 41rem; margin: 0.5rem auto; padding: 3rem 0 0 3rem; position: relative; border: 1px solid var(--keyboard-border); border-radius: 1rem; background-color: var(--keyboard); box-shadow: 0 0 50px var(--black-50) inset, 0 1px var(--keyboard-shadow) inset, 0 5px 15px var(--black-50); }</code>
We now have a nice looking CSS keyboard, but it is not interactive and does not make any sound. To do this, we need JavaScript.
Music JavaScript
To create the synthesizer's sound effects, we don't want to rely on audio samples - that's cheating! Instead, we can use the AudioContext API of the network, which has tools that can help us convert digital waveforms into sound.
To create a new audio context we can use:
<code>const audioContext = new (window.AudioContext || window.webkitAudioContext)();</code>
Before using our audioContext, it will be helpful to select all the notes elements in HTML. We can easily query elements using this helper function:
<code>const getElementByNote = (note) => note && document.querySelector(`[note="${note}"]`);</code>
We can then store the element in an object where the key of the object is a key that the user presses the keyboard to play that note.
<code>const keys = { A: { element: getElementByNote("C"), note: "C", octaveOffset: 0 }, W: { element: getElementByNote("C#"), note: "C#", octaveOffset: 0 }, S: { element: getElementByNote("D"), note: "D", octaveOffset: 0 }, E: { element: getElementByNote("D#"), note: "D#", octaveOffset: 0 }, D: { element: getElementByNote("E"), note: "E", octaveOffset: 0 }, F: { element: getElementByNote("F"), note: "F", octaveOffset: 0 }, T: { element: getElementByNote("F#"), note: "F#", octaveOffset: 0 }, G: { element: getElementByNote("G"), note: "G", octaveOffset: 0 }, Y: { element: getElementByNote("G#"), note: "G#", octaveOffset: 0 }, H: { element: getElementByNote("A"), note: "A", octaveOffset: 1 }, U: { element: getElementByNote("A#"), note: "A#", octaveOffset: 1 }, J: { element: getElementByNote("B"), note: "B", octaveOffset: 1 }, K: { element: getElementByNote("C2"), note: "C", octaveOffset: 1 }, O: { element: getElementByNote("C#2"), note: "C#", octaveOffset: 1 }, L: { element: getElementByNote("D2"), note: "D", octaveOffset: 1 }, P: { element: getElementByNote("D#2"), note: "D#", octaveOffset: 1 }, semicolon: { element: getElementByNote("E2"), note: "E", octaveOffset: 1 } };</code>
I found it useful to specify the name of the notes here and octaveOffset, which is needed when calculating the pitch.
We need to provide a pitch in Hz. The equation used to determine the pitch is x * 2^(y / 12), where x is the Hz value of the selected note—usually A₄, with a pitch of 440Hz—and y is the number of notes above or below that pitch.
This gives us something like this in the code:
<code>const getHz = (note = "A", octave = 4) => { const A4 = 440; let N = 0; switch (note) { default: case "A": N = 0; break; case "A#": case "Bb": N = 1; break; case "B": N = 2; break; case "C": N = 3; break; case "C#": case "Db": N = 4; break; case "D": N = 5; break; case "D#": case "Eb": N = 6; break; case "E": N = 7; break; case "F": N = 8; break; case "F#": case "Gb": N = 9; break; case "G": N = 10; break; case "G#": case "Ab": N = 11; break; } N = 12 * (octave - 4); return A4 * Math.pow(2, N / 12); };</code>
Although we only use upshots in the rest of the code, I decided to include downs here as well so that this function can be easily reused in different contexts.
For anyone who is uncertain about the score, for example, notes A# and Bb describe exactly the same pitch. If we play in a specific tune, we might choose one over the other, but the difference is not important for our purposes.
Play notes
We're ready to start playing some notes!
First, we need some way to tell which notes are being played at any given time. Let's do this using Map, because its unique key constraints can help us prevent the same note from being triggered multiple times in a single press. Additionally, users can only click one key at a time, so we can store it as a string.
<code>const pressedNotes = new Map(); let clickedKey = "";</code>
We need two functions, one for the play key - we will fire when keydown or mousedown - the other for stopping play key - we will fire when keyup or mouseup.
Each key will play on its own oscillator and has its own gain node (for controlling volume) and its own waveform type (for determining the sound of the sound). I chose a Triangle waveform, but you can use any "sine", "triangle", "serrated", and "square wave" you prefer. The specification provides more information about these values.
<code>const playKey = (key) => { if (!keys[key]) { return; } const osc = audioContext.createOscillator(); const noteGainNode = audioContext.createGain(); noteGainNode.connect(audioContext.destination); noteGainNode.gain.value = 0.5; osc.connect(noteGainNode); osc.type = "triangle"; const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) 4); if (Number.isFinite(freq)) { osc.frequency.value = freq; } keys[key].element.classList.add("pressed"); pressedNotes.set(key, osc); pressedNotes.get(key).start(); };</code>
Our voice needs improvement. Currently, it has a slightly sharp microwave buzzer quality! But that's enough to start. We'll be back at the end to make some adjustments!
The stop key is an easier task. We need to have each note "continue" for a while (about two seconds) after the user lifts his finger and make the necessary visual changes.
<code>const stopKey = (key) => { if (!keys[key]) { return; } keys[key].element.classList.remove("pressed"); const osc = pressedNotes.get(key); if (osc) { setTimeout(() => { osc.stop(); }, 2000); pressedNotes.delete(key); } };</code>
All that's left is to add our event listener:
<code>document.addEventListener("keydown", (e) => { const eventKey = e.key.toUpperCase(); const key = eventKey === ";" ? "semicolon" : eventKey; if (!key || pressedNotes.get(key)) { return; } playKey(key); }); document.addEventListener("keyup", (e) => { const eventKey = e.key.toUpperCase(); const key = eventKey === ";" ? "semicolon" : eventKey; if (!key) { return; } stopKey(key); }); for (const [key, { element }] of Object.entries(keys)) { element.addEventListener("mousedown", () => { playKey(key); clickedKey = key; }); } document.addEventListener("mouseup", () => { stopKey(clickedKey); });</code>
Note that while most of our event listeners are added to the HTML document, we can use the keys object to add the click listener to the specific element we have already queryed. We also need to do some special treatment on our highest notes, making sure we convert the ";" key to "semicolon" of the spelling form we use in the keys object.
We can now play the keys on the synthesizer! There is only one problem. The sound is still harsh! We may want to lower the keyboard's octave by changing the expression we assign to the freq constant:
<code>const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) 3);</code>
You may also be able to hear the "click" sound at the beginning and end of the sound. We can solve this problem by fading in and fading out more gradually every sound.
In music production, we use the term attack to describe the time it takes for a sound to go from silent to maximum volume, and “release” to describe the time it takes for the sound to fade to silent after it stops playing. Another useful concept is attenuation , the time it takes for the sound to drop from peak volume to continuous volume. Thankfully, our noteGainNode has a gain property with a method called exponentialRampToValueAtTime which we can use to control attacks, releases, and attenuation. If we replace the previous playKey function with the following function, we will get better pick sounds:
<code>const playKey = (key) => { if (!keys[key]) { return; } const osc = audioContext.createOscillator(); const noteGainNode = audioContext.createGain(); noteGainNode.connect(audioContext.destination); const zeroGain = 0.00001; const maxGain = 0.5; const sustainedGain = 0.001; noteGainNode.gain.value = zeroGain; const setAttack = () => noteGainNode.gain.exponentialRampToValueAtTime( maxGain, audioContext.currentTime 0.01 ); const setDecay = () => noteGainNode.gain.exponentialRampToValueAtTime( sustainedGain, audioContext.currentTime 1 ); const setRelease = () => noteGainNode.gain.exponentialRampToValueAtTime( zeroGain, audioContext.currentTime 2 ); setAttack(); setDecay(); setRelease(); osc.connect(noteGainNode); osc.type = "triangle"; const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) - 1); if (Number.isFinite(freq)) { osc.frequency.value = freq; } keys[key].element.classList.add("pressed"); pressedNotes.set(key, osc); pressedNotes.get(key).start(); };</code>
At this point, we should have a working, network-ready synthesizer!
The numbers in our setAttack, setDecay, and setRelease functions seem a bit random, but they are actually just style choices. Try changing them and see what changes have occurred to the sound. You may end up getting the effect you prefer!
If you are interested in furthering the project, there are a number of ways you can improve it. Maybe it's a volume control, a way to switch between octaves, or a way to choose between waveforms? We can add reverb or low pass filters. Or maybe each sound can be composed of multiple oscillators?
For anyone interested in understanding how to implement the concept of music theory on the web, I recommend looking at the source code of the tonal npm package.
The above is the detailed content of How to Code a Playable Synth Keyboard. For more information, please follow other related articles on the PHP Chinese website!

Hot AI Tools

Undresser.AI Undress
AI-powered app for creating realistic nude photos

AI Clothes Remover
Online AI tool for removing clothes from photos.

Undress AI Tool
Undress images for free

Clothoff.io
AI clothes remover

Video Face Swap
Swap faces in any video effortlessly with our completely free AI face swap tool!

Hot Article

Hot Tools

Notepad++7.3.1
Easy-to-use and free code editor

SublimeText3 Chinese version
Chinese version, very easy to use

Zend Studio 13.0.1
Powerful PHP integrated development environment

Dreamweaver CS6
Visual web development tools

SublimeText3 Mac version
God-level code editing software (SublimeText3)

Hot Topics











I see Google Fonts rolled out a new design (Tweet). Compared to the last big redesign, this feels much more iterative. I can barely tell the difference

Have you ever needed a countdown timer on a project? For something like that, it might be natural to reach for a plugin, but it’s actually a lot more

Everything you ever wanted to know about data attributes in HTML, CSS, and JavaScript.

At the start of a new project, Sass compilation happens in the blink of an eye. This feels great, especially when it’s paired with Browsersync, which reloads

Tartan is a patterned cloth that’s typically associated with Scotland, particularly their fashionable kilts. On tartanify.com, we gathered over 5,000 tartan

The inline-template directive allows us to build rich Vue components as a progressive enhancement over existing WordPress markup.

PHP templating often gets a bad rap for facilitating subpar code — but that doesn't have to be the case. Let’s look at how PHP projects can enforce a basic

Let’s attempt to coin a term here: "Static Form Provider." You bring your HTML
