Let's build an actual working Guitar🎸 with JavaScript 💻🤘

Pascal Thormeier - Feb 2 '21 - - Dev Community

Let's build a guitar! Well, ok, not a physical guitar, but the next best thing: A digital one! Excited? Alright! Just like a good rock show, might as well jump right in!

Forging the instrument

I start with some boilerplating: A simple HTML file with an inline SVG. Inline, because I need to attach a lot of JS later on. I always loved the Gibson Flying V's design, so I will take its head and neck as an inspiration. I start with some linear gradients and a filter for a drop shadow:

<svg id="guitar" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2400 800" preserveAspectRatio="xMidYMid meet" width="2400" height="800">
  <defs>
    <linearGradient id="fretboard" x1="42%" y1="0%" x2="0%" y2="90%">
      <stop offset="0%" style="stop-color: rgb(56, 53, 53);" />
      <stop offset="100%" style="stop-color: rgb(56, 49, 43);" />
    </linearGradient>

    <linearGradient id="fredboardBorder" x1="0%" y1="0%" x2="0%" y2="100%">
      <stop offset="0%" style="stop-color: rgb(111, 111, 111);" />
      <stop offset="53%" style="stop-color: rgb(255, 255, 255);" />
      <stop offset="100%" style="stop-color: rgb(160, 160, 160);" />
    </linearGradient>

    <linearGradient id="fret" x1="0%" y1="0%" x2="100%" y2="0%">
      <stop offset="0%" style="stop-color: rgb(122, 117, 113);" />
      <stop offset="100%" style="stop-color: rgb(56, 49, 43);" />
    </linearGradient>

    <filter id="dropshadow" height="400%">
      <feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
      <feOffset dx="4" dy="4" result="offsetblur"/>
      <feComponentTransfer>
        <feFuncA type="linear" slope="1.5"/>
      </feComponentTransfer>
      <feMerge>
        <feMergeNode/>
        <feMergeNode in="SourceGraphic"/>
      </feMerge>
    </filter>
  </defs>
  <!-- ... -->
</svg>
Enter fullscreen mode Exit fullscreen mode

I use a polygon for the basic structure, rects and polygons for the strings, a path for the frets:

<svg ...>
  <!-- ... -->
  <polygon
    points="
      -10,300 1860,300 1950,230 2380,400
      1950,570 1860,500 -10,500
    "
    fill="url(#fretboard)"
    stroke-width="10"
    stroke="url(#fredboardBorder)"
    style="filter:url(#dropshadow)"
    stroke-linejoin="round"
  />

  <path
    d="
      M110 305 110 495 M220 305 220 495 M330 305 330 495 M440 305 440 495
      M550 305 550 495 M660 305 660 495 M770 305 770 495 M880 305 880 495
      M990 305 990 495 M1100 305 1100 495 M1210 305 1210 495 M1320 305 1320 495
      M1430 305 1430 495 M1540 305 1540 495 M1650 305 1650 495 M1760 305 1760 495
      M1858 305 1858 495
    "
    stroke-width="10"
    stroke="rgb(122, 117, 113)"
  />

  <rect class="string" x="0" y="324.3" width="1864" height="5" fill="#ccc" />
  <rect class="string" x="0" y="353.6" width="1864" height="5" fill="#ccc" />
  <rect class="string" x="0" y="382.9" width="1864" height="5" fill="#ccc" />
  <rect class="string" x="0" y="412.2" width="1864" height="5" fill="#ccc" />
  <rect class="string" x="0" y="441.5" width="1864" height="5" fill="#ccc" />
  <rect class="string" x="0" y="470.8" width="1864" height="5" fill="#ccc" />

  <polygon points="1863,324.3 1980,290 1980,295 1863,329.3" fill="#ccc" />
  <polygon points="1863,353.6 2065,330 2065,335 1863,358.6" fill="#ccc" />
  <polygon points="1863,382.9 2150,365 2150,370 1863,387.9" fill="#ccc" />
  <polygon points="1863,412.2 2150,445 2150,450 1863,417.2" fill="#ccc" />
  <polygon points="1863,441.5 2065,475 2065,480 1863,446.5" fill="#ccc" />
  <polygon points="1863,470.8 1980,505 1980,510 1863,475.8" fill="#ccc" />

  <circle cx="1980" cy="510" r="20" fill="url(#fretboard)" stroke-width="15" stroke="url(#fredboardBorder)" />
  <circle cx="2065" cy="480" r="20" fill="url(#fretboard)" stroke-width="15" stroke="url(#fredboardBorder)" />
  <circle cx="2150" cy="445" r="20" fill="url(#fretboard)" stroke-width="15" stroke="url(#fredboardBorder)" />
  <circle cx="2150" cy="365" r="20" fill="url(#fretboard)" stroke-width="15" stroke="url(#fredboardBorder)" />
  <circle cx="2065" cy="330" r="20" fill="url(#fretboard)" stroke-width="15" stroke="url(#fredboardBorder)" />
  <circle cx="1980" cy="290" r="20" fill="url(#fretboard)" stroke-width="15" stroke="url(#fredboardBorder)" />
