SVG Piano Roll Component

code
web audio

June 10, 2020

After playback was implemented in the last post, we can now feed our eyes with some colored bars:

show source
renderRhythmObject(
  {
    color: 'darksalmon',
    duration: 4,
    parallel: [
      [
        [
          ['Eb4', 'F4', 'r', 'G3'],
          ['r', 'r', 'r', 'G3'],
        ],
        [['r', 'Bb3'], 'G3', 'r', ['r', 'D4']],
      ],
      {
        color: 'brown',
        value: [
          ['C3', 'C3', 'G2', 'r'],
          ['C3', 'C3', 'G2', 'r'],
        ],
      },
      [
        {
          color: 'steelblue',
          instrument: 'drums',
          parallel: [
            [
              ['hh', 'hh', 'hh', 'hh', 'hh', 'hh', 'hh', 'hh'],
              ['hh', 'hh', 'hh', 'hh', 'hh', 'hh', 'hh', ['hh', 'hh']],
            ],
            [
              ['bd', ['sn', 'bd'], 'bd', 'sn'],
              ['bd', ['sn', 'r', 'r', 'bd'], 'bd', 'sn'],
            ],
          ],
        },
      ],
    ],
  },
  [inheritProperty('instrument')]
);

Drawing the SVG

Using a little bit of d3, the basic implementation is pretty compact:

export default function PianoRoll(props: PianoRollProps) {
  let {
    width = 600,
    height = 200,
    margin = 1,
    noteRange = ['C3', 'C4'],
    timeRange = [0, 1],
    strokeWidth = 1,
    hiddenSymbols = ['r'],
    events,
  } = props;
  const deepest = max(events.map((e) => (e.path ? e.path.length : 1)));
  const midiRange = [Note.midi(noteRange[0]), Note.midi(noteRange[1])];
  const x = scaleLinear()
      .domain(timeRange)
      .range([margin, width - margin]),
    y = scaleLinear()
      .domain([midiRange[0] - 1, midiRange[1]])
      .range([height - margin, margin]);
  return (
    <svg {...{ width, height }}>
      {events
        .filter(({ value }) => hiddenSymbols.indexOf(value) === -1)
        .map(({ value, time, duration, path, color }, i) => {
          return (
            <rect
              stroke="black"
              strokeWidth={strokeWidth}
              fill={color || interpolateBlues((path ? path.length : 1) / deepest)}
              key={i}
              x={x(time)}
              y={y(Note.midi(value))}
              width={x(duration) - strokeWidth}
              height={y(midiRange[1] - 1) - strokeWidth}
            />
          );
        })}
    </svg>
  );
}

Usage:

<PianoRoll
  height={100}
  noteRange={['C3', 'A3']}
  events={renderRhythmObject({
    parallel: [
      { sequential: ['E3', 'F3', 'G3', 'A3'], color: 'magenta' },
      { sequential: ['C3', 'D3', 'E3', 'F3'], color: 'cyan' },
    ],
  })}
/>

Rhythm Lanes

  • With the above piano roll implementation, only notes in scientific pitch notation will work.
  • It would be nice if the piano roll recognizes all used values and creates a lane for each

By filtering the events to be unique, we can create a lane for each unique value.

const uniqueLanes = events
  .filter(({ value }) => hiddenSymbols.indexOf(value) === -1)
  .map((e) => e.value)
  .filter((v, i, e) => e.indexOf(v) === i)
  .sort((a, b) => Note.midi(b) - Note.midi(a));

This will create lanes for rhythms too:

Foldable Lanes

Using only unique events for notes has the effect of folding (similar to fold in ableton midi edit). I implemented this behaviour with the fold flag:




Customize Rhythm Lanes

For consistent visualizations, it would be good to have a setting that controls which rhythm lanes are created. By passing an array of strings to rhythmLanes, we can control which lanes are created for non note events. The order will remain consistent even if we fold:


<PianoRoll
  fold={state.fold}
  rhythmLanes={['sn', 'hh', 'cp', 'bd', 'cm']}
  events={renderRhythmObject({
    duration: 2,
    parallel: [
      ['hh', 'hh', 'hh', 'hh', 'hh', 'hh', 'hh', 'hh'],
      ['bd', ['sn', 'bd'], 'bd', 'sn'],
    ],
  })}
/>

Mixed Lanes

If we mix rhythm and note events, the piano roll will concat the rhythm lanes below the note lanes:


Custom note lanes

Like for custom rhythm lanes, we may also want to show a specific set of notes, like a scale. Also, notes that are not in the set will be hidden! This can also be useful if we want to use microtonality.

<PianoRoll
  height={100}
  noteLanes={['C3', 'D3', 'E3', 'F3', 'G3', 'A3', 'B3', 'C4']}
  events={renderRhythmObject({
    parallel: [
      { sequential: ['Db3', 'F3', 'Ab3', 'C4'], color: 'magenta' },
      { sequential: ['C3', 'D3', 'E3', 'F3'], color: 'cyan' },
    ],
  })}
/>

Sync with Playback

To synchronize the drawing with Tone.js, it is recommended to use Tone.Draw:

export function drawCallback(callback, grain = 1 / 30) {
  if (callback) {
    return new Tone.Loop((time) => {
      Tone.Draw.schedule(() => callback(Tone.Transport.seconds), time);
    }, grain).start(0);
  }
}

Working with frequencies

For experimentation with microtonality, we can also just use numbers to play frequencies:

Next Steps

  • numbers should be sorted vertically
  • change zoom
  • add block wrap feature (like implemented in REPL)
  • lane background colors
  • labels

Felix Roos 2023