Nicolò Andronio

Nicolò Andronio

Full-stack developer, computer scientist, engineer
Evil Genius in the spare time

Easily play a song track in JavaScript using Tone.js Transport

Tone.js is a very nice library for playing synthetic music in the browser. However, its documentation is a bit lacking. While writing a toy project for this article, I had to dig quite deep into Google search results to achieve a very simple functionality: play a sequence of notes defined by a list of objects, given a tempo and a time signature. The problem arose because every example on the web only ever tells you how to play a single note, or to play some notes by specifying an absolute time value in seconds for each one. However, I did not want to manually compute all those timing events. It looked way too convoluted. After a while, I discovered that Tone.js is actually able to convert any valid duration into an absolute numeric value (in seconds). That allowed me to fill in the blanks and come up with a more useful solution.

In my case, I had a sequence of events shaped like the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SequenceEvent: {
measure: {
notes: [{
type: 'rest' | 'note',
name?: string,
duration: string
}]
}
newTempo?: {
value: number,
unit: 'bpm'
},
newTimeSignature?: {
numerator: number,
denominator: number
}
}

The event can contain changes of tempo and time signature, and each event contains exactly one measure, which is basically a list of notes and rests (it was a bit more than that in my project, but I simplified it for the sake of argument). By using the Transport object, it is possible to schedule every measure indipendently and - within that measure - separately schedule every single note.

Transport is a singleton object provided by Tone.js that lets clients schedule events at a fixed time within the musical timeline. It can also be rewound, stopped, resumed or replayed. Tone.Transport.schedule accepts two arguments:

Also, Tone.Time(duration).toSeconds() can convert any time duration - even expressed in musical form, e.g. a crotchet or a quaver - into an absolute value. By combining these two APIs and summing up the total amount of time spent by notes that have already been played, it is possible to schedule each note at the right time. From there, it is quite easy to write a short snippet of code to play a sequence of events:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
class SimplePlayer {
constructor () {
this.synth = new Tone.Synth().toMaster();
}

/**
* If the given event has new tempo and/or time signatures, apply them to the Transport immediately.
* @param {SequenceEvent} event
* @param {boolean} ramp If true, tempo will ramp up/down to the given value over 1 second,
* otherwise it will change instantly.
*/

applyEventUpdates (event, ramp) {
if (event.newTempo && event.newTempo.unit === 'bpm') {
if (ramp) {
Tone.Transport.bpm.rampTo(event.newTempo.value, 1);
} else {
Tone.Transport.bpm.value = event.newTempo.value;
}
}

if (event.newTimeSignature) {
Tone.Transport.timeSignature = [
event.newTimeSignature.numerator,
event.newTimeSignature.denominator
];
}
}

/**
* Use Tone.js Transport to play a series of notes encoded by the event list passed in input,
* using the default ugly synthetic membrane sound.
* @param {SequenceEvent[]} track
*/

play (track) {
const synth = this.synth;

// We will use the Transport to schedule each measure independently. Given that we
// inform Tone.js of the current tempo and time signature, the Transport will be
// able to automatically interpret all measures and note durations as absolute
// time events in seconds without us actually bothering
let measureCounter = 0;
let firstEvent = true;

// Stop, rewind and clear all events from the transport (from previous plays)
Tone.Transport.stop();
Tone.Transport.position = 0;
Tone.Transport.cancel();

for (const event of track) {
// The first event is always supposed to have new tempo and time signature info
// so we should update the Transport appropriately
if (firstEvent) {
this.applyEventUpdates(event, false);
firstEvent = false;
}

// In the following callback, "time" represents the absolute time in seconds
// that the measure we are scheduling is expected to begin at, given the current
// tempo and time signature assigned to the Transport
Tone.Transport.schedule((time) => {
// Change the tempo if this event has a new tempo. Also do the same if a new time signatue is issued
this.applyEventUpdates(event, true);

// This contains the relative time of notes with respect to the
// start of the current measure, in seconds
let relativeTime = 0;

for (const note of event.measure.notes) {
const duration = note.duration;

// If this is an actual note (as opposed to a rest), schedule the
// corresponding sound to be played along the Transport timeline
// after the previous notes in the measure have been played (hence the relativeTime)
if (note.type === 'note') {
synth.triggerAttackRelease(note.name, note.duration, time + relativeTime);
}

// This is used to delay notes that come next by the correct amount
relativeTime += Tone.Time(duration).toSeconds();

}
}, `${measureCounter}m`);

measureCounter++;
}

Tone.Transport.start();
}
}

Notice that SimplePlayer accepts a sequence of events in input. If you don’t want to manually write all the javascript objects, here is a simple parser class that converts a list of measures into a sequence of events with a single tempo and time signature:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class SequenceParser {
constructor (tempoBpm, timeSignatureArray) {
this.initialTempo = { value: tempoBpm, unit: 'bpm' };
this.initialTimeSignature = { numerator: timeSignatureArray[0], denominator: timeSignatureArray[1] };
}

parse (textMeasures) {
const result = [];
let firstEvent = true;

for (const textMeasure of textMeasures) {
const event = { };

if (firstEvent) {
event.newTempo = this.initialTempo;
event.newTimeSignature = this.initialTimeSignature;
firstEvent = false;
}

event.measure = this.parseTextMeasure(textMeasure);
result.push(event);
}

return result;
}

parseTextMeasure (textMeasure) {
const notes = textMeasure.split(' ')
.filter(textNote => !!textNote)
.map(textNote => this.parseTextNote(textNote));

return { notes };
}

parseTextNote (textNote) {
const chunks = textNote.split('/');
const isNote = (chunks[0] !== 'rest');
return {
type: isNote ? 'note' : 'rest',
name: isNote ? chunks[0] : null,
duration: chunks[1] + 'n'
};
}
}

And then you can simply call it like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
const player = new SimplePlayer();
const sequenceParser = new SequenceParser(128, [2, 4]);
player.play(sequenceParser.parse([
'rest/4 B4/16 A4/16 G#4/16 A4/16',
'C5/8 rest/8 D5/16 C5/16 B4/16 C5/16',
'E5/8 rest/8 F5/16 E5/16 D#5/16 E5/16',
'B5/16 A5/16 G#5/16 A5/16 B5/16 A5/16 G#5/16 A5/16',
'C6/4 A5/8 C6/8',
'B5/8 A5/8 G5/8 A5/8',
'B5/8 A5/8 G5/8 A5/8',
'B5/8 A5/8 G5/8 F#5/8',
'E5/4'
]));

Click the button to try it!