</svg>
Enter fullscreen mode Exit fullscreen mode

And this is what it looks like:

Image of a guitar neck and head

Not the prettiest guitar ever, but gotta love it anyways! Now, let's make it playable with some JS and CSS!

Clamping the guitar strings in place

For those of you familiar with guitars/music theory, I will use the standard tune of E A d g h e. These are the notes played when no fret is pressed down. Each fret increases these by half a note, so for the first string, that would be this:

E2 > F2 > Gb2 > G2 > Ab2 > A2 > Bb2 > H2 > C3 > Db3 > D3 Eb3 > E3 > ...
Enter fullscreen mode Exit fullscreen mode

Once there's a wrap-around, the octave increaes by one and the circle starts anew. With a little help of my friends, I came up with this map of notes:

const noteMap = [
  ['Ab3', 'G3 ', 'Gb3', 'F3 ', 'E3 ', 'Eb3', 'D3 ', 'Db3', 'C3 ', 'B2 ', 'Bb2', 'A2 ', 'Ab2', 'G2 ', 'Gb2', 'F2 ', 'E2 '],
  ['Db4', 'C4 ', 'B3 ', 'Bb3', 'A3 ', 'Ab3', 'G3 ', 'Gb3', 'F3 ', 'E3 ', 'Eb3', 'D3 ', 'Db3', 'C3 ', 'B2 ', 'Bb2', 'A2 '],
  ['Gb4', 'F4 ', 'E4 ', 'Eb4', 'D4 ', 'Db4', 'C4 ', 'B3 ', 'Bb3', 'A3 ', 'Ab3', 'G3 ', 'Gb3', 'F3 ', 'E3 ', 'Eb3', 'D3 '],
  ['B4 ', 'Bb4', 'A4 ', 'Ab4', 'G4 ', 'Gb4', 'F4 ', 'E4 ', 'Eb4', 'D4 ', 'Db4', 'C4 ', 'B3 ', 'Bb3', 'A3 ', 'Ab3', 'G3 '],
  ['Eb5', 'D5 ', 'Db5', 'C5 ', 'B4 ', 'Bb4', 'A4 ', 'Ab4', 'G4 ', 'Gb3', 'F4 ', 'E4 ', 'Eb4', 'D4 ', 'Db4', 'C4 ', 'B3 '],
  ['Ab5', 'G5 ', 'Gb5', 'F5 ', 'E5 ', 'Eb5', 'D5 ', 'Db5', 'C5 ', 'B4 ', 'Bb4', 'A4 ', 'Ab4', 'G4 ', 'Gb4', 'F4 ', 'E4 ']
]
Enter fullscreen mode Exit fullscreen mode

