Using Sampled Instruments with Tone.js

tone.js
web audio
code

July 08, 2020

Caution: Playing the examples will load ~60MB of samples. Make sure you are on Wifi!

To get away from cheap synth sounds, let's implement instruments that use samples, with the help of Tone.js.

Rack for drums

So far, we already used samples for drums, implemented with a rack, using Tone.Players:

// rack.ts
import * as Tone from 'tone';
export function rack(samples: { [key: string]: any }, options = {}) {
  options = { volume: -12, attack: 0.05, ...options };
  let players = new Tone.Players(samples, options);

  const s = {
    customSymbols: Object.keys(samples),
    triggerAttackRelease: (key, duration, time, velocity) => {
      if (!players.has(key)) {
        console.warn(`key ${key} not found for playback`);
        return;
      }
      const player = players.get(key);
      player.start(time);
      player.stop(time + duration);
    },
    connect: (dest) => {
      players.connect(dest);
      return s;
    },
    toMaster: () => {
      players.toDestination();
      return s;
    },
  };
  return s;
}

Here we are essentially creating a triggerAttackRelease method for Players to have more control and to be able to use one unified method for all instruments later.

We can package a drum instrument like this:

// tidal.ts
import { rack } from './rack';
const tidalsounds = {
  bd: require('./bd/BT0A0D0.wav'),
  sn: require('./sn/ST0T0S3.wav'),
  hh: require('./hh/000_hh3closedhh.wav'),
  cp: require('./cp/HANDCLP0.wav'),
  mt: require('./mt/MT0D3.wav'),
  ht: require('./ht/HT0D3.wav'),
  lt: require('./lt/LT0D3.wav'),
};
export default rack(tidalsounds).toDestination();

You can download the samples from the Dirt-Samples repo.

Then we can play sounds like this:

import * as Tone from 'tone';
import drums from './tidal';
import { State } from 'react-powerplug';

const seq = new Tone.Sequence(
  (time, note, duration) => drums.triggerAttackRelease(note, 0.5, time),
  ['bd', 'hh', 'cp', 'hh', 'bd', 'hh', 'cp', 'hh'],
  '8n'
);
function toggleSeq() {
  if (seq.state !== 'started') {
    Tone.Transport.start('+0.1');
    seq.start();
  } else {
    Tone.Transport.stop();
    seq.stop();
  }
}
document.getElementById('#playButton').addEventListener('click', () => toggleSeq());
// in your dom: <button id="playButton">toggle</button>

With this approach, we can map any string to any sound, which is good for drums and non pitched sounds.

Sampler for pitched sounds

To use samples for playing back pitched sounds, we can use Tone.Sampler:

// sampler.ts

import { Distance, Interval, Note } from 'tonal';
import * as Tone from 'tone';

export function sampler(samples, options = {}) {
  options = { volume: -12, attack: 0.05, ...options };
  let sampler = new Tone.Sampler(samples, options);
  const s = {
    triggerAttackRelease: (note, duration, velocity) => {
      if (options['transpose']) {
        note = Distance.transpose(note, Interval.fromSemitones(options['transpose']));
      }
      sampler.triggerAttackRelease(Note.simplify(note), duration, velocity);
    },
    connect: (dest) => {
      sampler.connect(dest);
      return s;
    },
    toMaster: () => {
      sampler.toDestination();
      return s;
    },
  };
  return s;
}

To package an instrument, we need samples of different pitches of the instrument:

export const piano = {
  C1: require('./C1.mp3'),
  C2: require('./C2.mp3'),
  C3: require('./C3.mp3'),
  C4: require('./C4.mp3'),
  C5: require('./C5.mp3'),
  C6: require('./C6.mp3'),
  C7: require('./C7.mp3'),
};

import { sampler } from '../../../components/rhythmical/instruments/sampler';

export default sampler(piano).toDestination();

The good thing about Tone.Sampler, is that it is able to fill the gaps between non existing pitches. This allows much smaller sample payloads, for example, the above piano only has 217KB of samples. If we add a sample for each key from C1 to B7, we have a payload of 2,2MB.

You can compare the two and judge for yourself:

This time, we play it back using rhythmical + Tone.Part (as Tone.Sequence does not support durations):

const duration = 4;
const seq = (instr) => {
  const sequence = new Tone.Part(
    (time, { value, duration }) => instr.triggerAttackRelease(value, duration, time),
    renderRhythmObject({
      duration,
      sequential: [['c3', 'e3', 'g3', 'b3'], ['a3', 'g3', 'e3', 'c3'], ['d3', 'f3', 'a3', 'c4'], 'b3'],
    })
  );
  sequence.loop = true;
  sequence.loopEnd = duration;
  return sequence;
};
const cheapLoop = seq(tinypiano);
const goodLoop = seq(piano);
function toggleSeq(sequence) {
  if (sequence.state !== 'started') {
    Tone.Transport.start('+0.1');
    sequence.start();
  } else {
    Tone.Transport.stop();
    sequence.stop();
  }
}

Extracting Samples from Kontakt

If you do not have the time or equipment to record your large collection of vintage instruments at home, you can for example extract samples from Kontakt:

  1. purchase (or download a free) kontakt instrument
  2. open instrument kontakt standalone version
  3. open expert > groups
  4. select all and click download, make sure you deselect the compress option

Now you have all samples that are used by the kontakt instrument as wav on your hard drive!

Converting wav samples to mp3

If you have a large amount of wav files, it is best to compress them, as a wav file for every key can get quite large. For example, I extracted samples from a rhodes instrument which add up to over 170MB only for one sample per key (same velocity). It would be nice to have a quick way to convert all wavs to a more compressed format like mp3.

We can do that using node-lame:

const Lame = require('node-lame').Lame;
const path = require('path');
const fs = require('fs');
const folder = process.argv[2];
if (!folder) {
  console.log('no folder given!');
} else {
  //joining path of directory
  const directoryPath = path.join(__dirname, folder);
  console.log('convert ', directoryPath);
  //passsing directoryPath and callback function
  fs.readdir(directoryPath, function (err, files) {
    //handling error
    if (err) {
      return console.log('Unable to scan directory: ' + err);
    }
    files
      .filter((file) => file.split('.')[1] === 'wav')
      .forEach(function (file) {
        const encoder = new Lame({
          output: `${directoryPath}/${file.split('.')[0]}.mp3`,
          bitrate: 192,
        }).setFile(`${directoryPath}/${file.split('.')[0]}.wav`);
        encoder
          .encode()
          .then(() => console.log(`encoded ${directoryPath}/${file.split('.')[0]}.mp3`))
          .catch((error) => console.log(`error encoding ${directoryPath}/${file.split('.')[0]}.mp3: ${error}`));
      });
  });
}

This allowed me to reduce the size by 73% (from 756MB to 206MB for all articulations / from 170MB to 40MB for medium articulation)

Demo

So here are a few demos using a rhodes sound with 40MB payload:

Mr Sandman

green onions

schlechter empfang

microtonality

Sadly, Tone.Sampler will round frequencies to equal temperament, making it impossible to play microtonal music:

How it sounds:

How it should sound:

source:

<Player
  instruments={{ MK2md2 }}
  events={renderRhythmObject({
    duration: 4,
    sequential: [400, 410, 420, 430, 440, 450, 460, 470],
  })}
/>

Further ideas

  • implement multiple samples per key for different velocities
  • implement looping sections
  • write script that trims samples to a max length
  • learn KSP
  • find way to load samples only after accepting huge payload (40MB is much on mobile)
  • find ways to humanize playback e.g. velocity curves, timing variances + swing

Felix Roos 2022