SVG Piano

code
web audio

March 26, 2020

Herbie Hancock

Let's talk about (virtual) keyboards:

  • They are good to visualize chords as a static image
  • ...and melodies as an animation.
  • they are understood by most musicians

I recently made a little npm lib called svg-piano. Though there are a number of piano components out there most of them are either incorrectly sized or not hackable enough. Talkin about correct sizing, this one is the best i've found so far, but still its not on npm and its generating svg nodes directly, making it not so hackable.

What I wanted to achieve with my svg-piano lib:

  • npm installable
  • no dependencies
  • minimal boilerplate code

Usage with React

The lib comes with the method renderSVG, which dumps all needed svg element props into a json, which can be plugged directly to any rendering:

import React from 'react';
import { renderSVG } from 'svg-piano';

export default function Keyboard({ options }) {
  const { svg, children } = renderSVG(options);
  return (
    <svg {...svg}>
      {children.map(({ polygon, circle, text, key }, index) => [
        polygon && <polygon {...polygon} key={'p' + index} />,
        circle && <circle {...circle} key={'c' + index} />,
        text && (
          <text {...text} key={'t' + index}>
            {text.value}
          </text>
        ),
      ])}
    </svg>
  );
}

The children elements contain:

  • polygon: The actual key
  • text: Text label
  • circle: Background for text

Usage:

<Keyboard
  options={{
    range: ['A0', 'C8'],
    scaleX: 0.5,
    scaleY: 0.5,
  }}
/>

Adding Mouse Events

Let's add some mouse events to trigger attack / release. We can use 'react-use-gesture' to handle the mouse events:

const active = useRef([]);
const [colorized, setColorized] = useState([]);
const onDrag = useGesture({
  onDragStart: ({ down, args: [key] }) => down && activate(key),
  onHover: ({ down, active, args: [key] }) => {
    if (active && down) {
      activate(key);
    }
    if (!active) {
      deactivate(key);
    }
  },
});
const activate = (key) => {
  if (!colorized.includes(key.notes[0])) {
    active.current = [...active.current, key.notes[0]];
    onAttack && onAttack(key);
  }
  setColorized(active.current);
};
const deactivate = (key) => {
  if (colorized.includes(key.notes[0])) {
    active.current = active.current.filter((n) => n !== key.notes[0]);
    onRelease && onRelease(key);
  }
  setColorized(active.current);
};

I had to useRef as an extra layer to always have the latest active keys. Using state directly did not work for me, but maybe this can be done better.

Then we need to pass the colorization:

const { svg, children } = renderSVG({
  ...options,
  colorize: [...(options.colorize || []), { keys: colorized, color: 'red' }],
});

...and pass the mouse events to the key polygon:

<polygon
  {...polygon}
  key={'p' + index}
  {...onDrag(key)}
  onMouseUp={() => deactivate(key)}
  onClick={() => onClick && onClick(key)}
/>

We also need the mouse up event, as we need use the key and passing args to useGesture's onMouseUp is not possible.

Adding a Synth

Using Tone.js, we now can make the keys playable:

import { Synth } from "tone"
const synth = new Synth({ volume: -6 }).toDestination()

<Keyboard
  options={{range: ["C3", "C5"]}}
  onAttack={key => {
    console.log('attt',key);
    // synth && synth.triggerAttack(key.notes[0])
  }}
  onRelease={key => synth && synth.triggerRelease([key.notes[0]])}
/>

Adding keyboard support

To control our keyboard from the computer keyboard, lets add another custom hook:

import { useEffect } from 'react';

export function useKeyEvents({ downHandler, upHandler }) {
  useEffect(() => {
    window.addEventListener('keydown', downHandler);
    window.addEventListener('keyup', upHandler);
    return () => {
      window.removeEventListener('keydown', downHandler);
      window.removeEventListener('keyup', upHandler);
    };
  }, [downHandler, upHandler]);
}

... and use it inside Keyboard.js:

// + add keyControl to props
useKeyEvents({
  downHandler: (e) => keyControl && keyControl[e.key] && activate({ notes: [keyControl[e.key]] }),
  upHandler: (e) => keyControl && keyControl[e.key] && deactivate({ notes: [keyControl[e.key]] }),
});

in action:

<Keyboard
  onAttack={(key) => poly && poly.triggerAttack(key.notes[0])}
  onRelease={(key) => poly && poly.triggerRelease([key.notes[0]])}
  keyControl={{
    a: 'C3',
    w: 'C#3',
    s: 'D3',
    e: 'D#3',
    d: 'E3',
    f: 'F3',
    t: 'F#3',
    g: 'G3',
    z: 'G#3',
    h: 'A3',
    u: 'A#3',
    j: 'B3',
    k: 'C4',
  }}
  options={{
    range: ['C3', 'C4'],
    scaleX: 1.5,
    scaleY: 1.5,
    labels: {
      C3: 'A',
      'C#3': 'W',
      D3: 'S',
      'D#3': 'E',
      E3: 'D',
      F3: 'F',
      'F#3': 'T',
      G3: 'G',
      'G#3': 'Z',
      A3: 'H',
      'A#3': 'U',
      B3: 'J',
      C4: 'K',
    },
  }}
/>

Now type:

ffef a afuhf
AWSEDFTGZHUJK

Thats it for today..

Further Ideas

Felix Roos 2023