(Note that I'm going right to left here, because the lowest note is near the head.)

Now I need to make the strings clickable. Ideally, I add clickable areas to every fret for every string in order to figure out where a string was picked to figure out the note to play. I do that with JS by adding them to the SVG dynamically. I also add a global flag called isPlaying to determine if the mouse is pressed or not. The playNote() function currently outputs the note that will be played.

let isPlaying = false

function playNote (stringKey, note, force = false) {
  if (isPlaying || force) {
    console.log(note)
  }
}

window.addEventListener('mousedown', () => {
  isPlaying = true
})

window.addEventListener('mouseup', () => {
  isPlaying = false
})

const svg = document.querySelector('#guitar')

noteMap.forEach((string, stringKey) => {
  string.forEach((note, noteKey) => {
    const area = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
    area.setAttribute('x', noteKey * 110)
    area.setAttribute('y', 315 + (29.3 * stringKey))
    area.setAttribute('width', 110)
    area.setAttribute('height', 20)
    area.setAttribute('fill', '#fff')
    area.setAttribute('opacity', '0')
    area.addEventListener('click', () => {
      playNote(stringKey, note, true)
    })
    area.addEventListener('mouseover', () => {
      playNote(stringKey, note, false)
    })

    svg.appendChild(area)
  })
})
Enter fullscreen mode Exit fullscreen mode

Let's see it in action:

A gif showing the SVG guitar with strings in action.

Next, I add an animation to the played string for three seconds to give the user a visual feedback on which string was picked:

const stringVibrationTimes = [0, 0, 0, 0, 0, 0]
const strings = Array.from(document.querySelectorAll('.string'))

setInterval(() => {
  strings.forEach((stringEl, key) => {
    if (stringVibrationTimes[key] > 0) {
      stringEl.classList.add('vibrating')
    } else {
      stringEl.classList.remove('vibrating')
    }

    stringVibrationTimes[key] -= 50

    if (stringVibrationTimes[key] < 0) {
      stringVibrationTimes[key] = 0
    }
  })
}, 50)

function playNote (stringKey, note, force = false) {
  if (isPlaying || force) {
    console.log(note)

    stringVibrationTimes[stringKey] = 3000
  }
}
Enter fullscreen mode Exit fullscreen mode

And some CSS:

@keyframes vibrate {
    0% {
        transform: translateY(-2px);
    }
    50% {
        transform: translateY(2px);
    }
    100% {
        transform: translateY(-2px);
    }
}
.string {
    transform: translateY(0);
}
.string.vibrating {
    animation: vibrate .05s infinite;
}
Enter fullscreen mode Exit fullscreen mode

Looks amazing:

Vibrating guitar strings closeup, as programmed above

We're half way there, now there's only the sound missing!

Crank up the amp!

To make it play sounds, I use a Midi sound font. I'll use midi-js-soundfonts because I like the sound of it. I'm using the instrument electric_guitar_clean of FluidR3_GM. I needed to download the sound font and put into a folder called sound/ in order to make it available to the browser. To play the sound, I use Audio:

const soundFontUrl = './sound/'
function playNote (stringKey, note, force = false) {
  if (isPlaying || force) {
    console.log(note)
    const audio = new Audio(soundFontUrl + note.trim() + '.mp3')
    audio.play()

    stringVibrationTimes[stringKey] = 3000
  }
}
Enter fullscreen mode Exit fullscreen mode

And here's the fully working demo - Playing either by clicking on the strings separately or by holding your mouse down and swiping across the strings:

EDIT: Grab the pick!

In the comments devgrv suggested to add a pick as the cursor - which is just what I did, thank you for this idea!

So, first I created an SVG for the guitar pick. I looked for a good shape online and redrew that with a path and some bezier curves:

<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1280 1280" preserveAspectRatio="xMidYMid meet" width="80" height="80">
  <defs>
    <linearGradient id="pickbg" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" style="stop-color:rgb(77, 22, 22);" />
      <stop offset="100%" style="stop-color:rgb(150, 47, 47);" />
    </linearGradient>
  </defs>
  <g transform="rotate(135, 640, 640)">
    <path
      d="M120 310 C 330 -10 950 -10 1160 310 Q 980 1100 640 1210 Q 300 1100 120 310 Z"
      fill="url(#pickbg)"
    />
  </g>
</svg>
Enter fullscreen mode Exit fullscreen mode

It's important to make the SVG smaller (up to 128 by 128) with width and height attributes, because all larger SVGs are ignored by the browser. Next, I only needed to apply that new cursor image to the body:

body {
  /* ... */
  cursor: url(./pick.svg), auto;
}
Enter fullscreen mode Exit fullscreen mode

And done:

Gif of the guitar with a pick as cursor

Nice, all ready to rock!

Takeaway thoughts

That was even more fun than the self-made WYSIWYG markdown editor for Vue! Playing this thing is really hard and I'm sure the SVG could be optimized here and there, but it works. If you enjoyed this post, tell your friends and shout it out loud!


I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a ❤️ or a 🦄! I write tech articles in my free time and like to drink coffee every once in a while.

If you want to support my efforts, buy me a coffee or follow me on Twitter 🐦!

Buy me a coffee button

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player