search
HomeWeb Front-endCSS TutorialHow to Code a Playable Synth Keyboard

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!

Statement
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
CSS Flexbox vs Grid: a comprehensive reviewCSS Flexbox vs Grid: a comprehensive reviewMay 12, 2025 am 12:01 AM

Choosing Flexbox or Grid depends on the layout requirements: 1) Flexbox is suitable for one-dimensional layouts, such as navigation bar; 2) Grid is suitable for two-dimensional layouts, such as magazine layouts. The two can be used in the project to improve the layout effect.

How to Include CSS Files: Methods and Best PracticesHow to Include CSS Files: Methods and Best PracticesMay 11, 2025 am 12:02 AM

The best way to include CSS files is to use tags to introduce external CSS files in the HTML part. 1. Use tags to introduce external CSS files, such as. 2. For small adjustments, inline CSS can be used, but should be used with caution. 3. Large projects can use CSS preprocessors such as Sass or Less to import other CSS files through @import. 4. For performance, CSS files should be merged and CDN should be used, and compressed using tools such as CSSNano.

Flexbox vs Grid: should I learn them both?Flexbox vs Grid: should I learn them both?May 10, 2025 am 12:01 AM

Yes,youshouldlearnbothFlexboxandGrid.1)Flexboxisidealforone-dimensional,flexiblelayoutslikenavigationmenus.2)Gridexcelsintwo-dimensional,complexdesignssuchasmagazinelayouts.3)Combiningbothenhanceslayoutflexibilityandresponsiveness,allowingforstructur

Orbital Mechanics (or How I Optimized a CSS Keyframes Animation)Orbital Mechanics (or How I Optimized a CSS Keyframes Animation)May 09, 2025 am 09:57 AM

What does it look like to refactor your own code? John Rhea picks apart an old CSS animation he wrote and walks through the thought process of optimizing it.

CSS Animations: Is it hard to create them?CSS Animations: Is it hard to create them?May 09, 2025 am 12:03 AM

CSSanimationsarenotinherentlyhardbutrequirepracticeandunderstandingofCSSpropertiesandtimingfunctions.1)Startwithsimpleanimationslikescalingabuttononhoverusingkeyframes.2)Useeasingfunctionslikecubic-bezierfornaturaleffects,suchasabounceanimation.3)For

@keyframes CSS: The most used tricks@keyframes CSS: The most used tricksMay 08, 2025 am 12:13 AM

@keyframesispopularduetoitsversatilityandpowerincreatingsmoothCSSanimations.Keytricksinclude:1)Definingsmoothtransitionsbetweenstates,2)Animatingmultiplepropertiessimultaneously,3)Usingvendorprefixesforbrowsercompatibility,4)CombiningwithJavaScriptfo

CSS Counters: A Comprehensive Guide to Automatic NumberingCSS Counters: A Comprehensive Guide to Automatic NumberingMay 07, 2025 pm 03:45 PM

CSSCountersareusedtomanageautomaticnumberinginwebdesigns.1)Theycanbeusedfortablesofcontents,listitems,andcustomnumbering.2)Advancedusesincludenestednumberingsystems.3)Challengesincludebrowsercompatibilityandperformanceissues.4)Creativeusesinvolvecust

Modern Scroll Shadows Using Scroll-Driven AnimationsModern Scroll Shadows Using Scroll-Driven AnimationsMay 07, 2025 am 10:34 AM

Using scroll shadows, especially for mobile devices, is a subtle bit of UX that Chris has covered before. Geoff covered a newer approach that uses the animation-timeline property. Here’s yet another way.

See all articles

Hot AI Tools

Undresser.AI Undress

Undresser.AI Undress

AI-powered app for creating realistic nude photos

AI Clothes Remover

AI Clothes Remover

Online AI tool for removing clothes from photos.

Undress AI Tool

Undress AI Tool

Undress images for free

Clothoff.io

Clothoff.io

AI clothes remover

Video Face Swap

Video Face Swap

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

Hot Article

Hot Tools

SublimeText3 Linux new version

SublimeText3 Linux new version

SublimeText3 Linux latest version

ZendStudio 13.5.1 Mac

ZendStudio 13.5.1 Mac

Powerful PHP integrated development environment

EditPlus Chinese cracked version

EditPlus Chinese cracked version

Small size, syntax highlighting, does not support code prompt function

SAP NetWeaver Server Adapter for Eclipse

SAP NetWeaver Server Adapter for Eclipse

Integrate Eclipse with SAP NetWeaver application server.

MantisBT

MantisBT

Mantis is an easy-to-deploy web-based defect tracking tool designed to aid in product defect tracking. It requires PHP, MySQL and a web server. Check out our demo and hosting services.